@zukhruf/sqlite-queue 0.0.0-stage → 0.3.41
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/LICENSE +21 -0
- package/README.md +151 -2
- package/dist/activity.d.ts +10 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +93 -0
- package/dist/activity.js.map +1 -0
- package/dist/attorney.d.ts +35 -0
- package/dist/attorney.d.ts.map +1 -0
- package/dist/attorney.js +367 -0
- package/dist/attorney.js.map +1 -0
- package/dist/claim-timer.d.ts +8 -0
- package/dist/claim-timer.d.ts.map +1 -0
- package/dist/claim-timer.js +80 -0
- package/dist/claim-timer.js.map +1 -0
- package/dist/completion-data.d.ts +6 -0
- package/dist/completion-data.d.ts.map +1 -0
- package/dist/completion-data.js +36 -0
- package/dist/completion-data.js.map +1 -0
- package/dist/database-error.d.ts +6 -0
- package/dist/database-error.d.ts.map +1 -0
- package/dist/database-error.js +12 -0
- package/dist/database-error.js.map +1 -0
- package/dist/database.d.ts +7 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/database.js +28 -0
- package/dist/database.js.map +1 -0
- package/dist/in-flight.d.ts +7 -0
- package/dist/in-flight.d.ts.map +1 -0
- package/dist/in-flight.js +24 -0
- package/dist/in-flight.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1069 -0
- package/dist/index.js.map +1 -0
- package/dist/job-fetcher.d.ts +16 -0
- package/dist/job-fetcher.d.ts.map +1 -0
- package/dist/job-fetcher.js +227 -0
- package/dist/job-fetcher.js.map +1 -0
- package/dist/job-queue.d.ts +27 -0
- package/dist/job-queue.d.ts.map +1 -0
- package/dist/job-queue.js +2 -0
- package/dist/job-queue.js.map +1 -0
- package/dist/job-rows.d.ts +49 -0
- package/dist/job-rows.d.ts.map +1 -0
- package/dist/job-rows.js +155 -0
- package/dist/job-rows.js.map +1 -0
- package/dist/job-settler.d.ts +24 -0
- package/dist/job-settler.d.ts.map +1 -0
- package/dist/job-settler.js +240 -0
- package/dist/job-settler.js.map +1 -0
- package/dist/jsonb.d.ts +7 -0
- package/dist/jsonb.d.ts.map +1 -0
- package/dist/jsonb.js +85 -0
- package/dist/jsonb.js.map +1 -0
- package/dist/local-groups.d.ts +16 -0
- package/dist/local-groups.d.ts.map +1 -0
- package/dist/local-groups.js +113 -0
- package/dist/local-groups.js.map +1 -0
- package/dist/naive-timestamp.d.ts +6 -0
- package/dist/naive-timestamp.d.ts.map +1 -0
- package/dist/naive-timestamp.js +21 -0
- package/dist/naive-timestamp.js.map +1 -0
- package/dist/queue-cache.d.ts +9 -0
- package/dist/queue-cache.d.ts.map +1 -0
- package/dist/queue-cache.js +27 -0
- package/dist/queue-cache.js.map +1 -0
- package/dist/queue-rows.d.ts +25 -0
- package/dist/queue-rows.d.ts.map +1 -0
- package/dist/queue-rows.js +44 -0
- package/dist/queue-rows.js.map +1 -0
- package/dist/recurrence.d.ts +11 -0
- package/dist/recurrence.d.ts.map +1 -0
- package/dist/recurrence.js +226 -0
- package/dist/recurrence.js.map +1 -0
- package/dist/rows.d.ts +10 -0
- package/dist/rows.d.ts.map +1 -0
- package/dist/rows.js +46 -0
- package/dist/rows.js.map +1 -0
- package/dist/schema.d.ts +3 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +139 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql-values.d.ts +9 -0
- package/dist/sql-values.d.ts.map +1 -0
- package/dist/sql-values.js +26 -0
- package/dist/sql-values.js.map +1 -0
- package/dist/start-after.d.ts +10 -0
- package/dist/start-after.d.ts.map +1 -0
- package/dist/start-after.js +125 -0
- package/dist/start-after.js.map +1 -0
- package/dist/supervisor.d.ts +16 -0
- package/dist/supervisor.d.ts.map +1 -0
- package/dist/supervisor.js +288 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/throttle.d.ts +6 -0
- package/dist/throttle.d.ts.map +1 -0
- package/dist/throttle.js +35 -0
- package/dist/throttle.js.map +1 -0
- package/dist/timekeeper.d.ts +36 -0
- package/dist/timekeeper.d.ts.map +1 -0
- package/dist/timekeeper.js +305 -0
- package/dist/timekeeper.js.map +1 -0
- package/dist/timers.d.ts +9 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +50 -0
- package/dist/timers.js.map +1 -0
- package/dist/uuid.d.ts +7 -0
- package/dist/uuid.d.ts.map +1 -0
- package/dist/uuid.js +36 -0
- package/dist/uuid.js.map +1 -0
- package/dist/worker.d.ts +35 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +147 -0
- package/dist/worker.js.map +1 -0
- package/package.json +53 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { setImmediate } from 'node:timers/promises';
|
|
6
|
+
import { policies, scheduleKinds, } from 'pg-boss';
|
|
7
|
+
import { Latch } from '@zukhruf/async';
|
|
8
|
+
import { transaction } from '@zukhruf/sqlite-transaction';
|
|
9
|
+
import { Activity } from "./activity.js";
|
|
10
|
+
import { Attorney } from "./attorney.js";
|
|
11
|
+
import { DatabaseError } from "./database-error.js";
|
|
12
|
+
import { Database } from "./database.js";
|
|
13
|
+
import { InFlight } from "./in-flight.js";
|
|
14
|
+
import { JobFetcher } from "./job-fetcher.js";
|
|
15
|
+
import { JobRows } from "./job-rows.js";
|
|
16
|
+
import { JobSettler } from "./job-settler.js";
|
|
17
|
+
import { Jsonb } from "./jsonb.js";
|
|
18
|
+
import { LocalGroups } from "./local-groups.js";
|
|
19
|
+
import { QueueCache } from "./queue-cache.js";
|
|
20
|
+
import { QueueRows } from "./queue-rows.js";
|
|
21
|
+
import { Recurrence } from "./recurrence.js";
|
|
22
|
+
import { Row } from "./rows.js";
|
|
23
|
+
import { queueSchema } from "./schema.js";
|
|
24
|
+
import { SqlValues } from "./sql-values.js";
|
|
25
|
+
import { StartAfter } from "./start-after.js";
|
|
26
|
+
import { Supervisor } from "./supervisor.js";
|
|
27
|
+
import { Throttle } from "./throttle.js";
|
|
28
|
+
import { Timekeeper } from "./timekeeper.js";
|
|
29
|
+
import { Timers } from "./timers.js";
|
|
30
|
+
import { Uuid } from "./uuid.js";
|
|
31
|
+
import { Worker } from "./worker.js";
|
|
32
|
+
export { DatabaseError } from "./database-error.js";
|
|
33
|
+
export { UnsupportedStartAfterError } from "./start-after.js";
|
|
34
|
+
function toError(value) {
|
|
35
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
36
|
+
}
|
|
37
|
+
// pg-boss emits a spread copy of a worker's error, not the Error itself
|
|
38
|
+
// (node_modules/pg-boss/dist/manager.js:992-994): an Error's message, stack and
|
|
39
|
+
// name are non-enumerable, so the payload carries only the fields set here and
|
|
40
|
+
// is not `instanceof Error`.
|
|
41
|
+
function toWorkerEvent(error, queue, workerId) {
|
|
42
|
+
return spreadCopy(error, { queue, worker: workerId });
|
|
43
|
+
}
|
|
44
|
+
// pg-boss's spread copy of an error it emits (node_modules/pg-boss/dist/
|
|
45
|
+
// manager.js:846).
|
|
46
|
+
function toErrorEvent(error) {
|
|
47
|
+
return spreadCopy(toError(error), {});
|
|
48
|
+
}
|
|
49
|
+
// `{ ...error, message: error.message, stack: error.stack, ...extra }`.
|
|
50
|
+
// The stack is copied as is, also when an Error has none, a value Error's
|
|
51
|
+
// type cannot hold under exactOptionalPropertyTypes: the key is made in its
|
|
52
|
+
// place, then given the value.
|
|
53
|
+
function spreadCopy(error, extra) {
|
|
54
|
+
const copy = { ...error, message: error.message, stack: '', ...extra };
|
|
55
|
+
Object.defineProperty(copy, 'stack', { value: error.stack });
|
|
56
|
+
return copy;
|
|
57
|
+
}
|
|
58
|
+
function normalizeOptions(options) {
|
|
59
|
+
if (typeof options === 'string' || options === undefined) {
|
|
60
|
+
return { path: options ?? ':memory:' };
|
|
61
|
+
}
|
|
62
|
+
return { ...options, path: options.path ?? ':memory:' };
|
|
63
|
+
}
|
|
64
|
+
export class SqliteJobQueue extends EventEmitter {
|
|
65
|
+
// Like pg-boss (node_modules/pg-boss/dist/index.js:484-491): a `path` makes
|
|
66
|
+
// the queue own its database, opened by start() and refusing SQL until then
|
|
67
|
+
// (db.js:89); a caller-supplied `database` is used as is, like pg-boss's
|
|
68
|
+
// `db` adapter, so SQL before start() fails on the missing schema.
|
|
69
|
+
#connection;
|
|
70
|
+
#path;
|
|
71
|
+
#ownsDatabase;
|
|
72
|
+
// pg-boss's manager #stopped, set in start() and stop(), so work() before
|
|
73
|
+
// the first start() is allowed (node_modules/pg-boss/dist/
|
|
74
|
+
// manager.js:912-914).
|
|
75
|
+
#manager = { kind: 'unstarted' };
|
|
76
|
+
#clock;
|
|
77
|
+
#useListenNotify;
|
|
78
|
+
#warnedFetchOptions = new Set();
|
|
79
|
+
// pg-boss's notifier.available (node_modules/pg-boss/dist/notifier.js:28):
|
|
80
|
+
// this process is its own LISTEN/NOTIFY listener from start() to stop() when
|
|
81
|
+
// useListenNotify is on. Its wakes stay in this instance: another instance on
|
|
82
|
+
// the same file never hears them, where every pg-boss listener on a schema
|
|
83
|
+
// hears a NOTIFY.
|
|
84
|
+
#notifier = { kind: 'unavailable' };
|
|
85
|
+
// PgBoss's own #stopped and #started (node_modules/pg-boss/dist/
|
|
86
|
+
// index.js:64, 124, 256): armed before a start runs, so stop() can shut
|
|
87
|
+
// down what a start that threw partway left armed.
|
|
88
|
+
#lifecycle = { kind: 'stopped' };
|
|
89
|
+
#starting = null;
|
|
90
|
+
#stopping = null;
|
|
91
|
+
#activity = new Activity();
|
|
92
|
+
#attachment = null;
|
|
93
|
+
// When wip last went out, first read at construction
|
|
94
|
+
// (node_modules/pg-boss/dist/manager.js:194).
|
|
95
|
+
#wipTs;
|
|
96
|
+
#wipInterval;
|
|
97
|
+
#queueCache = new QueueCache();
|
|
98
|
+
#queueCacheIntervalSeconds;
|
|
99
|
+
#queueCacheInterval;
|
|
100
|
+
// The offWork() cleanups a stop waits for, each opened once it settles.
|
|
101
|
+
#pendingCleanups = new Set();
|
|
102
|
+
#localGroups = new LocalGroups();
|
|
103
|
+
#supervisor;
|
|
104
|
+
// attorney.js:412
|
|
105
|
+
#schedule;
|
|
106
|
+
#timekeeper;
|
|
107
|
+
workers = new Map();
|
|
108
|
+
constructor(options) {
|
|
109
|
+
super();
|
|
110
|
+
const normalized = normalizeOptions(options);
|
|
111
|
+
this.#connection =
|
|
112
|
+
normalized.database === undefined
|
|
113
|
+
? { kind: 'closed' }
|
|
114
|
+
: { kind: 'open', database: normalized.database };
|
|
115
|
+
this.#path = normalized.path ?? ':memory:';
|
|
116
|
+
this.#ownsDatabase = normalized.database === undefined;
|
|
117
|
+
// node_modules/pg-boss/dist/attorney.js:419, 421, 425
|
|
118
|
+
this.#clock = Attorney.applyClockConfig(normalized.clock);
|
|
119
|
+
const intervals = Attorney.applyOpsConfig(normalized);
|
|
120
|
+
const timekeeping = Attorney.applyScheduleConfig(normalized);
|
|
121
|
+
Attorney.validateWarningConfig(normalized);
|
|
122
|
+
this.#queueCacheIntervalSeconds = intervals.queueCacheIntervalSeconds;
|
|
123
|
+
this.#supervisor = new Supervisor(this.#clock, this.#activity, {
|
|
124
|
+
...intervals,
|
|
125
|
+
// attorney.js:413
|
|
126
|
+
supervise: 'supervise' in normalized ? normalized.supervise : true,
|
|
127
|
+
warningQueueSize: normalized.warningQueueSize,
|
|
128
|
+
});
|
|
129
|
+
// index.js:87-88, 101-104
|
|
130
|
+
this.#supervisor.on('error', (error) => this.emit('error', error));
|
|
131
|
+
this.#supervisor.on('warning', (warning) => this.emit('warning', warning));
|
|
132
|
+
this.#schedule = Boolean('schedule' in normalized ? normalized.schedule : true);
|
|
133
|
+
this.#timekeeper = new Timekeeper(this.#clock, this.#activity, timekeeping, {
|
|
134
|
+
createQueue: (name) => this.createQueue(name),
|
|
135
|
+
work: (name, options, handler) => this.work(name, options, handler),
|
|
136
|
+
offWork: (name, options) => this.offWork(name, options),
|
|
137
|
+
send: (request) => this.#send([request]),
|
|
138
|
+
insert: (name, jobs) => this.#insertSlotted(name, jobs),
|
|
139
|
+
});
|
|
140
|
+
// index.js:89
|
|
141
|
+
this.#timekeeper.on('error', (error) => this.emit('error', error));
|
|
142
|
+
this.#wipTs = this.#clock.now();
|
|
143
|
+
// node_modules/pg-boss/dist/attorney.js:416
|
|
144
|
+
this.#useListenNotify = normalized.useListenNotify ?? false;
|
|
145
|
+
}
|
|
146
|
+
#now() {
|
|
147
|
+
return new Date(this.#clock.now());
|
|
148
|
+
}
|
|
149
|
+
get db() {
|
|
150
|
+
assert(this.#connection.kind === 'open', 'Database not opened. Call open() before executing SQL.');
|
|
151
|
+
return this.#connection.database;
|
|
152
|
+
}
|
|
153
|
+
// node_modules/pg-boss/dist/index.js:106-132: a stop in flight finishes
|
|
154
|
+
// first, and a concurrent start shares the one in flight.
|
|
155
|
+
async start() {
|
|
156
|
+
if (this.#stopping) {
|
|
157
|
+
await this.#stopping.settled();
|
|
158
|
+
}
|
|
159
|
+
if (this.#starting) {
|
|
160
|
+
return this.#starting.result();
|
|
161
|
+
}
|
|
162
|
+
if (this.#lifecycle.kind === 'running') {
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
this.#lifecycle = { kind: 'armed' };
|
|
166
|
+
const starting = this.#doStart();
|
|
167
|
+
this.#starting = new InFlight(starting);
|
|
168
|
+
try {
|
|
169
|
+
return await starting;
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
this.#starting = null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// index.js:133-170: the clock attaches once the schema exists and before
|
|
176
|
+
// anything arms a timer.
|
|
177
|
+
async #doStart() {
|
|
178
|
+
if (this.#connection.kind === 'closed') {
|
|
179
|
+
this.#connection = {
|
|
180
|
+
kind: 'open',
|
|
181
|
+
database: new DatabaseSync(this.#path),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
this.applySchema();
|
|
185
|
+
if (Activity.isAttachable(this.#clock)) {
|
|
186
|
+
this.#attachment = await this.#activity.attach(this.#clock);
|
|
187
|
+
}
|
|
188
|
+
this.#manager = { kind: 'started' };
|
|
189
|
+
// manager.js:823-837: the cache refresh, then wip, both on the clock in
|
|
190
|
+
// that order, then the cache's first load.
|
|
191
|
+
this.#queueCacheInterval = this.#clock.setInterval(() => {
|
|
192
|
+
void this.#onCacheQueues(true);
|
|
193
|
+
}, this.#queueCacheIntervalSeconds * 1000);
|
|
194
|
+
this.#wipInterval = this.#clock.setInterval(() => this.#onWipInterval(), 2000);
|
|
195
|
+
await this.#onCacheQueues(false);
|
|
196
|
+
if (this.#useListenNotify) {
|
|
197
|
+
this.#notifier = { kind: 'available' };
|
|
198
|
+
await this.#activity.deliver(() => this.wakeNotifyQueueWorkers());
|
|
199
|
+
}
|
|
200
|
+
// index.js:159-164
|
|
201
|
+
this.#supervisor.start(this.db);
|
|
202
|
+
if (this.#schedule) {
|
|
203
|
+
await this.#timekeeper.start(this.db);
|
|
204
|
+
}
|
|
205
|
+
this.#lifecycle = { kind: 'running' };
|
|
206
|
+
return this;
|
|
207
|
+
}
|
|
208
|
+
// node_modules/pg-boss/dist/index.js:192-275.
|
|
209
|
+
async stop(options = {}) {
|
|
210
|
+
if (this.#starting) {
|
|
211
|
+
await this.#starting.settled();
|
|
212
|
+
}
|
|
213
|
+
if (this.#stopping) {
|
|
214
|
+
return this.#stopping.result();
|
|
215
|
+
}
|
|
216
|
+
const { close = true, graceful = true } = options;
|
|
217
|
+
// An instance not running is already stopped; asked to close, one that
|
|
218
|
+
// still holds its own database closes it.
|
|
219
|
+
if (this.#lifecycle.kind === 'stopped') {
|
|
220
|
+
if (close) {
|
|
221
|
+
this.#closeOwnDatabase();
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const timeout = Math.max(options.timeout ?? 30000, 1000);
|
|
226
|
+
const stopping = this.#doStop(close, graceful, timeout);
|
|
227
|
+
this.#stopping = new InFlight(stopping);
|
|
228
|
+
try {
|
|
229
|
+
return await stopping;
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
this.#stopping = null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async #doStop(close, graceful, timeout) {
|
|
236
|
+
// pg-boss closes its listener first, still listening through the close's
|
|
237
|
+
// I/O (node_modules/pg-boss/dist/index.js:228, notifier.js:70-77), so a
|
|
238
|
+
// fetch in flight lands before the workers stop and their group tracking
|
|
239
|
+
// is cleared.
|
|
240
|
+
if (this.#notifier.kind === 'available') {
|
|
241
|
+
await setImmediate();
|
|
242
|
+
}
|
|
243
|
+
this.#notifier = { kind: 'unavailable' };
|
|
244
|
+
// manager.js:866-880: refuse new work and let every worker wind down.
|
|
245
|
+
this.#manager = { kind: 'stopped' };
|
|
246
|
+
this.#clock.clearInterval(this.#queueCacheInterval);
|
|
247
|
+
this.#clock.clearInterval(this.#wipInterval);
|
|
248
|
+
// The send-it worker is the timekeeper's to stop (manager.js:872-874).
|
|
249
|
+
const names = new Set(Array.from(this.workers.values(), (w) => w.name).filter((name) => name !== Timekeeper.sendIt));
|
|
250
|
+
await Promise.allSettled(Array.from(names, (name) => this.offWork(name, { wait: false })));
|
|
251
|
+
this.#localGroups.clear();
|
|
252
|
+
// index.js:230-231
|
|
253
|
+
await this.#timekeeper.stop();
|
|
254
|
+
await this.#supervisor.stop();
|
|
255
|
+
// pg-boss awaits its notifier, timekeeper, navigator and bam stops here
|
|
256
|
+
// (node_modules/pg-boss/dist/index.js:227-233); in those turns
|
|
257
|
+
// an idle worker finishes stopping and leaves the workers failWip fails,
|
|
258
|
+
// even with the stale jobs a throwing batch left on it (worker.js:66-70).
|
|
259
|
+
await setImmediate();
|
|
260
|
+
const shutdown = async () => {
|
|
261
|
+
await this.#failWip();
|
|
262
|
+
const attachment = this.#attachment;
|
|
263
|
+
this.#attachment = null;
|
|
264
|
+
await attachment?.[Symbol.asyncDispose]();
|
|
265
|
+
if (close) {
|
|
266
|
+
this.#closeOwnDatabase();
|
|
267
|
+
}
|
|
268
|
+
this.#lifecycle = { kind: 'stopped' };
|
|
269
|
+
this.emit('stopped');
|
|
270
|
+
};
|
|
271
|
+
if (!graceful) {
|
|
272
|
+
await shutdown();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Real time, not the configured clock: the deadline bounds shutdown, and
|
|
276
|
+
// under a test clock nothing would tick it while stop() waits.
|
|
277
|
+
let deadlineTimer;
|
|
278
|
+
const deadline = new Promise((resolve) => {
|
|
279
|
+
deadlineTimer = setTimeout(resolve, timeout);
|
|
280
|
+
});
|
|
281
|
+
try {
|
|
282
|
+
await Promise.race([this.#settleCleanups(), deadline]);
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
clearTimeout(deadlineTimer);
|
|
286
|
+
}
|
|
287
|
+
await shutdown();
|
|
288
|
+
}
|
|
289
|
+
#closeOwnDatabase() {
|
|
290
|
+
if (this.#ownsDatabase && this.#connection.kind === 'open') {
|
|
291
|
+
this.#connection.database.close();
|
|
292
|
+
this.#connection = { kind: 'closed' };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// manager.js:884-909: fail each worker's batch, then abandon it. A failure
|
|
296
|
+
// is reported rather than thrown, so every worker is still reached.
|
|
297
|
+
async #failWip() {
|
|
298
|
+
for (const worker of this.workers.values()) {
|
|
299
|
+
const ids = worker.jobs.map((job) => job.id);
|
|
300
|
+
if (ids.length > 0) {
|
|
301
|
+
try {
|
|
302
|
+
this.#settle(worker.name, () => JobSettler.fail(this.db, worker.name, ids, 'pg-boss shut down while active', worker.jobs.map((job) => job.retry_count), this.#now()));
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
try {
|
|
306
|
+
this.emit('error', toWorkerEvent(toError(error), worker.name, worker.id));
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// An error event nobody listens for throws back out of emit();
|
|
310
|
+
// the remaining workers still have to be reached.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
worker.abort();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// manager.js:839-848: a refresh that fails keeps the cache it had, and only
|
|
318
|
+
// the interval's reports it.
|
|
319
|
+
async #onCacheQueues(emit) {
|
|
320
|
+
try {
|
|
321
|
+
const queues = await this.#activity.query(() => this.db
|
|
322
|
+
.prepare('SELECT * FROM pgboss_queue')
|
|
323
|
+
.all()
|
|
324
|
+
.map(QueueRows.decode));
|
|
325
|
+
this.#queueCache.replace(queues);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (emit) {
|
|
329
|
+
this.emit('error', toErrorEvent(error));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// manager.js:827-836: while a worker holds jobs, at least 2000 ms apart.
|
|
334
|
+
#onWipInterval() {
|
|
335
|
+
const now = this.#clock.now();
|
|
336
|
+
if (now - this.#wipTs < 2000) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const wip = this.#getWipData();
|
|
340
|
+
if (wip.some((worker) => worker.count > 0)) {
|
|
341
|
+
this.emit('wip', wip);
|
|
342
|
+
this.#wipTs = now;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// manager.js:1067-1075: before and after each batch, strictly more than
|
|
346
|
+
// 2000 ms apart, except the send-it worker's.
|
|
347
|
+
#emitWip(name) {
|
|
348
|
+
if (name === Timekeeper.sendIt) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const now = this.#clock.now();
|
|
352
|
+
if (now - this.#wipTs > 2000) {
|
|
353
|
+
this.emit('wip', this.#getWipData());
|
|
354
|
+
this.#wipTs = now;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// manager.js:1076-1081: the send-it worker is internal.
|
|
358
|
+
#getWipData() {
|
|
359
|
+
return Array.from(this.workers.values(), (worker) => worker.toWipData()).filter((worker) => worker.state !== 'stopped' && worker.name !== Timekeeper.sendIt);
|
|
360
|
+
}
|
|
361
|
+
// manager.js:1095-1125: stops the queue's workers, or one work() call's
|
|
362
|
+
// (by its id), and waits for their batches unless told not to.
|
|
363
|
+
async offWork(name, options = { wait: true }) {
|
|
364
|
+
assert(name, 'queue name is required');
|
|
365
|
+
assert(typeof name === 'string', 'queue name must be a string');
|
|
366
|
+
const workers = Array.from(this.workers.values()).filter((worker) => worker.name === name &&
|
|
367
|
+
(options?.id
|
|
368
|
+
? worker.id === options.id || worker.workId === options.id
|
|
369
|
+
: true));
|
|
370
|
+
if (workers.length === 0) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const cleanup = Promise.allSettled(workers.map(async (worker) => {
|
|
374
|
+
await worker.stop();
|
|
375
|
+
this.workers.delete(worker.id);
|
|
376
|
+
}));
|
|
377
|
+
if (options.wait) {
|
|
378
|
+
await cleanup;
|
|
379
|
+
this.#forgetLocalGroups(name);
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
const pending = new Latch();
|
|
383
|
+
this.#pendingCleanups.add(pending);
|
|
384
|
+
const settled = () => {
|
|
385
|
+
this.#pendingCleanups.delete(pending);
|
|
386
|
+
this.#forgetLocalGroups(name);
|
|
387
|
+
pending.open();
|
|
388
|
+
};
|
|
389
|
+
cleanup.then(settled, settled);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// manager.js:463-471: a queue's group tracking goes with its last worker
|
|
393
|
+
// neither stopping nor stopped.
|
|
394
|
+
#forgetLocalGroups(name) {
|
|
395
|
+
const live = Array.from(this.workers.values()).some((worker) => worker.name === name && !worker.stopping && !worker.stopped);
|
|
396
|
+
if (!live) {
|
|
397
|
+
this.#localGroups.forget(name);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// manager.js:1088-1094: resolves once no worker is still stopping.
|
|
401
|
+
async #settleCleanups() {
|
|
402
|
+
while (this.#pendingCleanups.size > 0) {
|
|
403
|
+
await Promise.all(Array.from(this.#pendingCleanups, (pending) => pending.wait()));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async createQueue(name, options = {}) {
|
|
407
|
+
// node_modules/pg-boss/dist/manager.js:2005-2013. `partition` is a pg-boss
|
|
408
|
+
// storage layout (a table per queue) with no observable effect, so it is
|
|
409
|
+
// accepted and has nothing to store here.
|
|
410
|
+
Attorney.assertQueueName(name);
|
|
411
|
+
const policy = options.policy || policies.standard;
|
|
412
|
+
assert(policy in policies, `${policy} is not a valid queue policy`);
|
|
413
|
+
Attorney.validateQueueArgs(options);
|
|
414
|
+
if (options.deadLetter) {
|
|
415
|
+
Attorney.assertQueueName(options.deadLetter);
|
|
416
|
+
assert.notStrictEqual(name, options.deadLetter, 'deadLetter cannot be itself');
|
|
417
|
+
this.getQueueOrThrow(options.deadLetter);
|
|
418
|
+
}
|
|
419
|
+
this.db
|
|
420
|
+
.prepare(`INSERT OR IGNORE INTO pgboss_queue (
|
|
421
|
+
name,
|
|
422
|
+
policy,
|
|
423
|
+
retry_limit,
|
|
424
|
+
retry_delay_seconds,
|
|
425
|
+
retry_backoff,
|
|
426
|
+
retry_delay_max_seconds,
|
|
427
|
+
expire_seconds,
|
|
428
|
+
retention_seconds,
|
|
429
|
+
deletion_seconds,
|
|
430
|
+
heartbeat_seconds,
|
|
431
|
+
dead_letter,
|
|
432
|
+
warning_queue_size,
|
|
433
|
+
notify
|
|
434
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
435
|
+
.run(name, policy, options.retryLimit ?? 2, options.retryDelay ?? 0, SqlValues.toSqlBool(options.retryBackoff) ?? 0, options.retryDelayMax ?? null, options.expireInSeconds ?? 900, options.retentionSeconds ?? 1_209_600, options.deleteAfterSeconds ?? 604_800, options.heartbeatSeconds ?? null, options.deadLetter ?? null, options.warningQueueSize ?? 0, SqlValues.toSqlBool(options.notify) ?? 0);
|
|
436
|
+
this.#queueCache.evict(name);
|
|
437
|
+
}
|
|
438
|
+
async updateQueue(name, options = {}) {
|
|
439
|
+
// pg-boss counts keys, not defined values, rejects the two settings fixed
|
|
440
|
+
// at creation, then validates every key present
|
|
441
|
+
// (node_modules/pg-boss/dist/manager.js:2049-2057).
|
|
442
|
+
Attorney.assertQueueName(name);
|
|
443
|
+
assert(Object.keys(options).length > 0, 'no properties found to update');
|
|
444
|
+
if ('policy' in options) {
|
|
445
|
+
throw new Error('queue policy cannot be changed after creation');
|
|
446
|
+
}
|
|
447
|
+
if ('partition' in options) {
|
|
448
|
+
throw new Error('queue partitioning cannot be changed after creation');
|
|
449
|
+
}
|
|
450
|
+
Attorney.validateQueueArgs(options);
|
|
451
|
+
// null clears the dead letter queue; only a name is checked
|
|
452
|
+
// (manager.js:2058-2064).
|
|
453
|
+
const { deadLetter } = options;
|
|
454
|
+
if (deadLetter !== null && deadLetter !== undefined) {
|
|
455
|
+
Attorney.assertQueueName(deadLetter);
|
|
456
|
+
assert.notStrictEqual(name, deadLetter, 'deadLetter cannot be itself');
|
|
457
|
+
}
|
|
458
|
+
const entries = [
|
|
459
|
+
...this.mapQueueUpdateOptions(options),
|
|
460
|
+
['updated_on', SqlValues.asIso(this.#now())],
|
|
461
|
+
];
|
|
462
|
+
const assignments = entries.map(([column]) => `${column} = ?`).join(', ');
|
|
463
|
+
const values = entries.map(([, value]) => value);
|
|
464
|
+
try {
|
|
465
|
+
this.db
|
|
466
|
+
.prepare(`UPDATE pgboss_queue
|
|
467
|
+
SET ${assignments}
|
|
468
|
+
WHERE name = ?`)
|
|
469
|
+
.run(...values, name);
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
if (Database.isForeignKeyViolation(error)) {
|
|
473
|
+
throw new DatabaseError('insert or update on table "queue" violates foreign key constraint "queue_dead_letter_fkey"', '23503');
|
|
474
|
+
}
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
this.#queueCache.evict(name);
|
|
478
|
+
}
|
|
479
|
+
async deleteQueue(name) {
|
|
480
|
+
Attorney.assertQueueName(name);
|
|
481
|
+
try {
|
|
482
|
+
this.db.prepare('DELETE FROM pgboss_queue WHERE name = ?').run(name);
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
if (!Database.isForeignKeyViolation(error)) {
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
// A queue's dead letter reference refuses the delete; a job's is
|
|
489
|
+
// RESTRICT (node_modules/pg-boss/dist/plans.js:321, 878-881).
|
|
490
|
+
const usedByQueue = this.db
|
|
491
|
+
.prepare('SELECT 1 FROM pgboss_queue WHERE dead_letter = ?')
|
|
492
|
+
.get(name);
|
|
493
|
+
throw usedByQueue === undefined
|
|
494
|
+
? new DatabaseError('update or delete on table "queue" violates RESTRICT setting of foreign key constraint "dlq_fkey" on table "job_common"', '23001')
|
|
495
|
+
: new DatabaseError('update or delete on table "queue" violates foreign key constraint "queue_dead_letter_fkey" on table "queue"', '23503');
|
|
496
|
+
}
|
|
497
|
+
this.#queueCache.evict(name);
|
|
498
|
+
}
|
|
499
|
+
// node_modules/pg-boss/dist/manager.js:1179-1182, 1232-1258.
|
|
500
|
+
async send(...args) {
|
|
501
|
+
return this.#send(args);
|
|
502
|
+
}
|
|
503
|
+
async #send(args) {
|
|
504
|
+
const { name, data, options } = Attorney.checkSendArgs(args);
|
|
505
|
+
// The insert is a statement like a fetch's claim: it runs when issued
|
|
506
|
+
// and its outcome comes back over I/O.
|
|
507
|
+
return this.#activity.query(() => {
|
|
508
|
+
const queue = this.getQueueOrThrow(name);
|
|
509
|
+
// node_modules/pg-boss/dist/manager.js:1238-1240
|
|
510
|
+
if (queue.policy === policies.key_strict_fifo && !options.singletonKey) {
|
|
511
|
+
throw new Error(`${policies.key_strict_fifo} queues require a singletonKey`);
|
|
512
|
+
}
|
|
513
|
+
// pg-boss sends the job as one JSON text that json_to_recordset reads
|
|
514
|
+
// (manager.js:1242, plans.js:2303), so the first escape no field can
|
|
515
|
+
// hold refuses the send, whichever field holds it.
|
|
516
|
+
Jsonb.assertEscapes(JobRows.payload(queue.name, data, options));
|
|
517
|
+
const now = this.#now();
|
|
518
|
+
const stored = this.#insertJob(queue.name, data, options, queue, now, {
|
|
519
|
+
startAfter: options.startAfter,
|
|
520
|
+
singletonOn: Throttle.slot(options.singletonSeconds, 0, now),
|
|
521
|
+
}) ??
|
|
522
|
+
// manager.js:1253-1256: a send a unique index refused retries once into
|
|
523
|
+
// the next throttle slot, starting when that slot does.
|
|
524
|
+
(options.singletonNextSlot
|
|
525
|
+
? this.#insertJob(queue.name, data, options, queue, now, {
|
|
526
|
+
startAfter: Throttle.debounceStartAfter(options.singletonSeconds, now),
|
|
527
|
+
singletonOn: Throttle.slot(options.singletonSeconds, options.singletonSeconds, now),
|
|
528
|
+
})
|
|
529
|
+
: null);
|
|
530
|
+
if (stored === null) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
// A notify queue's insert wakes the queue's workers when the stored job
|
|
534
|
+
// is due now (node_modules/pg-boss/dist/plans.js:2335-2350), heard as
|
|
535
|
+
// the insert runs (PGlite's src/pglite.ts:980-992 queues the NOTIFY
|
|
536
|
+
// then) and only while this process listens (notifier.js:57).
|
|
537
|
+
if (queue.notify &&
|
|
538
|
+
this.#notifier.kind === 'available' &&
|
|
539
|
+
stored.startAfter <= SqlValues.asIso(now)) {
|
|
540
|
+
this.notifyQueue(queue.name);
|
|
541
|
+
}
|
|
542
|
+
return stored.id;
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
// manager.js:1360-1440 with __singletonSlots, the cron pass's own insert:
|
|
546
|
+
// each job names the throttle slot it is filed in, and one a unique index
|
|
547
|
+
// refuses is skipped (plans.js:2258, 2329). One statement for the batch.
|
|
548
|
+
async #insertSlotted(name, jobs) {
|
|
549
|
+
await this.#activity.query(() => {
|
|
550
|
+
const queue = this.getQueueOrThrow(name);
|
|
551
|
+
const now = this.#now();
|
|
552
|
+
transaction(this.db, () => {
|
|
553
|
+
for (const job of jobs) {
|
|
554
|
+
this.#insertJob(queue.name, job.data, { singletonKey: job.singletonKey }, queue, now, { startAfter: undefined, singletonOn: job.singletonOn });
|
|
555
|
+
}
|
|
556
|
+
}, { mode: 'immediate' });
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
// One insert, returning the stored id and start_after; null when a unique
|
|
560
|
+
// index (policy or throttle) refuses it, like pg-boss's ON CONFLICT DO
|
|
561
|
+
// NOTHING (plans.js:2329).
|
|
562
|
+
#insertJob(name, data, options, queue, now, attempt) {
|
|
563
|
+
const startAfter = StartAfter.resolve(attempt.startAfter, now);
|
|
564
|
+
const retentionSeconds = options.retentionSeconds ?? queue.retention_seconds;
|
|
565
|
+
const keepUntil = SqlValues.addSeconds(startAfter, retentionSeconds);
|
|
566
|
+
let inserted;
|
|
567
|
+
try {
|
|
568
|
+
inserted = this.db
|
|
569
|
+
.prepare(`INSERT INTO pgboss_job (
|
|
570
|
+
id,
|
|
571
|
+
queue_name,
|
|
572
|
+
data,
|
|
573
|
+
policy,
|
|
574
|
+
priority,
|
|
575
|
+
retry_limit,
|
|
576
|
+
retry_delay_seconds,
|
|
577
|
+
retry_backoff,
|
|
578
|
+
retry_delay_max_seconds,
|
|
579
|
+
start_after,
|
|
580
|
+
singleton_key,
|
|
581
|
+
singleton_on,
|
|
582
|
+
expire_seconds,
|
|
583
|
+
retention_seconds,
|
|
584
|
+
deletion_seconds,
|
|
585
|
+
heartbeat_seconds,
|
|
586
|
+
created_on,
|
|
587
|
+
keep_until,
|
|
588
|
+
dead_letter,
|
|
589
|
+
group_id,
|
|
590
|
+
group_tier
|
|
591
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
592
|
+
ON CONFLICT DO NOTHING
|
|
593
|
+
RETURNING id, start_after`)
|
|
594
|
+
.get(options.id == null ? randomUUID() : Uuid.fromPayload(options.id), name,
|
|
595
|
+
// pg-boss sends the job as JSON.stringify([job]), where a payload
|
|
596
|
+
// JSON cannot hold drops to null (manager.js:1242).
|
|
597
|
+
JSON.stringify(data) ?? 'null', queue.policy, options.priority ?? 0, options.retryLimit ?? queue.retry_limit, options.retryDelay ?? queue.retry_delay_seconds, SqlValues.toSqlBool(options.retryBackoff) ?? queue.retry_backoff, options.retryDelayMax ?? queue.retry_delay_max_seconds, SqlValues.asIso(startAfter), options.singletonKey ?? null, attempt.singletonOn, options.expireInSeconds ?? queue.expire_seconds, retentionSeconds, options.deleteAfterSeconds ?? queue.deletion_seconds, options.heartbeatSeconds ?? queue.heartbeat_seconds, SqlValues.asIso(now), SqlValues.asIso(keepUntil), options.deadLetter ?? queue.dead_letter, options.group?.id ?? null, options.group?.tier ?? null);
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
// The queue is already checked, so a refused reference is the dead
|
|
601
|
+
// letter queue's (plans.js:878-881).
|
|
602
|
+
if (Database.isForeignKeyViolation(error)) {
|
|
603
|
+
throw new DatabaseError('insert or update on table "job_common" violates foreign key constraint "dlq_fkey"', '23503');
|
|
604
|
+
}
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
return inserted === undefined
|
|
608
|
+
? null
|
|
609
|
+
: {
|
|
610
|
+
id: Row.readString(inserted, 'id'),
|
|
611
|
+
startAfter: Row.readString(inserted, 'start_after'),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
async work(name, ...args) {
|
|
615
|
+
// The arguments are checked before the stopped state, as pg-boss does
|
|
616
|
+
// (node_modules/pg-boss/dist/manager.js:910-914).
|
|
617
|
+
const { pollingInterval, notifyPollingInterval } = Attorney.checkWorkArgs(name, args);
|
|
618
|
+
if (this.#manager.kind === 'stopped') {
|
|
619
|
+
throw new Error('Workers are disabled. pg-boss is stopped');
|
|
620
|
+
}
|
|
621
|
+
const handler = args.length === 1 ? args[0] : args[1];
|
|
622
|
+
const options = args.length === 1 ? {} : args[0] || {};
|
|
623
|
+
// checkWorkArgs' copy, own properties only, which work() reads and every
|
|
624
|
+
// worker of this call reports in wip (node_modules/pg-boss/dist/
|
|
625
|
+
// attorney.js:370, manager.js:911-915).
|
|
626
|
+
const workerOptions = {
|
|
627
|
+
...options,
|
|
628
|
+
pollingInterval,
|
|
629
|
+
notifyPollingInterval,
|
|
630
|
+
};
|
|
631
|
+
// node_modules/pg-boss/dist/manager.js:915-1004. Read once, as pg-boss
|
|
632
|
+
// destructures them here (915).
|
|
633
|
+
const { batchSize = 1, localConcurrency = 1, burstWhenReadyExceeds, burstWhenBatchFull = false, includeMetadata = false, priority, orderByCreatedOn, heartbeatRefreshSeconds, minPriority, maxPriority, perJobResults = false, groupConcurrency, localGroupConcurrency, transactional = false, } = workerOptions;
|
|
634
|
+
// pg-boss refuses a transactional worker unless its db adapter can open a
|
|
635
|
+
// transaction (manager.js:919-920); SQLite's connection never can, so it
|
|
636
|
+
// refuses every one, as pg-boss does on an adapter without
|
|
637
|
+
// beginTransaction.
|
|
638
|
+
assert(!transactional, 'transactional workers require a database connection pg-boss can open a transaction on: the built-in pool, or a db adapter implementing beginTransaction');
|
|
639
|
+
if (localGroupConcurrency != null) {
|
|
640
|
+
this.#localGroups.configure(name, localGroupConcurrency);
|
|
641
|
+
}
|
|
642
|
+
// The queue's notify flag as the last fetch read it (false when that read
|
|
643
|
+
// threw). pg-boss reads it from a queue cache its fetch fills
|
|
644
|
+
// (manager.js:850-864, 930), so choosing the wait does no I/O that could
|
|
645
|
+
// fail outside the loop's catch.
|
|
646
|
+
let queueNotifies = false;
|
|
647
|
+
// The wait before each fetch: none after a full batch while the cached
|
|
648
|
+
// ready count exceeds burstWhenReadyExceeds or when burstWhenBatchFull
|
|
649
|
+
// asks (ignored at batchSize 1, where every fetch is full), the notify
|
|
650
|
+
// backstop while this process hears the queue's notifications, else the
|
|
651
|
+
// base poll (manager.js:928-949). The ready count is the monitor's, as the
|
|
652
|
+
// cache last read it, and 0 for a queue it holds no entry for (932).
|
|
653
|
+
const resolveInterval = (lastFetchCount) => {
|
|
654
|
+
const readyCount = this.#queueCache.peek(name)?.ready_count ?? 0;
|
|
655
|
+
const burst = lastFetchCount >= batchSize &&
|
|
656
|
+
((burstWhenReadyExceeds !== undefined &&
|
|
657
|
+
readyCount > burstWhenReadyExceeds) ||
|
|
658
|
+
(burstWhenBatchFull && batchSize > 1));
|
|
659
|
+
if (burst) {
|
|
660
|
+
return 0;
|
|
661
|
+
}
|
|
662
|
+
return this.#notifier.kind === 'available' && queueNotifies
|
|
663
|
+
? notifyPollingInterval
|
|
664
|
+
: pollingInterval;
|
|
665
|
+
};
|
|
666
|
+
const firstWorkerId = randomUUID();
|
|
667
|
+
for (let index = 0; index < localConcurrency; index++) {
|
|
668
|
+
const workerId = index === 0 ? firstWorkerId : randomUUID();
|
|
669
|
+
const worker = new Worker({
|
|
670
|
+
id: workerId,
|
|
671
|
+
workId: firstWorkerId,
|
|
672
|
+
name,
|
|
673
|
+
options: workerOptions,
|
|
674
|
+
clock: this.#clock,
|
|
675
|
+
resolveInterval,
|
|
676
|
+
// pg-boss's claim runs as it is issued, ahead of any statement
|
|
677
|
+
// issued after it, and comes back over I/O, so work() resolves
|
|
678
|
+
// before its first fetch lands (manager.js:1575-1577).
|
|
679
|
+
fetch: () => {
|
|
680
|
+
// Read as the fetch starts, before its I/O (manager.js:951-955).
|
|
681
|
+
const ignoreGroups = localGroupConcurrency == null
|
|
682
|
+
? undefined
|
|
683
|
+
: this.#localGroups.atCapacity(name);
|
|
684
|
+
return this.#activity.query(() => {
|
|
685
|
+
this.#warnDeprecatedFetchOptions({ priority, orderByCreatedOn });
|
|
686
|
+
queueNotifies = false;
|
|
687
|
+
// pg-boss checks the queue on every fetch, not when work()
|
|
688
|
+
// registers (node_modules/pg-boss/dist/manager.js:849-861).
|
|
689
|
+
const queue = this.getQueueOrThrow(name);
|
|
690
|
+
queueNotifies = queue.notify;
|
|
691
|
+
return JobFetcher.claim(this.db, queue, {
|
|
692
|
+
batchSize,
|
|
693
|
+
minPriority,
|
|
694
|
+
maxPriority,
|
|
695
|
+
groupConcurrency,
|
|
696
|
+
ignoreGroups,
|
|
697
|
+
// The active keys the monitor last counted, as cached
|
|
698
|
+
// (manager.js:1564-1572).
|
|
699
|
+
ignoreSingletons: this.#queueCache.fill(queue).singletons_active,
|
|
700
|
+
}, this.#now());
|
|
701
|
+
});
|
|
702
|
+
},
|
|
703
|
+
onFetch: async (rows) => {
|
|
704
|
+
if (rows.length === 0) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
this.#emitWip(name);
|
|
708
|
+
const run = (jobs) => this.processJobs(name, jobs, handler, includeMetadata, worker, heartbeatRefreshSeconds, perJobResults);
|
|
709
|
+
if (localGroupConcurrency == null) {
|
|
710
|
+
await run(rows);
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
// manager.js:970-988: the jobs over their group's local limit go
|
|
714
|
+
// back to created before the handler sees the rest.
|
|
715
|
+
const { allowed, excess, grouped } = this.#localGroups.start(name, rows);
|
|
716
|
+
try {
|
|
717
|
+
if (excess.length > 0) {
|
|
718
|
+
// The handler waits for the restore's I/O, as for the
|
|
719
|
+
// fetch's.
|
|
720
|
+
await this.#activity.query(() => this.#settle(name, () => JobSettler.restore(this.db, name, excess.map((row) => row.id))));
|
|
721
|
+
// Shutdown fails only the jobs this worker still holds.
|
|
722
|
+
worker.jobs = allowed;
|
|
723
|
+
}
|
|
724
|
+
if (allowed.length > 0) {
|
|
725
|
+
await run(allowed);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
finally {
|
|
729
|
+
this.#localGroups.end(name, grouped);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
this.#emitWip(name);
|
|
733
|
+
},
|
|
734
|
+
onError: (error) => {
|
|
735
|
+
this.emit('error', toWorkerEvent(error, name, workerId));
|
|
736
|
+
},
|
|
737
|
+
});
|
|
738
|
+
this.workers.set(workerId, worker);
|
|
739
|
+
worker.start();
|
|
740
|
+
}
|
|
741
|
+
return firstWorkerId;
|
|
742
|
+
}
|
|
743
|
+
async findJobs(name, options = {}) {
|
|
744
|
+
Attorney.assertQueueName(name);
|
|
745
|
+
this.getQueueOrThrow(name);
|
|
746
|
+
const where = ['queue_name = ?'];
|
|
747
|
+
const values = [name];
|
|
748
|
+
if (options.id !== undefined) {
|
|
749
|
+
where.push('id = ?');
|
|
750
|
+
values.push(Uuid.parameter(options.id));
|
|
751
|
+
}
|
|
752
|
+
if (options.key !== undefined) {
|
|
753
|
+
where.push('singleton_key = ?');
|
|
754
|
+
values.push(options.key);
|
|
755
|
+
}
|
|
756
|
+
if (options.queued) {
|
|
757
|
+
where.push("state IN ('created', 'retry')");
|
|
758
|
+
}
|
|
759
|
+
const rows = this.db
|
|
760
|
+
.prepare(`SELECT * FROM pgboss_job
|
|
761
|
+
WHERE ${where.join(' AND ')}
|
|
762
|
+
ORDER BY created_on, id`)
|
|
763
|
+
.all(...values)
|
|
764
|
+
.map(JobRows.decode);
|
|
765
|
+
const filteredRows = options.data === undefined
|
|
766
|
+
? rows
|
|
767
|
+
: rows.filter((row) => Jsonb.contains(JSON.parse(row.data), options.data));
|
|
768
|
+
return filteredRows.map((row) => JobRows.toFound(row));
|
|
769
|
+
}
|
|
770
|
+
async cancel(name, id) {
|
|
771
|
+
Attorney.assertQueueName(name);
|
|
772
|
+
const { ids, attempts } = Attorney.mapAttemptArg(id, 'cancel');
|
|
773
|
+
this.getQueueOrThrow(name);
|
|
774
|
+
return JobSettler.response(ids, JobSettler.cancel(this.db, name, ids, attempts, this.#now()));
|
|
775
|
+
}
|
|
776
|
+
async fail(name, id, data) {
|
|
777
|
+
Attorney.assertQueueName(name);
|
|
778
|
+
const { ids, attempts } = Attorney.mapAttemptArg(id, 'fail');
|
|
779
|
+
this.getQueueOrThrow(name);
|
|
780
|
+
return JobSettler.response(ids, JobSettler.fail(this.db, name, ids, data, attempts, this.#now()));
|
|
781
|
+
}
|
|
782
|
+
// node_modules/pg-boss/dist/timekeeper.js:812-849
|
|
783
|
+
async schedule(name, cron, data, options = {}) {
|
|
784
|
+
const { tz: requestedTz, key = '', missed, ...rest } = options;
|
|
785
|
+
const tz = requestedTz || 'UTC';
|
|
786
|
+
const kind = Recurrence.isRrule(cron)
|
|
787
|
+
? scheduleKinds.rrule
|
|
788
|
+
: scheduleKinds.cron;
|
|
789
|
+
Recurrence.assertRecurrence(cron, tz);
|
|
790
|
+
const now = this.#now();
|
|
791
|
+
if (kind === scheduleKinds.rrule) {
|
|
792
|
+
Recurrence.assertRruleSends(cron, tz, now);
|
|
793
|
+
}
|
|
794
|
+
Attorney.checkSendArgs([name, data, { ...rest }]);
|
|
795
|
+
Attorney.assertKey(key);
|
|
796
|
+
Recurrence.assertMissedPolicy(missed);
|
|
797
|
+
try {
|
|
798
|
+
// plans.js:1337-1349
|
|
799
|
+
this.db
|
|
800
|
+
.prepare(`INSERT INTO pgboss_schedule (
|
|
801
|
+
queue_name,
|
|
802
|
+
key,
|
|
803
|
+
kind,
|
|
804
|
+
cron,
|
|
805
|
+
timezone,
|
|
806
|
+
data,
|
|
807
|
+
options,
|
|
808
|
+
created_on,
|
|
809
|
+
updated_on
|
|
810
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
811
|
+
ON CONFLICT(queue_name, key) DO UPDATE SET
|
|
812
|
+
kind = excluded.kind,
|
|
813
|
+
cron = excluded.cron,
|
|
814
|
+
timezone = excluded.timezone,
|
|
815
|
+
data = excluded.data,
|
|
816
|
+
options = excluded.options,
|
|
817
|
+
updated_on = excluded.updated_on`)
|
|
818
|
+
.run(name, SqlValues.text(key), kind, SqlValues.text(cron), tz, data == null ? null : Jsonb.parameter(data), Jsonb.parameter(options), SqlValues.asIso(now), SqlValues.asIso(now));
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
// timekeeper.js:844-847 names the queue a refused reference points
|
|
822
|
+
// at; Postgres names the column a null violates.
|
|
823
|
+
if (Database.isForeignKeyViolation(error)) {
|
|
824
|
+
throw new DatabaseError(`Queue ${name} not found`, '23503');
|
|
825
|
+
}
|
|
826
|
+
const column = Database.notNullColumn(error);
|
|
827
|
+
if (column !== undefined) {
|
|
828
|
+
throw new DatabaseError(`null value in column "${column}" of relation "schedule" violates not-null constraint`, '23502');
|
|
829
|
+
}
|
|
830
|
+
throw error;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
// timekeeper.js:850-853, plans.js:1350-1356
|
|
834
|
+
async unschedule(name, key = '') {
|
|
835
|
+
this.db
|
|
836
|
+
.prepare(`DELETE FROM pgboss_schedule
|
|
837
|
+
WHERE queue_name = ? AND key = ?`)
|
|
838
|
+
.run(SqlValues.text(name), SqlValues.text(key));
|
|
839
|
+
}
|
|
840
|
+
applySchema() {
|
|
841
|
+
this.db.exec('PRAGMA foreign_keys = ON');
|
|
842
|
+
this.db.exec('PRAGMA busy_timeout = 5000');
|
|
843
|
+
queueSchema.apply(this.db);
|
|
844
|
+
// The cron claim's one row (plans.js:288-298).
|
|
845
|
+
this.db.exec('INSERT OR IGNORE INTO pgboss_version (singleton) VALUES (1)');
|
|
846
|
+
}
|
|
847
|
+
mapQueueUpdateOptions(options) {
|
|
848
|
+
const entries = [];
|
|
849
|
+
this.pushOption(entries, 'retry_limit', options.retryLimit);
|
|
850
|
+
this.pushOption(entries, 'retry_delay_seconds', options.retryDelay);
|
|
851
|
+
this.pushOption(entries, 'retry_backoff', SqlValues.toSqlBool(options.retryBackoff));
|
|
852
|
+
this.pushClearableOption(entries, 'retry_delay_max_seconds', options.retryDelayMax);
|
|
853
|
+
this.pushOption(entries, 'expire_seconds', options.expireInSeconds);
|
|
854
|
+
this.pushOption(entries, 'retention_seconds', options.retentionSeconds);
|
|
855
|
+
this.pushOption(entries, 'deletion_seconds', options.deleteAfterSeconds);
|
|
856
|
+
this.pushClearableOption(entries, 'heartbeat_seconds', options.heartbeatSeconds);
|
|
857
|
+
this.pushClearableOption(entries, 'dead_letter', options.deadLetter);
|
|
858
|
+
this.pushOption(entries, 'warning_queue_size', options.warningQueueSize);
|
|
859
|
+
this.pushOption(entries, 'notify', SqlValues.toSqlBool(options.notify));
|
|
860
|
+
return entries;
|
|
861
|
+
}
|
|
862
|
+
// COALESCE((o.data->>key)::type, column) (node_modules/pg-boss/dist/
|
|
863
|
+
// plans.js:1159-1161, 1165-1168, 1172): a null keeps the stored value, as
|
|
864
|
+
// an absent key does.
|
|
865
|
+
pushOption(entries, column, value) {
|
|
866
|
+
if (value !== undefined && value !== null) {
|
|
867
|
+
entries.push([column, value]);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
// CASE WHEN (o.data -> key) IS NOT NULL (plans.js:1162-1164, 1169-1171,
|
|
871
|
+
// 1173-1175): a present key is written, so null clears the column.
|
|
872
|
+
pushClearableOption(entries, column, value) {
|
|
873
|
+
if (value !== undefined) {
|
|
874
|
+
entries.push([column, value]);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
findQueue(name) {
|
|
878
|
+
const queue = this.db
|
|
879
|
+
.prepare('SELECT * FROM pgboss_queue WHERE name = ?')
|
|
880
|
+
.get(name);
|
|
881
|
+
return queue === undefined ? undefined : QueueRows.decode(queue);
|
|
882
|
+
}
|
|
883
|
+
// pg-boss looks a queue up through getQueueCache, whose cache miss validates
|
|
884
|
+
// the name first and fills the cache (node_modules/pg-boss/dist/
|
|
885
|
+
// manager.js:849-861, 2028-2033).
|
|
886
|
+
getQueueOrThrow(name) {
|
|
887
|
+
Attorney.assertQueueName(name);
|
|
888
|
+
const queue = this.findQueue(name);
|
|
889
|
+
if (!queue) {
|
|
890
|
+
throw new Error(`Queue ${name} does not exist`);
|
|
891
|
+
}
|
|
892
|
+
this.#queueCache.fill(queue);
|
|
893
|
+
return queue;
|
|
894
|
+
}
|
|
895
|
+
// A DeprecationWarning, not a queue event, once per option per instance and
|
|
896
|
+
// only when the option is set to false (node_modules/pg-boss/dist/
|
|
897
|
+
// manager.js:133-139, 1552-1559).
|
|
898
|
+
#warnDeprecatedFetchOptions(options) {
|
|
899
|
+
for (const option of ['priority', 'orderByCreatedOn']) {
|
|
900
|
+
if (options[option] === false && !this.#warnedFetchOptions.has(option)) {
|
|
901
|
+
this.#warnedFetchOptions.add(option);
|
|
902
|
+
process.emitWarning(`${option}: false is deprecated and now ignored. Jobs are always fetched in priority and creation order. Remove it; it will be rejected in the next major.`, 'DeprecationWarning', 'PGBOSS_DEP_FETCH_SORT');
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
// Wakes every worker of a queue, busy ones included, so each fetches now
|
|
907
|
+
// instead of waiting out its poll (node_modules/pg-boss/dist/manager.js:
|
|
908
|
+
// 1137-1143).
|
|
909
|
+
notifyQueue(name) {
|
|
910
|
+
for (const worker of this.workers.values()) {
|
|
911
|
+
if (worker.name === name) {
|
|
912
|
+
worker.notify();
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// PGlite's listener reports a connect once it subscribes, and pg-boss then
|
|
917
|
+
// wakes every worker of a notify queue to fetch what it may have missed
|
|
918
|
+
// (node_modules/pg-boss/dist/adapters/pglite.js:144-152,
|
|
919
|
+
// manager.js:1144-1152).
|
|
920
|
+
wakeNotifyQueueWorkers() {
|
|
921
|
+
for (const worker of this.workers.values()) {
|
|
922
|
+
if (this.findQueue(worker.name)?.notify) {
|
|
923
|
+
worker.notify();
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// node_modules/pg-boss/dist/manager.js:513-663, without transactions, which
|
|
928
|
+
// slice 8 ports. The handler races a deadline of the batch's largest
|
|
929
|
+
// expireInSeconds on a controller every job's signal follows; the race aborts
|
|
930
|
+
// it when it settles. A job's signal also follows its own claim, which a
|
|
931
|
+
// heartbeat aborts once the claim is gone.
|
|
932
|
+
async processJobs(name, rows, handler, includeMetadata, worker, heartbeatRefreshSeconds, perJobResults) {
|
|
933
|
+
const ids = rows.map((row) => row.id);
|
|
934
|
+
// work() settles with the jobs it claimed, fenced to their attempts
|
|
935
|
+
// (manager.js:628, 652).
|
|
936
|
+
const attempts = rows.map((row) => row.retry_count);
|
|
937
|
+
const maxExpiration = rows.reduce((max, row) => Math.max(max, row.expire_seconds), 0);
|
|
938
|
+
const batch = new AbortController();
|
|
939
|
+
worker.abortController = batch;
|
|
940
|
+
const claimed = rows.map((row) => ({ row, claim: new AbortController() }));
|
|
941
|
+
// manager.js:529-530: the batch's signal or the job's own claim.
|
|
942
|
+
const jobs = claimed.map(({ row, claim }) => JobRows.toJob(row, includeMetadata, AbortSignal.any([batch.signal, claim.signal])));
|
|
943
|
+
const heartbeatTimer = this.#startHeartbeat(name, claimed, heartbeatRefreshSeconds);
|
|
944
|
+
try {
|
|
945
|
+
const result = await Timers.resolveWithinSeconds(this.#clock, handler(jobs), maxExpiration, `handler execution exceeded ${maxExpiration}s`, batch);
|
|
946
|
+
if (perJobResults) {
|
|
947
|
+
this.#settlePerJob(name, rows, result);
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
// The result is stored only for a batch of one (manager.js:628).
|
|
951
|
+
this.#settle(name, () => JobSettler.complete(this.db, name, ids, attempts, ids.length === 1 ? result : undefined, this.#now()));
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
this.#settle(name, () => JobSettler.fail(this.db, name, ids, error, attempts, this.#now()));
|
|
956
|
+
}
|
|
957
|
+
finally {
|
|
958
|
+
if (heartbeatTimer) {
|
|
959
|
+
this.#clock.clearInterval(heartbeatTimer);
|
|
960
|
+
}
|
|
961
|
+
worker.abortController = null;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
// manager.js:355-405: the handler's results settle each job of the batch.
|
|
965
|
+
// Entries without an id and status, naming no job of the batch by its exact
|
|
966
|
+
// id, or carrying another status are ignored, the last valid entry per job
|
|
967
|
+
// wins, and a job without one fails. The completed, failed and dead-lettered jobs then
|
|
968
|
+
// settle in that order, each group on its own (393-396), so a group that
|
|
969
|
+
// throws leaves the groups before it settled.
|
|
970
|
+
#settlePerJob(name, rows, result) {
|
|
971
|
+
if (!Array.isArray(result)) {
|
|
972
|
+
this.#settle(name, () => JobSettler.fail(this.db, name, rows.map((row) => row.id), new Error('perJobResults handler must resolve with an array of job results'), rows.map((row) => row.retry_count), this.#now()));
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
// Keyed by whatever id an entry names: only the batch's own ids are read.
|
|
976
|
+
const dispositions = new Map();
|
|
977
|
+
for (const item of result) {
|
|
978
|
+
if ((typeof item === 'object' || typeof item === 'function') &&
|
|
979
|
+
item !== null &&
|
|
980
|
+
'id' in item &&
|
|
981
|
+
'status' in item &&
|
|
982
|
+
(item.status === 'completed' ||
|
|
983
|
+
item.status === 'failed' ||
|
|
984
|
+
item.status === 'deadletter')) {
|
|
985
|
+
dispositions.set(item.id, item);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
const completed = [];
|
|
989
|
+
const failed = [];
|
|
990
|
+
const deadLettered = [];
|
|
991
|
+
for (const row of rows) {
|
|
992
|
+
const item = dispositions.get(row.id);
|
|
993
|
+
const status = item && 'status' in item ? item.status : undefined;
|
|
994
|
+
const outcome = {
|
|
995
|
+
id: row.id,
|
|
996
|
+
retryCount: row.retry_count,
|
|
997
|
+
output: item && 'output' in item ? item.output : undefined,
|
|
998
|
+
};
|
|
999
|
+
if (status === 'completed') {
|
|
1000
|
+
completed.push(outcome);
|
|
1001
|
+
}
|
|
1002
|
+
else if (status === 'failed') {
|
|
1003
|
+
failed.push(outcome);
|
|
1004
|
+
}
|
|
1005
|
+
else if (status === 'deadletter') {
|
|
1006
|
+
deadLettered.push(outcome);
|
|
1007
|
+
}
|
|
1008
|
+
else {
|
|
1009
|
+
failed.push({
|
|
1010
|
+
...outcome,
|
|
1011
|
+
output: new Error('no disposition returned by handler'),
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
// An empty group runs no statement (manager.js:394-396).
|
|
1016
|
+
if (completed.length > 0) {
|
|
1017
|
+
this.#settle(name, () => JobSettler.completeWithOutputs(this.db, name, completed, this.#now()));
|
|
1018
|
+
}
|
|
1019
|
+
if (failed.length > 0) {
|
|
1020
|
+
this.#settle(name, () => JobSettler.failWithOutputs(this.db, name, failed, false, this.#now()));
|
|
1021
|
+
}
|
|
1022
|
+
if (deadLettered.length > 0) {
|
|
1023
|
+
this.#settle(name, () => JobSettler.failWithOutputs(this.db, name, deadLettered, true, this.#now()));
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
// A worker's settle looks its queue up first, as pg-boss's complete(),
|
|
1027
|
+
// fail(), restore() and per-job settles do (manager.js:411, 430, 1641,
|
|
1028
|
+
// 1680, 1979), so a queue deleted under a running batch makes the settle
|
|
1029
|
+
// throw.
|
|
1030
|
+
#settle(name, write) {
|
|
1031
|
+
this.getQueueOrThrow(name);
|
|
1032
|
+
return write();
|
|
1033
|
+
}
|
|
1034
|
+
// manager.js:520-565: every heartbeatRefreshSeconds, or half the batch's
|
|
1035
|
+
// shortest heartbeatSeconds, touch the attempts still held; a job the touch
|
|
1036
|
+
// no longer finds active under this claim loses its own signal. Without a
|
|
1037
|
+
// heartbeat nothing is touched or aborted.
|
|
1038
|
+
#startHeartbeat(name, claimed, heartbeatRefreshSeconds) {
|
|
1039
|
+
const candidates = claimed
|
|
1040
|
+
.map(({ row }) => row.heartbeat_seconds ?? 0)
|
|
1041
|
+
.filter((seconds) => seconds > 0);
|
|
1042
|
+
if (candidates.length === 0) {
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
const refreshSeconds = heartbeatRefreshSeconds ?? Math.min(...candidates) / 2;
|
|
1046
|
+
return this.#clock.setInterval(() => {
|
|
1047
|
+
const held = claimed.filter(({ claim }) => !claim.signal.aborted);
|
|
1048
|
+
if (held.length === 0) {
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
let refreshed;
|
|
1052
|
+
try {
|
|
1053
|
+
this.getQueueOrThrow(name);
|
|
1054
|
+
refreshed = JobSettler.touch(this.db, name, held.map(({ row }) => row), this.#now());
|
|
1055
|
+
}
|
|
1056
|
+
catch (error) {
|
|
1057
|
+
// A heartbeat that could not run says nothing about the claim.
|
|
1058
|
+
this.emit('error', toError(error));
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
for (const { row, claim } of held) {
|
|
1062
|
+
if (!refreshed.has(row.id)) {
|
|
1063
|
+
claim.abort(new Error(`job ${row.id} is no longer active under this worker's claim`));
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}, refreshSeconds * 1000);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
//# sourceMappingURL=index.js.map
|