Stream uploads directly into the CDC chunk store (no spool, single write)
Every upload surface previously wrote each byte to disk twice: the HTTP body was spooled to a temp file (or assembled from chunk parts), then mmap-re-read for FastCDC analysis, and finally the new chunks were written to the blob backend. CDC could not start until the last byte arrived, so large uploads paid receive + reread + rewrite latency. The dedup engine now chunks, hashes and settles the stream WHILE it arrives (fastcdc AsyncStreamCDC + incremental BLAKE3): - Each batch of distinct chunks is pinned-or-classified by ONE `UPDATE … RETURNING` (no check-then-bump TOCTOU; pinned chunks can't be reclaimed mid-upload), and only chunks the store doesn't have are written — a full dedup hit performs zero content writes. - Durability before visibility is preserved: one batched fsync sweep, then one batched INSERT, then the manifest. Identical concurrent uploads are resolved at the manifest INSERT via ON CONFLICT (the loser releases its references and becomes a dedup hit). - A drop guard rolls back pins and surfaces written-but-unregistered chunks to GC if the request future is cancelled mid-stream. - MIME sniffing now peeks the first bytes in-flight; client-requested MD5/SHA-256 checksums are computed by a stream tee — the post-upload re-read of the assembled file is gone. All surfaces converge on the new interfaces::upload_ingest helper: REST multipart, WebDAV PUT, NextCloud PUT, WOPI PutFile, the dedup endpoint, and both chunked-upload completions (which now stream their ordered parts straight into the store instead of writing an assembled file — chunk parts persist until finalize, so completion is genuinely retryable). The legacy blob re-chunk migration streams from the backend with no spool file either. Legacy removed: store_from_file + mmap CDC analysers + temp-path plumbing through every port (pre_computed_hash, save_file_from_temp, update_file_content_from_temp), upload_spool + assembled-file assembly in both chunked services, create_file/update_file byte-slice variants (no callers), common::temp, the OXICLOUD_UPLOAD_TMPDIR config, and the memmap2 dependency. Verified end-to-end against PostgreSQL 16: 8 MB upload (26 chunks), identical re-upload (dedup hit, zero writes), 3-byte edit re-upload (26 chunks, 1 written), byte-identical downloads, Range across chunk boundaries, concurrent identical-upload race (manifest ref 2), and trash-empty reclaiming exactly the unshared chunk while the shared 25 survive for the edited file. The empty/sub-8KB multipart path found a post-EOF re-poll panic in the MIME peek (fixed with fuse + regression test). https://claude.ai/code/session_01WdNenpnujNR2sc32XVvwfS
This commit is contained in:
@@ -22,12 +22,12 @@ use crate::application::ports::file_ports::{
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::mime_detect::{filename_from_path, refine_content_type_from_file};
|
||||
use crate::common::mime_detect::filename_from_path;
|
||||
use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
||||
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
||||
use crate::interfaces::upload_spool::spool_body_to_temp;
|
||||
use crate::interfaces::upload_ingest::ingest_body_to_cas;
|
||||
|
||||
/// Extension trait to map XML write errors to `String` concisely.
|
||||
trait XmlResultExt<T> {
|
||||
@@ -575,46 +575,35 @@ async fn handle_put(
|
||||
// at 95 % loses everything.
|
||||
let max_upload = state.core.config.storage.direct_put_max_bytes;
|
||||
|
||||
// Stream the body to a temp file + incremental hash — never buffer the
|
||||
// full upload in RAM. The old `body::to_bytes` path loaded the entire
|
||||
// file (e.g. an 800 MB ISO) into anonymous memory before any dedup logic,
|
||||
// OOMKilling the process even on dedup hits. Shared with the native
|
||||
// WebDAV PUT handler; peak heap ~one frame regardless of file size.
|
||||
let spooled = spool_body_to_temp(
|
||||
req.into_body(),
|
||||
max_upload,
|
||||
state.core.config.storage.upload_temp_dir.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Detect real MIME type from the first bytes on disk (no full read).
|
||||
// Stream the body straight into the CDC chunk store — never buffer the
|
||||
// full upload in RAM and never spool it to disk. Chunking, hashing,
|
||||
// dedup checks and MIME sniffing (magic bytes off the first frames)
|
||||
// all run while the body arrives; chunks the store already has are
|
||||
// never written at all. Shared with the native WebDAV PUT handler.
|
||||
//
|
||||
// `filename` is owned so we don't hold a borrow of the `subpath` param
|
||||
// across the await (which would make the handler future non-Send).
|
||||
let filename = filename_from_path(subpath).to_string();
|
||||
let content_type =
|
||||
refine_content_type_from_file(spooled.temp.path(), &filename, &claimed_type).await;
|
||||
let ingested = ingest_body_to_cas(
|
||||
req.into_body(),
|
||||
&state.core.dedup_service,
|
||||
&filename,
|
||||
&claimed_type,
|
||||
max_upload,
|
||||
)
|
||||
.await?;
|
||||
let content_type = ingested.content_type.clone();
|
||||
|
||||
// Distinguish create (201) vs update (204) for the response status.
|
||||
let existed = file_service.get_file_by_path(&internal_path).await.is_ok();
|
||||
|
||||
// Single streaming path — handles both update and create internally,
|
||||
// passing the precomputed hash so the dedup fast path can short-circuit
|
||||
// without re-reading the file.
|
||||
// swapping the file row onto the already-ingested blob.
|
||||
let stored = upload_service
|
||||
.update_file_streaming(
|
||||
&internal_path,
|
||||
spooled.temp.path(),
|
||||
spooled.size,
|
||||
&content_type,
|
||||
Some(spooled.hash),
|
||||
oc_mtime,
|
||||
)
|
||||
.update_file_streaming(&internal_path, ingested.stored(), &content_type, oc_mtime)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to store file: {}", e)))?;
|
||||
|
||||
// dedup may have already moved the temp on a new-blob store; ignore error.
|
||||
let _ = tokio::fs::remove_file(spooled.temp.path()).await;
|
||||
|
||||
let status = if existed {
|
||||
StatusCode::NO_CONTENT
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user