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
+79 -9
View File
@@ -11,9 +11,9 @@
use crate::interfaces::middleware::auth::AuthUser;
use axum::{
Router,
body::Bytes,
body::Body,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
http::{HeaderMap, StatusCode, Request},
response::{Html, IntoResponse, Response},
routing::{get, post},
};
@@ -153,13 +153,22 @@ async fn get_file(
}
/// POST /wopi/files/{file_id}/contents — PutFile
///
/// **Streaming implementation**: the request body is spooled to a temp file
/// with incremental SHA-256 hashing. Peak RAM usage is ~256 KB regardless
/// of file size (previously buffered the entire body as `Bytes`).
async fn put_file(
Path(file_id): Path<String>,
Query(token_query): Query<WopiTokenQuery>,
headers: HeaderMap,
State(state): State<WopiState>,
body: Bytes,
req: Request<Body>,
) -> Response {
use http_body_util::BodyStream;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
use tokio_stream::StreamExt;
let claims = match state
.token_service
.validate_token(&token_query.access_token)
@@ -197,7 +206,7 @@ async fn put_file(
}
}
// Get file path for update_file
// Get file metadata for the path
let file = match state
.app_state
.applications
@@ -209,14 +218,75 @@ async fn put_file(
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
// Save the file content using path-based update
match state
// ── Streaming spool: body → temp file + incremental SHA-256 ──
let temp_file = match tempfile::NamedTempFile::new() {
Ok(f) => f,
Err(e) => {
tracing::error!("WOPI PutFile: failed to create temp file: {}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let temp_path = temp_file.path().to_path_buf();
let mut file_out = match tokio::fs::File::create(&temp_path).await {
Ok(f) => f,
Err(e) => {
tracing::error!("WOPI PutFile: failed to open temp file: {}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let content_type = file.mime_type.clone();
let mut hasher = Sha256::new();
let mut total_bytes: u64 = 0;
let mut stream = BodyStream::new(req.into_body());
while let Some(frame_result) = stream.next().await {
let frame = match frame_result {
Ok(f) => f,
Err(e) => {
let _ = tokio::fs::remove_file(&temp_path).await;
tracing::error!("WOPI PutFile: body read error: {}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
if let Some(chunk) = frame.data_ref() {
total_bytes += chunk.len() as u64;
hasher.update(chunk);
if let Err(e) = file_out.write_all(chunk).await {
let _ = tokio::fs::remove_file(&temp_path).await;
tracing::error!("WOPI PutFile: temp write error: {}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}
}
if let Err(e) = file_out.flush().await {
let _ = tokio::fs::remove_file(&temp_path).await;
tracing::error!("WOPI PutFile: flush error: {}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
drop(file_out);
let hash = hex::encode(hasher.finalize());
// ── Atomic store: temp file → dedup blob + DB metadata update ──
let result = state
.app_state
.applications
.file_upload_service
.update_file(&file.path, &body)
.await
{
.update_file_streaming(
&file.path,
&temp_path,
total_bytes,
&content_type,
Some(hash),
)
.await;
// Clean up temp file (may already be moved by dedup, ignore error)
let _ = tokio::fs::remove_file(&temp_path).await;
match result {
Ok(_) => StatusCode::OK.into_response(),
Err(e) => {
tracing::error!("WOPI PutFile failed: {}", e);