pg-boss 12.35.1 → 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 = [
@@ -187,6 +197,9 @@ class Manager extends EventEmitter {
187
197
  #localGroupActive;
188
198
  #localGroupConfig;
189
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;
190
203
  constructor(db, config) {
191
204
  super();
192
205
  this.config = config;
@@ -205,6 +218,8 @@ class Manager extends EventEmitter {
205
218
  this.#localGroupActive = new Map();
206
219
  this.#localGroupConfig = new Map();
207
220
  this.#localGroupMaxLimit = new Map();
221
+ this.#telemetry = new Telemetry(config.openTelemetry, config.schema, () => this.queues);
222
+ this.#traceContexts = new WeakMap();
208
223
  }
209
224
  getSpy(name) {
210
225
  if (!this.config.__test__enableSpies) {
@@ -352,6 +367,7 @@ class Manager extends EventEmitter {
352
367
  // (each output carried per-id via a JSON recordset), so batch size never drives the statement
353
368
  // count. Any batch job the handler omits (or returns with an invalid shape) is failed with a
354
369
  // descriptive error so it retries / dead-letters per queue config.
370
+ // Resolves with the error the whole batch was failed with, or undefined.
355
371
  async #settlePerJob(name, jobs, result) {
356
372
  if (!Array.isArray(result)) {
357
373
  // The handler opted into perJobResults but did not return an array: a contract violation.
@@ -359,7 +375,7 @@ class Manager extends EventEmitter {
359
375
  const err = new Error('perJobResults handler must resolve with an array of job results');
360
376
  await this.fail(name, jobs, err);
361
377
  await this.#trackJobsFailed(name, jobs, err);
362
- return;
378
+ return err;
363
379
  }
364
380
  // Index the handler's dispositions by job id, keeping only valid entries that reference a job
365
381
  // from this batch. Last write wins on duplicate ids.
@@ -391,9 +407,16 @@ class Manager extends EventEmitter {
391
407
  }
392
408
  }
393
409
  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;
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;
397
420
  // Only the jobs each statement actually settled: the attempt fence leaves a job whose claim
398
421
  // lapsed alone, and recording it would tell a spy it settled when another attempt holds it.
399
422
  const landed = (entries, result) => {
@@ -509,6 +532,8 @@ class Manager extends EventEmitter {
509
532
  * same as any other worker's. Because the claim is outside the transaction, the jobs stay
510
533
  * visibly `active` throughout, which is what keeps heartbeats, `expireInSeconds`, and another
511
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.
512
537
  */
513
538
  async #processJobs(name, jobs, callback, worker, heartbeatRefreshSeconds, perJobResults = false, transactional = false, transactionTimeoutSeconds) {
514
539
  const jobIds = jobs.map(job => job.id);
@@ -569,6 +594,9 @@ class Manager extends EventEmitter {
569
594
  let completedAffected = 0;
570
595
  let failedError;
571
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;
572
600
  // Only for a transactional worker, and only from the begin below until it settles. rollback()
573
601
  // is idempotent, so the catch can settle it without tracking whether the commit got there
574
602
  // first.
@@ -619,7 +647,7 @@ class Manager extends EventEmitter {
619
647
  // #settlePerJob settles each job individually and does its own (synchronous,
620
648
  // lookup-free) spy tracking via #trackJobsSettled, so the deferred tracker below
621
649
  // is skipped for this path.
622
- await this.#settlePerJob(name, jobs, result);
650
+ perJobError = await this.#settlePerJob(name, jobs, result);
623
651
  }
624
652
  else {
625
653
  // Read out before the completion below, which goes through the same complete() and would
@@ -676,6 +704,7 @@ class Manager extends EventEmitter {
676
704
  await this.#trackJobsCompleted(name, jobs, completedResult, completedAffected);
677
705
  }
678
706
  }
707
+ return didFail ? (failedError ?? new Error('handler rejected without a reason')) : perJobError;
679
708
  }
680
709
  /**
681
710
  * Gives the handler's transaction a deadline the database enforces.
@@ -822,6 +851,10 @@ class Manager extends EventEmitter {
822
851
  }
823
852
  async start() {
824
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
+ }
825
858
  this.queueCacheInterval = this.config.clock.setInterval(() => this.onCacheQueues({ emit: true }), this.config.queueCacheIntervalSeconds * 1000);
826
859
  this.wipInterval = this.config.clock.setInterval(() => {
827
860
  const now = this.config.clock.now();
@@ -835,12 +868,14 @@ class Manager extends EventEmitter {
835
868
  }
836
869
  }, 2000);
837
870
  await this.onCacheQueues();
871
+ this.#telemetry.observeQueues();
838
872
  }
839
873
  async onCacheQueues({ emit = false } = {}) {
840
874
  try {
841
875
  assert(!this.config.__test__throw_queueCache, 'test error');
842
876
  const queues = await this.getQueues();
843
877
  this.queues = queues.reduce((acc, i) => { acc[i.name] = i; return acc; }, {});
878
+ this.#telemetry.refreshInstruments();
844
879
  }
845
880
  catch (error) {
846
881
  emit && this.emit(events.error, { ...error, message: error.message, stack: error.stack });
@@ -863,10 +898,24 @@ class Manager extends EventEmitter {
863
898
  if (this.queues)
864
899
  delete this.queues[name];
865
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
+ }
866
914
  async stop() {
867
915
  this.stopped = true;
868
916
  this.config.clock.clearInterval(this.queueCacheInterval);
869
917
  this.config.clock.clearInterval(this.wipInterval);
918
+ this.#telemetry.unobserveQueues();
870
919
  // offWork stops every worker on a queue, so iterate queue names rather than workers - otherwise
871
920
  // a localConcurrency of N re-stops all N workers N times and registers N pending cleanups.
872
921
  const names = new Set([...this.workers.values()]
@@ -952,7 +1001,12 @@ class Manager extends EventEmitter {
952
1001
  const ignoreGroups = localGroupConcurrency != null
953
1002
  ? this.#getGroupsAtLocalCapacity(name)
954
1003
  : undefined;
955
- 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));
956
1010
  };
957
1011
  const onFetch = async (jobs) => {
958
1012
  if (!jobs.length)
@@ -965,7 +1019,7 @@ class Manager extends EventEmitter {
965
1019
  const worker = this.workers.get(workerId);
966
1020
  // Skip all in-memory group tracking when localGroupConcurrency is not enabled
967
1021
  if (localGroupConcurrency == null) {
968
- await this.#processJobs(name, jobs, callback, worker, heartbeatRefreshSeconds, perJobResults, transactional, transactionTimeoutSeconds);
1022
+ await processBatch(jobs, worker);
969
1023
  }
970
1024
  else {
971
1025
  const { allowed, excess, groupedJobs } = this.#trackLocalGroupStart(name, jobs);
@@ -980,7 +1034,7 @@ class Manager extends EventEmitter {
980
1034
  worker.jobs = allowed;
981
1035
  }
982
1036
  if (allowed.length > 0) {
983
- await this.#processJobs(name, allowed, callback, worker, heartbeatRefreshSeconds, perJobResults, transactional, transactionTimeoutSeconds);
1037
+ await processBatch(allowed, worker);
984
1038
  }
985
1039
  }
986
1040
  finally {
@@ -1164,6 +1218,10 @@ class Manager extends EventEmitter {
1164
1218
  }
1165
1219
  async publish(event, data, options) {
1166
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) {
1167
1225
  const sql = plans.getQueuesForEvent(this.config.schema);
1168
1226
  const { rows } = await this.db.executeSql(sql, [event]);
1169
1227
  const results = await Promise.allSettled(rows.map(({ name }) => this.send(name, data, options)));
@@ -1230,9 +1288,12 @@ class Manager extends EventEmitter {
1230
1288
  };
1231
1289
  }
1232
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) {
1233
1294
  const { name, data = null, options = {} } = request;
1234
1295
  const { db: wrapper, singletonSeconds, singletonNextSlot } = options;
1235
- const job = this.#toJobPayload(name, data, options);
1296
+ const job = { ...this.#toJobPayload(name, data, options), __traceContext: traceContext };
1236
1297
  const db = wrapper || this.db;
1237
1298
  const { table, policy, notify } = await this.getQueueCache(name);
1238
1299
  if (policy === plans.QUEUE_POLICIES.key_strict_fifo && !job.singletonKey) {
@@ -1309,9 +1370,14 @@ class Manager extends EventEmitter {
1309
1370
  }
1310
1371
  async upsert(...args) {
1311
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) {
1312
1379
  const { name, data } = request;
1313
1380
  const opts = (request.options ?? {});
1314
- Attorney.assertQueueName(name);
1315
1381
  const db = this.assertDb(opts);
1316
1382
  const { table, policy, notify } = await this.getQueueCache(name);
1317
1383
  const by = opts.id ? 'id' : 'singletonKey';
@@ -1326,7 +1392,7 @@ class Manager extends EventEmitter {
1326
1392
  const insertSql = plans.insertJobs(this.config.schema, { table, name, returnId: true, notify: notifyEnabled });
1327
1393
  const job = this.#toUpdatePayload(data, opts);
1328
1394
  const updatePayload = JSON.stringify(job);
1329
- const insertPayload = JSON.stringify([job]);
1395
+ const insertPayload = JSON.stringify([{ ...job, __traceContext: traceContext }]);
1330
1396
  const result = await this.ensureTransaction(db, async (tx) => {
1331
1397
  const { rows: updated } = await tx.executeSql(updateSql, [updatePayload]);
1332
1398
  if (updated.length) {
@@ -1363,6 +1429,9 @@ class Manager extends EventEmitter {
1363
1429
  // statement nor kept on the objects below, so this stays the raw path it has always been.
1364
1430
  options = {}) {
1365
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) {
1366
1435
  const slots = options.__singletonSlots === true;
1367
1436
  const seenIds = new Set();
1368
1437
  for (const job of jobs) {
@@ -1394,6 +1463,8 @@ class Manager extends EventEmitter {
1394
1463
  const dataById = spy ? new Map() : undefined;
1395
1464
  const insertPayload = jobs.map(j => {
1396
1465
  const { blocked, blocking, pendingDependencies, group, __singletonSlot, ...rest } = j;
1466
+ // Overwrites any __traceContext the caller passed.
1467
+ Object.assign(rest, { __traceContext: traceContext });
1397
1468
  // Reattached only for the caller that asked for the column, so a public insert() drops the
1398
1469
  // field rather than handing an unvalidated value to a timestamp cast.
1399
1470
  if (slots && __singletonSlot !== undefined) {
@@ -1441,6 +1512,11 @@ class Manager extends EventEmitter {
1441
1512
  }
1442
1513
  async flow(jobs, options = {}) {
1443
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) {
1444
1520
  // validate and normalize each job's options the same way send()/insert() do
1445
1521
  const flowJobs = jobs.map(job => ({
1446
1522
  ...job,
@@ -1503,7 +1579,8 @@ class Manager extends EventEmitter {
1503
1579
  deadLetter: j.options?.deadLetter ?? undefined,
1504
1580
  blocked: dependencyCount > 0 || undefined,
1505
1581
  blocking: parentRefs.has(j.ref) || undefined,
1506
- pendingDependencies: dependencyCount || undefined
1582
+ pendingDependencies: dependencyCount || undefined,
1583
+ __traceContext: traceContext
1507
1584
  };
1508
1585
  });
1509
1586
  statements.push(plans.insertFlowJobs(this.config.schema, { table, name: queueName }, insertPayload));
@@ -1559,6 +1636,9 @@ class Manager extends EventEmitter {
1559
1636
  }
1560
1637
  async fetch(name, options = {}) {
1561
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) {
1562
1642
  this.#warnDeprecatedFetchOptions(options);
1563
1643
  const db = this.assertDb(options);
1564
1644
  const { table, policy, singletonsActive } = await this.getQueueCache(name);
@@ -1569,7 +1649,8 @@ class Manager extends EventEmitter {
1569
1649
  name,
1570
1650
  policy,
1571
1651
  limit: options.batchSize || 1,
1572
- ignoreSingletons: singletonsActive
1652
+ ignoreSingletons: singletonsActive,
1653
+ includeTraceContext: this.#telemetry.enabled
1573
1654
  };
1574
1655
  const query = plans.fetchNextJob(fetchOptions, this.config.noSkipLocked);
1575
1656
  let result;
@@ -1586,6 +1667,13 @@ class Manager extends EventEmitter {
1586
1667
  throw err;
1587
1668
  }
1588
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
+ }
1589
1677
  // Even a minimal fetch (JOB_COLUMNS_MIN) returns numeric fields like expireInSeconds and
1590
1678
  // heartbeatSeconds, so normalize regardless of includeMetadata.
1591
1679
  return this.#numericJobFields(rows);
@@ -1638,21 +1726,23 @@ class Manager extends EventEmitter {
1638
1726
  const db = this.assertDb(options);
1639
1727
  const { ids, attempts: fetched } = this.mapAttemptArg(id, 'complete');
1640
1728
  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;
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
+ });
1656
1746
  }
1657
1747
  // Distributed complete/fail need several statements run atomically. When we own the pooled
1658
1748
  // connection we pin a single client via withTransaction(); when the caller supplied their own
@@ -1677,22 +1767,24 @@ class Manager extends EventEmitter {
1677
1767
  const db = this.assertDb(options);
1678
1768
  const { ids, attempts: fetched } = this.mapAttemptArg(id, 'fail');
1679
1769
  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;
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
+ });
1696
1788
  }
1697
1789
  async failDistributed(name, ids, outputData, table, db, attempts) {
1698
1790
  // CockroachDB doesn't support multi-mutation CTEs, but does support transactions, so the
@@ -1800,6 +1892,7 @@ class Manager extends EventEmitter {
1800
1892
  const data = toJsonText(job.data);
1801
1893
  const output = toJsonText(jobOutput);
1802
1894
  const sourceOutput = toJsonText(job.source_output);
1895
+ const traceContext = toJsonText(job.trace_context);
1803
1896
  // forceTerminal (perJobResults `deadletter`) skips retries so the job fails terminally and
1804
1897
  // routes straight to the dead letter queue below.
1805
1898
  const canRetry = !forceTerminal && retryCount < retryLimit;
@@ -1828,7 +1921,7 @@ class Manager extends EventEmitter {
1828
1921
  job.deletion_seconds, createdOn, null, keepUntil, job.policy,
1829
1922
  output, job.dead_letter,
1830
1923
  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
1924
+ job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, sourceOutput, job.source_root_id, traceContext
1832
1925
  ]);
1833
1926
  // The retry insert can be dropped by ON CONFLICT when the queue policy (e.g. stately,
1834
1927
  // singleton, key_strict_fifo) already has a non-terminal job. Mirror the failed_jobs
@@ -1843,11 +1936,11 @@ class Manager extends EventEmitter {
1843
1936
  job.deletion_seconds, createdOn, new Date(this.config.clock.now()), keepUntil, job.policy,
1844
1937
  output, job.dead_letter,
1845
1938
  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
1939
+ job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, sourceOutput, job.source_root_id, traceContext
1847
1940
  ]);
1848
1941
  // Insert to dead letter queue if failed and has dead_letter configured
1849
1942
  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]);
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]);
1851
1944
  }
1852
1945
  }
1853
1946
  count++;
@@ -1860,12 +1953,14 @@ class Manager extends EventEmitter {
1860
1953
  const db = this.assertDb(options);
1861
1954
  const { ids, attempts: fetched } = this.mapAttemptArg(id, 'deleteJob');
1862
1955
  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;
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
+ });
1869
1964
  }
1870
1965
  // The filter half of redrive and previewRedrive, validated once and in the parameter order
1871
1966
  // plans.redriveWhere expects ($2 through $6).
@@ -1956,12 +2051,14 @@ class Manager extends EventEmitter {
1956
2051
  const db = this.assertDb(options);
1957
2052
  const { ids, attempts: fetched } = this.mapAttemptArg(id, 'cancel');
1958
2053
  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;
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
+ });
1965
2062
  }
1966
2063
  async resume(name, id, options = {}) {
1967
2064
  Attorney.assertQueueName(name);
@@ -2013,7 +2110,7 @@ class Manager extends EventEmitter {
2013
2110
  }
2014
2111
  const sql = plans.createQueue(this.config.schema, name, { ...options, policy }, this.config.noAdvisoryLocks);
2015
2112
  await this.db.executeSql(sql);
2016
- this.#evictQueueCache(name);
2113
+ await this.#reloadQueueCache(name);
2017
2114
  }
2018
2115
  async getBlockedKeys(name) {
2019
2116
  Attorney.assertQueueName(name);
@@ -2043,6 +2140,11 @@ class Manager extends EventEmitter {
2043
2140
  }
2044
2141
  }
2045
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
+ }
2046
2148
  return rows;
2047
2149
  }
2048
2150
  async updateQueue(name, options = {}) {
@@ -2064,7 +2166,7 @@ class Manager extends EventEmitter {
2064
2166
  }
2065
2167
  const sql = plans.updateQueue(this.config.schema);
2066
2168
  await this.db.executeSql(sql, [name, options]);
2067
- this.#evictQueueCache(name);
2169
+ await this.#reloadQueueCache(name);
2068
2170
  }
2069
2171
  async getQueue(name) {
2070
2172
  const rows = await this.getQueues([name]);
@@ -2130,6 +2232,10 @@ class Manager extends EventEmitter {
2130
2232
  // getQueue(name).
2131
2233
  async getQueueStats(name, options = {}) {
2132
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)];
2133
2239
  const isCockroach = this.config.backend === 'cockroachdb';
2134
2240
  // `counted` is true for recorded snapshots. The cache path serves only gauges: the queue table's
2135
2241
  // counters describe the last pass that counted, not this reading.
@@ -2150,6 +2256,9 @@ class Manager extends EventEmitter {
2150
2256
  createdDelta: null,
2151
2257
  deltaSeconds: null,
2152
2258
  deltaOn: null,
2259
+ waitBins: null,
2260
+ runBins: null,
2261
+ readyOldestSeconds: null,
2153
2262
  capturedOn: row?.capturedOn ?? new Date(this.config.clock.now())
2154
2263
  };
2155
2264
  for (const field of counted ? [...STATS_COUNT_FIELDS, ...STATS_DELTA_FIELDS] : STATS_COUNT_FIELDS) {
@@ -2161,6 +2270,20 @@ class Manager extends EventEmitter {
2161
2270
  // The end of the interval the counters cover, handed on as the row holds it, like capturedOn.
2162
2271
  if (counted && row?.deltaOn != null)
2163
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
+ }
2164
2287
  return snapshot;
2165
2288
  };
2166
2289
  if (this.config.persistQueueStats) {
@@ -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;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"}
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,CA+sB3H;AAED,OAAO,EACL,QAAQ,EACR,IAAI,EACJ,OAAO,EACP,eAAe,EACf,MAAM,EACN,eAAe,EACf,aAAa,GACd,CAAA"}
@@ -1963,6 +1963,73 @@ AS $function$
1963
1963
  DROP COLUMN source_output,
1964
1964
  DROP COLUMN source_root_id`
1965
1965
  ]
1966
+ },
1967
+ {
1968
+ release: '12.36.0',
1969
+ version: 44,
1970
+ previous: 43,
1971
+ // The instance table starts empty: each instance registers on its next start().
1972
+ // blocked_count's constant default adds it without a table rewrite, and existing queues read 0
1973
+ // until the next monitor pass counts them. It goes on the queue row only, not queue_stats, so
1974
+ // the covering index on queue_stats needs no rebuild.
1975
+ // The histogram and ready_oldest_seconds columns are nullable with no default, like the v43
1976
+ // deltas, so no statement rewrites a table, and a snapshot captured before them reads as not
1977
+ // counted rather than as a queue with no waits.
1978
+ // trace_context is nullable with no default too, so adding it rewrites no rows.
1979
+ install: [
1980
+ /* eslint-disable no-restricted-syntax -- column defaults stay on the real clock: every pg-boss write names its timestamps through job_now() */
1981
+ `CREATE TABLE ${schema}.instance (
1982
+ id uuid PRIMARY KEY,
1983
+ name text,
1984
+ host text NOT NULL,
1985
+ pid int NOT NULL,
1986
+ version text NOT NULL,
1987
+ node_version text NOT NULL,
1988
+ application_name text,
1989
+ heartbeat_seconds int NOT NULL,
1990
+ supervise bool NOT NULL,
1991
+ schedule bool NOT NULL,
1992
+ migrate bool NOT NULL,
1993
+ persist_queue_stats bool NOT NULL,
1994
+ persist_warnings bool NOT NULL,
1995
+ pool_max int,
1996
+ pool_total int,
1997
+ pool_idle int,
1998
+ pool_waiting int,
1999
+ workers jsonb NOT NULL DEFAULT '[]'::jsonb,
2000
+ metrics jsonb,
2001
+ config jsonb NOT NULL DEFAULT '{}'::jsonb,
2002
+ crash_restarts int NOT NULL DEFAULT 0,
2003
+ crash_restarts_since timestamptz,
2004
+ started_on timestamptz NOT NULL DEFAULT now(),
2005
+ heartbeat_on timestamptz NOT NULL DEFAULT now(),
2006
+ stopped_on timestamptz
2007
+ )`,
2008
+ /* eslint-enable no-restricted-syntax */
2009
+ `ALTER TABLE ${schema}.queue
2010
+ ADD COLUMN blocked_count int NOT NULL DEFAULT 0,
2011
+ ADD COLUMN wait_bins int[],
2012
+ ADD COLUMN run_bins int[],
2013
+ ADD COLUMN ready_oldest_seconds int`,
2014
+ `ALTER TABLE ${schema}.queue_stats
2015
+ ADD COLUMN wait_bins int[],
2016
+ ADD COLUMN run_bins int[],
2017
+ ADD COLUMN ready_oldest_seconds int`,
2018
+ `ALTER TABLE ${schema}.job ADD COLUMN IF NOT EXISTS trace_context jsonb`
2019
+ ],
2020
+ uninstall: [
2021
+ `DROP TABLE ${schema}.instance`,
2022
+ `ALTER TABLE ${schema}.queue
2023
+ DROP COLUMN blocked_count,
2024
+ DROP COLUMN wait_bins,
2025
+ DROP COLUMN run_bins,
2026
+ DROP COLUMN ready_oldest_seconds`,
2027
+ `ALTER TABLE ${schema}.queue_stats
2028
+ DROP COLUMN wait_bins,
2029
+ DROP COLUMN run_bins,
2030
+ DROP COLUMN ready_oldest_seconds`,
2031
+ `ALTER TABLE ${schema}.job DROP COLUMN trace_context`
2032
+ ]
1966
2033
  }
1967
2034
  ];
1968
2035
  }