//! Reconciles `storage.chunk_manifests.ref_count` against its actual //! referrers. //! //! ### Why this exists //! //! There are **two** reference counters, and only one of them was ever //! verified. `DedupService::add_reference` bumps //! `chunk_manifests.ref_count` first and only falls back to //! `storage.blobs.ref_count`, so a reference lands on whichever counter //! its hash names: //! //! * a **chunk** reference → `storage.blobs.ref_count`, reconciled by //! `blobs_consistency::refcount_mismatch`; //! * a **Blob** reference (a CDC file, and now every derived artifact) → //! `chunk_manifests.ref_count`, reconciled by **nothing** before this //! job existed. //! //! That gap was survivable only because `dedup_gc`'s reap predicate had a //! second clause — "no `storage.files` row references this manifest" — //! which quietly compensated for drift on the bulk-delete paths where //! `ref_count` is never decremented. Generalising that clause to the //! reference registry (so thumbnails stop being reaped) removes the //! compensation, which is exactly why the manifest counter now has to be //! checked directly. See `docs/plan/derived-blobs.md`. //! //! ### The check //! //! * `manifest_refcount_mismatch` (severity `inconsistent`) — //! `chunk_manifests.ref_count` disagrees with the number of registered //! referrers. An **under**-count is the dangerous direction: GC reaps a //! manifest whose content is still reachable, taking its chunks with it. //! An over-count merely pins storage. Content-safe to report either way //! — the manifest row and its chunks are intact, the counter is wrong. //! //! ### Why a separate job rather than a phase of `blobs_consistency` //! //! One subject per job, per the subject-iteration principle the other five //! consistency tenants follow. It also avoids changing the cursor format of //! an existing *recoverable* job, which would strand any run paused across //! the deploy. use std::sync::Arc; use async_trait::async_trait; use sqlx::PgPool; use crate::application::ports::blob_reference_ports::{BlobReferenceRegistry, RefLevel}; use crate::infrastructure::scheduler::{ JobRegistry, JobRunArgs, JobStore, JobStoreProvider, Mutates, RecoverableJobHandler, RunOutcome, RunStatus, record_or_log, }; pub const MANIFESTS_CONSISTENCY_JOB_NAME: &str = "manifests_consistency"; /// Rows per batch. Each row costs one indexed subquery per registered /// source; 200 matches `blobs_consistency` so the cancel-poll cadence is /// the same for an operator watching either job. const BATCH_SIZE: i64 = 200; /// The page query, with `actual_ref_count` summed from the registered /// reference sources at [`RefLevel::Manifest`]. /// /// Only sources that reference a **Blob** contribute — `storage.files` /// today, plus `storage.content_derived_blobs` and /// `storage.file_attached_blobs` once they exist. /// `ChunksReferenceSource` returns `None` here: a manifest is never /// referenced by another manifest, and including it would count this /// manifest's own chunks as referrers of itself. /// /// # Panics /// /// If no source contributes at [`RefLevel::Manifest`] — a wiring bug that /// would report every manifest as mismatched. fn manifest_page_sql(registry: &BlobReferenceRegistry) -> String { let expected = registry.ref_count_expr(RefLevel::Manifest, "m.file_hash"); assert!( expected != "0", "no manifest-level blob reference source registered: every manifest \ would appear unreferenced" ); format!( "SELECT m.file_hash AS file_hash, m.ref_count AS ref_count, m.total_size AS total_size, m.chunk_count AS chunk_count, ({expected})::bigint AS actual_ref_count FROM storage.chunk_manifests m WHERE ($1::text IS NULL OR m.file_hash > $1) ORDER BY m.file_hash LIMIT $2" ) } /// Repair statement targeting one manifest by `file_hash`. Uses the /// SAME registry-derived expression as [`manifest_page_sql`] so /// detection and repair agree on what "actual" means — any future /// manifest-level ref source added to the registry flows into both /// queries with no code change here. /// /// The `<> (subquery)` guard makes the UPDATE a no-op when the value /// is already correct — so this is idempotent under concurrent-repair /// races AND under retry. /// /// The subquery re-reads inside the same statement, so a concurrent /// insert/delete between page fetch and this UPDATE can't leave a /// stale value: PG's snapshot for the UPDATE sees the up-to-date row /// counts. fn manifest_repair_sql(registry: &BlobReferenceRegistry) -> String { let expected = registry.ref_count_expr(RefLevel::Manifest, "m.file_hash"); format!( "UPDATE storage.chunk_manifests m SET ref_count = ({expected})::bigint WHERE m.file_hash = $1 AND m.ref_count <> ({expected})::bigint" ) } pub struct ManifestsConsistencyCheck { pool: Arc, /// Built once from the blob-reference registry so this recompute and /// `dedup_gc`'s reap predicate answer "what references this manifest" /// identically. Assembled at construction rather than per page so the /// sweep runs a fixed statement. page_sql: String, /// Repair statement — built from the SAME registry as `page_sql` so /// detection and repair use identical formulas by construction. Any /// future 4th manifest-level ref source added to the registry /// automatically flows into both queries with no code change here. /// /// Previously the repair query was inlined with the files-only /// formula, which meant drift from `content_derived_blobs` or /// `file_attached_blobs` would be DETECTED but NOT repaired even /// under `?repair=true`. Operators who added those tables saw /// findings that couldn't be cleared by the repair path — bug fixed /// 2026-09-02. /// /// The `?repair=true` gate on the trigger endpoint still stands as /// the operator's explicit opt-in — this fix only widens what /// repair CAN do when the operator chooses to run it. Discovery- /// only remains the default so leaks in insert paths still surface /// via findings between repair invocations. repair_sql: String, } impl ManifestsConsistencyCheck { pub fn new(pool: Arc, reference_registry: Arc) -> Self { Self { pool, page_sql: manifest_page_sql(&reference_registry), repair_sql: manifest_repair_sql(&reference_registry), } } /// Chainable self-registration. On-demand only — operators fire it /// from `POST /api/admin/jobs/manifests_consistency/trigger`. pub async fn register_recoverable_job( self: Arc, registry: &JobRegistry, provider: &Arc, ) -> Arc { registry .register_recoverable_job(self.clone(), provider.clone(), None) .await; self } } #[derive(Debug, sqlx::FromRow)] struct ManifestRow { file_hash: String, ref_count: i32, total_size: i64, chunk_count: i32, actual_ref_count: i64, } #[async_trait] impl RecoverableJobHandler for ManifestsConsistencyCheck { fn name(&self) -> &str { MANIFESTS_CONSISTENCY_JOB_NAME } fn description(&self) -> &'static str { "Reconciles storage.chunk_manifests.ref_count against its actual \ referrers. There are two reference counters — a chunk reference \ lands on storage.blobs.ref_count, a whole-Blob reference on the \ manifest — and only the first was ever verified; this covers the \ other half." } fn mutates(&self) -> Mutates { Mutates::OnRepairOnly } fn repair_description(&self) -> Option<&'static str> { Some( "Rewrites drifted manifest ref_count values to the recomputed \ truth. Nothing is deleted here — a corrected count only makes \ the manifest eligible for a later dedup_gc sweep.", ) } async fn count_total(&self) -> Option { let row: Result<(i64,), sqlx::Error> = sqlx::query_as("SELECT COUNT(*) FROM storage.chunk_manifests") .fetch_one(self.pool.as_ref()) .await; match row { Ok((n,)) => Some(n.max(0) as u64), Err(e) => { tracing::debug!( target: "oxicloud::consistency", event = "manifests_consistency.count_total_failed", error = %e, "count_total failed — run will not surface a progress bar" ); None } } } async fn run_resumable( &self, store: &dyn JobStore, args: &JobRunArgs, resume_cursor: Option>, ) -> RunOutcome { let is_fresh = resume_cursor.is_none(); // Cursor: the last `file_hash` as UTF-8. Same convention as // `blobs_consistency`, which also pages a hash-keyed table. let mut cursor: Option = match resume_cursor { None => None, Some(bytes) if bytes.is_empty() => None, Some(bytes) => match String::from_utf8(bytes) { Ok(s) => Some(s), Err(e) => { return RunOutcome::Failed { message: format!("invalid cursor: not valid UTF-8: {e}"), }; } }, }; // Persist the repair flag into `params.repair` so the admin // run-detail view can display whether the run was a discovery // scan or an active repair. Fresh takes it from args; Resume // reads back so a paused repair scan stays a repair scan (a // mid-scan crash mustn't silently downgrade the remaining // rows to discovery-only). Same shape as // `blobs_consistency_service.rs`'s `deep` handling — see the // reasoning documented there. let repair = if is_fresh { let v = if args.repair { "true" } else { "false" }; if let Err(e) = store.set_string_param("repair", v).await { return RunOutcome::Failed { message: format!("failed to persist repair flag to params: {e}"), }; } args.repair } else { match store.get_string_param("repair").await { Ok(Some(v)) => v == "true", Ok(None) => false, Err(e) => { return RunOutcome::Failed { message: format!("read `repair` from params: {e}"), }; } } }; if repair { tracing::info!( target: "oxicloud::consistency", event = "manifests_consistency.repair_mode_active", run_id = %store.run_id(), "repair mode: manifest_refcount_mismatch findings will trigger corrective UPDATE" ); } let mut finding_count = 0u64; // Only relevant when `repair == true`. Reported inline in // the completion log + the `extra_stats` payload so operators // can see "we found N and fixed M" in one line. let mut repaired_count = 0u64; loop { // Cooperative cancel poll between batches. match store.status().await { Ok(RunStatus::CancelRequested) => { tracing::info!( target: "oxicloud::consistency", event = "manifests_consistency.cancelled", run_id = %store.run_id(), finding_count = finding_count, "manifests_consistency cancelled cooperatively, pausing" ); return RunOutcome::Paused { cursor: cursor .as_ref() .map(|s| s.as_bytes().to_vec()) .unwrap_or_default(), }; } Ok(_) => {} Err(e) => { return RunOutcome::Failed { message: format!("status poll: {e}"), }; } } let rows: Vec = match sqlx::query_as(&self.page_sql) .bind(cursor.as_deref()) .bind(BATCH_SIZE) .fetch_all(self.pool.as_ref()) .await { Ok(r) => r, Err(e) => { return RunOutcome::Failed { message: format!("batch fetch: {e}"), }; } }; if rows.is_empty() { tracing::info!( target: "oxicloud::consistency", event = "manifests_consistency.completed", run_id = %store.run_id(), finding_count = finding_count, repaired_count = repaired_count, repair_requested = repair, "manifests_consistency completed with {} finding(s), {} repaired", finding_count, repaired_count ); return RunOutcome::completed_with(serde_json::json!({ "repair_requested": repair, "repaired_count": repaired_count, })); } for row in &rows { if row.ref_count as i64 == row.actual_ref_count { continue; } finding_count += 1; let delta = row.actual_ref_count - row.ref_count as i64; let detail = serde_json::json!({ "file_hash": row.file_hash, "stored": row.ref_count, "actual": row.actual_ref_count, "delta": delta, "total_size": row.total_size, "chunk_count": row.chunk_count, // Under-count is the dangerous direction: GC reaps // a manifest whose content is still reachable. "reap_risk": delta > 0, }); // Repair pass — content-safe corrective UPDATE. The // stored counter is set to what the auditor formula // would compute at UPDATE time (subquery matches // `manifest_page_sql`'s `actual_ref_count` predicate), // so a concurrent file insert/delete between our page // fetch and this UPDATE can't leave a stale value — // the subquery re-reads inside the same statement. // The `<> (subquery)` guard makes the UPDATE a no-op // if the value is already correct, so this is // idempotent under retry. // // `self.repair_sql` is built once at construction from // the same `BlobReferenceRegistry` as the page query — // detection and repair use identical formulas by // construction. See `manifest_repair_sql` for the SQL. // // Attempt repair FIRST, then record the finding with // severity/kind reflecting the final state: // * repair succeeded → severity "info", kind "manifest_refcount_repaired" // * repair no-op → severity "info", kind "manifest_refcount_resolved" // * repair failed → severity "inconsistent", kind "manifest_refcount_mismatch" // * no repair requested → severity "inconsistent", kind "manifest_refcount_mismatch" // // Parallels the WARN-then-INFO sequence in logs: an // unresolved drift raises attention ("inconsistent"), // a repaired one records the fix at info level without // inflating the "needs action" tally the outcome UI // shows. The detail JSON still carries `stored/actual/ // delta` so the audit trail is complete either way. let (kind, severity) = if repair { match sqlx::query(&self.repair_sql) .bind(&row.file_hash) .execute(self.pool.as_ref()) .await { Ok(res) if res.rows_affected() > 0 => { repaired_count += 1; tracing::info!( target: "audit", event = "manifests_consistency.repaired", run_id = %store.run_id(), file_hash = %row.file_hash, stored_was = row.ref_count, actual = row.actual_ref_count, "🩹 manifest ref_count repaired" ); ("manifest_refcount_repaired", "info") } Ok(_) => { // Row not touched — either another // concurrent repair fixed it first, or the // drift healed itself between page fetch // and UPDATE. Either way, current state // is correct — record as info. ("manifest_refcount_resolved", "info") } Err(e) => { tracing::warn!( target: "oxicloud::consistency", event = "manifests_consistency.repair_failed", run_id = %store.run_id(), file_hash = %row.file_hash, error = %e, "manifest ref_count repair UPDATE failed — finding stays" ); ("manifest_refcount_mismatch", "inconsistent") } } } else { ("manifest_refcount_mismatch", "inconsistent") }; record_or_log( store, MANIFESTS_CONSISTENCY_JOB_NAME, kind, severity, None, // a hash isn't a UUID; the identifier lives in detail detail, ) .await; } // Advance cursor + checkpoint. let last_hash = rows .last() .map(|r| r.file_hash.clone()) .expect("non-empty rows"); cursor = Some(last_hash.clone()); let batch_len = rows.len() as u64; if let Err(e) = store.checkpoint(last_hash.into_bytes(), batch_len).await { return RunOutcome::Failed { message: format!("checkpoint: {e}"), }; } if (rows.len() as i64) < BATCH_SIZE { tracing::info!( target: "oxicloud::consistency", event = "manifests_consistency.completed", run_id = %store.run_id(), finding_count = finding_count, repaired_count = repaired_count, repair_requested = repair, "manifests_consistency completed with {} finding(s), {} repaired", finding_count, repaired_count ); return RunOutcome::completed_with(serde_json::json!({ "repair_requested": repair, "repaired_count": repaired_count, })); } } } } #[cfg(test)] mod tests { use super::*; fn default_registry() -> BlobReferenceRegistry { let pool = Arc::new( sqlx::pool::PoolOptions::::new() .connect_lazy("postgres://invalid/invalid") .expect("lazy pool never connects"), ); crate::infrastructure::repositories::pg::blob_reference_sources::built_in_registry(pool) } /// Golden test — the statement is assembled from the registry, so pin it /// byte-for-byte and read the SQL here rather than deriving it mentally. /// /// Two invariants a future source must not break: the files term carries /// **no** `NOT EXISTS` guard (that guard exists to keep CDC rows out of /// the *chunk* level; applying it here would count nothing), and /// `chunk_hashes` appears nowhere — a manifest citing its own chunks is /// not a referrer of itself. #[tokio::test] async fn manifest_page_statement_is_stable() { let sql = manifest_page_sql(&default_registry()); let expected = r#"SELECT m.file_hash AS file_hash, m.ref_count AS ref_count, m.total_size AS total_size, m.chunk_count AS chunk_count, ((SELECT COUNT(*) FROM storage.files cnt_f WHERE cnt_f.blob_hash = m.file_hash) + (SELECT COUNT(*) FROM storage.content_derived_blobs cnt_d WHERE cnt_d.blob_hash = m.file_hash) + (SELECT COUNT(*) FROM storage.file_attached_blobs cnt_a WHERE cnt_a.blob_hash = m.file_hash))::bigint AS actual_ref_count FROM storage.chunk_manifests m WHERE ($1::text IS NULL OR m.file_hash > $1) ORDER BY m.file_hash LIMIT $2"#; assert_eq!(sql, expected, "manifest page statement changed:\n{sql}"); } #[tokio::test] async fn chunks_source_contributes_nothing_at_manifest_level() { let sql = manifest_page_sql(&default_registry()); assert!( !sql.contains("chunk_hashes"), "a manifest must not count its own chunks as referrers: {sql}" ); } #[test] #[should_panic(expected = "no manifest-level blob reference source")] fn empty_registry_refuses_to_build_page_statement() { let _ = manifest_page_sql(&BlobReferenceRegistry::new()); } /// Golden test — the repair statement is assembled from the same /// registry as `manifest_page_sql`, so pin it byte-for-byte too. /// If the registry ever changes what it produces at /// `RefLevel::Manifest`, BOTH this test and /// `manifest_page_statement_is_stable` above break together — an /// operator using `?repair=true` shouldn't see the detection /// formula report drift the repair formula can't clear. /// /// Ships the three-term formula (`storage.files` + /// `storage.content_derived_blobs` + `storage.file_attached_blobs`) /// twice — once in SET, once in the `<>` guard. Both must stay /// identical so the guard is meaningful (else the UPDATE would fire /// on drift the SET doesn't fix). #[tokio::test] async fn manifest_repair_statement_is_stable() { let sql = manifest_repair_sql(&default_registry()); let expected = r#"UPDATE storage.chunk_manifests m SET ref_count = ((SELECT COUNT(*) FROM storage.files cnt_f WHERE cnt_f.blob_hash = m.file_hash) + (SELECT COUNT(*) FROM storage.content_derived_blobs cnt_d WHERE cnt_d.blob_hash = m.file_hash) + (SELECT COUNT(*) FROM storage.file_attached_blobs cnt_a WHERE cnt_a.blob_hash = m.file_hash))::bigint WHERE m.file_hash = $1 AND m.ref_count <> ((SELECT COUNT(*) FROM storage.files cnt_f WHERE cnt_f.blob_hash = m.file_hash) + (SELECT COUNT(*) FROM storage.content_derived_blobs cnt_d WHERE cnt_d.blob_hash = m.file_hash) + (SELECT COUNT(*) FROM storage.file_attached_blobs cnt_a WHERE cnt_a.blob_hash = m.file_hash))::bigint"#; assert_eq!(sql, expected, "manifest repair statement changed:\n{sql}"); } }