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
- query a bounded page of durable, eligible records in deterministic order.
- record the lease, assignment, or claimed state under a stable identity.
- dispatch a bounded number of child workflows.
- each child persists its terminal or retryable outcome independently.
- 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 resource | durable control | why |
|---|---|---|
| external provider quota | provider-scoped queue/semaphore | separate processes still consume one provider limit. |
| tenant/account fairness | account-scoped lease or queue | one noisy owner cannot consume all shared capacity. |
| one durable parent run | workflow child limit | parent owns continuation and aggregate progress. |
| one database mutation identity | unique claim/lease record | competing workers need one authoritative winner. |
| local CPU for one short computation | bounded in-process work | only 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.
| shortcut | failure | selected direction |
|---|---|---|
| page rows then dispatch without a claim | two parents can process the same eligible row | claim/assign before dispatch. |
unbounded Promise.all | quota, memory, and error handling scale with row count | bounded page and concurrency. |
| process-local counter | another runtime ignores the same limit | durable/provider-scoped control. |
| infer completion from dispatched children | crash after dispatch leaves invisible work | derive 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.