@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 +4 -4
- package/lib/index.d.ts +32 -3
- package/lib/index.js +358 -92
- package/lib/memory.d.ts +6 -0
- package/lib/memory.js +9 -0
- 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,34 @@ export type CacheResult<V> = {
|
|
|
17
17
|
value: Promise<V>;
|
|
18
18
|
async: true;
|
|
19
19
|
};
|
|
20
|
-
|
|
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 |
|
|
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
|
-
|
|
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,35 +55,56 @@ const dbs = new Set ()
|
|
|
61
55
|
|
|
62
56
|
|
|
63
57
|
|
|
64
|
-
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
const VERSION = 4
|
|
65
64
|
const MAX_DURATION = 365000000e3
|
|
66
65
|
|
|
67
|
-
|
|
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
|
-
|
|
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(
|
|
217
|
-
this.#purgeStaleQuery?.run(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
432
|
+
this.#set(key, res.value)
|
|
433
|
+
return res
|
|
337
434
|
}
|
|
338
435
|
|
|
339
|
-
#
|
|
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) ??
|
|
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 =
|
|
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
|
|
365
|
-
? value
|
|
366
|
-
: JSON.stringify(value )
|
|
489
|
+
const data = ArrayBuffer.isView(value) ? value : JSON.stringify(value )
|
|
367
490
|
|
|
368
|
-
this.#
|
|
491
|
+
const entry = this.#createEntry(
|
|
492
|
+
key,
|
|
493
|
+
value,
|
|
369
494
|
ttl,
|
|
370
495
|
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
|
-
})
|
|
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
|
-
#
|
|
397
|
-
|
|
398
|
-
|
|
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.#
|
|
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.
|
|
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:
|
|
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": "cfc529e921c93a1fb0292018e2026f2b326ec998"
|
|
32
34
|
}
|