better-lock 3.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -10
- package/dist/async_context.d.ts +27 -0
- package/dist/async_context.js +58 -0
- package/dist/async_context.js.map +1 -0
- package/dist/better_lock.d.ts +21 -1
- package/dist/better_lock.js +162 -40
- package/dist/better_lock.js.map +1 -1
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +19 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/internals.d.ts +10 -0
- package/dist/internals.js +9 -0
- package/dist/internals.js.map +1 -1
- package/dist/options.d.ts +21 -0
- package/package.json +3 -4
- package/spec/async_context.spec.ts +379 -0
- package/spec/better_lock.spec.ts +12 -0
- package/src/async_context.ts +86 -0
- package/src/better_lock.ts +182 -47
- package/src/errors.ts +28 -0
- package/src/index.ts +5 -1
- package/src/internals.ts +12 -0
- package/src/options.ts +24 -0
- package/tsconfig.build.json +6 -0
- package/tsconfig.json +1 -0
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@ A (better) node.js lock library.
|
|
|
9
9
|
- Queue and execution timeouts
|
|
10
10
|
- Queue size limit
|
|
11
11
|
- Lock on multiple keys
|
|
12
|
+
- Reentrancy, with deadlock prevention (Node.js)
|
|
13
|
+
- Executors run in the caller's async context, preserving `AsyncLocalStorage` stores (Node.js)
|
|
12
14
|
- Extended stack traces
|
|
13
15
|
- Promise and callback interface
|
|
14
16
|
- JSDoc annotations
|
|
@@ -24,6 +26,27 @@ npm install --save better-lock
|
|
|
24
26
|
yarn add better-lock
|
|
25
27
|
```
|
|
26
28
|
|
|
29
|
+
### Browser usage
|
|
30
|
+
|
|
31
|
+
Better Lock can be bundled for frontend use. A lock coordinates asynchronous work only within the
|
|
32
|
+
current JavaScript context; it is not shared between tabs, workers, or other devices.
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import BetterLock from 'better-lock';
|
|
36
|
+
|
|
37
|
+
const lock = new BetterLock();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The published package is CommonJS, so use it through a frontend bundler such as Vite or webpack. It
|
|
41
|
+
cannot be loaded directly from a browser `<script type="module">` tag.
|
|
42
|
+
|
|
43
|
+
Extended stack traces are enabled by default when the runtime supports `Error.captureStackTrace`.
|
|
44
|
+
In browsers without that API, Better Lock automatically skips stack extension and otherwise works
|
|
45
|
+
normally.
|
|
46
|
+
|
|
47
|
+
[Async context](#async-context) and [reentrancy](#reentrancy) require Node.js `async_hooks`, so they are unavailable in
|
|
48
|
+
browsers. See [Runtime support](#runtime-support).
|
|
49
|
+
|
|
27
50
|
### Documentation
|
|
28
51
|
|
|
29
52
|
Full docs: <https://panta82.github.io/better-lock/>
|
|
@@ -132,15 +155,13 @@ lock.acquire(
|
|
|
132
155
|
);
|
|
133
156
|
```
|
|
134
157
|
|
|
135
|
-
You can see a bunch more usage examples in the spec file, [here](spec/better_lock.spec.ts);
|
|
136
|
-
|
|
137
158
|
### API
|
|
138
159
|
|
|
139
160
|
- `new BetterLock(options)`
|
|
140
161
|
Create a new instance of `BetterLock`. Options should match interface `BetterLockOptions`. See below for details.
|
|
141
162
|
|
|
142
163
|
- `BetterLock.acquire([key], executor, [callback], [jobOptions])`
|
|
143
|
-
The main method you'll want to call. For each `key`, given `executor` will be called only one at a time. If you don't provide `callback`, it will return a promise that will be resolved with whatever `executor` returns.
|
|
164
|
+
The main method you'll want to call. For each `key`, given `executor` will be called only one at a time. If you don't provide `callback`, it will return a promise that will be resolved with whatever `executor` returns. If the caller already holds the keys, the executor runs without waiting (see [Reentrancy](#reentrancy)).
|
|
144
165
|
|
|
145
166
|
- `key`: Arbitrary string under which to lock. It allows you to use the same lock instance for multiple parallel concerns. Eg. this might be a database record id or filename.
|
|
146
167
|
- `executor`: Function that will be called within the lock. This function should have one of two forms.
|
|
@@ -154,7 +175,7 @@ You can see a bunch more usage examples in the spec file, [here](spec/better_loc
|
|
|
154
175
|
an error, the lock will resolve/callback with the provided failureResult value (for example, a `null`).
|
|
155
176
|
|
|
156
177
|
- `BetterLock.canAcquire([key])`
|
|
157
|
-
Returns true if given key can be acquired.
|
|
178
|
+
Returns true if the given key can be acquired immediately: nothing holds it and nothing waits for it, or the caller already holds it and reentrancy is allowed.
|
|
158
179
|
|
|
159
180
|
- `BetterLock.abort([key])`
|
|
160
181
|
Abort all jobs for a given key (or from the default job queue, if no key is given). Job executors will not be called. Callbacks will be called with `JobAbortedError`. Currently executing job will not be interrupted.
|
|
@@ -188,6 +209,9 @@ Most commonly used options are:
|
|
|
188
209
|
- `queue_size`
|
|
189
210
|
Max queue size for waiting jobs.
|
|
190
211
|
|
|
212
|
+
- `reentrancy`
|
|
213
|
+
`'allow'` (default) or `'error'`. What to do when the caller already holds all the requested keys. See [Reentrancy](#reentrancy).
|
|
214
|
+
|
|
191
215
|
Default options are a static member `DEFAULT_OPTIONS` on the `BetterLock` class. During runtime, you can change the defaults like this:
|
|
192
216
|
|
|
193
217
|
```javascript
|
|
@@ -196,6 +220,47 @@ import BetterLock from 'better-lock';
|
|
|
196
220
|
BetterLock.DEFAULT_OPTIONS.wait_timeout = 1000;
|
|
197
221
|
```
|
|
198
222
|
|
|
223
|
+
### Reentrancy
|
|
224
|
+
|
|
225
|
+
Reentrancy means you can re-take the same lock from within the lock. This uses node's [AsyncLocalStorage](https://beta.docs.nodejs.org/async_context#class-asynclocalstorage) under the hood.
|
|
226
|
+
|
|
227
|
+
```javascript
|
|
228
|
+
const lock = new BetterLock();
|
|
229
|
+
|
|
230
|
+
async function saveUser(user) {
|
|
231
|
+
return lock.acquire(user.id, () => db.save(user));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function renameUser(id, name) {
|
|
235
|
+
return lock.acquire(id, async () => {
|
|
236
|
+
const user = await db.load(id);
|
|
237
|
+
user.name = name;
|
|
238
|
+
// Holds `id` already, so this doesn't wait
|
|
239
|
+
return saveUser(user);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
If you use multi-keys, the reentrancy will only work if you already hold _all_ the requested keys. Otherwise, the acquire fails with `BetterLockReentrancyError` (passed to the callback, or rejected from the promise), to prevent deadlocks. `acquireOr` passes it on too, instead of returning `failureResult`.
|
|
245
|
+
|
|
246
|
+
Reentrant acquire works transparently. You'll get an immediate callback in the next tick, without taking any extra slots. `canAcquire(key)` returns true. If `lock_condition` rejects the key, nothing is locked, so there is nothing to reenter.
|
|
247
|
+
|
|
248
|
+
### Async context
|
|
249
|
+
|
|
250
|
+
Executors and callbacks run in the async context of the `acquire` call. If you use `AsyncLocalStorage` (eg. for request ids in logs), code inside the executor sees the store of whoever called `acquire`, even if it had to wait for another caller to release the lock.
|
|
251
|
+
|
|
252
|
+
```javascript
|
|
253
|
+
const requestContext = new AsyncLocalStorage();
|
|
254
|
+
|
|
255
|
+
requestContext.run({ requestId: 1 }, () => lock.acquire(() => slowTask()));
|
|
256
|
+
requestContext.run({ requestId: 2 }, () =>
|
|
257
|
+
lock.acquire(() => {
|
|
258
|
+
// Runs after request 1 releases the lock, but still sees request 2's store
|
|
259
|
+
console.log(requestContext.getStore().requestId); // 2
|
|
260
|
+
})
|
|
261
|
+
);
|
|
262
|
+
```
|
|
263
|
+
|
|
199
264
|
### When to use
|
|
200
265
|
|
|
201
266
|
This library is a good fit if:
|
|
@@ -205,15 +270,23 @@ This library is a good fit if:
|
|
|
205
270
|
- You like good error messages, with full stack traces.
|
|
206
271
|
- You like good types, either through typescript or JSDoc comments
|
|
207
272
|
|
|
208
|
-
The library is not a good fit if
|
|
273
|
+
The library is not a good fit if you need a **shared lock between different processes**. BetterLock is single process only.
|
|
209
274
|
|
|
210
|
-
|
|
211
|
-
There is no good solution for this in node.js that I know of. And this library doesn't offer any.
|
|
275
|
+
### Change log
|
|
212
276
|
|
|
213
|
-
|
|
214
|
-
This library is a single process only. If you need to coordinate multiple apps or services, you need a different library.
|
|
277
|
+
#### **4.0.0** (_2026/11/09_)
|
|
215
278
|
|
|
216
|
-
|
|
279
|
+
- Added **reentrancy**, You can now acquire the same lock again from within the lock. Set `options.reentrancy` to `"error"` to fail with `BetterLockReentrancyError` instead of re-locking the keys.
|
|
280
|
+
- Executors and callbacks run in the async context of their `acquire` call, so `AsyncLocalStorage` stores no longer leak from the previous lock holder.
|
|
281
|
+
- Both features require `process.getBuiltinModule` (Node.js 20.16+ or 22.3+). In other runtimes, the lock behaves as before.
|
|
282
|
+
|
|
283
|
+
#### **3.3.0** (_2026/08/25_)
|
|
284
|
+
|
|
285
|
+
Make the library compatible with browsers.
|
|
286
|
+
|
|
287
|
+
#### **3.2.0** (_2024/06/21_)
|
|
288
|
+
|
|
289
|
+
Add new api `canAcquire`. A helper for "best effort" lock acquisition.
|
|
217
290
|
|
|
218
291
|
#### **3.2.0** (_2024/06/21_)
|
|
219
292
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { LockJob } from './internals';
|
|
2
|
+
/**
|
|
3
|
+
* The subset of Node.js AsyncResource we use. Declared locally, so the package (and its type declarations)
|
|
4
|
+
* don't depend on Node.js types and keep working in browsers.
|
|
5
|
+
*/
|
|
6
|
+
export interface IAsyncResource {
|
|
7
|
+
runInAsyncScope<TResult>(fn: (...args: any[]) => TResult, thisArg?: any, ...args: any[]): TResult;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Jobs whose executors are currently running, in the current async context. Jobs from all lock instances
|
|
11
|
+
* share one list; a job only ever matches the queues of the lock that created it.
|
|
12
|
+
*/
|
|
13
|
+
declare type IHeldJobs = ReadonlyArray<LockJob<any>>;
|
|
14
|
+
/**
|
|
15
|
+
* Capture the current async context, so a job can later run in it. Undefined if the runtime doesn't support it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function captureAsyncContext(): IAsyncResource | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Jobs that are executing in the current async context (the caller is inside their executors).
|
|
20
|
+
* Ended jobs can still be listed here (eg. detached work that outlived its job), so check `ended_at`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function getHeldJobs(): IHeldJobs | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Run fn with the given job added to the jobs held in the current async context.
|
|
25
|
+
*/
|
|
26
|
+
export declare function runHoldingJob<TResult>(job: LockJob<any>, fn: () => TResult): TResult;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runHoldingJob = exports.getHeldJobs = exports.captureAsyncContext = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Look up Node.js async_hooks at runtime, without a static import, so bundlers and browsers never see it.
|
|
6
|
+
* Requires Node.js 20.16+ or 22.3+. Returns undefined everywhere else.
|
|
7
|
+
*/
|
|
8
|
+
function lookupAsyncHooks() {
|
|
9
|
+
var _a, _b;
|
|
10
|
+
try {
|
|
11
|
+
var asyncHooks_1 = (_b = (_a = globalThis.process) === null || _a === void 0 ? void 0 : _a.getBuiltinModule) === null || _b === void 0 ? void 0 : _b.call(_a, 'node:async_hooks');
|
|
12
|
+
if (asyncHooks_1 &&
|
|
13
|
+
typeof asyncHooks_1.AsyncResource === 'function' &&
|
|
14
|
+
typeof asyncHooks_1.AsyncLocalStorage === 'function') {
|
|
15
|
+
return asyncHooks_1;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
// Unsupported runtime
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
var asyncHooks = lookupAsyncHooks();
|
|
24
|
+
var lockContext = asyncHooks
|
|
25
|
+
? new asyncHooks.AsyncLocalStorage()
|
|
26
|
+
: undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Capture the current async context, so a job can later run in it. Undefined if the runtime doesn't support it.
|
|
29
|
+
*/
|
|
30
|
+
function captureAsyncContext() {
|
|
31
|
+
return asyncHooks ? new asyncHooks.AsyncResource('BetterLock') : undefined;
|
|
32
|
+
}
|
|
33
|
+
exports.captureAsyncContext = captureAsyncContext;
|
|
34
|
+
/**
|
|
35
|
+
* Jobs that are executing in the current async context (the caller is inside their executors).
|
|
36
|
+
* Ended jobs can still be listed here (eg. detached work that outlived its job), so check `ended_at`.
|
|
37
|
+
*/
|
|
38
|
+
function getHeldJobs() {
|
|
39
|
+
return lockContext ? lockContext.getStore() : undefined;
|
|
40
|
+
}
|
|
41
|
+
exports.getHeldJobs = getHeldJobs;
|
|
42
|
+
/**
|
|
43
|
+
* Run fn with the given job added to the jobs held in the current async context.
|
|
44
|
+
*/
|
|
45
|
+
function runHoldingJob(job, fn) {
|
|
46
|
+
if (!lockContext) {
|
|
47
|
+
return fn();
|
|
48
|
+
}
|
|
49
|
+
return lockContext.run(withHeldJob(lockContext.getStore(), job), fn);
|
|
50
|
+
}
|
|
51
|
+
exports.runHoldingJob = runHoldingJob;
|
|
52
|
+
function withHeldJob(heldJobs, job) {
|
|
53
|
+
// Drop ended jobs, so the list doesn't grow when each executor schedules the next acquire
|
|
54
|
+
var result = heldJobs ? heldJobs.filter(function (heldJob) { return !heldJob.ended_at; }) : [];
|
|
55
|
+
result.push(job);
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=async_context.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"async_context.js","sourceRoot":"","sources":["../src/async_context.ts"],"names":[],"mappings":";;;AA6BA;;;GAGG;AACH,SAAS,gBAAgB;;IACvB,IAAI;QACF,IAAM,YAAU,GAAG,MAAA,MAAC,UAAkB,CAAC,OAAO,0CAAE,gBAAgB,mDAAG,kBAAkB,CAAC,CAAC;QACvF,IACE,YAAU;YACV,OAAO,YAAU,CAAC,aAAa,KAAK,UAAU;YAC9C,OAAO,YAAU,CAAC,iBAAiB,KAAK,UAAU,EAClD;YACA,OAAO,YAAU,CAAC;SACnB;KACF;IAAC,OAAO,GAAG,EAAE;QACZ,sBAAsB;KACvB;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,IAAM,UAAU,GAAG,gBAAgB,EAAE,CAAC;AAEtC,IAAM,WAAW,GAA8C,UAAU;IACvE,CAAC,CAAC,IAAI,UAAU,CAAC,iBAAiB,EAAa;IAC/C,CAAC,CAAC,SAAS,CAAC;AAEd;;GAEG;AACH,SAAgB,mBAAmB;IACjC,OAAO,UAAU,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC,aAAa,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AAC7E,CAAC;AAFD,kDAEC;AAED;;;GAGG;AACH,SAAgB,WAAW;IACzB,OAAO,WAAW,CAAC,CAAC,CAAC,WAAW,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AAC1D,CAAC;AAFD,kCAEC;AAED;;GAEG;AACH,SAAgB,aAAa,CAAU,GAAiB,EAAE,EAAiB;IACzE,IAAI,CAAC,WAAW,EAAE;QAChB,OAAO,EAAE,EAAE,CAAC;KACb;IACD,OAAO,WAAW,CAAC,GAAG,CAAC,WAAW,CAAC,WAAW,CAAC,QAAQ,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC;AACvE,CAAC;AALD,sCAKC;AAED,SAAS,WAAW,CAAC,QAA+B,EAAE,GAAiB;IACrE,0FAA0F;IAC1F,IAAM,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,UAAA,OAAO,IAAI,OAAA,CAAC,OAAO,CAAC,QAAQ,EAAjB,CAAiB,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAC7E,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACjB,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
package/dist/better_lock.d.ts
CHANGED
|
@@ -11,6 +11,10 @@ export declare class BetterLock {
|
|
|
11
11
|
private readonly options;
|
|
12
12
|
private readonly log;
|
|
13
13
|
private readonly queues;
|
|
14
|
+
/**
|
|
15
|
+
* Reentrant jobs that haven't started executing yet. They are not in any key queue, so we track them here for abort().
|
|
16
|
+
*/
|
|
17
|
+
private readonly pendingReentrantJobs;
|
|
14
18
|
/**
|
|
15
19
|
* Create a better lock instance
|
|
16
20
|
*/
|
|
@@ -20,9 +24,20 @@ export declare class BetterLock {
|
|
|
20
24
|
*/
|
|
21
25
|
private getQueue;
|
|
22
26
|
/**
|
|
23
|
-
* Returns true if the caller can *immediately* acquire the lock. There is nothing holding the key and nothing in the queue
|
|
27
|
+
* Returns true if the caller can *immediately* acquire the lock. There is nothing holding the key and nothing in the queue,
|
|
28
|
+
* or the caller already holds the key and reentrancy is allowed.
|
|
24
29
|
*/
|
|
25
30
|
canAcquire(key?: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* True if the given queue's key is held by a job that is executing in the caller's async context.
|
|
33
|
+
* Ended jobs don't count, so detached work that outlives its job doesn't skip the lock.
|
|
34
|
+
*/
|
|
35
|
+
private isHeldByCaller;
|
|
36
|
+
/**
|
|
37
|
+
* Find which of the given keys the caller already holds, and the jobs holding them.
|
|
38
|
+
* Returns null if the caller holds none of them.
|
|
39
|
+
*/
|
|
40
|
+
private findHeldKeys;
|
|
26
41
|
private normalizeAndValidateKey;
|
|
27
42
|
acquire<TResult extends IValidResult>(executor: IPromiseExecutor<TResult>, jobOptions?: ILockJobOptions): Promise<TResult>;
|
|
28
43
|
acquire<TResult extends IValidResult>(executor: ICallbackExecutor<TResult>, jobOptions?: ILockJobOptions): Promise<TResult>;
|
|
@@ -30,6 +45,10 @@ export declare class BetterLock {
|
|
|
30
45
|
acquire<TResult extends IValidResult>(key: ILockKey | Array<ILockKey>, executor: IPromiseExecutor<TResult>, jobOptions?: ILockJobOptions): Promise<TResult>;
|
|
31
46
|
acquire<TResult extends IValidResult>(key: ILockKey | Array<ILockKey>, executor: ICallbackExecutor<TResult>, jobOptions?: ILockJobOptions): Promise<TResult>;
|
|
32
47
|
acquire<TResult extends IValidResult>(key: ILockKey | Array<ILockKey>, executor: ICallbackExecutor<TResult>, callback: ICallback<TResult>, jobOptions?: ILockJobOptions): void;
|
|
48
|
+
/**
|
|
49
|
+
* Add job to its key queues and start waiting for the keys
|
|
50
|
+
*/
|
|
51
|
+
private enqueueJob;
|
|
33
52
|
acquireOr<TResult extends IValidResult>(failureResult: TResult, executor: IPromiseExecutor<TResult>, jobOptions?: ILockJobOptions): Promise<TResult>;
|
|
34
53
|
acquireOr<TResult extends IValidResult>(failureResult: TResult, executor: ICallbackExecutor<TResult>, jobOptions?: ILockJobOptions): Promise<TResult>;
|
|
35
54
|
acquireOr<TResult extends IValidResult>(failureResult: TResult, executor: ICallbackExecutor<TResult>, callback: ICallback<TResult>, jobOptions?: ILockJobOptions): void;
|
|
@@ -51,6 +70,7 @@ export declare class BetterLock {
|
|
|
51
70
|
* @param [key]
|
|
52
71
|
*/
|
|
53
72
|
abort(key?: ILockKey): void;
|
|
73
|
+
private removePendingReentrantJob;
|
|
54
74
|
/**
|
|
55
75
|
* Abort all pending jobs from all queues.
|
|
56
76
|
* Currently executing jobs will not be interrupted.
|
package/dist/better_lock.js
CHANGED
|
@@ -10,6 +10,7 @@ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
|
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.BetterLock = void 0;
|
|
13
|
+
var async_context_1 = require("./async_context");
|
|
13
14
|
var errors_1 = require("./errors");
|
|
14
15
|
var internals_1 = require("./internals");
|
|
15
16
|
var tools = require("./tools");
|
|
@@ -21,6 +22,10 @@ var BetterLock = /** @class */ (function () {
|
|
|
21
22
|
* Create a better lock instance
|
|
22
23
|
*/
|
|
23
24
|
function BetterLock(options) {
|
|
25
|
+
/**
|
|
26
|
+
* Reentrant jobs that haven't started executing yet. They are not in any key queue, so we track them here for abort().
|
|
27
|
+
*/
|
|
28
|
+
this.pendingReentrantJobs = [];
|
|
24
29
|
this.options = tools.assign({}, [BetterLock.DEFAULT_OPTIONS, options]);
|
|
25
30
|
this.log = tools.makeLog(this.options.name, this.options.log);
|
|
26
31
|
this.queues = {};
|
|
@@ -39,11 +44,43 @@ var BetterLock = /** @class */ (function () {
|
|
|
39
44
|
return queue;
|
|
40
45
|
};
|
|
41
46
|
/**
|
|
42
|
-
* Returns true if the caller can *immediately* acquire the lock. There is nothing holding the key and nothing in the queue
|
|
47
|
+
* Returns true if the caller can *immediately* acquire the lock. There is nothing holding the key and nothing in the queue,
|
|
48
|
+
* or the caller already holds the key and reentrancy is allowed.
|
|
43
49
|
*/
|
|
44
50
|
BetterLock.prototype.canAcquire = function (key) {
|
|
45
51
|
var queue = this.getQueue(key);
|
|
46
|
-
|
|
52
|
+
if (!queue.active && !queue.jobs.length) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return this.options.reentrancy !== 'error' && this.isHeldByCaller(queue, (0, async_context_1.getHeldJobs)());
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* True if the given queue's key is held by a job that is executing in the caller's async context.
|
|
59
|
+
* Ended jobs don't count, so detached work that outlives its job doesn't skip the lock.
|
|
60
|
+
*/
|
|
61
|
+
BetterLock.prototype.isHeldByCaller = function (queue, heldJobs) {
|
|
62
|
+
var holder = queue.active;
|
|
63
|
+
return !!(heldJobs && holder && !holder.ended_at && heldJobs.indexOf(holder) >= 0);
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Find which of the given keys the caller already holds, and the jobs holding them.
|
|
67
|
+
* Returns null if the caller holds none of them.
|
|
68
|
+
*/
|
|
69
|
+
BetterLock.prototype.findHeldKeys = function (keys) {
|
|
70
|
+
var heldJobs = (0, async_context_1.getHeldJobs)();
|
|
71
|
+
if (!heldJobs || !keys.length) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
var holders = [];
|
|
75
|
+
var heldKeys = [];
|
|
76
|
+
for (var i = 0; i < keys.length; i++) {
|
|
77
|
+
var queue = this.getQueue(keys[i]);
|
|
78
|
+
if (this.isHeldByCaller(queue, heldJobs)) {
|
|
79
|
+
holders.push(queue.active);
|
|
80
|
+
heldKeys.push(keys[i]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return holders.length ? { holders: holders, heldKeys: heldKeys } : null;
|
|
47
84
|
};
|
|
48
85
|
BetterLock.prototype.normalizeAndValidateKey = function (key) {
|
|
49
86
|
if (key === undefined) {
|
|
@@ -65,6 +102,10 @@ var BetterLock = /** @class */ (function () {
|
|
|
65
102
|
* Acquire the lock for given key or list of keys. If waiting on a list, keys will be acquired in order,
|
|
66
103
|
* as they become free. Any deadlock prevention must be handled by the caller.
|
|
67
104
|
*
|
|
105
|
+
* If the caller already holds all the keys (it runs inside an executor holding them), the executor runs
|
|
106
|
+
* without waiting. If it holds only some of the keys, or the "reentrancy" option is set to "error", the
|
|
107
|
+
* callback (or the promise) gets BetterLockReentrancyError. Reentrancy requires Node.js AsyncLocalStorage.
|
|
108
|
+
*
|
|
68
109
|
* Once the lock is acquired, we will call the executor function, with a "done" method.
|
|
69
110
|
* You must call "done" to release the lock. Alternatively, return a Promise; lock will be released
|
|
70
111
|
* once promise resolves or rejects.
|
|
@@ -77,7 +118,6 @@ var BetterLock = /** @class */ (function () {
|
|
|
77
118
|
* @param jobOptions Options to be applied on this job only
|
|
78
119
|
*/
|
|
79
120
|
BetterLock.prototype.acquire = function (key, executor, callback, jobOptions) {
|
|
80
|
-
var _this = this;
|
|
81
121
|
if (tools.isFunction(key)) {
|
|
82
122
|
// Presume we weren't given a key (first form)
|
|
83
123
|
jobOptions = callback;
|
|
@@ -124,17 +164,49 @@ var BetterLock = /** @class */ (function () {
|
|
|
124
164
|
if (!tools.isFunction(callback)) {
|
|
125
165
|
throw new errors_1.BetterLockInvalidArgumentError(this.options.name, 'callback', 'a function', callback);
|
|
126
166
|
}
|
|
167
|
+
// Invalid arguments throw above. Reentrancy errors depend on where acquire is called from,
|
|
168
|
+
// so they are reported like other lock errors: through the callback or promise.
|
|
169
|
+
var held = this.findHeldKeys(keys);
|
|
170
|
+
if (held &&
|
|
171
|
+
// Waiting for the remaining keys while holding these could deadlock
|
|
172
|
+
(held.heldKeys.length < keys.length || effectiveJobOptions.reentrancy === 'error')) {
|
|
173
|
+
var err_1 = new errors_1.BetterLockReentrancyError(this.options.name, keys, held.heldKeys);
|
|
174
|
+
this.log(err_1.message);
|
|
175
|
+
// Never call back synchronously from acquire
|
|
176
|
+
setTimeout(function () { return callback(err_1); }, 0);
|
|
177
|
+
return callback.promise;
|
|
178
|
+
}
|
|
179
|
+
var reentrantHolders = held ? held.holders : null;
|
|
127
180
|
var job = new internals_1.LockJob(keys, executor, callback, effectiveJobOptions);
|
|
181
|
+
// Executor and callback will run in the caller's async context, not the context of whoever releases the lock
|
|
182
|
+
job.async_context = (0, async_context_1.captureAsyncContext)();
|
|
128
183
|
// Set incoming stack
|
|
129
|
-
if (effectiveJobOptions.extend_stack_traces
|
|
184
|
+
if (effectiveJobOptions.extend_stack_traces &&
|
|
185
|
+
typeof Error.captureStackTrace === 'function') {
|
|
130
186
|
var tempErr = new Error();
|
|
131
187
|
Error.captureStackTrace(tempErr, this.acquire);
|
|
132
188
|
job.incoming_stack = tempErr.stack;
|
|
133
189
|
}
|
|
134
|
-
|
|
190
|
+
if (reentrantHolders) {
|
|
191
|
+
// The caller holds all the keys. Run without entering the queues.
|
|
192
|
+
job.reentrant_holders = reentrantHolders;
|
|
193
|
+
this.pendingReentrantJobs.push(job);
|
|
194
|
+
this.log("Reentering ".concat(job));
|
|
195
|
+
this.executeJob(job);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
this.enqueueJob(job);
|
|
199
|
+
}
|
|
200
|
+
return callback.promise;
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* Add job to its key queues and start waiting for the keys
|
|
204
|
+
*/
|
|
205
|
+
BetterLock.prototype.enqueueJob = function (job) {
|
|
206
|
+
var _this = this;
|
|
135
207
|
var queuesToUpdate = [];
|
|
136
|
-
for (var i = 0; i < keys.length; i++) {
|
|
137
|
-
var queue = this.getQueue(keys[i]);
|
|
208
|
+
for (var i = 0; i < job.keys.length; i++) {
|
|
209
|
+
var queue = this.getQueue(job.keys[i]);
|
|
138
210
|
queue.jobs.push(job);
|
|
139
211
|
queuesToUpdate.push(queue);
|
|
140
212
|
}
|
|
@@ -153,7 +225,6 @@ var BetterLock = /** @class */ (function () {
|
|
|
153
225
|
// Special case. We are given empty list of keys. We will immediately execute jobs like this.
|
|
154
226
|
this.executeJob(job);
|
|
155
227
|
}
|
|
156
|
-
return callback.promise;
|
|
157
228
|
};
|
|
158
229
|
/**
|
|
159
230
|
* The same as acquire, except if the lock can't be acquired (the executor never gets called), instead of throwing
|
|
@@ -235,19 +306,32 @@ var BetterLock = /** @class */ (function () {
|
|
|
235
306
|
job.wait_timeout_id = null;
|
|
236
307
|
// We want to do the rest of this in a separate context, because we don't want user executor code
|
|
237
308
|
// to ever interfere with the code calling acquire().
|
|
238
|
-
|
|
309
|
+
setTimeout(function () {
|
|
239
310
|
// Make sure job hasn't been ended in the meantime
|
|
240
311
|
if (job.executed_at || job.ended_at) {
|
|
241
312
|
return;
|
|
242
313
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
314
|
+
if (job.reentrant_holders) {
|
|
315
|
+
_this.removePendingReentrantJob(job);
|
|
316
|
+
if (job.reentrant_holders.some(function (holder) { return holder.ended_at; })) {
|
|
317
|
+
// The holder released the keys before this job could start (eg. the reentrant acquire wasn't awaited).
|
|
318
|
+
// Wait for the keys like any other job.
|
|
319
|
+
_this.log("".concat(job, " is no longer reentrant because its holder has ended"));
|
|
320
|
+
job.reentrant_holders = null;
|
|
321
|
+
_this.enqueueJob(job);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
// Validate this job is holding all queues it needs
|
|
327
|
+
for (var i = 0; i < job.keys.length; i++) {
|
|
328
|
+
var queue = _this.getQueue(job.keys[i]);
|
|
329
|
+
if (queue.active !== job) {
|
|
330
|
+
// This should never happen
|
|
331
|
+
return _this.endJob(job, [
|
|
332
|
+
new errors_1.BetterLockInternalError(_this.options.name, "Corrupted wait queue state for ".concat(queue.key)),
|
|
333
|
+
]);
|
|
334
|
+
}
|
|
251
335
|
}
|
|
252
336
|
}
|
|
253
337
|
// Mark job as having started executing
|
|
@@ -263,29 +347,43 @@ var BetterLock = /** @class */ (function () {
|
|
|
263
347
|
_this.log("Done called for ".concat(job));
|
|
264
348
|
_this.endJob(job, args);
|
|
265
349
|
};
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
350
|
+
var runExecutor = function () {
|
|
351
|
+
if (job.executor.length > 0) {
|
|
352
|
+
// Callback interface
|
|
353
|
+
job.executor(lockDone);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// Promise interface
|
|
357
|
+
var executorResult;
|
|
358
|
+
try {
|
|
359
|
+
executorResult = job.executor();
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
// Error thrown directly from executor
|
|
363
|
+
lockDone(err);
|
|
364
|
+
}
|
|
365
|
+
// Promise sniffing
|
|
366
|
+
if (_this.options.promise_tester(executorResult)) {
|
|
367
|
+
executorResult.then(function (res) { return lockDone(null, res); }, function (err) { return lockDone(err); });
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
// "promise" is just some random value. We don't have to wait
|
|
371
|
+
lockDone(null, executorResult);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
if (!job.async_context) {
|
|
375
|
+
// No async context support in this runtime
|
|
376
|
+
runExecutor();
|
|
279
377
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
378
|
+
else if (job.reentrant_holders) {
|
|
379
|
+
// The caller's context already records the holders
|
|
380
|
+
job.async_context.runInAsyncScope(runExecutor);
|
|
283
381
|
}
|
|
284
382
|
else {
|
|
285
|
-
//
|
|
286
|
-
|
|
383
|
+
// Restore the caller's context first, then record this job as holding its keys inside it
|
|
384
|
+
job.async_context.runInAsyncScope(function () { return (0, async_context_1.runHoldingJob)(job, runExecutor); });
|
|
287
385
|
}
|
|
288
|
-
});
|
|
386
|
+
}, 0);
|
|
289
387
|
};
|
|
290
388
|
BetterLock.prototype.onWaitTimeout = function (job) {
|
|
291
389
|
this.log("".concat(job, " has timed out after waiting in queue for ").concat(new Date().valueOf() - job.enqueued_at.valueOf(), "ms"));
|
|
@@ -296,6 +394,7 @@ var BetterLock = /** @class */ (function () {
|
|
|
296
394
|
this.endJob(job, [new errors_1.BetterLockExecutionTimeoutError(this.options.name, job)]);
|
|
297
395
|
};
|
|
298
396
|
BetterLock.prototype.endJob = function (job, callbackArgs) {
|
|
397
|
+
var _a;
|
|
299
398
|
var _this = this;
|
|
300
399
|
if (job.ended_at) {
|
|
301
400
|
this.log("WARNING: ".concat(job, " is trying to end, but it has already ended at ").concat(job.ended_at.toISOString(), ". Called with arguments: ").concat(callbackArgs));
|
|
@@ -306,9 +405,14 @@ var BetterLock = /** @class */ (function () {
|
|
|
306
405
|
clearTimeout(job.execution_timeout_id);
|
|
307
406
|
job.execution_timeout_id = null;
|
|
308
407
|
job.ended_at = new Date();
|
|
408
|
+
// Reentrant jobs never enter key queues, so they have nothing to release
|
|
409
|
+
var keysToRelease = job.reentrant_holders ? [] : job.keys;
|
|
410
|
+
if (job.reentrant_holders) {
|
|
411
|
+
this.removePendingReentrantJob(job);
|
|
412
|
+
}
|
|
309
413
|
var queuesToUpdate = [];
|
|
310
|
-
for (var i = 0; i <
|
|
311
|
-
var queue = this.getQueue(
|
|
414
|
+
for (var i = 0; i < keysToRelease.length; i++) {
|
|
415
|
+
var queue = this.getQueue(keysToRelease[i]);
|
|
312
416
|
queuesToUpdate.push(queue);
|
|
313
417
|
if (queue.active === job) {
|
|
314
418
|
// This job was holding on the queue's key
|
|
@@ -329,11 +433,16 @@ var BetterLock = /** @class */ (function () {
|
|
|
329
433
|
}
|
|
330
434
|
// Call the callback
|
|
331
435
|
try {
|
|
332
|
-
job.
|
|
436
|
+
if (job.async_context) {
|
|
437
|
+
(_a = job.async_context).runInAsyncScope.apply(_a, __spreadArray([job.callback, null], callbackArgs, false));
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
job.callback.apply(null, callbackArgs);
|
|
441
|
+
}
|
|
333
442
|
}
|
|
334
443
|
finally {
|
|
335
444
|
// Whatever happens, schedule a queue update
|
|
336
|
-
|
|
445
|
+
setTimeout(function () { return _this.update(queuesToUpdate); }, 0);
|
|
337
446
|
}
|
|
338
447
|
};
|
|
339
448
|
/**
|
|
@@ -352,6 +461,18 @@ var BetterLock = /** @class */ (function () {
|
|
|
352
461
|
// If we have a job holding this queue that hasn't been executed yet, abort it as well
|
|
353
462
|
this.endJob(queue.active, [new errors_1.BetterLockJobAbortedError(this.options.name, queue.active)]);
|
|
354
463
|
}
|
|
464
|
+
// Reentrant jobs for this key that haven't started yet
|
|
465
|
+
this.pendingReentrantJobs.slice().forEach(function (job) {
|
|
466
|
+
if (job.keys.some(function (jobKey) { return _this.getQueue(jobKey) === queue; })) {
|
|
467
|
+
_this.endJob(job, [new errors_1.BetterLockJobAbortedError(_this.options.name, job)]);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
};
|
|
471
|
+
BetterLock.prototype.removePendingReentrantJob = function (job) {
|
|
472
|
+
var index = this.pendingReentrantJobs.indexOf(job);
|
|
473
|
+
if (index >= 0) {
|
|
474
|
+
this.pendingReentrantJobs.splice(index, 1);
|
|
475
|
+
}
|
|
355
476
|
};
|
|
356
477
|
/**
|
|
357
478
|
* Abort all pending jobs from all queues.
|
|
@@ -370,6 +491,7 @@ var BetterLock = /** @class */ (function () {
|
|
|
370
491
|
BetterLock.DEFAULT_OPTIONS = {
|
|
371
492
|
extend_stack_traces: true,
|
|
372
493
|
promise_tester: function (p) { return p && typeof p.then === 'function'; },
|
|
494
|
+
reentrancy: 'allow',
|
|
373
495
|
};
|
|
374
496
|
return BetterLock;
|
|
375
497
|
}());
|