Skip to main content

macrame/temporal/
archive.rs

1use std::path::Path;
2
3use libsql::TransactionBehavior;
4
5use crate::error::{Result, WriteOp};
6use crate::schema::ddl::ARCHIVE_SESSION_MARKER;
7
8/// Outcome of one archive session.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ArchiveReport {
11    pub links_archived: usize,
12    pub log_entries_archived: usize,
13    /// Oldest `transaction_log.seq_id` still present in the hot file after the
14    /// session, i.e. the new horizon (see glossary). `None` if the hot log is empty.
15    pub horizon: Option<i64>,
16}
17
18/// Schema of the cold database. Deliberately trigger-free and FK-free: concepts
19/// are never archived (D-022), so a FK from cold.links to concepts could not be
20/// satisfied, and the delete guards must not exist on a file whose whole purpose
21/// is to receive rows.
22const COLD_SCHEMA: &[&str] = &[
23    // `weight` carries the same CHECK as the hot table (T2.1, D-083). Not
24    // symmetry for its own sake: the cold file is read back by `reconstruct`
25    // through the same `f64` decode, so a text weight is the same panic there
26    // as it is here, and a negative one is the same unsound shortest path.
27    //
28    // The hot table's constraint does not protect this one. Rows arrive by
29    // `INSERT … SELECT` across an ATTACH, which re-checks against *this*
30    // table's constraints — and a cold file may predate the hot file's rung, or
31    // have been written by a version that had neither.
32    //
33    // `IF NOT EXISTS` means an existing cold database keeps whatever definition
34    // it was created with; this constrains new cold files, and the loader guard
35    // is what covers the old ones. That is the same division of labour §4.7
36    // describes, and the reason the guard stays.
37    r#"CREATE TABLE IF NOT EXISTS cold.links (
38        source_id   TEXT NOT NULL,
39        target_id   TEXT NOT NULL,
40        edge_type   TEXT NOT NULL,
41        valid_from  TEXT NOT NULL,
42        recorded_at TEXT NOT NULL,
43        valid_to    TEXT NOT NULL,
44        weight      REAL NOT NULL CHECK (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real'),
45        properties  TEXT NOT NULL,
46        PRIMARY KEY (source_id, target_id, edge_type, valid_from, recorded_at)
47    )"#,
48    // seq_id is carried over verbatim from the hot log, so it is a plain
49    // INTEGER PRIMARY KEY -- never AUTOINCREMENT, which would renumber history.
50    r#"CREATE TABLE IF NOT EXISTS cold.transaction_log (
51        seq_id      INTEGER PRIMARY KEY,
52        table_name  TEXT NOT NULL,
53        entity_id   TEXT NOT NULL,
54        operation   TEXT NOT NULL,
55        payload     TEXT NOT NULL,
56        recorded_at TEXT NOT NULL
57    )"#,
58    "CREATE INDEX IF NOT EXISTS cold.idx_cold_txlog_entity ON transaction_log (entity_id)",
59    "CREATE INDEX IF NOT EXISTS cold.idx_cold_txlog_time ON transaction_log (recorded_at)",
60    r#"CREATE TABLE IF NOT EXISTS cold.archive_horizon (
61        archived_at TEXT NOT NULL,
62        cutoff      TEXT NOT NULL,
63        horizon     INTEGER
64    )"#,
65];
66
67/// A links assertion is archivable when it is older than the cutoff AND it is
68/// either superseded by a later assertion for the same interval key, or it is
69/// the current belief for an interval that closed before the cutoff.
70///
71/// This keeps every row that `links_current` still projects (Doctrine VI: the
72/// materialization must stay rebuildable from `links`) while moving exactly the
73/// "closed intervals, superseded history" the §2 diagram assigns to the cold file.
74const LINKS_ARCHIVABLE: &str = r#"
75    recorded_at < :cutoff AND (
76        EXISTS (
77            SELECT 1 FROM links newer
78            WHERE newer.source_id   = links.source_id
79              AND newer.target_id   = links.target_id
80              AND newer.edge_type   = links.edge_type
81              AND newer.valid_from  = links.valid_from
82              AND newer.recorded_at > links.recorded_at
83        )
84        OR (valid_to <> '9999-12-31T23:59:59.999999Z' AND valid_to <= :cutoff)
85    )
86"#;
87
88/// A log entry is archivable when it is older than the cutoff and a later entry
89/// exists for the same entity, i.e. it is superseded. The newest entry per
90/// entity always stays hot so that `reconstruct(now)` never needs the cold file.
91const LOG_ARCHIVABLE: &str = r#"
92    recorded_at < :cutoff AND EXISTS (
93        SELECT 1 FROM transaction_log newer
94        WHERE newer.entity_id = transaction_log.entity_id
95          AND newer.seq_id    > transaction_log.seq_id
96    )
97"#;
98
99/// Move closed edge intervals and superseded log rows older than `cutoff` into
100/// the cold database at `archive_path` (§5.7, D-012, D-022).
101///
102/// The whole session is one `BEGIN IMMEDIATE … COMMIT` transaction (D-012):
103/// copy-then-delete must be atomic, or a crash between the phases duplicates or
104/// loses rows. The archive-session marker that unlocks the delete guards
105/// (D-008 revised) is created as the first statement of that transaction and
106/// dropped as the last, so it never exists as committed state — commit drops
107/// it, rollback discards it, and there is no crash path that leaves the guards
108/// disarmed.
109///
110/// ATTACH is issued outside the transaction and DETACH is issued unconditionally
111/// on the way out, including on error: ATTACH is not transactional and survives
112/// ROLLBACK, so a leaked handle would make every later archive or cold-DB
113/// reconstruct fail with "database cold is already in use".
114/// `archived_at` is **when the session ran**; `cutoff` is the boundary it used.
115///
116/// Both go into `cold.archive_horizon`, and until Wave 4.5 both columns were
117/// written with the cutoff — so the table recorded that every archive had run at
118/// the instant it was archiving *up to*, which is the one time it certainly did
119/// not run. The two are different clocks (Doctrine II) and the row exists to
120/// carry both: the cutoff says what was moved, `archived_at` says when the
121/// decision was taken, and only the second can answer "how stale is this cold
122/// file". The column was there, correctly named, holding the wrong value.
123pub async fn archive(
124    conn: &libsql::Connection,
125    cutoff: &str,
126    archived_at: &str,
127    archive_path: &Path,
128) -> Result<ArchiveReport> {
129    crate::temporal::replay::detach_stale_cold(conn).await;
130
131    // ATTACH creates the cold file if it does not exist.
132    conn.execute(
133        "ATTACH DATABASE ?1 AS cold",
134        libsql::params![archive_path.to_string_lossy().as_ref()],
135    )
136    .await?;
137
138    let result = archive_session(conn, cutoff, archived_at).await;
139
140    // Unconditional: see the DETACH note above.
141    if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
142        tracing::warn!("archive: failed to DETACH cold database: {e}");
143    }
144
145    result
146}
147
148/// `conn` is passed alongside `tx` only so [`delete_guarded`] can hand it to
149/// `classify`, which queries on the error path. Both name the same connection.
150async fn archive_session(
151    conn: &libsql::Connection,
152    cutoff: &str,
153    archived_at: &str,
154) -> Result<ArchiveReport> {
155    for ddl in COLD_SCHEMA {
156        conn.execute(ddl, ()).await?;
157    }
158
159    let tx = conn
160        .transaction_with_behavior(TransactionBehavior::Immediate)
161        .await?;
162
163    // --- archive session opens: the delete guards are now satisfied ---
164    tx.execute(&format!("CREATE TABLE {ARCHIVE_SESSION_MARKER} (x)"), ())
165        .await?;
166
167    let links_archived = tx
168        .execute(
169            &format!(
170                "INSERT OR IGNORE INTO cold.links
171                     (source_id, target_id, edge_type, valid_from, recorded_at,
172                      valid_to, weight, properties)
173                 SELECT source_id, target_id, edge_type, valid_from, recorded_at,
174                        valid_to, weight, properties
175                 FROM links WHERE {LINKS_ARCHIVABLE}"
176            ),
177            libsql::named_params! {":cutoff": cutoff},
178        )
179        .await? as usize;
180
181    let links_deleted = delete_guarded(
182        &tx,
183        conn,
184        &format!("DELETE FROM links WHERE {LINKS_ARCHIVABLE}"),
185        cutoff,
186        "links",
187    )
188    .await?;
189
190    // links_current is derivative (Doctrine VI) and must equal the latest-belief
191    // projection of whatever remains in links, or audit_current() reports drift
192    // the moment an archive runs. Re-derive it rather than trying to describe
193    // the deletion's shadow: this used to be a hand-written
194    // `DELETE FROM links_current WHERE valid_to <= :cutoff`, which filters on
195    // *valid* time while LINKS_ARCHIVABLE also requires `recorded_at < :cutoff`.
196    // A row closed at the cutoff but recorded at or after it therefore survived
197    // in links and was deleted from links_current — permanent drift no later
198    // audit could explain, from a compensation that had quietly stopped being
199    // the image of the thing it compensated for. Doctrine II: two clocks, never
200    // mixed. Deriving from the definition cannot drift from the definition.
201    //
202    // **Skipped when the DELETE removed nothing (T1.1, D-080).** `links_current`
203    // is a function of `links`, so if `links` did not change its projection did
204    // not either, and there is no drift for a rebuild to repair. This was
205    // harmless while `archive()` was called once against a whole backlog,
206    // because the one session always had work. It stops being harmless the
207    // moment the caller windows: `rebuild_within` costs O(surviving `links`)
208    // regardless of how much the session archived (D-077), so without this a run
209    // of twenty windows over a quiet stretch of history pays twenty full
210    // reprojections to delete nothing — and windowing makes the archive slower
211    // in total than not windowing. `log_entries_archived` deliberately does not
212    // enter into it: archiving the transaction log cannot change `links`.
213    if links_deleted > 0 {
214        crate::integrity::rebuild::rebuild_within(&tx, crate::integrity::rebuild::Verify::No)
215            .await?;
216    }
217
218    let log_entries_archived = tx
219        .execute(
220            &format!(
221                "INSERT OR IGNORE INTO cold.transaction_log
222                     (seq_id, table_name, entity_id, operation, payload, recorded_at)
223                 SELECT seq_id, table_name, entity_id, operation, payload, recorded_at
224                 FROM transaction_log WHERE {LOG_ARCHIVABLE}"
225            ),
226            libsql::named_params! {":cutoff": cutoff},
227        )
228        .await? as usize;
229
230    delete_guarded(
231        &tx,
232        conn,
233        &format!("DELETE FROM transaction_log WHERE {LOG_ARCHIVABLE}"),
234        cutoff,
235        "transaction_log",
236    )
237    .await?;
238
239    // Record the new horizon in the cold file so a pre-horizon reconstruct can
240    // tell "archived" from "never existed" (glossary; R14).
241    let horizon: Option<i64> = tx
242        .query("SELECT MIN(seq_id) FROM transaction_log", ())
243        .await?
244        .next()
245        .await?
246        .and_then(|row| row.get(0).ok());
247
248    tx.execute(
249        "INSERT INTO cold.archive_horizon (archived_at, cutoff, horizon) VALUES (?1, ?2, ?3)",
250        libsql::params![archived_at, cutoff, horizon],
251    )
252    .await?;
253
254    // --- archive session closes: the guards re-arm before COMMIT ---
255    tx.execute(&format!("DROP TABLE {ARCHIVE_SESSION_MARKER}"), ())
256        .await?;
257
258    tx.commit().await?;
259
260    Ok(ArchiveReport {
261        links_archived,
262        log_entries_archived,
263        horizon,
264    })
265}
266
267/// Run one of the archive's `DELETE`s, naming the table if a guard refuses it.
268///
269/// **This is what closes defect AC, and the shape of the fix is the point.**
270/// There used to be a second classifier here — `classify_archive_violation` —
271/// which was defined, delegated correctly to [`crate::error::abort_kind`], and
272/// called from nowhere, so `DbError::ArchiveViolation` was unreachable by any
273/// code path in the crate. It was recorded as defect H, marked Fixed by a commit
274/// that made the *body* delegate rather than making the function *called*, and
275/// so survived its own repair. It is deleted rather than wired up, because
276/// [`crate::error::classify`] with [`WriteOp::Delete`] already did exactly what
277/// it did: the defect was one classifier too many, not one too few.
278///
279/// A guard firing here means the marker table is absent or was dropped early —
280/// the session's invariant broken from inside. That is worth a typed error
281/// naming the table rather than a raw engine message naming a trigger.
282async fn delete_guarded(
283    tx: &libsql::Transaction,
284    conn: &libsql::Connection,
285    sql: &str,
286    cutoff: &str,
287    table: &str,
288) -> Result<u64> {
289    match tx
290        .execute(sql, libsql::named_params! {":cutoff": cutoff})
291        .await
292    {
293        Ok(n) => Ok(n),
294        Err(e) => Err(crate::error::classify(conn, e, WriteOp::Delete { table }).await),
295    }
296}