5 min readResonate HQJust published

Distributed mutex via generator sequencing in TypeScript on Resonate

How a 15-line generator workflow produces mutual exclusion across workers without signals, lock workflows, or external coordination.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

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