@nxtedition/cache 2.1.9 → 2.1.11
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 +143 -60
- 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,16 @@ export class Cache extends EventEmi
|
|
|
92
98
|
#stale
|
|
93
99
|
#serializer
|
|
94
100
|
|
|
95
|
-
#
|
|
101
|
+
#lockOrigin = 0
|
|
102
|
+
#lockId = null
|
|
103
|
+
#lockArray = null
|
|
104
|
+
#lockSet
|
|
96
105
|
#lockVar = 0
|
|
97
106
|
#lockMean = 5
|
|
98
107
|
#lockTimeout = 10
|
|
99
108
|
#lockMinTimeout = 1
|
|
100
109
|
#lockMaxTimeout = 1_000
|
|
110
|
+
|
|
101
111
|
#flushHandle = null
|
|
102
112
|
#location
|
|
103
113
|
#databaseTimeout = 20
|
|
@@ -113,9 +123,10 @@ export class Cache extends EventEmi
|
|
|
113
123
|
#pageCountQuery = null
|
|
114
124
|
#pageSizeQuery = null
|
|
115
125
|
#setQuery = null
|
|
116
|
-
#setBatch
|
|
126
|
+
#setBatch =
|
|
127
|
+
[]
|
|
117
128
|
|
|
118
|
-
#emitError(err )
|
|
129
|
+
#emitError = (err ) => {
|
|
119
130
|
if (this.listenerCount('error') > 0) {
|
|
120
131
|
this.emit('error', err)
|
|
121
132
|
} else {
|
|
@@ -164,27 +175,42 @@ export class Cache extends EventEmi
|
|
|
164
175
|
}
|
|
165
176
|
|
|
166
177
|
if (opts?.lock === false || opts?.lock === null) {
|
|
178
|
+
this.#lockId = null
|
|
179
|
+
this.#lockArray = null
|
|
180
|
+
this.#lockSet = null
|
|
167
181
|
this.#lockMinTimeout = -1
|
|
168
182
|
this.#lockTimeout = -1
|
|
169
183
|
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')
|
|
184
|
+
} else {
|
|
185
|
+
if (opts?.lock !== undefined) {
|
|
186
|
+
if (typeof opts.lock !== 'object' || opts.lock === null) {
|
|
187
|
+
throw new TypeError('lock must be an object')
|
|
177
188
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
189
|
+
if (opts.lock.minTimeout !== undefined) {
|
|
190
|
+
if (typeof opts.lock.minTimeout !== 'number') {
|
|
191
|
+
throw new TypeError('lock.minTimeout must be a number')
|
|
192
|
+
}
|
|
193
|
+
this.#lockMinTimeout = Math.max(0, opts.lock.minTimeout)
|
|
194
|
+
this.#lockTimeout = this.#lockMinTimeout
|
|
195
|
+
this.#lockMean = this.#lockTimeout / 1.2
|
|
196
|
+
}
|
|
197
|
+
if (opts.lock.maxTimeout !== undefined) {
|
|
198
|
+
if (typeof opts.lock.maxTimeout !== 'number') {
|
|
199
|
+
throw new TypeError('lock.maxTimeout must be a number')
|
|
200
|
+
}
|
|
201
|
+
this.#lockMaxTimeout = Math.max(this.#lockMinTimeout, opts.lock.maxTimeout)
|
|
185
202
|
}
|
|
186
|
-
this.#lockMaxTimeout = Math.max(this.#lockMinTimeout, opts.lock.maxTimeout)
|
|
187
203
|
}
|
|
204
|
+
|
|
205
|
+
const sab = getOrCreate(`__@nxtedition/cache/${location}`, 8 + 1024)
|
|
206
|
+
|
|
207
|
+
const originArray = new BigInt64Array(sab, 0, 1)
|
|
208
|
+
Atomics.compareExchange(originArray, 0, 0n, BigInt(Date.now()))
|
|
209
|
+
|
|
210
|
+
this.#lockOrigin = Number(originArray[0])
|
|
211
|
+
this.#lockId = randomUUID()
|
|
212
|
+
this.#lockArray = new Int32Array(sab, 8)
|
|
213
|
+
this.#lockSet = new Set()
|
|
188
214
|
}
|
|
189
215
|
|
|
190
216
|
if (opts?.serializer !== undefined) {
|
|
@@ -227,12 +253,6 @@ export class Cache extends EventEmi
|
|
|
227
253
|
) WITHOUT ROWID;
|
|
228
254
|
|
|
229
255
|
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
256
|
`)
|
|
237
257
|
|
|
238
258
|
this.#getQuery = this.#database.prepare(
|
|
@@ -249,21 +269,32 @@ export class Cache extends EventEmi
|
|
|
249
269
|
`DELETE FROM cache_v${VERSION} WHERE key IN (SELECT key FROM cache_v${VERSION} ORDER BY stale ASC LIMIT ?)`,
|
|
250
270
|
)
|
|
251
271
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
272
|
+
if (this.#lockId != null) {
|
|
273
|
+
this.#database.exec(`
|
|
274
|
+
CREATE TABLE IF NOT EXISTS cache_lock_v${VERSION} (
|
|
275
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
276
|
+
lock_acquired INTEGER NOT NULL,
|
|
277
|
+
lock_owner TEXT NOT NULL
|
|
278
|
+
);
|
|
279
|
+
`)
|
|
280
|
+
|
|
281
|
+
// ON CONFLICT refreshes lock_acquired when we are the owner,
|
|
282
|
+
// preventing other processes from stealing our still-active lock.
|
|
283
|
+
this.#lockAcquireQuery = this.#database.prepare(
|
|
284
|
+
`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`,
|
|
285
|
+
)
|
|
286
|
+
this.#lockStealQuery = this.#database.prepare(
|
|
287
|
+
`UPDATE cache_lock_v${VERSION} SET lock_acquired = ?, lock_owner = ? WHERE key = ? AND lock_acquired = ?`,
|
|
288
|
+
)
|
|
289
|
+
// Read the current lock state after a failed steal to get the winner's timestamp.
|
|
290
|
+
this.#lockGetQuery = this.#database.prepare(
|
|
291
|
+
`SELECT lock_acquired FROM cache_lock_v${VERSION} WHERE key = ?`,
|
|
292
|
+
)
|
|
293
|
+
this.#lockPurgeQuery = this.#database.prepare(
|
|
294
|
+
`DELETE FROM cache_lock_v${VERSION} WHERE lock_acquired <= ?`,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
267
298
|
this.#pageCountQuery = this.#database.prepare('PRAGMA page_count')
|
|
268
299
|
this.#pageSizeQuery = this.#database.prepare('PRAGMA page_size')
|
|
269
300
|
break
|
|
@@ -497,15 +528,16 @@ export class Cache extends EventEmi
|
|
|
497
528
|
throw new TypeError('keySelector must return a non-empty string')
|
|
498
529
|
}
|
|
499
530
|
|
|
500
|
-
if (this.#
|
|
531
|
+
if (this.#lockId != null) {
|
|
501
532
|
// Another process holds the lock — wait for the value instead of calling valueSelector.
|
|
502
533
|
// Use deferred pattern so #waitForValue can check promise identity to detect delete+get races.
|
|
503
|
-
const
|
|
534
|
+
const lockIdx = this.#lockArray ? HASHER.h32(key) % this.#lockArray.length : -1
|
|
535
|
+
const lockResult = this.#tryAcquireLock(key, lockIdx)
|
|
504
536
|
if (typeof lockResult === '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, lockResult, promise).then(resolve, reject)
|
|
540
|
+
this.#waitForValue(args, key, lockIdx, lockResult, 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.store(this.#lockArray, idx, 0)
|
|
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
|
|
@@ -660,6 +715,11 @@ export class Cache extends EventEmi
|
|
|
660
715
|
) {
|
|
661
716
|
this.#evictQuery.run(256)
|
|
662
717
|
} else {
|
|
718
|
+
// Intentional: drop the rolled-back items from the batch and surface
|
|
719
|
+
// the error via #emitError below. The corresponding entries remain in
|
|
720
|
+
// #memory until natural eviction/TTL, which is fine for cache semantics
|
|
721
|
+
// — callers already accept that cache values can disappear at any time.
|
|
722
|
+
// Do NOT flag this as silent data loss (see closed issue #167).
|
|
663
723
|
this.#setBatch.splice(0, n)
|
|
664
724
|
throw err
|
|
665
725
|
}
|
|
@@ -707,8 +767,8 @@ export class Cache extends EventEmi
|
|
|
707
767
|
}
|
|
708
768
|
|
|
709
769
|
// Returns lock_acquired timestamp (number) when contended, or a string status.
|
|
710
|
-
#tryAcquireLock(key ) {
|
|
711
|
-
if (this.#lockAcquireQuery === null) {
|
|
770
|
+
#tryAcquireLock(key , lockIdx ) {
|
|
771
|
+
if (this.#lockAcquireQuery === null || this.#lockId == null) {
|
|
712
772
|
return 'unavailable'
|
|
713
773
|
}
|
|
714
774
|
|
|
@@ -724,6 +784,9 @@ export class Cache extends EventEmi
|
|
|
724
784
|
}
|
|
725
785
|
|
|
726
786
|
if (row.lock_owner === this.#lockId) {
|
|
787
|
+
if (this.#lockArray && lockIdx >= 0) {
|
|
788
|
+
Atomics.store(this.#lockArray, lockIdx, now - this.#lockOrigin)
|
|
789
|
+
}
|
|
727
790
|
return 'acquired'
|
|
728
791
|
}
|
|
729
792
|
|
|
@@ -734,6 +797,9 @@ export class Cache extends EventEmi
|
|
|
734
797
|
// process wins if multiple try to steal concurrently.
|
|
735
798
|
const stealResult = this.#lockStealQuery .run(now, this.#lockId, key, lockedAt)
|
|
736
799
|
if (stealResult.changes === 1) {
|
|
800
|
+
if (this.#lockArray && lockIdx >= 0) {
|
|
801
|
+
Atomics.store(this.#lockArray, lockIdx, now - this.#lockOrigin)
|
|
802
|
+
}
|
|
737
803
|
return 'acquired'
|
|
738
804
|
}
|
|
739
805
|
|
|
@@ -774,16 +840,33 @@ export class Cache extends EventEmi
|
|
|
774
840
|
// If no result found, take over by calling valueSelector ourselves.
|
|
775
841
|
// selfPromise is the exact promise stored in #dedupe, used for identity checks
|
|
776
842
|
// to avoid double valueSelector calls after delete()+get() races.
|
|
777
|
-
async #waitForValue(
|
|
843
|
+
async #waitForValue(
|
|
844
|
+
args ,
|
|
845
|
+
key ,
|
|
846
|
+
lockIdx ,
|
|
847
|
+
lockedAt ,
|
|
848
|
+
selfPromise ,
|
|
849
|
+
) {
|
|
850
|
+
// Wait for estimated completion: locked_at + our EMA-based timeout.
|
|
778
851
|
// Loop: wait for lock holder to write a value, retry if lock was stolen by another process.
|
|
779
852
|
for (let retries = 0; retries < 2 && this.#dedupe.get(key) === selfPromise; retries++) {
|
|
780
|
-
//
|
|
781
|
-
const waitTime = Math.
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
}
|
|
853
|
+
// Add 5% jitter to reduce thundering herd on contention
|
|
854
|
+
const waitTime = Math.ceil(
|
|
855
|
+
Math.max(0, lockedAt + this.#lockTimeout - Date.now()) * (1 + Math.random() * 0.05),
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
if (this.#lockArray != null && lockIdx >= 0) {
|
|
859
|
+
const { async, value } = Atomics.waitAsync(
|
|
860
|
+
this.#lockArray,
|
|
861
|
+
lockIdx,
|
|
862
|
+
lockedAt - this.#lockOrigin,
|
|
863
|
+
waitTime,
|
|
864
|
+
)
|
|
865
|
+
if (async) {
|
|
866
|
+
await value
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
await delay(waitTime, undefined, { ref: false })
|
|
787
870
|
}
|
|
788
871
|
|
|
789
872
|
// Check if the lock holder wrote a value
|
|
@@ -802,7 +885,7 @@ export class Cache extends EventEmi
|
|
|
802
885
|
}
|
|
803
886
|
|
|
804
887
|
// Try to acquire lock for takeover
|
|
805
|
-
const lockResult = this.#tryAcquireLock(key)
|
|
888
|
+
const lockResult = this.#tryAcquireLock(key, lockIdx)
|
|
806
889
|
if (typeof lockResult === 'number') {
|
|
807
890
|
// Another process holds the lock — wait for them
|
|
808
891
|
lockedAt = lockResult
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxtedition/cache",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.11",
|
|
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": "46cd2017c0b9d238f2509e3612afbdb11fbefb69",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@nxtedition/shared": "^5.1.10",
|
|
36
|
+
"xxhash-wasm": "^1.1.0"
|
|
37
|
+
}
|
|
34
38
|
}
|