File size: 19,881 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
// src/store.ts
//
// In-memory primary index with TTL and an ordered key index. Values may be held
// either inline in memory or as a pointer into the on-disk snapshot/WAL; in the
// latter case the caller injects a synchronous positioned reader.

import { SkipList, cmpString } from './skiplist.js';
import type { RangeEntry, RangeOptions } from './skiplist.js';

export interface ValueLoc {
  file: 'snapshot' | 'wal';
  off: number;
  len: number;
}

export type ValueRef = { kind: 'memory'; value: Buffer } | { kind: 'disk'; loc: ValueLoc };

export interface StoreRecord {
  ref: ValueRef;
  expireAt: number;
  seq: number;
  dt: Record<string, number> | null;
}

export interface StoreEntry {
  key: Buffer;
  value: Buffer;
  expireAt: number;
  dt: Record<string, number> | null;
}

export type ValueReader = (loc: ValueLoc) => Buffer;

/** Internal raw-record view used by derived-index rebuilds: the canonical
 *  (byte-string) key, the dt metadata, and a lazy value reader. Nothing is
 *  materialized unless readValue() is called, so a rebuild that only needs
 *  metadata never copies a buffer (memory mode) or issues a positioned read
 *  (disk mode). */
export interface RawRecord {
  kstr: string;
  dt: Record<string, number> | null;
  readValue: () => Buffer;
}

const toKStr = (key: string | Buffer): string =>
  typeof key === 'string' ? key : Buffer.from(key).toString('binary');
const fromKStr = (kstr: string): Buffer => Buffer.from(kstr, 'binary');
const DISK_REF_BYTES = 64;

interface HeapEntry {
  t: number;
  k: string;
  seq: number;
}

class MinHeap {
  private a: HeapEntry[] = [];
  get size(): number {
    return this.a.length;
  }
  clear(): void {
    this.a = [];
  }
  peek(): HeapEntry | undefined {
    return this.a[0];
  }
  push(item: HeapEntry): void {
    const a = this.a;
    a.push(item);
    let i = a.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (a[p]!.t <= a[i]!.t) break;
      [a[p], a[i]] = [a[i]!, a[p]!];
      i = p;
    }
  }
  pop(): HeapEntry | undefined {
    const a = this.a;
    const top = a[0];
    const last = a.pop();
    if (a.length && last !== undefined) {
      a[0] = last;
      let i = 0;
      while (true) {
        let s = i;
        const l = 2 * i + 1;
        const r = l + 1;
        if (l < a.length && a[l]!.t < a[s]!.t) s = l;
        if (r < a.length && a[r]!.t < a[s]!.t) s = r;
        if (s === i) break;
        [a[s], a[i]] = [a[i]!, a[s]!];
        i = s;
      }
    }
    return top;
  }
}

export interface StoreOptions {
  activeExpireIntervalMs?: number;
  activeExpireMaxPerTick?: number;
  /** Time budget (ms) per active-expiration tick, used once
   *  `activeExpireMaxPerTick` entries have been reaped: a simultaneously-expired
   *  burst keeps being reaped within this budget, so TTL storms drain far
   *  faster than the fixed per-tick count alone, while the event-loop pause per
   *  tick stays bounded. */
  activeExpireTimeBudgetMs?: number;
  /** Read a value back from disk for disk-backed records. Required whenever a
   *  StoreRecord may hold a disk ref. */
  readValue?: ValueReader;
  /** Called after a key is removed due to TTL expiration (lazy or active),
   *  so the owner can drop derived state (secondary/dt/text indexes). Not
   *  called for explicit del(), which the owner handles itself. */
  onExpire?: (key: string, record: StoreRecord) => void;
}

export class Store {
  readonly map = new Map<string, StoreRecord>(); // kstr -> record
  private order = new SkipList<string, string>({ compareKey: cmpString }); // kstr ordered
  private readonly heap = new MinHeap();
  private seq = 0;
  /** Approximate bytes held by live + expired-not-yet-reaped records. In
   *  valueMode:'disk' this counts keys/metadata/refs, not the value bulk. */
  bytes = 0;
  /** Number of records with an expiry set. Enables an O(1) size fast path when
   *  TTL is not in use. */
  private expiring = 0;
  private readonly maxPerTick: number;
  private readonly expireTimeBudgetMs: number;
  /** Sticky flag: set when the last tick reaped ≥ maxPerTick (i.e. there is an
   *  expiry backlog), making the next ticks aggressive until the storm drains
   *  — like Redis's aggressive expire cycle. */
  private expireAggressive = false;
  /** In-flight async bulk load (bulkLoadRefsAsync only — the sync bulk load
   *  yields nothing, so no timer can fire mid-load there). Active expiry is
   *  paused for the load's duration: a mid-load reap would delete the key
   *  from the map (and from the about-to-be-replaced OLD ordered index, a
   *  no-op) while the load still rebuilds `order` from its accumulated
   *  entries — resurrecting the reaped key in the ordered index, where a
   *  later re-set would duplicate it in ordered scans. Whatever elapsed
   *  during the load is reaped by the first tick after it, which is exactly
   *  the sync bulkLoadRefs behavior. */
  private bulkLoading = false;
  private timer: ReturnType<typeof setInterval> | null = null;
  private readonly onExpire?: (key: string, record: StoreRecord) => void;
  private readonly readValue?: ValueReader;

  constructor(opts: StoreOptions = {}) {
    this.maxPerTick = opts.activeExpireMaxPerTick ?? 100;
    this.expireTimeBudgetMs = opts.activeExpireTimeBudgetMs ?? 2;
    this.onExpire = opts.onExpire;
    this.readValue = opts.readValue;
    const interval = opts.activeExpireIntervalMs ?? 100;
    this.timer = interval > 0 ? setInterval(() => this.activeExpire(), interval) : null;
    this.timer?.unref?.();
  }

  get size(): number {
    // O(1) fast path: with no TTL ever set, every map entry is live.
    if (this.expiring === 0) return this.map.size;
    // Count only logically-live keys: expired-but-not-yet-reaped entries stay
    // in the map until lazy/active expiration removes them, so map.size would
    // otherwise over-report.
    let n = 0;
    const now = Date.now();
    for (const [, r] of this.map) if (!r.expireAt || r.expireAt > now) n++;
    return n;
  }

  private metaBytes(dt: Record<string, number> | null): number {
    return dt ? Buffer.byteLength(JSON.stringify({ dt }), 'utf8') : 0;
  }

  private refBytes(ref: ValueRef): number {
    return ref.kind === 'memory' ? ref.value.length : DISK_REF_BYTES;
  }

  private cloneRef(ref: ValueRef): ValueRef {
    return ref.kind === 'memory' ? { kind: 'memory', value: Buffer.from(ref.value) } : { kind: 'disk', loc: { ...ref.loc } };
  }

  private materialize(ref: ValueRef): Buffer {
    if (ref.kind === 'memory') return ref.value;
    if (!this.readValue) throw new Error('Store cannot read disk-backed value without a ValueReader');
    return this.readValue(ref.loc);
  }

  /** Approximate bytes used by one record, matching the bytes tracked on set(). */
  recordBytes(k: string): number {
    const r = this.map.get(k);
    return r ? Buffer.byteLength(k, 'binary') + this.refBytes(r.ref) + this.metaBytes(r.dt) : 0;
  }

  /** Approximate bytes a SET would store for this key/value/dt. Pass
   *  countValue:false for valueMode:'disk', where only key/metadata/ref bytes
   *  stay in RAM and the value bulk lives in the snapshot/WAL. */
  estimateSetBytes(
    key: string | Buffer,
    value: Buffer,
    dt: Record<string, number> | null,
    opts: { countValue?: boolean } = {},
  ): number {
    const k = toKStr(key);
    const countValue = opts.countValue ?? true;
    return Buffer.byteLength(k, 'binary') + (countValue ? value.length : DISK_REF_BYTES) + this.metaBytes(dt);
  }

  private remove(k: string): boolean {
    const r = this.map.get(k);
    const ok = this.map.delete(k);
    if (ok) {
      if (r) {
        this.bytes -= Buffer.byteLength(k, 'binary') + this.refBytes(r.ref) + this.metaBytes(r.dt);
        if (r.expireAt) this.expiring--;
      }
      this.order.delete(k, k);
    }
    return ok;
  }

  /** Remove a key that has expired and notify the owner so derived indexes
   *  stay in sync. */
  private expireKey(k: string, rec: StoreRecord): void {
    if (this.remove(k)) this.onExpire?.(k, rec);
  }

  set(key: string | Buffer, value: Buffer, expireAt = 0, dt: Record<string, number> | null = null): void {
    this.setRef(key, { kind: 'memory', value: Buffer.from(value) }, expireAt, dt);
  }

  setRef(key: string | Buffer, ref: ValueRef, expireAt = 0, dt: Record<string, number> | null = null): void {
    const k = toKStr(key);
    const prev = this.map.get(k);
    if (prev) {
      if (prev.expireAt) this.expiring--;
      this.bytes -= this.recordBytes(k);
    }
    const seq = ++this.seq;
    const stored = this.cloneRef(ref);
    this.map.set(k, { ref: stored, expireAt: expireAt || 0, seq, dt });
    this.bytes += Buffer.byteLength(k, 'binary') + this.refBytes(stored) + this.metaBytes(dt);
    if (!prev) this.order.insert(k, k);
    if (expireAt) {
      this.expiring++;
      this.heap.push({ t: expireAt, k, seq });
      // Overwriting a TTL key leaves a stale heap entry that is only reaped
      // when its (possibly far-future) timestamp passes, so the heap can grow
      // without bound under frequent TTL updates. Rebuild it once stale entries
      // clearly dominate the live set, bounding memory at ~2x live TTL keys.
      if (this.heap.size > this.map.size * 2 + 64) this.rebuildHeap();
    }
  }

  private rebuildHeap(): void {
    this.heap.clear();
    for (const [k, r] of this.map) {
      if (r.expireAt) this.heap.push({ t: r.expireAt, k, seq: r.seq });
    }
  }

  get(key: string | Buffer): Buffer | undefined {
    const k = toKStr(key);
    const r = this.map.get(k);
    if (!r) return undefined;
    if (r.expireAt && r.expireAt <= Date.now()) {
      this.expireKey(k, r); // lazy expiration
      return undefined;
    }
    return this.materialize(r.ref);
  }

  /** Read the full raw record. Like get(), an expired record is lazily reaped
   *  here (notifying the owner so derived indexes stay in sync) rather than
   *  being left behind as a ghost that read paths such as scan/query/dtRange
   *  would otherwise skip-but-not-clean. The returned record is raw: its ref may
   *  point at disk rather than holding a Buffer. */
  getRecord(key: string | Buffer): StoreRecord | undefined {
    const k = toKStr(key);
    const r = this.map.get(k);
    if (!r) return undefined;
    if (r.expireAt && r.expireAt <= Date.now()) {
      this.expireKey(k, r); // lazy expiration (same as get)
      return undefined;
    }
    return r;
  }

  del(key: string | Buffer): boolean {
    return this.remove(toKStr(key));
  }

  /** Metadata-only existence check: no value materialization (no buffer copy in
   *  memory mode, no positioned disk read for disk-backed records). Applies the
   *  same lazy-expiration semantics as get(). */
  has(key: string | Buffer): boolean {
    const k = toKStr(key);
    const r = this.map.get(k);
    if (!r) return false;
    if (r.expireAt && r.expireAt <= Date.now()) {
      this.expireKey(k, r); // lazy expiration, same as get()
      return false;
    }
    return true;
  }

  *entries(): Generator<StoreEntry> {
    const now = Date.now();
    for (const [k, r] of this.map) {
      if (r.expireAt && r.expireAt <= now) continue;
      yield { key: fromKStr(k), value: this.materialize(r.ref), expireAt: r.expireAt, dt: r.dt };
    }
  }

  /** Walk live records without materializing values: yields the canonical key,
   *  dt metadata, and a lazy value reader. Expired records are skipped (not
   *  reaped) exactly as in entries(). Internal to the package — derived-index
   *  rebuilds use it to share a single walk and a single decode per record. */
  *rawRecords(): Generator<RawRecord> {
    const now = Date.now();
    for (const [k, r] of this.map) {
      if (r.expireAt && r.expireAt <= now) continue;
      yield { kstr: k, dt: r.dt, readValue: () => this.materialize(r.ref) };
    }
  }

  /** Walk live records with their RAW value ref (never materialized): the
   *  stage-6 snapshot writer reads disk-backed values through its own async
   *  path (grouped, bounded-concurrency) instead of one synchronous
   *  positioned read per record. Expired records are skipped exactly as in
   *  entries(). Internal to the package. */
  *rawRefRecords(): Generator<{ kstr: string; ref: ValueRef; expireAt: number; dt: Record<string, number> | null }> {
    const now = Date.now();
    for (const [k, r] of this.map) {
      if (r.expireAt && r.expireAt <= now) continue;
      yield { kstr: k, ref: r.ref, expireAt: r.expireAt, dt: r.dt };
    }
  }

  /** Ordered scan over keys. */
  *scan(opts: RangeOptions<string> = {}): Generator<StoreEntry> {
    for (const n of this.order.range(opts) as Iterable<RangeEntry<string, string>>) {
      const r = this.getRecord(n.key);
      if (!r) continue;
      yield { key: fromKStr(n.key), value: this.materialize(r.ref), expireAt: r.expireAt, dt: r.dt };
    }
  }

  /** Prefix scan over keys. */
  *prefix(p: string, limit = Infinity): Generator<StoreEntry> {
    const pk = toKStr(p);
    yield* this.scan({ gte: pk, lt: pk + '\uffff', count: limit });
  }

  /** Ordered scan yielding canonical keys only, without materializing values
   *  (no buffer copies in memory mode, no positioned reads in disk mode).
   *  Expired records are lazily reaped, exactly as in scan(). */
  *rawKeys(opts: RangeOptions<string> = {}): Generator<string> {
    for (const n of this.order.range(opts) as Iterable<RangeEntry<string, string>>) {
      if (!this.getRecord(n.key)) continue;
      yield n.key;
    }
  }

  /** Rewrite disk-backed value locations after compaction rotates the
   *  snapshot/WAL files. Memory refs are left untouched. */
  remapLocs(remap: (k: string, loc: ValueLoc, rec: StoreRecord) => ValueLoc | undefined): void {
    for (const [k, r] of this.map) {
      if (r.ref.kind !== 'disk') continue;
      const next = remap(k, r.ref.loc, r);
      if (next) r.ref = { kind: 'disk', loc: { ...next } };
    }
  }

  /** Stage-5 generation load: populate the store wholesale from a recovered
   *  generation store image. `records` must be expiry-filtered by the caller
   *  (expired-past records dropped) and sorted by canonical key ascending (the
   *  image's write order), so the ordered index is bulk-built in O(N) instead
   *  of per-record inserts.
   *
   *  OWNERSHIP: the records' refs are adopted as-is (no defensive clone) —
   *  the image parser produced fresh buffers for exactly this purpose.
   *  `metaBytes` is the precomputed dt accounting value (0 = none), so the
   *  load never re-stringifies per record. */
  bulkLoadRefs(
    records: Iterable<{ kstr: string; ref: ValueRef; expireAt: number; dt: Record<string, number> | null; metaBytes?: number }>,
  ): void {
    const orderEntries: RangeEntry<string, string>[] = [];
    for (const { kstr, ref, expireAt, dt, metaBytes } of records) {
      const seq = ++this.seq;
      this.map.set(kstr, { ref, expireAt: expireAt || 0, seq, dt });
      this.bytes += Buffer.byteLength(kstr, 'binary') + this.refBytes(ref) + (metaBytes ?? 0);
      if (expireAt) {
        this.expiring++;
        this.heap.push({ t: expireAt, k: kstr, seq });
      }
      orderEntries.push({ key: kstr, val: kstr });
    }
    this.order = SkipList.bulkLoad(orderEntries, { compareKey: cmpString });
  }

  /** Sliced variant of bulkLoadRefs (the open-time main-thread path):
   *  identical resulting state, but the per-record map insertions yield to
   *  the event loop every `sliceEvery` records, so a large store image never
   *  loads in one synchronous run. The final ordered-index bulk build is a
   *  single O(N) pass over the sorted entries and is likewise sliced via
   *  SkipList.bulkLoadAsync. Safe to yield mid-load: the store is not
   *  published until open() returns, and active expiry is paused for the
   *  load's duration (see `bulkLoading`) so a mid-load reap cannot diverge
   *  the map from the not-yet-rebuilt ordered index. */
  async bulkLoadRefsAsync(
    records: Iterable<{ kstr: string; ref: ValueRef; expireAt: number; dt: Record<string, number> | null; metaBytes?: number }>,
    opts: { sliceEvery?: number } = {},
  ): Promise<void> {
    const sliceEvery = opts.sliceEvery ?? 8192;
    const orderEntries: RangeEntry<string, string>[] = [];
    this.bulkLoading = true;
    try {
      let n = 0;
      for (const { kstr, ref, expireAt, dt, metaBytes } of records) {
        const seq = ++this.seq;
        this.map.set(kstr, { ref, expireAt: expireAt || 0, seq, dt });
        this.bytes += Buffer.byteLength(kstr, 'binary') + this.refBytes(ref) + (metaBytes ?? 0);
        if (expireAt) {
          this.expiring++;
          this.heap.push({ t: expireAt, k: kstr, seq });
        }
        orderEntries.push({ key: kstr, val: kstr });
        if (++n % sliceEvery === 0) await new Promise((r) => setImmediate(r));
      }
      this.order = await SkipList.bulkLoadAsync(orderEntries, { compareKey: cmpString }, { sliceEvery });
    } finally {
      this.bulkLoading = false;
    }
  }

  private activeExpire(): void {
    // A sliced bulk load holds the map/order consistency contract: reaping
    // mid-load would drop the key from the map while the load still rebuilds
    // `order` from its accumulated entries. Skip this tick; the next one
    // reaps whatever elapsed meanwhile (identical to the sync bulk load,
    // during which no timer can fire at all).
    if (this.bulkLoading) return;
    const now = Date.now();
    // Normal ticks stay within the small budget; a tick that still finds a
    // full quota of expired keys flips to aggressive mode (larger budget, like
    // Redis's fast expire cycle) until the storm drains.
    const deadline = now + (this.expireAggressive ? Math.max(this.expireTimeBudgetMs, 10) : this.expireTimeBudgetMs);
    let n = 0;
    let reaped = 0;
    while (this.heap.size && this.heap.peek()!.t <= now) {
      // Once the guaranteed per-tick quota is reaped, keep draining within the
      // time budget: a fixed ~1000/s rate let a large simultaneous-expiry storm
      // (e.g. 100k keys) linger in memory for minutes. The budget bounds the
      // pause each tick instead of bounding the work.
      if (n >= this.maxPerTick && (n & 15) === 0 && Date.now() >= deadline) break;
      const e = this.heap.pop()!;
      const r = this.map.get(e.k);
      if (r && r.seq === e.seq && r.expireAt && r.expireAt <= now) {
        this.expireKey(e.k, r);
        reaped++;
      }
      n++;
    }
    this.expireAggressive = reaped >= this.maxPerTick;
  }

  /** Synchronously reap every expired record. Returns the number removed. */
  reapExpired(): number {
    const now = Date.now();
    let n = 0;
    for (const [k, r] of this.map) {
      if (r.expireAt && r.expireAt <= now) {
        this.expireKey(k, r);
        n++;
      }
    }
    return n;
  }

  /** Reap already-expired records via the TTL min-heap: O(due + stale heap
   *  entries) instead of the O(store) full scan of reapExpired(), so callers
   *  on the write hot path do not pay a full-store sweep per call. Falls back
   *  to the full scan only when the heap provably diverged from the map (live
   *  TTL records remain but the heap is empty — an invariant no code path may
   *  produce), resyncing instead of leaking expired bytes. */
  reapExpiredDue(): number {
    const now = Date.now();
    let n = 0;
    while (this.heap.size && this.heap.peek()!.t <= now) {
      const e = this.heap.pop()!;
      const r = this.map.get(e.k);
      if (r && r.seq === e.seq && r.expireAt && r.expireAt <= now) {
        this.expireKey(e.k, r);
        n++;
      }
    }
    if (this.expiring > 0 && this.heap.size === 0) return n + this.reapExpired();
    return n;
  }

  /** Stop the active-expiration timer. */
  close(): void {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }
}