@lakutata/mutex 0.0.0-stage → 3.0.0-beta.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/LICENSE +23 -0
- package/README.md +212 -2
- package/dist/cjs/exports/Mutex.d.ts +18 -0
- package/dist/cjs/exports/Mutex.js +37 -0
- package/dist/cjs/mutex/Mutex.d.ts +265 -0
- package/dist/cjs/mutex/Mutex.js +403 -0
- package/dist/cjs/mutex/MutexLock.d.ts +91 -0
- package/dist/cjs/mutex/MutexLock.js +113 -0
- package/dist/cjs/mutex/Synchronized.d.ts +90 -0
- package/dist/cjs/mutex/Synchronized.js +93 -0
- package/dist/cjs/mutex/drivers/FileMutexDriver.d.ts +115 -0
- package/dist/cjs/mutex/drivers/FileMutexDriver.js +230 -0
- package/dist/cjs/mutex/drivers/MemoryMutexDriver.d.ts +60 -0
- package/dist/cjs/mutex/drivers/MemoryMutexDriver.js +95 -0
- package/dist/cjs/mutex/drivers/MutexDriver.d.ts +82 -0
- package/dist/cjs/mutex/drivers/MutexDriver.js +38 -0
- package/dist/cjs/mutex/drivers/MysqlMutexDriver.d.ts +135 -0
- package/dist/cjs/mutex/drivers/MysqlMutexDriver.js +244 -0
- package/dist/cjs/mutex/drivers/PostgresMutexDriver.d.ts +136 -0
- package/dist/cjs/mutex/drivers/PostgresMutexDriver.js +230 -0
- package/dist/cjs/mutex/drivers/RedisMutexDriver.d.ts +129 -0
- package/dist/cjs/mutex/drivers/RedisMutexDriver.js +180 -0
- package/dist/cjs/mutex/exceptions/MutexDriverException.d.ts +12 -0
- package/dist/cjs/mutex/exceptions/MutexDriverException.js +19 -0
- package/dist/cjs/mutex/exceptions/MutexDriverNotFoundException.d.ts +12 -0
- package/dist/cjs/mutex/exceptions/MutexDriverNotFoundException.js +19 -0
- package/dist/cjs/mutex/exceptions/MutexLostException.d.ts +13 -0
- package/dist/cjs/mutex/exceptions/MutexLostException.js +20 -0
- package/dist/cjs/mutex/exceptions/MutexTimeoutException.d.ts +16 -0
- package/dist/cjs/mutex/exceptions/MutexTimeoutException.js +23 -0
- package/dist/cjs/mutex/lib/LoadDriver.d.ts +6 -0
- package/dist/cjs/mutex/lib/LoadDriver.js +64 -0
- package/dist/cjs/mutex/lib/Names.d.ts +14 -0
- package/dist/cjs/mutex/lib/Names.js +26 -0
- package/dist/cjs/mutex/options/FileMutexOptions.d.ts +33 -0
- package/dist/cjs/mutex/options/FileMutexOptions.js +45 -0
- package/dist/cjs/mutex/options/MemoryMutexOptions.d.ts +27 -0
- package/dist/cjs/mutex/options/MemoryMutexOptions.js +41 -0
- package/dist/cjs/mutex/options/MysqlMutexOptions.d.ts +70 -0
- package/dist/cjs/mutex/options/MysqlMutexOptions.js +77 -0
- package/dist/cjs/mutex/options/PostgresMutexOptions.d.ts +83 -0
- package/dist/cjs/mutex/options/PostgresMutexOptions.js +89 -0
- package/dist/cjs/mutex/options/RedisMutexOptions.d.ts +71 -0
- package/dist/cjs/mutex/options/RedisMutexOptions.js +77 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/exports/Mutex.js +18 -0
- package/dist/esm/mutex/Mutex.js +397 -0
- package/dist/esm/mutex/MutexLock.js +109 -0
- package/dist/esm/mutex/Synchronized.js +89 -0
- package/dist/esm/mutex/drivers/FileMutexDriver.js +223 -0
- package/dist/esm/mutex/drivers/MemoryMutexDriver.js +91 -0
- package/dist/esm/mutex/drivers/MutexDriver.js +34 -0
- package/dist/esm/mutex/drivers/MysqlMutexDriver.js +240 -0
- package/dist/esm/mutex/drivers/PostgresMutexDriver.js +226 -0
- package/dist/esm/mutex/drivers/RedisMutexDriver.js +176 -0
- package/dist/esm/mutex/exceptions/MutexDriverException.js +15 -0
- package/dist/esm/mutex/exceptions/MutexDriverNotFoundException.js +15 -0
- package/dist/esm/mutex/exceptions/MutexLostException.js +16 -0
- package/dist/esm/mutex/exceptions/MutexTimeoutException.js +19 -0
- package/dist/esm/mutex/lib/LoadDriver.js +28 -0
- package/dist/esm/mutex/lib/Names.js +21 -0
- package/dist/esm/mutex/options/FileMutexOptions.js +41 -0
- package/dist/esm/mutex/options/MemoryMutexOptions.js +37 -0
- package/dist/esm/mutex/options/MysqlMutexOptions.js +73 -0
- package/dist/esm/mutex/options/PostgresMutexOptions.js +85 -0
- package/dist/esm/mutex/options/RedisMutexOptions.js +73 -0
- package/dist/types/exports/Mutex.d.ts +18 -0
- package/dist/types/mutex/Mutex.d.ts +265 -0
- package/dist/types/mutex/MutexLock.d.ts +91 -0
- package/dist/types/mutex/Synchronized.d.ts +90 -0
- package/dist/types/mutex/drivers/FileMutexDriver.d.ts +115 -0
- package/dist/types/mutex/drivers/MemoryMutexDriver.d.ts +60 -0
- package/dist/types/mutex/drivers/MutexDriver.d.ts +82 -0
- package/dist/types/mutex/drivers/MysqlMutexDriver.d.ts +135 -0
- package/dist/types/mutex/drivers/PostgresMutexDriver.d.ts +136 -0
- package/dist/types/mutex/drivers/RedisMutexDriver.d.ts +129 -0
- package/dist/types/mutex/exceptions/MutexDriverException.d.ts +12 -0
- package/dist/types/mutex/exceptions/MutexDriverNotFoundException.d.ts +12 -0
- package/dist/types/mutex/exceptions/MutexLostException.d.ts +13 -0
- package/dist/types/mutex/exceptions/MutexTimeoutException.d.ts +16 -0
- package/dist/types/mutex/lib/LoadDriver.d.ts +6 -0
- package/dist/types/mutex/lib/Names.d.ts +14 -0
- package/dist/types/mutex/options/FileMutexOptions.d.ts +33 -0
- package/dist/types/mutex/options/MemoryMutexOptions.d.ts +27 -0
- package/dist/types/mutex/options/MysqlMutexOptions.d.ts +70 -0
- package/dist/types/mutex/options/PostgresMutexOptions.d.ts +83 -0
- package/dist/types/mutex/options/RedisMutexOptions.d.ts +71 -0
- package/package.json +57 -4
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { MutexDriver } from './MutexDriver.js';
|
|
2
|
+
import { loadDriver } from '../lib/LoadDriver.js';
|
|
3
|
+
import { lockToken, nameDigest } from '../lib/Names.js';
|
|
4
|
+
/**
|
|
5
|
+
* The MySQL driver (`{type: 'mysql'}`, MySQL or MariaDB): the locks are shared by every instance connected to the same
|
|
6
|
+
* server with the same prefix. It needs the optional peer dependency `mysql2` (3); without it, the first acquisition
|
|
7
|
+
* fails with {@link MutexDriverNotFoundException}. It has two modes:
|
|
8
|
+
* - `'advisory'` (the default): named locks (`GET_LOCK`) taken on one connection of the driver, opened at the first use.
|
|
9
|
+
* They do not expire and need no renewal: MySQL releases them when the connection closes, so a crashed process frees
|
|
10
|
+
* them at once. When the connection closes or fails, every lock it held is lost (a {@link Mutex} aborts their
|
|
11
|
+
* signals) and the next acquisition opens a new connection. A connection cut without the client noticing it is not
|
|
12
|
+
* detected by the renewals. No fencing token. The named locks are not replicated by the clusters (Galera, Group
|
|
13
|
+
* Replication): every holder must connect to the same server.
|
|
14
|
+
* - `'table'`: one row per lock in a table, created at the first use with its counter table `<table>_fence`. The rows
|
|
15
|
+
* expire by the database clock after their `ttl` unless renewed, so it works through the poolers and the clusters, and
|
|
16
|
+
* each acquisition gets a fencing token from the counter. A pool of 4 connections is used.
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* import {MysqlMutexDriver, type DriverLock} from 'lakutata/com/mutex'
|
|
20
|
+
*
|
|
21
|
+
* const driver: MysqlMutexDriver = new MysqlMutexDriver({
|
|
22
|
+
* host: '127.0.0.1',
|
|
23
|
+
* database: 'app',
|
|
24
|
+
* username: 'app',
|
|
25
|
+
* password: 'secret'
|
|
26
|
+
* }, 'my-app:')
|
|
27
|
+
* const lock: DriverLock | undefined = await driver.acquire('invoice:7', 30000)
|
|
28
|
+
* if (lock) await driver.release('invoice:7', lock)
|
|
29
|
+
* //Closing the connection releases its named locks
|
|
30
|
+
* await driver.close()
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export class MysqlMutexDriver extends MutexDriver {
|
|
34
|
+
#connection;
|
|
35
|
+
#pool;
|
|
36
|
+
#connecting;
|
|
37
|
+
#table;
|
|
38
|
+
#held = new Map();
|
|
39
|
+
/**
|
|
40
|
+
* Create a driver on a MySQL server; it connects at its first use.
|
|
41
|
+
* @param connection The connection to the server.
|
|
42
|
+
* @param prefix The prefix added to the lock names.
|
|
43
|
+
* @param mode `'advisory'` (the default) or `'table'`.
|
|
44
|
+
* @param table The table of the `table` mode, in the connection's database; `'lakutata_mutex'` by default.
|
|
45
|
+
* @throws {Error} When the table is not a valid name (letters, digits and `_`, not starting with a digit, without a
|
|
46
|
+
* database qualifier).
|
|
47
|
+
*/
|
|
48
|
+
constructor(connection, prefix, mode = 'advisory', table = 'lakutata_mutex') {
|
|
49
|
+
super();
|
|
50
|
+
this.connection = connection;
|
|
51
|
+
this.prefix = prefix;
|
|
52
|
+
this.mode = mode;
|
|
53
|
+
this.table = table;
|
|
54
|
+
this.expiring = mode === 'table';
|
|
55
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table))
|
|
56
|
+
throw new Error(`The table of the locks "${table}" is not a name of MySQL`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The configuration given to `mysql2` for the connections.
|
|
60
|
+
* @returns The `mysql2` connection configuration.
|
|
61
|
+
*/
|
|
62
|
+
config() {
|
|
63
|
+
return { host: this.connection.host, port: this.connection.port ?? 3306, database: this.connection.database, user: this.connection.username, password: this.connection.password };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The name of a lock for `GET_LOCK`: `lakutata:` and the start of the SHA-256 digest of the prefixed name, 64
|
|
67
|
+
* characters in all (the limit of MySQL).
|
|
68
|
+
* @param name The lock name.
|
|
69
|
+
* @returns The MySQL lock name.
|
|
70
|
+
*/
|
|
71
|
+
lockName(name) {
|
|
72
|
+
return `lakutata:${nameDigest(this.prefix + name).slice(0, 55)}`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The connection holding the named locks, opened at the first call; when it ends or fails, its locks are reported
|
|
76
|
+
* lost through {@link MutexDriver.onLost}.
|
|
77
|
+
* @returns The `mysql2` connection.
|
|
78
|
+
* @throws {MutexDriverNotFoundException} When the package `mysql2` is not installed.
|
|
79
|
+
*/
|
|
80
|
+
async session() {
|
|
81
|
+
if (this.#connection)
|
|
82
|
+
return this.#connection;
|
|
83
|
+
this.#connecting ??= (async () => {
|
|
84
|
+
const mysql = await loadDriver('createConnection', 'mysql2/promise');
|
|
85
|
+
const connection = await mysql.createConnection(this.config());
|
|
86
|
+
const lost = () => {
|
|
87
|
+
if (this.#connection !== connection)
|
|
88
|
+
return;
|
|
89
|
+
this.#connection = undefined;
|
|
90
|
+
const names = [...this.#held.keys()];
|
|
91
|
+
this.#held.clear();
|
|
92
|
+
if (names.length)
|
|
93
|
+
this.onLost(names);
|
|
94
|
+
};
|
|
95
|
+
connection.on('error', lost);
|
|
96
|
+
connection.on('end', lost);
|
|
97
|
+
this.#connection = connection;
|
|
98
|
+
return connection;
|
|
99
|
+
})().finally(() => {
|
|
100
|
+
this.#connecting = undefined;
|
|
101
|
+
});
|
|
102
|
+
return await this.#connecting;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The connection pool of the `table` mode, created at the first call along with the tables; created again at the
|
|
106
|
+
* next call when that fails.
|
|
107
|
+
* @returns The `mysql2` pool.
|
|
108
|
+
* @throws {MutexDriverNotFoundException} When the package `mysql2` is not installed.
|
|
109
|
+
*/
|
|
110
|
+
async pool() {
|
|
111
|
+
//Created once: the first uses at the same time share it
|
|
112
|
+
this.#table ??= (async () => {
|
|
113
|
+
const mysql = await loadDriver('createPool', 'mysql2/promise');
|
|
114
|
+
const pool = mysql.createPool({ ...this.config(), connectionLimit: 4 });
|
|
115
|
+
this.#pool = pool;
|
|
116
|
+
await this.#pool.query(`CREATE TABLE IF NOT EXISTS \`${this.table}\` (\`key\` VARCHAR(64) PRIMARY KEY, name TEXT NOT NULL, token VARCHAR(64) NOT NULL, fence BIGINT NOT NULL, expires_at DATETIME(3) NOT NULL)`);
|
|
117
|
+
await this.#pool.query(`CREATE TABLE IF NOT EXISTS \`${this.table}_fence\` (id TINYINT PRIMARY KEY, value BIGINT NOT NULL)`);
|
|
118
|
+
await this.#pool.query(`INSERT IGNORE INTO \`${this.table}_fence\` (id, value) VALUES (1, 0)`);
|
|
119
|
+
})().catch(async (error) => {
|
|
120
|
+
//Created again at the next use: this pool closed
|
|
121
|
+
this.#table = undefined;
|
|
122
|
+
const failed = this.#pool;
|
|
123
|
+
this.#pool = undefined;
|
|
124
|
+
await failed?.end().catch(() => undefined);
|
|
125
|
+
throw error;
|
|
126
|
+
});
|
|
127
|
+
await this.#table;
|
|
128
|
+
return this.#pool;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Try to acquire a lock once: a named lock of the driver's connection, or a row inserted (or taken over when
|
|
132
|
+
* expired) in the table.
|
|
133
|
+
* @param name The lock name.
|
|
134
|
+
* @param ttl How long the lock stays held without a renewal, in milliseconds (the `table` mode only).
|
|
135
|
+
* @returns The lock (with a fencing token in the `table` mode), or `undefined` when another holder has it, this
|
|
136
|
+
* driver included.
|
|
137
|
+
* @throws {MutexDriverNotFoundException} When the package `mysql2` is not installed.
|
|
138
|
+
*/
|
|
139
|
+
async acquire(name, ttl) {
|
|
140
|
+
const token = lockToken();
|
|
141
|
+
if (this.mode === 'table') {
|
|
142
|
+
const connection = await (await this.pool()).getConnection();
|
|
143
|
+
try {
|
|
144
|
+
//The fencing token: the counter incremented, read by the connection (LAST_INSERT_ID)
|
|
145
|
+
await connection.query(`UPDATE \`${this.table}_fence\` SET value = LAST_INSERT_ID(value + 1) WHERE id = 1`);
|
|
146
|
+
const [[{ fence }]] = await connection.query('SELECT LAST_INSERT_ID() AS fence');
|
|
147
|
+
const key = nameDigest(this.prefix + name);
|
|
148
|
+
//Taken when it is expired: the columns kept otherwise (expires_at assigned last, read before)
|
|
149
|
+
await connection.query(`INSERT INTO \`${this.table}\` (\`key\`, name, token, fence, expires_at) VALUES (?, ?, ?, ?, NOW(3) + INTERVAL ? MICROSECOND)
|
|
150
|
+
ON DUPLICATE KEY UPDATE name = IF(expires_at <= NOW(3), VALUES(name), name), token = IF(expires_at <= NOW(3), VALUES(token), token),
|
|
151
|
+
fence = IF(expires_at <= NOW(3), VALUES(fence), fence), expires_at = IF(expires_at <= NOW(3), VALUES(expires_at), expires_at)`, [key, name, token, fence, Math.ceil(ttl) * 1000]);
|
|
152
|
+
const [rows] = await connection.query(`SELECT token FROM \`${this.table}\` WHERE \`key\` = ?`, [key]);
|
|
153
|
+
return rows[0]?.token === token ? { token: token, fence: Number(fence) } : undefined;
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
connection.release();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (this.#held.has(name))
|
|
160
|
+
return undefined;
|
|
161
|
+
this.#held.set(name, undefined);
|
|
162
|
+
try {
|
|
163
|
+
const [[{ locked }]] = await (await this.session()).query('SELECT GET_LOCK(?, 0) AS locked', [this.lockName(name)]);
|
|
164
|
+
//NULL: an error of the server (the thread killed, out of memory), not a lock held by another
|
|
165
|
+
if (locked === null)
|
|
166
|
+
throw new Error(`GET_LOCK failed for the lock ${name}`);
|
|
167
|
+
if (Number(locked) !== 1) {
|
|
168
|
+
this.#held.delete(name);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
this.#held.set(name, token);
|
|
172
|
+
return { token: token };
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
this.#held.delete(name);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Release a lock still held by this holder: `RELEASE_LOCK`, or delete the row.
|
|
181
|
+
* @param name The lock name.
|
|
182
|
+
* @param lock The lock acquired.
|
|
183
|
+
* @returns Whether it was released (`false` when the row expired and was taken, or the connection was lost).
|
|
184
|
+
*/
|
|
185
|
+
async release(name, lock) {
|
|
186
|
+
if (this.mode === 'table') {
|
|
187
|
+
const [result] = await (await this.pool()).query(`DELETE FROM \`${this.table}\` WHERE \`key\` = ? AND token = ?`, [nameDigest(this.prefix + name), lock.token]);
|
|
188
|
+
return result.affectedRows === 1;
|
|
189
|
+
}
|
|
190
|
+
if (this.#held.get(name) !== lock.token)
|
|
191
|
+
return false;
|
|
192
|
+
this.#held.delete(name);
|
|
193
|
+
const connection = this.#connection;
|
|
194
|
+
if (!connection)
|
|
195
|
+
return false;
|
|
196
|
+
const [[{ released }]] = await connection.query('SELECT RELEASE_LOCK(?) AS released', [this.lockName(name)]);
|
|
197
|
+
return Number(released) === 1;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Renew a lock: in the `table` mode, push back the expiration of its row when it still holds this holder's token; in
|
|
201
|
+
* the `advisory` mode, check that the driver still holds it on its connection, by a query (a connection cut without
|
|
202
|
+
* notice fails it).
|
|
203
|
+
* @param name The lock name.
|
|
204
|
+
* @param lock The lock acquired.
|
|
205
|
+
* @param ttl How long the lock stays held without another renewal, in milliseconds (the `table` mode only).
|
|
206
|
+
* @returns Whether the lock is still held by this holder.
|
|
207
|
+
*/
|
|
208
|
+
async extend(name, lock, ttl) {
|
|
209
|
+
if (this.mode === 'table') {
|
|
210
|
+
const [result] = await (await this.pool()).query(`UPDATE \`${this.table}\` SET expires_at = NOW(3) + INTERVAL ? MICROSECOND WHERE \`key\` = ? AND token = ?`, [Math.ceil(ttl) * 1000, nameDigest(this.prefix + name), lock.token]);
|
|
211
|
+
return result.affectedRows === 1;
|
|
212
|
+
}
|
|
213
|
+
//Held as long as its connection: checked by a query (a connection cut silently fails it)
|
|
214
|
+
const connection = this.#connection;
|
|
215
|
+
if (this.#held.get(name) !== lock.token || !connection)
|
|
216
|
+
return false;
|
|
217
|
+
try {
|
|
218
|
+
await connection.query('SELECT 1');
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Close the connection (MySQL releases its named locks) and the pool; the rows of the `table` mode stay until they
|
|
227
|
+
* expire.
|
|
228
|
+
*/
|
|
229
|
+
async close() {
|
|
230
|
+
const connection = this.#connection;
|
|
231
|
+
this.#connection = undefined;
|
|
232
|
+
this.#held.clear();
|
|
233
|
+
await connection?.end().catch(() => undefined);
|
|
234
|
+
const pool = this.#pool;
|
|
235
|
+
this.#pool = undefined;
|
|
236
|
+
//Created again at the next use
|
|
237
|
+
this.#table = undefined;
|
|
238
|
+
await pool?.end().catch(() => undefined);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { MutexDriver } from './MutexDriver.js';
|
|
2
|
+
import { loadDriver } from '../lib/LoadDriver.js';
|
|
3
|
+
import { lockToken, nameDigest, nameKey64 } from '../lib/Names.js';
|
|
4
|
+
/**
|
|
5
|
+
* The PostgreSQL driver (`{type: 'postgres'}`): the locks are shared by every instance connected to the same database
|
|
6
|
+
* with the same prefix. It needs the optional peer dependency `pg` (8); without it, the first acquisition fails with
|
|
7
|
+
* {@link MutexDriverNotFoundException}. It has two modes:
|
|
8
|
+
* - `'advisory'` (the default): session advisory locks (`pg_try_advisory_lock`) taken on one connection of the driver,
|
|
9
|
+
* opened at the first use. They do not expire and need no renewal: PostgreSQL releases them when the connection
|
|
10
|
+
* closes, so a crashed process frees them at once. When the connection closes or fails, every lock it held is lost
|
|
11
|
+
* (a {@link Mutex} aborts their signals) and the next acquisition opens a new connection. A connection cut without
|
|
12
|
+
* the client noticing it is not detected by the renewals. No fencing token. The lock names are hashed to 64-bit keys,
|
|
13
|
+
* shared with the other advisory locks of the database. It does not work through a pooler in transaction mode
|
|
14
|
+
* (PgBouncer), which does not keep the session.
|
|
15
|
+
* - `'table'`: one row per lock in a table, created at the first use with its sequence `<table>_fence`. The rows expire
|
|
16
|
+
* by the database clock after their `ttl` unless renewed, so it works through the poolers and on the replicated
|
|
17
|
+
* setups writing to one primary, and each acquisition gets a fencing token from the sequence. A pool of 4 connections
|
|
18
|
+
* is used.
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* import {PostgresMutexDriver, type DriverLock} from 'lakutata/com/mutex'
|
|
22
|
+
*
|
|
23
|
+
* const driver: PostgresMutexDriver = new PostgresMutexDriver({
|
|
24
|
+
* host: '127.0.0.1',
|
|
25
|
+
* database: 'app',
|
|
26
|
+
* username: 'app',
|
|
27
|
+
* password: 'secret'
|
|
28
|
+
* }, 'my-app:', 'table', 'app_locks')
|
|
29
|
+
* const lock: DriverLock | undefined = await driver.acquire('invoice:7', 30000)
|
|
30
|
+
* if (lock) await driver.release('invoice:7', lock)
|
|
31
|
+
* await driver.close()
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export class PostgresMutexDriver extends MutexDriver {
|
|
35
|
+
#client;
|
|
36
|
+
#pool;
|
|
37
|
+
#connecting;
|
|
38
|
+
#table;
|
|
39
|
+
/**
|
|
40
|
+
* The advisory locks the connection holds, by name (the ones being acquired too: a session acquires its own lock
|
|
41
|
+
* again)
|
|
42
|
+
*/
|
|
43
|
+
#held = new Map();
|
|
44
|
+
/**
|
|
45
|
+
* Create a driver on a PostgreSQL database; it connects at its first use.
|
|
46
|
+
* @param connection The connection to the database.
|
|
47
|
+
* @param prefix The prefix added to the lock names.
|
|
48
|
+
* @param mode `'advisory'` (the default) or `'table'`.
|
|
49
|
+
* @param table The table of the `table` mode, optionally qualified by its schema (`locks.mutex`); `'lakutata_mutex'`
|
|
50
|
+
* by default.
|
|
51
|
+
* @throws {Error} When the table is not a valid PostgreSQL name (letters, digits and `_`, not starting with a digit).
|
|
52
|
+
*/
|
|
53
|
+
constructor(connection, prefix, mode = 'advisory', table = 'lakutata_mutex') {
|
|
54
|
+
super();
|
|
55
|
+
this.connection = connection;
|
|
56
|
+
this.prefix = prefix;
|
|
57
|
+
this.mode = mode;
|
|
58
|
+
this.table = table;
|
|
59
|
+
this.expiring = mode === 'table';
|
|
60
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/.test(table))
|
|
61
|
+
throw new Error(`The table of the locks "${table}" is not a name of PostgreSQL`);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The configuration given to `pg` for the connection.
|
|
65
|
+
* @returns The `pg` client configuration.
|
|
66
|
+
*/
|
|
67
|
+
config() {
|
|
68
|
+
return { host: this.connection.host, port: this.connection.port ?? 5432, database: this.connection.database, user: this.connection.username, password: this.connection.password, ssl: this.connection.ssl || undefined };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The connection holding the advisory locks, opened at the first call; when it ends or fails, its locks are reported
|
|
72
|
+
* lost through {@link MutexDriver.onLost}.
|
|
73
|
+
* @returns The connected `pg` client.
|
|
74
|
+
* @throws {MutexDriverNotFoundException} When the package `pg` is not installed.
|
|
75
|
+
* @protected
|
|
76
|
+
*/
|
|
77
|
+
async client() {
|
|
78
|
+
if (this.#client)
|
|
79
|
+
return this.#client;
|
|
80
|
+
this.#connecting ??= (async () => {
|
|
81
|
+
const pg = await loadDriver('Client', 'pg');
|
|
82
|
+
const client = new pg.Client(this.config());
|
|
83
|
+
const lost = () => {
|
|
84
|
+
if (this.#client !== client)
|
|
85
|
+
return;
|
|
86
|
+
this.#client = undefined;
|
|
87
|
+
const names = [...this.#held.keys()];
|
|
88
|
+
this.#held.clear();
|
|
89
|
+
if (names.length)
|
|
90
|
+
this.onLost(names);
|
|
91
|
+
};
|
|
92
|
+
client.on('error', lost);
|
|
93
|
+
client.on('end', lost);
|
|
94
|
+
await client.connect();
|
|
95
|
+
this.#client = client;
|
|
96
|
+
return client;
|
|
97
|
+
})().finally(() => {
|
|
98
|
+
this.#connecting = undefined;
|
|
99
|
+
});
|
|
100
|
+
return await this.#connecting;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The connection pool of the `table` mode, created at the first call along with the table and its sequence; created
|
|
104
|
+
* again at the next call when that fails.
|
|
105
|
+
* @returns The `pg` pool.
|
|
106
|
+
* @throws {MutexDriverNotFoundException} When the package `pg` is not installed.
|
|
107
|
+
* @protected
|
|
108
|
+
*/
|
|
109
|
+
async pool() {
|
|
110
|
+
//Created once: the first uses at the same time share it
|
|
111
|
+
this.#table ??= (async () => {
|
|
112
|
+
const pg = await loadDriver('Pool', 'pg');
|
|
113
|
+
const pool = new pg.Pool({ ...this.config(), max: 4 });
|
|
114
|
+
pool.on('error', () => undefined);
|
|
115
|
+
this.#pool = pool;
|
|
116
|
+
await this.#pool.query(`CREATE TABLE IF NOT EXISTS ${this.table} (key VARCHAR(64) PRIMARY KEY, name TEXT NOT NULL, token VARCHAR(64) NOT NULL, fence BIGINT NOT NULL, expires_at TIMESTAMPTZ NOT NULL)`);
|
|
117
|
+
await this.#pool.query(`CREATE SEQUENCE IF NOT EXISTS ${this.table}_fence`);
|
|
118
|
+
})().catch(async (error) => {
|
|
119
|
+
//Created again at the next use: this pool closed
|
|
120
|
+
this.#table = undefined;
|
|
121
|
+
const failed = this.#pool;
|
|
122
|
+
this.#pool = undefined;
|
|
123
|
+
await failed?.end().catch(() => undefined);
|
|
124
|
+
throw error;
|
|
125
|
+
});
|
|
126
|
+
await this.#table;
|
|
127
|
+
return this.#pool;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Try to acquire a lock once: an advisory lock of the driver's connection, or a row inserted (or taken over when
|
|
131
|
+
* expired) in the table.
|
|
132
|
+
* @param name The lock name.
|
|
133
|
+
* @param ttl How long the lock stays held without a renewal, in milliseconds (the `table` mode only).
|
|
134
|
+
* @returns The lock (with a fencing token in the `table` mode), or `undefined` when another holder has it, this
|
|
135
|
+
* driver included.
|
|
136
|
+
* @throws {MutexDriverNotFoundException} When the package `pg` is not installed.
|
|
137
|
+
*/
|
|
138
|
+
async acquire(name, ttl) {
|
|
139
|
+
const token = lockToken();
|
|
140
|
+
if (this.mode === 'table') {
|
|
141
|
+
const pool = await this.pool();
|
|
142
|
+
const result = await pool.query(`INSERT INTO ${this.table} (key, name, token, fence, expires_at) VALUES ($1, $2, $3, nextval('${this.table}_fence'), now() + $4 * interval '1 millisecond')
|
|
143
|
+
ON CONFLICT (key) DO UPDATE SET name = excluded.name, token = excluded.token, fence = excluded.fence, expires_at = excluded.expires_at
|
|
144
|
+
WHERE ${this.table}.expires_at <= now() RETURNING fence`, [nameDigest(this.prefix + name), name, token, Math.ceil(ttl)]);
|
|
145
|
+
return result.rows.length ? { token: token, fence: Number(result.rows[0].fence) } : undefined;
|
|
146
|
+
}
|
|
147
|
+
if (this.#held.has(name))
|
|
148
|
+
return undefined;
|
|
149
|
+
//Being acquired: the other acquisitions of the process refused meanwhile
|
|
150
|
+
this.#held.set(name, undefined);
|
|
151
|
+
try {
|
|
152
|
+
const client = await this.client();
|
|
153
|
+
const result = await client.query('SELECT pg_try_advisory_lock($1::bigint) AS locked', [nameKey64(this.prefix + name)]);
|
|
154
|
+
if (!result.rows[0].locked) {
|
|
155
|
+
this.#held.delete(name);
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
this.#held.set(name, token);
|
|
159
|
+
return { token: token };
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
this.#held.delete(name);
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Release a lock still held by this holder: unlock the advisory lock, or delete the row.
|
|
168
|
+
* @param name The lock name.
|
|
169
|
+
* @param lock The lock acquired.
|
|
170
|
+
* @returns Whether it was released (`false` when the row expired and was taken, or the connection was lost).
|
|
171
|
+
*/
|
|
172
|
+
async release(name, lock) {
|
|
173
|
+
if (this.mode === 'table') {
|
|
174
|
+
const pool = await this.pool();
|
|
175
|
+
return (await pool.query(`DELETE FROM ${this.table} WHERE key = $1 AND token = $2`, [nameDigest(this.prefix + name), lock.token])).rowCount === 1;
|
|
176
|
+
}
|
|
177
|
+
if (this.#held.get(name) !== lock.token)
|
|
178
|
+
return false;
|
|
179
|
+
this.#held.delete(name);
|
|
180
|
+
const client = this.#client;
|
|
181
|
+
if (!client)
|
|
182
|
+
return false;
|
|
183
|
+
return !!(await client.query('SELECT pg_advisory_unlock($1::bigint) AS unlocked', [nameKey64(this.prefix + name)])).rows[0].unlocked;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Renew a lock: in the `table` mode, push back the expiration of its row when it still holds this holder's token; in
|
|
187
|
+
* the `advisory` mode, check that the driver still holds it on its connection, by a query (a connection cut
|
|
188
|
+
* without notice fails it).
|
|
189
|
+
* @param name The lock name.
|
|
190
|
+
* @param lock The lock acquired.
|
|
191
|
+
* @param ttl How long the lock stays held without another renewal, in milliseconds (the `table` mode only).
|
|
192
|
+
* @returns Whether the lock is still held by this holder.
|
|
193
|
+
*/
|
|
194
|
+
async extend(name, lock, ttl) {
|
|
195
|
+
if (this.mode === 'table') {
|
|
196
|
+
const pool = await this.pool();
|
|
197
|
+
return (await pool.query(`UPDATE ${this.table} SET expires_at = now() + $3 * interval '1 millisecond' WHERE key = $1 AND token = $2`, [nameDigest(this.prefix + name), lock.token, Math.ceil(ttl)])).rowCount === 1;
|
|
198
|
+
}
|
|
199
|
+
//Held as long as its connection: checked by a query (a connection cut silently fails it)
|
|
200
|
+
const client = this.#client;
|
|
201
|
+
if (this.#held.get(name) !== lock.token || !client)
|
|
202
|
+
return false;
|
|
203
|
+
try {
|
|
204
|
+
await client.query('SELECT 1');
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Close the connection (PostgreSQL releases its advisory locks) and the pool; the rows of the `table` mode stay until
|
|
213
|
+
* they expire.
|
|
214
|
+
*/
|
|
215
|
+
async close() {
|
|
216
|
+
const client = this.#client;
|
|
217
|
+
this.#client = undefined;
|
|
218
|
+
this.#held.clear();
|
|
219
|
+
await client?.end().catch(() => undefined);
|
|
220
|
+
const pool = this.#pool;
|
|
221
|
+
this.#pool = undefined;
|
|
222
|
+
//Created again at the next use
|
|
223
|
+
this.#table = undefined;
|
|
224
|
+
await pool?.end().catch(() => undefined);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { MutexDriver } from './MutexDriver.js';
|
|
2
|
+
import { loadDriver } from '../lib/LoadDriver.js';
|
|
3
|
+
import { lockToken } from '../lib/Names.js';
|
|
4
|
+
/**
|
|
5
|
+
* The milliseconds a connection to the server is waited for
|
|
6
|
+
*/
|
|
7
|
+
const CONNECT_TIMEOUT = 5000;
|
|
8
|
+
/**
|
|
9
|
+
* Set the key when it does not exist, its fencing token incremented: the token, or 0
|
|
10
|
+
*/
|
|
11
|
+
const ACQUIRE = `if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then return redis.call('INCR', KEYS[2]) end return 0`;
|
|
12
|
+
/**
|
|
13
|
+
* Delete the key when it is the one of the holder
|
|
14
|
+
*/
|
|
15
|
+
const RELEASE = `if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0`;
|
|
16
|
+
/**
|
|
17
|
+
* Renew the key when it is the one of the holder
|
|
18
|
+
*/
|
|
19
|
+
const EXTEND = `if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) end return 0`;
|
|
20
|
+
/**
|
|
21
|
+
* The Redis driver (`{type: 'redis'}`): the locks are Redis keys holding their holder's token, shared by every instance
|
|
22
|
+
* connected to the same server (or cluster) with the same prefix. It needs the optional peer dependency `redis` (4.6 or
|
|
23
|
+
* 5); without it, the first acquisition fails with {@link MutexDriverNotFoundException}.
|
|
24
|
+
*
|
|
25
|
+
* The keys expire after their `ttl` unless renewed (`PEXPIRE`), and are released and renewed only for their holder's
|
|
26
|
+
* token. Each acquisition increments a fencing token kept in a second key (`<key>:fence`, never expiring); the lock's
|
|
27
|
+
* name is the key's hash tag, so both keys stay in the same slot of a cluster. Configure the server with
|
|
28
|
+
* `maxmemory-policy noeviction`: an evicted key is a lock lost. The locks of one primary are lost if it fails over before
|
|
29
|
+
* replicating them.
|
|
30
|
+
*
|
|
31
|
+
* The client connects at the first use. While the server cannot be reached, the calls wait for it to come back
|
|
32
|
+
* instead of failing (the client retries without limit): an acquisition does not end at its timeout, and the renewals
|
|
33
|
+
* do not complete, so the held locks are not marked lost while their keys may expire in Redis.
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* import {RedisMutexDriver, type DriverLock} from 'lakutata/com/mutex'
|
|
37
|
+
*
|
|
38
|
+
* const driver: RedisMutexDriver = new RedisMutexDriver({url: 'redis://127.0.0.1:6379'}, 'my-app:')
|
|
39
|
+
* const lock: DriverLock | undefined = await driver.acquire('invoice:7', 30000)
|
|
40
|
+
* if (lock) {
|
|
41
|
+
* //Increases with each acquisition: written along with the data to refuse an older holder's writes
|
|
42
|
+
* console.log(lock.fence)
|
|
43
|
+
* await driver.release('invoice:7', lock)
|
|
44
|
+
* }
|
|
45
|
+
* await driver.close()
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export class RedisMutexDriver extends MutexDriver {
|
|
49
|
+
#client;
|
|
50
|
+
#connecting;
|
|
51
|
+
/**
|
|
52
|
+
* Create a driver on a Redis server; it connects at its first use.
|
|
53
|
+
* @param connection The connection to the server.
|
|
54
|
+
* @param prefix The prefix of the keys.
|
|
55
|
+
*/
|
|
56
|
+
constructor(connection, prefix) {
|
|
57
|
+
super();
|
|
58
|
+
this.connection = connection;
|
|
59
|
+
this.prefix = prefix;
|
|
60
|
+
/**
|
|
61
|
+
* `true`: the locks expire after their `ttl` unless renewed.
|
|
62
|
+
*/
|
|
63
|
+
this.expiring = true;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The Redis client, connected at the first call; the concurrent first calls share one connection. An unreachable
|
|
67
|
+
* server fails the calls: the first connection is given up after 5 seconds, and while the client reconnects the
|
|
68
|
+
* calls fail at once (its commands are not queued).
|
|
69
|
+
* @returns The connected client.
|
|
70
|
+
* @throws {MutexDriverNotFoundException} When the package `redis` is not installed.
|
|
71
|
+
* @throws {Error} When the server is not reachable.
|
|
72
|
+
* @protected
|
|
73
|
+
*/
|
|
74
|
+
async client() {
|
|
75
|
+
const current = this.#client;
|
|
76
|
+
if (current?.isReady)
|
|
77
|
+
return current;
|
|
78
|
+
//Reconnecting: the calls fail at once until it is connected again (no new client)
|
|
79
|
+
if (current?.isOpen)
|
|
80
|
+
throw new Error('The Redis server of the locks is not reachable: reconnecting');
|
|
81
|
+
this.#connecting ??= (async () => {
|
|
82
|
+
const { createClient } = await loadDriver('createClient', 'redis');
|
|
83
|
+
const options = this.connection;
|
|
84
|
+
const client = createClient({
|
|
85
|
+
...options.url ? { url: options.url } : {},
|
|
86
|
+
socket: {
|
|
87
|
+
...options.url ? {} : { host: options.host ?? '127.0.0.1', port: options.port ?? 6379, tls: options.tls || undefined },
|
|
88
|
+
connectTimeout: CONNECT_TIMEOUT
|
|
89
|
+
},
|
|
90
|
+
username: options.username,
|
|
91
|
+
password: options.password,
|
|
92
|
+
database: options.database,
|
|
93
|
+
//The commands sent while disconnected fail at once rather than waiting for the connection
|
|
94
|
+
disableOfflineQueue: true
|
|
95
|
+
});
|
|
96
|
+
//The errors of the connection are the ones of the calls (reconnected by the client)
|
|
97
|
+
client.on('error', () => undefined);
|
|
98
|
+
let timer;
|
|
99
|
+
try {
|
|
100
|
+
//The client retries its first connection endlessly: given up after a while
|
|
101
|
+
await Promise.race([client.connect(), new Promise((_resolve, reject) => {
|
|
102
|
+
timer = setTimeout(() => reject(new Error(`The Redis server of the locks is not reachable in ${CONNECT_TIMEOUT} ms`)), CONNECT_TIMEOUT);
|
|
103
|
+
})]);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
try {
|
|
107
|
+
await client.disconnect();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
//Not connected
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
}
|
|
117
|
+
this.#client = client;
|
|
118
|
+
return client;
|
|
119
|
+
})().finally(() => {
|
|
120
|
+
this.#connecting = undefined;
|
|
121
|
+
});
|
|
122
|
+
return await this.#connecting;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The key of a lock: `<prefix>{<name>}`, the name being the hash tag that keeps the lock and its fencing token in the
|
|
126
|
+
* same slot of a cluster.
|
|
127
|
+
* @param name The lock name.
|
|
128
|
+
* @returns The Redis key.
|
|
129
|
+
*/
|
|
130
|
+
key(name) {
|
|
131
|
+
return `${this.prefix}{${name}}`;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Try to acquire a lock once: set its key when it does not exist, with a fencing token incremented.
|
|
135
|
+
* @param name The lock name.
|
|
136
|
+
* @param ttl How long the lock stays held without a renewal, in milliseconds (rounded up).
|
|
137
|
+
* @returns The lock with its fencing token, or `undefined` when another holder has it.
|
|
138
|
+
* @throws {MutexDriverNotFoundException} When the package `redis` is not installed.
|
|
139
|
+
*/
|
|
140
|
+
async acquire(name, ttl) {
|
|
141
|
+
const client = await this.client();
|
|
142
|
+
const token = lockToken();
|
|
143
|
+
const fence = Number(await client.eval(ACQUIRE, { keys: [this.key(name), `${this.key(name)}:fence`], arguments: [token, String(Math.ceil(ttl))] }));
|
|
144
|
+
return fence ? { token: token, fence: fence } : undefined;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Release a lock by deleting its key, when it still holds this holder's token.
|
|
148
|
+
* @param name The lock name.
|
|
149
|
+
* @param lock The lock acquired.
|
|
150
|
+
* @returns Whether it was released (`false` when the key expired or was taken).
|
|
151
|
+
*/
|
|
152
|
+
async release(name, lock) {
|
|
153
|
+
const client = await this.client();
|
|
154
|
+
return Number(await client.eval(RELEASE, { keys: [this.key(name)], arguments: [lock.token] })) === 1;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Renew a lock whose key still holds this holder's token, for `ttl` milliseconds from now.
|
|
158
|
+
* @param name The lock name.
|
|
159
|
+
* @param lock The lock acquired.
|
|
160
|
+
* @param ttl How long the lock stays held without another renewal, in milliseconds (rounded up).
|
|
161
|
+
* @returns Whether it was renewed (`false` when the key expired or was taken).
|
|
162
|
+
*/
|
|
163
|
+
async extend(name, lock, ttl) {
|
|
164
|
+
const client = await this.client();
|
|
165
|
+
return Number(await client.eval(EXTEND, { keys: [this.key(name)], arguments: [lock.token, String(Math.ceil(ttl))] })) === 1;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Close the connection (`QUIT`, or a disconnection when it fails); the keys stay until they expire.
|
|
169
|
+
*/
|
|
170
|
+
async close() {
|
|
171
|
+
const client = this.#client;
|
|
172
|
+
this.#client = undefined;
|
|
173
|
+
if (client?.isOpen)
|
|
174
|
+
await client.quit().catch(() => client.disconnect());
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Exception } from '@lakutata/core';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by the {@link Mutex} methods when the lock store fails (unreachable, a failed query, a file error), its
|
|
4
|
+
* message carrying the driver's error. An automatic renewal failing this way makes the lock lost instead. Its `errno` is
|
|
5
|
+
* `'E_MUTEX_DRIVER'`.
|
|
6
|
+
*/
|
|
7
|
+
export class MutexDriverException extends Exception {
|
|
8
|
+
constructor() {
|
|
9
|
+
super(...arguments);
|
|
10
|
+
/**
|
|
11
|
+
* `'E_MUTEX_DRIVER'`.
|
|
12
|
+
*/
|
|
13
|
+
this.errno = 'E_MUTEX_DRIVER';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Exception } from '@lakutata/core';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by the first acquisition of a {@link Mutex} (or a driver) whose client package is not installed: `redis` for
|
|
4
|
+
* the Redis driver, `pg` for PostgreSQL, `mysql2` for MySQL. Its message names the package to install. Its `errno` is
|
|
5
|
+
* `'E_MUTEX_DRIVER_NOT_FOUND'`.
|
|
6
|
+
*/
|
|
7
|
+
export class MutexDriverNotFoundException extends Exception {
|
|
8
|
+
constructor() {
|
|
9
|
+
super(...arguments);
|
|
10
|
+
/**
|
|
11
|
+
* `'E_MUTEX_DRIVER_NOT_FOUND'`.
|
|
12
|
+
*/
|
|
13
|
+
this.errno = 'E_MUTEX_DRIVER_NOT_FOUND';
|
|
14
|
+
}
|
|
15
|
+
}
|