fan-out and capacity

why

unbounded dispatch is a burst of load with no owner: an await Promise.all over every eligible record fans out until a provider, account, or runtime limit is hit, and a crash mid-flight leaves nobody knowing which items were dispatched. claiming work durably before a bounded dispatch makes every unit owned before it runs, so continuation reads persisted outcomes instead of lost promises, and each shared capacity boundary decides its own concurrency explicitly.

rule

fan-out is a durable capacity decision, not an unbounded Promise.all. a parent workflow pages eligible work, assigns bounded child workflows or steps, and records ownership before dispatch. every provider, account, and workspace has an explicit concurrency and rate boundary.

required approach

  1. query a bounded page of durable, eligible records in deterministic order.
  2. record the lease, assignment, or claimed state under a stable identity.
  3. dispatch a bounded number of child workflows.
  4. each child persists its terminal or retryable outcome independently.
  5. the parent determines continuation from durable outcomes, not from a local array of promises.

use a durable semaphore, lease, provider queue, or workflow-level limit where the constrained resource is actually shared. name the capacity owner: provider, workspace, account, or another shortest exact domain word.

select the capacity boundary

constrained resourcedurable controlwhy
external provider quotaprovider-scoped queue/semaphoreseparate processes still consume one provider limit.
tenant/account fairnessaccount-scoped lease or queueone noisy owner cannot consume all shared capacity.
one durable parent runworkflow child limitparent owns continuation and aggregate progress.
one database mutation identityunique claim/lease recordcompeting workers need one authoritative winner.
local CPU for one short computationbounded in-process workonly valid when no other process must coordinate it.

lease decision process

claim eligibility durably before dispatch: owner, expiry, stable identity. the claim holder may start the child. a retry either observes its own existing claim/outcome or waits for expiry — never assumes an in-memory array reveals global work. expiry recovers from a failed owner, it does not license duplicating a completed fact: the child still validates terminal state and idempotency on entry.

shortcutfailureselected direction
page rows then dispatch without a claimtwo parents can process the same eligible rowclaim/assign before dispatch.
unbounded Promise.allquota, memory, and error handling scale with row countbounded page and concurrency.
process-local counteranother runtime ignores the same limitdurable/provider-scoped control.
infer completion from dispatched childrencrash after dispatch leaves invisible workderive continuation from durable child outcomes.

good and bad

bad

const rows = await db.select(eligible);
await Promise.all(rows.map((row) => runChild(row)));

five eligible rows. two parents run this code at once (one crashed earlier, restarted, sees the same eligible set). both dispatch ten concurrent promises (50 total, unguarded). the payment provider's rate limit is 10/minute — quota exhausted for five minutes, legitimate customer payments fail. neither parent recorded a claim: if the first crashes after dispatch, the second restarts and processes the same rows again, duplicating charges.

good

page eligible rows in deterministic order
-> claim/assign each row under a stable identity
-> dispatch bounded child workflows within the capacity limit
-> derive continuation from durable child outcomes

five eligible rows. parent A claims rows 1-3, dispatches 3 children (durable claim recorded). parent B queries unclaimed rows: only 4-5 remain, claims and dispatches those. if parent A crashes between claim and completion, the lease expires and a later parent reclaims. the provider never sees duplicate requests; concurrency is bounded by the declared limit.

verification

run competing parents against the same eligible set, force one child crash, exhaust the constrained resource. confirm: one durable owner per item, bounded concurrent work, observable blocked/pending records, and a later run resuming from leases/outcomes instead of replaying completed items.

do not

  • launch one concurrent promise for every database row.
  • assume a process-local counter survives restart or coordinates another workflow instance.
  • let one failed child erase the visible outcomes of its siblings.
  • continue an exhausted parent without recording what remains pending or blocked.

search pages

go to any page