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}