Skip to main content

interlink/
bus.rs

1//! The broker: a durable, keep-until-acked FIFO per recipient over HTTP.
2//!
3//! The bus is deliberately dumb — it routes an opaque JSON payload to a
4//! recipient id, never inspects it, never verifies a signature, holds no keys.
5//! Messages **persist in a [`Store`] until the recipient acks them**, so a bus
6//! restart doesn't lose anything queued for an offline agent. Delivery is
7//! at-least-once; the recipient dedupes by `msg_id`, so a redelivered message is
8//! harmless.
9//!
10//! Recipients are Ed25519 public keys (base64). The bus treats them as strings.
11
12use std::sync::Arc;
13
14use axum::extract::{Query, State};
15use axum::response::{IntoResponse, Response};
16use axum::routing::{get, post};
17use axum::{Json, Router, http::StatusCode};
18use dashmap::DashMap;
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21use tokio::sync::Notify;
22use tokio::time::{Duration, timeout};
23
24use crate::store::Store;
25
26pub const DEFAULT_RECV_TIMEOUT_MS: u64 = 25_000;
27
28/// How long a presence announcement stays in the roster without a refresh. Nodes
29/// re-announce on a heartbeat, so the roster reflects who is *currently* online.
30pub const ROSTER_TTL_MS: u64 = 90_000;
31
32/// Cap on distinct roster entries, so an announcement flood can't grow it without
33/// limit. Far above any real mesh; expired entries are pruned first.
34const ROSTER_CAP: usize = 4096;
35
36/// A payload addressed to a recipient, stamped on arrival.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct Envelope {
39    pub payload: Value,
40    /// Unix milliseconds, set by the bus when the message was enqueued.
41    pub ts: u64,
42}
43
44#[derive(Clone)]
45pub struct Broker {
46    store: Store,
47    /// Per-recipient wakeups for the long-poll. In-memory and rebuildable — the
48    /// durable state is entirely in the store.
49    notifies: Arc<DashMap<String, Arc<Notify>>>,
50    /// Presence roster: pubkey → (opaque signed announcement, received-at ms).
51    /// In-memory and ephemeral — the bus stores and serves it but never verifies
52    /// it; clients check the signatures. Just a bulletin board.
53    roster: Arc<DashMap<String, (Value, u64)>>,
54    cap: usize,
55}
56
57impl Broker {
58    pub fn new(store: Store, cap: usize) -> Self {
59        Self {
60            store,
61            notifies: Arc::new(DashMap::new()),
62            roster: Arc::new(DashMap::new()),
63            cap: cap.max(1),
64        }
65    }
66
67    /// Record a presence announcement, keyed by its self-declared pubkey. Prunes
68    /// expired entries; the announcement is stored verbatim (the bus never
69    /// inspects it beyond the routing key).
70    pub fn announce(&self, pubkey: String, announcement: Value, now: u64) {
71        self.roster
72            .retain(|_, (_, at)| now.saturating_sub(*at) < ROSTER_TTL_MS);
73        if self.roster.len() >= ROSTER_CAP && !self.roster.contains_key(&pubkey) {
74            return; // full of live entries; drop the newcomer rather than evict
75        }
76        self.roster.insert(pubkey, (announcement, now));
77    }
78
79    /// The live (non-expired) announcements.
80    pub fn roster(&self, now: u64) -> Vec<Value> {
81        self.roster
82            .iter()
83            .filter(|e| now.saturating_sub(e.value().1) < ROSTER_TTL_MS)
84            .map(|e| e.value().0.clone())
85            .collect()
86    }
87
88    fn notify_handle(&self, id: &str) -> Arc<Notify> {
89        self.notifies
90            .entry(id.to_string())
91            .or_insert_with(|| Arc::new(Notify::new()))
92            .clone()
93    }
94
95    /// Enqueue for `to`: persist, enforce the cap (drop oldest), wake a waiter.
96    pub async fn enqueue(&self, to: &str, payload: Value, ts: u64) -> anyhow::Result<()> {
97        let bytes = serde_json::to_vec(&Envelope { payload, ts })?;
98        self.store.enqueue(to.to_string(), bytes).await?;
99        // Bounded: drop oldest beyond the cap so a never-returning recipient
100        // can't grow the store without limit.
101        while self.store.depth(to.to_string()).await? > self.cap {
102            match self.store.peek_oldest(to.to_string()).await? {
103                Some((old_key, _)) => {
104                    self.store.ack(old_key).await?;
105                    tracing::warn!(to, cap = self.cap, "queue full; dropped oldest");
106                }
107                None => break,
108            }
109        }
110        self.notify_handle(to).notify_one();
111        Ok(())
112    }
113
114    /// Wait up to `wait` for the oldest un-acked message for `id`. Returns the
115    /// envelope and its ack key; the message stays in the store until `ack`.
116    pub async fn recv(
117        &self,
118        id: &str,
119        wait: Duration,
120    ) -> anyhow::Result<Option<(Envelope, String)>> {
121        let deadline = tokio::time::Instant::now() + wait;
122        let notify = self.notify_handle(id);
123        loop {
124            if let Some((key, bytes)) = self.store.peek_oldest(id.to_string()).await? {
125                return Ok(Some((serde_json::from_slice(&bytes)?, key)));
126            }
127            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
128            if remaining.is_zero() {
129                return Ok(None);
130            }
131            // Register interest before re-checking, or a message enqueued in
132            // between would not wake us — the classic lost-wakeup.
133            let notified = notify.notified();
134            if timeout(remaining, notified).await.is_err() {
135                return Ok(None);
136            }
137        }
138    }
139
140    pub async fn ack(&self, key: &str) -> anyhow::Result<()> {
141        self.store.ack(key.to_string()).await
142    }
143
144    pub async fn depth(&self, id: &str) -> anyhow::Result<usize> {
145        self.store.depth(id.to_string()).await
146    }
147
148    pub fn router(self) -> Router {
149        Router::new()
150            .route("/send", post(send))
151            .route("/recv", get(recv_handler))
152            .route("/ack", post(ack_handler))
153            .route("/announce", post(announce_handler))
154            .route("/roster", get(roster_handler))
155            .with_state(self)
156    }
157}
158
159#[derive(Deserialize)]
160struct SendBody {
161    to: String,
162    payload: Value,
163}
164
165async fn send(State(broker): State<Broker>, Json(body): Json<SendBody>) -> StatusCode {
166    match broker
167        .enqueue(&body.to, body.payload, crate::now_ms())
168        .await
169    {
170        Ok(()) => StatusCode::ACCEPTED,
171        Err(e) => {
172            tracing::error!("enqueue failed: {e}");
173            StatusCode::INTERNAL_SERVER_ERROR
174        }
175    }
176}
177
178#[derive(Deserialize)]
179struct RecvQuery {
180    me: String,
181    #[serde(default = "default_timeout")]
182    timeout_ms: u64,
183}
184
185fn default_timeout() -> u64 {
186    DEFAULT_RECV_TIMEOUT_MS
187}
188
189async fn recv_handler(State(broker): State<Broker>, Query(q): Query<RecvQuery>) -> Response {
190    match broker
191        .recv(&q.me, Duration::from_millis(q.timeout_ms))
192        .await
193    {
194        Ok(Some((env, ack))) => {
195            Json(json!({ "status": "message", "envelope": env, "ack": ack })).into_response()
196        }
197        Ok(None) => Json(json!({ "status": "timeout" })).into_response(),
198        Err(e) => {
199            tracing::error!("recv failed: {e}");
200            // A 5xx (not a 200 with an error body) so the client's
201            // error_for_status() trips and it backs off instead of hot-looping.
202            (
203                StatusCode::INTERNAL_SERVER_ERROR,
204                Json(json!({ "status": "error" })),
205            )
206                .into_response()
207        }
208    }
209}
210
211#[derive(Deserialize)]
212struct AckBody {
213    me: String,
214    ack: String,
215}
216
217async fn ack_handler(State(broker): State<Broker>, Json(body): Json<AckBody>) -> StatusCode {
218    // The ack key encodes its recipient; only let `me` ack their own messages.
219    // Weak, but consistent with the bus's threat model (loopback/tailnet, no
220    // transport auth — signatures are what actually protect message integrity).
221    if !body.ack.starts_with(&format!("{}\u{0}", body.me)) {
222        return StatusCode::FORBIDDEN;
223    }
224    match broker.ack(&body.ack).await {
225        Ok(()) => StatusCode::OK,
226        Err(e) => {
227            tracing::error!("ack failed: {e}");
228            StatusCode::INTERNAL_SERVER_ERROR
229        }
230    }
231}
232
233/// The announcement is stored verbatim, keyed by the `pubkey` it self-declares —
234/// the only field the bus reads. Everything else (name, ts, sig) is opaque to it.
235async fn announce_handler(State(broker): State<Broker>, Json(body): Json<Value>) -> StatusCode {
236    let Some(pubkey) = body
237        .get("pubkey")
238        .and_then(|v| v.as_str())
239        .map(str::to_string)
240    else {
241        return StatusCode::BAD_REQUEST;
242    };
243    broker.announce(pubkey, body, crate::now_ms());
244    StatusCode::ACCEPTED
245}
246
247async fn roster_handler(State(broker): State<Broker>) -> Response {
248    Json(json!({ "roster": broker.roster(crate::now_ms()) })).into_response()
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    fn broker(cap: usize) -> Broker {
256        Broker::new(Store::in_memory().unwrap(), cap)
257    }
258
259    #[tokio::test]
260    async fn enqueue_recv_ack_roundtrip() {
261        let b = broker(8);
262        b.enqueue("alice", json!({ "hi": 1 }), 5).await.unwrap();
263        let (env, ack) = b
264            .recv("alice", Duration::from_millis(50))
265            .await
266            .unwrap()
267            .unwrap();
268        assert_eq!(env.payload, json!({ "hi": 1 }));
269        assert_eq!(env.ts, 5);
270        // keep-until-ack: still there before ack
271        assert_eq!(b.depth("alice").await.unwrap(), 1);
272        b.ack(&ack).await.unwrap();
273        assert_eq!(b.depth("alice").await.unwrap(), 0);
274    }
275
276    #[tokio::test]
277    async fn redelivers_until_acked() {
278        let b = broker(8);
279        b.enqueue("alice", json!("x"), 1).await.unwrap();
280        let (_e1, ack) = b
281            .recv("alice", Duration::from_millis(50))
282            .await
283            .unwrap()
284            .unwrap();
285        // A second recv without acking sees the SAME message again.
286        let (_e2, ack2) = b
287            .recv("alice", Duration::from_millis(50))
288            .await
289            .unwrap()
290            .unwrap();
291        assert_eq!(ack, ack2);
292        b.ack(&ack).await.unwrap();
293        assert!(
294            b.recv("alice", Duration::from_millis(20))
295                .await
296                .unwrap()
297                .is_none()
298        );
299    }
300
301    #[tokio::test]
302    async fn recv_times_out_when_empty() {
303        let b = broker(8);
304        assert!(
305            b.recv("nobody", Duration::from_millis(10))
306                .await
307                .unwrap()
308                .is_none()
309        );
310    }
311
312    #[tokio::test]
313    async fn fifo_order_across_acks() {
314        let b = broker(8);
315        for i in 0..3 {
316            b.enqueue("bob", json!(i), i).await.unwrap();
317        }
318        for i in 0..3 {
319            let (env, ack) = b
320                .recv("bob", Duration::from_millis(50))
321                .await
322                .unwrap()
323                .unwrap();
324            assert_eq!(env.payload, json!(i));
325            b.ack(&ack).await.unwrap();
326        }
327    }
328
329    #[tokio::test]
330    async fn bounded_queue_drops_oldest() {
331        let b = broker(2);
332        for i in 0..4 {
333            b.enqueue("bob", json!(i), i).await.unwrap();
334        }
335        assert_eq!(b.depth("bob").await.unwrap(), 2);
336        let (env, _) = b
337            .recv("bob", Duration::from_millis(50))
338            .await
339            .unwrap()
340            .unwrap();
341        assert_eq!(env.payload, json!(2), "0 and 1 were evicted");
342    }
343
344    #[tokio::test]
345    async fn a_waiting_recv_is_woken_by_a_later_send() {
346        let b = broker(8);
347        let b2 = b.clone();
348        let waiter = tokio::spawn(async move { b2.recv("alice", Duration::from_secs(2)).await });
349        tokio::time::sleep(Duration::from_millis(20)).await;
350        b.enqueue("alice", json!("wake"), 1).await.unwrap();
351        let (env, _) = waiter.await.unwrap().unwrap().unwrap();
352        assert_eq!(env.payload, json!("wake"));
353    }
354
355    #[test]
356    fn roster_stores_and_expires() {
357        let b = broker(8);
358        b.announce(
359            "keyA".into(),
360            json!({"pubkey":"keyA","name":"alice"}),
361            1_000,
362        );
363        b.announce("keyB".into(), json!({"pubkey":"keyB","name":"bob"}), 1_000);
364        assert_eq!(b.roster(1_000).len(), 2);
365        assert_eq!(b.roster(1_000 + ROSTER_TTL_MS - 1).len(), 2, "within TTL");
366        assert_eq!(
367            b.roster(1_000 + ROSTER_TTL_MS + 1).len(),
368            0,
369            "expired past TTL"
370        );
371    }
372
373    #[test]
374    fn announce_upserts_by_pubkey() {
375        let b = broker(8);
376        b.announce("keyA".into(), json!({"pubkey":"keyA","name":"old"}), 1_000);
377        b.announce("keyA".into(), json!({"pubkey":"keyA","name":"new"}), 2_000);
378        let r = b.roster(2_000);
379        assert_eq!(r.len(), 1, "same pubkey replaces, not appends");
380        assert_eq!(r[0]["name"], "new");
381    }
382}