@zukhruf/sqlite-queue 0.0.0-stage → 0.3.42

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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -2
  3. package/dist/activity.d.ts +10 -0
  4. package/dist/activity.d.ts.map +1 -0
  5. package/dist/activity.js +93 -0
  6. package/dist/activity.js.map +1 -0
  7. package/dist/attorney.d.ts +35 -0
  8. package/dist/attorney.d.ts.map +1 -0
  9. package/dist/attorney.js +367 -0
  10. package/dist/attorney.js.map +1 -0
  11. package/dist/claim-timer.d.ts +8 -0
  12. package/dist/claim-timer.d.ts.map +1 -0
  13. package/dist/claim-timer.js +80 -0
  14. package/dist/claim-timer.js.map +1 -0
  15. package/dist/completion-data.d.ts +6 -0
  16. package/dist/completion-data.d.ts.map +1 -0
  17. package/dist/completion-data.js +36 -0
  18. package/dist/completion-data.js.map +1 -0
  19. package/dist/database-error.d.ts +6 -0
  20. package/dist/database-error.d.ts.map +1 -0
  21. package/dist/database-error.js +12 -0
  22. package/dist/database-error.js.map +1 -0
  23. package/dist/database.d.ts +7 -0
  24. package/dist/database.d.ts.map +1 -0
  25. package/dist/database.js +28 -0
  26. package/dist/database.js.map +1 -0
  27. package/dist/in-flight.d.ts +7 -0
  28. package/dist/in-flight.d.ts.map +1 -0
  29. package/dist/in-flight.js +24 -0
  30. package/dist/in-flight.js.map +1 -0
  31. package/dist/index.d.ts +54 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +1069 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/job-fetcher.d.ts +16 -0
  36. package/dist/job-fetcher.d.ts.map +1 -0
  37. package/dist/job-fetcher.js +227 -0
  38. package/dist/job-fetcher.js.map +1 -0
  39. package/dist/job-queue.d.ts +27 -0
  40. package/dist/job-queue.d.ts.map +1 -0
  41. package/dist/job-queue.js +2 -0
  42. package/dist/job-queue.js.map +1 -0
  43. package/dist/job-rows.d.ts +49 -0
  44. package/dist/job-rows.d.ts.map +1 -0
  45. package/dist/job-rows.js +155 -0
  46. package/dist/job-rows.js.map +1 -0
  47. package/dist/job-settler.d.ts +24 -0
  48. package/dist/job-settler.d.ts.map +1 -0
  49. package/dist/job-settler.js +240 -0
  50. package/dist/job-settler.js.map +1 -0
  51. package/dist/jsonb.d.ts +7 -0
  52. package/dist/jsonb.d.ts.map +1 -0
  53. package/dist/jsonb.js +85 -0
  54. package/dist/jsonb.js.map +1 -0
  55. package/dist/local-groups.d.ts +16 -0
  56. package/dist/local-groups.d.ts.map +1 -0
  57. package/dist/local-groups.js +113 -0
  58. package/dist/local-groups.js.map +1 -0
  59. package/dist/naive-timestamp.d.ts +6 -0
  60. package/dist/naive-timestamp.d.ts.map +1 -0
  61. package/dist/naive-timestamp.js +21 -0
  62. package/dist/naive-timestamp.js.map +1 -0
  63. package/dist/queue-cache.d.ts +9 -0
  64. package/dist/queue-cache.d.ts.map +1 -0
  65. package/dist/queue-cache.js +27 -0
  66. package/dist/queue-cache.js.map +1 -0
  67. package/dist/queue-rows.d.ts +25 -0
  68. package/dist/queue-rows.d.ts.map +1 -0
  69. package/dist/queue-rows.js +44 -0
  70. package/dist/queue-rows.js.map +1 -0
  71. package/dist/recurrence.d.ts +11 -0
  72. package/dist/recurrence.d.ts.map +1 -0
  73. package/dist/recurrence.js +226 -0
  74. package/dist/recurrence.js.map +1 -0
  75. package/dist/rows.d.ts +10 -0
  76. package/dist/rows.d.ts.map +1 -0
  77. package/dist/rows.js +46 -0
  78. package/dist/rows.js.map +1 -0
  79. package/dist/schema.d.ts +3 -0
  80. package/dist/schema.d.ts.map +1 -0
  81. package/dist/schema.js +139 -0
  82. package/dist/schema.js.map +1 -0
  83. package/dist/sql-values.d.ts +9 -0
  84. package/dist/sql-values.d.ts.map +1 -0
  85. package/dist/sql-values.js +26 -0
  86. package/dist/sql-values.js.map +1 -0
  87. package/dist/start-after.d.ts +10 -0
  88. package/dist/start-after.d.ts.map +1 -0
  89. package/dist/start-after.js +125 -0
  90. package/dist/start-after.js.map +1 -0
  91. package/dist/supervisor.d.ts +16 -0
  92. package/dist/supervisor.d.ts.map +1 -0
  93. package/dist/supervisor.js +288 -0
  94. package/dist/supervisor.js.map +1 -0
  95. package/dist/throttle.d.ts +6 -0
  96. package/dist/throttle.d.ts.map +1 -0
  97. package/dist/throttle.js +35 -0
  98. package/dist/throttle.js.map +1 -0
  99. package/dist/timekeeper.d.ts +36 -0
  100. package/dist/timekeeper.d.ts.map +1 -0
  101. package/dist/timekeeper.js +305 -0
  102. package/dist/timekeeper.js.map +1 -0
  103. package/dist/timers.d.ts +9 -0
  104. package/dist/timers.d.ts.map +1 -0
  105. package/dist/timers.js +50 -0
  106. package/dist/timers.js.map +1 -0
  107. package/dist/uuid.d.ts +7 -0
  108. package/dist/uuid.d.ts.map +1 -0
  109. package/dist/uuid.js +36 -0
  110. package/dist/uuid.js.map +1 -0
  111. package/dist/worker.d.ts +35 -0
  112. package/dist/worker.d.ts.map +1 -0
  113. package/dist/worker.js +147 -0
  114. package/dist/worker.js.map +1 -0
  115. 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