A distributed semaphore that coordinates access to a limited resource, built on Temporal Workflows in TypeScript.
The trick: a Workflow ID is the lock. Temporal refuses to run two Workflows with
the same ID, so starting a Workflow named permit:{resource}:{slot} is an atomic
"claim this slot" operation.
A semaphore of capacity N is just N candidate slots (0 … N-1). Acquiring the
semaphore means winning the start race for any one slot.
permitSlotWorkflow(src/workflows/permit-slot.ts) — one running instance per held permit. It does nothing but wait on areleasesignal with a lease timeout, then exits (freeing the slot). It ends in one of three ways:releasesignal → returns"released"- lease elapses → returns
"lease-expired" - parent closes →
ParentClosePolicy.TERMINATEkills it
Semaphore(src/workflows/semaphore.ts) — the acquire logic. It shuffles the slots (to spread contention), tries tostartChildeach one, treatsWorkflowExecutionAlreadyStartedErroras "slot busy", and backs off when all slots are held.withPermit()wraps acquire/release in atry/finally.exampleConsumer(src/workflows/example.ts) — a parent Workflow that acquires a permit, does its work, and releases. As the acquirer, it is the parent of the permit children it starts.
The acquirer must be a Temporal Workflow, because acquisition uses
startChild. An external client could instead acquire viaClient.workflow.start, at the cost of losingParentClosePolicy.TERMINATE(crash cleanup would rely solely on the lease).
Three layers free a slot if a holder goes silent: ParentClosePolicy.TERMINATE (parent
died), the in-Workflow lease condition timeout, and workflowExecutionTimeout as a
server-side backstop. For resources that can be corrupted by overlap, pass the permit's
runId downstream as a fencing token and reject stale holders there.
Requires a local Temporal dev server:
temporal server start-dev # http://localhost:8233 for the Web UI
pnpm install
pnpm worker # terminal 1: start the worker
pnpm start # terminal 2: run the demo (8 consumers, capacity 3)The demo starts 8 consumers contending for a capacity-3 resource, so 5 queue until a
permit frees up. Watch the permit:gpu-pool:* Workflows come and go in the Web UI.
| Command | Description |
|---|---|
pnpm worker |
Run the Worker (tsx) |
pnpm start |
Run the demo client (tsx) |
pnpm build |
Compile with tsc + tsc-alias (rewrites the @/* alias) |
Design mirrors Temporal's Distributed Lock article, ported to TypeScript.