Skip to main content

mcpmem_core/
relation_integrity.rs

1//! Offline, operator-gated checks and repair for legacy physical relation rows.
2//!
3//! This module deliberately opens the database directly instead of through
4//! `GraphHandle`: auditing must not bootstrap or mutate a legacy database, and
5//! repair is never part of normal server startup.
6
7use std::fs::OpenOptions;
8use std::path::Path;
9use std::time::Duration;
10
11use rusqlite::{Connection, OpenFlags, OptionalExtension, backup::Backup};
12use serde::Serialize;
13
14use crate::errors::{MCSError, Result};
15use crate::events::sql_error;
16use crate::graph::TxGuard;
17
18/// Aggregate relation-integrity state. It contains counts only, never names or
19/// observation bodies, so it can safely be emitted by the maintenance CLI.
20#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
21pub struct AuditReport {
22    pub duplicate_groups: i64,
23    pub duplicate_rows: i64,
24    pub dangling_relation_rows: i64,
25    pub drift: DriftReport,
26}
27
28/// Denormalized counters whose stored values differ from physical graph rows.
29#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
30pub struct DriftReport {
31    pub graph_stat_relations: CounterDrift,
32    pub type_dict_count: i64,
33    pub entity_out_deg: i64,
34    pub entity_in_deg: i64,
35}
36
37/// A stored counter is optional because a damaged legacy database may be
38/// missing the `relations` statistic altogether.
39#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
40pub struct CounterDrift {
41    pub stored: Option<i64>,
42    pub actual: i64,
43}
44
45/// The two audits that bracket a successful repair.
46#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
47pub struct RepairReport {
48    pub before: AuditReport,
49    pub after: AuditReport,
50}
51
52impl AuditReport {
53    /// Whether the observable relation rows and their derived counters agree.
54    #[must_use]
55    pub fn is_clean(&self) -> bool {
56        self.duplicate_groups == 0
57            && self.duplicate_rows == 0
58            && self.dangling_relation_rows == 0
59            && self.drift.graph_stat_relations.stored
60                == Some(self.drift.graph_stat_relations.actual)
61            && self.drift.type_dict_count == 0
62            && self.drift.entity_out_deg == 0
63            && self.drift.entity_in_deg == 0
64    }
65}
66
67/// Audit an existing database in one deferred read transaction.
68///
69/// The connection has `query_only` enabled and is opened without `CREATE`, so
70/// this command cannot create or modify a user database.
71pub fn audit(database: &Path) -> Result<AuditReport> {
72    let conn = open_existing(database)?;
73    conn.execute_batch("PRAGMA query_only = ON;")
74        .map_err(sql_error)?;
75    read_transaction(&conn, audit_current)
76}
77
78/// Back up, preflight, and repair a legacy relation table.
79///
80/// A backup destination is reserved atomically before SQLite's online backup
81/// API opens it. Any failed preflight happens before a source-database write;
82/// once the write lock is held, `TxGuard` rolls back every later failure.
83pub fn repair(database: &Path, backup: Option<&Path>, confirmed: bool) -> Result<RepairReport> {
84    if !confirmed {
85        return Err(MCSError::InvalidParams(
86            "relation repair requires --confirm".into(),
87        ));
88    }
89    let backup = backup.ok_or_else(|| {
90        MCSError::InvalidParams("relation repair requires a --backup path".into())
91    })?;
92    let source = open_existing(database)?;
93    reserve_backup(backup)?;
94    backup_and_verify(&source, backup)?;
95
96    let tx = TxGuard::begin(&source)?;
97    validate_source(&source)?;
98    let before = audit_current(&source)?;
99    if before.dangling_relation_rows != 0 {
100        return Err(MCSError::MemoryError(
101            "relation repair refused: dangling relation rows require explicit remediation".into(),
102        ));
103    }
104
105    source
106        .execute_batch(
107            "DELETE FROM relation
108             WHERE rowid IN (
109               SELECT rowid FROM (
110                 SELECT rowid,
111                        ROW_NUMBER() OVER (
112                          PARTITION BY from_id, to_id, type_id
113                          ORDER BY created_us ASC, rowid ASC
114                        ) AS ordinal
115                 FROM relation
116               ) WHERE ordinal > 1
117             );",
118        )
119        .map_err(sql_error)?;
120    rebuild_relation_caches(&source)?;
121    // This is intentionally last: putting the index in bootstrap for an
122    // existing legacy relation table would prevent this operator repair.
123    ensure_relation_unique_index(&source)?;
124
125    let after = audit_current(&source)?;
126    if !after.is_clean() {
127        return Err(MCSError::MemoryError(
128            "relation repair did not produce a clean audit".into(),
129        ));
130    }
131    tx.commit()?;
132    Ok(RepairReport { before, after })
133}
134
135fn open_existing(path: &Path) -> Result<Connection> {
136    Connection::open_with_flags(
137        path,
138        OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX,
139    )
140    .map_err(sql_error)
141}
142
143fn reserve_backup(path: &Path) -> Result<()> {
144    OpenOptions::new()
145        .write(true)
146        .create_new(true)
147        .open(path)
148        .map(drop)
149        .map_err(MCSError::IoError)
150}
151
152fn backup_and_verify(source: &Connection, destination_path: &Path) -> Result<()> {
153    let mut destination = Connection::open_with_flags(
154        destination_path,
155        OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX,
156    )
157    .map_err(sql_error)?;
158    {
159        let backup = Backup::new(source, &mut destination).map_err(sql_error)?;
160        backup
161            .run_to_completion(128, Duration::from_millis(10), None)
162            .map_err(sql_error)?;
163    }
164    drop(destination);
165
166    let verified = open_existing(destination_path)?;
167    require_integrity_check(&verified, "backup")
168}
169
170fn validate_source(conn: &Connection) -> Result<()> {
171    require_integrity_check(conn, "source")?;
172    let mut statement = conn
173        .prepare("PRAGMA foreign_key_check")
174        .map_err(sql_error)?;
175    let mut rows = statement.query([]).map_err(sql_error)?;
176    if rows.next().map_err(sql_error)?.is_some() {
177        return Err(MCSError::MemoryError(
178            "relation repair refused: foreign_key_check returned violations".into(),
179        ));
180    }
181    Ok(())
182}
183
184fn require_integrity_check(conn: &Connection, label: &str) -> Result<()> {
185    let mut statement = conn.prepare("PRAGMA integrity_check").map_err(sql_error)?;
186    let results = statement
187        .query_map([], |row| row.get::<_, String>(0))
188        .map_err(sql_error)?
189        .collect::<rusqlite::Result<Vec<_>>>()
190        .map_err(sql_error)?;
191    if results.as_slice() != ["ok"] {
192        return Err(MCSError::MemoryError(format!(
193            "{label} database failed PRAGMA integrity_check"
194        )));
195    }
196    Ok(())
197}
198
199fn read_transaction<T>(
200    conn: &Connection,
201    read: impl FnOnce(&Connection) -> Result<T>,
202) -> Result<T> {
203    conn.execute_batch("BEGIN DEFERRED").map_err(sql_error)?;
204    match read(conn) {
205        Ok(value) => {
206            conn.execute_batch("COMMIT").map_err(sql_error)?;
207            Ok(value)
208        }
209        Err(error) => {
210            let _ = conn.execute_batch("ROLLBACK");
211            Err(error)
212        }
213    }
214}
215
216fn audit_current(conn: &Connection) -> Result<AuditReport> {
217    let mut duplicate_statement = conn
218        .prepare(
219            "SELECT COUNT(*)
220             FROM relation
221             GROUP BY from_id, to_id, type_id
222             HAVING COUNT(*) > 1
223             ORDER BY from_id, to_id, type_id",
224        )
225        .map_err(sql_error)?;
226    let duplicate_sizes = duplicate_statement
227        .query_map([], |row| row.get::<_, i64>(0))
228        .map_err(sql_error)?
229        .collect::<rusqlite::Result<Vec<_>>>()
230        .map_err(sql_error)?;
231    let duplicate_groups = duplicate_sizes.len() as i64;
232    let duplicate_rows = duplicate_sizes.into_iter().map(|count| count - 1).sum();
233    let dangling_relation_rows = conn
234        .query_row(
235            "SELECT COUNT(*)
236             FROM relation r
237             LEFT JOIN entity source ON source.id = r.from_id
238             LEFT JOIN entity destination ON destination.id = r.to_id
239             LEFT JOIN type_dict relation_type
240               ON relation_type.id = r.type_id AND relation_type.kind = 1
241             WHERE source.id IS NULL
242                OR destination.id IS NULL
243                OR relation_type.id IS NULL",
244            [],
245            |row| row.get(0),
246        )
247        .map_err(sql_error)?;
248    let relation_count = conn
249        .query_row("SELECT COUNT(*) FROM relation", [], |row| row.get(0))
250        .map_err(sql_error)?;
251    let stored_relation_count = conn
252        .query_row(
253            "SELECT value FROM graph_stat WHERE key = 'relations'",
254            [],
255            |row| row.get(0),
256        )
257        .optional()
258        .map_err(sql_error)?;
259    let type_dict_count = conn
260        .query_row(
261            "SELECT COUNT(*)
262             FROM type_dict dictionary
263             WHERE dictionary.count != CASE dictionary.kind
264               WHEN 0 THEN (
265                 SELECT COUNT(*) FROM entity
266                 WHERE entity.type_id = dictionary.id AND entity.flags = 0
267               )
268               WHEN 1 THEN (
269                 SELECT COUNT(*) FROM relation
270                 WHERE relation.type_id = dictionary.id
271               )
272               ELSE 0
273             END",
274            [],
275            |row| row.get(0),
276        )
277        .map_err(sql_error)?;
278    let entity_out_deg = conn
279        .query_row(
280            "SELECT COUNT(*) FROM entity source
281             WHERE source.out_deg != (
282               SELECT COUNT(*) FROM relation WHERE from_id = source.id
283             )",
284            [],
285            |row| row.get(0),
286        )
287        .map_err(sql_error)?;
288    let entity_in_deg = conn
289        .query_row(
290            "SELECT COUNT(*) FROM entity destination
291             WHERE destination.in_deg != (
292               SELECT COUNT(*) FROM relation WHERE to_id = destination.id
293             )",
294            [],
295            |row| row.get(0),
296        )
297        .map_err(sql_error)?;
298
299    Ok(AuditReport {
300        duplicate_groups,
301        duplicate_rows,
302        dangling_relation_rows,
303        drift: DriftReport {
304            graph_stat_relations: CounterDrift {
305                stored: stored_relation_count,
306                actual: relation_count,
307            },
308            type_dict_count,
309            entity_out_deg,
310            entity_in_deg,
311        },
312    })
313}
314
315fn rebuild_relation_caches(conn: &Connection) -> Result<()> {
316    conn.execute_batch(
317        "INSERT INTO graph_stat(key, value)
318         VALUES ('relations', (SELECT COUNT(*) FROM relation))
319         ON CONFLICT(key) DO UPDATE SET value = excluded.value;
320
321         UPDATE type_dict
322         SET count = CASE kind
323           WHEN 0 THEN (
324             SELECT COUNT(*) FROM entity
325             WHERE entity.type_id = type_dict.id AND entity.flags = 0
326           )
327           WHEN 1 THEN (
328             SELECT COUNT(*) FROM relation
329             WHERE relation.type_id = type_dict.id
330           )
331           ELSE 0
332         END;
333
334         UPDATE entity
335         SET out_deg = (SELECT COUNT(*) FROM relation WHERE from_id = entity.id),
336             in_deg = (SELECT COUNT(*) FROM relation WHERE to_id = entity.id);",
337    )
338    .map_err(sql_error)
339}
340
341fn ensure_relation_unique_index(conn: &Connection) -> Result<()> {
342    let index_attributes: Option<(bool, bool)> = conn
343        .query_row(
344            "SELECT \"unique\" = 1, \"partial\" = 0
345             FROM pragma_index_list('relation')
346             WHERE name = 'relation_unique_triple'",
347            [],
348            |row| Ok((row.get(0)?, row.get(1)?)),
349        )
350        .optional()
351        .map_err(sql_error)?;
352    match index_attributes {
353        None => conn
354            .execute_batch(
355                "CREATE UNIQUE INDEX relation_unique_triple
356                 ON relation(from_id, to_id, type_id);",
357            )
358            .map_err(sql_error),
359        Some((true, true)) => {
360            let columns = conn
361                .prepare(
362                    "SELECT name FROM pragma_index_info('relation_unique_triple') ORDER BY seqno",
363                )
364                .map_err(sql_error)?
365                .query_map([], |row| row.get::<_, String>(0))
366                .map_err(sql_error)?
367                .collect::<rusqlite::Result<Vec<_>>>()
368                .map_err(sql_error)?;
369            if columns == ["from_id", "to_id", "type_id"] {
370                Ok(())
371            } else {
372                Err(MCSError::MemoryError(
373                    "relation_unique_triple does not enforce the relation triple".into(),
374                ))
375            }
376        }
377        Some((true, false)) => Err(MCSError::MemoryError(
378            "relation_unique_triple must not be a partial index".into(),
379        )),
380        Some((false, _)) => Err(MCSError::MemoryError(
381            "relation_unique_triple exists but is not unique".into(),
382        )),
383    }
384}