Skip to content

Commit

Permalink
Add PromiseOperation class
Browse files Browse the repository at this point in the history
This is an `Operation` subclass that wraps a `Promise`, including
deferred execution of the handler that resolves the promise.

Fixes #58.
  • Loading branch information
lilyball committed Aug 20, 2020
1 parent 5f77b44 commit 5c859e9
Show file tree
Hide file tree
Showing 17 changed files with 1,289 additions and 25 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,14 @@ This function has an optional parameter `cancelRemaining:` that, if provided as

`Promise.delay(on:_:)` is a method that returns a new promise that adopts the same result as the receiver after the specified delay. It is intended primarily for testing purposes.

### `PromiseOperation`

`PromiseOperation` is an `Operation` subclass that wraps a `Promise` and allows for delayed execution of the promise handler. It's created just like `Promise`, with `init(on:_:)`, but it doesn't run the handler until the operation is started (either by calling `start()` or by adding it to an `OperationQueue`). The operation has a `.promise` property that returns a `Promise` that will resolve to the results of the computation, but can be accessed before the handler is invoked. If the operation is put on a queue and is initialized with the `.immediate` context, the provided handler will run on the queue.

Requesting cancellation of the `PromiseOperation.promise` is identical to calling `PromiseOperation.cancel()`. If the operation has already started, cancellation support is at the discretion of the provided handler, just like with a normal `Promise`. If the operation has not yet started, cancelling it will prevent the handler from ever executing, though the returned promise itself won't cancel until the operation has moved to the `isFinished` state (e.g. by being started).

The use of `PromiseOperation` instead of a `Promise` allows for delaying execution of the promise, setting up dependencies, controlling concurrency with the operation queue's `maxConcurrentOperationCount`, and generally integrating with existing operation queues.

### Objective-C

Tomorrowland has Obj-C compatibility in the form of `TWLPromise<ValueType,ErrorType>`. This is a parallel promise implementation that can be bridged to/from `Promise` and supports all of the same functionality. Note that some of the method names are different (due to lack of overloading), and while `TWLPromise` is generic over its types, the return values of callback registration methods that return new promises are not parameterized (due to inability to have generic methods).
Expand All @@ -347,6 +355,12 @@ Unless you explicitly state otherwise, any contribution intentionally submitted

## Version History

### Development

- Add `PromiseOperation` class (`TWLPromiseOperation` in Obj-C) that integrates promises with `OperationQueue`s. It can also be used similarly to `DelayedPromise` if you simply want more control over when the promise handler actually executes. `PromiseOperation` is useful if you want to be able to set up dependencies between promises or control concurrent execution counts ([#58][]).

[#58]: https://github.com/lilyball/Tomorrowland/issues/58 "Add Operation subclass that works with Promise"

### v1.4.0

- Fix the cancellation propagation behavior of `Promise.Resolver.resolve(with:)` and the `flatMap` family of methods. Previously, requesting cancellation of the promise associated with the resolver (for `resolve(with:)`, or the returned promise for the `flatMap` family) would immediately request cancellation of the upstream promise even if the upstream promise had other children. The new behavior fixes this such that it participates in automatic cancellation propagation just like any other child promise ([#54][]).
Expand Down
29 changes: 20 additions & 9 deletions Sources/DelayedPromise.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,26 @@ internal class DelayedPromiseBox<T,E>: PromiseBox<T,E> {
}

func toPromise(with seal: PromiseSeal<T,E>) -> Promise<T,E> {
let promise = Promise<T,E>(seal: seal)
if transitionState(to: .empty) {
let resolver = Promise<T,E>.Resolver(box: self)
let (context, callback) = _promiseInfo.unsafelyUnwrapped
_promiseInfo = nil
context.execute(isSynchronous: false) {
callback(resolver)
}
execute()
return Promise<T,E>(seal: seal)
}

/// If the box is `.delayed`, transitions to `.empty` and then executes the callback.
func execute() {
guard transitionState(to: .empty) else { return }
let resolver = Promise<T,E>.Resolver(box: self)
let (context, callback) = _promiseInfo.unsafelyUnwrapped
_promiseInfo = nil
context.execute(isSynchronous: false) {
callback(resolver)
}
return promise
}

/// If the box is `.delayed`, transitions to `.empty` without executing the callback, and then
/// cancels the box.
func emptyAndCancel() {
guard transitionState(to: .empty) else { return }
_promiseInfo = nil
resolveOrCancel(with: .cancelled)
}
}
22 changes: 22 additions & 0 deletions Sources/ObjC/TWLAsyncOperation.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// TWLAsyncOperation.h
// Tomorrowland
//
// Created by Lily Ballard on 8/18/20.
// Copyright © 2020 Lily Ballard. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.
//

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface TWLAsyncOperation : NSOperation
@end

NS_ASSUME_NONNULL_END
3 changes: 2 additions & 1 deletion Sources/ObjC/TWLDelayedPromise.m
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ - (instancetype)initOnContext:(TWLContext *)context handler:(void (^)(TWLResolve
_context = context;
_callback = [handler copy];
_promise = [[TWLPromise alloc] initDelayed];
} return self;
}
return self;
}

- (TWLPromise *)promise {
Expand Down
2 changes: 0 additions & 2 deletions Sources/ObjC/TWLPromise.mm
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ @interface TWLObjCPromiseBox<ValueType,ErrorType> () {
id _Nullable _value;
id _Nullable _error;
}
- (void)resolveOrCancelWithValue:(nullable ValueType)value error:(nullable ErrorType)error;
- (void)requestCancel;
- (void)seal;
@end

@interface TWLThreadDictionaryKey : NSObject <NSCopying>
Expand Down
52 changes: 52 additions & 0 deletions Sources/ObjC/TWLPromiseOperation.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// TWLPromiseOperation.h
// Tomorrowland
//
// Created by Lily Ballard on 8/19/20.
// Copyright © 2020 Lily Ballard. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.
//

#import <Foundation/Foundation.h>
#import <Tomorrowland/TWLAsyncOperation.h>

@class TWLContext;
@class TWLPromise<ValueType,ErrorType>;
@class TWLResolver<ValueType,ErrorType>;

NS_ASSUME_NONNULL_BEGIN

/// An \c NSOperation subclass that wraps a promise.
///
/// \c TWLPromiseOperation is an \c NSOperation subclass that wraps a promise. It doesn't invoke its
/// callback until the operation has been started, and the operation is marked as finished when the
/// promise is resolved.
///
/// The associated promise can be retrieved at any time with the \c .promise property, even before
/// the operation has started. Requesting cancellation of the promise will cancel the operation, but
/// if the operation has already started it's up to the provided handler to handle the cancellation
/// request.
///
/// \note Cancelling the operation or the associated promise before the operation has started will
/// always cancel the promise without executing the provided handler, regardless of whether the
/// handler itself supports cancellation.
NS_SWIFT_NAME(ObjCPromiseOperation)
@interface TWLPromiseOperation<__covariant ValueType, __covariant ErrorType> : TWLAsyncOperation

@property (atomic, readonly) TWLPromise<ValueType,ErrorType> *promise;

+ (instancetype)newOnContext:(TWLContext *)context handler:(void (^)(TWLResolver<ValueType,ErrorType> *resolver))handler NS_SWIFT_UNAVAILABLE("use init(on:_:)");
- (instancetype)initOnContext:(TWLContext *)context handler:(void (^)(TWLResolver<ValueType,ErrorType> *resolver))handler NS_SWIFT_NAME(init(on:_:)) NS_DESIGNATED_INITIALIZER;

+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (void)main NS_UNAVAILABLE;

@end

NS_ASSUME_NONNULL_END
153 changes: 153 additions & 0 deletions Sources/ObjC/TWLPromiseOperation.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//
// TWLPromiseOperation.m
// Tomorrowland
//
// Created by Lily Ballard on 8/19/20.
// Copyright © 2020 Lily Ballard. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.
//

#import "TWLPromiseOperation.h"
#import "TWLAsyncOperation+Private.h"
#import "TWLPromisePrivate.h"
#import "TWLPromiseBox.h"
#import "TWLContextPrivate.h"

@implementation TWLPromiseOperation {
TWLContext * _Nullable _context;
void (^ _Nullable _callback)(TWLResolver * _Nonnull);

/// The box for our internal promise.
TWLObjCPromiseBox * _Nonnull _box;

/// The actual promise we return to our callers.
///
/// This is a child of our internal promise. This way we can observe cancellation requests while
/// our box is still in the delayed state, and when we go out of scope the promise will get
/// cancelled if the callback was never invoked.
TWLPromise * _Nonnull _promise;
}

+ (instancetype)newOnContext:(TWLContext *)context handler:(void (^)(TWLResolver<id,id> * _Nonnull))handler {
return [[self alloc] initOnContext:context handler:handler];
}

- (instancetype)initOnContext:(TWLContext *)context handler:(void (^)(TWLResolver<id,id> * _Nonnull))handler {
if ((self = [super init])) {
_context = context;
TWLResolver *childResolver;
TWLPromise *childPromise = [[TWLPromise alloc] initWithResolver:&childResolver];
_promise = childPromise;
TWLPromise *promise = [[TWLPromise alloc] initDelayed];
_box = promise->_box;
_callback = ^(TWLResolver * _Nonnull resolver) {
// We piped data from the inner promise to the outer promise at the end of -init
// already, but we need to propagate cancellation the other way. We're deferring that
// until now because cancelling a box in the delayed state is ignored. By waiting until
// now, we ensure that the box is in the empty state instead and therefore will accept
// cancellation. We're still running the handler, but this way the handler can check for
// cancellation requests.
__weak TWLObjCPromiseBox *box = promise->_box;
[childResolver whenCancelRequestedOnContext:TWLContext.immediate handler:^(TWLResolver * _Nonnull resolver) {
[box propagateCancel];
}];
// Seal the inner promise box now. This way cancellation will propagate if appropriate.
// Note: We can't just nil out the promise unless we want to add autorelease pools.
[promise->_box seal];
// Now we can invoke the original handler.
handler(resolver);
};

// Observe the promise now in order to set our operation state
__weak typeof(self) weakSelf = self;
[promise tapOnContext:TWLContext.immediate handler:^(id _Nullable value, id _Nullable error) {
// Regardless of the result, mark ourselves as finished.
// We can only get resolved if we've been started.
weakSelf.state = TWLAsyncOperationStateFinished;
}];
// If someone requests cancellation of the promise, treat that as asking the operation
// itself to cancel.
[childResolver whenCancelRequestedOnContext:TWLContext.immediate handler:^(TWLResolver * _Nonnull resolver) {
typeof(self) this = weakSelf;
if (this
// -cancel invokes this callback; let's not invoke -cancel again.
// It should be safe to do so, but it will fire duplicate KVO notices.
&& !this.cancelled)
{
[this cancel];
}
}];
// Pipe data from the delayed box to our child promise now. This way if we never actually
// execute the callback, we'll get informed of cancellation.
[promise enqueueCallbackWithBox:childPromise->_box willPropagateCancel:YES]; // the propagateCancel happens in the callback
}
return self;
}

- (void)dealloc {
// If we're thrown away without executing, we need to clean up.
// Since the box is in the delayed state, it won't just cancel automatically.
[self emptyAndCancel];
}

// We could probably synthesize this, but it's a const ivar past initialization, so we don't need
// the synthesized lock.
- (TWLPromise *)promise {
return _promise;
}

- (void)cancel {
// Call super first so .cancelled is true.
[super cancel];
// Now request cancellation of the promise.
[_promise requestCancel];
// This does mean a KVO observer of the "isCancelled" key can act on the change prior to our
// promise being requested to cancel, but that should be meaningless; this is only even
// externally observable if the KVO observer has access to the promise's resolver.
}

- (void)main {
// Check if our promise has requested to cancel.
// We're doing this over just testing self.cancelled to handle the super edge case where one
// thread requests the promise to cancel at the same time as another thread starts the
// operation. Requesting our promise to cancel places it in the cancelled state prior to setting
// self.cancelled, which leaves a race where the promise is cancelled but the operation is not.
// If we were checking self.cancelled we could get into a situation where the handler executes
// and cannot tell that it was asked to cancel.
// The opposite is safe, if we cancel the operation and the operation starts before the promise
// is marked as cancelled, the cancellation will eventually be exposed to the handler, so it can
// take action accordingly.
if (_promise->_box.unfencedState == TWLPromiseBoxStateCancelling) {
[self emptyAndCancel];
} else {
[self execute];
}
}

- (void)execute {
if ([_box transitionStateTo:TWLPromiseBoxStateEmpty]) {
TWLResolver *resolver = [[TWLResolver alloc] initWithBox:_box];
void (^callback)(TWLResolver *) = _callback;
TWLContext *context = _context;
_context = nil;
_callback = nil;
[context executeIsSynchronous:NO block:^{
callback(resolver);
}];
}
}

- (void)emptyAndCancel {
if ([_box transitionStateTo:TWLPromiseBoxStateEmpty]) {
_context = nil;
_callback = nil;
[_box resolveOrCancelWithValue:nil error:nil];
}
}

@end
2 changes: 2 additions & 0 deletions Sources/ObjC/TWLPromisePrivate.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ NS_ASSUME_NONNULL_BEGIN
@property (atomic, readonly) std::tuple<BOOL,ValueType _Nullable,ErrorType _Nullable> result;
#endif
- (BOOL)getValue:(ValueType __strong _Nullable * _Nullable)outValue error:(ErrorType __strong _Nullable * _Nullable)outError;
- (void)resolveOrCancelWithValue:(nullable ValueType)value error:(nullable ErrorType)error;
- (void)propagateCancel;
- (void)seal;

@end

Expand Down
2 changes: 2 additions & 0 deletions Sources/ObjC/Tomorrowland.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ FOUNDATION_EXPORT const unsigned char TomorrowlandVersionString[];
#import <Tomorrowland/TWLPromise.h>
#import <Tomorrowland/TWLPromise+Convenience.h>
#import <Tomorrowland/TWLContext.h>
#import <Tomorrowland/TWLPromiseOperation.h>
#import <Tomorrowland/TWLDelayedPromise.h>
#import <Tomorrowland/TWLWhen.h>
#import <Tomorrowland/TWLUtilities.h>
#import <Tomorrowland/TWLAsyncOperation.h>
55 changes: 55 additions & 0 deletions Sources/Private/TWLAsyncOperation+Private.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// TWLAsyncOperation+Private.h
// Tomorrowland
//
// Created by Lily Ballard on 8/18/20.
// Copyright © 2020 Lily Ballard. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.
//

#import <Foundation/Foundation.h>
#import "TWLAsyncOperation.h"

NS_ASSUME_NONNULL_BEGIN

typedef NS_ENUM(NSUInteger, TWLAsyncOperationState) {
TWLAsyncOperationStateInitial = 0,
TWLAsyncOperationStateExecuting,
TWLAsyncOperationStateFinished,
};

/// An operation class to subclass for writing asynchronous operations.
///
/// This operation clss is marked as asynchronous by default and maintains an atomic \c state
/// property that is used to send the appropriate KVO notifications.
///
/// Subclasses should override \c -main which will be called automatically by \c -start when the
/// operation is ready. When the \c -main method is complete it must set \c state to
/// \c TWLAsyncOperationStateFinished. It must also check for cancellation and handle this
/// appropriately. When the \c -main method is executed the \c state will already be set to
/// \c TWLAsyncOperationStateExecuting.
@interface TWLAsyncOperation ()

/// The state property that controls the \c isExecuting and \c isFinished properties.
///
/// Setting this automatically sends the KVO notices for those other properties.
///
/// \note This property uses relaxed memory ordering. If the operation writes state that must be
/// visible to observers from other threads it needs to manage the synchronization itself.
@property (atomic) TWLAsyncOperationState state __attribute__((swift_private));

// Do not override this method.
- (void)start;

// Override this method. When the operation is complete, set \c state to
// \c TWLAsyncOperationStateFinished. Do not call \c super.
- (void)main;

@end

NS_ASSUME_NONNULL_END
Loading

0 comments on commit 5c859e9

Please sign in to comment.