Skip to content

Parallel work

Two ways to run things at once: a fixed group of different tasks, or one task fanned out over a list.

.parallelTasks(): a group of different tasks

ts
.parallelTasks([
  {
    name: "checkFraud",
    execute: async ({ input }) => ({ score: await score(input.customerId) }),
  },
  {
    name: "calculateTax",
    execute: async ({ results }) => ({ tax: results.validate.total * 0.2 }),
  },
])
// results.checkFraud and results.calculateTax are both typed

Every branch sees the same results, a snapshot from before the group started. A branch cannot read its siblings' output; that is what makes them parallel.

At least two tasks are required. One task in a group is just a task, and the builder throws rather than let you write it.

.mapParallelTask(): one task, many items

Identical to .mapTask() except every iteration starts at once:

ts
.mapParallelTask({
  name: "notifyWarehouses",
  iterator: ({ context }) => context.regions,
  execute: async ({ value: region }) => notify(region),
})
// results.notifyWarehouses is Ack[]

Because they all start together, no iteration can see what the others produced; results[taskName] is not populated mid-flight the way it is in the sequential version.

An empty iterator result is not an error; the task produces [] and the saga moves on.

Result ordering

Branches settle in whatever order they settle, but results are collected by position. results.notifyWarehouses[0] is always the first item's output, no matter which finished first.

Limiting concurrency

By default every branch starts at once. Fanning out over a thousand items means a thousand simultaneous requests, which is how you take down your own database:

ts
.mapParallelTask(
  {
    name: "notifyWarehouses",
    iterator: ({ input }) => input.regions,   // 1,000 of them
    execute: async ({ value: region }) => notify(region),
  },
  { concurrency: 10 },
)

At most ten run at a time; the rest wait their turn. Results still come back in input order, and everything else (failFast, AggregateError) behaves the same. concurrency: 1 is sequential, which is .mapTask() with extra steps.

The cap covers rollback too: if the saga later fails, the group's compensations run ten at a time, not five hundred. See Compensation order.

Must be a positive integer, checked when you build the saga.

A slot frees the instant a branch settles

concurrency workers pull from one shared queue, so a free worker takes the next unstarted branch rather than waiting on a particular earlier one, so an unusually slow branch delays only itself. Ordering between branches is not otherwise guaranteed: they are started in input order, and settle in whatever order they settle.

When a branch fails

The default is to wait for every branch, then reject with an AggregateError whose message names the failed branches and whose .errors holds each failure:

ts
if (!result.success && result.error instanceof AggregateError) {
  result.error.errors.map((e) => e.message);
}

Branches that did succeed are still registered for rollback, and their compensations run as a group, under the same concurrency cap.

Failing fast

ts
.parallelTasks(tasks, { failFast: true })

Rejects as soon as one branch rejects, with that branch's own error rather than an AggregateError. Whatever had already completed is still compensated.

Branches that have not started yet, because concurrency was holding them back, are never started at all.

Fail-fast cancels, it does not abandon

The branches already in flight when you bail out are asked to stop, through the signal their execute was handed, and they fail with an AbortError of their own. A task that forwards that signal stops there. A task that ignores it runs to completion, but the group waits for it either way, and whatever it produced is registered, so it is still rolled back.

So failFast buys you time only from branches that honour their signal. It never costs you a rollback.

Rollback of a group

A parallel group rolls back as a group: its compensations run concurrently with each other, but the group as a whole is still ordered against the rest of the saga. See Compensation.

MIT Licensed