perf: round 5 — CalDAV cursor streaming, SPA interning gaps, NC href prefix, per-request micro-allocs
Seven benchmark-gated changes (benches/ROUND5.md; BEFORE/AFTER bench + equivalence gate each, rollback rule as ROUND2-4 — two intermediate CalDAV shapes measured worse and were themselves rolled back before shipping): - CalDAV whole-calendar responses (REPORT no-range/sync-collection, depth-1 collection PROPFIND, .ics GET): buffered double-residency → ONE window-ordered scan (MIN(start_time) OVER (PARTITION BY ical_uid)) streamed through a PG cursor, pages cut at UID boundaries. TTFB 23.3→11.0 ms (2.1x), peak heap 14.2→8.0 MiB at 4k events / 45→24 MiB at 12k, wall +9-15% (documented trade, ZIP-streaming class); both multistatus and ICS byte-identical to the buffered output. Rejected shapes kept in the doc: per-page GROUP-BY keyset (3-4x wall) and per-uid ANY hydration (~20 µs/index descent). - SPA listing interning gaps: folder/recent/favorites resources handlers (and the WebDAV pseudo-root) called raw Arc::from per row for the closed display set ROUND3 interned — now intern_display/intern_mime, 4→0 allocs/row, byte-identical Arc contents. - NC PROPFIND child hrefs: username + parent path encoded once per request instead of per child (543→165 ns/row, 13→4 allocs); native WebDAV href drops its intermediate encode String. - suggest enrichment: entity clone + field re-clones per keystroke row → consume + move (166.5→126.8 µs/200 rows, 20→7 allocs/row). - list_readable_by returns the cache's Arc (246→128 ns warm hit, 4→0 allocs) — deep Vec clone per DAV-selector request removed. - CardDAV REPORT: borrowed props, reused href buffer, exact-size etag quoting (3.04→2.34 ms per 5k-contact getetag poll). - Auth span records: user_id.to_string() per request ×3 → tracing::field::display. Checks: cargo fmt, clippy --all-features --all-targets -D warnings, cargo test --workspace (523 passed). Follow-ups (CardDAV streaming, &[&str] id batches, ::text UUID casts A/B, share-landing join) recorded in benches/ROUND5.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
This commit is contained in:
+16
@@ -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
|
||||
|
||||
@@ -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<str>` 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<str>` claims.
|
||||
- Grouped/swimlane files view virtualization (frontend, carried since
|
||||
ROUND3).
|
||||
@@ -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<Utc>, 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<DateTime<Utc>> = 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<u8>) {
|
||||
let t0 = Instant::now();
|
||||
let events: Vec<CalendarEventDto> = 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<u8>) {
|
||||
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<CalendarEventDto> = 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<CalendarEventDto> = 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>) -> 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<CalendarEventDto> = 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<CalendarEventDto> = 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<Vec<CalendarEventDto>> = {
|
||||
use futures::TryStreamExt;
|
||||
let mut rows = repo.stream_events_uid_order(seeded.calendar_id);
|
||||
let mut pages = Vec::new();
|
||||
let mut page: Vec<CalendarEventDto> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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<DriveWithRootName>`
|
||||
//! 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>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
fn time_passes<T>(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<T>(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<File> {
|
||||
(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<SearchSuggestionItem> {
|
||||
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<File>, q: &str) -> Vec<SearchSuggestionItem> {
|
||||
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<DriveWithRootName> {
|
||||
(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<ContactDto> {
|
||||
(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<u8>,
|
||||
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<Uuid, Arc<Vec<DriveWithRootName>>> =
|
||||
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<DriveWithRootName> = (*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<String> = (0..rows).map(|i| format!("informe-{i}.pdf")).collect();
|
||||
let mime = "application/pdf";
|
||||
let row_before = |name: &str| {
|
||||
(
|
||||
Arc::<str>::from(mime),
|
||||
Arc::<str>::from(icon_class_for(name, mime)),
|
||||
Arc::<str>::from(icon_special_class_for(name, mime)),
|
||||
Arc::<str>::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<String> = (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);
|
||||
}
|
||||
}
|
||||
@@ -1064,15 +1064,44 @@ 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" {
|
||||
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<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
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<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
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
|
||||
@@ -1097,8 +1126,7 @@ impl CalDavAdapter {
|
||||
|
||||
// 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::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
|
||||
// getcontenttype
|
||||
@@ -1110,8 +1138,7 @@ impl CalDavAdapter {
|
||||
|
||||
// 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::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")))?;
|
||||
@@ -1123,12 +1150,56 @@ impl CalDavAdapter {
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the CalDAV `<D:multistatus>` 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<W: Write>(xml_writer: &mut Writer<W>) -> 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<W: Write>(xml_writer: &mut Writer<W>) -> 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<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
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<W: Write>(
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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") => {
|
||||
|
||||
@@ -116,6 +116,12 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> Result<Vec<CalendarEventDto>, 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<CalendarEventDto, DomainError>>;
|
||||
async fn list_events_by_calendar_paginated(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
@@ -218,6 +224,16 @@ pub trait CalendarUseCase: Send + Sync + 'static {
|
||||
offset: Option<i64>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarEventDto>, 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<CalendarEventDto, DomainError>>,
|
||||
DomainError,
|
||||
>;
|
||||
async fn get_events_in_range(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
|
||||
@@ -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<CalendarEventDto, DomainError>>,
|
||||
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,
|
||||
|
||||
@@ -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<Uuid> = 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();
|
||||
@@ -521,28 +521,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,
|
||||
|
||||
@@ -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<Uuid> = 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",
|
||||
|
||||
@@ -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<CalendarEvent>;
|
||||
|
||||
/// 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<CalendarEvent>>;
|
||||
|
||||
/// Lists all events in a specific calendar
|
||||
async fn list_events_by_calendar(
|
||||
&self,
|
||||
|
||||
@@ -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<Vec<…>>`: 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<Vec<DriveWithRootName>, DriveRepositoryError>;
|
||||
) -> Result<std::sync::Arc<Vec<DriveWithRootName>>, DriveRepositoryError>;
|
||||
|
||||
/// `true` when the drive holds no live (non-trashed) folders other
|
||||
/// than its own root and no live files at all. Used by
|
||||
|
||||
@@ -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<CalendarEventDto, DomainError>> {
|
||||
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,
|
||||
|
||||
@@ -16,6 +16,31 @@ impl CalendarEventPgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> 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<CalendarEvent> {
|
||||
let mut event = CalendarEvent::with_id(
|
||||
row.get("id"),
|
||||
row.get("calendar_id"),
|
||||
row.get("summary"),
|
||||
row.get::<Option<String>, _>("description"),
|
||||
row.get::<Option<String>, _>("location"),
|
||||
row.get("start_time"),
|
||||
row.get("end_time"),
|
||||
row.get("all_day"),
|
||||
row.get::<Option<String>, _>("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::<Option<DateTime<Utc>>, _>("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<CalendarEvent>> {
|
||||
// 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<CalendarEvent>,
|
||||
> = 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,
|
||||
|
||||
@@ -621,13 +621,14 @@ impl DriveRepository for DrivePgRepository {
|
||||
async fn list_readable_by(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
|
||||
) -> Result<Arc<Vec<DriveWithRootName>>, 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<DriveRepositoryError>| {
|
||||
Arc::try_unwrap(e)
|
||||
.unwrap_or_else(|shared| DriveRepositoryError::StorageError(shared.to_string()))
|
||||
})?;
|
||||
Ok((*cached).clone())
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_all(&self) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
|
||||
|
||||
@@ -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<CalendarService>,
|
||||
calendar_id: String,
|
||||
report: CalDavReportType,
|
||||
base_href: String,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Response<Body> {
|
||||
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<CalendarEventDto> =
|
||||
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<dyn std::error::Error + Send + Sync> { 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<CalendarService>,
|
||||
calendar: crate::application::dtos::calendar_dto::CalendarDto,
|
||||
propfind_request: PropFindRequest,
|
||||
calendar_id: String,
|
||||
base_href: String,
|
||||
caller_id: String,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Response<Body> {
|
||||
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<CalendarEventDto> =
|
||||
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<dyn std::error::Error + Send + Sync> { 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<CalendarService>,
|
||||
calendar_id: String,
|
||||
calendar_name: String,
|
||||
calendar_etag: String,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Response<Body> {
|
||||
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<CalendarEventDto> =
|
||||
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<dyn std::error::Error + Send + Sync> { 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
|
||||
|
||||
@@ -50,7 +50,7 @@ pub async fn list_drives(
|
||||
|
||||
match state.drive_repo.list_readable_by(caller_id).await {
|
||||
Ok(drives) => {
|
||||
let dtos: Vec<DriveDto> = drives.into_iter().map(DriveDto::from).collect();
|
||||
let dtos: Vec<DriveDto> = drives.iter().cloned().map(DriveDto::from).collect();
|
||||
(StatusCode::OK, Json(dtos)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()))?;
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -1526,6 +1526,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<String> = None;
|
||||
@@ -1563,12 +1576,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)
|
||||
@@ -1620,12 +1633,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)
|
||||
|
||||
Reference in New Issue
Block a user