Skip to content

Commit 35210e8

Browse files
committed
docs: add v4 migration guide
1 parent 8b20abb commit 35210e8

1 file changed

Lines changed: 46 additions & 14 deletions

File tree

README.md

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,39 @@ See the [benchmark guide](benchmarks/README.md) to run the BenchmarkDotNet suite
2020

2121
Version 4 requires .NET 8 or later. The package targets and tests `net8.0`, `net9.0`, and `net10.0`.
2222

23+
## Migrating from v3 to v4
24+
25+
Version 4 is a major release. Review these source and behaviour changes before upgrading:
26+
27+
- **.NET 8 is the minimum runtime.** The `net6.0` and `netstandard2.0` targets, the Polyfill dependency, and the `TaskCompletionSource` compatibility shim were removed. The package targets and tests `net8.0`, `net9.0`, and `net10.0`.
28+
- **Parallel processing has one API.** Use `ProcessInParallel(maxConcurrency: 100)` for bounded concurrency and `ProcessInParallel()` for unbounded concurrency. The old non-timed `RateLimitedParallelAsyncProcessor*` types and duplicate overload family were removed. Rename named `levelOfParallelism` arguments to `maxConcurrency`; replace positional `ProcessInParallel(true)` calls with `ProcessInParallel(scheduleOnThreadPool: true)`.
29+
- **Timed processing is a real start-rate limit.** It now uses a shared token bucket instead of holding each worker slot for at least one window. `ProcessInParallel(permitsPerWindow, window, maxConcurrency)` controls start rate and in-flight concurrency independently. The existing two-argument overload remains and uses its first value for both limits.
30+
- **`IEnumerable<T>` input is materialized once when the processor is built.** One-shot enumerables are now supported and side effects run once. Iterator exceptions surface from the terminal builder call, such as `ProcessInParallel(...)`, instead of later from an awaiter.
31+
- **Synchronous disposal no longer waits.** `Dispose()` cancels pending work and releases resources without blocking. Use `await DisposeAsync()` or `await using` when shutdown must wait for in-flight work; the async wait is bounded to 30 seconds.
32+
- **Arbitrary upper limits were removed.** Task counts and batch sizes may exceed 10,000, and time windows may exceed 24 hours. Validity checks remain: counts, batch sizes, concurrency, and permit counts must be positive; time windows cannot be negative.
33+
- **Validation is consistent and eager.** Invalid `maxConcurrency`, parallelism, batch, and timed-rate arguments now throw while the processor is built for both action and result variants.
34+
- **Incidental `TaskWrapper` API was removed.** `IEquatable<T>`, equality operators, `Deconstruct`, and custom `GetHashCode` members were implementation details and are no longer public. Recompile consumers that referenced those members.
35+
- **Synchronous `InParallelAsync` delegates now run concurrently.** The `Func<TSource, TResult>` and `Action<TSource>` overloads use thread-pool workers instead of executing delegates sequentially on the caller. Do not depend on their previous thread affinity or execution order.
36+
37+
Version 4 also adds cancellation-aware selectors. Use `(item, cancellationToken) => ...` when in-flight work must observe external cancellation, `CancelAll()`, or disposal:
38+
39+
```csharp
40+
await using var processor = ids
41+
.ForEachAsync(
42+
async (id, cancellationToken) =>
43+
await DoSomethingAsync(id, cancellationToken),
44+
cancellationToken)
45+
.ProcessInParallel(maxConcurrency: 100);
46+
47+
await processor.WaitAsync();
48+
```
49+
50+
## Execution model
51+
52+
Bounded parallel processors use a fixed set of workers, so coordination work scales with `maxConcurrency` instead of item count. Bounded `IAsyncEnumerable<T>` processing uses a bounded channel to apply source backpressure while preserving result order. Unbounded processing starts work as input is consumed and can place substantial pressure on memory, CPU, network connections, or downstream services.
53+
54+
Timed processors acquire a shared token-bucket permit before starting each operation. The permit rate and maximum in-flight concurrency are separate controls; long-running operations therefore do not reduce permit replenishment. A zero-length window disables start-rate throttling but retains the concurrency limit.
55+
2356
## Why I built this
2457

2558
Because I've come across situations where you need to fine tune the rate at which you do things.
@@ -40,7 +73,7 @@ Maybe you just don't want to write all the boilerplate code that comes with mana
4073
| `ResultParallelAsyncProcessor<TInput, TOutput>` ||| `.WithItems(IEnumerable<TInput>)` | `.SelectAsync(delegate)` |
4174

4275
**How it works**
43-
Processes asynchronous tasks in parallel. Pass `maxConcurrency` to bound the number of operations running at once, or omit it for unbounded concurrency.
76+
Processes asynchronous tasks in parallel. Pass `maxConcurrency` to use a fixed worker pool and bound the number of operations running at once, or omit it for unbounded concurrency.
4477

4578
**Usage**
4679

@@ -80,11 +113,9 @@ Choose a concurrency limit that protects downstream resources. Unbounded process
80113
| `ResultTimedRateLimitedParallelAsyncProcessor<TInput, TOutput>` ||| `.WithItems(IEnumerable<TInput>)` | `.SelectAsync(delegate)` |
81114

82115
**How it works**
83-
Processes your Asynchronous Tasks in Parallel, but honouring the limit that you set over the timespan that you set. As one finishes, another will start, unless you've hit the maximum allowed for the current timespan duration.
116+
Uses a shared token bucket to limit how many operations may start in each window. This is useful when a downstream API has a requests-per-second limit.
84117

85-
E.g. If you set a limit of 100, and a timespan of 1 second, only 100 operation should ever run at any one time over the course of a second. If the operation finishes sooner than a second (or your provided timespan), it'll wait and then start the next operation once that timespan has elapsed.
86-
87-
This is useful in scenarios where, for example, you have an API but it has a request per second limit
118+
The two-argument overload uses the same value for permits per window and maximum in-flight concurrency. Use the three-argument overload when those limits should differ.
88119

89120
**Usage**
90121

@@ -94,19 +125,20 @@ var ids = Enumerable.Range(0, 5000).ToList();
94125
// SelectAsync for if you want to return something - using proper disposal
95126
await using var processor = ids
96127
.SelectAsync(id => DoSomethingAndReturnSomethingAsync(id), CancellationToken.None)
97-
.ProcessInParallel(maxConcurrency: 100, TimeSpan.FromSeconds(1));
128+
.ProcessInParallel(
129+
permitsPerWindow: 100,
130+
window: TimeSpan.FromSeconds(1),
131+
maxConcurrency: 200);
98132
var results = await processor.GetResultsAsync();
99133

100134
// ForEachAsync for when you have nothing to return - using proper disposal
101135
await using var voidProcessor = ids
102136
.ForEachAsync(id => DoSomethingAsync(id), CancellationToken.None)
103-
.ProcessInParallel(maxConcurrency: 100, TimeSpan.FromSeconds(1));
137+
.ProcessInParallel(maxConcurrency: 100, timeSpan: TimeSpan.FromSeconds(1));
104138
await voidProcessor.WaitAsync();
105139
```
106140

107-
**Caveats**
108-
109-
- If your operations take longer than your provided TimeSpan, you probably won't get your desired throughput. This processor ensures you don't go over your rate limit, but will not increase parallel execution if you're below it.
141+
The first example allows up to 100 starts per second and 200 in-flight operations. The second preserves the source-compatible two-argument shape and applies 100 to both limits.
110142

111143
### One At A Time
112144

@@ -182,7 +214,7 @@ await ids
182214
As above, you can see that you can just `await` on the processor to get the results.
183215
Below shows examples of using the processor object and the various methods available.
184216

185-
This is for when you need to Enumerate through some objects and use them in your operations. E.g. Sending notifications to certain ids
217+
Use an item processor when each operation needs an input value, such as sending notifications to a set of IDs.
186218

187219
```csharp
188220
var httpClient = new HttpClient();
@@ -219,7 +251,7 @@ This is for when you need to Enumerate through some objects and use them in your
219251
}
220252
```
221253

222-
This is for when you need to don't need any objects - But just want to do something a certain amount of times. E.g. Pinging a site to warm up multiple instances
254+
Use an execution-count processor when an operation should run a fixed number of times without input values, such as warming multiple site instances.
223255

224256
```csharp
225257
var httpClient = new HttpClient();
@@ -344,8 +376,8 @@ private static void StartProcessing(int[] input, CancellationToken token)
344376

345377
When a processor is disposed:
346378

347-
1. **Cancellation**: Internal `CancellationTokenSource` is cancelled and any unstarted tasks complete as cancelled
348-
2. **Task Waiting**: `await DisposeAsync()` waits up to 30 seconds for in-flight tasks to finish; synchronous `Dispose()` cancels and releases without blocking
379+
1. **Cancellation**: The internal `CancellationTokenSource` is cancelled and unstarted tasks complete as cancelled. Token-aware selectors can cancel in-flight work.
380+
2. **Task Waiting**: `await DisposeAsync()` waits up to 30 seconds for in-flight tasks to finish; synchronous `Dispose()` cancels and releases without blocking.
349381
3. **Resource Cleanup**: Disposes internal resources like `CancellationTokenSource`
350382
4. **Thread Safety**: All disposal operations are thread-safe
351383

0 commit comments

Comments
 (0)