Exactly-Once Is a Lie. Here Is What a Workflow Engine Can Actually Promise
A workflow retries a step that already charged a customer. The engine cannot know whether the charge landed — the response was lost, not the request. Exactly-once is unachievable here. At-most-once plus an explicit unknown state is achievable, and it is what an honest engine should offer.
By Oleksii Vasylenko, Distributed Systems Architect & Hands-on Engineer · · 14 min read
Where this comes from. Orch8 — a durable workflow engine I built solo in Rust: every crate, every SDK, every integration. Ten-crate workspace, PostgreSQL and SQLite backends, server and on-device execution. The details below are the actual implementation, not a reference architecture.
In This Article
- The Problem Every Durable Engine Has and Few Name
- An Effect Is a State Machine, Not a Boolean
- Deterministic Effect IDs Make Retries Converge
- Not Every Step Needs This
- Enforcing At-Most-Once at the Storage Boundary
- What This Costs, Honestly
- Designing This Into Your Own System
- Effect States and What Each One Permits
The Problem Every Durable Engine Has and Few Name
A workflow step calls a payment API. The engine crashes between sending the request and persisting the response. On restart it finds a step that started and never finished. Retrying might charge the customer twice. Not retrying might leave the order unpaid forever.
This is not a rare edge case; it is the defining problem of durable execution. Every framework that advertises "exactly-once" is describing exactly-once *state transitions inside its own database* — which is genuinely valuable and completely different from exactly-once *effects on someone else's system*. Those are separated by a network, and no protocol closes that gap without cooperation from the other side.
The honest decomposition has three parts. Internal state transitions can be exactly-once, because one database transaction owns them. External effects can be at-most-once, because you can durably record intent before dispatching. And the outcome of a dispatched effect is sometimes genuinely unknown — at which point the only correct behaviour is to stop and say so.
An Effect Is a State Machine, Not a Boolean
The usual model for a side effect is a flag: done or not done. That model has no way to represent "we sent it and never heard back," which is the state that actually causes incidents. So effects get a full lifecycle with eight states:
pub enum EffectState {
Planned, // decided to do it
Prepared, // receipt durably written, not yet sent
Dispatched, // sent; outcome not yet known
Committed, // provider confirmed
Unknown, // sent, no answer — blocks automatic retry
Verified, // reconciled against the provider after the fact
Compensated, // undone by a compensating action
Abandoned, // explicitly written off by an operator
}The important state is `Unknown`, and it exists so the engine never has to guess. `Dispatched` becoming `Unknown` on restart is the crash case: the process died after sending and before recording an outcome. `Verified` is how it gets out — a reconciliation job or an operator queries the provider, discovers what actually happened, and settles the receipt with evidence.
Every transition is a compare-and-swap against the stored state, not a blind write. If the CAS fails, some other process already advanced the receipt, and the current caller is working from a stale view — so it stops rather than racing:
async fn advance(&mut self, next: EffectState) -> Result<(), EngineError> {
let expected = self.receipt.state;
let mut updated = self.receipt.clone();
updated.transition(next, Utc::now())?; // rejects illegal transitions
if !self.storage
.cas_effect_receipt(&updated.tenant_id, updated.id, expected, &updated)
.await?
{
let current = self.storage
.get_effect_receipt(&updated.tenant_id, updated.id).await?
.unwrap_or(self.receipt.clone());
return Err(blocked(¤t)); // stop; do not re-dispatch
}
self.receipt = updated;
Ok(())
}Deterministic Effect IDs Make Retries Converge
A retry has to find the receipt the first attempt created, or the whole scheme collapses into a fresh receipt per attempt and no protection at all. Generating a random ID at dispatch time does exactly that. So the effect ID is derived, not generated:
let id = deterministic_effect_id(
execution.continuity_id, // which logical execution
execution.epoch, // which ownership generation
instance_id, // which run
block_id, // which step
attempt, // which attempt
);Two processes racing on the same step compute the same ID and converge on one row. `ensure_effect_receipt` is an idempotent upsert, and its result is checked against the candidate — if a receipt with that ID exists but carries different evidence, that is a hash collision or an implementation bug, and the engine refuses to continue rather than reusing someone else's receipt.
Including `attempt` in the derivation is a deliberate choice with a trade-off. It means a legitimate retry after a *known* failure gets a fresh receipt and is allowed to proceed. It also means the deduplication boundary is one attempt wide, and the cross-attempt protection has to come from the `Unknown`-blocks-retry rule rather than from the ID alone. Those two mechanisms are complementary; neither is sufficient by itself.
The receipt also captures a destination fingerprint, a SHA-256 of the canonicalised request, any caller-supplied idempotency key, and the provider's own receipt ID once it comes back. That is the evidence a reconciliation job needs later, and it is why the receipt is written *before* dispatch rather than after.
Not Every Step Needs This
Wrapping every step in a durable receipt would add two round trips to work that cannot hurt anyone. A `log` step or a `transform` has no external footprint. So the guard is only constructed for handlers that can touch the outside world — and the classification is deliberately pessimistic:
const SIDE_EFFECT_BUILTINS: &[&str] = &[
"http_request", "llm_call", "tool_call", "mcp_call", "agent",
"emit_event", "send_signal", "self_modify",
"memory_store", "memory_delete", "blob_put", "embed",
];
pub(crate) fn handler_has_side_effects(handler: &str) -> bool {
SIDE_EFFECT_BUILTINS.contains(&handler)
|| !BUILTIN_HANDLER_NAMES.contains(&handler) // user handler: assume yes
}That second clause is the one that matters. A handler the engine does not recognise is a handler someone wrote, and the engine cannot prove it does not send email. Defaulting to "protected" costs a little latency on custom steps. Defaulting to "safe" would silently exclude exactly the code most likely to have effects the engine has never seen.
This same list drives release-risk classification: changing a step that touches one of these handlers is flagged as a side-effect risk in the semantic diff between two workflow versions, not merely as a behavioural change. One classification, two consumers.
Enforcing At-Most-Once at the Storage Boundary
Application-level checks cannot prevent two processes from dispatching concurrently — the check and the act are separated in time. So when a workflow declares an at-most-once invariant for a class of effect, the transition to `Dispatched` becomes a conditional storage operation that returns which of three things happened:
match storage.dispatch_effect_receipt_at_most_once(&tenant, &updated).await? {
EffectDispatchOutcome::Dispatched => { /* we own it; proceed */ }
EffectDispatchOutcome::Duplicate => {
record_effect_guard_results(storage, &guards, &updated, true).await?;
Err(EngineError::InvariantViolation { // a second dispatch was attempted
invariant_id: guards[0].id,
block_id: updated.block_id,
})
}
EffectDispatchOutcome::Stale => { // an older epoch tried to act
let current = storage.get_effect_receipt(&tenant, updated.id).await?;
Err(blocked(¤t))
}
}`Duplicate` and `Stale` are separate outcomes on purpose. `Duplicate` means the invariant caught a genuine double-dispatch attempt and the violation is recorded against the invariant that declared it — an operator sees which rule fired and where. `Stale` means the caller belongs to a superseded generation of the execution, which is what happens after ownership moves to another node. Collapsing them into one error would make those two very different incidents look identical in the logs.
The invariants themselves are per-workflow-version configuration rather than global engine behaviour. A workflow declares which effect kinds are at-most-once and which of those act as commit guards; the guard loads only the invariants matching the current receipt's kind. Charging a card and appending to a log do not need the same protection, and paying for the strong version everywhere would be a tax on the steps that do not need it.
What This Costs, Honestly
Every side-effecting step pays for a durable receipt write before dispatch and a CAS after it. That is real latency on the hot path, and it is the price of being able to answer "did it happen?" after a crash. There is no version of this that is free.
It also means workflows can get stuck in a way that a naive engine would not. An `Unknown` receipt blocks automatic retry until something resolves it. If you do not build the reconciliation path — a job that queries providers and settles receipts — you have traded silent duplicate charges for a queue of manually stuck workflows. That is a better failure mode, but only if someone is watching the queue.
| Guarantee | Achievable? | Mechanism |
|---|---|---|
| Exactly-once internal state | Yes | Single database transaction |
| At-most-once external effect | Yes | Durable receipt written before dispatch |
| At-least-once external effect | Yes | Retry until confirmed — duplicates possible |
| Exactly-once external effect | No | Requires provider-side idempotency keys |
The bottom row is the one vendors blur. You can get close with a provider that honours idempotency keys — but that is the provider's guarantee, not the engine's, and it is worth being clear about whose promise you are relying on.
Which is the practical takeaway: pass an idempotency key to every provider that accepts one, and let the engine carry it in the receipt so a reconciliation can use it. The engine's job is to make sure you never dispatch twice without knowing, and to remember enough to find out what happened. The provider's job is to make a duplicate harmless if you do.
Designing This Into Your Own System
You do not need a workflow engine to apply the pattern. The parts that matter transfer to any system that calls someone else's API from inside a retryable unit of work:
- Write intent durably before you dispatch. If the record is not committed, you cannot later prove you tried.
- Derive the record identity from position in the work, not from a fresh UUID at call time. Retries must converge on one row.
- Give ambiguity its own state. "Sent, outcome unknown" is not a failure and not a success, and modelling it as either causes a different bug.
- Make the state transition conditional at the storage layer. Check-then-act in application code is not mutual exclusion.
- Classify handlers pessimistically. Anything you did not write is side-effecting until proven otherwise.
- Build the reconciliation path before you need it, and alert on the depth of the unresolved queue.
And say what you actually guarantee. A system that says "at-most-once, with an explicit unknown state and a reconciliation path" is more trustworthy than one that says "exactly-once" and quietly means something narrower. Engineers who have operated distributed systems know the difference, and the second answer costs you their confidence at exactly the moment you were trying to earn it.
Effect States and What Each One Permits
| State | Meaning | Retry allowed? | How it resolves |
|---|---|---|---|
| Planned | Decided, nothing written yet | Yes | Advances to Prepared |
| Prepared | Receipt durable, not yet sent | Yes | Advances to Dispatched |
| Dispatched | Sent, outcome pending | No | Committed or Unknown |
| Committed | Provider confirmed | No | Terminal success |
| Unknown | Sent, never heard back | No — blocks | Verified, Compensated, or Abandoned |
| Verified | Reconciled against provider | No | Terminal |
| Compensated | Undone by compensating action | Yes, new attempt | Terminal |
| Abandoned | Operator wrote it off | No | Terminal, audited |
Three of the eight states are operator or reconciler outcomes. That ratio is the point: ambiguity is common enough to deserve first-class handling rather than a comment in a runbook.
Related Matching Engine Guides
The execution model underneath these receipts, and what each approach costs.
How effect receipts survive when the execution physically relocates mid-flight.
The scheduler that dispatches these steps, built on SKIP LOCKED and a partial index.
Stopping a failing dependency before it generates thousands of unresolved receipts.
Related Production Guide
The pillar guide covers the full engine: snapshot execution, the crate architecture, storage backends, SDK design, and the trade-offs behind each decision.
Read the durable workflow engine architecture guide →Primary References
Retrying a step that already charged someone?
I can audit where your system dispatches external effects inside retryable work, model the ambiguity you are currently guessing about, and design the receipt and reconciliation path. Send the handler in question and your current retry policy.
Discuss durable execution safety