Skip to main content

macrame/integrity/
rebuild.rs

1use crate::error::{DbError, Result};
2use crate::integrity::audit::audit_current;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct RebuildReport {
6    pub rows_rebuilt: usize,
7    pub drift_after: usize,
8}
9
10/// Rebuild the materialized current-belief table from `links` (§5.8).
11///
12/// One `BEGIN IMMEDIATE … COMMIT`. The empty window between the `DELETE` and
13/// the `INSERT` is the whole of current belief, so a failure across it — or a
14/// concurrent reader landing in it — sees a graph with no edges and no error.
15/// The transaction is what makes the repair a repair rather than a second way
16/// to lose the table.
17pub async fn rebuild_current(conn: &libsql::Connection) -> Result<RebuildReport> {
18    let tx = conn
19        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
20        .await?;
21    match rebuild_within(&tx, Verify::Yes).await {
22        Ok(report) => {
23            tx.commit().await?;
24            Ok(report)
25        }
26        Err(e) => {
27            let _ = tx.rollback().await;
28            Err(e)
29        }
30    }
31}
32
33/// Whether a rebuild audits itself when it is finished (T0.2, D-077).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub(crate) enum Verify {
36    /// Run `audit_current` afterwards and fail with `RebuildFailed` on drift.
37    ///
38    /// For the operator-facing repair. The post-check is what makes
39    /// `RebuildReport::drift_after` and `DbError::RebuildFailed` mean anything,
40    /// and a repair somebody invoked deliberately can afford to prove itself.
41    Yes,
42    /// Skip it.
43    ///
44    /// For `archive()`, which calls this **inside its own write transaction**.
45    /// The audit compares `links_current` against
46    /// [`LATEST_BELIEF_PROJECTION`](super::LATEST_BELIEF_PROJECTION); the insert
47    /// above fills `links_current` *from* that same projection, in the same
48    /// transaction, with nothing else able to write in between. So the check is
49    /// tautological — it verifies that `INSERT … SELECT` inserted what it
50    /// selected — and it is two `EXCEPT` passes over the whole table, O(E log E)
51    /// each, under the archive's lock.
52    ///
53    /// This was only safe to say once the projection had **one** definition. It
54    /// had two, byte-identical, in this file and `audit.rs`, and against two
55    /// copies the post-rebuild audit was a real check: that they still agreed.
56    No,
57}
58
59/// The rebuild itself, without a transaction of its own.
60///
61/// Exists so a caller that already holds one can reuse it — `archive()` does,
62/// to re-derive `links_current` after moving rows out of `links`. Opening a
63/// nested transaction there would simply fail, and doing the work outside the
64/// archive transaction would leave a window where the materialization does not
65/// match the ledger.
66pub(crate) async fn rebuild_within(
67    conn: &libsql::Connection,
68    verify: Verify,
69) -> Result<RebuildReport> {
70    conn.execute("DELETE FROM links_current", ()).await?;
71
72    let insert_query = format!(
73        "INSERT INTO links_current \
74         (source_id, target_id, edge_type, valid_from, valid_to, weight, properties, recorded_at) \
75         {projection}",
76        projection = super::LATEST_BELIEF_PROJECTION
77    );
78    let rows_inserted = conn.execute(&insert_query, ()).await?;
79
80    if verify == Verify::No {
81        return Ok(RebuildReport {
82            rows_rebuilt: rows_inserted as usize,
83            drift_after: 0,
84        });
85    }
86
87    match audit_current(conn).await {
88        Ok(0) => Ok(RebuildReport {
89            rows_rebuilt: rows_inserted as usize,
90            drift_after: 0,
91        }),
92        Ok(n) => Err(DbError::RebuildFailed { n }),
93        Err(DbError::CurrentDrift { n }) => Err(DbError::RebuildFailed { n }),
94        Err(e) => Err(e),
95    }
96}