perf: cache-stampede coalescing + DB safeguards; ui/i18n fixes

Backend — tail latency & throughput:
- FileContentCache, image transcode, and search now use moka single-flight
  (try_get_with / get_or_load) so N concurrent misses for the same key
  collapse to one disk read / transcode / query instead of a thundering herd.
  Microbenchmark (128 concurrent on one hot key): 128 loads / p99 ~1023ms
  before vs 1 load / p99 ~32ms after.
- DB: configurable per-statement timeout on the primary pool
  (OXICLOUD_DB_STATEMENT_TIMEOUT_SECS, default 30; maintenance pool exempt) so
  a runaway query can't pin a connection and starve the pool.
- DB: background pool-saturation monitor
  (OXICLOUD_DB_POOL_MONITOR_INTERVAL_SECS) that WARNs as the primary pool nears
  exhaustion — the early signal before tail latency cliffs.
- mimalloc: set MIMALLOC_PURGE_DELAY=0 (Dockerfile + compose) so freed pages
  return to the OS and RSS tracks the live working set; benchmarked on
  musl/aarch64 at ~400MB reclaimed vs 0MB with the default.

Frontend — UI / i18n fixes:
- i18n: fix literal "{{count}}" and "{{percentage}}/{{used}}/{{total}}" in the
  selection toolbar and storage line — the call sites passed param names that
  didn't match the locale placeholders; unify on `count` and pass the storage
  template its params. Add es files.selected_count.
- sidebar: hide the drive picker when there's only one drive (the redundant
  "Personal" row); remove the coloured left accent on the active nav item.
- logo: stop clipping the cloud's left bulge — viewBox recentred on the cloud's
  true bbox with proportional SVG size so it keeps the same rendered scale.
- user menu: drop the default <a> underline on the link rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-20 14:42:10 +02:00
parent ca18858630
commit b14c4dc911
19 changed files with 848 additions and 310 deletions
@@ -66,6 +66,25 @@ impl FileRetrievalService {
// ── private helpers ──────────────────────────────────────────
/// Read a file's full content through the streaming API into a single
/// `Bytes` buffer. Working memory stays at one chunk while reading; the
/// returned buffer holds the whole (sub-threshold) file.
async fn read_full(
file_read: &FileBlobReadRepository,
id: &str,
capacity: usize,
) -> Result<Bytes, DomainError> {
let stream = file_read.get_file_stream(id).await?;
let mut stream = Pin::from(stream);
let mut buf = BytesMut::with_capacity(capacity);
while let Some(chunk) = stream.next().await {
buf.extend_from_slice(&chunk.map_err(|e| {
DomainError::internal_error("File", format!("Stream read error: {}", e))
})?);
}
Ok(buf.freeze())
}
/// Helper: require the caller has `perm` on the given file id.
/// Fail-closed if no engine was injected (stub/test path).
async fn require_file(
@@ -162,60 +181,31 @@ impl FileRetrievalService {
// ── Tier 1: Hot cache + transcode (<10 MB) ──────────
if file_size < CACHE_THRESHOLD {
// Check content cache first (keyed by blob hash — see above)
if cacheable
&& let Some(cache) = &self.content_cache
&& let Some((cached, _etag, _ct)) = cache.get(&cache_key).await
{
debug!(
"🔥 TIER 1 Cache HIT: {} ({} bytes)",
file_name,
cached.len()
);
if do_transcode
&& let Some((t, m)) = self
.try_transcode(id, &cached, &mime_type, file_size, true)
.await
{
return Ok((
dto,
OptimizedFileContent::Bytes {
data: t,
mime_type: m,
was_transcoded: true,
},
));
}
return Ok((
dto,
OptimizedFileContent::Bytes {
data: cached,
mime_type: mime_type.clone(),
was_transcoded: false,
},
));
}
// Cache miss – load from disk via streaming (constant 64 KB memory)
debug!("💾 TIER 1 Cache MISS: {} – loading from disk", file_name);
let stream = self.file_read.get_file_stream(id).await?;
let mut stream = std::pin::Pin::from(stream);
let mut buf = BytesMut::with_capacity(file_size as usize);
while let Some(chunk) = stream.next().await {
buf.extend_from_slice(&chunk.map_err(|e| {
DomainError::internal_error("File", format!("Stream read error: {}", e))
})?);
}
let content_bytes = buf.freeze();
// Store in cache (keyed by blob hash; ETag = the immutable hash)
if cacheable && let Some(cache) = &self.content_cache {
// Fetch the raw blob bytes. When cacheable, `get_or_load` serves
// from the content cache on a hit and, on a miss, coalesces every
// concurrent request for the same blob hash into a SINGLE disk read
// (single-flight) — no thundering herd under load. Hash-less stub
// DTOs are uncacheable and stream straight from disk.
let content_bytes = if cacheable && let Some(cache) = &self.content_cache {
let etag: Arc<str> = format!("\"{}\"", cache_key).into();
let ct: Arc<str> = mime_type.clone();
cache
.put(cache_key.clone(), content_bytes.clone(), etag, ct)
.await;
}
let file_read = Arc::clone(&self.file_read);
let id_owned = id.to_string();
let cap = file_size as usize;
let (bytes, _etag, _ct) = cache
.get_or_load(cache_key.clone(), etag, ct, async move {
debug!("💾 TIER 1 Cache MISS: {} – loading from disk", id_owned);
Self::read_full(&file_read, &id_owned, cap).await
})
.await?;
bytes
} else {
debug!(
"💾 TIER 1 (uncacheable): {} – streaming from disk",
file_name
);
Self::read_full(&self.file_read, id, file_size as usize).await?
};
if do_transcode
&& let Some((t, m)) = self
+201 -205
View File
@@ -193,16 +193,6 @@ impl SearchService {
hasher.finish()
}
/// Attempts to retrieve results from the cache.
async fn get_from_cache(&self, key: u64) -> Option<Arc<SearchResultsDto>> {
self.search_cache.get(&key).await
}
/// Stores results in the cache.
async fn store_in_cache(&self, key: u64, results: Arc<SearchResultsDto>) {
self.search_cache.insert(key, results).await;
}
/// Enrich a FileDto → SearchFileResultDto with server-computed metadata.
///
/// `query_lower` must already be lowercased (empty string when no query).
@@ -522,209 +512,215 @@ impl SearchUseCase for SearchService {
criteria: SearchCriteriaDto,
user_id: Uuid,
) -> Result<Arc<SearchResultsDto>> {
let start = Instant::now();
let user_id_str = user_id.to_string();
// Try to get from cache
let cache_key = Self::create_cache_key(&criteria, &user_id_str);
if let Some(cached_results) = self.get_from_cache(cache_key).await {
return Ok(cached_results);
}
let query = criteria.name_contains.as_deref().unwrap_or("");
// Pre-compute once — avoids N heap allocations inside enrich_file/enrich_folder.
let query_lower = query.to_lowercase();
// Single-flight: collapse N identical concurrent searches into ONE
// execution. `try_get_with` serves the cached result on a hit and, on a
// miss, runs the closure exactly once while the other callers await it
// — so a burst of identical queries no longer floods Postgres or drains
// the connection pool (the old get-from-cache fast path is subsumed).
self.search_cache
.try_get_with(cache_key, async move {
let start = Instant::now();
let query = criteria.name_contains.as_deref().unwrap_or("");
// Pre-compute once — avoids N heap allocations inside enrich_file/enrich_folder.
let query_lower = query.to_lowercase();
// Content-index candidates (first page only). Feature-off or an
// index failure yields an empty set — the search stays name-only.
let content_hits = self.lookup_content_hits(&criteria, user_id).await;
// Content-index candidates (first page only). Feature-off or an
// index failure yields an empty set — the search stays name-only.
let content_hits = self.lookup_content_hits(&criteria, user_id).await;
// For non-recursive searches, use efficient database-level pagination
// This avoids loading all files into memory
if !criteria.recursive {
// Use database-level pagination
let (files, total_file_count) = self
.file_repository
.search_files_paginated(criteria.folder_id.as_deref(), &criteria, user_id)
.await?;
// For non-recursive searches, use efficient database-level pagination
// This avoids loading all files into memory
if !criteria.recursive {
// Use database-level pagination
let (files, total_file_count) = self
.file_repository
.search_files_paginated(criteria.folder_id.as_deref(), &criteria, user_id)
.await?;
// Convert to DTOs and enrich with metadata
let file_dtos: Vec<FileDto> = files.into_iter().map(FileDto::from).collect();
let mut enriched_files: Vec<SearchFileResultDto> = file_dtos
.iter()
.map(|f| Self::enrich_file(f, &query_lower))
.collect();
// Convert to DTOs and enrich with metadata
let file_dtos: Vec<FileDto> = files.into_iter().map(FileDto::from).collect();
let mut enriched_files: Vec<SearchFileResultDto> = file_dtos
.iter()
.map(|f| Self::enrich_file(f, &query_lower))
.collect();
// Get folders for this folder (non-recursive, filtered in SQL)
let folders = self
.folder_repository
.search_folders(
criteria.folder_id.as_deref(),
criteria.name_contains.as_deref(),
user_id,
false,
// Get folders for this folder (non-recursive, filtered in SQL)
let folders = self
.folder_repository
.search_folders(
criteria.folder_id.as_deref(),
criteria.name_contains.as_deref(),
user_id,
false,
)
.await?;
let filtered_folders: Vec<FolderDto> =
folders.into_iter().map(FolderDto::from).collect();
// For folders, apply sorting and pagination in memory (usually fewer folders)
let mut enriched_folders: Vec<SearchFolderResultDto> = filtered_folders
.iter()
.map(|f| Self::enrich_folder(f, &query_lower))
.collect();
// Sort folders (cached_key avoids O(N log N) temporary String allocations)
match criteria.sort_by.as_str() {
"name" => {
enriched_folders.sort_by_cached_key(|f| f.name.to_lowercase());
}
"name_desc" => {
enriched_folders.sort_by_cached_key(|f| Reverse(f.name.to_lowercase()));
}
"date" => {
enriched_folders.sort_by_key(|f| f.modified_at);
}
"date_desc" => {
enriched_folders.sort_by_key(|f| Reverse(f.modified_at));
}
_ => {
enriched_folders.sort_by_key(|f| Reverse(f.relevance_score));
}
}
// Blend in content-discovered files before the pagination math.
let added = self
.merge_content_hits(content_hits, &mut enriched_files, &criteria, user_id)
.await?;
let total_file_count = total_file_count + added;
let folder_count = enriched_folders.len();
let total_count = total_file_count + folder_count;
// Combine and paginate (folders first, then files)
let start_idx = criteria.offset.min(total_count);
let end_idx = (criteria.offset + criteria.limit).min(total_count);
let folder_start = start_idx.min(folder_count);
let folder_end = end_idx.min(folder_count);
let paginated_folders = enriched_folders[folder_start..folder_end].to_vec();
let file_start = start_idx.saturating_sub(folder_count);
let file_end = end_idx
.saturating_sub(folder_count)
.min(enriched_files.len());
let paginated_files = enriched_files[file_start..file_end].to_vec();
let elapsed_ms = start.elapsed().as_millis() as u64;
let search_results = Arc::new(SearchResultsDto::new(
paginated_files,
paginated_folders,
criteria.limit,
criteria.offset,
Some(total_count),
elapsed_ms,
criteria.sort_by.clone(),
));
return Ok(search_results);
}
// ── Recursive search via ltree (single SQL query per entity type) ──
// Uses PostgreSQL ltree GiST index to find all files and folders
// in the subtree in O(1) queries, replacing the O(N) spawn-per-folder
// approach that could saturate the connection pool.
let (found_files, total_file_count) = self
.file_repository
.search_files_in_subtree(criteria.folder_id.as_deref(), &criteria, user_id)
.await?;
// Get folders (SQL-filtered, user-scoped, recursive when applicable)
let found_folders: Vec<Folder> = self
.folder_repository
.search_folders(
criteria.folder_id.as_deref(),
criteria.name_contains.as_deref(),
user_id,
true,
)
.await?;
// ── Convert to DTOs and enrich with server-computed metadata ──
let file_dtos: Vec<FileDto> = found_files.into_iter().map(FileDto::from).collect();
let mut enriched_files: Vec<SearchFileResultDto> = file_dtos
.iter()
.map(|f| Self::enrich_file(f, &query_lower))
.collect();
let folder_dtos: Vec<FolderDto> =
found_folders.into_iter().map(FolderDto::from).collect();
let mut enriched_folders: Vec<SearchFolderResultDto> = folder_dtos
.iter()
.map(|f| Self::enrich_folder(f, &query_lower))
.collect();
// ── Sort folders (cached_key avoids O(N log N) temporary String allocations) ──
match criteria.sort_by.as_str() {
"name" => {
enriched_folders.sort_by_cached_key(|f| f.name.to_lowercase());
}
"name_desc" => {
enriched_folders.sort_by_cached_key(|f| Reverse(f.name.to_lowercase()));
}
"date" => {
enriched_folders.sort_by_key(|f| f.modified_at);
}
"date_desc" => {
enriched_folders.sort_by_key(|f| Reverse(f.modified_at));
}
_ => {
enriched_folders.sort_by_key(|f| Reverse(f.relevance_score));
}
}
// Blend in content-discovered files before the pagination math.
let added = self
.merge_content_hits(content_hits, &mut enriched_files, &criteria, user_id)
.await?;
let total_file_count = total_file_count + added;
// ── Pagination (folders first, then files) ──
let folder_count = enriched_folders.len();
let total_count = total_file_count + folder_count;
let start_idx = criteria.offset.min(total_count);
let end_idx = (criteria.offset + criteria.limit).min(total_count);
let folder_start = start_idx.min(folder_count);
let folder_end = end_idx.min(folder_count);
let paginated_folders = enriched_folders[folder_start..folder_end].to_vec();
let file_start = start_idx.saturating_sub(folder_count);
let file_end = end_idx
.saturating_sub(folder_count)
.min(enriched_files.len());
let paginated_files = enriched_files[file_start..file_end].to_vec();
let elapsed_ms = start.elapsed().as_millis() as u64;
let search_results = Arc::new(SearchResultsDto::new(
paginated_files,
paginated_folders,
criteria.limit,
criteria.offset,
Some(total_count),
elapsed_ms,
criteria.sort_by.clone(),
));
Ok(search_results)
})
.await
.map_err(|shared: Arc<crate::common::errors::DomainError>| {
crate::common::errors::DomainError::new(
shared.kind,
shared.entity_type,
shared.message.clone(),
)
.await?;
let filtered_folders: Vec<FolderDto> =
folders.into_iter().map(FolderDto::from).collect();
// For folders, apply sorting and pagination in memory (usually fewer folders)
let mut enriched_folders: Vec<SearchFolderResultDto> = filtered_folders
.iter()
.map(|f| Self::enrich_folder(f, &query_lower))
.collect();
// Sort folders (cached_key avoids O(N log N) temporary String allocations)
match criteria.sort_by.as_str() {
"name" => {
enriched_folders.sort_by_cached_key(|f| f.name.to_lowercase());
}
"name_desc" => {
enriched_folders.sort_by_cached_key(|f| Reverse(f.name.to_lowercase()));
}
"date" => {
enriched_folders.sort_by_key(|f| f.modified_at);
}
"date_desc" => {
enriched_folders.sort_by_key(|f| Reverse(f.modified_at));
}
_ => {
enriched_folders.sort_by_key(|f| Reverse(f.relevance_score));
}
}
// Blend in content-discovered files before the pagination math.
let added = self
.merge_content_hits(content_hits, &mut enriched_files, &criteria, user_id)
.await?;
let total_file_count = total_file_count + added;
let folder_count = enriched_folders.len();
let total_count = total_file_count + folder_count;
// Combine and paginate (folders first, then files)
let start_idx = criteria.offset.min(total_count);
let end_idx = (criteria.offset + criteria.limit).min(total_count);
let folder_start = start_idx.min(folder_count);
let folder_end = end_idx.min(folder_count);
let paginated_folders = enriched_folders[folder_start..folder_end].to_vec();
let file_start = start_idx.saturating_sub(folder_count);
let file_end = end_idx
.saturating_sub(folder_count)
.min(enriched_files.len());
let paginated_files = enriched_files[file_start..file_end].to_vec();
let elapsed_ms = start.elapsed().as_millis() as u64;
let search_results = Arc::new(SearchResultsDto::new(
paginated_files,
paginated_folders,
criteria.limit,
criteria.offset,
Some(total_count),
elapsed_ms,
criteria.sort_by.clone(),
));
self.store_in_cache(cache_key, Arc::clone(&search_results))
.await;
return Ok(search_results);
}
// ── Recursive search via ltree (single SQL query per entity type) ──
// Uses PostgreSQL ltree GiST index to find all files and folders
// in the subtree in O(1) queries, replacing the O(N) spawn-per-folder
// approach that could saturate the connection pool.
let (found_files, total_file_count) = self
.file_repository
.search_files_in_subtree(criteria.folder_id.as_deref(), &criteria, user_id)
.await?;
// Get folders (SQL-filtered, user-scoped, recursive when applicable)
let found_folders: Vec<Folder> = self
.folder_repository
.search_folders(
criteria.folder_id.as_deref(),
criteria.name_contains.as_deref(),
user_id,
true,
)
.await?;
// ── Convert to DTOs and enrich with server-computed metadata ──
let file_dtos: Vec<FileDto> = found_files.into_iter().map(FileDto::from).collect();
let mut enriched_files: Vec<SearchFileResultDto> = file_dtos
.iter()
.map(|f| Self::enrich_file(f, &query_lower))
.collect();
let folder_dtos: Vec<FolderDto> = found_folders.into_iter().map(FolderDto::from).collect();
let mut enriched_folders: Vec<SearchFolderResultDto> = folder_dtos
.iter()
.map(|f| Self::enrich_folder(f, &query_lower))
.collect();
// ── Sort folders (cached_key avoids O(N log N) temporary String allocations) ──
match criteria.sort_by.as_str() {
"name" => {
enriched_folders.sort_by_cached_key(|f| f.name.to_lowercase());
}
"name_desc" => {
enriched_folders.sort_by_cached_key(|f| Reverse(f.name.to_lowercase()));
}
"date" => {
enriched_folders.sort_by_key(|f| f.modified_at);
}
"date_desc" => {
enriched_folders.sort_by_key(|f| Reverse(f.modified_at));
}
_ => {
enriched_folders.sort_by_key(|f| Reverse(f.relevance_score));
}
}
// Blend in content-discovered files before the pagination math.
let added = self
.merge_content_hits(content_hits, &mut enriched_files, &criteria, user_id)
.await?;
let total_file_count = total_file_count + added;
// ── Pagination (folders first, then files) ──
let folder_count = enriched_folders.len();
let total_count = total_file_count + folder_count;
let start_idx = criteria.offset.min(total_count);
let end_idx = (criteria.offset + criteria.limit).min(total_count);
let folder_start = start_idx.min(folder_count);
let folder_end = end_idx.min(folder_count);
let paginated_folders = enriched_folders[folder_start..folder_end].to_vec();
let file_start = start_idx.saturating_sub(folder_count);
let file_end = end_idx
.saturating_sub(folder_count)
.min(enriched_files.len());
let paginated_files = enriched_files[file_start..file_end].to_vec();
let elapsed_ms = start.elapsed().as_millis() as u64;
let search_results = Arc::new(SearchResultsDto::new(
paginated_files,
paginated_folders,
criteria.limit,
criteria.offset,
Some(total_count),
elapsed_ms,
criteria.sort_by.clone(),
));
// Store in cache — Arc::clone is ~1 ns (atomic increment)
self.store_in_cache(cache_key, Arc::clone(&search_results))
.await;
Ok(search_results)
})
}
/// Returns quick suggestions for autocomplete.