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/README.md +2 -1
- package/dist/attorney.d.ts.map +1 -1
- package/dist/attorney.js +26 -0
- package/dist/boss.d.ts.map +1 -1
- package/dist/boss.js +35 -4
- package/dist/cli.js +8 -2
- package/dist/contractor.d.ts +1 -0
- package/dist/contractor.d.ts.map +1 -1
- package/dist/contractor.js +4 -0
- package/dist/db.d.ts +1 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +14 -3
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +88 -9
- package/dist/latency.d.ts +15 -0
- package/dist/latency.d.ts.map +1 -0
- package/dist/latency.js +45 -0
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +296 -75
- package/dist/migrationStore.d.ts.map +1 -1
- package/dist/migrationStore.js +194 -0
- package/dist/nurse.d.ts +20 -0
- package/dist/nurse.d.ts.map +1 -0
- package/dist/nurse.js +287 -0
- package/dist/plans.d.ts +27 -2
- package/dist/plans.d.ts.map +1 -1
- package/dist/plans.js +556 -55
- package/dist/registrar.d.ts +16 -0
- package/dist/registrar.d.ts.map +1 -0
- package/dist/registrar.js +226 -0
- package/dist/schema.json +514 -2
- package/dist/telemetry.d.ts +65 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +349 -0
- package/dist/timekeeper.d.ts +4 -1
- package/dist/timekeeper.d.ts.map +1 -1
- package/dist/timekeeper.js +22 -5
- package/dist/types.d.ts +213 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/worker.d.ts +1 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +2 -0
- package/package.json +14 -5
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
|
|
395
|
-
const
|
|
396
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
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
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
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
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
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
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
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
|
|
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
|
}
|