SpillFlow 0.3.0-alpha.9

This is a prerelease version of SpillFlow.
dotnet add package SpillFlow --version 0.3.0-alpha.9
                    
NuGet\Install-Package SpillFlow -Version 0.3.0-alpha.9
                    
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="SpillFlow" Version="0.3.0-alpha.9" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SpillFlow" Version="0.3.0-alpha.9" />
                    
Directory.Packages.props
<PackageReference Include="SpillFlow" />
                    
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 SpillFlow --version 0.3.0-alpha.9
                    
#r "nuget: SpillFlow, 0.3.0-alpha.9"
                    
#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 SpillFlow@0.3.0-alpha.9
                    
#: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=SpillFlow&version=0.3.0-alpha.9&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=SpillFlow&version=0.3.0-alpha.9&prerelease
                    
Install as a Cake Tool

SpillFlow

SpillFlow is an experimental .NET 8 library for bounded-memory operations over IAsyncEnumerable<T>. It provides OrderBySpill, DistinctSpill, ExceptBySpill, GroupJoinSpill, GroupJoinStreamSpill, DistinctRecoverableSpill, ExceptByRecoverableSpill, GroupAggregateSpill, GroupAggregateRecoverableSpill, and GroupJoinStreamRecoverableSpill.

Alpha: APIs, generated codec layout, and recovery file formats may change before 1.0. Review the current limitations before production use.

Why SpillFlow

  • Processes data larger than available RAM through bounded temporary storage.
  • Supports asynchronous Distinct, OrderBy, ExceptBy, grouped aggregation, materialized joins, and disk-streamed joins.
  • Uses no third-party runtime packages.
  • Provides disk quotas, bounded merge fan-in, recursive partitioning, checksums, schema fingerprints, optional AES-GCM encryption, and protected session folders.
  • Includes recoverable operators and transactional output identifiers for restartable long-running jobs.
  • Includes a source generator for deterministic compact binary codecs.

In the checked-in Windows x64 comparison, SpillFlow used 18.6% to 43.0% less median peak working set than ExternalSort across the four shared operations. These are measured results for the documented workload, not a universal promise.

Install

dotnet add package SpillFlow --version 0.3.0-alpha.9

The package includes the codec source generator; no second analyzer package is required. SpillFlow targets .NET 8.

The implementation uses an in-memory fast path for small distinct workloads, stable hash partitioning for set and join operations, and sorted runs with a k-way merge for ordering. A SpillSession owns temporary storage and removes it when disposed.

await using var session = new SpillSession(new SpillOptions
{
    MemoryLimitBytes = 256L * 1024 * 1024,
    MaxProcessPrivateMemoryBytes = 1024L * 1024 * 1024
});

session.RegisterCodec(new TransactionSpillCodec());

await foreach (var row in transactions.DistinctSpill(session, x => x.Id))
{
    // Process each unique transaction.
}

For production record types, annotate the type and register its generated codec:

[GenerateSpillCodec]
public sealed class Transaction
{
    public Guid Id { get; set; }
    public long AmountInCents { get; set; }
    public string Currency { get; set; } = "";
}

session.RegisterCodec(new TransactionSpillCodec());

The generator supports public get/set properties of type int, long, bool, Guid, and string. It emits checked, deterministic little-endian encoding and rejects unsupported property types at compile time. You can instead implement ISpillCodec<T> manually. int, long, and Guid have built-in fixed-width encoding. Without a codec, records use the slower JSON fallback.

Every binary codec declares a stable SchemaId. SpillFlow stores a 128-bit fingerprint of that identifier in each temporary block and rejects mismatched blocks before decoding. A custom codec must change its SchemaId whenever its binary layout changes.

Production-style callers can require binary codecs and reject the JSON fallback:

await using var strictSession = new SpillSession(new SpillOptions
{
    MemoryLimitBytes = 256L * 1024 * 1024,
    AllowJsonFallback = false
});

Grouped aggregation retains one accumulator per key instead of materializing every member of every group:

var totals = transactions.GroupAggregateSpill(
    session,
    row => row.AccountId,
    () => 0m,
    (total, row) => total + row.Amount,
    (accountId, total) => new AccountTotal(accountId, total));

GroupAggregateSpill begins with an adaptive in-memory path. It creates no temporary files when the estimated accumulator state fits within 75 percent of the session budget. If the threshold is crossed, it writes the buffered prefix to stable hash partitions and continues in bounded-memory mode without reading the source a second time.

GroupJoinSpill returns each match group as an IReadOnlyList, so a single group must fit its memory lease. For a key with more matches than RAM, use GroupJoinStreamSpill. Its result selector consumes an asynchronous match stream before returning:

var counts = accounts.GroupJoinStreamSpill(
    transactions,
    session,
    account => account.Id,
    transaction => transaction.AccountId,
    async (account, matches, cancellationToken) =>
    {
        var count = 0L;
        await foreach (var transaction in matches.WithCancellation(cancellationToken))
            count++;
        return new AccountTransactionCount(account.Id, count);
    });

When an oversized partition stops shrinking, the streaming operator reads its matches directly from disk. Left records are processed in bounded batches, and one right-partition scan broadcasts matches to every consumer in that batch through one-item channels with backpressure. HotKeyJoinBatchSize controls the maximum batch size and defaults to 64. HotKeyJoinBatches and HotKeyPartitionScans expose the actual work in session statistics. This keeps memory bounded and reduces scans from one per left record to approximately one per batch. Grouped aggregation remains preferable when callers need only a count, sum, or other summary.

Sorting is stable for equal keys and enforces MergeFanIn through bounded multi-pass merging. Hash operators use the supplied equality comparer for both equality and partition routing. Before a partition is materialized, SpillFlow applies PartitionMemoryExpansionFactor and fails with SpillResourceLimitException when the estimated retained state cannot fit safely inside the session budget.

MaxProcessPrivateMemoryBytes is an optional process-wide safety tripwire. SpillFlow samples private memory at session creation and at record or lease boundaries, no more often than ProcessMemoryCheckInterval, and reports the largest observation in PeakObservedProcessPrivateMemoryBytes. Sampling cannot guarantee a hard ceiling between checks. Use a container, Windows Job Object, or service-level operating-system limit when strict process isolation is needed.

For long-running sorting jobs whose source can reopen at a record offset, OrderByRecoverableSpill checkpoints input runs, merge replacements, and output progress:

await foreach (var row in session.OrderByRecoverableSpill(
    (offset, cancellationToken) => ReadTransactionsFromOffset(offset, cancellationToken),
    operationId: "daily-transactions",
    keySelector: transaction => transaction.Timestamp,
    outputCheckpointInterval: 1_000,
    cancellationToken: cancellationToken))
{
    await WriteResult(row, cancellationToken);
}

After an interruption, reopen the preserved directory with SpillSession.Recover and call the same operation ID. Reading resumes at the last durable run boundary, and merging resumes from the last durable run graph. Output is at-least-once: after a crash, up to outputCheckpointInterval records may be replayed. Use ProcessTransactionallyAsync when the destination can save the business change and a unique delivery acknowledgement in one transaction:

await session.OrderByRecoverableSpill(
        (offset, token) => ReadTransactionsFromOffset(offset, token),
        operationId: "daily-transactions",
        keySelector: transaction => transaction.Timestamp,
        cancellationToken: cancellationToken)
    .ProcessTransactionallyAsync(
        operationId: "post-daily-transactions",
        deliveryKeySelector: transaction => transaction.Id.ToString(),
        sink: sqlTransactionSink,
        cancellationToken: cancellationToken);

ISpillTransactionalSink<T> has two operations. IsAcknowledgedAsync checks a durable acknowledgement table. CommitAndAcknowledgeAsync must write the output and insert its (OperationId, DeliveryKey) acknowledgement under a unique constraint in the same database transaction. A lost response can cause SpillFlow to ask again, but the committed acknowledgement makes the retry a no-op. The delivery key must be unique for one logical output and stable across restarts; a database primary key or ledger transaction ID is a good choice. This gives exactly-once effects at that transactional destination. It cannot atomically cover two independent databases or a nontransactional external API.

Recoverable distinct processing uses the same offset-aware source contract:

await foreach (var row in session.DistinctRecoverableSpill(
    (offset, cancellationToken) => ReadTransactionsFromOffset(offset, cancellationToken),
    operationId: "unique-transactions",
    keySelector: transaction => transaction.Id,
    outputCheckpointInterval: 1_000,
    cancellationToken: cancellationToken))
{
    await WriteUniqueResult(row, cancellationToken);
}

It checkpoints durable input chunks before partitioning. Raw chunks are deleted only after the complete recursively refined partition graph is checkpointed, so interrupted partition construction can restart without reopening the source. Output is at-least-once and may replay up to the configured interval within the current partition. Recovery must use equivalent key-selector and comparer logic.

ExceptByRecoverableSpill accepts separate offset-aware factories for the primary sequence and exclusion keys. Each source advances independently. Both sets of input chunks remain available until the complete paired-partition graph is durably saved, so interrupted partitioning restarts locally. Output has the same at-least-once checkpoint window as recoverable distinct processing.

Recoverable grouped aggregation is intended for long-running summaries such as account balances, transaction counts, and ledger totals:

await foreach (var total in session.GroupAggregateRecoverableSpill(
    (offset, cancellationToken) => ReadTransactionsFromOffset(offset, cancellationToken),
    operationId: "daily-account-totals",
    keySelector: transaction => transaction.AccountId,
    seedFactory: () => 0L,
    accumulate: (sum, transaction) => sum + transaction.AmountInCents,
    resultSelector: (accountId, sum) => new AccountTotal(accountId, sum),
    cancellationToken: cancellationToken))
{
    await WriteTotal(total, cancellationToken);
}

The active partition is aggregated again after output interruption. Therefore the key selector, seed factory, accumulator, and result selector must be deterministic and free of external side effects. The downstream sink must handle the documented at-least-once replay window.

GroupJoinStreamRecoverableSpill provides the same two independent source offsets as recoverable ExceptBy while preserving the streaming hot-key path. If a right partition cannot fit after recursive refinement, a bounded batch of left records shares each disk scan and receives matches through asynchronous streams. Acknowledged left records are skipped without rerunning the result selector. The selector must consume the match stream before returning and tolerate being called again for an uncheckpointed output after interruption.

Status

This is a correctness-first prototype, not yet a production banking library. It now includes recursive partitioning, session memory leases, bounded merge fan-in, pooled asynchronous block I/O, disk quotas, abandoned-session cleanup, checksummed and schema-fingerprinted blocks, optional AES-GCM encryption, source-generated codecs, private per-user session directories, explicit caller checkpoints, operator-managed recovery for external ordering, recoverable distinct, ExceptBy, grouped aggregation, and streaming joins, plus optional process private-memory monitoring. The materialized-list join remains available for groups that fit RAM; recoverable workloads should use the streaming join. Remaining work includes secure-deletion policy and statistically repeated benchmarks on multiple storage devices. Transactional output integration is available when the destination implements ISpillTransactionalSink<T>.

Local failure qualification

The failure harness uses child processes and generated integer records. It does not call a hosted service or require a paid dataset:

dotnet build tools/SpillFlow.FailureHarness -c Release --no-restore
dotnet run -c Release --no-build --project tools/SpillFlow.FailureHarness

It hard-kills recoverable ordering during reading, merging, and output, then verifies the exact combined sequence after restart. It also requires fail-closed behavior for a corrupted block, missing run, truncated checkpoint, malformed manifest, and disk-quota exhaustion. A timestamped JSON evidence report is written under artifacts/failure-harness.

Security

Temporary blocks can be protected with a caller-managed AES-GCM key by setting SpillOptions.EncryptionKey. SpillFlow never stores or recovers the key. Do not hard-code production keys. Session directories use a protected current-user-only ACL on Windows and owner-only 0700 permissions on Unix by default. Permission setup fails closed; ProtectTemporaryDirectory = false is an explicit compatibility escape hatch. See the security policy and current limitations.

Contributing

See the contribution guide. This project uses the MIT License.

dotnet test SpillFlow.sln -c Release
dotnet run --project benchmarks/SpillFlow.Benchmarks -c Release -- 1000000

See BENCHMARK_RESULTS.md for the isolated 100-million-record SpillFlow versus ExternalSort matrix under a 1 GiB cap, plus a supplemental billion-row result. See the external memory comparison for the repeatable 10-million-record RAM gate and its measured scope.

For a simple but complete explanation of the architecture, operator algorithms, resource strategy, ExternalSort comparison, benchmark evidence, and current limits, see the architecture guide.

Documentation

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  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.
  • net8.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
0.3.0-alpha.9 78 8/26/2026

Alpha 9 reduces Distinct peak memory, adds recovery metadata limits and reparse-point protection, clears session key copies on disposal, and adds independently repeatable ExternalSort memory comparison tooling. See CHANGELOG.md for details.