@sqlane/core 0.0.2 → 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,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
+ }