about things notes.zzstoatzz.io
notes languages ziglang memory per-command-arena.md
6.3 kB · 111 lines
Markdown
at main

per-command response arenas #

written against zig 0.16.

a pattern for "the reply from this protocol command is a tree of slices that all share one lifetime — the next command." classic example: the redis client.

the problem #

a redis reply to XREADGROUP is a 4-level nested array. bulk strings inside reference the client's read buffer (cheap, zero-copy). array backings ([]Value) are necessarily heap-allocated — you don't know the length until you read it.

naively: allocate []Value from client.allocator, return the tree. caller frees with a recursive freeValue. but the bulk leaves still point into read_buf — call freeValue and the inner slices are fine until the NEXT command, at which point read_buf gets reused. two different lifetimes for what looks like one tree. callers can't reason about it. they leak.

the fix #

give the client two storages with the same lifetime:

  • read_buf — for bulk-string content; reused on every command
  • response_arena: std.heap.ArenaAllocator — for array []Value backings; reset on every command
pub fn sendCommand(self: *Self, args: []const []const u8) ClientError!Value {
    _ = self.response_arena.reset(.retain_capacity);
    // ...build + send command...
    return self.readResponse();
}

fn readArray(self: *Self) ClientError!Value {
    // ...
    const values = try self.response_arena.allocator().alloc(Value, ulen);
    for (0..ulen) |i| values[i] = try self.parseValue();
    return .{ .array = values };
}

now every slice anywhere inside the returned Value is valid until the next command, full stop. doc comment says so, callers don't have to traverse anything, freeValue becomes a no-op.

passing reply data to the next command #

"valid until the next command" ends when that command starts, before it encodes its arguments. if sendCommand resets the arena on entry, passing it a slice from the previous reply is a use-after-free: copy the stream ID from XREADGROUP before XACK, and an XAUTOCLAIM cursor before acknowledging. copy the bytes, not the slice.

small replies hide it, because the read buffer they live in stays allocated. a reply that grows the buffer leaves its early fields in an older allocation that the next command frees, so test with replies larger than the initial buffer. the alternative is a client that copies borrowed arguments into its own command storage before invalidating the reply.

extending the arena to typed parsed structs #

after parsing the raw RESP tree, command modules often construct typed result structs (StreamEntry, AutoclaimResult, etc.) that hold inner slices. those structs need the same lifetime as the Value they came from — they're just typed views over its memory.

expose the arena to those modules:

// client.zig
pub fn responseAlloc(self: *Self) Allocator {
    return self.response_arena.allocator();
}

// commands/streams.zig
fn parseEntry(self: *StreamCommands, val: Value) !?StreamEntry {
    var fields = try self.client.responseAlloc().alloc(StreamField, field_count);
    // ...
}

reset still resets all of it. one lifetime, one knob.

what NOT to put in the arena #

local working buffers (arg-vec builders, scratch space) that get freed inside the same function with defer .free(...) — keep those on the heap. the arena exists for things that need to outlive the function call but die at the next command.

pub fn hset(self: *Self, key: []const u8, pairs: ...) !i64 {
    var args = try self.client.allocator.alloc([]const u8, arg_count);  // <- ok
    defer self.client.allocator.free(args);
    // ...
}

an arena per concurrent leg dies before the caller reads it #

a request arena is not shareable across tasks, so a leg that runs on io.concurrent gets its own arena, defer-freed by the function that spawned it. anything the function returns that still points into that arena is a dangling slice — and it will pass a smoke test, because freed memory keeps its bytes until something reuses them. ReleaseSafe does not catch use-after-free; the symptom is a segfault deep in a json encoder's utf-8 validation, on a different request than the one that borrowed.

the rule: results cross an arena boundary by copy, never by borrow. make the contract testable — build rows in a throwaway arena, collect, deinit the row arena, then assert the results are intact and result.name.ptr != row.name.ptr.

comparison: alternatives #

  • caller-supplied arena: pub fn xreadgroup(out_alloc: Allocator, ...). cleaner in some ways (caller controls lifetime), but the client still needs to manage the []Value tree internally during parsing — you end up with both. the redis client's "next command invalidates" contract is more economical.
  • deep-copy on parse: build everything from scratch on the heap, dupe all bulks out. correct, simple, slow. legitimate choice if you don't care about throughput.
  • refcounting (the rust bytes::Bytes approach): each slice is a small refcounted header. works, but the C-ABI surface gets uglier and zig doesn't have a stdlib type for it.

the per-command arena is what fits the "single connection, one outstanding command at a time" model. break that assumption (pipelining, async) and you need a per-request arena instead.

summary #

storage what lifetime
read_buf bulk-string slices until next sendCommand
response_arena []Value, typed parses until next sendCommand
client.alloc local arg-builders bound by defer .free
caller's alloc data the caller dupes out caller-managed

sources #

  • find-bufo server/src/search.zig — collectResults copies keyword-leg rows out of their arena; regression test asserts non-aliasing (2026-08-28)
  • redis — src/client.zig response arenas, src/commands/streams.zig typed views
  • prefect-server src/broker/redis.zig, Redis acknowledgement incident — large-response replay and acknowledgement regression (2026-10-19)