@lakutata/mutex 0.0.0-stage → 3.0.0-beta.1

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