superlock 1.4.3 → 1.4.4
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 +27 -6
- package/package.json +15 -2
- package/src/fair.js +72 -80
- package/src/linked-list.js +23 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
- **Simple**: Designed for usage with `async` and `await`
|
|
12
12
|
- **Powerful**: Mutex, semaphore, and fair queuing
|
|
13
|
-
- **Secure**: Auto lock release
|
|
13
|
+
- **Secure**: Auto lock release to avoid deadlocks
|
|
14
14
|
- **Lightweight**: No dependencies
|
|
15
15
|
- **Well-tested**: 100% code coverage
|
|
16
16
|
|
|
@@ -24,7 +24,14 @@ $ npm install superlock --save
|
|
|
24
24
|
|
|
25
25
|
### as mutex
|
|
26
26
|
|
|
27
|
-
The lock is a mutex by default:
|
|
27
|
+
The lock is a mutex by default: one caller holds it, the rest wait in arrival order.
|
|
28
|
+
|
|
29
|
+
<a href="https://kikobeats.github.io/superlock/#/?id=as-mutex">
|
|
30
|
+
<picture>
|
|
31
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/anatomy/mutex.svg">
|
|
32
|
+
<img alt="A coin-feed escapement: tokens queue in a column, a pusher one token thick carries exactly one at a time to a press, and the next drops in only after release()." src="docs/anatomy/mutex-light.svg">
|
|
33
|
+
</picture>
|
|
34
|
+
</a>
|
|
28
35
|
|
|
29
36
|
```js
|
|
30
37
|
const { setTimeout } = require('timers/promises')
|
|
@@ -46,7 +53,14 @@ console.log(executions)
|
|
|
46
53
|
|
|
47
54
|
### as semaphore
|
|
48
55
|
|
|
49
|
-
|
|
56
|
+
Call `withLock(n)` with `n` as the maximum concurrency for the lock. Up to `n` callers hold it at once; a freed slot goes to the oldest waiter:
|
|
57
|
+
|
|
58
|
+
<a href="https://kikobeats.github.io/superlock/#/?id=as-semaphore">
|
|
59
|
+
<picture>
|
|
60
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/anatomy/semaphore.svg">
|
|
61
|
+
<img alt="Twin lanes fed by one column: a diverter drops the oldest waiting token into whichever of two lanes is free, and each lane releases on its own." src="docs/anatomy/semaphore-light.svg">
|
|
62
|
+
</picture>
|
|
63
|
+
</a>
|
|
50
64
|
|
|
51
65
|
```js
|
|
52
66
|
const { setTimeout } = require('timers/promises')
|
|
@@ -72,6 +86,13 @@ console.log(executions)
|
|
|
72
86
|
|
|
73
87
|
`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
88
|
|
|
89
|
+
<a href="https://kikobeats.github.io/superlock/#/?id=as-fair-queue">
|
|
90
|
+
<picture>
|
|
91
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/anatomy/fair-queue.svg">
|
|
92
|
+
<img alt="An indexing table: one column per key around a rotating dial that feeds one press, visiting the keys in turn so a deep column cannot starve the others." src="docs/anatomy/fair-queue-light.svg">
|
|
93
|
+
</picture>
|
|
94
|
+
</a>
|
|
95
|
+
|
|
75
96
|
```js
|
|
76
97
|
const { withFairQueue } = require('superlock')
|
|
77
98
|
|
|
@@ -184,7 +205,7 @@ const release = await queue(undefined, key)
|
|
|
184
205
|
release()
|
|
185
206
|
```
|
|
186
207
|
|
|
187
|
-
One key is served in arrival order. Across keys, a free slot goes to the
|
|
208
|
+
One key is served in arrival order. Across keys, a free slot goes to the waiting key that has gone longest without a turn; keys that never had one go first, in arrival order. A key that still holds a slot waits behind keys that hold none. Calls that arrive while a round is being served wait until every call that was already waiting has had its turn.
|
|
188
209
|
|
|
189
210
|
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
211
|
|
|
@@ -264,7 +285,7 @@ How many callers are waiting for a slot. On a fair queue that is the total acros
|
|
|
264
285
|
|
|
265
286
|
## License
|
|
266
287
|
|
|
267
|
-
**superlock** © [Kiko Beats](https://kikobeats.com), released under the [MIT](https://github.com/Kikobeats/
|
|
268
|
-
Authored and maintained by [Kiko Beats](https://kikobeats.com) with help from [contributors](https://github.com/Kikobeats/
|
|
288
|
+
**superlock** © [Kiko Beats](https://kikobeats.com), released under the [MIT](https://github.com/Kikobeats/superlock/blob/master/LICENSE.md) License.<br>
|
|
289
|
+
Authored and maintained by [Kiko Beats](https://kikobeats.com) with help from [contributors](https://github.com/Kikobeats/superlock/contributors).
|
|
269
290
|
|
|
270
291
|
> [kikobeats.com](https://kikobeats.com) · GitHub [Kiko Beats](https://github.com/Kikobeats) · X [@Kikobeats](https://x.com/Kikobeats)
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlock",
|
|
3
3
|
"description": "Minimal async mutex and semaphore for serializing critical sections in Node.js.",
|
|
4
|
-
"homepage": "https://github.
|
|
5
|
-
"version": "1.4.
|
|
4
|
+
"homepage": "https://kikobeats.github.io/superlock/",
|
|
5
|
+
"version": "1.4.4",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
7
7
|
"main": "src/index.js",
|
|
8
8
|
"exports": {
|
|
@@ -39,12 +39,22 @@
|
|
|
39
39
|
"@commitlint/cli": "latest",
|
|
40
40
|
"@commitlint/config-conventional": "latest",
|
|
41
41
|
"ava": "7",
|
|
42
|
+
"browser-sync": "latest",
|
|
42
43
|
"c8": "latest",
|
|
43
44
|
"ci-publish": "latest",
|
|
45
|
+
"concurrently": "latest",
|
|
46
|
+
"cssnano": "latest",
|
|
47
|
+
"cssnano-preset-advanced": "latest",
|
|
44
48
|
"finepack": "latest",
|
|
45
49
|
"git-authors-cli": "latest",
|
|
46
50
|
"github-generate-release": "latest",
|
|
51
|
+
"gulp": "5",
|
|
52
|
+
"gulp-concat": "latest",
|
|
53
|
+
"gulp-postcss": "latest",
|
|
54
|
+
"gulp-uglify": "latest",
|
|
47
55
|
"nano-staged": "latest",
|
|
56
|
+
"postcss": "latest",
|
|
57
|
+
"postcss-focus": "latest",
|
|
48
58
|
"simple-git-hooks": "latest",
|
|
49
59
|
"standard": "latest",
|
|
50
60
|
"standard-version": "latest"
|
|
@@ -56,9 +66,12 @@
|
|
|
56
66
|
"src"
|
|
57
67
|
],
|
|
58
68
|
"scripts": {
|
|
69
|
+
"build": "gulp build",
|
|
59
70
|
"clean": "rm -rf node_modules",
|
|
60
71
|
"contributors": "(npx git-authors-cli && npx finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true",
|
|
61
72
|
"coverage": "c8 report --reporter=text-lcov > coverage/lcov.info",
|
|
73
|
+
"dev": "concurrently \"gulp\" \"npm run dev:server\"",
|
|
74
|
+
"dev:server": "browser-sync start --server --files \"index.html, README.md, static/**/*.(css|js)\"",
|
|
62
75
|
"lint": "standard",
|
|
63
76
|
"postrelease": "npm run release:tags && npm run release:github && (ci-publish || npm publish --access=public)",
|
|
64
77
|
"prerelease": "npm run contributors",
|
package/src/fair.js
CHANGED
|
@@ -3,90 +3,89 @@
|
|
|
3
3
|
const LinkedList = require('./linked-list')
|
|
4
4
|
|
|
5
5
|
const SHARED = Symbol('shared')
|
|
6
|
-
const NONE = Symbol('none')
|
|
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
6
|
|
|
11
7
|
module.exports = (slots = 1) => {
|
|
12
|
-
const
|
|
13
|
-
const
|
|
8
|
+
const keys = new Map()
|
|
9
|
+
const turns = new LinkedList()
|
|
10
|
+
let newestWithoutTurn
|
|
14
11
|
let waiting = 0
|
|
15
|
-
let last = NONE
|
|
16
12
|
let seq = 0
|
|
17
13
|
let barrier = 0
|
|
18
14
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
const entryOf = key => {
|
|
16
|
+
let entry = keys.get(key)
|
|
17
|
+
if (!entry) {
|
|
18
|
+
entry = { key, queue: new LinkedList(), holders: 0 }
|
|
19
|
+
entry.turnNode = turns.insertAfter(newestWithoutTurn, entry)
|
|
20
|
+
newestWithoutTurn = entry.turnNode
|
|
21
|
+
keys.set(key, entry)
|
|
22
|
+
}
|
|
23
|
+
return entry
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
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 }
|
|
37
|
-
queues.set(key, entry)
|
|
26
|
+
const leaveTurns = entry => {
|
|
27
|
+
if (entry.turnNode === newestWithoutTurn) {
|
|
28
|
+
newestWithoutTurn = entry.turnNode.prev
|
|
38
29
|
}
|
|
39
|
-
|
|
40
|
-
waiter.entry = entry
|
|
41
|
-
waiter.joinSeq = ++seq
|
|
42
|
-
waiter.node = entry.queue.enqueue(waiter)
|
|
30
|
+
turns.remove(entry.turnNode)
|
|
43
31
|
}
|
|
44
32
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// ring is not circular, so the walk jumps from the tail back to the head.
|
|
51
|
-
const selectNode = () => {
|
|
52
|
-
const current = queues.get(last)
|
|
53
|
-
const start =
|
|
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)
|
|
33
|
+
const markTurn = entry => {
|
|
34
|
+
if (entry.turnNode === newestWithoutTurn) {
|
|
35
|
+
newestWithoutTurn = entry.turnNode.prev
|
|
36
|
+
}
|
|
37
|
+
turns.moveToTail(entry.turnNode)
|
|
60
38
|
}
|
|
61
39
|
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
40
|
+
const forgetIfIdle = entry => {
|
|
41
|
+
if (entry.holders > 0 || entry.queue.size() > 0) return
|
|
42
|
+
leaveTurns(entry)
|
|
43
|
+
keys.delete(entry.key)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const inRound = entry => entry.queue.head.data.joinSeq <= barrier
|
|
47
|
+
|
|
48
|
+
// A smaller holder count does not outrank an older turn.
|
|
49
|
+
const longestWithoutTurn = () => {
|
|
50
|
+
let holding
|
|
51
|
+
for (let node = turns.head; node; node = node.next) {
|
|
52
|
+
const entry = node.data
|
|
53
|
+
if (entry.queue.size() === 0 || !inRound(entry)) continue
|
|
54
|
+
if (entry.holders === 0) return entry
|
|
55
|
+
if (!holding) holding = entry
|
|
70
56
|
}
|
|
71
|
-
|
|
72
|
-
const waiter = entry.queue.dequeue()
|
|
73
|
-
waiting--
|
|
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
|
|
79
|
-
waiter.acquire()
|
|
57
|
+
return holding
|
|
80
58
|
}
|
|
81
59
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
60
|
+
const nextEntry = () => {
|
|
61
|
+
const entry = longestWithoutTurn()
|
|
62
|
+
if (entry) return entry
|
|
63
|
+
barrier = seq
|
|
64
|
+
return longestWithoutTurn()
|
|
85
65
|
}
|
|
86
66
|
|
|
87
|
-
const
|
|
67
|
+
const take = entry => {
|
|
88
68
|
--slots
|
|
89
|
-
|
|
69
|
+
entry.holders++
|
|
70
|
+
markTurn(entry)
|
|
71
|
+
let released = false
|
|
72
|
+
return () => {
|
|
73
|
+
if (released) return
|
|
74
|
+
released = true
|
|
75
|
+
entry.holders--
|
|
76
|
+
markTurn(entry)
|
|
77
|
+
forgetIfIdle(entry)
|
|
78
|
+
++slots
|
|
79
|
+
grant()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const grant = () => {
|
|
84
|
+
if (waiting === 0) return
|
|
85
|
+
const entry = nextEntry()
|
|
86
|
+
const waiter = entry.queue.dequeue()
|
|
87
|
+
waiting--
|
|
88
|
+
waiter.acquire(take(entry))
|
|
90
89
|
}
|
|
91
90
|
|
|
92
91
|
const lock = (signal, key) =>
|
|
@@ -99,31 +98,24 @@ module.exports = (slots = 1) => {
|
|
|
99
98
|
) {
|
|
100
99
|
throw new TypeError('`signal` needs to be an AbortSignal.')
|
|
101
100
|
}
|
|
102
|
-
const id = key === undefined ? SHARED : key
|
|
103
101
|
if (signal?.aborted) return resolve(null)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return acquire(resolve)
|
|
107
|
-
}
|
|
102
|
+
const entry = entryOf(key === undefined ? SHARED : key)
|
|
103
|
+
if (!lock.isLocked()) return resolve(take(entry))
|
|
108
104
|
|
|
109
|
-
const waiter = {
|
|
110
|
-
enqueue(
|
|
105
|
+
const waiter = { joinSeq: ++seq, acquire: resolve }
|
|
106
|
+
waiter.node = entry.queue.enqueue(waiter)
|
|
107
|
+
waiting++
|
|
111
108
|
|
|
112
109
|
if (signal != null) {
|
|
113
110
|
const onAbort = () => {
|
|
114
|
-
const entry = waiter.entry
|
|
115
111
|
entry.queue.remove(waiter.node)
|
|
116
112
|
waiting--
|
|
117
|
-
|
|
118
|
-
const current = queues.get(last) === entry
|
|
119
|
-
const cursor = drop(entry)
|
|
120
|
-
if (current) last = cursor
|
|
121
|
-
}
|
|
113
|
+
forgetIfIdle(entry)
|
|
122
114
|
resolve(null)
|
|
123
115
|
}
|
|
124
|
-
waiter.acquire =
|
|
116
|
+
waiter.acquire = release => {
|
|
125
117
|
signal.removeEventListener('abort', onAbort)
|
|
126
|
-
|
|
118
|
+
resolve(release)
|
|
127
119
|
}
|
|
128
120
|
signal.addEventListener('abort', onAbort, { once: true })
|
|
129
121
|
}
|
package/src/linked-list.js
CHANGED
|
@@ -31,6 +31,29 @@ module.exports = class LinkedList {
|
|
|
31
31
|
return node
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
insertAfter (previous, data) {
|
|
35
|
+
if (!previous) return this.prepend(data)
|
|
36
|
+
const node = new Node(data)
|
|
37
|
+
node.prev = previous
|
|
38
|
+
node.next = previous.next
|
|
39
|
+
if (previous.next) previous.next.prev = node
|
|
40
|
+
else this.tail = node
|
|
41
|
+
previous.next = node
|
|
42
|
+
this.length++
|
|
43
|
+
return node
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
moveToTail (node) {
|
|
47
|
+
if (node === this.tail) return
|
|
48
|
+
if (node.prev) node.prev.next = node.next
|
|
49
|
+
else this.head = node.next
|
|
50
|
+
node.next.prev = node.prev
|
|
51
|
+
node.prev = this.tail
|
|
52
|
+
node.next = undefined
|
|
53
|
+
this.tail.next = node
|
|
54
|
+
this.tail = node
|
|
55
|
+
}
|
|
56
|
+
|
|
34
57
|
dequeue () {
|
|
35
58
|
if (!this.head) return
|
|
36
59
|
const { data } = this.head
|