63cf6646d0
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
535 lines
20 KiB
Rust
535 lines
20 KiB
Rust
//! 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);
|
|
}
|
|
}
|