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/README.md +1 -0
- package/dist/attorney.d.ts.map +1 -1
- package/dist/attorney.js +21 -0
- package/dist/boss.d.ts.map +1 -1
- package/dist/boss.js +35 -4
- package/dist/db.d.ts +1 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +11 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -5
- 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 +2 -1
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +216 -73
- package/dist/migrationStore.d.ts.map +1 -1
- package/dist/migrationStore.js +67 -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 +20 -0
- package/dist/plans.d.ts.map +1 -1
- package/dist/plans.js +500 -50
- package/dist/registrar.d.ts +16 -0
- package/dist/registrar.d.ts.map +1 -0
- package/dist/registrar.js +221 -0
- package/dist/schema.json +480 -1
- package/dist/telemetry.d.ts +65 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +349 -0
- package/dist/types.d.ts +201 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +17 -8
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
|
|
391
|
-
const
|
|
392
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1080
|
-
|
|
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.
|
|
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
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
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
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
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
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
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.#
|
|
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.#
|
|
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) {
|