Foundation

EvalApp: a self-tuning async pipeline runtime for .NET

Stop hand-writing concurrency coordination.

  • Release 4.4.1
  • Zero dependencies
  • net8.0 and net10.0
  • Binary-only package
EvaluatedApplications.EvalApp
dotnet add package EvaluatedApplications.EvalApp
EvaluatedApplications.EvalApp.Neural
dotnet add package EvaluatedApplications.EvalApp.Neural

Commercial licence for commercial use. Evaluation and non-commercial use are permitted.Licence terms

The gate

24 jobs are queued at once and every one needs the network. The gate lets a declared number through at a time. Pick a bound and replay the run: all 24 finish in 1,113 ms at a bound of 1, in 139 ms at 8, and in 410 ms when all 24 run at once, because the simulated service slows when it is crowded.

When each job ran with a gate bound of 424 jobs queued at once pass through a gate that admits 4 at a time. All 24 finish in 280 milliseconds. The tables below give every bound.0501001502002501234milliseconds from the start of the run
Data behind the diagram
24 jobs queued at once, by gate bound. Each job waits for the gate, then runs a simulated download of 40 ms that slows once more than 6 are in flight.
Gate boundAll jobs finished in (ms)Most running at onceLongest wait (ms)Mean wait (ms)Jobs per second
Bound 11,11311,06753222
Bound 2557251125643
Bound 4280423311686
Bound 6189614372127
Bound 813989246173
Bound 12187129328128
Bound 24410240059
When each job ran, in milliseconds from the start of the run (admitted to finished)
JobBound 1Bound 2Bound 4Bound 6Bound 8Bound 12Bound 24
Job 10 to 430 to 480 to 460 to 480 to 460 to 470 to 48
Job 243 to 910 to 480 to 460 to 480 to 460 to 470 to 48
Job 391 to 13748 to 950 to 460 to 480 to 460 to 470 to 48
Job 4137 to 18348 to 950 to 460 to 480 to 460 to 470 to 48
Job 5183 to 22995 to 14046 to 930 to 480 to 460 to 470 to 48
Job 6275 to 322140 to 18646 to 930 to 480 to 460 to 470 to 48
Job 7229 to 27595 to 14046 to 9348 to 950 to 460 to 470 to 48
Job 8323 to 370140 to 18646 to 9348 to 9546 to 920 to 470 to 48
Job 9370 to 417186 to 23393 to 14048 to 950 to 460 to 930 to 64
Job 10417 to 463186 to 23393 to 14048 to 9546 to 920 to 620 to 64
Job 11463 to 509233 to 27893 to 14048 to 9546 to 920 to 620 to 96
Job 12601 to 649233 to 27893 to 14048 to 9546 to 920 to 780 to 80
Job 13509 to 555278 to 325140 to 18695 to 14346 to 9247 to 1410 to 80
Job 14555 to 601278 to 325140 to 18695 to 14346 to 9247 to 1250 to 112
Job 15649 to 696325 to 371140 to 18695 to 14346 to 9247 to 1090 to 142
Job 16696 to 742325 to 371140 to 18695 to 14346 to 9247 to 1250 to 158
Job 17742 to 788371 to 418186 to 23395 to 14392 to 13947 to 1250 to 174
Job 18788 to 835371 to 418186 to 23395 to 14392 to 13947 to 1410 to 205
Job 19835 to 882418 to 464186 to 233143 to 18992 to 13947 to 1250 to 236
Job 20882 to 929418 to 464233 to 280143 to 18992 to 13947 to 1410 to 267
Job 21974 to 1020464 to 511186 to 233143 to 18992 to 13962 to 1410 to 299
Job 22929 to 974464 to 511233 to 280143 to 18992 to 13962 to 1410 to 330
Job 231020 to 1067511 to 557233 to 280143 to 18992 to 13978 to 1720 to 362
Job 241067 to 1113511 to 557233 to 280143 to 18992 to 13993 to 1870 to 410
Recorded by running the published EvaluatedApplications.EvalApp package (release 4.4.1). The gate, the queueing and every timestamp are the package's behaviour. Each download is a simulated wait of 40 ms that stretches once more than 6 are in flight.

What EvalApp is

EvalApp replaces the plumbing that every real async .NET codebase ends up writing by hand: a SemaphoreSlim to stop a flood of requests hammering a database, a guessed constant for how many things run at once, a Task.WhenAll inside a try and catch to collect failures, and a rollback path bolted on after something breaks halfway through.

You describe a process as a plain data record and a sequence of steps. EvalApp compiles that into one reusable execution path and gates every resource it touches (CPU, network, disk, database or a pool of your own) at a real bound. When you turn tuning on, it looks for the concurrency that works on the hardware it runs on, instead of a number someone picked once. It is the runtime under the other Evaluated Applications libraries, and it works on its own: no broker, no external service, no config store.

  • One dependency instead of a stack

    Where you would reach for a DI container, MediatR, Polly and TPL Dataflow, EvalApp is a single compiled pipeline that covers fan-out, resource gating, fallback steps and compensation together.

  • The tuner probes, it does not wait for a failure

    A circuit breaker waits for something to break and then responds. The adaptive tuner keeps probing for the operating point that stops things breaking, independently for each instance and with no coordination. A Bayesian mode keeps what it learned across restarts.

  • Whole categories of coordination bugs do not compile

    A saga you open has to be closed before the pipeline builds. A step with a side effect cannot be added without declaring the resource it touches, so there is no way to write an ungated side effect.

  • The builder chain is the architecture

    Read the fluent declaration from top to bottom and it reads like the process it describes. It is the code, not documentation about the code, so it cannot drift out of date the way a diagram does.

  • Nothing to stand up first

    It is a package reference, not infrastructure. Most pipeline libraries want a broker or a database before the first item is processed. EvalApp does not.

Quick start

Define a record, describe the process as steps, build once, then run it as often as you like.
C#
using EvalApp;

Eval.App("Orders")
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep("Validate", d =>
            {
                if (d.Total <= 0) throw new InvalidOperationException("Total must be positive");
                return d with { Status = "Validated" };
            })
            .AddStep("ApplyDiscount", d => d with { Total = d.Total * 0.9m })
            .AddStep("Complete", d => d with { Status = "Complete" })
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

// Build once, at startup. Run as many times as you like.
var result = await pipeline.RunAsync(new OrderData("Alice", 99.99m));

switch (result)
{
    case PipelineResult<OrderData>.Success success:
        Console.WriteLine($"{success.Data.Status}: {success.Data.Total}");
        break;
    case PipelineResult<OrderData>.Failure failure:
        Console.WriteLine($"Failed: {failure.Exception.Message}");
        break;
    case PipelineResult<OrderData>.Skipped skipped:
        Console.WriteLine($"Skipped: {skipped.Reason}");
        break;
}

// Top-level statements come first, so the record goes last.
public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");
Output
Complete: 89.991

This is the complete program, compiled and run against the published package.

Capabilities

  • Fluent pipeline builder

    Eval.App(...) leads to domains, tasks and steps, compiled once into an immutable, reusable ICompiledPipeline<T>.

  • Resource-gated concurrency

    Declare Network, DiskIO, Cpu, Database or a named pool of your own, and every step that touches it contends for the same bound.

  • Adaptive and Bayesian tuning

    A hill-climbing tuner that rediscovers the optimum on each run, or a Bayesian tuner whose performance model survives restarts. Both are opt-in and stay inside the bounds you declare.

  • Parallel ForEach and parallel branches

    Bounded per-item fan-out over a collection, where Tunable.ForItems() lets the tuner pick the count, and a fixed set of concurrent branches with configurable merge strategies.

  • Sagas with compensation

    Chain steps with an undo. When a later step fails, every earlier compensation runs in reverse order.

  • Middleware

    Wrap any step for retry, timing or audit by implementing IStepMiddleware<T>. It is a seam you implement, not a library of ready-made ones.

  • Pipelines that run pipelines

    A compiled pipeline is a value. A step can run another pipeline or itself, so an outer schedule loop can drive an inner per-batch pipeline, or a pipeline can recurse through a tree, with no orchestration code of your own. PipelineRef<T> forward-declares a pipeline, PipelineTrace tells same-named steps apart across levels, and runaway recursion fails cleanly instead of overflowing the stack.

  • A harness for things that are not thread-safe

    Declare an ExclusiveResource once (a GPU context, a native handle, a single-writer file), guard a scope or named branches with it, and a second concurrent entry fails loudly or queues, as you choose. It is re-entrant for recursion, and forks inside a held scope exclude each other.

  • Single-thread hosts

    In a browser, on a one-core process or when you say so, EvalApp detects it once and stops fanning out. ForEach, parallel groups and ChunkedWork run inline, and long runs hand the thread back every few milliseconds so a page stays responsive. On a multi-core host none of it is active.

  • Results as data

    PipelineResult<T> is Success, Failure or Skipped, matched with a switch instead of caught.

  • Offline licence check

    The licence validator every Evaluated Applications package embeds is part of the package. It runs entirely offline with ECDsa P-256 and makes no network call.

EvalApp.Neural: a tuner that keeps learning

A learned policy in place of the built-in heuristic
  • EvaluatedApplications.EvalApp.Neural
  • Release 2.0.3

The built-in tuner is a heuristic. EvalApp.Neural swaps it for a small neural policy that reads each gate's recent history and keeps learning from its own decisions while the program runs. It starts from a model that ships with the package and is never frozen.

It is meant for pipelines with several gates that interact, such as a database, a CPU pool and a disk contending together, where moving one gate changes what the others should do. For a single obvious fan-out the built-in tuner is enough.

  • One line to turn on

    Call WithNeuralTuning() on the app builder, after WithResource and before DefineDomain. Nothing else changes.

  • One model for the process

    NeuralTuning.UseGlobally installs a shared model for every app in the process that has tuning enabled. UseGloballyPersisted saves it to a file as it trains and loads it next time.

  • Coordinated moves

    With several gates it credits the gate it moved last and moves only the one under the most contention, so coupled gates are not all changed at once.

  • No licence key

    It runs the same with or without a licence key.

The same ForEach pipeline, with the learned tuner switched on.

C#
using EvalApp;
using EvalApp.Neural;

Eval.App("NeuralTuned")
    .WithResource(ResourceKind.Network, Tunable.Between(1, 8, 2))
    .WithNeuralTuning()        // after WithResource, before DefineDomain
    .DefineDomain("Orders")
        .DefineTask<Batch>("Fetch")
            .ForEach(
                select: b => b.Items,
                merge: (b, items) => b with { Items = items.ToList() },
                collectionName: "Items",
                configure: sub => sub
                    .Gate(ResourceKind.Network, onWaiting: null, configure: g => g
                        .AddStep("Download", async (int i, CancellationToken ct) => { await Task.Delay(5, ct); return i + 1; })))
        .Run(out ICompiledPipeline<Batch> pipeline)
    .Build();

for (var run = 0; run < 3; run++)
{
    var result = await pipeline.RunAsync(new Batch(Enumerable.Range(0, 16).ToList()));
    Console.WriteLine($"run {run + 1}: {result.GetData().Items.Count} items, success {result.IsSuccess}");
}

public sealed record Batch(List<int> Items);
Output
run 1: 16 items, success True
run 2: 16 items, success True
run 3: 16 items, success True

Good to know

  • It depends on EvalApp and AlgFormer, which are pulled in with it, and it needs .NET 8 or later.
  • UseGlobally and UseGloballyPersisted change a process-wide default, so drive one workload at a time under them.
  • If the EVALAPP_NEURAL_MODEL environment variable is set, any process that loads the assembly installs the persisted tuner and trains against that file. Leave it unset unless you mean it.
  • A model file that does not match the expected shape is replaced silently by a fresh one, so a run that seems to have forgotten what it learned may have discarded a stale file.

Limits

What EvalApp does not do, and where to be careful
  • Work that has to survive a process restart over hours or days belongs to a workflow engine such as Temporal. EvalApp runs in process.
  • Purely reactive resilience, which detects a break and responds to it, is what Polly is for. EvalApp is for the case in between: no infrastructure to stand up, throughput that matters and concurrency that tunes itself.
  • ICompiledPipeline<T> is opaque. It exposes a name and RunAsync. Report() gives a read-only view of the plan, with call counts for each ForEach node.
  • The builder and step-construction path is safe to trim and to compile ahead of time. The tuner's file persistence and a couple of internal caches still use reflection-based JSON and raise trimmer warnings under PublishTrimmed, so leave WithTuning off if that matters for your build.
  • The package ships as a binary with no source and no XML documentation. Author against the root EvalApp namespace; everything public lives there.

Every public type is documented with a worked example in the package readme on NuGet. Open the NuGet page.

recorded runs where the most downloads in flight equalled the gate bound
7 of 7
time to finish 24 jobs at a bound of 1 and at the best bound, 8
1,113 ms to 139 ms
slower with all 24 jobs running at once than at the best bound
2.9 times

Licence

Proprietary. Every package ships as a compiled library. Evaluation and non-commercial development or testing are permitted; commercial, production or revenue-generating use needs a commercial licence from the owner. Read the licence terms.