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:
@@ -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");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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(¤t_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.");
|
||||
}
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user