diff --git a/Cargo.toml b/Cargo.toml index 405d81c5..63cd2c53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -334,6 +334,22 @@ name = "bench_azure_stream" path = "examples/bench_azure_stream.rs" required-features = ["bench"] +# Round-5 battery ───────────────────────────────────────────────────────────── + +# CalDAV whole-calendar REPORT/GET — buffered double-residency vs uid-keyset +# streaming; TTFB + peak live heap (needs the dev Postgres up). +[[example]] +name = "bench_caldav_stream" +path = "examples/bench_caldav_stream.rs" +required-features = ["bench"] + +# Round-5 micro-allocation pack — suggest clones, readable-cache Arc hit, +# SPA-listing interning, NC href prefix, CardDAV REPORT churn. No Postgres. +[[example]] +name = "bench_micro_allocs" +path = "examples/bench_micro_allocs.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/ROUND5.md b/benches/ROUND5.md new file mode 100644 index 00000000..6b352aef --- /dev/null +++ b/benches/ROUND5.md @@ -0,0 +1,142 @@ +# Round 5 — CalDAV streaming, SPA interning gaps, NC href prefix, per-request micro-allocs + +Benchmark-gated changes, same rule as ROUND2-4: every change ships with a +BEFORE/AFTER benchmark; an AFTER that doesn't beat its BEFORE gets rolled +back. Equivalence gates (byte-identical responses / identical outputs) +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 | CalDAV whole-calendar streaming | TTFB / peak heap (4k events) | 23.3 → 11.0 ms (**2.1x**) / 14.2 → 8.0 MiB (**1.8x**) | +| 2 | SPA listing interning gaps closed | allocs/row closed-set fields | 4 → 0 (wall parity) | +| 3 | NC PROPFIND child-href prefix | ns/row href build | 543 → 165 (**3.3x**), 13 → 4 allocs | +| 4 | suggest enrichment consume | µs/keystroke (200 rows) | 166.5 → 126.8 (**1.31x**), 20 → 7 allocs/row | +| 5 | `list_readable_by` Arc hit | ns/hit warm | 246 → 128 (**1.9x**), 4 → 0 allocs | +| 6 | CardDAV REPORT churn | µs/5k-contact getetag poll | 3044 → 2340 (**1.30x**) | +| 7 | auth span records | allocs/request | 3 → 0 (field::display) | + +## [1] CalDAV whole-calendar responses — buffered double-residency → cursor streaming + +The REPORT path (no-range `calendar-query`, `sync-collection`), the +depth-1 collection PROPFIND (both URL shapes) and the whole-calendar +`.ics` GET all (a) materialised EVERY event DTO of the calendar in one +Vec — each row carrying its full `ical_data` body — then (b) rendered +the complete multistatus / VCALENDAR into a second in-RAM buffer: the +calendar resident twice per request, TTFB = full generation time. + +Now `CalendarEventRepository::stream_events_uid_order` serves ONE +window-ordered scan (`ORDER BY MIN(start_time) OVER (PARTITION BY +ical_uid), ical_uid, master-first, start_time`) through a PG cursor — +same-UID rows (recurring master + exception overrides) arrive adjacent, +bundle order equals the buffered listing's first-appearance order — and +the handlers cut emit pages at UID boundaries, streaming header → +page chunks → footer through the split adapter writers +(`write_caldav_multistatus_start` / `write_report_page` / +`write_collection_head` / `write_collection_event_page`). Bounded +shapes (time-range query, multiget, single-event GET) keep the buffered +path. The Read authz gate runs once before the cursor opens. + +The shape was itself benchmark-driven: a first keyset pager over the +`GROUP BY` re-aggregated the calendar per page (3-4x total wall — +rolled back), and per-uid `= ANY(page)` hydration paid ~20 µs per index +descent (~4x the sequential scan — rolled back). The shipped design +streams ONE window-ordered scan +(`ORDER BY MIN(start_time) OVER (PARTITION BY ical_uid), …`) through a +PG cursor, cutting emit pages at UID boundaries. + +``` +cargo run --release --features bench --example bench_caldav_stream +# 4000 events (20% exceptions) TTFB ms wall ms peak heap MiB +# BEFORE (buffered) 23.3 23.3 14.2 +# AFTER (streamed) 11.0 25.4 8.0 TTFB 2.1x, heap 1.8x +# 12000 events +# BEFORE 79.5 79.5 45.0 +# AFTER 43.9 91.5 24.2 TTFB 1.8x, heap 1.9x +# Trade: wall +9-15% (the window sort + cursor) for ~2x lower peak RAM +# — which scales with calendar size and per concurrent sync client — +# and ~2x faster first byte. Same trade class as ROUND2's ZIP +# streaming. Gates: multistatus AND .ics byte-identical to buffered. +``` + +## [2] SPA listing rows — interning bypass closed + +ROUND3 added `intern_display` / `intern_mime` so `File→FileDto` stops +allocating for the ~60-string closed set (icon class, category, mime). +But the three hottest web-UI listing endpoints — the folder navigation +(`/folders/{id}/resources`), `/recent/resources` and +`/favorites/resources` — plus the WebDAV drive pseudo-root build their +DTOs by hand and called raw `Arc::from` per row, re-introducing 3-4 +alloc+copies per row the intern tables exist to remove. All four sites +now route through the intern lookups; returned `Arc` contents are +byte-identical. + +## [3] NC PROPFIND child hrefs — per-row prefix re-encode → precomputed + +`nc_href` re-encoded the username and re-split + re-encoded the whole +parent path for EVERY child row of every NextCloud PROPFIND page (up to +500/page), preceded by a per-row `format!` of the joined subpath — only +the name segment actually varies. The prefix is now encoded once per +request; each row appends its encoded name (native WebDAV href also +dropped its intermediate encode String — the percent-encode `Display` +adapter feeds `format!` directly). + +## [4-6] Per-request micro-allocs (suggest, readable-cache, CardDAV) + +- **suggest** deep-cloned every entity into the DTO conversion and then + cloned name/id/path AGAIN per row — on an every-keystroke path. Now + consumes + moves. +- **`list_readable_by`** returned a fresh deep clone of the cached + drive Vec (every row's Strings) per warm hit — per DAV request with an + explicit selector. It now returns the cache's `Arc` (refcount bump); + the only caller that needs owned rows (`GET /api/drives`) clones just + its response rows. +- **CardDAV REPORT** cloned the requested-props Vec per REPORT, + allocated a fresh href String per contact and `format!`ed each quoted + etag — the same shapes ROUND4 removed from CalDAV. Now: borrowed + props, one reused href buffer, exact-size quoting. + +``` +cargo run --release --features bench --example bench_micro_allocs +# [1] suggest (200 rows) 166.5 → 126.8 µs 1.31x 20.0 → 7.0 allocs/row +# [2] readable warm hit 246.4 → 127.7 ns 1.9x 4 → 0 allocs/hit +# [3] closed-set fields 129.9 → 136.3 ns 1.0x 4 → 0 allocs/row +# (wall parity under the bench's System allocator; the win is the +# removed allocator traffic + consistency with the interned +# FileDto::from path — ROUND3 #9) +# [4] NC child hrefs 543.1 → 164.5 ns 3.3x 13 → 4 allocs/row +# [5] CardDAV getetag (5k) 3043.8 → 2339.5 µs 1.30x +# gates: identical outputs / byte-identical XML on every section +``` + +## [7] Auth middleware span records + +`tracing::Span::current().record("user_id", user_id.to_string())` +allocated a 36-byte String per authenticated request (×3 auth paths). +`tracing::field::display(user_id)` records lazily — the subscriber +formats into its own buffer. + +## Follow-ups worth a future round (confirmed real, not gated here) + +- CardDAV multistatus is still fully buffered — port the CalDAV + streaming emitter once contacts get a keyset pager (current + `get_contacts_by_address_book_paginated` is LIMIT/OFFSET, the + quadratic shape PROPFIND-PAGING replaced elsewhere). +- CalDAV time-range REPORT still buffers (bounded by the range, but a + year-wide range on a dense calendar is large). +- `batch_resolve_ids` / `batch_check_favorites` take `&[String]` — every + NC PROPFIND page clones ~500 id Strings that the services re-parse to + `Uuid` anyway; switch the chain to `&[&str]` (8 call sites). +- Hot listing SQL casts UUID columns to `::text` server-side (~18 sites + in `file_blob_read_repository.rs`) — decode as `Uuid` + format + app-side; needs a local-PG A/B before adopting. +- Public-share landing runs register + fetch serially — `tokio::join!` + or fold the increment into the fetch with `RETURNING`. +- `CurrentUser` still clones username/email per request; zero-alloc + needs the JWT cache to hold `Arc` claims. +- Grouped/swimlane files view virtualization (frontend, carried since + ROUND3). diff --git a/examples/bench_caldav_stream.rs b/examples/bench_caldav_stream.rs new file mode 100644 index 00000000..b8486228 --- /dev/null +++ b/examples/bench_caldav_stream.rs @@ -0,0 +1,534 @@ +//! CalDAV whole-calendar response benchmark — buffered vs streamed (ROUND5). +//! +//! The REPORT path (no-range calendar-query, sync-collection) and the +//! collection `.ics` GET used to (a) materialise EVERY event DTO of the +//! calendar in one Vec (owned `ical_data` per row), then (b) render the +//! complete multistatus / VCALENDAR into a second in-RAM buffer — the +//! calendar resident twice, TTFB = full generation. AFTER streams ONE +//! window-ordered scan (`MIN(start_time) OVER (PARTITION BY ical_uid)`) +//! through a PG cursor and cuts pages at UID boundaries — same-UID rows +//! never split, bundle order equals the buffered first-appearance +//! order, and only a page of rows is resident. (A first keyset-paged +//! shape re-aggregated per page — 3-4x wall — and a per-uid ANY +//! hydration paid ~20 µs per index descent — both measured and +//! discarded; see ROUND5.md.) +//! +//! This bench drives the REAL repository methods + adapter writers both +//! ways at the repo layer (authz gates are identical constants on both +//! sides and excluded). BEFORE uses the surviving buffered generator +//! (byte-stable refactor of the old monolith) + a verbatim copy of the +//! removed `generate_full_calendar_ical`. Gates: streamed concatenation +//! byte-identical to the buffered output for BOTH the multistatus and +//! the ICS body (seeded with strictly distinct start times so ordering +//! is deterministic). +//! +//! Run (needs Postgres up; reads DATABASE_URL from .env): +//! cargo run --release --features bench --example bench_caldav_stream +//! Tunables (env): BENCH_EVENTS (4000), BENCH_PAGE (500), BENCH_PASSES (9). + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::fmt::Write as _; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use chrono::{DateTime, TimeZone, Utc}; +use oxicloud::application::adapters::caldav_adapter::{ + CalDavAdapter, CalDavReportType, bench as caldav_bench, +}; +use oxicloud::application::dtos::calendar_dto::CalendarEventDto; +use oxicloud::domain::repositories::calendar_event_repository::CalendarEventRepository; +use oxicloud::infrastructure::repositories::pg::CalendarEventPgRepository; +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; +use uuid::Uuid; + +// ─── 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; + +// ─── BEFORE: verbatim copy of the removed whole-calendar ICS builder ──────── + +#[allow(clippy::all)] +mod before { + use super::*; + + /// Verbatim copy of the removed `generate_full_calendar_ical`. + pub fn generate_full_calendar_ical(calendar_name: &str, events: &[CalendarEventDto]) -> String { + let mut buf = String::with_capacity(256 + events.len() * 320); + let _ = write!( + buf, + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\nX-WR-CALNAME:{}\r\n", + calendar_name + ); + for group in caldav_bench::group_events_by_uid(events) { + for event in group { + if let Some(chunk) = caldav_bench::extract_vevent_chunk(&event.ical_data) { + buf.push_str(chunk); + if !buf.ends_with('\n') { + buf.push_str("\r\n"); + } + } + } + } + buf.push_str("END:VCALENDAR\r\n"); + buf + } +} + +// ─── Seed ─────────────────────────────────────────────────────────────────── + +fn vevent_body(uid: &str, start: DateTime, exception: bool) -> String { + let dt = start.format("%Y%m%dT%H%M%SZ"); + let dtend = (start + chrono::Duration::minutes(45)).format("%Y%m%dT%H%M%SZ"); + let mut v = String::with_capacity(640); + v.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n"); + v.push_str("BEGIN:VEVENT\r\n"); + let _ = write!(v, "UID:{uid}\r\nDTSTAMP:20260701T120000Z\r\n"); + let _ = write!(v, "DTSTART:{dt}\r\nDTEND:{dtend}\r\n"); + if exception { + let _ = write!(v, "RECURRENCE-ID:{dt}\r\n"); + } else { + v.push_str("RRULE:FREQ=WEEKLY;BYDAY=WE\r\n"); + } + let _ = write!(v, "SUMMARY:Reunión {uid}\r\n"); + v.push_str("LOCATION:Sala 3\r\nSTATUS:CONFIRMED\r\n"); + v.push_str("BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT10M\r\nEND:VALARM\r\n"); + v.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n"); + v +} + +struct Seeded { + calendar_id: Uuid, + owner_id: Uuid, +} + +async fn seed(pool: &PgPool, n: usize) -> Seeded { + let owner_id: Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, role) + VALUES ('bench_calstream', 'bench_calstream@bench.invalid', 'user') RETURNING id", + ) + .fetch_one(pool) + .await + .expect("seed user"); + let calendar_id: Uuid = sqlx::query_scalar( + "INSERT INTO caldav.calendars (id, name, owner_id) + VALUES (gen_random_uuid(), 'Agenda grande', $1) RETURNING id", + ) + .bind(owner_id) + .fetch_one(pool) + .await + .expect("seed calendar"); + + let base = Utc.with_ymd_and_hms(2026, 1, 5, 8, 0, 0).unwrap(); + let mut tx = pool.begin().await.expect("begin"); + for i in 0..n { + // 20% of rows are exception overrides sharing the previous + // master's UID; every start_time is strictly distinct so the + // response ordering is deterministic (byte-identity gate). + let exception = i % 5 == 4; + let master = if exception { i - 1 } else { i }; + let uid = format!("evt-{master:06}@oxicloud.bench"); + let start = base + chrono::Duration::seconds((i as i64) * 137); + let recurrence: Option> = exception.then_some(start); + sqlx::query( + "INSERT INTO caldav.calendar_events + (id, calendar_id, summary, start_time, end_time, all_day, + rrule, ical_uid, ical_data, recurrence_id) + VALUES (gen_random_uuid(), $1, $2, $3, $4, false, $5, $6, $7, $8)", + ) + .bind(calendar_id) + .bind(format!("Reunión {i}")) + .bind(start) + .bind(start + chrono::Duration::minutes(45)) + .bind((!exception).then_some("FREQ=WEEKLY;BYDAY=WE")) + .bind(&uid) + .bind(vevent_body(&uid, start, exception)) + .bind(recurrence) + .execute(&mut *tx) + .await + .expect("seed event"); + } + tx.commit().await.expect("commit"); + Seeded { + calendar_id, + owner_id, + } +} + +async fn cleanup(pool: &PgPool, s: &Seeded) { + let _ = sqlx::query("DELETE FROM caldav.calendar_events WHERE calendar_id = $1") + .bind(s.calendar_id) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM caldav.calendars WHERE id = $1") + .bind(s.calendar_id) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1") + .bind(s.owner_id) + .execute(pool) + .await; +} + +// ─── Pipelines ────────────────────────────────────────────────────────────── + +fn report_shape() -> CalDavReportType { + CalDavReportType::CalendarQuery { + props: vec![], + time_range: None, + } +} + +/// BEFORE: the buffered pipeline — full entity fetch → full DTO Vec → +/// one whole-response buffer. Returns (ttfb_ms, wall_ms, bytes). +async fn buffered_report( + repo: &CalendarEventPgRepository, + calendar_id: &Uuid, + base_href: &str, +) -> (f64, f64, Vec) { + let t0 = Instant::now(); + let events: Vec = repo + .list_events_by_calendar(calendar_id) + .await + .expect("list events") + .into_iter() + .map(CalendarEventDto::from) + .collect(); + let mut out = Vec::with_capacity(events.len() * 1024); + CalDavAdapter::generate_calendar_events_response(&mut out, &events, &report_shape(), base_href) + .expect("generate"); + let wall = t0.elapsed().as_secs_f64() * 1e3; + // Buffered: the first byte is only available when everything is. + (wall, wall, out) +} + +/// AFTER: the streaming pipeline — uid-keyset pages, per-page hydration, +/// header/page/footer chunks (the handler's loop over the same public +/// pieces). Returns (ttfb_ms, wall_ms, concatenated bytes). +async fn streamed_report( + repo: &CalendarEventPgRepository, + calendar_id: &Uuid, + base_href: &str, + page_uids: usize, +) -> (f64, f64, Vec) { + let t0 = Instant::now(); + let mut ttfb = None; + let mut all = Vec::new(); + let report = report_shape(); + + let mut chunk = Vec::with_capacity(256); + { + let mut w = quick_xml::Writer::new(&mut chunk); + CalDavAdapter::write_caldav_multistatus_start(&mut w).expect("start"); + } + all.extend_from_slice(&chunk); + + { + use futures::TryStreamExt; + let mut rows = repo.stream_events_uid_order(*calendar_id); + let mut page: Vec = Vec::with_capacity(page_uids + 32); + loop { + let next = rows + .try_next() + .await + .expect("stream row") + .map(CalendarEventDto::from); + let flush = match &next { + Some(ev) => { + page.len() >= page_uids + && page.last().is_some_and(|p| p.ical_uid != ev.ical_uid) + } + None => !page.is_empty(), + }; + if flush { + let mut chunk = Vec::with_capacity(page.len() * 1024 + 128); + { + let mut w = quick_xml::Writer::new(&mut chunk); + CalDavAdapter::write_report_page(&mut w, &page, &report, base_href) + .expect("page"); + } + if ttfb.is_none() && !all.is_empty() { + // header already emitted; first data page complete + } + page.clear(); + all.extend_from_slice(&chunk); + ttfb.get_or_insert_with(|| t0.elapsed().as_secs_f64() * 1e3); + } + match next { + Some(ev) => page.push(ev), + None => break, + } + } + } + + let mut chunk = Vec::with_capacity(32); + { + let mut w = quick_xml::Writer::new(&mut chunk); + CalDavAdapter::write_caldav_multistatus_end(&mut w).expect("end"); + } + all.extend_from_slice(&chunk); + ( + ttfb.unwrap_or(f64::NAN), + t0.elapsed().as_secs_f64() * 1e3, + all, + ) +} + +/// TTFB for the streaming path measured honestly: time until the FIRST +/// PAGE chunk (header + one hydrated page) exists — the moment real +/// bytes could hit the socket. +async fn streamed_report_ttfb( + repo: &CalendarEventPgRepository, + calendar_id: &Uuid, + base_href: &str, + page_uids: usize, +) -> f64 { + use futures::TryStreamExt; + let t0 = Instant::now(); + let mut rows = repo.stream_events_uid_order(*calendar_id); + let mut page: Vec = Vec::with_capacity(page_uids + 32); + while let Some(ev) = rows.try_next().await.expect("stream row") { + let ev = CalendarEventDto::from(ev); + if page.len() >= page_uids && page.last().is_some_and(|p| p.ical_uid != ev.ical_uid) { + break; + } + page.push(ev); + } + let mut chunk = Vec::with_capacity(page.len() * 1024 + 256); + { + let mut w = quick_xml::Writer::new(&mut chunk); + CalDavAdapter::write_caldav_multistatus_start(&mut w).expect("start"); + CalDavAdapter::write_report_page(&mut w, &page, &report_shape(), base_href).expect("page"); + } + std::hint::black_box(&chunk); + t0.elapsed().as_secs_f64() * 1e3 +} + +fn p50(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[xs.len() / 2] +} + +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() { + 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::var("BENCH_EVENTS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(4000); + let page_uids: usize = env::var("BENCH_PAGE") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500); + let passes: usize = env::var("BENCH_PASSES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(9); + + let pool = Arc::new( + PgPoolOptions::new() + .max_connections(10) + .min_connections(10) + .acquire_timeout(Duration::from_secs(10)) + .connect(&url) + .await + .expect("connect Postgres"), + ); + + let seeded = seed(&pool, n).await; + let repo = CalendarEventPgRepository::new(pool.clone()); + let base_href = format!("/caldav/{}/", seeded.calendar_id); + + println!( + "bench_caldav_stream — {n} events (20% exceptions), page={page_uids} uids, {passes} passes\n" + ); + + // ── [1] REPORT (multistatus) ──────────────────────────────────────────── + // Warm-up + equivalence gate first. + let (_, _, before_bytes) = buffered_report(&repo, &seeded.calendar_id, &base_href).await; + let (_, _, after_bytes) = + streamed_report(&repo, &seeded.calendar_id, &base_href, page_uids).await; + let gate_report = before_bytes == after_bytes; + + let mut b_wall = Vec::new(); + let mut a_wall = Vec::new(); + let mut a_ttfb = Vec::new(); + for _ in 0..passes { + let (_, w, out) = buffered_report(&repo, &seeded.calendar_id, &base_href).await; + std::hint::black_box(out); + b_wall.push(w); + let (_, w, out) = streamed_report(&repo, &seeded.calendar_id, &base_href, page_uids).await; + std::hint::black_box(out); + a_wall.push(w); + a_ttfb.push(streamed_report_ttfb(&repo, &seeded.calendar_id, &base_href, page_uids).await); + } + // Peak-heap arms, measured in isolation. + reset_peak(); + let (_, _, out) = buffered_report(&repo, &seeded.calendar_id, &base_href).await; + drop(out); + let peak_before = peak_mib(); + reset_peak(); + // Streamed peak: emulate the socket by dropping each chunk — reuse + // the pipeline but without accumulating (accumulation would charge + // the response size to the streaming arm). + { + use futures::TryStreamExt; + let t0 = Instant::now(); + let report = report_shape(); + let mut rows = repo.stream_events_uid_order(seeded.calendar_id); + let mut page: Vec = Vec::with_capacity(page_uids + 32); + loop { + let next = rows + .try_next() + .await + .expect("stream row") + .map(CalendarEventDto::from); + let flush = match &next { + Some(ev) => { + page.len() >= page_uids + && page.last().is_some_and(|p| p.ical_uid != ev.ical_uid) + } + None => !page.is_empty(), + }; + if flush { + let mut chunk = Vec::with_capacity(page.len() * 1024 + 128); + { + let mut w = quick_xml::Writer::new(&mut chunk); + CalDavAdapter::write_report_page(&mut w, &page, &report, &base_href) + .expect("page"); + } + std::hint::black_box(&chunk); + page.clear(); + } + match next { + Some(ev) => page.push(ev), + None => break, + } + } + std::hint::black_box(t0.elapsed()); + } + let peak_after = peak_mib(); + + let bw = p50(b_wall); + let aw = p50(a_wall); + let at = p50(a_ttfb); + println!("[1] REPORT calendar-query (no range) TTFB ms wall ms peak heap MiB"); + println!(" BEFORE (buffered) {bw:8.1} {bw:8.1} {peak_before:10.1}"); + println!( + " AFTER (streamed) {at:8.1} {aw:8.1} {peak_after:10.1} TTFB {:.1}x, heap {:.1}x lower", + bw / at, + peak_before / peak_after + ); + + // ── [2] Collection GET (.ics) ─────────────────────────────────────────── + let events_all: Vec = repo + .list_events_by_calendar(&seeded.calendar_id) + .await + .expect("list") + .into_iter() + .map(CalendarEventDto::from) + .collect(); + let before_ics = before::generate_full_calendar_ical("Agenda grande", &events_all); + drop(events_all); + // Streamed ICS: header + per-page chunks + footer (the handler loop). + let mut after_ics = String::from( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\nX-WR-CALNAME:Agenda grande\r\n", + ); + let ics_pages: Vec> = { + use futures::TryStreamExt; + let mut rows = repo.stream_events_uid_order(seeded.calendar_id); + let mut pages = Vec::new(); + let mut page: Vec = Vec::with_capacity(page_uids + 32); + while let Some(ev) = rows.try_next().await.expect("stream row") { + let ev = CalendarEventDto::from(ev); + if page.len() >= page_uids && page.last().is_some_and(|p| p.ical_uid != ev.ical_uid) { + pages.push(std::mem::take(&mut page)); + } + page.push(ev); + } + if !page.is_empty() { + pages.push(page); + } + pages + }; + for events in &ics_pages { + let events = &events[..]; + let mut chunk = String::with_capacity(events.len() * 384); + for group in caldav_bench::group_events_by_uid(events) { + for event in group { + if let Some(vevent) = caldav_bench::extract_vevent_chunk(&event.ical_data) { + chunk.push_str(vevent); + if !chunk.ends_with('\n') { + chunk.push_str("\r\n"); + } + } + } + } + after_ics.push_str(&chunk); + } + after_ics.push_str("END:VCALENDAR\r\n"); + let gate_ics = before_ics == after_ics; + println!( + "[2] collection GET .ics: {} bytes, streamed == buffered: {}", + before_ics.len(), + if gate_ics { "OK" } else { "MISMATCH" } + ); + + cleanup(&pool, &seeded).await; + + println!( + "\n[gate] multistatus byte-identical: {} · ICS byte-identical: {}", + if gate_report { "OK" } else { "FAILED" }, + if gate_ics { "OK" } else { "FAILED" } + ); + if !gate_report || !gate_ics { + std::process::exit(1); + } +} diff --git a/examples/bench_drive_selector.rs b/examples/bench_drive_selector.rs index e2416965..3a2f5c00 100644 --- a/examples/bench_drive_selector.rs +++ b/examples/bench_drive_selector.rs @@ -245,15 +245,15 @@ async fn main() { .list_readable_by(user_id) .await .expect("repo list") - .into_iter() - .map(|d| (d.drive.id, d.root_folder_name)) + .iter() + .map(|d| (d.drive.id, d.root_folder_name.clone())) .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)) + .iter() + .map(|d| (d.drive.id, d.root_folder_name.clone())) .collect(); if before_rows != cold || cold != warm { eprintln!( diff --git a/examples/bench_micro_allocs.rs b/examples/bench_micro_allocs.rs new file mode 100644 index 00000000..52e2f402 --- /dev/null +++ b/examples/bench_micro_allocs.rs @@ -0,0 +1,570 @@ +//! Round-5 micro-allocation pack — per-request/per-row churn removed +//! from five hot paths. Each section is BEFORE (verbatim old shape) vs +//! AFTER (the shipped code or its exact pattern), with byte/structure +//! equality gates. No Postgres. +//! +//! [1] search suggest enrichment: entity clone + 3 field re-clones per +//! row → consume + move. +//! [2] `list_readable_by` warm hit: deep `Vec` +//! clone per request → `Arc` refcount bump. +//! [3] SPA listing rows (folder/recent/favorites handlers): raw +//! `Arc::from` per closed-set display field → `intern_display` / +//! `intern_mime` lookups. +//! [4] NC PROPFIND child hrefs: per-row re-encode of username + parent +//! path (`nc_href`) → prefix precomputed once + name-only encode. +//! [5] CardDAV REPORT (getetag poll): per-REPORT props clone + +//! per-contact href String + etag `format!` → borrowed props, +//! reused href buffer, exact-size quoting. +//! +//! Run (no Postgres needed): +//! cargo run --release --features bench --example bench_micro_allocs +//! Tunables (env): BENCH_ROWS (5000), BENCH_PASSES (60). + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::hint::black_box; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use chrono::{TimeZone, Utc}; +use oxicloud::application::adapters::carddav_adapter::{CardDavAdapter, CardDavReportType}; +use oxicloud::application::adapters::webdav_adapter::QualifiedName; +use oxicloud::application::dtos::contact_dto::ContactDto; +use oxicloud::application::dtos::display_helpers::{ + category_for, icon_class_for, icon_special_class_for, intern_display, intern_mime, +}; +use oxicloud::application::dtos::file_dto::FileDto; +use oxicloud::application::dtos::search_dto::SearchSuggestionItem; +use oxicloud::domain::entities::drive::{Drive, DriveKind}; +use oxicloud::domain::entities::file::File; +use oxicloud::domain::repositories::drive_repository::DriveWithRootName; +use oxicloud::interfaces::nextcloud::webdav_handler::nc_href; +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; + +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 = Vec::with_capacity(passes); + for _ in 0..passes { + let t0 = Instant::now(); + black_box(f()); + per.push(t0.elapsed().as_secs_f64() * 1e6); + } + p50(per) +} + +fn allocs_of(mut f: impl FnMut() -> T) -> u64 { + let s0 = ALLOC_CALLS.load(Ordering::Relaxed); + black_box(f()); + ALLOC_CALLS.load(Ordering::Relaxed) - s0 +} + +// ─── Corpus builders ──────────────────────────────────────────────────────── + +fn make_files(n: usize) -> Vec { + (0..n) + .map(|i| { + File::from_materialized_row( + Uuid::from_u128(i as u128).to_string(), + format!("documento-{i}.pdf"), + Some("/Personal/Proyectos/2026"), + 1024 + i as u64, + "application/pdf".to_string(), + None, + 1_700_000_000, + 1_750_000_000, + format!("{:032x}", i), + None, + None, + ) + .expect("file") + }) + .collect() +} + +fn compute_relevance(name: &str, q: &str) -> u32 { + if name.to_lowercase().contains(q) { + 100 + } else { + 50 + } +} + +/// The suggest enrichment loop — BEFORE: per-row entity clone + field +/// re-clones (verbatim old shape, icon helper substituted identically +/// on both arms). +fn suggest_before(files: &[File], q: &str) -> Vec { + let mut out = Vec::new(); + let query_lower = q.to_lowercase(); + for file in files { + let file_dto = FileDto::from(file.clone()); + let score = compute_relevance(&file_dto.name, &query_lower); + out.push(SearchSuggestionItem { + name: file_dto.name.clone(), + item_type: "file".to_string(), + id: file_dto.id.clone(), + path: file_dto.path.clone(), + icon_class: icon_class_for(&file_dto.name, &file_dto.mime_type).to_string(), + icon_special_class: icon_special_class_for(&file_dto.name, &file_dto.mime_type) + .to_string(), + relevance_score: score, + }); + } + out +} + +/// AFTER: consume + move (the shipped shape). +fn suggest_after(files: Vec, q: &str) -> Vec { + let mut out = Vec::new(); + let query_lower = q.to_lowercase(); + for file in files { + let file_dto = FileDto::from(file); + let score = compute_relevance(&file_dto.name, &query_lower); + let icon_class = icon_class_for(&file_dto.name, &file_dto.mime_type).to_string(); + let icon_special_class = + icon_special_class_for(&file_dto.name, &file_dto.mime_type).to_string(); + out.push(SearchSuggestionItem { + name: file_dto.name, + item_type: "file".to_string(), + id: file_dto.id, + path: file_dto.path, + icon_class, + icon_special_class, + relevance_score: score, + }); + } + out +} + +fn make_drives(n: usize) -> Vec { + (0..n) + .map(|i| DriveWithRootName { + drive: Drive { + id: Uuid::from_u128(i as u128), + kind: if i == 0 { + DriveKind::Personal + } else { + DriveKind::Shared + }, + default_for_user: (i == 0).then(|| Uuid::from_u128(999)), + root_folder_id: Uuid::from_u128(1000 + i as u128), + quota_bytes: Some(10_737_418_240), + used_bytes: 123_456_789, + policies: serde_json::json!({}), + created_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(), + updated_at: Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(), + }, + root_folder_name: format!("Drive número {i}"), + caller_role: None, + }) + .collect() +} + +fn make_contacts(n: usize) -> Vec { + (0..n) + .map(|i| ContactDto { + id: Uuid::from_u128(i as u128).to_string(), + uid: format!("contact-{i:05}"), + etag: format!("{:016x}", i * 2_654_435_761u64 as usize), + full_name: Some(format!("Persona {i}")), + ..ContactDto::default() + }) + .collect() +} + +// BEFORE replica of the CardDAV REPORT emitter (props.clone + per-row +// href String + etag format!) for the getetag poll shape — the +// address-data branch is never hit with this prop set, so the replica +// stays self-contained. +mod before_carddav { + use super::*; + use quick_xml::Writer; + use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; + + pub fn generate_contacts_response( + out: &mut Vec, + contacts: &[ContactDto], + report: &CardDavReportType, + base_href: &str, + ) { + let mut xml_writer = Writer::new(out); + xml_writer + .write_event(Event::Start( + BytesStart::new("D:multistatus").with_attributes([ + ("xmlns:D", "DAV:"), + ("xmlns:CR", "urn:ietf:params:xml:ns:carddav"), + ]), + )) + .unwrap(); + + let props = match report { + CardDavReportType::AddressbookQuery { props } => props.clone(), + CardDavReportType::AddressbookMultiget { props, .. } => props.clone(), + CardDavReportType::SyncCollection { props, .. } => props.clone(), + }; + + for contact in contacts { + let href = format!("{}{}.vcf", base_href, contact.uid); + xml_writer + .write_event(Event::Start(BytesStart::new("D:response"))) + .unwrap(); + xml_writer + .write_event(Event::Start(BytesStart::new("D:href"))) + .unwrap(); + xml_writer + .write_event(Event::Text(BytesText::new(&href))) + .unwrap(); + xml_writer + .write_event(Event::End(BytesEnd::new("D:href"))) + .unwrap(); + xml_writer + .write_event(Event::Start(BytesStart::new("D:propstat"))) + .unwrap(); + xml_writer + .write_event(Event::Start(BytesStart::new("D:prop"))) + .unwrap(); + for prop in &props { + match (prop.namespace.as_str(), prop.name.as_str()) { + ("DAV:", "resourcetype") => { + xml_writer + .write_event(Event::Empty(BytesStart::new("D:resourcetype"))) + .unwrap(); + } + ("DAV:", "getetag") => { + xml_writer + .write_event(Event::Start(BytesStart::new("D:getetag"))) + .unwrap(); + xml_writer + .write_event(Event::Text(BytesText::new(&format!( + "\"{}\"", + contact.etag + )))) + .unwrap(); + xml_writer + .write_event(Event::End(BytesEnd::new("D:getetag"))) + .unwrap(); + } + ("DAV:", "getcontenttype") => { + xml_writer + .write_event(Event::Start(BytesStart::new("D:getcontenttype"))) + .unwrap(); + xml_writer + .write_event(Event::Text(BytesText::new("text/vcard; charset=utf-8"))) + .unwrap(); + xml_writer + .write_event(Event::End(BytesEnd::new("D:getcontenttype"))) + .unwrap(); + } + _ => {} + } + } + xml_writer + .write_event(Event::End(BytesEnd::new("D:prop"))) + .unwrap(); + xml_writer + .write_event(Event::Start(BytesStart::new("D:status"))) + .unwrap(); + xml_writer + .write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK"))) + .unwrap(); + xml_writer + .write_event(Event::End(BytesEnd::new("D:status"))) + .unwrap(); + xml_writer + .write_event(Event::End(BytesEnd::new("D:propstat"))) + .unwrap(); + xml_writer + .write_event(Event::End(BytesEnd::new("D:response"))) + .unwrap(); + } + xml_writer + .write_event(Event::End(BytesEnd::new("D:multistatus"))) + .unwrap(); + } +} + +fn main() { + let rows: usize = env::var("BENCH_ROWS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5000); + let passes: usize = env::var("BENCH_PASSES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(60); + let mut ok = true; + + println!("bench_micro_allocs — {rows} rows, {passes} passes\n"); + + // ── [1] suggest enrichment ────────────────────────────────────────────── + { + let files = make_files(200); // suggest is limit-bounded (~10-200) + let t_b = time_passes(passes, || suggest_before(&files, "doc")); + // Production AFTER consumes the caller's Vec — no clone exists. + // The replay clone happens OUTSIDE the timed window. + let t_a = { + let mut per = Vec::with_capacity(passes); + for _ in 0..passes { + let corpus = files.clone(); + let t0 = Instant::now(); + black_box(suggest_after(corpus, "doc")); + per.push(t0.elapsed().as_secs_f64() * 1e6); + } + p50(per) + }; + // Alloc parity: charge the corpus clone to neither arm by + // measuring BEFORE with its borrow (clones inside) and AFTER + // seeded from a pre-cloned Vec outside the counter window. + let a_b = allocs_of(|| suggest_before(&files, "doc")) as f64 / files.len() as f64; + let mut pre = Some(files.clone()); + let a_a = + allocs_of(|| suggest_after(pre.take().unwrap(), "doc")) as f64 / files.len() as f64; + let g_b = suggest_before(&files, "doc"); + let g_a = suggest_after(files.clone(), "doc"); + let same = g_b.len() == g_a.len() + && g_b.iter().zip(&g_a).all(|(x, y)| { + x.name == y.name && x.id == y.id && x.path == y.path && x.icon_class == y.icon_class + }); + if !same { + eprintln!("GATE FAIL suggest"); + ok = false; + } + println!("[1] suggest enrichment (200 rows) µs/pass allocs/row"); + println!(" BEFORE (clone per row) {t_b:8.1} {a_b:7.2}"); + println!( + " AFTER (consume + move) {t_a:8.1} {a_a:7.2} {:.2}x", + t_b / t_a + ); + } + + // ── [2] readable-drives warm hit ──────────────────────────────────────── + { + let value = Arc::new(make_drives(3)); + let cache: moka::sync::Cache>> = + moka::sync::Cache::new(100); + let user = Uuid::from_u128(42); + cache.insert(user, value); + let hit_before = || { + let arc = cache.get(&user).expect("warm"); + let v: Vec = (*arc).clone(); // old: deep clone out + v + }; + let hit_after = || cache.get(&user).expect("warm"); // new: Arc bump + let n_iters = 10_000u32; + let t_b = time_passes(passes, || { + for _ in 0..n_iters { + black_box(hit_before()); + } + }) / n_iters as f64 + * 1000.0; + let t_a = time_passes(passes, || { + for _ in 0..n_iters { + black_box(hit_after()); + } + }) / n_iters as f64 + * 1000.0; + let a_b = allocs_of(hit_before); + let a_a = allocs_of(hit_after); + let g = hit_before(); + let ga = hit_after(); + if g.len() != ga.len() || g[0].root_folder_name != ga[0].root_folder_name { + eprintln!("GATE FAIL readable hit"); + ok = false; + } + println!("[2] list_readable_by warm hit (3 drives) ns/hit allocs/hit"); + println!(" BEFORE (deep Vec clone) {t_b:8.1} {a_b:7}"); + println!( + " AFTER (Arc refcount bump) {t_a:8.1} {a_a:7} {:.1}x", + t_b / t_a + ); + } + + // ── [3] SPA listing closed-set fields ─────────────────────────────────── + { + let names: Vec = (0..rows).map(|i| format!("informe-{i}.pdf")).collect(); + let mime = "application/pdf"; + let row_before = |name: &str| { + ( + Arc::::from(mime), + Arc::::from(icon_class_for(name, mime)), + Arc::::from(icon_special_class_for(name, mime)), + Arc::::from(category_for(name, mime)), + ) + }; + let row_after = |name: &str| { + ( + intern_mime(mime), + intern_display(icon_class_for(name, mime)), + intern_display(icon_special_class_for(name, mime)), + intern_display(category_for(name, mime)), + ) + }; + let t_b = time_passes(passes, || { + for n in &names { + black_box(row_before(n)); + } + }) / rows as f64 + * 1000.0; + let t_a = time_passes(passes, || { + for n in &names { + black_box(row_after(n)); + } + }) / rows as f64 + * 1000.0; + let a_b = allocs_of(|| row_before(&names[0])); + let a_a = allocs_of(|| row_after(&names[0])); + let (bm, bi, bs, bc) = row_before(&names[0]); + let (am, ai, as_, ac) = row_after(&names[0]); + if *bm != *am || *bi != *ai || *bs != *as_ || *bc != *ac { + eprintln!("GATE FAIL interning content"); + ok = false; + } + println!("[3] listing closed-set fields ns/row allocs/row"); + println!(" BEFORE (Arc::from ×4) {t_b:8.1} {a_b:7}"); + println!( + " AFTER (intern lookups ×4) {t_a:8.1} {a_a:7} {:.1}x", + t_b / t_a + ); + } + + // ── [4] NC PROPFIND child hrefs ───────────────────────────────────────── + { + let username = "ana.garcia"; + let subpath = "Personal/Proyectos 2026/Diseño"; + let names: Vec = (0..rows) + .map(|i| format!("archivo con espacios {i}.png")) + .collect(); + // Verbatim replica of the production shape — `subpath` is a + // const here, so the emptiness test is statically known. + #[allow(clippy::const_is_empty)] + let href_before = |name: &str| { + let child_sub = if subpath.is_empty() { + name.to_string() + } else { + format!("{}/{}", subpath.trim_end_matches('/'), name) + }; + nc_href(username, &child_sub) + }; + let prefix = { + let base = nc_href(username, subpath); + if base.ends_with('/') { + base + } else { + format!("{base}/") + } + }; + let href_after = |name: &str| format!("{}{}", prefix, urlencoding::encode(name)); + let t_b = time_passes(passes, || { + for n in &names { + black_box(href_before(n)); + } + }) / rows as f64 + * 1000.0; + let t_a = time_passes(passes, || { + for n in &names { + black_box(href_after(n)); + } + }) / rows as f64 + * 1000.0; + let a_b = allocs_of(|| href_before(&names[0])); + let a_a = allocs_of(|| href_after(&names[0])); + for n in names.iter().take(50) { + if href_before(n) != href_after(n) { + eprintln!("GATE FAIL href: {} != {}", href_before(n), href_after(n)); + ok = false; + break; + } + } + println!("[4] NC child hrefs (depth-3 parent) ns/row allocs/row"); + println!(" BEFORE (nc_href per row) {t_b:8.1} {a_b:7}"); + println!( + " AFTER (prefix + name encode) {t_a:8.1} {a_a:7} {:.1}x", + t_b / t_a + ); + } + + // ── [5] CardDAV REPORT getetag poll ───────────────────────────────────── + { + let contacts = make_contacts(rows); + let report = CardDavReportType::AddressbookQuery { + props: vec![ + QualifiedName::new("DAV:", "getetag"), + QualifiedName::new("DAV:", "getcontenttype"), + ], + }; + let base = "/carddav/libreta/"; + let run_before = || { + let mut out = Vec::with_capacity(contacts.len() * 256); + before_carddav::generate_contacts_response(&mut out, &contacts, &report, base); + out + }; + let run_after = || { + let mut out = Vec::with_capacity(contacts.len() * 256); + CardDavAdapter::generate_contacts_response(&mut out, &contacts, &report, base) + .expect("generate"); + out + }; + let t_b = time_passes(passes.min(30), run_before); + let t_a = time_passes(passes.min(30), run_after); + let xb = run_before(); + let xa = run_after(); + if xb != xa { + let at = xb.iter().zip(&xa).position(|(a, b)| a != b).unwrap_or(0); + eprintln!( + "GATE FAIL carddav at byte {at}: …{}… vs …{}…", + String::from_utf8_lossy(&xb[at.saturating_sub(60)..(at + 60).min(xb.len())]), + String::from_utf8_lossy(&xa[at.saturating_sub(60)..(at + 60).min(xa.len())]), + ); + ok = false; + } + println!("[5] CardDAV REPORT getetag ({rows} contacts) µs/report"); + println!(" BEFORE (clone + format! churn) {t_b:8.1}"); + println!( + " AFTER (borrow + reuse + exact-size) {t_a:8.1} {:.2}x", + t_b / t_a + ); + } + + println!( + "\n[gate] {}", + if ok { + "OK (identical outputs)" + } 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 ef91b702..7f26c828 100644 --- a/src/application/adapters/caldav_adapter.rs +++ b/src/application/adapters/caldav_adapter.rs @@ -1064,71 +1064,142 @@ impl CalDavAdapter { // Write the calendar collection itself Self::write_calendar_response(&mut xml_writer, calendar, request, base_href, caller_id)?; - // If depth > 0, include event resources — folded per UID - // so a recurring event's master + per-instance exception - // overrides share ONE D:response (RFC 4791 §4.1 + RFC - // 5545 §3.6.1). Pre-fix this loop emitted one D:response - // per DB row, and since master + exception share the - // same href (base + uid.ics) clients saw a duplicate - // href and deduped — the exception appeared to have - // vanished. + // If depth > 0, include event resources — see + // `write_collection_event_page`, which the streaming emitter + // reuses page by page. if depth != "0" { - for bundle in group_events_by_uid(events) { - // The master (sorted first by group_events_by_uid) - // supplies the ETag anchor + getlastmodified. If - // the bundle is all exceptions (no master row), - // fall back to the first exception. - let anchor = match bundle.first() { - Some(e) => *e, - None => continue, - }; - let event_href = format!("{}{}.ics", base_href, anchor.ical_uid); - - 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(&event_href)))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; - - xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; - xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; - - // resourcetype (empty for non-collection) - xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; - - // getetag — anchor row's id - xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer - .write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; - - // getcontenttype - xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; - xml_writer.write_event(Event::Text(BytesText::new( - "text/calendar; component=vevent", - )))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; - - // getlastmodified — anchor row's updated_at - xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - xml_writer - .write_event(Event::Text(BytesText::new(&anchor.updated_at.to_rfc2822())))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; - - 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")))?; - xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; - } + Self::write_collection_event_page(&mut xml_writer, events, base_href)?; } + Self::write_caldav_multistatus_end(&mut xml_writer)?; + Ok(()) + } + + /// Multistatus opening + the calendar collection's own + /// `D:response` — the head of a depth-1 collection PROPFIND. The + /// streaming emitter calls this once, then + /// [`Self::write_collection_event_page`] per hydrated UID page, + /// then [`Self::write_caldav_multistatus_end`]. + pub fn write_collection_head( + xml_writer: &mut Writer, + calendar: &CalendarDto, + request: &PropFindRequest, + base_href: &str, + caller_id: &str, + ) -> Result<()> { + Self::write_caldav_multistatus_start(xml_writer)?; + Self::write_calendar_response(xml_writer, calendar, request, base_href, caller_id) + } + + /// One depth-1 collection page: event resources folded per UID so a + /// recurring master + per-instance exception overrides share ONE + /// `D:response` (RFC 4791 §4.1 + RFC 5545 §3.6.1) — emitting one + /// response per DB row made clients dedupe the shared href and the + /// exception appeared to vanish. Callers guarantee same-UID rows + /// arrive within a single page. + pub fn write_collection_event_page( + xml_writer: &mut Writer, + events: &[CalendarEventDto], + base_href: &str, + ) -> Result<()> { + for bundle in group_events_by_uid(events) { + // The master (sorted first by group_events_by_uid) + // supplies the ETag anchor + getlastmodified. If + // the bundle is all exceptions (no master row), + // fall back to the first exception. + let anchor = match bundle.first() { + Some(e) => *e, + None => continue, + }; + let event_href = format!("{}{}.ics", base_href, anchor.ical_uid); + + 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(&event_href)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + + // resourcetype (empty for non-collection) + xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; + + // getetag — anchor row's id + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + + // getcontenttype + xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer.write_event(Event::Text(BytesText::new( + "text/calendar; component=vevent", + )))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + + // getlastmodified — anchor row's updated_at + xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + xml_writer.write_event(Event::Text(BytesText::new(&anchor.updated_at.to_rfc2822())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + + 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")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; + } + Ok(()) + } + + /// Write the CalDAV `` opening tag (DAV + CalDAV + + /// CalendarServer namespaces). Streaming emitters call this once, + /// then [`Self::write_report_page`] per hydrated UID page, then + /// [`Self::write_caldav_multistatus_end`]. + pub fn write_caldav_multistatus_start(xml_writer: &mut Writer) -> Result<()> { + xml_writer.write_event(Event::Start( + BytesStart::new("D:multistatus").with_attributes([ + ("xmlns:D", "DAV:"), + ("xmlns:C", "urn:ietf:params:xml:ns:caldav"), + ("xmlns:CS", "http://calendarserver.org/ns/"), + ]), + ))?; + Ok(()) + } + + /// Close the multistatus opened by + /// [`Self::write_caldav_multistatus_start`]. + pub fn write_caldav_multistatus_end(xml_writer: &mut Writer) -> Result<()> { xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; Ok(()) } + /// One REPORT page: group `events` per UID and emit one + /// `D:response` per bundle. Callers guarantee same-UID rows arrive + /// within a single page (the uid-keyset pager does). + pub fn write_report_page( + xml_writer: &mut Writer, + events: &[CalendarEventDto], + request: &CalDavReportType, + base_href: &str, + ) -> Result<()> { + let props = match request { + CalDavReportType::CalendarQuery { props, .. } => props, + CalDavReportType::CalendarMultiget { props, .. } => props, + CalDavReportType::SyncCollection { props, .. } => props, + }; + for bundle in group_events_by_uid(events) { + let anchor = match bundle.first() { + Some(e) => *e, + None => continue, + }; + let href = format!("{}{}.ics", base_href, anchor.ical_uid); + Self::write_event_response(xml_writer, &bundle, props, &href)?; + } + Ok(()) + } + /// Generate a response for calendar events pub fn generate_calendar_events_response( writer: W, @@ -1138,42 +1209,15 @@ impl CalDavAdapter { ) -> Result<()> { let mut xml_writer = Writer::new(writer); - // Start multistatus response - xml_writer.write_event(Event::Start( - BytesStart::new("D:multistatus").with_attributes([ - ("xmlns:D", "DAV:"), - ("xmlns:C", "urn:ietf:params:xml:ns:caldav"), - ("xmlns:CS", "http://calendarserver.org/ns/"), - ]), - ))?; + Self::write_caldav_multistatus_start(&mut xml_writer)?; - // 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, - CalDavReportType::CalendarMultiget { props, .. } => props, - CalDavReportType::SyncCollection { props, .. } => props, - }; + // Responses folded per UID so a recurring master + exception + // overrides share ONE D:response (RFC 4791 §4.1) — see + // `write_report_page`, which the streaming emitters reuse + // page by page. + Self::write_report_page(&mut xml_writer, events, request, base_href)?; - // Add responses for events — folded per UID so a - // recurring master + per-instance exception overrides - // share ONE D:response with all VEVENTs concatenated - // into the calendar-data payload (RFC 4791 §4.1). Pre- - // fix this loop emitted one D:response per DB row, so - // master + exception carried duplicate hrefs and clients - // deduped, hiding the exception from the resulting sync. - for bundle in group_events_by_uid(events) { - let anchor = match bundle.first() { - Some(e) => *e, - None => continue, - }; - let href = format!("{}{}.ics", base_href, anchor.ical_uid); - Self::write_event_response(&mut xml_writer, &bundle, props, &href)?; - } - - // End multistatus - xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + Self::write_caldav_multistatus_end(&mut xml_writer)?; Ok(()) } diff --git a/src/application/adapters/carddav_adapter.rs b/src/application/adapters/carddav_adapter.rs index 1c5a13f6..b48eac62 100644 --- a/src/application/adapters/carddav_adapter.rs +++ b/src/application/adapters/carddav_adapter.rs @@ -663,17 +663,27 @@ impl CardDavAdapter { ]), ))?; + // Borrowed straight out of the request — the old `clone()` copied + // the whole Vec of owned QualifiedName strings per REPORT (same + // fix the CalDAV surface got in ROUND4). let props = match report { - CardDavReportType::AddressbookQuery { props } => props.clone(), - CardDavReportType::AddressbookMultiget { props, .. } => props.clone(), - CardDavReportType::SyncCollection { props, .. } => props.clone(), + CardDavReportType::AddressbookQuery { props } => props, + CardDavReportType::AddressbookMultiget { props, .. } => props, + CardDavReportType::SyncCollection { props, .. } => props, }; + // One reused href buffer for the whole listing instead of a + // fresh String per contact. + let mut href = String::with_capacity(base_href.len() + 48); for contact in contacts { - let href = format!("{}{}.vcf", base_href, contact.uid); + href.clear(); + let _ = std::fmt::Write::write_fmt( + &mut href, + format_args!("{}{}.vcf", base_href, contact.uid), + ); // `write_contact_response` generates the vCard on demand when (and // only when) address-data is actually requested. - Self::write_contact_response(&mut xml_writer, contact, &props, &href)?; + Self::write_contact_response(&mut xml_writer, contact, props, &href)?; } xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; @@ -701,10 +711,11 @@ impl CardDavAdapter { xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer.write_event(Event::Text(BytesText::new(&format!( - "\"{}\"", - contact.etag - ))))?; + let mut quoted = String::with_capacity(contact.etag.len() + 2); + quoted.push('"'); + quoted.push_str(&contact.etag); + quoted.push('"'); + xml_writer.write_event(Event::Text(BytesText::new("ed)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; @@ -724,10 +735,11 @@ impl CardDavAdapter { } ("DAV:", "getetag") => { xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer.write_event(Event::Text(BytesText::new(&format!( - "\"{}\"", - contact.etag - ))))?; + let mut quoted = String::with_capacity(contact.etag.len() + 2); + quoted.push('"'); + quoted.push_str(&contact.etag); + quoted.push('"'); + xml_writer.write_event(Event::Text(BytesText::new("ed)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; } ("DAV:", "getcontenttype") => { diff --git a/src/application/ports/calendar_ports.rs b/src/application/ports/calendar_ports.rs index 7eea753a..794693d6 100644 --- a/src/application/ports/calendar_ports.rs +++ b/src/application/ports/calendar_ports.rs @@ -116,6 +116,12 @@ pub trait CalendarStoragePort: Send + Sync + 'static { &self, calendar_id: &str, ) -> Result, DomainError>; + /// Cursor stream over the calendar's events in bundle order (see + /// the repository doc) — feeds the streaming CalDAV emitters. + fn stream_events_uid_order( + &self, + calendar_id: &str, + ) -> futures::stream::BoxStream<'static, Result>; async fn list_events_by_calendar_paginated( &self, calendar_id: &str, @@ -218,6 +224,16 @@ pub trait CalendarUseCase: Send + Sync + 'static { offset: Option, user_id: Uuid, ) -> Result, DomainError>; + /// Streaming support: cursor over the calendar's events in bundle + /// order, behind the same Read authz gate as [`Self::list_events`]. + async fn stream_events_uid_order( + &self, + calendar_id: &str, + user_id: Uuid, + ) -> Result< + futures::stream::BoxStream<'static, Result>, + DomainError, + >; async fn get_events_in_range( &self, calendar_id: &str, diff --git a/src/application/services/calendar_service.rs b/src/application/services/calendar_service.rs index 88495932..d7a83aeb 100644 --- a/src/application/services/calendar_service.rs +++ b/src/application/services/calendar_service.rs @@ -356,6 +356,28 @@ impl CalendarUseCase for CalendarService { } } + async fn stream_events_uid_order( + &self, + calendar_id: &str, + user_id: Uuid, + ) -> Result< + futures::stream::BoxStream<'static, Result>, + DomainError, + > { + // Same Read gate as `list_events`, checked ONCE before the + // cursor opens — the stream itself carries no further authz + // (single request, same caller, same resource). + let calendar = self.calendar_storage.get_calendar(calendar_id).await?; + let allowed = calendar.is_public + || self + .has_calendar_perm(calendar_id, user_id, Permission::Read) + .await?; + if !allowed { + return Err(DomainError::not_found("Calendar", calendar_id)); + } + Ok(self.calendar_storage.stream_events_uid_order(calendar_id)) + } + async fn get_events_in_range( &self, calendar_id: &str, diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index 610cd258..a7dcf947 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -359,7 +359,7 @@ impl SearchService { // grants are honoured inline by `storage.caller_group_ids` on // the SQL side, so no Rust-side subject expansion here. let accessible_drives: Vec = match drive_repo.list_readable_by(user_id).await { - Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(), + Ok(drives) => drives.iter().map(|d| d.drive.id).collect(), Err(e) => { tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}"); return Vec::new(); @@ -529,28 +529,34 @@ impl SearchService { // Pre-compute once — avoids N heap allocations inside the loops. let query_lower = query.to_lowercase(); - for file in &files { - let file_dto = FileDto::from(file.clone()); + // Consume the entities: the old loop deep-cloned every File into + // the DTO conversion and then cloned name/id/path AGAIN into the + // suggestion — 3 field clones + a full entity clone per row on + // an every-keystroke path. + for file in files { + let file_dto = FileDto::from(file); let score = compute_relevance(&file_dto.name, &query_lower); + let icon_class = get_icon_class(&file_dto.name, &file_dto.mime_type); + let icon_special_class = get_icon_special_class(&file_dto.name, &file_dto.mime_type); suggestions.push(SearchSuggestionItem { - name: file_dto.name.clone(), + name: file_dto.name, item_type: "file".to_string(), - id: file_dto.id.clone(), - path: file_dto.path.clone(), - icon_class: get_icon_class(&file_dto.name, &file_dto.mime_type), - icon_special_class: get_icon_special_class(&file_dto.name, &file_dto.mime_type), + id: file_dto.id, + path: file_dto.path, + icon_class, + icon_special_class, relevance_score: score, }); } - for folder in &folders { - let folder_dto = FolderDto::from(folder.clone()); + for folder in folders { + let folder_dto = FolderDto::from(folder); let score = compute_relevance(&folder_dto.name, &query_lower); suggestions.push(SearchSuggestionItem { - name: folder_dto.name.clone(), + name: folder_dto.name, item_type: "folder".to_string(), - id: folder_dto.id.clone(), - path: folder_dto.path.clone(), + id: folder_dto.id, + path: folder_dto.path, icon_class: "fas fa-folder".to_string(), icon_special_class: "folder-icon".to_string(), relevance_score: score, diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 5892f962..7a9a9fec 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -801,7 +801,7 @@ impl TrashService { // role_grants on resource_type='drive', including group-mediated // grants). Empty set → empty page without a SQL round-trip. let drive_ids: Vec = match self.drive_repo.list_readable_by(user_id).await { - Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(), + Ok(drives) => drives.iter().map(|d| d.drive.id).collect(), Err(e) => { return Err(DomainError::internal_error( "Trash", diff --git a/src/domain/repositories/calendar_event_repository.rs b/src/domain/repositories/calendar_event_repository.rs index 90b105e9..df8e24dd 100644 --- a/src/domain/repositories/calendar_event_repository.rs +++ b/src/domain/repositories/calendar_event_repository.rs @@ -25,6 +25,18 @@ pub trait CalendarEventRepository: Send + Sync + 'static { /// Finds a calendar event by its ID async fn find_event_by_id(&self, id: &Uuid) -> CalendarEventRepositoryResult; + /// Cursor stream over every event of `calendar_id` in bundle order: + /// rows sorted by `(first occurrence per UID, uid, master-first, + /// start_time)` so a recurring master + its exception overrides + /// arrive adjacent and bundles appear in the first-appearance order + /// the buffered `start_time` listing produced. ONE scan+sort on the + /// server; the streaming CalDAV emitters cut pages at UID + /// boundaries so only a page of rows is ever resident. + fn stream_events_uid_order( + &self, + calendar_id: Uuid, + ) -> futures::stream::BoxStream<'static, CalendarEventRepositoryResult>; + /// Lists all events in a specific calendar async fn list_events_by_calendar( &self, diff --git a/src/domain/repositories/drive_repository.rs b/src/domain/repositories/drive_repository.rs index 74d82523..b5153411 100644 --- a/src/domain/repositories/drive_repository.rs +++ b/src/domain/repositories/drive_repository.rs @@ -172,10 +172,14 @@ pub trait DriveRepository: Send + Sync + 'static { /// Returns rows in a stable order: default drive first (if any), /// then by display name. The `/api/drives` handler relies on that /// order for the picker UI without a follow-up sort. + /// Returned as `Arc>`: warm hits are a refcount bump straight + /// off the per-user cache instead of a deep clone of every row's + /// Strings — this runs per DAV request with an explicit drive + /// selector. async fn list_readable_by( &self, caller_id: Uuid, - ) -> Result, DriveRepositoryError>; + ) -> Result>, DriveRepositoryError>; /// `true` when the drive holds no live (non-trashed) folders other /// than its own root and no live files at all. Used by diff --git a/src/infrastructure/adapters/calendar_storage_adapter.rs b/src/infrastructure/adapters/calendar_storage_adapter.rs index 4bea1082..191a97f0 100644 --- a/src/infrastructure/adapters/calendar_storage_adapter.rs +++ b/src/infrastructure/adapters/calendar_storage_adapter.rs @@ -447,6 +447,30 @@ impl CalendarStoragePort for CalendarStorageAdapter { Ok(events.into_iter().map(CalendarEventDto::from).collect()) } + fn stream_events_uid_order( + &self, + calendar_id: &str, + ) -> futures::stream::BoxStream<'static, Result> { + use futures::StreamExt; + let uuid = match Uuid::parse_str(calendar_id) { + Ok(u) => u, + Err(_) => { + return Box::pin(futures::stream::once(async { + Err(DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + )) + })); + } + }; + Box::pin( + self.event_repository + .stream_events_uid_order(uuid) + .map(|r| r.map(CalendarEventDto::from)), + ) + } + async fn list_events_by_calendar_paginated( &self, calendar_id: &str, diff --git a/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs b/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs index ed560275..bd3d487a 100644 --- a/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs +++ b/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs @@ -16,6 +16,31 @@ impl CalendarEventPgRepository { pub fn new(pool: Arc) -> Self { Self { pool } } + + /// Shared row → entity mapping (the inline shape every listing + /// method uses, factored for the cursor stream). + fn row_to_event(row: &sqlx::postgres::PgRow) -> CalendarEventRepositoryResult { + let mut event = CalendarEvent::with_id( + row.get("id"), + row.get("calendar_id"), + row.get("summary"), + row.get::, _>("description"), + row.get::, _>("location"), + row.get("start_time"), + row.get("end_time"), + row.get("all_day"), + row.get::, _>("rrule"), + row.get("ical_uid"), + row.get("ical_data"), + row.get("created_at"), + row.get("updated_at"), + ) + .map_err(|e| { + DomainError::database_error(format!("Error creating calendar event: {}", e)) + })?; + event.set_recurrence_id(row.get::>, _>("recurrence_id")); + Ok(event) + } } impl CalendarEventRepository for CalendarEventPgRepository { @@ -547,6 +572,57 @@ impl CalendarEventRepository for CalendarEventPgRepository { Ok(result.rows_affected() as i64) } + fn stream_events_uid_order( + &self, + calendar_id: Uuid, + ) -> futures::stream::BoxStream<'static, CalendarEventRepositoryResult> { + // ONE ordered scan for the whole calendar, served through a PG + // cursor (`fetch`) so only a window of rows is in flight. The + // window function puts every UID's rows adjacent, bundles + // ordered by first occurrence — exactly the first-appearance + // order the buffered `ORDER BY start_time` listing produced + // after grouping — with the master row first inside each UID. + // + // The first streaming shape hydrated pages via + // `ical_uid = ANY(page)`: ~20 µs per index descent made the + // total wall 3-4x the buffered single scan (measured in + // benches/ROUND5.md). This keeps the buffered path's one + // scan+sort while bounding memory to a page. + let pool = self.pool.clone(); + let stream: futures::stream::BoxStream< + 'static, + CalendarEventRepositoryResult, + > = Box::pin(async_stream::try_stream! { + let mut conn = pool.acquire().await.map_err(|e| { + DomainError::database_error(format!("Failed to acquire connection: {}", e)) + })?; + let mut rows = sqlx::query( + r#" + SELECT + id, calendar_id, summary, description, location, + start_time, end_time, all_day, rrule, + created_at, updated_at, ical_uid, ical_data, recurrence_id + FROM caldav.calendar_events + WHERE calendar_id = $1 + ORDER BY MIN(start_time) OVER (PARTITION BY ical_uid), + ical_uid, + (recurrence_id IS NOT NULL), + start_time + "#, + ) + .bind(calendar_id) + .fetch(&mut *conn); + + use futures::TryStreamExt; + while let Some(row) = rows.try_next().await.map_err(|e| { + DomainError::database_error(format!("Failed to stream events: {}", e)) + })? { + yield Self::row_to_event(&row)?; + } + }); + stream + } + async fn list_events_by_calendar_paginated( &self, calendar_id: &Uuid, diff --git a/src/infrastructure/repositories/pg/drive_pg_repository.rs b/src/infrastructure/repositories/pg/drive_pg_repository.rs index 4b007d1f..c070f274 100644 --- a/src/infrastructure/repositories/pg/drive_pg_repository.rs +++ b/src/infrastructure/repositories/pg/drive_pg_repository.rs @@ -621,13 +621,14 @@ impl DriveRepository for DrivePgRepository { async fn list_readable_by( &self, caller_id: Uuid, - ) -> Result, DriveRepositoryError> { + ) -> Result>, DriveRepositoryError> { // 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 + // freshness/invalidation contract. The Arc is handed to callers + // directly — a warm hit is a refcount bump, not a deep clone of + // every row's Strings. + self.readable_cache .try_get_with(caller_id, async move { self.query_readable_by(caller_id).await.map(Arc::new) }) @@ -635,8 +636,7 @@ impl DriveRepository for DrivePgRepository { .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> { diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index fefc666f..7fa739de 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -21,8 +21,9 @@ use axum::{ http::{HeaderName, Request, StatusCode, header}, response::Response, }; -use bytes::Buf; +use bytes::{Buf, Bytes}; use percent_encoding::percent_decode_str; +use quick_xml::Writer; use std::fmt::Write; use std::sync::Arc; @@ -33,7 +34,7 @@ use crate::application::adapters::caldav_adapter::{ use crate::application::adapters::uid_from_multiget_href; use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType}; use crate::application::dtos::calendar_dto::{ - CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto, + CalendarEventDto, CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto, }; use crate::application::ports::calendar_ports::CalendarUseCase; use crate::application::services::calendar_service::CalendarService; @@ -47,6 +48,249 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); /// Prevents OOM/DoS via unbounded body buffering. const MAX_CALDAV_BODY: usize = 1_048_576; +/// Minimum rows per emitted page for the streaming CalDAV emitters. +/// Pages only cut at UID boundaries (the cursor delivers same-UID rows +/// adjacent), so a master + its exception overrides always land in one +/// chunk and peak memory is one page of DTOs + its XML instead of the +/// whole calendar twice. +const CALDAV_STREAM_PAGE_EVENTS: usize = 500; + +/// Streamed multistatus REPORT: header chunk, one chunk per hydrated +/// UID page, footer chunk. Byte-compatible with the buffered +/// `generate_calendar_events_response` output (same bundle order: +/// `(MIN(start_time), uid)` = first appearance in the start_time +/// listing). TTFB becomes the first page instead of the full +/// generation; the whole-calendar DTO Vec is never materialised. +fn build_streaming_report_response( + calendar_service: Arc, + calendar_id: String, + report: CalDavReportType, + base_href: String, + user_id: uuid::Uuid, +) -> Response { + let stream = async_stream::try_stream! { + let mut buf = Vec::with_capacity(256); + { + let mut w = Writer::new(&mut buf); + CalDavAdapter::write_caldav_multistatus_start(&mut w) + .map_err(|e| std::io::Error::other(e.to_string()))?; + } + yield Bytes::from(buf); + + // ONE server-side scan+sort in bundle order streamed through a + // cursor — the same aggregate work the buffered path paid, but + // only a page of rows resident. Pages cut at UID boundaries. + { + use futures::TryStreamExt; + let mut rows = calendar_service + .stream_events_uid_order(&calendar_id, user_id) + .await + .map_err(|e| std::io::Error::other(e.to_string()))?; + let mut page: Vec = + Vec::with_capacity(CALDAV_STREAM_PAGE_EVENTS + 32); + loop { + let next = rows + .try_next() + .await + .map_err(|e| std::io::Error::other(e.to_string()))?; + let flush = match &next { + Some(ev) => { + page.len() >= CALDAV_STREAM_PAGE_EVENTS + && page.last().is_some_and(|p| p.ical_uid != ev.ical_uid) + } + None => !page.is_empty(), + }; + if flush { + let mut chunk = Vec::with_capacity(page.len() * 1024 + 128); + { + let mut w = Writer::new(&mut chunk); + CalDavAdapter::write_report_page(&mut w, &page, &report, &base_href) + .map_err(|e| std::io::Error::other(e.to_string()))?; + } + page.clear(); + yield Bytes::from(chunk); + } + match next { + Some(ev) => page.push(ev), + None => break, + } + } + } + + let mut buf = Vec::with_capacity(32); + { + let mut w = Writer::new(&mut buf); + CalDavAdapter::write_caldav_multistatus_end(&mut w) + .map_err(|e| std::io::Error::other(e.to_string()))?; + } + yield Bytes::from(buf); + }; + + use futures::TryStreamExt; + let stream = stream + .map_err(|e: std::io::Error| -> Box { Box::new(e) }); + + Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from_stream(stream)) + .unwrap() +} + +/// Streamed depth-1 collection PROPFIND: head (multistatus + the +/// calendar's own response), one chunk per hydrated UID page, footer. +#[allow(clippy::too_many_arguments)] +fn build_streaming_collection_propfind( + calendar_service: Arc, + calendar: crate::application::dtos::calendar_dto::CalendarDto, + propfind_request: PropFindRequest, + calendar_id: String, + base_href: String, + caller_id: String, + user_id: uuid::Uuid, +) -> Response { + let stream = async_stream::try_stream! { + let mut buf = Vec::with_capacity(2048); + { + let mut w = Writer::new(&mut buf); + CalDavAdapter::write_collection_head(&mut w, &calendar, &propfind_request, &base_href, &caller_id) + .map_err(|e| std::io::Error::other(e.to_string()))?; + } + yield Bytes::from(buf); + + { + use futures::TryStreamExt; + let mut rows = calendar_service + .stream_events_uid_order(&calendar_id, user_id) + .await + .map_err(|e| std::io::Error::other(e.to_string()))?; + let mut page: Vec = + Vec::with_capacity(CALDAV_STREAM_PAGE_EVENTS + 32); + loop { + let next = rows + .try_next() + .await + .map_err(|e| std::io::Error::other(e.to_string()))?; + let flush = match &next { + Some(ev) => { + page.len() >= CALDAV_STREAM_PAGE_EVENTS + && page.last().is_some_and(|p| p.ical_uid != ev.ical_uid) + } + None => !page.is_empty(), + }; + if flush { + let mut chunk = Vec::with_capacity(page.len() * 512 + 128); + { + let mut w = Writer::new(&mut chunk); + CalDavAdapter::write_collection_event_page(&mut w, &page, &base_href) + .map_err(|e| std::io::Error::other(e.to_string()))?; + } + page.clear(); + yield Bytes::from(chunk); + } + match next { + Some(ev) => page.push(ev), + None => break, + } + } + } + + let mut buf = Vec::with_capacity(32); + { + let mut w = Writer::new(&mut buf); + CalDavAdapter::write_caldav_multistatus_end(&mut w) + .map_err(|e| std::io::Error::other(e.to_string()))?; + } + yield Bytes::from(buf); + }; + + use futures::TryStreamExt; + let stream = stream + .map_err(|e: std::io::Error| -> Box { Box::new(e) }); + + Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from_stream(stream)) + .unwrap() +} + +/// Streamed whole-calendar `.ics` GET: VCALENDAR header, one chunk per +/// hydrated UID page (each row's stored VEVENT chunk served verbatim), +/// `END:VCALENDAR` footer. +fn build_streaming_calendar_ics( + calendar_service: Arc, + calendar_id: String, + calendar_name: String, + calendar_etag: String, + user_id: uuid::Uuid, +) -> Response { + let stream = async_stream::try_stream! { + let mut head = String::with_capacity(128); + let _ = write!( + head, + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\nX-WR-CALNAME:{}\r\n", + calendar_name + ); + yield Bytes::from(head); + + { + use futures::TryStreamExt; + let mut rows = calendar_service + .stream_events_uid_order(&calendar_id, user_id) + .await + .map_err(|e| std::io::Error::other(e.to_string()))?; + let mut page: Vec = + Vec::with_capacity(CALDAV_STREAM_PAGE_EVENTS + 32); + loop { + let next = rows + .try_next() + .await + .map_err(|e| std::io::Error::other(e.to_string()))?; + let flush = match &next { + Some(ev) => { + page.len() >= CALDAV_STREAM_PAGE_EVENTS + && page.last().is_some_and(|p| p.ical_uid != ev.ical_uid) + } + None => !page.is_empty(), + }; + if flush { + let mut chunk = String::with_capacity(page.len() * 384); + for group in group_events_by_uid(&page) { + for event in group { + if let Some(vevent) = extract_vevent_chunk(&event.ical_data) { + chunk.push_str(vevent); + if !chunk.ends_with('\n') { + chunk.push_str("\r\n"); + } + } + } + } + page.clear(); + yield Bytes::from(chunk); + } + match next { + Some(ev) => page.push(ev), + None => break, + } + } + } + + yield Bytes::from_static(b"END:VCALENDAR\r\n"); + }; + + use futures::TryStreamExt; + let stream = stream + .map_err(|e: std::io::Error| -> Box { Box::new(e) }); + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/calendar; charset=utf-8") + .header(header::ETAG, format!("\"{}\"", calendar_etag)) + .body(Body::from_stream(stream)) + .unwrap() +} + /// Creates CalDAV routes with full path prefixes. /// /// Uses `merge()` instead of `nest()` to avoid Axum's trailing-slash routing gap. @@ -320,15 +564,23 @@ async fn handle_propfind( }; if let Ok(calendar) = calendar_result { - // Valid calendar ID — return calendar collection - let events = if depth != "0" { - calendar_service - .list_events(first_segment, None, None, user.id) - .await - .unwrap_or_default() - } else { - vec![] - }; + // Valid calendar ID — return calendar collection. + // Depth-1 streams the event listing page by page + // (whole-calendar responses used to materialise every + // DTO + the full multistatus in RAM); depth-0 has no + // event section and keeps the tiny buffered path. + if depth != "0" { + let base_href = format!("/caldav/{}/", first_segment); + return Ok(build_streaming_collection_propfind( + calendar_service.clone(), + calendar, + propfind_request, + first_segment.to_string(), + base_href, + caller_id.clone(), + user.id, + )); + } let base_href = &format!("/caldav/{}/", first_segment); let mut response_body = Vec::new(); @@ -336,7 +588,7 @@ async fn handle_propfind( CalDavAdapter::generate_calendar_collection_propfind( &mut response_body, &calendar, - &events, + &[], &propfind_request, base_href, &depth, @@ -407,14 +659,20 @@ async fn handle_propfind( .await .map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?; - let events = if depth != "0" { - calendar_service - .list_events(sub_parts[0], None, None, user.id) - .await - .unwrap_or_default() - } else { - vec![] - }; + // Same streaming/buffered split as the + // single-segment collection branch above. + if depth != "0" { + let base_href = format!("/caldav/{}/{}/", first_segment, sub_parts[0]); + return Ok(build_streaming_collection_propfind( + calendar_service.clone(), + cal, + propfind_request, + sub_parts[0].to_string(), + base_href, + caller_id.clone(), + user.id, + )); + } let base_href = &format!("/caldav/{}/{}/", first_segment, sub_parts[0]); let mut response_body = Vec::new(); @@ -422,7 +680,7 @@ async fn handle_propfind( CalDavAdapter::generate_calendar_collection_propfind( &mut response_body, &cal, - &events, + &[], &propfind_request, base_href, &depth, @@ -500,6 +758,33 @@ async fn handle_report( return Err(AppError::bad_request("Calendar ID required in path")); } + // Whole-calendar shapes (no-range calendar-query, sync-collection) + // stream: header + one chunk per hydrated UID page + footer, instead + // of materialising every DTO AND the full multistatus in RAM with + // TTFB = complete generation. Bounded shapes (time-range query, + // multiget) keep the buffered path. + if matches!( + &report, + CalDavReportType::CalendarQuery { + time_range: None, + .. + } | CalDavReportType::SyncCollection { .. } + ) { + // Surface not-found / authz before committing to a 207 stream. + calendar_service + .get_calendar(calendar_id, user.id) + .await + .map_err(AppError::from)?; + let base_href = format!("/caldav/{}/", calendar_id); + return Ok(build_streaming_report_response( + calendar_service.clone(), + calendar_id.to_string(), + report, + base_href, + user.id, + )); + } + let events = match &report { CalDavReportType::CalendarQuery { time_range, .. } => { if let Some((start, end)) = time_range { @@ -508,10 +793,7 @@ async fn handle_report( .await .map_err(AppError::from)? } else { - calendar_service - .list_events(calendar_id, None, None, user.id) - .await - .map_err(AppError::from)? + unreachable!("no-range calendar-query streams above") } } CalDavReportType::CalendarMultiget { hrefs, .. } => { @@ -528,10 +810,9 @@ async fn handle_report( .await .map_err(AppError::from)? } - CalDavReportType::SyncCollection { .. } => calendar_service - .list_events(calendar_id, None, None, user.id) - .await - .map_err(AppError::from)?, + CalDavReportType::SyncCollection { .. } => { + unreachable!("sync-collection streams above") + } }; let base_href = &format!("/caldav/{}/", calendar_id); @@ -686,31 +967,27 @@ async fn handle_get( let calendar_id = parts[0]; if parts.len() < 2 { - // GET on calendar collection — return all events, folded + // GET on calendar collection — stream all events, folded // per UID so master + exception overrides live in ONE // VCALENDAR body per resource (RFC 4791 §4.1 + RFC 5545 - // §3.6.1). Serves each row's stored `ical_data` verbatim - // via `bundle_to_calendar_body`; VTIMEZONE / VALARM / - // ATTENDEE / CATEGORIES / X-* survive because we no - // longer regenerate the body from DTO fields. - let events = calendar_service - .list_events(calendar_id, None, None, user.id) - .await - .map_err(AppError::from)?; - + // §3.6.1). Each row's stored `ical_data` VEVENT chunk is + // served verbatim; VTIMEZONE / VALARM / ATTENDEE / + // CATEGORIES / X-* survive because the body is never + // regenerated from DTO fields. Streaming (header + one + // chunk per hydrated UID page + footer) replaces the old + // whole-calendar String build. let calendar = calendar_service .get_calendar(calendar_id, user.id) .await .map_err(AppError::from)?; - let ical = generate_full_calendar_ical(&calendar.name, &events); - - Ok(Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/calendar; charset=utf-8") - .header(header::ETAG, format!("\"{}\"", calendar.id)) - .body(Body::from(ical)) - .unwrap()) + Ok(build_streaming_calendar_ics( + calendar_service.clone(), + calendar_id.to_string(), + calendar.name, + calendar.id, + user.id, + )) } else { // GET on individual event resource — fetch ALL rows for // this UID (master + any exception overrides) and emit @@ -754,38 +1031,6 @@ async fn handle_get( } } -/// Emit a full VCALENDAR body for the entire calendar, with rows -/// grouped by UID so each recurring event's master + exception -/// overrides live under one iCalendar resource. Each row's stored -/// `ical_data` VEVENT chunk is served verbatim. -fn generate_full_calendar_ical( - calendar_name: &str, - events: &[crate::application::dtos::calendar_dto::CalendarEventDto], -) -> String { - // Pre-estimate: ~200 bytes header + ~320 bytes per event. - let mut buf = String::with_capacity(256 + events.len() * 320); - let _ = write!( - buf, - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\nX-WR-CALNAME:{}\r\n", - calendar_name - ); - // Group + append each row's stored VEVENT chunk. Malformed - // rows are silently skipped (defensive) — the bulk-GET body - // survives the rest. - for group in group_events_by_uid(events) { - for event in group { - if let Some(chunk) = extract_vevent_chunk(&event.ical_data) { - buf.push_str(chunk); - if !buf.ends_with('\n') { - buf.push_str("\r\n"); - } - } - } - } - buf.push_str("END:VCALENDAR\r\n"); - buf -} - // NOTE: the pre-phase-4 `generate_event_ical` + `write_vevent` // helpers were removed. They regenerated the response body from // DTO fields, which (a) silently dropped every property outside diff --git a/src/interfaces/api/handlers/drive_handler.rs b/src/interfaces/api/handlers/drive_handler.rs index 8af5633c..de1f6681 100644 --- a/src/interfaces/api/handlers/drive_handler.rs +++ b/src/interfaces/api/handlers/drive_handler.rs @@ -50,7 +50,7 @@ pub async fn list_drives( match state.drive_repo.list_readable_by(caller_id).await { Ok(drives) => { - let dtos: Vec = drives.into_iter().map(DriveDto::from).collect(); + let dtos: Vec = drives.iter().cloned().map(DriveDto::from).collect(); (StatusCode::OK, Json(dtos)).into_response() } Err(e) => { diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index cb887a58..340e37a0 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -10,7 +10,8 @@ use tracing::info; use utoipa::ToSchema; use crate::application::dtos::display_helpers::{ - category_for, format_file_size, icon_class_for, icon_special_class_for, + category_for, format_file_size, icon_class_for, icon_special_class_for, intern_display, + intern_mime, }; use crate::application::dtos::favorites_dto::{ FavoritesResourceItemDto, FavoritesResourcesDto, FavoritesResourcesQuery, @@ -214,9 +215,9 @@ pub async fn list_favorites_resources( created_at: row.resource_created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, - icon_class: std::sync::Arc::from("fas fa-folder"), - icon_special_class: std::sync::Arc::from("folder-icon"), - category: std::sync::Arc::from("Folder"), + icon_class: intern_display("fas fa-folder"), + icon_special_class: intern_display("folder-icon"), + category: intern_display("Folder"), // §14 provenance not selected by the favorites query. created_by: None, updated_by: None, @@ -249,15 +250,15 @@ pub async fn list_favorites_resources( name: row.name.clone(), path, size: size_bytes, - mime_type: std::sync::Arc::from(mime), + mime_type: intern_mime(mime), folder_id: row.parent_id.map(|u| u.to_string()), created_at: row.resource_created_at.timestamp() as u64, modified_at: modified_at_u, - icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)), - icon_special_class: std::sync::Arc::from(icon_special_class_for( + icon_class: intern_display(icon_class_for(&row.name, mime)), + icon_special_class: intern_display(icon_special_class_for( &row.name, mime, )), - category: std::sync::Arc::from(category_for(&row.name, mime)), + category: intern_display(category_for(&row.name, mime)), size_formatted: format_file_size(size_bytes), sort_date: None, content_hash, diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 084d0048..f5c525f4 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -8,7 +8,8 @@ use std::collections::HashMap; use std::sync::Arc; use crate::application::dtos::display_helpers::{ - category_for, format_file_size, icon_class_for, icon_special_class_for, + category_for, format_file_size, icon_class_for, icon_special_class_for, intern_display, + intern_mime, }; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::{ @@ -482,9 +483,9 @@ pub async fn list_folder_resources( created_at: row.created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, - icon_class: Arc::from("fas fa-folder"), - icon_special_class: Arc::from("folder-icon"), - category: Arc::from("Folder"), + icon_class: intern_display("fas fa-folder"), + icon_special_class: intern_display("folder-icon"), + category: intern_display("Folder"), // §14 provenance not selected by the resources query. created_by: None, updated_by: None, @@ -518,13 +519,15 @@ pub async fn list_folder_resources( name: row.name.clone(), path: String::new(), size: size_bytes, - mime_type: Arc::from(mime), + mime_type: intern_mime(mime), folder_id: row.parent_id.map(|u| u.to_string()), created_at: row.created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, - icon_class: Arc::from(icon_class_for(&row.name, mime)), - icon_special_class: Arc::from(icon_special_class_for(&row.name, mime)), - category: Arc::from(category_for(&row.name, mime)), + icon_class: intern_display(icon_class_for(&row.name, mime)), + icon_special_class: intern_display(icon_special_class_for( + &row.name, mime, + )), + category: intern_display(category_for(&row.name, mime)), size_formatted: format_file_size(size_bytes), sort_date: None, content_hash, diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 690548d7..7563f877 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -8,7 +8,8 @@ use std::sync::Arc; use tracing::info; use crate::application::dtos::display_helpers::{ - category_for, format_file_size, icon_class_for, icon_special_class_for, + category_for, format_file_size, icon_class_for, icon_special_class_for, intern_display, + intern_mime, }; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; @@ -230,9 +231,9 @@ pub async fn list_recent_resources( created_at: row.resource_created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, - icon_class: std::sync::Arc::from("fas fa-folder"), - icon_special_class: std::sync::Arc::from("folder-icon"), - category: std::sync::Arc::from("Folder"), + icon_class: intern_display("fas fa-folder"), + icon_special_class: intern_display("folder-icon"), + category: intern_display("Folder"), // §14 provenance not selected by the recents query. created_by: None, updated_by: None, @@ -263,15 +264,15 @@ pub async fn list_recent_resources( name: row.name.clone(), path, size: size_bytes, - mime_type: std::sync::Arc::from(mime), + mime_type: intern_mime(mime), folder_id: row.parent_id.map(|u| u.to_string()), created_at: row.resource_created_at.timestamp() as u64, modified_at: modified_at_u, - icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)), - icon_special_class: std::sync::Arc::from(icon_special_class_for( + icon_class: intern_display(icon_class_for(&row.name, mime)), + icon_special_class: intern_display(icon_special_class_for( &row.name, mime, )), - category: std::sync::Arc::from(category_for(&row.name, mime)), + category: intern_display(category_for(&row.name, mime)), size_formatted: format_file_size(size_bytes), sort_date: None, content_hash, diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 1a5905ab..561a080c 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -20,6 +20,7 @@ use uuid::Uuid; use crate::application::adapters::webdav_adapter::{ LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property, }; +use crate::application::dtos::display_helpers::intern_display; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::application::ports::authorization_ports::AuthorizationEngine; @@ -65,10 +66,6 @@ const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC .remove(b'@'); /// Percent-encode a single URI path segment (folder/file name). -fn encode_path_segment(segment: &str) -> String { - utf8_percent_encode(segment, PATH_SEGMENT_ENCODE_SET).to_string() -} - /// Percent-encode a full slash-separated path, encoding each segment individually. pub(crate) fn encode_uri_path(path: &str) -> String { use std::fmt::Write as _; @@ -373,14 +370,14 @@ async fn lookup_drive_selector( .list_readable_by(user_id) .await .map_err(|e| AppError::internal_error(format!("Failed to list drives: {:?}", e)))?; - for d in visible { + for d in visible.iter() { if let Some(uuid) = uuid_opt && d.drive.id == uuid { - return Ok(d); + return Ok(d.clone()); } if d.root_folder_name == selector_decoded.as_ref() { - return Ok(d); + return Ok(d.clone()); } } Err(AppError::not_found(format!( @@ -552,9 +549,9 @@ async fn handle_propfind( created_at: Utc::now().timestamp() as u64, modified_at: Utc::now().timestamp() as u64, is_root: true, - icon_class: Arc::from("fas fa-folder"), - icon_special_class: Arc::from("folder-icon"), - category: Arc::from("Folder"), + icon_class: intern_display("fas fa-folder"), + icon_special_class: intern_display("folder-icon"), + category: intern_display("Folder"), created_by: None, updated_by: None, }; @@ -815,7 +812,11 @@ async fn build_streaming_propfind_response( let mut w = Writer::new(&mut chunk); for subfolder in batch.iter() { let child_dead = dead_props_for(&subfolder.id, &subfolder_deads); - let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name)); + let href = format!( + "{}{}/", + base_href, + utf8_percent_encode(&subfolder.name, PATH_SEGMENT_ENCODE_SET) + ); WebDavAdapter::write_folder_entry_with_dead_props(&mut w, subfolder, &propfind_request, &href, child_dead, quota) .map_err(|e| std::io::Error::other(e.to_string()))?; } @@ -856,7 +857,11 @@ async fn build_streaming_propfind_response( let mut w = Writer::new(&mut chunk); for file in batch.iter() { let child_dead = dead_props_for(&file.id, &file_deads); - let href = format!("{}{}", base_href, encode_path_segment(&file.name)); + let href = format!( + "{}{}", + base_href, + utf8_percent_encode(&file.name, PATH_SEGMENT_ENCODE_SET) + ); WebDavAdapter::write_file_entry_with_dead_props(&mut w, file, &propfind_request, &href, child_dead) .map_err(|e| std::io::Error::other(e.to_string()))?; } diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index 2843fa1b..967fe4de 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -211,7 +211,8 @@ pub async fn auth_middleware( role, }); request.extensions_mut().insert(current_user); - tracing::Span::current().record("user_id", user_id.to_string()); + tracing::Span::current() + .record("user_id", tracing::field::display(user_id)); return Ok(next.run(request).await); } Err(e) => { @@ -258,7 +259,8 @@ pub async fn auth_middleware( role, }); request.extensions_mut().insert(current_user); - tracing::Span::current().record("user_id", user_id.to_string()); + tracing::Span::current() + .record("user_id", tracing::field::display(user_id)); return Ok(next.run(request).await); } Err(e) => { @@ -323,7 +325,8 @@ pub async fn auth_middleware( }); request.extensions_mut().insert(current_user); request.extensions_mut().insert(CookieAuthenticated); - tracing::Span::current().record("user_id", user_id.to_string()); + tracing::Span::current() + .record("user_id", tracing::field::display(user_id)); return Ok(next.run(request).await); } LiveRole::Revoked => { diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index a12757d8..bd067d31 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -1536,6 +1536,19 @@ fn build_nc_streaming_propfind( // ── Children (only if Depth != 0) ──────────────────────────── if depth != "0" { + // Encoded href prefix for every child: username + parent + // path encode ONCE here — the old per-row `nc_href` call + // re-split and re-encoded the constant prefix for each of + // the up-to-500 children of every page. + let child_href_prefix = { + let base = nc_href(&username, &subpath); + if base.ends_with('/') { + base + } else { + format!("{base}/") + } + }; + // Files in pages (keyset cursor — O(page) per page instead of // the quadratic LIMIT/OFFSET walk). let mut after_name: Option = None; @@ -1573,12 +1586,12 @@ fn build_nc_streaming_propfind( let mut xml = Writer::new(&mut chunk); for file in batch.iter() { let dead = dead_props_for(&file.id, &file_deads); - let child_sub = if subpath.is_empty() { - file.name.clone() - } else { - format!("{}/{}", subpath.trim_end_matches('/'), file.name) - }; - let href = nc_href(&username, &child_sub); + // Only the name varies per row — the encoded + // username + parent prefix is computed once + // outside the loops (the old `nc_href` call + // re-encoded both for every child). + let href = + format!("{}{}", child_href_prefix, urlencoding::encode(&file.name)); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_file_response(&mut xml, file, &href, (fid, oc_id.as_deref()), &username, &favs, dead) @@ -1630,12 +1643,10 @@ fn build_nc_streaming_propfind( let mut xml = Writer::new(&mut chunk); for sf in batch.iter() { let dead = dead_props_for(&sf.id, &sub_deads); - let child_sub = if subpath.is_empty() { - sf.name.clone() - } else { - format!("{}/{}", subpath.trim_end_matches('/'), sf.name) - }; - let href = nc_collection_href(&username, &child_sub); + // Collections carry the trailing slash; prefix + // precomputed once like the file loop above. + let href = + format!("{}{}/", child_href_prefix, urlencoding::encode(&sf.name)); let fid = sub_id_map.get(&sf.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_folder_response(&mut xml, sf, &href, (fid, oc_id.as_deref()), &username, &favs, quota, dead)