bundis 0.1.0 → 0.2.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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * HotCacheStorage — write-through in-memory hot cache over a StorageEngine.
3
+ *
4
+ * Policy (write-through + adaptive time-to-idle + LRU byte cap):
5
+ * - kvSet writes through to the inner engine, then caches the fresh value, so
6
+ * a key is hot from the moment it is written.
7
+ * - kvGet serves live cache entries without touching SQLite; misses read
8
+ * through and fill the cache.
9
+ * - An entry is evicted when idle longer than its effective TTI. The TTI grows
10
+ * with accumulated hits — `min(maxFactor·base, base·(1 + log2(1 + hits)))` —
11
+ * so frequently-read keys survive longer idle gaps. Hit counts halve on every
12
+ * sweep so past popularity decays.
13
+ * - Total cached bytes are capped; the least-recently-used entries are evicted
14
+ * first when the cap is exceeded. Values larger than 1/8 of the cap are never
15
+ * cached (one huge value must not flush the whole working set).
16
+ *
17
+ * Coherence (single-writer, single-process):
18
+ * - Every mutation in the inner engine fires its onWrite hook; the server wires
19
+ * that hook to {@link invalidate}, so all write paths (DEL, EXPIRE, INCR,
20
+ * hash/set ops, sweeps, type overwrites) evict stale entries automatically.
21
+ * - Inside an explicit transaction (MULTI/EXEC) fills are suppressed: a rolled
22
+ * back transaction must not leave uncommitted values in the cache.
23
+ * Invalidations still apply immediately (evicting too much is always safe).
24
+ * - Cache entries carry expireAtMs and are checked lazily on every hit, so an
25
+ * expired key never serves from cache.
26
+ */
27
+
28
+ import type { RedisType, SetOptions, StorageEngine } from "./types";
29
+
30
+ interface CacheEntry {
31
+ value: Uint8Array;
32
+ expireAtMs: number | null;
33
+ lastAccess: number;
34
+ hits: number;
35
+ bytes: number;
36
+ }
37
+
38
+ export interface HotCacheOptions {
39
+ /** Hard ceiling for cached bytes (keys + values + overhead). */
40
+ readonly maxBytes: number;
41
+ /** Base time-to-idle in ms. */
42
+ readonly baseIdleMs: number;
43
+ /** Cap on the adaptive TTI as a multiple of baseIdleMs (default 8). */
44
+ readonly maxIdleFactor?: number;
45
+ /** Min interval between idle sweeps in ms (default 5000). */
46
+ readonly sweepEveryMs?: number;
47
+ }
48
+
49
+ export interface CacheStats {
50
+ entries: number;
51
+ bytes: number;
52
+ maxBytes: number;
53
+ hits: number;
54
+ misses: number;
55
+ fills: number;
56
+ invalidations: number;
57
+ evictedIdle: number;
58
+ evictedLru: number;
59
+ }
60
+
61
+ const ENTRY_OVERHEAD = 96; // rough per-entry bookkeeping cost in bytes
62
+
63
+ /** Sentinel: the key vanished between write/read and the expiry read-back. */
64
+ const ABSENT = Symbol("absent");
65
+
66
+ export class HotCacheStorage implements StorageEngine {
67
+ // Map iteration order doubles as the LRU order: a hit re-inserts its entry.
68
+ #cache = new Map<string, CacheEntry>();
69
+ #bytes = 0;
70
+ #inTxn = false;
71
+ #lastSweep = 0;
72
+
73
+ #hits = 0;
74
+ #misses = 0;
75
+ #fills = 0;
76
+ #invalidations = 0;
77
+ #evictedIdle = 0;
78
+ #evictedLru = 0;
79
+
80
+ readonly #maxBytes: number;
81
+ readonly #baseIdleMs: number;
82
+ readonly #maxIdleMs: number;
83
+ readonly #sweepEveryMs: number;
84
+ readonly #maxEntryBytes: number;
85
+
86
+ constructor(
87
+ private readonly inner: StorageEngine,
88
+ opts: HotCacheOptions,
89
+ ) {
90
+ this.#maxBytes = opts.maxBytes;
91
+ this.#baseIdleMs = opts.baseIdleMs;
92
+ this.#maxIdleMs = opts.baseIdleMs * (opts.maxIdleFactor ?? 8);
93
+ this.#sweepEveryMs = opts.sweepEveryMs ?? 5000;
94
+ this.#maxEntryBytes = Math.max(1, Math.floor(opts.maxBytes / 8));
95
+ }
96
+
97
+ /** Evict one key (no-op if absent). Wired to the inner engine's onWrite. */
98
+ invalidate = (key: Uint8Array): void => {
99
+ const k = mapKey(key);
100
+ const entry = this.#cache.get(k);
101
+ if (entry) {
102
+ this.#cache.delete(k);
103
+ this.#bytes -= entry.bytes;
104
+ this.#invalidations++;
105
+ }
106
+ };
107
+
108
+ stats(): CacheStats {
109
+ return {
110
+ entries: this.#cache.size,
111
+ bytes: this.#bytes,
112
+ maxBytes: this.#maxBytes,
113
+ hits: this.#hits,
114
+ misses: this.#misses,
115
+ fills: this.#fills,
116
+ invalidations: this.#invalidations,
117
+ evictedIdle: this.#evictedIdle,
118
+ evictedLru: this.#evictedLru,
119
+ };
120
+ }
121
+
122
+ // ── cached fast paths ────────────────────────────────────────────────────-
123
+
124
+ kvGet(key: Uint8Array, now: number): Uint8Array | null {
125
+ const entry = this.#liveEntry(key, now);
126
+ if (entry) {
127
+ this.#hits++;
128
+ return entry.value;
129
+ }
130
+ this.#misses++;
131
+ const value = this.inner.kvGet(key, now);
132
+ if (value !== null && !this.#inTxn) {
133
+ const exp = this.#expireOf(key, now);
134
+ if (exp !== ABSENT) this.#fill(key, value, exp, now);
135
+ }
136
+ return value;
137
+ }
138
+
139
+ kvSet(key: Uint8Array, value: Uint8Array, now: number, opts?: SetOptions): "set" | "noop" {
140
+ const result = this.inner.kvSet(key, value, now, opts);
141
+ // onWrite already invalidated any stale entry; cache the committed value.
142
+ // A "noop" (NX/XX guard) fires no onWrite, so the old entry stays valid.
143
+ if (result === "set" && !this.#inTxn) {
144
+ // If the write carried an already-past expiry, the read-back's lazy
145
+ // expiry deletes the row and reports ABSENT — caching then would create
146
+ // an immortal ghost entry that SQLite no longer backs.
147
+ const exp = this.#expireOf(key, now);
148
+ if (exp !== ABSENT) this.#fill(key, value, exp, now);
149
+ }
150
+ return result;
151
+ }
152
+
153
+ exists(key: Uint8Array, now: number): boolean {
154
+ if (this.#liveEntry(key, now)) {
155
+ this.#hits++;
156
+ return true;
157
+ }
158
+ this.#misses++;
159
+ return this.inner.exists(key, now);
160
+ }
161
+
162
+ typeOf(key: Uint8Array, now: number): RedisType | null {
163
+ if (this.#liveEntry(key, now)) {
164
+ this.#hits++;
165
+ return "string";
166
+ }
167
+ this.#misses++;
168
+ return this.inner.typeOf(key, now);
169
+ }
170
+
171
+ pttl(key: Uint8Array, now: number): number {
172
+ const entry = this.#liveEntry(key, now);
173
+ if (entry) {
174
+ this.#hits++;
175
+ return entry.expireAtMs === null ? -1 : Math.max(0, entry.expireAtMs - now);
176
+ }
177
+ this.#misses++;
178
+ return this.inner.pttl(key, now);
179
+ }
180
+
181
+ sweepExpired(now: number): number {
182
+ if (now - this.#lastSweep >= this.#sweepEveryMs) {
183
+ this.#lastSweep = now;
184
+ this.#idleSweep(now);
185
+ }
186
+ // Inner sweep fires onWrite per removed key → cache invalidation included.
187
+ return this.inner.sweepExpired(now);
188
+ }
189
+
190
+ withTransaction<T>(fn: () => T): T {
191
+ // Suppress fills while a MULTI/EXEC body runs: if the transaction rolls
192
+ // back, the cache must not retain values that were never committed.
193
+ const outer = this.#inTxn;
194
+ this.#inTxn = true;
195
+ try {
196
+ return this.inner.withTransaction(fn);
197
+ } finally {
198
+ this.#inTxn = outer;
199
+ }
200
+ }
201
+
202
+ close(): void {
203
+ this.#cache.clear();
204
+ this.#bytes = 0;
205
+ this.inner.close();
206
+ }
207
+
208
+ // ── pure delegations (onWrite keeps the cache coherent) ──────────────────-
209
+
210
+ del(keys: Uint8Array[], now: number): number {
211
+ // Eager eviction (not just onWrite): inner.del only fires onWrite for keys
212
+ // whose row exists, so this guarantees DEL always heals any divergence.
213
+ for (const k of keys) this.invalidate(k);
214
+ return this.inner.del(keys, now);
215
+ }
216
+ expireSet(key: Uint8Array, atMs: number, now: number): boolean {
217
+ return this.inner.expireSet(key, atMs, now);
218
+ }
219
+ persist(key: Uint8Array, now: number): boolean {
220
+ return this.inner.persist(key, now);
221
+ }
222
+ dbsize(now: number): number {
223
+ return this.inner.dbsize(now);
224
+ }
225
+ flushAll(): void {
226
+ this.invalidateAll();
227
+ this.inner.flushAll();
228
+ }
229
+ /** Drop every cache entry (FLUSHDB or any whole-keyspace event). */
230
+ invalidateAll(): void {
231
+ this.#invalidations += this.#cache.size;
232
+ this.#cache.clear();
233
+ this.#bytes = 0;
234
+ }
235
+ incrBy(key: Uint8Array, delta: bigint, now: number): bigint {
236
+ return this.inner.incrBy(key, delta, now);
237
+ }
238
+ incrByFloat(key: Uint8Array, delta: number, now: number): number {
239
+ return this.inner.incrByFloat(key, delta, now);
240
+ }
241
+ append(key: Uint8Array, value: Uint8Array, now: number): number {
242
+ return this.inner.append(key, value, now);
243
+ }
244
+ hSet(key: Uint8Array, pairs: ReadonlyArray<readonly [Uint8Array, Uint8Array]>, now: number): number {
245
+ return this.inner.hSet(key, pairs, now);
246
+ }
247
+ hGet(key: Uint8Array, field: Uint8Array, now: number): Uint8Array | null {
248
+ return this.inner.hGet(key, field, now);
249
+ }
250
+ hDel(key: Uint8Array, fields: Uint8Array[], now: number): number {
251
+ return this.inner.hDel(key, fields, now);
252
+ }
253
+ hGetAll(key: Uint8Array, now: number): Array<[Uint8Array, Uint8Array]> {
254
+ return this.inner.hGetAll(key, now);
255
+ }
256
+ hKeys(key: Uint8Array, now: number): Uint8Array[] {
257
+ return this.inner.hKeys(key, now);
258
+ }
259
+ hVals(key: Uint8Array, now: number): Uint8Array[] {
260
+ return this.inner.hVals(key, now);
261
+ }
262
+ hLen(key: Uint8Array, now: number): number {
263
+ return this.inner.hLen(key, now);
264
+ }
265
+ hExists(key: Uint8Array, field: Uint8Array, now: number): boolean {
266
+ return this.inner.hExists(key, field, now);
267
+ }
268
+ hIncrBy(key: Uint8Array, field: Uint8Array, delta: bigint, now: number): bigint {
269
+ return this.inner.hIncrBy(key, field, delta, now);
270
+ }
271
+ hIncrByFloat(key: Uint8Array, field: Uint8Array, delta: number, now: number): number {
272
+ return this.inner.hIncrByFloat(key, field, delta, now);
273
+ }
274
+ sAdd(key: Uint8Array, members: Uint8Array[], now: number): number {
275
+ return this.inner.sAdd(key, members, now);
276
+ }
277
+ sRem(key: Uint8Array, members: Uint8Array[], now: number): number {
278
+ return this.inner.sRem(key, members, now);
279
+ }
280
+ sIsMember(key: Uint8Array, member: Uint8Array, now: number): boolean {
281
+ return this.inner.sIsMember(key, member, now);
282
+ }
283
+ sMembers(key: Uint8Array, now: number): Uint8Array[] {
284
+ return this.inner.sMembers(key, now);
285
+ }
286
+ sCard(key: Uint8Array, now: number): number {
287
+ return this.inner.sCard(key, now);
288
+ }
289
+ sRandMember(key: Uint8Array, count: number | null, now: number): Uint8Array[] | Uint8Array | null {
290
+ return this.inner.sRandMember(key, count, now);
291
+ }
292
+ sPop(key: Uint8Array, count: number | null, now: number): Uint8Array[] | Uint8Array | null {
293
+ return this.inner.sPop(key, count, now);
294
+ }
295
+
296
+ // ── internals ────────────────────────────────────────────────────────────-
297
+
298
+ /** Live cache entry for key, refreshed as most-recently-used; null if absent/expired. */
299
+ #liveEntry(key: Uint8Array, now: number): CacheEntry | null {
300
+ const k = mapKey(key);
301
+ const entry = this.#cache.get(k);
302
+ if (!entry) return null;
303
+ if (entry.expireAtMs !== null && entry.expireAtMs <= now) {
304
+ // Key TTL passed: drop from cache; the inner lazy path owns row deletion.
305
+ this.#cache.delete(k);
306
+ this.#bytes -= entry.bytes;
307
+ return null;
308
+ }
309
+ entry.lastAccess = now;
310
+ entry.hits++;
311
+ this.#cache.delete(k); // re-insert → most recently used
312
+ this.#cache.set(k, entry);
313
+ return entry;
314
+ }
315
+
316
+ #fill(key: Uint8Array, value: Uint8Array, expireAtMs: number | null, now: number): void {
317
+ const bytes = key.length + value.byteLength + ENTRY_OVERHEAD;
318
+ if (bytes > this.#maxEntryBytes) return;
319
+ const k = mapKey(key);
320
+ const prev = this.#cache.get(k);
321
+ if (prev) {
322
+ this.#bytes -= prev.bytes;
323
+ this.#cache.delete(k);
324
+ }
325
+ this.#cache.set(k, {
326
+ value,
327
+ expireAtMs,
328
+ lastAccess: now,
329
+ hits: prev ? prev.hits : 0, // overwrite keeps earned popularity
330
+ bytes,
331
+ });
332
+ this.#bytes += bytes;
333
+ this.#fills++;
334
+ while (this.#bytes > this.#maxBytes && this.#cache.size > 0) {
335
+ const oldest = this.#cache.keys().next().value as string;
336
+ const evicted = this.#cache.get(oldest)!;
337
+ this.#cache.delete(oldest);
338
+ this.#bytes -= evicted.bytes;
339
+ this.#evictedLru++;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Resulting expiry of a just-read/just-written string key: epoch ms, null
345
+ * for "no expiry", or ABSENT when the key no longer exists (-2) — callers
346
+ * must skip the fill in that case.
347
+ */
348
+ #expireOf(key: Uint8Array, now: number): number | null | typeof ABSENT {
349
+ const ttl = this.inner.pttl(key, now);
350
+ if (ttl === -2) return ABSENT;
351
+ return ttl === -1 ? null : now + ttl;
352
+ }
353
+
354
+ /** Evict idle entries; decay hit counts so popularity is not permanent. */
355
+ #idleSweep(now: number): void {
356
+ for (const [k, entry] of this.#cache) {
357
+ const grown = this.#baseIdleMs * (1 + Math.log2(1 + entry.hits));
358
+ const tti = Math.min(this.#maxIdleMs, grown);
359
+ if (now - entry.lastAccess > tti) {
360
+ this.#cache.delete(k);
361
+ this.#bytes -= entry.bytes;
362
+ this.#evictedIdle++;
363
+ } else {
364
+ entry.hits >>= 1;
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ /** Binary-safe Map key for raw key bytes. */
371
+ function mapKey(key: Uint8Array): string {
372
+ return Buffer.from(key).toString("latin1");
373
+ }
@@ -17,25 +17,57 @@ import {
17
17
  } from "../engine/errors";
18
18
  import type { RedisType, SetOptions, StorageEngine } from "./types";
19
19
 
20
+ const ENC = new TextEncoder();
21
+
22
+ /** Max expired keys reclaimed per sweep call (bounds reaper-tick stalls). */
23
+ const SWEEP_BATCH = 1000;
24
+
20
25
  export interface SqliteStorageOptions {
21
26
  /** Hook invoked with a key whenever it is mutated (drives WATCH versioning). */
22
27
  readonly onWrite?: (key: Uint8Array) => void;
28
+ /** Hook invoked after FLUSHDB/FLUSHALL (per-key hooks can't enumerate). */
29
+ readonly onFlushAll?: () => void;
30
+ /** SQLite page-cache size in KB (PRAGMA cache_size). Default: SQLite's own. */
31
+ readonly pageCacheKb?: number;
23
32
  }
24
33
 
25
34
  export class SqliteStorage implements StorageEngine {
26
35
  #db: Database;
27
36
  #onWrite: (key: Uint8Array) => void;
37
+ #onFlushAll: () => void;
28
38
 
29
39
  constructor(path = ":memory:", opts: SqliteStorageOptions = {}) {
30
- this.#db = new Database(path, { create: true, strict: false });
31
- this.#onWrite = opts.onWrite ?? (() => {});
32
- this.#init();
40
+ try {
41
+ this.#db = new Database(path, { create: true, strict: false });
42
+ this.#onWrite = opts.onWrite ?? (() => {});
43
+ this.#onFlushAll = opts.onFlushAll ?? (() => {});
44
+ this.#init(opts.pageCacheKb);
45
+ } catch (err) {
46
+ const msg = err instanceof Error ? err.message : String(err);
47
+ if (/locked|busy/i.test(msg)) {
48
+ throw new Error(
49
+ `database "${path}" is already in use by another bundis process ` +
50
+ "(the single-writer contract forbids sharing one .db file)",
51
+ );
52
+ }
53
+ throw new Error(`cannot open database "${path}": ${msg}`);
54
+ }
33
55
  }
34
56
 
35
- #init(): void {
57
+ #init(pageCacheKb?: number): void {
58
+ // Enforce the documented single-writer contract (§5.3): EXCLUSIVE locking
59
+ // holds the file lock for the process lifetime, so a second bundis on the
60
+ // same .db fails fast here instead of corrupting cache coherence later.
61
+ // (:memory: is unaffected; WAL+exclusive runs with a heap WAL-index.)
62
+ this.#db.exec("PRAGMA busy_timeout = 2000;");
63
+ this.#db.exec("PRAGMA locking_mode = EXCLUSIVE;");
36
64
  this.#db.exec("PRAGMA journal_mode = WAL;");
37
65
  this.#db.exec("PRAGMA synchronous = NORMAL;");
38
66
  this.#db.exec("PRAGMA foreign_keys = ON;");
67
+ if (pageCacheKb !== undefined && pageCacheKb > 0) {
68
+ // negative value = size in KB (positive would mean pages)
69
+ this.#db.exec(`PRAGMA cache_size = -${Math.floor(pageCacheKb)};`);
70
+ }
39
71
  this.#db.exec(`
40
72
  CREATE TABLE IF NOT EXISTS keys (
41
73
  key BLOB PRIMARY KEY,
@@ -146,22 +178,43 @@ export class SqliteStorage implements StorageEngine {
146
178
  return true;
147
179
  }
148
180
 
181
+ /**
182
+ * One bounded sweep batch per call (the reaper ticks every ~100ms, so a
183
+ * burst of expirations is reclaimed incrementally instead of blocking every
184
+ * connection for one giant transaction — measured 380ms p99 before the cap).
185
+ */
149
186
  sweepExpired(now: number): number {
150
187
  return this.withTransaction(() => {
151
188
  const rows = this.#stmt(
152
- "SELECT key FROM keys WHERE expire_at_ms IS NOT NULL AND expire_at_ms <= ?",
189
+ "DELETE FROM keys WHERE key IN (" +
190
+ "SELECT key FROM keys WHERE expire_at_ms IS NOT NULL AND expire_at_ms <= ? " +
191
+ `LIMIT ${SWEEP_BATCH}) RETURNING key`,
153
192
  ).all(now) as Array<{ key: Uint8Array }>;
154
- for (const r of rows) this.#deleteKey(r.key);
193
+ for (const r of rows) this.#onWrite(r.key);
155
194
  return rows.length;
156
195
  });
157
196
  }
158
197
 
159
198
  dbsize(now: number): number {
160
- this.sweepExpired(now);
161
- const row = this.#stmt("SELECT COUNT(*) AS n FROM keys").get() as { n: number };
199
+ // Count live keys without sweeping: INFO/DBSIZE polling must never pay
200
+ // (or trigger) a reclamation pass.
201
+ const row = this.#stmt(
202
+ "SELECT COUNT(*) AS n FROM keys WHERE expire_at_ms IS NULL OR expire_at_ms > ?",
203
+ ).get(now) as { n: number };
162
204
  return row.n;
163
205
  }
164
206
 
207
+ flushAll(): void {
208
+ this.withTransaction(() => {
209
+ // Children first: kv/hash_fields/set_members reference keys.
210
+ this.#stmt("DELETE FROM kv").run();
211
+ this.#stmt("DELETE FROM hash_fields").run();
212
+ this.#stmt("DELETE FROM set_members").run();
213
+ this.#stmt("DELETE FROM keys").run();
214
+ });
215
+ this.#onFlushAll();
216
+ }
217
+
165
218
  // ── string / kv ─────────────────────────────────────────────────────────--
166
219
 
167
220
  kvGet(key: Uint8Array, now: number): Uint8Array | null {
@@ -215,7 +268,7 @@ export class SqliteStorage implements StorageEngine {
215
268
  n = parseIntStrict(cur);
216
269
  }
217
270
  const next = n + delta;
218
- const buf = new TextEncoder().encode(next.toString());
271
+ const buf = ENC.encode(next.toString());
219
272
  this.#stmt(
220
273
  "INSERT INTO keys(key, type, expire_at_ms) VALUES (?, 'string', NULL) " +
221
274
  "ON CONFLICT(key) DO UPDATE SET type='string'",
@@ -237,7 +290,7 @@ export class SqliteStorage implements StorageEngine {
237
290
  if (!Number.isFinite(next)) {
238
291
  throw new RespError("ERR", "increment would produce NaN or Infinity");
239
292
  }
240
- const buf = new TextEncoder().encode(formatFloat(next));
293
+ const buf = ENC.encode(formatFloat(next));
241
294
  this.#stmt(
242
295
  "INSERT INTO keys(key, type, expire_at_ms) VALUES (?, 'string', NULL) " +
243
296
  "ON CONFLICT(key) DO UPDATE SET type='string'",
@@ -361,7 +414,7 @@ export class SqliteStorage implements StorageEngine {
361
414
  const cur = this.hGet(key, field, now);
362
415
  const n = cur === null ? 0n : parseIntStrict(cur);
363
416
  const next = n + delta;
364
- this.hSet(key, [[field, new TextEncoder().encode(next.toString())]], now);
417
+ this.hSet(key, [[field, ENC.encode(next.toString())]], now);
365
418
  return next;
366
419
  });
367
420
  }
@@ -371,16 +424,16 @@ export class SqliteStorage implements StorageEngine {
371
424
  const cur = this.hGet(key, field, now);
372
425
  const n = cur === null ? 0 : parseFloatStrict(cur);
373
426
  const next = n + delta;
374
- this.hSet(key, [[field, new TextEncoder().encode(formatFloat(next))]], now);
427
+ this.hSet(key, [[field, ENC.encode(formatFloat(next))]], now);
375
428
  return next;
376
429
  });
377
430
  }
378
431
 
379
432
  #dropIfEmptyHash(key: Uint8Array): void {
380
- const row = this.#stmt(
381
- "SELECT COUNT(*) AS n FROM hash_fields WHERE key = ?",
382
- ).get(key) as { n: number };
383
- if (row.n === 0) this.#stmt("DELETE FROM keys WHERE key = ?").run(key);
433
+ // Existence probe, not COUNT(*): a 100k-field hash must not pay a full
434
+ // index scan on every HDEL just to learn it is non-empty.
435
+ const any = this.#stmt("SELECT 1 FROM hash_fields WHERE key = ? LIMIT 1").get(key);
436
+ if (any === null) this.#stmt("DELETE FROM keys WHERE key = ?").run(key);
384
437
  }
385
438
 
386
439
  // ── set ────────────────────────────────────────────────────────────────---
@@ -449,15 +502,25 @@ export class SqliteStorage implements StorageEngine {
449
502
  count: number | null,
450
503
  now: number,
451
504
  ): Uint8Array[] | Uint8Array | null {
452
- const members = this.sMembers(key, now);
505
+ if (!this.#expectType(key, "set", now)) {
506
+ return count === null ? null : [];
507
+ }
508
+ // Random selection stays in SQL: materializing all members into JS just to
509
+ // pick a few measured ~5ms per op on a 100k set.
453
510
  if (count === null) {
454
- if (members.length === 0) return null;
455
- return members[Math.floor(Math.random() * members.length)]!;
511
+ const row = this.#randomMember(key);
512
+ return row ?? null;
456
513
  }
457
514
  if (count >= 0) {
458
- return shuffle(members).slice(0, count);
515
+ return (
516
+ this.#stmt(
517
+ "SELECT member FROM set_members WHERE key = ? ORDER BY random() LIMIT ?",
518
+ ).all(key, count) as Array<{ member: Uint8Array }>
519
+ ).map((r) => r.member);
459
520
  }
460
- // Negative count: allow repeats, |count| elements.
521
+ // Negative count: |count| picks WITH repeats — needs independent draws, so
522
+ // the full-load path is genuinely required here.
523
+ const members = this.sMembers(key, now);
461
524
  const out: Uint8Array[] = [];
462
525
  if (members.length === 0) return out;
463
526
  for (let i = 0; i < -count; i++) {
@@ -472,24 +535,53 @@ export class SqliteStorage implements StorageEngine {
472
535
  now: number,
473
536
  ): Uint8Array[] | Uint8Array | null {
474
537
  return this.withTransaction(() => {
475
- const members = this.sMembers(key, now);
538
+ if (!this.#expectType(key, "set", now)) {
539
+ return count === null ? null : [];
540
+ }
476
541
  if (count === null) {
477
- if (members.length === 0) return null;
478
- const pick = members[Math.floor(Math.random() * members.length)]!;
479
- this.sRem(key, [pick], now);
480
- return pick;
542
+ // Single pop: uniform COUNT+OFFSET pick is ~3x cheaper than
543
+ // ORDER BY random() (no per-row random + top-k sort).
544
+ const member = this.#randomMember(key);
545
+ if (member === undefined) return null;
546
+ this.#stmt("DELETE FROM set_members WHERE key = ? AND member = ?").run(key, member);
547
+ this.#dropIfEmptySet(key);
548
+ this.#onWrite(key);
549
+ return member;
550
+ }
551
+ const k = Math.max(0, count);
552
+ const rows =
553
+ k === 0
554
+ ? []
555
+ : (this.#stmt(
556
+ "DELETE FROM set_members WHERE key = ? AND member IN (" +
557
+ "SELECT member FROM set_members WHERE key = ? ORDER BY random() LIMIT ?" +
558
+ ") RETURNING member",
559
+ ).all(key, key, k) as Array<{ member: Uint8Array }>);
560
+ if (rows.length > 0) {
561
+ this.#dropIfEmptySet(key);
562
+ this.#onWrite(key);
481
563
  }
482
- const picks = shuffle(members).slice(0, Math.max(0, count));
483
- if (picks.length > 0) this.sRem(key, picks, now);
484
- return picks;
564
+ return rows.map((r) => r.member);
485
565
  });
486
566
  }
487
567
 
488
- #dropIfEmptySet(key: Uint8Array): void {
568
+ /** Uniform random member of a set key, or undefined when empty. */
569
+ #randomMember(key: Uint8Array): Uint8Array | undefined {
570
+ const n = (
571
+ this.#stmt("SELECT COUNT(*) AS n FROM set_members WHERE key = ?").get(key) as {
572
+ n: number;
573
+ }
574
+ ).n;
575
+ if (n === 0) return undefined;
489
576
  const row = this.#stmt(
490
- "SELECT COUNT(*) AS n FROM set_members WHERE key = ?",
491
- ).get(key) as { n: number };
492
- if (row.n === 0) this.#stmt("DELETE FROM keys WHERE key = ?").run(key);
577
+ "SELECT member FROM set_members WHERE key = ? LIMIT 1 OFFSET ?",
578
+ ).get(key, Math.floor(Math.random() * n)) as { member: Uint8Array } | null;
579
+ return row?.member;
580
+ }
581
+
582
+ #dropIfEmptySet(key: Uint8Array): void {
583
+ const any = this.#stmt("SELECT 1 FROM set_members WHERE key = ? LIMIT 1").get(key);
584
+ if (any === null) this.#stmt("DELETE FROM keys WHERE key = ?").run(key);
493
585
  }
494
586
 
495
587
  // ── atomicity / lifecycle ───────────────────────────────────────────────--
@@ -556,12 +648,3 @@ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
556
648
  out.set(b, a.length);
557
649
  return out;
558
650
  }
559
-
560
- function shuffle<T>(arr: T[]): T[] {
561
- const a = arr.slice();
562
- for (let i = a.length - 1; i > 0; i--) {
563
- const j = Math.floor(Math.random() * (i + 1));
564
- [a[i], a[j]] = [a[j]!, a[i]!];
565
- }
566
- return a;
567
- }
@@ -38,6 +38,8 @@ export interface StorageEngine {
38
38
  sweepExpired(now: number): number;
39
39
  /** Total live key count (after lazy considerations are out of scope here). */
40
40
  dbsize(now: number): number;
41
+ /** Remove every key (FLUSHDB/FLUSHALL). */
42
+ flushAll(): void;
41
43
 
42
44
  // ── string / kv ─────────────────────────────────────────────────────────--
43
45
  kvGet(key: Uint8Array, now: number): Uint8Array | null;