[AsyncAlgorithms] withDeadline

You can read the full pitch here. The previous discussion for the pitch can be referenced here.

This proposal will last until February 13th. Please focus feedback around the API, impact of which, and if it feels like the right fit for the project, avoid if possible specifics of implementation details as those can be changed over time. Does this fill a specific need? Have you used something similar? If so what were the impacts to development?

This particular proposal has an extra caveat; it has been discussed that this functionality could belong in the _Concurrency library as a general purpose feature for the language as a whole. Even though this is a potentially compelling discussion point, this proposal is not aimed to do that. Acceptance of this proposal is not a signal of including this more broadly or not. That would be a distinct process apart from this one.

Thanks for your consideration.

15 Likes

Taking off my review manager hat;

I for one think that this is a much needed functionality and the current approach listed seems to be quite well rounded. It has parallels to other existing timeout based APIs that currently exist but is a much more cohesive story in my opinion. It seems to be capable for evolution in the future and I think this is a perfect venue for a proving ground as well as handing out a potentially complicated mechanism to folks so they don't have to bother writing or maintaining it themselves in app layers. In short +1.

2 Likes

+1, yes please.

Basic and mostly-working versions of this race-against-timeout setup have been floating around on the forums since Async/Await started. It is something I've needed (or wanted) to reach for many times when setting up network and service clients, or assembling logic in that same path.

Now that we have the language support to make this work in a completely compose-able manner with structured concurrency, that's a huge win - and I think very worth of inclusion here.

2 Likes

+1, I think this pretty much makes the path to solve one of the issues with actors in swift which is reentrancy, making an approach like Erlang processes more viable (at least on the timeout side, I know there’s limitations with memory handling and such) cc @ktoso

My only issue is that I find the api a bit cumbersome to use if I want to use it multiple times chain of async operations, or even if I want to make it the default for all async operations, not sure how to solve it tho or if anyone has an opinion on it

-1 unless renamed, as per my comments in the pitch thread. Naming this withDeadline makes it an attractive nuisance, because it doesn’t actually do anything unless the task it’s used with has implemented cancellation effectively and correctly. Naming it withAutomaticCancellation(at:) does not mislead the user about the effectiveness of this API to act as a watchdog.

As a process note, I see that this pitch is already marked ā€œImplemented.ā€ Can we distinguish between prospectively-implemented and implemented-and-approved?

6 Likes

Short because I’m still sick, but need to get the input in here.

  1. The package authors should be fully prepared to quickly deprecate this API when Swift starts to offer this API. The Swift API will most likely be spelled as ā€œwithDeadlineā€ as well, so I want you to be aware of this and please be prepared to deprecate this.
  2. The ā€œuntilā€ is really bad spelling IMHO, there’s no ā€œdeadline until Xā€. We’re literarily passing the deadline there, therefore the API should be:
withDeadline(deadline, clock: clock)
  1. This proposal does not allow for propagation, that’s a big shortcoming and not something the stdlib would accept IMHO, as we care about propagation features. I think it’s fine for this first attempt to have less features. I’m okey with this living in this shape in the algos package, but want to be very clear this would not be sufficient to just copy over to the stdlib.
7 Likes

The function has the potential to be useful, but this:

Important: This function cancels the operation when the deadline expires, but waits for the operation to return. The function may run longer than the time until the deadline if the operation doesn't respond to cancellation immediately.

Wouldn't this behaviour really limit the utility of the function in real applications?

In real life, I would expect the function to finish immediately as soon as the deadline expires.

Upon expiry, couldn't the function send the operation to an orphanage and return immediately?

2 Likes

+1 on the functionality, -1 on the name. I agree that the name is misleading, and doesn't do what I would expect it to do. When I found the pitch thread my initial reaction was wondering how it was made to safely return after the timeout was reached, only to discover it doesn't really.

I expect others developers would find this and also expect it to immediately return once the deadline is reached, and not realize it doesn't until that causes a problem (if ever).

3 Likes

Thanks for this feature.

As proposed, the design of DeadlineError makes it difficult to "unwrap" it, in order to throw either the error that caused the operationFailed case, either a reified deadline exceeded error (which does not exist).

Why would one want to perform such unwrapping? Because for good ergonomics, adding withDeadline somewhere in a codebase should not break the existing error handling.

Say some throwing function downloadData performs a network request. I can throw URLError, and some client does perform specific handling of URLError:

// MyModule.swift
/// May throw URLError
public func downloadData() async throws { ... }

// Client.swift
import MyModule
func work() async {
  do {
    try await downloadData()
  } catch is URLError {
    // Specific handling of URLError
  } catch {
    // Handle other errors
  }
}

When the author of downloadData adds support for timeout, they do not want to stop throwing URLError in case of network error. It would break the clients. They thus need to unwrap the DeadlineError, rethrow the operation failure, and throw... something else for the deadlineExceeded case. There lies the problem. What can they throw? The error wrapped in the deadlineExceeded is not an error than means "deadline exceeded". There is no such DeadlineExceededError concrete type.

And actually I don't know how to write code that catches the DeadlineError which is generic:

 public func downloadData() async throws {
+  do {
+    try await withDeadline(...) {
       // Previous code
+    }
+  catch let deadlineError as DeadlineError { // How to write this, actually?
+    switch deadlineError.cause { ... }
+  }
 }

To sum up :

  • The current design does not help API authors add a new type of error while preserving the existing errors thrown from their APIs. Consequence: this new type will be recreated many times, by many teams and libraries. The proposal should address this.

  • It is not clear how one is supposed to catch DeadlineError<OperationError,Clock> and distinguish both causes, when one does not know the actual types used for OperationError and Clock.

  • The two above points look like a poor interaction of the proposed withDeadline with untyped throws. Please let me remind that the When to use typed throws section of SE-0413 says:

    [...] even with the addition of typed throws to Swift, untyped throws is better for most scenarios.

    This means that many codebases use untyped throws pervasively. Identifying and addressing the needs of those codebases is important.

10 Likes

Go has the concept of wrapped errors, with the preferred way to check for a specific error being errors.Is or errors.As which check within wrapped errors. I think Swift would need similar support for error catching with is, if wrapping errors becomes common.

To make catching the common case of DeadlineError<any Error, …> easier, should here be an AnyDeadlineError<Clock> type alias? (would this actually match any concrete DeadlineError<SpecificError, …>?)

Should there be some sort of affordance when the wrapped errors in DeadlineError is CancellationError? This seems like it will be a common error to expect if the task is cancelled because the deadline is exceeded.

Huge +1 for the idea and overall design.

Exactly as you said, I think many people will be surprised. This doesn’t work like a typical timeout or deadline.

Here are a few name variants that come to mind:

  • withCancellation(onDeadline:, body:)

  • withGracePeriod(deadline:, body:)

  • withDeadlineConstraint(deadline:, body:)

The first variant seems to reflect the function’s behavior most accurately. It’s clear that:

  • When the deadline occurs, the operation is simply cancelled, so there’s no expectation of immediate return or timeout-like behavior.
  • If the operation doesn't respond to cancellation, the function may run significantly longer after the deadline has passed.
4 Likes

The current withDeadline and withCancellation proposal works well for operations that throw errors on failure or cancellation. However, it’s unclear how this would behave with non-throwing operations that may return partial results, such as an empty array, nil, or incomplete data upon cancellation. This is an important scenario that needs further consideration, as many async operations, such as network requests or database queries, might be designed to return partial data or default values rather than throwing errors when they are canceled.

To address this, I propose adding overloads that accommodate non-throwing operations, allowing developers to handle cases where the operation may return partial results on cancellation, or where the result is inherently nullable (e.g., an empty array or nil).

Proposed Solution

Here are two potential solutions that could fit the needs of non-throwing operations:

1. Returning a value with a cancellation error:

For non-throwing operations that return values like arrays, an overload could return a tuple containing both the result and a cancellationError field. This way, developers can check if the operation was canceled and still receive the partial result.

Example:

/// Return array with partial result if canceled
func searchItemsArray(query: String) async -> [String]

/// Usage:
func withCancellation<Return>(onDeadline: Clock.Instant, body: () async -> Return) 
  -> (output: Return, cancellationError: CancellationError?)

let result = withCancellation(onDeadline: deadline) { 
  searchItemsArray(query: "query") 
}

if let error = result.cancellationError {
  print("Operation was cancelled with partial result:", result.output)
} else {
  print("Operation was fully completed with result:", result.output)
}

In this approach:

  • If the operation completes successfully, it returns the result.
  • If the operation is canceled, the function will still return the result (which may be partial or default, depending on the operation) along with a cancellationError indicating that the operation was canceled before completion.

2. Explicit InfallibleResult Type:

For cases where you want to clearly distinguish between a fully completed operation and a canceled operation, we could use a result type such as InfallibleResult. This enum allows the operation to return either a completed result or a partial result if the operation was canceled.

This solution also work better with operations that can return Optional / nil, so I find it more general.

Example:

enum InfallibleResult<T> {
  case completed(T)
  case partial(T)  // Returned when the operation was cancelled
}

func searchItems(query: String) async -> InfallibleResult<[String]>

/// Usage:
func withCancellation<Return>(onDeadline: Clock.Instant, body: () async -> Return) 
  -> InfallibleResult<Return>

let result = withCancellation(onDeadline: deadline) { 
  searchItems(query: "query") 
}

switch result {
case .completed(let output): 
  print("Operation was fully completed with result:", output)
  
case .partial(let output): 
  print("Operation was cancelled with partial result:", output)
}

In this approach:

  • The result type explicitly tells the developer whether the operation was completed or canceled with a partial result.
  • The partial case provides a way to handle the canceled operation without losing the data that may have been fetched before cancellation.
  • returning InfallibleResult explicitly and statically indicates that operation has proper cancellation support

Motivation

This suggestion aims to cover more use cases by handling non-throwing operations that return partial or empty results when canceled. Many async operations, such as networking or data transformations, may return partial data or default values when interrupted, and it’s important for developers to have a way to clearly identify these scenarios. The overloads outlined above provide an intuitive way to distinguish between completed and partially completed (canceled) results, enabling better error handling and data management in asynchronous workflows.

These overloads provide more flexibility to handle cancellation in a way that matches operations, without needing to adopt error-prone or verbose manual timeout implementations.

PS: I can create a separate pitch if someone is interested in what I'm suggesting but feels it is out of scope of current proposal

4 Likes

I think the functionality is a great addition to Swift.

I’m not sure we have fully explored or explained why this functionality should be in AsyncAlgorithms instead of Concurrency, though. To me it feels like part of the latter – AsyncAlgorithms has been focused on providing additional ā€œoperationsā€ on AsyncSequence. Even there, some of those we felt belonged in Concurrency too (reduce, contains, for example).

I am going to extend this review period to allow @FranzBusch to reply to some of the discussion brought up here.

@Tony_Parker I discussed this exact topic with Franz; the intent as I understand it is to use this as a point of evaluation - the wider audience of _Concurrency would likely have more runtime interaction entailed and that design would need some considerable more involvement than just a simple interface. That being said, we should also not prevent a reasonably good solution from moving forward and collecting information about how folks will use it. AsyncAlgorithms may be primarily focused on AsyncSequence extensions but that isnt per se a 100% rule, just as much as Swift Algorithms is not just about Sequence extensions.

I think with this as a starting point we can make incremental changes that reduce the overall risk profile of the APIs and move then to Concurrency when that runtime infrastructure can be changed more easily in the development cycle. That migration is not totally unprecedented and after my discussion with Franz, I think there are multiple avenues to approach that. I am very much in agreement with @ktoso w.r.t. the propagation.

I’m very excited to see something like this added.

I think the discussion about where, exactly, this kind of functionality ends up is tough. I think I get the pros and cons, and I trust that the right people are thinking about this. I’m not particularly worried about putting something in a library to start, and then deprecating, if that ends up being what happens.

However, I remain concerned that the naming here will result in confusion, especially for people that are the least-prepared to deal with it. I kinda liked the withAutomaticCancellation suggestion myself, because it really bakes in the important concepts.

3 Likes

As others have mentioned above, I also believe some developers will be extremely surprised to discover that withDeadline does not really enforce the timeline in any way, not even within some tolerance —like, say, a Timer— but rather limits itself to encouraging a timeline by having it work through task cancellation. I think this should be made more prominent in the documentation, ideally mentioned within its one-line summary.

I definitely wouldn't say:

Use this function to limit the execution time of an asynchronous operation to a specific instant.

Because that is exactly what a lot of people would be looking for, and not what this function does.

Since there's no reasonable alternative behavior for a timeout-like function in Swift Concurrency, I've convinced myself that the withDeadline name is fine, and that it could just become one of those quirks people get used to (although I liked the withAutomaticCancellation(at:) name too, and it may be better at nudging people into thinking about task cancellation more often).

Having it in async algorithms before Concurrency could prove useful precisely because of this: it may provide another data point on whether the name works or not.

Utility-wise, huge +1 on it. I've needed something like this multiple times in the past, and although at first glance I thought it wouldn't work for many of the use cases I had (e.g. waiting for a async sequence to fire within a period), I realized in many real world scenarios task cancellation "just works" even if you didn't think about it.

7 Likes

A strong +1 on the proposal, and while I'd like to see this in the stdlib in the future, I'd prefer if we didn't have to wait for that. Presumably once stdlib has it, we can deprecate it in async algos and point to the stdlib version?

I don't think I have much to contribute that hasn't already been said, except that deadlines and Swift Concurrency don't play very well together because the thing waiting for a deadline to pass may end up suspended longer than that deadline waiting to run even though forward progress is being made the whole time.

(This is fundamentally why Swift Testing doesn't have much time-sensitive API.)

1 Like

Meh, the same is true for blocking code – you could have some nasty blocking code that runs longer than a deadline, I don’t think this problem is unique to swift concurrency. We’d need a preemptive system in order to interrupt such ā€œblocking codeā€ if deadlines were exceeded, and that’s not really something we’re able to do in Swift/native runtime.

2 Likes

It's not, but it is an emergent behavior of cooperative multitasking systems without interrupts, which Swift Concurrency of course is.