Snail.Toolkit.Async.Pipeline 1.2.0

dotnet add package Snail.Toolkit.Async.Pipeline --version 1.2.0
                    
NuGet\Install-Package Snail.Toolkit.Async.Pipeline -Version 1.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Snail.Toolkit.Async.Pipeline" Version="1.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Snail.Toolkit.Async.Pipeline" Version="1.2.0" />
                    
Directory.Packages.props
<PackageReference Include="Snail.Toolkit.Async.Pipeline" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Snail.Toolkit.Async.Pipeline --version 1.2.0
                    
#r "nuget: Snail.Toolkit.Async.Pipeline, 1.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Snail.Toolkit.Async.Pipeline@1.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Snail.Toolkit.Async.Pipeline&version=1.2.0
                    
Install as a Cake Addin
#tool nuget:?package=Snail.Toolkit.Async.Pipeline&version=1.2.0
                    
Install as a Cake Tool

Snail.Toolkit.Async.Pipeline

AsyncPipeline<T> is a fluent wrapper around IAsyncEnumerable<T>: LINQ-like operators, and parallel ones that bound both their concurrency and their memory.


Entry points

var fromAsync = myAsyncSource.ToPipeline();
var fromList  = myList.ToPipeline();
var fromOne   = AsyncPipelineBuilder.From(() => LoadAsync());
var merged    = AsyncPipelineBuilder.Merge(first, second);

From takes a factory rather than a ValueTask<T>: a pipeline may be enumerated more than once, and a ValueTask may be consumed only once.

Parallel operators

Every parallel overload requires maxDegreeOfParallelism. There is no default, because a default of one turns a parallel operator into a slower sequential one without saying so.

// Ordered: results come out in source order.
var ordered = pipeline.Select(FetchAsync, maxDegreeOfParallelism: 8);

// Unordered: results come out as they finish.
var fastest = pipeline.SelectUnordered(FetchAsync, maxDegreeOfParallelism: 8);

// Concurrent filter, source order kept.
var live = pipeline.Where(IsReachableAsync, maxDegreeOfParallelism: 8);

// Several sub-streams read side by side, results interleaved.
var files = directories
    .ToPipeline()
    .SelectMany((path, ct) => fileService.SearchFilesAsync(path, ct), maxDegreeOfParallelism: 4);

The selector takes (element, CancellationToken). Note that SelectMany takes a plain method returning IAsyncEnumerable<T> — not an async lambda.

What the degree actually bounds

It bounds the calls in flight exactly, and the results held in memory with them. A slow consumer stops the source from being read, so an ordered Select over an endless source runs in constant memory even when one element is slow. Precisely:

Operator Calls at once Results held Source read ahead
Select, Where the degree the degree, counting the one being awaited one element
SelectUnordered, SelectMany the degree up to twice the degree and one two elements

A worker of the unordered operators is started as elements arrive, so a degree far above the source costs nothing.

What happens when something fails

The first failure faults the sequence and stops the rest. Nothing is dropped silently — if an element's selector throws, the consumer sees that exception rather than a quietly shorter result. A cancellation a selector or a source throws on its own, such as an HttpClient timeout, is that first failure and is reported as it was thrown, not as a cancellation of the pipeline.

What happens when you stop reading

Leaving the loop — by break, by exception or by cancellation — cancels the pending calls, disposes the source and waits for the background work before returning. No work outlives the consumer.

The wait is for the source and the calls to answer their token. One that ignores it — a read on a socket without a timeout — holds the return until it answers, because a MoveNextAsync in flight cannot be abandoned safely.

Cancellation

The token is supplied at enumeration and reaches every operator in the chain, and the exception the consumer receives when it fires carries that token, whatever token a selector threw with:

await foreach (var item in pipeline.WithCancellation(ct))
{
}

Operators do not capture a token when the chain is built.

Terminal methods

List<User> users = await pipeline.ToListAsync(ct);
User? first     = await pipeline.FirstOrDefaultAsync(ct);
int total       = await pipeline.AggregateAsync(0, (sum, x) => sum + x, ct);

ToListAsync, ToArrayAsync and ToDictionaryAsync hold every element, so they answer only for a sequence known to be finite.

Costs worth knowing

Operator Cost
Distinct remembers every distinct element for the whole enumeration
GroupBy reads the source to its end before yielding the first group
Merge order is not preserved and not stable between runs

Enumerating twice

A pipeline is a description of work, not a running one, and each enumeration starts the chain again. Whether a second enumeration is legal belongs to the source: a pipeline over a network stream or a database cursor is single-use.

Benchmarks

The bound on memory is the one promise here that no test states as a number, so it is measured instead:

dotnet run -c Release --project benchmarks -- --filter '*'

AbandonedAfterAPrefix reads a fixed prefix and leaves. Read the Allocated column down: it must stay flat as the source grows from ten thousand elements to a hundred thousand, because the results channel holds at most maxDegreeOfParallelism of them and a consumer that stops reading stops the source. DrainedToTheEnd beside it consumes everything: it may grow only by what the selector itself allocates, and with the selector there, which answers at once, the operator allocates nothing per element, so it stays flat too. A line that grows with the source is the bound broken.

InOrder and AsTheyComplete measure what each element costs the parallel operators, those that keep the source order and those that answer as calls complete, with calls that answer at once and calls that yield first. Each checks its operators' answers in its setup, and benchmarks/baseline/ keeps the last run.

License

Released under the MIT license.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.2.0 79 10/21/2026
1.1.0 155 9/5/2026
1.0.0 147 4/13/2026