Skip to content

DurabilitySemaphoreQueue

Queue and admit work against a shared semaphore budget.

Import DurabilitySemaphoreQueue from durability/experimental/semaphore and pin an exact version. It is the durable client-side admission queue for a semaphore holder, not another global budget.

How it uses your Durable Object

DurabilitySemaphoreQueue runs in each client object that queues work, next to the operations it admits, and talks to the holder object only over RPC:

  • ctx.storage.sql holds the queue’s items, this object’s copy of every grant and lease, per-key request pacing, and pending reports as rows of the shared outbox. Claims and their reports commit in one transaction.
  • The object’s one alarm is shared with every helper on the scheduler. It drives permit requests for due keys, admit() retries, finished() checks, lease renewals, and report publishing, so your class’s alarm() must delegate to the scheduler.
  • The live instance holds only the keys with a request in flight, the holder’s last blocked reasons, and the running admit() and finished() calls. Eviction loses an open request; the key is asked again after its deadline.
  • RPC to the holder goes through DurabilitySemaphore.client(): request waits for the holder’s round, and sync carries cumulative claimed, released, and drawn counts from the outbox. The holder never calls the client.

How DurabilitySemaphoreQueue uses your Durable Object

Shows JobShard, Queue. Focuses on Queue.Client Durable ObjectJobShardyour Durable ObjectQueuein memoryenqueue / release

1/9 · The queue shares the object’s scheduler

The queue attaches to the same DurabilityScheduler as the jobs helper, so both use this object’s storage and alarm. clientId is the object’s own ID, the name the holder knows this client by; server is the client from DurabilitySemaphore.client().

Open source ↗
import { DurableObject } from 'cloudflare:workers';
import { timeout } from '@durability/transforms';
import { Durability, DurabilityScheduler, type DurableCall } from 'durability';
import {
  DurabilitySemaphore,
  DurabilitySemaphoreQueue,
  type AdmittedItem,
  type SemaphoreRequest,
} from 'durability/experimental/semaphore';

type Env = {
  JOB_SHARDS: DurableObjectNamespace<JobShard>;
  JOB_API: Fetcher;
  /** Running jobs allowed per account. */
  ACCOUNT_CONCURRENCY: string;
};

type Job = {
  id: string;
  accountId: string;
  /** Jobs of one workflow may carry their own ceiling under the account's. */
  workflowId: string;
  payload: unknown;
};

/**
 * Holds one account's limit; the account ID is the semaphore key. Leases let
 * shards admit bursts without a round trip, and `strict` keeps grants plus
 * lease footprint under the limit at all times.
 */
export class AccountLimit extends DurableObject<Env> {
  private readonly scheduler = new DurabilityScheduler({ context: this.ctx });
  private readonly workflowLimits = new Map<string, number>();
  private readonly semaphore = new DurabilitySemaphore({
    scheduler: this.scheduler,
    limit: () => Number(this.env.ACCOUNT_CONCURRENCY),
    groupLimit: (workflowId) => this.workflowLimits.get(workflowId),
    leases: {
      provisioning: { mode: 'strict' },
      permitsPerLease: 16,
      ttlMs: 10 * 60_000,
    },
  });

  permits(message: SemaphoreRequest) {
    return this.semaphore.handle(message);
  }

  /** Administrative: cap one workflow. Takes effect at the next round. */
  limitWorkflow(workflowId: string, limit: number) {
    this.workflowLimits.set(workflowId, limit);
  }

  usage() {
    return this.semaphore.usage();
  }

  override alarm(info?: AlarmInvocationInfo) {
    return this.scheduler.alarm(info);
  }
}

export const limits = DurabilitySemaphore.client({
  target: AccountLimit,
  // Vite injects exportName from this application class and Wrangler's config.
  invoke: (stub, message) => stub.permits(message),
});

/**
 * Many shards, each queueing jobs for many accounts. Admitted jobs run as
 * durable operations; the permit returns when the operation reaches a
 * terminal state, success or failure, through the lifecycle hook or, if that
 * is lost, the queue's `finished` check.
 */
export class JobShard extends DurableObject<Env> {
  private readonly scheduler = new DurabilityScheduler({ context: this.ctx });
  private readonly jobs = new Durability({
    scheduler: this.scheduler,
    handlers: {
      run: async ({ id, payload, signal }: DurableCall<Job>) => {
        const response = await this.env.JOB_API.fetch('https://job-api/run', {
          method: 'POST',
          headers: { 'Idempotency-Key': id },
          body: JSON.stringify(payload.payload),
          signal,
        });
        if (!response.ok) {
          throw new Error(`Job API responded ${response.status}`);
        }
      },
    },
    retries: { maxAttempts: 3 },
    onLifecycleEvent: (event): Promise<void> | undefined => {
      const finished =
        event.type === 'terminal' ||
        (event.type === 'attempt_settled' && event.outcome === 'completed');
      if (finished && 'id' in event) {
        return this.permits.release(event.id).then(() => undefined);
      }
      return undefined;
    },
  });
  private readonly permits = new DurabilitySemaphoreQueue<Job>({
    scheduler: this.scheduler,
    clientId: this.ctx.id.toString(),
    server: limits.with(timeout, 5_000),
    // Operation IDs equal item IDs, so a repeated admit() is a no-op.
    admit: (items: AdmittedItem<Job>[]): Promise<void> =>
      Promise.all(
        items.map((item) => this.jobs.run({ id: item.id, payload: item.body }))
      ).then(() => undefined),
    // The lifecycle hook above is best-effort; this returns any permit it missed.
    finished: async (ids) => {
      const results = await Promise.all(
        ids.map((id) => this.jobs.run.getResult(id))
      );
      return ids.filter((_, index) => results[index]?.status !== 'pending');
    },
  });

  submit(jobs: Job[]) {
    return this.permits.enqueue(
      jobs.map((job) => ({
        id: job.id,
        key: job.accountId,
        group: job.workflowId,
        body: job,
      }))
    );
  }

  status(id: string) {
    return this.permits.status(id);
  }

  override alarm(info?: AlarmInvocationInfo) {
    return this.scheduler.alarm(info);
  }
}

/** Spread one account's jobs over a few shards; any stable function works. */
const shardFor = (accountId: string, width = 4): string => {
  let hash = 0;
  for (const char of accountId) {
    hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
  }
  return `${accountId}-${hash % width}`;
};

export default {
  async fetch(request, env): Promise<Response> {
    if (request.method !== 'POST') {
      return new Response('POST a job array', { status: 405 });
    }
    // Demo input only: authenticate and validate untrusted payloads in an application.
    const jobs = await request.json<Job[]>();
    const byShard = new Map<string, Job[]>();
    for (const job of jobs) {
      const shard = shardFor(job.accountId);
      byShard.set(shard, [...(byShard.get(shard) ?? []), job]);
    }
    const results = await Promise.all(
      [...byShard].map(([shard, shardJobs]) =>
        env.JOB_SHARDS.getByName(shard).submit(shardJobs)
      )
    );
    return Response.json(results, { status: 202 });
  },
} satisfies ExportedHandler<Env>;

Behind the scenes

Durable state: queue item, grant, pacing-key, and optional lease tables hold local demand and allocated capacity. An internal outbox channel, durability.semaphore-queue, persists cumulative claim/release reports on the same scheduler.

enqueue() idempotently inserts items by ID. Demand is aggregated per semaphore key and group. Returned grants are stored transactionally; local claims and their outbound reports commit together. Grant-based work becomes admitted after holder confirmation. Lease-drawn work can be admitted locally because its capacity was already reserved at the holder.

After a crash: admit() runs after admission commits and may be called again until it returns. Register actual operations using the same stable item ID so repeat admission is a no-op. Lost report replies are repaired by durable cumulative accounting, not by assuming a failed RPC did nothing.

Releasing: a successful lifecycle hook is a fast path, not a guarantee. Supply finished(ids) to check authoritative durable outcomes and recover a missed release. Without it, capacity can remain charged indefinitely. Define how not_found should be interpreted before purging operation history.

Request-in-flight state and blocked-reason diagnostics are in memory and advisory. The account concurrency composition explains the complete holder/queue/operation design and its external-task limits.

Implementation: semaphore-queue.ts, semaphore-queue-store.ts.

Queueing work in a client Durable Object

DurabilitySemaphoreQueue is the client half: it queues items per semaphore key in arrival order, drives the protocol above against the holder, and hands items to the application once a permit is confirmed. Claims and releases are published through an internal DurabilityOutbox channel as cumulative counts, so a crash or lost response never loses or double-counts a permit.

import { DurableObject } from 'cloudflare:workers';
import { timeout } from '@durability/transforms';
import { Durability, DurabilityScheduler } from 'durability';
import { DurabilitySemaphoreQueue } from 'durability/experimental/semaphore';

/** Many shards, each queueing jobs for many accounts. */
export class JobShard extends DurableObject<Env> {
  private readonly scheduler = new DurabilityScheduler({ context: this.ctx });
  private readonly jobs = new Durability({
    scheduler: this.scheduler,
    handlers: { run: (job: Job) => runJob(this.env, job) },
    // Return the permit as soon as the operation reaches a terminal state.
    onLifecycleEvent: (event) => {
      if (
        event.type === 'terminal' ||
        (event.type === 'attempt_settled' && event.outcome === 'completed')
      ) {
        return this.permits.release(event.id).then(() => undefined);
      }
    },
  });
  private readonly permits = new DurabilitySemaphoreQueue<Job>({
    scheduler: this.scheduler,
    clientId: this.ctx.id.toString(),
    server: limits.with(timeout, 5_000),
    admit: (items) =>
      Promise.all(
        items.map((item) => this.jobs.run({ id: item.id, payload: item.body }))
      ),
    // The hook above is best-effort; this check returns any permit it missed.
    finished: async (ids) => {
      const results = await Promise.all(
        ids.map((id) => this.jobs.run.getResult(id))
      );
      return ids.filter((_, index) => results[index]!.status !== 'pending');
    },
  });

  submit(job: Job) {
    return this.permits.enqueue([
      { id: job.id, key: job.accountId, group: job.workflowId, body: job },
    ]);
  }

  override alarm(info?: AlarmInvocationInfo) {
    return this.scheduler.alarm(info);
  }
}
  • States. An item is queued until permits arrive, claiming while the holder confirms the claim, and admitted once admit() has been handed the item. Items drawn from a lease skip claiming and are admitted at once; AdmittedItem.via says which path admitted an item. admit() runs after the admission commits and is retried under retries.delay until it returns, so it must be idempotent on item ID. release(id) returns the permit; cancel(id) removes an item that is still queued.
  • Releasing. Call release(id) when the work ends, and pass finished(ids) so a release that never happens — a lifecycle hook is best-effort, a crash can come between the work ending and the call — does not hold the permit forever. The queue asks finished about each admitted item finishedCheckMs (default 60 seconds) after admit() returned, and again at that interval, releasing the ones it reports done. Without finished, a lost release() leaks the permit until the item is released by hand.
  • Requests. Each key is asked at most once per nextRequestAt, with demand aggregated per group from the oldest queued items. A failed request, or one unanswered after attemptTimeoutMs, is retried with backoff. Items whose key already holds unclaimed permits — a grant sized for demand that was cancelled, say — are admitted without a request.
  • Blocked attribution. status(id) on a queued item carries the holder’s most recent reason for that key and group (limit, group_limit, …); blocked lifecycle events report counts per group. Attribution is in-memory and best-effort.
  • Composition. The queue shares the scheduler with everything else in the object and creates its own outbox channel (durability.semaphore-queue) on it; one queue per scheduler. Pass DurabilitySemaphore.client() as server — caller transforms apply to every request and sync.

DurabilitySemaphoreQueue.migrate(this.ctx, null) removes the queue schema. See the typechecked account concurrency composition.

Type to search…

↑↓ navigate↵ selectEsc close