@nxtedition/cache 2.1.14 → 2.1.16

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/lib/index.js CHANGED
@@ -9,10 +9,33 @@ function maybeToBuffer(value) {
9
9
  : value;
10
10
  }
11
11
  function isThenable(value) {
12
- return value != null && typeof value.then === 'function';
12
+ if (value == null) {
13
+ return false;
14
+ }
15
+ // Accessing `.then` can throw when it's a toxic getter. Without this guard
16
+ // a user valueSelector returning `{ get then() { throw ... } }` would crash
17
+ // #refresh on the isThenable check *after* the valueSelector try/catch had
18
+ // already exited, leaving the lock acquired — subsequent reads on the same
19
+ // key would hang indefinitely. Treat a throwing `.then` as non-thenable.
20
+ try {
21
+ return typeof value.then === 'function';
22
+ }
23
+ catch {
24
+ return false;
25
+ }
13
26
  }
14
27
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
28
  const dbs = new Set();
29
+ process.on('beforeExit', () => {
30
+ for (const db of dbs) {
31
+ try {
32
+ db.close();
33
+ }
34
+ catch (err) {
35
+ process.emitWarning(err);
36
+ }
37
+ }
38
+ });
16
39
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
40
  const defaultSerializer = {
18
41
  serialize(value) {
@@ -23,9 +46,75 @@ const defaultSerializer = {
23
46
  return ArrayBuffer.isView(data) ? data : JSON.parse(data);
24
47
  },
25
48
  };
26
- const VERSION = 5;
49
+ const VERSION = 6;
27
50
  const MAX_DURATION = 365000000e3;
28
51
  const HASHER = await xxhash();
52
+ class DatabaseShard {
53
+ location;
54
+ databaseTimeout = 20;
55
+ database;
56
+ getQuery;
57
+ delQuery;
58
+ purgeExpiredQuery;
59
+ evictQuery;
60
+ pageCountQuery;
61
+ pageSizeQuery;
62
+ setQuery;
63
+ setBatch = [];
64
+ constructor(location, opts) {
65
+ this.location = location;
66
+ this.databaseTimeout = opts?.timeout ?? 20;
67
+ for (let n = 0; true; n++) {
68
+ try {
69
+ this.database = new DatabaseSync(location, { timeout: this.databaseTimeout });
70
+ break;
71
+ }
72
+ catch (err) {
73
+ // Check the retry limit BEFORE sleeping so we don't block for 100ms
74
+ // on the final failure before throwing.
75
+ if (n >= 16) {
76
+ throw err;
77
+ }
78
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
79
+ }
80
+ }
81
+ const maxSize = opts?.maxSize ?? 128 * 1024 * 1024;
82
+ try {
83
+ this.database.exec(`
84
+ PRAGMA journal_mode = WAL;
85
+ PRAGMA synchronous = OFF;
86
+ PRAGMA wal_autocheckpoint = 10000;
87
+ PRAGMA cache_size = -${Math.ceil(maxSize / 1024 / 8)};
88
+ PRAGMA mmap_size = ${maxSize};
89
+ PRAGMA max_page_count = ${Math.ceil(maxSize / 4096)};
90
+ PRAGMA optimize;
91
+
92
+ CREATE TABLE IF NOT EXISTS cache_v${VERSION} (
93
+ key TEXT PRIMARY KEY NOT NULL,
94
+ val BLOB NOT NULL,
95
+ stale INTEGER NOT NULL,
96
+ expire INTEGER NOT NULL
97
+ ) WITHOUT ROWID;
98
+
99
+ CREATE INDEX IF NOT EXISTS cache_v${VERSION}_expire_idx ON cache_v${VERSION}(expire);
100
+ `);
101
+ this.getQuery = this.database.prepare(`SELECT val, stale, expire FROM cache_v${VERSION} WHERE key = ? AND expire > ?`);
102
+ this.setQuery = this.database.prepare(`INSERT OR REPLACE INTO cache_v${VERSION} (key, val, stale, expire) VALUES (?, ?, ?, ?)`);
103
+ this.delQuery = this.database.prepare(`DELETE FROM cache_v${VERSION} WHERE key = ?`);
104
+ this.purgeExpiredQuery = this.database.prepare(`DELETE FROM cache_v${VERSION} WHERE expire <= ?`);
105
+ this.evictQuery = this.database.prepare(`DELETE FROM cache_v${VERSION} WHERE key IN (SELECT key FROM cache_v${VERSION} ORDER BY expire ASC LIMIT ?)`);
106
+ this.pageCountQuery = this.database.prepare('PRAGMA page_count');
107
+ this.pageSizeQuery = this.database.prepare('PRAGMA page_size');
108
+ }
109
+ catch (err) {
110
+ this.database.close();
111
+ throw err;
112
+ }
113
+ }
114
+ [Symbol.dispose]() {
115
+ this.database.close();
116
+ }
117
+ }
29
118
  export class Cache extends EventEmitter {
30
119
  #memory;
31
120
  #dedupe = new Map();
@@ -38,21 +127,25 @@ export class Cache extends EventEmitter {
38
127
  #lockArray;
39
128
  #flushHandle = null;
40
129
  #location;
41
- #databaseTimeout = 20;
42
- #database = null;
43
- #getQuery = null;
44
- #delQuery = null;
45
- #purgeStaleQuery = null;
46
- #evictQuery = null;
47
- #pageCountQuery = null;
48
- #pageSizeQuery = null;
49
- #setQuery = null;
50
- #setBatch = [];
130
+ #shards = null;
51
131
  #emitError = (err) => {
52
- if (this.listenerCount('error') > 0) {
53
- this.emit('error', err);
132
+ // emit('error', err) rethrows synchronously if a user listener throws.
133
+ // #emitError is called from many places that iterate over shards (#flush,
134
+ // gc, constructor catch, the offPeak BroadcastChannel handler);
135
+ // a rethrow there would starve the remaining shards and skip cleanup
136
+ // (e.g. #flush's tail-uncork, gc's busy_timeout restore). Swallow
137
+ // listener throws and surface them as a warning so diagnostics aren't
138
+ // silently dropped.
139
+ try {
140
+ if (this.listenerCount('error') > 0) {
141
+ this.emit('error', err);
142
+ }
143
+ else {
144
+ process.emitWarning(err);
145
+ }
54
146
  }
55
- else {
147
+ catch (listenerErr) {
148
+ process.emitWarning(listenerErr);
56
149
  process.emitWarning(err);
57
150
  }
58
151
  };
@@ -97,7 +190,8 @@ export class Cache extends EventEmitter {
97
190
  this.#lockArray = null;
98
191
  }
99
192
  else {
100
- this.#lockArray = new Int32Array(getOrCreate(`__@nxtedition/cache/${location}`, 64 * 1024));
193
+ const size = 64 * 1024 * Int32Array.BYTES_PER_ELEMENT;
194
+ this.#lockArray = new Int32Array(getOrCreate(`__@nxtedition/cache/${location}`, size));
101
195
  }
102
196
  if (opts?.serializer !== undefined) {
103
197
  if (typeof opts.serializer !== 'object' || opts.serializer === null) {
@@ -113,53 +207,52 @@ export class Cache extends EventEmitter {
113
207
  this.#serializer = opts?.serializer ?? defaultSerializer;
114
208
  this.#memory =
115
209
  opts?.memory === false || opts?.memory === null ? null : new MemoryCache(opts?.memory);
116
- for (let n = 0; opts?.database !== null && opts?.database !== false; n++) {
210
+ if (opts?.database === false || opts?.database === null) {
211
+ this.#shards = null;
212
+ }
213
+ else {
214
+ // Option-shape validation runs synchronously and throws: these are
215
+ // programmer errors, not the environmental open failures that the
216
+ // try/catch below converts to error events. Keeping the two paths
217
+ // separate matches how other constructor validations (lock, ttl, …)
218
+ // behave.
219
+ const count = opts?.database?.shards ?? (location === ':memory:' ? 1 : 4);
220
+ if (!Number.isInteger(count)) {
221
+ throw new TypeError('database.shards must be a positive integer');
222
+ }
223
+ if (count < 1) {
224
+ throw new RangeError('database.shards must be a positive integer');
225
+ }
226
+ if (location === ':memory:' && count !== 1) {
227
+ throw new RangeError('in-memory database does not support sharding');
228
+ }
229
+ this.#shards = [];
117
230
  try {
118
- const maxSize = opts?.database?.maxSize ?? 128 * 1024 * 1024;
119
- this.#databaseTimeout = opts?.database?.timeout ?? 20;
120
- this.#database ??= new DatabaseSync(location, { timeout: this.#databaseTimeout });
121
- this.#database.exec(`
122
- PRAGMA journal_mode = WAL;
123
- PRAGMA synchronous = OFF;
124
- PRAGMA wal_autocheckpoint = 10000;
125
- PRAGMA cache_size = -${Math.ceil(maxSize / 1024 / 8)};
126
- PRAGMA mmap_size = ${maxSize};
127
- PRAGMA max_page_count = ${Math.ceil(maxSize / 4096)};
128
- PRAGMA optimize;
129
-
130
- CREATE TABLE IF NOT EXISTS cache_v${VERSION} (
131
- key TEXT PRIMARY KEY NOT NULL,
132
- val BLOB NOT NULL,
133
- ttl INTEGER NOT NULL,
134
- stale INTEGER NOT NULL
135
- ) WITHOUT ROWID;
136
-
137
- CREATE INDEX IF NOT EXISTS cache_v${VERSION}_stale_idx ON cache_v${VERSION}(stale);
138
- `);
139
- this.#getQuery = this.#database.prepare(`SELECT val, ttl, stale FROM cache_v${VERSION} WHERE key = ? AND stale > ?`);
140
- this.#setQuery = this.#database.prepare(`INSERT OR REPLACE INTO cache_v${VERSION} (key, val, ttl, stale) VALUES (?, ?, ?, ?)`);
141
- this.#delQuery = this.#database.prepare(`DELETE FROM cache_v${VERSION} WHERE key = ?`);
142
- this.#purgeStaleQuery = this.#database.prepare(`DELETE FROM cache_v${VERSION} WHERE stale <= ?`);
143
- this.#evictQuery = this.#database.prepare(`DELETE FROM cache_v${VERSION} WHERE key IN (SELECT key FROM cache_v${VERSION} ORDER BY stale ASC LIMIT ?)`);
144
- this.#pageCountQuery = this.#database.prepare('PRAGMA page_count');
145
- this.#pageSizeQuery = this.#database.prepare('PRAGMA page_size');
146
- break;
231
+ if (location === ':memory:') {
232
+ this.#shards.push(new DatabaseShard(location, opts?.database));
233
+ }
234
+ else {
235
+ for (let n = 0; n < count; n++) {
236
+ this.#shards.push(new DatabaseShard(count === 1 ? location : location + '.' + n, {
237
+ ...opts?.database,
238
+ maxSize: Math.ceil((opts?.database?.maxSize ?? 128 * 1024 * 1024) / count),
239
+ }));
240
+ }
241
+ }
147
242
  }
148
243
  catch (err) {
149
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
150
- if (n >= 16) {
151
- this.#database?.close();
152
- this.#database = null;
153
- this.#getQuery = null;
154
- this.#setQuery = null;
155
- this.#delQuery = null;
156
- this.#purgeStaleQuery = null;
157
- this.#evictQuery = null;
158
- this.#pageCountQuery = null;
159
- this.#pageSizeQuery = null;
160
- this.#emitError(err);
161
- break;
244
+ for (const shard of this.#shards) {
245
+ // Guard so one bad dispose can't starve the rest and skip the
246
+ // nulling + emitError below.
247
+ try {
248
+ shard[Symbol.dispose]();
249
+ }
250
+ catch {
251
+ // Already failing the cache construction; swallow dispose errors.
252
+ }
162
253
  }
254
+ this.#shards = null;
255
+ this.#emitError(err);
163
256
  }
164
257
  }
165
258
  dbs.add(this);
@@ -168,14 +261,17 @@ export class Cache extends EventEmitter {
168
261
  }
169
262
  get stats() {
170
263
  let database;
171
- if (this.#database) {
172
- let size;
264
+ if (this.#shards) {
265
+ let size = 0;
173
266
  try {
174
- const { page_count } = this.#pageCountQuery.get();
175
- const { page_size } = this.#pageSizeQuery.get();
176
- size = page_count * page_size;
267
+ for (const shard of this.#shards) {
268
+ const { page_count } = shard.pageCountQuery.get();
269
+ const { page_size } = shard.pageSizeQuery.get();
270
+ size += page_count * page_size;
271
+ }
177
272
  }
178
273
  catch (err) {
274
+ size = undefined;
179
275
  this.#emitError(err);
180
276
  }
181
277
  database = { location: this.#location, size };
@@ -189,28 +285,41 @@ export class Cache extends EventEmitter {
189
285
  [Symbol.dispose]() {
190
286
  this.close();
191
287
  }
192
- close() {
288
+ flushSync() {
289
+ if (this.#closed) {
290
+ return;
291
+ }
292
+ // Drain pending flushes. The pending #flushHandle was scheduled by a
293
+ // prior #set that already corked the memory cache; #flush's tail-uncork
294
+ // balances that cork when it finishes. Do NOT cork again here — the old
295
+ // code did, leaving the memory cache in a permanently-corked state
296
+ // after close (cork() +1 per iteration, uncork() only -1 per completed
297
+ // flush), which disabled pruning on the now-orphaned MemoryCache.
193
298
  while (this.#flushHandle) {
194
299
  clearImmediate(this.#flushHandle);
195
300
  this.#flush();
196
301
  }
302
+ }
303
+ close() {
304
+ if (this.#closed) {
305
+ return;
306
+ }
307
+ this.flushSync();
197
308
  this.#closed = true;
198
309
  this.#dedupe.clear();
199
310
  dbs.delete(this);
200
- this.#getQuery = null;
201
- this.#setQuery = null;
202
- this.#delQuery = null;
203
- this.#purgeStaleQuery = null;
204
- this.#evictQuery = null;
205
- this.#pageCountQuery = null;
206
- this.#pageSizeQuery = null;
207
- this.#database?.close();
208
- this.#database = null;
209
- globalThis.__nxt_cache = globalThis.__nxt_cache?.filter((ref) => ref.deref() != null) ?? [];
210
- const idx = globalThis.__nxt_cache?.findIndex((ref) => ref.deref() === this) ?? -1;
211
- if (idx !== -1) {
212
- globalThis.__nxt_cache.splice(idx, 1);
311
+ for (const shard of this.#shards ?? []) {
312
+ // Guard each dispose so one shard's close failure doesn't starve the
313
+ // rest and leave #shards as a partially-disposed array.
314
+ try {
315
+ shard[Symbol.dispose]();
316
+ }
317
+ catch (err) {
318
+ this.#emitError(err);
319
+ }
213
320
  }
321
+ this.#shards = null;
322
+ globalThis.__nxt_cache = globalThis.__nxt_cache?.filter((ref) => ref.deref() != null && ref.deref() !== this);
214
323
  }
215
324
  get(...args) {
216
325
  if (this.#closed) {
@@ -232,320 +341,379 @@ export class Cache extends EventEmitter {
232
341
  if (typeof key !== 'string' || key.length === 0) {
233
342
  throw new TypeError('keySelector must return a non-empty string');
234
343
  }
235
- let idx;
236
- if (this.#lockArray) {
237
- idx = HASHER.h32(key) % this.#lockArray.length;
238
- if (Atomics.add(this.#lockArray, idx, 1) >= 0xfffffff) {
239
- // Roll back the increment before throwing so we don't leak the slot.
240
- // #refresh would normally own the decrement, but we're bailing before
241
- // calling it.
242
- if (Atomics.sub(this.#lockArray, idx, 1) === 1) {
243
- Atomics.notify(this.#lockArray, idx);
244
- }
245
- throw new Error('lock counter overflow');
246
- }
247
- }
248
- else {
249
- idx = -1;
250
- }
251
- return this.#refresh(args, key, idx);
344
+ const hash = HASHER.h32(key);
345
+ return this.#refresh(args, key, hash);
252
346
  }
253
347
  delete(...args) {
254
348
  if (this.#closed) {
255
349
  throw new Error('cache is closed');
256
350
  }
257
- this.#set(this.#keySelector(...args), undefined);
351
+ const key = this.#keySelector(...args);
352
+ if (typeof key !== 'string' || key.length === 0) {
353
+ throw new TypeError('key must be a non-empty string');
354
+ }
355
+ const hash = HASHER.h32(key);
356
+ this.#set(key, undefined, hash);
258
357
  }
259
- purgeStale() {
358
+ gc() {
260
359
  if (this.#closed) {
261
360
  throw new Error('cache is closed');
262
361
  }
263
- this.#memory?.purgeStale(Date.now());
264
- this.#database?.exec('PRAGMA busy_timeout = 5000');
265
- try {
362
+ this.#memory?.gc(Date.now());
363
+ for (const shard of this.#shards ?? []) {
364
+ // Wrap the initial busy_timeout bump — if exec throws here, the old
365
+ // code threw out of the whole method, starving the remaining shards
366
+ // and leaving this shard's timeout unchanged (so the finally-branch
367
+ // restore wouldn't run either).
266
368
  try {
267
- this.#purgeStaleQuery?.run(Date.now());
369
+ shard.database.exec('PRAGMA busy_timeout = 5000');
268
370
  }
269
371
  catch (err) {
270
372
  this.#emitError(err);
373
+ continue;
271
374
  }
272
375
  try {
273
- this.#database?.exec('PRAGMA wal_checkpoint(TRUNCATE)');
274
- }
275
- catch (err) {
276
- this.#emitError(err);
277
- }
278
- try {
279
- this.#database?.exec('PRAGMA optimize');
280
- }
281
- catch (err) {
282
- this.#emitError(err);
283
- }
284
- }
285
- finally {
286
- try {
287
- this.#database?.exec(`PRAGMA busy_timeout = ${this.#databaseTimeout}`);
376
+ try {
377
+ shard.purgeExpiredQuery.run(Date.now());
378
+ }
379
+ catch (err) {
380
+ this.#emitError(err);
381
+ }
382
+ try {
383
+ shard.database.exec('PRAGMA wal_checkpoint(TRUNCATE)');
384
+ }
385
+ catch (err) {
386
+ this.#emitError(err);
387
+ }
388
+ try {
389
+ shard.database.exec('PRAGMA optimize');
390
+ }
391
+ catch (err) {
392
+ this.#emitError(err);
393
+ }
288
394
  }
289
- catch (err) {
290
- this.#emitError(err);
395
+ finally {
396
+ try {
397
+ shard.database.exec(`PRAGMA busy_timeout = ${shard.databaseTimeout}`);
398
+ }
399
+ catch (err) {
400
+ this.#emitError(err);
401
+ }
291
402
  }
292
403
  }
293
404
  }
294
405
  #load(args, refresh) {
406
+ if (this.#closed) {
407
+ throw new Error('cache is closed');
408
+ }
295
409
  const key = this.#keySelector(...args);
296
410
  if (typeof key !== 'string' || key.length === 0) {
297
411
  throw new TypeError('keySelector must return a non-empty string');
298
412
  }
299
413
  const now = Date.now();
414
+ const hash = HASHER.h32(key);
300
415
  let cached = this.#memory?.get(key);
301
416
  if (cached === undefined) {
302
- try {
303
- const row = this.#getQuery?.get(key, now);
304
- if (row !== undefined) {
305
- const entry = this.#loadRow(key, row);
306
- this.#memory?.set(key, entry);
307
- cached = entry;
308
- }
309
- }
310
- catch (err) {
311
- this.#emitError(err);
312
- }
417
+ cached = this.#get(key, hash, now);
313
418
  }
314
- if (cached !== undefined) {
315
- if (now < cached.ttl) {
316
- return { value: cached.value, async: false };
317
- }
318
- if (now >= cached.stale) {
319
- // stale-while-revalidate has expired, purge cached value.
320
- this.#memory?.delete(key);
321
- cached = undefined;
322
- }
419
+ else if (now >= cached.expire) {
420
+ // stale-while-revalidate has expired, purge cached value.
421
+ this.#memory?.delete(key);
422
+ cached = undefined;
423
+ }
424
+ if (cached !== undefined && now < cached.stale) {
425
+ return { value: cached.value, async: false };
323
426
  }
324
427
  if (!refresh) {
325
428
  // peek: return stale value if available, undefined if expired or missing
326
429
  return { value: cached?.value, async: false };
327
430
  }
328
- {
329
- const pending = this.#dedupe.get(key);
330
- if (pending !== undefined) {
331
- return { async: true, value: pending };
332
- }
333
- }
334
- const idx = this.#lockArray ? HASHER.h32(key) % this.#lockArray.length : -1;
431
+ const idx = this.#lockArray ? hash % this.#lockArray.length : -1;
335
432
  if (cached !== undefined) {
336
- try {
337
- const val = this.#lockArray ? Atomics.compareExchange(this.#lockArray, idx, 0, 1) : 0;
338
- if (val === 0) {
339
- const result = this.#refresh(args, key, idx);
340
- if (!result.async) {
341
- return result;
342
- }
343
- else {
344
- result.value.catch((err) => {
345
- this.#emitError(err);
346
- });
433
+ // Stale-while-revalidate: every concurrent caller returns stale sync.
434
+ // Only the caller that actually wins the lock kicks off a background
435
+ // refresh checking #dedupe first avoids redundant in-flight refreshes
436
+ // from prior callers. Without this, the first caller would return sync
437
+ // stale while later callers (seeing the pending promise) would
438
+ // inconsistently return async fresh for the same stale state.
439
+ if (!this.#dedupe.has(key)) {
440
+ try {
441
+ const val = this.#lockArray ? Atomics.compareExchange(this.#lockArray, idx, 0, 1) : 0;
442
+ if (val === 0) {
443
+ const result = this.#refresh(args, key, hash, true);
444
+ if (!result.async) {
445
+ return result;
446
+ }
447
+ else {
448
+ result.value.catch((err) => {
449
+ this.#emitError(err);
450
+ });
451
+ }
347
452
  }
348
453
  }
349
- }
350
- catch (err) {
351
- this.#emitError(err);
454
+ catch (err) {
455
+ this.#emitError(err);
456
+ }
352
457
  }
353
458
  return { async: false, value: cached.value };
354
459
  }
355
- while (this.#lockArray) {
356
- const val = Atomics.compareExchange(this.#lockArray, idx, 0, 1);
357
- if (val === 0) {
358
- break;
460
+ // Fully expired (or never cached): concurrent callers share one pending
461
+ // refresh via #dedupe.
462
+ {
463
+ const pending = this.#dedupe.get(key);
464
+ if (pending !== undefined) {
465
+ return { async: true, value: pending };
359
466
  }
360
- const { async, value } = Atomics.waitAsync(this.#lockArray, idx, val, 1e3);
361
- if (async) {
362
- return {
363
- async: true,
364
- value: value.then(() => this.#load(args, true).value),
365
- };
467
+ }
468
+ if (!this.#lockArray) {
469
+ return this.#refresh(args, key, hash, false);
470
+ }
471
+ const val = Atomics.compareExchange(this.#lockArray, idx, 0, 1);
472
+ if (val === 0) {
473
+ return this.#refresh(args, key, hash, true);
474
+ }
475
+ const { async, value } = Atomics.waitAsync(this.#lockArray, idx, val, 1e3);
476
+ if (async) {
477
+ return {
478
+ async: true,
479
+ value: value.then(() => this.#getOrRefresh(args, key, hash, now).value),
480
+ };
481
+ }
482
+ return this.#getOrRefresh(args, key, hash, now);
483
+ }
484
+ #getOrRefresh(args, key, hash, now) {
485
+ if (this.#closed) {
486
+ throw new Error('cache is closed');
487
+ }
488
+ const entry = this.#get(key, hash, now);
489
+ if (entry !== undefined) {
490
+ return { async: false, value: entry.value };
491
+ }
492
+ // The caller that released the lock may have failed to write a new
493
+ // value due to a database error, or may have written a value that
494
+ // expired by the time we woke up. In either case, fall back to
495
+ // refreshing the value ourselves — this is the same path as a
496
+ // cache miss, so it handles all the same edge cases (concurrent
497
+ // refreshes, database errors, etc) with the same robustness.
498
+ // Also note that locks are coarse and we might have been woken
499
+ // by a different key's release, so we can't assume anything
500
+ // about the state of the current key, but that's fine.
501
+ // There is a slight issue of potential thundering herd here if
502
+ // many callers are all waiting on the same lock and the refreshed
503
+ // value is always expired or fails to write.
504
+ return this.#refresh(args, key, hash, false);
505
+ }
506
+ #get(key, hash = HASHER.h32(key), now = Date.now()) {
507
+ try {
508
+ const shard = this.#shards ? this.#shards[hash % this.#shards.length] : null;
509
+ const row = shard?.getQuery.get(key, now);
510
+ if (row === undefined) {
511
+ return undefined;
366
512
  }
513
+ const entry = this.#loadRow(key, row);
514
+ this.#memory?.set(key, entry);
515
+ return entry;
516
+ }
517
+ catch (err) {
518
+ this.#emitError(err);
367
519
  }
368
- return this.#refresh(args, key, idx);
520
+ return undefined;
369
521
  }
370
- #refresh(args, key, idx) {
522
+ #release(hash) {
523
+ if (this.#lockArray) {
524
+ const idx = hash % this.#lockArray.length;
525
+ Atomics.sub(this.#lockArray, idx, 1);
526
+ Atomics.notify(this.#lockArray, idx);
527
+ }
528
+ }
529
+ #refresh(args, key, hash = HASHER.h32(key), acquired = false) {
371
530
  let value;
372
531
  try {
373
532
  value = this.#valueSelector(...args);
374
533
  }
375
534
  catch (err) {
376
- if (this.#lockArray && Atomics.sub(this.#lockArray, idx, 1) === 1) {
377
- Atomics.notify(this.#lockArray, idx);
535
+ if (acquired) {
536
+ this.#release(hash);
378
537
  }
379
538
  throw err;
380
539
  }
381
540
  if (!isThenable(value)) {
382
- try {
383
- this.#set(key, value);
384
- }
385
- finally {
386
- if (this.#lockArray && Atomics.sub(this.#lockArray, idx, 1) === 1) {
387
- Atomics.notify(this.#lockArray, idx);
388
- }
389
- }
541
+ this.#set(key, value, hash, acquired);
390
542
  return { async: false, value };
391
543
  }
392
544
  // Store the chained promise (with #set + lock-release wired in) in #dedupe
393
545
  // so concurrent get() callers see the same Promise instance as the original
394
546
  // caller — required for promise-identity-based dedupe semantics.
395
547
  const promise = Promise.resolve(value).then((value) => {
396
- // finally guarantees lock release even if #set throws (user-supplied
397
- // serializer / ttl / stale functions can throw); otherwise the slot
398
- // would stay at 1 forever and deadlock the key.
399
- try {
400
- // Identity check: if delete() or another refresh replaced our dedupe
401
- // entry, skip the write so we don't resurrect a deleted key or
402
- // overwrite a newer value. #set itself clears dedupe on entry.
403
- if (this.#dedupe.get(key) === promise) {
404
- this.#set(key, value);
405
- }
548
+ // Identity check: if delete() or another refresh replaced our dedupe
549
+ // entry, skip the write so we don't resurrect a deleted key or
550
+ // overwrite a newer value. #set itself clears dedupe on entry.
551
+ if (this.#dedupe.get(key) === promise) {
552
+ this.#set(key, value, hash, acquired);
406
553
  }
407
- finally {
408
- if (this.#lockArray && Atomics.sub(this.#lockArray, idx, 1) === 1) {
409
- Atomics.notify(this.#lockArray, idx);
410
- }
554
+ else if (acquired) {
555
+ // Even if we're not going to write the value, we still need to release
556
+ // the lock so that other callers aren't starved.
557
+ this.#release(hash);
411
558
  }
412
559
  return value;
413
560
  }, (err) => {
414
- try {
415
- if (this.#dedupe.get(key) === promise) {
416
- this.#dedupe.delete(key);
417
- }
561
+ if (this.#dedupe.get(key) === promise) {
562
+ this.#dedupe.delete(key);
418
563
  }
419
- finally {
420
- if (this.#lockArray && Atomics.sub(this.#lockArray, idx, 1) === 1) {
421
- Atomics.notify(this.#lockArray, idx);
422
- }
564
+ if (acquired) {
565
+ this.#release(hash);
423
566
  }
424
567
  throw err;
425
568
  });
426
569
  this.#dedupe.set(key, promise);
427
570
  return { async: true, value: promise };
428
571
  }
429
- #set(key, value) {
430
- if (typeof key !== 'string' || key.length === 0) {
431
- throw new TypeError('key must be a non-empty string');
432
- }
433
- // Drop any in-flight dedupe entry so an async refresh that resolves after
434
- // this call cannot resurrect a deleted key or overwrite a newer value —
435
- // the async path's onFulfilled identity-checks before calling #set.
436
- this.#dedupe.delete(key);
437
- if (value === undefined) {
438
- // Slow path... Should not be common...
439
- this.#memory?.delete(key);
440
- this.#setBatch = this.#setBatch.filter((item) => item.key !== key);
441
- try {
442
- this.#delQuery?.run(key);
572
+ #set(key, value, hash = HASHER.h32(key), acquired = false) {
573
+ try {
574
+ if (typeof key !== 'string' || key.length === 0) {
575
+ throw new TypeError('key must be a non-empty string');
576
+ }
577
+ // Drop any in-flight dedupe entry so an async refresh that resolves after
578
+ // this call cannot resurrect a deleted key or overwrite a newer value —
579
+ // the async path's onFulfilled identity-checks before calling #set.
580
+ this.#dedupe.delete(key);
581
+ const shard = this.#shards ? this.#shards[hash % this.#shards.length] : null;
582
+ if (value === undefined) {
583
+ // Slow path... Should not be common...
584
+ this.#memory?.delete(key);
585
+ if (shard) {
586
+ for (const item of shard.setBatch.splice(0)) {
587
+ if (item.key !== key) {
588
+ shard.setBatch.push(item);
589
+ }
590
+ else if (item.acquired) {
591
+ this.#release(item.hash);
592
+ }
593
+ }
594
+ try {
595
+ shard.delQuery.run(key);
596
+ }
597
+ catch (err) {
598
+ this.#emitError(err);
599
+ }
600
+ }
601
+ return;
443
602
  }
444
- catch (err) {
445
- this.#emitError(err);
603
+ const ttlValue = Math.min(MAX_DURATION, this.#ttl(value, key) ?? Infinity);
604
+ if (!Number.isFinite(ttlValue) || ttlValue < 0) {
605
+ throw new TypeError('ttl must be undefined, null, or a non-negative finite number');
446
606
  }
447
- return;
448
- }
449
- const ttlValue = Math.min(MAX_DURATION, this.#ttl(value, key) ?? Infinity);
450
- if (!Number.isFinite(ttlValue) || ttlValue < 0) {
451
- throw new TypeError('ttl must be undefined, null, or a non-negative finite number');
452
- }
453
- const staleValue = Math.min(MAX_DURATION, this.#stale(value, key) ?? Infinity);
454
- if (!Number.isFinite(staleValue) || staleValue < 0) {
455
- throw new TypeError('stale must be undefined, null, or a non-negative finite number');
456
- }
457
- const now = Date.now();
458
- const ttl = now + ttlValue;
459
- const stale = ttl + staleValue;
460
- if (stale <= now) {
461
- return;
607
+ const staleValue = Math.min(MAX_DURATION, this.#stale(value, key) ?? Infinity);
608
+ if (!Number.isFinite(staleValue) || staleValue < 0) {
609
+ throw new TypeError('stale must be undefined, null, or a non-negative finite number');
610
+ }
611
+ const now = Date.now();
612
+ const stale = now + ttlValue;
613
+ const expire = stale + staleValue;
614
+ if (expire <= now) {
615
+ return;
616
+ }
617
+ const data = this.#serializer.serialize(value);
618
+ const entry = this.#createEntry(key, value, stale, expire, ArrayBuffer.isView(data) ? data.byteLength : data.length * 2);
619
+ this.#memory?.set(key, entry);
620
+ if (!shard) {
621
+ return;
622
+ }
623
+ if (!this.#flushHandle) {
624
+ this.#memory?.cork();
625
+ this.#flushHandle = setImmediate(this.#flush);
626
+ }
627
+ else if (shard.setBatch.length > 512) {
628
+ clearImmediate(this.#flushHandle);
629
+ this.#flush();
630
+ }
631
+ shard.setBatch.push({ key, data, stale, expire, hash, acquired });
632
+ acquired = false;
462
633
  }
463
- const data = this.#serializer.serialize(value);
464
- const entry = this.#createEntry(key, value, ttl, stale, ArrayBuffer.isView(data) ? data.byteLength : data.length * 2);
465
- this.#memory?.set(key, entry);
466
- if (!this.#flushHandle) {
467
- this.#memory?.cork();
468
- this.#flushHandle = setImmediate(this.#flush);
634
+ finally {
635
+ if (acquired) {
636
+ this.#release(hash);
637
+ }
469
638
  }
470
- else if (this.#setBatch.length > 512) {
471
- clearImmediate(this.#flushHandle);
472
- this.#flush();
639
+ }
640
+ #releaseBatch(batch, start, deleteCount) {
641
+ for (const item of batch.splice(start, deleteCount)) {
642
+ if (item.acquired) {
643
+ this.#release(item.hash);
644
+ }
473
645
  }
474
- this.#setBatch.push({ key, data, ttl, stale });
475
646
  }
476
647
  #flush = () => {
477
648
  this.#flushHandle = null;
478
- if (this.#setBatch.length === 0 || this.#closed || this.#database == null) {
479
- this.#setBatch.length = 0;
480
- this.#memory?.uncork();
481
- return;
482
- }
483
- try {
484
- const startTime = performance.now();
485
- for (let retryCount = 0; true; retryCount++) {
486
- let n = 0;
487
- try {
488
- this.#database.exec('BEGIN');
489
- while (n < this.#setBatch.length) {
490
- const { key, data, ttl, stale } = this.#setBatch[n++];
491
- if (data != null) {
492
- this.#setQuery?.run(key, data, ttl, stale);
493
- }
494
- else {
495
- this.#delQuery?.run(key);
496
- }
497
- if ((n & 0xf) === 0 && performance.now() - startTime > 10) {
498
- break;
499
- }
649
+ const startTime = performance.now();
650
+ const len = this.#shards?.length ?? 0;
651
+ const idx = Math.floor(Math.random() * len);
652
+ for (let k = 0; k < len; k++) {
653
+ const shard = this.#shards[(idx + k) % len];
654
+ try {
655
+ for (let retryCount = 0; true; retryCount++) {
656
+ if (shard.setBatch.length === 0) {
657
+ break;
500
658
  }
501
- this.#database.exec('COMMIT');
502
- this.#setBatch.splice(0, n);
503
- break;
504
- }
505
- catch (err) {
506
- // ROLLBACK is required: a failed statement leaves the connection with
507
- // an open transaction; without it the next BEGIN would throw.
508
- // On SQLITE_FULL, SQLite automatically rolls back the transaction, so
509
- // the explicit ROLLBACK may fail with "no transaction is active" — ignore it.
659
+ let n = 0;
510
660
  try {
511
- this.#database.exec('ROLLBACK');
512
- }
513
- catch {
514
- // already rolled back automatically
515
- // TODO (fix): Check that the error is what we expect (something like "no transaction is active")...
516
- }
517
- if (err?.errcode === 13 /* SQLITE_FULL */ &&
518
- retryCount < 3 &&
519
- this.#evictQuery != null) {
520
- this.#evictQuery.run(256);
661
+ shard.database.exec('BEGIN');
662
+ while (n < shard.setBatch.length) {
663
+ const { key, data, stale, expire } = shard.setBatch[n++];
664
+ if (data != null) {
665
+ shard.setQuery.run(key, data, stale, expire);
666
+ }
667
+ else {
668
+ shard.delQuery.run(key);
669
+ }
670
+ }
671
+ shard.database.exec('COMMIT');
672
+ this.#releaseBatch(shard.setBatch, 0, n);
673
+ break;
521
674
  }
522
- else {
523
- // Intentional: drop the rolled-back items from the batch and surface
524
- // the error via #emitError below. The corresponding entries remain in
525
- // #memory until natural eviction/TTL, which is fine for cache semantics
526
- // callers already accept that cache values can disappear at any time.
527
- // Do NOT flag this as silent data loss (see closed issue #167).
528
- this.#setBatch.splice(0, n);
529
- throw err;
675
+ catch (err) {
676
+ // ROLLBACK is required: a failed statement leaves the connection with
677
+ // an open transaction; without it the next BEGIN would throw.
678
+ // On SQLITE_FULL, SQLite automatically rolls back the transaction, so
679
+ // the explicit ROLLBACK may fail with "no transaction is active" ignore it.
680
+ try {
681
+ shard.database.exec('ROLLBACK');
682
+ }
683
+ catch {
684
+ // already rolled back automatically
685
+ // TODO (fix): Check that the error is what we expect (something like "no transaction is active")...
686
+ }
687
+ if (err?.errcode === 13 /* SQLITE_FULL */ && retryCount < 3) {
688
+ shard.evictQuery.run(256);
689
+ }
690
+ else {
691
+ // Intentional: drop the rolled-back items from the batch and surface
692
+ // the error via #emitError below. The corresponding entries remain in
693
+ // #memory until natural eviction/TTL, which is fine for cache semantics
694
+ // — callers already accept that cache values can disappear at any time.
695
+ // Do NOT flag this as silent data loss (see closed issue #167).
696
+ this.#releaseBatch(shard.setBatch, 0, n);
697
+ throw err;
698
+ }
530
699
  }
531
700
  }
532
701
  }
702
+ catch (err) {
703
+ this.#releaseBatch(shard.setBatch, 0, shard.setBatch.length);
704
+ this.#emitError(err);
705
+ }
706
+ if (performance.now() - startTime > 10) {
707
+ this.#flushHandle = setImmediate(this.#flush);
708
+ return;
709
+ }
533
710
  }
534
- catch (err) {
535
- this.#emitError(err);
536
- }
537
- if (this.#setBatch.length > 0) {
538
- // If we weren't able to flush the entire batch within the time limit, schedule another flush.
539
- this.#flushHandle = setImmediate(this.#flush);
540
- }
541
- else {
542
- this.#memory?.uncork();
543
- }
711
+ this.#memory?.uncork();
544
712
  };
545
- #createEntry(key, value, ttl, stale, size) {
713
+ #createEntry(key, value, stale, expire, size) {
546
714
  return {
547
- ttl,
548
715
  stale,
716
+ expire,
549
717
  value,
550
718
  key,
551
719
  size,
@@ -555,7 +723,7 @@ export class Cache extends EventEmitter {
555
723
  }
556
724
  #loadRow(key, row) {
557
725
  const value = this.#serializer.deserialize(maybeToBuffer(row.val));
558
- return this.#createEntry(key, value, row.ttl, row.stale, ArrayBuffer.isView(row.val) ? row.val.byteLength : row.val.length * 2);
726
+ return this.#createEntry(key, value, row.stale, row.expire, ArrayBuffer.isView(row.val) ? row.val.byteLength : row.val.length * 2);
559
727
  }
560
728
  }
561
729
  {
@@ -564,13 +732,21 @@ export class Cache extends EventEmitter {
564
732
  offPeakBC.onmessage = () => {
565
733
  for (const db of dbs) {
566
734
  try {
567
- db.purgeStale();
735
+ db.gc();
568
736
  }
569
737
  catch (err) {
570
- if (db.listenerCount('error') > 0) {
571
- db.emit('error', err);
738
+ // Match #emitError semantics: don't let a throwing user error
739
+ // listener starve the remaining caches in `dbs`.
740
+ try {
741
+ if (db.listenerCount('error') > 0) {
742
+ db.emit('error', err);
743
+ }
744
+ else {
745
+ process.emitWarning(err);
746
+ }
572
747
  }
573
- else {
748
+ catch (listenerErr) {
749
+ process.emitWarning(listenerErr);
574
750
  process.emitWarning(err);
575
751
  }
576
752
  }