Concurrency limits

A concurrency limit caps how many pieces of one kind of work run at once, across every workflow that takes part. You declare it once with a capacity. A workflow takes a slot with acquireSlot before it starts the work, and gives it back with releaseSlot when the work ends. The slot lives in the box, not in the run, so the workflow that gives it back can be a different one.

When you need it

Use a limit when the cap belongs to a resource, not to one workflow. For example:

  • Your telephony provider lets you hold 20 calls at once, and three workflows place calls.
  • A partner API refuses more than 5 open sessions per account.
  • A long job starts in one workflow and ends in a webhook handled by another, and you must not start a new one until a running one ends.

If you only need one run of a workflow at a time, use serial execution instead. It needs no code.

Declare a limit

Add limits to stackbone.config.ts. Each key is a limit name: lowercase letters, digits, ., _ or -, at most 64 characters.

stackbone.config.ts
import { defineWorkspace } from '@stackbone/sdk';

export default defineWorkspace({
  limits: {
    'voice-calls': { capacity: 20, maxHold: '45m' },
  },
});
Field Required What it sets
capacity Yes How many slots can be busy at once. A whole number of at least 1.
maxHold Yes The longest one slot stays busy: '45m', '2h', '90s', or milliseconds. There is no default value.

maxHold has no default on purpose. A run that crashes before it releases its slot would otherwise keep that slot busy for good. Set it a little above the longest time the real work takes.

A config with only limits keeps the convention scan: the CLI still finds every workflow under workflows/. If you also list workflows, that list replaces the scan, as described in serial execution. defineWorkspace checks each limit when the config loads, and an error names the limit and the field. capacity is at most 100000 and maxHold at most 30 days. In stackbone dev, a change to limits takes effect when you restart stackbone dev.

Take a slot in one workflow, release it in another

Import the helpers from @stackbone/sdk/workflow. In this example, workflow A takes a slot and starts a call. The call ends later, and workflow B receives that event and gives the slot back.

// workflows/place-call.workflow.ts (workflow A)
import { z } from 'zod';
import { acquireSlot, SlotTimeoutError } from '@stackbone/sdk/workflow';

export const inputSchema = z.object({ callId: z.string() });
export const outputSchema = z.object({ slotId: z.string().nullable() });

export async function placeCallWorkflow(input: { callId: string }) {
  'use workflow';
  try {
    const slot = await acquireSlot('voice-calls', { label: input.callId, timeout: '30m' });
    await dialOut(input.callId, slot.id); // the provider reports back with slot.id
    return { slotId: slot.id };
  } catch (error) {
    if (SlotTimeoutError.is(error)) return { slotId: null }; // no slot free in 30 minutes
    throw error;
  }
}

async function dialOut(callId: string, slotId: string) {
  'use step';
  // Start the call and pass slotId along, so the "call ended" event carries it.
}
// workflows/call-ended.workflow.ts (workflow B)
import { z } from 'zod';
import { releaseSlot } from '@stackbone/sdk/workflow';

export const inputSchema = z.object({ slotId: z.string() });
export const outputSchema = z.object({ released: z.boolean() });

export async function callEndedWorkflow(input: { slotId: string }) {
  'use workflow';
  return releaseSlot(input.slotId);
}

Workflow A completes as soon as the call starts. Its slot stays busy until workflow B calls releaseSlot, or until maxHold passes.

Helper Returns Where you call it
acquireSlot(limit, { timeout?, label? }) { id, limiter } The workflow body only
releaseSlot(slotId) { released: boolean } The workflow body or a step
withLimit(limit, fn, { timeout?, label? }) What fn returns The workflow body only

acquireSlot and withLimit wait on a durable hook, so they belong in the 'use workflow' function. Inside a 'use step' they fail. releaseSlot is a step of its own.

label is an id you choose (a call id, an order id). Studio shows it next to the slot. Do not put personal data in it.

releaseSlot is idempotent. A second call with the same id, or a call for a slot that maxHold already took back, returns { released: false } and changes nothing. You can retry it safely.

Hold a slot for work inside one run

When the work starts and ends in the same workflow, use withLimit. It takes a slot, runs your function, and releases the slot when the function returns or throws.

import { withLimit } from '@stackbone/sdk/workflow';

export async function exportReportWorkflow(input: { reportId: string }) {
  'use workflow';
  return withLimit(
    'partner-api',
    async () => {
      const file = await buildReport(input.reportId); // a 'use step'
      return { file };
    },
    { label: input.reportId },
  );
}

Wait in the queue

When every slot is busy, acquireSlot puts the run in a queue for that limit. The queue is first come, first served, and it is durable: a box restart keeps it. While the run waits, its status stays running, and its step list shows a hook step named Esperando hueco · <limit>. The step closes when the run gets its slot.

The run waits up to timeout, which defaults to 24 hours. Pass a duration ('10m') or milliseconds. When the timeout passes first, the run leaves the queue and acquireSlot throws SlotTimeoutError. Check for it with SlotTimeoutError.is(error), which works across module copies where instanceof can fail. The error carries limiter and timeout.

A run that waits in the queue holds no slot. If you cancel it, or it fails, the box removes it from the queue.

What frees a slot

The next run in the queue gets a slot when one of these happens:

  • A workflow calls releaseSlot(slotId), or withLimit returns.
  • An operator releases the slot from Studio or the CLI.
  • The slot stays busy longer than maxHold. The box takes it back.
  • Someone raises the capacity.
  • A run that was waiting, or that the box had just picked, ends before it confirms the slot.

A slot the box gives to a waiting run is reserved for 2 minutes. If the run does not confirm it in that time (for example, the box restarted), the slot goes to the next run and the late run moves to the end of the queue, where it keeps waiting inside what is left of its timeout. A background pass every 15 seconds handles these cases and the maxHold expiry, so a freed slot can take up to 15 seconds to reach the queue after a crash.

Ending a run does not release a slot it already holds. That slot belongs to the work the run started, and only releaseSlot or maxHold frees it.

See and operate limits

Studio has a Limits screen. It lists each limit with its capacity, the busy and waiting counts, and its max hold. Open a limit to see its slots: busy slots first, with their run and label, then the queue in order. From there you can release a busy slot. Edit limit changes the capacity and the max hold.

The CLI does the same against a running installation:

stackbone limits list                          # every limit: busy/capacity, waiting, maxHold
stackbone limits slots voice-calls             # one limit's slots, busy first, then the queue
stackbone limits release <slotId> --yes        # give a busy slot back
stackbone limits capacity voice-calls 30       # override the declared capacity
stackbone limits capacity voice-calls reset    # go back to the value in stackbone.config.ts
stackbone limits max-hold voice-calls 2h       # override the declared maxHold
stackbone limits max-hold voice-calls reset    # go back to the value in stackbone.config.ts

Every verb targets the local-dev installation by default, or any installation with --agent <id>. See stackbone limits.

Release a slot by hand only when its release got lost: the work ended, and no workflow called releaseSlot. Otherwise the slot comes back on its own at maxHold.

A capacity or a max hold set in Studio or with the CLI wins over the value in stackbone.config.ts until you reset it, and a deploy does not clear it.

The box reads the capacity each time it hands out a slot. Raising it grants waiting runs at once. Lowering it takes no slot back: busy slots finish, and the box hands out new ones only when the busy count drops below the new capacity.

The max hold is read once per slot, when the box grants it: the slot's expiry is set then and never changes. A new max hold applies only to slots granted after the change. Lowering it takes back no running work, and raising it does not extend a slot that is already busy. The max hold override takes the same values as maxHold, up to 30 days.

Limits and serial mode

You need Use
One run of a workflow at a time, in arrival order Serial execution
At most N pieces of work at once, shared by several workflows A concurrency limit
Work that starts in one workflow and ends in another A concurrency limit with acquireSlot and releaseSlot

Serial mode queues whole runs before they start. A limit queues inside a run, at the acquireSlot call, so the steps before it run at once.

What limits do not do

  • They do not cap a rate. A limit counts busy slots, not calls per second.
  • They have no priorities. The queue is first come, first served.
  • They count per box. Two installations of the same agent each have their own slots.

What's next

BUILT WITH ❤️ FROM CANADA AND SPAIN