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