Skip to content

Releases: userFRM/photon-ring

v3.0.0 — sound by default

Choose a tag to compare

@userFRM userFRM released this 27 Jul 13:31

Eight soundness fixes, a ring for payloads that are not Pod, and a default that Miri can verify.

The headline change: the data-race-free slot implementation is now the default. It used to be opt-in, so a plain channel::<u64>() reached a backend that races on the payload — correct on real hardware, but a data race under the Rust memory model, reachable from entirely safe code. Measured back to back the two are indistinguishable (publish 3.36 ns against 3.39 ns), and both pay the same reader fence on ARM, so there was nothing to trade away. With the default sound, Miri runs the multi-threaded tests in CI instead of skipping fourteen of them: 101 tests, no undefined behaviour.

default-features = false still selects the volatile path, and a CI job keeps it building and tested.


Added

  • event_channel — a ring for payloads that are not Pod. String, Vec,
    enums, Option and bool are ordinary payloads here. Slots own their values,
    built once by a factory and mutated in place rather than overwritten, so
    nothing is copied into or out of the ring and steady-state publishing of
    heap-owning payloads allocates nothing — a String field reuses the capacity
    it had the previous time round the ring.

    The Pod bound exists so that a reader racing a writer sees a harmless torn
    value instead of undefined behaviour. That race is only possible when the
    publisher may overwrite a slot a subscriber has not read, which cannot happen
    on a bounded ring where every subscriber is registered for backpressure. With
    that established, the ring needs no seqlock either: the cursor's
    Release/Acquire pair is the whole publication edge, so there are no stamps,
    no torn-read retry and no fence on this path.

    The cost model differs from the Pod ring in a way worth knowing: an event
    ring costs what the publisher and subscriber actually touch, while the Pod
    ring costs size_of::<T>() on every publish and every receive regardless.
    Updating a few fields of a 4 KiB payload measures 7.9 ns against 207.7 ns for
    the copying ring; at 8 B, where there is no copy worth avoiding, the Pod ring
    is slightly ahead (3.8 ns against 4.1 ns). Rewriting an entire large payload
    every message collects no copy advantage.

    The trade is that every subscriber gates the publisher — there are no lossy
    observers on an event ring, because a reader that can be lapped is exactly the
    reader this design excludes. channel() remains for broadcast with lossy taps.

  • topology::Consumer — a managed terminal consumer. Spawns a thread that
    runs a handler over every message with (message, sequence, end_of_batch),
    handling shutdown, batch signalling and panic capture. Pipeline covers stages
    that transform and publish onward; this covers the end of the line, which
    previously meant hand-rolling a thread and a receive loop. DrainPolicy::Drain
    processes messages still buffered at shutdown so a clean stop loses nothing.

  • Subscribable::subscribe_lossy() — a subscriber that never gates the
    publisher, even on a bounded channel. Registered subscribers keep their
    no-loss guarantee while an observer (telemetry, logging, a debug tap) shares
    the same ring without being able to stall it; when it falls behind it reports
    Lagged { skipped } and receive_ratio() shows what it sampled. Both read the
    same sequence numbers, so observations correlate with the messages a gating
    consumer processed. Previously every subscriber on a bounded ring applied
    backpressure, so mixing the two contracts required two rings and a second
    publish.

  • Subscriber::cursor() — the sequence number this subscriber will read
    next, for correlating positions across subscribers on a ring.

  • tests/loom_backpressure.rs — a loom model of the bounded-channel
    backpressure guarantee, covering the publisher's cached-slowest fast path and
    a subscriber registering against a live publisher. Run in CI. Reverting the
    registration fix makes it fail with the offending interleaving, so the model is
    demonstrably load-bearing rather than decorative.

  • examples/degradation.rs and tests/degradation.rs — the slow-observer
    and dead-consumer scenarios, run and asserted rather than described.

Removed

  • Multi-field tuple Pod impls. (A, B) through the 12-element tuple used
    repr(Rust) layout, so the compiler could insert padding — (u8, u64) carries
    7 padding bytes. That made channel::<(u8, u64)>() undefined behaviour from
    entirely safe code under atomic-slots, using impls the crate itself provided.
    (), (A,), arrays and primitives remain, none of which can carry padding.
    Replace a multi-field tuple payload with a #[repr(C)] struct with explicit
    padding fields. Breaking.
  • Publisher::sequence() — returned exactly what published() returns. Use
    published(), whose docs now carry the lag-computation note. Breaking;
    this makes the next release semver-major.
  • SubscriberGroup, subscribe_group, and AsyncSubscriberGroup. All N
    logical subscribers shared one cursor, so the type had become a newtype over
    Subscriber whose const N did nothing but echo back from aligned_count()
    — an API surface implying a capability that no longer existed. Replace
    subscribe_group::<N>() with subscribe(): it is the same object with the
    same cost, since a group already performed exactly one ring read regardless of
    N. Breaking.

Changed

  • The data-race-free slot implementation is now the default. atomic-slots
    was opt-in, so a plain channel::<u64>() reached a backend that races on the
    payload — correct on real hardware, but a data race under the Rust memory
    model, reachable from entirely safe code. Measured back to back on x86-64 the
    two are indistinguishable (publish 3.39 ns volatile against 3.36 ns atomic;
    publish-and-receive 4.20 against 4.27), and since the reader-side fence landed
    both pay the same barrier on ARM, so there was no performance argument for
    shipping the racy one by default. default-features = false still selects it.

    With the default sound, Miri now runs the multi-threaded correctness tests in
    CI instead of skipping fourteen of them, and a new job keeps the volatile path
    building and tested. Breaking for anyone relying on atomic-slots being
    absent.

  • Dual-licensed under MIT OR Apache-2.0 (was Apache-2.0 only). LICENSE-MIT
    added; existing Apache-2.0 terms are unchanged and remain available.

  • Pipeline::try_join now reports failures. Stage panics are captured at the
    thread boundary, so the old Err(payload) arm was unreachable and the method
    always returned Ok(()) even when a stage had panicked. It now returns
    Err(payload) where payload downcasts to a Vec<usize> of panicked stage
    indices; the original panic payload is not recoverable by design.
    Pipeline::join documented accordingly — it never re-panics.

  • Crate packages exclude docs/, verification/, and scripts/.

Fixed

  • WaitStrategy::MonitorWait let safe code hand the wait path a raw pointer.
    The variant carried a *const u8 in a public enum, so safe code could put any
    address in it and pass it to recv_with; a safety precondition on a safe
    constructor is not enforceable. Removed. MonitorWaitFallback provides the
    same near-zero-power wait via TPAUSE, needs no address, and is sound.
    Breaking.
  • The Message derive accepted fields that are not Pod. An array field
    passed through unchecked, so [bool; 2] produced a wire struct carrying an
    unsafe impl Pod it did not deserve. Every passthrough field is now proven
    Pod at compile time, which also rejects aliases that merely look like
    primitives.
  • publish_with could invoke undefined behaviour from safe code. It handed
    the closure a &mut MaybeUninit<T> and then assumed initialisation
    unconditionally, so publish_with(|_| {}) read uninitialised memory. T: Pod
    makes every initialised bit pattern valid; it does not make uninitialised
    bytes readable. The closure now returns the value instead, which cannot leave
    the payload partly written. Breaking for callers that filled the slot in
    place; return the value instead.
  • event_channel shared &T across threads without requiring T: Sync.
    Several subscribers can hold a reference into the same slot at once, so a
    payload with interior mutability let safe code race through those references.
    The bound is now T: Send + Sync + 'static. Breaking.
  • The derive macros generated Pod types with padding. Pod forbids padding
    because an implicit gap is uninitialised memory and the atomic-slots copy
    reads the value as integer chunks; the generated wire structs routinely had it,
    which Miri reports as undefined behaviour. DerivePod now proves at compile
    time that a type's size equals the sum of its field sizes, and DeriveMessage
    emits fields widest-first with explicit tail padding. Ordering closes the gaps
    the macro can see; where a type alias hides a field's width the size assertion
    still fails the build rather than letting it through. Wire structs gain a
    _pad field.
    Breaking for code that names wire struct fields positionally.
  • The examples taught padded Pod impls. Reordered widest-first, with
    explicit padding where ordering alone cannot close the gap.
  • A newly registered subscriber could be lapped on a bounded channel.
    subscribe() read the head cursor and registered its tracker as two separate
    steps. The publisher only rescans trackers when its cached slowest cursor says
    it is close to lapping, so a subscriber that registered in the gap was invisible
    to a publisher whose cached value came from a faster consumer — and could be
    overwritten before the next rescan, losing messages the bounded channel had
    promised it. Both steps now happen under the tracker...
Read more

v2.5.0 — 7 cons eliminated

Choose a tag to compare

@userFRM userFRM released this 25 Mar 21:47

Highlights

  • Arbitrary capacity — any ring size >= 2 (Lemire fastmod, zero regression for pow2)
  • photon-ring-async — runtime-agnostic async wrappers (no tokio)
  • photon-ring-metrics — observability with snapshot/delta tracking
  • Pipeline WaitStrategy — then_with(f, WaitStrategy::YieldSpin)
  • #[photon(as_enum)] — explicit enum marking, compile error for unknown types
  • Loom MPMC model — exhaustive concurrency testing (7 scenarios)

Install

photon-ring = "2.5.0"
photon-ring-async = "2.5.0"    # optional
photon-ring-metrics = "2.5.0"  # optional

Breaking

  • #[derive(Message)] enum fields now require #[photon(as_enum)] attribute

Full changelog: v2.4.0...v2.5.0

v2.4.0 — atomic-slots + multi-model audit

Choose a tag to compare

@userFRM userFRM released this 19 Mar 12:15

What's New

atomic-slots feature — formally sound seqlock

Replaces write_volatile/read_volatile with AtomicU64 stripes. Zero performance cost on x86-64 (identical MOV instructions). ~5-10ns ARM64 reader overhead. Miri-passable. First formally-sound seqlock implementation in the Rust ecosystem.

photon-ring = { version = "2.4.0", features = ["atomic-slots"] }

Multi-model audit (17 findings fixed)

Full codebase audit by Gemini 2.5 Pro, Claude Opus 4.6, and Kimi k2.5:

  • Critical: MPMC AcqRel ordering, Pod derive #[repr(C)] check, enum transmute docs
  • High: Weak backpressure trackers, Pipeline Drop/panic propagation, derive signed-int/usize fixes
  • Medium: prefault() assertion, MonitorWait docs, NUMA bounds check, Pipeline Lagged spin

Performance

  • PREFETCHW on x86 publisher (-10.7% fanout 1 sub, -14.6% fanout 2 subs)
  • WFE in recv() Phase 2 on AArch64 (~12ns cache-line-event wakeup)
  • Dead rdtsc asm block removed (~20-25 cycles saved per UMWAIT/TPAUSE)

Benchmarks (default vs atomic-slots, x86-64)

Benchmark Default atomic-slots
Publish only 1.94 ns 1.98 ns
Fanout 10 subs 15.68 ns 15.96 ns
Cross-thread 96.2 ns 95.2 ns
MPMC 1p 1s 12.23 ns 11.99 ns

CI improvements

  • MSRV 1.94 verification job
  • photon-ring-derive auto-publish
  • atomic-slots feature gate job
  • Test timeouts to prevent CI hangs

Research

3 research documents exploring seqlock alternatives via constraint-anchored analysis. All 3 independent agents converged on the same design. See docs/research-*.md.

Full Changelog: v2.3.0...v2.4.0

v2.3.0 — Message derive macro

Choose a tag to compare

@userFRM userFRM released this 17 Mar 23:25

Zero-friction Pod conversion with #[derive(Message)]:

#[repr(u8)]
#[derive(Clone, Copy)]
enum Side { Buy = 0, Sell = 1 }

#[derive(photon_ring::DeriveMessage)]
struct Order {
    price: f64,
    qty: u32,
    side: Side,           // enum → u8 automatically
    filled: bool,         // bool → u8 automatically
    tag: Option<u32>,     // Option → u64 automatically
}

// Generated: OrderWire (Pod) + From<Order> + From<OrderWire>
let wire: OrderWire = order.into();
publisher.publish(wire);
let order: Order = subscriber.recv().into();

Also: channel.rs and topology.rs split into module directories (no API changes).

v2.2.0 — DependencyBarrier

Choose a tag to compare

@userFRM userFRM released this 17 Mar 13:25

Consumer dependency graphs for ordered multi-stage processing.

let mut upstream_a = subs.subscribe_tracked();
let mut upstream_b = subs.subscribe_tracked();
let barrier = DependencyBarrier::from_subscribers(&[&upstream_a, &upstream_b]);
let mut downstream = subs.subscribe_tracked();

// downstream won't read event N until both A and B have finished it
let value = downstream.recv_gated(&barrier);

Zero cost on the default path — independent subscribers pay nothing.

10 new tests. 132 total.

v2.1.1

Choose a tag to compare

@userFRM userFRM released this 17 Mar 13:05
  • Remove unnecessary unsafe from cpuid (safe in Rust 1.94+)
  • MSRV bumped to 1.94
  • CI auto-publishes to crates.io on tagged releases
  • Fixed clippy lints

v2.1.0 — MPMC Deadlock Fix, UMWAIT Strategies, Prefetch

Choose a tag to compare

@userFRM userFRM released this 17 Mar 12:18

Highlights

Critical Bug Fix

  • MPMC catch_up_cursor deadlock — After ring wraparound, the shared cursor could strand permanently, causing all subscribers to spin forever. One-character fix: != → < in the successor stamp check. Any MPMC channel publishing more messages than its capacity was affected. (#1)

New Wait Strategies

  • MonitorWait — UMONITOR/UMWAIT on Intel Alder Lake+ with runtime CPUID WAITPKG detection. Near-zero power, ~30 ns wakeup latency. Safe constructor: WaitStrategy::monitor_wait(&AtomicU64). Falls back to PAUSE on older x86, SEVL+WFE on aarch64. (#2)
  • MonitorWaitFallback — TPAUSE timed C0.1 pause without address monitoring. Same platform fallbacks.

Performance

  • Prefetch next slot on publish — PREFETCHT0 (x86) / PRFM PSTL1KEEP (ARM) hides the RFO stall. Applied to all SPMC and MPMC publish paths. (~15-25% write latency reduction) (#3)
  • recv_with() direct slot access — Pre-computes slot pointer outside the spin loop, eliminating per-iteration try_recv() overhead.
  • Cached has_backpressure on Publisher — Avoids Arc deref on every lossy publish.
  • WFE in MPMC predecessor spin — Low-power wait on aarch64 instead of YIELD.

Correctness

  • write_volatile/read_volatile in seqlock — Eliminates formal UB when a reader observes a partially-written slot. Zero runtime cost. (#4)
  • Removed dead count field from SubscriberGroup — Was always equal to const generic N.

Review

Three rounds of automated review with OpenAI Codex CLI. All findings addressed.

Upgrade

[dependencies]
photon-ring = "2.1.0"

No breaking API changes. WaitStrategy has two new variants (MonitorWait, MonitorWaitFallback); existing match arms are unaffected if using _ wildcard.

Full Changelog: v2.0.0...v2.1.0

v2.0.0 — Pod Trait, Derive Macro, Safety Contract

Choose a tag to compare

@userFRM userFRM released this 17 Mar 07:45

Photon Ring v2.0.0

BREAKING CHANGE: T: Copy replaced with unsafe trait Pod across the entire API.

Why this matters

The seqlock read protocol performs optimistic non-atomic reads that may produce torn bit patterns. Previously, the API accepted any T: Copy, including types like bool, char, and NonZero<u32> where certain bit patterns are invalid — making torn reads technically undefined behavior.

Pod ("Plain Old Data") guarantees that every bit pattern is valid, eliminating this class of UB entirely. The trait is pre-implemented for all numeric primitives, arrays, and tuples up to 12 elements.

Migration

// Before (v1.x)
let (mut p, s) = channel::<u64>(1024);

// After (v2.0) — primitives just work (Pod is pre-implemented)
let (mut p, s) = channel::<u64>(1024);

// Custom structs need unsafe impl:
#[repr(C)]
#[derive(Clone, Copy)]
struct Quote { price: f64, volume: u32 }
unsafe impl photon_ring::Pod for Quote {}

// Or with the derive macro (optional feature):
// photon-ring = { version = "2", features = ["derive"] }
#[derive(Clone, Copy, photon_ring::DerivePod)]
#[repr(C)]
struct Quote { price: f64, volume: u32 }

Other changes

  • try_publisher() on Photon<T> and TypedBus (returns Option, no panic)
  • Verification README: explicit limitations (SPMC-only, SC-only model)
  • Benchmark methodology documentation
  • 118 tests, all passing

v1.0.1

Choose a tag to compare

@userFRM userFRM released this 17 Mar 07:25

Updated benchmarks with M1 Pro numbers, banner, throughput ranges. No code changes from v1.0.0.

v1.0.0 — Topology Builder, O(1) Fanout, Payload Scaling

Choose a tag to compare

@userFRM userFRM released this 16 Mar 19:10

Photon Ring v1.0.0

The first major release. Addresses every LMAX Disruptor feature gap identified in independent review.

Highlights

Topology Builder

let (mut pub_, stages) = Pipeline::builder().capacity(1024).input::<RawTick>();
let (mut output, pipeline) = stages
    .then(|tick| enrich(tick))
    .then(|tick| generate_signal(tick))
    .build();

Dedicated thread per stage, graceful shutdown, fan-out support.

O(1) Subscriber Group Fanout

2.8 ns for 1, 10, or 1000 subscribers. Single cursor, compiler-unrolled.

Batch Processing

  • recv_batch(&mut self, buf) — fill a slice in one call
  • drain() — iterator over all available messages
  • Shutdown — coordinated graceful termination

Payload Scaling

Benchmarked 8B to 4KB payloads. Photon Ring outperforms Disruptor at every size.

Payload Scaling

Performance

Metric Value
One-way latency (RDTSC p50) 48 ns
Cross-thread roundtrip 96 ns
Publish cost 2.9 ns
SubscriberGroup (any N) 2.8 ns

Quality

  • 117 tests (28 unit + 69 integration + 20 doc)
  • MIRI verified (single-threaded)
  • TLA+ formal specification
  • 944-line academic technical report
  • Codex CLI reviewed (2 rounds, all findings addressed)
  • GitHub Actions CI (9 jobs, cross-platform)

Full changelog: https://github.com/userFRM/photon-ring/blob/master/CHANGELOG.md