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 +4 -62
- package/dist/src/index.js +110 -22
- package/dist/src/pg-store.js +43 -4
- 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,50 @@ export async function step(fn) {
|
|
|
103
103
|
throw lastErr;
|
|
104
104
|
}
|
|
105
105
|
ctx.seq += 1;
|
|
106
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
296
|
-
|
|
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:
|
|
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);
|
package/dist/src/pg-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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`);
|
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.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",
|