1use dashmap::DashMap;
4use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicUsize, Ordering};
7use tokio::sync::broadcast;
8
9use crate::security::{TenantContext, tenant_namespaced_name};
10
11const MAX_CHANNEL_BYTES: usize = 128;
12const MAX_EVENT_BYTES: usize = 128;
13const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
14const MIN_CHANNEL_SWEEP_THRESHOLD: usize = 64;
16
17#[derive(Clone, Debug, Serialize, Deserialize)]
19pub struct RealtimeMessage {
20 pub channel: String,
22 pub event: String,
24 pub payload: String,
26}
27
28pub struct Channel {
30 pub name: String,
32 sender: broadcast::Sender<RealtimeMessage>,
33}
34
35#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
37pub enum RealtimeError {
38 #[error("Broadcast send error: {0}")]
40 BroadcastError(String),
41 #[error("invalid realtime channel: {0}")]
43 InvalidChannel(String),
44 #[error("invalid realtime event: {0}")]
46 InvalidEvent(String),
47 #[error("realtime payload is too large: {actual} bytes exceeds {maximum}")]
49 PayloadTooLarge {
50 actual: usize,
52 maximum: usize,
54 },
55 #[error("invalid realtime presence identity: {0}")]
57 InvalidPresenceIdentity(String),
58}
59
60impl Channel {
61 pub fn new(name: impl Into<String>, capacity: usize) -> Self {
63 let (sender, _) = broadcast::channel(capacity.max(1));
66 Self {
67 name: name.into(),
68 sender,
69 }
70 }
71
72 pub fn broadcast(&self, event: &str, payload: &str) -> Result<usize, RealtimeError> {
74 let msg = RealtimeMessage {
75 channel: self.name.clone(),
76 event: event.to_string(),
77 payload: payload.to_string(),
78 };
79 self.sender
80 .send(msg)
81 .map_err(|e| RealtimeError::BroadcastError(e.to_string()))
82 }
83
84 pub fn subscribe(&self) -> broadcast::Receiver<RealtimeMessage> {
86 self.sender.subscribe()
87 }
88}
89
90#[derive(Clone)]
97#[non_exhaustive]
98pub struct TenantRealtime {
99 manager: Arc<BroadcastManager>,
100 tenant_id: String,
101}
102
103impl TenantRealtime {
104 pub fn from_context(manager: Arc<BroadcastManager>, context: &TenantContext) -> Self {
106 Self {
107 manager,
108 tenant_id: context.tenant_id.clone(),
109 }
110 }
111
112 pub fn tenant_id(&self) -> &str {
114 &self.tenant_id
115 }
116
117 pub fn namespaced_channel(&self, logical_channel: &str) -> Result<String, RealtimeError> {
123 validate_room(logical_channel)?;
124 Ok(tenant_namespaced_name(&self.tenant_id, logical_channel))
125 }
126
127 pub fn subscribe(
129 &self,
130 logical_channel: &str,
131 ) -> Result<broadcast::Receiver<RealtimeMessage>, RealtimeError> {
132 let channel = self.namespaced_channel(logical_channel)?;
133 Ok(self.manager.get_or_create(&channel).subscribe())
134 }
135
136 pub fn publish(
138 &self,
139 logical_channel: &str,
140 event: &str,
141 payload: &str,
142 ) -> Result<usize, RealtimeError> {
143 let channel = self.namespaced_channel(logical_channel)?;
144 validate_name(event, MAX_EVENT_BYTES, RealtimeError::InvalidEvent)?;
145 if payload.len() > MAX_PAYLOAD_BYTES {
146 return Err(RealtimeError::PayloadTooLarge {
147 actual: payload.len(),
148 maximum: MAX_PAYLOAD_BYTES,
149 });
150 }
151 self.manager.publish(&channel, event, payload)
152 }
153}
154
155fn validate_name(
156 value: &str,
157 maximum: usize,
158 error: impl FnOnce(String) -> RealtimeError,
159) -> Result<(), RealtimeError> {
160 if value.is_empty()
161 || value.len() > maximum
162 || !value.bytes().all(|byte| {
163 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/')
164 })
165 {
166 return Err(error(value.to_string()));
167 }
168 Ok(())
169}
170
171fn validate_room(room: &str) -> Result<(), RealtimeError> {
172 validate_name(room, MAX_CHANNEL_BYTES, RealtimeError::InvalidChannel)?;
173 if room
174 .split('/')
175 .any(|segment| segment.is_empty() || matches!(segment, "." | ".."))
176 {
177 return Err(RealtimeError::InvalidChannel(room.to_string()));
178 }
179 Ok(())
180}
181
182#[derive(Default)]
188pub struct BroadcastManager {
189 channels: DashMap<String, Arc<Channel>>,
190 sweep_threshold: AtomicUsize,
191}
192
193impl BroadcastManager {
194 pub fn new() -> Self {
196 Self::default()
197 }
198
199 pub fn get_or_create(&self, channel_name: &str) -> Arc<Channel> {
202 let mut created = false;
203 let channel = self
204 .channels
205 .entry(channel_name.to_string())
206 .or_insert_with(|| {
207 created = true;
208 Arc::new(Channel::new(channel_name, 100))
209 })
210 .value()
211 .clone();
212 if created {
213 self.sweep_idle_channels_if_due();
214 }
215 channel
216 }
217
218 pub fn publish(
221 &self,
222 channel_name: &str,
223 event: &str,
224 payload: &str,
225 ) -> Result<usize, RealtimeError> {
226 let Some(channel) = self
227 .channels
228 .get(channel_name)
229 .map(|entry| Arc::clone(entry.value()))
230 else {
231 return Err(RealtimeError::BroadcastError(
232 broadcast::error::SendError(()).to_string(),
233 ));
234 };
235 let result = channel.broadcast(event, payload);
236 if result.is_err() {
237 drop(channel);
238 self.channels
239 .remove_if(channel_name, |_, channel| is_idle_channel(channel));
240 }
241 result
242 }
243
244 fn sweep_idle_channels_if_due(&self) {
247 let threshold = self.sweep_threshold.load(Ordering::Relaxed);
248 if self.channels.len() >= threshold.max(MIN_CHANNEL_SWEEP_THRESHOLD) {
249 self.remove_idle_channels();
250 let next = self.channels.len().saturating_mul(2);
251 self.sweep_threshold.store(next, Ordering::Relaxed);
252 }
253 }
254
255 fn remove_idle_channels(&self) {
256 self.channels.retain(|_, channel| !is_idle_channel(channel));
257 }
258}
259
260fn is_idle_channel(channel: &Arc<Channel>) -> bool {
264 Arc::strong_count(channel) == 1 && channel.sender.receiver_count() == 0
265}
266
267#[derive(Default)]
271pub struct PresenceTracker {
272 online_users: DashMap<String, DashMap<String, u64>>,
273}
274
275impl PresenceTracker {
276 pub fn new() -> Self {
278 Self::default()
279 }
280
281 pub fn user_joined(&self, room: &str, user_id: &str) {
283 let now = std::time::SystemTime::now()
284 .duration_since(std::time::UNIX_EPOCH)
285 .unwrap_or_default()
286 .as_secs();
287
288 let room_map = self.online_users.entry(room.to_string()).or_default();
289 room_map.insert(user_id.to_string(), now);
290 }
291
292 pub fn user_left(&self, room: &str, user_id: &str) {
295 let now_empty = self.online_users.get(room).is_some_and(|room_map| {
296 room_map.remove(user_id);
297 room_map.is_empty()
298 });
299 if now_empty {
300 self.online_users
303 .remove_if(room, |_, room_map| room_map.is_empty());
304 }
305 }
306
307 pub fn count_online(&self, room: &str) -> usize {
309 self.online_users.get(room).map(|m| m.len()).unwrap_or(0)
310 }
311}
312
313#[derive(Clone)]
315#[non_exhaustive]
316pub struct TenantPresence {
317 tracker: Arc<PresenceTracker>,
318 tenant_id: String,
319}
320
321impl TenantPresence {
322 pub fn from_context(tracker: Arc<PresenceTracker>, context: &TenantContext) -> Self {
324 Self {
325 tracker,
326 tenant_id: context.tenant_id.clone(),
327 }
328 }
329
330 pub fn tenant_id(&self) -> &str {
332 &self.tenant_id
333 }
334
335 pub fn user_joined(&self, room: &str, user_id: &str) -> Result<(), RealtimeError> {
337 let room = self.namespaced_room(room)?;
338 validate_name(
339 user_id,
340 MAX_CHANNEL_BYTES,
341 RealtimeError::InvalidPresenceIdentity,
342 )?;
343 self.tracker.user_joined(&room, user_id);
344 Ok(())
345 }
346
347 pub fn user_left(&self, room: &str, user_id: &str) -> Result<(), RealtimeError> {
349 let room = self.namespaced_room(room)?;
350 validate_name(
351 user_id,
352 MAX_CHANNEL_BYTES,
353 RealtimeError::InvalidPresenceIdentity,
354 )?;
355 self.tracker.user_left(&room, user_id);
356 Ok(())
357 }
358
359 pub fn count_online(&self, room: &str) -> Result<usize, RealtimeError> {
361 Ok(self.tracker.count_online(&self.namespaced_room(room)?))
362 }
363
364 fn namespaced_room(&self, room: &str) -> Result<String, RealtimeError> {
365 validate_room(room)?;
366 Ok(tenant_namespaced_name(&self.tenant_id, room))
367 }
368}
369
370#[cfg(test)]
371#[path = "realtime_retention_tests.rs"]
372mod retention_tests;
373
374#[cfg(test)]
375#[allow(clippy::expect_used)]
376mod tests {
377 use super::*;
378 use crate::security::TenantMembership;
379
380 #[tokio::test]
381 async fn identical_channels_are_isolated_by_authenticated_tenant_context() {
383 let membership = TenantMembership::try_new(["school-alpha", "school-beta"])
384 .expect("valid tenant membership");
385 let alpha_context = membership.select("school-alpha").expect("alpha membership");
386 let beta_context = membership.select("school-beta").expect("beta membership");
387 let manager = Arc::new(BroadcastManager::new());
388 let alpha = TenantRealtime::from_context(Arc::clone(&manager), &alpha_context);
389 let beta = TenantRealtime::from_context(manager, &beta_context);
390 let mut alpha_receiver = alpha.subscribe("course/1").expect("alpha subscription");
391 let mut beta_receiver = beta.subscribe("course/1").expect("beta subscription");
392
393 alpha
394 .publish("course/1", "lesson.completed", r#"{"lesson_id":7}"#)
395 .expect("alpha publish");
396 let alpha_message = alpha_receiver.recv().await.expect("alpha message");
397
398 assert_eq!(alpha_message.channel, "tenants:school-alpha:course/1");
399 assert_eq!(alpha_message.event, "lesson.completed");
400 assert!(matches!(
401 beta_receiver.try_recv(),
402 Err(broadcast::error::TryRecvError::Empty)
403 ));
404 assert!(matches!(
405 beta.publish("../school-alpha/course/1", "lesson.completed", "{}"),
406 Err(RealtimeError::InvalidChannel(_))
407 ));
408 assert!(matches!(
409 beta.publish("course/1", "lesson completed", "{}"),
410 Err(RealtimeError::InvalidEvent(_))
411 ));
412 assert!(matches!(
413 beta.publish("course/1", "lesson.completed", &"x".repeat(65_537)),
414 Err(RealtimeError::PayloadTooLarge { .. })
415 ));
416
417 let presence = Arc::new(PresenceTracker::new());
418 let alpha_presence = TenantPresence::from_context(Arc::clone(&presence), &alpha_context);
419 let beta_presence = TenantPresence::from_context(presence, &beta_context);
420 alpha_presence
421 .user_joined("course/1", "learner-7")
422 .expect("alpha presence");
423 assert_eq!(
424 alpha_presence
425 .count_online("course/1")
426 .expect("alpha presence count"),
427 1
428 );
429 assert_eq!(
430 beta_presence
431 .count_online("course/1")
432 .expect("beta presence count"),
433 0
434 );
435 assert!(matches!(
436 beta_presence.user_joined("course/1", "learner 7"),
437 Err(RealtimeError::InvalidPresenceIdentity(_))
438 ));
439 }
440
441 #[tokio::test]
442 async fn colon_in_tenant_or_channel_cannot_alias_another_namespace() {
444 let membership =
445 TenantMembership::try_new(["a", "a:b", "acme"]).expect("valid tenant membership");
446 let a_context = membership.select("a").expect("a membership");
447 let a_b_context = membership.select("a:b").expect("a:b membership");
448 let acme_context = membership.select("acme").expect("acme membership");
449 let manager = Arc::new(BroadcastManager::new());
450 let a = TenantRealtime::from_context(Arc::clone(&manager), &a_context);
451 let a_b = TenantRealtime::from_context(Arc::clone(&manager), &a_b_context);
452 let acme = TenantRealtime::from_context(manager, &acme_context);
453
454 assert_eq!(a.namespaced_channel("b:c").expect("a"), "tenants:a:b:c");
455 assert_eq!(a_b.namespaced_channel("c").expect("a:b"), "tenants:a%3Ab:c");
456 assert_eq!(
457 acme.namespaced_channel("course:1").expect("acme"),
458 "tenants:acme:course:1"
459 );
460
461 let mut a_receiver = a.subscribe("b:c").expect("a subscription");
462 let mut a_b_receiver = a_b.subscribe("c").expect("a:b subscription");
463 assert_eq!(
464 a_b.publish("c", "lesson.completed", "{}")
465 .expect("a:b publish"),
466 1
467 );
468 assert_eq!(
469 a_b_receiver.recv().await.expect("a:b message").channel,
470 "tenants:a%3Ab:c"
471 );
472 assert!(matches!(
473 a_receiver.try_recv(),
474 Err(broadcast::error::TryRecvError::Empty)
475 ));
476
477 let presence = Arc::new(PresenceTracker::new());
478 let a_presence = TenantPresence::from_context(Arc::clone(&presence), &a_context);
479 let a_b_presence = TenantPresence::from_context(presence, &a_b_context);
480 assert_eq!(
481 a_b_presence.namespaced_room("c").expect("a:b room"),
482 "tenants:a%3Ab:c"
483 );
484 a_presence
485 .user_joined("b:c", "learner-7")
486 .expect("a presence");
487 assert_eq!(a_presence.count_online("b:c").expect("a count"), 1);
488 assert_eq!(a_b_presence.count_online("c").expect("a:b count"), 0);
489 }
490
491 #[test]
492 fn zero_capacity_channel_is_panic_free() {
493 let channel = Channel::new("bounded", 0);
494 let _receiver = channel.subscribe();
495 assert_eq!(channel.name, "bounded");
496 }
497}