lightflow-engine 0.1.3 → 0.2.0

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/BENCHMARKS.md ADDED
@@ -0,0 +1,158 @@
1
+ # lightflow — Benchmarks
2
+
3
+ All numbers measured locally on the dev machine: Node 22, Postgres 16,
4
+ localhost socket (no network hop). Reproduce with:
5
+
6
+ ```bash
7
+ npm run build
8
+ export LIGHTFLOW_PG_URL=postgres://user:***@localhost:5432/lightflow
9
+ node dist/test/bench.js # short-workflow throughput
10
+ node dist/test/bench-long.mjs # long-workflow throughput (RN=<steps>)
11
+ node --test dist/test/race.js # concurrency correctness
12
+ ```
13
+
14
+ Benchmarks live in [`test/bench.ts`](test/bench.ts) and
15
+ [`test/bench-long.mjs`](test/bench-long.mjs). SQL-statement counts come from
16
+ `pg_stat_statements`.
17
+
18
+ ---
19
+
20
+ ## v0.1.0 — baseline
21
+
22
+ The starting point: advisory-locked, statement-per-concern persistence.
23
+
24
+ | Metric | Value |
25
+ |---|---|
26
+ | Runs / sec (200 runs × 5 steps, conc 20) | **184** |
27
+ | Steps / sec | **922** |
28
+ | SQL statements per event append | 5 (BEGIN, advisory lock, max(seq), INSERT, COMMIT) |
29
+ | Advisory lock avg time in hot path | **7.8 ms** (40× every other query) |
30
+ | `writable.write` | re-fetches full event log per write |
31
+ | Racing resumes | can double-execute a memoized step (latent) |
32
+ | `wakeAt` timer index | present but **silently unused** (text/bigint cast mismatch) |
33
+ | Worker timer wake | ~4 queries + full event-log fetch per due timer |
34
+ | Replay memo lookup | O(n) linear scan → O(n²) replay |
35
+
36
+ ---
37
+
38
+ ## v0.1.1 — event-append round
39
+
40
+ **Focus: per-event write cost.** Profiled with `pg_stat_statements`, then:
41
+
42
+ - `appendEvent` fast path: single `INSERT … ON CONFLICT DO NOTHING` (caller
43
+ supplies seq; the `(run_id, seq)` unique constraint arbitrates races).
44
+ Advisory lock only on rare collisions.
45
+ - **Run lease** (`claimed_until`): atomic claim/release around each replay —
46
+ concurrent `resume()` calls can no longer re-execute a memoized step.
47
+ - Dropped `step_started` events (replay never reads them).
48
+ - `writable.write` uses the in-memory replay log, not a re-fetch.
49
+ - `createRun`: single CTE round trip. `waitFor`: adaptive poll 5→250 ms.
50
+
51
+ | Metric | v0.1.0 | v0.1.1 | Δ |
52
+ |---|---|---|---|
53
+ | Runs / sec (200×5, conc 20) | 184 | **456–556** | **~2.7×** |
54
+ | Steps / sec | 922 | **~2,300–2,800** | **~2.7×** |
55
+ | SQL per event append | 5 | 1 | −80% |
56
+ | Advisory lock in hot path | every event | removed | — |
57
+ | Double-execution under 20 racing resumes | possible | **0 in 200 races** | fixed |
58
+
59
+ Sustained (500 runs × 10 steps, conc 100): **445 runs/s, 4,450 steps/s**.
60
+
61
+ ---
62
+
63
+ ## v0.1.2 — replay & timer round
64
+
65
+ **Focus: replay cost and timer dispatch.** Researched Temporal's "replay
66
+ debt", PgQue's notify pattern, and Absurd's minimal-query design, then:
67
+
68
+ - **O(1) replay memo**: event log indexed once into a `Map` at replay start.
69
+ - **Fixed the timer index**: expression index on `((payload->>'wakeAt')::bigint)`.
70
+ - **`dueTimers` anti-join** against `sleep_completed` (returns key too) —
71
+ worker completes a timer in 2 queries instead of ~4 + full-log fetch.
72
+ - **Adaptive worker poll** (50 ms → configured, backs off when idle) plus an
73
+ optional LISTEN/NOTIFY wakeup nudge (lossy; polling remains authoritative).
74
+
75
+ | Metric | v0.1.1 | v0.1.2 | Δ |
76
+ |---|---|---|---|
77
+ | Steps / sec, 400-step workflows (50 runs) | 8,230 | **9,070** | +10% |
78
+ | Runs / sec, 100-step workflows (50 runs) | — | 65 (6,470 steps/s) | new |
79
+ | Queries per timer wake | ~4 + full log fetch | 2 | −60%+ |
80
+ | Replay memo lookup | O(n) | O(1) | — |
81
+ | Worker idle poll floor | 500 ms fixed | 50 ms adaptive + notify | — |
82
+
83
+ ---
84
+
85
+ ## v0.1.3 — compaction & client round
86
+
87
+ **Focus: replay-from-scratch cost, client overhead, polling waste.**
88
+
89
+ - **Snapshot compaction** (the Temporal `ContinueAsNew` problem, designed out):
90
+ every 200 completed steps, memoized state folds into a single `snapshot`
91
+ event; replay resumes from the latest snapshot instead of seq 0. A 700-step
92
+ run mid-way through sleeps and resumes correctly through snapshots.
93
+ - **pg pipelining** (`pg >= 8.23`, one option): queries batch per connection —
94
+ biggest wins under concurrency and on networked Postgres.
95
+ - **Zero-poll `returnValue`**: in-process callers await a deferred promise;
96
+ the DB is only polled by cross-process callers (adaptive 5→250 ms).
97
+
98
+ | Metric | v0.1.2 | v0.1.3 |
99
+ |---|---|---|
100
+ | Runs / sec, 500×10 @ conc 100 | — | **497** |
101
+ | Steps / sec, 400-step workflows | 9,070 | 8,956–10,658* |
102
+ | Steps / sec, 600-step workflows | — | 10,055 |
103
+ | `returnValue` DB polls (same process) | every 5–250 ms | **0** |
104
+ | Replay cost for an old run | O(total events) | O(events since snapshot) |
105
+
106
+ \* range across runs; the long-workflow number varies with how many
107
+ snapshots land before each resume.
108
+
109
+ ---
110
+
111
+ ## v0.1.4 — round-trip & fsync round
112
+
113
+ **Focus: query-count per run and the commit durability dial.**
114
+
115
+ - **Merged control round trips**: `claimRun` returns the cancelled flag with
116
+ the lease; new `finishRun` writes terminal status and releases the lease in
117
+ one UPDATE. Control queries per run: 6 → 4.
118
+ - **Pipelined step appends**: consecutive `step_completed` events share one
119
+ flush (buffered in `ctx.inflight`, awaited at suspension/terminal/snapshot
120
+ boundaries). Verified: 700-step sleep-resume path stays correct.
121
+ - **Opt-in async commit** (`sessionOptions: { synchronous_commit: 'off' }`):
122
+ documented tradeoff — the last few transactions may be lost on a server
123
+ crash (never corruption). Off by default; durability remains the default.
124
+
125
+ | Metric | v0.1.3 | v0.1.4 | Δ |
126
+ |---|---|---|---|
127
+ | Runs / sec (200×5, conc 20) | 437–480 | **502–512** | +8% |
128
+ | Runs / sec (500×10, conc 100) | 497 | **503–512** | +2% |
129
+ | Steps / sec (400-step) | 8,956 | **9,017–9,939** | +5% |
130
+ | Steps / sec (400-step, async commit opt-in) | — | **11,004** | +23% vs durable |
131
+ | Control queries per run | 6 | 4 | −33% |
132
+
133
+ ---
134
+
135
+ ## Reproducing
136
+
137
+ `test/bench.ts` honours `BENCH_N` (runs), `BENCH_M` (steps per run),
138
+ `BENCH_CONC` (in-flight). `bench-long.mjs` honours `RN` (steps) and runs 50
139
+ sequential-ish workflows through `start()` + terminal-poll.
140
+
141
+ Every version's table above was produced on the same machine and database,
142
+ so cross-version rows are directly comparable.
143
+
144
+ ## v0.2.0 — compat layer (perf-neutral)
145
+
146
+ Focus: Vercel Workflow drop-in compat (`lightflow-engine/compat/*`).
147
+ No perf regression vs v0.1.4 — two real bugs found and fixed along the way:
148
+ post-resume chunk loss (chunk keys now include the durable-timer position)
149
+ and chunks vanishing from streams after snapshot compaction (live chunks
150
+ now fold into snapshots).
151
+
152
+ | Scenario | v0.1.4 | v0.2.0 |
153
+ |---|---|---|
154
+ | 200 runs x 5 steps, conc 20 (runs/s) | 502–512 | 504.5 |
155
+ | 50 runs x 400 steps durable (steps/s) | 9,939 | 9,062–10,600 |
156
+
157
+ Verdict: perf-neutral within noise. Compat test (7/7) exercises the full
158
+ entry-agents surface end-to-end.
package/README.md CHANGED
@@ -149,6 +149,27 @@ Kill-and-resume is a first-class path, not an edge case. The engine includes a
149
149
  **reaper** that resumes runs wedged in `running` — a failure mode we hit in
150
150
  other engines and designed out here.
151
151
 
152
+ ## Vercel Workflow drop-in compat
153
+
154
+ lightflow ships a compatibility layer mirroring the exact Vercel Workflow
155
+ API surface (`start(fn, args)`, sync `getRun()`, `run.status` Promise,
156
+ `run.getReadable({ startIndex })` + `getTailIndex()`, `run.cancel()`,
157
+ `run.returnValue`, `getWritable()` as a web `WritableStream`,
158
+ `getWorkflowMetadata()`, `sleep(Date)`, `FatalError`, `withWorkflow`).
159
+ See [COMPAT.md](./COMPAT.md) for the 3-step swap guide and honest
160
+ differences list.
161
+
162
+ ```ts
163
+ import { createPostgresStore } from "lightflow-engine/pg";
164
+ import { Engine } from "lightflow-engine";
165
+ import { initWorkflowApi } from "lightflow-engine/compat/api";
166
+
167
+ const store = await createPostgresStore(process.env.LIGHTFLOW_PG_URL!);
168
+ const engine = new Engine(store, { pollMs: 50 });
169
+ await engine.startWorker();
170
+ initWorkflowApi(store, engine);
171
+ ```
172
+
152
173
  ## Benchmarks
153
174
 
154
175
  Full before/after numbers for every version live in [BENCHMARKS.md](BENCHMARKS.md).
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Vercel Workflow compat — `workflow/api` module surface.
3
+ *
4
+ * Differences from our core Engine API:
5
+ * - start(fn, args): takes the workflow FUNCTION (not an id); returns the
6
+ * run handle directly (not a promise).
7
+ * - getRun(runId): returns the handle synchronously; `status` is a Promise;
8
+ * status includes "pending" and "cancelled".
9
+ * - getReadable({ startIndex }): chunk-indexed resumable stream with
10
+ * getTailIndex().
11
+ * - run.cancel(), run.returnValue.
12
+ */
13
+ import { Engine, registerWorkflow } from "../index.js";
14
+ import { workflowIdFor } from "./workflow.js";
15
+ let sharedEngine = null;
16
+ let sharedStore = null;
17
+ /** Configure the compat layer with a store/engine (call once at boot). */
18
+ export function initWorkflowApi(store, engine) {
19
+ sharedStore = store;
20
+ sharedEngine = engine ?? new Engine(store);
21
+ }
22
+ export function getEngine() {
23
+ if (!sharedEngine) {
24
+ throw new Error("workflow/api not initialized — call initWorkflowApi(store) at startup");
25
+ }
26
+ return sharedEngine;
27
+ }
28
+ /**
29
+ * Register a workflow function under a stable id and start it. Mirrors
30
+ * Vercel's start(workflowFn, args).
31
+ */
32
+ export async function start(fn, args) {
33
+ const engine = getEngine();
34
+ const id = workflowIdFor(fn);
35
+ registerWorkflow(id, fn);
36
+ const { runId } = await engine.start(id, args);
37
+ return makeRunHandle(runId);
38
+ }
39
+ export function getRun(runId) {
40
+ return makeRunHandle(runId);
41
+ }
42
+ function makeRunHandle(runId) {
43
+ const engine = getEngine();
44
+ const store = sharedStore;
45
+ let cachedStatus = null;
46
+ const statusPromise = (async () => {
47
+ if (cachedStatus)
48
+ return cachedStatus;
49
+ const deadline = Date.now() + 10 * 60_000;
50
+ let delay = 5;
51
+ let seenRow = false;
52
+ while (Date.now() < deadline) {
53
+ const row = await store.getRun(runId);
54
+ if (row) {
55
+ seenRow = true;
56
+ if (row.status === "completed") {
57
+ cachedStatus = "completed";
58
+ return cachedStatus;
59
+ }
60
+ if (row.status === "failed") {
61
+ // cancelled runs are 'failed' with error 'cancelled'
62
+ cachedStatus =
63
+ row.output?.error === "cancelled"
64
+ ? "cancelled" : "failed";
65
+ return cachedStatus;
66
+ }
67
+ }
68
+ await new Promise((r) => setTimeout(r, delay));
69
+ delay = seenRow ? Math.min(delay * 1.6, 250) : 5;
70
+ }
71
+ throw new Error(`run status timeout: ${runId}`);
72
+ })();
73
+ const handle = {
74
+ runId,
75
+ status: statusPromise,
76
+ returnValue: (async () => (await engine.getRun(runId)).returnValue)(),
77
+ getReadable(opts) {
78
+ const startIndex = opts?.startIndex ?? 0;
79
+ let sent = startIndex;
80
+ let closed = false;
81
+ const collectChunks = async () => {
82
+ // Chunks can live in two places: the latest snapshot memo
83
+ // (compaction folds pre-snapshot chunks) and as raw events after
84
+ // it. Merge, dedupe by key, and order by chunk index.
85
+ const events = await store.getEvents(runId);
86
+ const byKey = new Map();
87
+ for (const e of events) {
88
+ if (e.type === "snapshot") {
89
+ const memo = e.payload.memo ?? {};
90
+ for (const [k, v] of Object.entries(memo)) {
91
+ if (!k.startsWith("chunk:"))
92
+ continue;
93
+ const p = v;
94
+ byKey.set(p.key, p);
95
+ }
96
+ }
97
+ else if (e.type === "chunk") {
98
+ const p = e.payload;
99
+ byKey.set(p.key, p);
100
+ }
101
+ }
102
+ return [...byKey.values()].sort((a, b) => a.index - b.index);
103
+ };
104
+ const stream = new ReadableStream({
105
+ async pull(controller) {
106
+ if (closed) {
107
+ controller.close();
108
+ return;
109
+ }
110
+ const chunks = await collectChunks();
111
+ const hasDone = chunks.some((c) => c.done);
112
+ // chunks carry a monotonic `index`; entries after startIndex
113
+ for (const c of chunks) {
114
+ const p = c;
115
+ if (p.done) {
116
+ closed = true;
117
+ controller.close();
118
+ return;
119
+ }
120
+ if (p.index < startIndex)
121
+ continue;
122
+ if (p.index >= sent) {
123
+ controller.enqueue(p.value);
124
+ sent = p.index + 1;
125
+ }
126
+ }
127
+ if (hasDone) {
128
+ closed = true;
129
+ controller.close();
130
+ return;
131
+ }
132
+ const row = await store.getRun(runId);
133
+ if (row && row.status !== "running") {
134
+ closed = true;
135
+ controller.close();
136
+ }
137
+ },
138
+ });
139
+ stream.getTailIndex = async () => {
140
+ const chunks = await collectChunks();
141
+ let max = -1;
142
+ for (const c of chunks) {
143
+ if (c.done)
144
+ return max;
145
+ if (typeof c.index === "number" && c.index > max)
146
+ max = c.index;
147
+ }
148
+ return max;
149
+ };
150
+ return stream;
151
+ },
152
+ async cancel() {
153
+ await (await engine.getRun(runId)).cancel();
154
+ },
155
+ };
156
+ return handle;
157
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Durable fetch: memoized per step position, like any other side effect.
3
+ * Mirrors Vercel's workflow fetch(): Response-shaped result.
4
+ */
5
+ import { step } from "../index.js";
6
+ export async function workflowFetch(input, init) {
7
+ return step(async () => {
8
+ const res = await fetch(input, init);
9
+ return {
10
+ __lfResponse: true,
11
+ status: res.status,
12
+ headers: Object.fromEntries(res.headers.entries()),
13
+ body: await res.text(),
14
+ };
15
+ });
16
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Vercel Workflow compat: getWorkflowMetadata() returns { workflowRunId }.
3
+ */
4
+ import { current } from "../index.js";
5
+ export function getWorkflowMetadata() {
6
+ if (!current)
7
+ throw new Error("getWorkflowMetadata() outside a workflow");
8
+ return { workflowRunId: current.runId };
9
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Vercel Workflow compat — `workflow/next` surface.
3
+ *
4
+ * Vercel's withWorkflow wraps the Next.js config to enable the directive
5
+ * compiler ("use workflow" / "use step"). lightflow's runtime does not need
6
+ * a bundler transform: directives are inert strings, workflow functions are
7
+ * registered explicitly by start(), and "use step" functions can be wrapped
8
+ * with step() from compat/workflow for durability.
9
+ *
10
+ * This drop-in is an identity wrapper so next.config.ts keeps working
11
+ * unchanged:
12
+ * import { withWorkflow } from "workflow/next";
13
+ * + import { withWorkflow } from "lightflow-engine/compat/next";
14
+ */
15
+ export function withWorkflow(config) {
16
+ // Directives are inert at runtime; nothing to compile. Accepts and
17
+ // returns the config untouched so existing call sites compose the same.
18
+ return config;
19
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Vercel Workflow compatibility layer — `workflow` module surface.
3
+ *
4
+ * Drop-in for entry-agents-style code: same named exports, same shapes.
5
+ * Directive notes:
6
+ * - "use workflow" / "use step" are inert strings at runtime. This layer
7
+ * makes directly-called "use step" functions durable via the companion
8
+ * webpack loader (workflow/next) or explicit step() wrappers.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { current } from "../index.js";
12
+ export { FatalError, CancelledError, sleep } from "../index.js";
13
+ export { getWorkflowMetadata } from "../compat/metadata.js";
14
+ export { workflowFetch } from "../compat/fetch.js";
15
+ /**
16
+ * Vercel's getWritable returns a web-standard WritableStream whose writes
17
+ * are durable chunk events. Ours maps 1:1: every writer.write(chunk) is a
18
+ * memoized chunk event keyed by call position; writer.close() emits the
19
+ * terminal done marker.
20
+ */
21
+ export function getWritable() {
22
+ const ctx = current;
23
+ if (!ctx)
24
+ throw new Error("getWritable() outside a workflow");
25
+ const underlying = {
26
+ async write(chunk) {
27
+ // Mirror of engine getWritable().write but through ctx.append.
28
+ // Key includes the durable-timer position so a post-resume write
29
+ // can never collide with (and be skipped against) a pre-timer chunk.
30
+ const key = `w:${ctx.sleepCalls}:${ctx.writes}`;
31
+ const already = ctx.memo.has(`chunk:${key}`);
32
+ ctx.writes += 1;
33
+ if (already)
34
+ return;
35
+ const index = await ctx.store.nextChunkIndex(ctx.runId);
36
+ ctx.seq += 1;
37
+ const payload = { value: chunk, index, key };
38
+ ctx.append({
39
+ runId: ctx.runId, seq: ctx.seq, type: "chunk",
40
+ payload, createdAt: ctx.now(),
41
+ });
42
+ // Record in the live memo so snapshot compaction folds it.
43
+ ctx.memo.set(`chunk:${key}`, { runId: ctx.runId, seq: ctx.seq,
44
+ type: "chunk", payload, createdAt: ctx.now() });
45
+ },
46
+ async close() {
47
+ const already = [...ctx.memo.values()].some((e) => e.type === "chunk" && e.payload?.done === true);
48
+ if (already)
49
+ return;
50
+ ctx.seq += 1;
51
+ ctx.append({
52
+ runId: ctx.runId, seq: ctx.seq, type: "chunk",
53
+ payload: { value: null, done: true, key: "close", index: ctx.writes },
54
+ createdAt: ctx.now(),
55
+ });
56
+ },
57
+ };
58
+ return new WritableStream({
59
+ async write(chunk) { await underlying.write(chunk); },
60
+ async close() { await underlying.close(); },
61
+ async abort() { },
62
+ });
63
+ }
64
+ /** Auto-registration id for a workflow function (stable per code site). */
65
+ export function workflowIdFor(fn) {
66
+ const named = fn.name;
67
+ if (named && named !== "anonymous")
68
+ return named;
69
+ return "wf_" + createHash("sha256")
70
+ .update(String(fn).slice(0, 512)).digest("hex").slice(0, 16);
71
+ }
package/dist/src/index.js CHANGED
@@ -34,7 +34,7 @@ export function registerWorkflow(id, fn) {
34
34
  export function registerStep(id, fn) {
35
35
  steps.set(id, fn);
36
36
  }
37
- let current = null;
37
+ export let current = null;
38
38
  export function getWorkflowMetadata() {
39
39
  if (!current)
40
40
  throw new Error("getWorkflowMetadata() outside a workflow");
@@ -104,7 +104,7 @@ export async function step(fn) {
104
104
  }
105
105
  ctx.seq += 1;
106
106
  ctx.freshResults.set(key, value);
107
- await ctx.store.appendEvent({
107
+ ctx.append({
108
108
  runId: ctx.runId, seq: ctx.seq, type: "step_completed",
109
109
  payload: { key, value }, createdAt: ctx.now(),
110
110
  });
@@ -120,6 +120,7 @@ const SNAPSHOT_MIN_EVENTS = 100;
120
120
  * costing replay time and can be pruned later without breaking resumes.
121
121
  */
122
122
  async function maybeSnapshot(ctx) {
123
+ await Promise.all(ctx.inflight);
123
124
  ctx.completedNow += 1;
124
125
  const done = ctx.completedNow +
125
126
  [...ctx.memo.values()].filter((e) => e.type === "step_completed").length;
@@ -180,13 +181,12 @@ export function getWritable() {
180
181
  const ctx = current;
181
182
  if (!current)
182
183
  throw new Error("getWritable() outside a workflow");
183
- /** Deterministic key: (durable-timer position, writes since it). */
184
- const keyFor = () => `${ctx.sleepCalls}:${ctx.writes}`;
185
184
  return {
186
185
  async write(chunk) {
187
- // Memoized by call position (like steps): on replay, a write whose
188
- // position was already emitted is skipped, and the loop advances.
189
- const key = `w:${ctx.writes}`;
186
+ // Memoized by deterministic call position: (sleep position, writes
187
+ // since it). sleepCalls survives a resume so a post-timer write can
188
+ // never collide with (and be skipped against) a pre-timer chunk.
189
+ const key = `w:${ctx.sleepCalls}:${ctx.writes}`;
190
190
  // Use the replay memo — no per-write re-fetch of the event log.
191
191
  const already = ctx.memo.has(`chunk:${key}`);
192
192
  ctx.writes += 1;
@@ -194,10 +194,14 @@ export function getWritable() {
194
194
  return;
195
195
  const index = await ctx.store.nextChunkIndex(ctx.runId);
196
196
  ctx.seq += 1;
197
+ const payload = { value: chunk, index, key };
197
198
  await ctx.store.appendEvent({
198
199
  runId: ctx.runId, seq: ctx.seq, type: "chunk",
199
- payload: { value: chunk, index, key }, createdAt: ctx.now(),
200
+ payload, createdAt: ctx.now(),
200
201
  });
202
+ // Record in the live memo so snapshot compaction folds it.
203
+ ctx.memo.set(`chunk:${key}`, { runId: ctx.runId, seq: ctx.seq,
204
+ type: "chunk", payload, createdAt: ctx.now() });
201
205
  },
202
206
  async close() {
203
207
  const already = [...ctx.memo.values()].some((e) => e.type === "chunk" && e.payload?.done === true);
@@ -307,8 +311,13 @@ export class Engine {
307
311
  // by the winner, and any newer events it would have seen are picked up
308
312
  // by the next resume.
309
313
  const claimer = this.store;
310
- if (claimer.claimRun && !(await claimer.claimRun(runId)))
314
+ const claim = await claimer.claimRun?.(runId);
315
+ if (claim && !claim.ok)
316
+ return;
317
+ if (claim?.cancelled) {
318
+ await this.store.setStatus(runId, "failed", { error: "cancelled" });
311
319
  return;
320
+ }
312
321
  const log = await this.store.getEvents(runId);
313
322
  // Memo index: one pass over the log, O(1) lookups during replay.
314
323
  // A leading 'snapshot' event pre-fills completed step/sleep/chunk state.
@@ -340,26 +349,36 @@ export class Engine {
340
349
  runId, seq: log.length ? log.reduce((m, e) => Math.max(m, e.seq), 0) + 1 : 1,
341
350
  stepCalls: 0, sleepCalls: 0, hookCalls: 0,
342
351
  writes: 0,
343
- store: this.store, log, memo, completedNow: 0, freshResults: new Map(), cursor: 0, chunks: [], now: () => Date.now(),
352
+ store: this.store, log, memo, completedNow: 0, freshResults: new Map(),
353
+ inflight: [], cursor: 0, chunks: [], now: () => Date.now(),
354
+ append: (e) => {
355
+ // Pipelined append: capture the promise; callers only await at
356
+ // suspension/end, so several step completions share one flush.
357
+ const p = ctx.store.appendEvent(e).then(() => { ctx.inflight = ctx.inflight.filter(x => x !== p); });
358
+ ctx.inflight.push(p);
359
+ return p;
360
+ },
344
361
  };
345
362
  const prev = current;
346
363
  current = ctx;
347
364
  try {
348
- if (await this.store.isCancelled?.(runId)) {
349
- await this.store.setStatus(runId, "failed", { error: "cancelled" });
350
- return;
351
- }
352
365
  const output = await fn(...args);
353
- await this.store.setStatus(runId, "completed", output);
366
+ await Promise.all(ctx.inflight);
367
+ await (this.store.finishRun?.(runId, "completed", output)
368
+ ?? this.store.setStatus(runId, "completed", output));
354
369
  this.local.get(runId)?.resolve(output);
355
370
  }
356
371
  catch (err) {
357
- if (err instanceof SuspendSignal)
372
+ if (err instanceof SuspendSignal) {
373
+ await Promise.all(ctx.inflight);
358
374
  return; // waiting on a timer
375
+ }
359
376
  const fail = async (error) => {
360
- await this.store.setStatus(runId, "failed", {
377
+ await (this.store.finishRun?.(runId, "failed", {
378
+ error: error instanceof Error ? error.message : String(error),
379
+ }) ?? this.store.setStatus(runId, "failed", {
361
380
  error: error instanceof Error ? error.message : String(error),
362
- });
381
+ }));
363
382
  this.local.get(runId)?.reject(err instanceof Error ? err : new Error(String(err)));
364
383
  };
365
384
  if (err instanceof CancelledError)
@@ -5,10 +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) {
8
+ export async function createPostgresStore(url, opts = {}) {
9
9
  // Pipelining (pg >= 8.23): batch queries on one connection instead of one
10
10
  // round trip per query — 1.5-2.4x on multi-query workloads.
11
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
+ }
12
21
  await pool.query(`
13
22
  CREATE TABLE IF NOT EXISTS lightflow_runs (
14
23
  run_id TEXT PRIMARY KEY,
@@ -208,14 +217,21 @@ export async function createPostgresStore(url) {
208
217
  async claimDue() { return false; },
209
218
  async claimRun(runId, leaseMs = 30_000) {
210
219
  const now = Date.now();
220
+ // One round trip: claim the lease AND read the cancelled flag.
211
221
  const r = await pool.query(`UPDATE lightflow_runs SET claimed_until=$2, updated_at=$3
212
222
  WHERE run_id=$1 AND claimed_until < $3
213
- RETURNING run_id`, [runId, now + leaseMs, now]);
214
- 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 };
215
225
  },
216
226
  async releaseRun(runId) {
217
227
  await pool.query(`UPDATE lightflow_runs SET claimed_until=0 WHERE run_id=$1 AND claimed_until > 0`, [runId]);
218
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
+ },
219
235
  /** LISTEN/NOTIFY wakeup support: re-poll immediately on notify. */
220
236
  async notifyWake() {
221
237
  await pool.query(`NOTIFY lightflow_wake`);
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Drop-in verification: the exact Vercel Workflow surface entry-agents uses,
3
+ * exercised against lightflow's compat layer:
4
+ * start(fn, args) / getRun(id) / run.status (Promise) / run.runId /
5
+ * run.returnValue / run.cancel() / run.getReadable({startIndex}) /
6
+ * getTailIndex() / getWritable() as WritableStream / getWorkflowMetadata()
7
+ * -> { workflowRunId } / sleep(Date) / FatalError.
8
+ */
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import pg from "pg";
12
+ import { Engine, step, sleep, FatalError } from "../src/index.js";
13
+ import { createPostgresStore } from "../src/pg-store.js";
14
+ import { initWorkflowApi, start } from "../src/compat/api.js";
15
+ import { getWritable, getWorkflowMetadata } from "../src/compat/workflow.js";
16
+ const PG_URL = process.env.LIGHTFLOW_PG_URL;
17
+ if (!PG_URL)
18
+ throw new Error("LIGHTFLOW_PG_URL is required");
19
+ test("compat: vercel-style agent chat workflow end-to-end", async () => {
20
+ const store = await createPostgresStore(PG_URL);
21
+ const engine = new Engine(store, { pollMs: 25 });
22
+ initWorkflowApi(store, engine);
23
+ const workerPromise = engine.startWorker();
24
+ setTimeout(() => engine.stopWorker(), 60_000);
25
+ const pool = new pg.Pool({ connectionString: PG_URL });
26
+ // A workflow shaped like entry-agents' runAgentWorkflow:
27
+ // - "use step"-style direct calls via step()
28
+ // - getWritable() as a real WritableStream, written via getWriter()
29
+ // - getWorkflowMetadata() -> { workflowRunId }
30
+ // - sleep(new Date(...)) for durable timers
31
+ // - FatalError for non-retryable failures
32
+ const runAgentWorkflow = async (opts) => {
33
+ "use workflow";
34
+ const { workflowRunId } = getWorkflowMetadata();
35
+ const writable = getWritable();
36
+ const writer = writable.getWriter();
37
+ await step(async () => {
38
+ await writer.write(`start:${opts.chatId}`);
39
+ });
40
+ const answer = await step(async () => `reply-to:${opts.messages.join(",")}`);
41
+ // mid-run durable timer like sandbox-lifecycle
42
+ await sleep(new Date(Date.now() + 50));
43
+ await step(async () => { await writer.write(`chunk:${answer}`); });
44
+ await step(async () => { await writer.write("done-marker"); });
45
+ await writer.close();
46
+ if (opts.messages.includes("boom"))
47
+ throw new FatalError("nope");
48
+ return { workflowRunId, answer };
49
+ };
50
+ // Vercel-style start(fn, args) — function, not id
51
+ const run = await start(runAgentWorkflow, [
52
+ { messages: ["hello", "world"], chatId: "c1" },
53
+ ]);
54
+ assert.ok(run.runId.startsWith("lrun_"));
55
+ // status is a Promise, resolvable to a terminal state
56
+ const status = await run.status;
57
+ assert.equal(status, "completed");
58
+ const ret = (await run.returnValue);
59
+ assert.equal(ret.answer, "reply-to:hello,world");
60
+ assert.equal(ret.workflowRunId, run.runId);
61
+ // getReadable() with startIndex + getTailIndex
62
+ const readable = run.getReadable({ startIndex: 0 });
63
+ const tail = await readable.getTailIndex();
64
+ const parts = [];
65
+ const reader = readable.getReader();
66
+ for (;;) {
67
+ const { done, value } = await reader.read();
68
+ if (done)
69
+ break;
70
+ parts.push(value);
71
+ }
72
+ assert.deepEqual(parts, ["start:c1", "chunk:reply-to:hello,world", "done-marker"]);
73
+ assert.ok(tail >= 1);
74
+ // resume from startIndex: skipping the first chunk
75
+ const resumed = run.getReadable({ startIndex: 1 });
76
+ const rreader = resumed.getReader();
77
+ const r1 = await rreader.read();
78
+ assert.equal(r1.value, "chunk:reply-to:hello,world");
79
+ // cancel semantics: getRun + status + cancel()
80
+ const dup = await start(runAgentWorkflow, [{ messages: ["x"], chatId: "c2" }]);
81
+ await dup.cancel();
82
+ assert.equal(await dup.status, "cancelled");
83
+ await pool.end();
84
+ engine.stopWorker();
85
+ await workerPromise;
86
+ });
@@ -0,0 +1,28 @@
1
+
2
+ import { Engine, step, sleep, registerWorkflow } from "/home/agentuser/lightflow/dist/src/index.js";
3
+ import { createPostgresStore } from "/home/agentuser/lightflow/dist/src/pg-store.js";
4
+ import { getWritable } from "/home/agentuser/lightflow/dist/src/compat/workflow.js";
5
+ import { initWorkflowApi, start } from "/home/agentuser/lightflow/dist/src/compat/api.js";
6
+ import pg from "pg";
7
+ const url = process.env.LIGHTFLOW_PG_URL;
8
+ const store = await createPostgresStore(url);
9
+ const engine = new Engine(store, { pollMs: 25 });
10
+ initWorkflowApi(store, engine);
11
+ const wp = engine.startWorker();
12
+ setTimeout(() => engine.stopWorker(), 30000);
13
+ const wf = async () => {
14
+ const wr = getWritable();
15
+ const writer = wr.getWriter();
16
+ await step(async () => { await writer.write("A"); });
17
+ await sleep(new Date(Date.now() + 50));
18
+ await step(async () => { await writer.write("B"); });
19
+ await step(async () => { await writer.write("C"); });
20
+ await writer.close();
21
+ return "ok";
22
+ };
23
+ const run = await start(wf, []);
24
+ console.log("status:", await run.status);
25
+ const pool = new pg.Pool({ connectionString: url });
26
+ const rows = await pool.query("SELECT seq, payload->>'key' k, payload->>'value' v FROM lightflow_events WHERE type='chunk' ORDER BY seq");
27
+ console.log(rows.rows.map(r=>`${r.k}=${r.v}`).join(" | "));
28
+ await pool.end(); process.exit(0);
@@ -0,0 +1,34 @@
1
+
2
+ import { Engine, step, sleep, registerWorkflow } from "/home/agentuser/lightflow/dist/src/index.js";
3
+ import { createPostgresStore } from "/home/agentuser/lightflow/dist/src/pg-store.js";
4
+ import { getWritable } from "/home/agentuser/lightflow/dist/src/compat/workflow.js";
5
+ import { initWorkflowApi, start } from "/home/agentuser/lightflow/dist/src/compat/api.js";
6
+ const store = await createPostgresStore(process.env.LIGHTFLOW_PG_URL);
7
+ const engine = new Engine(store, { pollMs: 25 });
8
+ initWorkflowApi(store, engine);
9
+ const wp = engine.startWorker();
10
+ setTimeout(() => engine.stopWorker(), 60000);
11
+ const wf = async () => {
12
+ const writer = getWritable().getWriter();
13
+ for (let i = 0; i < 205; i++) {
14
+ await step(async () => { await writer.write("c" + i); });
15
+ if (i === 100) await sleep(new Date(Date.now() + 40));
16
+ }
17
+ await writer.close();
18
+ return "done";
19
+ };
20
+ const run = await start(wf, []);
21
+ console.log("status:", await run.status);
22
+ const readable = run.getReadable({ startIndex: 0 });
23
+ const reader = readable.getReader();
24
+ const parts = [];
25
+ for (;;) { const { done, value } = await reader.read(); if (done) break; parts.push(value); }
26
+ const missing = []; for (let i=0;i<205;i++) if (!parts.includes("c"+i)) missing.push(i); console.log("chunks:", parts.length, "nmissing:", missing.length, "m0:", missing[0], "mLast:", missing[missing.length-1]);
27
+ console.log("tailIndex:", await readable.getTailIndex());
28
+ // resume from index 200
29
+ const r2 = run.getReadable({ startIndex: 200 });
30
+ const rd2 = r2.getReader();
31
+ const p2 = [];
32
+ for (;;) { const { done, value } = await rd2.read(); if (done) break; p2.push(value); }
33
+ console.log("resumed-from-200:", p2.length, p2[0], p2.at(-1));
34
+ process.exit(0);
package/package.json CHANGED
@@ -1,19 +1,23 @@
1
1
  {
2
2
  "name": "lightflow-engine",
3
- "version": "0.1.3",
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.",
3
+ "version": "0.2.0",
4
+ "description": "A tiny durable workflow engine for Node.js and Postgres \u2014 with a Vercel Workflow drop-in compat layer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",
9
9
  "exports": {
10
10
  ".": "./dist/index.js",
11
- "./pg": "./dist/pg-store.js"
11
+ "./pg": "./dist/pg-store.js",
12
+ "./compat/workflow": "./dist/compat/workflow.js",
13
+ "./compat/api": "./dist/compat/api.js",
14
+ "./compat/next": "./dist/compat/next.js"
12
15
  },
13
16
  "files": [
14
17
  "dist",
15
18
  "README.md",
16
- "LICENSE"
19
+ "LICENSE",
20
+ "BENCHMARKS.md"
17
21
  ],
18
22
  "keywords": [
19
23
  "workflow",