superlock 1.4.1 → 1.4.3

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,9 +9,9 @@
9
9
  **superlock** aims to be:
10
10
 
11
11
  - **Simple**: Designed for usage with `async` and `await`
12
- - **Powerful**: Mutex & Semaphore patterns supported
12
+ - **Powerful**: Mutex, semaphore, and fair queuing
13
13
  - **Secure**: Auto lock release toa void dead locks
14
- - **Lightweight**: No dependencies, just ~50 LOC
14
+ - **Lightweight**: No dependencies
15
15
  - **Well-tested**: 100% code coverage
16
16
 
17
17
  ## Install
@@ -46,7 +46,43 @@ console.log(executions)
46
46
 
47
47
  ### as semaphore
48
48
 
49
- Just call `withLock(n)` being `n` the maximum of concurrency desired for the lock.
49
+ Just call `withLock(n)` being `n` the maximum of concurrency desired for the lock:
50
+
51
+ ```js
52
+ const { setTimeout } = require('timers/promises')
53
+ const { withLock } = require('superlock')
54
+
55
+ const lock = withLock(2)
56
+
57
+ const executions = await Promise.all(
58
+ [...Array(10).keys()].map(index =>
59
+ lock(async () => {
60
+ await setTimeout(Math.random() * 100)
61
+ return index
62
+ })
63
+ )
64
+ )
65
+
66
+ console.log(executions)
67
+ ```
68
+
69
+ ### as fair queue
70
+
71
+ `withLock` and `createLock` are one FIFO queue. A caller that enqueues a lot of work keeps taking the next slot.
72
+
73
+ `withFairQueue` is [fair queuing](https://en.wikipedia.org/wiki/Fair_queuing): one queue per key, and a free slot rotates across those keys. The same key is still served in arrival order. Callers that omit the key share one queue.
74
+
75
+ ```js
76
+ const { withFairQueue } = require('superlock')
77
+
78
+ const queue = withFairQueue()
79
+
80
+ await queue(async () => {
81
+ /* your code execution */
82
+ }, userId)
83
+ ```
84
+
85
+ A second queued call for the same user waits until each other waiting user has had a turn. Pass an `AbortSignal` as the third argument when the caller can give up. Aborting while the call is queued skips the function.
50
86
 
51
87
  ### with cancellation
52
88
 
@@ -109,11 +145,122 @@ Type: `AbortSignal`
109
145
 
110
146
  When provided, aborting it drops the acquisition while it is still waiting in the queue: no slot is taken and the wrapped function never runs.
111
147
 
148
+ ### createLock([slots=1])
149
+
150
+ The FIFO semaphore behind `withLock`. The promise resolves to a release function you call when the work is done:
151
+
152
+ ```js
153
+ const { createLock } = require('superlock')
154
+
155
+ const lock = createLock()
156
+ const release = await lock()
157
+
158
+ try {
159
+ /* your code execution */
160
+ } finally {
161
+ release()
162
+ }
163
+ ```
164
+
165
+ An optional `AbortSignal` is the first argument. If it aborts while the call is queued, the promise resolves `null` and no slot is taken.
166
+
167
+ #### slots
168
+
169
+ Type: `number`<br>
170
+ Default: `1`
171
+
172
+ How many callers can hold the lock at once.
173
+
174
+ ### createFairQueue([slots=1])
175
+
176
+ The fair queue behind `withFairQueue`, in the same way `createLock` is behind `withLock`. The promise resolves to a release function you call when the work is done. `createFairLock` is this function.
177
+
178
+ ```js
179
+ const { createFairQueue } = require('superlock')
180
+
181
+ const queue = createFairQueue()
182
+ const release = await queue(undefined, key)
183
+
184
+ release()
185
+ ```
186
+
187
+ One key is served in arrival order. Across keys, a free slot goes to the next key that is waiting, then wraps around. A key with nobody left waiting leaves the rotation.
188
+
189
+ A missing key shares one queue. Any other value, including `null`, is its own queue. `null` and a missing signal do not cancel. A value that is not an `AbortSignal` throws `TypeError`. Aborting after the slot was granted does nothing.
190
+
191
+ #### slots
192
+
193
+ Type: `number`<br>
194
+ Default: `1`
195
+
196
+ How many callers can hold the queue at once.
197
+
198
+ #### signal
199
+
200
+ Type: `AbortSignal`
201
+
202
+ Optional first argument. Aborting it while the call is queued resolves `null` and does not take a slot.
203
+
204
+ #### key
205
+
206
+ Type: any
207
+
208
+ Optional second argument. Identifies the queue.
209
+
210
+ ### withFairQueue([slots=1])
211
+
212
+ It returns a function that runs your code under [fair queuing](https://en.wikipedia.org/wiki/Fair_queuing). The queue is released when the function finishes, including when it throws.
213
+
214
+ ```js
215
+ const { withFairQueue } = require('superlock')
216
+
217
+ const queue = withFairQueue()
218
+
219
+ await queue(() => {
220
+ /* your code execution */
221
+ }, key)
222
+ ```
223
+
224
+ An `AbortSignal` is an optional third argument:
225
+
226
+ ```js
227
+ await queue(() => {
228
+ /* your code execution */
229
+ }, key, signal)
230
+ ```
231
+
232
+ If `signal` aborts while the call is still queued, the function is skipped and the call resolves `undefined`.
233
+
234
+ #### slots
235
+
236
+ Type: `number`<br>
237
+ Default: `1`
238
+
239
+ How many callers can hold the queue at once.
240
+
241
+ #### key
242
+
243
+ Type: any
244
+
245
+ Optional second argument. Identifies the queue. A missing key shares one queue.
246
+
247
+ #### signal
248
+
249
+ Type: `AbortSignal`
250
+
251
+ Optional third argument. Aborting it while the call is queued skips the function.
252
+
112
253
  ### .isLocked()
113
254
 
114
255
  Type: `boolean`
115
256
 
116
- It returns `false` if there is at least one free concurrency slots in the lock.
257
+ It returns `false` if there is at least one free concurrency slots in the lock. `createLock`, `createFairQueue`, and `withFairQueue` expose it too.
258
+
259
+ ### .awaiting()
260
+
261
+ Type: `number`
262
+
263
+ How many callers are waiting for a slot. On a fair queue that is the total across every key.
117
264
 
118
265
  ## License
119
266
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "superlock",
3
3
  "description": "Minimal async mutex and semaphore for serializing critical sections in Node.js.",
4
4
  "homepage": "https://github.com/Kikobeats/superlock",
5
- "version": "1.4.1",
5
+ "version": "1.4.3",
6
6
  "types": "src/index.d.ts",
7
7
  "main": "src/index.js",
8
8
  "exports": {
package/src/fair.js CHANGED
@@ -5,38 +5,77 @@ const LinkedList = require('./linked-list')
5
5
  const SHARED = Symbol('shared')
6
6
  const NONE = Symbol('none')
7
7
 
8
+ // Map keys use SameValueZero, so NaN is one key and === cannot find it.
9
+ const sameKey = (a, b) => a === b || (Number.isNaN(a) && Number.isNaN(b))
10
+
8
11
  module.exports = (slots = 1) => {
9
12
  const queues = new Map()
10
13
  const ring = new LinkedList()
11
14
  let waiting = 0
12
15
  let last = NONE
16
+ let seq = 0
17
+ let barrier = 0
13
18
 
14
19
  const drop = entry => {
20
+ const predecessor = entry.ringNode.prev
15
21
  ring.remove(entry.ringNode)
16
22
  queues.delete(entry.key)
23
+ return predecessor ? predecessor.data : NONE
17
24
  }
18
25
 
19
26
  const enqueue = (key, waiter) => {
20
27
  let entry = queues.get(key)
21
28
  if (!entry) {
22
- entry = { key, queue: new LinkedList(), ringNode: ring.enqueue(key) }
29
+ // The holder is the cursor and is not on the ring yet. Put that key
30
+ // at the head so the next grant starts on the keys already waiting
31
+ // and reaches the holder last. At the tail, the cursor would step
32
+ // into keys that arrive later and skip the ones already in front.
33
+ const ringNode = sameKey(key, last)
34
+ ? ring.prepend(key)
35
+ : ring.enqueue(key)
36
+ entry = { key, queue: new LinkedList(), ringNode }
23
37
  queues.set(key, entry)
24
38
  }
25
39
  waiting++
26
40
  waiter.entry = entry
41
+ waiter.joinSeq = ++seq
27
42
  waiter.node = entry.queue.enqueue(waiter)
28
43
  }
29
44
 
30
- const grant = () => {
31
- if (ring.size() === 0) return
45
+ // The key's oldest waiter decides. A later waiter on a key that joined
46
+ // this round has a newer sequence and must not inherit the round.
47
+ const eligible = entry => entry.queue.head.data.joinSeq <= barrier
48
+
49
+ // Start just after the cursor and wrap once. Stop is required: the
50
+ // ring is not circular, so the walk jumps from the tail back to the head.
51
+ const selectNode = () => {
32
52
  const current = queues.get(last)
33
- const node =
53
+ const start =
34
54
  current && current.ringNode.next ? current.ringNode.next : ring.head
55
+ let node = start
56
+ do {
57
+ if (eligible(queues.get(node.data))) return node
58
+ node = node.next || ring.head
59
+ } while (node !== start)
60
+ }
61
+
62
+ const grant = () => {
63
+ if (ring.size() === 0) return
64
+ // Waiters past the barrier stay out until every earlier waiter has
65
+ // been served. When none of those remain, move the barrier up.
66
+ let node = selectNode()
67
+ if (!node) {
68
+ barrier = seq
69
+ node = selectNode()
70
+ }
35
71
  const entry = queues.get(node.data)
36
72
  const waiter = entry.queue.dequeue()
37
73
  waiting--
38
- if (entry.queue.size() === 0) drop(entry)
39
- last = entry.key
74
+ // A key that just left the ring must not stay as `last`. The next grant
75
+ // would miss it in the map and restart at the head, skipping whoever
76
+ // was behind it.
77
+ if (entry.queue.size() === 0) last = drop(entry)
78
+ else last = entry.key
40
79
  waiter.acquire()
41
80
  }
42
81
 
@@ -45,34 +84,46 @@ module.exports = (slots = 1) => {
45
84
  grant()
46
85
  }
47
86
 
48
- const acquire = (resolve, key) => {
87
+ const acquire = resolve => {
49
88
  --slots
50
- last = key
51
89
  resolve(release)
52
90
  }
53
91
 
54
92
  const lock = (signal, key) =>
55
93
  new Promise(resolve => {
56
- if (signal != null && typeof signal.addEventListener !== 'function') {
94
+ if (
95
+ signal != null &&
96
+ (typeof signal.addEventListener !== 'function' ||
97
+ typeof signal.removeEventListener !== 'function' ||
98
+ typeof signal.aborted !== 'boolean')
99
+ ) {
57
100
  throw new TypeError('`signal` needs to be an AbortSignal.')
58
101
  }
59
102
  const id = key === undefined ? SHARED : key
60
103
  if (signal?.aborted) return resolve(null)
61
- if (!lock.isLocked()) return acquire(resolve, id)
104
+ if (!lock.isLocked()) {
105
+ last = id
106
+ return acquire(resolve)
107
+ }
62
108
 
63
- const waiter = { acquire: () => acquire(resolve, id) }
109
+ const waiter = { acquire: () => acquire(resolve) }
64
110
  enqueue(id, waiter)
65
111
 
66
112
  if (signal != null) {
67
113
  const onAbort = () => {
68
- waiter.entry.queue.remove(waiter.node)
114
+ const entry = waiter.entry
115
+ entry.queue.remove(waiter.node)
69
116
  waiting--
70
- if (waiter.entry.queue.size() === 0) drop(waiter.entry)
117
+ if (entry.queue.size() === 0) {
118
+ const current = queues.get(last) === entry
119
+ const cursor = drop(entry)
120
+ if (current) last = cursor
121
+ }
71
122
  resolve(null)
72
123
  }
73
124
  waiter.acquire = () => {
74
125
  signal.removeEventListener('abort', onAbort)
75
- acquire(resolve, id)
126
+ acquire(resolve)
76
127
  }
77
128
  signal.addEventListener('abort', onAbort, { once: true })
78
129
  }
package/src/index.d.ts CHANGED
@@ -9,14 +9,27 @@ interface LockInterface extends Lock {
9
9
 
10
10
  declare function createLock(slots?: number): LockInterface;
11
11
 
12
- declare type FairLock = (signal: AbortSignal | undefined | null, key?: unknown) => Promise<Release | null>;
12
+ declare type FairQueue = (signal?: AbortSignal | null, key?: unknown) => Promise<Release | null>;
13
13
 
14
- interface FairLockInterface extends FairLock {
14
+ interface FairQueueInterface extends FairQueue {
15
15
  isLocked: () => boolean;
16
16
  awaiting: () => number;
17
17
  }
18
18
 
19
- declare function createFairLock(slots?: number): FairLockInterface;
19
+ declare function createFairQueue(slots?: number): FairQueueInterface;
20
+
21
+ declare function createFairLock(slots?: number): FairQueueInterface;
22
+
23
+ interface FairLockInterface extends FairQueueInterface {}
24
+
25
+ declare type WithFairQueue = <T>(fn: () => Promise<T>, key?: unknown, signal?: AbortSignal | null) => Promise<T | undefined>;
26
+
27
+ interface WithFairQueueInterface extends WithFairQueue {
28
+ isLocked: () => boolean;
29
+ awaiting: () => number;
30
+ }
31
+
32
+ declare function withFairQueue(slots?: number): WithFairQueueInterface;
20
33
 
21
34
  declare type WithLock = <T>(fn: () => Promise<T>, signal?: AbortSignal) => Promise<T | undefined>;
22
35
 
@@ -27,4 +40,4 @@ interface WithLockInterface extends WithLock {
27
40
 
28
41
  declare function withLock(opts?: number): WithLockInterface;
29
42
 
30
- export { withLock, createLock, createFairLock, WithLockInterface, FairLockInterface };
43
+ export { withLock, createLock, withFairQueue, createFairQueue, createFairLock, WithLockInterface, WithFairQueueInterface, FairQueueInterface, FairLockInterface };
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const createFairLock = require('./fair')
3
+ const createFairQueue = require('./fair')
4
4
  const createLock = require('./create')
5
5
 
6
6
  const withLock = opts => {
@@ -22,4 +22,29 @@ const withLock = opts => {
22
22
  return withLock
23
23
  }
24
24
 
25
- module.exports = { withLock, createLock, createFairLock }
25
+ const withFairQueue = opts => {
26
+ const lock = createFairQueue(opts)
27
+
28
+ const withFairQueue = async (fn, key, signal) => {
29
+ const release = await lock(signal, key)
30
+ if (!release) return
31
+ try {
32
+ return await fn()
33
+ } finally {
34
+ release()
35
+ }
36
+ }
37
+
38
+ withFairQueue.isLocked = lock.isLocked
39
+ withFairQueue.awaiting = lock.awaiting
40
+
41
+ return withFairQueue
42
+ }
43
+
44
+ module.exports = {
45
+ withLock,
46
+ createLock,
47
+ withFairQueue,
48
+ createFairQueue,
49
+ createFairLock: createFairQueue
50
+ }
@@ -21,6 +21,16 @@ module.exports = class LinkedList {
21
21
  return node
22
22
  }
23
23
 
24
+ prepend (data) {
25
+ const node = new Node(data)
26
+ node.next = this.head
27
+ if (this.head) this.head.prev = node
28
+ else this.tail = node
29
+ this.head = node
30
+ this.length++
31
+ return node
32
+ }
33
+
24
34
  dequeue () {
25
35
  if (!this.head) return
26
36
  const { data } = this.head