Files
skills/docs/foundation/flow/workflow/src/steps/parallel-step/parallel-step.md
2026-05-29 08:20:56 +01:00

3.4 KiB

parallel-step

(no submodules)

Purpose

ParallelStep groups multiple Step instances and runs them concurrently against a shared context. It is the only place in @suaveplan/workflow that introduces concurrency, which keeps the engine's main loop strictly sequential and easy to reason about. The class supports two execution modes: an unbounded mode (maxConcurrency: Infinity — default) that fans all steps out via a single Promise.allSettled, and a bounded mode that batches them in groups of maxConcurrency.

The group also exposes a compensateAll helper that walks every member step in reverse insertion order and invokes its compensation hook, which is how the engine and saga unwind a partially-completed parallel group on failure.

Features

  • addStep(step) fluent API to build up the group after construction.
  • failFast short-circuit between batches when any error has been observed.
  • maxConcurrency bounded batching (when finite).
  • Promise.allSettled semantics inside each batch so a single throw never aborts sibling steps in the same batch.
  • Guard short-circuit: a step whose shouldExecute returns false is recorded as "skipped" without invoking run.
  • Injectable Clock for deterministic timestamps.

Usage: Basic Example

import { createParallelStep, createStep } from "@suaveplan/workflow";

const group = createParallelStep<{ url: string }>({
	groupId: "fetch-group",
	name: "Fetch resources",
});

group
	.addStep(createStep({ id: "a", name: "A", execute: () => "a" }))
	.addStep(createStep({ id: "b", name: "B", execute: () => "b" }));

const result = await group.run({ url: "https://example.com" });

Usage: Advanced Example

import { createParallelStep, createStep } from "@suaveplan/workflow";

const group = createParallelStep<{ jobIds: string[] }>({
	groupId: "process",
	name: "Process jobs",
	maxConcurrency: 2,
	failFast: true,
});

for (const id of ["a", "b", "c", "d"]) {
	group.addStep(
		createStep({
			id,
			name: `process-${id}`,
			execute: async (ctx) => {
				if (id === "b") throw new Error("nope");
				return id;
			},
			compensate: async () => {
				await unprocess(id);
			},
		}),
	);
}

const result = await group.run({ jobIds: [] });
if (result.status === "failed") {
	await group.compensateAll({ jobIds: [] });
}

API Reference

  • ParallelResult<TCtx> — { groupId; status; results; startedAt; completedAt; errors; }.
  • ParallelStepOptions — { groupId; name; failFast?; maxConcurrency?; clock?; }.
  • ParallelStep<TCtx> —
    • size, steps accessors
    • addStep(step) returns this
    • run(context): Promise<ParallelResult<TCtx>>
    • compensateAll(context): Promise<void>
  • createParallelStep<TCtx>(options): ParallelStep<TCtx>.

Implementation Notes

In bounded mode, batches are computed by slicing _steps at maxConcurrency boundaries (so a [a, b, c, d] group with maxConcurrency: 2 produces [[a, b], [c, d]]). When failFast: true, the second batch is skipped if the first produced any error — but errors are already collected for every step in the failing batch, so callers see every sibling outcome that ran before the short-circuit.

compensateAll reverses the original insertion order so the most recently added (and therefore most recently completed) step rolls back first. This matches saga semantics and matches what callers expect when authoring inverse pairs (reserve → release, charge → refund).