@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,250 @@
|
|
|
1
|
+
// The worker side of the store on MySQL: probe, candidates, claim. Times are computed in SQL, in UTC.
|
|
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
|
+
const NOW = 'UTC_TIMESTAMP(3)';
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} column
|
|
15
|
+
* @returns {string} The epoch milliseconds of a time column.
|
|
16
|
+
*/
|
|
17
|
+
const epochMs = column => `TIMESTAMPDIFF(MICROSECOND, '1970-01-01', ${column}) DIV 1000`;
|
|
18
|
+
const HELD = `(m.held_until IS NULL OR m.held_until >= ${NOW})`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {number} count
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
function placeholders(count) {
|
|
25
|
+
return Array.from({ length: count }, () => '?').join(', ');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {unknown} json
|
|
30
|
+
* @returns {string[]}
|
|
31
|
+
*/
|
|
32
|
+
function parseKeys(json) {
|
|
33
|
+
return json === null || json === undefined ? [] : JSON.parse(String(json));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The SQL of the probe. `GROUP BY status, job_name`: the whole index prefix, else MySQL does not use a loose index
|
|
38
|
+
* scan.
|
|
39
|
+
* @param {string} p
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function probeSql(p) {
|
|
43
|
+
return `
|
|
44
|
+
SELECT job_name, TIMESTAMPDIFF(MICROSECOND, ${NOW}, MIN(run_at)) DIV 1000 AS delay_ms
|
|
45
|
+
FROM ${p}jobs FORCE INDEX (${p}jobs_status_name_run_at)
|
|
46
|
+
WHERE status = 'pending'
|
|
47
|
+
GROUP BY status, job_name
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The next due time of each job name with pending jobs: one index dive per pending name (loose index scan).
|
|
53
|
+
* @param {Executor} executor
|
|
54
|
+
* @param {string} p
|
|
55
|
+
* @returns {Promise<ProbeRow[]>}
|
|
56
|
+
*/
|
|
57
|
+
export async function probe(executor, p) {
|
|
58
|
+
const rows = await executor.query(probeSql(p), []);
|
|
59
|
+
|
|
60
|
+
return rows.map(row => ({ name: String(row.job_name), delayMs: Number(row.delay_ms) }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} p
|
|
65
|
+
* @param {string[]} names
|
|
66
|
+
* @param {(index: number) => string} limitSql
|
|
67
|
+
* @returns {string} The oldest due jobs of each name, one index range per name.
|
|
68
|
+
*/
|
|
69
|
+
function dueJobsSql(p, names, limitSql) {
|
|
70
|
+
return names.map((_, index) => `(
|
|
71
|
+
SELECT id, job_name, run_at, mutex_keys FROM ${p}jobs FORCE INDEX (${p}jobs_status_name_run_at)
|
|
72
|
+
WHERE status = 'pending' AND job_name = ? AND run_at <= ${NOW}
|
|
73
|
+
ORDER BY run_at, id LIMIT ${limitSql(index)}
|
|
74
|
+
)`).join(' UNION ALL ');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 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
|
|
79
|
+
* 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).
|
|
80
|
+
* @param {Executor} executor
|
|
81
|
+
* @param {string} p
|
|
82
|
+
* @param {Map<string, number>} limitsByName
|
|
83
|
+
* @param {string[]} keyedNames The handled names whose definition has mutex keys.
|
|
84
|
+
* @returns {Promise<Candidate[]>}
|
|
85
|
+
*/
|
|
86
|
+
export async function findCandidates(executor, p, limitsByName, keyedNames) {
|
|
87
|
+
const keyed = new Set(keyedNames);
|
|
88
|
+
const plainNames = [...limitsByName.keys()].filter(name => !keyed.has(name));
|
|
89
|
+
const keyedRequested = [...limitsByName.keys()].filter(name => keyed.has(name));
|
|
90
|
+
/** @type {Record<string, unknown>[]} */
|
|
91
|
+
const rows = [];
|
|
92
|
+
|
|
93
|
+
if (plainNames.length) {
|
|
94
|
+
rows.push(...await executor.query(`
|
|
95
|
+
SELECT id, job_name, ${epochMs('run_at')} AS run_at_ms
|
|
96
|
+
FROM (${dueJobsSql(p, plainNames, () => '?')}) AS due
|
|
97
|
+
`, plainNames.flatMap(name => [name, limitsByName.get(name)])));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (keyedRequested.length) {
|
|
101
|
+
const conditions = keyedRequested.map(() => '(job_name = ? AND name_rank <= ?)').join(' OR ');
|
|
102
|
+
|
|
103
|
+
rows.push(...await executor.query(`
|
|
104
|
+
WITH w AS (${dueJobsSql(p, keyedNames, () => String(MUTEX_WINDOW_SIZE))}),
|
|
105
|
+
wk AS (
|
|
106
|
+
SELECT w.id, k.mutex_key,
|
|
107
|
+
ROW_NUMBER() OVER (PARTITION BY k.mutex_key ORDER BY w.run_at, w.id) AS key_rank
|
|
108
|
+
FROM w, JSON_TABLE(w.mutex_keys, '$[*]' COLUMNS (
|
|
109
|
+
mutex_key VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin PATH '$'
|
|
110
|
+
)) AS k
|
|
111
|
+
),
|
|
112
|
+
busy AS (
|
|
113
|
+
SELECT wk.mutex_key FROM wk JOIN ${p}mutexes m ON m.mutex_key = wk.mutex_key
|
|
114
|
+
WHERE wk.key_rank = 1 AND ${HELD}
|
|
115
|
+
),
|
|
116
|
+
blocked AS (
|
|
117
|
+
SELECT id FROM wk WHERE key_rank > 1
|
|
118
|
+
UNION SELECT wk.id FROM wk JOIN busy ON busy.mutex_key = wk.mutex_key
|
|
119
|
+
),
|
|
120
|
+
eligible AS (
|
|
121
|
+
SELECT w.id, w.job_name, w.run_at,
|
|
122
|
+
ROW_NUMBER() OVER (PARTITION BY w.job_name ORDER BY w.run_at, w.id) AS name_rank
|
|
123
|
+
FROM w WHERE NOT EXISTS (SELECT 1 FROM blocked b WHERE b.id = w.id)
|
|
124
|
+
)
|
|
125
|
+
SELECT id, job_name, ${epochMs('run_at')} AS run_at_ms FROM eligible WHERE ${conditions}
|
|
126
|
+
`, [...keyedNames, ...keyedRequested.flatMap(name => [name, limitsByName.get(name)])]));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return rows
|
|
130
|
+
.map(row => ({ id: String(row.id), name: String(row.job_name), runAtMs: Number(row.run_at_ms) }))
|
|
131
|
+
.toSorted((a, b) => a.runAtMs - b.runAtMs || (a.id < b.id ? -1 : 1));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Takes the keys of the picked jobs, all or none per job. A held key stays with its holder; an ended linger is taken
|
|
136
|
+
* over. Keys are inserted in ascending order: no deadlocks between claims.
|
|
137
|
+
* @param {Executor} executor
|
|
138
|
+
* @param {string} p
|
|
139
|
+
* @param {{ id: string, mutexKeys: readonly string[] }[]} jobs The picked keyed jobs: their keys are distinct.
|
|
140
|
+
* @param {string} lockToken
|
|
141
|
+
* @returns {Promise<Set<string>>} The ids of the jobs that got all their keys.
|
|
142
|
+
*/
|
|
143
|
+
async function takeKeys(executor, p, jobs, lockToken) {
|
|
144
|
+
const holds = jobs
|
|
145
|
+
.flatMap(job => job.mutexKeys.map(key => ({ key, id: job.id })))
|
|
146
|
+
.toSorted((a, b) => (a.key < b.key ? -1 : 1));
|
|
147
|
+
const keys = holds.map(hold => hold.key);
|
|
148
|
+
// held_until last: MySQL sets the columns from left to right, and the others read its old value
|
|
149
|
+
/**
|
|
150
|
+
* @param {string} column
|
|
151
|
+
* @returns {string} Takes the new value over an ended linger.
|
|
152
|
+
*/
|
|
153
|
+
const takeOver = column => `${column} = IF(${p}mutexes.held_until < ${NOW}, new.${column}, ${p}mutexes.${column})`;
|
|
154
|
+
|
|
155
|
+
await executor.query(`
|
|
156
|
+
INSERT INTO ${p}mutexes (mutex_key, job_id, lock_token, held_until, created_at)
|
|
157
|
+
VALUES ${holds.map(() => `(?, ?, ?, NULL, ${NOW})`).join(', ')} AS new
|
|
158
|
+
ON DUPLICATE KEY UPDATE ${takeOver('job_id')}, ${takeOver('lock_token')}, ${takeOver('created_at')},
|
|
159
|
+
held_until = IF(${p}mutexes.held_until < ${NOW}, NULL, ${p}mutexes.held_until)
|
|
160
|
+
`, holds.flatMap(hold => [hold.key, hold.id, lockToken]));
|
|
161
|
+
|
|
162
|
+
const acquired = new Set((await executor.query(`
|
|
163
|
+
SELECT mutex_key FROM ${p}mutexes WHERE mutex_key IN (${placeholders(keys.length)}) AND lock_token = ?
|
|
164
|
+
`, [...keys, lockToken])).map(row => String(row.mutex_key)));
|
|
165
|
+
const withAllKeys = new Set(jobs.filter(job => job.mutexKeys.every(key => acquired.has(key))).map(job => job.id));
|
|
166
|
+
const partial = holds.filter(hold => !withAllKeys.has(hold.id) && acquired.has(hold.key));
|
|
167
|
+
|
|
168
|
+
if (partial.length) {
|
|
169
|
+
await executor.query(`
|
|
170
|
+
DELETE FROM ${p}mutexes WHERE mutex_key IN (${placeholders(partial.length)}) AND lock_token = ?
|
|
171
|
+
`, [...partial.map(hold => hold.key), lockToken]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return withAllKeys;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Claims due jobs among the candidates. Call it in a queue transaction.
|
|
179
|
+
* @param {Executor} executor
|
|
180
|
+
* @param {string} p
|
|
181
|
+
* @param {ClaimParams} params
|
|
182
|
+
* @returns {Promise<ClaimedJob[]>} In the order of run_at.
|
|
183
|
+
*/
|
|
184
|
+
export async function claim(executor, p, { candidateIds, lockToken, lockedBy, leaseMs, pick }) {
|
|
185
|
+
if (!candidateIds.length) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const lockedRows = (await executor.query(`
|
|
190
|
+
SELECT id, job_name, CAST(mutex_keys AS CHAR) AS mutex_keys, attempts, max_attempts, stalls, max_stalls,
|
|
191
|
+
failure_job, ${epochMs('created_at')} AS created_at_ms,
|
|
192
|
+
TIMESTAMPDIFF(MICROSECOND, run_at, ${NOW}) DIV 1000 AS waited_ms
|
|
193
|
+
FROM ${p}jobs
|
|
194
|
+
WHERE id IN (${placeholders(candidateIds.length)}) AND status = 'pending' AND run_at <= ${NOW}
|
|
195
|
+
ORDER BY run_at, id
|
|
196
|
+
FOR UPDATE SKIP LOCKED
|
|
197
|
+
`, candidateIds)).map(row => ({
|
|
198
|
+
id: String(row.id),
|
|
199
|
+
name: String(row.job_name),
|
|
200
|
+
mutexKeys: parseKeys(row.mutex_keys),
|
|
201
|
+
attempts: Number(row.attempts),
|
|
202
|
+
maxAttempts: Number(row.max_attempts),
|
|
203
|
+
stalls: Number(row.stalls),
|
|
204
|
+
maxStalls: Number(row.max_stalls),
|
|
205
|
+
failureJob: row.failure_job === null ? null : String(row.failure_job),
|
|
206
|
+
enqueuedAt: new Date(Number(row.created_at_ms)),
|
|
207
|
+
waitedMs: Number(row.waited_ms),
|
|
208
|
+
lockToken,
|
|
209
|
+
}));
|
|
210
|
+
const lockedKeys = [...new Set(lockedRows.flatMap(row => row.mutexKeys))];
|
|
211
|
+
const busyKeys = lockedKeys.length
|
|
212
|
+
? new Set((await executor.query(`
|
|
213
|
+
SELECT m.mutex_key FROM ${p}mutexes m WHERE m.mutex_key IN (${placeholders(lockedKeys.length)}) AND ${HELD}
|
|
214
|
+
`, lockedKeys)).map(row => String(row.mutex_key)))
|
|
215
|
+
: new Set();
|
|
216
|
+
const picked = pick(lockedRows, busyKeys);
|
|
217
|
+
const keyedPicked = picked.filter(row => row.mutexKeys.length);
|
|
218
|
+
const withKeys = keyedPicked.length ? await takeKeys(executor, p, keyedPicked, lockToken) : new Set();
|
|
219
|
+
const claimed = picked.filter(row => !row.mutexKeys.length || withKeys.has(row.id));
|
|
220
|
+
|
|
221
|
+
if (claimed.length) {
|
|
222
|
+
await executor.query(`
|
|
223
|
+
UPDATE ${p}jobs
|
|
224
|
+
SET status = 'running', lock_token = ?, locked_by = ?,
|
|
225
|
+
locked_until = ${NOW} + INTERVAL ? MICROSECOND, started_at = ${NOW}, timed_out_at = NULL,
|
|
226
|
+
updated_at = ${NOW}
|
|
227
|
+
WHERE id IN (${placeholders(claimed.length)})
|
|
228
|
+
`, [lockToken, lockedBy, leaseMs * 1000, ...claimed.map(row => row.id)]);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return claimed;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {Executor} executor
|
|
236
|
+
* @param {string} p
|
|
237
|
+
* @param {string[]} ids
|
|
238
|
+
* @returns {Promise<Map<string, unknown>>} The payloads by job id, as JSON gives them back.
|
|
239
|
+
*/
|
|
240
|
+
export async function readPayloads(executor, p, ids) {
|
|
241
|
+
if (!ids.length) {
|
|
242
|
+
return new Map();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const rows = await executor.query(`
|
|
246
|
+
SELECT id, CAST(payload AS CHAR) AS payload FROM ${p}payloads WHERE id IN (${placeholders(ids.length)})
|
|
247
|
+
`, ids);
|
|
248
|
+
|
|
249
|
+
return new Map(rows.map(row => [String(row.id), JSON.parse(String(row.payload))]));
|
|
250
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Enqueue on PostgreSQL: one statement, atomic even outside a transaction.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inserts the jobs and their payloads.
|
|
5
|
+
* @param {import('../types.js').Executor} executor
|
|
6
|
+
* @param {string} p The table prefix.
|
|
7
|
+
* @param {import('../new_job.js').NewJob[]} jobs Their ids are distinct.
|
|
8
|
+
* @param {string} enqueueToken
|
|
9
|
+
* @returns {Promise<Set<string>>} The ids that this call created.
|
|
10
|
+
*/
|
|
11
|
+
export async function insertJobsPostgres(executor, p, jobs, enqueueToken) {
|
|
12
|
+
const input = jobs.map(job => ({
|
|
13
|
+
id: job.id,
|
|
14
|
+
job_name: job.name,
|
|
15
|
+
payload: JSON.parse(job.payloadJson),
|
|
16
|
+
mutex_keys: job.mutexKeys.length ? job.mutexKeys : null,
|
|
17
|
+
max_attempts: job.maxAttempts,
|
|
18
|
+
max_stalls: job.maxStalls,
|
|
19
|
+
completed_retention_ms: job.completedRetentionMs,
|
|
20
|
+
failed_retention_ms: job.failedRetentionMs,
|
|
21
|
+
failure_job: job.failureJob,
|
|
22
|
+
delay_ms: job.delayMs,
|
|
23
|
+
run_at: job.runAt?.toISOString() ?? null,
|
|
24
|
+
}));
|
|
25
|
+
const rows = await executor.query(`
|
|
26
|
+
WITH input AS (
|
|
27
|
+
SELECT * FROM jsonb_to_recordset($1::jsonb) AS r(
|
|
28
|
+
id text, job_name text, payload jsonb, mutex_keys text[], max_attempts integer,
|
|
29
|
+
max_stalls integer, completed_retention_ms bigint, failed_retention_ms bigint, failure_job text,
|
|
30
|
+
delay_ms bigint, run_at timestamptz
|
|
31
|
+
)
|
|
32
|
+
),
|
|
33
|
+
inserted AS (
|
|
34
|
+
INSERT INTO ${p}jobs (id, job_name, status, run_at, max_attempts, max_stalls, mutex_keys, enqueue_token,
|
|
35
|
+
completed_retention_ms, failed_retention_ms, failure_job, created_at, updated_at)
|
|
36
|
+
SELECT id, job_name, 'pending',
|
|
37
|
+
-- Truncated: timestamptz(3) rounds, and a rounded-up time is not due for the claim
|
|
38
|
+
COALESCE(run_at, date_trunc('milliseconds', clock_timestamp() + delay_ms * interval '1 millisecond')),
|
|
39
|
+
max_attempts, max_stalls, mutex_keys, $2, completed_retention_ms, failed_retention_ms, failure_job,
|
|
40
|
+
clock_timestamp(), clock_timestamp()
|
|
41
|
+
FROM input ORDER BY id
|
|
42
|
+
ON CONFLICT (id) DO NOTHING
|
|
43
|
+
RETURNING id
|
|
44
|
+
),
|
|
45
|
+
payloads AS (
|
|
46
|
+
INSERT INTO ${p}payloads (id, payload)
|
|
47
|
+
SELECT input.id, input.payload FROM input JOIN inserted ON inserted.id = input.id
|
|
48
|
+
ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload
|
|
49
|
+
)
|
|
50
|
+
SELECT id FROM inserted
|
|
51
|
+
`, [JSON.stringify(input), enqueueToken]);
|
|
52
|
+
|
|
53
|
+
return new Set(rows.map(row => String(row.id)));
|
|
54
|
+
}
|