pg-boss 12.34.0 → 12.35.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 -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 +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -6
- package/dist/manager.d.ts +6 -5
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +186 -67
- 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 +22 -13
- package/dist/plans.d.ts.map +1 -1
- package/dist/plans.js +786 -202
- 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 +2 -2
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
|
|
@@ -152,6 +167,9 @@ class Manager extends EventEmitter {
|
|
|
152
167
|
// that transaction's db. #processJobs reads it to interpret its own completion; see the check
|
|
153
168
|
// there. Weak because the key is the transaction, so an entry goes away with it.
|
|
154
169
|
#handlerSettledJobs;
|
|
170
|
+
// The retryCount each job of a transactional batch was claimed at, keyed by the transaction the
|
|
171
|
+
// handler was handed, so the handler's own settles through it can be fenced (#handlerAttempts).
|
|
172
|
+
#handlerClaims;
|
|
155
173
|
// Which GUC this server bounds a transaction with, resolved on the first transactional batch and
|
|
156
174
|
// kept for the life of the process. Resolving to null means neither is recognised, or the probe
|
|
157
175
|
// itself failed; a failed probe clears the memo and sets the instant before which no batch asks
|
|
@@ -175,6 +193,7 @@ class Manager extends EventEmitter {
|
|
|
175
193
|
this.pendingOffWorkCleanups = new Set();
|
|
176
194
|
this.#spies = new Map();
|
|
177
195
|
this.#handlerSettledJobs = new WeakMap();
|
|
196
|
+
this.#handlerClaims = new WeakMap();
|
|
178
197
|
this.#transactionTimeoutGuc = null;
|
|
179
198
|
this.#transactionTimeoutProbeRetryAt = 0;
|
|
180
199
|
this.#transactionTimeoutProbeCooldownMs = config.__test__transactionTimeoutProbeCooldownMs ?? TRANSACTION_TIMEOUT_PROBE_COOLDOWN_MS;
|
|
@@ -334,7 +353,7 @@ class Manager extends EventEmitter {
|
|
|
334
353
|
// The handler opted into perJobResults but did not return an array: a contract violation.
|
|
335
354
|
// Fail the whole batch so the mistake surfaces and the jobs are retried.
|
|
336
355
|
const err = new Error('perJobResults handler must resolve with an array of job results');
|
|
337
|
-
await this.fail(name, jobs
|
|
356
|
+
await this.fail(name, jobs, err);
|
|
338
357
|
await this.#trackJobsFailed(name, jobs, err);
|
|
339
358
|
return;
|
|
340
359
|
}
|
|
@@ -367,31 +386,33 @@ class Manager extends EventEmitter {
|
|
|
367
386
|
failed.push({ job, output: new Error('no disposition returned by handler') });
|
|
368
387
|
}
|
|
369
388
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
389
|
+
const items = (entries) => entries.map(({ job, output }) => ({ id: job.id, retryCount: job.retryCount, output }));
|
|
390
|
+
const completedIds = completed.length > 0 ? await this.#completeWithOutputs(name, items(completed)) : null;
|
|
391
|
+
const failedIds = failed.length > 0 ? await this.#failWithOutputs(name, items(failed)) : null;
|
|
392
|
+
const deadLetteredIds = deadLettered.length > 0 ? await this.#failWithOutputs(name, items(deadLettered), true) : null;
|
|
393
|
+
// Only the jobs each statement actually settled: the attempt fence leaves a job whose claim
|
|
394
|
+
// lapsed alone, and recording it would tell a spy it settled when another attempt holds it.
|
|
395
|
+
const landed = (entries, result) => {
|
|
396
|
+
const settled = new Set(result?.settled ?? []);
|
|
397
|
+
return entries.filter(entry => settled.has(entry.job.id));
|
|
398
|
+
};
|
|
379
399
|
// Dead lettered jobs end in the same terminal `failed` state as failed jobs on the source queue.
|
|
380
|
-
this.#trackJobsSettled(name, completed, [...failed, ...deadLettered]);
|
|
400
|
+
this.#trackJobsSettled(name, landed(completed, completedIds), [...landed(failed, failedIds), ...landed(deadLettered, deadLetteredIds)]);
|
|
381
401
|
}
|
|
382
402
|
// Complete a set of active jobs, each with its own output, in a constant number of statements
|
|
383
403
|
// (one on Postgres, two on a distributed backend). Outputs are serialized like complete()/fail()
|
|
384
404
|
// and passed as a JSON recordset so the batch size doesn't drive the statement count.
|
|
405
|
+
// Each item carries the retryCount its job was fetched with, which the statement fences on.
|
|
385
406
|
async #completeWithOutputs(name, items) {
|
|
386
407
|
const { table } = await this.getQueueCache(name);
|
|
387
|
-
const payload = items.map(item => ({ id: item.id, output: this.mapCompletionDataArg(item.output) }));
|
|
408
|
+
const payload = items.map(item => ({ id: item.id, retry_count: item.retryCount, output: this.mapCompletionDataArg(item.output) }));
|
|
388
409
|
const ids = items.map(item => item.id);
|
|
389
410
|
if (this.config.noMultiMutationCte) {
|
|
390
411
|
// Dependency unblocking is handled out of band by the background resolver (Navigator), so
|
|
391
412
|
// completion is a single statement here too.
|
|
392
413
|
const sql = plans.completeJobsWithOutputsDistributed(this.config.schema, table);
|
|
393
414
|
const { rows } = await this.db.executeSql(sql, [name, JSON.stringify(payload)]);
|
|
394
|
-
return { jobs: ids, requested: ids.length, affected: rows.length };
|
|
415
|
+
return { jobs: ids, requested: ids.length, affected: rows.length, settled: rows.map(row => row.id) };
|
|
395
416
|
}
|
|
396
417
|
const sql = plans.completeJobsWithOutputs(this.config.schema, table);
|
|
397
418
|
const result = await this.db.executeSql(sql, [name, JSON.stringify(payload)]);
|
|
@@ -407,18 +428,19 @@ class Manager extends EventEmitter {
|
|
|
407
428
|
if (this.config.noMultiMutationCte) {
|
|
408
429
|
const outputById = new Map(items.map(item => [item.id, this.mapCompletionDataArg(item.output)]));
|
|
409
430
|
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]);
|
|
431
|
+
const selectQuery = plans.selectJobsToFailById(this.config.schema, table, true);
|
|
432
|
+
const { rows: jobs } = await tx.executeSql(selectQuery.text, [name, ids, plans.attemptPairs(ids, items.map(item => item.retryCount))]);
|
|
412
433
|
if (jobs.length === 0) {
|
|
413
|
-
return { jobs: ids, requested: ids.length, affected: 0 };
|
|
434
|
+
return { jobs: ids, requested: ids.length, affected: 0, settled: [] };
|
|
414
435
|
}
|
|
436
|
+
// Only the rows the select matched: see failDistributed.
|
|
415
437
|
const deleteQuery = plans.deleteJobsToFail(this.config.schema, table);
|
|
416
|
-
await tx.executeSql(deleteQuery.text, [name,
|
|
438
|
+
await tx.executeSql(deleteQuery.text, [name, jobs.map(job => job.id)]);
|
|
417
439
|
const count = await this.reinsertFailedJobs(tx, table, jobs, null, outputById, forceTerminal);
|
|
418
|
-
return { jobs: ids, requested: ids.length, affected: count };
|
|
440
|
+
return { jobs: ids, requested: ids.length, affected: count, settled: jobs.map(job => job.id) };
|
|
419
441
|
});
|
|
420
442
|
}
|
|
421
|
-
const payload = items.map(item => ({ id: item.id, output: this.mapCompletionDataArg(item.output) }));
|
|
443
|
+
const payload = items.map(item => ({ id: item.id, retry_count: item.retryCount, output: this.mapCompletionDataArg(item.output) }));
|
|
422
444
|
const sql = forceTerminal
|
|
423
445
|
? plans.deadLetterJobsByIdWithOutputs(this.config.schema, table)
|
|
424
446
|
: plans.failJobsByIdWithOutputs(this.config.schema, table);
|
|
@@ -494,7 +516,14 @@ class Manager extends EventEmitter {
|
|
|
494
516
|
const heartbeatCandidates = jobs.map(j => j.heartbeatSeconds || 0).filter(s => s > 0);
|
|
495
517
|
const heartbeatSeconds = heartbeatCandidates.length ? Math.min(...heartbeatCandidates) : 0;
|
|
496
518
|
const ac = new AbortController();
|
|
497
|
-
|
|
519
|
+
// Each job also gets a signal of its own, aborted when the heartbeat finds that job's claim gone
|
|
520
|
+
// (see the timer below), so a handler can stop work whose result can no longer land. Per job
|
|
521
|
+
// rather than per batch: a short heartbeat does not say why a job went missing, and the handler
|
|
522
|
+
// settling one of its own jobs early looks the same as another worker taking it. Aborting only
|
|
523
|
+
// the missing job makes that harmless, since a handler that settled a job no longer holds its
|
|
524
|
+
// claim either, and the rest of the batch runs on untouched.
|
|
525
|
+
const claims = new Map(jobs.map(job => [job.id, new AbortController()]));
|
|
526
|
+
jobs.forEach(job => { job.signal = AbortSignal.any([ac.signal, claims.get(job.id).signal]); });
|
|
498
527
|
// Store AbortController on worker so it can be aborted after graceful shutdown
|
|
499
528
|
if (worker) {
|
|
500
529
|
worker.abortController = ac;
|
|
@@ -505,11 +534,30 @@ class Manager extends EventEmitter {
|
|
|
505
534
|
const refreshSeconds = heartbeatRefreshSeconds ?? (heartbeatSeconds / 2);
|
|
506
535
|
const intervalMs = refreshSeconds * 1000;
|
|
507
536
|
heartbeatTimer = this.config.clock.setInterval(async () => {
|
|
537
|
+
const held = jobs.filter(job => !claims.get(job.id).signal.aborted);
|
|
538
|
+
if (held.length === 0)
|
|
539
|
+
return;
|
|
540
|
+
let touched;
|
|
508
541
|
try {
|
|
509
|
-
await this.touch(name,
|
|
542
|
+
touched = await this.touch(name, held);
|
|
510
543
|
}
|
|
511
544
|
catch (err) {
|
|
545
|
+
// A heartbeat that could not run says nothing about the claim, so nothing is aborted.
|
|
512
546
|
this.emit(events.error, err);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
// Without the ids it refreshed, a heartbeat cannot tell which job went missing, so it aborts
|
|
550
|
+
// nothing rather than everything.
|
|
551
|
+
if (!touched.settled)
|
|
552
|
+
return;
|
|
553
|
+
// touch() is fenced to the attempts this worker fetched, so a job it did not refresh is no
|
|
554
|
+
// longer active under this worker's claim: retried or settled elsewhere, or settled by the
|
|
555
|
+
// handler itself.
|
|
556
|
+
const refreshed = new Set(touched.settled);
|
|
557
|
+
for (const job of held) {
|
|
558
|
+
if (!refreshed.has(job.id)) {
|
|
559
|
+
claims.get(job.id).abort(new Error(`job ${job.id} is no longer active under this worker's claim`));
|
|
560
|
+
}
|
|
513
561
|
}
|
|
514
562
|
}, intervalMs);
|
|
515
563
|
}
|
|
@@ -537,13 +585,19 @@ class Manager extends EventEmitter {
|
|
|
537
585
|
// the bound lost for the whole cooldown after. Memoized after the first batch either way.
|
|
538
586
|
const timeoutGuc = timeoutMs > 0 ? await this.#resolveTransactionTimeoutGuc() : null;
|
|
539
587
|
transaction = await this.db.beginTransaction();
|
|
540
|
-
|
|
588
|
+
// The handler's view shares these, so a settle it runs on { db: tx } is still recognized and fenced.
|
|
589
|
+
const settled = new Set();
|
|
590
|
+
const claims = new Map(jobs.map(job => [job.id, job.retryCount]));
|
|
591
|
+
for (const db of [transaction.db, untracked(transaction.db)]) {
|
|
592
|
+
this.#handlerSettledJobs.set(db, settled);
|
|
593
|
+
this.#handlerClaims.set(db, claims);
|
|
594
|
+
}
|
|
541
595
|
if (timeoutGuc) {
|
|
542
596
|
await this.#applyTransactionTimeout(transaction, timeoutGuc, timeoutMs);
|
|
543
597
|
}
|
|
544
598
|
}
|
|
545
599
|
const handling = transaction
|
|
546
|
-
? callback(jobs, transaction.db)
|
|
600
|
+
? callback(jobs, untracked(transaction.db))
|
|
547
601
|
: callback(jobs);
|
|
548
602
|
const result = await resolveWithinSeconds(this.config.clock, handling, maxExpiration, `handler execution exceeded ${maxExpiration}s`, ac);
|
|
549
603
|
// An abort resolves the race rather than rejecting it, so on this line an abandoned handler
|
|
@@ -567,7 +621,7 @@ class Manager extends EventEmitter {
|
|
|
567
621
|
// Read out before the completion below, which goes through the same complete() and would
|
|
568
622
|
// otherwise record pg-boss's own settle as one the handler made.
|
|
569
623
|
const settledByHandler = transaction ? this.#takeHandlerSettles(transaction) : null;
|
|
570
|
-
const completion = await this.complete(name,
|
|
624
|
+
const completion = await this.complete(name, jobs, jobIds.length === 1 ? result : undefined, transaction ? { db: transaction.db } : undefined);
|
|
571
625
|
completedResult = result;
|
|
572
626
|
completedAffected = completion.affected;
|
|
573
627
|
if (settledByHandler) {
|
|
@@ -591,7 +645,7 @@ class Manager extends EventEmitter {
|
|
|
591
645
|
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
646
|
}
|
|
593
647
|
}
|
|
594
|
-
await this.fail(name,
|
|
648
|
+
await this.fail(name, jobs, err);
|
|
595
649
|
failedError = err;
|
|
596
650
|
didFail = true;
|
|
597
651
|
}
|
|
@@ -726,6 +780,7 @@ class Manager extends EventEmitter {
|
|
|
726
780
|
#takeHandlerSettles(transaction) {
|
|
727
781
|
const settled = this.#handlerSettledJobs.get(transaction.db) ?? new Set();
|
|
728
782
|
this.#handlerSettledJobs.delete(transaction.db);
|
|
783
|
+
this.#handlerSettledJobs.delete(untracked(transaction.db));
|
|
729
784
|
return settled;
|
|
730
785
|
}
|
|
731
786
|
// Records a settle a transactional handler ran through the transaction it was handed. A no-op for
|
|
@@ -749,6 +804,18 @@ class Manager extends EventEmitter {
|
|
|
749
804
|
settled.add(id);
|
|
750
805
|
}
|
|
751
806
|
}
|
|
807
|
+
// The attempt fence for a transactional handler settling its own batch through the transaction it
|
|
808
|
+
// was handed. Those settles go through the public complete(), fail(), cancel() and deleteJob(),
|
|
809
|
+
// which carry no fence of their own, and #assertClaimHeld counts whatever they touch as the
|
|
810
|
+
// handler's: unfenced, a handler whose claim lapsed would settle the newer attempt, the counts would
|
|
811
|
+
// agree, and the transaction would commit. Only when every id is one of the batch's; a settle that
|
|
812
|
+
// reaches beyond the batch is not a claim this worker holds, so there is nothing to fence it to.
|
|
813
|
+
#handlerAttempts(options, ids) {
|
|
814
|
+
const claims = options.db && this.#handlerClaims.get(options.db);
|
|
815
|
+
if (!claims || !ids.every(id => claims.has(id)))
|
|
816
|
+
return undefined;
|
|
817
|
+
return ids.map(id => claims.get(id));
|
|
818
|
+
}
|
|
752
819
|
async start() {
|
|
753
820
|
this.stopped = false;
|
|
754
821
|
this.queueCacheInterval = this.config.clock.setInterval(() => this.onCacheQueues({ emit: true }), this.config.queueCacheIntervalSeconds * 1000);
|
|
@@ -818,7 +885,7 @@ class Manager extends EventEmitter {
|
|
|
818
885
|
// jobs active and its completion finds nothing to settle. Its own failure is reported
|
|
819
886
|
// rather than thrown: the remaining workers still have to be aborted.
|
|
820
887
|
try {
|
|
821
|
-
await this.fail(worker.name,
|
|
888
|
+
await this.fail(worker.name, worker.jobs, 'pg-boss shut down while active');
|
|
822
889
|
}
|
|
823
890
|
catch (err) {
|
|
824
891
|
try {
|
|
@@ -902,6 +969,11 @@ class Manager extends EventEmitter {
|
|
|
902
969
|
if (excess.length > 0) {
|
|
903
970
|
const excessIds = excess.map(job => job.id);
|
|
904
971
|
await this.restore(name, excessIds);
|
|
972
|
+
// A restored job is claimable again at the same retryCount (restore clears started_on,
|
|
973
|
+
// so the next claim does not increment it), and failWip() fails whatever is left in
|
|
974
|
+
// worker.jobs: left there, a shutdown would fail another worker's claim on it.
|
|
975
|
+
if (worker)
|
|
976
|
+
worker.jobs = allowed;
|
|
905
977
|
}
|
|
906
978
|
if (allowed.length > 0) {
|
|
907
979
|
await this.#processJobs(name, allowed, callback, worker, heartbeatRefreshSeconds, perJobResults, transactional, transactionTimeoutSeconds);
|
|
@@ -1508,6 +1580,20 @@ class Manager extends EventEmitter {
|
|
|
1508
1580
|
// heartbeatSeconds, so normalize regardless of includeMetadata.
|
|
1509
1581
|
return this.#numericJobFields(rows);
|
|
1510
1582
|
}
|
|
1583
|
+
// The id argument of the calls that settle or refresh a claim. Jobs passed as { id, retryCount }
|
|
1584
|
+
// (what fetch() and work() hand out) come back with their attempts, which fence the call to the
|
|
1585
|
+
// attempt that was fetched (plans.attemptFence). Plain ids come back without, and are not fenced.
|
|
1586
|
+
mapAttemptArg(id, funcName) {
|
|
1587
|
+
assert(id, `${funcName}() requires an id`);
|
|
1588
|
+
const items = Array.isArray(id) ? id : [id];
|
|
1589
|
+
assert(items.length, `${funcName}() requires an id`);
|
|
1590
|
+
if (items.every(item => typeof item === 'string')) {
|
|
1591
|
+
return { ids: items };
|
|
1592
|
+
}
|
|
1593
|
+
const jobs = items;
|
|
1594
|
+
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`);
|
|
1595
|
+
return { ids: jobs.map(job => job.id), attempts: jobs.map(job => job.retryCount) };
|
|
1596
|
+
}
|
|
1511
1597
|
mapCompletionIdArg(id, funcName) {
|
|
1512
1598
|
const errorMessage = `${funcName}() requires an id`;
|
|
1513
1599
|
assert(id, errorMessage);
|
|
@@ -1529,27 +1615,30 @@ class Manager extends EventEmitter {
|
|
|
1529
1615
|
jobs: ids,
|
|
1530
1616
|
requested: ids.length,
|
|
1531
1617
|
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.
|
|
1618
|
+
// The settle statements and touch() aggregate the ids they touched beside the count; the
|
|
1619
|
+
// mutators that share this mapper without doing so (resume, restore, retry) leave it unset, so
|
|
1620
|
+
// it stays optional and #trackHandlerSettle falls back to the count when it is absent.
|
|
1535
1621
|
settled: result?.rows?.[0]?.ids
|
|
1536
1622
|
};
|
|
1537
1623
|
}
|
|
1624
|
+
// Fenced to the fetched attempt (plans.attemptFence) when `id` carries jobs, as the worker's own
|
|
1625
|
+
// settles do, or when a transactional handler settles its own batch through its transaction.
|
|
1538
1626
|
async complete(name, id, data, options = {}) {
|
|
1539
1627
|
Attorney.assertQueueName(name);
|
|
1540
1628
|
const db = this.assertDb(options);
|
|
1541
|
-
const ids = this.
|
|
1629
|
+
const { ids, attempts: fetched } = this.mapAttemptArg(id, 'complete');
|
|
1630
|
+
const attempts = fetched ?? this.#handlerAttempts(options, ids);
|
|
1542
1631
|
const { table } = await this.getQueueCache(name);
|
|
1543
1632
|
const outputData = this.mapCompletionDataArg(data);
|
|
1544
1633
|
let response;
|
|
1545
1634
|
// noMultiMutationCte: split the dependency-unblocking into a separate statement to
|
|
1546
1635
|
// avoid CockroachDB's multi-mutation CTE limitation (completeJobs updates two tables).
|
|
1547
1636
|
if (this.config.noMultiMutationCte) {
|
|
1548
|
-
response = await this.completeDistributed(name, ids, outputData, table, db, options.includeQueued);
|
|
1637
|
+
response = await this.completeDistributed(name, ids, outputData, table, db, options.includeQueued, attempts);
|
|
1549
1638
|
}
|
|
1550
1639
|
else {
|
|
1551
|
-
const sql = plans.completeJobs(this.config.schema, table, options.includeQueued);
|
|
1552
|
-
const result = await db.executeSql(sql, [name, ids, outputData]);
|
|
1640
|
+
const sql = plans.completeJobs(this.config.schema, table, options.includeQueued, !!attempts);
|
|
1641
|
+
const result = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
|
|
1553
1642
|
response = this.mapCommandResponse(ids, result);
|
|
1554
1643
|
}
|
|
1555
1644
|
this.#trackHandlerSettle(options, response);
|
|
@@ -1565,17 +1654,19 @@ class Manager extends EventEmitter {
|
|
|
1565
1654
|
}
|
|
1566
1655
|
return fn(db);
|
|
1567
1656
|
}
|
|
1568
|
-
async completeDistributed(name, ids, outputData, table, db, includeQueued) {
|
|
1657
|
+
async completeDistributed(name, ids, outputData, table, db, includeQueued, attempts) {
|
|
1569
1658
|
// Dependency unblocking is handled out of band by the background resolver (Navigator), so
|
|
1570
1659
|
// 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]);
|
|
1660
|
+
const sql = plans.completeJobsDistributed(this.config.schema, table, includeQueued, !!attempts);
|
|
1661
|
+
const { rows } = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
|
|
1573
1662
|
return { jobs: ids, requested: ids.length, affected: rows.length, settled: rows.map(row => row.id) };
|
|
1574
1663
|
}
|
|
1664
|
+
// Fenced to the fetched attempt as in complete().
|
|
1575
1665
|
async fail(name, id, data, options = {}) {
|
|
1576
1666
|
Attorney.assertQueueName(name);
|
|
1577
1667
|
const db = this.assertDb(options);
|
|
1578
|
-
const ids = this.
|
|
1668
|
+
const { ids, attempts: fetched } = this.mapAttemptArg(id, 'fail');
|
|
1669
|
+
const attempts = fetched ?? this.#handlerAttempts(options, ids);
|
|
1579
1670
|
const { table } = await this.getQueueCache(name);
|
|
1580
1671
|
const outputData = this.mapCompletionDataArg(data);
|
|
1581
1672
|
let response;
|
|
@@ -1583,29 +1674,31 @@ class Manager extends EventEmitter {
|
|
|
1583
1674
|
// The delete and re-insert run in a single transaction (see ensureTransaction) so the
|
|
1584
1675
|
// job cannot be lost between the two statements.
|
|
1585
1676
|
if (this.config.noMultiMutationCte) {
|
|
1586
|
-
response = await this.failDistributed(name, ids, outputData, table, db);
|
|
1677
|
+
response = await this.failDistributed(name, ids, outputData, table, db, attempts);
|
|
1587
1678
|
}
|
|
1588
1679
|
else {
|
|
1589
|
-
const sql = plans.failJobsById(this.config.schema, table);
|
|
1590
|
-
const result = await db.executeSql(sql, [name, ids, outputData]);
|
|
1680
|
+
const sql = plans.failJobsById(this.config.schema, table, !!attempts);
|
|
1681
|
+
const result = await db.executeSql(sql, attempts ? [name, ids, outputData, plans.attemptPairs(ids, attempts)] : [name, ids, outputData]);
|
|
1591
1682
|
response = this.mapCommandResponse(ids, result);
|
|
1592
1683
|
}
|
|
1593
1684
|
this.#trackHandlerSettle(options, response);
|
|
1594
1685
|
return response;
|
|
1595
1686
|
}
|
|
1596
|
-
async failDistributed(name, ids, outputData, table, db) {
|
|
1687
|
+
async failDistributed(name, ids, outputData, table, db, attempts) {
|
|
1597
1688
|
// CockroachDB doesn't support multi-mutation CTEs, but does support transactions, so the
|
|
1598
1689
|
// delete + re-insert is split into separate statements run atomically.
|
|
1599
1690
|
return this.ensureTransaction(db, async (tx) => {
|
|
1600
1691
|
// 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]);
|
|
1692
|
+
const selectQuery = plans.selectJobsToFailById(this.config.schema, table, !!attempts);
|
|
1693
|
+
const { rows: jobs } = await tx.executeSql(selectQuery.text, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
|
|
1603
1694
|
if (jobs.length === 0) {
|
|
1604
1695
|
return { jobs: ids, requested: ids.length, affected: 0, settled: [] };
|
|
1605
1696
|
}
|
|
1606
|
-
// Step 2: Delete the jobs
|
|
1697
|
+
// Step 2: Delete the jobs the select matched, not every id asked about. The re-insert below
|
|
1698
|
+
// only restores the selected rows, so deleting by the requested ids would drop any of them
|
|
1699
|
+
// the select skipped (an already completed or cancelled job) for good.
|
|
1607
1700
|
const deleteQuery = plans.deleteJobsToFail(this.config.schema, table);
|
|
1608
|
-
await tx.executeSql(deleteQuery.text, [name,
|
|
1701
|
+
await tx.executeSql(deleteQuery.text, [name, jobs.map(job => job.id)]);
|
|
1609
1702
|
// Step 3: Re-insert jobs with updated state
|
|
1610
1703
|
const count = await this.reinsertFailedJobs(tx, table, jobs, outputData);
|
|
1611
1704
|
return { jobs: ids, requested: ids.length, affected: count, settled: jobs.map(job => job.id) };
|
|
@@ -1720,7 +1813,7 @@ class Manager extends EventEmitter {
|
|
|
1720
1813
|
job.deletion_seconds, createdOn, null, keepUntil, job.policy,
|
|
1721
1814
|
jobOutput, job.dead_letter,
|
|
1722
1815
|
null, job.heartbeat_seconds, job.blocked, job.blocking, job.pending_dependencies,
|
|
1723
|
-
job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count
|
|
1816
|
+
job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, job.source_output, job.source_root_id
|
|
1724
1817
|
]);
|
|
1725
1818
|
// The retry insert can be dropped by ON CONFLICT when the queue policy (e.g. stately,
|
|
1726
1819
|
// singleton, key_strict_fifo) already has a non-terminal job. Mirror the failed_jobs
|
|
@@ -1735,24 +1828,26 @@ class Manager extends EventEmitter {
|
|
|
1735
1828
|
job.deletion_seconds, createdOn, new Date(this.config.clock.now()), keepUntil, job.policy,
|
|
1736
1829
|
jobOutput, job.dead_letter,
|
|
1737
1830
|
null, job.heartbeat_seconds, job.blocked, job.blocking, job.pending_dependencies,
|
|
1738
|
-
job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count
|
|
1831
|
+
job.source_name, job.source_id, sourceCreatedOn, job.source_retry_count, job.source_output, job.source_root_id
|
|
1739
1832
|
]);
|
|
1740
1833
|
// Insert to dead letter queue if failed and has dead_letter configured
|
|
1741
1834
|
if (job.dead_letter) {
|
|
1742
|
-
await tx.executeSql(dlqSql, [job.dead_letter, job.data, jobOutput, job.name, job.id, createdOn, job.retry_count, job.singleton_key, job.priority, job.group_id, job.group_tier]);
|
|
1835
|
+
await tx.executeSql(dlqSql, [job.dead_letter, job.data, jobOutput, job.name, job.id, createdOn, job.retry_count, job.singleton_key, job.priority, job.group_id, job.group_tier, job.source_root_id]);
|
|
1743
1836
|
}
|
|
1744
1837
|
}
|
|
1745
1838
|
count++;
|
|
1746
1839
|
}
|
|
1747
1840
|
return count;
|
|
1748
1841
|
}
|
|
1842
|
+
// Fenced to the fetched attempt as in complete().
|
|
1749
1843
|
async deleteJob(name, id, options = {}) {
|
|
1750
1844
|
Attorney.assertQueueName(name);
|
|
1751
1845
|
const db = this.assertDb(options);
|
|
1752
|
-
const ids = this.
|
|
1846
|
+
const { ids, attempts: fetched } = this.mapAttemptArg(id, 'deleteJob');
|
|
1847
|
+
const attempts = fetched ?? this.#handlerAttempts(options, ids);
|
|
1753
1848
|
const { table } = await this.getQueueCache(name);
|
|
1754
|
-
const sql = plans.deleteJobsById(this.config.schema, table);
|
|
1755
|
-
const result = await db.executeSql(sql, [name, ids]);
|
|
1849
|
+
const sql = plans.deleteJobsById(this.config.schema, table, !!attempts);
|
|
1850
|
+
const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
|
|
1756
1851
|
const response = this.mapCommandResponse(ids, result);
|
|
1757
1852
|
this.#trackHandlerSettle(options, response);
|
|
1758
1853
|
return response;
|
|
@@ -1793,16 +1888,23 @@ class Manager extends EventEmitter {
|
|
|
1793
1888
|
const db = this.assertDb(options);
|
|
1794
1889
|
const { table } = await this.getQueueCache(name);
|
|
1795
1890
|
// CockroachDB rejects the single-statement version (a DELETE and an INSERT on one table), so
|
|
1796
|
-
// the same move runs as
|
|
1891
|
+
// the same move runs as statements in one transaction. See plans.selectRedriveCandidates.
|
|
1797
1892
|
if (this.config.noMultiMutationCte) {
|
|
1798
1893
|
return this.ensureTransaction(db, async (tx) => {
|
|
1799
1894
|
const { rows } = await tx.executeSql(plans.selectRedriveCandidates(this.config.schema, table), [name, ...filter, limit]);
|
|
1800
1895
|
if (rows.length === 0)
|
|
1801
1896
|
return 0;
|
|
1802
1897
|
const ids = rows.map((row) => row.id);
|
|
1803
|
-
const
|
|
1804
|
-
await tx.executeSql(plans.
|
|
1805
|
-
|
|
1898
|
+
const newIds = rows.map((row) => row.new_id);
|
|
1899
|
+
const { rows: inserted } = await tx.executeSql(plans.insertRedrivenJobs(this.config.schema, table), [ids, filter[0], newIds]);
|
|
1900
|
+
const created = new Set(inserted.map((row) => row.id));
|
|
1901
|
+
const moved = ids.filter((_, i) => created.has(newIds[i]));
|
|
1902
|
+
const conflicted = ids.filter((_, i) => !created.has(newIds[i]));
|
|
1903
|
+
if (moved.length)
|
|
1904
|
+
await tx.executeSql(plans.deleteJobsByIds(this.config.schema, table).text, [moved]);
|
|
1905
|
+
if (conflicted.length)
|
|
1906
|
+
await tx.executeSql(plans.failRedriveConflicts(this.config.schema, table), [conflicted, filter[0]]);
|
|
1907
|
+
return moved.length;
|
|
1806
1908
|
});
|
|
1807
1909
|
}
|
|
1808
1910
|
const sql = plans.redriveJobs(this.config.schema, table);
|
|
@@ -1833,13 +1935,15 @@ class Manager extends EventEmitter {
|
|
|
1833
1935
|
unroutable
|
|
1834
1936
|
};
|
|
1835
1937
|
}
|
|
1938
|
+
// Fenced to the fetched attempt as in complete().
|
|
1836
1939
|
async cancel(name, id, options = {}) {
|
|
1837
1940
|
Attorney.assertQueueName(name);
|
|
1838
1941
|
const db = this.assertDb(options);
|
|
1839
|
-
const ids = this.
|
|
1942
|
+
const { ids, attempts: fetched } = this.mapAttemptArg(id, 'cancel');
|
|
1943
|
+
const attempts = fetched ?? this.#handlerAttempts(options, ids);
|
|
1840
1944
|
const { table } = await this.getQueueCache(name);
|
|
1841
|
-
const sql = plans.cancelJobs(this.config.schema, table);
|
|
1842
|
-
const result = await db.executeSql(sql, [name, ids]);
|
|
1945
|
+
const sql = plans.cancelJobs(this.config.schema, table, !!attempts);
|
|
1946
|
+
const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
|
|
1843
1947
|
const response = this.mapCommandResponse(ids, result);
|
|
1844
1948
|
this.#trackHandlerSettle(options, response);
|
|
1845
1949
|
return response;
|
|
@@ -1870,13 +1974,15 @@ class Manager extends EventEmitter {
|
|
|
1870
1974
|
const result = await db.executeSql(sql, [name, ids]);
|
|
1871
1975
|
return this.mapCommandResponse(ids, result);
|
|
1872
1976
|
}
|
|
1977
|
+
// Fenced to the fetched attempt as in complete(), so a worker that lost its claim cannot keep a
|
|
1978
|
+
// newer attempt's heartbeat fresh.
|
|
1873
1979
|
async touch(name, id, options = {}) {
|
|
1874
1980
|
Attorney.assertQueueName(name);
|
|
1875
1981
|
const db = this.assertDb(options);
|
|
1876
|
-
const ids = this.
|
|
1982
|
+
const { ids, attempts } = this.mapAttemptArg(id, 'touch');
|
|
1877
1983
|
const { table } = await this.getQueueCache(name);
|
|
1878
|
-
const sql = plans.touchJobs(this.config.schema, table);
|
|
1879
|
-
const result = await db.executeSql(sql, [name, ids]);
|
|
1984
|
+
const sql = plans.touchJobs(this.config.schema, table, !!attempts);
|
|
1985
|
+
const result = await db.executeSql(sql, attempts ? [name, ids, plans.attemptPairs(ids, attempts)] : [name, ids]);
|
|
1880
1986
|
return this.mapCommandResponse(ids, result);
|
|
1881
1987
|
}
|
|
1882
1988
|
async createQueue(name, options = {}) {
|
|
@@ -2005,7 +2111,9 @@ class Manager extends EventEmitter {
|
|
|
2005
2111
|
async getQueueStats(name, options = {}) {
|
|
2006
2112
|
Attorney.assertQueueName(name);
|
|
2007
2113
|
const isCockroach = this.config.backend === 'cockroachdb';
|
|
2008
|
-
|
|
2114
|
+
// `counted` is true for recorded snapshots. The cache path serves only gauges: the queue table's
|
|
2115
|
+
// counters describe the last pass that counted, not this reading.
|
|
2116
|
+
const toSnapshot = (row, counted = false) => {
|
|
2009
2117
|
const snapshot = {
|
|
2010
2118
|
name,
|
|
2011
2119
|
deferredCount: 0,
|
|
@@ -2014,14 +2122,25 @@ class Manager extends EventEmitter {
|
|
|
2014
2122
|
activeCount: 0,
|
|
2015
2123
|
failedCount: 0,
|
|
2016
2124
|
totalCount: 0,
|
|
2125
|
+
// Null, not zero, until something counted them: with persistQueueStats off
|
|
2126
|
+
// nobody does, and a snapshot captured before 12.35 predates the columns.
|
|
2127
|
+
// Zero would claim the queue was idle.
|
|
2128
|
+
completedDelta: null,
|
|
2129
|
+
failedDelta: null,
|
|
2130
|
+
createdDelta: null,
|
|
2131
|
+
deltaSeconds: null,
|
|
2132
|
+
deltaOn: null,
|
|
2017
2133
|
capturedOn: row?.capturedOn ?? new Date(this.config.clock.now())
|
|
2018
2134
|
};
|
|
2019
|
-
for (const field of STATS_COUNT_FIELDS) {
|
|
2135
|
+
for (const field of counted ? [...STATS_COUNT_FIELDS, ...STATS_DELTA_FIELDS] : STATS_COUNT_FIELDS) {
|
|
2020
2136
|
const value = row?.[field];
|
|
2021
2137
|
// CockroachDB returns integer columns as strings; normalize the counts.
|
|
2022
2138
|
if (value !== undefined && value !== null)
|
|
2023
2139
|
snapshot[field] = isCockroach ? Number(value) : value;
|
|
2024
2140
|
}
|
|
2141
|
+
// The end of the interval the counters cover, handed on as the row holds it, like capturedOn.
|
|
2142
|
+
if (counted && row?.deltaOn != null)
|
|
2143
|
+
snapshot.deltaOn = row.deltaOn;
|
|
2025
2144
|
return snapshot;
|
|
2026
2145
|
};
|
|
2027
2146
|
if (this.config.persistQueueStats) {
|
|
@@ -2039,11 +2158,11 @@ class Manager extends EventEmitter {
|
|
|
2039
2158
|
assert(Number.isInteger(width) && width >= 1, `getQueueStats: ${mode === 'bucket' ? 'bucketSeconds' : 'maxDataPoints'} must be a positive integer`);
|
|
2040
2159
|
const sql = plans.getQueueStatsHistoryBucketed(this.config.schema, aggregate, mode);
|
|
2041
2160
|
const { rows } = await this.db.executeSql(sql, [name, from, to, limit, width]);
|
|
2042
|
-
return rows.map(toSnapshot);
|
|
2161
|
+
return rows.map(row => toSnapshot(row, true));
|
|
2043
2162
|
}
|
|
2044
2163
|
const sql = plans.getQueueStatsHistory(this.config.schema);
|
|
2045
2164
|
const { rows } = await this.db.executeSql(sql, [name, from, to, limit]);
|
|
2046
|
-
return rows.map(toSnapshot);
|
|
2165
|
+
return rows.map(row => toSnapshot(row, true));
|
|
2047
2166
|
}
|
|
2048
2167
|
// persistQueueStats disabled: serve the cached counts the monitor keeps on the queue table.
|
|
2049
2168
|
// capturedOn is monitor_on, NULL if never monitored, or old if monitoring has since been turned
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migrationStore.d.ts","sourceRoot":"","sources":["../src/migrationStore.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAKnC,UAAU,cAAc;IACtB,WAAW,CAAC,EAAE,OAAO,CAAA;IAKrB,eAAe,CAAC,EAAE,KAAK,CAAC,kBAAkB,EAAE,CAAA;CAC7C;AAoGD,UAAU,iBAAiB;IACzB,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AASD,iBAAS,QAAQ,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,UAsB5G;AAED,iBAAS,IAAI,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,UAQxG;AAKD,iBAAS,eAAe,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,GAAG,iBAAiB,CAiDrK;AAKD,iBAAS,OAAO,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,UAIzI;
|
|
1
|
+
{"version":3,"file":"migrationStore.d.ts","sourceRoot":"","sources":["../src/migrationStore.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAKnC,UAAU,cAAc;IACtB,WAAW,CAAC,EAAE,OAAO,CAAA;IAKrB,eAAe,CAAC,EAAE,KAAK,CAAC,kBAAkB,EAAE,CAAA;CAC7C;AAoGD,UAAU,iBAAiB;IACzB,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AASD,iBAAS,QAAQ,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,UAsB5G;AAED,iBAAS,IAAI,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,UAQxG;AAKD,iBAAS,eAAe,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,GAAG,iBAAiB,CAiDrK;AAKD,iBAAS,OAAO,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,eAAe,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,UAIzI;AAgoCD,iBAAS,aAAa,CAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE9C;AAID,iBAAS,eAAe,CAAE,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,0BAA0B,EAAE,QAAQ,GAAG,qBAAqB,GAAG,mBAAmB,GAAG,qBAAqB,CAAC,GAAG,KAAK,CAAC,SAAS,EAAE,CAE3K;AAOD,iBAAS,MAAM,CAAE,MAAM,EAAE,MAAM,EAAE,cAAc,UAAQ,EAAE,UAAU,UAAQ,EAAE,mBAAmB,UAAQ,GAAG,KAAK,CAAC,SAAS,EAAE,CA4oB3H;AAED,OAAO,EACL,QAAQ,EACR,IAAI,EACJ,OAAO,EACP,eAAe,EACf,MAAM,EACN,eAAe,EACf,aAAa,GACd,CAAA"}
|