pg-boss 12.35.0 → 12.36.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,11 +6,13 @@ 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
11
  import { 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';
@@ -80,6 +82,7 @@ const NUMERIC_METADATA_FIELDS = [
80
82
  ];
81
83
  // Queue rows (plans.getQueues) return these integer columns as strings on CockroachDB too.
82
84
  const NUMERIC_QUEUE_FIELDS = [
85
+ 'blockedCount',
83
86
  'retryLimit',
84
87
  'retryDelay',
85
88
  'retryDelayMax',
@@ -97,7 +100,8 @@ const NUMERIC_QUEUE_FIELDS = [
97
100
  'createdDelta',
98
101
  'completedDelta',
99
102
  'failedDelta',
100
- 'deltaSeconds'
103
+ 'deltaSeconds',
104
+ 'readyOldestSeconds'
101
105
  ];
102
106
  // The gauges shared by live stats and recorded snapshots (the QueueStats shape).
103
107
  const STATS_COUNT_FIELDS = [
@@ -108,6 +112,12 @@ const STATS_COUNT_FIELDS = [
108
112
  'failedCount',
109
113
  'totalCount'
110
114
  ];
115
+ // A snapshot's histogram, LATENCY_SLOTS counts from a recorded pass or added up over a bucket, with
116
+ // the slots stored as null (no job landed there) handed out as 0. Null when no pass counted it; all
117
+ // zeros when one did and nothing finished. CockroachDB hands integers over as strings.
118
+ function toBins(bins) {
119
+ return Array.isArray(bins) ? bins.map(n => (n == null ? 0 : Number(n))) : null;
120
+ }
111
121
  // The throughput counters and the seconds they cover. Only recorded snapshots carry them; see
112
122
  // getQueueStats.
113
123
  const STATS_DELTA_FIELDS = [
@@ -147,6 +157,10 @@ function rethrowWriteError(err) {
147
157
  }
148
158
  throw err;
149
159
  }
160
+ // For a json value read back from a row and bound again behind `::text::jsonb`. Null stays SQL NULL.
161
+ function toJsonText(value) {
162
+ return value == null ? null : JSON.stringify(value);
163
+ }
150
164
  class Manager extends EventEmitter {
151
165
  events = events;
152
166
  // Warn once per option per instance, not once per fetch.
@@ -183,6 +197,9 @@ class Manager extends EventEmitter {
183
197
  #localGroupActive;
184
198
  #localGroupConfig;
185
199
  #localGroupMaxLimit;
200
+ #telemetry;
201
+ // The trace context each fetched job was sent with, kept off the job object handed to the handler.
202
+ #traceContexts;
186
203
  constructor(db, config) {
187
204
  super();
188
205
  this.config = config;
@@ -201,6 +218,8 @@ class Manager extends EventEmitter {
201
218
  this.#localGroupActive = new Map();
202
219
  this.#localGroupConfig = new Map();
203
220
  this.#localGroupMaxLimit = new Map();
221
+ this.#telemetry = new Telemetry(config.openTelemetry, config.schema, () => this.queues);
222
+ this.#traceContexts = new WeakMap();
204
223
  }
205
224
  getSpy(name) {
206
225
  if (!this.config.__test__enableSpies) {
@@ -348,6 +367,7 @@ class Manager extends EventEmitter {
348
367
  // (each output carried per-id via a JSON recordset), so batch size never drives the statement
349
368
  // count. Any batch job the handler omits (or returns with an invalid shape) is failed with a
350
369
  // descriptive error so it retries / dead-letters per queue config.
370
+ // Resolves with the error the whole batch was failed with, or undefined.
351
371
  async #settlePerJob(name, jobs, result) {
352
372
  if (!Array.isArray(result)) {
353
373
  // The handler opted into perJobResults but did not return an array: a contract violation.
@@ -355,7 +375,7 @@ class Manager extends EventEmitter {
355
375
  const err = new Error('perJobResults handler must resolve with an array of job results');
356
376
  await this.fail(name, jobs, err);
357
377
  await this.#trackJobsFailed(name, jobs, err);
358
- return;
378
+ return err;
359
379
  }
360
380
  // Index the handler's dispositions by job id, keeping only valid entries that reference a job
361
381
  // from this batch. Last write wins on duplicate ids.
@@ -387,9 +407,16 @@ class Manager extends EventEmitter {
387
407
  }
388
408
  }
389
409
  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;
410
+ const ids = (entries) => entries.map(({ job }) => job.id);
411
+ const completedIds = completed.length > 0
412
+ ? await this.#telemetry.settle('complete', name, ids(completed), () => this.#completeWithOutputs(name, items(completed)))
413
+ : null;
414
+ const failedIds = failed.length > 0
415
+ ? await this.#telemetry.settle('fail', name, ids(failed), () => this.#failWithOutputs(name, items(failed)))
416
+ : null;
417
+ const deadLetteredIds = deadLettered.length > 0
418
+ ? await this.#telemetry.settle('fail', name, ids(deadLettered), () => this.#failWithOutputs(name, items(deadLettered), true))
419
+ : null;
393
420
  // Only the jobs each statement actually settled: the attempt fence leaves a job whose claim
394
421
  // lapsed alone, and recording it would tell a spy it settled when another attempt holds it.
395
422
  const landed = (entries, result) => {
@@ -505,6 +532,8 @@ class Manager extends EventEmitter {
505
532
  * same as any other worker's. Because the claim is outside the transaction, the jobs stay
506
533
  * visibly `active` throughout, which is what keeps heartbeats, `expireInSeconds`, and another
507
534
  * instance's supervisor working on them as usual.
535
+ *
536
+ * Resolves with the error the batch was failed with, or undefined when it completed.
508
537
  */
509
538
  async #processJobs(name, jobs, callback, worker, heartbeatRefreshSeconds, perJobResults = false, transactional = false, transactionTimeoutSeconds) {
510
539
  const jobIds = jobs.map(job => job.id);
@@ -565,6 +594,9 @@ class Manager extends EventEmitter {
565
594
  let completedAffected = 0;
566
595
  let failedError;
567
596
  let didFail = false;
597
+ // A perJobResults batch #settlePerJob failed as a whole. Kept apart from failedError, since
598
+ // #settlePerJob has already failed and tracked those jobs itself.
599
+ let perJobError;
568
600
  // Only for a transactional worker, and only from the begin below until it settles. rollback()
569
601
  // is idempotent, so the catch can settle it without tracking whether the commit got there
570
602
  // first.
@@ -615,7 +647,7 @@ class Manager extends EventEmitter {
615
647
  // #settlePerJob settles each job individually and does its own (synchronous,
616
648
  // lookup-free) spy tracking via #trackJobsSettled, so the deferred tracker below
617
649
  // is skipped for this path.
618
- await this.#settlePerJob(name, jobs, result);
650
+ perJobError = await this.#settlePerJob(name, jobs, result);
619
651
  }
620
652
  else {
621
653
  // Read out before the completion below, which goes through the same complete() and would
@@ -672,6 +704,7 @@ class Manager extends EventEmitter {
672
704
  await this.#trackJobsCompleted(name, jobs, completedResult, completedAffected);
673
705
  }
674
706
  }
707
+ return didFail ? (failedError ?? new Error('handler rejected without a reason')) : perJobError;
675
708
  }
676
709
  /**
677
710
  * Gives the handler's transaction a deadline the database enforces.
@@ -818,6 +851,10 @@ class Manager extends EventEmitter {
818
851
  }
819
852
  async start() {
820
853
  this.stopped = false;
854
+ if (this.#telemetry.enabled) {
855
+ const { rows } = await this.db.executeSql(plans.currentDatabase());
856
+ this.#telemetry.setDatabase(rows[0].name);
857
+ }
821
858
  this.queueCacheInterval = this.config.clock.setInterval(() => this.onCacheQueues({ emit: true }), this.config.queueCacheIntervalSeconds * 1000);
822
859
  this.wipInterval = this.config.clock.setInterval(() => {
823
860
  const now = this.config.clock.now();
@@ -831,12 +868,14 @@ class Manager extends EventEmitter {
831
868
  }
832
869
  }, 2000);
833
870
  await this.onCacheQueues();
871
+ this.#telemetry.observeQueues();
834
872
  }
835
873
  async onCacheQueues({ emit = false } = {}) {
836
874
  try {
837
875
  assert(!this.config.__test__throw_queueCache, 'test error');
838
876
  const queues = await this.getQueues();
839
877
  this.queues = queues.reduce((acc, i) => { acc[i.name] = i; return acc; }, {});
878
+ this.#telemetry.refreshInstruments();
840
879
  }
841
880
  catch (error) {
842
881
  emit && this.emit(events.error, { ...error, message: error.message, stack: error.stack });
@@ -859,10 +898,24 @@ class Manager extends EventEmitter {
859
898
  if (this.queues)
860
899
  delete this.queues[name];
861
900
  }
901
+ // Replaces a queue's cache entry with its row as it stands, rather than evicting it, so a queue
902
+ // created again or updated stays in the cache and the queue gauge while still picking up what
903
+ // changed: new options, or a table that changed under it (deleted and recreated elsewhere with
904
+ // another partition setting).
905
+ async #reloadQueueCache(name) {
906
+ if (!this.queues)
907
+ return;
908
+ const queue = await this.getQueue(name);
909
+ if (queue)
910
+ this.queues[name] = queue;
911
+ else
912
+ this.#evictQueueCache(name);
913
+ }
862
914
  async stop() {
863
915
  this.stopped = true;
864
916
  this.config.clock.clearInterval(this.queueCacheInterval);
865
917
  this.config.clock.clearInterval(this.wipInterval);
918
+ this.#telemetry.unobserveQueues();
866
919
  // offWork stops every worker on a queue, so iterate queue names rather than workers - otherwise
867
920
  // a localConcurrency of N re-stops all N workers N times and registers N pending cleanups.
868
921
  const names = new Set([...this.workers.values()]
@@ -948,7 +1001,12 @@ class Manager extends EventEmitter {
948
1001
  const ignoreGroups = localGroupConcurrency != null
949
1002
  ? this.#getGroupsAtLocalCapacity(name)
950
1003
  : undefined;
951
- return this.fetch(name, { batchSize, includeMetadata, priority, orderByCreatedOn, groupConcurrency, ignoreGroups, minPriority, maxPriority });
1004
+ return this.#fetch(name, { batchSize, includeMetadata, priority, orderByCreatedOn, groupConcurrency, ignoreGroups, minPriority, maxPriority });
1005
+ };
1006
+ // Counted here rather than on fetch: jobs past localGroupConcurrency are restored, not delivered.
1007
+ const processBatch = (batch, worker) => {
1008
+ this.#telemetry.consumed(name, batch.length);
1009
+ return this.#telemetry.process(name, batch, job => this.#traceContexts.get(job), () => this.#processJobs(name, batch, callback, worker, heartbeatRefreshSeconds, perJobResults, transactional, transactionTimeoutSeconds));
952
1010
  };
953
1011
  const onFetch = async (jobs) => {
954
1012
  if (!jobs.length)
@@ -961,7 +1019,7 @@ class Manager extends EventEmitter {
961
1019
  const worker = this.workers.get(workerId);
962
1020
  // Skip all in-memory group tracking when localGroupConcurrency is not enabled
963
1021
  if (localGroupConcurrency == null) {
964
- await this.#processJobs(name, jobs, callback, worker, heartbeatRefreshSeconds, perJobResults, transactional, transactionTimeoutSeconds);
1022
+ await processBatch(jobs, worker);
965
1023
  }
966
1024
  else {
967
1025
  const { allowed, excess, groupedJobs } = this.#trackLocalGroupStart(name, jobs);
@@ -976,7 +1034,7 @@ class Manager extends EventEmitter {
976
1034
  worker.jobs = allowed;
977
1035
  }
978
1036
  if (allowed.length > 0) {
979
- await this.#processJobs(name, allowed, callback, worker, heartbeatRefreshSeconds, perJobResults, transactional, transactionTimeoutSeconds);
1037
+ await processBatch(allowed, worker);
980
1038
  }
981
1039
  }
982
1040
  finally {
@@ -1076,8 +1134,17 @@ class Manager extends EventEmitter {
1076
1134
  .filter(i => i.state !== 'stopped' && (!INTERNAL_QUEUES[i.name] || includeInternal));
1077
1135
  return data;
1078
1136
  }
1079
- hasPendingCleanups() {
1080
- return this.pendingOffWorkCleanups.size > 0;
1137
+ trackCleanup(cleanup) {
1138
+ this.pendingOffWorkCleanups.add(cleanup);
1139
+ const settled = () => { this.pendingOffWorkCleanups.delete(cleanup); };
1140
+ cleanup.then(settled, settled);
1141
+ }
1142
+ // Resolves once no cleanup is pending, including any a settling cleanup registers, so a graceful
1143
+ // stop() carries on the moment its last worker has stopped.
1144
+ async settleCleanups() {
1145
+ while (this.pendingOffWorkCleanups.size > 0) {
1146
+ await Promise.allSettled([...this.pendingOffWorkCleanups]);
1147
+ }
1081
1148
  }
1082
1149
  async offWork(name, options = { wait: true }) {
1083
1150
  assert(name, 'queue name is required');
@@ -1106,11 +1173,8 @@ class Manager extends EventEmitter {
1106
1173
  this.#cleanupLocalGroupTracking(name);
1107
1174
  }
1108
1175
  else {
1109
- this.pendingOffWorkCleanups.add(cleanupPromise);
1110
- cleanupPromise.finally(() => {
1111
- this.pendingOffWorkCleanups.delete(cleanupPromise);
1112
- this.#cleanupLocalGroupTracking(name);
1113
- });
1176
+ this.trackCleanup(cleanupPromise);
1177
+ cleanupPromise.finally(() => this.#cleanupLocalGroupTracking(name));
1114
1178
  }
1115
1179
  }
1116
1180
  notifyWorker(workerId) {
@@ -1154,6 +1218,10 @@ class Manager extends EventEmitter {
1154
1218
  }
1155
1219
  async publish(event, data, options) {
1156
1220
  assert(event, 'Missing required argument');
1221
+ // Counts no jobs of its own: each send() below records the job it creates.
1222
+ await this.#telemetry.send('publish', event, 0, () => this.#publish(event, data, options));
1223
+ }
1224
+ async #publish(event, data, options) {
1157
1225
  const sql = plans.getQueuesForEvent(this.config.schema);
1158
1226
  const { rows } = await this.db.executeSql(sql, [event]);
1159
1227
  const results = await Promise.allSettled(rows.map(({ name }) => this.send(name, data, options)));
@@ -1220,9 +1288,12 @@ class Manager extends EventEmitter {
1220
1288
  };
1221
1289
  }
1222
1290
  async createJob(request) {
1291
+ return this.#telemetry.send('send', request.name, 1, carrier => this.#createJob(request, carrier), id => id ? [id] : null);
1292
+ }
1293
+ async #createJob(request, traceContext) {
1223
1294
  const { name, data = null, options = {} } = request;
1224
1295
  const { db: wrapper, singletonSeconds, singletonNextSlot } = options;
1225
- const job = this.#toJobPayload(name, data, options);
1296
+ const job = { ...this.#toJobPayload(name, data, options), __traceContext: traceContext };
1226
1297
  const db = wrapper || this.db;
1227
1298
  const { table, policy, notify } = await this.getQueueCache(name);
1228
1299
  if (policy === plans.QUEUE_POLICIES.key_strict_fifo && !job.singletonKey) {
@@ -1299,9 +1370,14 @@ class Manager extends EventEmitter {
1299
1370
  }
1300
1371
  async upsert(...args) {
1301
1372
  const request = Attorney.checkUpdateArgs(args, { upsert: true });
1373
+ Attorney.assertQueueName(request.name);
1374
+ // Only an insert counts as a send, and only an inserted job stores the trace context: an
1375
+ // updated job keeps the trace of the send that created it.
1376
+ return this.#telemetry.send('upsert', request.name, result => result.inserted, carrier => this.#upsert(request, carrier), result => result.jobs);
1377
+ }
1378
+ async #upsert(request, traceContext) {
1302
1379
  const { name, data } = request;
1303
1380
  const opts = (request.options ?? {});
1304
- Attorney.assertQueueName(name);
1305
1381
  const db = this.assertDb(opts);
1306
1382
  const { table, policy, notify } = await this.getQueueCache(name);
1307
1383
  const by = opts.id ? 'id' : 'singletonKey';
@@ -1316,7 +1392,7 @@ class Manager extends EventEmitter {
1316
1392
  const insertSql = plans.insertJobs(this.config.schema, { table, name, returnId: true, notify: notifyEnabled });
1317
1393
  const job = this.#toUpdatePayload(data, opts);
1318
1394
  const updatePayload = JSON.stringify(job);
1319
- const insertPayload = JSON.stringify([job]);
1395
+ const insertPayload = JSON.stringify([{ ...job, __traceContext: traceContext }]);
1320
1396
  const result = await this.ensureTransaction(db, async (tx) => {
1321
1397
  const { rows: updated } = await tx.executeSql(updateSql, [updatePayload]);
1322
1398
  if (updated.length) {
@@ -1353,6 +1429,9 @@ class Manager extends EventEmitter {
1353
1429
  // statement nor kept on the objects below, so this stays the raw path it has always been.
1354
1430
  options = {}) {
1355
1431
  assert(Array.isArray(jobs), 'jobs argument should be an array');
1432
+ return this.#telemetry.send('insert', name, jobs.length, carrier => this.#insert(name, jobs, options, carrier));
1433
+ }
1434
+ async #insert(name, jobs, options, traceContext) {
1356
1435
  const slots = options.__singletonSlots === true;
1357
1436
  const seenIds = new Set();
1358
1437
  for (const job of jobs) {
@@ -1384,6 +1463,8 @@ class Manager extends EventEmitter {
1384
1463
  const dataById = spy ? new Map() : undefined;
1385
1464
  const insertPayload = jobs.map(j => {
1386
1465
  const { blocked, blocking, pendingDependencies, group, __singletonSlot, ...rest } = j;
1466
+ // Overwrites any __traceContext the caller passed.
1467
+ Object.assign(rest, { __traceContext: traceContext });
1387
1468
  // Reattached only for the caller that asked for the column, so a public insert() drops the
1388
1469
  // field rather than handing an unvalidated value to a timestamp cast.
1389
1470
  if (slots && __singletonSlot !== undefined) {
@@ -1431,6 +1512,11 @@ class Manager extends EventEmitter {
1431
1512
  }
1432
1513
  async flow(jobs, options = {}) {
1433
1514
  Attorney.validateFlowJobs(jobs);
1515
+ const queues = new Set(jobs.map(job => job.name));
1516
+ const destination = queues.size === 1 ? jobs[0].name : null;
1517
+ return this.#telemetry.send('flow', destination, jobs.length, carrier => this.#flow(jobs, options, carrier), refToId => Object.values(refToId));
1518
+ }
1519
+ async #flow(jobs, options, traceContext) {
1434
1520
  // validate and normalize each job's options the same way send()/insert() do
1435
1521
  const flowJobs = jobs.map(job => ({
1436
1522
  ...job,
@@ -1493,7 +1579,8 @@ class Manager extends EventEmitter {
1493
1579
  deadLetter: j.options?.deadLetter ?? undefined,
1494
1580
  blocked: dependencyCount > 0 || undefined,
1495
1581
  blocking: parentRefs.has(j.ref) || undefined,
1496
- pendingDependencies: dependencyCount || undefined
1582
+ pendingDependencies: dependencyCount || undefined,
1583
+ __traceContext: traceContext
1497
1584
  };
1498
1585
  });
1499
1586
  statements.push(plans.insertFlowJobs(this.config.schema, { table, name: queueName }, insertPayload));
@@ -1549,6 +1636,9 @@ class Manager extends EventEmitter {
1549
1636
  }
1550
1637
  async fetch(name, options = {}) {
1551
1638
  Attorney.checkFetchArgs(name, options);
1639
+ return this.#telemetry.receive(name, () => this.#fetch(name, options), job => this.#traceContexts.get(job));
1640
+ }
1641
+ async #fetch(name, options) {
1552
1642
  this.#warnDeprecatedFetchOptions(options);
1553
1643
  const db = this.assertDb(options);
1554
1644
  const { table, policy, singletonsActive } = await this.getQueueCache(name);
@@ -1559,7 +1649,8 @@ class Manager extends EventEmitter {
1559
1649
  name,
1560
1650
  policy,
1561
1651
  limit: options.batchSize || 1,
1562
- ignoreSingletons: singletonsActive
1652
+ ignoreSingletons: singletonsActive,
1653
+ includeTraceContext: this.#telemetry.enabled
1563
1654
  };
1564
1655
  const query = plans.fetchNextJob(fetchOptions, this.config.noSkipLocked);
1565
1656
  let result;
@@ -1576,6 +1667,13 @@ class Manager extends EventEmitter {
1576
1667
  throw err;
1577
1668
  }
1578
1669
  const rows = result?.rows || [];
1670
+ for (const row of rows) {
1671
+ // A db adapter may hand jsonb back unparsed.
1672
+ const carrier = typeof row.__traceContext === 'string' ? JSON.parse(row.__traceContext) : row.__traceContext;
1673
+ delete row.__traceContext;
1674
+ if (carrier)
1675
+ this.#traceContexts.set(row, carrier);
1676
+ }
1579
1677
  // Even a minimal fetch (JOB_COLUMNS_MIN) returns numeric fields like expireInSeconds and
1580
1678
  // heartbeatSeconds, so normalize regardless of includeMetadata.
1581
1679
  return this.#numericJobFields(rows);
@@ -1628,21 +1726,23 @@ class Manager extends EventEmitter {
1628
1726
  const db = this.assertDb(options);
1629
1727
  const { ids, attempts: fetched } = this.mapAttemptArg(id, 'complete');
1630
1728
  const attempts = fetched ?? this.#handlerAttempts(options, ids);
1631
- const { table } = await this.getQueueCache(name);
1632
- const outputData = this.mapCompletionDataArg(data);
1633
- let response;
1634
- // noMultiMutationCte: split the dependency-unblocking into a separate statement to
1635
- // avoid CockroachDB's multi-mutation CTE limitation (completeJobs updates two tables).
1636
- if (this.config.noMultiMutationCte) {
1637
- response = await this.completeDistributed(name, ids, outputData, table, db, options.includeQueued, attempts);
1638
- }
1639
- else {
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]);
1642
- response = this.mapCommandResponse(ids, result);
1643
- }
1644
- this.#trackHandlerSettle(options, response);
1645
- return response;
1729
+ return this.#telemetry.settle('complete', name, ids, async () => {
1730
+ const { table } = await this.getQueueCache(name);
1731
+ const outputData = this.mapCompletionDataArg(data);
1732
+ let response;
1733
+ // noMultiMutationCte: split the dependency-unblocking into a separate statement to
1734
+ // avoid CockroachDB's multi-mutation CTE limitation (completeJobs updates two tables).
1735
+ if (this.config.noMultiMutationCte) {
1736
+ response = await this.completeDistributed(name, ids, outputData, table, db, options.includeQueued, attempts);
1737
+ }
1738
+ else {
1739
+ const sql = plans.completeJobs(this.config.schema, table, options.includeQueued, !!attempts);
1740
+ const result = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
1741
+ response = this.mapCommandResponse(ids, result);
1742
+ }
1743
+ this.#trackHandlerSettle(options, response);
1744
+ return response;
1745
+ });
1646
1746
  }
1647
1747
  // Distributed complete/fail need several statements run atomically. When we own the pooled
1648
1748
  // connection we pin a single client via withTransaction(); when the caller supplied their own
@@ -1667,22 +1767,24 @@ class Manager extends EventEmitter {
1667
1767
  const db = this.assertDb(options);
1668
1768
  const { ids, attempts: fetched } = this.mapAttemptArg(id, 'fail');
1669
1769
  const attempts = fetched ?? this.#handlerAttempts(options, ids);
1670
- const { table } = await this.getQueueCache(name);
1671
- const outputData = this.mapCompletionDataArg(data);
1672
- let response;
1673
- // noMultiMutationCte: use separate queries to avoid CockroachDB's multi-mutation CTE limitation.
1674
- // The delete and re-insert run in a single transaction (see ensureTransaction) so the
1675
- // job cannot be lost between the two statements.
1676
- if (this.config.noMultiMutationCte) {
1677
- response = await this.failDistributed(name, ids, outputData, table, db, attempts);
1678
- }
1679
- else {
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]);
1682
- response = this.mapCommandResponse(ids, result);
1683
- }
1684
- this.#trackHandlerSettle(options, response);
1685
- return response;
1770
+ return this.#telemetry.settle('fail', name, ids, async () => {
1771
+ const { table } = await this.getQueueCache(name);
1772
+ const outputData = this.mapCompletionDataArg(data);
1773
+ let response;
1774
+ // noMultiMutationCte: use separate queries to avoid CockroachDB's multi-mutation CTE limitation.
1775
+ // The delete and re-insert run in a single transaction (see ensureTransaction) so the
1776
+ // job cannot be lost between the two statements.
1777
+ if (this.config.noMultiMutationCte) {
1778
+ response = await this.failDistributed(name, ids, outputData, table, db, attempts);
1779
+ }
1780
+ else {
1781
+ const sql = plans.failJobsById(this.config.schema, table, !!attempts);
1782
+ const result = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
1783
+ response = this.mapCommandResponse(ids, result);
1784
+ }
1785
+ this.#trackHandlerSettle(options, response);
1786
+ return response;
1787
+ });
1686
1788
  }
1687
1789
  async failDistributed(name, ids, outputData, table, db, attempts) {
1688
1790
  // CockroachDB doesn't support multi-mutation CTEs, but does support transactions, so the
@@ -1785,6 +1887,12 @@ class Manager extends EventEmitter {
1785
1887
  // Dead-letter provenance, carried through so a job in a dead letter queue that fails here
1786
1888
  // still knows where to be redriven. See failJobsBody for the single-statement path.
1787
1889
  const sourceCreatedOn = job.source_created_on_text ?? job.source_created_on;
1890
+ // The json columns go back as text. Bound as read, a job whose data is an array reaches pg as
1891
+ // a Postgres array literal and a string as bare text, and neither parses as json.
1892
+ const data = toJsonText(job.data);
1893
+ const output = toJsonText(jobOutput);
1894
+ const sourceOutput = toJsonText(job.source_output);
1895
+ const traceContext = toJsonText(job.trace_context);
1788
1896
  // forceTerminal (perJobResults `deadletter`) skips retries so the job fails terminally and
1789
1897
  // routes straight to the dead letter queue below.
1790
1898
  const canRetry = !forceTerminal && retryCount < retryLimit;
@@ -1807,13 +1915,13 @@ class Manager extends EventEmitter {
1807
1915
  // pending_dependencies are preserved so flows and heartbeat detection survive a retry
1808
1916
  // (matches the non-distributed failJobs() CTE).
1809
1917
  const { rows } = await tx.executeSql(insertSql, [
1810
- job.id, job.name, job.priority, job.data, 'retry', job.retry_limit, job.retry_count,
1918
+ job.id, job.name, job.priority, data, 'retry', job.retry_limit, job.retry_count,
1811
1919
  job.retry_delay, job.retry_backoff, job.retry_delay_max, startAfter, startedOn,
1812
1920
  job.singleton_key, singletonOn, job.group_id, job.group_tier, job.expire_seconds,
1813
1921
  job.deletion_seconds, createdOn, null, keepUntil, job.policy,
1814
- jobOutput, job.dead_letter,
1922
+ output, job.dead_letter,
1815
1923
  null, job.heartbeat_seconds, job.blocked, job.blocking, job.pending_dependencies,
1816
- job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, job.source_output, job.source_root_id
1924
+ job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, sourceOutput, job.source_root_id, traceContext
1817
1925
  ]);
1818
1926
  // The retry insert can be dropped by ON CONFLICT when the queue policy (e.g. stately,
1819
1927
  // singleton, key_strict_fifo) already has a non-terminal job. Mirror the failed_jobs
@@ -1822,17 +1930,17 @@ class Manager extends EventEmitter {
1822
1930
  }
1823
1931
  if (!retried) {
1824
1932
  await tx.executeSql(insertSql, [
1825
- job.id, job.name, job.priority, job.data, 'failed', job.retry_limit, job.retry_count,
1933
+ job.id, job.name, job.priority, data, 'failed', job.retry_limit, job.retry_count,
1826
1934
  job.retry_delay, job.retry_backoff, job.retry_delay_max, startAfterColumn, startedOn,
1827
1935
  job.singleton_key, singletonOn, job.group_id, job.group_tier, job.expire_seconds,
1828
1936
  job.deletion_seconds, createdOn, new Date(this.config.clock.now()), keepUntil, job.policy,
1829
- jobOutput, job.dead_letter,
1937
+ output, job.dead_letter,
1830
1938
  null, job.heartbeat_seconds, job.blocked, job.blocking, job.pending_dependencies,
1831
- job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, job.source_output, job.source_root_id
1939
+ job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, sourceOutput, job.source_root_id, traceContext
1832
1940
  ]);
1833
1941
  // Insert to dead letter queue if failed and has dead_letter configured
1834
1942
  if (job.dead_letter) {
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]);
1943
+ 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]);
1836
1944
  }
1837
1945
  }
1838
1946
  count++;
@@ -1845,12 +1953,14 @@ class Manager extends EventEmitter {
1845
1953
  const db = this.assertDb(options);
1846
1954
  const { ids, attempts: fetched } = this.mapAttemptArg(id, 'deleteJob');
1847
1955
  const attempts = fetched ?? this.#handlerAttempts(options, ids);
1848
- const { table } = await this.getQueueCache(name);
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]);
1851
- const response = this.mapCommandResponse(ids, result);
1852
- this.#trackHandlerSettle(options, response);
1853
- return response;
1956
+ return this.#telemetry.settle('delete', name, ids, async () => {
1957
+ const { table } = await this.getQueueCache(name);
1958
+ const sql = plans.deleteJobsById(this.config.schema, table, !!attempts);
1959
+ const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
1960
+ const response = this.mapCommandResponse(ids, result);
1961
+ this.#trackHandlerSettle(options, response);
1962
+ return response;
1963
+ });
1854
1964
  }
1855
1965
  // The filter half of redrive and previewRedrive, validated once and in the parameter order
1856
1966
  // plans.redriveWhere expects ($2 through $6).
@@ -1941,12 +2051,14 @@ class Manager extends EventEmitter {
1941
2051
  const db = this.assertDb(options);
1942
2052
  const { ids, attempts: fetched } = this.mapAttemptArg(id, 'cancel');
1943
2053
  const attempts = fetched ?? this.#handlerAttempts(options, ids);
1944
- const { table } = await this.getQueueCache(name);
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]);
1947
- const response = this.mapCommandResponse(ids, result);
1948
- this.#trackHandlerSettle(options, response);
1949
- return response;
2054
+ return this.#telemetry.settle('cancel', name, ids, async () => {
2055
+ const { table } = await this.getQueueCache(name);
2056
+ const sql = plans.cancelJobs(this.config.schema, table, !!attempts);
2057
+ const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
2058
+ const response = this.mapCommandResponse(ids, result);
2059
+ this.#trackHandlerSettle(options, response);
2060
+ return response;
2061
+ });
1950
2062
  }
1951
2063
  async resume(name, id, options = {}) {
1952
2064
  Attorney.assertQueueName(name);
@@ -1998,7 +2110,7 @@ class Manager extends EventEmitter {
1998
2110
  }
1999
2111
  const sql = plans.createQueue(this.config.schema, name, { ...options, policy }, this.config.noAdvisoryLocks);
2000
2112
  await this.db.executeSql(sql);
2001
- this.#evictQueueCache(name);
2113
+ await this.#reloadQueueCache(name);
2002
2114
  }
2003
2115
  async getBlockedKeys(name) {
2004
2116
  Attorney.assertQueueName(name);
@@ -2028,6 +2140,11 @@ class Manager extends EventEmitter {
2028
2140
  }
2029
2141
  }
2030
2142
  }
2143
+ // Every backend: the histograms' empty slots are stored as null and handed out as 0.
2144
+ for (const row of rows) {
2145
+ row.waitBins = toBins(row.waitBins);
2146
+ row.runBins = toBins(row.runBins);
2147
+ }
2031
2148
  return rows;
2032
2149
  }
2033
2150
  async updateQueue(name, options = {}) {
@@ -2049,7 +2166,7 @@ class Manager extends EventEmitter {
2049
2166
  }
2050
2167
  const sql = plans.updateQueue(this.config.schema);
2051
2168
  await this.db.executeSql(sql, [name, options]);
2052
- this.#evictQueueCache(name);
2169
+ await this.#reloadQueueCache(name);
2053
2170
  }
2054
2171
  async getQueue(name) {
2055
2172
  const rows = await this.getQueues([name]);
@@ -2082,10 +2199,14 @@ class Manager extends EventEmitter {
2082
2199
  const sql = plans.deleteStoredJobs(this.config.schema, table);
2083
2200
  await this.db.executeSql(sql, [name]);
2084
2201
  }
2202
+ // A truncate leaves nothing to count, so it zeroes the cached counts after itself. A monitor pass
2203
+ // that read the table first holds it until done, so the truncate and then the zeroes land after
2204
+ // that pass's write.
2085
2205
  async deleteAllJobs(name) {
2086
2206
  if (!name) {
2087
2207
  const sql = plans.truncateTable(this.config.schema, plans.BASE_JOB_TABLE);
2088
2208
  await this.db.executeSql(sql);
2209
+ await this.db.executeSql(plans.zeroQueueStats(this.config.schema));
2089
2210
  return;
2090
2211
  }
2091
2212
  Attorney.assertQueueName(name);
@@ -2093,6 +2214,7 @@ class Manager extends EventEmitter {
2093
2214
  if (partition) {
2094
2215
  const sql = plans.truncateTable(this.config.schema, table);
2095
2216
  await this.db.executeSql(sql);
2217
+ await this.db.executeSql(plans.zeroQueueStats(this.config.schema, true), [name]);
2096
2218
  }
2097
2219
  else {
2098
2220
  const sql = plans.deleteAllJobs(this.config.schema, table);
@@ -2110,6 +2232,10 @@ class Manager extends EventEmitter {
2110
2232
  // getQueue(name).
2111
2233
  async getQueueStats(name, options = {}) {
2112
2234
  Attorney.assertQueueName(name);
2235
+ assert(options.percentiles === undefined || (Array.isArray(options.percentiles) && options.percentiles.length > 0 &&
2236
+ 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]');
2237
+ // Each value once, in the order first asked for.
2238
+ const percentiles = options.percentiles && [...new Set(options.percentiles)];
2113
2239
  const isCockroach = this.config.backend === 'cockroachdb';
2114
2240
  // `counted` is true for recorded snapshots. The cache path serves only gauges: the queue table's
2115
2241
  // counters describe the last pass that counted, not this reading.
@@ -2130,6 +2256,9 @@ class Manager extends EventEmitter {
2130
2256
  createdDelta: null,
2131
2257
  deltaSeconds: null,
2132
2258
  deltaOn: null,
2259
+ waitBins: null,
2260
+ runBins: null,
2261
+ readyOldestSeconds: null,
2133
2262
  capturedOn: row?.capturedOn ?? new Date(this.config.clock.now())
2134
2263
  };
2135
2264
  for (const field of counted ? [...STATS_COUNT_FIELDS, ...STATS_DELTA_FIELDS] : STATS_COUNT_FIELDS) {
@@ -2141,6 +2270,20 @@ class Manager extends EventEmitter {
2141
2270
  // The end of the interval the counters cover, handed on as the row holds it, like capturedOn.
2142
2271
  if (counted && row?.deltaOn != null)
2143
2272
  snapshot.deltaOn = row.deltaOn;
2273
+ if (counted) {
2274
+ snapshot.waitBins = toBins(row?.waitBins);
2275
+ snapshot.runBins = toBins(row?.runBins);
2276
+ if (row?.readyOldestSeconds != null)
2277
+ snapshot.readyOldestSeconds = Number(row.readyOldestSeconds);
2278
+ }
2279
+ // Read from this snapshot's (or bucket's) own histograms, so they are null wherever those are.
2280
+ if (percentiles) {
2281
+ snapshot.percentiles = percentiles.map(p => ({
2282
+ p,
2283
+ waitSeconds: percentile(snapshot.waitBins, p),
2284
+ runSeconds: percentile(snapshot.runBins, p)
2285
+ }));
2286
+ }
2144
2287
  return snapshot;
2145
2288
  };
2146
2289
  if (this.config.persistQueueStats) {