@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
|
@@ -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
|
+
}
|