use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::{Duration, Instant},
};
use chrono::Utc;
use hyphae::Gettable;
use marshal_entities::{AutoSource, Room, RoomKind, RoomMember, Session};
use myko::{core::item::Eventable, server::CellServerCtx, utils::downcast_item};
pub const STALE_AFTER: Duration = Duration::from_secs(10);
pub const HOOK_BACKSTOP: Duration = Duration::from_secs(60 * 60);
pub const TICK_INTERVAL: Duration = Duration::from_secs(3);
pub async fn run_sweeper(ctx: CellServerCtx) {
let mut disconnected_since: HashMap<Arc<str>, Instant> = HashMap::new();
let mut interval = tokio::time::interval(TICK_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
sweep_once(&ctx, &mut disconnected_since);
sweep_rooms(&ctx);
}
}
fn sweep_rooms(ctx: &CellServerCtx) {
let Some(room_store) = ctx.registry.get(Room::ENTITY_NAME_STATIC) else {
return;
};
let Some(member_store) = ctx.registry.get(RoomMember::ENTITY_NAME_STATIC) else {
return;
};
let mut member_counts: HashMap<Arc<str>, usize> = HashMap::new();
for (_id, item) in member_store.entries().get() {
if let Some(m) = downcast_item::<RoomMember>(&item) {
*member_counts.entry(m.room_id.0.clone()).or_default() += 1;
}
}
let mut to_delete: Vec<Arc<str>> = Vec::new();
for (id, item) in room_store.entries().get() {
let Some(room) = downcast_item::<Room>(&item) else {
continue;
};
let RoomKind::Auto { source } = &room.kind else {
continue;
};
if matches!(source, AutoSource::Everyone) {
continue;
}
let is_host = matches!(source, AutoSource::Host { .. });
let empty = member_counts.get(&room.id.0).copied().unwrap_or(0) == 0;
if is_host || empty {
to_delete.push(id);
}
}
for id in to_delete {
log::info!("[cleanup] DELing stale auto-room {}", id);
if let Err(e) = ctx.del_by_id(Room::ENTITY_NAME_STATIC, &id) {
log::warn!("[cleanup] del room {} failed: {}", id, e);
}
}
}
fn sweep_once(ctx: &CellServerCtx, disconnected_since: &mut HashMap<Arc<str>, Instant>) {
let Some(session_store) = ctx.registry.get(Session::ENTITY_NAME_STATIC) else {
return;
};
let Some(client_store) = ctx.registry.get("Client") else {
return;
};
let live_client_ids: HashSet<Arc<str>> = client_store
.entries()
.get()
.into_iter()
.map(|(id, _)| id)
.collect();
let now = Instant::now();
let now_ms = Utc::now().timestamp_millis();
let backstop_ms = HOOK_BACKSTOP.as_millis() as i64;
let mut to_delete: Vec<Arc<str>> = Vec::new();
let mut still_disconnected: HashSet<Arc<str>> = HashSet::new();
for (id, item) in session_store.entries().get() {
let Some(session) = downcast_item::<Session>(&item) else {
continue;
};
match session.client_id.as_ref() {
None => {
let last = session.last_activity_at.unwrap_or(session.connected_at);
if now_ms.saturating_sub(last) >= backstop_ms {
to_delete.push(id);
}
}
Some(cid) if live_client_ids.contains(&cid.0) => {
disconnected_since.remove(&id);
}
Some(_) => {
still_disconnected.insert(id.clone());
let first_seen = *disconnected_since.entry(id.clone()).or_insert(now);
if now.duration_since(first_seen) >= STALE_AFTER {
to_delete.push(id);
}
}
}
}
disconnected_since.retain(|id, _| still_disconnected.contains(id));
for id in to_delete {
log::info!("[cleanup] DELing abandoned session {}", id);
if let Err(e) = ctx.del_by_id(Session::ENTITY_NAME_STATIC, &id) {
log::warn!("[cleanup] del session {} failed: {}", id, e);
continue;
}
disconnected_since.remove(&id);
}
}
#[cfg(test)]
mod tests {
use super::*;
use marshal_entities::{RoomId, RoomMemberId, SessionId};
use myko::{
server::Persister,
wire::{MEvent, MEventType},
};
use myko_server::{BlackholePersister, CellServer};
use std::collections::HashSet;
use uuid::Uuid;
fn setup() -> CellServerCtx {
marshal_entities::link();
crate::link();
let blackhole: Arc<dyn Persister> = Arc::new(BlackholePersister);
let server = CellServer::builder()
.with_default_persister(blackhole)
.build();
let ctx = server.ctx();
Box::leak(Box::new(server));
ctx
}
fn set_room(ctx: &CellServerCtx, id: &str, kind: RoomKind) {
let room = Room {
id: RoomId(Arc::from(id)),
name: id.to_string(),
description: None,
kind,
created_at: 0,
};
let ev = MEvent::from_item(&room, MEventType::SET, &Uuid::new_v4().to_string());
ctx.apply_event_batch(vec![ev]).expect("apply Room SET");
}
fn set_member(ctx: &CellServerCtx, room_id: &str, session_id: &str) {
let member = RoomMember {
id: RoomMemberId(Arc::from(RoomMember::make_id(room_id, session_id).as_str())),
room_id: RoomId(Arc::from(room_id)),
session_id: SessionId(Arc::from(session_id)),
joined_at: 0,
};
let ev = MEvent::from_item(&member, MEventType::SET, &Uuid::new_v4().to_string());
ctx.apply_event_batch(vec![ev])
.expect("apply RoomMember SET");
}
fn room_ids(ctx: &CellServerCtx) -> HashSet<String> {
ctx.registry
.get(Room::ENTITY_NAME_STATIC)
.map(|s| {
s.entries()
.get()
.into_iter()
.map(|(id, _)| id.to_string())
.collect()
})
.unwrap_or_default()
}
#[test]
fn sweep_reaps_host_and_empty_auto_rooms_and_spares_the_rest() {
let ctx = setup();
set_room(
&ctx,
"everyone",
RoomKind::Auto {
source: AutoSource::Everyone,
},
);
set_member(&ctx, "everyone", "sess-a");
set_room(
&ctx,
"host:node1",
RoomKind::Auto {
source: AutoSource::Host {
name: "node1".into(),
},
},
);
set_member(&ctx, "host:node1", "sess-a");
set_room(
&ctx,
"project:live",
RoomKind::Auto {
source: AutoSource::Project {
basename: "live".into(),
},
},
);
set_member(&ctx, "project:live", "sess-a");
set_room(
&ctx,
"project:stale",
RoomKind::Auto {
source: AutoSource::Project {
basename: "stale".into(),
},
},
);
set_room(&ctx, "design-sync", RoomKind::Adhoc);
sweep_rooms(&ctx);
let ids = room_ids(&ctx);
assert!(ids.contains("everyone"), "global room must survive");
assert!(
ids.contains("project:live"),
"populated project room must survive"
);
assert!(ids.contains("design-sync"), "empty adhoc room must survive");
assert!(!ids.contains("host:node1"), "host room must be reaped");
assert!(
!ids.contains("project:stale"),
"empty auto-room must be reaped"
);
}
}