Distributed mutex via generator sequencing in TypeScript on Resonate

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:31

The 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 for lfc). 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.run is documented in the SDK as the alias for lfc (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 form mutex/<resource>/<Date.now()>. This is the only durable promise the caller sees; the per-worker checkpoints are children of it.

Default Exponential retry policy on each child ctx.run (implicit) — resonate-sdk-ts/src/context.ts:418. accessResource is passed by function reference to ctx.run; it is not registered. Inside lfc, the retry policy is chosen at LFC construction with opts.retryPolicy ?? (util.isGeneratorFunction(func) ? new Never() : new Exponential()) — because accessResource is an async (non-generator) function, the Exponential branch is selected. The workflow code does not configure retry. The parallel top-level retry-policy ternary at resonate-sdk-ts/src/computation.ts:104-109 governs the root computation; since exclusiveResourceAccess is a generator, the root gets Never — 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 accessResource throws on attempt 1. src/workflow.ts:82-85 throws an Error on first attempt when shouldCrash && attempt === 1. The SDK's default Exponential policy for the child ctx.run (resonate-sdk-ts/src/context.ts:418) re-invokes accessResource after the configured delay; the in-process attemptMap (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.run retries, after Worker A and B have already completed. Worker A's and Worker B's ctx.run results were already checkpointed. The generator parks at Worker C's yield* 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 iteration i+1 until iteration i has 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 accessResource rejection 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.0 to match the example's @resonatehq/sdk ^0.10.0)

SDK primitives cited:

src/context.ts — Context.run (alias for lfc) at lines 207-209; child ctx.run default-retry-policy selection inside lfc at line 418

src/computation.ts — root-computation default-retry-policy selection (Exponential for async, Never for generators) at lines 104-109

src/retries.ts — Constant, Exponential, Linear, Never policies

src/resonate.ts — Resonate constructor; line 85 documents the in-memory network fallback when no URL is resolved

src/network/local.ts — line 140, in-memory promises = new Map<string, DurablePromise>() on the Server class

Resonate docs: docs.resonatehq.io