mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
feat(engine): Map iterations across loop edges (no-changelog) (#36524)
This commit is contained in:
@@ -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<Partial<GraphEdge> & Pick<GraphEdge, 'from' | 'to'>>,
|
||||
{ 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user