pg-boss 12.34.0 → 12.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/manager.js CHANGED
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
3
3
  import EventEmitter from 'node:events';
4
4
  import { serializeError as stringify } from 'serialize-error';
5
5
  import * as Attorney from "./attorney.js";
6
+ import { untracked } from "./activity.js";
6
7
  import { TRANSACTION_ROLLBACK_TIMEOUT_MS } from "./db.js";
7
8
  import * as plans from "./plans.js";
8
9
  import * as timekeeper from "./timekeeper.js";
@@ -89,10 +90,16 @@ const NUMERIC_QUEUE_FIELDS = [
89
90
  'deferredCount',
90
91
  'warningQueueSize',
91
92
  'queuedCount',
93
+ 'readyCount',
92
94
  'activeCount',
93
- 'totalCount'
95
+ 'failedCount',
96
+ 'totalCount',
97
+ 'createdDelta',
98
+ 'completedDelta',
99
+ 'failedDelta',
100
+ 'deltaSeconds'
94
101
  ];
95
- // The count columns shared by live stats and recorded snapshots (the QueueStats shape).
102
+ // The gauges shared by live stats and recorded snapshots (the QueueStats shape).
96
103
  const STATS_COUNT_FIELDS = [
97
104
  'deferredCount',
98
105
  'queuedCount',
@@ -101,6 +108,14 @@ const STATS_COUNT_FIELDS = [
101
108
  'failedCount',
102
109
  'totalCount'
103
110
  ];
111
+ // The throughput counters and the seconds they cover. Only recorded snapshots carry them; see
112
+ // getQueueStats.
113
+ const STATS_DELTA_FIELDS = [
114
+ 'completedDelta',
115
+ 'failedDelta',
116
+ 'createdDelta',
117
+ 'deltaSeconds'
118
+ ];
104
119
  // Stale-cache budget for getQueueStats when persistQueueStats is off. A queue-table cache older than
105
120
  // this means monitoring isn't keeping it current (e.g. supervise was enabled once but isn't now), so
106
121
  // the counts are recomputed and re-cached instead of returned. Defaults to one hour, raised to the
@@ -132,6 +147,10 @@ function rethrowWriteError(err) {
132
147
  }
133
148
  throw err;
134
149
  }
150
+ // For a json value read back from a row and bound again behind `::text::jsonb`. Null stays SQL NULL.
151
+ function toJsonText(value) {
152
+ return value == null ? null : JSON.stringify(value);
153
+ }
135
154
  class Manager extends EventEmitter {
136
155
  events = events;
137
156
  // Warn once per option per instance, not once per fetch.
@@ -152,6 +171,9 @@ class Manager extends EventEmitter {
152
171
  // that transaction's db. #processJobs reads it to interpret its own completion; see the check
153
172
  // there. Weak because the key is the transaction, so an entry goes away with it.
154
173
  #handlerSettledJobs;
174
+ // The retryCount each job of a transactional batch was claimed at, keyed by the transaction the
175
+ // handler was handed, so the handler's own settles through it can be fenced (#handlerAttempts).
176
+ #handlerClaims;
155
177
  // Which GUC this server bounds a transaction with, resolved on the first transactional batch and
156
178
  // kept for the life of the process. Resolving to null means neither is recognised, or the probe
157
179
  // itself failed; a failed probe clears the memo and sets the instant before which no batch asks
@@ -175,6 +197,7 @@ class Manager extends EventEmitter {
175
197
  this.pendingOffWorkCleanups = new Set();
176
198
  this.#spies = new Map();
177
199
  this.#handlerSettledJobs = new WeakMap();
200
+ this.#handlerClaims = new WeakMap();
178
201
  this.#transactionTimeoutGuc = null;
179
202
  this.#transactionTimeoutProbeRetryAt = 0;
180
203
  this.#transactionTimeoutProbeCooldownMs = config.__test__transactionTimeoutProbeCooldownMs ?? TRANSACTION_TIMEOUT_PROBE_COOLDOWN_MS;
@@ -334,7 +357,7 @@ class Manager extends EventEmitter {
334
357
  // The handler opted into perJobResults but did not return an array: a contract violation.
335
358
  // Fail the whole batch so the mistake surfaces and the jobs are retried.
336
359
  const err = new Error('perJobResults handler must resolve with an array of job results');
337
- await this.fail(name, jobs.map(job => job.id), err);
360
+ await this.fail(name, jobs, err);
338
361
  await this.#trackJobsFailed(name, jobs, err);
339
362
  return;
340
363
  }
@@ -367,31 +390,33 @@ class Manager extends EventEmitter {
367
390
  failed.push({ job, output: new Error('no disposition returned by handler') });
368
391
  }
369
392
  }
370
- if (completed.length > 0) {
371
- await this.#completeWithOutputs(name, completed.map(c => ({ id: c.job.id, output: c.output })));
372
- }
373
- if (failed.length > 0) {
374
- await this.#failWithOutputs(name, failed.map(f => ({ id: f.job.id, output: f.output })));
375
- }
376
- if (deadLettered.length > 0) {
377
- await this.#failWithOutputs(name, deadLettered.map(d => ({ id: d.job.id, output: d.output })), true);
378
- }
393
+ const items = (entries) => entries.map(({ job, output }) => ({ id: job.id, retryCount: job.retryCount, output }));
394
+ const completedIds = completed.length > 0 ? await this.#completeWithOutputs(name, items(completed)) : null;
395
+ const failedIds = failed.length > 0 ? await this.#failWithOutputs(name, items(failed)) : null;
396
+ const deadLetteredIds = deadLettered.length > 0 ? await this.#failWithOutputs(name, items(deadLettered), true) : null;
397
+ // Only the jobs each statement actually settled: the attempt fence leaves a job whose claim
398
+ // lapsed alone, and recording it would tell a spy it settled when another attempt holds it.
399
+ const landed = (entries, result) => {
400
+ const settled = new Set(result?.settled ?? []);
401
+ return entries.filter(entry => settled.has(entry.job.id));
402
+ };
379
403
  // Dead lettered jobs end in the same terminal `failed` state as failed jobs on the source queue.
380
- this.#trackJobsSettled(name, completed, [...failed, ...deadLettered]);
404
+ this.#trackJobsSettled(name, landed(completed, completedIds), [...landed(failed, failedIds), ...landed(deadLettered, deadLetteredIds)]);
381
405
  }
382
406
  // Complete a set of active jobs, each with its own output, in a constant number of statements
383
407
  // (one on Postgres, two on a distributed backend). Outputs are serialized like complete()/fail()
384
408
  // and passed as a JSON recordset so the batch size doesn't drive the statement count.
409
+ // Each item carries the retryCount its job was fetched with, which the statement fences on.
385
410
  async #completeWithOutputs(name, items) {
386
411
  const { table } = await this.getQueueCache(name);
387
- const payload = items.map(item => ({ id: item.id, output: this.mapCompletionDataArg(item.output) }));
412
+ const payload = items.map(item => ({ id: item.id, retry_count: item.retryCount, output: this.mapCompletionDataArg(item.output) }));
388
413
  const ids = items.map(item => item.id);
389
414
  if (this.config.noMultiMutationCte) {
390
415
  // Dependency unblocking is handled out of band by the background resolver (Navigator), so
391
416
  // completion is a single statement here too.
392
417
  const sql = plans.completeJobsWithOutputsDistributed(this.config.schema, table);
393
418
  const { rows } = await this.db.executeSql(sql, [name, JSON.stringify(payload)]);
394
- return { jobs: ids, requested: ids.length, affected: rows.length };
419
+ return { jobs: ids, requested: ids.length, affected: rows.length, settled: rows.map(row => row.id) };
395
420
  }
396
421
  const sql = plans.completeJobsWithOutputs(this.config.schema, table);
397
422
  const result = await this.db.executeSql(sql, [name, JSON.stringify(payload)]);
@@ -407,18 +432,19 @@ class Manager extends EventEmitter {
407
432
  if (this.config.noMultiMutationCte) {
408
433
  const outputById = new Map(items.map(item => [item.id, this.mapCompletionDataArg(item.output)]));
409
434
  return this.ensureTransaction(this.db, async (tx) => {
410
- const selectQuery = plans.selectJobsToFailById(this.config.schema, table);
411
- const { rows: jobs } = await tx.executeSql(selectQuery.text, [name, ids]);
435
+ const selectQuery = plans.selectJobsToFailById(this.config.schema, table, true);
436
+ const { rows: jobs } = await tx.executeSql(selectQuery.text, [name, ids, plans.attemptPairs(ids, items.map(item => item.retryCount))]);
412
437
  if (jobs.length === 0) {
413
- return { jobs: ids, requested: ids.length, affected: 0 };
438
+ return { jobs: ids, requested: ids.length, affected: 0, settled: [] };
414
439
  }
440
+ // Only the rows the select matched: see failDistributed.
415
441
  const deleteQuery = plans.deleteJobsToFail(this.config.schema, table);
416
- await tx.executeSql(deleteQuery.text, [name, ids]);
442
+ await tx.executeSql(deleteQuery.text, [name, jobs.map(job => job.id)]);
417
443
  const count = await this.reinsertFailedJobs(tx, table, jobs, null, outputById, forceTerminal);
418
- return { jobs: ids, requested: ids.length, affected: count };
444
+ return { jobs: ids, requested: ids.length, affected: count, settled: jobs.map(job => job.id) };
419
445
  });
420
446
  }
421
- const payload = items.map(item => ({ id: item.id, output: this.mapCompletionDataArg(item.output) }));
447
+ const payload = items.map(item => ({ id: item.id, retry_count: item.retryCount, output: this.mapCompletionDataArg(item.output) }));
422
448
  const sql = forceTerminal
423
449
  ? plans.deadLetterJobsByIdWithOutputs(this.config.schema, table)
424
450
  : plans.failJobsByIdWithOutputs(this.config.schema, table);
@@ -494,7 +520,14 @@ class Manager extends EventEmitter {
494
520
  const heartbeatCandidates = jobs.map(j => j.heartbeatSeconds || 0).filter(s => s > 0);
495
521
  const heartbeatSeconds = heartbeatCandidates.length ? Math.min(...heartbeatCandidates) : 0;
496
522
  const ac = new AbortController();
497
- jobs.forEach(job => { job.signal = ac.signal; });
523
+ // Each job also gets a signal of its own, aborted when the heartbeat finds that job's claim gone
524
+ // (see the timer below), so a handler can stop work whose result can no longer land. Per job
525
+ // rather than per batch: a short heartbeat does not say why a job went missing, and the handler
526
+ // settling one of its own jobs early looks the same as another worker taking it. Aborting only
527
+ // the missing job makes that harmless, since a handler that settled a job no longer holds its
528
+ // claim either, and the rest of the batch runs on untouched.
529
+ const claims = new Map(jobs.map(job => [job.id, new AbortController()]));
530
+ jobs.forEach(job => { job.signal = AbortSignal.any([ac.signal, claims.get(job.id).signal]); });
498
531
  // Store AbortController on worker so it can be aborted after graceful shutdown
499
532
  if (worker) {
500
533
  worker.abortController = ac;
@@ -505,11 +538,30 @@ class Manager extends EventEmitter {
505
538
  const refreshSeconds = heartbeatRefreshSeconds ?? (heartbeatSeconds / 2);
506
539
  const intervalMs = refreshSeconds * 1000;
507
540
  heartbeatTimer = this.config.clock.setInterval(async () => {
541
+ const held = jobs.filter(job => !claims.get(job.id).signal.aborted);
542
+ if (held.length === 0)
543
+ return;
544
+ let touched;
508
545
  try {
509
- await this.touch(name, jobIds);
546
+ touched = await this.touch(name, held);
510
547
  }
511
548
  catch (err) {
549
+ // A heartbeat that could not run says nothing about the claim, so nothing is aborted.
512
550
  this.emit(events.error, err);
551
+ return;
552
+ }
553
+ // Without the ids it refreshed, a heartbeat cannot tell which job went missing, so it aborts
554
+ // nothing rather than everything.
555
+ if (!touched.settled)
556
+ return;
557
+ // touch() is fenced to the attempts this worker fetched, so a job it did not refresh is no
558
+ // longer active under this worker's claim: retried or settled elsewhere, or settled by the
559
+ // handler itself.
560
+ const refreshed = new Set(touched.settled);
561
+ for (const job of held) {
562
+ if (!refreshed.has(job.id)) {
563
+ claims.get(job.id).abort(new Error(`job ${job.id} is no longer active under this worker's claim`));
564
+ }
513
565
  }
514
566
  }, intervalMs);
515
567
  }
@@ -537,13 +589,19 @@ class Manager extends EventEmitter {
537
589
  // the bound lost for the whole cooldown after. Memoized after the first batch either way.
538
590
  const timeoutGuc = timeoutMs > 0 ? await this.#resolveTransactionTimeoutGuc() : null;
539
591
  transaction = await this.db.beginTransaction();
540
- this.#handlerSettledJobs.set(transaction.db, new Set());
592
+ // The handler's view shares these, so a settle it runs on { db: tx } is still recognized and fenced.
593
+ const settled = new Set();
594
+ const claims = new Map(jobs.map(job => [job.id, job.retryCount]));
595
+ for (const db of [transaction.db, untracked(transaction.db)]) {
596
+ this.#handlerSettledJobs.set(db, settled);
597
+ this.#handlerClaims.set(db, claims);
598
+ }
541
599
  if (timeoutGuc) {
542
600
  await this.#applyTransactionTimeout(transaction, timeoutGuc, timeoutMs);
543
601
  }
544
602
  }
545
603
  const handling = transaction
546
- ? callback(jobs, transaction.db)
604
+ ? callback(jobs, untracked(transaction.db))
547
605
  : callback(jobs);
548
606
  const result = await resolveWithinSeconds(this.config.clock, handling, maxExpiration, `handler execution exceeded ${maxExpiration}s`, ac);
549
607
  // An abort resolves the race rather than rejecting it, so on this line an abandoned handler
@@ -567,7 +625,7 @@ class Manager extends EventEmitter {
567
625
  // Read out before the completion below, which goes through the same complete() and would
568
626
  // otherwise record pg-boss's own settle as one the handler made.
569
627
  const settledByHandler = transaction ? this.#takeHandlerSettles(transaction) : null;
570
- const completion = await this.complete(name, jobIds, jobIds.length === 1 ? result : undefined, transaction ? { db: transaction.db } : undefined);
628
+ const completion = await this.complete(name, jobs, jobIds.length === 1 ? result : undefined, transaction ? { db: transaction.db } : undefined);
571
629
  completedResult = result;
572
630
  completedAffected = completion.affected;
573
631
  if (settledByHandler) {
@@ -591,7 +649,7 @@ class Manager extends EventEmitter {
591
649
  err.message = `${err.message} (the handler left its transaction aborted: let a SQL error raised through tx propagate out of the handler, or isolate the statement with a SAVEPOINT)`;
592
650
  }
593
651
  }
594
- await this.fail(name, jobIds, err);
652
+ await this.fail(name, jobs, err);
595
653
  failedError = err;
596
654
  didFail = true;
597
655
  }
@@ -726,6 +784,7 @@ class Manager extends EventEmitter {
726
784
  #takeHandlerSettles(transaction) {
727
785
  const settled = this.#handlerSettledJobs.get(transaction.db) ?? new Set();
728
786
  this.#handlerSettledJobs.delete(transaction.db);
787
+ this.#handlerSettledJobs.delete(untracked(transaction.db));
729
788
  return settled;
730
789
  }
731
790
  // Records a settle a transactional handler ran through the transaction it was handed. A no-op for
@@ -749,6 +808,18 @@ class Manager extends EventEmitter {
749
808
  settled.add(id);
750
809
  }
751
810
  }
811
+ // The attempt fence for a transactional handler settling its own batch through the transaction it
812
+ // was handed. Those settles go through the public complete(), fail(), cancel() and deleteJob(),
813
+ // which carry no fence of their own, and #assertClaimHeld counts whatever they touch as the
814
+ // handler's: unfenced, a handler whose claim lapsed would settle the newer attempt, the counts would
815
+ // agree, and the transaction would commit. Only when every id is one of the batch's; a settle that
816
+ // reaches beyond the batch is not a claim this worker holds, so there is nothing to fence it to.
817
+ #handlerAttempts(options, ids) {
818
+ const claims = options.db && this.#handlerClaims.get(options.db);
819
+ if (!claims || !ids.every(id => claims.has(id)))
820
+ return undefined;
821
+ return ids.map(id => claims.get(id));
822
+ }
752
823
  async start() {
753
824
  this.stopped = false;
754
825
  this.queueCacheInterval = this.config.clock.setInterval(() => this.onCacheQueues({ emit: true }), this.config.queueCacheIntervalSeconds * 1000);
@@ -818,7 +889,7 @@ class Manager extends EventEmitter {
818
889
  // jobs active and its completion finds nothing to settle. Its own failure is reported
819
890
  // rather than thrown: the remaining workers still have to be aborted.
820
891
  try {
821
- await this.fail(worker.name, jobIds, 'pg-boss shut down while active');
892
+ await this.fail(worker.name, worker.jobs, 'pg-boss shut down while active');
822
893
  }
823
894
  catch (err) {
824
895
  try {
@@ -902,6 +973,11 @@ class Manager extends EventEmitter {
902
973
  if (excess.length > 0) {
903
974
  const excessIds = excess.map(job => job.id);
904
975
  await this.restore(name, excessIds);
976
+ // A restored job is claimable again at the same retryCount (restore clears started_on,
977
+ // so the next claim does not increment it), and failWip() fails whatever is left in
978
+ // worker.jobs: left there, a shutdown would fail another worker's claim on it.
979
+ if (worker)
980
+ worker.jobs = allowed;
905
981
  }
906
982
  if (allowed.length > 0) {
907
983
  await this.#processJobs(name, allowed, callback, worker, heartbeatRefreshSeconds, perJobResults, transactional, transactionTimeoutSeconds);
@@ -1004,8 +1080,17 @@ class Manager extends EventEmitter {
1004
1080
  .filter(i => i.state !== 'stopped' && (!INTERNAL_QUEUES[i.name] || includeInternal));
1005
1081
  return data;
1006
1082
  }
1007
- hasPendingCleanups() {
1008
- return this.pendingOffWorkCleanups.size > 0;
1083
+ trackCleanup(cleanup) {
1084
+ this.pendingOffWorkCleanups.add(cleanup);
1085
+ const settled = () => { this.pendingOffWorkCleanups.delete(cleanup); };
1086
+ cleanup.then(settled, settled);
1087
+ }
1088
+ // Resolves once no cleanup is pending, including any a settling cleanup registers, so a graceful
1089
+ // stop() carries on the moment its last worker has stopped.
1090
+ async settleCleanups() {
1091
+ while (this.pendingOffWorkCleanups.size > 0) {
1092
+ await Promise.allSettled([...this.pendingOffWorkCleanups]);
1093
+ }
1009
1094
  }
1010
1095
  async offWork(name, options = { wait: true }) {
1011
1096
  assert(name, 'queue name is required');
@@ -1034,11 +1119,8 @@ class Manager extends EventEmitter {
1034
1119
  this.#cleanupLocalGroupTracking(name);
1035
1120
  }
1036
1121
  else {
1037
- this.pendingOffWorkCleanups.add(cleanupPromise);
1038
- cleanupPromise.finally(() => {
1039
- this.pendingOffWorkCleanups.delete(cleanupPromise);
1040
- this.#cleanupLocalGroupTracking(name);
1041
- });
1122
+ this.trackCleanup(cleanupPromise);
1123
+ cleanupPromise.finally(() => this.#cleanupLocalGroupTracking(name));
1042
1124
  }
1043
1125
  }
1044
1126
  notifyWorker(workerId) {
@@ -1508,6 +1590,20 @@ class Manager extends EventEmitter {
1508
1590
  // heartbeatSeconds, so normalize regardless of includeMetadata.
1509
1591
  return this.#numericJobFields(rows);
1510
1592
  }
1593
+ // The id argument of the calls that settle or refresh a claim. Jobs passed as { id, retryCount }
1594
+ // (what fetch() and work() hand out) come back with their attempts, which fence the call to the
1595
+ // attempt that was fetched (plans.attemptFence). Plain ids come back without, and are not fenced.
1596
+ mapAttemptArg(id, funcName) {
1597
+ assert(id, `${funcName}() requires an id`);
1598
+ const items = Array.isArray(id) ? id : [id];
1599
+ assert(items.length, `${funcName}() requires an id`);
1600
+ if (items.every(item => typeof item === 'string')) {
1601
+ return { ids: items };
1602
+ }
1603
+ const jobs = items;
1604
+ assert(jobs.every(job => job && typeof job === 'object' && typeof job.id === 'string' && Number.isInteger(job.retryCount) && job.retryCount >= 0), `${funcName}() requires either ids or jobs with an id and an integer retryCount, not a mix`);
1605
+ return { ids: jobs.map(job => job.id), attempts: jobs.map(job => job.retryCount) };
1606
+ }
1511
1607
  mapCompletionIdArg(id, funcName) {
1512
1608
  const errorMessage = `${funcName}() requires an id`;
1513
1609
  assert(id, errorMessage);
@@ -1529,27 +1625,30 @@ class Manager extends EventEmitter {
1529
1625
  jobs: ids,
1530
1626
  requested: ids.length,
1531
1627
  affected: result && result.rows ? parseInt(result.rows[0].count) : 0,
1532
- // The settle statements aggregate the ids they touched beside the count; the read-only
1533
- // mutators that share this mapper (resume, restore, retry, touch) do not, so it stays
1534
- // optional and #trackHandlerSettle falls back to the count when it is absent.
1628
+ // The settle statements and touch() aggregate the ids they touched beside the count; the
1629
+ // mutators that share this mapper without doing so (resume, restore, retry) leave it unset, so
1630
+ // it stays optional and #trackHandlerSettle falls back to the count when it is absent.
1535
1631
  settled: result?.rows?.[0]?.ids
1536
1632
  };
1537
1633
  }
1634
+ // Fenced to the fetched attempt (plans.attemptFence) when `id` carries jobs, as the worker's own
1635
+ // settles do, or when a transactional handler settles its own batch through its transaction.
1538
1636
  async complete(name, id, data, options = {}) {
1539
1637
  Attorney.assertQueueName(name);
1540
1638
  const db = this.assertDb(options);
1541
- const ids = this.mapCompletionIdArg(id, 'complete');
1639
+ const { ids, attempts: fetched } = this.mapAttemptArg(id, 'complete');
1640
+ const attempts = fetched ?? this.#handlerAttempts(options, ids);
1542
1641
  const { table } = await this.getQueueCache(name);
1543
1642
  const outputData = this.mapCompletionDataArg(data);
1544
1643
  let response;
1545
1644
  // noMultiMutationCte: split the dependency-unblocking into a separate statement to
1546
1645
  // avoid CockroachDB's multi-mutation CTE limitation (completeJobs updates two tables).
1547
1646
  if (this.config.noMultiMutationCte) {
1548
- response = await this.completeDistributed(name, ids, outputData, table, db, options.includeQueued);
1647
+ response = await this.completeDistributed(name, ids, outputData, table, db, options.includeQueued, attempts);
1549
1648
  }
1550
1649
  else {
1551
- const sql = plans.completeJobs(this.config.schema, table, options.includeQueued);
1552
- const result = await db.executeSql(sql, [name, ids, outputData]);
1650
+ const sql = plans.completeJobs(this.config.schema, table, options.includeQueued, !!attempts);
1651
+ const result = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
1553
1652
  response = this.mapCommandResponse(ids, result);
1554
1653
  }
1555
1654
  this.#trackHandlerSettle(options, response);
@@ -1565,17 +1664,19 @@ class Manager extends EventEmitter {
1565
1664
  }
1566
1665
  return fn(db);
1567
1666
  }
1568
- async completeDistributed(name, ids, outputData, table, db, includeQueued) {
1667
+ async completeDistributed(name, ids, outputData, table, db, includeQueued, attempts) {
1569
1668
  // Dependency unblocking is handled out of band by the background resolver (Navigator), so
1570
1669
  // completion is a single statement on every backend.
1571
- const sql = plans.completeJobsDistributed(this.config.schema, table, includeQueued);
1572
- const { rows } = await db.executeSql(sql, [name, ids, outputData]);
1670
+ const sql = plans.completeJobsDistributed(this.config.schema, table, includeQueued, !!attempts);
1671
+ const { rows } = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
1573
1672
  return { jobs: ids, requested: ids.length, affected: rows.length, settled: rows.map(row => row.id) };
1574
1673
  }
1674
+ // Fenced to the fetched attempt as in complete().
1575
1675
  async fail(name, id, data, options = {}) {
1576
1676
  Attorney.assertQueueName(name);
1577
1677
  const db = this.assertDb(options);
1578
- const ids = this.mapCompletionIdArg(id, 'fail');
1678
+ const { ids, attempts: fetched } = this.mapAttemptArg(id, 'fail');
1679
+ const attempts = fetched ?? this.#handlerAttempts(options, ids);
1579
1680
  const { table } = await this.getQueueCache(name);
1580
1681
  const outputData = this.mapCompletionDataArg(data);
1581
1682
  let response;
@@ -1583,29 +1684,31 @@ class Manager extends EventEmitter {
1583
1684
  // The delete and re-insert run in a single transaction (see ensureTransaction) so the
1584
1685
  // job cannot be lost between the two statements.
1585
1686
  if (this.config.noMultiMutationCte) {
1586
- response = await this.failDistributed(name, ids, outputData, table, db);
1687
+ response = await this.failDistributed(name, ids, outputData, table, db, attempts);
1587
1688
  }
1588
1689
  else {
1589
- const sql = plans.failJobsById(this.config.schema, table);
1590
- const result = await db.executeSql(sql, [name, ids, outputData]);
1690
+ const sql = plans.failJobsById(this.config.schema, table, !!attempts);
1691
+ const result = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
1591
1692
  response = this.mapCommandResponse(ids, result);
1592
1693
  }
1593
1694
  this.#trackHandlerSettle(options, response);
1594
1695
  return response;
1595
1696
  }
1596
- async failDistributed(name, ids, outputData, table, db) {
1697
+ async failDistributed(name, ids, outputData, table, db, attempts) {
1597
1698
  // CockroachDB doesn't support multi-mutation CTEs, but does support transactions, so the
1598
1699
  // delete + re-insert is split into separate statements run atomically.
1599
1700
  return this.ensureTransaction(db, async (tx) => {
1600
1701
  // Step 1: Select jobs to fail
1601
- const selectQuery = plans.selectJobsToFailById(this.config.schema, table);
1602
- const { rows: jobs } = await tx.executeSql(selectQuery.text, [name, ids]);
1702
+ const selectQuery = plans.selectJobsToFailById(this.config.schema, table, !!attempts);
1703
+ const { rows: jobs } = await tx.executeSql(selectQuery.text, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
1603
1704
  if (jobs.length === 0) {
1604
1705
  return { jobs: ids, requested: ids.length, affected: 0, settled: [] };
1605
1706
  }
1606
- // Step 2: Delete the jobs
1707
+ // Step 2: Delete the jobs the select matched, not every id asked about. The re-insert below
1708
+ // only restores the selected rows, so deleting by the requested ids would drop any of them
1709
+ // the select skipped (an already completed or cancelled job) for good.
1607
1710
  const deleteQuery = plans.deleteJobsToFail(this.config.schema, table);
1608
- await tx.executeSql(deleteQuery.text, [name, ids]);
1711
+ await tx.executeSql(deleteQuery.text, [name, jobs.map(job => job.id)]);
1609
1712
  // Step 3: Re-insert jobs with updated state
1610
1713
  const count = await this.reinsertFailedJobs(tx, table, jobs, outputData);
1611
1714
  return { jobs: ids, requested: ids.length, affected: count, settled: jobs.map(job => job.id) };
@@ -1692,6 +1795,11 @@ class Manager extends EventEmitter {
1692
1795
  // Dead-letter provenance, carried through so a job in a dead letter queue that fails here
1693
1796
  // still knows where to be redriven. See failJobsBody for the single-statement path.
1694
1797
  const sourceCreatedOn = job.source_created_on_text ?? job.source_created_on;
1798
+ // The json columns go back as text. Bound as read, a job whose data is an array reaches pg as
1799
+ // a Postgres array literal and a string as bare text, and neither parses as json.
1800
+ const data = toJsonText(job.data);
1801
+ const output = toJsonText(jobOutput);
1802
+ const sourceOutput = toJsonText(job.source_output);
1695
1803
  // forceTerminal (perJobResults `deadletter`) skips retries so the job fails terminally and
1696
1804
  // routes straight to the dead letter queue below.
1697
1805
  const canRetry = !forceTerminal && retryCount < retryLimit;
@@ -1714,13 +1822,13 @@ class Manager extends EventEmitter {
1714
1822
  // pending_dependencies are preserved so flows and heartbeat detection survive a retry
1715
1823
  // (matches the non-distributed failJobs() CTE).
1716
1824
  const { rows } = await tx.executeSql(insertSql, [
1717
- job.id, job.name, job.priority, job.data, 'retry', job.retry_limit, job.retry_count,
1825
+ job.id, job.name, job.priority, data, 'retry', job.retry_limit, job.retry_count,
1718
1826
  job.retry_delay, job.retry_backoff, job.retry_delay_max, startAfter, startedOn,
1719
1827
  job.singleton_key, singletonOn, job.group_id, job.group_tier, job.expire_seconds,
1720
1828
  job.deletion_seconds, createdOn, null, keepUntil, job.policy,
1721
- jobOutput, job.dead_letter,
1829
+ output, job.dead_letter,
1722
1830
  null, job.heartbeat_seconds, job.blocked, job.blocking, job.pending_dependencies,
1723
- job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count
1831
+ job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, sourceOutput, job.source_root_id
1724
1832
  ]);
1725
1833
  // The retry insert can be dropped by ON CONFLICT when the queue policy (e.g. stately,
1726
1834
  // singleton, key_strict_fifo) already has a non-terminal job. Mirror the failed_jobs
@@ -1729,30 +1837,32 @@ class Manager extends EventEmitter {
1729
1837
  }
1730
1838
  if (!retried) {
1731
1839
  await tx.executeSql(insertSql, [
1732
- job.id, job.name, job.priority, job.data, 'failed', job.retry_limit, job.retry_count,
1840
+ job.id, job.name, job.priority, data, 'failed', job.retry_limit, job.retry_count,
1733
1841
  job.retry_delay, job.retry_backoff, job.retry_delay_max, startAfterColumn, startedOn,
1734
1842
  job.singleton_key, singletonOn, job.group_id, job.group_tier, job.expire_seconds,
1735
1843
  job.deletion_seconds, createdOn, new Date(this.config.clock.now()), keepUntil, job.policy,
1736
- jobOutput, job.dead_letter,
1844
+ output, job.dead_letter,
1737
1845
  null, job.heartbeat_seconds, job.blocked, job.blocking, job.pending_dependencies,
1738
- job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count
1846
+ job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, sourceOutput, job.source_root_id
1739
1847
  ]);
1740
1848
  // Insert to dead letter queue if failed and has dead_letter configured
1741
1849
  if (job.dead_letter) {
1742
- await tx.executeSql(dlqSql, [job.dead_letter, job.data, jobOutput, job.name, job.id, createdOn, job.retry_count, job.singleton_key, job.priority, job.group_id, job.group_tier]);
1850
+ await tx.executeSql(dlqSql, [job.dead_letter, data, output, job.name, job.id, createdOn, job.retry_count, job.singleton_key, job.priority, job.group_id, job.group_tier, job.source_root_id]);
1743
1851
  }
1744
1852
  }
1745
1853
  count++;
1746
1854
  }
1747
1855
  return count;
1748
1856
  }
1857
+ // Fenced to the fetched attempt as in complete().
1749
1858
  async deleteJob(name, id, options = {}) {
1750
1859
  Attorney.assertQueueName(name);
1751
1860
  const db = this.assertDb(options);
1752
- const ids = this.mapCompletionIdArg(id, 'deleteJob');
1861
+ const { ids, attempts: fetched } = this.mapAttemptArg(id, 'deleteJob');
1862
+ const attempts = fetched ?? this.#handlerAttempts(options, ids);
1753
1863
  const { table } = await this.getQueueCache(name);
1754
- const sql = plans.deleteJobsById(this.config.schema, table);
1755
- const result = await db.executeSql(sql, [name, ids]);
1864
+ const sql = plans.deleteJobsById(this.config.schema, table, !!attempts);
1865
+ const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
1756
1866
  const response = this.mapCommandResponse(ids, result);
1757
1867
  this.#trackHandlerSettle(options, response);
1758
1868
  return response;
@@ -1793,16 +1903,23 @@ class Manager extends EventEmitter {
1793
1903
  const db = this.assertDb(options);
1794
1904
  const { table } = await this.getQueueCache(name);
1795
1905
  // CockroachDB rejects the single-statement version (a DELETE and an INSERT on one table), so
1796
- // the same move runs as three statements in one transaction. See plans.selectRedriveCandidates.
1906
+ // the same move runs as statements in one transaction. See plans.selectRedriveCandidates.
1797
1907
  if (this.config.noMultiMutationCte) {
1798
1908
  return this.ensureTransaction(db, async (tx) => {
1799
1909
  const { rows } = await tx.executeSql(plans.selectRedriveCandidates(this.config.schema, table), [name, ...filter, limit]);
1800
1910
  if (rows.length === 0)
1801
1911
  return 0;
1802
1912
  const ids = rows.map((row) => row.id);
1803
- const { rows: inserted } = await tx.executeSql(plans.insertRedrivenJobs(this.config.schema, table), [ids, filter[0]]);
1804
- await tx.executeSql(plans.deleteJobsByIds(this.config.schema, table).text, [ids]);
1805
- return inserted.length;
1913
+ const newIds = rows.map((row) => row.new_id);
1914
+ const { rows: inserted } = await tx.executeSql(plans.insertRedrivenJobs(this.config.schema, table), [ids, filter[0], newIds]);
1915
+ const created = new Set(inserted.map((row) => row.id));
1916
+ const moved = ids.filter((_, i) => created.has(newIds[i]));
1917
+ const conflicted = ids.filter((_, i) => !created.has(newIds[i]));
1918
+ if (moved.length)
1919
+ await tx.executeSql(plans.deleteJobsByIds(this.config.schema, table).text, [moved]);
1920
+ if (conflicted.length)
1921
+ await tx.executeSql(plans.failRedriveConflicts(this.config.schema, table), [conflicted, filter[0]]);
1922
+ return moved.length;
1806
1923
  });
1807
1924
  }
1808
1925
  const sql = plans.redriveJobs(this.config.schema, table);
@@ -1833,13 +1950,15 @@ class Manager extends EventEmitter {
1833
1950
  unroutable
1834
1951
  };
1835
1952
  }
1953
+ // Fenced to the fetched attempt as in complete().
1836
1954
  async cancel(name, id, options = {}) {
1837
1955
  Attorney.assertQueueName(name);
1838
1956
  const db = this.assertDb(options);
1839
- const ids = this.mapCompletionIdArg(id, 'cancel');
1957
+ const { ids, attempts: fetched } = this.mapAttemptArg(id, 'cancel');
1958
+ const attempts = fetched ?? this.#handlerAttempts(options, ids);
1840
1959
  const { table } = await this.getQueueCache(name);
1841
- const sql = plans.cancelJobs(this.config.schema, table);
1842
- const result = await db.executeSql(sql, [name, ids]);
1960
+ const sql = plans.cancelJobs(this.config.schema, table, !!attempts);
1961
+ const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
1843
1962
  const response = this.mapCommandResponse(ids, result);
1844
1963
  this.#trackHandlerSettle(options, response);
1845
1964
  return response;
@@ -1870,13 +1989,15 @@ class Manager extends EventEmitter {
1870
1989
  const result = await db.executeSql(sql, [name, ids]);
1871
1990
  return this.mapCommandResponse(ids, result);
1872
1991
  }
1992
+ // Fenced to the fetched attempt as in complete(), so a worker that lost its claim cannot keep a
1993
+ // newer attempt's heartbeat fresh.
1873
1994
  async touch(name, id, options = {}) {
1874
1995
  Attorney.assertQueueName(name);
1875
1996
  const db = this.assertDb(options);
1876
- const ids = this.mapCompletionIdArg(id, 'touch');
1997
+ const { ids, attempts } = this.mapAttemptArg(id, 'touch');
1877
1998
  const { table } = await this.getQueueCache(name);
1878
- const sql = plans.touchJobs(this.config.schema, table);
1879
- const result = await db.executeSql(sql, [name, ids]);
1999
+ const sql = plans.touchJobs(this.config.schema, table, !!attempts);
2000
+ const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
1880
2001
  return this.mapCommandResponse(ids, result);
1881
2002
  }
1882
2003
  async createQueue(name, options = {}) {
@@ -1976,10 +2097,14 @@ class Manager extends EventEmitter {
1976
2097
  const sql = plans.deleteStoredJobs(this.config.schema, table);
1977
2098
  await this.db.executeSql(sql, [name]);
1978
2099
  }
2100
+ // A truncate leaves nothing to count, so it zeroes the cached counts after itself. A monitor pass
2101
+ // that read the table first holds it until done, so the truncate and then the zeroes land after
2102
+ // that pass's write.
1979
2103
  async deleteAllJobs(name) {
1980
2104
  if (!name) {
1981
2105
  const sql = plans.truncateTable(this.config.schema, plans.BASE_JOB_TABLE);
1982
2106
  await this.db.executeSql(sql);
2107
+ await this.db.executeSql(plans.zeroQueueStats(this.config.schema));
1983
2108
  return;
1984
2109
  }
1985
2110
  Attorney.assertQueueName(name);
@@ -1987,6 +2112,7 @@ class Manager extends EventEmitter {
1987
2112
  if (partition) {
1988
2113
  const sql = plans.truncateTable(this.config.schema, table);
1989
2114
  await this.db.executeSql(sql);
2115
+ await this.db.executeSql(plans.zeroQueueStats(this.config.schema, true), [name]);
1990
2116
  }
1991
2117
  else {
1992
2118
  const sql = plans.deleteAllJobs(this.config.schema, table);
@@ -2005,7 +2131,9 @@ class Manager extends EventEmitter {
2005
2131
  async getQueueStats(name, options = {}) {
2006
2132
  Attorney.assertQueueName(name);
2007
2133
  const isCockroach = this.config.backend === 'cockroachdb';
2008
- const toSnapshot = (row) => {
2134
+ // `counted` is true for recorded snapshots. The cache path serves only gauges: the queue table's
2135
+ // counters describe the last pass that counted, not this reading.
2136
+ const toSnapshot = (row, counted = false) => {
2009
2137
  const snapshot = {
2010
2138
  name,
2011
2139
  deferredCount: 0,
@@ -2014,14 +2142,25 @@ class Manager extends EventEmitter {
2014
2142
  activeCount: 0,
2015
2143
  failedCount: 0,
2016
2144
  totalCount: 0,
2145
+ // Null, not zero, until something counted them: with persistQueueStats off
2146
+ // nobody does, and a snapshot captured before 12.35 predates the columns.
2147
+ // Zero would claim the queue was idle.
2148
+ completedDelta: null,
2149
+ failedDelta: null,
2150
+ createdDelta: null,
2151
+ deltaSeconds: null,
2152
+ deltaOn: null,
2017
2153
  capturedOn: row?.capturedOn ?? new Date(this.config.clock.now())
2018
2154
  };
2019
- for (const field of STATS_COUNT_FIELDS) {
2155
+ for (const field of counted ? [...STATS_COUNT_FIELDS, ...STATS_DELTA_FIELDS] : STATS_COUNT_FIELDS) {
2020
2156
  const value = row?.[field];
2021
2157
  // CockroachDB returns integer columns as strings; normalize the counts.
2022
2158
  if (value !== undefined && value !== null)
2023
2159
  snapshot[field] = isCockroach ? Number(value) : value;
2024
2160
  }
2161
+ // The end of the interval the counters cover, handed on as the row holds it, like capturedOn.
2162
+ if (counted && row?.deltaOn != null)
2163
+ snapshot.deltaOn = row.deltaOn;
2025
2164
  return snapshot;
2026
2165
  };
2027
2166
  if (this.config.persistQueueStats) {
@@ -2039,11 +2178,11 @@ class Manager extends EventEmitter {
2039
2178
  assert(Number.isInteger(width) && width >= 1, `getQueueStats: ${mode === 'bucket' ? 'bucketSeconds' : 'maxDataPoints'} must be a positive integer`);
2040
2179
  const sql = plans.getQueueStatsHistoryBucketed(this.config.schema, aggregate, mode);
2041
2180
  const { rows } = await this.db.executeSql(sql, [name, from, to, limit, width]);
2042
- return rows.map(toSnapshot);
2181
+ return rows.map(row => toSnapshot(row, true));
2043
2182
  }
2044
2183
  const sql = plans.getQueueStatsHistory(this.config.schema);
2045
2184
  const { rows } = await this.db.executeSql(sql, [name, from, to, limit]);
2046
- return rows.map(toSnapshot);
2185
+ return rows.map(row => toSnapshot(row, true));
2047
2186
  }
2048
2187
  // persistQueueStats disabled: serve the cached counts the monitor keeps on the queue table.
2049
2188
  // capturedOn is monitor_on, NULL if never monitored, or old if monitoring has since been turned