Skip to main content

rainier_framework/
relay.rs

1//! Relaying broadcasts back into the sockets **this** process holds.
2//!
3//! [`rainier-websocket`](rainier_websocket) says, in its own documentation,
4//! that a `Rooms` registry lives in one process's memory and that two replicas
5//! behind a load balancer therefore have two sets of rooms. This is the module
6//! that stops that being true.
7//!
8//! ```text
9//!   POST /orders/7/ship  ─▶ replica A ─▶ Broadcasting ─▶ kafka topic
10//!                                                            │
11//!                                        ┌───────────────────┴────────────┐
12//!                                        ▼                                ▼
13//!                                 relay on replica A              relay on replica B
14//!                                        │                                │
15//!                                   its Rooms                        its Rooms
16//!                                        │                                │
17//!                                    browsers                         browsers
18//! ```
19//!
20//! Every replica publishes to the topic and every replica reads from it,
21//! including the one that published — which is what makes a broadcast reach a
22//! browser regardless of which replica served the request that caused it.
23//!
24//! # Two processes or one
25//!
26//! Nothing here needs a second deployment. [`spawn`] starts the relay inside
27//! the web process, next to the sockets it feeds, so the whole of "broadcasting
28//! that works behind a load balancer" is a Kafka topic and one line in a
29//! provider.
30
31use std::sync::Arc;
32use std::time::Duration;
33
34use rainier_broadcast::kafka::{KafkaRelay, RelayedBroadcast};
35use rainier_support::Result;
36use rainier_websocket::{socket_from_identity, Rooms};
37
38/// How long to wait before reconnecting after the relay falls over.
39///
40/// A broker restart or a leader election should not permanently stop a
41/// replica's broadcasts, and hammering a cluster that is having a bad time is
42/// how a blip becomes an outage.
43const RECONNECT_AFTER: Duration = Duration::from_secs(5);
44
45/// Decides the room a channel's broadcasts belong in, or that they belong
46/// nowhere here.
47type RoomNamer = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
48
49/// Delivers relayed broadcasts to the sockets in a [`Rooms`] registry.
50///
51/// ```no_run
52/// use std::sync::Arc;
53/// use rainier_framework::relay::SocketFanOut;
54/// use rainier_websocket::Rooms;
55///
56/// let rooms = Arc::new(Rooms::new());
57///
58/// // Channels are named `private-orders.7`; this application's rooms are
59/// // named `orders.7`.
60/// let fan_out = SocketFanOut::new(Arc::clone(&rooms))
61///     .naming_rooms(|channel| Some(channel.trim_start_matches("private-").to_string()));
62/// # let _ = fan_out;
63/// ```
64#[derive(Clone)]
65pub struct SocketFanOut {
66    rooms: Arc<Rooms>,
67    room_for: RoomNamer,
68}
69
70impl SocketFanOut {
71    /// Deliver to `rooms`, with the room named exactly as the channel is.
72    pub fn new(rooms: Arc<Rooms>) -> Self {
73        Self { rooms, room_for: Arc::new(|channel: &str| Some(channel.to_string())) }
74    }
75
76    /// Decide the room a channel's broadcasts belong in.
77    ///
78    /// `None` drops the broadcast, which is how a relay ignores channels this
79    /// process does not serve — a topic shared with another application, or a
80    /// channel that only a Pusher client listens to.
81    pub fn naming_rooms<F>(mut self, room_for: F) -> Self
82    where
83        F: Fn(&str) -> Option<String> + Send + Sync + 'static,
84    {
85        self.room_for = Arc::new(room_for);
86        self
87    }
88
89    /// Send one broadcast to whoever is in the room. Returns how many got it.
90    ///
91    /// `to_others` is honoured **here**, at the point of delivery, because this
92    /// is the only place that knows which sockets it is talking to. An identity
93    /// minted by another replica names no socket in this process, so nothing is
94    /// skipped and everyone here is told — which is the correct answer, not a
95    /// fallback.
96    pub fn deliver(&self, broadcast: &RelayedBroadcast) -> usize {
97        let Some(room) = (self.room_for)(&broadcast.channel) else {
98            return 0;
99        };
100
101        let body = broadcast.wire_payload().to_string();
102
103        match broadcast.except.as_deref().and_then(socket_from_identity) {
104            Some(socket) => self.rooms.send_except(&room, socket, body),
105            None => self.rooms.send(&room, body),
106        }
107    }
108
109    /// The registry being fanned out to.
110    pub fn rooms(&self) -> &Arc<Rooms> {
111        &self.rooms
112    }
113}
114
115/// Read the topic and deliver, until something goes wrong.
116///
117/// Returns on the first failure rather than swallowing it — [`spawn`] is what
118/// adds the retry, and a caller running this directly usually wants to know.
119pub async fn run(relay: &KafkaRelay, fan_out: &SocketFanOut) -> Result<()> {
120    let mut cursor = relay.subscribe().await?;
121
122    tracing::info!(
123        topic = relay.topic(),
124        partitions = cursor.len(),
125        "relaying broadcasts to this process's sockets"
126    );
127
128    loop {
129        for broadcast in relay.poll(&mut cursor).await? {
130            let reached = fan_out.deliver(&broadcast);
131
132            tracing::debug!(
133                channel = broadcast.channel,
134                event = broadcast.event,
135                sockets = reached,
136                "relayed"
137            );
138        }
139    }
140}
141
142/// Run the relay in the background, reconnecting when it falls over.
143///
144/// Call it from a provider's `boot`, in the process that serves WebSockets:
145///
146/// ```ignore
147/// let relay = KafkaRelay::new(Arc::clone(&kafka), "broadcasts");
148/// relay::spawn(relay, SocketFanOut::new(Arc::clone(&rooms)));
149/// ```
150///
151/// The task carries the facade scope with it, so anything the fan-out reaches
152/// can resolve from the container — a spawned task inherits neither the thread
153/// nor the task scope on its own.
154pub fn spawn(relay: KafkaRelay, fan_out: SocketFanOut) -> tokio::task::JoinHandle<()> {
155    rainier_container::spawn_with_facades(async move {
156        loop {
157            if let Err(e) = run(&relay, &fan_out).await {
158                tracing::error!(
159                    topic = relay.topic(),
160                    error = %e,
161                    "the broadcast relay stopped; reconnecting"
162                );
163            }
164
165            tokio::time::sleep(RECONNECT_AFTER).await;
166        }
167    })
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use rainier_drivers::kafka::KafkaPosition;
174    use rainier_websocket::{Socket, SocketId};
175    use serde_json::json;
176    use tokio::sync::mpsc;
177
178    fn broadcast(channel: &str, except: Option<&str>) -> RelayedBroadcast {
179        RelayedBroadcast {
180            channel: channel.to_string(),
181            event: "OrderShipped".into(),
182            payload: json!({ "id": 7 }),
183            except: except.map(str::to_string),
184            position: KafkaPosition { topic: "broadcasts".into(), partition: 0, offset: 1 },
185        }
186    }
187
188    fn socket() -> (Socket, mpsc::UnboundedReceiver<rainier_websocket::Outbound>) {
189        let (tx, rx) = mpsc::unbounded_channel();
190        (Socket::new(SocketId::next(), "/ws", vec![], tx), rx)
191    }
192
193    #[test]
194    fn a_broadcast_reaches_the_room_named_after_its_channel() {
195        let rooms = Arc::new(Rooms::new());
196        let (socket, mut rx) = socket();
197        rooms.join("private-orders.7", socket);
198
199        let reached = SocketFanOut::new(rooms).deliver(&broadcast("private-orders.7", None));
200
201        assert_eq!(reached, 1);
202        assert!(rx.try_recv().is_ok(), "the socket should have been sent something");
203    }
204
205    #[test]
206    fn the_room_name_can_be_derived_from_the_channel() {
207        let rooms = Arc::new(Rooms::new());
208        let (socket, mut rx) = socket();
209        rooms.join("orders.7", socket);
210
211        let fan_out = SocketFanOut::new(rooms)
212            .naming_rooms(|channel| Some(channel.trim_start_matches("private-").to_string()));
213
214        assert_eq!(fan_out.deliver(&broadcast("private-orders.7", None)), 1);
215        assert!(rx.try_recv().is_ok());
216    }
217
218    #[test]
219    fn a_channel_this_process_does_not_serve_is_dropped() {
220        let rooms = Arc::new(Rooms::new());
221        let (socket, mut rx) = socket();
222        rooms.join("private-orders.7", socket);
223
224        let fan_out = SocketFanOut::new(rooms).naming_rooms(|_| None);
225
226        assert_eq!(fan_out.deliver(&broadcast("private-orders.7", None)), 0);
227        assert!(rx.try_recv().is_err(), "nothing should have been sent");
228    }
229
230    #[test]
231    fn to_others_skips_the_socket_that_caused_it() {
232        let rooms = Arc::new(Rooms::new());
233        let (mine, mut mine_rx) = socket();
234        let (theirs, mut theirs_rx) = socket();
235
236        let identity = mine.identity();
237        rooms.join("news", mine);
238        rooms.join("news", theirs);
239
240        let reached = SocketFanOut::new(rooms).deliver(&broadcast("news", Some(&identity)));
241
242        assert_eq!(reached, 1);
243        assert!(mine_rx.try_recv().is_err(), "the socket that caused it hears nothing");
244        assert!(theirs_rx.try_recv().is_ok(), "everyone else does");
245    }
246
247    #[test]
248    fn an_identity_from_another_replica_skips_nobody_here() {
249        // The subtle one. Socket ids are per-process counters, so replica B
250        // also has a socket `7`. Skipping by the bare number would silence an
251        // unrelated browser on every replica but the one that published.
252        let rooms = Arc::new(Rooms::new());
253        let (socket, mut rx) = socket();
254        let number = socket.id().get();
255        rooms.join("news", socket);
256
257        let reached = SocketFanOut::new(rooms)
258            .deliver(&broadcast("news", Some(&format!("another-replica.{number}"))));
259
260        assert_eq!(reached, 1, "that socket is not in this process");
261        assert!(rx.try_recv().is_ok());
262    }
263
264    #[test]
265    fn the_body_is_the_event_and_its_data() {
266        let rooms = Arc::new(Rooms::new());
267        let (socket, mut rx) = socket();
268        rooms.join("news", socket);
269
270        SocketFanOut::new(rooms).deliver(&broadcast("news", None));
271
272        match rx.try_recv().unwrap() {
273            rainier_websocket::Outbound::Send(message) => {
274                let body: serde_json::Value =
275                    serde_json::from_str(message.as_text().unwrap()).unwrap();
276
277                assert_eq!(body["event"], "OrderShipped");
278                assert_eq!(body["data"]["id"], 7);
279            }
280            other => panic!("{other:?}"),
281        }
282    }
283}