@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.
- 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,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lock held, returned by {@link Mutex.acquire} and {@link Mutex.tryAcquire} (and given to the function of
|
|
3
|
+
* {@link Mutex.synchronized}); the component creates it. It stays held until it is released (once: the next releases do
|
|
4
|
+
* nothing) or lost; the component renews it in the store meanwhile. Declared with `await using`, it is released at the
|
|
5
|
+
* end of its scope.
|
|
6
|
+
*
|
|
7
|
+
* Its {@link MutexLock.signal} is aborted when it is lost: a renewal found it expired or taken by another holder, a
|
|
8
|
+
* renewal failed, or the connection holding it closed. A lost lock is not released in the store: another holder may get
|
|
9
|
+
* it once it expires. The work it protects should stop then, and the writes it makes may carry its
|
|
10
|
+
* {@link MutexLock.fence} so that the store refuses the writes of an older holder.
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* import type {Mutex, MutexLock} from 'lakutata/com/mutex'
|
|
14
|
+
*
|
|
15
|
+
* declare const mutex: Mutex
|
|
16
|
+
* declare function writeReport(fence: number | undefined, signal: AbortSignal): Promise<void>
|
|
17
|
+
*
|
|
18
|
+
* await using lock: MutexLock = await mutex.acquire('report:daily')
|
|
19
|
+
* lock.signal.addEventListener('abort', (): void => console.warn(`lost: ${lock.signal.reason}`))
|
|
20
|
+
* await writeReport(lock.fence, lock.signal)
|
|
21
|
+
* console.log(lock.held, Date.now() - lock.acquiredAt.getTime())
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export class MutexLock {
|
|
25
|
+
#abort;
|
|
26
|
+
#held;
|
|
27
|
+
/**
|
|
28
|
+
* Create a held lock (the component does it, when the store grants a lock).
|
|
29
|
+
* @param name The lock name, without the component's prefix.
|
|
30
|
+
* @param token The random token identifying this holder in the store.
|
|
31
|
+
* @param fence The fencing token given by the store, or `undefined` when the driver gives none.
|
|
32
|
+
* @param actions How the component releases and renews the lock.
|
|
33
|
+
*/
|
|
34
|
+
constructor(name, token, fence, actions) {
|
|
35
|
+
this.name = name;
|
|
36
|
+
this.token = token;
|
|
37
|
+
this.fence = fence;
|
|
38
|
+
this.actions = actions;
|
|
39
|
+
this.#abort = new AbortController();
|
|
40
|
+
this.#held = true;
|
|
41
|
+
/**
|
|
42
|
+
* When the lock was acquired (the time this object was created, once the store granted it).
|
|
43
|
+
*/
|
|
44
|
+
this.acquiredAt = new Date();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether the lock is still held: `false` once it is released or lost. It reflects what the component knows; an
|
|
48
|
+
* expiring lock whose renewal is late may already be expired in the store.
|
|
49
|
+
*/
|
|
50
|
+
get held() {
|
|
51
|
+
return this.#held;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Aborted when the lock is lost, with a {@link MutexLostException} as its reason: a renewal found it expired or taken,
|
|
55
|
+
* a renewal failed, or the connection holding it closed. The work the lock protects should stop then. It is not
|
|
56
|
+
* aborted by a release.
|
|
57
|
+
*/
|
|
58
|
+
get signal() {
|
|
59
|
+
return this.#abort.signal;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Release the lock in the store and stop its renewal. Only the first call releases it; the next ones, and the calls
|
|
63
|
+
* on a lost lock, return `false` without reaching the store. The lock no longer counts as held even when the store
|
|
64
|
+
* fails, in which case it stays in the store until it expires.
|
|
65
|
+
* @returns Whether the store still had the lock for this holder (`false` when it had expired, been taken, or its
|
|
66
|
+
* connection had closed).
|
|
67
|
+
* @throws {MutexDriverException} When the store fails.
|
|
68
|
+
*/
|
|
69
|
+
async release() {
|
|
70
|
+
if (!this.#held)
|
|
71
|
+
return false;
|
|
72
|
+
this.#held = false;
|
|
73
|
+
return await this.actions.release(this);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Renew the lock now, for `ttl` milliseconds from now; the component already renews it automatically every third of
|
|
77
|
+
* its `ttl`. A `ttl` given here is used by the next automatic renewals too, their interval following it. For the
|
|
78
|
+
* advisory locks (which do not expire), it checks that the lock is still held by a query on its connection.
|
|
79
|
+
* @param ttl How long the lock stays held without a renewal, in milliseconds; the lock's last `ttl` (the one of its
|
|
80
|
+
* acquisition) by default.
|
|
81
|
+
* @returns Whether the lock is still held; when the store no longer has it, the lock is marked lost (its signal
|
|
82
|
+
* aborted) and `false` is returned.
|
|
83
|
+
* @throws {MutexDriverException} When the store fails (the lock is not marked lost then).
|
|
84
|
+
*/
|
|
85
|
+
async extend(ttl) {
|
|
86
|
+
if (!this.#held)
|
|
87
|
+
return false;
|
|
88
|
+
return await this.actions.extend(this, ttl);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Mark the lock as lost and abort its signal with the reason; the component calls it when a renewal fails or the
|
|
92
|
+
* connection holding the lock closes. It does nothing on a lock no longer held, and it does not release the lock in
|
|
93
|
+
* the store nor stop its renewal: the applications do not call it.
|
|
94
|
+
* @param reason The signal's abort reason, a {@link MutexLostException} from the component.
|
|
95
|
+
*/
|
|
96
|
+
lose(reason) {
|
|
97
|
+
if (!this.#held)
|
|
98
|
+
return;
|
|
99
|
+
this.#held = false;
|
|
100
|
+
this.#abort.abort(reason);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Release the lock at the end of an `await using` scope, as {@link MutexLock.release} does.
|
|
104
|
+
* @throws {MutexDriverException} When the store fails.
|
|
105
|
+
*/
|
|
106
|
+
async [Symbol.asyncDispose]() {
|
|
107
|
+
await this.release();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a lock name from a template, replacing each `{field}` with that field of the input (`{a.b}` reads a nested
|
|
3
|
+
* field). {@link Synchronized} uses it for its template names; a template without `{}` is returned as it is.
|
|
4
|
+
* @param template The template, such as `'order:{id}'` or `'user:{user.id}:export'`.
|
|
5
|
+
* @param input The object whose fields fill the template (an action's validated input).
|
|
6
|
+
* @returns The lock name.
|
|
7
|
+
* @throws {TypeError} When a field of the template is missing, `null`, or an object (only strings, numbers and other
|
|
8
|
+
* primitives are accepted).
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import {lockNameOf} from 'lakutata/com/mutex'
|
|
12
|
+
*
|
|
13
|
+
* console.log(lockNameOf('order:{id}', {id: 42})) //order:42
|
|
14
|
+
* console.log(lockNameOf('user:{user.id}:export', {user: {id: 'u7'}})) //user:u7:export
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export function lockNameOf(template, input) {
|
|
18
|
+
return template.replace(/\{([^{}]+)}/g, (_, field) => {
|
|
19
|
+
const value = field.split('.').reduce((current, key) => current === null || current === undefined ? undefined : current[key], input);
|
|
20
|
+
if (value === undefined || value === null || (typeof value === 'object'))
|
|
21
|
+
throw new TypeError(`The lock "${template}" needs the field ${field} of the input of the action (a string or a number)`);
|
|
22
|
+
return String(value);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Run a controller action holding a lock, acquired once its input is validated and its access control passed, released
|
|
27
|
+
* after it (whether it returns or throws). The requests needing the same lock run one at a time in every instance
|
|
28
|
+
* sharing the lock store, whatever their transport (HTTP, CLI, service); the others run in parallel.
|
|
29
|
+
*
|
|
30
|
+
* The lock comes from the {@link Mutex} component registered as `mutex` (or `options.mutex`); a missing component
|
|
31
|
+
* fails the request. The wait for the lock stops when the request is aborted (its client gone), and a request not
|
|
32
|
+
* getting the lock in time fails with a {@link MutexTimeoutException} (HTTP 409). The action is not interrupted when
|
|
33
|
+
* its lock is lost: check the controller's `this.signal` for a long work.
|
|
34
|
+
* @param name The lock name: a template filled from the fields of the action's input (`'order:{id}'`, see
|
|
35
|
+
* {@link lockNameOf}; a missing field fails the request with a `TypeError`), or a function of the input and the
|
|
36
|
+
* request's context returning it.
|
|
37
|
+
* @param options The lock component, the wait and the lock's `ttl`.
|
|
38
|
+
* @returns The method decorator.
|
|
39
|
+
* @throws {TypeError} When the class is declared, if the name is neither a non-empty string nor a function, or if the
|
|
40
|
+
* decorated member is not a method.
|
|
41
|
+
* @throws {MutexTimeoutException} From the action, when the lock is not acquired in time (HTTP 409).
|
|
42
|
+
* @throws {MutexDriverException} From the action, when the lock store fails.
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* import {Controller} from 'lakutata/com/entrypoint'
|
|
46
|
+
* import {ServiceAction} from 'lakutata/decorator/ctrl'
|
|
47
|
+
* import {Synchronized} from 'lakutata/com/mutex'
|
|
48
|
+
*
|
|
49
|
+
* class OrderController extends Controller {
|
|
50
|
+
* //The payments of an order one at a time, the other orders in parallel
|
|
51
|
+
* @ServiceAction({cmd: 'order.pay'})
|
|
52
|
+
* @Synchronized('order:{id}')
|
|
53
|
+
* public async pay(input: {id: number}): Promise<void> {
|
|
54
|
+
* }
|
|
55
|
+
*
|
|
56
|
+
* //Fails at once (HTTP 409) while another request rebuilds the index
|
|
57
|
+
* @ServiceAction({cmd: 'search.reindex'})
|
|
58
|
+
* @Synchronized('search:reindex', {wait: false})
|
|
59
|
+
* public async reindex(): Promise<void> {
|
|
60
|
+
* }
|
|
61
|
+
*
|
|
62
|
+
* //A lock named by a function, from the component registered as "locks"
|
|
63
|
+
* @ServiceAction({cmd: 'user.export'})
|
|
64
|
+
* @Synchronized((input: {userId: string}): string => `export:${input.userId}`, {timeout: 5000, mutex: 'locks'})
|
|
65
|
+
* public async export(input: {userId: string}): Promise<void> {
|
|
66
|
+
* }
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function Synchronized(name, options = {}) {
|
|
71
|
+
if (typeof name !== 'function' && (typeof name !== 'string' || !name))
|
|
72
|
+
throw new TypeError('The lock of a synchronized action is a template or a function');
|
|
73
|
+
return (_target, propertyKey, descriptor) => {
|
|
74
|
+
const action = descriptor.value;
|
|
75
|
+
if (typeof action !== 'function')
|
|
76
|
+
throw new TypeError(`@Synchronized decorates a method (${String(propertyKey)})`);
|
|
77
|
+
descriptor.value = async function (...args) {
|
|
78
|
+
const input = args[0] ?? {};
|
|
79
|
+
const lockName = typeof name === 'function' ? name(input, this.context) : lockNameOf(name, input);
|
|
80
|
+
const mutex = await this.getObject(options.mutex ?? 'mutex');
|
|
81
|
+
return await mutex.synchronized(lockName, (_lock) => action.apply(this, args), {
|
|
82
|
+
timeout: options.wait === false ? 0 : options.timeout,
|
|
83
|
+
ttl: options.ttl,
|
|
84
|
+
signal: this.signal
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
return descriptor;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { link, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { MutexDriver } from './MutexDriver.js';
|
|
5
|
+
import { lockToken, nameDigest } from '../lib/Names.js';
|
|
6
|
+
/**
|
|
7
|
+
* Whether a process of this host runs
|
|
8
|
+
* @param pid
|
|
9
|
+
*/
|
|
10
|
+
function processRuns(pid) {
|
|
11
|
+
try {
|
|
12
|
+
process.kill(pid, 0);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
//Running, but not ours to signal
|
|
17
|
+
return error.code === 'EPERM';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The file driver (`{type: 'file'}`): each lock is a file of a directory, created exclusively, shared by the processes of
|
|
22
|
+
* one host using the same directory and prefix (several workers or applications of a machine). It needs no package.
|
|
23
|
+
* The processes must share the directory: the default one (`lakutata-mutex` in the operating system's temporary
|
|
24
|
+
* directory) can differ between users. A directory shared by several hosts (a network file system) works only as far
|
|
25
|
+
* as its exclusive creation and renames are atomic, and only the expiration then frees the lock of a crashed host.
|
|
26
|
+
*
|
|
27
|
+
* The locks expire after their `ttl` unless renewed; a lock whose process is gone on this host is taken over at once,
|
|
28
|
+
* without waiting for its expiration. The driver gives no fencing token (`fence` is `undefined`).
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* import {FileMutexDriver, type DriverLock} from 'lakutata/com/mutex'
|
|
32
|
+
*
|
|
33
|
+
* //The workers of this machine run the migration one at a time
|
|
34
|
+
* const driver: FileMutexDriver = new FileMutexDriver('/var/lock/my-app', 'my-app:')
|
|
35
|
+
* const lock: DriverLock | undefined = await driver.acquire('migrate', 60000)
|
|
36
|
+
* if (lock) {
|
|
37
|
+
* try {
|
|
38
|
+
* console.log('migrating')
|
|
39
|
+
* } finally {
|
|
40
|
+
* await driver.release('migrate', lock)
|
|
41
|
+
* }
|
|
42
|
+
* }
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export class FileMutexDriver extends MutexDriver {
|
|
46
|
+
#host;
|
|
47
|
+
/**
|
|
48
|
+
* Create a driver on a directory, created at the first acquisition when it does not exist.
|
|
49
|
+
* @param directory The directory of the lock files.
|
|
50
|
+
* @param prefix The prefix added to the lock names; `''` by default.
|
|
51
|
+
*/
|
|
52
|
+
constructor(directory, prefix = '') {
|
|
53
|
+
super();
|
|
54
|
+
this.directory = directory;
|
|
55
|
+
this.prefix = prefix;
|
|
56
|
+
/**
|
|
57
|
+
* `true`: the locks expire after their `ttl` unless renewed.
|
|
58
|
+
*/
|
|
59
|
+
this.expiring = true;
|
|
60
|
+
this.#host = hostname();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The path of a lock's file: the SHA-256 digest of the prefixed name, with the extension `.lock`.
|
|
64
|
+
* @param name The lock name.
|
|
65
|
+
* @returns The file path.
|
|
66
|
+
*/
|
|
67
|
+
file(name) {
|
|
68
|
+
return path.join(this.directory, `${nameDigest(this.prefix + name)}.lock`);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Read a lock file.
|
|
72
|
+
* @param file The file path.
|
|
73
|
+
* @returns Its content, or `undefined` when it does not exist or cannot be parsed (being written).
|
|
74
|
+
*/
|
|
75
|
+
async read(file) {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
//Not there, or being written
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Whether a lock file is no longer held: expired, or written by another process of this host which no longer runs.
|
|
86
|
+
* @param lock The content of the lock file.
|
|
87
|
+
* @returns Whether it may be taken over.
|
|
88
|
+
*/
|
|
89
|
+
stale(lock) {
|
|
90
|
+
return lock.expires <= Date.now() || (lock.host === this.#host && lock.pid !== process.pid && !processRuns(lock.pid));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Whether a lock file which cannot be read was abandoned (its process ended while writing it) rather than being
|
|
94
|
+
* written: unchanged for more than 5 seconds.
|
|
95
|
+
* @param file The file path.
|
|
96
|
+
* @returns Whether it may be taken over.
|
|
97
|
+
*/
|
|
98
|
+
async abandoned(file) {
|
|
99
|
+
try {
|
|
100
|
+
return Date.now() - (await stat(file)).mtimeMs > 5000;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Remove a lock file when it is still the one read, by renaming it first so that only one process takes it. A file
|
|
108
|
+
* of another holder taken by mistake is put back when its place is still free.
|
|
109
|
+
* @param file The file path.
|
|
110
|
+
* @param token The token read in the file, or `undefined` for a file which could not be read.
|
|
111
|
+
* @returns Whether the file was removed.
|
|
112
|
+
*/
|
|
113
|
+
async take(file, token) {
|
|
114
|
+
const taken = `${file}.${lockToken()}`;
|
|
115
|
+
try {
|
|
116
|
+
await rename(file, taken);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const content = await this.read(taken);
|
|
122
|
+
if (content?.token === token) {
|
|
123
|
+
await unlink(taken).catch(() => undefined);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
//Another lock was taken: given back when its place is free
|
|
127
|
+
await link(taken, file).catch(() => undefined);
|
|
128
|
+
await unlink(taken).catch(() => undefined);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Try to acquire a lock once: create its file exclusively, or take over a file expired, abandoned, or whose process
|
|
133
|
+
* of this host is gone.
|
|
134
|
+
* @param name The lock name.
|
|
135
|
+
* @param ttl How long the lock stays held without a renewal, in milliseconds.
|
|
136
|
+
* @returns The lock (without fencing token), or `undefined` when another holder has it.
|
|
137
|
+
* @throws {Error} When the directory cannot be created or the file written (permissions, disk full).
|
|
138
|
+
*/
|
|
139
|
+
async acquire(name, ttl) {
|
|
140
|
+
await mkdir(this.directory, { recursive: true });
|
|
141
|
+
const file = this.file(name);
|
|
142
|
+
const lock = { token: lockToken() };
|
|
143
|
+
const content = JSON.stringify({ name: name, token: lock.token, pid: process.pid, host: this.#host, expires: Date.now() + ttl });
|
|
144
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
145
|
+
try {
|
|
146
|
+
await writeFile(file, content, { flag: 'wx' });
|
|
147
|
+
return lock;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error.code !== 'EEXIST')
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
const current = await this.read(file);
|
|
154
|
+
if (current ? !this.stale(current) : !(await this.abandoned(file)))
|
|
155
|
+
return undefined;
|
|
156
|
+
if (!(await this.take(file, current?.token)))
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Release a lock by removing its file, when the file still holds this holder's token.
|
|
163
|
+
* @param name The lock name.
|
|
164
|
+
* @param lock The lock acquired.
|
|
165
|
+
* @returns Whether it was released.
|
|
166
|
+
*/
|
|
167
|
+
async release(name, lock) {
|
|
168
|
+
const file = this.file(name);
|
|
169
|
+
if ((await this.read(file))?.token !== lock.token)
|
|
170
|
+
return false;
|
|
171
|
+
return await this.take(file, lock.token);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Renew a lock whose file still holds this holder's token, for `ttl` milliseconds from now: the file is taken away
|
|
175
|
+
* (renamed), checked, rewritten and given back without replacing a lock another process acquired meanwhile (the
|
|
176
|
+
* renewal fails then). A lock expired is not renewed (lost), even when no other process took it over yet.
|
|
177
|
+
* @param name The lock name.
|
|
178
|
+
* @param lock The lock acquired.
|
|
179
|
+
* @param ttl How long the lock stays held without another renewal, in milliseconds.
|
|
180
|
+
* @returns Whether it was renewed.
|
|
181
|
+
*/
|
|
182
|
+
async extend(name, lock, ttl) {
|
|
183
|
+
const file = this.file(name);
|
|
184
|
+
const current = await this.read(file);
|
|
185
|
+
//Expired: lost, even when nobody took it yet
|
|
186
|
+
if (current?.token !== lock.token || current.expires <= Date.now())
|
|
187
|
+
return false;
|
|
188
|
+
//Taken away (renamed: only one taker has it) and checked, renewed, then given back without replacing a lock
|
|
189
|
+
//acquired meanwhile (linked: it fails when the file exists)
|
|
190
|
+
const taken = `${file}.${lockToken()}`;
|
|
191
|
+
try {
|
|
192
|
+
await rename(file, taken);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
const content = await this.read(taken);
|
|
198
|
+
if (content?.token !== lock.token) {
|
|
199
|
+
//Another holder's lock, taken over meanwhile: given back
|
|
200
|
+
await link(taken, file).catch(() => undefined);
|
|
201
|
+
await unlink(taken).catch(() => undefined);
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
await writeFile(taken, JSON.stringify({ ...content, expires: Date.now() + ttl }));
|
|
205
|
+
try {
|
|
206
|
+
await link(taken, file);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
//Acquired by another process while it was taken away: lost
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
await unlink(taken).catch(() => undefined);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Do nothing: the driver keeps no file open; the lock files stay until they are released or taken over.
|
|
219
|
+
*/
|
|
220
|
+
async close() {
|
|
221
|
+
//Nothing kept open
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { MutexDriver } from './MutexDriver.js';
|
|
2
|
+
import { lockToken } from '../lib/Names.js';
|
|
3
|
+
/**
|
|
4
|
+
* The locks of the process, shared by its components: the ones of a single instance (its development, its tests)
|
|
5
|
+
*/
|
|
6
|
+
const LOCKS = new Map();
|
|
7
|
+
let FENCE = 0;
|
|
8
|
+
/**
|
|
9
|
+
* The memory driver (`{type: 'memory'}`, the default of a {@link Mutex}): the locks live in the process memory, shared
|
|
10
|
+
* by every component and driver of the process using the same prefix, and invisible to the other processes. Use it for
|
|
11
|
+
* development, tests and an application running as a single process. It needs no package.
|
|
12
|
+
*
|
|
13
|
+
* The locks expire after their `ttl` unless renewed. The fencing token is a counter of the process, starting again at 1
|
|
14
|
+
* when the process restarts. The locks are lost with the process.
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* import {MemoryMutexDriver, type DriverLock} from 'lakutata/com/mutex'
|
|
18
|
+
*
|
|
19
|
+
* const driver: MemoryMutexDriver = new MemoryMutexDriver('test:')
|
|
20
|
+
* const first: DriverLock | undefined = await driver.acquire('job', 1000)
|
|
21
|
+
* console.log(await driver.acquire('job', 1000)) //undefined: held by the first
|
|
22
|
+
* if (first) console.log(await driver.release('job', first)) //true
|
|
23
|
+
* await driver.close()
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export class MemoryMutexDriver extends MutexDriver {
|
|
27
|
+
#locks;
|
|
28
|
+
/**
|
|
29
|
+
* Create a driver on the process memory.
|
|
30
|
+
* @param prefix The prefix added to the lock names; the drivers of the process with the same prefix share their
|
|
31
|
+
* locks. `''` by default.
|
|
32
|
+
*/
|
|
33
|
+
constructor(prefix = '') {
|
|
34
|
+
super();
|
|
35
|
+
this.prefix = prefix;
|
|
36
|
+
/**
|
|
37
|
+
* `true`: the locks expire after their `ttl` unless renewed.
|
|
38
|
+
*/
|
|
39
|
+
this.expiring = true;
|
|
40
|
+
this.#locks = LOCKS;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Try to acquire a lock once: granted when nobody holds it or its holder let it expire.
|
|
44
|
+
* @param name The lock name.
|
|
45
|
+
* @param ttl How long the lock stays held without a renewal, in milliseconds.
|
|
46
|
+
* @returns The lock with a fencing token, or `undefined` when another holder has it.
|
|
47
|
+
*/
|
|
48
|
+
async acquire(name, ttl) {
|
|
49
|
+
const key = this.prefix + name;
|
|
50
|
+
const current = this.#locks.get(key);
|
|
51
|
+
if (current && current.expires > Date.now())
|
|
52
|
+
return undefined;
|
|
53
|
+
const lock = { token: lockToken(), fence: ++FENCE };
|
|
54
|
+
this.#locks.set(key, { token: lock.token, expires: Date.now() + ttl });
|
|
55
|
+
return lock;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Release a lock still held by this holder.
|
|
59
|
+
* @param name The lock name.
|
|
60
|
+
* @param lock The lock acquired.
|
|
61
|
+
* @returns Whether it was released (`false` when another holder has taken it since).
|
|
62
|
+
*/
|
|
63
|
+
async release(name, lock) {
|
|
64
|
+
if (this.#locks.get(this.prefix + name)?.token !== lock.token)
|
|
65
|
+
return false;
|
|
66
|
+
this.#locks.delete(this.prefix + name);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Renew a lock still held by this holder, for `ttl` milliseconds from now. A lock expired is not renewed (lost),
|
|
71
|
+
* even when no other holder took it yet.
|
|
72
|
+
* @param name The lock name.
|
|
73
|
+
* @param lock The lock acquired.
|
|
74
|
+
* @param ttl How long the lock stays held without another renewal, in milliseconds.
|
|
75
|
+
* @returns Whether it was renewed.
|
|
76
|
+
*/
|
|
77
|
+
async extend(name, lock, ttl) {
|
|
78
|
+
const current = this.#locks.get(this.prefix + name);
|
|
79
|
+
//Expired: lost, even when nobody took it yet
|
|
80
|
+
if (current?.token !== lock.token || current.expires <= Date.now())
|
|
81
|
+
return false;
|
|
82
|
+
current.expires = Date.now() + ttl;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Do nothing: the locks stay in the process memory until they are released or expire.
|
|
87
|
+
*/
|
|
88
|
+
async close() {
|
|
89
|
+
//The locks of the process kept: the ones of this component are released by it
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The base class of the lock drivers: it acquires, releases and renews the locks in a store shared by their holders.
|
|
3
|
+
* A {@link Mutex} creates its driver from its `driver` option and adds the waiting, the automatic renewal and the lost
|
|
4
|
+
* locks; a custom subclass cannot be given to it. The drivers are exported to use a store directly (scripts, tests):
|
|
5
|
+
* they neither wait nor renew, and their errors are the clients' errors, not a {@link MutexDriverException}.
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import {MemoryMutexDriver, type DriverLock, type MutexDriver} from 'lakutata/com/mutex'
|
|
9
|
+
*
|
|
10
|
+
* async function once(driver: MutexDriver, name: string, work: () => Promise<void>): Promise<boolean> {
|
|
11
|
+
* const lock: DriverLock | undefined = await driver.acquire(name, 10000)
|
|
12
|
+
* if (!lock) return false
|
|
13
|
+
* try {
|
|
14
|
+
* await work()
|
|
15
|
+
* } finally {
|
|
16
|
+
* await driver.release(name, lock)
|
|
17
|
+
* }
|
|
18
|
+
* return true
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* const driver: MutexDriver = new MemoryMutexDriver('script:')
|
|
22
|
+
* console.log(await once(driver, 'migrate', async (): Promise<void> => undefined))
|
|
23
|
+
* await driver.close()
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export class MutexDriver {
|
|
27
|
+
constructor() {
|
|
28
|
+
/**
|
|
29
|
+
* Called with the names of the locks the driver lost at once, when the connection holding advisory locks closes or
|
|
30
|
+
* fails. A {@link Mutex} sets it; it does nothing by default.
|
|
31
|
+
*/
|
|
32
|
+
this.onLost = () => undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|