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 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
- 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;
@@ -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
- 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" });
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(), 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
+ },
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 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));
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.setStatus(runId, "failed", {
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)
@@ -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`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightflow-engine",
3
- "version": "0.1.3",
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",