@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
package/README.md
CHANGED
|
@@ -4,15 +4,53 @@
|
|
|
4
4
|
|
|
5
5
|
Transactional SQL job queue for Node.js, backed by MySQL or PostgreSQL.
|
|
6
6
|
|
|
7
|
-
>
|
|
8
|
-
|
|
9
|
-
## Goals
|
|
7
|
+
> Early version (0.0.x): the API can still change. See the [design](docs/design.md).
|
|
10
8
|
|
|
11
9
|
- Enqueue a job in your business transaction: no commit, no job.
|
|
12
|
-
- Many
|
|
10
|
+
- Many job names at low cost: idle names add no load to the database.
|
|
13
11
|
- Low latency: a job starts in milliseconds after the commit.
|
|
14
|
-
-
|
|
15
|
-
- MIT license.
|
|
12
|
+
- Mutex keys: jobs with a common key never run at the same time, in the whole cluster.
|
|
13
|
+
- MySQL 8.4+ and PostgreSQL 15+, with `mysql2` or `pg`. Node.js 22+. MIT license.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install @sqlane/core mysql2 # or pg
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import mysql from 'mysql2/promise';
|
|
23
|
+
import { createSqlane, defineJob } from '@sqlane/core';
|
|
24
|
+
import { mysql2Adapter } from '@sqlane/core/mysql2'; // or pgAdapter from '@sqlane/core/pg'
|
|
25
|
+
|
|
26
|
+
// Shared by producers and workers
|
|
27
|
+
export const buildReport = defineJob({
|
|
28
|
+
name: 'build_report',
|
|
29
|
+
maxAttempts: 5,
|
|
30
|
+
backoff: { type: 'exponential', delay: '5s', maxDelay: '1h' },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const queuePool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 3 });
|
|
34
|
+
const sqlane = createSqlane({ adapter: mysql2Adapter(queuePool), jobs: [buildReport] });
|
|
35
|
+
|
|
36
|
+
await sqlane.migrate(); // or getMigrations({ dialect: 'mysql' }) for your own migration tool
|
|
37
|
+
|
|
38
|
+
// Producer: with the connection of your transaction
|
|
39
|
+
await connection.beginTransaction();
|
|
40
|
+
await connection.query('INSERT INTO reports (id) VALUES (?)', [reportId]);
|
|
41
|
+
await sqlane.enqueue(connection, buildReport, { reportId }, { jobId: reportId });
|
|
42
|
+
await connection.commit();
|
|
43
|
+
|
|
44
|
+
// Worker
|
|
45
|
+
sqlane.handle(buildReport, async ({ payload, signal }) => {
|
|
46
|
+
await build(payload.reportId, { signal });
|
|
47
|
+
}, { concurrency: 2, timeout: '5m' });
|
|
48
|
+
await sqlane.start();
|
|
49
|
+
|
|
50
|
+
process.once('SIGTERM', () => sqlane.close({ deadline: '30s' }));
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
In tests, run the due jobs without `start()`: `await sqlane.drain()`. More in [`examples/`](examples).
|
|
16
54
|
|
|
17
55
|
## Development
|
|
18
56
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sqlane/core",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Transactional SQL job queue for Node.js (MySQL, PostgreSQL)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"background-jobs",
|
|
@@ -26,11 +26,17 @@
|
|
|
26
26
|
],
|
|
27
27
|
"type": "module",
|
|
28
28
|
"exports": {
|
|
29
|
-
".": "./src/index.js"
|
|
29
|
+
".": "./src/index.js",
|
|
30
|
+
"./mysql2": "./src/adapters/mysql2.js",
|
|
31
|
+
"./pg": "./src/adapters/pg.js"
|
|
30
32
|
},
|
|
31
33
|
"publishConfig": {
|
|
32
34
|
"access": "public"
|
|
33
35
|
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"parse-duration": "^2.1.11",
|
|
38
|
+
"ulid": "^3.0.2"
|
|
39
|
+
},
|
|
34
40
|
"devDependencies": {
|
|
35
41
|
"@changesets/changelog-github": "1.0.1",
|
|
36
42
|
"@changesets/cli": "3.0.3",
|
|
@@ -49,6 +55,18 @@
|
|
|
49
55
|
"typescript": "7.0.2",
|
|
50
56
|
"vitest": "5.0.5"
|
|
51
57
|
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"mysql2": "^3.11.0",
|
|
60
|
+
"pg": "^8.13.0"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"mysql2": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"pg": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
69
|
+
},
|
|
52
70
|
"engines": {
|
|
53
71
|
"node": ">=22.12.0"
|
|
54
72
|
},
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('mysql2/promise').Pool
|
|
3
|
+
* | import('mysql2/promise').PoolConnection
|
|
4
|
+
* | import('mysql2/promise').Connection} Mysql2Connection
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {Mysql2Connection} connection
|
|
9
|
+
* @returns {import('../types.js').Executor}
|
|
10
|
+
*/
|
|
11
|
+
function toExecutor(connection) {
|
|
12
|
+
return {
|
|
13
|
+
query: async (sql, params) => {
|
|
14
|
+
// The three types have the same query(); TS cannot call a method of a union of overloads.
|
|
15
|
+
const queryable = /** @type {import('mysql2/promise').Connection} */ (connection);
|
|
16
|
+
const [rows] = await queryable.query(sql, [...params]);
|
|
17
|
+
|
|
18
|
+
return Array.isArray(rows) ? /** @type {Record<string, unknown>[]} */ (rows) : [];
|
|
19
|
+
},
|
|
20
|
+
run: async (sql, params) => {
|
|
21
|
+
const queryable = /** @type {import('mysql2/promise').Connection} */ (connection);
|
|
22
|
+
const [result] = await queryable.query(sql, [...params]);
|
|
23
|
+
|
|
24
|
+
return /** @type {import('mysql2/promise').ResultSetHeader} */ (result).affectedRows;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Mysql2Connection} connection
|
|
31
|
+
* @returns {connection is import('mysql2/promise').Pool}
|
|
32
|
+
*/
|
|
33
|
+
function isPool(connection) {
|
|
34
|
+
return typeof (/** @type {{ getConnection?: unknown }} */ (connection).getConnection) === 'function';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Runs func in a transaction of its own, on a connection of the pool.
|
|
39
|
+
* @template T
|
|
40
|
+
* @param {import('mysql2/promise').Pool} pool
|
|
41
|
+
* @param {(executor: import('../types.js').Executor) => Promise<T>} func
|
|
42
|
+
* @param {boolean} [readCommitted] Default: false: the isolation level of the session.
|
|
43
|
+
* @returns {Promise<T>}
|
|
44
|
+
*/
|
|
45
|
+
async function inOwnTransaction(pool, func, readCommitted = false) {
|
|
46
|
+
const connection = await pool.getConnection();
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
if (readCommitted) {
|
|
50
|
+
// For the next transaction only
|
|
51
|
+
await connection.query('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await connection.beginTransaction();
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const result = await func(toExecutor(connection));
|
|
58
|
+
|
|
59
|
+
await connection.commit();
|
|
60
|
+
|
|
61
|
+
return result;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
await connection.rollback();
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
connection.release();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The adapter for mysql2 (its promise API). `enqueue` then takes a `Connection`, a `PoolConnection` (in a
|
|
73
|
+
* transaction) or a `Pool`.
|
|
74
|
+
* @param {import('mysql2/promise').Pool} pool The queue's own pool: claims, leases, results.
|
|
75
|
+
* @returns {import('../types.js').Adapter<Mysql2Connection>}
|
|
76
|
+
*/
|
|
77
|
+
export function mysql2Adapter(pool) {
|
|
78
|
+
return {
|
|
79
|
+
dialect: 'mysql',
|
|
80
|
+
wrap: toExecutor,
|
|
81
|
+
queue: toExecutor(pool),
|
|
82
|
+
withConnection: async (func) => {
|
|
83
|
+
const connection = await pool.getConnection();
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
return await func(toExecutor(connection));
|
|
87
|
+
} finally {
|
|
88
|
+
connection.release();
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
queueTransaction: func => inOwnTransaction(pool, func, true),
|
|
92
|
+
inTransaction: (connection, func) => (isPool(connection)
|
|
93
|
+
? inOwnTransaction(connection, func)
|
|
94
|
+
: func(toExecutor(connection))),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** @typedef {import('pg').Pool | import('pg').PoolClient | import('pg').Client} PgConnection */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} PgAdapterOptions
|
|
5
|
+
* @property {boolean} [notify] Hints by LISTEN/NOTIFY: `pg_notify` in the enqueue transaction, delivered on commit.
|
|
6
|
+
* Default: false.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {PgConnection} connection
|
|
11
|
+
* @returns {import('../types.js').Executor}
|
|
12
|
+
*/
|
|
13
|
+
function toExecutor(connection) {
|
|
14
|
+
return {
|
|
15
|
+
query: async (sql, params) => {
|
|
16
|
+
const result = await connection.query(sql, [...params]);
|
|
17
|
+
|
|
18
|
+
return result.rows;
|
|
19
|
+
},
|
|
20
|
+
run: async (sql, params) => {
|
|
21
|
+
const result = await connection.query(sql, [...params]);
|
|
22
|
+
|
|
23
|
+
return result.rowCount ?? 0;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {PgConnection} connection
|
|
30
|
+
* @returns {connection is import('pg').Pool}
|
|
31
|
+
*/
|
|
32
|
+
function isPool(connection) {
|
|
33
|
+
return typeof (/** @type {{ totalCount?: unknown }} */ (connection).totalCount) === 'number';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Runs func in a transaction of its own, on a client of the pool.
|
|
38
|
+
* @template T
|
|
39
|
+
* @param {import('pg').Pool} pool
|
|
40
|
+
* @param {(executor: import('../types.js').Executor) => Promise<T>} func
|
|
41
|
+
* @param {boolean} [readCommitted] Default: false: the isolation level of the session.
|
|
42
|
+
* @returns {Promise<T>}
|
|
43
|
+
*/
|
|
44
|
+
async function inOwnTransaction(pool, func, readCommitted = false) {
|
|
45
|
+
const client = await pool.connect();
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await client.query(readCommitted ? 'BEGIN ISOLATION LEVEL READ COMMITTED' : 'BEGIN');
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const result = await func(toExecutor(client));
|
|
52
|
+
|
|
53
|
+
await client.query('COMMIT');
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
await client.query('ROLLBACK');
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
client.release();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* LISTEN on a client of its own, taken from the pool; on an error of the client, listens again on a new one.
|
|
67
|
+
* @param {import('pg').Pool} pool
|
|
68
|
+
* @param {string} channel A safe identifier: `<tablePrefix>ready`.
|
|
69
|
+
* @param {(message: string) => void} onMessage
|
|
70
|
+
* @returns {Promise<() => Promise<void>>} Unlisten.
|
|
71
|
+
*/
|
|
72
|
+
async function listen(pool, channel, onMessage) {
|
|
73
|
+
/** @type {import('pg').PoolClient | null} */
|
|
74
|
+
let client = null;
|
|
75
|
+
let stopped = false;
|
|
76
|
+
|
|
77
|
+
/** @param {import('pg').Notification} notification */
|
|
78
|
+
const onNotification = (notification) => {
|
|
79
|
+
if (notification.channel === channel) {
|
|
80
|
+
onMessage(notification.payload ?? '');
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** @returns {Promise<void>} */
|
|
85
|
+
const connect = async () => {
|
|
86
|
+
const next = await pool.connect();
|
|
87
|
+
|
|
88
|
+
next.on('notification', onNotification);
|
|
89
|
+
next.once('error', (error) => {
|
|
90
|
+
next.release(error);
|
|
91
|
+
|
|
92
|
+
if (client === next) {
|
|
93
|
+
client = null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
reconnectLater();
|
|
97
|
+
});
|
|
98
|
+
await next.query(`LISTEN "${channel}"`);
|
|
99
|
+
client = next;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Listens again in 1 s, until it works or the listener stops. */
|
|
103
|
+
function reconnectLater() {
|
|
104
|
+
if (stopped) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
setTimeout(async () => {
|
|
109
|
+
try {
|
|
110
|
+
await connect();
|
|
111
|
+
} catch {
|
|
112
|
+
reconnectLater();
|
|
113
|
+
}
|
|
114
|
+
}, 1000).unref();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await connect();
|
|
118
|
+
|
|
119
|
+
return async () => {
|
|
120
|
+
stopped = true;
|
|
121
|
+
|
|
122
|
+
const current = client;
|
|
123
|
+
|
|
124
|
+
client = null;
|
|
125
|
+
|
|
126
|
+
if (current) {
|
|
127
|
+
try {
|
|
128
|
+
await current.query(`UNLISTEN "${channel}"`);
|
|
129
|
+
} finally {
|
|
130
|
+
current.removeListener('notification', onNotification);
|
|
131
|
+
current.release();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The adapter for pg. `enqueue` then takes a `Client`, a `PoolClient` (in a transaction) or a `Pool`.
|
|
139
|
+
* @param {import('pg').Pool} pool The queue's own pool: claims, leases, results.
|
|
140
|
+
* @param {PgAdapterOptions} [options]
|
|
141
|
+
* @returns {import('../types.js').Adapter<PgConnection>}
|
|
142
|
+
*/
|
|
143
|
+
export function pgAdapter(pool, options = {}) {
|
|
144
|
+
return {
|
|
145
|
+
dialect: 'postgres',
|
|
146
|
+
wrap: toExecutor,
|
|
147
|
+
queue: toExecutor(pool),
|
|
148
|
+
withConnection: async (func) => {
|
|
149
|
+
const client = await pool.connect();
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
return await func(toExecutor(client));
|
|
153
|
+
} finally {
|
|
154
|
+
client.release();
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
queueTransaction: func => inOwnTransaction(pool, func, true),
|
|
158
|
+
inTransaction: (connection, func) => (isPool(connection)
|
|
159
|
+
? inOwnTransaction(connection, func)
|
|
160
|
+
: func(toExecutor(connection))),
|
|
161
|
+
notify: options.notify ?? false,
|
|
162
|
+
listen: (channel, onMessage) => listen(pool, channel, onMessage),
|
|
163
|
+
};
|
|
164
|
+
}
|
package/src/backoff.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { parseDuration } from './duration.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The delay before the next attempt.
|
|
5
|
+
* @param {import('./types.js').Backoff} backoff Checked.
|
|
6
|
+
* @param {number} attempt The attempt that failed: 1 for the first.
|
|
7
|
+
* @param {() => number} [random] For tests. Default: `Math.random`.
|
|
8
|
+
* @returns {number} Milliseconds.
|
|
9
|
+
*/
|
|
10
|
+
export function getBackoffMs(backoff, attempt, random = Math.random) {
|
|
11
|
+
const delayMs = /** @type {number} */ (parseDuration(backoff.delay));
|
|
12
|
+
const jitter = backoff.jitter ?? 0.2;
|
|
13
|
+
let baseMs = delayMs;
|
|
14
|
+
|
|
15
|
+
if (backoff.type === 'exponential') {
|
|
16
|
+
const maxDelayMs = backoff.maxDelay === undefined
|
|
17
|
+
? Infinity
|
|
18
|
+
: /** @type {number} */ (parseDuration(backoff.maxDelay));
|
|
19
|
+
|
|
20
|
+
baseMs = Math.min(delayMs * 2 ** (attempt - 1), maxDelayMs);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return Math.round(baseMs * (1 - jitter * random()));
|
|
24
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { resolveDefaults } from './defaults.js';
|
|
2
|
+
import { createEnqueuer } from './enqueue.js';
|
|
3
|
+
import { createHintChannel } from './hint_channel.js';
|
|
4
|
+
import { ClosedError, ConfigError } from './errors.js';
|
|
5
|
+
import { runMigrations } from './migrations.js';
|
|
6
|
+
import { DEFAULT_MAX_PAYLOAD_BYTES } from './payload.js';
|
|
7
|
+
import { createRegistry } from './registry.js';
|
|
8
|
+
import { createRunner } from './runner.js';
|
|
9
|
+
import { createStore } from './store/store.js';
|
|
10
|
+
import { checkDuration, checkInteger, checkObject, checkTablePrefix, isPlainObject } from './validate.js';
|
|
11
|
+
|
|
12
|
+
const OPTION_NAMES = [
|
|
13
|
+
'adapter', 'jobs', 'concurrency', 'lanes', 'defaults', 'unknownJobs', 'hint', 'logger', 'onEvent', 'tablePrefix',
|
|
14
|
+
'pollInterval', 'maxPayloadBytes', 'cleanup', 'timings',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {unknown} jobs
|
|
19
|
+
* @param {import('./validate.js').Scope} scope
|
|
20
|
+
* @returns {Map<string, import('./types.js').JobDefinition>} The definitions by name.
|
|
21
|
+
*/
|
|
22
|
+
function indexJobs(jobs, scope) {
|
|
23
|
+
if (!Array.isArray(jobs)) {
|
|
24
|
+
throw new ConfigError(`${scope.where}: "jobs" must be an array of job definitions`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @type {Map<string, import('./types.js').JobDefinition>} */
|
|
28
|
+
const byName = new Map();
|
|
29
|
+
|
|
30
|
+
for (const job of jobs) {
|
|
31
|
+
if (!isPlainObject(job) || typeof job.name !== 'string') {
|
|
32
|
+
throw new ConfigError(`${scope.where}: "jobs" must be an array of job definitions`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const known = byName.get(job.name);
|
|
36
|
+
|
|
37
|
+
if (known && known !== job) {
|
|
38
|
+
throw new ConfigError(`${scope.where}: two different definitions of "${job.name}" in "jobs"`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
byName.set(job.name, /** @type {import('./types.js').JobDefinition} */ (job));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return byName;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The hint of an instance: the `hint` option, or the LISTEN/NOTIFY of a pg adapter with `notify`.
|
|
49
|
+
* @param {import('./types.js').Adapter<any>} adapter
|
|
50
|
+
* @param {unknown} hint
|
|
51
|
+
* @returns {import('./types.js').Hint | null}
|
|
52
|
+
*/
|
|
53
|
+
function resolveHint(adapter, hint) {
|
|
54
|
+
if (hint !== undefined && hint !== null) {
|
|
55
|
+
if (!isPlainObject(hint) || typeof hint.publish !== 'function' || typeof hint.subscribe !== 'function') {
|
|
56
|
+
throw new ConfigError('createSqlane: "hint" must be { publish(channel, message), subscribe(channel, onMessage) }');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (adapter.notify) {
|
|
60
|
+
throw new ConfigError('createSqlane: "hint" or pgAdapter(pool, { notify: true }), not both');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return /** @type {import('./types.js').Hint} */ (hint);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!adapter.notify || !adapter.listen) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const { listen } = adapter;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
publish: async (channel, message) => {
|
|
74
|
+
await adapter.queue.query('SELECT pg_notify($1, $2)', [channel, message]);
|
|
75
|
+
},
|
|
76
|
+
subscribe: listen,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Creates a sqlane instance. It connects, migrates and starts nothing.
|
|
82
|
+
* @template C The connections that `enqueue` accepts, from the adapter.
|
|
83
|
+
* @template {string} [L=never] The lane names, from `lanes`.
|
|
84
|
+
* @param {import('./types.js').SqlaneOptions<C, L>} options
|
|
85
|
+
* @returns {import('./types.js').Sqlane<C, L>}
|
|
86
|
+
*/
|
|
87
|
+
export function createSqlane(options) {
|
|
88
|
+
const scope = { where: 'createSqlane', ErrorClass: ConfigError };
|
|
89
|
+
|
|
90
|
+
checkObject(options, OPTION_NAMES, 'options', scope);
|
|
91
|
+
|
|
92
|
+
if (!options.adapter) {
|
|
93
|
+
throw new ConfigError('createSqlane: "adapter" is required');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const {
|
|
97
|
+
adapter,
|
|
98
|
+
tablePrefix = 'sqlane_',
|
|
99
|
+
unknownJobs = 'allow',
|
|
100
|
+
maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES,
|
|
101
|
+
} = options;
|
|
102
|
+
|
|
103
|
+
checkTablePrefix(tablePrefix, scope);
|
|
104
|
+
checkInteger(maxPayloadBytes, '"maxPayloadBytes"', scope);
|
|
105
|
+
|
|
106
|
+
if (unknownJobs !== 'allow' && unknownJobs !== 'reject') {
|
|
107
|
+
throw new ConfigError(`createSqlane: "unknownJobs" must be 'allow' or 'reject'`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { concurrency = 10, cleanup = true } = options;
|
|
111
|
+
|
|
112
|
+
checkInteger(concurrency, '"concurrency"', scope);
|
|
113
|
+
|
|
114
|
+
if (typeof cleanup !== 'boolean') {
|
|
115
|
+
throw new ConfigError('createSqlane: "cleanup" must be a boolean');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pollIntervalMs = checkDuration(options.pollInterval ?? '5s', '"pollInterval"', scope, true);
|
|
119
|
+
const jobsByName = indexJobs(options.jobs ?? [], scope);
|
|
120
|
+
const defaults = resolveDefaults(options.defaults);
|
|
121
|
+
const registry = createRegistry({
|
|
122
|
+
jobsByName,
|
|
123
|
+
lanes: options.lanes,
|
|
124
|
+
concurrency,
|
|
125
|
+
defaultTimeoutMs: defaults.timeoutMs,
|
|
126
|
+
});
|
|
127
|
+
const hint = resolveHint(adapter, options.hint);
|
|
128
|
+
const hintChannel = createHintChannel({
|
|
129
|
+
hint,
|
|
130
|
+
channel: `${tablePrefix}ready`,
|
|
131
|
+
reportError: (operation, error) => {
|
|
132
|
+
options.logger?.warn({ error, operation }, `sqlane: ${operation} failed`);
|
|
133
|
+
options.onEvent?.({ type: 'error', operation, error });
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
// NOTIFY in the enqueue transaction: no publish after the enqueue
|
|
137
|
+
const notifyChannel = adapter.notify ? `${tablePrefix}ready` : null;
|
|
138
|
+
// Undocumented, for the tests of sqlane
|
|
139
|
+
const testTimings = /** @type {{ timings?: object }} */ (options).timings;
|
|
140
|
+
const store = createStore(adapter, tablePrefix);
|
|
141
|
+
const runner = createRunner({
|
|
142
|
+
store,
|
|
143
|
+
registry,
|
|
144
|
+
concurrency,
|
|
145
|
+
defaults,
|
|
146
|
+
cleanup,
|
|
147
|
+
logger: options.logger,
|
|
148
|
+
onEvent: options.onEvent,
|
|
149
|
+
hintChannel,
|
|
150
|
+
timings: { pollIntervalMs, ...testTimings },
|
|
151
|
+
});
|
|
152
|
+
const enqueuer = createEnqueuer({
|
|
153
|
+
adapter,
|
|
154
|
+
tablePrefix,
|
|
155
|
+
findDefinition: registry.findDefinition,
|
|
156
|
+
unknownJobs,
|
|
157
|
+
defaults,
|
|
158
|
+
maxPayloadBytes,
|
|
159
|
+
notifyChannel,
|
|
160
|
+
});
|
|
161
|
+
/** @param {string} name */
|
|
162
|
+
const onCreated = (name) => {
|
|
163
|
+
runner.onEnqueued([name]);
|
|
164
|
+
|
|
165
|
+
// A process sends a hint only for the jobs that it cannot start itself
|
|
166
|
+
if (!notifyChannel && !runner.canStartNow(name)) {
|
|
167
|
+
hintChannel.publish(name);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
enqueue: async (connection, job, payload, enqueueOptions) => {
|
|
173
|
+
const result = await enqueuer.enqueue(connection, job, payload, enqueueOptions);
|
|
174
|
+
|
|
175
|
+
if (result.created) {
|
|
176
|
+
onCreated(typeof job === 'string' ? job : job.name);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return result;
|
|
180
|
+
},
|
|
181
|
+
enqueueMany: async (connection, job, items) => {
|
|
182
|
+
const results = await enqueuer.enqueueMany(connection, job, items);
|
|
183
|
+
|
|
184
|
+
if (results.some(result => result.created)) {
|
|
185
|
+
onCreated(typeof job === 'string' ? job : job.name);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return results;
|
|
189
|
+
},
|
|
190
|
+
handle: (job, handler, handleOptions) => {
|
|
191
|
+
if (runner.isClosed()) {
|
|
192
|
+
throw new ClosedError('handle() after close()');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (runner.isStarted()) {
|
|
196
|
+
throw new ConfigError('handle() after start(): register all the handlers before start()');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
registry.handle(job, handler, handleOptions);
|
|
200
|
+
},
|
|
201
|
+
start: runner.start,
|
|
202
|
+
drain: runner.drain,
|
|
203
|
+
close: runner.close,
|
|
204
|
+
migrate: async migrateOptions => ({
|
|
205
|
+
applied: await runMigrations(adapter, tablePrefix, migrateOptions),
|
|
206
|
+
}),
|
|
207
|
+
getStats: () => store.getStats(),
|
|
208
|
+
wake: name => runner.wake(name),
|
|
209
|
+
};
|
|
210
|
+
}
|
package/src/defaults.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parseDuration } from './duration.js';
|
|
2
|
+
import { ConfigError } from './errors.js';
|
|
3
|
+
import { checkJobFields, checkObject } from './validate.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The job defaults of an instance, merged with the built-in ones and checked.
|
|
7
|
+
* @typedef {object} ResolvedDefaults
|
|
8
|
+
* @property {number} maxAttempts
|
|
9
|
+
* @property {number} maxStalls
|
|
10
|
+
* @property {import('./types.js').Backoff} backoff
|
|
11
|
+
* @property {{ completedMs: number, failedMs: number }} retention
|
|
12
|
+
* @property {number | null} timeoutMs Null: no timeout.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @type {Required<import('./types.js').JobDefaults>['backoff']} */
|
|
16
|
+
const DEFAULT_BACKOFF = Object.freeze({ type: 'exponential', delay: '5s', maxDelay: '1h', jitter: 0.2 });
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {import('./types.js').JobDefaults} [defaults]
|
|
20
|
+
* @returns {ResolvedDefaults}
|
|
21
|
+
*/
|
|
22
|
+
export function resolveDefaults(defaults = {}) {
|
|
23
|
+
const scope = { where: 'createSqlane: "defaults"', ErrorClass: ConfigError };
|
|
24
|
+
|
|
25
|
+
checkObject(defaults, ['maxAttempts', 'maxStalls', 'backoff', 'retention', 'timeout'], 'defaults', scope);
|
|
26
|
+
checkJobFields(defaults, scope);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
maxAttempts: defaults.maxAttempts ?? 3,
|
|
30
|
+
maxStalls: defaults.maxStalls ?? 2,
|
|
31
|
+
backoff: defaults.backoff ?? DEFAULT_BACKOFF,
|
|
32
|
+
retention: {
|
|
33
|
+
completedMs: /** @type {number} */ (parseDuration(defaults.retention?.completed ?? '1h')),
|
|
34
|
+
failedMs: /** @type {number} */ (parseDuration(defaults.retention?.failed ?? '30d')),
|
|
35
|
+
},
|
|
36
|
+
timeoutMs: defaults.timeout === undefined ? null : parseDuration(defaults.timeout),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ConfigError } from './errors.js';
|
|
2
|
+
import { checkFailureJob, checkJobFields, checkObject, JOB_NAME_REGEXP } from './validate.js';
|
|
3
|
+
|
|
4
|
+
const JOB_OPTION_NAMES = [
|
|
5
|
+
'name', 'maxAttempts', 'maxStalls', 'backoff', 'retention', 'timeout', 'mutexKeys', 'failureJob',
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Defines a job name and its options. Producers and workers import the definition.
|
|
10
|
+
* @param {import('./types.js').JobOptions} options
|
|
11
|
+
* @returns {import('./types.js').JobDefinition} Frozen.
|
|
12
|
+
*/
|
|
13
|
+
export function defineJob(options) {
|
|
14
|
+
const name = typeof options?.name === 'string' ? options.name : '';
|
|
15
|
+
const where = `defineJob(${JSON.stringify(name)})`;
|
|
16
|
+
|
|
17
|
+
const scope = { where, ErrorClass: ConfigError };
|
|
18
|
+
|
|
19
|
+
checkObject(options, JOB_OPTION_NAMES, 'options', scope);
|
|
20
|
+
|
|
21
|
+
if (!JOB_NAME_REGEXP.test(name)) {
|
|
22
|
+
throw new ConfigError(`${where}: "name" must match ${JOB_NAME_REGEXP}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
checkJobFields(options, scope);
|
|
26
|
+
|
|
27
|
+
if (options.mutexKeys !== undefined && typeof options.mutexKeys !== 'function') {
|
|
28
|
+
throw new ConfigError(`${where}: "mutexKeys" must be a function of the payload`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (options.failureJob !== undefined) {
|
|
32
|
+
checkFailureJob(options.failureJob, name, scope);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
...options,
|
|
37
|
+
...(options.backoff && { backoff: Object.freeze({ ...options.backoff }) }),
|
|
38
|
+
...(options.retention && { retention: Object.freeze({ ...options.retention }) }),
|
|
39
|
+
});
|
|
40
|
+
}
|