perf(backend): close config/upload/export hot paths + stability fixes
Performance: - Cache the runtime `config` table in-memory (ConfigCache) with synchronous invalidation on every write (admin PATCH + test reseed). Was re-reading each key from Postgres on every request (~8 round-trips per upload). - Stream uploads chunk-by-chunk to a temp file instead of buffering the whole body in RAM (peak was up to the per-class cap, e.g. 500 MB/video); only 512 sniff-bytes are kept for magic-byte detection, then atomic rename into place. - Cache the media-filesystem disk snapshot (DiskCache, 15s TTL) shared by the quota check and admin stats; drop the discarded System::refresh_all(). - HTML export streams video (and small-image) originals straight into the ZIP via a manifest instead of copying them to a temp dir first (removed the transient 2x disk usage) and drops the double directory scan. - Auth extractor resolves session -> live user in one JOIN (was two queries), touching last_seen_at by token hash. Stability: - SSE: on broadcast lag, emit a `resync` event so the client runs a delta fetch instead of silently losing events; frontend reconciles adds, deletions, and (via an in-place refresh) like/comment counts on visible cards. - Storage quota fails OPEN when the disk can't be read (was a 0-byte limit that locked out all uploads). - Graceful shutdown drains in-flight requests on SIGTERM/SIGINT, bounded by a 10s backstop so open SSE streams can't stall a deploy. - Upload removes the persisted file if the DB transaction fails (no orphaned bytes with no row to reclaim them). Tests: - New pure select_disk() with 5 unit tests (longest-prefix, fallbacks, fail-open). - New e2e export-video spec covering the HTML export's video-streaming branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,46 +1,129 @@
|
||||
//! Reads of the runtime-tunable `config` table.
|
||||
//! Reads of the runtime-tunable `config` table, fronted by an in-memory cache.
|
||||
//!
|
||||
//! Each handler used to keep a small local copy of these helpers; consolidating them
|
||||
//! here means one place to add a parser, one place to mock for tests, and one place to
|
||||
//! find when a key changes. New keys do not require code changes — they're picked up
|
||||
//! the next time someone calls `get_*`.
|
||||
//! the next time the cache reloads.
|
||||
//!
|
||||
//! ## Why a cache
|
||||
//!
|
||||
//! The `config` table is effectively static during an event, yet it was the busiest
|
||||
//! query in the system: every request re-read each key with its own `SELECT`
|
||||
//! (an upload touched it ~8 times). Against the small connection pool that was the
|
||||
//! throughput ceiling. [`ConfigCache`] loads the whole table once and serves reads
|
||||
//! from memory.
|
||||
//!
|
||||
//! ## Consistency contract
|
||||
//!
|
||||
//! Correctness comes from **synchronous invalidation on every write**, not from the
|
||||
//! TTL. The two runtime write paths — the admin `PATCH /admin/config` handler and the
|
||||
//! test-mode truncate/reseed — both call [`ConfigCache::invalidate`] after committing,
|
||||
//! so the *next* read reloads from the DB and sees the new value immediately. The
|
||||
//! [`RELOAD_TTL`] is only a safety net for out-of-band changes (e.g. a migration or a
|
||||
//! manual DB edit); it is deliberately short but never the primary mechanism.
|
||||
//!
|
||||
//! Values are read with a default fallback so the app still starts if a key is missing
|
||||
//! (e.g. during a migration window). Production seeds keys via migrations 005 and 009.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
async fn fetch_raw(pool: &PgPool, key: &str) -> Option<String> {
|
||||
sqlx::query_as::<_, (String,)>("SELECT value FROM config WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(v,)| v)
|
||||
/// How long a loaded snapshot is trusted before the next read reloads it. This is a
|
||||
/// backstop for out-of-band DB changes only — every in-process write invalidates the
|
||||
/// cache synchronously, so tests that PATCH-then-assert never depend on this expiring.
|
||||
const RELOAD_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
struct Snapshot {
|
||||
values: HashMap<String, String>,
|
||||
loaded_at: Instant,
|
||||
}
|
||||
|
||||
pub async fn get_str(pool: &PgPool, key: &str, default: &str) -> String {
|
||||
fetch_raw(pool, key).await.unwrap_or_else(|| default.to_string())
|
||||
/// In-memory cache of the entire `config` table. Cheap to `clone` (shares the pool and
|
||||
/// the `Arc`), so it lives in `AppState` and every handler reads through it.
|
||||
#[derive(Clone)]
|
||||
pub struct ConfigCache {
|
||||
pool: PgPool,
|
||||
inner: Arc<RwLock<Option<Snapshot>>>,
|
||||
}
|
||||
|
||||
pub async fn get_i64(pool: &PgPool, key: &str, default: i64) -> i64 {
|
||||
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
impl ConfigCache {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the cached snapshot so the next read reloads the whole table from the DB.
|
||||
/// Call this after any write to the `config` table (admin PATCH, test reseed).
|
||||
pub fn invalidate(&self) {
|
||||
*self.inner.write().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Return the fresh snapshot if one is loaded and still within [`RELOAD_TTL`].
|
||||
fn fresh_snapshot(&self) -> Option<HashMap<String, String>> {
|
||||
let guard = self.inner.read().unwrap();
|
||||
match guard.as_ref() {
|
||||
Some(snap) if snap.loaded_at.elapsed() < RELOAD_TTL => Some(snap.values.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one key, loading the whole table into the cache on a miss/expiry. On a DB
|
||||
/// error we return `None` (callers fall back to their default) without poisoning
|
||||
/// the cache.
|
||||
async fn get_raw(&self, key: &str) -> Option<String> {
|
||||
if let Some(values) = self.fresh_snapshot() {
|
||||
return values.get(key).cloned();
|
||||
}
|
||||
|
||||
// Cache miss or stale — reload the entire table in one query.
|
||||
let rows: Vec<(String, String)> =
|
||||
match sqlx::query_as::<_, (String, String)>("SELECT key, value FROM config")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "config reload failed; using defaults for this read");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let values: HashMap<String, String> = rows.into_iter().collect();
|
||||
let result = values.get(key).cloned();
|
||||
*self.inner.write().unwrap() = Some(Snapshot {
|
||||
values,
|
||||
loaded_at: Instant::now(),
|
||||
});
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_usize(pool: &PgPool, key: &str, default: usize) -> usize {
|
||||
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
pub async fn get_str(cache: &ConfigCache, key: &str, default: &str) -> String {
|
||||
cache.get_raw(key).await.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_f64(pool: &PgPool, key: &str, default: f64) -> f64 {
|
||||
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
pub async fn get_i64(cache: &ConfigCache, key: &str, default: i64) -> i64 {
|
||||
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
pub async fn get_usize(cache: &ConfigCache, key: &str, default: usize) -> usize {
|
||||
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
pub async fn get_f64(cache: &ConfigCache, key: &str, default: f64) -> f64 {
|
||||
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Parses common truthy spellings used by both the migration seeds and the admin form.
|
||||
/// Accepts `true/false`, `1/0`, `yes/no`, `on/off` — case-insensitive. Anything else
|
||||
/// returns `default`.
|
||||
pub async fn get_bool(pool: &PgPool, key: &str, default: bool) -> bool {
|
||||
let Some(raw) = fetch_raw(pool, key).await else { return default };
|
||||
pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool {
|
||||
let Some(raw) = cache.get_raw(key).await else { return default };
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "yes" | "on" => true,
|
||||
"false" | "0" | "no" | "off" => false,
|
||||
|
||||
145
backend/src/services/disk.rs
Normal file
145
backend/src/services/disk.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! Cached view of the filesystem backing the media directory.
|
||||
//!
|
||||
//! Free/total disk space is needed on two hot paths — the per-user storage quota
|
||||
//! (checked on every upload *and* every `GET /me/quota` poll) and the admin stats
|
||||
//! endpoint. Reading it means `sysinfo::Disks::new_with_refreshed_list()`, which stats
|
||||
//! every mounted filesystem; doing that per request is wasteful for a number that
|
||||
//! barely moves. [`DiskCache`] refreshes it at most once per [`TTL`] and serves the
|
||||
//! rest from memory.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a disk reading is trusted before the next call re-stats the filesystem.
|
||||
const TTL: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DiskInfo {
|
||||
pub total: u64,
|
||||
pub free: u64,
|
||||
}
|
||||
|
||||
/// Cheap-to-clone cache of the media filesystem's total/free bytes. Lives in
|
||||
/// `AppState`.
|
||||
#[derive(Clone)]
|
||||
pub struct DiskCache {
|
||||
inner: Arc<RwLock<Option<(DiskInfo, Instant)>>>,
|
||||
}
|
||||
|
||||
impl DiskCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached `(total, free)` bytes for the filesystem that holds `media_path`.
|
||||
///
|
||||
/// Returns `None` when the mount can't be resolved — callers MUST treat that as
|
||||
/// "unknown", never "zero free". (The quota path in particular fails *open* on
|
||||
/// `None`: enforcing a 0-byte limit would lock every user out of uploading.)
|
||||
pub fn snapshot(&self, media_path: &Path) -> Option<DiskInfo> {
|
||||
if let Some((info, at)) = *self.inner.read().unwrap() {
|
||||
if at.elapsed() < TTL {
|
||||
return Some(info);
|
||||
}
|
||||
}
|
||||
let info = read_disk_for_path(media_path)?;
|
||||
*self.inner.write().unwrap() = Some((info, Instant::now()));
|
||||
Some(info)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiskCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the filesystem backing `media_path` and read its total/free bytes.
|
||||
///
|
||||
/// Snapshots the mount table via `sysinfo`, then delegates the selection to the pure
|
||||
/// [`select_disk`] so the (fiddly, edge-case-prone) matching logic is unit-testable
|
||||
/// without touching the real filesystem.
|
||||
fn read_disk_for_path(media_path: &Path) -> Option<DiskInfo> {
|
||||
let disks = sysinfo::Disks::new_with_refreshed_list();
|
||||
let mounts: Vec<(String, u64, u64)> = disks
|
||||
.iter()
|
||||
.map(|d| {
|
||||
(
|
||||
d.mount_point().to_string_lossy().to_string(),
|
||||
d.total_space(),
|
||||
d.available_space(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
select_disk(&mounts, &media_path.to_string_lossy())
|
||||
}
|
||||
|
||||
/// Pick the filesystem for `media_path` from a `(mount_point, total, free)` table.
|
||||
///
|
||||
/// Chooses the **longest** mount point that is a prefix of `media_path` (the most
|
||||
/// specific filesystem) rather than the first match — otherwise the root `/` mount,
|
||||
/// which prefixes every absolute path, could shadow a dedicated `/media` volume. Falls
|
||||
/// back to `/` when nothing prefixes the path (e.g. a relative media path), and to
|
||||
/// `None` when even that is absent — the caller treats `None` as "unknown" and fails
|
||||
/// open on quota.
|
||||
fn select_disk(mounts: &[(String, u64, u64)], media_path: &str) -> Option<DiskInfo> {
|
||||
mounts
|
||||
.iter()
|
||||
.filter(|(mp, _, _)| media_path.starts_with(mp.as_str()))
|
||||
.max_by_key(|(mp, _, _)| mp.len())
|
||||
.or_else(|| mounts.iter().find(|(mp, _, _)| mp == "/"))
|
||||
.map(|(_, total, free)| DiskInfo {
|
||||
total: *total,
|
||||
free: *free,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::select_disk;
|
||||
|
||||
#[test]
|
||||
fn picks_longest_matching_mount() {
|
||||
// Both "/" and "/media" prefix the path; the dedicated volume must win.
|
||||
let mounts = vec![
|
||||
("/".to_string(), 100, 40),
|
||||
("/media".to_string(), 200, 150),
|
||||
];
|
||||
let d = select_disk(&mounts, "/media/originals/x.jpg").unwrap();
|
||||
assert_eq!((d.total, d.free), (200, 150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_root_when_no_specific_mount_matches() {
|
||||
let mounts = vec![
|
||||
("/".to_string(), 100, 40),
|
||||
("/media".to_string(), 200, 150),
|
||||
];
|
||||
// "/var/lib" is only prefixed by "/".
|
||||
let d = select_disk(&mounts, "/var/lib/data").unwrap();
|
||||
assert_eq!((d.total, d.free), (100, 40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_uses_root_fallback() {
|
||||
let mounts = vec![("/".to_string(), 100, 40)];
|
||||
// A relative path prefixes nothing, so the explicit "/" fallback applies.
|
||||
let d = select_disk(&mounts, "media/originals").unwrap();
|
||||
assert_eq!((d.total, d.free), (100, 40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_when_no_mount_matches_and_no_root() {
|
||||
// No "/" present and nothing prefixes the relative path → unknown (fail-open).
|
||||
let mounts = vec![("/data".to_string(), 100, 40)];
|
||||
assert!(select_disk(&mounts, "relative/path").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_on_empty_mount_table() {
|
||||
assert!(select_disk(&[], "/media/x").is_none());
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,23 @@ async fn run_zip_export(
|
||||
|
||||
// ── HTML viewer export ──────────────────────────────────────────────────────
|
||||
|
||||
/// Where a media entry's bytes come from at ZIP-writing time. Derived variants
|
||||
/// (thumbnails, compressed images) are staged under the temp dir; original-fidelity
|
||||
/// variants (videos, small images) are streamed straight from the source on disk so
|
||||
/// the export never transiently doubles disk usage by copying large files to temp.
|
||||
enum MediaSource {
|
||||
Temp(PathBuf),
|
||||
Original(PathBuf),
|
||||
}
|
||||
|
||||
impl MediaSource {
|
||||
fn path(&self) -> &Path {
|
||||
match self {
|
||||
MediaSource::Temp(p) | MediaSource::Original(p) => p,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_html_export(
|
||||
event_id: Uuid,
|
||||
event_name: &str,
|
||||
@@ -221,6 +238,9 @@ async fn run_html_export(
|
||||
|
||||
// 3. Process media and build post data
|
||||
let mut viewer_posts: Vec<ViewerPost> = Vec::new();
|
||||
// (zip entry name under media/, where its bytes come from). Built here, streamed
|
||||
// into the ZIP in step 5 — so we also know the exact file count without a rescan.
|
||||
let mut media_manifest: Vec<(String, MediaSource)> = Vec::new();
|
||||
|
||||
for (i, row) in uploads.iter().enumerate() {
|
||||
let src = media_path.join(&row.original_path);
|
||||
@@ -231,8 +251,10 @@ async fn run_html_export(
|
||||
let is_video = row.mime_type.starts_with("video/");
|
||||
let id_str = row.id.to_string();
|
||||
|
||||
// Generate thumbnail and full variant
|
||||
let (thumb_name, full_name) = if is_video {
|
||||
// Generate thumbnail and full variant. `full_source` says where the full-res
|
||||
// bytes come from at ZIP time — for videos and small images that's the original
|
||||
// on disk (streamed directly, never copied to temp).
|
||||
let (thumb_name, full_name, full_source) = if is_video {
|
||||
let thumb = format!("{id_str}_thumb.jpg");
|
||||
let full_ext = ext_from_path(&row.original_path);
|
||||
let full = format!("{id_str}.{full_ext}");
|
||||
@@ -259,14 +281,13 @@ async fn run_html_export(
|
||||
Ok(output) if output.status.success() => {}
|
||||
_ => {
|
||||
tracing::warn!("ffmpeg thumbnail failed for upload {}, skipping thumb", row.id);
|
||||
// Create empty thumb entry — viewer handles missing thumbs gracefully
|
||||
// Missing thumb entry — viewer handles missing thumbs gracefully.
|
||||
}
|
||||
}
|
||||
|
||||
// Copy video as-is
|
||||
tokio::fs::copy(&src, media_tmp.join(&full)).await?;
|
||||
|
||||
(thumb, full)
|
||||
// Stream the video full-res straight from the original at ZIP time — no
|
||||
// copy to temp (that used to transiently double disk usage per video).
|
||||
(thumb, full, MediaSource::Original(src.clone()))
|
||||
} else {
|
||||
let thumb = format!("{id_str}_thumb.jpg");
|
||||
let ext = ext_from_path(&row.original_path);
|
||||
@@ -291,13 +312,12 @@ async fn run_html_export(
|
||||
tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id);
|
||||
}
|
||||
|
||||
// Full variant: compress if >5MB, otherwise copy original
|
||||
// Full variant: compress to temp if >5MB, otherwise stream the original
|
||||
// as-is (no temp copy).
|
||||
let src_meta = tokio::fs::metadata(&src).await?;
|
||||
let full_path = media_tmp.join(&full);
|
||||
|
||||
if src_meta.len() > 5_000_000 {
|
||||
// Resize to max 2000px
|
||||
let full_source = if src_meta.len() > 5_000_000 {
|
||||
let src_clone = src.clone();
|
||||
let full_path = media_tmp.join(&full);
|
||||
let full_path_clone = full_path.clone();
|
||||
|
||||
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
@@ -311,17 +331,31 @@ async fn run_html_export(
|
||||
})
|
||||
.await?;
|
||||
|
||||
if let Err(e) = compress_result {
|
||||
tracing::warn!("compression failed for upload {}, copying original: {e:#}", row.id);
|
||||
tokio::fs::copy(&src, &full_path).await?;
|
||||
match compress_result {
|
||||
Ok(()) => MediaSource::Temp(full_path),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"compression failed for upload {}, using original: {e:#}",
|
||||
row.id
|
||||
);
|
||||
MediaSource::Original(src.clone())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tokio::fs::copy(&src, &full_path).await?;
|
||||
}
|
||||
MediaSource::Original(src.clone())
|
||||
};
|
||||
|
||||
(thumb, full)
|
||||
(thumb, full, full_source)
|
||||
};
|
||||
|
||||
// Register this post's two media entries. Thumbnails always come from temp
|
||||
// (they're freshly generated); the full variant's source was decided above.
|
||||
media_manifest.push((
|
||||
thumb_name.clone(),
|
||||
MediaSource::Temp(media_tmp.join(&thumb_name)),
|
||||
));
|
||||
media_manifest.push((full_name.clone(), full_source));
|
||||
|
||||
// Build comments for this upload
|
||||
let post_comments: Vec<ViewerComment> = comments
|
||||
.iter()
|
||||
@@ -409,27 +443,23 @@ async fn run_html_export(
|
||||
|
||||
update_progress(pool, event_id, "html", 78).await;
|
||||
|
||||
// Write media files from temp directory
|
||||
let mut media_entries = tokio::fs::read_dir(&media_tmp).await?;
|
||||
let mut file_count = 0u32;
|
||||
// Write media files from the manifest built in step 3. Thumbnails and derived
|
||||
// image variants stream from temp; video and small-image full variants stream
|
||||
// straight from the original on disk. Sources that don't exist (e.g. a thumb
|
||||
// whose ffmpeg step failed) are skipped — the viewer tolerates gaps.
|
||||
let file_total = media_manifest.len().max(1) as f32;
|
||||
let mut files_written = 0u32;
|
||||
|
||||
// Count files first
|
||||
{
|
||||
let mut counter = tokio::fs::read_dir(&media_tmp).await?;
|
||||
while counter.next_entry().await?.is_some() {
|
||||
file_count += 1;
|
||||
for (name, source) in &media_manifest {
|
||||
let path = source.path();
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let file_total = file_count.max(1) as f32;
|
||||
|
||||
while let Some(dir_entry) = media_entries.next_entry().await? {
|
||||
let filename = dir_entry.file_name();
|
||||
let entry_name = format!("media/{}", filename.to_string_lossy());
|
||||
|
||||
let entry_name = format!("media/{name}");
|
||||
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
|
||||
let mut zip_entry = zip.write_entry_stream(builder).await?;
|
||||
let mut f = tokio::fs::File::open(dir_entry.path()).await?.compat();
|
||||
let mut f = tokio::fs::File::open(path).await?.compat();
|
||||
fcopy(&mut f, &mut zip_entry).await?;
|
||||
zip_entry.close().await?;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod compression;
|
||||
pub mod config;
|
||||
pub mod disk;
|
||||
pub mod export;
|
||||
pub mod jobs;
|
||||
pub mod maintenance;
|
||||
|
||||
Reference in New Issue
Block a user