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 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
- - **You need lock reentrancy.**
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
- - **You need a shared lock between different nodes**
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
- ### Change log
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"}
@@ -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.
@@ -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
- return !queue.active && !queue.jobs.length;
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
- // Add job to its key queues
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
- setImmediate(function () {
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
- // Validate this job is holding all queues it needs
244
- for (var i = 0; i < job.keys.length; i++) {
245
- var queue = _this.getQueue(job.keys[i]);
246
- if (queue.active !== job) {
247
- // This should never happen
248
- return _this.endJob(job, [
249
- new errors_1.BetterLockInternalError(_this.options.name, "Corrupted wait queue state for ".concat(queue.key)),
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
- if (job.executor.length > 0) {
267
- // Callback interface
268
- job.executor(lockDone);
269
- return;
270
- }
271
- // Promise interface
272
- var executorResult;
273
- try {
274
- executorResult = job.executor();
275
- }
276
- catch (err) {
277
- // Error thrown directly from executor
278
- lockDone(err);
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
- // Promise sniffing
281
- if (_this.options.promise_tester(executorResult)) {
282
- executorResult.then(function (res) { return lockDone(null, res); }, function (err) { return lockDone(err); });
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
- // "promise" is just some random value. We don't have to wait
286
- lockDone(null, executorResult);
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 < job.keys.length; i++) {
311
- var queue = this.getQueue(job.keys[i]);
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.callback.apply(null, callbackArgs);
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
- setImmediate(function () { return _this.update(queuesToUpdate); });
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
  }());