@dudousxd/nestjs-durable-transport-db 0.2.1 → 0.2.2

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.d.cts CHANGED
@@ -74,10 +74,27 @@ interface DbTransportOptions {
74
74
  prefix?: string;
75
75
  /** Poll interval in ms when a queue is empty. Default 500. */
76
76
  pollMs?: number;
77
- /** How long a claimed row is owned before it's reclaimable (crash recovery), in ms. Default 30_000. */
77
+ /**
78
+ * How long a claimed row is owned before it's reclaimable (crash recovery), in ms. Default 30_000.
79
+ * Every claim this instance holds is renewed every `leaseMs / 3` while it is waiting or running, so
80
+ * a step may run longer than `leaseMs`; only a crashed (or event-loop-blocked) instance loses it.
81
+ */
78
82
  leaseMs?: number;
79
- /** Max rows claimed per poll. Default 10. */
83
+ /**
84
+ * Max rows claimed per poll. Default 10. The task loop claims only as many rows as it has free
85
+ * {@link concurrency} slots (capped by this); the results loop claims up to this many.
86
+ */
80
87
  batchSize?: number;
88
+ /**
89
+ * How many tasks this instance runs at once (worker side). Default 1. A row is claimed only when a
90
+ * slot is free to run it, so rows never wait claimed behind a slow task while a sibling sits idle.
91
+ */
92
+ concurrency?: number;
93
+ /**
94
+ * Where a transport failure is reported: a failing poll, a throwing result handler (the result is
95
+ * re-delivered after the lease), a lost claim. Default `console.error`.
96
+ */
97
+ onError?: (err: unknown) => void;
81
98
  /** Create the two tables on first use if missing. Default true. */
82
99
  autoCreate?: boolean;
83
100
  /** Identifies this instance in `claimed_by` (for debugging). Defaults to a random id. */
@@ -104,6 +121,8 @@ declare class DbTransport implements Transport {
104
121
  private readonly pollMs;
105
122
  private readonly leaseMs;
106
123
  private readonly batchSize;
124
+ private readonly concurrency;
125
+ private readonly onError;
107
126
  private readonly autoCreate;
108
127
  private readonly instanceId;
109
128
  private readonly isPg;
@@ -112,12 +131,24 @@ declare class DbTransport implements Transport {
112
131
  private schemaReady?;
113
132
  private taskLoop?;
114
133
  private resultLoop?;
134
+ /** Tasks running on this instance (at most `concurrency`). */
135
+ private readonly runningTasks;
136
+ /** Claim tokens this instance holds, by table — every one is renewed on `renewTimer`. */
137
+ private readonly held;
138
+ private renewTimer?;
139
+ private renewal?;
115
140
  constructor(options: DbTransportOptions);
116
141
  /** Positional placeholder for param `i` (1-based): `$i` on Postgres, `?` elsewhere. */
117
142
  private ph;
118
143
  private q;
119
144
  private now;
120
145
  private ensureSchema;
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
+ private createIfMissing;
121
152
  private createTables;
122
153
  dispatch(task: RemoteTask): Promise<void>;
123
154
  handle(name: string, fn: StepHandler): void;
@@ -125,16 +156,47 @@ declare class DbTransport implements Transport {
125
156
  onHeartbeat(_handler: (beat: Heartbeat) => Promise<void>): void;
126
157
  /** Run `tick` repeatedly; sleep `pollMs` when it reports an empty queue. Stops on `close()`. */
127
158
  private loop;
128
- /** Claim a batch of tasks (FOR UPDATE SKIP LOCKED), then run each outside the lock. */
159
+ /**
160
+ * One task-loop round: claim as many rows as there are free slots (never more — a claimed row that
161
+ * isn't running is one an idle sibling can't run), and start each. With every slot busy, wait for
162
+ * one to free instead of polling.
163
+ */
129
164
  private drainTasks;
130
- /** Claim a batch of results, deliver each to the engine, then delete it. */
165
+ /**
166
+ * Run one claimed task and settle it — only while this instance still holds the claim: the claim is
167
+ * re-checked before running, and the task row is deleted and its result written in ONE transaction,
168
+ * conditional on the claim token, so an instance whose claim was taken over neither deletes the new
169
+ * holder's row nor publishes a second result.
170
+ */
171
+ private runTask;
172
+ /** Whether the claim on `stepId` is still `token`'s (re-stamping it if so). */
173
+ private stillHolds;
174
+ /** Delete the task row and publish its result in ONE transaction, only while `token` holds it. */
175
+ private settleTask;
176
+ /**
177
+ * Claim a batch of results, deliver each to the engine, and delete it — only after its handler ran
178
+ * (at-least-once), and only while this round still holds the claim (a newer result for the same
179
+ * step that replaced the row resets the claim, so it is delivered next instead of deleted). The
180
+ * batch's claim is renewed until it is done. A throwing handler is reported and its row left for
181
+ * re-delivery after the lease; the rest of the batch continues.
182
+ */
131
183
  private drainResults;
132
184
  /**
133
- * Atomically claim up to `batchSize` un-leased rows matching `where` and stamp them with this
134
- * instance's lease. `SELECT … FOR UPDATE SKIP LOCKED` inside the txn means concurrent instances
135
- * skip each other's locked rows instead of blocking, so no row is claimed twice.
185
+ * Atomically claim up to `limit` un-leased rows matching `where` and stamp them with a claim token
186
+ * unique to this round (`<instanceId>:<uuid>`). `SELECT … FOR UPDATE SKIP LOCKED` inside the txn
187
+ * means concurrent instances skip each other's locked rows instead of blocking, so no row is
188
+ * claimed twice; the token is what every later delete/settle is conditional on.
136
189
  */
137
190
  private claim;
191
+ private hold;
192
+ private release;
193
+ /** Re-stamp `claimed_at` on every row still claimed under a token this instance holds. */
194
+ private renewHeld;
195
+ /**
196
+ * Write a result row. A result already waiting for the same step (an earlier attempt's, not yet
197
+ * deleted by the engine) is REPLACED and its claim reset, never kept: `step_id` is stable across
198
+ * attempts, so keeping the old row would silently drop a retry's result.
199
+ */
138
200
  private insertResult;
139
201
  private sleep;
140
202
  /** Stop the pollers and wait for the current ticks to settle. Does not close the shared DataSource. */
package/dist/index.d.ts CHANGED
@@ -74,10 +74,27 @@ interface DbTransportOptions {
74
74
  prefix?: string;
75
75
  /** Poll interval in ms when a queue is empty. Default 500. */
76
76
  pollMs?: number;
77
- /** How long a claimed row is owned before it's reclaimable (crash recovery), in ms. Default 30_000. */
77
+ /**
78
+ * How long a claimed row is owned before it's reclaimable (crash recovery), in ms. Default 30_000.
79
+ * Every claim this instance holds is renewed every `leaseMs / 3` while it is waiting or running, so
80
+ * a step may run longer than `leaseMs`; only a crashed (or event-loop-blocked) instance loses it.
81
+ */
78
82
  leaseMs?: number;
79
- /** Max rows claimed per poll. Default 10. */
83
+ /**
84
+ * Max rows claimed per poll. Default 10. The task loop claims only as many rows as it has free
85
+ * {@link concurrency} slots (capped by this); the results loop claims up to this many.
86
+ */
80
87
  batchSize?: number;
88
+ /**
89
+ * How many tasks this instance runs at once (worker side). Default 1. A row is claimed only when a
90
+ * slot is free to run it, so rows never wait claimed behind a slow task while a sibling sits idle.
91
+ */
92
+ concurrency?: number;
93
+ /**
94
+ * Where a transport failure is reported: a failing poll, a throwing result handler (the result is
95
+ * re-delivered after the lease), a lost claim. Default `console.error`.
96
+ */
97
+ onError?: (err: unknown) => void;
81
98
  /** Create the two tables on first use if missing. Default true. */
82
99
  autoCreate?: boolean;
83
100
  /** Identifies this instance in `claimed_by` (for debugging). Defaults to a random id. */
@@ -104,6 +121,8 @@ declare class DbTransport implements Transport {
104
121
  private readonly pollMs;
105
122
  private readonly leaseMs;
106
123
  private readonly batchSize;
124
+ private readonly concurrency;
125
+ private readonly onError;
107
126
  private readonly autoCreate;
108
127
  private readonly instanceId;
109
128
  private readonly isPg;
@@ -112,12 +131,24 @@ declare class DbTransport implements Transport {
112
131
  private schemaReady?;
113
132
  private taskLoop?;
114
133
  private resultLoop?;
134
+ /** Tasks running on this instance (at most `concurrency`). */
135
+ private readonly runningTasks;
136
+ /** Claim tokens this instance holds, by table — every one is renewed on `renewTimer`. */
137
+ private readonly held;
138
+ private renewTimer?;
139
+ private renewal?;
115
140
  constructor(options: DbTransportOptions);
116
141
  /** Positional placeholder for param `i` (1-based): `$i` on Postgres, `?` elsewhere. */
117
142
  private ph;
118
143
  private q;
119
144
  private now;
120
145
  private ensureSchema;
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
+ private createIfMissing;
121
152
  private createTables;
122
153
  dispatch(task: RemoteTask): Promise<void>;
123
154
  handle(name: string, fn: StepHandler): void;
@@ -125,16 +156,47 @@ declare class DbTransport implements Transport {
125
156
  onHeartbeat(_handler: (beat: Heartbeat) => Promise<void>): void;
126
157
  /** Run `tick` repeatedly; sleep `pollMs` when it reports an empty queue. Stops on `close()`. */
127
158
  private loop;
128
- /** Claim a batch of tasks (FOR UPDATE SKIP LOCKED), then run each outside the lock. */
159
+ /**
160
+ * One task-loop round: claim as many rows as there are free slots (never more — a claimed row that
161
+ * isn't running is one an idle sibling can't run), and start each. With every slot busy, wait for
162
+ * one to free instead of polling.
163
+ */
129
164
  private drainTasks;
130
- /** Claim a batch of results, deliver each to the engine, then delete it. */
165
+ /**
166
+ * Run one claimed task and settle it — only while this instance still holds the claim: the claim is
167
+ * re-checked before running, and the task row is deleted and its result written in ONE transaction,
168
+ * conditional on the claim token, so an instance whose claim was taken over neither deletes the new
169
+ * holder's row nor publishes a second result.
170
+ */
171
+ private runTask;
172
+ /** Whether the claim on `stepId` is still `token`'s (re-stamping it if so). */
173
+ private stillHolds;
174
+ /** Delete the task row and publish its result in ONE transaction, only while `token` holds it. */
175
+ private settleTask;
176
+ /**
177
+ * Claim a batch of results, deliver each to the engine, and delete it — only after its handler ran
178
+ * (at-least-once), and only while this round still holds the claim (a newer result for the same
179
+ * step that replaced the row resets the claim, so it is delivered next instead of deleted). The
180
+ * batch's claim is renewed until it is done. A throwing handler is reported and its row left for
181
+ * re-delivery after the lease; the rest of the batch continues.
182
+ */
131
183
  private drainResults;
132
184
  /**
133
- * Atomically claim up to `batchSize` un-leased rows matching `where` and stamp them with this
134
- * instance's lease. `SELECT … FOR UPDATE SKIP LOCKED` inside the txn means concurrent instances
135
- * skip each other's locked rows instead of blocking, so no row is claimed twice.
185
+ * Atomically claim up to `limit` un-leased rows matching `where` and stamp them with a claim token
186
+ * unique to this round (`<instanceId>:<uuid>`). `SELECT … FOR UPDATE SKIP LOCKED` inside the txn
187
+ * means concurrent instances skip each other's locked rows instead of blocking, so no row is
188
+ * claimed twice; the token is what every later delete/settle is conditional on.
136
189
  */
137
190
  private claim;
191
+ private hold;
192
+ private release;
193
+ /** Re-stamp `claimed_at` on every row still claimed under a token this instance holds. */
194
+ private renewHeld;
195
+ /**
196
+ * Write a result row. A result already waiting for the same step (an earlier attempt's, not yet
197
+ * deleted by the engine) is REPLACED and its claim reset, never kept: `step_id` is stable across
198
+ * attempts, so keeping the old row would silently drop a retry's result.
199
+ */
138
200
  private insertResult;
139
201
  private sleep;
140
202
  /** Stop the pollers and wait for the current ticks to settle. Does not close the shared DataSource. */
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,10 @@ function mikroOrmExecutor(em) {
44
45
  __name(mikroOrmExecutor, "mikroOrmExecutor");
45
46
 
46
47
  // src/db-transport.ts
48
+ function numberOption(value, fallback, min) {
49
+ return value !== void 0 && Number.isFinite(value) && value >= min ? value : fallback;
50
+ }
51
+ __name(numberOption, "numberOption");
47
52
  var DbTransport = class {
48
53
  static {
49
54
  __name(this, "DbTransport");
@@ -55,6 +60,8 @@ var DbTransport = class {
55
60
  pollMs;
56
61
  leaseMs;
57
62
  batchSize;
63
+ concurrency;
64
+ onError;
58
65
  autoCreate;
59
66
  instanceId;
60
67
  isPg;
@@ -63,17 +70,25 @@ var DbTransport = class {
63
70
  schemaReady;
64
71
  taskLoop;
65
72
  resultLoop;
73
+ /** Tasks running on this instance (at most `concurrency`). */
74
+ runningTasks = /* @__PURE__ */ new Set();
75
+ /** Claim tokens this instance holds, by table — every one is renewed on `renewTimer`. */
76
+ held = /* @__PURE__ */ new Map();
77
+ renewTimer;
78
+ renewal;
66
79
  constructor(options) {
67
80
  this.exec = options.executor;
68
81
  this.group = options.group;
69
82
  const prefix = options.prefix ?? "durable";
70
83
  this.tasksTable = `${prefix}_transport_tasks`;
71
84
  this.resultsTable = `${prefix}_transport_results`;
72
- this.pollMs = options.pollMs ?? 500;
73
- this.leaseMs = options.leaseMs ?? 3e4;
74
- this.batchSize = options.batchSize ?? 10;
85
+ this.pollMs = numberOption(options.pollMs, 500, 0);
86
+ this.leaseMs = numberOption(options.leaseMs, 3e4, 1);
87
+ this.batchSize = Math.floor(numberOption(options.batchSize, 10, 1));
88
+ this.concurrency = Math.floor(numberOption(options.concurrency, 1, 1));
89
+ this.onError = options.onError ?? ((err) => console.error("[DbTransport]", err));
75
90
  this.autoCreate = options.autoCreate ?? true;
76
- this.instanceId = options.instanceId ?? `db-${Math.floor(Date.now()).toString(36)}`;
91
+ this.instanceId = options.instanceId ?? `db-${randomUUID()}`;
77
92
  this.isPg = this.exec.dialect === "postgres";
78
93
  }
79
94
  // ── dialect helpers ──────────────────────────────────────────────────────────────────────────
@@ -89,16 +104,33 @@ var DbTransport = class {
89
104
  }
90
105
  // ── schema ───────────────────────────────────────────────────────────────────────────────────
91
106
  ensureSchema() {
92
- if (!this.schemaReady) this.schemaReady = this.createTables();
107
+ if (!this.schemaReady) {
108
+ this.schemaReady = this.createTables().catch((err) => {
109
+ this.schemaReady = void 0;
110
+ throw err;
111
+ });
112
+ }
93
113
  return this.schemaReady;
94
114
  }
115
+ /**
116
+ * Run one `CREATE … IF NOT EXISTS`. Two instances booting on a fresh database race it, and Postgres
117
+ * can reject the loser with a unique violation on its catalog even with `IF NOT EXISTS`; the
118
+ * statement is simply re-run, by which point the table exists and it is a no-op.
119
+ */
120
+ async createIfMissing(sql) {
121
+ try {
122
+ await this.exec.query(sql);
123
+ } catch {
124
+ await this.exec.query(sql);
125
+ }
126
+ }
95
127
  async createTables() {
96
128
  if (!this.autoCreate) return;
97
129
  const str = "varchar(191)";
98
130
  const txt = this.isPg ? "text" : "longtext";
99
131
  const tasks = this.q(this.tasksTable);
100
132
  const results = this.q(this.resultsTable);
101
- await this.exec.query(`CREATE TABLE IF NOT EXISTS ${tasks} (
133
+ await this.createIfMissing(`CREATE TABLE IF NOT EXISTS ${tasks} (
102
134
  ${this.q("step_id")} ${str} PRIMARY KEY,
103
135
  ${this.q("run_id")} ${str} NOT NULL,
104
136
  ${this.q("seq")} integer NOT NULL,
@@ -111,7 +143,7 @@ var DbTransport = class {
111
143
  ${this.q("claimed_at")} bigint,
112
144
  ${this.q("created_at")} bigint NOT NULL
113
145
  )`);
114
- await this.exec.query(`CREATE TABLE IF NOT EXISTS ${results} (
146
+ await this.createIfMissing(`CREATE TABLE IF NOT EXISTS ${results} (
115
147
  ${this.q("step_id")} ${str} PRIMARY KEY,
116
148
  ${this.q("run_id")} ${str} NOT NULL,
117
149
  ${this.q("seq")} integer NOT NULL,
@@ -169,23 +201,56 @@ var DbTransport = class {
169
201
  // ── polling ──────────────────────────────────────────────────────────────────────────────────
170
202
  /** Run `tick` repeatedly; sleep `pollMs` when it reports an empty queue. Stops on `close()`. */
171
203
  async loop(tick) {
172
- await this.ensureSchema();
173
204
  while (this.running) {
174
205
  let processed = 0;
175
206
  try {
207
+ await this.ensureSchema();
176
208
  processed = await tick();
177
209
  } catch (err) {
178
- if (this.running) console.error("[DbTransport] poll failed", err);
210
+ if (this.running) this.onError(err);
179
211
  }
180
- if (processed === 0) await this.sleep(this.pollMs);
212
+ if (processed === 0 && this.running) await this.sleep(this.pollMs);
181
213
  }
182
214
  }
183
- /** Claim a batch of tasks (FOR UPDATE SKIP LOCKED), then run each outside the lock. */
215
+ /**
216
+ * One task-loop round: claim as many rows as there are free slots (never more — a claimed row that
217
+ * isn't running is one an idle sibling can't run), and start each. With every slot busy, wait for
218
+ * one to free instead of polling.
219
+ */
184
220
  async drainTasks(group) {
185
- const claimed = await this.claim(this.tasksTable, `${this.q("grp")} = ${this.ph(1)}`, [
221
+ const free = this.concurrency - this.runningTasks.size;
222
+ if (free <= 0) {
223
+ await Promise.race(this.runningTasks);
224
+ return 1;
225
+ }
226
+ const { rows, token } = await this.claim(this.tasksTable, Math.min(free, this.batchSize), `${this.q("grp")} = ${this.ph(1)}`, [
186
227
  group
187
228
  ]);
188
- for (const row of claimed) {
229
+ if (rows.length === 0) return 0;
230
+ this.hold(this.tasksTable, token);
231
+ let left = rows.length;
232
+ for (const row of rows) {
233
+ const run = this.runTask(row, group, token).finally(() => {
234
+ this.runningTasks.delete(run);
235
+ left -= 1;
236
+ if (left === 0) this.release(this.tasksTable, token);
237
+ });
238
+ this.runningTasks.add(run);
239
+ }
240
+ return rows.length;
241
+ }
242
+ /**
243
+ * Run one claimed task and settle it — only while this instance still holds the claim: the claim is
244
+ * re-checked before running, and the task row is deleted and its result written in ONE transaction,
245
+ * conditional on the claim token, so an instance whose claim was taken over neither deletes the new
246
+ * holder's row nor publishes a second result.
247
+ */
248
+ async runTask(row, group, token) {
249
+ try {
250
+ if (!await this.stillHolds(row.step_id, token)) {
251
+ 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`));
252
+ return;
253
+ }
189
254
  const task = {
190
255
  runId: row.run_id,
191
256
  seq: Number(row.seq),
@@ -196,62 +261,169 @@ var DbTransport = class {
196
261
  attempt: Number(row.attempt)
197
262
  };
198
263
  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
- ]);
264
+ if (!await this.settleTask(task.stepId, token, result)) {
265
+ 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`));
266
+ }
267
+ } catch (err) {
268
+ this.onError(err);
203
269
  }
204
- return claimed.length;
205
270
  }
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
271
+ /** Whether the claim on `stepId` is still `token`'s (re-stamping it if so). */
272
+ async stillHolds(stepId, token) {
273
+ const t = this.q(this.tasksTable);
274
+ const rows = await this.exec.transaction(async (tx) => {
275
+ 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`, [
276
+ stepId,
277
+ token
278
+ ]);
279
+ if (held.length > 0) {
280
+ 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)}`, [
281
+ this.now(),
282
+ stepId,
283
+ token
284
+ ]);
285
+ }
286
+ return held;
287
+ });
288
+ return rows.length > 0;
289
+ }
290
+ /** Delete the task row and publish its result in ONE transaction, only while `token` holds it. */
291
+ async settleTask(stepId, token, result) {
292
+ const t = this.q(this.tasksTable);
293
+ return this.exec.transaction(async (tx) => {
294
+ 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`, [
295
+ stepId,
296
+ token
222
297
  ]);
298
+ if (held.length === 0) return false;
299
+ await tx.query(`DELETE FROM ${t} WHERE ${this.q("step_id")} = ${this.ph(1)} AND ${this.q("claimed_by")} = ${this.ph(2)}`, [
300
+ stepId,
301
+ token
302
+ ]);
303
+ await this.insertResult(result, tx);
304
+ return true;
305
+ });
306
+ }
307
+ /**
308
+ * Claim a batch of results, deliver each to the engine, and delete it — only after its handler ran
309
+ * (at-least-once), and only while this round still holds the claim (a newer result for the same
310
+ * step that replaced the row resets the claim, so it is delivered next instead of deleted). The
311
+ * batch's claim is renewed until it is done. A throwing handler is reported and its row left for
312
+ * re-delivery after the lease; the rest of the batch continues.
313
+ */
314
+ async drainResults(handler) {
315
+ const { rows, token } = await this.claim(this.resultsTable, this.batchSize, "1 = 1", []);
316
+ if (rows.length === 0) return 0;
317
+ this.hold(this.resultsTable, token);
318
+ try {
319
+ for (const row of rows) {
320
+ if (!this.running) break;
321
+ try {
322
+ const result = {
323
+ runId: row.run_id,
324
+ seq: Number(row.seq),
325
+ stepId: row.step_id,
326
+ status: row.status,
327
+ output: row.output == null ? void 0 : JSON.parse(row.output),
328
+ error: row.error == null ? void 0 : JSON.parse(row.error),
329
+ startedAt: row.started_at == null ? void 0 : Number(row.started_at)
330
+ };
331
+ await handler(result);
332
+ 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)}`, [
333
+ row.step_id,
334
+ token
335
+ ]);
336
+ } catch (err) {
337
+ this.onError(err);
338
+ }
339
+ }
340
+ } finally {
341
+ this.release(this.resultsTable, token);
223
342
  }
224
- return claimed.length;
343
+ return rows.length;
225
344
  }
226
345
  /**
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.
346
+ * Atomically claim up to `limit` un-leased rows matching `where` and stamp them with a claim token
347
+ * unique to this round (`<instanceId>:<uuid>`). `SELECT … FOR UPDATE SKIP LOCKED` inside the txn
348
+ * means concurrent instances skip each other's locked rows instead of blocking, so no row is
349
+ * claimed twice; the token is what every later delete/settle is conditional on.
230
350
  */
231
- async claim(table, where, whereParams) {
351
+ async claim(table, limit, where, whereParams) {
232
352
  const t = this.q(table);
233
353
  const staleBefore = this.now() - this.leaseMs;
234
- return this.exec.transaction(async (tx) => {
354
+ const token = `${this.instanceId}:${randomUUID()}`;
355
+ const rows = await this.exec.transaction(async (tx) => {
235
356
  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`, [
357
+ const found = await tx.query(`SELECT * FROM ${t} WHERE ${where} AND ${leaseClause}
358
+ ORDER BY ${this.q("created_at")} ASC LIMIT ${Math.max(1, Math.floor(limit))} FOR UPDATE SKIP LOCKED`, [
238
359
  ...whereParams,
239
360
  staleBefore
240
361
  ]);
241
- if (rows.length > 0) {
242
- const ids = rows.map((r) => r.step_id);
362
+ if (found.length > 0) {
363
+ const ids = found.map((r) => r.step_id);
243
364
  const placeholders = ids.map((_, i) => this.ph(i + 3)).join(", ");
244
365
  await tx.query(`UPDATE ${t} SET ${this.q("claimed_by")} = ${this.ph(1)}, ${this.q("claimed_at")} = ${this.ph(2)}
245
366
  WHERE ${this.q("step_id")} IN (${placeholders})`, [
246
- this.instanceId,
367
+ token,
247
368
  this.now(),
248
369
  ...ids
249
370
  ]);
250
371
  }
251
- return rows;
372
+ return found;
252
373
  });
374
+ return {
375
+ rows,
376
+ token
377
+ };
378
+ }
379
+ // ── claim renewal: every claim held, not only the row being run ─────────────────────────────
380
+ hold(table, token) {
381
+ let tokens = this.held.get(table);
382
+ if (!tokens) {
383
+ tokens = /* @__PURE__ */ new Set();
384
+ this.held.set(table, tokens);
385
+ }
386
+ tokens.add(token);
387
+ if (this.renewTimer) return;
388
+ const intervalMs = Math.max(10, Math.floor(this.leaseMs / 3));
389
+ this.renewTimer = setInterval(() => {
390
+ if (this.renewal) return;
391
+ const round = this.renewHeld().catch((err) => this.onError(err)).finally(() => {
392
+ if (this.renewal === round) this.renewal = void 0;
393
+ });
394
+ this.renewal = round;
395
+ }, intervalMs);
396
+ this.renewTimer.unref?.();
397
+ }
398
+ release(table, token) {
399
+ const tokens = this.held.get(table);
400
+ tokens?.delete(token);
401
+ if (tokens?.size === 0) this.held.delete(table);
402
+ if (this.held.size === 0 && this.renewTimer) {
403
+ clearInterval(this.renewTimer);
404
+ this.renewTimer = void 0;
405
+ }
406
+ }
407
+ /** Re-stamp `claimed_at` on every row still claimed under a token this instance holds. */
408
+ async renewHeld() {
409
+ for (const [table, tokens] of this.held) {
410
+ if (tokens.size === 0) continue;
411
+ const list = [
412
+ ...tokens
413
+ ];
414
+ const placeholders = list.map((_, i) => this.ph(i + 2)).join(", ");
415
+ await this.exec.query(`UPDATE ${this.q(table)} SET ${this.q("claimed_at")} = ${this.ph(1)} WHERE ${this.q("claimed_by")} IN (${placeholders})`, [
416
+ this.now(),
417
+ ...list
418
+ ]);
419
+ }
253
420
  }
254
- async insertResult(result) {
421
+ /**
422
+ * Write a result row. A result already waiting for the same step (an earlier attempt's, not yet
423
+ * deleted by the engine) is REPLACED and its claim reset, never kept: `step_id` is stable across
424
+ * attempts, so keeping the old row would silently drop a retry's result.
425
+ */
426
+ async insertResult(result, tx = this.exec) {
255
427
  const t = this.q(this.resultsTable);
256
428
  const cols = [
257
429
  "step_id",
@@ -263,9 +435,26 @@ var DbTransport = class {
263
435
  "started_at",
264
436
  "created_at"
265
437
  ];
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, [
438
+ const replace = [
439
+ "run_id",
440
+ "seq",
441
+ "status",
442
+ "output",
443
+ "error",
444
+ "started_at",
445
+ "created_at"
446
+ ];
447
+ const values = `(${cols.map((c) => this.q(c)).join(", ")}) VALUES (${cols.map((_, i) => this.ph(i + 1)).join(", ")})`;
448
+ const sql = this.isPg ? `INSERT INTO ${t} ${values} ON CONFLICT (${this.q("step_id")}) DO UPDATE SET ${[
449
+ ...replace.map((c) => `${this.q(c)} = EXCLUDED.${this.q(c)}`),
450
+ `${this.q("claimed_by")} = NULL`,
451
+ `${this.q("claimed_at")} = NULL`
452
+ ].join(", ")}` : `INSERT INTO ${t} ${values} ON DUPLICATE KEY UPDATE ${[
453
+ ...replace.map((c) => `${this.q(c)} = VALUES(${this.q(c)})`),
454
+ `${this.q("claimed_by")} = NULL`,
455
+ `${this.q("claimed_at")} = NULL`
456
+ ].join(", ")}`;
457
+ await tx.query(sql, [
269
458
  result.stepId,
270
459
  result.runId,
271
460
  result.seq,
@@ -286,6 +475,12 @@ var DbTransport = class {
286
475
  this.taskLoop,
287
476
  this.resultLoop
288
477
  ]);
478
+ await Promise.allSettled([
479
+ ...this.runningTasks
480
+ ]);
481
+ if (this.renewTimer) clearInterval(this.renewTimer);
482
+ this.renewTimer = void 0;
483
+ await this.renewal;
289
484
  }
290
485
  };
291
486
  export {