Multiple workers need exclusive access to a shared resource that cannot tolerate concurrent calls — a payment gateway, a legacy database, a rate-limited third-party API. The Resonate shape of the solution is a generator workflow that iterates over the workers and calls yield* ctx.run(...) on each one; sequential yield* calls are serialized by the runtime, so the generator itself is the mutex. The example-distributed-mutex-ts repo shows this in roughly 15 lines of workflow code, with no signal handlers, no separate lock workflow, and no external coordination service.
The shape of the solution
export function* exclusiveResourceAccess(
ctx: Context,
resource: string,
workers: string[],
shouldCrash: boolean,
): Generator<any, MutexResult, any> {
const results: WorkResult[] = [];
const start = Date.now();
// Sequential processing — the generator IS the lock.
// Each yield* blocks until the previous one completes.
// No two workers touch the resource at the same time.
for (let i = 0; i < workers.length; i++) {
const worker = workers[i]!;
const crashThis = shouldCrash && i === 2; // crash the 3rd worker
const result = yield* ctx.run(
accessResource,
resource,
worker,
crashThis,
);
results.push(result);
}
return { resource, processed: results, totalMs: Date.now() - start };
}
// from example-distributed-mutex-ts/src/workflow.ts:31The generator yields one ctx.run(accessResource, ...) per worker. Each yield* does not advance until its inner durable promise resolves. Worker B's ctx.run therefore cannot be invoked until Worker A's ctx.run has returned. The for-loop ordering IS the mutual exclusion — there is no lock primitive to acquire and release.
The durable primitives in play
ctx.run(func, ...args)—src/workflow.ts:47. Local Function Call (alias forlfc). Each call is one durable checkpoint: the result is persisted before the generator advances, so a replay returns the cached result instead of re-executing the function.ctx.runis documented in the SDK as the alias forlfc(resonate-sdk-ts/src/context.ts:207-209).resonate.register(exclusiveResourceAccess)—src/index.ts:9. Registers the generator under the Resonate runtime so it can be invoked durably.resonate.run(id, fn, ...args)—src/index.ts:33-39. Top-level invocation with a durable promise id of the formmutex/<resource>/<Date.now()>. This is the only durable promise the caller sees; the per-worker checkpoints are children of it.- Default
Exponentialretry policy on each childctx.run(implicit) —resonate-sdk-ts/src/context.ts:418.accessResourceis passed by function reference toctx.run; it is not registered. Insidelfc, the retry policy is chosen at LFC construction withopts.retryPolicy ?? (util.isGeneratorFunction(func) ? new Never() : new Exponential())— becauseaccessResourceis an async (non-generator) function, theExponentialbranch is selected. The workflow code does not configure retry. The parallel top-level retry-policy ternary atresonate-sdk-ts/src/computation.ts:104-109governs the root computation; sinceexclusiveResourceAccessis a generator, the root getsNever— which is why the parent workflow's own failures do not loop.
What the SDK handles vs. what you write
You write: a generator function whose body is a for loop over the workers list, with one yield* ctx.run(accessResource, ...) per iteration (src/workflow.ts:43-54). You write accessResource itself — the critical-section work that touches the shared resource (src/workflow.ts:65-94). You write the top-level invocation with a durable id (src/index.ts:33-39).
The SDK handles: persisting each ctx.run result as a durable checkpoint before the loop advances; returning the cached value on replay instead of re-executing already-completed iterations; applying the default Exponential retry to a rejecting ctx.run until it eventually resolves; preserving the sequential yield* ordering across in-process retries because the underlying durable promises only resolve once. The example as shipped uses new Resonate() with no URL (src/index.ts:8), which the SDK documents as "a local in-memory network" (resonate-sdk-ts/src/resonate.ts:85), backed by an in-memory Map (resonate-sdk-ts/src/network/local.ts:140); surviving a full host-process restart additionally requires connecting Resonate to a persistent server. Without a durable execution layer, achieving mutual exclusion across processes typically requires a dedicated lock service (Redis/etcd/Zookeeper) or a coordinator workflow with signals, dynamic signal names, and explicit history-rotation logic. None of that appears in this example.
Failure modes covered
- Worker-C's
accessResourcethrows on attempt 1.src/workflow.ts:82-85throws anErroron first attempt whenshouldCrash && attempt === 1. The SDK's defaultExponentialpolicy for the childctx.run(resonate-sdk-ts/src/context.ts:418) re-invokesaccessResourceafter the configured delay; the in-processattemptMap(src/workflow.ts:63, 72-73) makes attempt 2 succeed. Worker D and Worker E only start AFTER Worker C eventually returns — the mutex is preserved across the retry. - The generator suspends mid-workflow while Worker C's failed
ctx.runretries, after Worker A and B have already completed. Worker A's and Worker B'sctx.runresults were already checkpointed. The generator parks at Worker C'syield*until the retry resolves. On resumption it advances to Worker D without re-running A or B; the README states this directly: "worker-A and worker-B each ran once (cached before crash)" (README.md:96). Note: this scenario is what the example actually exercises in-process with the embedded in-memory network. Surviving a full host-process restart additionally requires Resonate connected to a persistent server. - Two parallel workers attempt to touch the resource at the same time. This is structurally prevented by the generator.
yield*is single-threaded — the for-loop cannot start iterationi+1until iterationihas returned a value. There is no race condition to handle because there is no concurrent path. - A failed worker leaves the mutex held. No lock is held — there is nothing to release. The
accessResourcerejection is just a failed checkpoint; the mutex resumes on retry, then continues to the next worker.
When to reach for this pattern
- If you have a known, bounded set of work items that must hit a shared resource one at a time — "process these N things, in some order, no two at once."
- If the resource is something like a payment processor, a single-writer legacy database, or a third-party API with a per-account rate limit, where serializing at the application level is simpler than negotiating concurrency with the external service.
- If you want crash recovery on the serialization itself — already-completed iterations should NOT re-run after a retry, and the failed iteration should retry without releasing or re-acquiring anything.
- If a separate lock workflow with signals would be more coordination than the actual problem requires.
- Do not reach for this pattern when independent, unrelated workflows need to contend for a lock at runtime across a cluster. The README points to two alternatives for that case: build a lock-request queue using Resonate's raw promise model (
ctx.promise()), or reach for an external coordination service (README.md:142-143).
Sources
- Example repo: github.com/resonatehq-examples/example-distributed-mutex-ts
- TypeScript SDK repo: github.com/resonatehq/resonate-sdk-ts (pinned at
v0.10.0to match the example's@resonatehq/sdk ^0.10.0) - SDK primitives cited:
src/context.ts—Context.run(alias forlfc) at lines 207-209; childctx.rundefault-retry-policy selection insidelfcat line 418src/computation.ts— root-computation default-retry-policy selection (Exponentialfor async,Neverfor generators) at lines 104-109src/retries.ts—Constant,Exponential,Linear,Neverpoliciessrc/resonate.ts—Resonateconstructor; line 85 documents the in-memory network fallback when no URL is resolvedsrc/network/local.ts— line 140, in-memorypromises = new Map<string, DurablePromise>()on theServerclass
- Resonate docs: docs.resonatehq.io
