Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

'Cancel' for PromiseKit #899

Merged
merged 22 commits into from
Mar 19, 2019
Merged

'Cancel' for PromiseKit #899

merged 22 commits into from
Mar 19, 2019

Conversation

dougzilla32
Copy link
Collaborator

@dougzilla32 dougzilla32 commented Jul 19, 2018

These are the diffs for option 1 of Proposal for PromiseKit cancellation support #896. With option 1 the new cancellation code is included with CorePromise.

The repositories involved with the pull request for option 1 are:

Repositories
mxcl/PromiseKit
PromiseKit/Alamofire-
PromiseKit/Bolts
PromiseKit/CoreLocation
PromiseKit/Foundation
PromiseKit/HealthKit
PromiseKit/HomeKit
PromiseKit/MapKit
PromiseKit/StoreKit
PromiseKit/SystemConfiguration
PromiseKit/UIKit

}

class CancelItem: Hashable {
lazy var hashValue: Int = {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lazy is not thread-safe.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, great catch! The CancelContext instance's hashValue could indeed be accessed by multiple threads at the same time.

I fixed CancelContext by making a Hashable wrapper class, where the wrapper instance will only be accessed by a single thread. Other threads make their own wrapper instances so this solves the thread safety issue with CancelContext.

CancelItem also has a lazy hashValue, but all dictionary operations involving CancelItem are protected by the barrier lock.

Admittedly this is a slightly convoluted. But I want to use ObjectIdentifier.hashValue for the CancelContext and CancelItem hash values, which then forces you to use lazy.

I'm very open to ideas for simplifying this, lmk if you think of anything!!

@mxcl
Copy link
Owner

mxcl commented Aug 6, 2018

Should Guarantees be cancellable? My immediate reaction is: no. It’s not much of a guarantee if it may be canceled.

@dougzilla32
Copy link
Collaborator Author

Agreed, I like your idea to wrap the Guarantee with cancelable if you want the ability to cancel/abandon the guarantee (after is a great example).

@dougzilla32
Copy link
Collaborator Author

I've deleted CancellableGuarantee and put in a 'cancellable' global function that takes a Promise/Guarantee. This enabled me to delete a bunch of 'CC' functions.

In the extensions I renamed methods with the 'CC' suffix to use a 'cancellable' prefix instead.

A few questions --

  • The cancellable function bridges to the underlying Promise/Guarantee (Thenable) as follows. I'm not entirely sure this is the best way to do it, as the underlying Promise will not be rejected. Only the wrapper Promise is rejected. Can this be improved?
    /// Initialize a new cancellable promise bound to the provided `Thenable`.
    public convenience init<U: Thenable>(task: CancellableTask? = nil, _ bridge: U) where U.T == T {
        var reject: ((Error) -> Void)!
        self.init(Promise { seal in
            reject = seal.reject
            bridge.done(on: nil) {
                seal.fulfill($0)
            }.catch {
                seal.reject($0)
            }
        })
        self.appendCancellableTask(task: task, reject: reject)
    }
  • I could change all the cancellable extensions to use the cancellable global function instead of defining cancellable variants. For example: cancellable(Alamofire.request("http://example.com", method: .get).responseJSON()) rather than Alamofire.request("http://example.com", method: .get).cancellableResponseJSON(). This would require adding a property with type CancellableTask to Promise/Guarantee to keep track of the task to cancel. Comments?
  • How many Ls should be in cancelable/cancellable? I have it as two Ls right now, matching the existing PromiseKit code. If we decide on one L then a few existing properties and the CancellableError protocol would need to be marked as deprecated.

@cs4alhaider
Copy link

cs4alhaider commented Sep 7, 2018

When finished try to squash all of the commits .. git squash

@codecov
Copy link

codecov bot commented Sep 12, 2018

Codecov Report

Merging #899 into v7 will increase coverage by 5.2%.
The diff coverage is 83.9%.

Impacted file tree graph

@@           Coverage Diff           @@
##              v7    #899     +/-   ##
=======================================
+ Coverage   87.3%   92.5%   +5.2%     
=======================================
  Files         15      32     +17     
  Lines        793    1760    +967     
=======================================
+ Hits         692    1627    +935     
- Misses       101     133     +32
Impacted Files Coverage Δ
Sources/Guarantee.swift 93.2% <100%> (+0.2%) ⬆️
Sources/when.swift 100% <100%> (+0.9%) ⬆️
Sources/race.swift 100% <100%> (ø) ⬆️
Sources/after.swift 97% <100%> (-3%) ⬇️
Sources/cancellable.swift 100% <100%> (ø)
Sources/hang.swift 91% <100%> (+0.6%) ⬆️
Sources/firstly.swift 100% <100%> (+22.3%) ⬆️
Sources/Promise.swift 93.6% <66.7%> (-0.6%) ⬇️
Sources/CancellableThenable.swift 67% <67%> (ø)
Sources/CancellablePromise.swift 78.5% <78.5%> (ø)
... and 33 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 0070f3c...21a4ccf. Read the comment docs.

@mxcl
Copy link
Owner

mxcl commented Sep 25, 2018

When finished try to squash all of the commits .. git squash

Well we can do that from GitHub nowadays. So don't worry about it.

@dougzilla32
Copy link
Collaborator Author

Ok cool -- I've been squashing, but after merging from the master repo and then some further changes on my part the commits got interleaved between mine and others.

@dougzilla32
Copy link
Collaborator Author

Ok! This is ready for another review. The file diffs were temporarily messed up because I pulled in the latest changes from master using git merge instead of git rebase (oops). I was able to fix it after reading up on git rebase -- quite interesting!

The following are some questions, concerns, and feedback that has been incorporated.

Holding a reference to a CancellableTask inside Promise/Guarantee

Of particular interest to review are the changes I made to Promise.swift and Guarantee.swift. I am stashing away the CancellableTask (if there is one) in the Promise/Guarantee, so that the 'cancellable' function can later figure out what needs to be cancelled.

https://github.com/mxcl/PromiseKit/pull/899/files#diff-b9553d21255e0e41a9f3dfe3fd942d94
https://github.com/mxcl/PromiseKit/pull/899/files#diff-17cb84b70ac8d8ad79f2fa8942787098

The CancellableTask is always stored regardless of whether 'cancellable' is later used. The concern is if holding a reference to the CancellableTask inside the Promise/Guarantee will cause any problems. I'd rather not hold on to this reference, but I couldn't think of another way to accomplish this.

My reasoning is that the 'cancellable' function should always provide the ability to properly cancel the Promise or Guarantee passed to it. For example, cancelling a Promise from URLSession should always cancel the URLSessionTask in addition to rejecting the Promise with a CancellableError. You cannot prevent passing a given Promise or Guarantee to 'cancellable', therefore it must always work properly.

Cancellable 'wrappers' for all Promises that have an underlying CancellableTask

I went ahead and created 'wrapper' methods for all Promises that have an underlying CancellableTask. For example, for the URLSession extension the following are equivalent:

Using the 'wrapper' method:

URLSession.shared.cancellableDataTask(.promise, with: rq)

Using the cancellable function without the 'wrapper':

cancellable(URLSession.shared.dataTask(.promise, with: rq))

https://github.com/PromiseKit/Foundation/pull/9/files#diff-dc78425f8ca5fd7c8de9bec6171994c4

The question is, should we provide these 'wrapper' methods? They are only useful for indicating which
Promises have an underlying CancellableTask. Otherwise they are unnecessary.

'CC' methods and functions are gone

I have remove all 'CC' methods and functions, and rely on the global cancellable function instead

lazy is gone

I figured out how to get rid of all usages of lazy in CancelContext, because as Max pointed out lazy is not thread safe.

How many Ls should be in cancelable/cancellable and canceled/cancelled?

I have it as two Ls right now, matching the existing PromiseKit code. If we decide on one L then a few existing properties with two Ls and the CancellableError protocol would need to be marked as deprecated.

'Option 2' pull request is gone

I dropped the 'Option 2' pull request as we are not going in that direction.

Remaining ToDo

I am still updating the docs and also adding some tests so that it passes code coverage.

@dougzilla32
Copy link
Collaborator Author

Just noticed that Package@swift-4.2.swift is included in 'Files changed', but shouldn't be there. Everything else is correct.

@mxcl mxcl force-pushed the master branch 5 times, most recently from 0e105ba to 4ff9962 Compare October 2, 2018 21:08
@mxcl
Copy link
Owner

mxcl commented Oct 4, 2018

K, this is way overdue, I have created a v7 branch, can we alter this PR to merge into that?

@dougzilla32
Copy link
Collaborator Author

I've switched to the v7 branch. Let's merge in Garth Snyder's dispatch queue stuff first, then I'll merge his changes into my pull request (rebase!). I'll need to change the cancellable method signatures to match his new way of doing dispatch queues. I will deal with all this so the merge for you guys should be easy peasy.

After merging the cancel stuff into the v7 branch, I will change all the cancellable extension Cartfiles to point at the v7 branch for CI testing. And when v7 gets merged into master, I'll change them again to point at PromiseKit 7. We can merge each extension when it is passing CI against PMK 7.

All assuming cancellables get the nod for inclusion in PromiseKit 7 -- please let me know if you have any further feedback!

@dougzilla32
Copy link
Collaborator Author

The docs are pretty much complete and are ready for review!

Looks like the Travis and Codecov are not running for pull requests on the v7 branch. Can CI be turned on for v7? Both Travis and Codecov should pass now.

Travis is green for all the cancellable extensions!

@dougzilla32
Copy link
Collaborator Author

My remaining questions and concerns are:

Holding a reference to a CancellableTask inside Promise/Guarantee

Of particular interest to review are the changes I made to Promise.swift and Guarantee.swift. I am stashing away the CancellableTask (if there is one) in the Promise/Guarantee, so that the 'cancellable' function can later figure out what needs to be cancelled.

https://github.com/mxcl/PromiseKit/pull/899/files#diff-b9553d21255e0e41a9f3dfe3fd942d94
https://github.com/mxcl/PromiseKit/pull/899/files#diff-17cb84b70ac8d8ad79f2fa8942787098

The CancellableTask is always stored regardless of whether 'cancellable' is later used. The concern is that holding a reference to the CancellableTask inside the Promise/Guarantee will cause some kind of problem. I'd rather not hold on to this reference, but I couldn't think of another way to accomplish this.

My reasoning is that the 'cancellable' function should always provide the ability to properly cancel the Promise or Guarantee passed to it. For example, cancelling a Promise from URLSession should always cancel the URLSessionTask in addition to rejecting the Promise with a CancellableError. You cannot prevent passing a given Promise or Guarantee to 'cancellable', therefore it must always work properly.

Cancellable 'wrappers' for all Promises that have an underlying CancellableTask

I went ahead and created 'wrapper' methods for all Promises that have an underlying CancellableTask. For example, for the URLSession extension the following are equivalent:

Using the 'wrapper' method:

URLSession.shared.cancellableDataTask(.promise, with: rq)

Using the cancellable function without the 'wrapper':

cancellable(URLSession.shared.dataTask(.promise, with: rq))

https://github.com/PromiseKit/Foundation/pull/9/files#diff-dc78425f8ca5fd7c8de9bec6171994c4

The question is, should we provide these 'wrapper' methods? They are only useful for indicating which
Promises have an underlying CancellableTask. Otherwise they are unnecessary.

How many Ls should be in cancelable/cancellable and canceled/cancelled?

I have it as two Ls right now, matching the existing PromiseKit code. If we decide on one L then a few existing properties with two Ls and the CancellableError protocol would need to be marked as deprecated.

@mxcl mxcl mentioned this pull request Oct 10, 2018
@dougzilla32
Copy link
Collaborator Author

I've removed the cancellable wrappers. After thinking about it, it is confusing to have two ways to do the same thing. Plus it adds maintenance work for the extensions.

My remaining questions are:

  • Holding a reference to a CancellableTask inside Promise/Guarantee <= needs review
  • How many L's in cancellable/cancelable <= currently using two L's

Sorry, the git log for the pull request is a bit of a mess. Will try to fix this up when integrating the dispatch queue changes.

The file diffs are good so it is all ready for review.

@LarsStegman
Copy link
Contributor

Is this PR still active? If so, is it almost ready?

@mxcl
Copy link
Owner

mxcl commented Dec 13, 2018

It’s waiting on me. And will be a new major version of PromiseKit

@mxcl
Copy link
Owner

mxcl commented Jan 2, 2019

I think we can approach this by merging here and perhaps in PromiseKit/Foundation (v4) initially. Thoughts?

@dougzilla32
Copy link
Collaborator Author

Agreed that the level of duplication and intrusiveness is unfortunate. I'm happy to pare down in any areas that make sense and also a 'grand refactor' could help a lot!!

I'm glad this can be of use and no worries about timing!

I'll attempt to address all your points and am planning to add some tests to improve code coverage.

@dougzilla32
Copy link
Collaborator Author

Feedback is incorporated and code coverage is improved.

One outstanding item: what name should be used for the 'cancellable' function?

I'm done pending further feedback!

@mxcl mxcl force-pushed the v7 branch 5 times, most recently from c06a298 to 59553d6 Compare March 1, 2019 05:21
@tpoche
Copy link

tpoche commented Mar 11, 2019

My team is considering incorporating these cancel features into one or more of our apps. Is there anyway we can pull this version into our app using Cocoapods to start playing around with it and giving you guys some feedback?

@mxcl
Copy link
Owner

mxcl commented Mar 11, 2019

My team is considering incorporating these cancel features into one or more of our apps. Is there anyway we can pull this version into our app using Cocoapods to start playing around with it and giving you guys some feedback?

That would be useful thanks! You can specify branches for a pod spec, though currently this is not merging. Though that shouldn't matter since you can specify @dougzilla32’s fork and branch.

@mxcl
Copy link
Owner

mxcl commented Mar 11, 2019

One outstanding item: what name should be used for the 'cancellable' function?

cancellize? It's not great, but I haven't come up with anything better yet.

We'll alpha v7 after merging and I'll test it out in all my apps, so we can figure out changes to names etc. then.

@dougzilla32
Copy link
Collaborator Author

I merged in the latest 'v7' changes and renamed the 'cancellable' function to 'cancellize'.

@GarthSnyder
Copy link
Collaborator

I took a look at Doug's clone just to see how much the V7 Dispatcher stuff was muddying the waters for this kind of change. I didn't run any code, but the integration looks correct and complete. Nice work!

It's a shame to have to write these "backward compatibility" wrappers for new code, but it seems to be the only plausible way to continue to support nice forms like on: .main. So thanks for slogging through the boilerplate.

An unrelated comment: as someone new to the cancellation API, it seems a bit strange to me that cancellize() is a free function that takes a Thenable parameter rather than just being a method on Thenable. The rest of the PromiseKit API is largely chaining-oriented. Is cancellization represented this way to intentionally mark it as something extra and separate from the normal API, or...?

I wonder about cancellize, too. It's snappy, but since it's a coinage, you can't really intuit the meaning, although you can probably make a good guess. To the extent that it does have an English meaning, it's kind of misleading. If someone said they were going to "cancellize my auto insurance", you'd probably assume it was a fancy way of saying they were cancelling it.

Maybe it's worth biting the bullet and going with something clunky but clear like makeCancellable(). I thought it might be a bit nonstandard, but it turns out that make is in fact the Swift API design guide's recommendation for factory methods (e.g., makeIterator()).

Why is the user-facing cancellation protocol CancellableTask and not just Cancellable?

@dougzilla32
Copy link
Collaborator Author

dougzilla32 commented Mar 14, 2019

Thanks!! I tried to be thorough thinking we can easily delete boilerplate/wrappers later if they aren't really needed. Should the backwards compatible wrappers be marked as deprecated? I don't know enough about the trade-off to have an opinion here, just lmk what makes the most sense and I can update the code.

Great point about CancellableTask vs Cancellable. As far as I know it can simply be Cancellable. I'll go ahead and rename it unless there are objections.

For cancellize vs cancellable I don't have a strong opinion. But makeCancellable seems a bit long to me for how often it needs to appear in a chain of promises.

If cancellation were a method on Thenable I believe it would look something like:

cancellizeFirstly {
    Promise.value(1)
}.then { x in
    Promise.value(2 + x)
}.then { x in
    Promise.value(3 + x)
}

firstly {
    Promise.value(1)  // <-- not cancellable
}.cancellizeThen { x in
    Promise.value(2 + x)
}.then { x in
    Promise.value(3 + x)
}

or

cancellizeFirstly {
    Promise.value(1)
}.cancellizeThen { x in
    Promise.value(2 + x)
}.cancellizeThen { x in
    Promise.value(3 + x)
}

firstly {
    Promise.value(1)  // <-- not cancellable
}.cancellizeThen { x in
    Promise.value(2 + x)
}.cancellizeThen { x in
    Promise.value(3 + x)
}

vs how it currently is:

firstly {
    cancellize(Promise.value(1))
).then { x in
    cancellize(Promise.value(2 + x))
).then { x in
    cancellize(Promise.value(3 + x))
}

firstly {
    Promise.value(1)  // <-- not cancellable
).then { x in
    cancellize(Promise.value(2 + x))
).then { x in
    cancellize(Promise.value(3 + x))
}

or

// Could be changed to something like this
firstly {
    cancellize(Promise.value(1))
).then { x in
    Promise.value(2 + x)
).then { x in
    Promise.value(3 + x)
}

firstly {
    Promise.value(1)  // <-- not cancellable
).then { x in
    cancellize(Promise.value(2 + x))
).then { x in
    Promise.value(3 + x)
}

I don't have a strong opinion on this one.

Thank you for taking the time to give this insightful feedback!!

@GarthSnyder
Copy link
Collaborator

I tried to be thorough thinking we can easily delete boilerplate/wrappers later if they aren't really needed. Should the backwards compatible wrappers be marked as deprecated?

What you have looks just right as it is. I can do a more detailed pass through once this is integrated, but it's probably not going to turn up much.

I put "backward compatibility" in quotes because the wrappers are really just there to allow the existing API to continue forward into the future -- not (only) because that API is already being used, but because it's clearer and more concise than a Dispatcher-only API would be. Dispatcher is a protocol, and protocols can't have static members, so there's no way to allow on: .main or on: .global() if the argument is typed as a Dispatcher. You could spell out DispatchQueue.main, but that's not nearly as nice. That's the motivation for having DispatchQueue-typed wrappers even for new API.

Regarding cancellize, I was really just wondering why it's

cancellize(Promise.value(2 + x))

rather than

Promise.value(2 + x).cancellize()

Of course, the distinction is largely stylistic. But aside from not arousing the attention of the Free Function Police, a method on Thenable would allow you use cancellize smoothly as a regular chain element, e.g.

Promise.value(2 + x).cancellize().then { ... }

Is it wrong of me to think of cancellization this way?

I guess I'm thinking more broadly that if head-to-toe-cancellable chains are the recommended happy path, shouldn't CancellablePromise's then method automatically wrap the returned promise if it's not already cancellable? In other words, instead of

firstly {
    cancellize(Promise.value(1))
).then { x in
    cancellize(Promise.value(2 + x))
).then { x in
    cancellize(Promise.value(3 + x))
}

as shown above, why not

firstly {
    Promise.value(1)
).cancellize().then { x in
    Promise.value(2 + x)
).then { x in
    Promise.value(3 + x)
}

@dougzilla32
Copy link
Collaborator Author

Garth, your suggestion is excellent!!

Just as you suggested I tried moving the global function cancellize to a method on Thenable. At first I was just going to see if the compiler is happy with it and see if any other issues cropped up, and then report back here. But after making the code changes, in my opinion it looks and works decidedly better than the global function so I went ahead and pushed it!

It now works exactly as you suggest above. As a bonus I eliminated all the cancellableThen, cancellableRecover, etc. methods. Previously I was having ambiguity problems with compiler but it seems the Swift 5 compiler has addressed these.

Max, if there's a good reason to have it as a global function I can back it out. Just lmk.

@mxcl
Copy link
Owner

mxcl commented Mar 19, 2019

I'm going to merge so this doesn’t get out of sync again, which is painful for @dougzilla32

Sorry it took so long, I wanted to do a thorough review, but it's a big patch and I couldn't get my head around it properly in a PR. So let’s try it out in real code and see what we think.

Thanks everyone!

@mxcl mxcl merged commit f785e0e into mxcl:v7 Mar 19, 2019
@dougzilla32
Copy link
Collaborator Author

Wow, awesome!!

@GarthSnyder
Copy link
Collaborator

Great! I'm glad the comments were helpful. I'm looking forward to trying this out!

mxcl pushed a commit that referenced this pull request Mar 26, 2019
This patch adds the concept of a `CancelContext` to PromiseKit allowing chains to handle cancellation properly for each independent promise after the cancel point.
mxcl pushed a commit that referenced this pull request Apr 8, 2019
This patch adds the concept of a `CancelContext` to PromiseKit allowing chains to handle cancellation properly for each independent promise after the cancel point.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants