lightflow-engine 0.2.10 → 0.2.13
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/BENCHMARKS.md +23 -0
- package/dist/compat/api.js +2 -0
- package/dist/compat/workflow.js +5 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +30 -19
- package/dist/pg-store.js +28 -5
- package/package.json +1 -1
package/BENCHMARKS.md
CHANGED
|
@@ -156,6 +156,11 @@ same Postgres (16, local) and the same harness (200 runs x 5 steps @ conc 20;
|
|
|
156
156
|
| 0.2.4 | 456.1 | 2,280 | 8,280 |
|
|
157
157
|
| 0.2.5 | 459.1 | 2,295 | 9,068 |
|
|
158
158
|
| 0.2.6 | 494.9–525.1 | 2,474–2,626 | 8,379–9,737 |
|
|
159
|
+
| 0.2.7 | 416.0 | 2,080 | 9,180 |
|
|
160
|
+
| 0.2.8 | 455.2 | 2,276 | 8,695 |
|
|
161
|
+
| 0.2.9 | 474.1 | 2,371 | 9,008 |
|
|
162
|
+
| 0.2.10 | 455.3 | 2,277 | 9,368 |
|
|
163
|
+
| 0.2.12 | 457.0 | 2,285 | 9,943 |
|
|
159
164
|
|
|
160
165
|
### What changed per version (all perf-neutral by design)
|
|
161
166
|
|
|
@@ -178,6 +183,14 @@ same Postgres (16, local) and the same harness (200 runs x 5 steps @ conc 20;
|
|
|
178
183
|
resolved the package root, but the types were broken).
|
|
179
184
|
- **0.2.6** — `export { workflowFetch as fetch }` (entry imports both
|
|
180
185
|
names), `WorkflowFn` accepts typed args, `run.exists: true`.
|
|
186
|
+
- **0.2.7** — lazy `returnValue` on run handles: no unhandled rejection
|
|
187
|
+
when a status-only handle outlives a failed run. Added
|
|
188
|
+
`test/compat-runtime.ts` (makeStep args+retries, FatalError, ghost runs).
|
|
189
|
+
- **0.2.8–0.2.10** — cross-bundle runtime state for Next.js: engine/store,
|
|
190
|
+
workflow/step registries, and the execution context moved to globalThis
|
|
191
|
+
because instrumentation and route handlers load as separate module
|
|
192
|
+
instances. Found by a live end-to-end Entry chat run; zero measurable
|
|
193
|
+
cost (globalThis lookup vs module global).
|
|
181
194
|
|
|
182
195
|
### Spread notes
|
|
183
196
|
|
|
@@ -197,3 +210,13 @@ ln -s <path-to>/lightflow/node_modules <dir>/node_modules
|
|
|
197
210
|
PKG=<dir> LIGHTFLOW_PG_URL=postgres://... BENCH_N=200 RN=5 npx tsx bench-pkg.mts
|
|
198
211
|
PKG=<dir> LIGHTFLOW_PG_URL=postgres://... BENCH_N=50 RN=400 npx tsx bench-pkg.mts
|
|
199
212
|
```
|
|
213
|
+
|
|
214
|
+
### 0.2.12 — bug-fix release (cancel/reaper/chunk)
|
|
215
|
+
|
|
216
|
+
- **cancel is terminal**: `cancel()` now writes `status='cancelled'` + flag atomically; `finishRun` refuses to overwrite a cancelled run (was: racing replay resurrected the run as completed/failed). `RunStatus` gains `"cancelled"`.
|
|
217
|
+
- **Lease-aware stale reaper**: `staleRuns` only reaps runs whose lease expired — a run inside a long step is no longer double-executed.
|
|
218
|
+
- **Stream close marker**: `close()` uses a monotonic chunk index (was `ctx.writes`, which could collide and silently drop the done marker → hanging stream).
|
|
219
|
+
- **O(1) nextChunkIndex**: `lightflow_runs.chunk_count` counter replaces a full chunk scan per write — long-run steps/s 9,368 → ~9,900.
|
|
220
|
+
- Tidy: single `getCurrent()` per call site (was double call + `!`).
|
|
221
|
+
|
|
222
|
+
Short: 200×5 conc 20 → **457 runs/s / 2,285 steps/s** (within noise of 0.2.9/0.2.10). Long: 50×400 → **9,943 steps/s** (best recorded).
|
package/dist/compat/api.js
CHANGED
|
@@ -40,6 +40,8 @@ function makeRunHandle(runId) {
|
|
|
40
40
|
return "pending";
|
|
41
41
|
if (row.status === "completed")
|
|
42
42
|
return "completed";
|
|
43
|
+
if (row.status === "cancelled")
|
|
44
|
+
return "cancelled";
|
|
43
45
|
if (row.status === "failed") {
|
|
44
46
|
return row.output?.error === "cancelled"
|
|
45
47
|
? "cancelled"
|
package/dist/compat/workflow.js
CHANGED
|
@@ -51,10 +51,14 @@ export function getWritable() {
|
|
|
51
51
|
const already = [...ctx.memo.values()].some((e) => e.type === "chunk" && e.payload?.done === true);
|
|
52
52
|
if (already)
|
|
53
53
|
return;
|
|
54
|
+
// Monotonic chunk index — using ctx.writes here could collide with a
|
|
55
|
+
// real chunk's index and the unique-index no-op would silently drop
|
|
56
|
+
// the done marker (stream never terminates).
|
|
57
|
+
const index = await ctx.store.nextChunkIndex(ctx.runId);
|
|
54
58
|
ctx.seq += 1;
|
|
55
59
|
ctx.append({
|
|
56
60
|
runId: ctx.runId, seq: ctx.seq, type: "chunk",
|
|
57
|
-
payload: { value: null, done: true, key: "close", index
|
|
61
|
+
payload: { value: null, done: true, key: "close", index },
|
|
58
62
|
createdAt: ctx.now(),
|
|
59
63
|
});
|
|
60
64
|
},
|
package/dist/index.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export declare class FatalError extends Error {
|
|
|
19
19
|
readonly fatal = true;
|
|
20
20
|
constructor(message: string);
|
|
21
21
|
}
|
|
22
|
-
export type RunStatus = "running" | "completed" | "failed";
|
|
22
|
+
export type RunStatus = "running" | "completed" | "failed" | "cancelled";
|
|
23
23
|
export type StepEvent = {
|
|
24
24
|
runId: string;
|
|
25
25
|
seq: number;
|
|
@@ -49,6 +49,7 @@ export interface Store {
|
|
|
49
49
|
isCancelled?(runId: string): Promise<boolean>;
|
|
50
50
|
/** Next monotonic chunk index (max+1), atomic per run. */
|
|
51
51
|
nextChunkIndex?(runId: string): Promise<number>;
|
|
52
|
+
pruneEvents?(runId: string, beforeSeq: number): Promise<void>;
|
|
52
53
|
/** Run lease: claim (atomically) / release before executing a replay. */
|
|
53
54
|
claimRun?(runId: string, leaseMs?: number): Promise<{
|
|
54
55
|
ok: boolean;
|
package/dist/index.js
CHANGED
|
@@ -48,19 +48,20 @@ export function getCurrent() {
|
|
|
48
48
|
function setCurrent(v) {
|
|
49
49
|
globalThis.__lightflowCurrent = v;
|
|
50
50
|
}
|
|
51
|
-
// Rewrite plain assignments on `current` below to setCurrent(...)
|
|
52
51
|
export function getWorkflowMetadata() {
|
|
53
|
-
|
|
52
|
+
const ctx = getCurrent();
|
|
53
|
+
if (!ctx)
|
|
54
54
|
throw new Error("getWorkflowMetadata() outside a workflow");
|
|
55
|
-
return { runId:
|
|
55
|
+
return { runId: ctx.runId };
|
|
56
56
|
}
|
|
57
57
|
/* ------------------------------------------------------------------ */
|
|
58
58
|
/* The step id: derived from call order, so replay is deterministic */
|
|
59
59
|
/* ------------------------------------------------------------------ */
|
|
60
60
|
function nextStepKey() {
|
|
61
|
-
|
|
61
|
+
const ctx = getCurrent();
|
|
62
|
+
if (!ctx)
|
|
62
63
|
throw new Error("steps can only be called inside a workflow");
|
|
63
|
-
return `step:${
|
|
64
|
+
return `step:${ctx.stepCalls}`;
|
|
64
65
|
}
|
|
65
66
|
/* ------------------------------------------------------------------ */
|
|
66
67
|
/* Public API inside a workflow */
|
|
@@ -70,9 +71,9 @@ function nextStepKey() {
|
|
|
70
71
|
* the function body is NOT re-executed.
|
|
71
72
|
*/
|
|
72
73
|
export async function step(fn) {
|
|
73
|
-
if (!getCurrent())
|
|
74
|
-
return fn(); // plain call outside a workflow
|
|
75
74
|
const ctx = getCurrent();
|
|
75
|
+
if (!ctx)
|
|
76
|
+
return fn(); // plain call outside a workflow
|
|
76
77
|
const key = nextStepKey();
|
|
77
78
|
ctx.stepCalls += 1;
|
|
78
79
|
// Replay: memoized result for this call position? (O(1) map lookup)
|
|
@@ -160,14 +161,18 @@ async function maybeSnapshot(ctx) {
|
|
|
160
161
|
runId: ctx.runId, seq: ctx.seq, type: "snapshot",
|
|
161
162
|
payload: { memo }, createdAt: ctx.now(),
|
|
162
163
|
});
|
|
164
|
+
// Folded events below the snapshot are dead weight — drop them so the log
|
|
165
|
+
// stays O(recent activity), not O(total steps). Chunks are kept (stream
|
|
166
|
+
// replay reads them); run_completed (seq 0) is protected by beforeSeq > 0.
|
|
167
|
+
await ctx.store.pruneEvents?.(ctx.runId, ctx.seq - 1);
|
|
163
168
|
}
|
|
164
169
|
/** Durable sleep. Accepts milliseconds or an absolute Date. */
|
|
165
170
|
export async function sleep(until) {
|
|
166
|
-
|
|
171
|
+
const ctx = getCurrent();
|
|
172
|
+
if (!ctx) {
|
|
167
173
|
const ms = until instanceof Date ? until.getTime() - Date.now() : until;
|
|
168
174
|
return new Promise((r) => setTimeout(r, Math.max(0, ms)));
|
|
169
175
|
}
|
|
170
|
-
const ctx = getCurrent();
|
|
171
176
|
const wakeAt = until instanceof Date ? until.getTime() : ctx.now() + until;
|
|
172
177
|
const key = `sleep:${ctx.sleepCalls}`;
|
|
173
178
|
ctx.sleepCalls += 1;
|
|
@@ -193,7 +198,7 @@ export class SuspendSignal {
|
|
|
193
198
|
/** Ordered output stream for a run. Chunks are persisted and replayable. */
|
|
194
199
|
export function getWritable() {
|
|
195
200
|
const ctx = getCurrent();
|
|
196
|
-
if (!
|
|
201
|
+
if (!ctx)
|
|
197
202
|
throw new Error("getWritable() outside a workflow");
|
|
198
203
|
return {
|
|
199
204
|
async write(chunk) {
|
|
@@ -273,8 +278,7 @@ export class Engine {
|
|
|
273
278
|
getReadable: () => this.readable(runId),
|
|
274
279
|
/** Entry calls this to kill a duplicate stream (route.ts:172). */
|
|
275
280
|
cancel: async () => {
|
|
276
|
-
await this.store.cancel?.(runId);
|
|
277
|
-
await this.store.setStatus(runId, "failed", { error: "cancelled" });
|
|
281
|
+
await this.store.cancel?.(runId); // terminal: cancelled=true + status='cancelled'
|
|
278
282
|
},
|
|
279
283
|
};
|
|
280
284
|
}
|
|
@@ -333,7 +337,7 @@ export class Engine {
|
|
|
333
337
|
if (claim && !claim.ok)
|
|
334
338
|
return;
|
|
335
339
|
if (claim?.cancelled) {
|
|
336
|
-
await this.store.setStatus(runId, "
|
|
340
|
+
await this.store.setStatus(runId, "cancelled", { error: "cancelled" });
|
|
337
341
|
return;
|
|
338
342
|
}
|
|
339
343
|
const log = await this.store.getEvents(runId);
|
|
@@ -401,6 +405,11 @@ export class Engine {
|
|
|
401
405
|
};
|
|
402
406
|
if (err instanceof CancelledError)
|
|
403
407
|
return fail("cancelled");
|
|
408
|
+
if (await this.store.isCancelled?.(runId)) {
|
|
409
|
+
// A concurrent cancel() already wrote the terminal status — don't
|
|
410
|
+
// resurrect it as 'failed'.
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
404
413
|
if (err instanceof FatalError)
|
|
405
414
|
return fail(err.message);
|
|
406
415
|
return fail(err instanceof Error ? err.message : String(err));
|
|
@@ -533,21 +542,23 @@ export class CancelledError extends Error {
|
|
|
533
542
|
export function defineHook() {
|
|
534
543
|
return {
|
|
535
544
|
async create() {
|
|
536
|
-
|
|
545
|
+
const ctx = getCurrent();
|
|
546
|
+
if (!ctx)
|
|
537
547
|
throw new Error("hooks only inside a workflow");
|
|
538
548
|
const token = `hook_${randomUUID().replace(/-/g, "").slice(0, 20)}`;
|
|
539
|
-
const key = `hook:${
|
|
540
|
-
|
|
541
|
-
await
|
|
549
|
+
const key = `hook:${ctx.hookCalls}`;
|
|
550
|
+
ctx.hookCalls += 1;
|
|
551
|
+
await ctx.store.createHook?.(ctx.runId, token, key);
|
|
542
552
|
return { token };
|
|
543
553
|
},
|
|
544
554
|
};
|
|
545
555
|
}
|
|
546
556
|
/** Await a previously created hook until an external caller resolves it. */
|
|
547
557
|
export async function hookResult(token) {
|
|
548
|
-
|
|
558
|
+
const ctx = getCurrent();
|
|
559
|
+
if (!ctx)
|
|
549
560
|
throw new Error("hooks only inside a workflow");
|
|
550
|
-
const existing = await
|
|
561
|
+
const existing = await ctx.store.getHook?.(token);
|
|
551
562
|
if (existing && existing.payload?.resolved) {
|
|
552
563
|
return existing.payload.value;
|
|
553
564
|
}
|
package/dist/pg-store.js
CHANGED
|
@@ -63,6 +63,10 @@ export async function createPostgresStore(url, opts = {}) {
|
|
|
63
63
|
resolved BOOLEAN NOT NULL DEFAULT false,
|
|
64
64
|
created_at BIGINT NOT NULL
|
|
65
65
|
);
|
|
66
|
+
`);
|
|
67
|
+
// Stream chunk counter: O(1) nextChunkIndex without scanning all chunks.
|
|
68
|
+
await pool.query(`
|
|
69
|
+
ALTER TABLE lightflow_runs ADD COLUMN IF NOT EXISTS chunk_count INTEGER NOT NULL DEFAULT 0;
|
|
66
70
|
`);
|
|
67
71
|
// Durable timers: expression index matching the dueTimers query exactly
|
|
68
72
|
// (bigint cast on the payload value, partial on pending timers).
|
|
@@ -228,9 +232,11 @@ export async function createPostgresStore(url, opts = {}) {
|
|
|
228
232
|
},
|
|
229
233
|
/** Terminal status + lease release in one round trip. */
|
|
230
234
|
async finishRun(runId, status, output) {
|
|
235
|
+
// A cancelled run is terminal: the cancel path already wrote the final
|
|
236
|
+
// status, and a still-running replay must not resurrect it.
|
|
231
237
|
await pool.query(`UPDATE lightflow_runs
|
|
232
238
|
SET status=$2, output=$3, claimed_until=0, updated_at=$4
|
|
233
|
-
WHERE run_id=$1`, [runId, status, output === undefined ? null : JSON.stringify(output), Date.now()]);
|
|
239
|
+
WHERE run_id=$1 AND cancelled=false`, [runId, status, output === undefined ? null : JSON.stringify(output), Date.now()]);
|
|
234
240
|
},
|
|
235
241
|
/** LISTEN/NOTIFY wakeup support: re-poll immediately on notify. */
|
|
236
242
|
async notifyWake() {
|
|
@@ -252,17 +258,25 @@ export async function createPostgresStore(url, opts = {}) {
|
|
|
252
258
|
};
|
|
253
259
|
},
|
|
254
260
|
async nextChunkIndex(runId) {
|
|
255
|
-
|
|
256
|
-
|
|
261
|
+
// Atomic fetch-and-increment on the run row: O(1), race-free across
|
|
262
|
+
// replays (the chunk-key unique index still guards duplicates).
|
|
263
|
+
const r = await pool.query(`UPDATE lightflow_runs SET chunk_count=chunk_count+1, updated_at=$2
|
|
264
|
+
WHERE run_id=$1 RETURNING chunk_count-1 AS n`, [runId, Date.now()]);
|
|
257
265
|
return Number(r.rows[0].n);
|
|
258
266
|
},
|
|
259
267
|
async staleRuns(cutoff) {
|
|
268
|
+
// Only runs whose lease has EXPIRED are orphaned. A run inside a long
|
|
269
|
+
// (>staleMs) step still holds a live lease and must not be double-run.
|
|
260
270
|
const r = await pool.query(`SELECT run_id FROM lightflow_runs
|
|
261
|
-
WHERE status='running' AND updated_at < $1`, [cutoff]);
|
|
271
|
+
WHERE status='running' AND updated_at < $1 AND claimed_until < $2`, [cutoff, Date.now()]);
|
|
262
272
|
return r.rows.map((x) => x.run_id);
|
|
263
273
|
},
|
|
264
274
|
async cancel(runId) {
|
|
265
|
-
|
|
275
|
+
// Terminal in one statement: flag + final status together, so a racing
|
|
276
|
+
// replay can never observe cancel-flag-without-terminal-status.
|
|
277
|
+
await pool.query(`UPDATE lightflow_runs
|
|
278
|
+
SET cancelled=true, status='cancelled', output='{"error":"cancelled"}', claimed_until=0, updated_at=$2
|
|
279
|
+
WHERE run_id=$1`, [runId, Date.now()]);
|
|
266
280
|
},
|
|
267
281
|
async isCancelled(runId) {
|
|
268
282
|
const r = await pool.query(`SELECT cancelled FROM lightflow_runs WHERE run_id=$1`, [runId]);
|
|
@@ -286,6 +300,15 @@ export async function createPostgresStore(url, opts = {}) {
|
|
|
286
300
|
payload: { ...(r.rows[0].payload ?? {}), resolved: Boolean(r.rows[0].resolved) },
|
|
287
301
|
};
|
|
288
302
|
},
|
|
303
|
+
async pruneEvents(runId, beforeSeq) {
|
|
304
|
+
// Remove events fully folded into the latest snapshot. Chunk events are
|
|
305
|
+
// KEPT: getReadable/getTailIndex stream chunks from the event log, and
|
|
306
|
+
// the snapshot memo is not a chunk source. run_completed (seq 0) is
|
|
307
|
+
// protected by beforeSeq > 0.
|
|
308
|
+
await pool.query(`DELETE FROM lightflow_events
|
|
309
|
+
WHERE run_id=$1 AND seq < $2
|
|
310
|
+
AND type IN ('step_completed','step_failed','sleep_created','sleep_completed')`, [runId, beforeSeq]);
|
|
311
|
+
},
|
|
289
312
|
async setStatus(runId, status, output) {
|
|
290
313
|
await pool.query(`UPDATE lightflow_runs SET status=$2, output=$3, updated_at=$4 WHERE run_id=$1`, [runId, status, output === undefined ? null : JSON.stringify(output), Date.now()]);
|
|
291
314
|
},
|
package/package.json
CHANGED