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-core over indexbus-abi::layouts::SharedLayout
  • Fanout regions (producer → router → N consumers) via indexbus-core over indexbus-abi::layouts::SharedFanoutLayout<N>
  • State stream (overwrite-latest) via indexbus-core over indexbus-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)).
  • 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 (SpscSenderSpscReceiver)

  • Delivery: at-most-once.
    • If publish* returns Ok(()), the implementation MUST have committed the message to the region such that a receiver can later observe it.
    • If publish* returns Err(Full), the implementation MUST NOT have enqueued the message.
    • Receives are nonblocking: try_recv* MUST return Ok(None) when empty.
  • Duplicate delivery: not expected under correct operation.
  • Loss: possible by design (bounded queues) and via process crashes.

2.2 MPSC events (MpscProducerMpscConsumer)

  • 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:

  1. Producers publish into the producer→router queue.
  2. A router thread/process calls FanoutRouter::route_once_with (or a loop in indexbus-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 Broadcast is 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 (like Park) avoid dequeuing while nobody is eligible

Notes:

  • Credit eligibility is an input to routing. Implementations that support Park/Detach SHOULD 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.
    • Publishing/reserving is permitted only if $wrap \le \min_i gating_i$.
  • Consumer barrier: a consumer/stage may wait until cursor >= next to 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 seq counter in the mapped layout.
  • seq even means stable snapshot; seq odd means write in progress.

Normative rules (v1):

  • A writer MUST publish a new snapshot by:
    1. incrementing seq to an odd value (write-in-progress),
    2. writing payload bytes and publishing len,
    3. incrementing seq to the next even value (stable).
  • A reader MUST only accept a snapshot if it observes the same even seq value before and after copying the bytes and reading len.
  • 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 than INDEXBUS_SLOT_DATA_SIZE with Error::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 with TooLarge and 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.
  • 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)

5) Memory visibility (high-level)

IndexBus relies on the platform memory model and uses Acquire/Release atomics internally.

Normative guidance for users:

  • If publish* returns Ok(()), 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-msg magic is USM1).
  • Unknown flags MUST be rejected for deterministic behavior.
  • payload_len MUST be consistent with the available capacity (i.e. header_len + payload_len <= INDEXBUS_SLOT_DATA_SIZE).
  • msg_version is 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-msg header + payload via a chosen codec
    • they validate that meta/header values match at receive

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) and atomic_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 to head to 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 seq to odd (write-in-progress), writes bytes, publishes len, then increments seq to even (stable). (Current implementation uses fetch_add with AcqRel for the odd transition, store(len, Release), then fetch_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):

let next = cursor + 1;
let wrap = next.saturating_sub(capacity);
if wrap <= min_gating {
  // allowed to claim/publish
} else {
  // would wrap past slowest consumer
  return Err(Full);
}

State-stream reader snapshot rule (v1):

let s0 = seq.load(Acquire);
if s0 % 2 == 1 { return None; }
copy_bytes();
let s1 = seq.load(Acquire);
if s1 == s0 && s1 % 2 == 0 { return Some(snapshot); }
None

Appendix B) Arithmetic reference

  • Queue depth: $depth = tail \ominus head$ (Rust: tail.wrapping_sub(head)).
  • Queue full test: depth >= cap where cap is the fixed ring capacity.
  • Ring index: pos = counter & (cap - 1) when cap is a power of two.

See also: ../guides/v1-conformance-tests.md

Recommended (prioritized) semantic checks for this repo:

  1. SPSC publish/recv preserves FIFO order.
  2. SPSC returns Full when saturated.
  3. MPSC preserves per-producer ordering.
  4. Fanout Broadcast permits partial delivery (per-consumer drops).
  5. Fanout WorkQueue delivers to at most one consumer (never to two consumers).
  6. Credits skip no-credit consumers and behave according to the selected policy.
  7. State stream never returns a partial snapshot (parity protocol).

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).
Provenance
Need the canonical source?
Use the public hub to orient yourself, then jump to repo-owned docs or rustdoc when you need contract-level detail.