Skip to main content

mail4agent_server/
retention.rs

1//! Delivery-window retention (design: docs/mail4agent/messenger-model.md,
2//! "Server is a router"). The server is not an archive: a message event is
3//! only needed until every live device of every joined member has fetched it.
4//!
5//! * [`record_device_ack`] — a `/sync?since=s<N>` proves the device holds
6//!   every event up to stream id `N` (additive table `device_acks`).
7//! * [`retention_report`] — how many message events a purge would drop.
8//! * [`purge_delivered_events`] — drops them. NOT scheduled anywhere: the
9//!   owner decides when to switch it on (it changes what `/rooms/{id}/messages`
10//!   can return, see the design doc).
11//!
12//! Only non-state events of CLOSED rooms are ever touched: plaintext public
13//! channels live in `pub_events` ([`crate::public_channels`]) and are excluded
14//! here by construction and by predicate.
15//! Only non-state events are ever touched. State, keys, membership and
16//! to-device queues are untouched here.
17
18use rusqlite::{params, Connection};
19
20/// Most events one purge pass removes (keeps the DB lock short).
21pub const PURGE_BATCH: usize = 1000;
22
23/// `device_acks` DDL. Called from `init_messenger_db`.
24pub 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
38/// Records that `device_id` has received everything up to `stream_id`.
39/// Monotonic: an older token never moves the ack back.
40pub 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/// Retention knobs.
51#[derive(Debug, Clone, Copy)]
52pub struct RetentionPolicy {
53    /// Hard TTL: any message event older than this goes, acked or not.
54    pub ttl_ms: i64,
55    /// Grace after full ack before deletion (lets slow readers/retries land).
56    pub ack_grace_ms: i64,
57    /// Always keep this many newest message events per room.
58    pub keep_last: i64,
59    /// A device that has not acked within this window no longer holds
60    /// deletion back (it falls under the hard TTL).
61    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/// What a purge would do right now.
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct RetentionReport {
73    /// Message events stored.
74    pub message_events: i64,
75    /// Of those, deletable under the policy.
76    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        // Lowest stream id acked by every live device of every joined member;
87        // None when some live device never acked (blocks ack-based deletion).
88        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(_)) => {} // stale device: ignored
105                _ => min_ack = None,      // never acked: hold back
106            }
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
127/// Counts message events and how many a purge would delete.
128pub 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
133/// Deletes eligible message events (and rows pointing at them). Returns how
134/// many events were removed.
135pub 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        // Federated closed rooms keep a skeleton (event id, sender, signature, hashes) so
142        // the room's history stays verifiable for remote servers; only the ciphertext goes.
143        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            // DAG rooms (feature `f3-hash-ids`) keep a skeleton whether federated or not, once no
146            // federation delivery of the event is still queued; otherwise it waits for a later pass.
147            #[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        // Legacy table may be absent (dropped when empty).
164        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    // Idempotency records only matter inside the ring window.
170    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    // Media blobs are ciphertext attachments: same pump rule, same TTL.
177    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}