From 9d7208efcb5766c27c6df6eadd0055eab0cce426 Mon Sep 17 00:00:00 2001 From: Lavkush Kumar Date: Mon, 13 Jul 2026 23:18:48 +0530 Subject: [PATCH] feat(event): add published-prefix SPSC primitives --- .github/workflows/ci.yml | 79 +- Cargo.lock | 52 + Cargo.toml | 2 + MIGRATION_MATRIX.md | 11 +- README.md | 7 +- ROADMAP.md | 13 + docs/Documentation_Quality_Report.md | 15 +- docs/Implementation_Status.md | 19 + docs/LLD.md | 93 +- .../0006-cache-padded-spsc-event-fabric.md | 188 +++ ...007-sealed-generation-tagged-slab-pages.md | 266 ++++ ...append-only-published-prefix-slab-pages.md | 408 ++++++ docs/adr/index.md | 10 +- docs/current-vs-roadmap.md | 3 + lib/event/Cargo.toml | 27 + lib/event/benches/published_slab.rs | 35 + lib/event/benches/spsc_ring.rs | 62 + lib/event/src/descriptor.rs | 78 ++ lib/event/src/lib.rs | 30 + lib/event/src/published_slab.rs | 1223 +++++++++++++++++ lib/event/src/ring.rs | 491 +++++++ lib/event/src/slab.rs | 638 +++++++++ lib/event/tests/descriptor.rs | 74 + lib/event/tests/published_allocations.rs | 99 ++ lib/event/tests/published_slab.rs | 562 ++++++++ lib/event/tests/slab.rs | 379 +++++ lib/event/tests/spsc.rs | 151 ++ mkdocs.yml | 3 + 28 files changed, 4999 insertions(+), 19 deletions(-) create mode 100644 docs/adr/0006-cache-padded-spsc-event-fabric.md create mode 100644 docs/adr/0007-sealed-generation-tagged-slab-pages.md create mode 100644 docs/adr/0008-append-only-published-prefix-slab-pages.md create mode 100644 lib/event/Cargo.toml create mode 100644 lib/event/benches/published_slab.rs create mode 100644 lib/event/benches/spsc_ring.rs create mode 100644 lib/event/src/descriptor.rs create mode 100644 lib/event/src/lib.rs create mode 100644 lib/event/src/published_slab.rs create mode 100644 lib/event/src/ring.rs create mode 100644 lib/event/src/slab.rs create mode 100644 lib/event/tests/descriptor.rs create mode 100644 lib/event/tests/published_allocations.rs create mode 100644 lib/event/tests/published_slab.rs create mode 100644 lib/event/tests/slab.rs create mode 100644 lib/event/tests/spsc.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7634615f..ca8a2e9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,84 @@ jobs: --estimates target/criterion/receipt_hash/compute_receipt_hash_mid_chain/new/estimates.json \ --threshold 0.20 + # ── ADR-0006/0007/0008: event primitive safety gates ──────────────────────── + # The ring and published-prefix algorithms compile once with native std + # atomics/UnsafeCell and once with Loom's modeled equivalents. This job stays + # separate from the gateway matrix so the hot-path crate cannot accidentally + # acquire Tokio, SQLx, protobuf, or system-service dependencies. CRC32C is the + # only default runtime edge; Loom is optional model-checking infrastructure. + event-concurrency: + name: Event primitives native + Loom + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: . + key: event-concurrency + - name: Native correctness and stress tests + run: cargo test -p aegis-event + - name: All-feature correctness and feature-isolation tests + run: cargo test -p aegis-event --all-features + - name: Loom ring and published-prefix publication models + run: cargo test -p aegis-event --features loom loom_ + - name: All-feature Clippy + run: cargo clippy -p aegis-event --all-targets --all-features -- -D warnings + - name: Assert default and all-feature runtime allowlists are exact + run: | + test "$(cargo tree -p aegis-event --edges normal --depth 1 --prefix none | tail -n +2)" = "crc32c v0.6.8" + test "$(cargo tree -p aegis-event --edges normal --depth 1 --prefix none --all-features | tail -n +2)" = "$(printf 'crc32c v0.6.8\nloom v0.7.2')" + - name: Compile diagnostic event benchmarks + run: cargo bench -p aegis-event --bench spsc_ring --bench published_slab --no-run + + event-miri: + name: Event primitives Miri + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@nightly + with: + components: miri + - uses: Swatinem/rust-cache@v2 + with: + workspaces: . + key: event-miri + - name: Miri ring, sealed-page, and published-prefix ownership suite + run: cargo miri test -p aegis-event + + # Rust nightly currently exposes ASan and TSan but no `undefined` sanitizer. + # Miri is therefore the Rust UB/provenance gate; any future C/C++ ABI must add + # a real UBSan lane before that boundary can be accepted. + event-sanitizers: + name: Event published-prefix ${{ matrix.sanitizer }} sanitizer + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sanitizer: [address, thread] + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@nightly + with: + components: rust-src + - uses: Swatinem/rust-cache@v2 + with: + workspaces: . + key: event-${{ matrix.sanitizer }}-sanitizer + - name: Native raw-pointer and cross-thread stress + env: + RUSTFLAGS: -Zsanitizer=${{ matrix.sanitizer }} + ASAN_OPTIONS: detect_leaks=1:halt_on_error=1 + TSAN_OPTIONS: halt_on_error=1 + run: >- + cargo test -Zbuild-std + --target x86_64-unknown-linux-gnu + -p aegis-event + --test published_slab + # ── #1194 (Postgres GA): live Postgres integration smoke test ──────────── # Closes the biggest concrete gap named in # docs/adr/0002-sqlite-first-storage.md for Postgres GA: until this job, @@ -614,4 +692,3 @@ jobs: --set secret.create=true \ --set secret.apiToken=example-token \ > /dev/null - diff --git a/Cargo.lock b/Cargo.lock index 5cd4f2a0..af35e272 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "aegis-event" +version = "0.1.0" +dependencies = [ + "crc32c", + "criterion", + "loom", +] + [[package]] name = "aegis-llm-gateway" version = "0.1.0" @@ -1601,6 +1610,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -2504,6 +2522,21 @@ dependencies = [ "zip 2.4.2", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -3402,6 +3435,19 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "loop9" version = "0.1.5" @@ -5290,6 +5336,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index 485f0061..2edd01e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "src/canon", "lib/common", "lib/api", + "lib/event", "lib/storage", "lib/policy", "lib/soc", @@ -37,3 +38,4 @@ hmac = "0.12" axum = { version = "0.7.5", features = ["macros", "ws"] } cedar-policy = "3.2.1" utoipa = { version = "4.2", features = ["axum_extras", "uuid", "chrono"] } +crc32c = "=0.6.8" diff --git a/MIGRATION_MATRIX.md b/MIGRATION_MATRIX.md index 29ae4594..d2703605 100644 --- a/MIGRATION_MATRIX.md +++ b/MIGRATION_MATRIX.md @@ -2,11 +2,16 @@ **Status:** normative migration blueprint; target components are not shipped until their exit gates pass -**Audit date:** 2026-07-12 +**Audit date:** 2026-07-13 **Scope:** repository state at this checkout versus the Thread-Per-Core + HCMT target -**Audited HEAD:** `f027d07` (`feat(tool-broker): extract connector execution into standalone aegis-tool-broker binary`) +**Audited baseline HEAD:** `f027d07` (`feat(tool-broker): extract connector execution into standalone aegis-tool-broker binary`) + +**Post-audit delta:** the `current` branch adds unwired `aegis-event` SPSC, +sealed generation-tagged slab-page, and append-only published-prefix prototypes +under ADR-0006 through ADR-0008. The lexical counts below remain the frozen +baseline so future excision is measured against one reproducible commit. **Companion documents:** [HLD](ARCHITECTURE.md), [LLD](docs/LLD.md), [Roadmap](ROADMAP.md) @@ -240,7 +245,7 @@ The v1 router’s 146 path literals cover agents, tools, MCP, authorization, ing | Target capability | Current evidence | Gap | |---|---|---| | Thread-per-core reactor | Tokio multi-thread runtime; no affinity crate/config | No core ownership, per-core listener, NUMA allocation, or io_uring reactor | -| Disruptor/SPSC event fabric | Tokio bounded MPSC | No sequence barriers, cache padding, slab ownership, or epoch retirement | +| Disruptor/SPSC event fabric | Tokio bounded MPSC remains `current`; unwired `lib/event` adds 64-byte-separated cursors, Acquire/Release sequences, 32-byte descriptors, bounded closure/drop behavior, a safe seal-before-publish differential oracle, and an append-only page whose packed Release/Acquire state publishes descriptor count, byte watermark, and closure for immediate immutable-prefix resolution. Evidence includes native stress, the same publication algorithm under Loom, full Miri, defined ASan/TSan CI lanes, and a zero-allocation append-plus-resolve test. | The production fabric remains `target`, neither `shadow` nor `qualified`, and has no performance result. Blockers are green sanitizer CI artifacts, ADR acceptance/security review, composite ring reservation/admission, authenticated registry lookup, bounded page rotation/outstanding pages, generation reuse/epochs, NUMA-owner reclamation, priority lanes, production shadow wiring, UBSan support in the Rust toolchain, and qualification. | | HCMT/Arrow SSTables | SQLite/PostgreSQL rows; JSON/TEXT payloads | No Arrow dependency, WAL format, memtable, segment manifest, compactor, or mmap query path | | Gorilla timestamp codec | none | Codec, block restart points, fallback-to-raw rule, corpus absent | | Roaring pruning | none | Bitmap build/serialization/planner absent | diff --git a/README.md b/README.md index ef610de5..e9d20711 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,15 @@ Authorization compute and protected commit are measured separately. A warm deter | APIs | REST JSON plus partial tonic/protobuf | protobuf-first parity; binary fast path; REST compatibility off benchmark path | | Control storage | SQLite/PostgreSQL via `StorageBackend` | split transactional `ControlStore`/`ReceiptLog` | | Telemetry storage | row tables and JSON/TEXT fields | WAL + Arrow-compatible HCMT SSTables | -| Event bus | Tokio bounded MPSC | NUMA-local cache-padded SPSC ring matrix | +| Event bus | Tokio bounded MPSC; `current`, unwired `aegis-event` SPSC, safe sealed-page, and append-only published-prefix prototypes with packed Release/Acquire count, byte-watermark, and closure publication | `target` NUMA-local cache-padded SPSC/slab matrix after composite admission, registry, rotation, epoch/reclamation, shadow, safety, and qualification gates | | Detection | structured scalar rules; optional Qdrant | Aho DFA plus owned HNSW/PQ and isolated INT8 ONNX | | Host sensor | procfs polling, spool, signed commands | CO-RE eBPF telemetry/containment with truthful fallback | | Console | React JSON polling and SVG | Arrow IPC worker, Rust WASM, WebGL2 instancing | -No target row in this table is a shipped claim until its roadmap gate passes. +The event prototypes are `current` only as isolated, unwired code. The +production fabric remains `target`, neither `shadow` nor `qualified`, carries +no protected evidence, and has no performance claim. No target row in this +table is a shipped claim until its roadmap gate passes. ## Quick start diff --git a/ROADMAP.md b/ROADMAP.md index 97365bc7..d3c717ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -65,6 +65,19 @@ Gate: legacy versus typed authorization decisions, hashes, approvals, receipts a ### Week 4 — SPSC ring and slab prototype +Progress (2026-07-13): the `current` checkout has unwired ring, safe sealed-page +oracle, and append-only published-prefix prototypes under Proposed ADR-0006 +through ADR-0008. Packed Release/Acquire state publishes descriptor count, byte +watermark, and closure for immediate immutable-prefix resolution. Evidence +includes a safe sealed differential corpus, native stress, the same publication +algorithm under Loom, full Miri, defined ASan/TSan CI lanes, and a zero-allocation +append-plus-resolve test. The production fabric remains `target`, neither +`shadow` nor `qualified`, and has no performance result. ADR acceptance and +security review, green sanitizer CI artifacts, composite ring reservation/admission, authenticated registry, +bounded page rotation/outstanding pages, generation reuse/epochs, NUMA-owner +reclamation, production shadow wiring, UBSan support in the current Rust +toolchain, and qualification remain blockers. + Deliverables: - implement cache-padded SPSC ring, 32-byte descriptor and NUMA-local slab prototype; diff --git a/docs/Documentation_Quality_Report.md b/docs/Documentation_Quality_Report.md index 8d43d5cf..93e497f4 100644 --- a/docs/Documentation_Quality_Report.md +++ b/docs/Documentation_Quality_Report.md @@ -12,7 +12,7 @@ This inventory makes the all-documentation improvement program measurable. Each |---|---:|---:|---:| | authoring | 2 | 90% | 0 | | component | 15 | 96% | 0 | -| decision | 7 | 95% | 0 | +| decision | 10 | 95% | 0 | | flow | 11 | 95% | 0 | | guide | 53 | 67% | 38 | | landing | 10 | 93% | 0 | @@ -21,7 +21,7 @@ This inventory makes the all-documentation improvement program measurable. Each | reference | 11 | 85% | 0 | | runbook | 7 | 100% | 0 | -**Total:** 123 active Markdown pages · **Migration backlog:** 38 pages below 75%. +**Total:** 126 active Markdown pages · **Migration backlog:** 38 pages below 75%. ## Scoring signals @@ -84,7 +84,10 @@ Pages are sorted by structural coverage, then path. Improve factual accuracy and | [adr/0003-aegis-jcs-1-canonicalization.md](adr/0003-aegis-jcs-1-canonicalization.md) | decision | 90 | A | 100% | | [adr/0004-ed25519-receipt-signing.md](adr/0004-ed25519-receipt-signing.md) | decision | 85 | B | 83% | | [adr/0005-fail-closed-defaults.md](adr/0005-fail-closed-defaults.md) | decision | 97 | A | 100% | -| [adr/index.md](adr/index.md) | decision | 44 | B | 83% | +| [adr/0006-cache-padded-spsc-event-fabric.md](adr/0006-cache-padded-spsc-event-fabric.md) | decision | 189 | B | 83% | +| [adr/0007-sealed-generation-tagged-slab-pages.md](adr/0007-sealed-generation-tagged-slab-pages.md) | decision | 267 | A | 100% | +| [adr/0008-append-only-published-prefix-slab-pages.md](adr/0008-append-only-published-prefix-slab-pages.md) | decision | 409 | A | 100% | +| [adr/index.md](adr/index.md) | decision | 50 | B | 83% | | [adr/template.md](adr/template.md) | decision | 46 | A | 100% | | [AegisAgent_Agent_Cage.md](AegisAgent_Agent_Cage.md) | guide | 581 | B | 78% | | [AegisAgent_Agent_SOC_Design.md](AegisAgent_Agent_SOC_Design.md) | guide | 757 | D | 56% | @@ -134,7 +137,7 @@ Pages are sorted by structural coverage, then path. Improve factual accuracy and | [components/Tool_Broker.md](components/Tool_Broker.md) | component | 66 | A | 100% | | [concepts.md](concepts.md) | guide | 415 | B | 78% | | [contributing/documentation-standard.md](contributing/documentation-standard.md) | authoring | 207 | B | 80% | -| [current-vs-roadmap.md](current-vs-roadmap.md) | reference | 144 | A | 100% | +| [current-vs-roadmap.md](current-vs-roadmap.md) | reference | 146 | A | 100% | | [database-schema.md](database-schema.md) | reference | 289 | A | 100% | | [demo-github-attack.md](demo-github-attack.md) | guide | 220 | B | 78% | | [deployment-guide.md](deployment-guide.md) | guide | 302 | A | 100% | @@ -161,12 +164,12 @@ Pages are sorted by structural coverage, then path. Improve factual accuracy and | [github-integration.md](github-integration.md) | guide | 125 | D | 56% | | [Glossary.md](Glossary.md) | landing | 100 | A | 100% | | [How_It_Works.md](How_It_Works.md) | landing | 75 | A | 100% | -| [Implementation_Status.md](Implementation_Status.md) | reference | 107 | B | 80% | +| [Implementation_Status.md](Implementation_Status.md) | reference | 126 | B | 80% | | [index.md](index.md) | landing | 109 | B | 86% | | [installation.md](installation.md) | guide | 127 | A | 100% | | [Issue_Backlog_Execution_Plan.md](Issue_Backlog_Execution_Plan.md) | guide | 208 | D | 44% | | [Last_Mile_System_Walkthrough.md](Last_Mile_System_Walkthrough.md) | guide | 147 | C | 67% | -| [LLD.md](LLD.md) | guide | 1454 | A | 100% | +| [LLD.md](LLD.md) | guide | 1537 | A | 100% | | [Local_Development.md](Local_Development.md) | guide | 87 | D | 44% | | [mcp-defense-architecture.md](mcp-defense-architecture.md) | guide | 145 | C | 67% | | [mission.md](mission.md) | guide | 153 | D | 44% | diff --git a/docs/Implementation_Status.md b/docs/Implementation_Status.md index 79d1c1e7..adb63f17 100644 --- a/docs/Implementation_Status.md +++ b/docs/Implementation_Status.md @@ -11,6 +11,25 @@ > - **Unknown-agent runtime control** (cage execution loop + real sensor telemetry + forced egress) is **not** production-complete. > - **Multi-replica K8s** requires Postgres GA (#1194); Helm defaults remain `replicaCount: 1`. +## v2 architecture migration ledger + +This ledger uses the canonical `current`, `shadow`, `target`, and `qualified` +vocabulary from [architecture.md](architecture.md). The larger v1 capability +matrix below retains its historical release labels until it is migrated as a +separate documentation change. + +| Artifact | Status | Evidence in this checkout | Authority / traffic | Remaining gates | +|---|---|---|---|---| +| Unwired SPSC, sealed-page oracle, and published-prefix prototypes | current | `lib/event/`; ring FIFO/full/wrap/drop/layout; safe sealed differential corpus; packed Release/Acquire descriptor-count, byte-watermark, and closure publication with immediate immutable-prefix resolution; native stress; same-algorithm Loom; full Miri; ASan/TSan CI lanes defined; zero-allocation append-plus-resolve test; ADR-0006 through ADR-0008 | No production or `shadow` traffic; cannot carry protected evidence; no performance claim | Green sanitizer CI artifacts, ADR acceptance/security review, composite ring reservation/admission, authenticated registry, bounded page rotation/outstanding pages, generation reuse/epochs, NUMA-owner reclamation, production shadow wiring, UBSan support, qualification | +| Thread-per-core reactor | target | `ARCHITECTURE.md`, `docs/LLD.md` | None | runtime ADR, core-affinity/io_uring implementation, migration and benchmark gates | +| HCMT telemetry store | target | `ARCHITECTURE.md`, `docs/LLD.md` | None; SQL remains authoritative/current | WAL/segment ADR, recovery corpus, dual write, shadow equality, qualification | + +No v2 component is `qualified` in this checkout. The SPSC, sealed-page, and +published-prefix prototypes being `current` means only that their isolated, +unwired code and listed tests are present. The production event fabric remains +`target`, is neither `shadow` nor `qualified`, and is not +production-authoritative. + | Capability | Status | Current files | Missing pieces | Related issues | Test coverage | Prod-ready | Next PR | |---|---|---|---|---|---|---|---| | Gateway authorize | Implemented | `src/src/routes/authorize.rs`, `authorize_decision.rs`, `authorize_canon.rs` | — | #1305–#1313 | unit + integration + bench + fuzz (canon) | prod | perf follow-ups | diff --git a/docs/LLD.md b/docs/LLD.md index b4c07f76..35ed3e71 100644 --- a/docs/LLD.md +++ b/docs/LLD.md @@ -198,6 +198,25 @@ Admission is a reactor-local counter and byte budget. Permits are acquired befor ## 5. Disruptor-style SPSC ring +**Implementation status:** `lib/event` contains `current`, unwired prototypes +governed by Proposed ADR-0006 through ADR-0008. The ring implements +non-cloneable owning endpoints, checked capacity, modular wrap, closure/drain +semantics, unread-value destruction, and cache-layout assertions. The safe +sealed-page reference provides bounded layout, exact descriptor membership, +CRC32C, and immutable page-level lifetime. The append-only page uses +Release/Acquire to publish one packed descriptor-count, byte-watermark, and +closure state, then immediately resolves immutable prefix views while its +writer appends to a disjoint suffix. Evidence includes a safe sealed +differential corpus, native stress, the same publication algorithm under Loom, +full Miri, defined ASan/TSan CI lanes, and a zero-allocation append-plus-resolve +test. These +prototypes carry no production or protected-evidence traffic, are neither +`shadow` nor `qualified`, and make no performance claim. ADR acceptance and +security review, green sanitizer CI artifacts, composite ring reservation/admission, authenticated registry +lookup, bounded page rotation and outstanding pages, generation reuse and +epochs, NUMA-owner reclamation, production shadow wiring, UBSan support, and +qualification remain `target` gates. + ### 5.1 Memory layout ```rust @@ -271,7 +290,14 @@ impl<'a, T, const N: usize> Consumer<'a, T, N> { } ``` -The production implementation MUST add constructor endpoint uniqueness, wraparound proof, shutdown/drop of unread values, loom tests, Miri tests, sanitizer tests, and cache-layout assertions. The code above is an ownership skeleton, not permission to paste unreviewed unsafe code. +The condensed skeleton shows the cursor ordering, not the complete public API. +The current prototype uses setup-only `Arc` ownership so endpoints can move to +independent threads while final destruction waits for both endpoints. It has +constructor endpoint uniqueness, wraparound, shutdown/drop, Loom, native +stress, cache-layout tests, Miri coverage, and defined ASan/TSan CI lanes. Green +CI artifacts remain required. UBSan is unavailable in the current Rust +toolchain; supported qualification evidence remains mandatory before production +wiring. This snippet is not permission to copy unreviewed unsafe code. ### 5.2 Descriptor ABI @@ -292,6 +318,55 @@ pub struct TelemetryDescriptor { The descriptor is exactly 32 bytes. Payload bytes reside in a NUMA-local slab. `offset + len` uses checked arithmetic and MUST remain within the referenced immutable page. CRC32C covers the complete FlatBuffer payload. The descriptor’s sequence is monotonic per producer. +#### Current sealed-page reference implementation + +The current, unwired `SlabPageBuilder` is a safe ownership oracle, not the +target concurrent slab. It bounds one page to 64 MiB, one descriptor table to +65,536 entries, and each frame to 1 MiB. Successful append copies the caller's +already-verified bytes once into pre-reserved storage and performs no buffer +growth. Descriptors stay private until `seal(self)` consumes the only mutable +owner. Sealing moves the pre-reserved vectors behind one page-level `Arc`; this +allocates page metadata but does not copy payload bytes. The ring then transfers +only 32-byte descriptors. + +Resolution checks arena ID, generation, non-zero length, and exact membership +in the sealed descriptor table before that canonical entry can select bytes. +It then checks the range against the used prefix and CRC32C before returning a +borrowed slice. CRC32C is `O(n)` corruption detection, not +authentication or FlatBuffer verification. The reference withholds a page's +descriptors until seal, so it neither overlaps producer/consumer work nor meets +the target immediate-publication flow. Outstanding-page budgets, bounded flush +latency, authenticated routing scope, generation reuse/wrap, epoch retirement, +NUMA ownership, and owner-thread destruction remain production blockers. + +#### Current append-only published-prefix prototype + +The `current`, unwired `PublishedSlabPage` consumes one setup handle into +exactly one non-cloneable writer and reader. Its fixed payload and descriptor +cell arrays never resize. A successful append copies one caller-provided, +upstream-verified and redacted slice into a never-written suffix, initializes +its canonical descriptor, and then Release-stores a cache-line-aligned packed +state containing descriptor count, byte watermark, and writer closure. That +store is the append linearization point; the descriptor is not returned before +it completes. + +Resolution Acquire-loads one coherent state, rejects invalid reserved bits or +a sequence outside the published count, requires exact equality with the +canonical descriptor cell, checks its range against the published byte +watermark, and verifies CRC32C before returning a native borrowed view. Later +appends touch only the disjoint unpublished suffix. The shipping append and +resolve operations allocate nothing after construction; Loom exercises the +same publication algorithm but uses a disclosed model-only copy because a +borrow cannot escape its checked-cell guard. + +This closes only the within-page publication gap. The production fabric +remains `target`, neither `shadow` nor `qualified`, and has no performance +result. ADR acceptance and security review, atomic reservation across page +bytes and the descriptor ring, bounded page rotation and outstanding-page +admission, authenticated lookup, generation reuse with epoch retirement, +NUMA-owner reclamation, production shadow wiring, UBSan coverage when the +toolchain supports it, and qualification remain blockers. + ### 5.3 Priority and fairness Critical and normal traffic use separate rings and slab budgets. Writer polling uses deficit round robin: @@ -1382,10 +1457,17 @@ Normal telemetry may be rejected only when its source retains a bounded replay/s ### 29.2 Unsafe/concurrency -- Loom explores small SPSC publication, full/empty, wrap and shutdown states; -- Miri runs ring/slab/buffer unit tests; -- ASan/TSan/UBSan jobs on supported native targets; -- randomized producer/consumer soak beyond sequence wrap simulation; +- Loom explores the same SPSC and packed published-prefix + count/watermark/closure algorithms used by the `current` prototypes, + including shutdown; +- Miri runs the complete native ring, sealed-page, published-prefix borrow, + buffer, and poisoned-suffix corpus; +- ASan/TSan/UBSan evidence is required on supported native targets; the + `current` prototype defines ASan/TSan CI lanes but still requires their green + artifacts, while UBSan is unavailable in the current Rust toolchain and + remains an explicit gate; +- randomized producer/consumer soak resolves immutable prefixes while later + suffixes are appended and extends beyond sequence-wrap simulation; - epoch tests prove no pin crosses blocking I/O and no object frees early; - fuzz FlatBuffer verifier, WAL scanner, segment metadata, Gorilla decoder, Arrow envelope and protobuf conversions. @@ -1400,6 +1482,7 @@ Success means no unauthorized execution, no cross-tenant read, no acknowledged p | Benchmark | Gate | |---|---| | SPSC descriptor | cycles/op, cache misses, zero allocations, sustained wrap | +| published-prefix slab | append/resolve cycles, copied bytes, allocations, publication cache-line transfers, producer/consumer overlap, errors at saturation | | FlatBuffer verify | events/s by frame-size distribution | | authorize compute | `<1 ms p99` warm snapshot; p99.9 reported | | protected commit | hardware-profile p99, group size, zero lost receipts | diff --git a/docs/adr/0006-cache-padded-spsc-event-fabric.md b/docs/adr/0006-cache-padded-spsc-event-fabric.md new file mode 100644 index 00000000..60b2f1fb --- /dev/null +++ b/docs/adr/0006-cache-padded-spsc-event-fabric.md @@ -0,0 +1,188 @@ +# 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 `Producer` and one non-cloneable `Consumer`; +- each endpoint requires `&mut self` for mutation and is `Send` but not `Sync`; +- 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 `Release` store; +- the consumer observes publication with an `Acquire` load, 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 than `2^63`, so the + producer-consumer distance is unambiguous while the bounded-ring invariant + holds; +- `try_push` and `try_pop` perform 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 + +```bash +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. + +## References + +- [Mandatory architecture law](../architecture.md) +- [Target HLD: shared-memory SPSC event bus](../../ARCHITECTURE.md#5-shared-memory-spsc-event-bus) +- [Target LLD: Disruptor-style SPSC ring](../LLD.md#5-disruptor-style-spsc-ring) +- [Migration matrix](../../MIGRATION_MATRIX.md#8-target-component-map) +- [Contribution and unsafe-code standard](../../CONTRIBUTING.md#lock-free-structures) diff --git a/docs/adr/0007-sealed-generation-tagged-slab-pages.md b/docs/adr/0007-sealed-generation-tagged-slab-pages.md new file mode 100644 index 00000000..4fff359a --- /dev/null +++ b/docs/adr/0007-sealed-generation-tagged-slab-pages.md @@ -0,0 +1,266 @@ +# ADR-0007: Sealed generation-tagged slab pages + +**Status:** Proposed +**Date:** 2026-07-13 +**Issue/PR:** pending + +## Context + +ADR-0006 establishes a current, unwired SPSC prototype whose ring carries a +32-byte `TelemetryDescriptor`; it deliberately leaves payload-page ownership +and reclamation undefined. Publishing descriptors without that contract would +permit a consumer to resolve mutable, recycled, partially initialized, or +out-of-bounds bytes. A page-lifetime mistake is both a memory-safety risk and a +cross-event integrity risk even when the ring's cursor ordering is correct. + +The target HLD requires an ingress reactor to write a verified FlatBuffer once +into NUMA-local memory, publish descriptors, and retire pages through epochs. +Implementing concurrent published-prefix reads and page reuse in the same step +would combine three independent proof obligations: byte initialization, +publication ordering, and reclamation. This ADR permits a smaller +non-authoritative prototype that makes mutable-to-immutable ownership and stale +generation rejection testable before an epoch manager or `CoreReactor` exists. + +The prototype remains outside every current production path. It cannot carry +protected evidence, does not make the target event fabric current or qualified, +and does not establish the target admission-latency or throughput claims. + +## Decision + +Add a bounded `SlabPageBuilder` to `aegis-event`. The builder directly owns one +pre-reserved payload vector and one pre-reserved descriptor vector, appends +frames sequentially, and withholds all descriptors from callers until +`seal(self)` consumes the builder. Sealing moves the same vectors behind one +page-level `Arc` without copying payload bytes; page-level leases then keep the +page alive while the SPSC ring transfers only copied 32-byte descriptors. + +The prototype contract is: + +- a page is configured with `arena_id`, `arena_generation`, byte capacity, + descriptor capacity, and first producer sequence; +- byte capacity is in `1..=64 MiB`; descriptor capacity is in + `1..=65,536`; an individual frame remains bounded to `1 MiB`; +- payload and descriptor reservations are fallible and return typed errors; +- one builder owns all mutation; it is never shared with a reader; +- append validates slab-local non-empty/maximum length and both remaining + budgets before changing state; FlatBuffer verification is an upstream caller + precondition, not a guarantee encoded by this API; +- payloads are tightly packed in append order; `offset` is the prior used-byte + count and `len` is the exact input length, both represented as `u32` only + after checked conversion; +- append copies the input payload once into the pre-reserved page, computes + CRC32C over the complete payload, and pushes one fixed descriptor without + requesting vector growth; allocator instrumentation remains a qualification + gate rather than a claim from pointer stability alone; +- descriptor sequence advances modulo `2^64`, matching ADR-0006; generation + reuse is not implemented by this prototype; +- sealing is the only mutable-to-immutable transition. No public API exposes a + descriptor before its complete page is immutable; +- a reader resolves a descriptor only after exact arena-ID and generation + equality, non-empty length validation, and exact equality with the descriptor + stored at its bounded modular sequence distance in the sealed page table; + only that canonical entry may select bytes, after which checked offset/length + arithmetic against `used_bytes` and CRC32C equality are required; +- CRC32C detects accidental corruption; it is not authentication and cannot + replace tenant binding, FlatBuffer verification, receipt hashes, or + cryptographic signatures; +- integration MUST create reader leases once per bounded page handoff or + downstream consumer, not once per event; the standalone prototype cannot + enforce call frequency until an arena registry owns lease issuance; +- there is no reset/reuse method, mutable published prefix, registry, epoch + pin, NUMA allocator, priority lane, WAL, or production integration in this + slice. + +## Invariants and state transition + +```text +allocated builder + -- checked append* --> mutable private prefix + -- consume/seal ----> immutable sealed page + publishable descriptors + -- page-level lease -> immutable reader view + -- last lease drop --> backing allocation reclaimed +``` + +Only the builder can mutate the payload vector. `seal(self)` consumes that +capability before any descriptor can escape. `SealedSlabPage` and +`SlabPageReader` expose shared byte slices only; the backing vector is private +and never resized or mutated after sealing. Consequently, safe Rust supplies +the aliasing proof and this slice adds no unsafe code or atomic publication +protocol. + +Page identity is the tuple `(arena_id, arena_generation)`. A descriptor for any +other tuple fails before offset resolution. Exact table membership prevents a +forged descriptor from relabeling another valid byte range's schema or flags; +CRC still covers payload bytes only. The builder never reuses a page; therefore +generation allocation, wrap prevention, page registry publication, and ABA +freedom remain obligations of the future epoch/arena ADR. The sealed page +exposes its next sequence so a caller can carry sequence continuity into the +next page; the future arena manager must own and enforce that handoff. + +## Logical memory layout + +```text +payload allocation (logical capacity <= 64 MiB) ++----------------+----------------+---------------------+ +| frame 0 bytes | frame 1 bytes | unused capacity ... | ++----------------+----------------+---------------------+ +0 offset[1] used_bytes byte_capacity + +descriptor allocation (capacity <= 65,536) ++----------------------+----------------------+----------+ +| descriptor(frame 0) | descriptor(frame 1) | unused | ++----------------------+----------------------+----------+ +``` + +There is no page header, padding, endian contract, or persistent ABI. The +descriptor remains the in-memory prototype ABI defined by ADR-0006. A wire, +shared-process, or disk page format requires a separately versioned schema and +golden byte corpus. + +## Copy and allocation ledger + +| Boundary | Ownership before → after | Payload copies | Allocation/refcount behavior | +|---|---|---:|---| +| caller-provided slice → private builder page | caller → builder-owned bytes | one bounded copy | payload and descriptor capacities were reserved at construction; upstream verification is a precondition | +| builder → sealed page | builder-owned vectors → immutable page `Arc` | zero | one page-metadata `Arc` allocation; payload/vector allocations are moved, not copied | +| sealed page → SPSC ring | page remains lease-owned | zero | one 32-byte descriptor value is copied; no payload reference count | +| page reader → consumer parser | immutable page → borrowed slice | zero | no allocation or refcount per resolve | +| page lease create/drop | immutable page remains shared | zero | one `Arc` increment/decrement per page-level lease | + +CRC calculation and verification read the full payload but do not materialize +it. NIC/TLS/gRPC-to-caller ownership is outside this boundary and remains an +explicit copy decision in the wire/reactor ADR. + +## Failure and overload behavior + +- invalid zero or oversized budgets fail before allocation; +- payload/descriptor reservation failure is a typed constructor error; +- empty or over-`MAX_FRAME_BYTES` input is rejected without changing builder + state; +- insufficient byte or descriptor capacity returns a typed saturation error + without partial append, growth, eviction, or overwrite; +- stale arena ID or generation, zero length, integer overflow, range beyond + `used_bytes`, CRC mismatch, unknown sequence, or descriptor-table mismatch + fails closed and returns no byte slice; +- sealing an empty page is legal but yields no descriptors; callers cannot use + it to publish an event; +- ordinary process OOM policy still applies to the small `Arc` allocation at + seal; the append path does not attempt recovery allocation; +- no error changes Cedar authority, approval state, receipt durability, or a + protected action into best effort. + +## Consequences + +The slice creates a safe reference implementation for page layout, bounds, +generation checks, CRC verification, and page-level lifetime. It proves that a +descriptor can traverse the existing ring while its payload stays in one page +allocation, and it gives later concurrent implementations a differential +oracle. + +Sealing performs one page-level `Arc` metadata allocation but leaves the payload +and descriptor vector allocations in place. Withholding descriptors until a +whole page is sealed adds page-fill/rotation latency and prevents +producer/consumer overlap within a page. Without a bounded +time/size flush policy, a low-rate page could wait indefinitely; that behavior +is intentional for this prototype and is not the final target fast path. `Arc` +reclamation can also execute the payload deallocation on whichever thread drops +the final lease, so it is not an acceptable substitute for NUMA-owner epoch +retirement in a qualified reactor. The primitive bounds each page but does not +bound the number of outstanding pages or leases; the future arena manager must +enforce those budgets. + +CRC32C costs `O(n)` per payload at append and again at resolve. The cost is +independent of page size but proportional to frame bytes; hardware acceleration +is selected by the reviewed `crc32c` dependency when supported. No throughput +or latency result is claimed without the repository benchmark contract. + +The dependency is workspace-pinned to `crc32c = "=0.6.8"`, is dual +MIT/Apache-2.0 licensed, and adds no transitive normal runtime dependency. Its +manifest declares no MSRV and contains architecture-specific unsafe hardware +paths behind capability selection, so stable-toolchain compilation, a scalar +Castagnoli differential oracle, Miri, advisory scanning, and the exact CI +dependency allowlist remain required. This review does not transfer authority +to CRC32C or make it a cryptographic primitive. + +## Alternatives considered + +- **Concurrent append plus immutable published-prefix reads now** — closer to + the target latency, but requires unsafe disjoint-byte aliasing, an atomic + committed-prefix protocol, Loom modeling, Miri/sanitizer coverage, and page + reclamation rules. It is deferred until the safe oracle is present. +- **Epoch retirement in this slice** — rejected because there is no page + registry or pinned reactor reader yet. Adding `crossbeam-epoch` without a + real pointer-publication lifetime would provide ceremony rather than proof. +- **One `Arc<[u8]>` or `Bytes` per event** — safe but adds per-event allocation + or reference-count traffic and defeats page-level ownership. +- **Store variable payloads in ring slots** — makes the queue own allocation, + drop, and NUMA lifetime and violates ADR-0006's fixed-descriptor boundary. +- **Return descriptors directly from `try_append`** — rejected because callers + could publish a descriptor while later appends still mutate the same page. +- **Unchecked `Vec` growth** — rejected because capacity exhaustion would + allocate or panic instead of returning the declared bounded overload result. + +## Revisit when + +Revisit before production telemetry wiring or `CoreReactor` integration. The +next decision must specify concurrent byte publication, arena registry lookup, +generation allocation/wrap behavior, crossbeam-epoch pin/retire rules, NUMA +allocation and owner-thread destruction, page rotation thresholds, priority +budgets, and sanitizer/Loom evidence. It must retain this safe implementation +as a differential oracle or explain its replacement. + +## Security consequences + +The page is an internal integrity boundary after authentication, bounded wire +verification, and tenant routing; it does not derive tenant identity. Exact +identity/generation and sealed-table membership checks prevent an ordinary +stale or metadata-mutated descriptor from being resolved as a valid event. +Used-byte bounds prevent access to reserved but unwritten capacity. CRC +mismatch returns no view, but CRC collision resistance is not a security +property. Production pages must remain within one authenticated routing scope; +a tenant-agnostic global page registry is prohibited. + +The prototype stores arbitrary caller bytes in memory, so upstream redaction +and the ban on raw credentials remain mandatory. Errors and debug output expose +only sizes and numeric identity metadata, never payload content. Residual risk +includes caller-managed generation reuse, non-cryptographic descriptor +metadata, allocator/NUMA placement, and final-drop placement; production wiring +is blocked until those are resolved and security-reviewed. + +## Verification + +```bash +cargo test -p aegis-event +cargo test -p aegis-event --features loom loom_ +cargo +nightly miri test -p aegis-event +cargo clippy -p aegis-event --all-targets --all-features -- -D warnings +cargo tree -p aegis-event --edges normal +``` + +Tests must cover constructor limits, exact descriptor fields and known CRC32C +vectors, byte/descriptor saturation with state and sequence preservation, +modular sequence wrap and cross-page continuity, identity/generation/range/CRC +and forged-metadata rejection, page lifetime after sealed-owner drop, stable +pre-reserved vector pointers across append/seal, no per-resolve refcount, and +descriptor-only cross-thread SPSC transfer. Future concurrent publication +requires new Loom states; this safe sealed-page implementation has no new +atomics to model beyond `Arc`'s standard-library ownership. + +## Migration and rollback + +The page is current only as isolated, unwired prototype code while this ADR is +Proposed. Rollback removes the slab module and its single reviewed dependency; +no state, wire format, traffic flag, or release migration is involved. Future +shadow integration must retain the current SQL/Tokio telemetry path and use the +per-tenant `telemetry_wire`/`event_write` generations until equality, loss, +recovery, and release-artifact rollback gates pass. + +## References + +- [ADR-0006: Cache-padded SPSC event fabric](0006-cache-padded-spsc-event-fabric.md) +- [Mandatory architecture law](../architecture.md) +- [Target HLD: Shared-memory SPSC event bus](../../ARCHITECTURE.md#5-shared-memory-spsc-event-bus) +- [Target HLD: Protocol and copy contract](../../ARCHITECTURE.md#10-protocol-and-copy-contract) +- [Target LLD: Descriptor ABI and memory reclamation](../LLD.md#52-descriptor-abi) +- [Migration matrix](../../MIGRATION_MATRIX.md#10-gap-analysis-against-target) +- [Contribution standard: Zero-copy and allocation claims](../../CONTRIBUTING.md#zero-copy-and-allocation-claims) diff --git a/docs/adr/0008-append-only-published-prefix-slab-pages.md b/docs/adr/0008-append-only-published-prefix-slab-pages.md new file mode 100644 index 00000000..4fe21f03 --- /dev/null +++ b/docs/adr/0008-append-only-published-prefix-slab-pages.md @@ -0,0 +1,408 @@ +# ADR-0008: Append-only published-prefix slab pages + +**Status:** Proposed +**Date:** 2026-07-13 +**Issue/PR:** pending + +## Context + +ADR-0007 adds a current, unwired seal-before-publish slab page. That safe +implementation proves bounded layout, generation checks, exact descriptor +membership, CRC32C validation, and page-level lifetime, but it withholds every +descriptor until the complete page is sealed. The target event fabric must let +the ingress owner publish each descriptor immediately after its payload becomes +immutable so the consumer can overlap page production and consumption. + +Publishing a mutable prefix creates a new unsafe and concurrency boundary. A +reader must never observe partially copied bytes, an uninitialized descriptor, +or a descriptor whose bytes can later be overwritten. Reclamation and reuse are +separate obligations: solving publication does not establish ABA freedom, +tenant-safe registry lookup, NUMA-owner destruction, or bounded epoch pins. +Combining those mechanisms in one change would make failures difficult to +localize and would remove ADR-0007 as an independent correctness oracle. + +This decision therefore covers only an append-only, preallocated page whose +writer and reader endpoints are created together before the first append. The +prototype remains outside every production path. It cannot carry protected +evidence, does not make the target event fabric qualified, and provides no +authorization-latency or ingestion-throughput measurement. + +## Decision + +Add `PublishedSlabPage` to `aegis-event`. Construction preallocates one bounded +byte-cell array and one bounded descriptor-cell array, then `split` consumes the +setup handle and returns exactly one non-cloneable writer and one non-cloneable +reader. The writer appends to never-before-written cells and publishes one +packed, monotonic state word containing descriptor count, byte watermark, and +closure with a `Release` store. The reader performs one `Acquire` load of that +state before reading a committed descriptor slot or borrowing its immutable +byte range. + +The prototype contract is: + +- it reuses `SlabPageConfig` limits: byte capacity is `1..=64 MiB`, descriptor + capacity is `1..=65,536`, and one payload is `1..=1 MiB`; +- payload-cell and descriptor-cell reservations are fallible and return the + existing typed `SlabConfigError`; the small `Arc` metadata allocation remains + subject to the process OOM policy; +- each reservation is filled to its configured length before the page enters + the `Arc`; the private `Vec` and `Vec` metadata, + lengths, capacities, and backing addresses never change after construction; +- `split` creates one writer and one reader; neither endpoint is cloneable and + both are `!Sync`; each may move to its owner thread; +- the writer's staged descriptor cursor, used-byte cursor, and next sequence are + private, non-atomic state accessed through `&mut self`; the packed atomic word + is the authoritative cross-thread committed prefix; +- append checks payload length, descriptor budget, checked offset arithmetic, + byte budget, and `u32` representability before touching page cells; +- FlatBuffer verification and redaction are upstream preconditions; this API + validates slab-local framing and integrity only; +- append computes CRC32C, copies the payload once into a never-before-written + byte range, writes the complete canonical descriptor into a + never-before-written descriptor slot, and then + `Release`-stores one state word containing `published_count = slot_index + 1` + and `published_bytes = end_offset`; only after publication does it commit the + staged writer-private cursors; +- no fallible, allocating, callback, formatting, indexing, or other + panic-capable operation occurs after the first cell write and before the + `Release` publication. The writer marks that interval poisoned; if unwinding + is nevertheless forced and caught, future append attempts fail permanently + rather than overwriting a partially initialized suffix; +- the `Release` store is the append linearization point. No descriptor is + returned before that store completes; +- published count starts at zero, increases by exactly one, never exceeds the + configured descriptor capacity, and cannot wrap within one page; published + bytes starts at zero, increases by the non-empty payload length, and never + exceeds byte capacity; +- descriptor sequence advances modulo `2^64`. Because a page holds at most + `2^16` descriptors, modular distance from `first_sequence` is unambiguous; +- the reader validates exact arena ID and generation, loads the packed state + with `Acquire`, validates reserved bits/count/byte watermark against page + capacities, bounds the modular sequence distance by the published count, + reads the canonical descriptor slot, and requires exact descriptor equality + before accessing caller-selected payload bytes; +- only the canonical descriptor selects the payload range. Checked + offset/length arithmetic against the coherently published byte watermark and + CRC32C equality are then required before returning a view; +- the native reader returns a wrapper around a borrowed slice into the page; + resolution performs no payload copy, allocation, or reference-count change; +- the Loom feature uses modeled atomics and checked cells. Its returned payload + wrapper owns a small model-only copy because a reference cannot escape a Loom + `UnsafeCell` access guard; Miri and sanitizers cover the native borrowed-view + implementation; +- dropping the writer `Release`-publishes closure. The reader may drain every + committed descriptor and retain page ownership after writer drop; +- cells are never reset, overwritten, or reused. There is no registry, epoch, + generation allocator, NUMA pool, rotation policy, priority lane, WAL, or + production integration in this decision. + +## Ownership, publication, and memory-order proof + +```text +setup handle + -- consume/split --> one writer + one reader, both owning the page Arc + +writer append i: + validate all bounds + write byte cells [offset_i, end_i) exactly once + write descriptor cell i exactly once + Release store pack(count = i + 1, bytes = end_i) <-- linearization + commit staged writer-private cursors + return descriptor i + +reader resolve descriptor i: + validate page identity + state = Acquire load publication_state + validate state and require i < state.published_count + read canonical descriptor cell i + require exact descriptor equality + validate canonical byte range and CRC32C + borrow immutable byte cells [offset_i, end_i) +``` + +There is one writer, so descriptor slots and payload ranges are assigned in +strict append order without write/write races. A reader accesses slot `i` only +after an `Acquire` load observes a published count greater than `i`; that load +synchronizes with the `Release` store sequenced after both the byte copy and the +descriptor write. The byte watermark in the same state snapshot independently +bounds canonical ranges to initialized published bytes. Later appends touch +only disjoint cells, so an outstanding borrow into an earlier committed range +cannot alias a write. No committed cell is ever mutated. + +`Relaxed` is insufficient for state publication because it would not +make preceding cell initialization visible. `SeqCst` adds a global order that +the single-writer prefix protocol does not require. Per-cell ready flags are +unnecessary because the committed prefix has no gaps. The packed `AtomicU64` is +cache-line aligned with `#[repr(align(64))]`; bits `0..=31` encode published +bytes, bits `32..=48` encode the 17-bit published count, bits `49..=62` are +reserved and must be zero, and bit `63` encodes writer closure. The capacity +limits fit those fields exactly. Closure shares the publication line because it +is written once at shutdown and does not create an independent hot cursor. + +The progress property of a successful append is wait-free with respect to +other threads after bounded `O(n)` CRC and copy work for payload length `n`. +Resolution is wait-free after bounded `O(n)` CRC work. Neither operation loops, +blocks, grows a collection, calls an allocator, waits on another core, or +silently overwrites data. Capacity exhaustion is an immediate typed error. + +## Unsafe invariants + +The native implementation uses `#[repr(transparent)]` wrappers around +`UnsafeCell>` and +`UnsafeCell>` because safe slices cannot +express one writer mutating an unpublished suffix while another owner borrows +an immutable published prefix. Every unsafe block must preserve all of the +following: + +1. `UnsafeCell` and `MaybeUninit` have `T`'s representation; the + transparent wrappers therefore preserve the native `u8` and + `TelemetryDescriptor` size/alignment, and their arrays remain at stable + allocation-derived addresses from construction through final page drop; +2. the writer is the only mutator and writes each addressed cell exactly once; +3. all pointer arithmetic is derived from the owning allocation and preceded by + checked range validation against the allocated cell count; no `&u8`, + `&mut u8`, `&TelemetryDescriptor`, or `&mut TelemetryDescriptor` spans the + whole allocation or an uncommitted suffix; +4. no reader accesses a descriptor or byte cell until an `Acquire` state + snapshot proves the corresponding count and byte prefix was committed; +5. a descriptor is fully initialized before its state-word publication; +6. a returned native slice contains initialized `u8` cells only, is bounded by + one canonical descriptor, and cannot overlap any later write; +7. the slice lifetime is bounded by the reader's page-owning `Arc`; dropping the + writer cannot invalidate it; +8. `TelemetryDescriptor` and `u8` have no drop obligation in uncommitted cells; +9. final `Arc` drop occurs only after both endpoints and all reader borrows are + gone; uncommitted `MaybeUninit` cells require no destruction; +10. there is no external endian, persistent, or shared-process ABI in this + prototype. + +The writer stages every slice/slot lookup, integer conversion, CRC, descriptor, +next cursor value, and packed state before setting its poison bit and performing +the first raw write. Native payload copying is then one `copy_nonoverlapping` +between proven disjoint ranges; the source may be an earlier committed payload +because its range cannot overlap the append-only destination. Descriptor +initialization, one atomic store, writer-local assignments, and poison clearing +are the only subsequent operations. If an injected or platform fault unwinds +before the store, the atomic prefix is unchanged, the partial tail is +unreachable, and the poisoned writer cannot resume. Writer drop sets only the +closed bit with an atomic read-modify-write against the authoritative published +state; it never derives closure from staged cursors and therefore cannot expose +the partial tail. If an interruption is forced after the Release store but +before cursor commit/return, the complete prefix remains published and closure +preserves it, while the poisoned endpoint cannot append again; production +integration must recover the resulting committed-but-undelivered descriptor +through its future composite admission/WAL protocol. Process abort needs no +in-process recovery. + +The page cell arrays contain fixed-size elements and never resize. Native tests +assert the transparent-cell sizes and alignments, stable view pointers, and +borrow validity while later disjoint appends occur. The safe sealed page remains +the differential oracle for descriptor values and resolved payload bytes. + +## Logical memory layout + +```text +publication cache line (64-byte aligned) ++-------------------------+---------------+---------------------------+ +| bytes:u32 | count:u17 | reserved:14 | closed:1 | ++-------------------------+---------------+---------------------------+ + +payload cells (preallocated, append-only) ++=================+=================+-------------------------------+ +| committed frame | committed frame | unpublished/uninitialized | +| 0 bytes | 1 bytes | suffix | ++=================+=================+-------------------------------+ +0 offset[1] writer.used_bytes capacity + +descriptor cells (preallocated, append-only) ++======================+======================+-----------------------+ +| committed desc 0 | committed desc 1 | uninitialized suffix | ++======================+======================+-----------------------+ +0 published_count +``` + +This is a process-local layout, not a stable wire or disk format. The 32-byte +descriptor remains the prototype ABI from ADR-0006. Any mmap, cross-process, or +persistent representation requires a version, endian contract, checksums, and a +golden corpus in a separate ADR. + +## Copy and allocation ledger + +| Boundary | Ownership before → after | Payload copies | Allocation/refcount behavior | +|---|---|---:|---| +| page construction | allocator → setup handle | zero | one bounded byte reservation, one bounded descriptor reservation, and one small page `Arc` allocation | +| caller slice → unpublished suffix | caller → writer-owned cells | one bounded copy | no append-time allocation or growth | +| writer → reader publication | same page allocation remains endpoint-owned | zero | one 32-byte descriptor value returned; one `Release` atomic store | +| descriptor ring transfer | page remains reader-owned | zero | ring copies the 32-byte descriptor; no payload refcount | +| native resolve → parser | reader-owned page → borrowed view | zero | no allocation, copy, or `Arc` operation per resolve | +| Loom resolve | modeled cells → model assertion wrapper | one model-only copy | test-feature artifact; excluded from shipping copy claims | +| endpoint drop | last endpoint → allocator | zero | final `Arc` decrement may deallocate on either endpoint owner | + +Kernel/user, TLS, gRPC, FlatBuffer verification, decompression, Arrow, browser, +WASM, and GPU transfers are outside this page-local boundary and remain visible +in their owning copy ledgers. + +## Failure and overload behavior + +- invalid capacity fails before cell initialization; +- payload or descriptor reservation failure returns a typed allocation error; +- empty or oversized payload fails without changing writer state or cells; +- a poisoned writer fails closed without touching cells or publication state; +- descriptor or byte exhaustion fails without advancing sequence, committing a + slot, growing storage, spilling, evicting, or overwriting; +- checked-add or descriptor-address conversion failure is non-mutating; +- identity/generation mismatch, unpublished sequence, invalid packed state, + exact-descriptor mismatch, invalid canonical range, or CRC mismatch returns + no payload view; +- CRC errors expose sequence only, not payload bytes or expected/computed CRC; +- reader polling is not built into the page. An unpublished descriptor returns + immediately; bounded retry/admission belongs to the owning reactor; +- writer drop freezes the published prefix permanently. It does not seal, + recycle, transfer tenant scope, or make uncommitted suffix cells readable; +- process OOM for the small `Arc` metadata allocation remains the workspace + process policy; no append-path recovery allocation exists; +- no failure can loosen Cedar, consume an approval, weaken receipt durability, + or convert protected evidence to best effort. + +## Security consequences + +The page is an internal post-authentication boundary. It does not derive tenant +identity; construction and endpoint routing must remain inside one authenticated +tenant/shard scope. A tenant-agnostic global lookup table is prohibited. Exact +identity, generation, committed-prefix, and canonical descriptor checks prevent +ordinary stale, forged, relabeled, or redirected descriptors from selecting +bytes. Canonical membership is checked before CRC scanning so attacker-supplied +offset/length/CRC fields cannot turn the resolver into an arbitrary-range CRC +oracle. + +CRC32C detects accidental corruption and is not authentication. It cannot +replace FlatBuffer verification, receipt chaining, action hashes, Ed25519, or +tenant binding. Payload bytes may contain sensitive telemetry, so upstream +redaction and the raw-credential prohibition remain mandatory. Public errors +and `Debug` implementations must never include payload contents. + +Residual risk includes unsafe aliasing defects, process-local arena metadata, +caller-managed generation identity, final deallocation on the consumer core, +and absence of bounded page accounting. Production wiring remains blocked on +maintainer unsafe/security review, sanitizers, authenticated registry design, +generation/reuse proof, epoch/NUMA retirement, rotation/admission policy, and +shadow loss/equality evidence. + +## Performance hypothesis and benchmark method + +For payload length `n`, append performs `O(n)` CRC plus one `O(n)` copy and +`O(1)` descriptor initialization/publication. Resolve performs `O(1)` identity, +sequence, and table checks plus `O(n)` CRC and returns an `O(1)` borrowed view. +These costs are independent of total page population. The design removes the +whole-page seal latency and permits producer/consumer overlap, but it does not +prove a throughput or latency target. + +Qualification must compare sealed and published-prefix pages on the declared +hardware with identical payload distributions and include p50/p95/p99/p99.9, +events/s, bytes/s, allocation/event, copied bytes/event, cycles/byte, branch and +cache misses, cache-line transfers, producer stalls, errors, and saturation. +Tests must verify no lost, duplicated, reordered, or mismatched descriptors. +The measurement must separate CRC/copy cost from ring transfer and protected +durability. Raw histograms and hardware/kernel/NUMA configuration are required. + +## Alternatives considered + +- **Keep seal-before-publish only** — preserves safe Rust but adds page-fill or + timeout latency and prevents overlap within a page; retained as the oracle, + not the target publication mechanism. +- **Add page reuse and epochs now** — rejected because registry publication, + generation allocation, pin duration, retirement owner, and ABA freedom are + independent proof obligations with different failure modes. +- **Atomic flag per descriptor or byte** — rejected because it increases memory + and cache traffic while a single writer already guarantees a gap-free prefix. +- **Atomic committed count without a byte watermark** — sound when the + canonical descriptor is the sole initialization proof, but rejected because + one packed word can also fail closed if malformed internal metadata selects + reserved or unpublished tail bytes. +- **Atomic committed byte count only** — rejected because it does not prove a + canonical descriptor slot is initialized and makes variable-length record + lookup ambiguous. +- **Return `Arc<[u8]>`/`Bytes` per event** — rejected because it creates + per-event allocation or reference-count traffic and weakens page ownership. +- **Copy on native resolve** — rejected because it hides lifetime mistakes and + violates the page-local zero-copy objective; Loom alone uses a disclosed + model-only copy. +- **`Relaxed` publication** — rejected because descriptor and byte + initialization would have no happens-before edge to the reader. +- **`SeqCst` publication** — rejected because no cross-page total order is + needed; Acquire/Release is the minimal sufficient protocol. +- **Crossbeam epoch in an unwired page pair** — rejected because there is no + pointer registry or reuse path to reclaim, and adding an unused epoch guard + would not prove production lifetime. + +## Verification + +```bash +cargo test -p aegis-event +cargo test -p aegis-event --features loom loom_published +cargo +nightly miri test -p aegis-event +cargo clippy -p aegis-event --all-targets --all-features -- -D warnings +cargo tree -p aegis-event --edges normal +RUSTFLAGS='-Zsanitizer=address' cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu -p aegis-event --test published_slab +RUSTFLAGS='-Zsanitizer=thread' cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu -p aegis-event --test published_slab +``` + +Required coverage includes constructor bounds and allocation classification; +immediate same-page publication; identity/generation/metadata forgery rejection; +future-sequence rejection; byte and descriptor saturation with state and +sequence preservation; sequence wrap at `u64::MAX`; writer closure and page +lifetime; stable native borrowed pointers while later disjoint appends occur; +no per-resolve `Arc` change; deterministic corruption rejection; long +cross-thread descriptor-ring stress; byte-for-byte and descriptor-for-descriptor +differential tests against ADR-0007; Loom exploration of read-before/read-after +publication and shutdown; Miri native alias/lifetime checks; ASan/UBSan and TSan +where supported. + +Loom must execute the same packed-state and cell-access algorithm. Its +model-only payload copy is not accepted as native lifetime evidence. Miri and +sanitizers must execute the native borrowed-view path. + +The 2026-07-13 Rust nightly exposes `address` and `thread` sanitizers but rejects +`-Zsanitizer=undefined`; Miri is the current Rust undefined-behavior/provenance +gate. This limitation is recorded rather than relabeling another check as +UBSan. It remains an unmet `CONTRIBUTING.md` acceptance gate for this ADR; Miri +does not satisfy that requirement. Any future C/C++/FFI boundary must also add +its toolchain's real UBSan lane before acceptance. + +## Migration and rollback + +The implementation is current only as isolated, unwired prototype code while +this ADR is Proposed. It receives no production traffic, protected evidence, +schema authority, or durable state. Rollback deletes the published-prefix +module and ADR references while retaining ADR-0007's safe sealed page and +ADR-0006's descriptor ring; no data migration or runtime flag is involved. + +Future shadow integration must retain the current SQL/Tokio telemetry path and +use the documented per-tenant `telemetry_wire` and `event_write` generations. +Cutover additionally requires authenticated page routing, bounded outstanding +pages, epoch/NUMA reuse, crash/loss tests, shadow equality, reproducible tail +latency evidence, and release-artifact rollback. + +## Revisit when + +Revisit before any arena registry, reset/reuse, crossbeam-epoch reclamation, +NUMA pool, production reactor, or protected-evidence integration. The next ADR +must define identity allocation and wrap, authenticated registry scope, pin and +retire ownership, page rotation/flush deadlines, outstanding-page admission, +priority policy, and sanitizer/benchmark qualification. It must retain the +sealed oracle and this append-only implementation as differential references or +justify their replacement. + +## References + +- [ADR-0006: Cache-padded SPSC event fabric](0006-cache-padded-spsc-event-fabric.md) +- [ADR-0007: Sealed generation-tagged slab pages](0007-sealed-generation-tagged-slab-pages.md) +- [Mandatory architecture law](../architecture.md) +- [Target HLD: Shared-memory SPSC event bus](../../ARCHITECTURE.md#5-shared-memory-spsc-event-bus) +- [Target HLD: Protocol and copy contract](../../ARCHITECTURE.md#10-protocol-and-copy-contract) +- [Target LLD: Event fabric](../LLD.md#5-event-fabric-and-lmax-style-sequencing) +- [Target LLD: Descriptor ABI](../LLD.md#52-descriptor-abi) +- [Migration matrix](../../MIGRATION_MATRIX.md#10-gap-analysis-against-target) +- [Contribution standard: lock-free structures](../../CONTRIBUTING.md#lock-free-structures) +- [Contribution standard: unsafe Rust](../../CONTRIBUTING.md#unsafe-rust) +- [Contribution standard: zero-copy claims](../../CONTRIBUTING.md#zero-copy-and-allocation-claims) diff --git a/docs/adr/index.md b/docs/adr/index.md index a2c4026e..2757b8d7 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -2,7 +2,10 @@ **Issue:** [#1197](https://github.com/lavkushry/AegisAgent/issues/1197) -> **Status:** ADR-0001 through ADR-0005 are Accepted. A changed decision requires a new ADR and a supersedes link; do not silently rewrite historical rationale. +> **Status:** ADR-0001 through ADR-0005 are Accepted. ADR-0006 through ADR-0008 +> are Proposed and permit only unwired prototypes until accepted. A changed +> decision requires a new ADR and a supersedes link; do not silently rewrite +> historical rationale. ## Why ADRs Exist @@ -25,6 +28,9 @@ a new ADR and mark the old one "Superseded by ADR-NNNN." | [0003](0003-aegis-jcs-1-canonicalization.md) | `aegis-jcs-1` canonicalization scheme for `action_hash` | | [0004](0004-ed25519-receipt-signing.md) | Ed25519 for optional receipt signing | | [0005](0005-fail-closed-defaults.md) | Fail-closed defaults for unknown/ambiguous state | +| [0006](0006-cache-padded-spsc-event-fabric.md) | Cache-padded SPSC descriptor fabric (Proposed) | +| [0007](0007-sealed-generation-tagged-slab-pages.md) | Sealed generation-tagged slab-page ownership oracle (Proposed) | +| [0008](0008-append-only-published-prefix-slab-pages.md) | Append-only published-prefix slab pages (Proposed) | ## Security and Review @@ -33,7 +39,7 @@ An ADR affecting identity, tenant isolation, canonicalization, approvals, receip ## Creating an ADR ```bash -cp docs/adr/template.md docs/adr/0006-short-decision-name.md +cp docs/adr/template.md docs/adr/NNNN-short-decision-name.md ``` Replace every placeholder, link the issue/design, compare alternatives, name verification, and add the new record here and to MkDocs navigation. diff --git a/docs/current-vs-roadmap.md b/docs/current-vs-roadmap.md index a01b8f30..ff571300 100644 --- a/docs/current-vs-roadmap.md +++ b/docs/current-vs-roadmap.md @@ -45,6 +45,7 @@ Strongest current capabilities: - **Deploy** — gateway + sensor + egress + cage + llm-gateway + tool-broker Helm; single-writer SQLite default - **Postgres** — feature + migrations exist; read/write pool split with replica failover, DB-backed replay store, cross-replica correlation windows, and a live `postgres-integration` CI job now exercise it against a real Postgres instance (not just compile-checked) — but not the default HA production path; real cross-instance failover/load validation still pending - **OIDC console login** — self-service login/link flow (`src/src/oidc.rs`, `routes/oidc.rs`), gated on `AEGIS_OIDC_*` + `AEGIS_JWT_SECRET`, fails closed for unrecognized identities (no auto-provisioning); single-IdP-per-gateway, no SAML, no per-SSO-user attribution/revocation +- **v2 event primitives** — cache-padded SPSC, safe sealed-page oracle, and append-only published-prefix code are `current` in `lib/event`; packed Release/Acquire publication exposes an immediate immutable prefix, with safe differential, native stress, same-algorithm Loom, full Miri, defined ASan/TSan CI lanes, and a zero-allocation append-plus-resolve test. The code is unwired, carries no production traffic or protected evidence, and makes no performance claim. The production Thread-Per-Core event fabric remains `target`, neither `shadow` nor `qualified`. ### Roadmap / not done @@ -82,6 +83,7 @@ Strongest current capabilities: | Ban / quarantine centers | Partial | Stores/APIs; not every choke point + full UI. | | Postgres production mode | Roadmap / partial | Code path now CI-validated against a live Postgres instance (was compile-check only); SQLite single-writer is still the default deploy. | | Full Kubernetes multi-replica | Roadmap | Blocked on Postgres GA + broader Helm surface. | +| Thread-Per-Core event fabric | `current` prototypes / `target` fabric | Isolated SPSC, safe sealed-page, and append-only published-prefix prototypes exist. Packed Release/Acquire state publishes count, byte watermark, and closure for immediate immutable-prefix resolution; evidence includes a safe differential corpus, native stress, same-algorithm Loom, full Miri, defined ASan/TSan CI lanes, and a zero-allocation append-plus-resolve test. Production is neither `shadow` nor `qualified`, and no performance result exists. Blockers: green sanitizer CI artifacts, ADR acceptance/security review, composite ring reservation/admission, authenticated registry, bounded page rotation/outstanding pages, generation reuse/epochs, NUMA-owner reclamation, production shadow wiring, UBSan support, and qualification. | --- @@ -120,6 +122,7 @@ Do not claim that the current `main` already provides: - complete unknown-agent sandboxing (cage executor not on main) - multi-replica production Kubernetes on SQLite - full enterprise SSO (SAML, RBAC, multi-IdP) — a beta self-service OIDC login/link flow exists (single IdP, no per-user attribution/revocation), see Implementation Status +- production Thread-Per-Core ingestion, HCMT storage, or qualified million-event throughput; only unwired event primitives are `current` Those remain target architecture goals or incomplete waves (see Implementation Status). diff --git a/lib/event/Cargo.toml b/lib/event/Cargo.toml new file mode 100644 index 00000000..3bd4a486 --- /dev/null +++ b/lib/event/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "aegis-event" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Bounded descriptor transport primitives for the AegisAgent target data plane" + +[features] +default = [] +# Model-only replacement for std atomics/UnsafeCell. Never enable in a runtime +# artifact: Loom synchronization primitives require `loom::model` execution. +loom = ["dep:loom"] + +[dependencies] +crc32c.workspace = true +loom = { version = "=0.7.2", optional = true } + +[dev-dependencies] +criterion = "0.5" + +[[bench]] +name = "spsc_ring" +harness = false + +[[bench]] +name = "published_slab" +harness = false diff --git a/lib/event/benches/published_slab.rs b/lib/event/benches/published_slab.rs new file mode 100644 index 00000000..866ccfa1 --- /dev/null +++ b/lib/event/benches/published_slab.rs @@ -0,0 +1,35 @@ +use aegis_event::{PublishedSlabPage, SlabPageConfig}; +use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion}; + +const PAYLOAD: [u8; 256] = [0xa5; 256]; + +fn append_and_resolve(c: &mut Criterion) { + c.bench_function("published_slab_256b_append_and_resolve_diagnostic", |b| { + b.iter_batched( + || { + let page = PublishedSlabPage::new(SlabPageConfig { + arena_id: 1, + arena_generation: 1, + byte_capacity: PAYLOAD.len(), + descriptor_capacity: 1, + first_sequence: 0, + }) + .expect("benchmark page configuration is valid"); + page.split() + }, + |(mut writer, reader)| { + let descriptor = writer + .try_append(black_box(&PAYLOAD), 1, 0) + .expect("one preallocated frame fits"); + let payload = reader + .resolve(black_box(&descriptor)) + .expect("published benchmark frame resolves"); + black_box(payload.as_ref()); + }, + BatchSize::SmallInput, + ); + }); +} + +criterion_group!(benches, append_and_resolve); +criterion_main!(benches); diff --git a/lib/event/benches/spsc_ring.rs b/lib/event/benches/spsc_ring.rs new file mode 100644 index 00000000..ddffaff7 --- /dev/null +++ b/lib/event/benches/spsc_ring.rs @@ -0,0 +1,62 @@ +use aegis_event::{SpscRing, TelemetryDescriptor, TryPopError}; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn same_thread_round_trip(c: &mut Criterion) { + let ring = SpscRing::::new().expect("benchmark capacity is valid"); + let (mut producer, mut consumer) = ring.split(); + + c.bench_function("spsc_u64_same_thread_round_trip", |b| { + let mut sequence = 0_u64; + b.iter(|| { + producer + .try_push(sequence) + .expect("one push followed by one pop cannot fill the ring"); + let value = match consumer.try_pop() { + Ok(value) => value, + Err(TryPopError::Empty | TryPopError::Disconnected) => { + panic!("published value must be available") + } + }; + sequence = sequence.wrapping_add(1); + black_box(value) + }); + }); +} + +fn descriptor_same_thread_round_trip(c: &mut Criterion) { + let ring = SpscRing::::new().expect("benchmark capacity is valid"); + let (mut producer, mut consumer) = ring.split(); + + c.bench_function("spsc_descriptor_same_thread_round_trip", |b| { + let mut descriptor = TelemetryDescriptor { + sequence: 0, + arena_generation: 1, + arena_id: 1, + flags: 0, + offset: 0, + len: 256, + crc32c: 0, + schema_id: 1, + }; + b.iter(|| { + producer + .try_push(descriptor) + .expect("one push followed by one pop cannot fill the ring"); + let value = match consumer.try_pop() { + Ok(value) => value, + Err(TryPopError::Empty | TryPopError::Disconnected) => { + panic!("published descriptor must be available") + } + }; + descriptor.sequence = descriptor.sequence.wrapping_add(1); + black_box(value) + }); + }); +} + +criterion_group!( + benches, + same_thread_round_trip, + descriptor_same_thread_round_trip +); +criterion_main!(benches); diff --git a/lib/event/src/descriptor.rs b/lib/event/src/descriptor.rs new file mode 100644 index 00000000..2162058a --- /dev/null +++ b/lib/event/src/descriptor.rs @@ -0,0 +1,78 @@ +use std::{error::Error, fmt, ops::Range}; + +/// Maximum payload length addressable by one telemetry descriptor. +pub const MAX_FRAME_BYTES: usize = 1 << 20; + +/// Fixed descriptor passed through the SPSC event fabric. +/// +/// Payload bytes remain in an independently owned arena page. This structure is +/// an in-memory prototype ABI; it is not yet a stable wire or disk format. +#[repr(C, align(32))] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TelemetryDescriptor { + pub sequence: u64, + pub arena_generation: u32, + pub arena_id: u16, + pub flags: u16, + pub offset: u32, + pub len: u32, + pub crc32c: u32, + pub schema_id: u32, +} + +impl TelemetryDescriptor { + /// Validates the bounded payload range before an arena page is sliced. + pub fn checked_payload_range(&self, page_len: usize) -> Result, DescriptorError> { + let len = self.len as usize; + if len > MAX_FRAME_BYTES { + return Err(DescriptorError::FrameTooLarge { + len, + max: MAX_FRAME_BYTES, + }); + } + + let end = + self.offset + .checked_add(self.len) + .ok_or(DescriptorError::OffsetLengthOverflow { + offset: self.offset, + len: self.len, + })? as usize; + if end > page_len { + return Err(DescriptorError::OutOfBounds { end, page_len }); + } + + Ok(self.offset as usize..end) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DescriptorError { + FrameTooLarge { len: usize, max: usize }, + OffsetLengthOverflow { offset: u32, len: u32 }, + OutOfBounds { end: usize, page_len: usize }, +} + +impl fmt::Display for DescriptorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::FrameTooLarge { len, max } => { + write!(f, "telemetry frame length {len} exceeds limit {max}") + } + Self::OffsetLengthOverflow { offset, len } => { + write!( + f, + "telemetry descriptor offset {offset} + length {len} overflows" + ) + } + Self::OutOfBounds { end, page_len } => { + write!( + f, + "telemetry descriptor ends at {end}, beyond page length {page_len}" + ) + } + } + } +} + +impl Error for DescriptorError {} diff --git a/lib/event/src/lib.rs b/lib/event/src/lib.rs new file mode 100644 index 00000000..f025f460 --- /dev/null +++ b/lib/event/src/lib.rs @@ -0,0 +1,30 @@ +//! Bounded event-transfer primitives for the target AegisAgent data plane. +//! +//! This crate contains unwired prototypes governed by Proposed ADR-0006, +//! ADR-0007, and ADR-0008. It does not carry production telemetry or protected +//! evidence. +//! +//! The optional `loom` feature is model-checking infrastructure, not a runtime +//! configuration. Loom-backed primitives must execute only inside +//! `loom::model`. + +#![forbid(unsafe_op_in_unsafe_fn)] + +mod descriptor; +mod published_slab; +mod ring; +mod slab; + +pub use descriptor::{DescriptorError, TelemetryDescriptor, MAX_FRAME_BYTES}; +pub use published_slab::{ + PublishedPayload, PublishedSlabLayout, PublishedSlabPage, PublishedSlabReadError, + PublishedSlabReader, PublishedSlabWriter, +}; +pub use ring::{ + Consumer, Producer, RingConfigError, RingLayout, SpscRing, TryPopError, TryPushError, + CACHE_LINE_BYTES, +}; +pub use slab::{ + SealedSlabPage, SlabAppendError, SlabConfigError, SlabPageBuilder, SlabPageConfig, + SlabPageReader, SlabReadError, SlabResource, MAX_SLAB_DESCRIPTORS, MAX_SLAB_PAGE_BYTES, +}; diff --git a/lib/event/src/published_slab.rs b/lib/event/src/published_slab.rs new file mode 100644 index 00000000..6dfebc5d --- /dev/null +++ b/lib/event/src/published_slab.rs @@ -0,0 +1,1223 @@ +//! Append-only slab pages with an atomically published immutable prefix. +//! +//! This is a current, unwired prototype under Proposed ADR-0008. It has no +//! registry, reset/reuse, epoch reclamation, NUMA allocation, protected-evidence +//! authority, or production integration. +//! +//! # Safety invariants +//! +//! - Construction fills fixed-length byte and descriptor cell vectors before +//! sharing the page. Their lengths, capacities, and backing addresses never +//! change afterward. +//! - `split` creates exactly one non-cloneable writer and reader. Both endpoints +//! are `!Sync`; the writer mutates only through `&mut self`. +//! - The writer initializes each payload byte cell and descriptor cell exactly +//! once, in append order. It never writes a committed cell. +//! - Every range, index, and integer conversion is validated before the first +//! write. The mutation interval contains no fallible or indexing operation; +//! caught unwinding poisons the writer so a partial suffix cannot be reused. +//! - A `Release` store of the packed publication state follows complete payload +//! and descriptor initialization. A reader touches a descriptor cell only +//! after an `Acquire` snapshot proves its index is committed. +//! - The same state snapshot includes a byte watermark. A reader constructs a +//! payload view only from an exact canonical descriptor whose checked range +//! is contained in that watermark. +//! - Native `ByteCell` and `DescriptorCell` wrappers are transparent over +//! `UnsafeCell>`, which has `T`'s representation. Raw pointers +//! remain allocation-derived, aligned, and bounded by prevalidated slices. +//! - A native payload slice covers initialized committed `u8` cells only. Later +//! appends target a disjoint suffix, and committed bytes are immutable for the +//! rest of the allocation lifetime. +//! - Returned slices borrow the reader, whose page-level `Arc` keeps the fixed +//! allocation alive. Final drop occurs only after both endpoints and all +//! reader borrows are gone. +//! - Uncommitted cells contain `MaybeUninit` or +//! `MaybeUninit` and have no drop obligation. This module +//! has no persistent, cross-process, or endian ABI. + +use std::{ + cell::Cell, + error::Error, + fmt, + marker::PhantomData, + mem::{align_of, size_of, MaybeUninit}, + ops::Deref, +}; + +#[cfg(feature = "loom")] +use loom::{ + cell::UnsafeCell, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, +}; +#[cfg(not(feature = "loom"))] +use std::{ + cell::UnsafeCell, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, +}; + +use crate::{ + slab::validate_config, DescriptorError, SlabAppendError, SlabConfigError, SlabPageConfig, + SlabResource, TelemetryDescriptor, MAX_FRAME_BYTES, +}; + +const PUBLISHED_BYTES_MASK: u64 = u32::MAX as u64; +const PUBLISHED_COUNT_SHIFT: u32 = 32; +const PUBLISHED_COUNT_MASK: u64 = (1_u64 << 17) - 1; +const WRITER_CLOSED_BIT: u64 = 1_u64 << 63; +const VALID_STATE_MASK: u64 = + PUBLISHED_BYTES_MASK | (PUBLISHED_COUNT_MASK << PUBLISHED_COUNT_SHIFT) | WRITER_CLOSED_BIT; +const RESERVED_STATE_MASK: u64 = !VALID_STATE_MASK; + +#[derive(Clone, Copy)] +struct PublicationSnapshot { + published_bytes: u32, + published_count: u32, + writer_closed: bool, + reserved_bits: u64, +} + +impl PublicationSnapshot { + const fn decode(raw: u64) -> Self { + Self { + published_bytes: (raw & PUBLISHED_BYTES_MASK) as u32, + published_count: ((raw >> PUBLISHED_COUNT_SHIFT) & PUBLISHED_COUNT_MASK) as u32, + writer_closed: raw & WRITER_CLOSED_BIT != 0, + reserved_bits: raw & RESERVED_STATE_MASK, + } + } +} + +const fn encode_publication_state(published_count: u32, published_bytes: u32, closed: bool) -> u64 { + let state = (published_bytes as u64) | ((published_count as u64) << PUBLISHED_COUNT_SHIFT); + if closed { + state | WRITER_CLOSED_BIT + } else { + state + } +} + +#[repr(align(64))] +struct PaddedPublicationState(AtomicU64); + +#[repr(transparent)] +struct ByteCell(UnsafeCell>); + +impl ByteCell { + fn uninit() -> Self { + Self(UnsafeCell::new(MaybeUninit::uninit())) + } + + #[cfg(feature = "loom")] + fn write(&self, value: u8) { + self.0.with_mut(|cell| { + // SAFETY: The sole writer owns this unpublished cell, writes it + // exactly once, and publishes only after every byte is initialized. + unsafe { (*cell).write(value) }; + }); + } + + #[cfg(feature = "loom")] + fn read(&self) -> u8 { + self.0.with(|cell| { + // SAFETY: The reader reached this cell only after an Acquire state + // snapshot proved the canonical byte range was initialized. + unsafe { *(*cell).assume_init_ref() } + }) + } + + #[cfg(all(test, not(feature = "loom")))] + fn write_for_test(&self, value: u8) { + // SAFETY: Each caller in this module's native tests proves it has no + // concurrent cell access or outstanding view. Tests use the hook either + // to inject committed-byte corruption or an unreachable partial suffix; + // production code never calls it. + unsafe { self.0.get().write(MaybeUninit::new(value)) }; + } +} + +// SAFETY: Shared access is restricted to the single-assignment publication +// protocol above. The writer alone mutates an unpublished cell; readers access +// only committed cells after an Acquire snapshot and committed cells never +// change. +unsafe impl Sync for ByteCell {} + +#[repr(transparent)] +struct DescriptorCell(UnsafeCell>); + +impl DescriptorCell { + fn uninit() -> Self { + Self(UnsafeCell::new(MaybeUninit::uninit())) + } + + #[cfg(not(feature = "loom"))] + fn write(&self, descriptor: TelemetryDescriptor) { + // SAFETY: The sole writer owns this unpublished descriptor slot and + // initializes it exactly once before the Release publication store. + unsafe { (*self.0.get()).write(descriptor) }; + } + + #[cfg(feature = "loom")] + fn write(&self, descriptor: TelemetryDescriptor) { + self.0.with_mut(|slot| { + // SAFETY: The same single-writer and publication proof as the native + // path applies; Loom tracks exclusive access to this modeled cell. + unsafe { (*slot).write(descriptor) }; + }); + } + + #[cfg(not(feature = "loom"))] + fn read(&self) -> TelemetryDescriptor { + // SAFETY: An Acquire snapshot proved this slot was initialized, and the + // writer never mutates a committed descriptor. The descriptor is Copy. + unsafe { *(*self.0.get()).assume_init_ref() } + } + + #[cfg(feature = "loom")] + fn read(&self) -> TelemetryDescriptor { + self.0.with(|slot| { + // SAFETY: The same committed-slot proof as the native path applies; + // Loom tracks immutable access to this modeled cell. + unsafe { *(*slot).assume_init_ref() } + }) + } +} + +// SAFETY: The sole writer initializes each descriptor cell once before Release +// publication. Readers access only slots proven committed by an Acquire state +// snapshot, and committed descriptors are never changed. +unsafe impl Sync for DescriptorCell {} + +#[repr(C)] +struct PublishedInner { + publication: PaddedPublicationState, + arena_id: u16, + arena_generation: u32, + byte_capacity: usize, + descriptor_capacity: usize, + first_sequence: u64, + bytes: Vec, + descriptors: Vec, +} + +impl PublishedInner { + fn snapshot_is_valid(&self, snapshot: PublicationSnapshot) -> bool { + let published_count = snapshot.published_count as usize; + let published_bytes = snapshot.published_bytes as usize; + let empty_fields_disagree = (published_count == 0) != (published_bytes == 0); + + snapshot.reserved_bits == 0 + && published_count <= self.descriptor_capacity + && published_bytes <= self.byte_capacity + && published_bytes >= published_count + && !empty_fields_disagree + } + + fn load_snapshot(&self) -> Result { + let snapshot = PublicationSnapshot::decode(self.publication.0.load(Ordering::Acquire)); + if !self.snapshot_is_valid(snapshot) { + return Err(PublishedSlabReadError::PublicationStateInvalid); + } + + Ok(snapshot) + } + + #[cfg(not(feature = "loom"))] + fn payload( + &self, + range: std::ops::Range, + ) -> Result, PublishedSlabReadError> { + let cells = self + .bytes + .get(range) + .ok_or(PublishedSlabReadError::PublicationStateInvalid)?; + if cells.is_empty() { + return Err(PublishedSlabReadError::PublicationStateInvalid); + } + + // SAFETY: `ByteCell` is transparent over + // `UnsafeCell>`, so this allocation-derived pointer is + // aligned for `u8`. The canonical non-empty range was checked against + // the Acquire-published byte watermark. Every cell in it was initialized + // before that Release store, will never be written again, and remains + // alive for the returned borrow through this page-owning reader. + let bytes = unsafe { std::slice::from_raw_parts(cells.as_ptr().cast::(), cells.len()) }; + Ok(PublishedPayload { + bytes, + _reader: PhantomData, + }) + } + + #[cfg(feature = "loom")] + fn payload( + &self, + range: std::ops::Range, + ) -> Result, PublishedSlabReadError> { + let cells = self + .bytes + .get(range) + .ok_or(PublishedSlabReadError::PublicationStateInvalid)?; + if cells.is_empty() { + return Err(PublishedSlabReadError::PublicationStateInvalid); + } + + // Loom pointers may not escape `UnsafeCell::with`; copy each modeled + // byte while its access guard is active. This model-only allocation is + // excluded from the native zero-copy boundary and documented in ADR-0008. + let bytes = cells.iter().map(ByteCell::read).collect(); + Ok(PublishedPayload { + bytes, + _reader: PhantomData, + }) + } +} + +/// Setup handle for one fixed-capacity append-only page. +pub struct PublishedSlabPage { + inner: Arc, +} + +impl PublishedSlabPage { + pub fn new(config: SlabPageConfig) -> Result { + validate_config(config)?; + + let mut bytes = Vec::new(); + bytes.try_reserve_exact(config.byte_capacity).map_err(|_| { + SlabConfigError::AllocationFailed { + resource: SlabResource::PayloadBytes, + requested: config.byte_capacity, + } + })?; + bytes.resize_with(config.byte_capacity, ByteCell::uninit); + + let mut descriptors = Vec::new(); + descriptors + .try_reserve_exact(config.descriptor_capacity) + .map_err(|_| SlabConfigError::AllocationFailed { + resource: SlabResource::Descriptors, + requested: config.descriptor_capacity, + })?; + descriptors.resize_with(config.descriptor_capacity, DescriptorCell::uninit); + + Ok(Self { + inner: Arc::new(PublishedInner { + publication: PaddedPublicationState(AtomicU64::new(0)), + arena_id: config.arena_id, + arena_generation: config.arena_generation, + byte_capacity: config.byte_capacity, + descriptor_capacity: config.descriptor_capacity, + first_sequence: config.first_sequence, + bytes, + descriptors, + }), + }) + } + + pub fn split(self) -> (PublishedSlabWriter, PublishedSlabReader) { + let Self { inner } = self; + let reader_inner = Arc::clone(&inner); + let first_sequence = inner.first_sequence; + + ( + PublishedSlabWriter { + inner, + used_bytes: 0, + descriptor_count: 0, + next_sequence: first_sequence, + poisoned: false, + _not_sync: PhantomData, + }, + PublishedSlabReader { + inner: reader_inner, + _not_sync: PhantomData, + }, + ) + } + + pub fn layout(&self) -> PublishedSlabLayout { + PublishedSlabLayout { + publication_state_alignment: align_of::(), + publication_state_size: size_of::(), + byte_cell_size: size_of::(), + byte_cell_alignment: align_of::(), + descriptor_cell_size: size_of::(), + descriptor_cell_alignment: align_of::(), + } + } + + pub fn arena_id(&self) -> u16 { + self.inner.arena_id + } + + pub fn arena_generation(&self) -> u32 { + self.inner.arena_generation + } + + pub fn byte_capacity(&self) -> usize { + self.inner.byte_capacity + } + + pub fn descriptor_capacity(&self) -> usize { + self.inner.descriptor_capacity + } +} + +impl fmt::Debug for PublishedSlabPage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PublishedSlabPage") + .field("arena_id", &self.arena_id()) + .field("arena_generation", &self.arena_generation()) + .field("byte_capacity", &self.byte_capacity()) + .field("descriptor_capacity", &self.descriptor_capacity()) + .finish() + } +} + +/// Single-owner append capability for one published-prefix page. +/// +/// The endpoint intentionally implements neither `Sync` nor `Clone`: +/// +/// ```compile_fail +/// fn require_sync() {} +/// require_sync::(); +/// ``` +/// +/// ```compile_fail +/// fn require_clone() {} +/// require_clone::(); +/// ``` +pub struct PublishedSlabWriter { + inner: Arc, + used_bytes: u32, + descriptor_count: u32, + next_sequence: u64, + poisoned: bool, + _not_sync: PhantomData>, +} + +impl PublishedSlabWriter { + /// Copies, initializes, and Release-publishes one complete payload. + /// + /// FlatBuffer verification and redaction are upstream preconditions. Every + /// returned error is transactional; no descriptor escapes before the state + /// publication store. + pub fn try_append( + &mut self, + payload: &[u8], + schema_id: u32, + flags: u16, + ) -> Result { + if self.poisoned { + return Err(SlabAppendError::WriterPoisoned); + } + + let len = payload.len(); + if len == 0 { + return Err(SlabAppendError::EmptyPayload); + } + if len > MAX_FRAME_BYTES { + return Err(SlabAppendError::FrameTooLarge { + len, + maximum: MAX_FRAME_BYTES, + }); + } + + let descriptor_index = self.descriptor_count as usize; + if descriptor_index >= self.inner.descriptor_capacity { + return Err(SlabAppendError::DescriptorCapacityExhausted { + capacity: self.inner.descriptor_capacity, + }); + } + + let offset = self.used_bytes as usize; + let remaining = + self.inner + .byte_capacity + .checked_sub(offset) + .ok_or(SlabAppendError::PageFull { + requested: len, + remaining: 0, + })?; + let end = offset + .checked_add(len) + .ok_or(SlabAppendError::OffsetLengthOverflow { offset, len })?; + if len > remaining || end > self.inner.byte_capacity { + return Err(SlabAppendError::PageFull { + requested: len, + remaining, + }); + } + + let descriptor_offset = + u32::try_from(offset).map_err(|_| SlabAppendError::OffsetNotAddressable { offset })?; + let descriptor_len = + u32::try_from(len).map_err(|_| SlabAppendError::LengthNotAddressable { len })?; + let published_bytes = u32::try_from(end) + .map_err(|_| SlabAppendError::OffsetNotAddressable { offset: end })?; + let next_count = self.descriptor_count + 1; + let next_sequence = self.next_sequence.wrapping_add(1); + let checksum = crc32c::crc32c(payload); + let descriptor = TelemetryDescriptor { + sequence: self.next_sequence, + arena_generation: self.inner.arena_generation, + arena_id: self.inner.arena_id, + flags, + offset: descriptor_offset, + len: descriptor_len, + crc32c: checksum, + schema_id, + }; + let next_state = encode_publication_state(next_count, published_bytes, false); + + // Stage every bounds-checked reference and native pointer before the + // first cell is touched. No indexing or fallible work follows. + let payload_cells = self + .inner + .bytes + .get(offset..end) + .ok_or(SlabAppendError::PageFull { + requested: len, + remaining, + })?; + let descriptor_cell = self.inner.descriptors.get(descriptor_index).ok_or( + SlabAppendError::DescriptorCapacityExhausted { + capacity: self.inner.descriptor_capacity, + }, + )?; + + #[cfg(not(feature = "loom"))] + let payload_destination = payload_cells.as_ptr().cast::().cast_mut(); + + self.poisoned = true; + + #[cfg(not(feature = "loom"))] + { + // SAFETY: Source and destination both contain `len` initialized-byte + // positions. The destination is a checked never-written suffix. A + // source borrowed from this page can only be an earlier committed + // range, which is disjoint from the append-only destination. + unsafe { + std::ptr::copy_nonoverlapping(payload.as_ptr(), payload_destination, len); + } + } + + #[cfg(feature = "loom")] + for (cell, value) in payload_cells.iter().zip(payload.iter().copied()) { + cell.write(value); + } + + descriptor_cell.write(descriptor); + self.inner + .publication + .0 + .store(next_state, Ordering::Release); + self.used_bytes = published_bytes; + self.descriptor_count = next_count; + self.next_sequence = next_sequence; + self.poisoned = false; + + Ok(descriptor) + } + + pub fn arena_id(&self) -> u16 { + self.inner.arena_id + } + + pub fn arena_generation(&self) -> u32 { + self.inner.arena_generation + } + + pub fn byte_capacity(&self) -> usize { + self.inner.byte_capacity + } + + pub fn descriptor_capacity(&self) -> usize { + self.inner.descriptor_capacity + } + + pub fn used_bytes(&self) -> usize { + self.used_bytes as usize + } + + pub fn published_count(&self) -> usize { + self.descriptor_count as usize + } + + pub fn remaining_bytes(&self) -> usize { + self.inner.byte_capacity - self.used_bytes as usize + } + + pub fn remaining_descriptors(&self) -> usize { + self.inner.descriptor_capacity - self.descriptor_count as usize + } + + pub fn next_sequence(&self) -> u64 { + self.next_sequence + } + + pub fn is_poisoned(&self) -> bool { + self.poisoned + } +} + +impl Drop for PublishedSlabWriter { + fn drop(&mut self) { + // The atomic word, rather than writer-private staged cursors, is the + // authoritative committed prefix. `fetch_or` cannot regress or expose a + // partially written suffix if unwinding interrupts an append. + self.inner + .publication + .0 + .fetch_or(WRITER_CLOSED_BIT, Ordering::Release); + } +} + +impl fmt::Debug for PublishedSlabWriter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PublishedSlabWriter") + .field("arena_id", &self.arena_id()) + .field("arena_generation", &self.arena_generation()) + .field("byte_capacity", &self.byte_capacity()) + .field("descriptor_capacity", &self.descriptor_capacity()) + .field("used_bytes", &self.used_bytes()) + .field("published_count", &self.published_count()) + .field("next_sequence", &self.next_sequence()) + .field("poisoned", &self.is_poisoned()) + .finish() + } +} + +/// Single-owner reader capability for one atomically published prefix. +/// +/// The endpoint intentionally implements neither `Sync` nor `Clone`: +/// +/// ```compile_fail +/// fn require_sync() {} +/// require_sync::(); +/// ``` +/// +/// ```compile_fail +/// fn require_clone() {} +/// require_clone::(); +/// ``` +pub struct PublishedSlabReader { + inner: Arc, + _not_sync: PhantomData>, +} + +impl PublishedSlabReader { + /// Resolves one exact canonical descriptor to a page-backed payload view. + pub fn resolve( + &self, + descriptor: &TelemetryDescriptor, + ) -> Result, PublishedSlabReadError> { + if descriptor.arena_id != self.inner.arena_id { + return Err(PublishedSlabReadError::ArenaIdMismatch { + page: self.inner.arena_id, + descriptor: descriptor.arena_id, + }); + } + if descriptor.arena_generation != self.inner.arena_generation { + return Err(PublishedSlabReadError::GenerationMismatch { + page: self.inner.arena_generation, + descriptor: descriptor.arena_generation, + }); + } + + let snapshot = self.inner.load_snapshot()?; + let distance = descriptor.sequence.wrapping_sub(self.inner.first_sequence); + if distance >= snapshot.published_count as u64 { + return Err(PublishedSlabReadError::UnpublishedSequence { + first: self.inner.first_sequence, + committed: snapshot.published_count as usize, + descriptor: descriptor.sequence, + }); + } + + let index = distance as usize; + let canonical = self + .inner + .descriptors + .get(index) + .ok_or(PublishedSlabReadError::PublicationStateInvalid)? + .read(); + if canonical != *descriptor { + return Err(PublishedSlabReadError::DescriptorMismatch { + sequence: descriptor.sequence, + }); + } + if canonical.len == 0 { + return Err(PublishedSlabReadError::EmptyCanonicalPayload { + sequence: canonical.sequence, + }); + } + + let range = canonical + .checked_payload_range(snapshot.published_bytes as usize) + .map_err(PublishedSlabReadError::Descriptor)?; + let payload = self.inner.payload(range)?; + if crc32c::crc32c(payload.as_ref()) != canonical.crc32c { + return Err(PublishedSlabReadError::CrcMismatch { + sequence: canonical.sequence, + }); + } + + Ok(payload) + } + + pub fn arena_id(&self) -> u16 { + self.inner.arena_id + } + + pub fn arena_generation(&self) -> u32 { + self.inner.arena_generation + } + + pub fn byte_capacity(&self) -> usize { + self.inner.byte_capacity + } + + pub fn descriptor_capacity(&self) -> usize { + self.inner.descriptor_capacity + } + + pub fn published_count(&self) -> usize { + PublicationSnapshot::decode(self.inner.publication.0.load(Ordering::Acquire)) + .published_count as usize + } + + pub fn published_bytes(&self) -> usize { + PublicationSnapshot::decode(self.inner.publication.0.load(Ordering::Acquire)) + .published_bytes as usize + } + + pub fn is_writer_closed(&self) -> bool { + PublicationSnapshot::decode(self.inner.publication.0.load(Ordering::Acquire)).writer_closed + } +} + +impl fmt::Debug for PublishedSlabReader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let snapshot = + PublicationSnapshot::decode(self.inner.publication.0.load(Ordering::Acquire)); + f.debug_struct("PublishedSlabReader") + .field("arena_id", &self.arena_id()) + .field("arena_generation", &self.arena_generation()) + .field("byte_capacity", &self.byte_capacity()) + .field("descriptor_capacity", &self.descriptor_capacity()) + .field("published_count", &snapshot.published_count) + .field("published_bytes", &snapshot.published_bytes) + .field("writer_closed", &snapshot.writer_closed) + .field("state_valid", &self.inner.snapshot_is_valid(snapshot)) + .finish() + } +} + +/// Borrowed native page view; Loom owns a disclosed model-only byte copy. +#[must_use] +pub struct PublishedPayload<'reader> { + #[cfg(not(feature = "loom"))] + bytes: &'reader [u8], + #[cfg(feature = "loom")] + bytes: Vec, + _reader: PhantomData<&'reader PublishedSlabReader>, +} + +impl AsRef<[u8]> for PublishedPayload<'_> { + fn as_ref(&self) -> &[u8] { + #[cfg(not(feature = "loom"))] + { + self.bytes + } + #[cfg(feature = "loom")] + { + &self.bytes + } + } +} + +impl Deref for PublishedPayload<'_> { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.as_ref() + } +} + +impl PartialEq for PublishedPayload<'_> { + fn eq(&self, other: &Self) -> bool { + self.as_ref() == other.as_ref() + } +} + +impl Eq for PublishedPayload<'_> {} + +impl fmt::Debug for PublishedPayload<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PublishedPayload") + .field("len", &self.len()) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PublishedSlabLayout { + pub publication_state_alignment: usize, + pub publication_state_size: usize, + pub byte_cell_size: usize, + pub byte_cell_alignment: usize, + pub descriptor_cell_size: usize, + pub descriptor_cell_alignment: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PublishedSlabReadError { + ArenaIdMismatch { + page: u16, + descriptor: u16, + }, + GenerationMismatch { + page: u32, + descriptor: u32, + }, + PublicationStateInvalid, + UnpublishedSequence { + first: u64, + committed: usize, + descriptor: u64, + }, + DescriptorMismatch { + sequence: u64, + }, + EmptyCanonicalPayload { + sequence: u64, + }, + Descriptor(DescriptorError), + CrcMismatch { + sequence: u64, + }, +} + +impl fmt::Display for PublishedSlabReadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ArenaIdMismatch { page, descriptor } => write!( + f, + "descriptor arena ID {descriptor} does not match published slab page {page}" + ), + Self::GenerationMismatch { page, descriptor } => write!( + f, + "descriptor generation {descriptor} does not match published slab page {page}" + ), + Self::PublicationStateInvalid => { + write!(f, "published slab page state is invalid") + } + Self::UnpublishedSequence { + first, + committed, + descriptor, + } => write!( + f, + "descriptor sequence {descriptor} is outside published slab sequence {first} with committed count {committed}" + ), + Self::DescriptorMismatch { sequence } => write!( + f, + "descriptor metadata for sequence {sequence} does not match the published page table" + ), + Self::EmptyCanonicalPayload { sequence } => write!( + f, + "published slab canonical payload is empty for sequence {sequence}" + ), + Self::Descriptor(error) => { + write!(f, "invalid published slab descriptor range: {error}") + } + Self::CrcMismatch { sequence } => { + write!(f, "published slab payload CRC32C mismatch for sequence {sequence}") + } + } + } +} + +impl Error for PublishedSlabReadError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Descriptor(error) => Some(error), + _ => None, + } + } +} + +#[cfg(all(test, not(feature = "loom")))] +mod native_tests { + use super::{ + encode_publication_state, Arc, Ordering, PublicationSnapshot, PublishedSlabPage, + PublishedSlabReadError, + }; + use crate::{ + SlabAppendError, SlabPageConfig, TelemetryDescriptor, MAX_SLAB_DESCRIPTORS, + MAX_SLAB_PAGE_BYTES, + }; + + fn config(byte_capacity: usize, descriptor_capacity: usize) -> SlabPageConfig { + SlabPageConfig { + arena_id: 5, + arena_generation: 8, + byte_capacity, + descriptor_capacity, + first_sequence: 13, + } + } + + #[test] + fn packed_state_fields_cover_the_exact_page_limits_without_reserved_bits() { + let snapshot = PublicationSnapshot::decode(encode_publication_state( + MAX_SLAB_DESCRIPTORS as u32, + MAX_SLAB_PAGE_BYTES as u32, + true, + )); + + assert_eq!(snapshot.published_count as usize, MAX_SLAB_DESCRIPTORS); + assert_eq!(snapshot.published_bytes as usize, MAX_SLAB_PAGE_BYTES); + assert!(snapshot.writer_closed); + assert_eq!(snapshot.reserved_bits, 0); + } + + #[test] + fn published_resolve_does_not_change_the_page_arc_count() { + let page = PublishedSlabPage::new(config(16, 1)).expect("bounded page"); + let (mut writer, reader) = page.split(); + let descriptor = writer.try_append(b"payload", 1, 0).expect("payload fits"); + let before = Arc::strong_count(&reader.inner); + + for _ in 0..32 { + let payload = reader.resolve(&descriptor).expect("descriptor resolves"); + assert_eq!(payload.as_ref(), b"payload"); + } + + assert_eq!(Arc::strong_count(&reader.inner), before); + } + + #[test] + fn committed_byte_corruption_fails_closed_without_disclosing_checksums() { + let page = PublishedSlabPage::new(config(16, 1)).expect("bounded page"); + let (mut writer, reader) = page.split(); + let descriptor = writer + .try_append(b"raw-secret", 1, 0) + .expect("payload fits"); + reader.inner.bytes[0].write_for_test(b'X'); + + let error = reader + .resolve(&descriptor) + .expect_err("corrupt committed bytes must fail"); + assert_eq!(error, PublishedSlabReadError::CrcMismatch { sequence: 13 }); + let message = error.to_string(); + assert!(!message.contains(&format!("{:08x}", descriptor.crc32c))); + assert!(!message.contains("raw-secret")); + } + + #[test] + fn poisoned_partial_suffix_is_never_published_or_reused() { + let page = PublishedSlabPage::new(config(8, 1)).expect("bounded page"); + let (mut writer, reader) = page.split(); + + writer.poisoned = true; + writer.inner.bytes[0].write_for_test(b'X'); + assert_eq!( + writer.try_append(b"replacement", 1, 0), + Err(SlabAppendError::WriterPoisoned) + ); + assert_eq!(reader.published_count(), 0); + + let unreachable = TelemetryDescriptor { + sequence: 13, + arena_generation: 8, + arena_id: 5, + flags: 0, + offset: 0, + len: 1, + crc32c: crc32c::crc32c(b"X"), + schema_id: 1, + }; + assert_eq!( + reader.resolve(&unreachable), + Err(PublishedSlabReadError::UnpublishedSequence { + first: 13, + committed: 0, + descriptor: 13, + }) + ); + + drop(writer); + assert!(reader.is_writer_closed()); + assert_eq!(reader.published_count(), 0); + assert_eq!(reader.published_bytes(), 0); + } + + #[test] + fn writer_drop_closes_atomic_prefix_even_if_private_cursors_are_stale() { + let page = PublishedSlabPage::new(config(16, 1)).expect("bounded page"); + let (mut writer, reader) = page.split(); + let descriptor = writer.try_append(b"atomic", 1, 0).expect("payload fits"); + + // Simulate an unwind immediately after the Release publication and + // before the staged cursor assignments. Drop must consult the atomic + // prefix and must never regress it to these stale private values. + writer.used_bytes = 0; + writer.descriptor_count = 0; + writer.next_sequence = 13; + writer.poisoned = true; + drop(writer); + + assert!(reader.is_writer_closed()); + assert_eq!(reader.published_count(), 1); + assert_eq!(reader.published_bytes(), 6); + assert_eq!( + reader + .resolve(&descriptor) + .expect("atomic prefix survives drop") + .as_ref(), + b"atomic" + ); + } + + #[test] + fn malformed_packed_state_fails_before_any_descriptor_cell_read() { + let page = PublishedSlabPage::new(config(8, 1)).expect("bounded page"); + let (writer, reader) = page.split(); + let forged = TelemetryDescriptor { + sequence: 13, + arena_generation: 8, + arena_id: 5, + flags: 0, + offset: 0, + len: 1, + crc32c: 0, + schema_id: 1, + }; + + let reserved_bit = 1_u64 << 49; + reader.inner.publication.0.store( + encode_publication_state(1, 1, false) | reserved_bit, + Ordering::Release, + ); + assert_eq!( + reader.resolve(&forged), + Err(PublishedSlabReadError::PublicationStateInvalid) + ); + + reader + .inner + .publication + .0 + .store(encode_publication_state(2, 2, false), Ordering::Release); + assert_eq!( + reader.resolve(&forged), + Err(PublishedSlabReadError::PublicationStateInvalid) + ); + + drop(writer); + } + + #[test] + fn endpoints_can_move_to_their_owner_threads() { + fn assert_send() {} + + assert_send::(); + assert_send::(); + } +} + +#[cfg(all(test, feature = "loom"))] +mod loom_tests { + use super::{PublishedSlabPage, PublishedSlabReadError}; + use crate::{SlabPageConfig, SpscRing, TelemetryDescriptor, TryPopError, TryPushError}; + use loom::thread; + + fn config(byte_capacity: usize, descriptor_capacity: usize) -> SlabPageConfig { + SlabPageConfig { + arena_id: 5, + arena_generation: 8, + byte_capacity, + descriptor_capacity, + first_sequence: u64::MAX, + } + } + + fn descriptor(sequence: u64, offset: u32, payload: &[u8]) -> TelemetryDescriptor { + TelemetryDescriptor { + sequence, + arena_generation: 8, + arena_id: 5, + flags: 1, + offset, + len: payload.len() as u32, + crc32c: crc32c::crc32c(payload), + schema_id: 7, + } + } + + #[test] + fn loom_published_resolve_observes_nothing_or_a_complete_first_frame() { + loom::model(|| { + let page = PublishedSlabPage::new(config(2, 2)).expect("bounded model page"); + let (mut writer, reader) = page.split(); + let expected = descriptor(u64::MAX, 0, b"A"); + + let writer_thread = thread::spawn(move || { + let actual = writer.try_append(b"A", 7, 1).expect("model append fits"); + assert_eq!(actual, expected); + }); + let reader_thread = thread::spawn(move || match reader.resolve(&expected) { + Ok(payload) => assert_eq!(payload.as_ref(), b"A"), + Err(PublishedSlabReadError::UnpublishedSequence { + first, + committed, + descriptor, + }) => { + assert_eq!(first, u64::MAX); + assert_eq!(committed, 0); + assert_eq!(descriptor, u64::MAX); + } + Err(error) => panic!("unexpected publication result: {error}"), + }); + + writer_thread.join().expect("model writer succeeds"); + reader_thread.join().expect("model reader succeeds"); + }); + } + + #[test] + fn loom_published_later_state_also_publishes_every_earlier_slot() { + loom::model(|| { + let page = PublishedSlabPage::new(config(2, 2)).expect("bounded model page"); + let (mut writer, reader) = page.split(); + let first = descriptor(u64::MAX, 0, b"A"); + let second = descriptor(0, 1, b"B"); + + let writer_thread = thread::spawn(move || { + writer.try_append(b"A", 7, 1).expect("first append fits"); + thread::yield_now(); + writer.try_append(b"B", 7, 1).expect("second append fits"); + }); + let reader_thread = thread::spawn(move || { + let first_attempt = reader.resolve(&first); + let second_attempt = reader.resolve(&second); + + match second_attempt { + Ok(second_payload) => { + assert_eq!(second_payload.as_ref(), b"B"); + assert_eq!( + reader + .resolve(&first) + .expect("later state publishes prefix") + .as_ref(), + b"A" + ); + } + Err(PublishedSlabReadError::UnpublishedSequence { committed, .. }) => { + assert!(committed <= 1); + } + Err(error) => panic!("unexpected second-frame result: {error}"), + } + match first_attempt { + Ok(first_payload) => assert_eq!(first_payload.as_ref(), b"A"), + Err(PublishedSlabReadError::UnpublishedSequence { committed, .. }) => { + assert_eq!(committed, 0); + } + Err(error) => panic!("unexpected first-frame result: {error}"), + } + + if reader.is_writer_closed() { + assert_eq!(reader.published_count(), 2); + assert_eq!(reader.published_bytes(), 2); + assert_eq!(reader.resolve(&first).expect("closed first").as_ref(), b"A"); + assert_eq!( + reader.resolve(&second).expect("closed second").as_ref(), + b"B" + ); + } + }); + + writer_thread.join().expect("model writer succeeds"); + reader_thread.join().expect("model reader succeeds"); + }); + } + + #[test] + fn loom_published_writer_drop_closes_the_complete_final_prefix() { + loom::model(|| { + let page = PublishedSlabPage::new(config(2, 2)).expect("bounded model page"); + let (mut writer, reader) = page.split(); + let first = descriptor(u64::MAX, 0, b"A"); + let second = descriptor(0, 1, b"B"); + + let writer_thread = thread::spawn(move || { + writer.try_append(b"A", 7, 1).expect("first append fits"); + writer.try_append(b"B", 7, 1).expect("second append fits"); + }); + writer_thread.join().expect("model writer closes cleanly"); + + assert!(reader.is_writer_closed()); + assert_eq!(reader.published_count(), 2); + assert_eq!(reader.published_bytes(), 2); + assert_eq!(reader.resolve(&first).expect("final first").as_ref(), b"A"); + assert_eq!( + reader.resolve(&second).expect("final second").as_ref(), + b"B" + ); + }); + } + + #[test] + fn loom_published_page_and_ring_compose_without_partial_visibility() { + loom::model(|| { + let page = PublishedSlabPage::new(config(1, 1)).expect("bounded model page"); + let (mut writer, reader) = page.split(); + let ring = SpscRing::::new().expect("model ring"); + let (mut producer, mut consumer) = ring.split(); + + let writer_thread = thread::spawn(move || { + let mut pending = writer.try_append(b"A", 7, 1).expect("model append fits"); + loop { + match producer.try_push(pending) { + Ok(()) => break, + Err(TryPushError::Full(value)) => { + pending = value; + thread::yield_now(); + } + Err(TryPushError::Disconnected(_)) => { + panic!("model consumer disconnected") + } + } + } + }); + + let reader_thread = thread::spawn(move || { + let published = loop { + match consumer.try_pop() { + Ok(value) => break value, + Err(TryPopError::Empty) => thread::yield_now(), + Err(TryPopError::Disconnected) => { + panic!("model producer disconnected before publication") + } + } + }; + assert_eq!( + reader + .resolve(&published) + .expect("ring publication implies page publication") + .as_ref(), + b"A" + ); + }); + + writer_thread.join().expect("model writer succeeds"); + reader_thread.join().expect("model reader succeeds"); + }); + } +} diff --git a/lib/event/src/ring.rs b/lib/event/src/ring.rs new file mode 100644 index 00000000..f4b49045 --- /dev/null +++ b/lib/event/src/ring.rs @@ -0,0 +1,491 @@ +//! Cache-padded, bounded single-producer/single-consumer ring. +//! +//! # Safety invariants +//! +//! - `SpscRing::split` creates exactly one non-cloneable producer and consumer. +//! - Endpoint mutation requires `&mut self`; endpoint marker fields make them +//! `!Sync`, while `T: Send` permits ownership transfer to another thread. +//! - The producer is the only writer to a free slot. Its `Release` publication +//! follows initialization, and the consumer reads only after an `Acquire`. +//! - The consumer moves each value once. Its `Release` consumption follows the +//! move, and the producer reuses a slot only after an `Acquire`. +//! - Published minus consumed distance never exceeds capacity. Capacity is a +//! power of two below `2^63`, making modular sequence distance unambiguous. +//! - The final `Inner` drop runs only after both endpoints are gone and drops +//! exactly the still-published range. An invalid distance panics in debug and +//! leaks in release rather than dereferencing an unproven slot. + +use std::{cell::Cell, error::Error, fmt, marker::PhantomData, mem::MaybeUninit}; + +#[cfg(feature = "loom")] +use loom::{ + cell::UnsafeCell, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, + }, +}; +#[cfg(not(feature = "loom"))] +use std::{ + cell::UnsafeCell, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, + }, +}; + +pub const CACHE_LINE_BYTES: usize = 64; + +#[repr(align(64))] +struct PaddedSequence(AtomicU64); + +#[repr(align(64))] +struct PaddedEndpointState { + producer_closed: AtomicBool, + consumer_closed: AtomicBool, +} + +struct Slot(UnsafeCell>); + +impl Slot { + fn uninit() -> Self { + Self(UnsafeCell::new(MaybeUninit::uninit())) + } + + #[cfg(not(feature = "loom"))] + fn write(&self, value: T) { + // SAFETY: Only the producer accesses this slot before the publication + // sequence advances, and the consumed sequence proved any old value was + // moved out before this call. + unsafe { (*self.0.get()).write(value) }; + } + + #[cfg(feature = "loom")] + fn write(&self, value: T) { + self.0.with_mut(|slot| { + // SAFETY: The SPSC ownership and sequence proof is identical to the + // native implementation; Loom tracks the exclusive cell access. + unsafe { (*slot).write(value) }; + }); + } + + #[cfg(not(feature = "loom"))] + fn read(&self) -> T { + // SAFETY: The consumer observed the producer's Release publication with + // Acquire and is the only reader. The slot is initialized and is moved + // exactly once before the consumed sequence advances. + unsafe { (*self.0.get()).assume_init_read() } + } + + #[cfg(feature = "loom")] + fn read(&self) -> T { + self.0.with(|slot| { + // SAFETY: The SPSC ownership and publication proof is identical to + // the native implementation; Loom tracks the immutable cell access. + unsafe { (*slot).assume_init_read() } + }) + } + + #[cfg(not(feature = "loom"))] + fn drop_value(&self) { + // SAFETY: `Inner::drop` calls this only for the bounded sequence range + // published but not consumed. No endpoint still exists at final drop. + unsafe { (*self.0.get()).assume_init_drop() }; + } + + #[cfg(feature = "loom")] + fn drop_value(&self) { + self.0.with_mut(|slot| { + // SAFETY: The final-drop initialization proof is identical to the + // native implementation; Loom tracks the exclusive cell access. + unsafe { (*slot).assume_init_drop() }; + }); + } +} + +#[repr(C)] +struct Inner { + published_head: PaddedSequence, + consumed_tail: PaddedSequence, + endpoint_state: PaddedEndpointState, + slots: Box<[Slot]>, +} + +// SAFETY: `T: Send` values cross from the producer thread to the consumer +// thread. The sequence protocol provides exclusive slot access and publication. +unsafe impl Send for Inner {} + +// SAFETY: Shared `Inner` access is restricted to atomics plus slots whose +// exclusive owner is proven by the SPSC sequence protocol. +unsafe impl Sync for Inner {} + +impl Drop for Inner { + fn drop(&mut self) { + let head = self.published_head.0.load(Ordering::Acquire); + let tail = self.consumed_tail.0.load(Ordering::Acquire); + let remaining = head.wrapping_sub(tail); + + if remaining > self.slots.len() as u64 { + debug_assert!( + remaining <= self.slots.len() as u64, + "SPSC sequence invariant violated during drop" + ); + return; + } + + for distance in 0..remaining { + let sequence = tail.wrapping_add(distance); + let index = (sequence as usize) & (self.slots.len() - 1); + self.slots[index].drop_value(); + } + } +} + +pub struct SpscRing { + inner: Arc>, +} + +impl SpscRing { + pub fn new() -> Result { + Self::new_with_sequence(0) + } + + fn new_with_sequence(sequence: u64) -> Result { + validate_capacity::()?; + + let mut slots = Vec::with_capacity(N); + slots.resize_with(N, Slot::uninit); + + Ok(Self { + inner: Arc::new(Inner { + published_head: PaddedSequence(AtomicU64::new(sequence)), + consumed_tail: PaddedSequence(AtomicU64::new(sequence)), + endpoint_state: PaddedEndpointState { + producer_closed: AtomicBool::new(false), + consumer_closed: AtomicBool::new(false), + }, + slots: slots.into_boxed_slice(), + }), + }) + } + + pub const fn capacity(&self) -> usize { + N + } + + pub fn layout(&self) -> RingLayout { + let inner = &*self.inner; + let base = std::ptr::from_ref(inner) as usize; + let producer = std::ptr::addr_of!(inner.published_head) as usize; + let consumer = std::ptr::addr_of!(inner.consumed_tail) as usize; + + RingLayout { + cursor_alignment: std::mem::align_of::(), + producer_sequence_offset: producer - base, + consumer_sequence_offset: consumer - base, + } + } + + pub fn split(self) -> (Producer, Consumer) { + let Self { inner } = self; + let sequence = inner.published_head.0.load(Ordering::Relaxed); + let consumer_inner = Arc::clone(&inner); + + ( + Producer { + inner, + next: sequence, + cached_tail: sequence, + _not_sync: PhantomData, + }, + Consumer { + inner: consumer_inner, + next: sequence, + cached_head: sequence, + _not_sync: PhantomData, + }, + ) + } +} + +fn validate_capacity() -> Result<(), RingConfigError> { + if N == 0 { + return Err(RingConfigError::ZeroCapacity); + } + if !N.is_power_of_two() { + return Err(RingConfigError::NotPowerOfTwo { capacity: N }); + } + if (N as u128) >= (1_u128 << 63) { + return Err(RingConfigError::SequenceAmbiguous { capacity: N }); + } + Ok(()) +} + +pub struct Producer { + inner: Arc>, + next: u64, + cached_tail: u64, + _not_sync: PhantomData>, +} + +impl Producer { + pub const fn capacity(&self) -> usize { + N + } + + pub fn try_push(&mut self, value: T) -> Result<(), TryPushError> { + if self + .inner + .endpoint_state + .consumer_closed + .load(Ordering::Acquire) + { + return Err(TryPushError::Disconnected(value)); + } + + if self.next.wrapping_sub(self.cached_tail) >= N as u64 { + self.cached_tail = self.inner.consumed_tail.0.load(Ordering::Acquire); + if self.next.wrapping_sub(self.cached_tail) >= N as u64 { + return Err(TryPushError::Full(value)); + } + } + + let index = (self.next as usize) & (N - 1); + self.inner.slots[index].write(value); + self.next = self.next.wrapping_add(1); + self.inner + .published_head + .0 + .store(self.next, Ordering::Release); + Ok(()) + } + + pub fn is_consumer_closed(&self) -> bool { + self.inner + .endpoint_state + .consumer_closed + .load(Ordering::Acquire) + } +} + +impl Drop for Producer { + fn drop(&mut self) { + self.inner + .endpoint_state + .producer_closed + .store(true, Ordering::Release); + } +} + +pub struct Consumer { + inner: Arc>, + next: u64, + cached_head: u64, + _not_sync: PhantomData>, +} + +impl Consumer { + pub const fn capacity(&self) -> usize { + N + } + + pub fn try_pop(&mut self) -> Result { + if self.next == self.cached_head { + self.cached_head = self.inner.published_head.0.load(Ordering::Acquire); + if self.next == self.cached_head { + if self + .inner + .endpoint_state + .producer_closed + .load(Ordering::Acquire) + { + // Observing producer closure also observes every preceding + // publication. Reload the head so closure cannot hide the + // producer's final value behind an earlier empty read. + self.cached_head = self.inner.published_head.0.load(Ordering::Acquire); + if self.next == self.cached_head { + return Err(TryPopError::Disconnected); + } + } else { + return Err(TryPopError::Empty); + } + } + } + + let index = (self.next as usize) & (N - 1); + let value = self.inner.slots[index].read(); + self.next = self.next.wrapping_add(1); + self.inner + .consumed_tail + .0 + .store(self.next, Ordering::Release); + Ok(value) + } + + pub fn is_producer_closed(&self) -> bool { + self.inner + .endpoint_state + .producer_closed + .load(Ordering::Acquire) + } +} + +impl Drop for Consumer { + fn drop(&mut self) { + self.inner + .endpoint_state + .consumer_closed + .store(true, Ordering::Release); + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RingLayout { + pub cursor_alignment: usize, + pub producer_sequence_offset: usize, + pub consumer_sequence_offset: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RingConfigError { + ZeroCapacity, + NotPowerOfTwo { capacity: usize }, + SequenceAmbiguous { capacity: usize }, +} + +impl fmt::Display for RingConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ZeroCapacity => write!(f, "SPSC ring capacity must be non-zero"), + Self::NotPowerOfTwo { capacity } => { + write!(f, "SPSC ring capacity {capacity} is not a power of two") + } + Self::SequenceAmbiguous { capacity } => { + write!(f, "SPSC ring capacity {capacity} must be smaller than 2^63") + } + } + } +} + +impl Error for RingConfigError {} + +#[derive(Debug, Eq, PartialEq)] +pub enum TryPushError { + Full(T), + Disconnected(T), +} + +impl TryPushError { + pub fn into_inner(self) -> T { + match self { + Self::Full(value) | Self::Disconnected(value) => value, + } + } +} + +impl fmt::Display for TryPushError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Full(_) => write!(f, "SPSC ring is full"), + Self::Disconnected(_) => write!(f, "SPSC consumer is disconnected"), + } + } +} + +impl Error for TryPushError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TryPopError { + Empty, + Disconnected, +} + +impl fmt::Display for TryPopError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "SPSC ring is empty"), + Self::Disconnected => write!(f, "SPSC producer is disconnected"), + } + } +} + +impl Error for TryPopError {} + +#[cfg(all(test, not(feature = "loom")))] +mod tests { + use super::{SpscRing, TryPopError, TryPushError}; + + #[test] + fn modular_sequence_wrap_preserves_fifo_and_capacity() { + let ring = SpscRing::::new_with_sequence(u64::MAX - 1).expect("valid wrapped ring"); + let (mut producer, mut consumer) = ring.split(); + + for value in 0..4 { + producer.try_push(value).expect("ring has capacity"); + } + assert_eq!(producer.try_push(99), Err(TryPushError::Full(99))); + for value in 0..4 { + assert_eq!(consumer.try_pop(), Ok(value)); + } + assert_eq!(consumer.try_pop(), Err(TryPopError::Empty)); + } +} + +#[cfg(all(test, feature = "loom"))] +mod loom_tests { + use super::{SpscRing, TryPopError, TryPushError}; + use loom::thread; + + #[test] + fn loom_publication_reuse_and_shutdown() { + loom::model(|| { + let ring = SpscRing::::new().expect("valid model ring"); + let (mut producer, mut consumer) = ring.split(); + producer.try_push(1).expect("initial slot is free"); + + let producer_thread = thread::spawn(move || { + let mut pending = 2; + loop { + match producer.try_push(pending) { + Ok(()) => break, + Err(TryPushError::Full(value)) => { + pending = value; + thread::yield_now(); + } + Err(TryPushError::Disconnected(_)) => { + panic!("model consumer disconnected") + } + } + } + }); + + let consumer_thread = thread::spawn(move || { + for expected in [1, 2] { + loop { + match consumer.try_pop() { + Ok(actual) => { + assert_eq!(actual, expected); + break; + } + Err(TryPopError::Empty) => thread::yield_now(), + Err(TryPopError::Disconnected) => { + panic!("model producer disconnected before drain") + } + } + } + } + + loop { + match consumer.try_pop() { + Err(TryPopError::Disconnected) => break, + Err(TryPopError::Empty) => thread::yield_now(), + Ok(value) => panic!("unexpected extra model value {value}"), + } + } + }); + + producer_thread.join().expect("model producer succeeds"); + consumer_thread.join().expect("model consumer succeeds"); + }); + } +} diff --git a/lib/event/src/slab.rs b/lib/event/src/slab.rs new file mode 100644 index 00000000..750473dc --- /dev/null +++ b/lib/event/src/slab.rs @@ -0,0 +1,638 @@ +//! Bounded, generation-tagged slab pages with a seal-before-publish boundary. +//! +//! A builder is the only mutable owner. Descriptors remain private until +//! `seal` consumes that builder, after which page-level leases expose only +//! immutable payload views. This is a safe, unwired reference implementation; +//! concurrent published-prefix reads and epoch reuse remain target work under +//! ADR-0007. + +use std::{error::Error, fmt, sync::Arc}; + +use crate::{DescriptorError, TelemetryDescriptor, MAX_FRAME_BYTES}; + +/// Maximum logical byte capacity of one prototype slab page (64 MiB). +pub const MAX_SLAB_PAGE_BYTES: usize = 64 << 20; + +/// Maximum descriptor capacity of one prototype slab page. +pub const MAX_SLAB_DESCRIPTORS: usize = 1 << 16; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SlabPageConfig { + pub arena_id: u16, + pub arena_generation: u32, + pub byte_capacity: usize, + pub descriptor_capacity: usize, + pub first_sequence: u64, +} + +struct PageData { + arena_id: u16, + arena_generation: u32, + byte_capacity: usize, + descriptor_capacity: usize, + first_sequence: u64, + next_sequence: u64, + bytes: Vec, + descriptors: Vec, +} + +/// Single-owner mutable construction state for one slab page. +pub struct SlabPageBuilder { + data: PageData, +} + +impl SlabPageBuilder { + pub fn new(config: SlabPageConfig) -> Result { + validate_config(config)?; + + let mut bytes = Vec::new(); + bytes.try_reserve_exact(config.byte_capacity).map_err(|_| { + SlabConfigError::AllocationFailed { + resource: SlabResource::PayloadBytes, + requested: config.byte_capacity, + } + })?; + + let mut descriptors = Vec::new(); + descriptors + .try_reserve_exact(config.descriptor_capacity) + .map_err(|_| SlabConfigError::AllocationFailed { + resource: SlabResource::Descriptors, + requested: config.descriptor_capacity, + })?; + + Ok(Self { + data: PageData { + arena_id: config.arena_id, + arena_generation: config.arena_generation, + byte_capacity: config.byte_capacity, + descriptor_capacity: config.descriptor_capacity, + first_sequence: config.first_sequence, + next_sequence: config.first_sequence, + bytes, + descriptors, + }, + }) + } + + /// Copies one complete payload into the pre-reserved page. + /// + /// Validation and CRC calculation finish before the builder state changes. + /// Successful appends do not grow either backing allocation. + pub fn try_append( + &mut self, + payload: &[u8], + schema_id: u32, + flags: u16, + ) -> Result<(), SlabAppendError> { + let len = payload.len(); + if len == 0 { + return Err(SlabAppendError::EmptyPayload); + } + if len > MAX_FRAME_BYTES { + return Err(SlabAppendError::FrameTooLarge { + len, + maximum: MAX_FRAME_BYTES, + }); + } + if self.data.descriptors.len() == self.data.descriptor_capacity { + return Err(SlabAppendError::DescriptorCapacityExhausted { + capacity: self.data.descriptor_capacity, + }); + } + + let offset = self.data.bytes.len(); + let end = offset + .checked_add(len) + .ok_or(SlabAppendError::OffsetLengthOverflow { offset, len })?; + if end > self.data.byte_capacity { + return Err(SlabAppendError::PageFull { + requested: len, + remaining: self.data.byte_capacity - offset, + }); + } + + let offset = + u32::try_from(offset).map_err(|_| SlabAppendError::OffsetNotAddressable { offset })?; + let descriptor_len = + u32::try_from(len).map_err(|_| SlabAppendError::LengthNotAddressable { len })?; + let checksum = crc32c::crc32c(payload); + let descriptor = TelemetryDescriptor { + sequence: self.data.next_sequence, + arena_generation: self.data.arena_generation, + arena_id: self.data.arena_id, + flags, + offset, + len: descriptor_len, + crc32c: checksum, + schema_id, + }; + + self.data.bytes.extend_from_slice(payload); + self.data.descriptors.push(descriptor); + self.data.next_sequence = self.data.next_sequence.wrapping_add(1); + Ok(()) + } + + pub fn seal(self) -> SealedSlabPage { + SealedSlabPage { + data: Arc::new(self.data), + } + } + + pub fn arena_id(&self) -> u16 { + self.data.arena_id + } + + pub fn arena_generation(&self) -> u32 { + self.data.arena_generation + } + + pub fn byte_capacity(&self) -> usize { + self.data.byte_capacity + } + + pub fn descriptor_capacity(&self) -> usize { + self.data.descriptor_capacity + } + + pub fn used_bytes(&self) -> usize { + self.data.bytes.len() + } + + pub fn descriptor_count(&self) -> usize { + self.data.descriptors.len() + } + + pub fn remaining_bytes(&self) -> usize { + self.data.byte_capacity - self.data.bytes.len() + } + + pub fn remaining_descriptors(&self) -> usize { + self.data.descriptor_capacity - self.data.descriptors.len() + } + + pub fn next_sequence(&self) -> u64 { + self.data.next_sequence + } +} + +impl fmt::Debug for SlabPageBuilder { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SlabPageBuilder") + .field("arena_id", &self.arena_id()) + .field("arena_generation", &self.arena_generation()) + .field("byte_capacity", &self.byte_capacity()) + .field("descriptor_capacity", &self.descriptor_capacity()) + .field("used_bytes", &self.used_bytes()) + .field("descriptor_count", &self.descriptor_count()) + .field("next_sequence", &self.next_sequence()) + .finish() + } +} + +/// Immutable page owner that exposes the descriptors created before sealing. +pub struct SealedSlabPage { + data: Arc, +} + +impl SealedSlabPage { + pub fn descriptors(&self) -> &[TelemetryDescriptor] { + &self.data.descriptors + } + + /// Creates one page-level reader lease. Callers must not do this per event. + pub fn reader(&self) -> SlabPageReader { + SlabPageReader { + data: Arc::clone(&self.data), + } + } + + pub fn arena_id(&self) -> u16 { + self.data.arena_id + } + + pub fn arena_generation(&self) -> u32 { + self.data.arena_generation + } + + pub fn byte_capacity(&self) -> usize { + self.data.byte_capacity + } + + pub fn descriptor_capacity(&self) -> usize { + self.data.descriptor_capacity + } + + pub fn used_bytes(&self) -> usize { + self.data.bytes.len() + } + + pub fn descriptor_count(&self) -> usize { + self.data.descriptors.len() + } + + pub fn next_sequence(&self) -> u64 { + self.data.next_sequence + } +} + +impl fmt::Debug for SealedSlabPage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SealedSlabPage") + .field("arena_id", &self.arena_id()) + .field("arena_generation", &self.arena_generation()) + .field("byte_capacity", &self.byte_capacity()) + .field("descriptor_capacity", &self.descriptor_capacity()) + .field("used_bytes", &self.used_bytes()) + .field("descriptor_count", &self.descriptor_count()) + .field("next_sequence", &self.next_sequence()) + .finish() + } +} + +/// Page-level immutable payload lease. +pub struct SlabPageReader { + data: Arc, +} + +impl SlabPageReader { + /// Validates identity, exact sealed-table membership, range, and CRC32C. + pub fn resolve(&self, descriptor: &TelemetryDescriptor) -> Result<&[u8], SlabReadError> { + if descriptor.arena_id != self.data.arena_id { + return Err(SlabReadError::ArenaIdMismatch { + page: self.data.arena_id, + descriptor: descriptor.arena_id, + }); + } + if descriptor.arena_generation != self.data.arena_generation { + return Err(SlabReadError::GenerationMismatch { + page: self.data.arena_generation, + descriptor: descriptor.arena_generation, + }); + } + if descriptor.len == 0 { + return Err(SlabReadError::EmptyPayload); + } + + let distance = descriptor.sequence.wrapping_sub(self.data.first_sequence); + if distance >= self.data.descriptors.len() as u64 { + return Err(SlabReadError::UnknownSequence { + first: self.data.first_sequence, + count: self.data.descriptors.len(), + descriptor: descriptor.sequence, + }); + } + let index = distance as usize; + if self.data.descriptors[index] != *descriptor { + return Err(SlabReadError::DescriptorMismatch { + sequence: descriptor.sequence, + }); + } + + let canonical = &self.data.descriptors[index]; + let range = canonical + .checked_payload_range(self.data.bytes.len()) + .map_err(SlabReadError::Descriptor)?; + let payload = &self.data.bytes[range]; + if crc32c::crc32c(payload) != canonical.crc32c { + return Err(SlabReadError::CrcMismatch { + sequence: canonical.sequence, + }); + } + + Ok(payload) + } + + pub fn arena_id(&self) -> u16 { + self.data.arena_id + } + + pub fn arena_generation(&self) -> u32 { + self.data.arena_generation + } + + pub fn used_bytes(&self) -> usize { + self.data.bytes.len() + } + + pub fn descriptor_count(&self) -> usize { + self.data.descriptors.len() + } +} + +impl fmt::Debug for SlabPageReader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SlabPageReader") + .field("arena_id", &self.arena_id()) + .field("arena_generation", &self.arena_generation()) + .field("used_bytes", &self.used_bytes()) + .field("descriptor_count", &self.descriptor_count()) + .finish() + } +} + +pub(crate) fn validate_config(config: SlabPageConfig) -> Result<(), SlabConfigError> { + if config.byte_capacity == 0 { + return Err(SlabConfigError::ZeroByteCapacity); + } + if config.byte_capacity > MAX_SLAB_PAGE_BYTES { + return Err(SlabConfigError::ByteCapacityTooLarge { + requested: config.byte_capacity, + maximum: MAX_SLAB_PAGE_BYTES, + }); + } + if config.descriptor_capacity == 0 { + return Err(SlabConfigError::ZeroDescriptorCapacity); + } + if config.descriptor_capacity > MAX_SLAB_DESCRIPTORS { + return Err(SlabConfigError::DescriptorCapacityTooLarge { + requested: config.descriptor_capacity, + maximum: MAX_SLAB_DESCRIPTORS, + }); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SlabResource { + PayloadBytes, + Descriptors, +} + +impl fmt::Display for SlabResource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PayloadBytes => write!(f, "payload bytes"), + Self::Descriptors => write!(f, "descriptors"), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SlabConfigError { + ZeroByteCapacity, + ByteCapacityTooLarge { + requested: usize, + maximum: usize, + }, + ZeroDescriptorCapacity, + DescriptorCapacityTooLarge { + requested: usize, + maximum: usize, + }, + AllocationFailed { + resource: SlabResource, + requested: usize, + }, +} + +impl fmt::Display for SlabConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ZeroByteCapacity => write!(f, "slab page byte capacity must be non-zero"), + Self::ByteCapacityTooLarge { requested, maximum } => write!( + f, + "slab page byte capacity {requested} exceeds maximum {maximum}" + ), + Self::ZeroDescriptorCapacity => { + write!(f, "slab page descriptor capacity must be non-zero") + } + Self::DescriptorCapacityTooLarge { requested, maximum } => write!( + f, + "slab page descriptor capacity {requested} exceeds maximum {maximum}" + ), + Self::AllocationFailed { + resource, + requested, + } => write!( + f, + "failed to reserve slab page {resource} capacity {requested}" + ), + } + } +} + +impl Error for SlabConfigError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SlabAppendError { + WriterPoisoned, + EmptyPayload, + FrameTooLarge { len: usize, maximum: usize }, + DescriptorCapacityExhausted { capacity: usize }, + OffsetLengthOverflow { offset: usize, len: usize }, + PageFull { requested: usize, remaining: usize }, + OffsetNotAddressable { offset: usize }, + LengthNotAddressable { len: usize }, +} + +impl fmt::Display for SlabAppendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WriterPoisoned => write!(f, "published slab writer is poisoned"), + Self::EmptyPayload => write!(f, "slab payload must be non-empty"), + Self::FrameTooLarge { len, maximum } => { + write!(f, "slab payload length {len} exceeds maximum {maximum}") + } + Self::DescriptorCapacityExhausted { capacity } => { + write!(f, "slab descriptor capacity {capacity} is exhausted") + } + Self::OffsetLengthOverflow { offset, len } => { + write!(f, "slab offset {offset} + length {len} overflows") + } + Self::PageFull { + requested, + remaining, + } => write!( + f, + "slab page has {remaining} bytes remaining; append requested {requested}" + ), + Self::OffsetNotAddressable { offset } => { + write!(f, "slab offset {offset} is not addressable by a descriptor") + } + Self::LengthNotAddressable { len } => { + write!(f, "slab length {len} is not addressable by a descriptor") + } + } + } +} + +impl Error for SlabAppendError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SlabReadError { + ArenaIdMismatch { + page: u16, + descriptor: u16, + }, + GenerationMismatch { + page: u32, + descriptor: u32, + }, + EmptyPayload, + Descriptor(DescriptorError), + CrcMismatch { + sequence: u64, + }, + UnknownSequence { + first: u64, + count: usize, + descriptor: u64, + }, + DescriptorMismatch { + sequence: u64, + }, +} + +impl fmt::Display for SlabReadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ArenaIdMismatch { page, descriptor } => write!( + f, + "descriptor arena ID {descriptor} does not match slab page {page}" + ), + Self::GenerationMismatch { page, descriptor } => write!( + f, + "descriptor generation {descriptor} does not match slab page {page}" + ), + Self::EmptyPayload => write!(f, "descriptor payload must be non-empty"), + Self::Descriptor(error) => write!(f, "invalid slab descriptor range: {error}"), + Self::CrcMismatch { sequence } => { + write!(f, "slab payload CRC32C mismatch for sequence {sequence}") + } + Self::UnknownSequence { first, count, descriptor } => write!( + f, + "descriptor sequence {descriptor} is outside sealed page sequence {first} with count {count}" + ), + Self::DescriptorMismatch { sequence } => write!( + f, + "descriptor metadata for sequence {sequence} does not match the sealed page table" + ), + } + } +} + +impl Error for SlabReadError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Descriptor(error) => Some(error), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> SlabPageConfig { + SlabPageConfig { + arena_id: 1, + arena_generation: 2, + byte_capacity: 64, + descriptor_capacity: 4, + first_sequence: 3, + } + } + + #[test] + fn successful_appends_do_not_relocate_pre_reserved_storage() { + let mut builder = SlabPageBuilder::new(config()).expect("bounded page"); + let bytes_ptr = builder.data.bytes.as_ptr(); + let descriptor_ptr = builder.data.descriptors.as_ptr(); + let byte_capacity = builder.data.bytes.capacity(); + let descriptor_capacity = builder.data.descriptors.capacity(); + + builder.try_append(b"one", 1, 0).expect("first append"); + builder.try_append(b"two", 1, 0).expect("second append"); + + assert_eq!(builder.data.bytes.as_ptr(), bytes_ptr); + assert_eq!(builder.data.descriptors.as_ptr(), descriptor_ptr); + assert_eq!(builder.data.bytes.capacity(), byte_capacity); + assert_eq!(builder.data.descriptors.capacity(), descriptor_capacity); + } + + #[test] + fn seal_and_resolve_do_not_move_storage_or_increment_page_lease() { + let mut builder = SlabPageBuilder::new(config()).expect("bounded page"); + builder.try_append(b"payload", 1, 0).expect("append"); + let bytes_ptr = builder.data.bytes.as_ptr(); + let sealed = builder.seal(); + assert_eq!(sealed.data.bytes.as_ptr(), bytes_ptr); + + let reader = sealed.reader(); + let lease_count = Arc::strong_count(&reader.data); + let descriptor = sealed.descriptors()[0]; + let payload = reader.resolve(&descriptor).expect("resolve"); + + assert_eq!(payload.as_ptr(), bytes_ptr); + assert_eq!(Arc::strong_count(&reader.data), lease_count); + } + + #[test] + fn payload_corruption_is_reported_without_exposing_checksum_values() { + let mut builder = SlabPageBuilder::new(config()).expect("bounded page"); + builder.try_append(b"payload", 1, 0).expect("append"); + builder.data.bytes[0] ^= 1; + let sealed = builder.seal(); + let descriptor = sealed.descriptors()[0]; + let reader = sealed.reader(); + + assert_eq!( + reader.resolve(&descriptor), + Err(SlabReadError::CrcMismatch { sequence: 3 }) + ); + assert_eq!( + SlabReadError::CrcMismatch { sequence: 3 }.to_string(), + "slab payload CRC32C mismatch for sequence 3" + ); + } + + #[test] + fn canonical_descriptor_range_corruption_fails_before_slicing() { + let mut builder = SlabPageBuilder::new(config()).expect("bounded page"); + builder.try_append(b"payload", 1, 0).expect("append"); + builder.data.descriptors[0].offset = 6; + builder.data.descriptors[0].len = 2; + let sealed = builder.seal(); + let descriptor = sealed.descriptors()[0]; + let reader = sealed.reader(); + + assert_eq!( + reader.resolve(&descriptor), + Err(SlabReadError::Descriptor(DescriptorError::OutOfBounds { + end: 8, + page_len: 7, + })) + ); + } + + #[test] + fn crc_dependency_matches_a_scalar_castagnoli_oracle() { + fn scalar_crc32c(bytes: &[u8]) -> u32 { + let mut crc = !0_u32; + for byte in bytes { + crc ^= u32::from(*byte); + for _ in 0..8 { + let mask = 0_u32.wrapping_sub(crc & 1); + crc = (crc >> 1) ^ (0x82f6_3b78 & mask); + } + } + !crc + } + + assert_eq!(crc32c::crc32c(b"123456789"), 0xe306_9283); + + let maximum_length = if cfg!(miri) { 64 } else { 256 }; + let corpus: Vec = (0..maximum_length + 8) + .map(|index| (index as u8).wrapping_mul(31).wrapping_add(17)) + .collect(); + for offset in 0..=7 { + for len in 0..=maximum_length { + let payload = &corpus[offset..offset + len]; + assert_eq!(crc32c::crc32c(payload), scalar_crc32c(payload)); + } + } + } +} diff --git a/lib/event/tests/descriptor.rs b/lib/event/tests/descriptor.rs new file mode 100644 index 00000000..b57757cc --- /dev/null +++ b/lib/event/tests/descriptor.rs @@ -0,0 +1,74 @@ +use std::mem::{align_of, offset_of, size_of}; + +use aegis_event::{DescriptorError, TelemetryDescriptor, MAX_FRAME_BYTES}; + +fn descriptor(offset: u32, len: u32) -> TelemetryDescriptor { + TelemetryDescriptor { + sequence: 42, + arena_generation: 7, + arena_id: 3, + flags: 1, + offset, + len, + crc32c: 0x1234_5678, + schema_id: 2, + } +} + +#[test] +fn descriptor_abi_is_exactly_one_aligned_32_byte_unit() { + assert_eq!(size_of::(), 32); + assert_eq!(align_of::(), 32); + assert_eq!(offset_of!(TelemetryDescriptor, sequence), 0); + assert_eq!(offset_of!(TelemetryDescriptor, arena_generation), 8); + assert_eq!(offset_of!(TelemetryDescriptor, arena_id), 12); + assert_eq!(offset_of!(TelemetryDescriptor, flags), 14); + assert_eq!(offset_of!(TelemetryDescriptor, offset), 16); + assert_eq!(offset_of!(TelemetryDescriptor, len), 20); + assert_eq!(offset_of!(TelemetryDescriptor, crc32c), 24); + assert_eq!(offset_of!(TelemetryDescriptor, schema_id), 28); + + let descriptors = [descriptor(0, 1), descriptor(1, 1)]; + let first = std::ptr::addr_of!(descriptors[0]) as usize; + let second = std::ptr::addr_of!(descriptors[1]) as usize; + assert_eq!(second - first, 32); +} + +#[test] +fn checked_payload_range_accepts_an_in_bounds_frame() { + assert_eq!(descriptor(32, 64).checked_payload_range(128), Ok(32..96)); +} + +#[test] +fn checked_payload_range_rejects_frame_larger_than_the_hard_limit() { + let len = u32::try_from(MAX_FRAME_BYTES + 1).expect("test length fits u32"); + assert_eq!( + descriptor(0, len).checked_payload_range(MAX_FRAME_BYTES + 1), + Err(DescriptorError::FrameTooLarge { + len: MAX_FRAME_BYTES + 1, + max: MAX_FRAME_BYTES, + }) + ); +} + +#[test] +fn checked_payload_range_rejects_integer_overflow_before_slicing() { + assert_eq!( + descriptor(u32::MAX, 2).checked_payload_range(usize::MAX), + Err(DescriptorError::OffsetLengthOverflow { + offset: u32::MAX, + len: 2, + }) + ); +} + +#[test] +fn checked_payload_range_rejects_a_range_outside_the_arena_page() { + assert_eq!( + descriptor(96, 64).checked_payload_range(128), + Err(DescriptorError::OutOfBounds { + end: 160, + page_len: 128, + }) + ); +} diff --git a/lib/event/tests/published_allocations.rs b/lib/event/tests/published_allocations.rs new file mode 100644 index 00000000..f02ae58b --- /dev/null +++ b/lib/event/tests/published_allocations.rs @@ -0,0 +1,99 @@ +//! Thread-local allocation check for the native published-prefix hot operation. +//! +//! # Safety invariants +//! +//! - The test allocator delegates every operation to `System` with the exact +//! layout and pointer supplied by the caller. +//! - Counting uses const-initialized thread-local `Cell`s and performs no heap +//! allocation, recursion, pointer access, or ownership change. +//! - Tracking is enabled only on the test thread around one warmed append and +//! resolve; setup, page allocation, assertions, and drop are outside the +//! measured boundary. + +#![cfg(all(not(miri), not(feature = "loom")))] + +use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, +}; + +use aegis_event::{PublishedSlabPage, SlabPageConfig}; + +struct ThreadTrackingAllocator; + +thread_local! { + static TRACK_ALLOCATIONS: Cell = const { Cell::new(false) }; + static ALLOCATION_COUNT: Cell = const { Cell::new(0) }; +} + +fn record_allocation() { + let tracking = TRACK_ALLOCATIONS.try_with(Cell::get).unwrap_or(false); + if tracking { + let _ = ALLOCATION_COUNT.try_with(|count| count.set(count.get().saturating_add(1))); + } +} + +// SAFETY: Each method delegates unchanged pointer/layout semantics to the +// process `System` allocator. The additional thread-local accounting neither +// dereferences nor retains allocation pointers. +unsafe impl GlobalAlloc for ThreadTrackingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + record_allocation(); + // SAFETY: The caller supplies the `GlobalAlloc` layout contract, which + // is forwarded unchanged to `System`. + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + record_allocation(); + // SAFETY: The caller supplies the `GlobalAlloc` layout contract, which + // is forwarded unchanged to `System`. + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + // SAFETY: The pointer/layout pair came from this allocator, which + // delegates allocation unchanged to `System`. + unsafe { System.dealloc(pointer, layout) }; + } + + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + record_allocation(); + // SAFETY: The pointer/layout pair came from `System`; `new_size` is + // forwarded unchanged under the `GlobalAlloc::realloc` contract. + unsafe { System.realloc(pointer, layout, new_size) } + } +} + +#[global_allocator] +static ALLOCATOR: ThreadTrackingAllocator = ThreadTrackingAllocator; + +#[test] +fn warmed_native_append_and_resolve_allocate_nothing() { + let page = PublishedSlabPage::new(SlabPageConfig { + arena_id: 3, + arena_generation: 5, + byte_capacity: 64, + descriptor_capacity: 2, + first_sequence: 7, + }) + .expect("bounded page"); + let (mut writer, reader) = page.split(); + + // Warm architecture-specific CRC dispatch before the measured boundary. + let _ = crc32c::crc32c(b"warmup"); + TRACK_ALLOCATIONS.with(|tracking| tracking.set(true)); + ALLOCATION_COUNT.with(|count| count.set(0)); + + let descriptor = writer + .try_append(b"allocation-free", 1, 0) + .expect("preallocated append fits"); + let payload = reader + .resolve(&descriptor) + .expect("published descriptor resolves"); + let allocations = ALLOCATION_COUNT.with(Cell::get); + TRACK_ALLOCATIONS.with(|tracking| tracking.set(false)); + + assert_eq!(payload.as_ref(), b"allocation-free"); + assert_eq!(allocations, 0, "append + resolve allocated unexpectedly"); +} diff --git a/lib/event/tests/published_slab.rs b/lib/event/tests/published_slab.rs new file mode 100644 index 00000000..02e6e996 --- /dev/null +++ b/lib/event/tests/published_slab.rs @@ -0,0 +1,562 @@ +#![cfg(not(feature = "loom"))] + +use std::thread; + +use aegis_event::{ + PublishedSlabPage, PublishedSlabReadError, SlabAppendError, SlabConfigError, SlabPageBuilder, + SlabPageConfig, SpscRing, TelemetryDescriptor, TryPopError, TryPushError, CACHE_LINE_BYTES, + MAX_FRAME_BYTES, MAX_SLAB_DESCRIPTORS, MAX_SLAB_PAGE_BYTES, +}; + +fn config(byte_capacity: usize, descriptor_capacity: usize) -> SlabPageConfig { + SlabPageConfig { + arena_id: 17, + arena_generation: 23, + byte_capacity, + descriptor_capacity, + first_sequence: 41, + } +} + +#[test] +fn published_page_reuses_the_bounded_slab_configuration_contract() { + assert_eq!( + PublishedSlabPage::new(config(0, 1)).expect_err("zero byte capacity must fail"), + SlabConfigError::ZeroByteCapacity + ); + assert_eq!( + PublishedSlabPage::new(config(MAX_SLAB_PAGE_BYTES + 1, 1)) + .expect_err("oversized bytes must fail before allocation"), + SlabConfigError::ByteCapacityTooLarge { + requested: MAX_SLAB_PAGE_BYTES + 1, + maximum: MAX_SLAB_PAGE_BYTES, + } + ); + assert_eq!( + PublishedSlabPage::new(config(1, 0)).expect_err("zero descriptors must fail"), + SlabConfigError::ZeroDescriptorCapacity + ); + assert_eq!( + PublishedSlabPage::new(config(1, MAX_SLAB_DESCRIPTORS + 1)) + .expect_err("oversized descriptor budget must fail before allocation"), + SlabConfigError::DescriptorCapacityTooLarge { + requested: MAX_SLAB_DESCRIPTORS + 1, + maximum: MAX_SLAB_DESCRIPTORS, + } + ); +} + +#[test] +fn append_release_publishes_each_descriptor_without_sealing_the_page() { + let page = PublishedSlabPage::new(config(64, 4)).expect("bounded page"); + let (mut writer, reader) = page.split(); + + let first = writer + .try_append(b"123456789", 7, 0x0001) + .expect("first frame fits"); + assert_eq!(first.sequence, 41); + assert_eq!(first.offset, 0); + assert_eq!(first.len, 9); + assert_eq!(first.crc32c, 0xe306_9283); + assert_eq!(first.schema_id, 7); + assert_eq!(first.flags, 0x0001); + assert_eq!(reader.published_count(), 1); + assert_eq!(reader.published_bytes(), 9); + assert_eq!( + reader + .resolve(&first) + .expect("first frame is published") + .as_ref(), + b"123456789" + ); + + let second = writer + .try_append(b"second", 8, 0x0002) + .expect("second frame fits"); + assert_eq!(second.sequence, 42); + assert_eq!(second.offset, 9); + assert_eq!(second.len, 6); + assert_eq!(reader.published_count(), 2); + assert_eq!(reader.published_bytes(), 15); + assert_eq!( + reader + .resolve(&second) + .expect("second frame is published") + .as_ref(), + b"second" + ); + + assert_eq!(writer.used_bytes(), 15); + assert_eq!(writer.published_count(), 2); + assert_eq!(writer.remaining_bytes(), 49); + assert_eq!(writer.remaining_descriptors(), 2); + assert_eq!(writer.next_sequence(), 43); +} + +#[test] +fn failed_appends_do_not_publish_or_advance_writer_state() { + let page = PublishedSlabPage::new(config(4, 2)).expect("bounded page"); + let (mut writer, reader) = page.split(); + + assert_eq!( + writer.try_append(&[], 1, 0), + Err(SlabAppendError::EmptyPayload) + ); + assert_eq!(writer.used_bytes(), 0); + assert_eq!(writer.published_count(), 0); + assert_eq!(writer.next_sequence(), 41); + assert_eq!(reader.published_count(), 0); + + let first = writer.try_append(b"abc", 1, 0).expect("first frame fits"); + assert_eq!( + writer.try_append(b"de", 1, 0), + Err(SlabAppendError::PageFull { + requested: 2, + remaining: 1, + }) + ); + assert_eq!(writer.used_bytes(), 3); + assert_eq!(writer.published_count(), 1); + assert_eq!(writer.next_sequence(), 42); + assert_eq!(reader.published_count(), 1); + assert_eq!( + reader + .resolve(&first) + .expect("first remains valid") + .as_ref(), + b"abc" + ); + + let second = writer.try_append(b"d", 2, 0).expect("remaining byte fits"); + assert_eq!(second.sequence, 42); + assert_eq!( + writer.try_append(b"x", 3, 0), + Err(SlabAppendError::DescriptorCapacityExhausted { capacity: 2 }) + ); + assert_eq!(writer.used_bytes(), 4); + assert_eq!(writer.published_count(), 2); + assert_eq!(writer.next_sequence(), 43); + assert_eq!(reader.published_count(), 2); +} + +#[test] +fn resolver_rejects_unpublished_and_forged_descriptors_before_payload_access() { + let page = PublishedSlabPage::new(config(32, 3)).expect("bounded page"); + let (mut writer, reader) = page.split(); + let first = writer.try_append(b"verified", 9, 0).expect("payload fits"); + + let mut wrong_arena = first; + wrong_arena.arena_id = 99; + assert_eq!( + reader.resolve(&wrong_arena), + Err(PublishedSlabReadError::ArenaIdMismatch { + page: 17, + descriptor: 99, + }) + ); + + let mut stale_generation = first; + stale_generation.arena_generation = 22; + assert_eq!( + reader.resolve(&stale_generation), + Err(PublishedSlabReadError::GenerationMismatch { + page: 23, + descriptor: 22, + }) + ); + + let mut future = first; + future.sequence = 42; + assert_eq!( + reader.resolve(&future), + Err(PublishedSlabReadError::UnpublishedSequence { + first: 41, + committed: 1, + descriptor: 42, + }) + ); + + for forged in [ + { + let mut value = first; + value.offset = 1; + value + }, + { + let mut value = first; + value.len = 0; + value + }, + { + let mut value = first; + value.crc32c ^= 1; + value + }, + { + let mut value = first; + value.schema_id = 10; + value + }, + { + let mut value = first; + value.flags ^= 1; + value + }, + ] { + assert_eq!( + reader.resolve(&forged), + Err(PublishedSlabReadError::DescriptorMismatch { sequence: 41 }) + ); + } + + let second = writer + .try_append(b"redirect", 10, 1) + .expect("second payload fits"); + let mut redirected = first; + redirected.offset = second.offset; + redirected.len = second.len; + redirected.crc32c = second.crc32c; + assert_eq!( + reader.resolve(&redirected), + Err(PublishedSlabReadError::DescriptorMismatch { sequence: 41 }) + ); + + let mut sequence_swap = first; + sequence_swap.sequence = second.sequence; + assert_eq!( + reader.resolve(&sequence_swap), + Err(PublishedSlabReadError::DescriptorMismatch { + sequence: second.sequence, + }) + ); +} + +#[test] +fn modular_sequence_wrap_remains_unambiguous_within_one_page() { + let mut cfg = config(8, 2); + cfg.first_sequence = u64::MAX; + let page = PublishedSlabPage::new(cfg).expect("bounded page"); + let (mut writer, reader) = page.split(); + + let first = writer.try_append(b"a", 1, 0).expect("first frame fits"); + let second = writer.try_append(b"b", 1, 0).expect("second frame fits"); + + assert_eq!(first.sequence, u64::MAX); + assert_eq!(second.sequence, 0); + assert_eq!(writer.next_sequence(), 1); + assert_eq!( + reader.resolve(&first).expect("wrapped first").as_ref(), + b"a" + ); + assert_eq!( + reader.resolve(&second).expect("wrapped second").as_ref(), + b"b" + ); +} + +#[test] +fn native_borrowed_prefix_stays_stable_while_writer_appends_a_disjoint_suffix() { + let page = PublishedSlabPage::new(config(32, 3)).expect("bounded page"); + let (mut writer, reader) = page.split(); + let first = writer + .try_append(b"prefix", 1, 0) + .expect("first frame fits"); + + let first_view = reader.resolve(&first).expect("first frame resolves"); + let first_pointer = first_view.as_ptr(); + let second = writer + .try_append(b"suffix", 2, 0) + .expect("writer may append a disjoint suffix while the prefix is borrowed"); + + assert_eq!(first_view.as_ptr(), first_pointer); + assert_eq!(first_view.as_ref(), b"prefix"); + assert_eq!( + reader.resolve(&second).expect("suffix resolves").as_ref(), + b"suffix" + ); + assert_eq!( + reader + .resolve(&first) + .expect("prefix still resolves") + .as_ptr(), + first_pointer + ); +} + +#[test] +fn an_earlier_published_view_can_be_the_source_for_a_disjoint_append() { + let page = PublishedSlabPage::new(config(32, 2)).expect("bounded page"); + let (mut writer, reader) = page.split(); + let first = writer + .try_append(b"source", 1, 0) + .expect("first frame fits"); + let source = reader.resolve(&first).expect("source frame resolves"); + + let second = writer + .try_append(source.as_ref(), 2, 0) + .expect("source and append-only destination are disjoint"); + + assert_eq!(source.as_ref(), b"source"); + assert_eq!( + reader + .resolve(&second) + .expect("copied frame resolves") + .as_ref(), + b"source" + ); + assert_ne!( + source.as_ptr(), + reader.resolve(&second).expect("view").as_ptr() + ); +} + +#[test] +fn published_page_matches_the_safe_sealed_page_oracle() { + let cfg = config(64, 4); + let mut sealed_builder = SlabPageBuilder::new(cfg).expect("bounded safe page"); + let page = PublishedSlabPage::new(cfg).expect("bounded published page"); + let (mut writer, reader) = page.split(); + let frames: [(&[u8], u32, u16); 3] = + [(b"one", 1, 0), (b"two-two", 2, 1), (b"three", 3, 0x8000)]; + let mut published_descriptors = Vec::new(); + + for (payload, schema_id, flags) in frames { + sealed_builder + .try_append(payload, schema_id, flags) + .expect("safe oracle append fits"); + published_descriptors.push( + writer + .try_append(payload, schema_id, flags) + .expect("published append fits"), + ); + } + + let sealed = sealed_builder.seal(); + let sealed_reader = sealed.reader(); + assert_eq!(published_descriptors.as_slice(), sealed.descriptors()); + for descriptor in &published_descriptors { + assert_eq!( + reader.resolve(descriptor).expect("published view").as_ref(), + sealed_reader.resolve(descriptor).expect("safe oracle view") + ); + } +} + +#[test] +fn deterministic_variable_length_corpus_matches_the_safe_oracle() { + let frame_count = if cfg!(miri) { 16_usize } else { 512_usize }; + let cfg = config(frame_count * 257, frame_count); + let mut sealed_builder = SlabPageBuilder::new(cfg).expect("bounded safe page"); + let page = PublishedSlabPage::new(cfg).expect("bounded published page"); + let (mut writer, reader) = page.split(); + let mut expected_payloads = Vec::with_capacity(frame_count); + let mut published_descriptors = Vec::with_capacity(frame_count); + let mut state = 0x9e37_79b9_7f4a_7c15_u64; + + for index in 0..frame_count { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let len = (state as usize % 257) + 1; + let mut payload = Vec::with_capacity(len); + for byte_index in 0..len { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + payload.push((state as u8) ^ byte_index as u8); + } + let schema_id = (index % 11) as u32; + let flags = (state >> 48) as u16; + + sealed_builder + .try_append(&payload, schema_id, flags) + .expect("safe corpus append fits"); + published_descriptors.push( + writer + .try_append(&payload, schema_id, flags) + .expect("published corpus append fits"), + ); + expected_payloads.push(payload); + } + + let expected_exhaustion = SlabAppendError::DescriptorCapacityExhausted { + capacity: frame_count, + }; + assert_eq!( + sealed_builder.try_append(b"overflow", 99, 0), + Err(expected_exhaustion) + ); + assert_eq!( + writer.try_append(b"overflow", 99, 0), + Err(expected_exhaustion) + ); + + let sealed = sealed_builder.seal(); + let sealed_reader = sealed.reader(); + assert_eq!(published_descriptors.as_slice(), sealed.descriptors()); + assert_eq!(writer.next_sequence(), sealed.next_sequence()); + for ((descriptor, expected), sealed_descriptor) in published_descriptors + .iter() + .zip(&expected_payloads) + .zip(sealed.descriptors()) + { + assert_eq!(descriptor, sealed_descriptor); + assert_eq!( + reader + .resolve(descriptor) + .expect("published corpus view") + .as_ref(), + expected.as_slice() + ); + assert_eq!( + sealed_reader + .resolve(sealed_descriptor) + .expect("safe corpus view"), + expected.as_slice() + ); + } +} + +#[test] +fn exact_frame_and_page_boundaries_are_enforced_without_partial_publication() { + let frame_len = if cfg!(miri) { 4_096 } else { MAX_FRAME_BYTES }; + let page = PublishedSlabPage::new(config(frame_len, 2)).expect("bounded page"); + let (mut writer, reader) = page.split(); + let payload = vec![0xa5; frame_len]; + + let descriptor = writer + .try_append(&payload, 3, 0) + .expect("exact byte capacity fits"); + assert_eq!(writer.remaining_bytes(), 0); + assert_eq!(reader.published_bytes(), frame_len); + assert_eq!( + writer.try_append(b"x", 3, 0), + Err(SlabAppendError::PageFull { + requested: 1, + remaining: 0, + }) + ); + assert_eq!(writer.published_count(), 1); + assert_eq!(reader.published_count(), 1); + assert_eq!( + reader + .resolve(&descriptor) + .expect("boundary frame resolves") + .as_ref(), + payload.as_slice() + ); + + if !cfg!(miri) { + let oversized = vec![0_u8; MAX_FRAME_BYTES + 1]; + assert_eq!( + writer.try_append(&oversized, 3, 0), + Err(SlabAppendError::FrameTooLarge { + len: MAX_FRAME_BYTES + 1, + maximum: MAX_FRAME_BYTES, + }) + ); + assert_eq!(writer.published_count(), 1); + } +} + +#[test] +fn writer_drop_freezes_the_prefix_and_reader_keeps_the_page_alive() { + let page = PublishedSlabPage::new(config(16, 1)).expect("bounded page"); + let (mut writer, reader) = page.split(); + let descriptor = writer.try_append(b"lease", 1, 0).expect("payload fits"); + + assert!(!reader.is_writer_closed()); + drop(writer); + assert!(reader.is_writer_closed()); + assert_eq!(reader.published_count(), 1); + assert_eq!( + reader + .resolve(&descriptor) + .expect("reader owns the page after writer drop") + .as_ref(), + b"lease" + ); +} + +#[test] +fn publication_state_and_cells_have_the_reviewed_native_layout() { + let page = PublishedSlabPage::new(config(8, 2)).expect("bounded page"); + let layout = page.layout(); + + assert_eq!(layout.publication_state_alignment, CACHE_LINE_BYTES); + assert!(layout.publication_state_size >= CACHE_LINE_BYTES); + assert_eq!(layout.publication_state_size % CACHE_LINE_BYTES, 0); + assert_eq!(layout.byte_cell_size, 1); + assert_eq!(layout.byte_cell_alignment, 1); + assert_eq!(layout.descriptor_cell_size, 32); + assert_eq!(layout.descriptor_cell_alignment, 32); +} + +#[test] +fn cross_thread_ring_stress_resolves_only_fully_published_payloads() { + let event_count = if cfg!(miri) { 64_u64 } else { 50_000_u64 }; + let page = PublishedSlabPage::new(config( + usize::try_from(event_count * 8).expect("test byte capacity fits usize"), + usize::try_from(event_count).expect("test descriptor capacity fits usize"), + )) + .expect("bounded page"); + let (mut writer, reader) = page.split(); + let ring = SpscRing::::new().expect("valid descriptor ring"); + let (mut producer, mut consumer) = ring.split(); + + let consumer_thread = thread::spawn(move || { + for expected in 0..event_count { + let descriptor = loop { + match consumer.try_pop() { + Ok(value) => break value, + Err(TryPopError::Empty) => thread::yield_now(), + Err(TryPopError::Disconnected) => { + panic!("producer disconnected before every descriptor arrived") + } + } + }; + assert_eq!(descriptor.sequence, 41_u64.wrapping_add(expected)); + let payload = reader + .resolve(&descriptor) + .expect("ring cannot expose an incomplete append"); + let bytes: [u8; 8] = payload + .as_ref() + .try_into() + .expect("test payload is one u64"); + assert_eq!(u64::from_le_bytes(bytes), expected); + } + + loop { + match consumer.try_pop() { + Err(TryPopError::Disconnected) => break, + Err(TryPopError::Empty) => thread::yield_now(), + Ok(_) => panic!("unexpected descriptor after the test population"), + } + } + assert!(reader.is_writer_closed()); + assert_eq!(reader.published_count(), event_count as usize); + }); + + for value in 0..event_count { + let descriptor = writer + .try_append(&value.to_le_bytes(), 1, 0) + .expect("preallocated page has capacity"); + let mut pending = descriptor; + loop { + match producer.try_push(pending) { + Ok(()) => break, + Err(TryPushError::Full(value)) => { + pending = value; + thread::yield_now(); + } + Err(TryPushError::Disconnected(_)) => { + panic!("consumer disconnected during publication stress") + } + } + } + } + + drop(writer); + drop(producer); + consumer_thread.join().expect("consumer thread succeeds"); +} diff --git a/lib/event/tests/slab.rs b/lib/event/tests/slab.rs new file mode 100644 index 00000000..4d74b3e5 --- /dev/null +++ b/lib/event/tests/slab.rs @@ -0,0 +1,379 @@ +use aegis_event::{ + SlabAppendError, SlabConfigError, SlabPageBuilder, SlabPageConfig, SlabReadError, + MAX_FRAME_BYTES, MAX_SLAB_DESCRIPTORS, MAX_SLAB_PAGE_BYTES, +}; +#[cfg(not(feature = "loom"))] +use aegis_event::{SpscRing, TryPopError}; + +fn config(byte_capacity: usize, descriptor_capacity: usize) -> SlabPageConfig { + SlabPageConfig { + arena_id: 17, + arena_generation: 23, + byte_capacity, + descriptor_capacity, + first_sequence: 41, + } +} + +#[test] +fn slab_configuration_rejects_unbounded_or_empty_pages() { + assert_eq!( + SlabPageBuilder::new(config(0, 1)).expect_err("zero byte capacity must fail"), + SlabConfigError::ZeroByteCapacity + ); + assert_eq!( + SlabPageBuilder::new(config(MAX_SLAB_PAGE_BYTES + 1, 1)) + .expect_err("oversized page must fail before allocation"), + SlabConfigError::ByteCapacityTooLarge { + requested: MAX_SLAB_PAGE_BYTES + 1, + maximum: MAX_SLAB_PAGE_BYTES, + } + ); + assert_eq!( + SlabPageBuilder::new(config(1, 0)).expect_err("zero descriptors must fail"), + SlabConfigError::ZeroDescriptorCapacity + ); + assert_eq!( + SlabPageBuilder::new(config(1, MAX_SLAB_DESCRIPTORS + 1)) + .expect_err("oversized descriptor budget must fail before allocation"), + SlabConfigError::DescriptorCapacityTooLarge { + requested: MAX_SLAB_DESCRIPTORS + 1, + maximum: MAX_SLAB_DESCRIPTORS, + } + ); +} + +#[test] +fn append_then_seal_builds_checked_descriptors_without_reencoding_payloads() { + let mut builder = SlabPageBuilder::new(config(64, 4)).expect("bounded page"); + + builder + .try_append(b"123456789", 7, 0x0001) + .expect("first frame fits"); + builder + .try_append(b"second", 8, 0x0002) + .expect("second frame fits"); + + assert_eq!(builder.used_bytes(), 15); + assert_eq!(builder.descriptor_count(), 2); + assert_eq!(builder.remaining_bytes(), 49); + assert_eq!(builder.remaining_descriptors(), 2); + + let sealed = builder.seal(); + let descriptors = sealed.descriptors(); + + assert_eq!(sealed.arena_id(), 17); + assert_eq!(sealed.arena_generation(), 23); + assert_eq!(sealed.used_bytes(), 15); + assert_eq!(descriptors.len(), 2); + assert_eq!(descriptors[0].sequence, 41); + assert_eq!(descriptors[0].offset, 0); + assert_eq!(descriptors[0].len, 9); + assert_eq!(descriptors[0].crc32c, 0xe306_9283); + assert_eq!(descriptors[0].schema_id, 7); + assert_eq!(descriptors[0].flags, 0x0001); + assert_eq!(descriptors[1].sequence, 42); + assert_eq!(descriptors[1].offset, 9); + assert_eq!(descriptors[1].len, 6); + + let reader = sealed.reader(); + assert_eq!( + reader.resolve(&descriptors[0]).expect("valid first view"), + b"123456789" + ); + assert_eq!( + reader.resolve(&descriptors[1]).expect("valid second view"), + b"second" + ); +} + +#[test] +fn failed_appends_leave_the_builder_state_unchanged() { + let mut builder = SlabPageBuilder::new(config(4, 2)).expect("bounded page"); + + assert_eq!( + builder.try_append(&[], 1, 0), + Err(SlabAppendError::EmptyPayload) + ); + assert_eq!(builder.used_bytes(), 0); + assert_eq!(builder.descriptor_count(), 0); + + builder + .try_append(b"abc", 1, 0) + .expect("first payload fits"); + assert_eq!( + builder.try_append(b"de", 1, 0), + Err(SlabAppendError::PageFull { + requested: 2, + remaining: 1, + }) + ); + assert_eq!(builder.used_bytes(), 3); + assert_eq!(builder.descriptor_count(), 1); + + builder + .try_append(b"d", 2, 0) + .expect("a failed append did not consume bytes or sequence"); + + let oversized = vec![0_u8; MAX_FRAME_BYTES + 1]; + assert_eq!( + builder.try_append(&oversized, 1, 0), + Err(SlabAppendError::FrameTooLarge { + len: MAX_FRAME_BYTES + 1, + maximum: MAX_FRAME_BYTES, + }) + ); + assert_eq!(builder.used_bytes(), 4); + assert_eq!(builder.descriptor_count(), 2); + + let sealed = builder.seal(); + assert_eq!(sealed.descriptors()[0].sequence, 41); + assert_eq!(sealed.descriptors()[1].sequence, 42); + assert_eq!(sealed.next_sequence(), 43); +} + +#[test] +fn descriptor_budget_exhaustion_is_explicit_and_non_mutating() { + let mut builder = SlabPageBuilder::new(config(8, 1)).expect("bounded page"); + builder.try_append(b"one", 1, 0).expect("first frame fits"); + + assert_eq!( + builder.try_append(b"two", 1, 0), + Err(SlabAppendError::DescriptorCapacityExhausted { capacity: 1 }) + ); + assert_eq!(builder.used_bytes(), 3); + assert_eq!(builder.descriptor_count(), 1); +} + +#[test] +fn sequence_generation_is_monotonic_modulo_u64() { + let mut cfg = config(8, 2); + cfg.first_sequence = u64::MAX; + let mut builder = SlabPageBuilder::new(cfg).expect("bounded page"); + builder.try_append(b"a", 1, 0).expect("first frame fits"); + builder.try_append(b"b", 1, 0).expect("second frame fits"); + + let sealed = builder.seal(); + assert_eq!(sealed.descriptors()[0].sequence, u64::MAX); + assert_eq!(sealed.descriptors()[1].sequence, 0); + assert_eq!(sealed.next_sequence(), 1); + let reader = sealed.reader(); + assert_eq!( + reader + .resolve(&sealed.descriptors()[0]) + .expect("wrapped first descriptor resolves"), + b"a" + ); + assert_eq!( + reader + .resolve(&sealed.descriptors()[1]) + .expect("wrapped second descriptor resolves"), + b"b" + ); + + let mut next_page_config = config(8, 1); + next_page_config.arena_generation = 24; + next_page_config.first_sequence = sealed.next_sequence(); + let mut next_page = SlabPageBuilder::new(next_page_config).expect("next bounded page"); + next_page + .try_append(b"c", 1, 0) + .expect("next-page payload fits"); + assert_eq!(next_page.seal().descriptors()[0].sequence, 1); +} + +#[test] +fn resolver_fails_closed_on_identity_generation_and_metadata_mismatch() { + let mut builder = SlabPageBuilder::new(config(32, 2)).expect("bounded page"); + builder.try_append(b"verified", 9, 0).expect("payload fits"); + builder + .try_append(b"redirect", 10, 1) + .expect("second payload fits"); + let sealed = builder.seal(); + let reader = sealed.reader(); + let descriptor = sealed.descriptors()[0]; + let second = sealed.descriptors()[1]; + + let mut wrong_arena = descriptor; + wrong_arena.arena_id = 99; + assert_eq!( + reader.resolve(&wrong_arena), + Err(SlabReadError::ArenaIdMismatch { + page: 17, + descriptor: 99, + }) + ); + + let mut stale_generation = descriptor; + stale_generation.arena_generation = 22; + assert_eq!( + reader.resolve(&stale_generation), + Err(SlabReadError::GenerationMismatch { + page: 23, + descriptor: 22, + }) + ); + + let mut empty = descriptor; + empty.len = 0; + empty.crc32c = 0; + assert_eq!(reader.resolve(&empty), Err(SlabReadError::EmptyPayload)); + + let mut outside_used_bytes = descriptor; + outside_used_bytes.offset = 15; + outside_used_bytes.len = 2; + assert_eq!( + reader.resolve(&outside_used_bytes), + Err(SlabReadError::DescriptorMismatch { sequence: 41 }) + ); + + let mut wrong_crc = descriptor; + wrong_crc.crc32c ^= 1; + assert_eq!( + reader.resolve(&wrong_crc), + Err(SlabReadError::DescriptorMismatch { sequence: 41 }) + ); + + let mut unknown_sequence = descriptor; + unknown_sequence.sequence = 500; + assert_eq!( + reader.resolve(&unknown_sequence), + Err(SlabReadError::UnknownSequence { + first: 41, + count: 2, + descriptor: 500, + }) + ); + + let mut relabeled = descriptor; + relabeled.schema_id = 10; + assert_eq!( + reader.resolve(&relabeled), + Err(SlabReadError::DescriptorMismatch { sequence: 41 }) + ); + + let mut redirected = descriptor; + redirected.offset = second.offset; + redirected.len = second.len; + redirected.crc32c = second.crc32c; + assert_eq!( + reader.resolve(&redirected), + Err(SlabReadError::DescriptorMismatch { sequence: 41 }) + ); + + let mut changed_flags = descriptor; + changed_flags.flags ^= 1; + assert_eq!( + reader.resolve(&changed_flags), + Err(SlabReadError::DescriptorMismatch { sequence: 41 }) + ); + + let mut swapped_sequence = descriptor; + swapped_sequence.sequence = second.sequence; + assert_eq!( + reader.resolve(&swapped_sequence), + Err(SlabReadError::DescriptorMismatch { + sequence: second.sequence, + }) + ); +} + +#[test] +fn page_level_reader_lease_keeps_immutable_payload_alive() { + let mut builder = SlabPageBuilder::new(config(16, 1)).expect("bounded page"); + builder.try_append(b"lease", 1, 0).expect("payload fits"); + let sealed = builder.seal(); + let descriptor = sealed.descriptors()[0]; + let reader = sealed.reader(); + drop(sealed); + + assert_eq!( + reader.resolve(&descriptor).expect("lease remains live"), + b"lease" + ); +} + +#[test] +fn arbitrary_descriptor_metadata_never_exposes_an_unregistered_view() { + let mut builder = SlabPageBuilder::new(config(32, 2)).expect("bounded page"); + builder.try_append(b"abc", 1, 0).expect("first frame fits"); + builder + .try_append(b"defg", 2, 1) + .expect("second frame fits"); + let sealed = builder.seal(); + let reader = sealed.reader(); + let registered = sealed.descriptors(); + let attempts = if cfg!(miri) { 64 } else { 1_024 }; + let lengths = [0, 1, 3, 4, (MAX_FRAME_BYTES + 1) as u32, u32::MAX]; + let mut state = 0x9e37_79b9_7f4a_7c15_u64; + + for attempt in 0..attempts { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let descriptor = aegis_event::TelemetryDescriptor { + sequence: state, + arena_generation: (state >> 16) as u32, + arena_id: (state >> 48) as u16, + flags: state as u16, + offset: (state >> 32) as u32, + len: lengths[attempt % lengths.len()], + crc32c: state as u32, + schema_id: (state >> 8) as u32, + }; + + if let Ok(payload) = reader.resolve(&descriptor) { + assert!(registered.contains(&descriptor)); + let range = descriptor + .checked_payload_range(sealed.used_bytes()) + .expect("registered descriptor has a valid range"); + assert_eq!(payload, &b"abcdefg"[range]); + } + } +} + +#[test] +#[cfg(not(feature = "loom"))] +fn spsc_ring_transfers_only_descriptors_for_a_sealed_page() { + let mut builder = SlabPageBuilder::new(config(16, 2)).expect("bounded page"); + builder.try_append(b"one", 1, 0).expect("first frame fits"); + builder.try_append(b"two", 1, 0).expect("second frame fits"); + let sealed = builder.seal(); + let reader = sealed.reader(); + + let ring = SpscRing::<_, 2>::new().expect("valid descriptor ring"); + let (mut producer, mut consumer) = ring.split(); + let consumer_thread = std::thread::spawn(move || { + let (first, second) = { + let mut pop_next = || loop { + match consumer.try_pop() { + Ok(descriptor) => break descriptor, + Err(TryPopError::Empty) => std::thread::yield_now(), + Err(TryPopError::Disconnected) => { + panic!("producer disconnected before expected descriptor") + } + } + }; + (pop_next(), pop_next()) + }; + assert_eq!(reader.resolve(&first).expect("first page view"), b"one"); + assert_eq!(reader.resolve(&second).expect("second page view"), b"two"); + loop { + match consumer.try_pop() { + Err(TryPopError::Disconnected) => break, + Err(TryPopError::Empty) => std::thread::yield_now(), + Ok(_) => panic!("descriptor ring returned an unexpected third value"), + } + } + }); + + for descriptor in sealed.descriptors() { + producer + .try_push(*descriptor) + .expect("descriptor ring has capacity"); + } + drop(producer); + drop(sealed); + consumer_thread + .join() + .expect("descriptor consumer thread succeeds"); +} diff --git a/lib/event/tests/spsc.rs b/lib/event/tests/spsc.rs new file mode 100644 index 00000000..95770665 --- /dev/null +++ b/lib/event/tests/spsc.rs @@ -0,0 +1,151 @@ +#![cfg(not(feature = "loom"))] + +use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + thread, +}; + +use aegis_event::{RingConfigError, SpscRing, TryPopError, TryPushError, CACHE_LINE_BYTES}; + +#[test] +fn constructor_rejects_invalid_capacities() { + assert!(matches!( + SpscRing::::new(), + Err(RingConfigError::ZeroCapacity) + )); + assert!(matches!( + SpscRing::::new(), + Err(RingConfigError::NotPowerOfTwo { capacity: 3 }) + )); +} + +#[test] +fn fifo_full_and_empty_transitions_preserve_value_ownership() { + let ring = SpscRing::::new().expect("valid ring"); + let (mut producer, mut consumer) = ring.split(); + + assert_eq!(producer.capacity(), 2); + assert_eq!(consumer.capacity(), 2); + assert_eq!(consumer.try_pop(), Err(TryPopError::Empty)); + assert_eq!(producer.try_push("one".to_owned()), Ok(())); + assert_eq!(producer.try_push("two".to_owned()), Ok(())); + + let rejected = "three".to_owned(); + assert_eq!( + producer.try_push(rejected.clone()), + Err(TryPushError::Full(rejected)) + ); + assert_eq!(consumer.try_pop(), Ok("one".to_owned())); + assert_eq!(producer.try_push("three".to_owned()), Ok(())); + assert_eq!(consumer.try_pop(), Ok("two".to_owned())); + assert_eq!(consumer.try_pop(), Ok("three".to_owned())); + assert_eq!(consumer.try_pop(), Err(TryPopError::Empty)); +} + +#[test] +fn consumer_drains_published_values_before_reporting_disconnect() { + let ring = SpscRing::::new().expect("valid ring"); + let (mut producer, mut consumer) = ring.split(); + + producer.try_push(9).expect("ring has capacity"); + drop(producer); + + assert_eq!(consumer.try_pop(), Ok(9)); + assert_eq!(consumer.try_pop(), Err(TryPopError::Disconnected)); +} + +#[test] +fn producer_returns_the_value_when_the_consumer_is_closed() { + let ring = SpscRing::::new().expect("valid ring"); + let (mut producer, consumer) = ring.split(); + drop(consumer); + + assert_eq!(producer.try_push(11), Err(TryPushError::Disconnected(11))); +} + +#[derive(Clone, Debug)] +struct DropProbe(Arc); + +impl Drop for DropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +#[test] +fn final_ring_drop_destroys_each_unread_value_once() { + let drops = Arc::new(AtomicUsize::new(0)); + let ring = SpscRing::::new().expect("valid ring"); + let (mut producer, consumer) = ring.split(); + + producer + .try_push(DropProbe(Arc::clone(&drops))) + .expect("ring has capacity"); + producer + .try_push(DropProbe(Arc::clone(&drops))) + .expect("ring has capacity"); + + drop(consumer); + assert_eq!(drops.load(Ordering::Relaxed), 0); + drop(producer); + assert_eq!(drops.load(Ordering::Relaxed), 2); +} + +#[test] +fn producer_and_consumer_sequences_are_on_distinct_cache_lines() { + let ring = SpscRing::::new().expect("valid ring"); + let layout = ring.layout(); + + assert_eq!(layout.cursor_alignment, CACHE_LINE_BYTES); + assert!(layout.consumer_sequence_offset >= layout.producer_sequence_offset + CACHE_LINE_BYTES); + assert_eq!( + layout.consumer_sequence_offset - layout.producer_sequence_offset, + CACHE_LINE_BYTES + ); +} + +#[test] +fn native_cross_thread_stress_has_no_loss_duplicates_or_reordering() { + let event_count = if cfg!(miri) { 512_u64 } else { 250_000_u64 }; + let ring = SpscRing::::new().expect("valid ring"); + let (mut producer, mut consumer) = ring.split(); + + let producer_thread = thread::spawn(move || { + for sequence in 0..event_count { + let mut pending = sequence; + loop { + match producer.try_push(pending) { + Ok(()) => break, + Err(TryPushError::Full(value)) => { + pending = value; + thread::yield_now(); + } + Err(TryPushError::Disconnected(_)) => { + panic!("consumer disconnected during stress test") + } + } + } + } + }); + + for expected in 0..event_count { + loop { + match consumer.try_pop() { + Ok(actual) => { + assert_eq!(actual, expected); + break; + } + Err(TryPopError::Empty) => thread::yield_now(), + Err(TryPopError::Disconnected) => { + panic!("producer disconnected before publishing all values") + } + } + } + } + + producer_thread.join().expect("producer thread succeeds"); + assert_eq!(consumer.try_pop(), Err(TryPopError::Disconnected)); +} diff --git a/mkdocs.yml b/mkdocs.yml index 15cfc904..41718911 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -172,6 +172,9 @@ nav: - "ADR-0003: aegis-jcs-1 canonicalization": adr/0003-aegis-jcs-1-canonicalization.md - "ADR-0004: Ed25519 receipt signing": adr/0004-ed25519-receipt-signing.md - "ADR-0005: Fail-closed defaults": adr/0005-fail-closed-defaults.md + - "ADR-0006: Cache-padded SPSC event fabric": adr/0006-cache-padded-spsc-event-fabric.md + - "ADR-0007: Sealed generation-tagged slab pages": adr/0007-sealed-generation-tagged-slab-pages.md + - "ADR-0008: Append-only published-prefix slab pages": adr/0008-append-only-published-prefix-slab-pages.md - Reference: - API reference (OpenAPI/Redoc): api-reference.md - Runtime authorization API: runtime-authorization-api.md