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