1use 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#[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#[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#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
40pub struct CounterDrift {
41 pub stored: Option<i64>,
42 pub actual: i64,
43}
44
45#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
47pub struct RepairReport {
48 pub before: AuditReport,
49 pub after: AuditReport,
50}
51
52impl AuditReport {
53 #[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
67pub 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
78pub 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 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}