kevy_rt/replica_inbox.rs
1//! Cross-thread inbox from an external replica runner into a
2//! [`Shard`]'s reactor thread. The replica runner lives on its own OS
3//! thread (it does blocking `TcpStream` reads from the upstream
4//! primary via `ReplicaClient`); applying mutations to the shard's
5//! `Store` must happen on the shard's reactor thread, so the runner
6//! drops events into this channel and the shard drains it once per
7//! tick.
8//!
9//! The kevy server (the embedder) creates one [`ReplicaInbox`] pair
10//! per shard before `Runtime::run`, hands the receivers to the
11//! runtime via `with_replica_inboxes`, and keeps the senders to wire
12//! into the runner threads. One runner is spawned per shard
13//! (matching the primary's per-shard listener layout), so the
14//! channels are 1:1.
15//!
16//! Known cap: events are unbounded. Each [`ReplicaApply::Frame`]
17//! carries an owned [`Argv`] (snapshot path is `Vec<u8>` chunks); for
18//! a slow shard this can grow. Backpressure / capping is tracked as a
19//! follow-up. The unbounded channel never blocks the runner thread, so
20//! a stuck shard never stalls the runner's TCP read (it just buffers).
21//!
22//! Wake contract: a send signals the shard's [`Waker`] so a reactor
23//! parked in `Poller::wait` drains promptly — the flag in
24//! [`InboxSignal`] collapses a burst into one self-pipe write. Without
25//! this, drain only ran when *other* traffic happened to wake the
26//! reactor, and a fast upstream buried a quiet replica in backlog
27//! (found by repligate: ~7s of undrained frames after the primary
28//! froze, unmasked when `frames_from` went O(B) → O(log B) and the
29//! primary started feeding at full speed).
30
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::sync::mpsc::{Receiver, SendError, Sender, channel};
33use std::sync::{Arc, OnceLock};
34
35use kevy_sys::Waker;
36
37use crate::Argv;
38
39/// The cross-thread wake bridge shared by a sender/receiver pair. The
40/// shard installs its own waker at reactor start; `wake_pending`
41/// throttles the self-pipe to one write per drain cycle no matter how
42/// many frames a burst carries.
43///
44/// Aligned to a cache line: eight shards' signals are allocated
45/// back-to-back at startup, and an unaligned flag shares its line with
46/// a neighbouring shard's — every reactor iteration polls the flag, so
47/// a shared line ping-pongs across cores at reactor frequency (the
48/// sadd L1-miss A/B that caught it).
49#[repr(align(64))]
50pub(crate) struct InboxSignal {
51 pub(crate) waker: OnceLock<Arc<Waker>>,
52 pub(crate) wake_pending: AtomicBool,
53}
54
55/// Opaque completion token riding on [`ReplicaApply::SnapshotEnd`].
56/// The shard drops it only AFTER the snapshot swap has landed in its
57/// `Store`, so the embedder can hang side effects (e.g. lowering a
58/// `-LOADING` read gate) on the token's `Drop` and know they fire
59/// once the new keyspace — not the one about to be replaced — is
60/// what readers will see. Clones share one inner value: in broadcast
61/// (single-source) mode every shard holds a clone and the `Drop`
62/// fires when the LAST shard finishes its load.
63#[derive(Clone)]
64pub struct SnapshotGate(#[allow(dead_code)] Arc<dyn std::any::Any + Send + Sync>);
65
66impl SnapshotGate {
67 /// Wrap the embedder's drop-hook value.
68 #[must_use]
69 pub fn new(inner: Arc<dyn std::any::Any + Send + Sync>) -> Self {
70 Self(inner)
71 }
72}
73
74impl std::fmt::Debug for SnapshotGate {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.write_str("SnapshotGate")
77 }
78}
79
80/// One event delivered from a replica runner to its target shard.
81/// Mirrors `kevy_replicate::replica::ReplicaEvent` except `Frame`
82/// carries an owned [`Argv`] (already decoded by the runner) instead
83/// of a `DecodedFrame { offset, argv }` — the offset is gap-checked
84/// by the runner on the way in, so the shard doesn't need it.
85#[derive(Debug)]
86pub enum ReplicaApply {
87 /// Upstream started shipping a full snapshot. The shard should
88 /// reset its accumulating snapshot buffer.
89 SnapshotBegin,
90 /// One chunk of snapshot bytes. The shard appends to its buffer.
91 SnapshotChunk(Vec<u8>),
92 /// Upstream finished the snapshot. The shard hands its buffered
93 /// bytes to `kevy_persist::load_snapshot_from` (replacing the
94 /// `Store` contents) and resumes at `ack_offset` for live frames.
95 /// `routed = true` (single-source mode) means the payload is
96 /// the WHOLE upstream keyspace broadcast to every shard — each
97 /// shard loads only its own hash slice.
98 /// `gate`: dropped by the shard after the load lands (see
99 /// [`SnapshotGate`]); `None` when the runner has nothing to hang
100 /// on the completion.
101 SnapshotEnd { ack_offset: u64, routed: bool, gate: Option<SnapshotGate> },
102 /// One live mutation frame to be applied via `kevy::dispatch`
103 /// (inside a [`crate::ReplicatedApplyGuard`] scope so the apply
104 /// doesn't re-push into this shard's downstream
105 /// `ReplicationSource`).
106 Frame { offset: u64, argv: Argv },
107}
108
109/// Sender end of a per-shard replica inbox. `Send + Clone + Sync`
110/// (one std::sync::mpsc::Sender, no extra state) so the embedder can
111/// hand it freely to runner threads.
112#[derive(Clone)]
113pub struct ReplicaInboxSender {
114 inner: Sender<ReplicaApply>,
115 signal: Arc<InboxSignal>,
116}
117
118impl ReplicaInboxSender {
119 /// Send one event to the target shard, waking its reactor if it
120 /// may be parked. Fails only when the shard has dropped its
121 /// receiver (the runtime stopped or the shard crashed) — the
122 /// runner should treat that as "no more apply possible" and exit.
123 pub fn send(&self, ev: ReplicaApply) -> Result<(), SendError<ReplicaApply>> {
124 self.inner.send(ev)?;
125 // One self-pipe write per drain cycle, not per frame: the flag
126 // stays raised until the shard's drain lowers it.
127 if !self.signal.wake_pending.swap(true, Ordering::AcqRel)
128 && let Some(w) = self.signal.waker.get()
129 {
130 let _ = w.wake();
131 }
132 Ok(())
133 }
134}
135
136/// Receiver end. Lives inside the (private) `Shard`; drained every
137/// reactor iteration. Constructed by [`replica_inbox_pair`] and
138/// handed to the runtime via `Runtime::with_replica_inboxes`.
139pub struct ReplicaInboxReceiver {
140 pub(crate) inner: Receiver<ReplicaApply>,
141 pub(crate) signal: Arc<InboxSignal>,
142}
143
144impl ReplicaInboxReceiver {
145 /// Install the owning shard's waker — called once at reactor
146 /// start, after which sends interrupt a parked `Poller::wait`.
147 pub(crate) fn attach_waker(&self, waker: Arc<Waker>) {
148 let _ = self.signal.waker.set(waker);
149 }
150}
151
152/// Create a matched (sender, receiver) pair for one shard's replica
153/// inbox. The embedder calls this `nshards` times before
154/// `Runtime::run`.
155#[must_use]
156pub fn replica_inbox_pair() -> (ReplicaInboxSender, ReplicaInboxReceiver) {
157 let (tx, rx) = channel();
158 let signal = Arc::new(InboxSignal {
159 waker: OnceLock::new(),
160 wake_pending: AtomicBool::new(false),
161 });
162 (
163 ReplicaInboxSender { inner: tx, signal: Arc::clone(&signal) },
164 ReplicaInboxReceiver { inner: rx, signal },
165 )
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn pair_round_trips_one_event() {
174 let (tx, rx) = replica_inbox_pair();
175 tx.send(ReplicaApply::SnapshotBegin).unwrap();
176 match rx.inner.recv().unwrap() {
177 ReplicaApply::SnapshotBegin => {}
178 other => panic!("expected SnapshotBegin, got {other:?}"),
179 }
180 }
181
182 #[test]
183 fn drop_receiver_makes_send_fail() {
184 let (tx, rx) = replica_inbox_pair();
185 drop(rx);
186 let err = tx.send(ReplicaApply::SnapshotBegin).unwrap_err();
187 match err.0 {
188 ReplicaApply::SnapshotBegin => {}
189 other => panic!("expected payload roundtrip, got {other:?}"),
190 }
191 }
192
193 #[test]
194 fn sender_is_clone_send_sync() {
195 fn assert_traits<T: Clone + Send + Sync>() {}
196 assert_traits::<ReplicaInboxSender>();
197 }
198}