Skip to main content

rullst_core/
realtime.rs

1//! Native Real-Time Engine (Channels, Broadcast, Presence).
2
3use 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;
14/// Registry size that triggers the first sweep of idle channels.
15const MIN_CHANNEL_SWEEP_THRESHOLD: usize = 64;
16
17/// Payload model for realtime broadcast events.
18#[derive(Clone, Debug, Serialize, Deserialize)]
19pub struct RealtimeMessage {
20    /// Target channel name.
21    pub channel: String,
22    /// Event name or topic.
23    pub event: String,
24    /// Stringified JSON or HTML payload.
25    pub payload: String,
26}
27
28/// Represents a named realtime channel with broadcast capabilities.
29pub struct Channel {
30    /// Channel identifier name.
31    pub name: String,
32    sender: broadcast::Sender<RealtimeMessage>,
33}
34
35/// Strongly-typed error domain for Rullst Realtime and Broadcast operations.
36#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
37pub enum RealtimeError {
38    /// Broadcast transmission failed.
39    #[error("Broadcast send error: {0}")]
40    BroadcastError(String),
41    /// A tenant-scoped channel name is empty, oversized, or ambiguous.
42    #[error("invalid realtime channel: {0}")]
43    InvalidChannel(String),
44    /// An event name is empty, oversized, or ambiguous.
45    #[error("invalid realtime event: {0}")]
46    InvalidEvent(String),
47    /// A payload exceeds the bounded in-process realtime envelope.
48    #[error("realtime payload is too large: {actual} bytes exceeds {maximum}")]
49    PayloadTooLarge {
50        /// Observed UTF-8 payload length in bytes.
51        actual: usize,
52        /// Maximum accepted UTF-8 payload length in bytes.
53        maximum: usize,
54    },
55    /// A presence identity is empty, oversized, or ambiguous.
56    #[error("invalid realtime presence identity: {0}")]
57    InvalidPresenceIdentity(String),
58}
59
60impl Channel {
61    /// Creates a new realtime channel with specified message queue capacity.
62    pub fn new(name: impl Into<String>, capacity: usize) -> Self {
63        // Tokio rejects zero-capacity broadcast channels. Keep this infallible
64        // compatibility constructor panic-free while preserving a bounded queue.
65        let (sender, _) = broadcast::channel(capacity.max(1));
66        Self {
67            name: name.into(),
68            sender,
69        }
70    }
71
72    /// Broadcasts an event and payload to all subscribed clients.
73    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    /// Subscribes to messages broadcasted on this channel.
85    pub fn subscribe(&self) -> broadcast::Receiver<RealtimeMessage> {
86        self.sender.subscribe()
87    }
88}
89
90/// In-process realtime facade permanently bound to one authenticated tenant.
91///
92/// Logical channel names are mapped to an immutable tenant namespace before a
93/// channel is opened or published. Authentication and room-level authorization
94/// remain application responsibilities; this wrapper prevents accidental
95/// cross-tenant reuse of the same logical room name.
96#[derive(Clone)]
97#[non_exhaustive]
98pub struct TenantRealtime {
99    manager: Arc<BroadcastManager>,
100    tenant_id: String,
101}
102
103impl TenantRealtime {
104    /// Binds a shared broadcast manager to an authenticated tenant context.
105    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    /// Returns the authenticated tenant identifier bound to this instance.
113    pub fn tenant_id(&self) -> &str {
114        &self.tenant_id
115    }
116
117    /// Returns the canonical backend channel inside this tenant namespace.
118    ///
119    /// The channel is `tenants:<tenant>:<logical_channel>`. In the tenant
120    /// segment `%` is written as `%25` and `:` as `%3A`, so a logical channel
121    /// containing `:` can never alias another tenant's channel.
122    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    /// Subscribes only to the tenant-scoped version of a logical channel.
128    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    /// Publishes a bounded event only to this tenant's logical channel.
137    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/// Thread-safe in-memory pub/sub manager for realtime channels.
183///
184/// A channel is retained only while it has a subscriber or a caller holds the
185/// `Arc` from [`Self::get_or_create`]. Publishing never creates a channel, and
186/// idle channels are released on a failed publish or by an amortized sweep.
187#[derive(Default)]
188pub struct BroadcastManager {
189    channels: DashMap<String, Arc<Channel>>,
190    sweep_threshold: AtomicUsize,
191}
192
193impl BroadcastManager {
194    /// Creates a new BroadcastManager instance.
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// Retrieves an existing channel or creates a new one if it does not exist.
200    /// A channel with no subscriber and no outstanding `Arc` may be released.
201    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    /// Publishes a message directly to a channel by name. Without subscribers it
219    /// returns [`RealtimeError::BroadcastError`] and retains no channel.
220    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    /// Sweeps once the registry has doubled since the previous sweep, keeping
245    /// the amortized cost per created channel constant.
246    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
260/// Checked under the shard write lock: if the registry holds the only `Arc`,
261/// nobody can clone it or subscribe through it until the lock is released, so
262/// a concurrent subscriber is never detached from the channel it joined.
263fn is_idle_channel(channel: &Arc<Channel>) -> bool {
264    Arc::strong_count(channel) == 1 && channel.sender.receiver_count() == 0
265}
266
267/// In-memory tracker for active user presence across channels/rooms.
268///
269/// A room is removed when its last user leaves.
270#[derive(Default)]
271pub struct PresenceTracker {
272    online_users: DashMap<String, DashMap<String, u64>>,
273}
274
275impl PresenceTracker {
276    /// Creates a new PresenceTracker instance.
277    pub fn new() -> Self {
278        Self::default()
279    }
280
281    /// Registers a user as online in a specific room.
282    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    /// Removes a user from a specific room upon disconnect, and the room once
293    /// it is empty.
294    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            // `user_joined` inserts while holding this shard's write lock, so the
301            // re-check cannot discard a user who joined in the meantime.
302            self.online_users
303                .remove_if(room, |_, room_map| room_map.is_empty());
304        }
305    }
306
307    /// Returns the count of currently online users in a room.
308    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/// In-process presence facade permanently bound to one authenticated tenant.
314#[derive(Clone)]
315#[non_exhaustive]
316pub struct TenantPresence {
317    tracker: Arc<PresenceTracker>,
318    tenant_id: String,
319}
320
321impl TenantPresence {
322    /// Binds a shared presence tracker to an authenticated tenant context.
323    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    /// Returns the authenticated tenant identifier bound to this instance.
331    pub fn tenant_id(&self) -> &str {
332        &self.tenant_id
333    }
334
335    /// Registers a bounded identity only in this tenant's logical room.
336    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    /// Removes a bounded identity only from this tenant's logical room.
348    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    /// Returns the online count only for this tenant's logical room.
360    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    // TM-TENANT-04
382    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    // TM-TENANT-04
443    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}