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
This commit is contained in:
Claude
2026-07-18 09:03:33 +00:00
parent 61c9470981
commit 9729f033b2
24 changed files with 2012 additions and 154 deletions
+409
View File
@@ -0,0 +1,409 @@
//! 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);
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Batch-favorites AuthZ fan-out benchmark — serial `require` loop vs
//! `try_join_all`.
//!
//! VERDICT (round 6): the fan-out measured WORSE on both the cold and the
//! warm path against local-socket Postgres (see benches/ROUND6.md), so the
//! production loop stays serial. This example is kept as the reproducible
//! evidence for that rejection — re-run it if the DB ever moves behind real
//! network latency, where the answer could flip.
//!
//! `FavoritesService::batch_add_to_favorites` pre-checks `Permission::Read`
//! on every referenced resource. BEFORE awaited the checks one-by-one: for a
//! "select all → add to favorites" over N items whose drive-lookup isn't
//! cached yet, that is N sequential point-SELECT round-trips
//! (`drive_of` per distinct file) before the batched insert even starts.
//! AFTER fans the same checks out with `futures::future::try_join_all`
//! (fail-fast on any denial preserved).
//!
//! This bench drives the REAL `PgAclEngine` (owner/drive-role caches
//! included) against a seeded shared drive:
//! caller ──editor grant──▶ drive ─▶ root folder ─▶ N files
//!
//! Arms: cold engine (empty caches — the first-grid-load shape) and warm
//! repeat (all moka — parity check, both arms should collapse).
//!
//! Equivalence gates: every check grants for the member on both arms, and
//! both arms deny a control user with no grant.
//!
//! Run (needs Postgres up; reads DATABASE_URL from .env):
//! cargo run --release --features bench --example bench_favorites_authz
//! Tunables (env): BENCH_FILES (200), BENCH_POOL (20).
use std::env;
use std::sync::Arc;
use std::time::{Duration, Instant};
use oxicloud::application::ports::authorization_ports::AuthorizationEngine;
use oxicloud::domain::services::authorization::{Permission, Resource, Subject};
use oxicloud::infrastructure::repositories::pg::{
FileBlobReadRepository, FolderDbRepository, SubjectGroupPgRepository,
};
use oxicloud::infrastructure::services::dedup_service::DedupService;
use oxicloud::infrastructure::services::local_blob_backend::LocalBlobBackend;
use oxicloud::infrastructure::services::pg_acl_engine::PgAclEngine;
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use uuid::Uuid;
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
struct Seeded {
caller: Uuid,
control: Uuid,
drive_id: Uuid,
root_folder: Uuid,
blob_hash: String,
file_ids: Vec<Uuid>,
}
async fn seed(pool: &PgPool, n_files: usize) -> Seeded {
let mut tx = pool.begin().await.expect("begin");
let caller: Uuid = sqlx::query_scalar(
"INSERT INTO auth.users (username, email, role)
VALUES ('bench_favauthz', 'bench_favauthz@bench.invalid', 'user') RETURNING id",
)
.fetch_one(&mut *tx)
.await
.expect("seed caller");
let control: Uuid = sqlx::query_scalar(
"INSERT INTO auth.users (username, email, role)
VALUES ('bench_favauthz_ctl', 'bench_favauthz_ctl@bench.invalid', 'user') RETURNING id",
)
.fetch_one(&mut *tx)
.await
.expect("seed control");
let drive_id: Uuid =
sqlx::query_scalar("INSERT INTO storage.drives (kind) VALUES ('shared') RETURNING id")
.fetch_one(&mut *tx)
.await
.expect("seed drive");
let root_folder: Uuid = sqlx::query_scalar(
"INSERT INTO storage.folders (name, path, lpath, drive_id)
VALUES ('Bench Shared', '/Bench Shared', 'x', $1) RETURNING id",
)
.bind(drive_id)
.fetch_one(&mut *tx)
.await
.expect("seed folder");
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
.bind(root_folder)
.bind(drive_id)
.execute(&mut *tx)
.await
.expect("stamp root");
sqlx::query(
"INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
VALUES ('user', $1, 'drive', $2, 'editor'::storage.grant_role, $1)",
)
.bind(caller)
.bind(drive_id)
.execute(&mut *tx)
.await
.expect("seed grant");
let blob_hash = "benchfavauthz0000000000000000000000000000000000000000000000000b1".to_string();
sqlx::query("INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 1, 1)")
.bind(&blob_hash)
.execute(&mut *tx)
.await
.expect("seed blob");
let mut file_ids = Vec::with_capacity(n_files);
for i in 0..n_files {
let id: Uuid = sqlx::query_scalar(
"INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id)
VALUES ($1, $2, $3, 1, 'text/plain', $4) RETURNING id",
)
.bind(format!("bench-{i:04}.txt"))
.bind(root_folder)
.bind(&blob_hash)
.bind(drive_id)
.fetch_one(&mut *tx)
.await
.expect("seed file");
file_ids.push(id);
}
tx.commit().await.expect("commit");
Seeded {
caller,
control,
drive_id,
root_folder,
blob_hash,
file_ids,
}
}
async fn cleanup(pool: &PgPool, s: &Seeded) {
let _ = sqlx::query("DELETE FROM storage.role_grants WHERE resource_id = $1")
.bind(s.drive_id)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM storage.files WHERE drive_id = $1")
.bind(s.drive_id)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1")
.bind(s.drive_id)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM storage.folders WHERE id = $1")
.bind(s.root_folder)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM storage.blobs WHERE hash = $1")
.bind(&s.blob_hash)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM auth.users WHERE id IN ($1, $2)")
.bind(s.caller)
.bind(s.control)
.execute(pool)
.await;
}
fn fresh_engine(pool: &Arc<PgPool>) -> Arc<PgAclEngine> {
let folder_repo = Arc::new(FolderDbRepository::new(pool.clone()));
let backend = Arc::new(LocalBlobBackend::new(std::path::Path::new(
"/tmp/bench-favauthz-blobs",
)));
let dedup = Arc::new(DedupService::new(backend, pool.clone(), pool.clone()));
let file_repo = Arc::new(FileBlobReadRepository::new(
pool.clone(),
dedup,
folder_repo.clone(),
));
let group_repo = Arc::new(SubjectGroupPgRepository::new(pool.clone()));
Arc::new(PgAclEngine::new(
pool.clone(),
folder_repo,
file_repo,
group_repo,
))
}
/// BEFORE, verbatim shape: one awaited `require` per item.
async fn serial_checks(engine: &Arc<PgAclEngine>, user: Uuid, files: &[Uuid]) -> Result<(), ()> {
for id in files {
engine
.require(Subject::User(user), Permission::Read, Resource::File(*id))
.await
.map_err(|_| ())?;
}
Ok(())
}
/// AFTER: the same checks, fanned out with fail-fast join.
async fn joined_checks(engine: &Arc<PgAclEngine>, user: Uuid, files: &[Uuid]) -> Result<(), ()> {
futures::future::try_join_all(
files
.iter()
.map(|id| engine.require(Subject::User(user), Permission::Read, Resource::File(*id))),
)
.await
.map(|_| ())
.map_err(|_| ())
}
#[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_files: usize = env_or("BENCH_FILES", 200);
let pool_size: u32 = env_or("BENCH_POOL", 20);
let pool = Arc::new(
PgPoolOptions::new()
.max_connections(pool_size)
.min_connections(pool_size)
.acquire_timeout(Duration::from_secs(10))
.connect(&url)
.await
.expect("connect Postgres"),
);
let seeded = seed(&pool, n_files).await;
// ── Equivalence gates ────────────────────────────────────────────────
// Grant path: both arms must authorize every file for the member.
let gate_engine = fresh_engine(&pool);
if serial_checks(&gate_engine, seeded.caller, &seeded.file_ids)
.await
.is_err()
|| joined_checks(&gate_engine, seeded.caller, &seeded.file_ids)
.await
.is_err()
{
eprintln!("EQUIVALENCE GATE FAILED: member was denied");
cleanup(&pool, &seeded).await;
std::process::exit(1);
}
// Denial path: both arms must reject the control user (fresh engines so
// the joined arm can't ride the serial arm's caches).
let deny_a = fresh_engine(&pool);
let deny_b = fresh_engine(&pool);
if serial_checks(&deny_a, seeded.control, &seeded.file_ids)
.await
.is_ok()
|| joined_checks(&deny_b, seeded.control, &seeded.file_ids)
.await
.is_ok()
{
eprintln!("EQUIVALENCE GATE FAILED: control user was granted");
cleanup(&pool, &seeded).await;
std::process::exit(1);
}
println!("\n#################################################################");
println!("# batch-favorites authz: serial require loop vs try_join_all");
println!("# files={n_files} pool={pool_size} (shared-drive member, editor grant)");
println!("#################################################################\n");
println!("| {:<18} | {:>10} | {:>12} |", "arm", "wall ms", "µs/item");
for (label, joined, warm) in [
("serial COLD", false, false),
("join COLD", true, false),
("serial WARM", false, true),
("join WARM", true, true),
] {
// COLD: fresh engine per run (empty moka). WARM: prime, then measure.
let engine = fresh_engine(&pool);
if warm {
serial_checks(&engine, seeded.caller, &seeded.file_ids)
.await
.expect("prime");
}
let t = Instant::now();
let r = if joined {
joined_checks(&engine, seeded.caller, &seeded.file_ids).await
} else {
serial_checks(&engine, seeded.caller, &seeded.file_ids).await
};
let el = t.elapsed();
r.expect("granted");
println!(
"| {:<18} | {:>10.2} | {:>12.2} |",
label,
el.as_secs_f64() * 1e3,
el.as_secs_f64() * 1e6 / n_files as f64
);
}
cleanup(&pool, &seeded).await;
println!("\n(COLD = empty caches: N distinct `drive_of` point-SELECTs — the arm");
println!(" under test. WARM = all-moka parity check. Fail-fast denial semantics");
println!(" verified by the control-user gate on both arms.)");
}
+226
View File
@@ -0,0 +1,226 @@
//! Micro-alloc benchmark: digest-hex rendering and NC id-batch marshalling.
//!
//! Two round-6 changes, both equivalence-gated against their verbatim
//! BEFORE shapes and measured with a counting allocator:
//!
//! 1. `IncrementalHasher::finalize_hex` (upload_ingest.rs) rendered MD5 /
//! SHA-256 digests with `.map(|b| format!("{b:02x}")).collect()` — one
//! heap `String` per digest byte (16 / 32 allocs) per chunk finalize.
//! AFTER: `common::fmt::hex_lower` writes into one preallocated String.
//!
//! 2. `batch_resolve_ids` (NC webdav_handler) cloned every child id into a
//! `Vec<String>` and the id service keyed its result map by `String` —
//! ~3 heap allocs per child per page. AFTER the whole chain is borrowed:
//! `Vec<&str>` in, `HashMap<Uuid, i64>` out, `Uuid::parse_str` lookups.
//!
//! Run:
//! cargo run --release --features bench --example bench_hex_ids
//! Tunables (env): BENCH_ITERS (10000), BENCH_CHILDREN (500).
use std::alloc::{GlobalAlloc, Layout, System};
use std::collections::HashMap;
use std::env;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use md5::Digest;
use oxicloud::common::fmt::hex_lower;
use uuid::Uuid;
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 env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn measure<R>(f: impl FnOnce() -> R) -> (R, u64, f64) {
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
let r = f();
let el = t.elapsed().as_secs_f64();
let allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0;
(r, allocs, el)
}
// ── 1. digest hex ───────────────────────────────────────────────────────────
/// BEFORE, verbatim: one `format!` per digest byte.
fn hex_before(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn bench_hex(iters: usize) {
// Deterministic digests of both production sizes (MD5=16, SHA-256=32).
let md5s: Vec<[u8; 16]> = (0..64u64)
.map(|i| md5::Md5::digest(i.to_le_bytes()).into())
.collect();
let sha256s: Vec<[u8; 32]> = (0..64u64)
.map(|i| sha2::Sha256::digest(i.to_le_bytes()).into())
.collect();
// Equivalence gate: byte-identical output on every digest.
for d in &md5s {
assert_eq!(hex_lower(d), hex_before(d), "md5 hex mismatch");
}
for d in &sha256s {
assert_eq!(hex_lower(d), hex_before(d), "sha256 hex mismatch");
}
println!("── finalize_hex: per-byte format! vs hex_lower ({iters} finalizes/arm) ──\n");
println!(
"| {:<8} | {:<8} | {:>12} | {:>10} | {:>12} |",
"digest", "arm", "allocs", "wall ms", "allocs/call"
);
for (label, digests) in [("md5", md5s.len()), ("sha256", sha256s.len())] {
for arm in ["BEFORE", "AFTER"] {
let (sink, allocs, secs) = measure(|| {
let mut sink = 0usize;
for i in 0..iters {
let s = match (label, arm) {
("md5", "BEFORE") => hex_before(&md5s[i % digests]),
("md5", "AFTER") => hex_lower(&md5s[i % digests]),
("sha256", "BEFORE") => hex_before(&sha256s[i % digests]),
_ => hex_lower(&sha256s[i % digests]),
};
sink += s.len();
}
sink
});
std::hint::black_box(sink);
println!(
"| {:<8} | {:<8} | {:>12} | {:>10.2} | {:>12.2} |",
label,
arm,
allocs,
secs * 1e3,
allocs as f64 / iters as f64
);
}
}
}
// ── 2. NC id-batch marshalling ──────────────────────────────────────────────
/// BEFORE, verbatim caller+service marshalling: clone ids into `Vec<String>`,
/// key the result map by cloned `String`, look children up by `&String`.
fn ids_before(child_ids: &[String], nc: &HashMap<Uuid, i64>) -> Vec<Option<i64>> {
let file_uuids: Vec<String> = child_ids.to_vec();
let mut map: HashMap<String, i64> = HashMap::with_capacity(file_uuids.len());
for raw in &file_uuids {
let Ok(uuid) = Uuid::parse_str(raw) else {
continue;
};
if let Some(id) = nc.get(&uuid) {
map.insert(raw.clone(), *id);
}
}
child_ids.iter().map(|id| map.get(id).copied()).collect()
}
/// AFTER: borrowed slice in, `Uuid`-keyed map out, parse-and-get lookups —
/// the exact shapes now in `batch_resolve_ids` + `nc_id_of`.
fn ids_after(child_ids: &[String], nc: &HashMap<Uuid, i64>) -> Vec<Option<i64>> {
let file_uuids: Vec<&str> = child_ids.iter().map(String::as_str).collect();
let mut map: HashMap<Uuid, i64> = HashMap::with_capacity(file_uuids.len());
for raw in &file_uuids {
let Ok(uuid) = Uuid::parse_str(raw) else {
continue;
};
if let Some(id) = nc.get(&uuid) {
map.insert(uuid, *id);
}
}
child_ids
.iter()
.map(|id| Uuid::parse_str(id).ok().and_then(|u| map.get(&u).copied()))
.collect()
}
fn bench_ids(pages: usize, children: usize) {
// A PROPFIND page of `children` DTO ids (36-byte uuid strings) resolved
// against the id service's numeric mapping.
let uuids: Vec<Uuid> = (0..children).map(|_| Uuid::new_v4()).collect();
let child_ids: Vec<String> = uuids.iter().map(|u| u.to_string()).collect();
let nc: HashMap<Uuid, i64> = uuids
.iter()
.enumerate()
.map(|(i, u)| (*u, i as i64 + 1000))
.collect();
// Equivalence gate: identical per-child resolution, including an
// unparseable id and an unmapped-but-valid id.
let mut gate_ids = child_ids.clone();
gate_ids.push("not-a-uuid".to_string());
gate_ids.push(Uuid::new_v4().to_string());
assert_eq!(
ids_before(&gate_ids, &nc),
ids_after(&gate_ids, &nc),
"id resolution mismatch"
);
println!("\n── batch_resolve_ids marshalling: String-keyed vs borrowed+Uuid ──");
println!(" ({pages} pages × {children} children/arm)\n");
println!(
"| {:<8} | {:>12} | {:>10} | {:>14} |",
"arm", "allocs", "wall ms", "allocs/child"
);
for arm in ["BEFORE", "AFTER"] {
let (sink, allocs, secs) = measure(|| {
let mut sink = 0usize;
for _ in 0..pages {
let resolved = if arm == "BEFORE" {
ids_before(&child_ids, &nc)
} else {
ids_after(&child_ids, &nc)
};
sink += resolved.iter().flatten().count();
}
sink
});
assert_eq!(sink, pages * children, "all children must resolve");
println!(
"| {:<8} | {:>12} | {:>10.2} | {:>14.3} |",
arm,
allocs,
secs * 1e3,
allocs as f64 / (pages * children) as f64
);
}
}
fn main() {
let iters: usize = env_or("BENCH_ITERS", 10_000);
let children: usize = env_or("BENCH_CHILDREN", 500);
bench_hex(iters);
bench_ids(iters / 10, children);
println!("\n(BEFORE arms are verbatim replicas of the replaced shapes; equivalence");
println!(" asserted before timing. Allocs counted via a wrapping GlobalAlloc.)");
}
+248
View File
@@ -0,0 +1,248 @@
//! A/B: `id::text` server-side casts vs binary UUID decode + app-side format.
//!
//! `file_blob_read_repository.rs` (and friends) SELECT UUID columns as
//! `id::text` and decode `String`s directly. The alternative is to decode the
//! wire-native binary `Uuid` (16 bytes vs 36 on the wire) and render the
//! string app-side with `Uuid::to_string`. This bench decides ROUND6 task
//! "::text casts A/B" empirically: whichever loses is documented, only a
//! winner ships.
//!
//! Arms fetch the same 500-row page from a seeded `storage.files` subtree,
//! interleaved A/B to cancel drift; the equivalence gate asserts identical
//! `(id, folder_id, name)` string triples.
//!
//! Run (needs Postgres up; reads DATABASE_URL from .env):
//! cargo run --release --features bench --example bench_uuid_text_cast
//! Tunables (env): BENCH_ROWS (500), BENCH_PASSES (200).
use std::env;
use std::sync::Arc;
use std::time::{Duration, Instant};
use sqlx::postgres::PgPoolOptions;
use sqlx::{PgPool, Row};
use uuid::Uuid;
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
struct Seeded {
drive_id: Uuid,
root_folder: Uuid,
blob_hash: String,
}
async fn seed(pool: &PgPool, rows: usize) -> Seeded {
let mut tx = pool.begin().await.expect("begin");
let drive_id: Uuid =
sqlx::query_scalar("INSERT INTO storage.drives (kind) VALUES ('shared') RETURNING id")
.fetch_one(&mut *tx)
.await
.expect("seed drive");
let root_folder: Uuid = sqlx::query_scalar(
"INSERT INTO storage.folders (name, path, lpath, drive_id)
VALUES ('Bench Cast', '/Bench Cast', 'x', $1) RETURNING id",
)
.bind(drive_id)
.fetch_one(&mut *tx)
.await
.expect("seed folder");
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
.bind(root_folder)
.bind(drive_id)
.execute(&mut *tx)
.await
.expect("stamp root");
let blob_hash = "benchuuidcast000000000000000000000000000000000000000000000000b2".to_string();
sqlx::query("INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 1, 1)")
.bind(&blob_hash)
.execute(&mut *tx)
.await
.expect("seed blob");
for i in 0..rows {
sqlx::query(
"INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id)
VALUES ($1, $2, $3, 1, 'text/plain', $4)",
)
.bind(format!("cast-{i:05}.txt"))
.bind(root_folder)
.bind(&blob_hash)
.bind(drive_id)
.execute(&mut *tx)
.await
.expect("seed file");
}
tx.commit().await.expect("commit");
Seeded {
drive_id,
root_folder,
blob_hash,
}
}
async fn cleanup(pool: &PgPool, s: &Seeded) {
let _ = sqlx::query("DELETE FROM storage.files WHERE drive_id = $1")
.bind(s.drive_id)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1")
.bind(s.drive_id)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM storage.folders WHERE id = $1")
.bind(s.root_folder)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM storage.blobs WHERE hash = $1")
.bind(&s.blob_hash)
.execute(pool)
.await;
}
type Triple = (String, Option<String>, String);
/// Arm A — the current production shape: server-side `::text` casts.
async fn fetch_text_cast(pool: &PgPool, drive_id: Uuid) -> Vec<Triple> {
sqlx::query(
"SELECT id::text AS id, folder_id::text AS folder_id, name
FROM storage.files WHERE drive_id = $1 ORDER BY name",
)
.bind(drive_id)
.fetch_all(pool)
.await
.expect("text-cast fetch")
.iter()
.map(|r| {
(
r.get::<String, _>("id"),
r.get::<Option<String>, _>("folder_id"),
r.get::<String, _>("name"),
)
})
.collect()
}
/// Arm B — binary `Uuid` decode + app-side `to_string`.
async fn fetch_binary_uuid(pool: &PgPool, drive_id: Uuid) -> Vec<Triple> {
sqlx::query(
"SELECT id, folder_id, name
FROM storage.files WHERE drive_id = $1 ORDER BY name",
)
.bind(drive_id)
.fetch_all(pool)
.await
.expect("binary fetch")
.iter()
.map(|r| {
(
r.get::<Uuid, _>("id").to_string(),
r.get::<Option<Uuid>, _>("folder_id").map(|u| u.to_string()),
r.get::<String, _>("name"),
)
})
.collect()
}
struct Stats {
mean_ms: f64,
p50_ms: f64,
p95_ms: f64,
}
fn summarize(mut xs: Vec<f64>) -> Stats {
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = xs.len();
Stats {
mean_ms: xs.iter().sum::<f64>() / n as f64,
p50_ms: xs[n / 2],
p95_ms: xs[((n as f64 * 0.95) as usize).min(n - 1)],
}
}
#[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 rows: usize = env_or("BENCH_ROWS", 500);
let passes: usize = env_or("BENCH_PASSES", 200);
let pool = Arc::new(
PgPoolOptions::new()
.max_connections(4)
.min_connections(4)
.acquire_timeout(Duration::from_secs(10))
.connect(&url)
.await
.expect("connect Postgres"),
);
let seeded = seed(&pool, rows).await;
// ── Equivalence gate: identical string triples ───────────────────────
let a = fetch_text_cast(&pool, seeded.drive_id).await;
let b = fetch_binary_uuid(&pool, seeded.drive_id).await;
if a != b || a.len() != rows {
eprintln!(
"EQUIVALENCE GATE FAILED: rows differ (a={}, b={})",
a.len(),
b.len()
);
cleanup(&pool, &seeded).await;
std::process::exit(1);
}
// Warm-up both shapes (plan cache, buffer cache).
for _ in 0..10 {
std::hint::black_box(fetch_text_cast(&pool, seeded.drive_id).await);
std::hint::black_box(fetch_binary_uuid(&pool, seeded.drive_id).await);
}
// Interleaved A/B passes so drift (autovacuum, CPU governor) hits both.
let mut lat_a = Vec::with_capacity(passes);
let mut lat_b = Vec::with_capacity(passes);
for _ in 0..passes {
let t = Instant::now();
std::hint::black_box(fetch_text_cast(&pool, seeded.drive_id).await);
lat_a.push(t.elapsed().as_secs_f64() * 1e3);
let t = Instant::now();
std::hint::black_box(fetch_binary_uuid(&pool, seeded.drive_id).await);
lat_b.push(t.elapsed().as_secs_f64() * 1e3);
}
let sa = summarize(lat_a);
let sb = summarize(lat_b);
println!("\n#################################################################");
println!("# UUID columns: `id::text` server cast vs binary decode + app fmt");
println!("# rows/page={rows} passes={passes} (interleaved)");
println!("#################################################################\n");
println!(
"| {:<22} | {:>9} | {:>9} | {:>9} |",
"arm", "mean ms", "p50 ms", "p95 ms"
);
println!(
"| {:<22} | {:>9.3} | {:>9.3} | {:>9.3} |",
"A ::text (current)", sa.mean_ms, sa.p50_ms, sa.p95_ms
);
println!(
"| {:<22} | {:>9.3} | {:>9.3} | {:>9.3} |",
"B binary + to_string", sb.mean_ms, sb.p50_ms, sb.p95_ms
);
println!(
"\nB/A mean ratio: {:.3} ({})",
sb.mean_ms / sa.mean_ms,
if sb.mean_ms < sa.mean_ms {
"binary decode wins"
} else {
"::text cast wins"
}
);
cleanup(&pool, &seeded).await;
}