fdf445d2b0
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
370 lines
12 KiB
Rust
370 lines
12 KiB
Rust
//! 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.");
|
||
}
|