Looking for something in between a `TaskGroup` and a `ThrowingTaskGroup`

i’m trying to adopt the new closure-based SwiftNIO APIs introduced in 2.62.0 and i’m really frustrated with how poorly they compose with TaskGroup.

the main issue with TaskGroup is it does not support rethrows from the closure body, which means you can’t call any throws-colored functions such as NIOAsyncChannel.executeThenClose(_:) without wrapping it in a do block to juggle the error out of the task group.

why not use ThrowingTaskGroup instead? well, ThrowingTaskGroup is an AsyncSequence, and that means as soon as one of the child tasks throws an error, the task group enters a terminal state. this is not what i want here; i want to iterate over an asynchronous sequence of Result<T, any Error> where failed child tasks are expected and tolerated.

what i would really want is to have something in between a TaskGroup and a ThrowingTaskGroup, that supports throwing errors from the task group closure, but forbids errors to be thrown from inside the child tasks themselves.

1 Like

In the typed throws in the concurrency module pitch, we added a new method that fills this gap and allows different throwing types from the body and the child tasks.

5 Likes

This is what Result { … } is for.

yes, that is what is meant by juggling the error out of the task group. you have to catch the error from a do block, and wrap it in a Result.failure(_:), and then unwrap it outside the task group.

Instead of

do {
  return .success(try work())
} catch {
  return .failure(error)
}

you write

Result {
  return try work()
}

Which you can still put in a convenience method if you want, but it’s not nearly as out of the way.

1 Like

you are describing init(catching:), which is a “blue” API and does not support any red (async) functions.

for a long time i have wondered why there is no async version of that initializer.

2 Likes

Ah, you’re right. But it’s still a reasonable place to put something like this, rather than changing TaskGroup.

2 Likes

i guess it really comes down tolerance for dialectization (related discussion).

i’ve found in the past that when people start adding general purpose extensions to fundamental types like Result, you inevitably want to start using them in more than one module. so every project that does this eventually grows a _Core or _Common or _Basics or _Utilities module that becomes a magnet for miscellaneous “convenience” API (Collection.onlyElement, BidirectionalCollection.dropLast(while:), unreachable, log(_:), Double.format(places:), etc.).

and if you maintain more than one repository, it is highly probable that this module will metastasize into a swift-core-utils package that becomes a Universal Dependency and continue accumulating helpful extensions until you have reinvented Foundation.

anyway, that’s not specific to the issue at hand, just a general explanation of why i tend to recommend against adding generic helpers to standard library types.

1 Like

I am aware that you are all perfectly capable of hacking this together yourself, but I have had this exact thing laying around in a file I had open while reading this, so here is my implementation:

public extension Result where Failure == any Error {
    init(operation: () async throws -> Success) async {
        do {
            let result = try await operation()
            self = .success(result)
        } catch {
            self = .failure(error)
        }
    }
}

One thought about this:

We have something like this and I do not mind too much for internal, private packages. It's basically just "stdlib, foundation, my-private-foundation" as the base layer.

However, for public packages I agree and understand how this can be a struggle. I would advice to just copy-paste these internal helpers as-needed and very purposefully not be DRY in this case.

I run into this myself as well. I ended up using Result but the do/catch dance is quite annoying. You can add an async Result(catching:) overload but that should just be reasync in my personal opinion. We aren't likely getting reasync though so this isn't a real option.

Luckily this is properly solved in the typed throws in the concurrency module pitch as Franz already mentioned.

What is a red vs a blue API?

I think it is a reference to this piece of writing: What Color is Your Function? – journal.stuffwithstuff.com

  1. You can only call a red function from within another red function.
5 Likes