@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 +426 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +98 -10
- package/dist/index.d.ts +98 -10
- package/dist/index.js +426 -51
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
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.
|
|
99
|
-
this.
|
|
100
|
-
this.
|
|
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-${
|
|
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)
|
|
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.
|
|
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.
|
|
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)
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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.
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
/**
|
|
233
|
-
async
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
546
|
+
return rows.length;
|
|
251
547
|
}
|
|
252
548
|
/**
|
|
253
|
-
* Atomically claim up to `
|
|
254
|
-
*
|
|
255
|
-
* skip each other's locked rows instead of blocking, so no row is
|
|
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
|
-
|
|
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
|
|
263
|
-
ORDER BY ${this.q("created_at")} ASC LIMIT ${
|
|
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 (
|
|
268
|
-
const ids =
|
|
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
|
-
|
|
570
|
+
token,
|
|
273
571
|
this.now(),
|
|
274
572
|
...ids
|
|
275
573
|
]);
|
|
276
574
|
}
|
|
277
|
-
return
|
|
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
|
-
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
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:
|