1use std::{
21 collections::{HashMap, HashSet},
22 sync::Arc,
23 time::{Duration, Instant},
24};
25
26use chrono::Utc;
27use hyphae::Gettable;
28use marshal_entities::{AutoSource, Room, RoomKind, RoomMember, Session};
29use myko::{core::item::Eventable, server::CellServerCtx, utils::downcast_item};
30
31pub const STALE_AFTER: Duration = Duration::from_secs(10);
33
34pub const HOOK_BACKSTOP: Duration = Duration::from_secs(60 * 60);
40
41pub const TICK_INTERVAL: Duration = Duration::from_secs(3);
45
46pub async fn run_sweeper(ctx: CellServerCtx) {
48 let mut disconnected_since: HashMap<Arc<str>, Instant> = HashMap::new();
49 let mut interval = tokio::time::interval(TICK_INTERVAL);
50 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
51
52 loop {
53 interval.tick().await;
54 sweep_once(&ctx, &mut disconnected_since);
55 sweep_rooms(&ctx);
56 }
57}
58
59fn sweep_rooms(ctx: &CellServerCtx) {
67 let Some(room_store) = ctx.registry.get(Room::ENTITY_NAME_STATIC) else {
68 return;
69 };
70 let Some(member_store) = ctx.registry.get(RoomMember::ENTITY_NAME_STATIC) else {
71 return;
72 };
73
74 let mut member_counts: HashMap<Arc<str>, usize> = HashMap::new();
76 for (_id, item) in member_store.entries().get() {
77 if let Some(m) = downcast_item::<RoomMember>(&item) {
78 *member_counts.entry(m.room_id.0.clone()).or_default() += 1;
79 }
80 }
81
82 let mut to_delete: Vec<Arc<str>> = Vec::new();
83 for (id, item) in room_store.entries().get() {
84 let Some(room) = downcast_item::<Room>(&item) else {
85 continue;
86 };
87 let RoomKind::Auto { source } = &room.kind else {
89 continue;
90 };
91 if matches!(source, AutoSource::Everyone) {
93 continue;
94 }
95 let is_host = matches!(source, AutoSource::Host { .. });
96 let empty = member_counts.get(&room.id.0).copied().unwrap_or(0) == 0;
97 if is_host || empty {
98 to_delete.push(id);
99 }
100 }
101
102 for id in to_delete {
103 log::info!("[cleanup] DELing stale auto-room {}", id);
104 if let Err(e) = ctx.del_by_id(Room::ENTITY_NAME_STATIC, &id) {
105 log::warn!("[cleanup] del room {} failed: {}", id, e);
106 }
107 }
108}
109
110fn sweep_once(ctx: &CellServerCtx, disconnected_since: &mut HashMap<Arc<str>, Instant>) {
111 let Some(session_store) = ctx.registry.get(Session::ENTITY_NAME_STATIC) else {
112 return;
113 };
114 let Some(client_store) = ctx.registry.get("Client") else {
115 return;
119 };
120
121 let live_client_ids: HashSet<Arc<str>> = client_store
122 .entries()
123 .get()
124 .into_iter()
125 .map(|(id, _)| id)
126 .collect();
127
128 let now = Instant::now();
129 let now_ms = Utc::now().timestamp_millis();
130 let backstop_ms = HOOK_BACKSTOP.as_millis() as i64;
131 let mut to_delete: Vec<Arc<str>> = Vec::new();
132 let mut still_disconnected: HashSet<Arc<str>> = HashSet::new();
133
134 for (id, item) in session_store.entries().get() {
135 let Some(session) = downcast_item::<Session>(&item) else {
136 continue;
137 };
138
139 match session.client_id.as_ref() {
140 None => {
144 let last = session.last_activity_at.unwrap_or(session.connected_at);
145 if now_ms.saturating_sub(last) >= backstop_ms {
146 to_delete.push(id);
147 }
148 }
149 Some(cid) if live_client_ids.contains(&cid.0) => {
151 disconnected_since.remove(&id);
152 }
153 Some(_) => {
155 still_disconnected.insert(id.clone());
156 let first_seen = *disconnected_since.entry(id.clone()).or_insert(now);
157 if now.duration_since(first_seen) >= STALE_AFTER {
158 to_delete.push(id);
159 }
160 }
161 }
162 }
163
164 disconnected_since.retain(|id, _| still_disconnected.contains(id));
167
168 for id in to_delete {
169 log::info!("[cleanup] DELing abandoned session {}", id);
170 if let Err(e) = ctx.del_by_id(Session::ENTITY_NAME_STATIC, &id) {
171 log::warn!("[cleanup] del session {} failed: {}", id, e);
172 continue;
173 }
174 disconnected_since.remove(&id);
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use marshal_entities::{RoomId, RoomMemberId, SessionId};
182 use myko::{
183 server::Persister,
184 wire::{MEvent, MEventType},
185 };
186 use myko_server::{BlackholePersister, CellServer};
187 use std::collections::HashSet;
188 use uuid::Uuid;
189
190 fn setup() -> CellServerCtx {
191 marshal_entities::link();
192 crate::link();
193 let blackhole: Arc<dyn Persister> = Arc::new(BlackholePersister);
194 let server = CellServer::builder()
195 .with_default_persister(blackhole)
196 .build();
197 let ctx = server.ctx();
198 Box::leak(Box::new(server));
199 ctx
200 }
201
202 fn set_room(ctx: &CellServerCtx, id: &str, kind: RoomKind) {
203 let room = Room {
204 id: RoomId(Arc::from(id)),
205 name: id.to_string(),
206 description: None,
207 kind,
208 created_at: 0,
209 };
210 let ev = MEvent::from_item(&room, MEventType::SET, &Uuid::new_v4().to_string());
211 ctx.apply_event_batch(vec![ev]).expect("apply Room SET");
212 }
213
214 fn set_member(ctx: &CellServerCtx, room_id: &str, session_id: &str) {
215 let member = RoomMember {
216 id: RoomMemberId(Arc::from(RoomMember::make_id(room_id, session_id).as_str())),
217 room_id: RoomId(Arc::from(room_id)),
218 session_id: SessionId(Arc::from(session_id)),
219 joined_at: 0,
220 };
221 let ev = MEvent::from_item(&member, MEventType::SET, &Uuid::new_v4().to_string());
222 ctx.apply_event_batch(vec![ev])
223 .expect("apply RoomMember SET");
224 }
225
226 fn room_ids(ctx: &CellServerCtx) -> HashSet<String> {
227 ctx.registry
228 .get(Room::ENTITY_NAME_STATIC)
229 .map(|s| {
230 s.entries()
231 .get()
232 .into_iter()
233 .map(|(id, _)| id.to_string())
234 .collect()
235 })
236 .unwrap_or_default()
237 }
238
239 #[test]
240 fn sweep_reaps_host_and_empty_auto_rooms_and_spares_the_rest() {
241 let ctx = setup();
242
243 set_room(
245 &ctx,
246 "everyone",
247 RoomKind::Auto {
248 source: AutoSource::Everyone,
249 },
250 );
251 set_member(&ctx, "everyone", "sess-a");
252 set_room(
254 &ctx,
255 "host:node1",
256 RoomKind::Auto {
257 source: AutoSource::Host {
258 name: "node1".into(),
259 },
260 },
261 );
262 set_member(&ctx, "host:node1", "sess-a");
263 set_room(
265 &ctx,
266 "project:live",
267 RoomKind::Auto {
268 source: AutoSource::Project {
269 basename: "live".into(),
270 },
271 },
272 );
273 set_member(&ctx, "project:live", "sess-a");
274 set_room(
276 &ctx,
277 "project:stale",
278 RoomKind::Auto {
279 source: AutoSource::Project {
280 basename: "stale".into(),
281 },
282 },
283 );
284 set_room(&ctx, "design-sync", RoomKind::Adhoc);
286
287 sweep_rooms(&ctx);
288
289 let ids = room_ids(&ctx);
290 assert!(ids.contains("everyone"), "global room must survive");
291 assert!(
292 ids.contains("project:live"),
293 "populated project room must survive"
294 );
295 assert!(ids.contains("design-sync"), "empty adhoc room must survive");
296 assert!(!ids.contains("host:node1"), "host room must be reaped");
297 assert!(
298 !ids.contains("project:stale"),
299 "empty auto-room must be reaped"
300 );
301 }
302}