use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::error::{DbError, Result};
use crate::temporal::as_of::NodeAttributes;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaterializedState {
pub seq_anchor: i64,
pub timestamp: String,
pub concepts: HashMap<String, NodeAttributes>,
pub edges: Vec<(String, String, String, String, String)>,
}
impl MaterializedState {
fn empty(ts: &str) -> Self {
Self {
seq_anchor: 0,
timestamp: ts.to_string(),
concepts: HashMap::new(),
edges: Vec::new(),
}
}
}
pub(crate) const PAYLOAD_VERSION: u8 = 2;
const HOT_FOLD: &str = r#"
SELECT seq_id, table_name, entity_id, operation, payload
FROM (
SELECT seq_id, table_name, entity_id, operation, payload,
ROW_NUMBER() OVER (PARTITION BY table_name, entity_id ORDER BY seq_id DESC) as rn
FROM transaction_log
WHERE recorded_at <= ?1
) WHERE rn = 1
"#;
const COLD_FOLD: &str = r#"
SELECT seq_id, table_name, entity_id, operation, payload
FROM (
SELECT seq_id, table_name, entity_id, operation, payload,
ROW_NUMBER() OVER (PARTITION BY table_name, entity_id ORDER BY seq_id DESC) as rn
FROM (
SELECT seq_id, table_name, entity_id, operation, payload, recorded_at FROM main.transaction_log
UNION ALL
SELECT seq_id, table_name, entity_id, operation, payload, recorded_at FROM cold.transaction_log
) WHERE recorded_at <= ?1
) WHERE rn = 1
"#;
const ANCHORED_HOT_FOLD: &str = r#"
SELECT seq_id, table_name, entity_id, operation, payload
FROM (
SELECT seq_id, table_name, entity_id, operation, payload,
ROW_NUMBER() OVER (PARTITION BY table_name, entity_id ORDER BY seq_id DESC) as rn
FROM transaction_log
WHERE recorded_at <= ?1 AND seq_id > ?2
) WHERE rn = 1
"#;
const ANCHORED_COLD_FOLD: &str = r#"
SELECT seq_id, table_name, entity_id, operation, payload
FROM (
SELECT seq_id, table_name, entity_id, operation, payload,
ROW_NUMBER() OVER (PARTITION BY table_name, entity_id ORDER BY seq_id DESC) as rn
FROM (
SELECT seq_id, table_name, entity_id, operation, payload, recorded_at FROM main.transaction_log
UNION ALL
SELECT seq_id, table_name, entity_id, operation, payload, recorded_at FROM cold.transaction_log
) WHERE recorded_at <= ?1 AND seq_id > ?2
) WHERE rn = 1
"#;
#[derive(Default)]
struct Delta {
concepts: HashMap<String, NodeAttributes>,
edges: HashMap<String, (String, String, String, String, String)>,
concepts_gone: HashSet<String>,
max_seq: i64,
}
fn edge_key(e: &(String, String, String, String, String)) -> String {
format!("{}|{}|{}|{}", e.0, e.1, e.2, e.3)
}
pub(crate) async fn detach_stale_cold(conn: &libsql::Connection) {
let _ = conn.execute("DETACH DATABASE cold", ()).await;
}
pub async fn reconstruct(
conn: &libsql::Connection,
ts: &str,
archive_path: Option<&Path>,
snapshots_dir: Option<&Path>,
) -> Result<MaterializedState> {
if hot_log_is_complete(conn, ts, archive_path).await? {
if let Some(base) = snapshot_anchor(snapshots_dir, ts) {
let anchor = base.seq_anchor;
let delta = fold_delta(conn, ANCHORED_HOT_FOLD, libsql::params![ts, anchor]).await?;
return Ok(delta.apply_to(base, ts));
}
return fold(conn, ts, HOT_FOLD).await;
}
let archive = archive_path.ok_or_else(|| DbError::ReplayCorrupt {
seq: 0,
reason: format!("state at {ts} predates the hot log and no archive path was given"),
})?;
if !archive.exists() {
return Err(DbError::ReplayCorrupt {
seq: 0,
reason: format!("archive database file {archive:?} does not exist"),
});
}
detach_stale_cold(conn).await;
conn.execute(
"ATTACH DATABASE ?1 AS cold",
libsql::params![archive.to_string_lossy().as_ref()],
)
.await?;
let result = match snapshot_anchor(snapshots_dir, ts) {
Some(base) => {
let anchor = base.seq_anchor;
fold_delta(conn, ANCHORED_COLD_FOLD, libsql::params![ts, anchor])
.await
.map(|delta| delta.apply_to(base, ts))
}
None => fold(conn, ts, COLD_FOLD).await,
};
if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
tracing::warn!("reconstruct: failed to DETACH cold database: {e}");
}
result
}
pub async fn verify_snapshot_chain(
conn: &libsql::Connection,
ts: &str,
archive_path: Option<&Path>,
snapshots_dir: &Path,
) -> Result<ChainCheck> {
let composed = reconstruct(conn, ts, archive_path, Some(snapshots_dir)).await?;
let folded = reconstruct(conn, ts, archive_path, None).await?;
Ok(ChainCheck::compare(ts, &composed, &folded))
}
#[derive(Debug, Clone)]
pub struct ChainCheck {
pub timestamp: String,
pub composed_anchor: i64,
pub folded_anchor: i64,
pub composed_concepts: usize,
pub folded_concepts: usize,
pub composed_edges: usize,
pub folded_edges: usize,
pub concept_disagreements: Vec<String>,
pub edge_disagreements: Vec<String>,
pub truncated: bool,
}
impl ChainCheck {
pub const SAMPLE_LIMIT: usize = 32;
pub fn diverged(&self) -> bool {
!self.concept_disagreements.is_empty() || !self.edge_disagreements.is_empty()
}
fn compare(ts: &str, composed: &MaterializedState, folded: &MaterializedState) -> Self {
let mut concept_disagreements = Vec::new();
let mut truncated = false;
let mut ids: Vec<&String> = composed.concepts.keys().collect();
ids.extend(folded.concepts.keys());
ids.sort_unstable();
ids.dedup();
for id in ids {
let a = composed.concepts.get(id);
let b = folded.concepts.get(id);
let same = match (a, b) {
(Some(a), Some(b)) => {
a.title == b.title
&& a.content == b.content
&& a.embedding_model == b.embedding_model
}
(None, None) => true,
_ => false,
};
if !same {
if concept_disagreements.len() < Self::SAMPLE_LIMIT {
concept_disagreements.push(id.clone());
} else {
truncated = true;
}
}
}
let key = |e: &(String, String, String, String, String)| {
format!("{}|{}|{}|{}|{}", e.0, e.1, e.2, e.3, e.4)
};
let ca: HashSet<String> = composed.edges.iter().map(key).collect();
let fa: HashSet<String> = folded.edges.iter().map(key).collect();
let mut edge_disagreements: Vec<String> = ca.symmetric_difference(&fa).cloned().collect();
edge_disagreements.sort_unstable();
if edge_disagreements.len() > Self::SAMPLE_LIMIT {
edge_disagreements.truncate(Self::SAMPLE_LIMIT);
truncated = true;
}
Self {
timestamp: ts.to_string(),
composed_anchor: composed.seq_anchor,
folded_anchor: folded.seq_anchor,
composed_concepts: composed.concepts.len(),
folded_concepts: folded.concepts.len(),
composed_edges: ca.len(),
folded_edges: fa.len(),
concept_disagreements,
edge_disagreements,
truncated,
}
}
}
impl std::fmt::Display for ChainCheck {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if !self.diverged() {
return write!(
f,
"snapshot chain agrees with a genesis fold at {}: {} concepts, {} edges",
self.timestamp, self.folded_concepts, self.folded_edges
);
}
write!(
f,
"snapshot chain DIVERGED at {}: composed {} concepts / {} edges, \
genesis fold {} concepts / {} edges; {} concept and {} edge \
disagreements{}. The snapshots are a wrong cache, not a corrupt \
ledger — deleting the snapshot directory restores correctness and \
loses only speed (Doctrine VI). concepts: {:?} edges: {:?}",
self.timestamp,
self.composed_concepts,
self.composed_edges,
self.folded_concepts,
self.folded_edges,
self.concept_disagreements.len(),
self.edge_disagreements.len(),
if self.truncated { " (truncated)" } else { "" },
self.concept_disagreements,
self.edge_disagreements,
)
}
}
fn snapshot_anchor(snapshots_dir: Option<&Path>, ts: &str) -> Option<MaterializedState> {
let dir = snapshots_dir?;
let mut candidates: Vec<(i64, PathBuf)> = std::fs::read_dir(dir)
.ok()?
.flatten()
.map(|e| e.path())
.filter_map(|p| super::snapshot::seq_from_filename(&p).map(|s| (s, p)))
.collect();
candidates.sort_by_key(|(seq, _)| std::cmp::Reverse(*seq));
for (_, path) in candidates {
match super::snapshot::load_snapshot(&path) {
Ok(state) if state.timestamp.as_str() <= ts => return Some(state),
Ok(_) => continue,
Err(DbError::SnapshotIncompatible { reason, .. }) => {
tracing::warn!("skipping snapshot {path:?}: {reason}");
continue;
}
Err(e) => {
tracing::warn!("skipping unreadable snapshot {path:?}: {e}");
continue;
}
}
}
None
}
async fn hot_log_is_complete(
conn: &libsql::Connection,
ts: &str,
archive_path: Option<&Path>,
) -> Result<bool> {
let row = conn
.query(
"SELECT MIN(recorded_at), MAX(recorded_at) FROM transaction_log",
(),
)
.await?
.next()
.await?;
let (min_recorded_at, max_recorded_at): (Option<String>, Option<String>) = match row {
Some(r) => (r.get(0).ok(), r.get(1).ok()),
None => (None, None),
};
if archive_path.is_some_and(|p| p.exists()) {
return Ok(max_recorded_at.is_some_and(|max_ts| max_ts.as_str() <= ts));
}
Ok(match min_recorded_at {
Some(min_ts) => min_ts.as_str() <= ts,
None => true,
})
}
async fn fold(conn: &libsql::Connection, ts: &str, query: &str) -> Result<MaterializedState> {
let delta = fold_delta(conn, query, libsql::params![ts]).await?;
Ok(delta.apply_to(MaterializedState::empty(ts), ts))
}
async fn fold_delta(
conn: &libsql::Connection,
query: &str,
params: impl libsql::params::IntoParams,
) -> Result<Delta> {
let mut rows = conn.query(query, params).await?;
let mut d = Delta::default();
let (concepts, edges, max_seq) = (&mut d.concepts, &mut d.edges, &mut d.max_seq);
while let Some(row) = rows.next().await? {
let seq_id: i64 = row.get(0)?;
let table_name: String = row.get(1)?;
let _entity_id: String = row.get(2)?;
let op: String = row.get(3)?;
let payload_str: String = row.get(4)?;
if seq_id > *max_seq {
*max_seq = seq_id;
}
if op == "D" {
return Err(DbError::ReplayCorrupt {
seq: seq_id,
reason: format!(
"transaction_log carries a 'D' operation for {table_name} \
entity {_entity_id:?}; Doctrine V permits no physical delete \
outside an archive session, and the archive logs none. This \
row was not written by this crate."
),
});
}
let payload: serde_json::Value =
serde_json::from_str(&payload_str).map_err(|e| DbError::ReplayCorrupt {
seq: seq_id,
reason: format!("Failed to parse payload JSON: {e}"),
})?;
let v = payload.get("v").and_then(|v| v.as_u64()).unwrap_or(1);
if v > PAYLOAD_VERSION as u64 {
return Err(DbError::PayloadVersion {
got: v as u8,
max: PAYLOAD_VERSION,
});
}
if table_name == "concepts" {
let id = _entity_id;
let retired = payload.get("retired").and_then(|r| r.as_i64()).unwrap_or(0);
if retired == 0 {
let title = payload
.get("title")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let content = payload
.get("content")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let embedding_model = payload
.get("embedding_model")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
concepts.insert(
id.clone(),
NodeAttributes {
id,
title,
content,
embedding_model,
},
);
} else {
d.concepts_gone.insert(id);
}
} else if table_name == "links" {
let src = payload
.get("source_id")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let tgt = payload
.get("target_id")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let edge_type = payload
.get("edge_type")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let vf = payload
.get("valid_from")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let vt = payload
.get("valid_to")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
edges.insert(_entity_id, (src, tgt, edge_type, vf, vt));
}
}
Ok(d)
}
impl Delta {
fn apply_to(self, base: MaterializedState, ts: &str) -> MaterializedState {
let mut concepts = base.concepts;
let mut edges: HashMap<String, (String, String, String, String, String)> =
base.edges.into_iter().map(|e| (edge_key(&e), e)).collect();
for id in self.concepts_gone {
concepts.remove(&id);
}
concepts.extend(self.concepts);
edges.extend(self.edges);
let mut edges: Vec<_> = edges.into_values().collect();
edges.sort();
MaterializedState {
seq_anchor: self.max_seq.max(base.seq_anchor),
timestamp: ts.to_string(),
concepts,
edges,
}
}
}