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/README.md +26 -3
- package/package.json +1 -1
- package/src/cli.ts +14 -0
- package/src/commands/admin.ts +108 -0
- package/src/commands/expire.ts +36 -7
- package/src/commands/handshake.ts +25 -2
- package/src/commands/multikey.ts +8 -4
- package/src/commands/pubsub.ts +26 -3
- package/src/commands/set.ts +7 -1
- package/src/commands/string.ts +24 -11
- package/src/commands/transaction.ts +19 -9
- package/src/config.ts +29 -2
- package/src/connection.ts +34 -3
- package/src/dispatcher.ts +24 -0
- package/src/engine/errors.ts +6 -2
- package/src/launch.ts +27 -3
- package/src/resp/parser.ts +46 -6
- package/src/resp/serializer.ts +3 -0
- package/src/server.ts +60 -13
- package/src/sidecar/memory-guard.ts +31 -0
- package/src/sidecar/pubsub.ts +7 -0
- package/src/sidecar/reaper.ts +7 -1
- package/src/sidecar/watch.ts +52 -12
- package/src/storage/cache.ts +373 -0
- package/src/storage/sqlite.ts +125 -42
- package/src/storage/types.ts +2 -0
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(
|
|
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.#
|
|
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.#
|
|
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;
|
package/src/engine/errors.ts
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
package/src/resp/parser.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
53
|
-
this.#buf =
|
|
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)
|
|
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
|
|
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)
|
|
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 };
|
package/src/resp/serializer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/sidecar/pubsub.ts
CHANGED
|
@@ -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 {
|
package/src/sidecar/reaper.ts
CHANGED
|
@@ -19,7 +19,13 @@ export class ExpiryReaper {
|
|
|
19
19
|
start(): void {
|
|
20
20
|
if (this.#timer !== null) return;
|
|
21
21
|
this.#timer = setInterval(() => {
|
|
22
|
-
|
|
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?.();
|
package/src/sidecar/watch.ts
CHANGED
|
@@ -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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
#
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
}
|