Skip to content

Account-wide concurrency

Enforce one account budget across job shards and reclaim permits when a best-effort release is lost.

Updated

This example admits jobs against an account-scoped semaphore before running them as durable operations. It separates the globally shared budget from the objects that queue and execute work:

  • AccountLimit owns an account key’s semaphore state and optional workflow group limits.
  • JobShard owns admission queue state, operation records, and calls to JOB_API.
  • A request groups jobs by a deterministic shard name before submitting them.

The resulting limit is global for an account even when many job shards use the same account key. It is a bound on admitted durable operations, not a blanket proof about every external process that might ignore cancellation.

Source and prerequisites

  • Worker source, Wrangler configuration, and Vite transform configuration
  • Provision JOB_API, set ACCOUNT_CONCURRENCY, and deploy the AccountLimit and JobShard Durable Object classes. The Vite transform maps the semaphore client target to the exported class/Wrangler metadata.
  • Make JOB_API honor Idempotency-Key; retries can create repeated physical HTTP requests.

The pieces are semaphore, semaphore queue, operations, a per-object scheduler, and the queue’s internal outbox.

Architecture

How account concurrency uses two Durable Objects

Shows Worker, JobShard. Focuses on Worker.JobShard · one per shard nameWorkerfetch handlerJobShardyour Durable Objectsubmit(jobs)

1/12 · Route each account to one shard

The demo’s shardFor(accountId, 4) returns one stable name per account; it does not spread an account across four shards. getByName(shard) reaches the JobShard with that name.

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>;

AccountLimit and JobShard each create their own DurabilityScheduler and delegate alarm() to it. In a shard, that scheduler drives the queue, its internal outbox, and jobs operations together. It does not coordinate state across objects: the semaphore holder is the cross-shard authority because every queue asks it for permits keyed by accountId.

State or responsibility Owner Stable identity
Account capacity and leases AccountLimit semaphore queue key: accountId
Workflow cap AccountLimit map queue group: workflowId
Pending admission and recovery JobShard queue/outbox item id: job.id
Executing/retrying work JobShard operation operation id: job.id
External side effect JOB_API HTTP Idempotency-Key: job.id

The repeated job ID is intentional across queue, operation, and API. It gives each retry a way to recognize the same logical job without making the three stores one transaction.

End-to-end flow

  1. The worker accepts only POST and parses a demo array of Job values. It groups them by shardFor(job.accountId) and sends every group to one JobShard.
  2. JobShard.submit enqueues each item with a stable job ID, key: accountId, group: workflowId, and the full job body. Repeating an admission with the same ID is intentionally a no-op.
  3. The queue uses the generated limits semaphore client to obtain permission from the account’s AccountLimit. All shards using the same account key contend on that one semaphore.
  4. AccountLimit has a dynamic account ceiling from ACCOUNT_CONCURRENCY; it may also impose a workflow group ceiling from workflowLimits.
  5. Strict lease provisioning grants up to 16 permits per lease with a ten-minute reporting lifetime. strict accounts for both grants and lease footprint under the configured account limit, rather than allowing local shards to over-reserve capacity. Renewal and retirement preserve accounting for work that may still be running; expiry does not simply erase its footprint.
  6. When the queue admits an item, its admit callback calls jobs.run({ id: item.id, payload: item.body }). The operation uses the same ID as the queue item, so an admission retry does not start a distinct operation.
  7. run posts to JOB_API with Idempotency-Key: id, passes the operation abort signal, and retries at most three operation attempts.
  8. On a completed attempt or terminal event, the lifecycle hook releases the permit immediately. The queue’s finished(ids) path also asks jobs.run.getResult(id) and returns IDs no longer pending, reclaiming permits when that best-effort lifecycle release was missed.

The connecting code

The stable identity must cross the queue-to-operation boundary:

admit: (items) => Promise.all(
  items.map((item) => this.jobs.run({ id: item.id, payload: item.body }))
).then(() => undefined),

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

The lifecycle hook is a low-latency release path, not the sole source of recovery:

onLifecycleEvent: (event) => {
  const finished = event.type === 'terminal' ||
    (event.type === 'attempt_settled' && event.outcome === 'completed');
  return finished && 'id' in event
    ? this.permits.release(event.id).then(() => undefined)
    : undefined;
},
finished: async (ids) => {
  const results = await Promise.all(ids.map((id) => this.jobs.run.getResult(id)));
  return ids.filter((_, i) => results[i]?.status !== 'pending');
},

A naive design and its break

Do not keep independent running counters in each job shard. A counter can be incremented independently by several shards and exceed an account’s global ceiling; a crash after work settles but before decrement leaks capacity forever.

The walkthrough’s “Per-shard counters versus one account semaphore” scenario steps through each gap in that design and the line of this composition that closes it.

The semaphore is the shared capacity mechanism. Strict leases prevent a shard’s prefetched lease from silently making grants plus reservations exceed the limit. The queue persists admission and can re-check operation completion, so it has a recovery path for a lost release notification.

Lost release recovery

The walkthrough’s “Recover a lost release” scenario steps through this path: the attempt completes but the shard is evicted before the hook’s release commits, AccountLimit keeps counting the permit, and the shard’s own alarm later runs finished(), which reads the stored operation result and releases the item. The same check returns the permit of a job whose final attempt fails: that attempt settles as attempt_settled with outcome failed, which the hook does not match.

The recovery only works because admission is retried with stable IDs and the operation result remains inspectable. getResult(id) has a retention boundary: once operation history is purged it can be not_found/unavailable. Treat that case deliberately in production—do not purge result history before all queue recovery paths have resolved, or retain a separate durable completion record.

Sharding note

The design permits many JobShard objects to submit to a shared account key. This demo does not spread one account across four shards: shardFor(accountId, width = 4) hashes the account but returns ${accountId}-${hash % width}. For a given account it always returns one stable name; the width merely selects that account’s suffix. A production router may distribute an account across multiple stable shards, but every such shard must still submit with the same key: accountId to preserve the shared budget.

Failure and semantics table

Situation What this composition does Remaining concern
Repeated submit/admit Queue item ID equals operation ID, making retries idempotent at both boundaries. Clients must choose stable, unique job IDs.
Several shards submit one account All ask one account-keyed semaphore. Do not replace it with per-shard counters.
Fast lifecycle release is lost Queue’s finished() checks the operation and returns non-pending IDs for release. Keep operation results long enough; handle not_found after purge.
Job API fails Operation has at most three attempts; the queue’s finished() check releases a failed job. API must honor the idempotency key.
Workflow limit changes limitWorkflow changes an in-memory Map; it affects later rounds. The group-limit configuration is ephemeral and is lost on object eviction; persist/configure it externally if it matters.
Lease holder is idle or disappears Strict leases have a 10-minute reporting lifetime and reserve at most 16 permits per lease. Expired/retired footprint is not blindly forgotten while work may still run; reconcile usage and tune lease size/TTL.
Abort is signalled to external work signal is passed to fetch. An abort-ignoring API may continue running after the operation is no longer active, so the semaphore does not prove universal external concurrency.

Production work left to the application

Authenticate and validate untrusted request bodies; the example explicitly calls its input demo-only. Persist administrative workflow limits instead of relying on the Map, define queue/result retention, and observe permit usage and stuck work. Treat the account semaphore as admission control, while JOB_API idempotency and cancellation behavior govern what actually happens outside Workers. If you change routing to spread a single account over shards, test failures and retries with the same account key and stable job IDs.

Type to search…

↑↓ navigate↵ selectEsc close