Skip to main content

pylon_runtime/
presence.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3use std::time::{Duration, Instant};
4
5/// A user's presence state.
6#[derive(Debug, Clone)]
7pub struct PresenceEntry {
8    pub user_id: String,
9    pub room: String,
10    pub data: serde_json::Value,
11    pub last_seen: Instant,
12}
13
14/// Server-side presence tracker.
15///
16/// Tracks which users are currently present in which rooms, with automatic
17/// timeout-based expiration. Thread-safe via interior `Mutex`.
18pub struct PresenceTracker {
19    entries: Mutex<HashMap<String, PresenceEntry>>,
20    timeout: Duration,
21}
22
23impl PresenceTracker {
24    /// Create a new tracker with the given timeout in seconds.
25    /// Entries not refreshed within this window are considered stale.
26    pub fn new(timeout_secs: u64) -> Self {
27        Self {
28            entries: Mutex::new(HashMap::new()),
29            timeout: Duration::from_secs(timeout_secs),
30        }
31    }
32
33    /// Upsert a user's presence in a room. Resets the `last_seen` timestamp.
34    pub fn set(&self, room: &str, user_id: &str, data: serde_json::Value) {
35        let key = format!("{room}:{user_id}");
36        let entry = PresenceEntry {
37            user_id: user_id.to_string(),
38            room: room.to_string(),
39            data,
40            last_seen: Instant::now(),
41        };
42        self.entries
43            .lock()
44            .expect("presence lock poisoned")
45            .insert(key, entry);
46    }
47
48    /// Explicitly remove a user from a room.
49    pub fn remove(&self, room: &str, user_id: &str) {
50        let key = format!("{room}:{user_id}");
51        self.entries
52            .lock()
53            .expect("presence lock poisoned")
54            .remove(&key);
55    }
56
57    /// Return all active (non-timed-out) users in a room.
58    pub fn get_room(&self, room: &str) -> Vec<PresenceEntry> {
59        let now = Instant::now();
60        let entries = self.entries.lock().expect("presence lock poisoned");
61        entries
62            .values()
63            .filter(|e| e.room == room && now.duration_since(e.last_seen) < self.timeout)
64            .cloned()
65            .collect()
66    }
67
68    /// Remove all entries whose `last_seen` exceeds the timeout.
69    pub fn cleanup(&self) {
70        let now = Instant::now();
71        let timeout = self.timeout;
72        self.entries
73            .lock()
74            .expect("presence lock poisoned")
75            .retain(|_, e| now.duration_since(e.last_seen) < timeout);
76    }
77
78    /// Check whether a specific user is present (and not timed out) in a room.
79    pub fn is_present(&self, room: &str, user_id: &str) -> bool {
80        let key = format!("{room}:{user_id}");
81        let now = Instant::now();
82        let entries = self.entries.lock().expect("presence lock poisoned");
83        entries
84            .get(&key)
85            .map(|e| now.duration_since(e.last_seen) < self.timeout)
86            .unwrap_or(false)
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn set_and_get_room() {
96        let tracker = PresenceTracker::new(60);
97        tracker.set("lobby", "alice", serde_json::json!({"status": "online"}));
98        tracker.set("lobby", "bob", serde_json::json!({"status": "away"}));
99
100        let members = tracker.get_room("lobby");
101        assert_eq!(members.len(), 2);
102
103        let user_ids: Vec<&str> = members.iter().map(|e| e.user_id.as_str()).collect();
104        assert!(user_ids.contains(&"alice"));
105        assert!(user_ids.contains(&"bob"));
106    }
107
108    #[test]
109    fn get_room_excludes_other_rooms() {
110        let tracker = PresenceTracker::new(60);
111        tracker.set("lobby", "alice", serde_json::json!({}));
112        tracker.set("kitchen", "bob", serde_json::json!({}));
113
114        let lobby = tracker.get_room("lobby");
115        assert_eq!(lobby.len(), 1);
116        assert_eq!(lobby[0].user_id, "alice");
117
118        let kitchen = tracker.get_room("kitchen");
119        assert_eq!(kitchen.len(), 1);
120        assert_eq!(kitchen[0].user_id, "bob");
121    }
122
123    #[test]
124    fn upsert_refreshes_data() {
125        let tracker = PresenceTracker::new(60);
126        tracker.set("lobby", "alice", serde_json::json!({"status": "online"}));
127        tracker.set("lobby", "alice", serde_json::json!({"status": "away"}));
128
129        let members = tracker.get_room("lobby");
130        assert_eq!(members.len(), 1);
131        assert_eq!(members[0].data, serde_json::json!({"status": "away"}));
132    }
133
134    #[test]
135    fn remove_explicit() {
136        let tracker = PresenceTracker::new(60);
137        tracker.set("lobby", "alice", serde_json::json!({}));
138        assert!(tracker.is_present("lobby", "alice"));
139
140        tracker.remove("lobby", "alice");
141        assert!(!tracker.is_present("lobby", "alice"));
142        assert!(tracker.get_room("lobby").is_empty());
143    }
144
145    #[test]
146    fn is_present_returns_false_for_unknown() {
147        let tracker = PresenceTracker::new(60);
148        assert!(!tracker.is_present("lobby", "nobody"));
149    }
150
151    #[test]
152    fn timeout_expires_entries() {
153        // Use a zero-second timeout so entries expire immediately.
154        let tracker = PresenceTracker::new(0);
155        tracker.set("lobby", "alice", serde_json::json!({}));
156
157        // Even though we just inserted, a 0s timeout means last_seen is
158        // already >= timeout (Duration comparison is strictly less-than).
159        assert!(!tracker.is_present("lobby", "alice"));
160        assert!(tracker.get_room("lobby").is_empty());
161    }
162
163    #[test]
164    fn cleanup_removes_stale_entries() {
165        let tracker = PresenceTracker::new(0);
166        tracker.set("lobby", "alice", serde_json::json!({}));
167        tracker.set("lobby", "bob", serde_json::json!({}));
168
169        tracker.cleanup();
170
171        // After cleanup, the internal map should be empty.
172        let entries = tracker.entries.lock().unwrap();
173        assert!(entries.is_empty());
174    }
175
176    #[test]
177    fn multiple_rooms_same_user() {
178        let tracker = PresenceTracker::new(60);
179        tracker.set("lobby", "alice", serde_json::json!({"room": "lobby"}));
180        tracker.set("kitchen", "alice", serde_json::json!({"room": "kitchen"}));
181
182        assert!(tracker.is_present("lobby", "alice"));
183        assert!(tracker.is_present("kitchen", "alice"));
184
185        tracker.remove("lobby", "alice");
186        assert!(!tracker.is_present("lobby", "alice"));
187        assert!(tracker.is_present("kitchen", "alice"));
188    }
189}