@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.cjs +245 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +69 -7
- package/dist/index.d.ts +69 -7
- package/dist/index.js +245 -50
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 `
|
|
134
|
-
*
|
|
135
|
-
* skip each other's locked rows instead of blocking, so no row is
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 `
|
|
134
|
-
*
|
|
135
|
-
* skip each other's locked rows instead of blocking, so no row is
|
|
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
|
|
73
|
-
this.leaseMs = options.leaseMs
|
|
74
|
-
this.batchSize = options.batchSize
|
|
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-${
|
|
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)
|
|
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.
|
|
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.
|
|
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)
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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.
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
/**
|
|
207
|
-
async
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
343
|
+
return rows.length;
|
|
225
344
|
}
|
|
226
345
|
/**
|
|
227
|
-
* Atomically claim up to `
|
|
228
|
-
*
|
|
229
|
-
* skip each other's locked rows instead of blocking, so no row is
|
|
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
|
-
|
|
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
|
|
237
|
-
ORDER BY ${this.q("created_at")} ASC LIMIT ${
|
|
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 (
|
|
242
|
-
const ids =
|
|
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
|
-
|
|
367
|
+
token,
|
|
247
368
|
this.now(),
|
|
248
369
|
...ids
|
|
249
370
|
]);
|
|
250
371
|
}
|
|
251
|
-
return
|
|
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
|
-
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
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 {
|