@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.
@@ -0,0 +1,407 @@
1
+ // The final writes on PostgreSQL: lease renewal, complete, retry, fail, release, stall recovery, sweeps. Each write of
2
+ // a job checks its lock token: a process that lost the lease changes nothing. Each runs in a queue transaction.
3
+ import { monotonicFactory } from 'ulid';
4
+ import { insertJobsPostgres } from './postgres_enqueue.js';
5
+
6
+ /** @typedef {import('../types.js').Executor} Executor */
7
+ /** @typedef {import('./store.js').OwnedJob} OwnedJob */
8
+ /** @typedef {import('./store.js').FailureJobBuilder} FailureJobBuilder */
9
+ /** @typedef {import('./store.js').FailureJobResult} FailureJobResult */
10
+
11
+ const NOW = 'clock_timestamp()';
12
+ /**
13
+ * A due time from now, truncated to the ms of the column: timestamptz(3) rounds, and a rounded-up time is not due.
14
+ * @param {string} offset An interval.
15
+ * @returns {string}
16
+ */
17
+ const runAt = offset => `date_trunc('milliseconds', ${NOW} + ${offset})`;
18
+ const MS = 'interval \'1 millisecond\'';
19
+ const CLEAR_LOCK = 'lock_token = NULL, locked_by = NULL, locked_until = NULL, timed_out_at = NULL';
20
+ const enqueueToken = monotonicFactory();
21
+
22
+ /**
23
+ * Renews the leases of the jobs that the process runs. A row locked by another transaction is skipped: it does not
24
+ * block the others.
25
+ * @param {Executor} executor
26
+ * @param {string} p
27
+ * @param {OwnedJob[]} jobs
28
+ * @param {number} leaseMs
29
+ * @returns {Promise<Set<string>>} The ids whose lease was renewed.
30
+ */
31
+ export async function renewLeases(executor, p, jobs, leaseMs) {
32
+ if (!jobs.length) {
33
+ return new Set();
34
+ }
35
+
36
+ const rows = await executor.query(`
37
+ WITH locked AS (
38
+ SELECT j.id FROM ${p}jobs j
39
+ JOIN unnest($1::text[], $2::text[]) AS own(id, lock_token)
40
+ ON own.id = j.id AND own.lock_token = j.lock_token
41
+ WHERE j.status = 'running'
42
+ FOR UPDATE OF j SKIP LOCKED
43
+ )
44
+ UPDATE ${p}jobs j SET locked_until = ${NOW} + $3 * ${MS} FROM locked WHERE j.id = locked.id
45
+ RETURNING j.id
46
+ `, [jobs.map(job => job.id), jobs.map(job => job.lockToken), leaseMs]);
47
+
48
+ return new Set(rows.map(row => String(row.id)));
49
+ }
50
+
51
+ /**
52
+ * Marks a timeout: the job stays running until its handler settles (`stuck` in the stats).
53
+ * @param {Executor} executor
54
+ * @param {string} p
55
+ * @param {OwnedJob} job
56
+ * @returns {Promise<boolean>} False: the lease was lost.
57
+ */
58
+ export async function markTimedOut(executor, p, job) {
59
+ return await executor.run(`
60
+ UPDATE ${p}jobs SET timed_out_at = ${NOW}, updated_at = ${NOW} WHERE id = $1 AND lock_token = $2
61
+ `, [job.id, job.lockToken]) > 0;
62
+ }
63
+
64
+ /**
65
+ * @param {Executor} executor
66
+ * @param {string} p
67
+ * @param {OwnedJob} job
68
+ * @returns {Promise<void>}
69
+ */
70
+ async function deleteHolds(executor, p, job) {
71
+ await executor.run(`DELETE FROM ${p}mutexes WHERE job_id = $1 AND lock_token = $2`, [job.id, job.lockToken]);
72
+ }
73
+
74
+ /**
75
+ * @param {Executor} executor
76
+ * @param {string} p
77
+ * @param {OwnedJob} job
78
+ * @param {number} lingerMs The keys stay held this long. 0: deleted.
79
+ * @returns {Promise<void>}
80
+ */
81
+ async function lingerHolds(executor, p, job, lingerMs) {
82
+ if (lingerMs <= 0) {
83
+ await deleteHolds(executor, p, job);
84
+
85
+ return;
86
+ }
87
+
88
+ await executor.run(`
89
+ UPDATE ${p}mutexes SET held_until = ${NOW} + $3 * ${MS}
90
+ WHERE job_id = $1 AND lock_token = $2 AND held_until IS NULL
91
+ `, [job.id, job.lockToken, lingerMs]);
92
+ }
93
+
94
+ /**
95
+ * The job is done: completed until its retention ends, without its payload; its keys are freed.
96
+ * @param {Executor} executor
97
+ * @param {string} p
98
+ * @param {OwnedJob} job
99
+ * @returns {Promise<boolean>} False: no change (the lease was lost, or an earlier try committed).
100
+ */
101
+ export async function completeJob(executor, p, job) {
102
+ const changed = await executor.run(`
103
+ UPDATE ${p}jobs
104
+ SET status = 'completed', finished_at = ${NOW}, expires_at = ${NOW} + completed_retention_ms * ${MS},
105
+ ${CLEAR_LOCK}, updated_at = ${NOW}
106
+ WHERE id = $1 AND lock_token = $2
107
+ `, [job.id, job.lockToken]);
108
+
109
+ if (changed) {
110
+ await executor.run(`DELETE FROM ${p}payloads WHERE id = $1`, [job.id]);
111
+ }
112
+
113
+ await deleteHolds(executor, p, job);
114
+
115
+ return changed > 0;
116
+ }
117
+
118
+ /**
119
+ * The attempt failed: pending again after the delay, one more attempt used; its keys are freed.
120
+ * @param {Executor} executor
121
+ * @param {string} p
122
+ * @param {OwnedJob} job
123
+ * @param {{ delayMs: number, error: import('../error_json.js').ErrorJson }} params
124
+ * @returns {Promise<boolean>} False: no change.
125
+ */
126
+ export async function retryJob(executor, p, job, { delayMs, error }) {
127
+ const changed = await executor.run(`
128
+ UPDATE ${p}jobs
129
+ SET status = 'pending', run_at = ${runAt(`$3 * ${MS}`)}, attempts = attempts + 1, ${CLEAR_LOCK},
130
+ last_error = $4::jsonb, updated_at = ${NOW}
131
+ WHERE id = $1 AND lock_token = $2
132
+ `, [job.id, job.lockToken, delayMs, JSON.stringify(error)]);
133
+
134
+ await deleteHolds(executor, p, job);
135
+
136
+ return changed > 0;
137
+ }
138
+
139
+ /**
140
+ * Adds the failure job of a job that this transaction failed, under a savepoint: if the database refuses it, the job
141
+ * still fails, without it.
142
+ * @param {Executor} executor
143
+ * @param {string} p
144
+ * @param {Record<string, unknown>} row The failed job, read in this transaction.
145
+ * @param {{ error: import('../error_json.js').ErrorJson, buildFailureJob: FailureJobBuilder }} params
146
+ * @returns {Promise<FailureJobResult>}
147
+ */
148
+ async function addFailureJob(executor, p, row, { error, buildFailureJob }) {
149
+ let newJob;
150
+
151
+ try {
152
+ newJob = buildFailureJob({
153
+ id: String(row.id),
154
+ name: String(row.job_name),
155
+ payload: row.payload === null ? null : JSON.parse(String(row.payload)),
156
+ attempt: Number(row.attempts) + 1,
157
+ maxAttempts: Number(row.max_attempts),
158
+ failureJob: String(row.failure_job),
159
+ error,
160
+ });
161
+ } catch (buildError) {
162
+ return { notAdded: buildError };
163
+ }
164
+
165
+ await executor.run('SAVEPOINT sqlane_failure_job', []);
166
+
167
+ try {
168
+ await insertJobsPostgres(executor, p, [newJob], enqueueToken());
169
+ await executor.run('RELEASE SAVEPOINT sqlane_failure_job', []);
170
+
171
+ return { added: newJob };
172
+ } catch (insertError) {
173
+ await executor.run('ROLLBACK TO SAVEPOINT sqlane_failure_job', []);
174
+
175
+ return { notAdded: insertError };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * @param {Executor} executor
181
+ * @param {string} p
182
+ * @param {string} where The condition of the row, on `j`.
183
+ * @param {unknown[]} params
184
+ * @returns {Promise<Record<string, unknown> | undefined>} The job and its payload, locked.
185
+ */
186
+ async function lockJob(executor, p, where, params) {
187
+ const [row] = await executor.query(`
188
+ SELECT j.id, j.job_name, j.lock_token, j.attempts, j.max_attempts, j.stalls, j.max_stalls, j.failure_job,
189
+ pl.payload::text AS payload
190
+ FROM ${p}jobs j LEFT JOIN ${p}payloads pl ON pl.id = j.id
191
+ WHERE ${where}
192
+ FOR UPDATE OF j
193
+ `, params);
194
+
195
+ return row;
196
+ }
197
+
198
+ /**
199
+ * The job fails for good: failed until its retention ends, with its payload; its keys are freed; its failure job is
200
+ * added in the same transaction.
201
+ * @param {Executor} executor
202
+ * @param {string} p
203
+ * @param {OwnedJob} job
204
+ * @param {{ error: import('../error_json.js').ErrorJson, buildFailureJob: FailureJobBuilder }} params
205
+ * @returns {Promise<{ changed: boolean, failureJob: FailureJobResult | null }>}
206
+ */
207
+ export async function failJob(executor, p, job, { error, buildFailureJob }) {
208
+ const row = await lockJob(executor, p, 'j.id = $1 AND j.lock_token = $2', [job.id, job.lockToken]);
209
+
210
+ if (!row) {
211
+ return { changed: false, failureJob: null };
212
+ }
213
+
214
+ await executor.run(`
215
+ UPDATE ${p}jobs
216
+ SET status = 'failed', attempts = attempts + 1, finished_at = ${NOW},
217
+ expires_at = ${NOW} + failed_retention_ms * ${MS}, ${CLEAR_LOCK}, last_error = $2::jsonb,
218
+ updated_at = ${NOW}
219
+ WHERE id = $1
220
+ `, [job.id, JSON.stringify(error)]);
221
+ await deleteHolds(executor, p, job);
222
+
223
+ return {
224
+ changed: true,
225
+ failureJob: row.failure_job === null ? null : await addFailureJob(executor, p, row, { error, buildFailureJob }),
226
+ };
227
+ }
228
+
229
+ /**
230
+ * The job is released, without an attempt used: pending again after the delay.
231
+ * @param {Executor} executor
232
+ * @param {string} p
233
+ * @param {OwnedJob} job
234
+ * @param {{ delayMs: number, lingerMs: number }} params lingerMs: its keys stay held this long (its handler still
235
+ * runs); 0: they are freed.
236
+ * @returns {Promise<boolean>} False: no change.
237
+ */
238
+ export async function releaseJob(executor, p, job, { delayMs, lingerMs }) {
239
+ const changed = await executor.run(`
240
+ UPDATE ${p}jobs SET status = 'pending', run_at = ${runAt(`$3 * ${MS}`)}, ${CLEAR_LOCK}, updated_at = ${NOW}
241
+ WHERE id = $1 AND lock_token = $2
242
+ `, [job.id, job.lockToken, delayMs]);
243
+
244
+ await lingerHolds(executor, p, job, lingerMs);
245
+
246
+ return changed > 0;
247
+ }
248
+
249
+ /**
250
+ * @param {Executor} executor
251
+ * @param {string} p
252
+ * @param {number} limit
253
+ * @returns {Promise<{ id: string, name: string }[]>} The running jobs whose lease ended, the oldest first.
254
+ */
255
+ export async function findStaleJobs(executor, p, limit) {
256
+ const rows = await executor.query(`
257
+ SELECT id, job_name FROM ${p}jobs
258
+ WHERE status = 'running' AND locked_until < ${NOW}
259
+ ORDER BY locked_until LIMIT $1
260
+ `, [limit]);
261
+
262
+ return rows.map(row => ({ id: String(row.id), name: String(row.job_name) }));
263
+ }
264
+
265
+ /**
266
+ * A running job whose lease ended: one more stall, pending again after 30 s × 2^stalls (at most 1 h); after maxStalls,
267
+ * it fails for good. Its keys linger for one lease: its old handler may still run.
268
+ * @param {Executor} executor
269
+ * @param {string} p
270
+ * @param {string} id
271
+ * @param {{ leaseMs: number, buildFailureJob: FailureJobBuilder }} params
272
+ * @returns {Promise<{ result: 'released' | 'failed' | null, failureJob: FailureJobResult | null }>} null: not stale
273
+ * (any more).
274
+ */
275
+ export async function recoverStaleJob(executor, p, id, { leaseMs, buildFailureJob }) {
276
+ const row = await lockJob(executor, p, `j.id = $1 AND j.status = 'running' AND j.locked_until < ${NOW}`, [id]);
277
+
278
+ if (!row) {
279
+ return { result: null, failureJob: null };
280
+ }
281
+
282
+ const error = { name: 'StalledError', message: 'The lease of the job ended: its process stopped or blocked' };
283
+
284
+ await lingerHolds(executor, p, { id, lockToken: String(row.lock_token) }, leaseMs);
285
+
286
+ if (Number(row.stalls) < Number(row.max_stalls)) {
287
+ await executor.run(`
288
+ UPDATE ${p}jobs
289
+ SET status = 'pending', run_at = ${runAt(`LEAST(30000 * power(2, stalls), 3600000) * ${MS}`)},
290
+ stalls = stalls + 1, ${CLEAR_LOCK}, last_error = $2::jsonb, updated_at = ${NOW}
291
+ WHERE id = $1
292
+ `, [id, JSON.stringify(error)]);
293
+
294
+ return { result: 'released', failureJob: null };
295
+ }
296
+
297
+ await executor.run(`
298
+ UPDATE ${p}jobs
299
+ SET status = 'failed', stalls = stalls + 1, finished_at = ${NOW},
300
+ expires_at = ${NOW} + failed_retention_ms * ${MS}, ${CLEAR_LOCK}, last_error = $2::jsonb,
301
+ updated_at = ${NOW}
302
+ WHERE id = $1
303
+ `, [id, JSON.stringify(error)]);
304
+
305
+ return {
306
+ result: 'failed',
307
+ failureJob: row.failure_job === null ? null : await addFailureJob(executor, p, row, { error, buildFailureJob }),
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Deletes the ended lingers, and the holds without their running job (from a manual change, or old code).
313
+ * @param {Executor} executor
314
+ * @param {string} p
315
+ * @param {number} limit
316
+ * @returns {Promise<{ lingers: number, orphans: number }>}
317
+ */
318
+ export async function sweepMutexes(executor, p, limit) {
319
+ const lingers = await executor.run(`
320
+ DELETE FROM ${p}mutexes WHERE mutex_key IN (
321
+ SELECT mutex_key FROM ${p}mutexes WHERE held_until < ${NOW} LIMIT $1 FOR UPDATE SKIP LOCKED
322
+ )
323
+ `, [limit]);
324
+ const orphans = await executor.run(`
325
+ DELETE FROM ${p}mutexes WHERE mutex_key IN (
326
+ SELECT m.mutex_key FROM ${p}mutexes m
327
+ WHERE m.held_until IS NULL AND NOT EXISTS (
328
+ SELECT 1 FROM ${p}jobs j WHERE j.id = m.job_id AND j.status = 'running' AND j.lock_token = m.lock_token
329
+ )
330
+ LIMIT $1 FOR UPDATE SKIP LOCKED
331
+ )
332
+ `, [limit]);
333
+
334
+ return { lingers, orphans };
335
+ }
336
+
337
+ /**
338
+ * Deletes a batch of completed and failed jobs whose retention ended, with their payloads. SKIP LOCKED: processes
339
+ * share the work.
340
+ * @param {Executor} executor
341
+ * @param {string} p
342
+ * @param {number} limit
343
+ * @returns {Promise<number>} The deleted jobs.
344
+ */
345
+ export async function deleteExpiredJobs(executor, p, limit) {
346
+ const rows = await executor.query(`
347
+ WITH expired AS (
348
+ SELECT id FROM ${p}jobs WHERE expires_at < ${NOW} ORDER BY expires_at LIMIT $1 FOR UPDATE SKIP LOCKED
349
+ ),
350
+ deleted AS (
351
+ DELETE FROM ${p}jobs j USING expired WHERE j.id = expired.id RETURNING j.id
352
+ ),
353
+ payloads AS (
354
+ DELETE FROM ${p}payloads pl USING deleted WHERE pl.id = deleted.id
355
+ )
356
+ SELECT count(*) AS count FROM deleted
357
+ `, [limit]);
358
+
359
+ return Number(rows[0].count);
360
+ }
361
+
362
+ /**
363
+ * For drain({ includeDelayed }): the pending jobs of the names are due now.
364
+ * @param {Executor} executor
365
+ * @param {string} p
366
+ * @param {string[]} names
367
+ * @returns {Promise<void>}
368
+ */
369
+ export async function makeDue(executor, p, names) {
370
+ await executor.run(`
371
+ UPDATE ${p}jobs SET run_at = ${runAt('interval \'0\'')}
372
+ WHERE status = 'pending' AND job_name = ANY($1::text[]) AND run_at > ${NOW}
373
+ `, [names]);
374
+ }
375
+
376
+ /**
377
+ * The counts per job name, for alerts such as "queue stuck".
378
+ * @param {Executor} executor
379
+ * @param {string} p
380
+ * @returns {Promise<import('../types.js').JobStats[]>}
381
+ */
382
+ export async function getStats(executor, p) {
383
+ const rows = await executor.query(`
384
+ SELECT job_name,
385
+ count(*) FILTER (WHERE status = 'pending') AS pending,
386
+ count(*) FILTER (WHERE status = 'pending' AND run_at <= ${NOW}) AS due,
387
+ count(*) FILTER (WHERE status = 'running') AS running,
388
+ count(*) FILTER (WHERE status = 'running' AND timed_out_at IS NOT NULL) AS stuck,
389
+ count(*) FILTER (WHERE status = 'failed') AS failed,
390
+ (EXTRACT(EPOCH FROM ${NOW} - min(run_at) FILTER (WHERE status = 'pending' AND run_at <= ${NOW}))
391
+ * 1000)::bigint AS oldest_due_age_ms
392
+ FROM ${p}jobs
393
+ WHERE status IN ('pending', 'running', 'failed')
394
+ GROUP BY job_name
395
+ ORDER BY job_name
396
+ `, []);
397
+
398
+ return rows.map(row => ({
399
+ name: String(row.job_name),
400
+ pending: Number(row.pending),
401
+ due: Number(row.due),
402
+ running: Number(row.running),
403
+ stuck: Number(row.stuck),
404
+ failed: Number(row.failed),
405
+ oldestDueAgeMs: row.oldest_due_age_ms === null ? null : Number(row.oldest_due_age_ms),
406
+ }));
407
+ }
@@ -0,0 +1,224 @@
1
+ // The worker side of the store on PostgreSQL: probe, candidates, claim. Times are computed in SQL.
2
+
3
+ /** @typedef {import('../types.js').Executor} Executor */
4
+ /** @typedef {import('./store.js').ProbeRow} ProbeRow */
5
+ /** @typedef {import('./store.js').Candidate} Candidate */
6
+ /** @typedef {import('./store.js').ClaimParams} ClaimParams */
7
+ /** @typedef {import('./store.js').ClaimedJob} ClaimedJob */
8
+
9
+ /** The due jobs of each keyed name that the FIFO of the mutex keys reads */
10
+ export const MUTEX_WINDOW_SIZE = 500;
11
+
12
+ /**
13
+ * @param {string} column
14
+ * @returns {string} The epoch milliseconds of a time column.
15
+ */
16
+ const epochMs = column => `(EXTRACT(EPOCH FROM ${column}) * 1000)::bigint`;
17
+ const HELD = '(m.held_until IS NULL OR m.held_until >= clock_timestamp())';
18
+
19
+ /**
20
+ * The next due time of each job name with pending jobs. PostgreSQL has no loose index scan: a recursive CTE skips from
21
+ * one pending name to the next on the partial index, two index probes per pending name.
22
+ * @param {Executor} executor
23
+ * @param {string} p
24
+ * @returns {Promise<ProbeRow[]>}
25
+ */
26
+ export async function probe(executor, p) {
27
+ const rows = await executor.query(`
28
+ WITH RECURSIVE names AS (
29
+ (SELECT job_name FROM ${p}jobs WHERE status = 'pending' ORDER BY job_name LIMIT 1)
30
+ UNION ALL
31
+ SELECT (
32
+ SELECT j.job_name FROM ${p}jobs j
33
+ WHERE j.status = 'pending' AND j.job_name > names.job_name ORDER BY j.job_name LIMIT 1
34
+ ) FROM names WHERE names.job_name IS NOT NULL
35
+ )
36
+ SELECT names.job_name,
37
+ (EXTRACT(EPOCH FROM (
38
+ SELECT j.run_at FROM ${p}jobs j
39
+ WHERE j.status = 'pending' AND j.job_name = names.job_name ORDER BY j.run_at LIMIT 1
40
+ ) - clock_timestamp()) * 1000)::bigint AS delay_ms
41
+ FROM names WHERE names.job_name IS NOT NULL
42
+ `, []);
43
+
44
+ return rows.map(row => ({ name: String(row.job_name), delayMs: Number(row.delay_ms) }));
45
+ }
46
+
47
+ /**
48
+ * The due jobs that can start now, oldest first: at most `limit` of each name. A job of a keyed name waits if one of
49
+ * its keys is held, or an older due job of a keyed name has it (FIFO per key over the window of the keyed names).
50
+ * @param {Executor} executor
51
+ * @param {string} p
52
+ * @param {Map<string, number>} limitsByName
53
+ * @param {string[]} keyedNames The handled names whose definition has mutex keys.
54
+ * @returns {Promise<Candidate[]>}
55
+ */
56
+ export async function findCandidates(executor, p, limitsByName, keyedNames) {
57
+ const keyed = new Set(keyedNames);
58
+ const plainNames = [...limitsByName.keys()].filter(name => !keyed.has(name));
59
+ const keyedRequested = [...limitsByName.keys()].filter(name => keyed.has(name));
60
+ /** @type {Record<string, unknown>[]} */
61
+ const rows = [];
62
+
63
+ if (plainNames.length) {
64
+ rows.push(...await executor.query(`
65
+ SELECT due.id, due.job_name, ${epochMs('due.run_at')} AS run_at_ms
66
+ FROM unnest($1::text[], $2::integer[]) AS request(name, max_jobs)
67
+ CROSS JOIN LATERAL (
68
+ SELECT id, job_name, run_at FROM ${p}jobs
69
+ WHERE status = 'pending' AND job_name = request.name AND run_at <= clock_timestamp()
70
+ ORDER BY run_at, id LIMIT request.max_jobs
71
+ ) AS due
72
+ `, [plainNames, plainNames.map(name => limitsByName.get(name))]));
73
+ }
74
+
75
+ if (keyedRequested.length) {
76
+ rows.push(...await executor.query(`
77
+ WITH w AS (
78
+ SELECT due.* FROM unnest($1::text[]) AS keyed(name)
79
+ CROSS JOIN LATERAL (
80
+ SELECT id, job_name, run_at, mutex_keys FROM ${p}jobs
81
+ WHERE status = 'pending' AND job_name = keyed.name AND run_at <= clock_timestamp()
82
+ ORDER BY run_at, id LIMIT ${MUTEX_WINDOW_SIZE}
83
+ ) AS due
84
+ ),
85
+ wk AS (
86
+ SELECT w.id, k.mutex_key,
87
+ ROW_NUMBER() OVER (PARTITION BY k.mutex_key ORDER BY w.run_at, w.id) AS key_rank
88
+ FROM w CROSS JOIN LATERAL unnest(w.mutex_keys) AS k(mutex_key)
89
+ ),
90
+ busy AS (
91
+ SELECT wk.mutex_key FROM wk JOIN ${p}mutexes m ON m.mutex_key = wk.mutex_key
92
+ WHERE wk.key_rank = 1 AND ${HELD}
93
+ ),
94
+ blocked AS (
95
+ SELECT id FROM wk WHERE key_rank > 1
96
+ UNION SELECT wk.id FROM wk JOIN busy ON busy.mutex_key = wk.mutex_key
97
+ ),
98
+ eligible AS (
99
+ SELECT w.id, w.job_name, w.run_at,
100
+ ROW_NUMBER() OVER (PARTITION BY w.job_name ORDER BY w.run_at, w.id) AS name_rank
101
+ FROM w WHERE NOT EXISTS (SELECT 1 FROM blocked b WHERE b.id = w.id)
102
+ )
103
+ SELECT e.id, e.job_name, ${epochMs('e.run_at')} AS run_at_ms
104
+ FROM eligible e JOIN unnest($2::text[], $3::integer[]) AS request(name, max_jobs)
105
+ ON request.name = e.job_name AND e.name_rank <= request.max_jobs
106
+ `, [keyedNames, keyedRequested, keyedRequested.map(name => limitsByName.get(name))]));
107
+ }
108
+
109
+ return rows
110
+ .map(row => ({ id: String(row.id), name: String(row.job_name), runAtMs: Number(row.run_at_ms) }))
111
+ .toSorted((a, b) => a.runAtMs - b.runAtMs || (a.id < b.id ? -1 : 1));
112
+ }
113
+
114
+ /**
115
+ * Takes the keys of the picked jobs, all or none per job. A held key stays with its holder; an ended linger is taken
116
+ * over. Keys are inserted in ascending order: no deadlocks between claims.
117
+ * @param {Executor} executor
118
+ * @param {string} p
119
+ * @param {{ id: string, mutexKeys: readonly string[] }[]} jobs The picked keyed jobs: their keys are distinct.
120
+ * @param {string} lockToken
121
+ * @returns {Promise<Set<string>>} The ids of the jobs that got all their keys.
122
+ */
123
+ async function takeKeys(executor, p, jobs, lockToken) {
124
+ const holds = jobs
125
+ .flatMap(job => job.mutexKeys.map(key => ({ key, id: job.id })))
126
+ .toSorted((a, b) => (a.key < b.key ? -1 : 1));
127
+ const acquired = new Set((await executor.query(`
128
+ INSERT INTO ${p}mutexes AS m (mutex_key, job_id, lock_token, held_until, created_at)
129
+ SELECT hold.mutex_key, hold.job_id, $3, NULL, clock_timestamp()
130
+ FROM unnest($1::text[], $2::text[]) WITH ORDINALITY AS hold(mutex_key, job_id, position)
131
+ ORDER BY hold.position
132
+ ON CONFLICT (mutex_key) DO UPDATE
133
+ SET job_id = EXCLUDED.job_id, lock_token = EXCLUDED.lock_token, held_until = NULL,
134
+ created_at = EXCLUDED.created_at
135
+ WHERE m.held_until < clock_timestamp()
136
+ RETURNING m.mutex_key
137
+ `, [holds.map(hold => hold.key), holds.map(hold => hold.id), lockToken])).map(row => String(row.mutex_key)));
138
+ const withAllKeys = new Set(jobs.filter(job => job.mutexKeys.every(key => acquired.has(key))).map(job => job.id));
139
+ const partial = holds.filter(hold => !withAllKeys.has(hold.id) && acquired.has(hold.key));
140
+
141
+ if (partial.length) {
142
+ await executor.query(`DELETE FROM ${p}mutexes WHERE mutex_key = ANY($1::text[]) AND lock_token = $2`, [
143
+ partial.map(hold => hold.key),
144
+ lockToken,
145
+ ]);
146
+ }
147
+
148
+ return withAllKeys;
149
+ }
150
+
151
+ /**
152
+ * Claims due jobs among the candidates. Call it in a queue transaction.
153
+ * @param {Executor} executor
154
+ * @param {string} p
155
+ * @param {ClaimParams} params
156
+ * @returns {Promise<ClaimedJob[]>} In the order of run_at.
157
+ */
158
+ export async function claim(executor, p, { candidateIds, lockToken, lockedBy, leaseMs, pick }) {
159
+ if (!candidateIds.length) {
160
+ return [];
161
+ }
162
+
163
+ const lockedRows = (await executor.query(`
164
+ SELECT id, job_name, array_to_json(mutex_keys)::text AS mutex_keys, attempts, max_attempts, stalls,
165
+ max_stalls, failure_job, ${epochMs('created_at')} AS created_at_ms,
166
+ (EXTRACT(EPOCH FROM clock_timestamp() - run_at) * 1000)::bigint AS waited_ms
167
+ FROM ${p}jobs
168
+ WHERE id = ANY($1::text[]) AND status = 'pending' AND run_at <= clock_timestamp()
169
+ ORDER BY run_at, id
170
+ FOR UPDATE SKIP LOCKED
171
+ `, [candidateIds])).map(row => ({
172
+ id: String(row.id),
173
+ name: String(row.job_name),
174
+ mutexKeys: row.mutex_keys === null ? [] : JSON.parse(String(row.mutex_keys)),
175
+ attempts: Number(row.attempts),
176
+ maxAttempts: Number(row.max_attempts),
177
+ stalls: Number(row.stalls),
178
+ maxStalls: Number(row.max_stalls),
179
+ failureJob: row.failure_job === null ? null : String(row.failure_job),
180
+ enqueuedAt: new Date(Number(row.created_at_ms)),
181
+ waitedMs: Number(row.waited_ms),
182
+ lockToken,
183
+ }));
184
+ const lockedKeys = [...new Set(lockedRows.flatMap(row => row.mutexKeys))];
185
+ const busyKeys = lockedKeys.length
186
+ ? new Set((await executor.query(`
187
+ SELECT m.mutex_key FROM ${p}mutexes m WHERE m.mutex_key = ANY($1::text[]) AND ${HELD}
188
+ `, [lockedKeys])).map(row => String(row.mutex_key)))
189
+ : new Set();
190
+ const picked = pick(lockedRows, busyKeys);
191
+ const keyedPicked = picked.filter(row => row.mutexKeys.length);
192
+ const withKeys = keyedPicked.length ? await takeKeys(executor, p, keyedPicked, lockToken) : new Set();
193
+ const claimed = picked.filter(row => !row.mutexKeys.length || withKeys.has(row.id));
194
+
195
+ if (claimed.length) {
196
+ await executor.query(`
197
+ UPDATE ${p}jobs
198
+ SET status = 'running', lock_token = $2, locked_by = $3,
199
+ locked_until = clock_timestamp() + $4 * interval '1 millisecond', started_at = clock_timestamp(),
200
+ timed_out_at = NULL, updated_at = clock_timestamp()
201
+ WHERE id = ANY($1::text[])
202
+ `, [claimed.map(row => row.id), lockToken, lockedBy, leaseMs]);
203
+ }
204
+
205
+ return claimed;
206
+ }
207
+
208
+ /**
209
+ * @param {Executor} executor
210
+ * @param {string} p
211
+ * @param {string[]} ids
212
+ * @returns {Promise<Map<string, unknown>>} The payloads by job id, as JSON gives them back.
213
+ */
214
+ export async function readPayloads(executor, p, ids) {
215
+ if (!ids.length) {
216
+ return new Map();
217
+ }
218
+
219
+ const rows = await executor.query(`
220
+ SELECT id, payload::text AS payload FROM ${p}payloads WHERE id = ANY($1::text[])
221
+ `, [ids]);
222
+
223
+ return new Map(rows.map(row => [String(row.id), JSON.parse(String(row.payload))]));
224
+ }