perf: thumbnail semaphore, WOPI streaming, store_bytes guard, spawn_blocking SHA-256

- Issue #1: Add Semaphore(4) + 50MP resolution guard to thumbnail_service
  Bounds peak RAM from 4.8GB (50 uploads) to 384MB (4 concurrent decodes)
- Issue #3: Migrate WOPI PutFile from Bytes to streaming temp file + SHA-256
  RAM per WOPI PUT: ~100MB → ~256KB regardless of file size
- Issue #3: Add 10MB guard in dedup store_bytes (defense-in-depth)
- Issue #5: Move chunked upload assembly to spawn_blocking (sync I/O)
  Frees Tokio workers during SHA-256 hashing (~130ms for 500MB)
- Clean up unused tokio imports (OpenOptions, BufWriter)
This commit is contained in:
Dionisio
2026-02-24 16:11:52 +01:00
parent 71c2cb5edb
commit 6aa38d0d24
3 changed files with 154 additions and 11 deletions
@@ -155,15 +155,33 @@ impl DedupService {
// ── Core store operations ────────────────────────────────────
/// Maximum payload accepted by `store_bytes`. Anything larger
/// should use `store_from_file` (streaming — constant RAM).
const MAX_STORE_BYTES: usize = 10 * 1024 * 1024; // 10 MB
/// Store content with deduplication (from bytes).
///
/// Uses `SELECT … FOR UPDATE` + `INSERT … ON CONFLICT` for atomic
/// upsert — completely TOCTOU-free.
///
/// **Guard**: rejects payloads >10 MB. Large content must go through
/// `store_from_file` which streams from disk with constant RAM.
pub async fn store_bytes(
&self,
content: &[u8],
content_type: Option<String>,
) -> Result<DedupResultDto, DomainError> {
if content.len() > Self::MAX_STORE_BYTES {
return Err(DomainError::internal_error(
"Dedup",
format!(
"store_bytes called with {} bytes (max {}). Use store_from_file for large content.",
content.len(),
Self::MAX_STORE_BYTES
),
));
}
let size = content.len() as u64;
let hash = Self::hash_bytes(content);