lightflow-engine 0.1.2 → 0.1.4

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 CHANGED
@@ -151,69 +151,11 @@ other engines and designed out here.
151
151
 
152
152
  ## Benchmarks
153
153
 
154
- Throughput measured with [`test/bench.ts`](test/bench.ts) — plain `node`
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
- ```bash
158
- npm run build
159
- LIGHTFLOW_PG_URL=postgres://... node dist/test/bench.js
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,50 @@ export async function step(fn) {
103
103
  throw lastErr;
104
104
  }
105
105
  ctx.seq += 1;
106
- await ctx.store.appendEvent({
106
+ ctx.freshResults.set(key, value);
107
+ ctx.append({
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
+ await Promise.all(ctx.inflight);
124
+ ctx.completedNow += 1;
125
+ const done = ctx.completedNow +
126
+ [...ctx.memo.values()].filter((e) => e.type === "step_completed").length;
127
+ if (done < SNAPSHOT_MIN_EVENTS || done % SNAPSHOT_INTERVAL !== 0)
128
+ return;
129
+ const memo = {};
130
+ for (const e of ctx.memo.values()) {
131
+ const key = e.payload.key;
132
+ if (key === undefined)
133
+ continue;
134
+ if (e.type === "step_completed")
135
+ memo[`step_completed:${key}`] = e.payload.value;
136
+ else if (e.type === "sleep_completed")
137
+ memo[`sleep_completed:${key}`] = true;
138
+ else if (e.type === "chunk")
139
+ memo[`chunk:${key}`] = e.payload;
140
+ }
141
+ for (const [key, value] of ctx.freshResults)
142
+ memo[`step_completed:${key}`] = value;
143
+ // include the step we just completed (not yet in ctx.memo)
144
+ ctx.seq += 1;
145
+ await ctx.store.appendEvent({
146
+ runId: ctx.runId, seq: ctx.seq, type: "snapshot",
147
+ payload: { memo }, createdAt: ctx.now(),
148
+ });
149
+ }
112
150
  /** Durable sleep. Accepts milliseconds or an absolute Date. */
113
151
  export async function sleep(until) {
114
152
  if (!current) {
@@ -182,6 +220,16 @@ const DEFAULT_STEP_RETRIES = 3;
182
220
  export class Engine {
183
221
  store;
184
222
  opts;
223
+ /** In-process run completions: runId -> deferred. Avoids polling entirely. */
224
+ local = new Map();
225
+ defer(runId) {
226
+ let resolve;
227
+ let reject;
228
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
229
+ const d = { promise, resolve, reject };
230
+ this.local.set(runId, d);
231
+ return d;
232
+ }
185
233
  constructor(store, opts = {}) {
186
234
  this.store = store;
187
235
  this.opts = opts;
@@ -189,6 +237,7 @@ export class Engine {
189
237
  async start(workflowId, args) {
190
238
  const runId = `lrun_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
191
239
  await this.store.createRun(runId, workflowId, args);
240
+ this.defer(runId);
192
241
  void this.run(runId, workflowId, args);
193
242
  return { runId };
194
243
  }
@@ -209,9 +258,13 @@ export class Engine {
209
258
  };
210
259
  }
211
260
  async waitFor(runId) {
261
+ // In-process fast path: if this engine instance started the run, await
262
+ // the deferred promise directly — no database polling at all.
263
+ const d = this.local.get(runId);
264
+ if (d)
265
+ return d.promise;
266
+ // Cross-process fallback: adaptive poll (5ms -> 250ms).
212
267
  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
268
  let delay = 5;
216
269
  while (Date.now() < deadline) {
217
270
  const row = await this.store.getRun(runId);
@@ -255,12 +308,36 @@ export class Engine {
255
308
  // by the winner, and any newer events it would have seen are picked up
256
309
  // by the next resume.
257
310
  const claimer = this.store;
258
- if (claimer.claimRun && !(await claimer.claimRun(runId)))
311
+ const claim = await claimer.claimRun?.(runId);
312
+ if (claim && !claim.ok)
313
+ return;
314
+ if (claim?.cancelled) {
315
+ await this.store.setStatus(runId, "failed", { error: "cancelled" });
259
316
  return;
317
+ }
260
318
  const log = await this.store.getEvents(runId);
261
319
  // Memo index: one pass over the log, O(1) lookups during replay.
320
+ // A leading 'snapshot' event pre-fills completed step/sleep/chunk state.
262
321
  const memo = new Map();
263
322
  for (const e of log) {
323
+ if (e.type === "snapshot") {
324
+ const m = e.payload.memo ?? {};
325
+ for (const [k, v] of Object.entries(m)) {
326
+ if (k.startsWith("step_completed:")) {
327
+ memo.set(k, { runId, seq: 0, type: "step_completed",
328
+ payload: { key: k.slice("step_completed:".length), value: v }, createdAt: 0 });
329
+ }
330
+ else if (k.startsWith("sleep_completed:")) {
331
+ memo.set(k, { runId, seq: 0, type: "sleep_completed",
332
+ payload: { key: k.slice("sleep_completed:".length) }, createdAt: 0 });
333
+ }
334
+ else if (k.startsWith("chunk:")) {
335
+ memo.set(k, { runId, seq: 0, type: "chunk",
336
+ payload: v, createdAt: 0 });
337
+ }
338
+ }
339
+ continue;
340
+ }
264
341
  const key = e.payload.key;
265
342
  if (key !== undefined && key !== null)
266
343
  memo.set(`${e.type}:${key}`, e);
@@ -269,32 +346,43 @@ export class Engine {
269
346
  runId, seq: log.length ? log.reduce((m, e) => Math.max(m, e.seq), 0) + 1 : 1,
270
347
  stepCalls: 0, sleepCalls: 0, hookCalls: 0,
271
348
  writes: 0,
272
- store: this.store, log, memo, cursor: 0, chunks: [], now: () => Date.now(),
349
+ store: this.store, log, memo, completedNow: 0, freshResults: new Map(),
350
+ inflight: [], cursor: 0, chunks: [], now: () => Date.now(),
351
+ append: (e) => {
352
+ // Pipelined append: capture the promise; callers only await at
353
+ // suspension/end, so several step completions share one flush.
354
+ const p = ctx.store.appendEvent(e).then(() => { ctx.inflight = ctx.inflight.filter(x => x !== p); });
355
+ ctx.inflight.push(p);
356
+ return p;
357
+ },
273
358
  };
274
359
  const prev = current;
275
360
  current = ctx;
276
361
  try {
277
- if (await this.store.isCancelled?.(runId)) {
278
- await this.store.setStatus(runId, "failed", { error: "cancelled" });
279
- return;
280
- }
281
362
  const output = await fn(...args);
282
- await this.store.setStatus(runId, "completed", output);
363
+ await Promise.all(ctx.inflight);
364
+ await (this.store.finishRun?.(runId, "completed", output)
365
+ ?? this.store.setStatus(runId, "completed", output));
366
+ this.local.get(runId)?.resolve(output);
283
367
  }
284
368
  catch (err) {
285
- if (err instanceof SuspendSignal)
369
+ if (err instanceof SuspendSignal) {
370
+ await Promise.all(ctx.inflight);
286
371
  return; // waiting on a timer
287
- if (err instanceof CancelledError) {
288
- await this.store.setStatus(runId, "failed", { error: "cancelled" });
289
- return;
290
- }
291
- if (err instanceof FatalError) {
292
- await this.store.setStatus(runId, "failed", { error: err.message });
293
- return;
294
372
  }
295
- await this.store.setStatus(runId, "failed", {
296
- error: err instanceof Error ? err.message : String(err),
297
- });
373
+ const fail = async (error) => {
374
+ await (this.store.finishRun?.(runId, "failed", {
375
+ error: error instanceof Error ? error.message : String(error),
376
+ }) ?? this.store.setStatus(runId, "failed", {
377
+ error: error instanceof Error ? error.message : String(error),
378
+ }));
379
+ this.local.get(runId)?.reject(err instanceof Error ? err : new Error(String(err)));
380
+ };
381
+ if (err instanceof CancelledError)
382
+ return fail("cancelled");
383
+ if (err instanceof FatalError)
384
+ return fail(err.message);
385
+ return fail(err instanceof Error ? err.message : String(err));
298
386
  }
299
387
  finally {
300
388
  current = prev;
@@ -337,7 +425,7 @@ export class Engine {
337
425
  // dueTimers() already excludes completed timers (anti-join), so no
338
426
  // per-timer getEvents round trip: complete directly and resume.
339
427
  await this.store.appendEvent({
340
- runId: t.runId, seq: t.seq + 1_000_000, // collision-proof offset; ON CONFLICT handles races
428
+ runId: t.runId, seq: 0, // seq=0 forces slow path (collision) -> lock-allocated max+1
341
429
  type: "sleep_completed", payload: { key: t.key }, createdAt: Date.now(),
342
430
  });
343
431
  const row = await this.store.getRun(t.runId);
@@ -5,8 +5,19 @@
5
5
  * columns, so no timezone ambiguity (the bug that cost hours in world-postgres).
6
6
  */
7
7
  import pg from "pg";
8
- export async function createPostgresStore(url) {
9
- const pool = new pg.Pool({ connectionString: url, max: 20 });
8
+ export async function createPostgresStore(url, opts = {}) {
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 });
12
+ if (opts.sessionOptions) {
13
+ const sets = Object.entries(opts.sessionOptions)
14
+ .map(([k, v]) => `SET ${k} = ${v === "off" || v === "on" ? v : `'${v}'`}`)
15
+ .join("; ");
16
+ // Apply per connection as it opens (pg fires 'connect' per client).
17
+ pool.on("connect", (client) => {
18
+ void client.query(sets).catch(() => { });
19
+ });
20
+ }
10
21
  await pool.query(`
11
22
  CREATE TABLE IF NOT EXISTS lightflow_runs (
12
23
  run_id TEXT PRIMARY KEY,
@@ -160,6 +171,27 @@ export async function createPostgresStore(url) {
160
171
  }
161
172
  },
162
173
  async getEvents(runId) {
174
+ // Snapshot compaction: replay starts from the latest snapshot, not seq 0.
175
+ const snap = await pool.query(`SELECT seq, payload FROM lightflow_events
176
+ WHERE run_id=$1 AND type='snapshot' ORDER BY seq DESC LIMIT 1`, [runId]);
177
+ if (snap.rowCount) {
178
+ const s = snap.rows[0];
179
+ const r = await pool.query(`SELECT run_id, seq, type, payload, created_at FROM lightflow_events
180
+ WHERE run_id=$1 AND seq > $2 ORDER BY seq ASC`, [runId, s.seq]);
181
+ // Prepend the run_completed event so resume() can find name/args.
182
+ const start = await pool.query(`SELECT payload FROM lightflow_events WHERE run_id=$1 AND seq=0`, [runId]);
183
+ const events = [{
184
+ runId, seq: 0, type: "run_completed",
185
+ payload: start.rows[0]?.payload ?? {}, createdAt: 0,
186
+ }, {
187
+ runId, seq: s.seq, type: "snapshot",
188
+ payload: s.payload, createdAt: 0,
189
+ }].concat(r.rows.map((x) => ({
190
+ runId: x.run_id, seq: x.seq, type: x.type,
191
+ payload: x.payload, createdAt: Number(x.created_at),
192
+ })));
193
+ return events;
194
+ }
163
195
  const r = await pool.query(`SELECT run_id, seq, type, payload, created_at FROM lightflow_events
164
196
  WHERE run_id=$1 ORDER BY seq ASC`, [runId]);
165
197
  return r.rows.map((x) => ({
@@ -185,14 +217,21 @@ export async function createPostgresStore(url) {
185
217
  async claimDue() { return false; },
186
218
  async claimRun(runId, leaseMs = 30_000) {
187
219
  const now = Date.now();
220
+ // One round trip: claim the lease AND read the cancelled flag.
188
221
  const r = await pool.query(`UPDATE lightflow_runs SET claimed_until=$2, updated_at=$3
189
222
  WHERE run_id=$1 AND claimed_until < $3
190
- RETURNING run_id`, [runId, now + leaseMs, now]);
191
- return (r.rowCount ?? 0) > 0;
223
+ RETURNING cancelled`, [runId, now + leaseMs, now]);
224
+ return { ok: (r.rowCount ?? 0) > 0, cancelled: r.rows[0]?.cancelled === true };
192
225
  },
193
226
  async releaseRun(runId) {
194
227
  await pool.query(`UPDATE lightflow_runs SET claimed_until=0 WHERE run_id=$1 AND claimed_until > 0`, [runId]);
195
228
  },
229
+ /** Terminal status + lease release in one round trip. */
230
+ async finishRun(runId, status, output) {
231
+ await pool.query(`UPDATE lightflow_runs
232
+ 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()]);
234
+ },
196
235
  /** LISTEN/NOTIFY wakeup support: re-poll immediately on notify. */
197
236
  async notifyWake() {
198
237
  await pool.query(`NOTIFY lightflow_wake`);
@@ -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
- while (true) { const r = await store.getRun(runId); if (r && r.status !== "running") break; await new Promise(res=>setTimeout(res,5)); }
18
+ const run = await engine.getRun(runId);
19
+ await run.returnValue;
19
20
  }
20
21
  }));
21
22
  const dt = (performance.now()-t0)/1000;
@@ -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
- let run;
37
- do {
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.2",
3
+ "version": "0.1.4",
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",