diff --git a/Cargo.lock b/Cargo.lock index 12682e8a5f..f4394e5755 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2885,7 +2885,7 @@ dependencies = [ [[package]] name = "grafeo-adapters" version = "0.5.43" -source = "git+https://github.com/ScriptedAlchemy/grafeo.git?rev=be4713a5c2ee9df7e4639671c1e1244b7680f103#be4713a5c2ee9df7e4639671c1e1244b7680f103" +source = "git+https://github.com/ScriptedAlchemy/grafeo.git?branch=main#5116be3c7b9392006ef4d229634963e1306a7b6b" dependencies = [ "bincode", "grafeo-common", @@ -2900,7 +2900,7 @@ dependencies = [ [[package]] name = "grafeo-common" version = "0.5.43" -source = "git+https://github.com/ScriptedAlchemy/grafeo.git?rev=be4713a5c2ee9df7e4639671c1e1244b7680f103#be4713a5c2ee9df7e4639671c1e1244b7680f103" +source = "git+https://github.com/ScriptedAlchemy/grafeo.git?branch=main#5116be3c7b9392006ef4d229634963e1306a7b6b" dependencies = [ "arcstr", "bincode", @@ -2920,7 +2920,7 @@ dependencies = [ [[package]] name = "grafeo-core" version = "0.5.43" -source = "git+https://github.com/ScriptedAlchemy/grafeo.git?rev=be4713a5c2ee9df7e4639671c1e1244b7680f103#be4713a5c2ee9df7e4639671c1e1244b7680f103" +source = "git+https://github.com/ScriptedAlchemy/grafeo.git?branch=main#5116be3c7b9392006ef4d229634963e1306a7b6b" dependencies = [ "arc-swap", "arcstr", @@ -2943,7 +2943,7 @@ dependencies = [ [[package]] name = "grafeo-engine" version = "0.5.43" -source = "git+https://github.com/ScriptedAlchemy/grafeo.git?rev=be4713a5c2ee9df7e4639671c1e1244b7680f103#be4713a5c2ee9df7e4639671c1e1244b7680f103" +source = "git+https://github.com/ScriptedAlchemy/grafeo.git?branch=main#5116be3c7b9392006ef4d229634963e1306a7b6b" dependencies = [ "arcstr", "bincode", @@ -2965,7 +2965,7 @@ dependencies = [ [[package]] name = "grafeo-storage" version = "0.5.43" -source = "git+https://github.com/ScriptedAlchemy/grafeo.git?rev=be4713a5c2ee9df7e4639671c1e1244b7680f103#be4713a5c2ee9df7e4639671c1e1244b7680f103" +source = "git+https://github.com/ScriptedAlchemy/grafeo.git?branch=main#5116be3c7b9392006ef4d229634963e1306a7b6b" dependencies = [ "bincode", "byteorder", diff --git a/Cargo.toml b/Cargo.toml index f181a8429d..8d448c3b59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,14 +81,16 @@ asupersync = { git = "https://github.com/Dicklesworthstone/asupersync", rev = "e # and in-place, page-verified sealed sections that sealed generation stores # are built and reopened with. Its `memory_usage()` also counts the catalog, # empty property-index shards and plan-cache tables, which the serving graph -# heap charge reads. The fork's workspace resolves sibling crates -# by path, so all five crates must name the same `rev`; mixed revisions build -# type-incompatible instances. -grafeo-adapters = { git = "https://github.com/ScriptedAlchemy/grafeo.git", rev = "be4713a5c2ee9df7e4639671c1e1244b7680f103", default-features = false, features = ["algos"] } -grafeo-common = { git = "https://github.com/ScriptedAlchemy/grafeo.git", rev = "be4713a5c2ee9df7e4639671c1e1244b7680f103", default-features = false } -grafeo-core = { git = "https://github.com/ScriptedAlchemy/grafeo.git", rev = "be4713a5c2ee9df7e4639671c1e1244b7680f103", default-features = false, features = ["lpg"] } -grafeo-engine = { git = "https://github.com/ScriptedAlchemy/grafeo.git", rev = "be4713a5c2ee9df7e4639671c1e1244b7680f103", default-features = false, features = ["lpg", "gql", "algos", "wal", "grafeo-file"] } -grafeo-storage = { git = "https://github.com/ScriptedAlchemy/grafeo.git", rev = "be4713a5c2ee9df7e4639671c1e1244b7680f103", default-features = false, features = ["wal"] } +# heap charge reads, and reports native read allocation demand before +# materializing rows. These APIs live in the fork so every build uses the +# same implementation. The fork resolves sibling crates by path: keep all +# five dependencies on main together and lock their exact commit in Cargo.lock +# to avoid type-incompatible instances from mixed sources. +grafeo-adapters = { git = "https://github.com/ScriptedAlchemy/grafeo.git", branch = "main", default-features = false, features = ["algos"] } +grafeo-common = { git = "https://github.com/ScriptedAlchemy/grafeo.git", branch = "main", default-features = false } +grafeo-core = { git = "https://github.com/ScriptedAlchemy/grafeo.git", branch = "main", default-features = false, features = ["lpg"] } +grafeo-engine = { git = "https://github.com/ScriptedAlchemy/grafeo.git", branch = "main", default-features = false, features = ["lpg", "gql", "algos", "wal", "grafeo-file"] } +grafeo-storage = { git = "https://github.com/ScriptedAlchemy/grafeo.git", branch = "main", default-features = false, features = ["wal"] } # Pinned once: the MCP git handlers and the root's edit/lifecycle readers must # resolve the same object database and status implementation, so a feature # added for one is never missing for the other. diff --git a/MODULE.bazel b/MODULE.bazel index c4f38a74ae..4cb5ca79a8 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -103,13 +103,6 @@ crate.annotation( version = "0.46.0", ) -crate.annotation( - crate = "grafeo-core", - patch_args = ["-p1"], - patches = ["//scripts:bazel/patches/grafeo-core-read-allocation.patch"], - version = "0.5.43", -) - # arborium grammar build scripts read `env!("CARGO_MANIFEST_DIR")`, which bakes # the compiling output base's absolute path into the build-script binary. The # action key does not include that path, so the disk cache served one output diff --git a/crates/tracedecay-global-db/src/observation/canonical_body_gc.rs b/crates/tracedecay-global-db/src/observation/canonical_body_gc.rs new file mode 100644 index 0000000000..6c5af50fea --- /dev/null +++ b/crates/tracedecay-global-db/src/observation/canonical_body_gc.rs @@ -0,0 +1,342 @@ +//! Bounded reclamation of bodies shared by raw messages and observations. +//! +//! Writer triggers maintain a derived reference index in the same transaction +//! as each owner. Existing owners and body candidates are enrolled incrementally +//! after those triggers are installed. No body is collected before all three +//! bootstrap frontiers are complete. A candidate and its indexed references are +//! checked under one guarded writer, so another owner cannot race collection. + +use serde::{Deserialize, Serialize}; +use tracedecay_domain::errors::{Result, TraceDecayError}; +use tracedecay_runtime_core::db::Database; +use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, params}; +use tracedecay_store::collect_body_refs; + +pub(crate) mod schema; +#[cfg(test)] +mod tests; + +const OPERATION: &str = "collect unreferenced session canonical bodies"; +// Observation records may approach 1 MiB. This matches the observation audit's +// payload page bound and leaves headroom below the SQL channel's 64 MiB cap. +pub const MAX_CANONICAL_BODY_GC_ROWS: usize = 48; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CanonicalBodyGcReport { + pub roots_scanned: u64, + pub bodies_scanned: u64, + pub bodies_deleted: u64, + /// Stored BLOB bytes deleted, including the effect of their existing + /// compression. This is not database-file or filesystem space reclaimed. + pub stored_bytes_deleted: u64, + pub bootstrap_complete: bool, + /// Resume on the short maintenance cadence while bounded work remains. + pub has_more: bool, +} + +struct Progress { + raw_through: i64, + raw_until: i64, + observations_through: i64, + observations_until: i64, + bodies_after: String, + bodies_until: String, +} + +impl Progress { + fn complete(&self) -> bool { + self.raw_through == self.raw_until + && self.observations_through == self.observations_until + && self.bodies_after == self.bodies_until + } +} + +fn db_error(error: impl std::fmt::Display) -> TraceDecayError { + integrity_error(error.to_string()) +} + +fn integrity_error(message: impl Into) -> TraceDecayError { + TraceDecayError::Database { + operation: OPERATION.to_owned(), + message: message.into(), + } +} + +async fn read_progress(conn: &(impl QueryExecutor + ?Sized)) -> Result { + let mut rows = conn + .query( + "SELECT raw_through, raw_until, observations_through, observations_until, + bodies_after, bodies_until + FROM canonical_body_gc_progress WHERE singleton = 1", + (), + ) + .await + .map_err(db_error)?; + let row = + rows.next().await.map_err(db_error)?.ok_or_else(|| { + integrity_error("canonical body ownership bootstrap state is missing") + })?; + let progress = Progress { + raw_through: row.get(0).map_err(db_error)?, + raw_until: row.get(1).map_err(db_error)?, + observations_through: row.get(2).map_err(db_error)?, + observations_until: row.get(3).map_err(db_error)?, + bodies_after: row.get(4).map_err(db_error)?, + bodies_until: row.get(5).map_err(db_error)?, + }; + if progress.raw_through < 0 + || progress.raw_through > progress.raw_until + || progress.observations_through < 0 + || progress.observations_through > progress.observations_until + || progress.bodies_after > progress.bodies_until + { + return Err(integrity_error( + "canonical body ownership bootstrap state is invalid", + )); + } + Ok(progress) +} + +async fn save_progress(conn: &(impl Executor + ?Sized), progress: &Progress) -> Result<()> { + let changed = conn + .execute( + "UPDATE canonical_body_gc_progress + SET raw_through = ?1, observations_through = ?2, bodies_after = ?3 + WHERE singleton = 1 AND raw_until = ?4 AND observations_until = ?5 + AND bodies_until = ?6", + params![ + progress.raw_through, + progress.observations_through, + progress.bodies_after.as_str(), + progress.raw_until, + progress.observations_until, + progress.bodies_until.as_str() + ], + ) + .await + .map_err(db_error)?; + if changed != 1 { + return Err(integrity_error("canonical body ownership frontier changed")); + } + Ok(()) +} + +#[tracing::instrument(name = "global_db.canonical_bodies.gc", level = "trace", skip_all)] +pub(crate) async fn run(database: &Database, max_rows: usize) -> Result { + let transaction = database.begin_write_transaction(OPERATION).await?; + match step(&transaction, max_rows).await { + Ok(report) => { + transaction.commit().await?; + Ok(report) + } + Err(error) => match transaction.rollback().await { + Ok(()) => Err(error), + Err(rollback) => Err(integrity_error(format!( + "{error}; rollback failed: {rollback}" + ))), + }, + } +} + +async fn step(conn: &(impl Executor + ?Sized), max_rows: usize) -> Result { + schema::validate(conn).await?; + let limit = max_rows.clamp(1, MAX_CANONICAL_BODY_GC_ROWS) as i64; + let mut progress = read_progress(conn).await?; + let mut report = CanonicalBodyGcReport::default(); + if !progress.complete() { + if progress.raw_through < progress.raw_until { + report.roots_scanned = bootstrap_raw(conn, &mut progress, limit).await?; + } else if progress.observations_through < progress.observations_until { + report.roots_scanned = bootstrap_observations(conn, &mut progress, limit).await?; + } else { + report.bodies_scanned = bootstrap_bodies(conn, &mut progress, limit).await?; + } + save_progress(conn, &progress).await?; + } else { + collect_candidates(conn, limit, &mut report).await?; + } + report.bootstrap_complete = progress.complete(); + report.has_more = !report.bootstrap_complete || has_candidates(conn).await?; + Ok(report) +} + +async fn bootstrap_raw( + conn: &(impl Executor + ?Sized), + progress: &mut Progress, + limit: i64, +) -> Result { + let mut rows = conn + .query( + "SELECT store_id, CASE WHEN storage_kind = 'inline' AND content IS NULL + THEN content_hash END + FROM lcm_raw_messages WHERE store_id > ?1 AND store_id <= ?2 + ORDER BY store_id LIMIT ?3", + params![progress.raw_through, progress.raw_until, limit], + ) + .await + .map_err(db_error)?; + let mut page = Vec::new(); + while let Some(row) = rows.next().await.map_err(db_error)? { + page.push(( + row.get::(0).map_err(db_error)?, + row.get::>(1).map_err(db_error)?, + )); + } + drop(rows); + for (owner, hash) in &page { + if let Some(hash) = hash { + conn.execute( + "INSERT OR IGNORE INTO canonical_body_gc_refs(owner_kind, owner_id, content_hash) + VALUES ('raw', ?1, ?2)", + params![*owner, hash.as_str()], + ) + .await + .map_err(db_error)?; + } + progress.raw_through = *owner; + } + if page.len() < limit as usize { + progress.raw_through = progress.raw_until; + } + Ok(page.len() as u64) +} + +async fn bootstrap_observations( + conn: &(impl Executor + ?Sized), + progress: &mut Progress, + limit: i64, +) -> Result { + let mut rows = conn + .query( + "SELECT sequence, observation_json FROM observations + WHERE sequence > ?1 AND sequence <= ?2 ORDER BY sequence LIMIT ?3", + params![ + progress.observations_through, + progress.observations_until, + limit + ], + ) + .await + .map_err(db_error)?; + let mut page = Vec::new(); + while let Some(row) = rows.next().await.map_err(db_error)? { + page.push(( + row.get::(0).map_err(db_error)?, + row.get::(1).map_err(db_error)?, + )); + } + drop(rows); + for (owner, json) in &page { + for hash in collect_body_refs(json).map_err(db_error)? { + conn.execute( + "INSERT OR IGNORE INTO canonical_body_gc_refs(owner_kind, owner_id, content_hash) + VALUES ('observation', ?1, ?2)", + params![*owner, hash], + ) + .await + .map_err(db_error)?; + } + progress.observations_through = *owner; + } + if page.len() < limit as usize { + progress.observations_through = progress.observations_until; + } + Ok(page.len() as u64) +} + +async fn bootstrap_bodies( + conn: &(impl Executor + ?Sized), + progress: &mut Progress, + limit: i64, +) -> Result { + let mut rows = conn + .query( + "SELECT content_hash FROM session_canonical_bodies + WHERE content_hash > ?1 AND content_hash <= ?2 ORDER BY content_hash LIMIT ?3", + params![ + progress.bodies_after.as_str(), + progress.bodies_until.as_str(), + limit + ], + ) + .await + .map_err(db_error)?; + let mut page = Vec::new(); + while let Some(row) = rows.next().await.map_err(db_error)? { + page.push(row.get::(0).map_err(db_error)?); + } + drop(rows); + for hash in &page { + conn.execute( + "INSERT OR IGNORE INTO canonical_body_gc_candidates(content_hash) VALUES (?1)", + params![hash.as_str()], + ) + .await + .map_err(db_error)?; + progress.bodies_after.clone_from(hash); + } + if page.len() < limit as usize { + progress.bodies_after.clone_from(&progress.bodies_until); + } + Ok(page.len() as u64) +} + +async fn collect_candidates( + conn: &(impl Executor + ?Sized), + limit: i64, + report: &mut CanonicalBodyGcReport, +) -> Result<()> { + let mut rows = conn + .query( + "SELECT content_hash FROM canonical_body_gc_candidates ORDER BY content_hash LIMIT ?1", + params![limit], + ) + .await + .map_err(db_error)?; + let mut hashes = Vec::new(); + while let Some(row) = rows.next().await.map_err(db_error)? { + hashes.push(row.get::(0).map_err(db_error)?); + } + drop(rows); + report.bodies_scanned = hashes.len() as u64; + if hashes.is_empty() { + return Ok(()); + } + let hashes = serde_json::to_string(&hashes).map_err(db_error)?; + let mut deleted = conn + .query( + "DELETE FROM session_canonical_bodies + WHERE content_hash IN (SELECT value FROM json_each(?1)) + AND NOT EXISTS (SELECT 1 FROM canonical_body_gc_refs r + WHERE r.content_hash = session_canonical_bodies.content_hash) + RETURNING length(body)", + params![hashes.as_str()], + ) + .await + .map_err(db_error)?; + while let Some(row) = deleted.next().await.map_err(db_error)? { + let bytes = u64::try_from(row.get::(0).map_err(db_error)?) + .map_err(|_| integrity_error("canonical body stored byte count is negative"))?; + report.bodies_deleted += 1; + report.stored_bytes_deleted = report.stored_bytes_deleted.saturating_add(bytes); + } + drop(deleted); + // Referenced candidates are discharged too. The final owner removal + // records them again; idle ticks never revisit every retained body. + conn.execute( + "DELETE FROM canonical_body_gc_candidates + WHERE content_hash IN (SELECT value FROM json_each(?1))", + params![hashes], + ) + .await + .map_err(db_error)?; + Ok(()) +} + +async fn has_candidates(conn: &(impl QueryExecutor + ?Sized)) -> Result { + let mut rows = conn + .query("SELECT 1 FROM canonical_body_gc_candidates LIMIT 1", ()) + .await + .map_err(db_error)?; + rows.next().await.map(|row| row.is_some()).map_err(db_error) +} diff --git a/crates/tracedecay-global-db/src/observation/canonical_body_gc/schema.rs b/crates/tracedecay-global-db/src/observation/canonical_body_gc/schema.rs new file mode 100644 index 0000000000..3d80ad0bd7 --- /dev/null +++ b/crates/tracedecay-global-db/src/observation/canonical_body_gc/schema.rs @@ -0,0 +1,249 @@ +use std::collections::BTreeMap; + +use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, params}; + +use super::{db_error, integrity_error}; + +/// Derived ownership, never another copy of a body. The bootstrap progress is +/// committed with installation of every writer trigger, before deletion is +/// permitted. Admission can install this component without rewriting evidence. +pub(crate) const OBJECTS: &[(&str, &str)] = &[ + ( + "canonical_body_gc_refs", + "CREATE TABLE canonical_body_gc_refs ( + owner_kind TEXT NOT NULL CHECK(owner_kind IN ('raw', 'observation')), + owner_id INTEGER NOT NULL, + content_hash TEXT NOT NULL, + PRIMARY KEY(owner_kind, owner_id, content_hash), + FOREIGN KEY(content_hash) REFERENCES session_canonical_bodies(content_hash) + ON DELETE RESTRICT + )", + ), + ( + "canonical_body_gc_by_hash", + "CREATE INDEX canonical_body_gc_by_hash ON canonical_body_gc_refs(content_hash)", + ), + ( + "canonical_body_gc_candidates", + "CREATE TABLE canonical_body_gc_candidates ( + content_hash TEXT NOT NULL PRIMARY KEY, + FOREIGN KEY(content_hash) REFERENCES session_canonical_bodies(content_hash) + ON DELETE CASCADE + )", + ), + ( + "canonical_body_gc_progress", + "CREATE TABLE canonical_body_gc_progress ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + raw_through INTEGER NOT NULL CHECK(raw_through >= 0), + raw_until INTEGER NOT NULL CHECK(raw_until >= raw_through), + observations_through INTEGER NOT NULL CHECK(observations_through >= 0), + observations_until INTEGER NOT NULL CHECK(observations_until >= observations_through), + bodies_after TEXT NOT NULL, + bodies_until TEXT NOT NULL CHECK(bodies_until >= bodies_after) + )", + ), + ( + "canonical_body_gc_body_insert", + "CREATE TRIGGER canonical_body_gc_body_insert + AFTER INSERT ON session_canonical_bodies BEGIN + INSERT OR IGNORE INTO canonical_body_gc_candidates(content_hash) + VALUES (NEW.content_hash); + END", + ), + ( + "canonical_body_gc_ref_delete", + "CREATE TRIGGER canonical_body_gc_ref_delete + AFTER DELETE ON canonical_body_gc_refs BEGIN + INSERT OR IGNORE INTO canonical_body_gc_candidates(content_hash) + VALUES (OLD.content_hash); + END", + ), + ( + "canonical_body_gc_raw_insert", + "CREATE TRIGGER canonical_body_gc_raw_insert + AFTER INSERT ON lcm_raw_messages + WHEN NEW.storage_kind = 'inline' AND NEW.content IS NULL BEGIN + INSERT OR IGNORE INTO canonical_body_gc_refs(owner_kind, owner_id, content_hash) + VALUES ('raw', NEW.store_id, NEW.content_hash); + END", + ), + ( + "canonical_body_gc_raw_delete", + "CREATE TRIGGER canonical_body_gc_raw_delete + AFTER DELETE ON lcm_raw_messages BEGIN + DELETE FROM canonical_body_gc_refs + WHERE owner_kind = 'raw' AND owner_id = OLD.store_id; + END", + ), + ( + "canonical_body_gc_raw_update", + "CREATE TRIGGER canonical_body_gc_raw_update + AFTER UPDATE OF store_id, storage_kind, content, content_hash ON lcm_raw_messages + WHEN OLD.store_id IS NOT NEW.store_id + OR OLD.content_hash IS NOT NEW.content_hash + OR (OLD.storage_kind = 'inline' AND OLD.content IS NULL) + IS NOT (NEW.storage_kind = 'inline' AND NEW.content IS NULL) + BEGIN + DELETE FROM canonical_body_gc_refs + WHERE owner_kind = 'raw' AND owner_id = OLD.store_id; + INSERT OR IGNORE INTO canonical_body_gc_refs(owner_kind, owner_id, content_hash) + SELECT 'raw', NEW.store_id, NEW.content_hash + WHERE NEW.storage_kind = 'inline' AND NEW.content IS NULL; + END", + ), + ( + "canonical_body_gc_observation_insert", + "CREATE TRIGGER canonical_body_gc_observation_insert + AFTER INSERT ON observations BEGIN + SELECT CASE + WHEN NOT json_valid(NEW.observation_json) + THEN RAISE(ABORT, 'invalid canonical body reference document') + WHEN json_type(NEW.observation_json, '$._tracedecay_canonical_body_refs') + IS NOT NULL AND json_type(NEW.observation_json, + '$._tracedecay_canonical_body_refs') <> 'object' + THEN RAISE(ABORT, 'invalid canonical body reference map') + WHEN EXISTS (SELECT 1 FROM json_each(NEW.observation_json, + '$._tracedecay_canonical_body_refs') WHERE type <> 'text') + THEN RAISE(ABORT, 'invalid canonical body reference hash') + END; + INSERT OR IGNORE INTO canonical_body_gc_refs(owner_kind, owner_id, content_hash) + SELECT 'observation', NEW.sequence, value + FROM json_each(NEW.observation_json, '$._tracedecay_canonical_body_refs'); + END", + ), + ( + "canonical_body_gc_observation_delete", + "CREATE TRIGGER canonical_body_gc_observation_delete + AFTER DELETE ON observations BEGIN + DELETE FROM canonical_body_gc_refs + WHERE owner_kind = 'observation' AND owner_id = OLD.sequence; + END", + ), + ( + "canonical_body_gc_observation_update", + "CREATE TRIGGER canonical_body_gc_observation_update + AFTER UPDATE OF sequence, observation_json ON observations + WHEN OLD.sequence IS NOT NEW.sequence OR OLD.observation_json IS NOT NEW.observation_json + BEGIN + SELECT CASE + WHEN NOT json_valid(NEW.observation_json) + THEN RAISE(ABORT, 'invalid canonical body reference document') + WHEN json_type(NEW.observation_json, '$._tracedecay_canonical_body_refs') + IS NOT NULL AND json_type(NEW.observation_json, + '$._tracedecay_canonical_body_refs') <> 'object' + THEN RAISE(ABORT, 'invalid canonical body reference map') + WHEN EXISTS (SELECT 1 FROM json_each(NEW.observation_json, + '$._tracedecay_canonical_body_refs') WHERE type <> 'text') + THEN RAISE(ABORT, 'invalid canonical body reference hash') + END; + DELETE FROM canonical_body_gc_refs + WHERE owner_kind = 'observation' AND owner_id = OLD.sequence; + INSERT OR IGNORE INTO canonical_body_gc_refs(owner_kind, owner_id, content_hash) + SELECT 'observation', NEW.sequence, value + FROM json_each(NEW.observation_json, '$._tracedecay_canonical_body_refs'); + END", + ), +]; + +async fn inventory( + conn: &(impl QueryExecutor + ?Sized), +) -> tracedecay_domain::errors::Result> { + let mut rows = conn + .query( + "SELECT name, sql FROM sqlite_master + WHERE name GLOB 'canonical_body_gc_*' ORDER BY name LIMIT ?1", + params![OBJECTS.len() as i64 + 1], + ) + .await + .map_err(db_error)?; + let mut objects = BTreeMap::new(); + while let Some(row) = rows.next().await.map_err(db_error)? { + objects.insert( + row.get::(0).map_err(db_error)?, + row.get::(1).map_err(db_error)?, + ); + } + Ok(objects) +} + +fn require_inventory(objects: &BTreeMap) -> tracedecay_domain::errors::Result<()> { + if objects.len() != OBJECTS.len() { + return Err(integrity_error( + "canonical body ownership schema is incomplete", + )); + } + for &(name, expected) in OBJECTS { + // These objects are installed from these exact statements. Preserve + // case inside JSON paths and owner literals: case-folding a changed + // trigger could hide a reference-tracking failure. + if objects + .get(name) + .is_none_or(|actual| actual.trim().trim_end_matches(';') != expected) + { + return Err(integrity_error(format!( + "canonical body ownership object {name} does not match its contract" + ))); + } + } + Ok(()) +} + +pub(super) async fn validate( + conn: &(impl QueryExecutor + ?Sized), +) -> tracedecay_domain::errors::Result<()> { + require_inventory(&inventory(conn).await?) +} + +pub(crate) async fn install( + conn: &(impl Executor + ?Sized), +) -> tracedecay_domain::errors::Result<()> { + let objects = inventory(conn).await?; + if !objects.is_empty() { + require_inventory(&objects)?; + super::read_progress(conn).await?; + return Ok(()); + } + // The keyset bootstrap begins at zero. INTEGER PRIMARY KEY alone does + // not forbid hand-written nonpositive identities, so prove the frontier + // cannot skip an old owner before installing its collection authority. + let mut minima = conn + .query( + "SELECT (SELECT MIN(store_id) FROM lcm_raw_messages), + (SELECT MIN(sequence) FROM observations)", + (), + ) + .await + .map_err(db_error)?; + let minima = minima + .next() + .await + .map_err(db_error)? + .ok_or_else(|| integrity_error("canonical body ownership minima are missing"))?; + for index in 0..2 { + if minima + .get::>(index) + .map_err(db_error)? + .is_some_and(|id| id <= 0) + { + return Err(integrity_error( + "canonical body ownership requires positive source identities", + )); + } + } + for &(_, sql) in OBJECTS { + conn.execute_batch(sql).await.map_err(db_error)?; + } + conn.execute( + "INSERT INTO canonical_body_gc_progress ( + singleton, raw_through, raw_until, observations_through, observations_until, + bodies_after, bodies_until + ) SELECT 1, 0, COALESCE((SELECT MAX(store_id) FROM lcm_raw_messages), 0), + 0, COALESCE((SELECT MAX(sequence) FROM observations), 0), + '', COALESCE((SELECT MAX(content_hash) FROM session_canonical_bodies), '')", + (), + ) + .await + .map_err(db_error)?; + Ok(()) +} diff --git a/crates/tracedecay-global-db/src/observation/canonical_body_gc/tests.rs b/crates/tracedecay-global-db/src/observation/canonical_body_gc/tests.rs new file mode 100644 index 0000000000..9da4f65432 --- /dev/null +++ b/crates/tracedecay-global-db/src/observation/canonical_body_gc/tests.rs @@ -0,0 +1,508 @@ +use tracedecay_runtime_core::db::engine::{ + Connection, Executor, QueryExecutor, TestConnection, TransactionBehavior, params, +}; +use tracedecay_store::{ + BODY_REF_KEY, CANONICAL_BODIES_TABLE_SQL, StoredCanonicalBody, UPSERT_CANONICAL_BODY_SQL, +}; + +use super::{CanonicalBodyGcReport, has_candidates, read_progress, schema, step}; + +struct Store { + runtime: TestConnection, + _directory: tempfile::TempDir, +} + +impl Store { + async fn open() -> Self { + let directory = tempfile::tempdir().unwrap(); + let runtime = TestConnection::open(&directory.path().join("canonical-bodies.db")); + runtime + .execute_batch(CANONICAL_BODIES_TABLE_SQL) + .await + .unwrap(); + runtime + .execute_batch( + "CREATE TABLE lcm_raw_messages ( + store_id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_kind TEXT NOT NULL, content TEXT, content_hash TEXT NOT NULL + ); + CREATE TABLE observations ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + observation_json TEXT NOT NULL + );", + ) + .await + .unwrap(); + Self { + runtime, + _directory: directory, + } + } + + fn conn(&self) -> &Connection { + &self.runtime + } + + async fn install(&self) { + let transaction = self + .conn() + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .unwrap(); + schema::install(&transaction).await.unwrap(); + transaction.commit().await.unwrap(); + } + + async fn tick(&self, limit: usize) -> CanonicalBodyGcReport { + let transaction = self + .conn() + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .unwrap(); + let report = step(&transaction, limit).await.unwrap(); + transaction.commit().await.unwrap(); + report + } +} + +async fn insert_body(conn: &(impl Executor + ?Sized), body: &StoredCanonicalBody) { + conn.execute( + UPSERT_CANONICAL_BODY_SQL, + params![ + body.content_hash.as_str(), + body.encoding, + body.blob.as_slice(), + body.uncompressed_bytes + ], + ) + .await + .unwrap(); +} + +async fn body_exists(conn: &(impl QueryExecutor + ?Sized), hash: &str) -> bool { + conn.query( + "SELECT 1 FROM session_canonical_bodies WHERE content_hash = ?1", + params![hash], + ) + .await + .unwrap() + .next() + .await + .unwrap() + .is_some() +} + +fn observation(hash: &str) -> String { + serde_json::json!({ + "payload": {"text": null}, + (BODY_REF_KEY): {"/payload/text": hash} + }) + .to_string() +} + +async fn drain(store: &Store, limit: usize) -> (u64, u64) { + let mut deleted = 0; + let mut bytes = 0; + for _ in 0..32 { + let report = store.tick(limit).await; + assert!(report.roots_scanned <= limit as u64); + assert!(report.bodies_scanned <= limit as u64); + if !report.bootstrap_complete { + assert_eq!(report.bodies_deleted, 0, "bootstrap never collects"); + } + deleted += report.bodies_deleted; + bytes += report.stored_bytes_deleted; + if !report.has_more { + return (deleted, bytes); + } + } + panic!("bounded fixture must converge"); +} + +#[asupersync::test] +async fn bootstrap_and_owner_release_preserve_shared_bodies_until_the_final_reference() { + let store = Store::open().await; + let shared = + StoredCanonicalBody::pack("shared complete text ".repeat(1024).as_bytes()).unwrap(); + let orphan = + StoredCanonicalBody::pack("unreferenced old text ".repeat(1024).as_bytes()).unwrap(); + insert_body(store.conn(), &shared).await; + insert_body(store.conn(), &orphan).await; + store.conn().execute( + "INSERT INTO lcm_raw_messages(storage_kind, content, content_hash) VALUES ('inline', NULL, ?1)", + params![shared.content_hash.as_str()], + ).await.unwrap(); + store + .conn() + .execute( + "INSERT INTO observations(observation_json) VALUES (?1)", + params![observation(&shared.content_hash)], + ) + .await + .unwrap(); + store.install().await; + + let first = store.tick(1).await; + assert_eq!(first.roots_scanned, 1); + assert!( + !first.bootstrap_complete, + "observation and body seed are still pending" + ); + assert_eq!(first.bodies_deleted, 0); + assert!(body_exists(store.conn(), &orphan.content_hash).await); + let (deleted, bytes) = drain(&store, 1).await; + assert_eq!(deleted, 1); + assert_eq!(bytes, orphan.blob.len() as u64); + assert!(body_exists(store.conn(), &shared.content_hash).await); + assert!( + store + .conn() + .execute( + "DELETE FROM session_canonical_bodies WHERE content_hash = ?1", + params![shared.content_hash.as_str()] + ) + .await + .is_err(), + "the reference index also enforces its body foreign key" + ); + + store + .conn() + .execute("DELETE FROM lcm_raw_messages", ()) + .await + .unwrap(); + assert_eq!( + store.tick(1).await.bodies_deleted, + 0, + "observation still owns the body" + ); + store.conn().execute("UPDATE observations SET observation_json = '{\"__retention_released\":\"observation\"}'", ()).await.unwrap(); + let released = store.tick(1).await; + assert_eq!(released.bodies_deleted, 1); + assert_eq!(released.stored_bytes_deleted, shared.blob.len() as u64); + assert!(released.stored_bytes_deleted < shared.uncompressed_bytes as u64); + assert!(!released.has_more); +} + +#[asupersync::test] +async fn bootstrap_survives_interruption_and_tracks_owners_changed_between_pages() { + let store = Store::open().await; + let old = StoredCanonicalBody::pack("old complete body ".repeat(1024).as_bytes()).unwrap(); + let new = StoredCanonicalBody::pack("new complete body ".repeat(1024).as_bytes()).unwrap(); + insert_body(store.conn(), &old).await; + store + .conn() + .execute( + "INSERT INTO lcm_raw_messages(storage_kind, content_hash) VALUES ('inline', ?1)", + params![old.content_hash.as_str()], + ) + .await + .unwrap(); + store + .conn() + .execute( + "INSERT INTO observations(observation_json) VALUES (?1)", + params![observation(&old.content_hash)], + ) + .await + .unwrap(); + store.install().await; + + let transaction = store + .conn() + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .unwrap(); + assert_eq!(step(&transaction, 1).await.unwrap().roots_scanned, 1); + transaction.rollback().await.unwrap(); + assert_eq!( + read_progress(store.conn()).await.unwrap().raw_through, + 0, + "interruption rolls back the ownership page and frontier together" + ); + assert!(body_exists(store.conn(), &old.content_hash).await); + assert_eq!(store.tick(1).await.roots_scanned, 1); + + let transaction = store + .conn() + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .unwrap(); + insert_body(&transaction, &new).await; + transaction + .execute( + "INSERT INTO lcm_raw_messages(storage_kind, content_hash) VALUES ('inline', ?1)", + params![new.content_hash.as_str()], + ) + .await + .unwrap(); + transaction + .execute("DELETE FROM lcm_raw_messages WHERE store_id = 1", ()) + .await + .unwrap(); + transaction + .execute( + "UPDATE observations SET observation_json = ?1", + params![observation(&new.content_hash)], + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + + let (deleted, _) = drain(&store, 1).await; + assert_eq!(deleted, 1); + assert!(!body_exists(store.conn(), &old.content_hash).await); + assert!( + body_exists(store.conn(), &new.content_hash).await, + "owners above the initial frontier are tracked by writer triggers" + ); + store + .conn() + .execute("DELETE FROM observations", ()) + .await + .unwrap(); + assert_eq!( + store.tick(1).await.bodies_deleted, + 0, + "the raw owner added during bootstrap survives" + ); +} + +#[asupersync::test] +async fn damaged_guards_and_unresolvable_old_references_block_collection() { + let store = Store::open().await; + let orphan = StoredCanonicalBody::pack(b"unreferenced body").unwrap(); + insert_body(store.conn(), &orphan).await; + store + .conn() + .execute( + "INSERT INTO observations(observation_json) VALUES (?1)", + params![observation("missing-body")], + ) + .await + .unwrap(); + store.install().await; + let transaction = store + .conn() + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .unwrap(); + assert!( + step(&transaction, 1).await.is_err(), + "missing referenced body must not earn bootstrap completeness" + ); + transaction.rollback().await.unwrap(); + assert_eq!( + read_progress(store.conn()) + .await + .unwrap() + .observations_through, + 0 + ); + assert!(body_exists(store.conn(), &orphan.content_hash).await); + store + .conn() + .execute_batch("DROP TRIGGER canonical_body_gc_observation_insert") + .await + .unwrap(); + let transaction = store + .conn() + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .unwrap(); + let error = step(&transaction, 1).await.unwrap_err(); + assert!(error.to_string().contains("schema is incomplete")); + transaction.rollback().await.unwrap(); + assert!(body_exists(store.conn(), &orphan.content_hash).await); +} + +#[asupersync::test] +async fn registered_collection_commits_and_lost_authority_rolls_back_deleted_bodies() { + let mut harness = + crate::tests::harness::RegisteredGlobalDbHarness::open("canonical-body-gc").await; + let database = harness.registered.clone(); + let first = StoredCanonicalBody::pack(b"first unowned canonical body").unwrap(); + let retained = StoredCanonicalBody::pack(b"body whose collection is revoked").unwrap(); + let transaction = database.begin_write_transaction().await.unwrap(); + insert_body(&transaction, &first).await; + transaction.commit().await.unwrap(); + let report = database.run_canonical_body_gc(1).await.unwrap(); + assert!(report.bootstrap_complete); + assert_eq!(report.bodies_deleted, 1); + assert_eq!(report.stored_bytes_deleted, first.blob.len() as u64); + + let transaction = database.begin_write_transaction().await.unwrap(); + insert_body(&transaction, &retained).await; + transaction.commit().await.unwrap(); + let transaction = database.begin_write_transaction().await.unwrap(); + assert_eq!(step(&transaction, 1).await.unwrap().bodies_deleted, 1); + harness.revoke(); + assert!( + transaction.commit().await.is_err(), + "revoked writer cannot publish collection" + ); + assert!(body_exists(&database.read_connection(), &retained.content_hash).await); + assert!(database.run_canonical_body_gc(1).await.is_err()); +} + +#[asupersync::test] +async fn registered_legacy_bootstrap_and_live_references_survive_restarts() { + let mut harness = + crate::tests::harness::RegisteredGlobalDbHarness::open("canonical-body-gc-restart").await; + let live = StoredCanonicalBody::pack(b"retained by the registered raw owner").unwrap(); + let orphan = StoredCanonicalBody::pack(b"unowned before additive installation").unwrap(); + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + insert_body(&transaction, &live).await; + insert_body(&transaction, &orphan).await; + transaction + .execute_batch( + "INSERT INTO sessions(provider, session_id, project_key, project_path) + VALUES ('codex', 'gc-session', '/gc-project', '/gc-project');", + ) + .await + .unwrap(); + transaction + .execute( + "INSERT INTO lcm_raw_messages(provider, message_id, session_id, role, ordinal, + content, content_hash, storage_kind) + VALUES ('codex', 'gc-message', 'gc-session', 'assistant', 1, NULL, ?1, 'inline')", + params![live.content_hash.as_str()], + ) + .await + .unwrap(); + // Model an older admitted store: remove only derived GC objects while + // retaining canonical bodies and both source-root tables. + for &(name, sql) in schema::OBJECTS.iter().rev() { + let kind = if sql.starts_with("CREATE TRIGGER") { + "TRIGGER" + } else if sql.starts_with("CREATE INDEX") { + "INDEX" + } else { + "TABLE" + }; + transaction + .execute_batch(&format!("DROP {kind} {name}")) + .await + .unwrap(); + } + transaction.commit().await.unwrap(); + harness = harness.restart().await; + let report = harness.registered.run_canonical_body_gc(1).await.unwrap(); + assert_eq!(report.roots_scanned, 1); + assert!(!report.bootstrap_complete); + assert_eq!(report.bodies_deleted, 0); + let before = read_progress(&harness.registered.read_connection()) + .await + .unwrap(); + harness = harness.restart().await; + let after = read_progress(&harness.registered.read_connection()) + .await + .unwrap(); + assert_eq!(after.raw_through, before.raw_through); + assert_eq!(after.bodies_after, before.bodies_after); + schema::validate(&harness.registered.read_connection()) + .await + .unwrap(); + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + assert!( + transaction + .execute( + "DELETE FROM session_canonical_bodies WHERE content_hash = ?1", + params![live.content_hash.as_str()], + ) + .await + .is_err(), + "the registered production writer enforces the owner foreign key" + ); + transaction.rollback().await.unwrap(); + let seed = harness.registered.run_canonical_body_gc(1).await.unwrap(); + assert_eq!(seed.bodies_scanned, 1); + harness = harness.restart().await; + assert!( + has_candidates(&harness.registered.read_connection()) + .await + .unwrap(), + "candidate seed persists across reopen" + ); + let mut deleted = 0; + for _ in 0..8 { + let report = harness.registered.run_canonical_body_gc(1).await.unwrap(); + deleted += report.bodies_deleted; + if !report.has_more { + break; + } + } + assert_eq!(deleted, 1); + assert!(body_exists(&harness.registered.read_connection(), &live.content_hash).await); + assert!(!body_exists(&harness.registered.read_connection(), &orphan.content_hash).await); + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + transaction + .execute( + "DELETE FROM lcm_raw_messages WHERE provider = 'codex' AND message_id = 'gc-message'", + (), + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + assert_eq!( + harness + .registered + .run_canonical_body_gc(1) + .await + .unwrap() + .bodies_deleted, + 1 + ); +} + +#[asupersync::test] +async fn nonpositive_legacy_owner_ids_cannot_be_skipped_by_bootstrap() { + for raw_owner in [true, false] { + let store = Store::open().await; + let body = StoredCanonicalBody::pack(b"retained legacy body").unwrap(); + insert_body(store.conn(), &body).await; + if raw_owner { + store + .conn() + .execute( + "INSERT INTO lcm_raw_messages(store_id, storage_kind, content, content_hash) + VALUES (0, 'inline', NULL, ?1)", + params![body.content_hash.as_str()], + ) + .await + .unwrap(); + } else { + store + .conn() + .execute( + "INSERT INTO observations(sequence, observation_json) VALUES (-1, ?1)", + params![observation(&body.content_hash)], + ) + .await + .unwrap(); + } + let transaction = store + .conn() + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .unwrap(); + let error = schema::install(&transaction).await.unwrap_err(); + assert!(error.to_string().contains("positive source identities")); + transaction.rollback().await.unwrap(); + assert!(body_exists(store.conn(), &body.content_hash).await); + assert!( + store + .conn() + .query( + "SELECT 1 FROM sqlite_master WHERE name GLOB 'canonical_body_gc_*' LIMIT 1", + () + ) + .await + .unwrap() + .next() + .await + .unwrap() + .is_none() + ); + } +} diff --git a/crates/tracedecay-global-db/src/observation/mod.rs b/crates/tracedecay-global-db/src/observation/mod.rs index 9b7d176b21..a7b4c667ab 100644 --- a/crates/tracedecay-global-db/src/observation/mod.rs +++ b/crates/tracedecay-global-db/src/observation/mod.rs @@ -1,4 +1,5 @@ mod canonical_bodies; +pub(crate) mod canonical_body_gc; mod codec; mod persist; mod refusal_census; @@ -7,6 +8,7 @@ mod schema; pub(crate) use canonical_bodies::compact_lcm_bodies; pub use canonical_bodies::decode_observation_json; +pub use canonical_body_gc::{CanonicalBodyGcReport, MAX_CANONICAL_BODY_GC_ROWS}; pub use refusal_census::ingest_refusal_read_from_censuses; pub(super) use schema::{OBSERVATION_ADMISSION_MARKERS, ensure_observation_schema}; diff --git a/crates/tracedecay-global-db/src/observation/retention.rs b/crates/tracedecay-global-db/src/observation/retention.rs index 36c2154bcd..33efbdae93 100644 --- a/crates/tracedecay-global-db/src/observation/retention.rs +++ b/crates/tracedecay-global-db/src/observation/retention.rs @@ -240,7 +240,8 @@ pub struct ObservationRetentionPhaseReport { pub eligible: u64, /// Rows actually released (`0` in a dry run). pub acted: u64, - /// Bytes of payload reclaimed from the database by this pass. + /// Logical UTF-8 payload bytes removed, excluding replacement markers. + /// Page and file reclamation are measured separately. pub bytes_reclaimed: u64, /// Oldest governing disposition timestamp among the bounded eligible rows. #[serde(default)] @@ -606,11 +607,9 @@ async fn execute_required(executor: &(impl Executor + ?Sized), sql: &str) -> Res .map_err(db_error) } -/// Reclaimed bytes for one released column: the original length minus the -/// compact marker that replaces it (saturating so a payload already smaller -/// than the marker never underflows). -fn reclaimed_bytes(original_len: u64, marker: &str) -> u64 { - original_len.saturating_sub(marker.len() as u64) +/// Logical payload reduction across the columns replaced with a marker. +fn reclaimed_bytes(original_len: u64, marker: &str, marker_columns: u64) -> u64 { + original_len.saturating_sub((marker.len() as u64).saturating_mul(marker_columns)) } /// One row whose payload a pass selected for release. @@ -626,6 +625,7 @@ struct PayloadRelease { drop_trigger: &'static str, create_trigger: &'static str, marker: &'static str, + marker_columns: u64, /// `UPDATE … RETURNING id` over `{ids}` that re-checks each row's /// eligibility (`?1` is the marker, `?2` the disposition cutoff). update: String, @@ -673,7 +673,9 @@ async fn run_release_pass( if !mode.is_apply() { report.bytes_reclaimed = targets .iter() - .map(|target| reclaimed_bytes(target.original_len, release.marker)) + .map(|target| { + reclaimed_bytes(target.original_len, release.marker, release.marker_columns) + }) .sum(); return Ok(report); } @@ -733,7 +735,9 @@ async fn release_payload_batch( chunk .iter() .filter(|target| released.contains(&target.id)) - .map(|target| reclaimed_bytes(target.original_len, release.marker)) + .map(|target| { + reclaimed_bytes(target.original_len, release.marker, release.marker_columns) + }) .sum(), ); } @@ -819,7 +823,7 @@ async fn run_anchor_pass( }; let cutoff = release_cutoff(window, now); let sql = format!( - "SELECT a.anchor_id, LENGTH(a.anchor_json) AS len, + "SELECT a.anchor_id, LENGTH(CAST(a.anchor_json AS BLOB)) AS len, ( SELECT d.effective_at FROM retrieval_anchor_dispositions d @@ -840,6 +844,7 @@ async fn run_anchor_pass( drop_trigger: DROP_ANCHOR_UPDATE_TRIGGER, create_trigger: CREATE_ANCHOR_UPDATE_TRIGGER, marker: ANCHOR_RELEASED_MARKER, + marker_columns: 1, update: format!( "UPDATE retrieval_anchors AS a SET anchor_json = ?1 WHERE a.anchor_id IN ({{ids}}) @@ -900,7 +905,7 @@ async fn run_observation_pass( // rewrites the payload, and restores the exact canonical trigger before // commit. let sql = format!( - "SELECT o.observation_id, LENGTH(o.observation_json) AS len, + "SELECT o.observation_id, LENGTH(CAST(o.observation_json AS BLOB)) AS len, released.effective_at FROM observations o JOIN ( @@ -932,6 +937,7 @@ async fn run_observation_pass( drop_trigger: DROP_OBSERVATION_UPDATE_TRIGGER, create_trigger: CREATE_OBSERVATION_UPDATE_TRIGGER, marker: OBSERVATION_RELEASED_MARKER, + marker_columns: 1, update: format!( "UPDATE observations AS o SET observation_json = ?1 WHERE o.observation_id IN ({{ids}}) @@ -967,7 +973,8 @@ async fn run_provenance_pass( // IS NULL)` satisfied. let sql = format!( "SELECT p.observation_id, - LENGTH(p.availability_json) + LENGTH(COALESCE(p.capture_json, '')) AS len, + LENGTH(CAST(p.availability_json AS BLOB)) + + LENGTH(CAST(COALESCE(p.capture_json, '') AS BLOB)) AS len, ( SELECT d.effective_at FROM retrieval_anchor_dispositions d @@ -990,6 +997,7 @@ async fn run_provenance_pass( drop_trigger: DROP_PROVENANCE_UPDATE_TRIGGER, create_trigger: CREATE_PROVENANCE_UPDATE_TRIGGER, marker: PROVENANCE_RELEASED_MARKER, + marker_columns: 2, update: format!( "UPDATE observation_repository_provenance AS p SET availability_json = ?1, capture_json = ?1 diff --git a/crates/tracedecay-global-db/src/observation/retention/tests.rs b/crates/tracedecay-global-db/src/observation/retention/tests.rs index 91add69739..c3afed8fdc 100644 --- a/crates/tracedecay-global-db/src/observation/retention/tests.rs +++ b/crates/tracedecay-global-db/src/observation/retention/tests.rs @@ -101,6 +101,16 @@ async fn seed_evidence( generation: &str, size: usize, ) -> Result<(), String> { + seed_evidence_with_text(conn, anchor_id, generation, &"x".repeat(size)).await +} + +async fn seed_evidence_with_text( + conn: &RetentionTestStore, + anchor_id: &str, + generation: &str, + text: &str, +) -> Result<(), String> { + let payload = |field: &str| serde_json::json!({ (field): text }).to_string(); let observation_id = format!("obs-{anchor_id}"); let receipt_id = format!("receipt-{anchor_id}"); conn.execute( @@ -117,12 +127,18 @@ async fn seed_evidence( params![ observation_id.as_str(), receipt_id.as_str(), - payload("body", size) + payload("body") ], ) .await .map_err(|err| format!("insert observation: {err}"))?; - insert_anchor(conn, anchor_id, generation, size).await?; + conn.execute( + "INSERT INTO retrieval_anchors(anchor_id, anchor_json, owner_json, projection_generation) + VALUES (?1, ?2, ?3, ?4)", + params![anchor_id, payload("target"), OWNER, generation], + ) + .await + .map_err(|err| format!("insert anchor: {err}"))?; conn.execute( "INSERT INTO observation_retrieval_anchors(observation_id, anchor_id) VALUES (?1, ?2)", @@ -136,8 +152,8 @@ async fn seed_evidence( VALUES (?1, ?2, ?3, ?4, ?5)", params![ observation_id.as_str(), - payload("avail", size), - payload("capture", size), + payload("avail"), + payload("capture"), anchor_id, OWNER ], @@ -702,6 +718,90 @@ async fn reports_measurable_reclaim_metrics() -> Result<(), String> { Ok(()) } +async fn evidence_payloads(conn: &RetentionTestStore) -> Result<[String; 4], String> { + let mut rows = conn + .query( + "SELECT a.anchor_json, o.observation_json, p.availability_json, p.capture_json + FROM retrieval_anchors a + JOIN observation_retrieval_anchors b ON b.anchor_id = a.anchor_id + JOIN observations o ON o.observation_id = b.observation_id + JOIN observation_repository_provenance p ON p.observation_id = o.observation_id + WHERE a.anchor_id = 'measured'", + (), + ) + .await + .map_err(|error| error.to_string())?; + let row = rows + .next() + .await + .map_err(|error| error.to_string())? + .ok_or("evidence payload row")?; + Ok([ + row.get(0).map_err(|error| error.to_string())?, + row.get(1).map_err(|error| error.to_string())?, + row.get(2).map_err(|error| error.to_string())?, + row.get(3).map_err(|error| error.to_string())?, + ]) +} + +#[asupersync::test] +async fn reports_utf8_payload_reduction_after_every_replacement_marker() -> Result<(), String> { + for text in ["evidence".repeat(64), "é漢🙂".repeat(64), String::new()] { + let conn = test_store().await; + seed_evidence_with_text(&conn, "measured", GEN, &text).await?; + set_disposition(&conn, "measured", "deleted", NOW - 90 * DAY, None).await?; + let before = evidence_payloads(&conn).await?; + let dry = conn + .database() + .run_observation_retention( + None, + &released_config(), + RetentionMode::DryRun, + UtcMicros(NOW), + ) + .await + .map_err(|error| error.to_string())?; + assert_eq!(evidence_payloads(&conn).await?, before); + + let applied = run_apply(&conn, None, &released_config()).await?; + let after = evidence_payloads(&conn).await?; + assert_eq!(after[0], ANCHOR_RELEASED_MARKER); + assert_eq!(after[1], OBSERVATION_RELEASED_MARKER); + assert_eq!(after[2], PROVENANCE_RELEASED_MARKER); + assert_eq!(after[3], PROVENANCE_RELEASED_MARKER); + let lengths = |payloads: &[String; 4]| { + [ + payloads[0].len(), + payloads[1].len(), + payloads[2].len() + payloads[3].len(), + ] + }; + let expected = lengths(&before) + .into_iter() + .zip(lengths(&after)) + .map(|(before, after)| before.saturating_sub(after) as u64) + .collect::>(); + for report in [&dry, &applied] { + assert_eq!( + [ + report.anchors_released.bytes_reclaimed, + report.observations_released.bytes_reclaimed, + report.provenance_released.bytes_reclaimed, + ], + expected.as_slice(), + ); + assert_eq!(report.bytes_reclaimed(), expected.iter().sum::()); + } + assert_eq!(dry.anchors_released.acted, 0); + assert_eq!(dry.observations_released.acted, 0); + assert_eq!(dry.provenance_released.acted, 0); + assert_eq!(applied.anchors_released.acted, 1); + assert_eq!(applied.observations_released.acted, 1); + assert_eq!(applied.provenance_released.acted, 1); + } + Ok(()) +} + // Retention is generation-scoped: only the targeted generation is released. #[asupersync::test] async fn retention_is_generation_scoped() -> Result<(), String> { diff --git a/crates/tracedecay-global-db/src/registered.rs b/crates/tracedecay-global-db/src/registered.rs index b95c02f80a..64dfb5ef25 100644 --- a/crates/tracedecay-global-db/src/registered.rs +++ b/crates/tracedecay-global-db/src/registered.rs @@ -781,6 +781,27 @@ impl RegisteredGlobalDb { .map_err(|error| registered_error("run registered session retention", error)) } + /// Advances one bounded ownership-bootstrap or orphan-collection page. + /// All body and reference mutations share the guarded database writer. + pub async fn run_canonical_body_gc( + &self, + max_rows: usize, + ) -> tracedecay_domain::errors::Result { + crate::observation::canonical_body_gc::run(&self.database, max_rows).await + } + + /// Removes old exact replay-string duplicates using verified raw bodies, + /// without changing summary evidence or the effective replay message. + pub async fn compact_replay_metadata( + &self, + max_rows: usize, + ) -> tracedecay_domain::errors::Result + { + tracedecay_lcm::retention::compact_replay_metadata(&self.database, max_rows) + .await + .map_err(|error| registered_error("compact duplicate LCM replay metadata", error)) + } + #[tracing::instrument( name = "global_db.registered.retention.observations", level = "trace", diff --git a/crates/tracedecay-global-db/src/schema_contract/mod.rs b/crates/tracedecay-global-db/src/schema_contract/mod.rs index 7e6d8a9d39..3c849edec9 100644 --- a/crates/tracedecay-global-db/src/schema_contract/mod.rs +++ b/crates/tracedecay-global-db/src/schema_contract/mod.rs @@ -130,6 +130,13 @@ fn hash_admitted_schema(omit: Option<(&str, &str)>) -> (String, bool) { )); } } + for (name, sql) in crate::observation::canonical_body_gc::schema::OBJECTS { + parts.push(format!("canonical_body_gc:{name}:{sql}")); + } + parts.push(format!( + "lcm_fts_update:{}", + tracedecay_lcm::schema::RAW_FTS_UPDATE_TRIGGER_SQL + )); for (table, columns) in TEMPORAL_TABLE_COLUMNS { parts.push(format!("temporal:{table}")); for column in *columns { diff --git a/crates/tracedecay-global-db/src/schema_stages.rs b/crates/tracedecay-global-db/src/schema_stages.rs index 525f78dd41..c6044950f3 100644 --- a/crates/tracedecay-global-db/src/schema_stages.rs +++ b/crates/tracedecay-global-db/src/schema_stages.rs @@ -1140,6 +1140,7 @@ async fn install_registered_schema_stage_sequence( }, error => global_db_operation_error("initialize LCM schema", error), })?; + observation::canonical_body_gc::schema::install(transaction).await?; } // `force_exhaustive` means admission observed damaged or missing guard // triggers (for example a dropped guarded table takes its triggers with diff --git a/crates/tracedecay-lcm/src/compression.rs b/crates/tracedecay-lcm/src/compression.rs index 41d77e8fda..8debaa39f7 100644 --- a/crates/tracedecay-lcm/src/compression.rs +++ b/crates/tracedecay-lcm/src/compression.rs @@ -2098,7 +2098,8 @@ async fn ingest_active_messages( let mut replay = message.clone(); replay["role"] = Value::String(role.clone()); replay["content"] = original_content.clone(); - let initial_metadata_json = active_message_metadata(message, &replay); + let initial_metadata_json = + active_message_metadata(message, &replay, &storage_text); let expected_content_hash = projected_content_hash(&storage_text); if let Some(existing) = existing_state { let matches_stored_row = existing.ordinal == ordinal @@ -2167,6 +2168,7 @@ async fn ingest_active_messages( let metadata_json = active_replay_metadata_json( upsert.projection_metadata_json.as_deref(), &replay, + &raw, ); if metadata_json != initial_metadata_json { update_active_replay_metadata(conn, provider, &message_id, &metadata_json) @@ -2354,7 +2356,7 @@ fn default_message_kind(role: &str) -> String { } } -fn active_message_metadata(message: &Value, replay: &Value) -> String { +fn active_message_metadata(message: &Value, replay: &Value, storage_text: &str) -> String { let mut metadata = Map::new(); metadata.insert( replay_transactions::ACTIVE_REPLAY_METADATA_KEY.to_string(), @@ -2362,7 +2364,7 @@ fn active_message_metadata(message: &Value, replay: &Value) -> String { ); metadata.insert( replay_transactions::ACTIVE_REPLAY_MESSAGE_KEY.to_string(), - active_replay_for_metadata(replay), + active_replay_for_metadata(replay, Some(storage_text)), ); if let Some(lcm_ingest) = message.get("lcm_ingest") { metadata.insert("lcm_ingest".to_string(), lcm_ingest.clone()); @@ -2384,7 +2386,11 @@ fn replay_content_value( serde_json::from_str(&raw.content).unwrap_or_else(|_| Value::String(raw.content.clone())) } -fn active_replay_metadata_json(existing_metadata_json: Option<&str>, replay: &Value) -> String { +fn active_replay_metadata_json( + existing_metadata_json: Option<&str>, + replay: &Value, + raw: &LcmRawMessage, +) -> String { let mut metadata = existing_metadata_json .and_then(|text| serde_json::from_str::(text).ok()) .and_then(|value| value.as_object().cloned()) @@ -2395,18 +2401,29 @@ fn active_replay_metadata_json(existing_metadata_json: Option<&str>, replay: &Va ); metadata.insert( replay_transactions::ACTIVE_REPLAY_MESSAGE_KEY.to_string(), - active_replay_for_metadata(replay), + active_replay_for_metadata( + replay, + (raw.storage_kind == LcmStorageKind::Inline).then_some(raw.content.as_str()), + ), ); Value::Object(metadata).to_string() } -fn active_replay_for_metadata(replay: &Value) -> Value { +fn active_replay_for_metadata(replay: &Value, canonical_content: Option<&str>) -> Value { let mut replay = replay.clone(); if let Some(object) = replay.as_object_mut() { // This locator is regenerated from the canonical raw row at replay. // Persisting it makes our own replay look like a source metadata edit // and invalidates otherwise unchanged summary lineage on re-ingest. object.remove("store_id"); + // The canonical raw body already owns this exact string. The replay + // reader restores omitted content from it; structured content and + // external payload placeholders retain their separate representation. + if canonical_content + .is_some_and(|content| object.get("content").and_then(Value::as_str) == Some(content)) + { + object.remove("content"); + } replay_transactions::strip_disposable_assistant_replay_sidecars(object, ""); } replay diff --git a/crates/tracedecay-lcm/src/raw.rs b/crates/tracedecay-lcm/src/raw.rs index dc99667e9b..7a307488b7 100644 --- a/crates/tracedecay-lcm/src/raw.rs +++ b/crates/tracedecay-lcm/src/raw.rs @@ -28,12 +28,15 @@ pub const RAW_MESSAGE_SELECT_COLUMNS: &str = "provider, message_id, session_id, store_id, role, ordinal, timestamp, content, content_hash, storage_kind, payload_ref, snippet_text, metadata_json, - (SELECT encoding FROM session_canonical_bodies - WHERE content_hash = lcm_raw_messages.content_hash), - (SELECT body FROM session_canonical_bodies - WHERE content_hash = lcm_raw_messages.content_hash), - (SELECT uncompressed_bytes FROM session_canonical_bodies - WHERE content_hash = lcm_raw_messages.content_hash)"; + CASE WHEN storage_kind = 'inline' AND content IS NULL THEN + (SELECT encoding FROM session_canonical_bodies + WHERE content_hash = lcm_raw_messages.content_hash) END, + CASE WHEN storage_kind = 'inline' AND content IS NULL THEN + (SELECT body FROM session_canonical_bodies + WHERE content_hash = lcm_raw_messages.content_hash) END, + CASE WHEN storage_kind = 'inline' AND content IS NULL THEN + (SELECT uncompressed_bytes FROM session_canonical_bodies + WHERE content_hash = lcm_raw_messages.content_hash) END"; fn record_select_columns(alias: &str, text: &str, metadata: &str) -> String { format!( "{alias}.provider, {alias}.message_id, {alias}.session_id, {alias}.role, @@ -231,6 +234,14 @@ async fn persist_canonical_text( ) -> Result<(), LcmError> { let body = StoredCanonicalBody::pack(text.as_bytes()) .map_err(|error| LcmError::Db(format!("canonical body pack failed: {error}")))?; + persist_canonical_body(conn, &body).await +} + +/// Persists an already packed body inside the caller's raw-owner transaction. +pub(crate) async fn persist_canonical_body( + conn: &(impl Executor + ?Sized), + body: &StoredCanonicalBody, +) -> Result<(), LcmError> { conn.execute( UPSERT_CANONICAL_BODY_SQL, params![ diff --git a/crates/tracedecay-lcm/src/retention.rs b/crates/tracedecay-lcm/src/retention.rs index 3bf7b09734..dd76166b26 100644 --- a/crates/tracedecay-lcm/src/retention.rs +++ b/crates/tracedecay-lcm/src/retention.rs @@ -20,22 +20,28 @@ //! //! # Passes //! -//! Both passes reuse the store's content-addressed external payload lifecycle -//! (`lcm_external_payloads` keyed by a content-hash-derived `payload_ref`, -//! with a full reaping GC in [`super::gc`]). In reclaim order: +//! External payloads use the store's owner-scoped lifecycle +//! (`lcm_external_payloads`, with a reaping GC in [`super::gc`]). In order: //! //! 1. **Drop** (terminal, longest window): projection-durable raw rows past //! `drop_after_days` are deleted. Any now unreferenced external payload is //! reaped by the existing payload GC. -//! 2. **Offload** (recoverable, shorter window): projection-durable *inline* -//! raw rows past `offload_after_days` have their bulky `content` -//! externalized to the content-addressed store (deduplicated by hash) and -//! replaced with a recoverable placeholder, reclaiming the inline column and -//! its FTS shadow. +//! 2. **Inline** (lossless): bodies previously externalized by retention return +//! to their raw rows or the existing shared canonical-body store when its +//! packed body, retrieval snippet and record allowance are smaller. Released +//! files follow the existing GC grace period; this pass does not delete files. +//! 3. **Offload** (lossless, shorter window): projection-durable direct-inline +//! bodies past `offload_after_days` move to shared canonical storage only +//! when the same storage comparison succeeds. Non-beneficial bodies stay +//! intact; no new uncompressed payload files are created. Generated search +//! text follows the retained body or canonical snippet, and verified reads +//! continue to return the complete original text. //! -//! Every pass is bounded (`max_batch_size`) and incremental so the daemon can -//! schedule it off the hot path without competing with foreground writes, and a -//! dry run counts what would be reclaimed without mutating anything. +//! Every pass is incremental. The lossless storage passes examine at most 500 +//! candidates and 8 MiB of body/metadata bytes per pass, with a 4 MiB cap on each +//! body and row metadata envelope before body hydration. Larger records remain +//! intact. Dry runs count candidates without hydrating, compressing or changing +//! them, so their candidate count does not predict how many bodies will shrink. use std::collections::BTreeSet; use std::path::Path; @@ -52,11 +58,25 @@ use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, Row, params}; use tracedecay_runtime_core::db::{ Database, DatabaseEngineReadConnection, DatabaseMemoryTransaction, }; +use tracedecay_store::{INLINE_BODY_BYTES, StoredCanonicalBody}; +#[cfg(test)] use super::payload::{ExternalPayloadWrite, PayloadFileRollback}; -use super::{LcmError, payload, schema, util}; +use super::{LcmError, payload, raw, schema, summary_convergence, util}; + +mod replay_metadata; +pub use replay_metadata::{ + MAX_REPLAY_METADATA_COMPACTION_ROWS, ReplayMetadataCompactionReport, compact_replay_metadata, +}; const SECONDS_PER_DAY: i64 = 24 * 60 * 60; +const MAX_REPACK_BODY_BYTES: usize = 4 * 1024 * 1024; +const MAX_STORAGE_PASS_BYTES: usize = 8 * 1024 * 1024; +const MAX_STORAGE_PASS_ROWS: i64 = 500; +const RESTORE_CURSOR: &str = "restore_offloaded_v2"; +// Covers the canonical record, hash indexes and owner/candidate references. +// This is a conservative logical storage allowance, not a filesystem estimate. +const CANONICAL_RECORD_ALLOWANCE: usize = 1024; /// SQL predicate (over an aliased `lcm_raw_messages` row `r`) that is true when /// the raw row's `store_id` is covered by a durable summary node's lineage. @@ -83,17 +103,18 @@ const OFFLOAD_KIND: &str = "retention_offload"; /// Per-table/per-store retention windows for the session store. Defaults keep /// a six-month recovery horizon for projection-durable raw evidence and -/// offload its bulky inline payload after 30 days. Rows without durable -/// summary lineage remain untouched. +/// compact its bulky inline payload after 30 days when canonical storage is +/// smaller. Rows without durable summary lineage remain untouched. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct LcmRetentionConfig { /// Master switch. When `false`, [`run_session_retention`] is a /// no-op even in [`RetentionMode::Apply`]. #[serde(default = "default_retention_enabled")] pub enabled: bool, - /// Window after which a projection-durable, still-inline raw row has its - /// content offloaded to the content-addressed store. `None` disables the - /// offload pass. + /// Window after which projection-durable bodies may be compacted into the + /// existing canonical store. Retention-offloaded files past this window may + /// return inline or become canonical when smaller; non-beneficial and + /// oversized bodies remain intact. `None` disables both lossless passes. #[serde(default = "default_offload_after_days")] pub offload_after_days: Option, /// Window after which a projection-durable raw row is dropped. `None` @@ -171,7 +192,9 @@ pub struct LcmRetentionPhaseReport { pub eligible: u64, /// Rows actually acted on (`0` in a dry run). pub acted: u64, - /// Bytes of message content reclaimed from the database by this pass. + /// Logical inline-content bytes removed from the database by this pass. + /// This excludes metadata, indexes, canonical bodies and payload files, and + /// does not measure physical disk reclamation. Inlining reports zero. pub bytes_reclaimed: u64, /// Oldest timestamp among the bounded eligible rows, when any. #[serde(default)] @@ -195,6 +218,19 @@ pub struct LcmRetentionReport { pub ended_at: i64, pub dropped: LcmRetentionPhaseReport, pub offloaded: LcmRetentionPhaseReport, + /// Retention-offloaded bodies restored to normal inline/canonical storage. + /// Released files remain available until existing payload GC verifies them + /// after its grace period. + #[serde(default)] + pub inlined: LcmRetentionPhaseReport, + /// Subset of restored/offloaded rows compacted into shared canonical bodies. + /// This counts representation changes, not filesystem bytes reclaimed. + #[serde(default)] + pub canonicalized_rows: u64, + /// A lossless pass reached its row/byte budget before draining its cursor. + /// The daemon may continue this work without rerunning age-based deletion. + #[serde(default)] + pub storage_has_more: bool, /// Database `PRAGMA freelist_count` before/after (freed pages are the /// measurable, VACUUM-free signal that space was reclaimed). pub freelist_before: u64, @@ -245,7 +281,7 @@ pub async fn read_session_retention_backlog( .unwrap_or(now); let sql = format!( "SELECT MIN(r.timestamp), - COALESCE(SUM(LENGTH(COALESCE(r.content, ''))), 0) + COALESCE(SUM(LENGTH(CAST(COALESCE(r.content, '') AS BLOB))), 0) FROM lcm_raw_messages r WHERE {DURABLE_ROW_IDS} AND r.timestamp IS NOT NULL @@ -254,7 +290,7 @@ pub async fn read_session_retention_backlog( OR (?3 = 1 AND +r.timestamp < ?4 AND r.storage_kind = 'inline' AND r.content IS NOT NULL - AND LENGTH(r.content) > 0) + AND LENGTH(CAST(r.content AS BLOB)) >= {INLINE_BODY_BYTES}) )" ); let mut rows = conn @@ -383,6 +419,9 @@ async fn run_session_retention_inner( ended_at: now, dropped: LcmRetentionPhaseReport::disabled(), offloaded: LcmRetentionPhaseReport::disabled(), + inlined: LcmRetentionPhaseReport::disabled(), + canonicalized_rows: 0, + storage_has_more: false, freelist_before, freelist_after: freelist_before, page_count_before, @@ -393,6 +432,7 @@ async fn run_session_retention_inner( if !config.enabled || !config.any_window() { report.dropped.window_days = config.drop_after_days; report.offloaded.window_days = config.offload_after_days; + report.inlined.window_days = config.offload_after_days; return Ok(report); } @@ -400,8 +440,8 @@ async fn run_session_retention_inner( provider, session_id, }; - // Drop first (terminal, longest window) so offload never externalizes a row - // that is about to be deleted. + // Drop first (terminal, longest window) so the lossless passes never compact + // a row that is about to be deleted. let (dropped, drop_cursor) = run_drop_pass( store, scope, @@ -413,7 +453,7 @@ async fn run_session_retention_inner( ) .await?; report.dropped = dropped; - let (offloaded, offload_cursor) = run_offload_pass( + let (inlined, inline_cursor, inline_has_more) = run_inline_offloaded_pass( store, storage_root, scope, @@ -421,14 +461,32 @@ async fn run_session_retention_inner( mode, now, &mut report.errors, + &mut report.canonicalized_rows, + authorize, + ) + .await?; + report.inlined = inlined; + let (offloaded, offload_cursor, offload_has_more) = run_offload_pass( + store, + scope, + config, + mode, + now, + &mut report.errors, + &mut report.canonicalized_rows, authorize, ) .await?; report.offloaded = offloaded; + report.storage_has_more = inline_has_more || offload_has_more; if mode.is_apply() { // Consume the staged GC/reporting meta cards: record the last run so a // scheduler and Doctor can report retention backlog without a rescan. - let acted = report.dropped.acted.saturating_add(report.offloaded.acted); + let acted = report + .dropped + .acted + .saturating_add(report.offloaded.acted) + .saturating_add(report.inlined.acted); let transaction = store .begin_memory_write_transaction("begin session retention metadata", authorize) .await?; @@ -436,7 +494,11 @@ async fn run_session_retention_inner( // A scoped run examined only its own rows, so only an unscoped run may // move the cursors past what it examined. if scope.is_unscoped() { - for (pass, cursor) in [("drop", drop_cursor), ("offload", offload_cursor)] { + for (pass, cursor) in [ + ("drop", drop_cursor), + ("offload", offload_cursor), + (RESTORE_CURSOR, inline_cursor), + ] { if let Some(cursor) = cursor { cursor.persist(&transaction, pass).await?; } @@ -863,7 +925,7 @@ async fn run_drop_pass( cursor, cutoff, config.batch_limit(), - "r.store_id, r.timestamp, LENGTH(COALESCE(r.content, ''))", + "r.store_id, r.timestamp, LENGTH(CAST(COALESCE(r.content, '') AS BLOB))", "1 = 1", |row| { Ok(DropRow { @@ -922,6 +984,274 @@ async fn run_drop_pass( Ok((report, (errors.len() == errors_before).then_some(next))) } +struct InlineOffloadedRow { + store_id: i64, + timestamp: i64, + provider: String, + session_id: String, + message_id: String, + payload_ref: String, + content_hash: String, + placeholder_text: Option, + metadata_json: Option, + byte_count: usize, +} + +fn restorable_offload_predicate() -> String { + format!( + "r.storage_kind = 'external' AND r.content IS NULL + AND COALESCE(LENGTH(CAST(r.metadata_json AS BLOB)), 0) + + COALESCE(LENGTH(CAST(r.placeholder_text AS BLOB)), 0) <= {MAX_REPACK_BODY_BYTES} + AND EXISTS ( + SELECT 1 FROM lcm_external_payloads e + WHERE e.payload_ref = r.payload_ref + AND e.provider = r.provider AND e.session_id = r.session_id + AND e.message_id = r.message_id AND e.content_hash = r.content_hash + AND e.kind = '{OFFLOAD_KIND}' + AND e.byte_count > 0 AND e.byte_count <= {MAX_REPACK_BODY_BYTES} + )" + ) +} + +#[allow(clippy::too_many_arguments)] +async fn run_inline_offloaded_pass( + store: RetentionStore<'_>, + storage_root: &Path, + scope: RetentionScope<'_>, + config: &LcmRetentionConfig, + mode: RetentionMode, + now: i64, + errors: &mut Vec, + canonicalized: &mut u64, + authorize: Option<&RetentionAuthorization<'_>>, +) -> Result<(LcmRetentionPhaseReport, Option, bool), LcmError> { + let mut report = LcmRetentionPhaseReport { + window_days: config.offload_after_days, + ..LcmRetentionPhaseReport::default() + }; + let Some(window) = config.offload_after_days else { + return Ok((report, None, false)); + }; + let read = store.read_connection(); + // The v2 cursor revisits files skipped by the prior tiny-file-only pass. + let mut cursor = RetentionCursor::read(&read, RESTORE_CURSOR).await?; + let mut remaining = MAX_STORAGE_PASS_BYTES; + let mut has_more = true; + for _ in 0..config.batch_limit().min(MAX_STORAGE_PASS_ROWS) { + let (mut targets, next) = select_retention_candidates( + &read, + scope, + cursor, + cutoff_secs(window, now), + 1, + "r.store_id, r.timestamp, r.provider, r.session_id, r.message_id, + r.payload_ref, r.content_hash, r.placeholder_text, r.metadata_json, + (SELECT byte_count FROM lcm_external_payloads WHERE payload_ref = r.payload_ref)", + &restorable_offload_predicate(), + |row| { + Ok(InlineOffloadedRow { + store_id: row.get(0)?, + timestamp: row.get(1)?, + provider: row.get(2)?, + session_id: row.get(3)?, + message_id: row.get(4)?, + payload_ref: row.get(5)?, + content_hash: row.get(6)?, + placeholder_text: row.get(7)?, + metadata_json: row.get(8)?, + byte_count: usize::try_from(row.get::(9)?) + .map_err(|_| LcmError::PayloadIntegrityMismatch)?, + }) + }, + ) + .await?; + if let Some(target) = targets.pop() { + let cost = target + .byte_count + .saturating_add(target.metadata_json.as_ref().map_or(0, String::len)) + .saturating_add(target.placeholder_text.as_ref().map_or(0, String::len)); + if cost > remaining { + break; + } + remaining -= cost; + report.eligible += 1; + report.oldest_eligible_at = Some( + report + .oldest_eligible_at + .map_or(target.timestamp, |old| old.min(target.timestamp)), + ); + if mode.is_apply() { + match inline_offloaded_one(store, storage_root, &target, authorize).await { + Ok(Some(compacted)) => { + report.acted += 1; + *canonicalized += u64::from(compacted); + } + Ok(None) => {} + Err(error) => { + errors.push(format!("inline raw row {}: {error}", target.store_id)); + has_more = false; + break; + } + } + } + } + if next == cursor { + has_more = false; + break; + } + cursor = next; + } + // Inlining releases a file reference, not physical bytes. Existing owner + // GC proves it is unreferenced after grace and reports its own reclamation. + Ok((report, mode.is_apply().then_some(cursor), has_more)) +} + +async fn inline_offloaded_one( + store: RetentionStore<'_>, + storage_root: &Path, + target: &InlineOffloadedRow, + authorize: Option<&RetentionAuthorization<'_>>, +) -> Result, LcmError> { + let read = store.read_connection(); + let metadata = payload::load_payload_metadata(&read, &target.payload_ref).await?; + if metadata.provider != target.provider + || metadata.session_id != target.session_id + || metadata.message_id != target.message_id + { + return Err(LcmError::PayloadNotOwnedBySession); + } + let bytes = + usize::try_from(metadata.byte_count).map_err(|_| LcmError::PayloadIntegrityMismatch)?; + let chars = + usize::try_from(metadata.char_count).map_err(|_| LcmError::PayloadIntegrityMismatch)?; + if metadata.kind != OFFLOAD_KIND + || metadata.content_hash != target.content_hash + || bytes == 0 + || bytes != target.byte_count + || bytes > MAX_REPACK_BODY_BYTES + || chars > bytes + { + return Err(LcmError::PayloadIntegrityMismatch); + } + let content = payload::read_verified_payload_content_with_checkpoint( + storage_root, + &target.payload_ref, + &target.content_hash, + bytes, + chars, + &mut || { + if let Some(authorize) = authorize { + authorize("read session retention inline payload")?; + } + Ok(()) + }, + )?; + let packed = (bytes >= INLINE_BODY_BYTES) + .then(|| pack_canonical(&content)) + .transpose()?; + let placeholder = raw::derived_text_for_snippet(&content); + let txn = store + .begin_memory_write_transaction("begin session retention inline pass", authorize) + .await?; + if let Some(body) = &packed { + if !canonical_storage_saves_bytes(&txn, body, &placeholder).await? { + txn.rollback().await?; + return Ok(None); + } + raw::persist_canonical_body(&txn, body).await?; + } + if let Some(authorize) = authorize { + authorize("compare and swap session retention inline row")?; + } + summary_convergence::suspend_raw_update_guard(&txn).await?; + let update_sql = format!( + "UPDATE lcm_raw_messages AS r + SET content = ?2, storage_kind = 'inline', payload_ref = NULL, placeholder_text = ?11 + WHERE r.store_id = ?1 AND r.content_hash = ?3 AND r.payload_ref = ?4 + AND r.provider = ?5 AND r.session_id = ?6 AND r.message_id = ?7 + AND r.timestamp = ?8 AND r.placeholder_text IS ?9 AND r.metadata_json IS ?10 + AND {PROJECTION_DURABLE} AND {}", + restorable_offload_predicate() + ); + let changed = txn + .execute( + &update_sql, + params![ + target.store_id, + if packed.is_none() { + Some(content.as_str()) + } else { + None + }, + target.content_hash.as_str(), + target.payload_ref.as_str(), + target.provider.as_str(), + target.session_id.as_str(), + target.message_id.as_str(), + target.timestamp, + util::opt_text(target.placeholder_text.as_deref()), + util::opt_text(target.metadata_json.as_deref()), + packed.as_ref().map(|_| placeholder.as_str()) + ], + ) + .await?; + if changed != 1 { + txn.rollback().await?; + return Err(LcmError::Db(format!( + "inline compare-and-swap rejected changed row {}", + target.store_id + ))); + } + // The normal raw read authority verifies the original sanitizer receipt + // against the restored full text. A corrupt receipt never becomes visible + // as inline text, and its external file remains intact on rollback. + if let Err(error) = raw::load_raw_message_by_store_id(&txn, target.store_id).await { + txn.rollback().await?; + return Err(error); + } + if let Err(error) = summary_convergence::restore_raw_update_guard(&txn).await { + txn.rollback().await?; + return Err(error); + } + commit_authorized(txn, authorize, "commit session retention inline pass").await?; + Ok(Some(packed.is_some())) +} + +fn pack_canonical(content: &str) -> Result { + StoredCanonicalBody::pack(content.as_bytes()) + .map_err(|error| LcmError::Db(format!("retention canonical body pack failed: {error}"))) +} + +async fn canonical_storage_saves_bytes( + conn: &(impl QueryExecutor + ?Sized), + body: &StoredCanonicalBody, + snippet: &str, +) -> Result { + let original_bytes = + usize::try_from(body.uncompressed_bytes).map_err(|_| LcmError::PayloadIntegrityMismatch)?; + let mut rows = conn.query( + "SELECT LENGTH(body), uncompressed_bytes FROM session_canonical_bodies WHERE content_hash = ?1", + params![body.content_hash.as_str()], + ).await?; + let marginal_body_bytes = if let Some(existing) = rows.next().await? { + // Validate sizes before the normal raw reader hydrates an existing body. + // It subsequently proves its decoded bytes, hash and sanitizer receipt + // under this same transaction, before releasing the original source. + let stored_bytes = usize::try_from(existing.get::(0)?) + .map_err(|_| LcmError::PayloadIntegrityMismatch)?; + if existing.get::(1)? != body.uncompressed_bytes || stored_bytes > original_bytes { + return Err(LcmError::PayloadIntegrityMismatch); + } + 0 + } else { + body.blob.len() + }; + Ok(marginal_body_bytes + .saturating_add(snippet.len()) + .saturating_add(CANONICAL_RECORD_ALLOWANCE) + < original_bytes) +} + struct OffloadRow { store_id: i64, provider: String, @@ -929,74 +1259,196 @@ struct OffloadRow { message_id: String, timestamp: i64, content: String, + metadata_json: Option, +} + +struct OffloadHeader { + store_id: i64, + timestamp: i64, + provider: String, + session_id: String, + message_id: String, + content_hash: String, + metadata_json: Option, + byte_count: usize, +} + +async fn load_offload_row( + read: &(impl QueryExecutor + ?Sized), + target: OffloadHeader, +) -> Result { + let mut rows = read + .query( + &format!( + "SELECT {} FROM lcm_raw_messages + WHERE store_id = ?1 AND timestamp = ?2 AND provider = ?3 AND session_id = ?4 + AND message_id = ?5 AND content_hash = ?6 AND metadata_json IS ?7 + AND storage_kind = 'inline' AND content IS NOT NULL + AND LENGTH(CAST(content AS BLOB)) = ?8", + raw::RAW_MESSAGE_SELECT_COLUMNS + ), + params![ + target.store_id, + target.timestamp, + target.provider.as_str(), + target.session_id.as_str(), + target.message_id.as_str(), + target.content_hash.as_str(), + target.metadata_json.as_deref(), + target.byte_count as i64 + ], + ) + .await?; + let row = rows.next().await?.ok_or_else(|| { + LcmError::Db(format!( + "offload compare-and-swap rejected changed row {}", + target.store_id + )) + })?; + let message = raw::verified_raw_message_from_row(&row)?; + Ok(OffloadRow { + store_id: target.store_id, + timestamp: target.timestamp, + provider: target.provider, + session_id: target.session_id, + message_id: target.message_id, + content: message.content, + metadata_json: target.metadata_json, + }) } #[allow(clippy::too_many_arguments)] async fn run_offload_pass( store: RetentionStore<'_>, - storage_root: &Path, scope: RetentionScope<'_>, config: &LcmRetentionConfig, mode: RetentionMode, now: i64, errors: &mut Vec, + canonicalized: &mut u64, authorize: Option<&RetentionAuthorization<'_>>, -) -> Result<(LcmRetentionPhaseReport, Option), LcmError> { +) -> Result<(LcmRetentionPhaseReport, Option, bool), LcmError> { let mut report = LcmRetentionPhaseReport { window_days: config.offload_after_days, ..LcmRetentionPhaseReport::default() }; let Some(window) = config.offload_after_days else { - return Ok((report, None)); + return Ok((report, None, false)); }; let cutoff = cutoff_secs(window, now); let read = store.read_connection(); - let cursor = RetentionCursor::read(&read, "offload").await?; - let (targets, next) = select_retention_candidates( - &read, - scope, - cursor, - cutoff, - config.batch_limit(), - "r.store_id, r.timestamp, r.provider, r.session_id, r.message_id, r.content", - "r.storage_kind = 'inline' AND r.content IS NOT NULL AND LENGTH(r.content) > 0", - |row| { - Ok(OffloadRow { - store_id: row.get(0)?, - timestamp: row.get(1)?, - provider: row.get(2)?, - session_id: row.get(3)?, - message_id: row.get(4)?, - content: row.get(5)?, - }) - }, - ) - .await?; - report.eligible = targets.len() as u64; - report.oldest_eligible_at = targets.iter().map(|target| target.timestamp).min(); - if !mode.is_apply() { - report.bytes_reclaimed = targets.iter().map(|t| t.content.len() as u64).sum(); - return Ok((report, None)); + let mut cursor = RetentionCursor::read(&read, "offload").await?; + let mut remaining = MAX_STORAGE_PASS_BYTES; + let mut has_more = true; + for _ in 0..config.batch_limit().min(MAX_STORAGE_PASS_ROWS) { + let (mut targets, next) = select_retention_candidates( + &read, scope, cursor, cutoff, 1, + "r.store_id, r.timestamp, r.provider, r.session_id, r.message_id, r.content_hash, + r.metadata_json, LENGTH(CAST(r.content AS BLOB))", + &format!("r.storage_kind = 'inline' AND r.content IS NOT NULL + AND COALESCE(LENGTH(CAST(r.metadata_json AS BLOB)), 0) <= {MAX_REPACK_BODY_BYTES} + AND LENGTH(CAST(r.content AS BLOB)) BETWEEN {INLINE_BODY_BYTES} AND {MAX_REPACK_BODY_BYTES}"), + |row| Ok(OffloadHeader { + store_id: row.get(0)?, timestamp: row.get(1)?, provider: row.get(2)?, + session_id: row.get(3)?, message_id: row.get(4)?, content_hash: row.get(5)?, + metadata_json: row.get(6)?, + byte_count: usize::try_from(row.get::(7)?).map_err(|_| LcmError::PayloadIntegrityMismatch)?, + }), + ).await?; + if let Some(header) = targets.pop() { + let cost = header + .byte_count + .saturating_add(header.metadata_json.as_ref().map_or(0, String::len)); + if cost > remaining { + break; + } + remaining -= cost; + report.eligible += 1; + report.oldest_eligible_at = Some( + report + .oldest_eligible_at + .map_or(header.timestamp, |old| old.min(header.timestamp)), + ); + if mode.is_apply() { + let store_id = header.store_id; + let result = async { + let target = load_offload_row(&read, header).await?; + offload_one(store, &target, authorize).await + } + .await; + match result { + Ok(Some(bytes)) => { + report.acted += 1; + report.bytes_reclaimed = report.bytes_reclaimed.saturating_add(bytes); + *canonicalized += 1; + } + Ok(None) => {} + Err(error) => { + errors.push(format!("offload raw row {store_id}: {error}")); + has_more = false; + break; + } + } + } + } + if next == cursor { + has_more = false; + break; + } + cursor = next; } + Ok((report, mode.is_apply().then_some(cursor), has_more)) +} - // Each row is offloaded atomically: write the content-addressed file, then - // flip the row to external + placeholder in its own transaction. A crash - // between file write and commit is cleaned up by the rollback guard. - let errors_before = errors.len(); - for target in targets { - match offload_one(store, storage_root, &target, authorize).await { - Ok(bytes) => { - report.acted += 1; - report.bytes_reclaimed = report.bytes_reclaimed.saturating_add(bytes); +async fn offload_one( + store: RetentionStore<'_>, + target: &OffloadRow, + authorize: Option<&RetentionAuthorization<'_>>, +) -> Result, LcmError> { + if target.content.len() > MAX_REPACK_BODY_BYTES { + return Err(LcmError::Db( + "retention body exceeds the 4 MiB repack limit".to_owned(), + )); + } + if target.content.len() >= INLINE_BODY_BYTES { + let packed = pack_canonical(&target.content)?; + let placeholder = raw::derived_text_for_snippet(&target.content); + let txn = store + .begin_memory_write_transaction("begin session retention canonical pass", authorize) + .await?; + if canonical_storage_saves_bytes(&txn, &packed, &placeholder).await? { + raw::persist_canonical_body(&txn, &packed).await?; + let changed = txn.execute(&format!( + "UPDATE lcm_raw_messages AS r SET content = NULL, placeholder_text = ?2 + WHERE r.store_id = ?1 AND r.content_hash = ?3 AND r.provider = ?4 + AND r.session_id = ?5 AND r.message_id = ?6 AND r.timestamp = ?7 + AND r.content = ?8 AND r.metadata_json IS ?9 + AND r.storage_kind = 'inline' AND r.payload_ref IS NULL AND {PROJECTION_DURABLE}"), + params![target.store_id, placeholder.as_str(), packed.content_hash.as_str(), + target.provider.as_str(), target.session_id.as_str(), target.message_id.as_str(), + target.timestamp, target.content.as_str(), target.metadata_json.as_deref()] + ).await?; + if changed != 1 { + txn.rollback().await?; + return Err(LcmError::Db(format!( + "offload compare-and-swap rejected changed row {}", + target.store_id + ))); + } + if let Err(error) = raw::load_raw_message_by_store_id(&txn, target.store_id).await { + txn.rollback().await?; + return Err(error); } - Err(err) => errors.push(format!("offload raw row {}: {err}", target.store_id)), + commit_authorized(txn, authorize, "commit session retention canonical pass").await?; + return Ok(Some(target.content.len() as u64)); } + txn.rollback().await?; } - // A failed row stays behind the cursor so the next pass retries it. - Ok((report, (errors.len() == errors_before).then_some(next))) + Ok(None) } -async fn offload_one( +#[cfg(test)] +async fn offload_external_one( store: RetentionStore<'_>, storage_root: &Path, target: &OffloadRow, @@ -1051,6 +1503,7 @@ async fn offload_one( AND r.message_id = ?7 AND r.timestamp = ?8 AND r.content = ?9 + AND r.metadata_json IS ?10 AND r.storage_kind = 'inline' AND r.payload_ref IS NULL AND {PROJECTION_DURABLE}" @@ -1067,7 +1520,8 @@ async fn offload_one( target.session_id.as_str(), target.message_id.as_str(), target.timestamp, - target.content.as_str() + target.content.as_str(), + target.metadata_json.as_deref() ], ) .await?; diff --git a/crates/tracedecay-lcm/src/retention/replay_metadata.rs b/crates/tracedecay-lcm/src/retention/replay_metadata.rs new file mode 100644 index 0000000000..31d39bde33 --- /dev/null +++ b/crates/tracedecay-lcm/src/retention/replay_metadata.rs @@ -0,0 +1,284 @@ +//! Bounded, lossless removal of old replay strings duplicated by raw bodies. +//! +//! The raw reader verifies canonical bytes and their sanitizer receipt before +//! a duplicate is removed. Structured replay content and external/media +//! representations keep their own content. A durable rowid frontier and a byte +//! budget keep this repair off the foreground ingest path. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tracedecay_runtime_core::db::Database; +use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, params}; + +use crate::{LcmError, LcmRawMessage, gc, payload, raw, schema, security, summary_convergence}; + +#[cfg(test)] +mod tests; + +const FRONTIER_KEY: &str = "replay_metadata_compaction_store_id_v1"; +const OPERATION: &str = "compact duplicate LCM replay metadata"; +pub const MAX_REPLAY_METADATA_COMPACTION_ROWS: usize = 48; +// Bound both receipt verification and metadata sanitization while a writer is +// held. The raw metadata envelope itself is capped at 1 MiB by ingestion. +const MAX_BATCH_BYTES: u64 = 4 * 1024 * 1024; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReplayMetadataCompactionReport { + pub rows_scanned: u64, + pub rows_compacted: u64, + /// Eligible rows retained because their representation, sanitizer result, + /// or byte bound could not prove this narrow lossless transformation. + pub rows_skipped: u64, + /// Subset of skipped rows whose metadata plus canonical/stored body + /// representations exceed this pass's 4 MiB read and verification budget. + pub rows_skipped_oversized: u64, + /// UTF-8 metadata bytes removed. This does not measure file shrinkage. + pub metadata_bytes_removed: u64, + pub has_more: bool, +} + +fn db_error(error: impl std::fmt::Display) -> LcmError { + LcmError::Db(format!("{OPERATION}: {error}")) +} + +async fn frontier(conn: &(impl QueryExecutor + ?Sized)) -> Result { + match schema::get_gc_meta(conn, FRONTIER_KEY).await? { + None => Ok(0), + Some(value) => value + .parse::() + .ok() + .filter(|value| *value >= 0) + .ok_or_else(|| db_error("invalid replay metadata compaction frontier")), + } +} + +async fn has_work(conn: &(impl QueryExecutor + ?Sized), after: i64) -> Result { + Ok(conn + .query( + "SELECT 1 FROM lcm_raw_messages WHERE store_id > ?1 LIMIT 1", + params![after], + ) + .await? + .next() + .await? + .is_some()) +} + +/// Advances one bounded repair page under the registered database writer. +/// Body bytes, lineage and effective replay content remain unchanged. +pub async fn compact_replay_metadata( + database: &Database, + max_rows: usize, +) -> Result { + let read = database.read_connection(); + if !has_work(&read, frontier(&read).await?).await? { + return Ok(ReplayMetadataCompactionReport::default()); + } + let transaction = database + .begin_write_transaction(OPERATION) + .await + .map_err(db_error)?; + match step(&transaction, max_rows).await { + Ok(report) => { + transaction.commit().await.map_err(db_error)?; + Ok(report) + } + Err(error) => match transaction.rollback().await { + Ok(()) => Err(error), + Err(rollback) => Err(db_error(format!("{error}; rollback failed: {rollback}"))), + }, + } +} + +struct Candidate { + store_id: i64, + eligible: bool, + read_bytes: Option, +} + +/// Read only sizes and identities until each candidate earns the byte budget. +async fn candidates( + conn: &(impl QueryExecutor + ?Sized), + after: i64, + limit: usize, +) -> Result, LcmError> { + let mut rows = conn + .query( + "SELECT r.store_id, + CASE WHEN r.storage_kind = 'inline' AND json_valid(r.metadata_json) + THEN json_type(r.metadata_json, '$.lcm_active_replay') = 'true' + AND json_type(r.metadata_json, '$.active_replay.content') = 'text' + ELSE 0 END, + LENGTH(CAST(r.metadata_json AS BLOB)), + COALESCE(LENGTH(CAST(r.content AS BLOB)), b.uncompressed_bytes), + COALESCE(LENGTH(b.body), 0) + FROM lcm_raw_messages r + LEFT JOIN session_canonical_bodies b ON b.content_hash = r.content_hash + WHERE r.store_id > ?1 ORDER BY r.store_id LIMIT ?2", + params![ + after, + limit.clamp(1, MAX_REPLAY_METADATA_COMPACTION_ROWS) as i64 + ], + ) + .await?; + let mut result = Vec::new(); + while let Some(row) = rows.next().await? { + let metadata: Option = row.get(2)?; + let body: Option = row.get(3)?; + let stored: i64 = row.get(4)?; + let read_bytes = metadata + .zip(body) + .and_then(|(metadata, body)| { + Some(( + u64::try_from(metadata).ok()?, + u64::try_from(body).ok()?, + u64::try_from(stored).ok()?, + )) + }) + .and_then(|(metadata, body, stored)| metadata.checked_add(body)?.checked_add(stored)); + result.push(Candidate { + store_id: row.get(0)?, + eligible: row.get::>(1)?.unwrap_or(0) != 0, + read_bytes, + }); + } + Ok(result) +} + +async fn step( + conn: &(impl Executor + ?Sized), + max_rows: usize, +) -> Result { + let mut after = frontier(conn).await?; + let candidates = candidates(conn, after, max_rows).await?; + let mut report = ReplayMetadataCompactionReport::default(); + let mut remaining = MAX_BATCH_BYTES; + let mut guard_suspended = false; + for candidate in candidates { + if candidate.eligible { + let bytes = candidate.read_bytes.ok_or_else(|| { + db_error(format!( + "raw row {} has no bounded canonical body", + candidate.store_id + )) + })?; + if bytes > MAX_BATCH_BYTES { + report.rows_skipped += 1; + report.rows_skipped_oversized += 1; + } else if bytes > remaining { + break; + } else { + remaining -= bytes; + let message = raw::load_raw_message_by_store_id(conn, candidate.store_id).await?; + if let Some(metadata) = compacted_metadata(&message)? { + if !guard_suspended { + summary_convergence::suspend_raw_update_guard(conn).await?; + guard_suspended = true; + } + let changed = + apply_metadata(conn, candidate.store_id, &message, &metadata).await?; + if changed { + report.rows_compacted += 1; + report.metadata_bytes_removed += message + .metadata_json + .as_deref() + .map_or(0, str::len) + .saturating_sub(metadata.len()) + as u64; + } else { + report.rows_skipped += 1; + } + } else { + report.rows_skipped += 1; + } + } + } + report.rows_scanned += 1; + after = candidate.store_id; + } + if guard_suspended { + summary_convergence::restore_raw_update_guard(conn).await?; + } + schema::set_gc_meta(conn, FRONTIER_KEY, &after.to_string()).await?; + report.has_more = has_work(conn, after).await?; + Ok(report) +} + +fn compacted_metadata(message: &LcmRawMessage) -> Result, LcmError> { + if message.storage_kind != crate::LcmStorageKind::Inline { + return Ok(None); + } + let Some(stored) = message.metadata_json.as_deref() else { + return Ok(None); + }; + let mut metadata: Value = serde_json::from_str(stored).map_err(db_error)?; + if metadata.get("lcm_active_replay").and_then(Value::as_bool) != Some(true) { + return Ok(None); + } + let Some(replay) = metadata + .get_mut("active_replay") + .and_then(Value::as_object_mut) + else { + return Ok(None); + }; + let Some(content) = replay.get("content").and_then(Value::as_str) else { + return Ok(None); + }; + if content != message.content + || gc::is_known_payload_placeholder_prefix(content.trim_start()) + || !payload::extract_payload_refs_from_text(content).is_empty() + || security::contains_media_payload(content) + { + return Ok(None); + } + replay.remove("content"); + let rebound = raw::resanitize_stored_provider_metadata_json(&metadata.to_string())?; + let mut expected = metadata; + let mut actual: Value = serde_json::from_str(&rebound).map_err(db_error)?; + // Only the metadata receipt may change in addition to the proven duplicate. + // New sanitizer policy changes belong to privacy remediation, not this pass. + remove_metadata_receipt(&mut expected); + remove_metadata_receipt(&mut actual); + if expected != actual || rebound.len() >= stored.len() { + return Ok(None); + } + Ok(Some(rebound)) +} + +fn remove_metadata_receipt(metadata: &mut Value) { + if let Some(protection) = metadata + .get_mut("ingest_protection") + .and_then(Value::as_object_mut) + { + protection.remove("metadata_sanitization_receipt"); + } +} + +async fn apply_metadata( + conn: &(impl Executor + ?Sized), + store_id: i64, + message: &LcmRawMessage, + metadata: &str, +) -> Result { + let changed = conn + .execute( + "UPDATE lcm_raw_messages SET metadata_json = ?1 + WHERE store_id = ?2 AND provider = ?3 AND session_id = ?4 AND message_id = ?5 + AND content_hash = ?6 AND metadata_json IS ?7 AND storage_kind = 'inline' + AND role = ?8 AND ordinal = ?9 AND timestamp IS ?10 AND payload_ref IS NULL", + params![ + metadata, + store_id, + message.provider.as_str(), + message.session_id.as_str(), + message.message_id.as_str(), + message.content_hash.as_str(), + message.metadata_json.as_deref(), + message.role.as_str(), + message.ordinal, + message.timestamp + ], + ) + .await?; + Ok(changed == 1) +} diff --git a/crates/tracedecay-lcm/src/retention/replay_metadata/tests.rs b/crates/tracedecay-lcm/src/retention/replay_metadata/tests.rs new file mode 100644 index 0000000000..1bc50de1ce --- /dev/null +++ b/crates/tracedecay-lcm/src/retention/replay_metadata/tests.rs @@ -0,0 +1,371 @@ +use tracedecay_domain::{ComponentVersion, SanitizationReceiptV1}; +use tracedecay_privacy::{ + LCM_PAYLOAD_SANITIZER_VERSION_V1, sanitize_lcm_payload_text, verify_sanitized_json_payload, +}; +use tracedecay_runtime_core::db::engine::{TestConnection, TransactionBehavior}; +use tracedecay_store::{StoredCanonicalBody, UPSERT_CANONICAL_BODY_SQL}; + +use super::*; + +struct Store { + conn: TestConnection, + _directory: tempfile::TempDir, +} + +impl Store { + async fn open() -> Self { + let directory = tempfile::tempdir().unwrap(); + let conn = TestConnection::open(&directory.path().join("replay-metadata.db")); + conn.execute_batch( + "CREATE TABLE sessions ( + provider TEXT NOT NULL, session_id TEXT NOT NULL, + PRIMARY KEY(provider, session_id) + ); INSERT INTO sessions VALUES ('codex', 'session');", + ) + .await + .unwrap(); + schema::ensure_lcm_schema(&conn).await.unwrap(); + Self { + conn, + _directory: directory, + } + } + + async fn insert(&self, ordinal: i64, text: &str, replay: Value, canonical: bool) -> i64 { + let sanitized = sanitize_lcm_payload_text(text).unwrap(); + assert_eq!(sanitized.sanitized_text(), text); + let body = StoredCanonicalBody::pack(text.as_bytes()).unwrap(); + let metadata = serde_json::json!({ + "lcm_active_replay": true, + "active_replay": {"role": "assistant", "content": replay, "provider_field": "kept"}, + "ingest_protection": {"sanitization_receipt": sanitized.receipt()} + }); + let metadata = + raw::resanitize_stored_provider_metadata_json(&metadata.to_string()).unwrap(); + let transaction = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .unwrap(); + if canonical { + transaction + .execute( + UPSERT_CANONICAL_BODY_SQL, + params![ + body.content_hash.as_str(), + body.encoding, + body.blob.as_slice(), + body.uncompressed_bytes + ], + ) + .await + .unwrap(); + } + transaction + .execute( + "INSERT INTO lcm_raw_messages(provider, message_id, session_id, role, ordinal, + timestamp, content, content_hash, storage_kind, metadata_json) + VALUES ('codex', ?1, 'session', 'assistant', ?2, NULL, ?3, ?4, 'inline', ?5)", + params![ + format!("message-{ordinal}"), + ordinal, + (!canonical).then_some(text), + body.content_hash.as_str(), + metadata.as_str() + ], + ) + .await + .unwrap(); + let mut rows = transaction + .query( + "SELECT store_id FROM lcm_raw_messages WHERE ordinal = ?1", + params![ordinal], + ) + .await + .unwrap(); + let id = rows.next().await.unwrap().unwrap().get(0).unwrap(); + transaction.commit().await.unwrap(); + id + } + + async fn tick(&self, rows: usize) -> ReplayMetadataCompactionReport { + let transaction = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .unwrap(); + let report = step(&transaction, rows).await.unwrap(); + transaction.commit().await.unwrap(); + report + } +} + +async fn scalar(conn: &(impl QueryExecutor + ?Sized), sql: &str) -> i64 { + conn.query(sql, ()) + .await + .unwrap() + .next() + .await + .unwrap() + .unwrap() + .get(0) + .unwrap() +} + +fn metadata(message: &LcmRawMessage) -> Value { + serde_json::from_str(message.metadata_json.as_deref().unwrap()).unwrap() +} + +#[asupersync::test] +async fn removes_only_the_duplicate_and_preserves_receipts_replay_and_summary_state() { + let store = Store::open().await; + let text = "retained verified reasoning and tool evidence ".repeat(180); + let id = store + .insert(1, &text, Value::String(text.clone()), true) + .await; + store + .conn + .execute_batch( + "INSERT INTO session_summary_nodes(summary_id, provider, conversation_id, session_id, + depth, summary_text, summary_hash, summary_token_count, source_token_count) + VALUES ('summary', 'codex', 'conversation', 'session', 0, 'summary', 'hash', 1, 1); + INSERT INTO session_summary_sources(summary_id, source_kind, source_id, ordinal) + VALUES ('summary', 'raw_message', '1', 0); + UPDATE lcm_summary_convergence_queue SET state = 'current', attempted_raw_store_id = 1;", + ) + .await + .unwrap(); + let before = raw::load_raw_message_by_store_id(&store.conn, id) + .await + .unwrap(); + let before_replay = crate::replay_transactions::raw_replay_message(&before); + let revision = scalar( + &store.conn, + "SELECT raw_revision_generation FROM lcm_summary_convergence_queue", + ) + .await; + let report = store.tick(48).await; + assert_eq!(report.rows_compacted, 1); + assert!(report.metadata_bytes_removed >= text.len() as u64); + assert!(!report.has_more); + let after = raw::load_raw_message_by_store_id(&store.conn, id) + .await + .unwrap(); + assert_eq!(after.content, before.content); + assert_eq!(after.content_hash, before.content_hash); + assert_eq!( + crate::replay_transactions::raw_replay_message(&after), + before_replay + ); + let mut after_metadata = metadata(&after); + assert!(after_metadata["active_replay"].get("content").is_none()); + assert_eq!( + after_metadata["ingest_protection"]["sanitization_receipt"], + metadata(&before)["ingest_protection"]["sanitization_receipt"] + ); + let protection = after_metadata + .as_object_mut() + .unwrap() + .remove("ingest_protection") + .unwrap(); + let receipt: SanitizationReceiptV1 = + serde_json::from_value(protection["metadata_sanitization_receipt"].clone()).unwrap(); + verify_sanitized_json_payload( + &Value::String(after_metadata.to_string()), + &receipt, + &ComponentVersion::new(LCM_PAYLOAD_SANITIZER_VERSION_V1).unwrap(), + ) + .unwrap(); + assert_eq!( + scalar( + &store.conn, + "SELECT raw_revision_generation FROM lcm_summary_convergence_queue" + ) + .await, + revision + ); + assert_eq!( + scalar( + &store.conn, + "SELECT COUNT(*) FROM lcm_summary_convergence_dirty_raw" + ) + .await, + 0 + ); + assert_eq!( + scalar(&store.conn, "SELECT COUNT(*) FROM session_summary_sources").await, + 1 + ); + assert_eq!( + scalar(&store.conn, "SELECT COUNT(*) FROM session_canonical_bodies").await, + 1 + ); + summary_convergence::validate_raw_update_guard(&store.conn) + .await + .unwrap(); + store.conn.execute("UPDATE lcm_raw_messages SET metadata_json = json_set(metadata_json, '$.provider_note', 'changed') WHERE store_id = ?1", params![id]).await.unwrap(); + assert_eq!( + scalar( + &store.conn, + "SELECT COUNT(*) FROM lcm_summary_convergence_dirty_raw" + ) + .await, + 1, + "ordinary updates still invalidate" + ); +} + +#[asupersync::test] +async fn structured_different_media_and_external_representations_are_retained() { + let store = Store::open().await; + let text = "ordinary original body ".repeat(30); + store + .insert( + 1, + &text, + serde_json::json!([{"type": "text", "text": text}]), + false, + ) + .await; + store + .insert( + 2, + &text, + Value::String("provider-specific replay representation".repeat(30)), + false, + ) + .await; + let placeholder = "[externalized tool output: retained-payload-placeholder]".repeat(20); + store + .insert(3, &placeholder, Value::String(placeholder.clone()), false) + .await; + let id = store + .insert(4, &text, Value::String(text.clone()), false) + .await; + store.conn.execute("UPDATE lcm_raw_messages SET storage_kind = 'external', payload_ref = 'payload-ref', placeholder_text = 'external' WHERE store_id = ?1", params![id]).await.unwrap(); + let media = "data:image/png;base64,aGVsbG8="; + store + .insert(5, media, Value::String(media.to_owned()), false) + .await; + let report = store.tick(48).await; + assert_eq!(report.rows_scanned, 5); + assert_eq!(report.rows_compacted, 0); + assert_eq!(report.metadata_bytes_removed, 0); + // All five provider representations still carry their original content. + assert_eq!(scalar(&store.conn, "SELECT COUNT(*) FROM lcm_raw_messages WHERE json_type(metadata_json, '$.active_replay.content') IS NOT NULL").await, 5); +} + +#[asupersync::test] +async fn corrupt_body_rolls_back_prior_changes_frontier_and_guard_suspension() { + let store = Store::open().await; + let first = "first verified duplicated string ".repeat(120); + let second = "second verified duplicated string ".repeat(160); + let id = store + .insert(1, &first, Value::String(first.clone()), false) + .await; + store + .insert(2, &second, Value::String(second.clone()), true) + .await; + let original = raw::load_raw_message_by_store_id(&store.conn, id) + .await + .unwrap(); + store + .conn + .execute_batch("UPDATE session_canonical_bodies SET body = X'00'") + .await + .unwrap(); + let transaction = store + .conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .unwrap(); + assert!(step(&transaction, 48).await.is_err()); + transaction.rollback().await.unwrap(); + assert_eq!(frontier(&store.conn).await.unwrap(), 0); + assert_eq!( + raw::load_raw_message_by_store_id(&store.conn, id) + .await + .unwrap(), + original + ); + summary_convergence::validate_raw_update_guard(&store.conn) + .await + .unwrap(); + store.conn.execute("UPDATE lcm_raw_messages SET metadata_json = json_set(metadata_json, '$.provider_note', 'changed') WHERE store_id = ?1", params![id]).await.unwrap(); + assert_eq!( + scalar( + &store.conn, + "SELECT COUNT(*) FROM lcm_summary_convergence_dirty_raw" + ) + .await, + 1, + "rollback restores ordinary invalidation" + ); +} + +#[asupersync::test] +async fn stale_metadata_cannot_be_overwritten_and_missing_guard_refuses_repair() { + let store = Store::open().await; + let text = "verified duplicate content ".repeat(80); + let id = store + .insert(1, &text, Value::String(text.clone()), false) + .await; + let snapshot = raw::load_raw_message_by_store_id(&store.conn, id) + .await + .unwrap(); + let compacted = compacted_metadata(&snapshot).unwrap().unwrap(); + store.conn.execute("UPDATE lcm_raw_messages SET metadata_json = json_set(metadata_json, '$.provider_note', 'concurrent') WHERE store_id = ?1", params![id]).await.unwrap(); + assert!( + !apply_metadata(&store.conn, id, &snapshot, &compacted) + .await + .unwrap() + ); + assert_eq!( + metadata( + &raw::load_raw_message_by_store_id(&store.conn, id) + .await + .unwrap() + )["provider_note"], + "concurrent" + ); + store + .conn + .execute_batch("DROP TRIGGER lcm_summary_convergence_raw_unprotected_update") + .await + .unwrap(); + let transaction = store + .conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .unwrap(); + let error = step(&transaction, 48).await.unwrap_err(); + assert!(error.to_string().contains("guard is missing")); + transaction.rollback().await.unwrap(); + assert_eq!(frontier(&store.conn).await.unwrap(), 0); +} + +#[asupersync::test] +async fn row_and_byte_budgets_advance_a_durable_frontier_and_finished_pages_are_noops() { + let store = Store::open().await; + let text = "bounded replay text ".repeat(35_000); + for ordinal in 1..=4 { + store + .insert(ordinal, &text, Value::String(text.clone()), true) + .await; + } + let first = store.tick(1).await; + assert_eq!(first.rows_scanned, 1); + assert_eq!(first.rows_compacted, 1); + assert!(first.has_more); + assert_eq!(frontier(&store.conn).await.unwrap(), 1); + let second = store.tick(48).await; + assert!( + second.rows_scanned > 0 && second.rows_scanned < 3, + "byte cap stops before the third large row" + ); + assert!(second.has_more); + let last = store.tick(48).await; + assert!(!last.has_more); + let idle = store.tick(48).await; + assert_eq!(idle, ReplayMetadataCompactionReport::default()); +} diff --git a/crates/tracedecay-lcm/src/retention/tests.rs b/crates/tracedecay-lcm/src/retention/tests.rs index 6978cf853b..7d481db6ed 100644 --- a/crates/tracedecay-lcm/src/retention/tests.rs +++ b/crates/tracedecay-lcm/src/retention/tests.rs @@ -402,7 +402,8 @@ async fn dry_run_counts_without_mutating() -> Result<(), String> { #[asupersync::test] async fn backlog_read_reports_real_eligible_bytes_and_watermark() -> Result<(), String> { let store = test_store().await?; - let durable = insert_message(&store.conn, 1, 90, "retention backlog bytes").await?; + let content = "retention backlog café ☕"; + let durable = insert_message(&store.conn, 1, 90, content).await?; make_projection_durable(&store.conn, durable).await?; let records = read_session_retention_backlog( @@ -417,7 +418,7 @@ async fn backlog_read_reports_real_eligible_bytes_and_watermark() -> Result<(), assert_eq!(records.len(), 1); assert_eq!(records[0].table.as_str(), "lcm_raw_messages"); - assert!(records[0].past_window_bytes.get() > 0); + assert_eq!(records[0].past_window_bytes.get(), content.len() as u64); assert_eq!( records[0].oldest_past_window_at, tracedecay_domain::UtcMicros((NOW - 90 * DAY) * 1_000_000) @@ -487,16 +488,15 @@ async fn drop_removes_the_single_content_copy_and_its_index() -> Result<(), Stri Ok(()) } -// (a) offload only externalizes projection-durable rows AFTER durability; the -// bulky inline content leaves the raw column, replaced by a recoverable -// content-addressed placeholder (§4 one content copy). +// Compaction only changes projection-durable rows, and retains complete text +// through the canonical verified reader without creating a plain payload file. #[asupersync::test] -async fn offload_externalizes_durable_content_after_durability() -> Result<(), String> { +async fn offload_compacts_durable_content_without_creating_payload_files() -> Result<(), String> { let store = test_store().await?; let conn = &store.conn; - let content = "x".repeat(4096); + let content = "x".repeat(8192); let durable = insert_message(conn, 1, 90, &content).await?; - let _live = insert_message(conn, 2, 90, &content).await?; + let live = insert_message(conn, 2, 90, &content).await?; make_projection_durable(conn, durable).await?; let config = LcmRetentionConfig { @@ -511,34 +511,811 @@ async fn offload_externalizes_durable_content_after_durability() -> Result<(), S "only the durable row is offloaded" ); assert!(report.offloaded.bytes_reclaimed >= 4096); + assert_eq!(report.canonicalized_rows, 1); + assert!(report.errors.is_empty(), "{:?}", report.errors); - // Durable row: inline content cleared, now external with a payload_ref. + // The ordinary inline reader hydrates the shared canonical body. let kind: String = fetch_str( conn, "SELECT storage_kind FROM lcm_raw_messages WHERE store_id = ?1", params![durable], ) .await?; - assert_eq!(kind, "external"); + assert_eq!(kind, "inline"); let payload_present = fetch_i64(conn, "SELECT COUNT(*) FROM lcm_external_payloads", ()).await?; - assert_eq!(payload_present, 1, "content stored once, addressed by hash"); - // The un-projected live row is untouched (still inline). - let live_kind: i64 = fetch_i64( + assert_eq!(payload_present, 0); + assert_eq!(count(conn, "session_canonical_bodies").await?, 1); + assert!(!payload::payload_dir(&store.storage_root).exists()); + let restored = raw::load_raw_message_by_store_id(conn, durable) + .await + .map_err(|error| error.to_string())?; + assert_eq!(restored.content, content); + assert!(restored.payload_ref.is_none()); + let live_content = fetch_str( conn, - "SELECT COUNT(*) FROM lcm_raw_messages WHERE storage_kind = 'inline'", + "SELECT content FROM lcm_raw_messages WHERE store_id = ?1", + params![live], + ) + .await?; + assert_eq!(live_content, content, "live un-projected row stays intact"); + let repeated = run_apply(conn, &store.storage_root, &config).await?; + assert_eq!(repeated.offloaded.acted, 0); + assert_eq!(repeated.canonicalized_rows, 0); + Ok(()) +} + +#[asupersync::test] +async fn offload_keeps_small_bodies_inline_and_uses_byte_threshold() -> Result<(), String> { + let store = test_store().await?; + let small = "é".repeat(INLINE_BODY_BYTES / 2 - 1); + let small_id = insert_message(&store.conn, 1, 90, &small).await?; + make_projection_durable(&store.conn, small_id).await?; + let config = LcmRetentionConfig { + drop_after_days: None, + ..LcmRetentionConfig::default() + }; + let report = run_apply(&store.conn, &store.storage_root, &config).await?; + assert!(report.errors.is_empty(), "{:?}", report.errors); + assert_eq!(report.offloaded.eligible, 0); + assert_eq!(count(&store.conn, "lcm_external_payloads").await?, 0); + assert!(!payload::payload_dir(&store.storage_root).exists()); + let backlog = read_session_retention_backlog( + &store.conn, + tracedecay_contracts::storage::StoreKeyV1::new("sessions.db") + .map_err(|error| error.to_string())?, + &config, + NOW, + ) + .await + .map_err(|error| error.to_string())?; + assert_eq!(backlog[0].past_window_bytes.get(), 0); + + let boundary = "é".repeat(INLINE_BODY_BYTES / 2); + let boundary_id = insert_message(&store.conn, 2, 90, &boundary).await?; + make_projection_durable(&store.conn, boundary_id).await?; + let report = run_apply(&store.conn, &store.storage_root, &config).await?; + assert!(report.errors.is_empty(), "{:?}", report.errors); + assert_eq!(report.offloaded.eligible, 1); + assert_eq!( + report.offloaded.acted, 0, + "the full UTF-8 snippet would duplicate this body" + ); + assert_eq!(report.offloaded.bytes_reclaimed, 0); + assert_eq!(count(&store.conn, "lcm_external_payloads").await?, 0); + let unchanged = raw::load_raw_message_by_store_id(&store.conn, small_id) + .await + .map_err(|error| error.to_string())?; + assert_eq!(unchanged.content, small); + Ok(()) +} + +#[asupersync::test] +async fn small_offload_restores_verified_text_and_releases_file_only_after_gc_grace() +-> Result<(), String> { + let store = test_store().await?; + let content = "small tool output: café completed"; + let target = seed_retention_offload(&store, 1, content).await?; + let path = payload::payload_dir(&store.storage_root).join(&target.payload_ref); + let config = LcmRetentionConfig { + drop_after_days: None, + ..LcmRetentionConfig::default() + }; + let preview = run_session_retention_authorized( + &store.conn, + &store.storage_root, + "all", + None, + &config, + RetentionMode::DryRun, + NOW, + &|_| Ok(()), + ) + .await + .map_err(|error| error.to_string())?; + assert_eq!(preview.inlined.eligible, 1); + assert_eq!(preview.inlined.acted, 0); + assert_eq!(preview.bytes_reclaimed(), 0); + assert_eq!(count(&store.conn, "lcm_external_payloads").await?, 1); + assert!(path.exists()); + + let report = run_apply(&store.conn, &store.storage_root, &config).await?; + assert!(report.errors.is_empty(), "{:?}", report.errors); + assert_eq!(report.inlined.acted, 1); + assert_eq!(report.offloaded.acted, 0); + assert_eq!(report.bytes_reclaimed(), 0); + assert_eq!(count(&store.conn, "session_canonical_bodies").await?, 0); + let restored = raw::load_raw_message_by_store_id(&store.conn, target.store_id) + .await + .map_err(|error| error.to_string())?; + assert_eq!(restored.content, content); + assert_eq!(restored.content_hash, target.content_hash); + assert_eq!(restored.metadata_json, target.metadata_json); + assert!(restored.payload_ref.is_none()); + assert!(path.exists(), "inlining never deletes the payload file"); + assert_eq!( + fetch_i64( + &store.conn, + "SELECT COUNT(*) FROM lcm_raw_messages_fts + WHERE lcm_raw_messages_fts MATCH 'completed'", + (), + ) + .await?, + 1, + "restoration also makes the original tool text searchable again" + ); + + let marked_at = fetch_i64( + &store.conn, + "SELECT first_seen_at FROM lcm_gc_marks WHERE payload_ref = ?1", + params![target.payload_ref.as_str()], + ) + .await?; + let gc_config = crate::LcmGcConfig::default(); + for (now, retained) in [ + (marked_at + gc_config.grace_seconds as i64 - 1, true), + (marked_at + gc_config.grace_seconds as i64 + 1, false), + ] { + let gc = crate::gc::run_payload_gc_with_apply( + &store.conn, + &store.storage_root, + PROVIDER, + Some(SESSION), + &gc_config, + true, + now, + ) + .await + .map_err(|error| error.to_string())?; + assert!(gc.errors.is_empty(), "{:?}", gc.errors); + assert_eq!(path.exists(), retained); + assert_eq!( + count(&store.conn, "lcm_external_payloads").await?, + i64::from(retained) + ); + } + let restored = raw::load_raw_message_by_store_id(&store.conn, target.store_id) + .await + .map_err(|error| error.to_string())?; + assert_eq!(restored.content, content, "full text survives payload GC"); + Ok(()) +} + +#[asupersync::test] +async fn offload_restoration_rejects_corruption_staleness_and_lost_authority() -> Result<(), String> +{ + for failure in ["payload", "receipt", "stale", "metadata", "authority"] { + let store = test_store().await?; + let content = if failure == "payload" { + "original complete text".to_owned() + } else { + "original complete text ".repeat(1024) + }; + let mut target = seed_retention_offload(&store, 1, &content).await?; + let path = payload::payload_dir(&store.storage_root).join(&target.payload_ref); + match failure { + "payload" => { + std::fs::write(&path, "tampered bytes").map_err(|error| error.to_string())? + } + "receipt" => { + store + .conn + .execute( + "UPDATE lcm_raw_messages SET metadata_json = '{}' WHERE store_id = ?1", + params![target.store_id], + ) + .await + .map_err(|error| error.to_string())?; + target.metadata_json = Some("{}".to_owned()); + } + "stale" => { + store + .conn + .execute( + "UPDATE lcm_raw_messages SET timestamp = ?2 WHERE store_id = ?1", + params![target.store_id, NOW], + ) + .await + .map_err(|error| error.to_string())?; + } + "metadata" => { + store + .conn + .execute( + "UPDATE lcm_raw_messages + SET metadata_json = json_set(metadata_json, '$.changed', 1) + WHERE store_id = ?1", + params![target.store_id], + ) + .await + .map_err(|error| error.to_string())?; + } + _ => {} + } + let convergence_before = mark_convergence_current(&store.conn).await?; + let error = inline_offloaded_one( + RetentionStore::Connection(&store.conn), + &store.storage_root, + &target, + Some(&|intent| { + if failure == "authority" && intent == "commit session retention inline pass" { + Err(LcmError::Cancelled) + } else { + Ok(()) + } + }), + ) + .await + .expect_err("unproven or changed payload must remain external"); + match failure { + "payload" | "receipt" => assert_eq!(error, LcmError::PayloadIntegrityMismatch), + "stale" | "metadata" => { + assert!(error.to_string().contains("compare-and-swap rejected")) + } + "authority" => assert_eq!(error, LcmError::Cancelled), + _ => unreachable!(), + } + assert_eq!( + fetch_i64( + &store.conn, + "SELECT COUNT(*) FROM lcm_raw_messages + WHERE store_id = ?1 AND storage_kind = 'external' + AND content IS NULL AND payload_ref = ?2", + params![target.store_id, target.payload_ref.as_str()], + ) + .await?, + 1, + "{failure} must preserve the original row" + ); + assert_eq!(count(&store.conn, "lcm_external_payloads").await?, 1); + assert_eq!(count(&store.conn, "session_canonical_bodies").await?, 0); + assert!(path.exists(), "{failure} must preserve the payload file"); + assert_eq!(convergence_snapshot(&store.conn).await?, convergence_before); + summary_convergence::validate_raw_update_guard(&store.conn) + .await + .map_err(|error| error.to_string())?; + store + .conn + .execute( + "UPDATE lcm_raw_messages SET timestamp = timestamp + 1 WHERE store_id = ?1", + params![target.store_id], + ) + .await + .map_err(|error| error.to_string())?; + assert_eq!( + count(&store.conn, "lcm_summary_convergence_dirty_raw").await?, + 1, + "ordinary evidence changes still invalidate after {failure}" + ); + } + Ok(()) +} + +#[asupersync::test] +async fn large_offloads_share_verified_canonical_text_and_release_files_after_grace() +-> Result<(), String> { + let store = test_store().await?; + let content = "restored searchable café tool output\n".repeat(1024); + let first = seed_retention_offload(&store, 1, &content).await?; + let second = seed_retention_offload(&store, 2, &content).await?; + let convergence_before = mark_convergence_current(&store.conn).await?; + let config = LcmRetentionConfig { + drop_after_days: None, + ..LcmRetentionConfig::default() + }; + let report = run_apply(&store.conn, &store.storage_root, &config).await?; + assert!(report.errors.is_empty(), "{:?}", report.errors); + assert_eq!(report.inlined.acted, 2); + assert_eq!(report.canonicalized_rows, 2); + assert_eq!(report.offloaded.acted, 0); + assert_eq!( + report.bytes_reclaimed(), + 0, + "restoration does not report moved bytes as reclaimed" + ); + assert_eq!(count(&store.conn, "session_canonical_bodies").await?, 1); + assert_eq!(convergence_snapshot(&store.conn).await?, convergence_before); + assert_eq!(fetch_i64(&store.conn, + "SELECT COUNT(*) FROM lcm_raw_messages_fts WHERE lcm_raw_messages_fts MATCH 'searchable'", () + ).await?, 2); + for target in [&first, &second] { + let restored = raw::load_raw_message_by_store_id(&store.conn, target.store_id) + .await + .map_err(|error| error.to_string())?; + assert_eq!(restored.content, content); + assert_eq!(restored.content_hash, target.content_hash); + assert_eq!(restored.metadata_json, target.metadata_json); + assert!(restored.payload_ref.is_none()); + assert!( + payload::payload_dir(&store.storage_root) + .join(&target.payload_ref) + .exists() + ); + } + let marked_at = fetch_i64( + &store.conn, + "SELECT MAX(first_seen_at) FROM lcm_gc_marks", (), ) .await?; - assert_eq!(live_kind, 1, "live un-projected row stays inline"); + let gc_config = crate::LcmGcConfig::default(); + let gc = crate::gc::run_payload_gc_with_apply( + &store.conn, + &store.storage_root, + PROVIDER, + Some(SESSION), + &gc_config, + true, + marked_at + gc_config.grace_seconds as i64 + 1, + ) + .await + .map_err(|error| error.to_string())?; + assert!(gc.errors.is_empty(), "{:?}", gc.errors); + assert_eq!(count(&store.conn, "lcm_external_payloads").await?, 0); + for target in [&first, &second] { + assert!( + !payload::payload_dir(&store.storage_root) + .join(&target.payload_ref) + .exists() + ); + assert_eq!( + raw::load_raw_message_by_store_id(&store.conn, target.store_id) + .await + .map_err(|error| error.to_string())? + .content, + content + ); + } + summary_convergence::validate_raw_update_guard(&store.conn) + .await + .map_err(|error| error.to_string())?; + store.conn.execute( + "UPDATE lcm_raw_messages SET metadata_json = json_set(metadata_json, '$.provider_note', 'changed') + WHERE store_id = ?1", params![first.store_id], + ).await.map_err(|error| error.to_string())?; + assert_eq!( + count(&store.conn, "lcm_summary_convergence_dirty_raw").await?, + 1, + "ordinary metadata changes still invalidate after successful restoration" + ); + Ok(()) +} + +#[asupersync::test] +async fn canonical_compaction_counts_utf8_snippets_and_marginal_shared_body_cost() +-> Result<(), String> { + let store = test_store().await?; + let mut state = 0x923a_405b_u64; + let ascii: String = (0..6000) + .map(|index| { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + if index % 9 == 8 { + ' ' + } else { + char::from(b'a' + ((state >> 32) % 26) as u8) + } + }) + .collect(); + let unicode = "é".repeat(3000); + let mut targets = Vec::new(); + for (index, content) in [&ascii, &unicode].into_iter().enumerate() { + let packed = pack_canonical(content).map_err(|error| error.to_string())?; + let snippet = raw::derived_text_for_snippet(content); + assert!( + packed.blob.len() < content.len(), + "BLOB-only comparison would permit this body" + ); + assert!( + packed.blob.len() + snippet.len() >= content.len(), + "the full representation grows" + ); + targets.push(seed_retention_offload(&store, index as i64 + 1, content).await?); + let direct = insert_message(&store.conn, index as i64 + 3, 90, content).await?; + make_projection_durable(&store.conn, direct).await?; + } + assert!( + raw::derived_text_for_snippet(&unicode).len() + > raw::derived_text_for_snippet(&unicode).chars().count() + ); + let config = LcmRetentionConfig { + drop_after_days: None, + ..LcmRetentionConfig::default() + }; + let report = run_apply(&store.conn, &store.storage_root, &config).await?; + assert!(report.errors.is_empty(), "{:?}", report.errors); + assert_eq!(report.inlined.eligible, 2); + assert_eq!(report.offloaded.eligible, 2); + assert_eq!(report.inlined.acted + report.offloaded.acted, 0); + assert_eq!(count(&store.conn, "session_canonical_bodies").await?, 0); + assert_eq!(count(&store.conn, "lcm_external_payloads").await?, 2); + + // Another ordinary canonical owner makes this BLOB a zero-marginal-cost + // reuse. Only its new snippet and record allowance need to fit. + let packed = pack_canonical(&ascii).map_err(|error| error.to_string())?; + let txn = store + .conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .map_err(|error| error.to_string())?; + raw::persist_canonical_body(&txn, &packed) + .await + .map_err(|error| error.to_string())?; + txn.execute( + "UPDATE lcm_raw_messages SET content = NULL, placeholder_text = ?1 WHERE message_id = 'msg-3'", + params![raw::derived_text_for_snippet(&ascii)], + ).await.map_err(|error| error.to_string())?; + txn.commit().await.map_err(|error| error.to_string())?; + assert_eq!( + inline_offloaded_one( + RetentionStore::Connection(&store.conn), + &store.storage_root, + &targets[0], + None + ) + .await + .map_err(|error| error.to_string())?, + Some(true) + ); + let restored = raw::load_raw_message_by_store_id(&store.conn, targets[0].store_id) + .await + .map_err(|error| error.to_string())?; + assert_eq!(restored.content, ascii); + assert_eq!(restored.metadata_json, targets[0].metadata_json); + assert_eq!(count(&store.conn, "session_canonical_bodies").await?, 1); + assert_eq!( + fetch_str( + &store.conn, + "SELECT storage_kind FROM lcm_raw_messages WHERE store_id = ?1", + params![targets[1].store_id] + ) + .await?, + "external" + ); Ok(()) } #[asupersync::test] -async fn offload_cas_preserves_revived_row_and_rolls_back_payload() -> Result<(), String> { +async fn restoration_revisits_old_cursor_and_stops_before_exceeding_byte_budget() +-> Result<(), String> { let store = test_store().await?; - let original = "stale content".repeat(128); + let content = "bounded historical body\n".repeat(3 * 1024 * 1024 / 24); + let mut targets = Vec::new(); + for ordinal in 1..=3 { + targets.push(seed_retention_offload(&store, ordinal, &content).await?); + } + let oversized = + seed_retention_offload(&store, 4, &"x".repeat(MAX_REPACK_BODY_BYTES + 1)).await?; + RetentionCursor { + aged_through: (NOW, i64::MAX), + durable_through: i64::MAX, + } + .persist(&store.conn, "inline_offloaded") + .await + .map_err(|error| error.to_string())?; + let config = LcmRetentionConfig { + drop_after_days: None, + ..LcmRetentionConfig::default() + }; + let preview = run_session_retention_authorized( + &store.conn, + &store.storage_root, + "all", + None, + &config, + RetentionMode::DryRun, + NOW, + &|_| Ok(()), + ) + .await + .map_err(|error| error.to_string())?; + assert_eq!(preview.inlined.eligible, 2); + assert_eq!(preview.inlined.acted, 0); + assert_eq!(count(&store.conn, "session_canonical_bodies").await?, 0); + assert!( + schema::get_gc_meta(&store.conn, &RetentionCursor::keys(RESTORE_CURSOR).0) + .await + .map_err(|error| error.to_string())? + .is_none() + ); + for (expected, has_more) in [(2, true), (1, false), (0, false)] { + let report = run_apply(&store.conn, &store.storage_root, &config).await?; + assert!(report.errors.is_empty(), "{:?}", report.errors); + assert_eq!(report.inlined.acted, expected); + assert_eq!(report.canonicalized_rows, expected); + assert_eq!(report.storage_has_more, has_more); + } + for target in &targets { + assert_eq!( + raw::load_raw_message_by_store_id(&store.conn, target.store_id) + .await + .map_err(|error| error.to_string())? + .content, + content + ); + } + assert_eq!( + fetch_str( + &store.conn, + "SELECT payload_ref FROM lcm_raw_messages WHERE store_id = ?1", + params![oversized.store_id] + ) + .await?, + oversized.payload_ref + ); + assert!( + payload::payload_dir(&store.storage_root) + .join(&oversized.payload_ref) + .exists() + ); + // A short continuation also drains newly durable inline rows across its + // row cap without repeating already-compacted work or skipping a source. + let direct_content = "short continuation text\n".repeat(1024); + for ordinal in 5..=7 { + let store_id = insert_message(&store.conn, ordinal, 90, &direct_content).await?; + make_projection_durable(&store.conn, store_id).await?; + } + let config = LcmRetentionConfig { + max_batch_size: 1, + ..config + }; + let mut total = 0; + let mut drained = false; + for _ in 0..6 { + let report = run_apply(&store.conn, &store.storage_root, &config).await?; + assert!(report.errors.is_empty(), "{:?}", report.errors); + assert!(report.offloaded.acted <= 1); + total += report.offloaded.acted; + if !report.storage_has_more { + drained = true; + break; + } + } + assert!(drained, "bounded short continuations must reach the end"); + assert_eq!(total, 3); + Ok(()) +} + +#[asupersync::test] +async fn canonical_compaction_commit_denial_preserves_original_inline_body() -> Result<(), String> { + let store = test_store().await?; + let content = "complete retained body\n".repeat(1024); + let store_id = insert_message(&store.conn, 1, 90, &content).await?; + make_projection_durable(&store.conn, store_id).await?; + let original = raw::load_raw_message_by_store_id(&store.conn, store_id) + .await + .map_err(|error| error.to_string())?; + let config = LcmRetentionConfig { + drop_after_days: None, + ..LcmRetentionConfig::default() + }; + let report = run_session_retention_authorized( + &store.conn, + &store.storage_root, + "all", + None, + &config, + RetentionMode::Apply, + NOW, + &|intent| { + if intent == "commit session retention canonical pass" { + Err(LcmError::Cancelled) + } else { + Ok(()) + } + }, + ) + .await + .map_err(|error| error.to_string())?; + assert_eq!(report.errors.len(), 1); + assert_eq!(report.offloaded.acted, 0); + assert!( + !report.storage_has_more, + "authority loss must not request a hot-loop retry" + ); + let retained = raw::load_raw_message_by_store_id(&store.conn, store_id) + .await + .map_err(|error| error.to_string())?; + assert_eq!(retained.content, original.content); + assert_eq!(retained.metadata_json, original.metadata_json); + assert_eq!(count(&store.conn, "session_canonical_bodies").await?, 0); + assert_eq!(count(&store.conn, "lcm_external_payloads").await?, 0); + assert_eq!( + RetentionCursor::read(&store.conn, "offload") + .await + .map_err(|error| error.to_string())? + .aged_through, + (i64::MIN, i64::MIN) + ); + Ok(()) +} + +#[asupersync::test] +async fn corrupt_shared_body_cannot_replace_plaintext_or_external_source() -> Result<(), String> { + let store = test_store().await?; + let content = "verified source text\n".repeat(1024); + let external = seed_retention_offload(&store, 1, &content).await?; + let direct = insert_message(&store.conn, 2, 90, &content).await?; + make_projection_durable(&store.conn, direct).await?; + let mut packed = pack_canonical(&content).map_err(|error| error.to_string())?; + packed.blob = vec![0xff]; + raw::persist_canonical_body(&store.conn, &packed) + .await + .map_err(|error| error.to_string())?; + // Both kinds have independent authoritative content and must not fetch an + // unrelated canonical BLOB. The normal canonical path still rejects it. + for store_id in [external.store_id, direct] { + let mut rows = store + .conn + .query( + &format!( + "SELECT {} FROM lcm_raw_messages WHERE store_id = ?1", + raw::RAW_MESSAGE_SELECT_COLUMNS + ), + params![store_id], + ) + .await + .map_err(|error| error.to_string())?; + let row = rows + .next() + .await + .map_err(|error| error.to_string())? + .ok_or("missing fixture row")?; + assert!( + row.get::>>(14) + .map_err(|error| error.to_string())? + .is_none() + ); + raw::verified_raw_message_from_row(&row).map_err(|error| error.to_string())?; + } + let config = LcmRetentionConfig { + drop_after_days: None, + ..LcmRetentionConfig::default() + }; + let report = run_apply(&store.conn, &store.storage_root, &config).await?; + assert_eq!(report.errors.len(), 2); + assert_eq!(report.inlined.acted + report.offloaded.acted, 0); + assert_eq!( + fetch_str( + &store.conn, + "SELECT content FROM lcm_raw_messages WHERE store_id = ?1", + params![direct] + ) + .await?, + content + ); + assert_eq!( + fetch_str( + &store.conn, + "SELECT payload_ref FROM lcm_raw_messages WHERE store_id = ?1", + params![external.store_id] + ) + .await?, + external.payload_ref + ); + assert!( + payload::payload_dir(&store.storage_root) + .join(&external.payload_ref) + .exists() + ); + assert_eq!( + RetentionCursor::read(&store.conn, RESTORE_CURSOR) + .await + .map_err(|error| error.to_string())? + .aged_through, + (i64::MIN, i64::MIN) + ); + store + .conn + .execute( + "UPDATE lcm_raw_messages SET content = NULL, placeholder_text = ?1 WHERE store_id = ?2", + params![raw::derived_text_for_snippet(&content), direct], + ) + .await + .map_err(|error| error.to_string())?; + assert_eq!( + raw::load_raw_message_by_store_id(&store.conn, direct) + .await + .expect_err("actual canonical content must be verified"), + LcmError::PayloadIntegrityMismatch + ); + Ok(()) +} + +#[asupersync::test] +async fn small_offload_restoration_is_bounded_scoped_and_leaves_protected_payloads() +-> Result<(), String> { + let store = test_store().await?; + let first = seed_retention_offload(&store, 1, "first old body").await?; + let second = seed_retention_offload(&store, 2, "second old body").await?; + let protected = seed_retention_offload(&store, 3, "protected body").await?; + store + .conn + .execute( + "UPDATE lcm_external_payloads SET kind = 'quarantined_assistant_output' + WHERE payload_ref = ?1", + params![protected.payload_ref.as_str()], + ) + .await + .map_err(|error| error.to_string())?; + let config = LcmRetentionConfig { + drop_after_days: None, + max_batch_size: 1, + ..LcmRetentionConfig::default() + }; + for disabled in [ + LcmRetentionConfig { + enabled: false, + ..config + }, + LcmRetentionConfig { + offload_after_days: None, + ..config + }, + ] { + let report = run_apply(&store.conn, &store.storage_root, &disabled).await?; + assert!(report.errors.is_empty(), "{:?}", report.errors); + assert_eq!(report.inlined.eligible, 0); + assert_eq!(report.inlined.acted, 0); + } + for (provider, session_id, expected) in [ + ("other-provider", None, 0), + (PROVIDER, Some("other-session"), 0), + (PROVIDER, Some(SESSION), 1), + ("all", None, 1), + ("all", None, 0), + ] { + let report = run_session_retention_authorized( + &store.conn, + &store.storage_root, + provider, + session_id, + &config, + RetentionMode::Apply, + NOW, + &|_| Ok(()), + ) + .await + .map_err(|error| error.to_string())?; + assert!(report.errors.is_empty(), "{:?}", report.errors); + assert_eq!(report.inlined.acted, expected); + assert!(report.inlined.eligible <= 1); + assert_eq!(report.bytes_reclaimed(), 0); + } + for target in [&first, &second] { + assert!( + raw::load_raw_message_by_store_id(&store.conn, target.store_id) + .await + .map_err(|error| error.to_string())? + .payload_ref + .is_none() + ); + } + assert_eq!( + fetch_str( + &store.conn, + "SELECT payload_ref FROM lcm_raw_messages WHERE store_id = ?1", + params![protected.store_id], + ) + .await?, + protected.payload_ref + ); + assert!( + payload::payload_dir(&store.storage_root) + .join(&protected.payload_ref) + .exists() + ); + Ok(()) +} + +#[asupersync::test] +async fn offload_cas_preserves_revived_row_and_rolls_back_canonical_body() -> Result<(), String> { + let store = test_store().await?; + let original = "stale content".repeat(1024); let store_id = insert_message(&store.conn, 1, 90, &original).await?; make_projection_durable(&store.conn, store_id).await?; + let metadata_json = raw::load_raw_message_by_store_id(&store.conn, store_id) + .await + .map_err(|error| error.to_string())? + .metadata_json; let target = OffloadRow { store_id, provider: PROVIDER.to_string(), @@ -546,6 +1323,7 @@ async fn offload_cas_preserves_revived_row_and_rolls_back_payload() -> Result<() message_id: "msg-1".to_string(), timestamp: NOW - 90 * DAY, content: original, + metadata_json, }; let revived = "revived content"; let revived_hash = sha256_hex(revived.as_bytes()); @@ -565,7 +1343,6 @@ async fn offload_cas_preserves_revived_row_and_rolls_back_payload() -> Result<() } let error = offload_one( RetentionStore::Connection(&store.conn), - &store.storage_root, &target, Some(&authorize), ) @@ -592,13 +1369,8 @@ async fn offload_cas_preserves_revived_row_and_rolls_back_payload() -> Result<() "inline" ); assert_eq!(count(&store.conn, "lcm_external_payloads").await?, 0); - assert_eq!( - std::fs::read_dir(crate::payload::payload_dir(&store.storage_root)) - .map_err(|error| error.to_string())? - .count(), - 0, - "failed CAS removes the newly-created payload file" - ); + assert_eq!(count(&store.conn, "session_canonical_bodies").await?, 0); + assert!(!payload::payload_dir(&store.storage_root).exists()); Ok(()) } @@ -709,3 +1481,90 @@ async fn fetch_str( .ok_or_else(|| "no row".to_string())?; row.get::(0).map_err(|e| e.to_string()) } + +async fn convergence_snapshot(conn: &(impl QueryExecutor + ?Sized)) -> Result { + fetch_str( + conn, + "SELECT json_array(state, protection_frontier_store_id, attempted_raw_store_id, + raw_revision_generation, stale_from_store_id, + (SELECT COUNT(*) FROM lcm_summary_convergence_dirty_raw), + (SELECT COUNT(*) FROM lcm_summary_convergence_invalidation_work), + (SELECT COUNT(*) FROM session_summary_sources)) + FROM lcm_summary_convergence_queue", + (), + ) + .await +} + +async fn mark_convergence_current(conn: &(impl Executor + ?Sized)) -> Result { + conn.execute_batch( + "DELETE FROM lcm_summary_convergence_invalidation_work; + DELETE FROM lcm_summary_convergence_dirty_raw; + UPDATE lcm_summary_convergence_queue SET state = 'current', + attempted_raw_store_id = newest_raw_store_id, + protection_frontier_store_id = newest_raw_store_id, + stale_from_store_id = NULL;", + ) + .await + .map_err(|error| error.to_string())?; + convergence_snapshot(conn).await +} + +/// Seeds the prior retention representation through its actual payload writer. +async fn seed_retention_offload( + store: &TestStore, + ordinal: i64, + content: &str, +) -> Result { + let store_id = insert_message(&store.conn, ordinal, 90, content).await?; + make_projection_durable(&store.conn, store_id).await?; + let original = raw::load_raw_message_by_store_id(&store.conn, store_id) + .await + .map_err(|error| error.to_string())?; + assert_eq!( + original.content, content, + "fixture content must survive normal sanitization" + ); + offload_external_one( + RetentionStore::Connection(&store.conn), + &store.storage_root, + &OffloadRow { + store_id, + provider: PROVIDER.to_owned(), + session_id: SESSION.to_owned(), + message_id: format!("msg-{ordinal}"), + timestamp: NOW - 90 * DAY, + content: content.to_owned(), + metadata_json: original.metadata_json, + }, + None, + ) + .await + .map_err(|error| error.to_string())?; + let mut rows = store + .conn + .query( + "SELECT payload_ref, content_hash, placeholder_text, metadata_json + FROM lcm_raw_messages WHERE store_id = ?1", + params![store_id], + ) + .await + .map_err(|error| error.to_string())?; + let row = rows + .next() + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "missing offloaded row".to_string())?; + Ok(InlineOffloadedRow { + store_id, + timestamp: NOW - 90 * DAY, + provider: PROVIDER.to_owned(), + session_id: SESSION.to_owned(), + message_id: format!("msg-{ordinal}"), + payload_ref: row.get(0).map_err(|error| error.to_string())?, + content_hash: row.get(1).map_err(|error| error.to_string())?, + placeholder_text: row.get(2).map_err(|error| error.to_string())?, + metadata_json: row.get(3).map_err(|error| error.to_string())?, + byte_count: content.len(), + }) +} diff --git a/crates/tracedecay-lcm/src/schema.rs b/crates/tracedecay-lcm/src/schema.rs index 9036550627..4698c8fc77 100644 --- a/crates/tracedecay-lcm/src/schema.rs +++ b/crates/tracedecay-lcm/src/schema.rs @@ -10,6 +10,10 @@ use super::{LcmError, LcmRawMessage, raw}; #[cfg(test)] use super::util; +#[cfg(test)] +#[path = "schema/fts_tests.rs"] +mod fts_tests; + /// Message and raw rows carry no copy of their observation envelope; readers /// join it from the `observations` row. Raw rows store their body once. /// `snippet_text` and `index_text` are virtual columns computing @@ -27,9 +31,9 @@ use super::util; /// `session_summary_sources` authority (session temporal schema) through /// [`SUMMARY_VISIBLE_SQL`]. Session rows LCM creates carry their store's /// scope as project fields (version 14). A message whose host content is a -/// block array is stored as its text (version 15). Version 16 creates every -/// table, index and trigger in its final shape; no in-place upgrade runs on -/// an existing store. Older stores require a profile reset. +/// block array is stored as its text (version 15). Version 16 defines the +/// current durable schema. Derived index maintenance is refreshed on admission; +/// older durable schemas require a profile reset. pub const LCM_SCHEMA_VERSION: i64 = 16; /// Visibility rule for every LCM summary read, over a `session_summary_nodes` @@ -121,9 +125,20 @@ const RAW_FTS_DDL: &str = "CREATE VIRTUAL TABLE IF NOT EXISTS lcm_raw_messages_f 'delete', OLD.store_id, OLD.index_text, OLD.role, OLD.kind, OLD.model, OLD.tool_names ); - END; - CREATE TRIGGER IF NOT EXISTS lcm_raw_messages_fts_update - AFTER UPDATE ON lcm_raw_messages BEGIN + END;"; + +/// Derived index maintenance only runs when an indexed value actually changes. +/// `index_text` is generated, so comparing its values also covers body and +/// placeholder updates without treating metadata-only writes as new documents. +pub const RAW_FTS_UPDATE_TRIGGER_SQL: &str = "CREATE TRIGGER lcm_raw_messages_fts_update + AFTER UPDATE ON lcm_raw_messages + WHEN OLD.store_id IS NOT NEW.store_id + OR OLD.index_text IS NOT NEW.index_text + OR OLD.role IS NOT NEW.role + OR OLD.kind IS NOT NEW.kind + OR OLD.model IS NOT NEW.model + OR OLD.tool_names IS NOT NEW.tool_names + BEGIN INSERT INTO lcm_raw_messages_fts( lcm_raw_messages_fts, rowid, index_text, role, kind, model, tool_names ) @@ -135,6 +150,30 @@ const RAW_FTS_DDL: &str = "CREATE VIRTUAL TABLE IF NOT EXISTS lcm_raw_messages_f VALUES (NEW.store_id, NEW.index_text, NEW.role, NEW.kind, NEW.model, NEW.tool_names); END;"; +async fn ensure_raw_fts_update_trigger(conn: &(impl Executor + ?Sized)) -> Result<(), LcmError> { + let mut rows = conn + .query( + "SELECT sql FROM sqlite_schema + WHERE type = 'trigger' AND name = 'lcm_raw_messages_fts_update'", + (), + ) + .await?; + let existing = match rows.next().await? { + Some(row) => Some(row.get::(0)?), + None => None, + }; + drop(rows); + if existing.as_deref().map(|sql| sql.trim_end_matches(';')) + == Some(RAW_FTS_UPDATE_TRIGGER_SQL.trim_end_matches(';')) + { + return Ok(()); + } + conn.execute_batch("DROP TRIGGER IF EXISTS lcm_raw_messages_fts_update;") + .await?; + conn.execute_batch(RAW_FTS_UPDATE_TRIGGER_SQL).await?; + Ok(()) +} + /// A payload is referenced only by its owner raw row, the /// `(provider, message_id)` its metadata names. Payload GC therefore never /// scans message text for references: a change to the owner row that can drop @@ -257,7 +296,7 @@ pub async fn ensure_lcm_schema_in_transaction( conn: &(impl Executor + ?Sized), ) -> Result<(), LcmError> { match require_admissible_lcm_schema(conn).await? { - LcmSchemaAdmission::Current => return Ok(()), + LcmSchemaAdmission::Current => return ensure_raw_fts_update_trigger(conn).await, LcmSchemaAdmission::Fresh => {} } @@ -379,6 +418,7 @@ pub async fn ensure_lcm_schema_in_transaction( ensure_raw_identity_schema(conn).await?; ensure_payload_gc_candidates(conn).await?; conn.execute_batch(RAW_FTS_DDL).await?; + ensure_raw_fts_update_trigger(conn).await?; super::summary_convergence::ensure_schema(conn).await?; for sql in LCM_STATUS_PERFORMANCE_INDEX_SQL { conn.execute_batch(sql).await?; diff --git a/crates/tracedecay-lcm/src/schema/fts_tests.rs b/crates/tracedecay-lcm/src/schema/fts_tests.rs new file mode 100644 index 0000000000..212bf53fd6 --- /dev/null +++ b/crates/tracedecay-lcm/src/schema/fts_tests.rs @@ -0,0 +1,193 @@ +use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, TestConnection, params}; + +use super::{ensure_lcm_schema, util}; + +async fn fixture() -> Result<(tempfile::TempDir, TestConnection), String> { + let temp = tempfile::tempdir().map_err(|error| error.to_string())?; + let connection = TestConnection::open(&temp.path().join("sessions.db")); + connection + .execute_batch( + "CREATE TABLE sessions ( + provider TEXT NOT NULL, session_id TEXT NOT NULL, + PRIMARY KEY(provider, session_id) + ); + INSERT INTO sessions VALUES ('codex', 'session');", + ) + .await + .map_err(|error| error.to_string())?; + ensure_lcm_schema(&connection) + .await + .map_err(|error| error.to_string())?; + connection + .execute_batch( + "INSERT INTO lcm_raw_messages ( + provider, message_id, session_id, role, ordinal, content, + content_hash, storage_kind + ) VALUES ('codex', 'message', 'session', 'assistant', 1, + 'originalword', 'hash', 'inline');", + ) + .await + .map_err(|error| error.to_string())?; + Ok((temp, connection)) +} + +async fn matches(connection: &TestConnection, query: &str) -> Result { + util::fetch_i64( + &**connection, + "SELECT COUNT(*) FROM lcm_raw_messages_fts WHERE lcm_raw_messages_fts MATCH ?1", + params![query], + "FTS match count", + ) + .await + .map_err(|error| error.to_string()) +} + +#[asupersync::test] +async fn metadata_and_noop_updates_do_not_write_search_segments() -> Result<(), String> { + let (_temp, connection) = fixture().await?; + connection + .execute_batch( + "CREATE TRIGGER reject_search_segment_insert + BEFORE INSERT ON lcm_raw_messages_fts_data + BEGIN SELECT RAISE(ABORT, 'unexpected search segment write'); END; + CREATE TRIGGER reject_search_segment_update + BEFORE UPDATE ON lcm_raw_messages_fts_data + BEGIN SELECT RAISE(ABORT, 'unexpected search segment write'); END; + CREATE TRIGGER reject_search_segment_delete + BEFORE DELETE ON lcm_raw_messages_fts_data + BEGIN SELECT RAISE(ABORT, 'unexpected search segment write'); END;", + ) + .await + .map_err(|error| error.to_string())?; + for statement in [ + "UPDATE lcm_raw_messages SET metadata_json = '{\"receipt\":1}'", + "UPDATE lcm_raw_messages SET ordinal = ordinal + 1, source_path = 'changed'", + "UPDATE lcm_raw_messages SET content = content, role = role, kind = kind, + model = model, tool_names = tool_names", + "UPDATE lcm_raw_messages SET placeholder_text = 'unused while content is inline'", + ] { + connection + .execute_batch(statement) + .await + .map_err(|error| format!("unrelated update wrote search segments: {error}"))?; + } + assert_eq!(matches(&connection, "originalword").await?, 1); + assert!( + connection + .execute_batch("UPDATE lcm_raw_messages SET content = 'replacementword'") + .await + .is_err(), + "the write rejection must detect a real indexed change" + ); + assert_eq!(matches(&connection, "originalword").await?, 1); + + // Model a current store whose derived trigger still rewrites every update. + connection + .execute_batch( + "DROP TRIGGER lcm_raw_messages_fts_update; + CREATE TRIGGER lcm_raw_messages_fts_update + AFTER UPDATE ON lcm_raw_messages BEGIN + INSERT INTO lcm_raw_messages_fts( + lcm_raw_messages_fts, rowid, index_text, role, kind, model, tool_names + ) VALUES ('delete', OLD.store_id, OLD.index_text, OLD.role, + OLD.kind, OLD.model, OLD.tool_names); + INSERT INTO lcm_raw_messages_fts(rowid, index_text, role, kind, model, tool_names) + VALUES (NEW.store_id, NEW.index_text, NEW.role, NEW.kind, NEW.model, NEW.tool_names); + END;", + ) + .await + .map_err(|error| error.to_string())?; + assert!( + connection + .execute_batch("UPDATE lcm_raw_messages SET metadata_json = NULL") + .await + .is_err() + ); + ensure_lcm_schema(&connection) + .await + .map_err(|error| error.to_string())?; + connection + .execute_batch("UPDATE lcm_raw_messages SET metadata_json = NULL") + .await + .map_err(|error| error.to_string())?; + assert_eq!(matches(&connection, "originalword").await?, 1); + let schema_version = + util::fetch_i64(&*connection, "PRAGMA schema_version", (), "schema version") + .await + .map_err(|error| error.to_string())?; + ensure_lcm_schema(&connection) + .await + .map_err(|error| error.to_string())?; + assert_eq!( + util::fetch_i64(&*connection, "PRAGMA schema_version", (), "schema version") + .await + .map_err(|error| error.to_string())?, + schema_version, + "admitting the current trigger must not write schema again" + ); + Ok(()) +} + +#[asupersync::test] +async fn changed_generated_text_fields_nulls_and_rowids_still_reindex() -> Result<(), String> { + let (_temp, connection) = fixture().await?; + for (statement, absent, present) in [ + ( + "UPDATE lcm_raw_messages SET content = 'replacementword'", + "originalword", + "replacementword", + ), + ( + "UPDATE lcm_raw_messages SET content = NULL, placeholder_text = 'placeholderword'", + "replacementword", + "placeholderword", + ), + ( + "UPDATE lcm_raw_messages SET placeholder_text = 'changedplaceholder'", + "placeholderword", + "changedplaceholder", + ), + ( + "UPDATE lcm_raw_messages SET role = 'tool', kind = 'command', + model = 'testmodel', tool_names = 'testtool'", + "role:assistant", + "role:tool AND kind:command AND model:testmodel AND tool_names:testtool", + ), + ( + "UPDATE lcm_raw_messages SET kind = NULL, model = NULL, tool_names = NULL", + "kind:command OR model:testmodel OR tool_names:testtool", + "role:tool", + ), + ] { + connection + .execute_batch(statement) + .await + .map_err(|error| error.to_string())?; + assert_eq!(matches(&connection, absent).await?, 0, "{statement}"); + assert_eq!(matches(&connection, present).await?, 1, "{statement}"); + } + connection + .execute_batch("UPDATE lcm_raw_messages SET store_id = 42") + .await + .map_err(|error| error.to_string())?; + let mut rows = connection + .query( + "SELECT rowid FROM lcm_raw_messages_fts + WHERE lcm_raw_messages_fts MATCH 'changedplaceholder'", + (), + ) + .await + .map_err(|error| error.to_string())?; + assert_eq!( + rows.next().await.unwrap().unwrap().get::(0).unwrap(), + 42 + ); + assert!(rows.next().await.unwrap().is_none()); + drop(rows); + connection + .execute_batch("DELETE FROM lcm_raw_messages") + .await + .map_err(|error| error.to_string())?; + assert_eq!(matches(&connection, "changedplaceholder").await?, 0); + Ok(()) +} diff --git a/crates/tracedecay-lcm/src/summary_convergence.rs b/crates/tracedecay-lcm/src/summary_convergence.rs index bdffe3b99c..acf0cd1e23 100644 --- a/crates/tracedecay-lcm/src/summary_convergence.rs +++ b/crates/tracedecay-lcm/src/summary_convergence.rs @@ -110,6 +110,11 @@ CREATE TRIGGER IF NOT EXISTS lcm_summary_convergence_raw_insert raw_revision_generation = lcm_summary_convergence_queue.raw_revision_generation + 1; END; +"#; + +/// Canonical guard restored by transaction-scoped storage maintenance after it +/// proves that effective content and its sanitizer receipt remain unchanged. +pub(crate) const RAW_UPDATE_TRIGGER_SQL: &str = r#" CREATE TRIGGER IF NOT EXISTS lcm_summary_convergence_raw_unprotected_update AFTER UPDATE OF provider, session_id, role, ordinal, timestamp, content_hash, storage_kind, payload_ref, metadata_json @@ -194,6 +199,53 @@ CREATE TRIGGER IF NOT EXISTS lcm_summary_convergence_raw_unprotected_update END; "#; +pub(crate) async fn validate_raw_update_guard( + conn: &(impl QueryExecutor + ?Sized), +) -> Result<(), LcmError> { + let mut rows = conn + .query( + "SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?1", + params!["lcm_summary_convergence_raw_unprotected_update"], + ) + .await?; + let actual = rows + .next() + .await? + .ok_or_else(|| LcmError::Db("summary convergence guard is missing".to_owned()))? + .get::(0)?; + let expected = RAW_UPDATE_TRIGGER_SQL.trim().replacen( + "CREATE TRIGGER IF NOT EXISTS ", + "CREATE TRIGGER ", + 1, + ); + if actual.trim().trim_end_matches(';') != expected.trim_end_matches(';') { + return Err(LcmError::Db( + "summary convergence guard does not match its contract".to_owned(), + )); + } + Ok(()) +} + +/// Only lossless storage maintenance may call this inside its authorized writer +/// transaction. It must verify the unchanged body/receipt and restore this +/// exact guard before committing. Rollback restores it on any failure. Body +/// ownership, privacy, FTS and payload-reference triggers remain installed. +pub(crate) async fn suspend_raw_update_guard( + conn: &(impl Executor + ?Sized), +) -> Result<(), LcmError> { + validate_raw_update_guard(conn).await?; + conn.execute_batch("DROP TRIGGER lcm_summary_convergence_raw_unprotected_update") + .await?; + Ok(()) +} + +pub(crate) async fn restore_raw_update_guard( + conn: &(impl Executor + ?Sized), +) -> Result<(), LcmError> { + conn.execute_batch(RAW_UPDATE_TRIGGER_SQL).await?; + validate_raw_update_guard(conn).await +} + pub const NEXT_CANDIDATE_SQL: &str = "SELECT provider, session_id, newest_raw_store_id, protection_frontier_store_id, attempted_raw_store_id, failure_count, raw_revision_generation, stale_from_store_id @@ -278,6 +330,7 @@ impl LcmSummaryConvergenceQueueState { pub async fn ensure_schema(conn: &(impl Executor + ?Sized)) -> Result<(), LcmError> { conn.execute_batch(SUMMARY_CONVERGENCE_TABLE_SQL).await?; conn.execute_batch(SUMMARY_CONVERGENCE_WORK_SQL).await?; + conn.execute_batch(RAW_UPDATE_TRIGGER_SQL).await?; Ok(()) } diff --git a/crates/tracedecay-maintenance/src/generation.rs b/crates/tracedecay-maintenance/src/generation.rs index b76a1c6331..70cefa4823 100644 --- a/crates/tracedecay-maintenance/src/generation.rs +++ b/crates/tracedecay-maintenance/src/generation.rs @@ -55,11 +55,11 @@ pub async fn run_project_generation_maintenance( let mut outcome = match code_generation { CodeGenerationRetentionOutcomeV1::Complete => MaintenanceTickOutcome::Complete, CodeGenerationRetentionOutcomeV1::MoreWork => { - MaintenanceTickOutcome::Continue(MaintenanceContinuation::CodeGenerationRetention) + MaintenanceTickOutcome::Continue(MaintenanceContinuation::StoreRetention) } CodeGenerationRetentionOutcomeV1::Failed => MaintenanceTickOutcome::Retry, }; - if continuation == Some(MaintenanceContinuation::CodeGenerationRetention) { + if continuation == Some(MaintenanceContinuation::StoreRetention) { return finalize_generation_outcome(outcome, cancellation); } if !cancellation.is_cancelled() { @@ -124,7 +124,7 @@ pub async fn run_registered_project_generation_maintenance( { CodeGenerationRetentionOutcomeV1::Complete => MaintenanceTickOutcome::Complete, CodeGenerationRetentionOutcomeV1::MoreWork => { - MaintenanceTickOutcome::Continue(MaintenanceContinuation::CodeGenerationRetention) + MaintenanceTickOutcome::Continue(MaintenanceContinuation::StoreRetention) } CodeGenerationRetentionOutcomeV1::Failed => MaintenanceTickOutcome::Retry, }; diff --git a/crates/tracedecay-maintenance/src/loop_run.rs b/crates/tracedecay-maintenance/src/loop_run.rs index 947f11d3d4..2fec3c051f 100644 --- a/crates/tracedecay-maintenance/src/loop_run.rs +++ b/crates/tracedecay-maintenance/src/loop_run.rs @@ -256,7 +256,7 @@ mod tests { drive_loop( MaintenanceCadence::with_retry_delay(DAILY, RETRY), vec![MaintenanceTickOutcome::Continue( - MaintenanceContinuation::CodeGenerationRetention, + MaintenanceContinuation::StoreRetention, )], |_wake, mut observed| async move { let first = next_tick(&mut observed).await; @@ -264,7 +264,7 @@ mod tests { assert_eq!(first.continuation, None); assert_eq!( resumed.continuation, - Some(MaintenanceContinuation::CodeGenerationRetention), + Some(MaintenanceContinuation::StoreRetention), "bounded progress must resume its code-generation phase instead of a full tick" ); assert!( diff --git a/crates/tracedecay-maintenance/src/retention/registered_store.rs b/crates/tracedecay-maintenance/src/retention/registered_store.rs index 434fc119d5..52236a295c 100644 --- a/crates/tracedecay-maintenance/src/retention/registered_store.rs +++ b/crates/tracedecay-maintenance/src/retention/registered_store.rs @@ -4,7 +4,11 @@ use tracedecay_domain::UtcMicros; use tracedecay_global_db::observation::retention::{ ObservationRetentionConfig, ObservationRetentionReport, RetentionMode, }; +use tracedecay_global_db::observation::{CanonicalBodyGcReport, MAX_CANONICAL_BODY_GC_ROWS}; use tracedecay_global_db::{ObservabilityRetentionReceiptV1, RegisteredGlobalDb}; +use tracedecay_lcm::retention::{ + MAX_REPLAY_METADATA_COMPACTION_ROWS, ReplayMetadataCompactionReport, +}; use tracedecay_lcm::{ LcmGcConfig, LcmGcReport, LcmRetentionConfig, LcmRetentionReport, RetentionMode as LcmRetentionMode, @@ -14,13 +18,18 @@ use tracedecay_lcm::{ pub enum RegisteredStoreRetentionErrorV1 { Observability { diagnostic: String }, PayloadGc { diagnostic: String }, + CanonicalBodyGc { diagnostic: String }, + ReplayMetadataCompaction { diagnostic: String }, } impl RegisteredStoreRetentionErrorV1 { #[must_use] pub fn diagnostic(&self) -> &str { match self { - Self::Observability { diagnostic } | Self::PayloadGc { diagnostic } => diagnostic, + Self::Observability { diagnostic } + | Self::PayloadGc { diagnostic } + | Self::CanonicalBodyGc { diagnostic } + | Self::ReplayMetadataCompaction { diagnostic } => diagnostic, } } } @@ -34,6 +43,11 @@ pub struct RegisteredStoreRetentionReportV1 { /// Applied external-payload GC. It runs after session retention so the /// payloads of rows that pass just dropped are reconciled by the same tick. pub payload_gc: Result, + /// Shared canonical bodies are collected only after both owner indexes + /// and the initial candidate seed are complete. + pub canonical_body_gc: Result, + /// Lossless repair of old provider replay strings duplicated by raw bodies. + pub replay_metadata: Result, } impl RegisteredStoreRetentionReportV1 { @@ -55,6 +69,8 @@ impl RegisteredStoreRetentionReportV1 { && observations_succeeded && self.observability.is_ok() && payload_gc_succeeded + && self.canonical_body_gc.is_ok() + && self.replay_metadata.is_ok() } } @@ -99,12 +115,63 @@ pub async fn run_registered_store_retention( .await .map_err(|diagnostic| RegisteredStoreRetentionErrorV1::Observability { diagnostic }); let payload_gc = run_payload_gc(database, now).await; + let canonical_body_gc = run_canonical_body_gc(database).await; + let replay_metadata = run_replay_metadata_compaction(database).await; RegisteredStoreRetentionReportV1 { session_lcm, observations, observability, payload_gc, + canonical_body_gc, + replay_metadata, + } +} + +/// Resume bounded, lossless storage work without repeating the raw-drop policy. +pub async fn run_session_storage_compaction( + database: &RegisteredGlobalDb, + config: &LcmRetentionConfig, + now: i64, +) -> Option> { + if !config.enabled || config.offload_after_days.is_none() { + return None; } + let config = LcmRetentionConfig { + drop_after_days: None, + ..*config + }; + Some( + database + .run_session_lcm_retention("all", None, &config, LcmRetentionMode::Apply, now) + .await, + ) +} + +/// Continuation ticks advance the bounded body lifecycle, leaving destructive +/// age policies and profile-wide maintenance on their normal cadence. +pub async fn run_canonical_body_gc( + database: &RegisteredGlobalDb, +) -> Result { + database + .run_canonical_body_gc(MAX_CANONICAL_BODY_GC_ROWS) + .await + .map_err(|error| RegisteredStoreRetentionErrorV1::CanonicalBodyGc { + diagnostic: error.to_string(), + }) +} + +/// Runs a bounded replay-metadata repair page without repeating age policies. +pub async fn run_replay_metadata_compaction( + database: &RegisteredGlobalDb, +) -> Result { + database + .compact_replay_metadata(MAX_REPLAY_METADATA_COMPACTION_ROWS) + .await + .map_err( + |error| RegisteredStoreRetentionErrorV1::ReplayMetadataCompaction { + diagnostic: error.to_string(), + }, + ) } async fn run_payload_gc( @@ -171,6 +238,41 @@ mod tests { ..ObservationRetentionConfig::default() }; + let storage = run_session_storage_compaction( + &harness.registered, + &LcmRetentionConfig { + drop_after_days: Some(0), + ..LcmRetentionConfig::default() + }, + 31 * 86_400, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(storage.dropped.window_days, None); + assert_eq!(storage.dropped.acted, 0); + assert!( + run_session_storage_compaction(&harness.registered, &session_lcm, 31 * 86_400) + .await + .is_none(), + "disabling session retention also stops its storage continuation" + ); + assert_eq!( + harness + .registered + .query_analytics_events(&AnalyticsEventQuery { + provider: Some("tracedecay-observability".to_owned()), + project_id: Some("scope:retention".to_owned()), + limit: 10, + ..AnalyticsEventQuery::default() + }) + .await + .unwrap() + .len(), + 1, + "storage continuation must not run profile-wide age policies" + ); + let report = run_registered_store_retention( &harness.registered, &session_lcm, diff --git a/crates/tracedecay-maintenance/src/store_maintenance/scope_reconciliation.rs b/crates/tracedecay-maintenance/src/store_maintenance/scope_reconciliation.rs index ab08133ac0..6ef5e64725 100644 --- a/crates/tracedecay-maintenance/src/store_maintenance/scope_reconciliation.rs +++ b/crates/tracedecay-maintenance/src/store_maintenance/scope_reconciliation.rs @@ -137,7 +137,7 @@ pub async fn run_code_index_scope_reconciliation( ), ], ); - MaintenanceTickOutcome::Continue(MaintenanceContinuation::CodeGenerationRetention) + MaintenanceTickOutcome::Continue(MaintenanceContinuation::StoreRetention) } Ok(Err((failure, error))) => scope_reconciliation_failed(failure, error), Err(_) => scope_reconciliation_failed("scope_task_panicked", None), diff --git a/crates/tracedecay-maintenance/src/telemetry.rs b/crates/tracedecay-maintenance/src/telemetry.rs index 581c46765f..62c45ce92f 100644 --- a/crates/tracedecay-maintenance/src/telemetry.rs +++ b/crates/tracedecay-maintenance/src/telemetry.rs @@ -965,7 +965,7 @@ mod retention_tick_log_tests { assert!( registry.admit_retention_tick_log(MaintenanceTickOutcome::Continue( - MaintenanceContinuation::CodeGenerationRetention, + MaintenanceContinuation::StoreRetention, )) ); assert!(registry.admit_retention_tick_log(MaintenanceTickOutcome::Retry)); diff --git a/crates/tracedecay-maintenance/src/tick.rs b/crates/tracedecay-maintenance/src/tick.rs index 5524c2d134..eabd536284 100644 --- a/crates/tracedecay-maintenance/src/tick.rs +++ b/crates/tracedecay-maintenance/src/tick.rs @@ -1,12 +1,14 @@ //! Maintenance tick policy: continuation, cadence, and store-window selection. +use std::collections::BTreeSet; use std::time::{Duration, Instant}; -/// Resume a bounded maintenance phase over the normal graph window. +/// Resume bounded store maintenance over the normal store window. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MaintenanceContinuation { - /// Resume bounded code-generation retention over the normal graph window. - CodeGenerationRetention, + /// Resume code-generation retention and canonical-body collection, without + /// repeating age policies or profile-wide maintenance on the short cadence. + StoreRetention, } /// Outcome of one maintenance tick or per-store unit. @@ -40,9 +42,7 @@ impl MaintenanceTickOutcome { pub fn label(self) -> &'static str { match self { Self::Complete => "complete", - Self::Continue(MaintenanceContinuation::CodeGenerationRetention) => { - "code_generation_progress" - } + Self::Continue(MaintenanceContinuation::StoreRetention) => "store_retention_progress", Self::Retry => "retry", } } @@ -60,6 +60,44 @@ impl MaintenanceTickOutcome { } } +/// Retains unfinished store work across round-robin windows. An idle window +/// must not cancel a continuation another mounted store still needs. +#[derive(Debug, Default)] +pub struct StoreMaintenanceContinuations { + pending: BTreeSet, +} + +impl StoreMaintenanceContinuations { + /// The coordinator's work keys are sorted and represent current mounts. + pub fn retain_stores(&mut self, sorted_keys: &[String]) { + self.pending + .retain(|key| sorted_keys.binary_search(key).is_ok()); + } + + pub fn record(&mut self, key: &str, outcome: MaintenanceTickOutcome) { + match outcome { + MaintenanceTickOutcome::Continue(_) => { + self.pending.insert(key.to_owned()); + } + MaintenanceTickOutcome::Complete => { + self.pending.remove(key); + } + MaintenanceTickOutcome::Retry => {} + } + } + + #[must_use] + pub fn combine(&self, current: MaintenanceTickOutcome) -> MaintenanceTickOutcome { + if self.pending.is_empty() { + current + } else { + current.combine(MaintenanceTickOutcome::Continue( + MaintenanceContinuation::StoreRetention, + )) + } + } +} + /// Interval and retry-delay policy for the maintenance loop. #[derive(Debug)] pub struct MaintenanceCadence { @@ -170,7 +208,7 @@ pub fn cursor_after_attempted_units( mod tests { use super::{ MaintenanceCadence, MaintenanceContinuation, MaintenanceTickOutcome, - cursor_after_attempted_units, select_store_window, + StoreMaintenanceContinuations, cursor_after_attempted_units, select_store_window, }; use std::time::Duration; @@ -198,8 +236,7 @@ mod tests { #[test] fn retry_outcome_takes_precedence_over_bounded_progress() { - let progress = - MaintenanceTickOutcome::Continue(MaintenanceContinuation::CodeGenerationRetention); + let progress = MaintenanceTickOutcome::Continue(MaintenanceContinuation::StoreRetention); assert_eq!( progress.combine(MaintenanceTickOutcome::Retry), @@ -214,7 +251,7 @@ mod tests { #[test] fn code_generation_continuation_dominates_a_complete_tick() { let code_generation = - MaintenanceTickOutcome::Continue(MaintenanceContinuation::CodeGenerationRetention); + MaintenanceTickOutcome::Continue(MaintenanceContinuation::StoreRetention); assert_eq!( code_generation.combine(MaintenanceTickOutcome::Complete), @@ -234,6 +271,32 @@ mod tests { (0..count).map(|index| format!("s:{index:03}")).collect() } + #[test] + fn idle_windows_do_not_forget_another_stores_pending_work() { + let progress = MaintenanceTickOutcome::Continue(MaintenanceContinuation::StoreRetention); + let mut pending = StoreMaintenanceContinuations::default(); + pending.retain_stores(&["a".to_owned(), "b".to_owned()]); + pending.record("a", progress); + pending.record("b", MaintenanceTickOutcome::Complete); + assert_eq!(pending.combine(MaintenanceTickOutcome::Complete), progress); + pending.record("a", MaintenanceTickOutcome::Retry); + assert_eq!( + pending.combine(MaintenanceTickOutcome::Retry), + MaintenanceTickOutcome::Retry + ); + pending.record("a", MaintenanceTickOutcome::Complete); + assert_eq!( + pending.combine(MaintenanceTickOutcome::Complete), + MaintenanceTickOutcome::Complete + ); + pending.record("a", progress); + pending.retain_stores(&["b".to_owned()]); + assert_eq!( + pending.combine(MaintenanceTickOutcome::Complete), + MaintenanceTickOutcome::Complete + ); + } + #[test] fn store_window_round_robin_reaches_every_store_and_never_starves() { // With more stores than the budget, feeding each tick's cursor into the diff --git a/crates/tracedecay-rusqlite-runtime/src/writer/worker/mod.rs b/crates/tracedecay-rusqlite-runtime/src/writer/worker/mod.rs index 92ba85b5c1..59a98dd175 100644 --- a/crates/tracedecay-rusqlite-runtime/src/writer/worker/mod.rs +++ b/crates/tracedecay-rusqlite-runtime/src/writer/worker/mod.rs @@ -1178,9 +1178,19 @@ fn run_incremental_vacuum( return; } }; - if let Err(error) = - transaction.pragma_update(None, "incremental_vacuum", command.max_pages.max(1)) - { + // Each result row releases one page. pragma_update only steps once, so + // drain this bounded pragma before deciding whether its transaction commits. + let reclaimed = transaction + .prepare(&format!( + "PRAGMA incremental_vacuum({})", + command.max_pages.max(1) + )) + .and_then(|mut statement| { + let mut rows = statement.query([])?; + while rows.next()?.is_some() {} + Ok(()) + }); + if let Err(error) = reclaimed { command.settle(Err(WriterActorError::IncrementalVacuumFailed( error.to_string(), ))); @@ -2025,12 +2035,71 @@ mod auxiliary_scheduling_tests { ); } - #[test] - fn incremental_vacuum_rolls_back_when_authority_is_revoked() { - let mut connection = rusqlite::Connection::open_in_memory().unwrap(); + fn vacuum_connection_with_free_pages() -> rusqlite::Connection { + let connection = rusqlite::Connection::open_in_memory().unwrap(); connection .pragma_update(None, "auto_vacuum", "INCREMENTAL") .unwrap(); + connection + .execute_batch( + "CREATE TABLE retained(value TEXT NOT NULL); + INSERT INTO retained VALUES ('keep the live evidence'); + CREATE TABLE released(value BLOB NOT NULL); + WITH RECURSIVE copies(n) AS ( + VALUES(1) UNION ALL SELECT n + 1 FROM copies WHERE n < 64 + ) INSERT INTO released SELECT zeroblob(16384) FROM copies; + DELETE FROM released;", + ) + .unwrap(); + assert!(vacuum_page_counts(&connection).1 > 32); + connection + } + + fn vacuum_page_counts(connection: &rusqlite::Connection) -> (i64, i64) { + connection + .query_row( + "SELECT page_count, freelist_count FROM pragma_page_count, pragma_freelist_count", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap() + } + + #[test] + fn incremental_vacuum_reclaims_the_requested_page_budget() { + let mut connection = vacuum_connection_with_free_pages(); + for max_pages in [0, 8, 3] { + let before = vacuum_page_counts(&connection); + let authority = Arc::new(RecordingAuthority { + stages: Arc::new(Mutex::new(Vec::new())), + deny_before_commit: false, + }); + let (reply, mut response) = oneshot::channel(); + + run_incremental_vacuum( + &mut connection, + IncrementalVacuumCommand::new(max_pages, authority, reply), + ); + + assert!(response.try_recv().unwrap().is_ok()); + let after = vacuum_page_counts(&connection); + assert_eq!(before.1 - after.1, i64::from(max_pages.max(1))); + assert!(after.0 < before.0, "reclaimed pages leave the database"); + assert!(after.1 > 0, "one call must not drain the entire freelist"); + assert_eq!( + connection + .query_row("SELECT value FROM retained", [], |row| row + .get::<_, String>(0)) + .unwrap(), + "keep the live evidence" + ); + } + } + + #[test] + fn incremental_vacuum_rolls_back_when_authority_is_revoked() { + let mut connection = vacuum_connection_with_free_pages(); + let before = vacuum_page_counts(&connection); let authority = Arc::new(RecordingAuthority { stages: Arc::new(Mutex::new(Vec::new())), deny_before_commit: true, @@ -2049,5 +2118,6 @@ mod auxiliary_scheduling_tests { }) )); assert!(connection.is_autocommit()); + assert_eq!(vacuum_page_counts(&connection), before); } } diff --git a/crates/tracedecay/src/daemon/maintenance.rs b/crates/tracedecay/src/daemon/maintenance.rs index 35fdc012d2..dec31a63b5 100644 --- a/crates/tracedecay/src/daemon/maintenance.rs +++ b/crates/tracedecay/src/daemon/maintenance.rs @@ -20,8 +20,8 @@ use tracedecay_maintenance::telemetry::{ StoreFileIdentityStateV1, StoreTelemetrySamplingOutcome, StoreTelemetrySamplingRegistry, }; use tracedecay_maintenance::tick::{ - MaintenanceContinuation, MaintenanceTickOutcome, cursor_after_attempted_units, - select_store_window, + MaintenanceContinuation, MaintenanceTickOutcome, StoreMaintenanceContinuations, + cursor_after_attempted_units, select_store_window, }; use tracedecay_runtime_core::storage::{RESPONSE_HANDLES_DIRECTORY, profile_sharded_data_root}; @@ -207,7 +207,7 @@ async fn join_cancelled_maintenance_task( async fn run_registered_store_retention( database: &tracedecay_global_db::RegisteredGlobalDb, config: &tracedecay_configuration::RetentionConfig, -) -> bool { +) -> MaintenanceTickOutcome { let now = match now_secs_i64() { Ok(now) => now, Err(failure) => { @@ -218,7 +218,7 @@ async fn run_registered_store_retention( ("failure", failure.to_owned()), ], ); - return false; + return MaintenanceTickOutcome::Retry; } }; let report = @@ -229,29 +229,7 @@ async fn run_registered_store_retention( now, ) .await; - match &report.session_lcm { - Some(Ok(session_lcm)) => { - let reclaimed = session_lcm.bytes_reclaimed(); - if reclaimed > 0 || !session_lcm.errors.is_empty() { - log_daemon_event( - "retention_session_lcm", - &[ - ("store", "mounted_sessions".to_owned()), - ("bytes_reclaimed", reclaimed.to_string()), - ("errors", session_lcm.errors.len().to_string()), - ], - ); - } - } - Some(Err(_)) => log_daemon_event( - "retention_degraded", - &[ - ("pass", "session_lcm".to_owned()), - ("failure", "retention_pass_failed".to_owned()), - ], - ), - None => {} - } + let session_storage_outcome = record_session_lcm_outcome(&report.session_lcm); match &report.observations { Some(Ok(observations)) => { let reclaimed = observations.bytes_reclaimed(); @@ -289,15 +267,198 @@ async fn run_registered_store_retention( Ok(_) => {} } log_payload_gc_outcome(&report.payload_gc); - let mut succeeded = report.succeeded(); + let canonical_body_outcome = record_canonical_body_gc_outcome(&report.canonical_body_gc); + let replay_metadata_outcome = record_replay_metadata_outcome(&report.replay_metadata); + let outcome = if report.succeeded() { + session_storage_outcome + .combine(canonical_body_outcome) + .combine(replay_metadata_outcome) + } else { + MaintenanceTickOutcome::Retry + }; if let Some(compaction) = &config.compaction { let outcome = tracedecay_maintenance::retention::live_compaction::compact_registered_store( database, compaction, ) .await; - succeeded &= record_live_compaction_outcome("mounted_sessions", outcome); + if !record_live_compaction_outcome("mounted_sessions", outcome) { + return MaintenanceTickOutcome::Retry; + } + } + outcome +} + +fn record_session_lcm_outcome( + outcome: &Option>, +) -> MaintenanceTickOutcome { + match outcome { + Some(Ok(session_lcm)) => { + let reclaimed = session_lcm.bytes_reclaimed(); + if reclaimed > 0 + || session_lcm.inlined.acted > 0 + || session_lcm.canonicalized_rows > 0 + || !session_lcm.errors.is_empty() + { + log_daemon_event( + "retention_session_lcm", + &[ + ("store", "mounted_sessions".to_owned()), + ("bytes_reclaimed", reclaimed.to_string()), + ("inlined_rows", session_lcm.inlined.acted.to_string()), + ( + "canonicalized_rows", + session_lcm.canonicalized_rows.to_string(), + ), + ("errors", session_lcm.errors.len().to_string()), + ], + ); + } + } + Some(Err(_)) => log_daemon_event( + "retention_degraded", + &[ + ("pass", "session_lcm".to_owned()), + ("failure", "retention_pass_failed".to_owned()), + ], + ), + None => {} + } + match outcome { + Some(Ok(report)) if report.errors.is_empty() => { + if report.storage_has_more { + MaintenanceTickOutcome::Continue(MaintenanceContinuation::StoreRetention) + } else { + MaintenanceTickOutcome::Complete + } + } + None => MaintenanceTickOutcome::Complete, + _ => MaintenanceTickOutcome::Retry, + } +} + +async fn run_registered_storage_continuation( + database: &tracedecay_global_db::RegisteredGlobalDb, + config: &tracedecay_configuration::RetentionConfig, +) -> MaintenanceTickOutcome { + let now = match now_secs_i64() { + Ok(now) => now, + Err(failure) => { + log_daemon_event( + "retention_degraded", + &[ + ("pass", "session_storage".to_owned()), + ("failure", failure.to_owned()), + ], + ); + return MaintenanceTickOutcome::Retry; + } + }; + let session_lcm = + tracedecay_maintenance::retention::registered_store::run_session_storage_compaction( + database, + &config.session_lcm, + now, + ) + .await; + let canonical_body_gc = + tracedecay_maintenance::retention::registered_store::run_canonical_body_gc(database).await; + let replay_metadata = + tracedecay_maintenance::retention::registered_store::run_replay_metadata_compaction( + database, + ) + .await; + record_session_lcm_outcome(&session_lcm) + .combine(record_canonical_body_gc_outcome(&canonical_body_gc)) + .combine(record_replay_metadata_outcome(&replay_metadata)) +} + +fn record_canonical_body_gc_outcome( + outcome: &Result< + tracedecay_global_db::observation::CanonicalBodyGcReport, + tracedecay_maintenance::retention::registered_store::RegisteredStoreRetentionErrorV1, + >, +) -> MaintenanceTickOutcome { + match outcome { + Ok(report) => { + if report.roots_scanned > 0 || report.bodies_scanned > 0 { + log_daemon_event( + "retention_canonical_bodies", + &[ + ("roots_scanned", report.roots_scanned.to_string()), + ("bodies_scanned", report.bodies_scanned.to_string()), + ("bodies_deleted", report.bodies_deleted.to_string()), + ( + "stored_bytes_deleted", + report.stored_bytes_deleted.to_string(), + ), + ("bootstrap_complete", report.bootstrap_complete.to_string()), + ("has_more", report.has_more.to_string()), + ], + ); + } + if report.has_more { + MaintenanceTickOutcome::Continue(MaintenanceContinuation::StoreRetention) + } else { + MaintenanceTickOutcome::Complete + } + } + Err(error) => { + log_daemon_event( + "retention_degraded", + &[ + ("pass", "canonical_body_gc".to_owned()), + ("failure", error.diagnostic().to_owned()), + ], + ); + MaintenanceTickOutcome::Retry + } + } +} + +fn record_replay_metadata_outcome( + outcome: &Result< + tracedecay_lcm::retention::ReplayMetadataCompactionReport, + tracedecay_maintenance::retention::registered_store::RegisteredStoreRetentionErrorV1, + >, +) -> MaintenanceTickOutcome { + match outcome { + Ok(report) => { + if report.rows_scanned > 0 { + log_daemon_event( + "retention_replay_metadata", + &[ + ("rows_scanned", report.rows_scanned.to_string()), + ("rows_compacted", report.rows_compacted.to_string()), + ("rows_skipped", report.rows_skipped.to_string()), + ( + "rows_skipped_oversized", + report.rows_skipped_oversized.to_string(), + ), + ( + "metadata_bytes_removed", + report.metadata_bytes_removed.to_string(), + ), + ("has_more", report.has_more.to_string()), + ], + ); + } + if report.has_more { + MaintenanceTickOutcome::Continue(MaintenanceContinuation::StoreRetention) + } else { + MaintenanceTickOutcome::Complete + } + } + Err(error) => { + log_daemon_event( + "retention_degraded", + &[ + ("pass", "replay_metadata_compaction".to_owned()), + ("failure", error.diagnostic().to_owned()), + ], + ); + MaintenanceTickOutcome::Retry + } } - succeeded } fn log_payload_gc_outcome( @@ -456,6 +617,7 @@ pub(super) struct MaintenanceCoordinator { /// last store processed. The next tick resumes immediately after it so no /// store is starved when the mounted set exceeds `MAINTENANCE_STORE_PAGE_LIMIT`. store_cursor: Arc>>, + store_continuations: Arc>, /// Instant of the last branch-store GC pass that succeeded for every /// mounted project. `None` keeps the daily cadence retry-eligible. last_branch_gc: Arc>>, @@ -476,6 +638,7 @@ impl Default for MaintenanceCoordinator { task: Arc::new(Mutex::new(None)), metrics: Arc::new(Mutex::new(MaintenanceMetricsV1::default())), store_cursor: Arc::new(Mutex::new(None)), + store_continuations: Arc::new(Mutex::new(StoreMaintenanceContinuations::default())), last_branch_gc: Arc::new(Mutex::new(None)), resident_memory_sampler: Arc::new(Mutex::new(None)), resident_memory_log: Arc::new(std::sync::Mutex::new( @@ -801,16 +964,12 @@ impl MaintenanceCoordinator { administration .store_telemetry_sampling() .begin_retention_tick_log_window(); - let session_databases = if continuation.is_none() { - administration - .mounted_registered_session_databases() - .await - .iter() - .map(|database| database.db_path().to_path_buf()) - .collect() - } else { - Vec::new() - }; + let session_databases = administration + .mounted_registered_session_databases() + .await + .iter() + .map(|database| database.db_path().to_path_buf()) + .collect::>(); let project_graphs = administration .mounted_project_graphs() .await @@ -900,6 +1059,7 @@ impl MaintenanceCoordinator { } work.sort_by(|left, right| left.0.cmp(&right.0)); let keys = work.iter().map(|(key, _)| key.clone()).collect::>(); + self.store_continuations.lock().await.retain_stores(&keys); let after = self.store_cursor.lock().await.clone(); let (window, _) = select_store_window(&keys, after.as_deref(), MAINTENANCE_STORE_PAGE_LIMIT); @@ -940,8 +1100,8 @@ impl MaintenanceCoordinator { // Bounded, round-robin slice of mounted stores. Writer admission is // per unit so one busy store defers only itself, and the cursor // advances past attempted units even on cancellation. A continuation - // omits session stores, but remains phase-scoped over the same bounded - // graph window rather than pinning one project. + // advances bounded lossless storage work and code-generation retention + // over the same bounded store window rather than pinning one project. let mut attempted = 0usize; let mut deferred = 0u64; let mut outcome = MaintenanceTickOutcome::Complete; @@ -966,10 +1126,10 @@ impl MaintenanceCoordinator { else { return MaintenanceTickOutcome::Complete; }; - if run_registered_store_retention(&database, retention).await { - MaintenanceTickOutcome::Complete + if continuation.is_some() { + run_registered_storage_continuation(&database, retention).await } else { - MaintenanceTickOutcome::Retry + run_registered_store_retention(&database, retention).await } } MaintenanceStoreWork::Graph(_) => { @@ -1007,7 +1167,13 @@ impl MaintenanceCoordinator { .await; attempted = attempted.saturating_add(1); match admitted { - Some(unit_outcome) => outcome = outcome.combine(unit_outcome), + Some(unit_outcome) => { + self.store_continuations + .lock() + .await + .record(&keys[index], unit_outcome); + outcome = outcome.combine(unit_outcome); + } None => { deferred = deferred.saturating_add(1); outcome = MaintenanceTickOutcome::Retry; @@ -1020,6 +1186,7 @@ impl MaintenanceCoordinator { } *self.store_cursor.lock().await = cursor_after_attempted_units(&keys, &window, attempted, after.as_deref()); + outcome = self.store_continuations.lock().await.combine(outcome); if !registry_listed { outcome = MaintenanceTickOutcome::Retry; } @@ -1646,7 +1813,7 @@ mod tests { &schedulers, &retention, branch_gc, - Some(MaintenanceContinuation::CodeGenerationRetention), + Some(MaintenanceContinuation::StoreRetention), ) .await; assert_eq!(continuation, MaintenanceTickOutcome::Complete); diff --git a/crates/tracedecay/src/daemon/production_harness/generation_retention_test.rs b/crates/tracedecay/src/daemon/production_harness/generation_retention_test.rs index 723ab51d0d..7affbade6f 100644 --- a/crates/tracedecay/src/daemon/production_harness/generation_retention_test.rs +++ b/crates/tracedecay/src/daemon/production_harness/generation_retention_test.rs @@ -262,7 +262,7 @@ async fn mounted_code_generation_retention_continues_capped_segment_reclamation( }; assert_eq!( outcome, - MaintenanceTickOutcome::Continue(MaintenanceContinuation::CodeGenerationRetention), + MaintenanceTickOutcome::Continue(MaintenanceContinuation::StoreRetention), "a capped segment-only pass must keep the production maintenance cadence short" ); assert_eq!( diff --git a/crates/tracedecay/tests/session_suite/lcm_compression/replay.rs b/crates/tracedecay/tests/session_suite/lcm_compression/replay.rs index 72d17d0eff..429fb6c79b 100644 --- a/crates/tracedecay/tests/session_suite/lcm_compression/replay.rs +++ b/crates/tracedecay/tests/session_suite/lcm_compression/replay.rs @@ -214,6 +214,75 @@ async fn threshold_pressure_summarizes_short_huge_active_context() { ); } +#[tokio::test] +async fn active_tool_result_body_is_stored_once_with_lossless_replay() { + let tmp = TempDir::new().unwrap(); + let db = open_lcm_db(&tmp).await; + insert_session(&db, "cursor", "session-tool-body").await; + + let body = format!( + "tool-output-start\n{}tool-output-tail", + "test result: module completed successfully\n".repeat(2_048) + ); + assert!(!tracedecay_lcm::security::should_externalize( + "tool", + Some("tool_result"), + &body, + )); + let mut messages = active_multi_tool_transaction(); + messages[1]["content"] = json!(body); + let initial = + ingest_active_messages(&db, "cursor", "session-tool-body", messages.clone()).await; + let raw = db + .lcm_load_raw_message("cursor", "tool-a") + .await + .expect("tool result should be stored"); + assert_eq!(raw.storage_kind, LcmStorageKind::Inline); + assert_eq!(raw.content, body); + let stored_metadata = raw + .metadata_json + .as_deref() + .expect("active replay metadata"); + let metadata: Value = serde_json::from_str(stored_metadata).unwrap(); + assert!( + metadata["active_replay"].get("content").is_none(), + "the canonical body must not also be stored in SQL metadata" + ); + assert!(stored_metadata.len() < tracedecay_store::INLINE_BODY_BYTES); + assert_eq!(metadata["active_replay"]["tool_call_id"], "call-a"); + assert_eq!(initial.replay_messages[1]["content"], body); + assert_eq!(initial.replay_messages[1]["tool_call_id"], "call-a"); + + let preflight = db + .lcm_preflight(preflight_request( + "cursor", + "session-tool-body", + Vec::new(), + Some(100), + )) + .await + .unwrap(); + assert_eq!(preflight.replay_messages, initial.replay_messages); + + for input in [messages, initial.replay_messages.clone()] { + let replay = ingest_active_messages(&db, "cursor", "session-tool-body", input).await; + assert_eq!(replay.replay_messages, initial.replay_messages); + assert_eq!(replay.replay_token_estimate, initial.replay_token_estimate); + assert_eq!( + db.lcm_load_raw_message("cursor", "tool-a").await.unwrap(), + raw, + "original input and replay must preserve body, identity, and metadata" + ); + assert_eq!( + db.lcm_status("cursor", Some("session-tool-body")) + .await + .unwrap() + .raw_message_count, + 3 + ); + } +} + #[tokio::test] async fn active_structured_content_survives_compress_ingest_and_preflight_replay() { let tmp = TempDir::new().unwrap(); diff --git a/scripts/bazel/patches/grafeo-core-read-allocation.patch b/scripts/bazel/patches/grafeo-core-read-allocation.patch deleted file mode 100644 index 1effd1e0ff..0000000000 --- a/scripts/bazel/patches/grafeo-core-read-allocation.patch +++ /dev/null @@ -1,473 +0,0 @@ -diff --git a/src/graph/compact/column.rs b/src/graph/compact/column.rs -index 861a9595..098e900f 100644 ---- a/src/graph/compact/column.rs -+++ b/src/graph/compact/column.rs -@@ -291,6 +291,28 @@ pub enum ColumnCodec { - } - - impl ColumnCodec { -+ /// Reads borrowed codec storage. It never decodes a Value. -+ pub fn allocation_bytes(&self, index: usize) -> usize { -+ use std::mem::size_of; -+ use grafeo_common::memory::heap::arc_slice_bytes; -+ match self { -+ Self::Dict(dict) => dict.get(index).map_or(0, |entry| { -+ // A malformed marker falls back to a literal string. Include -+ // the partial hex vector as well as that fallback allocation. -+ entry.len().saturating_mul(2).saturating_add(2 * size_of::()) -+ }), -+ Self::Int8Vector { dimensions, .. } => { -+ usize::from(*dimensions).saturating_mul(size_of::()) -+ .saturating_add(arc_slice_bytes::(usize::from(*dimensions))) -+ } -+ Self::Float32Vector { dimensions, .. } => { -+ usize::from(*dimensions).saturating_mul(size_of::()) -+ .saturating_add(arc_slice_bytes::(usize::from(*dimensions))) -+ } -+ _ => 0, -+ } -+ } -+ - // ── Phase 3a: Bytes-backed constructors ───────────────────────── - - /// Constructs a [`RawI64`](Self::RawI64) column from a `Vec` -diff --git a/src/graph/compact/graph_store_impl.rs b/src/graph/compact/graph_store_impl.rs -index 341460db..75dfac1b 100644 ---- a/src/graph/compact/graph_store_impl.rs -+++ b/src/graph/compact/graph_store_impl.rs -@@ -19,6 +19,40 @@ use crate::graph::traits::{GraphStore, GraphStoreSearch}; - use crate::statistics::Statistics; - - impl GraphStore for CompactStore { -+ fn node_allocation_bytes(&self, id: NodeId) -> Option { -+ let Some((table, offset)) = self.resolve_node(id) else { return Some(0); }; -+ let table = self.resolve_node_table(table)?; -+ Some(table.allocation_bytes(usize::try_from(offset).ok()?).saturating_add(table.label().len())) -+ } -+ -+ fn edge_allocation_bytes(&self, id: EdgeId) -> Option { -+ let Some((table, offset)) = self.resolve_edge(id) else { return Some(0); }; -+ Some(self.resolve_rel_table(table)?.allocation_bytes(usize::try_from(offset).ok()?)) -+ } -+ -+ fn node_property_allocation_bytes(&self, id: NodeId, key: &PropertyKey) -> Option { -+ let Some((table, offset)) = self.resolve_node(id) else { return Some(0); }; -+ Some(self.resolve_node_table(table)?.property_allocation_bytes(usize::try_from(offset).ok()?, key)) -+ } -+ -+ fn label_allocation_bytes(&self) -> Option { -+ Some(self.table_id_to_label.iter().fold( -+ self.table_id_to_label.len().saturating_mul(std::mem::size_of::()), -+ |bytes, label| bytes.saturating_add(label.len()), -+ )) -+ } -+ -+ fn property_lookup_allocation_bytes(&self, key: &PropertyKey, value: &Value) -> Option { -+ let index = self.property_value_index(key)?; -+ let mut count = 0_usize; -+ for (table, order) in index.iter() { -+ let col = self.resolve_node_table(*table)?.column(key)?; -+ count = count.saturating_add(super::value_order::equality_count(col, order, value)?); -+ } -+ let payload = match value { Value::String(value) => value.len(), Value::Bytes(value) => value.len() * 2, _ => 0 }; -+ Some(count.saturating_mul(8 * std::mem::size_of::()).saturating_add(payload.saturating_mul(3)).saturating_add(128)) -+ } -+ - fn get_node(&self, id: NodeId) -> Option { - let (table_id, offset) = self.resolve_node(id)?; - let nt = self.resolve_node_table(table_id)?; -@@ -29,10 +63,7 @@ impl GraphStore for CompactStore { - - let mut node = Node::new(id); - node.add_label(nt.label()); -- let props = nt.get_all_properties(row); -- for (k, v) in props { -- node.set_property(k, v); -- } -+ nt.for_each_property(row, |key, value| node.set_property(key.clone(), value)); - Some(node) - } - -@@ -48,10 +79,7 @@ impl GraphStore for CompactStore { - let edge_type = rt.edge_type().clone(); - - let mut edge = Edge::new(id, src, dst, edge_type); -- let props = rt.get_all_edge_properties(pos as usize); -- for (k, v) in props { -- edge.set_property(k, v); -- } -+ rt.for_each_property(pos as usize, |key, value| edge.set_property(key.clone(), value)); - Some(edge) - } - -diff --git a/src/graph/compact/layered.rs b/src/graph/compact/layered.rs -index 6eb25cb7..ed189b50 100644 ---- a/src/graph/compact/layered.rs -+++ b/src/graph/compact/layered.rs -@@ -437,6 +437,31 @@ impl LayeredStore { - // ── GraphStore implementation ────────────────────────────────────── - - impl GraphStore for LayeredStore { -+ fn node_allocation_bytes(&self, id: NodeId) -> Option { -+ Some(self.base.load().node_allocation_bytes(id)? -+ .saturating_add(self.overlay.load().node_allocation_bytes(id)?)) -+ } -+ -+ fn edge_allocation_bytes(&self, id: EdgeId) -> Option { -+ Some(self.base.load().edge_allocation_bytes(id)? -+ .saturating_add(self.overlay.load().edge_allocation_bytes(id)?)) -+ } -+ -+ fn node_property_allocation_bytes(&self, id: NodeId, key: &PropertyKey) -> Option { -+ Some(self.base.load().node_property_allocation_bytes(id, key)? -+ .saturating_add(self.overlay.load().node_property_allocation_bytes(id, key)?)) -+ } -+ -+ fn label_allocation_bytes(&self) -> Option { -+ Some(self.base.load().label_allocation_bytes()? -+ .saturating_add(self.overlay.load().label_allocation_bytes()?).saturating_mul(4)) -+ } -+ -+ fn property_lookup_allocation_bytes(&self, key: &PropertyKey, value: &Value) -> Option { -+ Some(self.base.load().property_lookup_allocation_bytes(key, value)? -+ .saturating_add(self.overlay.load().property_lookup_allocation_bytes(key, value)?).saturating_mul(2)) -+ } -+ - fn get_node(&self, id: NodeId) -> Option { - if self.is_node_deleted_from_base(id) { - return None; -diff --git a/src/graph/compact/node_table.rs b/src/graph/compact/node_table.rs -index 95ee80e5..103db9d0 100644 ---- a/src/graph/compact/node_table.rs -+++ b/src/graph/compact/node_table.rs -@@ -135,6 +135,34 @@ impl NodeTable { - /// A `true` row is one the builder padded because the node never had the - /// property; its column body still decodes to a value there, which no - /// read may surface. -+ pub fn allocation_bytes(&self, offset: usize) -> usize { -+ use std::mem::size_of; -+ if offset >= self.len { return 0; } -+ self.columns.iter().fold( -+ self.columns.len().saturating_mul(4 * size_of::<(PropertyKey, Value)>()), -+ |bytes, (key, col)| { -+ if self.is_null(offset, key) { bytes } else { -+ bytes.saturating_add(col.allocation_bytes(offset)).saturating_add(key.as_str().len()) -+ } -+ }, -+ ) -+ } -+ -+ pub fn for_each_property(&self, offset: usize, mut visit: impl FnMut(&PropertyKey, Value)) { -+ for (key, col) in &self.columns { -+ if !self.is_null(offset, key) { -+ if let Some(value) = col.get(offset) { -+ visit(key, value); -+ } -+ } -+ } -+ } -+ -+ pub fn property_allocation_bytes(&self, offset: usize, key: &PropertyKey) -> usize { -+ if self.is_null(offset, key) { return 0; } -+ self.columns.get(key).map_or(0, |col| col.allocation_bytes(offset)) -+ } -+ - #[must_use] - pub fn is_null(&self, offset: usize, key: &PropertyKey) -> bool { - self.null_masks -diff --git a/src/graph/compact/rel_table.rs b/src/graph/compact/rel_table.rs -index f5e5df76..55eb73e3 100644 ---- a/src/graph/compact/rel_table.rs -+++ b/src/graph/compact/rel_table.rs -@@ -182,6 +182,28 @@ impl RelTable { - - /// Returns the property value for a specific edge (by CSR position) and - /// key; `None` when the edge holds no value for it. -+ pub fn allocation_bytes(&self, offset: usize) -> usize { -+ use std::mem::size_of; -+ self.properties.iter().fold( -+ self.properties.len().saturating_mul(4 * size_of::<(PropertyKey, Value)>()), -+ |bytes, (key, col)| { -+ if self.is_null(offset, key) { bytes } else { -+ bytes.saturating_add(col.allocation_bytes(offset)).saturating_add(key.as_str().len()) -+ } -+ }, -+ ) -+ } -+ -+ pub fn for_each_property(&self, offset: usize, mut visit: impl FnMut(&PropertyKey, Value)) { -+ for (key, col) in &self.properties { -+ if !self.is_null(offset, key) { -+ if let Some(value) = col.get(offset) { -+ visit(key, value); -+ } -+ } -+ } -+ } -+ - #[must_use] - pub fn get_edge_property(&self, csr_position: usize, key: &PropertyKey) -> Option { - if self.is_null(csr_position, key) { -diff --git a/src/graph/compact/value_order.rs b/src/graph/compact/value_order.rs -index 230b3a4d..83a3c523 100644 ---- a/src/graph/compact/value_order.rs -+++ b/src/graph/compact/value_order.rs -@@ -137,6 +137,46 @@ pub(crate) fn build_row_order( - Some(RowOrder::Inline(rows)) - } - -+/// The number of rows whose value equals `value`, found by binary search -+/// over `order`, mirroring [`find_eq`] without collecting the rows. -+/// -+/// `None` when the codec and value are not an orderable pair this index -+/// serves, so the caller can decline rather than assume zero. -+pub(crate) fn equality_count(codec: &ColumnCodec, order: &RowOrder, value: &Value) -> Option { -+ let entry; -+ let compare: Box Option + '_> = match (codec, value) { -+ (ColumnCodec::Dict(dict), Value::String(_) | Value::Bytes(_)) => { -+ entry = encode_dict_entry(value); -+ Box::new(|row| { -+ let code = dict.code_at(row)?; -+ Some(dict.entry(code as usize)?.cmp(entry.as_str())) -+ }) -+ } -+ (ColumnCodec::BitPacked(packed), &Value::Int64(target)) => { -+ let target = u64::try_from(target).ok()?; -+ Box::new(move |row| Some(packed.get(row)?.cmp(&target))) -+ } -+ (ColumnCodec::RawI64(store), &Value::Int64(target)) => { -+ Box::new(move |row| Some(store.get(row)?.cmp(&target))) -+ } -+ _ => return None, -+ }; -+ let bound = |inclusive: bool| -> Option { -+ let (mut low, mut high) = (0, order.len()); -+ while low < high { -+ let mid = low + (high - low) / 2; -+ let ordering = order.row(mid).and_then(&compare)?; -+ if ordering == Ordering::Less || (inclusive && ordering == Ordering::Equal) { -+ low = mid + 1; -+ } else { -+ high = mid; -+ } -+ } -+ Some(low) -+ }; -+ Some(bound(true)?.saturating_sub(bound(false)?)) -+} -+ - /// The rows of `codec` whose value equals `value`, in row order, found by - /// binary search over `order`. - pub(crate) fn find_eq(codec: &ColumnCodec, order: &RowOrder, value: &Value) -> Vec { -diff --git a/src/graph/lpg/property.rs b/src/graph/lpg/property.rs -index 415d1498..e22be7fd 100644 ---- a/src/graph/lpg/property.rs -+++ b/src/graph/lpg/property.rs -@@ -302,6 +302,27 @@ impl PropertyStorage { - } - - /// Gets all properties for an entity. -+ #[must_use] -+ /// Borrowed current values, without a map, Value clone, or payload decode. -+ pub fn allocation_bytes(&self, id: Id, key: Option<&PropertyKey>) -> usize { -+ use std::mem::size_of; -+ let columns = self.columns.read(); -+ let mut bytes = 0_usize; -+ for (name, col) in columns.iter() { -+ if key.is_some_and(|key| key != name) { continue; } -+ if let Some(value) = col.borrowed(id) { -+ bytes = bytes.saturating_add(4 * size_of::<(PropertyKey, Value)>()) -+ .saturating_add(name.as_str().len()) -+ .saturating_add(match value { -+ Value::String(value) => value.len(), -+ Value::Bytes(value) => value.len(), -+ _ => 0, -+ }); -+ } -+ } -+ bytes -+ } -+ - #[must_use] - pub fn get_all(&self, id: Id) -> FxHashMap { - let columns = self.columns.read(); -@@ -705,6 +726,16 @@ impl PropertyStorage { - } - } - -+ /// Visits the same visible epoch values as get_all_at. -+ pub fn for_each_at(&self, id: Id, epoch: EpochId, mut visit: impl FnMut(&PropertyKey, Value)) { -+ let columns = self.columns.read(); -+ for (key, col) in columns.iter() { -+ if let Some(value) = col.get_at(id, epoch) { -+ visit(key, value); -+ } -+ } -+ } -+ - /// Removes all PENDING entries from all columns (transaction rollback). - pub fn remove_pending(&self) { - let mut columns = self.columns.write(); -@@ -1020,6 +1051,12 @@ impl PropertyColumn { - /// - /// First checks the hot buffer (uncompressed values), then falls back - /// to the compressed data if present. -+ #[must_use] -+ #[cfg(not(feature = "temporal"))] -+ fn borrowed(&self, id: Id) -> Option<&Value> { -+ self.values.get(&id) -+ } -+ - #[must_use] - pub fn get(&self, id: Id) -> Option { - // First check hot buffer -@@ -1685,6 +1722,12 @@ impl PropertyColumn { - } - - /// Gets the latest value for an entity, filtering out tombstones (Null). -+ #[must_use] -+ #[cfg(feature = "temporal")] -+ fn borrowed(&self, id: Id) -> Option<&Value> { -+ self.values.get(&id).and_then(|log| log.latest()).filter(|value| !value.is_null()) -+ } -+ - #[must_use] - pub fn get(&self, id: Id) -> Option { - self.values -diff --git a/src/graph/lpg/store/edge_ops.rs b/src/graph/lpg/store/edge_ops.rs -index 2aa96b6c..e9a49569 100644 ---- a/src/graph/lpg/store/edge_ops.rs -+++ b/src/graph/lpg/store/edge_ops.rs -@@ -20,7 +20,9 @@ impl LpgStore { - id_to_type.get(record.type_id as usize)?.clone() - }; - let mut edge = Edge::new(id, record.src, record.dst, edge_type); -- edge.properties = self.edge_properties.get_all(id).into_iter().collect(); -+ self.edge_properties.for_each(id, |key, value| { -+ edge.set_property(key.clone(), value); -+ }); - Some(edge) - } - -@@ -76,11 +78,7 @@ impl LpgStore { - id_to_type.get(record.type_id as usize)?.clone() - }; - let mut edge = Edge::new(id, record.src, record.dst, edge_type); -- edge.properties = self -- .edge_properties -- .get_all_at(id, epoch) -- .into_iter() -- .collect(); -+ self.edge_properties.for_each_at(id, epoch, |key, value| edge.set_property(key.clone(), value)); - Some(edge) - } - -diff --git a/src/graph/lpg/store/graph_store_impl.rs b/src/graph/lpg/store/graph_store_impl.rs -index f19a3e9e..8fb6e2e8 100644 ---- a/src/graph/lpg/store/graph_store_impl.rs -+++ b/src/graph/lpg/store/graph_store_impl.rs -@@ -20,6 +20,41 @@ use grafeo_common::utils::hash::FxHashMap; - use std::sync::Arc; - - impl GraphStore for LpgStore { -+ fn node_allocation_bytes(&self, id: NodeId) -> Option { -+ if !self.node_visible(id) { return Some(0); } -+ let labels = self.node_labels.read(); -+ #[cfg(not(feature = "temporal"))] -+ let count = labels.get(&id).map_or(0, |labels| labels.len()); -+ #[cfg(feature = "temporal")] -+ let count = labels.get(&id).and_then(|log| log.latest()).map_or(0, |labels| labels.len()); -+ Some(self.node_properties.allocation_bytes(id, None) -+ .saturating_add(count.saturating_mul(4 * std::mem::size_of::()))) -+ } -+ -+ fn property_lookup_allocation_bytes(&self, key: &PropertyKey, value: &Value) -> Option { -+ let indexes = self.property_indexes.read(); -+ let index = indexes.get(key)?; -+ let value = grafeo_common::types::HashableValue::new(value.clone()); -+ Some(index.get(&value).map_or(0, |nodes| nodes.len()).saturating_mul(std::mem::size_of::())) -+ } -+ -+ fn edge_allocation_bytes(&self, id: EdgeId) -> Option { -+ if self.edge_header(id).is_none() { return Some(0); } -+ Some(self.edge_properties.allocation_bytes(id, None)) -+ } -+ -+ fn node_property_allocation_bytes(&self, id: NodeId, key: &PropertyKey) -> Option { -+ Some(self.node_properties.allocation_bytes(id, Some(key))) -+ } -+ -+ fn label_allocation_bytes(&self) -> Option { -+ let registry = self.label_registry.read(); -+ Some(registry.names().iter().fold( -+ registry.names().len().saturating_mul(std::mem::size_of::()), -+ |bytes, label| bytes.saturating_add(label.len()), -+ )) -+ } -+ - fn get_node(&self, id: NodeId) -> Option { - LpgStore::get_node(self, id) - } -diff --git a/src/graph/lpg/store/node_ops.rs b/src/graph/lpg/store/node_ops.rs -index 3cd1b525..bd3a02d9 100644 ---- a/src/graph/lpg/store/node_ops.rs -+++ b/src/graph/lpg/store/node_ops.rs -@@ -102,7 +102,9 @@ impl LpgStore { - } - } - -- node.properties = self.node_properties.get_all(id).into_iter().collect(); -+ self.node_properties.for_each(id, |key, value| { -+ node.set_property(key.clone(), value); -+ }); - node - } - -@@ -181,11 +183,7 @@ impl LpgStore { - } - } - -- node.properties = self -- .node_properties -- .get_all_at(id, epoch) -- .into_iter() -- .collect(); -+ self.node_properties.for_each_at(id, epoch, |key, value| node.set_property(key.clone(), value)); - node - } - -diff --git a/src/graph/traits.rs b/src/graph/traits.rs -index 4b226ec2..cb2154e6 100644 ---- a/src/graph/traits.rs -+++ b/src/graph/traits.rs -@@ -44,6 +44,30 @@ use std::sync::Arc; - pub trait GraphStore: Send + Sync { - // --- Point lookups --- - -+ /// Allocation demand of materializing a native row, without materializing it. -+ /// None means this store cannot supply allocation metadata. -+ fn node_allocation_bytes(&self, _id: NodeId) -> Option { -+ None -+ } -+ -+ fn edge_allocation_bytes(&self, _id: EdgeId) -> Option { -+ None -+ } -+ -+ /// Allocation demand of one property lookup, including dictionary decoding. -+ fn node_property_allocation_bytes(&self, _id: NodeId, _key: &PropertyKey) -> Option { -+ None -+ } -+ -+ /// Allocation demand of enumerating the label universe. -+ fn label_allocation_bytes(&self) -> Option { -+ None -+ } -+ -+ fn property_lookup_allocation_bytes(&self, _key: &PropertyKey, _value: &Value) -> Option { -+ None -+ } -+ - /// Returns a node by ID (latest visible version at current epoch). - fn get_node(&self, id: NodeId) -> Option; -