Skip to main content

net/adapter/net/redex/
replication_router.rs

1//! Per-`Redex` replication router — registers every spawned
2//! [`ReplicationRuntimeHandle`] by [`ChannelId`] and dispatches
3//! inbound `SUBPROTOCOL_REDEX` events from the mesh dispatch loop
4//! to the right runtime's inbox.
5//!
6//! The substrate's mesh-side dispatcher decodes each inbound
7//! `SUBPROTOCOL_REDEX` frame into an [`Inbound`] event keyed on
8//! [`ChannelId`] (channel-name BLAKE2s), then calls
9//! [`ReplicationInboundRouter::try_route`]. This module's
10//! [`RedexReplicationRouter`] is the production impl — owns a
11//! `DashMap<ChannelId, Arc<ReplicationRuntimeHandle>>` and
12//! delegates `try_route` to the named runtime's `try_dispatch`.
13//!
14//! Lifecycle:
15//!
16//! - `Redex::enable_replication(mesh)` constructs one router per
17//!   `Redex` and installs it on the `MeshNode` via
18//!   `set_replication_inbound_router`. Idempotent — the second
19//!   call to `enable_replication` is a no-op.
20//! - `Redex::open_file` with `RedexFileConfig::replication.is_some()`
21//!   spawns a `ReplicationRuntime` and registers its handle
22//!   under the channel's [`ChannelId`].
23//! - `Redex` drop / explicit `close_file` cancels the runtime +
24//!   removes the registration; the router's `try_route` then
25//!   returns `Err(inbound)` for that channel (which the mesh
26//!   dispatcher drops silently).
27//!
28//! Routing edge cases:
29//!
30//! - Unknown channel id — runtime not registered (channel not
31//!   opened on this node, or registration was removed during
32//!   cleanup): `try_route` returns `Err(inbound)`. Caller (mesh
33//!   dispatch) drops silently.
34//! - Runtime inbox full — at [`RUNTIME_INBOX_CAPACITY`] (1024 per
35//!   plan §3 cardinality budget): `try_route` returns
36//!   `Err(inbound)`. Same drop-silently shape; reliable-stream /
37//!   heartbeat cycle recovers observable state.
38
39use std::sync::Arc;
40
41use dashmap::DashMap;
42
43use super::replication::ChannelId;
44use super::replication_runtime::{Inbound, ReplicationInboundRouter, ReplicationRuntimeHandle};
45
46/// Per-`Redex` registry of runtime handles, dispatching by
47/// channel id. Cheap to clone (everything is Arc) so the same
48/// router can be shared between `Redex` (for registration) and
49/// `MeshNode` (for inbound dispatch).
50#[derive(Default)]
51pub struct RedexReplicationRouter {
52    runtimes: DashMap<ChannelId, Arc<ReplicationRuntimeHandle>>,
53}
54
55impl RedexReplicationRouter {
56    /// Construct an empty router.
57    pub fn new() -> Self {
58        Self {
59            runtimes: DashMap::new(),
60        }
61    }
62
63    /// Register a runtime handle under `channel_id`. Returns the
64    /// previously-registered handle if one existed, so the caller
65    /// can cancel it cleanly. Re-registration is the
66    /// `RedexFileConfig::replication` update path — same channel,
67    /// new config, new runtime.
68    pub fn register(
69        &self,
70        channel_id: ChannelId,
71        handle: Arc<ReplicationRuntimeHandle>,
72    ) -> Option<Arc<ReplicationRuntimeHandle>> {
73        self.runtimes.insert(channel_id, handle)
74    }
75
76    /// Look up a runtime handle. Cloned `Arc` so the caller can
77    /// drive the handle (dispatch events, cancel) without
78    /// holding the DashMap shard lock.
79    pub fn get(&self, channel_id: &ChannelId) -> Option<Arc<ReplicationRuntimeHandle>> {
80        self.runtimes.get(channel_id).map(|e| e.value().clone())
81    }
82
83    /// Remove the registration for `channel_id`. Returns the
84    /// removed handle, if any, so the caller can cancel + await
85    /// its exit deterministically.
86    pub fn unregister(&self, channel_id: &ChannelId) -> Option<Arc<ReplicationRuntimeHandle>> {
87        self.runtimes.remove(channel_id).map(|(_, v)| v)
88    }
89
90    /// Number of registered runtimes.
91    pub fn len(&self) -> usize {
92        self.runtimes.len()
93    }
94
95    /// True iff no runtimes are registered.
96    pub fn is_empty(&self) -> bool {
97        self.runtimes.is_empty()
98    }
99
100    /// Snapshot every registered `(ChannelId, handle)` pair. The
101    /// `Arc` clone is cheap; the caller can iterate without
102    /// holding shard locks. Consumed by `Redex` to build the
103    /// per-channel status snapshot for operator observability.
104    pub fn snapshot_handles(&self) -> Vec<(ChannelId, Arc<ReplicationRuntimeHandle>)> {
105        self.runtimes
106            .iter()
107            .map(|e| (*e.key(), e.value().clone()))
108            .collect()
109    }
110}
111
112impl ReplicationInboundRouter for RedexReplicationRouter {
113    fn try_route(&self, channel_id: ChannelId, inbound: Inbound) -> Result<(), Inbound> {
114        match self.runtimes.get(&channel_id) {
115            Some(handle) => handle.value().try_dispatch(inbound),
116            None => Err(inbound),
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::adapter::net::channel::ChannelName;
125    use crate::adapter::net::redex::replication::{ReplicaRole, SyncHeartbeat};
126
127    fn cid_for(name: &str) -> ChannelId {
128        let cn = ChannelName::new(name).unwrap();
129        ChannelId::from_name(&cn)
130    }
131
132    fn dummy_inbound(channel_id: ChannelId) -> Inbound {
133        Inbound::Heartbeat {
134            from: 0xAA,
135            msg: SyncHeartbeat {
136                channel_id,
137                tail_seq: 0,
138                role: ReplicaRole::Replica,
139                wall_clock_ms: 0,
140            },
141        }
142    }
143
144    #[test]
145    fn unknown_channel_returns_inbound_back() {
146        let router = RedexReplicationRouter::new();
147        let cid = cid_for("test/unknown");
148        let event = dummy_inbound(cid);
149        let result = router.try_route(cid, event);
150        assert!(result.is_err(), "unknown channel must reject");
151    }
152
153    #[test]
154    fn empty_router_reports_empty() {
155        let router = RedexReplicationRouter::new();
156        assert!(router.is_empty());
157        assert_eq!(router.len(), 0);
158        assert!(router.get(&cid_for("nothing")).is_none());
159    }
160
161    #[test]
162    fn unregister_returns_handle_and_drops_registration() {
163        // We use a runtime-spawned handle, which requires the
164        // tokio runtime. Build a minimal one with `block_on`.
165        let rt = tokio::runtime::Runtime::new().unwrap();
166        let _guard = rt.enter();
167        let cid = cid_for("test/unregister");
168        let handle = build_dummy_handle();
169        let router = RedexReplicationRouter::new();
170        router.register(cid, handle.clone());
171        assert_eq!(router.len(), 1);
172        let removed = router.unregister(&cid);
173        assert!(removed.is_some(), "unregister must return the handle");
174        assert!(router.is_empty());
175        // Re-routing the same channel now fails (registration
176        // removed).
177        let result = router.try_route(cid, dummy_inbound(cid));
178        assert!(result.is_err());
179        // Drain the runtime so the task exits cleanly.
180        rt.block_on(handle.cancel());
181    }
182
183    #[test]
184    fn register_replaces_returns_previous_handle() {
185        let rt = tokio::runtime::Runtime::new().unwrap();
186        let _guard = rt.enter();
187        let cid = cid_for("test/replace");
188        let first = build_dummy_handle();
189        let second = build_dummy_handle();
190        let router = RedexReplicationRouter::new();
191        assert!(router.register(cid, first.clone()).is_none());
192        let previous = router.register(cid, second.clone());
193        assert!(
194            previous.is_some(),
195            "second register must return the prior handle"
196        );
197        assert_eq!(router.len(), 1, "still one channel — second replaced first");
198        rt.block_on(first.cancel());
199        rt.block_on(second.cancel());
200    }
201
202    #[test]
203    fn try_route_to_registered_channel_dispatches() {
204        let rt = tokio::runtime::Runtime::new().unwrap();
205        let _guard = rt.enter();
206        let cid = cid_for("test/runtime");
207        let handle = build_dummy_handle();
208        let router = RedexReplicationRouter::new();
209        router.register(cid, handle.clone());
210        // A real event flows into the runtime's inbox.
211        let result = router.try_route(cid, dummy_inbound(cid));
212        assert!(result.is_ok(), "registered channel must route");
213        rt.block_on(handle.cancel());
214    }
215
216    /// Build a minimal `ReplicationRuntimeHandle` for unit tests.
217    /// Uses the channel name `"test/runtime"` and the same shape
218    /// as the unit tests in `replication_runtime.rs`.
219    fn build_dummy_handle() -> Arc<ReplicationRuntimeHandle> {
220        use super::super::file::RedexFile;
221        use super::super::manager::Redex;
222        use super::super::replication_budget::BandwidthBudget;
223        use super::super::replication_config::ReplicationConfig;
224        use super::super::replication_coordinator::{
225            ChainTagSink, ChannelIdentity, ReplicationCoordinator,
226        };
227        use super::super::replication_metrics::ReplicationMetricsRegistry;
228        use super::super::replication_runtime::{
229            spawn_replication_runtime, ReplicationDispatcher, RuntimeInputs,
230        };
231        use crate::adapter::net::behavior::placement::NodeId;
232        use crate::adapter::net::channel::ChannelName;
233        use crate::adapter::net::redex::config::RedexFileConfig;
234        use crate::error::AdapterError;
235        use parking_lot::Mutex;
236        use std::time::{Duration, Instant};
237
238        struct NoopSink;
239        #[async_trait::async_trait]
240        impl ChainTagSink for NoopSink {
241            async fn announce_chain(
242                &self,
243                _origin_hash: u64,
244                _tip_seq: u64,
245            ) -> Result<(), AdapterError> {
246                Ok(())
247            }
248            async fn withdraw_chain(&self, _origin_hash: u64) -> Result<(), AdapterError> {
249                Ok(())
250            }
251        }
252        struct NoopDispatcher;
253        #[async_trait::async_trait]
254        impl ReplicationDispatcher for NoopDispatcher {
255            async fn send_heartbeat(
256                &self,
257                _target: NodeId,
258                _msg: SyncHeartbeat,
259            ) -> Result<(), AdapterError> {
260                Ok(())
261            }
262            async fn send_sync_request(
263                &self,
264                _target: NodeId,
265                _msg: super::super::replication::SyncRequest,
266            ) -> Result<(), AdapterError> {
267                Ok(())
268            }
269            async fn send_sync_response(
270                &self,
271                _target: NodeId,
272                _msg: super::super::replication::SyncResponse,
273            ) -> Result<(), AdapterError> {
274                Ok(())
275            }
276            async fn send_sync_nack(
277                &self,
278                _target: NodeId,
279                _msg: super::super::replication::SyncNack,
280            ) -> Result<(), AdapterError> {
281                Ok(())
282            }
283        }
284
285        let cn = ChannelName::new("test/runtime").unwrap();
286        let redex = Redex::new();
287        let file: RedexFile = redex.open_file(&cn, RedexFileConfig::default()).unwrap();
288        let registry = ReplicationMetricsRegistry::new();
289        let coordinator = Arc::new(ReplicationCoordinator::new(
290            ChannelIdentity {
291                channel_name: "test/runtime".to_string(),
292                origin_hash: 0xCAFE_BABE,
293            },
294            ReplicationConfig::new(),
295            Arc::new(NoopSink) as Arc<dyn ChainTagSink>,
296            &registry,
297        ));
298        let inputs = RuntimeInputs {
299            channel: ChannelIdentity {
300                channel_name: "test/runtime".to_string(),
301                origin_hash: 0xCAFE_BABE,
302            },
303            channel_id: cid_for("test/runtime"),
304            self_node_id: 0x10,
305            replica_set: vec![0x10, 0x20],
306            heartbeat_ms: 60_000, // very slow tick for tests
307            wall_clock_provider: Arc::new(|| 0),
308            tail_provider: Arc::new(|| 0),
309            rtt_lookup: Arc::new(|_| Some(Duration::from_millis(5))),
310            file,
311            default_bandwidth_class: Default::default(),
312            background_fraction: 0.3,
313        };
314        let budget = Arc::new(Mutex::new(BandwidthBudget::new(
315            0.5,
316            1_000_000,
317            Instant::now(),
318        )));
319        Arc::new(spawn_replication_runtime(
320            inputs,
321            coordinator,
322            Arc::new(NoopDispatcher),
323            budget,
324        ))
325    }
326}