Map-Reduce & High-Volume Compute
Status: DRAFT v0.1 — part of the Patterns "at enterprise scale" set.
Some enterprise workloads are not "handle a request" but "calculate over a very large dataset, fast" — revalue a book of a million positions at end of day, run a risk simulation across thousands of scenarios, reprice a portfolio when the curve moves, score a batch of transactions. The shape that fits is map-reduce / scatter-gather: split the work into independent shards, run them in parallel across many workers (the map), and combine the results (the reduce).
Benzene has no dedicated map-reduce primitive — and that is fine, because it composes cleanly from parts Benzene does give you. This document is honest about which parts are built-in and which you assemble, and works an end-of-day portfolio risk calculation through it.
What's built-in, what you compose
Be clear-eyed about this up front — it is the difference between using the framework and fighting it:
| Piece | Status |
|---|---|
| Scatter — dispatch N units of work concurrently | Composed — Task.WhenAll over SendAsync (to core services / Lambdas), or concurrent Lambda-to-Lambda invokes. Ordinary async fan-out; the sender is stateless per call. |
| Bounded parallelism — a cap on how many run at once | Built-in helper — BoundedFanOut.WhenAllAsync(source, body, maxDegreeOfParallelism) (semaphore-gated Task.WhenAll, results in source order); ConcurrentRequests + BoundedConcurrentDispatcher on self-hosted workers. |
| One message → many transports | Built-in — UseParallel((..),(..)) on an outbound route, all-must-succeed. Fan-out publish, not scatter-gather of distinct work. |
| A fixed, heterogeneous parallel step set with rollback | Built-in — a saga stage runs its steps concurrently (Task.WhenAll) and compensates on failure. |
| Reduce — aggregate the workers' results | Composed — entirely app-level. There is no built-in response aggregator; you write the fold. |
So: the map is a fan-out you compose; the reduce is a fold you write. Benzene supplies the transport, the topic addressing, the bounded-concurrency helper, and (for a fixed step set) the saga — it does not supply a scatter-gather-with-reduction API, and you should not go looking for one.
The shape
A coordinator service owns the map-reduce; the workers are ordinary Benzene services (or Lambdas) that each compute one shard:
┌──────────────────────────────────────────┐
trigger ──────► │ COORDINATOR │
(schedule / API) │ 1. split dataset into shards │
│ 2. scatter: fan out a job per shard │
│ 3. gather + reduce the partial results │
└──────────────────────────────────────────┘
│ │ │ │ │
SendAsync("valuation:shard", …) × N (bounded fan-out)
▼ ▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│worker│ │worker│ │worker│ │worker│ │worker│ each: compute one shard,
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘ return a partial result
- Map (scatter): the coordinator issues one request per shard, concurrently, with a concurrency cap so it doesn't open ten-thousand simultaneous invocations.
- Reduce (gather): each worker returns a partial result (a sub-total, a partial risk vector); the coordinator folds them into the final answer.
- Workers are stateless and independent — the property that lets them scale horizontally and retry individually. Each is transport-neutral: run it as a Lambda for burst parallelism, or as a container for steady load, without changing its handler.
Two-level scatter for very large fan-out
At a million positions, one coordinator issuing a million calls is itself a bottleneck. Shard
hierarchically: the coordinator scatters to a modest number of partition workers (say, one
per book or per asset class), and each partition worker scatters again over its slice and reduces
locally, returning a partial to the coordinator's final reduce. Fan-out becomes a tree; each level
stays a bounded fan-out. This is the serverless equivalent of a map-reduce shuffle, assembled from
the same SendAsync + fold at each level.
How you build it with Benzene
Scatter with a concurrency cap
(informative, .NET) The map is a bounded parallel fan-out — BoundedFanOut.WhenAllAsync keeps the
in-flight count under a ceiling and returns results in source order, so the reduce is
deterministic:
// shards: the dataset split into independent units of work
var partials = await BoundedFanOut.WhenAllAsync(
shards,
shard => _sender.SendAsync<ValueShard, ShardResult>("valuation:shard", shard),
maxDegreeOfParallelism: 64);
var total = partials
.Where(p => p.IsSuccessful)
.Aggregate(RiskVector.Zero, (acc, p) => acc + p.Payload); // the reduce — your fold
On AWS the worker call resolves to a Lambda-to-Lambda invoke (service communication): cheap, fast, and burst-parallel — a thousand shards become a thousand concurrent Lambdas, each billed only for its own runtime. For a genuinely uniform "same calculation over a partitioned collection", that burst model is Benzene's sweet spot.
The worker
A worker is a normal handler — one shard in, one partial out:
[Message("valuation:shard")]
public class ValueShard : IMessageHandler<ValueShard, ShardResult>
{
public Task<IBenzeneResult<ShardResult>> HandleAsync(ValueShard shard)
=> Task.FromResult(BenzeneResult.Ok(Revalue(shard.Positions, shard.MarketData)));
}
Handling partial failure
Fan-out at scale will have stragglers and failures. Two honest options, and the choice is a business decision:
- All-or-nothing: if any shard fails, the whole calculation is invalid (a regulatory number that must be complete). Model the map-reduce as a saga so a failure compensates/aborts the run cleanly, or fail the coordinator and let the trigger re-run it.
- Best-effort with retry: retry failed shards (
.UseRetry(n)on the worker route), and only fail the run if a shard is still failing after its budget. Idempotent workers (UseIdempotency) make retries safe. Report which shards were retried so the number's provenance is auditable.
Never silently drop a failed shard into the reduce — a partial total presented as complete is the worst outcome. If coverage was reduced, the result must say so.
Worked example: end-of-day portfolio risk
The system: each evening, revalue and risk-assess a book of ~1M positions against the day's closing curves, produce the firm's risk numbers, and store them for reporting and regulatory submission.
- Trigger. A schedule fires the risk coordinator (a Benzene service behind a timer trigger).
- Split. The coordinator partitions the book by portfolio into a few hundred shards (a shard sized so a worker finishes in seconds).
- Scatter. It fans out
SendAsync("risk:shard", shard)under aBoundedFanOutcap of, say, 128 — a few hundred Lambda workers run concurrently, each revaluing its portfolio against the curves. - Workers compute. Each
risk:shardhandler revalues its positions and returns a partial risk vector (P&L, greeks, VaR contributions). Workers are stateless and idempotent, so a retried shard is safe. - Reduce. The coordinator folds the partial vectors into the firm-level risk — deterministically,
because
BoundedFanOutreturns results in shard order. - Persist + publish. The final numbers are written (they become the authoritative end-of-day
figures) and a
risk:completedevent is emitted — from which a reporting read model projects the regulatory views and an event-sourced ledger records the run for audit.
The whole thing is minutes of wall-clock for a job that is hours if run serially — because the map is a burst of hundreds of independent Lambdas — and every part of it is an ordinary Benzene handler plus a fold you own.
When to use it
- Use it for large, partitionable computations where the work per shard is independent and the results combine — batch revaluation, scenario/Monte-Carlo risk, bulk scoring, report generation over a big dataset.
- Don't use it when the computation is not partitionable (each step depends on the previous — that is a sequential pipeline, or a stream if it's ordered records), or when the dataset is small enough that one worker handles it (the fan-out overhead isn't worth it).
Checklist
Map-reduce is well-formed when:
- Work is split into independent shards sized for a few seconds each.
- Scatter is a bounded fan-out (
BoundedFanOut/ a concurrency cap), not an unboundedTask.WhenAllover everything. - Workers are stateless and idempotent, so shards retry safely.
- The reduce is a deterministic fold you own (results in shard order).
- Partial failure has an explicit policy (all-or-nothing vs best-effort + retry); a reduced-coverage result says so.
- Very large fan-outs shard hierarchically (a tree of bounded fan-outs).
See also: service communication (the Lambda-to-Lambda calls the scatter rides on) and stream processing (for ordered, rolling computations rather than batch fan-out).