pg-boss 12.34.0 → 12.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -152,6 +167,9 @@ class Manager extends EventEmitter {
152
167
  // that transaction's db. #processJobs reads it to interpret its own completion; see the check
153
168
  // there. Weak because the key is the transaction, so an entry goes away with it.
154
169
  #handlerSettledJobs;
170
+ // The retryCount each job of a transactional batch was claimed at, keyed by the transaction the
171
+ // handler was handed, so the handler's own settles through it can be fenced (#handlerAttempts).
172
+ #handlerClaims;
155
173
  // Which GUC this server bounds a transaction with, resolved on the first transactional batch and
156
174
  // kept for the life of the process. Resolving to null means neither is recognised, or the probe
157
175
  // itself failed; a failed probe clears the memo and sets the instant before which no batch asks
@@ -175,6 +193,7 @@ class Manager extends EventEmitter {
175
193
  this.pendingOffWorkCleanups = new Set();
176
194
  this.#spies = new Map();
177
195
  this.#handlerSettledJobs = new WeakMap();
196
+ this.#handlerClaims = new WeakMap();
178
197
  this.#transactionTimeoutGuc = null;
179
198
  this.#transactionTimeoutProbeRetryAt = 0;
180
199
  this.#transactionTimeoutProbeCooldownMs = config.__test__transactionTimeoutProbeCooldownMs ?? TRANSACTION_TIMEOUT_PROBE_COOLDOWN_MS;
@@ -334,7 +353,7 @@ class Manager extends EventEmitter {
334
353
  // The handler opted into perJobResults but did not return an array: a contract violation.
335
354
  // Fail the whole batch so the mistake surfaces and the jobs are retried.
336
355
  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);
356
+ await this.fail(name, jobs, err);
338
357
  await this.#trackJobsFailed(name, jobs, err);
339
358
  return;
340
359
  }
@@ -367,31 +386,33 @@ class Manager extends EventEmitter {
367
386
  failed.push({ job, output: new Error('no disposition returned by handler') });
368
387
  }
369
388
  }
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
- }
389
+ const items = (entries) => entries.map(({ job, output }) => ({ id: job.id, retryCount: job.retryCount, output }));
390
+ const completedIds = completed.length > 0 ? await this.#completeWithOutputs(name, items(completed)) : null;
391
+ const failedIds = failed.length > 0 ? await this.#failWithOutputs(name, items(failed)) : null;
392
+ const deadLetteredIds = deadLettered.length > 0 ? await this.#failWithOutputs(name, items(deadLettered), true) : null;
393
+ // Only the jobs each statement actually settled: the attempt fence leaves a job whose claim
394
+ // lapsed alone, and recording it would tell a spy it settled when another attempt holds it.
395
+ const landed = (entries, result) => {
396
+ const settled = new Set(result?.settled ?? []);
397
+ return entries.filter(entry => settled.has(entry.job.id));
398
+ };
379
399
  // Dead lettered jobs end in the same terminal `failed` state as failed jobs on the source queue.
380
- this.#trackJobsSettled(name, completed, [...failed, ...deadLettered]);
400
+ this.#trackJobsSettled(name, landed(completed, completedIds), [...landed(failed, failedIds), ...landed(deadLettered, deadLetteredIds)]);
381
401
  }
382
402
  // Complete a set of active jobs, each with its own output, in a constant number of statements
383
403
  // (one on Postgres, two on a distributed backend). Outputs are serialized like complete()/fail()
384
404
  // and passed as a JSON recordset so the batch size doesn't drive the statement count.
405
+ // Each item carries the retryCount its job was fetched with, which the statement fences on.
385
406
  async #completeWithOutputs(name, items) {
386
407
  const { table } = await this.getQueueCache(name);
387
- const payload = items.map(item => ({ id: item.id, output: this.mapCompletionDataArg(item.output) }));
408
+ const payload = items.map(item => ({ id: item.id, retry_count: item.retryCount, output: this.mapCompletionDataArg(item.output) }));
388
409
  const ids = items.map(item => item.id);
389
410
  if (this.config.noMultiMutationCte) {
390
411
  // Dependency unblocking is handled out of band by the background resolver (Navigator), so
391
412
  // completion is a single statement here too.
392
413
  const sql = plans.completeJobsWithOutputsDistributed(this.config.schema, table);
393
414
  const { rows } = await this.db.executeSql(sql, [name, JSON.stringify(payload)]);
394
- return { jobs: ids, requested: ids.length, affected: rows.length };
415
+ return { jobs: ids, requested: ids.length, affected: rows.length, settled: rows.map(row => row.id) };
395
416
  }
396
417
  const sql = plans.completeJobsWithOutputs(this.config.schema, table);
397
418
  const result = await this.db.executeSql(sql, [name, JSON.stringify(payload)]);
@@ -407,18 +428,19 @@ class Manager extends EventEmitter {
407
428
  if (this.config.noMultiMutationCte) {
408
429
  const outputById = new Map(items.map(item => [item.id, this.mapCompletionDataArg(item.output)]));
409
430
  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]);
431
+ const selectQuery = plans.selectJobsToFailById(this.config.schema, table, true);
432
+ const { rows: jobs } = await tx.executeSql(selectQuery.text, [name, ids, plans.attemptPairs(ids, items.map(item => item.retryCount))]);
412
433
  if (jobs.length === 0) {
413
- return { jobs: ids, requested: ids.length, affected: 0 };
434
+ return { jobs: ids, requested: ids.length, affected: 0, settled: [] };
414
435
  }
436
+ // Only the rows the select matched: see failDistributed.
415
437
  const deleteQuery = plans.deleteJobsToFail(this.config.schema, table);
416
- await tx.executeSql(deleteQuery.text, [name, ids]);
438
+ await tx.executeSql(deleteQuery.text, [name, jobs.map(job => job.id)]);
417
439
  const count = await this.reinsertFailedJobs(tx, table, jobs, null, outputById, forceTerminal);
418
- return { jobs: ids, requested: ids.length, affected: count };
440
+ return { jobs: ids, requested: ids.length, affected: count, settled: jobs.map(job => job.id) };
419
441
  });
420
442
  }
421
- const payload = items.map(item => ({ id: item.id, output: this.mapCompletionDataArg(item.output) }));
443
+ const payload = items.map(item => ({ id: item.id, retry_count: item.retryCount, output: this.mapCompletionDataArg(item.output) }));
422
444
  const sql = forceTerminal
423
445
  ? plans.deadLetterJobsByIdWithOutputs(this.config.schema, table)
424
446
  : plans.failJobsByIdWithOutputs(this.config.schema, table);
@@ -494,7 +516,14 @@ class Manager extends EventEmitter {
494
516
  const heartbeatCandidates = jobs.map(j => j.heartbeatSeconds || 0).filter(s => s > 0);
495
517
  const heartbeatSeconds = heartbeatCandidates.length ? Math.min(...heartbeatCandidates) : 0;
496
518
  const ac = new AbortController();
497
- jobs.forEach(job => { job.signal = ac.signal; });
519
+ // Each job also gets a signal of its own, aborted when the heartbeat finds that job's claim gone
520
+ // (see the timer below), so a handler can stop work whose result can no longer land. Per job
521
+ // rather than per batch: a short heartbeat does not say why a job went missing, and the handler
522
+ // settling one of its own jobs early looks the same as another worker taking it. Aborting only
523
+ // the missing job makes that harmless, since a handler that settled a job no longer holds its
524
+ // claim either, and the rest of the batch runs on untouched.
525
+ const claims = new Map(jobs.map(job => [job.id, new AbortController()]));
526
+ jobs.forEach(job => { job.signal = AbortSignal.any([ac.signal, claims.get(job.id).signal]); });
498
527
  // Store AbortController on worker so it can be aborted after graceful shutdown
499
528
  if (worker) {
500
529
  worker.abortController = ac;
@@ -505,11 +534,30 @@ class Manager extends EventEmitter {
505
534
  const refreshSeconds = heartbeatRefreshSeconds ?? (heartbeatSeconds / 2);
506
535
  const intervalMs = refreshSeconds * 1000;
507
536
  heartbeatTimer = this.config.clock.setInterval(async () => {
537
+ const held = jobs.filter(job => !claims.get(job.id).signal.aborted);
538
+ if (held.length === 0)
539
+ return;
540
+ let touched;
508
541
  try {
509
- await this.touch(name, jobIds);
542
+ touched = await this.touch(name, held);
510
543
  }
511
544
  catch (err) {
545
+ // A heartbeat that could not run says nothing about the claim, so nothing is aborted.
512
546
  this.emit(events.error, err);
547
+ return;
548
+ }
549
+ // Without the ids it refreshed, a heartbeat cannot tell which job went missing, so it aborts
550
+ // nothing rather than everything.
551
+ if (!touched.settled)
552
+ return;
553
+ // touch() is fenced to the attempts this worker fetched, so a job it did not refresh is no
554
+ // longer active under this worker's claim: retried or settled elsewhere, or settled by the
555
+ // handler itself.
556
+ const refreshed = new Set(touched.settled);
557
+ for (const job of held) {
558
+ if (!refreshed.has(job.id)) {
559
+ claims.get(job.id).abort(new Error(`job ${job.id} is no longer active under this worker's claim`));
560
+ }
513
561
  }
514
562
  }, intervalMs);
515
563
  }
@@ -537,13 +585,19 @@ class Manager extends EventEmitter {
537
585
  // the bound lost for the whole cooldown after. Memoized after the first batch either way.
538
586
  const timeoutGuc = timeoutMs > 0 ? await this.#resolveTransactionTimeoutGuc() : null;
539
587
  transaction = await this.db.beginTransaction();
540
- this.#handlerSettledJobs.set(transaction.db, new Set());
588
+ // The handler's view shares these, so a settle it runs on { db: tx } is still recognized and fenced.
589
+ const settled = new Set();
590
+ const claims = new Map(jobs.map(job => [job.id, job.retryCount]));
591
+ for (const db of [transaction.db, untracked(transaction.db)]) {
592
+ this.#handlerSettledJobs.set(db, settled);
593
+ this.#handlerClaims.set(db, claims);
594
+ }
541
595
  if (timeoutGuc) {
542
596
  await this.#applyTransactionTimeout(transaction, timeoutGuc, timeoutMs);
543
597
  }
544
598
  }
545
599
  const handling = transaction
546
- ? callback(jobs, transaction.db)
600
+ ? callback(jobs, untracked(transaction.db))
547
601
  : callback(jobs);
548
602
  const result = await resolveWithinSeconds(this.config.clock, handling, maxExpiration, `handler execution exceeded ${maxExpiration}s`, ac);
549
603
  // An abort resolves the race rather than rejecting it, so on this line an abandoned handler
@@ -567,7 +621,7 @@ class Manager extends EventEmitter {
567
621
  // Read out before the completion below, which goes through the same complete() and would
568
622
  // otherwise record pg-boss's own settle as one the handler made.
569
623
  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);
624
+ const completion = await this.complete(name, jobs, jobIds.length === 1 ? result : undefined, transaction ? { db: transaction.db } : undefined);
571
625
  completedResult = result;
572
626
  completedAffected = completion.affected;
573
627
  if (settledByHandler) {
@@ -591,7 +645,7 @@ class Manager extends EventEmitter {
591
645
  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
646
  }
593
647
  }
594
- await this.fail(name, jobIds, err);
648
+ await this.fail(name, jobs, err);
595
649
  failedError = err;
596
650
  didFail = true;
597
651
  }
@@ -726,6 +780,7 @@ class Manager extends EventEmitter {
726
780
  #takeHandlerSettles(transaction) {
727
781
  const settled = this.#handlerSettledJobs.get(transaction.db) ?? new Set();
728
782
  this.#handlerSettledJobs.delete(transaction.db);
783
+ this.#handlerSettledJobs.delete(untracked(transaction.db));
729
784
  return settled;
730
785
  }
731
786
  // Records a settle a transactional handler ran through the transaction it was handed. A no-op for
@@ -749,6 +804,18 @@ class Manager extends EventEmitter {
749
804
  settled.add(id);
750
805
  }
751
806
  }
807
+ // The attempt fence for a transactional handler settling its own batch through the transaction it
808
+ // was handed. Those settles go through the public complete(), fail(), cancel() and deleteJob(),
809
+ // which carry no fence of their own, and #assertClaimHeld counts whatever they touch as the
810
+ // handler's: unfenced, a handler whose claim lapsed would settle the newer attempt, the counts would
811
+ // agree, and the transaction would commit. Only when every id is one of the batch's; a settle that
812
+ // reaches beyond the batch is not a claim this worker holds, so there is nothing to fence it to.
813
+ #handlerAttempts(options, ids) {
814
+ const claims = options.db && this.#handlerClaims.get(options.db);
815
+ if (!claims || !ids.every(id => claims.has(id)))
816
+ return undefined;
817
+ return ids.map(id => claims.get(id));
818
+ }
752
819
  async start() {
753
820
  this.stopped = false;
754
821
  this.queueCacheInterval = this.config.clock.setInterval(() => this.onCacheQueues({ emit: true }), this.config.queueCacheIntervalSeconds * 1000);
@@ -818,7 +885,7 @@ class Manager extends EventEmitter {
818
885
  // jobs active and its completion finds nothing to settle. Its own failure is reported
819
886
  // rather than thrown: the remaining workers still have to be aborted.
820
887
  try {
821
- await this.fail(worker.name, jobIds, 'pg-boss shut down while active');
888
+ await this.fail(worker.name, worker.jobs, 'pg-boss shut down while active');
822
889
  }
823
890
  catch (err) {
824
891
  try {
@@ -902,6 +969,11 @@ class Manager extends EventEmitter {
902
969
  if (excess.length > 0) {
903
970
  const excessIds = excess.map(job => job.id);
904
971
  await this.restore(name, excessIds);
972
+ // A restored job is claimable again at the same retryCount (restore clears started_on,
973
+ // so the next claim does not increment it), and failWip() fails whatever is left in
974
+ // worker.jobs: left there, a shutdown would fail another worker's claim on it.
975
+ if (worker)
976
+ worker.jobs = allowed;
905
977
  }
906
978
  if (allowed.length > 0) {
907
979
  await this.#processJobs(name, allowed, callback, worker, heartbeatRefreshSeconds, perJobResults, transactional, transactionTimeoutSeconds);
@@ -1508,6 +1580,20 @@ class Manager extends EventEmitter {
1508
1580
  // heartbeatSeconds, so normalize regardless of includeMetadata.
1509
1581
  return this.#numericJobFields(rows);
1510
1582
  }
1583
+ // The id argument of the calls that settle or refresh a claim. Jobs passed as { id, retryCount }
1584
+ // (what fetch() and work() hand out) come back with their attempts, which fence the call to the
1585
+ // attempt that was fetched (plans.attemptFence). Plain ids come back without, and are not fenced.
1586
+ mapAttemptArg(id, funcName) {
1587
+ assert(id, `${funcName}() requires an id`);
1588
+ const items = Array.isArray(id) ? id : [id];
1589
+ assert(items.length, `${funcName}() requires an id`);
1590
+ if (items.every(item => typeof item === 'string')) {
1591
+ return { ids: items };
1592
+ }
1593
+ const jobs = items;
1594
+ 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`);
1595
+ return { ids: jobs.map(job => job.id), attempts: jobs.map(job => job.retryCount) };
1596
+ }
1511
1597
  mapCompletionIdArg(id, funcName) {
1512
1598
  const errorMessage = `${funcName}() requires an id`;
1513
1599
  assert(id, errorMessage);
@@ -1529,27 +1615,30 @@ class Manager extends EventEmitter {
1529
1615
  jobs: ids,
1530
1616
  requested: ids.length,
1531
1617
  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.
1618
+ // The settle statements and touch() aggregate the ids they touched beside the count; the
1619
+ // mutators that share this mapper without doing so (resume, restore, retry) leave it unset, so
1620
+ // it stays optional and #trackHandlerSettle falls back to the count when it is absent.
1535
1621
  settled: result?.rows?.[0]?.ids
1536
1622
  };
1537
1623
  }
1624
+ // Fenced to the fetched attempt (plans.attemptFence) when `id` carries jobs, as the worker's own
1625
+ // settles do, or when a transactional handler settles its own batch through its transaction.
1538
1626
  async complete(name, id, data, options = {}) {
1539
1627
  Attorney.assertQueueName(name);
1540
1628
  const db = this.assertDb(options);
1541
- const ids = this.mapCompletionIdArg(id, 'complete');
1629
+ const { ids, attempts: fetched } = this.mapAttemptArg(id, 'complete');
1630
+ const attempts = fetched ?? this.#handlerAttempts(options, ids);
1542
1631
  const { table } = await this.getQueueCache(name);
1543
1632
  const outputData = this.mapCompletionDataArg(data);
1544
1633
  let response;
1545
1634
  // noMultiMutationCte: split the dependency-unblocking into a separate statement to
1546
1635
  // avoid CockroachDB's multi-mutation CTE limitation (completeJobs updates two tables).
1547
1636
  if (this.config.noMultiMutationCte) {
1548
- response = await this.completeDistributed(name, ids, outputData, table, db, options.includeQueued);
1637
+ response = await this.completeDistributed(name, ids, outputData, table, db, options.includeQueued, attempts);
1549
1638
  }
1550
1639
  else {
1551
- const sql = plans.completeJobs(this.config.schema, table, options.includeQueued);
1552
- const result = await db.executeSql(sql, [name, ids, outputData]);
1640
+ const sql = plans.completeJobs(this.config.schema, table, options.includeQueued, !!attempts);
1641
+ const result = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
1553
1642
  response = this.mapCommandResponse(ids, result);
1554
1643
  }
1555
1644
  this.#trackHandlerSettle(options, response);
@@ -1565,17 +1654,19 @@ class Manager extends EventEmitter {
1565
1654
  }
1566
1655
  return fn(db);
1567
1656
  }
1568
- async completeDistributed(name, ids, outputData, table, db, includeQueued) {
1657
+ async completeDistributed(name, ids, outputData, table, db, includeQueued, attempts) {
1569
1658
  // Dependency unblocking is handled out of band by the background resolver (Navigator), so
1570
1659
  // 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]);
1660
+ const sql = plans.completeJobsDistributed(this.config.schema, table, includeQueued, !!attempts);
1661
+ const { rows } = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
1573
1662
  return { jobs: ids, requested: ids.length, affected: rows.length, settled: rows.map(row => row.id) };
1574
1663
  }
1664
+ // Fenced to the fetched attempt as in complete().
1575
1665
  async fail(name, id, data, options = {}) {
1576
1666
  Attorney.assertQueueName(name);
1577
1667
  const db = this.assertDb(options);
1578
- const ids = this.mapCompletionIdArg(id, 'fail');
1668
+ const { ids, attempts: fetched } = this.mapAttemptArg(id, 'fail');
1669
+ const attempts = fetched ?? this.#handlerAttempts(options, ids);
1579
1670
  const { table } = await this.getQueueCache(name);
1580
1671
  const outputData = this.mapCompletionDataArg(data);
1581
1672
  let response;
@@ -1583,29 +1674,31 @@ class Manager extends EventEmitter {
1583
1674
  // The delete and re-insert run in a single transaction (see ensureTransaction) so the
1584
1675
  // job cannot be lost between the two statements.
1585
1676
  if (this.config.noMultiMutationCte) {
1586
- response = await this.failDistributed(name, ids, outputData, table, db);
1677
+ response = await this.failDistributed(name, ids, outputData, table, db, attempts);
1587
1678
  }
1588
1679
  else {
1589
- const sql = plans.failJobsById(this.config.schema, table);
1590
- const result = await db.executeSql(sql, [name, ids, outputData]);
1680
+ const sql = plans.failJobsById(this.config.schema, table, !!attempts);
1681
+ const result = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
1591
1682
  response = this.mapCommandResponse(ids, result);
1592
1683
  }
1593
1684
  this.#trackHandlerSettle(options, response);
1594
1685
  return response;
1595
1686
  }
1596
- async failDistributed(name, ids, outputData, table, db) {
1687
+ async failDistributed(name, ids, outputData, table, db, attempts) {
1597
1688
  // CockroachDB doesn't support multi-mutation CTEs, but does support transactions, so the
1598
1689
  // delete + re-insert is split into separate statements run atomically.
1599
1690
  return this.ensureTransaction(db, async (tx) => {
1600
1691
  // 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]);
1692
+ const selectQuery = plans.selectJobsToFailById(this.config.schema, table, !!attempts);
1693
+ const { rows: jobs } = await tx.executeSql(selectQuery.text, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
1603
1694
  if (jobs.length === 0) {
1604
1695
  return { jobs: ids, requested: ids.length, affected: 0, settled: [] };
1605
1696
  }
1606
- // Step 2: Delete the jobs
1697
+ // Step 2: Delete the jobs the select matched, not every id asked about. The re-insert below
1698
+ // only restores the selected rows, so deleting by the requested ids would drop any of them
1699
+ // the select skipped (an already completed or cancelled job) for good.
1607
1700
  const deleteQuery = plans.deleteJobsToFail(this.config.schema, table);
1608
- await tx.executeSql(deleteQuery.text, [name, ids]);
1701
+ await tx.executeSql(deleteQuery.text, [name, jobs.map(job => job.id)]);
1609
1702
  // Step 3: Re-insert jobs with updated state
1610
1703
  const count = await this.reinsertFailedJobs(tx, table, jobs, outputData);
1611
1704
  return { jobs: ids, requested: ids.length, affected: count, settled: jobs.map(job => job.id) };
@@ -1720,7 +1813,7 @@ class Manager extends EventEmitter {
1720
1813
  job.deletion_seconds, createdOn, null, keepUntil, job.policy,
1721
1814
  jobOutput, job.dead_letter,
1722
1815
  null, job.heartbeat_seconds, job.blocked, job.blocking, job.pending_dependencies,
1723
- job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count
1816
+ job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, job.source_output, job.source_root_id
1724
1817
  ]);
1725
1818
  // The retry insert can be dropped by ON CONFLICT when the queue policy (e.g. stately,
1726
1819
  // singleton, key_strict_fifo) already has a non-terminal job. Mirror the failed_jobs
@@ -1735,24 +1828,26 @@ class Manager extends EventEmitter {
1735
1828
  job.deletion_seconds, createdOn, new Date(this.config.clock.now()), keepUntil, job.policy,
1736
1829
  jobOutput, job.dead_letter,
1737
1830
  null, job.heartbeat_seconds, job.blocked, job.blocking, job.pending_dependencies,
1738
- job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count
1831
+ job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, job.source_output, job.source_root_id
1739
1832
  ]);
1740
1833
  // Insert to dead letter queue if failed and has dead_letter configured
1741
1834
  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]);
1835
+ 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, job.source_root_id]);
1743
1836
  }
1744
1837
  }
1745
1838
  count++;
1746
1839
  }
1747
1840
  return count;
1748
1841
  }
1842
+ // Fenced to the fetched attempt as in complete().
1749
1843
  async deleteJob(name, id, options = {}) {
1750
1844
  Attorney.assertQueueName(name);
1751
1845
  const db = this.assertDb(options);
1752
- const ids = this.mapCompletionIdArg(id, 'deleteJob');
1846
+ const { ids, attempts: fetched } = this.mapAttemptArg(id, 'deleteJob');
1847
+ const attempts = fetched ?? this.#handlerAttempts(options, ids);
1753
1848
  const { table } = await this.getQueueCache(name);
1754
- const sql = plans.deleteJobsById(this.config.schema, table);
1755
- const result = await db.executeSql(sql, [name, ids]);
1849
+ const sql = plans.deleteJobsById(this.config.schema, table, !!attempts);
1850
+ const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
1756
1851
  const response = this.mapCommandResponse(ids, result);
1757
1852
  this.#trackHandlerSettle(options, response);
1758
1853
  return response;
@@ -1793,16 +1888,23 @@ class Manager extends EventEmitter {
1793
1888
  const db = this.assertDb(options);
1794
1889
  const { table } = await this.getQueueCache(name);
1795
1890
  // 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.
1891
+ // the same move runs as statements in one transaction. See plans.selectRedriveCandidates.
1797
1892
  if (this.config.noMultiMutationCte) {
1798
1893
  return this.ensureTransaction(db, async (tx) => {
1799
1894
  const { rows } = await tx.executeSql(plans.selectRedriveCandidates(this.config.schema, table), [name, ...filter, limit]);
1800
1895
  if (rows.length === 0)
1801
1896
  return 0;
1802
1897
  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;
1898
+ const newIds = rows.map((row) => row.new_id);
1899
+ const { rows: inserted } = await tx.executeSql(plans.insertRedrivenJobs(this.config.schema, table), [ids, filter[0], newIds]);
1900
+ const created = new Set(inserted.map((row) => row.id));
1901
+ const moved = ids.filter((_, i) => created.has(newIds[i]));
1902
+ const conflicted = ids.filter((_, i) => !created.has(newIds[i]));
1903
+ if (moved.length)
1904
+ await tx.executeSql(plans.deleteJobsByIds(this.config.schema, table).text, [moved]);
1905
+ if (conflicted.length)
1906
+ await tx.executeSql(plans.failRedriveConflicts(this.config.schema, table), [conflicted, filter[0]]);
1907
+ return moved.length;
1806
1908
  });
1807
1909
  }
1808
1910
  const sql = plans.redriveJobs(this.config.schema, table);
@@ -1833,13 +1935,15 @@ class Manager extends EventEmitter {
1833
1935
  unroutable
1834
1936
  };
1835
1937
  }
1938
+ // Fenced to the fetched attempt as in complete().
1836
1939
  async cancel(name, id, options = {}) {
1837
1940
  Attorney.assertQueueName(name);
1838
1941
  const db = this.assertDb(options);
1839
- const ids = this.mapCompletionIdArg(id, 'cancel');
1942
+ const { ids, attempts: fetched } = this.mapAttemptArg(id, 'cancel');
1943
+ const attempts = fetched ?? this.#handlerAttempts(options, ids);
1840
1944
  const { table } = await this.getQueueCache(name);
1841
- const sql = plans.cancelJobs(this.config.schema, table);
1842
- const result = await db.executeSql(sql, [name, ids]);
1945
+ const sql = plans.cancelJobs(this.config.schema, table, !!attempts);
1946
+ const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
1843
1947
  const response = this.mapCommandResponse(ids, result);
1844
1948
  this.#trackHandlerSettle(options, response);
1845
1949
  return response;
@@ -1870,13 +1974,15 @@ class Manager extends EventEmitter {
1870
1974
  const result = await db.executeSql(sql, [name, ids]);
1871
1975
  return this.mapCommandResponse(ids, result);
1872
1976
  }
1977
+ // Fenced to the fetched attempt as in complete(), so a worker that lost its claim cannot keep a
1978
+ // newer attempt's heartbeat fresh.
1873
1979
  async touch(name, id, options = {}) {
1874
1980
  Attorney.assertQueueName(name);
1875
1981
  const db = this.assertDb(options);
1876
- const ids = this.mapCompletionIdArg(id, 'touch');
1982
+ const { ids, attempts } = this.mapAttemptArg(id, 'touch');
1877
1983
  const { table } = await this.getQueueCache(name);
1878
- const sql = plans.touchJobs(this.config.schema, table);
1879
- const result = await db.executeSql(sql, [name, ids]);
1984
+ const sql = plans.touchJobs(this.config.schema, table, !!attempts);
1985
+ const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
1880
1986
  return this.mapCommandResponse(ids, result);
1881
1987
  }
1882
1988
  async createQueue(name, options = {}) {
@@ -2005,7 +2111,9 @@ class Manager extends EventEmitter {
2005
2111
  async getQueueStats(name, options = {}) {
2006
2112
  Attorney.assertQueueName(name);
2007
2113
  const isCockroach = this.config.backend === 'cockroachdb';
2008
- const toSnapshot = (row) => {
2114
+ // `counted` is true for recorded snapshots. The cache path serves only gauges: the queue table's
2115
+ // counters describe the last pass that counted, not this reading.
2116
+ const toSnapshot = (row, counted = false) => {
2009
2117
  const snapshot = {
2010
2118
  name,
2011
2119
  deferredCount: 0,
@@ -2014,14 +2122,25 @@ class Manager extends EventEmitter {
2014
2122
  activeCount: 0,
2015
2123
  failedCount: 0,
2016
2124
  totalCount: 0,
2125
+ // Null, not zero, until something counted them: with persistQueueStats off
2126
+ // nobody does, and a snapshot captured before 12.35 predates the columns.
2127
+ // Zero would claim the queue was idle.
2128
+ completedDelta: null,
2129
+ failedDelta: null,
2130
+ createdDelta: null,
2131
+ deltaSeconds: null,
2132
+ deltaOn: null,
2017
2133
  capturedOn: row?.capturedOn ?? new Date(this.config.clock.now())
2018
2134
  };
2019
- for (const field of STATS_COUNT_FIELDS) {
2135
+ for (const field of counted ? [...STATS_COUNT_FIELDS, ...STATS_DELTA_FIELDS] : STATS_COUNT_FIELDS) {
2020
2136
  const value = row?.[field];
2021
2137
  // CockroachDB returns integer columns as strings; normalize the counts.
2022
2138
  if (value !== undefined && value !== null)
2023
2139
  snapshot[field] = isCockroach ? Number(value) : value;
2024
2140
  }
2141
+ // The end of the interval the counters cover, handed on as the row holds it, like capturedOn.
2142
+ if (counted && row?.deltaOn != null)
2143
+ snapshot.deltaOn = row.deltaOn;
2025
2144
  return snapshot;
2026
2145
  };
2027
2146
  if (this.config.persistQueueStats) {
@@ -2039,11 +2158,11 @@ class Manager extends EventEmitter {
2039
2158
  assert(Number.isInteger(width) && width >= 1, `getQueueStats: ${mode === 'bucket' ? 'bucketSeconds' : 'maxDataPoints'} must be a positive integer`);
2040
2159
  const sql = plans.getQueueStatsHistoryBucketed(this.config.schema, aggregate, mode);
2041
2160
  const { rows } = await this.db.executeSql(sql, [name, from, to, limit, width]);
2042
- return rows.map(toSnapshot);
2161
+ return rows.map(row => toSnapshot(row, true));
2043
2162
  }
2044
2163
  const sql = plans.getQueueStatsHistory(this.config.schema);
2045
2164
  const { rows } = await this.db.executeSql(sql, [name, from, to, limit]);
2046
- return rows.map(toSnapshot);
2165
+ return rows.map(row => toSnapshot(row, true));
2047
2166
  }
2048
2167
  // persistQueueStats disabled: serve the cached counts the monitor keeps on the queue table.
2049
2168
  // capturedOn is monitor_on, NULL if never monitored, or old if monitoring has since been turned
@@ -1 +1 @@
1
- {"version":3,"file":"migrationStore.d.ts","sourceRoot":"","sources":["../src/migrationStore.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAKnC,UAAU,cAAc;IACtB,WAAW,CAAC,EAAE,OAAO,CAAA;IAKrB,eAAe,CAAC,EAAE,KAAK,CAAC,kBAAkB,EAAE,CAAA;CAC7C;AAoGD,UAAU,iBAAiB;IACzB,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AASD,iBAAS,QAAQ,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,UAsB5G;AAED,iBAAS,IAAI,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,UAQxG;AAKD,iBAAS,eAAe,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,GAAG,iBAAiB,CAiDrK;AAKD,iBAAS,OAAO,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,UAIzI;AAoiCD,iBAAS,aAAa,CAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE9C;AAID,iBAAS,eAAe,CAAE,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,0BAA0B,EAAE,QAAQ,GAAG,qBAAqB,GAAG,mBAAmB,GAAG,qBAAqB,CAAC,GAAG,KAAK,CAAC,SAAS,EAAE,CAE3K;AAOD,iBAAS,MAAM,CAAE,MAAM,EAAE,MAAM,EAAE,cAAc,UAAQ,EAAE,UAAU,UAAQ,EAAE,mBAAmB,UAAQ,GAAG,KAAK,CAAC,SAAS,EAAE,CAqkB3H;AAED,OAAO,EACL,QAAQ,EACR,IAAI,EACJ,OAAO,EACP,eAAe,EACf,MAAM,EACN,eAAe,EACf,aAAa,GACd,CAAA"}
1
+ {"version":3,"file":"migrationStore.d.ts","sourceRoot":"","sources":["../src/migrationStore.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAKnC,UAAU,cAAc;IACtB,WAAW,CAAC,EAAE,OAAO,CAAA;IAKrB,eAAe,CAAC,EAAE,KAAK,CAAC,kBAAkB,EAAE,CAAA;CAC7C;AAoGD,UAAU,iBAAiB;IACzB,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AASD,iBAAS,QAAQ,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,UAsB5G;AAED,iBAAS,IAAI,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,UAQxG;AAKD,iBAAS,eAAe,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,GAAG,iBAAiB,CAiDrK;AAKD,iBAAS,OAAO,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,UAIzI;AAgoCD,iBAAS,aAAa,CAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE9C;AAID,iBAAS,eAAe,CAAE,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,0BAA0B,EAAE,QAAQ,GAAG,qBAAqB,GAAG,mBAAmB,GAAG,qBAAqB,CAAC,GAAG,KAAK,CAAC,SAAS,EAAE,CAE3K;AAOD,iBAAS,MAAM,CAAE,MAAM,EAAE,MAAM,EAAE,cAAc,UAAQ,EAAE,UAAU,UAAQ,EAAE,mBAAmB,UAAQ,GAAG,KAAK,CAAC,SAAS,EAAE,CA4oB3H;AAED,OAAO,EACL,QAAQ,EACR,IAAI,EACJ,OAAO,EACP,eAAe,EACf,MAAM,EACN,eAAe,EACf,aAAa,GACd,CAAA"}