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;
}
}
}
|