}`;
+
+/**
+ * Which method belongs to which point.
+ *
+ * The type pins each point to exactly its own method name, so a missing entry, a stray
+ * entry, and a point wired to the wrong method all fail to compile. If a method is renamed
+ * on the interface without updating this list, {@link pointsImplementedBy} stops compiling.
+ */
+export const ENFORCEMENT_POINT_METHODS: {
+ readonly [P in EnforcementPoint]: `on${Capitalize}`;
+} = {
+ workflowSave: 'onWorkflowSave',
+ workflowPublish: 'onWorkflowPublish',
+ workflowStart: 'onWorkflowStart',
+ workflowTransfer: 'onWorkflowTransfer',
+ credentialDecrypt: 'onCredentialDecrypt',
+ contentImport: 'onContentImport',
+};
+
+/** Constructor of a class implementing {@link RegisteredPolicyCheck}, for DI. */
+export type PolicyCheckClass = Constructable;
+
+/**
+ * Which points a check actually implements.
+ *
+ * Kept next to the mapping above so callers don't each work it out themselves.
+ */
+export const pointsImplementedBy = (check: RegisteredPolicyCheck): EnforcementPoint[] =>
+ ENFORCEMENT_POINTS.filter(
+ (point) => typeof check[ENFORCEMENT_POINT_METHODS[point]] === 'function',
+ );
diff --git a/packages/@n8n/engine/AGENTS.md b/packages/@n8n/engine/AGENTS.md
index 67dc0b918e2..5a59c254b6d 100644
--- a/packages/@n8n/engine/AGENTS.md
+++ b/packages/@n8n/engine/AGENTS.md
@@ -35,9 +35,18 @@ a deployable engine worker) without touching core logic.
(in-memory default). The Postgres/ORM coupling lives *here only*.
- **Serving infra** — `server/` (express), `serve.ts` (standalone entrypoint),
Dockerfile. First candidate to be extracted later.
+- **Runtime factory** — `runtime/` (`createEngineRuntime`). Owns the engine's
+ internal topology: the queues, the stores, the handlers, the workers, the HTTP
+ app and the start/stop order. It takes the adapters a host chooses and returns
+ a running engine, so no host repeats the wiring. A data plane database is not
+ optional — the factory's type requires one, and each composition root refuses
+ to start without it rather than serving `/healthz` while unable to run a
+ workflow.
- **Composition roots** — `serve.ts` (standalone) and, in integrated mode,
- `packages/cli`. These construct the concrete adapters (the `DataSource`, etc.)
- and hand them in. **Construction lives here, not in the core.**
+ `packages/cli`. These construct the concrete adapters (the `DataSource`, the
+ admittance policy, the v1 step executor) and hand them to
+ `createEngineRuntime`. **Construction lives here, not in the core** — but the
+ topology does not: that belongs to the factory.
## Rules that keep the seams extractable
diff --git a/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts b/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts
index d22d5f8aed8..39983c8cf1b 100644
--- a/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts
+++ b/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts
@@ -30,18 +30,21 @@ describe('workflow_execution table (integration)', () => {
status: 'running',
mode: 'production',
graph: { nodes: [], edges: [] },
- triggerPayload: { foo: 'bar' },
+ triggerOutputs: [{ foo: 'bar' }],
finishedAt: null,
});
await repo.save(created);
- const found = await repo.findOneByOrFail({ id: created.id });
+ // NOTE: `findOne({ where })`, not `findOneByOrFail`: the latter's overload
+ // exceeds TypeScript's instantiation depth on the recursive `triggerOutputs`
+ // column type.
+ const found = await repo.findOneOrFail({ where: { id: created.id } });
expect(found.id).toBeTruthy();
expect(found.workflowId).toBe('wf-1');
expect(found.status).toBe('running');
expect(found.mode).toBe('production');
- expect(found.triggerPayload).toEqual({ foo: 'bar' });
+ expect(found.triggerOutputs).toEqual([{ foo: 'bar' }]);
expect(found.finishedAt).toBeNull();
expect(found.createdAt).toBeInstanceOf(Date);
expect(found.updatedAt).toBeInstanceOf(Date);
@@ -56,7 +59,7 @@ describe('workflow_execution table (integration)', () => {
status: 'running',
mode: 'production',
graph: { nodes: [], edges: [] },
- triggerPayload: null,
+ triggerOutputs: null,
finishedAt: null,
}),
);
@@ -66,7 +69,7 @@ describe('workflow_execution table (integration)', () => {
status: 'completed',
mode: 'production',
graph: { nodes: [], edges: [] },
- triggerPayload: null,
+ triggerOutputs: null,
finishedAt: new Date(),
}),
);
diff --git a/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts b/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts
index dc26532b1a8..0cf22272e6e 100644
--- a/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts
+++ b/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts
@@ -34,7 +34,7 @@ describe('workflow_step_execution table (integration)', () => {
status: 'running',
mode: 'production',
graph: { nodes: [], edges: [] },
- triggerPayload: null,
+ triggerOutputs: null,
finishedAt: null,
});
await repo.save(execution);
@@ -55,6 +55,7 @@ describe('workflow_step_execution table (integration)', () => {
async function seedStep(record: {
executionId: string;
nodeId: string;
+ iteration?: number;
status: StepStatus;
outputs?: StepSlots;
}): Promise<{ id: string }> {
@@ -67,7 +68,7 @@ describe('workflow_step_execution table (integration)', () => {
const executionId = await createExecution();
const repo = dataSource.getRepository(WorkflowStepExecution);
- const created = repo.create({ executionId, nodeId: 'node-a', status: 'queued' });
+ const created = repo.create({ executionId, nodeId: 'node-a', iteration: 0, status: 'queued' });
await repo.save(created);
const found = await repo.findOneOrFail({ where: { id: created.id } });
@@ -81,7 +82,11 @@ describe('workflow_step_execution table (integration)', () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- const { id } = await createStep(store, executionId, { nodeId: 'x', status: 'queued' });
+ const { id } = await createStep(store, executionId, {
+ nodeId: 'x',
+ iteration: 0,
+ status: 'queued',
+ });
const found = await dataSource
.getRepository(WorkflowStepExecution)
@@ -95,9 +100,9 @@ describe('workflow_step_execution table (integration)', () => {
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
const ids = await store.createSteps(executionId, [
- { nodeId: 'trigger', status: 'completed', outputs: [{}] },
- { nodeId: 'a', status: 'queued' },
- { nodeId: 'b', status: 'queued' },
+ { nodeId: 'trigger', iteration: 0, status: 'completed', outputs: [{}] },
+ { nodeId: 'a', iteration: 0, status: 'queued' },
+ { nodeId: 'b', iteration: 0, status: 'queued' },
]);
expect(ids).toHaveLength(3);
@@ -122,22 +127,22 @@ describe('workflow_step_execution table (integration)', () => {
it.each([
{
violation: 'a transition-only status',
- record: { nodeId: 'x', status: 'failed' },
+ record: { nodeId: 'x', iteration: 0, status: 'failed' },
message: 'transition method',
},
{
violation: 'outputs on a non-completed step',
- record: { nodeId: 'x', status: 'queued', outputs: [{}] },
+ record: { nodeId: 'x', iteration: 0, status: 'queued', outputs: [{}] },
message: 'with outputs',
},
{
violation: 'a completed step without outputs',
- record: { nodeId: 'x', status: 'completed' },
+ record: { nodeId: 'x', iteration: 0, status: 'completed' },
message: 'without a slot list',
},
{
violation: 'a completed step with null outputs',
- record: { nodeId: 'x', status: 'completed', outputs: null },
+ record: { nodeId: 'x', iteration: 0, status: 'completed', outputs: null },
message: 'without a slot list',
},
])('TypeOrmStepStore.createSteps rejects $violation at runtime', async ({ record, message }) => {
@@ -156,7 +161,9 @@ describe('workflow_step_execution table (integration)', () => {
it('cascades step deletion when the parent execution is deleted', async () => {
const executionId = await createExecution();
const stepRepo = dataSource.getRepository(WorkflowStepExecution);
- await stepRepo.save(stepRepo.create({ executionId, nodeId: 'a', status: 'queued' }));
+ await stepRepo.save(
+ stepRepo.create({ executionId, nodeId: 'a', iteration: 0, status: 'queued' }),
+ );
await dataSource.getRepository(WorkflowExecution).delete({ id: executionId });
@@ -166,14 +173,22 @@ describe('workflow_step_execution table (integration)', () => {
it('rejects a step referencing a non-existent execution (foreign key)', async () => {
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
await expect(
- createStep(store, '00000000-0000-7000-8000-000000000000', { nodeId: 'a', status: 'queued' }),
+ createStep(store, '00000000-0000-7000-8000-000000000000', {
+ nodeId: 'a',
+ iteration: 0,
+ status: 'queued',
+ }),
).rejects.toThrow();
});
it('TypeOrmStepStore.loadStep returns the step, or throws when absent', async () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- const { id } = await createStep(store, executionId, { nodeId: 'node-a', status: 'queued' });
+ const { id } = await createStep(store, executionId, {
+ nodeId: 'node-a',
+ iteration: 0,
+ status: 'queued',
+ });
const step = await store.loadStep(id);
@@ -183,6 +198,7 @@ describe('workflow_step_execution table (integration)', () => {
id,
executionId,
nodeId: 'node-a',
+ iteration: 0,
status: 'queued',
outputs: null,
error: null,
@@ -195,13 +211,18 @@ describe('workflow_step_execution table (integration)', () => {
it('TypeOrmStepStore.claimStep only claims a queued step', async () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- const { id } = await createStep(store, executionId, { nodeId: 'a', status: 'queued' });
+ const { id } = await createStep(store, executionId, {
+ nodeId: 'a',
+ iteration: 0,
+ status: 'queued',
+ });
// the winning claim gets the step back, already `running`
expect(await store.claimStep(id)).toEqual({
id,
executionId,
nodeId: 'a',
+ iteration: 0,
status: 'running',
outputs: null,
error: null,
@@ -215,25 +236,35 @@ describe('workflow_step_execution table (integration)', () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
// b is planned first, then a's failure lands before b's step:ready is claimed
- const { id } = await createStep(store, executionId, { nodeId: 'b', status: 'queued' });
- await seedStep({ executionId, nodeId: 'a', status: 'failed' });
+ const { id } = await createStep(store, executionId, {
+ nodeId: 'b',
+ iteration: 0,
+ status: 'queued',
+ });
+ await seedStep({ executionId, nodeId: 'a', iteration: 0, status: 'failed' });
expect(await store.claimStep(id)).toBeNull();
expect((await store.loadStep(id)).status).toBe('queued');
// a failure in one execution doesn't block claims in another
const otherExecutionId = await createExecution();
- const other = await createStep(store, otherExecutionId, { nodeId: 'a', status: 'queued' });
+ const other = await createStep(store, otherExecutionId, {
+ nodeId: 'a',
+ iteration: 0,
+ status: 'queued',
+ });
expect(await store.claimStep(other.id)).not.toBeNull();
});
it('TypeOrmStepStore.createSteps creates nothing once any step in the execution failed', async () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- await seedStep({ executionId, nodeId: 'a', status: 'failed' });
+ await seedStep({ executionId, nodeId: 'a', iteration: 0, status: 'failed' });
// rows planned after the cancellation sweep would be stuck queued forever
- const created = await store.createSteps(executionId, [{ nodeId: 'b', status: 'queued' }]);
+ const created = await store.createSteps(executionId, [
+ { nodeId: 'b', iteration: 0, status: 'queued' },
+ ]);
expect(created).toEqual([]);
const stepRepo = dataSource.getRepository(WorkflowStepExecution);
@@ -243,7 +274,7 @@ describe('workflow_step_execution table (integration)', () => {
it('TypeOrmStepStore.createSteps waits out a concurrently committing failure and creates nothing', async () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- const failing = await seedStep({ executionId, nodeId: 'a', status: 'running' });
+ const failing = await seedStep({ executionId, nodeId: 'a', iteration: 0, status: 'running' });
const failure = dataSource.createQueryRunner();
await failure.connect();
@@ -256,7 +287,9 @@ describe('workflow_step_execution table (integration)', () => {
]);
// the insert must park on the execution lock until the failure commits
- const create = store.createSteps(executionId, [{ nodeId: 'b', status: 'queued' }]);
+ const create = store.createSteps(executionId, [
+ { nodeId: 'b', iteration: 0, status: 'queued' },
+ ]);
await new Promise((resolve) => setTimeout(resolve, 150));
await failure.commitTransaction();
await failure.release();
@@ -267,8 +300,12 @@ describe('workflow_step_execution table (integration)', () => {
it('TypeOrmStepStore.claimStep waits out a concurrently committing failure and refuses', async () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- const failing = await seedStep({ executionId, nodeId: 'a', status: 'running' });
- const { id } = await createStep(store, executionId, { nodeId: 'b', status: 'queued' });
+ const failing = await seedStep({ executionId, nodeId: 'a', iteration: 0, status: 'running' });
+ const { id } = await createStep(store, executionId, {
+ nodeId: 'b',
+ iteration: 0,
+ status: 'queued',
+ });
// A failStep mid-transaction, with the lock held and the failed row
// uncommitted. Without the lock, the claim would see no failure and succeed.
@@ -295,7 +332,7 @@ describe('workflow_step_execution table (integration)', () => {
it('TypeOrmStepStore.completeStep persists outputs and marks the step completed', async () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- const { id } = await seedStep({ executionId, nodeId: 'a', status: 'running' });
+ const { id } = await seedStep({ executionId, nodeId: 'a', iteration: 0, status: 'running' });
expect(await store.completeStep(id, [[{ json: { ok: true } }]])).toBe(true);
@@ -307,7 +344,11 @@ describe('workflow_step_execution table (integration)', () => {
it('TypeOrmStepStore.completeStep and failStep only record a step that is running', async () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- const { id } = await createStep(store, executionId, { nodeId: 'a', status: 'queued' });
+ const { id } = await createStep(store, executionId, {
+ nodeId: 'a',
+ iteration: 0,
+ status: 'queued',
+ });
expect(await store.completeStep(id, [[{ json: { ok: true } }]])).toBe(false);
expect(await store.failStep(id, { name: 'Error', message: 'node blew up' })).toBe(false);
@@ -327,21 +368,25 @@ describe('workflow_step_execution table (integration)', () => {
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
const { id: queuedId } = await createStep(store, executionId, {
nodeId: 'a',
+ iteration: 0,
status: 'queued',
});
const { id: runningId } = await seedStep({
executionId,
nodeId: 'b',
+ iteration: 0,
status: 'running',
});
const { id: completedId } = await createStep(store, executionId, {
nodeId: 'c',
+ iteration: 0,
status: 'completed',
outputs: [{}],
});
// scoped to the execution: a sibling execution's queued work is untouched
const { id: otherId } = await createStep(store, otherExecutionId, {
nodeId: 'a',
+ iteration: 0,
status: 'queued',
});
@@ -356,7 +401,7 @@ describe('workflow_step_execution table (integration)', () => {
it('TypeOrmStepStore.failStep persists the error and marks the step failed', async () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- const { id } = await seedStep({ executionId, nodeId: 'a', status: 'running' });
+ const { id } = await seedStep({ executionId, nodeId: 'a', iteration: 0, status: 'running' });
const error = {
name: 'Error',
@@ -377,7 +422,11 @@ describe('workflow_step_execution table (integration)', () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- const { id } = await createStep(store, executionId, { nodeId: 'x', status: 'skipped' });
+ const { id } = await createStep(store, executionId, {
+ nodeId: 'x',
+ iteration: 0,
+ status: 'skipped',
+ });
const step = await store.loadStep(id);
expect(step.status).toBe('skipped');
@@ -385,87 +434,126 @@ describe('workflow_step_execution table (integration)', () => {
expect(step.error).toBeNull();
});
- it('TypeOrmStepStore.loadStepsByNodeIds returns rows keyed by node id, omitting absent nodes', async () => {
+ it('TypeOrmStepStore.loadStepsByKeys returns rows keyed by step key, omitting absent keys', async () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- const { id: aId } = await seedStep({ executionId, nodeId: 'a', status: 'running' });
+ const { id: aId } = await seedStep({
+ executionId,
+ nodeId: 'a',
+ iteration: 0,
+ status: 'running',
+ });
// a multi-slot output with a dead slot round-trips as stored
await store.completeStep(aId, [[{ json: { from: 'a' } }], null]);
- const { id: bId } = await createStep(store, executionId, { nodeId: 'b', status: 'skipped' });
- await createStep(store, executionId, { nodeId: 'c', status: 'queued' });
+ const { id: bId } = await createStep(store, executionId, {
+ nodeId: 'b',
+ iteration: 0,
+ status: 'skipped',
+ });
+ await createStep(store, executionId, { nodeId: 'c', iteration: 0, status: 'queued' });
// scoped to the execution: a sibling execution's row for 'a' must not leak in
const otherExecutionId = await createExecution();
- await createStep(store, otherExecutionId, { nodeId: 'a', status: 'queued' });
+ await createStep(store, otherExecutionId, { nodeId: 'a', iteration: 0, status: 'queued' });
- const steps = await store.loadStepsByNodeIds(executionId, ['a', 'b', 'c', 'd']);
+ const steps = await store.loadStepsByKeys(
+ executionId,
+ ['a', 'b', 'c', 'd'].map((nodeId) => ({ nodeId, iteration: 0 })),
+ );
// 'd' has no row, so it has no key — absence means "not planned yet"
- expect(Object.keys(steps).sort()).toEqual(['a', 'b', 'c']);
- expect(steps.a).toMatchObject({
+ expect(Object.keys(steps).sort()).toEqual(['a@0', 'b@0', 'c@0']);
+ expect(steps['a@0']).toMatchObject({
id: aId,
+ iteration: 0,
status: 'completed',
outputs: [[{ json: { from: 'a' } }], null],
});
- expect(steps.b).toMatchObject({ id: bId, status: 'skipped', outputs: null });
- expect(steps.c).toMatchObject({ status: 'queued', outputs: null });
+ expect(steps['b@0']).toMatchObject({ id: bId, status: 'skipped', outputs: null });
+ expect(steps['c@0']).toMatchObject({ status: 'queued', outputs: null });
});
- it('TypeOrmStepStore.loadStepSummaries reports per-slot liveness without payloads', async () => {
+ it('TypeOrmStepStore.loadStepSummariesByKeys reports per-slot liveness without payloads', async () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- const { id: aId } = await seedStep({ executionId, nodeId: 'a', status: 'running' });
+ const { id: aId } = await seedStep({
+ executionId,
+ nodeId: 'a',
+ iteration: 0,
+ status: 'running',
+ });
// slot 0 has data, slot 1 was never fired, slot 2 ran but produced zero
// items — [] is live, only JSON null marks a dead slot
await store.completeStep(aId, [[{ json: { big: 'payload' } }], null, []]);
- const { id: bId } = await createStep(store, executionId, { nodeId: 'b', status: 'skipped' });
- await createStep(store, executionId, { nodeId: 'c', status: 'queued' });
+ const { id: bId } = await createStep(store, executionId, {
+ nodeId: 'b',
+ iteration: 0,
+ status: 'skipped',
+ });
+ await createStep(store, executionId, { nodeId: 'c', iteration: 0, status: 'queued' });
// scoped to the execution: a sibling execution's row for 'a' must not leak in
const otherExecutionId = await createExecution();
- await createStep(store, otherExecutionId, { nodeId: 'a', status: 'queued' });
+ await createStep(store, otherExecutionId, { nodeId: 'a', iteration: 0, status: 'queued' });
- const summaries = await store.loadStepSummaries(executionId, ['a', 'b', 'c', 'd']);
+ const summaries = await store.loadStepSummariesByKeys(
+ executionId,
+ ['a', 'b', 'c', 'd'].map((nodeId) => ({ nodeId, iteration: 0 })),
+ );
- expect(Object.keys(summaries).sort()).toEqual(['a', 'b', 'c']);
- expect(summaries.a).toEqual({
+ expect(Object.keys(summaries).sort()).toEqual(['a@0', 'b@0', 'c@0']);
+ expect(summaries['a@0']).toEqual({
id: aId,
nodeId: 'a',
+ iteration: 0,
status: 'completed',
filledOutputSlots: [true, false, true],
});
- expect(summaries.b).toEqual({
+ expect(summaries['b@0']).toEqual({
id: bId,
nodeId: 'b',
+ iteration: 0,
status: 'skipped',
filledOutputSlots: [],
});
- expect(summaries.c).toMatchObject({ status: 'queued', filledOutputSlots: [] });
+ expect(summaries['c@0']).toMatchObject({ status: 'queued', filledOutputSlots: [] });
});
- it('TypeOrmStepStore.loadStepSummaries is a no-op for no node ids', async () => {
+ it('TypeOrmStepStore.loadStepSummariesByKeys is a no-op for no keys', async () => {
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- expect(await store.loadStepSummaries('00000000-0000-7000-8000-000000000000', [])).toEqual({});
+ expect(await store.loadStepSummariesByKeys('00000000-0000-7000-8000-000000000000', [])).toEqual(
+ {},
+ );
});
- it('TypeOrmStepStore.loadStepsByNodeIds is a no-op for no node ids', async () => {
+ it('TypeOrmStepStore.loadStepsByKeys is a no-op for no keys', async () => {
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
- expect(await store.loadStepsByNodeIds('00000000-0000-7000-8000-000000000000', [])).toEqual({});
+ expect(await store.loadStepsByKeys('00000000-0000-7000-8000-000000000000', [])).toEqual({});
});
it('TypeOrmStepStore.countSettledSteps counts terminal rows only, per execution', async () => {
const executionId = await createExecution();
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
await store.createSteps(executionId, [
- { nodeId: 'trigger', status: 'completed', outputs: [{}] },
- { nodeId: 'a', status: 'skipped' },
- { nodeId: 'c', status: 'queued' },
+ { nodeId: 'trigger', iteration: 0, status: 'completed', outputs: [{}] },
+ { nodeId: 'a', iteration: 0, status: 'skipped' },
+ { nodeId: 'c', iteration: 0, status: 'queued' },
]);
- await seedStep({ executionId, nodeId: 'b', status: 'cancelled' });
- await seedStep({ executionId, nodeId: 'd', status: 'running' });
- const { id: eId } = await seedStep({ executionId, nodeId: 'e', status: 'running' });
+ await seedStep({ executionId, nodeId: 'b', iteration: 0, status: 'cancelled' });
+ await seedStep({ executionId, nodeId: 'd', iteration: 0, status: 'running' });
+ const { id: eId } = await seedStep({
+ executionId,
+ nodeId: 'e',
+ iteration: 0,
+ status: 'running',
+ });
await store.failStep(eId, { name: 'Error', message: 'node blew up' });
// a sibling execution's settled rows must not count
const otherExecutionId = await createExecution();
- await createStep(store, otherExecutionId, { nodeId: 'x', status: 'completed', outputs: [{}] });
+ await createStep(store, otherExecutionId, {
+ nodeId: 'x',
+ iteration: 0,
+ status: 'completed',
+ outputs: [{}],
+ });
// trigger, a, b, e — not c (queued) or d (running)
expect(await store.countSettledSteps(executionId)).toBe(4);
@@ -475,7 +563,9 @@ describe('workflow_step_execution table (integration)', () => {
const executionId = await createExecution();
const repo = dataSource.getRepository(WorkflowStepExecution);
await expect(
- repo.save(repo.create({ executionId, nodeId: 'a', status: 'bogus' as StepStatus })),
+ repo.save(
+ repo.create({ executionId, nodeId: 'a', iteration: 0, status: 'bogus' as StepStatus }),
+ ),
).rejects.toThrow();
});
@@ -485,16 +575,16 @@ describe('workflow_step_execution table (integration)', () => {
const executionId = await createExecution();
const repo = dataSource.getRepository(WorkflowStepExecution);
const store = new TypeOrmStepStore(repo);
- await createStep(store, executionId, { nodeId: 'a', status: 'queued' });
+ await createStep(store, executionId, { nodeId: 'a', iteration: 0, status: 'queued' });
// 'a' is taken; 'b' is not, and must still land — the whole point of skipping
// the conflict rather than failing the statement
const created = await store.createSteps(executionId, [
- { nodeId: 'a', status: 'queued' },
- { nodeId: 'b', status: 'queued' },
+ { nodeId: 'a', iteration: 0, status: 'queued' },
+ { nodeId: 'b', iteration: 0, status: 'queued' },
]);
- expect(created).toEqual([{ id: expect.any(String) as string, nodeId: 'b' }]);
+ expect(created).toEqual([{ id: expect.any(String) as string, nodeId: 'b', iteration: 0 }]);
// `count({ where })`, not `countBy`, for the reason given in `loadStep`
expect(await repo.count({ where: { executionId, nodeId: 'a' } })).toBe(1);
expect(await repo.count({ where: { executionId, nodeId: 'b' } })).toBe(1);
@@ -504,10 +594,121 @@ describe('workflow_step_execution table (integration)', () => {
const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
const executionId = await createExecution();
const otherExecutionId = await createExecution();
- await createStep(store, executionId, { nodeId: 'a', status: 'queued' });
+ await createStep(store, executionId, { nodeId: 'a', iteration: 0, status: 'queued' });
- const created = await store.createSteps(otherExecutionId, [{ nodeId: 'a', status: 'queued' }]);
+ const created = await store.createSteps(otherExecutionId, [
+ { nodeId: 'a', iteration: 0, status: 'queued' },
+ ]);
- expect(created).toEqual([{ id: expect.any(String) as string, nodeId: 'a' }]);
+ expect(created).toEqual([{ id: expect.any(String) as string, nodeId: 'a', iteration: 0 }]);
+ });
+
+ // The run dimension (CAT-2875): a loop member runs once per pass, one row
+ // per run, so uniqueness holds per (execution, node, iteration).
+ it('TypeOrmStepStore.createSteps accepts the same node at a new iteration and dedups within one', async () => {
+ const executionId = await createExecution();
+ const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
+ await createStep(store, executionId, { nodeId: 'b', iteration: 0, status: 'queued' });
+
+ const created = await store.createSteps(executionId, [
+ // iteration 0 is taken; iteration 1 is a legitimately new row
+ { nodeId: 'b', iteration: 0, status: 'queued' },
+ { nodeId: 'b', iteration: 1, status: 'queued' },
+ ]);
+
+ expect(created).toEqual([{ id: expect.any(String) as string, nodeId: 'b', iteration: 1 }]);
+ });
+
+ it('TypeOrmStepStore.createSteps rejects a negative or fractional iteration at runtime', async () => {
+ const executionId = await createExecution();
+ const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
+
+ for (const iteration of [-1, 0.5]) {
+ await expect(
+ store.createSteps(executionId, [{ nodeId: 'x', iteration, status: 'queued' }]),
+ ).rejects.toMatchObject({
+ name: 'UnexpectedError',
+ message: expect.stringContaining('non-negative integers') as string,
+ });
+ }
+ });
+
+ it('TypeOrmStepStore.loadStepsByKeys and loadStepSummariesByKeys distinguish iterations of one node', async () => {
+ const executionId = await createExecution();
+ const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
+ await store.createSteps(executionId, [
+ { nodeId: 'b', iteration: 0, status: 'completed', outputs: [null, [{ json: { n: 0 } }]] },
+ { nodeId: 'b', iteration: 1, status: 'queued' },
+ ]);
+
+ const steps = await store.loadStepsByKeys(executionId, [
+ { nodeId: 'b', iteration: 0 },
+ { nodeId: 'b', iteration: 1 },
+ { nodeId: 'b', iteration: 2 },
+ ]);
+ expect(Object.keys(steps).sort()).toEqual(['b@0', 'b@1']);
+ expect(steps['b@0']).toMatchObject({ status: 'completed' });
+ expect(steps['b@1']).toMatchObject({ status: 'queued' });
+
+ const summaries = await store.loadStepSummariesByKeys(executionId, [
+ { nodeId: 'b', iteration: 0 },
+ { nodeId: 'b', iteration: 1 },
+ ]);
+ expect(Object.keys(summaries).sort()).toEqual(['b@0', 'b@1']);
+ expect(summaries['b@0']).toMatchObject({ iteration: 0, filledOutputSlots: [false, true] });
+ expect(summaries['b@1']).toMatchObject({ iteration: 1, filledOutputSlots: [] });
+ });
+
+ it('TypeOrmStepStore.claimStep hands back the claimed iteration', async () => {
+ const executionId = await createExecution();
+ const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
+ const [step] = await store.createSteps(executionId, [
+ { nodeId: 'b', iteration: 3, status: 'queued' },
+ ]);
+
+ expect(await store.claimStep(step.id)).toMatchObject({
+ id: step.id,
+ nodeId: 'b',
+ iteration: 3,
+ status: 'running',
+ });
+ });
+
+ it('TypeOrmStepStore.loadLatestStep returns the highest-iteration row, or null', async () => {
+ const executionId = await createExecution();
+ const store = new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution));
+ await store.createSteps(executionId, [
+ { nodeId: 'b', iteration: 0, status: 'completed', outputs: [null, [{ json: {} }]] },
+ { nodeId: 'b', iteration: 1, status: 'completed', outputs: [[{ json: {} }], null] },
+ ]);
+ // a sibling execution's higher iteration must not leak in
+ const otherExecutionId = await createExecution();
+ await store.createSteps(otherExecutionId, [{ nodeId: 'b', iteration: 5, status: 'queued' }]);
+
+ const latest = await store.loadLatestStep(executionId, 'b');
+ expect(latest).toMatchObject({ nodeId: 'b', iteration: 1, status: 'completed' });
+
+ expect(await store.loadLatestStep(executionId, 'ghost')).toBeNull();
+ });
+
+ it('carries the unique key and the failed-rows partial index in the schema', async () => {
+ const indexes: Array<{ indexname: string; indexdef: string }> = await dataSource.query(
+ "SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'workflow_step_execution'",
+ );
+ const byName = Object.fromEntries(indexes.map((row) => [row.indexname, row.indexdef]));
+
+ expect(byName.uniq_workflow_step_execution_execution_id_node_id_iteration).toContain(
+ 'UNIQUE INDEX',
+ );
+ expect(byName.uniq_workflow_step_execution_execution_id_node_id_iteration).toContain(
+ '(execution_id, node_id, iteration)',
+ );
+ expect(byName.idx_workflow_step_execution_failed).toContain("WHERE ((status)::text = 'failed'");
+
+ const [column]: Array<{ is_nullable: string; column_default: string }> = await dataSource.query(
+ `SELECT is_nullable, column_default FROM information_schema.columns
+ WHERE table_name = 'workflow_step_execution' AND column_name = 'iteration'`,
+ );
+ expect(column).toEqual({ is_nullable: 'NO', column_default: '0' });
});
});
diff --git a/packages/@n8n/engine/src/database/create-stores.ts b/packages/@n8n/engine/src/database/create-stores.ts
index a0bf2d85060..2920ff5bf65 100644
--- a/packages/@n8n/engine/src/database/create-stores.ts
+++ b/packages/@n8n/engine/src/database/create-stores.ts
@@ -6,10 +6,13 @@ import { TypeOrmStepStore } from './typeorm-step-store';
import type { ExecutionStore } from '../execution/execution-store';
import type { StepStore } from '../execution/step-store';
-export function createStores(dataSource: DataSource): {
+/** The engine's own persistence, for hosts that must read the data it writes. */
+export interface EngineStores {
executionStore: ExecutionStore;
stepStore: StepStore;
-} {
+}
+
+export function createStores(dataSource: DataSource): EngineStores {
return {
executionStore: new TypeOrmExecutionStore(dataSource.getRepository(WorkflowExecution)),
stepStore: new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution)),
diff --git a/packages/@n8n/engine/src/database/entities/workflow-execution.entity.ts b/packages/@n8n/engine/src/database/entities/workflow-execution.entity.ts
index 9ba841009e4..9debbb82512 100644
--- a/packages/@n8n/engine/src/database/entities/workflow-execution.entity.ts
+++ b/packages/@n8n/engine/src/database/entities/workflow-execution.entity.ts
@@ -8,8 +8,11 @@ import {
UpdateDateColumn,
} from '@n8n/typeorm';
-import type { JsonObject } from '../../common';
-import type { ExecutionMode, ExecutionStatus } from '../../execution/execution.types';
+import type {
+ ExecutionMode,
+ ExecutionStatus,
+ TriggerOutputs,
+} from '../../execution/execution.types';
import type { WorkflowGraph } from '../../graph';
import { generateId } from '../generate-id';
@@ -32,8 +35,8 @@ export class WorkflowExecution {
@Column('jsonb')
graph!: WorkflowGraph;
- @Column('jsonb', { name: 'trigger_payload', nullable: true })
- triggerPayload!: JsonObject | null;
+ @Column('jsonb', { name: 'trigger_outputs', nullable: true })
+ triggerOutputs!: TriggerOutputs | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz', precision: 3 })
createdAt!: Date;
diff --git a/packages/@n8n/engine/src/database/entities/workflow-step-execution.entity.ts b/packages/@n8n/engine/src/database/entities/workflow-step-execution.entity.ts
index 441894a3973..5abf8d39b07 100644
--- a/packages/@n8n/engine/src/database/entities/workflow-step-execution.entity.ts
+++ b/packages/@n8n/engine/src/database/entities/workflow-step-execution.entity.ts
@@ -13,9 +13,12 @@ import type { StepError } from '../../execution/step-store';
import { generateId } from '../generate-id';
@Entity('workflow_step_execution')
-@Index('uniq_workflow_step_execution_execution_id_node_id', ['executionId', 'nodeId'], {
- unique: true,
-})
+@Index(
+ 'uniq_workflow_step_execution_execution_id_node_id_iteration',
+ ['executionId', 'nodeId', 'iteration'],
+ { unique: true },
+)
+@Index('idx_workflow_step_execution_failed', ['executionId'], { where: "status = 'failed'" })
export class WorkflowStepExecution {
@PrimaryColumn('uuid')
id!: string;
@@ -26,6 +29,9 @@ export class WorkflowStepExecution {
@Column('varchar', { name: 'node_id' })
nodeId!: string;
+ @Column('int', { default: 0 })
+ iteration!: number;
+
@Column('varchar', { length: 32 })
status!: StepStatus;
diff --git a/packages/@n8n/engine/src/database/index.ts b/packages/@n8n/engine/src/database/index.ts
index 793c8516c88..a093e60bba9 100644
--- a/packages/@n8n/engine/src/database/index.ts
+++ b/packages/@n8n/engine/src/database/index.ts
@@ -1,5 +1,6 @@
export { createDataSource } from './data-source';
export { createStores } from './create-stores';
+export type { EngineStores } from './create-stores';
export { WorkflowExecution, WorkflowStepExecution } from './entities';
export { TypeOrmExecutionStore } from './typeorm-execution-store';
export { TypeOrmStepStore } from './typeorm-step-store';
diff --git a/packages/@n8n/engine/src/database/migrations/1778529600000-CreateWorkflowExecution.ts b/packages/@n8n/engine/src/database/migrations/1778529600000-CreateWorkflowExecution.ts
index b6f61a3b204..5fd0f8cd56f 100644
--- a/packages/@n8n/engine/src/database/migrations/1778529600000-CreateWorkflowExecution.ts
+++ b/packages/@n8n/engine/src/database/migrations/1778529600000-CreateWorkflowExecution.ts
@@ -14,7 +14,7 @@ export class CreateWorkflowExecution1778529600000 implements MigrationInterface
{ name: 'status', type: 'varchar', length: '32' },
{ name: 'mode', type: 'varchar', length: '32' },
{ name: 'graph', type: 'jsonb' },
- { name: 'trigger_payload', type: 'jsonb', isNullable: true },
+ { name: 'trigger_outputs', type: 'jsonb', isNullable: true },
{
name: 'created_at',
type: 'timestamptz',
diff --git a/packages/@n8n/engine/src/database/migrations/1784890100000-CreateWorkflowStepExecution.ts b/packages/@n8n/engine/src/database/migrations/1784890100000-CreateWorkflowStepExecution.ts
index 4fee71158e3..79bdf5cc73f 100644
--- a/packages/@n8n/engine/src/database/migrations/1784890100000-CreateWorkflowStepExecution.ts
+++ b/packages/@n8n/engine/src/database/migrations/1784890100000-CreateWorkflowStepExecution.ts
@@ -12,6 +12,12 @@ export class CreateWorkflowStepExecution1784890100000 implements MigrationInterf
{ name: 'id', type: 'uuid', isPrimary: true },
{ name: 'execution_id', type: 'uuid' },
{ name: 'node_id', type: 'varchar' },
+ {
+ name: 'iteration',
+ type: 'int',
+ default: 0,
+ comment: 'Which pass over a loop body this row belongs to. 0 for non-loops.',
+ },
{ name: 'status', type: 'varchar', length: '32' },
{
name: 'outputs',
@@ -40,14 +46,21 @@ export class CreateWorkflowStepExecution1784890100000 implements MigrationInterf
},
],
indices: [
- // Index execution and node id to look up inputs on the hot path. Unique
- // to prevent double writes on failure recovery.
- // TODO(CAT-2875): looping adds an iteration to this key.
+ // Index execution, node and iteration to look up inputs on the hot path.
+ // Unique to prevent double writes on failure recovery, and keyed per pass
+ // because a node inside a loop body runs once per pass.
{
- name: 'uniq_workflow_step_execution_execution_id_node_id',
- columnNames: ['execution_id', 'node_id'],
+ name: 'uniq_workflow_step_execution_execution_id_node_id_iteration',
+ columnNames: ['execution_id', 'node_id', 'iteration'],
isUnique: true,
},
+ // Partial index for the failed-sibling checks: they run on every event, and
+ // loops make an execution's row count scale with its data.
+ {
+ name: 'idx_workflow_step_execution_failed',
+ columnNames: ['execution_id'],
+ where: "status = 'failed'",
+ },
],
foreignKeys: [
{
diff --git a/packages/@n8n/engine/src/database/typeorm-execution-store.ts b/packages/@n8n/engine/src/database/typeorm-execution-store.ts
index 2bc9bdbc3c8..b585dcf13b2 100644
--- a/packages/@n8n/engine/src/database/typeorm-execution-store.ts
+++ b/packages/@n8n/engine/src/database/typeorm-execution-store.ts
@@ -29,7 +29,9 @@ export class TypeOrmExecutionStore implements ExecutionStore {
}
async loadExecution(id: string): Promise {
- const row = await this.repo.findOneBy({ id });
+ // NOTE: `findOne({ where })`, not `findOneBy`: the latter's overload exceeds
+ // TypeScript's instantiation depth on the recursive `triggerOutputs` column type.
+ const row = await this.repo.findOne({ where: { id } });
if (!row) throw new ExecutionNotFoundError(id);
return row;
}
diff --git a/packages/@n8n/engine/src/database/typeorm-step-store.ts b/packages/@n8n/engine/src/database/typeorm-step-store.ts
index 8d497a1d37e..fad2975c342 100644
--- a/packages/@n8n/engine/src/database/typeorm-step-store.ts
+++ b/packages/@n8n/engine/src/database/typeorm-step-store.ts
@@ -5,6 +5,9 @@ import { generateId } from './generate-id';
import { UnexpectedError } from '../common';
import {
SETTLED_STEP_STATUSES,
+ stepKeyId,
+ type StepKey,
+ type StepKeyId,
type StepSlots,
type StepStatus,
} from '../execution/execution.types';
@@ -18,8 +21,26 @@ import {
} from '../execution/step-store';
/** RETURNING rows come back keyed by database column name (snake_case). */
-type InsertedStepRow = { id: string; node_id: string };
-type ClaimedStepRow = { id: string; execution_id: string; node_id: string };
+type InsertedStepRow = { id: string; node_id: string; iteration: number };
+type ClaimedStepRow = { id: string; execution_id: string; node_id: string; iteration: number };
+
+/**
+ * `(node_id, iteration) IN ((:n0, :i0), ...)` as a fragment + parameters, since
+ * the key filter is a row-value comparison no query-builder helper covers.
+ */
+function stepKeyFilter(keys: StepKey[]): {
+ fragment: string;
+ parameters: Record;
+} {
+ const tuples: string[] = [];
+ const parameters: Record = {};
+ keys.forEach(({ nodeId, iteration }, index) => {
+ tuples.push(`(:n${index}, :i${index})`);
+ parameters[`n${index}`] = nodeId;
+ parameters[`i${index}`] = iteration;
+ });
+ return { fragment: `(step.node_id, step.iteration) IN (${tuples.join(', ')})`, parameters };
+}
/** TypeORM-backed `StepStore` adapter. */
export class TypeOrmStepStore implements StepStore {
@@ -28,7 +49,7 @@ export class TypeOrmStepStore implements StepStore {
async createSteps(
executionId: string,
records: NewStepRecord[],
- ): Promise> {
+ ): Promise> {
if (records.length === 0) return [];
for (const record of records) assertCreatableRecord(record);
@@ -61,10 +82,14 @@ export class TypeOrmStepStore implements StepStore {
.into(WorkflowStepExecution)
.values(rows)
.orIgnore()
- .returning(['id', 'nodeId'])
+ .returning(['id', 'nodeId', 'iteration'])
.execute();
- return (result.raw as InsertedStepRow[]).map(({ id, node_id: nodeId }) => ({ id, nodeId }));
+ return (result.raw as InsertedStepRow[]).map(({ id, node_id: nodeId, iteration }) => ({
+ id,
+ nodeId,
+ iteration,
+ }));
});
}
@@ -106,7 +131,7 @@ export class TypeOrmStepStore implements StepStore {
)`,
{ executionId: execution.id },
)
- .returning(['id', 'executionId', 'nodeId'])
+ .returning(['id', 'executionId', 'nodeId', 'iteration'])
.execute();
const [row] = result.raw as ClaimedStepRow[];
@@ -116,6 +141,7 @@ export class TypeOrmStepStore implements StepStore {
id: row.id,
executionId: row.execution_id,
nodeId: row.node_id,
+ iteration: row.iteration,
status: 'running',
outputs: null,
error: null,
@@ -165,34 +191,42 @@ export class TypeOrmStepStore implements StepStore {
return result.affected === 1;
}
- async loadStepsByNodeIds(
+ async loadStepsByKeys(
executionId: string,
- nodeIds: string[],
- ): Promise> {
- if (nodeIds.length === 0) return {};
+ keys: StepKey[],
+ ): Promise> {
+ if (keys.length === 0) return {};
- const rows = await this.repo.find({ where: { executionId, nodeId: In(nodeIds) } });
- return Object.fromEntries(rows.map((row) => [row.nodeId, row]));
+ const { fragment, parameters } = stepKeyFilter(keys);
+ const rows = await this.repo
+ .createQueryBuilder('step')
+ .where('step.execution_id = :executionId', { executionId })
+ .andWhere(fragment, parameters)
+ .getMany();
+ return Object.fromEntries(rows.map((row) => [stepKeyId(row), row]));
}
- async loadStepSummaries(
+ async loadStepSummariesByKeys(
executionId: string,
- nodeIds: string[],
- ): Promise> {
- if (nodeIds.length === 0) return {};
+ keys: StepKey[],
+ ): Promise> {
+ if (keys.length === 0) return {};
// The per-slot booleans are computed inside the query, so the potentially
// large outputs payloads are never transferred. A slot counts as filled
// unless it holds JSON null.
+ const { fragment, parameters } = stepKeyFilter(keys);
const rows: Array<{
id: string;
nodeId: string;
+ iteration: number;
status: StepStatus;
filledOutputSlots: boolean[];
}> = await this.repo
.createQueryBuilder('step')
.select('step.id', 'id')
.addSelect('step.node_id', 'nodeId')
+ .addSelect('step.iteration', 'iteration')
.addSelect('step.status', 'status')
.addSelect(
`COALESCE(
@@ -203,10 +237,18 @@ export class TypeOrmStepStore implements StepStore {
'filledOutputSlots',
)
.where('step.execution_id = :executionId', { executionId })
- .andWhere('step.node_id IN (:...nodeIds)', { nodeIds })
+ .andWhere(fragment, parameters)
.getRawMany();
- return Object.fromEntries(rows.map((row) => [row.nodeId, row]));
+ return Object.fromEntries(rows.map((row) => [stepKeyId(row), row]));
+ }
+
+ async loadLatestStep(executionId: string, nodeId: string): Promise {
+ // NOTE: `findOne({ where })`, not `findOneBy`, as in `loadStep`.
+ return await this.repo.findOne({
+ where: { executionId, nodeId },
+ order: { iteration: 'DESC' },
+ });
}
async countSettledSteps(executionId: string): Promise {
@@ -228,9 +270,15 @@ const CREATION_STATUSES: readonly StepStatus[] = ['queued', 'completed', 'skippe
*/
function assertCreatableRecord(record: {
nodeId: string;
+ iteration: number;
status: StepStatus;
outputs?: StepSlots;
}): void {
+ if (!Number.isInteger(record.iteration) || record.iteration < 0) {
+ throw new UnexpectedError(
+ `step for node ${record.nodeId} is created with iteration ${record.iteration}; iterations are non-negative integers`,
+ );
+ }
if (!CREATION_STATUSES.includes(record.status)) {
throw new UnexpectedError(
`step for node ${record.nodeId} is created ${record.status}, a status only reachable through its transition method`,
diff --git a/packages/@n8n/engine/src/execution/__tests__/execution-start-handler.test.ts b/packages/@n8n/engine/src/execution/__tests__/execution-start-handler.test.ts
index 7af72c3e421..1b0fe553dc2 100644
--- a/packages/@n8n/engine/src/execution/__tests__/execution-start-handler.test.ts
+++ b/packages/@n8n/engine/src/execution/__tests__/execution-start-handler.test.ts
@@ -29,8 +29,9 @@ function makeStepStore(createSteps = vi.fn()): StepStore {
completeStep: vi.fn(),
failStep: vi.fn(),
cancelQueuedSteps: vi.fn(),
- loadStepsByNodeIds: vi.fn().mockResolvedValue({}),
- loadStepSummaries: vi.fn().mockResolvedValue({}),
+ loadStepsByKeys: vi.fn().mockResolvedValue({}),
+ loadStepSummariesByKeys: vi.fn().mockResolvedValue({}),
+ loadLatestStep: vi.fn().mockResolvedValue(null),
countSettledSteps: vi.fn(),
hasFailedSteps: vi.fn(),
};
@@ -43,7 +44,7 @@ function record(graph: WorkflowGraph, overrides: Partial = {}):
status: 'running',
mode: 'production',
graph,
- triggerPayload: null,
+ triggerOutputs: null,
...overrides,
};
}
@@ -60,7 +61,7 @@ describe('ExecutionStartHandler', () => {
const executionStore = makeExecutionStore({
loadExecution: vi
.fn()
- .mockResolvedValue(record(graph, { triggerPayload: { webhook: 'data' } })),
+ .mockResolvedValue(record(graph, { triggerOutputs: [[{ json: { webhook: 'data' } }]] })),
});
const createSteps = vi.fn().mockResolvedValue([{ id: 'step-trigger', nodeId: 'trigger' }]);
const stepStore = makeStepStore(createSteps);
@@ -71,13 +72,14 @@ describe('ExecutionStartHandler', () => {
expect(executionStore.transitionStatus).toHaveBeenCalledWith('exec-1', 'queued', 'running');
// only the trigger's row — planning is the step:settled handler's job.
- // Its payload rides along as output slot 0, read downstream like any
+ // Its payload rides along as output slots, read downstream like any
// other predecessor's outputs.
expect(createSteps).toHaveBeenCalledExactlyOnceWith('exec-1', [
{
nodeId: 'trigger',
+ iteration: 0,
status: 'completed',
- outputs: [{ webhook: 'data' }],
+ outputs: [[{ json: { webhook: 'data' } }]],
},
]);
expect(queue.publish).toHaveBeenCalledExactlyOnceWith({
@@ -87,15 +89,17 @@ describe('ExecutionStartHandler', () => {
});
});
- it('records an empty object in slot 0 when the trigger has no payload', async () => {
- // The trigger fired — that is why the execution exists — so its output
- // slot must count as data, or branching would treat it as never taken.
+ it('records multi-slot trigger payloads verbatim, including dead slots', async () => {
const graph: WorkflowGraph = {
nodes: [{ id: 'trigger', name: 'T', type: 'trigger' }],
edges: [],
};
const executionStore = makeExecutionStore({
- loadExecution: vi.fn().mockResolvedValue(record(graph, { triggerPayload: null })),
+ loadExecution: vi.fn().mockResolvedValue(
+ record(graph, {
+ triggerOutputs: [[{ json: { i1: true } }], null, [{ json: { i2: true } }]],
+ }),
+ ),
});
const createSteps = vi.fn().mockResolvedValue([{ id: 'step-trigger', nodeId: 'trigger' }]);
const stepStore = makeStepStore(createSteps);
@@ -104,7 +108,33 @@ describe('ExecutionStartHandler', () => {
await handler.handle({ type: 'execution:enqueued', executionId: 'exec-1' });
expect(createSteps).toHaveBeenCalledExactlyOnceWith('exec-1', [
- { nodeId: 'trigger', status: 'completed', outputs: [{}] },
+ {
+ nodeId: 'trigger',
+ iteration: 0,
+ status: 'completed',
+ outputs: [[{ json: { i1: true } }], null, [{ json: { i2: true } }]],
+ },
+ ]);
+ });
+
+ it('records no slots at all when the trigger has no payload', async () => {
+ // No payload means no data on any slot — every successor edge reads
+ // undefined and is treated as dead, same as any other step producing nothing.
+ const graph: WorkflowGraph = {
+ nodes: [{ id: 'trigger', name: 'T', type: 'trigger' }],
+ edges: [],
+ };
+ const executionStore = makeExecutionStore({
+ loadExecution: vi.fn().mockResolvedValue(record(graph, { triggerOutputs: null })),
+ });
+ const createSteps = vi.fn().mockResolvedValue([{ id: 'step-trigger', nodeId: 'trigger' }]);
+ const stepStore = makeStepStore(createSteps);
+ const handler = new ExecutionStartHandler(executionStore, stepStore, makeOrchestrationQueue());
+
+ await handler.handle({ type: 'execution:enqueued', executionId: 'exec-1' });
+
+ expect(createSteps).toHaveBeenCalledExactlyOnceWith('exec-1', [
+ { nodeId: 'trigger', iteration: 0, status: 'completed', outputs: [] },
]);
});
diff --git a/packages/@n8n/engine/src/execution/__tests__/execution-start.integration.test.ts b/packages/@n8n/engine/src/execution/__tests__/execution-start.integration.test.ts
index ab335f61a0e..2dfd82e094b 100644
--- a/packages/@n8n/engine/src/execution/__tests__/execution-start.integration.test.ts
+++ b/packages/@n8n/engine/src/execution/__tests__/execution-start.integration.test.ts
@@ -84,13 +84,15 @@ describe('execution start (integration)', () => {
const { executionId } = await startExecution.start({
workflowId: 'wf-1',
graph,
- triggerPayload: null,
+ triggerOutputs: [[{ json: { hello: 'world' } }]],
});
await ready;
+ // `findOne({ where })`, not `findOneByOrFail`: the latter's overload exceeds
+ // TypeScript's instantiation depth on the recursive `triggerOutputs` column type.
const row = await dataSource
.getRepository(WorkflowExecution)
- .findOneByOrFail({ id: executionId });
+ .findOneOrFail({ where: { id: executionId } });
expect(row.status).toBe('running');
const steps = await dataSource
@@ -99,6 +101,7 @@ describe('execution start (integration)', () => {
const triggerStep = steps.find((s) => s.nodeId === 'trigger');
const firstStep = steps.find((s) => s.nodeId === 'step-a');
expect(triggerStep?.status).toBe('completed');
+ expect(triggerStep?.outputs).toEqual([[{ json: { hello: 'world' } }]]);
expect(firstStep?.status).toBe('queued');
// step:ready references the durable step-record id, not the node id.
@@ -119,7 +122,7 @@ describe('execution start (integration)', () => {
status: 'queued',
mode: 'production',
graph,
- triggerPayload: null,
+ triggerOutputs: null,
});
// Delivered twice, both awaited — the CAS is what makes the second a no-op.
@@ -127,9 +130,11 @@ describe('execution start (integration)', () => {
await handler.handle(event);
await handler.handle(event);
+ // `findOne({ where })`, not `findOneByOrFail`: the latter's overload exceeds
+ // TypeScript's instantiation depth on the recursive `triggerOutputs` column type.
const row = await dataSource
.getRepository(WorkflowExecution)
- .findOneByOrFail({ id: executionId });
+ .findOneOrFail({ where: { id: executionId } });
expect(row.status).toBe('running');
const steps = await dataSource
diff --git a/packages/@n8n/engine/src/execution/__tests__/iteration-mapping.test.ts b/packages/@n8n/engine/src/execution/__tests__/iteration-mapping.test.ts
new file mode 100644
index 00000000000..781a533998c
--- /dev/null
+++ b/packages/@n8n/engine/src/execution/__tests__/iteration-mapping.test.ts
@@ -0,0 +1,148 @@
+import { describe, expect, it } from 'vitest';
+
+import { deriveLoops } from '../../graph';
+import type { GraphEdge, StepType, WorkflowGraph } from '../../graph';
+import { classifyEdge, sourceRow, targetKey } from '../iteration-mapping';
+
+function makeGraph(
+ edges: Array & Pick>,
+ { batch = [] as string[] } = {},
+): WorkflowGraph {
+ const nodeIds = [...new Set(edges.flatMap(({ from, to }) => [from, to]))];
+ const typeOf = (id: string): StepType =>
+ id === 'trigger' ? 'trigger' : batch.includes(id) ? 'batch' : 'v1-node';
+ return {
+ nodes: nodeIds.map((id) => ({ id, name: id.toUpperCase(), type: typeOf(id) })),
+ edges: edges.map((edge) => ({ outputIndex: 0, inputIndex: 0, ...edge })),
+ };
+}
+
+/**
+ * ┌───────┐ ┌───┐ o0 ┌───┐
+ * │trigger├───►│ ├──────►│ d │
+ * └───────┘ │ B │ └───┘
+ * │ │ o1 ┌───┐
+ * │ ├──────►│ x │
+ * └─▲─┘ └─┬─┘
+ * └──(back)───┘
+ */
+const loop = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+);
+
+const loops = deriveLoops(loop);
+const edge = (from: string, to: string): GraphEdge =>
+ loop.edges.find((e) => e.from === from && e.to === to)!;
+const classOf = (from: string, to: string) => classifyEdge(edge(from, to), loops);
+
+describe('classifyEdge', () => {
+ it('classifies the four edges around a loop', () => {
+ expect(classOf('trigger', 'B')).toBe('entry');
+ expect(classOf('B', 'x')).toBe('intra');
+ expect(classOf('x', 'B')).toBe('back');
+ expect(classOf('B', 'd')).toBe('exit');
+ });
+
+ it('classifies edges of a loopless graph as plain', () => {
+ const flat = makeGraph([{ from: 'trigger', to: 'a' }]);
+ expect(classifyEdge(flat.edges[0], deriveLoops(flat))).toBe('plain');
+ });
+
+ it('classifies an edge from one loop into the next as exit', () => {
+ // two simple loops chained: an edge leaves B1's done slot into B2
+ const chained = makeGraph(
+ [
+ { from: 'trigger', to: 'B1' },
+ { from: 'B1', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B1', isBackEdge: true },
+ { from: 'B1', to: 'B2', outputIndex: 0 },
+ { from: 'B2', to: 'y', outputIndex: 1 },
+ { from: 'y', to: 'B2', isBackEdge: true },
+ ],
+ { batch: ['B1', 'B2'] },
+ );
+ const chainEdge = chained.edges.find((e) => e.from === 'B1' && e.to === 'B2')!;
+
+ expect(classifyEdge(chainEdge, deriveLoops(chained))).toBe('exit');
+ });
+});
+
+describe('targetKey', () => {
+ it('keeps the iteration inside the loop', () => {
+ expect(targetKey(edge('B', 'x'), 'intra', { nodeId: 'B', iteration: 2 })).toEqual({
+ nodeId: 'x',
+ iteration: 2,
+ });
+ });
+
+ it('advances the iteration across the return edge', () => {
+ expect(targetKey(edge('x', 'B'), 'back', { nodeId: 'x', iteration: 2 })).toEqual({
+ nodeId: 'B',
+ iteration: 3,
+ });
+ });
+
+ it('drops back to iteration 0 when leaving the loop', () => {
+ expect(targetKey(edge('B', 'd'), 'exit', { nodeId: 'B', iteration: 7 })).toEqual({
+ nodeId: 'd',
+ iteration: 0,
+ });
+ });
+
+ it('targets iteration 0 through the entry edge', () => {
+ expect(targetKey(edge('trigger', 'B'), 'entry', { nodeId: 'trigger', iteration: 0 })).toEqual({
+ nodeId: 'B',
+ iteration: 0,
+ });
+ });
+});
+
+describe('sourceRow', () => {
+ const row = (nodeId: string, iteration: number) => ({
+ kind: 'row',
+ key: { nodeId, iteration },
+ });
+
+ it('reads the same iteration inside the loop', () => {
+ expect(sourceRow(edge('B', 'x'), 'intra', { nodeId: 'x', iteration: 2 })).toEqual(row('B', 2));
+ });
+
+ it('reads the previous iteration across the return edge', () => {
+ expect(sourceRow(edge('x', 'B'), 'back', { nodeId: 'B', iteration: 3 })).toEqual(row('x', 2));
+ });
+
+ it('reads the terminal row across an exit edge', () => {
+ expect(sourceRow(edge('B', 'd'), 'exit', { nodeId: 'd', iteration: 0 }, 4)).toEqual(
+ row('B', 4),
+ );
+ });
+
+ it('reads the entry edge at iteration 0 only, later iterations coming from the return edge', () => {
+ expect(sourceRow(edge('trigger', 'B'), 'entry', { nodeId: 'B', iteration: 0 })).toEqual(
+ row('trigger', 0),
+ );
+ expect(sourceRow(edge('trigger', 'B'), 'entry', { nodeId: 'B', iteration: 1 })).toEqual({
+ kind: 'none',
+ });
+ });
+
+ // `none` and `pending` are both empty, and the caller has to treat them
+ // differently: ignore the first, hold the decision open on the second.
+ it('distinguishes an edge that does not apply from one waiting for the loop to end', () => {
+ // a return edge has no source row at target iteration 0
+ expect(sourceRow(edge('x', 'B'), 'back', { nodeId: 'B', iteration: 0 })).toEqual({
+ kind: 'none',
+ });
+
+ // an exit edge does apply here, but its terminal source row does not exist yet
+ expect(sourceRow(edge('B', 'd'), 'exit', { nodeId: 'd', iteration: 0 })).toEqual({
+ kind: 'pending',
+ });
+ });
+});
diff --git a/packages/@n8n/engine/src/execution/__tests__/settlement.test.ts b/packages/@n8n/engine/src/execution/__tests__/settlement.test.ts
index c02e52eec55..ccc20119efc 100644
--- a/packages/@n8n/engine/src/execution/__tests__/settlement.test.ts
+++ b/packages/@n8n/engine/src/execution/__tests__/settlement.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { GraphEdge, WorkflowGraph } from '../../graph';
import { getDescendantNodeIds } from '../../graph';
-import type { StepStatus } from '../execution.types';
+import { stepKeyId, type StepKey, type StepStatus } from '../execution.types';
import { decideSuccessors } from '../settlement';
import type { StepSummary } from '../step-store';
@@ -11,11 +11,16 @@ function summary(
status: StepStatus,
filledOutputSlots: boolean[] = [],
): StepSummary {
- return { id: `step-${nodeId}`, nodeId, status, filledOutputSlots };
+ return { id: `step-${nodeId}`, nodeId, iteration: 0, status, filledOutputSlots };
}
function makeSteps(...summaries: StepSummary[]): Record {
- return Object.fromEntries(summaries.map((s) => [s.nodeId, s]));
+ return Object.fromEntries(summaries.map((s) => [stepKeyId(s), s]));
+}
+
+/** Every row in these tests sits at iteration 0; loops are CAT-2875 part 2. */
+function keyFor(nodeId: string): StepKey {
+ return { nodeId, iteration: 0 };
}
function makeGraph(
@@ -32,7 +37,7 @@ function makeGraph(
};
}
-/** trigger → if → {a (out 0), b (out 1)} → m: the conditional diamond. */
+/** trigger -> if -> {a (out 0), b (out 1)} -> m: the conditional diamond. */
const diamond = makeGraph([
{ from: 'trigger', to: 'if' },
{ from: 'if', to: 'a', outputIndex: 0 },
@@ -47,11 +52,11 @@ describe('decideSuccessors', () => {
// not considered — b's own settled event will examine it.
const decisions = decideSuccessors(
diamond,
- 'if',
+ keyFor('if'),
makeSteps(summary('trigger', 'completed', [true]), summary('if', 'completed', [true, false])),
);
- expect(decisions).toEqual({ toQueue: ['a'], toSkip: ['b'] });
+ expect(decisions).toEqual({ toQueue: [keyFor('a')], toSkip: [keyFor('b')] });
});
it('decides a merge once its last predecessor settles', () => {
@@ -59,7 +64,7 @@ describe('decideSuccessors', () => {
// edge, so it runs on the live data.
const decisions = decideSuccessors(
diamond,
- 'a',
+ keyFor('a'),
makeSteps(
summary('trigger', 'completed', [true]),
summary('if', 'completed', [true, false]),
@@ -68,7 +73,7 @@ describe('decideSuccessors', () => {
),
);
- expect(decisions).toEqual({ toQueue: ['m'], toSkip: [] });
+ expect(decisions).toEqual({ toQueue: [keyFor('m')], toSkip: [] });
});
it('leaves a merge undecided while a predecessor is still unsettled', () => {
@@ -76,7 +81,7 @@ describe('decideSuccessors', () => {
// yet, and a's own settlement will decide it later.
const decisions = decideSuccessors(
diamond,
- 'b',
+ keyFor('b'),
makeSteps(
summary('trigger', 'completed', [true]),
summary('if', 'completed', [true, false]),
@@ -89,7 +94,7 @@ describe('decideSuccessors', () => {
});
it('cascades a skip one hop at a time through the event loop', () => {
- // dead chain b → c → d: each settled event plans exactly the next hop.
+ // dead chain b -> c -> d: each settled event plans exactly the next hop.
const graph = makeGraph([
{ from: 'trigger', to: 'if' },
{ from: 'if', to: 'a', outputIndex: 0 },
@@ -102,13 +107,22 @@ describe('decideSuccessors', () => {
summary('if', 'completed', [true, false]),
);
- expect(decideSuccessors(graph, 'if', steps)).toEqual({ toQueue: ['a'], toSkip: ['b'] });
+ expect(decideSuccessors(graph, keyFor('if'), steps)).toEqual({
+ toQueue: [keyFor('a')],
+ toSkip: [keyFor('b')],
+ });
- steps.b = summary('b', 'skipped');
- expect(decideSuccessors(graph, 'b', steps)).toEqual({ toQueue: [], toSkip: ['c'] });
+ steps[stepKeyId(keyFor('b'))] = summary('b', 'skipped');
+ expect(decideSuccessors(graph, keyFor('b'), steps)).toEqual({
+ toQueue: [],
+ toSkip: [keyFor('c')],
+ });
- steps.c = summary('c', 'skipped');
- expect(decideSuccessors(graph, 'c', steps)).toEqual({ toQueue: [], toSkip: ['d'] });
+ steps[stepKeyId(keyFor('c'))] = summary('c', 'skipped');
+ expect(decideSuccessors(graph, keyFor('c'), steps)).toEqual({
+ toQueue: [],
+ toSkip: [keyFor('d')],
+ });
});
it('skips a node only when every predecessor has settled dead', () => {
@@ -128,10 +142,13 @@ describe('decideSuccessors', () => {
summary('b', 'skipped'),
);
- expect(decideSuccessors(graph, 'b', steps)).toEqual({ toQueue: [], toSkip: [] });
+ expect(decideSuccessors(graph, keyFor('b'), steps)).toEqual({ toQueue: [], toSkip: [] });
- steps.c = summary('c', 'skipped');
- expect(decideSuccessors(graph, 'c', steps)).toEqual({ toQueue: [], toSkip: ['d'] });
+ steps[stepKeyId(keyFor('c'))] = summary('c', 'skipped');
+ expect(decideSuccessors(graph, keyFor('c'), steps)).toEqual({
+ toQueue: [],
+ toSkip: [keyFor('d')],
+ });
});
it('does not re-decide a successor that already has a row', () => {
@@ -140,7 +157,7 @@ describe('decideSuccessors', () => {
// reconciliation's job (CAT-2938), not this planner's.
const decisions = decideSuccessors(
diamond,
- 'if',
+ keyFor('if'),
makeSteps(
summary('trigger', 'completed', [true]),
summary('if', 'completed', [true, false]),
@@ -162,14 +179,14 @@ describe('decideSuccessors', () => {
const decisions = decideSuccessors(
graph,
- 'switch',
+ keyFor('switch'),
makeSteps(
summary('trigger', 'completed', [true]),
summary('switch', 'completed', [true, false, true]),
),
);
- expect(decisions).toEqual({ toQueue: ['a', 'c'], toSkip: ['b'] });
+ expect(decisions).toEqual({ toQueue: [keyFor('a'), keyFor('c')], toSkip: [keyFor('b')] });
});
it('treats an output slot beyond the produced list as dead', () => {
@@ -180,11 +197,11 @@ describe('decideSuccessors', () => {
const decisions = decideSuccessors(
graph,
- 'a',
+ keyFor('a'),
makeSteps(summary('trigger', 'completed', [true]), summary('a', 'completed', [true])),
);
- expect(decisions).toEqual({ toQueue: [], toSkip: ['b'] });
+ expect(decisions).toEqual({ toQueue: [], toSkip: [keyFor('b')] });
});
});
@@ -257,18 +274,18 @@ function simulateExecution(
if (handleEvent) {
const nodeId = settledEvents[0];
settledEvents.shift();
- const { toQueue, toSkip } = decideSuccessors(graph, nodeId, steps);
- for (const id of toQueue) {
- steps[id] = summary(id, 'queued');
+ const { toQueue, toSkip } = decideSuccessors(graph, keyFor(nodeId), steps);
+ for (const { nodeId: id } of toQueue) {
+ steps[stepKeyId(keyFor(id))] = summary(id, 'queued');
queuedNodes.push(id);
}
- for (const id of toSkip) {
- steps[id] = summary(id, 'skipped');
+ for (const { nodeId: id } of toSkip) {
+ steps[stepKeyId(keyFor(id))] = summary(id, 'skipped');
settledEvents.push(id);
}
} else {
const [nodeId] = queuedNodes.splice(randomInt(rand, queuedNodes.length), 1);
- steps[nodeId] = summary(nodeId, 'completed', outputs[nodeId]);
+ steps[stepKeyId(keyFor(nodeId))] = summary(nodeId, 'completed', outputs[nodeId]);
settledEvents.push(nodeId);
}
}
@@ -311,7 +328,7 @@ describe('the event loop over decideSuccessors matches the reference evaluator',
const reachable = ['trigger', ...getDescendantNodeIds(graph, 'trigger')];
const actual = Object.fromEntries(
- reachable.map((id) => [id, terminal[id]?.status ?? 'missing']),
+ reachable.map((id) => [id, terminal[stepKeyId(keyFor(id))]?.status ?? 'missing']),
);
// rule 1 (the finish predicate): every reachable node settled, and
// with exactly the fate the spec assigns
diff --git a/packages/@n8n/engine/src/execution/__tests__/start-execution.service.test.ts b/packages/@n8n/engine/src/execution/__tests__/start-execution.service.test.ts
index 664e898750a..48dd8fd3b4f 100644
--- a/packages/@n8n/engine/src/execution/__tests__/start-execution.service.test.ts
+++ b/packages/@n8n/engine/src/execution/__tests__/start-execution.service.test.ts
@@ -37,7 +37,7 @@ describe('StartExecutionService', () => {
const result = await service.start({
workflowId: 'wf-1',
graph: sampleGraph,
- triggerPayload: { hello: 'world' },
+ triggerOutputs: [[{ json: { hello: 'world' } }]],
});
expect(result.executionId).toBe('exec-id-1');
@@ -47,7 +47,7 @@ describe('StartExecutionService', () => {
status: 'queued',
mode: 'production',
graph: sampleGraph,
- triggerPayload: { hello: 'world' },
+ triggerOutputs: [[{ json: { hello: 'world' } }]],
});
expect(queue.publish).toHaveBeenCalledWith({
type: 'execution:enqueued',
@@ -55,7 +55,7 @@ describe('StartExecutionService', () => {
});
});
- it('defaults mode to production and triggerPayload to null', async () => {
+ it('defaults mode to production and triggerOutputs to null', async () => {
const admittance: AdmittanceService = {
evaluate: vi.fn().mockResolvedValue({ accept: true }),
};
@@ -66,7 +66,7 @@ describe('StartExecutionService', () => {
await service.start({ workflowId: 'wf-1', graph: sampleGraph });
expect(store.createExecution).toHaveBeenCalledWith(
- expect.objectContaining({ mode: 'production', triggerPayload: null }),
+ expect.objectContaining({ mode: 'production', triggerOutputs: null }),
);
});
diff --git a/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts b/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts
index 67afc9a26cd..400c4ba7453 100644
--- a/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts
+++ b/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts
@@ -1,26 +1,23 @@
import type { DataSource } from '@n8n/typeorm';
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import postgresVersions from 'n8n-containers/postgres-versions.json';
+import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { AllowAllAdmittance } from '../../admittance';
-import type { JsonObject } from '../../common';
import {
createDataSource,
- TypeOrmExecutionStore,
- TypeOrmStepStore,
+ createStores,
WorkflowExecution,
WorkflowStepExecution,
} from '../../database';
import type { IStepExecutor, StepExecutionRequest } from '../../dependencies';
import type { WorkflowGraph } from '../../graph';
-import { InMemoryWorkQueue, type OrchestrationMessage, type StepMessage } from '../../queue';
-import { ExecutionStartHandler } from '../execution-start-handler';
-import { OrchestrationWorker } from '../orchestration-worker';
-import { StartExecutionService } from '../start-execution.service';
+import { InMemoryWorkQueue, type OrchestrationMessage } from '../../queue';
+import { createEngineRuntime } from '../../runtime';
+import type { TriggerOutputs } from '../execution.types';
+import type { StartExecutionResult } from '../start-execution.service';
import { StepReadyHandler } from '../step-ready-handler';
-import { StepSettledHandler } from '../step-settled-handler';
-import { StepWorker } from '../step-worker';
const graph: WorkflowGraph = {
nodes: [
@@ -46,59 +43,43 @@ describe('step execution (integration)', () => {
if (container) await container.stop();
});
- function stores() {
- return {
- executionStore: new TypeOrmExecutionStore(dataSource.getRepository(WorkflowExecution)),
- stepStore: new TypeOrmStepStore(dataSource.getRepository(WorkflowStepExecution)),
- };
- }
-
/**
- * Wires both workers over shared queues and runs a workflow through them.
- * Resolves once the execution's outcome is recorded, which is after every
- * step's own outcome is durable.
+ * Runs a workflow through a real engine runtime. Resolves once the
+ * execution's outcome is recorded, which is after every step's own outcome is
+ * durable.
*/
async function runWorkflow(
executor: IStepExecutor,
- triggerPayload: JsonObject,
+ triggerOutputs: TriggerOutputs,
{ workflowId = 'wf-1', graph: workflowGraph = graph } = {},
) {
- const { executionStore, stepStore } = stores();
- const orchestrationQueue = new InMemoryWorkQueue();
- const stepQueue = new InMemoryWorkQueue();
-
let done!: () => void;
const finished = new Promise((resolve) => (done = resolve));
- const finishExecution = executionStore.finishExecution.bind(executionStore);
- vi.spyOn(executionStore, 'finishExecution').mockImplementation(async (id, status) => {
- const recorded = await finishExecution(id, status);
- done();
- return recorded;
+
+ const runtime = createEngineRuntime({
+ dataSource,
+ admittance: new AllowAllAdmittance(),
+ // also how the test reaches the stores the runtime owns
+ externalDependencies: ({ executionStore }) => {
+ const finishExecution = executionStore.finishExecution.bind(executionStore);
+ vi.spyOn(executionStore, 'finishExecution').mockImplementation(async (id, status) => {
+ const recorded = await finishExecution(id, status);
+ done();
+ return recorded;
+ });
+ return { v1StepExecutor: executor };
+ },
});
+ runtime.start();
- const orchestrationWorker = new OrchestrationWorker(
- orchestrationQueue,
- new ExecutionStartHandler(executionStore, stepStore, orchestrationQueue),
- new StepSettledHandler(executionStore, stepStore, stepQueue, orchestrationQueue),
- );
- const stepWorker = new StepWorker(
- stepQueue,
- new StepReadyHandler(executionStore, stepStore, orchestrationQueue, {
- v1StepExecutor: executor,
- }),
- );
- orchestrationWorker.start();
- stepWorker.start();
-
- const { executionId } = await new StartExecutionService(
- new AllowAllAdmittance(),
- executionStore,
- orchestrationQueue,
- ).start({ workflowId, graph: workflowGraph, triggerPayload });
+ const response = await request(runtime.app)
+ .post('/api/workflow-executions')
+ .send({ workflowId, graph: workflowGraph, triggerOutputs })
+ .expect(201);
+ const { executionId } = response.body as StartExecutionResult;
await finished;
- await stepWorker.stop();
- await orchestrationWorker.stop();
+ await runtime.stop();
const execution = await dataSource
.getRepository(WorkflowExecution)
@@ -119,9 +100,9 @@ describe('step execution (integration)', () => {
},
};
- const { executionId, execution, steps } = await runWorkflow(executor, {
- body: { name: 'ada' },
- });
+ const { executionId, execution, steps } = await runWorkflow(executor, [
+ { body: { name: 'ada' } },
+ ]);
const step = steps.find(({ nodeId }) => nodeId === 'node-a');
expect(execution.status).toBe('completed');
@@ -150,7 +131,7 @@ describe('step execution (integration)', () => {
},
};
- const { execution, steps } = await runWorkflow(executor, {});
+ const { execution, steps } = await runWorkflow(executor, [{}]);
const step = steps.find(({ nodeId }) => nodeId === 'node-a');
// the failure is terminal for the execution too
@@ -187,11 +168,10 @@ describe('step execution (integration)', () => {
},
};
- const { execution } = await runWorkflow(
- executor,
- { body: { name: 'ada' } },
- { workflowId: 'wf-chain', graph: chainGraph },
- );
+ const { execution } = await runWorkflow(executor, [{ body: { name: 'ada' } }], {
+ workflowId: 'wf-chain',
+ graph: chainGraph,
+ });
// both nodes ran, in order, each on what came before it: node-a on the
// trigger's payload slot, node-b on node-a's output slot 0
@@ -227,11 +207,10 @@ describe('step execution (integration)', () => {
},
};
- const { execution } = await runWorkflow(
- executor,
- { body: { name: 'ada' } },
- { workflowId: 'wf-diamond', graph: diamondGraph },
- );
+ const { execution } = await runWorkflow(executor, [{ body: { name: 'ada' } }], {
+ workflowId: 'wf-diamond',
+ graph: diamondGraph,
+ });
// the merge ran exactly once, after both branches, one slot per branch
const merge = requests.filter(({ node }) => node.id === 'node-m');
@@ -243,7 +222,7 @@ describe('step execution (integration)', () => {
});
it('settles a conditional diamond: dead chain skipped, merge runs on the live side', async () => {
- // trigger → if → {a (out 0), b (out 1) → c} → m: the not-taken branch is
+ // trigger -> if -> {a (out 0), b (out 1) -> c} -> m: the not-taken branch is
// two nodes long, so its skips must cascade through the event loop
// before the merge can settle and the execution can finish.
const branchingGraph: WorkflowGraph = {
@@ -277,11 +256,10 @@ describe('step execution (integration)', () => {
},
};
- const { execution, steps } = await runWorkflow(
- executor,
- {},
- { workflowId: 'wf-branch', graph: branchingGraph },
- );
+ const { execution, steps } = await runWorkflow(executor, [{}], {
+ workflowId: 'wf-branch',
+ graph: branchingGraph,
+ });
// the dead branch never ran a node
expect(requests.map(({ node }) => node.id).sort()).toEqual(['node-a', 'node-if', 'node-m']);
@@ -307,7 +285,7 @@ describe('step execution (integration)', () => {
});
it('is idempotent across duplicate step:ready deliveries', async () => {
- const { executionStore, stepStore } = stores();
+ const { executionStore, stepStore } = createStores(dataSource);
const execute = vi.fn().mockResolvedValue({ outputs: [[{ json: { n: 1 } }]] });
const orchestrationQueue = new InMemoryWorkQueue();
const handler = new StepReadyHandler(executionStore, stepStore, orchestrationQueue, {
@@ -319,12 +297,12 @@ describe('step execution (integration)', () => {
status: 'running',
mode: 'production',
graph,
- triggerPayload: null,
+ triggerOutputs: null,
});
const created = await stepStore.createSteps(executionId, [
// completed steps always carry outputs, as the start handler writes them
- { nodeId: 'trigger', status: 'completed', outputs: [{}] },
- { nodeId: 'node-a', status: 'queued' },
+ { nodeId: 'trigger', iteration: 0, status: 'completed', outputs: [{}] },
+ { nodeId: 'node-a', iteration: 0, status: 'queued' },
]);
const stepId = created.find(({ nodeId }) => nodeId === 'node-a')!.id;
diff --git a/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts b/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts
index 9fe7eabf7b1..3c61e361059 100644
--- a/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts
+++ b/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts
@@ -4,13 +4,24 @@ import type { ExternalDependencies, IStepExecutor } from '../../dependencies';
import type { WorkflowGraph } from '../../graph';
import type { OrchestrationMessage, WorkQueue } from '../../queue';
import type { ExecutionRecord, ExecutionStore } from '../execution-store';
-import type { StepSlots, StepStatus } from '../execution.types';
+import { stepKeyId, type StepSlots, type StepStatus } from '../execution.types';
import { StepReadyHandler } from '../step-ready-handler';
import type { StepRecord, StepStore } from '../step-store';
-/** A predecessor row as `loadStepsByNodeIds` returns it. */
+/** Key for a `loadStepsByKeys` result at iteration 0, as the handler requests them. */
+const at = (nodeId: string) => stepKeyId({ nodeId, iteration: 0 });
+
+/** A predecessor row as `loadStepsByKeys` returns it, keyed at iteration 0. */
function stepRow(nodeId: string, status: StepStatus, outputs: StepSlots | null = null): StepRecord {
- return { id: `step-${nodeId}`, executionId: 'exec-1', nodeId, status, outputs, error: null };
+ return {
+ id: `step-${nodeId}`,
+ executionId: 'exec-1',
+ nodeId,
+ iteration: 0,
+ status,
+ outputs,
+ error: null,
+ };
}
/** trigger -> a -> b, so `a` has the trigger as its only predecessor. */
@@ -33,7 +44,7 @@ function makeExecutionStore(overrides: Partial = {}): Execution
status: 'running',
mode: 'production',
graph,
- triggerPayload: null,
+ triggerOutputs: null,
...overrides,
};
return {
@@ -49,6 +60,7 @@ function makeStepStore(step: Partial = {}, overrides: Partial = {}, overrides: Partial {
const stepStore = makeStepStore(
{},
{
- loadStepsByNodeIds: vi.fn().mockResolvedValue({
- trigger: stepRow('trigger', 'completed', [{ body: { hello: 'world' } }]),
+ loadStepsByKeys: vi.fn().mockResolvedValue({
+ [at('trigger')]: stepRow('trigger', 'completed', [{ body: { hello: 'world' } }]),
}),
},
);
@@ -95,7 +108,7 @@ describe('StepReadyHandler', () => {
const executor = makeExecutor({ outputs: [[{ json: { ok: true } }]] });
// a stale payload on the execution record must not be consulted: the
// trigger's step row is the one source of its output
- const executionStore = makeExecutionStore({ triggerPayload: { body: { stale: true } } });
+ const executionStore = makeExecutionStore({ triggerOutputs: [{ body: { stale: true } }] });
const handler = new StepReadyHandler(executionStore, stepStore, queue, {
v1StepExecutor: executor,
});
@@ -104,7 +117,9 @@ describe('StepReadyHandler', () => {
expect(stepStore.claimStep).toHaveBeenCalledWith('step-a');
// 'a' sits behind the trigger; its input slot 0 is the trigger's output slot 0
- expect(stepStore.loadStepsByNodeIds).toHaveBeenCalledWith('exec-1', ['trigger']);
+ expect(stepStore.loadStepsByKeys).toHaveBeenCalledWith('exec-1', [
+ { nodeId: 'trigger', iteration: 0 },
+ ]);
expect(executor.execute).toHaveBeenCalledWith({
node: { id: 'a', name: 'A', type: 'v1-node', config: { some: 'config' } },
inputs: [{ body: { hello: 'world' } }],
@@ -130,9 +145,9 @@ describe('StepReadyHandler', () => {
const stepStore = makeStepStore(
{ id: 'step-b', nodeId: 'b' },
{
- loadStepsByNodeIds: vi
+ loadStepsByKeys: vi
.fn()
- .mockResolvedValue({ a: stepRow('a', 'completed', [[{ json: { from: 'a' } }]]) }),
+ .mockResolvedValue({ [at('a')]: stepRow('a', 'completed', [[{ json: { from: 'a' } }]]) }),
},
);
const handler = new StepReadyHandler(makeExecutionStore(), stepStore, makeQueue(), {
@@ -141,7 +156,9 @@ describe('StepReadyHandler', () => {
await handler.handle({ ...event, stepId: 'step-b' });
- expect(stepStore.loadStepsByNodeIds).toHaveBeenCalledWith('exec-1', ['a']);
+ expect(stepStore.loadStepsByKeys).toHaveBeenCalledWith('exec-1', [
+ { nodeId: 'a', iteration: 0 },
+ ]);
expect(executor.execute).toHaveBeenCalledWith(
expect.objectContaining({ inputs: [[{ json: { from: 'a' } }]] }),
);
@@ -161,9 +178,9 @@ describe('StepReadyHandler', () => {
const stepStore = makeStepStore(
{ id: 'step-m', nodeId: 'm' },
{
- loadStepsByNodeIds: vi.fn().mockResolvedValue({
- a: stepRow('a', 'completed', [[{ json: { from: 'a' } }]]),
- b: stepRow('b', 'completed', [[{ json: { from: 'b' } }]]),
+ loadStepsByKeys: vi.fn().mockResolvedValue({
+ [at('a')]: stepRow('a', 'completed', [[{ json: { from: 'a' } }]]),
+ [at('b')]: stepRow('b', 'completed', [[{ json: { from: 'b' } }]]),
}),
},
);
@@ -178,8 +195,11 @@ describe('StepReadyHandler', () => {
await handler.handle({ ...event, stepId: 'step-m' });
// one round trip for all predecessors
- expect(stepStore.loadStepsByNodeIds).toHaveBeenCalledTimes(1);
- expect(stepStore.loadStepsByNodeIds).toHaveBeenCalledWith('exec-1', ['a', 'b']);
+ expect(stepStore.loadStepsByKeys).toHaveBeenCalledTimes(1);
+ expect(stepStore.loadStepsByKeys).toHaveBeenCalledWith('exec-1', [
+ { nodeId: 'a', iteration: 0 },
+ { nodeId: 'b', iteration: 0 },
+ ]);
expect(executor.execute).toHaveBeenCalledWith(
expect.objectContaining({ inputs: [[{ json: { from: 'a' } }], [{ json: { from: 'b' } }]] }),
);
@@ -198,9 +218,9 @@ describe('StepReadyHandler', () => {
const stepStore = makeStepStore(
{ id: 'step-m', nodeId: 'm' },
{
- loadStepsByNodeIds: vi.fn().mockResolvedValue({
- a: stepRow('a', 'completed', [[{ json: { from: 'a' } }]]),
- b: stepRow('b', 'completed', [[{ json: { from: 'b' } }]]),
+ loadStepsByKeys: vi.fn().mockResolvedValue({
+ [at('a')]: stepRow('a', 'completed', [[{ json: { from: 'a' } }]]),
+ [at('b')]: stepRow('b', 'completed', [[{ json: { from: 'b' } }]]),
}),
},
);
@@ -233,9 +253,9 @@ describe('StepReadyHandler', () => {
const stepStore = makeStepStore(
{ id: 'step-m', nodeId: 'm' },
{
- loadStepsByNodeIds: vi
+ loadStepsByKeys: vi
.fn()
- .mockResolvedValue({ a: stepRow('a', 'completed', [[{ json: { from: 'a' } }]]) }),
+ .mockResolvedValue({ [at('a')]: stepRow('a', 'completed', [[{ json: { from: 'a' } }]]) }),
},
);
const executor = makeExecutor();
@@ -248,7 +268,9 @@ describe('StepReadyHandler', () => {
await handler.handle({ ...event, stepId: 'step-m' });
- expect(stepStore.loadStepsByNodeIds).toHaveBeenCalledWith('exec-1', ['a']);
+ expect(stepStore.loadStepsByKeys).toHaveBeenCalledWith('exec-1', [
+ { nodeId: 'a', iteration: 0 },
+ ]);
expect(executor.execute).toHaveBeenCalledWith(
expect.objectContaining({
inputs: [[{ json: { from: 'a' } }], [{ json: { from: 'a' } }]],
@@ -301,9 +323,9 @@ describe('StepReadyHandler', () => {
const stepStore = makeStepStore(
{ id: 'step-b', nodeId: 'b' },
{
- loadStepsByNodeIds: vi
+ loadStepsByKeys: vi
.fn()
- .mockResolvedValue({ a: stepRow('a', 'completed', [[{ json: { from: 'a' } }]]) }),
+ .mockResolvedValue({ [at('a')]: stepRow('a', 'completed', [[{ json: { from: 'a' } }]]) }),
},
);
const executor = makeExecutor({ outputs: [null] });
@@ -326,8 +348,8 @@ describe('StepReadyHandler', () => {
const stepStore = makeStepStore(
{ id: 'step-b', nodeId: 'b' },
{
- loadStepsByNodeIds: vi.fn().mockResolvedValue({
- a: stepRow('a', 'completed', [[{ json: { slot: 0 } }], [{ json: { slot: 1 } }]]),
+ loadStepsByKeys: vi.fn().mockResolvedValue({
+ [at('a')]: stepRow('a', 'completed', [[{ json: { slot: 0 } }], [{ json: { slot: 1 } }]]),
}),
},
);
@@ -366,9 +388,9 @@ describe('StepReadyHandler', () => {
const stepStore = makeStepStore(
{ id: 'step-m', nodeId: 'm' },
{
- loadStepsByNodeIds: vi.fn().mockResolvedValue({
- b: stepRow('b', 'completed', [[{ json: { from: 'b' } }]]),
- c: stepRow('c', 'skipped'),
+ loadStepsByKeys: vi.fn().mockResolvedValue({
+ [at('b')]: stepRow('b', 'completed', [[{ json: { from: 'b' } }]]),
+ [at('c')]: stepRow('c', 'skipped'),
}),
},
);
@@ -392,7 +414,7 @@ describe('StepReadyHandler', () => {
it.each([
{
reason: 'the predecessor row is still unsettled',
- rows: () => ({ trigger: stepRow('trigger', 'running') }),
+ rows: () => ({ [at('trigger')]: stepRow('trigger', 'running') }),
},
{
reason: 'the predecessor has no row at all',
@@ -402,7 +424,7 @@ describe('StepReadyHandler', () => {
// a step is planned only once every predecessor settled, so anything else
// means the planner and the store disagree — running on a fabricated
// empty input would mask that
- const stepStore = makeStepStore({}, { loadStepsByNodeIds: vi.fn().mockResolvedValue(rows()) });
+ const stepStore = makeStepStore({}, { loadStepsByKeys: vi.fn().mockResolvedValue(rows()) });
const executor = makeExecutor();
const handler = new StepReadyHandler(makeExecutionStore(), stepStore, makeQueue(), {
v1StepExecutor: executor,
diff --git a/packages/@n8n/engine/src/execution/__tests__/step-settled-handler.test.ts b/packages/@n8n/engine/src/execution/__tests__/step-settled-handler.test.ts
index 8947beb65d0..60d64c314ac 100644
--- a/packages/@n8n/engine/src/execution/__tests__/step-settled-handler.test.ts
+++ b/packages/@n8n/engine/src/execution/__tests__/step-settled-handler.test.ts
@@ -3,12 +3,12 @@ import { describe, expect, it, vi } from 'vitest';
import type { WorkflowGraph } from '../../graph';
import type { OrchestrationMessage, StepMessage, WorkQueue } from '../../queue';
import type { ExecutionRecord, ExecutionStore } from '../execution-store';
-import type { StepStatus } from '../execution.types';
+import { stepKeyId, type StepKey, type StepStatus } from '../execution.types';
import { StepSettledHandler } from '../step-settled-handler';
import type { NewStepRecord, StepRecord, StepStore, StepSummary } from '../step-store';
/**
- * trigger → a → {b (out 0), c (out 1)} → m. So `a` fans out across two output
+ * trigger -> a -> {b (out 0), c (out 1)} -> m. So `a` fans out across two output
* slots, `m` fans in behind both `b` and `c`, and `m` is terminal. Five
* reachable nodes in total, which the finish tests count against.
*/
@@ -34,7 +34,7 @@ function summary(
status: StepStatus,
filledOutputSlots: boolean[] = [],
): StepSummary {
- return { id: `step-${nodeId}`, nodeId, status, filledOutputSlots };
+ return { id: `step-${nodeId}`, nodeId, iteration: 0, status, filledOutputSlots };
}
/** Both of a's output slots fired, so both branches are live by default. */
@@ -53,7 +53,7 @@ function makeExecutionStore(
status: 'running',
mode: 'production',
graph,
- triggerPayload: null,
+ triggerOutputs: null,
...overrides,
};
return {
@@ -74,31 +74,36 @@ function makeStepStore(
id: 'step-a',
executionId: 'exec-1',
nodeId: 'a',
+ iteration: 0,
status: 'completed',
outputs: null,
error: null,
...step,
};
- const summariesByNodeId = Object.fromEntries(summaries.map((s) => [s.nodeId, s]));
+ const summariesByKey = Object.fromEntries(summaries.map((s) => [stepKeyId(s), s]));
return {
// ids derived from the node, so assertions can name the step they expect
createSteps: vi.fn().mockImplementation(async (_: string, records: NewStepRecord[]) => {
await Promise.resolve();
- return records.map(({ nodeId }) => ({ id: `step-${nodeId}`, nodeId }));
+ return records.map(({ nodeId, iteration }) => ({ id: `step-${nodeId}`, nodeId, iteration }));
}),
loadStep: vi.fn().mockResolvedValue(record),
claimStep: vi.fn(),
completeStep: vi.fn(),
failStep: vi.fn(),
cancelQueuedSteps: vi.fn(),
- // like the store: only requested nodes that have rows appear
- loadStepSummaries: vi.fn().mockImplementation(async (_: string, nodeIds: string[]) => {
+ // like the store: only requested keys that have rows appear
+ loadStepSummariesByKeys: vi.fn().mockImplementation(async (_: string, keys: StepKey[]) => {
await Promise.resolve();
return Object.fromEntries(
- nodeIds.filter((id) => summariesByNodeId[id]).map((id) => [id, summariesByNodeId[id]]),
+ keys
+ .map(stepKeyId)
+ .filter((key) => summariesByKey[key])
+ .map((key) => [key, summariesByKey[key]]),
);
}),
- loadStepsByNodeIds: vi.fn().mockResolvedValue({}),
+ loadStepsByKeys: vi.fn().mockResolvedValue({}),
+ loadLatestStep: vi.fn().mockResolvedValue(null),
// far from settled, so finish tests opt in explicitly
countSettledSteps: vi.fn().mockResolvedValue(0),
hasFailedSteps: vi.fn().mockResolvedValue(false),
@@ -140,8 +145,8 @@ describe('StepSettledHandler', () => {
await handler.handle(event);
expect(stepStore.createSteps).toHaveBeenCalledExactlyOnceWith('exec-1', [
- { nodeId: 'b', status: 'queued' },
- { nodeId: 'c', status: 'queued' },
+ { nodeId: 'b', iteration: 0, status: 'queued' },
+ { nodeId: 'c', iteration: 0, status: 'queued' },
]);
expect(stepQueue.publish).toHaveBeenCalledTimes(2);
expect(stepQueue.publish).toHaveBeenCalledWith({
@@ -168,7 +173,7 @@ describe('StepSettledHandler', () => {
await handler.handle({ ...event, stepId: 'step-trigger' });
expect(stepStore.createSteps).toHaveBeenCalledExactlyOnceWith('exec-1', [
- { nodeId: 'a', status: 'queued' },
+ { nodeId: 'a', iteration: 0, status: 'queued' },
]);
expect(stepQueue.publish).toHaveBeenCalledExactlyOnceWith({
type: 'step:ready',
@@ -190,7 +195,7 @@ describe('StepSettledHandler', () => {
expect(executionStore.finishExecution).toHaveBeenCalledExactlyOnceWith('exec-1', 'failed');
expect(stepStore.cancelQueuedSteps).toHaveBeenCalledExactlyOnceWith('exec-1');
- expect(stepStore.loadStepSummaries).not.toHaveBeenCalled();
+ expect(stepStore.loadStepSummariesByKeys).not.toHaveBeenCalled();
expect(stepStore.createSteps).not.toHaveBeenCalled();
expect(stepQueue.publish).not.toHaveBeenCalled();
expect(orchestrationQueue.publish).not.toHaveBeenCalled();
@@ -206,7 +211,11 @@ describe('StepSettledHandler', () => {
await handler.handle({ ...event, stepId: 'step-b' });
// b's only successor is m; m reads from both b and c
- expect(stepStore.loadStepSummaries).toHaveBeenCalledExactlyOnceWith('exec-1', ['m', 'b', 'c']);
+ expect(stepStore.loadStepSummariesByKeys).toHaveBeenCalledExactlyOnceWith('exec-1', [
+ { nodeId: 'm', iteration: 0 },
+ { nodeId: 'b', iteration: 0 },
+ { nodeId: 'c', iteration: 0 },
+ ]);
});
it('skips a dead branch and announces its settlement', async () => {
@@ -222,8 +231,8 @@ describe('StepSettledHandler', () => {
await handler.handle(event);
expect(stepStore.createSteps).toHaveBeenCalledExactlyOnceWith('exec-1', [
- { nodeId: 'b', status: 'queued' },
- { nodeId: 'c', status: 'skipped' },
+ { nodeId: 'b', iteration: 0, status: 'queued' },
+ { nodeId: 'c', iteration: 0, status: 'skipped' },
]);
expect(stepQueue.publish).toHaveBeenCalledExactlyOnceWith({
type: 'step:ready',
@@ -251,7 +260,7 @@ describe('StepSettledHandler', () => {
await handler.handle({ ...event, stepId: 'step-c' });
expect(stepStore.createSteps).toHaveBeenCalledExactlyOnceWith('exec-1', [
- { nodeId: 'm', status: 'queued' },
+ { nodeId: 'm', iteration: 0, status: 'queued' },
]);
expect(stepQueue.publish).toHaveBeenCalledExactlyOnceWith({
type: 'step:ready',
@@ -274,7 +283,7 @@ describe('StepSettledHandler', () => {
expect(stepQueue.publish).not.toHaveBeenCalled();
expect(orchestrationQueue.publish).not.toHaveBeenCalled();
- // nothing queued by us and the count says work remains → no finish
+ // nothing queued by us and the count says work remains -> no finish
expect(executionStore.finishExecution).not.toHaveBeenCalled();
});
diff --git a/packages/@n8n/engine/src/execution/execution-start-handler.ts b/packages/@n8n/engine/src/execution/execution-start-handler.ts
index 8ae2a93a94e..50b4e69b1fc 100644
--- a/packages/@n8n/engine/src/execution/execution-start-handler.ts
+++ b/packages/@n8n/engine/src/execution/execution-start-handler.ts
@@ -2,11 +2,12 @@ import { UnexpectedError } from '../common';
import { findTriggerNode } from '../graph';
import type { ExecutionEnqueuedEvent, OrchestrationMessage, WorkQueue } from '../queue';
import type { ExecutionStore } from './execution-store';
+import { DEFAULT_TRIGGER_OUTPUTS } from './execution.types';
import type { StepStore } from './step-store';
/**
* Handles the `execution:enqueued` orchestration event: claims the execution
- * (`queued → running`), records the trigger as a completed step, and announces
+ * (`queued -> running`), records the trigger as a completed step, and announces
* that completion. The first step(s) are planned by the step completion handler
* that handles the trigger completion.
* NOTE: this means an extra trip through the queue, but it eliminates some
@@ -37,17 +38,17 @@ export class ExecutionStartHandler {
throw new UnexpectedError(`Execution ${event.executionId} has no trigger node in its graph`);
}
- // The trigger's output was captured at execution start; record it as
- // already completed so successors can treat it as a satisfied predecessor.
+ // The trigger's outputs were captured at execution start; record them as
+ // already completed so successors read them like any predecessor's slots.
+ // No payload means no slots at all: every successor edge reads undefined
+ // and is treated as dead, same as any other step that produced nothing.
// The claim above makes this the only writer, so the row cannot exist yet.
- // NOTE: trigger payloads are not really supported yet — the raw payload is
- // not item-shaped, so the v1 shim coerces it to zero items. Proper trigger
- // handling will decide its shape.
const [triggerStep] = await this.stepStore.createSteps(event.executionId, [
{
nodeId: trigger.id,
+ iteration: 0,
status: 'completed',
- outputs: [execution.triggerPayload ?? {}],
+ outputs: execution.triggerOutputs ?? DEFAULT_TRIGGER_OUTPUTS,
},
]);
if (!triggerStep) {
diff --git a/packages/@n8n/engine/src/execution/execution-store.ts b/packages/@n8n/engine/src/execution/execution-store.ts
index b310c4cd637..96243e57719 100644
--- a/packages/@n8n/engine/src/execution/execution-store.ts
+++ b/packages/@n8n/engine/src/execution/execution-store.ts
@@ -1,6 +1,5 @@
-import type { JsonObject } from '../common';
import type { WorkflowGraph } from '../graph';
-import type { ExecutionMode, ExecutionStatus } from './execution.types';
+import type { ExecutionMode, ExecutionStatus, TriggerOutputs } from './execution.types';
/** A new execution to persist. `id` and timestamps are assigned by the store. */
export interface NewExecutionRecord {
@@ -8,7 +7,7 @@ export interface NewExecutionRecord {
status: ExecutionStatus;
mode: ExecutionMode;
graph: WorkflowGraph;
- triggerPayload: JsonObject | null;
+ triggerOutputs: TriggerOutputs | null;
}
/** A full execution record. */
@@ -18,7 +17,7 @@ export interface ExecutionRecord {
status: ExecutionStatus;
mode: ExecutionMode;
graph: WorkflowGraph;
- triggerPayload: JsonObject | null;
+ triggerOutputs: TriggerOutputs | null;
}
/** Thrown by `loadExecution` when no execution exists for the given id. */
diff --git a/packages/@n8n/engine/src/execution/execution.types.ts b/packages/@n8n/engine/src/execution/execution.types.ts
index 8f85b886564..db68ed059d3 100644
--- a/packages/@n8n/engine/src/execution/execution.types.ts
+++ b/packages/@n8n/engine/src/execution/execution.types.ts
@@ -40,3 +40,26 @@ export function isSettledStatus(status: StepStatus): boolean {
* the same thing — a step that ran and produced zero items is still live.
*/
export type StepSlots = JsonValue[];
+
+/**
+ * The trigger step's outputs, supplied by whoever starts the execution: one
+ * entry per output slot, `null` for a slot the trigger didn't fire. Same shape
+ * and same opacity as any other step's `StepSlots` — a v1 host puts JSON-shaped
+ * `INodeExecutionData[]` in each slot.
+ */
+export type TriggerOutputs = StepSlots;
+
+/** Slots recorded for a trigger that fired without a payload: no slots at all. */
+export const DEFAULT_TRIGGER_OUTPUTS: TriggerOutputs = [];
+
+export interface StepKey {
+ nodeId: string;
+ iteration: number;
+}
+
+/** A step key in the string form that keys instance-keyed lookups. */
+export type StepKeyId = string;
+
+export function stepKeyId({ nodeId, iteration }: StepKey): StepKeyId {
+ return `${nodeId}@${iteration}`;
+}
diff --git a/packages/@n8n/engine/src/execution/index.ts b/packages/@n8n/engine/src/execution/index.ts
index 13d77b8ac2a..0797b9819ce 100644
--- a/packages/@n8n/engine/src/execution/index.ts
+++ b/packages/@n8n/engine/src/execution/index.ts
@@ -3,11 +3,20 @@ export type {
StartExecutionRequest,
StartExecutionResult,
} from './start-execution.service';
-export type { ExecutionMode, ExecutionStatus, StepSlots, StepStatus } from './execution.types';
+export { stepKeyId } from './execution.types';
+export type {
+ ExecutionMode,
+ ExecutionStatus,
+ StepKey,
+ StepKeyId,
+ StepSlots,
+ StepStatus,
+ TriggerOutputs,
+} from './execution.types';
export { ExecutionNotFoundError } from './execution-store';
export type { ExecutionRecord, ExecutionStore, NewExecutionRecord } from './execution-store';
export { StepNotFoundError } from './step-store';
-export type { NewStepRecord, StepError, StepRecord, StepStore } from './step-store';
+export type { NewStepRecord, StepError, StepRecord, StepStore, StepSummary } from './step-store';
export { ExecutionStartHandler } from './execution-start-handler';
export { OrchestrationWorker } from './orchestration-worker';
export { StepSettledHandler } from './step-settled-handler';
diff --git a/packages/@n8n/engine/src/execution/iteration-mapping.ts b/packages/@n8n/engine/src/execution/iteration-mapping.ts
new file mode 100644
index 00000000000..28c94eadadb
--- /dev/null
+++ b/packages/@n8n/engine/src/execution/iteration-mapping.ts
@@ -0,0 +1,86 @@
+import type { GraphEdge, WorkflowLoop } from '../graph';
+import type { StepKey } from './execution.types';
+
+/**
+ * Which rows an edge connects, given that a loop member has one row per
+ * iteration. Each class fixes the iteration of the edge's source row (at
+ * `edge.from`) and of its target row (at `edge.to`):
+ *
+ * - `plain`: neither end is in a loop, so both rows sit at iteration 0.
+ * - `entry`: enters a batch node from outside. Target iteration 0 only, since
+ * later iterations are reached by the return edge instead.
+ * - `intra`: both ends inside one loop, so source and target share an iteration.
+ * - `back`: the marked return edge, where source iteration `i` pairs with target
+ * iteration `i + 1`.
+ * - `exit`: leaves a loop from the batch node's done slot. Its source is the
+ * loop's terminal row, whatever iteration that is, and its target sits at
+ * iteration 0.
+ */
+export type EdgeClass = 'plain' | 'entry' | 'intra' | 'back' | 'exit';
+
+/**
+ * The source row an edge has at a given target iteration. The two empty cases
+ * mean different things and must not be collapsed:
+ *
+ * - `none`: the edge connects nothing at this target iteration, so the caller
+ * ignores it. An entry edge has a source at target iteration 0 only, a return
+ * edge at target iteration 1 upwards only. Treating this as unresolved would
+ * leave every later iteration undecidable.
+ * - `pending`: the source is a terminal row that does not exist yet, so the
+ * caller leaves the decision undecidable. Treating this as `none` would decide
+ * the done side of a loop that has not ended, and settled fates are immutable.
+ */
+export type SourceRow = { kind: 'row'; key: StepKey } | { kind: 'none' } | { kind: 'pending' };
+
+export function classifyEdge(edge: GraphEdge, loops: WorkflowLoop[]): EdgeClass {
+ if (edge.isBackEdge) return 'back';
+
+ const sourceLoop = loops.find((loop) => loop.memberIds.has(edge.from));
+ const targetLoop = loops.find((loop) => loop.memberIds.has(edge.to));
+
+ if (sourceLoop && sourceLoop === targetLoop) return 'intra';
+ // An edge leaving one loop into another matches both `exit` and `entry`.
+ // `exit` is the correct one: the source row is the first loop's terminal row.
+ // The target row sits at iteration 0 either way.
+ if (sourceLoop) return 'exit';
+ if (targetLoop) return 'entry';
+ return 'plain';
+}
+
+/** The target row, given the source row. */
+export function targetKey(edge: GraphEdge, edgeClass: EdgeClass, source: StepKey): StepKey {
+ switch (edgeClass) {
+ case 'back':
+ return { nodeId: edge.to, iteration: source.iteration + 1 };
+ case 'exit':
+ case 'entry':
+ return { nodeId: edge.to, iteration: 0 };
+ default:
+ return { nodeId: edge.to, iteration: source.iteration };
+ }
+}
+
+/** The source row, given the target row. */
+export function sourceRow(
+ edge: GraphEdge,
+ edgeClass: EdgeClass,
+ target: StepKey,
+ terminalIteration?: number,
+): SourceRow {
+ const row = (iteration: number): SourceRow => ({
+ kind: 'row',
+ key: { nodeId: edge.from, iteration },
+ });
+
+ switch (edgeClass) {
+ case 'back':
+ return target.iteration === 0 ? { kind: 'none' } : row(target.iteration - 1);
+ case 'entry':
+ return target.iteration > 0 ? { kind: 'none' } : row(0);
+ case 'exit':
+ if (target.iteration > 0) return { kind: 'none' };
+ return terminalIteration === undefined ? { kind: 'pending' } : row(terminalIteration);
+ default:
+ return row(target.iteration);
+ }
+}
diff --git a/packages/@n8n/engine/src/execution/settlement.ts b/packages/@n8n/engine/src/execution/settlement.ts
index ab183b3b16c..2134e61e13e 100644
--- a/packages/@n8n/engine/src/execution/settlement.ts
+++ b/packages/@n8n/engine/src/execution/settlement.ts
@@ -1,5 +1,5 @@
import { getSuccessorNodeIds, type GraphEdge, type WorkflowGraph } from '../graph';
-import { isSettledStatus } from './execution.types';
+import { stepKeyId, isSettledStatus, type StepKey, type StepKeyId } from './execution.types';
import type { StepSummary } from './step-store';
/**
@@ -12,7 +12,7 @@ import type { StepSummary } from './step-store';
* slot; it is dead if the source settled any other way, or left the slot
* null. An edge whose source is unsettled is neither yet.
* 3. A node is decidable once every predecessor is settled. Decidable with at
- * least one live incoming edge → it runs (queued). Decidable with none →
+ * least one live incoming edge -> it runs (queued). Decidable with none ->
* it is skipped: settled at birth, its own out-edges all dead.
* 4. A settlement decides only its direct successors; the cascade is the
* event loop. A skip is announced as settled like any other settlement,
@@ -25,29 +25,34 @@ import type { StepSummary } from './step-store';
* Fates are pure functions of settled rows, so any planner, at any time,
* recomputes the same decisions; duplicates and races converge instead of
* corrupting.
+ *
+ * Rows are identified per `(nodeId, iteration)`: loop members run once per
+ * pass (CAT-2875). Forward edges stay within a pass; the back-edge and exit
+ * mappings land with loop execution, and until then back-edges are rejected.
*/
export interface SuccessorDecisions {
/** Successors with a live input, to enqueue — in edge order. */
- toQueue: string[];
+ toQueue: StepKey[];
/** Successors with settled but all-dead inputs, to record as skipped. */
- toSkip: string[];
+ toSkip: StepKey[];
}
/**
- * Decides the direct successors of `settledNodeId` (rules 2–4). `steps` holds
- * the existing rows for those successors and their predecessors, including
- * `settledNodeId` itself.
+ * Decides the direct successors of the settled step (rules 2–4). `steps`
+ * holds the existing rows for those successors and their predecessors, keyed
+ * by `stepKeyId`, including the settled step itself.
*/
export function decideSuccessors(
graph: WorkflowGraph,
- settledNodeId: string,
- steps: Record,
+ settled: StepKey,
+ steps: Record,
): SuccessorDecisions {
const decisions: SuccessorDecisions = { toQueue: [], toSkip: [] };
- for (const successor of getSuccessorNodeIds(graph, settledNodeId)) {
+ for (const successorNodeId of getSuccessorNodeIds(graph, settled.nodeId)) {
+ const successor: StepKey = { nodeId: successorNodeId, iteration: settled.iteration };
// An existing row was decided by an earlier settlement, which announced it.
- if (steps[successor]) continue;
+ if (steps[stepKeyId(successor)]) continue;
const fate = decideNodeFate(graph, successor, steps);
if (fate === 'queued') decisions.toQueue.push(successor);
else if (fate === 'skipped') decisions.toSkip.push(successor);
@@ -55,24 +60,31 @@ export function decideSuccessors(
return decisions;
}
-/** One node's fate under rules 2–3; undecidable while a predecessor is unsettled. */
+/** One candidate's fate under rules 2–3; undecidable while a predecessor is unsettled. */
function decideNodeFate(
graph: WorkflowGraph,
- nodeId: string,
- steps: Record,
+ candidate: StepKey,
+ steps: Record,
): 'queued' | 'skipped' | 'undecidable' {
// Back-edges aside: loop iteration is CAT-2875.
- const incoming = graph.edges.filter((edge) => edge.to === nodeId && !edge.isBackEdge);
+ const incoming = graph.edges.filter((edge) => edge.to === candidate.nodeId && !edge.isBackEdge);
const predecessors = [...new Set(incoming.map((edge) => edge.from))];
- const settled = predecessors.every(
- (id) => steps[id] !== undefined && isSettledStatus(steps[id].status),
- );
+ const settled = predecessors.every((nodeId) => {
+ const row = steps[stepKeyId({ nodeId, iteration: candidate.iteration })];
+ return row !== undefined && isSettledStatus(row.status);
+ });
if (!settled) return 'undecidable';
- return incoming.some((edge) => isLiveEdge(edge, steps)) ? 'queued' : 'skipped';
+ return incoming.some((edge) => isLiveEdge(edge, candidate.iteration, steps))
+ ? 'queued'
+ : 'skipped';
}
/** Rule 2. A slot beyond the produced list reads undefined — dead, like null. */
-function isLiveEdge(edge: GraphEdge, steps: Record): boolean {
- const source = steps[edge.from];
+function isLiveEdge(
+ edge: GraphEdge,
+ iteration: number,
+ steps: Record,
+): boolean {
+ const source = steps[stepKeyId({ nodeId: edge.from, iteration })];
return source?.status === 'completed' && Boolean(source.filledOutputSlots[edge.outputIndex]);
}
diff --git a/packages/@n8n/engine/src/execution/start-execution.service.ts b/packages/@n8n/engine/src/execution/start-execution.service.ts
index c25e9143d4d..fcf0c7b7b0a 100644
--- a/packages/@n8n/engine/src/execution/start-execution.service.ts
+++ b/packages/@n8n/engine/src/execution/start-execution.service.ts
@@ -1,14 +1,14 @@
import { AdmittanceRejectedError, type AdmittanceService } from '../admittance';
-import type { JsonObject } from '../common';
import { validateExecutableGraph, type WorkflowGraph } from '../graph';
import type { OrchestrationMessage, WorkQueue } from '../queue';
import type { ExecutionStore } from './execution-store';
-import type { ExecutionMode } from './execution.types';
+import type { ExecutionMode, TriggerOutputs } from './execution.types';
export interface StartExecutionRequest {
workflowId: string;
graph: WorkflowGraph;
- triggerPayload?: JsonObject | null;
+ /** Trigger step's output slots, one entry per output. */
+ triggerOutputs?: TriggerOutputs | null;
mode?: ExecutionMode;
}
@@ -40,7 +40,7 @@ export class StartExecutionService {
status: 'queued',
mode: request.mode ?? 'production',
graph: request.graph,
- triggerPayload: request.triggerPayload ?? null,
+ triggerOutputs: request.triggerOutputs ?? null,
});
// TODO(CAT-2938): the persist above and this publish aren't atomic — a
diff --git a/packages/@n8n/engine/src/execution/step-ready-handler.ts b/packages/@n8n/engine/src/execution/step-ready-handler.ts
index af7afcd4587..c33dabd80e8 100644
--- a/packages/@n8n/engine/src/execution/step-ready-handler.ts
+++ b/packages/@n8n/engine/src/execution/step-ready-handler.ts
@@ -3,12 +3,12 @@ import type { ExternalDependencies, IStepExecutor } from '../dependencies';
import type { GraphEdge, GraphNode } from '../graph';
import type { OrchestrationMessage, StepReadyEvent, WorkQueue } from '../queue';
import type { ExecutionRecord, ExecutionStore } from './execution-store';
-import { isSettledStatus, type StepSlots } from './execution.types';
+import { stepKeyId, isSettledStatus, type StepKeyId, type StepSlots } from './execution.types';
import type { StepError, StepRecord, StepStore } from './step-store';
import { validateStepContext } from './validate-step-context';
/**
- * Handles the `step:ready` step event: claims the step (`queued → running`),
+ * Handles the `step:ready` step event: claims the step (`queued -> running`),
* runs it through the executor for its step type, records the outcome, and
* reports back to the orchestration worker with `step:settled`.
*
@@ -117,9 +117,9 @@ export class StepReadyHandler {
validateIncomingEdges(incomingEdges, step);
const predecessorNodeIds = [...new Set(incomingEdges.map((edge) => edge.from))];
- const predecessorSteps = await this.stepStore.loadStepsByNodeIds(
+ const predecessorSteps = await this.stepStore.loadStepsByKeys(
execution.id,
- predecessorNodeIds,
+ predecessorNodeIds.map((nodeId) => ({ nodeId, iteration: step.iteration })),
);
// Array of length equal to the highest input slot plus one.
@@ -130,7 +130,7 @@ export class StepReadyHandler {
);
for (const edge of incomingEdges) {
- inputs[edge.inputIndex] = readEdgeValue(edge, predecessorSteps, step);
+ inputs[edge.inputIndex] = readEdgeValue(edge, step, predecessorSteps);
}
return inputs;
@@ -178,10 +178,10 @@ function validateIncomingEdges(incomingEdges: GraphEdge[], step: StepRecord): vo
*/
function readEdgeValue(
edge: GraphEdge,
- predecessorSteps: Record,
step: StepRecord,
+ predecessorSteps: Record,
): JsonValue {
- const row = predecessorSteps[edge.from];
+ const row = predecessorSteps[stepKeyId({ nodeId: edge.from, iteration: step.iteration })];
if (!row || !isSettledStatus(row.status)) {
// A step is planned only once every predecessor settled, so running on
// a fabricated empty input would mask a planner/store inconsistency.
diff --git a/packages/@n8n/engine/src/execution/step-settled-handler.ts b/packages/@n8n/engine/src/execution/step-settled-handler.ts
index f9bc1421e55..d493eeeafcb 100644
--- a/packages/@n8n/engine/src/execution/step-settled-handler.ts
+++ b/packages/@n8n/engine/src/execution/step-settled-handler.ts
@@ -7,8 +7,9 @@ import {
} from '../graph';
import type { OrchestrationMessage, StepMessage, StepSettledEvent, WorkQueue } from '../queue';
import type { ExecutionRecord, ExecutionStore } from './execution-store';
+import { stepKeyId, type StepKey, type StepKeyId } from './execution.types';
import { decideSuccessors } from './settlement';
-import type { StepStore, StepSummary } from './step-store';
+import type { StepRecord, StepStore, StepSummary } from './step-store';
import { validateStepContext } from './validate-step-context';
/**
@@ -56,8 +57,8 @@ export class StepSettledHandler {
return;
}
- const steps = await this.loadDecisionSteps(execution, step.nodeId);
- queued = await this.planSuccessors(execution, step.nodeId, steps);
+ const steps = await this.loadDecisionSteps(execution, step);
+ queued = await this.planSuccessors(execution, step, steps);
}
// If we've queued steps, we know the execution isn't done yet, so we
@@ -72,13 +73,13 @@ export class StepSettledHandler {
await this.stepStore.cancelQueuedSteps(executionId);
}
- /** Plans the direct successors of `nodeId`, returning how many were queued. */
+ /** Plans the settled step's direct successors, returning how many were queued. */
private async planSuccessors(
execution: ExecutionRecord,
- nodeId: string,
- steps: Record,
+ step: StepRecord,
+ steps: Record,
): Promise {
- const { toQueue, toSkip } = decideSuccessors(execution.graph, nodeId, steps);
+ const { toQueue, toSkip } = decideSuccessors(execution.graph, step, steps);
if (toQueue.length === 0 && toSkip.length === 0) return 0;
// One batch, so a settlement's consequence lands atomically and a fan-out
@@ -88,11 +89,11 @@ export class StepSettledHandler {
// the rows forever; the reconciler re-announces stale queued steps and
// settled steps whose decidable successors have no rows.
const created = await this.stepStore.createSteps(execution.id, [
- ...toQueue.map((id) => ({ nodeId: id, status: 'queued' as const })),
- ...toSkip.map((id) => ({ nodeId: id, status: 'skipped' as const })),
+ ...toQueue.map((key) => ({ ...key, status: 'queued' as const })),
+ ...toSkip.map((key) => ({ ...key, status: 'skipped' as const })),
]);
- return await this.announceCreatedSteps(execution.id, created, new Set(toQueue));
+ return await this.announceCreatedSteps(execution.id, created, new Set(toQueue.map(stepKeyId)));
}
/**
@@ -102,16 +103,19 @@ export class StepSettledHandler {
*/
private async loadDecisionSteps(
execution: ExecutionRecord,
- nodeId: string,
- ): Promise> {
- const successors = getSuccessorNodeIds(execution.graph, nodeId);
+ step: StepRecord,
+ ): Promise> {
+ const successors = getSuccessorNodeIds(execution.graph, step.nodeId);
const nodeIds = [
...new Set([
...successors,
...successors.flatMap((id) => getPredecessorNodeIds(execution.graph, id)),
]),
];
- return await this.stepStore.loadStepSummaries(execution.id, nodeIds);
+ // Forward edges stay within a pass, so every row read sits at the
+ // settled row's iteration.
+ const keys = nodeIds.map((nodeId) => ({ nodeId, iteration: step.iteration }));
+ return await this.stepStore.loadStepSummariesByKeys(execution.id, keys);
}
/**
@@ -121,12 +125,12 @@ export class StepSettledHandler {
*/
private async announceCreatedSteps(
executionId: string,
- created: Array<{ id: string; nodeId: string }>,
- queuedNodeIds: Set,
+ created: Array<{ id: string } & StepKey>,
+ queuedKeys: Set,
): Promise {
let queued = 0;
- for (const { id: stepId, nodeId } of created) {
- if (queuedNodeIds.has(nodeId)) {
+ for (const { id: stepId, ...key } of created) {
+ if (queuedKeys.has(stepKeyId(key))) {
queued += 1;
await this.stepQueue.publish({ type: 'step:ready', executionId, stepId });
} else {
diff --git a/packages/@n8n/engine/src/execution/step-store.ts b/packages/@n8n/engine/src/execution/step-store.ts
index 77e92c4c983..769c5dc309b 100644
--- a/packages/@n8n/engine/src/execution/step-store.ts
+++ b/packages/@n8n/engine/src/execution/step-store.ts
@@ -1,5 +1,5 @@
import type { JsonValue } from '../common';
-import type { StepSlots, StepStatus } from './execution.types';
+import type { StepKey, StepKeyId, StepSlots, StepStatus } from './execution.types';
/**
* A new step to persist. `id` and timestamps are assigned by the store.
@@ -12,7 +12,7 @@ import type { StepSlots, StepStatus } from './execution.types';
* `[]`: a missing one persists as SQL NULL, which liveness reads as every
* output slot dead.
*/
-export type NewStepRecord = { nodeId: string } & (
+export type NewStepRecord = { nodeId: string; iteration: number } & (
| { status: Extract; outputs?: never }
| { status: Extract; outputs: StepSlots }
);
@@ -35,6 +35,7 @@ export interface StepRecord {
id: string;
executionId: string;
nodeId: string;
+ iteration: number;
status: StepStatus;
/** Outputs of a completed step, indexed by output slot; `null` until it completes. */
outputs: StepSlots | null;
@@ -50,6 +51,7 @@ export interface StepRecord {
export interface StepSummary {
id: string;
nodeId: string;
+ iteration: number;
status: StepStatus;
/** Per output slot: whether the completed step put data there. Empty unless completed. */
filledOutputSlots: boolean[];
@@ -69,9 +71,10 @@ export interface StepStore {
* Persist new step records for one execution, batched so planning a fan-out
* costs a single round trip. Returns the rows actually created.
*
- * If a step for a given `(executionId, nodeId)` already exists, it is skipped
- * and not returned. This allows multiple planners to race to enqueue the same
- * node without erroring or duplicating work. We return the actually created rows
+ * If a step for a given `(executionId, nodeId, iteration)` already exists, it
+ * is skipped and not returned. This allows multiple planners to race to enqueue
+ * the same step without erroring or duplicating work. A further iteration of
+ * the same node is a new row, not a duplicate. We return the actually created rows
* so the caller knows which step creations it needs to publish.
*
* Creates nothing once any step in the execution has failed (serialized with
@@ -81,13 +84,13 @@ export interface StepStore {
createSteps(
executionId: string,
records: NewStepRecord[],
- ): Promise>;
+ ): Promise>;
/** Load a single step by id. Throws `StepNotFoundError` if absent. */
loadStep(id: string): Promise;
/**
- * Claim a queued step for execution (`queued → running`). A compare-and-set,
+ * Claim a queued step for execution (`queued -> running`). A compare-and-set,
* so it returns the claimed step for at most one caller — `null` means the
* claim was lost and duplicate/redelivered events are handled idempotently.
*
@@ -112,25 +115,34 @@ export interface StepStore {
/** Record a failed run: persist `error` and mark the step failed. As `completeStep`. */
failStep(id: string, error: StepError): Promise;
- /** Cancel every step of the execution still `queued` (`queued → cancelled`). */
+ /** Cancel every step of the execution still `queued` (`queued -> cancelled`). */
cancelQueuedSteps(executionId: string): Promise;
/**
- * Step rows of the given nodes within an execution, keyed by node id. A node
- * with no row yet is absent from the result — absence always means "not
- * planned yet", never "forgotten".
+ * Step rows of the given keys within an execution, keyed by `stepKeyId`. A
+ * key with no row yet is absent from the result — absence always means
+ * "not planned yet", never "forgotten".
*
* Full rows, outputs included — for gathering a ready step's inputs from its
- * direct predecessors. Planning reads `loadStepSummaries` instead.
+ * direct predecessors. Planning reads `loadStepSummariesByKeys` instead.
*/
- loadStepsByNodeIds(executionId: string, nodeIds: string[]): Promise>;
+ loadStepsByKeys(executionId: string, keys: StepKey[]): Promise>;
/**
- * Planning view of the given nodes' rows, keyed by node id; absent as in
- * `loadStepsByNodeIds`. The per-slot booleans are computed in the database,
- * so planning never pulls the potentially large outputs over the wire.
+ * Planning view of the given keys' rows, keyed by `stepKeyId`; absent as in
+ * `loadStepsByKeys`. The per-slot booleans are computed in the database, so
+ * planning never pulls the potentially large outputs over the wire.
*/
- loadStepSummaries(executionId: string, nodeIds: string[]): Promise>;
+ loadStepSummariesByKeys(
+ executionId: string,
+ keys: StepKey[],
+ ): Promise>;
+
+ /**
+ * The node's highest-iteration row, or `null` when it has none. For a
+ * batch node this is the loop's ledger tip.
+ */
+ loadLatestStep(executionId: string, nodeId: string): Promise;
/**
* How many of the execution's steps have settled (completed, failed,
diff --git a/packages/@n8n/engine/src/graph/__tests__/loops.test.ts b/packages/@n8n/engine/src/graph/__tests__/loops.test.ts
new file mode 100644
index 00000000000..e300906ed29
--- /dev/null
+++ b/packages/@n8n/engine/src/graph/__tests__/loops.test.ts
@@ -0,0 +1,599 @@
+import { describe, expect, it } from 'vitest';
+
+import { deriveLoops, validateLoops } from '../loops';
+import type { GraphEdge, StepType, WorkflowGraph } from '../workflow-graph';
+
+/** Nodes are inferred from edges; `batch:` names the batch-typed ones. */
+function makeGraph(
+ edges: Array & Pick>,
+ { batch = [] as string[] } = {},
+): WorkflowGraph {
+ const nodeIds = [...new Set(edges.flatMap(({ from, to }) => [from, to]))];
+ const typeOf = (id: string): StepType =>
+ id === 'trigger' ? 'trigger' : batch.includes(id) ? 'batch' : 'v1-node';
+ return {
+ nodes: nodeIds.map((id) => ({ id, name: id.toUpperCase(), type: typeOf(id) })),
+ edges: edges.map((edge) => ({ outputIndex: 0, inputIndex: 0, ...edge })),
+ };
+}
+
+/**
+ * The canonical shape: entry into B, body x, return to B, exit from B's done slot.
+ *
+ * ┌───────┐ ┌───┐ o0 ┌───┐
+ * │trigger├───►│ ├──────►│ d │
+ * └───────┘ │ B │ └───┘
+ * │ │ o1 ┌───┐
+ * │ ├──────►│ x │
+ * └─▲─┘ └─┬─┘
+ * └──(back)───┘
+ */
+const simpleLoop = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+);
+
+describe('deriveLoops', () => {
+ it('derives nothing for a graph without back-edges', () => {
+ // ┌───────┐ ┌───┐
+ // │trigger├───►│ a │
+ // └───────┘ └───┘
+ expect(deriveLoops(makeGraph([{ from: 'trigger', to: 'a' }]))).toEqual([]);
+ });
+
+ it('derives the members and boundary edges of a simple loop', () => {
+ const [loop] = deriveLoops(simpleLoop);
+
+ expect(loop.batchNodeId).toBe('B');
+ expect(loop.memberIds).toEqual(new Set(['B', 'x']));
+ expect(loop.backEdges).toEqual([
+ { from: 'x', to: 'B', outputIndex: 0, inputIndex: 0, isBackEdge: true },
+ ]);
+ expect(loop.entryEdges).toEqual([{ from: 'trigger', to: 'B', outputIndex: 0, inputIndex: 0 }]);
+ expect(loop.exitEdges).toEqual([{ from: 'B', to: 'd', outputIndex: 0, inputIndex: 0 }]);
+ });
+
+ it('derives a self-loop as a single-member loop', () => {
+ // ┌───────┐ ┌───┐ o0 ┌───┐
+ // │trigger├───►│ B ├──────►│ d │
+ // └───────┘ └▲─┬┘ └───┘
+ // └─┘ o1 (back into its own slot 0)
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'B', outputIndex: 1, isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+
+ const [loop] = deriveLoops(graph);
+ expect(loop.memberIds).toEqual(new Set(['B']));
+ expect(loop.backEdges).toHaveLength(1);
+ });
+
+ it('derives two disjoint loops independently', () => {
+ // ┌───────┐ ┌────┐ o1 ┌───┐
+ // │trigger├───►│ ├──────►│ x │
+ // └───────┘ │ B1 │ └─┬─┘
+ // │ ◄──(back)─┘
+ // └─┬──┘
+ // o0▼
+ // ┌────┐ o1 ┌───┐
+ // │ ├──────►│ y │
+ // │ B2 │ └─┬─┘
+ // │ ◄──(back)─┘
+ // └─┬──┘
+ // o0▼
+ // ┌───┐
+ // │ d │
+ // └───┘
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B1' },
+ { from: 'B1', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B1', isBackEdge: true },
+ { from: 'B1', to: 'B2', outputIndex: 0 },
+ { from: 'B2', to: 'y', outputIndex: 1 },
+ { from: 'y', to: 'B2', isBackEdge: true },
+ { from: 'B2', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B1', 'B2'] },
+ );
+
+ const loops = deriveLoops(graph);
+ expect(loops.map((l) => [l.batchNodeId, [...l.memberIds].sort()])).toEqual([
+ ['B1', ['B1', 'x']],
+ ['B2', ['B2', 'y']],
+ ]);
+ });
+});
+
+describe('validateLoops', () => {
+ it('accepts a simple loop, a self-loop, and a branchy body that reconverges', () => {
+ expect(() => validateLoops(simpleLoop)).not.toThrow();
+
+ // ┌───────┐ ┌───┐ o0 ┌───┐
+ // │trigger├───►│ B ├──────►│ d │
+ // └───────┘ └▲─┬┘ └───┘
+ // └─┘ o1 (back into its own slot 0)
+ const selfLoop = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'B', outputIndex: 1, isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+ expect(() => validateLoops(selfLoop)).not.toThrow();
+
+ // ┌───────┐ ┌───┐ o1 ┌───┐ o0 ┌────┐ i0 ┌───┐
+ // │trigger├───►│ ├──────►│ x ├──────►│ y1 ├──────►│ │
+ // └───────┘ │ B │ └─┬─┘ └────┘ │ m │
+ // │ │ o1 │ ┌────┐ i1 │ │
+ // │ │ └────────►│ y2 ├──────►│ │
+ // │ │ └────┘ └─┬─┘
+ // │ ◄──────────────(back)──────────────┘
+ // └─┬─┘
+ // o0▼
+ // ┌───┐
+ // │ d │
+ // └───┘
+ const branchyBody = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'y1', outputIndex: 0 },
+ { from: 'x', to: 'y2', outputIndex: 1 },
+ { from: 'y1', to: 'm', inputIndex: 0 },
+ { from: 'y2', to: 'm', inputIndex: 1 },
+ { from: 'm', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+ expect(() => validateLoops(branchyBody)).not.toThrow();
+ });
+
+ it('accepts a graph without loops', () => {
+ // ┌───────┐ ┌───┐
+ // │trigger├───►│ a │
+ // └───────┘ └───┘
+ expect(() => validateLoops(makeGraph([{ from: 'trigger', to: 'a' }]))).not.toThrow();
+ });
+
+ it('rejects an edge endpoint that is not a node', () => {
+ // ┌───────┐ ┌───┐ o1 ┌───────┐
+ // │trigger├───►│ B ├──────►│ ghost │
+ // └───────┘ └─▲─┘ └───┬───┘
+ // └───(back)────┘
+ // SCC membership would otherwise admit the ghost as a loop member, and
+ // the planner would create a row no worker can run
+ const graph: WorkflowGraph = {
+ nodes: [
+ { id: 'trigger', name: 'T', type: 'trigger' },
+ { id: 'B', name: 'B', type: 'batch' },
+ ],
+ edges: [
+ { from: 'trigger', to: 'B', outputIndex: 0, inputIndex: 0 },
+ { from: 'B', to: 'ghost', outputIndex: 1, inputIndex: 0 },
+ { from: 'ghost', to: 'B', outputIndex: 0, inputIndex: 0, isBackEdge: true },
+ ],
+ };
+
+ expect(() => validateLoops(graph)).toThrow(/not a node in the graph/);
+ });
+
+ it('rejects duplicate node ids', () => {
+ // the canonical shape, but two nodes both claim the id B
+ const graph: WorkflowGraph = {
+ nodes: [
+ { id: 'trigger', name: 'T', type: 'trigger' },
+ { id: 'B', name: 'B', type: 'batch' },
+ { id: 'B', name: 'B2', type: 'v1-node' },
+ { id: 'x', name: 'X', type: 'v1-node' },
+ ],
+ edges: [
+ { from: 'trigger', to: 'B', outputIndex: 0, inputIndex: 0 },
+ { from: 'B', to: 'x', outputIndex: 1, inputIndex: 0 },
+ { from: 'x', to: 'B', outputIndex: 0, inputIndex: 0, isBackEdge: true },
+ ],
+ };
+
+ expect(() => validateLoops(graph)).toThrow(/share the id/);
+ });
+
+ it('rejects an unmarked forward cycle among members (rule 1)', () => {
+ // ┌───────┐ ┌───┐ o0 ┌───┐
+ // │trigger├───►│ ├──────►│ d │
+ // └───────┘ │ B │ └───┘
+ // │ │ o1 ┌───┐ ┌───┐
+ // │ ├──────►│ a ├──────►│ c │
+ // │ ◄─(back)┴─▲─┘ └─┬─┘
+ // └───┘ └────i1─────┘
+ // B -> a -> c -> a is a cycle no back-edge closes: a and c deadlock waiting
+ // on each other's settlement.
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'a', outputIndex: 1 },
+ { from: 'a', to: 'c' },
+ { from: 'c', to: 'a', inputIndex: 1 },
+ { from: 'a', to: 'B', outputIndex: 1, isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/cycle with no back-edge/);
+ });
+
+ it('rejects a back-edge returning to a non-batch node (rule 2)', () => {
+ // ┌───────┐ ┌───┐ ┌───┐
+ // │trigger├───►│ a ├──────►│ x │ a is a plain node
+ // └───────┘ └─▲─┘ └─┬─┘
+ // └──(back)───┘
+ const graph = makeGraph([
+ { from: 'trigger', to: 'a' },
+ { from: 'a', to: 'x' },
+ { from: 'x', to: 'a', isBackEdge: true },
+ ]);
+
+ expect(() => validateLoops(graph)).toThrow(/not a batch node/);
+ });
+
+ it('rejects a back-edge into a slot other than 0 (rule 2)', () => {
+ // ┌───────┐ ┌───┐ o0 ┌───┐
+ // │trigger├───►│ ├──────►│ d │
+ // └───────┘ │ B │ └───┘
+ // │ │ o1 ┌───┐
+ // │ ├──────►│ x │
+ // └─▲─┘ └─┬─┘
+ // └─(back,i1)─┘
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B', inputIndex: 1, isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/slot 0/);
+ });
+
+ it('rejects a back-edge returning from outside the loop (rule 2)', () => {
+ // ┌───────┐ ┌───┐ o0 ┌───┐
+ // │ ├───►│ B ├──────►│ d │
+ // │trigger│ └─▲─┘ └───┘
+ // │ │ │ (back)
+ // │ │ ┌─┴─┐
+ // │ ├───►│ z │
+ // └───────┘ └───┘
+ // z never receives anything from B, so it is no member, yet returns to it
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'trigger', to: 'z', outputIndex: 0 },
+ { from: 'z', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/outside the loop/);
+ });
+
+ it('rejects a batch node nothing returns to (rule 3)', () => {
+ // ┌───────┐ ┌───┐ o0 ┌───┐ ┌────┐ o1 ┌───────┐
+ // │trigger├───►│ ├──────►│ d │ │ B2 ├──────►│ other │
+ // └───────┘ │ B │ └───┘ └─▲──┘ └───┬───┘
+ // │ │ o1 ┌───┐ └────(back)────┘
+ // │ ├──────►│ x │
+ // └───┘ └───┘ nothing returns to B
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ // x -> B exists in a valid loop; here the loop is left open
+ { from: 'other', to: 'B2', isBackEdge: true },
+ { from: 'B2', to: 'other', outputIndex: 1 },
+ ],
+ { batch: ['B', 'B2'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/no back-edge returning to it/);
+ });
+
+ it('rejects several returns to one batch node (rule 3, CAT-3982)', () => {
+ // ┌───────┐ ┌───┐ o1 ┌───┐ ┌───┐
+ // │trigger├───►│ B ├──────►│ x ├──────►│ y │
+ // └───────┘ └─▲─┘ └─┬─┘ └─┬─┘
+ // ├─(back,o1)─┘ │
+ // └────────(back)─────────┘
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'y' },
+ { from: 'x', to: 'B', outputIndex: 1, isBackEdge: true },
+ { from: 'y', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/not supported yet/);
+ });
+
+ it('rejects an entry edge into a batch slot other than 0 (rule 4)', () => {
+ // ┌───────┐ i1 ┌───┐ o0 ┌───┐
+ // │trigger├───►│ ├──────►│ d │
+ // └───────┘ │ B │ └───┘
+ // │ │ o1 ┌───┐
+ // │ ├──────►│ x │
+ // └─▲─┘ └─┬─┘
+ // └──(back)───┘
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B', inputIndex: 1 },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/only slot 0/);
+ });
+
+ it('rejects a batch out-edge beyond slots 0 and 1 (rule 4)', () => {
+ // ┌───────┐ ┌───┐ o2 ┌───┐
+ // │trigger├───►│ ├──────►│ d │ o2 does not exist on a batch node
+ // └───────┘ │ B │ └───┘
+ // │ │ o1 ┌───┐
+ // │ ├──────►│ x │
+ // └─▲─┘ └─┬─┘
+ // └──(back)───┘
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 2 },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/done \(0\) and loop \(1\)/);
+
+ // same shape, exit on slot -1: `validateLoops` also runs standalone, where
+ // nothing has checked slot sanity yet
+ const negativeSlot = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: -1 },
+ ],
+ { batch: ['B'] },
+ );
+ expect(() => validateLoops(negativeSlot)).toThrow(/done \(0\) and loop \(1\)/);
+ });
+
+ it('rejects a dangling body branch (rule 5, sequentiality)', () => {
+ // ┌───────┐ ┌───┐ o0 ┌───┐
+ // │trigger├───►│ ├──────►│ d │
+ // └───────┘ │ B │ └───┘
+ // │ │ o1 ┌───┐ o1 ┌───┐
+ // │ ├──────►│ x ├──────►│ z │
+ // └─▲─┘ └─┬─┘ └───┘
+ // └─(back,o0)─┘
+ // z hangs off the body and never returns: its pass i could still run
+ // while pass i+1 starts, so it is rejected until a barrier exists.
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'z', outputIndex: 1 },
+ { from: 'x', to: 'B', outputIndex: 0, isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/dangling body branches/);
+ });
+
+ it('rejects a loop-slot edge that leaves the loop (rule 5)', () => {
+ // ┌───────┐ ┌───┐ o1 ┌───┐
+ // │trigger├───►│ ├──────►│ x │
+ // └───────┘ │ B │ └─┬─┘
+ // │ ◄──(back)─┘
+ // │ │ o1 ┌───┐
+ // │ ├──────►│ z │ also o1, but z never returns
+ // └───┘ └───┘
+ // B's loop slot feeds a node that never returns
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'z', outputIndex: 1 },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/leaves the loop/);
+ });
+
+ it('rejects a done slot feeding the loop body (rule 5)', () => {
+ // ┌───────┐ ┌───┐ o1 ┌───┐
+ // │trigger├───►│ ├──────►│ │
+ // └───────┘ │ B │ │ x │
+ // │ │ o0 i1 │ │
+ // │ ├──────►│ │
+ // └─▲─┘ └─┬─┘
+ // └──(back)───┘
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'B', to: 'x', outputIndex: 0, inputIndex: 1 },
+ { from: 'x', to: 'B', isBackEdge: true },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/member of its own loop/);
+ });
+
+ it('rejects an edge entering the loop mid-body (rule 5)', () => {
+ // ┌───────┐ ┌───┐ o1 ┌───┐
+ // │trigger├───►│ B ├──────►│ x │
+ // └───────┘ └─▲─┘ └─┬─┘
+ // └──(back)───┘
+ // the trigger also feeds x's slot 1, entering the body without passing B
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'trigger', to: 'x', outputIndex: 0, inputIndex: 1 },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/mid-body/);
+ });
+
+ it('rejects several entry edges into one batch node (CAT-3982)', () => {
+ // ┌───────┐ o0 ┌────┐
+ // │ ├───►│ p1 ├──┐
+ // │ │ └────┘ │ ┌───┐ o0 ┌───┐
+ // │trigger│ └─►│ ├──────►│ d │
+ // │ │ o1 ┌────┐ ┌─►│ B │ └───┘
+ // │ ├───►│ p2 ├──┘ │ │ o1 ┌───┐
+ // └───────┘ └────┘ │ ├──────►│ x │
+ // └─▲─┘ └─┬─┘
+ // └──(back)───┘
+ // both branches feed B's slot 0 at iteration 0: same-slot convergence
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'p1', outputIndex: 0 },
+ { from: 'trigger', to: 'p2', outputIndex: 1 },
+ { from: 'p1', to: 'B' },
+ { from: 'p2', to: 'B' },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/2 entry edges/);
+ });
+
+ it('rejects a self-loop returning from the done slot (rule 5)', () => {
+ // ┌───────┐ ┌───┐
+ // │trigger├───►│ B │
+ // └───────┘ └▲─┬┘
+ // └─┘ o0 (back from the done slot)
+ // the done slot is dead while items remain, so this loop would end after
+ // its first slice
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'B', outputIndex: 0, isBackEdge: true },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/member of its own loop/);
+ });
+
+ it('reports a malformed entry slot over unsupported convergence (rules 4 vs 3982)', () => {
+ // ┌───────┐ o0 ┌────┐
+ // │ ├───►│ p1 ├──┐
+ // │ │ └────┘ │ ┌───┐ o0 ┌───┐
+ // │trigger│ └─►│ ├──────►│ d │
+ // │ │ o1 ┌────┐ ┌─►│ B │ └───┘
+ // │ ├───►│ p2 ├──┘ │ │ o1 ┌───┐
+ // └───────┘ └────┘ │ ├──────►│ x │
+ // └─▲─┘ └─┬─┘
+ // └──(back)───┘
+ // as above, but p2 arrives on B's slot 1
+ // the slot-1 entry can never be valid: it outranks unsupported convergence
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'p1', outputIndex: 0 },
+ { from: 'trigger', to: 'p2', outputIndex: 1 },
+ { from: 'p1', to: 'B' },
+ { from: 'p2', to: 'B', inputIndex: 1 },
+ { from: 'B', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/only slot 0/);
+ });
+
+ it('reports a non-batch back-edge target as such, even beside a batch node (rule 2)', () => {
+ // ┌───────┐ ┌───┐ ┌────┐
+ // │trigger├───►│ a ├──────►│ B2 │
+ // └───────┘ └─▲─┘ └▲─┬┬┘
+ // │ └─┘│ o1 (back into its own slot 0)
+ // └───(back, o0)┘
+ // a and B2 share one component; the defect is the target, not nesting
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'a' },
+ { from: 'a', to: 'B2' },
+ { from: 'B2', to: 'a', outputIndex: 0, isBackEdge: true },
+ { from: 'B2', to: 'B2', outputIndex: 1, isBackEdge: true },
+ ],
+ { batch: ['B2'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/not a batch node/);
+ });
+
+ it('rejects a nested loop (rule 5)', () => {
+ // ┌───────┐ ┌───┐ o0 ┌───┐
+ // │trigger├───►│ ├──────►│ d │
+ // └───────┘ │ B │ └───┘
+ // │ │ o1 ┌────┐ o1 ┌───┐
+ // │ ├──────►│ ├──────►│ x │
+ // │ │ │ B2 │ └─┬─┘
+ // │ │ │ ◄──(back)─┘
+ // │ │ └─┬──┘ o0
+ // │ │ ┌──▼───┐
+ // │ ◄──────┤ tail │ (back)
+ // └───┘ └──────┘
+ const graph = makeGraph(
+ [
+ { from: 'trigger', to: 'B' },
+ { from: 'B', to: 'B2', outputIndex: 1 },
+ { from: 'B2', to: 'x', outputIndex: 1 },
+ { from: 'x', to: 'B2', isBackEdge: true },
+ { from: 'B2', to: 'tail', outputIndex: 0 },
+ { from: 'tail', to: 'B', isBackEdge: true },
+ { from: 'B', to: 'd', outputIndex: 0 },
+ ],
+ { batch: ['B', 'B2'] },
+ );
+
+ expect(() => validateLoops(graph)).toThrow(/nested loops are not supported yet/);
+ });
+});
diff --git a/packages/@n8n/engine/src/graph/__tests__/validate-executable-graph.test.ts b/packages/@n8n/engine/src/graph/__tests__/validate-executable-graph.test.ts
index 299865e0565..8b2d7c1ee9b 100644
--- a/packages/@n8n/engine/src/graph/__tests__/validate-executable-graph.test.ts
+++ b/packages/@n8n/engine/src/graph/__tests__/validate-executable-graph.test.ts
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest';
import { UnimplementedError } from '../../common';
-import { GraphValidationError, validateExecutableGraph } from '../validate-executable-graph';
+import { GraphValidationError } from '../graph-validation.error';
+import { validateExecutableGraph } from '../validate-executable-graph';
import type { WorkflowGraph } from '../workflow-graph';
const validGraph: WorkflowGraph = {
diff --git a/packages/@n8n/engine/src/graph/graph-validation.error.ts b/packages/@n8n/engine/src/graph/graph-validation.error.ts
new file mode 100644
index 00000000000..c550d9351d2
--- /dev/null
+++ b/packages/@n8n/engine/src/graph/graph-validation.error.ts
@@ -0,0 +1,7 @@
+/** Thrown when a graph fails a structural rule and can never execute. */
+export class GraphValidationError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'GraphValidationError';
+ }
+}
diff --git a/packages/@n8n/engine/src/graph/index.ts b/packages/@n8n/engine/src/graph/index.ts
index d78f8d0dc05..bbd640baf4e 100644
--- a/packages/@n8n/engine/src/graph/index.ts
+++ b/packages/@n8n/engine/src/graph/index.ts
@@ -11,4 +11,7 @@ export {
getPredecessorNodeIds,
getSuccessorNodeIds,
} from './workflow-graph-queries';
-export { GraphValidationError, validateExecutableGraph } from './validate-executable-graph';
+export { GraphValidationError } from './graph-validation.error';
+export { MAX_SLOT_INDEX, validateExecutableGraph } from './validate-executable-graph';
+export { deriveLoops, validateLoops } from './loops';
+export type { WorkflowLoop } from './loops';
diff --git a/packages/@n8n/engine/src/graph/loops.ts b/packages/@n8n/engine/src/graph/loops.ts
new file mode 100644
index 00000000000..0dfc99e766a
--- /dev/null
+++ b/packages/@n8n/engine/src/graph/loops.ts
@@ -0,0 +1,267 @@
+import { UnimplementedError } from '../common';
+import { GraphValidationError } from './graph-validation.error';
+import type { GraphEdge, WorkflowGraph } from './workflow-graph';
+
+/** A loop, derived from the graph's marked back-edges. */
+export interface WorkflowLoop {
+ /** The node the back-edges return to: the loop's entry. */
+ batchNodeId: string;
+ /** Nodes on cycles through the entry (its strongly connected component). */
+ memberIds: Set;
+ /** Marked return edges into the entry. */
+ backEdges: GraphEdge[];
+ /** Forward edges into the entry from outside the loop. */
+ entryEdges: GraphEdge[];
+ /** Forward edges from a member to a node outside the loop. */
+ exitEdges: GraphEdge[];
+}
+
+/** One loop per back-edge target, members from the full graph's SCCs. */
+export function deriveLoops(graph: WorkflowGraph): WorkflowLoop[] {
+ const backEdgeTargets = [...new Set(graph.edges.filter((e) => e.isBackEdge).map((e) => e.to))];
+ if (backEdgeTargets.length === 0) return [];
+
+ const sccByNode = computeSccMembership(graph, graph.edges);
+
+ return backEdgeTargets.map((batchNodeId) => {
+ const memberIds = sccByNode.get(batchNodeId) ?? new Set([batchNodeId]);
+ return {
+ batchNodeId,
+ memberIds,
+ backEdges: graph.edges.filter((e) => e.isBackEdge && e.to === batchNodeId),
+ entryEdges: graph.edges.filter(
+ (e) => !e.isBackEdge && e.to === batchNodeId && !memberIds.has(e.from),
+ ),
+ exitEdges: graph.edges.filter(
+ (e) => !e.isBackEdge && memberIds.has(e.from) && !memberIds.has(e.to),
+ ),
+ };
+ });
+}
+
+/**
+ * Strongly connected components over `edges`, as a node -> members map. A node
+ * on no cycle maps to a singleton. Tarjan's algorithm.
+ */
+function computeSccMembership(graph: WorkflowGraph, edges: GraphEdge[]): Map> {
+ const outgoing = new Map();
+ for (const edge of edges) {
+ const list = outgoing.get(edge.from);
+ if (list) list.push(edge);
+ else outgoing.set(edge.from, [edge]);
+ }
+
+ const indexById = new Map();
+ const lowlinkById = new Map();
+ const stack: string[] = [];
+ const onStack = new Set();
+ const membership = new Map>();
+ let nextIndex = 0;
+
+ const connect = (nodeId: string): void => {
+ indexById.set(nodeId, nextIndex);
+ lowlinkById.set(nodeId, nextIndex);
+ nextIndex += 1;
+ stack.push(nodeId);
+ onStack.add(nodeId);
+
+ for (const edge of outgoing.get(nodeId) ?? []) {
+ if (!indexById.has(edge.to)) {
+ connect(edge.to);
+ lowlinkById.set(nodeId, Math.min(lowlinkById.get(nodeId)!, lowlinkById.get(edge.to)!));
+ } else if (onStack.has(edge.to)) {
+ lowlinkById.set(nodeId, Math.min(lowlinkById.get(nodeId)!, indexById.get(edge.to)!));
+ }
+ }
+
+ if (lowlinkById.get(nodeId) === indexById.get(nodeId)) {
+ const members = new Set();
+ let member: string;
+ do {
+ member = stack.pop()!;
+ onStack.delete(member);
+ members.add(member);
+ } while (member !== nodeId);
+ for (const id of members) membership.set(id, members);
+ }
+ };
+
+ for (const node of graph.nodes) {
+ if (!indexById.has(node.id)) connect(node.id);
+ }
+
+ return membership;
+}
+
+function isCyclic(members: Set, edges: GraphEdge[]): boolean {
+ if (members.size > 1) return true;
+ const [only] = members;
+ return edges.some((edge) => edge.from === only && edge.to === only);
+}
+
+/**
+ * Structural rules for loop shapes:
+ *
+ * 1. Remove the marked back-edges and what remains is acyclic. Termination and
+ * sequential iterations both rely on a loop body being a DAG.
+ * 2. A back-edge starts inside its loop and returns to the batch node's input
+ * slot 0.
+ * 3. A batch node has exactly one incoming back-edge. With none, the loop can
+ * never advance. With several, returns converge on one input slot.
+ * 4. Edges into a batch node feed input slot 0. Edges out of it leave output
+ * slot 0 (done) or output slot 1 (loop).
+ * 5. The batch node is the loop's only boundary. Nothing enters the body except
+ * through it, no loop nests inside it, the done slot feeds no member (a node
+ * cannot run both per iteration and after the loop), and the only edge
+ * leaving the member set is the one from that done slot.
+ */
+export function validateLoops(graph: WorkflowGraph): void {
+ const namesById = new Map(graph.nodes.map((node) => [node.id, node.name]));
+ const name = (id: string) => namesById.get(id) ?? id;
+
+ // Prerequisites for everything below: colliding IDs would make both
+ // component membership and `(nodeId, iteration)` step identity ambiguous,
+ // and an edge pointing at a missing node has no membership to test.
+ const seenNodeIds = new Set();
+ for (const node of graph.nodes) {
+ if (seenNodeIds.has(node.id)) {
+ throw new GraphValidationError(`Two nodes share the id ${node.id}`);
+ }
+ seenNodeIds.add(node.id);
+ }
+ for (const edge of graph.edges) {
+ for (const endpoint of [edge.from, edge.to]) {
+ if (!namesById.has(endpoint)) {
+ throw new GraphValidationError(
+ `Edge ${name(edge.from)} -> ${name(edge.to)} references ${endpoint}, which is not a node in the graph`,
+ );
+ }
+ }
+ }
+
+ // Rule 1: with the back-edges removed, no component may still be cyclic.
+ const forwardEdges = graph.edges.filter((edge) => !edge.isBackEdge);
+ for (const members of new Set(computeSccMembership(graph, forwardEdges).values())) {
+ if (isCyclic(members, forwardEdges)) {
+ throw new GraphValidationError(
+ `Nodes ${[...members].map(name).join(', ')} form a cycle with no back-edge to close it, so none of them can ever become runnable`,
+ );
+ }
+ }
+
+ const batchNodeIds = new Set(
+ graph.nodes.filter((node) => node.type === 'batch').map((node) => node.id),
+ );
+ const loops = deriveLoops(graph);
+ const loopsByBatchNode = new Map(loops.map((loop) => [loop.batchNodeId, loop]));
+
+ // Rule 3, the "none" case. Checked over batch nodes, not over loops: a
+ // batch node with no return edge heads no derived loop.
+ for (const batchNodeId of batchNodeIds) {
+ if (!loopsByBatchNode.has(batchNodeId)) {
+ throw new GraphValidationError(
+ `Batch node ${name(batchNodeId)} has no back-edge returning to it, so its loop could never advance`,
+ );
+ }
+ }
+
+ for (const loop of loops) {
+ const { batchNodeId, memberIds, backEdges } = loop;
+
+ // Rule 2's target, first because every rule below assumes the loop is
+ // headed by a batch node.
+ if (!batchNodeIds.has(batchNodeId)) {
+ throw new GraphValidationError(
+ `Node ${name(batchNodeId)} has a back-edge returning to it but is not a batch node`,
+ );
+ }
+
+ // Nesting, before the shape rules: two looping batch nodes land in one
+ // component, which those rules would read as a single malformed loop.
+ for (const memberId of memberIds) {
+ if (memberId !== batchNodeId && batchNodeIds.has(memberId)) {
+ throw new UnimplementedError(
+ `Batch node ${name(memberId)} sits inside the loop of ${name(batchNodeId)}; nested loops are not supported yet`,
+ );
+ }
+ }
+
+ // Rule 2's slot and direction: a return arrives on slot 0, from a member.
+ for (const edge of backEdges) {
+ if (edge.inputIndex !== 0) {
+ throw new GraphValidationError(
+ `Back-edge ${name(edge.from)} -> ${name(edge.to)} feeds input slot ${edge.inputIndex}; returns feed the batch node's slot 0`,
+ );
+ }
+ if (!memberIds.has(edge.from)) {
+ throw new GraphValidationError(
+ `Back-edge ${name(edge.from)} -> ${name(edge.to)} returns from outside the loop`,
+ );
+ }
+ }
+
+ // Rule 3, the "several" case: more than one return into that same slot 0.
+ if (backEdges.length > 1) {
+ throw new UnimplementedError(
+ `Batch node ${name(batchNodeId)} has ${backEdges.length} back-edges; multiple returns converge on one input slot, which is not supported yet`,
+ );
+ }
+
+ // Rule 4: the batch node's own slots, which hold whether or not the edge
+ // crosses the loop boundary, hence the scan over every edge.
+ for (const edge of graph.edges) {
+ if (edge.to === batchNodeId && !edge.isBackEdge && edge.inputIndex !== 0) {
+ throw new GraphValidationError(
+ `Edge ${name(edge.from)} -> ${name(edge.to)} feeds input slot ${edge.inputIndex} of a batch node, which has only slot 0`,
+ );
+ }
+ if (edge.from === batchNodeId && edge.outputIndex !== 0 && edge.outputIndex !== 1) {
+ throw new GraphValidationError(
+ `Edge ${name(edge.from)} -> ${name(edge.to)} leaves output slot ${edge.outputIndex} of a batch node, which has only done (0) and loop (1)`,
+ );
+ }
+ }
+
+ // A single entry edge, after rule 4 so that an impossible slot is reported
+ // as fatal rather than as an unsupported convergence.
+ if (loop.entryEdges.length > 1) {
+ throw new UnimplementedError(
+ `Batch node ${name(batchNodeId)} has ${loop.entryEdges.length} entry edges; converging entries on its input slot is not supported yet`,
+ );
+ }
+
+ // Rule 5, the way out: only the batch node's done slot leaves the loop.
+ for (const edge of loop.exitEdges) {
+ if (edge.from !== batchNodeId) {
+ throw new UnimplementedError(
+ `Edge ${name(edge.from)} -> ${name(edge.to)} leaves the loop of ${name(batchNodeId)} mid-body; dangling body branches are not supported yet`,
+ );
+ }
+ if (edge.outputIndex !== 0) {
+ throw new GraphValidationError(
+ `Edge ${name(edge.from)} -> ${name(edge.to)} leaves the loop from the loop slot; only the done slot (0) exits`,
+ );
+ }
+ }
+ for (const edge of graph.edges) {
+ // Rule 5, where the done slot may point. Marked edges included: a
+ // done-slot edge flagged as a return is the same defect.
+ if (edge.from === batchNodeId && edge.outputIndex === 0 && memberIds.has(edge.to)) {
+ throw new GraphValidationError(
+ `Done slot of ${name(batchNodeId)} feeds ${name(edge.to)}, a member of its own loop; a node cannot run both per iteration and after the loop`,
+ );
+ }
+ // Rule 5, the way in: the batch node is the only entrance to the body.
+ if (
+ !edge.isBackEdge &&
+ memberIds.has(edge.to) &&
+ edge.to !== batchNodeId &&
+ !memberIds.has(edge.from)
+ ) {
+ throw new GraphValidationError(
+ `Edge ${name(edge.from)} -> ${name(edge.to)} enters the loop of ${name(batchNodeId)} mid-body; the batch node is the only way in`,
+ );
+ }
+ }
+ }
+}
diff --git a/packages/@n8n/engine/src/graph/validate-executable-graph.ts b/packages/@n8n/engine/src/graph/validate-executable-graph.ts
index 3bf3b1a7bb9..a4ded99e54a 100644
--- a/packages/@n8n/engine/src/graph/validate-executable-graph.ts
+++ b/packages/@n8n/engine/src/graph/validate-executable-graph.ts
@@ -1,16 +1,9 @@
import { UnimplementedError } from '../common';
+import { GraphValidationError } from './graph-validation.error';
import type { WorkflowGraph } from './workflow-graph';
import { getDescendantNodeIds } from './workflow-graph-queries';
-const MAX_SLOT_INDEX = 100;
-
-/** Thrown when a graph fails a structural rule and can never execute. */
-export class GraphValidationError extends Error {
- constructor(message: string) {
- super(message);
- this.name = 'GraphValidationError';
- }
-}
+export const MAX_SLOT_INDEX = 100;
/**
* Asserts the graph is one the engine is willing to execute, before any state
@@ -40,7 +33,7 @@ export function validateExecutableGraph(graph: WorkflowGraph): void {
for (const edge of graph.edges) {
if (reachable.has(edge.to) && !reachable.has(edge.from)) {
throw new GraphValidationError(
- `Edge ${edge.from} → ${edge.to} feeds a node the trigger reaches from one it cannot reach, so ${edge.to} would wait on ${edge.from} forever`,
+ `Edge ${edge.from} -> ${edge.to} feeds a node the trigger reaches from one it cannot reach, so ${edge.to} would wait on ${edge.from} forever`,
);
}
}
@@ -51,12 +44,12 @@ export function validateExecutableGraph(graph: WorkflowGraph): void {
for (const index of [edge.outputIndex, edge.inputIndex]) {
if (!Number.isInteger(index) || index < 0) {
throw new GraphValidationError(
- `Edge ${edge.from} → ${edge.to} has slot index ${index}; slot indices are non-negative integers`,
+ `Edge ${edge.from} -> ${edge.to} has slot index ${index}; slot indices are non-negative integers`,
);
}
if (index > MAX_SLOT_INDEX) {
throw new GraphValidationError(
- `Edge ${edge.from} → ${edge.to} has slot index ${index}; slot indices above ${MAX_SLOT_INDEX} are not supported yet`,
+ `Edge ${edge.from} -> ${edge.to} has slot index ${index}; slot indices above ${MAX_SLOT_INDEX} are not supported yet`,
);
}
}
diff --git a/packages/@n8n/engine/src/index.ts b/packages/@n8n/engine/src/index.ts
index 0025bc5c4db..b373db0b53e 100644
--- a/packages/@n8n/engine/src/index.ts
+++ b/packages/@n8n/engine/src/index.ts
@@ -1,5 +1,5 @@
-export { createEngineServer } from './server';
-export type { EngineServerDeps } from './server';
+export { createEngineRuntime } from './runtime';
+export type { EngineRuntime, EngineRuntimeOptions } from './runtime';
export type { JsonObject, JsonValue } from './common';
@@ -26,7 +26,6 @@ export type {
AdmittanceService,
} from './admittance';
-export { InMemoryWorkQueue } from './queue';
export type {
ExecutionEnqueuedEvent,
OrchestrationMessage,
@@ -36,16 +35,7 @@ export type {
WorkQueue,
} from './queue';
-export {
- ExecutionNotFoundError,
- ExecutionStartHandler,
- OrchestrationWorker,
- StartExecutionService,
- StepNotFoundError,
- StepReadyHandler,
- StepSettledHandler,
- StepWorker,
-} from './execution';
+export { ExecutionNotFoundError, StepNotFoundError } from './execution';
export type {
ExecutionMode,
ExecutionRecord,
@@ -56,15 +46,14 @@ export type {
StartExecutionRequest,
StartExecutionResult,
StepError,
+ StepKey,
+ StepKeyId,
StepRecord,
StepSlots,
StepStatus,
StepStore,
+ TriggerOutputs,
} from './execution';
-export {
- createDataSource,
- createStores,
- WorkflowExecution,
- WorkflowStepExecution,
-} from './database';
+export { createDataSource, WorkflowExecution, WorkflowStepExecution } from './database';
+export type { EngineStores } from './database';
diff --git a/packages/@n8n/engine/src/runtime/__tests__/create-engine-runtime.test.ts b/packages/@n8n/engine/src/runtime/__tests__/create-engine-runtime.test.ts
new file mode 100644
index 00000000000..e9f16e43928
--- /dev/null
+++ b/packages/@n8n/engine/src/runtime/__tests__/create-engine-runtime.test.ts
@@ -0,0 +1,55 @@
+import type { DataSource } from '@n8n/typeorm';
+import request from 'supertest';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AllowAllAdmittance } from '../../admittance';
+import type { EngineStores } from '../../database';
+import { createEngineRuntime } from '../create-engine-runtime';
+
+/** Enough of a `DataSource` for the stores: they only hold on to a repository. */
+const fakeDataSource = () => ({ getRepository: vi.fn(() => ({})) }) as unknown as DataSource;
+
+const runtime = () =>
+ createEngineRuntime({
+ dataSource: fakeDataSource(),
+ admittance: new AllowAllAdmittance(),
+ });
+
+describe('createEngineRuntime', () => {
+ it('mounts the execution API', async () => {
+ // a rejected body proves the route is mounted without reaching the store
+ const response = await request(runtime().app).post('/api/workflow-executions').send({});
+
+ expect(response.status).toBe(400);
+ });
+
+ it('serves the healthcheck', async () => {
+ const response = await request(runtime().app).get('/healthz');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ status: 'ok' });
+ });
+
+ it('hands the engine stores to the external dependencies', () => {
+ let stores: EngineStores | undefined;
+
+ createEngineRuntime({
+ dataSource: fakeDataSource(),
+ admittance: new AllowAllAdmittance(),
+ externalDependencies: (given) => {
+ stores = given;
+ return {};
+ },
+ });
+
+ expect(stores?.executionStore).toBeDefined();
+ expect(stores?.stepStore).toBeDefined();
+ });
+
+ it('starts and stops both workers', async () => {
+ const engine = runtime();
+ engine.start();
+
+ await expect(engine.stop()).resolves.toBeUndefined();
+ });
+});
diff --git a/packages/@n8n/engine/src/runtime/create-engine-runtime.ts b/packages/@n8n/engine/src/runtime/create-engine-runtime.ts
new file mode 100644
index 00000000000..1f8bfa5d680
--- /dev/null
+++ b/packages/@n8n/engine/src/runtime/create-engine-runtime.ts
@@ -0,0 +1,97 @@
+import type { DataSource } from '@n8n/typeorm';
+import type { Application } from 'express';
+
+import type { AdmittanceService } from '../admittance';
+import { createStores } from '../database';
+import type { EngineStores } from '../database';
+import type { ExternalDependencies } from '../dependencies';
+import {
+ ExecutionStartHandler,
+ OrchestrationWorker,
+ StartExecutionService,
+ StepReadyHandler,
+ StepSettledHandler,
+ StepWorker,
+} from '../execution';
+import { InMemoryWorkQueue } from '../queue';
+import type { OrchestrationMessage, StepMessage } from '../queue';
+import { createEngineServer } from '../server';
+
+export interface EngineRuntimeOptions {
+ /** The data plane database, already initialized and migrated. */
+ dataSource: DataSource;
+ admittance: AdmittanceService;
+ /**
+ * Builds the capabilities the engine does not own. It receives the engine's
+ * stores, because a `v1-node` executor reads step data through them and the
+ * runtime owns them.
+ *
+ * Standalone mode omits it, so `v1-node` steps fail as unimplemented: the v1
+ * executor lives in `@n8n/node-engine-compatibility`, which depends on this
+ * package, so only an integrated host can supply it.
+ */
+ externalDependencies?: (stores: EngineStores) => ExternalDependencies;
+}
+
+/** A built engine, ready for a host to serve. */
+export interface EngineRuntime {
+ /** The engine HTTP app. The host decides where, and whether, to listen. */
+ app: Application;
+ /** Starts consuming the engine's queues. */
+ start(): void;
+ /** Stops the engine's workers. The host still owns its listener and its `DataSource`. */
+ stop(): Promise;
+}
+
+/**
+ * Builds a working engine from the adapters a host chooses.
+ *
+ * The engine keeps its internal topology here — which handler goes into which
+ * worker, which queue feeds which handler, and the start and stop order — so no
+ * host can get it wrong. A host keeps only its own choices: the database, the
+ * admittance policy, the external dependencies and the HTTP listener.
+ */
+export function createEngineRuntime({
+ dataSource,
+ admittance,
+ externalDependencies,
+}: EngineRuntimeOptions): EngineRuntime {
+ const orchestrationQueue = new InMemoryWorkQueue();
+ const stepQueue = new InMemoryWorkQueue();
+ const { executionStore, stepStore } = createStores(dataSource);
+
+ const orchestrationWorker = new OrchestrationWorker(
+ orchestrationQueue,
+ new ExecutionStartHandler(executionStore, stepStore, orchestrationQueue),
+ new StepSettledHandler(executionStore, stepStore, stepQueue, orchestrationQueue),
+ );
+ const stepWorker = new StepWorker(
+ stepQueue,
+ new StepReadyHandler(
+ executionStore,
+ stepStore,
+ orchestrationQueue,
+ externalDependencies?.({ executionStore, stepStore }) ?? {},
+ ),
+ );
+
+ const { app } = createEngineServer(
+ new StartExecutionService(admittance, executionStore, orchestrationQueue),
+ );
+
+ return {
+ app,
+
+ start: () => {
+ orchestrationWorker.start();
+ stepWorker.start();
+ },
+
+ stop: async () => {
+ // TODO(CAT-3882): drain in-flight work instead. Stopping a worker waits
+ // only for whatever it is mid-handling; anything queued behind it is
+ // dropped, since the in-memory queues die with the process.
+ await Promise.all([orchestrationWorker.stop(), stepWorker.stop()]);
+ },
+ };
+}
diff --git a/packages/@n8n/engine/src/runtime/index.ts b/packages/@n8n/engine/src/runtime/index.ts
new file mode 100644
index 00000000000..f57838b94bd
--- /dev/null
+++ b/packages/@n8n/engine/src/runtime/index.ts
@@ -0,0 +1,2 @@
+export { createEngineRuntime } from './create-engine-runtime';
+export type { EngineRuntime, EngineRuntimeOptions } from './create-engine-runtime';
diff --git a/packages/@n8n/engine/src/serve.ts b/packages/@n8n/engine/src/serve.ts
index d0b3efd0b7b..7875038726a 100644
--- a/packages/@n8n/engine/src/serve.ts
+++ b/packages/@n8n/engine/src/serve.ts
@@ -1,64 +1,27 @@
import { EngineConfig } from '@n8n/config';
import { Container } from '@n8n/di';
-import type { DataSource } from '@n8n/typeorm';
import { AllowAllAdmittance } from './admittance';
-import { createDataSource, createStores } from './database';
-import {
- ExecutionStartHandler,
- OrchestrationWorker,
- StepSettledHandler,
- StepReadyHandler,
- StepWorker,
-} from './execution';
-import { InMemoryWorkQueue } from './queue';
-import type { OrchestrationMessage, StepMessage } from './queue';
-import { createEngineServer } from './server';
+import { createDataSource } from './database';
+import { createEngineRuntime } from './runtime';
async function main(): Promise {
const config = Container.get(EngineConfig);
- let dataSource: DataSource | undefined;
- if (config.databaseUrl) {
- dataSource = createDataSource(config.databaseUrl);
- await dataSource.initialize();
- await dataSource.runMigrations();
- } else {
- console.warn(
- 'engine: N8N_ENGINE_DATABASE_URL not set; running in healthcheck-only mode (workflow execution endpoints disabled)',
- );
+ // Refused rather than degraded: an engine with nowhere to record an execution
+ // would report healthy while being unable to run one.
+ if (!config.databaseUrl) {
+ throw new Error('engine: N8N_ENGINE_DATABASE_URL is not set');
}
- const orchestrationQueue = new InMemoryWorkQueue();
- const stepQueue = new InMemoryWorkQueue();
+ const dataSource = createDataSource(config.databaseUrl);
+ await dataSource.initialize();
+ await dataSource.runMigrations();
- let orchestrationWorker: OrchestrationWorker | undefined;
- let stepWorker: StepWorker | undefined;
- if (dataSource) {
- const { executionStore, stepStore } = createStores(dataSource);
- orchestrationWorker = new OrchestrationWorker(
- orchestrationQueue,
- new ExecutionStartHandler(executionStore, stepStore, orchestrationQueue),
- new StepSettledHandler(executionStore, stepStore, stepQueue, orchestrationQueue),
- );
- // No executors here: the v1 one lives in `@n8n/node-engine-compatibility`,
- // which depends on this package, so only an integrated host can supply it.
- // `v1-node` steps therefore fail as unimplemented in standalone mode.
- stepWorker = new StepWorker(
- stepQueue,
- new StepReadyHandler(executionStore, stepStore, orchestrationQueue, {}),
- );
- orchestrationWorker.start();
- stepWorker.start();
- }
+ const runtime = createEngineRuntime({ dataSource, admittance: new AllowAllAdmittance() });
+ runtime.start();
- const { app } = createEngineServer(
- dataSource
- ? { dataSource, admittance: new AllowAllAdmittance(), workQueue: orchestrationQueue }
- : undefined,
- );
-
- const server = app.listen(config.port, config.host, () => {
+ const server = runtime.app.listen(config.port, config.host, () => {
console.log(`engine: listening on http://${config.host}:${config.port}`);
});
@@ -70,12 +33,8 @@ async function main(): Promise {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
- // TODO(CAT-3882): drain in-flight work instead. Stopping the workers waits
- // only for whatever each is mid-handling; anything queued behind it is
- // dropped, since the in-memory queues die with the process.
- if (orchestrationWorker) await orchestrationWorker.stop();
- if (stepWorker) await stepWorker.stop();
- if (dataSource?.isInitialized) await dataSource.destroy();
+ await runtime.stop();
+ if (dataSource.isInitialized) await dataSource.destroy();
process.exit(0);
};
diff --git a/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts b/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts
index 2f216d7761a..f31755f9a4e 100644
--- a/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts
+++ b/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts
@@ -5,7 +5,8 @@ import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AllowAllAdmittance } from '../../admittance';
-import { createDataSource, WorkflowExecution } from '../../database';
+import { createDataSource, createStores, WorkflowExecution } from '../../database';
+import { StartExecutionService } from '../../execution';
import type { WorkflowGraph } from '../../graph';
import type { OrchestrationMessage, WorkQueue } from '../../queue';
import { startEngineServer } from '../../testing/start-engine-server';
@@ -31,11 +32,10 @@ describe('POST /api/workflow-executions (integration)', () => {
beforeEach(async () => {
workQueue = { publish: vi.fn(), start: vi.fn(), stop: vi.fn() };
- ({ url, stop } = await startEngineServer({
- dataSource,
- admittance: new AllowAllAdmittance(),
- workQueue,
- }));
+ const { executionStore } = createStores(dataSource);
+ ({ url, stop } = await startEngineServer(
+ new StartExecutionService(new AllowAllAdmittance(), executionStore, workQueue),
+ ));
});
afterEach(async () => {
@@ -53,7 +53,7 @@ describe('POST /api/workflow-executions (integration)', () => {
.send({
workflowId: 'wf-1',
graph: sampleGraph,
- triggerPayload: { hello: 'world' },
+ triggerOutputs: [[{ json: { hello: 'world' } }]],
});
expect(response.status).toBe(201);
@@ -61,12 +61,14 @@ describe('POST /api/workflow-executions (integration)', () => {
expect(executionId).toBeTruthy();
const repo = dataSource.getRepository(WorkflowExecution);
- const row = await repo.findOneByOrFail({ id: executionId });
+ // `findOne({ where })`, not `findOneByOrFail`: the latter's overload exceeds
+ // TypeScript's instantiation depth on the recursive `triggerOutputs` column type.
+ const row = await repo.findOneOrFail({ where: { id: executionId } });
expect(row.workflowId).toBe('wf-1');
expect(row.status).toBe('queued');
expect(row.mode).toBe('production');
expect(row.graph).toEqual(sampleGraph);
- expect(row.triggerPayload).toEqual({ hello: 'world' });
+ expect(row.triggerOutputs).toEqual([[{ json: { hello: 'world' } }]]);
expect(workQueue.publish).toHaveBeenCalledWith({ type: 'execution:enqueued', executionId });
});
@@ -80,6 +82,52 @@ describe('POST /api/workflow-executions (integration)', () => {
expect((response.body as { error: string }).error).toBe('invalid_request');
});
+ it('rejects a bare-object triggerOutputs with 400 (the legacy, dropped shape)', async () => {
+ const response = await request(url)
+ .post('/api/workflow-executions')
+ .send({
+ workflowId: 'wf-1',
+ graph: sampleGraph,
+ triggerOutputs: { hello: 'world' },
+ });
+
+ expect(response.status).toBe(400);
+ expect((response.body as { error: string }).error).toBe('invalid_request');
+ });
+
+ it.each([['str'], [42]])('rejects triggerOutputs %p with 400', async (triggerOutputs) => {
+ const response = await request(url)
+ .post('/api/workflow-executions')
+ .send({ workflowId: 'wf-1', graph: sampleGraph, triggerOutputs });
+
+ expect(response.status).toBe(400);
+ expect((response.body as { error: string }).error).toBe('invalid_request');
+ });
+
+ it('rejects an empty-array triggerOutputs with 400 (send null or omit for "no payload")', async () => {
+ const response = await request(url).post('/api/workflow-executions').send({
+ workflowId: 'wf-1',
+ graph: sampleGraph,
+ triggerOutputs: [],
+ });
+
+ expect(response.status).toBe(400);
+ expect((response.body as { error: string }).error).toBe('invalid_request');
+ });
+
+ it('rejects a triggerOutputs with more slots than the cap with 400', async () => {
+ const response = await request(url)
+ .post('/api/workflow-executions')
+ .send({
+ workflowId: 'wf-1',
+ graph: sampleGraph,
+ triggerOutputs: Array.from({ length: 102 }, () => []),
+ });
+
+ expect(response.status).toBe(400);
+ expect((response.body as { error: string }).error).toBe('invalid_request');
+ });
+
it('rejects a graph without a trigger with 400, creating nothing', async () => {
const response = await request(url)
.post('/api/workflow-executions')
diff --git a/packages/@n8n/engine/src/server/create-engine-server.ts b/packages/@n8n/engine/src/server/create-engine-server.ts
index 0b82212f22b..3020cae8aa9 100644
--- a/packages/@n8n/engine/src/server/create-engine-server.ts
+++ b/packages/@n8n/engine/src/server/create-engine-server.ts
@@ -1,24 +1,10 @@
-import type { DataSource } from '@n8n/typeorm';
import express, { type Application } from 'express';
-import type { AdmittanceService } from '../admittance';
-import { TypeOrmExecutionStore, WorkflowExecution } from '../database';
-import { StartExecutionService } from '../execution/start-execution.service';
-import type { OrchestrationMessage, WorkQueue } from '../queue';
+import type { StartExecutionService } from '../execution/start-execution.service';
import { createWorkflowExecutionsRouter } from './routes/workflow-executions';
-export interface EngineServerDeps {
- dataSource: DataSource;
- admittance: AdmittanceService;
- workQueue: WorkQueue;
-}
-
-/**
- * Builds the engine HTTP app. Without `deps` it serves only `/healthz`; with
- * `deps` it also mounts the execution API. Deps are all-or-nothing so the API
- * can't be half-wired.
- */
-export function createEngineServer(deps?: EngineServerDeps): { app: Application } {
+/** Builds the engine HTTP app: `/healthz` plus the execution API. */
+export function createEngineServer(startExecution: StartExecutionService): { app: Application } {
const app = express();
app.use(express.json());
@@ -26,17 +12,7 @@ export function createEngineServer(deps?: EngineServerDeps): { app: Application
res.status(200).json({ status: 'ok' });
});
- if (deps) {
- const executionStore = new TypeOrmExecutionStore(
- deps.dataSource.getRepository(WorkflowExecution),
- );
- const startExecution = new StartExecutionService(
- deps.admittance,
- executionStore,
- deps.workQueue,
- );
- app.use('/api/workflow-executions', createWorkflowExecutionsRouter(startExecution));
- }
+ app.use('/api/workflow-executions', createWorkflowExecutionsRouter(startExecution));
return { app };
}
diff --git a/packages/@n8n/engine/src/server/index.ts b/packages/@n8n/engine/src/server/index.ts
index 734d722b1a6..b68b301130a 100644
--- a/packages/@n8n/engine/src/server/index.ts
+++ b/packages/@n8n/engine/src/server/index.ts
@@ -1,2 +1 @@
export { createEngineServer } from './create-engine-server';
-export type { EngineServerDeps } from './create-engine-server';
diff --git a/packages/@n8n/engine/src/server/routes/workflow-executions.ts b/packages/@n8n/engine/src/server/routes/workflow-executions.ts
index c384c9dbff4..df0da705998 100644
--- a/packages/@n8n/engine/src/server/routes/workflow-executions.ts
+++ b/packages/@n8n/engine/src/server/routes/workflow-executions.ts
@@ -4,7 +4,9 @@ import { z } from 'zod';
import { AdmittanceRejectedError } from '../../admittance';
import { UnimplementedError, type JsonValue } from '../../common';
import type { StartExecutionService } from '../../execution/start-execution.service';
-import { GraphValidationError } from '../../graph';
+import { GraphValidationError, MAX_SLOT_INDEX } from '../../graph';
+
+const MAX_TRIGGER_SLOTS = MAX_SLOT_INDEX + 1;
const jsonValueSchema: z.ZodType = z.lazy(() =>
z.union([
@@ -17,8 +19,6 @@ const jsonValueSchema: z.ZodType = z.lazy(() =>
]),
);
-const jsonObjectSchema = z.record(jsonValueSchema);
-
const StepTypeSchema = z.enum(['trigger', 'v1-node', 'wait', 'subworkflow', 'batch']);
const GraphNodeSchema = z.object({
@@ -46,7 +46,8 @@ const WorkflowGraphSchema = z.object({
const StartExecutionBody = z.object({
workflowId: z.string().min(1),
graph: WorkflowGraphSchema,
- triggerPayload: jsonObjectSchema.nullable().optional(),
+ /** Trigger output slots. Empty means "no payload" — send `null` or omit instead. */
+ triggerOutputs: z.array(jsonValueSchema).min(1).max(MAX_TRIGGER_SLOTS).nullable().optional(),
mode: z.enum(['production', 'manual']).optional(),
});
diff --git a/packages/@n8n/engine/src/testing/__tests__/start-engine-server.integration.test.ts b/packages/@n8n/engine/src/testing/__tests__/start-engine-server.integration.test.ts
index 2a00e01338e..9c6523ab104 100644
--- a/packages/@n8n/engine/src/testing/__tests__/start-engine-server.integration.test.ts
+++ b/packages/@n8n/engine/src/testing/__tests__/start-engine-server.integration.test.ts
@@ -1,6 +1,7 @@
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import type { StartExecutionService } from '../../execution';
import { startEngineServer } from '../start-engine-server';
describe('engine HTTP server (e2e)', () => {
@@ -8,7 +9,8 @@ describe('engine HTTP server (e2e)', () => {
let stop: () => Promise;
beforeAll(async () => {
- ({ url, stop } = await startEngineServer());
+ // only /healthz is under test, and the execution route never calls the service
+ ({ url, stop } = await startEngineServer({} as StartExecutionService));
});
afterAll(async () => {
diff --git a/packages/@n8n/engine/src/testing/start-engine-server.ts b/packages/@n8n/engine/src/testing/start-engine-server.ts
index 4cf1239a8a7..191d5528baa 100644
--- a/packages/@n8n/engine/src/testing/start-engine-server.ts
+++ b/packages/@n8n/engine/src/testing/start-engine-server.ts
@@ -1,12 +1,13 @@
import type { Server } from 'node:http';
-import { createEngineServer, type EngineServerDeps } from '../server';
+import type { StartExecutionService } from '../execution';
+import { createEngineServer } from '../server';
-export async function startEngineServer(deps?: EngineServerDeps): Promise<{
+export async function startEngineServer(startExecution: StartExecutionService): Promise<{
url: string;
stop: () => Promise