Cloud & resilience patterns · C# / .NET

Scheduler Agent Supervisor

Coordinate a multi-step job that spans several services as one operation. Retry what is transient, recover from crashed processes and missed deadlines, and when a step truly cannot succeed, undo the work already done, so the whole thing succeeds or fails cleanly.

Scheduler: runs the workflow Agent: talks to one service Supervisor: spots stuck steps State store: the memory
Jump to the failure simulator ↓
Scheduler Agent A Agent B State store Supervisor
On this page
  1. 1. Why it exists
  2. 2. Mental model
  3. 3. The actors
  4. 4. The state store
  5. 5. Failure simulator
  6. 6. Failure catalogue
  7. 7. Key concepts
  8. 8. Tuning the timings
  9. 9. C# implementation
  10. 10. When to use / not
  11. 11. Real-world scenarios
  12. 12. Related patterns
  13. 13. Pitfalls & testing
  14. 14. Cheat sheet

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 idea in one sentence: write down the progress of every step in a durable store with a deadline, let a separate Supervisor look for steps whose deadline has passed, and have it arrange either a retry or a full undo.

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.

Head chef= Scheduler. Reads the ticket, decides the order of dishes, calls them out.
Line cooks= Agents. Each owns one station (grill, fryer) and knows its quirks.
Ticket rail= State store. Every ticket shows what is cooking, who has it and when it is due.
Expediter= Supervisor. Walks the rail: "Table 7's steak is 5 min late. Refire it."
Comp the meal= Compensation. Can't serve it? Cancel the rest and refund.

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.
Scaling note: real systems run many Schedulers, many Agents and sometimes several Supervisors. Supervisors must coordinate so they don't both recover the same step. Use the Leader Election pattern (for example, a blob lease) so only one is active at a time.

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:

FieldMeaningWho writes it
OrderId / StepNoWhich task and which step. Also your correlation ID in logs and traces.Submission
LockedByThe Scheduler instance that owns the step right now. Empty means anyone may claim it.Scheduler (claim), Supervisor (reset)
CompleteByThe 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)
ProcessStatePending Processing Processed Error (plus Compensated when you undo)Scheduler, Supervisor
FailureCountHow many times the step has been recovered. Crossing a threshold means the fault is assumed non-transient.Supervisor
IdempotencyKeyStable ID sent to the service on every retry, e.g. ORD-1042:charge.Submission
ETag / row versionOptimistic concurrency so claims are exclusive and atomic.Store
CompensationDataWhat you need to undo the step later (payment ID, booking reference).Scheduler after success
ReplyToOptional private status queue for the client app, so it can be told "received / completed / failed".Submission

The life of one step

Pending Processing Processed Error Compensated claim agent replies OK permanent error CompleteBy expired, failures < N: Supervisor resets expired, failures ≥ N undo earliersteps

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.

Inject on next step:
Sim clock: t = 0 s Order: Not started Armed fault: none
Ready.Press Play to run order ORD-1042, or Step to go one beat at a time.

State store (one row per step)

#StepStatusLockedByCompleteByFails

Event log

    Code walkthrough

    Highlighted lines follow the simulator.

    
                
    Try these in order: 1) plain Play, 2) Agent crash, 3) Slow service, 4) Duplicate message, 5) Scheduler crash, 6) Business error on step 2 (arm it after step 1 finishes), 7) Long outage, 8) Agent + Supervisor crash.

    6. Failure catalogue: what breaks and who fixes it

    FailureWhat you observeWho handles itHow
    Network blip, 503, 429One call fails, the next succeedsAgentRetry with exponential back-off and jitter, inside the complete-by budget
    Dependency down for minutesEvery call failsAgent, then SupervisorCircuit breaker fails fast; deadline passes; Supervisor retries later with back-off
    Slow serviceNo reply before CompleteByAgent stops silently; SupervisorRow is Processing + expired, so reset to Pending and retry
    Agent process crashNo reply at allPlatform restarts it; SupervisorSame as above: expired row, retry with the same idempotency key
    Scheduler crashReplies wait in the queuePlatform starts a new instanceNew instance reads the store and reply queue and carries on
    Supervisor crashExpired rows not handled for a whileStandby SupervisorLeader lease expires; another instance takes over. A timer-scheduled Supervisor also simply runs again
    Duplicate messageSame request handled twiceAgent + remote serviceIdempotency key on the call; Scheduler ignores replies for rows not in Processing
    Late reply after a resetReply arrives for an attempt that was abandonedSchedulerCheck status, deadline, or attempt number before accepting
    Permanent business error"Card declined", "out of stock"SchedulerMark Error, compensate earlier steps, notify user/operator. Never retry
    Retries exhaustedFailureCount reaches NSupervisorMark Error, raise alert, ask Scheduler to undo
    Compensation failsRefund call errorsScheduler, then a humanCompensations are idempotent and retried; after that, dead-letter queue and a runbook
    Poison messageHandler crashes on every deliveryMessage brokerMax delivery count, then dead-letter queue and alert
    State store unavailableNothing can claim or recordEveryone waitsIt is the critical dependency: choose a highly available, replicated store
    Clock skewSteps expire too earlyDesignUse 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.

    Worst-case time to notice a dead step
    complete-by + one interval
    Worst-case time before giving up
    N attempts + back-offs
    Supervisor scans per day
    per Supervisor leader

    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 rolePlain .NET 8/10 (hand-built)Azure Durable Functions (isolated worker)
    SchedulerBackgroundService that claims Pending rows and sends requestsOrchestrator function; the runtime checkpoints its progress
    AgentQueue-triggered handler wrapping one service with a Polly v8 pipelineActivity function (+ AddStandardResilienceHandler() on its HttpClient)
    SupervisorBackgroundService with PeriodicTimer + leader leaseMostly built in: replay after crashes, RetryPolicy; optional timer-triggered sweeper
    State storeAzure Table / Cosmos DB / SQL rows with ETagOrchestration history in the task hub (Durable Task Scheduler, Azure Storage or MSSQL backend)
    Complete-byCompleteBy column + CancelAfterRace the activity against ctx.CreateTimer with Task.WhenAny
    Failure thresholdFailureCount columnRetryPolicy(maxNumberOfAttempts, …) or a custom retry handler
    CompensationScheduler sends undo requests in reverse ordertry/catch around steps + an undo stack
    MessagingAzure Service Bus request and reply queuesInternal 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 from Microsoft.DurableTask (TaskOrchestrationContext, TaskOptions, RetryPolicy).
    • Failures arrive as TaskFailedException in the isolated model (in-process used FunctionFailedException).
    • 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.CurrentUtcDateTime not DateTime.UtcNow, ctx.NewGuid() not Guid.NewGuid(), no direct I/O, and log through ctx.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

    Agents: inventory, payment, courier · Complete-by: ~30 s per step

    1. Reserve stock
    2. Charge card (idempotency key per order)
    3. 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

    Agents: ARM/Bicep deployer, DNS, certificate authority · Complete-by: minutes

    1. Create resource group and network
    2. Create VM or container app
    3. 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

    Agents: airline GDS, hotel, car rental · Complete-by: 60 s (hold), hours (ticketing)

    1. Hold flight seat
    2. Book hotel
    3. 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

    Agents: core banking, fraud check, notification · Complete-by: seconds

    1. Fraud and limits check
    2. Debit source
    3. 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

    Agents: Entra ID, Microsoft 365 licensing, HR system, laptop vendor · Complete-by: minutes to days

    1. Create account and groups
    2. Assign licences, mailbox
    3. 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

    Agents: number porting, network provisioning (HLR/HSS), billing

    1. Reserve number or start port
    2. Provision on network
    3. 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

    Agents: EHR (FHIR), lab system, billing/insurance

    1. Create order in lab system
    2. Track specimen
    3. 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

    Agents: virus scan, GPU transcoder, thumbnailer, CDN · Complete-by: proportional to file length

    1. Scan upload
    2. Transcode to several renditions
    3. 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

    Agents: partner SFTP, validator, data warehouse loader, report refresher

    1. Pull partner files
    2. Validate and stage
    3. 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

    Agents: device registry, update service, telemetry checker

    1. Stage firmware to a device ring
    2. Push and wait for devices to report
    3. 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

    Agents: OCR, LLM extraction, validation, ERP posting

    1. OCR the invoice
    2. Extract fields with a model
    3. 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

    Agents: payment authorisation, restaurant tablet, courier dispatch · Complete-by: very short

    1. Authorise card
    2. Restaurant accepts
    3. 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 TimeProvider everywhere and use FakeTimeProvider (package Microsoft.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.