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 #[cfg(feature = "f3-hash-ids")]
148 if crate::f3::is_f3_room(&tx, &room) {
149 if crate::f3::skeletonize(&tx, id).map_err(|_| rusqlite::Error::InvalidQuery)? {
150 removed += 1;
151 }
152 continue;
153 }
154 if crate::fed_rooms::room_is_federated(&tx, &room)? {
155 crate::fed_rooms::skeletonize_event(&tx, id)?;
156 removed += 1;
157 continue;
158 }
159 }
160 tx.execute("DELETE FROM relations WHERE event_id = ?1 OR target_id = ?1", params![id])?;
161 tx.execute("DELETE FROM receipts WHERE event_id = ?1", params![id])?;
162 tx.execute("DELETE FROM txn_dedup WHERE event_id = ?1", params![id])?;
163 let _ = tx.execute("DELETE FROM legacy_dm_message_map WHERE event_id = ?1", params![id]);
165 tx.execute("UPDATE events SET redacts = NULL WHERE redacts = ?1", params![id])?;
166 tx.execute("UPDATE events SET redacted_by = NULL WHERE redacted_by = ?1", params![id])?;
167 removed += tx.execute("DELETE FROM events WHERE event_id = ?1 AND state_key IS NULL", params![id])?;
168 }
169 let cutoff = chrono::DateTime::from_timestamp_millis(now_ms - policy.ttl_ms)
171 .map(|d| d.to_rfc3339())
172 .unwrap_or_default();
173 if !cutoff.is_empty() {
174 tx.execute("DELETE FROM txn_dedup WHERE created_at < ?1", params![cutoff])?;
175 }
176 tx.execute("DELETE FROM media WHERE created_ms < ?1", params![now_ms - policy.ttl_ms])?;
178 tx.commit()?;
179 Ok(removed)
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 fn db() -> Connection {
187 let conn = Connection::open_in_memory().unwrap();
188 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
189 crate::store::create_matrix_schema(&conn).unwrap();
190 crate::keys::create_matrix_keys_schema(&conn).unwrap();
191 create_retention_schema(&conn).unwrap();
192 conn.execute("INSERT INTO rooms (id, kind, creator_user_id, created_at) VALUES ('!r:x', 'group', 1, 't')", []).unwrap();
193 conn
194 }
195
196 fn add_event(conn: &Connection, stream: i64, ts: i64) {
197 conn.execute(
198 "INSERT INTO events (stream_id, event_id, room_id, sender_user_id, event_type, state_key, content, origin_server_ts)
199 VALUES (?1, ?2, '!r:x', 1, 'm.room.encrypted', NULL, '{}', ?3)",
200 params![stream, format!("$e{stream}"), ts],
201 )
202 .unwrap();
203 }
204
205 #[test]
206 fn ack_is_monotonic() {
207 let conn = db();
208 record_device_ack(&conn, 1, "D", 10, 1).unwrap();
209 record_device_ack(&conn, 1, "D", 5, 2).unwrap();
210 let s: i64 = conn.query_row("SELECT stream_id FROM device_acks", [], |r| r.get(0)).unwrap();
211 assert_eq!(s, 10);
212 }
213
214 #[test]
215 fn ttl_purges_unacked_but_keeps_last_n() {
216 let mut conn = db();
217 for i in 1..=30 {
218 add_event(&conn, i, 1_000);
219 }
220 let policy = RetentionPolicy { ttl_ms: 5_000, ack_grace_ms: 0, keep_last: 20, stale_device_ms: 1 };
221 let now = 100_000;
222 assert_eq!(retention_report(&conn, now, &policy).unwrap(), RetentionReport { message_events: 30, eligible: 10 });
223 assert_eq!(purge_delivered_events(&mut conn, now, &policy).unwrap(), 10);
224 let left: i64 = conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0)).unwrap();
225 assert_eq!(left, 20);
226 }
227
228 #[test]
229 fn deleted_only_after_every_live_device_acked() {
230 let mut conn = db();
231 for u in [1, 2] {
232 conn.execute("INSERT INTO room_members (room_id, user_id, membership, updated_at) VALUES ('!r:x', ?1, 'join', 't')", params![u]).unwrap();
233 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();
234 }
235 for i in 1..=3 {
236 add_event(&conn, i, 1_000);
237 }
238 conn.execute("INSERT INTO txn_dedup (user_id, device_id, txn_id, event_id, created_at) VALUES (1, 'D', 't1', '$e1', 't')", []).unwrap();
239 let policy = RetentionPolicy { ttl_ms: 10_000_000, ack_grace_ms: 0, keep_last: 0, stale_device_ms: 10_000_000 };
240 let now = 100_000;
241 assert_eq!(purge_delivered_events(&mut conn, now, &policy).unwrap(), 0, "nobody acked");
242 record_device_ack(&conn, 1, "D", 3, now).unwrap();
243 assert_eq!(purge_delivered_events(&mut conn, now, &policy).unwrap(), 0, "one device still behind");
244 record_device_ack(&conn, 2, "D", 2, now).unwrap();
245 assert_eq!(purge_delivered_events(&mut conn, now, &policy).unwrap(), 2, "events 1,2 acked by all");
246 let left: i64 = conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0)).unwrap();
247 assert_eq!(left, 1);
248 }
249
250 #[test]
251 fn young_events_survive_without_acks() {
252 let conn = db();
253 for i in 1..=30 {
254 add_event(&conn, i, 99_000);
255 }
256 let policy = RetentionPolicy { ttl_ms: 50_000, ack_grace_ms: 0, keep_last: 5, stale_device_ms: 1 };
257 assert_eq!(retention_report(&conn, 100_000, &policy).unwrap().eligible, 0);
258 }
259}