1use rusqlite::{params, Connection};
19
20pub const PURGE_BATCH: usize = 1000;
22
23pub fn create_retention_schema(conn: &Connection) -> rusqlite::Result<()> {
25 conn.execute_batch(
26 r#"
27 CREATE TABLE IF NOT EXISTS device_acks (
28 user_id INTEGER NOT NULL,
29 device_id TEXT NOT NULL,
30 stream_id INTEGER NOT NULL,
31 updated_ms INTEGER NOT NULL,
32 PRIMARY KEY (user_id, device_id)
33 );
34 "#,
35 )
36}
37
38pub fn record_device_ack(conn: &Connection, user_id: i64, device_id: &str, stream_id: i64, now_ms: i64) -> rusqlite::Result<()> {
41 conn.execute(
42 "INSERT INTO device_acks (user_id, device_id, stream_id, updated_ms) VALUES (?1, ?2, ?3, ?4)
43 ON CONFLICT(user_id, device_id) DO UPDATE SET
44 stream_id = MAX(stream_id, excluded.stream_id), updated_ms = excluded.updated_ms",
45 params![user_id, device_id, stream_id, now_ms],
46 )?;
47 Ok(())
48}
49
50#[derive(Debug, Clone, Copy)]
52pub struct RetentionPolicy {
53 pub ttl_ms: i64,
55 pub ack_grace_ms: i64,
57 pub keep_last: i64,
59 pub stale_device_ms: i64,
62}
63
64impl Default for RetentionPolicy {
65 fn default() -> Self {
66 Self { ttl_ms: 14 * 86_400_000, ack_grace_ms: 300_000, keep_last: 0, stale_device_ms: 30 * 86_400_000 }
67 }
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct RetentionReport {
73 pub message_events: i64,
75 pub eligible: i64,
77}
78
79fn eligible_ids(conn: &Connection, now_ms: i64, policy: &RetentionPolicy) -> rusqlite::Result<Vec<String>> {
80 let mut out = Vec::new();
81 let rooms: Vec<String> = conn
82 .prepare("SELECT DISTINCT room_id FROM events WHERE state_key IS NULL AND room_id NOT IN (SELECT id FROM rooms WHERE kind = 'channel' AND is_encrypted = 0)")?
83 .query_map([], |r| r.get(0))?
84 .collect::<Result<_, _>>()?;
85 for room in rooms {
86 let mut stmt = conn.prepare(
89 "SELECT d.user_id, d.device_id, a.stream_id, a.updated_ms
90 FROM room_members m JOIN devices d ON d.user_id = m.user_id
91 LEFT JOIN device_acks a ON a.user_id = d.user_id AND a.device_id = d.device_id
92 WHERE m.room_id = ?1 AND m.membership = 'join'",
93 )?;
94 let mut min_ack: Option<i64> = Some(i64::MAX);
95 let rows = stmt.query_map(params![room], |r| {
96 Ok((r.get::<_, Option<i64>>(2)?, r.get::<_, Option<i64>>(3)?))
97 })?;
98 for row in rows {
99 let (ack, updated) = row?;
100 match (ack, updated) {
101 (Some(ack), Some(updated)) if now_ms - updated <= policy.stale_device_ms => {
102 min_ack = min_ack.map(|m| m.min(ack));
103 }
104 (Some(_), Some(_)) => {} _ => min_ack = None, }
107 }
108 let ack_bound = min_ack.filter(|m| *m != i64::MAX).unwrap_or(-1);
109 let mut ev = conn.prepare(
110 "SELECT event_id FROM events WHERE room_id = ?1 AND state_key IS NULL
111 AND stream_id NOT IN (SELECT stream_id FROM events WHERE room_id = ?1 AND state_key IS NULL
112 ORDER BY stream_id DESC LIMIT ?2)
113 AND event_id NOT IN (SELECT event_id FROM fed_skeleton)
114 AND ((stream_id <= ?3 AND origin_server_ts <= ?4) OR origin_server_ts <= ?5)",
115 )?;
116 let ids = ev.query_map(
117 params![room, policy.keep_last, ack_bound, now_ms - policy.ack_grace_ms, now_ms - policy.ttl_ms],
118 |r| r.get::<_, String>(0),
119 )?;
120 for id in ids {
121 out.push(id?);
122 }
123 }
124 Ok(out)
125}
126
127pub fn retention_report(conn: &Connection, now_ms: i64, policy: &RetentionPolicy) -> rusqlite::Result<RetentionReport> {
129 let message_events = conn.query_row("SELECT COUNT(*) FROM events WHERE state_key IS NULL", [], |r| r.get(0))?;
130 Ok(RetentionReport { message_events, eligible: eligible_ids(conn, now_ms, policy)?.len() as i64 })
131}
132
133pub fn purge_delivered_events(conn: &mut Connection, now_ms: i64, policy: &RetentionPolicy) -> rusqlite::Result<usize> {
136 let mut ids = eligible_ids(conn, now_ms, policy)?;
137 ids.truncate(PURGE_BATCH);
138 let tx = conn.transaction()?;
139 let mut removed = 0;
140 for id in &ids {
141 let room: Option<String> = tx.query_row("SELECT room_id FROM events WHERE event_id = ?1", params![id], |r| r.get(0)).ok();
144 if let Some(room) = room {
145 if crate::fed_rooms::room_is_federated(&tx, &room)? {
146 crate::fed_rooms::skeletonize_event(&tx, id)?;
147 removed += 1;
148 continue;
149 }
150 }
151 tx.execute("DELETE FROM relations WHERE event_id = ?1 OR target_id = ?1", params![id])?;
152 tx.execute("DELETE FROM receipts WHERE event_id = ?1", params![id])?;
153 tx.execute("DELETE FROM txn_dedup WHERE event_id = ?1", params![id])?;
154 let _ = tx.execute("DELETE FROM legacy_dm_message_map WHERE event_id = ?1", params![id]);
156 tx.execute("UPDATE events SET redacts = NULL WHERE redacts = ?1", params![id])?;
157 tx.execute("UPDATE events SET redacted_by = NULL WHERE redacted_by = ?1", params![id])?;
158 removed += tx.execute("DELETE FROM events WHERE event_id = ?1 AND state_key IS NULL", params![id])?;
159 }
160 let cutoff = chrono::DateTime::from_timestamp_millis(now_ms - policy.ttl_ms)
162 .map(|d| d.to_rfc3339())
163 .unwrap_or_default();
164 if !cutoff.is_empty() {
165 tx.execute("DELETE FROM txn_dedup WHERE created_at < ?1", params![cutoff])?;
166 }
167 tx.execute("DELETE FROM media WHERE created_ms < ?1", params![now_ms - policy.ttl_ms])?;
169 tx.commit()?;
170 Ok(removed)
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn db() -> Connection {
178 let conn = Connection::open_in_memory().unwrap();
179 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
180 crate::store::create_matrix_schema(&conn).unwrap();
181 crate::keys::create_matrix_keys_schema(&conn).unwrap();
182 create_retention_schema(&conn).unwrap();
183 conn.execute("INSERT INTO rooms (id, kind, creator_user_id, created_at) VALUES ('!r:x', 'group', 1, 't')", []).unwrap();
184 conn
185 }
186
187 fn add_event(conn: &Connection, stream: i64, ts: i64) {
188 conn.execute(
189 "INSERT INTO events (stream_id, event_id, room_id, sender_user_id, event_type, state_key, content, origin_server_ts)
190 VALUES (?1, ?2, '!r:x', 1, 'm.room.encrypted', NULL, '{}', ?3)",
191 params![stream, format!("$e{stream}"), ts],
192 )
193 .unwrap();
194 }
195
196 #[test]
197 fn ack_is_monotonic() {
198 let conn = db();
199 record_device_ack(&conn, 1, "D", 10, 1).unwrap();
200 record_device_ack(&conn, 1, "D", 5, 2).unwrap();
201 let s: i64 = conn.query_row("SELECT stream_id FROM device_acks", [], |r| r.get(0)).unwrap();
202 assert_eq!(s, 10);
203 }
204
205 #[test]
206 fn ttl_purges_unacked_but_keeps_last_n() {
207 let mut conn = db();
208 for i in 1..=30 {
209 add_event(&conn, i, 1_000);
210 }
211 let policy = RetentionPolicy { ttl_ms: 5_000, ack_grace_ms: 0, keep_last: 20, stale_device_ms: 1 };
212 let now = 100_000;
213 assert_eq!(retention_report(&conn, now, &policy).unwrap(), RetentionReport { message_events: 30, eligible: 10 });
214 assert_eq!(purge_delivered_events(&mut conn, now, &policy).unwrap(), 10);
215 let left: i64 = conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0)).unwrap();
216 assert_eq!(left, 20);
217 }
218
219 #[test]
220 fn deleted_only_after_every_live_device_acked() {
221 let mut conn = db();
222 for u in [1, 2] {
223 conn.execute("INSERT INTO room_members (room_id, user_id, membership, updated_at) VALUES ('!r:x', ?1, 'join', 't')", params![u]).unwrap();
224 conn.execute("INSERT INTO devices (user_id, device_id, credential_kind, credential_ref, created_at, last_seen_at) VALUES (?1, 'D', 'Bearer', ?2, 't', 't')", params![u, format!("c{u}")]).unwrap();
225 }
226 for i in 1..=3 {
227 add_event(&conn, i, 1_000);
228 }
229 conn.execute("INSERT INTO txn_dedup (user_id, device_id, txn_id, event_id, created_at) VALUES (1, 'D', 't1', '$e1', 't')", []).unwrap();
230 let policy = RetentionPolicy { ttl_ms: 10_000_000, ack_grace_ms: 0, keep_last: 0, stale_device_ms: 10_000_000 };
231 let now = 100_000;
232 assert_eq!(purge_delivered_events(&mut conn, now, &policy).unwrap(), 0, "nobody acked");
233 record_device_ack(&conn, 1, "D", 3, now).unwrap();
234 assert_eq!(purge_delivered_events(&mut conn, now, &policy).unwrap(), 0, "one device still behind");
235 record_device_ack(&conn, 2, "D", 2, now).unwrap();
236 assert_eq!(purge_delivered_events(&mut conn, now, &policy).unwrap(), 2, "events 1,2 acked by all");
237 let left: i64 = conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0)).unwrap();
238 assert_eq!(left, 1);
239 }
240
241 #[test]
242 fn young_events_survive_without_acks() {
243 let conn = db();
244 for i in 1..=30 {
245 add_event(&conn, i, 99_000);
246 }
247 let policy = RetentionPolicy { ttl_ms: 50_000, ack_grace_ms: 0, keep_last: 5, stale_device_ms: 1 };
248 assert_eq!(retention_report(&conn, 100_000, &policy).unwrap().eligible, 0);
249 }
250}