Files
Oxicloud/examples/bench_nc_enrich_join.rs
T
Claude fdf445d2b0 perf: round 9 — decorator PUT reactivation, session/search/dedup alloc purges, PROPFIND join!, folder-level cascade
Benchmark-gated round (benches/ROUND9.md): every change carries a
BEFORE/AFTER bench with equivalence/safety gates; verdicts below are from
the committed harnesses on 4 cores / local PG 16.

Backend:
- Blob decorators (Retry/Cached) now forward put_blob_from_bytes_unsynced
  + sync_blobs — the trait default had silently reinstated HEAD-before-PUT
  per chunk on decorated remote stacks, undoing ROUND3 §8. Full production
  stack: 500 probes -> 0, 1.9x wall at 10 ms RTT (bench_s3_put §3).
- NC PROPFIND per-page enrichment triple (favorites / oc:fileid / dead
  props) overlapped with tokio::join!: 2.07x local, 2.86x at 5 ms RTT
  (bench_nc_enrich_join, injected-latency decide-by-bench).
- Search enrichment consumes its DTOs and carries the interned Arc<str>
  display fields end-to-end (SearchFileResultDto type change, OpenAPI
  shape preserved): enrich_file 2.0x, 11.6 -> 2.2 allocs/row; the NC
  REPORT conversion stops re-running all three classifiers per row
  (bench_search_enrich).
- NC session Arc end-to-end: SharedNcSession extractor (8 -> 0 allocs),
  Arc<FolderDto> chroot cache (4 -> 0/hit), single shared Arc<CurrentUser>
  + lazy span render (11 -> 6/build) (bench_nc_session).
- Storage micro-pack: atomic create_new chunk writes (2.1x fresh),
  stream_chunks over the manifest Arc (4097 -> 0 allocs/read incl. the
  Range path), manifest single-flight (herd 64 -> 1 loads), hex_lower for
  chunk Content-MD5 (18 -> 1 allocs) (bench_storage_micro).
- OCS capabilities memoized into OnceLock<[Bytes;2]>: 237x, 102 -> 0
  allocs/poll, byte-identical (bench_capabilities_static).
- Drive::is_empty COUNT(*) sum -> EXISTS: 34.4x on a 100k-file drive
  (bench_drive_is_empty).
- favorites/recents row-map ROUND7 port: path/name/blob_hash moved,
  -2.75 allocs/row (bench_resource_row_map §2).
- Folder rows decode binary UUIDs (ROUND6 §10 port): 1.03-1.07x page
  fetch, honest verdict incl. one noise-band wash documented
  (bench_folder_uuid_decode).
- Authz: file cascade decision decomposed into memoized folder-level
  decision + direct-grant lookup (ROUND8 deferred item). Cold shared-album
  first view 592 -> 418 µs/thumb; warm path unchanged; safety gates incl.
  new direct-grant sibling isolation, revoke-flush re-verified, full
  integration authz suite green (bench_thumbnail_cascade_cache).

Frontend (vitest gates committed beside the code):
- resolveLabel/resolveRecipient O(directory) scan -> id-keyed Map: 13.9x
  (recipients.bench.test.ts).
- ResourceList selection-prune effect skips when nothing is selected
  (100 -> 0 Set builds per drain) and the photos timeline reads a
  listener-fed mobile flag instead of matchMedia per recompute
  (listDerives.bench.test.ts).

Verification: cargo fmt + clippy --all-features --all-targets -D warnings
clean; 524 unit + 554 integration (--cfg integration_tests) tests pass;
frontend npm run check clean with 293 vitest tests green.

Deferred with rationale in ROUND9.md: CalDAV authz-before-fetch reorder
(maintainer sign-off), per-page batched parent resolution, JWT-claims
Arc<str>, batch_operations signature widening.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDc9VtXvskJ6dnMRraSndn
2026-07-18 16:12:04 +00:00

370 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! NC PROPFIND per-page enrichment — 3 serial round-trips vs `tokio::join!`.
//!
//! Every Depth:1 PROPFIND page on the NextCloud surface enriches its ≤500
//! children with three INDEPENDENT batched reads: favorites
//! (`user_favorites … = ANY`), oc:fileid resolution
//! (`nextcloud_object_ids … = ANY`) and WebDAV dead properties
//! (`webdav_dead_properties … = ANY`). The old code awaited them in
//! sequence — 3×RTT per page; overlapping them costs ~max(RTT).
//!
//! Decide-by-bench (the round-7 deferred "serial pairs" item): round 6
//! showed concurrency can LOSE on local-socket PG (authz `try_join_all`
//! regressed), so this A/B carries an **injected-latency arm** — each
//! round-trip is prefixed with `tokio::time::sleep(L)` to model network
//! RTT at L = 0 / 0.25 / 1 / 5 ms. Adoption rule: `join!` must not
//! regress at L=0 (the local-socket floor) and must win under injected
//! RTT; the L=0 row is the rollback gate.
//!
//! The three queries are the production shapes bound over the same seeded
//! 500-child page; the equivalence gate asserts both arms return
//! identical favorite sets / id maps / dead-prop rows.
//!
//! Run (needs Postgres up; reads DATABASE_URL from .env):
//! cargo run --release --features bench --example bench_nc_enrich_join
//! Tunables (env): BENCH_CHILDREN (500), BENCH_PASSES (100)
use std::collections::HashSet;
use std::env;
use std::time::{Duration, Instant};
use sqlx::{PgPool, Row, 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 {
drive_id: Uuid,
user_id: Uuid,
file_ids: Vec<Uuid>,
}
async fn seed(pool: &PgPool, children: usize) -> Seeded {
let user_id: Uuid = sqlx::query_scalar(
"INSERT INTO auth.users (username, email, role)
VALUES ('bench_enrich', 'bench_enrich@example.com', 'user') RETURNING id",
)
.fetch_one(pool)
.await
.expect("seed user");
let mut tx = pool.begin().await.expect("begin");
let drive_id: Uuid = sqlx::query_scalar(
"INSERT INTO storage.drives (kind, quota_bytes) VALUES ('shared', NULL) RETURNING id",
)
.fetch_one(&mut *tx)
.await
.expect("drive");
let root: Uuid = sqlx::query_scalar(
"INSERT INTO storage.folders (name, path, lpath, drive_id)
VALUES ('bench_enrich', '/bench_enrich', 'bench_enrich', $1) RETURNING id",
)
.bind(drive_id)
.fetch_one(&mut *tx)
.await
.expect("root");
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
.bind(root)
.bind(drive_id)
.execute(&mut *tx)
.await
.expect("stamp root");
tx.commit().await.expect("commit");
let file_ids: Vec<Uuid> = sqlx::query_scalar(
"INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id)
SELECT 'f' || i, $1,
'benchenrich0000000000000000000000000000000000000000000000000000',
1024, 'image/jpeg', $2
FROM generate_series(1, $3) AS i
RETURNING id",
)
.bind(root)
.bind(drive_id)
.bind(children as i32)
.fetch_all(pool)
.await
.expect("seed files");
// Every 5th file favorited, all files carry an oc:fileid mapping,
// every 10th file has a dead property — a realistic mixed page.
sqlx::query(
"INSERT INTO auth.user_favorites (user_id, item_id, item_type)
SELECT $1, id::text, 'file' FROM storage.files
WHERE folder_id = $2 AND (('x' || substr(md5(id::text), 1, 4))::bit(16)::int % 5) = 0",
)
.bind(user_id)
.bind(root)
.execute(pool)
.await
.expect("seed favorites");
sqlx::query(
"INSERT INTO storage.nextcloud_object_ids (object_type, object_id)
SELECT 'file', id FROM storage.files WHERE folder_id = $1
ON CONFLICT DO NOTHING",
)
.bind(root)
.execute(pool)
.await
.expect("seed object ids");
sqlx::query(
"INSERT INTO storage.webdav_dead_properties (file_id, namespace, local_name, value)
SELECT id, 'urn:bench', 'displayname', 'v'
FROM storage.files
WHERE folder_id = $1 AND (('x' || substr(md5(id::text), 1, 4))::bit(16)::int % 10) = 0",
)
.bind(root)
.execute(pool)
.await
.expect("seed dead props");
Seeded {
drive_id,
user_id,
file_ids,
}
}
async fn cleanup(pool: &PgPool, s: &Seeded) {
sqlx::query("DELETE FROM storage.webdav_dead_properties WHERE file_id = ANY($1)")
.bind(&s.file_ids)
.execute(pool)
.await
.ok();
sqlx::query("DELETE FROM storage.nextcloud_object_ids WHERE object_id = ANY($1)")
.bind(&s.file_ids)
.execute(pool)
.await
.ok();
sqlx::query("DELETE FROM auth.user_favorites WHERE user_id = $1")
.bind(s.user_id)
.execute(pool)
.await
.ok();
sqlx::query("DELETE FROM storage.files WHERE drive_id = $1")
.bind(s.drive_id)
.execute(pool)
.await
.ok();
sqlx::query("UPDATE storage.drives SET root_folder_id = NULL WHERE id = $1")
.bind(s.drive_id)
.execute(pool)
.await
.ok();
sqlx::query("DELETE FROM storage.folders WHERE drive_id = $1")
.bind(s.drive_id)
.execute(pool)
.await
.ok();
sqlx::query("DELETE FROM storage.drives WHERE id = $1")
.bind(s.drive_id)
.execute(pool)
.await
.ok();
sqlx::query("DELETE FROM auth.users WHERE id = $1")
.bind(s.user_id)
.execute(pool)
.await
.ok();
}
// ── The three production-shaped round-trips ─────────────────────────────────
async fn q_favorites(
pool: &PgPool,
user_id: Uuid,
ids: &[String],
lat: Duration,
) -> HashSet<String> {
if !lat.is_zero() {
tokio::time::sleep(lat).await;
}
let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect();
sqlx::query("SELECT item_id FROM auth.user_favorites WHERE user_id = $1 AND item_id = ANY($2)")
.bind(user_id)
.bind(&id_refs)
.fetch_all(pool)
.await
.expect("favorites")
.into_iter()
.map(|r| r.get::<String, _>(0))
.collect()
}
async fn q_object_ids(pool: &PgPool, uuids: &[Uuid], lat: Duration) -> Vec<(i64, Uuid)> {
if !lat.is_zero() {
tokio::time::sleep(lat).await;
}
let mut rows: Vec<(i64, Uuid)> = sqlx::query(
"SELECT id, object_id FROM storage.nextcloud_object_ids
WHERE object_type = 'file' AND object_id = ANY($1::uuid[])",
)
.bind(uuids)
.fetch_all(pool)
.await
.expect("object ids")
.into_iter()
.map(|r| (r.get::<i64, _>(0), r.get::<Uuid, _>(1)))
.collect();
rows.sort_unstable();
rows
}
async fn q_dead_props(pool: &PgPool, uuids: &[Uuid], lat: Duration) -> Vec<(Uuid, String)> {
if !lat.is_zero() {
tokio::time::sleep(lat).await;
}
let mut rows: Vec<(Uuid, String)> = sqlx::query(
"SELECT file_id, local_name FROM storage.webdav_dead_properties
WHERE file_id = ANY($1)",
)
.bind(uuids)
.fetch_all(pool)
.await
.expect("dead props")
.into_iter()
.map(|r| (r.get::<Uuid, _>(0), r.get::<String, _>(1)))
.collect();
rows.sort_unstable();
rows
}
type PageResult = (HashSet<String>, Vec<(i64, Uuid)>, Vec<(Uuid, String)>);
/// BEFORE — the old serial shape.
async fn page_serial(
pool: &PgPool,
user_id: Uuid,
ids: &[String],
uuids: &[Uuid],
lat: Duration,
) -> PageResult {
let favs = q_favorites(pool, user_id, ids, lat).await;
let oc = q_object_ids(pool, uuids, lat).await;
let dead = q_dead_props(pool, uuids, lat).await;
(favs, oc, dead)
}
/// AFTER — the production `join!` shape.
async fn page_joined(
pool: &PgPool,
user_id: Uuid,
ids: &[String],
uuids: &[Uuid],
lat: Duration,
) -> PageResult {
let (favs, oc, dead) = tokio::join!(
q_favorites(pool, user_id, ids, lat),
q_object_ids(pool, uuids, lat),
q_dead_props(pool, uuids, lat),
);
(favs, oc, dead)
}
fn p50(mut xs: Vec<f64>) -> f64 {
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
xs[xs.len() / 2]
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
dotenvy::dotenv().ok();
let url = env::var("DATABASE_URL").expect("set DATABASE_URL — the dev Postgres URL");
let children: usize = env_or("BENCH_CHILDREN", 500);
let passes: usize = env_or("BENCH_PASSES", 100);
// 4 connections: the production pool always has slack beyond 3.
let pool = PgPoolOptions::new()
.max_connections(4)
.min_connections(4)
.connect(&url)
.await
.expect("connect");
let seeded = seed(&pool, children).await;
let ids: Vec<String> = seeded.file_ids.iter().map(|u| u.to_string()).collect();
let uuids = seeded.file_ids.clone();
// Equivalence gate.
let a = page_serial(&pool, seeded.user_id, &ids, &uuids, Duration::ZERO).await;
let b = page_joined(&pool, seeded.user_id, &ids, &uuids, Duration::ZERO).await;
if a != b {
eprintln!("EQUIVALENCE GATE FAILED: serial and joined results differ");
cleanup(&pool, &seeded).await;
std::process::exit(1);
}
assert!(
!a.0.is_empty() && !a.1.is_empty() && !a.2.is_empty(),
"seed produced empty enrichment"
);
println!(
"# equivalence gate: identical results (favs={}, oc_ids={}, dead={}) — OK",
a.0.len(),
a.1.len(),
a.2.len()
);
for _ in 0..10 {
std::hint::black_box(
page_serial(&pool, seeded.user_id, &ids, &uuids, Duration::ZERO).await,
);
std::hint::black_box(
page_joined(&pool, seeded.user_id, &ids, &uuids, Duration::ZERO).await,
);
}
println!("\n#################################################################");
println!("# NC PROPFIND page enrichment — serial 3×RTT vs tokio::join!");
println!("# children={children} passes={passes} (interleaved, p50 ms/page)");
println!("#################################################################\n");
println!(
"| {:<14} | {:>12} | {:>12} | {:>8} |",
"injected RTT", "serial ms", "join! ms", "ratio"
);
let mut zero_lat_ratio = 0.0;
for lat_us in [0u64, 250, 1_000, 5_000] {
let lat = Duration::from_micros(lat_us);
let mut serial = Vec::with_capacity(passes);
let mut joined = Vec::with_capacity(passes);
for _ in 0..passes {
let t = Instant::now();
std::hint::black_box(page_serial(&pool, seeded.user_id, &ids, &uuids, lat).await);
serial.push(t.elapsed().as_secs_f64() * 1e3);
let t = Instant::now();
std::hint::black_box(page_joined(&pool, seeded.user_id, &ids, &uuids, lat).await);
joined.push(t.elapsed().as_secs_f64() * 1e3);
}
let (s, j) = (p50(serial), p50(joined));
if lat_us == 0 {
zero_lat_ratio = j / s;
}
println!(
"| {:>11} µs | {:>12.3} | {:>12.3} | {:>7.2}x |",
lat_us,
s,
j,
s / j
);
}
cleanup(&pool, &seeded).await;
// Adoption gate: join! must not regress the local-socket floor by >5%
// (measurement noise band); the injected-RTT rows document the win.
if zero_lat_ratio > 1.05 {
eprintln!(
"\nGATE FAIL: join! is {:.1}% slower at 0 RTT — rollback the overlap",
(zero_lat_ratio - 1.0) * 100.0
);
std::process::exit(1);
}
println!("\nGATE PASS: no local-socket regression; overlap wins under injected RTT.");
}