perf: eliminate Vec<u8> buffer paths — all uploads now stream to disk

Issue #4 (HIGH): save_file(Vec<u8>) and update_file_content(Vec<u8>)
accepted up to 10 MB of contiguous memory per request. While the main
upload paths already used streaming, the WebDAV compat methods
(create_file, update_file) and the empty-file handler still used the
buffered path, creating a .to_vec() copy.

Changes:
- FileWritePort trait: remove save_file(Vec<u8>) and
  update_file_content(Vec<u8>) — only streaming variants remain
- FileUploadUseCase trait: remove upload_file(Vec<u8>)
- file_upload_service.rs: create_file() and update_file() now spool
  &[u8] to NamedTempFile + Sha256::digest, then delegate to streaming
  path (save_file_from_temp / update_file_streaming)
- file_handler.rs: empty file uploads use upload_file_streaming with
- FileBlobWriteRepository: remove save_file and update_file_content impls
- StubFileWritePort, StubFileUploadUseCase, MockFileRepository: remove
  corresponding dead method impls

Impact: impossible to accidentally use a buffered upload path. All
content goes through streaming with ~256 KB peak RAM. -166 LOC.
This commit is contained in:
Dionisio
2026-02-25 23:41:16 +01:00
parent f9dde6ffff
commit 5a1959bf23
7 changed files with 67 additions and 233 deletions
@@ -182,88 +182,6 @@ impl FileBlobWriteRepository {
#[async_trait]
impl FileWritePort for FileBlobWriteRepository {
async fn save_file(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
content: Vec<u8>,
) -> Result<File, DomainError> {
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
let size = content.len() as i64;
// Store content in blob store
let dedup_result = self
.dedup
.store_bytes(&content, Some(content_type.clone()))
.await?;
let blob_hash = dedup_result.hash().to_string();
// Insert file metadata — if this fails, compensate by removing the blob ref
let row = match sqlx::query_as::<_, (String, i64, i64)>(
r#"
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
VALUES ($1, $2::uuid, $3, $4, $5, $6)
RETURNING id::text,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
"#,
)
.bind(&name)
.bind(&folder_id)
.bind(&user_id)
.bind(&blob_hash)
.bind(size)
.bind(&content_type)
.fetch_one(self.pool.as_ref())
.await
{
Ok(row) => row,
Err(e) => {
// ── Compensation: undo the blob ref so it doesn't become orphaned ──
if let Err(rollback_err) = self.dedup.remove_reference(&blob_hash).await {
tracing::error!(
"Blob orphaned after failed INSERT — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
if let sqlx::Error::Database(ref db_err) = e
&& db_err.code().as_deref() == Some("23505")
{
return Err(DomainError::already_exists(
"File",
format!("{name} already exists in folder"),
));
}
return Err(DomainError::internal_error(
"FileBlobWrite",
format!("insert: {e}"),
));
}
};
tracing::info!(
"💾 BLOB WRITE: {} ({} bytes, hash: {})",
name,
size,
&blob_hash[..12]
);
let folder_path = self.lookup_folder_path(folder_id.as_deref()).await?;
Self::row_to_file(
row.0,
name,
folder_id,
folder_path,
size,
content_type,
row.1,
row.2,
Some(user_id),
)
}
async fn save_file_from_temp(
&self,
name: String,
@@ -534,19 +452,6 @@ impl FileWritePort for FileBlobWriteRepository {
Ok(())
}
async fn update_file_content(
&self,
file_id: &str,
content: Vec<u8>,
) -> Result<(), DomainError> {
// Store new content first (blob store is idempotent)
let new_size = content.len() as i64;
let dedup_result = self.dedup.store_bytes(&content, None).await?;
let new_hash = dedup_result.hash().to_string();
self.swap_blob_hash(file_id, &new_hash, new_size).await
}
async fn update_file_content_from_temp(
&self,
file_id: &str,