- Status: Accepted
- Date: 2026-06-13
- Deciders: Daniel Polo (maintainer)
- Related: ADR-0025 (the
InstrumentedPoolDecorator this extends, and which already intercepts everyalloc/free), ADR-0024 §1 (thegrow_poolslow path the growth counter hooks), ADR-0020 (the compile-time Strategy, contrasted here with the runtime Observer), ADR-0015 (the introspection-accessor patternmemory_pool_growthsfollows; the 192-byte budget the new field fits), ROADMAP §6.2 (this item) ← §6.1 (ADR-0025) → §6.3 (the zero-overhead-when-disabled verification).
Milestone 6.2 adds the Observer pattern: notify registered observers of pool-lifecycle events — exhaustion, growth, and destruction. The constraints from Milestone 6's goal (and the project's architecture) are firm: the C core's hot path must stay zero-overhead, the C ABI stays exception-free, and a plain Pool must pay nothing.
Two questions:
- Where does the Observer live, and how are the three events detected without touching the C hot path?
- How is "growth" observed at all — it happens inside the C
pop_head, invisible above the C boundary?
InstrumentedPool (the M6.1 Decorator) already intercepts every allocation verb and owns the wrapper-level view of the pool. Making it the Observer Subject — adding add_observer(PoolObserver&) and notifying at its existing interception points — reuses that machinery and avoids a separate ObservablePool that could not stack with InstrumentedPool (each would own the move-only Pool). So the Decorator (stats) and the Observer (events) compose in one cohesive observability type. A program that wants neither uses plain Pool and pays nothing.
The Observer is the classic GoF runtime-polymorphic form — a PoolObserver abstract base with virtual void on_pool_event(PoolEvent, const PoolStats&), and observers registered dynamically. This is deliberately unlike the compile-time Strategy (ADR-0020): observers are user-defined and attached at run time, the notifications fire on rare events (not the hot path), so virtual dispatch is the right, idiomatic tool here.
enum class PoolEvent { exhausted, grew, destroyed };
struct PoolObserver { virtual void on_pool_event(PoolEvent, const PoolStats&) = 0; /* + rule-of-five */ };- Exhaustion —
try_allocate()returningnullptrorallocate()throwingstd::bad_alloc(for a fixed pool, or a dynamic pool whose growth allocation failed). Detected at the wrapper; notified after counting the failure. - Destruction —
InstrumentedPool's destructor (now user-provided) notifiesdestroyedonce. A moved-from instance notifies nobody: the move transfers the observer list (leaving the source's list empty), so the moved-from destructor's notify is a no-op — no spurious event. - Growth — detected via a new O(1) core counter (next decision), compared after each successful allocation; a rise means the dynamic pool grew, so
grewis notified.
Growth happens inside the C pop_head (grow_pool, ADR-0024 §1) and is invisible above the C boundary. Detecting it by diffing metadata_bytes per allocation would be O(chunks) per alloc — unacceptable. Instead the core gains a single counter:
struct memory_poolgainsstd::atomic<std::size_t> grow_count_, incremented (relaxed) insidegrow_pool— i.e. on the rare growth slow path only, never on the hot pop. The atomic keeps the wrapper's read data-race-free when observing a thread-safe pool.- a new public accessor
size_t memory_pool_growths(const memory_pool_t*)returns it in O(1) —NULL-tolerant, ANSI C C89-compatible, always present (not diagnostics-gated, since the Observer is usable in any build), the same shape asmemory_pool_metadata_bytes/memory_pool_block_size.
InstrumentedPool reads memory_pool_growths after each successful allocation (an O(1) atomic load) and notifies grew when it rises. The hot path of a plain Pool is untouched — only grow_pool writes the counter, only the opt-in wrapper reads it. The field adds 8 bytes (NONE struct → 64, MUTEX → 144), comfortably inside the ADR-0015 192-byte budget.
Plain Pool is unchanged (no observer, no counter read, no virtual call). When an InstrumentedPool has no observers, notify is an empty-vector check; the per-allocation growth read is a single relaxed atomic load on the opt-in path. Observer registration and notification are not internally synchronized — observers are notified on the calling thread, and concurrent registration or notification needs external synchronization (the standard Observer caveat); register observers before concurrent use and make them thread-safe. The growth counter itself is atomic, so observing a thread-safe pool is data-race-free; the wrapper's last_growths_ watermark is an approximate, eventually-consistent trigger under contention.
- A separate
ObservablePoolwrapper. Rejected — it could not stack withInstrumentedPool(both own the move-onlyPool), and it would duplicate the alloc/free interception points the Decorator already has. Folding Observer intoInstrumentedPoolkeeps one observability type. - Notify directly from the C core (a C callback in
pop_head/grow_pool/destroy). Rejected — it forces a C++ callback across the exception-free C ABI and taxes the hot path with a per-op callback check, violating the §6 zero-overhead goal. Observation belongs above the C boundary. - Detect growth by diffing
metadata_bytesper allocation. Rejected — O(chunks) per alloc. The O(1)grow_count_counter + accessor is the fix (and the counter sits on the slow path). std::functionobservers instead of an interface. Rejected — the GoF interface (PoolObserver) gives clear observer lifetime/ownership (the wrapper stores non-owning pointers), avoids per-observer heap allocation, and is the textbook Observer; astd::functionlist adds allocation and obscures lifetime.- Compile-time (policy) Observer. Rejected — observers are inherently runtime/dynamic, and the events are off the hot path, so runtime virtual dispatch is correct here (unlike the on-hot-path thread-safety Strategy).
- A
growingevent fired from inside the lock-free path. N/A —LOCKFREEpools never grow (ADR-0024 §2), sogrow_count_stays 0 andgrewnever fires there; consistent and needs no special case.
Positive
- Lifecycle observability (exhaustion / growth / destruction) with zero cost to plain
Pooland near-zero cost to an observer-lessInstrumentedPool. - The growth counter is a small, reusable O(1) introspection accessor (
memory_pool_growths) on the slow path; the hot path is untouched. - Decorator (stats) and Observer (events) compose in one type — usable together, no stacking problem.
- Runtime virtual Observer cleanly contrasts the compile-time Strategy, rounding out the pattern catalogue.
Negative
- Observer notification is not internally synchronized — concurrent observable use needs external synchronization / thread-safe observers (documented). The
last_growths_watermark is approximate under contention. struct memory_poolgrows by 8 bytes (the atomic counter), now NONE 64 / LOCKFREE 80 / MUTEX 144 — still within the 192 budget, but the budget continues to tighten.- Mixing two patterns (Decorator + Observer) in
InstrumentedPooltrades single-pattern-per-type purity for a cohesive, stackable observability surface — a deliberate call recorded here.
Required documentation updates landing in the same PR as this ADR
docs/adr/README.md— index row for ADR-0026.docs/patterns/README.md— Observer moves to Adopted (statusImplemented).ROADMAP.md§6.2 →[x]with the inline summary.CHANGELOG.mdUnreleased— Added (M6.2) entry.
instrumented_pool.hpp gains PoolEvent, PoolObserver, and the InstrumentedPool observer surface; memory_pool.h / memory_pool.cpp gain grow_count_ + memory_pool_growths; c_consumer_min.c exercises the accessor; instrumented_pool_test.cpp gains the observer cases. The zero-overhead verification is M6.3.