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
This commit is contained in:
Claude
2026-07-18 16:12:04 +00:00
parent 2317d594e3
commit fdf445d2b0
40 changed files with 4279 additions and 346 deletions
+157
View File
@@ -0,0 +1,157 @@
//! OCS capabilities poll benchmark — rebuild-per-request vs memoized bytes.
//!
//! `/ocs/v{1,2}.php/cloud/capabilities` returns a payload that is
//! process-invariant (pure config: base URL + emulated NC version), yet
//! every NC desktop/mobile client polls it on connect and periodically.
//! The old handler re-built the ~40-node `json!` tree — including a
//! `std::env::var("OXICLOUD_BASE_URL")` lookup and three `format!`s —
//! and re-serialized it on EVERY poll. Round 9 serializes both versions
//! once into a `OnceLock<[Bytes; 2]>`; a poll is a `Bytes` refcount bump.
//!
//! The BEFORE arm is the production payload builder invoked per request
//! (via the bench wrapper) + `serde_json::to_vec`, exactly the old
//! handler flow (`Json(payload)` serializes with `to_vec`). The AFTER
//! arm is the memoized-bytes flow. The equivalence gate asserts the
//! served bytes are identical.
//!
//! Run (no Postgres needed):
//! cargo run --release --features bench --example bench_capabilities_static
//! Tunables (env): BENCH_POLLS (50000)
use std::alloc::{GlobalAlloc, Layout, System};
use std::env;
use std::hint::black_box;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use bytes::Bytes;
use oxicloud::interfaces::nextcloud::ocs_handler::capabilities_payload_for_bench;
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)
}
const EMULATED: (u32, u32, u32) = (28, 0, 4);
const VERSION_STRING: &str = "28.0.4";
/// BEFORE flow, verbatim shape: env lookup + tree build + serialize per poll.
fn before_poll(ocs_version: u8) -> Vec<u8> {
let base_url =
env::var("OXICLOUD_BASE_URL").unwrap_or_else(|_| "http://localhost:8086".to_string());
let payload = capabilities_payload_for_bench(&base_url, EMULATED, VERSION_STRING, ocs_version);
serde_json::to_vec(&payload).expect("serialize")
}
/// AFTER flow: the production memoization shape (OnceLock + Bytes clone).
fn after_poll(cache: &OnceLock<[Bytes; 2]>, ocs_version: u8) -> Bytes {
let bodies = cache.get_or_init(|| {
let base_url =
env::var("OXICLOUD_BASE_URL").unwrap_or_else(|_| "http://localhost:8086".to_string());
[1u8, 2u8].map(|v| {
Bytes::from(
serde_json::to_vec(&capabilities_payload_for_bench(
&base_url,
EMULATED,
VERSION_STRING,
v,
))
.expect("serialize"),
)
})
});
bodies[usize::from(ocs_version != 1)].clone()
}
fn main() {
let polls: usize = env_or("BENCH_POLLS", 50_000);
let cache: OnceLock<[Bytes; 2]> = OnceLock::new();
// Equivalence gate: identical served bytes for both OCS versions.
for v in [1u8, 2u8] {
assert_eq!(
before_poll(v),
after_poll(&cache, v).as_ref(),
"capabilities v{v} bytes differ"
);
}
println!("# equivalence gate: v1 + v2 served bytes identical — OK");
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
for i in 0..polls {
black_box(before_poll(if i % 2 == 0 { 1 } else { 2 }));
}
let before_ms = t.elapsed().as_secs_f64() * 1e3;
let before_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0;
let a1 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
for i in 0..polls {
black_box(after_poll(&cache, if i % 2 == 0 { 1 } else { 2 }));
}
let after_ms = t.elapsed().as_secs_f64() * 1e3;
let after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1;
println!("\n#################################################################");
println!("# OCS capabilities poll — rebuild+serialize vs memoized Bytes");
println!("# polls={polls}");
println!("#################################################################\n");
println!(
"| {:<26} | {:>10} | {:>12} | {:>12} |",
"arm", "wall ms", "allocs", "allocs/poll"
);
println!(
"| {:<26} | {:>10.1} | {:>12} | {:>12.2} |",
"BEFORE (rebuild)",
before_ms,
before_allocs,
before_allocs as f64 / polls as f64
);
println!(
"| {:<26} | {:>10.1} | {:>12} | {:>12.2} |",
"AFTER (memoized)",
after_ms,
after_allocs,
after_allocs as f64 / polls as f64
);
println!(
"\n{:.1}x faster, {:.0}x fewer allocs",
before_ms / after_ms,
before_allocs as f64 / after_allocs.max(1) as f64
);
if after_ms >= before_ms || after_allocs >= before_allocs {
eprintln!("GATE FAIL: memoized arm not strictly better — rollback");
std::process::exit(1);
}
println!("GATE PASS");
}
+218
View File
@@ -0,0 +1,218 @@
//! `Drive::is_empty` benchmark — full-drive `COUNT(*)` sum vs short-circuit
//! `EXISTS OR EXISTS`.
//!
//! The drive-deletion precheck only needs a boolean, but the old query
//! aggregated every live folder AND file in the drive (two full index/heap
//! scans) to compare the sum with 0. `EXISTS` stops at the first matching
//! row, so a populated drive answers from one probe.
//!
//! Both query shapes run against the same seeded data; the equivalence
//! gate asserts identical booleans for a populated and an empty drive.
//!
//! Run (needs Postgres up; reads DATABASE_URL from .env):
//! cargo run --release --features bench --example bench_drive_is_empty
//! Tunables (env): BENCH_FILES (100000), BENCH_REPS (25)
use std::env;
use std::time::Instant;
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)
}
async fn seed_drive(pool: &PgPool, files: usize) -> Uuid {
// Drive + root folder must commit together (deferred root-folder trigger).
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_is_empty', '/bench_is_empty', 'bench_is_empty', $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");
if files > 0 {
sqlx::query(
"INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id)
SELECT 'f' || i, $1,
'benchempty00000000000000000000000000000000000000000000000000000',
1024, 'image/jpeg', $2
FROM generate_series(1, $3) AS i",
)
.bind(root)
.bind(drive_id)
.bind(files as i32)
.execute(pool)
.await
.expect("seed files");
}
drive_id
}
async fn cleanup(pool: &PgPool, drive_id: Uuid) {
sqlx::query("DELETE FROM storage.files WHERE drive_id = $1")
.bind(drive_id)
.execute(pool)
.await
.ok();
sqlx::query("UPDATE storage.drives SET root_folder_id = NULL WHERE id = $1")
.bind(drive_id)
.execute(pool)
.await
.ok();
sqlx::query("DELETE FROM storage.folders WHERE drive_id = $1")
.bind(drive_id)
.execute(pool)
.await
.ok();
sqlx::query("DELETE FROM storage.drives WHERE id = $1")
.bind(drive_id)
.execute(pool)
.await
.ok();
}
/// BEFORE — verbatim old query shape.
async fn is_empty_count(pool: &PgPool, drive_id: Uuid) -> bool {
let count: (i64,) = sqlx::query_as(
r#"
SELECT (
(SELECT COUNT(*) FROM storage.folders
WHERE drive_id = $1 AND parent_id IS NOT NULL AND NOT is_trashed)
+ (SELECT COUNT(*) FROM storage.files
WHERE drive_id = $1 AND NOT is_trashed)
)
"#,
)
.bind(drive_id)
.fetch_one(pool)
.await
.expect("count query");
count.0 == 0
}
/// AFTER — the production EXISTS shape.
async fn is_empty_exists(pool: &PgPool, drive_id: Uuid) -> bool {
let occupied: (bool,) = sqlx::query_as(
r#"
SELECT EXISTS(
SELECT 1 FROM storage.folders
WHERE drive_id = $1 AND parent_id IS NOT NULL AND NOT is_trashed)
OR EXISTS(
SELECT 1 FROM storage.files
WHERE drive_id = $1 AND NOT is_trashed)
"#,
)
.bind(drive_id)
.fetch_one(pool)
.await
.expect("exists query");
!occupied.0
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
dotenvy::dotenv().ok();
let url = env::var("DATABASE_URL").expect("set DATABASE_URL — the dev Postgres URL");
let pool = PgPoolOptions::new()
.max_connections(4)
.connect(&url)
.await
.expect("connect");
let files: usize = env_or("BENCH_FILES", 100_000);
let reps: usize = env_or("BENCH_REPS", 25);
let populated = seed_drive(&pool, files).await;
let empty = seed_drive(&pool, 0).await;
// Equivalence gate on both data shapes.
assert_eq!(
is_empty_count(&pool, populated).await,
is_empty_exists(&pool, populated).await,
"populated drive verdict differs"
);
assert_eq!(
is_empty_count(&pool, empty).await,
is_empty_exists(&pool, empty).await,
"empty drive verdict differs"
);
assert!(!is_empty_exists(&pool, populated).await);
assert!(is_empty_exists(&pool, empty).await);
println!("# equivalence gate: identical booleans on populated + empty drives — OK");
// Warm both shapes.
for _ in 0..3 {
is_empty_count(&pool, populated).await;
is_empty_exists(&pool, populated).await;
}
let mut rows = Vec::new();
for (label, drive) in [("populated (100k files)", populated), ("empty", empty)] {
let t = Instant::now();
for _ in 0..reps {
std::hint::black_box(is_empty_count(&pool, drive).await);
}
let before_ms = t.elapsed().as_secs_f64() * 1e3 / reps as f64;
let t = Instant::now();
for _ in 0..reps {
std::hint::black_box(is_empty_exists(&pool, drive).await);
}
let after_ms = t.elapsed().as_secs_f64() * 1e3 / reps as f64;
rows.push((label, before_ms, after_ms));
}
println!("\n#################################################################");
println!("# Drive::is_empty — COUNT(*) sum vs EXISTS OR EXISTS");
println!("# files={files} reps={reps} (ms per call)");
println!("#################################################################\n");
println!(
"| {:<24} | {:>14} | {:>14} | {:>8} |",
"drive", "BEFORE ms", "AFTER ms", "speedup"
);
let mut populated_gain = 0.0;
for (label, before_ms, after_ms) in &rows {
println!(
"| {:<24} | {:>14.3} | {:>14.3} | {:>7.1}x |",
label,
before_ms,
after_ms,
before_ms / after_ms
);
if label.starts_with("populated") {
populated_gain = before_ms / after_ms;
}
}
cleanup(&pool, populated).await;
cleanup(&pool, empty).await;
if populated_gain <= 1.0 {
eprintln!("\nGATE FAIL: EXISTS not faster on the populated drive — rollback");
std::process::exit(1);
}
println!("\nGATE PASS: identical verdicts, populated drive {populated_gain:.1}x faster.");
}
+247
View File
@@ -0,0 +1,247 @@
//! Folder-listing UUID decode benchmark — `id::text`/`parent_id::text`
//! server casts vs binary `Uuid` decode + one app-side render.
//!
//! Round 6 adopted binary decode for the FILE listing rows
//! (`row_to_file`, benches/ROUND6.md §10: 1.17x on 500-row pages) and
//! queued "other repos with the same shape" — `FolderDbRepository` never
//! got the port. Its rows (`list_folders`, `list_folders_batch` — every
//! Depth:1 PROPFIND subfolder page — descendants, suggest) still shipped
//! two `::text` casts per row: 36+36 B on the wire instead of 16+16 and
//! a server-side cast per column.
//!
//! Same methodology as `bench_uuid_text_cast` (the round-6 A/B this
//! ports): seeded page, equivalence gate on identical `(id, parent_id,
//! name, path)` string tuples, warm-up, interleaved passes.
//!
//! Run (needs Postgres up; reads DATABASE_URL from .env):
//! cargo run --release --features bench --example bench_folder_uuid_decode
//! Tunables (env): BENCH_ROWS (500), BENCH_PASSES (200)
use std::env;
use std::sync::Arc;
use std::time::{Duration, Instant};
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 {
drive_id: Uuid,
parent_id: Uuid,
}
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, 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_uuid_folders', '/bench_uuid_folders', 'bench_uuid_folders', $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");
sqlx::query(
"INSERT INTO storage.folders (name, parent_id, path, lpath, drive_id)
SELECT 'sub' || i, $1, '/bench_uuid_folders/sub' || i,
('bench_uuid_folders.sub' || i)::ltree, $2
FROM generate_series(1, $3) AS i",
)
.bind(root)
.bind(drive_id)
.bind(rows as i32)
.execute(pool)
.await
.expect("seed subfolders");
Seeded {
drive_id,
parent_id: root,
}
}
async fn cleanup(pool: &PgPool, s: &Seeded) {
sqlx::query("DELETE FROM storage.folders WHERE drive_id = $1 AND parent_id IS NOT NULL")
.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();
}
/// Materialized tuple both arms must produce identically.
type FolderTuple = (String, String, String, Option<String>);
/// BEFORE — verbatim old query shape: two server-side `::text` casts,
/// decode as String.
async fn fetch_text_cast(pool: &PgPool, parent_id: Uuid) -> Vec<FolderTuple> {
sqlx::query_as::<_, (String, String, String, Option<String>)>(
r#"
SELECT id::text, name, path, parent_id::text
FROM storage.folders
WHERE parent_id = $1 AND NOT is_trashed
ORDER BY name
"#,
)
.bind(parent_id)
.fetch_all(pool)
.await
.expect("text-cast fetch")
}
/// AFTER — the production shape: binary decode, one `to_string` app-side
/// (exactly what `row_to_folder` does now).
async fn fetch_binary_uuid(pool: &PgPool, parent_id: Uuid) -> Vec<FolderTuple> {
let rows = sqlx::query_as::<_, (Uuid, String, String, Option<Uuid>)>(
r#"
SELECT id, name, path, parent_id
FROM storage.folders
WHERE parent_id = $1 AND NOT is_trashed
ORDER BY name
"#,
)
.bind(parent_id)
.fetch_all(pool)
.await
.expect("binary fetch");
rows.into_iter()
.map(|(id, name, path, pid)| (id.to_string(), name, path, pid.map(|u| u.to_string())))
.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 tuples in identical order.
let a = fetch_text_cast(&pool, seeded.parent_id).await;
let b = fetch_binary_uuid(&pool, seeded.parent_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);
}
println!("# equivalence gate: {rows} identical (id, name, path, parent_id) tuples — OK");
for _ in 0..10 {
std::hint::black_box(fetch_text_cast(&pool, seeded.parent_id).await);
std::hint::black_box(fetch_binary_uuid(&pool, seeded.parent_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.parent_id).await);
lat_a.push(t.elapsed().as_secs_f64() * 1e3);
let t = Instant::now();
std::hint::black_box(fetch_binary_uuid(&pool, seeded.parent_id).await);
lat_b.push(t.elapsed().as_secs_f64() * 1e3);
}
let sa = summarize(lat_a);
let sb = summarize(lat_b);
println!("\n#################################################################");
println!("# folder page: `::text` casts vs binary UUID 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 (before)", sa.mean_ms, sa.p50_ms, sa.p95_ms
);
println!(
"| {:<22} | {:>9.3} | {:>9.3} | {:>9.3} |",
"B binary (after)", sb.mean_ms, sb.p50_ms, sb.p95_ms
);
println!(
"\nB/A mean ratio: {:.3} ({:.2}x)",
sb.mean_ms / sa.mean_ms,
sa.mean_ms / sb.mean_ms
);
cleanup(&pool, &seeded).await;
if sb.mean_ms >= sa.mean_ms {
eprintln!("GATE FAIL: binary decode not faster than ::text — rollback");
std::process::exit(1);
}
println!("GATE PASS");
}
+11 -4
View File
@@ -135,9 +135,15 @@ fn suggest_before(files: &[File], q: &str) -> Vec<SearchSuggestionItem> {
item_type: "file".to_string(),
id: file_dto.id.clone(),
path: file_dto.path.clone(),
icon_class: icon_class_for(&file_dto.name, &file_dto.mime_type).to_string(),
// `.into()` bridges the round-9 `Arc<str>` field type; the
// conversion is identical on both arms so the round-5 delta
// this bench gates (clone vs move) is unaffected.
icon_class: icon_class_for(&file_dto.name, &file_dto.mime_type)
.to_string()
.into(),
icon_special_class: icon_special_class_for(&file_dto.name, &file_dto.mime_type)
.to_string(),
.to_string()
.into(),
relevance_score: score,
});
}
@@ -159,8 +165,9 @@ fn suggest_after(files: Vec<File>, q: &str) -> Vec<SearchSuggestionItem> {
item_type: "file".to_string(),
id: file_dto.id,
path: file_dto.path,
icon_class,
icon_special_class,
// Same `.into()` bridge as the BEFORE arm — see note there.
icon_class: icon_class.into(),
icon_special_class: icon_special_class.into(),
relevance_score: score,
});
}
+369
View File
@@ -0,0 +1,369 @@
//! 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.");
}
+334
View File
@@ -0,0 +1,334 @@
//! NextCloud per-request session benchmark — deep-clone vs `Arc` end-to-end.
//!
//! Every authenticated NC request (all six DAV dispatchers + OCS) extracts
//! the session. The old pipeline paid, per request:
//!
//! • extractor: `(**arc).clone()` — a DEEP clone of `NcSession`
//! (`CurrentUser` 3 Strings + `raw_username` + chroot `FolderDto`
//! ~5 Strings ≈ 8-9 heap allocs) despite the doc claiming "one Arc
//! increment";
//! • chroot cache hit: moka `get` clones the stored `FolderDto` by value
//! (~5 more allocs) on the markerless (default-drive) branch;
//! • session build: `CurrentUser` built then cloned for the extension,
//! `raw_username` cloned, `user_id.to_string()` for the span.
//!
//! Round 9 stores `Arc<FolderDto>` in the cache, shares one
//! `Arc<CurrentUser>` between the extension and the session, and extracts
//! `SharedNcSession` (an `Arc` handle that derefs to `NcSession`).
//!
//! `mod before` replicates the old struct shapes + clone flows verbatim;
//! equivalence gates assert every field consumed by handlers is identical.
//!
//! Sections:
//! 1. Extractor — allocs/extract + ns/extract (BEFORE deep clone vs
//! AFTER production `SharedNcSession::from_request_parts`)
//! 2. Chroot-cache hit — allocs/hit (FolderDto-by-value vs Arc)
//! 3. Session build — allocs/build (double CurrentUser + clones vs
//! single shared Arc + moves)
//!
//! Run (no Postgres needed):
//! cargo run --release --features bench --example bench_nc_session
//! Tunables (env): BENCH_REQS (100000)
use std::alloc::{GlobalAlloc, Layout, System};
use std::env;
use std::hint::black_box;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use axum::extract::FromRequestParts;
use oxicloud::application::dtos::folder_dto::FolderDto;
use oxicloud::interfaces::middleware::auth::CurrentUser;
use oxicloud::interfaces::nextcloud::session::{NcSession, SharedNcSession};
// ─── Counting allocator ─────────────────────────────────────────────────────
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)
}
// ─── BEFORE replicas (verbatim old shapes) ──────────────────────────────────
mod before {
use super::*;
/// Old `NcSession` shape: owned `CurrentUser`, chroot by value.
#[derive(Debug, Clone)]
pub struct OldNcSession {
pub user: CurrentUser,
pub raw_username: String,
pub chroot: Option<FolderDto>,
}
/// Old extractor body: deep clone out of the shared Arc.
pub fn extract(arc: &Arc<OldNcSession>) -> OldNcSession {
(**arc).clone()
}
}
fn fixture_folder() -> FolderDto {
FolderDto {
id: uuid::Uuid::new_v4().to_string(),
name: "Personal".to_string(),
path: "Personal".to_string(),
parent_id: None,
drive_id: uuid::Uuid::new_v4(),
created_at: 1_700_000_000,
modified_at: 1_700_000_100,
is_root: true,
etag: "8f2e5a1c9b3d4e6f".to_string(),
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
created_by: None,
updated_by: None,
}
}
fn fixture_user(id: uuid::Uuid) -> CurrentUser {
CurrentUser {
id,
username: "alice.longname".to_string(),
email: "alice.longname@example.com".to_string(),
role: "user".to_string(),
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let reqs: usize = env_or("BENCH_REQS", 100_000);
let user_id = uuid::Uuid::new_v4();
// ── Section 1: extractor ────────────────────────────────────────────────
let old_session = Arc::new(before::OldNcSession {
user: fixture_user(user_id),
raw_username: "alice.longname".to_string(),
chroot: Some(fixture_folder()),
});
let new_session = Arc::new(NcSession {
user: Arc::new(fixture_user(user_id)),
raw_username: "alice.longname".to_string(),
chroot: Some(Arc::new(fixture_folder())),
});
// Equivalence gate: every field handlers consume is identical.
{
let old = before::extract(&old_session);
let (mut parts, _) = axum::http::Request::builder()
.uri("/ocs/v2.php/cloud/user")
.extension(Arc::clone(&new_session))
.body(())
.expect("request")
.into_parts();
let new = SharedNcSession::from_request_parts(&mut parts, &())
.await
.expect("extract");
assert_eq!(old.user.id, new.user.id);
assert_eq!(old.user.username, new.user.username);
assert_eq!(old.user.email, new.user.email);
assert_eq!(old.user.role, new.user.role);
assert_eq!(old.raw_username, new.raw_username);
let (oc, nc) = (old.chroot.as_ref().unwrap(), new.require_chroot().unwrap());
assert_eq!(oc.name, nc.name);
assert_eq!(oc.path, nc.path);
assert_eq!(oc.etag, nc.etag);
println!("# equivalence gate: extracted session fields identical — OK");
}
// The URL cross-check runs in both arms' request flow; the BEFORE arm
// replicates only the clone (its cross-check was identical string
// compare — unchanged by round 9), so both arms time the same work
// minus the measured clone-vs-bump difference.
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
for _ in 0..reqs {
black_box(before::extract(black_box(&old_session)));
}
let before_ms = t.elapsed().as_secs_f64() * 1e3;
let before_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0;
let (mut parts, _) = axum::http::Request::builder()
.uri("/ocs/v2.php/cloud/user")
.extension(Arc::clone(&new_session))
.body(())
.expect("request")
.into_parts();
let a1 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
for _ in 0..reqs {
let s = SharedNcSession::from_request_parts(black_box(&mut parts), &())
.await
.expect("extract");
black_box(&s);
}
let after_ms = t.elapsed().as_secs_f64() * 1e3;
let after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1;
println!("\n#################################################################");
println!("# [1] NC session extractor — deep clone vs Arc handle");
println!("# extracts={reqs}");
println!("#################################################################\n");
println!(
"| {:<26} | {:>10} | {:>12} | {:>14} |",
"arm", "wall ms", "allocs", "allocs/extract"
);
println!(
"| {:<26} | {:>10.1} | {:>12} | {:>14.3} |",
"BEFORE (deep clone)",
before_ms,
before_allocs,
before_allocs as f64 / reqs as f64
);
println!(
"| {:<26} | {:>10.1} | {:>12} | {:>14.3} |",
"AFTER (SharedNcSession)",
after_ms,
after_allocs,
after_allocs as f64 / reqs as f64
);
let s1_ok = after_allocs < before_allocs && after_ms < before_ms;
// ── Section 2: chroot-cache hit ─────────────────────────────────────────
let by_value: moka::sync::Cache<uuid::Uuid, FolderDto> = moka::sync::Cache::new(100);
let by_arc: moka::sync::Cache<uuid::Uuid, Arc<FolderDto>> = moka::sync::Cache::new(100);
let root_id = uuid::Uuid::new_v4();
by_value.insert(root_id, fixture_folder());
by_arc.insert(root_id, Arc::new(fixture_folder()));
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
for _ in 0..reqs {
black_box(by_value.get(black_box(&root_id)));
}
let bv_ms = t.elapsed().as_secs_f64() * 1e3;
let bv_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0;
let a1 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
for _ in 0..reqs {
black_box(by_arc.get(black_box(&root_id)));
}
let ba_ms = t.elapsed().as_secs_f64() * 1e3;
let ba_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1;
println!("\n#################################################################");
println!("# [2] chroot-cache hit — FolderDto by value vs Arc<FolderDto>");
println!("# hits={reqs}");
println!("#################################################################\n");
println!(
"| {:<26} | {:>10} | {:>12} | {:>12} |",
"arm", "wall ms", "allocs", "allocs/hit"
);
println!(
"| {:<26} | {:>10.1} | {:>12} | {:>12.3} |",
"BEFORE (by value)",
bv_ms,
bv_allocs,
bv_allocs as f64 / reqs as f64
);
println!(
"| {:<26} | {:>10.1} | {:>12} | {:>12.3} |",
"AFTER (Arc)",
ba_ms,
ba_allocs,
ba_allocs as f64 / reqs as f64
);
let s2_ok = ba_allocs < bv_allocs;
// ── Section 3: session build ────────────────────────────────────────────
// BEFORE: build CurrentUser, clone it for the extension Arc, clone
// raw_username, `to_string` the span value. AFTER: one Arc shared by
// extension + session, raw_username moved, span rendered lazily (the
// lazy render costs nothing here; the removed `to_string` did).
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
for _ in 0..reqs {
let raw_username = String::from("alice.longname");
let span_value = user_id.to_string();
let current_user = fixture_user(user_id);
let ext = Arc::new(current_user.clone());
let session = Arc::new(before::OldNcSession {
user: current_user,
raw_username: raw_username.clone(),
chroot: None,
});
black_box((&span_value, &ext, &session));
}
let sb_ms = t.elapsed().as_secs_f64() * 1e3;
let sb_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0;
let a1 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
for _ in 0..reqs {
let raw_username = String::from("alice.longname");
let current_user = Arc::new(fixture_user(user_id));
let ext = Arc::clone(&current_user);
let session = Arc::new(NcSession {
user: current_user,
raw_username,
chroot: None,
});
black_box((&ext, &session));
}
let sa_ms = t.elapsed().as_secs_f64() * 1e3;
let sa_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1;
println!("\n#################################################################");
println!("# [3] session build — double CurrentUser + clones vs shared Arc");
println!("# builds={reqs}");
println!("#################################################################\n");
println!(
"| {:<26} | {:>10} | {:>12} | {:>12} |",
"arm", "wall ms", "allocs", "allocs/build"
);
println!(
"| {:<26} | {:>10.1} | {:>12} | {:>12.3} |",
"BEFORE (clone x2 + span)",
sb_ms,
sb_allocs,
sb_allocs as f64 / reqs as f64
);
println!(
"| {:<26} | {:>10.1} | {:>12} | {:>12.3} |",
"AFTER (shared Arc)",
sa_ms,
sa_allocs,
sa_allocs as f64 / reqs as f64
);
let s3_ok = sa_allocs < sb_allocs;
if !(s1_ok && s2_ok && s3_ok) {
eprintln!("\nGATE FAIL: (extractor={s1_ok} cache={s2_ok} build={s3_ok}) — rollback");
std::process::exit(1);
}
println!("\nGATE PASS: all three session stages allocate less with identical fields.");
}
+273
View File
@@ -7,6 +7,14 @@
//! category classes first (they borrow `&row.name`), then MOVES `row.name`
//! into the DTO — the same output, one fewer alloc per row.
//!
//! Section 2 (round 9): the SAME clone-vs-move port applied to the
//! favorites/recents listings (`/api/favorites/resources`,
//! `/api/recent/resources`), which the round-7 rewrite never reached. Their
//! per-row mapping additionally cloned `row.path` (owner rows) and
//! `row.blob_hash` (file rows), so the saving is up to 3 allocs per file row.
//! The two handlers share one mapping shape (only the `favorited_at` /
//! `accessed_at` passthrough differs), so the favorites row stands for both.
//!
//! Run:
//! cargo run --release --features bench --example bench_resource_row_map
//! Tunables (env): BENCH_ROWS (500).
@@ -21,6 +29,7 @@ use oxicloud::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for, intern_display,
intern_mime,
};
use oxicloud::application::dtos::favorites_dto::FavoriteResourceRow;
use oxicloud::application::dtos::file_dto::FileDto;
use oxicloud::application::dtos::folder_dto::{FolderDto, FolderResourceRow};
use oxicloud::domain::entities::file::File;
@@ -223,6 +232,218 @@ fn map_after(rows: Vec<FolderResourceRow>) -> Vec<Probe> {
.collect()
}
// ── Section 2: favorites/recents row→DTO mapping (round 9 port) ─────────────
fn fav_rows(n: usize) -> Vec<FavoriteResourceRow> {
let ts: DateTime<Utc> = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
(0..n)
.map(|i| {
let is_folder = i % 4 == 0;
FavoriteResourceRow {
resource_type: if is_folder { "folder" } else { "file" }.to_string(),
resource_id: Uuid::new_v4(),
name: if is_folder {
format!("Folder {i:05}")
} else {
format!("document-{i:05}.pdf")
},
parent_id: Some(Uuid::new_v4()),
mime_type: if is_folder {
None
} else {
Some("application/pdf".to_string())
},
size: if is_folder { -1 } else { 4096 },
resource_created_at: ts,
modified_at: ts,
drive_id: Uuid::new_v4(),
blob_hash: if is_folder {
None
} else {
Some("a".repeat(64))
},
is_owner: true,
favorited_at: ts,
path: Some(format!("Documents/Work/item-{i:05}")),
sort_str: Some(format!("row {i}")),
sort_int: None,
sort_ts: None,
}
})
.collect()
}
/// (name, path, content_hash, icon_class, category) — every field the
/// clone→move rewrite touches on the favorites/recents mapping.
type FavProbe = (
String,
String,
String,
std::sync::Arc<str>,
std::sync::Arc<str>,
);
/// BEFORE — verbatim favorites/recents mapping: `row.path.clone()`,
/// `row.name.clone()` (both branches) and `row.blob_hash.clone()`.
fn fav_map_before(rows: Vec<FavoriteResourceRow>) -> Vec<FavProbe> {
rows.into_iter()
.map(|row| {
let path = if row.is_owner {
row.path.clone().unwrap_or_default()
} else {
String::new()
};
if row.resource_type == "folder" {
let resource_id = row.resource_id.to_string();
let dto = FolderDto {
etag: resource_id.clone(),
id: resource_id,
name: row.name.clone(),
path,
parent_id: row.parent_id.map(|u| u.to_string()),
drive_id: row.drive_id,
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
created_by: None,
updated_by: None,
};
(
dto.name,
dto.path,
String::new(),
dto.icon_class,
dto.category,
)
} else {
let mime = row
.mime_type
.as_deref()
.unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64;
let modified_at_u = row.modified_at.timestamp() as u64;
let content_hash = row.blob_hash.clone().unwrap_or_default();
let etag = if content_hash.is_empty() {
String::new()
} else {
File::compute_etag(&content_hash, modified_at_u)
};
let dto = FileDto {
id: row.resource_id.to_string(),
name: row.name.clone(),
path,
size: size_bytes,
mime_type: intern_mime(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: modified_at_u,
icon_class: intern_display(icon_class_for(&row.name, mime)),
icon_special_class: intern_display(icon_special_class_for(&row.name, mime)),
category: intern_display(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes),
sort_date: None,
content_hash,
etag,
created_by: None,
updated_by: None,
};
(
dto.name,
dto.path,
dto.content_hash,
dto.icon_class,
dto.category,
)
}
})
.collect()
}
/// AFTER — the round-9 handler code: `path`/`blob_hash` moved, classes
/// computed before `row.name` moves.
fn fav_map_after(rows: Vec<FavoriteResourceRow>) -> Vec<FavProbe> {
rows.into_iter()
.map(|row| {
let path = if row.is_owner {
row.path.unwrap_or_default()
} else {
String::new()
};
if row.resource_type == "folder" {
let resource_id = row.resource_id.to_string();
let dto = FolderDto {
etag: resource_id.clone(),
id: resource_id,
name: row.name,
path,
parent_id: row.parent_id.map(|u| u.to_string()),
drive_id: row.drive_id,
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
created_by: None,
updated_by: None,
};
(
dto.name,
dto.path,
String::new(),
dto.icon_class,
dto.category,
)
} else {
let mime = row
.mime_type
.as_deref()
.unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64;
let modified_at_u = row.modified_at.timestamp() as u64;
let content_hash = row.blob_hash.unwrap_or_default();
let etag = if content_hash.is_empty() {
String::new()
} else {
File::compute_etag(&content_hash, modified_at_u)
};
let icon_class = intern_display(icon_class_for(&row.name, mime));
let icon_special_class = intern_display(icon_special_class_for(&row.name, mime));
let category = intern_display(category_for(&row.name, mime));
let dto = FileDto {
id: row.resource_id.to_string(),
name: row.name,
path,
size: size_bytes,
mime_type: intern_mime(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: modified_at_u,
icon_class,
icon_special_class,
category,
size_formatted: format_file_size(size_bytes),
sort_date: None,
content_hash,
etag,
created_by: None,
updated_by: None,
};
(
dto.name,
dto.path,
dto.content_hash,
dto.icon_class,
dto.category,
)
}
})
.collect()
}
fn main() {
let n: usize = env_or("BENCH_ROWS", 500);
@@ -279,4 +500,56 @@ fn main() {
before_allocs.saturating_sub(after_allocs),
(before_allocs.saturating_sub(after_allocs)) as f64 / n as f64
);
// ── Section 2: favorites/recents mapping (round-9 port) ────────────────
if fav_map_before(fav_rows(n)) != fav_map_after(fav_rows(n)) {
eprintln!("EQUIVALENCE GATE FAILED: favorites mapping output differs");
std::process::exit(1);
}
std::hint::black_box(fav_map_before(fav_rows(n)));
std::hint::black_box(fav_map_after(fav_rows(n)));
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
std::hint::black_box(fav_map_before(fav_rows(n)));
let fb_ms = t.elapsed().as_secs_f64() * 1e3;
let fb_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0;
let a1 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
std::hint::black_box(fav_map_after(fav_rows(n)));
let fa_ms = t.elapsed().as_secs_f64() * 1e3;
let fa_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1;
println!("\n#################################################################");
println!("# [2] favorites/recents row→DTO mapping: clone path+name+hash vs move");
println!("# rows={n} (same mapping shape in both handlers)");
println!("#################################################################\n");
println!(
"| {:<20} | {:>12} | {:>10} | {:>14} |",
"arm", "allocs", "wall ms", "allocs/row"
);
println!(
"| {:<20} | {:>12} | {:>10.3} | {:>14.3} |",
"BEFORE (clone)",
fb_allocs,
fb_ms,
fb_allocs as f64 / n as f64
);
println!(
"| {:<20} | {:>12} | {:>10.3} | {:>14.3} |",
"AFTER (move)",
fa_allocs,
fa_ms,
fa_allocs as f64 / n as f64
);
println!(
"\nSaved {} allocs ({:.2}/row) — path + name + blob_hash clones removed.",
fb_allocs.saturating_sub(fa_allocs),
(fb_allocs.saturating_sub(fa_allocs)) as f64 / n as f64
);
if fa_allocs >= fb_allocs {
eprintln!("GATE FAIL: AFTER allocs not below BEFORE — rollback");
std::process::exit(1);
}
}
+218 -6
View File
@@ -13,7 +13,20 @@
//!
//! Section 2 measures the removed Azure `data.to_vec()` copy in isolation.
//!
//! Gates: AFTER request count == chunks (vs 2x), AFTER wall < BEFORE wall.
//! Section 3 (round 9) drives the same A/B **through the decorator stacks**
//! (`RetryBlobBackend`, `CachedBlobBackend`, and the full production
//! Cache(Encrypted(Retry(S3))) composition). Until round 9 neither Retry nor
//! Cached overrode `put_blob_from_bytes_unsynced`/`sync_blobs`, so the trait
//! default silently re-routed every decorated chunk write back through the
//! probing synced path — undoing this bench's own Section-1 win on every
//! remote deployment with retry or cache enabled. The BEFORE arm is the
//! still-present synced route (`put_blob_from_bytes`, byte-identical requests
//! to what the fallthrough produced); the AFTER arm is the now-forwarded
//! unsynced route. A write-through equivalence gate asserts the Cached stack
//! still populates its local cache identically on both routes.
//!
//! Gates: AFTER request count == chunks (vs 2x), AFTER wall < BEFORE wall,
//! per-stack AFTER HEADs == 0, cache population identical on both routes.
//!
//! No Postgres. Run:
//! cargo run --release --features bench --example bench_s3_put
@@ -28,6 +41,9 @@ use std::time::{Duration, Instant};
use bytes::Bytes;
use oxicloud::application::ports::blob_storage_ports::BlobStorageBackend;
use oxicloud::common::config::S3StorageConfig;
use oxicloud::infrastructure::services::cached_blob_backend::{BlobCacheConfig, CachedBlobBackend};
use oxicloud::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend;
use oxicloud::infrastructure::services::retry_blob_backend::{RetryBlobBackend, RetryPolicy};
use oxicloud::infrastructure::services::s3_blob_backend::S3BlobBackend;
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
@@ -37,6 +53,23 @@ fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
.unwrap_or(default)
}
/// Recursively count regular files under `dir` (the blob cache shards blobs
/// into 2-hex-char prefix subdirectories).
fn count_files(dir: &std::path::Path) -> usize {
let mut n = 0;
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
n += count_files(&path);
} else {
n += 1;
}
}
}
n
}
#[derive(Clone, Default)]
struct Counters {
heads: Arc<AtomicU64>,
@@ -75,11 +108,12 @@ async fn stub_s3(latency: Duration, counters: Counters) -> String {
}
async fn drive(
backend: Arc<S3BlobBackend>,
backend: Arc<dyn BlobStorageBackend>,
chunks: usize,
chunk_kb: usize,
concurrency: usize,
unsynced: bool,
hash_prefix: &str,
) -> f64 {
let payload = Bytes::from(vec![0x5au8; chunk_kb * 1024]);
let sem = Arc::new(tokio::sync::Semaphore::new(concurrency));
@@ -89,15 +123,17 @@ async fn drive(
let b = backend.clone();
let p = payload.clone();
let sem = sem.clone();
let hash = format!("{hash_prefix}{i:060x}");
set.spawn(async move {
let _permit = sem.acquire().await.expect("sem");
let hash = format!("{i:064x}");
let n = if unsynced {
b.put_blob_from_bytes_unsynced(&hash, p).await.expect("put")
} else {
b.put_blob_from_bytes(&hash, p).await.expect("put")
};
assert_eq!(n as usize, chunk_kb * 1024);
// Encrypted arms return the ciphertext size (plaintext + AEAD
// framing), so gate on >= rather than == for stack generality.
assert!(n as usize >= chunk_kb * 1024);
});
}
while let Some(r) = set.join_next().await {
@@ -106,6 +142,81 @@ async fn drive(
t.elapsed().as_secs_f64() * 1000.0
}
/// Run BEFORE (synced route == the pre-round-9 unsynced fallthrough) and
/// AFTER (forwarded unsynced route) through one backend stack, printing the
/// two rows and gating AFTER on zero probe requests. `prefixes` carries the
/// (BEFORE, AFTER) hash namespaces keeping the arms' key spaces disjoint.
async fn stack_ab(
label: &str,
backend: Arc<dyn BlobStorageBackend>,
counters: &Counters,
chunks: usize,
chunk_kb: usize,
concurrency: usize,
prefixes: (&str, &str),
) -> (f64, f64) {
let (prefix_before, prefix_after) = prefixes;
let before = drive(
backend.clone(),
chunks,
chunk_kb,
concurrency,
false,
prefix_before,
)
.await;
let before_heads = counters.heads.swap(0, Ordering::Relaxed);
let before_puts = counters.puts.swap(0, Ordering::Relaxed);
println!(
"{:<34} {:>10.0} {:>8} {:>8} {:>8}",
format!("{label} BEFORE (synced route)"),
before,
before_heads,
before_puts,
"1.0x"
);
let after = drive(
backend.clone(),
chunks,
chunk_kb,
concurrency,
true,
prefix_after,
)
.await;
let after_heads = counters.heads.swap(0, Ordering::Relaxed);
let after_puts = counters.puts.swap(0, Ordering::Relaxed);
println!(
"{:<34} {:>10.0} {:>8} {:>8} {:>8}",
format!("{label} AFTER (unsynced)"),
after,
after_heads,
after_puts,
format!("{:.1}x", before / after)
);
if before_heads != chunks as u64 {
eprintln!(
"GATE FAIL [{label}]: BEFORE issued {before_heads} HEADs (expected {chunks} — the probing route must still probe)"
);
std::process::exit(1);
}
if after_heads != 0 || after_puts != chunks as u64 {
eprintln!(
"GATE FAIL [{label}]: AFTER issued {after_heads} HEADs / {after_puts} PUTs (expected 0 / {chunks})"
);
std::process::exit(1);
}
if after >= before {
eprintln!(
"GATE FAIL [{label}]: AFTER ({after:.0} ms) not faster than BEFORE ({before:.0} ms) — rollback"
);
std::process::exit(1);
}
(before, after)
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
let chunks: usize = env_or("BENCH_CHUNKS", 500);
@@ -133,7 +244,15 @@ async fn main() {
);
// BEFORE: the trait-default route (put_blob_from_bytes = HEAD + PUT).
let before = drive(backend.clone(), chunks, chunk_kb, concurrency, false).await;
let before = drive(
backend.clone() as Arc<dyn BlobStorageBackend>,
chunks,
chunk_kb,
concurrency,
false,
"a0a0",
)
.await;
let before_heads = counters.heads.swap(0, Ordering::Relaxed);
let before_puts = counters.puts.swap(0, Ordering::Relaxed);
println!(
@@ -142,7 +261,15 @@ async fn main() {
);
// AFTER: the unsynced override (PUT only).
let after = drive(backend.clone(), chunks, chunk_kb, concurrency, true).await;
let after = drive(
backend.clone() as Arc<dyn BlobStorageBackend>,
chunks,
chunk_kb,
concurrency,
true,
"a0a1",
)
.await;
let after_heads = counters.heads.swap(0, Ordering::Relaxed);
let after_puts = counters.puts.swap(0, Ordering::Relaxed);
println!(
@@ -168,6 +295,91 @@ async fn main() {
"\n# [2] removed Azure per-chunk copy: to_vec() of {mb} MiB = {copy_ms:.2} ms + {mb} MiB transient alloc per chunk"
);
// ── Section 3: the same A/B through the decorator stacks ────────────
println!(
"\n# [3] decorated stacks — pre-round-9 the unsynced call fell through to the synced (probing) route"
);
println!(
"{:<34} {:>10} {:>8} {:>8} {:>8}",
"variant", "wall ms", "HEADs", "PUTs", "vs OLD"
);
// Retry(S3)
let retry_stack: Arc<dyn BlobStorageBackend> = Arc::new(RetryBlobBackend::new(
backend.clone() as Arc<dyn BlobStorageBackend>,
RetryPolicy::default(),
));
stack_ab(
"retry(s3)",
retry_stack,
&counters,
chunks,
chunk_kb,
concurrency,
("b0b0", "b0b1"),
)
.await;
// Cache(S3) — count cache write-through population on both routes.
let cache_dir_a = tempfile::tempdir().expect("tempdir");
let cached_stack: Arc<dyn BlobStorageBackend> = Arc::new(CachedBlobBackend::new(
backend.clone() as Arc<dyn BlobStorageBackend>,
&BlobCacheConfig {
cache_dir: cache_dir_a.path().to_path_buf(),
max_cache_bytes: u64::MAX,
},
));
stack_ab(
"cache(s3)",
cached_stack,
&counters,
chunks,
chunk_kb,
concurrency,
("c0c0", "c0c1"),
)
.await;
// Write-through equivalence gate: BOTH routes populated the local cache
// (the round-9 override keeps post-upload read locality intact).
let cached_files = count_files(cache_dir_a.path());
if cached_files != 2 * chunks {
eprintln!(
"GATE FAIL [cache(s3)]: cache holds {cached_files} blobs (expected {} — write-through must populate on BOTH routes)",
2 * chunks
);
std::process::exit(1);
}
// Full production composition: Cache(Encrypted(Retry(S3))).
let cache_dir_b = tempfile::tempdir().expect("tempdir");
let full_stack: Arc<dyn BlobStorageBackend> = Arc::new(CachedBlobBackend::new(
Arc::new(EncryptedBlobBackend::new(
Arc::new(RetryBlobBackend::new(
backend.clone() as Arc<dyn BlobStorageBackend>,
RetryPolicy::default(),
)),
&[0x42u8; 32],
)),
&BlobCacheConfig {
cache_dir: cache_dir_b.path().to_path_buf(),
max_cache_bytes: u64::MAX,
},
));
let (full_before, full_after) = stack_ab(
"cache(enc(retry(s3)))",
full_stack,
&counters,
chunks,
chunk_kb,
concurrency,
("d0d0", "d0d1"),
)
.await;
println!(
"# full stack: a {chunks}-chunk upload sheds {} probe round-trips ({:.0} -> {:.0} ms at {rtt_ms} ms RTT)",
chunks, full_before, full_after
);
// ── Gates ───────────────────────────────────────────────────────────
if after_heads != 0 || after_puts != chunks as u64 {
eprintln!(
+4 -4
View File
@@ -133,15 +133,15 @@ fn synth_entry(idx: u64) -> Arc<SearchResultsDto> {
name,
path,
size: 831_942,
mime_type: MIMES[row % MIMES.len()].to_string(),
mime_type: MIMES[row % MIMES.len()].into(),
folder_id: Some(pseudo_uuid(&mut rng)),
created_at: 1_752_700_000,
modified_at: 1_752_800_000,
relevance_score: 50,
size_formatted: "812.4 KB".to_string(),
icon_class: "fas fa-file-pdf".to_string(),
icon_special_class: "pdf-icon".to_string(),
category: "document".to_string(),
icon_class: "fas fa-file-pdf".into(),
icon_special_class: "pdf-icon".into(),
category: "document".into(),
blob_hash: pseudo_hex(&mut rng, 64),
snippet: content_hit.then(|| SNIPPET.to_string()),
match_source: Some(match_source.to_string()),
+566
View File
@@ -0,0 +1,566 @@
//! Search-result enrichment benchmark — borrow+clone+reclassify vs consume.
//!
//! `SearchService::enrich_file` took `&FileDto`, cloned every owned `String`
//! out of it (id/name/path/folder_id/content_hash), allocated fresh `String`s
//! for `mime_type` + the three display fields, and RE-RAN the three display
//! classifiers (`icon_class_for` / `icon_special_class_for` / `category_for`)
//! whose results the `FileDto` already carried interned (`Arc<str>`, computed
//! once in `FileDto::from`). The recursive search branch runs this map over
//! the ENTIRE pre-pagination match set, so a subtree query matching thousands
//! of files paid ~11 allocs + 3 classifier passes per row. `enrich_folder`
//! cloned its 4 strings the same way, and the NC REPORT conversion
//! (`file_dto_from_search`) re-ran all three classifiers a SECOND time per
//! emitted row.
//!
//! Round 9 changes `SearchFileResultDto.{mime_type,icon_class,
//! icon_special_class,category}` to `Arc<str>`, makes both enrichers consume
//! their DTO (strings move, interned fields transfer as refcount bumps), and
//! has the NC conversion reuse the carried values.
//!
//! `mod before` holds the pre-round-9 logic verbatim (old struct shape
//! included); the equivalence gate asserts field-by-field identical output
//! for every row, and the NC-conversion gate asserts the reused display
//! fields byte-equal a fresh classifier run.
//!
//! Sections:
//! 1. enrich_file — ns/row + allocs/row, BEFORE vs AFTER
//! 2. enrich_folder — ns/row + allocs/row, BEFORE vs AFTER
//! 3. NC REPORT search→FileDto conversion — allocs/row, BEFORE vs AFTER
//!
//! Run (no Postgres needed):
//! cargo run --release --features bench --example bench_search_enrich
//! Tunables (env): BENCH_ROWS (10000), BENCH_PASSES (50)
use std::alloc::{GlobalAlloc, Layout, System};
use std::env;
use std::hint::black_box;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use oxicloud::application::dtos::file_dto::FileDto;
use oxicloud::application::dtos::folder_dto::FolderDto;
use oxicloud::application::services::search_service::SearchService;
// ─── Counting allocator ─────────────────────────────────────────────────────
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)
}
// ─── BEFORE: verbatim pre-round-9 logic ─────────────────────────────────────
#[allow(clippy::all)]
mod before {
use oxicloud::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
};
use oxicloud::application::dtos::file_dto::FileDto;
use oxicloud::application::dtos::folder_dto::FolderDto;
use oxicloud::domain::entities::file::File;
/// Old `SearchFileResultDto` shape — all-String display fields.
pub struct OldSearchFileResultDto {
pub id: String,
pub name: String,
pub path: String,
pub size: u64,
pub mime_type: String,
pub folder_id: Option<String>,
pub created_at: u64,
pub modified_at: u64,
pub relevance_score: u32,
pub size_formatted: String,
pub icon_class: String,
pub icon_special_class: String,
pub category: String,
pub blob_hash: String,
pub snippet: Option<String>,
pub match_source: Option<String>,
}
pub struct OldSearchFolderResultDto {
pub id: String,
pub name: String,
pub path: String,
pub parent_id: Option<String>,
pub drive_id: uuid::Uuid,
pub created_at: u64,
pub modified_at: u64,
pub is_root: bool,
pub relevance_score: u32,
}
// Verbatim copies of the old private helpers.
fn get_icon_class(name: &str, mime: &str) -> String {
icon_class_for(name, mime).to_string()
}
fn get_icon_special_class(name: &str, mime: &str) -> String {
icon_special_class_for(name, mime).to_string()
}
fn get_category(name: &str, mime: &str) -> String {
category_for(name, mime).to_string()
}
/// Verbatim copy of the service's private `format_bytes` (unchanged by
/// round 9; the equivalence gate asserts it still matches production).
pub fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
if bytes == 0 {
return "0 B".to_string();
}
let exp = (bytes as f64).log(1024.0).floor() as usize;
let exp = exp.min(UNITS.len() - 1);
let value = bytes as f64 / 1024_f64.powi(exp as i32);
if exp == 0 {
format!("{} B", bytes)
} else {
format!("{:.1} {}", value, UNITS[exp])
}
}
/// Verbatim copy of the service's private `compute_relevance` (unchanged
/// by round 9; the equivalence gate asserts it still matches production).
pub fn compute_relevance(name: &str, query_lower: &str) -> u32 {
let name_lower = name.to_lowercase();
if name_lower == query_lower {
100
} else if name_lower.starts_with(query_lower) {
80
} else if name_lower.contains(query_lower) {
// Bonus for shorter names (more specific match)
let ratio = query_lower.len() as f64 / name_lower.len() as f64;
50 + (ratio * 20.0) as u32
} else {
0
}
}
/// Verbatim old `enrich_file` (borrowing, cloning, re-classifying).
pub fn enrich_file(file: &FileDto, query_lower: &str) -> OldSearchFileResultDto {
let relevance = if query_lower.is_empty() {
50
} else {
compute_relevance(&file.name, query_lower)
};
OldSearchFileResultDto {
id: file.id.clone(),
name: file.name.clone(),
path: file.path.clone(),
size: file.size,
mime_type: file.mime_type.to_string(),
folder_id: file.folder_id.clone(),
created_at: file.created_at,
modified_at: file.modified_at,
relevance_score: relevance,
size_formatted: format_bytes(file.size),
icon_class: get_icon_class(&file.name, &file.mime_type),
icon_special_class: get_icon_special_class(&file.name, &file.mime_type),
category: get_category(&file.name, &file.mime_type),
blob_hash: file.content_hash.clone(),
snippet: None,
match_source: (!query_lower.is_empty() && relevance > 0).then(|| "name".to_string()),
}
}
/// Verbatim old `enrich_folder`.
pub fn enrich_folder(folder: &FolderDto, query_lower: &str) -> OldSearchFolderResultDto {
let relevance = if query_lower.is_empty() {
50
} else {
compute_relevance(&folder.name, query_lower)
};
OldSearchFolderResultDto {
id: folder.id.clone(),
name: folder.name.clone(),
path: folder.path.clone(),
parent_id: folder.parent_id.clone(),
drive_id: folder.drive_id,
created_at: folder.created_at,
modified_at: folder.modified_at,
is_root: folder.is_root,
relevance_score: relevance,
}
}
/// Verbatim old NC REPORT `file_dto_from_search` body (String-field
/// input shape) — re-runs all three classifiers per converted row.
pub fn file_dto_from_search(fr: &OldSearchFileResultDto) -> FileDto {
let etag = if fr.blob_hash.is_empty() {
String::new()
} else {
File::compute_etag(&fr.blob_hash, fr.modified_at)
};
FileDto {
id: fr.id.clone(),
name: fr.name.clone(),
path: fr.path.clone(),
size: fr.size,
mime_type: fr.mime_type.clone().into(),
folder_id: fr.folder_id.clone(),
created_at: fr.created_at,
modified_at: fr.modified_at,
icon_class: icon_class_for(&fr.name, &fr.mime_type).to_string().into(),
icon_special_class: icon_special_class_for(&fr.name, &fr.mime_type)
.to_string()
.into(),
category: category_for(&fr.name, &fr.mime_type).to_string().into(),
size_formatted: format_file_size(fr.size),
sort_date: None,
content_hash: fr.blob_hash.clone(),
etag,
created_by: None,
updated_by: None,
}
}
}
// ─── Fixture ────────────────────────────────────────────────────────────────
const NAMES: [(&str, &str); 5] = [
("report-{i}.pdf", "application/pdf"),
("photo-{i}.jpg", "image/jpeg"),
("notes-{i}.txt", "text/plain"),
("track-{i}.mp3", "audio/mpeg"),
("data-{i}.bin", "application/octet-stream"),
];
fn file_dtos(n: usize) -> Vec<FileDto> {
(0..n)
.map(|i| {
let (name_t, mime) = NAMES[i % NAMES.len()];
let name = name_t.replace("{i}", &format!("{i:05}"));
let file = oxicloud::domain::entities::file::File::from_materialized_row(
uuid::Uuid::new_v4().to_string(),
name,
Some("Documents/Work"),
4096 + i as u64,
mime.to_string(),
Some(uuid::Uuid::new_v4().to_string()),
1_700_000_000,
1_700_000_100,
"a".repeat(64),
None,
None,
)
.expect("fixture file");
FileDto::from(file)
})
.collect()
}
fn folder_dtos(n: usize) -> Vec<FolderDto> {
(0..n)
.map(|i| FolderDto {
id: uuid::Uuid::new_v4().to_string(),
name: format!("Folder {i:05}"),
path: format!("Documents/Folder-{i:05}"),
parent_id: Some(uuid::Uuid::new_v4().to_string()),
drive_id: uuid::Uuid::new_v4(),
created_at: 1_700_000_000,
modified_at: 1_700_000_100,
is_root: false,
etag: format!("{i:032x}"),
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
created_by: None,
updated_by: None,
})
.collect()
}
fn p50(mut v: Vec<f64>) -> f64 {
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
v[v.len() / 2]
}
fn main() {
let n: usize = env_or("BENCH_ROWS", 10_000);
let passes: usize = env_or("BENCH_PASSES", 50);
let query_lower = "report";
// ── Equivalence gate: field-by-field identical enrichment ───────────────
{
let dtos = file_dtos(500);
for dto in &dtos {
let old = before::enrich_file(dto, query_lower);
let new = SearchService::enrich_file_for_bench(dto.clone(), query_lower);
let same = old.id == new.id
&& old.name == new.name
&& old.path == new.path
&& old.size == new.size
&& old.mime_type == *new.mime_type
&& old.folder_id == new.folder_id
&& old.created_at == new.created_at
&& old.modified_at == new.modified_at
&& old.relevance_score == new.relevance_score
&& old.size_formatted == new.size_formatted
&& old.icon_class == *new.icon_class
&& old.icon_special_class == *new.icon_special_class
&& old.category == *new.category
&& old.blob_hash == new.blob_hash
&& old.snippet == new.snippet
&& old.match_source == new.match_source;
if !same {
eprintln!("EQUIVALENCE GATE FAILED (file): {} differs", old.name);
std::process::exit(1);
}
}
let folders = folder_dtos(500);
for dto in &folders {
let old = before::enrich_folder(dto, query_lower);
let new = SearchService::enrich_folder_for_bench(dto.clone(), query_lower);
let same = old.id == new.id
&& old.name == new.name
&& old.path == new.path
&& old.parent_id == new.parent_id
&& old.drive_id == new.drive_id
&& old.created_at == new.created_at
&& old.modified_at == new.modified_at
&& old.is_root == new.is_root
&& old.relevance_score == new.relevance_score;
if !same {
eprintln!("EQUIVALENCE GATE FAILED (folder): {} differs", old.name);
std::process::exit(1);
}
}
println!("# equivalence gate: 500 files + 500 folders field-identical — OK");
}
// ── NC REPORT conversion gate: carried display fields == fresh run ──────
{
let dtos = file_dtos(500);
for dto in dtos {
let old_row = before::enrich_file(&dto, "");
let new_row = SearchService::enrich_file_for_bench(dto, "");
let old_conv = before::file_dto_from_search(&old_row);
let new_conv =
oxicloud::interfaces::nextcloud::report_handler::file_dto_from_search_for_bench(
&new_row,
);
let same = old_conv.id == new_conv.id
&& old_conv.name == new_conv.name
&& old_conv.mime_type == new_conv.mime_type
&& old_conv.icon_class == new_conv.icon_class
&& old_conv.icon_special_class == new_conv.icon_special_class
&& old_conv.category == new_conv.category
&& old_conv.size_formatted == new_conv.size_formatted
&& old_conv.etag == new_conv.etag
&& old_conv.content_hash == new_conv.content_hash;
if !same {
eprintln!("NC CONVERSION GATE FAILED: {} differs", old_conv.name);
std::process::exit(1);
}
}
println!("# NC REPORT conversion gate: 500 rows field-identical — OK");
}
// ── Section 1: enrich_file wall + allocs ────────────────────────────────
let mut before_wall = Vec::with_capacity(passes);
let mut after_wall = Vec::with_capacity(passes);
let mut before_allocs = 0u64;
let mut after_allocs = 0u64;
for pass in 0..passes {
// BEFORE consumes borrowed rows: reuse one input set per pass, built
// outside the measured window (both arms see identical inputs).
let input = file_dtos(n);
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
let out: Vec<_> = input
.iter()
.map(|f| before::enrich_file(f, query_lower))
.collect();
before_wall.push(t.elapsed().as_secs_f64() * 1e9 / n as f64);
if pass == 0 {
before_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0;
}
black_box(&out);
drop(out);
let a1 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
let out: Vec<_> = input
.into_iter()
.map(|f| SearchService::enrich_file_for_bench(f, query_lower))
.collect();
after_wall.push(t.elapsed().as_secs_f64() * 1e9 / n as f64);
if pass == 0 {
after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1;
}
black_box(&out);
}
println!("\n#################################################################");
println!("# [1] enrich_file — borrow+clone+reclassify vs consume");
println!("# rows={n} passes={passes} (p50 of per-pass ns/row; allocs from pass 0)");
println!("#################################################################\n");
println!(
"| {:<22} | {:>10} | {:>12} | {:>12} |",
"arm", "ns/row", "allocs", "allocs/row"
);
println!(
"| {:<22} | {:>10.1} | {:>12} | {:>12.3} |",
"BEFORE (borrow+clone)",
p50(before_wall.clone()),
before_allocs,
before_allocs as f64 / n as f64
);
println!(
"| {:<22} | {:>10.1} | {:>12} | {:>12.3} |",
"AFTER (consume)",
p50(after_wall.clone()),
after_allocs,
after_allocs as f64 / n as f64
);
let s1_ok = after_allocs < before_allocs;
// ── Section 2: enrich_folder ────────────────────────────────────────────
let mut fb_wall = Vec::with_capacity(passes);
let mut fa_wall = Vec::with_capacity(passes);
let mut fb_allocs = 0u64;
let mut fa_allocs = 0u64;
for pass in 0..passes {
let input = folder_dtos(n);
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
let out: Vec<_> = input
.iter()
.map(|f| before::enrich_folder(f, query_lower))
.collect();
fb_wall.push(t.elapsed().as_secs_f64() * 1e9 / n as f64);
if pass == 0 {
fb_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0;
}
black_box(&out);
drop(out);
let a1 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
let out: Vec<_> = input
.into_iter()
.map(|f| SearchService::enrich_folder_for_bench(f, query_lower))
.collect();
fa_wall.push(t.elapsed().as_secs_f64() * 1e9 / n as f64);
if pass == 0 {
fa_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1;
}
black_box(&out);
}
println!("\n#################################################################");
println!("# [2] enrich_folder — borrow+clone vs consume");
println!("#################################################################\n");
println!(
"| {:<22} | {:>10} | {:>12} | {:>12} |",
"arm", "ns/row", "allocs", "allocs/row"
);
println!(
"| {:<22} | {:>10.1} | {:>12} | {:>12.3} |",
"BEFORE (borrow+clone)",
p50(fb_wall.clone()),
fb_allocs,
fb_allocs as f64 / n as f64
);
println!(
"| {:<22} | {:>10.1} | {:>12} | {:>12.3} |",
"AFTER (consume)",
p50(fa_wall.clone()),
fa_allocs,
fa_allocs as f64 / n as f64
);
let s2_ok = fa_allocs < fb_allocs;
// ── Section 3: NC REPORT conversion ─────────────────────────────────────
let conv_n = n.min(5_000);
let old_rows: Vec<_> = file_dtos(conv_n)
.iter()
.map(|f| before::enrich_file(f, ""))
.collect();
let new_rows: Vec<_> = file_dtos(conv_n)
.into_iter()
.map(|f| SearchService::enrich_file_for_bench(f, ""))
.collect();
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
let out: Vec<_> = old_rows.iter().map(before::file_dto_from_search).collect();
let conv_before_ms = t.elapsed().as_secs_f64() * 1e3;
let conv_before_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0;
black_box(&out);
drop(out);
let a1 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
let out: Vec<_> = new_rows
.iter()
.map(oxicloud::interfaces::nextcloud::report_handler::file_dto_from_search_for_bench)
.collect();
let conv_after_ms = t.elapsed().as_secs_f64() * 1e3;
let conv_after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1;
black_box(&out);
println!("\n#################################################################");
println!("# [3] NC REPORT search→FileDto conversion — reclassify vs carry");
println!("# rows={conv_n}");
println!("#################################################################\n");
println!(
"| {:<22} | {:>10} | {:>12} | {:>12} |",
"arm", "wall ms", "allocs", "allocs/row"
);
println!(
"| {:<22} | {:>10.3} | {:>12} | {:>12.3} |",
"BEFORE (reclassify)",
conv_before_ms,
conv_before_allocs,
conv_before_allocs as f64 / conv_n as f64
);
println!(
"| {:<22} | {:>10.3} | {:>12} | {:>12.3} |",
"AFTER (carry Arc)",
conv_after_ms,
conv_after_allocs,
conv_after_allocs as f64 / conv_n as f64
);
let s3_ok = conv_after_allocs < conv_before_allocs;
if !(s1_ok && s2_ok && s3_ok) {
eprintln!("\nGATE FAIL: allocs not reduced (s1={s1_ok} s2={s2_ok} s3={s3_ok}) — rollback");
std::process::exit(1);
}
println!("\nGATE PASS: allocs reduced in all three sections; outputs field-identical.");
}
+399
View File
@@ -0,0 +1,399 @@
//! Round-9 storage micro-pack benchmark — four independent A/Bs, no Postgres.
//!
//! [1] Local chunk write — the old `try_exists` (stat) + `File::create` pair
//! vs the new single atomic `create_new` open, at chunk-write level via
//! the bench wrapper over the production writer. Fresh-write AND
//! already-exists (dedup re-upload skip) arms.
//! [2] CDC read prep — the old per-read deep clone of the cached manifest's
//! `Vec<String>` chunk-hash list vs the new index-over-`Arc` iteration
//! (structural replica of `DedupService::stream_chunks` before/after;
//! the production change is exactly this data-flow).
//! [3] Manifest cache miss herd — the old `get → SELECT → insert` shape vs
//! the new fast-get + `try_get_with` single-flight, K concurrent cold
//! readers on one key over a real moka cache with a counted loader
//! (structural replica of `DedupService::manifest_cached`, sqlx swapped
//! for a latency-injected counted loader).
//! [4] Chunk `Content-MD5` verification hex — 16× `format!("{b:02x}")` +
//! collect vs `common::fmt::hex_lower` (1 sized alloc).
//!
//! Gates: [1] AFTER wall < BEFORE wall (fresh) + identical on-disk content +
//! identical skip semantics; [2] AFTER allocs < BEFORE allocs + identical
//! hash sequence; [3] AFTER loader runs == 1 (BEFORE > 1) + identical value;
//! [4] identical hex + fewer allocs.
//!
//! Run:
//! cargo run --release --features bench --example bench_storage_micro
//! Tunables (env): BENCH_CHUNKS (20000), BENCH_CHUNK_KB (4), BENCH_HERD (64),
//! BENCH_MANIFEST_CHUNKS (4096)
use std::alloc::{GlobalAlloc, Layout, System};
use std::env;
use std::hint::black_box;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use bytes::Bytes;
use oxicloud::infrastructure::services::local_blob_backend::write_blob_bytes_for_bench;
// ─── Counting allocator ─────────────────────────────────────────────────────
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)
}
// ─── [1] BEFORE replica: stat-then-create chunk writer (verbatim) ───────────
async fn write_blob_bytes_before(
blob_path: &std::path::Path,
data: &Bytes,
) -> std::io::Result<Option<tokio::fs::File>> {
use tokio::io::AsyncWriteExt;
if tokio::fs::try_exists(blob_path).await.unwrap_or(false) {
return Ok(None);
}
let mut file = tokio::fs::File::create(blob_path).await?;
file.write_all(data).await?;
Ok(Some(file))
}
async fn section_1(chunks: usize, chunk_kb: usize) {
let payload = Bytes::from(vec![0x5au8; chunk_kb * 1024]);
let dir_before = tempfile::tempdir().expect("tempdir");
let dir_after = tempfile::tempdir().expect("tempdir");
// Fresh writes.
let t = Instant::now();
for i in 0..chunks {
let p = dir_before.path().join(format!("{i:08x}.blob"));
write_blob_bytes_before(&p, &payload)
.await
.expect("before write");
}
let before_fresh = t.elapsed().as_secs_f64() * 1e3;
let t = Instant::now();
for i in 0..chunks {
let p = dir_after.path().join(format!("{i:08x}.blob"));
write_blob_bytes_for_bench(&p, &payload)
.await
.expect("after write");
}
let after_fresh = t.elapsed().as_secs_f64() * 1e3;
// Equivalence: same file count, same bytes for a sample.
let sample = dir_after.path().join(format!("{:08x}.blob", chunks / 2));
let got = tokio::fs::read(&sample).await.expect("sample read");
assert_eq!(got.len(), payload.len(), "content length mismatch");
assert_eq!(&got[..64], &payload[..64], "content mismatch");
// Already-exists skip (dedup re-upload): both must return None-equivalent.
let t = Instant::now();
for i in 0..chunks {
let p = dir_before.path().join(format!("{i:08x}.blob"));
let r = write_blob_bytes_before(&p, &payload).await.expect("skip");
assert!(r.is_none(), "BEFORE re-put must skip");
}
let before_skip = t.elapsed().as_secs_f64() * 1e3;
let t = Instant::now();
for i in 0..chunks {
let p = dir_after.path().join(format!("{i:08x}.blob"));
let r = write_blob_bytes_for_bench(&p, &payload)
.await
.expect("skip");
assert!(r.is_none(), "AFTER re-put must skip (AlreadyExists)");
}
let after_skip = t.elapsed().as_secs_f64() * 1e3;
println!("\n#################################################################");
println!("# [1] local chunk write — stat+create vs atomic create_new");
println!("# chunks={chunks} x {chunk_kb} KiB");
println!("#################################################################\n");
println!(
"| {:<26} | {:>12} | {:>12} |",
"arm", "fresh ms", "re-put ms"
);
println!(
"| {:<26} | {:>12.1} | {:>12.1} |",
"BEFORE (stat+create)", before_fresh, before_skip
);
println!(
"| {:<26} | {:>12.1} | {:>12.1} |",
"AFTER (create_new)", after_fresh, after_skip
);
println!(
"\nfresh {:.2}x · re-put {:.2}x",
before_fresh / after_fresh,
before_skip / after_skip
);
if after_fresh >= before_fresh {
eprintln!("GATE FAIL [1]: create_new not faster on fresh writes — rollback");
std::process::exit(1);
}
}
// ─── [2] manifest read prep: Vec clone vs Arc-index ─────────────────────────
struct ManifestReplica {
chunk_hashes: Vec<String>,
}
fn section_2(manifest_chunks: usize) {
let manifest = Arc::new(ManifestReplica {
chunk_hashes: (0..manifest_chunks).map(|i| format!("{i:064x}")).collect(),
});
let reads = 200usize;
// BEFORE: each read clones the whole hash list out of the shared Arc
// (the old `stream_chunks(m.chunk_hashes.clone())` call shape).
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
let mut sum_before = 0usize;
for _ in 0..reads {
let hashes: Vec<String> = manifest.chunk_hashes.clone();
for h in &hashes {
sum_before += h.len();
}
black_box(&hashes);
}
let before_ms = t.elapsed().as_secs_f64() * 1e3;
let before_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0;
// AFTER: each read bumps the Arc and indexes (the new `stream_chunks(m)`).
let a1 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
let mut sum_after = 0usize;
for _ in 0..reads {
let m = manifest.clone();
for i in 0..m.chunk_hashes.len() {
sum_after += m.chunk_hashes[i].len();
}
black_box(&m);
}
let after_ms = t.elapsed().as_secs_f64() * 1e3;
let after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1;
assert_eq!(sum_before, sum_after, "hash sequence mismatch");
println!("\n#################################################################");
println!("# [2] CDC read prep — manifest Vec<String> clone vs Arc index");
println!("# manifest={manifest_chunks} chunks, reads={reads}");
println!("#################################################################\n");
println!(
"| {:<26} | {:>10} | {:>12} | {:>12} |",
"arm", "wall ms", "allocs", "allocs/read"
);
println!(
"| {:<26} | {:>10.3} | {:>12} | {:>12.1} |",
"BEFORE (clone Vec)",
before_ms,
before_allocs,
before_allocs as f64 / reads as f64
);
println!(
"| {:<26} | {:>10.3} | {:>12} | {:>12.1} |",
"AFTER (Arc index)",
after_ms,
after_allocs,
after_allocs as f64 / reads as f64
);
if after_allocs >= before_allocs {
eprintln!("GATE FAIL [2]: Arc-index not fewer allocs — rollback");
std::process::exit(1);
}
}
// ─── [3] manifest miss herd: get→insert vs try_get_with ─────────────────────
async fn section_3(herd: usize) {
type Cache = moka::future::Cache<String, Arc<Vec<u64>>>;
let value = || Arc::new(vec![7u64; 1024]);
let simulated_query = Duration::from_millis(2);
// BEFORE shape: check, query (2 ms), insert — every cold caller loads.
let cache: Cache = moka::future::Cache::new(1000);
let loads = Arc::new(AtomicU64::new(0));
let mut set = tokio::task::JoinSet::new();
let t = Instant::now();
for _ in 0..herd {
let cache = cache.clone();
let loads = loads.clone();
set.spawn(async move {
if let Some(v) = cache.get("hot-file").await {
return v;
}
loads.fetch_add(1, Ordering::Relaxed);
tokio::time::sleep(simulated_query).await;
let v = value();
cache.insert("hot-file".to_string(), v.clone()).await;
v
});
}
let mut first: Option<Arc<Vec<u64>>> = None;
while let Some(r) = set.join_next().await {
let v = r.expect("join");
if let Some(f) = &first {
assert_eq!(f.len(), v.len());
} else {
first = Some(v);
}
}
let before_ms = t.elapsed().as_secs_f64() * 1e3;
let before_loads = loads.load(Ordering::Relaxed);
// AFTER shape: fast get + try_get_with — the herd coalesces onto 1 load.
let cache: Cache = moka::future::Cache::new(1000);
let loads = Arc::new(AtomicU64::new(0));
let mut set = tokio::task::JoinSet::new();
let t = Instant::now();
for _ in 0..herd {
let cache = cache.clone();
let loads = loads.clone();
set.spawn(async move {
if let Some(v) = cache.get("hot-file").await {
return v;
}
cache
.try_get_with("hot-file".to_string(), async move {
loads.fetch_add(1, Ordering::Relaxed);
tokio::time::sleep(simulated_query).await;
Ok::<_, std::convert::Infallible>(value())
})
.await
.expect("infallible")
});
}
while let Some(r) = set.join_next().await {
let v = r.expect("join");
assert_eq!(v.len(), first.as_ref().unwrap().len());
}
let after_ms = t.elapsed().as_secs_f64() * 1e3;
let after_loads = loads.load(Ordering::Relaxed);
println!("\n#################################################################");
println!("# [3] manifest cold-miss herd — get→insert vs try_get_with");
println!("# herd={herd} concurrent readers, 2 ms simulated manifest SELECT");
println!("#################################################################\n");
println!("| {:<26} | {:>10} | {:>12} |", "arm", "wall ms", "loads");
println!(
"| {:<26} | {:>10.1} | {:>12} |",
"BEFORE (get→insert)", before_ms, before_loads
);
println!(
"| {:<26} | {:>10.1} | {:>12} |",
"AFTER (single-flight)", after_ms, after_loads
);
if after_loads != 1 {
eprintln!("GATE FAIL [3]: single-flight ran {after_loads} loads (expected 1) — rollback");
std::process::exit(1);
}
if before_loads <= 1 {
eprintln!(
"GATE WARN [3]: BEFORE herd only loaded {before_loads}x — herd too small to show the stampede"
);
}
}
// ─── [4] Content-MD5 hex ────────────────────────────────────────────────────
fn section_4() {
let digests: Vec<[u8; 16]> = (0..1000u32)
.map(|i| {
let mut d = [0u8; 16];
d[..4].copy_from_slice(&i.to_le_bytes());
d
})
.collect();
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
let before: Vec<String> = digests
.iter()
.map(|d| d.iter().map(|b| format!("{b:02x}")).collect::<String>())
.collect();
let before_ms = t.elapsed().as_secs_f64() * 1e3;
let before_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0;
let a1 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
let after: Vec<String> = digests
.iter()
.map(|d| oxicloud::common::fmt::hex_lower(d))
.collect();
let after_ms = t.elapsed().as_secs_f64() * 1e3;
let after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1;
assert_eq!(before, after, "hex output mismatch");
println!("\n#################################################################");
println!("# [4] chunk Content-MD5 hex — per-byte format! vs hex_lower");
println!("# digests=1000");
println!("#################################################################\n");
println!(
"| {:<26} | {:>10} | {:>12} | {:>14} |",
"arm", "wall ms", "allocs", "allocs/digest"
);
println!(
"| {:<26} | {:>10.3} | {:>12} | {:>14.2} |",
"BEFORE (format!/byte)",
before_ms,
before_allocs,
before_allocs as f64 / 1000.0
);
println!(
"| {:<26} | {:>10.3} | {:>12} | {:>14.2} |",
"AFTER (hex_lower)",
after_ms,
after_allocs,
after_allocs as f64 / 1000.0
);
if after_allocs >= before_allocs {
eprintln!("GATE FAIL [4]: hex_lower not fewer allocs — rollback");
std::process::exit(1);
}
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
let chunks: usize = env_or("BENCH_CHUNKS", 20_000);
let chunk_kb: usize = env_or("BENCH_CHUNK_KB", 4);
let herd: usize = env_or("BENCH_HERD", 64);
let manifest_chunks: usize = env_or("BENCH_MANIFEST_CHUNKS", 4096);
section_1(chunks, chunk_kb).await;
section_2(manifest_chunks);
section_3(herd).await;
section_4();
println!("\nGATE PASS: all four sections improved with identical outputs.");
}
+115 -4
View File
@@ -13,12 +13,23 @@
//! on any File/Folder grant write). The check still runs on every request —
//! it is never skipped — but after the first query it resolves in-memory.
//!
//! Round 9 additionally decomposes the FILE decision: parent point-read
//! (memoised) → the FOLDER cascade decision (one ltree query per folder,
//! shared by every sibling) → direct-file-grant fallback. A shared album's
//! COLD first view drops from one ltree UNION query per file to one ltree
//! query per FOLDER plus cheap PK reads. The `ROUND8 cold` arm below runs
//! the historical UNION verbatim per file for comparison.
//!
//! Safety gates (hard asserts, exit 1 on failure):
//! 1. the folder-grant recipient is allowed; an outsider is denied;
//! 2. REVOCATION — after a warm cache serves `allowed`, `clear_role` on the
//! shared folder makes the very next check DENY (proves the grant-write
//! invalidation flushes the cache; without it the stale `true` would
//! still serve).
//! still serve);
//! 3. DIRECT-GRANT SIBLING (round 9) — a caller holding ONLY a direct
//! grant on one file is allowed that file and denied its siblings,
//! proving the folder-level decomposition neither shadows direct file
//! grants nor leaks a file decision to siblings.
//!
//! Run (needs Postgres up; reads DATABASE_URL from .env):
//! cargo run --release --features bench --example bench_thumbnail_cascade_cache
@@ -29,7 +40,9 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use oxicloud::application::ports::authorization_ports::AuthorizationEngine;
use oxicloud::domain::services::authorization::{Permission, Resource, Role, Subject};
use oxicloud::domain::services::authorization::{
Permission, Resource, Role, Subject, roles_implying,
};
use oxicloud::infrastructure::repositories::pg::{
FileBlobReadRepository, FolderDbRepository, SubjectGroupPgRepository,
};
@@ -308,6 +321,46 @@ async fn main() {
.expect("re-grant");
}
// ── Safety gate 3 (round 9): direct-grant sibling isolation ──
// The outsider gets a DIRECT grant on file[0] only (no folder/drive
// grant): they must be allowed file[0] — the folder half of the
// decomposition denies, the direct half matches — and denied file[1]
// even immediately after the allowed check (no sibling leak through
// the folder-level cache).
{
let engine = fresh_engine(&pool);
engine
.set_role(
s.owner,
Subject::User(s.outsider),
Role::Viewer,
Resource::File(s.files[0]),
None,
)
.await
.expect("direct file grant");
if !allowed(&engine, s.outsider, s.files[0]).await {
eprintln!(
"SAFETY GATE FAILED: direct file grant denied — the folder-level \
decomposition shadowed the direct-grant branch"
);
cleanup(&pool, &s).await;
std::process::exit(1);
}
if allowed(&engine, s.outsider, s.files[1]).await {
eprintln!(
"SAFETY GATE FAILED: direct grant on file[0] leaked to a sibling — \
a file decision must never authorize other files"
);
cleanup(&pool, &s).await;
std::process::exit(1);
}
engine
.clear_role(Subject::User(s.outsider), Resource::File(s.files[0]))
.await
.expect("clear direct grant");
}
println!("\n#################################################################");
println!("# shared-album thumbnail authz: folder-cascade query/thumb vs cache");
println!("# thumbs={thumbs} (recipient holds a folder grant, no drive membership)");
@@ -331,8 +384,66 @@ async fn main() {
);
}
// AFTER cold: one persistent engine — the first grid view queries once per
// distinct file (cache misses populate).
// ROUND8 cold: the historical per-file UNION (direct grant ∨ ltree
// ancestor join) run verbatim once per file — what a cold first view
// cost before the round-9 folder-level decomposition.
{
let subject_types: Vec<&str> = vec!["user", "group"];
let subject_ids = vec![s.recipient];
let roles: Vec<&str> = roles_implying(Permission::Read)
.iter()
.map(|r| r.as_str())
.collect();
let t = Instant::now();
for &f in &s.files {
let exists: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM (
SELECT 1
FROM storage.role_grants
WHERE subject_type = ANY($1)
AND subject_id = ANY($2)
AND role = ANY($3::storage.grant_role[])
AND resource_type = 'file' AND resource_id = $4
AND (expires_at IS NULL OR expires_at > NOW())
UNION ALL
SELECT 1
FROM storage.role_grants g
JOIN storage.folders gf ON gf.id = g.resource_id
JOIN storage.files target_f ON target_f.id = $4
WHERE g.subject_type = ANY($1)
AND g.subject_id = ANY($2)
AND g.role = ANY($3::storage.grant_role[])
AND g.resource_type = 'folder'
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND target_f.folder_id IS NOT NULL
AND gf.lpath @> (SELECT lpath FROM storage.folders
WHERE id = target_f.folder_id)
) any_match
LIMIT 1
"#,
)
.bind(&subject_types)
.bind(&subject_ids)
.bind(&roles)
.bind(f)
.fetch_optional(pool.as_ref())
.await
.expect("round8 union query");
assert!(exists.is_some(), "ROUND8 arm: recipient must be allowed");
}
let el = t.elapsed();
println!(
"| {:<28} | {:>10.2} | {:>12.2} |",
"ROUND8 cold (union/file)",
el.as_secs_f64() * 1e3,
el.as_secs_f64() * 1e6 / thumbs as f64
);
}
// AFTER cold: one persistent engine — the first grid view resolves each
// file's parent (PK read) and shares ONE folder-cascade decision.
let engine = fresh_engine(&pool);
{
let t = Instant::now();