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.

This is just the Swift support. The Obj-C support will come in a
separate commit.

Fixes #58.
  • Loading branch information
lilyball committed Aug 20, 2020
1 parent 5f77b44 commit 8666ffa
Show file tree
Hide file tree
Showing 10 changed files with 666 additions and 9 deletions.
6 changes: 6 additions & 0 deletions README.md
Expand Up @@ -347,6 +347,12 @@ Unless you explicitly state otherwise, any contribution intentionally submitted

## Version History

### Development

- Add `PromiseOperation` class 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
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
@@ -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
1 change: 1 addition & 0 deletions Sources/ObjC/Tomorrowland.h
Expand Up @@ -26,3 +26,4 @@ FOUNDATION_EXPORT const unsigned char TomorrowlandVersionString[];
#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
@@ -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
73 changes: 73 additions & 0 deletions Sources/Private/TWLAsyncOperation.m
@@ -0,0 +1,73 @@
//
// TWLAsyncOperation.m
// 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 "TWLAsyncOperation+Private.h"
#import <stdatomic.h>

@implementation TWLAsyncOperation {
atomic_ulong _state;
}

- (TWLAsyncOperationState)state {
return atomic_load_explicit(&_state, memory_order_relaxed);
}

- (void)setState:(TWLAsyncOperationState)state {
[self willChangeValueForKey:@"isExecuting"];
[self willChangeValueForKey:@"isFinished"];
atomic_store_explicit(&_state, state, memory_order_relaxed);
[self didChangeValueForKey:@"isFinished"];
[self didChangeValueForKey:@"isExecuting"];
}

- (void)start {
if (self.state != TWLAsyncOperationStateInitial) {
// Attempted to call -start after it's already been started.
return;
}
self.state = TWLAsyncOperationStateExecuting;
[self main];
}

- (void)main {
// This should be overridden. If it does get invoked, just mark ourselves as finished.
NSAssert(self.state == TWLAsyncOperationStateExecuting, @"-[TWLAsyncOperation main] invoked while the operation was not executing.");
self.state = TWLAsyncOperationStateFinished;
}

- (BOOL)isExecuting {
switch (self.state) {
case TWLAsyncOperationStateInitial:
case TWLAsyncOperationStateFinished:
return NO;
case TWLAsyncOperationStateExecuting:
return YES;
}
}

- (BOOL)isFinished {
switch (self.state) {
case TWLAsyncOperationStateInitial:
case TWLAsyncOperationStateExecuting:
return NO;
case TWLAsyncOperationStateFinished:
return YES;
}
}

- (BOOL)isAsynchronous {
return YES;
}

@end
148 changes: 148 additions & 0 deletions Sources/PromiseOperation.swift
@@ -0,0 +1,148 @@
//
// PromiseOperation.swift
// 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 Tomorrowland.Private

/// `StdPromiseOperation` is an alias for a `PromiseOperation` whose error type is `Swift.Error`.
public typealias StdPromiseOperation<Value> = PromiseOperation<Value,Swift.Error>

/// An `Operation` subclass that wraps ` Promise`.
///
/// `PromiseOperation` is an `Operation` 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 `.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.
public final class PromiseOperation<Value,Error>: TWLAsyncOperation {
/// The type of the promise resolver. See `Promise<Value,Error>.Resolver`.
public typealias Resolver = Promise<Value,Error>.Resolver

// Re-use DelayedPromiseBox here as it does everything we need it to
private let _box: DelayedPromiseBox<Value,Error>

/// The actual promise we return to callers.
///
/// This is a child of our internal promise. This way we can observe cancellation requests while
/// our `_box` is still in `.delayed`, and when we go out of scope the promise will get
/// cancelled if the callback was never invoked.
private let _promise: Promise<Value,Error>

/// Returns a new `PromiseOperation` that can be resolved with the given block.
///
/// The `PromiseOperation` won't execute the block until it has been started, either by adding
/// it to an `OperationQueue` or by invoking the `start()` method directly.
///
/// - Parameter context: The context to execute the handler on. If `.immediate`, the handler is
/// invoked on the thread that starts the operation; if the `start()` method is called
/// directly it's the current thread, if the operation is added to an `OperationQueue` it's
/// will be invoked on the queue.
/// - Parameter handler: A block that will be executed when the operation starts in order to
/// fulfill the promise. The operation will not be marked as finished until the promise
/// resolves, even if the handler returns before then.
/// - Parameter resolver: The `Resolver` used to resolve the promise.
public init(on context: PromiseContext, _ handler: @escaping (_ resolver: Resolver) -> Void) {
let (promise, resolver) = Promise<Value,Error>.makeWithResolver()
var seal: PromiseSeal<Value,Error>!
_box = DelayedPromiseBox(context: context, callback: { (innerResolver) in
// 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.
resolver.propagateCancellation(to: Promise(seal: seal))
// Throw away the seal now, to seal the box. We won't be using it again. This way
// cancellation will propagate if appropriate.
seal = nil
// Now we can invoke the original handler.
handler(innerResolver)
})
seal = PromiseSeal(delayedBox: _box)
_promise = promise
super.init()
// Observe the promise now in order to set our operation state.
promise.tap(on: .immediate) { [weak self] (result) in
// Regardless of the result, mark ourselves as finished.
// We can only get resolved if we've been started.
self?.__state = .finished
}
// If someone requests cancellation of the promise, treat that as asking the operation
// itself to cancel.
resolver.onRequestCancel(on: .immediate) { [weak self] (_) in
guard let self = self,
// cancel() invokes this callback; let's not invoke cancel() again.
// It should be safe to do so, but it will fire duplicate KVO notices.
!self.isCancelled
else { return }
self.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.
seal._enqueue(box: promise._box) // the propagateCancel happens in the DelayedPromiseBox callback
}

deinit {
// If we're thrown away without executing, we need to clean up.
// Our caller could still be holding onto the promise so our box won't necessarily just go
// away.
_box.emptyAndCancel()
}

/// Returns a `Promise` that asynchronously contains the value of the computation.
///
/// The `.promise` property may be accessed at any time, but the promise will not be resolved
/// until after the operation has started, either by adding it to an operation queue or by
/// invoking the `start()` method.
///
/// The same `Promise` is returned every time.
public var promise: Promise<Value,Error> {
return _promise
}

public override func cancel() {
// Call super first so `isCancelled` 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.
}

@available(*, unavailable) // disallow direct invocation through this type
public override func main() {
// Check if our promise has requested to cancel.
// We're doing this over just testing `self.isCancelled` 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 `isCancelled`, which leaves a race where the promise is cancelled but the
// operation is not. If we were checking `isCancelled` 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 == .cancelling {
_box.emptyAndCancel()
} else {
_box.execute()
}
}
}
1 change: 1 addition & 0 deletions Sources/tomorrowland.modulemap
Expand Up @@ -8,5 +8,6 @@ explicit module Tomorrowland.Private {
header "TWLOneshotBlock.h"
header "TWLThreadLocal.h"
header "TWLBlockOperation.h"
header "TWLAsyncOperation+Private.h"
export *
}

0 comments on commit 8666ffa

Please sign in to comment.