Skip to main content

dwbase_swarm_nats/
lib.rs

1//! NATS-backed presence for DWBase components (hello/presence).
2//! This module provides a trait-based client with an in-memory mock bus suitable for tests or local runs.
3
4use std::sync::Arc;
5use std::time::Duration;
6
7#[cfg(feature = "nats")]
8use futures::StreamExt;
9use parking_lot::RwLock;
10use serde::{Deserialize, Serialize};
11use time::{format_description::well_known::Rfc3339, OffsetDateTime};
12
13pub mod replication;
14pub mod swarm;
15pub mod world_events;
16
17type HelloHandler = dyn Fn(NodeHello) + Send + Sync;
18
19/// Presence announcement payload.
20#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
21pub struct NodeHello {
22    pub node_id: String,
23    pub endpoint: String,
24    pub worlds_served: Vec<String>,
25    pub trust_score: f32,
26    pub started_at: String,
27    pub version: String,
28}
29
30/// Peer record with expiry.
31#[derive(Clone, Debug)]
32pub struct PeerInfo {
33    pub hello: NodeHello,
34    pub last_seen: OffsetDateTime,
35    pub ttl: Duration,
36}
37
38/// Simple peer table with TTL expiry.
39#[derive(Default, Clone)]
40pub struct PeerTable {
41    inner: Arc<RwLock<Vec<PeerInfo>>>,
42}
43
44impl PeerTable {
45    pub fn upsert(&self, hello: NodeHello, ttl: Duration) {
46        let mut guard = self.inner.write();
47        let now = OffsetDateTime::now_utc();
48        if let Some(existing) = guard.iter_mut().find(|p| p.hello.node_id == hello.node_id) {
49            existing.hello = hello;
50            existing.last_seen = now;
51            existing.ttl = ttl;
52        } else {
53            guard.push(PeerInfo {
54                hello,
55                last_seen: now,
56                ttl,
57            });
58        }
59    }
60
61    pub fn peers(&self) -> Vec<NodeHello> {
62        self.prune();
63        self.inner.read().iter().map(|p| p.hello.clone()).collect()
64    }
65
66    fn prune(&self) {
67        let now = OffsetDateTime::now_utc();
68        let mut guard = self.inner.write();
69        guard.retain(|p| now - p.last_seen <= p.ttl);
70    }
71}
72
73/// Client trait so real or mock NATS can be plugged in.
74pub trait NatsClient: Send + Sync {
75    fn publish_hello(&self, hello: &NodeHello) -> anyhow::Result<()>;
76    fn subscribe_hello(&self, handler: Box<HelloHandler>) -> anyhow::Result<()>;
77}
78
79/// In-memory broadcast bus for tests/local dev (not real NATS).
80#[derive(Default, Clone)]
81pub struct MockNats {
82    subs: Arc<RwLock<Vec<Arc<HelloHandler>>>>,
83}
84
85impl NatsClient for MockNats {
86    fn publish_hello(&self, hello: &NodeHello) -> anyhow::Result<()> {
87        for sub in self.subs.read().iter() {
88            sub(hello.clone());
89        }
90        Ok(())
91    }
92
93    fn subscribe_hello(&self, handler: Box<HelloHandler>) -> anyhow::Result<()> {
94        self.subs.write().push(handler.into());
95        Ok(())
96    }
97}
98
99/// Async NATS client adapter.
100#[cfg(feature = "nats")]
101pub struct AsyncNats {
102    client: async_nats::Client,
103    runtime: tokio::runtime::Runtime,
104}
105
106#[cfg(feature = "nats")]
107impl AsyncNats {
108    pub fn connect(url: &str) -> anyhow::Result<Self> {
109        let rt = tokio::runtime::Builder::new_multi_thread()
110            .enable_all()
111            .build()?;
112        let client = rt.block_on(async { async_nats::connect(url).await })?;
113        Ok(Self {
114            client,
115            runtime: rt,
116        })
117    }
118}
119
120#[cfg(feature = "nats")]
121impl NatsClient for AsyncNats {
122    fn publish_hello(&self, hello: &NodeHello) -> anyhow::Result<()> {
123        let payload = serde_json::to_vec(hello)?;
124        self.runtime.block_on(async {
125            self.client
126                .publish("dwbase.node.hello", payload.into())
127                .await
128        })?;
129        Ok(())
130    }
131
132    fn subscribe_hello(&self, handler: Box<HelloHandler>) -> anyhow::Result<()> {
133        let client = self.client.clone();
134        let handler = Arc::new(handler);
135        self.runtime.spawn(async move {
136            if let Ok(mut sub) = client.subscribe("dwbase.node.hello").await {
137                while let Some(msg) = sub.next().await {
138                    if let Ok(hello) = serde_json::from_slice::<NodeHello>(&msg.payload) {
139                        handler(hello);
140                    }
141                }
142            }
143        });
144        Ok(())
145    }
146}
147
148/// Periodically publish hello and feed the peer table.
149pub fn start_presence_loop(
150    client: Arc<dyn NatsClient>,
151    hello: NodeHello,
152    table: PeerTable,
153    ttl: Duration,
154) -> std::thread::JoinHandle<()> {
155    let send_interval = ttl / 2;
156    // Feed local table immediately
157    table.upsert(hello.clone(), ttl);
158    let c = client.clone();
159    let h = hello.clone();
160    std::thread::spawn(move || loop {
161        let _ = c.publish_hello(&h);
162        std::thread::sleep(send_interval);
163    });
164
165    let handler_table = table.clone();
166    let _ = client.subscribe_hello(Box::new(move |msg| {
167        handler_table.upsert(msg, ttl);
168    }));
169
170    // Return dummy handle for caller to hold; the publishing loop is separate.
171    std::thread::spawn(|| {})
172}
173
174pub fn now_rfc3339() -> String {
175    OffsetDateTime::now_utc()
176        .format(&Rfc3339)
177        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use std::time::Duration;
184
185    #[test]
186    fn peer_table_expires() {
187        let table = PeerTable::default();
188        table.upsert(
189            NodeHello {
190                node_id: "a".into(),
191                endpoint: "http://a".into(),
192                worlds_served: vec!["w1".into()],
193                trust_score: 1.0,
194                started_at: now_rfc3339(),
195                version: "v1".into(),
196            },
197            Duration::from_millis(10),
198        );
199        assert_eq!(table.peers().len(), 1);
200        std::thread::sleep(Duration::from_millis(30));
201        assert_eq!(table.peers().len(), 0);
202    }
203
204    #[test]
205    fn mock_bus_propagates_presence() {
206        let client = Arc::new(MockNats::default());
207        let table_a = PeerTable::default();
208        let table_b = PeerTable::default();
209        let hello_a = NodeHello {
210            node_id: "a".into(),
211            endpoint: "http://a".into(),
212            worlds_served: vec!["w1".into()],
213            trust_score: 0.9,
214            started_at: now_rfc3339(),
215            version: "v1".into(),
216        };
217        let hello_b = NodeHello {
218            node_id: "b".into(),
219            endpoint: "http://b".into(),
220            worlds_served: vec!["w2".into()],
221            trust_score: 0.8,
222            started_at: now_rfc3339(),
223            version: "v1".into(),
224        };
225        let ttl = Duration::from_millis(100);
226        start_presence_loop(client.clone(), hello_a.clone(), table_a.clone(), ttl);
227        start_presence_loop(client.clone(), hello_b.clone(), table_b.clone(), ttl);
228        std::thread::sleep(Duration::from_millis(100));
229        let peers_a = table_a.peers();
230        let peers_b = table_b.peers();
231        assert!(peers_a.iter().any(|p| p.node_id == "b"));
232        assert!(peers_b.iter().any(|p| p.node_id == "a"));
233    }
234}