- Status: Accepted
- Date: 2026-06-13
- Deciders: Daniel Polo (maintainer)
- Related: ADR-0022 (the growth policy and chunk-linking strategy this ADR realises as a data structure), ADR-0009 §1/§6 (the implicit free list now shared across chunks, and the struct field list this extends), ADR-0012 (the range check that now walks the chunk list), ADR-0015 (the per-pool budget the new
overflow_head pressures, and the per-block-zero guarantee preserved), ADR-0020 (the MUTEX policy whose embeddedstd::mutexmakes the budget tight), ROADMAP §5.2 (this item) ← §5.1 (ADR-0022) → §5.3 (the growth slow path that populates the list).
ADR-0022 settled the dynamic-growth policy: geometric sizing, an append-only forward-linked list of chunks, one shared implicit free list. Milestone 5.2 introduces the Composite data structure that policy needs — before the growth trigger and configuration surface (M5.3) — so the representation lands behavior-preserving and reviewable on its own. With no growth trigger yet, a pool in M5.2 always has exactly one chunk, and every operation must behave bit-for-bit as v0.4.0.
The design question is how to represent "the original pool is the first chunk, plus a list of overflow chunks" concretely, given two constraints measured on the existing code: struct memory_pool is 40 / 56 / 120 bytes under NONE / LOCKFREE / MUTEX, and the ADR-0015 budget is 128 bytes — so the MUTEX variant has only 8 bytes of headroom.
struct memory_pool keeps its ADR-0009 §6 fields (backing_, head_, block_size_, block_count_, alignment_) — these are the pool's first chunk — and gains a single Chunk* overflow_ head. Overflow chunks are separate descriptor nodes:
struct Chunk {
void* backing_;
std::size_t block_count_;
Chunk* next_;
};The Composite is: the memory_pool root composes itself (the inline first chunk) with a forward-linked list of Chunk leaves. overflow_ is nullptr in fixed mode — the only mode until M5.3 — so the pool is exactly one chunk and the list machinery is dormant.
Why inline-first-chunk rather than a uniform list (where even the first chunk is a separate Chunk and the struct holds only a Chunk* chunks_ head): the uniform form would move backing_/block_count_ out of the struct (changing ADR-0009 §6) and, more importantly, would make memory_pool_metadata_bytes either under-report (the struct shrinks 8 bytes while a 24-byte descriptor it doesn't count appears) or, if it counts the descriptor, exceed the 128-byte budget under MUTEX (112 + 24 = 136). The inline-first-chunk keeps backing_/block_count_ in the struct, so metadata_bytes stays honest and simple, and adds only an 8-byte pointer — measured to land the MUTEX struct at exactly 128 (≤ budget), so M5.2 needs no budget renegotiation. (M5.3's growth-config fields will need one — ADR-0015 §4 — as ADR-0022 anticipated.)
The head_ and the in-slot next-links form a single logical free list whose links may point into any chunk. So alloc (pop head) and the free push stay O(1) regardless of chunk count. Only two operations gain a chunk walk:
memory_pool_free's foreign-pointer check (ADR-0012):is_block_in_rangeprobes the inline first chunk, then each overflow chunk, via a sharedblock_in_chunkhelper. O(chunks) = O(log N) in dynamic mode; O(1) in fixed mode (overflow_null → first chunk only).memory_pool_destroy: walks the overflow list, releasing each chunk's backing and descriptor, then the first backing. O(chunks).
Each overflow Chunk is a small separate allocation (new Chunk / delete). memory_pool_metadata_bytes returns sizeof(memory_pool) + (overflow chunk count) × sizeof(Chunk) — honest, O(chunks). In fixed mode the sum is zero, so it returns exactly sizeof(memory_pool), unchanged from v0.4.0, and the M2.10 invariants (≤ budget; O(1) in block_count; equal for a 1024- vs 1,000,000-block pool) all still hold. The compile-time static_assert continues to gate the fixed part (sizeof(memory_pool) <= 128). Per-block overhead remains zero — descriptors are per-chunk, never per-block.
With overflow_ always null this milestone, create / alloc / free / destroy / the range check / metadata_bytes all reduce to their v0.4.0 single-backing paths. The full test suite (including the M4 concurrent stress under MUTEX/LOCKFREE) passes unchanged — that is the regression gate. M5.3 adds the only new behavior: on exhaustion, allocate a Chunk, initialise its slots into the shared free list, and link it.
- Uniform chunk list (first chunk also a separate
Chunk; struct holds only a list head). Rejected — purer Composite, but it changes ADR-0009 §6, and forcesmetadata_bytesto either under-report or bust the MUTEX budget (§1). The inline-first-chunk sidesteps both for the price of slightly non-uniform iteration (the first chunk is special-cased in two helpers). - Inline per-chunk descriptor (store the
Chunkheader at the start of each overflow chunk's backing). Considered (ADR-0022 flagged it). Rejected for M5.2 — it saves the small separate allocations but complicates block alignment and the per-chunk block-count math (the header steals slot space), and the O(log N) descriptor count makes the separate-allocation overhead negligible anyway. - Per-chunk free lists (each chunk keeps its own list). Rejected —
allocwould then have to find a non-empty chunk (O(chunks) or extra bookkeeping), losing the O(1) pop. One shared list keepsallocO(1); the cost lands only on the rarerfree-validation anddestroy. - Raise the ADR-0015 budget now (to e.g. 192) and use the uniform list. Rejected for M5.2 — unnecessary churn when the inline form fits at 128. The renegotiation is deferred to M5.3, where the growth-config fields genuinely force it.
Positive
- The representation the dynamic-growth slow path needs is in place and reviewed in isolation, with zero behavior change (the regression gate is the existing suite).
allocand thefreepush stay O(1) across any number of chunks (shared free list); only thefreevalidation anddestroyscale with the small chunk count.metadata_bytesstays honest and the M2.10 invariants hold; per-block overhead stays zero (ADR-0015).- ADR-0009 §6's struct fields are preserved; the change is one added pointer.
Negative
- Two helpers special-case the inline first chunk before walking
overflow_— slightly less uniform than a pure list. Documented; the trade is honesty + budget fit. - The MUTEX struct is now at exactly the 128-byte budget — zero headroom. M5.3's growth-config fields will exceed it and must renegotiate per ADR-0015 §4.
memory_pool_free's safety check is no longer strictly O(1) in dynamic mode (O(log N)); fixed mode is unaffected (ADR-0022 §3 trade-off, realised here).
Required documentation updates landing in the same PR as this ADR
docs/adr/README.md— index row for ADR-0023.docs/patterns/README.md— Composite moves to Adopted (statusImplemented).ROADMAP.md§5.2 →[x]with the inline summary.CHANGELOG.mdUnreleased— Added (M5.2) entry.
memory_pool.cpp lands struct Chunk, the overflow_ head, the block_in_chunk / chunk-walking is_block_in_range, and the chunk-walking destroy / metadata_bytes. No header, test, or build-config change — behavior is byte-identical, so the existing CTest suite is the gate.
- E. Gamma et al., Design Patterns — Composite (compose objects into tree/list structures so clients treat individual and composed objects uniformly); here the pool composes its inline first chunk with a list of overflow chunk leaves under one shared free list.
- ADR-0022 §3 — the shared-free-list, append-only, O(log N)-chunk strategy this ADR represents.
- ADR-0015 — the per-pool budget (now at 128 under MUTEX) and the per-block-zero guarantee (preserved).