This file provides guidance to Claude Code when working in this repository.
zigkv — an in-memory, Redis-compatible key-value store written in pure Zig. Zero dependencies, single statically-linked binary, RESP2 protocol compatible (works with redis-cli and redis-benchmark).
Key facts:
- Requires Zig >= 0.15.2 (declared in
build.zig.zon). - Linux-only: the server is built on
epoll(src/server.zig). It will not compile or run on Windows/macOS. - No external packages —
.dependencies = .{}inbuild.zig.zon.
zig build # debug build → ./zig-out/bin/zigkv
zig build -Doptimize=ReleaseFast # release build (use for server/perf work)
zig build run -- server 6379 # build and run server on a port
zig build run -- ping # send a PING to 127.0.0.1:6379
zig build test # compile + run unit tests
./bench.sh [requests] [connections] # benchmark zigkv vs real Redis (needs redis-server + redis-benchmark)CLI usage of the built binary: zigkv server [port] (default port 6379) and zigkv ping.
All code lives in src/. Import graph: main.zig → server.zig → (store.zig, resp.zig, commands.zig). src/root.zig re-exports the same modules as an embeddable zigkv module (b.addModule("zigkv", ...) in build.zig) for in-process use (no socket/Redis needed).
| File | Role |
|---|---|
src/main.zig |
Entry point. Parses args, picks the allocator, dispatches server / ping. |
src/server.zig |
Single-threaded, non-blocking epoll (edge-triggered) event loop. Owns connections, does read → parse → execute → flush; reads drain to EAGAIN every wakeup. |
src/resp.zig |
RESP2 protocol parser. Non-allocating scan-then-parse, supports pipelining. |
src/commands.zig |
Command dispatch: parses the ParsedCommand, mutates Store, returns an owned RESP response. |
src/store.zig |
In-memory data structures: StringHashMap-backed strings (each entry is one allocation holding key+value), lists, hashes, sets. |
- Allocator choice (
main.zig): debug builds useGeneralPurposeAllocator(slow safety bookkeeping, catches leaks); release builds usestd.heap.smp_allocator. Don't change this split casually. - Per-connection arena (
server.zig): eachConnectionhas anArenaAllocatorthat is reset after every fully-drained batch of pipelined commands. Per-request allocations (parsed args, plus owned buffers from LPOP/HGET/HGETALL/LRANGE/SMEMBERS/GETSET) live in the arena and must not outlive the reset. Since the speed pass,commands.executeappends its RESP reply directly into the connection's long-livedwrite_buf(grown only with the connection allocator) and returnsvoid— no arena response slice, noappendSlicecopy.handleReadablerecordswrite_buf.items.lenbefore each command and rolls back to it on error, appending-ERR command error. - RESP parser (
resp.zig): two-phase design —scanCompletefinds the byte length of one complete command without allocating (safe to call repeatedly on a growing non-blocking read buffer);parseCompletethen allocates args.tryParsereturnsconsumed, the number of bytes belonging to that command — the caller must shift/drop exactly those bytes offread_buf(see thecopyForwards/shrinkRetainingCapacityinserver.zig). Never assume onerecv()== one full command. - epoll is edge-triggered (
server.zig): EPOLLET cuts kernel wakeups, but every wakeup must drain the socket to EAGAIN (the read loop has no early-out) or the next edge is lost. - Command dispatch (
commands.zig): a first-byteswitchon the lowercased command name narrows to a group, then the existingstd.ascii.eqlIgnoreCase(cmd, "...")+ arity checks, ordered with hot commands (SET, GET, INCR, PING) first. Replies go through the file-scopeResphelper (stack-bufferstd.fmt.bufPrint, zero heap allocations per reply) appended straight into the caller's write buffer; the entry point isexecute(scratch_alloc, buf_alloc, out, store, parsed) !void. Add new commands by following the pattern inside the matching group. Command names are matched case-insensitively; args are not. - TTL is stored per-key as
?i64expiry (ms precision);EXPIRE/TTL/PERSIST/SET ... EX Nare implemented. TTL is applied lazily on access, not via a background sweeper. - Response format: simple strings (
+OK\r\n), bulk strings ($<len>\r\n...\r\n), integers (:<n>\r\n), errors (-ERR ...\r\n), arrays (*<n>\r\n).
String: SET (with EX TTL), MSET, GET, MGET, GETSET, APPEND, EXISTS, INCR, INCRBY, DECR, DECRBY, DEL, EXPIRE, TTL, PERSIST, DBSIZE, TYPE, FLUSHALL, CONFIG, PING
List: LPUSH, RPUSH, LPOP, RPOP, LLEN, LRANGE
Hash: HSET, HGET, HGETALL, HDEL, HEXISTS
Set: SADD, SREM, SISMEMBER, SCARD, SMEMBERS
The README advertises features that are not implemented in the current source. Don't assume they exist:
- Batch mode (
zigkv batch, NDJSON input) — NOT implemented;main.zigonly handlesserverandping. - Persistence / snapshots (
zigkv save dump.json/zigkv load dump.json) — NOT implemented. No JSON read/write code exists (only thePERSISTcommand, which is unrelated). - Built-in benchmark (
zigkv bench) — NOT implemented; benchmarking is done via the shell scriptbench.sh. - README's benchmark table numbers are real measured runs but hardware-dependent/noisy (see README note); re-run
bench.shrather than trusting them.
zig build test compiles and runs tests declared in src/. Currently no test "..." blocks exist in the codebase — the test build step succeeds but runs nothing. When adding tests, use the standard test block syntax and std.testing (e.g. std.testing.allocator, std.testing.expectEqualStrings). The resp.zig parser and store.zig operations are the most natural candidates for unit tests.
- Match the existing conventions: snake_case names, structs with explicit field types,
std.*imports grouped at top, doc comments on non-obvious public functions (//!module docs,///for functions). - The code favors dense explanatory comments — keep them when editing.
- No formatting step configured; the code follows zigfmt by hand. Run
zig fmton any file you touch.