pg-boss 12.35.1 → 12.37.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
@@ -6,14 +6,24 @@ import * as Attorney from "./attorney.js";
6
6
  import { untracked } from "./activity.js";
7
7
  import { TRANSACTION_ROLLBACK_TIMEOUT_MS } from "./db.js";
8
8
  import * as plans from "./plans.js";
9
+ import { percentile } from "./latency.js";
9
10
  import * as timekeeper from "./timekeeper.js";
10
- import { resolveWithinSeconds } from "./tools.js";
11
+ import { delay, resolveWithinSeconds } from "./tools.js";
11
12
  import * as types from "./types.js";
12
13
  import Worker from "./worker.js";
13
14
  import { JobSpy } from "./spy.js";
15
+ import Telemetry, {} from "./telemetry.js";
14
16
  const INTERNAL_QUEUES = Object.values(timekeeper.QUEUES).reduce((acc, i) => ({ ...acc, [i]: i }), {});
15
17
  // postgres: current transaction is aborted, commands ignored until end of transaction block
16
18
  const TRANSACTION_ABORTED = '25P02';
19
+ // serialization_failure, which CockroachDB raises for a transaction it has to restart.
20
+ const SERIALIZATION_FAILURE = '40001';
21
+ const UPSERT_ATTEMPTS = 3;
22
+ const QUEUE_NOT_FOUND = 'PGBOSS_QUEUE_NOT_FOUND';
23
+ // How long deleteQueue() keeps trying for the locks a queue with its own table needs: 25 ms doubling
24
+ // to 400 ms between tries, about 3 seconds in all.
25
+ const DELETE_QUEUE_ATTEMPTS = 12;
26
+ const DELETE_QUEUE_MAX_DELAY_MS = 400;
17
27
  // pg's own default when the pool size is not configured. Used to tell a transactional worker how
18
28
  // much room it actually has, since each handler in flight holds a connection of its own.
19
29
  const DEFAULT_POOL_MAX = 10;
@@ -80,6 +90,7 @@ const NUMERIC_METADATA_FIELDS = [
80
90
  ];
81
91
  // Queue rows (plans.getQueues) return these integer columns as strings on CockroachDB too.
82
92
  const NUMERIC_QUEUE_FIELDS = [
93
+ 'blockedCount',
83
94
  'retryLimit',
84
95
  'retryDelay',
85
96
  'retryDelayMax',
@@ -97,7 +108,8 @@ const NUMERIC_QUEUE_FIELDS = [
97
108
  'createdDelta',
98
109
  'completedDelta',
99
110
  'failedDelta',
100
- 'deltaSeconds'
111
+ 'deltaSeconds',
112
+ 'readyOldestSeconds'
101
113
  ];
102
114
  // The gauges shared by live stats and recorded snapshots (the QueueStats shape).
103
115
  const STATS_COUNT_FIELDS = [
@@ -108,6 +120,12 @@ const STATS_COUNT_FIELDS = [
108
120
  'failedCount',
109
121
  'totalCount'
110
122
  ];
123
+ // A snapshot's histogram, LATENCY_SLOTS counts from a recorded pass or added up over a bucket, with
124
+ // the slots stored as null (no job landed there) handed out as 0. Null when no pass counted it; all
125
+ // zeros when one did and nothing finished. CockroachDB hands integers over as strings.
126
+ function toBins(bins) {
127
+ return Array.isArray(bins) ? bins.map(n => (n == null ? 0 : Number(n))) : null;
128
+ }
111
129
  // The throughput counters and the seconds they cover. Only recorded snapshots carry them; see
112
130
  // getQueueStats.
113
131
  const STATS_DELTA_FIELDS = [
@@ -147,6 +165,16 @@ function rethrowWriteError(err) {
147
165
  }
148
166
  throw err;
149
167
  }
168
+ // The foreign key a job insert broke, from the error's constraint field where the driver reports one
169
+ // and from the message otherwise, and the key value it could not find. Bun.SQL carries the SQLSTATE
170
+ // in errno.
171
+ function brokenForeignKey(err) {
172
+ if (err?.code !== plans.PG_ERROR.foreignKeyViolation && err?.errno !== plans.PG_ERROR.foreignKeyViolation)
173
+ return;
174
+ const constraint = err.constraint ?? err.constraint_name ?? /foreign key constraint "(\w+)"/.exec(err.message)?.[1];
175
+ const value = /\)=\('?(.*?)'?\) is not present/.exec(err.detail ?? err.message)?.[1];
176
+ return { constraint, value };
177
+ }
150
178
  // For a json value read back from a row and bound again behind `::text::jsonb`. Null stays SQL NULL.
151
179
  function toJsonText(value) {
152
180
  return value == null ? null : JSON.stringify(value);
@@ -187,6 +215,9 @@ class Manager extends EventEmitter {
187
215
  #localGroupActive;
188
216
  #localGroupConfig;
189
217
  #localGroupMaxLimit;
218
+ #telemetry;
219
+ // The trace context each fetched job was sent with, kept off the job object handed to the handler.
220
+ #traceContexts;
190
221
  constructor(db, config) {
191
222
  super();
192
223
  this.config = config;
@@ -205,6 +236,8 @@ class Manager extends EventEmitter {
205
236
  this.#localGroupActive = new Map();
206
237
  this.#localGroupConfig = new Map();
207
238
  this.#localGroupMaxLimit = new Map();
239
+ this.#telemetry = new Telemetry(config.openTelemetry, config.schema, () => this.queues);
240
+ this.#traceContexts = new WeakMap();
208
241
  }
209
242
  getSpy(name) {
210
243
  if (!this.config.__test__enableSpies) {
@@ -352,6 +385,7 @@ class Manager extends EventEmitter {
352
385
  // (each output carried per-id via a JSON recordset), so batch size never drives the statement
353
386
  // count. Any batch job the handler omits (or returns with an invalid shape) is failed with a
354
387
  // descriptive error so it retries / dead-letters per queue config.
388
+ // Resolves with the error the whole batch was failed with, or undefined.
355
389
  async #settlePerJob(name, jobs, result) {
356
390
  if (!Array.isArray(result)) {
357
391
  // The handler opted into perJobResults but did not return an array: a contract violation.
@@ -359,7 +393,7 @@ class Manager extends EventEmitter {
359
393
  const err = new Error('perJobResults handler must resolve with an array of job results');
360
394
  await this.fail(name, jobs, err);
361
395
  await this.#trackJobsFailed(name, jobs, err);
362
- return;
396
+ return err;
363
397
  }
364
398
  // Index the handler's dispositions by job id, keeping only valid entries that reference a job
365
399
  // from this batch. Last write wins on duplicate ids.
@@ -391,9 +425,16 @@ class Manager extends EventEmitter {
391
425
  }
392
426
  }
393
427
  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;
428
+ const ids = (entries) => entries.map(({ job }) => job.id);
429
+ const completedIds = completed.length > 0
430
+ ? await this.#telemetry.settle('complete', name, ids(completed), () => this.#completeWithOutputs(name, items(completed)))
431
+ : null;
432
+ const failedIds = failed.length > 0
433
+ ? await this.#telemetry.settle('fail', name, ids(failed), () => this.#failWithOutputs(name, items(failed)))
434
+ : null;
435
+ const deadLetteredIds = deadLettered.length > 0
436
+ ? await this.#telemetry.settle('fail', name, ids(deadLettered), () => this.#failWithOutputs(name, items(deadLettered), true))
437
+ : null;
397
438
  // Only the jobs each statement actually settled: the attempt fence leaves a job whose claim
398
439
  // lapsed alone, and recording it would tell a spy it settled when another attempt holds it.
399
440
  const landed = (entries, result) => {
@@ -509,6 +550,8 @@ class Manager extends EventEmitter {
509
550
  * same as any other worker's. Because the claim is outside the transaction, the jobs stay
510
551
  * visibly `active` throughout, which is what keeps heartbeats, `expireInSeconds`, and another
511
552
  * instance's supervisor working on them as usual.
553
+ *
554
+ * Resolves with the error the batch was failed with, or undefined when it completed.
512
555
  */
513
556
  async #processJobs(name, jobs, callback, worker, heartbeatRefreshSeconds, perJobResults = false, transactional = false, transactionTimeoutSeconds) {
514
557
  const jobIds = jobs.map(job => job.id);
@@ -569,6 +612,9 @@ class Manager extends EventEmitter {
569
612
  let completedAffected = 0;
570
613
  let failedError;
571
614
  let didFail = false;
615
+ // A perJobResults batch #settlePerJob failed as a whole. Kept apart from failedError, since
616
+ // #settlePerJob has already failed and tracked those jobs itself.
617
+ let perJobError;
572
618
  // Only for a transactional worker, and only from the begin below until it settles. rollback()
573
619
  // is idempotent, so the catch can settle it without tracking whether the commit got there
574
620
  // first.
@@ -600,6 +646,10 @@ class Manager extends EventEmitter {
600
646
  await this.#applyTransactionTimeout(transaction, timeoutGuc, timeoutMs);
601
647
  }
602
648
  }
649
+ // A fetch or transaction begin can finish after stop() has exhausted its active-handler grace.
650
+ if (worker?.graceExpired) {
651
+ throw new Error('pg-boss shut down before the handler started');
652
+ }
603
653
  const handling = transaction
604
654
  ? callback(jobs, untracked(transaction.db))
605
655
  : callback(jobs);
@@ -619,7 +669,7 @@ class Manager extends EventEmitter {
619
669
  // #settlePerJob settles each job individually and does its own (synchronous,
620
670
  // lookup-free) spy tracking via #trackJobsSettled, so the deferred tracker below
621
671
  // is skipped for this path.
622
- await this.#settlePerJob(name, jobs, result);
672
+ perJobError = await this.#settlePerJob(name, jobs, result);
623
673
  }
624
674
  else {
625
675
  // Read out before the completion below, which goes through the same complete() and would
@@ -676,6 +726,7 @@ class Manager extends EventEmitter {
676
726
  await this.#trackJobsCompleted(name, jobs, completedResult, completedAffected);
677
727
  }
678
728
  }
729
+ return didFail ? (failedError ?? new Error('handler rejected without a reason')) : perJobError;
679
730
  }
680
731
  /**
681
732
  * Gives the handler's transaction a deadline the database enforces.
@@ -822,6 +873,10 @@ class Manager extends EventEmitter {
822
873
  }
823
874
  async start() {
824
875
  this.stopped = false;
876
+ if (this.#telemetry.enabled) {
877
+ const { rows } = await this.db.executeSql(plans.currentDatabase());
878
+ this.#telemetry.setDatabase(rows[0].name);
879
+ }
825
880
  this.queueCacheInterval = this.config.clock.setInterval(() => this.onCacheQueues({ emit: true }), this.config.queueCacheIntervalSeconds * 1000);
826
881
  this.wipInterval = this.config.clock.setInterval(() => {
827
882
  const now = this.config.clock.now();
@@ -835,12 +890,14 @@ class Manager extends EventEmitter {
835
890
  }
836
891
  }, 2000);
837
892
  await this.onCacheQueues();
893
+ this.#telemetry.observeQueues();
838
894
  }
839
895
  async onCacheQueues({ emit = false } = {}) {
840
896
  try {
841
897
  assert(!this.config.__test__throw_queueCache, 'test error');
842
898
  const queues = await this.getQueues();
843
899
  this.queues = queues.reduce((acc, i) => { acc[i.name] = i; return acc; }, {});
900
+ this.#telemetry.refreshInstruments();
844
901
  }
845
902
  catch (error) {
846
903
  emit && this.emit(events.error, { ...error, message: error.message, stack: error.stack });
@@ -854,7 +911,7 @@ class Manager extends EventEmitter {
854
911
  }
855
912
  queue = await this.getQueue(name);
856
913
  if (!queue) {
857
- throw new Error(`Queue ${name} does not exist`);
914
+ throw Object.assign(new Error(`Queue ${name} does not exist`), { code: QUEUE_NOT_FOUND });
858
915
  }
859
916
  this.queues[name] = queue;
860
917
  return queue;
@@ -863,10 +920,60 @@ class Manager extends EventEmitter {
863
920
  if (this.queues)
864
921
  delete this.queues[name];
865
922
  }
923
+ // A job insert is what notices a queue deleted after this instance cached it: q_fkey fails for a
924
+ // queue in the shared table, and a queue with its own table (partition: true) fails on the dropped
925
+ // table (42P01), dlq_fkey for its dead letter queue. Each is rewritten into the error a missing
926
+ // queue gets up front, and a missing queue loses its cache entry so the next call fails before
927
+ // reaching the database. `names` are the queues the statement wrote to.
928
+ async #rethrowInsertError(err, names, owned) {
929
+ const broken = brokenForeignKey(err);
930
+ if (broken?.constraint === 'q_fkey') {
931
+ this.#throwQueueNotFound(broken.value && names.includes(broken.value) ? [broken.value] : names, err, owned);
932
+ }
933
+ // A missing table can also mean a queue deleted and created again elsewhere under its own table, and
934
+ // a partition constraint (23514) one created again with its own table while this instance still
935
+ // writes to the shared one. So the queues are read again: a gone one is reported as missing, and the
936
+ // others take their current row, so the next call writes to the right table. A failed read leaves
937
+ // the original error.
938
+ if (err?.code === plans.PG_ERROR.undefinedTable || err?.code === plans.PG_ERROR.checkViolation) {
939
+ const queues = await Promise.all(names.map(name => this.getQueue(name))).catch(() => null);
940
+ if (queues) {
941
+ const missing = names.filter((name, i) => !queues[i]);
942
+ if (missing.length)
943
+ this.#throwQueueNotFound(missing, err, owned);
944
+ queues.forEach(queue => { if (queue && this.queues)
945
+ this.queues[queue.name] = queue; });
946
+ }
947
+ }
948
+ if (broken?.constraint === 'dlq_fkey') {
949
+ throw new Error(broken.value ? `Dead letter queue ${broken.value} does not exist` : 'Dead letter queue does not exist', { cause: err });
950
+ }
951
+ rethrowWriteError(err);
952
+ }
953
+ // Coded, so publish() skips it, only when the statement ran on pg-boss's own connection: a failed
954
+ // statement in a caller's transaction has aborted it, and that has to reach the caller.
955
+ #throwQueueNotFound(missing, cause, owned) {
956
+ missing.forEach(name => this.#evictQueueCache(name));
957
+ throw Object.assign(new Error(`Queue ${missing.join(', ')} does not exist`, { cause }), owned ? { code: QUEUE_NOT_FOUND } : {});
958
+ }
959
+ // Replaces a queue's cache entry with its row as it stands, rather than evicting it, so a queue
960
+ // created again or updated stays in the cache and the queue gauge while still picking up what
961
+ // changed: new options, or a table that changed under it (deleted and recreated elsewhere with
962
+ // another partition setting).
963
+ async #reloadQueueCache(name) {
964
+ if (!this.queues)
965
+ return;
966
+ const queue = await this.getQueue(name);
967
+ if (queue)
968
+ this.queues[name] = queue;
969
+ else
970
+ this.#evictQueueCache(name);
971
+ }
866
972
  async stop() {
867
973
  this.stopped = true;
868
974
  this.config.clock.clearInterval(this.queueCacheInterval);
869
975
  this.config.clock.clearInterval(this.wipInterval);
976
+ this.#telemetry.unobserveQueues();
870
977
  // offWork stops every worker on a queue, so iterate queue names rather than workers - otherwise
871
978
  // a localConcurrency of N re-stops all N workers N times and registers N pending cleanups.
872
979
  const names = new Set([...this.workers.values()]
@@ -882,6 +989,11 @@ class Manager extends EventEmitter {
882
989
  // `stopped` all wait on this, so every worker has to be reached even when one of them cannot be
883
990
  // failed.
884
991
  async failWip() {
992
+ // Every worker is marked before any fail below is awaited, so a claim landing on a later worker
993
+ // while an earlier one's fail is in flight is refused too.
994
+ for (const worker of this.workers.values()) {
995
+ worker.graceExpired = true;
996
+ }
885
997
  for (const worker of this.workers.values()) {
886
998
  const jobIds = worker.jobs.map(j => j.id);
887
999
  if (jobIds.length) {
@@ -919,6 +1031,10 @@ class Manager extends EventEmitter {
919
1031
  if (transactional) {
920
1032
  assert(typeof this.db.beginTransaction === 'function', 'transactional workers require a database connection pg-boss can open a transaction on: the built-in pool, or a db adapter implementing beginTransaction');
921
1033
  await this.#assertTransactionalHeartbeatSupported(name);
1034
+ // A stop() during that await has already looked for workers, and would not see this one.
1035
+ if (this.stopped) {
1036
+ throw new Error('Workers are disabled. pg-boss is stopped');
1037
+ }
922
1038
  this.#warnOnTransactionalPoolHeadroom(localConcurrency);
923
1039
  }
924
1040
  if (localGroupConcurrency != null) {
@@ -952,7 +1068,12 @@ class Manager extends EventEmitter {
952
1068
  const ignoreGroups = localGroupConcurrency != null
953
1069
  ? this.#getGroupsAtLocalCapacity(name)
954
1070
  : undefined;
955
- return this.fetch(name, { batchSize, includeMetadata, priority, orderByCreatedOn, groupConcurrency, ignoreGroups, minPriority, maxPriority });
1071
+ return this.#fetch(name, { batchSize, includeMetadata, priority, orderByCreatedOn, groupConcurrency, ignoreGroups, minPriority, maxPriority });
1072
+ };
1073
+ // Counted here rather than on fetch: jobs past localGroupConcurrency are restored, not delivered.
1074
+ const processBatch = (batch, worker) => {
1075
+ this.#telemetry.consumed(name, batch.length);
1076
+ return this.#telemetry.process(name, batch, job => this.#traceContexts.get(job), () => this.#processJobs(name, batch, callback, worker, heartbeatRefreshSeconds, perJobResults, transactional, transactionTimeoutSeconds));
956
1077
  };
957
1078
  const onFetch = async (jobs) => {
958
1079
  if (!jobs.length)
@@ -965,7 +1086,7 @@ class Manager extends EventEmitter {
965
1086
  const worker = this.workers.get(workerId);
966
1087
  // Skip all in-memory group tracking when localGroupConcurrency is not enabled
967
1088
  if (localGroupConcurrency == null) {
968
- await this.#processJobs(name, jobs, callback, worker, heartbeatRefreshSeconds, perJobResults, transactional, transactionTimeoutSeconds);
1089
+ await processBatch(jobs, worker);
969
1090
  }
970
1091
  else {
971
1092
  const { allowed, excess, groupedJobs } = this.#trackLocalGroupStart(name, jobs);
@@ -980,7 +1101,7 @@ class Manager extends EventEmitter {
980
1101
  worker.jobs = allowed;
981
1102
  }
982
1103
  if (allowed.length > 0) {
983
- await this.#processJobs(name, allowed, callback, worker, heartbeatRefreshSeconds, perJobResults, transactional, transactionTimeoutSeconds);
1104
+ await processBatch(allowed, worker);
984
1105
  }
985
1106
  }
986
1107
  finally {
@@ -1164,12 +1285,17 @@ class Manager extends EventEmitter {
1164
1285
  }
1165
1286
  async publish(event, data, options) {
1166
1287
  assert(event, 'Missing required argument');
1288
+ // Counts no jobs of its own: each send() below records the job it creates.
1289
+ await this.#telemetry.send('publish', event, 0, () => this.#publish(event, data, options));
1290
+ }
1291
+ async #publish(event, data, options) {
1167
1292
  const sql = plans.getQueuesForEvent(this.config.schema);
1168
1293
  const { rows } = await this.db.executeSql(sql, [event]);
1169
1294
  const results = await Promise.allSettled(rows.map(({ name }) => this.send(name, data, options)));
1170
1295
  const failures = results
1171
1296
  .map((result, index) => ({ result, name: rows[index].name }))
1172
- .filter((entry) => entry.result.status === 'rejected');
1297
+ // A deleted subscriber can still be in the selection, just as a cached send can insert no job.
1298
+ .filter((entry) => entry.result.status === 'rejected' && entry.result.reason?.code !== QUEUE_NOT_FOUND);
1173
1299
  if (failures.length > 0) {
1174
1300
  // Each entry names its own queue, so attribution doesn't depend on lining errors[] up with
1175
1301
  // the queue order positionally, and survives two subscribers failing with identical text.
@@ -1230,16 +1356,20 @@ class Manager extends EventEmitter {
1230
1356
  };
1231
1357
  }
1232
1358
  async createJob(request) {
1359
+ return this.#telemetry.send('send', request.name, 1, carrier => this.#createJob(request, carrier), id => id ? [id] : null);
1360
+ }
1361
+ async #createJob(request, traceContext) {
1233
1362
  const { name, data = null, options = {} } = request;
1234
1363
  const { db: wrapper, singletonSeconds, singletonNextSlot } = options;
1235
- const job = this.#toJobPayload(name, data, options);
1364
+ const job = { ...this.#toJobPayload(name, data, options), __traceContext: traceContext };
1236
1365
  const db = wrapper || this.db;
1237
1366
  const { table, policy, notify } = await this.getQueueCache(name);
1238
1367
  if (policy === plans.QUEUE_POLICIES.key_strict_fifo && !job.singletonKey) {
1239
1368
  throw new Error(`${plans.QUEUE_POLICIES.key_strict_fifo} queues require a singletonKey`);
1240
1369
  }
1241
1370
  const sql = plans.insertJobs(this.config.schema, { table, name, returnId: true, notify: this.#notifyEnabled(notify) });
1242
- const { rows: try1 } = await db.executeSql(sql, [JSON.stringify([job])]);
1371
+ const insert = () => db.executeSql(sql, [JSON.stringify([job])]).catch(err => this.#rethrowInsertError(err, [name], !wrapper));
1372
+ const { rows: try1 } = await insert();
1243
1373
  if (try1.length === 1) {
1244
1374
  const jobId = try1[0].id;
1245
1375
  if (this.config.__test__enableSpies) {
@@ -1254,7 +1384,7 @@ class Manager extends EventEmitter {
1254
1384
  // delay starting by the offset to honor throttling config
1255
1385
  job.startAfter = this.getDebounceStartAfter(singletonSeconds, this.timekeeper.clockSkew);
1256
1386
  job.singletonOffset = singletonSeconds;
1257
- const { rows: try2 } = await db.executeSql(sql, [JSON.stringify([job])]);
1387
+ const { rows: try2 } = await insert();
1258
1388
  if (try2.length === 1) {
1259
1389
  const jobId = try2[0].id;
1260
1390
  if (this.config.__test__enableSpies) {
@@ -1309,9 +1439,14 @@ class Manager extends EventEmitter {
1309
1439
  }
1310
1440
  async upsert(...args) {
1311
1441
  const request = Attorney.checkUpdateArgs(args, { upsert: true });
1442
+ Attorney.assertQueueName(request.name);
1443
+ // Only an insert counts as a send, and only an inserted job stores the trace context: an
1444
+ // updated job keeps the trace of the send that created it.
1445
+ return this.#telemetry.send('upsert', request.name, result => result.inserted, carrier => this.#upsert(request, carrier), result => result.jobs);
1446
+ }
1447
+ async #upsert(request, traceContext) {
1312
1448
  const { name, data } = request;
1313
1449
  const opts = (request.options ?? {});
1314
- Attorney.assertQueueName(name);
1315
1450
  const db = this.assertDb(opts);
1316
1451
  const { table, policy, notify } = await this.getQueueCache(name);
1317
1452
  const by = opts.id ? 'id' : 'singletonKey';
@@ -1323,11 +1458,12 @@ class Manager extends EventEmitter {
1323
1458
  }
1324
1459
  const notifyEnabled = this.#notifyEnabled(notify);
1325
1460
  const updateSql = plans.updateJob(this.config.schema, table, name, by, match, notifyEnabled);
1326
- const insertSql = plans.insertJobs(this.config.schema, { table, name, returnId: true, notify: notifyEnabled });
1461
+ const insertSql = plans.insertJobs(this.config.schema, { table, name, returnId: true, notify: notifyEnabled, upsertByKey: by === 'singletonKey' });
1327
1462
  const job = this.#toUpdatePayload(data, opts);
1328
1463
  const updatePayload = JSON.stringify(job);
1329
- const insertPayload = JSON.stringify([job]);
1330
- const result = await this.ensureTransaction(db, async (tx) => {
1464
+ const insertPayload = JSON.stringify([{ ...job, __traceContext: traceContext }]);
1465
+ // The catch is outside the transaction: a deferred q_fkey reports at its COMMIT.
1466
+ const attempt = () => this.ensureTransaction(db, async (tx) => {
1331
1467
  const { rows: updated } = await tx.executeSql(updateSql, [updatePayload]);
1332
1468
  if (updated.length) {
1333
1469
  const jobs = updated.map(row => row.id);
@@ -1343,7 +1479,22 @@ class Manager extends EventEmitter {
1343
1479
  const { rows: retry } = await tx.executeSql(updateSql, [updatePayload]);
1344
1480
  const jobs = retry.map(row => row.id);
1345
1481
  return { jobs, updated: jobs.length, inserted: 0 };
1346
- });
1482
+ }).catch(err => this.#rethrowInsertError(err, [name], !opts.db));
1483
+ // Where every transaction is serializable (CockroachDB), two upserts of one key that both miss
1484
+ // conflict and one fails with 40001, rather than waiting on job_i13 as under READ COMMITTED. The
1485
+ // transaction is pg-boss's own unless the caller passed a db, so the upsert runs again and finds
1486
+ // the other's job. A caller's transaction is theirs to retry.
1487
+ let result;
1488
+ for (let attempts = 1;; attempts++) {
1489
+ try {
1490
+ result = await attempt();
1491
+ break;
1492
+ }
1493
+ catch (err) {
1494
+ if (opts.db || err?.code !== SERIALIZATION_FAILURE || attempts === UPSERT_ATTEMPTS)
1495
+ throw err;
1496
+ }
1497
+ }
1347
1498
  // Track inserted (newly created) jobs for spies, matching createJob/insert. Runs after the
1348
1499
  // transaction commits so a rolled-back insert never leaves a phantom spy entry.
1349
1500
  if (result.inserted && this.config.__test__enableSpies) {
@@ -1363,6 +1514,9 @@ class Manager extends EventEmitter {
1363
1514
  // statement nor kept on the objects below, so this stays the raw path it has always been.
1364
1515
  options = {}) {
1365
1516
  assert(Array.isArray(jobs), 'jobs argument should be an array');
1517
+ return this.#telemetry.send('insert', name, jobs.length, carrier => this.#insert(name, jobs, options, carrier));
1518
+ }
1519
+ async #insert(name, jobs, options, traceContext) {
1366
1520
  const slots = options.__singletonSlots === true;
1367
1521
  const seenIds = new Set();
1368
1522
  for (const job of jobs) {
@@ -1394,6 +1548,8 @@ class Manager extends EventEmitter {
1394
1548
  const dataById = spy ? new Map() : undefined;
1395
1549
  const insertPayload = jobs.map(j => {
1396
1550
  const { blocked, blocking, pendingDependencies, group, __singletonSlot, ...rest } = j;
1551
+ // Overwrites any __traceContext the caller passed.
1552
+ Object.assign(rest, { __traceContext: traceContext });
1397
1553
  // Reattached only for the caller that asked for the column, so a public insert() drops the
1398
1554
  // field rather than handing an unvalidated value to a timestamp cast.
1399
1555
  if (slots && __singletonSlot !== undefined) {
@@ -1427,7 +1583,7 @@ class Manager extends EventEmitter {
1427
1583
  // Return IDs if spy is active for this queue (needed for job tracking)
1428
1584
  const returnId = !!spy || !!options.returnId;
1429
1585
  const sql = plans.insertJobs(this.config.schema, { table, name, returnId, notify: this.#notifyEnabled(notify), slots });
1430
- const { rows } = await db.executeSql(sql, [JSON.stringify(insertPayload)]);
1586
+ const { rows } = await db.executeSql(sql, [JSON.stringify(insertPayload)]).catch(err => this.#rethrowInsertError(err, [name], !options.db));
1431
1587
  if (rows.length) {
1432
1588
  if (spy) {
1433
1589
  // dataById is populated for every job when a spy is active
@@ -1441,6 +1597,11 @@ class Manager extends EventEmitter {
1441
1597
  }
1442
1598
  async flow(jobs, options = {}) {
1443
1599
  Attorney.validateFlowJobs(jobs);
1600
+ const queues = new Set(jobs.map(job => job.name));
1601
+ const destination = queues.size === 1 ? jobs[0].name : null;
1602
+ return this.#telemetry.send('flow', destination, jobs.length, carrier => this.#flow(jobs, options, carrier), refToId => Object.values(refToId));
1603
+ }
1604
+ async #flow(jobs, options, traceContext) {
1444
1605
  // validate and normalize each job's options the same way send()/insert() do
1445
1606
  const flowJobs = jobs.map(job => ({
1446
1607
  ...job,
@@ -1503,7 +1664,8 @@ class Manager extends EventEmitter {
1503
1664
  deadLetter: j.options?.deadLetter ?? undefined,
1504
1665
  blocked: dependencyCount > 0 || undefined,
1505
1666
  blocking: parentRefs.has(j.ref) || undefined,
1506
- pendingDependencies: dependencyCount || undefined
1667
+ pendingDependencies: dependencyCount || undefined,
1668
+ __traceContext: traceContext
1507
1669
  };
1508
1670
  });
1509
1671
  statements.push(plans.insertFlowJobs(this.config.schema, { table, name: queueName }, insertPayload));
@@ -1526,7 +1688,7 @@ class Manager extends EventEmitter {
1526
1688
  await db.executeSql(sql);
1527
1689
  }
1528
1690
  catch (err) {
1529
- rethrowWriteError(err);
1691
+ await this.#rethrowInsertError(err, [...byQueue.keys()], !options.db);
1530
1692
  }
1531
1693
  return refToId;
1532
1694
  }
@@ -1559,6 +1721,9 @@ class Manager extends EventEmitter {
1559
1721
  }
1560
1722
  async fetch(name, options = {}) {
1561
1723
  Attorney.checkFetchArgs(name, options);
1724
+ return this.#telemetry.receive(name, () => this.#fetch(name, options), job => this.#traceContexts.get(job));
1725
+ }
1726
+ async #fetch(name, options) {
1562
1727
  this.#warnDeprecatedFetchOptions(options);
1563
1728
  const db = this.assertDb(options);
1564
1729
  const { table, policy, singletonsActive } = await this.getQueueCache(name);
@@ -1569,7 +1734,8 @@ class Manager extends EventEmitter {
1569
1734
  name,
1570
1735
  policy,
1571
1736
  limit: options.batchSize || 1,
1572
- ignoreSingletons: singletonsActive
1737
+ ignoreSingletons: singletonsActive,
1738
+ includeTraceContext: this.#telemetry.enabled
1573
1739
  };
1574
1740
  const query = plans.fetchNextJob(fetchOptions, this.config.noSkipLocked);
1575
1741
  let result;
@@ -1586,6 +1752,13 @@ class Manager extends EventEmitter {
1586
1752
  throw err;
1587
1753
  }
1588
1754
  const rows = result?.rows || [];
1755
+ for (const row of rows) {
1756
+ // A db adapter may hand jsonb back unparsed.
1757
+ const carrier = typeof row.__traceContext === 'string' ? JSON.parse(row.__traceContext) : row.__traceContext;
1758
+ delete row.__traceContext;
1759
+ if (carrier)
1760
+ this.#traceContexts.set(row, carrier);
1761
+ }
1589
1762
  // Even a minimal fetch (JOB_COLUMNS_MIN) returns numeric fields like expireInSeconds and
1590
1763
  // heartbeatSeconds, so normalize regardless of includeMetadata.
1591
1764
  return this.#numericJobFields(rows);
@@ -1638,21 +1811,23 @@ class Manager extends EventEmitter {
1638
1811
  const db = this.assertDb(options);
1639
1812
  const { ids, attempts: fetched } = this.mapAttemptArg(id, 'complete');
1640
1813
  const attempts = fetched ?? this.#handlerAttempts(options, ids);
1641
- const { table } = await this.getQueueCache(name);
1642
- const outputData = this.mapCompletionDataArg(data);
1643
- let response;
1644
- // noMultiMutationCte: split the dependency-unblocking into a separate statement to
1645
- // avoid CockroachDB's multi-mutation CTE limitation (completeJobs updates two tables).
1646
- if (this.config.noMultiMutationCte) {
1647
- response = await this.completeDistributed(name, ids, outputData, table, db, options.includeQueued, attempts);
1648
- }
1649
- else {
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]);
1652
- response = this.mapCommandResponse(ids, result);
1653
- }
1654
- this.#trackHandlerSettle(options, response);
1655
- return response;
1814
+ return this.#telemetry.settle('complete', name, ids, async () => {
1815
+ const { table } = await this.getQueueCache(name);
1816
+ const outputData = this.mapCompletionDataArg(data);
1817
+ let response;
1818
+ // noMultiMutationCte: split the dependency-unblocking into a separate statement to
1819
+ // avoid CockroachDB's multi-mutation CTE limitation (completeJobs updates two tables).
1820
+ if (this.config.noMultiMutationCte) {
1821
+ response = await this.completeDistributed(name, ids, outputData, table, db, options.includeQueued, attempts);
1822
+ }
1823
+ else {
1824
+ const sql = plans.completeJobs(this.config.schema, table, options.includeQueued, !!attempts);
1825
+ const result = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
1826
+ response = this.mapCommandResponse(ids, result);
1827
+ }
1828
+ this.#trackHandlerSettle(options, response);
1829
+ return response;
1830
+ });
1656
1831
  }
1657
1832
  // Distributed complete/fail need several statements run atomically. When we own the pooled
1658
1833
  // connection we pin a single client via withTransaction(); when the caller supplied their own
@@ -1677,22 +1852,24 @@ class Manager extends EventEmitter {
1677
1852
  const db = this.assertDb(options);
1678
1853
  const { ids, attempts: fetched } = this.mapAttemptArg(id, 'fail');
1679
1854
  const attempts = fetched ?? this.#handlerAttempts(options, ids);
1680
- const { table } = await this.getQueueCache(name);
1681
- const outputData = this.mapCompletionDataArg(data);
1682
- let response;
1683
- // noMultiMutationCte: use separate queries to avoid CockroachDB's multi-mutation CTE limitation.
1684
- // The delete and re-insert run in a single transaction (see ensureTransaction) so the
1685
- // job cannot be lost between the two statements.
1686
- if (this.config.noMultiMutationCte) {
1687
- response = await this.failDistributed(name, ids, outputData, table, db, attempts);
1688
- }
1689
- else {
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]);
1692
- response = this.mapCommandResponse(ids, result);
1693
- }
1694
- this.#trackHandlerSettle(options, response);
1695
- return response;
1855
+ return this.#telemetry.settle('fail', name, ids, async () => {
1856
+ const { table } = await this.getQueueCache(name);
1857
+ const outputData = this.mapCompletionDataArg(data);
1858
+ let response;
1859
+ // noMultiMutationCte: use separate queries to avoid CockroachDB's multi-mutation CTE limitation.
1860
+ // The delete and re-insert run in a single transaction (see ensureTransaction) so the
1861
+ // job cannot be lost between the two statements.
1862
+ if (this.config.noMultiMutationCte) {
1863
+ response = await this.failDistributed(name, ids, outputData, table, db, attempts);
1864
+ }
1865
+ else {
1866
+ const sql = plans.failJobsById(this.config.schema, table, !!attempts);
1867
+ const result = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
1868
+ response = this.mapCommandResponse(ids, result);
1869
+ }
1870
+ this.#trackHandlerSettle(options, response);
1871
+ return response;
1872
+ });
1696
1873
  }
1697
1874
  async failDistributed(name, ids, outputData, table, db, attempts) {
1698
1875
  // CockroachDB doesn't support multi-mutation CTEs, but does support transactions, so the
@@ -1800,6 +1977,7 @@ class Manager extends EventEmitter {
1800
1977
  const data = toJsonText(job.data);
1801
1978
  const output = toJsonText(jobOutput);
1802
1979
  const sourceOutput = toJsonText(job.source_output);
1980
+ const traceContext = toJsonText(job.trace_context);
1803
1981
  // forceTerminal (perJobResults `deadletter`) skips retries so the job fails terminally and
1804
1982
  // routes straight to the dead letter queue below.
1805
1983
  const canRetry = !forceTerminal && retryCount < retryLimit;
@@ -1828,7 +2006,7 @@ class Manager extends EventEmitter {
1828
2006
  job.deletion_seconds, createdOn, null, keepUntil, job.policy,
1829
2007
  output, job.dead_letter,
1830
2008
  null, job.heartbeat_seconds, job.blocked, job.blocking, job.pending_dependencies,
1831
- job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, sourceOutput, job.source_root_id
2009
+ job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, sourceOutput, job.source_root_id, traceContext
1832
2010
  ]);
1833
2011
  // The retry insert can be dropped by ON CONFLICT when the queue policy (e.g. stately,
1834
2012
  // singleton, key_strict_fifo) already has a non-terminal job. Mirror the failed_jobs
@@ -1843,11 +2021,11 @@ class Manager extends EventEmitter {
1843
2021
  job.deletion_seconds, createdOn, new Date(this.config.clock.now()), keepUntil, job.policy,
1844
2022
  output, job.dead_letter,
1845
2023
  null, job.heartbeat_seconds, job.blocked, job.blocking, job.pending_dependencies,
1846
- job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, sourceOutput, job.source_root_id
2024
+ job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, sourceOutput, job.source_root_id, traceContext
1847
2025
  ]);
1848
2026
  // Insert to dead letter queue if failed and has dead_letter configured
1849
2027
  if (job.dead_letter) {
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]);
2028
+ 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, traceContext]);
1851
2029
  }
1852
2030
  }
1853
2031
  count++;
@@ -1860,12 +2038,14 @@ class Manager extends EventEmitter {
1860
2038
  const db = this.assertDb(options);
1861
2039
  const { ids, attempts: fetched } = this.mapAttemptArg(id, 'deleteJob');
1862
2040
  const attempts = fetched ?? this.#handlerAttempts(options, ids);
1863
- const { table } = await this.getQueueCache(name);
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]);
1866
- const response = this.mapCommandResponse(ids, result);
1867
- this.#trackHandlerSettle(options, response);
1868
- return response;
2041
+ return this.#telemetry.settle('delete', name, ids, async () => {
2042
+ const { table } = await this.getQueueCache(name);
2043
+ const sql = plans.deleteJobsById(this.config.schema, table, !!attempts);
2044
+ const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
2045
+ const response = this.mapCommandResponse(ids, result);
2046
+ this.#trackHandlerSettle(options, response);
2047
+ return response;
2048
+ });
1869
2049
  }
1870
2050
  // The filter half of redrive and previewRedrive, validated once and in the parameter order
1871
2051
  // plans.redriveWhere expects ($2 through $6).
@@ -1956,12 +2136,14 @@ class Manager extends EventEmitter {
1956
2136
  const db = this.assertDb(options);
1957
2137
  const { ids, attempts: fetched } = this.mapAttemptArg(id, 'cancel');
1958
2138
  const attempts = fetched ?? this.#handlerAttempts(options, ids);
1959
- const { table } = await this.getQueueCache(name);
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]);
1962
- const response = this.mapCommandResponse(ids, result);
1963
- this.#trackHandlerSettle(options, response);
1964
- return response;
2139
+ return this.#telemetry.settle('cancel', name, ids, async () => {
2140
+ const { table } = await this.getQueueCache(name);
2141
+ const sql = plans.cancelJobs(this.config.schema, table, !!attempts);
2142
+ const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
2143
+ const response = this.mapCommandResponse(ids, result);
2144
+ this.#trackHandlerSettle(options, response);
2145
+ return response;
2146
+ });
1965
2147
  }
1966
2148
  async resume(name, id, options = {}) {
1967
2149
  Attorney.assertQueueName(name);
@@ -2013,7 +2195,7 @@ class Manager extends EventEmitter {
2013
2195
  }
2014
2196
  const sql = plans.createQueue(this.config.schema, name, { ...options, policy }, this.config.noAdvisoryLocks);
2015
2197
  await this.db.executeSql(sql);
2016
- this.#evictQueueCache(name);
2198
+ await this.#reloadQueueCache(name);
2017
2199
  }
2018
2200
  async getBlockedKeys(name) {
2019
2201
  Attorney.assertQueueName(name);
@@ -2043,6 +2225,11 @@ class Manager extends EventEmitter {
2043
2225
  }
2044
2226
  }
2045
2227
  }
2228
+ // Every backend: the histograms' empty slots are stored as null and handed out as 0.
2229
+ for (const row of rows) {
2230
+ row.waitBins = toBins(row.waitBins);
2231
+ row.runBins = toBins(row.runBins);
2232
+ }
2046
2233
  return rows;
2047
2234
  }
2048
2235
  async updateQueue(name, options = {}) {
@@ -2064,7 +2251,7 @@ class Manager extends EventEmitter {
2064
2251
  }
2065
2252
  const sql = plans.updateQueue(this.config.schema);
2066
2253
  await this.db.executeSql(sql, [name, options]);
2067
- this.#evictQueueCache(name);
2254
+ await this.#reloadQueueCache(name);
2068
2255
  }
2069
2256
  async getQueue(name) {
2070
2257
  const rows = await this.getQueues([name]);
@@ -2081,8 +2268,21 @@ class Manager extends EventEmitter {
2081
2268
  catch {
2082
2269
  return;
2083
2270
  }
2084
- const sql = plans.deleteQueue(this.config.schema, name, this.config.noAdvisoryLocks);
2085
- await this.db.executeSql(sql);
2271
+ const sql = plans.deleteQueue(this.config.schema, name, this.config.noAdvisoryLocks, !this.config.noTablePartitioning);
2272
+ // Only a queue with its own table takes locks that can be busy, and only those are tried again.
2273
+ for (let attempt = 1;; attempt++) {
2274
+ try {
2275
+ await this.db.executeSql(sql);
2276
+ break;
2277
+ }
2278
+ catch (err) {
2279
+ if (err?.code !== plans.PG_ERROR.lockNotAvailable)
2280
+ throw err;
2281
+ if (attempt === DELETE_QUEUE_ATTEMPTS)
2282
+ throw new Error(`Queue ${name} was not deleted: its tables stayed locked through ${attempt} tries`, { cause: err });
2283
+ await delay(Math.min(25 * 2 ** (attempt - 1), DELETE_QUEUE_MAX_DELAY_MS) * (0.5 + Math.random() / 2));
2284
+ }
2285
+ }
2086
2286
  this.#evictQueueCache(name);
2087
2287
  }
2088
2288
  async deleteQueuedJobs(name) {
@@ -2130,6 +2330,10 @@ class Manager extends EventEmitter {
2130
2330
  // getQueue(name).
2131
2331
  async getQueueStats(name, options = {}) {
2132
2332
  Attorney.assertQueueName(name);
2333
+ assert(options.percentiles === undefined || (Array.isArray(options.percentiles) && options.percentiles.length > 0 &&
2334
+ options.percentiles.every(p => typeof p === 'number' && p >= 1 && p <= 100)), 'getQueueStats: percentiles must be a non-empty array of percents from 1 to 100, such as [50, 95]');
2335
+ // Each value once, in the order first asked for.
2336
+ const percentiles = options.percentiles && [...new Set(options.percentiles)];
2133
2337
  const isCockroach = this.config.backend === 'cockroachdb';
2134
2338
  // `counted` is true for recorded snapshots. The cache path serves only gauges: the queue table's
2135
2339
  // counters describe the last pass that counted, not this reading.
@@ -2150,6 +2354,9 @@ class Manager extends EventEmitter {
2150
2354
  createdDelta: null,
2151
2355
  deltaSeconds: null,
2152
2356
  deltaOn: null,
2357
+ waitBins: null,
2358
+ runBins: null,
2359
+ readyOldestSeconds: null,
2153
2360
  capturedOn: row?.capturedOn ?? new Date(this.config.clock.now())
2154
2361
  };
2155
2362
  for (const field of counted ? [...STATS_COUNT_FIELDS, ...STATS_DELTA_FIELDS] : STATS_COUNT_FIELDS) {
@@ -2161,6 +2368,20 @@ class Manager extends EventEmitter {
2161
2368
  // The end of the interval the counters cover, handed on as the row holds it, like capturedOn.
2162
2369
  if (counted && row?.deltaOn != null)
2163
2370
  snapshot.deltaOn = row.deltaOn;
2371
+ if (counted) {
2372
+ snapshot.waitBins = toBins(row?.waitBins);
2373
+ snapshot.runBins = toBins(row?.runBins);
2374
+ if (row?.readyOldestSeconds != null)
2375
+ snapshot.readyOldestSeconds = Number(row.readyOldestSeconds);
2376
+ }
2377
+ // Read from this snapshot's (or bucket's) own histograms, so they are null wherever those are.
2378
+ if (percentiles) {
2379
+ snapshot.percentiles = percentiles.map(p => ({
2380
+ p,
2381
+ waitSeconds: percentile(snapshot.waitBins, p),
2382
+ runSeconds: percentile(snapshot.runBins, p)
2383
+ }));
2384
+ }
2164
2385
  return snapshot;
2165
2386
  };
2166
2387
  if (this.config.persistQueueStats) {
@@ -2293,7 +2514,7 @@ class Manager extends EventEmitter {
2293
2514
  return options.db;
2294
2515
  }
2295
2516
  if (this.db._pgbdb) {
2296
- assert(this.db.opened, 'Database connection is not opened');
2517
+ assert(this.db.opened, 'Database not opened. Call start() before using pg-boss, or again after stop().');
2297
2518
  }
2298
2519
  return this.db;
2299
2520
  }