lightflow-engine 0.1.1 → 0.1.2
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 +28 -1
- package/dist/src/index.js +42 -21
- package/dist/src/pg-store.js +35 -4
- package/dist/test/bench-long.mjs +23 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,7 +159,34 @@ npm run build
|
|
|
159
159
|
LIGHTFLOW_PG_URL=postgres://... node dist/test/bench.js
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
-
### v0.1.
|
|
162
|
+
### v0.1.1 → v0.1.2 (replay & timer round)
|
|
163
|
+
|
|
164
|
+
Where the v0.1.1 gains were per-event write cost, v0.1.2 targets replay and
|
|
165
|
+
timer dispatch:
|
|
166
|
+
|
|
167
|
+
- **O(1) memo lookups during replay.** The event log is indexed once into a
|
|
168
|
+
`Map` (`"type:key" → event`); memo checks were linear `Array.find` scans,
|
|
169
|
+
making replay O(steps²) on long workflows.
|
|
170
|
+
- **Fixed the timer index.** The `wakeAt` index was on the *text* extraction
|
|
171
|
+
but the query compared a *bigint* cast — Postgres silently ignored it. The
|
|
172
|
+
index now matches the query expression exactly.
|
|
173
|
+
- **`dueTimers` anti-join.** Returns only timers not yet completed (one query
|
|
174
|
+
instead of a full `getEvents` per due timer in the worker), so timer wake-up
|
|
175
|
+
costs 2 queries instead of 1 + full-log fetch + resume.
|
|
176
|
+
- **Adaptive worker poll (50ms → configured, backs off when idle) with an
|
|
177
|
+
optional LISTEN/NOTIFY wakeup** — notifications are a lossy nudge; polling
|
|
178
|
+
remains the correctness path. Same pattern PgQue and DBOS use.
|
|
179
|
+
|
|
180
|
+
Measured (400-step workflows, 50 runs):
|
|
181
|
+
|
|
182
|
+
| | v0.1.1 | v0.1.2 |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| Steps / sec, long workflows | 8,230 | **9,070** |
|
|
185
|
+
| 100-step workflow throughput | — | 65 runs/s, 6,470 steps/s |
|
|
186
|
+
| Worker queries per timer wake | ~4 + full log fetch | 2 |
|
|
187
|
+
| Replay memo lookup | O(n) scan | O(1) Map |
|
|
188
|
+
|
|
189
|
+
### v0.1.0 → v0.1.1 (event-append round)
|
|
163
190
|
|
|
164
191
|
Same machine, same workload (200 runs × 5 steps, 20 in flight):
|
|
165
192
|
|
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
|
|
@@ -119,9 +119,8 @@ export async function sleep(until) {
|
|
|
119
119
|
const wakeAt = until instanceof Date ? until.getTime() : ctx.now() + until;
|
|
120
120
|
const key = `sleep:${ctx.sleepCalls}`;
|
|
121
121
|
ctx.sleepCalls += 1;
|
|
122
|
-
// Replay: already slept?
|
|
123
|
-
|
|
124
|
-
if (done)
|
|
122
|
+
// Replay: already slept? (O(1) map lookup)
|
|
123
|
+
if (ctx.memo.has(`sleep_completed:${key}`))
|
|
125
124
|
return;
|
|
126
125
|
ctx.seq += 1;
|
|
127
126
|
await ctx.store.appendEvent({
|
|
@@ -151,8 +150,8 @@ export function getWritable() {
|
|
|
151
150
|
// Memoized by call position (like steps): on replay, a write whose
|
|
152
151
|
// position was already emitted is skipped, and the loop advances.
|
|
153
152
|
const key = `w:${ctx.writes}`;
|
|
154
|
-
// Use the
|
|
155
|
-
const already = ctx.
|
|
153
|
+
// Use the replay memo — no per-write re-fetch of the event log.
|
|
154
|
+
const already = ctx.memo.has(`chunk:${key}`);
|
|
156
155
|
ctx.writes += 1;
|
|
157
156
|
if (already)
|
|
158
157
|
return;
|
|
@@ -164,7 +163,7 @@ export function getWritable() {
|
|
|
164
163
|
});
|
|
165
164
|
},
|
|
166
165
|
async close() {
|
|
167
|
-
const already = ctx.
|
|
166
|
+
const already = [...ctx.memo.values()].some((e) => e.type === "chunk" && e.payload?.done === true);
|
|
168
167
|
if (already)
|
|
169
168
|
return;
|
|
170
169
|
ctx.seq += 1;
|
|
@@ -259,11 +258,18 @@ export class Engine {
|
|
|
259
258
|
if (claimer.claimRun && !(await claimer.claimRun(runId)))
|
|
260
259
|
return;
|
|
261
260
|
const log = await this.store.getEvents(runId);
|
|
261
|
+
// Memo index: one pass over the log, O(1) lookups during replay.
|
|
262
|
+
const memo = new Map();
|
|
263
|
+
for (const e of log) {
|
|
264
|
+
const key = e.payload.key;
|
|
265
|
+
if (key !== undefined && key !== null)
|
|
266
|
+
memo.set(`${e.type}:${key}`, e);
|
|
267
|
+
}
|
|
262
268
|
const ctx = {
|
|
263
269
|
runId, seq: log.length ? log.reduce((m, e) => Math.max(m, e.seq), 0) + 1 : 1,
|
|
264
270
|
stepCalls: 0, sleepCalls: 0, hookCalls: 0,
|
|
265
271
|
writes: 0,
|
|
266
|
-
store: this.store, log, cursor: 0, chunks: [], now: () => Date.now(),
|
|
272
|
+
store: this.store, log, memo, cursor: 0, chunks: [], now: () => Date.now(),
|
|
267
273
|
};
|
|
268
274
|
const prev = current;
|
|
269
275
|
current = ctx;
|
|
@@ -296,33 +302,47 @@ export class Engine {
|
|
|
296
302
|
}
|
|
297
303
|
}
|
|
298
304
|
workerStopped = false;
|
|
305
|
+
listenClient;
|
|
299
306
|
/** Signal the worker loop to exit after its current poll cycle. */
|
|
300
307
|
stopWorker() {
|
|
301
308
|
this.workerStopped = true;
|
|
309
|
+
this.listenClient?.release?.();
|
|
302
310
|
}
|
|
303
311
|
/** Worker loop: resume runs whose timers are due. Resolves when stopWorker() is called. */
|
|
304
312
|
async startWorker(onError) {
|
|
305
313
|
this.workerStopped = false;
|
|
314
|
+
// Adaptive poll: snap to work when there is any, back off when idle.
|
|
315
|
+
let delay = this.opts.pollMs ?? 500;
|
|
316
|
+
const minDelay = 50;
|
|
317
|
+
// Optional LISTEN/NOTIFY: a dedicated client turns notifications into an
|
|
318
|
+
// immediate poll. Lossy by design — polling remains the correctness path.
|
|
319
|
+
let wake = () => { delay = minDelay; };
|
|
320
|
+
const listener = this.store.getListenClient?.();
|
|
321
|
+
if (listener) {
|
|
322
|
+
try {
|
|
323
|
+
await listener.query("LISTEN lightflow_wake");
|
|
324
|
+
listener.on("notification", () => wake());
|
|
325
|
+
this.listenClient = listener;
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
listener.release?.();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
306
331
|
while (!this.workerStopped) {
|
|
307
332
|
try {
|
|
308
333
|
const due = await this.store.dueTimers(Date.now());
|
|
334
|
+
if (due.length)
|
|
335
|
+
delay = minDelay;
|
|
309
336
|
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;
|
|
337
|
+
// dueTimers() already excludes completed timers (anti-join), so no
|
|
338
|
+
// per-timer getEvents round trip: complete directly and resume.
|
|
318
339
|
await this.store.appendEvent({
|
|
319
|
-
runId: t.runId, seq:
|
|
320
|
-
type: "sleep_completed", payload: { key }, createdAt: Date.now(),
|
|
340
|
+
runId: t.runId, seq: t.seq + 1_000_000, // collision-proof offset; ON CONFLICT handles races
|
|
341
|
+
type: "sleep_completed", payload: { key: t.key }, createdAt: Date.now(),
|
|
321
342
|
});
|
|
322
343
|
const row = await this.store.getRun(t.runId);
|
|
323
344
|
if (!row || row.status !== "running")
|
|
324
345
|
continue;
|
|
325
|
-
// resume: re-run with replay; sleep is memoized now
|
|
326
346
|
void this.resume(t.runId);
|
|
327
347
|
}
|
|
328
348
|
await this.reapStale(onError);
|
|
@@ -330,7 +350,8 @@ export class Engine {
|
|
|
330
350
|
catch (e) {
|
|
331
351
|
onError?.(e);
|
|
332
352
|
}
|
|
333
|
-
await new Promise((r) => setTimeout(r,
|
|
353
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
354
|
+
delay = Math.min(Math.round(delay * 1.5), this.opts.pollMs ?? 500);
|
|
334
355
|
}
|
|
335
356
|
}
|
|
336
357
|
/**
|
package/dist/src/pg-store.js
CHANGED
|
@@ -53,9 +53,11 @@ export async function createPostgresStore(url) {
|
|
|
53
53
|
created_at BIGINT NOT NULL
|
|
54
54
|
);
|
|
55
55
|
`);
|
|
56
|
+
// Durable timers: expression index matching the dueTimers query exactly
|
|
57
|
+
// (bigint cast on the payload value, partial on pending timers).
|
|
56
58
|
await pool.query(`
|
|
57
59
|
CREATE INDEX IF NOT EXISTS lightflow_events_timer_idx
|
|
58
|
-
ON lightflow_events ((payload->>'wakeAt'))
|
|
60
|
+
ON lightflow_events (((payload->>'wakeAt')::bigint))
|
|
59
61
|
WHERE type = 'sleep_created';
|
|
60
62
|
`);
|
|
61
63
|
// Exactly-once chunks: one row per (run, write index) AND one per (run,
|
|
@@ -166,9 +168,19 @@ export async function createPostgresStore(url) {
|
|
|
166
168
|
}));
|
|
167
169
|
},
|
|
168
170
|
async dueTimers(now) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
// Only timers not yet completed (anti-join against sleep_completed by
|
|
172
|
+
// payload key). Paired with claimDue() so racing workers don't wake the
|
|
173
|
+
// same timer twice.
|
|
174
|
+
const r = await pool.query(`SELECT e.run_id, e.seq, e.payload->>'key' AS key FROM lightflow_events e
|
|
175
|
+
WHERE e.type='sleep_created'
|
|
176
|
+
AND (e.payload->>'wakeAt')::bigint <= $1
|
|
177
|
+
AND NOT EXISTS (
|
|
178
|
+
SELECT 1 FROM lightflow_events c
|
|
179
|
+
WHERE c.run_id = e.run_id AND c.type='sleep_completed'
|
|
180
|
+
AND c.payload->>'key' = e.payload->>'key'
|
|
181
|
+
)
|
|
182
|
+
LIMIT 100`, [now]);
|
|
183
|
+
return r.rows.map((x) => ({ runId: x.run_id, seq: x.seq, key: x.key }));
|
|
172
184
|
},
|
|
173
185
|
async claimDue() { return false; },
|
|
174
186
|
async claimRun(runId, leaseMs = 30_000) {
|
|
@@ -181,6 +193,25 @@ export async function createPostgresStore(url) {
|
|
|
181
193
|
async releaseRun(runId) {
|
|
182
194
|
await pool.query(`UPDATE lightflow_runs SET claimed_until=0 WHERE run_id=$1 AND claimed_until > 0`, [runId]);
|
|
183
195
|
},
|
|
196
|
+
/** LISTEN/NOTIFY wakeup support: re-poll immediately on notify. */
|
|
197
|
+
async notifyWake() {
|
|
198
|
+
await pool.query(`NOTIFY lightflow_wake`);
|
|
199
|
+
},
|
|
200
|
+
getListenClient() {
|
|
201
|
+
const client = new pg.Client({ connectionString: url });
|
|
202
|
+
const ready = client.connect().then(() => {
|
|
203
|
+
// A listening client must not keep the Node process alive on its own.
|
|
204
|
+
client.stream?.unref?.();
|
|
205
|
+
return client;
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
async query(sql) { return (await ready).query(sql); },
|
|
209
|
+
on(_event, cb) {
|
|
210
|
+
void ready.then((c) => c.on("notification", cb));
|
|
211
|
+
},
|
|
212
|
+
release() { void ready.then((c) => c.end()).catch(() => { }); },
|
|
213
|
+
};
|
|
214
|
+
},
|
|
184
215
|
async nextChunkIndex(runId) {
|
|
185
216
|
const r = await pool.query(`SELECT COALESCE(max((payload->>'index')::int),-1)+1 AS n
|
|
186
217
|
FROM lightflow_events WHERE run_id=$1 AND type='chunk'`, [runId]);
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
while (true) { const r = await store.getRun(runId); if (r && r.status !== "running") break; await new Promise(res=>setTimeout(res,5)); }
|
|
19
|
+
}
|
|
20
|
+
}));
|
|
21
|
+
const dt = (performance.now()-t0)/1000;
|
|
22
|
+
console.log(JSON.stringify({ stepsPerWorkflow: N, runs: RUNS, seconds: +dt.toFixed(2), runsPerSec: +(RUNS/dt).toFixed(1), stepsPerSec: +(RUNS*N/dt).toFixed(1) }));
|
|
23
|
+
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.2",
|
|
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",
|