@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/README.md +105 -19
- package/lib/index.d.ts +3 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +486 -310
- package/lib/index.js.map +1 -1
- package/lib/memory.d.ts +2 -2
- package/lib/memory.d.ts.map +1 -1
- package/lib/memory.js +15 -4
- package/lib/memory.js.map +1 -1
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -9,10 +9,33 @@ function maybeToBuffer(value) {
|
|
|
9
9
|
: value;
|
|
10
10
|
}
|
|
11
11
|
function isThenable(value) {
|
|
12
|
-
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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.#
|
|
172
|
-
let size;
|
|
264
|
+
if (this.#shards) {
|
|
265
|
+
let size = 0;
|
|
173
266
|
try {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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.#
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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.#
|
|
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
|
-
|
|
358
|
+
gc() {
|
|
260
359
|
if (this.#closed) {
|
|
261
360
|
throw new Error('cache is closed');
|
|
262
361
|
}
|
|
263
|
-
this.#memory?.
|
|
264
|
-
this.#
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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 (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
351
|
-
|
|
454
|
+
catch (err) {
|
|
455
|
+
this.#emitError(err);
|
|
456
|
+
}
|
|
352
457
|
}
|
|
353
458
|
return { async: false, value: cached.value };
|
|
354
459
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
520
|
+
return undefined;
|
|
369
521
|
}
|
|
370
|
-
#
|
|
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 (
|
|
377
|
-
|
|
535
|
+
if (acquired) {
|
|
536
|
+
this.#release(hash);
|
|
378
537
|
}
|
|
379
538
|
throw err;
|
|
380
539
|
}
|
|
381
540
|
if (!isThenable(value)) {
|
|
382
|
-
|
|
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
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
408
|
-
if
|
|
409
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
this.#dedupe.delete(key);
|
|
417
|
-
}
|
|
561
|
+
if (this.#dedupe.get(key) === promise) {
|
|
562
|
+
this.#dedupe.delete(key);
|
|
418
563
|
}
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
this.#memory?.cork();
|
|
468
|
-
this.#flushHandle = setImmediate(this.#flush);
|
|
634
|
+
finally {
|
|
635
|
+
if (acquired) {
|
|
636
|
+
this.#release(hash);
|
|
637
|
+
}
|
|
469
638
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
735
|
+
db.gc();
|
|
568
736
|
}
|
|
569
737
|
catch (err) {
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
748
|
+
catch (listenerErr) {
|
|
749
|
+
process.emitWarning(listenerErr);
|
|
574
750
|
process.emitWarning(err);
|
|
575
751
|
}
|
|
576
752
|
}
|