@nxtedition/cache 2.1.10 → 2.1.12
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 +39 -11
- package/lib/index.js +137 -63
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# @nxtedition/cache
|
|
2
2
|
|
|
3
|
-
A two-tier async cache with SQLite persistence, in-memory LRU, stale-while-revalidate, cross-process deduplication, and automatic request
|
|
3
|
+
A two-tier async cache with SQLite persistence, in-memory pseudo-LRU, stale-while-revalidate, cross-process/thread deduplication, and automatic request coalescing.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **Two-tier storage** — In-memory
|
|
7
|
+
- **Two-tier storage** — In-memory cache backed by SQLite on disk
|
|
8
8
|
- **Stale-while-revalidate** — Serve stale data synchronously while refreshing in the background
|
|
9
|
-
- **Request
|
|
10
|
-
- **Cross-process locking** — SQLite-based distributed locks prevent redundant work across processes
|
|
9
|
+
- **Request coalescing** — Concurrent fetches for the same key share a single in-flight request
|
|
10
|
+
- **Cross-process/thread locking** — SQLite-based distributed locks with `Atomics`-based wakeup prevent redundant work across processes and worker threads
|
|
11
11
|
- **Async value resolution** — Transparently fetches missing values via a user-defined `valueSelector`
|
|
12
12
|
- **Binary support** — Store and retrieve `Buffer` / `Uint8Array` alongside JSON values
|
|
13
13
|
- **Size-bounded storage** — Configurable max database size with automatic eviction of oldest entries
|
|
14
14
|
- **Custom serialization** — Pluggable `serialize`/`deserialize` for non-JSON value types
|
|
15
|
+
- **Batched writes** — SQLite writes are coalesced into transactions via `setImmediate`, reducing I/O
|
|
16
|
+
- **Disposable** — Implements `Symbol.dispose` for use with `using` declarations
|
|
15
17
|
|
|
16
18
|
## Usage
|
|
17
19
|
|
|
@@ -42,6 +44,16 @@ if (result.async) {
|
|
|
42
44
|
}
|
|
43
45
|
```
|
|
44
46
|
|
|
47
|
+
### Using `Symbol.dispose`
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
{
|
|
51
|
+
using cache = new Cache('./tmp.db', fetchItem, (id) => id, { ttl: 5_000 })
|
|
52
|
+
const result = cache.get('key')
|
|
53
|
+
// ...
|
|
54
|
+
} // cache.close() called automatically
|
|
55
|
+
```
|
|
56
|
+
|
|
45
57
|
## API
|
|
46
58
|
|
|
47
59
|
### `new Cache(location, valueSelector?, keySelector?, opts?)`
|
|
@@ -61,7 +73,7 @@ if (result.async) {
|
|
|
61
73
|
| `stale` | `number \| (value, key) => number` | `MAX_SAFE_INTEGER` | Stale-while-revalidate window in ms. After `ttl + stale`, the entry is purged. |
|
|
62
74
|
| `memory` | `MemoryOptions \| false \| null` | `{ maxSize: 16MB, maxCount: 16384 }` | In-memory cache config, or `false`/`null` to disable. |
|
|
63
75
|
| `database` | `DatabaseOptions \| false \| null` | `{ timeout: 20, maxSize: 128MB }` | SQLite config, or `false`/`null` to disable persistence. |
|
|
64
|
-
| `lock` | `LockOptions \| false \| null` | `{ minTimeout: 1, maxTimeout: 1000 }` | Cross-process lock config, or `false`/`null` to disable.
|
|
76
|
+
| `lock` | `LockOptions \| false \| null` | `{ minTimeout: 1, maxTimeout: 1000 }` | Cross-process/thread lock config, or `false`/`null` to disable. |
|
|
65
77
|
| `serializer` | `Serializer<V>` | JSON + ArrayBufferView passthrough | Custom `{ serialize, deserialize }` for value encoding. |
|
|
66
78
|
|
|
67
79
|
#### `MemoryOptions`
|
|
@@ -71,6 +83,8 @@ if (result.async) {
|
|
|
71
83
|
| `maxSize` | `number` | `16 * 1024 * 1024` (16 MB) | Maximum total size in bytes of cached entries. |
|
|
72
84
|
| `maxCount` | `number` | `16 * 1024` (16384) | Maximum number of entries in memory. |
|
|
73
85
|
|
|
86
|
+
The in-memory tier uses a random-two-choice eviction strategy: when the cache is full, two entries are sampled at random and the least recently accessed one is evicted. This provides near-LRU behavior with O(1) eviction cost.
|
|
87
|
+
|
|
74
88
|
#### `DatabaseOptions`
|
|
75
89
|
|
|
76
90
|
| Option | Type | Default | Description |
|
|
@@ -80,7 +94,7 @@ if (result.async) {
|
|
|
80
94
|
|
|
81
95
|
#### `LockOptions`
|
|
82
96
|
|
|
83
|
-
Cross-process locking prevents multiple processes from computing the same value simultaneously. The lock timeout is adaptive — it uses an exponential moving average (EMA) of `valueSelector` durations to estimate how long to wait before taking over a lock.
|
|
97
|
+
Cross-process/thread locking prevents multiple processes or worker threads from computing the same value simultaneously. The lock timeout is adaptive — it uses an exponential moving average (EMA) of `valueSelector` durations to estimate how long to wait before taking over a lock.
|
|
84
98
|
|
|
85
99
|
| Option | Type | Default | Description |
|
|
86
100
|
| ------------ | -------- | ------- | -------------------------------------------------------------------------- |
|
|
@@ -119,7 +133,7 @@ if (result.async) {
|
|
|
119
133
|
|
|
120
134
|
#### `cache.get(...args): CacheResult<V>`
|
|
121
135
|
|
|
122
|
-
Returns a cached value or triggers a fetch on cache miss. If the entry is stale and the `valueSelector` is async, returns the stale value synchronously while a background refresh runs.
|
|
136
|
+
Returns a cached value or triggers a fetch on cache miss. If the entry is stale and the `valueSelector` is async, returns the stale value synchronously (`async: false`) while a background refresh runs. If the `valueSelector` throws during a stale revalidation, the error is emitted and the stale value is preserved.
|
|
123
137
|
|
|
124
138
|
#### `cache.peek(...args): CacheResult<V>`
|
|
125
139
|
|
|
@@ -139,7 +153,9 @@ Remove all expired entries (past `ttl + stale`) from both the in-memory cache an
|
|
|
139
153
|
|
|
140
154
|
#### `cache.close(): void`
|
|
141
155
|
|
|
142
|
-
|
|
156
|
+
Flush any pending writes, close the SQLite database, and release resources. Clears all in-flight deduplication. Operations after `close()` throw.
|
|
157
|
+
|
|
158
|
+
Also available as `[Symbol.dispose]()` for use with `using` declarations.
|
|
143
159
|
|
|
144
160
|
#### `cache.stats`
|
|
145
161
|
|
|
@@ -183,11 +199,23 @@ Once the stale window expires, the entry is purged entirely and the next `get()`
|
|
|
183
199
|
+ bg refresh
|
|
184
200
|
```
|
|
185
201
|
|
|
186
|
-
## Cross-Process Locking
|
|
202
|
+
## Cross-Process/Thread Locking
|
|
203
|
+
|
|
204
|
+
When multiple processes or worker threads share the same SQLite database, the lock mechanism prevents redundant `valueSelector` calls. Process A acquires a lock, computes the value, and writes it. Process B sees the lock, waits for the estimated completion time (EMA-based), then reads the value from SQLite.
|
|
205
|
+
|
|
206
|
+
For worker threads sharing the same process, a `SharedArrayBuffer` + `Atomics.waitAsync` mechanism provides efficient cross-thread wakeup — the waiting thread is notified immediately when the lock holder writes the value, rather than polling.
|
|
207
|
+
|
|
208
|
+
If the lock holder crashes, the lock becomes stale after `3 * lockTimeout` and another process steals it.
|
|
209
|
+
|
|
210
|
+
## Batched Writes
|
|
211
|
+
|
|
212
|
+
SQLite writes are batched using `setImmediate` — multiple `set()` calls within the same microtask turn are coalesced into a single `BEGIN`/`COMMIT` transaction. While a write batch is pending, the in-memory cache is corked (eviction deferred) to avoid dropping entries before they reach disk. If a batch exceeds 512 items, it is flushed immediately.
|
|
213
|
+
|
|
214
|
+
If the database is full (`SQLITE_FULL`), the cache evicts the 256 oldest entries and retries up to 3 times. On other errors, the rolled-back items are dropped from the batch and the error is emitted.
|
|
187
215
|
|
|
188
|
-
|
|
216
|
+
## Error Handling
|
|
189
217
|
|
|
190
|
-
|
|
218
|
+
`Cache` extends `EventEmitter`. Non-fatal errors (SQLite failures, lock contention errors, stale revalidation failures) are emitted as `'error'` events when a listener is attached. If no `'error'` listener is registered, errors are surfaced via `process.emitWarning()` instead, avoiding unhandled crashes.
|
|
191
219
|
|
|
192
220
|
## Off-Peak Purge
|
|
193
221
|
|
package/lib/index.js
CHANGED
|
@@ -2,6 +2,11 @@ import { randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { EventEmitter } from 'node:events'
|
|
3
3
|
import { DatabaseSync, } from 'node:sqlite'
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
5
|
+
|
|
6
|
+
import xxhash from 'xxhash-wasm'
|
|
7
|
+
|
|
8
|
+
import { getOrCreate } from '@nxtedition/shared'
|
|
9
|
+
|
|
5
10
|
import { MemoryCache, } from "./memory.js"
|
|
6
11
|
|
|
7
12
|
|
|
@@ -79,6 +84,7 @@ const defaultSerializer = {
|
|
|
79
84
|
|
|
80
85
|
const VERSION = 5
|
|
81
86
|
const MAX_DURATION = 365000000e3
|
|
87
|
+
const HASHER = await xxhash()
|
|
82
88
|
|
|
83
89
|
export class Cache extends EventEmitter {
|
|
84
90
|
#memory
|
|
@@ -92,12 +98,15 @@ export class Cache extends EventEmi
|
|
|
92
98
|
#stale
|
|
93
99
|
#serializer
|
|
94
100
|
|
|
95
|
-
#lockId
|
|
101
|
+
#lockId = null
|
|
102
|
+
#lockArray = null
|
|
103
|
+
#lockSet
|
|
96
104
|
#lockVar = 0
|
|
97
105
|
#lockMean = 5
|
|
98
106
|
#lockTimeout = 10
|
|
99
107
|
#lockMinTimeout = 1
|
|
100
108
|
#lockMaxTimeout = 1_000
|
|
109
|
+
|
|
101
110
|
#flushHandle = null
|
|
102
111
|
#location
|
|
103
112
|
#databaseTimeout = 20
|
|
@@ -113,9 +122,10 @@ export class Cache extends EventEmi
|
|
|
113
122
|
#pageCountQuery = null
|
|
114
123
|
#pageSizeQuery = null
|
|
115
124
|
#setQuery = null
|
|
116
|
-
#setBatch
|
|
125
|
+
#setBatch =
|
|
126
|
+
[]
|
|
117
127
|
|
|
118
|
-
#emitError(err )
|
|
128
|
+
#emitError = (err ) => {
|
|
119
129
|
if (this.listenerCount('error') > 0) {
|
|
120
130
|
this.emit('error', err)
|
|
121
131
|
} else {
|
|
@@ -164,27 +174,36 @@ export class Cache extends EventEmi
|
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
if (opts?.lock === false || opts?.lock === null) {
|
|
177
|
+
this.#lockId = null
|
|
178
|
+
this.#lockArray = null
|
|
179
|
+
this.#lockSet = null
|
|
167
180
|
this.#lockMinTimeout = -1
|
|
168
181
|
this.#lockTimeout = -1
|
|
169
182
|
this.#lockMean = -1
|
|
170
|
-
} else
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (opts.lock.minTimeout !== undefined) {
|
|
175
|
-
if (typeof opts.lock.minTimeout !== 'number') {
|
|
176
|
-
throw new TypeError('lock.minTimeout must be a number')
|
|
183
|
+
} else {
|
|
184
|
+
if (opts?.lock !== undefined) {
|
|
185
|
+
if (typeof opts.lock !== 'object' || opts.lock === null) {
|
|
186
|
+
throw new TypeError('lock must be an object')
|
|
177
187
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
188
|
+
if (opts.lock.minTimeout !== undefined) {
|
|
189
|
+
if (typeof opts.lock.minTimeout !== 'number') {
|
|
190
|
+
throw new TypeError('lock.minTimeout must be a number')
|
|
191
|
+
}
|
|
192
|
+
this.#lockMinTimeout = Math.max(0, opts.lock.minTimeout)
|
|
193
|
+
this.#lockTimeout = this.#lockMinTimeout
|
|
194
|
+
this.#lockMean = this.#lockTimeout / 1.2
|
|
195
|
+
}
|
|
196
|
+
if (opts.lock.maxTimeout !== undefined) {
|
|
197
|
+
if (typeof opts.lock.maxTimeout !== 'number') {
|
|
198
|
+
throw new TypeError('lock.maxTimeout must be a number')
|
|
199
|
+
}
|
|
200
|
+
this.#lockMaxTimeout = Math.max(this.#lockMinTimeout, opts.lock.maxTimeout)
|
|
185
201
|
}
|
|
186
|
-
this.#lockMaxTimeout = Math.max(this.#lockMinTimeout, opts.lock.maxTimeout)
|
|
187
202
|
}
|
|
203
|
+
|
|
204
|
+
this.#lockId = randomUUID()
|
|
205
|
+
this.#lockArray = new Int32Array(getOrCreate(`__@nxtedition/cache/${location}`, 8 + 1024))
|
|
206
|
+
this.#lockSet = new Set()
|
|
188
207
|
}
|
|
189
208
|
|
|
190
209
|
if (opts?.serializer !== undefined) {
|
|
@@ -227,12 +246,6 @@ export class Cache extends EventEmi
|
|
|
227
246
|
) WITHOUT ROWID;
|
|
228
247
|
|
|
229
248
|
CREATE INDEX IF NOT EXISTS cache_v${VERSION}_stale_idx ON cache_v${VERSION}(stale);
|
|
230
|
-
|
|
231
|
-
CREATE TABLE IF NOT EXISTS cache_lock_v${VERSION} (
|
|
232
|
-
key TEXT PRIMARY KEY NOT NULL,
|
|
233
|
-
lock_acquired INTEGER NOT NULL,
|
|
234
|
-
lock_owner TEXT NOT NULL
|
|
235
|
-
);
|
|
236
249
|
`)
|
|
237
250
|
|
|
238
251
|
this.#getQuery = this.#database.prepare(
|
|
@@ -249,21 +262,32 @@ export class Cache extends EventEmi
|
|
|
249
262
|
`DELETE FROM cache_v${VERSION} WHERE key IN (SELECT key FROM cache_v${VERSION} ORDER BY stale ASC LIMIT ?)`,
|
|
250
263
|
)
|
|
251
264
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
if (this.#lockId != null) {
|
|
266
|
+
this.#database.exec(`
|
|
267
|
+
CREATE TABLE IF NOT EXISTS cache_lock_v${VERSION} (
|
|
268
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
269
|
+
lock_acquired INTEGER NOT NULL,
|
|
270
|
+
lock_owner TEXT NOT NULL
|
|
271
|
+
);
|
|
272
|
+
`)
|
|
273
|
+
|
|
274
|
+
// ON CONFLICT refreshes lock_acquired when we are the owner,
|
|
275
|
+
// preventing other processes from stealing our still-active lock.
|
|
276
|
+
this.#lockAcquireQuery = this.#database.prepare(
|
|
277
|
+
`INSERT INTO cache_lock_v${VERSION} (key, lock_acquired, lock_owner) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET lock_acquired = CASE WHEN lock_owner = excluded.lock_owner THEN excluded.lock_acquired ELSE lock_acquired END RETURNING lock_acquired, lock_owner`,
|
|
278
|
+
)
|
|
279
|
+
this.#lockStealQuery = this.#database.prepare(
|
|
280
|
+
`UPDATE cache_lock_v${VERSION} SET lock_acquired = ?, lock_owner = ? WHERE key = ? AND lock_acquired = ?`,
|
|
281
|
+
)
|
|
282
|
+
// Read the current lock state after a failed steal to get the winner's timestamp.
|
|
283
|
+
this.#lockGetQuery = this.#database.prepare(
|
|
284
|
+
`SELECT lock_acquired FROM cache_lock_v${VERSION} WHERE key = ?`,
|
|
285
|
+
)
|
|
286
|
+
this.#lockPurgeQuery = this.#database.prepare(
|
|
287
|
+
`DELETE FROM cache_lock_v${VERSION} WHERE lock_acquired <= ?`,
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
267
291
|
this.#pageCountQuery = this.#database.prepare('PRAGMA page_count')
|
|
268
292
|
this.#pageSizeQuery = this.#database.prepare('PRAGMA page_size')
|
|
269
293
|
break
|
|
@@ -497,15 +521,23 @@ export class Cache extends EventEmi
|
|
|
497
521
|
throw new TypeError('keySelector must return a non-empty string')
|
|
498
522
|
}
|
|
499
523
|
|
|
500
|
-
if (this.#
|
|
524
|
+
if (this.#lockId != null) {
|
|
501
525
|
// Another process holds the lock — wait for the value instead of calling valueSelector.
|
|
502
526
|
// Use deferred pattern so #waitForValue can check promise identity to detect delete+get races.
|
|
503
|
-
|
|
504
|
-
|
|
527
|
+
let lockIdx = -1
|
|
528
|
+
let lockVal = 0
|
|
529
|
+
if (this.#lockArray) {
|
|
530
|
+
lockIdx = HASHER.h32(key) % this.#lockArray.length
|
|
531
|
+
lockVal = Atomics.load(this.#lockArray, lockIdx)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const lockRes = this.#tryAcquireLock(key, lockIdx)
|
|
535
|
+
|
|
536
|
+
if (typeof lockRes === 'number') {
|
|
505
537
|
const { promise, resolve, reject } = Promise.withResolvers ()
|
|
506
538
|
promise.catch(noop)
|
|
507
539
|
this.#dedupe.set(key, promise)
|
|
508
|
-
this.#waitForValue(args, key,
|
|
540
|
+
this.#waitForValue(args, key, lockIdx, lockVal, lockRes, promise).then(resolve, reject)
|
|
509
541
|
return { async: true, value: promise }
|
|
510
542
|
}
|
|
511
543
|
}
|
|
@@ -541,16 +573,26 @@ export class Cache extends EventEmi
|
|
|
541
573
|
const value = this.#valueSelector(...args)
|
|
542
574
|
|
|
543
575
|
if (isThenable(value)) {
|
|
576
|
+
const promise = Promise.resolve(value)
|
|
577
|
+
|
|
578
|
+
if (this.#lockId != null) {
|
|
579
|
+
promise
|
|
580
|
+
.then(() => {
|
|
581
|
+
this.#updateLockTimeout(performance.now() - startTime)
|
|
582
|
+
})
|
|
583
|
+
.catch(this.#emitError)
|
|
584
|
+
}
|
|
585
|
+
|
|
544
586
|
return {
|
|
545
587
|
async: true,
|
|
546
|
-
value:
|
|
547
|
-
this.#updateLockTimeout(performance.now() - startTime)
|
|
548
|
-
return value
|
|
549
|
-
}),
|
|
588
|
+
value: promise,
|
|
550
589
|
}
|
|
551
590
|
}
|
|
552
591
|
|
|
553
|
-
this.#
|
|
592
|
+
if (this.#lockId != null) {
|
|
593
|
+
this.#updateLockTimeout(performance.now() - startTime)
|
|
594
|
+
}
|
|
595
|
+
|
|
554
596
|
return { async: false, value }
|
|
555
597
|
}
|
|
556
598
|
|
|
@@ -609,7 +651,7 @@ export class Cache extends EventEmi
|
|
|
609
651
|
this.#flush()
|
|
610
652
|
}
|
|
611
653
|
|
|
612
|
-
this.#setBatch.push({ key, data, ttl, stale })
|
|
654
|
+
this.#setBatch.push({ key, data, ttl, stale, hash: this.#lockArray ? HASHER.h32(key) : -1 })
|
|
613
655
|
}
|
|
614
656
|
|
|
615
657
|
#flush = () => {
|
|
@@ -628,18 +670,31 @@ export class Cache extends EventEmi
|
|
|
628
670
|
try {
|
|
629
671
|
this.#database.exec('BEGIN')
|
|
630
672
|
while (n < this.#setBatch.length) {
|
|
631
|
-
const { key, data, ttl, stale } = this.#setBatch[n++]
|
|
673
|
+
const { key, data, ttl, stale, hash } = this.#setBatch[n++]
|
|
632
674
|
if (data != null) {
|
|
633
675
|
this.#setQuery?.run(key, data, ttl, stale)
|
|
634
676
|
} else {
|
|
635
677
|
this.#delQuery?.run(key)
|
|
636
678
|
}
|
|
679
|
+
|
|
680
|
+
if (this.#lockSet && this.#lockArray && hash >= 0) {
|
|
681
|
+
this.#lockSet.add(hash % this.#lockArray.length)
|
|
682
|
+
}
|
|
683
|
+
|
|
637
684
|
if ((n & 0xf) === 0 && performance.now() - startTime > 10) {
|
|
638
685
|
break
|
|
639
686
|
}
|
|
640
687
|
}
|
|
641
688
|
this.#database.exec('COMMIT')
|
|
642
689
|
this.#setBatch.splice(0, n)
|
|
690
|
+
|
|
691
|
+
if (this.#lockSet && this.#lockArray) {
|
|
692
|
+
for (const idx of this.#lockSet) {
|
|
693
|
+
Atomics.add(this.#lockArray, idx, 1)
|
|
694
|
+
Atomics.notify(this.#lockArray, idx)
|
|
695
|
+
}
|
|
696
|
+
this.#lockSet.clear()
|
|
697
|
+
}
|
|
643
698
|
break
|
|
644
699
|
} catch (err) {
|
|
645
700
|
// ROLLBACK is required: a failed statement leaves the connection with
|
|
@@ -712,8 +767,8 @@ export class Cache extends EventEmi
|
|
|
712
767
|
}
|
|
713
768
|
|
|
714
769
|
// Returns lock_acquired timestamp (number) when contended, or a string status.
|
|
715
|
-
#tryAcquireLock(key ) {
|
|
716
|
-
if (this.#lockAcquireQuery === null) {
|
|
770
|
+
#tryAcquireLock(key , lockIdx ) {
|
|
771
|
+
if (this.#lockAcquireQuery === null || this.#lockId == null) {
|
|
717
772
|
return 'unavailable'
|
|
718
773
|
}
|
|
719
774
|
|
|
@@ -729,6 +784,9 @@ export class Cache extends EventEmi
|
|
|
729
784
|
}
|
|
730
785
|
|
|
731
786
|
if (row.lock_owner === this.#lockId) {
|
|
787
|
+
if (this.#lockArray) {
|
|
788
|
+
Atomics.add(this.#lockArray, lockIdx, 1)
|
|
789
|
+
}
|
|
732
790
|
return 'acquired'
|
|
733
791
|
}
|
|
734
792
|
|
|
@@ -739,6 +797,9 @@ export class Cache extends EventEmi
|
|
|
739
797
|
// process wins if multiple try to steal concurrently.
|
|
740
798
|
const stealResult = this.#lockStealQuery .run(now, this.#lockId, key, lockedAt)
|
|
741
799
|
if (stealResult.changes === 1) {
|
|
800
|
+
if (this.#lockArray) {
|
|
801
|
+
Atomics.add(this.#lockArray, lockIdx, 1)
|
|
802
|
+
}
|
|
742
803
|
return 'acquired'
|
|
743
804
|
}
|
|
744
805
|
|
|
@@ -779,16 +840,29 @@ export class Cache extends EventEmi
|
|
|
779
840
|
// If no result found, take over by calling valueSelector ourselves.
|
|
780
841
|
// selfPromise is the exact promise stored in #dedupe, used for identity checks
|
|
781
842
|
// to avoid double valueSelector calls after delete()+get() races.
|
|
782
|
-
async #waitForValue(
|
|
843
|
+
async #waitForValue(
|
|
844
|
+
args ,
|
|
845
|
+
key ,
|
|
846
|
+
lockIdx ,
|
|
847
|
+
lockVal ,
|
|
848
|
+
lockedAt ,
|
|
849
|
+
selfPromise ,
|
|
850
|
+
) {
|
|
851
|
+
// Wait for estimated completion: locked_at + our EMA-based timeout.
|
|
783
852
|
// Loop: wait for lock holder to write a value, retry if lock was stolen by another process.
|
|
784
853
|
for (let retries = 0; retries < 2 && this.#dedupe.get(key) === selfPromise; retries++) {
|
|
785
|
-
//
|
|
786
|
-
const waitTime = Math.
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
})
|
|
854
|
+
// Add 5% jitter to reduce thundering herd on contention
|
|
855
|
+
const waitTime = Math.ceil(
|
|
856
|
+
Math.max(0, lockedAt + this.#lockTimeout - Date.now()) * (1 + Math.random() * 0.05),
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
if (this.#lockArray != null && lockIdx >= 0) {
|
|
860
|
+
const { async, value } = Atomics.waitAsync(this.#lockArray, lockIdx, lockVal, waitTime)
|
|
861
|
+
if (async) {
|
|
862
|
+
await value
|
|
863
|
+
}
|
|
864
|
+
} else {
|
|
865
|
+
await delay(waitTime, undefined, { ref: false })
|
|
792
866
|
}
|
|
793
867
|
|
|
794
868
|
// Check if the lock holder wrote a value
|
|
@@ -807,10 +881,10 @@ export class Cache extends EventEmi
|
|
|
807
881
|
}
|
|
808
882
|
|
|
809
883
|
// Try to acquire lock for takeover
|
|
810
|
-
const
|
|
811
|
-
if (typeof
|
|
884
|
+
const lockRes = this.#tryAcquireLock(key, lockIdx)
|
|
885
|
+
if (typeof lockRes === 'number') {
|
|
812
886
|
// Another process holds the lock — wait for them
|
|
813
|
-
lockedAt =
|
|
887
|
+
lockedAt = lockRes
|
|
814
888
|
} else {
|
|
815
889
|
// We acquired the lock or lock is unavailable — do the work
|
|
816
890
|
break
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxtedition/cache",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -30,5 +30,9 @@
|
|
|
30
30
|
"tsd": "^0.33.0",
|
|
31
31
|
"typescript": "^5.9.3"
|
|
32
32
|
},
|
|
33
|
-
"gitHead": "
|
|
33
|
+
"gitHead": "89b5cbdea4feddf44ef7955ba2b60105dea996f0",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@nxtedition/shared": "^5.1.10",
|
|
36
|
+
"xxhash-wasm": "^1.1.0"
|
|
37
|
+
}
|
|
34
38
|
}
|