Files
Oxicloud/src/infrastructure/services/s3_blob_backend.rs
T
Claude cd4c62042a perf: keyset/LATERAL SQL shapes, auth+blob-cache single-flight, spool buffers, DTO interning
Round 3 of benchmark-gated optimizations (benches/ROUND3.md; every change
gated by a before/after benchmark — an AFTER that did not beat its BEFORE
was to be rolled back; none needed it. Equivalence gates assert identical
row sequences / byte-identical output on every behavior-preserving rewrite):

DB hot paths (local PG16, EXPLAIN-verified):
- Web-UI listing (list_resources_paged): cursor pushed INSIDE the
  folders/files UNION-ALL branches as sargable row-value comparisons with
  per-branch ORDER/LIMIT + two partial expression indexes
  (folder_id, LOWER(name), id). 20k-entry folder: 26.6 -> 1.3 ms/page
  (19.5x); other sort modes at parity or better. New migration
  20260918000000. [benches/LISTING-KEYSET.md section in ROUND3]
- Photos timeline (list_media_files): per-drive CROSS JOIN LATERAL top-N
  on the timeline index, joins moved above the top-N. 50k-photo library:
  97.4 -> 1.6 ms/page (55.7x). The old "LIMIT stops the scan early"
  comment was refuted by EXPLAIN.
- PROPFIND sub-folders (both DAV surfaces): keyset list_folders_batch off
  idx_folders_unique_name replaces COUNT(*) OVER() + LIMIT/OFFSET
  (5k dirs: 79.7 -> 17.9 ms full walk, 4.5x).

Concurrency:
- Basic-auth cache single-flight (moka try_get_with): 8 concurrent DAV
  connections at TTL expiry paid 8 Argon2id runs (2.6 s CPU + 8x64 MiB);
  now 1 (300 ms). Failed verifications remain uncached.
- CachedBlobBackend per-hash single-flight + unique tmp names: 16
  concurrent cold readers = 16 full remote downloads racing truncating
  writes on ONE deterministic .tmp (corruptible cache); now 1 download
  (16x less egress, 2.8x wall on a shared link) and torn files can never
  be renamed into the cache.

I/O and allocations:
- Chunk-assembly reads 64K -> 512K buffers (2.3x, 8x fewer syscalls);
  chunk-spool writes via BufWriter 512K (5.6x, 32x fewer syscalls).
- S3/Azure put_blob_from_bytes_unsynced overrides: dedup settle no longer
  pays a HEAD probe per new chunk (2 RTT -> 1, 1.8x); Azure stops copying
  every chunk (Bytes -> Body, -0.44 ms - 4 MiB alloc per 4 MiB chunk).
- Entity->DTO mapping: Arc<str> interning of closed-set display fields +
  common MIMEs, 1-alloc etag/size formatting, FolderDto moves instead of
  clones. File row: 11 -> 4 allocs; folder row: 11.8 -> 1 (2.1x faster).
- CardDAV REPORT: deleted dead per-contact vCard pre-generation and the
  O(N^2) uid scan whose result was discarded (5k contacts: 55.7 -> 5.7 ms,
  9.8x); byte-identical XML asserted.
- Search-results cache: byte weigher + 32 MiB budget
  (OXICLOUD_SEARCH_CACHE_MAX_BYTES) replaces the 1000-ENTRY cap that let
  ~300 MiB of enriched rows sit in RSS; read latency parity.
- Dropped aws-config + aws-smithy-types (zero references; -82 dep-graph
  nodes, three SDK stacks gone from every build). tokio "process" is now
  an explicit feature (was enabled transitively by aws-config).

Frontend:
- Cached Intl.DateTimeFormat keyed by (locale, options) in formatDate and
  4 sibling callsites: 20k dates 2612 -> 51 ms (51.6x); vitest gate
  asserts output identity across locales and a 3x floor.

Validation: cargo fmt + clippy --all-features --all-targets -D warnings
clean; 518 unit + 548 integration-cfg tests green; new-shape endpoints
smoke-tested end-to-end over HTTP (all 5 listing sort modes with cursor
walks, WebDAV PROPFIND Depth-1, photos timeline, Basic-auth DAV login);
frontend npm run check clean, new vitest gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBsU2qEzny3A8WQUEuMNCr
2026-07-17 11:10:27 +00:00

425 lines
13 KiB
Rust

//! S3-Compatible Blob Backend — stores blobs in any S3-compatible object store.
//!
//! Supports AWS S3, Backblaze B2, Cloudflare R2, MinIO, DigitalOcean Spaces,
//! Wasabi, and any other service that implements the S3 API.
use aws_sdk_s3::primitives::ByteStream;
use bytes::Bytes;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use tokio::fs;
use tokio_util::io::ReaderStream;
use crate::application::ports::blob_storage_ports::{
BlobStorageBackend, BlobStream, StorageHealthStatus,
};
use crate::common::config::S3StorageConfig;
use crate::domain::errors::{DomainError, ErrorKind};
/// S3-compatible blob storage backend.
///
/// Blobs are stored as objects with key `{2-char-prefix}/{hash}.blob`,
/// mirroring the local filesystem layout for consistency.
pub struct S3BlobBackend {
client: aws_sdk_s3::Client,
bucket: String,
}
impl S3BlobBackend {
/// Build a new S3 backend from configuration.
///
/// Supports custom endpoints for non-AWS providers (Backblaze B2,
/// MinIO, Cloudflare R2, etc.).
pub fn new(config: &S3StorageConfig) -> Self {
let credentials = aws_sdk_s3::config::Credentials::new(
&config.access_key,
&config.secret_key,
None,
None,
"oxicloud",
);
let mut builder = aws_sdk_s3::config::Builder::new()
.region(aws_sdk_s3::config::Region::new(config.region.clone()))
.credentials_provider(credentials)
.behavior_version_latest();
if let Some(ref endpoint) = config.endpoint_url {
builder = builder.endpoint_url(endpoint);
}
if config.force_path_style {
builder = builder.force_path_style(true);
}
let client = aws_sdk_s3::Client::from_conf(builder.build());
Self {
client,
bucket: config.bucket.clone(),
}
}
/// Compute the S3 object key for a given hash.
fn object_key(hash: &str) -> String {
let prefix = &hash[0..2];
format!("{}/{}.blob", prefix, hash)
}
}
impl BlobStorageBackend for S3BlobBackend {
fn initialize(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
Box::pin(async move {
// Verify bucket exists and is accessible
self.client
.head_bucket()
.bucket(&self.bucket)
.send()
.await
.map_err(|e| {
DomainError::internal_error(
"S3",
format!("Cannot access bucket '{}': {}", self.bucket, e),
)
})?;
tracing::info!("S3 blob backend initialized: bucket={}", self.bucket);
Ok(())
})
}
fn put_blob(
&self,
hash: &str,
source_path: &Path,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
let source_path = source_path.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
// Check if object already exists (idempotent)
let exists = self
.client
.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.is_ok();
if exists {
// Blob already in S3 — remove local source and return size
let file_size = fs::metadata(&source_path)
.await
.map_err(|e| {
DomainError::internal_error(
"S3",
format!("Failed to stat source file: {}", e),
)
})?
.len();
let _ = fs::remove_file(&source_path).await;
return Ok(file_size);
}
// Upload from local file
let body = ByteStream::from_path(&source_path).await.map_err(|e| {
DomainError::internal_error("S3", format!("Failed to read source file: {}", e))
})?;
let file_size = fs::metadata(&source_path)
.await
.map_err(|e| {
DomainError::internal_error("S3", format!("Failed to stat source file: {}", e))
})?
.len();
self.client
.put_object()
.bucket(&self.bucket)
.key(&key)
.body(body)
.send()
.await
.map_err(|e| {
DomainError::internal_error(
"S3",
format!("Failed to upload blob {}: {}", hash, e),
)
})?;
// Clean up local source after successful upload
let _ = fs::remove_file(&source_path).await;
Ok(file_size)
})
}
fn put_blob_from_bytes(
&self,
hash: &str,
data: Bytes,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
let size = data.len() as u64;
// Idempotent: skip if already exists
if self
.client
.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.is_ok()
{
return Ok(size);
}
let body = ByteStream::from(data);
self.client
.put_object()
.bucket(&self.bucket)
.key(&key)
.body(body)
.send()
.await
.map_err(|e| {
DomainError::internal_error(
"S3",
format!("Failed to upload blob {}: {}", hash, e),
)
})?;
Ok(size)
})
}
/// Dedup settle path: PUT unconditionally. Keys are content-addressed
/// (BLAKE3), so a re-PUT writes identical bytes — overwrite-safe
/// idempotency without the HEAD probe `put_blob_from_bytes` pays. The
/// dedup layer already filtered out chunks the database knows about,
/// so the probe was a pure extra round-trip on every NEW chunk of
/// every upload (2 RTTs -> 1, benches/S3-PUT.md).
fn put_blob_from_bytes_unsynced(
&self,
hash: &str,
data: Bytes,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
let size = data.len() as u64;
self.client
.put_object()
.bucket(&self.bucket)
.key(&key)
.body(ByteStream::from(data))
.send()
.await
.map_err(|e| {
DomainError::internal_error(
"S3",
format!("Failed to upload blob {}: {}", hash, e),
)
})?;
Ok(size)
})
}
fn get_blob_stream(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let hash = hash.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
let output = self
.client
.get_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"S3",
format!("Failed to get blob {}: {}", hash, e),
)
})?;
// Convert S3 ByteStream into a Stream<Item = Result<Bytes, io::Error>>
// via AsyncRead adapter
let reader = output.body.into_async_read();
Ok(Box::pin(ReaderStream::with_capacity(reader, 256 * 1024)) as BlobStream)
})
}
fn get_blob_range_stream(
&self,
hash: &str,
start: u64,
end: Option<u64>,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let hash = hash.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
let range = match end {
Some(end_pos) => format!("bytes={}-{}", start, end_pos.saturating_sub(1)),
None => format!("bytes={}-", start),
};
let output = self
.client
.get_object()
.bucket(&self.bucket)
.key(&key)
.range(range)
.send()
.await
.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"S3",
format!("Failed to get blob range {}: {}", hash, e),
)
})?;
let reader = output.body.into_async_read();
Ok(Box::pin(ReaderStream::with_capacity(reader, 256 * 1024)) as BlobStream)
})
}
fn delete_blob(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
// S3 DeleteObject is already idempotent (returns 204 even if not found)
self.client
.delete_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.map_err(|e| {
DomainError::internal_error(
"S3",
format!("Failed to delete blob {}: {}", hash, e),
)
})?;
Ok(())
})
}
fn blob_exists(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<bool, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
match self
.client
.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
{
Ok(_) => Ok(true),
Err(e) => {
// Check if it's a 404 (not found) vs an actual error
let service_err = e.into_service_error();
if service_err.is_not_found() {
Ok(false)
} else {
Err(DomainError::internal_error(
"S3",
format!("Failed to check blob {}: {}", hash, service_err),
))
}
}
}
})
}
fn blob_size(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
let output = self
.client
.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"S3",
format!("Failed to stat blob {}: {}", hash, e),
)
})?;
Ok(output.content_length().unwrap_or(0) as u64)
})
}
fn health_check(
&self,
) -> Pin<
Box<dyn std::future::Future<Output = Result<StorageHealthStatus, DomainError>> + Send + '_>,
> {
Box::pin(async move {
match self.client.head_bucket().bucket(&self.bucket).send().await {
Ok(_) => Ok(StorageHealthStatus {
connected: true,
backend_type: "s3".to_string(),
message: format!("S3 bucket '{}' is accessible", self.bucket),
available_bytes: None,
}),
Err(e) => Ok(StorageHealthStatus {
connected: false,
backend_type: "s3".to_string(),
message: format!("S3 bucket '{}' is not accessible: {}", self.bucket, e),
available_bytes: None,
}),
}
})
}
fn backend_type(&self) -> &'static str {
"s3"
}
/// Remote object store: overlap chunk GETs to hide per-request latency.
fn read_prefetch(&self) -> usize {
8
}
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
None // Remote backend — no local path
}
}