Files
Oxicloud/examples/bench_carddav_stream.rs
T
Claude 9729f033b2 perf: round 6 backend — CardDAV cursor streaming, borrowed NC id chain, binary UUID decode, one-alloc hex
Benchmark-gated (equivalence + BEFORE/AFTER in examples/bench_*, results
and reproduce commands in benches/ROUND6.md):

- CardDAV whole-book REPORT + depth-1 PROPFIND stream through a PG
  cursor (stream_contacts_by_book, 500-contact pages) instead of
  materialising every vCard twice: 8 000 contacts TTFB 37.4 → 7.6 ms
  (4.9x), peak heap 19.0 → 7.0 MiB (2.7x), wall -23%; REPORT and
  PROPFIND byte-identical to the buffered writers.

- NC numeric-id chain fully borrowed: get_or_create_file_ids/folder_ids
  take &[&str] and return HashMap<Uuid, i64>; batch_resolve_ids callers
  (PROPFIND pages, REPORT, trashbin, OCS search) pass id slices and look
  up via nc_id_of. 2.006 → 0.006 allocs/child (334x), 1.53x wall per
  500-child page. batch_check_favorites binds &[&str] as text[].

- file_blob_read_repository listing SELECTs drop id::text/folder_id::text
  server casts: rows decode binary Uuid (16 vs 36 bytes on the wire) and
  render once in row_to_file. A/B on 500-row pages: 1.225 → 1.044 ms
  mean (1.17x), p95 1.686 → 1.345 (bench_uuid_text_cast; single-row,
  param and min() sites left as-is deliberately).

- IncrementalHasher::finalize_hex renders through common::fmt::hex_lower
  instead of one format! per digest byte: 18 → 1 (md5) / 35 → 1 (sha256)
  allocs per chunk finalize, 14-15x wall.

- Share landing overlaps the access-count UPDATE with the unlock fetch
  via tokio::join! (one round-trip off every public link hit).

- REJECTED by benchmark and reverted: try_join_all fan-out of the
  batch-favorites authz pre-check — 42.6 → 56.4 ms cold, 0.15 → 0.23 ms
  warm against local-socket PG (bench_favorites_authz kept as evidence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
2026-07-18 09:03:33 +00:00

410 lines
14 KiB
Rust

//! CardDAV whole-book response benchmark — buffered vs cursor streaming
//! (ROUND6).
//!
//! The REPORT path (addressbook-query, sync-collection) and the depth-1
//! collection PROPFIND materialised EVERY contact DTO of the book in
//! one Vec, then rendered the complete multistatus into a second in-RAM
//! buffer — the book resident twice, TTFB = full generation. AFTER
//! streams ONE ordered scan (`full_name, first_name, last_name`, the
//! buffered listing's order) through a PG cursor and emits fixed-size
//! pages (contacts carry no bundling constraint).
//!
//! Drives the REAL repository + adapter writers both ways at the repo
//! layer (authz identical both sides, excluded). Gates: streamed
//! concatenation byte-identical to the buffered output for the REPORT
//! (getetag poll shape) AND the collection PROPFIND (allprop), seeded
//! with strictly distinct names so ordering is deterministic.
//!
//! Run (needs Postgres up; reads DATABASE_URL from .env):
//! cargo run --release --features bench --example bench_carddav_stream
//! Tunables (env): BENCH_CONTACTS (8000), BENCH_PAGE (500), BENCH_PASSES (9).
use std::alloc::{GlobalAlloc, Layout, System};
use std::env;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use oxicloud::application::adapters::carddav_adapter::{CardDavAdapter, CardDavReportType};
use oxicloud::application::adapters::webdav_adapter::{
PropFindRequest, PropFindType, QualifiedName,
};
use oxicloud::application::dtos::address_book_dto::AddressBookDto;
use oxicloud::application::dtos::contact_dto::ContactDto;
use oxicloud::domain::repositories::contact_repository::ContactRepository;
use oxicloud::infrastructure::repositories::pg::ContactPgRepository;
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;
struct Seeded {
book_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_cardstream', 'bench_cardstream@bench.invalid', 'user') RETURNING id",
)
.fetch_one(pool)
.await
.expect("seed user");
let book_id: Uuid = sqlx::query_scalar(
"INSERT INTO carddav.address_books (id, name, owner_id)
VALUES (gen_random_uuid(), 'Libreta grande', $1) RETURNING id",
)
.bind(owner_id)
.fetch_one(pool)
.await
.expect("seed book");
let mut tx = pool.begin().await.expect("begin");
for i in 0..n {
// Strictly distinct full_names keep the listing order (and thus
// the byte gate) deterministic. Every production row carries its
// full serialized vCard — the payload whose double-residency the
// streaming path removes — so the seed does too (~250 B each).
let uid = format!("contact-{i:06}");
let vcard = format!(
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:Persona {i:06}\r\nN:Apellido{i};Nombre{i};;;\r\nEMAIL;TYPE=INTERNET:persona{i}@bench.invalid\r\nTEL;TYPE=CELL:+34 600 {i:06}\r\nORG:OxiCloud Bench\r\nNOTE:Fila sintetica del banco de pruebas CardDAV.\r\nEND:VCARD\r\n"
);
sqlx::query(
"INSERT INTO carddav.contacts
(id, address_book_id, uid, full_name, first_name, last_name, vcard, etag)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7)",
)
.bind(book_id)
.bind(&uid)
.bind(format!("Persona {i:06}"))
.bind(format!("Nombre{i}"))
.bind(format!("Apellido{i}"))
.bind(&vcard)
.bind(format!("{:016x}", (i as u64).wrapping_mul(2_654_435_761)))
.execute(&mut *tx)
.await
.expect("seed contact");
}
tx.commit().await.expect("commit");
Seeded { book_id, owner_id }
}
async fn cleanup(pool: &PgPool, s: &Seeded) {
let _ = sqlx::query("DELETE FROM carddav.contacts WHERE address_book_id = $1")
.bind(s.book_id)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM carddav.address_books WHERE id = $1")
.bind(s.book_id)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1")
.bind(s.owner_id)
.execute(pool)
.await;
}
fn report_shape() -> CardDavReportType {
CardDavReportType::AddressbookQuery {
props: vec![
QualifiedName::new("DAV:", "getetag"),
QualifiedName::new("DAV:", "getcontenttype"),
],
}
}
async fn fetch_all_dtos(repo: &ContactPgRepository, book_id: &Uuid) -> Vec<ContactDto> {
repo.get_contacts_by_address_book(book_id)
.await
.expect("list contacts")
.into_iter()
.map(ContactDto::from)
.collect()
}
/// BEFORE: full fetch + whole-response buffer. First byte exists only
/// when everything does.
async fn buffered_report(
repo: &ContactPgRepository,
book_id: &Uuid,
base_href: &str,
) -> (f64, Vec<u8>) {
let t0 = Instant::now();
let contacts = fetch_all_dtos(repo, book_id).await;
let mut out = Vec::with_capacity(contacts.len() * 256);
CardDavAdapter::generate_contacts_response(&mut out, &contacts, &report_shape(), base_href)
.expect("generate");
(t0.elapsed().as_secs_f64() * 1e3, out)
}
/// AFTER: cursor + page writers (the handler loop over public pieces).
/// Returns (ttfb_ms — first data page rendered, wall_ms, bytes).
async fn streamed_report(
repo: &ContactPgRepository,
book_id: &Uuid,
base_href: &str,
page_rows: usize,
accumulate: bool,
) -> (f64, f64, Vec<u8>) {
use futures::TryStreamExt;
let t0 = Instant::now();
let mut ttfb = None;
let mut all = Vec::new();
let report = report_shape();
let mut chunk = Vec::with_capacity(160);
{
let mut w = quick_xml::Writer::new(&mut chunk);
CardDavAdapter::write_report_multistatus_start(&mut w).expect("start");
}
if accumulate {
all.extend_from_slice(&chunk);
}
let mut rows = repo.stream_contacts_by_book(*book_id);
let mut page: Vec<ContactDto> = Vec::with_capacity(page_rows);
loop {
let next = rows
.try_next()
.await
.expect("stream row")
.map(ContactDto::from);
let flush = match &next {
Some(_) => page.len() >= page_rows,
None => !page.is_empty(),
};
if flush {
let mut chunk = Vec::with_capacity(page.len() * 256 + 64);
{
let mut w = quick_xml::Writer::new(&mut chunk);
CardDavAdapter::write_contacts_report_page(&mut w, &page, &report, base_href)
.expect("page");
}
ttfb.get_or_insert_with(|| t0.elapsed().as_secs_f64() * 1e3);
page.clear();
if accumulate {
all.extend_from_slice(&chunk);
}
std::hint::black_box(&chunk);
}
match next {
Some(c) => page.push(c),
None => break,
}
}
let mut chunk = Vec::with_capacity(32);
{
let mut w = quick_xml::Writer::new(&mut chunk);
CardDavAdapter::write_carddav_multistatus_end(&mut w).expect("end");
}
if accumulate {
all.extend_from_slice(&chunk);
}
(
ttfb.unwrap_or(f64::NAN),
t0.elapsed().as_secs_f64() * 1e3,
all,
)
}
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)
}
fn book_dto(seeded: &Seeded) -> AddressBookDto {
AddressBookDto {
id: seeded.book_id.to_string(),
name: "Libreta grande".to_string(),
owner_id: seeded.owner_id.to_string(),
..AddressBookDto::default()
}
}
#[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_CONTACTS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8000);
let page_rows: 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 = ContactPgRepository::new(pool.clone());
let base_href = format!("/carddav/{}/", seeded.book_id);
println!("bench_carddav_stream — {n} contacts, page={page_rows}, {passes} passes\n");
// ── Equivalence gates ───────────────────────────────────────────────────
let (_, before_bytes) = buffered_report(&repo, &seeded.book_id, &base_href).await;
let (_, _, after_bytes) =
streamed_report(&repo, &seeded.book_id, &base_href, page_rows, true).await;
let gate_report = before_bytes == after_bytes;
// Collection PROPFIND (allprop): buffered generator vs head+pages.
let request = PropFindRequest {
prop_find_type: PropFindType::AllProp,
};
let book = book_dto(&seeded);
let contacts_all = fetch_all_dtos(&repo, &seeded.book_id).await;
let mut coll_before = Vec::new();
CardDavAdapter::generate_addressbook_collection_propfind(
&mut coll_before,
&book,
&contacts_all,
&request,
&base_href,
"1",
)
.expect("collection");
drop(contacts_all);
let coll_after = {
use futures::TryStreamExt;
let mut out = Vec::new();
{
let mut w = quick_xml::Writer::new(&mut out);
CardDavAdapter::write_collection_head(&mut w, &book, &request, &base_href)
.expect("head");
}
let mut rows = repo.stream_contacts_by_book(seeded.book_id);
let mut page: Vec<ContactDto> = Vec::with_capacity(page_rows);
loop {
let next = rows
.try_next()
.await
.expect("stream row")
.map(ContactDto::from);
let flush = match &next {
Some(_) => page.len() >= page_rows,
None => !page.is_empty(),
};
if flush {
let mut w = quick_xml::Writer::new(&mut out);
CardDavAdapter::write_collection_contact_page(&mut w, &page, &base_href)
.expect("page");
page.clear();
}
match next {
Some(c) => page.push(c),
None => break,
}
}
let mut w = quick_xml::Writer::new(&mut out);
CardDavAdapter::write_carddav_multistatus_end(&mut w).expect("end");
out
};
let gate_coll = coll_before == coll_after;
drop(coll_before);
drop(coll_after);
// ── [1] REPORT timing + peak ────────────────────────────────────────────
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.book_id, &base_href).await;
std::hint::black_box(out);
b_wall.push(w);
let (t, w, _) = streamed_report(&repo, &seeded.book_id, &base_href, page_rows, false).await;
a_ttfb.push(t);
a_wall.push(w);
}
reset_peak();
let (_, out) = buffered_report(&repo, &seeded.book_id, &base_href).await;
drop(out);
let peak_before = peak_mib();
reset_peak();
let _ = streamed_report(&repo, &seeded.book_id, &base_href, page_rows, false).await;
let peak_after = peak_mib();
let bw = p50(b_wall);
let aw = p50(a_wall);
let at = p50(a_ttfb);
println!("[1] REPORT addressbook-query (getetag) TTFB ms wall ms peak heap MiB");
println!(" BEFORE (buffered) {bw:8.1} {bw:8.1} {peak_before:10.1}");
println!(
" AFTER (cursor stream) {at:8.1} {aw:8.1} {peak_after:10.1} TTFB {:.1}x, heap {:.1}x lower",
bw / at,
peak_before / peak_after
);
cleanup(&pool, &seeded).await;
println!(
"\n[gate] REPORT byte-identical: {} · collection PROPFIND byte-identical: {}",
if gate_report { "OK" } else { "FAILED" },
if gate_coll { "OK" } else { "FAILED" }
);
if !gate_report || !gate_coll {
std::process::exit(1);
}
}