@nxtedition/cache 2.0.1 → 2.0.3

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
@@ -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 { AsyncCache } from '@nxtedition/cache'
17
+ import { Cache } from '@nxtedition/cache'
18
18
 
19
- const cache = new AsyncCache(
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 AsyncCache(location, valueSelector, keySelector, opts?)`
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` | `AsyncCacheOptions<V>` | Optional configuration |
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 AsyncCacheOptions<V> {
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,34 @@ export type CacheResult<V> = {
17
17
  value: Promise<V>;
18
18
  async: true;
19
19
  };
20
- export declare class AsyncCache<V = unknown, A extends unknown[] = unknown[]> {
20
+ declare global {
21
+ var __nxt_cache: {
22
+ stats: Cache['stats'];
23
+ }[];
24
+ }
25
+ export declare class Cache<V = unknown, A extends unknown[] = unknown[]> {
21
26
  #private;
22
- constructor(location: string, valueSelector: (...args: A) => V | Promise<V>, keySelector: (...args: A) => string, opts?: AsyncCacheOptions<V>);
27
+ constructor(location: string, valueSelector: (...args: A) => V | PromiseLike<V>, keySelector: (...args: A) => string, opts?: CacheOptions<V>);
28
+ get stats(): {
29
+ lock: {
30
+ timeout: number;
31
+ mean: number;
32
+ stddev: number;
33
+ };
34
+ dedupe: {
35
+ size: number;
36
+ };
37
+ memory: {
38
+ size: number;
39
+ maxSize: number;
40
+ count: number;
41
+ maxCount: number;
42
+ } | undefined;
43
+ database: {
44
+ location: string;
45
+ size: number | undefined;
46
+ } | undefined;
47
+ };
23
48
  close(): void;
24
49
  get(...args: A): CacheResult<V>;
25
50
  peek(...args: A): CacheResult<V>;
@@ -27,3 +52,7 @@ export declare class AsyncCache<V = unknown, A extends unknown[] = unknown[]> {
27
52
  delete(...args: A): void;
28
53
  purgeStale(): void;
29
54
  }
55
+ /** @deprecated Use `Cache` instead. */
56
+ export declare const AsyncCache: typeof Cache;
57
+ /** @deprecated Use `CacheOptions` instead. */
58
+ 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
- let fastNowTime = 0
8
+ function noop() {}
7
9
 
8
- function fastNow() {
9
- if (fastNowTime === 0) {
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,35 +55,56 @@ const dbs = new Set ()
61
55
 
62
56
 
63
57
 
64
- const VERSION = 2
58
+
59
+
60
+
61
+
62
+
63
+ const VERSION = 4
65
64
  const MAX_DURATION = 365000000e3
66
65
 
67
- export class AsyncCache {
66
+ globalThis.__nxt_cache ??= []
67
+
68
+ export class Cache {
68
69
  #memory
69
70
  #dedupe = new Map ()
71
+ #closed = false
70
72
 
71
- #valueSelector
73
+ #valueSelector
72
74
  #keySelector
73
75
 
74
76
  #ttl
75
77
  #stale
76
78
 
79
+ #lockMean = 5
80
+ #lockVar = 0
81
+ #lockTimeout = 10
82
+ #lockId = randomUUID()
83
+
84
+ #location
77
85
  #database = null
78
86
  #getQuery = null
79
87
  #setQuery = null
80
88
  #delQuery = null
81
89
  #purgeStaleQuery = null
82
90
  #evictQuery = null
91
+ #lockAcquireQuery = null
92
+ #lockStealQuery = null
93
+ #lockGetQuery = null
94
+ #lockPurgeQuery = null
95
+ #pageCountQuery = null
96
+ #pageSizeQuery = null
83
97
 
84
98
  constructor(
85
99
  location ,
86
- valueSelector ,
100
+ valueSelector ,
87
101
  keySelector ,
88
- opts ,
102
+ opts ,
89
103
  ) {
90
104
  if (typeof location !== 'string') {
91
105
  throw new TypeError('location must be a string')
92
106
  }
107
+ this.#location = location
93
108
 
94
109
  if (typeof valueSelector !== 'function') {
95
110
  throw new TypeError('valueSelector must be a function')
@@ -141,6 +156,12 @@ export class AsyncCache {
141
156
  ttl INTEGER NOT NULL,
142
157
  stale INTEGER NOT NULL
143
158
  );
159
+
160
+ CREATE TABLE IF NOT EXISTS cache_lock_v${VERSION} (
161
+ key TEXT PRIMARY KEY NOT NULL,
162
+ lock_acquired INTEGER NOT NULL,
163
+ lock_owner TEXT NOT NULL
164
+ );
144
165
  `)
145
166
 
146
167
  {
@@ -163,6 +184,24 @@ export class AsyncCache {
163
184
  this.#evictQuery = this.#database.prepare(
164
185
  `DELETE FROM cache_v${VERSION} WHERE key IN (SELECT key FROM cache_v${VERSION} ORDER BY stale ASC LIMIT ?)`,
165
186
  )
187
+
188
+ // Bug A fix: ON CONFLICT refreshes lock_acquired when we are the owner,
189
+ // preventing other processes from stealing our still-active lock.
190
+ this.#lockAcquireQuery = this.#database.prepare(
191
+ `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`,
192
+ )
193
+ this.#lockStealQuery = this.#database.prepare(
194
+ `UPDATE cache_lock_v${VERSION} SET lock_acquired = ?, lock_owner = ? WHERE key = ? AND lock_acquired = ?`,
195
+ )
196
+ // Bug B fix: read the current lock state after a failed steal to get the winner's timestamp.
197
+ this.#lockGetQuery = this.#database.prepare(
198
+ `SELECT lock_acquired FROM cache_lock_v${VERSION} WHERE key = ?`,
199
+ )
200
+ this.#lockPurgeQuery = this.#database.prepare(
201
+ `DELETE FROM cache_lock_v${VERSION} WHERE lock_acquired <= ?`,
202
+ )
203
+ this.#pageCountQuery = this.#database.prepare('PRAGMA page_count')
204
+ this.#pageSizeQuery = this.#database.prepare('PRAGMA page_size')
166
205
  break
167
206
  } catch (err) {
168
207
  if (n >= 16) {
@@ -174,6 +213,12 @@ export class AsyncCache {
174
213
  this.#delQuery = null
175
214
  this.#purgeStaleQuery = null
176
215
  this.#evictQuery = null
216
+ this.#lockAcquireQuery = null
217
+ this.#lockStealQuery = null
218
+ this.#lockGetQuery = null
219
+ this.#lockPurgeQuery = null
220
+ this.#pageCountQuery = null
221
+ this.#pageSizeQuery = null
177
222
 
178
223
  process.emitWarning(err )
179
224
  break
@@ -182,39 +227,97 @@ export class AsyncCache {
182
227
  }
183
228
 
184
229
  dbs.add(this)
230
+
231
+ globalThis.__nxt_cache.push(this)
232
+ }
233
+
234
+ get stats() {
235
+ let database
236
+ if (this.#database) {
237
+ let size
238
+ try {
239
+ const { page_count } = this.#pageCountQuery .get()
240
+ const { page_size } = this.#pageSizeQuery .get()
241
+ size = page_count * page_size
242
+ } catch (err) {
243
+ process.emitWarning(err )
244
+ }
245
+ database = { location: this.#location, size }
246
+ }
247
+
248
+ return {
249
+ lock: {
250
+ timeout: this.#lockTimeout,
251
+ mean: this.#lockMean,
252
+ stddev: Math.sqrt(this.#lockVar),
253
+ },
254
+ dedupe: { size: this.#dedupe.size },
255
+ memory: this.#memory?.stats,
256
+ database,
257
+ }
185
258
  }
186
259
 
187
260
  close() {
261
+ this.#closed = true
262
+ this.#dedupe.clear()
188
263
  dbs.delete(this)
264
+
189
265
  this.#getQuery = null
190
266
  this.#setQuery = null
191
267
  this.#delQuery = null
192
268
  this.#purgeStaleQuery = null
193
269
  this.#evictQuery = null
270
+ this.#lockAcquireQuery = null
271
+ this.#lockStealQuery = null
272
+ this.#lockGetQuery = null
273
+ this.#lockPurgeQuery = null
274
+ this.#pageCountQuery = null
275
+ this.#pageSizeQuery = null
194
276
  this.#database?.close()
195
277
  this.#database = null
278
+
279
+ const idx = globalThis.__nxt_cache.indexOf(this)
280
+ if (idx !== -1) {
281
+ globalThis.__nxt_cache.splice(idx, 1)
282
+ }
196
283
  }
197
284
 
198
285
  get(...args ) {
286
+ if (this.#closed) {
287
+ throw new Error('cache is closed')
288
+ }
199
289
  return this.#load(args, true)
200
290
  }
201
291
 
202
292
  peek(...args ) {
293
+ if (this.#closed) {
294
+ throw new Error('cache is closed')
295
+ }
203
296
  return this.#load(args, false)
204
297
  }
205
298
 
206
299
  refresh(...args ) {
300
+ if (this.#closed) {
301
+ throw new Error('cache is closed')
302
+ }
207
303
  return this.#refresh(args)
208
304
  }
209
305
 
210
306
  delete(...args ) {
211
- this.#delete(this.#keySelector(...args))
307
+ if (this.#closed) {
308
+ throw new Error('cache is closed')
309
+ }
310
+ this.#set(this.#keySelector(...args), undefined)
212
311
  }
213
312
 
214
313
  purgeStale() {
314
+ if (this.#closed) {
315
+ throw new Error('cache is closed')
316
+ }
215
317
  try {
216
- this.#memory?.purgeStale(fastNow())
217
- this.#purgeStaleQuery?.run(fastNow())
318
+ this.#memory?.purgeStale(Date.now())
319
+ this.#purgeStaleQuery?.run(Date.now())
320
+ this.#lockPurgeQuery?.run(Date.now() - 3_600_000)
218
321
  } catch (err) {
219
322
  process.emitWarning(err )
220
323
  }
@@ -227,7 +330,7 @@ export class AsyncCache {
227
330
  throw new TypeError('keySelector must return a non-empty string')
228
331
  }
229
332
 
230
- const now = fastNow()
333
+ const now = Date.now()
231
334
 
232
335
  let cached = this.#memory?.get(key)
233
336
 
@@ -235,24 +338,8 @@ export class AsyncCache {
235
338
  try {
236
339
  const row = this.#getQuery?.get(key, now)
237
340
  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
- }
341
+ const entry = this.#loadRow(key, row)
254
342
  this.#memory?.set(key, entry)
255
-
256
343
  cached = entry
257
344
  }
258
345
  } catch (err) {
@@ -278,7 +365,19 @@ export class AsyncCache {
278
365
  }
279
366
  }
280
367
 
281
- const result = refresh ? this.#refresh(args, key) : ({ value: undefined, async: false } )
368
+ let result
369
+ if (refresh) {
370
+ try {
371
+ result = this.#refresh(args, key)
372
+ } catch (err) {
373
+ if (cached !== undefined) {
374
+ return { value: cached.value, async: false }
375
+ }
376
+ throw err
377
+ }
378
+ } else {
379
+ result = { value: undefined, async: false }
380
+ }
282
381
 
283
382
  if (result.async && cached !== undefined) {
284
383
  return { value: cached.value, async: false }
@@ -292,68 +391,94 @@ export class AsyncCache {
292
391
  throw new TypeError('keySelector must return a non-empty string')
293
392
  }
294
393
 
295
- // TODO (fix): cross process/thread dedupe...
296
394
  const existing = this.#dedupe.get(key)
297
395
  if (existing !== undefined) {
298
396
  return { async: true, value: existing }
299
397
  }
300
398
 
301
- const value = this.#valueSelector(...args)
302
-
303
- if (!(value instanceof Promise)) {
304
- this.#dedupe.delete(key)
305
- if (value === undefined) {
306
- this.#delete(key)
307
- } else {
308
- this.#set(key, value)
309
- }
310
- return { async: false, value }
399
+ // Another process holds the lock — wait for the value instead of calling valueSelector.
400
+ // Use deferred pattern so #waitForValue can check promise identity to detect delete+get races.
401
+ const lockResult = this.#tryAcquireLock(key)
402
+ if (typeof lockResult === 'number') {
403
+ const { promise, resolve, reject } = Promise.withResolvers ()
404
+ promise.catch(noop)
405
+ this.#dedupe.set(key, promise)
406
+ this.#waitForValue(args, key, lockResult, promise).then(resolve, reject)
407
+ return { async: true, value: promise }
311
408
  }
312
409
 
313
- // eslint-disable-next-line: no-unsafe-argument
314
- const promise = value.then(
315
- (value) => {
316
- if (this.#dedupe.get(key) === promise) {
317
- this.#dedupe.delete(key)
318
- if (value === undefined) {
319
- this.#delete(key)
320
- } else {
410
+ const res = this.#fetch(args)
411
+
412
+ if (res.async) {
413
+ const promise = res.value.then(
414
+ (value) => {
415
+ if (this.#dedupe.get(key) === promise) {
321
416
  this.#set(key, value)
322
417
  }
323
- }
324
- return value
325
- },
326
- (err) => {
327
- if (this.#dedupe.get(key) === promise) {
328
- this.#dedupe.delete(key)
329
- }
330
- throw err
331
- },
332
- )
333
- promise.catch(noop)
334
- this.#dedupe.set(key, promise)
418
+ return value
419
+ },
420
+ (err) => {
421
+ if (this.#dedupe.get(key) === promise) {
422
+ this.#dedupe.delete(key)
423
+ }
424
+ throw err
425
+ },
426
+ )
427
+ promise.catch(noop)
428
+ this.#dedupe.set(key, promise)
429
+ return { async: true, value: promise }
430
+ }
335
431
 
336
- return { async: true, value: promise }
432
+ this.#set(key, res.value)
433
+ return res
337
434
  }
338
435
 
339
- #set(key , value ) {
436
+ #fetch(args ) {
437
+ const startTime = performance.now()
438
+ const value = this.#valueSelector(...args)
439
+
440
+ if (isThenable(value)) {
441
+ return {
442
+ async: true,
443
+ value: Promise.resolve(value).then((value) => {
444
+ this.#updateLockTimeout(performance.now() - startTime)
445
+ return value
446
+ }),
447
+ }
448
+ }
449
+
450
+ this.#updateLockTimeout(performance.now() - startTime)
451
+ return { async: false, value }
452
+ }
453
+
454
+ #set(key , value ) {
340
455
  if (typeof key !== 'string' || key.length === 0) {
341
456
  throw new TypeError('key must be a non-empty string')
342
457
  }
343
458
 
344
459
  this.#dedupe.delete(key)
345
460
 
461
+ if (value === undefined) {
462
+ this.#memory?.delete(key)
463
+ try {
464
+ this.#delQuery?.run(key)
465
+ } catch (err) {
466
+ process.emitWarning(err )
467
+ }
468
+ return
469
+ }
470
+
346
471
  const ttlValue = Math.min(MAX_DURATION, this.#ttl(value, key) ?? Infinity)
347
472
  if (!Number.isFinite(ttlValue) || ttlValue < 0) {
348
473
  throw new TypeError('ttl must be nully or a positive integer')
349
474
  }
350
475
 
351
- const staleValue = Math.min(MAX_DURATION, this.#stale(value, key) ?? 0)
476
+ const staleValue = Math.min(MAX_DURATION, this.#stale(value, key) ?? Number.MAX_SAFE_INTEGER)
352
477
  if (!Number.isFinite(staleValue) || staleValue < 0) {
353
478
  throw new TypeError('stale must be nully or a positive integer')
354
479
  }
355
480
 
356
- const now = fastNow()
481
+ const now = Date.now()
357
482
  const ttl = now + ttlValue
358
483
  const stale = ttl + staleValue
359
484
 
@@ -361,21 +486,16 @@ export class AsyncCache {
361
486
  return
362
487
  }
363
488
 
364
- const data = ArrayBuffer.isView(value)
365
- ? value
366
- : JSON.stringify(value )
489
+ const data = ArrayBuffer.isView(value) ? value : JSON.stringify(value )
367
490
 
368
- this.#memory?.set(key, {
491
+ const entry = this.#createEntry(
492
+ key,
493
+ value,
369
494
  ttl,
370
495
  stale,
371
- value: ArrayBuffer.isView(value)
372
- ? (Buffer.from(value.buffer, value.byteOffset, value.byteLength) )
373
- : value,
374
- key,
375
- size: (ArrayBuffer.isView(data) ? data.byteLength : data.length) + key.length + 64,
376
- index: -1,
377
- counter: -1,
378
- })
496
+ ArrayBuffer.isView(data) ? data.byteLength : data.length * 2,
497
+ )
498
+ this.#memory?.set(key, entry)
379
499
 
380
500
  try {
381
501
  this.#setQuery?.run(key, data , ttl, stale)
@@ -393,17 +513,163 @@ export class AsyncCache {
393
513
  }
394
514
  }
395
515
 
396
- #delete(key ) {
397
- if (typeof key !== 'string' || key.length === 0) {
398
- throw new TypeError('key must be a non-empty string')
516
+ #createEntry(
517
+ key ,
518
+ value ,
519
+ ttl ,
520
+ stale ,
521
+ size ,
522
+ ) {
523
+ return {
524
+ ttl,
525
+ stale,
526
+ value: ArrayBuffer.isView(value)
527
+ ? (Buffer.from(value.buffer, value.byteOffset, value.byteLength) )
528
+ : value,
529
+ key,
530
+ size,
531
+ index: -1,
532
+ counter: -1,
399
533
  }
534
+ }
535
+
536
+ #loadRow(key , row ) {
537
+ const value = (ArrayBuffer.isView(row.val) ? row.val : JSON.parse(row.val))
538
+ return this.#createEntry(
539
+ key,
540
+ value,
541
+ row.ttl,
542
+ row.stale,
543
+ ArrayBuffer.isView(row.val) ? row.val.byteLength : row.val.length * 2,
544
+ )
545
+ }
546
+
547
+ // Returns lock_acquired timestamp (number) when contended, or a string status.
548
+ #tryAcquireLock(key ) {
549
+ if (this.#lockAcquireQuery === null) {
550
+ return 'unavailable'
551
+ }
552
+
553
+ const now = Date.now()
400
554
 
401
- this.#dedupe.delete(key)
402
- this.#memory?.delete(key)
403
555
  try {
404
- this.#delQuery?.run(key)
556
+ const row = this.#lockAcquireQuery.get(key, now, this.#lockId)
557
+
558
+
559
+
560
+ if (row === undefined) {
561
+ return 'unavailable'
562
+ }
563
+
564
+ if (row.lock_owner === this.#lockId) {
565
+ return 'acquired'
566
+ }
567
+
568
+ const lockedAt = row.lock_acquired
569
+ if (now - lockedAt > this.#lockTimeout * 3) {
570
+ // Lock is stale (3x the EMA-based timeout) — attempt to steal atomically.
571
+ // The WHERE clause matches the exact lock_acquired we observed, so only one
572
+ // process wins if multiple try to steal concurrently.
573
+ const stealResult = this.#lockStealQuery .run(now, this.#lockId, key, lockedAt)
574
+ if (stealResult.changes === 1) {
575
+ return 'acquired'
576
+ }
577
+
578
+ // Steal failed — another process won the race. Read the winner's lock_acquired
579
+ // so the caller waits the right amount of time instead of using the stale timestamp.
580
+ const current = this.#lockGetQuery .get(key)
581
+ if (current !== undefined) {
582
+ return current.lock_acquired
583
+ }
584
+ }
585
+
586
+ return lockedAt
405
587
  } catch (err) {
406
588
  process.emitWarning(err )
589
+ return 'unavailable'
590
+ }
591
+ }
592
+
593
+ #updateLockTimeout(duration ) {
594
+ // EMA of mean and variance (Welford-style), clamped to [10ms, 1s].
595
+ // Timeout = mean * 1.2 + 3 * stddev: 20% base margin plus 3 standard
596
+ // deviations to accommodate timing variability.
597
+ const alpha = 0.2
598
+ const diff = duration - this.#lockMean
599
+ this.#lockMean += alpha * diff
600
+ this.#lockVar = (1 - alpha) * (this.#lockVar + alpha * diff * diff)
601
+ this.#lockTimeout = Math.max(
602
+ 10,
603
+ Math.min(1_000, Math.ceil(this.#lockMean * 1.2 + Math.sqrt(this.#lockVar) * 3)),
604
+ )
605
+ }
606
+
607
+ // Wait for another process to complete, then check for their result.
608
+ // If no result found, take over by calling valueSelector ourselves.
609
+ // selfPromise is the exact promise stored in #dedupe, used for identity checks
610
+ // to avoid double valueSelector calls after delete()+get() races.
611
+ async #waitForValue(args , key , lockedAt , selfPromise ) {
612
+ // Loop: wait for lock holder to write a value, retry if lock was stolen by another process.
613
+ for (let retries = 0; retries < 2 && this.#dedupe.get(key) === selfPromise; retries++) {
614
+ // Wait for estimated completion: locked_at + our EMA-based timeout.
615
+ const waitTime = Math.max(0, lockedAt + this.#lockTimeout - Date.now())
616
+ if (waitTime > 0) {
617
+ // Add 20% jitter to reduce thundering herd on contention
618
+ await delay(waitTime + Math.floor(Math.random() * waitTime * 0.2), undefined, {
619
+ ref: false,
620
+ })
621
+ }
622
+
623
+ // Check if the lock holder wrote a value
624
+ try {
625
+ const row = this.#getQuery?.get(key, Date.now())
626
+ if (row !== undefined) {
627
+ if (this.#dedupe.get(key) === selfPromise) {
628
+ this.#dedupe.delete(key)
629
+ }
630
+ const entry = this.#loadRow(key, row)
631
+ this.#memory?.set(key, entry)
632
+ return entry.value
633
+ }
634
+ } catch (err) {
635
+ process.emitWarning(err )
636
+ }
637
+
638
+ // Try to acquire lock for takeover
639
+ const lockResult = this.#tryAcquireLock(key)
640
+ if (typeof lockResult === 'number') {
641
+ // Another process holds the lock — wait for them
642
+ lockedAt = lockResult
643
+ } else {
644
+ // We acquired the lock or lock is unavailable — do the work
645
+ break
646
+ }
647
+ }
648
+
649
+ {
650
+ const promise = this.#dedupe.get(key)
651
+ if (promise !== undefined && promise !== selfPromise) {
652
+ return promise
653
+ }
654
+ }
655
+
656
+ try {
657
+ const res = this.#fetch(args)
658
+ const value = res.async ? await res.value : res.value
659
+ if (this.#dedupe.get(key) === selfPromise) {
660
+ this.#set(key, value)
661
+ }
662
+ return value
663
+ } catch (err) {
664
+ if (this.#dedupe.get(key) === selfPromise) {
665
+ this.#dedupe.delete(key)
666
+ }
667
+ throw err
407
668
  }
408
669
  }
409
670
  }
671
+
672
+ /** @deprecated Use `Cache` instead. */
673
+ export const AsyncCache = Cache
674
+ /** @deprecated Use `CacheOptions` instead. */
675
+
package/lib/memory.d.ts CHANGED
@@ -18,4 +18,10 @@ export declare class MemoryCache<V> {
18
18
  get(key: string): MemoryCacheEntry<V> | undefined;
19
19
  delete(key: string): void;
20
20
  purgeStale(now: number): void;
21
+ get stats(): {
22
+ size: number;
23
+ maxSize: number;
24
+ count: number;
25
+ maxCount: number;
26
+ };
21
27
  }
package/lib/memory.js CHANGED
@@ -97,6 +97,15 @@ export class MemoryCache {
97
97
  }
98
98
  }
99
99
 
100
+ get stats() {
101
+ return {
102
+ size: this.#size,
103
+ maxSize: this.#maxSize,
104
+ count: this.#count,
105
+ maxCount: this.#maxCount,
106
+ }
107
+ }
108
+
100
109
  #prune() {
101
110
  while (this.#size > this.#maxSize || this.#count > this.#maxCount) {
102
111
  const e1 = this.#arr[(Math.random() * this.#arr.length) | 0]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/cache",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
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: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"
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": "5f5dcfe6c7fbfdb01cca92e42e99ddd586ecf04b"
33
+ "gitHead": "cfc529e921c93a1fb0292018e2026f2b326ec998"
32
34
  }