[Review] SF-0023: Progress Reporting in Swift Concurrency

Hello Swift community,

The review of SF-0023: Progress Reporting in Swift Concurrency begins now and runs through May 7, 2025 (original: Apr. 30, 2025)

Pitch Thread

Reviews are an important part of the Swift-Foundation evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to me as the review manager by email or DM. When contacting the review manager directly, please include proposal name in the subject line.

Trying it out

Check out the implementation branch here.

What goes into a review?

The goal of the review process is to improve the proposal under review

through constructive criticism and, eventually, determine the direction of

Swift-Foundation. When writing your review, here are some questions you might want to

answer in your review:

  • What is your evaluation of the proposal?

  • Does this proposal fit well with the feel and direction of Swift-Foundation?

  • If you have used other languages or libraries with a similar

feature, how do you feel that this proposal compares to those?

  • How much effort did you put into your review? A glance, a quick

reading, or an in-depth study?

More information about Swift-Foundation review process is available at

https://github.com/apple/swift-foundation/blob/main/CONTRIBUTING.md

Thank you,

Charles Hu

Review Manager

2 Likes

I'm shocked to see this up for review. The most recent pitch thread had a lot of critiques of the API that were unaddressed by the proposal authors. I was expecting some sort of closure there before seeing it go up for review. I also missed that this review went up at all, as it was not linked from the original pitch thread and got lost in the recent activity of forum posts. I only heard about it because I was talking with another developer who asked if I'd seen the review thread yet.


In reviewing this, I've gone to the proposal document linked above and tried to read it with an open mind. After thoroughly going through it, I've come to essentially the same conclusion:

This API should absolutely not be accepted in its current form.

The motivation is inadequate

This proposal aims to introduce an efficient, easy-to-use, less error-prone Progress Reporting API β€”β€” ProgressReporter β€”β€” that is compatible with async/await style concurrency to Foundation.

NSProgress has a myriad of problems, especially centered around the complexity of its API and the performance of its implementation. So a superficial reading of the motivation might suggest that this pitch will be addressing those problems.

It does not. The proposed API is structurally identical to NSProgress: hierarchical reference types with complex composition, which seems a really odd choice in a world of async streams and value types. (I can't speak to the performance of the implementation; I sure hope it's better, but the pitch doesn't address that)

Nowhere in the motivation is there anything about actually addressing NSProgress's problems, which leads to the second point.

The solution is inadequate

I repeatedly brought up the point that having multiple consumers is a common scenario to account for when handling progress. Just the other day I came across another situation where this was once again evident, and could not come up with a way that this proposed API could be used to address that scenario. This need for multiple consumption was reiterated by other developers in the pitch thread and never addressed.

Nowhere in the pitch does it address why the strict hierarchical model is so desirable and (apparently) superior to other models. The authors seem to have decided a priori that this is the model they want and will not reconsider the base assumptions upon which the entire proposal is resting. That, in and of itself, should be utterly disqualifying for the entire pitch. How can we actually review something when feedback is ignored and challenges to fundamental assumptions are ignored?

I also disagree with the conclusions in the "Alternatives Considered" section that this type cannot live in the Standard Library. I believe that, if we were allowed to challenge the assumptions this pitch is built upon, we could come up with alternatives that would work over XPC connections, and could be extended to work with the Observation framework. It is an utter shame that our language primitives for performing work will still have no way to report progress of that work.

Summary

This is a far-less-capable replacement for NSProgress that doesn't address its actual shortcomings; its only benefits are some minor changes to type safety that make it a bit clearer how the hierarchy of progress should be constructed. But it does not add anything new that was not already possible with NSProgress and ends up removing many of its capabilities that power the workarounds other developers mentioned in the pitch thread.

Accepting this proposal means we will be left struggling with an inadequate progress reporting API that is overly-complicated and integrated at the wrong level of the language.

33 Likes

Also disappointed to see this up for review, given the many unresolved questions and issues in the pitch thread. Although it has changed a little since the previous pitch, I still don't think it's usable for any use-case I have, in its current state. And I think given the repeated failure of the pitch to address the actual concerns of the respondents, the final proposal needs to include a link to a repository of executable, worked examples, including at least but certainly not limited to

  • progress not readily representable as an integer fraction (perhaps because it's vended as a double from an existing C library)
  • multiple subprogress integrated where the weight of each subprogress is not known beforehand (eg. searching a directory hierarchy & sending each file to a different build tool β€” we might model each tool invocation as having the same cost, but we don't know how many files will belong to each tool when we begin the search)
  • progress for an operation which begins indeterminate and becomes determinate (the example of download+filter should cover this, since total download size will not be known up-front)
  • progress for an operation which is indeterminate until completion (just an API usage question, since it doesn't directly lend itself to this situation)
  • multiple consumers (was a common requirement from respondents)

In larger complaints,

  • I still don't think the proposed model where certain properties are set imperatively by the user, and certain properties are aggregated automatically by reduce, actually works in practice β€” the two processes will necessarily overwrite each other. A progress reporter without children could allow imperative update, and one with children could allow automatic reduction, but I don't think one node can support both modes of operation at the same time.
  • I still don't think any system based on pre-allocation of integer weights is suitable for many use-cases. I think the user should be offered free choice of unit, and the ability to adjust the proportions of the total progress that each subprocess represents, during the operation.
5 Likes

Hi Keith, thanks for the detailed feedback! I'll try to address them by segments here:

We aim to address the most common cases, which are cases in which total and completed are needed to represent gradually incrementing progress.

If I understand this use case correctly, we can model each tool as having the same cost, that means we distribute equal weights to each subprogress when we call subprogress(assigningCount:). If it is not known how many files will belong to each tool, there is an option, when calling reporter(totalCount:) to set totalCount to nil. Feel free to clarify with more details if I am not understanding correctly.

There is an option to set the totalCount to nil if you'd like to start the progress for the operation off as indeterminate, and set the totalCount to an Int later to make it determinate.

In this case, with this API, developers can call complete(count:) while totalCount is nil, but the fractionCompleted will stay as 0.0 before totalCount is set. If totalCount is set later after completedCount is already a certain number, the completedCount value will not be thrown away.

For example, you have totalCount of nil and you call complete(count:) 3 times, each time with 1. At this point you have completedCount = 3, totalCount = nil, fractionCompleted = 0.0. If you then set totalCount = 3, then fractionCompleted = 1.0 without completedCount thrown away.

If a parent ProgressReporter has children, then the automatic reduction takes precedence. So we don't expect developers to set, for example, the fileTotalCount on the parent ProgressReporter. This is a convenience for developers to not have to worry about setting fileTotalCount at the top level, and instead focus on setting the fileTotalCount only at the leaf nodes that represent subtasks. Each children at leaf level will have their fileTotalCount, and the parent will automatically aggregate the fileTotalCount with reduce, and show the sum of its children's fileTotalCount.

Do you mind clarifying further why this cannot be supported? Do you have a use case in mind that we can refer to?

While developers need to specify a certain count when calling subprogress(assigningCount:), developers can specify the granularity of each progress for each subtask when they call reporter(totalCount:). For example, say we have a parent of totalCount = 3, and 3 subtasks, each receiving a subprogress that represents 1 count of parent's totalCount. Each of the subtask still has different totalCount for their respective subprogress.

2 Likes

Is there an implementation available of this somewhere? Because I just tried to implement it (so I could actually play with those answers and find out how well it worked), and I don't know how to implement it.

There are a bunch of typos, which I can deal with, but there's a few other problems in the interface:

  • ProgressReporter is a class, and therefore cannot be Hashable or Equatable
  • The two format styles use Option and Options inconsistently
  • debugDescription is erroneously static in both instances

But the implementation problem I didn't know how to deal with was Values β€” presumably, modifications to properties (including dynamic member lookup properties) on Values is supposed to trigger observation on ProgressReporter, but

  • since Values is no longer available as a property of ProgressReporter, there's no KeyPaths to report to the ObservationRegistrar
  • even if there were, you can't create a KeyPath to a dynamic member property except as a literal
  • I'm forced to conclude that the only observation that can be posted is of \ProgressReporter.self
  • If that's the case, then the observation can be reported on return of withProperties, so long as Values tracks whether it was modified or not (the values aren't Equatable, so it can't just diff).
  • but if it's intended to offer finer-grained observation during the withProperties closure, then Values' lifetime is problematic, given it needs to report those accesses and mutations up to the parent, across the lock boundary; it might be more appropriate for it to be nonescaping.

Hi Keith, the implementation is actually linked in the Review post above, and here's the link to it too: GitHub - chloe-yeo/swift-foundation at implementation/progress-reporter

3 Likes

Any chance this could be provided as a package so people can easily try it out? I think that’s the kind of proposal that really benefits from experimentation before it’s settled.

Ah I looked in the SF- itself (they usually have a link to the branch) and the swift-foundation repo, but didn't think to check the OP :woman_facepalming:

Yes - swift-foundation is buildable as a package. Just point the dependency at the fork and branch linked above in your Package.swift.

2 Likes

Hi everyone! A quick update: I'm currently working on a package that contains examples of use cases for ProgressReporter to better illustrate and clarify some questions that have been raised in feedback. I will be sharing this package within the week.

4 Likes

I think @davedelong's put quite a bit of thought and effort into pitch and review but his comments in the review have not yet been answered. Will the package respond decisively to it?

11 Likes

Will the review deadline be extended given the lack of response to the raised issues before that deadline?

1 Like

Hi all, we are extending the review period for on more week (until May. 7, 2025) to give @chloe-yeo more time to address review comments.

3 Likes

I've only just seen this proposal review and I'm pretty shocked to see that it got through the pitch stage 'as is' considering all of the feedback.

I really appreciate all the time and effort that has gone in to this initiative but I echo Dave's comments that very few of the core issues raised in the pitch thread have been addressed. The fact that it's been brought to the review stage 'as is' just feels like it's being rushed through for WWDC. This may be completely untrue as it is purely speculation on my part, but I've seen this happen before so I'm going to defer to history on this one.

As it stands I'm a -1 until the core feedback from the pitch thread is addressed, including my own. Please don't rush this through, please take the time to address peoples concerns. If it doesn't make it in to the latest releases then so be it.

14 Likes

Hi Keith, you're right in pointing out that the prior implementation is flawed in its handling of the custom properties as a single node could not support both modes of operation at the same time. We're in the process of reworking the implementation of the handling of custom properties. We will update on this thread again once we've validated that the new implementation works, together with example code.

Thanks for your feedback!

2 Likes

This is a reminder from the pitch discussion [1] of something not mentioned or addressed in the proposal: the completion states of tasks cancelled or in error.

Concurrency being structured means progress is composite, and thus that some sub-tasks may be done while others are incomplete; of those incomplete, some may have terminated via cancel or error.

The proposal does not address how these incomplete but terminal states are represented as progress values.

Granting that progress is not responsible for implementing or handling cancelled or errors, progress still needs to represent those states accurately to avoid creating more problems.

Because the proposal collapses all progress into a single numeric digest, any cancelled/failed task makes the entire progress digest invalid. In the current/max number scheme, the maximum would imply termination is completion, and using sub-maximal values will make digests incorrect and fail to represent a terminal state.

Unless the proposal represents these states, it will be impossible for developers to do so, unless a developer controls progress reporting for the entire forest of possible tasks and can somehow create and tunnel magic values via progress counters. But we hope and expect developers to use tasks from each other and from libraries, so the proposal should address this.

Since error handling and cancellation are a cooperative effort and code can be complex, it's common to get systems that are work normally but balk strangely on failure -- "strangely" because it can be hard to isolate what's happening or wrong. This is one of the biggest hurdles to concurrency in Swift: not understanding when it doesn't work as intended. If progress reporting could represent these states accurately, then it would really help.

Solutions depend on the current implementation flexibility:

  • Do nothing, but document as out-of-scope
    • -> developers at least on notice
  • Write sample code showing how developers could report this
    • -> developers agree, if diligent about following documentation
  • Create magic constants, known to all
    • -> compile-time agreement but without type-safety
  • Write static utilities or factories to enforce and detect magic values
    • -> Compatible for well-behaved developers
  • Use a binary-compatible struct wrapping the current Int
    • -> type-safe, ergonomic, and possibly interoperable?
  • Use an enum with associated values?
    • -> but the enum is complicated, difficult to extend, and not interoperable

If there are constraints against changing the format, the static utilities seem like the best solution. They also have the benefit of signaling whether source code is possibly cooperative or doesn't support it.

Thanks for your patience...

[1] [Pitch] Progress Reporting in Swift Concurrency - #47 by wes1

1 Like

Hi all, thanks so much for your patience and continued engagement. I appreciate the time that all of you have put in to give thorough and thoughtful review, and I would like to address a couple of key points that have been brought up in both the prior pitch thread and this review thread:

  1. Tree vs Acyclic Graph Structure
    While the current API draws a line by committing to a tree structure, this was a choice that we made while designing this API, grounded in two key observations:
  • Most real-world progress reporting is naturally hierarchical, in which tasks can have subtasks, and progress reporitng needs to account for the progress of the subtasks within the progress of a task. Additionally, this tree structure also allows for nice integration with Structured Concurrency, which also has a tree structure. Developers can execute subtasks that report progress within a withTaskGroup closure nicely.
  • Allowing child nodes to report to multiple parents complicates the API surface and potentially performance issues by introducing additional issues with propagation: a single update having to notify multiple parents of changes, differing weights of child in its different parents, etc.

Other concerns raised with wanting an acyclic graph structure to model progress reporting after, seems to be due to the fact that the ProgressReporter, being initialized from a ~Copyable Subprogress instance passed into a method that reports progress, becomes locally scoped and inaccessible to other scopes from which developers may want to observe the changes.

For this particular case, I don't believe that an acyclic graph structure is necessary to address this concern. Developers can instantiate an intermediary ProgressReporter, which they would can hold a reference to, in order to receive updates from a subtask that reports progress with their locally-scoped ProgressReporter instantiated from Subprogress , as follows:

graph TD
    Root --> Intermediary1
    Root --> Intermediary2
    Intermediary1 --> Child1 
    Intermediary2 --> Child2 

I also understand that the dance to introduce an intermediary via reporter.subprogress(assigningCount: 1).reporter(totalCount: 1) is not the most ideal, so I am in the process of working out an improvement to do so.

  1. Reflecting State of Cancellation in ProgressReporter
    I agree that the interaction between structured concurrency and progress reporting needs to be addressed better in this proposal. In particular, we need to clarity the rules around what ProgressReporter will do when a task that it reports progress for is cancelled.

In short, the clarification here is that a ProgressReporter should always be completed. If a Subprogress is not consumed, it will be completed. If a ProgressReporter is deallocated before it is finished, in the event of a cancellation, it will be completed.

I don't believe that it's the responsibility of ProgressReporter to represent sub-cancellation state. The state of whether or not a task is cancelled up to the Swift concurrency system, and progress tree should be resilient, so ProgressReporter will always complete itself.

With this API, one direction I have in mind is also to more clearly define the boundaries between the progress reporting mechanism and the actual mechanism of doing work / executing tasks. A progress reporting mechanism reports the progress being made in an ongoing task, and does not assume any ownership over any task that it reports progress for, and at the same time remain resilient to changes in states of tasks. If developers would like to report the status of operation, such as what errors are being thrown, we don't think developers should count on ProgressReporter to represent that. With the ability to add custom properties to ProgressReporter, which is a new addition introduced in this API, developers are free to report custom metadata in a type-safe manner within a progress tree.

  1. Handling of custom properties in ProgressReporter
    The prior implementation doesn't support the dual mode of operation in each ProgressReporter, which is a flaw in the prior implementation. I've reimplemented this part, so that each ProgressReporter can support both incrementing its own custom properties, and providing developers the values of custom properties of the whole tree to be aggregated.

An example to illustrate this is as follows:

graph TD
    Root --> Child
    Child --> Grandchild1 
    Child --> Grandchild2

Assuming we have a custom property with name customCount of type MyCustomCount. If the Root itself has a customCount of 2, and Child did not specify customCount, whereas Grandchild1 and Grandchild2 each has a customCount of 5. If a developer asks for customCount at Root, via the withProperties closure, it will return 2. If a developer wishes to get the values of children from the rest of the tree, we added a method to get all values of type MyCustomCount, so that it will return [2, nil, 5, 5] in this case. We also add another method to help developers aggregate all values AdditiveArithmetic types in the tree, so then developers can get 12 from the provided [2, nil, 5, 5] when they wish for the values to be aggregated.

This is so that it is clear to developers what are the effects of writing code for getting and setting customCount within the withProperties closure, so that getting customCount of Root just means getting 2, and setting customCount of Root to 4 just means setting customCount to 4. Writing something like customCount += 2 when the customCount is 2 means that customCount will become 4. None of the aggregation will be done within here.

Developers would need to call a method to get all values of customCount in the tree, and another one to aggregate them, and that is so that we can support dual modes of operations on a single ProgressReporter.

In addition to the points above, I acknowledge that there are also several other points brought up, which will be considered as improvements in the future as part of the future directions for this API, some of which include:

  • Developers having to keep track of how much totalCount is available for creating a Subprogress, even in simple cases. Refactoring code may involve tracing the whole progress tree to ensure that all the counts assigned and completed for a root progress does not cause over-calculations. To address this concern, we may consider adding a macro to add total counts up for simple cases in the future.
  • Progress is not always universally represented in totalCount and completedCount, and can sometimes be represented as a Double. We may also consider adding a special kind of reporter which can receive double updates and propagate these updates up the tree in the future.

I will also update the proposal soon to contain the changes in the API and the discussions that we have had in the thread. Feel free to follow up in this thread if you have any clarifications, concerns and questions!

1 Like

It does indeed complicate the API surface, and performance may indeed suffer. Maybe allowing multiple parents isn't the best way to handle the use case. I just wish we had some guidance about how such a feature should be implemented.

Is it really the case that if you need to report on the overall progress ("O"), and you need to show the progress of possibly-overlapping subsets of that something (subsets "A" and "B"), that you just can't use the proposed ProgressReporter API at all? And that you just need to reimplement the entirety of progress reporting yourself? Is there no way to leverage any of the existing work?

I can imagine two different ways such a feature could be supported. The first way is with multiple parents, as shown below. This idea has already been rejected by the proposal author.

graph BT
O[Overall Progress]
1(Operation 1)
2(Operation 2)
3(Operation 3)
A[Subset A]
B[Subset B]
1-->A
2-->A
1-->O
2-->O
3-->O
2-->B
3-->B

The second way I can imagine to implement such a feature is with some sort of "progress mirroring" API, where a separate progress instance can be created that "mirrors" an existing child. This is what I've done in the past, by using KVO to observe the progressCompleted of an existing child Progress and add it to a new parent. It makes for a more complicated graph, but it works:

graph BT
O[Overall Progress]
A[Subset A]
B[Subset B]
1(Operation 1)
2(Operation 2)
3(Operation 3)
M1(Mirror 1)
M2a(Mirror 2a)
M2b(Mirror 2b)
M3(Mirror 3)
1 --> O
2-->O
3-->O
M1 -.-> 1
M2a -.-> 2
M2b -.-> 2
M3 -.-> 3
M1 ---> A
M2a ---> A
M2b ---> B
M3 ---> B

Under this model, each child maintains only one parent, though it does require some sort of support for a "mirroring" relationship.

However, the proposal under review doesn't support either of these alternatives. We can't directly add a child to multiple parents, and we can't even manually build a mirror of a child and then add that mirror to a parent, because the proposed API doesn't allow for us to add an arbitrary child to a parent. The only way we can add a child is by leveraging the interoperability with the existing Progress API to make a Progress mirror of an existing ProgressReporter, and then add that Progress instance as a child:

(Expand to see sample implementation)
var childReporters: [String: ProgressReporter] = [:]

func childTask(name: String, progress: Subprogress) async {
  let reporter = progress.reporter(totalCount: 10)
  
  // Assign the child reporter into a dictionary so that we can find it later
  childReporters[name] = reporter

  await doTheWork()
  reporter.complete(count: 10)
}

let overall = ProgressReporter(totalCount: 3)

Task {
  await childTask(name: "1", progress: overall.subprogress(assigningCount: 1))
  await childTask(name: "2", progress: overall.subprogress(assigningCount: 1))
  await childTask(name: "3", progress: overall.subprogress(assigningCount: 1))
}

// Set up Subset A
Task {
  let subsetA = ProgressReporter(totalCount: 2)
  let mirror1 = childReporters["1"].mirror()
  let mirror2 = childReporters["2"].mirror()
 
  subsetA.subprogress(assigningCount: 1, to: mirror1)
  subsetA.subprogress(assigningCount: 1, to: mirror2)
}

// Omitted: do the same thing for Subset B

extension ProgressReporter {
  /// Returns a new Progress instance that approximately imitates the
  /// 'fractionCompleted' of the receiver. This can be assigned to a 
  /// ProgressReporter via subprogrss(assigningCount:to:)
  ///
  /// This *must* return a Progress, not a ProgressReporter, because
  /// we can't add an existing ProgressReporter as a child.
  func mirror() -> Progress {
    let mirror = Progress(totalCount: 1000)
    _ = withObservationTracking {
      self.fractionCompleted
    } onChange: {
      Task { 
        // Run this asynchronously to make sure we see the new value
        mirror.completedCount = Int(self.fractionCompleted * 1000)
      }
    }
    return mirror
  }
}

That's a lot of work to enable the ability to report on arbitrary subsets. And the mirroring functionality is incomplete; it only works if the child is determinate. But this is the only way I can see to use the proposed API to support the requirements of my project. And if I have to do that, I'll probably just stick with the existing solution we have that uses the Progress API, and I'll just have to be really careful when using async functions.

Now, maybe the answer is simply that I shouldn't try to use the proposed API in this way; that if I need to report on the progress of overlapping subsets, I should just stick with the old Progress API. Maybe this proposal is designed to make simple things easy, but doesn't have the goal of making complex things possible. That seems unfortunate, but maybe that's the answer. Telling me that most people don't need to do this doesn't help me, because I do need to do this.

So I'd like to hear that from the proposal author: Is it expected that users who need to report progress on overlapping subsets will not be able to use the ProgressReporter API at all? Or if not, can you give some guidance on how this API would be used in such a case?

3 Likes

I also have concerns about the spelling of this proposed API:

extension ProgressReporter {
  func subprogress(assigningCount: Int, to progress: Foundation.Progress)
}

The naming of this function looks like it should return a Subprogress, but it does not. This may lead to confusion:

let overall = ProgressReporter(totalCount: 2)
let sub1 = overall.subprogress(assigningCount: 1) // ok
let sub2 = overall.subprogress(assigningCount: 1, to: p) // Warning: sub2 is Void

I wonder if this variant that doesn't return a subprogress should be named differently to make this distinction clearer?

let sub1 = overall.subprogress(assigningCount: 1)
overall.assign(count: 1, to: p)

This would make the two different operations look different


I'll also note that subprogress(assigningCount: 1) is quite a mouthful. I wonder if subprogress(for: 1) would be sufficient?

2 Likes

Thanks everyone for your feedbacks! The review period for SF-0023 ProgressReporter: Progress Reporting in Swift Concurrency is now closed.

The Foundation Workgroup will now take some time to review the feedbacks and the proposal to determine the next steps.

Thanks,
Charles, review manager