IndexBus Semantics (normative, v1)
This document defines normative semantics for IndexBus v1 as implemented in this repo.
Normative language
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Scope:
- Events regions (SPSC + MPSC) via
indexbus-coreoverindexbus-abi::layouts::SharedLayout - Fanout regions (producer → router → N consumers) via
indexbus-coreoverindexbus-abi::layouts::SharedFanoutLayout<N> - State stream (overwrite-latest) via
indexbus-coreoverindexbus-abi::layouts::StateLayout<STATE_MAX> - Structured envelope (
indexbus-msg) and typed adapters (indexbus-typed) as conventions over slot bytes
Non-goals:
- Exactly-once delivery, durability, replication, or cross-host reliability guarantees.
1) Common terminology
- Region: a mapped/shared memory layout defined in
indexbus-abi. - Slot: fixed-size payload storage (bytes) with an associated length.
- Queue: an index queue carrying slot indices (not payload bytes).
- Producer / consumer: publishes into / receives from a queue.
- Router: a fanout component that moves indices from the producer queue into consumer queues.
1.1 Counters, head/tail, and wrapping arithmetic
IndexBus v1 uses monotonic unsigned counters in shared memory (typically u64) for
cursors like head, tail, and cursor, which wrap modulo $2^64$.
Normative rules:
- Implementations MUST treat these counters as monotonic unsigned counters that wrap modulo $2^64$.
- Modular differences between counters (e.g. queue depth) MUST be computed using
wrapping subtraction.
- Example: $depth = tail \ominus head$ (Rust:
tail.wrapping_sub(head)).
- Example: $depth = tail \ominus head$ (Rust:
- Non-modular, clamped computations (e.g. sequencer wrap-prevention “wrap point” defined
as $\max(0, S - C)$) MUST use an arithmetic form that cannot underflow; in Rust
this is typically
saturating_sub. - Depth comparisons against capacities (which are small, fixed, power-of-two constants in v1) MUST use the wrapped depth.
Terminology:
- head: the consumer position (number of dequeues so far).
- tail: the producer position (number of enqueues so far).
- depth: number of elements present in a bounded queue, defined as $tail \ominus head$.
2) Delivery semantics by primitive
2.1 SPSC events (SpscSender → SpscReceiver)
- Delivery: at-most-once.
- If
publish*returnsOk(()), the implementation MUST have committed the message to the region such that a receiver can later observe it. - If
publish*returnsErr(Full), the implementation MUST NOT have enqueued the message. - Receives are nonblocking:
try_recv*MUST returnOk(None)when empty.
- If
- Duplicate delivery: not expected under correct operation.
- Loss: possible by design (bounded queues) and via process crashes.
2.2 MPSC events (MpscProducer → MpscConsumer)
- Delivery: at-most-once.
- Ordering:
- Per-producer ordering MUST be preserved (a single producer’s publishes are observed in order).
- Cross-producer ordering is not guaranteed.
2.3 Fanout (router-mediated)
Fanout has two stages:
- Producers publish into the producer→router queue.
- A router thread/process calls
FanoutRouter::route_once_with(or a loop inindexbus-route).
Semantics depend on RouterMode:
Broadcast:- For each dequeued message, the router MUST attempt an independent enqueue into each consumer queue.
- Delivery is per-consumer best-effort: a message MAY be delivered to some consumers and dropped for others due to bounded destination capacity.
WorkQueue:- Each routed message is delivered to at most one consumer.
- Selection is round-robin search starting from a router cursor (
router_rr) in v1. - If routing is attempted while all candidate consumer queues are full, and the router policy does not allow waiting/parking, the message MUST be dropped.
- With router-enforced credits and
Park/Detach, implementations MAY park/wait without dequeuing from the producer→router queue until at least one consumer is eligible (§2.4). - Implementations that claim deterministic selection/replayability MUST document their selection rule (e.g., strict round-robin by consumer id).
Failure/liveness notes:
- The router step is not atomic across consumers. Partial delivery in
Broadcastis allowed. - If the router dies mid-message, partial delivery is expected and acceptable in v1; any drop attribution/metadata MAY be approximate.
- Without a running router, consumer queues do not advance and producers will eventually
observe
Err(Full)once the producer→router queue and/or slot pool saturates.
2.4 Fanout credits (router-enforced)
Router-enforced credits add overload control on top of base fanout routing.
Credit model (v1): depth-based credit.
- For each consumer $i$, the router estimates queue depth as $depth_i = tail_i \ominus head_i$.
- Let $cap$ be the fixed destination queue capacity.
- A consumer is eligible if it is not detached, has destination space ($depth_i < cap$), and $depth_i < credit_max$.
- A consumer is ineligible (no-credit) if it is not detached, has destination space ($depth_i < cap$), and $depth_i \ge credit_max$.
- A consumer is ineligible (full) if it has no destination space ($depth_i \ge cap$).
Behavior by routing mode:
-
Broadcast:- The router attempts to deliver to eligible consumers only.
- Consumers that are already at/over the credit limit are skipped (not attempted).
- Drop attribution is best-effort in v1 and MUST NOT be treated as a strict per-consumer accounting contract.
-
WorkQueue:- The router selects only eligible consumers for delivery.
- If no consumers are eligible, the router action is policy-defined:
CreditPolicy::Drop: dequeue and drop (attributed to no-credit when possible)CreditPolicy::Park: do not dequeue; wait (bounded by the configured wait strategy)CreditPolicy::Detach: detach persistently-over-credit consumers (router-local) and (likePark) avoid dequeuing while nobody is eligible
Notes:
- Credit eligibility is an input to routing. Implementations that support
Park/DetachSHOULD compute eligibility before dequeuing from the producer→router queue so that parking does not consume and drop work.
Credits do not change delivery semantics: they are a bounded overload-control mechanism and do not provide reliability.
2.5 Sequencer + gating sequences
The sequencer provides a Disruptor-style sequencer control plane over a shared region:
- A single published cursor (
SequencerLayout.cursor) representing the highest published sequence. - Per-consumer gating sequences (
SequencerLayout.gating[i]) representing the highest sequence that consumer/stage has completed.
Storage model (v1): the sequencer does not dictate payload storage. Typical usage is a caller-owned ring buffer indexed by $idx = seq \bmod capacity$.
Normative rules (v1):
- Publishing is single-writer (single producer) in v1.
- Producers MUST publish sequences in increasing order.
- Gating: the producer MUST NOT advance such that it would wrap past the minimum
gating sequence.
- Given ring capacity $C$ and next sequence $S$, define the wrap-prevention point as
$wrap = \max(0, S - C)$.
- In Rust:
let wrap = S.saturating_sub(C); - Note: saturating subtraction is used here to express a clamped wrap-prevention point, not a modular counter difference.
- In Rust:
- Publishing/reserving is permitted only if $wrap \le \min_i gating_i$.
- Given ring capacity $C$ and next sequence $S$, define the wrap-prevention point as
$wrap = \max(0, S - C)$.
- Consumer barrier: a consumer/stage may wait until
cursor >= nextto process the next item. - Stage barrier: a downstream stage may wait on an upstream stage’s gating sequence
(e.g. stage C waits until
gating[B] >= next).
When capacity is exhausted, the producer experiences explicit backpressure (reservation
fails with Full or the caller blocks/polls until gating advances).
2.6 State stream (overwrite-latest)
The state stream is an overwrite-latest shared region with a sequence-parity protocol:
- The writer maintains a
seqcounter in the mapped layout. seqeven means stable snapshot;seqodd means write in progress.
Normative rules (v1):
- A writer MUST publish a new snapshot by:
- incrementing
seqto an odd value (write-in-progress), - writing payload bytes and publishing
len, - incrementing
seqto the next even value (stable).
- incrementing
- A reader MUST only accept a snapshot if it observes the same even
seqvalue before and after copying the bytes and readinglen. - Under correct use of the protocol, a reader MUST NOT observe a partially written snapshot.
3) Ordering guarantees
3.1 Within a single stream
- Events (SPSC, MPSC): FIFO order is preserved within the constraints above.
- Fanout: for a given consumer, the order matches the router’s routing order.
3.2 Cross-stream and cross-producer ordering
- Cross-producer ordering is not guaranteed unless explicitly stated.
- Fanout does not introduce additional ordering across different producer sources beyond the router’s selection behavior.
4) Boundedness and capacity
4.0 Capacity constants (v1)
The following constants are fixed by the v1 ABI and define the boundedness of the system:
INDEXBUS_SLOT_DATA_SIZE: bytes available per slot payload.INDEXBUS_SLOTS_CAPACITY: number of slots in the shared pool.INDEXBUS_QUEUE_CAPACITY: ring capacity for index queues (power-of-two).
4.1 Slot payload bounds
- Each slot has a fixed byte capacity
INDEXBUS_SLOT_DATA_SIZE. - Publishing raw bytes:
publish(&[u8])MUST reject payloads larger thanINDEXBUS_SLOT_DATA_SIZEwithError::TooLarge.
- Publishing via closure:
publish_with(|buf| -> Result<usize, _>)MUST return the number of bytes written.- If the returned
len > INDEXBUS_SLOT_DATA_SIZE, the publish MUST fail withTooLargeand the slot MUST be freed.
4.2 Slot pool and queue capacity
- Regions have a fixed-size slot pool (
INDEXBUS_SLOTS_CAPACITY). - Enqueueing consumes a slot index; dequeueing eventually frees it back to the pool.
- When the pool is exhausted or the queue cannot accept an index, publishing fails with
Error::Full.
4.3 Full/empty behavior
publish*:- returns
Ok(())when the message is committed to the region. - returns
Err(Full)when bounded capacity is exhausted.
- returns
try_recv_into/try_recv_with:- returns
Ok(None)when empty. - returns
Err(BufferTooSmall)when the caller’s output buffer cannot fit the message. - may return
Err(IncompatibleLayout)if the mapped region contains invalid slot metadata (treated as corruption/incompatibility)
- returns
5) Memory visibility (high-level)
IndexBus relies on the platform memory model and uses Acquire/Release atomics internally.
Normative guidance for users:
- If
publish*returnsOk(()), consumers that successfully receive that message will observe the written payload bytes. - The message boundary (commit) is the synchronization point; users must not assume visibility before the message is committed.
This spec is intentionally high-level; the precise atomic operations are an internal detail, but MUST uphold the above rule. See Appendix A for a concrete (v1) mapping of logical events to Acquire/Release operations.
6) Safety and lifetime rules
6.1 Borrowed receive (try_recv_with)
- The borrowed
&[u8]passed to the callback is valid only for the duration of the callback. - It MUST NOT be stored or used after the callback returns.
- After the callback returns (success or error), the slot is freed back to the pool.
6.2 Publication via closure (publish_with)
- The
&mut [u8]passed to the closure is exclusive access to a slot’s payload buffer. - The closure MUST return the number of bytes it wrote.
- On any closure error, the slot MUST be freed and the publish is treated as not having happened.
6.3 Layout validity
- Handles are only valid over regions that pass validation (
validate_*/split_*/fanout_handles). - If validation fails, callers MUST treat the region as incompatible/corrupt and not proceed.
6.4 Corruption, incompatibility, and recovery
- Callers MUST validate a mapping (via
validate_*or a constructor that performs validation) before creating handles and using a region across process boundaries. - If an operation returns
Error::IncompatibleLayout, the mapping MUST be treated as incompatible or corrupt and all derived handles MUST be considered invalid. - The safe recovery action is to unmap and recreate the region using a known-good producer, then re-run validation before resuming.
7) Envelope + typed messaging conventions
7.1 Envelope (indexbus-msg)
- The v1 envelope header is fixed-size (
V1_HEADER_LEN) and lives at the start of slot bytes. - The envelope header MUST contain a fixed magic value and decoders MUST
validate it (v1
indexbus-msgmagic isUSM1). - Unknown flags MUST be rejected for deterministic behavior.
payload_lenMUST be consistent with the available capacity (i.e.header_len + payload_len <= INDEXBUS_SLOT_DATA_SIZE).msg_versionis an application-level version field; receivers MAY enforce version policies.
7.2 Typed adapters (indexbus-typed)
- Typed adapters are a layering on top of the bytes primitives:
- they encode a
indexbus-msgheader + payload via a chosen codec - they validate that meta/header values match at receive
- they encode a
Typed adapters do not change delivery semantics; they only define a message format and validation rules.
Appendix A) Atomic ordering (concrete v1 guidance)
This appendix gives a concrete mapping that satisfies the v1 “Ok means visible” rule while staying within Acquire/Release.
Appendix A.0) 64-bit atomic storage on 32-bit systems
The generated C header represents ABI atomics as storage-only integer typedefs (e.g.
IndexbusAtomicU32, IndexbusAtomicU64) rather than C11 _Atomic.
On some 32-bit platforms, plain 64-bit loads/stores are not naturally atomic and may tear.
Normative integration rules:
- Implementations MUST access shared atomic fields using proper atomic operations (compiler intrinsics / C11 atomics / Rust atomics) with the required ordering.
- Consumers MUST NOT concurrently read/write these fields using plain, non-atomic dereferences.
- Implementations targeting platforms without lock-free 64-bit atomics MUST provide a correct fallback (e.g. a versioned/seqlock-style protocol or another ABI-defined mechanism).
Concrete C/C++ guidance (one acceptable approach):
- C11: use
_Atomic(uint64_t)andatomic_load_explicit/atomic_store_explicit. - GCC/Clang: use
__atomic_load_n(ptr, __ATOMIC_ACQUIRE)/__atomic_store_n(ptr, value, __ATOMIC_RELEASE).
Events (SPSC ring) shape:
- Producer:
- write payload bytes into the slot buffer (ordinary writes)
- write the slot index into the ring buffer slot
store(tail, tail+1, Release)to commit the enqueue- The Release store MUST occur after the payload + ring-slot writes that the consumer will observe.
- Consumer:
load(tail, Acquire)and compare toheadto determine emptiness- read the slot index from the ring buffer
- read payload bytes from the slot buffer
- The Acquire load MUST occur before reading the ring slot index and payload.
store(head, head+1, Release)to commit the dequeue
MPSC queue (sequence-based) shape:
- Producer reserves a position, writes the value, then
store(seq[cell], pos+1, Release). - Consumer
load(seq[cell], Acquire)before reading the stored value.
State stream shape:
- Writer increments
seqto odd (write-in-progress), writes bytes, publisheslen, then incrementsseqto even (stable). (Current implementation usesfetch_addwithAcqRelfor the odd transition,store(len, Release), thenfetch_add(seq, 1, Release)for the even transition.) - Reader uses
load(seq, Acquire)before and after copying; only accepts if the same even value is observed.
This appendix is guidance for implementers. Other implementations are allowed as long as they uphold the observable contracts in this document.
Appendix A.1) Minimal pseudo-Rust examples
Sequencer wrap-prevention check (v1):
State-stream reader snapshot rule (v1):
Appendix B) Arithmetic reference
- Queue depth: $depth = tail \ominus head$ (Rust:
tail.wrapping_sub(head)). - Queue full test:
depth >= capwherecapis the fixed ring capacity. - Ring index:
pos = counter & (cap - 1)whencapis a power of two.
Appendix C) Conformance checklist (recommended)
See also: ../guides/v1-conformance-tests.md
Recommended (prioritized) semantic checks for this repo:
- SPSC publish/recv preserves FIFO order.
- SPSC returns
Fullwhen saturated. - MPSC preserves per-producer ordering.
- Fanout
Broadcastpermits partial delivery (per-consumer drops). - Fanout
WorkQueuedelivers to at most one consumer (never to two consumers). - Credits skip no-credit consumers and behave according to the selected policy.
- State stream never returns a partial snapshot (parity protocol).
8) Observability (recommended)
IndexBus v1 is designed to be operable.
- Implementations SHOULD expose metrics/counters sufficient to distinguish producer-side saturation from router-side and consumer-side bottlenecks (e.g., queue depths, routed/sec, drops/sec, and credit ineligibility).