ADR-0006: Cache-padded SPSC event fabric¶
Status: Proposed Date: 2026-07-13 Issue/PR: pending
Context¶
The current telemetry path uses Tokio tasks and bounded MPSC channels before materializing events into SQL-oriented storage. The target data plane assigns mutable hot state to one pinned thread and transfers telemetry from each ingress reactor to one NUMA-local writer. That topology is single-producer, single-consumer by construction; a generic MPSC queue adds producer coordination, scheduler wakeups, and cache-line traffic that the edge does not need.
The ring also becomes an unsafe ownership boundary. Publishing a cursor before the payload is initialized, reusing a slot before the consumer finishes, or dropping an initialized slot twice would be memory-unsafe. A fast prototype is therefore acceptable only with an explicit ownership and memory-ordering contract plus model, interpreter, stress, and layout tests.
This ADR permits a non-authoritative aegis-event prototype while its status is
Proposed. Production telemetry wiring, protected evidence, slab-page lifetime,
and performance claims remain blocked until this ADR is accepted and their
separate gates pass.
Decision¶
Create a bounded, power-of-two SPSC ring with the following contract:
- construction consumes the only splittable ring handle and returns exactly one
non-cloneable
Producerand one non-cloneableConsumer; - each endpoint requires
&mut selffor mutation and isSendbut notSync; - producer and consumer cursors occupy distinct 64-byte-aligned cache lines;
- the producer exclusively initializes a free slot, then publishes the next
monotonic sequence with a
Releasestore; - the consumer observes publication with an
Acquireload, moves the value out exactly once, then releases the consumed sequence; - the producer acquires the consumed sequence before reusing a slot;
- cached remote cursors may cause a conservative full/empty result but cannot permit overwrite or uninitialized read;
- sequence arithmetic is modulo
2^64; capacity is less than2^63, so the producer-consumer distance is unambiguous while the bounded-ring invariant holds; try_pushandtry_popperform bounded work and never sleep, spin, allocate, invoke a callback, or enter an async runtime;- saturation returns ownership of the rejected value to the producer;
- endpoint closure is explicit. A consumer drains already-published values before reporting producer disconnection. Concurrent closure may race with at most one successful publication, which remains initialized and is either consumed or dropped by final ring destruction;
- final ring destruction drops every published but unread value exactly once;
- the ring transfers fixed descriptors. Payload bytes remain in a separately owned slab and are not copied by the ring.
The initial descriptor ABI is #[repr(C, align(32))] and exactly 32 bytes. It
contains sequence, arena identity/generation, flags, checked offset/length,
CRC32C, and schema ID fields. This ADR freezes the in-memory prototype layout,
not a stable cross-process or disk ABI; those require the wire/segment ADR.
The unsafe implementation is confined to lib/event/src/ring.rs. Each unsafe
operation states the slot ownership, initialization, aliasing, and ordering
preconditions at the operation. The crate forbids unsafe operations inside an
unsafe function unless they are in an explicit unsafe block.
Invariants and linearization points¶
Producer::try_push linearizes at the Release store to published_head.
Before that store, only the producer may access the selected slot. After an
Acquire load observes the sequence, only the consumer may move the value from
that slot.
Consumer::try_pop makes the slot reusable at the Release store to
consumed_tail. A producer may overwrite the slot only after an Acquire load
observes that sequence. The producer never advances more than N sequences
ahead of the consumer; the consumer never advances beyond the published head.
The progress property of each try-operation is wait-free for its owning thread: the operation executes a fixed number of local operations and atomic loads or stores. End-to-end delivery is not wait-free because progress also requires the other endpoint and downstream capacity.
Failure and overload behavior¶
- zero, non-power-of-two, or sequence-ambiguous capacity is rejected before allocation;
- a full ring returns
TryPushError::Full(value)without modifying any slot; - an empty connected ring returns
TryPopError::Empty; - a closed peer returns
Disconnected; normal telemetry callers must reject or use their bounded durable spool; - the ring never silently overwrites, expands, allocates an overflow node, or changes protected work to best effort;
- panic during a user value destructor follows Rust's ordinary unwinding rules; it cannot cause another initialized slot to be read twice, although remaining values may leak during process unwind as with other panicking destructors.
Consequences¶
The normal data transfer costs one slot write, one publication store, one slot read, and one consumption store. Remote cursors are cached, reducing coherence loads until the ring approaches empty or full. Setup allocates the slot array and two endpoint reference counts; steady-state push/pop allocates nothing.
The design deliberately does not include blocking waits, multi-producer access, dynamic resizing, slab allocation, epoch reclamation, priority scheduling, or WAL durability. Those responsibilities remain separate so their overload and failure policies cannot be hidden inside a queue primitive.
Maintaining custom unsafe concurrency code has a substantial review and tooling cost. The implementation must remain smaller than a generic queue and may be replaced if a maintained primitive proves the same cursor visibility, layout, drop, and slab-lifetime contract with equal or better measurements.
Alternatives considered¶
- Tokio bounded MPSC — already useful in the compatibility plane, but it permits multiple producers and couples progress to the async scheduler. It does not establish the target one-core ownership or descriptor/slab contract.
- Crossbeam
ArrayQueue— bounded and well reviewed, but implements MPMC coordination and per-slot sequencing that this topology does not require. - An external SPSC crate — preferable if it exposes the required monotonic publication/consumption sequences, shutdown/drop proof, 64-byte cursor isolation, and Loom/Miri evidence. No dependency is selected by this ADR; replacement remains explicitly allowed after an audited comparison.
- A ring of payload objects — rejected because variable payload ownership would make the queue responsible for allocation, copies, and NUMA lifetime. The ring carries only descriptors into an independently bounded slab.
- Per-slot atomics — unnecessary for one producer and one consumer. Global producer/consumer sequences prove ownership and avoid another atomic per slot.
Revisit when¶
Revisit before production wiring, when slab-page epoch retirement is designed, when a maintained SPSC crate satisfies the full contract, when qualification shows cursor/state layout is a bottleneck, or when a target lacks lock-free 64-bit atomics.
Security consequences¶
The ring handles authenticated, tenant-routed telemetry only after bounded wire verification; it does not authenticate tenant claims or authorize actions. Descriptor offset and length arithmetic is checked before a slab view is created. Payload bytes and raw credentials are never logged by this primitive.
Normal telemetry may be rejected only under its declared replay/spool policy. Critical evidence must use a separately reserved ring and protected WAL lane; until that lane exists, this prototype must not carry evidence whose successful publication would authorize execution. Ring corruption, disconnect, or saturation never converts a Cedar deny, approval failure, or receipt durability failure into allow.
Residual risk is concentrated in unsafe slot initialization/drop and the atomic ordering proof. Production wiring requires designated unsafe/concurrency review, Miri, Loom, supported sanitizers, long native stress, and raw cache/allocation measurements.
Verification¶
cargo test -p aegis-event
cargo test -p aegis-event --features loom loom_
cargo +nightly miri test -p aegis-event
cargo bench -p aegis-event --bench spsc_ring
cargo clippy -p aegis-event --all-targets -- -D warnings
cargo tree -p aegis-event
Required tests cover FIFO ordering, full/empty transitions, endpoint closure, unread-value destruction, modular sequence wrap, cache-line and descriptor layout, randomized native stress, and Loom publication/reuse schedules. A benchmark result is evidence only when accompanied by the repository's hardware manifest and raw artifacts; this ADR makes no throughput claim.
Migration and rollback¶
The prototype is not wired into any current path, so rollback is removal of the
workspace member. Later integration must be controlled by the per-tenant
event_write generation and retain the current Tokio/SQL pipeline until shadow
equality, loss/duplicate, crash, and release-artifact rollback gates pass.