diff --git a/Cargo.toml b/Cargo.toml index bb006857..405d81c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -283,6 +283,57 @@ name = "bench_owner_cache" path = "examples/bench_owner_cache.rs" required-features = ["bench"] +# Round-4 battery ───────────────────────────────────────────────────────────── + +# PG row → entity path materialization — the per-listing-row make_file_path +# split→rejoin + NFC copy chain vs the one-pass builders. No Postgres. +[[example]] +name = "bench_row_path" +path = "examples/bench_row_path.rs" +required-features = ["bench"] + +# WebDAV drive-selector resolution — the per-request list_readable_by grants +# join vs the per-user readable_cache (needs the dev Postgres up). +[[example]] +name = "bench_drive_selector" +path = "examples/bench_drive_selector.rs" +required-features = ["bench"] + +# CalDAV parse path — from_ical's 8×-reparse vs single parse, per-event +# uppercase copies on REPORT/GET, UID clone churn. No Postgres. +[[example]] +name = "bench_caldav_parse" +path = "examples/bench_caldav_parse.rs" +required-features = ["bench"] + +# PROPFIND per-row XML emit — partition Vec churn + chrono format-interpreter +# dates vs single-pass + stack-rendered fields. No Postgres. +[[example]] +name = "bench_propfind_xml" +path = "examples/bench_propfind_xml.rs" +required-features = ["bench"] + +# Grant-listing hydration N+1 (calendars / address books / playlists) + +# user-flags cold-cache herd (needs the dev Postgres up). +[[example]] +name = "bench_n1_hydration" +path = "examples/bench_n1_hydration.rs" +required-features = ["bench"] + +# Face-indexing fan-out — unbounded per-image spawn vs core-count semaphore; +# peak-live-heap + wall on the bench_support photo corpus. No Postgres. +[[example]] +name = "bench_faces_bound" +path = "examples/bench_faces_bound.rs" +required-features = ["bench"] + +# Azure download path — whole-blob collect vs streamed pages, TTFB + peak +# live heap against a local Azure-GET stub (endpoint_url hook). No Postgres. +[[example]] +name = "bench_azure_stream" +path = "examples/bench_azure_stream.rs" +required-features = ["bench"] + # Round-3 battery ───────────────────────────────────────────────────────────── # Web-UI folder listing — whole-folder rescan + top-N sort per page vs keyset diff --git a/benches/ROUND4.md b/benches/ROUND4.md new file mode 100644 index 00000000..63635ac7 --- /dev/null +++ b/benches/ROUND4.md @@ -0,0 +1,251 @@ +# Round 4 — row-path allocs, drive-selector cache, CalDAV parse, PROPFIND emit, N+1 hydration, Azure streaming, faces bound + +Eight benchmark-gated changes. Rule of the round (same as ROUND2/ROUND3): +every change ships with a BEFORE/AFTER benchmark; an AFTER that doesn't +beat its BEFORE gets rolled back — none did. Equivalence gates +(byte-identical output / identical row or id sets / BLAKE3 payload +identity) guard every behavior-preserving rewrite. + +Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release +profile. Reproduce any row with the command in its section. + +## Summary + +| # | change | key metric | before → after | +|--:|---|---|---| +| 1 | Row→entity path build (one-pass) | ns/row file / allocs | 743 → 417 (**1.78x**), 15.8 → 10.5 | +| 2 | Drive-selector readable-cache | µs/resolution p50, 8 conns | 441 → 0.80 (**~550x**), queries → 0 | +| 3 | CalDAV single-parse `from_ical` | µs/event PUT parse | 83.8 → 11.8 (**7.1x**) | +| 4 | CalDAV read-side copies | chunk ns / group µs (5k) | 297 → 215 (**1.4x**) / 1221 → 951 (**1.3x**) | +| 5 | PROPFIND XML emit | µs/1100-row page / allocs/row | 1535 → 1253 (**1.22x**), 17.9 → 12.0 | +| 6 | Grant-listing hydration batch | ms/listing K=15 | 4.4 → 0.33 (**~13x**), 15 queries → 1 | +| 7 | user-flags single-flight | cold herd of 32 | 32 → 1 query, 4.7 → 0.6 ms | +| 8 | Azure download streaming | TTFB / peak heap, 256 MiB | 349 → 4 ms (**87x**), 480 → 1.9 MiB (**254x**) | +| 9 | Face-indexing semaphore | peak live heap, 48 images | 1175 → 176 MiB (**6.7x**), wall also −13% | + +--- + +## [1] PG row → entity path materialization — one-pass builders — 1.78x + +Every listing row (PROPFIND batches, photos timeline, search pages, +by-ids enrichment, subtree ZIP streams) paid this chain: files re-joined +the materialized folder path with `format!`, split the copy into a +per-segment `Vec`, NFC-copied the already-NFC name +(`normalize_storage_name` always allocated), then `Display`/`join` +re-joined the segments it had just split into `path_string` — the only +form the DTOs actually serve. Folders arrived with an owned canonical +`path` column, split it, dropped it, and rebuilt an identical String. + +Now: `StoragePath::from_folder_and_name` / `from_joined` build segments +AND the joined string in one pass (`from_joined` reuses the owned input +when canonical — every row the repository writes), the entity +constructors take the name by value through the new zero-copy +`normalize_storage_name_owned`, `Display` writes segments without the +`join` temp, and both duplicated repo-side `make_file_path` copies were +replaced by the shared builder (`File::from_materialized_row` / +`Folder::from_materialized_row`). + +``` +cargo run --release --features bench --example bench_row_path +# 10k rows, 100 passes ns/row (p50) allocs/row +# File BEFORE 743.2 15.75 +# File AFTER 416.8 1.78x 10.51 +# Folder BEFORE 704.8 14.08 +# Folder AFTER 620.4 1.14x 10.08 +# gate: (name, path_string, segments) byte-identical + error parity, +# realistic corpus + adversarial (traversal, //, NFD, empties) +``` + +## [2] WebDAV drive-selector — grants join/request → per-user cache — ~550x + +`lookup_drive_selector` (every native `/webdav//…` request, +all verbs, MOVE/COPY twice) ran `list_readable_by`: a +role_grants ⋈ drives ⋈ folders join with inline transitive-group +expansion, GROUP BY + MIN(role) + ORDER BY — per request, uncached. The +same join also ran per request in search, trash listing and the +`GET /api/drives` picker. + +Now `DrivePgRepository` carries a `readable_cache` +(user → `Arc>`, 30 s TTL, `try_get_with` +single-flight, errors never cached) mirroring the CHROOT-CACHE +precedent. Every mutation that can change a user's drive list +invalidates explicitly: personal/shared drive creation, deletion, policy +edits (repo), membership set/remove (`DriveManagementService`, per-User +subject or full clear for Group subjects), and group-membership changes +(`SubjectGroupService` invalidates per affected transitive user). The +residual staleness sources (root-folder rename; grant writes that can't +reach this cache) stay bounded by the same 30 s TTL the sibling caches +accept; permission *enforcement* is unaffected (the ACL engine +re-checks per operation with its own invalidation). + +``` +cargo run --release --features bench --example bench_drive_selector +# pool=20, window=4s, 3 drives/user req/s p50 µs p99 µs queries +# conc=8 BEFORE (join/request) 17,098 441.23 1143.85 68,394 +# conc=8 AFTER (readable_cache) 2,371,541 0.80 8.61 0 +# conc=64 BEFORE 21,462 2818.27 5440.99 85,850 +# conc=64 AFTER 1,506,230 1.71 17.08 0 +# gate: (id, name) sequences identical — BEFORE == cold == warm +``` + +## [3] CalDAV `from_ical` — 8 full parses per VEVENT → 1 — 7.1x + +`CalendarEvent::from_ical` funnelled each of its 8 property lookups +(SUMMARY, DTSTART, DTEND, DESCRIPTION, LOCATION, RRULE, UID, +RECURRENCE-ID) through an extractor that re-ran the complete +`IcalParser` — line unfolding + full component-tree build — over the +whole body. Every CalDAV PUT paid 8 parses per VEVENT; a master+M- +exceptions PUT paid `8·(M+1)`; an N-event import `8·N`. +`update_ical_data` had the same shape (7 lookups). Now both parse ONCE +and read properties from the parsed component; value-only lookups also +skip the parameter-map build, and `split_vevents` stopped uppercasing +every line into a fresh String (allocation-free CI prefix test). + +``` +cargo run --release --features bench --example bench_caldav_parse +# 200 realistic ~1.3 KiB VEVENTs (params, folding, VALARM, exceptions) +# [1] from_ical µs/event 83.81 → 11.76 (excl. body clone) 7.1x +# [2] 50-event import body µs 4412.5 → 1002.3 4.4x +# gates: parsed fields byte-identical (incl. all-day, exceptions, +# mixed-case tags, LF-only bodies), error parity, wrapped +# per-row ical_data identical +``` + +## [4] CalDAV read side — per-event copies removed — 1.3-1.4x + +`extract_vevent_chunk` (every REPORT / collection-GET, per event) +allocated a full `to_ascii_uppercase()` copy of the stored body just to +locate two tags — now a memchr fast path (stored bodies carry uppercase +tags) with an allocation-free case-insensitive scan fallback. +`group_events_by_uid` cloned every event's UID String into its map — +now borrowed keys. `generate_calendar_events_response` also stopped +cloning the requested-props Vec per REPORT. + +``` +# [3] extract_vevent_chunk ns/event 297 → 215 1.4x (stable +# across 3 isolated re-runs; one battery pass showed 0.9x noise) +# [4] group_events_by_uid µs/5k events 1221.0 → 951.1 1.3x +# gates: identical chunk slices (incl. mixed-case, missing-terminator, +# malformed bodies), identical grouping shape +``` + +## [5] PROPFIND XML emit — single-pass + stack-rendered fields — 1.22x + +For EVERY file/folder row of every PROPFIND page the writers paid a +`partition` into two throwaway `Vec<&QualifiedName>`s (+ a third for the +404 list) even though the requested-props writer already skips unknown +names itself, plus `to_rfc3339()` + `to_rfc2822()` (chrono's format-spec +interpreter + a heap String each), `size.to_string()` and a +`format!("\"{etag}\"")`. Now: one pass computing only the +usually-empty 404 list, and `common::fmt` stack renderers — RFC 3339 / +RFC 2822 / integers written into stack buffers, byte-identical to chrono +(sweep-tested across 60 years; out-of-range values keep the chrono +fallback). The same renderers replaced the per-row date/etag/size +formatting in the NextCloud PROPFIND emitters. + +The first version of `rfc2822_utc` zero-padded the day; chrono does not +(`Thu, 1 Jan`). **The byte-identity gate caught it** and the padded +version never shipped — exactly the failure mode these gates exist for. + +``` +cargo run --release --features bench --example bench_propfind_xml +# 1000 files + 100 folders/page, 200 passes µs/page allocs/row +# named-prop (sync set) BEFORE 1534.9 17.91 +# AFTER 1253.1 1.22x 12.00 +# allprop (+quota) BEFORE 1072.1 9.67 +# AFTER 895.1 1.20x 4.58 +# gate: multistatus XML byte-identical (named-prop incl. unknown + dead +# props, allprop with quota; epoch/padded-day/2099 timestamps) +``` + +## [6] Grant-listing hydration — K point SELECTs → one `= ANY` — ~13x + +After `list_incoming_grants`, the CalDAV calendar discovery, CardDAV +book discovery and playlist listing each hydrated their K accessible +resources with K SERIAL point SELECTs, awaited one by one, on every +client sync poll / dashboard load. New batch methods +(`find_calendars_by_ids` / `get_address_books_by_ids` / +`find_playlists_by_ids`) collapse each listing to one round-trip; +missing rows still drop out silently (deleted/trashed race carve-out +preserved). + +``` +cargo run --release --features bench --example bench_n1_hydration +# K=15 resources, 200 passes ms/listing p50 queries +# calendars BEFORE → AFTER 4.411 → 0.338 15 → 1 13.0x +# address books BEFORE → AFTER 4.365 → 0.325 15 → 1 13.4x +# playlists BEFORE → AFTER 4.378 → 0.342 15 → 1 12.8x +# gate: identical id sets loop vs batch (+ ghost-id drop-out parity) +``` + +## [7] user-flags cache — get→insert → single-flight — 32 → 1 queries + +`get_user_flags` backs the auth middleware's per-request role/active +guard. Its cache was get→insert: on every 30 s TTL expiry, every +in-flight request of that user fired the SELECT concurrently (the same +herd shape ROUND3 fixed for basic-auth, minus the Argon2 cost). Now +`moka::future` + `try_get_with`: concurrent misses coalesce, errors are +never cached, eager invalidation on role/active changes unchanged. + +``` +# cold-cache herd of 32 concurrent callers +# BEFORE (get→insert) 4.72 ms 32 queries +# AFTER (try_get_with) 0.57 ms 1 query +# gate: identical flags from every caller +``` + +## [8] Azure download path — whole-blob buffering → streaming — 87-254x + +`AzureBlobBackend::get_blob_stream` / `get_blob_range_stream` drained +the ENTIRE blob (or range) into one `Vec` before yielding a single +mega-chunk: whole-blob RAM residency per reader, TTFB = full download +time, and with `read_prefetch() = 8` the CDC reassembly path could hold +8 entire chunk-blobs at once. Now the SDK's page/body streams forward +directly (first page still awaited eagerly so a missing blob surfaces +as the same up-front NotFound). `AzureStorageConfig` gained +`endpoint_url` (`OXICLOUD_AZURE_ENDPOINT_URL`) mirroring S3's override — +it powers the bench stub and enables Azurite for local dev. + +``` +cargo run --release --features bench --example bench_azure_stream +# 256 MiB blob, local Azure-GET stub TTFB ms wall ms peak heap MiB +# full BEFORE (collect-then-yield) 349.3 465.3 479.8 +# full AFTER (streamed) 4.0 308.5 1.9 87x / 254x +# tail-128 MiB range BEFORE 165.5 225.3 240.7 +# tail-128 MiB range AFTER 1.3 147.3 1.9 125x / 127x +# gate: BLAKE3(BEFORE) == BLAKE3(AFTER) == source, full + range +``` + +## [9] Face indexing — unbounded per-image spawn → semaphore — 6.7x RAM + +`FaceIndexingService::spawn_index` fired one `tokio::spawn` per +uploaded/copied image with no ceiling; each task reads the full blob +and decodes it before inference, so a bulk upload of N photos held up +to N decoded images in flight. Now an `Arc` sized to the +effective core count (`OXICLOUD_FACES_INDEX_CONCURRENCY` override), +permit acquired BEFORE the blob read — the exact +`ThumbnailService::decode_semaphore` invariant ("peak memory = +permits × image size"). Pattern bench (the real service needs +Postgres + an ONNX model): task body = full-file read + JPEG/PNG decode +on the `bench_support` corpus, spawn/permit shape copied verbatim. + +``` +cargo run --release --features bench --example bench_faces_bound +# 48 × 11.1 MiB images, permits=4 wall ms peak live heap MiB +# BEFORE (unbounded) 870.5 1175.4 +# AFTER (semaphore 4) 755.1 176.0 6.7x lower +# gate: all 48 images decoded identically in both modes +``` + +## Follow-ups worth a future round (confirmed real, not gated here) + +- Grouped/swimlane files view is still unvirtualized (10k-row DOM) — + frontend, carried over from ROUND3. +- CalDAV REPORT / collection-GET still buffer the full multistatus / + VCALENDAR in RAM (`caldav_handler.rs`) — the WebDAV surface streams, + the CalDAV one doesn't yet; pairs with paged event loading. +- Auth middleware per-request `user_id.to_string()` span records and + owned `CurrentUser` strings (`interfaces/middleware/auth.rs`) — + small but ubiquitous. +- Search suggest clones each entity before DTO conversion + (`search_service.rs:525/539`). diff --git a/examples/bench_azure_stream.rs b/examples/bench_azure_stream.rs new file mode 100644 index 00000000..89e4dd7a --- /dev/null +++ b/examples/bench_azure_stream.rs @@ -0,0 +1,398 @@ +//! Azure download-path benchmark — whole-blob buffering vs streaming (ROUND4). +//! +//! The old `AzureBlobBackend::get_blob_stream` / `get_blob_range_stream` +//! drained the ENTIRE blob (or range) into one `Vec` before yielding +//! a single mega-chunk: whole-blob RAM residency per reader, TTFB = full +//! download time, and with `read_prefetch() = 8` the CDC reassembly path +//! could hold 8 entire chunk-blobs at once. AFTER forwards the SDK's +//! page/body streams directly (first page still awaited eagerly so a +//! missing blob is an up-front NotFound). +//! +//! Technique: a local axum stub speaks just enough of the Azure Blob GET +//! REST surface (ranged 16 MiB pages, `x-ms-*` headers) for the REAL +//! `azure_storage_blobs` client — the backend points at it via the new +//! `endpoint_url` override (also the Azurite hook). The stub synthesizes +//! blob bytes deterministically per offset, so it holds no buffer and +//! the peak-live-heap metric isolates the CLIENT path. BEFORE is the old +//! collect-everything logic copied verbatim; AFTER is the real +//! `AzureBlobBackend`. BLAKE3 gates assert byte-identical payloads. +//! +//! Run (no Postgres needed): +//! cargo run --release --features bench --example bench_azure_stream +//! Tunables (env): BENCH_MB (256) blob size, BENCH_TAIL_MB (128) range tail. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use axum::body::Body; +use axum::http::{HeaderMap, Request, Response, StatusCode}; +use bytes::Bytes; +use futures::StreamExt; +use oxicloud::application::ports::blob_storage_ports::BlobStorageBackend; +use oxicloud::common::config::AzureStorageConfig; +use oxicloud::infrastructure::services::azure_blob_backend::AzureBlobBackend; +use tokio::net::TcpListener; + +// ─── Peak-live-heap tracking allocator ────────────────────────────────────── + +static LIVE: AtomicU64 = AtomicU64::new(0); +static PEAK: AtomicU64 = AtomicU64::new(0); + +struct PeakAlloc; + +fn bump(sz: u64) { + let live = LIVE.fetch_add(sz, Ordering::Relaxed) + sz; + PEAK.fetch_max(live, Ordering::Relaxed); +} + +unsafe impl GlobalAlloc for PeakAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + bump(layout.size() as u64); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + LIVE.fetch_sub(layout.size() as u64, Ordering::Relaxed); + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + if new_size > layout.size() { + bump((new_size - layout.size()) as u64); + } else { + LIVE.fetch_sub((layout.size() - new_size) as u64, Ordering::Relaxed); + } + unsafe { System.realloc(ptr, layout, new_size) } + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + bump(layout.size() as u64); + unsafe { System.alloc_zeroed(layout) } + } +} + +#[global_allocator] +static GLOBAL: PeakAlloc = PeakAlloc; + +// ─── Deterministic blob content (no stored buffer) ────────────────────────── + +fn splitmix64(mut z: u64) -> u64 { + z = z.wrapping_add(0x9E3779B97F4A7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + z ^ (z >> 31) +} + +/// Fill `out` with the blob bytes at absolute offset `offset`. +fn fill_at(out: &mut [u8], offset: u64) { + let mut i = 0usize; + while i < out.len() { + let abs = offset + i as u64; + let block = abs / 8; + let word = splitmix64(block).to_le_bytes(); + let start_in_word = (abs % 8) as usize; + let take = (8 - start_in_word).min(out.len() - i); + out[i..i + take].copy_from_slice(&word[start_in_word..start_in_word + take]); + i += take; + } +} + +/// BLAKE3 of an arbitrary blob range, streamed in 1 MiB pieces. +fn expected_hash(offset: u64, len: u64) -> blake3::Hash { + let mut hasher = blake3::Hasher::new(); + let mut buf = vec![0u8; 1 << 20]; + let mut pos = 0u64; + while pos < len { + let take = ((len - pos) as usize).min(buf.len()); + fill_at(&mut buf[..take], offset + pos); + hasher.update(&buf[..take]); + pos += take as u64; + } + hasher.finalize() +} + +// ─── Azure Blob GET stub ──────────────────────────────────────────────────── + +fn parse_range(headers: &HeaderMap) -> Option<(u64, Option)> { + let raw = headers + .get("x-ms-range") + .or_else(|| headers.get("range"))? + .to_str() + .ok()?; + let spec = raw.strip_prefix("bytes=")?; + let (a, b) = spec.split_once('-')?; + let start: u64 = a.parse().ok()?; + let end: Option = if b.is_empty() { None } else { b.parse().ok() }; + Some((start, end)) +} + +/// Serve GET {container}/{blob} with ranged responses in streamed 256 KiB +/// frames, synthesizing content per offset — the stub never holds the blob. +async fn stub_azure(blob_len: u64) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind stub"); + let addr = listener.local_addr().expect("stub addr"); + + let app = axum::Router::new().fallback(move |req: Request| async move { + if req.method() != axum::http::Method::GET { + return Response::builder() + .status(StatusCode::CREATED) + .header("etag", "\"0x1\"") + .header("last-modified", "Thu, 01 Jan 2026 00:00:00 GMT") + .header("x-ms-request-id", "11111111-1111-1111-1111-111111111111") + .header("date", "Thu, 01 Jan 2026 00:00:00 GMT") + .body(Body::empty()) + .unwrap(); + } + let (start, end_incl) = parse_range(req.headers()).unwrap_or((0, None)); + let end_incl = end_incl.unwrap_or(blob_len - 1).min(blob_len - 1); + let this_len = end_incl - start + 1; + + // Stream the payload in 256 KiB frames, generated on the fly. + let body_stream = futures::stream::unfold(0u64, move |sent| async move { + if sent >= this_len { + return None; + } + let take = ((this_len - sent) as usize).min(256 * 1024); + let mut frame = vec![0u8; take]; + fill_at(&mut frame, start + sent); + Some(( + Ok::(Bytes::from(frame)), + sent + take as u64, + )) + }); + + Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header("content-type", "application/octet-stream") + .header("content-length", this_len.to_string()) + .header( + "content-range", + format!("bytes {start}-{end_incl}/{blob_len}"), + ) + .header("etag", "\"0x1\"") + .header("last-modified", "Thu, 01 Jan 2026 00:00:00 GMT") + .header("x-ms-blob-type", "BlockBlob") + .header("x-ms-lease-status", "unlocked") + .header("x-ms-lease-state", "available") + .header("x-ms-request-id", "11111111-1111-1111-1111-111111111111") + .header("x-ms-version", "2020-04-08") + .header("x-ms-creation-time", "Thu, 01 Jan 2026 00:00:00 GMT") + .header("x-ms-server-encrypted", "true") + .header("date", "Thu, 01 Jan 2026 00:00:00 GMT") + .body(Body::from_stream(body_stream)) + .unwrap() + }); + + tokio::spawn(async move { + axum::serve(listener, app).await.expect("stub serve"); + }); + format!("http://{addr}/devaccount") +} + +// ─── BEFORE: verbatim old collect-everything implementations ──────────────── + +mod before { + use super::*; + use azure_storage_blobs::prelude::BlobClient; + use oxicloud::application::ports::blob_storage_ports::BlobStream; + + /// Old `get_blob_stream` body (drain everything, yield one chunk). + pub async fn get_blob_stream(client: &BlobClient) -> Result { + let mut result_data: Vec = Vec::new(); + let mut stream = client.get().into_stream(); + + while let Some(response) = stream.next().await { + let response = response.map_err(|e| format!("Failed to get blob: {e}"))?; + let mut body = response.data; + while let Some(chunk) = body.next().await { + let chunk = chunk.map_err(|e| format!("Stream read error: {e}"))?; + result_data.extend_from_slice(&chunk); + } + } + + let stream: BlobStream = Box::pin(futures::stream::once(async move { + Ok(Bytes::from(result_data)) + })); + Ok(stream) + } + + /// Old `get_blob_range_stream` body. + pub async fn get_blob_range_stream( + client: &BlobClient, + start: u64, + end: Option, + ) -> Result { + let range = match end { + Some(e) => azure_core::request_options::Range::new(start, e), + None => azure_core::request_options::Range::new(start, u64::MAX), + }; + + let mut result_data: Vec = Vec::new(); + let mut stream = client.get().range(range).into_stream(); + + while let Some(response) = stream.next().await { + let response = response.map_err(|e| format!("Failed to get blob range: {e}"))?; + let mut body = response.data; + while let Some(chunk) = body.next().await { + let chunk = chunk.map_err(|e| format!("Stream range read error: {e}"))?; + result_data.extend_from_slice(&chunk); + } + } + + let stream: BlobStream = Box::pin(futures::stream::once(async move { + Ok(Bytes::from(result_data)) + })); + Ok(stream) + } +} + +// ─── Drain helper: TTFB + wall + hash ─────────────────────────────────────── + +async fn drain( + stream: oxicloud::application::ports::blob_storage_ports::BlobStream, + t0: Instant, +) -> (f64, f64, blake3::Hash, u64) { + let mut stream = stream; + let mut hasher = blake3::Hasher::new(); + let mut ttfb = None; + let mut total = 0u64; + while let Some(chunk) = stream.next().await { + let chunk = chunk.expect("stream chunk"); + if ttfb.is_none() { + ttfb = Some(t0.elapsed().as_secs_f64() * 1e3); + } + total += chunk.len() as u64; + hasher.update(&chunk); + } + ( + ttfb.unwrap_or(f64::NAN), + t0.elapsed().as_secs_f64() * 1e3, + hasher.finalize(), + total, + ) +} + +fn reset_peak() { + PEAK.store(LIVE.load(Ordering::Relaxed), Ordering::Relaxed); +} + +fn peak_mib() -> f64 { + PEAK.load(Ordering::Relaxed) as f64 / (1024.0 * 1024.0) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + let mb: u64 = env::var("BENCH_MB") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(256); + let tail_mb: u64 = env::var("BENCH_TAIL_MB") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(128); + let blob_len = mb * 1024 * 1024; + let hash = "aabbccdd00112233445566778899eeff00112233445566778899aabbccddeeff"; + + let endpoint = stub_azure(blob_len).await; + println!("bench_azure_stream — {mb} MiB blob via local stub at {endpoint}\n"); + + // AFTER: the real backend pointed at the stub via endpoint_url. + let backend = AzureBlobBackend::new(&AzureStorageConfig { + account_name: "devaccount".to_string(), + account_key: base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + b"benchkeybenchkeybenchkey", + ), + container: "blobs".to_string(), + sas_token: None, + endpoint_url: Some(endpoint.clone()), + }); + + // BEFORE: a raw SDK client at the same endpoint for the verbatim old code. + let creds = azure_storage::StorageCredentials::access_key( + "devaccount", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + b"benchkeybenchkeybenchkey", + ), + ); + let old_client = azure_storage_blobs::prelude::ClientBuilder::with_location( + azure_storage::CloudLocation::Custom { + account: "devaccount".to_string(), + uri: endpoint.clone(), + }, + creds, + ) + .container_client("blobs") + .blob_client(format!("{}/{}.blob", &hash[0..2], hash)); + + let expect_full = expected_hash(0, blob_len); + let tail_start = blob_len - tail_mb * 1024 * 1024; + let expect_tail = expected_hash(tail_start, blob_len - tail_start); + + // ── [1] Full-blob download ────────────────────────────────────────────── + reset_peak(); + let t0 = Instant::now(); + let s = before::get_blob_stream(&old_client) + .await + .expect("before stream"); + let (ttfb_b, wall_b, hash_b, len_b) = drain(s, t0).await; + let peak_b = peak_mib(); + + reset_peak(); + let t0 = Instant::now(); + let s = backend.get_blob_stream(hash).await.expect("after stream"); + let (ttfb_a, wall_a, hash_a, len_a) = drain(s, t0).await; + let peak_a = peak_mib(); + + println!("[1] full {mb} MiB download TTFB ms wall ms peak live heap MiB"); + println!(" BEFORE (collect-then-yield) {ttfb_b:9.1} {wall_b:9.1} {peak_b:10.1}"); + println!( + " AFTER (streamed) {ttfb_a:9.1} {wall_a:9.1} {peak_a:10.1} TTFB {:.0}x, heap {:.0}x lower", + ttfb_b / ttfb_a, + peak_b / peak_a + ); + + // ── [2] Open-ended range (seek to last {tail_mb} MiB) ─────────────────── + reset_peak(); + let t0 = Instant::now(); + let s = before::get_blob_range_stream(&old_client, tail_start, None) + .await + .expect("before range"); + let (rttfb_b, rwall_b, rhash_b, rlen_b) = drain(s, t0).await; + let rpeak_b = peak_mib(); + + reset_peak(); + let t0 = Instant::now(); + let s = backend + .get_blob_range_stream(hash, tail_start, None) + .await + .expect("after range"); + let (rttfb_a, rwall_a, rhash_a, rlen_a) = drain(s, t0).await; + let rpeak_a = peak_mib(); + + println!("[2] range bytes={tail_start}- ({tail_mb} MiB tail)"); + println!(" BEFORE (collect-then-yield) {rttfb_b:9.1} {rwall_b:9.1} {rpeak_b:10.1}"); + println!( + " AFTER (streamed) {rttfb_a:9.1} {rwall_a:9.1} {rpeak_a:10.1} TTFB {:.0}x, heap {:.0}x lower", + rttfb_b / rttfb_a, + rpeak_b / rpeak_a + ); + + // ── Equivalence gates ─────────────────────────────────────────────────── + let mut ok = true; + if hash_b != expect_full || hash_a != expect_full || len_b != blob_len || len_a != blob_len { + eprintln!("GATE FAIL full blob: hashes/length differ"); + ok = false; + } + if rhash_b != expect_tail || rhash_a != expect_tail || rlen_b != rlen_a { + eprintln!("GATE FAIL range: hashes/length differ"); + ok = false; + } + println!( + "\n[gate] BLAKE3(BEFORE) == BLAKE3(AFTER) == source: {}", + if ok { "OK" } else { "FAILED" } + ); + if !ok { + std::process::exit(1); + } +} diff --git a/examples/bench_caldav_parse.rs b/examples/bench_caldav_parse.rs new file mode 100644 index 00000000..08cdf32c --- /dev/null +++ b/examples/bench_caldav_parse.rs @@ -0,0 +1,572 @@ +//! CalDAV parse-path benchmark — the write-side 8×-reparse and the +//! read-side per-event copies (ROUND4). +//! +//! What changed: +//! +//! • `CalendarEvent::from_ical` funnelled each of its 8 property +//! lookups through an extractor that re-ran the full `IcalParser` +//! (line unfolding + component tree) over the whole body — 8 +//! complete parses per VEVENT on every CalDAV PUT, `8·(M+1)` on a +//! master+M-exceptions PUT, `8·N` on an N-event import. Now: one +//! parse, all lookups on the parsed component (value-only lookups +//! also skip the parameter-map build). +//! • `split_vevents` uppercased EVERY line into a fresh String. +//! Now: allocation-free case-insensitive prefix tests. +//! • `extract_vevent_chunk` (read side: every REPORT/GET, per event) +//! allocated a full uppercase copy of the stored body just to find +//! two tags. Now: memchr fast path + alloc-free CI scan fallback. +//! • `group_events_by_uid` (read side, per REPORT) cloned every +//! event's UID String. Now: borrowed keys. +//! +//! The OLD logic is copied verbatim into `mod before`; equivalence +//! gates assert byte-identical parsed fields / chunk slices / grouping +//! across a corpus incl. folded lines, params, VALARM, all-day, +//! exceptions and mixed-case tags (exit 1 on any diff). +//! +//! Run (no Postgres needed): +//! cargo run --release --features bench --example bench_caldav_parse +//! Tunables (env): +//! BENCH_EVENTS (200) BENCH_PASSES (30) BENCH_GROUP_N (5000) + +use std::env; +use std::hint::black_box; +use std::time::Instant; + +use chrono::{DateTime, TimeZone, Utc}; +use oxicloud::application::adapters::caldav_adapter::bench as caldav_bench; +use oxicloud::application::dtos::calendar_dto::CalendarEventDto; +use oxicloud::domain::entities::calendar_event::CalendarEvent; +use uuid::Uuid; + +// ─── BEFORE: verbatim copies of the pre-optimization logic ────────────────── + +#[allow(clippy::all)] +mod before { + use std::collections::HashMap; + + /// Old `parse_first_vevent` — fresh parser per call. + pub fn parse_first_vevent(ical_data: &str) -> Option { + use std::io::BufReader; + let reader = BufReader::new(ical_data.as_bytes()); + let parser = ical::IcalParser::new(reader); + for cal in parser { + let Ok(cal) = cal else { continue }; + if let Some(event) = cal.events.into_iter().next() { + return Some(event); + } + } + None + } + + /// Old params-aware extractor — one FULL parse per property lookup. + pub fn extract_ical_property_with_params( + ical_data: &str, + property_name: &str, + ) -> Option<(String, HashMap>)> { + let event = parse_first_vevent(ical_data)?; + let prop = event + .properties + .into_iter() + .find(|p| p.name.eq_ignore_ascii_case(property_name))?; + let value = prop.value?; + if value.trim().is_empty() { + return None; + } + let mut params: HashMap> = HashMap::new(); + if let Some(param_list) = prop.params { + for (name, values) in param_list { + params.insert(name.to_ascii_uppercase(), values); + } + } + Some((value.trim().to_string(), params)) + } + + pub fn extract_ical_property(ical_data: &str, property_name: &str) -> Option { + extract_ical_property_with_params(ical_data, property_name).map(|(v, _p)| v) + } + + /// Comparable subset of the entity fields `from_ical` derives. + #[derive(Debug, PartialEq)] + pub struct BeforeEvent { + pub summary: String, + pub description: Option, + pub location: Option, + pub start_time: chrono::DateTime, + pub end_time: chrono::DateTime, + pub all_day: bool, + pub rrule: Option, + pub ical_uid: Option, + pub recurrence_id: Option>, + } + + /// Old `from_ical` body (8 extractor calls = 8 full parses), minus + /// the entity envelope (ids/timestamps — identical on both sides). + pub fn from_ical(ical_data: &str) -> Result { + let summary = extract_ical_property(ical_data, "SUMMARY").ok_or("Missing SUMMARY")?; + let (dtstart_value, dtstart_params) = + extract_ical_property_with_params(ical_data, "DTSTART").ok_or("Missing DTSTART")?; + let (dtend_value, _dtend_params) = + extract_ical_property_with_params(ical_data, "DTEND").ok_or("Missing DTEND")?; + let all_day = dtstart_params + .get("VALUE") + .map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE"))) + .unwrap_or(false); + let start_time = parse_ical_datetime(&dtstart_value, all_day)?; + let end_time = parse_ical_datetime(&dtend_value, all_day)?; + let description = extract_ical_property(ical_data, "DESCRIPTION"); + let location = extract_ical_property(ical_data, "LOCATION"); + let rrule = extract_ical_property(ical_data, "RRULE"); + let ical_uid = extract_ical_property(ical_data, "UID"); + let recurrence_id = match extract_ical_property_with_params(ical_data, "RECURRENCE-ID") { + Some((value, params)) => { + let is_date = params + .get("VALUE") + .map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE"))) + .unwrap_or(false); + parse_ical_datetime(&value, is_date).ok() + } + None => None, + }; + Ok(BeforeEvent { + summary, + description, + location, + start_time, + end_time, + all_day, + rrule, + ical_uid, + recurrence_id, + }) + } + + /// Old datetime parser (verbatim semantics for the two supported forms). + pub fn parse_ical_datetime( + value: &str, + is_date_only: bool, + ) -> Result, String> { + use chrono::TimeZone; + if is_date_only { + if value.len() != 8 { + return Err("bad all-day".into()); + } + let year: i32 = value[0..4].parse().map_err(|_| "year")?; + let month: u32 = value[4..6].parse().map_err(|_| "month")?; + let day: u32 = value[6..8].parse().map_err(|_| "day")?; + return chrono::NaiveDate::from_ymd_opt(year, month, day) + .map(|d| chrono::Utc.from_utc_datetime(&d.and_hms_opt(0, 0, 0).unwrap())) + .ok_or_else(|| "date".into()); + } + if value.len() < 15 || !value.ends_with('Z') { + return Err(format!("bad datetime {value:?}")); + } + let year: i32 = value[0..4].parse().map_err(|_| "year")?; + let month: u32 = value[4..6].parse().map_err(|_| "month")?; + let day: u32 = value[6..8].parse().map_err(|_| "day")?; + let hour: u32 = value[9..11].parse().map_err(|_| "hour")?; + let minute: u32 = value[11..13].parse().map_err(|_| "minute")?; + let second: u32 = value[13..15].parse().map_err(|_| "second")?; + match chrono::NaiveDate::from_ymd_opt(year, month, day) { + Some(date) => match date.and_hms_opt(hour, minute, second) { + Some(datetime) => Ok(chrono::Utc.from_utc_datetime(&datetime)), + None => Err("time".into()), + }, + None => Err("date".into()), + } + } + + /// Old `split_vevents` — per-line uppercase String. + pub fn split_vevents(ical_data: &str) -> Vec { + let mut blocks = Vec::new(); + let mut in_event = false; + let mut current = String::new(); + for raw_line in ical_data.split('\n') { + let line = raw_line.trim_end_matches('\r'); + let upper = line.trim_start().to_ascii_uppercase(); + if upper.starts_with("BEGIN:VEVENT") { + in_event = true; + current.clear(); + } + if in_event { + current.push_str(line); + current.push_str("\r\n"); + } + if in_event && upper.starts_with("END:VEVENT") { + blocks.push(std::mem::take(&mut current)); + in_event = false; + } + } + blocks + } + + /// Old `extract_vevent_chunk` — full uppercase copy of the body. + pub fn extract_vevent_chunk(ical_data: &str) -> Option<&str> { + let upper = ical_data.to_ascii_uppercase(); + let begin = upper.find("BEGIN:VEVENT")?; + let after_begin = &upper[begin..]; + let rel_end = after_begin.find("END:VEVENT")?; + let end_tag_end = begin + rel_end + "END:VEVENT".len(); + let mut end = end_tag_end; + if ical_data[end..].starts_with('\r') { + end += 1; + } + if ical_data[end..].starts_with('\n') { + end += 1; + } + Some(&ical_data[begin..end]) + } + + /// Old `group_events_by_uid` — String-keyed map, UID cloned per event. + pub fn group_events_by_uid<'a>( + events: &'a [oxicloud::application::dtos::calendar_dto::CalendarEventDto], + ) -> Vec> { + let mut order: Vec = Vec::new(); + let mut buckets: HashMap< + String, + Vec<&'a oxicloud::application::dtos::calendar_dto::CalendarEventDto>, + > = HashMap::new(); + for event in events { + let key = event.ical_uid.clone(); + if !buckets.contains_key(&key) { + order.push(key.clone()); + } + buckets.entry(key).or_default().push(event); + } + let mut out = Vec::with_capacity(order.len()); + for uid in order { + let mut bucket = buckets.remove(&uid).unwrap_or_default(); + bucket.sort_by_key(|e| e.recurrence_id.is_some()); + out.push(bucket); + } + out + } +} + +// ─── Corpus ───────────────────────────────────────────────────────────────── + +/// A realistic ~1.3 KiB VEVENT: params on DTSTART, folded DESCRIPTION, +/// three ATTENDEEs with CN/PARTSTAT, ORGANIZER, VALARM, CATEGORIES, +/// STATUS and X-props. `variant` 0 = timed master with RRULE, 1 = all-day, +/// 2 = exception override (RECURRENCE-ID). +fn build_vevent_body(i: usize, variant: usize) -> String { + let uid = format!("evt-{i:05}@oxicloud.bench"); + let mut v = String::with_capacity(1400); + v.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n"); + v.push_str("BEGIN:VEVENT\r\n"); + v.push_str(&format!("UID:{uid}\r\n")); + v.push_str("DTSTAMP:20260701T120000Z\r\n"); + match variant { + 1 => { + v.push_str("DTSTART;VALUE=DATE:20260810\r\n"); + v.push_str("DTEND;VALUE=DATE:20260811\r\n"); + } + 2 => { + v.push_str("DTSTART:20260812T090000Z\r\n"); + v.push_str("DTEND:20260812T100000Z\r\n"); + v.push_str("RECURRENCE-ID:20260812T090000Z\r\n"); + } + _ => { + v.push_str("DTSTART:20260805T090000Z\r\n"); + v.push_str("DTEND:20260805T103000Z\r\n"); + v.push_str("RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20261231T000000Z\r\n"); + } + } + v.push_str(&format!( + "SUMMARY:Sprint review #{i} — métricas y datos\r\n" + )); + v.push_str( + "DESCRIPTION:Repaso de los objetivos del sprint con el equipo completo\\, in\r\n cluyendo demo de la nueva vista de fotos y el plan de la ronda de rendimien\r\n to número cuatro.\r\n", + ); + v.push_str("LOCATION:Sala Turing — 3ª planta\r\n"); + v.push_str("ORGANIZER;CN=Ana García:mailto:ana@example.com\r\n"); + v.push_str( + "ATTENDEE;CN=Luis Pérez;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:luis@example.com\r\n", + ); + v.push_str("ATTENDEE;CN=Sam Chen;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:sam@example.com\r\n"); + v.push_str("ATTENDEE;CN=Río Núñez;PARTSTAT=TENTATIVE:mailto:rio@example.com\r\n"); + v.push_str("CATEGORIES:TRABAJO,EQUIPO\r\n"); + v.push_str("STATUS:CONFIRMED\r\n"); + v.push_str("SEQUENCE:2\r\n"); + v.push_str("TRANSP:OPAQUE\r\n"); + v.push_str("X-OXICLOUD-ROUND:4\r\n"); + v.push_str("BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\n"); + v.push_str("END:VEVENT\r\n"); + v.push_str("END:VCALENDAR\r\n"); + v +} + +/// N-event import body (master + exception pairs inside one VCALENDAR). +fn build_import_body(n_events: usize) -> String { + let mut v = String::with_capacity(n_events * 1400); + v.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Foreign//Client//EN\r\n"); + for i in 0..n_events { + let single = build_vevent_body(i, i % 3); + // Extract just the VEVENT block from the standalone body. + let begin = single.find("BEGIN:VEVENT").unwrap(); + let end = single.find("END:VEVENT").unwrap() + "END:VEVENT\r\n".len(); + v.push_str(&single[begin..end]); + } + v.push_str("END:VCALENDAR\r\n"); + v +} + +fn make_dto(i: usize, uid: &str, recurrence: Option>) -> CalendarEventDto { + CalendarEventDto { + id: Uuid::from_u128(i as u128).to_string(), + calendar_id: Uuid::nil().to_string(), + summary: format!("Evento {i}"), + description: None, + location: None, + start_time: Utc.with_ymd_and_hms(2026, 8, 5, 9, 0, 0).unwrap(), + end_time: Utc.with_ymd_and_hms(2026, 8, 5, 10, 0, 0).unwrap(), + all_day: false, + rrule: None, + ical_uid: uid.to_string(), + recurrence_id: recurrence, + ical_data: build_vevent_body(i, if recurrence.is_some() { 2 } else { 0 }), + created_at: Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(), + updated_at: Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(), + } +} + +fn p50(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[xs.len() / 2] +} + +fn time_passes(passes: usize, mut f: impl FnMut() -> T) -> f64 { + let mut per_pass = Vec::with_capacity(passes); + for _ in 0..passes { + let t0 = Instant::now(); + black_box(f()); + per_pass.push(t0.elapsed().as_secs_f64() * 1e6); + } + p50(per_pass) +} + +fn main() { + let n_events: usize = env::var("BENCH_EVENTS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(200); + let passes: usize = env::var("BENCH_PASSES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(30); + let group_n: usize = env::var("BENCH_GROUP_N") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5000); + + let calendar_id = Uuid::nil(); + let bodies: Vec = (0..n_events).map(|i| build_vevent_body(i, i % 3)).collect(); + let import_body = build_import_body(50); + + println!("bench_caldav_parse — {n_events} bodies, {passes} passes\n"); + + // ── [1] from_ical: single-event PUT path ──────────────────────────────── + let t_before = time_passes(passes, || { + for b in &bodies { + black_box(before::from_ical(b).expect("before parse")); + } + }) / n_events as f64; + let t_after = time_passes(passes, || { + for b in &bodies { + black_box(CalendarEvent::from_ical(calendar_id, b.clone()).expect("after parse")); + } + }) / n_events as f64; + // The AFTER side clones the body (the real API takes it by value) — + // measure that clone alone so the comparison can subtract it. + let t_clone = time_passes(passes, || { + for b in &bodies { + black_box(b.clone()); + } + }) / n_events as f64; + println!("[1] from_ical µs/event (8-parse chain vs single parse)"); + println!(" BEFORE {t_before:8.2}"); + println!( + " AFTER {t_after:8.2} (incl. {t_clone:.2} body clone) {:.1}x", + t_before / (t_after - t_clone) + ); + + // ── [2] parse_all_events: 50-event import PUT ─────────────────────────── + let t_before_imp = time_passes(passes, || { + let blocks = before::split_vevents(&import_body); + let mut out = Vec::with_capacity(blocks.len()); + for block in blocks { + let wrapped = format!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n{}END:VCALENDAR\r\n", + block, + ); + out.push(before::from_ical(&wrapped).expect("before import")); + } + out + }); + let t_after_imp = time_passes(passes, || { + CalendarEvent::parse_all_events(calendar_id, &import_body).expect("after import") + }); + println!("[2] parse_all_events µs/50-event import body"); + println!(" BEFORE {t_before_imp:8.1}"); + println!( + " AFTER {t_after_imp:8.1} {:.1}x", + t_before_imp / t_after_imp + ); + + // ── [3] extract_vevent_chunk: REPORT/GET read path ────────────────────── + let t_chunk_before = time_passes(passes, || { + for b in &bodies { + black_box(before::extract_vevent_chunk(b)); + } + }) / n_events as f64 + * 1000.0; + let t_chunk_after = time_passes(passes, || { + for b in &bodies { + black_box(caldav_bench::extract_vevent_chunk(b)); + } + }) / n_events as f64 + * 1000.0; + println!("[3] extract_vevent_chunk ns/event (uppercase copy vs direct scan)"); + println!(" BEFORE {t_chunk_before:8.0}"); + println!( + " AFTER {t_chunk_after:8.0} {:.1}x", + t_chunk_before / t_chunk_after + ); + + // ── [4] group_events_by_uid: REPORT fold ──────────────────────────────── + // 80% masters, 20% exception overrides sharing a master's UID. + let dtos: Vec = (0..group_n) + .map(|i| { + if i % 5 == 4 { + let master = i - 1; + make_dto( + i, + &format!("evt-{master:05}@oxicloud.bench"), + Some(Utc.with_ymd_and_hms(2026, 8, 12, 9, 0, 0).unwrap()), + ) + } else { + make_dto(i, &format!("evt-{i:05}@oxicloud.bench"), None) + } + }) + .collect(); + let t_grp_before = time_passes(passes, || black_box(before::group_events_by_uid(&dtos))); + let t_grp_after = time_passes(passes, || { + black_box(caldav_bench::group_events_by_uid(&dtos)) + }); + println!("[4] group_events_by_uid µs/{group_n} events (String keys vs borrowed)"); + println!(" BEFORE {t_grp_before:8.1}"); + println!( + " AFTER {t_grp_after:8.1} {:.1}x", + t_grp_before / t_grp_after + ); + + // ── [5] Equivalence gates ─────────────────────────────────────────────── + let mut ok = true; + + // Gate A: from_ical field identity across the corpus + edge bodies. + let mut gate_bodies: Vec = bodies.clone(); + gate_bodies.push(build_vevent_body(9990, 1)); + gate_bodies.push(build_vevent_body(9991, 2)); + // Mixed-case tags + LF-only line endings (foreign client shapes). + gate_bodies.push( + "begin:vcalendar\nversion:2.0\nbegin:vevent\nuid:mixed-case@x\nsummary:Mixed Case\ndtstart:20260801T080000Z\ndtend:20260801T090000Z\nend:vevent\nend:vcalendar\n" + .to_string(), + ); + for b in &gate_bodies { + let bf = before::from_ical(b); + let af = CalendarEvent::from_ical(calendar_id, b.clone()); + match (bf, af) { + (Ok(bf), Ok(af)) => { + let same = bf.summary == af.summary() + && bf.description.as_deref() == af.description() + && bf.location.as_deref() == af.location() + && bf.start_time == *af.start_time() + && bf.end_time == *af.end_time() + && bf.all_day == af.all_day() + && bf.rrule.as_deref() == af.rrule() + && bf.ical_uid.as_deref() == Some(af.ical_uid()) + && bf.recurrence_id.as_ref() == af.recurrence_id(); + if !same { + eprintln!("GATE A FAIL: field mismatch for body:\n{b}\n before={bf:?}"); + ok = false; + } + } + (Err(_), Err(_)) => {} + (bf, af) => { + eprintln!( + "GATE A FAIL: error parity broke (before_ok={} after_ok={}) for body:\n{b}", + bf.is_ok(), + af.is_ok() + ); + ok = false; + } + } + } + + // Gate B: parse_all_events equivalence on the import body — same + // events, same wrapped per-row ical_data. + let after_events = + CalendarEvent::parse_all_events(calendar_id, &import_body).expect("import parses"); + let before_blocks = before::split_vevents(&import_body); + if after_events.len() != before_blocks.len() { + eprintln!( + "GATE B FAIL: event count {} != block count {}", + after_events.len(), + before_blocks.len() + ); + ok = false; + } + for (evt, block) in after_events.iter().zip(&before_blocks) { + let wrapped = format!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n{}END:VCALENDAR\r\n", + block, + ); + if evt.ical_data() != wrapped { + eprintln!("GATE B FAIL: wrapped ical_data mismatch"); + ok = false; + break; + } + let bf = before::from_ical(&wrapped).expect("before parses wrapped"); + if bf.summary != evt.summary() || bf.recurrence_id.as_ref() != evt.recurrence_id() { + eprintln!("GATE B FAIL: field mismatch on wrapped block"); + ok = false; + break; + } + } + + // Gate C: chunk slices byte-identical (incl. mixed-case + no-terminator). + let mut chunk_bodies = bodies.clone(); + chunk_bodies.push("BEGIN:VCALENDAR\r\nbegin:vevent\r\nUID:x@y\r\nend:vevent".to_string()); + chunk_bodies.push("no vevent here at all".to_string()); + for b in &chunk_bodies { + if before::extract_vevent_chunk(b) != caldav_bench::extract_vevent_chunk(b) { + eprintln!("GATE C FAIL: chunk mismatch for body:\n{b}"); + ok = false; + } + } + + // Gate D: grouping identity — same UID order, same per-bucket rows. + let g_before = before::group_events_by_uid(&dtos); + let g_after = caldav_bench::group_events_by_uid(&dtos); + let shape = |g: &Vec>| -> Vec> { + g.iter() + .map(|bucket| { + bucket + .iter() + .map(|e| (e.id.clone(), e.recurrence_id.is_some())) + .collect() + }) + .collect() + }; + if shape(&g_before) != shape(&g_after) { + eprintln!("GATE D FAIL: grouping mismatch"); + ok = false; + } + + println!( + "[5] Equivalence gates: {}", + if ok { "OK (byte-identical)" } else { "FAILED" } + ); + if !ok { + std::process::exit(1); + } +} diff --git a/examples/bench_drive_selector.rs b/examples/bench_drive_selector.rs new file mode 100644 index 00000000..e2416965 --- /dev/null +++ b/examples/bench_drive_selector.rs @@ -0,0 +1,333 @@ +//! WebDAV drive-selector resolution benchmark — grants join/request vs moka. +//! +//! Every native `/webdav//…` request (all verbs; MOVE and COPY +//! twice) resolved its scope through `lookup_drive_selector` → +//! `DriveRepository::list_readable_by`: a role_grants ⋈ drives ⋈ folders +//! join with inline transitive-group expansion, GROUP BY + MIN(role) + +//! ORDER BY — per request, uncached. The same join also ran per request +//! in search, trash listing and the `GET /api/drives` picker. +//! +//! AFTER wires the per-user `readable_cache` (30 s TTL, single-flight, +//! explicit invalidation on every membership/lifecycle mutation) into +//! `DrivePgRepository` — this bench drives the REAL repository (cache, +//! `try_get_with` and the per-hit `Vec` clone included), not a synthetic +//! lookup, against the verbatim BEFORE query. +//! +//! Run (needs Postgres up; reads DATABASE_URL from .env): +//! cargo run --release --features bench --example bench_drive_selector +//! Tunables (env): BENCH_POOL (20), BENCH_SECONDS (4), BENCH_CONCURRENCIES ("8,64"). + +use std::env; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use oxicloud::domain::repositories::drive_repository::DriveRepository; +use oxicloud::infrastructure::repositories::pg::DrivePgRepository; +use sqlx::postgres::PgPoolOptions; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +struct Seeded { + user_id: Uuid, +} + +/// user → personal drive (default) + two shared drives, each with a +/// role_grant for the user — the shape a typical DAV-syncing member of a +/// small team resolves on every request. +async fn seed(pool: &PgPool) -> Seeded { + let mut tx = pool.begin().await.expect("begin"); + let user_id: Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, role) + VALUES ('bench_drivesel', 'bench_drivesel@bench.invalid', 'user') + RETURNING id", + ) + .fetch_one(&mut *tx) + .await + .expect("seed user"); + + // (name, kind, default_for_user, role) + let drives: [(&str, &str, Option, &str); 3] = [ + ("Personal", "personal", Some(user_id), "owner"), + ("Equipo Diseño", "shared", None, "editor"), + ("Archivo 2026", "shared", None, "viewer"), + ]; + for (name, kind, default_for, role) in drives { + let drive_id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.drives (kind, default_for_user) VALUES ($1, $2) RETURNING id", + ) + .bind(kind) + .bind(default_for) + .fetch_one(&mut *tx) + .await + .expect("seed drive"); + let folder_id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.folders (name, path, lpath, drive_id) + VALUES ($1, '/' || $1, 'x', $2) RETURNING id", + ) + .bind(name) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("seed folder"); + sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2") + .bind(folder_id) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("stamp root"); + sqlx::query( + "INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) + VALUES ('user', $1, 'drive', $2, $3::storage.grant_role, $1)", + ) + .bind(user_id) + .bind(drive_id) + .bind(role) + .execute(&mut *tx) + .await + .expect("seed grant"); + } + tx.commit().await.expect("commit"); + Seeded { user_id } +} + +async fn cleanup(pool: &PgPool, user_id: Uuid) { + // Drives/folders/grants cascade off the user via the grant cleanup + // trigger + explicit deletes (drives carry no owner FK). + let ids: Vec = sqlx::query_scalar( + "SELECT resource_id FROM storage.role_grants + WHERE subject_type = 'user' AND subject_id = $1 AND resource_type = 'drive'", + ) + .bind(user_id) + .fetch_all(pool) + .await + .unwrap_or_default(); + for id in ids { + let _ = sqlx::query( + "DELETE FROM storage.role_grants WHERE resource_type='drive' AND resource_id=$1", + ) + .bind(id) + .execute(pool) + .await; + let root: Option = + sqlx::query_scalar("SELECT root_folder_id FROM storage.drives WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await + .ok() + .flatten(); + let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1") + .bind(id) + .execute(pool) + .await; + if let Some(root) = root { + let _ = sqlx::query("DELETE FROM storage.folders WHERE id = $1") + .bind(root) + .execute(pool) + .await; + } + } + let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1") + .bind(user_id) + .execute(pool) + .await; +} + +/// The exact production BEFORE — `list_readable_by`'s query, verbatim. +async fn one_op_before(pool: &PgPool, user_id: Uuid, queries: &AtomicUsize) -> Vec<(Uuid, String)> { + let rows = sqlx::query( + r#" + SELECT d.id, d.kind, d.default_for_user, d.root_folder_id, + d.quota_bytes, d.used_bytes, d.policies, + d.created_at, d.updated_at, + f.name AS root_folder_name, + MIN(g.role)::text AS caller_role + FROM storage.drives d + JOIN storage.folders f ON f.id = d.root_folder_id + JOIN storage.role_grants g + ON g.resource_type = 'drive' + AND g.resource_id = d.id + WHERE ( + (g.subject_type = 'user' AND g.subject_id = $1) + OR (g.subject_type = 'group' AND g.subject_id IN + (SELECT storage.caller_group_ids($1))) + ) + AND (g.expires_at IS NULL OR g.expires_at > NOW()) + GROUP BY d.id, d.kind, d.default_for_user, d.root_folder_id, + d.quota_bytes, d.used_bytes, d.policies, + d.created_at, d.updated_at, f.name + ORDER BY (d.default_for_user IS NULL) ASC, + LOWER(f.name) ASC + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await + .expect("grants join"); + queries.fetch_add(1, Ordering::Relaxed); + rows.iter() + .map(|r| { + ( + r.get::("id"), + r.get::("root_folder_name"), + ) + }) + .collect() +} + +struct Stats { + rps: f64, + p50: f64, + p95: f64, + p99: f64, +} + +fn summarize(mut lats: Vec, secs: u64) -> Stats { + lats.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let n = lats.len(); + let pct = |p: f64| { + if n == 0 { + 0.0 + } else { + lats[((n as f64 * p) as usize).min(n - 1)] + } + }; + Stats { + rps: n as f64 / secs as f64, + p50: pct(0.50), + p95: pct(0.95), + p99: pct(0.99), + } +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + dotenvy::dotenv().ok(); + let url = env::var("DATABASE_URL") + .or_else(|_| env::var("OXICLOUD_DB_CONNECTION_STRING")) + .expect("set DATABASE_URL — the dev Postgres URL"); + + let pool_size: u32 = env_or("BENCH_POOL", 20); + let secs: u64 = env_or("BENCH_SECONDS", 4); + let concurrencies: Vec = env::var("BENCH_CONCURRENCIES") + .ok() + .map(|s| s.split(',').filter_map(|x| x.trim().parse().ok()).collect()) + .unwrap_or_else(|| vec![8, 64]); + + let pool = Arc::new( + PgPoolOptions::new() + .max_connections(pool_size) + .min_connections(pool_size) + .acquire_timeout(Duration::from_secs(10)) + .connect(&url) + .await + .expect("connect Postgres"), + ); + + let seeded = seed(&pool).await; + let user_id = seeded.user_id; + + // AFTER = the real repository with its readable_cache. + let repo = Arc::new(DrivePgRepository::new(pool.clone())); + + // ── Equivalence gate: BEFORE rows == repo output (cold), == warm hit ── + let gate_q = AtomicUsize::new(0); + let before_rows = one_op_before(&pool, user_id, &gate_q).await; + let cold: Vec<(Uuid, String)> = repo + .list_readable_by(user_id) + .await + .expect("repo list") + .into_iter() + .map(|d| (d.drive.id, d.root_folder_name)) + .collect(); + let warm: Vec<(Uuid, String)> = repo + .list_readable_by(user_id) + .await + .expect("repo list warm") + .into_iter() + .map(|d| (d.drive.id, d.root_folder_name)) + .collect(); + if before_rows != cold || cold != warm { + eprintln!( + "EQUIVALENCE GATE FAILED:\n before={before_rows:?}\n cold={cold:?}\n warm={warm:?}" + ); + cleanup(&pool, user_id).await; + std::process::exit(1); + } + if before_rows.len() != 3 { + eprintln!("seed expected 3 readable drives, got {}", before_rows.len()); + cleanup(&pool, user_id).await; + std::process::exit(1); + } + + println!("\n#################################################################"); + println!("# WebDAV drive-selector: BEFORE (grants join/req) vs AFTER (cache)"); + println!("# pool={pool_size} window={secs}s/run drives/user=3"); + println!("#################################################################\n"); + println!( + "| {:>5} | {:<6} | {:>10} | {:>9} | {:>9} | {:>9} | {:>9} |", + "conc", "mode", "req/s", "p50 µs", "p95 µs", "p99 µs", "queries" + ); + + for &conc in &concurrencies { + for mode in ["BEFORE", "AFTER"] { + let queries = Arc::new(AtomicUsize::new(0)); + let deadline = Instant::now() + Duration::from_secs(secs); + let mut handles = Vec::new(); + for _ in 0..conc { + let pool = pool.clone(); + let repo = repo.clone(); + let queries = queries.clone(); + let mode = mode.to_string(); + handles.push(tokio::spawn(async move { + let mut lats = Vec::new(); + while Instant::now() < deadline { + let t = Instant::now(); + if mode == "BEFORE" { + std::hint::black_box(one_op_before(&pool, user_id, &queries).await); + } else { + let v = repo.list_readable_by(user_id).await.expect("repo list"); + std::hint::black_box(v); + } + lats.push(t.elapsed().as_secs_f64() * 1_000_000.0); + if mode == "AFTER" { + // cache hit is sub-µs; yield so the loop doesn't + // monopolise workers and skew the run count. + tokio::task::yield_now().await; + } + } + lats + })); + } + let mut all = Vec::new(); + for h in handles { + all.extend(h.await.unwrap()); + } + let s = summarize(all, secs); + println!( + "| {:>5} | {:<6} | {:>10.0} | {:>9.2} | {:>9.2} | {:>9.2} | {:>9} |", + conc, + mode, + s.rps, + s.p50, + s.p95, + s.p99, + queries.load(Ordering::Relaxed) + ); + } + } + + cleanup(&pool, user_id).await; + println!("\n(BEFORE = the verbatim list_readable_by join per request; AFTER = the"); + println!(" real DrivePgRepository serving from its per-user readable_cache —"); + println!(" try_get_with single-flight + per-hit Vec clone included. Equivalence"); + println!(" gate asserts identical (id, name) sequences: BEFORE == cold == warm.)"); +} diff --git a/examples/bench_faces_bound.rs b/examples/bench_faces_bound.rs new file mode 100644 index 00000000..64257433 --- /dev/null +++ b/examples/bench_faces_bound.rs @@ -0,0 +1,173 @@ +//! Face-indexing fan-out benchmark — unbounded spawn vs semaphore (ROUND4). +//! +//! `FaceIndexingService::spawn_index` fired one `tokio::spawn` per +//! uploaded/copied image with NO ceiling; each task reads the full blob +//! into RAM and decodes it before inference. A bulk upload of N photos +//! therefore held up to N decoded images in flight simultaneously. +//! AFTER: an `Arc` sized to the effective core count +//! (`OXICLOUD_FACES_INDEX_CONCURRENCY` override), permit acquired BEFORE +//! the blob read — the exact `ThumbnailService::decode_semaphore` +//! invariant ("peak memory = permits × image size"). +//! +//! This is a *pattern* bench (like POOL-CONCURRENCY / RUNTIME): the real +//! service needs Postgres + an ONNX model, so the task body models the +//! dominant costs — full-file read + JPEG decode on the deterministic +//! `bench_support` photo corpus — while the spawn/permit shape is copied +//! from the service verbatim. Metrics: wall time, PEAK LIVE HEAP (exact, +//! via counting allocator), decode results asserted identical. +//! +//! Run (no Postgres needed): +//! cargo run --release --features bench --example bench_faces_bound +//! Tunables (env): BENCH_IMAGES (48), BENCH_PERMITS (effective cores). + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::hint::black_box; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::time::Instant; + +// ─── Peak-live-heap tracking allocator ────────────────────────────────────── + +static LIVE: AtomicU64 = AtomicU64::new(0); +static PEAK: AtomicU64 = AtomicU64::new(0); + +struct PeakAlloc; + +fn bump(sz: u64) { + let live = LIVE.fetch_add(sz, Ordering::Relaxed) + sz; + PEAK.fetch_max(live, Ordering::Relaxed); +} + +unsafe impl GlobalAlloc for PeakAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + bump(layout.size() as u64); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + LIVE.fetch_sub(layout.size() as u64, Ordering::Relaxed); + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + if new_size > layout.size() { + bump((new_size - layout.size()) as u64); + } else { + LIVE.fetch_sub((layout.size() - new_size) as u64, Ordering::Relaxed); + } + unsafe { System.realloc(ptr, layout, new_size) } + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + bump(layout.size() as u64); + unsafe { System.alloc_zeroed(layout) } + } +} + +#[global_allocator] +static GLOBAL: PeakAlloc = PeakAlloc; + +/// The modelled per-image work: full blob read (as `index_file` does via +/// `tokio::fs::read`) + JPEG decode (the analyzer's first step). +async fn index_one(path: std::path::PathBuf, dims: Arc) { + let bytes = tokio::fs::read(&path).await.expect("read blob"); + let img = tokio::task::spawn_blocking(move || image::load_from_memory(&bytes).expect("decode")) + .await + .expect("join decode"); + dims.fetch_add((img.width() + img.height()) as usize, Ordering::Relaxed); + black_box(img); +} + +fn effective_parallelism() -> usize { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(2) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + let images: usize = env::var("BENCH_IMAGES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(48); + let permits: usize = env::var("BENCH_PERMITS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or_else(effective_parallelism); + + // Deterministic photo corpus (12 MP JPEG case) → one temp file per + // "upload" so each task pays a real filesystem read. + let corpus = oxicloud::bench_support::load_or_generate(); + let jpeg = corpus + .iter() + .max_by_key(|c| c.bytes.len()) + .expect("corpus nonempty"); + println!( + "bench_faces_bound — {images} images ({} · {:.1} MiB encoded), permits={permits}\n", + jpeg.name, + jpeg.bytes.len() as f64 / (1024.0 * 1024.0) + ); + let dir = tempfile::tempdir().expect("tempdir"); + let mut paths = Vec::with_capacity(images); + for i in 0..images { + let p = dir.path().join(format!("{i}.blob")); + std::fs::write(&p, &jpeg.bytes).expect("write blob"); + paths.push(p); + } + + // ── BEFORE: unbounded spawn per image (the old spawn_index shape) ── + let dims_before = Arc::new(AtomicUsize::new(0)); + PEAK.store(LIVE.load(Ordering::Relaxed), Ordering::Relaxed); + let t0 = Instant::now(); + let mut handles = Vec::with_capacity(images); + for p in &paths { + let p = p.clone(); + let dims = dims_before.clone(); + handles.push(tokio::spawn(async move { + index_one(p, dims).await; + })); + } + for h in handles { + h.await.unwrap(); + } + let wall_before = t0.elapsed().as_secs_f64() * 1e3; + let peak_before = PEAK.load(Ordering::Relaxed) as f64 / (1024.0 * 1024.0); + + // ── AFTER: same spawn shape + semaphore permit before the read ── + let dims_after = Arc::new(AtomicUsize::new(0)); + let semaphore = Arc::new(tokio::sync::Semaphore::new(permits)); + PEAK.store(LIVE.load(Ordering::Relaxed), Ordering::Relaxed); + let t0 = Instant::now(); + let mut handles = Vec::with_capacity(images); + for p in &paths { + let p = p.clone(); + let dims = dims_after.clone(); + let semaphore = semaphore.clone(); + handles.push(tokio::spawn(async move { + let _permit = semaphore + .acquire_owned() + .await + .expect("semaphore never closes"); + index_one(p, dims).await; + })); + } + for h in handles { + h.await.unwrap(); + } + let wall_after = t0.elapsed().as_secs_f64() * 1e3; + let peak_after = PEAK.load(Ordering::Relaxed) as f64 / (1024.0 * 1024.0); + + println!(" wall ms peak live heap MiB"); + println!("BEFORE (unbounded) {wall_before:8.1} {peak_before:10.1}"); + println!( + "AFTER (semaphore {permits:>2}) {wall_after:8.1} {peak_after:10.1} heap {:.1}x lower", + peak_before / peak_after + ); + + // ── Equivalence gate: identical decode results ── + let db = dims_before.load(Ordering::Relaxed); + let da = dims_after.load(Ordering::Relaxed); + if db != da || db == 0 { + eprintln!("GATE FAIL: dimension sums differ (before={db} after={da})"); + std::process::exit(1); + } + println!("\n[gate] OK — all {images} images decoded identically in both modes"); +} diff --git a/examples/bench_n1_hydration.rs b/examples/bench_n1_hydration.rs new file mode 100644 index 00000000..d79ddcca --- /dev/null +++ b/examples/bench_n1_hydration.rs @@ -0,0 +1,436 @@ +//! Grant-listing hydration N+1 benchmark + user-flags herd (ROUND4). +//! +//! [1-3] After `list_incoming_grants`, the CalDAV calendar discovery, +//! CardDAV book discovery and playlist listing each hydrated their K +//! accessible resources with K SERIAL point SELECTs (one +//! `WHERE id = $1` round-trip per resource, awaited in a loop) on every +//! client sync poll / dashboard load. AFTER: one `WHERE id = ANY($1)` +//! round-trip via the new `find_*_by_ids` batch methods — this bench +//! drives the REAL repositories both ways (the single-get methods still +//! exist for point lookups). +//! +//! [4] `get_user_flags` (called by the auth middleware on EVERY +//! authenticated request) used a get→insert cache: on each 30 s TTL +//! expiry, all in-flight requests of that user fired the SELECT +//! concurrently. AFTER: `try_get_with` single-flight. The bench +//! replicates both cache patterns around the real `UserPgRepository` +//! query, herd-style. +//! +//! Equivalence gates: identical id sets from loop vs batch for all +//! three resources; identical flags from every herd caller. +//! +//! Run (needs Postgres up; reads DATABASE_URL from .env): +//! cargo run --release --features bench --example bench_n1_hydration +//! Tunables (env): BENCH_RESOURCES (15), BENCH_PASSES (200), BENCH_HERD (32). + +use std::collections::HashSet; +use std::env; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use oxicloud::domain::repositories::address_book_repository::AddressBookRepository; +use oxicloud::domain::repositories::calendar_repository::CalendarRepository; +use oxicloud::domain::repositories::playlist_repository::PlaylistRepository; +use oxicloud::infrastructure::repositories::pg::{ + AddressBookPgRepository, CalendarPgRepository, PlaylistPgRepository, UserPgRepository, +}; +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; +use uuid::Uuid; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +struct Seeded { + user_id: Uuid, + calendar_ids: Vec, + book_ids: Vec, + playlist_ids: Vec, +} + +async fn seed(pool: &PgPool, n: usize) -> Seeded { + let user_id: Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, role) + VALUES ('bench_n1', 'bench_n1@bench.invalid', 'user') RETURNING id", + ) + .fetch_one(pool) + .await + .expect("seed user"); + + let mut calendar_ids = Vec::with_capacity(n); + let mut book_ids = Vec::with_capacity(n); + let mut playlist_ids = Vec::with_capacity(n); + for i in 0..n { + calendar_ids.push( + sqlx::query_scalar( + "INSERT INTO caldav.calendars (id, name, owner_id, color) + VALUES (gen_random_uuid(), $1, $2, '#3788d8') RETURNING id", + ) + .bind(format!("Calendario {i}")) + .bind(user_id) + .fetch_one(pool) + .await + .expect("seed calendar"), + ); + book_ids.push( + sqlx::query_scalar( + "INSERT INTO carddav.address_books (id, name, owner_id) + VALUES (gen_random_uuid(), $1, $2) RETURNING id", + ) + .bind(format!("Libreta {i}")) + .bind(user_id) + .fetch_one(pool) + .await + .expect("seed book"), + ); + playlist_ids.push( + sqlx::query_scalar( + "INSERT INTO audio.playlists (name, owner_id) + VALUES ($1, $2) RETURNING id", + ) + .bind(format!("Lista {i}")) + .bind(user_id) + .fetch_one(pool) + .await + .expect("seed playlist"), + ); + } + Seeded { + user_id, + calendar_ids, + book_ids, + playlist_ids, + } +} + +async fn cleanup(pool: &PgPool, s: &Seeded) { + let _ = sqlx::query("DELETE FROM caldav.calendars WHERE owner_id = $1") + .bind(s.user_id) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM carddav.address_books WHERE owner_id = $1") + .bind(s.user_id) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM audio.playlists WHERE owner_id = $1") + .bind(s.user_id) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1") + .bind(s.user_id) + .execute(pool) + .await; +} + +fn p50(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[xs.len() / 2] +} + +async fn bench_pair( + label: &str, + passes: usize, + n: usize, + mut before: FB, + mut after: FA, +) where + FB: AsyncFnMut() -> TB, + FA: AsyncFnMut() -> TA, +{ + let mut lb = Vec::with_capacity(passes); + let mut la = Vec::with_capacity(passes); + for _ in 0..passes { + let t0 = Instant::now(); + std::hint::black_box(before().await); + lb.push(t0.elapsed().as_secs_f64() * 1e3); + let t0 = Instant::now(); + std::hint::black_box(after().await); + la.push(t0.elapsed().as_secs_f64() * 1e3); + } + let b = p50(lb); + let a = p50(la); + println!("[{label}] ms/listing (p50, K={n})"); + println!(" BEFORE (K point SELECTs) {b:8.3} ({n} queries)"); + println!( + " AFTER (1 × = ANY) {a:8.3} (1 query) {:.1}x", + b / a + ); +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + dotenvy::dotenv().ok(); + let url = env::var("DATABASE_URL") + .or_else(|_| env::var("OXICLOUD_DB_CONNECTION_STRING")) + .expect("set DATABASE_URL — the dev Postgres URL"); + let n: usize = env_or("BENCH_RESOURCES", 15); + let passes: usize = env_or("BENCH_PASSES", 200); + let herd: usize = env_or("BENCH_HERD", 32); + + let pool = Arc::new( + PgPoolOptions::new() + .max_connections(40) + .min_connections(40) + .acquire_timeout(Duration::from_secs(10)) + .connect(&url) + .await + .expect("connect Postgres"), + ); + + let seeded = seed(&pool, n).await; + + let cal_repo = CalendarPgRepository::new(pool.clone()); + let book_repo = AddressBookPgRepository::new(pool.clone()); + let pl_repo = PlaylistPgRepository::new(pool.clone()); + + println!("bench_n1_hydration — {n} resources/listing, {passes} passes, herd={herd}\n"); + + // ── [1] calendars ── + bench_pair( + "1 calendars", + passes, + n, + async || { + let mut out = Vec::with_capacity(n); + for id in &seeded.calendar_ids { + if let Ok(c) = cal_repo.find_calendar_by_id(id).await { + out.push(c); + } + } + out + }, + async || { + cal_repo + .find_calendars_by_ids(&seeded.calendar_ids) + .await + .expect("batch calendars") + }, + ) + .await; + + // ── [2] address books ── + bench_pair( + "2 address books", + passes, + n, + async || { + let mut out = Vec::with_capacity(n); + for id in &seeded.book_ids { + if let Ok(Some(b)) = book_repo.get_address_book_by_id(id).await { + out.push(b); + } + } + out + }, + async || { + book_repo + .get_address_books_by_ids(&seeded.book_ids) + .await + .expect("batch books") + }, + ) + .await; + + // ── [3] playlists ── + bench_pair( + "3 playlists", + passes, + n, + async || { + let mut out = Vec::with_capacity(n); + for id in &seeded.playlist_ids { + if let Ok(p) = pl_repo.find_playlist_by_id(id).await { + out.push(p); + } + } + out + }, + async || { + pl_repo + .find_playlists_by_ids(&seeded.playlist_ids) + .await + .expect("batch playlists") + }, + ) + .await; + + // ── Equivalence gates ── + let mut ok = true; + { + let loop_ids: HashSet = { + let mut s = HashSet::new(); + for id in &seeded.calendar_ids { + if let Ok(c) = cal_repo.find_calendar_by_id(id).await { + s.insert(*c.id()); + } + } + s + }; + let batch_ids: HashSet = cal_repo + .find_calendars_by_ids(&seeded.calendar_ids) + .await + .expect("batch") + .iter() + .map(|c| *c.id()) + .collect(); + if loop_ids != batch_ids { + eprintln!("GATE FAIL calendars: {loop_ids:?} != {batch_ids:?}"); + ok = false; + } + // Missing ids drop out on both sides. + let with_ghost: Vec = seeded + .calendar_ids + .iter() + .copied() + .chain([Uuid::new_v4()]) + .collect(); + let ghost_ids: HashSet = cal_repo + .find_calendars_by_ids(&with_ghost) + .await + .expect("batch+ghost") + .iter() + .map(|c| *c.id()) + .collect(); + if ghost_ids != batch_ids { + eprintln!("GATE FAIL calendars: ghost id changed result"); + ok = false; + } + } + { + let loop_ids: HashSet = { + let mut s = HashSet::new(); + for id in &seeded.book_ids { + if let Ok(Some(b)) = book_repo.get_address_book_by_id(id).await { + s.insert(*b.id()); + } + } + s + }; + let batch_ids: HashSet = book_repo + .get_address_books_by_ids(&seeded.book_ids) + .await + .expect("batch") + .iter() + .map(|b| *b.id()) + .collect(); + if loop_ids != batch_ids { + eprintln!("GATE FAIL books"); + ok = false; + } + } + { + let loop_ids: HashSet = { + let mut s = HashSet::new(); + for id in &seeded.playlist_ids { + if let Ok(p) = pl_repo.find_playlist_by_id(id).await { + s.insert(*p.id()); + } + } + s + }; + let batch_ids: HashSet = pl_repo + .find_playlists_by_ids(&seeded.playlist_ids) + .await + .expect("batch") + .iter() + .map(|p| *p.id()) + .collect(); + if loop_ids != batch_ids { + eprintln!("GATE FAIL playlists"); + ok = false; + } + } + + // ── [4] user-flags herd: get→insert vs try_get_with ───────────────────── + let user_repo = Arc::new(UserPgRepository::new(pool.clone())); + let queries = Arc::new(AtomicUsize::new(0)); + + // BEFORE: sync moka get/insert — every cold caller queries. + let sync_cache: moka::sync::Cache = + moka::sync::Cache::builder() + .max_capacity(10_000) + .time_to_live(Duration::from_secs(30)) + .build(); + let t0 = Instant::now(); + let mut handles = Vec::new(); + for _ in 0..herd { + let cache = sync_cache.clone(); + let repo = user_repo.clone(); + let queries = queries.clone(); + let uid = seeded.user_id; + handles.push(tokio::spawn(async move { + if let Some(f) = cache.get(&uid) { + return f; + } + queries.fetch_add(1, Ordering::Relaxed); + let f = repo.get_user_flags(uid).await.expect("flags"); + cache.insert(uid, f); + f + })); + } + let mut before_flags = Vec::new(); + for h in handles { + before_flags.push(h.await.unwrap()); + } + let before_wall = t0.elapsed().as_secs_f64() * 1e3; + let before_queries = queries.swap(0, Ordering::Relaxed); + + // AFTER: future moka try_get_with — one query per herd. + let future_cache: moka::future::Cache = + moka::future::Cache::builder() + .max_capacity(10_000) + .time_to_live(Duration::from_secs(30)) + .build(); + let t0 = Instant::now(); + let mut handles = Vec::new(); + for _ in 0..herd { + let cache = future_cache.clone(); + let repo = user_repo.clone(); + let queries = queries.clone(); + let uid = seeded.user_id; + handles.push(tokio::spawn(async move { + cache + .try_get_with(uid, async { + queries.fetch_add(1, Ordering::Relaxed); + repo.get_user_flags(uid).await + }) + .await + .expect("flags") + })); + } + let mut after_flags = Vec::new(); + for h in handles { + after_flags.push(h.await.unwrap()); + } + let after_wall = t0.elapsed().as_secs_f64() * 1e3; + let after_queries = queries.load(Ordering::Relaxed); + + println!("[4] user-flags cold-cache herd of {herd}"); + println!(" BEFORE (get→insert) {before_wall:7.2} ms {before_queries} queries"); + println!(" AFTER (try_get_with) {after_wall:7.2} ms {after_queries} queries"); + + for f in before_flags.iter().chain(&after_flags) { + if *f != before_flags[0] { + eprintln!("GATE FAIL user flags mismatch"); + ok = false; + } + } + + cleanup(&pool, &seeded).await; + println!( + "\n[gate] {}", + if ok { + "OK (identical result sets)" + } else { + "FAILED" + } + ); + if !ok { + std::process::exit(1); + } +} diff --git a/examples/bench_propfind_xml.rs b/examples/bench_propfind_xml.rs new file mode 100644 index 00000000..0b0df1de --- /dev/null +++ b/examples/bench_propfind_xml.rs @@ -0,0 +1,801 @@ +//! PROPFIND per-row XML emit benchmark — Vec churn + format-interpreter +//! dates (ROUND4). +//! +//! For EVERY file/folder row of every PROPFIND page the old writers paid: +//! • a `partition` into two throwaway `Vec<&QualifiedName>`s (+ a third +//! for the 404 list) — even though the requested-props writer already +//! skips unknown names itself; +//! • `to_rfc3339()` + `to_rfc2822()` — chrono's format-spec interpreter +//! plus a heap String each; +//! • `size.to_string()` and a `format!("\"{etag}\"")`. +//! +//! AFTER: single-pass 404 computation (usually-empty Vec), stack-rendered +//! dates/sizes (`common::fmt`, byte-identical, chrono fallback for +//! out-of-range), exactly-sized etag quoting. +//! +//! The OLD writers are copied verbatim into `mod before`; the gate +//! asserts byte-identical multistatus XML for named-prop (typical sync +//! client set + unknown props), AllProp (with quota), and dead-prop +//! carrying rows. Exit 1 on any diff. +//! +//! Run (no Postgres needed): +//! cargo run --release --features bench --example bench_propfind_xml +//! Tunables (env): BENCH_ROWS (1000), BENCH_PASSES (200) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::hint::black_box; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use oxicloud::application::adapters::webdav_adapter::{ + PropFindRequest, PropFindType, QualifiedName, bench as dav_bench, +}; +use oxicloud::application::dtos::file_dto::FileDto; +use oxicloud::application::dtos::folder_dto::FolderDto; +use oxicloud::domain::entities::file::File; +use oxicloud::domain::entities::folder::Folder; +use uuid::Uuid; + +// ─── Counting allocator ───────────────────────────────────────────────────── + +static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0); + +struct CountingAlloc; + +unsafe impl GlobalAlloc for CountingAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.realloc(ptr, layout, new_size) } + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc_zeroed(layout) } + } +} + +#[global_allocator] +static GLOBAL: CountingAlloc = CountingAlloc; + +// ─── BEFORE: verbatim copy of the old per-row writers ─────────────────────── + +#[allow(clippy::all)] +mod before { + use chrono::Utc; + use oxicloud::application::adapters::webdav_adapter::{ + PropFindRequest, PropFindType, QualifiedName, + }; + use oxicloud::application::dtos::file_dto::FileDto; + use oxicloud::application::dtos::folder_dto::FolderDto; + use quick_xml::Writer; + use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; + use std::io::Write; + + type Result = std::result::Result; + + fn folder_prop_is_known(prop: &QualifiedName, quota: Option<(i64, Option)>) -> bool { + if prop.namespace != "DAV:" { + return false; + } + match prop.name.as_str() { + "resourcetype" | "displayname" | "creationdate" | "getlastmodified" | "getetag" + | "getcontentlength" | "getcontenttype" => true, + "quota-used-bytes" => quota.is_some(), + "quota-available-bytes" => quota.is_some_and(|(_, available)| available.is_some()), + _ => false, + } + } + + fn file_prop_is_known(prop: &QualifiedName) -> bool { + prop.namespace == "DAV:" + && matches!( + prop.name.as_str(), + "resourcetype" + | "displayname" + | "getcontenttype" + | "getcontentlength" + | "creationdate" + | "getlastmodified" + | "getetag" + ) + } + + fn write_qname_empty(xml_writer: &mut Writer, prop: &QualifiedName) -> Result<()> { + if prop.namespace.is_empty() { + xml_writer.write_event(Event::Empty(BytesStart::new(prop.name.as_str())))?; + } else if prop.namespace == "DAV:" { + xml_writer.write_event(Event::Empty(BytesStart::new(format!("D:{}", prop.name))))?; + } else { + let tag = format!("X:{}", prop.name); + let mut start = BytesStart::new(tag.as_str()); + start.push_attribute(("xmlns:X", prop.namespace.as_str())); + xml_writer.write_event(Event::Empty(start))?; + } + Ok(()) + } + + fn write_unknown_props_404( + xml_writer: &mut Writer, + unknown: &[&QualifiedName], + ) -> Result<()> { + if unknown.is_empty() { + return Ok(()); + } + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + for prop in unknown { + write_qname_empty(xml_writer, prop)?; + } + xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; + xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 404 Not Found")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + Ok(()) + } + + fn write_dead_props_propstat( + xml_writer: &mut Writer, + dead_props: &[(QualifiedName, Option)], + ) -> Result<()> { + if dead_props.is_empty() { + return Ok(()); + } + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + for (name, value) in dead_props { + let tag = if name.namespace.is_empty() { + name.name.clone() + } else { + format!("X:{}", name.name) + }; + let mut start = BytesStart::new(tag.as_str()); + if !name.namespace.is_empty() { + start.push_attribute(("xmlns:X", name.namespace.as_str())); + } + match value { + Some(v) if !v.is_empty() => { + xml_writer.write_event(Event::Start(start))?; + xml_writer.write_event(Event::Text(BytesText::new(v)))?; + xml_writer.write_event(Event::End(BytesEnd::new(tag.as_str())))?; + } + _ => { + xml_writer.write_event(Event::Empty(start))?; + } + } + } + xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; + xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + Ok(()) + } + + fn write_quota_props( + xml_writer: &mut Writer, + used_bytes: i64, + available_bytes: Option, + ) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:quota-used-bytes")))?; + xml_writer.write_event(Event::Text(BytesText::new(&used_bytes.to_string())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:quota-used-bytes")))?; + + if let Some(available_bytes) = available_bytes { + xml_writer.write_event(Event::Start(BytesStart::new("D:quota-available-bytes")))?; + xml_writer.write_event(Event::Text(BytesText::new(&available_bytes.to_string())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:quota-available-bytes")))?; + } + Ok(()) + } + + fn write_folder_standard_props( + xml_writer: &mut Writer, + folder: &FolderDto, + quota: Option<(i64, Option)>, + ) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; + xml_writer.write_event(Event::Text(BytesText::new(&folder.name)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; + let created_at = chrono::DateTime::::from_timestamp(folder.created_at as i64, 0) + .unwrap_or_else(Utc::now); + xml_writer.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + let modified_at = chrono::DateTime::::from_timestamp(folder.modified_at as i64, 0) + .unwrap_or_else(Utc::now); + xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", folder.etag))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; + xml_writer.write_event(Event::Text(BytesText::new("0")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + + if let Some((used, available)) = quota { + write_quota_props(xml_writer, used, available)?; + } + Ok(()) + } + + fn write_file_standard_props( + xml_writer: &mut Writer, + file: &FileDto, + ) -> Result<()> { + xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; + xml_writer.write_event(Event::Text(BytesText::new(&file.name)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer.write_event(Event::Text(BytesText::new(&file.mime_type)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; + xml_writer.write_event(Event::Text(BytesText::new(&file.size.to_string())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; + let created_at = chrono::DateTime::::from_timestamp(file.created_at as i64, 0) + .unwrap_or_else(Utc::now); + xml_writer.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + let modified_at = chrono::DateTime::::from_timestamp(file.modified_at as i64, 0) + .unwrap_or_else(Utc::now); + xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", file.etag))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + Ok(()) + } + + fn write_folder_requested_props( + xml_writer: &mut Writer, + folder: &FolderDto, + props: &[&QualifiedName], + quota: Option<(i64, Option)>, + ) -> Result<()> { + for prop in props { + if prop.namespace == "DAV:" { + match prop.name.as_str() { + "resourcetype" => { + xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + } + "displayname" => { + xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; + xml_writer.write_event(Event::Text(BytesText::new(&folder.name)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + } + "creationdate" => { + xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; + let created_at = + chrono::DateTime::::from_timestamp(folder.created_at as i64, 0) + .unwrap_or_else(Utc::now); + xml_writer + .write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; + } + "getlastmodified" => { + xml_writer + .write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + let modified_at = + chrono::DateTime::::from_timestamp(folder.modified_at as i64, 0) + .unwrap_or_else(Utc::now); + xml_writer + .write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + } + "getetag" => { + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "\"{}\"", + folder.etag + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + } + "getcontentlength" => { + xml_writer + .write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; + xml_writer.write_event(Event::Text(BytesText::new("0")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; + } + "getcontenttype" => { + xml_writer + .write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer + .write_event(Event::Text(BytesText::new("httpd/unix-directory")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + } + "quota-used-bytes" => { + if let Some((used, _)) = quota { + xml_writer + .write_event(Event::Start(BytesStart::new("D:quota-used-bytes")))?; + xml_writer + .write_event(Event::Text(BytesText::new(&used.to_string())))?; + xml_writer + .write_event(Event::End(BytesEnd::new("D:quota-used-bytes")))?; + } + } + "quota-available-bytes" => { + if let Some((_, Some(available))) = quota { + xml_writer.write_event(Event::Start(BytesStart::new( + "D:quota-available-bytes", + )))?; + xml_writer + .write_event(Event::Text(BytesText::new(&available.to_string())))?; + xml_writer.write_event(Event::End(BytesEnd::new( + "D:quota-available-bytes", + )))?; + } + } + _ => {} + } + } + } + Ok(()) + } + + fn write_file_requested_props( + xml_writer: &mut Writer, + file: &FileDto, + props: &[&QualifiedName], + ) -> Result<()> { + for prop in props { + if prop.namespace == "DAV:" { + match prop.name.as_str() { + "resourcetype" => { + xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; + } + "displayname" => { + xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; + xml_writer.write_event(Event::Text(BytesText::new(&file.name)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + } + "getcontenttype" => { + xml_writer + .write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer.write_event(Event::Text(BytesText::new(&file.mime_type)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + } + "getcontentlength" => { + xml_writer + .write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; + xml_writer + .write_event(Event::Text(BytesText::new(&file.size.to_string())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; + } + "creationdate" => { + xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; + let created_at = + chrono::DateTime::::from_timestamp(file.created_at as i64, 0) + .unwrap_or_else(Utc::now); + xml_writer + .write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; + } + "getlastmodified" => { + xml_writer + .write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + let modified_at = + chrono::DateTime::::from_timestamp(file.modified_at as i64, 0) + .unwrap_or_else(Utc::now); + xml_writer + .write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + } + "getetag" => { + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "\"{}\"", + file.etag + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + } + _ => {} + } + } + } + Ok(()) + } + + pub fn write_file_response_with_dead_props( + xml_writer: &mut Writer, + file: &FileDto, + request: &PropFindRequest, + href: &str, + dead_props: &[(QualifiedName, Option)], + ) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(href)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + + let relevant_dead: Vec<_> = match &request.prop_find_type { + PropFindType::Prop(requested) => dead_props + .iter() + .filter(|(name, _)| requested.iter().any(|r| r == name)) + .cloned() + .collect(), + PropFindType::AllProp => dead_props.to_vec(), + PropFindType::PropName => vec![], + }; + let dead_name_set: std::collections::HashSet<&QualifiedName> = + relevant_dead.iter().map(|(n, _)| n).collect(); + + match &request.prop_find_type { + PropFindType::Prop(props) => { + let (known, unknown): (Vec<_>, Vec<_>) = + props.iter().partition(|p| file_prop_is_known(p)); + let truly_unknown: Vec<_> = unknown + .into_iter() + .filter(|p| !dead_name_set.contains(*p)) + .collect(); + + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + write_file_requested_props(xml_writer, file, &known)?; + xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; + xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + + write_unknown_props_404(xml_writer, &truly_unknown)?; + } + other => { + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + match other { + PropFindType::AllProp => { + write_file_standard_props(xml_writer, file)?; + } + PropFindType::PropName => { + // not exercised in this bench + } + PropFindType::Prop(_) => unreachable!(), + } + xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; + xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + } + } + + write_dead_props_propstat(xml_writer, &relevant_dead)?; + + xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; + Ok(()) + } + + pub fn write_folder_response_with_dead_props( + xml_writer: &mut Writer, + folder: &FolderDto, + request: &PropFindRequest, + href: &str, + dead_props: &[(QualifiedName, Option)], + quota: Option<(i64, Option)>, + ) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(href)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + + let relevant_dead: Vec<_> = match &request.prop_find_type { + PropFindType::Prop(requested) => dead_props + .iter() + .filter(|(name, _)| requested.iter().any(|r| r == name)) + .cloned() + .collect(), + PropFindType::AllProp => dead_props.to_vec(), + PropFindType::PropName => vec![], + }; + let dead_name_set: std::collections::HashSet<&QualifiedName> = + relevant_dead.iter().map(|(n, _)| n).collect(); + + match &request.prop_find_type { + PropFindType::Prop(props) => { + let (known, unknown): (Vec<_>, Vec<_>) = + props.iter().partition(|p| folder_prop_is_known(p, quota)); + let truly_unknown: Vec<_> = unknown + .into_iter() + .filter(|p| !dead_name_set.contains(*p)) + .collect(); + + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + write_folder_requested_props(xml_writer, folder, &known, quota)?; + xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; + xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + + write_unknown_props_404(xml_writer, &truly_unknown)?; + } + other => { + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + match other { + PropFindType::AllProp => { + write_folder_standard_props(xml_writer, folder, quota)?; + } + PropFindType::PropName => {} + PropFindType::Prop(_) => unreachable!(), + } + xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; + xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + } + } + + write_dead_props_propstat(xml_writer, &relevant_dead)?; + + xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; + Ok(()) + } +} + +// ─── Corpus ───────────────────────────────────────────────────────────────── + +fn build_files(rows: usize) -> Vec { + (0..rows) + .map(|i| { + // Timestamp mix: epoch edge, padded-day dates, recent, far future. + let created = [0u64, 1_120_176_000, 1_700_000_000, 4_102_444_799][i % 4]; + let f = File::from_materialized_row( + Uuid::from_u128(i as u128).to_string(), + format!("informe-{i}.pdf"), + Some("/Personal/Projects/2026"), + (i as u64) * 3_517 + 42, + "application/pdf".to_string(), + Some(Uuid::nil().to_string()), + created, + created + 86_400 * (i as u64 % 300), + format!("{:032x}", i * 2_654_435_761), + None, + None, + ) + .expect("valid file"); + FileDto::from(f) + }) + .collect() +} + +fn build_folders(rows: usize) -> Vec { + (0..rows) + .map(|i| { + let created = [0u64, 1_120_176_000, 1_700_000_000, 4_102_444_799][i % 4]; + let f = Folder::from_materialized_row( + Uuid::from_u128((1_000_000 + i) as u128).to_string(), + format!("Carpeta {i}"), + format!("/Personal/Carpeta {i}"), + None, + Uuid::nil(), + created, + created + 3_600, + created + 7_200, + None, + None, + ) + .expect("valid folder"); + FolderDto::from(f) + }) + .collect() +} + +/// The prop set DAVx⁵/rclone-style clients poll with, plus two unknown +/// names so the 404 path is exercised. +fn sync_request() -> PropFindRequest { + PropFindRequest { + prop_find_type: PropFindType::Prop(vec![ + QualifiedName::new("DAV:", "resourcetype"), + QualifiedName::new("DAV:", "displayname"), + QualifiedName::new("DAV:", "getcontenttype"), + QualifiedName::new("DAV:", "getcontentlength"), + QualifiedName::new("DAV:", "getlastmodified"), + QualifiedName::new("DAV:", "getetag"), + QualifiedName::new("DAV:", "lockdiscovery"), + QualifiedName::new("http://owncloud.org/ns", "fileid"), + ]), + } +} + +fn allprop_request() -> PropFindRequest { + PropFindRequest { + prop_find_type: PropFindType::AllProp, + } +} + +fn p50(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[xs.len() / 2] +} + +const QUOTA: Option<(i64, Option)> = Some((123_456_789, Some(9_876_543_210))); + +fn render_before( + files: &[FileDto], + folders: &[FolderDto], + request: &PropFindRequest, + dead: &[(QualifiedName, Option)], +) -> Vec { + let mut out = Vec::with_capacity(1 << 20); + let mut w = quick_xml::Writer::new(&mut out); + for (i, folder) in folders.iter().enumerate() { + let dead = if i % 7 == 0 { dead } else { &[] }; + before::write_folder_response_with_dead_props( + &mut w, + folder, + request, + "/webdav/Personal/", + dead, + QUOTA, + ) + .expect("before folder row"); + } + for (i, file) in files.iter().enumerate() { + let dead = if i % 7 == 0 { dead } else { &[] }; + before::write_file_response_with_dead_props( + &mut w, + file, + request, + "/webdav/Personal/informe.pdf", + dead, + ) + .expect("before file row"); + } + out +} + +fn render_after( + files: &[FileDto], + folders: &[FolderDto], + request: &PropFindRequest, + dead: &[(QualifiedName, Option)], +) -> Vec { + let mut out = Vec::with_capacity(1 << 20); + let mut w = quick_xml::Writer::new(&mut out); + for (i, folder) in folders.iter().enumerate() { + let dead = if i % 7 == 0 { dead } else { &[] }; + dav_bench::write_folder_propfind_row( + &mut w, + folder, + request, + "/webdav/Personal/", + dead, + QUOTA, + ) + .expect("after folder row"); + } + for (i, file) in files.iter().enumerate() { + let dead = if i % 7 == 0 { dead } else { &[] }; + dav_bench::write_file_propfind_row( + &mut w, + file, + request, + "/webdav/Personal/informe.pdf", + dead, + ) + .expect("after file row"); + } + out +} + +fn main() { + let rows: usize = env::var("BENCH_ROWS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1000); + let passes: usize = env::var("BENCH_PASSES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(200); + + let files = build_files(rows); + let folders = build_folders(rows / 10); + let total_rows = files.len() + folders.len(); + let dead: Vec<(QualifiedName, Option)> = vec![( + QualifiedName::new("http://example.com/ns", "color"), + Some("azul".to_string()), + )]; + + let sync_req = sync_request(); + let all_req = allprop_request(); + + println!( + "bench_propfind_xml — {} files + {} folders/page, {passes} passes\n", + files.len(), + folders.len() + ); + + for (label, req) in [("named-prop (sync set)", &sync_req), ("allprop", &all_req)] { + let mut lat_before = Vec::with_capacity(passes); + let mut lat_after = Vec::with_capacity(passes); + for _ in 0..passes { + let t0 = Instant::now(); + black_box(render_before(&files, &folders, req, &dead)); + lat_before.push(t0.elapsed().as_secs_f64() * 1e6); + let t0 = Instant::now(); + black_box(render_after(&files, &folders, req, &dead)); + lat_after.push(t0.elapsed().as_secs_f64() * 1e6); + } + let b = p50(lat_before); + let a = p50(lat_after); + + let s0 = ALLOC_CALLS.load(Ordering::Relaxed); + black_box(render_before(&files, &folders, req, &dead)); + let ab = (ALLOC_CALLS.load(Ordering::Relaxed) - s0) as f64 / total_rows as f64; + let s0 = ALLOC_CALLS.load(Ordering::Relaxed); + black_box(render_after(&files, &folders, req, &dead)); + let aa = (ALLOC_CALLS.load(Ordering::Relaxed) - s0) as f64 / total_rows as f64; + + println!("[{label}] µs/page (p50) + allocs/row"); + println!(" BEFORE {b:9.1} µs {ab:6.2} allocs/row"); + println!( + " AFTER {a:9.1} µs {aa:6.2} allocs/row {:.2}x", + b / a + ); + } + + // ── Equivalence gate: byte-identical multistatus XML ──────────────────── + let mut ok = true; + for req in [&sync_req, &all_req] { + let xb = render_before(&files, &folders, req, &dead); + let xa = render_after(&files, &folders, req, &dead); + if xb != xa { + ok = false; + let diff_at = xb.iter().zip(&xa).position(|(a, b)| a != b).unwrap_or(0); + let lo = diff_at.saturating_sub(120); + eprintln!( + "GATE FAIL ({:?}): first diff at byte {diff_at}\n BEFORE: …{}…\n AFTER: …{}…", + match req.prop_find_type { + PropFindType::Prop(_) => "prop", + PropFindType::AllProp => "allprop", + PropFindType::PropName => "propname", + }, + String::from_utf8_lossy(&xb[lo..(diff_at + 120).min(xb.len())]), + String::from_utf8_lossy(&xa[lo..(diff_at + 120).min(xa.len())]), + ); + } + } + println!( + "\n[gate] multistatus XML: {}", + if ok { "OK (byte-identical)" } else { "FAILED" } + ); + if !ok { + std::process::exit(1); + } +} diff --git a/examples/bench_row_path.rs b/examples/bench_row_path.rs new file mode 100644 index 00000000..e2fbe273 --- /dev/null +++ b/examples/bench_row_path.rs @@ -0,0 +1,669 @@ +//! PG row → entity path materialization benchmark — the per-listing-row +//! `make_file_path` split→rejoin + NFC-copy chain (ROUND3 follow-up). +//! +//! Every listing row (PROPFIND batches, photos timeline, search pages, +//! by-ids enrichment, subtree ZIP streams) used to pay this chain: +//! +//! • files: `format!("{fp}/{name}")` temp → `StoragePath::from_string` +//! split (one `String` per segment + `Vec`) → constructor NFC-copies +//! the already-NFC name → `Display`/`join` re-joins the segments it +//! just split into `path_string` (join temp + unsized `to_string`). +//! • folders: same minus the format temp — the materialized `path` +//! column arrives owned, is split, dropped, and re-joined into an +//! identical `String`. +//! +//! The optimized path builds segments + joined string in ONE pass +//! (`StoragePath::from_folder_and_name` / `from_joined`, the latter +//! reusing the owned input when canonical) and normalizes the owned name +//! without the always-copy (`normalize_storage_name_owned`). +//! +//! The OLD logic is copied verbatim into `mod before` so one binary +//! reports BEFORE vs AFTER side by side; an equivalence gate asserts +//! byte-identical (name, path_string, segments) triples — including +//! adversarial non-canonical inputs — and error parity for invalid +//! names (exit 1 on any diff). +//! +//! Sections: +//! 1. File row wall time (p50 ns/row over BENCH_PASSES passes) +//! 2. Folder row wall time (same) +//! 3. Alloc calls/row (counting allocator wrapping System — the lib +//! crate sets no global allocator; mimalloc lives in main.rs only) +//! 4. Equivalence gate (realistic corpus + adversarial set) +//! +//! Run (no Postgres needed): +//! cargo run --release --features bench --example bench_row_path +//! Tunables (env): +//! BENCH_ROWS (10000) BENCH_PASSES (100) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::hint::black_box; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use oxicloud::domain::entities::file::File; +use oxicloud::domain::entities::folder::Folder; +use uuid::Uuid; + +// ─── Counting allocator (Section 3) ───────────────────────────────────────── + +static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0); + +struct CountingAlloc; + +unsafe impl GlobalAlloc for CountingAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.realloc(ptr, layout, new_size) } + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc_zeroed(layout) } + } +} + +#[global_allocator] +static GLOBAL: CountingAlloc = CountingAlloc; + +// ─── BEFORE: verbatim copy of the pre-optimization chain ──────────────────── + +/// Pre-optimization reference implementation. `OldStoragePath` + +/// `normalize_storage_name` + `make_file_path` + the constructor bodies +/// are copied byte-for-byte from the old `path_service.rs` / +/// `file.rs` / `folder.rs` / repository code so the equivalence gate +/// proves the optimized paths change nothing observable. +#[allow(clippy::all)] +mod before { + use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick}; + use uuid::Uuid; + + /// Old borrowing normalize — allocates a copy even on the NFC fast path. + fn normalize_storage_name(name: &str) -> String { + if is_nfc_quick(name.chars()) == IsNormalized::Yes { + return name.to_string(); + } + name.nfc().collect() + } + + fn validate_storage_name(name: &str) -> Result<(), &'static str> { + if name.is_empty() { + return Err("name cannot be empty"); + } + if name.contains('/') || name.contains('\\') { + return Err("name must not contain '/' or '\\'"); + } + if name.contains('\0') { + return Err("name must not contain null bytes"); + } + if name == "." || name == ".." { + return Err("'.' and '..' are not valid names"); + } + Ok(()) + } + + pub struct OldStoragePath { + pub segments: Vec, + } + + impl OldStoragePath { + fn is_safe_segment(s: &str) -> bool { + !s.is_empty() && s != "." && s != ".." && !s.contains('/') + } + + fn from_string(path: &str) -> Self { + let segments = path + .split('/') + .filter(|s| Self::is_safe_segment(s)) + .map(|s| s.to_string()) + .collect(); + Self { segments } + } + } + + /// Old `Display` impl (join temp) driven through the std `ToString` + /// blanket — the exact `storage_path.to_string()` the constructors ran. + impl std::fmt::Display for OldStoragePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.segments.is_empty() { + write!(f, "/") + } else { + write!(f, "/{}", self.segments.join("/")) + } + } + } + + /// Old repository helper (identical copies lived in the read + write + /// file repositories). + fn make_file_path(folder_path: Option<&str>, file_name: &str) -> OldStoragePath { + match folder_path { + Some(fp) if !fp.is_empty() => OldStoragePath::from_string(&format!("{fp}/{file_name}")), + _ => OldStoragePath::from_string(file_name), + } + } + + /// Entity-shaped product so BEFORE pays the same field moves the real + /// constructors pay; only the path/name chain differs from AFTER. + /// Fields exist to be *built* (cost parity), not read. + #[allow(dead_code)] + pub struct BeforeFile { + pub id: String, + pub name: String, + pub storage_path: OldStoragePath, + pub path_string: String, + pub size: u64, + pub mime_type: String, + pub folder_id: Option, + pub created_at: u64, + pub modified_at: u64, + pub blob_hash: String, + pub created_by: Option, + pub updated_by: Option, + } + + /// Old `row_to_file` + `File::with_timestamps_blob_hash_and_provenance`. + #[allow(clippy::too_many_arguments)] + pub fn file_row( + id: String, + name: String, + folder_path: Option<&str>, + size: u64, + mime_type: String, + folder_id: Option, + created_at: u64, + modified_at: u64, + blob_hash: String, + created_by: Option, + updated_by: Option, + ) -> Result { + let storage_path = make_file_path(folder_path, &name); + + let name = normalize_storage_name(&name); + if let Err(reason) = validate_storage_name(&name) { + return Err(format!("{name}: {reason}")); + } + + // Store the path string for serialization compatibility + let path_string = storage_path.to_string(); + + Ok(BeforeFile { + id, + name, + storage_path, + path_string, + size, + mime_type, + folder_id, + created_at, + modified_at, + blob_hash, + created_by, + updated_by, + }) + } + + #[allow(dead_code)] + pub struct BeforeFolder { + pub id: String, + pub name: String, + pub storage_path: OldStoragePath, + pub path_string: String, + pub parent_id: Option, + pub drive_id: Uuid, + pub created_at: u64, + pub modified_at: u64, + pub tree_modified_at: u64, + pub created_by: Option, + pub updated_by: Option, + } + + /// Old `row_to_folder` + `Folder::with_timestamps_tree_and_provenance`. + #[allow(clippy::too_many_arguments)] + pub fn folder_row( + id: String, + name: String, + path: String, + parent_id: Option, + drive_id: Uuid, + created_at: u64, + modified_at: u64, + tree_modified_at: u64, + created_by: Option, + updated_by: Option, + ) -> Result { + let storage_path = OldStoragePath::from_string(&path); + + let name = normalize_storage_name(&name); + if let Err(reason) = validate_storage_name(&name) { + return Err(format!("{name}: {reason}")); + } + + let path_string = storage_path.to_string(); + + Ok(BeforeFolder { + id, + name, + storage_path, + path_string, + parent_id, + drive_id, + created_at, + modified_at, + tree_modified_at, + created_by, + updated_by, + }) + } +} + +// ─── Corpus ───────────────────────────────────────────────────────────────── + +struct Row { + id: String, + name: String, + folder_path: Option, + mime: String, +} + +/// Deterministic LCG so runs are reproducible. +struct Lcg(u64); +impl Lcg { + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 >> 33 + } + fn pick<'a>(&mut self, xs: &[&'a str]) -> &'a str { + xs[(self.next() as usize) % xs.len()] + } +} + +const SEGMENTS: &[&str] = &[ + "Personal", + "Projects", + "2026", + "Q3 Reports", + "Fotos de familia", + "Archive", + "Contabilidad", + "src", + "Diseño gráfico", + "backup-2026-07", +]; + +const NAMES: &[&str] = &[ + "informe-final.pdf", + "IMG_20260714_183042.jpg", + "Presupuesto Q3 2026.xlsx", + "Capture d\u{2019}\u{00E9}cran.png", // NFC accents — the common Unicode case + "notes.md", + "vacaciones-c\u{00F3}rdoba.mp4", + "main.rs", + "espa\u{00F1}ol.txt", +]; + +fn build_corpus(rows: usize) -> Vec { + let mut rng = Lcg(0x0c1_f00d); + (0..rows) + .map(|i| { + let depth = (rng.next() % 6) as usize; // 0..=5 + let folder_path = if depth == 0 { + None + } else { + let mut p = String::new(); + for _ in 0..depth { + p.push('/'); + p.push_str(rng.pick(SEGMENTS)); + } + Some(p) + }; + Row { + id: Uuid::from_u128(i as u128).to_string(), + name: format!("{}-{}", i, rng.pick(NAMES)), + folder_path, + mime: "application/octet-stream".to_string(), + } + }) + .collect() +} + +fn p50(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[xs.len() / 2] +} + +// ─── Runners ──────────────────────────────────────────────────────────────── + +fn run_file_before(corpus: &[Row]) -> before::BeforeFile { + let mut last = None; + for r in corpus { + let f = before::file_row( + r.id.clone(), + r.name.clone(), + r.folder_path.as_deref(), + 1234, + r.mime.clone(), + Some(r.id.clone()), + 1_700_000_000, + 1_750_000_000, + "aabbccddeeff00112233445566778899".to_string(), + None, + None, + ) + .expect("valid row"); + last = Some(f); + } + last.unwrap() +} + +fn run_file_after(corpus: &[Row]) -> File { + let mut last = None; + for r in corpus { + let f = File::from_materialized_row( + r.id.clone(), + r.name.clone(), + r.folder_path.as_deref(), + 1234, + r.mime.clone(), + Some(r.id.clone()), + 1_700_000_000, + 1_750_000_000, + "aabbccddeeff00112233445566778899".to_string(), + None, + None, + ) + .expect("valid row"); + last = Some(f); + } + last.unwrap() +} + +fn folder_full_path(r: &Row) -> String { + match &r.folder_path { + Some(p) => format!("{}/{}", p, r.name), + None => format!("/{}", r.name), + } +} + +fn run_folder_before(corpus: &[Row]) -> before::BeforeFolder { + let mut last = None; + for r in corpus { + let f = before::folder_row( + r.id.clone(), + r.name.clone(), + folder_full_path(r), + Some(r.id.clone()), + Uuid::nil(), + 1_700_000_000, + 1_750_000_000, + 1_750_000_000, + None, + None, + ) + .expect("valid row"); + last = Some(f); + } + last.unwrap() +} + +fn run_folder_after(corpus: &[Row]) -> Folder { + let mut last = None; + for r in corpus { + let f = Folder::from_materialized_row( + r.id.clone(), + r.name.clone(), + folder_full_path(r), + Some(r.id.clone()), + Uuid::nil(), + 1_700_000_000, + 1_750_000_000, + 1_750_000_000, + None, + None, + ) + .expect("valid row"); + last = Some(f); + } + last.unwrap() +} + +fn time_ns_per_row(passes: usize, rows: usize, mut f: impl FnMut() -> T) -> f64 { + let mut per_pass = Vec::with_capacity(passes); + for _ in 0..passes { + let t0 = Instant::now(); + black_box(f()); + per_pass.push(t0.elapsed().as_nanos() as f64 / rows as f64); + } + p50(per_pass) +} + +fn allocs_per_row(rows: usize, mut f: impl FnMut() -> T) -> f64 { + let start = ALLOC_CALLS.load(Ordering::Relaxed); + black_box(f()); + (ALLOC_CALLS.load(Ordering::Relaxed) - start) as f64 / rows as f64 +} + +// ─── Equivalence gate ─────────────────────────────────────────────────────── + +fn gate_file(name: &str, folder_path: Option<&str>) -> bool { + let b = before::file_row( + "id".into(), + name.to_string(), + folder_path, + 0, + "m".into(), + None, + 0, + 0, + String::new(), + None, + None, + ); + let a = File::from_materialized_row( + "id".into(), + name.to_string(), + folder_path, + 0, + "m".into(), + None, + 0, + 0, + String::new(), + None, + None, + ); + match (b, a) { + (Ok(b), Ok(a)) => { + let seg_a: Vec = a.storage_path().segments().to_vec(); + if b.name != a.name() + || b.path_string != a.path_string() + || b.storage_path.segments != seg_a + { + eprintln!( + "GATE FAIL file name={name:?} fp={folder_path:?}\n BEFORE name={:?} path={:?} segs={:?}\n AFTER name={:?} path={:?} segs={:?}", + b.name, + b.path_string, + b.storage_path.segments, + a.name(), + a.path_string(), + seg_a + ); + return false; + } + true + } + (Err(_), Err(_)) => true, // error parity + (b, a) => { + eprintln!( + "GATE FAIL file name={name:?} fp={folder_path:?}: error parity broke (before_ok={} after_ok={})", + b.is_ok(), + a.is_ok() + ); + false + } + } +} + +fn gate_folder(name: &str, path: &str) -> bool { + let b = before::folder_row( + "id".into(), + name.to_string(), + path.to_string(), + None, + Uuid::nil(), + 0, + 0, + 0, + None, + None, + ); + let a = Folder::from_materialized_row( + "id".into(), + name.to_string(), + path.to_string(), + None, + Uuid::nil(), + 0, + 0, + 0, + None, + None, + ); + match (b, a) { + (Ok(b), Ok(a)) => { + let seg_a: Vec = a.storage_path().segments().to_vec(); + if b.name != a.name() + || b.path_string != a.path_string() + || b.storage_path.segments != seg_a + { + eprintln!( + "GATE FAIL folder name={name:?} path={path:?}\n BEFORE name={:?} path={:?} segs={:?}\n AFTER name={:?} path={:?} segs={:?}", + b.name, + b.path_string, + b.storage_path.segments, + a.name(), + a.path_string(), + seg_a + ); + return false; + } + true + } + (Err(_), Err(_)) => true, + (b, a) => { + eprintln!( + "GATE FAIL folder name={name:?} path={path:?}: error parity broke (before_ok={} after_ok={})", + b.is_ok(), + a.is_ok() + ); + false + } + } +} + +fn main() { + let rows: usize = env::var("BENCH_ROWS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10_000); + let passes: usize = env::var("BENCH_PASSES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(100); + let corpus = build_corpus(rows); + + println!("bench_row_path — {rows} rows, {passes} passes (p50 ns/row)"); + println!(); + + // Warm-up + black_box(run_file_before(&corpus)); + black_box(run_file_after(&corpus)); + black_box(run_folder_before(&corpus)); + black_box(run_folder_after(&corpus)); + + // [1] file rows + let f_before = time_ns_per_row(passes, rows, || run_file_before(&corpus)); + let f_after = time_ns_per_row(passes, rows, || run_file_after(&corpus)); + println!("[1] File row (path chain + entity build)"); + println!(" BEFORE {f_before:8.1} ns/row"); + println!( + " AFTER {f_after:8.1} ns/row {:.2}x", + f_before / f_after + ); + + // [2] folder rows + let d_before = time_ns_per_row(passes, rows, || run_folder_before(&corpus)); + let d_after = time_ns_per_row(passes, rows, || run_folder_after(&corpus)); + println!("[2] Folder row (path chain + entity build)"); + println!(" BEFORE {d_before:8.1} ns/row"); + println!( + " AFTER {d_after:8.1} ns/row {:.2}x", + d_before / d_after + ); + + // [3] allocs/row + let fa_before = allocs_per_row(rows, || run_file_before(&corpus)); + let fa_after = allocs_per_row(rows, || run_file_after(&corpus)); + let da_before = allocs_per_row(rows, || run_folder_before(&corpus)); + let da_after = allocs_per_row(rows, || run_folder_after(&corpus)); + println!("[3] Alloc calls/row"); + println!(" File BEFORE {fa_before:6.2} AFTER {fa_after:6.2}"); + println!(" Folder BEFORE {da_before:6.2} AFTER {da_after:6.2}"); + + // [4] equivalence gate — realistic corpus + adversarial inputs + let mut ok = true; + for r in &corpus { + ok &= gate_file(&r.name, r.folder_path.as_deref()); + ok &= gate_folder(&r.name, &folder_full_path(r)); + } + // Adversarial: non-canonical paths, traversal, NFD names, empties. + let adversarial_files: &[(&str, Option<&str>)] = &[ + ("file.txt", None), + ("file.txt", Some("")), + ("file.txt", Some("/")), + ("file.txt", Some("a//b")), + ("file.txt", Some("/a/b/")), + ("file.txt", Some("../etc")), + ("file.txt", Some("a/./b")), + ("file.txt", Some("//")), + // NFD name (decomposed é): DB rows are NFC by invariant, but the + // chain must stay byte-identical even for un-normalized input. + ("cafe\u{0301}.txt", Some("/a")), + ("", Some("/a")), // error parity + ("..", Some("/a")), // error parity + ("nul\0l.txt", Some("/a")), // error parity + ("a\\b.txt", Some("/a")), // error parity + ]; + for (n, fp) in adversarial_files { + ok &= gate_file(n, *fp); + } + let adversarial_folders: &[(&str, &str)] = &[ + ("Docs", "/Docs"), + ("Docs", "Docs"), + ("Docs", "/a//Docs"), + ("Docs", "/a/Docs/"), + ("Docs", "/"), + ("Docs", ""), + ("Docs", "/../Docs"), + ("Doc\u{0301}s", "/a/Doc\u{0301}s"), // NFD in both + ]; + for (n, p) in adversarial_folders { + ok &= gate_folder(n, p); + } + println!( + "[4] Equivalence gate: {}", + if ok { "OK (byte-identical)" } else { "FAILED" } + ); + + if !ok { + std::process::exit(1); + } +} diff --git a/src/application/adapters/caldav_adapter.rs b/src/application/adapters/caldav_adapter.rs index ab93fe12..ef91b702 100644 --- a/src/application/adapters/caldav_adapter.rs +++ b/src/application/adapters/caldav_adapter.rs @@ -56,14 +56,32 @@ fn parse_caldav_datetime(value: &str) -> Option> { /// `None` if either tag is missing (malformed body) so callers /// can fall back safely. pub(crate) fn extract_vevent_chunk(ical_data: &str) -> Option<&str> { - let upper = ical_data.to_ascii_uppercase(); - let begin = upper.find("BEGIN:VEVENT")?; - // End marker: the line-start of END:VEVENT after `begin`, plus - // the length of "END:VEVENT" itself, then find the next CRLF/LF - // to include the terminator line. - let after_begin = &upper[begin..]; - let rel_end = after_begin.find("END:VEVENT")?; - let end_tag_end = begin + rel_end + "END:VEVENT".len(); + // Byte index of the first ASCII-case-insensitive occurrence of + // `needle` in `hay` at or after `from`. Every stored body OxiCloud + // itself writes carries uppercase tags, so try the memchr-backed + // exact `find` first; only genuinely mixed-case foreign bodies pay + // the manual scan. Either way this replaces the old + // `to_ascii_uppercase()` of the ENTIRE body — one full-copy String + // allocation per event per REPORT/GET, done purely to locate two + // tags. + fn find_ci(hay: &str, needle: &str, from: usize) -> Option { + if let Some(i) = hay[from..].find(needle) { + return Some(from + i); + } + let h = hay.as_bytes(); + let n = needle.as_bytes(); + if h.len() < n.len() { + return None; + } + (from..=h.len() - n.len()).find(|&i| h[i..i + n.len()].eq_ignore_ascii_case(n)) + } + + let begin = find_ci(ical_data, "BEGIN:VEVENT", 0)?; + // End marker: the first END:VEVENT after `begin`, plus the length + // of "END:VEVENT" itself, then any immediate CRLF/LF to include + // the terminator line. + let rel_end = find_ci(ical_data, "END:VEVENT", begin)?; + let end_tag_end = rel_end + "END:VEVENT".len(); // Include any immediate line terminator so the chunk stays a // well-formed line even when the caller concatenates. let mut end = end_tag_end; @@ -88,21 +106,27 @@ pub(crate) fn extract_vevent_chunk(ical_data: &str) -> Option<&str> { pub(crate) fn group_events_by_uid<'a>( events: &'a [CalendarEventDto], ) -> Vec> { - let mut order: Vec = Vec::new(); - let mut buckets: std::collections::HashMap> = + // Keys borrow from the DTO slice (which outlives every local) — the + // old String-keyed map cloned every event's UID (twice for first + // appearances) on every REPORT / collection PROPFIND / GET. + let mut order: Vec<&'a str> = Vec::new(); + let mut buckets: std::collections::HashMap<&'a str, Vec<&'a CalendarEventDto>> = std::collections::HashMap::new(); for event in events { - let key = event.ical_uid.clone(); - if !buckets.contains_key(&key) { - order.push(key.clone()); + let key = event.ical_uid.as_str(); + match buckets.entry(key) { + std::collections::hash_map::Entry::Vacant(slot) => { + order.push(key); + slot.insert(vec![event]); + } + std::collections::hash_map::Entry::Occupied(mut slot) => slot.get_mut().push(event), } - buckets.entry(key).or_default().push(event); } let mut out = Vec::with_capacity(order.len()); for uid in order { - let mut bucket = buckets.remove(&uid).unwrap_or_default(); + let mut bucket = buckets.remove(uid).unwrap_or_default(); // Master first (recurrence_id None), exceptions in insertion order. bucket.sort_by_key(|e| e.recurrence_id.is_some()); out.push(bucket); @@ -1123,11 +1147,13 @@ impl CalDavAdapter { ]), ))?; - // Determine which properties to include based on request type + // Determine which properties to include based on request type — + // borrowed straight out of the request (the old `clone()` copied + // the whole Vec of owned QualifiedName strings per REPORT). let props = match request { - CalDavReportType::CalendarQuery { props, .. } => props.clone(), - CalDavReportType::CalendarMultiget { props, .. } => props.clone(), - CalDavReportType::SyncCollection { props, .. } => props.clone(), + CalDavReportType::CalendarQuery { props, .. } => props, + CalDavReportType::CalendarMultiget { props, .. } => props, + CalDavReportType::SyncCollection { props, .. } => props, }; // Add responses for events — folded per UID so a @@ -1143,7 +1169,7 @@ impl CalDavAdapter { None => continue, }; let href = format!("{}{}.ics", base_href, anchor.ical_uid); - Self::write_event_response(&mut xml_writer, &bundle, &props, &href)?; + Self::write_event_response(&mut xml_writer, &bundle, props, &href)?; } // End multistatus @@ -1418,6 +1444,26 @@ impl CalDavAdapter { } } +// ───────────────────────────────────────────────────────────── +// Bench support +// ───────────────────────────────────────────────────────────── + +/// Thin public wrappers over the `pub(crate)` read-side helpers so +/// `examples/bench_caldav_parse.rs` can measure them. Gated behind the +/// `bench` feature — adds nothing to prod builds. +#[cfg(feature = "bench")] +pub mod bench { + use super::*; + + pub fn extract_vevent_chunk(ical_data: &str) -> Option<&str> { + super::extract_vevent_chunk(ical_data) + } + + pub fn group_events_by_uid(events: &[CalendarEventDto]) -> Vec> { + super::group_events_by_uid(events) + } +} + // ───────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────── diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index 05a58613..15623f3b 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -627,17 +627,19 @@ impl WebDavAdapter { // RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat. // Props found in the dead store are returned in the dead 200 propstat, // so exclude them from the 404 propstat to avoid duplicate reporting. - let (known, unknown): (Vec<_>, Vec<_>) = props + // Single pass: the requested-props writer skips unknown + // names itself (its match arms mirror + // `folder_prop_is_known` exactly), so only the usually + // empty 404 list needs materialising — the old + // `partition` built two throwaway Vecs per row. + let truly_unknown: Vec<_> = props .iter() - .partition(|p| Self::folder_prop_is_known(p, quota)); - let truly_unknown: Vec<_> = unknown - .into_iter() - .filter(|p| !dead_name_set.contains(*p)) + .filter(|p| !Self::folder_prop_is_known(p, quota) && !dead_name_set.contains(p)) .collect(); xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; - Self::write_folder_requested_props(xml_writer, folder, &known, quota)?; + Self::write_folder_requested_props(xml_writer, folder, props, quota)?; xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; @@ -714,16 +716,19 @@ impl WebDavAdapter { // RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat. // Props found in the dead store are returned in the dead 200 propstat, // so exclude them from the 404 propstat to avoid duplicate reporting. - let (known, unknown): (Vec<_>, Vec<_>) = - props.iter().partition(|p| Self::file_prop_is_known(p)); - let truly_unknown: Vec<_> = unknown - .into_iter() - .filter(|p| !dead_name_set.contains(*p)) + // Single pass: the requested-props writer skips unknown + // names itself (its match arms mirror `file_prop_is_known` + // exactly), so only the usually empty 404 list needs + // materialising — the old `partition` built two throwaway + // Vecs per row. + let truly_unknown: Vec<_> = props + .iter() + .filter(|p| !Self::file_prop_is_known(p) && !dead_name_set.contains(p)) .collect(); xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; - Self::write_file_requested_props(xml_writer, file, &known)?; + Self::write_file_requested_props(xml_writer, file, props)?; xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; @@ -759,6 +764,71 @@ impl WebDavAdapter { Ok(()) } + // ── Per-row formatted-value writers (stack-rendered) ───────────── + // + // PROPFIND emits two formatted dates, a size and a quoted etag for + // EVERY row of every listing. `to_rfc3339()`/`to_rfc2822()` ran + // chrono's format-spec interpreter and allocated a String each; + // `to_string()`/`format!` added two more. These render the same + // bytes from stack buffers (`common::fmt`); out-of-range timestamps + // keep the old chrono path as a byte-identical fallback. + + fn write_creationdate(xml_writer: &mut Writer, secs: u64) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; + let secs = secs as i64; + let mut buf = [0u8; 25]; + match crate::common::fmt::rfc3339_utc(&mut buf, secs) { + Some(s) => xml_writer.write_event(Event::Text(BytesText::new(s)))?, + None => { + let s = chrono::DateTime::::from_timestamp(secs, 0) + .unwrap_or_else(Utc::now) + .to_rfc3339(); + xml_writer.write_event(Event::Text(BytesText::new(&s)))?; + } + } + xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; + Ok(()) + } + + fn write_lastmodified(xml_writer: &mut Writer, secs: u64) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + let secs = secs as i64; + let mut buf = [0u8; 31]; + match crate::common::fmt::rfc2822_utc(&mut buf, secs) { + Some(s) => xml_writer.write_event(Event::Text(BytesText::new(s)))?, + None => { + let s = chrono::DateTime::::from_timestamp(secs, 0) + .unwrap_or_else(Utc::now) + .to_rfc2822(); + xml_writer.write_event(Event::Text(BytesText::new(&s)))?; + } + } + xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + Ok(()) + } + + fn write_etag_quoted(xml_writer: &mut Writer, etag: &str) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + // One exactly-sized allocation instead of format!'s grow-from-empty. + let mut quoted = String::with_capacity(etag.len() + 2); + quoted.push('"'); + quoted.push_str(etag); + quoted.push('"'); + xml_writer.write_event(Event::Text(BytesText::new("ed)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + Ok(()) + } + + fn write_contentlength(xml_writer: &mut Writer, size: u64) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; + let mut buf = [0u8; 20]; + xml_writer.write_event(Event::Text(BytesText::new(crate::common::fmt::u64_str( + &mut buf, size, + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; + Ok(()) + } + /// Write standard folder properties fn write_folder_standard_props( xml_writer: &mut Writer, @@ -776,31 +846,15 @@ impl WebDavAdapter { xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; // Creation date - xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; - - // Convert u64 timestamp to DateTime - let created_at = chrono::DateTime::::from_timestamp(folder.created_at as i64, 0) - .unwrap_or_else(Utc::now); - - xml_writer.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; + Self::write_creationdate(xml_writer, folder.created_at)?; // Last modified - xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - - // Convert u64 timestamp to DateTime - let modified_at = chrono::DateTime::::from_timestamp(folder.modified_at as i64, 0) - .unwrap_or_else(Utc::now); - - xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + Self::write_lastmodified(xml_writer, folder.modified_at)?; // ETag — routes through `FolderDto::etag` (= `Folder::etag()`) // so every WebDAV emitter and HEAD response agree on a single // value for the same folder. - xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", folder.etag))))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + Self::write_etag_quoted(xml_writer, &folder.etag)?; // Content length (0 for directories) xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; @@ -829,13 +883,19 @@ impl WebDavAdapter { used_bytes: i64, available_bytes: Option, ) -> Result<()> { + let mut buf = [0u8; 21]; xml_writer.write_event(Event::Start(BytesStart::new("D:quota-used-bytes")))?; - xml_writer.write_event(Event::Text(BytesText::new(&used_bytes.to_string())))?; + xml_writer.write_event(Event::Text(BytesText::new(crate::common::fmt::i64_str( + &mut buf, used_bytes, + ))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:quota-used-bytes")))?; if let Some(available_bytes) = available_bytes { xml_writer.write_event(Event::Start(BytesStart::new("D:quota-available-bytes")))?; - xml_writer.write_event(Event::Text(BytesText::new(&available_bytes.to_string())))?; + xml_writer.write_event(Event::Text(BytesText::new(crate::common::fmt::i64_str( + &mut buf, + available_bytes, + ))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:quota-available-bytes")))?; } @@ -861,36 +921,18 @@ impl WebDavAdapter { xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; // Content length - xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; - xml_writer.write_event(Event::Text(BytesText::new(&file.size.to_string())))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; + Self::write_contentlength(xml_writer, file.size)?; // Creation date - xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; - - // Convert u64 timestamp to DateTime - let created_at = chrono::DateTime::::from_timestamp(file.created_at as i64, 0) - .unwrap_or_else(Utc::now); - - xml_writer.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; + Self::write_creationdate(xml_writer, file.created_at)?; // Last modified - xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - - // Convert u64 timestamp to DateTime - let modified_at = chrono::DateTime::::from_timestamp(file.modified_at as i64, 0) - .unwrap_or_else(Utc::now); - - xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + Self::write_lastmodified(xml_writer, file.modified_at)?; // ETag — routes through `FileDto::etag` (= `File::etag()`) so // PROPFIND, GET, HEAD, PUT-response, and MOVE all emit // byte-identical values for the same file. - xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", file.etag))))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + Self::write_etag_quoted(xml_writer, &file.etag)?; Ok(()) } @@ -936,7 +978,7 @@ impl WebDavAdapter { fn write_folder_requested_props( xml_writer: &mut Writer, folder: &FolderDto, - props: &[&QualifiedName], + props: &[QualifiedName], quota: Option<(i64, Option)>, ) -> Result<()> { for prop in props { @@ -953,37 +995,13 @@ impl WebDavAdapter { xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; } "creationdate" => { - xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; - - // Convert u64 timestamp to DateTime - let created_at = - chrono::DateTime::::from_timestamp(folder.created_at as i64, 0) - .unwrap_or_else(Utc::now); - - xml_writer - .write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; + Self::write_creationdate(xml_writer, folder.created_at)?; } "getlastmodified" => { - xml_writer - .write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - - // Convert u64 timestamp to DateTime - let modified_at = - chrono::DateTime::::from_timestamp(folder.modified_at as i64, 0) - .unwrap_or_else(Utc::now); - - xml_writer - .write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + Self::write_lastmodified(xml_writer, folder.modified_at)?; } "getetag" => { - xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer.write_event(Event::Text(BytesText::new(&format!( - "\"{}\"", - folder.etag - ))))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + Self::write_etag_quoted(xml_writer, &folder.etag)?; } "getcontentlength" => { xml_writer @@ -1000,21 +1018,25 @@ impl WebDavAdapter { } "quota-used-bytes" => { if let Some((used, _)) = quota { + let mut buf = [0u8; 21]; xml_writer .write_event(Event::Start(BytesStart::new("D:quota-used-bytes")))?; - xml_writer - .write_event(Event::Text(BytesText::new(&used.to_string())))?; + xml_writer.write_event(Event::Text(BytesText::new( + crate::common::fmt::i64_str(&mut buf, used), + )))?; xml_writer .write_event(Event::End(BytesEnd::new("D:quota-used-bytes")))?; } } "quota-available-bytes" => { if let Some((_, Some(available))) = quota { + let mut buf = [0u8; 21]; xml_writer.write_event(Event::Start(BytesStart::new( "D:quota-available-bytes", )))?; - xml_writer - .write_event(Event::Text(BytesText::new(&available.to_string())))?; + xml_writer.write_event(Event::Text(BytesText::new( + crate::common::fmt::i64_str(&mut buf, available), + )))?; xml_writer.write_event(Event::End(BytesEnd::new( "D:quota-available-bytes", )))?; @@ -1035,7 +1057,7 @@ impl WebDavAdapter { fn write_file_requested_props( xml_writer: &mut Writer, file: &FileDto, - props: &[&QualifiedName], + props: &[QualifiedName], ) -> Result<()> { for prop in props { if prop.namespace == "DAV:" { @@ -1055,44 +1077,16 @@ impl WebDavAdapter { xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; } "getcontentlength" => { - xml_writer - .write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; - xml_writer - .write_event(Event::Text(BytesText::new(&file.size.to_string())))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; + Self::write_contentlength(xml_writer, file.size)?; } "creationdate" => { - xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; - - // Convert u64 timestamp to DateTime - let created_at = - chrono::DateTime::::from_timestamp(file.created_at as i64, 0) - .unwrap_or_else(Utc::now); - - xml_writer - .write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; + Self::write_creationdate(xml_writer, file.created_at)?; } "getlastmodified" => { - xml_writer - .write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - - // Convert u64 timestamp to DateTime - let modified_at = - chrono::DateTime::::from_timestamp(file.modified_at as i64, 0) - .unwrap_or_else(Utc::now); - - xml_writer - .write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + Self::write_lastmodified(xml_writer, file.modified_at)?; } "getetag" => { - xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer.write_event(Event::Text(BytesText::new(&format!( - "\"{}\"", - file.etag - ))))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + Self::write_etag_quoted(xml_writer, &file.etag)?; } _ => { // Unknown prop — skipped here; caller writes 404 propstat. @@ -1586,3 +1580,36 @@ impl WebDavAdapter { Self::write_file_response_with_dead_props(writer, file, request, href, dead_props) } } + +/// Thin public wrappers over the private per-row PROPFIND writers so +/// `examples/bench_propfind_xml.rs` can measure them. Gated behind the +/// `bench` feature — adds nothing to prod builds. +#[cfg(feature = "bench")] +pub mod bench { + use super::*; + + pub fn write_file_propfind_row( + xml_writer: &mut Writer, + file: &FileDto, + request: &PropFindRequest, + href: &str, + dead_props: &[(QualifiedName, Option)], + ) -> Result<()> { + WebDavAdapter::write_file_response_with_dead_props( + xml_writer, file, request, href, dead_props, + ) + } + + pub fn write_folder_propfind_row( + xml_writer: &mut Writer, + folder: &FolderDto, + request: &PropFindRequest, + href: &str, + dead_props: &[(QualifiedName, Option)], + quota: Option<(i64, Option)>, + ) -> Result<()> { + WebDavAdapter::write_folder_response_with_dead_props( + xml_writer, folder, request, href, dead_props, quota, + ) + } +} diff --git a/src/application/ports/calendar_ports.rs b/src/application/ports/calendar_ports.rs index cd4781bf..7eea753a 100644 --- a/src/application/ports/calendar_ports.rs +++ b/src/application/ports/calendar_ports.rs @@ -34,6 +34,12 @@ pub trait CalendarStoragePort: Send + Sync + 'static { ) -> Result; async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError>; async fn get_calendar(&self, calendar_id: &str) -> Result; + + /// Batch sibling of [`Self::get_calendar`]: hydrate a page of + /// grant-derived calendar ids in ONE storage round-trip. Missing + /// rows (deleted/trashed race) drop out silently; ordering is not + /// guaranteed. + async fn get_calendars_by_ids(&self, ids: &[Uuid]) -> Result, DomainError>; async fn list_calendars_by_owner( &self, owner_id: Uuid, diff --git a/src/application/ports/carddav_ports.rs b/src/application/ports/carddav_ports.rs index 3dd420a8..6a638a2c 100644 --- a/src/application/ports/carddav_ports.rs +++ b/src/application/ports/carddav_ports.rs @@ -37,6 +37,12 @@ pub trait ContactStoragePort: Send + Sync + 'static { ) -> Result; async fn delete_address_book(&self, id: &Uuid) -> Result<(), DomainError>; async fn get_address_book_by_id(&self, id: &Uuid) -> Result, DomainError>; + + /// Batch sibling of [`Self::get_address_book_by_id`]: hydrate a page + /// of grant-derived ids in ONE storage round-trip. Missing rows drop + /// out silently; ordering is not guaranteed. + async fn get_address_books_by_ids(&self, ids: &[Uuid]) + -> Result, DomainError>; async fn get_public_address_books(&self) -> Result, DomainError>; // ── Contacts ───────────────────────────────────────────────── diff --git a/src/application/ports/music_ports.rs b/src/application/ports/music_ports.rs index c20cd454..111b9cca 100644 --- a/src/application/ports/music_ports.rs +++ b/src/application/ports/music_ports.rs @@ -104,6 +104,11 @@ pub trait MusicStoragePort: Send + Sync { async fn get_playlist(&self, playlist_id: &str) -> Result, DomainError>; + /// Batch sibling of [`Self::get_playlist`]: hydrate a page of + /// grant-derived ids in ONE storage round-trip. Missing rows drop + /// out silently; ordering is not guaranteed. + async fn get_playlists_by_ids(&self, ids: &[Uuid]) -> Result, DomainError>; + async fn list_playlists_by_owner( &self, owner_id: Uuid, diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 94f5fb36..156f618e 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -147,8 +147,12 @@ pub struct AuthApplicationService { /// request. The short TTL keeps the "role changes apply without token /// rotation" property within seconds while removing one DB round-trip /// per request; the known mutation paths (`change_user_role`, - /// `set_user_active`) also invalidate eagerly. - user_flags_cache: Cache, + /// `set_user_active`) also invalidate eagerly. `moka::future` so + /// concurrent misses for one user coalesce into a single DB lookup + /// (`try_get_with` single-flight) — every authenticated request + /// calls this, so each 30 s TTL expiry used to fan out one SELECT + /// per in-flight request of that user. + user_flags_cache: moka::future::Cache, /// Self-service auth-method allowlist (mirrors /// `AuthConfig::allowed_auth_methods`). Empty = both methods /// allowed. Consulted by login / register / magic-link handlers via @@ -198,7 +202,7 @@ impl AuthApplicationService { .time_to_live(Duration::from_secs(120)) .build(), magic_link_repo: None, - user_flags_cache: Cache::builder() + user_flags_cache: moka::future::Cache::builder() .max_capacity(10_000) .time_to_live(USER_FLAGS_CACHE_TTL) .build(), @@ -1363,7 +1367,7 @@ impl AuthApplicationService { // Invalidate the flags cache so subsequent per-request guards // observe the new `is_external=false` without waiting for the // 30-second TTL. Same pattern as `change_user_role`. - self.user_flags_cache.invalidate(&caller_id); + self.user_flags_cache.invalidate(&caller_id).await; // Dispatch — home-drive provisioning happens here. Log-and- // continue: a provisioning failure leaves the row updated and @@ -1508,12 +1512,20 @@ impl AuthApplicationService { /// Staleness is bounded by [`USER_FLAGS_CACHE_TTL`]; role and active /// changes made through this service invalidate the entry eagerly. pub async fn get_user_flags(&self, user_id: Uuid) -> Result { - if let Some(flags) = self.user_flags_cache.get(&user_id) { - return Ok(flags); - } - let flags = self.user_storage.get_user_flags(user_id).await?; - self.user_flags_cache.insert(user_id, flags); - Ok(flags) + // Single-flight: concurrent misses for the same user coalesce + // into ONE storage lookup; errors are never cached (same herd + // shape ROUND3 fixed for basic-auth, minus the Argon2 cost). + self.user_flags_cache + .try_get_with(user_id, async { + Ok::<_, DomainError>(self.user_storage.get_user_flags(user_id).await?) + }) + .await + // try_get_with hands back `Arc` shared by all + // waiters; DomainError isn't Clone, so rebuild a fresh one + // preserving the kind / entity / message. + .map_err(|shared: std::sync::Arc| { + DomainError::new(shared.kind, shared.entity_type, shared.message.clone()) + }) } /// Apply a profile update on behalf of the calling user (PR 24). @@ -2226,7 +2238,7 @@ impl AuthApplicationService { self.user_storage .set_user_active_status(user_id, active) .await?; - self.user_flags_cache.invalidate(&user_id); + self.user_flags_cache.invalidate(&user_id).await; Ok(()) } @@ -2240,7 +2252,7 @@ impl AuthApplicationService { )); } self.user_storage.change_role(user_id, role).await?; - self.user_flags_cache.invalidate(&user_id); + self.user_flags_cache.invalidate(&user_id).await; Ok(()) } diff --git a/src/application/services/calendar_service.rs b/src/application/services/calendar_service.rs index c8c2b093..88495932 100644 --- a/src/application/services/calendar_service.rs +++ b/src/application/services/calendar_service.rs @@ -189,17 +189,13 @@ impl CalendarUseCase for CalendarService { }) .collect(); - // Hydrate DTOs. `get_calendar` misses on trashed / deleted - // calendars — those are dropped from the listing rather than - // erroring, so a lifecycle-race doesn't turn a PROPFIND into - // a 5xx. - let mut out = Vec::with_capacity(calendar_ids.len()); - for id in calendar_ids { - if let Ok(dto) = self.calendar_storage.get_calendar(&id.to_string()).await { - out.push(dto); - } - } - Ok(out) + // Hydrate DTOs in ONE `= ANY` round-trip (was one point SELECT + // per accessible calendar — K serial round-trips on every + // CalDAV discovery poll). Missing rows (deleted/trashed race) + // drop out of the result set instead of erroring, so a + // lifecycle-race still doesn't turn a PROPFIND into a 5xx. + let ids: Vec = calendar_ids.into_iter().collect(); + self.calendar_storage.get_calendars_by_ids(&ids).await } async fn list_public_calendars( diff --git a/src/application/services/contact_service.rs b/src/application/services/contact_service.rs index 04ac2dde..ef70b912 100644 --- a/src/application/services/contact_service.rs +++ b/src/application/services/contact_service.rs @@ -494,12 +494,14 @@ impl AddressBookUseCase for ContactService { let mut address_book_map = std::collections::HashMap::new(); - for id in book_ids { - // Missing rows (deleted / trashed race) drop out silently - // — matches the calendar-listing carve-out. - if let Ok(Some(book)) = self.contact_storage.get_address_book_by_id(&id).await { - address_book_map.insert(*book.id(), book); - } + // Hydrate in ONE `= ANY` round-trip (was one point SELECT per + // accessible book — K serial round-trips on every CardDAV + // discovery poll). Missing rows (deleted / trashed race) drop + // out of the result set — matches the calendar-listing + // carve-out. + let ids: Vec = book_ids.into_iter().collect(); + for book in self.contact_storage.get_address_books_by_ids(&ids).await? { + address_book_map.insert(*book.id(), book); } // Public address books surface for every authenticated caller diff --git a/src/application/services/drive_management_service.rs b/src/application/services/drive_management_service.rs index bcf8736a..91e90e27 100644 --- a/src/application/services/drive_management_service.rs +++ b/src/application/services/drive_management_service.rs @@ -263,6 +263,12 @@ impl DriveManagementService { self.authz .invalidate_drive_role_cache_for_drive(drive_id) .await; + // Same freshness contract for the repo's readable-drives cache: + // the subject's drive list changed with this grant. + match subject { + Subject::User(uid) => self.drive_repo.invalidate_readable_for_user(uid).await, + _ => self.drive_repo.invalidate_readable_all(), + } // D6 §11: canonical `drive.member_added` audit event covers // every successful membership write (add + role-refresh, since @@ -335,6 +341,12 @@ impl DriveManagementService { self.authz .invalidate_drive_role_cache_for_drive(drive_id) .await; + // And the repo's readable-drives cache: the drive must vanish + // from the removed subject's list immediately. + match subject { + Subject::User(uid) => self.drive_repo.invalidate_readable_for_user(uid).await, + _ => self.drive_repo.invalidate_readable_all(), + } // D6 §11: canonical `drive.member_removed` audit event covers // every successful removal (owner-driven or admin bypass). diff --git a/src/application/services/music_service.rs b/src/application/services/music_service.rs index 20244251..79df764a 100644 --- a/src/application/services/music_service.rs +++ b/src/application/services/music_service.rs @@ -196,15 +196,18 @@ impl MusicUseCase for MusicService { // only. Owner is a grant like any other in `role_grants`, so we // filter the aggregated set against the owner_id stamped on // each row after hydration — cheaper than a second SQL round-trip. - let mut playlists: Vec = Vec::with_capacity(playlist_ids.len()); + // Hydrate in ONE `= ANY` round-trip (was one point SELECT per + // accessible playlist). Missing rows (deleted race) drop out of + // the result set silently, as before. let user_str = user_id.to_string(); - for id in playlist_ids.drain() { - if let Ok(Some(p)) = self.storage.get_playlist(&id.to_string()).await - && (include_shared || p.owner_id == user_str) - { - playlists.push(p); - } - } + let ids: Vec = playlist_ids.drain().collect(); + let mut playlists: Vec = self + .storage + .get_playlists_by_ids(&ids) + .await? + .into_iter() + .filter(|p| include_shared || p.owner_id == user_str) + .collect(); if include_public { let public = self.storage.list_public_playlists(limit, offset).await?; diff --git a/src/application/services/subject_group_service.rs b/src/application/services/subject_group_service.rs index d8d68fa3..3fb2858d 100644 --- a/src/application/services/subject_group_service.rs +++ b/src/application/services/subject_group_service.rs @@ -44,6 +44,11 @@ pub struct SubjectGroupService { /// 30 s TTL. Without this, fresh group-mediated drive grants /// don't appear in `/api/drives` for up to 30 s after `add_member`. engine: Arc, + /// Same freshness contract for the drive repository's per-user + /// readable-drives cache: a membership change on a group that holds + /// drive grants changes every affected user's visible drive list, + /// so the cached lists drop alongside `user_groups_cache`. + drive_repo: Arc, } impl SubjectGroupService { @@ -52,12 +57,14 @@ impl SubjectGroupService { pool: Arc, user_storage: Arc, engine: Arc, + drive_repo: Arc, ) -> Self { Self { repo, pool, user_storage, engine, + drive_repo, } } @@ -426,6 +433,7 @@ impl SubjectGroupService { // call for up to 30 s. for uid in self.invalidation_targets(member).await? { self.engine.invalidate_user_groups_cache(uid).await; + self.drive_repo.invalidate_readable_for_user(uid).await; } tracing::info!( @@ -525,6 +533,7 @@ impl SubjectGroupService { // for up to 30 s, surfacing grants they no longer have. for uid in self.invalidation_targets(member).await? { self.engine.invalidate_user_groups_cache(uid).await; + self.drive_repo.invalidate_readable_for_user(uid).await; } tracing::info!( @@ -634,7 +643,9 @@ mod integration_tests { // future test starts exercising real authz lookups. let engine = Arc::new(crate::infrastructure::services::pg_acl_engine::PgAclEngine::new_stub()); - SubjectGroupService::new(repo, pool, user_storage, engine) + let drive_repo = + Arc::new(crate::infrastructure::repositories::pg::DrivePgRepository::new(pool.clone())); + SubjectGroupService::new(repo, pool, user_storage, engine, drive_repo) } async fn first_admin(pool: &sqlx::PgPool) -> Uuid { diff --git a/src/common/config.rs b/src/common/config.rs index 71df0b23..e0dc817c 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -310,6 +310,10 @@ pub struct AzureStorageConfig { pub container: String, /// Optional SAS token (alternative to account key). pub sas_token: Option, + /// Optional custom endpoint (Azurite emulator, private deployments, + /// benches). `None` = the public cloud URL derived from the account + /// name. Mirrors S3's `endpoint_url`. + pub endpoint_url: Option, } /// LRU local disk cache configuration for remote blob backends. @@ -2140,6 +2144,7 @@ impl AppConfig { account_key: env::var("OXICLOUD_AZURE_ACCOUNT_KEY").unwrap_or_default(), container, sas_token: env::var("OXICLOUD_AZURE_SAS_TOKEN").ok(), + endpoint_url: env::var("OXICLOUD_AZURE_ENDPOINT_URL").ok(), }); } diff --git a/src/common/di.rs b/src/common/di.rs index fdc1163a..664ad52a 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1682,6 +1682,7 @@ impl AppServiceFactory { ), ), authorization.clone(), + drive_repo.clone(), ), )), email_sender: None, // populated below diff --git a/src/common/fmt.rs b/src/common/fmt.rs new file mode 100644 index 00000000..7e70fa8e --- /dev/null +++ b/src/common/fmt.rs @@ -0,0 +1,256 @@ +//! Heap-free fixed-layout formatters for the hot XML/HTTP emit paths. +//! +//! PROPFIND writes two formatted dates, a size and a quoted etag for +//! EVERY row of every listing; `to_rfc3339()` / `to_rfc2822()` run +//! chrono's format-spec interpreter and allocate a `String` each, and +//! `u64::to_string()` allocates another. These helpers render the same +//! bytes into a caller-provided stack buffer: zero heap traffic, no +//! interpreter. +//! +//! Byte-identity with chrono (for whole-second in-range UTC datetimes) +//! is asserted by the unit tests below and by the equivalence gate in +//! `examples/bench_propfind_xml.rs`. Out-of-range seconds (negative or +//! year > 9999, where the fixed-width layout no longer applies) return +//! `None` — callers keep the old chrono path as fallback, so exotic +//! values change nothing observable. + +/// Seconds range rendering to a fixed-width 4-digit year: 1970-01-01 +/// through 9999-12-31 23:59:59 UTC. +const MAX_4DIGIT_YEAR_SECS: i64 = 253_402_300_799; + +const MONTHS: [&[u8; 3]; 12] = [ + b"Jan", b"Feb", b"Mar", b"Apr", b"May", b"Jun", b"Jul", b"Aug", b"Sep", b"Oct", b"Nov", b"Dec", +]; +const WEEKDAYS: [&[u8; 3]; 7] = [b"Thu", b"Fri", b"Sat", b"Sun", b"Mon", b"Tue", b"Wed"]; + +/// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); // day-of-era [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] + (if m <= 2 { y + 1 } else { y }, m, d) +} + +#[inline] +fn push2(out: &mut [u8], pos: usize, v: u32) { + out[pos] = b'0' + (v / 10) as u8; + out[pos + 1] = b'0' + (v % 10) as u8; +} + +#[inline] +fn push4(out: &mut [u8], pos: usize, v: i64) { + out[pos] = b'0' + (v / 1000 % 10) as u8; + out[pos + 1] = b'0' + (v / 100 % 10) as u8; + out[pos + 2] = b'0' + (v / 10 % 10) as u8; + out[pos + 3] = b'0' + (v % 10) as u8; +} + +/// Split epoch seconds into (days, y, m, d, hh, mm, ss). +#[inline] +fn split(secs: i64) -> (i64, i64, u32, u32, u32, u32, u32) { + let days = secs.div_euclid(86_400); + let sod = secs.rem_euclid(86_400); + let (y, m, d) = civil_from_days(days); + ( + days, + y, + m, + d, + (sod / 3600) as u32, + (sod / 60 % 60) as u32, + (sod % 60) as u32, + ) +} + +/// `chrono::DateTime::to_rfc3339()` for a whole-second timestamp: +/// `2026-07-17T11:47:14+00:00` (25 bytes) written into `buf`. +/// +/// Returns `None` when `secs` is outside the fixed-width range — +/// callers fall back to chrono. +pub fn rfc3339_utc(buf: &mut [u8; 25], secs: i64) -> Option<&str> { + if !(0..=MAX_4DIGIT_YEAR_SECS).contains(&secs) { + return None; + } + let (_days, y, m, d, hh, mm, ss) = split(secs); + push4(buf, 0, y); + buf[4] = b'-'; + push2(buf, 5, m); + buf[7] = b'-'; + push2(buf, 8, d); + buf[10] = b'T'; + push2(buf, 11, hh); + buf[13] = b':'; + push2(buf, 14, mm); + buf[16] = b':'; + push2(buf, 17, ss); + buf[19..25].copy_from_slice(b"+00:00"); + // SAFETY-free: every byte written above is ASCII. + Some(std::str::from_utf8(&buf[..]).expect("ascii")) +} + +/// `chrono::DateTime::to_rfc2822()` for a whole-second timestamp: +/// `Fri, 17 Jul 2026 11:47:14 +0000` written into `buf`. +/// +/// chrono does NOT zero-pad the day (`Thu, 1 Jan 1970 …`), so the +/// rendered length is 30 or 31 bytes — the round-4 PROPFIND equivalence +/// gate caught an early padded version of this function; the sweep test +/// below pins parity byte-for-byte across 60 years. +pub fn rfc2822_utc(buf: &mut [u8; 31], secs: i64) -> Option<&str> { + if !(0..=MAX_4DIGIT_YEAR_SECS).contains(&secs) { + return None; + } + let (days, y, m, d, hh, mm, ss) = split(secs); + let weekday = WEEKDAYS[days.rem_euclid(7) as usize]; + buf[0..3].copy_from_slice(weekday); + buf[3] = b','; + buf[4] = b' '; + let mut p = 5; + if d >= 10 { + buf[p] = b'0' + (d / 10) as u8; + p += 1; + } + buf[p] = b'0' + (d % 10) as u8; + p += 1; + buf[p] = b' '; + p += 1; + buf[p..p + 3].copy_from_slice(MONTHS[(m - 1) as usize]); + p += 3; + buf[p] = b' '; + p += 1; + push4(buf, p, y); + p += 4; + buf[p] = b' '; + p += 1; + push2(buf, p, hh); + p += 2; + buf[p] = b':'; + p += 1; + push2(buf, p, mm); + p += 2; + buf[p] = b':'; + p += 1; + push2(buf, p, ss); + p += 2; + buf[p..p + 6].copy_from_slice(b" +0000"); + p += 6; + Some(std::str::from_utf8(&buf[..p]).expect("ascii")) +} + +/// `u64::to_string()` without the heap `String`: renders into `buf`, +/// returns the populated tail slice. +pub fn u64_str(buf: &mut [u8; 20], mut v: u64) -> &str { + let mut pos = buf.len(); + loop { + pos -= 1; + buf[pos] = b'0' + (v % 10) as u8; + v /= 10; + if v == 0 { + break; + } + } + std::str::from_utf8(&buf[pos..]).expect("ascii") +} + +/// `i64::to_string()` without the heap `String` (quota bytes are `i64`). +pub fn i64_str(buf: &mut [u8; 21], v: i64) -> &str { + let mut u = [0u8; 20]; + let digits = u64_str(&mut u, v.unsigned_abs()); + let neg = v < 0; + let start = 21 - digits.len() - usize::from(neg); + if neg { + buf[start] = b'-'; + } + buf[start + usize::from(neg)..].copy_from_slice(digits.as_bytes()); + std::str::from_utf8(&buf[start..]).expect("ascii") +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + + /// Edge-heavy corpus: epoch, single-digit day (padding!), leap day, + /// end-of-year, DST-irrelevant midsummer, far future, max in-range. + const CASES: [i64; 12] = [ + 0, + 1, + 86_399, + 86_400, + 951_782_400, // 2000-02-29 (leap) + 1_120_176_000, // 2005-07-01 (day < 10 → chrono pads) + 1_752_753_434, + 2_147_483_647, + 4_102_444_799, // 2099-12-31 23:59:59 + 7_258_118_400, + 250_000_000_000, + MAX_4DIGIT_YEAR_SECS, + ]; + + #[test] + fn rfc3339_matches_chrono() { + for &secs in &CASES { + let dt = Utc.timestamp_opt(secs, 0).unwrap(); + let mut buf = [0u8; 25]; + assert_eq!( + rfc3339_utc(&mut buf, secs).expect("in range"), + dt.to_rfc3339(), + "secs={secs}" + ); + } + } + + #[test] + fn rfc2822_matches_chrono() { + for &secs in &CASES { + let dt = Utc.timestamp_opt(secs, 0).unwrap(); + let mut buf = [0u8; 31]; + assert_eq!( + rfc2822_utc(&mut buf, secs).expect("in range"), + dt.to_rfc2822(), + "secs={secs}" + ); + } + } + + #[test] + fn out_of_range_falls_back() { + let mut b3 = [0u8; 25]; + let mut b2 = [0u8; 31]; + assert!(rfc3339_utc(&mut b3, -1).is_none()); + assert!(rfc2822_utc(&mut b2, -1).is_none()); + assert!(rfc3339_utc(&mut b3, MAX_4DIGIT_YEAR_SECS + 1).is_none()); + } + + #[test] + fn ints_match_std() { + let mut b = [0u8; 20]; + for v in [0u64, 1, 9, 10, 42, 1024, u64::MAX] { + assert_eq!(u64_str(&mut b, v), v.to_string()); + } + let mut b = [0u8; 21]; + for v in [0i64, -1, 42, -1024, i64::MIN, i64::MAX] { + assert_eq!(i64_str(&mut b, v), v.to_string()); + } + } + + /// Exhaustive-ish sweep: every 6h13m across 60 years — catches any + /// weekday / month-boundary drift against chrono. + #[test] + fn sweep_matches_chrono() { + let mut secs: i64 = 0; + while secs < 60 * 366 * 86_400 { + let dt = Utc.timestamp_opt(secs, 0).unwrap(); + let mut b3 = [0u8; 25]; + let mut b2 = [0u8; 31]; + assert_eq!(rfc3339_utc(&mut b3, secs).unwrap(), dt.to_rfc3339()); + assert_eq!(rfc2822_utc(&mut b2, secs).unwrap(), dt.to_rfc2822()); + secs += 22_380; // 6h13m — walks through all times of day + weekdays + } + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index a9f142c7..6232ba12 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,6 +1,7 @@ pub mod config; pub mod di; pub mod errors; +pub mod fmt; pub mod locale; pub mod mime_detect; pub mod runtime; diff --git a/src/domain/entities/calendar_event.rs b/src/domain/entities/calendar_event.rs index d0bc4e60..afa146e4 100644 --- a/src/domain/entities/calendar_event.rs +++ b/src/domain/entities/calendar_event.rs @@ -250,25 +250,36 @@ impl CalendarEvent { * @return Result containing the new CalendarEvent or a domain error */ pub fn from_ical(calendar_id: Uuid, ical_data: String) -> Result { - // This implementation would require a proper iCalendar parser - // For brevity, we're using a simplified version here + // Parse the body ONCE and read every property from the parsed + // component. The previous shape funnelled each of the 8 property + // lookups below through `extract_ical_property[_with_params]`, + // which re-ran the full `IcalParser` (line unfolding + component + // tree build) per property — 8 complete parses per VEVENT on + // every CalDAV PUT / import. A missing-or-unparseable body maps + // to the same "Missing SUMMARY" error the old first lookup + // produced, preserving error parity. + let event = Self::parse_first_vevent(&ical_data); - // Extract required fields from iCalendar data - let summary = Self::extract_ical_property(&ical_data, "SUMMARY").ok_or_else(|| { - DomainError::new( - ErrorKind::InvalidInput, - "CalendarEvent", - "Missing SUMMARY in iCalendar data", - ) - })?; + // Extract required fields from the parsed component + let summary = event + .as_ref() + .and_then(|e| Self::prop_value(e, "SUMMARY")) + .ok_or_else(|| { + DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "Missing SUMMARY in iCalendar data", + ) + })?; + let event = event.expect("prop_value returned Some, so the parse succeeded"); // DTSTART / DTEND: use the params-aware extractor so we can // detect `VALUE=DATE` (all-day) from the property parameters // rather than scanning the raw property line. The pre-parser- // rewrite substring scan couldn't see param-carrying lines at // all — see #528. - let (dtstart_value, dtstart_params) = - Self::extract_ical_property_with_params(&ical_data, "DTSTART").ok_or_else(|| { + let (dtstart_value, dtstart_params) = Self::prop_with_params(&event, "DTSTART") + .ok_or_else(|| { DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", @@ -277,7 +288,7 @@ impl CalendarEvent { })?; let (dtend_value, _dtend_params) = - Self::extract_ical_property_with_params(&ical_data, "DTEND").ok_or_else(|| { + Self::prop_with_params(&event, "DTEND").ok_or_else(|| { DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", @@ -313,13 +324,13 @@ impl CalendarEvent { })?; // Extract optional fields - let description = Self::extract_ical_property(&ical_data, "DESCRIPTION"); - let location = Self::extract_ical_property(&ical_data, "LOCATION"); - let rrule = Self::extract_ical_property(&ical_data, "RRULE"); + let description = Self::prop_value(&event, "DESCRIPTION"); + let location = Self::prop_value(&event, "LOCATION"); + let rrule = Self::prop_value(&event, "RRULE"); // Extract UID or generate a new one - let ical_uid = Self::extract_ical_property(&ical_data, "UID") - .unwrap_or_else(|| Uuid::new_v4().to_string()); + let ical_uid = + Self::prop_value(&event, "UID").unwrap_or_else(|| Uuid::new_v4().to_string()); // RECURRENCE-ID (RFC 5545 §3.8.4.4). When present, this VEVENT // is an override for a specific occurrence of a recurring @@ -329,17 +340,16 @@ impl CalendarEvent { // gets stored, just as a plain event (worst case a client sync // treats it as a new master, which the DB uniqueness will // refuse; better a persistence error than a silent split). - let recurrence_id = - match Self::extract_ical_property_with_params(&ical_data, "RECURRENCE-ID") { - Some((value, params)) => { - let is_date = params - .get("VALUE") - .map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE"))) - .unwrap_or(false); - Self::parse_ical_datetime(&value, is_date).ok() - } - None => None, - }; + let recurrence_id = match Self::prop_with_params(&event, "RECURRENCE-ID") { + Some((value, params)) => { + let is_date = params + .get("VALUE") + .map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE"))) + .unwrap_or(false); + Self::parse_ical_datetime(&value, is_date).ok() + } + None => None, + }; let now = Utc::now(); @@ -627,18 +637,28 @@ impl CalendarEvent { )); } - // Extract and update properties from iCalendar data - if let Some(summary) = Self::extract_ical_property(&ical_data, "SUMMARY") { + // Parse the body ONCE and update every property from the parsed + // component (same 8-parses→1 collapse as `from_ical`). An + // unparseable body behaves exactly like the old per-property + // lookups all returning `None`: optional fields clear, required + // fields keep their previous values. + let event = Self::parse_first_vevent(&ical_data); + + if let Some(summary) = event.as_ref().and_then(|e| Self::prop_value(e, "SUMMARY")) { self.summary = summary; } - self.description = Self::extract_ical_property(&ical_data, "DESCRIPTION"); - self.location = Self::extract_ical_property(&ical_data, "LOCATION"); + self.description = event + .as_ref() + .and_then(|e| Self::prop_value(e, "DESCRIPTION")); + self.location = event.as_ref().and_then(|e| Self::prop_value(e, "LOCATION")); // Extract DTSTART with parameters — needed for the all-day // detection below AND for the DTSTART/DTEND datetime parsers // (they need to know whether the value is a date or a datetime). - let dtstart_pair = Self::extract_ical_property_with_params(&ical_data, "DTSTART"); + let dtstart_pair = event + .as_ref() + .and_then(|e| Self::prop_with_params(e, "DTSTART")); let all_day = dtstart_pair .as_ref() .and_then(|(_v, params)| params.get("VALUE")) @@ -652,15 +672,17 @@ impl CalendarEvent { self.start_time = start_time; } - if let Some((value, _params)) = Self::extract_ical_property_with_params(&ical_data, "DTEND") + if let Some((value, _params)) = event + .as_ref() + .and_then(|e| Self::prop_with_params(e, "DTEND")) && let Ok(end_time) = Self::parse_ical_datetime(&value, all_day) { self.end_time = end_time; } - self.rrule = Self::extract_ical_property(&ical_data, "RRULE"); + self.rrule = event.as_ref().and_then(|e| Self::prop_value(e, "RRULE")); - if let Some(uid) = Self::extract_ical_property(&ical_data, "UID") { + if let Some(uid) = event.as_ref().and_then(|e| Self::prop_value(e, "UID")) { self.ical_uid = uid; } @@ -756,43 +778,74 @@ impl CalendarEvent { * @param property_name The name of the property to extract * @return Option containing the property value if found */ + #[cfg(test)] fn extract_ical_property(ical_data: &str, property_name: &str) -> Option { - Self::extract_ical_property_with_params(ical_data, property_name).map(|(v, _p)| v) + Self::prop_value(&Self::parse_first_vevent(ical_data)?, property_name) } - /// Extract a property's value AND parameter map. Same lookup rules - /// as `extract_ical_property`; the second element is a map keyed by - /// parameter name (`"VALUE"`, `"TZID"`, `"CN"`, …) whose value is - /// the list of parameter values (parameters can be multi-valued — - /// `MEMBER="mailto:a@x","mailto:b@x"` — hence the `Vec` - /// per key). - /// - /// Callers that only need the value should use `extract_ical_property`; - /// this variant is for DTSTART / DTEND / RECURRENCE-ID which need - /// `VALUE=DATE` detection to distinguish all-day from timed events. + /// Test-only sibling of [`Self::prop_with_params`] that parses the + /// raw body first. Production callers (`from_ical`, + /// `update_ical_data`) parse ONCE and use the by-reference helpers. + #[cfg(test)] fn extract_ical_property_with_params( ical_data: &str, property_name: &str, ) -> Option<(String, std::collections::HashMap>)> { - let event = Self::parse_first_vevent(ical_data)?; + Self::prop_with_params(&Self::parse_first_vevent(ical_data)?, property_name) + } + + /// Read a property's trimmed value from an already-parsed VEVENT. + /// + /// Value-only lookups skip the parameter-map build entirely; use + /// [`Self::prop_with_params`] for DTSTART / DTEND / RECURRENCE-ID + /// which need `VALUE=DATE` detection. + /// + /// Returns `None` when the property is missing or its value is + /// empty after trimming — the same rules the old per-property + /// full-parse extractors applied. + fn prop_value( + event: &ical::parser::ical::component::IcalEvent, + property_name: &str, + ) -> Option { let prop = event .properties - .into_iter() + .iter() .find(|p| p.name.eq_ignore_ascii_case(property_name))?; - let value = prop.value?; - if value.trim().is_empty() { + let trimmed = prop.value.as_deref()?.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_string()) + } + + /// Read a property's trimmed value AND parameter map from an + /// already-parsed VEVENT. The map is keyed by parameter name + /// (`"VALUE"`, `"TZID"`, `"CN"`, …) whose value is the list of + /// parameter values (parameters can be multi-valued — + /// `MEMBER="mailto:a@x","mailto:b@x"` — hence the `Vec` + /// per key). + fn prop_with_params( + event: &ical::parser::ical::component::IcalEvent, + property_name: &str, + ) -> Option<(String, std::collections::HashMap>)> { + let prop = event + .properties + .iter() + .find(|p| p.name.eq_ignore_ascii_case(property_name))?; + let trimmed = prop.value.as_deref()?.trim(); + if trimmed.is_empty() { return None; } let mut params: std::collections::HashMap> = std::collections::HashMap::new(); - if let Some(param_list) = prop.params { + if let Some(param_list) = &prop.params { for (name, values) in param_list { // RFC 5545 property parameter names are ASCII case-insensitive. // Normalise to UPPER so callers key on a canonical form. - params.insert(name.to_ascii_uppercase(), values); + params.insert(name.to_ascii_uppercase(), values.clone()); } } - Some((value.trim().to_string(), params)) + Some((trimmed.to_string(), params)) } /// Parse a VCALENDAR body containing one or more VEVENT components @@ -847,6 +900,18 @@ impl CalendarEvent { let mut in_event = false; let mut current = String::new(); + // Allocation-free case-insensitive prefix test. `to_ascii_uppercase` + // maps ASCII bytes in place and leaves multi-byte chars untouched, + // so "first N bytes uppercased equal TAG" ⇔ "first N bytes + // ASCII-case-insensitively equal TAG"; `get(..N)` returning `None` + // (char straddling the boundary) implies the prefix can't be the + // all-ASCII tag. The old per-line `to_ascii_uppercase()` allocated + // a String for every line of every uploaded body. + fn starts_with_ci(line: &str, tag: &str) -> bool { + line.get(..tag.len()) + .is_some_and(|p| p.eq_ignore_ascii_case(tag)) + } + for raw_line in ical_data.split('\n') { let line = raw_line.trim_end_matches('\r'); // Match the tag ignoring case, allowing surrounding @@ -854,9 +919,9 @@ impl CalendarEvent { // continuations — the raw-line scan sees those but they // won't start with BEGIN/END so they slot through as // in-event content, which is correct). - let upper = line.trim_start().to_ascii_uppercase(); + let tag_area = line.trim_start(); - if upper.starts_with("BEGIN:VEVENT") { + if starts_with_ci(tag_area, "BEGIN:VEVENT") { in_event = true; current.clear(); } @@ -866,7 +931,7 @@ impl CalendarEvent { current.push_str("\r\n"); } - if in_event && upper.starts_with("END:VEVENT") { + if in_event && starts_with_ci(tag_area, "END:VEVENT") { blocks.push(std::mem::take(&mut current)); in_event = false; } diff --git a/src/domain/entities/file.rs b/src/domain/entities/file.rs index 769528ee..1f5d9007 100644 --- a/src/domain/entities/file.rs +++ b/src/domain/entities/file.rs @@ -1,7 +1,7 @@ use uuid::Uuid; use crate::domain::services::path_service::{ - StoragePath, normalize_storage_name, validate_storage_name, + StoragePath, normalize_storage_name_owned, validate_storage_name, }; // Re-export entity errors from the centralized module @@ -122,7 +122,7 @@ impl File { mime_type: String, folder_id: Option, ) -> FileResult { - let name = normalize_storage_name(&name); + let name = normalize_storage_name_owned(name); if let Err(reason) = validate_storage_name(&name) { return Err(FileError::InvalidFileName(format!("{name}: {reason}"))); } @@ -133,7 +133,7 @@ impl File { .as_secs(); // Store the path string for serialization compatibility - let path_string = storage_path.to_string(); + let path_string = storage_path.to_path_string(); Ok(Self { id, @@ -160,13 +160,13 @@ impl File { created_at: u64, modified_at: u64, ) -> FileResult { - let name = normalize_storage_name(&name); + let name = normalize_storage_name_owned(name); if let Err(reason) = validate_storage_name(&name) { return Err(FileError::InvalidFileName(format!("{name}: {reason}"))); } // Store the path string for serialization compatibility - let path_string = storage_path.to_string(); + let path_string = storage_path.to_path_string(); Ok(Self { id, @@ -252,13 +252,64 @@ impl File { created_by: Option, updated_by: Option, ) -> FileResult { - let name = normalize_storage_name(&name); + let name = normalize_storage_name_owned(name); if let Err(reason) = validate_storage_name(&name) { return Err(FileError::InvalidFileName(format!("{name}: {reason}"))); } // Store the path string for serialization compatibility - let path_string = storage_path.to_string(); + let path_string = storage_path.to_path_string(); + + Ok(Self { + id, + name, + storage_path, + path_string, + size, + mime_type, + folder_id, + created_at, + modified_at, + blob_hash, + created_by, + updated_by, + }) + } + + /// PG-row constructor: the per-listing-row hot path. + /// + /// Builds `storage_path` **and** `path_string` in one pass from the + /// materialized folder path via + /// [`StoragePath::from_folder_and_name`], instead of the old chain + /// (`format!` temp → `from_string` split → `Display` re-join) that + /// allocated the full path three times per row. The owned `name` is + /// NFC-normalized without the always-copy of the borrowing variant + /// (DB rows are NFC by invariant, so this is a zero-alloc check). + /// + /// The path is built from the raw incoming name and the name field is + /// normalized afterwards — the exact observable sequence of the old + /// `make_file_path` + constructor pair, byte-identical for every + /// input (for DB rows the two names coincide: stored names are NFC). + #[allow(clippy::too_many_arguments)] + pub fn from_materialized_row( + id: String, + name: String, + folder_path: Option<&str>, + size: u64, + mime_type: String, + folder_id: Option, + created_at: u64, + modified_at: u64, + blob_hash: String, + created_by: Option, + updated_by: Option, + ) -> FileResult { + let (storage_path, path_string) = StoragePath::from_folder_and_name(folder_path, &name); + + let name = normalize_storage_name_owned(name); + if let Err(reason) = validate_storage_name(&name) { + return Err(FileError::InvalidFileName(format!("{name}: {reason}"))); + } Ok(Self { id, @@ -442,7 +493,7 @@ impl File { // Create directly without validation to avoid errors in DTO // conversions. Still NFC-normalize so even DTO-reconstructed // entities maintain the storage invariant. - let name = normalize_storage_name(&name); + let name = normalize_storage_name_owned(name); Self { id, @@ -466,7 +517,7 @@ impl File { /// Creates a new version of the file with updated name pub fn with_name(mut self, new_name: String) -> FileResult { - let new_name = normalize_storage_name(&new_name); + let new_name = normalize_storage_name_owned(new_name); if let Err(reason) = validate_storage_name(&new_name) { return Err(FileError::InvalidFileName(format!("{new_name}: {reason}"))); } @@ -485,7 +536,7 @@ impl File { // Consume `self` and mutate in place — only the path, name and mtime // change; id / mime_type / folder_id / blob_hash are carried over // without the per-field clone the old `&self` builder paid. - self.path_string = new_storage_path.to_string(); + self.path_string = new_storage_path.to_path_string(); self.storage_path = new_storage_path; self.name = new_name; self.modified_at = now; @@ -510,7 +561,7 @@ impl File { .as_secs(); // Consume `self`: only the path, folder_id and mtime change. - self.path_string = new_storage_path.to_string(); + self.path_string = new_storage_path.to_path_string(); self.storage_path = new_storage_path; self.folder_id = folder_id; self.modified_at = now; diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index 7b4d33bb..f8dfa365 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -1,7 +1,7 @@ use uuid::Uuid; use crate::domain::services::path_service::{ - StoragePath, normalize_storage_name, validate_storage_name, + StoragePath, normalize_storage_name_owned, validate_storage_name, }; // Re-export entity errors from the centralized module @@ -120,7 +120,7 @@ impl Folder { storage_path: StoragePath, parent_id: Option, ) -> FolderResult { - let name = normalize_storage_name(&name); + let name = normalize_storage_name_owned(name); if let Err(reason) = validate_storage_name(&name) { return Err(FolderError::InvalidFolderName(format!("{name}: {reason}"))); } @@ -130,7 +130,7 @@ impl Folder { .unwrap_or_default() .as_secs(); - let path_string = storage_path.to_string(); + let path_string = storage_path.to_path_string(); Ok(Self { id, @@ -221,12 +221,56 @@ impl Folder { created_by: Option, updated_by: Option, ) -> FolderResult { - let name = normalize_storage_name(&name); + let name = normalize_storage_name_owned(name); if let Err(reason) = validate_storage_name(&name) { return Err(FolderError::InvalidFolderName(format!("{name}: {reason}"))); } - let path_string = storage_path.to_string(); + let path_string = storage_path.to_path_string(); + + Ok(Self { + id, + name, + storage_path, + path_string, + parent_id, + drive_id, + created_at, + modified_at, + tree_modified_at, + created_by, + updated_by, + }) + } + + /// PG-row constructor: the per-listing-row hot path. + /// + /// Takes the materialized `storage.folders.path` column by value and + /// splits it once via [`StoragePath::from_joined`] — when the stored + /// path is already canonical (every row the repository writes), the + /// input `String` is reused as `path_string` with zero copies, + /// replacing the old `from_string` split + `Display` re-join pair. + /// The owned `name` is NFC-normalized without the always-copy of the + /// borrowing variant (DB rows are NFC by invariant). + #[allow(clippy::too_many_arguments)] + pub fn from_materialized_row( + id: String, + name: String, + path: String, + parent_id: Option, + drive_id: Uuid, + created_at: u64, + modified_at: u64, + tree_modified_at: u64, + created_by: Option, + updated_by: Option, + ) -> FolderResult { + let name = normalize_storage_name_owned(name); + if let Err(reason) = validate_storage_name(&name) { + return Err(FolderError::InvalidFolderName(format!("{name}: {reason}"))); + } + + let (storage_path, path_string) = StoragePath::from_joined(path); Ok(Self { id, @@ -411,7 +455,7 @@ impl Folder { // round-trips lose the real rollup signal, so callers that // need a freshly-rolled-up etag must reload from the // repository. - let name = normalize_storage_name(&name); + let name = normalize_storage_name_owned(name); Self { id, name, @@ -437,7 +481,7 @@ impl Folder { /// Creates a new version of the folder with updated name pub fn with_name(&self, new_name: String) -> FolderResult { - let new_name = normalize_storage_name(&new_name); + let new_name = normalize_storage_name_owned(new_name); if let Err(reason) = validate_storage_name(&new_name) { return Err(FolderError::InvalidFolderName(format!( "{new_name}: {reason}" @@ -452,7 +496,7 @@ impl Folder { }; // Update string representation - let new_path_string = new_storage_path.to_string(); + let new_path_string = new_storage_path.to_path_string(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -492,7 +536,7 @@ impl Folder { }; // Update string representation - let new_path_string = new_storage_path.to_string(); + let new_path_string = new_storage_path.to_path_string(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/src/domain/repositories/address_book_repository.rs b/src/domain/repositories/address_book_repository.rs index 6e86c9cb..ef16b67e 100644 --- a/src/domain/repositories/address_book_repository.rs +++ b/src/domain/repositories/address_book_repository.rs @@ -24,6 +24,14 @@ pub trait AddressBookRepository: Send + Sync + 'static { address_book: AddressBook, ) -> AddressBookRepositoryResult; async fn delete_address_book(&self, id: &Uuid) -> AddressBookRepositoryResult<()>; + /// Batch sibling of `get_address_book_by_id`: one `= ANY($1)` + /// round-trip for a page of grant-derived ids. Missing ids drop + /// out; ordering is not guaranteed. + async fn get_address_books_by_ids( + &self, + ids: &[Uuid], + ) -> AddressBookRepositoryResult>; + async fn get_address_book_by_id( &self, id: &Uuid, diff --git a/src/domain/repositories/calendar_repository.rs b/src/domain/repositories/calendar_repository.rs index 8719fbdb..798b09e2 100644 --- a/src/domain/repositories/calendar_repository.rs +++ b/src/domain/repositories/calendar_repository.rs @@ -25,6 +25,12 @@ pub trait CalendarRepository: Send + Sync + 'static { /// Finds a calendar by its ID async fn find_calendar_by_id(&self, id: &Uuid) -> CalendarRepositoryResult; + /// Batch sibling of [`Self::find_calendar_by_id`]: one `= ANY($1)` + /// round-trip for a page of grant-derived ids. Missing ids drop out + /// (no per-id NotFound), matching the listing carve-out for + /// deleted/trashed races. Ordering is not guaranteed. + async fn find_calendars_by_ids(&self, ids: &[Uuid]) -> CalendarRepositoryResult>; + /// Lists all calendars owned by a specific user. Post-Round-3 the /// service layer prefers `authz.list_incoming_grants` (surfaces /// owned + shared in one union), but this direct lookup remains diff --git a/src/domain/repositories/playlist_repository.rs b/src/domain/repositories/playlist_repository.rs index 186b633d..57a0eb40 100644 --- a/src/domain/repositories/playlist_repository.rs +++ b/src/domain/repositories/playlist_repository.rs @@ -13,6 +13,11 @@ pub trait PlaylistRepository: Send + Sync + 'static { async fn find_playlist_by_id(&self, id: &Uuid) -> PlaylistRepositoryResult; + /// Batch sibling of [`Self::find_playlist_by_id`]: one `= ANY($1)` + /// round-trip for a page of grant-derived ids. Missing ids drop + /// out; ordering is not guaranteed. + async fn find_playlists_by_ids(&self, ids: &[Uuid]) -> PlaylistRepositoryResult>; + async fn list_playlists_by_owner( &self, owner_id: Uuid, diff --git a/src/domain/services/path_service.rs b/src/domain/services/path_service.rs index aa2b0291..f30fef7a 100644 --- a/src/domain/services/path_service.rs +++ b/src/domain/services/path_service.rs @@ -40,6 +40,22 @@ pub fn normalize_storage_name(name: &str) -> String { name.nfc().collect() } +/// Owned-input sibling of [`normalize_storage_name`]. +/// +/// The borrowing variant must always allocate a fresh `String` even when +/// the input is already NFC — which is every name loaded back from +/// PostgreSQL (DB invariant) and every ASCII name. Callers that own the +/// `String` (entity constructors receive `name: String` by value) were +/// paying that copy only to drop the original immediately. This variant +/// returns the input unchanged on the fast path: zero allocations per +/// row on every listing (PROPFIND, photos timeline, search). +pub fn normalize_storage_name_owned(name: String) -> String { + if is_nfc_quick(name.chars()) == IsNormalized::Yes { + return name; + } + name.nfc().collect() +} + /// Validates a single file or folder name component. /// /// Returns `Err` with a human-readable reason if the name is rejected. @@ -102,6 +118,88 @@ impl StoragePath { Self { segments } } + /// One-pass builder for PG listing rows: materialized folder path + + /// file name → `(StoragePath, path_string)`. + /// + /// Replaces the old per-row chain + /// `StoragePath::from_string(&format!("{fp}/{name}"))` + + /// `storage_path.to_string()`, which allocated a joined temporary, + /// split it back into per-segment `String`s, and then re-joined those + /// segments (via `join` + `write!`) into the `path_string` the DTOs + /// actually serve. Here both representations are built in a single + /// pass with exactly one `String` for the joined form and no + /// intermediate temporaries. + /// + /// Byte-equivalence with the old chain holds because concatenating + /// with a `/` separator distributes over `split('/')`: + /// `(fp + "/" + name).split('/') == fp.split('/') ⧺ name.split('/')`, + /// and the joined form is exactly `Display`'s `/`-prefixed rendering + /// of the surviving segments (root renders as `"/"`). + pub fn from_folder_and_name(folder_path: Option<&str>, file_name: &str) -> (Self, String) { + let fp = folder_path.unwrap_or(""); + // Upper bounds: every byte of both inputs survives at most once, + // plus one leading '/' per segment (≤ segment count) — sizing to + // input length + 2 covers the worst case without a second scan. + let mut joined = String::with_capacity(fp.len() + file_name.len() + 2); + let mut segments: Vec = + Vec::with_capacity(fp.bytes().filter(|&b| b == b'/').count() + 2); + for seg in fp + .split('/') + .chain(file_name.split('/')) + .filter(|s| Self::is_safe_segment(s)) + { + joined.push('/'); + joined.push_str(seg); + segments.push(seg.to_string()); + } + if segments.is_empty() { + joined.push('/'); + } + (Self { segments }, joined) + } + + /// One-pass splitter for a pre-joined materialized path (the + /// `storage.folders.path` column) → `(StoragePath, path_string)`. + /// + /// When the input is already in canonical joined form (leading `/`, + /// no empty/`.`/`..` segments, no trailing `/`) — which is every row + /// the repository writes — the input `String` is reused as the + /// `path_string` with zero copies. Non-canonical inputs fall back to + /// the filtering rebuild and produce exactly what + /// `from_string(&path).to_string()` used to. + pub fn from_joined(path: String) -> (Self, String) { + if Self::is_canonical_joined(&path) { + let segments: Vec = if path.len() == 1 { + Vec::new() + } else { + path[1..].split('/').map(str::to_string).collect() + }; + return (Self { segments }, path); + } + // Fallback: identical to the old from_string + to_string pair. + let segments: Vec = path + .split('/') + .filter(|s| Self::is_safe_segment(s)) + .map(str::to_string) + .collect(); + let sp = Self { segments }; + let joined = sp.to_path_string(); + (sp, joined) + } + + /// `true` when `path` is exactly `Display`'s canonical rendering of + /// its own segments: `"/"` alone, or `/seg(/seg)*` where every + /// segment is safe. One scan, no allocations. + fn is_canonical_joined(path: &str) -> bool { + if path == "/" { + return true; + } + if !path.starts_with('/') || path.ends_with('/') { + return false; + } + path[1..].split('/').all(Self::is_safe_segment) + } + /// Creates a path from a PathBuf pub fn from(path_buf: PathBuf) -> Self { let segments = path_buf @@ -152,14 +250,40 @@ impl StoragePath { impl std::fmt::Display for StoragePath { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.segments.is_empty() { - write!(f, "/") - } else { - write!(f, "/{}", self.segments.join("/")) + return f.write_str("/"); } + // Write segments directly — the old `self.segments.join("/")` + // allocated a full joined temporary inside every `format!`/ + // `to_string` of a path. + for seg in &self.segments { + f.write_str("/")?; + f.write_str(seg)?; + } + Ok(()) } } impl StoragePath { + /// The canonical joined form (`Display`'s output) in exactly one + /// pre-sized allocation. + /// + /// `to_string()` routes through `Display` into an unsized `String` + /// that grows geometrically (multiple reallocs + copies for typical + /// path lengths). Entity constructors call this once per row on + /// every listing, so the sized single-alloc variant is the default + /// there. + pub fn to_path_string(&self) -> String { + if self.segments.is_empty() { + return "/".to_string(); + } + let mut s = String::with_capacity(self.segments.iter().map(|seg| seg.len() + 1).sum()); + for seg in &self.segments { + s.push('/'); + s.push_str(seg); + } + s + } + /// Returns the path representation as a string pub fn as_str(&self) -> &str { // Note: The implementation should really store the string, diff --git a/src/infrastructure/adapters/calendar_storage_adapter.rs b/src/infrastructure/adapters/calendar_storage_adapter.rs index 42552539..4bea1082 100644 --- a/src/infrastructure/adapters/calendar_storage_adapter.rs +++ b/src/infrastructure/adapters/calendar_storage_adapter.rs @@ -115,6 +115,11 @@ impl CalendarStoragePort for CalendarStorageAdapter { Ok(CalendarDto::from(calendar)) } + async fn get_calendars_by_ids(&self, ids: &[Uuid]) -> Result, DomainError> { + let calendars = self.calendar_repository.find_calendars_by_ids(ids).await?; + Ok(calendars.into_iter().map(CalendarDto::from).collect()) + } + async fn list_calendars_by_owner( &self, owner_id: Uuid, diff --git a/src/infrastructure/adapters/contact_storage_adapter.rs b/src/infrastructure/adapters/contact_storage_adapter.rs index 5617bb6d..5af4dd89 100644 --- a/src/infrastructure/adapters/contact_storage_adapter.rs +++ b/src/infrastructure/adapters/contact_storage_adapter.rs @@ -84,6 +84,15 @@ impl ContactStoragePort for ContactStorageAdapter { .await } + async fn get_address_books_by_ids( + &self, + ids: &[Uuid], + ) -> Result, DomainError> { + self.address_book_repository + .get_address_books_by_ids(ids) + .await + } + async fn get_public_address_books(&self) -> Result, DomainError> { self.address_book_repository .get_public_address_books() diff --git a/src/infrastructure/adapters/music_storage_adapter.rs b/src/infrastructure/adapters/music_storage_adapter.rs index 00df57c3..f8720e17 100644 --- a/src/infrastructure/adapters/music_storage_adapter.rs +++ b/src/infrastructure/adapters/music_storage_adapter.rs @@ -95,6 +95,11 @@ impl MusicStoragePort for MusicStorageAdapter { } } + async fn get_playlists_by_ids(&self, ids: &[Uuid]) -> Result, DomainError> { + let playlists = self.playlist_repository.find_playlists_by_ids(ids).await?; + Ok(playlists.into_iter().map(PlaylistDto::from).collect()) + } + async fn list_playlists_by_owner( &self, owner_id: Uuid, diff --git a/src/infrastructure/repositories/pg/address_book_pg_repository.rs b/src/infrastructure/repositories/pg/address_book_pg_repository.rs index 8ea91ca4..ddb16449 100644 --- a/src/infrastructure/repositories/pg/address_book_pg_repository.rs +++ b/src/infrastructure/repositories/pg/address_book_pg_repository.rs @@ -110,6 +110,45 @@ impl AddressBookRepository for AddressBookPgRepository { Ok(()) } + async fn get_address_books_by_ids( + &self, + ids: &[Uuid], + ) -> AddressBookRepositoryResult> { + if ids.is_empty() { + return Ok(Vec::new()); + } + let rows = sqlx::query( + r#" + SELECT id, name, owner_id, description, color, is_public, created_at, updated_at + FROM carddav.address_books + WHERE id = ANY($1) + "#, + ) + .bind(ids) + .fetch_all(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to get address books by ids: {}", e)) + })?; + + Ok(rows + .iter() + .map(|row| { + let owner_id: Uuid = row.get("owner_id"); + AddressBook::from_raw( + row.get("id"), + row.get("name"), + owner_id.to_string(), + row.get("description"), + row.get("color"), + row.get("is_public"), + row.get("created_at"), + row.get("updated_at"), + ) + }) + .collect()) + } + async fn get_address_book_by_id( &self, id: &Uuid, diff --git a/src/infrastructure/repositories/pg/calendar_pg_repository.rs b/src/infrastructure/repositories/pg/calendar_pg_repository.rs index 8eae0ce1..ac68d7f5 100644 --- a/src/infrastructure/repositories/pg/calendar_pg_repository.rs +++ b/src/infrastructure/repositories/pg/calendar_pg_repository.rs @@ -138,6 +138,42 @@ impl CalendarRepository for CalendarPgRepository { Ok(calendar) } + async fn find_calendars_by_ids(&self, ids: &[Uuid]) -> CalendarRepositoryResult> { + if ids.is_empty() { + return Ok(Vec::new()); + } + let rows = sqlx::query( + r#" + SELECT id, name, owner_id, description, color, is_public, created_at, updated_at + FROM caldav.calendars + WHERE id = ANY($1) + "#, + ) + .bind(ids) + .fetch_all(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to get calendars by ids: {}", e)) + })?; + + rows.iter() + .map(|row| { + Calendar::with_id( + row.get("id"), + row.get("name"), + row.get("owner_id"), + row.get("description"), + row.get("color"), + row.get("created_at"), + row.get("updated_at"), + ) + .map_err(|e| { + DomainError::database_error(format!("Failed to create calendar object: {}", e)) + }) + }) + .collect() + } + async fn list_calendars_by_owner( &self, owner_id: Uuid, diff --git a/src/infrastructure/repositories/pg/drive_pg_repository.rs b/src/infrastructure/repositories/pg/drive_pg_repository.rs index 89e9b494..4b007d1f 100644 --- a/src/infrastructure/repositories/pg/drive_pg_repository.rs +++ b/src/infrastructure/repositories/pg/drive_pg_repository.rs @@ -41,6 +41,27 @@ pub struct DrivePgRepository { /// provisioning idempotency check (`NotFound` → create) always sees /// the live table. default_drive_cache: Cache, + /// caller_id → every drive the caller can read (the full + /// role_grants ⋈ drives ⋈ folders join of [`list_readable_by`], + /// including the transitive-group expansion). + /// + /// Re-resolved before this cache existed on EVERY native `/webdav` + /// request that names an explicit drive selector (all verbs; MOVE + /// and COPY twice), plus per-request in search, trash listing and + /// the `GET /api/drives` picker — the heaviest per-request query + /// left on the DAV path after CHROOT-CACHE. Concurrent misses are + /// coalesced (`try_get_with`), errors are never cached. + /// + /// Freshness: every membership/lifecycle mutation that flows + /// through this repository or `DriveManagementService` invalidates + /// explicitly (per-user when the subject is a User, whole cache for + /// Group subjects, whose transitive membership is not resolvable + /// here). Residual staleness — a root-folder rename or a grant + /// written by a path that can't reach this cache — is bounded by + /// the same 30 s TTL the sibling caches accept; actual permission + /// enforcement is unaffected (the ACL engine re-checks per + /// operation with its own invalidation). + readable_cache: Cache>>, } impl DrivePgRepository { @@ -51,9 +72,27 @@ impl DrivePgRepository { .max_capacity(DEFAULT_DRIVE_CACHE_CAPACITY) .time_to_live(DEFAULT_DRIVE_CACHE_TTL) .build(), + readable_cache: Cache::builder() + .max_capacity(DEFAULT_DRIVE_CACHE_CAPACITY) + .time_to_live(DEFAULT_DRIVE_CACHE_TTL) + .build(), } } + /// Drop the cached readable-drive list for one user (their grant set + /// changed: membership write, personal-drive provisioning, …). + pub async fn invalidate_readable_for_user(&self, user_id: Uuid) { + self.readable_cache.invalidate(&user_id).await; + } + + /// Drop every cached readable-drive list. Used when the affected + /// user set is unknown at this layer: group-subject grants, drive + /// deletion, policy edits. All are admin-rare; repopulation costs + /// one join per active caller. + pub fn invalidate_readable_all(&self) { + self.readable_cache.invalidate_all(); + } + fn map_sqlx_err(context: &'static str, e: sqlx::Error) -> DriveRepositoryError { if let sqlx::Error::Database(ref dberr) = e && let Some(code) = dberr.code() @@ -112,6 +151,63 @@ impl DrivePgRepository { dwr.caller_role = role_str.as_deref().and_then(Role::parse); Ok(dwr) } + + /// The uncached grants join behind [`DriveRepository::list_readable_by`]. + /// + /// Joining role_grants → drives → folders returns every drive the + /// caller can read, paired with its display name. Group + /// memberships (direct + transitive) are expanded inline by + /// `storage.caller_group_ids($caller)` — no Rust-side ceremony. + /// + /// ORDER BY puts default drives first (so the picker UI doesn't + /// need a follow-up sort), then alphabetical by name. GROUP BY + /// collapses duplicate role_grants on the same drive (direct + + /// group-mediated) and sidesteps PostgreSQL's "ORDER BY + /// expression must appear in select list" rule that SELECT + /// DISTINCT imposes. + /// `MIN(g.role)` picks the caller's strongest role on each drive: + /// `storage.grant_role` is declared `owner → viewer` (strongest → + /// weakest), so MIN returns the strongest. Cast `::text` matches + /// the codebase convention for reading enum columns into Rust + /// (see `pg_acl_engine.rs`); `Role::parse` handles the trip back. + async fn query_readable_by( + &self, + caller_id: Uuid, + ) -> Result, DriveRepositoryError> { + let rows = sqlx::query( + r#" + SELECT d.id, d.kind, d.default_for_user, d.root_folder_id, + d.quota_bytes, d.used_bytes, d.policies, + d.created_at, d.updated_at, + f.name AS root_folder_name, + MIN(g.role)::text AS caller_role + FROM storage.drives d + JOIN storage.folders f ON f.id = d.root_folder_id + JOIN storage.role_grants g + ON g.resource_type = 'drive' + AND g.resource_id = d.id + WHERE ( + (g.subject_type = 'user' AND g.subject_id = $1) + OR (g.subject_type = 'group' AND g.subject_id IN + (SELECT storage.caller_group_ids($1))) + ) + AND (g.expires_at IS NULL OR g.expires_at > NOW()) + GROUP BY d.id, d.kind, d.default_for_user, d.root_folder_id, + d.quota_bytes, d.used_bytes, d.policies, + d.created_at, d.updated_at, f.name + ORDER BY (d.default_for_user IS NULL) ASC, + LOWER(f.name) ASC + "#, + ) + .bind(caller_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("list_readable_by", e))?; + + rows.iter() + .map(Self::row_to_drive_with_name_and_role) + .collect() + } } #[async_trait::async_trait] @@ -239,6 +335,8 @@ impl DriveRepository for DrivePgRepository { // Drop any cached default-drive resolution for this user (a stale // NotFound is never cached, but be explicit about the write path). self.default_drive_cache.invalidate(&owner_id).await; + // The owner gained a drive — their readable list changed too. + self.invalidate_readable_for_user(owner_id).await; Self::row_to_drive_with_name(&row) } @@ -347,6 +445,16 @@ impl DriveRepository for DrivePgRepository { .await .map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.commit", e))?; + // The owner grant written above changes the grantee's readable + // list. User subjects invalidate precisely; Group subjects fall + // back to a full clear (transitive members unknown here). + match owner_subject { + crate::domain::services::authorization::Subject::User(uid) => { + self.invalidate_readable_for_user(uid).await; + } + _ => self.invalidate_readable_all(), + } + Self::row_to_drive_with_name(&row) } @@ -425,10 +533,11 @@ impl DriveRepository for DrivePgRepository { tx.commit() .await .map_err(|e| Self::map_sqlx_err("delete_atomic.commit", e))?; - // We only have the drive id here; the cache is keyed by user. - // Deletion is rare — clearing the whole cache is the simple, + // We only have the drive id here; the caches are keyed by user. + // Deletion is rare — clearing them whole is the simple, // always-correct move (repopulates at one query per active user). self.default_drive_cache.invalidate_all(); + self.invalidate_readable_all(); Ok(()) } @@ -513,55 +622,21 @@ impl DriveRepository for DrivePgRepository { &self, caller_id: Uuid, ) -> Result, DriveRepositoryError> { - // Joining role_grants → drives → folders returns every drive the - // caller can read, paired with its display name. Group - // memberships (direct + transitive) are expanded inline by - // `storage.caller_group_ids($caller)` — no Rust-side ceremony. - // - // ORDER BY puts default drives first (so the picker UI doesn't - // need a follow-up sort), then alphabetical by name. GROUP BY - // collapses duplicate role_grants on the same drive (direct + - // group-mediated) and sidesteps PostgreSQL's "ORDER BY - // expression must appear in select list" rule that SELECT - // DISTINCT imposes. - // `MIN(g.role)` picks the caller's strongest role on each drive: - // `storage.grant_role` is declared `owner → viewer` (strongest → - // weakest), so MIN returns the strongest. Cast `::text` matches - // the codebase convention for reading enum columns into Rust - // (see `pg_acl_engine.rs`); `Role::parse` handles the trip back. - let rows = sqlx::query( - r#" - SELECT d.id, d.kind, d.default_for_user, d.root_folder_id, - d.quota_bytes, d.used_bytes, d.policies, - d.created_at, d.updated_at, - f.name AS root_folder_name, - MIN(g.role)::text AS caller_role - FROM storage.drives d - JOIN storage.folders f ON f.id = d.root_folder_id - JOIN storage.role_grants g - ON g.resource_type = 'drive' - AND g.resource_id = d.id - WHERE ( - (g.subject_type = 'user' AND g.subject_id = $1) - OR (g.subject_type = 'group' AND g.subject_id IN - (SELECT storage.caller_group_ids($1))) - ) - AND (g.expires_at IS NULL OR g.expires_at > NOW()) - GROUP BY d.id, d.kind, d.default_for_user, d.root_folder_id, - d.quota_bytes, d.used_bytes, d.policies, - d.created_at, d.updated_at, f.name - ORDER BY (d.default_for_user IS NULL) ASC, - LOWER(f.name) ASC - "#, - ) - .bind(caller_id) - .fetch_all(self.pool.as_ref()) - .await - .map_err(|e| Self::map_sqlx_err("list_readable_by", e))?; - - rows.iter() - .map(Self::row_to_drive_with_name_and_role) - .collect() + // Serve from the per-user cache; concurrent misses for the same + // caller are coalesced into one join (`try_get_with`), and errors + // are never cached. See the `readable_cache` field docs for the + // freshness/invalidation contract. + let cached = self + .readable_cache + .try_get_with(caller_id, async move { + self.query_readable_by(caller_id).await.map(Arc::new) + }) + .await + .map_err(|e: Arc| { + Arc::try_unwrap(e) + .unwrap_or_else(|shared| DriveRepositoryError::StorageError(shared.to_string())) + })?; + Ok((*cached).clone()) } async fn list_all(&self) -> Result, DriveRepositoryError> { @@ -717,9 +792,10 @@ impl DriveRepository for DrivePgRepository { .ok_or_else(|| DriveRepositoryError::NotFound(drive_id.to_string()))? .0; // Policy edits must not serve a stale `policies` bag from the - // default-drive cache (keyed by user, and we only have the drive - // id) — clear it; policy edits are admin-rare. + // user-keyed caches (we only have the drive id) — clear both; + // policy edits are admin-rare. self.default_drive_cache.invalidate_all(); + self.invalidate_readable_all(); Ok(crate::domain::entities::drive::DrivePolicies::from_value( &raw, )) diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 7254b146..5abf1c5c 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -413,14 +413,6 @@ impl FileBlobReadRepository { } } - /// Build a `StoragePath` from the materialized folder path + file name. - fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath { - match folder_path { - Some(fp) if !fp.is_empty() => StoragePath::from_string(&format!("{fp}/{file_name}")), - _ => StoragePath::from_string(file_name), - } - } - #[allow(clippy::too_many_arguments)] fn row_to_file( id: String, @@ -435,11 +427,10 @@ impl FileBlobReadRepository { created_by: Option, updated_by: Option, ) -> Result { - let storage_path = Self::make_file_path(folder_path.as_deref(), &name); - File::with_timestamps_blob_hash_and_provenance( + File::from_materialized_row( id, name, - storage_path, + folder_path.as_deref(), size as u64, mime_type, folder_id, @@ -930,7 +921,7 @@ impl FileReadPort for FileBlobReadRepository { .map_err(|e| DomainError::internal_error("FileBlobRead", format!("path: {e}")))? .ok_or_else(|| DomainError::not_found("File", id))?; - Ok(Self::make_file_path(row.1.as_deref(), &row.0)) + Ok(StoragePath::from_folder_and_name(row.1.as_deref(), &row.0).0) } async fn get_parent_folder_id( diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index e77be731..1d6af3d7 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -17,7 +17,6 @@ use crate::application::dtos::display_helpers::category_order_for; use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort}; use crate::common::errors::DomainError; use crate::domain::entities::file::File; -use crate::domain::services::path_service::StoragePath; use super::transaction_utils::retry_on_deadlock; use crate::infrastructure::services::dedup_service::DedupService; @@ -61,14 +60,6 @@ impl FileBlobWriteRepository { } } - /// Build a `StoragePath` from the materialized folder path + file name. - fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath { - match folder_path { - Some(fp) if !fp.is_empty() => StoragePath::from_string(&format!("{fp}/{file_name}")), - _ => StoragePath::from_string(file_name), - } - } - /// Look up the materialized folder path. O(1) — no recursive CTE. async fn lookup_folder_path( &self, @@ -108,11 +99,10 @@ impl FileBlobWriteRepository { created_by: Option, updated_by: Option, ) -> Result { - let storage_path = Self::make_file_path(folder_path.as_deref(), &name); - File::with_timestamps_blob_hash_and_provenance( + File::from_materialized_row( id, name, - storage_path, + folder_path.as_deref(), size as u64, mime_type, folder_id, diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 63656d97..3421b9dc 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -142,11 +142,10 @@ impl FolderDbRepository { created_by: Option, updated_by: Option, ) -> Result { - let storage_path = StoragePath::from_string(&path); - Folder::with_timestamps_tree_and_provenance( + Folder::from_materialized_row( id, name, - storage_path, + path, parent_id, drive_id, created_at as u64, diff --git a/src/infrastructure/repositories/pg/playlist_pg_repository.rs b/src/infrastructure/repositories/pg/playlist_pg_repository.rs index 8f3f880c..c8550757 100644 --- a/src/infrastructure/repositories/pg/playlist_pg_repository.rs +++ b/src/infrastructure/repositories/pg/playlist_pg_repository.rs @@ -180,6 +180,35 @@ impl PlaylistRepository for PlaylistPgRepository { .map_err(|e| DomainError::new(ErrorKind::InternalError, "Playlist", e.to_string())) } + async fn find_playlists_by_ids(&self, ids: &[Uuid]) -> PlaylistRepositoryResult> { + if ids.is_empty() { + return Ok(Vec::new()); + } + let rows = sqlx::query_as::<_, PlaylistRow>( + "SELECT id, name, description, owner_id, is_public, cover_file_id, created_at, updated_at FROM audio.playlists WHERE id = ANY($1)", + ) + .bind(ids) + .fetch_all(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to find playlists: {}", e)))?; + + rows.into_iter() + .map(|row| { + Playlist::with_id( + row.id, + row.name, + row.description, + row.owner_id, + row.is_public, + row.cover_file_id, + row.created_at, + row.updated_at, + ) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Playlist", e.to_string())) + }) + .collect() + } + async fn list_playlists_by_owner( &self, owner_id: Uuid, diff --git a/src/infrastructure/services/azure_blob_backend.rs b/src/infrastructure/services/azure_blob_backend.rs index 19f7c53c..353a5aa3 100644 --- a/src/infrastructure/services/azure_blob_backend.rs +++ b/src/infrastructure/services/azure_blob_backend.rs @@ -9,7 +9,7 @@ use std::pin::Pin; use azure_storage::StorageCredentials; use azure_storage_blobs::prelude::*; use bytes::Bytes; -use futures::StreamExt; +use futures::{StreamExt, TryStreamExt}; use tokio::fs; use crate::application::ports::blob_storage_ports::{ @@ -33,8 +33,21 @@ impl AzureBlobBackend { StorageCredentials::access_key(&config.account_name, config.account_key.clone()) }; - let container_client = ClientBuilder::new(&config.account_name, credentials) - .container_client(&config.container); + // Custom endpoint (Azurite emulator / private deployment / + // benches) mirrors S3's `endpoint_url`; default is the public + // cloud URL derived from the account name. + let container_client = match &config.endpoint_url { + Some(uri) => ClientBuilder::with_location( + azure_storage::CloudLocation::Custom { + account: config.account_name.clone(), + uri: uri.trim_end_matches('/').to_string(), + }, + credentials, + ) + .container_client(&config.container), + None => ClientBuilder::new(&config.account_name, credentials) + .container_client(&config.container), + }; Self { container_client, @@ -169,29 +182,46 @@ impl BlobStorageBackend for AzureBlobBackend { Box::pin(async move { let client = self.blob_client(&hash); - let mut result_data: Vec = Vec::new(); - let mut stream = client.get().into_stream(); - - while let Some(response) = stream.next().await { - let response = response.map_err(|e| { - DomainError::new( + // The old implementation drained the ENTIRE blob into one + // `Vec` before yielding a single mega-chunk — whole-blob + // RAM residency per reader, and with `read_prefetch() = 8` + // up to 8 entire chunk-blobs resident at once during CDC + // reassembly. Now the SDK's page/body streams forward + // directly. The FIRST page is still awaited eagerly so a + // missing blob surfaces as the same up-front NotFound the + // old code produced; later pages/chunks map to io::Error + // items like every other backend's stream. + let mut pages = client.get().into_stream(); + let first = match pages.next().await { + Some(Ok(response)) => response, + Some(Err(e)) => { + return Err(DomainError::new( ErrorKind::NotFound, "Azure", format!("Failed to get blob {hash}: {e}"), - ) - })?; - let mut body = response.data; - while let Some(chunk) = body.next().await { - let chunk = chunk.map_err(|e| { - DomainError::internal_error("Azure", format!("Stream read error: {e}")) - })?; - result_data.extend_from_slice(&chunk); + )); } - } + None => { + let empty: BlobStream = + Box::pin(futures::stream::once(async move { Ok(Bytes::new()) })); + return Ok(empty); + } + }; - let stream: BlobStream = Box::pin(futures::stream::once(async move { - Ok(Bytes::from(result_data)) - })); + let first_body = first.data.map(|chunk| { + chunk.map_err(|e| std::io::Error::other(format!("Stream read error: {e}"))) + }); + let tail = pages + .map(|page| match page { + Ok(response) => Ok(response.data.map(|chunk| { + chunk.map_err(|e| std::io::Error::other(format!("Stream read error: {e}"))) + })), + Err(e) => Err(std::io::Error::other(format!( + "Failed to get blob page: {e}" + ))), + }) + .try_flatten(); + let stream: BlobStream = Box::pin(first_body.chain(tail)); Ok(stream) }) } @@ -212,32 +242,42 @@ impl BlobStorageBackend for AzureBlobBackend { None => azure_core::request_options::Range::new(start, u64::MAX), }; - let mut result_data: Vec = Vec::new(); - let mut stream = client.get().range(range).into_stream(); - - while let Some(response) = stream.next().await { - let response = response.map_err(|e| { - DomainError::new( + // Same forwarding shape as `get_blob_stream` — a ranged read + // doubly so: the caller explicitly asked NOT to pay for the + // whole blob, yet the old code buffered the full range. + let mut pages = client.get().range(range).into_stream(); + let first = match pages.next().await { + Some(Ok(response)) => response, + Some(Err(e)) => { + return Err(DomainError::new( ErrorKind::NotFound, "Azure", format!("Failed to get blob range {hash}: {e}"), - ) - })?; - let mut body = response.data; - while let Some(chunk) = body.next().await { - let chunk = chunk.map_err(|e| { - DomainError::internal_error( - "Azure", - format!("Stream range read error: {e}"), - ) - })?; - result_data.extend_from_slice(&chunk); + )); } - } + None => { + let empty: BlobStream = + Box::pin(futures::stream::once(async move { Ok(Bytes::new()) })); + return Ok(empty); + } + }; - let stream: BlobStream = Box::pin(futures::stream::once(async move { - Ok(Bytes::from(result_data)) - })); + let first_body = first.data.map(|chunk| { + chunk.map_err(|e| std::io::Error::other(format!("Stream range read error: {e}"))) + }); + let tail = pages + .map(|page| match page { + Ok(response) => Ok(response.data.map(|chunk| { + chunk.map_err(|e| { + std::io::Error::other(format!("Stream range read error: {e}")) + }) + })), + Err(e) => Err(std::io::Error::other(format!( + "Failed to get blob range page: {e}" + ))), + }) + .try_flatten(); + let stream: BlobStream = Box::pin(first_body.chain(tail)); Ok(stream) }) } diff --git a/src/infrastructure/services/face_indexing_service.rs b/src/infrastructure/services/face_indexing_service.rs index 1ec0705b..b1700aad 100644 --- a/src/infrastructure/services/face_indexing_service.rs +++ b/src/infrastructure/services/face_indexing_service.rs @@ -28,11 +28,35 @@ fn is_image(content_type: &str) -> bool { content_type.starts_with("image/") } +/// Concurrent index-task budget. Env override +/// `OXICLOUD_FACES_INDEX_CONCURRENCY`, else the effective core count — +/// each task is a full-image read + decode + ONNX inference, so more +/// permits than cores only adds RAM pressure, not throughput. +fn max_concurrent_index() -> usize { + std::env::var("OXICLOUD_FACES_INDEX_CONCURRENCY") + .ok() + .and_then(|v| v.parse().ok()) + .filter(|&n: &usize| n > 0) + .unwrap_or_else(|| { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(2) + }) +} + pub struct FaceIndexingService { pool: Arc, repo: Arc, analyzer: Arc, blob_root: PathBuf, + /// Bounds concurrent indexing tasks. The lifecycle hooks spawn one + /// task per uploaded/copied image with no ceiling, so a bulk upload + /// used to fan out N simultaneous full-image reads + decodes + + /// inferences — peak RSS N × image size plus CPU thrash. Same + /// invariant as `ThumbnailService::decode_semaphore`: the permit is + /// acquired BEFORE the blob read, so peak memory is + /// `permits × image size` regardless of upload concurrency. + index_semaphore: Arc, } impl FaceIndexingService { @@ -43,6 +67,7 @@ impl FaceIndexingService { repo, analyzer, blob_root, + index_semaphore: Arc::new(tokio::sync::Semaphore::new(max_concurrent_index())), } } @@ -60,7 +85,15 @@ impl FaceIndexingService { let repo = self.repo.clone(); let analyzer = self.analyzer.clone(); let blob_path = self.blob_path(&blob_hash); + let semaphore = self.index_semaphore.clone(); tokio::spawn(async move { + // Queue behind the concurrency budget BEFORE touching the + // blob — excess tasks wait holding only this tiny future, + // not a decoded image. + let _permit = semaphore + .acquire_owned() + .await + .expect("face index semaphore never closes"); if delete_first { let _ = repo.delete_faces_for_file(file_id).await; } diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index c977111f..9813866f 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -1700,21 +1700,24 @@ pub fn write_folder_response( write_text_element(xml, "d:displayname", &folder.name)?; - let created_at = - chrono::DateTime::::from_timestamp(timestamp_to_i64(folder.created_at), 0) - .unwrap_or_else(Utc::now); - let modified_at = - chrono::DateTime::::from_timestamp(timestamp_to_i64(folder.modified_at), 0) - .unwrap_or_else(Utc::now); - - write_text_element(xml, "d:getlastmodified", &modified_at.to_rfc2822())?; + write_date_element( + xml, + "d:getlastmodified", + timestamp_to_i64(folder.modified_at), + true, + )?; // Route through `FolderDto::etag` (= `Folder::etag()`: the // descendant-aware `{id[..16]}-{tree_modified_at}` — see the // entity for the formula and the async-bump freshness contract). - write_text_element(xml, "d:getetag", &format!("\"{}\"", folder.etag))?; + write_etag_element(xml, "d:getetag", &folder.etag)?; write_text_element(xml, "d:getcontenttype", "httpd/unix-directory")?; write_text_element(xml, "d:getcontentlength", "0")?; - write_text_element(xml, "d:creationdate", &created_at.to_rfc3339())?; + write_date_element( + xml, + "d:creationdate", + timestamp_to_i64(folder.created_at), + false, + )?; // Nextcloud/ownCloud properties if let Some(id) = file_id { @@ -1795,17 +1798,28 @@ pub fn write_file_response( write_text_element(xml, "d:displayname", &file.name)?; write_text_element(xml, "d:getcontenttype", &file.mime_type)?; - write_text_element(xml, "d:getcontentlength", &file.size.to_string())?; + { + let mut buf = [0u8; 20]; + write_text_element( + xml, + "d:getcontentlength", + crate::common::fmt::u64_str(&mut buf, file.size), + )?; + } - let created_at = chrono::DateTime::::from_timestamp(timestamp_to_i64(file.created_at), 0) - .unwrap_or_else(Utc::now); - let modified_at = - chrono::DateTime::::from_timestamp(timestamp_to_i64(file.modified_at), 0) - .unwrap_or_else(Utc::now); - - write_text_element(xml, "d:getlastmodified", &modified_at.to_rfc2822())?; - write_text_element(xml, "d:getetag", &format!("\"{}\"", file.etag))?; - write_text_element(xml, "d:creationdate", &created_at.to_rfc3339())?; + write_date_element( + xml, + "d:getlastmodified", + timestamp_to_i64(file.modified_at), + true, + )?; + write_etag_element(xml, "d:getetag", &file.etag)?; + write_date_element( + xml, + "d:creationdate", + timestamp_to_i64(file.created_at), + false, + )?; // Nextcloud/ownCloud properties if let Some(id) = file_id { @@ -1817,7 +1831,14 @@ pub fn write_file_response( write_text_element(xml, "oc:permissions", "RGDNVW")?; // Numeric share-permissions bitmask: Read=1 + Update=2 + Delete=8 + Share=16 = 27 write_text_element(xml, "ocs:share-permissions", "27")?; - write_text_element(xml, "oc:size", &file.size.to_string())?; + { + let mut buf = [0u8; 20]; + write_text_element( + xml, + "oc:size", + crate::common::fmt::u64_str(&mut buf, file.size), + )?; + } write_text_element(xml, "oc:owner-id", owner)?; write_text_element(xml, "oc:owner-display-name", owner)?; @@ -1861,6 +1882,47 @@ pub fn write_file_response( Ok(()) } +/// Stack-rendered `d:getlastmodified` / `d:creationdate` bodies +/// (`common::fmt`) — the old per-row `to_rfc2822()` / `to_rfc3339()` +/// ran chrono's format interpreter and allocated a String each. +/// Out-of-range timestamps keep the chrono path, byte-identical. +fn write_date_element( + xml: &mut Writer, + tag: &str, + secs: i64, + rfc2822: bool, +) -> Result<(), String> { + if rfc2822 { + let mut buf = [0u8; 31]; + if let Some(s) = crate::common::fmt::rfc2822_utc(&mut buf, secs) { + return write_text_element(xml, tag, s); + } + let dt = chrono::DateTime::::from_timestamp(secs, 0).unwrap_or_else(Utc::now); + write_text_element(xml, tag, &dt.to_rfc2822()) + } else { + let mut buf = [0u8; 25]; + if let Some(s) = crate::common::fmt::rfc3339_utc(&mut buf, secs) { + return write_text_element(xml, tag, s); + } + let dt = chrono::DateTime::::from_timestamp(secs, 0).unwrap_or_else(Utc::now); + write_text_element(xml, tag, &dt.to_rfc3339()) + } +} + +/// `d:getetag` with the HTTP quoting — one exactly-sized allocation +/// instead of `format!`'s grow-from-empty. +fn write_etag_element( + xml: &mut Writer, + tag: &str, + etag: &str, +) -> Result<(), String> { + let mut quoted = String::with_capacity(etag.len() + 2); + quoted.push('"'); + quoted.push_str(etag); + quoted.push('"'); + write_text_element(xml, tag, "ed) +} + pub fn write_text_element( xml: &mut Writer, tag: &str,