Skip to content

Commit 7241170

Browse files
authored
Merge pull request #18 from userFRM/feat/lossy-subscribers
feat: subscribe_lossy — per-consumer delivery contracts on one ring
2 parents f563bfb + b4288de commit 7241170

9 files changed

Lines changed: 602 additions & 69 deletions

File tree

CHANGELOG.md

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **`Subscribable::subscribe_lossy()`** — a subscriber that never gates the
12+
publisher, even on a bounded channel. Registered subscribers keep their
13+
no-loss guarantee while an observer (telemetry, logging, a debug tap) shares
14+
the same ring without being able to stall it; when it falls behind it reports
15+
`Lagged { skipped }` and `receive_ratio()` shows what it sampled. Both read the
16+
same sequence numbers, so observations correlate with the messages a gating
17+
consumer processed. Previously every subscriber on a bounded ring applied
18+
backpressure, so mixing the two contracts required two rings and a second
19+
publish.
20+
- **`Subscriber::cursor()`** — the sequence number this subscriber will read
21+
next, for correlating positions across subscribers on a ring.
22+
- **`examples/degradation.rs` and `tests/degradation.rs`** — the slow-observer
23+
and dead-consumer scenarios, run and asserted rather than described.
24+
1025
### Removed
26+
- **Multi-field tuple `Pod` impls.** `(A, B)` through the 12-element tuple used
27+
`repr(Rust)` layout, so the compiler could insert padding — `(u8, u64)` carries
28+
7 padding bytes. That made `channel::<(u8, u64)>()` undefined behaviour from
29+
entirely safe code under `atomic-slots`, using impls the crate itself provided.
30+
`()`, `(A,)`, arrays and primitives remain, none of which can carry padding.
31+
Replace a multi-field tuple payload with a `#[repr(C)]` struct with explicit
32+
padding fields. **Breaking.**
1133
- **`Publisher::sequence()`** — returned exactly what `published()` returns. Use
1234
`published()`, whose docs now carry the lag-computation note. **Breaking**;
1335
this makes the next release semver-major.
@@ -28,6 +50,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2850
- Crate packages exclude `docs/`, `verification/`, and `scripts/`.
2951

3052
### Fixed
53+
- **A newly registered subscriber could be lapped on a bounded channel.**
54+
`subscribe()` read the head cursor and registered its tracker as two separate
55+
steps. The publisher only rescans trackers when its cached slowest cursor says
56+
it is close to lapping, so a subscriber that registered in the gap was invisible
57+
to a publisher whose cached value came from a faster consumer — and could be
58+
overwritten before the next rescan, losing messages the bounded channel had
59+
promised it. Both steps now happen under the tracker lock, which orders them
60+
against the scan. `subscribe_from_oldest` is inherently exempt: it starts at a
61+
sequence the publisher was already entitled to overwrite, which registration
62+
cannot retroactively reserve, and this is now documented on the method.
63+
- **Missing read-side `Acquire` fence in the default (volatile) slot read.**
64+
`try_read` loaded the payload, then re-checked the stamp with an acquire *load*.
65+
An acquire load is a one-way barrier: it stops later accesses from moving
66+
earlier, but leaves the hardware free to satisfy the payload read *after* the
67+
re-check has validated. On a weakly ordered CPU — aarch64, which this crate
68+
supports and benchmarks — a reader could therefore return data from a later
69+
overwrite as a valid read. Fixed by placing an `Acquire` fence between the
70+
payload read and the re-check, mirroring both the `atomic-slots` path and the
71+
`smp_rmb()` in the Linux kernel's `read_seqcount_retry()`. On x86 it emits no
72+
instruction, but it is still a compiler barrier: the same-thread roundtrip
73+
microbenchmark moves from ~3.0 ns to ~4.2 ns because the optimiser may no
74+
longer reorder across it. Correctness on weakly ordered hardware is worth it,
75+
and the cross-thread path — where the crate is actually used — moves ~2.8%.
3176
- **Out-of-bounds atomic access in the `atomic-slots` payload copy.** The striped
3277
copy rounded the payload up to whole `AtomicU64` stripes, so for any `T` whose
3378
size is not a multiple of 8 — `u8`, `u16`, `u32`, and most structs — the final
@@ -39,12 +84,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3984
in bounds with no change to slot layout and no extra operations in the common
4085
cases. Found by running Miri against the feature for the first time.
4186
- **Documented the padding requirement for `atomic-slots`.** A payload with
42-
implicit padding `(u8, u64)` has 7 such bytes, reachable through the crate's
43-
own blanket tuple impls — leaves those bytes uninitialized, and reading them as
44-
part of an atomic word is undefined regardless of the fix above. `Pod`'s safety
45-
contract now states the no-padding requirement explicitly, and the feature's
46-
soundness claim is scoped to padding-free payloads. Enforcing this at compile
47-
time requires tightening the `Pod` contract and is deferred to a major release.
87+
implicit padding leaves those bytes uninitialized, and reading them as part of
88+
an atomic word is undefined regardless of the fix above. `Pod`'s safety contract
89+
now states the no-padding requirement explicitly. The reachable-from-safe-code
90+
case is closed by removing the multi-field tuple impls (see Removed); a
91+
hand-written `unsafe impl Pod` for a padded struct remains the implementor's
92+
responsibility, which the `miri (atomic-slots)` job is there to catch.
4893
- **`atomic-slots` is now covered by Miri in CI** (`miri (atomic-slots)` job). The
4994
existing `miri` job runs the default volatile slots single-threaded with all
5095
cross-thread tests skipped, so it could never have caught the above; the

README.md

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
Photon Ring is a zero-allocation pub/sub crate for Rust built around pre-allocated ring buffers, per-slot stamp validation, and `T: Pod` payloads. It targets the part of concurrent systems where queueing overhead dominates: market data, telemetry fanout, staged pipelines, and other hot-path broadcast workloads where every subscriber should see every message.
1616

17-
By default, slots use a volatile-based seqlock for maximum performance. With the `atomic-slots` feature, the same stamp protocol operates over `AtomicU64` stripes — **formally sound under the Rust abstract machine** with zero performance regression on x86-64.
17+
By default, slots use a volatile-based seqlock for maximum performance. With the `atomic-slots` feature, the same stamp protocol operates over `AtomicU64` stripes — **free of data races under the Rust abstract machine** for padding-free payloads, with zero performance regression on x86-64, and verified under Miri in CI.
1818

1919
It is `no_std` compatible with `alloc`, supports named-topic buses and typed buses, and includes a pipeline builder for multi-stage thread topologies on supported desktop/server platforms.
2020

@@ -57,7 +57,7 @@ Optional features:
5757

5858
- `derive`: enables `#[derive(photon_ring::DerivePod)]` for user-defined `Pod` types.
5959
- `hugepages`: enables Linux memory controls such as `mlock`, `prefault`, and NUMA helpers.
60-
- `atomic-slots`: enables a data-race-free slot implementation that decomposes the payload into `AtomicU64` stripes (stepping down through `AtomicU32`/`U16`/`U8` for a trailing partial stripe) instead of `write_volatile`/`read_volatile`. Zero performance cost on x86-64; ~5-10ns reader overhead on ARM64 due to acquire fence. Eliminates the formal undefined behavior the default path carries under the Rust abstract machine, **for payloads with no padding bytes**. Its multi-threaded tests run under Miri in CI (`miri (atomic-slots)`), so the claim is machine-checked rather than asserted. Padding is the remaining gap: a type such as `(u8, u64)` has 7 uninitialized bytes, and reading those as part of an atomic word is itself undefined. Use `#[repr(C)]` with explicit padding fields (as the examples do) so every byte is initialized.
60+
- `atomic-slots`: enables a data-race-free slot implementation that decomposes the payload into `AtomicU64` stripes (stepping down through `AtomicU32`/`U16`/`U8` for a trailing partial stripe) instead of `write_volatile`/`read_volatile`. Zero performance cost on x86-64. On ARM64 both paths pay the same reader-side acquire fence, so `atomic-slots` costs nothing extra there either. Eliminates the formal undefined behavior the default path carries under the Rust abstract machine, **for payloads with no padding bytes**. Its multi-threaded tests run under Miri in CI (`miri (atomic-slots)`), so the claim is machine-checked rather than asserted. Padding is the remaining gap: a type such as `(u8, u64)` has 7 uninitialized bytes, and reading those as part of an atomic word is itself undefined. Use `#[repr(C)]` with explicit padding fields (as the examples do) so every byte is initialized.
6161

6262
Rust 1.94+ is supported. For best performance, compile with `-C target-cpu=native` to enable `PREFETCHW` and other CPU-specific optimizations.
6363

@@ -106,9 +106,10 @@ Photon Ring moves synchronization into each slot. Every slot carries its own seq
106106
3. if s1 < expected -> Empty
107107
4. if s1 > expected -> Lagged
108108
5. value = read_volatile(slot) (direct read, T: Pod)
109-
6. s2 = stamp.load(Acquire)
110-
7. if s1 == s2 -> return
111-
8. else -> retry
109+
6. fence(Acquire) (payload read completes before re-check)
110+
7. s2 = stamp.load(Relaxed)
111+
8. if s1 == s2 -> return
112+
9. else -> retry
112113
```
113114

114115
## Why this is fast
@@ -128,7 +129,7 @@ Measured with Criterion on an **Intel i7-10700KF** (8C/16T, 3.80 GHz, Linux 6.8,
128129
129130
### Against `disruptor-rs`
130131

131-
- **Publish:** 2.8 ns (Intel) / 2.4 ns (M1 Pro), versus 30.6 ns / 15.3 ns for `disruptor-rs`
132+
- **Publish:** not directly comparable as measured here — photon's publish-only benchmark ran with no consumer attached, while `disruptor-rs` always runs one, so the gap partly measures cache-coherence traffic that photon never paid. Use the `publish, live consumer` benchmarks for a like-for-like figure.
132133
- **Cross-thread roundtrip:** 95 ns (Intel) / 130 ns (M1 Pro), versus 138 ns / 186 ns for `disruptor-rs`
133134

134135
### Core operations
@@ -153,6 +154,47 @@ Measured with Criterion on an **Intel i7-10700KF** (8C/16T, 3.80 GHz, Linux 6.8,
153154
- **Sustained throughput:** about 300M msg/s on Intel and 88M msg/s on M1 Pro
154155
- **Payload scaling:** at cache-line-sized payloads the copy is a few percent of latency — cross-core cache-coherence transfer dominates. The copy only becomes co-dominant in the KiB range; see [`docs/payload-scaling.md`](docs/payload-scaling.md)
155156

157+
## Degradation, not deadlock
158+
159+
Backpressure exists so a consumer that must not lose messages can stop the
160+
publisher. But two things should never stop the world: a consumer that is only
161+
*observing*, and a consumer that has *died*.
162+
163+
Because each slot carries its own stamp, subscribers need no shared barrier —
164+
so a single ring can carry **different delivery contracts per consumer**:
165+
166+
```rust
167+
let (mut pub_, subs) = channel_bounded::<Order>(1024, 0);
168+
169+
let mut risk = subs.subscribe(); // gates the publisher, loses nothing
170+
let mut telemetry = subs.subscribe_lossy(); // never gates it, drops when behind
171+
```
172+
173+
`risk` keeps its no-loss guarantee. `telemetry` is invisible to the publisher's
174+
backpressure scan, so however slow it gets it cannot stall order flow; when it
175+
falls behind it observes `Lagged { skipped }` with an exact count, and
176+
`receive_ratio()` reports what it sampled. Both read the same sequence numbers,
177+
so an observation can be correlated with the message the risk engine processed.
178+
179+
A consumer that *dies* also releases the ring, **provided its `Subscriber` drops
180+
with it** — which is automatic when the consumer thread owns the subscriber, as
181+
the thread unwinds and `Drop` removes it from the backpressure set. A subscriber
182+
parked in long-lived shared state (an `Arc`'d registry, a supervisor struct)
183+
outlives its consumer and keeps gating the publisher, so don't do that with a
184+
tracked subscriber. A merely *wedged* consumer still applies backpressure — that
185+
is the guarantee working as intended.
186+
187+
The no-loss guarantee is a property of each tracked subscriber's lifetime, not
188+
of the ring: if the last tracked subscriber goes away while lossy ones remain,
189+
nothing gates the publisher any more and the bounded ring behaves like a lossy
190+
one.
191+
192+
Subscribers can also attach to a ring that is already running, so a lossy
193+
debug tap can be added and removed on a live system without perturbing it.
194+
195+
`cargo run --release --example degradation` demonstrates both scenarios;
196+
`tests/degradation.rs` asserts them.
197+
156198
## Comparison
157199

158200
| | Photon Ring | disruptor-rs (v4) | crossbeam-channel | bus |
@@ -242,11 +284,25 @@ Photon Ring offers two slot implementations, selectable at compile time:
242284
| **Formal status** | Data race under Rust abstract machine (practical UB) | **Formally sound** — no data races |
243285
| **Miri** | Flags multi-threaded tests | **Passes, enforced in CI** |
244286
| **x86-64 cost** | Baseline | **Zero** — identical `MOV` instructions |
245-
| **ARM64 cost** | Baseline | **+5-10 ns** reader (one `DMB ISHLD` fence) |
287+
| **ARM64 cost** | One `DMB ISHLD` reader fence (both paths) | Same fence — no additional cost |
246288
| **Precedent** | Same pattern as Linux kernel seqlocks (20+ years) | Per-word atomic decomposition, as in `atomic-memcpy` |
247289

248290
> [!NOTE]
249-
> The default volatile-based implementation is **correct on all real hardware** (x86, ARM). The "UB" is purely under Rust's abstract machine — no compiler has ever miscompiled this pattern, and the Linux kernel relies on identical semantics. Enable `atomic-slots` if you need formal soundness, Miri compliance, or defense against hypothetical future compiler optimizations.
291+
> Both implementations place an `Acquire` fence between the payload read and the
292+
> stamp re-check — the same barrier the Linux kernel's `read_seqcount_retry()`
293+
> carries as `smp_rmb()`. Without it an acquire *load* is one-way and a weakly
294+
> ordered CPU may satisfy the payload read after the re-check has validated,
295+
> which would return data from a later overwrite. On x86 the fence emits no
296+
> instruction — TSO already orders load-load — but it is still a compiler
297+
> barrier, and measured at roughly +1.2 ns on the same-thread roundtrip
298+
> microbenchmark because it forbids reordering the optimiser was otherwise
299+
> free to do. That is the price of the guarantee, on every architecture.
300+
>
301+
> With that fence in place, the default volatile implementation produces correct
302+
> results on real hardware; what remains is that it is a data race under Rust's
303+
> abstract machine, which Miri reports and no compiler has yet exploited. Enable
304+
> `atomic-slots` for a build free of that race — machine-checked in CI, and free
305+
> on x86-64.
250306
251307
> [!TIP]
252308
> Keep rich domain types at the edges and publish compact `Pod` messages in the middle. Convert enums, `Option`, booleans, and strings into explicit numeric fields or fixed-size buffers before calling `publish`.

0 commit comments

Comments
 (0)