macrame/temporal/replay.rs
1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3use std::path::{Path, PathBuf};
4
5use crate::branch::Ancestor;
6use crate::error::{DbError, Result};
7use crate::temporal::as_of::NodeAttributes;
8
9/// One lineage's belief about one edge, at the instant a fold asked (§15.2).
10///
11/// # Why this is a struct and was a five-tuple until 0.14.5
12///
13/// The tuple had nowhere to put `branch_id`, and that was not a cosmetic
14/// shortfall: [D-216](../../docs/architecture/s13-decision-register.md) widened
15/// the four SQL folds to partition by `(table_name, entity_id, branch_id)` so
16/// two lineages' beliefs about one edge would stay two rows, and then the
17/// composition immediately downstream re-collapsed them, because `edge_key`
18/// composed `source|target|type|valid_from` and the map it fed had one slot per
19/// edge key. The widened partition was handing two rows to a container that
20/// could not hold two. That is [D-221](../../docs/architecture/s13-decision-register.md#d-221),
21/// and this type is its fix.
22///
23/// A struct rather than a six-tuple because the next field to arrive should be
24/// additive, which is why it is also `#[non_exhaustive]` — the same call
25/// [D-207](../../docs/architecture/s13-decision-register.md#d-207) made for
26/// `DbError`, one release earlier, for the same reason. Construct these by
27/// reading a [`MaterializedState`]; the crate is the only writer.
28///
29/// **Ordered by the tuple order of its fields**, so a `Vec<EdgeBelief>` sorts to
30/// a canonical form and two reconstructions of the same instant are *equal*
31/// rather than merely equivalent — a property the snapshot suite compares on.
32///
33/// # Constructing one
34///
35/// `#[non_exhaustive]` means no crate but this one may write the literal, and
36/// [`save_snapshot`](crate::temporal::save_snapshot) is public and takes a
37/// `MaterializedState` — so without a constructor the attribute would not make
38/// the next field additive, it would make a public function uncallable. Use
39/// [`EdgeBelief::new`], which takes the five fields that were the tuple and
40/// defaults the sixth to the trunk, with [`EdgeBelief::on_branch`] for the
41/// rest. That is [`EdgeAssertion::new`](crate::graph::EdgeAssertion::new)'s
42/// shape, deliberately: the two are the same fact travelling in opposite
43/// directions and should not need two idioms.
44#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
45#[non_exhaustive]
46pub struct EdgeBelief {
47 pub source_id: String,
48 pub target_id: String,
49 pub edge_type: String,
50 pub valid_from: String,
51 pub valid_to: String,
52 /// The lineage that holds this belief (0.14.5, D-221).
53 ///
54 /// `#[serde(default = "default_branch")]` so the field is additive at the
55 /// bincode level. It is belt and braces — the snapshot container refuses any
56 /// file whose format version is not this build's, and 0.14.5 bumps it
57 /// precisely so a state written without this field gets a named refusal
58 /// rather than a deserialisation error — but a default that is *right* costs
59 /// nothing and `'main'` is what every pre-v12 row actually carried.
60 #[serde(default = "default_branch")]
61 pub branch_id: String,
62}
63
64fn default_branch() -> String {
65 crate::schema::ddl::MAIN_BRANCH.to_string()
66}
67
68impl EdgeBelief {
69 /// A belief held by the trunk. Use [`Self::on_branch`] for any other.
70 ///
71 /// Five arguments rather than six because `main` is what every belief
72 /// written before 0.14.5 carried, so a caller porting a five-tuple wraps it
73 /// and is correct rather than being asked a question the old shape could
74 /// not have answered.
75 pub fn new(
76 source_id: impl Into<String>,
77 target_id: impl Into<String>,
78 edge_type: impl Into<String>,
79 valid_from: impl Into<String>,
80 valid_to: impl Into<String>,
81 ) -> Self {
82 Self {
83 source_id: source_id.into(),
84 target_id: target_id.into(),
85 edge_type: edge_type.into(),
86 valid_from: valid_from.into(),
87 valid_to: valid_to.into(),
88 branch_id: default_branch(),
89 }
90 }
91
92 /// The lineage holding this belief.
93 ///
94 /// Unchecked against `branches`, because this type is a value and not a
95 /// write: a `MaterializedState` naming a lineage the register has never
96 /// heard of is a snapshot that will disagree with the log, which
97 /// `verify_snapshot_chain` is there to report.
98 pub fn on_branch(mut self, branch_id: impl Into<String>) -> Self {
99 self.branch_id = branch_id.into();
100 self
101 }
102
103 /// The log `entity_id` this belief was folded under.
104 ///
105 /// Must match `trg_links_log_insert`'s
106 /// `source_id || '|' || target_id || '|' || edge_type || '|' || valid_from`
107 /// exactly, or a delta row will fail to replace the snapshot row it
108 /// supersedes. Safe because ULIDs are Crockford base32 and edge types are
109 /// `[A-Z0-9]+`, so `|` cannot occur inside a component (§4.3).
110 ///
111 /// **This is not a unique key across lineages** and must not be used as one
112 /// — see [`Self::belief_key`], which is.
113 pub fn entity_id(&self) -> String {
114 format!(
115 "{}|{}|{}|{}",
116 self.source_id, self.target_id, self.edge_type, self.valid_from
117 )
118 }
119
120 /// What identifies this belief: the edge key **and** the lineage holding it.
121 pub fn belief_key(&self) -> String {
122 format!("{}|{}", self.entity_id(), self.branch_id)
123 }
124}
125
126/// Full materialized state reconstructed from transaction_log replay (§5.5).
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[non_exhaustive]
129pub struct MaterializedState {
130 pub seq_anchor: i64,
131 pub timestamp: String,
132 pub concepts: HashMap<String, NodeAttributes>,
133 /// Every lineage's belief, each labelled with the lineage holding it.
134 ///
135 /// **Not resolved to one lineage's view**, and deliberately: `reconstruct`
136 /// asks a whole-ledger question — *what did the ledger hold at `ts`* — and
137 /// the ledger held both. Resolving here would require an ancestry, which
138 /// requires a connection this type does not have, and would silently answer
139 /// a narrower question than the one asked. A caller wanting one lineage's
140 /// view uses `graph::TraversalBuilder::on_branch` or
141 /// `temporal::query_as_of_edges_on`, which resolve against the register
142 /// ([D-220](../../docs/architecture/s13-decision-register.md#d-220)).
143 pub edges: Vec<EdgeBelief>,
144 /// **Nothing had been recorded yet at `timestamp`** (0.8.0, B5, D-121).
145 ///
146 /// An empty state has two meanings and a caller can act differently on
147 /// them. *Everything was retired by then* is a fact about the data;
148 /// *the ledger had not started* is a fact about the question. Both come
149 /// back as zero concepts and zero edges, so the difference has to be
150 /// carried rather than inferred.
151 ///
152 /// Set only when the log was verified **intact** — see
153 /// `hot_log_reach`. If rows had been archived away, `ts` below the hot
154 /// floor is not "before history", it is "the history is in the other file",
155 /// and that path raises instead of answering.
156 ///
157 /// `#[serde(default)]` so the field is additive: a snapshot written without
158 /// it deserialises with `false`, which is the right answer for any state
159 /// that had rows to fold. Old snapshots cannot actually reach this code —
160 /// the container carries `SCHEMA_VERSION` and v8 refused every v7 file
161 /// (D-043) — but the tolerance costs nothing and the next field to arrive
162 /// may not land in a release that bumps the schema.
163 #[serde(default)]
164 pub predates_recorded_history: bool,
165}
166
167impl MaterializedState {
168 /// The state before any log row has been applied, at `ts`.
169 ///
170 /// Public since 0.15.13 (W15.3, [D-255]) because the struct became
171 /// `#[non_exhaustive]` in that release and [`save_snapshot`] takes one:
172 /// a caller who writes a snapshot of a state they assembled needs a way in
173 /// that is not the field literal. Assemble from here — the fields are
174 /// `pub` and stay assignable on a value you own.
175 ///
176 /// [`save_snapshot`]: crate::temporal::save_snapshot
177 /// [D-255]: ../../docs/architecture/s13-decision-register.md#d-255
178 pub fn empty(ts: &str) -> Self {
179 Self {
180 seq_anchor: 0,
181 timestamp: ts.to_string(),
182 concepts: HashMap::new(),
183 edges: Vec::new(),
184 predates_recorded_history: false,
185 }
186 }
187}
188
189/// One lineage's view of a fold, from every lineage's beliefs (review C-10).
190///
191/// The nearest lineage holding an edge key wins, and a key no visible lineage
192/// holds is absent. That is `graph::lineage::visible_cte`'s rule — `ROW_NUMBER() OVER
193/// (PARTITION BY the edge key ORDER BY g.dist)`, `rn = 1` — written once more,
194/// in Rust, over a value a caller already has (0.15.17, [D-259]).
195///
196/// # What this is for
197///
198/// Until this release the rule existed **only** as SQL. A caller holding a
199/// [`MaterializedState`] — from [`reconstruct`], or read back from a snapshot —
200/// had every lineage's belief in one `Vec` and no function in the crate that
201/// would finish the question, so the choice was to re-issue the read through
202/// `graph::TraversalBuilder::on_branch` (a different query against the
203/// *projection*, not against the state in hand) or to reimplement the rule.
204/// Reimplementing it is what review C-10 expected someone to do, and the two
205/// copies would have drifted the first time a shape was added.
206///
207/// # Ties, and why there are none to break in practice
208///
209/// A fold emits at most one row per `(edge key, lineage)` and an ancestry names
210/// each lineage once, so no two candidates for a key share a `dist` and the
211/// winner is determined by distance alone. This still breaks ties on the
212/// belief's own ordering rather than on iteration order, because it is a public
213/// pure function: an input the crate did not build should get an answer that is
214/// a function of the input, not of a hash seed.
215///
216/// # The cutoff is not applied here, and cannot be
217///
218/// An ancestor's rows are visible to a descendant only up to
219/// [`Ancestor::cutoff`], and that is a comparison against the row's
220/// `recorded_at` — a column [`EdgeBelief`] does not carry, deliberately, since
221/// a belief is *what was believed* and not *when it was written down*. So the
222/// cutoff belongs to whatever produced the beliefs: [`reconstruct_on`] applies
223/// it by folding each ancestor to its own instant before calling this.
224///
225/// Handing this the unbounded `edges` of a plain [`reconstruct`] therefore
226/// gives the nearest-lineage answer **without** the fork bound — right on a
227/// database whose ancestors have not been written to since the fork, and
228/// quietly wide on one that has. The doc says so rather than the signature,
229/// because a `&[EdgeBelief]` cannot be typed into "already cut".
230///
231/// [D-259]: ../../docs/architecture/s13-decision-register.md#d-259
232pub fn resolve_beliefs(beliefs: &[EdgeBelief], ancestry: &[Ancestor]) -> Vec<EdgeBelief> {
233 let rank: HashMap<&str, i64> = ancestry
234 .iter()
235 .map(|a| (a.branch_id.as_str(), a.dist))
236 .collect();
237
238 let mut best: HashMap<String, (i64, &EdgeBelief)> = HashMap::new();
239 for belief in beliefs {
240 // A lineage outside the ancestry is not an ancestor and not the reader:
241 // a sibling, or a descendant. `visible_cte` drops those by inner-joining
242 // `lineage`, which is the same thing said in SQL.
243 let Some(&dist) = rank.get(belief.branch_id.as_str()) else {
244 continue;
245 };
246 best.entry(belief.entity_id())
247 .and_modify(|held| {
248 if (dist, belief) < (held.0, held.1) {
249 *held = (dist, belief);
250 }
251 })
252 .or_insert((dist, belief));
253 }
254
255 // Sorted for the same reason `Delta::apply_to` sorts: the answer is a
256 // function of the state, not of hash iteration order.
257 let mut out: Vec<EdgeBelief> = best.into_values().map(|(_, b)| b.clone()).collect();
258 out.sort();
259 out
260}
261
262/// State at `ts` **as one lineage saw it** (0.15.17, [D-259], review C-10).
263///
264/// [`reconstruct`] answers a whole-ledger question — *what did the ledger hold
265/// at `ts`* — and the ledger held every lineage's belief at once. This answers
266/// the narrower one a caller usually means: *what did `branch` hold at `ts`*,
267/// with the ancestry resolved and each ancestor bounded at its fork point.
268///
269/// # How it is assembled
270///
271/// One fold, with the ancestry bound into it: the `JOIN` against the `lineage`
272/// relation drops every lineage the reader cannot see, `recorded_at <=
273/// g.cutoff` bounds each ancestor at its own fork point, and [`resolve_beliefs`]
274/// then picks the nearest holder of each key. The cut is applied to the
275/// window's **input**, which is the part that cannot be done any other way —
276/// see `bounded_hot_fold`.
277///
278/// ## The shape this is not, and the measurement that decided it
279///
280/// The first version folded once per **distinct effective instant** — `min(ts,
281/// cutoff)` for the reader and each ancestor — and kept from each fold the
282/// lineages whose instant it was. That form reuses [`reconstruct`] whole,
283/// snapshot composition included, and the argument for it was that a fork depth
284/// of 1 is two cheap folds where this one is a single expensive one.
285///
286/// Measured (`examples/reconstruct_on_probe.rs`, 400 concepts), the argument
287/// holds at exactly one of the four configurations tried:
288///
289/// | snapshots | fork depth | fold-per-bound | this |
290/// |---|---|---|---|
291/// | off | 1 | 5.6 ms | **2.9 ms** |
292/// | off | 8 | 24.1 ms | **3.2 ms** |
293/// | on | 1 | **2.0 ms** | 2.9 ms |
294/// | on | 8 | 7.5 ms | **3.2 ms** |
295///
296/// Both shapes run in one process against one build, alternating, because the
297/// first version of this comparison ran them in two processes against two
298/// builds and that is thin evidence for reversing a design. The probe also
299/// asserts that the two shapes return the **same edges** before it times them,
300/// and counts the snapshot files on disk rather than trusting that asking for a
301/// cadence produced one — the whole argument for fold-per-bound rests on
302/// composition actually being available.
303///
304/// The per-bound form is linear in fork depth and this one is flat, so the
305/// crossover is at depth 2 with snapshots configured and below depth 1 without
306/// them. Losing about 1 ms at the one point where the other shape wins buys a
307/// cost that does not depend on how deeply a caller has forked, and one code
308/// path instead of two — the same call [D-056]'s guard made earlier in this
309/// release for the same reason.
310///
311/// ## What that costs: no snapshot composition
312///
313/// A snapshot is a materialised state with no `recorded_at` left in it, so
314/// there is nothing for a cutoff to compare against and no way to anchor a
315/// bounded fold on one. This therefore folds from genesis every time, which is
316/// where the flat ~3 ms comes from — and why, on a database with snapshots
317/// configured, this is **4x** [`reconstruct`] rather than 1.2x. The absolute
318/// cost is the same in both configurations; it is `reconstruct` that gets
319/// faster, not this that gets slower.
320///
321/// An unforked database never pays any of it: the shape is `Trunk`, there is
322/// one lineage and nothing to resolve, and this delegates to [`reconstruct`]
323/// unchanged, snapshots and all.
324///
325/// # Concepts need no distance rule, because the tie cannot happen (0.15.18,
326/// [D-260])
327///
328/// [`MaterializedState::concepts`] is keyed by concept id alone, so once a row
329/// is folded there is no lineage left on it to pick a nearest one by. The fold
330/// here *is* narrowed — a lineage outside the ancestry contributes nothing, and
331/// an ancestor's post-cutoff concept writes are cut like its edges — and that
332/// narrowing is all a concept needs, because **two visible lineages cannot both
333/// hold one concept id**.
334///
335/// That is the schema's guarantee and not this function's. `concepts.id` is
336/// `NOT NULL UNIQUE` — identity, not identity-per-lineage — and
337/// `trg_concepts_cross_lineage` turns the index's refusal into
338/// [`DbError::CrossLineage`](crate::DbError::CrossLineage) so it says which
339/// rule was broken; `trg_concepts_branch_immutable` stops a concept being moved
340/// to another lineage afterwards. A branch therefore **inherits** its parent's
341/// concepts and cannot restate them (§15.2, [D-225]), which is the same rule
342/// read from the other side.
343///
344/// The one route past that guard is [`archive_branch`] — it reads the live
345/// table, and archiving moves rows out of it — so archiving a lineage and then
346/// minting its id on the trunk does leave two lineages' rows for one id in
347/// hot-plus-cold history. It still reaches no reader: an archived lineage is
348/// gone from `branches`, so it is in nobody's ancestry, so the `JOIN` above
349/// drops its rows on **both** arms (the cold one joins the union, not each
350/// file). `rehydrate` refuses to bring the concept back while its lineage is
351/// forgotten ([D-253]). `examples/concept_lineage_probe.rs` walks all five
352/// routes and prints which the database refuses.
353///
354/// So this is not a resolution the caller must compensate for. It is a rule
355/// with nothing to decide, and if `concepts` ever gained per-lineage rows —
356/// the overlay design [D-214] defers — it would need one, along with a lineage
357/// on the folded row to apply it to.
358///
359/// Only [`MaterializedState::edges`] gets the distance rule, which is the field
360/// review C-10 named and the only one the rule has ever been needed for.
361///
362/// [D-260]: ../../docs/architecture/s13-decision-register.md#d-260
363/// [D-253]: ../../docs/architecture/s13-decision-register.md#d-253
364/// [D-225]: ../../docs/architecture/s13-decision-register.md#d-225
365/// [D-214]: ../../docs/architecture/s13-decision-register.md#d-214
366/// [`archive_branch`]: crate::Database::archive_branch
367///
368/// # This result is not a snapshot
369///
370/// It is one lineage's *view*, so it is missing beliefs the ledger holds. Do not
371/// pass it to [`save_snapshot`](crate::temporal::save_snapshot): a later
372/// [`reconstruct`] anchoring on it would compose a whole-ledger answer on top of
373/// a partial base and return the other lineages' rows only where something
374/// touched them again. [`seq_anchor`](MaterializedState::seq_anchor) is the
375/// highest `seq_id` among the rows this lineage can *see*, which is the honest
376/// number for what was folded and is not a licence to anchor on it.
377///
378/// [D-056]: ../../docs/architecture/s13-decision-register.md#d-056
379///
380/// # Errors
381///
382/// [`DbError::UnknownBranch`](crate::DbError::UnknownBranch), naming it, when
383/// `branch` is not registered — refused rather than answered for the trunk, for
384/// the reason `graph::lineage::Lineages::shape` gives.
385///
386/// Otherwise the same refusals [`reconstruct`] raises at `ts`, and for the same
387/// reasons: reach is decided by `ts` alone, because the cutoffs are a predicate
388/// inside one query rather than instants of their own.
389///
390/// # Where this narrows silently, named rather than left to be found
391///
392/// An ancestor's inherited row is the *last* one it wrote at or before the fork
393/// point, and the fold finds it in the hot log. `LOG_ARCHIVABLE` archives an
394/// entry once a later one supersedes it for the same entity — so a pre-fork
395/// assertion that the ancestor corrected afterwards is archivable, and once the
396/// retention horizon passes the fork point it can be cold. The reader then
397/// loses an edge it should have inherited, and **nothing raises**, because `ts`
398/// is well inside the hot log and reach was asked about `ts`.
399///
400/// That is not new and not this function's: it is the same degradation
401/// `graph::lineage`'s module docs describe for `links_cut`, reached from the
402/// fold side instead of the projection side, and it is bounded to keys an
403/// ancestor churned after forking. Passing `archive_path` closes it — the cold
404/// arm unions both files before it cuts.
405///
406/// [D-259]: ../../docs/architecture/s13-decision-register.md#d-259
407pub async fn reconstruct_on(
408 conn: &libsql::Connection,
409 ts: &str,
410 branch: &str,
411 archive_path: Option<&Path>,
412 snapshots_dir: Option<&Path>,
413) -> Result<MaterializedState> {
414 let (shape, ancestry) = crate::graph::lineage::resolve_for(conn, Some(branch)).await?;
415 if !shape.binds_branch() {
416 // `Trunk`: one lineage, so every belief in the ledger is this one's and
417 // the resolution is the identity. Delegated rather than run through the
418 // machinery below so that an unforked database pays nothing at all for
419 // this function existing.
420 return reconstruct(conn, ts, archive_path, snapshots_dir).await;
421 }
422
423 // `TrunkOnForked` resolves to its own rows and `resolve_for` leaves its
424 // ancestry empty, because the SQL form for that shape emits no `lineage`
425 // relation to fill. Here the one-row ancestry is what expresses "its own
426 // rows", so it is written out rather than special-cased below.
427 let ancestry = if ancestry.is_empty() {
428 vec![Ancestor {
429 branch_id: branch.to_string(),
430 dist: 0,
431 cutoff: None,
432 }]
433 } else {
434 ancestry
435 };
436
437 // `?1` is the instant; the ancestry block follows it, which is the same
438 // "fixed slots first, ancestry last" layout every read path uses.
439 const ANCESTRY_SLOT: usize = 2;
440 let mut params: Vec<libsql::Value> = vec![ts.into()];
441 params.extend(crate::graph::lineage::ancestry_params(&ancestry));
442 let rows = ancestry.len();
443
444 let state = match hot_log_reach(conn, ts, archive_path).await? {
445 HotLogReach::Covers => {
446 let delta =
447 fold_delta(conn, &bounded_hot_fold(rows, ANCESTRY_SLOT), params.clone()).await?;
448 Some(delta.apply_to(MaterializedState::empty(ts), ts))
449 }
450 HotLogReach::PredatesRecordedHistory => {
451 let mut state = MaterializedState::empty(ts);
452 state.predates_recorded_history = true;
453 Some(state)
454 }
455 HotLogReach::NeedsArchive => None,
456 };
457
458 let mut out = match state {
459 Some(s) => s,
460 // The cold arm, spelled the way `reconstruct` spells it: the same
461 // refusals, the same ATTACH/DETACH pairing, and the same reason the
462 // hint is computed inside the error arms rather than before them.
463 None => {
464 let archive = match archive_path {
465 Some(p) => p,
466 None => {
467 return Err(DbError::ReplayCorrupt {
468 seq: 0,
469 reason: format!(
470 "state at {ts} predates the hot log and no archive path was given; {}",
471 archive_hint(conn).await
472 ),
473 })
474 }
475 };
476 if !crate::temporal::archive::archive_present(archive) {
477 return Err(DbError::ReplayCorrupt {
478 seq: 0,
479 reason: format!(
480 "archive database file {archive:?} does not exist; {}",
481 archive_hint(conn).await
482 ),
483 });
484 }
485
486 detach_stale_cold(conn).await;
487 conn.execute(
488 "ATTACH DATABASE ?1 AS cold",
489 libsql::params![archive.to_string_lossy().as_ref()],
490 )
491 .await?;
492 let cold_shape = cold_lineage(conn).await;
493 let result = fold_delta(
494 conn,
495 &bounded_cold_fold(cold_shape, rows, ANCESTRY_SLOT),
496 params,
497 )
498 .await;
499 if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
500 tracing::warn!("reconstruct_on: failed to DETACH cold database: {e}");
501 }
502 result?.apply_to(MaterializedState::empty(ts), ts)
503 }
504 };
505
506 // The fold has already dropped invisible lineages and cut the visible ones.
507 // What is left is the distance rule, which is a function of the rows in
508 // hand and is therefore Rust rather than a second window in the SQL.
509 out.edges = resolve_beliefs(&out.edges, &ancestry);
510 Ok(out)
511}
512
513/// The newest log payload shape this build writes and the highest it can read.
514///
515/// Kept beside the folds because they are the only readers, and bumped in step
516/// with the `json_object('v', …)` literals in `schema::ddl` — a test asserts the
517/// two agree, since nothing else would notice them drifting apart.
518pub(crate) const PAYLOAD_VERSION: u8 = 2;
519
520/// Every fold partitions on `(table_name, entity_id)`, never `entity_id` alone.
521///
522/// The two namespaces are not disjoint and nothing makes them so. A link's
523/// `entity_id` is the synthetic `source|target|type|valid_from`; a concept's is
524/// whatever the caller passed, unvalidated (defect AD). Partitioning on the id
525/// alone therefore lets a concept and a link contend for one window, and
526/// `ROW_NUMBER() = 1` hands the whole partition to whichever has the greater
527/// `seq_id` — so the loser vanishes from the reconstruction while sitting
528/// plainly in both `concepts` and `transaction_log`. Silent, and on the read
529/// path the ledger exists to make trustworthy.
530///
531/// Validating identifiers would make the collision unreachable and is the
532/// durable fix; this makes it harmless regardless, which is the property worth
533/// having at the fold. `table_name` leads the partition because the log is
534/// already indexed on `entity_id` and the discriminator is two values wide.
535const HOT_FOLD: &str = r#"
536 SELECT seq_id, table_name, entity_id, operation, payload, branch_id
537 FROM (
538 SELECT seq_id, table_name, entity_id, operation, payload, branch_id,
539 ROW_NUMBER() OVER (PARTITION BY table_name, entity_id, branch_id ORDER BY seq_id DESC) as rn
540 FROM transaction_log
541 WHERE recorded_at <= ?1
542 ) WHERE rn = 1
543"#;
544
545/// Fold over hot and cold together (§5.5, D-026). Requires `cold` to be ATTACHed.
546///
547/// The hot entry wins for entities present in both files because its `seq_id` is
548/// greater — the same last-writer-wins rule as snapshot composition.
549fn cold_fold(cold_lineage: ColdLineage) -> String {
550 format!(
551 r#"
552 SELECT seq_id, table_name, entity_id, operation, payload, branch_id
553 FROM (
554 SELECT seq_id, table_name, entity_id, operation, payload, branch_id,
555 ROW_NUMBER() OVER (PARTITION BY table_name, entity_id, branch_id ORDER BY seq_id DESC) as rn
556 FROM (
557 SELECT seq_id, table_name, entity_id, operation, payload, recorded_at, branch_id FROM main.transaction_log
558 UNION ALL
559 SELECT seq_id, table_name, entity_id, operation, payload, recorded_at, {cold} FROM cold.transaction_log
560 ) WHERE recorded_at <= ?1
561 ) WHERE rn = 1
562"#,
563 cold = cold_lineage.projection()
564 )
565}
566
567/// The hot fold, narrowed to one lineage's view (0.15.17, [D-259]).
568///
569/// [`HOT_FOLD`] with the ancestry joined in: the `JOIN` drops every lineage the
570/// reader cannot see, and `recorded_at <= g.cutoff` bounds each ancestor at its
571/// own fork point. `?1` is the instant, and the ancestry binds from
572/// `first_slot` — three placeholders per ancestor, the same block
573/// [`crate::graph::lineage::ancestry_values`] emits for the read path.
574///
575/// **The cutoff is inside the window's input, not applied to its output**, and
576/// that is the whole reason this is a fold rather than a filter over
577/// [`reconstruct`]'s answer. If an ancestor wrote a row and then superseded it
578/// after the fork, the reader must see the *earlier* row — so the cut has to
579/// happen before `ROW_NUMBER` picks a winner. A predicate over a finished
580/// `MaterializedState` cannot express that, and would return nothing for that
581/// key instead of returning the row the lineage actually inherited.
582fn bounded_hot_fold(rows: usize, first_slot: usize) -> String {
583 format!(
584 r#"WITH {}
585 SELECT seq_id, table_name, entity_id, operation, payload, branch_id
586 FROM (
587 SELECT tl.seq_id, tl.table_name, tl.entity_id, tl.operation, tl.payload, tl.branch_id,
588 ROW_NUMBER() OVER (PARTITION BY tl.table_name, tl.entity_id, tl.branch_id ORDER BY tl.seq_id DESC) as rn
589 FROM transaction_log tl
590 JOIN lineage g ON g.branch_id = tl.branch_id
591 WHERE tl.recorded_at <= ?1 AND (g.cutoff IS NULL OR tl.recorded_at <= g.cutoff)
592 ) WHERE rn = 1
593"#,
594 crate::graph::lineage::ancestry_values(rows, first_slot, "")
595 )
596}
597
598/// [`bounded_hot_fold`] over hot and cold together. Requires `cold` ATTACHed.
599///
600/// The union is inside the cutoff filter rather than outside it, for the same
601/// reason the plain [`cold_fold`] puts `recorded_at <= ?1` there: a row's file
602/// is not a fact about its lineage, and a bound applied to one file and not the
603/// other would give a different answer depending on when the archive ran.
604fn bounded_cold_fold(cold_lineage: ColdLineage, rows: usize, first_slot: usize) -> String {
605 format!(
606 r#"WITH {lineage}
607 SELECT seq_id, table_name, entity_id, operation, payload, branch_id
608 FROM (
609 SELECT u.seq_id, u.table_name, u.entity_id, u.operation, u.payload, u.branch_id,
610 ROW_NUMBER() OVER (PARTITION BY u.table_name, u.entity_id, u.branch_id ORDER BY u.seq_id DESC) as rn
611 FROM (
612 SELECT tl.seq_id, tl.table_name, tl.entity_id, tl.operation, tl.payload, tl.recorded_at, tl.branch_id
613 FROM main.transaction_log tl
614 UNION ALL
615 SELECT tl.seq_id, tl.table_name, tl.entity_id, tl.operation, tl.payload, tl.recorded_at, {cold}
616 FROM cold.transaction_log tl
617 ) u
618 -- Every column qualified: `u` and `lineage` both carry `branch_id`, and
619 -- an unqualified one here is `ambiguous column name` at runtime rather
620 -- than a compile error. The hot fold does not need this because its
621 -- inner projection names one source.
622 JOIN lineage g ON g.branch_id = u.branch_id
623 WHERE u.recorded_at <= ?1 AND (g.cutoff IS NULL OR u.recorded_at <= g.cutoff)
624 ) WHERE rn = 1
625"#,
626 lineage = crate::graph::lineage::ancestry_values(rows, first_slot, ""),
627 cold = cold_lineage.projection()
628 )
629}
630
631/// Fold over the hot log *above a snapshot anchor* (§5.5, [D-049]).
632///
633/// `seq_id > ?2` is an inequality, and deliberately so: the hot log's ids have
634/// gaps, so successor arithmetic (`seq_id = :anchor + 1`) would stop at the
635/// first one and silently truncate the delta. This is the first anchored fold
636/// in the crate, which makes it the first code [D-024]'s rule has ever bound —
637/// before this the rule was vacuous, not satisfied.
638///
639/// **The gaps come from the archive, not from rollbacks** (0.15.19, review
640/// C-17). This comment used to name a rolled-back transaction as the source,
641/// which is [D-024]'s stated mechanism and is the one thing [D-049] measured
642/// and disproved: `sqlite_sequence` is written *inside* the transaction, so a
643/// rollback takes the allocation with it and the number is reused. What does
644/// leave gaps is `temporal::archive`, which deletes superseded rows from
645/// `transaction_log` — scattered through the sequence rather than forming a
646/// prefix, which is exactly the shape successor arithmetic cannot walk. Same
647/// inequality, and now the same reason the register gives for it; the
648/// gap-tolerance test builds its state by deleting a log row inside a session
649/// marker, which is the real mechanism and not the retracted one.
650///
651/// [D-049]: ../../docs/architecture/s13-decision-register.md#d-049
652/// [D-024]: ../../docs/architecture/s13-decision-register.md#d-024
653const ANCHORED_HOT_FOLD: &str = r#"
654 SELECT seq_id, table_name, entity_id, operation, payload, branch_id
655 FROM (
656 SELECT seq_id, table_name, entity_id, operation, payload, branch_id,
657 ROW_NUMBER() OVER (PARTITION BY table_name, entity_id, branch_id ORDER BY seq_id DESC) as rn
658 FROM transaction_log
659 WHERE recorded_at <= ?1 AND seq_id > ?2
660 ) WHERE rn = 1
661"#;
662
663/// Fold over hot **and cold** above a snapshot anchor (§5.5, 0.5.5).
664///
665/// The union is what lets composition survive an archive. Rows keep their
666/// `seq_id` when they move to cold — the cold schema declares a plain `INTEGER
667/// PRIMARY KEY` precisely so history is not renumbered — so `seq_id > ?2`
668/// partitions the two files consistently and last-writer-wins across them by the
669/// same rule the unanchored folds use.
670fn anchored_cold_fold(cold_lineage: ColdLineage) -> String {
671 format!(
672 r#"
673 SELECT seq_id, table_name, entity_id, operation, payload, branch_id
674 FROM (
675 SELECT seq_id, table_name, entity_id, operation, payload, branch_id,
676 ROW_NUMBER() OVER (PARTITION BY table_name, entity_id, branch_id ORDER BY seq_id DESC) as rn
677 FROM (
678 SELECT seq_id, table_name, entity_id, operation, payload, recorded_at, branch_id FROM main.transaction_log
679 UNION ALL
680 SELECT seq_id, table_name, entity_id, operation, payload, recorded_at, {cold} FROM cold.transaction_log
681 ) WHERE recorded_at <= ?1 AND seq_id > ?2
682 ) WHERE rn = 1
683"#,
684 cold = cold_lineage.projection()
685 )
686}
687
688/// Whether an attached cold file predates the lineage column (§15.2, v12).
689///
690/// Cold files are **read-only media as far as the read path is concerned**.
691/// They get moved (D-026), they can sit on a share, and a fold that upgraded
692/// one in order to read it would be a write on a path callers have every reason
693/// to believe is a read. So the shape is detected and tolerated, never
694/// corrected: the archive *writer* upgrades, and only inside its own
695/// transaction.
696///
697/// Detection is column presence rather than a version stamp, because a cold
698/// file carries no version anyone can trust — it is a file that has been moved.
699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700enum ColdLineage {
701 /// v12 or later: the file stamps its own rows.
702 Stamped,
703 /// Pre-v12: every row in it was written when only the trunk existed.
704 PreV12,
705}
706
707impl ColdLineage {
708 fn projection(self) -> &'static str {
709 match self {
710 ColdLineage::Stamped => "branch_id",
711 // A literal, not a default: rows written before lineage existed
712 // *were* trunk rows, and saying so is a fact about them rather than
713 // a fallback.
714 ColdLineage::PreV12 => "'main' AS branch_id",
715 }
716 }
717}
718
719/// Ask the attached cold file whether it carries `transaction_log.branch_id`.
720///
721/// Returns [`ColdLineage::PreV12`] when the pragma cannot be read at all. That
722/// is the conservative direction: a fold that guesses "stamped" against a v11
723/// file fails with `no such column`, while a fold that guesses "pre-v12"
724/// against a v12 file reads rows it can still fold — it would mislabel a
725/// branch's rows as trunk, which is why the guess is never made when the pragma
726/// answers.
727async fn cold_lineage(conn: &libsql::Connection) -> ColdLineage {
728 let Ok(mut rows) = conn
729 .query("PRAGMA cold.table_info(transaction_log)", ())
730 .await
731 else {
732 return ColdLineage::PreV12;
733 };
734 while let Ok(Some(row)) = rows.next().await {
735 if row.get::<String>(1).is_ok_and(|name| name == "branch_id") {
736 return ColdLineage::Stamped;
737 }
738 }
739 ColdLineage::PreV12
740}
741
742/// The winning log rows for one fold, before they are applied to a base state.
743///
744/// Absence and disappearance are different facts, and a merge is where the
745/// difference starts to matter. A full fold from nothing can treat "this entity
746/// went away" and "there is no row for it" identically — both end as absence.
747/// Composed onto a snapshot they are opposites: a disappearance must *remove*
748/// the entity the snapshot carries, and skipping it leaves the snapshot's stale
749/// row standing as though nothing had happened. So they are collected rather
750/// than dropped, and the full fold applies them to an empty base, which keeps
751/// one code path for both cases (D-049).
752///
753/// **There is one such set, not two (D-072).** It used to carry `edges_gone`
754/// beside `concepts_gone`, and both were populated only from the `'D'` branch of
755/// [`fold_delta`] — so when that branch became an error, `edges_gone` was left
756/// reachable by nothing. Closing one unreachable path by opening another is not
757/// a fix, so it went too.
758///
759/// The asymmetry is real and worth stating, because "concepts can vanish and
760/// edges cannot" looks like an oversight until you follow it:
761///
762/// * A **concept** disappears by being *retired*, which writes a `'U'` row whose
763/// payload has `retired = 1`. That is a genuine removal from a composed state
764/// and `concepts_gone` carries it.
765/// * An **edge** never disappears. It is retired by asserting a successor over
766/// the same interval key — same `source|target|type|valid_from`, later
767/// `recorded_at` — so the log row is an `'I'` under the *same* `entity_id`, and
768/// last-writer-wins in [`Self::apply_to`] replaces the tuple in place. There is
769/// nothing to remove because nothing left; the interval simply closed.
770///
771/// That is Doctrine III showing through: an edge assertion is immutable and
772/// superseded, never deleted.
773#[derive(Default)]
774struct Delta {
775 concepts: HashMap<String, NodeAttributes>,
776 /// Keyed by `entity_id` **and** `branch_id` — see [`EdgeBelief::belief_key`].
777 ///
778 /// `entity_id` alone was the collapse [D-221](../../docs/architecture/s13-decision-register.md#d-221)
779 /// records: it is the edge key, shared across lineages by design, so an
780 /// ancestor's assertion and a descendant's correction landed in one slot.
781 edges: HashMap<String, EdgeBelief>,
782 /// Concepts retired as of the fold's instant. See the type's note for why
783 /// there is no edge equivalent.
784 concepts_gone: HashSet<String>,
785 max_seq: i64,
786}
787
788/// Release a `cold` handle left attached by an earlier call (§5.5, D-044).
789///
790/// Both ATTACH sites pair with an unconditional DETACH on the way out, so in
791/// the normal course this finds nothing and the statement fails harmlessly with
792/// "no such database: cold". It exists for the case the pairing cannot cover: a
793/// panic unwinding between the two, which skips the DETACH no matter which exit
794/// path the `Result` would have taken.
795///
796/// A `Drop` guard is the reflex here and does not work — `execute` is `async`,
797/// and a `Drop` impl cannot await, so it would build a future, discard it, and
798/// leave the handle attached while looking like it had cleaned up. Recovering
799/// on the way *in* needs no destructor, works regardless of how the handle
800/// leaked, and turns permanent poisoning of the connection into one failed
801/// statement nobody sees.
802pub(crate) async fn detach_stale_cold(conn: &libsql::Connection) {
803 let _ = conn.execute("DETACH DATABASE cold", ()).await;
804}
805
806/// Reconstruct database state as believed at past instant `ts` using window-function log fold (§5.5, D-026).
807///
808/// When `ts` predates the hot log's horizon the cold database is ATTACHed for
809/// exactly one fold and DETACHed unconditionally on the way out, error paths
810/// included. ATTACH is not transactional and survives ROLLBACK, so a handle
811/// leaked by an early return would make every later `reconstruct` *and* every
812/// later `archive` fail with "database cold is already in use" — one corrupt
813/// payload would permanently poison the connection. This is the same failure
814/// mode `archive()` carries a note about, and the two now share a shape.
815/// Snapshot composition (§5.5, D-049) applies when `snapshots_dir` holds a
816/// snapshot at or before `ts` and no archive database exists — see
817/// `snapshot_anchor` for why archiving disables it. Otherwise the fold runs
818/// from genesis, which is correct and costs what the whole log costs.
819pub async fn reconstruct(
820 conn: &libsql::Connection,
821 ts: &str,
822 archive_path: Option<&Path>,
823 snapshots_dir: Option<&Path>,
824) -> Result<MaterializedState> {
825 let base = snapshot_anchor(snapshots_dir, ts).await;
826 reconstruct_from(conn, ts, archive_path, base).await
827}
828
829/// [`reconstruct`] with the anchor chosen by the caller (0.15.19, review C-18).
830///
831/// The whole of `reconstruct` except the one line that picks a base, split out
832/// because [`verify_last_link`] needs to compose onto a **named** snapshot
833/// rather than onto whichever one is newest at `ts` — and picking it is the
834/// only thing the two do differently. Written as a split rather than as a
835/// second copy of the ATTACH bracket for the reason the module keeps
836/// re-learning: a read spelled twice drifts, and the half nobody calls is the
837/// half that drifts first ([D-227]).
838///
839/// `base` of `None` is a fold from genesis.
840///
841/// [D-227]: ../../docs/architecture/s13-decision-register.md#d-227
842async fn reconstruct_from(
843 conn: &libsql::Connection,
844 ts: &str,
845 archive_path: Option<&Path>,
846 base: Option<MaterializedState>,
847) -> Result<MaterializedState> {
848 match hot_log_reach(conn, ts, archive_path).await? {
849 HotLogReach::Covers => {
850 if let Some(base) = base {
851 let anchor = base.seq_anchor;
852 let delta =
853 fold_delta(conn, ANCHORED_HOT_FOLD, libsql::params![ts, anchor]).await?;
854 return Ok(delta.apply_to(base, ts));
855 }
856 return fold(conn, ts, HOT_FOLD).await;
857 }
858 HotLogReach::PredatesRecordedHistory => {
859 // Nothing had been recorded by `ts`, and nothing has been removed
860 // from the log, so there is no history anywhere to go looking for.
861 // The empty state is the answer, flagged so a caller can tell it
862 // from a state that is empty because everything was retired.
863 let mut state = MaterializedState::empty(ts);
864 state.predates_recorded_history = true;
865 return Ok(state);
866 }
867 HotLogReach::NeedsArchive => {}
868 }
869
870 // The delta lives in the cold archive database. Both ways of failing to
871 // reach it carry `archive_hint`, which is the message the rejected hot-side
872 // marker was wanted for — see that function for why no marker is needed.
873 //
874 // **Computed inside the error arms, not before them.** `NeedsArchive` is the
875 // ordinary path to a cold fold and usually succeeds; an eager hint would put
876 // an extra query on it for a string almost every caller discards. An
877 // injection probe caught this — `a_failed_cold_reconstruct_still_detaches`
878 // reached the hint on a run that raised nothing from here.
879 let archive = match archive_path {
880 Some(p) => p,
881 None => {
882 return Err(DbError::ReplayCorrupt {
883 seq: 0,
884 reason: format!(
885 "state at {ts} predates the hot log and no archive path was given; {}",
886 archive_hint(conn).await
887 ),
888 })
889 }
890 };
891 if !crate::temporal::archive::archive_present(archive) {
892 return Err(DbError::ReplayCorrupt {
893 seq: 0,
894 reason: format!(
895 "archive database file {archive:?} does not exist; {}",
896 archive_hint(conn).await
897 ),
898 });
899 }
900
901 detach_stale_cold(conn).await;
902
903 // Bound, not interpolated: a path is caller data, and hand-rolled quote
904 // doubling is a worse version of what the driver already does correctly.
905 conn.execute(
906 "ATTACH DATABASE ?1 AS cold",
907 libsql::params![archive.to_string_lossy().as_ref()],
908 )
909 .await?;
910
911 // Asked once, after the ATTACH and before either fold, because both arms
912 // need it and the answer cannot change while we hold the handle.
913 let cold_shape = cold_lineage(conn).await;
914
915 // Composition works across the archive boundary because the anchored fold
916 // unions both files; before 0.5.5 it was refused here rather than made to
917 // work, and the refusal was the only thing keeping the answer right.
918 let result = match base {
919 Some(base) => {
920 let anchor = base.seq_anchor;
921 fold_delta(
922 conn,
923 &anchored_cold_fold(cold_shape),
924 libsql::params![ts, anchor],
925 )
926 .await
927 .map(|delta| delta.apply_to(base, ts))
928 }
929 None => fold(conn, ts, &cold_fold(cold_shape)).await,
930 };
931
932 // Unconditional: see the ATTACH note above.
933 if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
934 tracing::warn!("reconstruct: failed to DETACH cold database: {e}");
935 }
936
937 result
938}
939
940/// Fold from genesis and compare against the composed answer (§5.5, T5.3,
941/// D-092).
942///
943/// # The problem this exists for
944///
945/// [`crate::temporal::save_snapshot`] is written by `write_final`, which calls
946/// [`reconstruct`] — and `reconstruct` composes onto the *previous* snapshot
947/// whenever one is usable. So snapshot *n* is derived from snapshot *n−1*, and
948/// there is no periodic full fold anywhere in the chain. An error introduced at
949/// any link is copied forward indefinitely, and every subsequent read agrees
950/// with it, because they are all reading the same descendant.
951///
952/// The project's own open item names the difficulty honestly: a full fold is
953/// exactly the cost snapshots exist to avoid, so this cannot run on every read.
954/// It is a **scheduling** problem, and this function is the thing to schedule.
955///
956/// # It reports; it does not repair
957///
958/// Deliberate, and not merely conservative. Under [Doctrine VI] a snapshot is
959/// derivative and disposable, so the repair is *delete the snapshots* — one
960/// line, available to the caller, and correct without this function's help.
961/// What the caller cannot get for themselves is the knowledge that the chain
962/// diverged, and silently rewriting the file would destroy the only evidence of
963/// a bug in composition. A divergence here is not a corrupt database; it is a
964/// wrong **cache**, and it means composition has a defect worth finding.
965///
966/// # Cost
967///
968/// One fold from genesis over the whole log, plus one composed reconstruction.
969/// That is the expensive path by construction — see [`crate::Database::
970/// verify_snapshot_chain`] for the handle-level entry point and the note on
971/// when to run it.
972///
973/// [Doctrine VI]: ../../../docs/architecture/s0-s3-foundations.md#doctrine-vi
974pub async fn verify_snapshot_chain(
975 conn: &libsql::Connection,
976 ts: &str,
977 archive_path: Option<&Path>,
978 snapshots_dir: &Path,
979) -> Result<ChainCheck> {
980 // The composed answer: what every reader gets today.
981 let composed = reconstruct(conn, ts, archive_path, Some(snapshots_dir)).await?;
982 // The authority: the same instant, with the snapshot directory withheld, so
983 // `snapshot_anchor` finds nothing and the fold runs from genesis. Passing
984 // `None` is what makes this an independent computation rather than a second
985 // call to the thing under test.
986 let folded = reconstruct(conn, ts, archive_path, None).await?;
987 Ok(ChainCheck::compare(ts, &composed, &folded))
988}
989
990/// Check the **newest link** of the snapshot chain (0.15.19, review C-18).
991///
992/// # What it is for
993///
994/// [`verify_snapshot_chain`] is right and unaffordable: two folds, one of them
995/// from genesis over the whole log. Its own rustdoc calls scheduling it the
996/// open problem, and nothing schedules it, so in practice a composition defect
997/// is copied forward with nothing looking. This is the cheap half of the same
998/// idea — re-derive snapshot *n* from snapshot *n−1* and compare — which costs
999/// one anchored delta and can therefore run whenever a snapshot is written.
1000/// The snapshot cadence does exactly that and logs a divergence.
1001///
1002/// `Ok(None)` when there are not two snapshots to compare, which is a young
1003/// database and not a fault.
1004///
1005/// # What it catches, and what it does not
1006///
1007/// It catches a defect **as it is introduced**: a snapshot that does not
1008/// survive its own serialize/load round trip, an `apply_to` that composes
1009/// differently from how it was composed, or a delta that has stopped covering
1010/// the window between the two anchors. That last one is the practical case —
1011/// rows archived out of the hot log between the two writes, with no archive
1012/// path given here to fold them back in.
1013///
1014/// It does **not** catch a defect inherited from further back. If the chain
1015/// went wrong at link three and every link since has composed faithfully onto
1016/// it, this agrees at every one of them, because both sides descend from the
1017/// same wrong state. Only a genesis fold answers that, which is what
1018/// [`verify_snapshot_chain`] is and why it stays.
1019///
1020/// Pass `archive_path` whenever there is an archive. Without it the delta is
1021/// folded from the hot log alone, and a link spanning an archive session will
1022/// disagree for a reason that is not a defect.
1023///
1024/// # It reports; it does not repair
1025///
1026/// [`verify_snapshot_chain`]'s reasoning, unchanged: under [Doctrine VI] a
1027/// snapshot is derivative, so the repair is *delete the snapshots*, which is
1028/// one line and the caller's to run. Rewriting the file here would destroy the
1029/// only evidence that composition has a bug.
1030///
1031/// [Doctrine VI]: ../../../docs/architecture/s0-s3-foundations.md#doctrine-vi
1032pub async fn verify_last_link(
1033 conn: &libsql::Connection,
1034 archive_path: Option<&Path>,
1035 snapshots_dir: &Path,
1036) -> Result<Option<ChainCheck>> {
1037 let Some((base, newest)) = two_newest_snapshots(snapshots_dir).await else {
1038 return Ok(None);
1039 };
1040 let ts = newest.timestamp.clone();
1041 let redone = reconstruct_from(conn, &ts, archive_path, Some(base)).await?;
1042 // `newest` is the composed answer — the file a reader would be handed —
1043 // and `redone` is the independent one, which is the way round
1044 // `ChainCheck`'s two families of field are named.
1045 Ok(Some(ChainCheck::compare(&ts, &newest, &redone)))
1046}
1047
1048/// The result of a [`verify_snapshot_chain`] cross-check.
1049///
1050/// Carries the disagreements rather than a bool, because "the chain diverged" is
1051/// not actionable and "these three concepts differ, and this edge is present in
1052/// one and not the other" is. Bounded — see [`ChainCheck::SAMPLE_LIMIT`] — since
1053/// a chain that went wrong early can disagree about every row, and a report that
1054/// is the size of the database is one nobody reads.
1055#[derive(Debug, Clone)]
1056#[non_exhaustive]
1057pub struct ChainCheck {
1058 pub timestamp: String,
1059 /// `seq_anchor` of the composed answer and of the genesis fold. These
1060 /// **may legitimately differ**: the composed answer anchors at the snapshot
1061 /// it started from plus its delta, and the fold anchors at the newest row it
1062 /// saw. Reported for diagnosis, never compared.
1063 pub composed_anchor: i64,
1064 pub folded_anchor: i64,
1065 pub composed_concepts: usize,
1066 pub folded_concepts: usize,
1067 pub composed_edges: usize,
1068 pub folded_edges: usize,
1069 /// Concept ids present in one and not the other, or whose attributes differ.
1070 pub concept_disagreements: Vec<String>,
1071 /// Edge keys present in one and not the other.
1072 pub edge_disagreements: Vec<String>,
1073 /// True when either list was truncated at [`ChainCheck::SAMPLE_LIMIT`].
1074 pub truncated: bool,
1075}
1076
1077impl ChainCheck {
1078 /// How many disagreements of each kind to carry.
1079 pub const SAMPLE_LIMIT: usize = 32;
1080
1081 pub fn diverged(&self) -> bool {
1082 !self.concept_disagreements.is_empty() || !self.edge_disagreements.is_empty()
1083 }
1084
1085 fn compare(ts: &str, composed: &MaterializedState, folded: &MaterializedState) -> Self {
1086 let mut concept_disagreements = Vec::new();
1087 let mut truncated = false;
1088
1089 let mut ids: Vec<&String> = composed.concepts.keys().collect();
1090 ids.extend(folded.concepts.keys());
1091 ids.sort_unstable();
1092 ids.dedup();
1093 for id in ids {
1094 let a = composed.concepts.get(id);
1095 let b = folded.concepts.get(id);
1096 let same = match (a, b) {
1097 (Some(a), Some(b)) => {
1098 a.title == b.title
1099 && a.content == b.content
1100 && a.embedding_model == b.embedding_model
1101 }
1102 (None, None) => true,
1103 _ => false,
1104 };
1105 if !same {
1106 if concept_disagreements.len() < Self::SAMPLE_LIMIT {
1107 concept_disagreements.push(id.clone());
1108 } else {
1109 truncated = true;
1110 }
1111 }
1112 }
1113
1114 // Edges are a `Vec` of tuples with no declared order, so the comparison
1115 // is on the set. Comparing the vectors directly would report a
1116 // divergence for a reordering, which is not one — and that false
1117 // positive is worse than useless here, because the whole point of this
1118 // check is that a report means "go and find the bug".
1119 // `valid_to` is in the key as well as the identity, because a
1120 // divergence in *what* the two paths believe is exactly what this
1121 // reports — two rows agreeing on the edge and the lineage and
1122 // disagreeing on the interval are a disagreement, not one row.
1123 let key = |e: &EdgeBelief| format!("{}|{}", e.belief_key(), e.valid_to);
1124 let ca: HashSet<String> = composed.edges.iter().map(key).collect();
1125 let fa: HashSet<String> = folded.edges.iter().map(key).collect();
1126 let mut edge_disagreements: Vec<String> = ca.symmetric_difference(&fa).cloned().collect();
1127 edge_disagreements.sort_unstable();
1128 if edge_disagreements.len() > Self::SAMPLE_LIMIT {
1129 edge_disagreements.truncate(Self::SAMPLE_LIMIT);
1130 truncated = true;
1131 }
1132
1133 Self {
1134 timestamp: ts.to_string(),
1135 composed_anchor: composed.seq_anchor,
1136 folded_anchor: folded.seq_anchor,
1137 composed_concepts: composed.concepts.len(),
1138 folded_concepts: folded.concepts.len(),
1139 composed_edges: ca.len(),
1140 folded_edges: fa.len(),
1141 concept_disagreements,
1142 edge_disagreements,
1143 truncated,
1144 }
1145 }
1146}
1147
1148impl std::fmt::Display for ChainCheck {
1149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1150 if !self.diverged() {
1151 return write!(
1152 f,
1153 "snapshot chain agrees with a genesis fold at {}: {} concepts, {} edges",
1154 self.timestamp, self.folded_concepts, self.folded_edges
1155 );
1156 }
1157 write!(
1158 f,
1159 "snapshot chain DIVERGED at {}: composed {} concepts / {} edges, \
1160 genesis fold {} concepts / {} edges; {} concept and {} edge \
1161 disagreements{}. The snapshots are a wrong cache, not a corrupt \
1162 ledger — deleting the snapshot directory restores correctness and \
1163 loses only speed (Doctrine VI). concepts: {:?} edges: {:?}",
1164 self.timestamp,
1165 self.composed_concepts,
1166 self.composed_edges,
1167 self.folded_concepts,
1168 self.folded_edges,
1169 self.concept_disagreements.len(),
1170 self.edge_disagreements.len(),
1171 if self.truncated { " (truncated)" } else { "" },
1172 self.concept_disagreements,
1173 self.edge_disagreements,
1174 )
1175 }
1176}
1177
1178/// The newest usable snapshot at or before `ts`, or `None` to fold from genesis.
1179///
1180/// **Composition used to be disabled once an archive database existed, and as of
1181/// 0.5.5 it is not.** The reason for the refusal was real: `LOG_ARCHIVABLE`
1182/// (§5.7) removes superseded rows scattered through the sequence, so a row above
1183/// the anchor and at or before `ts` could be in cold while a newer row for the
1184/// same entity — recorded *after* `ts`, invisible to the fold — kept it out of
1185/// the hot log. The delta missed it and the snapshot answered with a stale
1186/// value. The fix is the one that note named: the cold log is now in the delta,
1187/// via [`ANCHORED_COLD_FOLD`], so the archived row is visible again and there is
1188/// nothing left to refuse.
1189///
1190/// Selection loads candidates newest-first and stops at the first whose
1191/// timestamp is at or before `ts`, so the common case — `reconstruct(now)` —
1192/// reads exactly one file. A snapshot this build cannot read
1193/// ([`DbError::SnapshotIncompatible`], D-043) is skipped, not raised: an
1194/// incompatible snapshot is an ordinary consequence of upgrading, and the whole
1195/// point of distinguishing it from corruption is that the answer is to carry on
1196/// without it.
1197///
1198/// # It runs on a blocking thread, and a lost one costs speed only (0.13.11, W8.1, D-184)
1199///
1200/// The scan is a directory listing plus one or more full
1201/// [`load_snapshot`](super::snapshot::load_snapshot) calls — decompression and
1202/// bincode over the whole state, on a worker that has other tasks waiting. The
1203/// *whole scan* is offloaded rather than each file, because the loop is
1204/// sequential by construction (it stops at the first usable file) and a hop per
1205/// candidate would add scheduling to a path whose common case reads exactly one.
1206///
1207/// A [`tokio::task::JoinError`] means the loader panicked, and the answer is the
1208/// same one this function already gives for every other kind of unusable file:
1209/// `None`, and fold from genesis. That is not leniency, it is what a snapshot
1210/// *is* — derivative and disposable under [Doctrine VI], so the cost of ignoring
1211/// one is a slower reconstruction and never a wrong one. It is also a real
1212/// improvement over the previous arrangement: inline, a panic in the loader
1213/// unwound through [`reconstruct`] and took the caller's task with it, which
1214/// meant a single corrupt file could stop a process that had a correct answer
1215/// available the whole time. W8.4 fuzzes for exactly those panics; this is what
1216/// happens to the ones it has not found yet.
1217///
1218/// [Doctrine VI]: ../../../docs/architecture/s0-s3-foundations.md#doctrine-vi
1219async fn snapshot_anchor(snapshots_dir: Option<&Path>, ts: &str) -> Option<MaterializedState> {
1220 let dir = snapshots_dir?.to_path_buf();
1221 let ts = ts.to_string();
1222 match tokio::task::spawn_blocking(move || newest_usable_snapshot(&dir, &ts)).await {
1223 Ok(found) => found,
1224 Err(e) => {
1225 tracing::warn!("the snapshot scan did not finish ({e}); folding from genesis");
1226 None
1227 }
1228 }
1229}
1230
1231/// The blocking half of [`snapshot_anchor`]: read the directory, load
1232/// newest-first, stop at the first snapshot at or before `ts`.
1233fn newest_usable_snapshot(dir: &Path, ts: &str) -> Option<MaterializedState> {
1234 let mut candidates: Vec<(i64, PathBuf)> = std::fs::read_dir(dir)
1235 .ok()?
1236 .flatten()
1237 .map(|e| e.path())
1238 .filter_map(|p| super::snapshot::seq_from_filename(&p).map(|s| (s, p)))
1239 .collect();
1240 candidates.sort_by_key(|(seq, _)| std::cmp::Reverse(*seq));
1241
1242 for (_, path) in candidates {
1243 match super::snapshot::load_snapshot(&path) {
1244 // Sound as a string comparison because every timestamp is the
1245 // canonical fixed width (D-029).
1246 Ok(state) if state.timestamp.as_str() <= ts => return Some(state),
1247 Ok(_) => continue,
1248 Err(DbError::SnapshotIncompatible { reason, .. }) => {
1249 tracing::warn!("skipping snapshot {path:?}: {reason}");
1250 continue;
1251 }
1252 Err(e) => {
1253 tracing::warn!("skipping unreadable snapshot {path:?}: {e}");
1254 continue;
1255 }
1256 }
1257 }
1258 None
1259}
1260
1261/// The two newest snapshots on disk, oldest first (0.15.19, review C-18).
1262///
1263/// `None` when there are not two loadable ones with distinct anchors, which is
1264/// the ordinary state of a young database and not a failure. Unreadable and
1265/// incompatible files are skipped with a warning, exactly as
1266/// [`newest_usable_snapshot`] skips them: this is a check, and a check that
1267/// cannot run should not be the thing that raises.
1268///
1269/// Distinct `seq_anchor`s rather than distinct paths, because two files at one
1270/// anchor describe the same instant and comparing them would test the writer's
1271/// determinism, not the chain's composition.
1272async fn two_newest_snapshots(dir: &Path) -> Option<(MaterializedState, MaterializedState)> {
1273 let dir = dir.to_path_buf();
1274 match tokio::task::spawn_blocking(move || {
1275 let mut candidates: Vec<(i64, PathBuf)> = std::fs::read_dir(&dir)
1276 .ok()?
1277 .flatten()
1278 .map(|e| e.path())
1279 .filter_map(|p| super::snapshot::seq_from_filename(&p).map(|s| (s, p)))
1280 .collect();
1281 candidates.sort_by_key(|(seq, _)| std::cmp::Reverse(*seq));
1282
1283 let mut loaded: Vec<MaterializedState> = Vec::with_capacity(2);
1284 for (_, path) in candidates {
1285 match super::snapshot::load_snapshot(&path) {
1286 Ok(state) => {
1287 if loaded.iter().any(|s| s.seq_anchor == state.seq_anchor) {
1288 continue;
1289 }
1290 loaded.push(state);
1291 if loaded.len() == 2 {
1292 break;
1293 }
1294 }
1295 Err(e) => tracing::warn!("skipping snapshot {path:?} for the link check: {e}"),
1296 }
1297 }
1298 let older = loaded.pop()?;
1299 let newer = loaded.pop()?;
1300 Some((older, newer))
1301 })
1302 .await
1303 {
1304 Ok(found) => found,
1305 Err(e) => {
1306 tracing::warn!("the snapshot scan for the link check did not finish ({e})");
1307 None
1308 }
1309 }
1310}
1311
1312/// Where the answer for `ts` lives.
1313///
1314/// Three cases, not two (0.8.0, B5, D-121). This used to be a `bool`, and the
1315/// missing third case is the whole of B5: *below the log's floor* was folded in
1316/// with *the delta is elsewhere*, so a question about a time before the ledger
1317/// started came back as [`DbError::ReplayCorrupt`] — the class meaning the
1318/// ledger is damaged — naming an archive file the caller had never created.
1319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1320enum HotLogReach {
1321 /// The hot log holds everything needed at `ts`. Fold it.
1322 Covers,
1323 /// Nothing had been recorded by `ts`, and nothing has ever been removed
1324 /// from the log, so no other file could hold it either. The empty state is
1325 /// the correct answer, not a failure to find one.
1326 PredatesRecordedHistory,
1327 /// The delta is in the cold archive. If it cannot be reached, that is an
1328 /// error and stays one.
1329 NeedsArchive,
1330}
1331
1332/// Whether the hot log alone can answer for `ts` — a *completeness* test.
1333///
1334/// **This replaces a reach test that was not one (0.5.5).** The previous version
1335/// asked `MIN(recorded_at) <= ts`: whether the hot log stretches back far enough
1336/// to contain `ts`. That is a different question from whether it still contains
1337/// everything needed to answer at `ts`, and `LOG_ARCHIVABLE` (§5.7) is exactly
1338/// what pulls the two apart — it removes *superseded* rows, scattered through
1339/// the sequence rather than forming a prefix. One entity archived and another
1340/// not is enough: the unarchived one keeps `MIN` pointing before the cutoff
1341/// while the archived one's winning row is gone, and the fold silently returns a
1342/// state missing an entity. Measured, not theorised — see
1343/// `reconstructing_before_the_archive_cutoff_keeps_every_entity`.
1344///
1345/// The sound test rests on the one guarantee the archive does make: **the newest
1346/// row per entity is never archivable**, because archivability requires a later
1347/// row to exist. So if `ts` is at or after the newest hot stamp, every entity's
1348/// winning row at `ts` is its newest row overall, and every such row is hot.
1349/// That covers `reconstruct(now)` — the common case, and the case §5.7 designed
1350/// `LOG_ARCHIVABLE` around — and nothing else.
1351///
1352/// Anything earlier goes to the cold file. That is more ATTACHes than the old
1353/// rule performed, and the trade is not close: the old rule was cheaper because
1354/// it was answering a question nobody asked.
1355///
1356/// With no archive database in play the reach test *is* the completeness test —
1357/// nothing has been removed, so the hot log is the whole log — and it is kept,
1358/// because it is also what distinguishes "before recorded history" from "the
1359/// cold file is missing" (D-026).
1360async fn hot_log_reach(
1361 conn: &libsql::Connection,
1362 ts: &str,
1363 archive_path: Option<&Path>,
1364) -> Result<HotLogReach> {
1365 if archive_path.is_some_and(crate::temporal::archive::archive_present) {
1366 // An archive file beside the log is direct evidence that rows may have
1367 // gone, and it is *stronger* evidence than [`hot_log_is_intact`] on one
1368 // case: an empty hot log passes the seq_id test vacuously, so the
1369 // fully-archived database would otherwise report its own emptiness as
1370 // history. It cannot arise from `archive()` itself — the newest row per
1371 // entity always stays — but answering "covered" there would make such a
1372 // file reconstruct to the empty state with no error at all.
1373 return reach_with_rows_removed(conn, ts).await;
1374 }
1375
1376 hot_log_reach_within(conn, ts).await
1377}
1378
1379/// The verdict the **hot file alone** supports (0.15.4, W14.2, review C-2).
1380///
1381/// This is the whole of the reach question minus the one thing an archive path
1382/// adds, and it is a separate function because two callers need exactly it:
1383/// [`hot_log_reach`] when no archive file is present, and
1384/// [`hot_log_answers_for`] on behalf of readers that never had a path to offer.
1385/// Those two used to answer differently — the first on `MIN(recorded_at)`, the
1386/// second on intactness alone — and neither answer was the right one.
1387///
1388/// # Two cases, and the split is intactness rather than the timestamp
1389///
1390/// **Nothing was ever removed.** The hot log is the whole log, so it answers at
1391/// every instant. Above its floor the fold runs; below it, *nothing had been
1392/// recorded yet* is not a failure to find the answer, it is the answer
1393/// ([`HotLogReach::PredatesRecordedHistory`], D-121).
1394///
1395/// **Rows were removed.** Only [`reach_with_rows_removed`]'s rule holds, and it
1396/// is a bound on `ts` from above rather than below. This is the case the old
1397/// `MIN(recorded_at) <= ts` arm got wrong: it asked whether the hot log
1398/// *stretches back* far enough, which is the question 0.5.5 already established
1399/// is not the same as whether it is still *complete*. With no archive path to
1400/// fall through to, a `reconstruct` on an archived database whose cold file was
1401/// not passed folded whatever was left and returned it as history — the silent
1402/// short answer D-189 refused at the two connection-only readers, reachable at
1403/// the one reader that takes a path and was handed `None`. Pinned by
1404/// `reconstructing_without_the_archive_path_refuses_rather_than_folding_a_gap`.
1405async fn hot_log_reach_within(conn: &libsql::Connection, ts: &str) -> Result<HotLogReach> {
1406 // **The cheap arm first, and it is sound before the case split rather than
1407 // inside one of its branches** (0.15.5, W14.4, [D-247]). `MAX <= ts` covers
1408 // under *both* rules: on a log rows were removed from it is
1409 // [`reach_with_rows_removed`]'s argument, and on an intact one
1410 // `MIN <= MAX <= ts` gives the same verdict a step later. So the question
1411 // "were rows removed" — the only expensive one here — does not have to be
1412 // asked at all when the instant is at or after the newest surviving stamp.
1413 //
1414 // Which is where the readers actually ask. `as_of_recorded(now)`,
1415 // `reconstruct(now)` and every read at a recent instant land here, and pay
1416 // one index seek against `idx_txlog_time` instead of a covering scan whose
1417 // cost is the whole hot log. Measured at 500,000 log rows: **3.4 µs against
1418 // 24.2 ms**.
1419 if newest_stamp_covers(conn, ts).await? {
1420 return Ok(HotLogReach::Covers);
1421 }
1422
1423 // Below the newest surviving stamp, and now it matters. An intact log
1424 // answers at every instant; a log rows were taken out of answers at none
1425 // below that stamp, and there is no cheaper exact test than counting —
1426 // `LOG_ARCHIVABLE` removes rows scattered through the sequence, so a gap
1427 // can be anywhere and only `COUNT(*)` finds it (see [`hot_log_is_intact`]).
1428 // This arm is *not* made cheaper by the reordering and pays one extra seek
1429 // for the arm that is: 3.4 µs on top of a scan that starts at 96 µs.
1430 if !hot_log_is_intact(conn).await? {
1431 return Ok(HotLogReach::NeedsArchive);
1432 }
1433
1434 Ok(match oldest_hot_stamp(conn).await? {
1435 // Sound as a string comparison because every recorded_at is the
1436 // canonical fixed width (D-029).
1437 Some(min_ts) if min_ts.as_str() <= ts => HotLogReach::Covers,
1438 // Below the floor of a complete log, or no log at all: either way
1439 // nothing had been recorded by `ts` and the empty state is correct.
1440 _ => HotLogReach::PredatesRecordedHistory,
1441 })
1442}
1443
1444/// The one rule that survives archiving, in the one place both callers read it.
1445///
1446/// `LOG_ARCHIVABLE` requires a later row at the same entity, so **the newest row
1447/// per entity is never archivable**. If `ts` is at or after the newest stamp
1448/// still in the hot log, then every entity's winning row at `ts` is its newest
1449/// row overall, and every such row is hot — the fold is complete without
1450/// knowing anything about what left. That covers `reconstruct(now)`, the common
1451/// case and the one §5.7 designed `LOG_ARCHIVABLE` around, and nothing earlier.
1452///
1453/// Which is also why the two halves of the question have opposite senses. On an
1454/// intact log the test is `MIN <= ts`: *does the log reach back to `ts`*. Once
1455/// rows have gone it is `MAX <= ts`: *is `ts` late enough that nothing missing
1456/// could matter*. Reading the second as a weaker form of the first is the
1457/// mistake 0.5.5 corrected once and W14.2 corrected again in the arm 0.5.5 did
1458/// not reach.
1459async fn reach_with_rows_removed(conn: &libsql::Connection, ts: &str) -> Result<HotLogReach> {
1460 Ok(if newest_stamp_covers(conn, ts).await? {
1461 HotLogReach::Covers
1462 } else {
1463 HotLogReach::NeedsArchive
1464 })
1465}
1466
1467/// The rule itself, as a predicate, because two callers now read it and one of
1468/// them ([`hot_log_reach_within`]'s first arm) is not deciding between the same
1469/// two verdicts.
1470///
1471/// An empty log covers nothing, which is the arm that keeps a fully-archived
1472/// database from reporting its own emptiness as history.
1473async fn newest_stamp_covers(conn: &libsql::Connection, ts: &str) -> Result<bool> {
1474 Ok(newest_hot_stamp(conn)
1475 .await?
1476 .is_some_and(|max_ts| max_ts.as_str() <= ts))
1477}
1478
1479/// The oldest `recorded_at` still in the hot log, or `None` if it is empty.
1480async fn oldest_hot_stamp(conn: &libsql::Connection) -> Result<Option<String>> {
1481 hot_stamp(conn, "MIN").await
1482}
1483
1484/// The newest `recorded_at` still in the hot log, or `None` if it is empty.
1485async fn newest_hot_stamp(conn: &libsql::Connection) -> Result<Option<String>> {
1486 hot_stamp(conn, "MAX").await
1487}
1488
1489/// One aggregate over `transaction_log.recorded_at`, which `idx_txlog_time`
1490/// serves as an index scan of one row at either end.
1491async fn hot_stamp(conn: &libsql::Connection, agg: &str) -> Result<Option<String>> {
1492 let row = conn
1493 .query(
1494 &format!("SELECT {agg}(recorded_at) FROM transaction_log"),
1495 (),
1496 )
1497 .await?
1498 .next()
1499 .await?;
1500 Ok(row.and_then(|r| r.get(0).ok()))
1501}
1502
1503/// What the caller needs to know when the cold delta cannot be reached —
1504/// **assembled from the hot file alone** (0.9.0, C4).
1505///
1506/// # This is the message the hot-side marker was wanted for
1507///
1508/// [D-121](../../docs/architecture/s13-decision-register.md) rejected a hot-side
1509/// marker recording *archived at* and *horizon*, then left the door open: 0.9.0
1510/// was to adopt it "only if it wants the richer message". C4 asked for the
1511/// message and found the marker cannot supply it, because the proposed message —
1512/// *"this database was archived on X; pass the archive path"* — is **weaker**
1513/// than what the hot log already carries:
1514///
1515/// * *how many rows went* is `MAX(seq_id) - COUNT(*)`, exact for the reason
1516/// [`hot_log_is_intact`] gives;
1517/// * *how far back the hot file still reaches* is `MIN(seq_id)` and its
1518/// `recorded_at` — which is the fact that actually tells a caller whether the
1519/// archive is worth fetching, and which a marker's archive **timestamp** does
1520/// not give them;
1521/// * *that archiving happened at all* is the one bit [`hot_log_is_intact`]
1522/// already answers.
1523///
1524/// The only datum a marker would add is the wall-clock instant of the last
1525/// archive run, and no branch and no caller needs it. So the marker is refused
1526/// outright rather than deferred again: under
1527/// [D-036](../../docs/architecture/s13-decision-register.md) a hot-table addition
1528/// lands pre-1.0 or not at all, and a table whose whole content is a timestamp
1529/// used in one error string is not worth a rung.
1530///
1531/// # There is no "nothing was archived" case, and that was settled by injection
1532///
1533/// This first carried a branch for `removed == 0`, on the reasoning that the
1534/// `NeedsArchive` arm is reachable without any archiving. That reasoning was
1535/// **wrong about where the cost lands and right about the branch**, and only a
1536/// probe told the two apart: replacing the branch body with a panic showed it
1537/// firing from `a_failed_cold_reconstruct_still_detaches`, a test that raises
1538/// nothing from here — because the hint was being computed *before* the two
1539/// arms that use it, on every cold fold. Made lazy, the probe went quiet across
1540/// all 27 targets.
1541///
1542/// So the branch was dead at the use sites: both arms require
1543/// [`hot_log_is_intact`] to have returned false, or an archive file to have
1544/// existed when `hot_log_reach` looked and to have gone by the time this did.
1545/// Rows really were removed in every case that gets here, and the message may
1546/// say so without qualification. Deleted rather than kept as a defensive
1547/// fallback, for the reason `delete_guarded` records about
1548/// `classify_archive_violation`: unreachable code that looks reasonable is
1549/// harder to remove later than now.
1550///
1551/// Best-effort by construction: this runs on the error path, where a second
1552/// failure must not replace the diagnosis with its own. A query that does not
1553/// answer yields a hint that says so, and the caller still gets the error it came
1554/// for.
1555async fn archive_hint(conn: &libsql::Connection) -> String {
1556 // `COUNT(*)` always returns a row, so `None` here means the query itself
1557 // failed and there is nothing to say beyond that.
1558 let row = match conn
1559 .query(
1560 "SELECT COUNT(*), MIN(seq_id), MAX(seq_id), MIN(recorded_at) FROM transaction_log",
1561 (),
1562 )
1563 .await
1564 {
1565 Ok(mut rows) => rows.next().await.ok().flatten(),
1566 Err(_) => None,
1567 };
1568
1569 let Some(row) = row else {
1570 return "the hot log could not be inspected for an archive horizon".into();
1571 };
1572 let count: i64 = row.get(0).unwrap_or(0);
1573 if count == 0 {
1574 return "the hot log is empty".into();
1575 }
1576 let min: i64 = row.get(1).unwrap_or(0);
1577 let max: i64 = row.get(2).unwrap_or(0);
1578 let floor: String = row.get(3).unwrap_or_default();
1579 let removed = max - count;
1580
1581 format!(
1582 "{removed} log rows have been archived out of this database; the hot log \
1583 now begins at seq_id {min} ({floor})"
1584 )
1585}
1586
1587/// Was any row ever removed from `transaction_log`? — answered exactly, from
1588/// the hot file alone (0.8.0, B5, D-121).
1589///
1590/// # Why this question needs answering at all
1591///
1592/// With `ts` below the hot log's floor and no archive file present, the state
1593/// on disk is consistent with two very different histories: **nothing was ever
1594/// archived**, in which case the hot log is the whole log and the answer to
1595/// *what was believed at `ts`* is "nothing yet"; or **rows were archived and
1596/// the cold file is gone**, in which case the answer is unknowable and saying
1597/// "nothing" would be inventing one. Before this, the two were conflated and
1598/// both raised — which made an ordinary question about a young database report
1599/// the ledger as damaged.
1600///
1601/// # It was a `COUNT(*)` until 0.15.7, and the count was the whole cost
1602///
1603/// The v15 form was `MIN(seq_id) = 1 AND COUNT(*) = MAX(seq_id)`, and the
1604/// argument for it was a proof rather than a heuristic. `transaction_log.seq_id`
1605/// is `INTEGER PRIMARY KEY AUTOINCREMENT`, so values are allocated 1, 2, 3, …
1606/// and **never reused**; a rolled-back transaction leaves no gap, which
1607/// [D-049](../../docs/architecture/s13-decision-register.md#d-049) established
1608/// by measurement after assuming the opposite; and `trg_txlog_guard_delete`
1609/// confines deletion to an archive session. So if nothing was removed the ids
1610/// are exactly `1..=MAX`, and conversely those two equalities force the `COUNT`
1611/// distinct ids inside `[1, MAX]` to be all of it. Exact in both directions.
1612///
1613/// It was also a scan. `MIN` and `MAX` on the rowid are index seeks and
1614/// `COUNT(*)` is not, so this cost the whole hot log — **0.134 ms at 2,000 rows
1615/// and 32.6 ms at 500,000** — on every recorded-time read below the newest
1616/// surviving stamp, in front of an id-bounded hydration that is flat at 0.14 ms
1617/// however long the log is (`examples/log_integrity_probe.rs`, review C-5,
1618/// [D-247], [D-249]). The one-row read is **0.033 ms and does not move with the
1619/// log**, which is the shape of the change rather than the factor: at 2,000
1620/// rows it is 4x, at half a million it is 930x, and the difference between
1621/// those two is the whole finding.
1622///
1623/// # So the storage writes it down at the moment it becomes true
1624///
1625/// `log_integrity.rows_removed`, maintained by `trg_txlog_mark_gap`, is the
1626/// same bit as a one-row read. A **trigger** rather than the archive code,
1627/// because there is no route to deleting a log row that avoids it — §4.2 admits
1628/// that raw SQL against the file can do what this API refuses, and a bit
1629/// maintained in Rust would be wrong after exactly that, in the direction that
1630/// folds a gap silently.
1631///
1632/// The proof above did not go away; it moved. It is what the v15 → v16 rung
1633/// runs, once, to seed a database that may already have been archived, and what
1634/// `the_bit_agrees_with_the_count_it_replaced` asserts it against.
1635///
1636/// # One state changed hands, and it was wrong before
1637///
1638/// An **empty** hot log used to answer *intact*, unconditionally: `count == 0`
1639/// returned `true`. That conflates a young database with a fully archived one,
1640/// and the second then reported its own emptiness as history — the caller was
1641/// told nothing had been recorded by `ts` when in truth everything had, and was
1642/// told it without an error. [`hot_log_reach`] catches that case when it has an
1643/// archive path to look at; [`hot_log_answers_for`] has none and could not.
1644/// The bit tells them apart on the log alone, which is what a young database
1645/// and an emptied one differ by.
1646///
1647/// # What it deliberately does not claim
1648///
1649/// Nothing about *when* the archiving happened or *what* went, which is what
1650/// the marker [D-132](../../docs/architecture/s13-decision-register.md#d-132)
1651/// refused would have carried, and [D-249] does not revisit that refusal — this
1652/// row answers the guard's own question and holds nothing a message would want.
1653///
1654/// [D-247]: ../../docs/architecture/s13-decision-register.md#d-247
1655/// [D-249]: ../../docs/architecture/s13-decision-register.md#d-249
1656async fn hot_log_is_intact(conn: &libsql::Connection) -> Result<bool> {
1657 let row = conn
1658 .query("SELECT rows_removed FROM log_integrity WHERE id = 1", ())
1659 .await?
1660 .next()
1661 .await?;
1662 // No row is not a state the ladder produces: the rung seeds it and the
1663 // baseline seeds it, and `verify` fails a database missing the table. A
1664 // database that reached here without one is damaged in a way this function
1665 // must not paper over with an optimistic answer.
1666 let Some(row) = row else {
1667 return Ok(false);
1668 };
1669 Ok(row.get::<i64>(0)? == 0)
1670}
1671
1672/// Whether a connection alone can fold `transaction_log` at `ts` (W7.1, D-174).
1673///
1674/// The completeness question [`hot_log_reach`] answers, minus the archive file
1675/// it does not have. Both callers take a `Connection`, so when the hot log is
1676/// short they have nowhere to go and must refuse rather than fold what is left:
1677/// [`crate::graph::TraversalBuilder::as_of_recorded`] folds for topology, and
1678/// [`crate::temporal::hydrate_attributes`] folds for the text (0.13.16, W9.1,
1679/// [D-189](../../docs/architecture/s13-decision-register.md#d-189)). The second
1680/// was folding without asking, which is what §3.2 was.
1681///
1682/// # It ignored `ts` until 0.15.4 (W14.2, review C-2)
1683///
1684/// The body was `hot_log_is_intact(conn)` and the parameter was `_ts`: one bit,
1685/// *was anything ever removed*, with the instant discarded. So the first archive
1686/// session a deployment ever ran took `AttributeMode::AtTime` and every
1687/// `as_of_recorded` traversal away from it permanently, for its whole history
1688/// rather than for the archived part of it — including `as_of_recorded(now)`,
1689/// which is the instant the archive is *guaranteed* to answer.
1690///
1691/// The old comment here justified that as conservative-by-one-bit on the ground
1692/// that the archive cutoff is not recorded hot-side (D-132's refused marker),
1693/// and the ground was sound. The conclusion did not follow: the cutoff is not
1694/// needed. [`reach_with_rows_removed`] decides the same question from the newest
1695/// surviving stamp, which is hot by construction, and [`hot_log_reach`] had been
1696/// computing exactly that verdict per timestamp since 0.5.5 two functions away.
1697/// Both readers now take the three-way verdict and refuse on one arm of it.
1698///
1699/// [`HotLogReach::PredatesRecordedHistory`] is an answer, not a refusal: the
1700/// fold returns the empty state, which is what was believed at an instant before
1701/// anything was recorded. That is also what the old bit did there, so the arm is
1702/// unchanged rather than newly permitted.
1703pub(crate) async fn hot_log_answers_for(conn: &libsql::Connection, ts: &str) -> Result<bool> {
1704 Ok(!matches!(
1705 hot_log_reach_within(conn, ts).await?,
1706 HotLogReach::NeedsArchive
1707 ))
1708}
1709
1710/// Run one fold query from nothing — the unanchored path.
1711async fn fold(conn: &libsql::Connection, ts: &str, query: &str) -> Result<MaterializedState> {
1712 let delta = fold_delta(conn, query, libsql::params![ts]).await?;
1713 Ok(delta.apply_to(MaterializedState::empty(ts), ts))
1714}
1715
1716/// Run one fold query and collect the winning rows, deletions included.
1717async fn fold_delta(
1718 conn: &libsql::Connection,
1719 query: &str,
1720 params: impl libsql::params::IntoParams,
1721) -> Result<Delta> {
1722 let mut rows = conn.query(query, params).await?;
1723 let mut d = Delta::default();
1724 let (concepts, edges, max_seq) = (&mut d.concepts, &mut d.edges, &mut d.max_seq);
1725
1726 while let Some(row) = rows.next().await? {
1727 let seq_id: i64 = row.get(0)?;
1728 let table_name: String = row.get(1)?;
1729 let _entity_id: String = row.get(2)?;
1730 let op: String = row.get(3)?;
1731 let payload_str: String = row.get(4)?;
1732 // Projected by all four folds since 0.14.5. They have partitioned on it
1733 // since D-216; what was missing was carrying it out of the query, which
1734 // is why the correct partition produced a collapsed result anyway.
1735 let branch_id: String = row.get(5)?;
1736
1737 if seq_id > *max_seq {
1738 *max_seq = seq_id;
1739 }
1740
1741 // A `'D'` row is corruption, not a tombstone (D-072).
1742 //
1743 // Doctrine V permits no physical delete outside an archive session, and
1744 // the archive *moves* rows rather than logging their removal — so no
1745 // trigger in the schema writes a `'D'`, and no code path in the crate
1746 // can produce one. This arm used to treat it as a tombstone, which read
1747 // as a claim that deletions are recorded and reconstructible. They are
1748 // not. Refusing here makes the doctrine enforced at the fold rather than
1749 // assumed by it, and is the same call D-060 made for overlap: the layer
1750 // that can notice should.
1751 //
1752 // Retirement is unaffected and is the mechanism that actually removes a
1753 // concept from a composed state — see the `retired != 0` branch below,
1754 // which is where `concepts_gone` is populated in practice.
1755 if op == "D" {
1756 return Err(DbError::ReplayCorrupt {
1757 seq: seq_id,
1758 reason: format!(
1759 "transaction_log carries a 'D' operation for {table_name} \
1760 entity {_entity_id:?}; Doctrine V permits no physical delete \
1761 outside an archive session, and the archive logs none. This \
1762 row was not written by this crate."
1763 ),
1764 });
1765 }
1766
1767 let payload: serde_json::Value =
1768 serde_json::from_str(&payload_str).map_err(|e| DbError::ReplayCorrupt {
1769 seq: seq_id,
1770 reason: format!("Failed to parse payload JSON: {e}"),
1771 })?;
1772
1773 // v1 and v2 differ by one added field, so v1 folds by reading it as
1774 // absent — which is what `Option` already means here. A future shape
1775 // that *removes* or *retypes* a field would not be able to share this
1776 // path, and would want a match on `v` rather than a ceiling.
1777 let v = payload.get("v").and_then(|v| v.as_u64()).unwrap_or(1);
1778 if v > PAYLOAD_VERSION as u64 {
1779 return Err(DbError::PayloadVersion {
1780 got: v as u8,
1781 max: PAYLOAD_VERSION,
1782 });
1783 }
1784
1785 if table_name == "concepts" {
1786 let id = _entity_id;
1787 let retired = payload.get("retired").and_then(|r| r.as_i64()).unwrap_or(0);
1788 if retired == 0 {
1789 let title = payload
1790 .get("title")
1791 .and_then(|s| s.as_str())
1792 .unwrap_or("")
1793 .to_string();
1794 let content = payload
1795 .get("content")
1796 .and_then(|s| s.as_str())
1797 .unwrap_or("")
1798 .to_string();
1799 let embedding_model = payload
1800 .get("embedding_model")
1801 .and_then(|s| s.as_str())
1802 .map(|s| s.to_string());
1803 concepts.insert(
1804 id.clone(),
1805 NodeAttributes {
1806 id,
1807 title,
1808 content,
1809 embedding_model,
1810 },
1811 );
1812 } else {
1813 // Retirement is the application axis (§4.1), and a reconstruction
1814 // shows what was visible. Onto a snapshot that means removing
1815 // the concept, not declining to add it.
1816 d.concepts_gone.insert(id);
1817 }
1818 } else if table_name == "links" {
1819 let src = payload
1820 .get("source_id")
1821 .and_then(|s| s.as_str())
1822 .unwrap_or("")
1823 .to_string();
1824 let tgt = payload
1825 .get("target_id")
1826 .and_then(|s| s.as_str())
1827 .unwrap_or("")
1828 .to_string();
1829 let edge_type = payload
1830 .get("edge_type")
1831 .and_then(|s| s.as_str())
1832 .unwrap_or("")
1833 .to_string();
1834 let vf = payload
1835 .get("valid_from")
1836 .and_then(|s| s.as_str())
1837 .unwrap_or("")
1838 .to_string();
1839 let vt = payload
1840 .get("valid_to")
1841 .and_then(|s| s.as_str())
1842 .unwrap_or("")
1843 .to_string();
1844 let belief = EdgeBelief {
1845 source_id: src,
1846 target_id: tgt,
1847 edge_type,
1848 valid_from: vf,
1849 valid_to: vt,
1850 branch_id,
1851 };
1852 edges.insert(belief.belief_key(), belief);
1853 }
1854 }
1855
1856 Ok(d)
1857}
1858
1859impl Delta {
1860 /// Compose onto `base` under last-writer-wins by `seq_id` (§5.5).
1861 ///
1862 /// The delta is by construction newer than the base — it is the fold of
1863 /// everything above the base's anchor — so every row it carries wins, and
1864 /// every retirement it carries removes. This is the same rule
1865 /// `trg_links_current_sync`'s upsert applies and the same rule the cold
1866 /// fold applies; that the three agree is asserted by test rather than by
1867 /// this comment (§8).
1868 fn apply_to(self, base: MaterializedState, ts: &str) -> MaterializedState {
1869 let mut concepts = base.concepts;
1870 let mut edges: HashMap<String, EdgeBelief> = base
1871 .edges
1872 .into_iter()
1873 .map(|e| (e.belief_key(), e))
1874 .collect();
1875
1876 for id in self.concepts_gone {
1877 concepts.remove(&id);
1878 }
1879 // No edge equivalent: an edge is superseded in place under the same
1880 // `entity_id`, never removed — see [`Delta`] (D-072).
1881 concepts.extend(self.concepts);
1882 edges.extend(self.edges);
1883
1884 // Sorted so the result is a function of the state and not of hash
1885 // iteration order — `reconstruct` is compared against itself by the
1886 // property suite, and two runs must be equal, not merely equivalent.
1887 let mut edges: Vec<_> = edges.into_values().collect();
1888 edges.sort();
1889
1890 MaterializedState {
1891 seq_anchor: self.max_seq.max(base.seq_anchor),
1892 timestamp: ts.to_string(),
1893 concepts,
1894 edges,
1895 // A delta was applied, so there was history to fold. `reconstruct`
1896 // sets the flag on the one path that never gets here.
1897 predates_recorded_history: false,
1898 }
1899 }
1900}
1901
1902#[cfg(test)]
1903mod reach_table {
1904 //! Every cell of the reach question, named (0.15.5, W14.4, [D-247]).
1905 //!
1906 //! [`hot_log_reach_within`] decides on two facts — whether rows were removed
1907 //! from the log, and where `ts` sits against the stamps that remain — and
1908 //! the order it establishes them in is a **cost** decision, not a
1909 //! correctness one. 0.15.5 reordered it so the cheap fact is enough on the
1910 //! arm the readers actually use. A reordering is exactly the kind of change
1911 //! that is obviously behaviour-preserving until it is not, and the argument
1912 //! for it ("`MAX <= ts` covers under both rules") is short enough to be
1913 //! believed without checking. This table is the checking.
1914 //!
1915 //! Enumerated rather than sampled, because the defects this area has
1916 //! actually produced were all boundary cells: `ts` exactly at the newest
1917 //! stamp (0.15.4), `ts` below the floor of an intact log (0.8.0, D-121),
1918 //! and an empty log that passes the intactness test vacuously (0.5.5).
1919
1920 use super::*;
1921
1922 const A: &str = "1970-01-01T01:00:00.000000Z";
1923 const B: &str = "1970-01-01T02:00:00.000000Z";
1924 const C: &str = "1970-01-01T03:00:00.000000Z";
1925 const BEFORE_A: &str = "1970-01-01T00:30:00.000000Z";
1926 const BETWEEN: &str = "1970-01-01T02:30:00.000000Z";
1927 const AFTER_C: &str = "1970-01-01T04:00:00.000000Z";
1928
1929 /// A log holding one row at each of `A`, `B`, `C`, optionally with `B`'s
1930 /// removed the way an archive removes it — a hole in the middle of the
1931 /// `seq_id` run, leaving the floor and the ceiling where they were.
1932 ///
1933 /// That shape is the point. A gap at the end cannot happen (the newest row
1934 /// per entity is never archivable) and a gap at the front would move `MIN`
1935 /// and make the two rules agree by accident.
1936 async fn log(gapped: bool) -> libsql::Connection {
1937 let db = libsql::Builder::new_local(":memory:")
1938 .build()
1939 .await
1940 .unwrap();
1941 let conn = db.connect().unwrap();
1942 crate::schema::run_migrations(&conn).await.unwrap();
1943 for (i, ts) in [A, B, C].iter().enumerate() {
1944 conn.execute(
1945 "INSERT INTO transaction_log \
1946 (table_name, entity_id, operation, payload, recorded_at) \
1947 VALUES ('concepts', ?1, 'upsert', '{}', ?2)",
1948 libsql::params![format!("c{i}").as_str(), *ts],
1949 )
1950 .await
1951 .unwrap();
1952 }
1953 if gapped {
1954 let marker = crate::schema::ddl::ARCHIVE_SESSION_MARKER;
1955 conn.execute(&format!("CREATE TABLE {marker} (x)"), ())
1956 .await
1957 .unwrap();
1958 conn.execute(
1959 "DELETE FROM transaction_log WHERE recorded_at = ?1",
1960 libsql::params![B],
1961 )
1962 .await
1963 .unwrap();
1964 conn.execute(&format!("DROP TABLE {marker}"), ())
1965 .await
1966 .unwrap();
1967 }
1968 conn
1969 }
1970
1971 async fn empty_log() -> libsql::Connection {
1972 let db = libsql::Builder::new_local(":memory:")
1973 .build()
1974 .await
1975 .unwrap();
1976 let conn = db.connect().unwrap();
1977 crate::schema::run_migrations(&conn).await.unwrap();
1978 conn
1979 }
1980
1981 /// An intact log is the whole log, so it answers at every instant: with the
1982 /// fold above its floor, and with the empty state below it.
1983 #[tokio::test]
1984 async fn an_intact_log_answers_everywhere() {
1985 let conn = log(false).await;
1986 for (ts, want) in [
1987 (BEFORE_A, HotLogReach::PredatesRecordedHistory),
1988 (A, HotLogReach::Covers),
1989 (B, HotLogReach::Covers),
1990 (BETWEEN, HotLogReach::Covers),
1991 (C, HotLogReach::Covers),
1992 (AFTER_C, HotLogReach::Covers),
1993 ] {
1994 assert_eq!(
1995 hot_log_reach_within(&conn, ts).await.unwrap(),
1996 want,
1997 "intact log at {ts}"
1998 );
1999 assert!(
2000 hot_log_answers_for(&conn, ts).await.unwrap(),
2001 "an intact log refuses nothing, and refused {ts}"
2002 );
2003 }
2004 }
2005
2006 /// Once a row has gone, the boundary moves to the *newest* surviving stamp
2007 /// and the sense of the comparison inverts.
2008 ///
2009 /// `A` is the case that matters and the one the old rule got wrong: the log
2010 /// still reaches back to it — `MIN(recorded_at)` is `A` — and the answer at
2011 /// `A` is nonetheless in the other file, because the row that won at `A`
2012 /// for the entity whose `B` row went is no longer here to be found.
2013 #[tokio::test]
2014 async fn a_gapped_log_answers_only_from_its_newest_stamp() {
2015 let conn = log(true).await;
2016 for (ts, want) in [
2017 (BEFORE_A, HotLogReach::NeedsArchive),
2018 (A, HotLogReach::NeedsArchive),
2019 (BETWEEN, HotLogReach::NeedsArchive),
2020 (C, HotLogReach::Covers),
2021 (AFTER_C, HotLogReach::Covers),
2022 ] {
2023 assert_eq!(
2024 hot_log_reach_within(&conn, ts).await.unwrap(),
2025 want,
2026 "gapped log at {ts}"
2027 );
2028 assert_eq!(
2029 hot_log_answers_for(&conn, ts).await.unwrap(),
2030 want != HotLogReach::NeedsArchive,
2031 "the boolean guard must agree with the verdict at {ts}"
2032 );
2033 }
2034 }
2035
2036 /// `C` is the newest surviving stamp and must be *answered*, not refused.
2037 ///
2038 /// Split out of the table above rather than left as one row in it, because
2039 /// it is the cell 0.15.4 was about and the cell a `<` instead of a `<=`
2040 /// takes. A boundary that is one row of six is a boundary nobody reads.
2041 #[tokio::test]
2042 async fn the_newest_surviving_stamp_is_answered_and_not_refused() {
2043 let conn = log(true).await;
2044 assert_eq!(
2045 hot_log_reach_within(&conn, C).await.unwrap(),
2046 HotLogReach::Covers,
2047 "at the newest surviving stamp every entity's winning row is its \
2048 newest row, and every one of those is still here"
2049 );
2050 }
2051
2052 /// An empty log is intact vacuously — nothing was removed because nothing
2053 /// is there — and the empty state is the honest answer at every instant.
2054 ///
2055 /// This is the cell that keeps the archive-file check in [`hot_log_reach`]
2056 /// from being folded into intactness: the *same* database with a cold file
2057 /// beside it is fully archived rather than young, and must not answer here.
2058 #[tokio::test]
2059 async fn an_empty_log_predates_everything_rather_than_covering_it() {
2060 let conn = empty_log().await;
2061 for ts in [BEFORE_A, C, AFTER_C] {
2062 assert_eq!(
2063 hot_log_reach_within(&conn, ts).await.unwrap(),
2064 HotLogReach::PredatesRecordedHistory,
2065 "empty log at {ts}"
2066 );
2067 }
2068 }
2069
2070 /// The reordering is a cost change, so the cheap arm must give the verdict
2071 /// the whole case split gives — on both sides of the split.
2072 ///
2073 /// Written as a comparison rather than as two expected values: it is the
2074 /// property the optimisation rests on, and asserting literals here would
2075 /// pass if the property were false and both sides were wrong together.
2076 #[tokio::test]
2077 async fn the_cheap_arm_agrees_with_the_rule_it_short_circuits() {
2078 for gapped in [false, true] {
2079 let conn = log(gapped).await;
2080 for ts in [BEFORE_A, A, BETWEEN, C, AFTER_C] {
2081 if newest_stamp_covers(&conn, ts).await.unwrap() {
2082 assert_eq!(
2083 hot_log_reach_within(&conn, ts).await.unwrap(),
2084 HotLogReach::Covers,
2085 "the cheap arm claimed {ts} on a gapped={gapped} log and \
2086 the full rule disagrees"
2087 );
2088 }
2089 }
2090 }
2091 }
2092
2093 /// Empty the log the way a long-running archive does.
2094 async fn empty_the_log(conn: &libsql::Connection) {
2095 let marker = crate::schema::ddl::ARCHIVE_SESSION_MARKER;
2096 conn.execute(&format!("CREATE TABLE {marker} (x)"), ())
2097 .await
2098 .unwrap();
2099 conn.execute("DELETE FROM transaction_log", ())
2100 .await
2101 .unwrap();
2102 conn.execute(&format!("DROP TABLE {marker}"), ())
2103 .await
2104 .unwrap();
2105 }
2106
2107 /// A log archived down to nothing is not a log nothing was written to
2108 /// (0.15.7, W14.5, [D-249]).
2109 ///
2110 /// This is the cell the module doc names as a defect from 0.5.5 and the one
2111 /// the table did not have: `count = 0` returned *intact* by a separate arm,
2112 /// so a fully archived database was told `PredatesRecordedHistory` —
2113 /// nothing had been recorded by `ts` — at every instant, with its whole
2114 /// history sitting in the archive and no error to say so. [`hot_log_reach`]
2115 /// catches it when it has an archive path to look at; this function has
2116 /// none, and the bit is what it has instead.
2117 #[tokio::test]
2118 async fn a_log_archived_down_to_nothing_asks_for_the_archive() {
2119 let conn = log(false).await;
2120 empty_the_log(&conn).await;
2121
2122 for ts in [BEFORE_A, A, BETWEEN, C, AFTER_C] {
2123 assert_eq!(
2124 hot_log_reach_within(&conn, ts).await.unwrap(),
2125 HotLogReach::NeedsArchive,
2126 "an emptied log answered for {ts} out of its own emptiness"
2127 );
2128 assert!(
2129 !hot_log_answers_for(&conn, ts).await.unwrap(),
2130 "an emptied log claims to answer for {ts}"
2131 );
2132 }
2133
2134 // And the state it must not be confused with, unchanged: a log nothing
2135 // was ever written to still predates history rather than refusing.
2136 let young = empty_log().await;
2137 assert_eq!(
2138 hot_log_reach_within(&young, BEFORE_A).await.unwrap(),
2139 HotLogReach::PredatesRecordedHistory,
2140 "a young log was made to refuse, which is the opposite over-correction"
2141 );
2142 }
2143
2144 /// A database whose integrity row is gone is damaged, and damaged is not
2145 /// intact (0.15.7, W14.5, [D-249]).
2146 ///
2147 /// The ladder seeds the row and `verify` requires the table, so nothing the
2148 /// crate does produces this. Something outside the crate can — §4.2 says
2149 /// so — and the arm that handles it chooses to refuse rather than to assume
2150 /// the happy answer, because the happy answer here is *fold an incomplete
2151 /// log and return it as belief*.
2152 #[tokio::test]
2153 async fn a_log_without_its_integrity_row_is_not_assumed_intact() {
2154 let conn = log(false).await;
2155 conn.execute("DELETE FROM log_integrity", ()).await.unwrap();
2156
2157 assert!(
2158 !hot_log_is_intact(&conn).await.unwrap(),
2159 "a missing integrity row was read as an intact log"
2160 );
2161 assert_eq!(
2162 hot_log_reach_within(&conn, BETWEEN).await.unwrap(),
2163 HotLogReach::NeedsArchive,
2164 "a damaged database was answered from rather than refused"
2165 );
2166 }
2167}