Dapr Workflow and the Durable Task Framework (.NET / Go)
The Durable Task Framework (DTFx) is Microsoft's original "orchestration as code" replay engine: an orchestrator function is re-executed from the top against an append-only history of typed events, and every scheduling call is matched to its history record by a per-execution sequence number. Dapr Workflow embeds the Go port of that engine (durabletask-go) inside the Dapr sidecar and makes each workflow instance an actor whose history is a run of keys in a state store, with actor reminders as the crash-recovery mechanism. This page covers the family; Azure Durable Functions, the serverless packaging of the same engine, has its own page (sibling).
| Field | Value |
|---|---|
| Language | C# (Azure/durabletask, microsoft/durabletask-dotnet), Go (dapr/durabletask-go, dapr/dapr) |
| License | Apache-2.0 (DTFx .NET, Dapr runtime); MIT (durabletask-go, per the Dapr engine README) |
| Repository | Azure/durabletask · microsoft/durabletask-dotnet · dapr/durabletask-go · dapr/dapr |
| Documentation | Dapr Workflow docs (features, architecture, patterns, versioning); DTFx's History/README.md |
| Category | durable-execution engine (DTFx core, durabletask-go backend) + durable-execution SDK (the .NET and Go authoring surfaces) + event-sourced actors (Dapr's workflow/activity actors) |
| Persistence model | replay |
| Journal store | Pluggable IOrchestrationService backends in DTFx (Azure Storage, Service Bus, MSSQL, in-memory emulator); in Dapr, the actor state store: history-NNNNNN, inbox-NNNNNN, metadata keys per workflow actor |
| Latest release | DurableTask.Core VersionPrefix 3.9.0 (csproj); Microsoft.DurableTask 1.25.0 (Release.props); durabletask-go v0.14.1 (pinned by Dapr's go.mod) |
| Local clone | $REPOS/durabletask at b385165ac10ecebbf183fdfdb07db33307756792 · $REPOS/durabletask-dotnet at bc2bc12ca5ee3a12a6e633250ade5efe6ae90ef4 · $REPOS/durabletask-go at 18cd4b5a26a5d53f127a12dd7aa2d496314f3de6 · $REPOS/dapr at e9f08dc2dfbb37c9d52fa17186c1bbf6d9d94444 · $REPOS/dapr-docs at 78b25330358bbe68b7e0f2d704f02d0432aebb75 |
Last reviewed: September 12, 2026.
Overview
What it solves
A long-running business process — order fulfilment, a rollout, a multi-step release — is naturally written as one sequential function, but a sequential function dies with its process. DTFx keeps the sequential form and makes it survive: the orchestrator function is pure decision logic, every side effect is delegated to an activity (a separately dispatched, at-least-once unit of work), and the engine records what the orchestrator asked for and what came back. After a crash the function is simply run again; every request it makes that the history already answers is satisfied from the history instead of being re-issued, so the function fast-forwards to where it was.
Dapr Workflow packages this for polyglot microservices. The engine lives in the sidecar, the application only pulls work items over a gRPC stream, and durability comes from the actor runtime the sidecar already has: a workflow instance is an actor, its history is that actor's state, and a one-shot reminder guarantees the instance is re-driven if the host dies mid-turn.
Design philosophy
The Dapr docs describe the persistence model in one paragraph (features and concepts):
"Dapr Workflows maintain their execution state by using a technique known as event sourcing. Instead of storing the current state of a workflow as a snapshot, the workflow engine manages an append-only log of history events that describe the various steps that a workflow has taken. When using the workflow SDK, these history events are stored automatically whenever the workflow "awaits" for the result of a scheduled task."
And the consequence the whole family lives with:
"Using this replay technique, a workflow is able to resume execution from any "await" point as if it had never been unloaded from memory. Even the values of local variables from previous runs can be restored without the workflow engine knowing anything about what data they stored."
The engine does not know what the local variables are. It never inspects the function; it only checks that the sequence of scheduling requests the function emits agrees, position by position, with the requests in the history. That is the single invariant everything else — determinism rules, versioning, patching, the nondeterminism error — exists to protect. The DTFx protocol reference states it as the ownership rule (state and history): "The history is the "source of truth". If the orchestration code changes in a non-deterministic way (e.g., adding a new activity call in the middle of existing code), the replay will fail because the code's requests won't match the recorded history."
How it works
The history event model
DTFx's history is a list of HistoryEvent subclasses. The base class carries the fields every event shares (History/HistoryEvent.cs):
[DataContract]
public abstract class HistoryEvent : IExtensibleDataObject
{
[DataMember] public int EventId { get; internal set; }
[DataMember] public bool IsPlayed { get; set; }
[DataMember] public DateTime Timestamp { get; set; }
[DataMember] public virtual EventType EventType { get; private set; }
}EventType enumerates the vocabulary (History/EventType.cs): ExecutionStarted, ExecutionCompleted, ExecutionFailed, ExecutionTerminated, TaskScheduled, TaskCompleted, TaskFailed, SubOrchestrationInstanceCreated, SubOrchestrationInstanceCompleted, SubOrchestrationInstanceFailed, TimerCreated, TimerFired, OrchestratorStarted, OrchestratorCompleted, EventSent, EventRaised, ContinueAsNew, GenericEvent, HistoryState, ExecutionSuspended, ExecutionResumed, ExecutionRewound. Each has a class under src/DurableTask.Core/History/; the ones that matter for replay pair up by id:
| Request event | Resolution event | Correlation |
|---|---|---|
TaskScheduledEvent (Name, Version, Input, Tags) | TaskCompletedEvent (Result) / TaskFailedEvent (Reason, FailureDetails) | TaskScheduledId == request's EventId (source) |
SubOrchestrationInstanceCreatedEvent (Name, InstanceId, …) | SubOrchestrationInstanceCompletedEvent / …FailedEvent | TaskScheduledId == request's EventId |
TimerCreatedEvent (FireAt) | TimerFiredEvent (TimerId, FireAt) | TimerId == request's EventId |
EventSentEvent | — (one-way) | — |
| — (external) | EventRaisedEvent (Name, Input) | matched by name, FIFO, not by id |
Two bookkeeping events bracket every turn. The History/README.md explains them (source): OrchestratorStarted marks "the orchestrator function is starting a new execution. You will see many of these events in your history - one for each time that an orchestrator resumes from an await", and "The timestamp of this event is used to populate the CurrentDateTimeUtc property"; OrchestratorCompleted marks that the function "has awaited and committed any side effects". ExecutionStarted is "always the second event in an orchestration history" and carries Name, Version, Input, Tags, ParentInstance, Generation (source). ContinueAsNewEvent is an ExecutionCompletedEvent subclass (source).
The Go port carries the same model as a protobuf oneof (api/protos/history_events.pb.go): EventId, Timestamp, then ExecutionStarted, ExecutionCompleted, ExecutionTerminated, TaskScheduled, TaskCompleted, TaskFailed, ChildWorkflowInstanceCreated/Completed/Failed (renamed from "sub-orchestration"), TimerCreated, TimerFired, WorkflowStarted/WorkflowCompleted (renamed from "orchestrator started/completed"), EventSent, EventRaised, ContinueAsNew, ExecutionSuspended, ExecutionResumed, and two Dapr additions: ExecutionStalled and DetachedWorkflowInstanceCreated.
The user-facing API
DTFx's abstract OrchestrationContext (source) is the only handle an orchestrator gets. The replay-relevant members:
public abstract class OrchestrationContext
{
[ThreadStatic] public static bool IsOrchestratorThread; // detects illegal async use
public string Version { get; } // from ExecutionStartedEvent
public virtual DateTime CurrentUtcDateTime { get; } // "Replay-safe current UTC datetime"
public bool IsReplaying { get; } // true while past events are replayed
public abstract Task<TResult> ScheduleTask<TResult>(string name, string version, params object[] parameters);
public abstract Task<T> CreateSubOrchestrationInstance<T>(string name, string version, string instanceId, object input);
public abstract Task<T> CreateTimer<T>(DateTime fireAt, T state);
public abstract void SendEvent(OrchestrationInstance orchestrationInstance, string eventName, object eventData);
public abstract void ContinueAsNew(object input);
}The newer .NET SDK adds NewGuid(), documented as "a name-based UUID V5 … The name input used to generate this value is a combination of the orchestration instance ID, the current time, and an internally managed sequence number" (source) — i.e. randomness derived from replay-stable inputs. The Go WorkflowContext exposes the same surface as CallActivity, CallChildWorkflow, CreateTimer, WaitForSingleEvent, ContinueAsNew, IsPatched, with IsReplaying and CurrentTimeUtc as plain fields (source).
The replay algorithm
Each turn, the dispatcher loads the instance's OrchestrationRuntimeState — PastEvents (the stored history) plus NewEvents (the messages that woke the instance) (source) — and hands it to TaskOrchestrationExecutor. The loop is short enough to quote whole (source):
void ProcessEvents(IEnumerable<HistoryEvent> events)
{
foreach (HistoryEvent historyEvent in events)
{
if (historyEvent.EventType == EventType.OrchestratorStarted)
{
var decisionStartedEvent = (OrchestratorStartedEvent)historyEvent;
this.context.CurrentUtcDateTime = decisionStartedEvent.Timestamp;
continue;
}
this.ProcessEvent(historyEvent);
historyEvent.IsPlayed = true;
}
}
this.context.IsReplaying = true;
ProcessEvents(pastEvents);
this.context.IsReplaying = false;
ProcessEvents(newEvents);ExecutionStarted invokes the user function; every other event is routed to a handler on TaskOrchestrationContext. The function runs on a SynchronousTaskScheduler whose queue is executed inline (source), so an await on an unresolved task parks the continuation and returns control to the loop instead of blocking a thread. The Go port has no scheduler to hijack: Await pumps processNextEvent() until its task resolves, and if the history runs out it panics with ErrTaskBlocked, which start() recovers as the normal "yield" (source).
Matching is by sequence number. Every scheduling call takes idCounter++ and files an action under it (source):
public async Task<object> ScheduleTaskInternal(string name, string version, string taskList, Type resultType,
ScheduleTaskOptions options, params object[] parameters)
{
int id = this.idCounter++;
var scheduleTaskTaskAction = new ScheduleTaskOrchestratorAction { Id = id, Name = name, Version = version, Input = serializedInput, Tags = options.Tags };
this.orchestratorActionsMap.Add(id, scheduleTaskTaskAction);
var tcs = new TaskCompletionSource<string>();
this.openTasks.Add(id, new OpenTaskInfo { Name = name, Version = version, Result = tcs });
string serializedResult = await tcs.Task;
return this.MessageDataConverter.Deserialize(serializedResult, resultType);
}When replay reaches the TaskScheduledEvent that a previous turn persisted for that id, the handler checks that the current execution produced the same request — same id, same action kind, same name (case-insensitive) — and, if so, removes the pending action so it is not re-emitted. Any mismatch is a NonDeterministicOrchestrationException (source):
public void HandleTaskScheduledEvent(TaskScheduledEvent scheduledEvent)
{
int taskId = scheduledEvent.EventId;
if (!this.orchestratorActionsMap.ContainsKey(taskId))
throw new NonDeterministicOrchestrationException(scheduledEvent.EventId,
$"A previous execution of this orchestration scheduled an activity task with sequence ID {taskId} and name "
+ $"'{scheduledEvent.Name}' (version '{scheduledEvent.Version}'), but the current replay execution hasn't "
+ "(yet?) scheduled this task. Was a change made to the orchestrator code after this instance had already started running?");
var orchestrationAction = this.orchestratorActionsMap[taskId];
if (orchestrationAction is not ScheduleTaskOrchestratorAction currentReplayAction)
throw new NonDeterministicOrchestrationException(/* "…replay instead produced a {type} action with this sequence number…" */);
if (!string.Equals(scheduledEvent.Name, currentReplayAction.Name, StringComparison.OrdinalIgnoreCase))
throw new NonDeterministicOrchestrationException(/* "…replay instead scheduled an activity task named '{name}'…" */);
this.orchestratorActionsMap.Remove(taskId);
}The exception's constructor prefixes every message with "Non-Deterministic workflow detected: " (source); the executor catches it and fails the orchestration. Resolution events are the other half: HandleTaskCompletedEvent looks up openTasks[TaskScheduledId] and sets the TaskCompletionSource, which resumes the parked await; a resolution with no open task is logged as a duplicate and dropped, not treated as an error. HandleEventRaisedEvent bypasses ids entirely and calls the orchestration's RaiseEvent by name. Inputs are not compared on replay — only id, kind and name — so an orchestration whose activity argument changes is not detected.
The Go port's onTaskScheduled is the same check with the same message shape (source): "a previous execution called CallActivity for '%s' and sequence number %d at this point in the workflow logic, but the current execution doesn't have this action with this sequence number". Its runtime state layer additionally deduplicates resolutions on a (kind, id) correlator — KindTask/KindTimer/KindChild × TaskScheduledId/TimerId — before they ever reach the orchestrator, returning ErrDuplicateEvent (source, source).
Actions out, events in
An execution turn does not write history events for its requests directly. It returns OrchestratorActions (schedule task, create timer, create sub-orchestration, send event, complete/continue-as-new); the dispatcher turns each into the corresponding request event appended to history plus an outbound message, and only the backend's commit persists both atomically. A ContinueAsNew action becomes a ContinueAsNewEvent in the old history and a brand-new ExecutionStartedEvent message with a fresh ExecutionId, the same Name, and Version = NewVersion ?? runtimeState.Version (source). The dispatcher also caps events per turn: past MaxMessageCount it appends a fake timer with a reserved id (FakeTimerIdToSplitDecision) to defer the rest to the next turn, and the context skips that id on replay.
Dapr's actor-based engine
Dapr's engine README states the layering (source): "Internally, this engine depends on the Durable Task Framework for Go … an MIT-licensed open-source project for authoring workflows (or "orchestrations") as code." The durabletask-go backend.Backend interface is implemented by an actors backend (source) that registers two internal actor types (architecture docs): dapr.internal.<ns>.<app>.workflow and dapr.internal.<ns>.<app>.activity.
Workflow actor. One per instance, id = instance id. Its state is a set of keys in the actor state store (wfengine/state/state.go): inbox-NNNNNN (the FIFO of unprocessed events, six-digit zero-padded index), history-NNNNNN (the append-only history), customStatus, metadata (inbox length, history length, Generation), plus signing-related keys. The docs describe the history keys as "Like an append-only log, workflow history events are only added and never removed (except when a workflow performs a "continue as new" operation, which purges all history and restarts a workflow with a new input)" (source). A turn (runWorkflow, source) loads the state, appends any reminder payload (a fired timer or a cascaded terminate) to the inbox, drains the inbox into NewEvents, calls the application over the work-item stream, applies the returned actions to the runtime state, and saves. The docs' five-step lifecycle: "1. A workflow actor is activated when it receives a new message. 2. New messages then trigger the associated workflow code … 3. Once the result is received, the actor schedules any tasks as necessary. 4. After scheduling, the actor updates its state in the state store. 5. Finally, the actor goes idle …"
Activity actor. One per scheduled activity, id = workflow id + sequence number + generation. It stores no state; InvokeMethod "creates a reminder that executes the activity logic. InvokeMethod returns immediately after creating the reminder, enabling the workflow to continue processing other events in parallel" (source). The reminder name is constant per actor so retries collapse onto one scheduler entry (source).
Reminders are the durability primitive. Durable timers are reminders named timer-<id> carrying a pre-built TimerFired event and the generation that created them (source); runWorkflow discards a timer from an older generation. The docs state the guarantee: "Prior to invoking application workflow code, the workflow or activity actor will create a new reminder. These reminders are made "one shot", meaning that they will expire after successful triggering. … if the node or the sidecar hosting the associated workflow or activity crashes, the reminder will reactivate the corresponding actor and the execution will be retried, forever." (source)
Analysis
1. Step identity and replay matching
Identity is positional: a per-execution monotonic idCounter (Go: sequenceNumber), reset to zero at the start of every turn, assigned in the order the orchestrator function makes scheduling calls. The persisted TaskScheduledEvent.EventId is that counter value; a completion carries it back as TaskScheduledId (README). Matching on replay checks three things — id present in the current turn's action map, action kind, activity name (case-insensitive) — and nothing else (HandleTaskScheduledEvent). There is no stable name, no attempt counter, no args hash; retries are visible only as further TaskScheduled events with higher ids ("there may be multiple Task*** events generated if an activity task is retried"). External events are the exception: EventRaisedEvent is matched by name, FIFO across waiters, and buffered in history if it arrives before a waiter exists ("the event will be saved into the workflow's history and consumed immediately after the workflow requests the event" — features; Go: bufferedExternalEvents in onExternalEventRaised).
2. Journal versus world
The journal wins, unconditionally. The orchestrator never observes the world: the determinism rule is "Workflows must not interact with global variables, environment variables, the file system, or make network calls" (features); every observation is an activity whose result is a TaskCompletedEvent, and on replay that recorded result is returned verbatim, never re-fetched. Disagreement is therefore undetectable by design at the orchestrator level — the model has no notion of a re-observed value. What the engine does detect is journal-versus-code disagreement, via the sequence-number check. The Dapr layer adds one world-versus-journal check outside the orchestrator: history signing (sigcert/signature keys in state.go), which verifies that the stored history was written by an authorised sidecar, i.e. tamper-evidence rather than reconciliation.
3. Determinism enforcement
By discipline plus a coarse runtime check. The language does nothing: C# and Go both allow DateTime.UtcNow, rand, goroutines and I/O inside an orchestrator. The docs enumerate the rules verbatim (features):
"APIs that generate random numbers, random UUIDs, or the current date are non-deterministic. To work around this limitation, you can: Use these APIs in activity functions, or (Preferred) Use built-in equivalent APIs offered by the SDK."
"Workflow functions must only interact indirectly with external state. External data includes any data that isn't stored in the workflow state. Workflows must not interact with global variables, environment variables, the file system, or make network calls."
"Workflow functions must execute only on the workflow dispatch thread. … Workflow functions must never: Schedule background threads, or Use APIs that schedule a callback function to run on another thread. Failure to follow this rule could result in undefined behavior."
The replay-safe substitutes are CurrentUtcDateTime (the OrchestratorStartedEvent.Timestamp of the current turn, so stable within a turn and monotone across turns) and NewGuid() (UUID v5 over instance id + time + sequence). IsReplaying is offered for side-effect suppression (logging) — the .NET SDK's loggers "automatically check IsReplaying and suppress" (source). The runtime check catches a subset of violations: a divergent sequence of scheduling calls fails as NonDeterministicOrchestrationException; a divergent argument, a data-dependent branch that happens to schedule the same names, or a direct side effect are invisible. DTFx also sets a thread-static IsOrchestratorThread "for detecting illegal async usage in orchestration code" (source). The .NET SDK ships Roslyn analyzers (src/Analyzers) for the common mistakes; Go and the Dapr SDKs rely on review.
4. Compensation and failure handling
Retries are an orchestrator-side loop over durable timers, not an engine feature: RetryInterceptor.Invoke calls the activity, catches the TaskFailedException, computes back-off from RetryOptions and CurrentUtcDateTime, and awaits CreateTimer(retryAt, …) before trying again (source); the Go port's internalScheduleTaskWithRetries is the same shape (source). Every attempt and every back-off timer is therefore in the history — the docs warn "The actions performed by a retry policy are saved into a workflow's history. Care must be taken not to change the behavior of a retry policy after a workflow has already been executed" (features). FailureDetails.IsNonRetriable short-circuits the loop.
Compensation is a documented pattern with no engine support. The patterns page (source) defines it — "The compensation pattern (also known as the saga pattern) provides a mechanism for rolling back or undoing operations that have already been executed when a workflow fails partway through" — and then shows user code: push a compensation name onto a list after each successful step, catch, reverse the list, call each compensating activity. The benefits list makes the absence explicit: "Compensation Control: You have full control over when and how compensation activities are executed." There is no scope, no registration API, no LIFO guarantee beyond what the user writes, and because the compensation calls are themselves activities they get sequence numbers and are subject to the same replay rules.
Failure propagation: an activity's unhandled exception becomes TaskFailedEvent and resumes the orchestrator's await with TaskFailedException; a sub-orchestration's failure surfaces as SubOrchestrationFailedException. Unhandled in the orchestrator, it fails the instance (ExecutionCompleted with failure details). Dapr adds ExecutionStalled (see §5) as a non-terminal failure state.
5. Versioning against old histories
Three mechanisms, layered:
- Instance-level version string.
ExecutionStartedEvent.Versionis set at creation and readable asOrchestrationContext.Version;INameVersionObjectManagerresolves(Name, Version)to an orchestration class, so old instances keep routing to old code.ContinueAsNew(newVersion, input)is the only way an instance changes version (source). DTFx 3.x adds worker-sideVersioningSettings—MatchStrategyNone/Strict/CurrentOrOlderandFailureStrategyReject(abandon the work item so another worker picks it up) orFail(complete the instance withVersionMismatch) — evaluated before the executor runs (source, dispatcher). - Patches (Dapr / Go).
ctx.IsPatched("use-sms")returns true at the end of history (new decision) or if the patch name is recorded on a priorWorkflowStartedevent'sversion.patches, and false when replaying inside history without a record (source). The docs: "Patch checks are recorded in the workflow instance history the first time they are evaluated", and the rules — never reuse an identifier, never remove or reorder patches, only nest new patches inside old ones (versioning). - Named workflow versioning (Dapr). Duplicate the function, register with
AddVersionedWorkflow("Workflow", isLatest, fn); new instances get the latest, in-flight instances their recorded name. "the runtime does not migrate workflows between versions sequentially … so there's is no need to handle any compensation logic between versions" (source).
The failure mode when these are not followed is stalling, not crashing: hasPatchMismatch requires the history's patch list to be an exact prefix of the current run's; on mismatch or an unregistered version name stallWorkflow discards the turn's NewEvents, appends ExecutionStalled, saves, and blocks the actor until context cancellation (source). "Workflows can remain stalled for the entire duration of the rollout, until there are only new replicas available" (versioning).
6. Concurrency under replay
Fan-out is ordinary code: schedule N tasks without awaiting, then await Task.WhenAll(tasks) (C#, patterns) or, in Go, loop Await over the slice (samples/parallel/parallel.go). It works because scheduling and awaiting are decoupled: ids are assigned at schedule time in program order (idCounter++ inside ScheduleTaskInternal), so the N TaskScheduled events are deterministic regardless of completion order, and completions resolve openTasks[TaskScheduledId] whichever order they arrive in. WhenAll/WhenAny are the plain .NET combinators running on the SynchronousTaskScheduler; the engine never sees them. The Go port has no WhenAll helper at all — Await on the first task pumps history, so later tasks may already be resolved by the time they are awaited. The limit is IsReplaying: it flips once, when the loop crosses from PastEvents to NewEvents, so there is no per-task "is this branch replaying" — the per-event IsPlayed flag exists in the history but is not surfaced to the orchestrator. Dapr bounds parallelism operationally (per-sidecar and global concurrency limits, docs) rather than in the model.
7. Replay or snapshot
Pure replay; there is no snapshot path in the modern backends (HistoryStateEvent exists in the enum — "contains a snapshot of the orchestration history. This event type is not used in most modern backend types" — README). Costs: every turn re-runs the function from the top over the full history, and Dapr loads history-NNNNNN keys individually from the state store each turn, so the docs push ContinueAsNew for anything long-lived: "Continue-as-new restarts the workflow immediately and discards the results of any incomplete tasks - including activities, timers, and child workflows that were started but not awaited" and "truncates the existing history, replacing it with a new history" (features). Dapr's Generation counter (in metadata, on timers, on activity actor ids) is what lets a continued-as-new instance ignore stragglers from its previous life (runWorkflow). What replay rules out: the engine cannot inspect or migrate state (it has none of its own), and cannot tolerate a changed call sequence — hence §5.
8. Testing
DTFx ships an in-process backend, DurableTask.Emulator.LocalOrchestrationService — "Fully functional in-proc orchestration service for testing" — implementing IOrchestrationService and IOrchestrationServiceClient over in-memory queues (source); the .NET SDK's DurableTaskTestHost does the same behind the gRPC sidecar protocol, "without requiring any external backend (Azure Storage, SQL, etc)", backed by InMemoryOrchestrationService (source). durabletask-go has backend/local for an in-memory task backend (source) and an sqlite backend. The engine's own suites include an explicit nondeterminism test — NonDeterministicOrchestrationTest drives FAILTIMER/FAILTASK/FAILSUBORCH variants of an orchestration and asserts the instance fails (source) — and the Go tests cover sequence-number reconciliation against synthetic optional timers (source). What is absent: no crash-injection harness, no "replay this stored history against new code" fixture, and no guidance in the Dapr docs on unit-testing a workflow function (the SDK pages document no mocking API). The DTFx unit of test is a whole orchestration run end-to-end against the emulator.
9. Journal integrity and the single writer
Dapr's answer is inherited from its actor runtime, and it adds one mechanism nothing else in this survey has: the record is cryptographically signed.
The single writer is an actor activation. A workflow instance is an actor, and the actor runtime "provides a simple turn-based access model for accessing actor methods. Turn-based access greatly simplifies concurrent systems as there is no need for synchronization mechanisms for data access" (actors overview). Placement guarantees one live activation per actor id across the cluster, so "one writer per workflow" is a property of the platform rather than of the journal format, and there is no expected-version check on an individual history append.
Appends are transactional, at the state-store level. History events, the inbox and the metadata key are written through the actor state store's transactional API, which the state store must support — the component has to declare actorStateStore true (actors overview). A turn's worth of events therefore commits together, which is what makes the per-turn idCounter scheme of §1 sound.
Every history event is signed, and the signature is checked on every load."Every history event produced during a workflow's lifetime is signed using the sidecar's mTLS identity … creating an auditable chain of signatures that is verified each time the workflow state is loaded" (history signing). No other system surveyed treats its own record as potentially tampered with. The documentation is also unusually candid about the operational cost: signing trusts the Dapr root certificate authority, the default self-signed root lasts a year, and "if that root expires, or if you rotate to a new root with a different private key, every signed workflow issued under the old root stops verifying and fails to load with error type SignatureVerificationFailed. There is no re-sign path." That is a durability mechanism whose failure mode is the loss of every in-flight record, stated plainly by its own authors.
Tamper detection is not writer fencing. A signature proves the sidecar's identity wrote the event; it does not order two writers or reject a stale one. Those remain the actor placement's job.
Torn writes are the state store's problem, and the choice of store therefore decides what "durable" means — a deliberate consequence of the pluggable-component model.
Retention is a first-class policy. History is kept indefinitely by default, and a retention policy can be set per terminal state (Completed, Failed, Terminated) with a Go duration, with deletion only once a workflow is terminal (retention policy). Few systems here make "how long is the record kept" a configurable per-outcome decision.
10. Operator recovery and intervention
Seven operations exist as public API handlers, which is a broader surface than most and narrower than it looks.
The verbs are get, start, terminate, raise-event, pause, resume and purge (workflow.go), each versioned three times over (alpha, beta and stable) — which is itself evidence that the operator surface is treated as part of the product contract rather than as tooling.
Pause and resume act on an in-flight instance, unlike Inngest's function-level pause: a paused workflow stops consuming its inbox and resumes where it stopped.
Purge deletes a terminal instance's state, and is the manual counterpart to the retention policy.
Terminate has no cancellation counterpart. There is no operation that delivers a cancellation the orchestrator can observe and handle, so compensation — which is user code in a catch (§4) — does not get a turn when an operator stops a workflow. That is a real gap next to Temporal's cancel-versus-terminate or Restate's cancel-versus-kill.
Raise-event doubles as the intervention channel, letting an operator supply a value the workflow is waiting for (§11) without touching the state store.
Inspection is get-workflow plus whatever the state store allows. There is no API that returns the history event by event, so "what did this workflow do" is answered by reading the actor's state keys directly — and, when signing is on, by a record that verifies.
A mismatch stalls rather than corrupts. A version or patch mismatch puts the instance in a stalled state (§5), which is the right default: it leaves the record intact for an operator to act on instead of failing the run or guessing.
11. Suspension and external input
Waiting is not running. The workflow actor is deactivated while it waits, and a durable timer is a reminder registered with the actor runtime; the reminder is what brings the actor back. Because reminders are the platform's own durability primitive, the documentation states the consequence bluntly: "if the node or the sidecar hosting the associated workflow or activity crashes, the reminder will reactivate the corresponding actor and the execution will be retried, forever."
The primitives are durable timers, external events (WaitForExternalEvent/RaiseEvent), child workflows, and activity completion. They are the Durable Task Framework's set, unchanged.
External input is addressed by workflow id plus event name, and events are matched to waiters by name in arrival order (§1). An event that arrives before the orchestrator reaches its wait is buffered in the history; one that arrives twice is two events, so deduplication is the author's job; one that never arrives leaves the instance waiting until a timer the author raced against it fires.
There is no distinct persisted "suspended" state for a wait. The runtime status vocabulary covers running, completed, failed, terminated, pending, suspended — but Suspended there means operator-paused, not waiting-on-input. A caller cannot distinguish "blocked on an external event" from "activity in flight" without reading the history.
Human-in-the-loop is the documented pattern and is built from a timer raced against an external event, with the timer as escalation — the same construction as Azure Durable Functions, from which this model descends.
Strengths
- The matching rule fits on one screen. Id + kind + name; the error message says exactly what was expected and what was produced, and names the likely cause ("Was a change made to the orchestrator code after this instance had already started running?").
- Fan-out needs no engine support: ids at schedule time, results by id, plain
WhenAll. - Retries are just code over timers, so they are visible in history and replay like anything else.
- Dapr's reminder-driven turn gives at-least-once re-drive of a crashed turn with no separate queue; timers, activity dispatch and re-drive are all the same primitive.
- Patches + named versions + stall is a complete rollout story: old code stays, new instances take the new path, mismatches park instead of corrupting.
- In-process backends (emulator,
InMemoryOrchestrationService,backend/local) make end-to-end tests cheap.
Weaknesses
- Inputs are never compared on replay, so an activity whose argument changed replays with the old result silently.
- Determinism is discipline: nothing prevents
DateTime.UtcNowor a network call in an orchestrator; the runtime check only fires when the call sequence diverges. - No re-observation model: the journal always wins, so a world that changed under a resumed instance is invisible until an activity runs.
- Compensation is a pattern with no API: the docs' saga is a hand-maintained list reversed in a
catch. - Per-turn replay cost is linear in history and Dapr fetches history one key per event;
ContinueAsNewis the only relief and it discards in-flight work. - No replay-against-stored-history test fixture; nondeterminism is found in production or by an end-to-end test that happens to cover the change.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Positional sequence ids, reset per turn | Zero API burden; ids fall out of program order; fan-out is free | Any insertion/reorder of a scheduling call breaks every in-flight instance; no stable step names |
| Match id + kind + name, not input | Cheap, catches the common code-edit mistake | Argument drift is silent |
| Orchestrator is forbidden from observing the world | Makes replay trivially deterministic; no reconciliation logic in the engine | Every observation is an activity round-trip; stale results are re-served forever |
| Actions returned, events appended by the dispatcher | Requests and outbound messages commit atomically per turn | A turn is a batch: a crash mid-turn re-runs the whole turn |
IsReplaying as a single flip at the past/new boundary | Simple, enough for log suppression | No per-task replay awareness; concurrent branches cannot tell which of them is "new" |
| Retries as orchestrator code over durable timers | Fully durable, visible in history, no engine state | Changing a retry policy is a nondeterministic code change |
| Dapr: workflow = actor, history = state keys, reminder = re-drive | Reuses placement, state stores and reminders the sidecar already has | Per-event key I/O; state-store semantics (ETags, bulk ops) bound throughput |
Patches recorded on WorkflowStarted; mismatch ⇒ stall | Rollouts with mixed replicas never corrupt history | A stalled instance needs an operator; patch identifiers are forever |
ContinueAsNew as the only history truncation | Keeps the model pure replay | Drops unfinished tasks; needs a generation counter to fence stragglers |
Implications for a durable-execution library
- Signing the record is a mechanism nobody else has, and its failure mode is instructive (§9). Cryptographic tamper detection verified on every load is a real capability; trusting a certificate authority whose expiry invalidates every in-flight record, with no re-sign path, is a real hazard. A library adding integrity checks should prefer something whose key material cannot expire — a content hash rather than a signature — unless tamper-attribution is actually required.
- Placement-based single-writer is strong and unportable. One live activation per id across a cluster is a better guarantee than any lease, and it is available only to a library that owns a placement service. A library without one has to fence explicitly.
- Terminate without a cancel counterpart is a gap (§10). If compensation is user code, an operator stop that never gives the program a turn cannot run it. Offering only the abrupt verb silently makes rollback unreachable.
- Stall on mismatch, rather than fail (§5). Leaving the record intact and the instance parked is the recovery-friendly default, and it composes with an operator surface that can then resume.
- Retention as a per-terminal-state policy is the right granularity: a failed run is worth keeping longer than a successful one, and a library that keeps everything forever eventually becomes a storage problem for its users.
- Positional per-turn identity survives fan-out because ids are assigned at schedule time (§6), which is the general lesson: positional schemes are safe under concurrency exactly when the position is fixed before any await.
- A journaled clock and id generator derived from already-recorded data is inherited from the Durable Task Framework and remains the cheapest way to remove two sources of nondeterminism (§3).
- Retry forever is a default worth questioning. Reminder-driven reactivation means a permanently failing activity retries indefinitely with no quarantine state, so an operator must notice.
Sources
- Azure/durabletask — GitHub repository
- microsoft/durabletask-dotnet — GitHub repository
- dapr/durabletask-go — GitHub repository
- dapr/dapr — GitHub repository
src/DurableTask.Core/History/README.md— history event cataloguesrc/DurableTask.Core/History/HistoryEvent.cs— base eventsrc/DurableTask.Core/History/EventType.cs— event enumsrc/DurableTask.Core/History/TaskScheduledEvent.cs·ExecutionStartedEvent.cs·ContinueAsNewEvent.cssrc/DurableTask.Core/TaskOrchestrationExecutor.cs— the replay loopsrc/DurableTask.Core/TaskOrchestrationContext.cs— sequence ids, matching, nondeterminism checkssrc/DurableTask.Core/OrchestrationContext.cs—IsReplaying,CurrentUtcDateTime,Versionsrc/DurableTask.Core/OrchestrationRuntimeState.cs—PastEvents/NewEventssrc/DurableTask.Core/TaskOrchestrationDispatcher.cs— actions to events,ContinueAsNew, versioning gatesrc/DurableTask.Core/RetryInterceptor.cs— retries over durable timerssrc/DurableTask.Core/Settings/VersioningSettings.cssrc/DurableTask.Core/Exceptions/NonDeterministicOrchestrationException.cssrc/DurableTask.Core/SynchronousTaskScheduler.cssrc/DurableTask.Core/DurableTask.Core.csproj— versionsrc/DurableTask.Emulator/LocalOrchestrationService.cs— in-proc backendtest/DurableTask.ServiceBus.Tests/DispatcherTests.cs—NonDeterministicOrchestrationTestsrc/Abstractions/TaskOrchestrationContext.cs(.NET SDK) —NewGuid,IsReplayingdocssrc/InProcessTestHost/DurableTaskTestHost.cs(.NET SDK) ·InMemoryOrchestrationService.cs·eng/targets/Release.propstask/orchestrator.go(Go) —WorkflowContext,onTaskScheduled,IsPatchedtask/task.go(Go) —Await/ErrTaskBlockedbackend/runtimestate/runtimestate.go(Go) —addEventWithDedup·dedup/key.goapi/protos/history_events.pb.go(Go) —HistoryEventoneofbackend/local/task.go(Go) — in-memory backend ·tests/task_executor_test.go·samples/parallel/parallel.gopkg/runtime/wfengine/README.md(Dapr)pkg/runtime/wfengine/backends/actors/actors.go(Dapr) — the actors backendpkg/runtime/wfengine/state/state.go(Dapr) — state-store key layoutpkg/actors/targets/workflow/orchestrator/run.go(Dapr) — a workflow actor turnpkg/actors/targets/workflow/orchestrator/timer.go(Dapr) — timers as reminderspkg/actors/targets/workflow/orchestrator/versioning.go(Dapr) — patch mismatch, stallpkg/actors/targets/workflow/activity/activity.go(Dapr) — activity actor ·reminder.gogo.mod(Dapr) — pinneddurabletask-go- Dapr docs: Workflow overview · Features and concepts · Architecture · Patterns · Versioning · Protocol: state and history
- Related: Azure Durable Functions · Temporal · Catalog index · Comparison