Files
Mangalord/backend/src/storage/mod.rs
MechaCat02 4e154434a1 fix(upload): stream chapter pages to storage instead of buffering the whole chapter
The chapter upload handler read every `page` part fully into a Vec before
writing any, so peak memory was the whole chapter (bounded only by the
200 MiB body limit and amplified by concurrent uploads). It also accepted
an unbounded number of pages.

Stream each page part to a `staging/{upload_id}/…` key as it arrives — at
most one page's bytes are held at a time — then, once the chapter row (and
its id) exists, promote each staged blob to its final key via a new
`Storage::rename` (LocalStorage: fs rename; default impl: stream+delete for
future backends). Finalization is all-or-nothing: on any failure the DB
rolls back and both staged and already-finalized blobs are cleaned up.

Add MAX_PAGES_PER_CHAPTER (UploadConfig, default 2000, 0 = disabled),
rejecting an over-cap upload with 413 before any DB write. Also document
the crawler-side CRAWLER_MAX_IMAGES_PER_CHAPTER (added earlier) in
.env.example + docker-compose so the env-coverage test passes.

Tests: LocalStorage rename unit tests; a 413 over-cap upload test; existing
rollback + happy-path upload tests still green (the fault-injecting storage
counts put/put_stream, so mid-upload failure still rolls back).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:33:47 +02:00

123 lines
5.1 KiB
Rust

//! Pluggable blob storage.
//!
//! Handlers depend on the `Storage` trait, never on a concrete backend.
//! Add new backends (S3, GCS, …) as new impls in this module and wire
//! them up in `app::build` based on config.
mod local;
use std::io;
use std::pin::Pin;
use std::path::Path;
use async_trait::async_trait;
use bytes::Bytes;
use futures_core::Stream;
pub use local::LocalStorage;
#[derive(thiserror::Error, Debug)]
pub enum StorageError {
#[error(transparent)]
Io(#[from] io::Error),
#[error("not found")]
NotFound,
#[error("invalid storage key")]
BadKey,
}
/// Boxed byte stream returned by `Storage::get_stream` so the trait stays
/// object-safe regardless of the concrete reader behind it.
pub type ByteStream = Pin<Box<dyn Stream<Item = io::Result<Bytes>> + Send>>;
/// Boxed byte stream accepted by `Storage::put_stream`. The item type
/// is fallible so a producer (e.g. an HTTP body) can surface a transport
/// error mid-stream without breaking the trait shape; the storage impl
/// is responsible for not installing a partial blob on such an error.
pub type PutByteStream<'a> =
Pin<Box<dyn Stream<Item = Result<Bytes, StorageError>> + Send + 'a>>;
pub struct StreamingFile {
pub stream: ByteStream,
pub size_bytes: u64,
}
#[async_trait]
pub trait Storage: Send + Sync {
async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError>;
/// Stream a blob to storage without holding the entire body in
/// memory. The chapter-content download path uses this so peak
/// memory stays at one chunk per concurrent dispatch (not one full
/// page image). The contract is atomic: a stream that errors mid-way
/// must leave nothing visible at `key` — implementations should
/// write to a temp location and rename only on the successful
/// drain. Returns the total bytes written on success.
///
/// The default implementation buffers the stream into memory and
/// calls `put`, so backends without a native streaming write still
/// satisfy the contract (at the cost of peak memory). LocalStorage
/// overrides this to do a temp-file rename; a future S3Storage
/// would override with a multi-part upload.
async fn put_stream(
&self,
key: &str,
mut stream: PutByteStream<'_>,
) -> Result<u64, StorageError> {
use futures_util::StreamExt as _;
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
buf.extend_from_slice(&chunk);
}
let len = buf.len() as u64;
self.put(key, &buf).await?;
Ok(len)
}
/// Reads the entire blob into memory. Convenient for small assets
/// (covers, thumbnails). For pages and other large blobs, use
/// `get_stream` so axum can pipe bytes straight to the client.
async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError>;
async fn get_stream(&self, key: &str) -> Result<StreamingFile, StorageError>;
async fn delete(&self, key: &str) -> Result<(), StorageError>;
async fn exists(&self, key: &str) -> Result<bool, StorageError>;
/// Move a blob from `from` to `to`, overwriting any existing blob at
/// `to`. Returns `NotFound` if `from` doesn't exist. The chapter
/// upload path uses this to promote a staged page to its final,
/// chapter-scoped key once the chapter row (and thus its id) exists —
/// so pages can be streamed to storage as their multipart parts
/// arrive, without buffering the whole chapter in memory.
///
/// The default implementation streams `from` to `to` and deletes the
/// source, so backends without a native move still satisfy the
/// contract. LocalStorage overrides it with a filesystem rename (an
/// atomic metadata op within a mount); a future S3Storage would
/// override with a server-side copy + delete.
async fn rename(&self, from: &str, to: &str) -> Result<(), StorageError> {
use futures_util::StreamExt as _;
let StreamingFile { stream, .. } = self.get_stream(from).await?;
let mapped: PutByteStream<'_> = Box::pin(stream.map(|r| r.map_err(StorageError::Io)));
self.put_stream(to, mapped).await?;
self.delete(from).await?;
Ok(())
}
/// Size in bytes of the blob at `key`, `NotFound` when it doesn't
/// exist. Cheap metadata lookup (local: `fs::metadata`; a future
/// `S3Storage`: HEAD object). Used by the cover-capture path and the
/// one-shot storage-size backfill — never on the hot read path, which
/// reads the stored `size_bytes` columns. Required (no default) so a
/// new backend can't silently skip it.
async fn size(&self, key: &str) -> Result<u64, StorageError>;
/// Filesystem path the backend is rooted at, when introspectable.
/// Returns `None` for backends that aren't a local filesystem (e.g.
/// a future `S3Storage`). The admin system endpoint uses this to
/// statvfs the data dir; backends that return `None` get a `disk:
/// null` payload instead of fabricated numbers.
fn local_root(&self) -> Option<&Path> {
None
}
}