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, Message, 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. Sized
32/// to survive a WHOLE-FLEET reconnect after a daemon restart: on restart every
33/// session replays from disk carrying a stale `client_id`, and the in-memory
34/// `disconnected_since` map is empty, so all sessions enter the grace at once.
35/// Too short and a slow-to-redial shim gets reaped — cascading its UNREAD
36/// direct messages away (belongs_to(Session), review R5) — before it
37/// reconnects to read them. 60s gives the fleet room to re-dial; a genuinely
38/// dead session just lingers that long (and the roster's liveness fields show
39/// it going stale).
40pub const STALE_AFTER: Duration = Duration::from_secs(60);
41
42/// Messages older than this are pruned by `sweep_messages`, regardless of
43/// recipient. Direct messages already cascade away with a DEL'd recipient
44/// session; this bounds the ones that DON'T — broadcasts addressed to the
45/// never-DEL'd `everyone`/`op:`/`project:` rooms, which otherwise accumulate
46/// forever (review R6/D). DELing a Message cascades its `MessageRead` rows.
47pub const MESSAGE_TTL: Duration = Duration::from_secs(14 * 24 * 60 * 60);
48
49/// Run the message-retention sweep every N session-sweeper ticks. The scan is
50/// O(all messages); at one tick per `TICK_INTERVAL`, every 100 ticks (~5 min)
51/// bounds growth without paying the scan each tick.
52const MESSAGE_SWEEP_EVERY: u64 = 100;
53
54/// How long a pull/hook session (no WS client) may go without any hook
55/// activity before the backstop DELs it. Generous: merely-idle sessions
56/// re-register on their next turn, so this only needs to be short enough
57/// to eventually reclaim sessions whose client crashed without firing
58/// `/hook/session-end`. 60 min.
59pub const HOOK_BACKSTOP: Duration = Duration::from_secs(60 * 60);
60
61/// How often the sweeper wakes up to check for stale sessions. Anything
62/// roughly under `STALE_AFTER` is fine; the trade-off is reaction latency
63/// (lower) vs. wake-ups per minute (higher).
64pub const TICK_INTERVAL: Duration = Duration::from_secs(3);
65
66/// Run the sweeper forever. Spawn this on a tokio task and forget it.
67pub async fn run_sweeper(ctx: CellServerCtx) {
68    let mut disconnected_since: HashMap<Arc<str>, Instant> = HashMap::new();
69    let mut interval = tokio::time::interval(TICK_INTERVAL);
70    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
71    let mut tick: u64 = 0;
72
73    loop {
74        interval.tick().await;
75        sweep_once(&ctx, &mut disconnected_since);
76        sweep_rooms(&ctx);
77        // Prune old messages on the first tick (boot cleanup) and every
78        // MESSAGE_SWEEP_EVERY ticks thereafter — not every tick (the scan is
79        // O(all messages)).
80        if tick.is_multiple_of(MESSAGE_SWEEP_EVERY) {
81            sweep_messages(&ctx);
82        }
83        tick = tick.wrapping_add(1);
84    }
85}
86
87/// Prune messages older than `MESSAGE_TTL`. Bounds the never-cascaded
88/// broadcasts (to `everyone`/`op:`/`project:`, which are never DEL'd) that
89/// would otherwise grow the store without limit. DELing a Message cascades its
90/// `MessageRead` rows via `belongs_to(Message)`, so read-state is cleaned too.
91fn sweep_messages(ctx: &CellServerCtx) {
92    let Some(store) = ctx.registry.get(Message::ENTITY_NAME_STATIC) else {
93        return;
94    };
95    let cutoff = Utc::now().timestamp_millis() - MESSAGE_TTL.as_millis() as i64;
96    let mut to_delete: Vec<Arc<str>> = Vec::new();
97    for (id, item) in store.entries().get() {
98        if let Some(m) = downcast_item::<Message>(&item)
99            && m.sent_at < cutoff
100        {
101            to_delete.push(id);
102        }
103    }
104    if to_delete.is_empty() {
105        return;
106    }
107    log::info!(
108        "[cleanup] pruning {} message(s) older than {} days",
109        to_delete.len(),
110        MESSAGE_TTL.as_secs() / 86_400,
111    );
112    for id in to_delete {
113        if let Err(e) = ctx.del_by_id(Message::ENTITY_NAME_STATIC, &id) {
114            log::warn!("[cleanup] prune message {} failed: {}", id, e);
115        }
116    }
117}
118
119/// GC pass: DEL auto-rooms that no longer earn their place — any `host:`
120/// room (that anchor is retired; see `auto_rooms.rs`) and any auto-room that
121/// has dropped to zero members (its anchoring sessions were all reaped, e.g.
122/// the ~35 stale empties that accreted because room GC never existed).
123/// `everyone` is permanent; adhoc rooms are user-owned and left alone.
124/// DELing a Room cascades its RoomMember rows via `belongs_to(Room)`, so
125/// live memberships never orphan.
126fn sweep_rooms(ctx: &CellServerCtx) {
127    let Some(room_store) = ctx.registry.get(Room::ENTITY_NAME_STATIC) else {
128        return;
129    };
130    let Some(member_store) = ctx.registry.get(RoomMember::ENTITY_NAME_STATIC) else {
131        return;
132    };
133
134    // Live member count per room id.
135    let mut member_counts: HashMap<Arc<str>, usize> = HashMap::new();
136    for (_id, item) in member_store.entries().get() {
137        if let Some(m) = downcast_item::<RoomMember>(&item) {
138            *member_counts.entry(m.room_id.0.clone()).or_default() += 1;
139        }
140    }
141
142    let mut to_delete: Vec<Arc<str>> = Vec::new();
143    for (id, item) in room_store.entries().get() {
144        let Some(room) = downcast_item::<Room>(&item) else {
145            continue;
146        };
147        // Only auto-rooms are GC'd; adhoc rooms are user-owned.
148        let RoomKind::Auto { source } = &room.kind else {
149            continue;
150        };
151        // The global room is permanent.
152        if matches!(source, AutoSource::Everyone) {
153            continue;
154        }
155        let is_host = matches!(source, AutoSource::Host { .. });
156        let empty = member_counts.get(&room.id.0).copied().unwrap_or(0) == 0;
157        if is_host || empty {
158            to_delete.push(id);
159        }
160    }
161
162    for id in to_delete {
163        log::info!("[cleanup] DELing stale auto-room {}", id);
164        if let Err(e) = ctx.del_by_id(Room::ENTITY_NAME_STATIC, &id) {
165            log::warn!("[cleanup] del room {} failed: {}", id, e);
166        }
167    }
168}
169
170fn sweep_once(ctx: &CellServerCtx, disconnected_since: &mut HashMap<Arc<str>, Instant>) {
171    let Some(session_store) = ctx.registry.get(Session::ENTITY_NAME_STATIC) else {
172        return;
173    };
174    let Some(client_store) = ctx.registry.get("Client") else {
175        // No Client store yet (server still warming up). Treat all sessions
176        // as "live" this tick — wait until we have the snapshot before
177        // making delete decisions.
178        return;
179    };
180
181    let live_client_ids: HashSet<Arc<str>> = client_store
182        .entries()
183        .get()
184        .into_iter()
185        .map(|(id, _)| id)
186        .collect();
187
188    let now = Instant::now();
189    let now_ms = Utc::now().timestamp_millis();
190    let backstop_ms = HOOK_BACKSTOP.as_millis() as i64;
191    let mut to_delete: Vec<Arc<str>> = Vec::new();
192    let mut still_disconnected: HashSet<Arc<str>> = HashSet::new();
193
194    for (id, item) in session_store.entries().get() {
195        let Some(session) = downcast_item::<Session>(&item) else {
196            continue;
197        };
198
199        match session.client_id.as_ref() {
200            // Pull/hook session: no WS client by design. Liveness is hook
201            // activity + the long backstop; the SessionEnd hook is the
202            // clean DEL path. Not subject to the WS reconnect grace.
203            None => {
204                let last = session.last_activity_at.unwrap_or(session.connected_at);
205                if now_ms.saturating_sub(last) >= backstop_ms {
206                    to_delete.push(id);
207                }
208            }
209            // WS shim session bound to a live client: healthy.
210            Some(cid) if live_client_ids.contains(&cid.0) => {
211                disconnected_since.remove(&id);
212            }
213            // WS shim session whose client has gone: reconnect grace.
214            Some(_) => {
215                still_disconnected.insert(id.clone());
216                let first_seen = *disconnected_since.entry(id.clone()).or_insert(now);
217                if now.duration_since(first_seen) >= STALE_AFTER {
218                    to_delete.push(id);
219                }
220            }
221        }
222    }
223
224    // Drop tracking for sessions that no longer exist in the store (e.g.
225    // someone DEL'd them out from under us).
226    disconnected_since.retain(|id, _| still_disconnected.contains(id));
227
228    for id in to_delete {
229        log::info!("[cleanup] DELing abandoned session {}", id);
230        if let Err(e) = ctx.del_by_id(Session::ENTITY_NAME_STATIC, &id) {
231            log::warn!("[cleanup] del session {} failed: {}", id, e);
232            continue;
233        }
234        disconnected_since.remove(&id);
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use marshal_entities::{Message, MessageId, RoomId, RoomMemberId, SessionId};
242    use myko::{
243        server::Persister,
244        wire::{MEvent, MEventType},
245    };
246    use myko_server::{BlackholePersister, CellServer};
247    use std::collections::HashSet;
248    use uuid::Uuid;
249
250    fn setup() -> CellServerCtx {
251        marshal_entities::link();
252        crate::link();
253        let blackhole: Arc<dyn Persister> = Arc::new(BlackholePersister);
254        let server = CellServer::builder()
255            .with_default_persister(blackhole)
256            .build();
257        let ctx = server.ctx();
258        Box::leak(Box::new(server));
259        ctx
260    }
261
262    fn set_room(ctx: &CellServerCtx, id: &str, kind: RoomKind) {
263        let room = Room {
264            id: RoomId(Arc::from(id)),
265            name: id.to_string(),
266            description: None,
267            kind,
268            created_at: 0,
269        };
270        let ev = MEvent::from_item(&room, MEventType::SET, &Uuid::new_v4().to_string());
271        ctx.apply_event_batch(vec![ev]).expect("apply Room SET");
272    }
273
274    fn set_member(ctx: &CellServerCtx, room_id: &str, session_id: &str) {
275        let member = RoomMember {
276            id: RoomMemberId(Arc::from(RoomMember::make_id(room_id, session_id).as_str())),
277            room_id: RoomId(Arc::from(room_id)),
278            session_id: SessionId(Arc::from(session_id)),
279            joined_at: 0,
280        };
281        let ev = MEvent::from_item(&member, MEventType::SET, &Uuid::new_v4().to_string());
282        ctx.apply_event_batch(vec![ev])
283            .expect("apply RoomMember SET");
284    }
285
286    fn room_ids(ctx: &CellServerCtx) -> HashSet<String> {
287        ctx.registry
288            .get(Room::ENTITY_NAME_STATIC)
289            .map(|s| {
290                s.entries()
291                    .get()
292                    .into_iter()
293                    .map(|(id, _)| id.to_string())
294                    .collect()
295            })
296            .unwrap_or_default()
297    }
298
299    #[test]
300    fn sweep_reaps_host_and_empty_auto_rooms_and_spares_the_rest() {
301        let ctx = setup();
302
303        // Survives: the global room, always.
304        set_room(
305            &ctx,
306            "everyone",
307            RoomKind::Auto {
308                source: AutoSource::Everyone,
309            },
310        );
311        set_member(&ctx, "everyone", "sess-a");
312        // Reaped: a host room, even with a live member (anchor retired).
313        set_room(
314            &ctx,
315            "host:node1",
316            RoomKind::Auto {
317                source: AutoSource::Host {
318                    name: "node1".into(),
319                },
320            },
321        );
322        set_member(&ctx, "host:node1", "sess-a");
323        // Survives: a project room with members.
324        set_room(
325            &ctx,
326            "project:live",
327            RoomKind::Auto {
328                source: AutoSource::Project {
329                    basename: "live".into(),
330                },
331            },
332        );
333        set_member(&ctx, "project:live", "sess-a");
334        // Reaped: a project room whose sessions were all reaped (0 members).
335        set_room(
336            &ctx,
337            "project:stale",
338            RoomKind::Auto {
339                source: AutoSource::Project {
340                    basename: "stale".into(),
341                },
342            },
343        );
344        // Survives: an adhoc room even when empty — user-owned lifecycle.
345        set_room(&ctx, "design-sync", RoomKind::Adhoc);
346
347        sweep_rooms(&ctx);
348
349        let ids = room_ids(&ctx);
350        assert!(ids.contains("everyone"), "global room must survive");
351        assert!(
352            ids.contains("project:live"),
353            "populated project room must survive"
354        );
355        assert!(ids.contains("design-sync"), "empty adhoc room must survive");
356        assert!(!ids.contains("host:node1"), "host room must be reaped");
357        assert!(
358            !ids.contains("project:stale"),
359            "empty auto-room must be reaped"
360        );
361    }
362
363    fn set_message(ctx: &CellServerCtx, id: &str, sent_at: i64) {
364        let msg = Message {
365            id: MessageId(Arc::from(id)),
366            from_session_id: SessionId(Arc::from("sender")),
367            to_session_id: Some(SessionId(Arc::from("recipient"))),
368            to_room_id: None,
369            to_operator: None,
370            body: "hi".into(),
371            sent_at,
372        };
373        let ev = MEvent::from_item(&msg, MEventType::SET, &Uuid::new_v4().to_string());
374        ctx.apply_event_batch(vec![ev]).expect("apply Message SET");
375    }
376
377    fn message_ids(ctx: &CellServerCtx) -> HashSet<String> {
378        ctx.registry
379            .get(Message::ENTITY_NAME_STATIC)
380            .map(|s| {
381                s.entries()
382                    .get()
383                    .into_iter()
384                    .map(|(id, _)| id.to_string())
385                    .collect()
386            })
387            .unwrap_or_default()
388    }
389
390    #[test]
391    fn sweep_messages_prunes_old_and_keeps_recent() {
392        let ctx = setup();
393        let now = chrono::Utc::now().timestamp_millis();
394        // Older than the TTL → pruned.
395        set_message(&ctx, "old", now - MESSAGE_TTL.as_millis() as i64 - 1);
396        // Well within the TTL → kept.
397        set_message(&ctx, "recent", now - 1_000);
398
399        sweep_messages(&ctx);
400
401        let ids = message_ids(&ctx);
402        assert!(
403            !ids.contains("old"),
404            "message older than the TTL must be pruned"
405        );
406        assert!(ids.contains("recent"), "recent message must survive");
407    }
408}