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)]
5#[non_exhaustive]
6pub struct RebuildReport {
7    pub rows_rebuilt: usize,
8    pub drift_after: usize,
9}
10
11/// Rebuild the materialized current-belief table from `links` (§5.8).
12///
13/// One `BEGIN IMMEDIATE … COMMIT`. The empty window between the `DELETE` and
14/// the `INSERT` is the whole of current belief, so a failure across it — or a
15/// concurrent reader landing in it — sees a graph with no edges and no error.
16/// The transaction is what makes the repair a repair rather than a second way
17/// to lose the table.
18pub async fn rebuild_current(conn: &libsql::Connection) -> Result<RebuildReport> {
19    let tx = conn
20        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
21        .await?;
22    match rebuild_within(&tx, Verify::Yes).await {
23        Ok(report) => {
24            tx.commit().await?;
25            Ok(report)
26        }
27        Err(e) => {
28            let _ = tx.rollback().await;
29            Err(e)
30        }
31    }
32}
33
34/// Whether a rebuild audits itself when it is finished (T0.2, D-077).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub(crate) enum Verify {
37    /// Run `audit_current` afterwards and fail with `RebuildFailed` on drift.
38    ///
39    /// For the operator-facing repair. The post-check is what makes
40    /// `RebuildReport::drift_after` and `DbError::RebuildFailed` mean anything,
41    /// and a repair somebody invoked deliberately can afford to prove itself.
42    Yes,
43    /// Skip it.
44    ///
45    /// For `archive()`, which calls this **inside its own write transaction**.
46    /// The audit compares `links_current` against
47    /// [`latest_belief_projection`](super::latest_belief_projection); the insert
48    /// above fills `links_current` *from* that same projection, in the same
49    /// transaction, with nothing else able to write in between. So the check is
50    /// tautological — it verifies that `INSERT … SELECT` inserted what it
51    /// selected — and it is two `EXCEPT` passes over the whole table, O(E log E)
52    /// each, under the archive's lock.
53    ///
54    /// This was only safe to say once the projection had **one** definition. It
55    /// had two, byte-identical, in this file and `audit.rs`, and against two
56    /// copies the post-rebuild audit was a real check: that they still agreed.
57    No,
58}
59
60/// The five columns `links_current` is keyed by, in the order the primary key
61/// declares them.
62///
63/// Spelled once because the keyed repair names them four times — the temp
64/// table, the `DELETE`'s tuple, its subquery, and the re-projection's — and a
65/// list that has to agree with itself four times is a list that will not.
66pub(crate) const PROJECTION_KEY: &str = "source_id, target_id, edge_type, valid_from, branch_id";
67
68/// Re-derive `links_current` **at named keys only** (0.15.3, [D-245]).
69///
70/// `keys` is a table holding [`PROJECTION_KEY`] — the archive collects it from
71/// `links` before its `DELETE`, inside the same transaction, so it names every
72/// key the session is about to disturb and nothing else.
73///
74/// # Why this is exact, and not an approximation of the rebuild
75///
76/// `links_current` is a function of `links`, one row per key (Doctrine VI), and
77/// the function is *pointwise*: the row at a key depends on the `links` rows at
78/// that key and on nothing else. So a change confined to a set of keys can only
79/// change the projection at those keys, and re-deriving there is not a cheaper
80/// estimate of the full rebuild — it is the same answer with the untouched
81/// partitions left alone. Both halves matter and both are derived from the
82/// definition rather than described: the `DELETE` removes what was there, the
83/// `INSERT` puts back whatever the surviving rows project to, which is **no
84/// row** when the session archived the last belief at that key. That case is
85/// the one a hand-written compensation gets wrong, and it is why the repair is
86/// two statements against the projection instead of one `DELETE` with a
87/// predicate.
88///
89/// The full [`rebuild_within`] stays for `rebuild_current`, where the caller is
90/// asking for exactly that and has no key set to offer.
91///
92/// [D-245]: ../../docs/architecture/s13-decision-register.md#d-245
93pub(crate) async fn repair_keys_within(conn: &libsql::Connection, keys: &str) -> Result<usize> {
94    conn.execute(
95        &format!("DELETE FROM links_current WHERE ({PROJECTION_KEY}) IN (SELECT {PROJECTION_KEY} FROM {keys})"),
96        (),
97    )
98    .await?;
99
100    let rows = conn
101        .execute(
102            &format!(
103                "INSERT INTO links_current \
104                 (source_id, target_id, edge_type, valid_from, valid_to, weight, properties, \
105                  recorded_at, branch_id) \
106                 {projection}",
107                projection = super::projection_where(&format!(
108                    "({PROJECTION_KEY}) IN (SELECT {PROJECTION_KEY} FROM {keys})"
109                ))
110            ),
111            (),
112        )
113        .await?;
114
115    Ok(rows as usize)
116}
117
118/// The rebuild itself, without a transaction of its own.
119///
120/// Exists so a caller that already holds one can reuse it — `archive()` does,
121/// to re-derive `links_current` after moving rows out of `links`. Opening a
122/// nested transaction there would simply fail, and doing the work outside the
123/// archive transaction would leave a window where the materialization does not
124/// match the ledger.
125pub(crate) async fn rebuild_within(
126    conn: &libsql::Connection,
127    verify: Verify,
128) -> Result<RebuildReport> {
129    conn.execute("DELETE FROM links_current", ()).await?;
130
131    let insert_query = format!(
132        "INSERT INTO links_current \
133         (source_id, target_id, edge_type, valid_from, valid_to, weight, properties, \
134          recorded_at, branch_id) \
135         {projection}",
136        projection = super::latest_belief_projection()
137    );
138    let rows_inserted = conn.execute(&insert_query, ()).await?;
139
140    if verify == Verify::No {
141        return Ok(RebuildReport {
142            rows_rebuilt: rows_inserted as usize,
143            drift_after: 0,
144        });
145    }
146
147    match audit_current(conn).await {
148        Ok(0) => Ok(RebuildReport {
149            rows_rebuilt: rows_inserted as usize,
150            drift_after: 0,
151        }),
152        Ok(n) => Err(DbError::RebuildFailed { n }),
153        Err(DbError::CurrentDrift { n }) => Err(DbError::RebuildFailed { n }),
154        Err(e) => Err(e),
155    }
156}