Cancel Combine Future

I want to use Combine.Future in an API I'm working on. I am trying to figure out how to wire up the cancellation of a future to the my async code. Consider the following code:

    func solveComplexProblem(for input: SomeInput) -> Combine.Future<SomeOutput, Error> {
        let future = Combine.Future<SomeOutput, Error> { promise in
            // Here is my code to run my logic, which is cancellable, but -
            // How can I know when to cancel?
        }
        return future
    }

How do I know when the user cancelled the subscription so that I can cancel my internal operation?

3 Likes

Hi @tourultimate,

Before answering the question, it is worth going through some particular behavior from Combine.Future:

  • Future is a reference type that invokes the passed closure as soon as it is created (i.e. the closure gets invoked before any subscriber has subscribed)
    This was surprising to me since I was expecting the closure to execute per-demand when a subscriber subscribes (as most other operators).
  • Once the Future's closure call its promise, the result is stored to deliver to the current and future subscribers.

Now, supposing that the Future behavior satisfies your needs, the best way is to attach a handleEvents() operator after the future. Do keep in mind that the Future's closure is executed right away.

var isCancelled = false
let publisher = Combine.Future<SomeOutput,SomeError> { (promise) in
    // Call promise(.success(...)) at some point
   // Check isCancelled at some point during the closure's execution
}.handleEvents(receiveCancel: {
    isCancelled = true
})

I have actually found that the Future behavior is not what I want/need. In that case, you have two choices:

  • wrap the Future on a Deferred.
    A new Future instance will be created every time a subscriber subscribes.
  • create your own custom publisher.
    I went and did that for funsies, you can take it a look here.
2 Likes

By the way, you can proof the previous claims with a simple test like this one:

final class PlaygroundTests: XCTestCase {
    func testFuture() {
        let publisher = Future<Void,Never> { (promise) in
            print("1. Future closure reached!")
            DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
                print("3. Future closure generating value")
                promise(.success(()))
            }
        }
        
        var cancellable: AnyCancellable? = nil
        let e = self.expectation(description: "Finished!")
        
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
            cancellable = publisher.sink {
                print("4. Value received")
                e.fulfill()
            }
        }
        
        print("2. Start the wait.")
        
        self.waitForExpectations(timeout: 5)
        cancellable = nil
    }
}

I had exactly the same surprise and filed a feedback about it (FB7455914) as Future not reacting automatically like it was wrapped in Deferred (execution of its closure on demand) was contrary to what it implies to me.

It came up when someone asked for my help using Retry with a Future to wrap an async call. i ended up documenting it with a unit test showing the surprising behavior as well:

Sorry I couldn't access the GitHub link, I have some doubts regarding cancellation:

Would a custom publisher help because a custom publisher could have a custom subscription and when a subscriber cancels, the subscription would be cancelled and we can have custom logic in the Subscription.cancel function ?

I am sorry @somu, I am not sure I understand your question. Do you mind rewriting it differently?
Referring the Github link, you are right! That doesn't work anymore since I extracted most of my custom publishers into its own repo. You can find them here.

The custom publisher I was talking about is called DeferredFuture. It is just a custom Future publisher behaving as I (and arguably most people) expect Future to behave.

Thanks for the response,

Sorry about the confusing wording, I have tried to explain my question.

You had suggested using a custom Publisher as an option in your earlier post. I wanted to know how a custom publisher would help cancellation ?

I mean if I wanted to execute some code during cancellation, would a custom publisher help ? If so how ? (would it help by using a custom subscription that we can cancel ?)

Sorry I understand better now, thanks !

Ok @somu, I think I get your question now :slight_smile: I believe your inquiry has two sides/parts:

  1. The operation that you run within the future's closure must accept cancellation in whatever shape and form.
    Let's suppose you run an operation that queries a database and takes around 10 seconds. That operation needs to support cancellation if the user is not interested on it anymore. For example, the user cancels it at second 3.
  2. The future's closure where the operation is starting should have a way to communicate that the subscriber is no longer interested on the operation.
    The system provided Combine's Future nor my custom DeferredFuture offer that functionality.

There are several ways to solve problem 2 if the first point is supported.

  • The brute force way.
    Add an isCancelled operation (or similar variable) outside the closure and check it in the future's closure. isCancelled can be toggled with the handleEvent() operator.
    var isCancelled = false
    let publisher = DeferredFuture<SomeOutput,SomeError> { (promise) in
        // Call promise(.success(...)) at some point
        // Check isCancelled at some point during the closure's execution
    }.handleEvents(receiveCancel: {
        isCancelled = true
    })
    
    The upside of this method is that you already have all the tools you need. However, this would be a poll-type operation; always querying at certain time intervals.
  • The custom way.
    Write your own custom publisher/subscription. This can take any shape you want, but one example can be to have a custom publisher accepting two closures, one executed as a regular operation, and another which would only get executed if the subscriber cancels the chain.

Writing custom publishers seems daunting, but I encourage you to do it. It gives you a greater understanding on how Combine's work. If your code runs in production, though, try to use system provided publishers. They are more "battle-tested".

1 Like

Thank you so much for patiently explaining, I am still learning Combine.

I was wondering when using a subscriber (example: sink), the subscriber would return a AnyCancellable that can be used to cancel the subscription.

let canceller = Combine.Future<Int,Never> { (promise) in
    // Call promise(.success(...)) at some point
   // Check isCancelled at some point during the closure's execution
}.handleEvents(receiveCancel: {})
    .sink { value in
    print("value = \(value)")
}

//canceller is of the type AnyCancellable
canceller.cancel()

I agree creating a custom publisher helps understand how things work. I was following Creating a custom Combine Publisher to extend UIKit - SwiftLee