superlock 1.4.3 → 1.4.5

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
@@ -1,8 +1,19 @@
1
- [![Last version](https://img.shields.io/github/v/tag/Kikobeats/superlock?style=flat-square)](https://github.com/Kikobeats/superlock/releases)
2
- [![Coverage Status](https://img.shields.io/coverallsCoverage/github/Kikobeats/superlock?style=flat-square)](https://coveralls.io/github/Kikobeats/superlock)
3
- [![NPM Status](https://img.shields.io/npm/dm/superlock?style=flat-square)](https://www.npmjs.com/package/superlock)
4
-
5
- > A mutex/semaphore implementation made easy to use.
1
+ <p align="center">
2
+ <a href="https://superlock.js.org/">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="docs/anatomy/wordmark.svg">
5
+ <img alt="superlock" src="docs/anatomy/wordmark-light.svg" width="640">
6
+ </picture>
7
+ </a>
8
+ </p>
9
+
10
+ <p align="center" class="subtitle">The minimal Node.js concurrency toolkit.</p>
11
+
12
+ <p align="center" class="badges">
13
+ <a href="https://github.com/Kikobeats/superlock/releases"><img alt="Last version" src="https://img.shields.io/github/v/tag/Kikobeats/superlock?style=flat-square"></a>
14
+ <a href="https://coveralls.io/github/Kikobeats/superlock"><img alt="Coverage Status" src="https://img.shields.io/coverallsCoverage/github/Kikobeats/superlock?style=flat-square"></a>
15
+ <a href="https://www.npmjs.com/package/superlock"><img alt="NPM Status" src="https://img.shields.io/npm/dm/superlock?style=flat-square"></a>
16
+ </p>
6
17
 
7
18
  ## Why
8
19
 
@@ -10,7 +21,7 @@
10
21
 
11
22
  - **Simple**: Designed for usage with `async` and `await`
12
23
  - **Powerful**: Mutex, semaphore, and fair queuing
13
- - **Secure**: Auto lock release toa void dead locks
24
+ - **Secure**: Auto lock release to avoid deadlocks
14
25
  - **Lightweight**: No dependencies
15
26
  - **Well-tested**: 100% code coverage
16
27
 
@@ -24,7 +35,14 @@ $ npm install superlock --save
24
35
 
25
36
  ### as mutex
26
37
 
27
- The lock is a mutex by default:
38
+ The lock is a mutex by default: one caller holds it, the rest wait in arrival order.
39
+
40
+ <a href="https://superlock.js.org/#/?id=as-mutex">
41
+ <picture>
42
+ <source media="(prefers-color-scheme: dark)" srcset="docs/anatomy/mutex.svg">
43
+ <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">
44
+ </picture>
45
+ </a>
28
46
 
29
47
  ```js
30
48
  const { setTimeout } = require('timers/promises')
@@ -46,7 +64,14 @@ console.log(executions)
46
64
 
47
65
  ### as semaphore
48
66
 
49
- Just call `withLock(n)` being `n` the maximum of concurrency desired for the lock:
67
+ 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:
68
+
69
+ <a href="https://superlock.js.org/#/?id=as-semaphore">
70
+ <picture>
71
+ <source media="(prefers-color-scheme: dark)" srcset="docs/anatomy/semaphore.svg">
72
+ <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">
73
+ </picture>
74
+ </a>
50
75
 
51
76
  ```js
52
77
  const { setTimeout } = require('timers/promises')
@@ -72,6 +97,13 @@ console.log(executions)
72
97
 
73
98
  `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
99
 
100
+ <a href="https://superlock.js.org/#/?id=as-fair-queue">
101
+ <picture>
102
+ <source media="(prefers-color-scheme: dark)" srcset="docs/anatomy/fair-queue.svg">
103
+ <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">
104
+ </picture>
105
+ </a>
106
+
75
107
  ```js
76
108
  const { withFairQueue } = require('superlock')
77
109
 
@@ -184,7 +216,7 @@ const release = await queue(undefined, key)
184
216
  release()
185
217
  ```
186
218
 
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.
219
+ 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
220
 
189
221
  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
222
 
@@ -264,7 +296,7 @@ How many callers are waiting for a slot. On a fair queue that is the total acros
264
296
 
265
297
  ## License
266
298
 
267
- **superlock** © [Kiko Beats](https://kikobeats.com), released under the [MIT](https://github.com/Kikobeats/lock/blob/master/LICENSE.md) License.<br>
268
- Authored and maintained by [Kiko Beats](https://kikobeats.com) with help from [contributors](https://github.com/Kikobeats/lock/contributors).
299
+ **superlock** © [Kiko Beats](https://kikobeats.com), released under the [MIT](https://github.com/Kikobeats/superlock/blob/master/LICENSE.md) License.<br>
300
+ Authored and maintained by [Kiko Beats](https://kikobeats.com) with help from [contributors](https://github.com/Kikobeats/superlock/contributors).
269
301
 
270
302
  > [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
- "description": "Minimal async mutex and semaphore for serializing critical sections in Node.js.",
4
- "homepage": "https://github.com/Kikobeats/superlock",
5
- "version": "1.4.3",
3
+ "description": "The minimal Node.js concurrency toolkit: mutex, semaphore and fair queue for async/await, with no dependencies.",
4
+ "homepage": "https://superlock.js.org/",
5
+ "version": "1.4.5",
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,10 +66,14 @@
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",
76
+ "og": "node docs/og/build.mjs",
63
77
  "postrelease": "npm run release:tags && npm run release:github && (ci-publish || npm publish --access=public)",
64
78
  "prerelease": "npm run contributors",
65
79
  "pretest": "npm run lint",
@@ -75,6 +89,11 @@
75
89
  "!test/helpers.js"
76
90
  ]
77
91
  },
92
+ "c8": {
93
+ "include": [
94
+ "src/**/*.js"
95
+ ]
96
+ },
78
97
  "commitlint": {
79
98
  "extends": [
80
99
  "@commitlint/config-conventional"
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 queues = new Map()
13
- const ring = new LinkedList()
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 drop = entry => {
20
- const predecessor = entry.ringNode.prev
21
- ring.remove(entry.ringNode)
22
- queues.delete(entry.key)
23
- return predecessor ? predecessor.data : NONE
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 enqueue = (key, waiter) => {
27
- let entry = queues.get(key)
28
- if (!entry) {
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
- waiting++
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
- // 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 = () => {
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 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()
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
- const entry = queues.get(node.data)
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 release = () => {
83
- ++slots
84
- grant()
60
+ const nextEntry = () => {
61
+ const entry = longestWithoutTurn()
62
+ if (entry) return entry
63
+ barrier = seq
64
+ return longestWithoutTurn()
85
65
  }
86
66
 
87
- const acquire = resolve => {
67
+ const take = entry => {
88
68
  --slots
89
- resolve(release)
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
- if (!lock.isLocked()) {
105
- last = id
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 = { acquire: () => acquire(resolve) }
110
- enqueue(id, waiter)
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
- if (entry.queue.size() === 0) {
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
- acquire(resolve)
118
+ resolve(release)
127
119
  }
128
120
  signal.addEventListener('abort', onAbort, { once: true })
129
121
  }
@@ -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