tick-cache 1.0.0
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/LICENSE +21 -0
- package/README.md +422 -0
- package/dist/index.cjs +718 -0
- package/dist/index.d.cts +64 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +691 -0
- package/package.json +70 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
TtlWheelCache: () => TtlWheelCache
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/constants.ts
|
|
28
|
+
var NIL = -1;
|
|
29
|
+
var BUCKET_NONE = -1;
|
|
30
|
+
var BUCKET_OVERFLOW = -2;
|
|
31
|
+
|
|
32
|
+
// src/entry-store.ts
|
|
33
|
+
function fillI32(arr, value) {
|
|
34
|
+
arr.fill(value);
|
|
35
|
+
}
|
|
36
|
+
var EntryStore = class {
|
|
37
|
+
maxEntries;
|
|
38
|
+
cap;
|
|
39
|
+
sizeAllocated;
|
|
40
|
+
// next fresh id
|
|
41
|
+
freeList;
|
|
42
|
+
// LIFO stack of free ids
|
|
43
|
+
freeCount;
|
|
44
|
+
// number of free ids in the stack
|
|
45
|
+
// SoA refs
|
|
46
|
+
keyRef;
|
|
47
|
+
valRef;
|
|
48
|
+
// SoA metadata
|
|
49
|
+
expiresTick;
|
|
50
|
+
ttlMs;
|
|
51
|
+
// Original TTL in milliseconds (for updateTTLOnGet)
|
|
52
|
+
wheelNext;
|
|
53
|
+
wheelPrev;
|
|
54
|
+
lruNext;
|
|
55
|
+
lruPrev;
|
|
56
|
+
wheelBucket;
|
|
57
|
+
constructor(opts) {
|
|
58
|
+
const { maxEntries } = opts;
|
|
59
|
+
if (!Number.isInteger(maxEntries) || maxEntries <= 0) {
|
|
60
|
+
throw new Error("maxEntries must be a positive integer");
|
|
61
|
+
}
|
|
62
|
+
this.maxEntries = maxEntries;
|
|
63
|
+
const initialCap = opts.initialCap ?? Math.min(1024, maxEntries);
|
|
64
|
+
if (!Number.isInteger(initialCap) || initialCap <= 0) {
|
|
65
|
+
throw new Error("initialCap must be a positive integer");
|
|
66
|
+
}
|
|
67
|
+
if (initialCap > maxEntries) {
|
|
68
|
+
throw new Error("initialCap cannot exceed maxEntries");
|
|
69
|
+
}
|
|
70
|
+
this.cap = initialCap;
|
|
71
|
+
this.sizeAllocated = 0;
|
|
72
|
+
this.freeList = new Int32Array(maxEntries);
|
|
73
|
+
this.freeCount = 0;
|
|
74
|
+
this.keyRef = new Array(this.cap);
|
|
75
|
+
this.valRef = new Array(this.cap);
|
|
76
|
+
this.expiresTick = new Uint32Array(this.cap);
|
|
77
|
+
this.ttlMs = new Uint32Array(this.cap);
|
|
78
|
+
this.wheelNext = new Int32Array(this.cap);
|
|
79
|
+
this.wheelPrev = new Int32Array(this.cap);
|
|
80
|
+
this.lruNext = new Int32Array(this.cap);
|
|
81
|
+
this.lruPrev = new Int32Array(this.cap);
|
|
82
|
+
this.wheelBucket = new Int32Array(this.cap);
|
|
83
|
+
fillI32(this.wheelNext, NIL);
|
|
84
|
+
fillI32(this.wheelPrev, NIL);
|
|
85
|
+
fillI32(this.lruNext, NIL);
|
|
86
|
+
fillI32(this.lruPrev, NIL);
|
|
87
|
+
fillI32(this.wheelBucket, BUCKET_NONE);
|
|
88
|
+
}
|
|
89
|
+
debug() {
|
|
90
|
+
return {
|
|
91
|
+
cap: this.cap,
|
|
92
|
+
sizeAllocated: this.sizeAllocated,
|
|
93
|
+
freeCount: this.freeCount
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Allocate an entryId.
|
|
98
|
+
* Returns -1 if impossible (at maxEntries and no free slot).
|
|
99
|
+
*/
|
|
100
|
+
allocId() {
|
|
101
|
+
if (this.freeCount > 0) {
|
|
102
|
+
const reused = this.freeList[--this.freeCount];
|
|
103
|
+
this.resetSlot(reused);
|
|
104
|
+
return reused;
|
|
105
|
+
}
|
|
106
|
+
if (this.sizeAllocated >= this.maxEntries) return -1;
|
|
107
|
+
const id = this.sizeAllocated++;
|
|
108
|
+
if (id >= this.cap) {
|
|
109
|
+
this.ensureCapacity(id + 1);
|
|
110
|
+
}
|
|
111
|
+
this.resetSlot(id);
|
|
112
|
+
return id;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Store key and value for the given entry ID.
|
|
116
|
+
* Should be called after allocating an entry ID.
|
|
117
|
+
*/
|
|
118
|
+
setEntry(id, key, value) {
|
|
119
|
+
if (!Number.isInteger(id) || id < 0 || id >= this.cap) {
|
|
120
|
+
throw new Error(`invalid entryId: ${id}`);
|
|
121
|
+
}
|
|
122
|
+
this.keyRef[id] = key;
|
|
123
|
+
this.valRef[id] = value;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Free an entryId back to the free list.
|
|
127
|
+
* Throws on double-free.
|
|
128
|
+
*/
|
|
129
|
+
freeId(id) {
|
|
130
|
+
if (!Number.isInteger(id) || id < 0 || id >= this.cap) {
|
|
131
|
+
throw new Error(`invalid entryId: ${id}`);
|
|
132
|
+
}
|
|
133
|
+
if (this.keyRef[id] === void 0) {
|
|
134
|
+
throw new Error(`double-free detected for entryId=${id}`);
|
|
135
|
+
}
|
|
136
|
+
this.resetSlot(id);
|
|
137
|
+
this.freeList[this.freeCount++] = id;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Reset all SoA fields for a slot to the neutral state.
|
|
141
|
+
*/
|
|
142
|
+
resetSlot(id) {
|
|
143
|
+
this.keyRef[id] = void 0;
|
|
144
|
+
this.valRef[id] = void 0;
|
|
145
|
+
this.expiresTick[id] = 0;
|
|
146
|
+
this.ttlMs[id] = 0;
|
|
147
|
+
this.wheelNext[id] = NIL;
|
|
148
|
+
this.wheelPrev[id] = NIL;
|
|
149
|
+
this.lruNext[id] = NIL;
|
|
150
|
+
this.lruPrev[id] = NIL;
|
|
151
|
+
this.wheelBucket[id] = BUCKET_NONE;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Growth strategy: doubling, capped at maxEntries.
|
|
155
|
+
* Copies typed arrays.
|
|
156
|
+
*/
|
|
157
|
+
ensureCapacity(required) {
|
|
158
|
+
if (required <= this.cap) return;
|
|
159
|
+
let newCap = this.cap;
|
|
160
|
+
while (newCap < required) {
|
|
161
|
+
const prevCap = newCap;
|
|
162
|
+
newCap = Math.min(newCap * 2, this.maxEntries);
|
|
163
|
+
if (newCap === prevCap) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`cannot grow capacity to ${required} (maxEntries=${this.maxEntries})`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
this.keyRef.length = newCap;
|
|
170
|
+
this.valRef.length = newCap;
|
|
171
|
+
const oldExpires = this.expiresTick;
|
|
172
|
+
this.expiresTick = new Uint32Array(newCap);
|
|
173
|
+
this.expiresTick.set(oldExpires);
|
|
174
|
+
const oldTtlMs = this.ttlMs;
|
|
175
|
+
this.ttlMs = new Uint32Array(newCap);
|
|
176
|
+
this.ttlMs.set(oldTtlMs);
|
|
177
|
+
const oldWheelNext = this.wheelNext;
|
|
178
|
+
const oldWheelPrev = this.wheelPrev;
|
|
179
|
+
const oldLruNext = this.lruNext;
|
|
180
|
+
const oldLruPrev = this.lruPrev;
|
|
181
|
+
const oldWheelBucket = this.wheelBucket;
|
|
182
|
+
this.wheelNext = new Int32Array(newCap);
|
|
183
|
+
this.wheelPrev = new Int32Array(newCap);
|
|
184
|
+
this.lruNext = new Int32Array(newCap);
|
|
185
|
+
this.lruPrev = new Int32Array(newCap);
|
|
186
|
+
this.wheelBucket = new Int32Array(newCap);
|
|
187
|
+
fillI32(this.wheelNext, NIL);
|
|
188
|
+
fillI32(this.wheelPrev, NIL);
|
|
189
|
+
fillI32(this.lruNext, NIL);
|
|
190
|
+
fillI32(this.lruPrev, NIL);
|
|
191
|
+
fillI32(this.wheelBucket, BUCKET_NONE);
|
|
192
|
+
this.wheelNext.set(oldWheelNext);
|
|
193
|
+
this.wheelPrev.set(oldWheelPrev);
|
|
194
|
+
this.lruNext.set(oldLruNext);
|
|
195
|
+
this.lruPrev.set(oldLruPrev);
|
|
196
|
+
this.wheelBucket.set(oldWheelBucket);
|
|
197
|
+
this.cap = newCap;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// src/timer-wheel.ts
|
|
202
|
+
function isPowerOfTwo(n) {
|
|
203
|
+
return n > 0 && (n & n - 1) === 0;
|
|
204
|
+
}
|
|
205
|
+
var TimerWheel = class {
|
|
206
|
+
store;
|
|
207
|
+
ticker;
|
|
208
|
+
wheelSize;
|
|
209
|
+
wheelMask;
|
|
210
|
+
horizonTicks;
|
|
211
|
+
budgetPerTick;
|
|
212
|
+
// Heads of bucket lists
|
|
213
|
+
wheelHeads;
|
|
214
|
+
// Overflow list head (uses store.wheelNext/Prev too)
|
|
215
|
+
overflowHead = NIL;
|
|
216
|
+
overflowCountApprox = 0;
|
|
217
|
+
// Current processed tick (discrete)
|
|
218
|
+
nowTick;
|
|
219
|
+
// continuation state when budget is exceeded mid-advance
|
|
220
|
+
pendingTargetTick = null;
|
|
221
|
+
constructor(opts) {
|
|
222
|
+
this.store = opts.store;
|
|
223
|
+
this.ticker = opts.ticker;
|
|
224
|
+
if (!Number.isInteger(opts.wheelSize) || opts.wheelSize < 2 || !isPowerOfTwo(opts.wheelSize)) {
|
|
225
|
+
throw new Error("wheelSize must be a power of two >= 2");
|
|
226
|
+
}
|
|
227
|
+
if (!Number.isInteger(opts.budgetPerTick) || opts.budgetPerTick <= 0) {
|
|
228
|
+
throw new Error("budgetPerTick must be a positive integer");
|
|
229
|
+
}
|
|
230
|
+
this.wheelSize = opts.wheelSize;
|
|
231
|
+
this.wheelMask = this.wheelSize - 1;
|
|
232
|
+
this.horizonTicks = this.wheelSize;
|
|
233
|
+
this.budgetPerTick = opts.budgetPerTick;
|
|
234
|
+
this.wheelHeads = new Int32Array(this.wheelSize);
|
|
235
|
+
this.wheelHeads.fill(NIL);
|
|
236
|
+
this.nowTick = opts.initialNowTick ?? this.ticker.nowTick();
|
|
237
|
+
}
|
|
238
|
+
stats() {
|
|
239
|
+
return {
|
|
240
|
+
nowTick: this.nowTick,
|
|
241
|
+
horizonTicks: this.horizonTicks,
|
|
242
|
+
overflowCountApprox: this.overflowCountApprox
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Schedule or reschedule an entryId to expire at expTick.
|
|
247
|
+
* This method will unlink it from wherever it currently is (if any), then link it.
|
|
248
|
+
*/
|
|
249
|
+
schedule(id, expTick) {
|
|
250
|
+
if (!Number.isInteger(expTick) || expTick < 0) {
|
|
251
|
+
throw new Error("expTick must be a non-negative integer");
|
|
252
|
+
}
|
|
253
|
+
if (expTick <= this.nowTick) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Cannot schedule in the past: expTick=${expTick}, nowTick=${this.nowTick}`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
this.unlink(id);
|
|
259
|
+
this.store.expiresTick[id] = expTick >>> 0;
|
|
260
|
+
const delta = expTick - this.nowTick;
|
|
261
|
+
if (delta > this.horizonTicks) {
|
|
262
|
+
this.linkOverflow(id);
|
|
263
|
+
} else {
|
|
264
|
+
const bucket = this.bucketOf(expTick);
|
|
265
|
+
this.linkWheelHead(id, bucket);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Unlink an id from wheel or overflow if it is linked. O(1).
|
|
270
|
+
*/
|
|
271
|
+
unlink(id) {
|
|
272
|
+
const b = this.store.wheelBucket[id];
|
|
273
|
+
if (b === BUCKET_NONE) return;
|
|
274
|
+
if (b === BUCKET_OVERFLOW) {
|
|
275
|
+
this.unlinkOverflow(id);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
this.unlinkWheel(id, b);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Advance time to current ticker time (monotonic) and process expirations.
|
|
282
|
+
* Returns true if fully caught up, false if budget exceeded and needs another call.
|
|
283
|
+
*/
|
|
284
|
+
advanceToNow(onExpire) {
|
|
285
|
+
const targetTick = this.ticker.nowTick();
|
|
286
|
+
return this.advanceToTick(targetTick, onExpire);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* - Processes ticks in order: (nowTick+1 .. targetTick)
|
|
290
|
+
* - Drift is naturally coalesced by processing multiple ticks at once, but guarded by budget.
|
|
291
|
+
*/
|
|
292
|
+
advanceToTick(targetTick, onExpire) {
|
|
293
|
+
if (!Number.isInteger(targetTick) || targetTick < 0) throw new Error("targetTick must be a non-negative integer");
|
|
294
|
+
if (this.pendingTargetTick !== null) {
|
|
295
|
+
targetTick = Math.max(targetTick, this.pendingTargetTick);
|
|
296
|
+
}
|
|
297
|
+
let processed = 0;
|
|
298
|
+
while (this.nowTick < targetTick) {
|
|
299
|
+
this.nowTick++;
|
|
300
|
+
processed += this.drainOverflowWithinHorizon(processed, onExpire);
|
|
301
|
+
if (processed >= this.budgetPerTick) {
|
|
302
|
+
this.pendingTargetTick = targetTick;
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
const bucket = this.bucketOf(this.nowTick);
|
|
306
|
+
processed += this.processBucket(bucket, processed, onExpire);
|
|
307
|
+
if (processed >= this.budgetPerTick) {
|
|
308
|
+
this.pendingTargetTick = targetTick;
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
this.pendingTargetTick = null;
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
// ---- Wheel internals ----
|
|
316
|
+
bucketOf(tick) {
|
|
317
|
+
return tick & this.wheelMask;
|
|
318
|
+
}
|
|
319
|
+
linkWheelHead(id, bucket) {
|
|
320
|
+
const head = this.wheelHeads[bucket];
|
|
321
|
+
this.store.wheelBucket[id] = bucket;
|
|
322
|
+
this.store.wheelPrev[id] = NIL;
|
|
323
|
+
this.store.wheelNext[id] = head;
|
|
324
|
+
if (head !== NIL) this.store.wheelPrev[head] = id;
|
|
325
|
+
this.wheelHeads[bucket] = id;
|
|
326
|
+
}
|
|
327
|
+
unlinkWheel(id, bucket) {
|
|
328
|
+
const prev = this.store.wheelPrev[id];
|
|
329
|
+
const next = this.store.wheelNext[id];
|
|
330
|
+
if (prev !== NIL) {
|
|
331
|
+
this.store.wheelNext[prev] = next;
|
|
332
|
+
} else {
|
|
333
|
+
this.wheelHeads[bucket] = next;
|
|
334
|
+
}
|
|
335
|
+
if (next !== NIL) {
|
|
336
|
+
this.store.wheelPrev[next] = prev;
|
|
337
|
+
}
|
|
338
|
+
this.store.wheelPrev[id] = NIL;
|
|
339
|
+
this.store.wheelNext[id] = NIL;
|
|
340
|
+
this.store.wheelBucket[id] = BUCKET_NONE;
|
|
341
|
+
}
|
|
342
|
+
// ---- Overflow internals ----
|
|
343
|
+
linkOverflow(id) {
|
|
344
|
+
const head = this.overflowHead;
|
|
345
|
+
this.store.wheelBucket[id] = BUCKET_OVERFLOW;
|
|
346
|
+
this.store.wheelPrev[id] = NIL;
|
|
347
|
+
this.store.wheelNext[id] = head;
|
|
348
|
+
if (head !== NIL) this.store.wheelPrev[head] = id;
|
|
349
|
+
this.overflowHead = id;
|
|
350
|
+
this.overflowCountApprox++;
|
|
351
|
+
}
|
|
352
|
+
unlinkOverflow(id) {
|
|
353
|
+
const prev = this.store.wheelPrev[id];
|
|
354
|
+
1;
|
|
355
|
+
const next = this.store.wheelNext[id];
|
|
356
|
+
3;
|
|
357
|
+
if (prev !== NIL) {
|
|
358
|
+
this.store.wheelNext[prev] = next;
|
|
359
|
+
} else {
|
|
360
|
+
this.overflowHead = next;
|
|
361
|
+
}
|
|
362
|
+
if (next !== NIL) {
|
|
363
|
+
this.store.wheelPrev[next] = prev;
|
|
364
|
+
}
|
|
365
|
+
this.store.wheelPrev[id] = NIL;
|
|
366
|
+
this.store.wheelNext[id] = NIL;
|
|
367
|
+
this.store.wheelBucket[id] = BUCKET_NONE;
|
|
368
|
+
if (this.overflowCountApprox > 0) this.overflowCountApprox--;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Move overflow entries into the wheel when they are close enough.
|
|
372
|
+
* Unsorted overflow list: we scan until we hit budget.
|
|
373
|
+
*/
|
|
374
|
+
drainOverflowWithinHorizon(processedSoFar, onExpire) {
|
|
375
|
+
let processed = 0;
|
|
376
|
+
let cursor = this.overflowHead;
|
|
377
|
+
while (cursor !== NIL && processedSoFar + processed < this.budgetPerTick) {
|
|
378
|
+
const id = cursor;
|
|
379
|
+
const nextCursor = this.store.wheelNext[id];
|
|
380
|
+
const exp = this.store.expiresTick[id] >>> 0;
|
|
381
|
+
const delta = exp - this.nowTick;
|
|
382
|
+
if (delta <= this.horizonTicks) {
|
|
383
|
+
this.unlinkOverflow(id);
|
|
384
|
+
if (exp <= this.nowTick) {
|
|
385
|
+
onExpire(id);
|
|
386
|
+
} else {
|
|
387
|
+
this.linkWheelHead(id, this.bucketOf(exp));
|
|
388
|
+
}
|
|
389
|
+
processed++;
|
|
390
|
+
}
|
|
391
|
+
cursor = nextCursor;
|
|
392
|
+
}
|
|
393
|
+
return processed;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Process a wheel bucket (batch).
|
|
397
|
+
* For each entry:
|
|
398
|
+
* - if expired (expTick <= nowTick): expire
|
|
399
|
+
* - else: guardrail: move to correct bucket if necessary
|
|
400
|
+
*/
|
|
401
|
+
processBucket(bucket, processedSoFar, onExpire) {
|
|
402
|
+
let processed = 0;
|
|
403
|
+
let cursor = this.wheelHeads[bucket];
|
|
404
|
+
while (cursor !== NIL && processedSoFar + processed < this.budgetPerTick) {
|
|
405
|
+
const id = cursor;
|
|
406
|
+
const nextCursor = this.store.wheelNext[id];
|
|
407
|
+
const exp = this.store.expiresTick[id] >>> 0;
|
|
408
|
+
if (exp <= this.nowTick) {
|
|
409
|
+
this.unlinkWheel(id, bucket);
|
|
410
|
+
onExpire(id);
|
|
411
|
+
} else {
|
|
412
|
+
const correctBucket = this.bucketOf(exp);
|
|
413
|
+
if (correctBucket !== bucket) {
|
|
414
|
+
this.unlinkWheel(id, bucket);
|
|
415
|
+
this.linkWheelHead(id, correctBucket);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
processed++;
|
|
419
|
+
cursor = nextCursor;
|
|
420
|
+
}
|
|
421
|
+
return processed;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// src/monotone-time.ts
|
|
426
|
+
var import_node_perf_hooks = require("perf_hooks");
|
|
427
|
+
var PerfTimeSource = class {
|
|
428
|
+
nowMs() {
|
|
429
|
+
return import_node_perf_hooks.performance.now();
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
var MonotoneTicker = class {
|
|
433
|
+
tickMs;
|
|
434
|
+
time;
|
|
435
|
+
constructor(opts) {
|
|
436
|
+
if (!Number.isFinite(opts.tickMs) || opts.tickMs <= 0) {
|
|
437
|
+
throw new Error("tickMs must be > 0");
|
|
438
|
+
}
|
|
439
|
+
this.tickMs = opts.tickMs;
|
|
440
|
+
this.time = opts.time ?? new PerfTimeSource();
|
|
441
|
+
}
|
|
442
|
+
nowTick() {
|
|
443
|
+
const ms = this.time.nowMs();
|
|
444
|
+
return Math.floor(ms / this.tickMs);
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// src/lru-list.ts
|
|
449
|
+
var LruList = class {
|
|
450
|
+
store;
|
|
451
|
+
head;
|
|
452
|
+
tail;
|
|
453
|
+
constructor(store) {
|
|
454
|
+
this.store = store;
|
|
455
|
+
this.head = NIL;
|
|
456
|
+
this.tail = NIL;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Link an entry to the head of the LRU list (most recent position).
|
|
460
|
+
*/
|
|
461
|
+
linkHead(id) {
|
|
462
|
+
const oldHead = this.head;
|
|
463
|
+
this.store.lruNext[id] = oldHead;
|
|
464
|
+
this.store.lruPrev[id] = NIL;
|
|
465
|
+
if (oldHead !== NIL) {
|
|
466
|
+
this.store.lruPrev[oldHead] = id;
|
|
467
|
+
} else {
|
|
468
|
+
this.tail = id;
|
|
469
|
+
}
|
|
470
|
+
this.head = id;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Remove an entry from the LRU list.
|
|
474
|
+
*/
|
|
475
|
+
unlink(id) {
|
|
476
|
+
const prev = this.store.lruPrev[id];
|
|
477
|
+
const next = this.store.lruNext[id];
|
|
478
|
+
if (prev !== NIL) {
|
|
479
|
+
this.store.lruNext[prev] = next;
|
|
480
|
+
} else {
|
|
481
|
+
this.head = next;
|
|
482
|
+
}
|
|
483
|
+
if (next !== NIL) {
|
|
484
|
+
this.store.lruPrev[next] = prev;
|
|
485
|
+
} else {
|
|
486
|
+
this.tail = prev;
|
|
487
|
+
}
|
|
488
|
+
this.store.lruNext[id] = NIL;
|
|
489
|
+
this.store.lruPrev[id] = NIL;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Move an entry to the head (mark as recently used).
|
|
493
|
+
* Optimization: if already at head, does nothing.
|
|
494
|
+
*/
|
|
495
|
+
touch(id) {
|
|
496
|
+
if (this.head === id) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
this.unlink(id);
|
|
500
|
+
this.linkHead(id);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Get the tail (least recently used) entry ID.
|
|
504
|
+
* Returns NIL if the list is empty.
|
|
505
|
+
*/
|
|
506
|
+
getTail() {
|
|
507
|
+
return this.tail;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Check if the list is empty.
|
|
511
|
+
*/
|
|
512
|
+
isEmpty() {
|
|
513
|
+
return this.head === NIL;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Reset the list to empty state.
|
|
517
|
+
*/
|
|
518
|
+
reset() {
|
|
519
|
+
this.head = NIL;
|
|
520
|
+
this.tail = NIL;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// src/ttl-wheel-cache.ts
|
|
525
|
+
var TtlWheelCache = class {
|
|
526
|
+
// Core components
|
|
527
|
+
store;
|
|
528
|
+
wheel;
|
|
529
|
+
ticker;
|
|
530
|
+
keyIndex;
|
|
531
|
+
lru;
|
|
532
|
+
// Configuration
|
|
533
|
+
maxEntries;
|
|
534
|
+
updateTTLOnGet;
|
|
535
|
+
ttlAutopurge;
|
|
536
|
+
tickMs;
|
|
537
|
+
onDispose;
|
|
538
|
+
// Cleanup interval
|
|
539
|
+
intervalId;
|
|
540
|
+
constructor(options) {
|
|
541
|
+
this.maxEntries = options.maxEntries;
|
|
542
|
+
this.updateTTLOnGet = options.updateTTLOnGet ?? false;
|
|
543
|
+
this.ttlAutopurge = options.ttlAutopurge ?? true;
|
|
544
|
+
this.onDispose = options.onDispose;
|
|
545
|
+
this.tickMs = options.tickMs ?? 50;
|
|
546
|
+
const wheelSize = options.wheelSize ?? 4096;
|
|
547
|
+
const budgetPerTick = options.budgetPerTick ?? 2e5;
|
|
548
|
+
this.store = new EntryStore({
|
|
549
|
+
maxEntries: options.maxEntries
|
|
550
|
+
});
|
|
551
|
+
this.keyIndex = /* @__PURE__ */ new Map();
|
|
552
|
+
this.lru = new LruList(this.store);
|
|
553
|
+
this.ticker = new MonotoneTicker({
|
|
554
|
+
tickMs: this.tickMs,
|
|
555
|
+
time: options.time
|
|
556
|
+
});
|
|
557
|
+
this.wheel = new TimerWheel({
|
|
558
|
+
store: this.store,
|
|
559
|
+
ticker: this.ticker,
|
|
560
|
+
wheelSize,
|
|
561
|
+
budgetPerTick
|
|
562
|
+
});
|
|
563
|
+
if (this.ttlAutopurge) {
|
|
564
|
+
this.startCleanupInterval();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
set(key, value, ttlMs) {
|
|
568
|
+
if (!this.ttlAutopurge) {
|
|
569
|
+
this.advanceWheel();
|
|
570
|
+
}
|
|
571
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const existingId = this.keyIndex.get(key);
|
|
575
|
+
if (existingId !== void 0) {
|
|
576
|
+
const oldValue = this.store.valRef[existingId];
|
|
577
|
+
this.store.valRef[existingId] = value;
|
|
578
|
+
this.store.ttlMs[existingId] = ttlMs;
|
|
579
|
+
const expireTick = this.ticker.nowTick() + Math.floor(ttlMs / this.ticker.tickMs);
|
|
580
|
+
this.wheel.schedule(existingId, expireTick);
|
|
581
|
+
this.lru.touch(existingId);
|
|
582
|
+
if (this.onDispose && oldValue !== void 0) {
|
|
583
|
+
this.onDispose(key, oldValue, "set");
|
|
584
|
+
}
|
|
585
|
+
} else {
|
|
586
|
+
while (this.keyIndex.size >= this.maxEntries) {
|
|
587
|
+
const evicted = this.evictLru();
|
|
588
|
+
if (!evicted) {
|
|
589
|
+
throw new Error("Failed to evict LRU entry");
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const id = this.store.allocId();
|
|
593
|
+
if (id === NIL) {
|
|
594
|
+
throw new Error("Failed to allocate entry ID");
|
|
595
|
+
}
|
|
596
|
+
this.store.setEntry(id, key, value);
|
|
597
|
+
this.store.ttlMs[id] = ttlMs;
|
|
598
|
+
this.keyIndex.set(key, id);
|
|
599
|
+
const expireTick = this.ticker.nowTick() + Math.floor(ttlMs / this.ticker.tickMs);
|
|
600
|
+
this.wheel.schedule(id, expireTick);
|
|
601
|
+
this.lru.linkHead(id);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
get(key) {
|
|
605
|
+
if (!this.ttlAutopurge) {
|
|
606
|
+
this.advanceWheel();
|
|
607
|
+
}
|
|
608
|
+
const id = this.keyIndex.get(key);
|
|
609
|
+
if (id === void 0) {
|
|
610
|
+
return void 0;
|
|
611
|
+
}
|
|
612
|
+
const expireTick = this.store.expiresTick[id];
|
|
613
|
+
const nowTick = this.ticker.nowTick();
|
|
614
|
+
if (expireTick <= nowTick) {
|
|
615
|
+
this.onExpireEntry(id, "ttl");
|
|
616
|
+
return void 0;
|
|
617
|
+
}
|
|
618
|
+
this.lru.touch(id);
|
|
619
|
+
if (this.updateTTLOnGet) {
|
|
620
|
+
const ttl = this.store.ttlMs[id];
|
|
621
|
+
if (ttl > 0) {
|
|
622
|
+
const newExpireTick = this.ticker.nowTick() + Math.floor(ttl / this.ticker.tickMs);
|
|
623
|
+
this.wheel.schedule(id, newExpireTick);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return this.store.valRef[id];
|
|
627
|
+
}
|
|
628
|
+
has(key) {
|
|
629
|
+
if (!this.ttlAutopurge) {
|
|
630
|
+
this.advanceWheel();
|
|
631
|
+
}
|
|
632
|
+
const id = this.keyIndex.get(key);
|
|
633
|
+
if (id === void 0) {
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
const expireTick = this.store.expiresTick[id];
|
|
637
|
+
const nowTick = this.ticker.nowTick();
|
|
638
|
+
if (expireTick <= nowTick) {
|
|
639
|
+
this.onExpireEntry(id, "ttl");
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
delete(key) {
|
|
645
|
+
if (!this.ttlAutopurge) {
|
|
646
|
+
this.advanceWheel();
|
|
647
|
+
}
|
|
648
|
+
const id = this.keyIndex.get(key);
|
|
649
|
+
if (id === void 0) {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
this.removeEntry(id, "delete");
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
clear() {
|
|
656
|
+
const ids = Array.from(this.keyIndex.values());
|
|
657
|
+
for (const id of ids) {
|
|
658
|
+
this.removeEntry(id, "clear");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
size() {
|
|
662
|
+
return this.keyIndex.size;
|
|
663
|
+
}
|
|
664
|
+
stats() {
|
|
665
|
+
return {
|
|
666
|
+
size: this.size()
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
close() {
|
|
670
|
+
if (this.intervalId !== void 0) {
|
|
671
|
+
clearInterval(this.intervalId);
|
|
672
|
+
this.intervalId = void 0;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Advance the timer wheel and process expirations.
|
|
677
|
+
* Called automatically in active mode (ttlAutopurge=false).
|
|
678
|
+
*/
|
|
679
|
+
advanceWheel() {
|
|
680
|
+
this.wheel.advanceToNow((id) => {
|
|
681
|
+
this.onExpireEntry(id, "ttl");
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
removeEntry(id, reason) {
|
|
685
|
+
const key = this.store.keyRef[id];
|
|
686
|
+
const value = this.store.valRef[id];
|
|
687
|
+
if (key !== void 0) {
|
|
688
|
+
this.keyIndex.delete(key);
|
|
689
|
+
}
|
|
690
|
+
this.wheel.unlink(id);
|
|
691
|
+
this.lru.unlink(id);
|
|
692
|
+
this.store.freeId(id);
|
|
693
|
+
if (this.onDispose && key !== void 0 && value !== void 0) {
|
|
694
|
+
this.onDispose(key, value, reason);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
onExpireEntry(id, reason) {
|
|
698
|
+
this.removeEntry(id, reason);
|
|
699
|
+
}
|
|
700
|
+
evictLru() {
|
|
701
|
+
const tail = this.lru.getTail();
|
|
702
|
+
if (tail === NIL) {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
this.removeEntry(tail, "lru");
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
startCleanupInterval() {
|
|
709
|
+
this.intervalId = setInterval(() => {
|
|
710
|
+
this.advanceWheel();
|
|
711
|
+
}, this.tickMs);
|
|
712
|
+
this.intervalId.unref();
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
716
|
+
0 && (module.exports = {
|
|
717
|
+
TtlWheelCache
|
|
718
|
+
});
|