//! Admin storage-usage stats + the one-shot size backfill. //! //! Kept separate from `/admin/system` on purpose: that handler eats a //! 250 ms CPU sample on every call. Storage figures are pure DB //! aggregates with no such cost, so they get their own endpoint. //! //! Both handlers are admin-only (`RequireAdmin`, cookie-only). use axum::extract::State; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::Serialize; use serde_json::json; use uuid::Uuid; use crate::app::AppState; use crate::auth::extractor::RequireAdmin; use crate::domain::StorageStats; use crate::error::AppResult; use crate::repo; use crate::repo::storage_stats::BACKFILL_BATCH; use crate::storage::StorageError; /// How many entries each leaderboard returns. const TOP_N: i64 = 5; pub fn routes() -> Router { Router::new() .route("/admin/storage", get(storage)) .route("/admin/storage/backfill", post(backfill)) } async fn storage( State(state): State, _admin: RequireAdmin, ) -> AppResult> { // Independent reads — run concurrently so dashboard latency is the // slowest query, not their sum. let (totals, averages, top_mangas, top_chapters, unmeasured) = tokio::try_join!( repo::storage_stats::totals(&state.db), repo::storage_stats::averages(&state.db), repo::storage_stats::top_mangas(&state.db, TOP_N), repo::storage_stats::top_chapters(&state.db, TOP_N), repo::storage_stats::unmeasured_counts(&state.db), )?; let (covers_bytes, chapters_bytes) = totals; let (avg_image_bytes, avg_cover_bytes, avg_chapter_page_bytes) = averages; let (unmeasured_pages, unmeasured_covers) = unmeasured; let total_bytes = covers_bytes + chapters_bytes; // Reuse the System tab's statvfs formula so the disk total and the // ratio can never drift apart. let disk_total_bytes = state .storage .local_root() .and_then(super::system::disk_stats_for) .map(|d| d.total_bytes); let ratio_of_disk = disk_total_bytes.and_then(|d| { if d > 0 { Some(total_bytes as f64 / d as f64) } else { None } }); Ok(Json(StorageStats { total_bytes, covers_bytes, chapters_bytes, disk_total_bytes, ratio_of_disk, avg_image_bytes, avg_cover_bytes, avg_chapter_page_bytes, top_mangas, top_chapters, unmeasured_pages, unmeasured_covers, })) } /// Upper bound on blobs stat-ed in a single backfill request. Each stat /// is a syscall (or, for a future S3 backend, a network round-trip), so /// an unbounded run over a huge legacy library could exceed a proxy / /// client timeout. Capping keeps each request bounded in wall-clock; the /// run is idempotent and `more_remaining` tells the caller to run again. const MAX_PER_RUN: usize = 20_000; #[derive(Debug, Serialize)] pub struct BackfillResponse { /// Pages whose size was written this run (rows actually updated). pub pages: usize, /// Covers whose size was written this run. pub covers: usize, /// Blobs the row points at but storage reports as absent /// (deleted/orphaned). Expected during cleanup; left unmeasured. pub missing: usize, /// Blobs that errored for another reason (bad key, transient IO). /// Distinct from `missing` so an operator doesn't mistake a retryable /// failure for a genuinely orphaned blob. Re-running may resolve these. pub errored: usize, /// `true` when the per-run cap was hit before the backlog drained, so /// the caller should run the backfill again to finish. pub more_remaining: bool, } /// Idempotent: only touches unmeasured rows (`size_bytes IS NULL`, /// `cover_size_bytes IS NULL`). Keyset-paginated in batches so the whole /// backlog is never held in memory; capped at `MAX_PER_RUN` stats so the /// request stays bounded in wall-clock. The batched UPDATEs are /// `IS NULL`-guarded so a concurrent write is never clobbered, and the /// reported counts are the rows actually written. Blobs that can't be /// stat-ed are counted and skipped — never fatal — so a single orphan /// doesn't abort the run. async fn backfill( State(state): State, admin: RequireAdmin, ) -> AppResult> { let mut resp = BackfillResponse { pages: 0, covers: 0, missing: 0, errored: 0, more_remaining: false, }; // Stats attempted this run, across pages + covers, against the cap. let mut budget = MAX_PER_RUN; // Pages. `pages_after` ends at the max id processed (rows are scanned in // id order), so it cleanly separates attempted (id <= cursor) from // not-yet-reached (id > cursor) rows. let mut pages_after = Uuid::nil(); while budget > 0 { let limit = (budget as i64).min(BACKFILL_BATCH); let batch = repo::storage_stats::pages_needing_backfill_after(&state.db, pages_after, limit) .await?; if batch.is_empty() { break; } let mut ids = Vec::with_capacity(batch.len()); let mut sizes = Vec::with_capacity(batch.len()); for (id, key) in &batch { pages_after = *id; // advance past every row, including failures budget -= 1; if let Some(size) = classify(state.storage.size(key).await, &mut resp.missing, &mut resp.errored) { ids.push(*id); sizes.push(size); } } resp.pages += repo::storage_stats::batch_set_page_sizes(&state.db, &ids, &sizes).await? as usize; } // Covers. let mut covers_after = Uuid::nil(); while budget > 0 { let limit = (budget as i64).min(BACKFILL_BATCH); let batch = repo::storage_stats::covers_needing_backfill_after(&state.db, covers_after, limit) .await?; if batch.is_empty() { break; } let mut ids = Vec::with_capacity(batch.len()); let mut sizes = Vec::with_capacity(batch.len()); for (id, key) in &batch { covers_after = *id; budget -= 1; if let Some(size) = classify(state.storage.size(key).await, &mut resp.missing, &mut resp.errored) { ids.push(*id); sizes.push(size); } } resp.covers += repo::storage_stats::batch_set_cover_sizes(&state.db, &ids, &sizes).await? as usize; } // `more_remaining` = the scan stopped at the cap AND unmeasured rows // remain *beyond the cursor we reached*. The keyset peek (LIMIT 1 past // the cursor) excludes rows we already attempted this run, so a // permanently un-stat-able orphan we passed over (id <= cursor) never // keeps this true — it converges instead of looping forever. It's also // exact at the boundary: a backlog of exactly the cap drains to "no rows // past the cursor" → false (no spurious follow-up prompt). if budget == 0 { let more_pages = !repo::storage_stats::pages_needing_backfill_after( &state.db, pages_after, 1, ) .await? .is_empty(); let more_covers = !repo::storage_stats::covers_needing_backfill_after( &state.db, covers_after, 1, ) .await? .is_empty(); resp.more_remaining = more_pages || more_covers; } // Audit is written after the action here *by necessity*, not oversight // (unlike reenqueue/analyze_page, which wrap their single DB mutation + // audit in one tx). The backfill is a budgeted scan that interleaves // filesystem `storage.size()` reads with per-batch DB writes across many // iterations; wrapping it in a transaction would hold one open across all // that I/O (a long-running tx). The batch size-writes are also idempotent // recomputations, so a lost audit row has negligible impact. repo::admin_audit::insert( &state.db, admin.0.id, "storage_backfill", "system", None, json!({ "pages": resp.pages, "covers": resp.covers, "missing": resp.missing, "errored": resp.errored, }), ) .await?; Ok(Json(resp)) } /// Turn a `Storage::size` result into the byte count to persist, bumping /// the right counter on failure. `NotFound` → `missing` (orphaned); /// anything else (bad key, IO) → `errored` (possibly retryable). fn classify( result: Result, missing: &mut usize, errored: &mut usize, ) -> Option { match result { Ok(size) => Some(size as i64), Err(StorageError::NotFound) => { *missing += 1; None } Err(e) => { tracing::warn!(error = ?e, "storage backfill: cannot stat blob"); *errored += 1; None } } }