lightflow-engine 0.1.2 → 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 -62
- package/dist/src/index.js +87 -15
- package/dist/src/pg-store.js +24 -1
- package/dist/test/bench-long.mjs +2 -1
- 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,69 +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.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)
|
|
190
|
-
|
|
191
|
-
Same machine, same workload (200 runs × 5 steps, 20 in flight):
|
|
192
|
-
|
|
193
|
-
| | v0.1.0 | v0.1.1 | |
|
|
194
|
-
|---|---|---|---|
|
|
195
|
-
| Runs / sec | 184 | 456–556 | **~2.7× faster** |
|
|
196
|
-
| Steps / sec | 922 | ~2,300–2,800 | **~2.7× faster** |
|
|
197
|
-
| SQL statements per event | 5 (BEGIN, advisory lock, max(seq), INSERT, COMMIT) | 1 (single INSERT … ON CONFLICT) | −80% |
|
|
198
|
-
| Advisory lock in hot path | every event, 7.8ms avg | removed | — |
|
|
199
|
-
| Racing resumes double-executing a step | possible (latent) | impossible (run lease) | fixed |
|
|
200
|
-
|
|
201
|
-
Sustained load, v0.1.1 (500 runs × 10 steps, 100 in flight):
|
|
202
|
-
**445 runs/sec, 4,450 steps/sec.**
|
|
203
|
-
|
|
204
|
-
What changed in v0.1.1, and why it matters:
|
|
205
|
-
|
|
206
|
-
- **Single-statement event append.** The old path took an advisory lock and
|
|
207
|
-
scanned `max(seq)` per event; profiling showed the lock averaging 7.8ms —
|
|
208
|
-
40× the cost of any other query. The new path relies on the `(run_id, seq)`
|
|
209
|
-
unique constraint and a single upsert.
|
|
210
|
-
- **Run lease.** Concurrent `resume()` calls for the same run are arbitrated
|
|
211
|
-
by an atomic `claimed_until` column, so two replays can never execute the
|
|
212
|
-
same step at once (verified by `test/race.ts`: 20 racing resumes × 10 runs,
|
|
213
|
-
zero double-executions).
|
|
214
|
-
- **No `step_started` writes** (replay never reads them), stream writes use
|
|
215
|
-
the in-memory replay log instead of re-fetching the event log, `createRun`
|
|
216
|
-
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.
|
|
217
159
|
|
|
218
160
|
## Design decisions
|
|
219
161
|
|
package/dist/src/index.js
CHANGED
|
@@ -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) {
|
|
@@ -182,6 +219,16 @@ const DEFAULT_STEP_RETRIES = 3;
|
|
|
182
219
|
export class Engine {
|
|
183
220
|
store;
|
|
184
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
|
+
}
|
|
185
232
|
constructor(store, opts = {}) {
|
|
186
233
|
this.store = store;
|
|
187
234
|
this.opts = opts;
|
|
@@ -189,6 +236,7 @@ export class Engine {
|
|
|
189
236
|
async start(workflowId, args) {
|
|
190
237
|
const runId = `lrun_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
191
238
|
await this.store.createRun(runId, workflowId, args);
|
|
239
|
+
this.defer(runId);
|
|
192
240
|
void this.run(runId, workflowId, args);
|
|
193
241
|
return { runId };
|
|
194
242
|
}
|
|
@@ -209,9 +257,13 @@ export class Engine {
|
|
|
209
257
|
};
|
|
210
258
|
}
|
|
211
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).
|
|
212
266
|
const deadline = Date.now() + 10 * 60_000;
|
|
213
|
-
// Adaptive poll: fast at first (most runs finish in ms), backing off so
|
|
214
|
-
// long waits don't hammer the database.
|
|
215
267
|
let delay = 5;
|
|
216
268
|
while (Date.now() < deadline) {
|
|
217
269
|
const row = await this.store.getRun(runId);
|
|
@@ -259,8 +311,27 @@ export class Engine {
|
|
|
259
311
|
return;
|
|
260
312
|
const log = await this.store.getEvents(runId);
|
|
261
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.
|
|
262
315
|
const memo = new Map();
|
|
263
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
|
+
}
|
|
264
335
|
const key = e.payload.key;
|
|
265
336
|
if (key !== undefined && key !== null)
|
|
266
337
|
memo.set(`${e.type}:${key}`, e);
|
|
@@ -269,7 +340,7 @@ export class Engine {
|
|
|
269
340
|
runId, seq: log.length ? log.reduce((m, e) => Math.max(m, e.seq), 0) + 1 : 1,
|
|
270
341
|
stepCalls: 0, sleepCalls: 0, hookCalls: 0,
|
|
271
342
|
writes: 0,
|
|
272
|
-
store: this.store, log, memo, cursor: 0, chunks: [], now: () => Date.now(),
|
|
343
|
+
store: this.store, log, memo, completedNow: 0, freshResults: new Map(), cursor: 0, chunks: [], now: () => Date.now(),
|
|
273
344
|
};
|
|
274
345
|
const prev = current;
|
|
275
346
|
current = ctx;
|
|
@@ -280,21 +351,22 @@ export class Engine {
|
|
|
280
351
|
}
|
|
281
352
|
const output = await fn(...args);
|
|
282
353
|
await this.store.setStatus(runId, "completed", output);
|
|
354
|
+
this.local.get(runId)?.resolve(output);
|
|
283
355
|
}
|
|
284
356
|
catch (err) {
|
|
285
357
|
if (err instanceof SuspendSignal)
|
|
286
358
|
return; // waiting on a timer
|
|
287
|
-
|
|
288
|
-
await this.store.setStatus(runId, "failed", {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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));
|
|
298
370
|
}
|
|
299
371
|
finally {
|
|
300
372
|
current = prev;
|
|
@@ -337,7 +409,7 @@ export class Engine {
|
|
|
337
409
|
// dueTimers() already excludes completed timers (anti-join), so no
|
|
338
410
|
// per-timer getEvents round trip: complete directly and resume.
|
|
339
411
|
await this.store.appendEvent({
|
|
340
|
-
runId: t.runId, seq:
|
|
412
|
+
runId: t.runId, seq: 0, // seq=0 forces slow path (collision) -> lock-allocated max+1
|
|
341
413
|
type: "sleep_completed", payload: { key: t.key }, createdAt: Date.now(),
|
|
342
414
|
});
|
|
343
415
|
const row = await this.store.getRun(t.runId);
|
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,
|
|
@@ -160,6 +162,27 @@ export async function createPostgresStore(url) {
|
|
|
160
162
|
}
|
|
161
163
|
},
|
|
162
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
|
+
}
|
|
163
186
|
const r = await pool.query(`SELECT run_id, seq, type, payload, created_at FROM lightflow_events
|
|
164
187
|
WHERE run_id=$1 ORDER BY seq ASC`, [runId]);
|
|
165
188
|
return r.rows.map((x) => ({
|
package/dist/test/bench-long.mjs
CHANGED
|
@@ -15,7 +15,8 @@ const RUNS = 50;
|
|
|
15
15
|
await Promise.all(Array.from({length: 10}, async () => {
|
|
16
16
|
for (let i = 0; i < RUNS/10; i++) {
|
|
17
17
|
const { runId } = await engine.start("long", []);
|
|
18
|
-
|
|
18
|
+
const run = await engine.getRun(runId);
|
|
19
|
+
await run.returnValue;
|
|
19
20
|
}
|
|
20
21
|
}));
|
|
21
22
|
const dt = (performance.now()-t0)/1000;
|
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",
|