feat(storage): admin storage-usage stats + per-manga/chapter sizes
Persist blob sizes (covers + chapter pages) and surface upload storage usage to admins in two places: - Admin System tab: total/covers/chapters totals, ratio of disk, average image sizes, and top-5 largest mangas/chapters leaderboards, behind a new GET /api/v1/admin/storage endpoint (separate from /admin/system so the cheap DB aggregates aren't coupled to its 250ms CPU sample). - Manga detail page: total chapter-content size and per-chapter size, with an em-dash for uncrawled or not-yet-measured chapters. Sizes are captured at write time (crawler put_stream return, uploaded page/cover byte length) into nullable pages.size_bytes / mangas.cover_size_bytes columns; NULL means "not yet measured", distinct from a real 0. A one-shot, idempotent admin "Backfill sizes" action (POST /api/v1/admin/storage/backfill) stats pre-existing blobs in keyset-paginated, capped batches and reports more_remaining so a large legacy library can be drained over multiple runs. Aggregates and leaderboards treat NULL honestly (only fully-measured entities are ranked); a dashboard banner flags unmeasured rows so partial figures aren't read as complete. persist_pages now prunes stale page rows on a shrinking re-crawl so totals and page_count stay consistent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ pub mod crawler;
|
||||
pub mod mangas;
|
||||
pub mod resync;
|
||||
pub mod settings;
|
||||
pub mod storage;
|
||||
pub mod system;
|
||||
pub mod users;
|
||||
|
||||
@@ -21,6 +22,7 @@ pub fn routes() -> Router<AppState> {
|
||||
.merge(users::routes())
|
||||
.merge(mangas::routes())
|
||||
.merge(resync::routes())
|
||||
.merge(storage::routes())
|
||||
.merge(system::routes())
|
||||
.merge(crawler::routes())
|
||||
.merge(analysis::routes())
|
||||
|
||||
246
backend/src/api/admin/storage.rs
Normal file
246
backend/src/api/admin/storage.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
//! 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<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/storage", get(storage))
|
||||
.route("/admin/storage/backfill", post(backfill))
|
||||
}
|
||||
|
||||
async fn storage(
|
||||
State(state): State<AppState>,
|
||||
_admin: RequireAdmin,
|
||||
) -> AppResult<Json<StorageStats>> {
|
||||
// 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<AppState>,
|
||||
admin: RequireAdmin,
|
||||
) -> AppResult<Json<BackfillResponse>> {
|
||||
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;
|
||||
}
|
||||
|
||||
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<u64, StorageError>,
|
||||
missing: &mut usize,
|
||||
errored: &mut usize,
|
||||
) -> Option<i64> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ async fn system(
|
||||
}))
|
||||
}
|
||||
|
||||
fn disk_stats_for(root: &Path) -> Option<DiskStats> {
|
||||
pub(crate) fn disk_stats_for(root: &Path) -> Option<DiskStats> {
|
||||
let s = nix::sys::statvfs::statvfs(root).ok()?;
|
||||
// statvfs reports `f_frsize * f_blocks` for total bytes. `f_bavail`
|
||||
// is "free to non-root callers" which is what an operator actually
|
||||
|
||||
@@ -146,14 +146,26 @@ async fn create(
|
||||
manga_id, chapter.id, nnnn, page.ext
|
||||
);
|
||||
state.storage.put(&key, &page.bytes).await?;
|
||||
let created =
|
||||
repo::page::create(&mut *tx, chapter.id, page_number, &key, page.mime).await?;
|
||||
let created = repo::page::create(
|
||||
&mut *tx,
|
||||
chapter.id,
|
||||
page_number,
|
||||
&key,
|
||||
page.mime,
|
||||
page.bytes.len() as i64,
|
||||
)
|
||||
.await?;
|
||||
page_ids.push(created.id);
|
||||
}
|
||||
|
||||
let page_count = pages.len() as i32;
|
||||
repo::chapter::set_page_count(&mut *tx, chapter.id, page_count).await?;
|
||||
chapter.page_count = page_count;
|
||||
// `repo::chapter::create` returned the row before any pages existed, so
|
||||
// its `size_bytes` is a stale 0. Every uploaded page's size was just
|
||||
// captured, so the true total is the sum of their byte lengths — set it
|
||||
// on the response so the 201 body matches the persisted state.
|
||||
chapter.size_bytes = Some(pages.iter().map(|p| p.bytes.len() as i64).sum());
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
|
||||
@@ -212,7 +212,8 @@ async fn create(
|
||||
if let Some(img) = cover {
|
||||
let key = format!("mangas/{}/cover.{}", manga.id, img.ext);
|
||||
state.storage.put(&key, &img.bytes).await?;
|
||||
repo::manga::set_cover_image_path(&mut *tx, manga.id, &key).await?;
|
||||
repo::manga::set_cover_image_path(&mut *tx, manga.id, &key, img.bytes.len() as i64)
|
||||
.await?;
|
||||
manga.cover_image_path = Some(key);
|
||||
}
|
||||
|
||||
@@ -338,7 +339,7 @@ async fn put_cover(
|
||||
}
|
||||
}
|
||||
|
||||
repo::manga::set_cover_image_path(&state.db, id, &new_key).await?;
|
||||
repo::manga::set_cover_image_path(&state.db, id, &new_key, img.bytes.len() as i64).await?;
|
||||
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user