perf: round 9 — decorator PUT reactivation, session/search/dedup alloc purges, PROPFIND join!, folder-level cascade
Benchmark-gated round (benches/ROUND9.md): every change carries a BEFORE/AFTER bench with equivalence/safety gates; verdicts below are from the committed harnesses on 4 cores / local PG 16. Backend: - Blob decorators (Retry/Cached) now forward put_blob_from_bytes_unsynced + sync_blobs — the trait default had silently reinstated HEAD-before-PUT per chunk on decorated remote stacks, undoing ROUND3 §8. Full production stack: 500 probes -> 0, 1.9x wall at 10 ms RTT (bench_s3_put §3). - NC PROPFIND per-page enrichment triple (favorites / oc:fileid / dead props) overlapped with tokio::join!: 2.07x local, 2.86x at 5 ms RTT (bench_nc_enrich_join, injected-latency decide-by-bench). - Search enrichment consumes its DTOs and carries the interned Arc<str> display fields end-to-end (SearchFileResultDto type change, OpenAPI shape preserved): enrich_file 2.0x, 11.6 -> 2.2 allocs/row; the NC REPORT conversion stops re-running all three classifiers per row (bench_search_enrich). - NC session Arc end-to-end: SharedNcSession extractor (8 -> 0 allocs), Arc<FolderDto> chroot cache (4 -> 0/hit), single shared Arc<CurrentUser> + lazy span render (11 -> 6/build) (bench_nc_session). - Storage micro-pack: atomic create_new chunk writes (2.1x fresh), stream_chunks over the manifest Arc (4097 -> 0 allocs/read incl. the Range path), manifest single-flight (herd 64 -> 1 loads), hex_lower for chunk Content-MD5 (18 -> 1 allocs) (bench_storage_micro). - OCS capabilities memoized into OnceLock<[Bytes;2]>: 237x, 102 -> 0 allocs/poll, byte-identical (bench_capabilities_static). - Drive::is_empty COUNT(*) sum -> EXISTS: 34.4x on a 100k-file drive (bench_drive_is_empty). - favorites/recents row-map ROUND7 port: path/name/blob_hash moved, -2.75 allocs/row (bench_resource_row_map §2). - Folder rows decode binary UUIDs (ROUND6 §10 port): 1.03-1.07x page fetch, honest verdict incl. one noise-band wash documented (bench_folder_uuid_decode). - Authz: file cascade decision decomposed into memoized folder-level decision + direct-grant lookup (ROUND8 deferred item). Cold shared-album first view 592 -> 418 µs/thumb; warm path unchanged; safety gates incl. new direct-grant sibling isolation, revoke-flush re-verified, full integration authz suite green (bench_thumbnail_cascade_cache). Frontend (vitest gates committed beside the code): - resolveLabel/resolveRecipient O(directory) scan -> id-keyed Map: 13.9x (recipients.bench.test.ts). - ResourceList selection-prune effect skips when nothing is selected (100 -> 0 Set builds per drain) and the photos timeline reads a listener-fed mobile flag instead of matchMedia per recompute (listDerives.bench.test.ts). Verification: cargo fmt + clippy --all-features --all-targets -D warnings clean; 524 unit + 554 integration (--cfg integration_tests) tests pass; frontend npm run check clean with 293 vitest tests green. Deferred with rationale in ROUND9.md: CalDAV authz-before-fetch reorder (maintainer sign-off), per-page batched parent resolution, JWT-claims Arc<str>, batch_operations signature widening. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDc9VtXvskJ6dnMRraSndn
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/**
|
||||
@@ -109,8 +110,10 @@ pub struct SearchFileResultDto {
|
||||
pub path: String,
|
||||
/// Size in bytes
|
||||
pub size: u64,
|
||||
/// MIME type
|
||||
pub mime_type: String,
|
||||
/// MIME type — `Arc<str>` so enrichment reuses `FileDto`'s interned
|
||||
/// value (an atomic increment) instead of allocating per result row.
|
||||
#[schema(value_type = String)]
|
||||
pub mime_type: Arc<str>,
|
||||
/// Parent folder ID
|
||||
pub folder_id: Option<String>,
|
||||
/// Creation timestamp
|
||||
@@ -122,11 +125,14 @@ pub struct SearchFileResultDto {
|
||||
/// Human-readable file size (e.g., "2.5 MB")
|
||||
pub size_formatted: String,
|
||||
/// CSS icon class for the file type (e.g., "fas fa-file-pdf")
|
||||
pub icon_class: String,
|
||||
#[schema(value_type = String)]
|
||||
pub icon_class: Arc<str>,
|
||||
/// Extra CSS class for icon styling (e.g., "pdf-icon", "code-icon js-icon")
|
||||
pub icon_special_class: String,
|
||||
#[schema(value_type = String)]
|
||||
pub icon_special_class: Arc<str>,
|
||||
/// Content category: "document", "image", "video", "audio", "archive", "code", "other"
|
||||
pub category: String,
|
||||
#[schema(value_type = String)]
|
||||
pub category: Arc<str>,
|
||||
/// Raw BLAKE3 content hash. Feeds `FileDto::content_hash` and
|
||||
/// `File::compute_etag` when search results are converted to
|
||||
/// `FileDto` (NC REPORT/SEARCH response). Defaults to `String::new()`
|
||||
@@ -267,9 +273,11 @@ pub struct SearchSuggestionItem {
|
||||
/// Path for context
|
||||
pub path: String,
|
||||
/// CSS icon class
|
||||
pub icon_class: String,
|
||||
#[schema(value_type = String)]
|
||||
pub icon_class: Arc<str>,
|
||||
/// Extra CSS class for icon styling
|
||||
pub icon_special_class: String,
|
||||
#[schema(value_type = String)]
|
||||
pub icon_special_class: Arc<str>,
|
||||
/// Relevance score
|
||||
pub relevance_score: u32,
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ use std::cmp::Reverse;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::application::dtos::display_helpers::{
|
||||
category_for, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
use crate::application::dtos::display_helpers::intern_display;
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::dtos::search_dto::{
|
||||
@@ -209,23 +207,6 @@ fn format_bytes(bytes: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Font Awesome icon class for a file based on extension and MIME type.
|
||||
/// Delegates to the centralised `display_helpers` so every API surface is
|
||||
/// consistent.
|
||||
fn get_icon_class(name: &str, mime: &str) -> String {
|
||||
icon_class_for(name, mime).to_string()
|
||||
}
|
||||
|
||||
/// Get CSS special class for icon styling.
|
||||
fn get_icon_special_class(name: &str, mime: &str) -> String {
|
||||
icon_special_class_for(name, mime).to_string()
|
||||
}
|
||||
|
||||
/// Get category label from centralised helpers.
|
||||
fn get_category(name: &str, mime: &str) -> String {
|
||||
category_for(name, mime).to_string()
|
||||
}
|
||||
|
||||
// ─── SearchService implementation ───────────────────────────────────────
|
||||
|
||||
impl SearchService {
|
||||
@@ -267,8 +248,14 @@ impl SearchService {
|
||||
|
||||
/// Enrich a FileDto → SearchFileResultDto with server-computed metadata.
|
||||
///
|
||||
/// Consumes the DTO: every `String` moves and the interned display
|
||||
/// fields (`mime_type`/`icon_class`/`icon_special_class`/`category`,
|
||||
/// already computed once in `FileDto::from`) transfer as refcount
|
||||
/// bumps — the old borrow-based version cloned all of them AND re-ran
|
||||
/// the three display classifiers per result row.
|
||||
///
|
||||
/// `query_lower` must already be lowercased (empty string when no query).
|
||||
fn enrich_file(file: &FileDto, query_lower: &str) -> SearchFileResultDto {
|
||||
fn enrich_file(file: FileDto, query_lower: &str) -> SearchFileResultDto {
|
||||
let relevance = if query_lower.is_empty() {
|
||||
50
|
||||
} else {
|
||||
@@ -276,23 +263,23 @@ impl SearchService {
|
||||
};
|
||||
|
||||
SearchFileResultDto {
|
||||
id: file.id.clone(),
|
||||
name: file.name.clone(),
|
||||
path: file.path.clone(),
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
mime_type: file.mime_type.to_string(),
|
||||
folder_id: file.folder_id.clone(),
|
||||
mime_type: file.mime_type,
|
||||
folder_id: file.folder_id,
|
||||
created_at: file.created_at,
|
||||
modified_at: file.modified_at,
|
||||
relevance_score: relevance,
|
||||
size_formatted: format_bytes(file.size),
|
||||
icon_class: get_icon_class(&file.name, &file.mime_type),
|
||||
icon_special_class: get_icon_special_class(&file.name, &file.mime_type),
|
||||
category: get_category(&file.name, &file.mime_type),
|
||||
icon_class: file.icon_class,
|
||||
icon_special_class: file.icon_special_class,
|
||||
category: file.category,
|
||||
// Carry the content hash through so REPORT/SEARCH
|
||||
// responses on the NC surface can emit the same ETag
|
||||
// (`File::compute_etag`) as PROPFIND/GET would.
|
||||
blob_hash: file.content_hash.clone(),
|
||||
blob_hash: file.content_hash,
|
||||
snippet: None,
|
||||
match_source: (!query_lower.is_empty() && relevance > 0).then(|| "name".to_string()),
|
||||
}
|
||||
@@ -300,8 +287,10 @@ impl SearchService {
|
||||
|
||||
/// Enrich a FolderDto → SearchFolderResultDto with server-computed metadata.
|
||||
///
|
||||
/// Consumes the DTO so the owned strings move instead of cloning.
|
||||
///
|
||||
/// `query_lower` must already be lowercased (empty string when no query).
|
||||
fn enrich_folder(folder: &FolderDto, query_lower: &str) -> SearchFolderResultDto {
|
||||
fn enrich_folder(folder: FolderDto, query_lower: &str) -> SearchFolderResultDto {
|
||||
let relevance = if query_lower.is_empty() {
|
||||
50
|
||||
} else {
|
||||
@@ -309,10 +298,10 @@ impl SearchService {
|
||||
};
|
||||
|
||||
SearchFolderResultDto {
|
||||
id: folder.id.clone(),
|
||||
name: folder.name.clone(),
|
||||
path: folder.path.clone(),
|
||||
parent_id: folder.parent_id.clone(),
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
parent_id: folder.parent_id,
|
||||
drive_id: folder.drive_id,
|
||||
created_at: folder.created_at,
|
||||
modified_at: folder.modified_at,
|
||||
@@ -481,9 +470,10 @@ impl SearchService {
|
||||
let Some(hit) = by_id.get(dto.id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let mut enriched = Self::enrich_file(&dto, "");
|
||||
enriched.relevance_score = content_relevance(hit.score, max_score);
|
||||
enriched.snippet = hit.snippet.clone();
|
||||
let (score, snippet) = (hit.score, hit.snippet.clone());
|
||||
let mut enriched = Self::enrich_file(dto, "");
|
||||
enriched.relevance_score = content_relevance(score, max_score);
|
||||
enriched.snippet = snippet;
|
||||
enriched.match_source = Some("content".to_string());
|
||||
enriched_files.push(enriched);
|
||||
added += 1;
|
||||
@@ -536,15 +526,15 @@ impl SearchService {
|
||||
for file in files {
|
||||
let file_dto = FileDto::from(file);
|
||||
let score = compute_relevance(&file_dto.name, &query_lower);
|
||||
let icon_class = get_icon_class(&file_dto.name, &file_dto.mime_type);
|
||||
let icon_special_class = get_icon_special_class(&file_dto.name, &file_dto.mime_type);
|
||||
suggestions.push(SearchSuggestionItem {
|
||||
name: file_dto.name,
|
||||
item_type: "file".to_string(),
|
||||
id: file_dto.id,
|
||||
path: file_dto.path,
|
||||
icon_class,
|
||||
icon_special_class,
|
||||
// Interned in `FileDto::from` — reuse instead of re-running
|
||||
// the display classifiers per keystroke suggestion.
|
||||
icon_class: file_dto.icon_class,
|
||||
icon_special_class: file_dto.icon_special_class,
|
||||
relevance_score: score,
|
||||
});
|
||||
}
|
||||
@@ -557,8 +547,8 @@ impl SearchService {
|
||||
item_type: "folder".to_string(),
|
||||
id: folder_dto.id,
|
||||
path: folder_dto.path,
|
||||
icon_class: "fas fa-folder".to_string(),
|
||||
icon_special_class: "folder-icon".to_string(),
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
relevance_score: score,
|
||||
});
|
||||
}
|
||||
@@ -575,6 +565,22 @@ impl SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bench-only public wrappers (feature = "bench") ──────────────────────
|
||||
|
||||
#[cfg(feature = "bench")]
|
||||
impl SearchService {
|
||||
/// Public wrapper over the private `enrich_file` so
|
||||
/// `examples/bench_search_enrich.rs` can measure it.
|
||||
pub fn enrich_file_for_bench(file: FileDto, query_lower: &str) -> SearchFileResultDto {
|
||||
Self::enrich_file(file, query_lower)
|
||||
}
|
||||
|
||||
/// Public wrapper over the private `enrich_folder` for the same bench.
|
||||
pub fn enrich_folder_for_bench(folder: FolderDto, query_lower: &str) -> SearchFolderResultDto {
|
||||
Self::enrich_folder(folder, query_lower)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SearchUseCase trait implementation ──────────────────────────────────
|
||||
|
||||
impl SearchUseCase for SearchService {
|
||||
@@ -627,11 +633,11 @@ impl SearchUseCase for SearchService {
|
||||
.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))
|
||||
// Convert to DTOs and enrich with metadata — one fused
|
||||
// pass, no intermediate Vec<FileDto> materialization.
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = files
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_file(FileDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
// Get folders for this folder (non-recursive, filtered in SQL)
|
||||
@@ -645,13 +651,10 @@ impl SearchUseCase for SearchService {
|
||||
)
|
||||
.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))
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = folders
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_folder(FolderDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
// Sort folders (cached_key avoids O(N log N) temporary String allocations)
|
||||
@@ -732,17 +735,15 @@ impl SearchUseCase for SearchService {
|
||||
.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))
|
||||
// Fused single pass: no intermediate DTO Vec materialization.
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = found_files
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_file(FileDto::from(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))
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = found_folders
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_folder(FolderDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
// ── Sort folders (cached_key avoids O(N log N) temporary String allocations) ──
|
||||
@@ -893,15 +894,15 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
path: format!("/{name}"),
|
||||
size,
|
||||
mime_type: "text/plain".to_string(),
|
||||
mime_type: "text/plain".into(),
|
||||
folder_id: None,
|
||||
created_at: 0,
|
||||
modified_at,
|
||||
relevance_score: relevance,
|
||||
size_formatted: String::new(),
|
||||
icon_class: String::new(),
|
||||
icon_special_class: String::new(),
|
||||
category: String::new(),
|
||||
icon_class: "".into(),
|
||||
icon_special_class: "".into(),
|
||||
category: "".into(),
|
||||
blob_hash: String::new(),
|
||||
snippet: None,
|
||||
match_source: None,
|
||||
|
||||
@@ -498,21 +498,26 @@ impl DriveRepository for DrivePgRepository {
|
||||
// the trash. Trashed items don't count — owners can delete a
|
||||
// drive even when its trash bin still holds rows; the trash GC
|
||||
// will clean those up after the standard retention window.
|
||||
let count: (i64,) = sqlx::query_as(
|
||||
//
|
||||
// EXISTS instead of COUNT(*): only emptiness is tested, so the
|
||||
// planner stops at the first matching row — a populated drive
|
||||
// answers from one index probe instead of aggregating every
|
||||
// live file + folder it contains.
|
||||
let occupied: (bool,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT (
|
||||
(SELECT COUNT(*) FROM storage.folders
|
||||
WHERE drive_id = $1 AND parent_id IS NOT NULL AND NOT is_trashed)
|
||||
+ (SELECT COUNT(*) FROM storage.files
|
||||
WHERE drive_id = $1 AND NOT is_trashed)
|
||||
)
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM storage.folders
|
||||
WHERE drive_id = $1 AND parent_id IS NOT NULL AND NOT is_trashed)
|
||||
OR EXISTS(
|
||||
SELECT 1 FROM storage.files
|
||||
WHERE drive_id = $1 AND NOT is_trashed)
|
||||
"#,
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("is_empty", e))?;
|
||||
Ok(count.0 == 0)
|
||||
Ok(!occupied.0)
|
||||
}
|
||||
|
||||
async fn delete_atomic(&self, drive_id: Uuid) -> Result<(), DriveRepositoryError> {
|
||||
|
||||
@@ -32,11 +32,15 @@ use crate::domain::services::path_service::StoragePath;
|
||||
/// Post-D7-step-6: `storage.folders.user_id` dropped, so the tuple
|
||||
/// no longer carries it. The domain entity's `user_id` field is
|
||||
/// populated with `None` at `row_to_folder` construction.
|
||||
/// `id` / `parent_id` decode as binary `Uuid` (16 bytes on the wire vs 36
|
||||
/// as `::text`, and the server skips the cast); `row_to_folder` renders
|
||||
/// them to `String` once app-side — the round-6 `row_to_file` shape
|
||||
/// (benches/ROUND6.md §10) applied to the folder listings.
|
||||
type FolderRow = (
|
||||
Uuid,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<Uuid>,
|
||||
Uuid,
|
||||
i64,
|
||||
i64,
|
||||
@@ -49,10 +53,10 @@ type FolderRow = (
|
||||
/// the last element after the §14 provenance columns). Same
|
||||
/// column set as [`FolderRow`] plus the trailing count.
|
||||
type FolderRowPaginated = (
|
||||
Uuid,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<Uuid>,
|
||||
Uuid,
|
||||
i64,
|
||||
i64,
|
||||
@@ -131,10 +135,10 @@ impl FolderDbRepository {
|
||||
/// `Option<Uuid>` because the FK is `ON DELETE SET NULL`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn row_to_folder(
|
||||
id: String,
|
||||
id: Uuid,
|
||||
name: String,
|
||||
path: String,
|
||||
parent_id: Option<String>,
|
||||
parent_id: Option<Uuid>,
|
||||
drive_id: Uuid,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
@@ -143,10 +147,10 @@ impl FolderDbRepository {
|
||||
updated_by: Option<Uuid>,
|
||||
) -> Result<Folder, DomainError> {
|
||||
Folder::from_materialized_row(
|
||||
id,
|
||||
id.to_string(),
|
||||
name,
|
||||
path,
|
||||
parent_id,
|
||||
parent_id.map(|u| u.to_string()),
|
||||
drive_id,
|
||||
created_at as u64,
|
||||
modified_at as u64,
|
||||
@@ -170,7 +174,7 @@ impl FolderDbRepository {
|
||||
|
||||
let rows = sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
SELECT id, name, path, parent_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -235,12 +239,25 @@ impl FolderRepository for FolderDbRepository {
|
||||
//
|
||||
// RETURNING surfaces the two provenance columns so the built
|
||||
// entity / DTO carries fresh values without a re-read.
|
||||
let row = sqlx::query_as::<_, (String, String, i64, i64, i64, Option<Uuid>, Option<Uuid>)>(
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
Uuid,
|
||||
Option<Uuid>,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
INSERT INTO storage.folders
|
||||
(name, parent_id, drive_id, created_by, updated_by)
|
||||
VALUES ($1, $2::uuid, $3, $4, $4)
|
||||
RETURNING id::text,
|
||||
RETURNING id,
|
||||
parent_id,
|
||||
path,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
@@ -268,16 +285,16 @@ impl FolderRepository for FolderDbRepository {
|
||||
})?;
|
||||
|
||||
Self::row_to_folder(
|
||||
row.0, name, row.1, parent_id, drive_id, row.2, row.3, row.4,
|
||||
row.0, name, row.2, row.1, drive_id, row.3, row.4, row.5,
|
||||
// Fresh from RETURNING — caller_id was bound to both columns.
|
||||
row.5, row.6,
|
||||
row.6, row.7,
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
||||
let row = sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
SELECT id, name, path, parent_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -319,7 +336,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
// wrapper scoping post-D0).
|
||||
let row = sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
SELECT id, name, path, parent_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -345,7 +362,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
SELECT id, name, path, parent_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -361,7 +378,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
SELECT id, name, path, parent_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -404,7 +421,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
// top of `folder_repository.rs`. Frontend cross-references
|
||||
// `/api/drives::caller_role` via `folder.drive_id`.
|
||||
let sql = format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
"SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
@@ -445,7 +462,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
SELECT id, name, path, parent_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -465,7 +482,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
SELECT id, name, path, parent_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -529,7 +546,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
"AND $3::text IS NULL"
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT id::text, name, path, parent_id::text, drive_id, \
|
||||
"SELECT id, name, path, parent_id, drive_id, \
|
||||
EXTRACT(EPOCH FROM created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint, \
|
||||
@@ -567,7 +584,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
include_total: bool,
|
||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||
let sql = format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
"SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
@@ -629,7 +646,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
UPDATE storage.folders
|
||||
SET name = $1, updated_at = NOW(), updated_by = $3
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, path, parent_id::text, drive_id,
|
||||
RETURNING id, name, path, parent_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -690,7 +707,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
updated_at = NOW(),
|
||||
updated_by = $3
|
||||
WHERE f.id = $2::uuid AND NOT f.is_trashed
|
||||
RETURNING f.id::text, f.name, f.path, f.parent_id::text, f.drive_id,
|
||||
RETURNING f.id, f.name, f.path, f.parent_id, f.drive_id,
|
||||
EXTRACT(EPOCH FROM f.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM f.updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM f.tree_modified_at)::bigint,
|
||||
@@ -989,7 +1006,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
/// Ordered by `fo.path` so callers can iterate in directory order.
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<Folder>, DomainError> {
|
||||
let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
let sql = "SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
@@ -1056,7 +1073,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
if recursive {
|
||||
// Recursive, no folder scope → ALL folders in caller's readable drives
|
||||
let sql = format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
"SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
@@ -1095,7 +1112,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
// the caller can read (parent_id already establishes the subtree).
|
||||
let sql = if parent_id.is_some() {
|
||||
format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
"SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
@@ -1115,7 +1132,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
_ => "",
|
||||
};
|
||||
format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
"SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
@@ -1188,7 +1205,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
"SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
@@ -1245,7 +1262,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(&format!(
|
||||
r#"
|
||||
SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, fo.drive_id,
|
||||
SELECT fo.id, fo.name, fo.path, fo.parent_id, fo.drive_id,
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint,
|
||||
@@ -1274,7 +1291,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(&format!(
|
||||
r#"
|
||||
SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, fo.drive_id,
|
||||
SELECT fo.id, fo.name, fo.path, fo.parent_id, fo.drive_id,
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint,
|
||||
|
||||
@@ -187,22 +187,48 @@ impl BlobStorageBackend for CachedBlobBackend {
|
||||
};
|
||||
Box::pin(async move {
|
||||
let size = inner.put_blob_from_bytes(&hash, data.clone()).await?;
|
||||
// Also cache locally (best-effort): write bytes to cache path
|
||||
let dest = self_ref.cached_path(&hash);
|
||||
if let Some(parent) = dest.parent() {
|
||||
let _ = fs::create_dir_all(parent).await;
|
||||
}
|
||||
let _ = fs::write(&dest, &data).await;
|
||||
let data_len = data.len() as u64;
|
||||
let mut idx = self_ref.index.lock().await;
|
||||
if let Some(old) = idx.put(hash, CacheEntry { size: data_len }) {
|
||||
self_ref.current_size.fetch_sub(old.size, Ordering::Relaxed);
|
||||
}
|
||||
self_ref.current_size.fetch_add(data_len, Ordering::Relaxed);
|
||||
self_ref.cache_bytes_write_through(hash, &data).await;
|
||||
Ok(size)
|
||||
})
|
||||
}
|
||||
|
||||
// Without this override the trait default would re-route the CDC chunk
|
||||
// write through `put_blob_from_bytes` above, whose inner (synced) call
|
||||
// pays the remote exists-probe per chunk. The local write-through cache
|
||||
// population is kept identical — post-upload readers (thumbnail/EXIF/
|
||||
// face hooks) hit the cache instead of re-fetching from the remote.
|
||||
fn put_blob_from_bytes_unsynced(
|
||||
&self,
|
||||
hash: &str,
|
||||
data: Bytes,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
|
||||
let inner = self.inner.clone();
|
||||
let hash = hash.to_string();
|
||||
let self_ref = CachedRef {
|
||||
cache_dir: self.cache_dir.clone(),
|
||||
max_cache_bytes: self.max_cache_bytes,
|
||||
index: self.index.clone(),
|
||||
current_size: self.current_size.clone(),
|
||||
inflight: self.inflight.clone(),
|
||||
};
|
||||
Box::pin(async move {
|
||||
let size = inner
|
||||
.put_blob_from_bytes_unsynced(&hash, data.clone())
|
||||
.await?;
|
||||
self_ref.cache_bytes_write_through(hash, &data).await;
|
||||
Ok(size)
|
||||
})
|
||||
}
|
||||
|
||||
// The durability barrier must reach the backend that buffered the
|
||||
// unsynced writes; the local cache copy is disposable and needs none.
|
||||
fn sync_blobs(
|
||||
&self,
|
||||
hashes: &[String],
|
||||
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
|
||||
self.inner.sync_blobs(hashes)
|
||||
}
|
||||
|
||||
fn get_blob_stream(
|
||||
&self,
|
||||
hash: &str,
|
||||
@@ -437,6 +463,24 @@ impl CachedRef {
|
||||
self.cache_dir.join(prefix).join(format!("{hash}.blob"))
|
||||
}
|
||||
|
||||
/// Best-effort write-through cache population shared by both blob-bytes
|
||||
/// PUT paths. Deliberately no eviction sweep here — the byte budget is
|
||||
/// enforced on read-miss inserts (`insert_into_cache_static`), matching
|
||||
/// the historical write-path behavior.
|
||||
async fn cache_bytes_write_through(&self, hash: String, data: &Bytes) {
|
||||
let dest = self.cached_path(&hash);
|
||||
if let Some(parent) = dest.parent() {
|
||||
let _ = fs::create_dir_all(parent).await;
|
||||
}
|
||||
let _ = fs::write(&dest, data).await;
|
||||
let data_len = data.len() as u64;
|
||||
let mut idx = self.index.lock().await;
|
||||
if let Some(old) = idx.put(hash, CacheEntry { size: data_len }) {
|
||||
self.current_size.fetch_sub(old.size, Ordering::Relaxed);
|
||||
}
|
||||
self.current_size.fetch_add(data_len, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Single-flight wrapper around [`Self::fetch_and_cache_static`]: the
|
||||
/// first caller for a hash becomes the leader and downloads; concurrent
|
||||
/// callers queue on the per-hash gate, then re-check the cache and serve
|
||||
|
||||
@@ -834,8 +834,7 @@ impl ChunkedUploadService {
|
||||
let data_clone = data.clone(); // Bytes::clone is O(1) — just an Arc increment
|
||||
let actual_checksum = tokio::task::spawn_blocking(move || {
|
||||
use md5::{Digest, Md5};
|
||||
let hash = Md5::digest(&data_clone);
|
||||
hash.iter().map(|b| format!("{b:02x}")).collect::<String>()
|
||||
crate::common::fmt::hex_lower(&Md5::digest(&data_clone))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("MD5 checksum task failed: {e}"))?;
|
||||
|
||||
@@ -1747,18 +1747,24 @@ impl DedupService {
|
||||
/// remote object stores where overlapping fetches hide per-chunk latency).
|
||||
/// Shared by [`Self::read_blob_stream`] and [`Self::read_blob_bytes`] so both
|
||||
/// build the chunk stream identically from a manifest's `chunk_hashes`.
|
||||
/// Takes the shared manifest `Arc` and iterates its hashes by index —
|
||||
/// the old `Vec<String>` signature forced every read to deep-clone the
|
||||
/// whole hash list out of the cached manifest before the first byte
|
||||
/// (N ~64-B String allocs per read of an N-chunk file); the per-chunk
|
||||
/// `Arc` bump here is a single atomic increment.
|
||||
fn stream_chunks(
|
||||
&self,
|
||||
chunk_hashes: Vec<String>,
|
||||
manifest: Arc<ChunkManifest>,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> {
|
||||
let prefetch = self.backend.read_prefetch().max(1);
|
||||
let backend = self.backend.clone();
|
||||
let chunk_stream = stream::iter(chunk_hashes)
|
||||
.map(move |chunk_hash| {
|
||||
let chunk_stream = stream::iter(0..manifest.chunk_hashes.len())
|
||||
.map(move |i| {
|
||||
let backend = backend.clone();
|
||||
let manifest = manifest.clone();
|
||||
async move {
|
||||
backend
|
||||
.get_blob_stream(&chunk_hash)
|
||||
.get_blob_stream(&manifest.chunk_hashes[i])
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||
}
|
||||
@@ -1771,31 +1777,56 @@ impl DedupService {
|
||||
/// Cached manifest fetch for the read path (see the `manifest_cache`
|
||||
/// field docs). `None` = legacy whole-file blob — never cached, so a
|
||||
/// background rechunk that creates a manifest is honoured immediately.
|
||||
///
|
||||
/// Misses are single-flighted through `try_get_with`: K concurrent cold
|
||||
/// readers of one newly-hot file (e.g. parallel Range probes on a big
|
||||
/// video) coalesce onto ONE manifest SELECT instead of K. The
|
||||
/// positive-only contract is preserved by routing "no manifest row" and
|
||||
/// DB failures through the loader's error channel, which moka never
|
||||
/// caches. The zero-alloc `get` fast path stays in front so warm reads
|
||||
/// don't pay the owned-key clone `try_get_with` requires.
|
||||
async fn manifest_cached(&self, hash: &str) -> Result<Option<Arc<ChunkManifest>>, DomainError> {
|
||||
if let Some(m) = self.manifest_cache.get(hash).await {
|
||||
return Ok(Some(m));
|
||||
}
|
||||
let row = sqlx::query_as::<_, (Vec<String>, Vec<i64>, i64)>(
|
||||
"SELECT chunk_hashes, chunk_sizes, total_size
|
||||
FROM storage.chunk_manifests WHERE file_hash = $1",
|
||||
)
|
||||
.bind(hash)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("Dedup", format!("Manifest lookup: {}", e)))?;
|
||||
match row {
|
||||
Some((chunk_hashes, chunk_sizes, total_size)) => {
|
||||
let m = Arc::new(ChunkManifest {
|
||||
chunk_hashes,
|
||||
chunk_sizes,
|
||||
total_size,
|
||||
});
|
||||
self.manifest_cache
|
||||
.insert(hash.to_string(), m.clone())
|
||||
.await;
|
||||
Ok(Some(m))
|
||||
}
|
||||
None => Ok(None),
|
||||
|
||||
enum MissKind {
|
||||
Legacy,
|
||||
Db(String),
|
||||
}
|
||||
|
||||
let pool = self.pool.clone();
|
||||
let query_hash = hash.to_string();
|
||||
let result = self
|
||||
.manifest_cache
|
||||
.try_get_with(hash.to_string(), async move {
|
||||
let row = sqlx::query_as::<_, (Vec<String>, Vec<i64>, i64)>(
|
||||
"SELECT chunk_hashes, chunk_sizes, total_size
|
||||
FROM storage.chunk_manifests WHERE file_hash = $1",
|
||||
)
|
||||
.bind(&query_hash)
|
||||
.fetch_optional(pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| MissKind::Db(e.to_string()))?;
|
||||
match row {
|
||||
Some((chunk_hashes, chunk_sizes, total_size)) => Ok(Arc::new(ChunkManifest {
|
||||
chunk_hashes,
|
||||
chunk_sizes,
|
||||
total_size,
|
||||
})),
|
||||
None => Err(MissKind::Legacy),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(m) => Ok(Some(m)),
|
||||
Err(miss) => match &*miss {
|
||||
MissKind::Legacy => Ok(None),
|
||||
MissKind::Db(msg) => Err(DomainError::internal_error(
|
||||
"Dedup",
|
||||
format!("Manifest lookup: {}", msg),
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1810,7 +1841,7 @@ impl DedupService {
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>, DomainError>
|
||||
{
|
||||
match self.manifest_cached(hash).await? {
|
||||
Some(m) => Ok(self.stream_chunks(m.chunk_hashes.clone())),
|
||||
Some(m) => Ok(self.stream_chunks(m)),
|
||||
// Legacy whole-file blob
|
||||
None => self.backend.get_blob_stream(hash).await,
|
||||
}
|
||||
@@ -1829,10 +1860,10 @@ impl DedupService {
|
||||
/// every full-blob read (e.g. 2N queries for an N-image gallery cold load).
|
||||
pub async fn read_blob_bytes(&self, hash: &str) -> Result<Bytes, DomainError> {
|
||||
let (mut stream, expected_size) = match self.manifest_cached(hash).await? {
|
||||
Some(m) => (
|
||||
self.stream_chunks(m.chunk_hashes.clone()),
|
||||
m.total_size.max(0) as usize,
|
||||
),
|
||||
Some(m) => {
|
||||
let expected = m.total_size.max(0) as usize;
|
||||
(self.stream_chunks(m), expected)
|
||||
}
|
||||
None => {
|
||||
// Legacy whole-file blob: size + stream straight from the backend.
|
||||
let size = self.backend.blob_size(hash).await? as usize;
|
||||
@@ -1863,16 +1894,17 @@ impl DedupService {
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>, DomainError>
|
||||
{
|
||||
if let Some(m) = self.manifest_cached(hash).await? {
|
||||
let (chunk_hashes, chunk_sizes, total_size) =
|
||||
(&m.chunk_hashes, &m.chunk_sizes, m.total_size);
|
||||
let end = end.unwrap_or(total_size as u64);
|
||||
let end = end.unwrap_or(m.total_size as u64);
|
||||
|
||||
// Calculate which chunks overlap [start, end)
|
||||
// Calculate which chunks overlap [start, end). Chunks are
|
||||
// addressed by manifest INDEX (the hash is read through the
|
||||
// shared `Arc` at fetch time) — a `bytes=0-` probe of an
|
||||
// N-chunk video used to clone all N hash Strings here.
|
||||
let mut offset: u64 = 0;
|
||||
// (chunk_hash, range_start_within_chunk, range_end_within_chunk)
|
||||
let mut selected: Vec<(String, u64, Option<u64>)> = Vec::new();
|
||||
// (chunk_index, range_start_within_chunk, range_end_within_chunk)
|
||||
let mut selected: Vec<(usize, u64, Option<u64>)> = Vec::new();
|
||||
|
||||
for (i, &chunk_size) in chunk_sizes.iter().enumerate() {
|
||||
for (i, &chunk_size) in m.chunk_sizes.iter().enumerate() {
|
||||
let chunk_size = chunk_size as u64;
|
||||
let chunk_end = offset + chunk_size;
|
||||
|
||||
@@ -1883,7 +1915,7 @@ impl DedupService {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
selected.push((chunk_hashes[i].clone(), range_start, range_end));
|
||||
selected.push((i, range_start, range_end));
|
||||
}
|
||||
|
||||
offset += chunk_size;
|
||||
@@ -1897,11 +1929,16 @@ impl DedupService {
|
||||
let prefetch = self.backend.read_prefetch().max(1);
|
||||
let backend = self.backend.clone();
|
||||
let chunk_stream = stream::iter(selected)
|
||||
.map(move |(chunk_hash, range_start, range_end)| {
|
||||
.map(move |(i, range_start, range_end)| {
|
||||
let backend = backend.clone();
|
||||
let manifest = m.clone();
|
||||
async move {
|
||||
backend
|
||||
.get_blob_range_stream(&chunk_hash, range_start, range_end)
|
||||
.get_blob_range_stream(
|
||||
&manifest.chunk_hashes[i],
|
||||
range_start,
|
||||
range_end,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -128,18 +128,41 @@ async fn fsync_paths_parallel(paths: Vec<PathBuf>, strict: bool) -> Result<(), D
|
||||
/// (fsync now vs. deferred batch sync), or `None` when the blob already
|
||||
/// existed (idempotent skip — content-addressed, so identical by definition).
|
||||
async fn write_blob_bytes(blob_path: &Path, data: &Bytes) -> Result<Option<File>, DomainError> {
|
||||
if fs::try_exists(blob_path).await.unwrap_or(false) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut file = fs::File::create(blob_path).await.map_err(|e| {
|
||||
DomainError::internal_error("Blob", format!("Failed to create blob file: {}", e))
|
||||
})?;
|
||||
// One atomic O_CREAT|O_EXCL open replaces the old stat-then-create pair:
|
||||
// `AlreadyExists` IS the idempotent skip (content-addressed names mean an
|
||||
// existing file has identical content), saving a syscall + a blocking-pool
|
||||
// dispatch on every new chunk of every upload.
|
||||
let mut file = match fs::File::options()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(blob_path)
|
||||
.await
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(None),
|
||||
Err(e) => {
|
||||
return Err(DomainError::internal_error(
|
||||
"Blob",
|
||||
format!("Failed to create blob file: {}", e),
|
||||
));
|
||||
}
|
||||
};
|
||||
file.write_all(data).await.map_err(|e| {
|
||||
DomainError::internal_error("Blob", format!("Failed to write blob from bytes: {}", e))
|
||||
})?;
|
||||
Ok(Some(file))
|
||||
}
|
||||
|
||||
/// Bench-only public wrapper (feature = "bench") over the private chunk
|
||||
/// writer so `examples/bench_storage_micro.rs` can A/B the open strategy.
|
||||
#[cfg(feature = "bench")]
|
||||
pub async fn write_blob_bytes_for_bench(
|
||||
blob_path: &Path,
|
||||
data: &Bytes,
|
||||
) -> Result<Option<File>, DomainError> {
|
||||
write_blob_bytes(blob_path, data).await
|
||||
}
|
||||
|
||||
/// Compile-time lookup table for the 256 two-digit lowercase hex prefixes ("00"…"ff").
|
||||
static HEX_PREFIXES: [&str; 256] = [
|
||||
"00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "0a", "0b", "0c", "0d", "0e", "0f",
|
||||
|
||||
@@ -116,6 +116,15 @@ const CASCADE_GRANT_CACHE_CAPACITY: u64 = 100_000;
|
||||
/// invalidation tree". Short enough that any such change takes effect in <1 min.
|
||||
const CASCADE_GRANT_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// `file_parent_cache` bound/TTL: `file_id → Option<folder_id>` point rows
|
||||
/// (~50 B each) resolved on the file-cascade path so an N-file album pays
|
||||
/// ONE folder-cascade query instead of N (ROUND9). Parentage changes only
|
||||
/// on move — an indirect path the cascade cache already self-heals via TTL,
|
||||
/// so the same 30 s window applies (grant writes don't alter parentage and
|
||||
/// need no flush here).
|
||||
const FILE_PARENT_CACHE_CAPACITY: u64 = 100_000;
|
||||
const FILE_PARENT_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
pub struct PgAclEngine {
|
||||
pool: Arc<PgPool>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
@@ -202,6 +211,13 @@ pub struct PgAclEngine {
|
||||
/// only positively-or-negatively for at most the TTL. A revoke via
|
||||
/// `clear_role` flushes immediately; anything missed self-heals in ≤30 s.
|
||||
cascade_grant_cache: Cache<(Subject, Resource, Permission), bool>,
|
||||
/// `file_id → Option<parent folder_id>` memo for the file-cascade
|
||||
/// decomposition (see `cascade_grant_cached`): resolving the parent lets
|
||||
/// a whole folder's files share ONE folder-cascade decision, so a shared
|
||||
/// album's first view runs one ltree query instead of one per file.
|
||||
/// Grant writes don't affect parentage — only the TTL applies (moves are
|
||||
/// an indirect path, same self-heal contract as `cascade_grant_cache`).
|
||||
file_parent_cache: Cache<Uuid, Option<Uuid>>,
|
||||
}
|
||||
|
||||
impl PgAclEngine {
|
||||
@@ -243,6 +259,10 @@ impl PgAclEngine {
|
||||
.max_capacity(CASCADE_GRANT_CACHE_CAPACITY)
|
||||
.time_to_live(CASCADE_GRANT_CACHE_TTL)
|
||||
.build(),
|
||||
file_parent_cache: Cache::builder()
|
||||
.max_capacity(FILE_PARENT_CACHE_CAPACITY)
|
||||
.time_to_live(FILE_PARENT_CACHE_TTL)
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +337,10 @@ impl PgAclEngine {
|
||||
.max_capacity(1)
|
||||
.time_to_live(Duration::from_secs(1))
|
||||
.build(),
|
||||
file_parent_cache: Cache::builder()
|
||||
.max_capacity(1)
|
||||
.time_to_live(Duration::from_secs(1))
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,11 +742,11 @@ impl PgAclEngine {
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
/// Cascading check for files: either a direct file grant OR a grant on
|
||||
/// any ancestor folder of the file's containing folder. See
|
||||
/// `folder_cascade_grant_exists` for the meaning of `subject_types` /
|
||||
/// `subject_ids` and the D-Prep role-array migration.
|
||||
async fn file_cascade_grant_exists(
|
||||
/// Direct file grant only — the first branch of the historical file
|
||||
/// cascade UNION, split out so `cascade_grant_cached` can amortize the
|
||||
/// ancestor-folder branch per FOLDER (see the `Resource::File` arm).
|
||||
/// A plain indexed `role_grants` point lookup, no ltree join.
|
||||
async fn file_direct_grant_exists(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
@@ -735,30 +759,12 @@ impl PgAclEngine {
|
||||
let exists: Option<i32> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT 1
|
||||
FROM (
|
||||
-- direct file grant
|
||||
SELECT 1
|
||||
FROM storage.role_grants
|
||||
WHERE subject_type = ANY($1)
|
||||
AND subject_id = ANY($2)
|
||||
AND role = ANY($3::storage.grant_role[])
|
||||
AND resource_type = 'file' AND resource_id = $4
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
UNION ALL
|
||||
-- cascading from any ancestor folder of the file's containing folder
|
||||
SELECT 1
|
||||
FROM storage.role_grants g
|
||||
JOIN storage.folders gf ON gf.id = g.resource_id
|
||||
JOIN storage.files target_f ON target_f.id = $4
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
AND g.role = ANY($3::storage.grant_role[])
|
||||
AND g.resource_type = 'folder'
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND target_f.folder_id IS NOT NULL
|
||||
AND gf.lpath @> (SELECT lpath FROM storage.folders
|
||||
WHERE id = target_f.folder_id)
|
||||
) any_match
|
||||
FROM storage.role_grants
|
||||
WHERE subject_type = ANY($1)
|
||||
AND subject_id = ANY($2)
|
||||
AND role = ANY($3::storage.grant_role[])
|
||||
AND resource_type = 'file' AND resource_id = $4
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
@@ -768,11 +774,36 @@ impl PgAclEngine {
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("file cascade: {e}")))?;
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("file direct grant: {e}")))?;
|
||||
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
/// Memoised `file_id → Option<parent folder_id>` point read backing the
|
||||
/// file-cascade decomposition. `None` covers both a missing row and a
|
||||
/// NULL `folder_id` — in either case only the direct-file-grant branch
|
||||
/// can match (mirroring the historical UNION's `folder_id IS NOT NULL`
|
||||
/// guard).
|
||||
async fn file_parent_folder_cached(
|
||||
&self,
|
||||
file_id: Uuid,
|
||||
counters: &QueryCounters,
|
||||
) -> Result<Option<Uuid>, DomainError> {
|
||||
if let Some(parent) = self.file_parent_cache.get(&file_id).await {
|
||||
return Ok(parent);
|
||||
}
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let parent: Option<Option<Uuid>> =
|
||||
sqlx::query_scalar("SELECT folder_id FROM storage.files WHERE id = $1")
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("file parent: {e}")))?;
|
||||
let parent = parent.flatten();
|
||||
self.file_parent_cache.insert(file_id, parent).await;
|
||||
Ok(parent)
|
||||
}
|
||||
|
||||
/// Cache-aware wrapper over the File/Folder grant cascade. Serves the
|
||||
/// memoised `(subject, resource, permission)` decision when warm; on a
|
||||
/// miss it expands the subject set (itself cached) and runs the matching
|
||||
@@ -780,10 +811,23 @@ impl PgAclEngine {
|
||||
/// precheck fails, so it never caches a decision a drive grant would have
|
||||
/// satisfied — a later drive grant short-circuits above this cache.
|
||||
///
|
||||
/// **File decomposition (ROUND9).** The historical file query was one
|
||||
/// UNION: `direct file grant ∨ grant on any ancestor of the parent
|
||||
/// folder` — one ltree join per file, so a shared N-photo album's FIRST
|
||||
/// view ran N near-identical ancestor queries (round 8 memoised only the
|
||||
/// per-file result, covering revalidation). The arm now resolves the
|
||||
/// file's parent (memoised point read) and recurses into the FOLDER arm
|
||||
/// for the ancestor half — one ltree query per folder, shared by every
|
||||
/// sibling — falling back to the direct-file-grant lookup only when the
|
||||
/// folder half denies. The decomposition is exactly the UNION split in
|
||||
/// two: no decision changes, including the parentless edge (the UNION's
|
||||
/// `folder_id IS NOT NULL` guard ≡ the direct-only fallback).
|
||||
///
|
||||
/// The result is a pure function of the subject's group expansion + the
|
||||
/// resource's grants + folder ancestry; `invalidate_cascade_grant_cache_all`
|
||||
/// (on File/Folder grant writes) and the 30 s TTL (indirect changes) keep
|
||||
/// it fresh. See the `cascade_grant_cache` field doc.
|
||||
/// (on File/Folder grant writes — it holds file AND folder decisions in
|
||||
/// the same map) and the 30 s TTL (indirect changes, incl. moves for the
|
||||
/// parent memo) keep it fresh. See the `cascade_grant_cache` field doc.
|
||||
async fn cascade_grant_cached(
|
||||
&self,
|
||||
subject: Subject,
|
||||
@@ -799,9 +843,10 @@ impl PgAclEngine {
|
||||
counters.cache_hit.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(allowed);
|
||||
}
|
||||
let (subject_types, subject_ids) = self.subject_match_set(subject, counters).await?;
|
||||
let allowed = match resource {
|
||||
Resource::Folder(id) => {
|
||||
let (subject_types, subject_ids) =
|
||||
self.subject_match_set(subject, counters).await?;
|
||||
self.folder_cascade_grant_exists(
|
||||
&subject_types,
|
||||
&subject_ids,
|
||||
@@ -812,14 +857,34 @@ impl PgAclEngine {
|
||||
.await?
|
||||
}
|
||||
Resource::File(id) => {
|
||||
self.file_cascade_grant_exists(
|
||||
&subject_types,
|
||||
&subject_ids,
|
||||
permission,
|
||||
id,
|
||||
counters,
|
||||
)
|
||||
.await?
|
||||
// Ancestor half first — amortized to one query per FOLDER
|
||||
// via the recursive Folder arm (its own cache entry).
|
||||
let folder_allowed = match self.file_parent_folder_cached(id, counters).await? {
|
||||
Some(parent) => {
|
||||
Box::pin(self.cascade_grant_cached(
|
||||
subject,
|
||||
Resource::Folder(parent),
|
||||
permission,
|
||||
counters,
|
||||
))
|
||||
.await?
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if folder_allowed {
|
||||
true
|
||||
} else {
|
||||
let (subject_types, subject_ids) =
|
||||
self.subject_match_set(subject, counters).await?;
|
||||
self.file_direct_grant_exists(
|
||||
&subject_types,
|
||||
&subject_ids,
|
||||
permission,
|
||||
id,
|
||||
counters,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
// Only File/Folder reach this helper (see `check_inner`).
|
||||
_ => return Ok(false),
|
||||
|
||||
@@ -159,6 +159,43 @@ impl BlobStorageBackend for RetryBlobBackend {
|
||||
})
|
||||
}
|
||||
|
||||
// Without this override the trait default would re-route the CDC chunk
|
||||
// write through `put_blob_from_bytes` above — reinstating the remote
|
||||
// backend's exists-probe (HEAD/get_properties) per chunk that the
|
||||
// `_unsynced` fast path exists to skip.
|
||||
fn put_blob_from_bytes_unsynced(
|
||||
&self,
|
||||
hash: &str,
|
||||
data: Bytes,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
|
||||
let inner = self.inner.clone();
|
||||
let policy = self.policy.clone();
|
||||
let hash = hash.to_string();
|
||||
Box::pin(async move {
|
||||
retry_async(
|
||||
&policy,
|
||||
&format!("put_blob_from_bytes_unsynced({hash})"),
|
||||
|| {
|
||||
let inner = inner.clone();
|
||||
let hash = hash.clone();
|
||||
let data = data.clone();
|
||||
async move { inner.put_blob_from_bytes_unsynced(&hash, data).await }
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
// Forwarded WITHOUT retry wrapping: a failed fsync must surface, not be
|
||||
// re-issued — after an fsync error the kernel may have dropped the dirty
|
||||
// pages, so a retried fsync can report success for data that was lost.
|
||||
fn sync_blobs(
|
||||
&self,
|
||||
hashes: &[String],
|
||||
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
|
||||
self.inner.sync_blobs(hashes)
|
||||
}
|
||||
|
||||
fn get_blob_stream(
|
||||
&self,
|
||||
hash: &str,
|
||||
|
||||
@@ -198,7 +198,7 @@ pub async fn list_favorites_resources(
|
||||
// Path is only shown to the owner; non-owners see ""
|
||||
// to avoid leaking another user's folder hierarchy.
|
||||
let path = if row.is_owner {
|
||||
row.path.clone().unwrap_or_default()
|
||||
row.path.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
@@ -208,7 +208,7 @@ pub async fn list_favorites_resources(
|
||||
let dto = FolderDto {
|
||||
etag: resource_id.clone(),
|
||||
id: resource_id,
|
||||
name: row.name.clone(),
|
||||
name: row.name,
|
||||
path,
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
drive_id: row.drive_id,
|
||||
@@ -239,26 +239,30 @@ pub async fn list_favorites_resources(
|
||||
// file. `blob_hash` is `None` only for
|
||||
// folder rows, which take the other branch.
|
||||
let modified_at_u = row.modified_at.timestamp() as u64;
|
||||
let content_hash = row.blob_hash.clone().unwrap_or_default();
|
||||
let content_hash = row.blob_hash.unwrap_or_default();
|
||||
let etag = if content_hash.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
File::compute_etag(&content_hash, modified_at_u)
|
||||
};
|
||||
// Name-derived display classes borrow `row.name`;
|
||||
// compute them before the name moves into the DTO.
|
||||
let icon_class = intern_display(icon_class_for(&row.name, mime));
|
||||
let icon_special_class =
|
||||
intern_display(icon_special_class_for(&row.name, mime));
|
||||
let category = intern_display(category_for(&row.name, mime));
|
||||
let dto = FileDto {
|
||||
id: row.resource_id.to_string(),
|
||||
name: row.name.clone(),
|
||||
name: row.name,
|
||||
path,
|
||||
size: size_bytes,
|
||||
mime_type: intern_mime(mime),
|
||||
folder_id: row.parent_id.map(|u| u.to_string()),
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: modified_at_u,
|
||||
icon_class: intern_display(icon_class_for(&row.name, mime)),
|
||||
icon_special_class: intern_display(icon_special_class_for(
|
||||
&row.name, mime,
|
||||
)),
|
||||
category: intern_display(category_for(&row.name, mime)),
|
||||
icon_class,
|
||||
icon_special_class,
|
||||
category,
|
||||
size_formatted: format_file_size(size_bytes),
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
|
||||
@@ -510,7 +510,7 @@ pub async fn list_folder_resources(
|
||||
// listing's `etag` byte-equals what a
|
||||
// conditional request would compare against.
|
||||
let modified_at_u = row.modified_at.timestamp() as u64;
|
||||
let content_hash = row.blob_hash.clone().unwrap_or_default();
|
||||
let content_hash = row.blob_hash.unwrap_or_default();
|
||||
let etag = if content_hash.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
|
||||
@@ -214,7 +214,7 @@ pub async fn list_recent_resources(
|
||||
// Path is only shown to the owner; non-owners see ""
|
||||
// to avoid leaking another user's folder hierarchy.
|
||||
let path = if row.is_owner {
|
||||
row.path.clone().unwrap_or_default()
|
||||
row.path.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
@@ -224,7 +224,7 @@ pub async fn list_recent_resources(
|
||||
let dto = FolderDto {
|
||||
etag: resource_id.clone(),
|
||||
id: resource_id,
|
||||
name: row.name.clone(),
|
||||
name: row.name,
|
||||
path,
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
drive_id: row.drive_id,
|
||||
@@ -253,26 +253,30 @@ pub async fn list_recent_resources(
|
||||
// listing matches GET/HEAD/PROPFIND byte-for-byte
|
||||
// for the same file.
|
||||
let modified_at_u = row.modified_at.timestamp() as u64;
|
||||
let content_hash = row.blob_hash.clone().unwrap_or_default();
|
||||
let content_hash = row.blob_hash.unwrap_or_default();
|
||||
let etag = if content_hash.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
File::compute_etag(&content_hash, modified_at_u)
|
||||
};
|
||||
// Name-derived display classes borrow `row.name`;
|
||||
// compute them before the name moves into the DTO.
|
||||
let icon_class = intern_display(icon_class_for(&row.name, mime));
|
||||
let icon_special_class =
|
||||
intern_display(icon_special_class_for(&row.name, mime));
|
||||
let category = intern_display(category_for(&row.name, mime));
|
||||
let dto = FileDto {
|
||||
id: row.resource_id.to_string(),
|
||||
name: row.name.clone(),
|
||||
name: row.name,
|
||||
path,
|
||||
size: size_bytes,
|
||||
mime_type: intern_mime(mime),
|
||||
folder_id: row.parent_id.map(|u| u.to_string()),
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: modified_at_u,
|
||||
icon_class: intern_display(icon_class_for(&row.name, mime)),
|
||||
icon_special_class: intern_display(icon_special_class_for(
|
||||
&row.name, mime,
|
||||
)),
|
||||
category: intern_display(category_for(&row.name, mime)),
|
||||
icon_class,
|
||||
icon_special_class,
|
||||
category,
|
||||
size_formatted: format_file_size(size_bytes),
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
|
||||
@@ -28,12 +28,16 @@ use crate::interfaces::middleware::auth::CurrentUser;
|
||||
/// default drive root, so no per-request authorization decision is being
|
||||
/// skipped. The drive-marker branch keeps its `get_folder_with_perms`
|
||||
/// check on every request.
|
||||
static NC_CHROOT_CACHE: LazyLock<moka::sync::Cache<uuid::Uuid, FolderDto>> = LazyLock::new(|| {
|
||||
moka::sync::Cache::builder()
|
||||
.max_capacity(100_000)
|
||||
.time_to_live(Duration::from_secs(30))
|
||||
.build()
|
||||
});
|
||||
// `Arc<FolderDto>` values: a hit hands back a refcount bump instead of a
|
||||
// deep clone of the DTO's ~5 owned Strings (moka's `get` clones `V`), and
|
||||
// the same `Arc` then rides inside `NcSession` for the whole request.
|
||||
static NC_CHROOT_CACHE: LazyLock<moka::sync::Cache<uuid::Uuid, Arc<FolderDto>>> =
|
||||
LazyLock::new(|| {
|
||||
moka::sync::Cache::builder()
|
||||
.max_capacity(100_000)
|
||||
.time_to_live(Duration::from_secs(30))
|
||||
.build()
|
||||
});
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NextcloudAuthError {
|
||||
@@ -184,13 +188,19 @@ pub async fn basic_auth_middleware(
|
||||
// request would appear in the logs with `user_id=-`,
|
||||
// making it harder to correlate WebDAV / OCS activity to
|
||||
// a specific principal.
|
||||
tracing::Span::current().record("user_id", user_id.to_string());
|
||||
let current_user = CurrentUser {
|
||||
// `field::display` renders lazily into the subscriber's buffer —
|
||||
// no per-request `to_string` (mirrors the JWT path since ROUND5).
|
||||
tracing::Span::current().record("user_id", tracing::field::display(user_id));
|
||||
// One shared identity: the same `Arc` serves the
|
||||
// `Arc<CurrentUser>` extension AND `NcSession.user` (the old
|
||||
// code built the struct, cloned it for the extension, then
|
||||
// moved the original — 2-3 String allocs per request).
|
||||
let current_user = Arc::new(CurrentUser {
|
||||
id: user_id,
|
||||
username: uname,
|
||||
email,
|
||||
role,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Resolve chroot from the Basic Auth drive marker ─────
|
||||
// No marker → caller's default personal drive's root folder
|
||||
@@ -226,9 +236,10 @@ pub async fn basic_auth_middleware(
|
||||
.folder_service
|
||||
.get_folder(&root_id.to_string())
|
||||
.await
|
||||
.ok();
|
||||
.ok()
|
||||
.map(Arc::new);
|
||||
if let Some(f) = &fetched {
|
||||
NC_CHROOT_CACHE.insert(root_id, f.clone());
|
||||
NC_CHROOT_CACHE.insert(root_id, Arc::clone(f));
|
||||
}
|
||||
fetched
|
||||
}
|
||||
@@ -242,7 +253,8 @@ pub async fn basic_auth_middleware(
|
||||
.folder_service
|
||||
.get_folder_with_perms(folder_id, current_user.id)
|
||||
.await
|
||||
.ok(),
|
||||
.ok()
|
||||
.map(Arc::new),
|
||||
};
|
||||
if chroot.is_none() {
|
||||
tracing::warn!(
|
||||
@@ -253,25 +265,20 @@ pub async fn basic_auth_middleware(
|
||||
return Err(NextcloudAuthError::Unauthorized);
|
||||
}
|
||||
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(Arc::new(current_user.clone()));
|
||||
// Record from the local before it moves into the session —
|
||||
// the old code re-read the just-inserted extension and paid a
|
||||
// `to_string` for the span value.
|
||||
if let Some(c) = &chroot {
|
||||
tracing::Span::current().record("chroot_id", tracing::field::display(&c.id));
|
||||
}
|
||||
request.extensions_mut().insert(Arc::clone(¤t_user));
|
||||
request.extensions_mut().insert(Arc::new(
|
||||
crate::interfaces::nextcloud::session::NcSession {
|
||||
user: current_user,
|
||||
raw_username: raw_username.clone(),
|
||||
raw_username,
|
||||
chroot,
|
||||
},
|
||||
));
|
||||
tracing::Span::current().record(
|
||||
"chroot_id",
|
||||
request
|
||||
.extensions()
|
||||
.get::<Arc<crate::interfaces::nextcloud::session::NcSession>>()
|
||||
.and_then(|s| s.chroot.as_ref())
|
||||
.map(|c| c.id.to_string())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
Err(_) => {
|
||||
|
||||
@@ -35,20 +35,51 @@ fn ocs_err(statuscode: u16, message: &str) -> serde_json::Value {
|
||||
}
|
||||
|
||||
pub async fn handle_capabilities_v1(State(state): State<Arc<AppState>>) -> Response {
|
||||
let payload = capabilities_payload(&state, 1);
|
||||
tracing::info!("[NC] capabilities v1 requested, returning payload");
|
||||
Json(payload).into_response()
|
||||
capabilities_response(&state, 1)
|
||||
}
|
||||
|
||||
pub async fn handle_capabilities_v2(State(state): State<Arc<AppState>>) -> Response {
|
||||
let payload = capabilities_payload(&state, 2);
|
||||
tracing::info!("[NC] capabilities v2 requested, returning payload");
|
||||
Json(payload).into_response()
|
||||
capabilities_response(&state, 2)
|
||||
}
|
||||
|
||||
/// Pre-serialized capabilities bodies, `[v1, v2]`. The payload is
|
||||
/// process-invariant (pure config: base URL + emulated NC version), yet
|
||||
/// every desktop/mobile client polls it periodically — the old handler
|
||||
/// re-built the ~40-node `json!` tree, re-read `OXICLOUD_BASE_URL` from
|
||||
/// the environment and re-serialized on every poll. Now that work runs
|
||||
/// once; a poll is a `Bytes` refcount bump.
|
||||
static CAPABILITIES_BODIES: std::sync::OnceLock<[bytes::Bytes; 2]> = std::sync::OnceLock::new();
|
||||
|
||||
fn capabilities_response(state: &AppState, ocs_version: u8) -> Response {
|
||||
let bodies = CAPABILITIES_BODIES.get_or_init(|| {
|
||||
let base_url = state.core.config.base_url();
|
||||
let emulated = state.core.config.nextcloud.emulated_version;
|
||||
let version_string = state.core.config.nextcloud.version_string();
|
||||
[1u8, 2u8].map(|v| {
|
||||
bytes::Bytes::from(
|
||||
serde_json::to_vec(&capabilities_payload(
|
||||
&base_url,
|
||||
emulated,
|
||||
&version_string,
|
||||
v,
|
||||
))
|
||||
.expect("static capabilities JSON serializes"),
|
||||
)
|
||||
})
|
||||
});
|
||||
let body = bodies[usize::from(ocs_version != 1)].clone();
|
||||
(
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn handle_user_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
session: crate::interfaces::nextcloud::session::NcSession,
|
||||
session: crate::interfaces::nextcloud::session::SharedNcSession,
|
||||
) -> Response {
|
||||
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
|
||||
Some(service) => match service.get_user_storage_info(session.user.id).await {
|
||||
@@ -530,11 +561,19 @@ fn empty_search_response() -> Json<serde_json::Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
fn capabilities_payload(state: &AppState, ocs_version: u8) -> serde_json::Value {
|
||||
/// Build the capabilities JSON tree from its three config inputs. Public
|
||||
/// only under the `bench` feature caller path via
|
||||
/// [`capabilities_payload_for_bench`]; production reaches it once through
|
||||
/// the [`CAPABILITIES_BODIES`] init.
|
||||
fn capabilities_payload(
|
||||
base_url: &str,
|
||||
emulated_version: (u32, u32, u32),
|
||||
version_string: &str,
|
||||
ocs_version: u8,
|
||||
) -> serde_json::Value {
|
||||
let statuscode = if ocs_version == 1 { 100 } else { 200 };
|
||||
let base_url = state.core.config.base_url();
|
||||
let (nc_major, nc_minor, nc_micro) = state.core.config.nextcloud.emulated_version;
|
||||
let nc_version_str = state.core.config.nextcloud.version_string();
|
||||
let (nc_major, nc_minor, nc_micro) = emulated_version;
|
||||
let nc_version_str = version_string;
|
||||
|
||||
json!({
|
||||
"ocs": {
|
||||
@@ -602,6 +641,19 @@ fn capabilities_payload(state: &AppState, ocs_version: u8) -> serde_json::Value
|
||||
})
|
||||
}
|
||||
|
||||
/// Bench-only public wrapper (feature = "bench") over the private payload
|
||||
/// builder so `examples/bench_capabilities_static.rs` can A/B the
|
||||
/// rebuild-per-poll flow against the memoized bytes.
|
||||
#[cfg(feature = "bench")]
|
||||
pub fn capabilities_payload_for_bench(
|
||||
base_url: &str,
|
||||
emulated_version: (u32, u32, u32),
|
||||
version_string: &str,
|
||||
ocs_version: u8,
|
||||
) -> serde_json::Value {
|
||||
capabilities_payload(base_url, emulated_version, version_string, ocs_version)
|
||||
}
|
||||
|
||||
fn extract_basic_password(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let value = headers
|
||||
.get(axum::http::header::AUTHORIZATION)?
|
||||
|
||||
@@ -10,9 +10,7 @@ use quick_xml::{
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
use crate::application::dtos::display_helpers::format_file_size;
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
||||
@@ -401,15 +399,16 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
|
||||
name: fr.name.clone(),
|
||||
path: fr.path.clone(),
|
||||
size: fr.size,
|
||||
mime_type: fr.mime_type.clone().into(),
|
||||
// Interned `Arc<str>` carried through from enrichment — refcount
|
||||
// bumps; the old code re-ran all three display classifiers and
|
||||
// re-allocated each value per converted search row.
|
||||
mime_type: fr.mime_type.clone(),
|
||||
folder_id: fr.folder_id.clone(),
|
||||
created_at: fr.created_at,
|
||||
modified_at: fr.modified_at,
|
||||
icon_class: icon_class_for(&fr.name, &fr.mime_type).to_string().into(),
|
||||
icon_special_class: icon_special_class_for(&fr.name, &fr.mime_type)
|
||||
.to_string()
|
||||
.into(),
|
||||
category: category_for(&fr.name, &fr.mime_type).to_string().into(),
|
||||
icon_class: fr.icon_class.clone(),
|
||||
icon_special_class: fr.icon_special_class.clone(),
|
||||
category: fr.category.clone(),
|
||||
size_formatted: format_file_size(fr.size),
|
||||
sort_date: None,
|
||||
content_hash: fr.blob_hash.clone(),
|
||||
@@ -420,6 +419,16 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
|
||||
}
|
||||
}
|
||||
|
||||
/// Bench-only public wrapper (feature = "bench") over the private
|
||||
/// search→FileDto conversion so `examples/bench_search_enrich.rs` can
|
||||
/// measure and equivalence-gate it.
|
||||
#[cfg(feature = "bench")]
|
||||
pub fn file_dto_from_search_for_bench(
|
||||
fr: &crate::application::dtos::search_dto::SearchFileResultDto,
|
||||
) -> FileDto {
|
||||
file_dto_from_search(fr)
|
||||
}
|
||||
|
||||
/// Build a `FolderDto` from a search folder result.
|
||||
fn folder_dto_from_search(
|
||||
sr: &crate::application::dtos::search_dto::SearchFolderResultDto,
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::interfaces::nextcloud::basic_auth_middleware::basic_auth_middleware;
|
||||
use crate::interfaces::nextcloud::login_v2_handler;
|
||||
use crate::interfaces::nextcloud::ocs_handler;
|
||||
use crate::interfaces::nextcloud::preview_handler;
|
||||
use crate::interfaces::nextcloud::session::NcSession;
|
||||
use crate::interfaces::nextcloud::session::SharedNcSession;
|
||||
use crate::interfaces::nextcloud::status_handler;
|
||||
use crate::interfaces::nextcloud::trashbin_handler;
|
||||
use crate::interfaces::nextcloud::uploads_handler;
|
||||
@@ -216,7 +216,7 @@ pub fn nextcloud_routes_with_state(state: Arc<AppState>) -> Router<Arc<AppState>
|
||||
async fn handle_dav_files(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((_url_user, subpath)): Path<(String, String)>,
|
||||
session: NcSession,
|
||||
session: SharedNcSession,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
webdav_handler::handle_nc_webdav(state, req, session, subpath)
|
||||
@@ -227,7 +227,7 @@ async fn handle_dav_files(
|
||||
async fn handle_dav_files_root(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(_url_user): Path<String>,
|
||||
session: NcSession,
|
||||
session: SharedNcSession,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
webdav_handler::handle_nc_webdav(state, req, session, String::new())
|
||||
@@ -238,7 +238,7 @@ async fn handle_dav_files_root(
|
||||
async fn handle_dav_uploads(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((_url_user, upload_id, rest)): Path<(String, String, String)>,
|
||||
session: NcSession,
|
||||
session: SharedNcSession,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
uploads_handler::handle_nc_uploads(state, req, session, upload_id, rest)
|
||||
@@ -249,7 +249,7 @@ async fn handle_dav_uploads(
|
||||
async fn handle_dav_uploads_root(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((_url_user, upload_id)): Path<(String, String)>,
|
||||
session: NcSession,
|
||||
session: SharedNcSession,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
uploads_handler::handle_nc_uploads(state, req, session, upload_id, String::new())
|
||||
@@ -279,7 +279,7 @@ async fn handle_legacy_webdav_root(user_ext: AuthUser) -> Response {
|
||||
async fn handle_dav_trashbin(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((_url_user, subpath)): Path<(String, String)>,
|
||||
session: NcSession,
|
||||
session: SharedNcSession,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
trashbin_handler::handle_nc_trashbin(state, req, session, subpath)
|
||||
@@ -290,7 +290,7 @@ async fn handle_dav_trashbin(
|
||||
async fn handle_dav_trashbin_root(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(_url_user): Path<String>,
|
||||
session: NcSession,
|
||||
session: SharedNcSession,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
trashbin_handler::handle_nc_trashbin(state, req, session, String::new())
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! Bundles WHO the caller is, the raw wire username they presented,
|
||||
//! and (for path-scoped endpoints) WHERE they're confined to. Built
|
||||
//! by `basic_auth_middleware` and stashed in request extensions as
|
||||
//! `Arc<NcSession>`; handlers extract it via the [`FromRequestParts`]
|
||||
//! impl below — just declare `session: NcSession` in the signature.
|
||||
//! `Arc<NcSession>`; handlers extract it via [`SharedNcSession`]
|
||||
//! (derefs to `NcSession`) — declare `session: SharedNcSession` in
|
||||
//! the signature.
|
||||
//!
|
||||
//! ## Source of truth
|
||||
//!
|
||||
@@ -46,9 +47,13 @@ use crate::interfaces::middleware::auth::CurrentUser;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NcSession {
|
||||
pub user: CurrentUser,
|
||||
/// Shared with the `Arc<CurrentUser>` request extension — one identity
|
||||
/// build per request instead of a clone per consumer.
|
||||
pub user: Arc<CurrentUser>,
|
||||
pub raw_username: String,
|
||||
pub chroot: Option<FolderDto>,
|
||||
/// Shared with `NC_CHROOT_CACHE` (markerless branch) — a cache hit is
|
||||
/// an `Arc` bump, not a `FolderDto` deep-clone.
|
||||
pub chroot: Option<Arc<FolderDto>>,
|
||||
}
|
||||
|
||||
impl NcSession {
|
||||
@@ -56,7 +61,7 @@ impl NcSession {
|
||||
/// without one. Documents the invariant that every NC route
|
||||
/// today is path-scoped — if this fires, route wiring is wrong.
|
||||
pub fn require_chroot(&self) -> Result<&FolderDto, AppError> {
|
||||
self.chroot.as_ref().ok_or_else(|| {
|
||||
self.chroot.as_deref().ok_or_else(|| {
|
||||
AppError::internal_error(
|
||||
"NcSession: path-scoped handler reached without a chroot — route wiring bug",
|
||||
)
|
||||
@@ -101,10 +106,13 @@ fn extract_url_user(path: &str) -> Option<String> {
|
||||
urlencoding::decode(user_seg).ok().map(|s| s.into_owned())
|
||||
}
|
||||
|
||||
/// Axum extractor: pulls the `Arc<NcSession>` that
|
||||
/// `basic_auth_middleware` stashed in request extensions and clones
|
||||
/// it (cheap — one `Arc` increment, no field copy) into an owned
|
||||
/// `NcSession` for handler use.
|
||||
/// Axum extractor: the shared handle to the request's [`NcSession`].
|
||||
///
|
||||
/// Derefs to `NcSession`, so handler bodies read `session.user`,
|
||||
/// `session.require_chroot()`, … unchanged. Extraction is one `Arc`
|
||||
/// refcount increment — the previous extractor deep-cloned the whole
|
||||
/// session (`CurrentUser` + `raw_username` + chroot `FolderDto`, ~8-9
|
||||
/// `String` allocs) on every authenticated NC request.
|
||||
///
|
||||
/// On path-scoped DAV routes (`/remote.php/dav/{files,uploads,
|
||||
/// trashbin}/{user}/…`), the URL `{user}` segment is cross-checked
|
||||
@@ -113,14 +121,33 @@ fn extract_url_user(path: &str) -> Option<String> {
|
||||
/// (`get_folder_with_perms`) is what actually prevents cross-user
|
||||
/// access. It just surfaces malformed requests early (403) instead
|
||||
/// of silently letting them through.
|
||||
impl<S: Send + Sync> FromRequestParts<S> for NcSession {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SharedNcSession(Arc<NcSession>);
|
||||
|
||||
impl SharedNcSession {
|
||||
/// Wrap an already-shared session (used by the bench harness; the
|
||||
/// middleware inserts the `Arc` into request extensions directly).
|
||||
pub fn from_arc(session: Arc<NcSession>) -> Self {
|
||||
Self(session)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for SharedNcSession {
|
||||
type Target = NcSession;
|
||||
|
||||
fn deref(&self) -> &NcSession {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for SharedNcSession {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let session = parts
|
||||
.extensions
|
||||
.get::<Arc<NcSession>>()
|
||||
.map(|arc| (**arc).clone())
|
||||
.cloned()
|
||||
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
|
||||
|
||||
if let Some(url_user) = extract_url_user(parts.uri.path())
|
||||
@@ -129,6 +156,6 @@ impl<S: Send + Sync> FromRequestParts<S> for NcSession {
|
||||
return Err(StatusCode::FORBIDDEN.into_response());
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
Ok(Self(session))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
|
||||
pub async fn handle_nc_trashbin(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
session: crate::interfaces::nextcloud::session::NcSession,
|
||||
session: crate::interfaces::nextcloud::session::SharedNcSession,
|
||||
subpath: String,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let method = req.method().clone();
|
||||
|
||||
@@ -110,7 +110,7 @@ async fn session_bytes_so_far(
|
||||
pub async fn handle_nc_uploads(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
session: crate::interfaces::nextcloud::session::NcSession,
|
||||
session: crate::interfaces::nextcloud::session::SharedNcSession,
|
||||
upload_id: String,
|
||||
rest: String, // chunk name or ".file" or empty
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
|
||||
@@ -218,7 +218,7 @@ pub fn nc_href(username: &str, subpath: &str) -> String {
|
||||
pub async fn handle_nc_webdav(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
session: crate::interfaces::nextcloud::session::NcSession,
|
||||
session: crate::interfaces::nextcloud::session::SharedNcSession,
|
||||
subpath: String,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
// Validate up-front that we have a chroot — every method below is
|
||||
@@ -1566,19 +1566,29 @@ fn build_nc_streaming_propfind(
|
||||
}
|
||||
let batch_len = batch.len();
|
||||
|
||||
// Per-page enrichment: favorites + oc:fileids, two batch queries.
|
||||
let favs = if let Some(fav) = fav_svc {
|
||||
let items: Vec<(&str, &str)> =
|
||||
batch.iter().map(|f| (f.id.as_str(), "file")).collect();
|
||||
fav.batch_check_favorites(user_id, &items).await.unwrap_or_default()
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
// Per-page enrichment: favorites + oc:fileids + dead props —
|
||||
// three independent reads over the same id batch, overlapped
|
||||
// with `join!` so a page pays ~max(RTT) instead of 3×RTT
|
||||
// (each query still batched per page: DEAD-PROPS.md). The
|
||||
// round-7 deferred "serial pairs" item, adopted for this
|
||||
// per-page triple after the injected-latency A/B in
|
||||
// benches/ROUND9.md showed no local-PG regression.
|
||||
let fav_items: Vec<(&str, &str)> =
|
||||
batch.iter().map(|f| (f.id.as_str(), "file")).collect();
|
||||
let file_uuids: Vec<&str> = batch.iter().map(|f| f.id.as_str()).collect();
|
||||
let (file_id_map, _) = batch_resolve_ids(file_id_svc, &file_uuids, &[]).await;
|
||||
// One batched dead-props query per page, not one per child
|
||||
// (benches/DEAD-PROPS.md).
|
||||
let file_deads = files_dead_props_map(&state.webdav_dead_props, &batch).await;
|
||||
let (favs, (file_id_map, _), file_deads) = tokio::join!(
|
||||
async {
|
||||
if let Some(fav) = fav_svc {
|
||||
fav.batch_check_favorites(user_id, &fav_items)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
HashSet::new()
|
||||
}
|
||||
},
|
||||
batch_resolve_ids(file_id_svc, &file_uuids, &[]),
|
||||
files_dead_props_map(&state.webdav_dead_props, &batch),
|
||||
);
|
||||
|
||||
let mut chunk = Vec::with_capacity(batch_len * 1024);
|
||||
{
|
||||
@@ -1624,18 +1634,23 @@ fn build_nc_streaming_propfind(
|
||||
break;
|
||||
}
|
||||
|
||||
let favs = if let Some(fav) = fav_svc {
|
||||
let items: Vec<(&str, &str)> =
|
||||
batch.iter().map(|sf| (sf.id.as_str(), "folder")).collect();
|
||||
fav.batch_check_favorites(user_id, &items).await.unwrap_or_default()
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
// Same overlapped enrichment triple as the file pages above.
|
||||
let fav_items: Vec<(&str, &str)> =
|
||||
batch.iter().map(|sf| (sf.id.as_str(), "folder")).collect();
|
||||
let folder_uuids: Vec<&str> = batch.iter().map(|sf| sf.id.as_str()).collect();
|
||||
let (_, sub_id_map) = batch_resolve_ids(file_id_svc, &[], &folder_uuids).await;
|
||||
// Batched — see benches/DEAD-PROPS.md.
|
||||
let sub_deads =
|
||||
folders_dead_props_map(&state.webdav_dead_props, &batch).await;
|
||||
let (favs, (_, sub_id_map), sub_deads) = tokio::join!(
|
||||
async {
|
||||
if let Some(fav) = fav_svc {
|
||||
fav.batch_check_favorites(user_id, &fav_items)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
HashSet::new()
|
||||
}
|
||||
},
|
||||
batch_resolve_ids(file_id_svc, &[], &folder_uuids),
|
||||
folders_dead_props_map(&state.webdav_dead_props, &batch),
|
||||
);
|
||||
|
||||
let mut chunk = Vec::with_capacity(batch.len() * 1024);
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user