@kiberon-labs/behave-graph-suspendable 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/package.json +41 -0
- package/src/engine.ts +237 -0
- package/src/fiber.ts +162 -0
- package/src/index.ts +1 -0
- package/src/types.ts +67 -0
- package/tests/asyncNodeSuspension.test.ts +124 -0
- package/tests/coreNodeSuspension.test.ts +143 -0
- package/tests/fixtures/testNodes.ts +222 -0
- package/tests/loopSuspension.test.ts +137 -0
- package/tests/nestedLoopSuspension.test.ts +125 -0
- package/tests/nodes/suspender.ts +31 -0
- package/tests/testUtils.ts +37 -0
- package/tests/tsconfig.json +15 -0
- package/tests/variableSerialization.test.ts +96 -0
- package/tsconfig.json +55 -0
- package/tsdown.config.ts +13 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { GraphJSON } from '@kiberon-labs/behave-graph';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { makeRecorderDescription } from './fixtures/testNodes';
|
|
4
|
+
import { makeTestEngine } from './testUtils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Core flow nodes keep their cursors in node state (not closures), so the
|
|
8
|
+
* engine can capture them generically — no ISuspendable implementation needed.
|
|
9
|
+
* These tests run the unmodified core `flow/forLoop` and `flow/sequence`
|
|
10
|
+
* through a mid-execution suspension round-trip.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const COMPLETED = 999;
|
|
14
|
+
|
|
15
|
+
const forLoopGraph: GraphJSON = {
|
|
16
|
+
variables: [],
|
|
17
|
+
customEvents: [],
|
|
18
|
+
nodes: [
|
|
19
|
+
{
|
|
20
|
+
type: 'lifecycle/onStart',
|
|
21
|
+
id: '0',
|
|
22
|
+
flows: { flow: { nodeId: '1', socket: 'flow' } }
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
type: 'flow/forLoop',
|
|
26
|
+
id: '1',
|
|
27
|
+
parameters: {
|
|
28
|
+
startIndex: { value: 0 },
|
|
29
|
+
endIndex: { value: 5 }
|
|
30
|
+
},
|
|
31
|
+
flows: {
|
|
32
|
+
loopBody: { nodeId: '2', socket: 'flow' },
|
|
33
|
+
completed: { nodeId: '3', socket: 'flow' }
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: 'test/recorder',
|
|
38
|
+
id: '2',
|
|
39
|
+
parameters: { index: { link: { nodeId: '1', socket: 'index' } } }
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
type: 'test/recorder',
|
|
43
|
+
id: '3',
|
|
44
|
+
parameters: { index: { value: COMPLETED } }
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** flow/sequence with three outputs, each reporting a distinct marker. */
|
|
50
|
+
const sequenceGraph: GraphJSON = {
|
|
51
|
+
variables: [],
|
|
52
|
+
customEvents: [],
|
|
53
|
+
nodes: [
|
|
54
|
+
{
|
|
55
|
+
type: 'lifecycle/onStart',
|
|
56
|
+
id: '0',
|
|
57
|
+
flows: { flow: { nodeId: '1', socket: 'flow' } }
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
type: 'flow/sequence',
|
|
61
|
+
id: '1',
|
|
62
|
+
configuration: { numOutputs: 3 },
|
|
63
|
+
flows: {
|
|
64
|
+
1: { nodeId: '2', socket: 'flow' },
|
|
65
|
+
2: { nodeId: '3', socket: 'flow' },
|
|
66
|
+
3: { nodeId: '4', socket: 'flow' }
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
type: 'test/recorder',
|
|
71
|
+
id: '2',
|
|
72
|
+
parameters: { index: { value: 1 } }
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
type: 'test/recorder',
|
|
76
|
+
id: '3',
|
|
77
|
+
parameters: { index: { value: 2 } }
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
type: 'test/recorder',
|
|
81
|
+
id: '4',
|
|
82
|
+
parameters: { index: { value: 3 } }
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const makeEngineWithRecorder = (
|
|
88
|
+
graph: GraphJSON,
|
|
89
|
+
record: (index: number) => void
|
|
90
|
+
) =>
|
|
91
|
+
makeTestEngine(graph, { 'test/recorder': makeRecorderDescription(record) });
|
|
92
|
+
|
|
93
|
+
describe('core flow/forLoop across suspension', () => {
|
|
94
|
+
it('resumes the remaining iterations instead of restarting', async () => {
|
|
95
|
+
const firstRun: number[] = [];
|
|
96
|
+
const first = makeEngineWithRecorder(forLoopGraph, (i) => firstRun.push(i));
|
|
97
|
+
|
|
98
|
+
first.start();
|
|
99
|
+
await first.engine.executeAllSync(100, 4);
|
|
100
|
+
expect(firstRun).toEqual([0, 1, 2]);
|
|
101
|
+
|
|
102
|
+
const suspension = first.engine.suspend();
|
|
103
|
+
// the loop cursor is captured from node state, generically
|
|
104
|
+
expect(suspension.nodes['1']).toEqual({ nextIndex: 3 });
|
|
105
|
+
|
|
106
|
+
const persisted = JSON.parse(JSON.stringify(suspension));
|
|
107
|
+
|
|
108
|
+
const secondRun: number[] = [];
|
|
109
|
+
const second = makeEngineWithRecorder(forLoopGraph, (i) =>
|
|
110
|
+
secondRun.push(i)
|
|
111
|
+
);
|
|
112
|
+
second.engine.unsuspend(persisted, undefined);
|
|
113
|
+
await second.engine.executeAllSync();
|
|
114
|
+
|
|
115
|
+
expect(secondRun).toEqual([3, 4, COMPLETED]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('core flow/sequence across suspension', () => {
|
|
120
|
+
it('fires only the remaining outputs instead of restarting', async () => {
|
|
121
|
+
const firstRun: number[] = [];
|
|
122
|
+
const first = makeEngineWithRecorder(sequenceGraph, (i) =>
|
|
123
|
+
firstRun.push(i)
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
first.start();
|
|
127
|
+
// budget: sequence trigger + first output's recorder
|
|
128
|
+
await first.engine.executeAllSync(100, 2);
|
|
129
|
+
expect(firstRun).toEqual([1]);
|
|
130
|
+
|
|
131
|
+
const suspension = first.engine.suspend();
|
|
132
|
+
expect(suspension.nodes['1']).toEqual({ nextIndex: 1 });
|
|
133
|
+
|
|
134
|
+
const secondRun: number[] = [];
|
|
135
|
+
const second = makeEngineWithRecorder(sequenceGraph, (i) =>
|
|
136
|
+
secondRun.push(i)
|
|
137
|
+
);
|
|
138
|
+
second.engine.unsuspend(JSON.parse(JSON.stringify(suspension)), undefined);
|
|
139
|
+
await second.engine.executeAllSync();
|
|
140
|
+
|
|
141
|
+
expect(secondRun).toEqual([2, 3]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AsyncNode,
|
|
3
|
+
FlowNode,
|
|
4
|
+
NodeDescription,
|
|
5
|
+
Socket,
|
|
6
|
+
type Engine,
|
|
7
|
+
type Fiber,
|
|
8
|
+
type IGraph,
|
|
9
|
+
type NodeConfiguration
|
|
10
|
+
} from '@kiberon-labs/behave-graph';
|
|
11
|
+
import type { SuspendableEngine } from '../../src/engine';
|
|
12
|
+
import type { IAsyncSuspendable, ISuspendable } from '../../src/types';
|
|
13
|
+
|
|
14
|
+
export type SuspendedLoopState = {
|
|
15
|
+
nextIndex: number | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A for-loop flow node that can survive a suspension round-trip.
|
|
20
|
+
*
|
|
21
|
+
* Unlike the core `flow/forLoop` (which keeps its cursor in closures on the
|
|
22
|
+
* fiber's listener stack and therefore restarts when rehydrated), this node
|
|
23
|
+
* tracks the next iteration index on the instance. `suspend()` captures it and
|
|
24
|
+
* `hydrate()` restores it, so a re-trigger after unsuspension continues from
|
|
25
|
+
* where the loop left off instead of `startIndex`.
|
|
26
|
+
*/
|
|
27
|
+
export class SuspendableForLoop
|
|
28
|
+
extends FlowNode
|
|
29
|
+
implements ISuspendable<SuspendedLoopState>
|
|
30
|
+
{
|
|
31
|
+
public static Description = new NodeDescription(
|
|
32
|
+
'flow/suspendableForLoop',
|
|
33
|
+
'Flow',
|
|
34
|
+
'Suspendable For Loop',
|
|
35
|
+
(description, graph, config, id) =>
|
|
36
|
+
new SuspendableForLoop(description, graph, config, id)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
private nextIndex: number | null = null;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
description: NodeDescription,
|
|
43
|
+
graph: IGraph,
|
|
44
|
+
config: NodeConfiguration,
|
|
45
|
+
id: string
|
|
46
|
+
) {
|
|
47
|
+
super(
|
|
48
|
+
description,
|
|
49
|
+
graph,
|
|
50
|
+
[
|
|
51
|
+
new Socket('flow', 'flow'),
|
|
52
|
+
new Socket('integer', 'startIndex'),
|
|
53
|
+
new Socket('integer', 'endIndex')
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
new Socket('flow', 'loopBody'),
|
|
57
|
+
new Socket('integer', 'index'),
|
|
58
|
+
new Socket('flow', 'completed')
|
|
59
|
+
],
|
|
60
|
+
config,
|
|
61
|
+
id
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
suspend(): SuspendedLoopState {
|
|
66
|
+
return { nextIndex: this.nextIndex };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
hydrate(data: SuspendedLoopState): void {
|
|
70
|
+
this.nextIndex = data.nextIndex;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
override triggered(fiber: Fiber, _triggeringSocketName: string) {
|
|
74
|
+
const endIndex = Number(this.readInput('endIndex'));
|
|
75
|
+
|
|
76
|
+
// fresh run starts at startIndex; a hydrated run resumes mid-loop
|
|
77
|
+
if (this.nextIndex === null) {
|
|
78
|
+
this.nextIndex = Number(this.readInput('startIndex'));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const iterate = () => {
|
|
82
|
+
if (this.nextIndex !== null && this.nextIndex < endIndex) {
|
|
83
|
+
this.writeOutput('index', BigInt(this.nextIndex));
|
|
84
|
+
this.nextIndex++;
|
|
85
|
+
fiber.commit(this, 'loopBody', () => {
|
|
86
|
+
iterate();
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
this.nextIndex = null;
|
|
90
|
+
fiber.commit(this, 'completed');
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
iterate();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type SuspendedWaitState = {
|
|
98
|
+
waiting: boolean;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* An async node that parks the fiber on a pending promise until externally
|
|
103
|
+
* signalled — the asynchronous counterpart to a flow node. Unlike flow nodes
|
|
104
|
+
* (which resume by re-triggering with restored state), an async node is
|
|
105
|
+
* resumed through `fiber.continue()` -> `unsuspend(continuanceData)`, which
|
|
106
|
+
* delivers the awaited data and commits the downstream flow.
|
|
107
|
+
*
|
|
108
|
+
* After being signalled once (via unsuspension), subsequent triggers pass
|
|
109
|
+
* straight through so resumed graphs can run to completion.
|
|
110
|
+
*/
|
|
111
|
+
export class WaitForSignal
|
|
112
|
+
extends AsyncNode
|
|
113
|
+
implements IAsyncSuspendable<unknown, SuspendedWaitState>
|
|
114
|
+
{
|
|
115
|
+
public static Description = new NodeDescription(
|
|
116
|
+
'test/waitForSignal',
|
|
117
|
+
'Flow',
|
|
118
|
+
'Wait For Signal',
|
|
119
|
+
(description, graph, config, id) =>
|
|
120
|
+
new WaitForSignal(description, graph, config, id)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
private waiting = false;
|
|
124
|
+
private signalled = false;
|
|
125
|
+
private notifyParked: (() => void) | undefined;
|
|
126
|
+
|
|
127
|
+
/** Resolves when the node has parked the fiber. */
|
|
128
|
+
public readonly parked: Promise<void>;
|
|
129
|
+
|
|
130
|
+
constructor(
|
|
131
|
+
description: NodeDescription,
|
|
132
|
+
graph: IGraph,
|
|
133
|
+
config: NodeConfiguration,
|
|
134
|
+
id: string
|
|
135
|
+
) {
|
|
136
|
+
super(
|
|
137
|
+
description,
|
|
138
|
+
graph,
|
|
139
|
+
[new Socket('flow', 'flow')],
|
|
140
|
+
[new Socket('flow', 'flow'), new Socket('integer', 'value')],
|
|
141
|
+
config,
|
|
142
|
+
id
|
|
143
|
+
);
|
|
144
|
+
this.parked = new Promise((resolve) => {
|
|
145
|
+
this.notifyParked = resolve;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
override triggered(
|
|
150
|
+
engine: Engine,
|
|
151
|
+
_triggeringSocketName: string,
|
|
152
|
+
finished: () => void
|
|
153
|
+
) {
|
|
154
|
+
if (this.signalled) {
|
|
155
|
+
// already received its signal in a previous (suspended) life
|
|
156
|
+
this.writeOutput('value', BigInt(-1));
|
|
157
|
+
engine.commitToNewFiber(this, 'flow');
|
|
158
|
+
finished();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.waiting = true;
|
|
163
|
+
this.notifyParked?.();
|
|
164
|
+
// park the fiber until the engine is suspended (the promise never
|
|
165
|
+
// resolves; the suspension snapshot captures this node as the resume point)
|
|
166
|
+
return new Promise<void>(() => {});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
suspend(): SuspendedWaitState {
|
|
170
|
+
return { waiting: this.waiting };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
hydrate(data: SuspendedWaitState): void {
|
|
174
|
+
this.waiting = data.waiting;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async unsuspend(
|
|
178
|
+
data: unknown,
|
|
179
|
+
engine: SuspendableEngine,
|
|
180
|
+
_triggeringSocketName: string,
|
|
181
|
+
cb: () => void
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
this.waiting = false;
|
|
184
|
+
this.signalled = true;
|
|
185
|
+
this.writeOutput('value', BigInt(data as number));
|
|
186
|
+
engine.commitToNewFiber(this, 'flow');
|
|
187
|
+
cb();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
override dispose() {
|
|
191
|
+
this.waiting = false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A flow node that reports each visited iteration index to the test. The
|
|
197
|
+
* factory closes over the callback so each test gets an isolated recorder.
|
|
198
|
+
*/
|
|
199
|
+
export const makeRecorderDescription = (record: (index: number) => void) =>
|
|
200
|
+
new NodeDescription(
|
|
201
|
+
'test/recorder',
|
|
202
|
+
'Action',
|
|
203
|
+
'Recorder',
|
|
204
|
+
(description, graph, config, id) =>
|
|
205
|
+
new (class extends FlowNode {
|
|
206
|
+
constructor() {
|
|
207
|
+
super(
|
|
208
|
+
description,
|
|
209
|
+
graph,
|
|
210
|
+
[new Socket('flow', 'flow'), new Socket('integer', 'index')],
|
|
211
|
+
[new Socket('flow', 'flow')],
|
|
212
|
+
config,
|
|
213
|
+
id
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
override triggered(fiber: Fiber, _socketName: string) {
|
|
218
|
+
record(Number(this.readInput('index')));
|
|
219
|
+
fiber.commit(this, 'flow');
|
|
220
|
+
}
|
|
221
|
+
})()
|
|
222
|
+
);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { GraphJSON } from '@kiberon-labs/behave-graph';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
makeRecorderDescription,
|
|
5
|
+
SuspendableForLoop
|
|
6
|
+
} from './fixtures/testNodes';
|
|
7
|
+
import { makeTestEngine } from './testUtils';
|
|
8
|
+
|
|
9
|
+
/** Marker the completion recorder reports when the loop's `completed` fires. */
|
|
10
|
+
const COMPLETED = 999;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* onStart -> suspendable for-loop [0, 5).
|
|
14
|
+
* Loop body reports each index via `test/recorder`; the `completed` output
|
|
15
|
+
* reports the COMPLETED marker.
|
|
16
|
+
*/
|
|
17
|
+
const loopGraph: GraphJSON = {
|
|
18
|
+
variables: [],
|
|
19
|
+
customEvents: [],
|
|
20
|
+
nodes: [
|
|
21
|
+
{
|
|
22
|
+
type: 'lifecycle/onStart',
|
|
23
|
+
id: '0',
|
|
24
|
+
flows: { flow: { nodeId: '1', socket: 'flow' } }
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
type: 'flow/suspendableForLoop',
|
|
28
|
+
id: '1',
|
|
29
|
+
parameters: {
|
|
30
|
+
startIndex: { value: 0 },
|
|
31
|
+
endIndex: { value: 5 }
|
|
32
|
+
},
|
|
33
|
+
flows: {
|
|
34
|
+
loopBody: { nodeId: '2', socket: 'flow' },
|
|
35
|
+
completed: { nodeId: '3', socket: 'flow' }
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: 'test/recorder',
|
|
40
|
+
id: '2',
|
|
41
|
+
parameters: {
|
|
42
|
+
index: { link: { nodeId: '1', socket: 'index' } }
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: 'test/recorder',
|
|
47
|
+
id: '3',
|
|
48
|
+
parameters: {
|
|
49
|
+
index: { value: COMPLETED }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const makeLoopEngine = (record: (index: number) => void) =>
|
|
56
|
+
makeTestEngine(loopGraph, {
|
|
57
|
+
[SuspendableForLoop.Description.typeName]: SuspendableForLoop.Description,
|
|
58
|
+
'test/recorder': makeRecorderDescription(record)
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('suspending mid-loop', () => {
|
|
62
|
+
it('runs the loop to completion when never suspended', async () => {
|
|
63
|
+
const recorded: number[] = [];
|
|
64
|
+
const { engine, start } = makeLoopEngine((i) => recorded.push(i));
|
|
65
|
+
|
|
66
|
+
start();
|
|
67
|
+
await engine.executeAllSync();
|
|
68
|
+
|
|
69
|
+
expect(recorded).toEqual([0, 1, 2, 3, 4, COMPLETED]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('completes only the remaining iterations after unsuspending, instead of restarting', async () => {
|
|
73
|
+
// --- first engine: run the loop partway, then suspend -----------------
|
|
74
|
+
const firstRun: number[] = [];
|
|
75
|
+
const { engine, start } = makeLoopEngine((i) => firstRun.push(i));
|
|
76
|
+
|
|
77
|
+
start();
|
|
78
|
+
// Step budget chosen to stop execution mid-loop: the loop trigger plus the
|
|
79
|
+
// first three body executions consume the budget, leaving iterations 3 and
|
|
80
|
+
// 4 (and the completion) outstanding on the fiber.
|
|
81
|
+
await engine.executeAllSync(100, 4);
|
|
82
|
+
expect(firstRun).toEqual([0, 1, 2]);
|
|
83
|
+
expect(engine.hasPending()).toBe(true);
|
|
84
|
+
|
|
85
|
+
const suspension = engine.suspend();
|
|
86
|
+
|
|
87
|
+
// the loop's cursor must be captured in the suspension...
|
|
88
|
+
expect(suspension.nodes['1']).toEqual({ nextIndex: 3 });
|
|
89
|
+
// ...along with the pending fiber pointing back at the loop node
|
|
90
|
+
expect(suspension.fiberQueue).toHaveLength(1);
|
|
91
|
+
expect(suspension.fiberQueue[0]!.queue).toEqual(['1']);
|
|
92
|
+
|
|
93
|
+
// serialization must survive a stringify round-trip (e.g. disk persistence)
|
|
94
|
+
const persisted = JSON.parse(JSON.stringify(suspension));
|
|
95
|
+
|
|
96
|
+
// --- second engine: rehydrate on a fresh graph and finish -------------
|
|
97
|
+
const secondRun: number[] = [];
|
|
98
|
+
const { engine: resumed } = makeLoopEngine((i) => secondRun.push(i));
|
|
99
|
+
|
|
100
|
+
resumed.unsuspend(persisted, undefined);
|
|
101
|
+
await resumed.executeAllSync();
|
|
102
|
+
|
|
103
|
+
// only the remaining iterations execute — the loop does not restart at 0
|
|
104
|
+
expect(secondRun).toEqual([3, 4, COMPLETED]);
|
|
105
|
+
|
|
106
|
+
// and across both runs every iteration executed exactly once
|
|
107
|
+
expect([...firstRun, ...secondRun.slice(0, -1)]).toEqual([0, 1, 2, 3, 4]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('can immediately re-suspend after resuming', async () => {
|
|
111
|
+
const firstRun: number[] = [];
|
|
112
|
+
const first = makeLoopEngine((i) => firstRun.push(i));
|
|
113
|
+
first.start();
|
|
114
|
+
await first.engine.executeAllSync(100, 2);
|
|
115
|
+
expect(firstRun).toEqual([0]);
|
|
116
|
+
|
|
117
|
+
const suspension = first.engine.suspend();
|
|
118
|
+
expect(suspension.nodes['1']).toEqual({ nextIndex: 1 });
|
|
119
|
+
|
|
120
|
+
// resume, run one more iteration, suspend again
|
|
121
|
+
const secondRun: number[] = [];
|
|
122
|
+
const second = makeLoopEngine((i) => secondRun.push(i));
|
|
123
|
+
second.engine.unsuspend(suspension, undefined);
|
|
124
|
+
await second.engine.executeAllSync(100, 2);
|
|
125
|
+
expect(secondRun).toEqual([1]);
|
|
126
|
+
|
|
127
|
+
const secondSuspension = second.engine.suspend();
|
|
128
|
+
expect(secondSuspension.nodes['1']).toEqual({ nextIndex: 2 });
|
|
129
|
+
|
|
130
|
+
// final resume runs the loop to the end
|
|
131
|
+
const thirdRun: number[] = [];
|
|
132
|
+
const third = makeLoopEngine((i) => thirdRun.push(i));
|
|
133
|
+
third.engine.unsuspend(secondSuspension, undefined);
|
|
134
|
+
await third.engine.executeAllSync();
|
|
135
|
+
expect(thirdRun).toEqual([2, 3, 4, COMPLETED]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { GraphJSON } from '@kiberon-labs/behave-graph';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { makeRecorderDescription } from './fixtures/testNodes';
|
|
4
|
+
import { makeTestEngine } from './testUtils';
|
|
5
|
+
|
|
6
|
+
const COMPLETED = 999;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* onStart -> outer forLoop [0,3) -> inner forLoop [0,3) -> recorder.
|
|
10
|
+
* The recorder receives outer*10 + inner via math nodes, so each recorded
|
|
11
|
+
* value identifies exactly which (outer, inner) iteration ran.
|
|
12
|
+
*/
|
|
13
|
+
const nestedLoopGraph: GraphJSON = {
|
|
14
|
+
variables: [],
|
|
15
|
+
customEvents: [],
|
|
16
|
+
nodes: [
|
|
17
|
+
{
|
|
18
|
+
type: 'lifecycle/onStart',
|
|
19
|
+
id: '0',
|
|
20
|
+
flows: { flow: { nodeId: 'outer', socket: 'flow' } }
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
type: 'flow/forLoop',
|
|
24
|
+
id: 'outer',
|
|
25
|
+
parameters: {
|
|
26
|
+
startIndex: { value: 0 },
|
|
27
|
+
endIndex: { value: 3 }
|
|
28
|
+
},
|
|
29
|
+
flows: {
|
|
30
|
+
loopBody: { nodeId: 'inner', socket: 'flow' },
|
|
31
|
+
completed: { nodeId: 'done', socket: 'flow' }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
type: 'flow/forLoop',
|
|
36
|
+
id: 'inner',
|
|
37
|
+
parameters: {
|
|
38
|
+
startIndex: { value: 0 },
|
|
39
|
+
endIndex: { value: 3 }
|
|
40
|
+
},
|
|
41
|
+
flows: {
|
|
42
|
+
loopBody: { nodeId: 'rec', socket: 'flow' }
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: 'math/multiply/integer',
|
|
47
|
+
id: 'mul',
|
|
48
|
+
parameters: {
|
|
49
|
+
a: { link: { nodeId: 'outer', socket: 'index' } },
|
|
50
|
+
b: { value: 10 }
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: 'math/add/integer',
|
|
55
|
+
id: 'add',
|
|
56
|
+
parameters: {
|
|
57
|
+
a: { link: { nodeId: 'mul', socket: 'result' } },
|
|
58
|
+
b: { link: { nodeId: 'inner', socket: 'index' } }
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: 'test/recorder',
|
|
63
|
+
id: 'rec',
|
|
64
|
+
parameters: { index: { link: { nodeId: 'add', socket: 'result' } } }
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
type: 'test/recorder',
|
|
68
|
+
id: 'done',
|
|
69
|
+
parameters: { index: { value: COMPLETED } }
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const ALL_ITERATIONS = [0, 1, 2, 10, 11, 12, 20, 21, 22];
|
|
75
|
+
|
|
76
|
+
const makeNestedEngine = (record: (index: number) => void) =>
|
|
77
|
+
makeTestEngine(nestedLoopGraph, {
|
|
78
|
+
'test/recorder': makeRecorderDescription(record)
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('nested loops across suspension', () => {
|
|
82
|
+
it('runs all inner iterations for every outer iteration when not suspended', async () => {
|
|
83
|
+
const recorded: number[] = [];
|
|
84
|
+
const { engine, start } = makeNestedEngine((i) => recorded.push(i));
|
|
85
|
+
|
|
86
|
+
start();
|
|
87
|
+
await engine.executeAllSync();
|
|
88
|
+
|
|
89
|
+
expect(recorded).toEqual([...ALL_ITERATIONS, COMPLETED]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('resumes mid-inner-loop: finishes the inner loop, then the remaining outer iterations', async () => {
|
|
93
|
+
const firstRun: number[] = [];
|
|
94
|
+
const first = makeNestedEngine((i) => firstRun.push(i));
|
|
95
|
+
|
|
96
|
+
first.start();
|
|
97
|
+
// run until partway through the inner loop of outer iteration 1
|
|
98
|
+
let steps = 0;
|
|
99
|
+
while (firstRun.length < 5 && first.engine.hasPending() && steps < 100) {
|
|
100
|
+
await first.engine.executeAllSync(100, 1);
|
|
101
|
+
steps++;
|
|
102
|
+
}
|
|
103
|
+
expect(firstRun).toEqual([0, 1, 2, 10, 11]);
|
|
104
|
+
|
|
105
|
+
const suspension = first.engine.suspend();
|
|
106
|
+
|
|
107
|
+
// both cursors captured independently: outer is mid-iteration 1 (next: 2),
|
|
108
|
+
// inner has fired 10 and 11 (next: 2)
|
|
109
|
+
expect(suspension.nodes['outer']).toEqual({ nextIndex: 2 });
|
|
110
|
+
expect(suspension.nodes['inner']).toEqual({ nextIndex: 2 });
|
|
111
|
+
|
|
112
|
+
const persisted = JSON.parse(JSON.stringify(suspension));
|
|
113
|
+
|
|
114
|
+
const secondRun: number[] = [];
|
|
115
|
+
const second = makeNestedEngine((i) => secondRun.push(i));
|
|
116
|
+
second.engine.unsuspend(persisted, undefined);
|
|
117
|
+
await second.engine.executeAllSync();
|
|
118
|
+
|
|
119
|
+
// the inner loop finishes its remaining iteration (12) BEFORE the outer
|
|
120
|
+
// loop advances — and neither loop restarts
|
|
121
|
+
expect(secondRun).toEqual([12, 20, 21, 22, COMPLETED]);
|
|
122
|
+
|
|
123
|
+
expect([...firstRun, ...secondRun.slice(0, -1)]).toEqual(ALL_ITERATIONS);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AsyncNode,
|
|
3
|
+
NodeDescription,
|
|
4
|
+
NodeDescription2,
|
|
5
|
+
Socket,
|
|
6
|
+
type IGraph
|
|
7
|
+
} from '@kiberon-labs/behave-graph';
|
|
8
|
+
import type { IAsyncSuspendable } from '~/types';
|
|
9
|
+
|
|
10
|
+
export class Suspender extends AsyncNode implements IAsyncSuspendable {
|
|
11
|
+
public static Description = new NodeDescription2({
|
|
12
|
+
typeName: 'Suspender',
|
|
13
|
+
category: 'Testing',
|
|
14
|
+
factory: (description, graph) => new Suspender(description, graph)
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
constructor(description: NodeDescription, graph: IGraph) {
|
|
18
|
+
super(
|
|
19
|
+
description,
|
|
20
|
+
graph,
|
|
21
|
+
[new Socket('flow', 'flow')],
|
|
22
|
+
[new Socket('flow', 'flow')]
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
suspend() {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
async unsuspend(): Promise<void> {}
|
|
30
|
+
hydrate(): void {}
|
|
31
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ManualLifecycleEventEmitter,
|
|
3
|
+
readGraphFromJSON,
|
|
4
|
+
registerCoreProfile,
|
|
5
|
+
type GraphJSON,
|
|
6
|
+
type IRegistry
|
|
7
|
+
} from '@kiberon-labs/behave-graph';
|
|
8
|
+
import { SuspendableEngine } from '../src/engine';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Builds an isolated engine over the given graph JSON. Each call produces a
|
|
12
|
+
* fresh registry, lifecycle emitter, graph and engine so that suspension
|
|
13
|
+
* round-trips can be tested across truly independent engine instances (as if
|
|
14
|
+
* resuming in a new process).
|
|
15
|
+
*/
|
|
16
|
+
export const makeTestEngine = (
|
|
17
|
+
graphJson: GraphJSON,
|
|
18
|
+
extraNodes: Record<string, unknown> = {}
|
|
19
|
+
) => {
|
|
20
|
+
const lifecycle = new ManualLifecycleEventEmitter();
|
|
21
|
+
const registry: IRegistry = registerCoreProfile({
|
|
22
|
+
values: {},
|
|
23
|
+
nodes: {},
|
|
24
|
+
dependencies: {
|
|
25
|
+
ILifecycleEventEmitter: lifecycle
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
Object.assign(registry.nodes, extraNodes);
|
|
29
|
+
|
|
30
|
+
const graph = readGraphFromJSON({ graphJson, registry });
|
|
31
|
+
const engine = new SuspendableEngine(graph, registry);
|
|
32
|
+
|
|
33
|
+
/** Emits the lifecycle start event, queueing the onStart fiber. */
|
|
34
|
+
const start = () => lifecycle.startEvent.emit();
|
|
35
|
+
|
|
36
|
+
return { engine, graph, registry, lifecycle, start };
|
|
37
|
+
};
|