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
dotnet add package EvaluatedApplications.EvalAppdotnet add package EvaluatedApplications.EvalApp.NeuralCommercial 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.
Pick a gate bound, then replay the run. Drag the scene or use the arrow keys while it is focused to turn and tilt it.
Gate bound 4: all 24 jobs finished in 280 ms. At most 4 ran at once; the longest wait at the gate was 233 ms.
- Gate bound
- 4
- All jobs finished in
- 280 ms
- Most running at once
- 4
- Longest wait
- 233 ms
Data behind the diagram
| Gate bound | All jobs finished in (ms) | Most running at once | Longest wait (ms) | Mean wait (ms) | Jobs per second |
|---|---|---|---|---|---|
| Bound 1 | 1,113 | 1 | 1,067 | 532 | 22 |
| Bound 2 | 557 | 2 | 511 | 256 | 43 |
| Bound 4 | 280 | 4 | 233 | 116 | 86 |
| Bound 6 | 189 | 6 | 143 | 72 | 127 |
| Bound 8 | 139 | 8 | 92 | 46 | 173 |
| Bound 12 | 187 | 12 | 93 | 28 | 128 |
| Bound 24 | 410 | 24 | 0 | 0 | 59 |
| Job | Bound 1 | Bound 2 | Bound 4 | Bound 6 | Bound 8 | Bound 12 | Bound 24 |
|---|---|---|---|---|---|---|---|
| Job 1 | 0 to 43 | 0 to 48 | 0 to 46 | 0 to 48 | 0 to 46 | 0 to 47 | 0 to 48 |
| Job 2 | 43 to 91 | 0 to 48 | 0 to 46 | 0 to 48 | 0 to 46 | 0 to 47 | 0 to 48 |
| Job 3 | 91 to 137 | 48 to 95 | 0 to 46 | 0 to 48 | 0 to 46 | 0 to 47 | 0 to 48 |
| Job 4 | 137 to 183 | 48 to 95 | 0 to 46 | 0 to 48 | 0 to 46 | 0 to 47 | 0 to 48 |
| Job 5 | 183 to 229 | 95 to 140 | 46 to 93 | 0 to 48 | 0 to 46 | 0 to 47 | 0 to 48 |
| Job 6 | 275 to 322 | 140 to 186 | 46 to 93 | 0 to 48 | 0 to 46 | 0 to 47 | 0 to 48 |
| Job 7 | 229 to 275 | 95 to 140 | 46 to 93 | 48 to 95 | 0 to 46 | 0 to 47 | 0 to 48 |
| Job 8 | 323 to 370 | 140 to 186 | 46 to 93 | 48 to 95 | 46 to 92 | 0 to 47 | 0 to 48 |
| Job 9 | 370 to 417 | 186 to 233 | 93 to 140 | 48 to 95 | 0 to 46 | 0 to 93 | 0 to 64 |
| Job 10 | 417 to 463 | 186 to 233 | 93 to 140 | 48 to 95 | 46 to 92 | 0 to 62 | 0 to 64 |
| Job 11 | 463 to 509 | 233 to 278 | 93 to 140 | 48 to 95 | 46 to 92 | 0 to 62 | 0 to 96 |
| Job 12 | 601 to 649 | 233 to 278 | 93 to 140 | 48 to 95 | 46 to 92 | 0 to 78 | 0 to 80 |
| Job 13 | 509 to 555 | 278 to 325 | 140 to 186 | 95 to 143 | 46 to 92 | 47 to 141 | 0 to 80 |
| Job 14 | 555 to 601 | 278 to 325 | 140 to 186 | 95 to 143 | 46 to 92 | 47 to 125 | 0 to 112 |
| Job 15 | 649 to 696 | 325 to 371 | 140 to 186 | 95 to 143 | 46 to 92 | 47 to 109 | 0 to 142 |
| Job 16 | 696 to 742 | 325 to 371 | 140 to 186 | 95 to 143 | 46 to 92 | 47 to 125 | 0 to 158 |
| Job 17 | 742 to 788 | 371 to 418 | 186 to 233 | 95 to 143 | 92 to 139 | 47 to 125 | 0 to 174 |
| Job 18 | 788 to 835 | 371 to 418 | 186 to 233 | 95 to 143 | 92 to 139 | 47 to 141 | 0 to 205 |
| Job 19 | 835 to 882 | 418 to 464 | 186 to 233 | 143 to 189 | 92 to 139 | 47 to 125 | 0 to 236 |
| Job 20 | 882 to 929 | 418 to 464 | 233 to 280 | 143 to 189 | 92 to 139 | 47 to 141 | 0 to 267 |
| Job 21 | 974 to 1020 | 464 to 511 | 186 to 233 | 143 to 189 | 92 to 139 | 62 to 141 | 0 to 299 |
| Job 22 | 929 to 974 | 464 to 511 | 233 to 280 | 143 to 189 | 92 to 139 | 62 to 141 | 0 to 330 |
| Job 23 | 1020 to 1067 | 511 to 557 | 233 to 280 | 143 to 189 | 92 to 139 | 78 to 172 | 0 to 362 |
| Job 24 | 1067 to 1113 | 511 to 557 | 233 to 280 | 143 to 189 | 92 to 139 | 93 to 187 | 0 to 410 |
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.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");Complete: 89.991This 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.
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);run 1: 16 items, success True
run 2: 16 items, success True
run 3: 16 items, success TrueGood 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.
Evidence
Method and every number- 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
Related
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.