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 +151 -4
- package/package.json +1 -1
- package/src/fair.js +65 -14
- package/src/index.d.ts +17 -4
- package/src/index.js +27 -2
- package/src/linked-list.js +10 -0
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
|
|
12
|
+
- **Powerful**: Mutex, semaphore, and fair queuing
|
|
13
13
|
- **Secure**: Auto lock release toa void dead locks
|
|
14
|
-
- **Lightweight**: No dependencies
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
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 =
|
|
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 (
|
|
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())
|
|
104
|
+
if (!lock.isLocked()) {
|
|
105
|
+
last = id
|
|
106
|
+
return acquire(resolve)
|
|
107
|
+
}
|
|
62
108
|
|
|
63
|
-
const waiter = { acquire: () => acquire(resolve
|
|
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
|
|
114
|
+
const entry = waiter.entry
|
|
115
|
+
entry.queue.remove(waiter.node)
|
|
69
116
|
waiting--
|
|
70
|
-
if (
|
|
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
|
|
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
|
|
12
|
+
declare type FairQueue = (signal?: AbortSignal | null, key?: unknown) => Promise<Release | null>;
|
|
13
13
|
|
|
14
|
-
interface
|
|
14
|
+
interface FairQueueInterface extends FairQueue {
|
|
15
15
|
isLocked: () => boolean;
|
|
16
16
|
awaiting: () => number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
declare function
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/linked-list.js
CHANGED
|
@@ -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
|