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/README.md
CHANGED
|
@@ -60,17 +60,34 @@ await server.stop(); // kills the child and waits for exit
|
|
|
60
60
|
|
|
61
61
|
Options for both: `host` (default `127.0.0.1`), `port` (default `6379`, `0` =
|
|
62
62
|
ephemeral), `dbPath` (default `./data.db`, `":memory:"` for non-persistent),
|
|
63
|
-
`password`, `reaperIntervalMs
|
|
63
|
+
`password`, `reaperIntervalMs`, `maxMemoryMb` (default `256` — overall memory
|
|
64
|
+
budget: 50% SQLite page cache, 25% hot cache), `cacheMb` (hot-cache ceiling,
|
|
65
|
+
default `maxMemoryMb/4`, `0` disables), `cacheIdleSec` (hot-cache base
|
|
66
|
+
time-to-idle, default `300`). `spawnServer` additionally takes `bunPath` and
|
|
64
67
|
`readyTimeoutMs`. The returned `url` already embeds the password when set.
|
|
65
68
|
|
|
69
|
+
### Hot cache
|
|
70
|
+
|
|
71
|
+
Every `SET` is written through to SQLite **and** kept in an in-memory hot cache;
|
|
72
|
+
`GET`s served from memory skip SQLite entirely (~2.8x read throughput on hot
|
|
73
|
+
working sets). Entries fall out after an idle period that grows with hit count
|
|
74
|
+
(adaptive TTI, capped at 8x), under an LRU byte ceiling. The cache is a pure
|
|
75
|
+
read accelerator — SQLite remains the source of truth, so durability and
|
|
76
|
+
restart behavior are unchanged. Stats appear under `# Cache` in `INFO`.
|
|
77
|
+
|
|
66
78
|
### Standalone daemon — CLI
|
|
67
79
|
|
|
68
80
|
```bash
|
|
69
81
|
bunx bundis --port 6379 --db ./data.db
|
|
70
82
|
# (in this repo: bun run src/cli.ts)
|
|
71
|
-
# flags (or env): --host/REDIS_HOST
|
|
83
|
+
# flags (or env): --host/REDIS_HOST (default 127.0.0.1; use 0.0.0.0 to expose)
|
|
84
|
+
# --port/REDIS_PORT
|
|
72
85
|
# --db/REDIS_DB_PATH (":memory:" for in-memory)
|
|
73
86
|
# --password/REDIS_PASSWORD
|
|
87
|
+
# --max-memory-mb/REDIS_MAX_MEMORY_MB (default 256)
|
|
88
|
+
# --cache-mb/REDIS_CACHE_MB (default maxMemory/4; 0 = off)
|
|
89
|
+
# --cache-idle/REDIS_CACHE_IDLE_SEC (default 300)
|
|
90
|
+
# --max-clients/REDIS_MAX_CLIENTS (default 10000)
|
|
74
91
|
```
|
|
75
92
|
|
|
76
93
|
stdout prints one JSON ready line (`{"event":"bundis:ready",...}`);
|
|
@@ -89,12 +106,18 @@ await client.get("k"); // "v"
|
|
|
89
106
|
- **String / numeric:** SET (EX/PX/EXAT/PXAT/NX/XX/KEEPTTL/GET), GET, GETSET,
|
|
90
107
|
GETDEL, APPEND, STRLEN, DEL/UNLINK, EXISTS, INCR/DECR/INCRBY/DECRBY/INCRBYFLOAT
|
|
91
108
|
- **Multi-key:** MGET, MSET, MSETNX, SETEX, PSETEX, SETNX
|
|
92
|
-
- **Expiry:** EXPIRE
|
|
109
|
+
- **Expiry:** EXPIRE/PEXPIRE/EXPIREAT/PEXPIREAT (with NX/XX/GT/LT), TTL, PTTL, PERSIST
|
|
93
110
|
- **Hash:** HSET, HMSET, HSETNX, HGET, HMGET, HGETALL, HDEL, HEXISTS, HKEYS,
|
|
94
111
|
HVALS, HLEN, HINCRBY, HINCRBYFLOAT
|
|
95
112
|
- **Set:** SADD, SREM, SISMEMBER, SMEMBERS, SCARD, SRANDMEMBER, SPOP
|
|
96
113
|
- **Pub/Sub:** SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PUBLISH, PUBSUB
|
|
97
114
|
- **Transactions:** MULTI, EXEC, DISCARD, WATCH, UNWATCH
|
|
115
|
+
- **Server/admin:** TYPE, DBSIZE, FLUSHDB, FLUSHALL, CONFIG GET/SET, COMMAND
|
|
116
|
+
|
|
117
|
+
The only supported client is the stock `Bun.RedisClient`, which always speaks
|
|
118
|
+
RESP3 — that is the wire-compatibility contract, and the server is RESP3-only by
|
|
119
|
+
design. Default bind is `127.0.0.1`; set `--host 0.0.0.0` (ideally with
|
|
120
|
+
`--password`) to expose the server beyond loopback.
|
|
98
121
|
|
|
99
122
|
## Test
|
|
100
123
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -36,3 +36,17 @@ function shutdown(): void {
|
|
|
36
36
|
|
|
37
37
|
process.on("SIGINT", shutdown);
|
|
38
38
|
process.on("SIGTERM", shutdown);
|
|
39
|
+
|
|
40
|
+
// Last-resort backstop: log, try to close storage cleanly, exit non-zero.
|
|
41
|
+
process.on("uncaughtException", (err) => {
|
|
42
|
+
console.error("bundis: uncaught exception:", err);
|
|
43
|
+
try {
|
|
44
|
+
running.stop();
|
|
45
|
+
} catch {
|
|
46
|
+
// already torn down
|
|
47
|
+
}
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
50
|
+
process.on("unhandledRejection", (err) => {
|
|
51
|
+
console.error("bundis: unhandled rejection:", err);
|
|
52
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server/admin commands: TYPE, DBSIZE, FLUSHDB, FLUSHALL, CONFIG, COMMAND.
|
|
3
|
+
*
|
|
4
|
+
* These exist because real tooling sends them before doing anything useful:
|
|
5
|
+
* integration tests flush between cases, redis-cli probes TYPE/COMMAND on
|
|
6
|
+
* connect, and client libraries (BullMQ, connect-redis, health checks) read
|
|
7
|
+
* CONFIG GET at init. CONFIG SET is a lenient stub — it never honors
|
|
8
|
+
* security-sensitive parameters (dir, save, …); values are accepted and
|
|
9
|
+
* dropped so init-time probes succeed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { R, type Reply } from "../resp/types";
|
|
13
|
+
import { Errors } from "../engine/errors";
|
|
14
|
+
import { commandCount } from "../dispatcher";
|
|
15
|
+
import type { CommandContext } from "../engine/context";
|
|
16
|
+
|
|
17
|
+
export function type(ctx: CommandContext): Reply {
|
|
18
|
+
ctx.requireExactArgc(1);
|
|
19
|
+
return R.simple(ctx.storage.typeOf(ctx.arg(0), ctx.nowMs) ?? "none");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function dbsize(ctx: CommandContext): Reply {
|
|
23
|
+
ctx.requireExactArgc(0);
|
|
24
|
+
return R.int(ctx.storage.dbsize(ctx.nowMs));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** FLUSHDB/FLUSHALL [ASYNC|SYNC] — single DB, so both clear everything. */
|
|
28
|
+
export function flushall(ctx: CommandContext): Reply {
|
|
29
|
+
if (ctx.argc > 1) return R.error("ERR", "syntax error");
|
|
30
|
+
if (ctx.argc === 1) {
|
|
31
|
+
const mode = ctx.upper(0);
|
|
32
|
+
if (mode !== "ASYNC" && mode !== "SYNC") return R.error("ERR", "syntax error");
|
|
33
|
+
}
|
|
34
|
+
ctx.storage.flushAll();
|
|
35
|
+
return R.ok();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Values reported by CONFIG GET (kept truthful to the actual configuration). */
|
|
39
|
+
function configValues(ctx: CommandContext): Record<string, string> {
|
|
40
|
+
return {
|
|
41
|
+
maxmemory: String(ctx.server.config.maxMemoryBytes),
|
|
42
|
+
"maxmemory-policy": "noeviction",
|
|
43
|
+
appendonly: "no",
|
|
44
|
+
save: "",
|
|
45
|
+
databases: "1",
|
|
46
|
+
"proto-max-bulk-len": String(512 * 1024 * 1024),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function config(ctx: CommandContext): Reply {
|
|
51
|
+
ctx.requireArgc(1);
|
|
52
|
+
const sub = ctx.upper(0);
|
|
53
|
+
switch (sub) {
|
|
54
|
+
case "GET": {
|
|
55
|
+
ctx.requireArgc(2);
|
|
56
|
+
const values = configValues(ctx);
|
|
57
|
+
// Dedupe across overlapping patterns: each parameter appears at most once.
|
|
58
|
+
const matched = new Map<string, string>();
|
|
59
|
+
for (let i = 1; i < ctx.argc; i++) {
|
|
60
|
+
const pattern = ctx.str(i).toLowerCase();
|
|
61
|
+
for (const [name, value] of Object.entries(values)) {
|
|
62
|
+
if (globMatch(pattern, name)) matched.set(name, value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return R.map([...matched].map(([name, value]) => [R.bulk(name), R.bulk(value)]));
|
|
66
|
+
}
|
|
67
|
+
case "SET":
|
|
68
|
+
// Lenient stub: accepted, never applied. Still validate arity like Redis
|
|
69
|
+
// (at least one name/value pair → an even number of trailing tokens).
|
|
70
|
+
if (ctx.argc < 3 || ctx.argc % 2 === 0) throw Errors.wrongArgs("config|set");
|
|
71
|
+
return R.ok();
|
|
72
|
+
case "RESETSTAT":
|
|
73
|
+
case "REWRITE":
|
|
74
|
+
return R.ok();
|
|
75
|
+
default:
|
|
76
|
+
return R.error("ERR", `Unknown CONFIG subcommand or wrong number of arguments for '${ctx.str(0)}'`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function command(ctx: CommandContext): Reply {
|
|
81
|
+
if (ctx.argc === 0) return R.array([]); // full introspection unsupported; empty is accepted by clients
|
|
82
|
+
switch (ctx.upper(0)) {
|
|
83
|
+
case "COUNT":
|
|
84
|
+
return R.int(commandCount());
|
|
85
|
+
case "DOCS":
|
|
86
|
+
return R.map([]);
|
|
87
|
+
case "INFO":
|
|
88
|
+
// One reply element per requested command name (nil = no details), so
|
|
89
|
+
// positional consumers that map request→reply slots stay aligned.
|
|
90
|
+
return R.array(Array.from({ length: Math.max(0, ctx.argc - 1) }, () => R.nullReply()));
|
|
91
|
+
case "GETKEYS":
|
|
92
|
+
case "GETKEYSANDFLAGS":
|
|
93
|
+
return R.error("ERR", "The command has no key arguments");
|
|
94
|
+
default:
|
|
95
|
+
return R.error(
|
|
96
|
+
"ERR",
|
|
97
|
+
`Unknown subcommand or wrong number of arguments for '${ctx.str(0)}'. Try COMMAND HELP.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Minimal glob: `*`, `?` (enough for CONFIG GET patterns). */
|
|
103
|
+
function globMatch(pattern: string, s: string): boolean {
|
|
104
|
+
const re = new RegExp(
|
|
105
|
+
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
|
|
106
|
+
);
|
|
107
|
+
return re.test(s);
|
|
108
|
+
}
|
package/src/commands/expire.ts
CHANGED
|
@@ -1,32 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Expiry commands (Phase 0): EXPIRE, PEXPIRE, EXPIREAT, PEXPIREAT, TTL, PTTL,
|
|
3
3
|
* PERSIST. TTL/PTTL return -2 (missing), -1 (no expiry), or remaining time.
|
|
4
|
+
*
|
|
5
|
+
* EXPIRE-family commands accept one optional NX|XX|GT|LT flag (Redis ≥ 7.0):
|
|
6
|
+
* NX = only when no TTL exists, XX = only when one exists, GT/LT = only when
|
|
7
|
+
* the new expiry is later/earlier than the current one (a key without TTL
|
|
8
|
+
* counts as infinitely late, so GT never applies and LT always does).
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
11
|
import { R, type Reply } from "../resp/types";
|
|
12
|
+
import { Errors } from "../engine/errors";
|
|
7
13
|
import type { CommandContext } from "../engine/context";
|
|
8
14
|
|
|
9
15
|
export function expire(ctx: CommandContext): Reply {
|
|
10
16
|
ctx.requireArgc(2);
|
|
11
|
-
|
|
12
|
-
return R.int(ctx.storage.expireSet(ctx.arg(0), atMs, ctx.nowMs) ? 1 : 0);
|
|
17
|
+
return expireGeneric(ctx, ctx.nowMs + Number(ctx.int(1)) * 1000);
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
export function pexpire(ctx: CommandContext): Reply {
|
|
16
21
|
ctx.requireArgc(2);
|
|
17
|
-
|
|
18
|
-
return R.int(ctx.storage.expireSet(ctx.arg(0), atMs, ctx.nowMs) ? 1 : 0);
|
|
22
|
+
return expireGeneric(ctx, ctx.nowMs + Number(ctx.int(1)));
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export function expireat(ctx: CommandContext): Reply {
|
|
22
26
|
ctx.requireArgc(2);
|
|
23
|
-
|
|
24
|
-
return R.int(ctx.storage.expireSet(ctx.arg(0), atMs, ctx.nowMs) ? 1 : 0);
|
|
27
|
+
return expireGeneric(ctx, Number(ctx.int(1)) * 1000);
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export function pexpireat(ctx: CommandContext): Reply {
|
|
28
31
|
ctx.requireArgc(2);
|
|
29
|
-
|
|
32
|
+
return expireGeneric(ctx, Number(ctx.int(1)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function expireGeneric(ctx: CommandContext, atMs: number): Reply {
|
|
36
|
+
const flags: string[] = [];
|
|
37
|
+
for (let i = 2; i < ctx.argc; i++) {
|
|
38
|
+
const f = ctx.upper(i);
|
|
39
|
+
if (f !== "NX" && f !== "XX" && f !== "GT" && f !== "LT") throw Errors.syntax();
|
|
40
|
+
flags.push(f);
|
|
41
|
+
}
|
|
42
|
+
if (flags.includes("NX") && flags.length > 1) {
|
|
43
|
+
return R.error("ERR", "NX and XX, GT or LT options at the same time are not compatible");
|
|
44
|
+
}
|
|
45
|
+
if (flags.includes("GT") && flags.includes("LT")) {
|
|
46
|
+
return R.error("ERR", "GT and LT options at the same time are not compatible");
|
|
47
|
+
}
|
|
48
|
+
const flag = (flags[0] ?? null) as "NX" | "XX" | "GT" | "LT" | null;
|
|
49
|
+
if (flag !== null) {
|
|
50
|
+
const cur = ctx.storage.pttl(ctx.arg(0), ctx.nowMs);
|
|
51
|
+
if (cur === -2) return R.int(0); // missing key: no flag can apply
|
|
52
|
+
const hasTtl = cur >= 0;
|
|
53
|
+
const curAtMs = hasTtl ? ctx.nowMs + cur : Infinity; // no TTL = infinitely late
|
|
54
|
+
if (flag === "NX" && hasTtl) return R.int(0);
|
|
55
|
+
if (flag === "XX" && !hasTtl) return R.int(0);
|
|
56
|
+
if (flag === "GT" && atMs <= curAtMs) return R.int(0);
|
|
57
|
+
if (flag === "LT" && atMs >= curAtMs) return R.int(0);
|
|
58
|
+
}
|
|
30
59
|
return R.int(ctx.storage.expireSet(ctx.arg(0), atMs, ctx.nowMs) ? 1 : 0);
|
|
31
60
|
}
|
|
32
61
|
|
|
@@ -59,8 +59,12 @@ export function ping(ctx: CommandContext): Reply {
|
|
|
59
59
|
return R.simple("PONG");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
export function select(
|
|
63
|
-
// Single logical DB
|
|
62
|
+
export function select(ctx: CommandContext): Reply {
|
|
63
|
+
// Single logical DB (CLAUDE.md scope): index 0 is accepted, anything else is
|
|
64
|
+
// an honest error — silently mapping /1 onto /0's data would clobber it.
|
|
65
|
+
ctx.requireExactArgc(1);
|
|
66
|
+
const idx = Number(ctx.int(0));
|
|
67
|
+
if (idx !== 0) return R.error("ERR", "DB index is out of range");
|
|
64
68
|
return R.ok();
|
|
65
69
|
}
|
|
66
70
|
|
|
@@ -86,10 +90,29 @@ export function info(ctx: CommandContext): Reply {
|
|
|
86
90
|
"# Clients",
|
|
87
91
|
"connected_clients:1",
|
|
88
92
|
"",
|
|
93
|
+
"# Memory",
|
|
94
|
+
`used_memory:${process.memoryUsage().rss}`,
|
|
95
|
+
`maxmemory:${ctx.server.config.maxMemoryBytes}`,
|
|
96
|
+
"maxmemory_policy:noeviction", // data lives in SQLite; only caches evict
|
|
97
|
+
"",
|
|
89
98
|
"# Keyspace",
|
|
90
99
|
`db0:keys=${ctx.storage.dbsize(ctx.nowMs)},expires=0,avg_ttl=0`,
|
|
91
100
|
"",
|
|
92
101
|
];
|
|
102
|
+
const stats = (ctx.storage as { stats?: () => Record<string, number> }).stats?.();
|
|
103
|
+
if (stats) {
|
|
104
|
+
lines.push(
|
|
105
|
+
"# Cache",
|
|
106
|
+
`cache_entries:${stats.entries}`,
|
|
107
|
+
`cache_bytes:${stats.bytes}`,
|
|
108
|
+
`cache_max_bytes:${stats.maxBytes}`,
|
|
109
|
+
`cache_hits:${stats.hits}`,
|
|
110
|
+
`cache_misses:${stats.misses}`,
|
|
111
|
+
`cache_evicted_idle:${stats.evictedIdle}`,
|
|
112
|
+
`cache_evicted_lru:${stats.evictedLru}`,
|
|
113
|
+
"",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
93
116
|
return R.verbatim("txt", lines.join("\r\n"));
|
|
94
117
|
}
|
|
95
118
|
|
package/src/commands/multikey.ts
CHANGED
|
@@ -44,15 +44,19 @@ export function msetnx(ctx: CommandContext): Reply {
|
|
|
44
44
|
|
|
45
45
|
export function setex(ctx: CommandContext): Reply {
|
|
46
46
|
ctx.requireExactArgc(3);
|
|
47
|
-
const
|
|
48
|
-
|
|
47
|
+
const seconds = Number(ctx.int(1));
|
|
48
|
+
if (seconds <= 0) throw Errors.invalidExpire("setex"); // Redis semantics
|
|
49
|
+
ctx.storage.kvSet(ctx.arg(0), ctx.arg(2), ctx.nowMs, {
|
|
50
|
+
expireAtMs: ctx.nowMs + seconds * 1000,
|
|
51
|
+
});
|
|
49
52
|
return R.ok();
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
export function psetex(ctx: CommandContext): Reply {
|
|
53
56
|
ctx.requireExactArgc(3);
|
|
54
|
-
const
|
|
55
|
-
|
|
57
|
+
const ms = Number(ctx.int(1));
|
|
58
|
+
if (ms <= 0) throw Errors.invalidExpire("psetex"); // Redis semantics
|
|
59
|
+
ctx.storage.kvSet(ctx.arg(0), ctx.arg(2), ctx.nowMs, { expireAtMs: ctx.nowMs + ms });
|
|
56
60
|
return R.ok();
|
|
57
61
|
}
|
|
58
62
|
|
package/src/commands/pubsub.ts
CHANGED
|
@@ -80,8 +80,12 @@ export function pubsub(ctx: CommandContext): Reply {
|
|
|
80
80
|
ctx.requireArgc(1);
|
|
81
81
|
const sub = ctx.upper(0);
|
|
82
82
|
switch (sub) {
|
|
83
|
-
case "CHANNELS":
|
|
84
|
-
|
|
83
|
+
case "CHANNELS": {
|
|
84
|
+
const names = ctx.server.hub.channelNames();
|
|
85
|
+
const filtered =
|
|
86
|
+
ctx.argc >= 2 ? names.filter((c) => globMatch(ctx.str(1), c)) : names;
|
|
87
|
+
return R.array(filtered.map((c) => R.bulk(c)));
|
|
88
|
+
}
|
|
85
89
|
case "NUMSUB": {
|
|
86
90
|
const out: Reply[] = [];
|
|
87
91
|
for (let i = 1; i < ctx.argc; i++) {
|
|
@@ -90,11 +94,30 @@ export function pubsub(ctx: CommandContext): Reply {
|
|
|
90
94
|
}
|
|
91
95
|
return R.array(out);
|
|
92
96
|
}
|
|
97
|
+
case "NUMPAT":
|
|
98
|
+
if (ctx.argc !== 1) {
|
|
99
|
+
return R.error(
|
|
100
|
+
"ERR",
|
|
101
|
+
`Unknown PUBSUB subcommand or wrong number of arguments for '${ctx.str(0)}'`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return R.int(ctx.server.hub.numPat());
|
|
93
105
|
default:
|
|
94
|
-
return R.
|
|
106
|
+
return R.error(
|
|
107
|
+
"ERR",
|
|
108
|
+
`Unknown PUBSUB subcommand or wrong number of arguments for '${ctx.str(0)}'`,
|
|
109
|
+
);
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
112
|
|
|
113
|
+
/** Minimal glob matcher (`*`, `?`) for PUBSUB CHANNELS [pattern]. */
|
|
114
|
+
function globMatch(pattern: string, s: string): boolean {
|
|
115
|
+
const re = new RegExp(
|
|
116
|
+
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
|
|
117
|
+
);
|
|
118
|
+
return re.test(s);
|
|
119
|
+
}
|
|
120
|
+
|
|
98
121
|
function rangeArgs(ctx: CommandContext, from: number): string[] {
|
|
99
122
|
const out: string[] = [];
|
|
100
123
|
for (let i = from; i < ctx.argc; i++) out.push(ctx.str(i));
|
package/src/commands/set.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { R, type Reply } from "../resp/types";
|
|
10
|
+
import { Errors } from "../engine/errors";
|
|
10
11
|
import type { CommandContext } from "../engine/context";
|
|
11
12
|
|
|
12
13
|
export function sadd(ctx: CommandContext): Reply {
|
|
@@ -36,6 +37,7 @@ export function scard(ctx: CommandContext): Reply {
|
|
|
36
37
|
|
|
37
38
|
export function srandmember(ctx: CommandContext): Reply {
|
|
38
39
|
ctx.requireArgc(1);
|
|
40
|
+
if (ctx.argc > 2) throw Errors.syntax();
|
|
39
41
|
const hasCount = ctx.argOpt(1) !== undefined;
|
|
40
42
|
const count = hasCount ? Number(ctx.int(1)) : null;
|
|
41
43
|
const res = ctx.storage.sRandMember(ctx.arg(0), count, ctx.nowMs);
|
|
@@ -48,11 +50,15 @@ export function srandmember(ctx: CommandContext): Reply {
|
|
|
48
50
|
|
|
49
51
|
export function spop(ctx: CommandContext): Reply {
|
|
50
52
|
ctx.requireArgc(1);
|
|
53
|
+
if (ctx.argc > 2) throw Errors.syntax();
|
|
51
54
|
const hasCount = ctx.argOpt(1) !== undefined;
|
|
52
55
|
const count = hasCount ? Number(ctx.int(1)) : null;
|
|
56
|
+
if (count !== null && count < 0) {
|
|
57
|
+
return R.error("ERR", "value is out of range, must be positive");
|
|
58
|
+
}
|
|
53
59
|
const res = ctx.storage.sPop(ctx.arg(0), count, ctx.nowMs);
|
|
54
60
|
if (count === null) {
|
|
55
61
|
return res === null ? R.nullReply() : R.bulk(res as Uint8Array);
|
|
56
62
|
}
|
|
57
|
-
return R.
|
|
63
|
+
return R.array((res as Uint8Array[]).map((m) => R.bulk(m)));
|
|
58
64
|
}
|
package/src/commands/string.ts
CHANGED
|
@@ -24,14 +24,20 @@ export function set(ctx: CommandContext): Reply {
|
|
|
24
24
|
while (ctx.argOpt(i) !== undefined) {
|
|
25
25
|
const o = ctx.upper(i);
|
|
26
26
|
switch (o) {
|
|
27
|
-
case "EX":
|
|
28
|
-
|
|
27
|
+
case "EX": {
|
|
28
|
+
const sec = Number(ctx.int(i + 1));
|
|
29
|
+
if (sec <= 0) throw Errors.invalidExpire("set"); // Redis semantics
|
|
30
|
+
opts.expireAtMs = ctx.nowMs + sec * 1000;
|
|
29
31
|
i += 2;
|
|
30
32
|
break;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
}
|
|
34
|
+
case "PX": {
|
|
35
|
+
const ms = Number(ctx.int(i + 1));
|
|
36
|
+
if (ms <= 0) throw Errors.invalidExpire("set");
|
|
37
|
+
opts.expireAtMs = ctx.nowMs + ms;
|
|
33
38
|
i += 2;
|
|
34
39
|
break;
|
|
40
|
+
}
|
|
35
41
|
case "EXAT":
|
|
36
42
|
opts.expireAtMs = Number(ctx.int(i + 1)) * 1000;
|
|
37
43
|
i += 2;
|
|
@@ -61,13 +67,20 @@ export function set(ctx: CommandContext): Reply {
|
|
|
61
67
|
}
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
if (wantGet) {
|
|
71
|
+
// GET option returns the old value whether or not the write happened; it
|
|
72
|
+
// needs read-then-write atomicity, so the cache fill is suppressed inside
|
|
73
|
+
// this transaction (correct — a rollback must not cache a phantom).
|
|
74
|
+
let old: Uint8Array | null = null;
|
|
75
|
+
ctx.storage.withTransaction(() => {
|
|
76
|
+
old = ctx.storage.kvGet(key, ctx.nowMs);
|
|
77
|
+
ctx.storage.kvSet(key, value, ctx.nowMs, opts);
|
|
78
|
+
});
|
|
79
|
+
return R.bulk(old);
|
|
80
|
+
}
|
|
81
|
+
// Plain SET: kvSet is already internally transactional, so we DON'T wrap it —
|
|
82
|
+
// wrapping would set #inTxn and suppress the hot-cache write-through fill.
|
|
83
|
+
const result = ctx.storage.kvSet(key, value, ctx.nowMs, opts);
|
|
71
84
|
return result === "set" ? R.ok() : R.nullReply();
|
|
72
85
|
}
|
|
73
86
|
|
|
@@ -12,7 +12,7 @@ import { R, type Reply } from "../resp/types";
|
|
|
12
12
|
import { Errors } from "../engine/errors";
|
|
13
13
|
import { executeCore } from "../dispatcher";
|
|
14
14
|
import type { CommandContext } from "../engine/context";
|
|
15
|
-
import { hashKey, type WatchSnapshot } from "../sidecar/watch";
|
|
15
|
+
import { hashKey, releaseSnapshot, type WatchSnapshot } from "../sidecar/watch";
|
|
16
16
|
|
|
17
17
|
export function multi(ctx: CommandContext): Reply {
|
|
18
18
|
if (ctx.conn.txn) return R.error("ERR", "MULTI calls can not be nested");
|
|
@@ -23,7 +23,7 @@ export function multi(ctx: CommandContext): Reply {
|
|
|
23
23
|
export function discard(ctx: CommandContext): Reply {
|
|
24
24
|
if (!ctx.conn.txn) throw Errors.discardWithoutMulti();
|
|
25
25
|
ctx.conn.txn = null;
|
|
26
|
-
ctx
|
|
26
|
+
clearWatch(ctx);
|
|
27
27
|
return R.ok();
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -34,14 +34,14 @@ export function exec(ctx: CommandContext): Reply {
|
|
|
34
34
|
conn.txn = null;
|
|
35
35
|
|
|
36
36
|
if (txn.error) {
|
|
37
|
-
|
|
37
|
+
clearWatch(ctx);
|
|
38
38
|
throw Errors.execAbort();
|
|
39
39
|
}
|
|
40
40
|
if (conn.watch && isWatchDirty(conn.watch, ctx)) {
|
|
41
|
-
|
|
41
|
+
clearWatch(ctx);
|
|
42
42
|
return R.nullReply(); // a watched key changed → abort
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
clearWatch(ctx);
|
|
45
45
|
|
|
46
46
|
const results = ctx.storage.withTransaction(() =>
|
|
47
47
|
txn.queued.map((cmd) => {
|
|
@@ -58,28 +58,38 @@ export function watch(ctx: CommandContext): Reply {
|
|
|
58
58
|
const snap: WatchSnapshot = ctx.conn.watch ?? new Map();
|
|
59
59
|
for (let i = 0; i < ctx.argc; i++) {
|
|
60
60
|
const key = ctx.arg(i);
|
|
61
|
-
|
|
61
|
+
const k = hashKey(key);
|
|
62
|
+
if (snap.has(k)) continue; // already watched: keep the earlier snapshot
|
|
63
|
+
snap.set(k, { key, version: ctx.server.watch.acquire(key) });
|
|
62
64
|
}
|
|
63
65
|
ctx.conn.watch = snap;
|
|
64
66
|
return R.ok();
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
export function unwatch(ctx: CommandContext): Reply {
|
|
68
|
-
ctx
|
|
70
|
+
clearWatch(ctx);
|
|
69
71
|
return R.ok();
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
export function reset(ctx: CommandContext): Reply {
|
|
73
75
|
ctx.conn.txn = null;
|
|
74
|
-
ctx
|
|
76
|
+
clearWatch(ctx);
|
|
75
77
|
ctx.server.hub.drop(ctx.conn);
|
|
76
78
|
ctx.conn.state = "READY";
|
|
77
79
|
return R.simple("RESET");
|
|
78
80
|
}
|
|
79
81
|
|
|
82
|
+
/** Drop the connection's WATCH snapshot, releasing registry interest. */
|
|
83
|
+
function clearWatch(ctx: CommandContext): void {
|
|
84
|
+
if (ctx.conn.watch) {
|
|
85
|
+
releaseSnapshot(ctx.server.watch, ctx.conn.watch);
|
|
86
|
+
ctx.conn.watch = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
function isWatchDirty(snap: WatchSnapshot, ctx: CommandContext): boolean {
|
|
81
91
|
for (const { key, version } of snap.values()) {
|
|
82
|
-
if (ctx.server.watch.
|
|
92
|
+
if (ctx.server.watch.peek(key) !== version) return true;
|
|
83
93
|
}
|
|
84
94
|
return false;
|
|
85
95
|
}
|
package/src/config.ts
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
* Server configuration, resolved from CLI flags and environment.
|
|
3
3
|
*
|
|
4
4
|
* Precedence: CLI flag > env var > default. Kept tiny and dependency-free.
|
|
5
|
-
* --host / REDIS_HOST (default 0.0.0.0)
|
|
5
|
+
* --host / REDIS_HOST (default 127.0.0.1; use 0.0.0.0 to expose)
|
|
6
6
|
* --port / REDIS_PORT (default 6379; 0 = ephemeral, used by tests)
|
|
7
7
|
* --db / REDIS_DB_PATH (default ./data.db; ":memory:" for in-memory)
|
|
8
8
|
* --password / REDIS_PASSWORD (default none → AUTH always succeeds)
|
|
9
|
+
* --max-memory-mb / REDIS_MAX_MEMORY_MB (default 256 — overall budget:
|
|
10
|
+
* 50% SQLite page cache, 25% hot cache unless --cache-mb given)
|
|
11
|
+
* --cache-mb / REDIS_CACHE_MB (default: maxMemory/4; 0 disables hot cache)
|
|
12
|
+
* --cache-idle / REDIS_CACHE_IDLE_SEC (default 300 — hot-cache base TTI)
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
15
|
export interface ServerConfig {
|
|
@@ -15,17 +19,40 @@ export interface ServerConfig {
|
|
|
15
19
|
password: string | null;
|
|
16
20
|
/** Active-expiry sweep interval in ms. */
|
|
17
21
|
reaperIntervalMs: number;
|
|
22
|
+
/** Max simultaneous client connections. */
|
|
23
|
+
maxClients: number;
|
|
24
|
+
/**
|
|
25
|
+
* Overall memory budget for the server in bytes. A budget, not a hard OS
|
|
26
|
+
* limit: it sizes the SQLite page cache (50%) and, when --cache-mb is not
|
|
27
|
+
* given, the hot cache (25%); the rest is headroom for connections/JS heap.
|
|
28
|
+
*/
|
|
29
|
+
maxMemoryBytes: number;
|
|
30
|
+
/** Hot-cache byte ceiling; 0 disables the in-memory cache. */
|
|
31
|
+
cacheMaxBytes: number;
|
|
32
|
+
/** Hot-cache base time-to-idle in ms. */
|
|
33
|
+
cacheIdleMs: number;
|
|
18
34
|
}
|
|
19
35
|
|
|
20
36
|
export function loadConfig(argv: string[] = Bun.argv.slice(2)): ServerConfig {
|
|
21
37
|
const flags = parseFlags(argv);
|
|
22
38
|
const env = Bun.env;
|
|
39
|
+
const MB = 1024 * 1024;
|
|
40
|
+
const maxMemoryMb = int(flags["max-memory-mb"] ?? env.REDIS_MAX_MEMORY_MB, 256);
|
|
41
|
+
// Unless set explicitly, the hot cache takes 25% of the overall budget.
|
|
42
|
+
const cacheMbRaw = flags["cache-mb"] ?? env.REDIS_CACHE_MB;
|
|
43
|
+
const cacheMb = cacheMbRaw !== undefined ? int(cacheMbRaw, 64) : Math.floor(maxMemoryMb / 4);
|
|
23
44
|
return {
|
|
24
|
-
|
|
45
|
+
// Loopback by default: exposing a (possibly password-less) server to the
|
|
46
|
+
// LAN must be an explicit decision (--host 0.0.0.0).
|
|
47
|
+
host: flags.host ?? env.REDIS_HOST ?? "127.0.0.1",
|
|
25
48
|
port: int(flags.port ?? env.REDIS_PORT, 6379),
|
|
26
49
|
dbPath: flags.db ?? env.REDIS_DB_PATH ?? "./data.db",
|
|
27
50
|
password: flags.password ?? env.REDIS_PASSWORD ?? null,
|
|
28
51
|
reaperIntervalMs: int(flags.reaper ?? env.REDIS_REAPER_MS, 100),
|
|
52
|
+
maxClients: int(flags["max-clients"] ?? env.REDIS_MAX_CLIENTS, 10_000),
|
|
53
|
+
maxMemoryBytes: maxMemoryMb * MB,
|
|
54
|
+
cacheMaxBytes: cacheMb * MB,
|
|
55
|
+
cacheIdleMs: int(flags["cache-idle"] ?? env.REDIS_CACHE_IDLE_SEC, 300) * 1000,
|
|
29
56
|
};
|
|
30
57
|
}
|
|
31
58
|
|