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:
Claude
2026-07-17 15:19:00 +00:00
parent 12dc648cff
commit 63cf6646d0
24 changed files with 2008 additions and 261 deletions
+16
View File
@@ -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
+142
View File
@@ -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).
+534
View File
@@ -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);
}
}
+4 -4
View File
@@ -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!(
+570
View File
@@ -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);
}
}
+90 -46
View File
@@ -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(())
}
+25 -13
View File
@@ -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(&quoted)))?;
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(&quoted)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
}
("DAV:", "getcontenttype") => {
+16
View File
@@ -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,
+19 -13
View File
@@ -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,
+1 -1
View File
@@ -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,
+5 -1
View File
@@ -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> {
+324 -79
View File
@@ -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
+1 -1
View File
@@ -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,
+11 -8
View File
@@ -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,
+17 -12
View File
@@ -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()))?;
}
+6 -3
View File
@@ -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 => {
+23 -12
View File
@@ -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)