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#session_id` → (opaque signed announcement,
51    /// received-at ms). Keyed per *session* so several live sessions under one
52    /// identity coexist instead of overwriting each other; clients group by the
53    /// announcement's `pubkey`. In-memory and ephemeral — the bus stores and serves
54    /// it but never verifies it; clients check the signatures. Just a bulletin board.
55    roster: Arc<DashMap<String, (Value, u64)>>,
56    cap: usize,
57}
58
59impl Broker {
60    pub fn new(store: Store, cap: usize) -> Self {
61        Self {
62            store,
63            notifies: Arc::new(DashMap::new()),
64            roster: Arc::new(DashMap::new()),
65            cap: cap.max(1),
66        }
67    }
68
69    /// Record a presence announcement under a per-session key (`pubkey#session_id`,
70    /// or a bare `pubkey` for a sessionless legacy announcement). Prunes expired
71    /// entries; the announcement is stored verbatim (the bus never inspects it
72    /// beyond the routing key).
73    pub fn announce(&self, key: String, announcement: Value, now: u64) {
74        self.roster
75            .retain(|_, (_, at)| now.saturating_sub(*at) < ROSTER_TTL_MS);
76        if self.roster.len() >= ROSTER_CAP && !self.roster.contains_key(&key) {
77            return; // full of live entries; drop the newcomer rather than evict
78        }
79        self.roster.insert(key, (announcement, now));
80    }
81
82    /// Immediately drop a session's presence (a graceful close), so a peer learns
83    /// it's really gone rather than waiting out the TTL. Unsigned and best-effort:
84    /// the bus verifies nothing, and a still-live session simply re-announces on its
85    /// next heartbeat, so a spurious unregister is self-healing.
86    pub fn unregister(&self, key: &str) {
87        self.roster.remove(key);
88    }
89
90    /// The live (non-expired) announcements.
91    pub fn roster(&self, now: u64) -> Vec<Value> {
92        self.roster
93            .iter()
94            .filter(|e| now.saturating_sub(e.value().1) < ROSTER_TTL_MS)
95            .map(|e| e.value().0.clone())
96            .collect()
97    }
98
99    fn notify_handle(&self, id: &str) -> Arc<Notify> {
100        self.notifies
101            .entry(id.to_string())
102            .or_insert_with(|| Arc::new(Notify::new()))
103            .clone()
104    }
105
106    /// Enqueue for `to`: persist, enforce the cap (drop oldest), wake a waiter.
107    pub async fn enqueue(&self, to: &str, payload: Value, ts: u64) -> anyhow::Result<()> {
108        let bytes = serde_json::to_vec(&Envelope { payload, ts })?;
109        self.store.enqueue(to.to_string(), bytes).await?;
110        // Bounded: drop oldest beyond the cap so a never-returning recipient
111        // can't grow the store without limit.
112        while self.store.depth(to.to_string()).await? > self.cap {
113            match self.store.peek_oldest(to.to_string()).await? {
114                Some((old_key, _)) => {
115                    self.store.ack(old_key).await?;
116                    tracing::warn!(to, cap = self.cap, "queue full; dropped oldest");
117                }
118                None => break,
119            }
120        }
121        self.notify_handle(to).notify_one();
122        Ok(())
123    }
124
125    /// Wait up to `wait` for the oldest un-acked message for `id`. Returns the
126    /// envelope and its ack key; the message stays in the store until `ack`.
127    pub async fn recv(
128        &self,
129        id: &str,
130        wait: Duration,
131    ) -> anyhow::Result<Option<(Envelope, String)>> {
132        let deadline = tokio::time::Instant::now() + wait;
133        let notify = self.notify_handle(id);
134        loop {
135            if let Some((key, bytes)) = self.store.peek_oldest(id.to_string()).await? {
136                return Ok(Some((serde_json::from_slice(&bytes)?, key)));
137            }
138            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
139            if remaining.is_zero() {
140                return Ok(None);
141            }
142            // Register interest before re-checking, or a message enqueued in
143            // between would not wake us — the classic lost-wakeup.
144            let notified = notify.notified();
145            if timeout(remaining, notified).await.is_err() {
146                return Ok(None);
147            }
148        }
149    }
150
151    pub async fn ack(&self, key: &str) -> anyhow::Result<()> {
152        self.store.ack(key.to_string()).await
153    }
154
155    pub async fn depth(&self, id: &str) -> anyhow::Result<usize> {
156        self.store.depth(id.to_string()).await
157    }
158
159    pub fn router(self) -> Router {
160        Router::new()
161            .route("/send", post(send))
162            .route("/recv", get(recv_handler))
163            .route("/ack", post(ack_handler))
164            .route("/announce", post(announce_handler))
165            .route("/unregister", post(unregister_handler))
166            .route("/roster", get(roster_handler))
167            .with_state(self)
168    }
169}
170
171#[derive(Deserialize)]
172struct SendBody {
173    to: String,
174    payload: Value,
175}
176
177async fn send(State(broker): State<Broker>, Json(body): Json<SendBody>) -> StatusCode {
178    match broker
179        .enqueue(&body.to, body.payload, crate::now_ms())
180        .await
181    {
182        Ok(()) => StatusCode::ACCEPTED,
183        Err(e) => {
184            tracing::error!("enqueue failed: {e}");
185            StatusCode::INTERNAL_SERVER_ERROR
186        }
187    }
188}
189
190#[derive(Deserialize)]
191struct RecvQuery {
192    me: String,
193    #[serde(default = "default_timeout")]
194    timeout_ms: u64,
195}
196
197fn default_timeout() -> u64 {
198    DEFAULT_RECV_TIMEOUT_MS
199}
200
201async fn recv_handler(State(broker): State<Broker>, Query(q): Query<RecvQuery>) -> Response {
202    match broker
203        .recv(&q.me, Duration::from_millis(q.timeout_ms))
204        .await
205    {
206        Ok(Some((env, ack))) => {
207            Json(json!({ "status": "message", "envelope": env, "ack": ack })).into_response()
208        }
209        Ok(None) => Json(json!({ "status": "timeout" })).into_response(),
210        Err(e) => {
211            tracing::error!("recv failed: {e}");
212            // A 5xx (not a 200 with an error body) so the client's
213            // error_for_status() trips and it backs off instead of hot-looping.
214            (
215                StatusCode::INTERNAL_SERVER_ERROR,
216                Json(json!({ "status": "error" })),
217            )
218                .into_response()
219        }
220    }
221}
222
223#[derive(Deserialize)]
224struct AckBody {
225    me: String,
226    ack: String,
227}
228
229async fn ack_handler(State(broker): State<Broker>, Json(body): Json<AckBody>) -> StatusCode {
230    // The ack key encodes its recipient; only let `me` ack their own messages.
231    // Weak, but consistent with the bus's threat model (loopback/tailnet, no
232    // transport auth — signatures are what actually protect message integrity).
233    if !body.ack.starts_with(&format!("{}\u{0}", body.me)) {
234        return StatusCode::FORBIDDEN;
235    }
236    match broker.ack(&body.ack).await {
237        Ok(()) => StatusCode::OK,
238        Err(e) => {
239            tracing::error!("ack failed: {e}");
240            StatusCode::INTERNAL_SERVER_ERROR
241        }
242    }
243}
244
245/// The roster key: `pubkey#session_id`, or a bare `pubkey` when the announcement
246/// carries no session (a legacy node). The pubkey is the only field the bus needs
247/// to read; the rest (name, session, ts, sig) is opaque to it.
248fn roster_key(body: &Value) -> Option<String> {
249    let pubkey = body.get("pubkey").and_then(|v| v.as_str())?;
250    let session_id = body
251        .get("session")
252        .and_then(|s| s.get("session_id"))
253        .and_then(|v| v.as_str())
254        .filter(|s| !s.is_empty());
255    Some(match session_id {
256        Some(sid) => format!("{pubkey}#{sid}"),
257        None => pubkey.to_string(),
258    })
259}
260
261/// The announcement is stored verbatim under its per-session roster key.
262async fn announce_handler(State(broker): State<Broker>, Json(body): Json<Value>) -> StatusCode {
263    let Some(key) = roster_key(&body) else {
264        return StatusCode::BAD_REQUEST;
265    };
266    broker.announce(key, body, crate::now_ms());
267    StatusCode::ACCEPTED
268}
269
270/// Graceful presence removal. Takes the same `{ pubkey, session }` shape as an
271/// announcement (the sig is ignored — see [`Broker::unregister`]).
272async fn unregister_handler(State(broker): State<Broker>, Json(body): Json<Value>) -> StatusCode {
273    let Some(key) = roster_key(&body) else {
274        return StatusCode::BAD_REQUEST;
275    };
276    broker.unregister(&key);
277    StatusCode::ACCEPTED
278}
279
280async fn roster_handler(State(broker): State<Broker>) -> Response {
281    Json(json!({ "roster": broker.roster(crate::now_ms()) })).into_response()
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn broker(cap: usize) -> Broker {
289        Broker::new(Store::in_memory().unwrap(), cap)
290    }
291
292    #[tokio::test]
293    async fn enqueue_recv_ack_roundtrip() {
294        let b = broker(8);
295        b.enqueue("alice", json!({ "hi": 1 }), 5).await.unwrap();
296        let (env, ack) = b
297            .recv("alice", Duration::from_millis(50))
298            .await
299            .unwrap()
300            .unwrap();
301        assert_eq!(env.payload, json!({ "hi": 1 }));
302        assert_eq!(env.ts, 5);
303        // keep-until-ack: still there before ack
304        assert_eq!(b.depth("alice").await.unwrap(), 1);
305        b.ack(&ack).await.unwrap();
306        assert_eq!(b.depth("alice").await.unwrap(), 0);
307    }
308
309    #[tokio::test]
310    async fn redelivers_until_acked() {
311        let b = broker(8);
312        b.enqueue("alice", json!("x"), 1).await.unwrap();
313        let (_e1, ack) = b
314            .recv("alice", Duration::from_millis(50))
315            .await
316            .unwrap()
317            .unwrap();
318        // A second recv without acking sees the SAME message again.
319        let (_e2, ack2) = b
320            .recv("alice", Duration::from_millis(50))
321            .await
322            .unwrap()
323            .unwrap();
324        assert_eq!(ack, ack2);
325        b.ack(&ack).await.unwrap();
326        assert!(
327            b.recv("alice", Duration::from_millis(20))
328                .await
329                .unwrap()
330                .is_none()
331        );
332    }
333
334    #[tokio::test]
335    async fn recv_times_out_when_empty() {
336        let b = broker(8);
337        assert!(
338            b.recv("nobody", Duration::from_millis(10))
339                .await
340                .unwrap()
341                .is_none()
342        );
343    }
344
345    #[tokio::test]
346    async fn fifo_order_across_acks() {
347        let b = broker(8);
348        for i in 0..3 {
349            b.enqueue("bob", json!(i), i).await.unwrap();
350        }
351        for i in 0..3 {
352            let (env, ack) = b
353                .recv("bob", Duration::from_millis(50))
354                .await
355                .unwrap()
356                .unwrap();
357            assert_eq!(env.payload, json!(i));
358            b.ack(&ack).await.unwrap();
359        }
360    }
361
362    #[tokio::test]
363    async fn bounded_queue_drops_oldest() {
364        let b = broker(2);
365        for i in 0..4 {
366            b.enqueue("bob", json!(i), i).await.unwrap();
367        }
368        assert_eq!(b.depth("bob").await.unwrap(), 2);
369        let (env, _) = b
370            .recv("bob", Duration::from_millis(50))
371            .await
372            .unwrap()
373            .unwrap();
374        assert_eq!(env.payload, json!(2), "0 and 1 were evicted");
375    }
376
377    #[tokio::test]
378    async fn a_waiting_recv_is_woken_by_a_later_send() {
379        let b = broker(8);
380        let b2 = b.clone();
381        let waiter = tokio::spawn(async move { b2.recv("alice", Duration::from_secs(2)).await });
382        tokio::time::sleep(Duration::from_millis(20)).await;
383        b.enqueue("alice", json!("wake"), 1).await.unwrap();
384        let (env, _) = waiter.await.unwrap().unwrap().unwrap();
385        assert_eq!(env.payload, json!("wake"));
386    }
387
388    #[test]
389    fn roster_stores_and_expires() {
390        let b = broker(8);
391        b.announce(
392            "keyA".into(),
393            json!({"pubkey":"keyA","name":"alice"}),
394            1_000,
395        );
396        b.announce("keyB".into(), json!({"pubkey":"keyB","name":"bob"}), 1_000);
397        assert_eq!(b.roster(1_000).len(), 2);
398        assert_eq!(b.roster(1_000 + ROSTER_TTL_MS - 1).len(), 2, "within TTL");
399        assert_eq!(
400            b.roster(1_000 + ROSTER_TTL_MS + 1).len(),
401            0,
402            "expired past TTL"
403        );
404    }
405
406    #[test]
407    fn announce_upserts_by_pubkey() {
408        let b = broker(8);
409        b.announce("keyA".into(), json!({"pubkey":"keyA","name":"old"}), 1_000);
410        b.announce("keyA".into(), json!({"pubkey":"keyA","name":"new"}), 2_000);
411        let r = b.roster(2_000);
412        assert_eq!(r.len(), 1, "same pubkey replaces, not appends");
413        assert_eq!(r[0]["name"], "new");
414    }
415
416    #[test]
417    fn roster_key_scopes_by_session() {
418        let s1 = json!({"pubkey":"keyA","session":{"session_id":"aa11"}});
419        let s2 = json!({"pubkey":"keyA","session":{"session_id":"bb22"}});
420        assert_eq!(roster_key(&s1).unwrap(), "keyA#aa11");
421        assert_ne!(
422            roster_key(&s1),
423            roster_key(&s2),
424            "distinct sessions distinct"
425        );
426        // Legacy / sessionless announcement falls back to the bare pubkey.
427        assert_eq!(roster_key(&json!({"pubkey":"keyA"})).unwrap(), "keyA");
428        assert!(
429            roster_key(&json!({"name":"x"})).is_none(),
430            "pubkey required"
431        );
432    }
433
434    #[test]
435    fn two_sessions_under_one_identity_coexist() {
436        let b = broker(8);
437        let s1 = json!({"pubkey":"keyA","session":{"session_id":"aa11"}});
438        let s2 = json!({"pubkey":"keyA","session":{"session_id":"bb22"}});
439        b.announce(roster_key(&s1).unwrap(), s1, 1_000);
440        b.announce(roster_key(&s2).unwrap(), s2, 1_000);
441        assert_eq!(b.roster(1_000).len(), 2, "same identity, two live sessions");
442    }
443
444    #[test]
445    fn unregister_removes_one_session_immediately() {
446        let b = broker(8);
447        let s1 = json!({"pubkey":"keyA","session":{"session_id":"aa11"}});
448        let s2 = json!({"pubkey":"keyA","session":{"session_id":"bb22"}});
449        b.announce(roster_key(&s1).unwrap(), s1.clone(), 1_000);
450        b.announce(roster_key(&s2).unwrap(), s2, 1_000);
451        b.unregister(&roster_key(&s1).unwrap());
452        let r = b.roster(1_000);
453        assert_eq!(r.len(), 1, "only the unregistered session is gone");
454        assert_eq!(r[0]["session"]["session_id"], "bb22");
455    }
456}