Repository navigation
Releases: userFRM/photon-ring
Release list
v3.0.0 — sound by default
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 notPod.String,Vec,
enums,Optionandboolare 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 — aStringfield reuses the capacity
it had the previous time round the ring.The
Podbound 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/Acquirepair 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
Podring in a way worth knowing: an event
ring costs what the publisher and subscriber actually touch, while thePod
ring costssize_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, thePodring
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.Pipelinecovers 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 }andreceive_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.rsandtests/degradation.rs— the slow-observer
and dead-consumer scenarios, run and asserted rather than described.
Removed
- Multi-field tuple
Podimpls.(A, B)through the 12-element tuple used
repr(Rust)layout, so the compiler could insert padding —(u8, u64)carries
7 padding bytes. That madechannel::<(u8, u64)>()undefined behaviour from
entirely safe code underatomic-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 whatpublished()returns. Use
published(), whose docs now carry the lag-computation note. Breaking;
this makes the next release semver-major.SubscriberGroup,subscribe_group, andAsyncSubscriberGroup. AllN
logical subscribers shared one cursor, so the type had become a newtype over
Subscriberwhose constNdid nothing but echo back fromaligned_count()
— an API surface implying a capability that no longer existed. Replace
subscribe_group::<N>()withsubscribe(): 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 plainchannel::<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 = falsestill 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 onatomic-slotsbeing
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_joinnow reports failures. Stage panics are captured at the
thread boundary, so the oldErr(payload)arm was unreachable and the method
always returnedOk(())even when a stage had panicked. It now returns
Err(payload)wherepayloaddowncasts to aVec<usize>of panicked stage
indices; the original panic payload is not recoverable by design.
Pipeline::joindocumented accordingly — it never re-panics. -
Crate packages exclude
docs/,verification/, andscripts/.
Fixed
WaitStrategy::MonitorWaitlet safe code hand the wait path a raw pointer.
The variant carried a*const u8in a public enum, so safe code could put any
address in it and pass it torecv_with; a safety precondition on a safe
constructor is not enforceable. Removed.MonitorWaitFallbackprovides the
same near-zero-power wait viaTPAUSE, needs no address, and is sound.
Breaking.- The
Messagederive accepted fields that are notPod. An array field
passed through unchecked, so[bool; 2]produced a wire struct carrying an
unsafe impl Podit did not deserve. Every passthrough field is now proven
Podat compile time, which also rejects aliases that merely look like
primitives. publish_withcould invoke undefined behaviour from safe code. It handed
the closure a&mut MaybeUninit<T>and then assumed initialisation
unconditionally, sopublish_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_channelshared&Tacross threads without requiringT: 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 nowT: Send + Sync + 'static. Breaking.- The derive macros generated
Podtypes with padding.Podforbids padding
because an implicit gap is uninitialised memory and theatomic-slotscopy
reads the value as integer chunks; the generated wire structs routinely had it,
which Miri reports as undefined behaviour.DerivePodnow proves at compile
time that a type's size equals the sum of its field sizes, andDeriveMessage
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
_padfield.
Breaking for code that names wire struct fields positionally. - The examples taught padded
Podimpls. 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...
v2.5.0 — 7 cons eliminated
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" # optionalBreaking
#[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
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
AcqRelordering, Pod derive#[repr(C)]check, enum transmute docs - High:
Weakbackpressure trackers, PipelineDrop/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
rdtscasm 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-deriveauto-publishatomic-slotsfeature 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
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
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
v2.1.0 — MPMC Deadlock Fix, UMWAIT Strategies, Prefetch
Highlights
Critical Bug Fix
- MPMC
catch_up_cursordeadlock — 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-iterationtry_recv()overhead.- Cached
has_backpressureon 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_volatilein seqlock — Eliminates formal UB when a reader observes a partially-written slot. Zero runtime cost. (#4)- Removed dead
countfield fromSubscriberGroup— Was always equal to const genericN.
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
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()onPhoton<T>andTypedBus(returnsOption, no panic)- Verification README: explicit limitations (SPMC-only, SC-only model)
- Benchmark methodology documentation
- 118 tests, all passing
v1.0.1
v1.0.0 — Topology Builder, O(1) Fanout, Payload Scaling
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 calldrain()— iterator over all available messagesShutdown— coordinated graceful termination
Payload Scaling
Benchmarked 8B to 4KB payloads. Photon Ring outperforms Disruptor at every size.
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
