diff --git a/src/modules/chathistory.rs b/src/modules/chathistory.rs index cf208e6..985ba0f 100644 --- a/src/modules/chathistory.rs +++ b/src/modules/chathistory.rs @@ -9,6 +9,7 @@ use std::collections::{HashMap, VecDeque}; use crate::channels::RANK_HALFOP; use crate::command::{CmdResult, Command}; +use crate::module::Module; use crate::server::{iso_time, now, parse_iso, Server}; use crate::Uid; @@ -91,6 +92,30 @@ pub fn commands() -> Vec> { vec![Box::new(ChatHistory), Box::new(Redact)] } +/// Garbage-collects the history ring. Each conversation's buffer is count-capped, but +/// the SET of conversation keys (every channel + DM pair that ever spoke) was never +/// pruned — a slow leak over the process lifetime. On the tick, drop any conversation +/// whose newest message is older than `chathistory_maxage` (default 7d; 0 disables). +pub struct ChatHistoryGc; +impl Module for ChatHistoryGc { + fn name(&self) -> &'static str { + "chathistory" + } + fn on_tick(&mut self, s: &mut Server) { + let maxage = s.conf_num("chathistory_maxage", 604800u64); + if maxage == 0 { + return; + } + let now = now(); + if let Some(h) = s.ext.get_mut::() { + h.0.retain(|_, buf| { + buf.back() + .is_some_and(|m| now.saturating_sub(m.ts) < maxage) + }); + } + } +} + /// CHATHISTORY — replay recent messages (draft/chathistory), leveraging BATCH. /// `CHATHISTORY <#chan|nick> /// `; a `` is `*`, `timestamp=` or `msgid=`. Channel @@ -389,3 +414,37 @@ impl Command for Redact { CmdResult::Ok } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::sync::atomic::AtomicU64; + use std::sync::{mpsc, Arc}; + + // The GC drops conversation keys whose newest message aged past chathistory_maxage + // (default 7d), so the key set can't grow forever with every distinct DM/channel. + #[test] + fn gc_drops_stale_conversation_keys() { + let (tx, _rx) = mpsc::channel(); + let mut s = Server::new(Config::default(), tx, Arc::new(AtomicU64::new(1))); + let n = now(); + let msg = |ts| HistMsg { + ts, + msgid: "m".into(), + prefix: "p!u@h".into(), + verb: "PRIVMSG", + target: "#c".into(), + text: "hi".into(), + }; + { + let h = s.ext.get_or_insert_with::(History::default); + h.0.insert("#fresh".into(), VecDeque::from([msg(n)])); + h.0.insert("#stale".into(), VecDeque::from([msg(n.saturating_sub(700_000))])); + } + ChatHistoryGc.on_tick(&mut s); + let h = s.ext.get::().unwrap(); + assert!(h.0.contains_key("#fresh"), "recent conversation kept"); + assert!(!h.0.contains_key("#stale"), "stale conversation GC'd"); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 2a07d0b..148b796 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -111,6 +111,7 @@ pub fn default_modules() -> Vec> { Box::new(autodrop::AutoDrop), Box::new(operprefix::OperPrefix), Box::new(permchannels::PermChannels::default()), + Box::new(chathistory::ChatHistoryGc), ] }