Skip to main content

max / makenotwork

4.6 KB · 144 lines History Blame Raw
1 //! Bounded retention. There is deliberately no way to express "keep forever".
2 //!
3 //! Chat in both consumers is ephemeral: messages are written down only so that a
4 //! reconnect after a deploy is lossless and so moderation can reach a message
5 //! that already scrolled past. Neither app is a chat archive.
6 //!
7 //! That intent is enforced in the type rather than in a comment. [`Retention`]
8 //! has no `Option`, no sentinel, and no public fields; the only constructor
9 //! rejects anything above the crate ceilings. An app cannot configure its way to
10 //! unbounded growth, and a future caller cannot reintroduce "forever" without
11 //! deleting this module's constructor.
12
13 use std::time::{Duration, SystemTime};
14
15 use crate::error::ChatError;
16
17 /// Longest retention any room may request.
18 pub const MAX_AGE_CEILING: Duration = Duration::from_hours(720);
19
20 /// Most messages any room may hold.
21 pub const MAX_MESSAGES_CEILING: usize = 20_000;
22
23 /// How long a room keeps messages, and how many.
24 ///
25 /// Whichever bound is hit first wins: a busy room is trimmed by count long
26 /// before `max_age` elapses, and a quiet one expires by age while well under
27 /// `max_messages`.
28 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
29 pub struct Retention {
30 max_age: Duration,
31 max_messages: usize,
32 }
33
34 impl Retention {
35 /// Both bounds are required and both are checked against the crate ceilings.
36 ///
37 /// # Errors
38 ///
39 /// [`ChatError::RetentionTooLong`] if either bound exceeds its ceiling, or
40 /// [`ChatError::RetentionTooShort`] if either is zero. A zero bound is
41 /// rejected rather than treated as "disabled", because a room that discards
42 /// every message the instant it arrives is a configuration mistake, not a
43 /// policy.
44 pub fn new(max_age: Duration, max_messages: usize) -> Result<Self, ChatError> {
45 if max_age.is_zero() || max_messages == 0 {
46 return Err(ChatError::RetentionTooShort);
47 }
48 if max_age > MAX_AGE_CEILING || max_messages > MAX_MESSAGES_CEILING {
49 return Err(ChatError::RetentionTooLong {
50 max_age,
51 max_messages,
52 });
53 }
54 Ok(Self {
55 max_age,
56 max_messages,
57 })
58 }
59
60 /// Multithreaded's default for a newly enabled forum room: 7 days, 5000
61 /// messages.
62 pub fn forum_default() -> Self {
63 Self {
64 max_age: Duration::from_hours(168),
65 max_messages: 5_000,
66 }
67 }
68
69 /// The MNW server's default for stream chat: 24 hours past the stream, 5000
70 /// messages. A creator may raise it, still bounded by the ceilings.
71 pub fn stream_default() -> Self {
72 Self {
73 max_age: Duration::from_hours(24),
74 max_messages: 5_000,
75 }
76 }
77
78 pub fn max_age(&self) -> Duration {
79 self.max_age
80 }
81
82 pub fn max_messages(&self) -> usize {
83 self.max_messages
84 }
85
86 /// When a message written at `now` should expire.
87 ///
88 /// Stored on the row at insert so the sweep is a single indexed delete rather
89 /// than a join against room policy. Shortening a room's retention is
90 /// therefore a recompute `UPDATE` over that room, which is what makes a
91 /// shortened window apply to messages already sent.
92 pub fn expires_at(&self, now: SystemTime) -> SystemTime {
93 now + self.max_age
94 }
95 }
96
97 #[cfg(test)]
98 mod tests {
99 use super::*;
100
101 #[test]
102 fn rejects_anything_above_the_ceilings() {
103 assert!(matches!(
104 Retention::new(MAX_AGE_CEILING + Duration::from_secs(1), 100),
105 Err(ChatError::RetentionTooLong { .. })
106 ));
107 assert!(matches!(
108 Retention::new(Duration::from_mins(1), MAX_MESSAGES_CEILING + 1),
109 Err(ChatError::RetentionTooLong { .. })
110 ));
111 }
112
113 #[test]
114 fn accepts_exactly_the_ceilings() {
115 assert!(Retention::new(MAX_AGE_CEILING, MAX_MESSAGES_CEILING).is_ok());
116 }
117
118 #[test]
119 fn rejects_zero_bounds() {
120 assert!(matches!(
121 Retention::new(Duration::ZERO, 100),
122 Err(ChatError::RetentionTooShort)
123 ));
124 assert!(matches!(
125 Retention::new(Duration::from_mins(1), 0),
126 Err(ChatError::RetentionTooShort)
127 ));
128 }
129
130 #[test]
131 fn defaults_are_within_the_ceilings() {
132 for r in [Retention::forum_default(), Retention::stream_default()] {
133 assert!(Retention::new(r.max_age(), r.max_messages()).is_ok());
134 }
135 }
136
137 #[test]
138 fn expiry_is_insert_time_plus_max_age() {
139 let r = Retention::forum_default();
140 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
141 assert_eq!(r.expires_at(now), now + r.max_age());
142 }
143 }
144