Skip to main content

daemon/
cleanup.rs

1//! Periodic sweep that DELs abandoned sessions.
2//!
3//! Two session kinds, two liveness rules:
4//!
5//! - **WS shim sessions** (`client_id: Some`): live while that client id
6//!   is in the live `Client` store. When the client drops, a `STALE_AFTER`
7//!   grace covers reconnect blips, then the session is DEL'd. Tracking is
8//!   in-memory (`disconnected_since`); daemon restart resets it, giving
9//!   every reloaded session a fresh grace window.
10//!
11//! - **Pull/hook sessions** (`client_id: None`): an HTTP-MCP agent that
12//!   registered via the `/hook/session-start` endpoint has no WS client at
13//!   all — it would be swept instantly by the client-id rule. Its liveness
14//!   is instead its `last_activity_at` (the hook bumps it every turn) plus
15//!   a long `HOOK_BACKSTOP` grace. The clean teardown path is the explicit
16//!   `/hook/session-end` DEL; this grace is only a backstop for a client
17//!   that crashed without firing SessionEnd. Because `last_activity_at` is
18//!   wall-clock and persisted, the backstop survives daemon restarts.
19
20use 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
31/// How long a WS-shim session must be without a live client before DEL.
32pub const STALE_AFTER: Duration = Duration::from_secs(10);
33
34/// How long a pull/hook session (no WS client) may go without any hook
35/// activity before the backstop DELs it. Generous: merely-idle sessions
36/// re-register on their next turn, so this only needs to be short enough
37/// to eventually reclaim sessions whose client crashed without firing
38/// `/hook/session-end`. 60 min.
39pub const HOOK_BACKSTOP: Duration = Duration::from_secs(60 * 60);
40
41/// How often the sweeper wakes up to check for stale sessions. Anything
42/// roughly under `STALE_AFTER` is fine; the trade-off is reaction latency
43/// (lower) vs. wake-ups per minute (higher).
44pub const TICK_INTERVAL: Duration = Duration::from_secs(3);
45
46/// Run the sweeper forever. Spawn this on a tokio task and forget it.
47pub 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
59/// GC pass: DEL auto-rooms that no longer earn their place — any `host:`
60/// room (that anchor is retired; see `auto_rooms.rs`) and any auto-room that
61/// has dropped to zero members (its anchoring sessions were all reaped, e.g.
62/// the ~35 stale empties that accreted because room GC never existed).
63/// `everyone` is permanent; adhoc rooms are user-owned and left alone.
64/// DELing a Room cascades its RoomMember rows via `belongs_to(Room)`, so
65/// live memberships never orphan.
66fn 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    // Live member count per room id.
75    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        // Only auto-rooms are GC'd; adhoc rooms are user-owned.
88        let RoomKind::Auto { source } = &room.kind else {
89            continue;
90        };
91        // The global room is permanent.
92        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        // No Client store yet (server still warming up). Treat all sessions
116        // as "live" this tick — wait until we have the snapshot before
117        // making delete decisions.
118        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            // Pull/hook session: no WS client by design. Liveness is hook
141            // activity + the long backstop; the SessionEnd hook is the
142            // clean DEL path. Not subject to the WS reconnect grace.
143            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            // WS shim session bound to a live client: healthy.
150            Some(cid) if live_client_ids.contains(&cid.0) => {
151                disconnected_since.remove(&id);
152            }
153            // WS shim session whose client has gone: reconnect grace.
154            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    // Drop tracking for sessions that no longer exist in the store (e.g.
165    // someone DEL'd them out from under us).
166    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        // Survives: the global room, always.
244        set_room(
245            &ctx,
246            "everyone",
247            RoomKind::Auto {
248                source: AutoSource::Everyone,
249            },
250        );
251        set_member(&ctx, "everyone", "sess-a");
252        // Reaped: a host room, even with a live member (anchor retired).
253        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        // Survives: a project room with members.
264        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        // Reaped: a project room whose sessions were all reaped (0 members).
275        set_room(
276            &ctx,
277            "project:stale",
278            RoomKind::Auto {
279                source: AutoSource::Project {
280                    basename: "stale".into(),
281                },
282            },
283        );
284        // Survives: an adhoc room even when empty — user-owned lifecycle.
285        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}