feat(recoverable-job): add folder_consistency

This commit is contained in:
Edouard Vanbelle
2026-07-29 01:33:47 +02:00
parent 394708c9a1
commit 782a5c99bd
5 changed files with 370 additions and 6 deletions
@@ -0,0 +1,328 @@
//! Second tenant of Part 2 (recoverable-run engine).
//!
//! Iterates `storage.folders` and reports each row whose maintained-
//! by-trigger materialised state has drifted from what walking its
//! `parent_id` chain would produce. **Read-only** — reports drift as
//! findings but does NOT rewrite anything; a curative sweep (or a
//! targeted repair endpoint) is a separate concern.
//!
//! Why this matters: the ltree cascade trigger `trg_folders_cascade_path`
//! is what keeps `path` and `lpath` in sync with `parent_id`. Any
//! bulk write path that bypasses the trigger (raw COPY, migration
//! backfill with FOR EACH STATEMENT triggers disabled, hand-rolled
//! UPDATE with `SET LOCAL session_replication_role = 'replica'`) can
//! leave the materialised columns wrong. Silent divergence breaks
//! every `WHERE lpath <@ ancestor` query — subtree list, breadcrumb
//! walk, recursive copy/move/delete. Detecting the drift is what
//! surfaces the underlying misuse.
//!
//! ### v1 checks (three per-row branches)
//!
//! * `parent_trashed_mismatch` — a non-trashed folder whose parent
//! IS trashed. FK enforcement + trash cascade should make this
//! impossible; when it does happen the cascade missed a row.
//! * `path_mismatch` — materialised `folders.path` differs from
//! the parent-chain reconstruction.
//! * `lpath_mismatch` — materialised `folders.lpath` differs from
//! the parent-chain reconstruction.
//!
//! Reconstruction convention (mirrors `storage.compute_folder_path`
//! from `20260307000000_initial_schema.sql`):
//!
//! ```text
//! my_label = replace(id::text, '-', '_')
//! root: path = name lpath = my_label
//! non-root: path = parent.path || '/' || name
//! lpath = parent.lpath || my_label
//! ```
//!
//! Findings are LOGGED to `target: "oxicloud::consistency"` for now,
//! same as `drives_consistency`. Persistence to `jobs.run_findings`
//! lands with the findings-table migration (deferred); at that point
//! this handler swaps its `tracing::warn!` calls for
//! `store.record_finding(...)` — nothing else changes.
//!
//! ### Room to grow (already-cheap branches deferred)
//!
//! * `drive_id_parent_mismatch` — parent + child in different drives.
//! The self-join already loads `parent.drive_id`; one more per-row
//! `if` when the drive-membership rules stabilise post-D7.
//! * `orphan_root` — a non-trashed `parent_id IS NULL` folder no
//! `drives.root_folder_id` points at. The `check_no_orphan_root_folder`
//! trigger from `20260803000000_*` blocks new orphans; a check here
//! would surface pre-trigger legacy rows.
//! * Name-vs-path terminal drift (`path` ending in the folder's `name`).
//! Redundant with `path_mismatch` unless we ever start storing them
//! independently.
use std::sync::Arc;
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use crate::infrastructure::scheduler::{
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, RecoverableJobHandler, RunOutcome,
RunStatus,
};
pub const FOLDERS_CONSISTENCY_JOB_NAME: &str = "folders_consistency";
/// Rows per batch. Folders can be numerous (millions on big
/// installs), each row is light. 500 keeps the cancel-poll cadence
/// sub-second on a warm cache while amortising round-trip overhead.
const BATCH_SIZE: i64 = 500;
pub struct FoldersConsistencyCheck {
pool: Arc<PgPool>,
}
impl FoldersConsistencyCheck {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
/// Chainable self-registration — mirrors `DrivesConsistencyCheck`.
/// On-demand only (no periodic tick); operators fire it from
/// `POST /api/admin/jobs/folders_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 FolderRow {
id: Uuid,
parent_id: Option<Uuid>,
is_trashed: bool,
path: String,
lpath_text: String,
parent_is_trashed: Option<bool>,
parent_path: Option<String>,
parent_lpath_text: Option<String>,
expected_path: String,
expected_lpath_text: String,
}
#[async_trait]
impl RecoverableJobHandler for FoldersConsistencyCheck {
fn name(&self) -> &str {
FOLDERS_CONSISTENCY_JOB_NAME
}
async fn run_resumable(
&self,
store: &dyn JobStore,
_args: &JobRunArgs,
resume_cursor: Option<Vec<u8>>,
) -> RunOutcome {
// Cursor: 16 raw UUID bytes, empty/absent = start from beginning.
// Same convention as `drives_consistency` so the resume path
// in `PgJobStoreProvider` treats every UUID-cursor tenant the
// same way.
let mut cursor: Option<Uuid> = match resume_cursor {
None => None,
Some(bytes) if bytes.is_empty() => None,
Some(bytes) if bytes.len() == 16 => {
let mut arr = [0u8; 16];
arr.copy_from_slice(&bytes);
Some(Uuid::from_bytes(arr))
}
Some(bytes) => {
return RunOutcome::Failed {
message: format!("invalid cursor: expected 16 bytes, got {}", bytes.len()),
};
}
};
let mut finding_count = 0u64;
loop {
// Cancel poll BETWEEN batches — the cooperative cancel
// contract (see `RecoverableJobHandler` trait doc).
match store.status().await {
Ok(RunStatus::CancelRequested) => {
tracing::info!(
target: "oxicloud::consistency",
event = "folders_consistency.cancelled",
run_id = %store.run_id(),
finding_count = finding_count,
"folders_consistency cancelled cooperatively, pausing"
);
return RunOutcome::Paused {
cursor: cursor.map(|u| u.as_bytes().to_vec()).unwrap_or_default(),
};
}
Ok(_) => {}
Err(e) => {
return RunOutcome::Failed {
message: format!("status poll: {e}"),
};
}
}
// Fetch a batch of folders + reconstruct expected
// path/lpath in SQL via LEFT JOIN on parent. The
// reconstruction formula mirrors `compute_folder_path`
// 1:1; if that trigger's convention ever changes both
// must move together.
let rows: Vec<FolderRow> = match sqlx::query_as(
r#"
SELECT
f.id AS id,
f.parent_id AS parent_id,
f.is_trashed AS is_trashed,
f.path AS path,
f.lpath::text AS lpath_text,
parent.is_trashed AS parent_is_trashed,
parent.path AS parent_path,
parent.lpath::text AS parent_lpath_text,
CASE
WHEN f.parent_id IS NULL THEN f.name
ELSE parent.path || '/' || f.name
END AS expected_path,
CASE
WHEN f.parent_id IS NULL THEN replace(f.id::text, '-', '_')
ELSE parent.lpath::text || '.' || replace(f.id::text, '-', '_')
END AS expected_lpath_text
FROM storage.folders f
LEFT JOIN storage.folders parent ON parent.id = f.parent_id
WHERE ($1::uuid IS NULL OR f.id > $1)
ORDER BY f.id
LIMIT $2
"#,
)
.bind(cursor)
.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 = "folders_consistency.completed",
run_id = %store.run_id(),
finding_count = finding_count,
"folders_consistency completed with {} finding(s)",
finding_count
);
return RunOutcome::Completed;
}
// Per-row branches. Add new ones here — same pattern as
// `drives_consistency`. Emit at most one finding per
// (row, kind); multiple different kinds for the same row
// are fine and independent.
for row in &rows {
// (1) parent_trashed_mismatch: a live folder under a
// soft-deleted parent. Cascade missed.
if !row.is_trashed && row.parent_is_trashed == Some(true) {
finding_count += 1;
tracing::warn!(
target: "oxicloud::consistency",
event = "consistency_finding",
run_id = %store.run_id(),
job = FOLDERS_CONSISTENCY_JOB_NAME,
kind = "parent_trashed_mismatch",
severity = "inconsistent",
resource_id = %row.id,
parent_id = ?row.parent_id,
"folder {} is live but its parent {:?} is trashed",
row.id,
row.parent_id
);
}
// (2) path_mismatch: materialised path drifted from
// the parent-chain reconstruction.
if row.path != row.expected_path {
finding_count += 1;
tracing::warn!(
target: "oxicloud::consistency",
event = "consistency_finding",
run_id = %store.run_id(),
job = FOLDERS_CONSISTENCY_JOB_NAME,
kind = "path_mismatch",
severity = "inconsistent",
resource_id = %row.id,
stored = %row.path,
expected = %row.expected_path,
parent_path = ?row.parent_path,
"folder {} path drift: stored={:?} expected={:?}",
row.id,
row.path,
row.expected_path
);
}
// (3) lpath_mismatch: materialised lpath drifted from
// the parent-chain reconstruction. Independent of (2)
// — either can be wrong without the other, and both
// silently break different query shapes.
if row.lpath_text != row.expected_lpath_text {
finding_count += 1;
tracing::warn!(
target: "oxicloud::consistency",
event = "consistency_finding",
run_id = %store.run_id(),
job = FOLDERS_CONSISTENCY_JOB_NAME,
kind = "lpath_mismatch",
severity = "inconsistent",
resource_id = %row.id,
stored = %row.lpath_text,
expected = %row.expected_lpath_text,
parent_lpath = ?row.parent_lpath_text,
"folder {} lpath drift: stored={:?} expected={:?}",
row.id,
row.lpath_text,
row.expected_lpath_text
);
}
}
// Advance cursor + checkpoint. Batch length is what we
// report to `stats.scanned_count`; findings are separate
// (they arrive via the tracing subscriber / eventually
// `run_findings`).
let last_id = rows.last().map(|r| r.id).expect("non-empty rows");
cursor = Some(last_id);
let batch_len = rows.len() as u64;
if let Err(e) = store
.checkpoint(last_id.as_bytes().to_vec(), batch_len)
.await
{
return RunOutcome::Failed {
message: format!("checkpoint: {e}"),
};
}
// Short batch = drained the folders table.
if (rows.len() as i64) < BATCH_SIZE {
tracing::info!(
target: "oxicloud::consistency",
event = "folders_consistency.completed",
run_id = %store.run_id(),
finding_count = finding_count,
"folders_consistency completed with {} finding(s)",
finding_count
);
return RunOutcome::Completed;
}
}
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod face_indexing_service;
pub mod ffmpeg_video_frame_service;
pub mod file_content_cache;
pub mod file_system_i18n_service;
pub mod folders_consistency_service;
pub mod grant_cleanup_service;
pub mod image_transcode_service;
pub mod jwt_service;