@jarenjs/db 0.56.0 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- 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 +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- 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 +17 -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.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- package/types/wasm.d.ts +14 -0
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,8 +38,11 @@
|
|
|
32
38
|
* timer, and its abandoned job recovers by lease expiry (§5).
|
|
33
39
|
*/
|
|
34
40
|
|
|
41
|
+
import { resolveRuntime } from '@jarenjs/core/runtime';
|
|
35
42
|
import { chain, attempt } from './driver.js';
|
|
36
|
-
import { DbCompileError, DbRuntimeError } from './errors.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';
|
|
37
46
|
|
|
38
47
|
export const JOBS_TABLE = '_jaren_jobs';
|
|
39
48
|
export const JOB_CHECKPOINTS_TABLE = '_jaren_job_checkpoints';
|
|
@@ -103,6 +112,8 @@ const CREATE_JOBS = `CREATE TABLE IF NOT EXISTS "${JOBS_TABLE}" (
|
|
|
103
112
|
max_attempts INTEGER NOT NULL,
|
|
104
113
|
lease_until INTEGER,
|
|
105
114
|
lease_owner TEXT,
|
|
115
|
+
lease_generation INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
lease_token TEXT,
|
|
106
117
|
last_error TEXT,
|
|
107
118
|
result TEXT,
|
|
108
119
|
created_at INTEGER NOT NULL,
|
|
@@ -114,10 +125,25 @@ CREATE TABLE IF NOT EXISTS "${JOB_CHECKPOINTS_TABLE}" (
|
|
|
114
125
|
run_id TEXT NOT NULL,
|
|
115
126
|
node_id TEXT NOT NULL,
|
|
116
127
|
value TEXT NOT NULL,
|
|
128
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
117
129
|
PRIMARY KEY (run_id, node_id)
|
|
118
130
|
);`;
|
|
119
131
|
|
|
120
|
-
/**
|
|
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. */
|
|
121
147
|
function publicJob(row) {
|
|
122
148
|
return Object.freeze({
|
|
123
149
|
id: row.id,
|
|
@@ -129,6 +155,7 @@ function publicJob(row) {
|
|
|
129
155
|
maxAttempts: Number(row.max_attempts),
|
|
130
156
|
leaseUntil: row.lease_until === null ? null : Number(row.lease_until),
|
|
131
157
|
leaseOwner: row.lease_owner,
|
|
158
|
+
leaseGeneration: Number(row.lease_generation ?? 0),
|
|
132
159
|
lastError: row.last_error,
|
|
133
160
|
result: row.result === null ? null : JSON.parse(row.result),
|
|
134
161
|
createdAt: Number(row.created_at),
|
|
@@ -136,26 +163,72 @@ function publicJob(row) {
|
|
|
136
163
|
});
|
|
137
164
|
}
|
|
138
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
|
+
|
|
139
194
|
/**
|
|
140
195
|
* The queue engine over one open connection.
|
|
141
196
|
* @param {{ connection: any, now?: () => number,
|
|
142
197
|
* random?: () => number,
|
|
143
|
-
*
|
|
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.
|
|
144
207
|
*/
|
|
145
208
|
export function createJobEngine(options) {
|
|
146
209
|
const { connection } = options;
|
|
147
|
-
const
|
|
148
|
-
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());
|
|
149
226
|
const defaults = { ...JOB_DEFAULTS, ...options.defaults };
|
|
150
227
|
|
|
151
228
|
/** Every queue statement failure rides the store's own wrap (§9):
|
|
152
229
|
* a read-only file, a locked database, a constraint — never the
|
|
153
230
|
* driver's raw error. */
|
|
154
|
-
const wrapJobs = (error) => (
|
|
155
|
-
? error
|
|
156
|
-
: new DbRuntimeError('JD2005',
|
|
157
|
-
`the database rejected the operation: ${error?.message ?? String(error)}`,
|
|
158
|
-
{ docPath: '/jobs', collection: JOBS_TABLE, cause: error }));
|
|
231
|
+
const wrapJobs = (error) => wrapDriverError(error, { docPath: '/jobs', collection: JOBS_TABLE });
|
|
159
232
|
/** @type {Map<string, any>} */
|
|
160
233
|
const statements = new Map();
|
|
161
234
|
const prepared = (key, sql) => {
|
|
@@ -178,18 +251,42 @@ export function createJobEngine(options) {
|
|
|
178
251
|
for (const wake of [...wakers]) wake();
|
|
179
252
|
};
|
|
180
253
|
|
|
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
|
+
|
|
181
277
|
// the tables are created here, or refused here: a read-only store
|
|
182
278
|
// leaked the driver's "attempt to write a readonly database"
|
|
183
|
-
const ready = attempt(() => connection.exec(CREATE_JOBS),
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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));
|
|
187
284
|
|
|
188
285
|
const enqueue = (kind, payload, enqueueOptions) => {
|
|
189
286
|
if (typeof kind !== 'string' || kind === '') {
|
|
190
287
|
throw new TypeError('enqueue: "kind" must be a non-empty string');
|
|
191
288
|
}
|
|
192
|
-
const id = enqueueOptions?.id ??
|
|
289
|
+
const id = enqueueOptions?.id ?? uuid();
|
|
193
290
|
// a `runAt` that is not a number stored as NaN and left the job
|
|
194
291
|
// pending forever; a Date could not even be bound
|
|
195
292
|
if (enqueueOptions?.runAt !== undefined && !Number.isFinite(enqueueOptions.runAt)) {
|
|
@@ -226,7 +323,7 @@ export function createJobEngine(options) {
|
|
|
226
323
|
WHERE state IN ('pending', 'failed') GROUP BY kind`).all([]),
|
|
227
324
|
(kinds) => {
|
|
228
325
|
const out = {
|
|
229
|
-
pending: 0, leased: 0, done: 0, failed: 0, dead: 0,
|
|
326
|
+
pending: 0, leased: 0, done: 0, failed: 0, dead: 0, cancelled: 0,
|
|
230
327
|
/** @type {Record<string, number>} */
|
|
231
328
|
pendingKinds: {},
|
|
232
329
|
};
|
|
@@ -248,8 +345,13 @@ export function createJobEngine(options) {
|
|
|
248
345
|
}
|
|
249
346
|
const at = now();
|
|
250
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
|
|
251
352
|
const statement = prepared(`claim:${kinds.length}`,
|
|
252
353
|
`UPDATE "${JOBS_TABLE}" SET state='leased', lease_owner=?, lease_until=?,
|
|
354
|
+
lease_generation = lease_generation + 1, lease_token=?,
|
|
253
355
|
attempts = attempts + 1, updated_at=?
|
|
254
356
|
WHERE id = (SELECT id FROM "${JOBS_TABLE}"
|
|
255
357
|
WHERE (state='pending' OR state='failed'
|
|
@@ -258,8 +360,80 @@ export function createJobEngine(options) {
|
|
|
258
360
|
ORDER BY run_at, created_at, id LIMIT 1)
|
|
259
361
|
RETURNING *`);
|
|
260
362
|
return chain(statement.get([
|
|
261
|
-
owner, at + (claimOptions.leaseMs ?? defaults.leaseMs),
|
|
262
|
-
|
|
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 });
|
|
263
437
|
};
|
|
264
438
|
|
|
265
439
|
/** §4: the jittered exponential backoff. */
|
|
@@ -270,93 +444,373 @@ export function createJobEngine(options) {
|
|
|
270
444
|
};
|
|
271
445
|
|
|
272
446
|
/**
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
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
|
|
276
481
|
* @throws {TypeError} when the result is not representable
|
|
277
482
|
*/
|
|
278
|
-
const complete = (
|
|
483
|
+
const complete = (lease, result) => {
|
|
484
|
+
const misuse = requireLease(lease, 'complete()');
|
|
485
|
+
if (misuse !== null) throw misuse;
|
|
279
486
|
const serialized = serializeResult(result);
|
|
280
487
|
if (!('text' in serialized)) throw new TypeError(serialized.reason);
|
|
488
|
+
const at = now();
|
|
281
489
|
return chain(
|
|
282
490
|
prepared('complete', `UPDATE "${JOBS_TABLE}"
|
|
283
|
-
SET state='done', result=?, lease_until=NULL, updated_at=?
|
|
284
|
-
WHERE id=? AND
|
|
285
|
-
serialized.text,
|
|
286
|
-
]), (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
|
+
})));
|
|
287
500
|
};
|
|
288
501
|
|
|
289
502
|
/** Fail a leased job: schedule the retry or dead-letter (§4). */
|
|
290
|
-
const fail = (
|
|
291
|
-
|
|
292
|
-
|
|
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;
|
|
293
678
|
const at = now();
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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;
|
|
308
732
|
|
|
309
733
|
/**
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
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]
|
|
316
743
|
*/
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
`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 });
|
|
334
760
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
complete(runId, /** @type {string} */ (job.leaseOwner), result),
|
|
342
|
-
(completed) => {
|
|
343
|
-
if (!completed) {
|
|
344
|
-
throw new Error(
|
|
345
|
-
`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 });
|
|
346
767
|
}
|
|
347
|
-
|
|
348
|
-
`
|
|
349
|
-
|
|
350
|
-
|
|
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
|
+
};
|
|
351
774
|
|
|
352
|
-
/**
|
|
353
|
-
|
|
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);
|
|
354
807
|
|
|
355
808
|
/**
|
|
356
809
|
* §6: N claim-execute loops over one handler registry.
|
|
357
810
|
* @param {{ handlers: Record<string, Function>, concurrency?: number,
|
|
358
811
|
* pollInterval?: number, leaseMs?: number, owner?: string,
|
|
359
|
-
* backoffBase?: number, backoffCap?: number
|
|
812
|
+
* backoffBase?: number, backoffCap?: number, renew?: boolean,
|
|
813
|
+
* onOutcome?: (event: any) => void }} workerOptions
|
|
360
814
|
*/
|
|
361
815
|
const createWorker = (workerOptions) => {
|
|
362
816
|
const handlers = workerOptions?.handlers;
|
|
@@ -367,14 +821,83 @@ export function createJobEngine(options) {
|
|
|
367
821
|
'createWorker: "handlers" must be a non-empty object of functions');
|
|
368
822
|
}
|
|
369
823
|
const kinds = Object.keys(handlers);
|
|
370
|
-
const owner = workerOptions.owner ??
|
|
824
|
+
const owner = workerOptions.owner ?? uuid();
|
|
371
825
|
const concurrency = workerOptions.concurrency ?? 1;
|
|
372
826
|
if (!(Number.isInteger(concurrency) && concurrency >= 1)) {
|
|
373
827
|
// `Array.from({ length: 0 })` started a worker that never claimed
|
|
374
828
|
throw new TypeError('createWorker: "concurrency" is a positive integer');
|
|
375
829
|
}
|
|
376
830
|
const pollInterval = workerOptions.pollInterval ?? defaults.pollInterval;
|
|
377
|
-
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
|
+
};
|
|
378
901
|
|
|
379
902
|
let running = false;
|
|
380
903
|
/** @type {Promise<void>[]} */
|
|
@@ -419,54 +942,220 @@ export function createJobEngine(options) {
|
|
|
419
942
|
/** In-flight handler count, so `stop()` can report what it left. */
|
|
420
943
|
let inFlight = 0;
|
|
421
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
|
+
|
|
422
1069
|
/**
|
|
423
1070
|
* Record one failed attempt. Reporting a failure must never itself
|
|
424
1071
|
* fail the loop, so a storage error here is swallowed after the
|
|
425
|
-
* 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.
|
|
426
1075
|
*/
|
|
427
|
-
const recordFailure = async (
|
|
428
|
-
stats.failures += 1;
|
|
1076
|
+
const recordFailure = async (attempt, error) => {
|
|
429
1077
|
try {
|
|
430
|
-
await
|
|
1078
|
+
await io(() => fail(attempt.lease, error, workerOptions), 'a job settlement');
|
|
431
1079
|
}
|
|
432
|
-
catch {
|
|
1080
|
+
catch (refusal) {
|
|
1081
|
+
if (isLeaseLost(refusal)) {
|
|
1082
|
+
recordLost(attempt, 'failure', refusal);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
433
1085
|
// the lease will expire and the job will be re-claimed (§5);
|
|
434
1086
|
// a worker must not die because the failure write failed
|
|
435
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
|
+
});
|
|
436
1095
|
};
|
|
437
1096
|
|
|
438
1097
|
/** @param {{ cancelled: boolean }} loopSession */
|
|
439
1098
|
const runOne = async (job, loopSession) => {
|
|
440
1099
|
stats.claims += 1;
|
|
441
1100
|
inFlight += 1;
|
|
1101
|
+
const attempt = beginAttempt(job);
|
|
442
1102
|
try {
|
|
443
1103
|
let result;
|
|
444
1104
|
try {
|
|
445
1105
|
result = await handlers[job.kind](job.payload,
|
|
446
|
-
{ job,
|
|
1106
|
+
{ job, checkpoints: attempt.checkpoints, signal: attempt.signal });
|
|
447
1107
|
}
|
|
448
1108
|
catch (error) {
|
|
449
1109
|
// past cancellation the store is closing: leave the leased
|
|
450
1110
|
// row to expiry-based recovery (§5) instead of racing it
|
|
451
1111
|
if (loopSession.cancelled) return;
|
|
452
|
-
|
|
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);
|
|
453
1121
|
return;
|
|
454
1122
|
}
|
|
455
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
|
+
}
|
|
456
1135
|
try {
|
|
457
|
-
// a §7 handler may have completed transactionally already;
|
|
458
|
-
//
|
|
459
|
-
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');
|
|
460
1139
|
}
|
|
461
1140
|
catch (error) {
|
|
1141
|
+
if (isLeaseLost(error)) {
|
|
1142
|
+
recordLost(attempt, 'completion', error);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
462
1145
|
// an unrepresentable result, or a storage failure at the
|
|
463
1146
|
// completion write: this attempt failed, the worker did not
|
|
464
|
-
await recordFailure(
|
|
1147
|
+
await recordFailure(attempt, error);
|
|
465
1148
|
return;
|
|
466
1149
|
}
|
|
467
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
|
+
});
|
|
468
1156
|
}
|
|
469
1157
|
finally {
|
|
1158
|
+
endAttempt(attempt);
|
|
470
1159
|
inFlight -= 1;
|
|
471
1160
|
}
|
|
472
1161
|
};
|
|
@@ -476,13 +1165,15 @@ export function createJobEngine(options) {
|
|
|
476
1165
|
while (running && !loopSession.cancelled) {
|
|
477
1166
|
let job;
|
|
478
1167
|
try {
|
|
479
|
-
job = await
|
|
480
|
-
|
|
1168
|
+
job = await io(() => claim({ kinds, owner, leaseMs }),
|
|
1169
|
+
'a worker claim', shutdown.signal);
|
|
481
1170
|
}
|
|
482
|
-
catch {
|
|
1171
|
+
catch (error) {
|
|
483
1172
|
// a storage failure backs off to the poll — COUNTED, so a
|
|
484
|
-
// worker on a read-only store is not silently idle forever
|
|
485
|
-
|
|
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;
|
|
486
1177
|
job = undefined;
|
|
487
1178
|
}
|
|
488
1179
|
if (!running || loopSession.cancelled) return;
|
|
@@ -504,6 +1195,10 @@ export function createJobEngine(options) {
|
|
|
504
1195
|
|
|
505
1196
|
const worker = {
|
|
506
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),
|
|
507
1202
|
start() {
|
|
508
1203
|
if (running) throw new TypeError('the worker is already started');
|
|
509
1204
|
running = true;
|
|
@@ -524,6 +1219,19 @@ export function createJobEngine(options) {
|
|
|
524
1219
|
* A loop the grace period could not drain is cancelled outright:
|
|
525
1220
|
* when its handler finally settles it exits without another
|
|
526
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.
|
|
527
1235
|
* @param {{ graceMs?: number }} [stopOptions]
|
|
528
1236
|
* @returns {Promise<{ drained: boolean, inFlight: number }>}
|
|
529
1237
|
*/
|
|
@@ -541,7 +1249,29 @@ export function createJobEngine(options) {
|
|
|
541
1249
|
]);
|
|
542
1250
|
clearTimeout(timer);
|
|
543
1251
|
if (drained) loops = [];
|
|
544
|
-
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]);
|
|
545
1275
|
wakers.delete(onWake);
|
|
546
1276
|
workers.delete(worker);
|
|
547
1277
|
return { drained: drained === true, inFlight };
|
|
@@ -558,10 +1288,16 @@ export function createJobEngine(options) {
|
|
|
558
1288
|
get,
|
|
559
1289
|
counts,
|
|
560
1290
|
claim,
|
|
1291
|
+
renew,
|
|
561
1292
|
complete,
|
|
562
1293
|
fail,
|
|
563
1294
|
checkpointsFor,
|
|
564
1295
|
createWorker,
|
|
1296
|
+
page,
|
|
1297
|
+
cancel,
|
|
1298
|
+
settledLocally,
|
|
1299
|
+
requeue,
|
|
1300
|
+
sweep,
|
|
565
1301
|
/** Stop every worker, bounded. Resolves to the per-worker outcome so
|
|
566
1302
|
* `close()` can report a handler it could not wait out rather than
|
|
567
1303
|
* hanging on it. */
|