Files
Oxicloud/src/infrastructure/services/manifests_consistency_service.rs
T
2026-09-02 22:23:10 +02:00

567 lines
24 KiB
Rust

//! 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<PgPool>,
/// 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<PgPool>, reference_registry: Arc<BlobReferenceRegistry>) -> 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<Self>,
registry: &JobRegistry,
provider: &Arc<dyn JobStoreProvider>,
) -> Arc<Self> {
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<u64> {
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<Vec<u8>>,
) -> 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<String> = 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<ManifestRow> = 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::<sqlx::Postgres>::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}");
}
}