@jarenjs/db 0.49.2 → 0.66.1
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/ARCHITECTURE.md +420 -71
- package/README.md +711 -79
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +309 -45
- package/docs/LIVE-FORMAT.md +156 -19
- package/docs/MIGRATION-FORMAT.md +247 -40
- package/docs/MODEL-FORMAT.md +968 -86
- package/package.json +21 -8
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +255 -44
- package/src/cli.js +337 -50
- package/src/cursor.js +411 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +125 -11
- package/src/dialect.js +267 -112
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +245 -12
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +503 -69
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +18 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +349 -51
- package/src/entity.js +102 -59
- package/src/errors.js +430 -2
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -19
- package/src/introspect.js +583 -0
- package/src/jobs.js +870 -99
- package/src/json-bytes.js +58 -0
- package/src/live-time.js +12 -3
- package/src/live.js +11 -1
- package/src/maintenance.js +175 -0
- package/src/migrate.js +606 -333
- package/src/model.js +241 -8
- package/src/plan.js +1238 -160
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1748 -312
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1672 -276
- package/src/tracker.js +367 -68
- package/src/udf.js +88 -7
- package/types/index.d.ts +1246 -32
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +72 -3
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +81 -3
- package/types/wasm.d.ts +21 -0
- package/dist/types/algebra.d.ts +0 -230
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -154
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -170
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live-time.d.ts +0 -141
- package/dist/types/live.d.ts +0 -64
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -142
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -112
- package/dist/types/residual.d.ts +0 -64
- package/dist/types/series.d.ts +0 -227
- package/dist/types/store.d.ts +0 -60
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- package/dist/types/window.d.ts +0 -52
package/src/jobs.js
CHANGED
|
@@ -9,10 +9,16 @@
|
|
|
9
9
|
* composition binds (§7) — completion marks the job done and records
|
|
10
10
|
* the result in ONE guarded transaction.
|
|
11
11
|
*
|
|
12
|
-
* Every
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Every settling transition is guarded by a FENCE — the opaque token
|
|
13
|
+
* one claim mints, plus a lease that is still valid — never by the
|
|
14
|
+
* owner, which one worker reuses for every attempt it ever makes and
|
|
15
|
+
* which therefore cannot say which attempt is speaking. Execution is
|
|
16
|
+
* at-least-once; settlement is exactly-once AGAINST THE STORE, and a
|
|
17
|
+
* guard that matches nothing is a coded refusal naming which of the
|
|
18
|
+
* three reasons applied, never a silent `false`. `now` and `random`
|
|
19
|
+
* are injectable — explicitly, or through the host's runtime record,
|
|
20
|
+
* which also mints every identifier — and the platform defaults are the
|
|
21
|
+
* clock and `Math.random`; every test injects.
|
|
16
22
|
*
|
|
17
23
|
* The worker LIFECYCLE holds two invariants that a long-running process
|
|
18
24
|
* depends on, and neither is a detail:
|
|
@@ -32,7 +38,11 @@
|
|
|
32
38
|
* timer, and its abandoned job recovers by lease expiry (§5).
|
|
33
39
|
*/
|
|
34
40
|
|
|
35
|
-
import {
|
|
41
|
+
import { resolveRuntime } from '@jarenjs/core/runtime';
|
|
42
|
+
import { chain, attempt } from './driver.js';
|
|
43
|
+
import { DbCompileError, DbRuntimeError, wrapDriverError } from './errors.js';
|
|
44
|
+
import { createCursor, rowClassOf, PAGE_LIMIT_DEFAULT } from './cursor.js';
|
|
45
|
+
import { refuseCancelled } from './cancellation.js';
|
|
36
46
|
|
|
37
47
|
export const JOBS_TABLE = '_jaren_jobs';
|
|
38
48
|
export const JOB_CHECKPOINTS_TABLE = '_jaren_job_checkpoints';
|
|
@@ -102,6 +112,8 @@ const CREATE_JOBS = `CREATE TABLE IF NOT EXISTS "${JOBS_TABLE}" (
|
|
|
102
112
|
max_attempts INTEGER NOT NULL,
|
|
103
113
|
lease_until INTEGER,
|
|
104
114
|
lease_owner TEXT,
|
|
115
|
+
lease_generation INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
lease_token TEXT,
|
|
105
117
|
last_error TEXT,
|
|
106
118
|
result TEXT,
|
|
107
119
|
created_at INTEGER NOT NULL,
|
|
@@ -113,10 +125,25 @@ CREATE TABLE IF NOT EXISTS "${JOB_CHECKPOINTS_TABLE}" (
|
|
|
113
125
|
run_id TEXT NOT NULL,
|
|
114
126
|
node_id TEXT NOT NULL,
|
|
115
127
|
value TEXT NOT NULL,
|
|
128
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
116
129
|
PRIMARY KEY (run_id, node_id)
|
|
117
130
|
);`;
|
|
118
131
|
|
|
119
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* The columns a database written before the fence does not have, added
|
|
134
|
+
* in place. Jobs rows are live work, so an existing queue is upgraded,
|
|
135
|
+
* never rebuilt: every column carries a default that reads as "claimed
|
|
136
|
+
* before the fence existed", which no token can ever match.
|
|
137
|
+
*/
|
|
138
|
+
const ADDED_COLUMNS = Object.freeze([
|
|
139
|
+
{ table: JOBS_TABLE, name: 'lease_generation', definition: 'INTEGER NOT NULL DEFAULT 0' },
|
|
140
|
+
{ table: JOBS_TABLE, name: 'lease_token', definition: 'TEXT' },
|
|
141
|
+
{ table: JOB_CHECKPOINTS_TABLE, name: 'generation', definition: 'INTEGER NOT NULL DEFAULT 0' },
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
/** Map a raw row to the frozen public record (§2). The lease token is
|
|
145
|
+
* deliberately absent: a record anyone can read must not carry the
|
|
146
|
+
* capability to settle the job it describes. */
|
|
120
147
|
function publicJob(row) {
|
|
121
148
|
return Object.freeze({
|
|
122
149
|
id: row.id,
|
|
@@ -128,6 +155,7 @@ function publicJob(row) {
|
|
|
128
155
|
maxAttempts: Number(row.max_attempts),
|
|
129
156
|
leaseUntil: row.lease_until === null ? null : Number(row.lease_until),
|
|
130
157
|
leaseOwner: row.lease_owner,
|
|
158
|
+
leaseGeneration: Number(row.lease_generation ?? 0),
|
|
131
159
|
lastError: row.last_error,
|
|
132
160
|
result: row.result === null ? null : JSON.parse(row.result),
|
|
133
161
|
createdAt: Number(row.created_at),
|
|
@@ -135,24 +163,83 @@ function publicJob(row) {
|
|
|
135
163
|
});
|
|
136
164
|
}
|
|
137
165
|
|
|
166
|
+
/**
|
|
167
|
+
* The capability one claim mints: the right to settle THIS attempt of
|
|
168
|
+
* this job, for as long as the lease is valid.
|
|
169
|
+
*
|
|
170
|
+
* It is a token, never an owner (D1). One worker reuses one owner for
|
|
171
|
+
* its whole life, so an owner cannot tell two attempts of one job apart
|
|
172
|
+
* — which is how a corpse completed a job another attempt was still
|
|
173
|
+
* running. And it is immutable (D2): `renew` answers a NEW lease, so a
|
|
174
|
+
* reference someone kept can never become valid again behind their back.
|
|
175
|
+
*/
|
|
176
|
+
function leaseOf(row) {
|
|
177
|
+
return Object.freeze({
|
|
178
|
+
jobId: row.id,
|
|
179
|
+
token: row.lease_token,
|
|
180
|
+
generation: Number(row.lease_generation),
|
|
181
|
+
attempt: Number(row.attempts),
|
|
182
|
+
owner: row.lease_owner,
|
|
183
|
+
expiresAt: Number(row.lease_until),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Whether a value is a lease this engine minted, rather than the
|
|
188
|
+
* `(id, owner)` pair the pre-fence surface took. */
|
|
189
|
+
function isLease(value) {
|
|
190
|
+
return value !== null && typeof value === 'object'
|
|
191
|
+
&& typeof value.token === 'string' && typeof value.jobId === 'string';
|
|
192
|
+
}
|
|
193
|
+
|
|
138
194
|
/**
|
|
139
195
|
* The queue engine over one open connection.
|
|
140
196
|
* @param {{ connection: any, now?: () => number,
|
|
141
197
|
* random?: () => number,
|
|
142
|
-
*
|
|
198
|
+
* gate?: (fn: () => any, what?: string, signal?: AbortSignal) => any,
|
|
199
|
+
* bracket?: (fn: () => any) => any,
|
|
200
|
+
* defaults?: Partial<typeof JOB_DEFAULTS>,
|
|
201
|
+
* runtime?: Partial<import('@jarenjs/core/runtime').Runtime> }} options
|
|
202
|
+
* `runtime` is the host's runtime record: its `now` and `random` apply
|
|
203
|
+
* where the explicit `now` and `random` options are absent, and its
|
|
204
|
+
* `uuid` mints every job id, lease token and default worker owner.
|
|
205
|
+
* `bracket` runs the first-open DDL (the store's immediate transaction
|
|
206
|
+
* on a writable store); bare when absent.
|
|
143
207
|
*/
|
|
144
208
|
export function createJobEngine(options) {
|
|
145
209
|
const { connection } = options;
|
|
146
|
-
const
|
|
147
|
-
const
|
|
210
|
+
const bracket = options.bracket ?? ((/** @type {() => any} */ fn) => fn());
|
|
211
|
+
const runtime = resolveRuntime(options.runtime);
|
|
212
|
+
const now = options.now ?? runtime.now;
|
|
213
|
+
const random = options.random ?? runtime.random;
|
|
214
|
+
const uuid = runtime.uuid;
|
|
215
|
+
/**
|
|
216
|
+
* The store gate a WORKER's control-plane I/O takes: a worker is a
|
|
217
|
+
* root-owned long-lived component, so its claims, renewals,
|
|
218
|
+
* checkpoint stores and settlements are unrelated callers that wait
|
|
219
|
+
* for an open application transaction instead of joining its fate —
|
|
220
|
+
* even when the worker was created through a transaction view. The
|
|
221
|
+
* engine's bare operations stay ungated here: the STORE decides per
|
|
222
|
+
* handle (root `store.jobs` gates them, `tx.jobs` runs as the exact
|
|
223
|
+
* scope, which is the transactional outbox).
|
|
224
|
+
*/
|
|
225
|
+
const gate = options.gate ?? ((/** @type {() => any} */ fn) => fn());
|
|
148
226
|
const defaults = { ...JOB_DEFAULTS, ...options.defaults };
|
|
149
227
|
|
|
228
|
+
/** Every queue statement failure rides the store's own wrap (§9):
|
|
229
|
+
* a read-only file, a locked database, a constraint — never the
|
|
230
|
+
* driver's raw error. */
|
|
231
|
+
const wrapJobs = (error) => wrapDriverError(error, { docPath: '/jobs', collection: JOBS_TABLE });
|
|
150
232
|
/** @type {Map<string, any>} */
|
|
151
233
|
const statements = new Map();
|
|
152
234
|
const prepared = (key, sql) => {
|
|
153
235
|
let statement = statements.get(key);
|
|
154
236
|
if (statement === undefined) {
|
|
155
|
-
|
|
237
|
+
const raw = connection.prepare(sql);
|
|
238
|
+
statement = {
|
|
239
|
+
run: (params) => attempt(() => raw.run(params), wrapJobs),
|
|
240
|
+
get: (params) => attempt(() => raw.get(params), wrapJobs),
|
|
241
|
+
all: (params) => attempt(() => raw.all(params), wrapJobs),
|
|
242
|
+
};
|
|
156
243
|
statements.set(key, statement);
|
|
157
244
|
}
|
|
158
245
|
return statement;
|
|
@@ -164,13 +251,51 @@ export function createJobEngine(options) {
|
|
|
164
251
|
for (const wake of [...wakers]) wake();
|
|
165
252
|
};
|
|
166
253
|
|
|
167
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Add whatever this database is missing, in place. `CREATE TABLE IF
|
|
256
|
+
* NOT EXISTS` leaves a table written before the fence exactly as it
|
|
257
|
+
* was, so the columns are added one at a time behind a presence
|
|
258
|
+
* check — jobs rows are live work, and a rebuild would drop a queue.
|
|
259
|
+
* Forward-only: nothing is ever removed here.
|
|
260
|
+
*/
|
|
261
|
+
const upgradeColumns = () => {
|
|
262
|
+
const dialect = connection.dialect;
|
|
263
|
+
const next = (i) => {
|
|
264
|
+
if (i >= ADDED_COLUMNS.length) return null;
|
|
265
|
+
const { table, name, definition } = ADDED_COLUMNS[i];
|
|
266
|
+
return chain(connection.prepare(dialect.introspect.columns(table)), (statement) =>
|
|
267
|
+
chain(statement.all([]), (rows) => {
|
|
268
|
+
if (rows.some((/** @type {any} */ row) => row.name === name)) return next(i + 1);
|
|
269
|
+
return chain(
|
|
270
|
+
connection.exec(`ALTER TABLE "${table}" ADD COLUMN ${name} ${definition}`),
|
|
271
|
+
() => next(i + 1));
|
|
272
|
+
}));
|
|
273
|
+
};
|
|
274
|
+
return next(0);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// the tables are created here, or refused here: a read-only store
|
|
278
|
+
// leaked the driver's "attempt to write a readonly database"
|
|
279
|
+
const ready = attempt(() => bracket(() => chain(connection.exec(CREATE_JOBS), upgradeColumns)),
|
|
280
|
+
(error) => new DbCompileError('JD0002',
|
|
281
|
+
`the job tables could not be created (${error?.message ?? String(error)}) — `
|
|
282
|
+
+ 'a read-only store creates nothing; open it read-write once, or without jobs',
|
|
283
|
+
'/jobs', error));
|
|
168
284
|
|
|
169
285
|
const enqueue = (kind, payload, enqueueOptions) => {
|
|
170
286
|
if (typeof kind !== 'string' || kind === '') {
|
|
171
287
|
throw new TypeError('enqueue: "kind" must be a non-empty string');
|
|
172
288
|
}
|
|
173
|
-
const id = enqueueOptions?.id ??
|
|
289
|
+
const id = enqueueOptions?.id ?? uuid();
|
|
290
|
+
// a `runAt` that is not a number stored as NaN and left the job
|
|
291
|
+
// pending forever; a Date could not even be bound
|
|
292
|
+
if (enqueueOptions?.runAt !== undefined && !Number.isFinite(enqueueOptions.runAt)) {
|
|
293
|
+
throw new TypeError('enqueue: "runAt" is an epoch in milliseconds (a finite number)');
|
|
294
|
+
}
|
|
295
|
+
if (enqueueOptions?.maxAttempts !== undefined
|
|
296
|
+
&& !(Number.isInteger(enqueueOptions.maxAttempts) && enqueueOptions.maxAttempts >= 1)) {
|
|
297
|
+
throw new TypeError('enqueue: "maxAttempts" is a positive integer');
|
|
298
|
+
}
|
|
174
299
|
const at = now();
|
|
175
300
|
return chain(prepared('enqueue', `INSERT INTO "${JOBS_TABLE}"
|
|
176
301
|
(id, kind, payload, state, run_at, max_attempts, created_at, updated_at)
|
|
@@ -198,7 +323,7 @@ export function createJobEngine(options) {
|
|
|
198
323
|
WHERE state IN ('pending', 'failed') GROUP BY kind`).all([]),
|
|
199
324
|
(kinds) => {
|
|
200
325
|
const out = {
|
|
201
|
-
pending: 0, leased: 0, done: 0, failed: 0, dead: 0,
|
|
326
|
+
pending: 0, leased: 0, done: 0, failed: 0, dead: 0, cancelled: 0,
|
|
202
327
|
/** @type {Record<string, number>} */
|
|
203
328
|
pendingKinds: {},
|
|
204
329
|
};
|
|
@@ -220,8 +345,13 @@ export function createJobEngine(options) {
|
|
|
220
345
|
}
|
|
221
346
|
const at = now();
|
|
222
347
|
const placeholders = kinds.map(() => '?').join(', ');
|
|
348
|
+
// the claim MINTS the fence: a fresh token for this attempt, and a
|
|
349
|
+
// generation one higher than whatever ran before it. Both come back
|
|
350
|
+
// through the RETURNING the claim already had, so the attempt that
|
|
351
|
+
// holds them is the only one that can settle the job
|
|
223
352
|
const statement = prepared(`claim:${kinds.length}`,
|
|
224
353
|
`UPDATE "${JOBS_TABLE}" SET state='leased', lease_owner=?, lease_until=?,
|
|
354
|
+
lease_generation = lease_generation + 1, lease_token=?,
|
|
225
355
|
attempts = attempts + 1, updated_at=?
|
|
226
356
|
WHERE id = (SELECT id FROM "${JOBS_TABLE}"
|
|
227
357
|
WHERE (state='pending' OR state='failed'
|
|
@@ -230,8 +360,80 @@ export function createJobEngine(options) {
|
|
|
230
360
|
ORDER BY run_at, created_at, id LIMIT 1)
|
|
231
361
|
RETURNING *`);
|
|
232
362
|
return chain(statement.get([
|
|
233
|
-
owner, at + (claimOptions.leaseMs ?? defaults.leaseMs),
|
|
234
|
-
|
|
363
|
+
owner, at + (claimOptions.leaseMs ?? defaults.leaseMs), uuid(),
|
|
364
|
+
at, at, at, ...kinds,
|
|
365
|
+
]), (row) => (row === undefined
|
|
366
|
+
? undefined
|
|
367
|
+
: Object.freeze({ ...publicJob(row), lease: leaseOf(row) })));
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Why a settling call matched no row. A guard that answers zero is not
|
|
372
|
+
* `false` — a caller that cannot tell "already done" from "you are
|
|
373
|
+
* stale" guesses, and guesses wrong (D7). The row itself says which of
|
|
374
|
+
* the three it was.
|
|
375
|
+
* @param {any} lease
|
|
376
|
+
* @param {string} verb
|
|
377
|
+
*/
|
|
378
|
+
const refuseSettlement = (lease, verb) => chain(
|
|
379
|
+
prepared('fenceRow', `SELECT state, lease_token, lease_until, lease_generation
|
|
380
|
+
FROM "${JOBS_TABLE}" WHERE id = ?`).get([lease.jobId]),
|
|
381
|
+
(row) => {
|
|
382
|
+
const at = now();
|
|
383
|
+
// THIS attempt already settled it — a §7 handler that completed
|
|
384
|
+
// transactionally, then the worker's own completion behind it.
|
|
385
|
+
// Settling twice from one attempt is idempotent, not a refusal;
|
|
386
|
+
// the generation says whose settlement the row carries.
|
|
387
|
+
if (row !== undefined && row.state !== 'leased'
|
|
388
|
+
&& Number(row.lease_generation) === lease.generation) return null;
|
|
389
|
+
if (row === undefined || row.state !== 'leased') {
|
|
390
|
+
return new DbRuntimeError('JD2065',
|
|
391
|
+
`${verb} refused: job '${lease.jobId}' is ${row === undefined
|
|
392
|
+
? 'unknown' : `'${row.state}'`}, not leased — it was settled by someone else, `
|
|
393
|
+
+ 'or never existed',
|
|
394
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: lease.jobId });
|
|
395
|
+
}
|
|
396
|
+
if (row.lease_token !== lease.token) {
|
|
397
|
+
return new DbRuntimeError('JD2066',
|
|
398
|
+
`${verb} refused: the lease on job '${lease.jobId}' was superseded — `
|
|
399
|
+
+ `this one is generation ${lease.generation}, the job is on `
|
|
400
|
+
+ `generation ${Number(row.lease_generation)}. Another attempt holds it now; `
|
|
401
|
+
+ 'stop writing on its behalf',
|
|
402
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: lease.jobId });
|
|
403
|
+
}
|
|
404
|
+
if (Number(row.lease_until) <= at) {
|
|
405
|
+
return new DbRuntimeError('JD2067',
|
|
406
|
+
`${verb} refused: the lease on job '${lease.jobId}' expired `
|
|
407
|
+
+ `${at - Number(row.lease_until)}ms ago — renew() while a handler runs longer `
|
|
408
|
+
+ 'than its lease, or claim it with a longer leaseMs',
|
|
409
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: lease.jobId });
|
|
410
|
+
}
|
|
411
|
+
// the row agrees on every guard, so the update matched nothing for
|
|
412
|
+
// a reason this engine does not know: report it rather than retry
|
|
413
|
+
return new DbRuntimeError('JD2065',
|
|
414
|
+
`${verb} refused: job '${lease.jobId}' did not accept the settlement`,
|
|
415
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: lease.jobId });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
/** The guard every settling statement carries (D1): the token, and a
|
|
419
|
+
* lease that is still valid. Never the owner — one worker reuses one
|
|
420
|
+
* owner for every attempt it ever makes. */
|
|
421
|
+
const FENCE = "state='leased' AND lease_token=? AND lease_until > ?";
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Check the lease a settling call was given, before it is used. The
|
|
425
|
+
* pre-fence `(id, owner)` spelling cannot stay a settling call — it is
|
|
426
|
+
* the defect — so it is refused by name rather than silently accepted.
|
|
427
|
+
* @param {any} lease
|
|
428
|
+
* @param {string} verb
|
|
429
|
+
*/
|
|
430
|
+
const requireLease = (lease, verb) => {
|
|
431
|
+
if (isLease(lease)) return null;
|
|
432
|
+
return new DbRuntimeError('JD2068',
|
|
433
|
+
`${verb} takes the lease the claim returned (job.lease), not an id and an owner. `
|
|
434
|
+
+ 'An owner is reused by every attempt one worker makes, so it cannot say WHICH '
|
|
435
|
+
+ 'attempt is settling; the lease can.',
|
|
436
|
+
{ docPath: '/jobs', collection: JOBS_TABLE });
|
|
235
437
|
};
|
|
236
438
|
|
|
237
439
|
/** §4: the jittered exponential backoff. */
|
|
@@ -242,93 +444,373 @@ export function createJobEngine(options) {
|
|
|
242
444
|
};
|
|
243
445
|
|
|
244
446
|
/**
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
447
|
+
* Renew a lease (D2): a NEW token, a later expiry, the same attempt
|
|
448
|
+
* and the same generation — a renewal is not a new attempt. The lease
|
|
449
|
+
* it answers replaces the one it was given, and that one then fails
|
|
450
|
+
* every settling call, so a reference kept across a renewal can never
|
|
451
|
+
* quietly come back to life.
|
|
452
|
+
* @param {any} lease - the lease the claim (or the last renew) returned
|
|
453
|
+
* @param {{ leaseMs?: number }} [renewOptions]
|
|
454
|
+
*/
|
|
455
|
+
const renew = (lease, renewOptions) => {
|
|
456
|
+
const misuse = requireLease(lease, 'renew()');
|
|
457
|
+
if (misuse !== null) throw misuse;
|
|
458
|
+
const at = now();
|
|
459
|
+
return chain(prepared('renew', `UPDATE "${JOBS_TABLE}"
|
|
460
|
+
SET lease_until=?, lease_token=?, updated_at=?
|
|
461
|
+
WHERE id=? AND ${FENCE}
|
|
462
|
+
RETURNING *`).get([
|
|
463
|
+
at + (renewOptions?.leaseMs ?? defaults.leaseMs), uuid(),
|
|
464
|
+
at, lease.jobId, lease.token, at,
|
|
465
|
+
]), (row) => (row === undefined
|
|
466
|
+
? chain(refuseSettlement(lease, 'renew()'), (error) => {
|
|
467
|
+
// a settled job cannot be renewed, whoever settled it
|
|
468
|
+
throw error ?? new DbRuntimeError('JD2065',
|
|
469
|
+
`renew() refused: job '${lease.jobId}' is already settled`,
|
|
470
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: lease.jobId });
|
|
471
|
+
})
|
|
472
|
+
: leaseOf(row)));
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Complete a leased job — guarded by the fence, exactly-once against
|
|
477
|
+
* the store (§3). A result JSON cannot express is refused HERE, before
|
|
478
|
+
* any write, so the caller can fail the attempt instead of the worker.
|
|
479
|
+
* @param {any} lease
|
|
480
|
+
* @param {any} result
|
|
248
481
|
* @throws {TypeError} when the result is not representable
|
|
249
482
|
*/
|
|
250
|
-
const complete = (
|
|
483
|
+
const complete = (lease, result) => {
|
|
484
|
+
const misuse = requireLease(lease, 'complete()');
|
|
485
|
+
if (misuse !== null) throw misuse;
|
|
251
486
|
const serialized = serializeResult(result);
|
|
252
487
|
if (!('text' in serialized)) throw new TypeError(serialized.reason);
|
|
488
|
+
const at = now();
|
|
253
489
|
return chain(
|
|
254
490
|
prepared('complete', `UPDATE "${JOBS_TABLE}"
|
|
255
|
-
SET state='done', result=?, lease_until=NULL, updated_at=?
|
|
256
|
-
WHERE id=? AND
|
|
257
|
-
serialized.text,
|
|
258
|
-
]), (out) => Number(out.changes ?? 0) > 0
|
|
491
|
+
SET state='done', result=?, lease_until=NULL, lease_token=NULL, updated_at=?
|
|
492
|
+
WHERE id=? AND ${FENCE}`).run([
|
|
493
|
+
serialized.text, at, lease.jobId, lease.token, at,
|
|
494
|
+
]), (out) => (Number(out.changes ?? 0) > 0
|
|
495
|
+
? true
|
|
496
|
+
: chain(refuseSettlement(lease, 'complete()'), (error) => {
|
|
497
|
+
if (error !== null) throw error;
|
|
498
|
+
return true; // this attempt's settlement already landed
|
|
499
|
+
})));
|
|
259
500
|
};
|
|
260
501
|
|
|
261
502
|
/** Fail a leased job: schedule the retry or dead-letter (§4). */
|
|
262
|
-
const fail = (
|
|
263
|
-
|
|
264
|
-
|
|
503
|
+
const fail = (lease, error, workerDefaults) => {
|
|
504
|
+
const misuse = requireLease(lease, 'fail()');
|
|
505
|
+
if (misuse !== null) throw misuse;
|
|
506
|
+
return chain(get(lease.jobId), (job) => {
|
|
507
|
+
if (job === undefined) {
|
|
508
|
+
return chain(refuseSettlement(lease, 'fail()'), (refusal) => {
|
|
509
|
+
throw refusal ?? new DbRuntimeError('JD2065',
|
|
510
|
+
`fail() refused: job '${lease.jobId}' is unknown`,
|
|
511
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: lease.jobId });
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
const terminal = job.attempts >= job.maxAttempts;
|
|
515
|
+
const at = now();
|
|
516
|
+
const statement = terminal
|
|
517
|
+
? prepared('dead', `UPDATE "${JOBS_TABLE}"
|
|
518
|
+
SET state='dead', last_error=?, lease_until=NULL, lease_token=NULL, updated_at=?
|
|
519
|
+
WHERE id=? AND ${FENCE}`)
|
|
520
|
+
: prepared('retry', `UPDATE "${JOBS_TABLE}"
|
|
521
|
+
SET state='failed', last_error=?, lease_until=NULL, lease_token=NULL,
|
|
522
|
+
run_at=?, updated_at=?
|
|
523
|
+
WHERE id=? AND ${FENCE}`);
|
|
524
|
+
// host code decides what it throws; reading it must not throw back
|
|
525
|
+
const message = describeValue(error);
|
|
526
|
+
const params = terminal
|
|
527
|
+
? [message, at, lease.jobId, lease.token, at]
|
|
528
|
+
: [message, at + backoffOf(job.attempts, workerDefaults), at,
|
|
529
|
+
lease.jobId, lease.token, at];
|
|
530
|
+
return chain(statement.run(params), (out) => (Number(out.changes ?? 0) > 0
|
|
531
|
+
? true
|
|
532
|
+
: chain(refuseSettlement(lease, 'fail()'), (refusal) => {
|
|
533
|
+
if (refusal !== null) throw refusal;
|
|
534
|
+
return true; // this attempt's settlement already landed
|
|
535
|
+
})));
|
|
536
|
+
});
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* §7: the flow checkpoint store bound to ONE claimed attempt. Every
|
|
541
|
+
* row it writes is stamped with that attempt's generation, and that
|
|
542
|
+
* is what keeps a corpse from erasing the living:
|
|
543
|
+
*
|
|
544
|
+
* - `save` carries the same fence as any other settling call, so a
|
|
545
|
+
* superseded or expired attempt writes nothing;
|
|
546
|
+
* - `load` reads what was written up to and including this
|
|
547
|
+
* generation, so a re-claimed attempt resumes from its
|
|
548
|
+
* predecessor's work rather than from nothing;
|
|
549
|
+
* - `complete`'s prune deletes only generations at or below the
|
|
550
|
+
* settling lease's, so a stale settlement cannot take a newer
|
|
551
|
+
* attempt's checkpoints with it.
|
|
552
|
+
*
|
|
553
|
+
* @param {{ id: string, lease?: any, leaseOwner?: string | null }} job -
|
|
554
|
+
* a claim result, which carries its own lease
|
|
555
|
+
*/
|
|
556
|
+
const checkpointsFor = (job) => {
|
|
557
|
+
const lease = job?.lease;
|
|
558
|
+
const generation = isLease(lease) ? lease.generation : 0;
|
|
559
|
+
return {
|
|
560
|
+
load: (runId) => chain(
|
|
561
|
+
prepared('cpLoad', `SELECT node_id, value FROM "${JOB_CHECKPOINTS_TABLE}"
|
|
562
|
+
WHERE run_id = ? AND generation <= ?`).all([runId, generation]),
|
|
563
|
+
(rows) => (rows.length === 0
|
|
564
|
+
? null
|
|
565
|
+
: {
|
|
566
|
+
values: Object.fromEntries(
|
|
567
|
+
rows.map((row) => [row.node_id, JSON.parse(row.value)])),
|
|
568
|
+
})),
|
|
569
|
+
save: (runId, nodeId, value) => connection.transaction(() => {
|
|
570
|
+
const misuse = requireLease(lease, 'checkpoint save()');
|
|
571
|
+
if (misuse !== null) throw misuse;
|
|
572
|
+
const at = now();
|
|
573
|
+
return chain(
|
|
574
|
+
prepared('cpFence', `SELECT id FROM "${JOBS_TABLE}"
|
|
575
|
+
WHERE id=? AND ${FENCE}`).get([runId, lease.token, at]),
|
|
576
|
+
(row) => {
|
|
577
|
+
if (row === undefined) {
|
|
578
|
+
return chain(refuseSettlement({ ...lease, jobId: runId }, 'checkpoint save()'),
|
|
579
|
+
(error) => {
|
|
580
|
+
// a checkpoint is not a settlement: writing one after the
|
|
581
|
+
// job is settled is still refused, whoever settled it
|
|
582
|
+
throw error ?? new DbRuntimeError('JD2065',
|
|
583
|
+
`checkpoint save() refused: job '${runId}' is already settled`,
|
|
584
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: runId });
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return prepared('cpSave', `INSERT INTO "${JOB_CHECKPOINTS_TABLE}"
|
|
588
|
+
(run_id, node_id, value, generation) VALUES (?, ?, ?, ?)
|
|
589
|
+
ON CONFLICT (run_id, node_id) DO UPDATE
|
|
590
|
+
SET value=excluded.value, generation=excluded.generation`)
|
|
591
|
+
.run([runId, nodeId, JSON.stringify(value), generation]);
|
|
592
|
+
});
|
|
593
|
+
}),
|
|
594
|
+
complete: (runId, result) => connection.transaction(() => chain(
|
|
595
|
+
complete(lease, result),
|
|
596
|
+
() => prepared('cpPrune', `DELETE FROM "${JOB_CHECKPOINTS_TABLE}"
|
|
597
|
+
WHERE run_id = ? AND generation <= ?`).run([runId, generation]))),
|
|
598
|
+
};
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
/** @type {Set<any>} */
|
|
602
|
+
const workers = new Set();
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The attempts in flight in THIS process, by job id: what `cancel`
|
|
606
|
+
* aborts and waits on. One attempt per job at a time — the fence
|
|
607
|
+
* guarantees it — so the job id is the key, and it follows renewals
|
|
608
|
+
* where a token would not.
|
|
609
|
+
* @type {Map<string, { controller: AbortController, cancelled: boolean, done: Promise<void> }>}
|
|
610
|
+
*/
|
|
611
|
+
const attemptsByJob = new Map();
|
|
612
|
+
|
|
613
|
+
// ————— administration (§10): mechanism, never schedule —————
|
|
614
|
+
|
|
615
|
+
const JOB_STATES = Object.freeze(['pending', 'leased', 'done', 'failed', 'dead', 'cancelled']);
|
|
616
|
+
/** The states a job has when nothing more will happen to it on its own. */
|
|
617
|
+
const SETTLED = "('done', 'dead', 'cancelled')";
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* A keyset cursor over the queue, by job id: one record per pull, the
|
|
621
|
+
* record `get` answers. Filters are a closed state and a kind;
|
|
622
|
+
* `after` continues past an id; `limit` bounds the items. The cursor
|
|
623
|
+
* is the store's own — cancellation at row boundaries (`JD2072` /
|
|
624
|
+
* `JD2075`), the driver's streaming class, every driver failure
|
|
625
|
+
* classified — and the store admits it per pull.
|
|
626
|
+
* @param {{ state?: string, kind?: string, after?: string, limit?: number,
|
|
627
|
+
* signal?: AbortSignal, deadline?: number }} [pageOptions]
|
|
628
|
+
*/
|
|
629
|
+
const page = (pageOptions = undefined) => {
|
|
630
|
+
const state = pageOptions?.state;
|
|
631
|
+
if (state !== undefined && !JOB_STATES.includes(state)) {
|
|
632
|
+
throw new TypeError(`page: state is one of ${JOB_STATES.map((s) => `'${s}'`).join(', ')}, got ${
|
|
633
|
+
JSON.stringify(state)}`);
|
|
634
|
+
}
|
|
635
|
+
const kind = pageOptions?.kind;
|
|
636
|
+
if (kind !== undefined && (typeof kind !== 'string' || kind === ''))
|
|
637
|
+
throw new TypeError('page: kind is a non-empty string');
|
|
638
|
+
const after = pageOptions?.after;
|
|
639
|
+
if (after !== undefined && typeof after !== 'string')
|
|
640
|
+
throw new TypeError('page: after is the id to continue past');
|
|
641
|
+
const limit = pageOptions?.limit ?? PAGE_LIMIT_DEFAULT;
|
|
642
|
+
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
643
|
+
throw new TypeError('page: limit is a positive integer');
|
|
644
|
+
const conditions = ['id > ?'];
|
|
645
|
+
const params = [after ?? ''];
|
|
646
|
+
if (state !== undefined) { conditions.push('state = ?'); params.push(state); }
|
|
647
|
+
if (kind !== undefined) { conditions.push('kind = ?'); params.push(kind); }
|
|
648
|
+
params.push(limit);
|
|
649
|
+
const sql = `SELECT * FROM "${JOBS_TABLE}" WHERE ${conditions.join(' AND ')} ORDER BY id LIMIT ?`;
|
|
650
|
+
// a statement of its own per cursor: two live iterators over one
|
|
651
|
+
// cached statement invalidate each other at the driver
|
|
652
|
+
return createCursor({
|
|
653
|
+
...rowClassOf(connection),
|
|
654
|
+
signal: pageOptions?.signal, deadline: pageOptions?.deadline, now,
|
|
655
|
+
wrap: wrapJobs,
|
|
656
|
+
open: () => chain(connection.prepare(sql), (statement) => statement.iterate(params)),
|
|
657
|
+
items: (row) => [publicJob(row)],
|
|
658
|
+
});
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Cancel a job. Queued (pending or failed): the enqueue-time identity
|
|
663
|
+
* authorises, and the row settles as `cancelled` in one write. Claimed:
|
|
664
|
+
* the CURRENT lease authorises, exactly as every settling call (§3) —
|
|
665
|
+
* the fenced write settles the row, and an attempt of this process is
|
|
666
|
+
* aborted through its signal. Answers `true` when this call cancelled
|
|
667
|
+
* it, `false` when it already was; refuses `JD2065` (unknown or
|
|
668
|
+
* settled), `JD2068` (claimed, no lease given), or the lease's own
|
|
669
|
+
* `JD2066`/`JD2067`. The wind-up of a local attempt is awaited by the
|
|
670
|
+
* store, outside the gate the handler's own settlement needs.
|
|
671
|
+
* @param {string} id
|
|
672
|
+
* @param {{ lease?: any, signal?: AbortSignal, deadline?: number }} [cancelOptions]
|
|
673
|
+
*/
|
|
674
|
+
const cancel = (id, cancelOptions = undefined) => {
|
|
675
|
+
if (typeof id !== 'string' || id === '') throw new TypeError('cancel: id is a non-empty string');
|
|
676
|
+
refuseCancelled(cancelOptions, now, { abortCode: 'JD2081', aborted: 'cancel() ran', passed: 'cancel() ran' });
|
|
677
|
+
const lease = cancelOptions?.lease;
|
|
265
678
|
const at = now();
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
679
|
+
/** Abort a local attempt, so its handler winds up with a coded reason. */
|
|
680
|
+
const abortLocal = () => {
|
|
681
|
+
const attempt = attemptsByJob.get(id);
|
|
682
|
+
if (attempt === undefined) return;
|
|
683
|
+
attempt.cancelled = true;
|
|
684
|
+
attempt.controller.abort(new DbRuntimeError('JD2065',
|
|
685
|
+
`job '${id}' was cancelled; this attempt no longer speaks for it`,
|
|
686
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: id }));
|
|
687
|
+
};
|
|
688
|
+
if (lease === undefined) {
|
|
689
|
+
return chain(prepared('cancelQueued', `UPDATE "${JOBS_TABLE}"
|
|
690
|
+
SET state='cancelled', last_error='cancelled', lease_until=NULL, lease_token=NULL, updated_at=?
|
|
691
|
+
WHERE id=? AND state IN ('pending', 'failed')`).run([at, id]), (out) => {
|
|
692
|
+
if (Number(out.changes ?? 0) > 0) return true;
|
|
693
|
+
return chain(get(id), (job) => {
|
|
694
|
+
if (job === undefined) {
|
|
695
|
+
throw new DbRuntimeError('JD2065', `cancel() refused: job '${id}' is unknown`,
|
|
696
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: id });
|
|
697
|
+
}
|
|
698
|
+
if (job.state === 'cancelled') return false;
|
|
699
|
+
if (job.state === 'leased') {
|
|
700
|
+
throw new DbRuntimeError('JD2068',
|
|
701
|
+
`cancel() refused: job '${id}' is claimed — cancelling a claimed job takes the lease `
|
|
702
|
+
+ 'its attempt holds (cancel(id, { lease })), so no stranger settles another\'s work',
|
|
703
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: id });
|
|
704
|
+
}
|
|
705
|
+
throw new DbRuntimeError('JD2065',
|
|
706
|
+
`cancel() refused: job '${id}' is '${job.state}', already settled`,
|
|
707
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: id });
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
const misuse = requireLease(lease, 'cancel()');
|
|
712
|
+
if (misuse !== null) throw misuse;
|
|
713
|
+
if (lease.jobId !== id) throw new TypeError('cancel: the lease belongs to another job');
|
|
714
|
+
return chain(prepared('cancelLeased', `UPDATE "${JOBS_TABLE}"
|
|
715
|
+
SET state='cancelled', last_error='cancelled', lease_until=NULL, lease_token=NULL, updated_at=?
|
|
716
|
+
WHERE id=? AND ${FENCE}`).run([at, id, lease.token, at]), (out) => {
|
|
717
|
+
if (Number(out.changes ?? 0) > 0) {
|
|
718
|
+
abortLocal();
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
return chain(refuseSettlement(lease, 'cancel()'), (error) => {
|
|
722
|
+
if (error !== null) throw error;
|
|
723
|
+
// this attempt already settled it as cancelled: idempotent
|
|
724
|
+
abortLocal();
|
|
725
|
+
return false;
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
/** The wind-up of a local in-flight attempt of `id`, or nothing. */
|
|
731
|
+
const settledLocally = (id) => attemptsByJob.get(id)?.done ?? null;
|
|
280
732
|
|
|
281
733
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
734
|
+
* Return a failed, dead, cancelled or lease-expired job to the queue
|
|
735
|
+
* at the clock's instant — the queue's own ordering. The attempt
|
|
736
|
+
* history is kept: a requeued job's `attempts` counts every claim it
|
|
737
|
+
* ever had, and the next claim increments it, so `maxAttempts` still
|
|
738
|
+
* bounds what follows. Answers `true` when the row moved, `false` when
|
|
739
|
+
* it already was pending; a live lease refuses `JD2068` (requeue is
|
|
740
|
+
* not a steal), unknown or done `JD2065`.
|
|
741
|
+
* @param {string} id
|
|
742
|
+
* @param {{ signal?: AbortSignal, deadline?: number }} [requeueOptions]
|
|
288
743
|
*/
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
`the lease on job '${runId}' was lost; the checkpoint is refused`);
|
|
744
|
+
const requeue = (id, requeueOptions = undefined) => {
|
|
745
|
+
if (typeof id !== 'string' || id === '') throw new TypeError('requeue: id is a non-empty string');
|
|
746
|
+
refuseCancelled(requeueOptions, now, { abortCode: 'JD2081', aborted: 'requeue() ran', passed: 'requeue() ran' });
|
|
747
|
+
const at = now();
|
|
748
|
+
return chain(prepared('requeue', `UPDATE "${JOBS_TABLE}"
|
|
749
|
+
SET state='pending', run_at=?, lease_until=NULL, lease_token=NULL, lease_owner=NULL, updated_at=?
|
|
750
|
+
WHERE id=? AND (state IN ('failed', 'dead', 'cancelled')
|
|
751
|
+
OR (state='leased' AND lease_until < ?))`).run([at, at, id, at]), (out) => {
|
|
752
|
+
if (Number(out.changes ?? 0) > 0) {
|
|
753
|
+
wakeAll();
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
return chain(get(id), (job) => {
|
|
757
|
+
if (job === undefined) {
|
|
758
|
+
throw new DbRuntimeError('JD2065', `requeue() refused: job '${id}' is unknown`,
|
|
759
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: id });
|
|
306
760
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
complete(runId, /** @type {string} */ (job.leaseOwner), result),
|
|
314
|
-
(completed) => {
|
|
315
|
-
if (!completed) {
|
|
316
|
-
throw new Error(
|
|
317
|
-
`the lease on job '${runId}' was lost; the completion is refused`);
|
|
761
|
+
if (job.state === 'pending') return false;
|
|
762
|
+
if (job.state === 'leased') {
|
|
763
|
+
throw new DbRuntimeError('JD2068',
|
|
764
|
+
`requeue() refused: job '${id}' holds a live lease — requeue is not a steal; cancel the `
|
|
765
|
+
+ 'attempt with its lease, or wait for the lease to expire',
|
|
766
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: id });
|
|
318
767
|
}
|
|
319
|
-
|
|
320
|
-
`
|
|
321
|
-
|
|
322
|
-
|
|
768
|
+
throw new DbRuntimeError('JD2065',
|
|
769
|
+
`requeue() refused: job '${id}' is '${job.state}' — a completed job is not re-run`,
|
|
770
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: id });
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
};
|
|
323
774
|
|
|
324
|
-
/**
|
|
325
|
-
|
|
775
|
+
/**
|
|
776
|
+
* Delete settled jobs (done, dead, cancelled) whose last change is
|
|
777
|
+
* older than `settledBefore`, oldest first, at most `limit` of them,
|
|
778
|
+
* with their checkpoints — one transaction. The horizon is required:
|
|
779
|
+
* a sweep with none is retention policy, which is the host's. A
|
|
780
|
+
* second identical sweep answers `{ removed: 0 }`.
|
|
781
|
+
* @param {{ settledBefore: number, limit?: number, signal?: AbortSignal, deadline?: number }} sweepOptions
|
|
782
|
+
*/
|
|
783
|
+
const sweep = (sweepOptions) => {
|
|
784
|
+
const horizon = sweepOptions?.settledBefore;
|
|
785
|
+
if (typeof horizon !== 'number' || !Number.isFinite(horizon))
|
|
786
|
+
throw new TypeError('sweep: settledBefore is required — an epoch in milliseconds before which a settled job is swept');
|
|
787
|
+
const limit = sweepOptions?.limit;
|
|
788
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1))
|
|
789
|
+
throw new TypeError('sweep: limit is a positive integer');
|
|
790
|
+
refuseCancelled(sweepOptions, now, { abortCode: 'JD2081', aborted: 'sweep() ran', passed: 'sweep() ran' });
|
|
791
|
+
const selection = `SELECT id FROM "${JOBS_TABLE}" WHERE state IN ${SETTLED} AND updated_at < ? `
|
|
792
|
+
+ `ORDER BY updated_at, id${limit === undefined ? '' : ' LIMIT ?'}`;
|
|
793
|
+
const params = limit === undefined ? [horizon] : [horizon, limit];
|
|
794
|
+
return connection.transaction(() => chain(
|
|
795
|
+
prepared(`sweepCheckpoints:${limit === undefined ? 'all' : 'bounded'}`,
|
|
796
|
+
`DELETE FROM "${JOB_CHECKPOINTS_TABLE}" WHERE run_id IN (${selection})`).run(params),
|
|
797
|
+
() => chain(prepared(`sweepJobs:${limit === undefined ? 'all' : 'bounded'}`,
|
|
798
|
+
`DELETE FROM "${JOBS_TABLE}" WHERE id IN (${selection})`).run(params),
|
|
799
|
+
(out) => ({ removed: Number(out.changes ?? 0) }))));
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
/** A settling refusal, as opposed to a storage failure: the three
|
|
803
|
+
* the fence raises mean this attempt no longer holds the job, and no
|
|
804
|
+
* amount of retrying will change that. */
|
|
805
|
+
const LOST_CODES = new Set(['JD2065', 'JD2066', 'JD2067']);
|
|
806
|
+
const isLeaseLost = (error) => LOST_CODES.has(/** @type {any} */ (error)?.code);
|
|
326
807
|
|
|
327
808
|
/**
|
|
328
809
|
* §6: N claim-execute loops over one handler registry.
|
|
329
810
|
* @param {{ handlers: Record<string, Function>, concurrency?: number,
|
|
330
811
|
* pollInterval?: number, leaseMs?: number, owner?: string,
|
|
331
|
-
* backoffBase?: number, backoffCap?: number
|
|
812
|
+
* backoffBase?: number, backoffCap?: number, renew?: boolean,
|
|
813
|
+
* onOutcome?: (event: any) => void }} workerOptions
|
|
332
814
|
*/
|
|
333
815
|
const createWorker = (workerOptions) => {
|
|
334
816
|
const handlers = workerOptions?.handlers;
|
|
@@ -339,10 +821,83 @@ export function createJobEngine(options) {
|
|
|
339
821
|
'createWorker: "handlers" must be a non-empty object of functions');
|
|
340
822
|
}
|
|
341
823
|
const kinds = Object.keys(handlers);
|
|
342
|
-
const owner = workerOptions.owner ??
|
|
824
|
+
const owner = workerOptions.owner ?? uuid();
|
|
343
825
|
const concurrency = workerOptions.concurrency ?? 1;
|
|
826
|
+
if (!(Number.isInteger(concurrency) && concurrency >= 1)) {
|
|
827
|
+
// `Array.from({ length: 0 })` started a worker that never claimed
|
|
828
|
+
throw new TypeError('createWorker: "concurrency" is a positive integer');
|
|
829
|
+
}
|
|
344
830
|
const pollInterval = workerOptions.pollInterval ?? defaults.pollInterval;
|
|
345
|
-
const
|
|
831
|
+
const leaseMs = workerOptions.leaseMs ?? defaults.leaseMs;
|
|
832
|
+
// three renewals fit inside one lease, so two may fail — to a
|
|
833
|
+
// stalled event loop or a busy database — before the attempt is
|
|
834
|
+
// actually at risk. A handler that must not outlive its lease says
|
|
835
|
+
// `renew: false` and gets the old behaviour, deliberately.
|
|
836
|
+
const renewing = workerOptions.renew !== false;
|
|
837
|
+
const renewEvery = Math.max(1, Math.floor(leaseMs / 3));
|
|
838
|
+
const stats = {
|
|
839
|
+
claims: 0, completions: 0, failures: 0, polls: 0, wakes: 0, claimErrors: 0,
|
|
840
|
+
/** Attempts cancelled through `cancel()` while their handler ran. */
|
|
841
|
+
cancellations: 0,
|
|
842
|
+
/** Leases replaced while a handler was still running. */
|
|
843
|
+
renewals: 0,
|
|
844
|
+
/** Attempts whose lease was lost mid-flight: aborted, and NEVER
|
|
845
|
+
* counted as a completion or a failure, because this attempt no
|
|
846
|
+
* longer speaks for the job. */
|
|
847
|
+
lostSettlements: 0,
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
/** Tell the host what became of one attempt. An observer that throws
|
|
851
|
+
* must never affect the loop (the app.observe discipline). */
|
|
852
|
+
const notify = (event) => {
|
|
853
|
+
const observer = workerOptions.onOutcome;
|
|
854
|
+
if (typeof observer !== 'function') return;
|
|
855
|
+
try {
|
|
856
|
+
observer(Object.freeze(event));
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
// deliberately swallowed; the attempt has already settled
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* The attempts this worker has in flight, keyed by the FENCE TOKEN.
|
|
865
|
+
* Never by owner and never by job id: one worker reuses one owner for
|
|
866
|
+
* every attempt it makes, and a re-claim of the same job is a
|
|
867
|
+
* different attempt that must not collide with its predecessor's
|
|
868
|
+
* bookkeeping.
|
|
869
|
+
* @type {Map<string, any>}
|
|
870
|
+
*/
|
|
871
|
+
const attempts = new Map();
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Every store operation this worker itself starts — claims,
|
|
875
|
+
* renewals, its checkpoint stores, settlements — while it is in
|
|
876
|
+
* flight. `stop()` waits for the set to drain after the timers are
|
|
877
|
+
* disarmed, so no worker-started database operation can still be
|
|
878
|
+
* running when the caller closes the store: the quiescence half of
|
|
879
|
+
* shutdown, separate from (and never sharing a promise with) the
|
|
880
|
+
* grace-bounded handler drain.
|
|
881
|
+
* @type {Set<Promise<any>>}
|
|
882
|
+
*/
|
|
883
|
+
const controlIo = new Set();
|
|
884
|
+
/**
|
|
885
|
+
* Run one worker-owned store operation through the root gate,
|
|
886
|
+
* tracked for quiescence. A `signal` (the worker's shutdown) takes
|
|
887
|
+
* a still-queued call off the connection's queue, so a stop never
|
|
888
|
+
* waits out a stranger's transaction just to cancel a claim.
|
|
889
|
+
* @param {() => any} fn
|
|
890
|
+
* @param {string} what
|
|
891
|
+
* @param {AbortSignal} [signal]
|
|
892
|
+
* @returns {Promise<any>}
|
|
893
|
+
*/
|
|
894
|
+
const io = (fn, what, signal) => {
|
|
895
|
+
const running = Promise.resolve().then(() => gate(fn, what, signal));
|
|
896
|
+
controlIo.add(running);
|
|
897
|
+
const done = () => controlIo.delete(running);
|
|
898
|
+
running.then(done, done);
|
|
899
|
+
return running;
|
|
900
|
+
};
|
|
346
901
|
|
|
347
902
|
let running = false;
|
|
348
903
|
/** @type {Promise<void>[]} */
|
|
@@ -387,54 +942,220 @@ export function createJobEngine(options) {
|
|
|
387
942
|
/** In-flight handler count, so `stop()` can report what it left. */
|
|
388
943
|
let inFlight = 0;
|
|
389
944
|
|
|
945
|
+
/**
|
|
946
|
+
* The lease this attempt LOST, and everything that follows from it:
|
|
947
|
+
* the renewal timer stops, the handler's signal aborts so it writes
|
|
948
|
+
* nothing else, and the attempt is barred from settling — because
|
|
949
|
+
* another attempt holds the job now, and a settlement from here
|
|
950
|
+
* would discard its work.
|
|
951
|
+
*/
|
|
952
|
+
const loseLease = (attempt, error) => {
|
|
953
|
+
if (attempt.lost !== null) return;
|
|
954
|
+
attempt.lost = error;
|
|
955
|
+
clearTimeout(attempt.timer);
|
|
956
|
+
attempt.timer = null;
|
|
957
|
+
attempt.controller.abort(error);
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
/** Arm the next renewal. Each one REPLACES the lease (D2), so the
|
|
961
|
+
* attempt's newest lease is the only one that settles anything.
|
|
962
|
+
* Never re-armed once the worker is stopping: after `stop()`
|
|
963
|
+
* resolves, no control path may arm a timer. */
|
|
964
|
+
const armRenewal = (attempt) => {
|
|
965
|
+
if (!renewing || !running || attempt.lost !== null || attempt.settled) return;
|
|
966
|
+
attempt.timer = setTimeout(() => {
|
|
967
|
+
if (attempt.lost !== null || attempt.settled) return;
|
|
968
|
+
io(() => renew(attempt.lease, { leaseMs }), 'a lease renewal', shutdown.signal)
|
|
969
|
+
.then((next) => {
|
|
970
|
+
if (attempt.settled) return;
|
|
971
|
+
attempts.delete(attempt.lease.token);
|
|
972
|
+
attempt.lease = next;
|
|
973
|
+
attempts.set(next.token, attempt);
|
|
974
|
+
stats.renewals += 1;
|
|
975
|
+
armRenewal(attempt);
|
|
976
|
+
})
|
|
977
|
+
.catch((error) => {
|
|
978
|
+
// Only the fence's three codes (LOST_CODES) prove this
|
|
979
|
+
// attempt no longer holds the job. Anything else — a busy
|
|
980
|
+
// database, a queue refusal, the shutdown cancelling a
|
|
981
|
+
// queued renewal — is a storage problem the NEXT renewal may
|
|
982
|
+
// well survive: the current immutable lease stays in force,
|
|
983
|
+
// the handler's signal stays live, and the next renewal is
|
|
984
|
+
// armed while there is time. If the lease truly expires
|
|
985
|
+
// first, the next fenced call answers JD2067 and THAT is the
|
|
986
|
+
// one lost outcome; no uncoded "maybe lost" is ever minted.
|
|
987
|
+
if (isLeaseLost(error)) loseLease(attempt, error);
|
|
988
|
+
else armRenewal(attempt);
|
|
989
|
+
});
|
|
990
|
+
}, renewEvery);
|
|
991
|
+
attempt.timer.unref?.();
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
/** Everything one in-flight attempt owns, filed under its token. */
|
|
995
|
+
const beginAttempt = (job) => {
|
|
996
|
+
const controller = new AbortController();
|
|
997
|
+
// what a cancelling caller waits on, resolved by `endAttempt`
|
|
998
|
+
const { promise: done, resolve: finish } = Promise.withResolvers();
|
|
999
|
+
const attempt = {
|
|
1000
|
+
job, lease: job.lease, controller, lost: null, settled: false, timer: null,
|
|
1001
|
+
// the handler winds up for either reason: the worker is stopping,
|
|
1002
|
+
// or the job is no longer this attempt's to finish
|
|
1003
|
+
signal: AbortSignal.any([shutdown.signal, controller.signal]),
|
|
1004
|
+
/** Set by `cancel()`: the attempt's outcome is a cancellation. */
|
|
1005
|
+
cancelled: false,
|
|
1006
|
+
finish,
|
|
1007
|
+
done,
|
|
1008
|
+
};
|
|
1009
|
+
attemptsByJob.set(job.id, attempt);
|
|
1010
|
+
// the checkpoint store follows the attempt's CURRENT lease: a
|
|
1011
|
+
// renewal replaced the token, and a store bound to the old one
|
|
1012
|
+
// would be refused by the fence it is supposed to satisfy. Its
|
|
1013
|
+
// operations are worker control I/O — root-gated and tracked —
|
|
1014
|
+
// so a checkpoint can neither join an unrelated application
|
|
1015
|
+
// transaction nor still be writing when stop() has resolved
|
|
1016
|
+
attempt.checkpoints = {
|
|
1017
|
+
load: (runId) => io(() =>
|
|
1018
|
+
checkpointsFor({ ...job, lease: attempt.lease }).load(runId), 'a checkpoint read'),
|
|
1019
|
+
save: (runId, nodeId, value) => io(() =>
|
|
1020
|
+
checkpointsFor({ ...job, lease: attempt.lease }).save(runId, nodeId, value),
|
|
1021
|
+
'a checkpoint save'),
|
|
1022
|
+
complete: (runId, result) => io(() =>
|
|
1023
|
+
checkpointsFor({ ...job, lease: attempt.lease }).complete(runId, result),
|
|
1024
|
+
'a checkpoint settlement'),
|
|
1025
|
+
};
|
|
1026
|
+
attempts.set(job.lease.token, attempt);
|
|
1027
|
+
armRenewal(attempt);
|
|
1028
|
+
return attempt;
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
const endAttempt = (attempt) => {
|
|
1032
|
+
attempt.settled = true;
|
|
1033
|
+
clearTimeout(attempt.timer);
|
|
1034
|
+
attempt.timer = null;
|
|
1035
|
+
attempts.delete(attempt.lease.token);
|
|
1036
|
+
if (attemptsByJob.get(attempt.job.id) === attempt) attemptsByJob.delete(attempt.job.id);
|
|
1037
|
+
attempt.finish();
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
/** The outcome of an attempt `cancel()` settled: neither a
|
|
1041
|
+
* completion nor a failure, and not a loss — the job was taken
|
|
1042
|
+
* from this attempt on purpose, by name. */
|
|
1043
|
+
const recordCancelled = (attempt) => {
|
|
1044
|
+
stats.cancellations += 1;
|
|
1045
|
+
notify({
|
|
1046
|
+
outcome: 'cancelled',
|
|
1047
|
+
jobId: attempt.job.id, kind: attempt.job.kind,
|
|
1048
|
+
attempt: attempt.lease.attempt, generation: attempt.lease.generation,
|
|
1049
|
+
});
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* An attempt that no longer holds its job. It is its OWN outcome:
|
|
1054
|
+
* counting it as a completion is what let a corpse report success
|
|
1055
|
+
* over work another attempt was still doing, and counting it as a
|
|
1056
|
+
* failure would burn a retry the job never spent.
|
|
1057
|
+
*/
|
|
1058
|
+
const recordLost = (attempt, phase, error) => {
|
|
1059
|
+
stats.lostSettlements += 1;
|
|
1060
|
+
notify({
|
|
1061
|
+
outcome: 'lost', phase,
|
|
1062
|
+
jobId: attempt.job.id, kind: attempt.job.kind,
|
|
1063
|
+
attempt: attempt.lease.attempt, generation: attempt.lease.generation,
|
|
1064
|
+
code: /** @type {any} */ (error)?.code ?? null,
|
|
1065
|
+
reason: describeValue(error),
|
|
1066
|
+
});
|
|
1067
|
+
};
|
|
1068
|
+
|
|
390
1069
|
/**
|
|
391
1070
|
* Record one failed attempt. Reporting a failure must never itself
|
|
392
1071
|
* fail the loop, so a storage error here is swallowed after the
|
|
393
|
-
* attempt count has already been incremented by the claim
|
|
1072
|
+
* attempt count has already been incremented by the claim — but a
|
|
1073
|
+
* FENCE refusal is not a storage error: it says this attempt does
|
|
1074
|
+
* not hold the job, and that is a lost settlement, not a failure.
|
|
394
1075
|
*/
|
|
395
|
-
const recordFailure = async (
|
|
396
|
-
stats.failures += 1;
|
|
1076
|
+
const recordFailure = async (attempt, error) => {
|
|
397
1077
|
try {
|
|
398
|
-
await
|
|
1078
|
+
await io(() => fail(attempt.lease, error, workerOptions), 'a job settlement');
|
|
399
1079
|
}
|
|
400
|
-
catch {
|
|
1080
|
+
catch (refusal) {
|
|
1081
|
+
if (isLeaseLost(refusal)) {
|
|
1082
|
+
recordLost(attempt, 'failure', refusal);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
401
1085
|
// the lease will expire and the job will be re-claimed (§5);
|
|
402
1086
|
// a worker must not die because the failure write failed
|
|
403
1087
|
}
|
|
1088
|
+
stats.failures += 1;
|
|
1089
|
+
notify({
|
|
1090
|
+
outcome: 'failed',
|
|
1091
|
+
jobId: attempt.job.id, kind: attempt.job.kind,
|
|
1092
|
+
attempt: attempt.lease.attempt, generation: attempt.lease.generation,
|
|
1093
|
+
reason: describeValue(error),
|
|
1094
|
+
});
|
|
404
1095
|
};
|
|
405
1096
|
|
|
406
1097
|
/** @param {{ cancelled: boolean }} loopSession */
|
|
407
1098
|
const runOne = async (job, loopSession) => {
|
|
408
1099
|
stats.claims += 1;
|
|
409
1100
|
inFlight += 1;
|
|
1101
|
+
const attempt = beginAttempt(job);
|
|
410
1102
|
try {
|
|
411
1103
|
let result;
|
|
412
1104
|
try {
|
|
413
1105
|
result = await handlers[job.kind](job.payload,
|
|
414
|
-
{ job,
|
|
1106
|
+
{ job, checkpoints: attempt.checkpoints, signal: attempt.signal });
|
|
415
1107
|
}
|
|
416
1108
|
catch (error) {
|
|
417
1109
|
// past cancellation the store is closing: leave the leased
|
|
418
1110
|
// row to expiry-based recovery (§5) instead of racing it
|
|
419
1111
|
if (loopSession.cancelled) return;
|
|
420
|
-
|
|
1112
|
+
if (attempt.cancelled) {
|
|
1113
|
+
recordCancelled(attempt);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (attempt.lost !== null) {
|
|
1117
|
+
recordLost(attempt, 'failure', attempt.lost);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
await recordFailure(attempt, error);
|
|
421
1121
|
return;
|
|
422
1122
|
}
|
|
423
1123
|
if (loopSession.cancelled) return;
|
|
1124
|
+
if (attempt.cancelled) {
|
|
1125
|
+
// it may have produced a result; the job was cancelled under it
|
|
1126
|
+
recordCancelled(attempt);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (attempt.lost !== null) {
|
|
1130
|
+
// it may have produced a perfectly good result; it is simply
|
|
1131
|
+
// not this attempt's to record any more
|
|
1132
|
+
recordLost(attempt, 'completion', attempt.lost);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
424
1135
|
try {
|
|
425
|
-
// a §7 handler may have completed transactionally already;
|
|
426
|
-
//
|
|
427
|
-
await
|
|
1136
|
+
// a §7 handler may have completed transactionally already;
|
|
1137
|
+
// settling twice from ONE attempt is idempotent
|
|
1138
|
+
await io(() => complete(attempt.lease, result ?? null), 'a job settlement');
|
|
428
1139
|
}
|
|
429
1140
|
catch (error) {
|
|
1141
|
+
if (isLeaseLost(error)) {
|
|
1142
|
+
recordLost(attempt, 'completion', error);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
430
1145
|
// an unrepresentable result, or a storage failure at the
|
|
431
1146
|
// completion write: this attempt failed, the worker did not
|
|
432
|
-
await recordFailure(
|
|
1147
|
+
await recordFailure(attempt, error);
|
|
433
1148
|
return;
|
|
434
1149
|
}
|
|
435
1150
|
stats.completions += 1;
|
|
1151
|
+
notify({
|
|
1152
|
+
outcome: 'completed',
|
|
1153
|
+
jobId: job.id, kind: job.kind,
|
|
1154
|
+
attempt: attempt.lease.attempt, generation: attempt.lease.generation,
|
|
1155
|
+
});
|
|
436
1156
|
}
|
|
437
1157
|
finally {
|
|
1158
|
+
endAttempt(attempt);
|
|
438
1159
|
inFlight -= 1;
|
|
439
1160
|
}
|
|
440
1161
|
};
|
|
@@ -444,11 +1165,16 @@ export function createJobEngine(options) {
|
|
|
444
1165
|
while (running && !loopSession.cancelled) {
|
|
445
1166
|
let job;
|
|
446
1167
|
try {
|
|
447
|
-
job = await
|
|
448
|
-
|
|
1168
|
+
job = await io(() => claim({ kinds, owner, leaseMs }),
|
|
1169
|
+
'a worker claim', shutdown.signal);
|
|
449
1170
|
}
|
|
450
|
-
catch {
|
|
451
|
-
|
|
1171
|
+
catch (error) {
|
|
1172
|
+
// a storage failure backs off to the poll — COUNTED, so a
|
|
1173
|
+
// worker on a read-only store is not silently idle forever.
|
|
1174
|
+
// A claim the shutdown cancelled out of the queue (JD2064) is
|
|
1175
|
+
// the stop working, not a storage error, and counts nothing.
|
|
1176
|
+
if (/** @type {any} */ (error)?.code !== 'JD2064') stats.claimErrors += 1;
|
|
1177
|
+
job = undefined;
|
|
452
1178
|
}
|
|
453
1179
|
if (!running || loopSession.cancelled) return;
|
|
454
1180
|
if (job === undefined) {
|
|
@@ -469,6 +1195,10 @@ export function createJobEngine(options) {
|
|
|
469
1195
|
|
|
470
1196
|
const worker = {
|
|
471
1197
|
stats: () => ({ ...stats, inFlight }),
|
|
1198
|
+
/** The leases this worker holds right now, newest token first. A
|
|
1199
|
+
* diagnostic, and the shape a test reads to prove the bookkeeping
|
|
1200
|
+
* is keyed by token rather than by owner. */
|
|
1201
|
+
leases: () => [...attempts.values()].map((attempt) => attempt.lease),
|
|
472
1202
|
start() {
|
|
473
1203
|
if (running) throw new TypeError('the worker is already started');
|
|
474
1204
|
running = true;
|
|
@@ -489,6 +1219,19 @@ export function createJobEngine(options) {
|
|
|
489
1219
|
* A loop the grace period could not drain is cancelled outright:
|
|
490
1220
|
* when its handler finally settles it exits without another
|
|
491
1221
|
* claim, store write or poll timer.
|
|
1222
|
+
*
|
|
1223
|
+
* Two obligations, never sharing one unresolved promise:
|
|
1224
|
+
*
|
|
1225
|
+
* 1. handler DRAIN is bounded by `graceMs` — a hostile handler is
|
|
1226
|
+
* reported through `{ drained: false, inFlight }` rather than
|
|
1227
|
+
* waited out;
|
|
1228
|
+
* 2. QUIESCENCE is unconditional — every claim, renewal,
|
|
1229
|
+
* checkpoint and settlement this worker started is cancelled
|
|
1230
|
+
* (a queued call leaves the connection queue on the shutdown
|
|
1231
|
+
* signal) or drained before `stop()` resolves, and no timer is
|
|
1232
|
+
* left armed, so the caller may close the store knowing no
|
|
1233
|
+
* control path still touches it. A grace timeout may detach a
|
|
1234
|
+
* handler; it may not leave a database operation behind it.
|
|
492
1235
|
* @param {{ graceMs?: number }} [stopOptions]
|
|
493
1236
|
* @returns {Promise<{ drained: boolean, inFlight: number }>}
|
|
494
1237
|
*/
|
|
@@ -506,7 +1249,29 @@ export function createJobEngine(options) {
|
|
|
506
1249
|
]);
|
|
507
1250
|
clearTimeout(timer);
|
|
508
1251
|
if (drained) loops = [];
|
|
509
|
-
else
|
|
1252
|
+
else {
|
|
1253
|
+
session.cancelled = true; // cancel what could not be drained
|
|
1254
|
+
// …and bar it from settling. §6.1's whole point is that a
|
|
1255
|
+
// cancelled loop touches the store the caller is about to
|
|
1256
|
+
// close no further. The abandoned lease then expires and the
|
|
1257
|
+
// job recovers by re-claim (§5), which is exactly what the
|
|
1258
|
+
// cancellation means.
|
|
1259
|
+
for (const abandoned of attempts.values()) abandoned.settled = true;
|
|
1260
|
+
}
|
|
1261
|
+
// quiescence, on BOTH paths: no renewal timer stays armed (and
|
|
1262
|
+
// none re-arms — armRenewal refuses once `running` is false)…
|
|
1263
|
+
for (const attempt of attempts.values()) {
|
|
1264
|
+
clearTimeout(attempt.timer);
|
|
1265
|
+
attempt.timer = null;
|
|
1266
|
+
}
|
|
1267
|
+
// …and whatever control I/O is still in flight — a cancelled
|
|
1268
|
+
// queued claim, a renewal a timer fired before the stop, a
|
|
1269
|
+
// settlement the grace deadline raced — settles before stop()
|
|
1270
|
+
// does. Each is signal-cancelled or bounded by the connection's
|
|
1271
|
+
// own timeouts, so this wait is bounded too; the loop re-checks
|
|
1272
|
+
// because a settling failure may record itself with one more
|
|
1273
|
+
// write.
|
|
1274
|
+
while (controlIo.size > 0) await Promise.allSettled([...controlIo]);
|
|
510
1275
|
wakers.delete(onWake);
|
|
511
1276
|
workers.delete(worker);
|
|
512
1277
|
return { drained: drained === true, inFlight };
|
|
@@ -523,10 +1288,16 @@ export function createJobEngine(options) {
|
|
|
523
1288
|
get,
|
|
524
1289
|
counts,
|
|
525
1290
|
claim,
|
|
1291
|
+
renew,
|
|
526
1292
|
complete,
|
|
527
1293
|
fail,
|
|
528
1294
|
checkpointsFor,
|
|
529
1295
|
createWorker,
|
|
1296
|
+
page,
|
|
1297
|
+
cancel,
|
|
1298
|
+
settledLocally,
|
|
1299
|
+
requeue,
|
|
1300
|
+
sweep,
|
|
530
1301
|
/** Stop every worker, bounded. Resolves to the per-worker outcome so
|
|
531
1302
|
* `close()` can report a handler it could not wait out rather than
|
|
532
1303
|
* hanging on it. */
|