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