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