@nxtedition/cache 2.0.1 → 2.0.2
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 +4 -4
- package/lib/index.d.ts +7 -3
- package/lib/index.js +308 -92
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -14,9 +14,9 @@ An async cache with SQLite persistence, in-memory LRU, stale-while-revalidate, a
|
|
|
14
14
|
## Usage
|
|
15
15
|
|
|
16
16
|
```ts
|
|
17
|
-
import {
|
|
17
|
+
import { Cache } from '@nxtedition/cache'
|
|
18
18
|
|
|
19
|
-
const cache = new
|
|
19
|
+
const cache = new Cache(
|
|
20
20
|
'./my-cache.db', // SQLite file path, or ':memory:'
|
|
21
21
|
async (id: string) => {
|
|
22
22
|
// fetch the value for this key
|
|
@@ -43,14 +43,14 @@ if (result.async) {
|
|
|
43
43
|
|
|
44
44
|
## API
|
|
45
45
|
|
|
46
|
-
### `new
|
|
46
|
+
### `new Cache(location, valueSelector, keySelector, opts?)`
|
|
47
47
|
|
|
48
48
|
| Parameter | Type | Description |
|
|
49
49
|
| --------------- | ------------------------------ | --------------------------------------------- |
|
|
50
50
|
| `location` | `string` | SQLite database path, or `':memory:'` |
|
|
51
51
|
| `valueSelector` | `(...args) => V \| Promise<V>` | Function to fetch a value on cache miss |
|
|
52
52
|
| `keySelector` | `(...args) => string` | Function to derive a cache key from arguments |
|
|
53
|
-
| `opts` | `
|
|
53
|
+
| `opts` | `CacheOptions<V>` | Optional configuration |
|
|
54
54
|
|
|
55
55
|
#### Options
|
|
56
56
|
|
package/lib/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export interface DatabaseOptions {
|
|
|
4
4
|
timeout?: number;
|
|
5
5
|
maxSize?: number;
|
|
6
6
|
}
|
|
7
|
-
export interface
|
|
7
|
+
export interface CacheOptions<V> {
|
|
8
8
|
ttl?: number | ((value: V, key: string) => number);
|
|
9
9
|
stale?: number | ((value: V, key: string) => number);
|
|
10
10
|
memory?: MemoryOptions | false | null;
|
|
@@ -17,9 +17,9 @@ export type CacheResult<V> = {
|
|
|
17
17
|
value: Promise<V>;
|
|
18
18
|
async: true;
|
|
19
19
|
};
|
|
20
|
-
export declare class
|
|
20
|
+
export declare class Cache<V = unknown, A extends unknown[] = unknown[]> {
|
|
21
21
|
#private;
|
|
22
|
-
constructor(location: string, valueSelector: (...args: A) => V |
|
|
22
|
+
constructor(location: string, valueSelector: (...args: A) => V | PromiseLike<V>, keySelector: (...args: A) => string, opts?: CacheOptions<V>);
|
|
23
23
|
close(): void;
|
|
24
24
|
get(...args: A): CacheResult<V>;
|
|
25
25
|
peek(...args: A): CacheResult<V>;
|
|
@@ -27,3 +27,7 @@ export declare class AsyncCache<V = unknown, A extends unknown[] = unknown[]> {
|
|
|
27
27
|
delete(...args: A): void;
|
|
28
28
|
purgeStale(): void;
|
|
29
29
|
}
|
|
30
|
+
/** @deprecated Use `Cache` instead. */
|
|
31
|
+
export declare const AsyncCache: typeof Cache;
|
|
32
|
+
/** @deprecated Use `CacheOptions` instead. */
|
|
33
|
+
export type AsyncCacheOptions<V> = CacheOptions<V>;
|
package/lib/index.js
CHANGED
|
@@ -1,22 +1,16 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
1
2
|
import { DatabaseSync, } from 'node:sqlite'
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
2
4
|
import { MemoryCache, } from "./memory.js"
|
|
3
5
|
|
|
4
6
|
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
function noop() {}
|
|
7
9
|
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
fastNowTime = Math.floor(Date.now() / 1e3) * 1e3
|
|
11
|
-
setInterval(() => {
|
|
12
|
-
fastNowTime = Math.floor(Date.now() / 1e3) * 1e3
|
|
13
|
-
}, 1e3).unref()
|
|
14
|
-
}
|
|
15
|
-
return fastNowTime
|
|
10
|
+
function isThenable(value ) {
|
|
11
|
+
return value != null && typeof (value ).then === 'function'
|
|
16
12
|
}
|
|
17
13
|
|
|
18
|
-
function noop() {}
|
|
19
|
-
|
|
20
14
|
const dbs = new Set ()
|
|
21
15
|
|
|
22
16
|
{
|
|
@@ -50,7 +44,7 @@ const dbs = new Set ()
|
|
|
50
44
|
|
|
51
45
|
|
|
52
46
|
|
|
53
|
-
|
|
47
|
+
|
|
54
48
|
|
|
55
49
|
|
|
56
50
|
|
|
@@ -61,31 +55,41 @@ const dbs = new Set ()
|
|
|
61
55
|
|
|
62
56
|
|
|
63
57
|
|
|
64
|
-
const VERSION =
|
|
58
|
+
const VERSION = 4
|
|
65
59
|
const MAX_DURATION = 365000000e3
|
|
66
60
|
|
|
67
|
-
export class
|
|
61
|
+
export class Cache {
|
|
68
62
|
#memory
|
|
69
63
|
#dedupe = new Map ()
|
|
64
|
+
#closed = false
|
|
70
65
|
|
|
71
|
-
#valueSelector
|
|
66
|
+
#valueSelector
|
|
72
67
|
#keySelector
|
|
73
68
|
|
|
74
69
|
#ttl
|
|
75
70
|
#stale
|
|
76
71
|
|
|
72
|
+
#lockMean = 5
|
|
73
|
+
#lockVar = 0
|
|
74
|
+
#lockTimeout = 10
|
|
75
|
+
#lockId = randomUUID()
|
|
76
|
+
|
|
77
77
|
#database = null
|
|
78
78
|
#getQuery = null
|
|
79
79
|
#setQuery = null
|
|
80
80
|
#delQuery = null
|
|
81
81
|
#purgeStaleQuery = null
|
|
82
82
|
#evictQuery = null
|
|
83
|
+
#lockAcquireQuery = null
|
|
84
|
+
#lockStealQuery = null
|
|
85
|
+
#lockGetQuery = null
|
|
86
|
+
#lockPurgeQuery = null
|
|
83
87
|
|
|
84
88
|
constructor(
|
|
85
89
|
location ,
|
|
86
|
-
valueSelector
|
|
90
|
+
valueSelector ,
|
|
87
91
|
keySelector ,
|
|
88
|
-
opts
|
|
92
|
+
opts ,
|
|
89
93
|
) {
|
|
90
94
|
if (typeof location !== 'string') {
|
|
91
95
|
throw new TypeError('location must be a string')
|
|
@@ -141,6 +145,12 @@ export class AsyncCache {
|
|
|
141
145
|
ttl INTEGER NOT NULL,
|
|
142
146
|
stale INTEGER NOT NULL
|
|
143
147
|
);
|
|
148
|
+
|
|
149
|
+
CREATE TABLE IF NOT EXISTS cache_lock_v${VERSION} (
|
|
150
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
151
|
+
lock_acquired INTEGER NOT NULL,
|
|
152
|
+
lock_owner TEXT NOT NULL
|
|
153
|
+
);
|
|
144
154
|
`)
|
|
145
155
|
|
|
146
156
|
{
|
|
@@ -163,6 +173,22 @@ export class AsyncCache {
|
|
|
163
173
|
this.#evictQuery = this.#database.prepare(
|
|
164
174
|
`DELETE FROM cache_v${VERSION} WHERE key IN (SELECT key FROM cache_v${VERSION} ORDER BY stale ASC LIMIT ?)`,
|
|
165
175
|
)
|
|
176
|
+
|
|
177
|
+
// Bug A fix: ON CONFLICT refreshes lock_acquired when we are the owner,
|
|
178
|
+
// preventing other processes from stealing our still-active lock.
|
|
179
|
+
this.#lockAcquireQuery = this.#database.prepare(
|
|
180
|
+
`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`,
|
|
181
|
+
)
|
|
182
|
+
this.#lockStealQuery = this.#database.prepare(
|
|
183
|
+
`UPDATE cache_lock_v${VERSION} SET lock_acquired = ?, lock_owner = ? WHERE key = ? AND lock_acquired = ?`,
|
|
184
|
+
)
|
|
185
|
+
// Bug B fix: read the current lock state after a failed steal to get the winner's timestamp.
|
|
186
|
+
this.#lockGetQuery = this.#database.prepare(
|
|
187
|
+
`SELECT lock_acquired FROM cache_lock_v${VERSION} WHERE key = ?`,
|
|
188
|
+
)
|
|
189
|
+
this.#lockPurgeQuery = this.#database.prepare(
|
|
190
|
+
`DELETE FROM cache_lock_v${VERSION} WHERE lock_acquired <= ?`,
|
|
191
|
+
)
|
|
166
192
|
break
|
|
167
193
|
} catch (err) {
|
|
168
194
|
if (n >= 16) {
|
|
@@ -174,6 +200,10 @@ export class AsyncCache {
|
|
|
174
200
|
this.#delQuery = null
|
|
175
201
|
this.#purgeStaleQuery = null
|
|
176
202
|
this.#evictQuery = null
|
|
203
|
+
this.#lockAcquireQuery = null
|
|
204
|
+
this.#lockStealQuery = null
|
|
205
|
+
this.#lockGetQuery = null
|
|
206
|
+
this.#lockPurgeQuery = null
|
|
177
207
|
|
|
178
208
|
process.emitWarning(err )
|
|
179
209
|
break
|
|
@@ -185,36 +215,59 @@ export class AsyncCache {
|
|
|
185
215
|
}
|
|
186
216
|
|
|
187
217
|
close() {
|
|
218
|
+
this.#closed = true
|
|
219
|
+
this.#dedupe.clear()
|
|
188
220
|
dbs.delete(this)
|
|
221
|
+
|
|
189
222
|
this.#getQuery = null
|
|
190
223
|
this.#setQuery = null
|
|
191
224
|
this.#delQuery = null
|
|
192
225
|
this.#purgeStaleQuery = null
|
|
193
226
|
this.#evictQuery = null
|
|
227
|
+
this.#lockAcquireQuery = null
|
|
228
|
+
this.#lockStealQuery = null
|
|
229
|
+
this.#lockGetQuery = null
|
|
230
|
+
this.#lockPurgeQuery = null
|
|
194
231
|
this.#database?.close()
|
|
195
232
|
this.#database = null
|
|
196
233
|
}
|
|
197
234
|
|
|
198
235
|
get(...args ) {
|
|
236
|
+
if (this.#closed) {
|
|
237
|
+
throw new Error('cache is closed')
|
|
238
|
+
}
|
|
199
239
|
return this.#load(args, true)
|
|
200
240
|
}
|
|
201
241
|
|
|
202
242
|
peek(...args ) {
|
|
243
|
+
if (this.#closed) {
|
|
244
|
+
throw new Error('cache is closed')
|
|
245
|
+
}
|
|
203
246
|
return this.#load(args, false)
|
|
204
247
|
}
|
|
205
248
|
|
|
206
249
|
refresh(...args ) {
|
|
250
|
+
if (this.#closed) {
|
|
251
|
+
throw new Error('cache is closed')
|
|
252
|
+
}
|
|
207
253
|
return this.#refresh(args)
|
|
208
254
|
}
|
|
209
255
|
|
|
210
256
|
delete(...args ) {
|
|
211
|
-
|
|
257
|
+
if (this.#closed) {
|
|
258
|
+
throw new Error('cache is closed')
|
|
259
|
+
}
|
|
260
|
+
this.#set(this.#keySelector(...args), undefined)
|
|
212
261
|
}
|
|
213
262
|
|
|
214
263
|
purgeStale() {
|
|
264
|
+
if (this.#closed) {
|
|
265
|
+
throw new Error('cache is closed')
|
|
266
|
+
}
|
|
215
267
|
try {
|
|
216
|
-
this.#memory?.purgeStale(
|
|
217
|
-
this.#purgeStaleQuery?.run(
|
|
268
|
+
this.#memory?.purgeStale(Date.now())
|
|
269
|
+
this.#purgeStaleQuery?.run(Date.now())
|
|
270
|
+
this.#lockPurgeQuery?.run(Date.now() - 3_600_000)
|
|
218
271
|
} catch (err) {
|
|
219
272
|
process.emitWarning(err )
|
|
220
273
|
}
|
|
@@ -227,7 +280,7 @@ export class AsyncCache {
|
|
|
227
280
|
throw new TypeError('keySelector must return a non-empty string')
|
|
228
281
|
}
|
|
229
282
|
|
|
230
|
-
const now =
|
|
283
|
+
const now = Date.now()
|
|
231
284
|
|
|
232
285
|
let cached = this.#memory?.get(key)
|
|
233
286
|
|
|
@@ -235,24 +288,8 @@ export class AsyncCache {
|
|
|
235
288
|
try {
|
|
236
289
|
const row = this.#getQuery?.get(key, now)
|
|
237
290
|
if (row !== undefined) {
|
|
238
|
-
const entry
|
|
239
|
-
ttl: row.ttl,
|
|
240
|
-
stale: row.stale,
|
|
241
|
-
value: ArrayBuffer.isView(row.val)
|
|
242
|
-
? (Buffer.from(
|
|
243
|
-
row.val.buffer,
|
|
244
|
-
row.val.byteOffset,
|
|
245
|
-
row.val.byteLength,
|
|
246
|
-
) )
|
|
247
|
-
: JSON.parse(row.val),
|
|
248
|
-
key,
|
|
249
|
-
size:
|
|
250
|
-
(ArrayBuffer.isView(row.val) ? row.val.byteLength : row.val.length) + key.length + 64,
|
|
251
|
-
index: -1,
|
|
252
|
-
counter: -1,
|
|
253
|
-
}
|
|
291
|
+
const entry = this.#loadRow(key, row)
|
|
254
292
|
this.#memory?.set(key, entry)
|
|
255
|
-
|
|
256
293
|
cached = entry
|
|
257
294
|
}
|
|
258
295
|
} catch (err) {
|
|
@@ -278,7 +315,19 @@ export class AsyncCache {
|
|
|
278
315
|
}
|
|
279
316
|
}
|
|
280
317
|
|
|
281
|
-
|
|
318
|
+
let result
|
|
319
|
+
if (refresh) {
|
|
320
|
+
try {
|
|
321
|
+
result = this.#refresh(args, key)
|
|
322
|
+
} catch (err) {
|
|
323
|
+
if (cached !== undefined) {
|
|
324
|
+
return { value: cached.value, async: false }
|
|
325
|
+
}
|
|
326
|
+
throw err
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
result = { value: undefined, async: false }
|
|
330
|
+
}
|
|
282
331
|
|
|
283
332
|
if (result.async && cached !== undefined) {
|
|
284
333
|
return { value: cached.value, async: false }
|
|
@@ -292,68 +341,94 @@ export class AsyncCache {
|
|
|
292
341
|
throw new TypeError('keySelector must return a non-empty string')
|
|
293
342
|
}
|
|
294
343
|
|
|
295
|
-
// TODO (fix): cross process/thread dedupe...
|
|
296
344
|
const existing = this.#dedupe.get(key)
|
|
297
345
|
if (existing !== undefined) {
|
|
298
346
|
return { async: true, value: existing }
|
|
299
347
|
}
|
|
300
348
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
return { async: false, value }
|
|
349
|
+
// Another process holds the lock — wait for the value instead of calling valueSelector.
|
|
350
|
+
// Use deferred pattern so #waitForValue can check promise identity to detect delete+get races.
|
|
351
|
+
const lockResult = this.#tryAcquireLock(key)
|
|
352
|
+
if (typeof lockResult === 'number') {
|
|
353
|
+
const { promise, resolve, reject } = Promise.withResolvers ()
|
|
354
|
+
promise.catch(noop)
|
|
355
|
+
this.#dedupe.set(key, promise)
|
|
356
|
+
this.#waitForValue(args, key, lockResult, promise).then(resolve, reject)
|
|
357
|
+
return { async: true, value: promise }
|
|
311
358
|
}
|
|
312
359
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
this.#delete(key)
|
|
320
|
-
} else {
|
|
360
|
+
const res = this.#fetch(args)
|
|
361
|
+
|
|
362
|
+
if (res.async) {
|
|
363
|
+
const promise = res.value.then(
|
|
364
|
+
(value) => {
|
|
365
|
+
if (this.#dedupe.get(key) === promise) {
|
|
321
366
|
this.#set(key, value)
|
|
322
367
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
368
|
+
return value
|
|
369
|
+
},
|
|
370
|
+
(err) => {
|
|
371
|
+
if (this.#dedupe.get(key) === promise) {
|
|
372
|
+
this.#dedupe.delete(key)
|
|
373
|
+
}
|
|
374
|
+
throw err
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
promise.catch(noop)
|
|
378
|
+
this.#dedupe.set(key, promise)
|
|
379
|
+
return { async: true, value: promise }
|
|
380
|
+
}
|
|
335
381
|
|
|
336
|
-
|
|
382
|
+
this.#set(key, res.value)
|
|
383
|
+
return res
|
|
337
384
|
}
|
|
338
385
|
|
|
339
|
-
#
|
|
386
|
+
#fetch(args ) {
|
|
387
|
+
const startTime = performance.now()
|
|
388
|
+
const value = this.#valueSelector(...args)
|
|
389
|
+
|
|
390
|
+
if (isThenable(value)) {
|
|
391
|
+
return {
|
|
392
|
+
async: true,
|
|
393
|
+
value: Promise.resolve(value).then((value) => {
|
|
394
|
+
this.#updateLockTimeout(performance.now() - startTime)
|
|
395
|
+
return value
|
|
396
|
+
}),
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
this.#updateLockTimeout(performance.now() - startTime)
|
|
401
|
+
return { async: false, value }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
#set(key , value ) {
|
|
340
405
|
if (typeof key !== 'string' || key.length === 0) {
|
|
341
406
|
throw new TypeError('key must be a non-empty string')
|
|
342
407
|
}
|
|
343
408
|
|
|
344
409
|
this.#dedupe.delete(key)
|
|
345
410
|
|
|
411
|
+
if (value === undefined) {
|
|
412
|
+
this.#memory?.delete(key)
|
|
413
|
+
try {
|
|
414
|
+
this.#delQuery?.run(key)
|
|
415
|
+
} catch (err) {
|
|
416
|
+
process.emitWarning(err )
|
|
417
|
+
}
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
346
421
|
const ttlValue = Math.min(MAX_DURATION, this.#ttl(value, key) ?? Infinity)
|
|
347
422
|
if (!Number.isFinite(ttlValue) || ttlValue < 0) {
|
|
348
423
|
throw new TypeError('ttl must be nully or a positive integer')
|
|
349
424
|
}
|
|
350
425
|
|
|
351
|
-
const staleValue = Math.min(MAX_DURATION, this.#stale(value, key) ??
|
|
426
|
+
const staleValue = Math.min(MAX_DURATION, this.#stale(value, key) ?? Number.MAX_SAFE_INTEGER)
|
|
352
427
|
if (!Number.isFinite(staleValue) || staleValue < 0) {
|
|
353
428
|
throw new TypeError('stale must be nully or a positive integer')
|
|
354
429
|
}
|
|
355
430
|
|
|
356
|
-
const now =
|
|
431
|
+
const now = Date.now()
|
|
357
432
|
const ttl = now + ttlValue
|
|
358
433
|
const stale = ttl + staleValue
|
|
359
434
|
|
|
@@ -361,21 +436,16 @@ export class AsyncCache {
|
|
|
361
436
|
return
|
|
362
437
|
}
|
|
363
438
|
|
|
364
|
-
const data
|
|
365
|
-
? value
|
|
366
|
-
: JSON.stringify(value )
|
|
439
|
+
const data = ArrayBuffer.isView(value) ? value : JSON.stringify(value )
|
|
367
440
|
|
|
368
|
-
this.#
|
|
441
|
+
const entry = this.#createEntry(
|
|
442
|
+
key,
|
|
443
|
+
value,
|
|
369
444
|
ttl,
|
|
370
445
|
stale,
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
key,
|
|
375
|
-
size: (ArrayBuffer.isView(data) ? data.byteLength : data.length) + key.length + 64,
|
|
376
|
-
index: -1,
|
|
377
|
-
counter: -1,
|
|
378
|
-
})
|
|
446
|
+
ArrayBuffer.isView(data) ? data.byteLength : data.length * 2,
|
|
447
|
+
)
|
|
448
|
+
this.#memory?.set(key, entry)
|
|
379
449
|
|
|
380
450
|
try {
|
|
381
451
|
this.#setQuery?.run(key, data , ttl, stale)
|
|
@@ -393,17 +463,163 @@ export class AsyncCache {
|
|
|
393
463
|
}
|
|
394
464
|
}
|
|
395
465
|
|
|
396
|
-
#
|
|
397
|
-
|
|
398
|
-
|
|
466
|
+
#createEntry(
|
|
467
|
+
key ,
|
|
468
|
+
value ,
|
|
469
|
+
ttl ,
|
|
470
|
+
stale ,
|
|
471
|
+
size ,
|
|
472
|
+
) {
|
|
473
|
+
return {
|
|
474
|
+
ttl,
|
|
475
|
+
stale,
|
|
476
|
+
value: ArrayBuffer.isView(value)
|
|
477
|
+
? (Buffer.from(value.buffer, value.byteOffset, value.byteLength) )
|
|
478
|
+
: value,
|
|
479
|
+
key,
|
|
480
|
+
size,
|
|
481
|
+
index: -1,
|
|
482
|
+
counter: -1,
|
|
399
483
|
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
#loadRow(key , row ) {
|
|
487
|
+
const value = (ArrayBuffer.isView(row.val) ? row.val : JSON.parse(row.val))
|
|
488
|
+
return this.#createEntry(
|
|
489
|
+
key,
|
|
490
|
+
value,
|
|
491
|
+
row.ttl,
|
|
492
|
+
row.stale,
|
|
493
|
+
ArrayBuffer.isView(row.val) ? row.val.byteLength : row.val.length * 2,
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Returns lock_acquired timestamp (number) when contended, or a string status.
|
|
498
|
+
#tryAcquireLock(key ) {
|
|
499
|
+
if (this.#lockAcquireQuery === null) {
|
|
500
|
+
return 'unavailable'
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const now = Date.now()
|
|
400
504
|
|
|
401
|
-
this.#dedupe.delete(key)
|
|
402
|
-
this.#memory?.delete(key)
|
|
403
505
|
try {
|
|
404
|
-
this.#
|
|
506
|
+
const row = this.#lockAcquireQuery.get(key, now, this.#lockId)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
if (row === undefined) {
|
|
511
|
+
return 'unavailable'
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (row.lock_owner === this.#lockId) {
|
|
515
|
+
return 'acquired'
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const lockedAt = row.lock_acquired
|
|
519
|
+
if (now - lockedAt > this.#lockTimeout * 3) {
|
|
520
|
+
// Lock is stale (3x the EMA-based timeout) — attempt to steal atomically.
|
|
521
|
+
// The WHERE clause matches the exact lock_acquired we observed, so only one
|
|
522
|
+
// process wins if multiple try to steal concurrently.
|
|
523
|
+
const stealResult = this.#lockStealQuery .run(now, this.#lockId, key, lockedAt)
|
|
524
|
+
if (stealResult.changes === 1) {
|
|
525
|
+
return 'acquired'
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Steal failed — another process won the race. Read the winner's lock_acquired
|
|
529
|
+
// so the caller waits the right amount of time instead of using the stale timestamp.
|
|
530
|
+
const current = this.#lockGetQuery .get(key)
|
|
531
|
+
if (current !== undefined) {
|
|
532
|
+
return current.lock_acquired
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return lockedAt
|
|
405
537
|
} catch (err) {
|
|
406
538
|
process.emitWarning(err )
|
|
539
|
+
return 'unavailable'
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#updateLockTimeout(duration ) {
|
|
544
|
+
// EMA of mean and variance (Welford-style), clamped to [10ms, 1s].
|
|
545
|
+
// Timeout = mean * 1.2 + 3 * stddev: 20% base margin plus 3 standard
|
|
546
|
+
// deviations to accommodate timing variability.
|
|
547
|
+
const alpha = 0.2
|
|
548
|
+
const diff = duration - this.#lockMean
|
|
549
|
+
this.#lockMean += alpha * diff
|
|
550
|
+
this.#lockVar = (1 - alpha) * (this.#lockVar + alpha * diff * diff)
|
|
551
|
+
this.#lockTimeout = Math.max(
|
|
552
|
+
10,
|
|
553
|
+
Math.min(1_000, Math.ceil(this.#lockMean * 1.2 + Math.sqrt(this.#lockVar) * 3)),
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Wait for another process to complete, then check for their result.
|
|
558
|
+
// If no result found, take over by calling valueSelector ourselves.
|
|
559
|
+
// selfPromise is the exact promise stored in #dedupe, used for identity checks
|
|
560
|
+
// to avoid double valueSelector calls after delete()+get() races.
|
|
561
|
+
async #waitForValue(args , key , lockedAt , selfPromise ) {
|
|
562
|
+
// Loop: wait for lock holder to write a value, retry if lock was stolen by another process.
|
|
563
|
+
for (let retries = 0; retries < 2 && this.#dedupe.get(key) === selfPromise; retries++) {
|
|
564
|
+
// Wait for estimated completion: locked_at + our EMA-based timeout.
|
|
565
|
+
const waitTime = Math.max(0, lockedAt + this.#lockTimeout - Date.now())
|
|
566
|
+
if (waitTime > 0) {
|
|
567
|
+
// Add 20% jitter to reduce thundering herd on contention
|
|
568
|
+
await delay(waitTime + Math.floor(Math.random() * waitTime * 0.2), undefined, {
|
|
569
|
+
ref: false,
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Check if the lock holder wrote a value
|
|
574
|
+
try {
|
|
575
|
+
const row = this.#getQuery?.get(key, Date.now())
|
|
576
|
+
if (row !== undefined) {
|
|
577
|
+
if (this.#dedupe.get(key) === selfPromise) {
|
|
578
|
+
this.#dedupe.delete(key)
|
|
579
|
+
}
|
|
580
|
+
const entry = this.#loadRow(key, row)
|
|
581
|
+
this.#memory?.set(key, entry)
|
|
582
|
+
return entry.value
|
|
583
|
+
}
|
|
584
|
+
} catch (err) {
|
|
585
|
+
process.emitWarning(err )
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Try to acquire lock for takeover
|
|
589
|
+
const lockResult = this.#tryAcquireLock(key)
|
|
590
|
+
if (typeof lockResult === 'number') {
|
|
591
|
+
// Another process holds the lock — wait for them
|
|
592
|
+
lockedAt = lockResult
|
|
593
|
+
} else {
|
|
594
|
+
// We acquired the lock or lock is unavailable — do the work
|
|
595
|
+
break
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
{
|
|
600
|
+
const promise = this.#dedupe.get(key)
|
|
601
|
+
if (promise !== undefined && promise !== selfPromise) {
|
|
602
|
+
return promise
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
const res = this.#fetch(args)
|
|
608
|
+
const value = res.async ? await res.value : res.value
|
|
609
|
+
if (this.#dedupe.get(key) === selfPromise) {
|
|
610
|
+
this.#set(key, value)
|
|
611
|
+
}
|
|
612
|
+
return value
|
|
613
|
+
} catch (err) {
|
|
614
|
+
if (this.#dedupe.get(key) === selfPromise) {
|
|
615
|
+
this.#dedupe.delete(key)
|
|
616
|
+
}
|
|
617
|
+
throw err
|
|
407
618
|
}
|
|
408
619
|
}
|
|
409
620
|
}
|
|
621
|
+
|
|
622
|
+
/** @deprecated Use `Cache` instead. */
|
|
623
|
+
export const AsyncCache = Cache
|
|
624
|
+
/** @deprecated Use `CacheOptions` instead. */
|
|
625
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxtedition/cache",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -17,16 +17,18 @@
|
|
|
17
17
|
"build": "rimraf lib && tsc && amaroc ./src/index.ts && mv src/index.js lib/ && amaroc ./src/memory.ts && mv src/memory.js lib/",
|
|
18
18
|
"prepublishOnly": "yarn build",
|
|
19
19
|
"typecheck": "tsc --noEmit",
|
|
20
|
-
"test": "node --test && yarn build",
|
|
21
|
-
"test:ci": "node --test && yarn build",
|
|
22
|
-
"test:
|
|
20
|
+
"test": "node --test '**/*.test.js' && yarn build",
|
|
21
|
+
"test:ci": "node --test '**/*.test.js' && yarn build",
|
|
22
|
+
"test:types": "tsd",
|
|
23
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-include=src/index.ts --test-coverage-include=src/memory.ts --test-coverage-lines=90 --test-coverage-branches=90 --test-coverage-functions=100 '**/*.test.js'"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
26
|
"@types/node": "^25.2.3",
|
|
26
27
|
"amaroc": "^1.0.1",
|
|
27
28
|
"oxlint-tsgolint": "^0.13.0",
|
|
28
29
|
"rimraf": "^6.1.3",
|
|
30
|
+
"tsd": "^0.33.0",
|
|
29
31
|
"typescript": "^5.9.3"
|
|
30
32
|
},
|
|
31
|
-
"gitHead": "
|
|
33
|
+
"gitHead": "f6592f0be62fecc161237610ae6454ec6585548b"
|
|
32
34
|
}
|