@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,136 @@
1
+ // The store: the SQL of one dialect, bound to an adapter and a table prefix.
2
+ import * as mysqlFinal from './mysql_final.js';
3
+ import * as mysqlWorker from './mysql_worker.js';
4
+ import * as postgresFinal from './postgres_final.js';
5
+ import * as postgresWorker from './postgres_worker.js';
6
+
7
+ /**
8
+ * @typedef {object} ProbeRow
9
+ * @property {string} name A job name with pending jobs.
10
+ * @property {number} delayMs Until its oldest pending job is due: 0 or less means due now.
11
+ */
12
+
13
+ /**
14
+ * @typedef {object} Candidate
15
+ * @property {string} id
16
+ * @property {string} name
17
+ * @property {number} runAtMs
18
+ */
19
+
20
+ /**
21
+ * A job that a claim locked, and then claimed: it runs.
22
+ * @typedef {object} ClaimedJob
23
+ * @property {string} id
24
+ * @property {string} name
25
+ * @property {string[]} mutexKeys
26
+ * @property {number} attempts The failed attempts so far.
27
+ * @property {number} maxAttempts
28
+ * @property {number} stalls
29
+ * @property {number} maxStalls
30
+ * @property {string | null} failureJob
31
+ * @property {Date} enqueuedAt
32
+ * @property {number} waitedMs Since it was due.
33
+ * @property {string} lockToken
34
+ */
35
+
36
+ /**
37
+ * @typedef {object} ClaimParams
38
+ * @property {string[]} candidateIds
39
+ * @property {string} lockToken Of this claim.
40
+ * @property {string} lockedBy The process.
41
+ * @property {number} leaseMs
42
+ * @property {(locked: ClaimedJob[], busyKeys: Set<string>) => ClaimedJob[]} pick The jobs to take among the locked
43
+ * ones (in the order of run_at): the free slots, the free keys. The keys of the picked jobs must be distinct.
44
+ */
45
+
46
+ /**
47
+ * A job that this process claimed: every write of it checks the lock token.
48
+ * @typedef {object} OwnedJob
49
+ * @property {string} id
50
+ * @property {string} lockToken
51
+ */
52
+
53
+ /**
54
+ * The job that fails for good, as the failure job gets it.
55
+ * @typedef {object} FailedJob
56
+ * @property {string} id
57
+ * @property {string} name
58
+ * @property {unknown} payload Null: its payload is missing.
59
+ * @property {number} attempt
60
+ * @property {number} maxAttempts
61
+ * @property {string} failureJob The name of its failure job.
62
+ * @property {import('../error_json.js').ErrorJson} error
63
+ */
64
+
65
+ /**
66
+ * Builds the failure job: its options from its definition, else the defaults; no failure job of its own.
67
+ * @typedef {(failed: FailedJob) => import('../new_job.js').NewJob} FailureJobBuilder
68
+ */
69
+
70
+ /** @typedef {{ added: import('../new_job.js').NewJob } | { notAdded: unknown }} FailureJobResult */
71
+
72
+ /**
73
+ * @typedef {object} Store
74
+ * @property {() => Promise<ProbeRow[]>} probe
75
+ * @property {(limitsByName: Map<string, number>, keyedNames: string[]) => Promise<Candidate[]>} findCandidates
76
+ * @property {(params: ClaimParams) => Promise<ClaimedJob[]>} claim In a queue transaction of its own.
77
+ * @property {(ids: string[]) => Promise<Map<string, unknown>>} readPayloads
78
+ * @property {(jobs: OwnedJob[], leaseMs: number) => Promise<Set<string>>} renewLeases
79
+ * @property {(job: OwnedJob) => Promise<boolean>} markTimedOut
80
+ * @property {(job: OwnedJob) => Promise<boolean>} completeJob
81
+ * @property {(job: OwnedJob, params: { delayMs: number, error: import('../error_json.js').ErrorJson })
82
+ * => Promise<boolean>} retryJob
83
+ * @property {(
84
+ * job: OwnedJob,
85
+ * params: { error: import('../error_json.js').ErrorJson, buildFailureJob: FailureJobBuilder },
86
+ * ) => Promise<{ changed: boolean, failureJob: FailureJobResult | null }>} failJob
87
+ * @property {(job: OwnedJob, params: { delayMs: number, lingerMs: number }) => Promise<boolean>} releaseJob
88
+ * @property {(limit: number) => Promise<{ id: string, name: string }[]>} findStaleJobs
89
+ * @property {(id: string, params: { leaseMs: number, buildFailureJob: FailureJobBuilder })
90
+ * => Promise<{ result: 'released' | 'failed' | null, failureJob: FailureJobResult | null }>} recoverStaleJob
91
+ * @property {(limit: number) => Promise<{ lingers: number, orphans: number }>} sweepMutexes
92
+ * @property {(limit: number) => Promise<number>} deleteExpiredJobs
93
+ * @property {(names: string[]) => Promise<void>} makeDue For drain({ includeDelayed }).
94
+ * @property {() => Promise<import('../types.js').JobStats[]>} getStats
95
+ */
96
+
97
+ /**
98
+ * @param {import('../types.js').Adapter<any>} adapter
99
+ * @param {string} tablePrefix Checked.
100
+ * @returns {Store}
101
+ */
102
+ export function createStore(adapter, tablePrefix) {
103
+ const sql = adapter.dialect === 'mysql' ? mysqlWorker : postgresWorker;
104
+ const final = adapter.dialect === 'mysql' ? mysqlFinal : postgresFinal;
105
+ /**
106
+ * @template T
107
+ * @param {(executor: import('../types.js').Executor) => Promise<T>} func
108
+ * @returns {Promise<T>}
109
+ */
110
+ const inQueue = func => adapter.queueTransaction(func);
111
+ const p = tablePrefix;
112
+
113
+ return {
114
+ probe: () => sql.probe(adapter.queue, tablePrefix),
115
+ findCandidates: (limitsByName, keyedNames) => sql.findCandidates(
116
+ adapter.queue,
117
+ tablePrefix,
118
+ limitsByName,
119
+ keyedNames,
120
+ ),
121
+ claim: params => adapter.queueTransaction(executor => sql.claim(executor, tablePrefix, params)),
122
+ readPayloads: ids => sql.readPayloads(adapter.queue, tablePrefix, ids),
123
+ renewLeases: (jobs, leaseMs) => inQueue(executor => final.renewLeases(executor, p, jobs, leaseMs)),
124
+ markTimedOut: job => final.markTimedOut(adapter.queue, p, job),
125
+ completeJob: job => inQueue(executor => final.completeJob(executor, p, job)),
126
+ retryJob: (job, params) => inQueue(executor => final.retryJob(executor, p, job, params)),
127
+ failJob: (job, params) => inQueue(executor => final.failJob(executor, p, job, params)),
128
+ releaseJob: (job, params) => inQueue(executor => final.releaseJob(executor, p, job, params)),
129
+ findStaleJobs: limit => final.findStaleJobs(adapter.queue, p, limit),
130
+ recoverStaleJob: (id, params) => inQueue(executor => final.recoverStaleJob(executor, p, id, params)),
131
+ sweepMutexes: limit => inQueue(executor => final.sweepMutexes(executor, p, limit)),
132
+ deleteExpiredJobs: limit => inQueue(executor => final.deleteExpiredJobs(executor, p, limit)),
133
+ makeDue: names => inQueue(executor => final.makeDue(executor, p, names)),
134
+ getStats: () => final.getStats(adapter.queue, p),
135
+ };
136
+ }
@@ -0,0 +1,22 @@
1
+ /** MySQL: deadlock, lock wait timeout. PostgreSQL: serialization failure, deadlock. */
2
+ const TRANSIENT_CODES = new Set(['ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT', '40001', '40P01']);
3
+ const TRANSIENT_ERRNOS = new Set([1205, 1213]);
4
+
5
+ /**
6
+ * A database error that a retry of the same work can pass: the transaction lost a lock race.
7
+ * @param {unknown} error
8
+ * @returns {boolean}
9
+ */
10
+ export function isTransientDbError(error) {
11
+ for (let current = error, depth = 0; current && depth < 5; depth++) {
12
+ const { code, errno, cause } = /** @type {{ code?: unknown, errno?: unknown, cause?: unknown }} */ (current);
13
+
14
+ if (TRANSIENT_CODES.has(String(code)) || TRANSIENT_ERRNOS.has(Number(errno))) {
15
+ return true;
16
+ }
17
+
18
+ current = cause;
19
+ }
20
+
21
+ return false;
22
+ }
package/src/types.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The public types of sqlane. See docs/design.md.
2
2
 
3
3
  /**
4
- * A duration: a string (`'500ms'`, `'5s'`, `'10m'`, `'1h'`, `'30d'`) or milliseconds.
4
+ * A duration: milliseconds, or a string of parse-duration (`'500ms'`, `'5s'`, `'4m30s'`, `'1h'`, `'2 days'`).
5
5
  * @typedef {string | number} Duration
6
6
  */
7
7
 
@@ -183,12 +183,23 @@
183
183
  * @property {'mysql' | 'postgres'} dialect
184
184
  * @property {(connection: C) => Executor} wrap Used by sqlane: wraps a connection given to `enqueue`.
185
185
  * @property {Executor} queue Used by sqlane: the queue's own pool.
186
+ * @property {<T>(func: (executor: Executor) => Promise<T>) => Promise<T>} withConnection Used by sqlane: runs func on
187
+ * one connection of the queue's pool, then releases it.
188
+ * @property {<T>(func: (executor: Executor) => Promise<T>) => Promise<T>} queueTransaction Used by sqlane: runs func
189
+ * in a `READ COMMITTED` transaction on a connection of the queue's pool.
190
+ * @property {<T>(connection: C, func: (executor: Executor) => Promise<T>) => Promise<T>} inTransaction Used by sqlane:
191
+ * a pool → func in a transaction of its own; a connection or a client → func on it, in the caller's transaction.
192
+ * @property {boolean} [notify] PostgreSQL: hints by LISTEN/NOTIFY.
193
+ * @property {(channel: string, onMessage: (message: string) => void) => Promise<() => Promise<void>>} [listen] Used by
194
+ * sqlane, with `notify`: LISTEN on a connection of its own; resolves to the unlisten function.
186
195
  */
187
196
 
188
197
  /**
189
198
  * Runs SQL. Used by sqlane, through an adapter.
190
199
  * @typedef {object} Executor
191
200
  * @property {(sql: string, params: readonly unknown[]) => Promise<readonly Record<string, unknown>[]>} query
201
+ * @property {(sql: string, params: readonly unknown[]) => Promise<number>} run Runs an INSERT, UPDATE or DELETE:
202
+ * the number of rows it changed.
192
203
  */
193
204
 
194
205
  /**
@@ -273,7 +284,8 @@
273
284
  * @property {(options?: DrainOptions) => Promise<DrainResult>} drain For tests: runs the due jobs of the handled
274
285
  * names until none is left, without `start()`.
275
286
  * @property {(options?: CloseOptions) => Promise<void>} close
276
- * @property {(options?: MigrateOptions) => Promise<void>} migrate
287
+ * @property {(options?: MigrateOptions) => Promise<{ applied: string[] }>} migrate Applies the missing migrations:
288
+ * their ids.
277
289
  * @property {() => Promise<JobStats[]>} getStats
278
290
  * @property {(jobName: string) => void} wake Low level: probes this name now.
279
291
  */
@@ -0,0 +1,181 @@
1
+ import { parseDuration } from './duration.js';
2
+
3
+ /** A job name: no `:` (the job id is `<name>:<jobId>`), lowercase. */
4
+ export const JOB_NAME_REGEXP = /^[a-z][a-z0-9_.-]{0,127}$/;
5
+
6
+ /** A table prefix: the longest table name, `<prefix>migrations`, stays within the 63 characters of PostgreSQL. */
7
+ export const TABLE_PREFIX_REGEXP = /^[a-z][a-z0-9_]{0,31}$/;
8
+
9
+ /** Printable ASCII, no space at the ends: MySQL `ascii_bin` ignores trailing spaces in comparisons. */
10
+ export const PRINTABLE_ID_REGEXP = /^[\x21-\x7E](?:[\x20-\x7E]*[\x21-\x7E])?$/;
11
+
12
+ /**
13
+ * Where a check runs: for the error message and its class.
14
+ * @typedef {object} Scope
15
+ * @property {string} where E.g. `defineJob("build_report")`.
16
+ * @property {new (message: string) => Error} ErrorClass
17
+ */
18
+
19
+ /**
20
+ * @param {unknown} value
21
+ * @returns {value is Record<string, unknown>}
22
+ */
23
+ export function isPlainObject(value) {
24
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
25
+ }
26
+
27
+ /**
28
+ * Throws if the value is not an object with only the allowed keys.
29
+ * @param {unknown} value
30
+ * @param {readonly string[]} allowedKeys
31
+ * @param {string} label E.g. `"options"`.
32
+ * @param {Scope} scope
33
+ * @returns {Record<string, unknown>}
34
+ */
35
+ export function checkObject(value, allowedKeys, label, { where, ErrorClass }) {
36
+ if (!isPlainObject(value)) {
37
+ throw new ErrorClass(`${where}: ${label} must be an object`);
38
+ }
39
+
40
+ const unknownKeys = Object.keys(value).filter(key => !allowedKeys.includes(key));
41
+
42
+ if (unknownKeys.length) {
43
+ throw new ErrorClass(`${where}: unknown ${label}: ${unknownKeys.join(', ')}`);
44
+ }
45
+
46
+ return value;
47
+ }
48
+
49
+ /**
50
+ * @param {unknown} value
51
+ * @param {string} label
52
+ * @param {Scope} scope
53
+ * @param {number} [min] Default: 1.
54
+ */
55
+ export function checkInteger(value, label, { where, ErrorClass }, min = 1) {
56
+ if (!Number.isSafeInteger(value) || /** @type {number} */ (value) < min) {
57
+ throw new ErrorClass(`${where}: ${label} must be an integer of at least ${min}, got ${JSON.stringify(value)}`);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * @param {unknown} value
63
+ * @param {string} label
64
+ * @param {Scope} scope
65
+ * @param {boolean} [positive] Default: false: 0 is a duration.
66
+ * @returns {number} Milliseconds.
67
+ */
68
+ export function checkDuration(value, label, { where, ErrorClass }, positive = false) {
69
+ const ms = parseDuration(value);
70
+
71
+ if (ms === null || (positive && ms === 0)) {
72
+ throw new ErrorClass(
73
+ `${where}: ${label} must be a ${positive ? 'positive ' : ''}duration ('5s', '4m30s', or milliseconds), `
74
+ + `got ${JSON.stringify(value)}`,
75
+ );
76
+ }
77
+
78
+ return ms;
79
+ }
80
+
81
+ /**
82
+ * @param {unknown} value
83
+ * @param {Scope} scope
84
+ */
85
+ export function checkBackoff(value, scope) {
86
+ const { where, ErrorClass } = scope;
87
+ const backoff = checkObject(value, ['type', 'delay', 'maxDelay', 'jitter'], '"backoff" options', scope);
88
+
89
+ if (backoff.type !== 'exponential' && backoff.type !== 'fixed') {
90
+ throw new ErrorClass(`${where}: "backoff.type" must be 'exponential' or 'fixed'`);
91
+ }
92
+
93
+ const delayMs = checkDuration(backoff.delay, '"backoff.delay"', scope);
94
+
95
+ if (backoff.maxDelay !== undefined) {
96
+ if (backoff.type === 'fixed') {
97
+ throw new ErrorClass(`${where}: "backoff.maxDelay" is only for the exponential backoff`);
98
+ }
99
+
100
+ if (checkDuration(backoff.maxDelay, '"backoff.maxDelay"', scope) < delayMs) {
101
+ throw new ErrorClass(`${where}: "backoff.maxDelay" must be at least "backoff.delay"`);
102
+ }
103
+ }
104
+
105
+ const { jitter } = backoff;
106
+
107
+ if (jitter !== undefined && (typeof jitter !== 'number' || !(jitter >= 0 && jitter <= 1))) {
108
+ throw new ErrorClass(`${where}: "backoff.jitter" must be a number from 0 to 1`);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * @param {unknown} value
114
+ * @param {Scope} scope
115
+ */
116
+ export function checkRetention(value, scope) {
117
+ const retention = checkObject(value, ['completed', 'failed'], '"retention" options', scope);
118
+
119
+ for (const key of ['completed', 'failed']) {
120
+ if (retention[key] !== undefined) {
121
+ checkDuration(retention[key], `"retention.${key}"`, scope);
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Checks the options that a definition, the instance defaults and an enqueue share.
128
+ * @param {Record<string, unknown>} options
129
+ * @param {Scope} scope
130
+ */
131
+ export function checkJobFields(options, scope) {
132
+ if (options.maxAttempts !== undefined) {
133
+ checkInteger(options.maxAttempts, '"maxAttempts"', scope);
134
+ }
135
+
136
+ if (options.maxStalls !== undefined) {
137
+ checkInteger(options.maxStalls, '"maxStalls"', scope, 0);
138
+ }
139
+
140
+ if (options.backoff !== undefined) {
141
+ checkBackoff(options.backoff, scope);
142
+ }
143
+
144
+ if (options.retention !== undefined) {
145
+ checkRetention(options.retention, scope);
146
+ }
147
+
148
+ if (options.timeout !== undefined) {
149
+ checkDuration(options.timeout, '"timeout"', scope, true);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * @param {unknown} value A name, or a definition.
155
+ * @param {string} jobName The job that names it.
156
+ * @param {Scope} scope
157
+ * @returns {string} The name of the failure job.
158
+ */
159
+ export function checkFailureJob(value, jobName, { where, ErrorClass }) {
160
+ const name = isPlainObject(value) ? value.name : value;
161
+
162
+ if (typeof name !== 'string' || !JOB_NAME_REGEXP.test(name)) {
163
+ throw new ErrorClass(`${where}: "failureJob" must be a job name or a job definition`);
164
+ }
165
+
166
+ if (name === jobName) {
167
+ throw new ErrorClass(`${where}: a job cannot be its own failure job`);
168
+ }
169
+
170
+ return name;
171
+ }
172
+
173
+ /**
174
+ * @param {unknown} tablePrefix
175
+ * @param {Scope} scope
176
+ */
177
+ export function checkTablePrefix(tablePrefix, { where, ErrorClass }) {
178
+ if (typeof tablePrefix !== 'string' || !TABLE_PREFIX_REGEXP.test(tablePrefix)) {
179
+ throw new ErrorClass(`${where}: "tablePrefix" must match ${TABLE_PREFIX_REGEXP}`);
180
+ }
181
+ }