1. Why this pattern exists
A single business task often needs several remote calls: reserve stock, charge a card, book a courier. Each call can fail for different reasons, and they fail independently. You cannot wrap three different services in one ACID database transaction, so you need another way to make the whole task end up either fully done or cleanly undone.
Transient faults
A network blip, a 503, a throttled API (429). Usually fixed by simply trying again a moment later, which is the Retry pattern.
Long-lasting faults
A dependency is down for ten minutes. Hammering it makes things worse. You need to wait, back off, and eventually decide to give up.
Process failures
The worker running your code crashes mid-step. Nobody is left to report what happened. Without a watchdog, the order sits "in progress" forever.
The pattern was first described by Clemens Vasters (Microsoft) from his work on early Windows Azure, where queues and storage could misbehave and machines could be recycled at any time. It is documented today in the Azure Architecture Center's cloud design patterns, and the same idea sits inside modern workflow engines such as Azure Durable Functions.
2. Mental model: a restaurant kitchen
If you remember one picture, remember this one. A dinner order is a multi-step task handled by different stations.
Notice what the expediter does not do: they don't cook, and they don't decide the recipe. They only notice lateness and trigger "try again" or "give up". If a cook faints, the restaurant (the platform) brings in another cook; the expediter just makes sure the late ticket gets redone.
3. The actors and their exact responsibilities
These are logical roles. Physically they can be separate services, one process, functions, or containers; several logical agents can live in one web service.
Scheduler
- Runs the steps in the right order (a pipeline or workflow).
- Records each step's state in the store: not started, running, completed.
- Sets a complete-by time (deadline) for each step.
- Talks to Agents with asynchronous request/response messages, usually queues.
- Owns business logic, including how to compensate.
- After a restart, works out what was in flight from the store and resumes.
Plays the same role as a Process Manager (Enterprise Integration Patterns).
Agent
- Wraps calls to one remote service or resource.
- Does its own error handling and retries (Retry, Circuit Breaker), but only inside the complete-by window.
- Passes a stable identifier on every attempt so the service can de-duplicate.
- If the deadline passes: stops and sends nothing, not even an error. Another agent instance may already be redoing the step.
- If it hits a permanent, non-transient error it can reply with an error straight away.
Supervisor
- Runs periodically (timer) and reads the step states.
- Finds steps that are "running" but past their complete-by.
- Increments a failure count; below the threshold, it arranges a retry (often with back-off).
- Above the threshold, it marks the step as failed, alerts someone, and asks the Scheduler to undo the task.
Not its job restarting crashed Schedulers or Agents (the hosting infrastructure does that), and knowing business operations or how to compensate (the Scheduler's workflow does that).
State store
- Durable record of each task and each step: status, owner, deadline, failure count.
- The single source of truth that lets any instance take over from any other.
- Must support atomic, exclusive updates (ETag, row version, conditional update) so two Schedulers never grab the same step.
- Can be replicated for more resilience, because everything depends on it.
4. The state store, field by field
Microsoft's reference example stores one record per order. In a multi-step workflow you store one record per step. These fields carry the whole pattern:
| Field | Meaning | Who writes it |
|---|---|---|
OrderId / StepNo | Which task and which step. Also your correlation ID in logs and traces. | Submission |
LockedBy | The Scheduler instance that owns the step right now. Empty means anyone may claim it. | Scheduler (claim), Supervisor (reset) |
CompleteBy | The deadline. A step that is still Processing after this time is presumed failed. The pattern deliberately does not distinguish "slow" from "dead". | Scheduler, Supervisor (extend) |
ProcessState | Pending Processing Processed Error (plus Compensated when you undo) | Scheduler, Supervisor |
FailureCount | How many times the step has been recovered. Crossing a threshold means the fault is assumed non-transient. | Supervisor |
IdempotencyKey | Stable ID sent to the service on every retry, e.g. ORD-1042:charge. | Submission |
ETag / row version | Optimistic concurrency so claims are exclusive and atomic. | Store |
CompensationData | What you need to undo the step later (payment ID, booking reference). | Scheduler after success |
ReplyTo | Optional private status queue for the client app, so it can be told "received / completed / failed". | Submission |
The life of one step
5. Failure simulator: break things and watch recovery
An order runs through three steps, each owned by an Agent that wraps one remote service. Press Play for the happy path, or arm a fault first: it hits the next step that gets dispatched. Every beat highlights the matching lines in the C# code on the right. Click any line of the event log to jump back to that explanation.
State store (one row per step)
| # | Step | Status | LockedBy | CompleteBy | Fails |
|---|
Event log
Code walkthrough
Highlighted lines follow the simulator.
6. Failure catalogue: what breaks and who fixes it
| Failure | What you observe | Who handles it | How |
|---|---|---|---|
| Network blip, 503, 429 | One call fails, the next succeeds | Agent | Retry with exponential back-off and jitter, inside the complete-by budget |
| Dependency down for minutes | Every call fails | Agent, then Supervisor | Circuit breaker fails fast; deadline passes; Supervisor retries later with back-off |
| Slow service | No reply before CompleteBy | Agent stops silently; Supervisor | Row is Processing + expired, so reset to Pending and retry |
| Agent process crash | No reply at all | Platform restarts it; Supervisor | Same as above: expired row, retry with the same idempotency key |
| Scheduler crash | Replies wait in the queue | Platform starts a new instance | New instance reads the store and reply queue and carries on |
| Supervisor crash | Expired rows not handled for a while | Standby Supervisor | Leader lease expires; another instance takes over. A timer-scheduled Supervisor also simply runs again |
| Duplicate message | Same request handled twice | Agent + remote service | Idempotency key on the call; Scheduler ignores replies for rows not in Processing |
| Late reply after a reset | Reply arrives for an attempt that was abandoned | Scheduler | Check status, deadline, or attempt number before accepting |
| Permanent business error | "Card declined", "out of stock" | Scheduler | Mark Error, compensate earlier steps, notify user/operator. Never retry |
| Retries exhausted | FailureCount reaches N | Supervisor | Mark Error, raise alert, ask Scheduler to undo |
| Compensation fails | Refund call errors | Scheduler, then a human | Compensations are idempotent and retried; after that, dead-letter queue and a runbook |
| Poison message | Handler crashes on every delivery | Message broker | Max delivery count, then dead-letter queue and alert |
| State store unavailable | Nothing can claim or record | Everyone waits | It is the critical dependency: choose a highly available, replicated store |
| Clock skew | Steps expire too early | Design | Use one time source (store or TimeProvider) and generous deadlines |
7. Key concepts you need (glossary)
Complete-by (lease, deadline)
An ultimatum: "this step must be reported done by this time in the worst successful case." Too short causes false timeouts and duplicate work; too long delays recovery. Clemens Vasters' original write-up calls it a LockedUntil timeout.
Idempotency
Doing an operation twice has the same effect as once. Required because Agents' steps may run more than once. Achieve it with idempotency keys, upserts, "check then act" on a unique ID, or natural keys.
At-least-once delivery
Queues and workflow runtimes guarantee a message is delivered, not that it is delivered once. Durable Functions activities are also at-least-once. Design for duplicates.
Optimistic concurrency
Update a row only if it hasn't changed since you read it (ETag / rowversion). This is how a claim stays exclusive with many Scheduler instances.
Compensating transaction
A new business action that semantically undoes a completed step (refund, release, cancel). It is not a rollback: history keeps both actions. Compensations can fail too and must be retried and idempotent.
Transient vs non-transient
Transient: likely to fix itself (timeouts, 503, 429). Non-transient: won't (validation failure, card declined, 403). Retry the first, fail fast and compensate on the second.
Back-off and jitter
Wait longer between successive retries (exponential) and randomise the wait (jitter) so thousands of clients don't retry in lock-step and cause a retry storm.
Circuit breaker
After many failures, stop calling the dependency for a while and fail fast. Protects the dependency and frees your Agent's time budget.
Leader election
Pick one active Supervisor among many instances, usually by holding a renewable lease. Prevents duplicate recovery.
Process Manager / orchestration
A central component that holds the state of a multi-step process and decides the next step. The Scheduler is one. The opposite style is choreography, where services react to each other's events.
Transactional outbox
Write the business row and the "message to send" in one local transaction, then publish. Microsoft's example writes the order and its state record in the same operation for the same reason.
Dead-letter queue (DLQ)
Where messages go after too many failed deliveries, for a human or a repair tool to inspect. The last line of defence for poison messages and failed compensations.
Pivot step
The point of no return in a workflow (for example, shipping the parcel). Steps before it must be compensable; steps after it must be retryable until they succeed.
Observability
Use the OrderId as a correlation ID on every message, log and trace (OpenTelemetry). Polly v8 emits metrics for retries and breaker state; alert on Error rows and growing FailureCount.
8. Tuning the timings (interactive)
Three numbers decide how fast you recover and how much load you add. Move the sliders to see the trade-offs.
9. Building it in C# / .NET
The simulator's code panel holds two complete, annotated listings. Here is how the pattern's roles map onto each, plus the pieces worth copying.
| Pattern role | Plain .NET 8/10 (hand-built) | Azure Durable Functions (isolated worker) |
|---|---|---|
| Scheduler | BackgroundService that claims Pending rows and sends requests | Orchestrator function; the runtime checkpoints its progress |
| Agent | Queue-triggered handler wrapping one service with a Polly v8 pipeline | Activity function (+ AddStandardResilienceHandler() on its HttpClient) |
| Supervisor | BackgroundService with PeriodicTimer + leader lease | Mostly built in: replay after crashes, RetryPolicy; optional timer-triggered sweeper |
| State store | Azure Table / Cosmos DB / SQL rows with ETag | Orchestration history in the task hub (Durable Task Scheduler, Azure Storage or MSSQL backend) |
| Complete-by | CompleteBy column + CancelAfter | Race the activity against ctx.CreateTimer with Task.WhenAny |
| Failure threshold | FailureCount column | RetryPolicy(maxNumberOfAttempts, …) or a custom retry handler |
| Compensation | Scheduler sends undo requests in reverse order | try/catch around steps + an undo stack |
| Messaging | Azure Service Bus request and reply queues | Internal queues managed by the runtime |
Atomic claim: only one Scheduler wins
// Azure Table Storage or Cosmos DB: optimistic concurrency with the ETag
public async Task<bool> TryClaimAsync(StepEntity row, string me, DateTimeOffset deadline, CancellationToken ct)
{
row.LockedBy = me;
row.Status = "Processing";
row.CompleteBy = deadline;
try
{
await _table.UpdateEntityAsync(row, row.ETag, TableUpdateMode.Replace, ct);
return true; // we own the step
}
catch (RequestFailedException e) when (e.Status == 412)
{
return false; // another instance won the race
}
}
// SQL Server equivalent: claim only if still free; @@ROWCOUNT = 1 means you own it
// UPDATE StepState SET LockedBy = @me, Status = 'Processing',
// CompleteBy = DATEADD(second, 30, SYSUTCDATETIME())
// WHERE OrderId = @id AND StepNo = @n AND Status = 'Pending' AND LockedBy IS NULL;
Durable Functions: things that changed recently
- Use the isolated worker model. Microsoft's migration guide states the in-process model reaches end of support on 10 November 2026. Packages:
Microsoft.Azure.Functions.Worker.Extensions.DurableTask; types come fromMicrosoft.DurableTask(TaskOrchestrationContext,TaskOptions,RetryPolicy). - Failures arrive as
TaskFailedExceptionin the isolated model (in-process usedFunctionFailedException). - Durable Task Scheduler is now the recommended, fully managed backend. Its Dedicated SKU went GA in November 2025 and its pay-per-use Consumption SKU went GA in spring 2026. It also backs the Durable Task SDKs on Container Apps, AKS and App Service.
- Netherite backend support ends 31 March 2028; Microsoft suggests evaluating Durable Task Scheduler instead.
- Orchestrators must be deterministic: use
ctx.CurrentUtcDateTimenotDateTime.UtcNow,ctx.NewGuid()notGuid.NewGuid(), no direct I/O, and log throughctx.CreateReplaySafeLogger. - Don't retry business errors. Separate transient failures (retry) from validation or authorization failures (fail fast, compensate). Compensation activities must be idempotent because they can run more than once.
Resilience inside an Agent (Polly v8)
Microsoft.Extensions.Http.Polly is deprecated. Use Microsoft.Extensions.Resilience (general pipelines) and Microsoft.Extensions.Http.Resilience (HttpClient). Order matters: a timeout outside the retry is a total budget; a timeout inside it is per attempt.
builder.Services.AddHttpClient<CardGatewayClient>()
.AddStandardResilienceHandler(); // rate limiter, total timeout, retry, circuit breaker, attempt timeout
builder.Services.AddResiliencePipeline("warehouse", p => p
.AddTimeout(TimeSpan.FromSeconds(25)) // total budget (keep it below CompleteBy)
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 4,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
ShouldHandle = new PredicateBuilder().Handle<HttpRequestException>()
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatio = 0.5, MinimumThroughput = 10,
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(30)
})
.AddTimeout(TimeSpan.FromSeconds(5))); // per attempt
Which flavour should I choose?
Prefer a workflow engine
Durable Functions / Durable Task SDKs, Dapr Workflow, Temporal or Logic Apps already give you durable state, replay after crashes, timers, retries and history. You write the Scheduler's logic and the Agents; the engine is your Supervisor and state store.
Hand-build when…
You must use a specific store or broker, run outside supported hosts, integrate with legacy job tables, need custom recovery rules (operator approval, per-tenant quotas), or want the Supervisor to act across many unrelated systems.
10. When to use it, and when not to
Use it when…
- A process in a distributed environment must survive both communication failures and crashed processes.
- A task has several steps that call different remote services or resources.
- Background jobs orchestrate multistep workflows: order processing, resource provisioning, onboarding.
- Partial completion is unacceptable: you need "all or nothing" without distributed transactions.
- Steps can take seconds to hours, and you need to know which ones are stuck.
- You need an audit trail of every step, attempt and failure.
- You want self-healing: lost workers replaced, stalled work resumed automatically.
Avoid it when…
- The task doesn't invoke remote services or resources (a plain in-process method is fine).
- Everything lives in one database: use a normal ACID transaction.
- It is a single idempotent fire-and-forget job: a queue with retries and a DLQ is enough.
- A user is waiting synchronously for a sub-second answer: supervisor intervals add latency.
- Steps can be neither made idempotent nor compensated, and you haven't planned a pivot step.
- A managed workflow engine is available and fits: don't re-implement it.
- Loosely coupled domains where each service should own its reaction to events (choreography fits better).
- The team can't afford to test every failure mode. The pattern is hard to implement well.
11. Real-world scenarios
The same roles appear across very different industries. For each, look at the steps, the deadline, and how undo works.
🛒 E-commerce order
- Reserve stock
- Charge card (idempotency key per order)
- Book courier
Undo: refund, release stock. Watch out: the payment gateway may succeed after your timeout, so always re-check with the same key before charging again.
☁️ Cloud resource provisioning
- Create resource group and network
- Create VM or container app
- Add DNS record, issue TLS certificate
Undo: delete created resources in reverse. Watch out: deployments are long; the Supervisor should poll provisioning state, not assume death.
✈️ Travel booking
- Hold flight seat
- Book hotel
- Reserve car, then confirm all
Undo: cancel holds; some fares are non-refundable, so ticketing is the pivot step and goes last.
🏦 Money transfer / loan payout
- Fraud and limits check
- Debit source
- Credit destination, notify
Undo: reversal entry, never deleting the debit. Watch out: regulators want the full step history; the state store doubles as an audit log.
👩💼 Employee onboarding
- Create account and groups
- Assign licences, mailbox
- Order laptop, print badge (human step)
Undo: disable account, release licence. Watch out: human steps need long deadlines and reminders, not aggressive retries.
📱 eSIM / mobile activation
- Reserve number or start port
- Provision on network
- Create billing account, send QR code
Undo: release number, deprovision. Watch out: carrier porting APIs are slow and flaky, a textbook use of long complete-by plus back-off.
🧪 Healthcare lab order
- Create order in lab system
- Track specimen
- Post results to EHR, bill insurer
Undo: clinical data is never deleted; send cancellation or correction messages instead. Operator alerts matter more than automatic undo here.
🎬 Media processing pipeline
- Scan upload
- Transcode to several renditions
- Thumbnails, publish to CDN
Undo: delete partial renditions. Watch out: set the deadline per job (a 3-hour film is not stuck at minute 10).
🌙 Nightly ETL / data batch
- Pull partner files
- Validate and stage
- Load, refresh dashboards
Undo: swap back to yesterday's partition. Watch out: the Supervisor is what catches the job that silently hung at 2 a.m.
📡 IoT firmware rollout
- Stage firmware to a device ring
- Push and wait for devices to report
- Verify health, widen the ring
Undo: roll the ring back to the last good version. Watch out: offline devices are normal, so threshold by percentage, not single failures.
🤖 AI document processing
- OCR the invoice
- Extract fields with a model
- Validate; post to ERP or send to manual review
Watch out: model APIs throttle (429) and models can retry internally while the workflow retries the whole step, so bound both. Low confidence is a business outcome routed to a human, not a retry.
🍔 Food delivery
- Authorise card
- Restaurant accepts
- Assign courier
Undo: void the authorisation, notify the restaurant. Watch out: customers are watching live, so short deadlines and fast Supervisor intervals.
13. Pitfalls and how to test
Common mistakes
- Agents that aren't idempotent, causing double charges on retry.
- Agents that reply after the deadline and race a newer attempt.
- Scheduler keeping workflow state in memory, so a crash loses it.
- Business rules or compensation logic inside the Supervisor.
- Several Supervisors without a lease, recovering the same step twice.
- Complete-by set near the p99, producing constant false timeouts.
- Retrying permanent errors (declined card, validation failure).
- Not saving compensation data (payment ID) when a step succeeds.
- Retry without jitter or circuit breaker: retry storms during an outage.
- No alert on Error rows, so failed orders wait silently.
- Forgetting to tell the caller what happened.
Testing every failure mode
- Time: inject
TimeProvidereverywhere and useFakeTimeProvider(packageMicrosoft.Extensions.TimeProvider.Testing) to jump past deadlines instantly. - Chaos: Polly v8.3+ includes chaos strategies (fault, latency, outcome) to inject failures in test and staging.
- Kill tests: stop a Scheduler, Agent and Supervisor mid-step and assert the order still finishes or compensates.
- Duplicates: deliver every message twice in tests.
- Platform: Azure Chaos Studio for real infrastructure faults.
[Fact]
public async Task Expired_step_is_reset_for_retry()
{
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-25T10:00:00Z"));
var store = new InMemoryStateStore();
await store.AddAsync(StepState.Processing("ORD-1", stepNo: 2,
completeBy: time.GetUtcNow().AddSeconds(30)));
var supervisor = new Supervisor(store, new AlwaysLeader(), new NullAlerts(), time);
await supervisor.StartAsync(CancellationToken.None);
time.Advance(TimeSpan.FromSeconds(46)); // past CompleteBy + one 15 s tick
var row = await store.WaitForChangeAsync("ORD-1", 2);
Assert.Equal(StepStatus.Pending, row.Status);
Assert.Equal(1, row.FailureCount);
Assert.Null(row.LockedBy);
}
// Chaos in staging: 10% very slow calls, 5% faults
builder.Services.AddResiliencePipeline("payment-chaos", p => p
.AddChaosLatency(0.10, TimeSpan.FromSeconds(40))
.AddChaosFault(0.05, () => new HttpRequestException("chaos")));
14. Cheat sheet
Scheduler
- Order of steps
- Claim atomically
- Set complete-by
- Async request/reply
- Owns compensation
- Stateless between messages
Agent
- One service each
- Retry + breaker inside budget
- Same idempotency key every time
- Silent after deadline
- Fail fast on permanent errors
Supervisor
- Runs on a timer
- Processing + expired = failed
- Count failures
- Retry with back-off or give up
- Leader lease if many
- No business logic
Golden rules: state lives in the store, never in memory · every step is idempotent · every completed step has a recorded way to undo it · deadlines are generous but finite · something always tells a human when automation gives up.