cd4c62042a
Round 3 of benchmark-gated optimizations (benches/ROUND3.md; every change gated by a before/after benchmark — an AFTER that did not beat its BEFORE was to be rolled back; none needed it. Equivalence gates assert identical row sequences / byte-identical output on every behavior-preserving rewrite): DB hot paths (local PG16, EXPLAIN-verified): - Web-UI listing (list_resources_paged): cursor pushed INSIDE the folders/files UNION-ALL branches as sargable row-value comparisons with per-branch ORDER/LIMIT + two partial expression indexes (folder_id, LOWER(name), id). 20k-entry folder: 26.6 -> 1.3 ms/page (19.5x); other sort modes at parity or better. New migration 20260918000000. [benches/LISTING-KEYSET.md section in ROUND3] - Photos timeline (list_media_files): per-drive CROSS JOIN LATERAL top-N on the timeline index, joins moved above the top-N. 50k-photo library: 97.4 -> 1.6 ms/page (55.7x). The old "LIMIT stops the scan early" comment was refuted by EXPLAIN. - PROPFIND sub-folders (both DAV surfaces): keyset list_folders_batch off idx_folders_unique_name replaces COUNT(*) OVER() + LIMIT/OFFSET (5k dirs: 79.7 -> 17.9 ms full walk, 4.5x). Concurrency: - Basic-auth cache single-flight (moka try_get_with): 8 concurrent DAV connections at TTL expiry paid 8 Argon2id runs (2.6 s CPU + 8x64 MiB); now 1 (300 ms). Failed verifications remain uncached. - CachedBlobBackend per-hash single-flight + unique tmp names: 16 concurrent cold readers = 16 full remote downloads racing truncating writes on ONE deterministic .tmp (corruptible cache); now 1 download (16x less egress, 2.8x wall on a shared link) and torn files can never be renamed into the cache. I/O and allocations: - Chunk-assembly reads 64K -> 512K buffers (2.3x, 8x fewer syscalls); chunk-spool writes via BufWriter 512K (5.6x, 32x fewer syscalls). - S3/Azure put_blob_from_bytes_unsynced overrides: dedup settle no longer pays a HEAD probe per new chunk (2 RTT -> 1, 1.8x); Azure stops copying every chunk (Bytes -> Body, -0.44 ms - 4 MiB alloc per 4 MiB chunk). - Entity->DTO mapping: Arc<str> interning of closed-set display fields + common MIMEs, 1-alloc etag/size formatting, FolderDto moves instead of clones. File row: 11 -> 4 allocs; folder row: 11.8 -> 1 (2.1x faster). - CardDAV REPORT: deleted dead per-contact vCard pre-generation and the O(N^2) uid scan whose result was discarded (5k contacts: 55.7 -> 5.7 ms, 9.8x); byte-identical XML asserted. - Search-results cache: byte weigher + 32 MiB budget (OXICLOUD_SEARCH_CACHE_MAX_BYTES) replaces the 1000-ENTRY cap that let ~300 MiB of enriched rows sit in RSS; read latency parity. - Dropped aws-config + aws-smithy-types (zero references; -82 dep-graph nodes, three SDK stacks gone from every build). tokio "process" is now an explicit feature (was enabled transitively by aws-config). Frontend: - Cached Intl.DateTimeFormat keyed by (locale, options) in formatDate and 4 sibling callsites: 20k dates 2612 -> 51 ms (51.6x); vitest gate asserts output identity across locales and a 3x floor. Validation: cargo fmt + clippy --all-features --all-targets -D warnings clean; 518 unit + 548 integration-cfg tests green; new-shape endpoints smoke-tested end-to-end over HTTP (all 5 listing sort modes with cursor walks, WebDAV PROPFIND Depth-1, photos timeline, Basic-auth DAV login); frontend npm run check clean, new vitest gates green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBsU2qEzny3A8WQUEuMNCr
362 lines
13 KiB
Rust
362 lines
13 KiB
Rust
//! Search-results cache memory benchmark — entry-count bound vs byte bound.
|
||
//!
|
||
//! The search cache keys pages by user × query × offset × limit, and each
|
||
//! page holds up to 500 enriched rows (`MAX_SEARCH_LIMIT`) of owned Strings.
|
||
//! Bounded by ENTRY COUNT (the old scheme: `max_capacity(1000)` + TTL), a
|
||
//! burst of keystrokes/pages/users could pin ~300 MB of invisible RSS for
|
||
//! the 5-minute TTL. Bounded by BYTES (a `weigher` + 32 MiB budget — the
|
||
//! same pattern as the file-content and dedup-manifest caches), retention
|
||
//! can never exceed the budget.
|
||
//!
|
||
//! Two sub-phases over the same synthetic corpus (1,000 pages × 500 rows,
|
||
//! ~150-char paths, realistic field contents):
|
||
//! * BEFORE — a moka cache configured exactly as the old production wiring
|
||
//! (entry-count 1000 + 300 s TTL).
|
||
//! * AFTER — `build_search_results_cache(...)`, the *identical* function
|
||
//! production now uses (weigher + 32 MiB + 300 s TTL).
|
||
//!
|
||
//! Reported per phase: entries retained, retained bytes (recomputed with the
|
||
//! production weigher after `run_pending_tasks`), best-effort process memory
|
||
//! (`VmHWM`/`VmRSS` from /proc/self/status), and hot-key `get()` p50 over
|
||
//! 100k reads (proves the weigher — which only runs on insert — does not
|
||
//! slow reads).
|
||
//!
|
||
//! NOTE on RSS: `VmHWM` is a monotonic high-water mark and the allocator may
|
||
//! keep freed pages, so the AFTER phase (which runs second, after a full
|
||
//! drop of the BEFORE cache) cannot show a peak below the BEFORE peak.
|
||
//! Treat the RSS columns as best-effort corroboration; the authoritative
|
||
//! metric is the weigher-recomputed retained bytes.
|
||
//!
|
||
//! Gates (exit code 1 on failure):
|
||
//! * AFTER retained bytes ≤ 32 MiB budget
|
||
//! * BEFORE retained bytes ≥ 8× the budget (measured ≈9–10×)
|
||
//! * AFTER get() p50 within 20% of BEFORE
|
||
//!
|
||
//! No Postgres needed.
|
||
//! Run: `cargo run --release --features bench --example bench_search_cache_mem`
|
||
|
||
use std::hint::black_box;
|
||
use std::sync::Arc;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use oxicloud::application::dtos::search_dto::{SearchFileResultDto, SearchResultsDto};
|
||
use oxicloud::application::services::search_service::{
|
||
build_search_results_cache, search_results_entry_weight,
|
||
};
|
||
|
||
/// Distinct cached pages inserted per phase (≈ users × queries × pages).
|
||
const ENTRIES: u64 = 1_000;
|
||
/// Rows per page — the handler's `MAX_SEARCH_LIMIT` clamp.
|
||
const ROWS_PER_ENTRY: usize = 500;
|
||
/// Production TTL (unchanged by the fix).
|
||
const TTL_SECS: u64 = 300;
|
||
/// The old production bound: 1000 ENTRIES, blind to entry size.
|
||
const BEFORE_MAX_ENTRIES: u64 = 1_000;
|
||
/// The new production bound: 32 MiB of weighed bytes.
|
||
const AFTER_MAX_BYTES: u64 = 32 * 1024 * 1024;
|
||
/// Hot-key reads per phase for the p50 latency comparison.
|
||
const GETS: usize = 100_000;
|
||
|
||
const MIB: f64 = 1024.0 * 1024.0;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Deterministic synthetic corpus (no rand dependency)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Tiny xorshift64 PRNG — fast, deterministic, no dependency.
|
||
fn xorshift(state: &mut u64) -> u64 {
|
||
let mut x = *state;
|
||
x ^= x << 13;
|
||
x ^= x >> 7;
|
||
x ^= x << 17;
|
||
*state = x;
|
||
x
|
||
}
|
||
|
||
/// Lowercase-hex string of `chars` nibbles.
|
||
fn pseudo_hex(state: &mut u64, chars: usize) -> String {
|
||
let mut s = String::with_capacity(chars);
|
||
while s.len() < chars {
|
||
let block = format!("{:016x}", xorshift(state));
|
||
let take = (chars - s.len()).min(16);
|
||
s.push_str(&block[..take]);
|
||
}
|
||
s
|
||
}
|
||
|
||
/// 36-char UUID-shaped string (8-4-4-4-12), like the real `Uuid::to_string()`
|
||
/// ids that populate `SearchFileResultDto::id` / `folder_id`.
|
||
fn pseudo_uuid(state: &mut u64) -> String {
|
||
let h = pseudo_hex(state, 32);
|
||
format!(
|
||
"{}-{}-{}-{}-{}",
|
||
&h[0..8],
|
||
&h[8..12],
|
||
&h[12..16],
|
||
&h[16..20],
|
||
&h[20..32]
|
||
)
|
||
}
|
||
|
||
/// One synthetic 500-row search page with realistic field contents:
|
||
/// UUID ids, ~30-char names, ~150-char nested drive paths, real MIME types,
|
||
/// 64-hex BLAKE3 blob hashes, icon/category metadata, and a content-index
|
||
/// snippet on every 8th row.
|
||
fn synth_entry(idx: u64) -> Arc<SearchResultsDto> {
|
||
const MIMES: [&str; 4] = [
|
||
"application/pdf",
|
||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
"image/jpeg",
|
||
"text/markdown",
|
||
];
|
||
const SNIPPET: &str = "…the quarterly numbers show a steady increase in storage usage \
|
||
across all departments, with the engineering share growing fastest and…";
|
||
|
||
let mut rng = idx.wrapping_mul(0x9E3779B97F4A7C15) | 1;
|
||
let mut files = Vec::with_capacity(ROWS_PER_ENTRY);
|
||
for row in 0..ROWS_PER_ENTRY {
|
||
let name = format!(
|
||
"quarterly_report_{:04}_rev{:03}.pdf",
|
||
xorshift(&mut rng) % 10_000,
|
||
row % 1_000
|
||
);
|
||
let path = format!(
|
||
"/drives/{}/Departments/Engineering/Projects/oxicloud-benchmarks/2026/Q{}/weekly-sync-notes/attachments/{}",
|
||
pseudo_uuid(&mut rng),
|
||
row % 4 + 1,
|
||
name
|
||
);
|
||
let content_hit = row % 8 == 0;
|
||
let match_source = if content_hit { "content" } else { "name" };
|
||
files.push(SearchFileResultDto {
|
||
id: pseudo_uuid(&mut rng),
|
||
name,
|
||
path,
|
||
size: 831_942,
|
||
mime_type: MIMES[row % MIMES.len()].to_string(),
|
||
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(),
|
||
blob_hash: pseudo_hex(&mut rng, 64),
|
||
snippet: content_hit.then(|| SNIPPET.to_string()),
|
||
match_source: Some(match_source.to_string()),
|
||
});
|
||
}
|
||
|
||
Arc::new(SearchResultsDto::new(
|
||
files,
|
||
Vec::new(),
|
||
ROWS_PER_ENTRY,
|
||
0,
|
||
Some(12_345),
|
||
3,
|
||
"relevance".to_string(),
|
||
))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Best-effort process memory (Linux /proc; "n/a" elsewhere)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Read a kB-valued field (`VmHWM`, `VmRSS`) from /proc/self/status.
|
||
fn status_kb(field: &str) -> Option<u64> {
|
||
let text = std::fs::read_to_string("/proc/self/status").ok()?;
|
||
text.lines()
|
||
.find(|l| l.starts_with(field))
|
||
.and_then(|l| l.split_whitespace().nth(1))
|
||
.and_then(|kb| kb.parse().ok())
|
||
}
|
||
|
||
fn fmt_kb(v: Option<u64>) -> String {
|
||
match v {
|
||
Some(kb) => format!("{:.1} MiB", kb as f64 / 1024.0),
|
||
None => "n/a".to_string(),
|
||
}
|
||
}
|
||
|
||
fn fmt_kb_delta(start: Option<u64>, end: Option<u64>) -> String {
|
||
match (start, end) {
|
||
(Some(s), Some(e)) => format!("{:+.1} MiB", (e as f64 - s as f64) / 1024.0),
|
||
_ => "n/a".to_string(),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Phase runner
|
||
// ---------------------------------------------------------------------------
|
||
|
||
struct PhaseReport {
|
||
retained_entries: u64,
|
||
retained_bytes: u64,
|
||
hwm_start_kb: Option<u64>,
|
||
hwm_end_kb: Option<u64>,
|
||
rss_start_kb: Option<u64>,
|
||
rss_end_kb: Option<u64>,
|
||
p50_get_ns: u64,
|
||
}
|
||
|
||
/// Insert the full corpus, settle the cache, then measure retention and
|
||
/// hot-key read latency. Identical for both variants — only the cache
|
||
/// configuration differs.
|
||
async fn run_phase(cache: &moka::future::Cache<u64, Arc<SearchResultsDto>>) -> PhaseReport {
|
||
let hwm_start_kb = status_kb("VmHWM");
|
||
let rss_start_kb = status_kb("VmRSS");
|
||
|
||
for i in 0..ENTRIES {
|
||
cache.insert(i, synth_entry(i)).await;
|
||
// Let eviction run as it would under live traffic, so evicted pages
|
||
// are actually freed instead of piling up in moka's pending queue.
|
||
if i % 64 == 0 {
|
||
cache.run_pending_tasks().await;
|
||
}
|
||
}
|
||
cache.run_pending_tasks().await;
|
||
|
||
let retained_entries = cache.entry_count();
|
||
// Recompute retained bytes with the production weigher — for the BEFORE
|
||
// variant this is exactly the memory its entry-count bound was blind to.
|
||
let retained_bytes: u64 = cache
|
||
.iter()
|
||
.map(|(k, v)| u64::from(search_results_entry_weight(&k, &v)))
|
||
.sum();
|
||
|
||
// Hot-key read latency: p50 over GETS reads of one resident key.
|
||
let hot: u64 = *cache.iter().next().expect("cache is empty after fill").0;
|
||
for _ in 0..1_000 {
|
||
black_box(cache.get(&hot).await); // warmup
|
||
}
|
||
let mut lat_ns = Vec::with_capacity(GETS);
|
||
for _ in 0..GETS {
|
||
let t = Instant::now();
|
||
let v = cache.get(&hot).await;
|
||
lat_ns.push(t.elapsed().as_nanos() as u64);
|
||
black_box(v);
|
||
}
|
||
lat_ns.sort_unstable();
|
||
let p50_get_ns = lat_ns[lat_ns.len() / 2];
|
||
|
||
PhaseReport {
|
||
retained_entries,
|
||
retained_bytes,
|
||
hwm_start_kb,
|
||
hwm_end_kb: status_kb("VmHWM"),
|
||
rss_start_kb,
|
||
rss_end_kb: status_kb("VmRSS"),
|
||
p50_get_ns,
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tokio::main]
|
||
async fn main() {
|
||
let entry_weight = u64::from(search_results_entry_weight(&0, &synth_entry(0)));
|
||
println!("\n###########################################################");
|
||
println!("# Search-results cache: entry-count bound vs byte bound");
|
||
println!(
|
||
"# corpus: {ENTRIES} pages x {ROWS_PER_ENTRY} rows, ~{:.0} KiB/page (weigher)",
|
||
entry_weight as f64 / 1024.0
|
||
);
|
||
println!(
|
||
"# BEFORE: max_capacity({BEFORE_MAX_ENTRIES}) entries + {TTL_SECS}s TTL (old di.rs wiring)"
|
||
);
|
||
println!(
|
||
"# AFTER : build_search_results_cache({TTL_SECS}, {} MiB) — production fn",
|
||
AFTER_MAX_BYTES as f64 / MIB
|
||
);
|
||
println!("###########################################################\n");
|
||
|
||
// --- Phase 1: BEFORE (entry-count bound, exactly the old wiring) ---
|
||
let before_cache: moka::future::Cache<u64, Arc<SearchResultsDto>> =
|
||
moka::future::Cache::builder()
|
||
.max_capacity(BEFORE_MAX_ENTRIES)
|
||
.time_to_live(Duration::from_secs(TTL_SECS))
|
||
.build();
|
||
let before = run_phase(&before_cache).await;
|
||
// Full drop between phases so the AFTER numbers never sit on top of the
|
||
// BEFORE cache's live memory.
|
||
drop(before_cache);
|
||
|
||
// --- Phase 2: AFTER (weigher + byte budget, the production builder) ---
|
||
let after_cache = build_search_results_cache(TTL_SECS, AFTER_MAX_BYTES);
|
||
let after = run_phase(&after_cache).await;
|
||
|
||
// --- Report ---
|
||
println!("| metric | BEFORE (1000 entries + TTL) | AFTER (weigher + 32 MiB) |");
|
||
println!("|---|---|---|");
|
||
println!(
|
||
"| entries retained | {} | {} |",
|
||
before.retained_entries, after.retained_entries
|
||
);
|
||
println!(
|
||
"| retained bytes (weigher) | {:.1} MiB | {:.1} MiB |",
|
||
before.retained_bytes as f64 / MIB,
|
||
after.retained_bytes as f64 / MIB
|
||
);
|
||
println!(
|
||
"| byte budget | n/a (entry-count bound) | {:.0} MiB |",
|
||
AFTER_MAX_BYTES as f64 / MIB
|
||
);
|
||
println!(
|
||
"| VmHWM phase delta (best-effort) | {} | {} |",
|
||
fmt_kb_delta(before.hwm_start_kb, before.hwm_end_kb),
|
||
fmt_kb_delta(after.hwm_start_kb, after.hwm_end_kb)
|
||
);
|
||
println!(
|
||
"| VmRSS start -> end | {} -> {} | {} -> {} |",
|
||
fmt_kb(before.rss_start_kb),
|
||
fmt_kb(before.rss_end_kb),
|
||
fmt_kb(after.rss_start_kb),
|
||
fmt_kb(after.rss_end_kb)
|
||
);
|
||
println!(
|
||
"| get() p50, hot key ({GETS} reads) | {} ns | {} ns |",
|
||
before.p50_get_ns, after.p50_get_ns
|
||
);
|
||
println!(
|
||
"\nRSS note: VmHWM is monotonic and the allocator may retain freed pages, \
|
||
so the AFTER phase (running second) cannot peak below the BEFORE peak; \
|
||
the weigher-recomputed retained bytes are the authoritative comparison."
|
||
);
|
||
|
||
// --- Gates ---
|
||
let before_ratio = before.retained_bytes as f64 / AFTER_MAX_BYTES as f64;
|
||
let lat_ratio = after.p50_get_ns as f64 / before.p50_get_ns.max(1) as f64;
|
||
let gate_after_bounded = after.retained_bytes <= AFTER_MAX_BYTES;
|
||
let gate_before_unbounded = before_ratio >= 8.0;
|
||
let gate_latency = lat_ratio <= 1.2;
|
||
|
||
println!("\n| gate | condition | measured | result |");
|
||
println!("|---|---|---|---|");
|
||
println!(
|
||
"| AFTER bounded | retained <= 32 MiB budget | {:.1} MiB | {} |",
|
||
after.retained_bytes as f64 / MIB,
|
||
if gate_after_bounded { "PASS" } else { "FAIL" }
|
||
);
|
||
println!(
|
||
"| BEFORE unbounded | retained >= 8x budget (~10x expected) | {before_ratio:.1}x | {} |",
|
||
if gate_before_unbounded {
|
||
"PASS"
|
||
} else {
|
||
"FAIL"
|
||
}
|
||
);
|
||
println!(
|
||
"| read parity | AFTER p50 <= 1.2x BEFORE p50 | {lat_ratio:.2}x | {} |",
|
||
if gate_latency { "PASS" } else { "FAIL" }
|
||
);
|
||
|
||
if !(gate_after_bounded && gate_before_unbounded && gate_latency) {
|
||
eprintln!("\nbench_search_cache_mem: GATE FAILURE");
|
||
std::process::exit(1);
|
||
}
|
||
println!("\nAll gates passed.");
|
||
}
|