better-lock 3.3.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.
@@ -1,9 +1,11 @@
1
+ import { captureAsyncContext, getHeldJobs, runHoldingJob } from './async_context';
1
2
  import {
2
3
  BetterLockExecutionTimeoutError,
3
4
  BetterLockInternalError,
4
5
  BetterLockInvalidArgumentError,
5
6
  BetterLockJobAbortedError,
6
7
  BetterLockQueueOverflowError,
8
+ BetterLockReentrancyError,
7
9
  BetterLockWaitTimeoutError,
8
10
  isAcquisitionFailureError,
9
11
  } from './errors';
@@ -22,12 +24,18 @@ export class BetterLock {
22
24
  static DEFAULT_OPTIONS: IOptions = {
23
25
  extend_stack_traces: true,
24
26
  promise_tester: p => p && typeof p.then === 'function',
27
+ reentrancy: 'allow',
25
28
  };
26
29
 
27
30
  private readonly options: IOptions;
28
31
  private readonly log: (msg: string) => void;
29
32
  private readonly queues: { [key: string]: KeyQueue };
30
33
 
34
+ /**
35
+ * Reentrant jobs that haven't started executing yet. They are not in any key queue, so we track them here for abort().
36
+ */
37
+ private readonly pendingReentrantJobs: LockJob<any>[] = [];
38
+
31
39
  /**
32
40
  * Create a better lock instance
33
41
  */
@@ -52,11 +60,49 @@ export class BetterLock {
52
60
  }
53
61
 
54
62
  /**
55
- * Returns true if the caller can *immediately* acquire the lock. There is nothing holding the key and nothing in the queue.
63
+ * Returns true if the caller can *immediately* acquire the lock. There is nothing holding the key and nothing in the queue,
64
+ * or the caller already holds the key and reentrancy is allowed.
56
65
  */
57
66
  public canAcquire(key?: string): boolean {
58
67
  const queue = this.getQueue(key);
59
- return !queue.active && !queue.jobs.length;
68
+ if (!queue.active && !queue.jobs.length) {
69
+ return true;
70
+ }
71
+ return this.options.reentrancy !== 'error' && this.isHeldByCaller(queue, getHeldJobs());
72
+ }
73
+
74
+ /**
75
+ * True if the given queue's key is held by a job that is executing in the caller's async context.
76
+ * Ended jobs don't count, so detached work that outlives its job doesn't skip the lock.
77
+ */
78
+ private isHeldByCaller(queue: KeyQueue, heldJobs: ReadonlyArray<LockJob<any>> | undefined) {
79
+ const holder = queue.active;
80
+ return !!(heldJobs && holder && !holder.ended_at && heldJobs.indexOf(holder) >= 0);
81
+ }
82
+
83
+ /**
84
+ * Find which of the given keys the caller already holds, and the jobs holding them.
85
+ * Returns null if the caller holds none of them.
86
+ */
87
+ private findHeldKeys(
88
+ keys: Array<string | null>
89
+ ): { holders: LockJob<any>[]; heldKeys: Array<string | null> } | null {
90
+ const heldJobs = getHeldJobs();
91
+ if (!heldJobs || !keys.length) {
92
+ return null;
93
+ }
94
+
95
+ const holders: LockJob<any>[] = [];
96
+ const heldKeys: Array<string | null> = [];
97
+ for (let i = 0; i < keys.length; i++) {
98
+ const queue = this.getQueue(keys[i]);
99
+ if (this.isHeldByCaller(queue, heldJobs)) {
100
+ holders.push(queue.active);
101
+ heldKeys.push(keys[i]);
102
+ }
103
+ }
104
+
105
+ return holders.length ? { holders, heldKeys } : null;
60
106
  }
61
107
 
62
108
  private normalizeAndValidateKey(key: ILockKey) {
@@ -114,6 +160,10 @@ export class BetterLock {
114
160
  * Acquire the lock for given key or list of keys. If waiting on a list, keys will be acquired in order,
115
161
  * as they become free. Any deadlock prevention must be handled by the caller.
116
162
  *
163
+ * If the caller already holds all the keys (it runs inside an executor holding them), the executor runs
164
+ * without waiting. If it holds only some of the keys, or the "reentrancy" option is set to "error", the
165
+ * callback (or the promise) gets BetterLockReentrancyError. Reentrancy requires Node.js AsyncLocalStorage.
166
+ *
117
167
  * Once the lock is acquired, we will call the executor function, with a "done" method.
118
168
  * You must call "done" to release the lock. Alternatively, return a Promise; lock will be released
119
169
  * once promise resolves or rejects.
@@ -187,8 +237,27 @@ export class BetterLock {
187
237
  );
188
238
  }
189
239
 
240
+ // Invalid arguments throw above. Reentrancy errors depend on where acquire is called from,
241
+ // so they are reported like other lock errors: through the callback or promise.
242
+ const held = this.findHeldKeys(keys);
243
+ if (
244
+ held &&
245
+ // Waiting for the remaining keys while holding these could deadlock
246
+ (held.heldKeys.length < keys.length || effectiveJobOptions.reentrancy === 'error')
247
+ ) {
248
+ const err = new BetterLockReentrancyError(this.options.name, keys, held.heldKeys);
249
+ this.log(err.message);
250
+ // Never call back synchronously from acquire
251
+ setTimeout(() => callback(err), 0);
252
+ return (callback as any).promise;
253
+ }
254
+ const reentrantHolders = held ? held.holders : null;
255
+
190
256
  const job = new LockJob(keys, executor, callback, effectiveJobOptions);
191
257
 
258
+ // Executor and callback will run in the caller's async context, not the context of whoever releases the lock
259
+ job.async_context = captureAsyncContext();
260
+
192
261
  // Set incoming stack
193
262
  if (
194
263
  effectiveJobOptions.extend_stack_traces &&
@@ -199,10 +268,26 @@ export class BetterLock {
199
268
  job.incoming_stack = tempErr.stack;
200
269
  }
201
270
 
202
- // Add job to its key queues
271
+ if (reentrantHolders) {
272
+ // The caller holds all the keys. Run without entering the queues.
273
+ job.reentrant_holders = reentrantHolders;
274
+ this.pendingReentrantJobs.push(job);
275
+ this.log(`Reentering ${job}`);
276
+ this.executeJob(job);
277
+ } else {
278
+ this.enqueueJob(job);
279
+ }
280
+
281
+ return (callback as any).promise;
282
+ }
283
+
284
+ /**
285
+ * Add job to its key queues and start waiting for the keys
286
+ */
287
+ private enqueueJob(job: LockJob<any>) {
203
288
  const queuesToUpdate = [];
204
- for (let i = 0; i < keys.length; i++) {
205
- const queue = this.getQueue(keys[i]);
289
+ for (let i = 0; i < job.keys.length; i++) {
290
+ const queue = this.getQueue(job.keys[i]);
206
291
  queue.jobs.push(job);
207
292
  queuesToUpdate.push(queue);
208
293
  }
@@ -224,8 +309,6 @@ export class BetterLock {
224
309
  // Special case. We are given empty list of keys. We will immediately execute jobs like this.
225
310
  this.executeJob(job);
226
311
  }
227
-
228
- return (callback as any).promise;
229
312
  }
230
313
 
231
314
  public acquireOr<TResult extends IValidResult>(
@@ -365,17 +448,30 @@ export class BetterLock {
365
448
  return;
366
449
  }
367
450
 
368
- // Validate this job is holding all queues it needs
369
- for (let i = 0; i < job.keys.length; i++) {
370
- const queue = this.getQueue(job.keys[i]);
371
- if (queue.active !== job) {
372
- // This should never happen
373
- return this.endJob(job, [
374
- new BetterLockInternalError(
375
- this.options.name,
376
- `Corrupted wait queue state for ${queue.key}`
377
- ),
378
- ]);
451
+ if (job.reentrant_holders) {
452
+ this.removePendingReentrantJob(job);
453
+
454
+ if (job.reentrant_holders.some(holder => holder.ended_at)) {
455
+ // The holder released the keys before this job could start (eg. the reentrant acquire wasn't awaited).
456
+ // Wait for the keys like any other job.
457
+ this.log(`${job} is no longer reentrant because its holder has ended`);
458
+ job.reentrant_holders = null;
459
+ this.enqueueJob(job);
460
+ return;
461
+ }
462
+ } else {
463
+ // Validate this job is holding all queues it needs
464
+ for (let i = 0; i < job.keys.length; i++) {
465
+ const queue = this.getQueue(job.keys[i]);
466
+ if (queue.active !== job) {
467
+ // This should never happen
468
+ return this.endJob(job, [
469
+ new BetterLockInternalError(
470
+ this.options.name,
471
+ `Corrupted wait queue state for ${queue.key}`
472
+ ),
473
+ ]);
474
+ }
379
475
  }
380
476
  }
381
477
 
@@ -395,30 +491,43 @@ export class BetterLock {
395
491
  this.endJob(job, args);
396
492
  };
397
493
 
398
- if (job.executor.length > 0) {
399
- // Callback interface
400
- job.executor(lockDone);
401
- return;
402
- }
494
+ const runExecutor = () => {
495
+ if (job.executor.length > 0) {
496
+ // Callback interface
497
+ job.executor(lockDone);
498
+ return;
499
+ }
403
500
 
404
- // Promise interface
405
- let executorResult;
406
- try {
407
- executorResult = (job.executor as any)();
408
- } catch (err) {
409
- // Error thrown directly from executor
410
- lockDone(err);
411
- }
501
+ // Promise interface
502
+ let executorResult;
503
+ try {
504
+ executorResult = (job.executor as any)();
505
+ } catch (err) {
506
+ // Error thrown directly from executor
507
+ lockDone(err);
508
+ }
412
509
 
413
- // Promise sniffing
414
- if (this.options.promise_tester(executorResult)) {
415
- executorResult.then(
416
- res => lockDone(null, res),
417
- err => lockDone(err)
418
- );
510
+ // Promise sniffing
511
+ if (this.options.promise_tester(executorResult)) {
512
+ executorResult.then(
513
+ res => lockDone(null, res),
514
+ err => lockDone(err)
515
+ );
516
+ } else {
517
+ // "promise" is just some random value. We don't have to wait
518
+ lockDone(null, executorResult);
519
+ }
520
+ };
521
+
522
+ if (!job.async_context) {
523
+ // No async context support in this runtime
524
+ runExecutor();
525
+ } else if (job.reentrant_holders) {
526
+ // The caller's context already records the holders
527
+ job.async_context.runInAsyncScope(runExecutor);
419
528
  } else {
420
- // "promise" is just some random value. We don't have to wait
421
- lockDone(null, executorResult);
529
+ // Restore the caller's context first, then record this job as holding its keys inside it
530
+ job.async_context.runInAsyncScope(() => runHoldingJob(job, runExecutor));
422
531
  }
423
532
  }, 0);
424
533
  }
@@ -459,9 +568,15 @@ export class BetterLock {
459
568
 
460
569
  job.ended_at = new Date();
461
570
 
571
+ // Reentrant jobs never enter key queues, so they have nothing to release
572
+ const keysToRelease = job.reentrant_holders ? [] : job.keys;
573
+ if (job.reentrant_holders) {
574
+ this.removePendingReentrantJob(job);
575
+ }
576
+
462
577
  const queuesToUpdate = [];
463
- for (let i = 0; i < job.keys.length; i++) {
464
- const queue = this.getQueue(job.keys[i]);
578
+ for (let i = 0; i < keysToRelease.length; i++) {
579
+ const queue = this.getQueue(keysToRelease[i]);
465
580
  queuesToUpdate.push(queue);
466
581
 
467
582
  if (queue.active === job) {
@@ -484,7 +599,11 @@ export class BetterLock {
484
599
 
485
600
  // Call the callback
486
601
  try {
487
- job.callback.apply(null, callbackArgs);
602
+ if (job.async_context) {
603
+ job.async_context.runInAsyncScope(job.callback as any, null, ...callbackArgs);
604
+ } else {
605
+ job.callback.apply(null, callbackArgs);
606
+ }
488
607
  } finally {
489
608
  // Whatever happens, schedule a queue update
490
609
  setTimeout(() => this.update(queuesToUpdate), 0);
@@ -506,6 +625,19 @@ export class BetterLock {
506
625
  // If we have a job holding this queue that hasn't been executed yet, abort it as well
507
626
  this.endJob(queue.active, [new BetterLockJobAbortedError(this.options.name, queue.active)]);
508
627
  }
628
+ // Reentrant jobs for this key that haven't started yet
629
+ this.pendingReentrantJobs.slice().forEach(job => {
630
+ if (job.keys.some(jobKey => this.getQueue(jobKey) === queue)) {
631
+ this.endJob(job, [new BetterLockJobAbortedError(this.options.name, job)]);
632
+ }
633
+ });
634
+ }
635
+
636
+ private removePendingReentrantJob(job: LockJob<any>) {
637
+ const index = this.pendingReentrantJobs.indexOf(job);
638
+ if (index >= 0) {
639
+ this.pendingReentrantJobs.splice(index, 1);
640
+ }
509
641
  }
510
642
 
511
643
  /**
package/src/errors.ts CHANGED
@@ -131,6 +131,34 @@ export class BetterLockJobAbortedError extends BetterLockError {
131
131
  }
132
132
  }
133
133
 
134
+ export class BetterLockReentrancyError extends BetterLockError {
135
+ /**
136
+ * Keys requested by the rejected acquire call
137
+ */
138
+ public keys: string[];
139
+
140
+ /**
141
+ * Requested keys that the caller already holds
142
+ */
143
+ public held_keys: string[];
144
+
145
+ constructor(lockName, keys: string[], heldKeys: string[]) {
146
+ const describe = (list: string[]) =>
147
+ list.map(key => (key === null ? '<default>' : `"${key}"`)).join(', ');
148
+ const message =
149
+ heldKeys.length < keys.length
150
+ ? `Caller holds only some of the requested keys (${describe(
151
+ heldKeys
152
+ )} of ${describe(keys)}). Waiting for the rest while holding these could deadlock`
153
+ : `Reentrant acquire of ${describe(keys)} is not allowed (reentrancy: "error")`;
154
+
155
+ super(lockName, message);
156
+
157
+ this.keys = keys;
158
+ this.held_keys = heldKeys;
159
+ }
160
+ }
161
+
134
162
  export function isAcquisitionFailureError(err) {
135
163
  return (
136
164
  (err && err instanceof BetterLockQueueOverflowError) ||
package/src/index.ts CHANGED
@@ -6,9 +6,10 @@ import {
6
6
  BetterLockInvalidArgumentError,
7
7
  BetterLockJobAbortedError,
8
8
  BetterLockQueueOverflowError,
9
+ BetterLockReentrancyError,
9
10
  BetterLockWaitTimeoutError,
10
11
  } from './errors';
11
- import { ILockJobOptions as BetterLockJobOptions, IOptions as BetterLockOptions, IQueueEjectionStrategy as BetterLockQueueEjectionStrategy } from './options';
12
+ import { ILockJobOptions as BetterLockJobOptions, IOptions as BetterLockOptions, IQueueEjectionStrategy as BetterLockQueueEjectionStrategy, IReentrancyMode as BetterLockReentrancyMode } from './options';
12
13
  import {
13
14
  IErrorName as BetterLockErrorName,
14
15
  IExecutor as BetterLockExecutor,
@@ -18,6 +19,7 @@ import {
18
19
  export {
19
20
  BetterLockOptions,
20
21
  BetterLockQueueEjectionStrategy,
22
+ BetterLockReentrancyMode,
21
23
  BetterLockJobOptions,
22
24
  BetterLockExecutor,
23
25
  BetterLockKey,
@@ -29,6 +31,7 @@ export {
29
31
  BetterLockInvalidArgumentError,
30
32
  BetterLockJobAbortedError,
31
33
  BetterLockQueueOverflowError,
34
+ BetterLockReentrancyError,
32
35
  BetterLockWaitTimeoutError,
33
36
  };
34
37
 
@@ -50,6 +53,7 @@ export const BetterLock = attachExports(BetterLockUnadorned, {
50
53
  ExecutionTimeoutError: BetterLockExecutionTimeoutError,
51
54
  QueueOverflowError: BetterLockQueueOverflowError,
52
55
  JobAbortedError: BetterLockJobAbortedError,
56
+ ReentrancyError: BetterLockReentrancyError,
53
57
  });
54
58
 
55
59
  export default BetterLock;
package/src/internals.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { IAsyncResource } from './async_context';
1
2
  import { ILockJobOptions } from './options';
2
3
  import { ICallback, IExecutor } from './types';
3
4
 
@@ -66,6 +67,17 @@ export class LockJob<TResult> {
66
67
  */
67
68
  public incoming_stack: string = null;
68
69
 
70
+ /**
71
+ * Async context of the acquire() call. Executor and callback run in it. Undefined if the runtime doesn't support it.
72
+ */
73
+ public async_context: IAsyncResource | undefined = undefined;
74
+
75
+ /**
76
+ * Set for reentrant jobs: the executing jobs (from the caller's async context) that hold this job's keys.
77
+ * A reentrant job doesn't enter key queues. It runs while these jobs hold the keys.
78
+ */
79
+ public reentrant_holders: LockJob<any>[] | null = null;
80
+
69
81
  constructor(
70
82
  keys: Array<string | null>,
71
83
  executor: IExecutor<TResult>,
package/src/options.ts CHANGED
@@ -25,10 +25,21 @@ export interface ILockJobOptions {
25
25
  * Overrides BetterLockOptions value.
26
26
  */
27
27
  lock_condition?: (key: ILockKey | Array<ILockKey>) => boolean;
28
+
29
+ /**
30
+ * What to do when acquire is called from code that already holds all the requested keys. Overrides BetterLockOptions value.
31
+ */
32
+ reentrancy?: IReentrancyMode;
28
33
  }
29
34
 
30
35
  export type IQueueEjectionStrategy = 'oldest' | 'newest';
31
36
 
37
+ /**
38
+ * - 'allow': run the executor without waiting for the keys the caller already holds.
39
+ * - 'error': fail with BetterLockReentrancyError.
40
+ */
41
+ export type IReentrancyMode = 'allow' | 'error';
42
+
32
43
  /**
33
44
  * Options that apply on BetterLock instance
34
45
  */
@@ -60,6 +71,19 @@ export interface IOptions extends ILockJobOptions {
60
71
  */
61
72
  lock_condition?: (key: ILockKey | Array<ILockKey>) => boolean;
62
73
 
74
+ /**
75
+ * What to do when acquire is called from code that already holds all the requested keys (inside the executor, or
76
+ * in async work the executor started and that runs before it finishes). 'allow' runs the executor without
77
+ * waiting, 'error' fails with BetterLockReentrancyError. Defaults to 'allow'.
78
+ *
79
+ * Holding only some of the requested keys always fails with BetterLockReentrancyError, because waiting for the rest
80
+ * while holding these could deadlock.
81
+ *
82
+ * Requires Node.js AsyncLocalStorage, looked up through process.getBuiltinModule (Node.js 20.16+ or 22.3+).
83
+ * In other runtimes, this option has no effect and nested acquire calls wait in the queue like any other.
84
+ */
85
+ reentrancy?: IReentrancyMode;
86
+
63
87
  /**
64
88
  * Lock name. This will be written in all logs and error messages, to help you distinguish between different locks
65
89
  */