@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.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. 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 worker transition is guarded by `state='leased' AND
13
- * lease_owner=?`: execution is at-least-once, completion is
14
- * exactly-once. `now` and `random` are injectable the runtime
15
- * defaults are the clock and `Math.random`; every test injects.
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
- /** Map a raw row to the frozen public record (§2). */
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
- * defaults?: Partial<typeof JOB_DEFAULTS> }} options
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 now = options.now ?? Date.now;
148
- const random = options.random ?? Math.random;
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) => (typeof error?.code === 'string' && error.code.startsWith('JD')
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), (error) => new DbCompileError('JD0002',
184
- `the job tables could not be created (${error?.message ?? String(error)}) — `
185
- + 'a read-only store creates nothing; open it read-write once, or without jobs',
186
- '/jobs', error));
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 ?? crypto.randomUUID();
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), at, at, at, ...kinds,
262
- ]), (row) => (row === undefined ? undefined : publicJob(row)));
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
- * Complete a leased job guarded, exactly-once (§3). A result JSON
274
- * cannot express is refused HERE, before any write, so the caller can
275
- * fail the attempt instead of the worker.
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 = (id, owner, result) => {
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 state='leased' AND lease_owner=?`).run([
285
- serialized.text, now(), id, owner,
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 = (id, owner, error, workerDefaults) => chain(get(id), (job) => {
291
- if (job === undefined) return false;
292
- const terminal = job.attempts >= job.maxAttempts;
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
- const statement = terminal
295
- ? prepared('dead', `UPDATE "${JOBS_TABLE}"
296
- SET state='dead', last_error=?, lease_until=NULL, updated_at=?
297
- WHERE id=? AND state='leased' AND lease_owner=?`)
298
- : prepared('retry', `UPDATE "${JOBS_TABLE}"
299
- SET state='failed', last_error=?, lease_until=NULL, run_at=?, updated_at=?
300
- WHERE id=? AND state='leased' AND lease_owner=?`);
301
- // host code decides what it throws; reading it must not throw back
302
- const message = describeValue(error);
303
- const params = terminal
304
- ? [message, at, id, owner]
305
- : [message, at + backoffOf(job.attempts, workerDefaults), at, id, owner];
306
- return chain(statement.run(params), (out) => Number(out.changes ?? 0) > 0);
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
- * §7: the flow checkpoint store bound to ONE claimed job. `save`
311
- * refuses once the lease is lost (the stale run aborts fast);
312
- * `complete` records the result, marks the job done and prunes the
313
- * checkpoint rows in ONE guarded transaction a failure leaves
314
- * neither.
315
- * @param {{ id: string, leaseOwner: string | null }} job
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 checkpointsFor = (job) => ({
318
- load: (runId) => chain(
319
- prepared('cpLoad', `SELECT node_id, value FROM "${JOB_CHECKPOINTS_TABLE}"
320
- WHERE run_id = ?`).all([runId]),
321
- (rows) => (rows.length === 0
322
- ? null
323
- : {
324
- values: Object.fromEntries(
325
- rows.map((row) => [row.node_id, JSON.parse(row.value)])),
326
- })),
327
- save: (runId, nodeId, value) => connection.transaction(() => chain(
328
- prepared('cpOwner', `SELECT lease_owner FROM "${JOBS_TABLE}"
329
- WHERE id=? AND state='leased'`).get([runId]),
330
- (row) => {
331
- if (row === undefined || row.lease_owner !== job.leaseOwner) {
332
- throw new Error(
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
- return prepared('cpSave', `INSERT INTO "${JOB_CHECKPOINTS_TABLE}"
336
- (run_id, node_id, value) VALUES (?, ?, ?)
337
- ON CONFLICT (run_id, node_id) DO UPDATE SET value=excluded.value`)
338
- .run([runId, nodeId, JSON.stringify(value)]);
339
- })),
340
- complete: (runId, result) => connection.transaction(() => chain(
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
- return prepared('cpPrune',
348
- `DELETE FROM "${JOB_CHECKPOINTS_TABLE}" WHERE run_id = ?`).run([runId]);
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
- /** @type {Set<any>} */
353
- const workers = new Set();
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 }} workerOptions
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 ?? crypto.randomUUID();
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 stats = { claims: 0, completions: 0, failures: 0, polls: 0, wakes: 0, claimErrors: 0 };
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 (job, error) => {
428
- stats.failures += 1;
1076
+ const recordFailure = async (attempt, error) => {
429
1077
  try {
430
- await Promise.resolve(fail(job.id, owner, error, workerOptions));
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, checkpointsFor, signal: shutdown.signal });
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
- await recordFailure(job, error);
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; the
458
- // guarded update makes this a no-op then
459
- await Promise.resolve(complete(job.id, owner, result ?? null));
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(job, error);
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 Promise.resolve(claim({
480
- kinds, owner, leaseMs: workerOptions.leaseMs }));
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
- stats.claimErrors += 1;
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 session.cancelled = true; // cancel what could not be drained
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. */