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 CHANGED
@@ -151,42 +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.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.log.find((e) => e.type === "step_completed" && e.payload?.key === key);
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
- const done = ctx.log.find((e) => e.type === "sleep_completed" && e.payload?.key === key);
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 in-memory replay log — no per-write re-fetch of the event log.
155
- const already = ctx.log.some((e) => e.type === "chunk" && e.payload?.key === key);
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.log.some((e) => e.type === "chunk" && e.payload?.done === true);
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
- if (err instanceof CancelledError) {
282
- await this.store.setStatus(runId, "failed", { error: "cancelled" });
283
- return;
284
- }
285
- if (err instanceof FatalError) {
286
- await this.store.setStatus(runId, "failed", { error: err.message });
287
- return;
288
- }
289
- await this.store.setStatus(runId, "failed", {
290
- error: err instanceof Error ? err.message : String(err),
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
- const ev = await this.store.getEvents(t.runId);
311
- const created = ev.find((x) => x.seq === t.seq);
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: ev.reduce((m, e) => Math.max(m, e.seq), 0) + 1,
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, this.opts.pollMs ?? 500));
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
  /**
@@ -6,7 +6,9 @@
6
6
  */
7
7
  import pg from "pg";
8
8
  export async function createPostgresStore(url) {
9
- const pool = new pg.Pool({ connectionString: url, max: 20 });
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
- const r = await pool.query(`SELECT run_id, seq FROM lightflow_events
170
- WHERE type='sleep_created' AND (payload->>'wakeAt')::bigint <= $1`, [now]);
171
- return r.rows.map((x) => ({ runId: x.run_id, seq: x.seq }));
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);
@@ -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.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",