//! Bounded retention. There is deliberately no way to express "keep forever". //! //! Chat in both consumers is ephemeral: messages are written down only so that a //! reconnect after a deploy is lossless and so moderation can reach a message //! that already scrolled past. Neither app is a chat archive. //! //! That intent is enforced in the type rather than in a comment. [`Retention`] //! has no `Option`, no sentinel, and no public fields; the only constructor //! rejects anything above the crate ceilings. An app cannot configure its way to //! unbounded growth, and a future caller cannot reintroduce "forever" without //! deleting this module's constructor. use std::time::{Duration, SystemTime}; use crate::error::ChatError; /// Longest retention any room may request. pub const MAX_AGE_CEILING: Duration = Duration::from_hours(720); /// Most messages any room may hold. pub const MAX_MESSAGES_CEILING: usize = 20_000; /// How long a room keeps messages, and how many. /// /// Whichever bound is hit first wins: a busy room is trimmed by count long /// before `max_age` elapses, and a quiet one expires by age while well under /// `max_messages`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Retention { max_age: Duration, max_messages: usize, } impl Retention { /// Both bounds are required and both are checked against the crate ceilings. /// /// # Errors /// /// [`ChatError::RetentionTooLong`] if either bound exceeds its ceiling, or /// [`ChatError::RetentionTooShort`] if either is zero. A zero bound is /// rejected rather than treated as "disabled", because a room that discards /// every message the instant it arrives is a configuration mistake, not a /// policy. pub fn new(max_age: Duration, max_messages: usize) -> Result { if max_age.is_zero() || max_messages == 0 { return Err(ChatError::RetentionTooShort); } if max_age > MAX_AGE_CEILING || max_messages > MAX_MESSAGES_CEILING { return Err(ChatError::RetentionTooLong { max_age, max_messages, }); } Ok(Self { max_age, max_messages, }) } /// Multithreaded's default for a newly enabled forum room: 7 days, 5000 /// messages. pub fn forum_default() -> Self { Self { max_age: Duration::from_hours(168), max_messages: 5_000, } } /// The MNW server's default for stream chat: 24 hours past the stream, 5000 /// messages. A creator may raise it, still bounded by the ceilings. pub fn stream_default() -> Self { Self { max_age: Duration::from_hours(24), max_messages: 5_000, } } pub fn max_age(&self) -> Duration { self.max_age } pub fn max_messages(&self) -> usize { self.max_messages } /// When a message written at `now` should expire. /// /// Stored on the row at insert so the sweep is a single indexed delete rather /// than a join against room policy. Shortening a room's retention is /// therefore a recompute `UPDATE` over that room, which is what makes a /// shortened window apply to messages already sent. pub fn expires_at(&self, now: SystemTime) -> SystemTime { now + self.max_age } } #[cfg(test)] mod tests { use super::*; #[test] fn rejects_anything_above_the_ceilings() { assert!(matches!( Retention::new(MAX_AGE_CEILING + Duration::from_secs(1), 100), Err(ChatError::RetentionTooLong { .. }) )); assert!(matches!( Retention::new(Duration::from_mins(1), MAX_MESSAGES_CEILING + 1), Err(ChatError::RetentionTooLong { .. }) )); } #[test] fn accepts_exactly_the_ceilings() { assert!(Retention::new(MAX_AGE_CEILING, MAX_MESSAGES_CEILING).is_ok()); } #[test] fn rejects_zero_bounds() { assert!(matches!( Retention::new(Duration::ZERO, 100), Err(ChatError::RetentionTooShort) )); assert!(matches!( Retention::new(Duration::from_mins(1), 0), Err(ChatError::RetentionTooShort) )); } #[test] fn defaults_are_within_the_ceilings() { for r in [Retention::forum_default(), Retention::stream_default()] { assert!(Retention::new(r.max_age(), r.max_messages()).is_ok()); } } #[test] fn expiry_is_insert_time_plus_max_age() { let r = Retention::forum_default(); let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); assert_eq!(r.expires_at(now), now + r.max_age()); } }