@sqlane/core 0.0.1 → 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/README.md +44 -6
- package/package.json +20 -2
- package/src/adapters/mysql2.js +96 -0
- package/src/adapters/pg.js +164 -0
- package/src/backoff.js +24 -0
- package/src/create_sqlane.js +210 -0
- package/src/defaults.js +38 -0
- package/src/define_job.js +40 -0
- package/src/duration.js +27 -0
- package/src/enqueue.js +132 -0
- package/src/error_json.js +30 -0
- package/src/errors.js +188 -0
- package/src/hint_channel.js +107 -0
- package/src/index.js +64 -2
- package/src/migrations.js +163 -0
- package/src/mutex_keys.js +34 -0
- package/src/new_job.js +128 -0
- package/src/payload.js +54 -0
- package/src/registry.js +169 -0
- package/src/runner.js +974 -0
- package/src/schema/mysql.js +74 -0
- package/src/schema/postgres.js +67 -0
- package/src/store/mysql_enqueue.js +98 -0
- package/src/store/mysql_final.js +424 -0
- package/src/store/mysql_worker.js +250 -0
- package/src/store/postgres_enqueue.js +54 -0
- package/src/store/postgres_final.js +407 -0
- package/src/store/postgres_worker.js +224 -0
- package/src/store/store.js +136 -0
- package/src/transient_error.js +22 -0
- package/src/types.js +300 -0
- package/src/validate.js +181 -0
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
|
+
}
|