lightflow-engine 0.1.1 → 0.1.3
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 +4 -35
- package/dist/src/index.js +127 -34
- package/dist/src/pg-store.js +59 -5
- package/dist/test/bench-long.mjs +24 -0
- package/dist/test/bench.js +2 -5
- package/dist/test/compact-check.mjs +27 -0
- package/dist/test/crash-snap.mjs +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -151,42 +151,11 @@ other engines and designed out here.
|
|
|
151
151
|
|
|
152
152
|
## Benchmarks
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
against a local Postgres 16, no network hop. Run it yourself:
|
|
154
|
+
Full before/after numbers for every version live in [BENCHMARKS.md](BENCHMARKS.md).
|
|
156
155
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
### v0.1.0 → v0.1.1 (before / after)
|
|
163
|
-
|
|
164
|
-
Same machine, same workload (200 runs × 5 steps, 20 in flight):
|
|
165
|
-
|
|
166
|
-
| | v0.1.0 | v0.1.1 | |
|
|
167
|
-
|---|---|---|---|
|
|
168
|
-
| Runs / sec | 184 | 456–556 | **~2.7× faster** |
|
|
169
|
-
| Steps / sec | 922 | ~2,300–2,800 | **~2.7× faster** |
|
|
170
|
-
| SQL statements per event | 5 (BEGIN, advisory lock, max(seq), INSERT, COMMIT) | 1 (single INSERT … ON CONFLICT) | −80% |
|
|
171
|
-
| Advisory lock in hot path | every event, 7.8ms avg | removed | — |
|
|
172
|
-
| Racing resumes double-executing a step | possible (latent) | impossible (run lease) | fixed |
|
|
173
|
-
|
|
174
|
-
Sustained load, v0.1.1 (500 runs × 10 steps, 100 in flight):
|
|
175
|
-
**445 runs/sec, 4,450 steps/sec.**
|
|
176
|
-
|
|
177
|
-
What changed in v0.1.1, and why it matters:
|
|
178
|
-
|
|
179
|
-
- **Single-statement event append.** The old path took an advisory lock and
|
|
180
|
-
scanned `max(seq)` per event; profiling showed the lock averaging 7.8ms —
|
|
181
|
-
40× the cost of any other query. The new path relies on the `(run_id, seq)`
|
|
182
|
-
unique constraint and a single upsert.
|
|
183
|
-
- **Run lease.** Concurrent `resume()` calls for the same run are arbitrated
|
|
184
|
-
by an atomic `claimed_until` column, so two replays can never execute the
|
|
185
|
-
same step at once (verified by `test/race.ts`: 20 racing resumes × 10 runs,
|
|
186
|
-
zero double-executions).
|
|
187
|
-
- **No `step_started` writes** (replay never reads them), stream writes use
|
|
188
|
-
the in-memory replay log instead of re-fetching the event log, `createRun`
|
|
189
|
-
is one round trip, and `getRun().returnValue` polls adaptively (5ms → 250ms).
|
|
156
|
+
Headline: **184 → ~500 runs/sec (~2.7×)** on the standard workload, with
|
|
157
|
+
single-statement event appends, O(1) replay lookups, a run lease that makes
|
|
158
|
+
racing replays safe, and LISTEN/NOTIFY-assisted timer wake-up.
|
|
190
159
|
|
|
191
160
|
## Design decisions
|
|
192
161
|
|
package/dist/src/index.js
CHANGED
|
@@ -61,8 +61,8 @@ export async function step(fn) {
|
|
|
61
61
|
const ctx = current;
|
|
62
62
|
const key = nextStepKey();
|
|
63
63
|
ctx.stepCalls += 1;
|
|
64
|
-
// Replay: memoized result for this call position?
|
|
65
|
-
const done = ctx.
|
|
64
|
+
// Replay: memoized result for this call position? (O(1) map lookup)
|
|
65
|
+
const done = ctx.memo.get(`step_completed:${key}`);
|
|
66
66
|
if (done)
|
|
67
67
|
return done.payload.value;
|
|
68
68
|
// Skip the step_started write entirely on the normal path: replay only
|
|
@@ -103,12 +103,49 @@ export async function step(fn) {
|
|
|
103
103
|
throw lastErr;
|
|
104
104
|
}
|
|
105
105
|
ctx.seq += 1;
|
|
106
|
+
ctx.freshResults.set(key, value);
|
|
106
107
|
await ctx.store.appendEvent({
|
|
107
108
|
runId: ctx.runId, seq: ctx.seq, type: "step_completed",
|
|
108
109
|
payload: { key, value }, createdAt: ctx.now(),
|
|
109
110
|
});
|
|
111
|
+
await maybeSnapshot(ctx);
|
|
110
112
|
return value;
|
|
111
113
|
}
|
|
114
|
+
const SNAPSHOT_INTERVAL = 200;
|
|
115
|
+
const SNAPSHOT_MIN_EVENTS = 100;
|
|
116
|
+
/**
|
|
117
|
+
* History compaction: every SNAPSHOT_INTERVAL completed steps, fold the
|
|
118
|
+
* memoized step results (and completed sleeps) into a single 'snapshot'
|
|
119
|
+
* event. getEvents() replays from the latest snapshot, so old events stop
|
|
120
|
+
* costing replay time and can be pruned later without breaking resumes.
|
|
121
|
+
*/
|
|
122
|
+
async function maybeSnapshot(ctx) {
|
|
123
|
+
ctx.completedNow += 1;
|
|
124
|
+
const done = ctx.completedNow +
|
|
125
|
+
[...ctx.memo.values()].filter((e) => e.type === "step_completed").length;
|
|
126
|
+
if (done < SNAPSHOT_MIN_EVENTS || done % SNAPSHOT_INTERVAL !== 0)
|
|
127
|
+
return;
|
|
128
|
+
const memo = {};
|
|
129
|
+
for (const e of ctx.memo.values()) {
|
|
130
|
+
const key = e.payload.key;
|
|
131
|
+
if (key === undefined)
|
|
132
|
+
continue;
|
|
133
|
+
if (e.type === "step_completed")
|
|
134
|
+
memo[`step_completed:${key}`] = e.payload.value;
|
|
135
|
+
else if (e.type === "sleep_completed")
|
|
136
|
+
memo[`sleep_completed:${key}`] = true;
|
|
137
|
+
else if (e.type === "chunk")
|
|
138
|
+
memo[`chunk:${key}`] = e.payload;
|
|
139
|
+
}
|
|
140
|
+
for (const [key, value] of ctx.freshResults)
|
|
141
|
+
memo[`step_completed:${key}`] = value;
|
|
142
|
+
// include the step we just completed (not yet in ctx.memo)
|
|
143
|
+
ctx.seq += 1;
|
|
144
|
+
await ctx.store.appendEvent({
|
|
145
|
+
runId: ctx.runId, seq: ctx.seq, type: "snapshot",
|
|
146
|
+
payload: { memo }, createdAt: ctx.now(),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
112
149
|
/** Durable sleep. Accepts milliseconds or an absolute Date. */
|
|
113
150
|
export async function sleep(until) {
|
|
114
151
|
if (!current) {
|
|
@@ -119,9 +156,8 @@ export async function sleep(until) {
|
|
|
119
156
|
const wakeAt = until instanceof Date ? until.getTime() : ctx.now() + until;
|
|
120
157
|
const key = `sleep:${ctx.sleepCalls}`;
|
|
121
158
|
ctx.sleepCalls += 1;
|
|
122
|
-
// Replay: already slept?
|
|
123
|
-
|
|
124
|
-
if (done)
|
|
159
|
+
// Replay: already slept? (O(1) map lookup)
|
|
160
|
+
if (ctx.memo.has(`sleep_completed:${key}`))
|
|
125
161
|
return;
|
|
126
162
|
ctx.seq += 1;
|
|
127
163
|
await ctx.store.appendEvent({
|
|
@@ -151,8 +187,8 @@ export function getWritable() {
|
|
|
151
187
|
// Memoized by call position (like steps): on replay, a write whose
|
|
152
188
|
// position was already emitted is skipped, and the loop advances.
|
|
153
189
|
const key = `w:${ctx.writes}`;
|
|
154
|
-
// Use the
|
|
155
|
-
const already = ctx.
|
|
190
|
+
// Use the replay memo — no per-write re-fetch of the event log.
|
|
191
|
+
const already = ctx.memo.has(`chunk:${key}`);
|
|
156
192
|
ctx.writes += 1;
|
|
157
193
|
if (already)
|
|
158
194
|
return;
|
|
@@ -164,7 +200,7 @@ export function getWritable() {
|
|
|
164
200
|
});
|
|
165
201
|
},
|
|
166
202
|
async close() {
|
|
167
|
-
const already = ctx.
|
|
203
|
+
const already = [...ctx.memo.values()].some((e) => e.type === "chunk" && e.payload?.done === true);
|
|
168
204
|
if (already)
|
|
169
205
|
return;
|
|
170
206
|
ctx.seq += 1;
|
|
@@ -183,6 +219,16 @@ const DEFAULT_STEP_RETRIES = 3;
|
|
|
183
219
|
export class Engine {
|
|
184
220
|
store;
|
|
185
221
|
opts;
|
|
222
|
+
/** In-process run completions: runId -> deferred. Avoids polling entirely. */
|
|
223
|
+
local = new Map();
|
|
224
|
+
defer(runId) {
|
|
225
|
+
let resolve;
|
|
226
|
+
let reject;
|
|
227
|
+
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
228
|
+
const d = { promise, resolve, reject };
|
|
229
|
+
this.local.set(runId, d);
|
|
230
|
+
return d;
|
|
231
|
+
}
|
|
186
232
|
constructor(store, opts = {}) {
|
|
187
233
|
this.store = store;
|
|
188
234
|
this.opts = opts;
|
|
@@ -190,6 +236,7 @@ export class Engine {
|
|
|
190
236
|
async start(workflowId, args) {
|
|
191
237
|
const runId = `lrun_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
192
238
|
await this.store.createRun(runId, workflowId, args);
|
|
239
|
+
this.defer(runId);
|
|
193
240
|
void this.run(runId, workflowId, args);
|
|
194
241
|
return { runId };
|
|
195
242
|
}
|
|
@@ -210,9 +257,13 @@ export class Engine {
|
|
|
210
257
|
};
|
|
211
258
|
}
|
|
212
259
|
async waitFor(runId) {
|
|
260
|
+
// In-process fast path: if this engine instance started the run, await
|
|
261
|
+
// the deferred promise directly — no database polling at all.
|
|
262
|
+
const d = this.local.get(runId);
|
|
263
|
+
if (d)
|
|
264
|
+
return d.promise;
|
|
265
|
+
// Cross-process fallback: adaptive poll (5ms -> 250ms).
|
|
213
266
|
const deadline = Date.now() + 10 * 60_000;
|
|
214
|
-
// Adaptive poll: fast at first (most runs finish in ms), backing off so
|
|
215
|
-
// long waits don't hammer the database.
|
|
216
267
|
let delay = 5;
|
|
217
268
|
while (Date.now() < deadline) {
|
|
218
269
|
const row = await this.store.getRun(runId);
|
|
@@ -259,11 +310,37 @@ export class Engine {
|
|
|
259
310
|
if (claimer.claimRun && !(await claimer.claimRun(runId)))
|
|
260
311
|
return;
|
|
261
312
|
const log = await this.store.getEvents(runId);
|
|
313
|
+
// Memo index: one pass over the log, O(1) lookups during replay.
|
|
314
|
+
// A leading 'snapshot' event pre-fills completed step/sleep/chunk state.
|
|
315
|
+
const memo = new Map();
|
|
316
|
+
for (const e of log) {
|
|
317
|
+
if (e.type === "snapshot") {
|
|
318
|
+
const m = e.payload.memo ?? {};
|
|
319
|
+
for (const [k, v] of Object.entries(m)) {
|
|
320
|
+
if (k.startsWith("step_completed:")) {
|
|
321
|
+
memo.set(k, { runId, seq: 0, type: "step_completed",
|
|
322
|
+
payload: { key: k.slice("step_completed:".length), value: v }, createdAt: 0 });
|
|
323
|
+
}
|
|
324
|
+
else if (k.startsWith("sleep_completed:")) {
|
|
325
|
+
memo.set(k, { runId, seq: 0, type: "sleep_completed",
|
|
326
|
+
payload: { key: k.slice("sleep_completed:".length) }, createdAt: 0 });
|
|
327
|
+
}
|
|
328
|
+
else if (k.startsWith("chunk:")) {
|
|
329
|
+
memo.set(k, { runId, seq: 0, type: "chunk",
|
|
330
|
+
payload: v, createdAt: 0 });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const key = e.payload.key;
|
|
336
|
+
if (key !== undefined && key !== null)
|
|
337
|
+
memo.set(`${e.type}:${key}`, e);
|
|
338
|
+
}
|
|
262
339
|
const ctx = {
|
|
263
340
|
runId, seq: log.length ? log.reduce((m, e) => Math.max(m, e.seq), 0) + 1 : 1,
|
|
264
341
|
stepCalls: 0, sleepCalls: 0, hookCalls: 0,
|
|
265
342
|
writes: 0,
|
|
266
|
-
store: this.store, log, cursor: 0, chunks: [], now: () => Date.now(),
|
|
343
|
+
store: this.store, log, memo, completedNow: 0, freshResults: new Map(), cursor: 0, chunks: [], now: () => Date.now(),
|
|
267
344
|
};
|
|
268
345
|
const prev = current;
|
|
269
346
|
current = ctx;
|
|
@@ -274,21 +351,22 @@ export class Engine {
|
|
|
274
351
|
}
|
|
275
352
|
const output = await fn(...args);
|
|
276
353
|
await this.store.setStatus(runId, "completed", output);
|
|
354
|
+
this.local.get(runId)?.resolve(output);
|
|
277
355
|
}
|
|
278
356
|
catch (err) {
|
|
279
357
|
if (err instanceof SuspendSignal)
|
|
280
358
|
return; // waiting on a timer
|
|
281
|
-
|
|
282
|
-
await this.store.setStatus(runId, "failed", {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
359
|
+
const fail = async (error) => {
|
|
360
|
+
await this.store.setStatus(runId, "failed", {
|
|
361
|
+
error: error instanceof Error ? error.message : String(error),
|
|
362
|
+
});
|
|
363
|
+
this.local.get(runId)?.reject(err instanceof Error ? err : new Error(String(err)));
|
|
364
|
+
};
|
|
365
|
+
if (err instanceof CancelledError)
|
|
366
|
+
return fail("cancelled");
|
|
367
|
+
if (err instanceof FatalError)
|
|
368
|
+
return fail(err.message);
|
|
369
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
292
370
|
}
|
|
293
371
|
finally {
|
|
294
372
|
current = prev;
|
|
@@ -296,33 +374,47 @@ export class Engine {
|
|
|
296
374
|
}
|
|
297
375
|
}
|
|
298
376
|
workerStopped = false;
|
|
377
|
+
listenClient;
|
|
299
378
|
/** Signal the worker loop to exit after its current poll cycle. */
|
|
300
379
|
stopWorker() {
|
|
301
380
|
this.workerStopped = true;
|
|
381
|
+
this.listenClient?.release?.();
|
|
302
382
|
}
|
|
303
383
|
/** Worker loop: resume runs whose timers are due. Resolves when stopWorker() is called. */
|
|
304
384
|
async startWorker(onError) {
|
|
305
385
|
this.workerStopped = false;
|
|
386
|
+
// Adaptive poll: snap to work when there is any, back off when idle.
|
|
387
|
+
let delay = this.opts.pollMs ?? 500;
|
|
388
|
+
const minDelay = 50;
|
|
389
|
+
// Optional LISTEN/NOTIFY: a dedicated client turns notifications into an
|
|
390
|
+
// immediate poll. Lossy by design — polling remains the correctness path.
|
|
391
|
+
let wake = () => { delay = minDelay; };
|
|
392
|
+
const listener = this.store.getListenClient?.();
|
|
393
|
+
if (listener) {
|
|
394
|
+
try {
|
|
395
|
+
await listener.query("LISTEN lightflow_wake");
|
|
396
|
+
listener.on("notification", () => wake());
|
|
397
|
+
this.listenClient = listener;
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
listener.release?.();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
306
403
|
while (!this.workerStopped) {
|
|
307
404
|
try {
|
|
308
405
|
const due = await this.store.dueTimers(Date.now());
|
|
406
|
+
if (due.length)
|
|
407
|
+
delay = minDelay;
|
|
309
408
|
for (const t of due) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const key = created?.payload?.key
|
|
313
|
-
?? `sleep:${t.seq}`;
|
|
314
|
-
const alreadyDone = ev.some((x) => x.type === "sleep_completed" &&
|
|
315
|
-
x.payload?.key === key);
|
|
316
|
-
if (alreadyDone)
|
|
317
|
-
continue;
|
|
409
|
+
// dueTimers() already excludes completed timers (anti-join), so no
|
|
410
|
+
// per-timer getEvents round trip: complete directly and resume.
|
|
318
411
|
await this.store.appendEvent({
|
|
319
|
-
runId: t.runId, seq:
|
|
320
|
-
type: "sleep_completed", payload: { key }, createdAt: Date.now(),
|
|
412
|
+
runId: t.runId, seq: 0, // seq=0 forces slow path (collision) -> lock-allocated max+1
|
|
413
|
+
type: "sleep_completed", payload: { key: t.key }, createdAt: Date.now(),
|
|
321
414
|
});
|
|
322
415
|
const row = await this.store.getRun(t.runId);
|
|
323
416
|
if (!row || row.status !== "running")
|
|
324
417
|
continue;
|
|
325
|
-
// resume: re-run with replay; sleep is memoized now
|
|
326
418
|
void this.resume(t.runId);
|
|
327
419
|
}
|
|
328
420
|
await this.reapStale(onError);
|
|
@@ -330,7 +422,8 @@ export class Engine {
|
|
|
330
422
|
catch (e) {
|
|
331
423
|
onError?.(e);
|
|
332
424
|
}
|
|
333
|
-
await new Promise((r) => setTimeout(r,
|
|
425
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
426
|
+
delay = Math.min(Math.round(delay * 1.5), this.opts.pollMs ?? 500);
|
|
334
427
|
}
|
|
335
428
|
}
|
|
336
429
|
/**
|
package/dist/src/pg-store.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import pg from "pg";
|
|
8
8
|
export async function createPostgresStore(url) {
|
|
9
|
-
|
|
9
|
+
// Pipelining (pg >= 8.23): batch queries on one connection instead of one
|
|
10
|
+
// round trip per query — 1.5-2.4x on multi-query workloads.
|
|
11
|
+
const pool = new pg.Pool({ connectionString: url, max: 20, pipeline: true });
|
|
10
12
|
await pool.query(`
|
|
11
13
|
CREATE TABLE IF NOT EXISTS lightflow_runs (
|
|
12
14
|
run_id TEXT PRIMARY KEY,
|
|
@@ -53,9 +55,11 @@ export async function createPostgresStore(url) {
|
|
|
53
55
|
created_at BIGINT NOT NULL
|
|
54
56
|
);
|
|
55
57
|
`);
|
|
58
|
+
// Durable timers: expression index matching the dueTimers query exactly
|
|
59
|
+
// (bigint cast on the payload value, partial on pending timers).
|
|
56
60
|
await pool.query(`
|
|
57
61
|
CREATE INDEX IF NOT EXISTS lightflow_events_timer_idx
|
|
58
|
-
ON lightflow_events ((payload->>'wakeAt'))
|
|
62
|
+
ON lightflow_events (((payload->>'wakeAt')::bigint))
|
|
59
63
|
WHERE type = 'sleep_created';
|
|
60
64
|
`);
|
|
61
65
|
// Exactly-once chunks: one row per (run, write index) AND one per (run,
|
|
@@ -158,6 +162,27 @@ export async function createPostgresStore(url) {
|
|
|
158
162
|
}
|
|
159
163
|
},
|
|
160
164
|
async getEvents(runId) {
|
|
165
|
+
// Snapshot compaction: replay starts from the latest snapshot, not seq 0.
|
|
166
|
+
const snap = await pool.query(`SELECT seq, payload FROM lightflow_events
|
|
167
|
+
WHERE run_id=$1 AND type='snapshot' ORDER BY seq DESC LIMIT 1`, [runId]);
|
|
168
|
+
if (snap.rowCount) {
|
|
169
|
+
const s = snap.rows[0];
|
|
170
|
+
const r = await pool.query(`SELECT run_id, seq, type, payload, created_at FROM lightflow_events
|
|
171
|
+
WHERE run_id=$1 AND seq > $2 ORDER BY seq ASC`, [runId, s.seq]);
|
|
172
|
+
// Prepend the run_completed event so resume() can find name/args.
|
|
173
|
+
const start = await pool.query(`SELECT payload FROM lightflow_events WHERE run_id=$1 AND seq=0`, [runId]);
|
|
174
|
+
const events = [{
|
|
175
|
+
runId, seq: 0, type: "run_completed",
|
|
176
|
+
payload: start.rows[0]?.payload ?? {}, createdAt: 0,
|
|
177
|
+
}, {
|
|
178
|
+
runId, seq: s.seq, type: "snapshot",
|
|
179
|
+
payload: s.payload, createdAt: 0,
|
|
180
|
+
}].concat(r.rows.map((x) => ({
|
|
181
|
+
runId: x.run_id, seq: x.seq, type: x.type,
|
|
182
|
+
payload: x.payload, createdAt: Number(x.created_at),
|
|
183
|
+
})));
|
|
184
|
+
return events;
|
|
185
|
+
}
|
|
161
186
|
const r = await pool.query(`SELECT run_id, seq, type, payload, created_at FROM lightflow_events
|
|
162
187
|
WHERE run_id=$1 ORDER BY seq ASC`, [runId]);
|
|
163
188
|
return r.rows.map((x) => ({
|
|
@@ -166,9 +191,19 @@ export async function createPostgresStore(url) {
|
|
|
166
191
|
}));
|
|
167
192
|
},
|
|
168
193
|
async dueTimers(now) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
194
|
+
// Only timers not yet completed (anti-join against sleep_completed by
|
|
195
|
+
// payload key). Paired with claimDue() so racing workers don't wake the
|
|
196
|
+
// same timer twice.
|
|
197
|
+
const r = await pool.query(`SELECT e.run_id, e.seq, e.payload->>'key' AS key FROM lightflow_events e
|
|
198
|
+
WHERE e.type='sleep_created'
|
|
199
|
+
AND (e.payload->>'wakeAt')::bigint <= $1
|
|
200
|
+
AND NOT EXISTS (
|
|
201
|
+
SELECT 1 FROM lightflow_events c
|
|
202
|
+
WHERE c.run_id = e.run_id AND c.type='sleep_completed'
|
|
203
|
+
AND c.payload->>'key' = e.payload->>'key'
|
|
204
|
+
)
|
|
205
|
+
LIMIT 100`, [now]);
|
|
206
|
+
return r.rows.map((x) => ({ runId: x.run_id, seq: x.seq, key: x.key }));
|
|
172
207
|
},
|
|
173
208
|
async claimDue() { return false; },
|
|
174
209
|
async claimRun(runId, leaseMs = 30_000) {
|
|
@@ -181,6 +216,25 @@ export async function createPostgresStore(url) {
|
|
|
181
216
|
async releaseRun(runId) {
|
|
182
217
|
await pool.query(`UPDATE lightflow_runs SET claimed_until=0 WHERE run_id=$1 AND claimed_until > 0`, [runId]);
|
|
183
218
|
},
|
|
219
|
+
/** LISTEN/NOTIFY wakeup support: re-poll immediately on notify. */
|
|
220
|
+
async notifyWake() {
|
|
221
|
+
await pool.query(`NOTIFY lightflow_wake`);
|
|
222
|
+
},
|
|
223
|
+
getListenClient() {
|
|
224
|
+
const client = new pg.Client({ connectionString: url });
|
|
225
|
+
const ready = client.connect().then(() => {
|
|
226
|
+
// A listening client must not keep the Node process alive on its own.
|
|
227
|
+
client.stream?.unref?.();
|
|
228
|
+
return client;
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
async query(sql) { return (await ready).query(sql); },
|
|
232
|
+
on(_event, cb) {
|
|
233
|
+
void ready.then((c) => c.on("notification", cb));
|
|
234
|
+
},
|
|
235
|
+
release() { void ready.then((c) => c.end()).catch(() => { }); },
|
|
236
|
+
};
|
|
237
|
+
},
|
|
184
238
|
async nextChunkIndex(runId) {
|
|
185
239
|
const r = await pool.query(`SELECT COALESCE(max((payload->>'index')::int),-1)+1 AS n
|
|
186
240
|
FROM lightflow_events WHERE run_id=$1 AND type='chunk'`, [runId]);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
|
|
2
|
+
import { Engine, step, registerWorkflow } from "/home/agentuser/lightflow/dist/src/index.js";
|
|
3
|
+
import { createPostgresStore } from "/home/agentuser/lightflow/dist/src/pg-store.js";
|
|
4
|
+
const url = process.env.LIGHTFLOW_PG_URL;
|
|
5
|
+
const store = await createPostgresStore(url);
|
|
6
|
+
const N = Number(process.env.RN ?? 100); // steps per workflow
|
|
7
|
+
registerWorkflow("long", async () => {
|
|
8
|
+
for (let i = 0; i < N; i++) await step(async () => i);
|
|
9
|
+
return N;
|
|
10
|
+
});
|
|
11
|
+
const engine = new Engine(store, { pollMs: 50 });
|
|
12
|
+
// throughput pass
|
|
13
|
+
const t0 = performance.now();
|
|
14
|
+
const RUNS = 50;
|
|
15
|
+
await Promise.all(Array.from({length: 10}, async () => {
|
|
16
|
+
for (let i = 0; i < RUNS/10; i++) {
|
|
17
|
+
const { runId } = await engine.start("long", []);
|
|
18
|
+
const run = await engine.getRun(runId);
|
|
19
|
+
await run.returnValue;
|
|
20
|
+
}
|
|
21
|
+
}));
|
|
22
|
+
const dt = (performance.now()-t0)/1000;
|
|
23
|
+
console.log(JSON.stringify({ stepsPerWorkflow: N, runs: RUNS, seconds: +dt.toFixed(2), runsPerSec: +(RUNS/dt).toFixed(1), stepsPerSec: +(RUNS*N/dt).toFixed(1) }));
|
|
24
|
+
process.exit(0);
|
package/dist/test/bench.js
CHANGED
|
@@ -33,11 +33,8 @@ async function main() {
|
|
|
33
33
|
while (queue.length) {
|
|
34
34
|
const i = queue.shift();
|
|
35
35
|
const { runId } = await engine.start("bench", []);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
await new Promise(r => setTimeout(r, 20));
|
|
39
|
-
run = await store.getRun(runId);
|
|
40
|
-
} while (run && run.status === "running");
|
|
36
|
+
const run = await engine.getRun(runId);
|
|
37
|
+
await run.returnValue;
|
|
41
38
|
done++;
|
|
42
39
|
}
|
|
43
40
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
|
|
2
|
+
import { Engine, step, registerWorkflow } from "/home/agentuser/lightflow/dist/src/index.js";
|
|
3
|
+
import { createPostgresStore } from "/home/agentuser/lightflow/dist/src/pg-store.js";
|
|
4
|
+
import pg from "pg";
|
|
5
|
+
const url = process.env.LIGHTFLOW_PG_URL;
|
|
6
|
+
const store = await createPostgresStore(url);
|
|
7
|
+
const pool = new pg.Pool({ connectionString: url });
|
|
8
|
+
const N = 600;
|
|
9
|
+
let calls = 0;
|
|
10
|
+
registerWorkflow("compact", async () => {
|
|
11
|
+
const vals = [];
|
|
12
|
+
for (let i = 0; i < N; i++) vals.push(await step(async () => { calls++; return i; }));
|
|
13
|
+
return vals.length;
|
|
14
|
+
});
|
|
15
|
+
const engine = new Engine(store);
|
|
16
|
+
const { runId } = await engine.start("compact", []);
|
|
17
|
+
const run = await engine.getRun(runId);
|
|
18
|
+
const ret = await run.returnValue;
|
|
19
|
+
// count events + snapshots
|
|
20
|
+
const evs = await pool.query("SELECT type, count(*) c FROM lightflow_events WHERE run_id=$1 GROUP BY 1", [runId]);
|
|
21
|
+
const snaps = await pool.query("SELECT count(*) c FROM lightflow_events WHERE run_id=$1 AND type='snapshot'", [runId]);
|
|
22
|
+
console.log("result:", ret, "calls:", calls, "snapshots:", snaps.rows[0].c);
|
|
23
|
+
console.log(evs.rows.map(r=>`${r.type}=${r.c}`).join(", "));
|
|
24
|
+
// replay from snapshot: simulate resume on completed run (should be no-op) - measure getEvents size returned
|
|
25
|
+
const log = await store.getEvents(runId);
|
|
26
|
+
console.log("getEvents rows returned (should be << total):", log.length);
|
|
27
|
+
await pool.end(); process.exit(0);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
import { Engine, step, registerWorkflow, sleep } from "/home/agentuser/lightflow/dist/src/index.js";
|
|
3
|
+
import { createPostgresStore } from "/home/agentuser/lightflow/dist/src/pg-store.js";
|
|
4
|
+
const url = process.env.LIGHTFLOW_PG_URL;
|
|
5
|
+
const store = await createPostgresStore(url);
|
|
6
|
+
const N = 700;
|
|
7
|
+
registerWorkflow("crashy", async () => {
|
|
8
|
+
const vals = [];
|
|
9
|
+
for (let i = 0; i < N; i++) {
|
|
10
|
+
vals.push(await step(async () => i));
|
|
11
|
+
if (i === 350) await sleep(120); // suspend mid-run; snapshots exist by now
|
|
12
|
+
}
|
|
13
|
+
return vals.length;
|
|
14
|
+
});
|
|
15
|
+
const engine = new Engine(store, { pollMs: 50 });
|
|
16
|
+
const wp = engine.startWorker();
|
|
17
|
+
setTimeout(() => engine.stopWorker(), 60000);
|
|
18
|
+
const { runId } = await engine.start("crashy", []);
|
|
19
|
+
const run = await engine.getRun(runId);
|
|
20
|
+
const ret = await run.returnValue;
|
|
21
|
+
console.log("completed after suspend+resume:", ret, ret === N ? "OK" : "FAIL"); engine.stopWorker();
|
|
22
|
+
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lightflow-engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "A tiny durable workflow engine for Node.js and Postgres: steps that run at most once, timers that survive crashes, streams that replay exactly once.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|