lightflow-engine 0.1.3 → 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/dist/src/index.js +27 -11
- package/dist/src/pg-store.js +19 -3
- package/package.json +1 -1
package/dist/src/index.js
CHANGED
|
@@ -104,7 +104,7 @@ export async function step(fn) {
|
|
|
104
104
|
}
|
|
105
105
|
ctx.seq += 1;
|
|
106
106
|
ctx.freshResults.set(key, value);
|
|
107
|
-
|
|
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;
|
|
@@ -307,8 +308,13 @@ export class Engine {
|
|
|
307
308
|
// by the winner, and any newer events it would have seen are picked up
|
|
308
309
|
// by the next resume.
|
|
309
310
|
const claimer = this.store;
|
|
310
|
-
|
|
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" });
|
|
311
316
|
return;
|
|
317
|
+
}
|
|
312
318
|
const log = await this.store.getEvents(runId);
|
|
313
319
|
// Memo index: one pass over the log, O(1) lookups during replay.
|
|
314
320
|
// A leading 'snapshot' event pre-fills completed step/sleep/chunk state.
|
|
@@ -340,26 +346,36 @@ export class Engine {
|
|
|
340
346
|
runId, seq: log.length ? log.reduce((m, e) => Math.max(m, e.seq), 0) + 1 : 1,
|
|
341
347
|
stepCalls: 0, sleepCalls: 0, hookCalls: 0,
|
|
342
348
|
writes: 0,
|
|
343
|
-
store: this.store, log, memo, completedNow: 0, freshResults: new Map(),
|
|
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
|
+
},
|
|
344
358
|
};
|
|
345
359
|
const prev = current;
|
|
346
360
|
current = ctx;
|
|
347
361
|
try {
|
|
348
|
-
if (await this.store.isCancelled?.(runId)) {
|
|
349
|
-
await this.store.setStatus(runId, "failed", { error: "cancelled" });
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
362
|
const output = await fn(...args);
|
|
353
|
-
await
|
|
363
|
+
await Promise.all(ctx.inflight);
|
|
364
|
+
await (this.store.finishRun?.(runId, "completed", output)
|
|
365
|
+
?? this.store.setStatus(runId, "completed", output));
|
|
354
366
|
this.local.get(runId)?.resolve(output);
|
|
355
367
|
}
|
|
356
368
|
catch (err) {
|
|
357
|
-
if (err instanceof SuspendSignal)
|
|
369
|
+
if (err instanceof SuspendSignal) {
|
|
370
|
+
await Promise.all(ctx.inflight);
|
|
358
371
|
return; // waiting on a timer
|
|
372
|
+
}
|
|
359
373
|
const fail = async (error) => {
|
|
360
|
-
await this.store.
|
|
374
|
+
await (this.store.finishRun?.(runId, "failed", {
|
|
375
|
+
error: error instanceof Error ? error.message : String(error),
|
|
376
|
+
}) ?? this.store.setStatus(runId, "failed", {
|
|
361
377
|
error: error instanceof Error ? error.message : String(error),
|
|
362
|
-
});
|
|
378
|
+
}));
|
|
363
379
|
this.local.get(runId)?.reject(err instanceof Error ? err : new Error(String(err)));
|
|
364
380
|
};
|
|
365
381
|
if (err instanceof CancelledError)
|
package/dist/src/pg-store.js
CHANGED
|
@@ -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
|
|
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`);
|
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",
|