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/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); + } +}