@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,74 @@
1
+ // The schema of sqlane on MySQL 8.4+. Each statement is idempotent: MySQL commits each DDL statement, so a migration
2
+ // cut in the middle runs again from its start.
3
+
4
+ /** IDs, names, keys, tokens: ASCII, compared byte by byte (case-sensitive, like PostgreSQL). */
5
+ const ASCII = 'CHARACTER SET ascii COLLATE ascii_bin';
6
+
7
+ /**
8
+ * @param {string} p The table prefix.
9
+ * @returns {import('../types.js').Migration[]}
10
+ */
11
+ export function mysqlMigrations(p) {
12
+ return [
13
+ {
14
+ id: '0001_initial',
15
+ statements: [
16
+ `CREATE TABLE IF NOT EXISTS ${p}jobs (
17
+ id VARCHAR(255) ${ASCII} NOT NULL,
18
+ job_name VARCHAR(128) ${ASCII} NOT NULL,
19
+ status VARCHAR(16) ${ASCII} NOT NULL,
20
+ run_at DATETIME(3) NOT NULL,
21
+ attempts INT NOT NULL DEFAULT 0,
22
+ max_attempts INT NOT NULL,
23
+ stalls INT NOT NULL DEFAULT 0,
24
+ max_stalls INT NOT NULL,
25
+ mutex_keys JSON NULL,
26
+ enqueue_token VARCHAR(26) ${ASCII} NOT NULL,
27
+ lock_token VARCHAR(26) ${ASCII} NULL,
28
+ locked_by VARCHAR(255) NULL,
29
+ locked_until DATETIME(3) NULL,
30
+ timed_out_at DATETIME(3) NULL,
31
+ last_error JSON NULL,
32
+ completed_retention_ms BIGINT NOT NULL,
33
+ failed_retention_ms BIGINT NOT NULL,
34
+ failure_job VARCHAR(128) ${ASCII} NULL,
35
+ created_at DATETIME(3) NOT NULL,
36
+ started_at DATETIME(3) NULL,
37
+ finished_at DATETIME(3) NULL,
38
+ expires_at DATETIME(3) NULL,
39
+ updated_at DATETIME(3) NOT NULL,
40
+ PRIMARY KEY (id),
41
+ KEY ${p}jobs_status_name_run_at (status, job_name, run_at),
42
+ KEY ${p}jobs_expires_at (expires_at),
43
+ CONSTRAINT ${p}jobs_status CHECK (status IN ('pending', 'running', 'completed', 'failed'))
44
+ ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_bin`,
45
+ `CREATE TABLE IF NOT EXISTS ${p}payloads (
46
+ id VARCHAR(255) ${ASCII} NOT NULL,
47
+ payload JSON NOT NULL,
48
+ PRIMARY KEY (id)
49
+ ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_bin`,
50
+ `CREATE TABLE IF NOT EXISTS ${p}mutexes (
51
+ mutex_key VARCHAR(255) ${ASCII} NOT NULL,
52
+ job_id VARCHAR(255) ${ASCII} NOT NULL,
53
+ lock_token VARCHAR(26) ${ASCII} NOT NULL,
54
+ held_until DATETIME(3) NULL,
55
+ created_at DATETIME(3) NOT NULL,
56
+ PRIMARY KEY (mutex_key),
57
+ KEY ${p}mutexes_job_id (job_id)
58
+ ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_bin`,
59
+ ],
60
+ },
61
+ ];
62
+ }
63
+
64
+ /**
65
+ * @param {string} p
66
+ * @returns {string}
67
+ */
68
+ export function mysqlMigrationsTable(p) {
69
+ return `CREATE TABLE IF NOT EXISTS ${p}migrations (
70
+ id VARCHAR(64) ${ASCII} NOT NULL,
71
+ applied_at DATETIME(3) NOT NULL,
72
+ PRIMARY KEY (id)
73
+ ) ENGINE = InnoDB`;
74
+ }
@@ -0,0 +1,67 @@
1
+ // The schema of sqlane on PostgreSQL 15+. The migrations run in one transaction (transactional DDL).
2
+
3
+ /**
4
+ * @param {string} p The table prefix.
5
+ * @returns {import('../types.js').Migration[]}
6
+ */
7
+ export function postgresMigrations(p) {
8
+ return [
9
+ {
10
+ id: '0001_initial',
11
+ statements: [
12
+ `CREATE TABLE IF NOT EXISTS ${p}jobs (
13
+ id varchar(255) COLLATE "C" NOT NULL PRIMARY KEY,
14
+ job_name varchar(128) COLLATE "C" NOT NULL,
15
+ status varchar(16) COLLATE "C" NOT NULL
16
+ CONSTRAINT ${p}jobs_status CHECK (status IN ('pending', 'running', 'completed', 'failed')),
17
+ run_at timestamptz(3) NOT NULL,
18
+ attempts integer NOT NULL DEFAULT 0,
19
+ max_attempts integer NOT NULL,
20
+ stalls integer NOT NULL DEFAULT 0,
21
+ max_stalls integer NOT NULL,
22
+ mutex_keys text[] COLLATE "C",
23
+ enqueue_token varchar(26) COLLATE "C" NOT NULL,
24
+ lock_token varchar(26) COLLATE "C",
25
+ locked_by varchar(255),
26
+ locked_until timestamptz(3),
27
+ timed_out_at timestamptz(3),
28
+ last_error jsonb,
29
+ completed_retention_ms bigint NOT NULL,
30
+ failed_retention_ms bigint NOT NULL,
31
+ failure_job varchar(128) COLLATE "C",
32
+ created_at timestamptz(3) NOT NULL,
33
+ started_at timestamptz(3),
34
+ finished_at timestamptz(3),
35
+ expires_at timestamptz(3),
36
+ updated_at timestamptz(3) NOT NULL
37
+ )`,
38
+ `CREATE INDEX IF NOT EXISTS ${p}jobs_pending ON ${p}jobs (job_name, run_at) WHERE status = 'pending'`,
39
+ `CREATE INDEX IF NOT EXISTS ${p}jobs_running ON ${p}jobs (locked_until) WHERE status = 'running'`,
40
+ `CREATE INDEX IF NOT EXISTS ${p}jobs_expires_at ON ${p}jobs (expires_at)`,
41
+ `CREATE TABLE IF NOT EXISTS ${p}payloads (
42
+ id varchar(255) COLLATE "C" NOT NULL PRIMARY KEY,
43
+ payload jsonb NOT NULL
44
+ )`,
45
+ `CREATE TABLE IF NOT EXISTS ${p}mutexes (
46
+ mutex_key varchar(255) COLLATE "C" NOT NULL PRIMARY KEY,
47
+ job_id varchar(255) COLLATE "C" NOT NULL,
48
+ lock_token varchar(26) COLLATE "C" NOT NULL,
49
+ held_until timestamptz(3),
50
+ created_at timestamptz(3) NOT NULL
51
+ )`,
52
+ `CREATE INDEX IF NOT EXISTS ${p}mutexes_job_id ON ${p}mutexes (job_id)`,
53
+ ],
54
+ },
55
+ ];
56
+ }
57
+
58
+ /**
59
+ * @param {string} p
60
+ * @returns {string}
61
+ */
62
+ export function postgresMigrationsTable(p) {
63
+ return `CREATE TABLE IF NOT EXISTS ${p}migrations (
64
+ id varchar(64) COLLATE "C" NOT NULL PRIMARY KEY,
65
+ applied_at timestamptz(3) NOT NULL
66
+ )`;
67
+ }
@@ -0,0 +1,98 @@
1
+ // Enqueue on MySQL: in the caller's transaction. MySQL has no RETURNING: the rows of this call are the rows with its
2
+ // enqueue token.
3
+
4
+ /** Rows per INSERT: 14 placeholders a row stay far below the 65,535 of MySQL. */
5
+ const CHUNK_SIZE = 500;
6
+
7
+ /**
8
+ * @param {Date} date
9
+ * @returns {string} The UTC time, as DATETIME(3) reads it.
10
+ */
11
+ function toUtcDatetime(date) {
12
+ return date.toISOString().replace('T', ' ').replace('Z', '');
13
+ }
14
+
15
+ /**
16
+ * @param {import('../new_job.js').NewJob} job
17
+ * @returns {{ sql: string, params: unknown[] }} The run_at expression of the job.
18
+ */
19
+ function runAtSql(job) {
20
+ return job.runAt
21
+ ? { sql: '?', params: [toUtcDatetime(job.runAt)] }
22
+ : { sql: 'UTC_TIMESTAMP(3) + INTERVAL ? MICROSECOND', params: [job.delayMs * 1000] };
23
+ }
24
+
25
+ /**
26
+ * @param {import('../types.js').Executor} executor
27
+ * @param {string} p
28
+ * @param {import('../new_job.js').NewJob[]} jobs
29
+ * @param {string} enqueueToken
30
+ * @returns {Promise<Set<string>>} The ids that this call created.
31
+ */
32
+ async function insertChunk(executor, p, jobs, enqueueToken) {
33
+ const values = jobs.map(job => `(?, ?, 'pending', ${runAtSql(job).sql}, ?, ?, ?, ?, ?, ?, ?, `
34
+ + 'UTC_TIMESTAMP(3), UTC_TIMESTAMP(3))');
35
+ const params = jobs.flatMap(job => [
36
+ job.id,
37
+ job.name,
38
+ ...runAtSql(job).params,
39
+ job.maxAttempts,
40
+ job.maxStalls,
41
+ job.mutexKeys.length ? JSON.stringify(job.mutexKeys) : null,
42
+ enqueueToken,
43
+ job.completedRetentionMs,
44
+ job.failedRetentionMs,
45
+ job.failureJob,
46
+ ]);
47
+
48
+ // A duplicate id keeps its row: its token is not this one.
49
+ await executor.query(`
50
+ INSERT INTO ${p}jobs (id, job_name, status, run_at, max_attempts, max_stalls, mutex_keys, enqueue_token,
51
+ completed_retention_ms, failed_retention_ms, failure_job, created_at, updated_at)
52
+ VALUES ${values.join(', ')}
53
+ ON DUPLICATE KEY UPDATE id = id
54
+ `, params);
55
+
56
+ const ids = jobs.map(job => job.id);
57
+ const rows = await executor.query(`
58
+ SELECT id FROM ${p}jobs WHERE id IN (${ids.map(() => '?').join(', ')}) AND enqueue_token = ?
59
+ `, [...ids, enqueueToken]);
60
+ const created = new Set(rows.map(row => String(row.id)));
61
+ const createdJobs = jobs.filter(job => created.has(job.id));
62
+
63
+ if (createdJobs.length) {
64
+ // A payload row without its job (a manual change) belongs to the new job.
65
+ await executor.query(`
66
+ INSERT INTO ${p}payloads (id, payload) VALUES ${createdJobs.map(() => '(?, ?)').join(', ')} AS new
67
+ ON DUPLICATE KEY UPDATE payload = new.payload
68
+ `, createdJobs.flatMap(job => [job.id, job.payloadJson]));
69
+ }
70
+
71
+ return created;
72
+ }
73
+
74
+ /**
75
+ * Inserts the jobs and their payloads. Call it in a transaction.
76
+ * @param {import('../types.js').Executor} executor
77
+ * @param {string} p The table prefix.
78
+ * @param {import('../new_job.js').NewJob[]} jobs Their ids are distinct.
79
+ * @param {string} enqueueToken
80
+ * @returns {Promise<Set<string>>} The ids that this call created.
81
+ */
82
+ export async function insertJobsMysql(executor, p, jobs, enqueueToken) {
83
+ const created = new Set();
84
+ // In the order of the ids: concurrent enqueues lock the rows in one order, without deadlocks.
85
+ const sortedJobs = jobs.toSorted((a, b) => (a.id < b.id ? -1 : 1));
86
+
87
+ for (let start = 0; start < sortedJobs.length; start += CHUNK_SIZE) {
88
+ const chunk = sortedJobs.slice(start, start + CHUNK_SIZE);
89
+ // oxlint-disable-next-line no-await-in-loop -- one connection: the chunks run one after the other
90
+ const chunkCreated = await insertChunk(executor, p, chunk, enqueueToken);
91
+
92
+ for (const id of chunkCreated) {
93
+ created.add(id);
94
+ }
95
+ }
96
+
97
+ return created;
98
+ }
@@ -0,0 +1,424 @@
1
+ // The final writes on MySQL: lease renewal, complete, retry, fail, release, stall recovery, sweeps. Each write of a
2
+ // 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 { insertJobsMysql } from './mysql_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 = 'UTC_TIMESTAMP(3)';
12
+ const CLEAR_LOCK = 'lock_token = NULL, locked_by = NULL, locked_until = NULL, timed_out_at = NULL';
13
+ const enqueueToken = monotonicFactory();
14
+
15
+ /**
16
+ * @param {number} count
17
+ * @returns {string}
18
+ */
19
+ function placeholders(count) {
20
+ return Array.from({ length: count }, () => '?').join(', ');
21
+ }
22
+
23
+ /**
24
+ * Renews the leases of the jobs that the process runs. A row locked by another transaction is skipped: it does not
25
+ * block the others.
26
+ * @param {Executor} executor
27
+ * @param {string} p
28
+ * @param {OwnedJob[]} jobs
29
+ * @param {number} leaseMs
30
+ * @returns {Promise<Set<string>>} The ids whose lease was renewed.
31
+ */
32
+ export async function renewLeases(executor, p, jobs, leaseMs) {
33
+ if (!jobs.length) {
34
+ return new Set();
35
+ }
36
+
37
+ const rows = await executor.query(`
38
+ SELECT id FROM ${p}jobs FORCE INDEX (PRIMARY)
39
+ WHERE id IN (${placeholders(jobs.length)}) AND (id, lock_token) IN (${jobs.map(() => '(?, ?)').join(', ')})
40
+ AND status = 'running'
41
+ FOR UPDATE SKIP LOCKED
42
+ `, [...jobs.map(job => job.id), ...jobs.flatMap(job => [job.id, job.lockToken])]);
43
+ const ids = rows.map(row => String(row.id));
44
+
45
+ if (ids.length) {
46
+ await executor.run(`
47
+ UPDATE ${p}jobs SET locked_until = ${NOW} + INTERVAL ? MICROSECOND WHERE id IN (${placeholders(ids.length)})
48
+ `, [leaseMs * 1000, ...ids]);
49
+ }
50
+
51
+ return new Set(ids);
52
+ }
53
+
54
+ /**
55
+ * Marks a timeout: the job stays running until its handler settles (`stuck` in the stats).
56
+ * @param {Executor} executor
57
+ * @param {string} p
58
+ * @param {OwnedJob} job
59
+ * @returns {Promise<boolean>} False: the lease was lost.
60
+ */
61
+ export async function markTimedOut(executor, p, job) {
62
+ return await executor.run(`
63
+ UPDATE ${p}jobs SET timed_out_at = ${NOW}, updated_at = ${NOW} WHERE id = ? AND lock_token = ?
64
+ `, [job.id, job.lockToken]) > 0;
65
+ }
66
+
67
+ /**
68
+ * @param {Executor} executor
69
+ * @param {string} p
70
+ * @param {OwnedJob} job
71
+ * @returns {Promise<void>}
72
+ */
73
+ async function deleteHolds(executor, p, job) {
74
+ await executor.run(`DELETE FROM ${p}mutexes WHERE job_id = ? AND lock_token = ?`, [job.id, job.lockToken]);
75
+ }
76
+
77
+ /**
78
+ * @param {Executor} executor
79
+ * @param {string} p
80
+ * @param {OwnedJob} job
81
+ * @param {number} lingerMs The keys stay held this long. 0: deleted.
82
+ * @returns {Promise<void>}
83
+ */
84
+ async function lingerHolds(executor, p, job, lingerMs) {
85
+ if (lingerMs <= 0) {
86
+ await deleteHolds(executor, p, job);
87
+
88
+ return;
89
+ }
90
+
91
+ await executor.run(`
92
+ UPDATE ${p}mutexes SET held_until = ${NOW} + INTERVAL ? MICROSECOND
93
+ WHERE job_id = ? AND lock_token = ? AND held_until IS NULL
94
+ `, [lingerMs * 1000, job.id, job.lockToken]);
95
+ }
96
+
97
+ /**
98
+ * The job is done: completed until its retention ends, without its payload; its keys are freed.
99
+ * @param {Executor} executor
100
+ * @param {string} p
101
+ * @param {OwnedJob} job
102
+ * @returns {Promise<boolean>} False: no change (the lease was lost, or an earlier try committed).
103
+ */
104
+ export async function completeJob(executor, p, job) {
105
+ const changed = await executor.run(`
106
+ UPDATE ${p}jobs
107
+ SET status = 'completed', finished_at = ${NOW},
108
+ expires_at = ${NOW} + INTERVAL completed_retention_ms * 1000 MICROSECOND, ${CLEAR_LOCK},
109
+ updated_at = ${NOW}
110
+ WHERE id = ? AND lock_token = ?
111
+ `, [job.id, job.lockToken]);
112
+
113
+ if (changed) {
114
+ await executor.run(`DELETE FROM ${p}payloads WHERE id = ?`, [job.id]);
115
+ }
116
+
117
+ await deleteHolds(executor, p, job);
118
+
119
+ return changed > 0;
120
+ }
121
+
122
+ /**
123
+ * The attempt failed: pending again after the delay, one more attempt used; its keys are freed.
124
+ * @param {Executor} executor
125
+ * @param {string} p
126
+ * @param {OwnedJob} job
127
+ * @param {{ delayMs: number, error: import('../error_json.js').ErrorJson }} params
128
+ * @returns {Promise<boolean>} False: no change.
129
+ */
130
+ export async function retryJob(executor, p, job, { delayMs, error }) {
131
+ const changed = await executor.run(`
132
+ UPDATE ${p}jobs
133
+ SET status = 'pending', run_at = ${NOW} + INTERVAL ? MICROSECOND, attempts = attempts + 1, ${CLEAR_LOCK},
134
+ last_error = ?, updated_at = ${NOW}
135
+ WHERE id = ? AND lock_token = ?
136
+ `, [delayMs * 1000, JSON.stringify(error), job.id, job.lockToken]);
137
+
138
+ await deleteHolds(executor, p, job);
139
+
140
+ return changed > 0;
141
+ }
142
+
143
+ /**
144
+ * Adds the failure job of a job that this transaction failed, under a savepoint: if the database refuses it, the job
145
+ * still fails, without it.
146
+ * @param {Executor} executor
147
+ * @param {string} p
148
+ * @param {Record<string, unknown>} row The failed job, read in this transaction.
149
+ * @param {{ error: import('../error_json.js').ErrorJson, buildFailureJob: FailureJobBuilder }} params
150
+ * @returns {Promise<FailureJobResult>}
151
+ */
152
+ async function addFailureJob(executor, p, row, { error, buildFailureJob }) {
153
+ let newJob;
154
+
155
+ try {
156
+ newJob = buildFailureJob({
157
+ id: String(row.id),
158
+ name: String(row.job_name),
159
+ payload: row.payload === null ? null : JSON.parse(String(row.payload)),
160
+ attempt: Number(row.attempts) + 1,
161
+ maxAttempts: Number(row.max_attempts),
162
+ failureJob: String(row.failure_job),
163
+ error,
164
+ });
165
+ } catch (buildError) {
166
+ return { notAdded: buildError };
167
+ }
168
+
169
+ await executor.run('SAVEPOINT sqlane_failure_job', []);
170
+
171
+ try {
172
+ await insertJobsMysql(executor, p, [newJob], enqueueToken());
173
+
174
+ return { added: newJob };
175
+ } catch (insertError) {
176
+ // MySQL rolls back only the failed statement: the job row can be there without its payload
177
+ await executor.run('ROLLBACK TO SAVEPOINT sqlane_failure_job', []);
178
+
179
+ return { notAdded: insertError };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * @param {Executor} executor
185
+ * @param {string} p
186
+ * @param {string} where The condition of the row, on `j`.
187
+ * @param {unknown[]} params
188
+ * @returns {Promise<Record<string, unknown> | undefined>} The job and its payload, locked.
189
+ */
190
+ async function lockJob(executor, p, where, params) {
191
+ const [row] = await executor.query(`
192
+ SELECT j.id, j.job_name, j.lock_token, j.attempts, j.max_attempts, j.stalls, j.max_stalls, j.failure_job,
193
+ CAST(pl.payload AS CHAR) AS payload
194
+ FROM ${p}jobs j LEFT JOIN ${p}payloads pl ON pl.id = j.id
195
+ WHERE ${where}
196
+ FOR UPDATE OF j
197
+ `, params);
198
+
199
+ return row;
200
+ }
201
+
202
+ /**
203
+ * The job fails for good: failed until its retention ends, with its payload; its keys are freed; its failure job is
204
+ * added in the same transaction.
205
+ * @param {Executor} executor
206
+ * @param {string} p
207
+ * @param {OwnedJob} job
208
+ * @param {{ error: import('../error_json.js').ErrorJson, buildFailureJob: FailureJobBuilder }} params
209
+ * @returns {Promise<{ changed: boolean, failureJob: FailureJobResult | null }>}
210
+ */
211
+ export async function failJob(executor, p, job, { error, buildFailureJob }) {
212
+ const row = await lockJob(executor, p, 'j.id = ? AND j.lock_token = ?', [job.id, job.lockToken]);
213
+
214
+ if (!row) {
215
+ return { changed: false, failureJob: null };
216
+ }
217
+
218
+ await executor.run(`
219
+ UPDATE ${p}jobs
220
+ SET status = 'failed', attempts = attempts + 1, finished_at = ${NOW},
221
+ expires_at = ${NOW} + INTERVAL failed_retention_ms * 1000 MICROSECOND, ${CLEAR_LOCK},
222
+ last_error = ?, updated_at = ${NOW}
223
+ WHERE id = ?
224
+ `, [JSON.stringify(error), job.id]);
225
+ await deleteHolds(executor, p, job);
226
+
227
+ return {
228
+ changed: true,
229
+ failureJob: row.failure_job === null ? null : await addFailureJob(executor, p, row, { error, buildFailureJob }),
230
+ };
231
+ }
232
+
233
+ /**
234
+ * The job is released, without an attempt used: pending again after the delay.
235
+ * @param {Executor} executor
236
+ * @param {string} p
237
+ * @param {OwnedJob} job
238
+ * @param {{ delayMs: number, lingerMs: number }} params lingerMs: its keys stay held this long (its handler still
239
+ * runs); 0: they are freed.
240
+ * @returns {Promise<boolean>} False: no change.
241
+ */
242
+ export async function releaseJob(executor, p, job, { delayMs, lingerMs }) {
243
+ const changed = await executor.run(`
244
+ UPDATE ${p}jobs
245
+ SET status = 'pending', run_at = ${NOW} + INTERVAL ? MICROSECOND, ${CLEAR_LOCK}, updated_at = ${NOW}
246
+ WHERE id = ? AND lock_token = ?
247
+ `, [delayMs * 1000, job.id, job.lockToken]);
248
+
249
+ await lingerHolds(executor, p, job, lingerMs);
250
+
251
+ return changed > 0;
252
+ }
253
+
254
+ /**
255
+ * @param {Executor} executor
256
+ * @param {string} p
257
+ * @param {number} limit
258
+ * @returns {Promise<{ id: string, name: string }[]>} The running jobs whose lease ended, the oldest first.
259
+ */
260
+ export async function findStaleJobs(executor, p, limit) {
261
+ const rows = await executor.query(`
262
+ SELECT id, job_name FROM ${p}jobs FORCE INDEX (${p}jobs_status_name_run_at)
263
+ WHERE status = 'running' AND locked_until < ${NOW}
264
+ ORDER BY locked_until LIMIT ?
265
+ `, [limit]);
266
+
267
+ return rows.map(row => ({ id: String(row.id), name: String(row.job_name) }));
268
+ }
269
+
270
+ /**
271
+ * A running job whose lease ended: one more stall, pending again after 30 s × 2^stalls (at most 1 h); after maxStalls,
272
+ * it fails for good. Its keys linger for one lease: its old handler may still run.
273
+ * @param {Executor} executor
274
+ * @param {string} p
275
+ * @param {string} id
276
+ * @param {{ leaseMs: number, buildFailureJob: FailureJobBuilder }} params
277
+ * @returns {Promise<{ result: 'released' | 'failed' | null, failureJob: FailureJobResult | null }>} null: not stale
278
+ * (any more).
279
+ */
280
+ export async function recoverStaleJob(executor, p, id, { leaseMs, buildFailureJob }) {
281
+ const row = await lockJob(executor, p, `j.id = ? AND j.status = 'running' AND j.locked_until < ${NOW}`, [id]);
282
+
283
+ if (!row) {
284
+ return { result: null, failureJob: null };
285
+ }
286
+
287
+ const owned = { id, lockToken: String(row.lock_token) };
288
+ const error = { name: 'StalledError', message: 'The lease of the job ended: its process stopped or blocked' };
289
+ const stalls = Number(row.stalls);
290
+
291
+ await lingerHolds(executor, p, owned, leaseMs);
292
+
293
+ if (stalls < Number(row.max_stalls)) {
294
+ // run_at before stalls: MySQL sets the columns from left to right
295
+ await executor.run(`
296
+ UPDATE ${p}jobs
297
+ SET status = 'pending',
298
+ run_at = ${NOW} + INTERVAL CAST(LEAST(30000000 * POW(2, stalls), 3600000000) AS UNSIGNED) MICROSECOND,
299
+ stalls = stalls + 1, ${CLEAR_LOCK}, last_error = ?, updated_at = ${NOW}
300
+ WHERE id = ?
301
+ `, [JSON.stringify(error), id]);
302
+
303
+ return { result: 'released', failureJob: null };
304
+ }
305
+
306
+ await executor.run(`
307
+ UPDATE ${p}jobs
308
+ SET status = 'failed', stalls = stalls + 1, finished_at = ${NOW},
309
+ expires_at = ${NOW} + INTERVAL failed_retention_ms * 1000 MICROSECOND, ${CLEAR_LOCK},
310
+ last_error = ?, updated_at = ${NOW}
311
+ WHERE id = ?
312
+ `, [JSON.stringify(error), id]);
313
+
314
+ return {
315
+ result: 'failed',
316
+ failureJob: row.failure_job === null
317
+ ? null
318
+ : await addFailureJob(executor, p, row, { error, buildFailureJob }),
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Deletes the ended lingers, and the holds without their running job (from a manual change, or old code).
324
+ * @param {Executor} executor
325
+ * @param {string} p
326
+ * @param {number} limit
327
+ * @returns {Promise<{ lingers: number, orphans: number }>}
328
+ */
329
+ export async function sweepMutexes(executor, p, limit) {
330
+ const lingers = await executor.run(`DELETE FROM ${p}mutexes WHERE held_until < ${NOW} LIMIT ?`, [limit]);
331
+ const orphanKeys = (await executor.query(`
332
+ SELECT m.mutex_key FROM ${p}mutexes m
333
+ WHERE m.held_until IS NULL AND NOT EXISTS (
334
+ SELECT 1 FROM ${p}jobs j WHERE j.id = m.job_id AND j.status = 'running' AND j.lock_token = m.lock_token
335
+ )
336
+ LIMIT ?
337
+ `, [limit])).map(row => String(row.mutex_key));
338
+ const orphans = orphanKeys.length
339
+ ? await executor.run(`
340
+ DELETE m FROM ${p}mutexes m LEFT JOIN ${p}jobs j
341
+ ON j.id = m.job_id AND j.status = 'running' AND j.lock_token = m.lock_token
342
+ WHERE m.mutex_key IN (${placeholders(orphanKeys.length)}) AND m.held_until IS NULL AND j.id IS NULL
343
+ `, orphanKeys)
344
+ : 0;
345
+
346
+ return { lingers, orphans };
347
+ }
348
+
349
+ /**
350
+ * Deletes a batch of completed and failed jobs whose retention ended, with their payloads. SKIP LOCKED: processes
351
+ * share the work.
352
+ * @param {Executor} executor
353
+ * @param {string} p
354
+ * @param {number} limit
355
+ * @returns {Promise<number>} The deleted jobs.
356
+ */
357
+ export async function deleteExpiredJobs(executor, p, limit) {
358
+ const ids = (await executor.query(`
359
+ SELECT id FROM ${p}jobs FORCE INDEX (${p}jobs_expires_at)
360
+ WHERE expires_at < ${NOW} ORDER BY expires_at LIMIT ?
361
+ FOR UPDATE SKIP LOCKED
362
+ `, [limit])).map(row => String(row.id));
363
+
364
+ if (!ids.length) {
365
+ return 0;
366
+ }
367
+
368
+ const deleted = await executor.run(`DELETE FROM ${p}jobs WHERE id IN (${placeholders(ids.length)})`, ids);
369
+
370
+ await executor.run(`DELETE FROM ${p}payloads WHERE id IN (${placeholders(ids.length)})`, ids);
371
+
372
+ return deleted;
373
+ }
374
+
375
+ /**
376
+ * For drain({ includeDelayed }): the pending jobs of the names are due now.
377
+ * @param {Executor} executor
378
+ * @param {string} p
379
+ * @param {string[]} names
380
+ * @returns {Promise<void>}
381
+ */
382
+ export async function makeDue(executor, p, names) {
383
+ if (!names.length) {
384
+ return;
385
+ }
386
+
387
+ await executor.run(`
388
+ UPDATE ${p}jobs SET run_at = ${NOW}
389
+ WHERE status = 'pending' AND job_name IN (${placeholders(names.length)}) AND run_at > ${NOW}
390
+ `, names);
391
+ }
392
+
393
+ /**
394
+ * The counts per job name, for alerts such as "queue stuck". Reads the status index only.
395
+ * @param {Executor} executor
396
+ * @param {string} p
397
+ * @returns {Promise<import('../types.js').JobStats[]>}
398
+ */
399
+ export async function getStats(executor, p) {
400
+ const rows = await executor.query(`
401
+ SELECT job_name,
402
+ SUM(status = 'pending') AS pending,
403
+ SUM(status = 'pending' AND run_at <= ${NOW}) AS due,
404
+ SUM(status = 'running') AS running,
405
+ SUM(status = 'running' AND timed_out_at IS NOT NULL) AS stuck,
406
+ SUM(status = 'failed') AS failed,
407
+ TIMESTAMPDIFF(MICROSECOND, MIN(IF(status = 'pending' AND run_at <= ${NOW}, run_at, NULL)), ${NOW})
408
+ DIV 1000 AS oldest_due_age_ms
409
+ FROM ${p}jobs
410
+ WHERE status IN ('pending', 'running', 'failed')
411
+ GROUP BY job_name
412
+ ORDER BY job_name
413
+ `, []);
414
+
415
+ return rows.map(row => ({
416
+ name: String(row.job_name),
417
+ pending: Number(row.pending),
418
+ due: Number(row.due),
419
+ running: Number(row.running),
420
+ stuck: Number(row.stuck),
421
+ failed: Number(row.failed),
422
+ oldestDueAgeMs: row.oldest_due_age_ms === null ? null : Number(row.oldest_due_age_ms),
423
+ }));
424
+ }