@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.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.
|
|
73
|
-
this.
|
|
74
|
-
this.
|
|
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-${
|
|
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)
|
|
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.
|
|
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.
|
|
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)
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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.
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
/**
|
|
207
|
-
async
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
520
|
+
return rows.length;
|
|
225
521
|
}
|
|
226
522
|
/**
|
|
227
|
-
* Atomically claim up to `
|
|
228
|
-
*
|
|
229
|
-
* skip each other's locked rows instead of blocking, so no row is
|
|
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
|
-
|
|
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
|
|
237
|
-
ORDER BY ${this.q("created_at")} ASC LIMIT ${
|
|
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 (
|
|
242
|
-
const ids =
|
|
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
|
-
|
|
544
|
+
token,
|
|
247
545
|
this.now(),
|
|
248
546
|
...ids
|
|
249
547
|
]);
|
|
250
548
|
}
|
|
251
|
-
return
|
|
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
|
-
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
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 {
|