|
| 1 | +# CLAUDE.md |
| 2 | + |
| 3 | +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
| 4 | + |
| 5 | +## Repository Overview |
| 6 | + |
| 7 | +EnumerableAsyncProcessor is a NuGet library for processing asynchronous tasks with controlled concurrency: one at a time, batched, rate limited, timed rate limited (e.g. requests-per-second), or fully parallel. The library multi-targets `net8.0`, `net9.0`, and `net10.0` and is strong-named (`Directory.Build.props` signs with `strongname.snk`; internals are visible to the test project). |
| 8 | + |
| 9 | +`agents.md` is a symlink to this file (`claude.md` resolves to `CLAUDE.md` on Windows' case-insensitive filesystem). |
| 10 | + |
| 11 | +## Commands |
| 12 | + |
| 13 | +```powershell |
| 14 | +# Build |
| 15 | +dotnet build |
| 16 | +
|
| 17 | +# Run all tests (TUnit on Microsoft.Testing.Platform; global.json wires dotnet test to MTP) |
| 18 | +dotnet test |
| 19 | +
|
| 20 | +# Run tests for a single target framework directly |
| 21 | +dotnet run --project EnumerableAsyncProcessor.UnitTests -f net10.0 |
| 22 | +
|
| 23 | +# Run a single test — TUnit uses --treenode-filter (/Assembly/Namespace/Class/Method), NOT --filter |
| 24 | +dotnet run --project EnumerableAsyncProcessor.UnitTests -f net10.0 -- --treenode-filter "/*/*/*/TestMethodName" |
| 25 | +
|
| 26 | +# Run all tests in one class |
| 27 | +dotnet run --project EnumerableAsyncProcessor.UnitTests -f net10.0 -- --treenode-filter "/*/*/ParallelAsyncProcessorTests/*" |
| 28 | +``` |
| 29 | + |
| 30 | +TUnit test projects compile to executables; VSTest-style `dotnet test --filter` does not work. See the `tunit-testing` skill for full filter syntax. |
| 31 | + |
| 32 | +CI (`.github/workflows/dotnet.yml`) runs the `EnumerableAsyncProcessor.Pipeline` project (a ModularPipelines app, `dotnet run -c Release` from that directory), which builds, tests, packs, and — on `main` — publishes to NuGet. Versioning comes from GitVersion (`GitVersion.yml` pins `next-version: 4.0.0`; keep that file present, its absence makes ModularPipelines generate a Mainline config that crashes on GitHub PR merge commits). |
| 33 | + |
| 34 | +## Architecture |
| 35 | + |
| 36 | +### Fluent API flow: extensions → builders → runnable processors |
| 37 | + |
| 38 | +1. **Entry points** (`Extensions/EnumerableExtensions.cs`, `Extensions/AsyncEnumerableExtensions.cs`, `Builders/AsyncProcessorBuilder.cs`): `items.SelectAsync(...)` / `items.ForEachAsync(...)`, or `AsyncProcessorBuilder.WithItems(...)` / `.WithExecutionCount(n)` for source-less runs. |
| 39 | +2. **Builders** (`Builders/`) capture the items, the delegate, and a `CancellationTokenSource` linked to the caller's token. |
| 40 | +3. **Terminal methods** (`ProcessInParallel`, `ProcessInBatches`, `ProcessOneAtATime`) construct the matching processor from `RunnableProcessors/` and immediately call `StartProcessing()` — **processing begins at build time**, not on first await. |
| 41 | + |
| 42 | +### Processor matrix |
| 43 | + |
| 44 | +Processor classes vary along three axes, reflected in naming: |
| 45 | + |
| 46 | +- **Input**: with items (`<TInput>`) vs. execution-count only (non-generic). |
| 47 | +- **Output**: `Result*`-prefixed classes (in `RunnableProcessors/ResultProcessors/`) return values via `IAsyncProcessor<TOutput>` (`GetResultsAsync()`, `GetResultsAsyncEnumerable()`, `GetEnumerableTasks()`); unprefixed classes are fire-and-await (`WaitAsync()`). |
| 48 | +- **Strategy**: `OneAtATime`, `Batch`, `Parallel`, `RateLimitedParallel`, `TimedRateLimitedParallel`. |
| 49 | + |
| 50 | +`RunnableProcessors/AsyncEnumerable/` holds parallel variants for `IAsyncEnumerable<T>` sources. File-name suffixes `_1`/`_2` distinguish generic arity (e.g. `BatchAsyncProcessor_1.cs` is `BatchAsyncProcessor<TInput>`). |
| 51 | + |
| 52 | +### Core mechanics (read these before changing behavior) |
| 53 | + |
| 54 | +- **`ProcessorLifecycle.cs`**: owns start/cancel/dispose shared by both base-class hierarchies. `AbstractAsyncProcessorBase` (void) and `ResultAbstractAsyncProcessorBase` (results) cannot share an ancestor because they fan out to differently typed `TaskCompletionSource` lists, so both delegate to this class. Cancellation is registered in `Start`, not the constructor, so a pre-cancelled token can never fire on a partially built instance. `DisposeAsync` waits up to 30 seconds for in-flight tasks; sync `Dispose` cancels without blocking. |
| 55 | +- **TCS-per-item**: each item gets a `TaskCompletionSource`; `TaskWrapper.Process` never throws — it completes the item's TCS with the failure/cancellation instead, so one failed item cannot kill the run or leave awaiters hanging. |
| 56 | +- **`WorkerPool.cs`**: rate-limited processors run a fixed pool of worker loops claiming items via `Interlocked.Increment` (P `Task.Run` tasks total, not N throttled tasks + semaphore). `minimumIterationTime` is how timed rate limiting is implemented: each worker holds its slot for at least that duration per item. |
| 57 | +- **Multi-targeting**: `EnumerableExtensions.ToIAsyncEnumerable` uses `Task.WhenEach` on `NET9_0_OR_GREATER` and a completion-order-bucket fallback otherwise. The test project targets `net8.0` specifically to exercise the fallback path — don't drop that TFM. |
| 58 | + |
| 59 | +### Disposal contract |
| 60 | + |
| 61 | +All processors implement `IDisposable`/`IAsyncDisposable`; the README documents the patterns users rely on (`await using`, safe double/early disposal). The builder extension shortcuts on `IAsyncEnumerable<T>` dispose internally; processors returned from the builder pattern are the caller's responsibility. Preserve these semantics — there are dedicated regression tests (`DisposalRegressionTests`, `ExceptionFidelityTests`, `InputEnumerationRegressionTests`). |
0 commit comments