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.
package/src/connection.ts CHANGED
@@ -12,9 +12,13 @@ import { serialize } from "./resp/serializer";
12
12
  import { RespParser } from "./resp/parser";
13
13
  import type { Command, Reply } from "./resp/types";
14
14
  import type { WatchSnapshot } from "./sidecar/watch";
15
+ import type { MemoryGuard } from "./sidecar/memory-guard";
15
16
 
16
17
  export type ConnState = "HANDSHAKE" | "READY" | "SUBSCRIBED";
17
18
 
19
+ /** Hard ceiling for bytes queued toward one slow-reading client. */
20
+ const MAX_OUTBOX_BYTES = 32 * 1024 * 1024;
21
+
18
22
  /** A buffered MULTI transaction. */
19
23
  export interface TxnState {
20
24
  queued: Command[];
@@ -44,9 +48,16 @@ export class Connection {
44
48
 
45
49
  /** Bytes awaiting a `drain` event when the socket buffer was full. */
46
50
  #outbox: Uint8Array[] = [];
51
+ #outboxBytes = 0;
47
52
  #closed = false;
53
+ readonly #guard: MemoryGuard;
48
54
 
49
- constructor(readonly socket: Socket<Connection>) {}
55
+ constructor(
56
+ readonly socket: Socket<Connection>,
57
+ guard: MemoryGuard,
58
+ ) {
59
+ this.#guard = guard;
60
+ }
50
61
 
51
62
  /** Total active (P)SUBSCRIBE count, used in reply frames. */
52
63
  subscriptionCount(): number {
@@ -68,13 +79,29 @@ export class Connection {
68
79
  if (this.#closed) return;
69
80
  if (this.#outbox.length > 0) {
70
81
  // Preserve ordering: once we're behind, everything queues.
71
- this.#outbox.push(bytes);
82
+ this.#queue(bytes);
72
83
  return;
73
84
  }
74
85
  const written = this.socket.write(bytes);
75
86
  if (written < bytes.length) {
76
- this.#outbox.push(bytes.subarray(written));
87
+ this.#queue(bytes.subarray(written));
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Queue backpressured bytes; a consumer that stops reading (e.g. a stalled
93
+ * subscriber under PUBLISH load) is disconnected at the cap rather than
94
+ * letting its outbox grow until the process OOMs.
95
+ */
96
+ #queue(bytes: Uint8Array): void {
97
+ this.#outboxBytes += bytes.length;
98
+ this.#guard.add(bytes.length);
99
+ if (this.#outboxBytes > MAX_OUTBOX_BYTES || this.#guard.overLimit) {
100
+ this.markClosed();
101
+ this.socket.end();
102
+ return;
77
103
  }
104
+ this.#outbox.push(bytes);
78
105
  }
79
106
 
80
107
  /** Called from the socket `drain` handler: flush what we can, in order. */
@@ -82,6 +109,8 @@ export class Connection {
82
109
  while (this.#outbox.length > 0 && !this.#closed) {
83
110
  const chunk = this.#outbox[0]!;
84
111
  const written = this.socket.write(chunk);
112
+ this.#outboxBytes -= written;
113
+ this.#guard.sub(written);
85
114
  if (written < chunk.length) {
86
115
  this.#outbox[0] = chunk.subarray(written);
87
116
  return; // still backpressured
@@ -92,6 +121,8 @@ export class Connection {
92
121
 
93
122
  markClosed(): void {
94
123
  this.#closed = true;
124
+ this.#guard.sub(this.#outboxBytes);
95
125
  this.#outbox.length = 0;
126
+ this.#outboxBytes = 0;
96
127
  }
97
128
  }
package/src/dispatcher.ts CHANGED
@@ -22,6 +22,7 @@ import * as hash from "./commands/hash";
22
22
  import * as set from "./commands/set";
23
23
  import * as pubsub from "./commands/pubsub";
24
24
  import * as txn from "./commands/transaction";
25
+ import * as admin from "./commands/admin";
25
26
 
26
27
  /** A command handler. Returns the reply, or null if it wrote its own output. */
27
28
  export type Handler = (ctx: CommandContext) => Reply | null;
@@ -109,8 +110,21 @@ const TABLE: Record<string, Handler> = {
109
110
  WATCH: txn.watch,
110
111
  UNWATCH: txn.unwatch,
111
112
  RESET: txn.reset,
113
+
114
+ // server / admin
115
+ TYPE: admin.type,
116
+ DBSIZE: admin.dbsize,
117
+ FLUSHDB: admin.flushall, // single DB: FLUSHDB == FLUSHALL
118
+ FLUSHALL: admin.flushall,
119
+ CONFIG: admin.config,
120
+ COMMAND: admin.command,
112
121
  };
113
122
 
123
+ /** Number of dispatchable commands (COMMAND COUNT). */
124
+ export function commandCount(): number {
125
+ return Object.keys(TABLE).length;
126
+ }
127
+
114
128
  /** Commands permitted while in SUBSCRIBED mode (§6.1). */
115
129
  const SUBSCRIBE_ALLOWED = new Set([
116
130
  "SUBSCRIBE",
@@ -163,6 +177,16 @@ export function dispatch(
163
177
  conn.send(errReply(Errors.unknownCmd(command.name, argStrings(command))));
164
178
  return;
165
179
  }
180
+ if (SUBSCRIBE_ALLOWED.has(command.name) && command.name !== "PING" && command.name !== "QUIT") {
181
+ // (P)SUBSCRIBE/(P)UNSUBSCRIBE inside MULTI would flip the connection
182
+ // mode mid-EXEC and desync the client's reply matching (Redis rejects).
183
+ conn.txn.error = true;
184
+ conn.send(errReply({
185
+ code: "ERR",
186
+ message: `${command.name} is not allowed in transactions`,
187
+ }));
188
+ return;
189
+ }
166
190
  conn.txn.queued.push(command);
167
191
  conn.send(R.simple("QUEUED"));
168
192
  return;
@@ -61,6 +61,8 @@ export const Errors = {
61
61
  syntax: () => new RespError("ERR", "syntax error"),
62
62
  notInt: () => new RespError("ERR", "value is not an integer or out of range"),
63
63
  notFloat: () => new RespError("ERR", "value is not a valid float"),
64
+ invalidExpire: (cmd: string) =>
65
+ new RespError("ERR", `invalid expire time in '${cmd}' command`),
64
66
  wrongArgs: (cmd: string) =>
65
67
  new RespError("ERR", `wrong number of arguments for '${cmd.toLowerCase()}' command`),
66
68
  unknownCmd: (cmd: string, args: string[]) =>
@@ -88,6 +90,8 @@ export function toRespError(err: unknown): RespError {
88
90
  if (err instanceof TypeMismatchError) return Errors.wrongType();
89
91
  if (err instanceof NotIntegerError) return Errors.notInt();
90
92
  if (err instanceof NotFloatError) return Errors.notFloat();
91
- const msg = err instanceof Error ? err.message : String(err);
92
- return new RespError("ERR", msg);
93
+ // Unknown internals (SQLite/IO/runtime): log server-side for the operator,
94
+ // never echo raw messages (paths, engine state) over the wire.
95
+ console.error("bundis: internal command error:", err);
96
+ return new RespError("ERR", "internal error");
93
97
  }
package/src/launch.ts CHANGED
@@ -28,6 +28,15 @@ export interface LaunchOptions {
28
28
  password?: string;
29
29
  /** Active-expiry sweep interval in ms (default 100). */
30
30
  reaperIntervalMs?: number;
31
+ /**
32
+ * Overall memory budget in MB (default 256). Sizes the SQLite page cache
33
+ * (50%) and the hot cache default (25%). A budget, not a hard OS limit.
34
+ */
35
+ maxMemoryMb?: number;
36
+ /** Hot-cache ceiling in MB (default: maxMemoryMb/4; 0 disables the cache). */
37
+ cacheMb?: number;
38
+ /** Hot-cache base time-to-idle in seconds (default 300). */
39
+ cacheIdleSec?: number;
31
40
  }
32
41
 
33
42
  export interface EmbeddedServer {
@@ -77,6 +86,11 @@ export interface SpawnServerOptions extends LaunchOptions {
77
86
  export async function spawnServer(opts: SpawnServerOptions = {}): Promise<SpawnedServer> {
78
87
  const config = resolveConfig(opts);
79
88
  const cliPath = Bun.fileURLToPath(new URL("./cli.ts", import.meta.url));
89
+ // The password travels via environment, never argv — argv is visible to
90
+ // every local user in `ps` for the lifetime of the child.
91
+ const env: Record<string, string | undefined> = { ...process.env };
92
+ delete env.REDIS_PASSWORD;
93
+ if (config.password !== null) env.REDIS_PASSWORD = config.password;
80
94
  const proc = Bun.spawn(
81
95
  [
82
96
  opts.bunPath ?? process.execPath,
@@ -84,10 +98,12 @@ export async function spawnServer(opts: SpawnServerOptions = {}): Promise<Spawne
84
98
  "--host", config.host,
85
99
  "--port", String(config.port),
86
100
  "--db", config.dbPath,
87
- ...(config.password !== null ? ["--password", config.password] : []),
88
101
  "--reaper", String(config.reaperIntervalMs),
102
+ "--max-memory-mb", String(Math.floor(config.maxMemoryBytes / (1024 * 1024))),
103
+ "--cache-mb", String(Math.floor(config.cacheMaxBytes / (1024 * 1024))),
104
+ "--cache-idle", String(Math.floor(config.cacheIdleMs / 1000)),
89
105
  ],
90
- { stdout: "pipe", stderr: "inherit" },
106
+ { stdout: "pipe", stderr: "inherit", env },
91
107
  );
92
108
 
93
109
  let ready: { host: string; port: number };
@@ -114,12 +130,20 @@ export async function spawnServer(opts: SpawnServerOptions = {}): Promise<Spawne
114
130
  // ── helpers ──────────────────────────────────────────────────────────────---
115
131
 
116
132
  function resolveConfig(opts: LaunchOptions): ServerConfig {
133
+ const MB = 1024 * 1024;
134
+ const maxMemoryMb = opts.maxMemoryMb ?? 256;
117
135
  return {
118
136
  host: opts.host ?? "127.0.0.1",
119
137
  port: opts.port ?? 6379,
120
138
  dbPath: opts.dbPath ?? "./data.db",
121
- password: opts.password ?? null,
139
+ // Honor flag > env > none, so an env-configured password is not silently
140
+ // dropped (which would launch an open server — fail-open regression).
141
+ password: opts.password ?? Bun.env.REDIS_PASSWORD ?? null,
122
142
  reaperIntervalMs: opts.reaperIntervalMs ?? 100,
143
+ maxClients: 10_000,
144
+ maxMemoryBytes: maxMemoryMb * MB,
145
+ cacheMaxBytes: (opts.cacheMb ?? Math.floor(maxMemoryMb / 4)) * MB,
146
+ cacheIdleMs: (opts.cacheIdleSec ?? 300) * 1000,
123
147
  };
124
148
  }
125
149
 
@@ -9,6 +9,10 @@
9
9
  * The stock `Bun.RedisClient` always sends commands as a RESP Array of Bulk
10
10
  * Strings (`*N\r\n($len\r\n<bytes>\r\n)*`). We parse that shape, plus inline
11
11
  * commands as a tolerant fallback. Argument bytes are preserved verbatim.
12
+ *
13
+ * Input caps (defense against pre-auth memory exhaustion — ProtocolError closes
14
+ * the connection): declared bulk length ≤ 512MB (Redis proto-max-bulk-len),
15
+ * multibulk count ≤ 1M, inline line ≤ 64KB, total pending buffer ≤ 768MB.
12
16
  */
13
17
 
14
18
  import type { Command } from "./types";
@@ -18,20 +22,39 @@ const LF = 10;
18
22
  const STAR = 42; // '*'
19
23
  const DOLLAR = 36; // '$'
20
24
 
25
+ const MAX_BULK_LEN = 512 * 1024 * 1024;
26
+ const MAX_MULTIBULK = 1024 * 1024;
27
+ const MAX_INLINE_LEN = 64 * 1024;
28
+ const MAX_PENDING = 768 * 1024 * 1024;
29
+
30
+ const EMPTY = new Uint8Array(0);
31
+
21
32
  export class RespParser {
22
33
  /** Pending bytes not yet consumed into a full command. */
23
- #buf: Uint8Array = new Uint8Array(0);
34
+ #buf: Uint8Array = EMPTY;
35
+ /** True while #buf aliases an externally-owned chunk (no copy taken yet). */
36
+ #aliased = false;
37
+
38
+ /** Bytes currently held pending a complete command. */
39
+ get bufferedBytes(): number {
40
+ return this.#buf.length;
41
+ }
24
42
 
25
43
  /** Append a freshly received chunk to the internal buffer. */
26
44
  push(chunk: Uint8Array): void {
45
+ if (this.#buf.length + chunk.length > MAX_PENDING) {
46
+ throw new ProtocolError("query buffer exceeds limit");
47
+ }
27
48
  if (this.#buf.length === 0) {
28
49
  this.#buf = chunk;
50
+ this.#aliased = true; // zero-copy fast path; drain() copies any leftover
29
51
  return;
30
52
  }
31
53
  const merged = new Uint8Array(this.#buf.length + chunk.length);
32
54
  merged.set(this.#buf, 0);
33
55
  merged.set(chunk, this.#buf.length);
34
56
  this.#buf = merged;
57
+ this.#aliased = false;
35
58
  }
36
59
 
37
60
  /**
@@ -49,8 +72,15 @@ export class RespParser {
49
72
  out.push(res.command);
50
73
  offset = res.next;
51
74
  }
52
- if (offset > 0) {
53
- this.#buf = this.#buf.subarray(offset);
75
+ if (offset >= this.#buf.length) {
76
+ this.#buf = EMPTY;
77
+ this.#aliased = false;
78
+ } else if (offset > 0 || this.#aliased) {
79
+ // Copy the remainder: the caller's chunk buffer may be reused by the
80
+ // runtime after this callback returns, so we must never retain a view
81
+ // of it across data() events.
82
+ this.#buf = this.#buf.slice(offset);
83
+ this.#aliased = false;
54
84
  }
55
85
  return out;
56
86
  }
@@ -70,7 +100,9 @@ export class RespParser {
70
100
  const header = this.#readLine(start);
71
101
  if (header === null) return null;
72
102
  const count = parseInt(decodeAscii(buf.subarray(start + 1, header.end)), 10);
73
- if (Number.isNaN(count)) throw new ProtocolError("invalid multibulk length");
103
+ if (Number.isNaN(count) || count > MAX_MULTIBULK) {
104
+ throw new ProtocolError("invalid multibulk length");
105
+ }
74
106
  if (count <= 0) {
75
107
  // Empty/negative multibulk: skip, no command produced. Represent as empty.
76
108
  return { command: { name: "", args: [] }, next: header.next };
@@ -83,7 +115,9 @@ export class RespParser {
83
115
  const lenLine = this.#readLine(offset);
84
116
  if (lenLine === null) return null;
85
117
  const len = parseInt(decodeAscii(buf.subarray(offset + 1, lenLine.end)), 10);
86
- if (Number.isNaN(len) || len < 0) throw new ProtocolError("invalid bulk length");
118
+ if (Number.isNaN(len) || len < 0 || len > MAX_BULK_LEN) {
119
+ throw new ProtocolError("invalid bulk length");
120
+ }
87
121
  const dataStart = lenLine.next;
88
122
  const dataEnd = dataStart + len;
89
123
  if (dataEnd + 2 > buf.length) return null; // data + trailing CRLF not all here yet
@@ -96,8 +130,14 @@ export class RespParser {
96
130
 
97
131
  #parseInline(start: number): { command: Command; next: number } | null {
98
132
  const line = this.#readLine(start);
99
- if (line === null) return null;
133
+ if (line === null) {
134
+ if (this.#buf.length - start > MAX_INLINE_LEN) {
135
+ throw new ProtocolError("too big inline request");
136
+ }
137
+ return null;
138
+ }
100
139
  const raw = this.#buf.subarray(start, line.end);
140
+ if (raw.length > MAX_INLINE_LEN) throw new ProtocolError("too big inline request");
101
141
  const text = decodeAscii(raw).trim();
102
142
  if (text.length === 0) {
103
143
  return { command: { name: "", args: [] }, next: line.next };
@@ -4,6 +4,9 @@
4
4
  * Walks a {@link Reply} tree and produces wire bytes. Bulk/verbatim lengths are
5
5
  * computed in **bytes** (not characters) so binary-safe values and multi-byte
6
6
  * UTF-8 are framed correctly. This is the only place RESP3 type bytes are chosen.
7
+ *
8
+ * RESP3 only by design: the sole supported client is the stock `Bun.RedisClient`,
9
+ * which always negotiates `HELLO 3` (§2.1). No RESP2 downgrade path exists.
7
10
  */
8
11
 
9
12
  import type { Reply } from "./types";
package/src/server.ts CHANGED
@@ -13,8 +13,11 @@ import { dispatch } from "./dispatcher";
13
13
  import { ProtocolError } from "./resp/parser";
14
14
  import { R } from "./resp/types";
15
15
  import { SqliteStorage } from "./storage/sqlite";
16
+ import { HotCacheStorage } from "./storage/cache";
17
+ import type { StorageEngine } from "./storage/types";
16
18
  import { PubSubHub } from "./sidecar/pubsub";
17
- import { WatchRegistry } from "./sidecar/watch";
19
+ import { releaseSnapshot, WatchRegistry } from "./sidecar/watch";
20
+ import { MemoryGuard } from "./sidecar/memory-guard";
18
21
  import { ExpiryReaper } from "./sidecar/reaper";
19
22
  import type { ServerConfig } from "./config";
20
23
  import type { ServerContext } from "./engine/context";
@@ -30,37 +33,75 @@ export interface RunningServer {
30
33
 
31
34
  export function startServer(config: ServerConfig): RunningServer {
32
35
  const watch = new WatchRegistry();
33
- const storage = new SqliteStorage(config.dbPath, { onWrite: watch.bump });
36
+ let cache: HotCacheStorage | null = null;
37
+ const sqlite = new SqliteStorage(config.dbPath, {
38
+ onWrite: (key) => {
39
+ watch.bump(key);
40
+ cache?.invalidate(key); // every mutation evicts its stale cache entry
41
+ },
42
+ onFlushAll: () => {
43
+ watch.bumpAll();
44
+ cache?.invalidateAll();
45
+ },
46
+ // 50% of the overall memory budget goes to the SQLite page cache.
47
+ pageCacheKb: Math.floor(config.maxMemoryBytes / 2 / 1024),
48
+ });
49
+ const storage: StorageEngine =
50
+ config.cacheMaxBytes > 0
51
+ ? (cache = new HotCacheStorage(sqlite, {
52
+ maxBytes: config.cacheMaxBytes,
53
+ baseIdleMs: config.cacheIdleMs,
54
+ }))
55
+ : sqlite;
34
56
  const hub = new PubSubHub();
35
57
  const ctx: ServerContext = { storage, hub, watch, config };
36
58
 
37
59
  const reaper = new ExpiryReaper(storage, config.reaperIntervalMs);
38
60
  reaper.start();
39
61
 
62
+ // Aggregate buffer ceiling across all connections (inbound parser + outbound
63
+ // backpressure), independent of per-connection caps.
64
+ const guard = new MemoryGuard(config.maxMemoryBytes);
65
+ let liveClients = 0;
40
66
  const listener: TCPSocketListener<Connection> = Bun.listen<Connection>({
41
67
  hostname: config.host,
42
68
  port: config.port,
43
69
  socket: {
44
70
  open(socket: Socket<Connection>) {
45
- socket.data = new Connection(socket);
71
+ if (liveClients >= config.maxClients) {
72
+ socket.write("-ERR max number of clients reached\r\n");
73
+ socket.end();
74
+ return; // socket.data stays unset; close() skips it
75
+ }
76
+ liveClients++;
77
+ // Nagle interacts badly with small request/reply round-trips.
78
+ (socket as unknown as { setNoDelay?: (on: boolean) => void }).setNoDelay?.(true);
79
+ socket.data = new Connection(socket, guard);
46
80
  },
47
81
  data(socket: Socket<Connection>, chunk: Buffer) {
48
82
  const conn = socket.data;
49
- conn.parser.push(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
50
- let commands;
51
83
  try {
52
- commands = conn.parser.drain();
84
+ const before = conn.parser.bufferedBytes;
85
+ conn.parser.push(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
86
+ const commands = conn.parser.drain();
87
+ // Account the net change in the inbound buffer against the global cap.
88
+ guard.add(conn.parser.bufferedBytes - before);
89
+ if (guard.overLimit) throw new ProtocolError("server memory limit reached");
90
+ const now = Date.now();
91
+ for (const command of commands) {
92
+ dispatch(conn, command, ctx, now);
93
+ }
53
94
  } catch (err) {
54
95
  if (err instanceof ProtocolError) {
55
96
  conn.send(R.error("ERR", `Protocol error: ${err.message}`));
56
- socket.end();
57
- return;
97
+ } else {
98
+ // Contain the blast radius to this connection — one bad socket
99
+ // must never take down the whole server.
100
+ console.error(`bundis: connection #${conn.id} error:`, err);
101
+ conn.send(R.error("ERR", "internal error"));
58
102
  }
59
- throw err;
60
- }
61
- const now = Date.now();
62
- for (const command of commands) {
63
- dispatch(conn, command, ctx, now);
103
+ guard.sub(conn.parser.bufferedBytes); // releasing this connection's inbound bytes
104
+ socket.end();
64
105
  }
65
106
  },
66
107
  drain(socket: Socket<Connection>) {
@@ -69,8 +110,14 @@ export function startServer(config: ServerConfig): RunningServer {
69
110
  close(socket: Socket<Connection>) {
70
111
  const conn = socket.data;
71
112
  if (conn) {
113
+ liveClients--;
114
+ guard.sub(conn.parser.bufferedBytes); // release inbound bytes (markClosed releases outbound)
72
115
  conn.markClosed();
73
116
  hub.drop(conn);
117
+ if (conn.watch) {
118
+ releaseSnapshot(watch, conn.watch); // refcounted registry interest
119
+ conn.watch = null;
120
+ }
74
121
  }
75
122
  },
76
123
  error(socket: Socket<Connection>) {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * MemoryGuard — process-global ceiling on bytes buffered across ALL connections
3
+ * (inbound parser buffers + outbound backpressure queues).
4
+ *
5
+ * Per-connection caps alone don't bound aggregate memory: maxClients × the
6
+ * per-connection limit can dwarf the configured budget. This single counter is
7
+ * the backstop — once the aggregate crosses the limit, the connection that
8
+ * pushed it over is closed by its caller.
9
+ */
10
+ export class MemoryGuard {
11
+ #used = 0;
12
+
13
+ constructor(private readonly limit: number) {}
14
+
15
+ add(n: number): void {
16
+ this.#used += n;
17
+ }
18
+
19
+ sub(n: number): void {
20
+ this.#used -= n;
21
+ if (this.#used < 0) this.#used = 0;
22
+ }
23
+
24
+ get used(): number {
25
+ return this.#used;
26
+ }
27
+
28
+ get overLimit(): boolean {
29
+ return this.#used > this.limit;
30
+ }
31
+ }
@@ -79,6 +79,13 @@ export class PubSubHub {
79
79
  numSub(channel: string): number {
80
80
  return this.#channels.get(channel)?.size ?? 0;
81
81
  }
82
+
83
+ /** Count of distinct patterns with at least one subscriber. */
84
+ numPat(): number {
85
+ let n = 0;
86
+ for (const conns of this.#patterns.values()) if (conns.size > 0) n++;
87
+ return n;
88
+ }
82
89
  }
83
90
 
84
91
  function addTo(map: Map<string, Set<Connection>>, key: string, conn: Connection): void {
@@ -19,7 +19,13 @@ export class ExpiryReaper {
19
19
  start(): void {
20
20
  if (this.#timer !== null) return;
21
21
  this.#timer = setInterval(() => {
22
- this.storage.sweepExpired(Date.now());
22
+ try {
23
+ this.storage.sweepExpired(Date.now());
24
+ } catch (err) {
25
+ // A transient storage error (SQLITE_BUSY, disk) must not kill the
26
+ // process; the next tick simply retries.
27
+ console.error("bundis: expiry sweep failed:", err);
28
+ }
23
29
  }, this.intervalMs);
24
30
  // Don't keep the process alive solely for the reaper.
25
31
  this.#timer.unref?.();
@@ -2,32 +2,72 @@
2
2
  * WatchRegistry — optimistic-lock version tracking for MULTI/WATCH/EXEC.
3
3
  *
4
4
  * Under the single-writer assumption (§5.3), correctness only requires detecting
5
- * whether any watched key was modified between WATCH and EXEC. We keep an
6
- * in-process monotonic version per key, bumped on every write. WATCH snapshots
7
- * the current versions; EXEC compares. A missing key has version 0.
5
+ * whether any watched key was modified between WATCH and EXEC. The registry
6
+ * tracks versions ONLY for currently-watched keys (refcounted): bump() is a
7
+ * no-op when nobody watches, and entries die with their last watcher — so the
8
+ * per-write tax is one Map miss and the registry cannot grow without bound.
8
9
  */
9
10
 
10
11
  export class WatchRegistry {
11
- #versions = new Map<string, number>();
12
+ #entries = new Map<string, { version: number; refs: number }>();
13
+ /** Whole-keyspace generation: bumped by FLUSHDB/FLUSHALL. */
14
+ #epoch = 0;
12
15
 
13
16
  /** Bump a key's version. Called by storage on every mutation. */
14
17
  bump = (key: Uint8Array): void => {
15
- const k = hashKey(key);
16
- this.#versions.set(k, (this.#versions.get(k) ?? 0) + 1);
18
+ if (this.#entries.size === 0) return; // nobody watches anything
19
+ const e = this.#entries.get(hashKey(key));
20
+ if (e) e.version++;
17
21
  };
18
22
 
19
- /** Current version of a key (0 if never written). */
20
- version(key: Uint8Array): number {
21
- return this.#versions.get(hashKey(key)) ?? 0;
23
+ /** WATCH: register interest in a key and snapshot its current version. */
24
+ acquire(key: Uint8Array): number {
25
+ const k = hashKey(key);
26
+ let e = this.#entries.get(k);
27
+ if (!e) {
28
+ e = { version: 0, refs: 0 };
29
+ this.#entries.set(k, e);
30
+ }
31
+ e.refs++;
32
+ return e.version + this.#epoch;
33
+ }
34
+
35
+ /** Current version without registering interest (EXEC dirty check). */
36
+ peek(key: Uint8Array): number {
37
+ return (this.#entries.get(hashKey(key))?.version ?? 0) + this.#epoch;
38
+ }
39
+
40
+ /** Release interest registered by {@link acquire}. */
41
+ release(key: Uint8Array): void {
42
+ const k = hashKey(key);
43
+ const e = this.#entries.get(k);
44
+ if (!e) return;
45
+ if (--e.refs <= 0) this.#entries.delete(k);
46
+ }
47
+
48
+ /**
49
+ * FLUSHDB/FLUSHALL: every key changed. The epoch also covers keys whose
50
+ * writes predate this process (persisted DB) and so have no entry.
51
+ */
52
+ bumpAll(): void {
53
+ this.#epoch++;
54
+ }
55
+
56
+ /** Tracked-entry count (observability/tests). */
57
+ size(): number {
58
+ return this.#entries.size;
22
59
  }
23
60
  }
24
61
 
25
62
  /** A connection's WATCH snapshot: key (as hash) → version observed at WATCH. */
26
63
  export type WatchSnapshot = Map<string, { key: Uint8Array; version: number }>;
27
64
 
65
+ /** Release every key in a snapshot (UNWATCH/EXEC/DISCARD/RESET/disconnect). */
66
+ export function releaseSnapshot(registry: WatchRegistry, snap: WatchSnapshot): void {
67
+ for (const { key } of snap.values()) registry.release(key);
68
+ }
69
+
28
70
  /** Stable map-key for a byte array (latin1 round-trips every byte). */
29
71
  export function hashKey(key: Uint8Array): string {
30
- let s = "";
31
- for (let i = 0; i < key.length; i++) s += String.fromCharCode(key[i]!);
32
- return s;
72
+ return Buffer.from(key.buffer, key.byteOffset, key.byteLength).toString("latin1");
33
73
  }