ADR-0017: Buffer taxonomy — fixed head pool, inline small responses, and streaming I/O pools
Status: Accepted (2026-06-09).
Context
Two distinct kinds of RAM buffer were being conflated. They have different lifetimes and sizing, and treating them the same is what makes the current GET allocate (and copy) a whole object.
Decision
- Resident payload buffers — FIXED. Fixed-size large-object heads and packed RAM-only small
objects live in fixed, unswappable data pools (ADR-0008), sized by
config and bounded by S3-FIFO eviction (ADR-0012). They are
long-lived (cached across requests). The classes share blocks by default; explicit
--small-memorygives the packed-small-object class a strict, non-borrowing pool. - I/O buffers — small, separate, fixed-size, reused. A small slab of fixed chunk buffers for
streaming the SSD/HDD portions (GET egress) and SET ingest. Transient: acquired per pipeline
step, released immediately. Not proportional to object size. Size ≈
chunk × pipeline_depth × concurrent_streams. Kept separate from the head pool so transient churn never fragments the head cache. - Inline or pinned head send. If a complete RAM-resident response body is at most 16 KiB, copy
it beside its protocol framing and issue one send. This removes extra completion round trips on
the small-object path. Larger resident slices stay pinned and are sent directly from the head
region, normally with the header through
sendmsg, without an intermediate payload copy.IORING_OP_SEND_ZCremains a possible NIC-copy optimization. Neither head path borrows a disk I/O buffer. - Streaming, not whole-file. GET = inline response or header → pinned head → SSD/HDD in fixed chunks from a per-worker read pool, head-first (ADR-0006), never buffering a large whole value. The default read-ahead depth is four 256 KiB chunks. Reads may complete out of order, but delivery is strictly ordered and one consumer owns a buffer until its callback or socket send completes. SET streams socket→disk through the shared fixed write-staging pool. RAM per transfer is a small number of chunks, independent of object size.
--memorysizes the local fixed-head budget;--sub-memoryoptionally adds fixed-head blocks on every foreign NUMA node.--small-memoryopts into a dedicated local packed-small-object budget, with--small-sub-memoryoptionally adding the same-sized role on each foreign node. The two resident pools do not borrow; without--small-memory, both classes retain the legacy shared behavior. Read I/O pools are separately bounded by--io-chunk × --io-buffersper worker; the shared write-staging pool is bounded by--write-io-chunk × --io-buffers. On Linux all fixed pools first attempt explicit HugeTLB backing at the platform page order;--blockis the common logical allocation unit and the fixed-head promotion unit, and may span multiple huge pages. Every resident budget is a whole--blockmultiple. Ordinary fallback memory ismlock‘d unless--no-mlockis selected (ADR-0016). Chunk and head geometry and split-pool isolation are unchanged by the backing choice. Packed-small-object blocks are not NUMA-promoted.
Consequences
- ➕ RAM per request is O(chunks), not O(object size); larger heads avoid an intermediate userspace payload copy, while small responses avoid extra sends; the pools have clean, independent lifetimes.
- ➖ The inline threshold trades one small copy for fewer network completions. Larger transfers still require pin lifetime tracking and a streaming state machine.
Implementation status (2026-07-15)
Done: the fixed head pool, per-worker read IoBufferPools, one shared write-staging pool,
streaming GET, streaming SET, the ≤16 KiB inline fast path, and pinned sendmsg for larger
heads. The embedded library exposes the same ordered block stream as serial (pointer, length)
callbacks while the server releases each lane on socket-send completion. IORING_OP_SEND_ZC and
registered io_uring buffers remain possible measured follow-ups.
