@dudousxd/nestjs-durable-transport-db 0.2.1 → 0.3.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/dist/index.js CHANGED
@@ -2,6 +2,7 @@ var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
3
 
4
4
  // src/db-transport.ts
5
+ import { randomUUID } from "node:crypto";
5
6
  import { runStepHandler } from "@dudousxd/nestjs-durable-core";
6
7
 
7
8
  // src/executors.ts
@@ -44,6 +45,12 @@ function mikroOrmExecutor(em) {
44
45
  __name(mikroOrmExecutor, "mikroOrmExecutor");
45
46
 
46
47
  // src/db-transport.ts
48
+ var RUNS_INSERT_CHUNK = 500;
49
+ var RUNS_CLAIM_MAX = 50;
50
+ function numberOption(value, fallback, min) {
51
+ return value !== void 0 && Number.isFinite(value) && value >= min ? value : fallback;
52
+ }
53
+ __name(numberOption, "numberOption");
47
54
  var DbTransport = class {
48
55
  static {
49
56
  __name(this, "DbTransport");
@@ -52,9 +59,13 @@ var DbTransport = class {
52
59
  group;
53
60
  tasksTable;
54
61
  resultsTable;
62
+ runsTable;
63
+ runConsumers = /* @__PURE__ */ new Set();
55
64
  pollMs;
56
65
  leaseMs;
57
66
  batchSize;
67
+ concurrency;
68
+ onError;
58
69
  autoCreate;
59
70
  instanceId;
60
71
  isPg;
@@ -63,17 +74,26 @@ var DbTransport = class {
63
74
  schemaReady;
64
75
  taskLoop;
65
76
  resultLoop;
77
+ /** Tasks running on this instance (at most `concurrency`). */
78
+ runningTasks = /* @__PURE__ */ new Set();
79
+ /** Claim tokens this instance holds, by table — every one is renewed on `renewTimer`. */
80
+ held = /* @__PURE__ */ new Map();
81
+ renewTimer;
82
+ renewal;
66
83
  constructor(options) {
67
84
  this.exec = options.executor;
68
85
  this.group = options.group;
69
86
  const prefix = options.prefix ?? "durable";
70
87
  this.tasksTable = `${prefix}_transport_tasks`;
71
88
  this.resultsTable = `${prefix}_transport_results`;
72
- this.pollMs = options.pollMs ?? 500;
73
- this.leaseMs = options.leaseMs ?? 3e4;
74
- this.batchSize = options.batchSize ?? 10;
89
+ this.runsTable = `${prefix}_transport_runs`;
90
+ this.pollMs = numberOption(options.pollMs, 500, 0);
91
+ this.leaseMs = numberOption(options.leaseMs, 3e4, 1);
92
+ this.batchSize = Math.floor(numberOption(options.batchSize, 10, 1));
93
+ this.concurrency = Math.floor(numberOption(options.concurrency, 1, 1));
94
+ this.onError = options.onError ?? ((err) => console.error("[DbTransport]", err));
75
95
  this.autoCreate = options.autoCreate ?? true;
76
- this.instanceId = options.instanceId ?? `db-${Math.floor(Date.now()).toString(36)}`;
96
+ this.instanceId = options.instanceId ?? `db-${randomUUID()}`;
77
97
  this.isPg = this.exec.dialect === "postgres";
78
98
  }
79
99
  // ── dialect helpers ──────────────────────────────────────────────────────────────────────────
@@ -89,16 +109,33 @@ var DbTransport = class {
89
109
  }
90
110
  // ── schema ───────────────────────────────────────────────────────────────────────────────────
91
111
  ensureSchema() {
92
- if (!this.schemaReady) this.schemaReady = this.createTables();
112
+ if (!this.schemaReady) {
113
+ this.schemaReady = this.createTables().catch((err) => {
114
+ this.schemaReady = void 0;
115
+ throw err;
116
+ });
117
+ }
93
118
  return this.schemaReady;
94
119
  }
120
+ /**
121
+ * Run one `CREATE … IF NOT EXISTS`. Two instances booting on a fresh database race it, and Postgres
122
+ * can reject the loser with a unique violation on its catalog even with `IF NOT EXISTS`; the
123
+ * statement is simply re-run, by which point the table exists and it is a no-op.
124
+ */
125
+ async createIfMissing(sql) {
126
+ try {
127
+ await this.exec.query(sql);
128
+ } catch {
129
+ await this.exec.query(sql);
130
+ }
131
+ }
95
132
  async createTables() {
96
133
  if (!this.autoCreate) return;
97
134
  const str = "varchar(191)";
98
135
  const txt = this.isPg ? "text" : "longtext";
99
136
  const tasks = this.q(this.tasksTable);
100
137
  const results = this.q(this.resultsTable);
101
- await this.exec.query(`CREATE TABLE IF NOT EXISTS ${tasks} (
138
+ await this.createIfMissing(`CREATE TABLE IF NOT EXISTS ${tasks} (
102
139
  ${this.q("step_id")} ${str} PRIMARY KEY,
103
140
  ${this.q("run_id")} ${str} NOT NULL,
104
141
  ${this.q("seq")} integer NOT NULL,
@@ -111,7 +148,7 @@ var DbTransport = class {
111
148
  ${this.q("claimed_at")} bigint,
112
149
  ${this.q("created_at")} bigint NOT NULL
113
150
  )`);
114
- await this.exec.query(`CREATE TABLE IF NOT EXISTS ${results} (
151
+ await this.createIfMissing(`CREATE TABLE IF NOT EXISTS ${results} (
115
152
  ${this.q("step_id")} ${str} PRIMARY KEY,
116
153
  ${this.q("run_id")} ${str} NOT NULL,
117
154
  ${this.q("seq")} integer NOT NULL,
@@ -123,6 +160,18 @@ var DbTransport = class {
123
160
  ${this.q("claimed_at")} bigint,
124
161
  ${this.q("created_at")} bigint NOT NULL
125
162
  )`);
163
+ await this.createIfMissing(`CREATE TABLE IF NOT EXISTS ${this.q(this.runsTable)} (
164
+ ${this.q("run_id")} ${str} PRIMARY KEY,
165
+ ${this.q("ns")} ${str} NOT NULL,
166
+ ${this.q("priority")} integer NOT NULL,
167
+ ${this.q("wake")} integer NOT NULL,
168
+ ${this.q("claimed_by")} ${str},
169
+ ${this.q("claim_token")} ${str},
170
+ ${this.q("claimed_at")} bigint,
171
+ ${this.q("created_at")} bigint NOT NULL
172
+ )`);
173
+ await this.exec.query(`CREATE INDEX ${this.q(`${this.runsTable}_ns_idx`)} ON ${this.q(this.runsTable)} (${this.q("ns")}, ${this.q("priority")}, ${this.q("created_at")})`).catch(() => {
174
+ });
126
175
  await this.exec.query(`CREATE INDEX ${this.q(`${this.tasksTable}_grp_idx`)} ON ${tasks} (${this.q("grp")}, ${this.q("status")}, ${this.q("created_at")})`).catch(() => {
127
176
  });
128
177
  }
@@ -166,26 +215,219 @@ var DbTransport = class {
166
215
  }
167
216
  onHeartbeat(_handler) {
168
217
  }
218
+ // ── run queue (engine ↔ engine) ─────────────────────────────────────────────────────────────
219
+ /**
220
+ * Queue `pending` runs for whichever engine instance polls the run queue first: one row per run,
221
+ * keyed by run id, so a run already waiting is not queued twice. A run whose row an instance holds
222
+ * right now is released for another take, so a dispatch landing while the run's previous entry is
223
+ * being handled is not lost when that entry is deleted. Up to {@link RUNS_INSERT_CHUNK} rows per
224
+ * insert.
225
+ */
226
+ async dispatchRuns(runs, opts) {
227
+ if (runs.length === 0) return;
228
+ await this.ensureSchema();
229
+ const byRun = /* @__PURE__ */ new Map();
230
+ for (const run of runs) {
231
+ if (!byRun.has(run.runId) || run.reason === "wake") byRun.set(run.runId, run);
232
+ }
233
+ const unique = [
234
+ ...byRun.values()
235
+ ];
236
+ const t = this.q(this.runsTable);
237
+ const cols = [
238
+ "run_id",
239
+ "ns",
240
+ "priority",
241
+ "wake",
242
+ "created_at"
243
+ ];
244
+ const now = this.now();
245
+ for (let at = 0; at < unique.length; at += RUNS_INSERT_CHUNK) {
246
+ const chunk = unique.slice(at, at + RUNS_INSERT_CHUNK);
247
+ const values = chunk.map((_, r) => `(${cols.map((__, c) => this.ph(r * cols.length + c + 1)).join(", ")})`).join(", ");
248
+ const insert = `INTO ${t} (${cols.map((c) => this.q(c)).join(", ")}) VALUES ${values}`;
249
+ const release = [
250
+ "claimed_by",
251
+ "claim_token",
252
+ "claimed_at"
253
+ ].map((c) => `${this.q(c)} = NULL`).join(", ");
254
+ const wake = this.q("wake");
255
+ const sql = this.isPg ? `INSERT ${insert} ON CONFLICT (${this.q("run_id")}) DO UPDATE SET ${release}, ${wake} = GREATEST(${t}.${wake}, EXCLUDED.${wake})` : `INSERT ${insert} ON DUPLICATE KEY UPDATE ${release}, ${wake} = GREATEST(${wake}, VALUES(${wake}))`;
256
+ await this.exec.query(sql, chunk.flatMap((run, r) => [
257
+ run.runId,
258
+ opts?.namespace ?? "",
259
+ run.priority ?? 0,
260
+ run.reason === "wake" ? 1 : 0,
261
+ now + r
262
+ ]));
263
+ }
264
+ }
265
+ /**
266
+ * Poll the run queue, at most `opts.concurrency` runs in flight on this instance. Rows are claimed
267
+ * with `FOR UPDATE SKIP LOCKED` (instances never take the same run), highest priority then oldest
268
+ * first; a row is deleted once its handler settles, and its lease is renewed meanwhile, so a row is
269
+ * re-taken only after the instance holding it went down. The returned stop takes no more rows and
270
+ * resolves once the runs in flight settled.
271
+ */
272
+ onDispatchedRun(handler, opts) {
273
+ const consumer = {
274
+ stopped: false,
275
+ inflight: /* @__PURE__ */ new Set(),
276
+ slotFreed: void 0,
277
+ loop: Promise.resolve()
278
+ };
279
+ const concurrency = Math.max(1, Math.floor(opts.concurrency));
280
+ const ns = opts.namespace ?? "";
281
+ consumer.loop = this.consumeRuns(consumer, handler, concurrency, ns).catch((err) => {
282
+ this.onError(err);
283
+ });
284
+ this.runConsumers.add(consumer);
285
+ return () => this.stopRunConsumer(consumer);
286
+ }
287
+ async stopRunConsumer(consumer) {
288
+ consumer.stopped = true;
289
+ consumer.slotFreed?.();
290
+ await consumer.loop;
291
+ this.runConsumers.delete(consumer);
292
+ }
293
+ async consumeRuns(consumer, handler, concurrency, ns) {
294
+ const renew = setInterval(() => void this.renewRunLeases(consumer).catch(() => void 0), Math.max(1e3, Math.floor(this.leaseMs / 3)));
295
+ renew.unref?.();
296
+ const settled = /* @__PURE__ */ new Set();
297
+ try {
298
+ while (this.running && !consumer.stopped) {
299
+ const free = concurrency - consumer.inflight.size;
300
+ if (free <= 0) {
301
+ await new Promise((resolve) => {
302
+ consumer.slotFreed = resolve;
303
+ });
304
+ consumer.slotFreed = void 0;
305
+ continue;
306
+ }
307
+ let rows = [];
308
+ try {
309
+ await this.ensureSchema();
310
+ rows = await this.claimRuns(ns, Math.min(free, RUNS_CLAIM_MAX));
311
+ } catch (err) {
312
+ if (this.running && !consumer.stopped) this.onError(err);
313
+ }
314
+ for (const { run, token } of rows) {
315
+ consumer.inflight.add(token);
316
+ const done = handler(run).catch(() => void 0).then(() => this.exec.query(`DELETE FROM ${this.q(this.runsTable)} WHERE ${this.q("claim_token")} = ${this.ph(1)}`, [
317
+ token
318
+ ]).catch(() => void 0)).finally(() => {
319
+ consumer.inflight.delete(token);
320
+ settled.delete(done);
321
+ consumer.slotFreed?.();
322
+ });
323
+ settled.add(done);
324
+ }
325
+ if (rows.length === 0) await this.sleep(this.pollMs);
326
+ }
327
+ await Promise.allSettled([
328
+ ...settled
329
+ ]);
330
+ } finally {
331
+ clearInterval(renew);
332
+ }
333
+ }
334
+ async claimRuns(ns, limit) {
335
+ const t = this.q(this.runsTable);
336
+ const staleBefore = this.now() - this.leaseMs;
337
+ return this.exec.transaction(async (tx) => {
338
+ const rows = await tx.query(`SELECT ${this.q("run_id")}, ${this.q("wake")} FROM ${t}
339
+ WHERE ${this.q("ns")} = ${this.ph(1)}
340
+ AND (${this.q("claimed_at")} IS NULL OR ${this.q("claimed_at")} < ${this.ph(2)})
341
+ ORDER BY ${this.q("priority")} DESC, ${this.q("created_at")} ASC
342
+ LIMIT ${Math.floor(limit)} FOR UPDATE SKIP LOCKED`, [
343
+ ns,
344
+ staleBefore
345
+ ]);
346
+ const claimed = rows.map((r) => ({
347
+ run: {
348
+ runId: r.run_id,
349
+ reason: Number(r.wake) === 1 ? "wake" : "start"
350
+ },
351
+ token: globalThis.crypto.randomUUID()
352
+ }));
353
+ const now = this.now();
354
+ for (const { run: { runId }, token } of claimed) {
355
+ await tx.query(`UPDATE ${t} SET ${this.q("claimed_by")} = ${this.ph(1)}, ${this.q("claim_token")} = ${this.ph(2)}, ${this.q("claimed_at")} = ${this.ph(3)}
356
+ WHERE ${this.q("run_id")} = ${this.ph(4)}`, [
357
+ this.instanceId,
358
+ token,
359
+ now,
360
+ runId
361
+ ]);
362
+ }
363
+ return claimed;
364
+ });
365
+ }
366
+ /** Keep the rows of the runs in flight leased, so a long turn is not re-taken by another instance. */
367
+ async renewRunLeases(consumer) {
368
+ const tokens = [
369
+ ...consumer.inflight
370
+ ];
371
+ if (tokens.length === 0) return;
372
+ await this.exec.query(`UPDATE ${this.q(this.runsTable)} SET ${this.q("claimed_at")} = ${this.ph(1)}
373
+ WHERE ${this.q("claim_token")} IN (${tokens.map((_, i) => this.ph(i + 2)).join(", ")})`, [
374
+ this.now(),
375
+ ...tokens
376
+ ]);
377
+ }
169
378
  // ── polling ──────────────────────────────────────────────────────────────────────────────────
170
379
  /** Run `tick` repeatedly; sleep `pollMs` when it reports an empty queue. Stops on `close()`. */
171
380
  async loop(tick) {
172
- await this.ensureSchema();
173
381
  while (this.running) {
174
382
  let processed = 0;
175
383
  try {
384
+ await this.ensureSchema();
176
385
  processed = await tick();
177
386
  } catch (err) {
178
- if (this.running) console.error("[DbTransport] poll failed", err);
387
+ if (this.running) this.onError(err);
179
388
  }
180
- if (processed === 0) await this.sleep(this.pollMs);
389
+ if (processed === 0 && this.running) await this.sleep(this.pollMs);
181
390
  }
182
391
  }
183
- /** Claim a batch of tasks (FOR UPDATE SKIP LOCKED), then run each outside the lock. */
392
+ /**
393
+ * One task-loop round: claim as many rows as there are free slots (never more — a claimed row that
394
+ * isn't running is one an idle sibling can't run), and start each. With every slot busy, wait for
395
+ * one to free instead of polling.
396
+ */
184
397
  async drainTasks(group) {
185
- const claimed = await this.claim(this.tasksTable, `${this.q("grp")} = ${this.ph(1)}`, [
398
+ const free = this.concurrency - this.runningTasks.size;
399
+ if (free <= 0) {
400
+ await Promise.race(this.runningTasks);
401
+ return 1;
402
+ }
403
+ const { rows, token } = await this.claim(this.tasksTable, Math.min(free, this.batchSize), `${this.q("grp")} = ${this.ph(1)}`, [
186
404
  group
187
405
  ]);
188
- for (const row of claimed) {
406
+ if (rows.length === 0) return 0;
407
+ this.hold(this.tasksTable, token);
408
+ let left = rows.length;
409
+ for (const row of rows) {
410
+ const run = this.runTask(row, group, token).finally(() => {
411
+ this.runningTasks.delete(run);
412
+ left -= 1;
413
+ if (left === 0) this.release(this.tasksTable, token);
414
+ });
415
+ this.runningTasks.add(run);
416
+ }
417
+ return rows.length;
418
+ }
419
+ /**
420
+ * Run one claimed task and settle it — only while this instance still holds the claim: the claim is
421
+ * re-checked before running, and the task row is deleted and its result written in ONE transaction,
422
+ * conditional on the claim token, so an instance whose claim was taken over neither deletes the new
423
+ * holder's row nor publishes a second result.
424
+ */
425
+ async runTask(row, group, token) {
426
+ try {
427
+ if (!await this.stillHolds(row.step_id, token)) {
428
+ this.onError(new Error(`[DbTransport] lost the claim on task ${row.step_id} before running it \u2014 another instance took it over; not running it here`));
429
+ return;
430
+ }
189
431
  const task = {
190
432
  runId: row.run_id,
191
433
  seq: Number(row.seq),
@@ -196,62 +438,169 @@ var DbTransport = class {
196
438
  attempt: Number(row.attempt)
197
439
  };
198
440
  const result = await runStepHandler(task, this.handlers.get(task.name));
199
- await this.insertResult(result);
200
- await this.exec.query(`DELETE FROM ${this.q(this.tasksTable)} WHERE ${this.q("step_id")} = ${this.ph(1)}`, [
201
- task.stepId
202
- ]);
441
+ if (!await this.settleTask(task.stepId, token, result)) {
442
+ this.onError(new Error(`[DbTransport] lost the claim on task ${task.stepId} while running it (this instance went silent past leaseMs) \u2014 another instance took it over and may run it too; this run's result was dropped. Raise leaseMs if steps block the event loop`));
443
+ }
444
+ } catch (err) {
445
+ this.onError(err);
203
446
  }
204
- return claimed.length;
205
447
  }
206
- /** Claim a batch of results, deliver each to the engine, then delete it. */
207
- async drainResults(handler) {
208
- const claimed = await this.claim(this.resultsTable, "1 = 1", []);
209
- for (const row of claimed) {
210
- const result = {
211
- runId: row.run_id,
212
- seq: Number(row.seq),
213
- stepId: row.step_id,
214
- status: row.status,
215
- output: row.output == null ? void 0 : JSON.parse(row.output),
216
- error: row.error == null ? void 0 : JSON.parse(row.error),
217
- startedAt: row.started_at == null ? void 0 : Number(row.started_at)
218
- };
219
- await handler(result);
220
- await this.exec.query(`DELETE FROM ${this.q(this.resultsTable)} WHERE ${this.q("step_id")} = ${this.ph(1)}`, [
221
- result.stepId
448
+ /** Whether the claim on `stepId` is still `token`'s (re-stamping it if so). */
449
+ async stillHolds(stepId, token) {
450
+ const t = this.q(this.tasksTable);
451
+ const rows = await this.exec.transaction(async (tx) => {
452
+ const held = await tx.query(`SELECT ${this.q("step_id")} FROM ${t} WHERE ${this.q("step_id")} = ${this.ph(1)} AND ${this.q("claimed_by")} = ${this.ph(2)} FOR UPDATE`, [
453
+ stepId,
454
+ token
222
455
  ]);
456
+ if (held.length > 0) {
457
+ await tx.query(`UPDATE ${t} SET ${this.q("claimed_at")} = ${this.ph(1)} WHERE ${this.q("step_id")} = ${this.ph(2)} AND ${this.q("claimed_by")} = ${this.ph(3)}`, [
458
+ this.now(),
459
+ stepId,
460
+ token
461
+ ]);
462
+ }
463
+ return held;
464
+ });
465
+ return rows.length > 0;
466
+ }
467
+ /** Delete the task row and publish its result in ONE transaction, only while `token` holds it. */
468
+ async settleTask(stepId, token, result) {
469
+ const t = this.q(this.tasksTable);
470
+ return this.exec.transaction(async (tx) => {
471
+ const held = await tx.query(`SELECT ${this.q("step_id")} FROM ${t} WHERE ${this.q("step_id")} = ${this.ph(1)} AND ${this.q("claimed_by")} = ${this.ph(2)} FOR UPDATE`, [
472
+ stepId,
473
+ token
474
+ ]);
475
+ if (held.length === 0) return false;
476
+ await tx.query(`DELETE FROM ${t} WHERE ${this.q("step_id")} = ${this.ph(1)} AND ${this.q("claimed_by")} = ${this.ph(2)}`, [
477
+ stepId,
478
+ token
479
+ ]);
480
+ await this.insertResult(result, tx);
481
+ return true;
482
+ });
483
+ }
484
+ /**
485
+ * Claim a batch of results, deliver each to the engine, and delete it — only after its handler ran
486
+ * (at-least-once), and only while this round still holds the claim (a newer result for the same
487
+ * step that replaced the row resets the claim, so it is delivered next instead of deleted). The
488
+ * batch's claim is renewed until it is done. A throwing handler is reported and its row left for
489
+ * re-delivery after the lease; the rest of the batch continues.
490
+ */
491
+ async drainResults(handler) {
492
+ const { rows, token } = await this.claim(this.resultsTable, this.batchSize, "1 = 1", []);
493
+ if (rows.length === 0) return 0;
494
+ this.hold(this.resultsTable, token);
495
+ try {
496
+ for (const row of rows) {
497
+ if (!this.running) break;
498
+ try {
499
+ const result = {
500
+ runId: row.run_id,
501
+ seq: Number(row.seq),
502
+ stepId: row.step_id,
503
+ status: row.status,
504
+ output: row.output == null ? void 0 : JSON.parse(row.output),
505
+ error: row.error == null ? void 0 : JSON.parse(row.error),
506
+ startedAt: row.started_at == null ? void 0 : Number(row.started_at)
507
+ };
508
+ await handler(result);
509
+ await this.exec.query(`DELETE FROM ${this.q(this.resultsTable)} WHERE ${this.q("step_id")} = ${this.ph(1)} AND ${this.q("claimed_by")} = ${this.ph(2)}`, [
510
+ row.step_id,
511
+ token
512
+ ]);
513
+ } catch (err) {
514
+ this.onError(err);
515
+ }
516
+ }
517
+ } finally {
518
+ this.release(this.resultsTable, token);
223
519
  }
224
- return claimed.length;
520
+ return rows.length;
225
521
  }
226
522
  /**
227
- * Atomically claim up to `batchSize` un-leased rows matching `where` and stamp them with this
228
- * instance's lease. `SELECT … FOR UPDATE SKIP LOCKED` inside the txn means concurrent instances
229
- * skip each other's locked rows instead of blocking, so no row is claimed twice.
523
+ * Atomically claim up to `limit` un-leased rows matching `where` and stamp them with a claim token
524
+ * unique to this round (`<instanceId>:<uuid>`). `SELECT … FOR UPDATE SKIP LOCKED` inside the txn
525
+ * means concurrent instances skip each other's locked rows instead of blocking, so no row is
526
+ * claimed twice; the token is what every later delete/settle is conditional on.
230
527
  */
231
- async claim(table, where, whereParams) {
528
+ async claim(table, limit, where, whereParams) {
232
529
  const t = this.q(table);
233
530
  const staleBefore = this.now() - this.leaseMs;
234
- return this.exec.transaction(async (tx) => {
531
+ const token = `${this.instanceId}:${randomUUID()}`;
532
+ const rows = await this.exec.transaction(async (tx) => {
235
533
  const leaseClause = `(${this.q("claimed_at")} IS NULL OR ${this.q("claimed_at")} < ${this.ph(whereParams.length + 1)})`;
236
- const rows = await tx.query(`SELECT * FROM ${t} WHERE ${where} AND ${leaseClause}
237
- ORDER BY ${this.q("created_at")} ASC LIMIT ${this.batchSize} FOR UPDATE SKIP LOCKED`, [
534
+ const found = await tx.query(`SELECT * FROM ${t} WHERE ${where} AND ${leaseClause}
535
+ ORDER BY ${this.q("created_at")} ASC LIMIT ${Math.max(1, Math.floor(limit))} FOR UPDATE SKIP LOCKED`, [
238
536
  ...whereParams,
239
537
  staleBefore
240
538
  ]);
241
- if (rows.length > 0) {
242
- const ids = rows.map((r) => r.step_id);
539
+ if (found.length > 0) {
540
+ const ids = found.map((r) => r.step_id);
243
541
  const placeholders = ids.map((_, i) => this.ph(i + 3)).join(", ");
244
542
  await tx.query(`UPDATE ${t} SET ${this.q("claimed_by")} = ${this.ph(1)}, ${this.q("claimed_at")} = ${this.ph(2)}
245
543
  WHERE ${this.q("step_id")} IN (${placeholders})`, [
246
- this.instanceId,
544
+ token,
247
545
  this.now(),
248
546
  ...ids
249
547
  ]);
250
548
  }
251
- return rows;
549
+ return found;
252
550
  });
551
+ return {
552
+ rows,
553
+ token
554
+ };
555
+ }
556
+ // ── claim renewal: every claim held, not only the row being run ─────────────────────────────
557
+ hold(table, token) {
558
+ let tokens = this.held.get(table);
559
+ if (!tokens) {
560
+ tokens = /* @__PURE__ */ new Set();
561
+ this.held.set(table, tokens);
562
+ }
563
+ tokens.add(token);
564
+ if (this.renewTimer) return;
565
+ const intervalMs = Math.max(10, Math.floor(this.leaseMs / 3));
566
+ this.renewTimer = setInterval(() => {
567
+ if (this.renewal) return;
568
+ const round = this.renewHeld().catch((err) => this.onError(err)).finally(() => {
569
+ if (this.renewal === round) this.renewal = void 0;
570
+ });
571
+ this.renewal = round;
572
+ }, intervalMs);
573
+ this.renewTimer.unref?.();
253
574
  }
254
- async insertResult(result) {
575
+ release(table, token) {
576
+ const tokens = this.held.get(table);
577
+ tokens?.delete(token);
578
+ if (tokens?.size === 0) this.held.delete(table);
579
+ if (this.held.size === 0 && this.renewTimer) {
580
+ clearInterval(this.renewTimer);
581
+ this.renewTimer = void 0;
582
+ }
583
+ }
584
+ /** Re-stamp `claimed_at` on every row still claimed under a token this instance holds. */
585
+ async renewHeld() {
586
+ for (const [table, tokens] of this.held) {
587
+ if (tokens.size === 0) continue;
588
+ const list = [
589
+ ...tokens
590
+ ];
591
+ const placeholders = list.map((_, i) => this.ph(i + 2)).join(", ");
592
+ await this.exec.query(`UPDATE ${this.q(table)} SET ${this.q("claimed_at")} = ${this.ph(1)} WHERE ${this.q("claimed_by")} IN (${placeholders})`, [
593
+ this.now(),
594
+ ...list
595
+ ]);
596
+ }
597
+ }
598
+ /**
599
+ * Write a result row. A result already waiting for the same step (an earlier attempt's, not yet
600
+ * deleted by the engine) is REPLACED and its claim reset, never kept: `step_id` is stable across
601
+ * attempts, so keeping the old row would silently drop a retry's result.
602
+ */
603
+ async insertResult(result, tx = this.exec) {
255
604
  const t = this.q(this.resultsTable);
256
605
  const cols = [
257
606
  "step_id",
@@ -263,9 +612,26 @@ var DbTransport = class {
263
612
  "started_at",
264
613
  "created_at"
265
614
  ];
266
- const head = this.isPg ? `INSERT INTO ${t} (...) ON CONFLICT (${this.q("step_id")}) DO NOTHING` : `INSERT IGNORE INTO ${t} (...)`;
267
- const sql = head.replace("(...)", `(${cols.map((c) => this.q(c)).join(", ")}) VALUES (${cols.map((_, i) => this.ph(i + 1)).join(", ")})`);
268
- await this.exec.query(sql, [
615
+ const replace = [
616
+ "run_id",
617
+ "seq",
618
+ "status",
619
+ "output",
620
+ "error",
621
+ "started_at",
622
+ "created_at"
623
+ ];
624
+ const values = `(${cols.map((c) => this.q(c)).join(", ")}) VALUES (${cols.map((_, i) => this.ph(i + 1)).join(", ")})`;
625
+ const sql = this.isPg ? `INSERT INTO ${t} ${values} ON CONFLICT (${this.q("step_id")}) DO UPDATE SET ${[
626
+ ...replace.map((c) => `${this.q(c)} = EXCLUDED.${this.q(c)}`),
627
+ `${this.q("claimed_by")} = NULL`,
628
+ `${this.q("claimed_at")} = NULL`
629
+ ].join(", ")}` : `INSERT INTO ${t} ${values} ON DUPLICATE KEY UPDATE ${[
630
+ ...replace.map((c) => `${this.q(c)} = VALUES(${this.q(c)})`),
631
+ `${this.q("claimed_by")} = NULL`,
632
+ `${this.q("claimed_at")} = NULL`
633
+ ].join(", ")}`;
634
+ await tx.query(sql, [
269
635
  result.stepId,
270
636
  result.runId,
271
637
  result.seq,
@@ -284,8 +650,17 @@ var DbTransport = class {
284
650
  this.running = false;
285
651
  await Promise.allSettled([
286
652
  this.taskLoop,
287
- this.resultLoop
653
+ this.resultLoop,
654
+ ...[
655
+ ...this.runConsumers
656
+ ].map((c) => this.stopRunConsumer(c))
657
+ ]);
658
+ await Promise.allSettled([
659
+ ...this.runningTasks
288
660
  ]);
661
+ if (this.renewTimer) clearInterval(this.renewTimer);
662
+ this.renewTimer = void 0;
663
+ await this.renewal;
289
664
  }
290
665
  };
291
666
  export {