pg-boss 12.34.0 → 12.35.1
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 -2
- package/dist/activity.d.ts +15 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +95 -0
- package/dist/attorney.d.ts.map +1 -1
- package/dist/attorney.js +1 -0
- package/dist/boss.d.ts.map +1 -1
- package/dist/boss.js +50 -13
- package/dist/claimTimer.d.ts.map +1 -1
- package/dist/claimTimer.js +19 -1
- package/dist/clock.d.ts +10 -7
- package/dist/clock.d.ts.map +1 -1
- package/dist/clock.js +67 -38
- package/dist/index.d.ts +7 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -11
- package/dist/manager.d.ts +8 -6
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +217 -78
- package/dist/migrationStore.d.ts.map +1 -1
- package/dist/migrationStore.js +166 -3
- package/dist/navigator.d.ts.map +1 -1
- package/dist/navigator.js +4 -0
- package/dist/plans.d.ts +23 -13
- package/dist/plans.d.ts.map +1 -1
- package/dist/plans.js +815 -208
- package/dist/schema.json +196 -2
- package/dist/timekeeper.d.ts +2 -1
- package/dist/timekeeper.d.ts.map +1 -1
- package/dist/timekeeper.js +17 -8
- package/dist/types.d.ts +89 -88
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -7
package/dist/manager.js
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import EventEmitter from 'node:events';
|
|
4
4
|
import { serializeError as stringify } from 'serialize-error';
|
|
5
5
|
import * as Attorney from "./attorney.js";
|
|
6
|
+
import { untracked } from "./activity.js";
|
|
6
7
|
import { TRANSACTION_ROLLBACK_TIMEOUT_MS } from "./db.js";
|
|
7
8
|
import * as plans from "./plans.js";
|
|
8
9
|
import * as timekeeper from "./timekeeper.js";
|
|
@@ -89,10 +90,16 @@ const NUMERIC_QUEUE_FIELDS = [
|
|
|
89
90
|
'deferredCount',
|
|
90
91
|
'warningQueueSize',
|
|
91
92
|
'queuedCount',
|
|
93
|
+
'readyCount',
|
|
92
94
|
'activeCount',
|
|
93
|
-
'
|
|
95
|
+
'failedCount',
|
|
96
|
+
'totalCount',
|
|
97
|
+
'createdDelta',
|
|
98
|
+
'completedDelta',
|
|
99
|
+
'failedDelta',
|
|
100
|
+
'deltaSeconds'
|
|
94
101
|
];
|
|
95
|
-
// The
|
|
102
|
+
// The gauges shared by live stats and recorded snapshots (the QueueStats shape).
|
|
96
103
|
const STATS_COUNT_FIELDS = [
|
|
97
104
|
'deferredCount',
|
|
98
105
|
'queuedCount',
|
|
@@ -101,6 +108,14 @@ const STATS_COUNT_FIELDS = [
|
|
|
101
108
|
'failedCount',
|
|
102
109
|
'totalCount'
|
|
103
110
|
];
|
|
111
|
+
// The throughput counters and the seconds they cover. Only recorded snapshots carry them; see
|
|
112
|
+
// getQueueStats.
|
|
113
|
+
const STATS_DELTA_FIELDS = [
|
|
114
|
+
'completedDelta',
|
|
115
|
+
'failedDelta',
|
|
116
|
+
'createdDelta',
|
|
117
|
+
'deltaSeconds'
|
|
118
|
+
];
|
|
104
119
|
// Stale-cache budget for getQueueStats when persistQueueStats is off. A queue-table cache older than
|
|
105
120
|
// this means monitoring isn't keeping it current (e.g. supervise was enabled once but isn't now), so
|
|
106
121
|
// the counts are recomputed and re-cached instead of returned. Defaults to one hour, raised to the
|
|
@@ -132,6 +147,10 @@ function rethrowWriteError(err) {
|
|
|
132
147
|
}
|
|
133
148
|
throw err;
|
|
134
149
|
}
|
|
150
|
+
// For a json value read back from a row and bound again behind `::text::jsonb`. Null stays SQL NULL.
|
|
151
|
+
function toJsonText(value) {
|
|
152
|
+
return value == null ? null : JSON.stringify(value);
|
|
153
|
+
}
|
|
135
154
|
class Manager extends EventEmitter {
|
|
136
155
|
events = events;
|
|
137
156
|
// Warn once per option per instance, not once per fetch.
|
|
@@ -152,6 +171,9 @@ class Manager extends EventEmitter {
|
|
|
152
171
|
// that transaction's db. #processJobs reads it to interpret its own completion; see the check
|
|
153
172
|
// there. Weak because the key is the transaction, so an entry goes away with it.
|
|
154
173
|
#handlerSettledJobs;
|
|
174
|
+
// The retryCount each job of a transactional batch was claimed at, keyed by the transaction the
|
|
175
|
+
// handler was handed, so the handler's own settles through it can be fenced (#handlerAttempts).
|
|
176
|
+
#handlerClaims;
|
|
155
177
|
// Which GUC this server bounds a transaction with, resolved on the first transactional batch and
|
|
156
178
|
// kept for the life of the process. Resolving to null means neither is recognised, or the probe
|
|
157
179
|
// itself failed; a failed probe clears the memo and sets the instant before which no batch asks
|
|
@@ -175,6 +197,7 @@ class Manager extends EventEmitter {
|
|
|
175
197
|
this.pendingOffWorkCleanups = new Set();
|
|
176
198
|
this.#spies = new Map();
|
|
177
199
|
this.#handlerSettledJobs = new WeakMap();
|
|
200
|
+
this.#handlerClaims = new WeakMap();
|
|
178
201
|
this.#transactionTimeoutGuc = null;
|
|
179
202
|
this.#transactionTimeoutProbeRetryAt = 0;
|
|
180
203
|
this.#transactionTimeoutProbeCooldownMs = config.__test__transactionTimeoutProbeCooldownMs ?? TRANSACTION_TIMEOUT_PROBE_COOLDOWN_MS;
|
|
@@ -334,7 +357,7 @@ class Manager extends EventEmitter {
|
|
|
334
357
|
// The handler opted into perJobResults but did not return an array: a contract violation.
|
|
335
358
|
// Fail the whole batch so the mistake surfaces and the jobs are retried.
|
|
336
359
|
const err = new Error('perJobResults handler must resolve with an array of job results');
|
|
337
|
-
await this.fail(name, jobs
|
|
360
|
+
await this.fail(name, jobs, err);
|
|
338
361
|
await this.#trackJobsFailed(name, jobs, err);
|
|
339
362
|
return;
|
|
340
363
|
}
|
|
@@ -367,31 +390,33 @@ class Manager extends EventEmitter {
|
|
|
367
390
|
failed.push({ job, output: new Error('no disposition returned by handler') });
|
|
368
391
|
}
|
|
369
392
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
393
|
+
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;
|
|
397
|
+
// Only the jobs each statement actually settled: the attempt fence leaves a job whose claim
|
|
398
|
+
// lapsed alone, and recording it would tell a spy it settled when another attempt holds it.
|
|
399
|
+
const landed = (entries, result) => {
|
|
400
|
+
const settled = new Set(result?.settled ?? []);
|
|
401
|
+
return entries.filter(entry => settled.has(entry.job.id));
|
|
402
|
+
};
|
|
379
403
|
// Dead lettered jobs end in the same terminal `failed` state as failed jobs on the source queue.
|
|
380
|
-
this.#trackJobsSettled(name, completed, [...failed, ...deadLettered]);
|
|
404
|
+
this.#trackJobsSettled(name, landed(completed, completedIds), [...landed(failed, failedIds), ...landed(deadLettered, deadLetteredIds)]);
|
|
381
405
|
}
|
|
382
406
|
// Complete a set of active jobs, each with its own output, in a constant number of statements
|
|
383
407
|
// (one on Postgres, two on a distributed backend). Outputs are serialized like complete()/fail()
|
|
384
408
|
// and passed as a JSON recordset so the batch size doesn't drive the statement count.
|
|
409
|
+
// Each item carries the retryCount its job was fetched with, which the statement fences on.
|
|
385
410
|
async #completeWithOutputs(name, items) {
|
|
386
411
|
const { table } = await this.getQueueCache(name);
|
|
387
|
-
const payload = items.map(item => ({ id: item.id, output: this.mapCompletionDataArg(item.output) }));
|
|
412
|
+
const payload = items.map(item => ({ id: item.id, retry_count: item.retryCount, output: this.mapCompletionDataArg(item.output) }));
|
|
388
413
|
const ids = items.map(item => item.id);
|
|
389
414
|
if (this.config.noMultiMutationCte) {
|
|
390
415
|
// Dependency unblocking is handled out of band by the background resolver (Navigator), so
|
|
391
416
|
// completion is a single statement here too.
|
|
392
417
|
const sql = plans.completeJobsWithOutputsDistributed(this.config.schema, table);
|
|
393
418
|
const { rows } = await this.db.executeSql(sql, [name, JSON.stringify(payload)]);
|
|
394
|
-
return { jobs: ids, requested: ids.length, affected: rows.length };
|
|
419
|
+
return { jobs: ids, requested: ids.length, affected: rows.length, settled: rows.map(row => row.id) };
|
|
395
420
|
}
|
|
396
421
|
const sql = plans.completeJobsWithOutputs(this.config.schema, table);
|
|
397
422
|
const result = await this.db.executeSql(sql, [name, JSON.stringify(payload)]);
|
|
@@ -407,18 +432,19 @@ class Manager extends EventEmitter {
|
|
|
407
432
|
if (this.config.noMultiMutationCte) {
|
|
408
433
|
const outputById = new Map(items.map(item => [item.id, this.mapCompletionDataArg(item.output)]));
|
|
409
434
|
return this.ensureTransaction(this.db, async (tx) => {
|
|
410
|
-
const selectQuery = plans.selectJobsToFailById(this.config.schema, table);
|
|
411
|
-
const { rows: jobs } = await tx.executeSql(selectQuery.text, [name, ids]);
|
|
435
|
+
const selectQuery = plans.selectJobsToFailById(this.config.schema, table, true);
|
|
436
|
+
const { rows: jobs } = await tx.executeSql(selectQuery.text, [name, ids, plans.attemptPairs(ids, items.map(item => item.retryCount))]);
|
|
412
437
|
if (jobs.length === 0) {
|
|
413
|
-
return { jobs: ids, requested: ids.length, affected: 0 };
|
|
438
|
+
return { jobs: ids, requested: ids.length, affected: 0, settled: [] };
|
|
414
439
|
}
|
|
440
|
+
// Only the rows the select matched: see failDistributed.
|
|
415
441
|
const deleteQuery = plans.deleteJobsToFail(this.config.schema, table);
|
|
416
|
-
await tx.executeSql(deleteQuery.text, [name,
|
|
442
|
+
await tx.executeSql(deleteQuery.text, [name, jobs.map(job => job.id)]);
|
|
417
443
|
const count = await this.reinsertFailedJobs(tx, table, jobs, null, outputById, forceTerminal);
|
|
418
|
-
return { jobs: ids, requested: ids.length, affected: count };
|
|
444
|
+
return { jobs: ids, requested: ids.length, affected: count, settled: jobs.map(job => job.id) };
|
|
419
445
|
});
|
|
420
446
|
}
|
|
421
|
-
const payload = items.map(item => ({ id: item.id, output: this.mapCompletionDataArg(item.output) }));
|
|
447
|
+
const payload = items.map(item => ({ id: item.id, retry_count: item.retryCount, output: this.mapCompletionDataArg(item.output) }));
|
|
422
448
|
const sql = forceTerminal
|
|
423
449
|
? plans.deadLetterJobsByIdWithOutputs(this.config.schema, table)
|
|
424
450
|
: plans.failJobsByIdWithOutputs(this.config.schema, table);
|
|
@@ -494,7 +520,14 @@ class Manager extends EventEmitter {
|
|
|
494
520
|
const heartbeatCandidates = jobs.map(j => j.heartbeatSeconds || 0).filter(s => s > 0);
|
|
495
521
|
const heartbeatSeconds = heartbeatCandidates.length ? Math.min(...heartbeatCandidates) : 0;
|
|
496
522
|
const ac = new AbortController();
|
|
497
|
-
|
|
523
|
+
// Each job also gets a signal of its own, aborted when the heartbeat finds that job's claim gone
|
|
524
|
+
// (see the timer below), so a handler can stop work whose result can no longer land. Per job
|
|
525
|
+
// rather than per batch: a short heartbeat does not say why a job went missing, and the handler
|
|
526
|
+
// settling one of its own jobs early looks the same as another worker taking it. Aborting only
|
|
527
|
+
// the missing job makes that harmless, since a handler that settled a job no longer holds its
|
|
528
|
+
// claim either, and the rest of the batch runs on untouched.
|
|
529
|
+
const claims = new Map(jobs.map(job => [job.id, new AbortController()]));
|
|
530
|
+
jobs.forEach(job => { job.signal = AbortSignal.any([ac.signal, claims.get(job.id).signal]); });
|
|
498
531
|
// Store AbortController on worker so it can be aborted after graceful shutdown
|
|
499
532
|
if (worker) {
|
|
500
533
|
worker.abortController = ac;
|
|
@@ -505,11 +538,30 @@ class Manager extends EventEmitter {
|
|
|
505
538
|
const refreshSeconds = heartbeatRefreshSeconds ?? (heartbeatSeconds / 2);
|
|
506
539
|
const intervalMs = refreshSeconds * 1000;
|
|
507
540
|
heartbeatTimer = this.config.clock.setInterval(async () => {
|
|
541
|
+
const held = jobs.filter(job => !claims.get(job.id).signal.aborted);
|
|
542
|
+
if (held.length === 0)
|
|
543
|
+
return;
|
|
544
|
+
let touched;
|
|
508
545
|
try {
|
|
509
|
-
await this.touch(name,
|
|
546
|
+
touched = await this.touch(name, held);
|
|
510
547
|
}
|
|
511
548
|
catch (err) {
|
|
549
|
+
// A heartbeat that could not run says nothing about the claim, so nothing is aborted.
|
|
512
550
|
this.emit(events.error, err);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
// Without the ids it refreshed, a heartbeat cannot tell which job went missing, so it aborts
|
|
554
|
+
// nothing rather than everything.
|
|
555
|
+
if (!touched.settled)
|
|
556
|
+
return;
|
|
557
|
+
// touch() is fenced to the attempts this worker fetched, so a job it did not refresh is no
|
|
558
|
+
// longer active under this worker's claim: retried or settled elsewhere, or settled by the
|
|
559
|
+
// handler itself.
|
|
560
|
+
const refreshed = new Set(touched.settled);
|
|
561
|
+
for (const job of held) {
|
|
562
|
+
if (!refreshed.has(job.id)) {
|
|
563
|
+
claims.get(job.id).abort(new Error(`job ${job.id} is no longer active under this worker's claim`));
|
|
564
|
+
}
|
|
513
565
|
}
|
|
514
566
|
}, intervalMs);
|
|
515
567
|
}
|
|
@@ -537,13 +589,19 @@ class Manager extends EventEmitter {
|
|
|
537
589
|
// the bound lost for the whole cooldown after. Memoized after the first batch either way.
|
|
538
590
|
const timeoutGuc = timeoutMs > 0 ? await this.#resolveTransactionTimeoutGuc() : null;
|
|
539
591
|
transaction = await this.db.beginTransaction();
|
|
540
|
-
|
|
592
|
+
// The handler's view shares these, so a settle it runs on { db: tx } is still recognized and fenced.
|
|
593
|
+
const settled = new Set();
|
|
594
|
+
const claims = new Map(jobs.map(job => [job.id, job.retryCount]));
|
|
595
|
+
for (const db of [transaction.db, untracked(transaction.db)]) {
|
|
596
|
+
this.#handlerSettledJobs.set(db, settled);
|
|
597
|
+
this.#handlerClaims.set(db, claims);
|
|
598
|
+
}
|
|
541
599
|
if (timeoutGuc) {
|
|
542
600
|
await this.#applyTransactionTimeout(transaction, timeoutGuc, timeoutMs);
|
|
543
601
|
}
|
|
544
602
|
}
|
|
545
603
|
const handling = transaction
|
|
546
|
-
? callback(jobs, transaction.db)
|
|
604
|
+
? callback(jobs, untracked(transaction.db))
|
|
547
605
|
: callback(jobs);
|
|
548
606
|
const result = await resolveWithinSeconds(this.config.clock, handling, maxExpiration, `handler execution exceeded ${maxExpiration}s`, ac);
|
|
549
607
|
// An abort resolves the race rather than rejecting it, so on this line an abandoned handler
|
|
@@ -567,7 +625,7 @@ class Manager extends EventEmitter {
|
|
|
567
625
|
// Read out before the completion below, which goes through the same complete() and would
|
|
568
626
|
// otherwise record pg-boss's own settle as one the handler made.
|
|
569
627
|
const settledByHandler = transaction ? this.#takeHandlerSettles(transaction) : null;
|
|
570
|
-
const completion = await this.complete(name,
|
|
628
|
+
const completion = await this.complete(name, jobs, jobIds.length === 1 ? result : undefined, transaction ? { db: transaction.db } : undefined);
|
|
571
629
|
completedResult = result;
|
|
572
630
|
completedAffected = completion.affected;
|
|
573
631
|
if (settledByHandler) {
|
|
@@ -591,7 +649,7 @@ class Manager extends EventEmitter {
|
|
|
591
649
|
err.message = `${err.message} (the handler left its transaction aborted: let a SQL error raised through tx propagate out of the handler, or isolate the statement with a SAVEPOINT)`;
|
|
592
650
|
}
|
|
593
651
|
}
|
|
594
|
-
await this.fail(name,
|
|
652
|
+
await this.fail(name, jobs, err);
|
|
595
653
|
failedError = err;
|
|
596
654
|
didFail = true;
|
|
597
655
|
}
|
|
@@ -726,6 +784,7 @@ class Manager extends EventEmitter {
|
|
|
726
784
|
#takeHandlerSettles(transaction) {
|
|
727
785
|
const settled = this.#handlerSettledJobs.get(transaction.db) ?? new Set();
|
|
728
786
|
this.#handlerSettledJobs.delete(transaction.db);
|
|
787
|
+
this.#handlerSettledJobs.delete(untracked(transaction.db));
|
|
729
788
|
return settled;
|
|
730
789
|
}
|
|
731
790
|
// Records a settle a transactional handler ran through the transaction it was handed. A no-op for
|
|
@@ -749,6 +808,18 @@ class Manager extends EventEmitter {
|
|
|
749
808
|
settled.add(id);
|
|
750
809
|
}
|
|
751
810
|
}
|
|
811
|
+
// The attempt fence for a transactional handler settling its own batch through the transaction it
|
|
812
|
+
// was handed. Those settles go through the public complete(), fail(), cancel() and deleteJob(),
|
|
813
|
+
// which carry no fence of their own, and #assertClaimHeld counts whatever they touch as the
|
|
814
|
+
// handler's: unfenced, a handler whose claim lapsed would settle the newer attempt, the counts would
|
|
815
|
+
// agree, and the transaction would commit. Only when every id is one of the batch's; a settle that
|
|
816
|
+
// reaches beyond the batch is not a claim this worker holds, so there is nothing to fence it to.
|
|
817
|
+
#handlerAttempts(options, ids) {
|
|
818
|
+
const claims = options.db && this.#handlerClaims.get(options.db);
|
|
819
|
+
if (!claims || !ids.every(id => claims.has(id)))
|
|
820
|
+
return undefined;
|
|
821
|
+
return ids.map(id => claims.get(id));
|
|
822
|
+
}
|
|
752
823
|
async start() {
|
|
753
824
|
this.stopped = false;
|
|
754
825
|
this.queueCacheInterval = this.config.clock.setInterval(() => this.onCacheQueues({ emit: true }), this.config.queueCacheIntervalSeconds * 1000);
|
|
@@ -818,7 +889,7 @@ class Manager extends EventEmitter {
|
|
|
818
889
|
// jobs active and its completion finds nothing to settle. Its own failure is reported
|
|
819
890
|
// rather than thrown: the remaining workers still have to be aborted.
|
|
820
891
|
try {
|
|
821
|
-
await this.fail(worker.name,
|
|
892
|
+
await this.fail(worker.name, worker.jobs, 'pg-boss shut down while active');
|
|
822
893
|
}
|
|
823
894
|
catch (err) {
|
|
824
895
|
try {
|
|
@@ -902,6 +973,11 @@ class Manager extends EventEmitter {
|
|
|
902
973
|
if (excess.length > 0) {
|
|
903
974
|
const excessIds = excess.map(job => job.id);
|
|
904
975
|
await this.restore(name, excessIds);
|
|
976
|
+
// A restored job is claimable again at the same retryCount (restore clears started_on,
|
|
977
|
+
// so the next claim does not increment it), and failWip() fails whatever is left in
|
|
978
|
+
// worker.jobs: left there, a shutdown would fail another worker's claim on it.
|
|
979
|
+
if (worker)
|
|
980
|
+
worker.jobs = allowed;
|
|
905
981
|
}
|
|
906
982
|
if (allowed.length > 0) {
|
|
907
983
|
await this.#processJobs(name, allowed, callback, worker, heartbeatRefreshSeconds, perJobResults, transactional, transactionTimeoutSeconds);
|
|
@@ -1004,8 +1080,17 @@ class Manager extends EventEmitter {
|
|
|
1004
1080
|
.filter(i => i.state !== 'stopped' && (!INTERNAL_QUEUES[i.name] || includeInternal));
|
|
1005
1081
|
return data;
|
|
1006
1082
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1083
|
+
trackCleanup(cleanup) {
|
|
1084
|
+
this.pendingOffWorkCleanups.add(cleanup);
|
|
1085
|
+
const settled = () => { this.pendingOffWorkCleanups.delete(cleanup); };
|
|
1086
|
+
cleanup.then(settled, settled);
|
|
1087
|
+
}
|
|
1088
|
+
// Resolves once no cleanup is pending, including any a settling cleanup registers, so a graceful
|
|
1089
|
+
// stop() carries on the moment its last worker has stopped.
|
|
1090
|
+
async settleCleanups() {
|
|
1091
|
+
while (this.pendingOffWorkCleanups.size > 0) {
|
|
1092
|
+
await Promise.allSettled([...this.pendingOffWorkCleanups]);
|
|
1093
|
+
}
|
|
1009
1094
|
}
|
|
1010
1095
|
async offWork(name, options = { wait: true }) {
|
|
1011
1096
|
assert(name, 'queue name is required');
|
|
@@ -1034,11 +1119,8 @@ class Manager extends EventEmitter {
|
|
|
1034
1119
|
this.#cleanupLocalGroupTracking(name);
|
|
1035
1120
|
}
|
|
1036
1121
|
else {
|
|
1037
|
-
this.
|
|
1038
|
-
cleanupPromise.finally(() =>
|
|
1039
|
-
this.pendingOffWorkCleanups.delete(cleanupPromise);
|
|
1040
|
-
this.#cleanupLocalGroupTracking(name);
|
|
1041
|
-
});
|
|
1122
|
+
this.trackCleanup(cleanupPromise);
|
|
1123
|
+
cleanupPromise.finally(() => this.#cleanupLocalGroupTracking(name));
|
|
1042
1124
|
}
|
|
1043
1125
|
}
|
|
1044
1126
|
notifyWorker(workerId) {
|
|
@@ -1508,6 +1590,20 @@ class Manager extends EventEmitter {
|
|
|
1508
1590
|
// heartbeatSeconds, so normalize regardless of includeMetadata.
|
|
1509
1591
|
return this.#numericJobFields(rows);
|
|
1510
1592
|
}
|
|
1593
|
+
// The id argument of the calls that settle or refresh a claim. Jobs passed as { id, retryCount }
|
|
1594
|
+
// (what fetch() and work() hand out) come back with their attempts, which fence the call to the
|
|
1595
|
+
// attempt that was fetched (plans.attemptFence). Plain ids come back without, and are not fenced.
|
|
1596
|
+
mapAttemptArg(id, funcName) {
|
|
1597
|
+
assert(id, `${funcName}() requires an id`);
|
|
1598
|
+
const items = Array.isArray(id) ? id : [id];
|
|
1599
|
+
assert(items.length, `${funcName}() requires an id`);
|
|
1600
|
+
if (items.every(item => typeof item === 'string')) {
|
|
1601
|
+
return { ids: items };
|
|
1602
|
+
}
|
|
1603
|
+
const jobs = items;
|
|
1604
|
+
assert(jobs.every(job => job && typeof job === 'object' && typeof job.id === 'string' && Number.isInteger(job.retryCount) && job.retryCount >= 0), `${funcName}() requires either ids or jobs with an id and an integer retryCount, not a mix`);
|
|
1605
|
+
return { ids: jobs.map(job => job.id), attempts: jobs.map(job => job.retryCount) };
|
|
1606
|
+
}
|
|
1511
1607
|
mapCompletionIdArg(id, funcName) {
|
|
1512
1608
|
const errorMessage = `${funcName}() requires an id`;
|
|
1513
1609
|
assert(id, errorMessage);
|
|
@@ -1529,27 +1625,30 @@ class Manager extends EventEmitter {
|
|
|
1529
1625
|
jobs: ids,
|
|
1530
1626
|
requested: ids.length,
|
|
1531
1627
|
affected: result && result.rows ? parseInt(result.rows[0].count) : 0,
|
|
1532
|
-
// The settle statements aggregate the ids they touched beside the count; the
|
|
1533
|
-
// mutators that share this mapper (resume, restore, retry
|
|
1534
|
-
// optional and #trackHandlerSettle falls back to the count when it is absent.
|
|
1628
|
+
// The settle statements and touch() aggregate the ids they touched beside the count; the
|
|
1629
|
+
// mutators that share this mapper without doing so (resume, restore, retry) leave it unset, so
|
|
1630
|
+
// it stays optional and #trackHandlerSettle falls back to the count when it is absent.
|
|
1535
1631
|
settled: result?.rows?.[0]?.ids
|
|
1536
1632
|
};
|
|
1537
1633
|
}
|
|
1634
|
+
// Fenced to the fetched attempt (plans.attemptFence) when `id` carries jobs, as the worker's own
|
|
1635
|
+
// settles do, or when a transactional handler settles its own batch through its transaction.
|
|
1538
1636
|
async complete(name, id, data, options = {}) {
|
|
1539
1637
|
Attorney.assertQueueName(name);
|
|
1540
1638
|
const db = this.assertDb(options);
|
|
1541
|
-
const ids = this.
|
|
1639
|
+
const { ids, attempts: fetched } = this.mapAttemptArg(id, 'complete');
|
|
1640
|
+
const attempts = fetched ?? this.#handlerAttempts(options, ids);
|
|
1542
1641
|
const { table } = await this.getQueueCache(name);
|
|
1543
1642
|
const outputData = this.mapCompletionDataArg(data);
|
|
1544
1643
|
let response;
|
|
1545
1644
|
// noMultiMutationCte: split the dependency-unblocking into a separate statement to
|
|
1546
1645
|
// avoid CockroachDB's multi-mutation CTE limitation (completeJobs updates two tables).
|
|
1547
1646
|
if (this.config.noMultiMutationCte) {
|
|
1548
|
-
response = await this.completeDistributed(name, ids, outputData, table, db, options.includeQueued);
|
|
1647
|
+
response = await this.completeDistributed(name, ids, outputData, table, db, options.includeQueued, attempts);
|
|
1549
1648
|
}
|
|
1550
1649
|
else {
|
|
1551
|
-
const sql = plans.completeJobs(this.config.schema, table, options.includeQueued);
|
|
1552
|
-
const result = await db.executeSql(sql, [name, ids, outputData]);
|
|
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]);
|
|
1553
1652
|
response = this.mapCommandResponse(ids, result);
|
|
1554
1653
|
}
|
|
1555
1654
|
this.#trackHandlerSettle(options, response);
|
|
@@ -1565,17 +1664,19 @@ class Manager extends EventEmitter {
|
|
|
1565
1664
|
}
|
|
1566
1665
|
return fn(db);
|
|
1567
1666
|
}
|
|
1568
|
-
async completeDistributed(name, ids, outputData, table, db, includeQueued) {
|
|
1667
|
+
async completeDistributed(name, ids, outputData, table, db, includeQueued, attempts) {
|
|
1569
1668
|
// Dependency unblocking is handled out of band by the background resolver (Navigator), so
|
|
1570
1669
|
// completion is a single statement on every backend.
|
|
1571
|
-
const sql = plans.completeJobsDistributed(this.config.schema, table, includeQueued);
|
|
1572
|
-
const { rows } = await db.executeSql(sql, [name, ids, outputData]);
|
|
1670
|
+
const sql = plans.completeJobsDistributed(this.config.schema, table, includeQueued, !!attempts);
|
|
1671
|
+
const { rows } = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
|
|
1573
1672
|
return { jobs: ids, requested: ids.length, affected: rows.length, settled: rows.map(row => row.id) };
|
|
1574
1673
|
}
|
|
1674
|
+
// Fenced to the fetched attempt as in complete().
|
|
1575
1675
|
async fail(name, id, data, options = {}) {
|
|
1576
1676
|
Attorney.assertQueueName(name);
|
|
1577
1677
|
const db = this.assertDb(options);
|
|
1578
|
-
const ids = this.
|
|
1678
|
+
const { ids, attempts: fetched } = this.mapAttemptArg(id, 'fail');
|
|
1679
|
+
const attempts = fetched ?? this.#handlerAttempts(options, ids);
|
|
1579
1680
|
const { table } = await this.getQueueCache(name);
|
|
1580
1681
|
const outputData = this.mapCompletionDataArg(data);
|
|
1581
1682
|
let response;
|
|
@@ -1583,29 +1684,31 @@ class Manager extends EventEmitter {
|
|
|
1583
1684
|
// The delete and re-insert run in a single transaction (see ensureTransaction) so the
|
|
1584
1685
|
// job cannot be lost between the two statements.
|
|
1585
1686
|
if (this.config.noMultiMutationCte) {
|
|
1586
|
-
response = await this.failDistributed(name, ids, outputData, table, db);
|
|
1687
|
+
response = await this.failDistributed(name, ids, outputData, table, db, attempts);
|
|
1587
1688
|
}
|
|
1588
1689
|
else {
|
|
1589
|
-
const sql = plans.failJobsById(this.config.schema, table);
|
|
1590
|
-
const result = await db.executeSql(sql, [name, ids, outputData]);
|
|
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]);
|
|
1591
1692
|
response = this.mapCommandResponse(ids, result);
|
|
1592
1693
|
}
|
|
1593
1694
|
this.#trackHandlerSettle(options, response);
|
|
1594
1695
|
return response;
|
|
1595
1696
|
}
|
|
1596
|
-
async failDistributed(name, ids, outputData, table, db) {
|
|
1697
|
+
async failDistributed(name, ids, outputData, table, db, attempts) {
|
|
1597
1698
|
// CockroachDB doesn't support multi-mutation CTEs, but does support transactions, so the
|
|
1598
1699
|
// delete + re-insert is split into separate statements run atomically.
|
|
1599
1700
|
return this.ensureTransaction(db, async (tx) => {
|
|
1600
1701
|
// Step 1: Select jobs to fail
|
|
1601
|
-
const selectQuery = plans.selectJobsToFailById(this.config.schema, table);
|
|
1602
|
-
const { rows: jobs } = await tx.executeSql(selectQuery.text, [name, ids]);
|
|
1702
|
+
const selectQuery = plans.selectJobsToFailById(this.config.schema, table, !!attempts);
|
|
1703
|
+
const { rows: jobs } = await tx.executeSql(selectQuery.text, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
|
|
1603
1704
|
if (jobs.length === 0) {
|
|
1604
1705
|
return { jobs: ids, requested: ids.length, affected: 0, settled: [] };
|
|
1605
1706
|
}
|
|
1606
|
-
// Step 2: Delete the jobs
|
|
1707
|
+
// Step 2: Delete the jobs the select matched, not every id asked about. The re-insert below
|
|
1708
|
+
// only restores the selected rows, so deleting by the requested ids would drop any of them
|
|
1709
|
+
// the select skipped (an already completed or cancelled job) for good.
|
|
1607
1710
|
const deleteQuery = plans.deleteJobsToFail(this.config.schema, table);
|
|
1608
|
-
await tx.executeSql(deleteQuery.text, [name,
|
|
1711
|
+
await tx.executeSql(deleteQuery.text, [name, jobs.map(job => job.id)]);
|
|
1609
1712
|
// Step 3: Re-insert jobs with updated state
|
|
1610
1713
|
const count = await this.reinsertFailedJobs(tx, table, jobs, outputData);
|
|
1611
1714
|
return { jobs: ids, requested: ids.length, affected: count, settled: jobs.map(job => job.id) };
|
|
@@ -1692,6 +1795,11 @@ class Manager extends EventEmitter {
|
|
|
1692
1795
|
// Dead-letter provenance, carried through so a job in a dead letter queue that fails here
|
|
1693
1796
|
// still knows where to be redriven. See failJobsBody for the single-statement path.
|
|
1694
1797
|
const sourceCreatedOn = job.source_created_on_text ?? job.source_created_on;
|
|
1798
|
+
// The json columns go back as text. Bound as read, a job whose data is an array reaches pg as
|
|
1799
|
+
// a Postgres array literal and a string as bare text, and neither parses as json.
|
|
1800
|
+
const data = toJsonText(job.data);
|
|
1801
|
+
const output = toJsonText(jobOutput);
|
|
1802
|
+
const sourceOutput = toJsonText(job.source_output);
|
|
1695
1803
|
// forceTerminal (perJobResults `deadletter`) skips retries so the job fails terminally and
|
|
1696
1804
|
// routes straight to the dead letter queue below.
|
|
1697
1805
|
const canRetry = !forceTerminal && retryCount < retryLimit;
|
|
@@ -1714,13 +1822,13 @@ class Manager extends EventEmitter {
|
|
|
1714
1822
|
// pending_dependencies are preserved so flows and heartbeat detection survive a retry
|
|
1715
1823
|
// (matches the non-distributed failJobs() CTE).
|
|
1716
1824
|
const { rows } = await tx.executeSql(insertSql, [
|
|
1717
|
-
job.id, job.name, job.priority,
|
|
1825
|
+
job.id, job.name, job.priority, data, 'retry', job.retry_limit, job.retry_count,
|
|
1718
1826
|
job.retry_delay, job.retry_backoff, job.retry_delay_max, startAfter, startedOn,
|
|
1719
1827
|
job.singleton_key, singletonOn, job.group_id, job.group_tier, job.expire_seconds,
|
|
1720
1828
|
job.deletion_seconds, createdOn, null, keepUntil, job.policy,
|
|
1721
|
-
|
|
1829
|
+
output, job.dead_letter,
|
|
1722
1830
|
null, job.heartbeat_seconds, job.blocked, job.blocking, job.pending_dependencies,
|
|
1723
|
-
job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count
|
|
1831
|
+
job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, sourceOutput, job.source_root_id
|
|
1724
1832
|
]);
|
|
1725
1833
|
// The retry insert can be dropped by ON CONFLICT when the queue policy (e.g. stately,
|
|
1726
1834
|
// singleton, key_strict_fifo) already has a non-terminal job. Mirror the failed_jobs
|
|
@@ -1729,30 +1837,32 @@ class Manager extends EventEmitter {
|
|
|
1729
1837
|
}
|
|
1730
1838
|
if (!retried) {
|
|
1731
1839
|
await tx.executeSql(insertSql, [
|
|
1732
|
-
job.id, job.name, job.priority,
|
|
1840
|
+
job.id, job.name, job.priority, data, 'failed', job.retry_limit, job.retry_count,
|
|
1733
1841
|
job.retry_delay, job.retry_backoff, job.retry_delay_max, startAfterColumn, startedOn,
|
|
1734
1842
|
job.singleton_key, singletonOn, job.group_id, job.group_tier, job.expire_seconds,
|
|
1735
1843
|
job.deletion_seconds, createdOn, new Date(this.config.clock.now()), keepUntil, job.policy,
|
|
1736
|
-
|
|
1844
|
+
output, job.dead_letter,
|
|
1737
1845
|
null, job.heartbeat_seconds, job.blocked, job.blocking, job.pending_dependencies,
|
|
1738
|
-
job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count
|
|
1846
|
+
job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, sourceOutput, job.source_root_id
|
|
1739
1847
|
]);
|
|
1740
1848
|
// Insert to dead letter queue if failed and has dead_letter configured
|
|
1741
1849
|
if (job.dead_letter) {
|
|
1742
|
-
await tx.executeSql(dlqSql, [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]);
|
|
1743
1851
|
}
|
|
1744
1852
|
}
|
|
1745
1853
|
count++;
|
|
1746
1854
|
}
|
|
1747
1855
|
return count;
|
|
1748
1856
|
}
|
|
1857
|
+
// Fenced to the fetched attempt as in complete().
|
|
1749
1858
|
async deleteJob(name, id, options = {}) {
|
|
1750
1859
|
Attorney.assertQueueName(name);
|
|
1751
1860
|
const db = this.assertDb(options);
|
|
1752
|
-
const ids = this.
|
|
1861
|
+
const { ids, attempts: fetched } = this.mapAttemptArg(id, 'deleteJob');
|
|
1862
|
+
const attempts = fetched ?? this.#handlerAttempts(options, ids);
|
|
1753
1863
|
const { table } = await this.getQueueCache(name);
|
|
1754
|
-
const sql = plans.deleteJobsById(this.config.schema, table);
|
|
1755
|
-
const result = await db.executeSql(sql, [name, ids]);
|
|
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]);
|
|
1756
1866
|
const response = this.mapCommandResponse(ids, result);
|
|
1757
1867
|
this.#trackHandlerSettle(options, response);
|
|
1758
1868
|
return response;
|
|
@@ -1793,16 +1903,23 @@ class Manager extends EventEmitter {
|
|
|
1793
1903
|
const db = this.assertDb(options);
|
|
1794
1904
|
const { table } = await this.getQueueCache(name);
|
|
1795
1905
|
// CockroachDB rejects the single-statement version (a DELETE and an INSERT on one table), so
|
|
1796
|
-
// the same move runs as
|
|
1906
|
+
// the same move runs as statements in one transaction. See plans.selectRedriveCandidates.
|
|
1797
1907
|
if (this.config.noMultiMutationCte) {
|
|
1798
1908
|
return this.ensureTransaction(db, async (tx) => {
|
|
1799
1909
|
const { rows } = await tx.executeSql(plans.selectRedriveCandidates(this.config.schema, table), [name, ...filter, limit]);
|
|
1800
1910
|
if (rows.length === 0)
|
|
1801
1911
|
return 0;
|
|
1802
1912
|
const ids = rows.map((row) => row.id);
|
|
1803
|
-
const
|
|
1804
|
-
await tx.executeSql(plans.
|
|
1805
|
-
|
|
1913
|
+
const newIds = rows.map((row) => row.new_id);
|
|
1914
|
+
const { rows: inserted } = await tx.executeSql(plans.insertRedrivenJobs(this.config.schema, table), [ids, filter[0], newIds]);
|
|
1915
|
+
const created = new Set(inserted.map((row) => row.id));
|
|
1916
|
+
const moved = ids.filter((_, i) => created.has(newIds[i]));
|
|
1917
|
+
const conflicted = ids.filter((_, i) => !created.has(newIds[i]));
|
|
1918
|
+
if (moved.length)
|
|
1919
|
+
await tx.executeSql(plans.deleteJobsByIds(this.config.schema, table).text, [moved]);
|
|
1920
|
+
if (conflicted.length)
|
|
1921
|
+
await tx.executeSql(plans.failRedriveConflicts(this.config.schema, table), [conflicted, filter[0]]);
|
|
1922
|
+
return moved.length;
|
|
1806
1923
|
});
|
|
1807
1924
|
}
|
|
1808
1925
|
const sql = plans.redriveJobs(this.config.schema, table);
|
|
@@ -1833,13 +1950,15 @@ class Manager extends EventEmitter {
|
|
|
1833
1950
|
unroutable
|
|
1834
1951
|
};
|
|
1835
1952
|
}
|
|
1953
|
+
// Fenced to the fetched attempt as in complete().
|
|
1836
1954
|
async cancel(name, id, options = {}) {
|
|
1837
1955
|
Attorney.assertQueueName(name);
|
|
1838
1956
|
const db = this.assertDb(options);
|
|
1839
|
-
const ids = this.
|
|
1957
|
+
const { ids, attempts: fetched } = this.mapAttemptArg(id, 'cancel');
|
|
1958
|
+
const attempts = fetched ?? this.#handlerAttempts(options, ids);
|
|
1840
1959
|
const { table } = await this.getQueueCache(name);
|
|
1841
|
-
const sql = plans.cancelJobs(this.config.schema, table);
|
|
1842
|
-
const result = await db.executeSql(sql, [name, ids]);
|
|
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]);
|
|
1843
1962
|
const response = this.mapCommandResponse(ids, result);
|
|
1844
1963
|
this.#trackHandlerSettle(options, response);
|
|
1845
1964
|
return response;
|
|
@@ -1870,13 +1989,15 @@ class Manager extends EventEmitter {
|
|
|
1870
1989
|
const result = await db.executeSql(sql, [name, ids]);
|
|
1871
1990
|
return this.mapCommandResponse(ids, result);
|
|
1872
1991
|
}
|
|
1992
|
+
// Fenced to the fetched attempt as in complete(), so a worker that lost its claim cannot keep a
|
|
1993
|
+
// newer attempt's heartbeat fresh.
|
|
1873
1994
|
async touch(name, id, options = {}) {
|
|
1874
1995
|
Attorney.assertQueueName(name);
|
|
1875
1996
|
const db = this.assertDb(options);
|
|
1876
|
-
const ids = this.
|
|
1997
|
+
const { ids, attempts } = this.mapAttemptArg(id, 'touch');
|
|
1877
1998
|
const { table } = await this.getQueueCache(name);
|
|
1878
|
-
const sql = plans.touchJobs(this.config.schema, table);
|
|
1879
|
-
const result = await db.executeSql(sql, [name, ids]);
|
|
1999
|
+
const sql = plans.touchJobs(this.config.schema, table, !!attempts);
|
|
2000
|
+
const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
|
|
1880
2001
|
return this.mapCommandResponse(ids, result);
|
|
1881
2002
|
}
|
|
1882
2003
|
async createQueue(name, options = {}) {
|
|
@@ -1976,10 +2097,14 @@ class Manager extends EventEmitter {
|
|
|
1976
2097
|
const sql = plans.deleteStoredJobs(this.config.schema, table);
|
|
1977
2098
|
await this.db.executeSql(sql, [name]);
|
|
1978
2099
|
}
|
|
2100
|
+
// A truncate leaves nothing to count, so it zeroes the cached counts after itself. A monitor pass
|
|
2101
|
+
// that read the table first holds it until done, so the truncate and then the zeroes land after
|
|
2102
|
+
// that pass's write.
|
|
1979
2103
|
async deleteAllJobs(name) {
|
|
1980
2104
|
if (!name) {
|
|
1981
2105
|
const sql = plans.truncateTable(this.config.schema, plans.BASE_JOB_TABLE);
|
|
1982
2106
|
await this.db.executeSql(sql);
|
|
2107
|
+
await this.db.executeSql(plans.zeroQueueStats(this.config.schema));
|
|
1983
2108
|
return;
|
|
1984
2109
|
}
|
|
1985
2110
|
Attorney.assertQueueName(name);
|
|
@@ -1987,6 +2112,7 @@ class Manager extends EventEmitter {
|
|
|
1987
2112
|
if (partition) {
|
|
1988
2113
|
const sql = plans.truncateTable(this.config.schema, table);
|
|
1989
2114
|
await this.db.executeSql(sql);
|
|
2115
|
+
await this.db.executeSql(plans.zeroQueueStats(this.config.schema, true), [name]);
|
|
1990
2116
|
}
|
|
1991
2117
|
else {
|
|
1992
2118
|
const sql = plans.deleteAllJobs(this.config.schema, table);
|
|
@@ -2005,7 +2131,9 @@ class Manager extends EventEmitter {
|
|
|
2005
2131
|
async getQueueStats(name, options = {}) {
|
|
2006
2132
|
Attorney.assertQueueName(name);
|
|
2007
2133
|
const isCockroach = this.config.backend === 'cockroachdb';
|
|
2008
|
-
|
|
2134
|
+
// `counted` is true for recorded snapshots. The cache path serves only gauges: the queue table's
|
|
2135
|
+
// counters describe the last pass that counted, not this reading.
|
|
2136
|
+
const toSnapshot = (row, counted = false) => {
|
|
2009
2137
|
const snapshot = {
|
|
2010
2138
|
name,
|
|
2011
2139
|
deferredCount: 0,
|
|
@@ -2014,14 +2142,25 @@ class Manager extends EventEmitter {
|
|
|
2014
2142
|
activeCount: 0,
|
|
2015
2143
|
failedCount: 0,
|
|
2016
2144
|
totalCount: 0,
|
|
2145
|
+
// Null, not zero, until something counted them: with persistQueueStats off
|
|
2146
|
+
// nobody does, and a snapshot captured before 12.35 predates the columns.
|
|
2147
|
+
// Zero would claim the queue was idle.
|
|
2148
|
+
completedDelta: null,
|
|
2149
|
+
failedDelta: null,
|
|
2150
|
+
createdDelta: null,
|
|
2151
|
+
deltaSeconds: null,
|
|
2152
|
+
deltaOn: null,
|
|
2017
2153
|
capturedOn: row?.capturedOn ?? new Date(this.config.clock.now())
|
|
2018
2154
|
};
|
|
2019
|
-
for (const field of STATS_COUNT_FIELDS) {
|
|
2155
|
+
for (const field of counted ? [...STATS_COUNT_FIELDS, ...STATS_DELTA_FIELDS] : STATS_COUNT_FIELDS) {
|
|
2020
2156
|
const value = row?.[field];
|
|
2021
2157
|
// CockroachDB returns integer columns as strings; normalize the counts.
|
|
2022
2158
|
if (value !== undefined && value !== null)
|
|
2023
2159
|
snapshot[field] = isCockroach ? Number(value) : value;
|
|
2024
2160
|
}
|
|
2161
|
+
// The end of the interval the counters cover, handed on as the row holds it, like capturedOn.
|
|
2162
|
+
if (counted && row?.deltaOn != null)
|
|
2163
|
+
snapshot.deltaOn = row.deltaOn;
|
|
2025
2164
|
return snapshot;
|
|
2026
2165
|
};
|
|
2027
2166
|
if (this.config.persistQueueStats) {
|
|
@@ -2039,11 +2178,11 @@ class Manager extends EventEmitter {
|
|
|
2039
2178
|
assert(Number.isInteger(width) && width >= 1, `getQueueStats: ${mode === 'bucket' ? 'bucketSeconds' : 'maxDataPoints'} must be a positive integer`);
|
|
2040
2179
|
const sql = plans.getQueueStatsHistoryBucketed(this.config.schema, aggregate, mode);
|
|
2041
2180
|
const { rows } = await this.db.executeSql(sql, [name, from, to, limit, width]);
|
|
2042
|
-
return rows.map(toSnapshot);
|
|
2181
|
+
return rows.map(row => toSnapshot(row, true));
|
|
2043
2182
|
}
|
|
2044
2183
|
const sql = plans.getQueueStatsHistory(this.config.schema);
|
|
2045
2184
|
const { rows } = await this.db.executeSql(sql, [name, from, to, limit]);
|
|
2046
|
-
return rows.map(toSnapshot);
|
|
2185
|
+
return rows.map(row => toSnapshot(row, true));
|
|
2047
2186
|
}
|
|
2048
2187
|
// persistQueueStats disabled: serve the cached counts the monitor keeps on the queue table.
|
|
2049
2188
|
// capturedOn is monitor_on, NULL if never monitored, or old if monitoring has since been turned
|