@sqlane/core 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runner.js ADDED
@@ -0,0 +1,974 @@
1
+ // The runner of an instance: the dispatcher loop, the handlers, the timers, close() and drain().
2
+ import { hostname } from 'node:os';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { monotonicFactory } from 'ulid';
5
+ import { getBackoffMs } from './backoff.js';
6
+ import { parseDuration } from './duration.js';
7
+ import { toErrorJson } from './error_json.js';
8
+ import {
9
+ ClosedError,
10
+ ConfigError,
11
+ DrainTimeoutError,
12
+ FatalJobError,
13
+ InvalidOptionsError,
14
+ JobTimeoutError,
15
+ LeaseLostError,
16
+ MissingPayloadError,
17
+ RetryJobError,
18
+ ShutdownError,
19
+ } from './errors.js';
20
+ import { ANY_NAME } from './hint_channel.js';
21
+ import { buildNewJob } from './new_job.js';
22
+ import { isTransientDbError } from './transient_error.js';
23
+
24
+ /** @typedef {import('./store/store.js').ClaimedJob} ClaimedJob */
25
+ /** @typedef {import('./registry.js').HandlerEntry} HandlerEntry */
26
+ /** @typedef {import('./registry.js').Lane} Lane */
27
+
28
+ /**
29
+ * @typedef {object} Timings
30
+ * @property {number} leaseMs
31
+ * @property {number} renewIntervalMs
32
+ * @property {number} recoveryIntervalMs
33
+ * @property {number} cleanupIntervalMs
34
+ * @property {number} pollIntervalMs
35
+ * @property {number} maxFinalizingMs How long a failed write of a result is retried.
36
+ * @property {number} missingPayloadGraceMs A job without its payload waits this long for it.
37
+ */
38
+
39
+ /** @type {Timings} */
40
+ export const DEFAULT_TIMINGS = {
41
+ leaseMs: 240_000,
42
+ renewIntervalMs: 30_000,
43
+ recoveryIntervalMs: 30_000,
44
+ cleanupIntervalMs: 300_000,
45
+ pollIntervalMs: 5000,
46
+ maxFinalizingMs: 600_000,
47
+ missingPayloadGraceMs: 60_000,
48
+ };
49
+
50
+ /** The wakes after an enqueue in this process, until the commit makes the job visible */
51
+ const REPROBE_DELAYS_MS = [20, 50, 100, 200, 500, 1000, 2000];
52
+ const RELEASE_EXTRA_DELAY_MS = 2000;
53
+ const STALLED = { name: 'StalledError', message: 'The lease of the job ended: its process stopped or blocked' };
54
+ const BATCH = 1000;
55
+
56
+ /**
57
+ * @typedef {object} RunningJob
58
+ * @property {string} key
59
+ * @property {ClaimedJob} claimed
60
+ * @property {HandlerEntry} entry
61
+ * @property {AbortController} controller
62
+ * @property {Promise<void>} done Resolves when the result is written (or given up).
63
+ * @property {boolean} settled The handler settled.
64
+ * @property {boolean} released Released or abandoned on close: the result is not written.
65
+ * @property {boolean} timedOut
66
+ * @property {boolean} leaseLost
67
+ * @property {number} leaseEndsAt
68
+ * @property {number} startedAt
69
+ */
70
+
71
+ /**
72
+ * @typedef {object} RunnerParams
73
+ * @property {import('./store/store.js').Store} store
74
+ * @property {import('./registry.js').Registry} registry
75
+ * @property {number} concurrency
76
+ * @property {import('./defaults.js').ResolvedDefaults} defaults
77
+ * @property {boolean} cleanup
78
+ * @property {import('./types.js').Logger | undefined} logger
79
+ * @property {((event: import('./types.js').SqlaneEvent) => void) | undefined} onEvent
80
+ * @property {import('./hint_channel.js').HintChannel} hintChannel
81
+ * @property {Partial<Timings>} [timings] For tests.
82
+ */
83
+
84
+ /**
85
+ * @typedef {object} Runner
86
+ * @property {() => Promise<void>} start
87
+ * @property {(options?: import('./types.js').DrainOptions) => Promise<import('./types.js').DrainResult>} drain
88
+ * @property {(options?: import('./types.js').CloseOptions) => Promise<void>} close
89
+ * @property {(name: string) => void} wake
90
+ * @property {(names: string[]) => void} onEnqueued
91
+ * @property {() => boolean} isClosed
92
+ * @property {() => boolean} isStarted
93
+ * @property {(name: string) => boolean} canStartNow This process handles the name and has a free slot for it.
94
+ */
95
+
96
+ /**
97
+ * @param {RunningJob} job
98
+ * @returns {import('./types.js').HandlerContext}
99
+ */
100
+ function contextOf(job) {
101
+ const { claimed } = job;
102
+
103
+ return {
104
+ payload: undefined,
105
+ job: {
106
+ id: claimed.id,
107
+ name: claimed.name,
108
+ attempt: claimed.attempts + 1,
109
+ maxAttempts: claimed.maxAttempts,
110
+ enqueuedAt: claimed.enqueuedAt,
111
+ mutexKeys: claimed.mutexKeys,
112
+ },
113
+ signal: job.controller.signal,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * @param {RunnerParams} params
119
+ * @returns {Runner}
120
+ */
121
+ export function createRunner({
122
+ store,
123
+ registry,
124
+ concurrency,
125
+ defaults,
126
+ cleanup,
127
+ logger,
128
+ onEvent,
129
+ hintChannel,
130
+ timings = {},
131
+ }) {
132
+ const t = { ...DEFAULT_TIMINGS, ...timings };
133
+ const lockedBy = `${hostname()}:${process.pid}:${monotonicFactory()()}`;
134
+ const lockToken = monotonicFactory();
135
+ /** @type {'idle' | 'started' | 'draining' | 'closing' | 'closed'} */
136
+ let state = 'idle';
137
+ /** @type {Map<string, RunningJob>} */
138
+ const running = new Map();
139
+ let wakeCount = 0;
140
+ /** @type {(() => void) | null} */
141
+ let endSleep = null;
142
+ /** @type {Promise<void> | null} */
143
+ let loopDone = null;
144
+ /** @type {Promise<void> | null} */
145
+ let closeDone = null;
146
+ /** @type {Set<NodeJS.Timeout>} */
147
+ const timers = new Set();
148
+ /** @type {Set<string>} The names with a series of re-probes */
149
+ const reprobing = new Set();
150
+ let contentionDelayMs = 0;
151
+ const counts = { completed: 0, retried: 0, failed: 0 };
152
+ /** The jobs that finished: their results are written */
153
+ let finishedCount = 0;
154
+
155
+ // ---- events, logs, timers ----
156
+
157
+ /** @param {import('./types.js').SqlaneEvent} event */
158
+ function emit(event) {
159
+ try {
160
+ onEvent?.(event);
161
+ } catch (error) {
162
+ logger?.error({ error }, 'sqlane: onEvent threw');
163
+ }
164
+ }
165
+
166
+ /**
167
+ * @param {string} operation
168
+ * @param {unknown} error
169
+ */
170
+ function reportError(operation, error) {
171
+ logger?.error({ error, operation }, `sqlane: ${operation} failed`);
172
+ emit({ type: 'error', operation, error });
173
+ }
174
+
175
+ /**
176
+ * @param {() => void} func
177
+ * @param {number} delayMs
178
+ */
179
+ function setTimer(func, delayMs) {
180
+ const timer = setTimeout(() => {
181
+ timers.delete(timer);
182
+ func();
183
+ }, delayMs);
184
+
185
+ timer.unref();
186
+ timers.add(timer);
187
+ }
188
+
189
+ /**
190
+ * A timer that runs func again after it settles, while the runner is started.
191
+ * @param {string} operation
192
+ * @param {() => Promise<unknown>} func
193
+ * @param {number} intervalMs
194
+ */
195
+ function every(operation, func, intervalMs) {
196
+ const run = async () => {
197
+ if (state !== 'started') {
198
+ return;
199
+ }
200
+
201
+ try {
202
+ await func();
203
+ } catch (error) {
204
+ reportError(operation, error);
205
+ }
206
+
207
+ setTimer(run, intervalMs * (0.9 + (Math.random() * 0.2)));
208
+ };
209
+
210
+ setTimer(run, intervalMs * Math.random());
211
+ }
212
+
213
+ function wakeUp() {
214
+ wakeCount++;
215
+ endSleep?.();
216
+ }
217
+
218
+ /**
219
+ * Sleeps until the delay ends or a wake.
220
+ * @param {number} delayMs
221
+ * @param {number} seenWakes The wakes before the decision to sleep: a wake since then ends the sleep at once.
222
+ * @returns {Promise<void>}
223
+ */
224
+ async function sleepUntilWake(delayMs, seenWakes) {
225
+ if (wakeCount !== seenWakes || state !== 'started') {
226
+ return;
227
+ }
228
+
229
+ const controller = new AbortController();
230
+
231
+ endSleep = () => controller.abort();
232
+
233
+ try {
234
+ await sleep(Math.max(delayMs, 0), undefined, { signal: controller.signal });
235
+ } catch {
236
+ // Woken
237
+ } finally {
238
+ endSleep = null;
239
+ }
240
+ }
241
+
242
+ // ---- slots ----
243
+
244
+ /** @returns {number} */
245
+ function freeProcessSlots() {
246
+ return concurrency - running.size;
247
+ }
248
+
249
+ /**
250
+ * @param {Lane} lane
251
+ * @returns {number}
252
+ */
253
+ function freeSlots(lane) {
254
+ return Math.max(0, Math.min(lane.concurrency - lane.running, freeProcessSlots()));
255
+ }
256
+
257
+ /**
258
+ * Takes the locked jobs that fit the free slots and whose keys are free, oldest first. A passed-over job keeps its
259
+ * keys from the newer ones.
260
+ * @param {ClaimedJob[]} locked
261
+ * @param {Set<string>} busyKeys
262
+ * @returns {ClaimedJob[]}
263
+ */
264
+ function pick(locked, busyKeys) {
265
+ /** @type {Map<Lane, number>} */
266
+ const pickedByLane = new Map();
267
+ const seenKeys = new Set(busyKeys);
268
+ let picked = 0;
269
+
270
+ return locked.filter((job) => {
271
+ const entry = registry.getEntry(job.name);
272
+ const keysFree = job.mutexKeys.every(key => !seenKeys.has(key));
273
+
274
+ for (const key of job.mutexKeys) {
275
+ seenKeys.add(key);
276
+ }
277
+
278
+ if (!entry || !keysFree) {
279
+ return false;
280
+ }
281
+
282
+ const laneCount = pickedByLane.get(entry.lane) ?? 0;
283
+
284
+ if (laneCount >= freeSlots(entry.lane) || picked >= freeProcessSlots()) {
285
+ return false;
286
+ }
287
+
288
+ pickedByLane.set(entry.lane, laneCount + 1);
289
+ picked++;
290
+
291
+ return true;
292
+ });
293
+ }
294
+
295
+ // ---- results ----
296
+
297
+ /** @type {import('./store/store.js').FailureJobBuilder} */
298
+ function buildFailureJob(failed) {
299
+ const newJob = buildNewJob({
300
+ name: failed.failureJob,
301
+ definition: registry.findDefinition(failed.failureJob),
302
+ defaults,
303
+ payload: {
304
+ job: {
305
+ id: failed.id,
306
+ name: failed.name,
307
+ payload: failed.payload,
308
+ attempt: failed.attempt,
309
+ maxAttempts: failed.maxAttempts,
310
+ },
311
+ error: failed.error,
312
+ },
313
+ options: undefined,
314
+ maxPayloadBytes: Infinity,
315
+ });
316
+
317
+ return { ...newJob, failureJob: null };
318
+ }
319
+
320
+ /**
321
+ * @param {{ id: string, name: string, attempt: number, failureJob: string | null }} job
322
+ * @param {import('./store/store.js').FailureJobResult | null} result
323
+ */
324
+ function reportFailureJob(job, result) {
325
+ if (result && 'notAdded' in result) {
326
+ reportError('failure_job', result.notAdded);
327
+ emit({
328
+ type: 'failure_job_not_added',
329
+ jobId: job.id,
330
+ jobName: job.name,
331
+ attempt: job.attempt,
332
+ failureJob: String(job.failureJob),
333
+ error: result.notAdded,
334
+ });
335
+ } else if (result && 'added' in result) {
336
+ onEnqueued([result.added.name]);
337
+ hintChannel.publish(result.added.name);
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Runs a write of the result until it succeeds; on errors, it retries with a backoff while the lease is renewed.
343
+ * @param {RunningJob} job
344
+ * @param {() => Promise<unknown>} write
345
+ * @returns {Promise<void>}
346
+ */
347
+ async function writeResult(job, write) {
348
+ const since = Date.now();
349
+
350
+ for (let attempt = 0; ; attempt++) {
351
+ try {
352
+ // oxlint-disable-next-line no-await-in-loop -- a retry waits for the previous try
353
+ await write();
354
+
355
+ return;
356
+ } catch (error) {
357
+ reportError('write_result', error);
358
+
359
+ if (Date.now() - since >= t.maxFinalizingMs || job.released || state === 'closed') {
360
+ // The lease ends, and the stall recovery takes the job.
361
+ return;
362
+ }
363
+
364
+ // oxlint-disable-next-line no-await-in-loop -- a retry waits for the previous try
365
+ await sleep(Math.min(100 * (2 ** attempt), 2000));
366
+ }
367
+ }
368
+ }
369
+
370
+ /**
371
+ * @param {RunningJob} job
372
+ * @param {unknown} error Null: the handler succeeded.
373
+ * @returns {Promise<void>}
374
+ */
375
+ async function finalize(job, error) {
376
+ const { claimed } = job;
377
+ const base = { jobId: claimed.id, jobName: claimed.name, attempt: claimed.attempts + 1 };
378
+ const durationMs = Date.now() - job.startedAt;
379
+
380
+ if (error === null && !job.timedOut) {
381
+ await writeResult(job, () => store.completeJob(claimed));
382
+ counts.completed++;
383
+ emit({ type: 'completed', ...base, durationMs });
384
+
385
+ return;
386
+ }
387
+
388
+ const fatal = error instanceof FatalJobError || error instanceof MissingPayloadError;
389
+ const attemptsLeft = claimed.attempts + 1 < claimed.maxAttempts;
390
+
391
+ if (!fatal && attemptsLeft) {
392
+ const backoff = job.entry.definition?.backoff ?? defaults.backoff;
393
+ const delayMs = error instanceof RetryJobError
394
+ ? parseDuration(error.delay) ?? getBackoffMs(backoff, claimed.attempts + 1)
395
+ : getBackoffMs(backoff, claimed.attempts + 1);
396
+
397
+ await writeResult(job, () => store.retryJob(claimed, { delayMs, error: toErrorJson(error) }));
398
+ counts.retried++;
399
+ emit({ type: 'retried', ...base, durationMs, delayMs, error });
400
+
401
+ return;
402
+ }
403
+
404
+ logger?.error({ error, jobId: claimed.id, jobName: claimed.name }, 'sqlane: job failed');
405
+ await writeResult(job, async () => {
406
+ const { failureJob } = await store.failJob(claimed, { error: toErrorJson(error), buildFailureJob });
407
+
408
+ reportFailureJob(
409
+ { id: claimed.id, name: claimed.name, attempt: base.attempt, failureJob: claimed.failureJob },
410
+ failureJob,
411
+ );
412
+ });
413
+ counts.failed++;
414
+ emit({ type: 'failed', ...base, durationMs, error });
415
+ }
416
+
417
+ /**
418
+ * Runs the handler, then writes the result.
419
+ * @param {RunningJob} job
420
+ * @param {{ found: boolean, payload: unknown }} payload
421
+ * @returns {Promise<void>}
422
+ */
423
+ async function runJob(job, payload) {
424
+ const { claimed, entry } = job;
425
+ /** @type {unknown} */
426
+ let error = null;
427
+
428
+ if (!payload.found) {
429
+ if (Date.now() - claimed.enqueuedAt.getTime() < t.missingPayloadGraceMs) {
430
+ // Its payload can still be on the way (an enqueue outside a transaction)
431
+ job.settled = true;
432
+ await writeResult(job, () => store.releaseJob(claimed, { delayMs: 1000, lingerMs: 0 }));
433
+
434
+ return;
435
+ }
436
+
437
+ error = new MissingPayloadError(`The payload of job "${claimed.id}" is missing`);
438
+ } else if (claimed.mutexKeys.length && entry.timeoutMs === null) {
439
+ error = new FatalJobError(
440
+ 'A job with mutex keys needs a timeout: set one in handle(), the definition or "defaults"',
441
+ );
442
+ } else {
443
+ error = await runHandler(job, payload.payload);
444
+ }
445
+
446
+ job.settled = true;
447
+
448
+ if (job.released || job.leaseLost) {
449
+ return;
450
+ }
451
+
452
+ await finalize(job, error);
453
+ }
454
+
455
+ /**
456
+ * @param {RunningJob} job
457
+ * @param {unknown} payload
458
+ * @returns {Promise<unknown>} The error, or null.
459
+ */
460
+ async function runHandler(job, payload) {
461
+ const { entry } = job;
462
+ const handlerDone = (async () => {
463
+ try {
464
+ await entry.handler({ ...contextOf(job), payload });
465
+
466
+ return null;
467
+ } catch (error) {
468
+ return error ?? new Error('The handler threw no error object');
469
+ }
470
+ })();
471
+
472
+ if (entry.timeoutMs === null) {
473
+ return handlerDone;
474
+ }
475
+
476
+ /** @type {NodeJS.Timeout | undefined} */
477
+ let timer;
478
+ const timedOut = new Promise((resolve) => {
479
+ timer = setTimeout(() => resolve('timeout'), entry.timeoutMs ?? 0);
480
+ });
481
+ const first = await Promise.race([handlerDone, timedOut]);
482
+
483
+ clearTimeout(timer);
484
+
485
+ if (first !== 'timeout') {
486
+ return first;
487
+ }
488
+
489
+ const timeoutError = new JobTimeoutError(`The job timed out after ${entry.timeoutMs} ms`);
490
+
491
+ job.timedOut = true;
492
+ job.controller.abort(timeoutError);
493
+ emit({
494
+ type: 'timed_out',
495
+ jobId: job.claimed.id,
496
+ jobName: job.claimed.name,
497
+ attempt: job.claimed.attempts + 1,
498
+ durationMs: Date.now() - job.startedAt,
499
+ });
500
+
501
+ try {
502
+ await store.markTimedOut(job.claimed);
503
+ } catch (error) {
504
+ reportError('mark_timed_out', error);
505
+ }
506
+
507
+ // The job keeps its keys and its slot until the handler settles, at most one more lease
508
+ await Promise.race([handlerDone, sleep(t.leaseMs, undefined, { ref: false })]);
509
+
510
+ return timeoutError;
511
+ }
512
+
513
+ /**
514
+ * @param {ClaimedJob} claimed
515
+ * @param {{ found: boolean, payload: unknown }} payload
516
+ */
517
+ function startJob(claimed, payload) {
518
+ const entry = /** @type {HandlerEntry} */ (registry.getEntry(claimed.name));
519
+ /** @type {RunningJob} */
520
+ const job = {
521
+ key: `${claimed.id}\n${claimed.lockToken}`,
522
+ claimed,
523
+ entry,
524
+ controller: new AbortController(),
525
+ done: Promise.resolve(),
526
+ settled: false,
527
+ released: false,
528
+ timedOut: false,
529
+ leaseLost: false,
530
+ leaseEndsAt: Date.now() + t.leaseMs,
531
+ startedAt: Date.now(),
532
+ };
533
+
534
+ entry.lane.running++;
535
+ running.set(job.key, job);
536
+ emit({ type: 'claimed', jobId: claimed.id, jobName: claimed.name, attempt: claimed.attempts + 1 });
537
+ job.done = (async () => {
538
+ try {
539
+ await runJob(job, payload);
540
+ } catch (error) {
541
+ reportError('run_job', error);
542
+ } finally {
543
+ entry.lane.running--;
544
+ running.delete(job.key);
545
+ finishedCount++;
546
+ wakeUp();
547
+
548
+ if (claimed.mutexKeys.length) {
549
+ // Its keys are free: the jobs that wait for them can start, here or elsewhere
550
+ hintChannel.publish(ANY_NAME);
551
+ }
552
+ }
553
+ })();
554
+ }
555
+
556
+ // ---- dispatch ----
557
+
558
+ /**
559
+ * Claims the due jobs of the given names that fit the free slots, and starts them.
560
+ * @param {string[]} dueNames
561
+ * @returns {Promise<{ candidates: number, claimed: number }>}
562
+ */
563
+ async function claimAndStart(dueNames) {
564
+ /** @type {Map<string, number>} */
565
+ const limits = new Map();
566
+
567
+ for (const name of dueNames) {
568
+ const entry = registry.getEntry(name);
569
+ const free = entry ? freeSlots(entry.lane) : 0;
570
+
571
+ if (free > 0) {
572
+ // 2 × the free slots: the processes woken together can take different rows
573
+ limits.set(name, 2 * free);
574
+ }
575
+ }
576
+
577
+ if (!limits.size) {
578
+ return { candidates: 0, claimed: 0 };
579
+ }
580
+
581
+ const candidates = await store.findCandidates(limits, registry.keyedNames());
582
+
583
+ if (!candidates.length) {
584
+ return { candidates: 0, claimed: 0 };
585
+ }
586
+
587
+ const claimed = await store.claim({
588
+ candidateIds: candidates.map(candidate => candidate.id),
589
+ lockToken: lockToken(),
590
+ lockedBy,
591
+ leaseMs: t.leaseMs,
592
+ pick,
593
+ });
594
+
595
+ if (!claimed.length) {
596
+ return { candidates: candidates.length, claimed: 0 };
597
+ }
598
+
599
+ let payloads;
600
+
601
+ try {
602
+ payloads = await store.readPayloads(claimed.map(job => job.id));
603
+ } catch (error) {
604
+ reportError('read_payloads', error);
605
+ await Promise.all(claimed.map(job => store.releaseJob(job, { delayMs: 0, lingerMs: 0 }).catch(
606
+ releaseError => reportError('release', releaseError),
607
+ )));
608
+
609
+ return { candidates: candidates.length, claimed: 0 };
610
+ }
611
+
612
+ for (const job of claimed) {
613
+ startJob(job, { found: payloads.has(job.id), payload: payloads.get(job.id) });
614
+ }
615
+
616
+ return { candidates: candidates.length, claimed: claimed.length };
617
+ }
618
+
619
+ /** @returns {string[]} The handled names whose lane has a free slot. */
620
+ function namesWithFreeSlots() {
621
+ return registry.entries().filter(entry => freeSlots(entry.lane) > 0).map(entry => entry.name);
622
+ }
623
+
624
+ /**
625
+ * One turn of the dispatcher.
626
+ * @returns {Promise<number>} The delay before the next turn (a wake ends it sooner).
627
+ */
628
+ async function dispatch() {
629
+ const names = new Set(namesWithFreeSlots());
630
+
631
+ if (!names.size) {
632
+ return t.pollIntervalMs;
633
+ }
634
+
635
+ const probe = (await store.probe()).filter(row => registry.getEntry(row.name));
636
+ const due = probe.filter(row => row.delayMs <= 0 && names.has(row.name)).map(row => row.name);
637
+ const delays = probe.filter(row => row.delayMs > 0).map(row => row.delayMs);
638
+ const nextDelayMs = Math.min(t.pollIntervalMs, ...delays);
639
+
640
+ if (!due.length) {
641
+ return nextDelayMs;
642
+ }
643
+
644
+ const { candidates, claimed } = await claimAndStart(due);
645
+
646
+ if (claimed) {
647
+ contentionDelayMs = 0;
648
+
649
+ return 0;
650
+ }
651
+
652
+ if (candidates) {
653
+ // Other processes took them: back off a little
654
+ contentionDelayMs = Math.min(Math.max(contentionDelayMs * 2, 10), 200);
655
+
656
+ return contentionDelayMs * (0.5 + Math.random());
657
+ }
658
+
659
+ // The due jobs wait for keys: a key release wakes this process, the poll is the fallback
660
+ return nextDelayMs;
661
+ }
662
+
663
+ async function loop() {
664
+ // oxlint-disable-next-line no-unmodified-loop-condition -- close() changes it
665
+ while (state === 'started') {
666
+ const seenWakes = wakeCount;
667
+ let delayMs;
668
+
669
+ try {
670
+ // oxlint-disable-next-line no-await-in-loop -- one turn after the other
671
+ delayMs = await dispatch();
672
+ } catch (error) {
673
+ reportError('dispatch', error);
674
+ delayMs = Math.min(t.pollIntervalMs, 1000);
675
+ }
676
+
677
+ // oxlint-disable-next-line no-await-in-loop -- one turn after the other
678
+ await sleepUntilWake(delayMs, seenWakes);
679
+ }
680
+ }
681
+
682
+ // ---- timers ----
683
+
684
+ async function renewLeases() {
685
+ const jobs = [...running.values()].filter(job => !job.released && !job.leaseLost);
686
+
687
+ if (!jobs.length) {
688
+ return;
689
+ }
690
+
691
+ const renewedAt = Date.now();
692
+ const renewed = await store.renewLeases(jobs.map(job => job.claimed), t.leaseMs);
693
+
694
+ for (const job of jobs) {
695
+ if (renewed.has(job.claimed.id)) {
696
+ job.leaseEndsAt = renewedAt + t.leaseMs;
697
+ } else if (job.leaseEndsAt - Date.now() <= t.renewIntervalMs) {
698
+ // Not renewed in time: another process may take the job
699
+ job.leaseLost = true;
700
+ job.controller.abort(new LeaseLostError(`The lease of job "${job.claimed.id}" was lost`));
701
+ emit({
702
+ type: 'lease_lost',
703
+ jobId: job.claimed.id,
704
+ jobName: job.claimed.name,
705
+ attempt: job.claimed.attempts + 1,
706
+ });
707
+ }
708
+ }
709
+ }
710
+
711
+ async function recoverStaleJobs() {
712
+ const ownIds = new Set([...running.values()].map(job => job.claimed.id));
713
+ let freedKeys = false;
714
+
715
+ for (const { id, name } of await store.findStaleJobs(100)) {
716
+ if (state !== 'started' || ownIds.has(id)) {
717
+ continue;
718
+ }
719
+
720
+ // oxlint-disable-next-line no-await-in-loop -- small batches, one job per transaction
721
+ const { result, failureJob } = await store.recoverStaleJob(id, { leaseMs: t.leaseMs, buildFailureJob });
722
+
723
+ if (result === 'released') {
724
+ freedKeys = true;
725
+ emit({ type: 'stalled', jobId: id, jobName: name, attempt: 0 });
726
+ } else if (result === 'failed') {
727
+ freedKeys = true;
728
+ counts.failed++;
729
+ emit({ type: 'failed', jobId: id, jobName: name, attempt: 0, durationMs: 0, error: STALLED });
730
+ reportFailureJob({ id, name, attempt: 0, failureJob: null }, failureJob);
731
+ }
732
+ }
733
+
734
+ const { lingers, orphans } = await store.sweepMutexes(100);
735
+
736
+ if (freedKeys || lingers || orphans) {
737
+ wakeUp();
738
+ hintChannel.publish(ANY_NAME);
739
+ }
740
+ }
741
+
742
+ async function deleteExpiredJobs() {
743
+ // oxlint-disable-next-line no-unmodified-loop-condition -- close() changes state
744
+ for (let deleted = BATCH; deleted >= BATCH && state === 'started';) {
745
+ // oxlint-disable-next-line no-await-in-loop -- batches one after the other
746
+ deleted = await store.deleteExpiredJobs(BATCH);
747
+ }
748
+ }
749
+
750
+ // ---- public ----
751
+
752
+ /**
753
+ * @param {string[]} names
754
+ */
755
+ function onEnqueued(names) {
756
+ if (state !== 'started') {
757
+ return;
758
+ }
759
+
760
+ for (const name of names) {
761
+ if ((name !== ANY_NAME && !registry.getEntry(name)) || reprobing.has(name)) {
762
+ continue;
763
+ }
764
+
765
+ reprobing.add(name);
766
+
767
+ let elapsedMs = 0;
768
+
769
+ for (const delayMs of REPROBE_DELAYS_MS.filter(delay => delay < t.pollIntervalMs)) {
770
+ elapsedMs = delayMs;
771
+ setTimer(wakeUp, delayMs);
772
+ }
773
+
774
+ setTimer(() => reprobing.delete(name), elapsedMs);
775
+ }
776
+ }
777
+
778
+ async function start() {
779
+ if (state === 'closing' || state === 'closed') {
780
+ throw new ClosedError('start() after close()');
781
+ }
782
+
783
+ if (state !== 'idle') {
784
+ return;
785
+ }
786
+
787
+ registry.validate();
788
+ state = 'started';
789
+ every('renew_leases', renewLeases, t.renewIntervalMs);
790
+ every('recover_stale_jobs', recoverStaleJobs, t.recoveryIntervalMs);
791
+
792
+ if (cleanup) {
793
+ every('delete_expired_jobs', deleteExpiredJobs, t.cleanupIntervalMs);
794
+ }
795
+
796
+ loopDone = loop();
797
+
798
+ try {
799
+ // A hint is sent before the commit of its enqueue: re-probe until the job is visible
800
+ await hintChannel.listen(name => onEnqueued([name]));
801
+ } catch (error) {
802
+ reportError('hint_listen', error);
803
+ }
804
+ }
805
+
806
+ /**
807
+ * @param {import('./types.js').DrainOptions} [options]
808
+ * @returns {Promise<import('./types.js').DrainResult>}
809
+ */
810
+ async function drain(options = {}) {
811
+ if (state === 'closing' || state === 'closed') {
812
+ throw new ClosedError('drain() after close()');
813
+ }
814
+
815
+ if (state !== 'idle') {
816
+ throw new ConfigError('drain() runs without start(), and one at a time');
817
+ }
818
+
819
+ const timeoutMs = parseDuration(options.timeout ?? '10s');
820
+
821
+ if (timeoutMs === null) {
822
+ throw new InvalidOptionsError('drain: "timeout" must be a duration');
823
+ }
824
+
825
+ registry.validate();
826
+ state = 'draining';
827
+
828
+ const before = { ...counts };
829
+ const deadline = Date.now() + timeoutMs;
830
+ const names = registry.entries().map(entry => entry.name);
831
+
832
+ let quietRounds = 0;
833
+
834
+ try {
835
+ while (Date.now() < deadline) {
836
+ // A job that finishes during this round can add a job (a retry, a failure job) that the round misses
837
+ const finishedBefore = finishedCount;
838
+
839
+ let claimed = 0;
840
+ let lostRace = false;
841
+
842
+ try {
843
+ if (options.includeDelayed) {
844
+ // oxlint-disable-next-line no-await-in-loop -- one round after the other
845
+ await store.makeDue(names);
846
+ }
847
+
848
+ // oxlint-disable-next-line no-await-in-loop -- one round after the other
849
+ ({ claimed } = await claimAndStart(names));
850
+ } catch (error) {
851
+ if (!isTransientDbError(error)) {
852
+ throw error;
853
+ }
854
+
855
+ // A deadlock with a write of a job: the next round does the work again
856
+ reportError('drain', error);
857
+ lostRace = true;
858
+ }
859
+
860
+ const quiet = !claimed && !lostRace && !running.size && finishedCount === finishedBefore;
861
+
862
+ quietRounds = quiet ? quietRounds + 1 : 0;
863
+
864
+ // Two quiet rounds in a row: a write that commits late still gets a round
865
+ if (quietRounds >= 2) {
866
+ return {
867
+ completed: counts.completed - before.completed,
868
+ retried: counts.retried - before.retried,
869
+ failed: counts.failed - before.failed,
870
+ };
871
+ }
872
+
873
+ // oxlint-disable-next-line no-await-in-loop -- one round after the other
874
+ await Promise.race([...[...running.values()].map(job => job.done), sleep(claimed ? 0 : 10)]);
875
+ }
876
+
877
+ throw new DrainTimeoutError(`drain() did not finish in ${timeoutMs} ms`);
878
+ } finally {
879
+ if (state === 'draining') {
880
+ state = 'idle';
881
+ }
882
+ }
883
+ }
884
+
885
+ /**
886
+ * @param {import('./types.js').CloseOptions} [options]
887
+ * @returns {Promise<void>}
888
+ */
889
+ function close(options = {}) {
890
+ closeDone ??= closeOnce(options);
891
+
892
+ return closeDone;
893
+ }
894
+
895
+ /**
896
+ * @param {import('./types.js').CloseOptions} options
897
+ * @returns {Promise<void>}
898
+ */
899
+ async function closeOnce(options) {
900
+ const startedAt = Date.now();
901
+ const deadlineMs = parseDuration(options.deadline ?? '30s') ?? 30_000;
902
+ let exitsByMs = parseDuration(options.exitsBy ?? deadlineMs + t.leaseMs);
903
+
904
+ if (exitsByMs === null || exitsByMs < deadlineMs) {
905
+ logger?.warn({ exitsBy: options.exitsBy }, 'sqlane: close() got an invalid "exitsBy": the default is used');
906
+ exitsByMs = deadlineMs + t.leaseMs;
907
+ }
908
+
909
+ const mode = options.mode === 'abandon' ? 'abandon' : 'release';
910
+
911
+ state = 'closing';
912
+ wakeUp();
913
+ await hintChannel.stop();
914
+ await loopDone;
915
+
916
+ const deadline = startedAt + deadlineMs;
917
+ const allDone = Promise.allSettled([...running.values()].map(job => job.done));
918
+
919
+ await Promise.race([allDone, sleep(Math.max(deadline - Date.now(), 0))]);
920
+
921
+ const unfinished = [...running.values()].filter(job => !job.settled);
922
+
923
+ for (const job of unfinished) {
924
+ job.released = true;
925
+ job.controller.abort(new ShutdownError('The process is closing'));
926
+ }
927
+
928
+ if (mode === 'release') {
929
+ const delayMs = Math.max(startedAt + exitsByMs - Date.now(), 0) + RELEASE_EXTRA_DELAY_MS;
930
+
931
+ await Promise.all(unfinished.map(async (job) => {
932
+ try {
933
+ await store.releaseJob(job.claimed, { delayMs, lingerMs: delayMs });
934
+ emit({
935
+ type: 'released',
936
+ jobId: job.claimed.id,
937
+ jobName: job.claimed.name,
938
+ attempt: job.claimed.attempts + 1,
939
+ });
940
+ } catch (error) {
941
+ reportError('release', error);
942
+ }
943
+ }));
944
+ }
945
+
946
+ // The writes of the finished handlers, until the deadline
947
+ await Promise.race([
948
+ Promise.allSettled([...running.values()].filter(job => job.settled).map(job => job.done)),
949
+ sleep(Math.max(deadline - Date.now(), 0)),
950
+ ]);
951
+
952
+ for (const timer of timers) {
953
+ clearTimeout(timer);
954
+ }
955
+
956
+ timers.clear();
957
+ state = 'closed';
958
+ }
959
+
960
+ return {
961
+ start,
962
+ drain,
963
+ close,
964
+ wake: wakeUp,
965
+ onEnqueued,
966
+ isClosed: () => state === 'closing' || state === 'closed',
967
+ isStarted: () => state === 'started',
968
+ canStartNow: (name) => {
969
+ const entry = registry.getEntry(name);
970
+
971
+ return state === 'started' && Boolean(entry) && freeSlots(/** @type {HandlerEntry} */ (entry).lane) > 0;
972
+ },
973
+ };
974
+ }