Files
Oxicloud/examples/bench_search_cache_mem.rs
T
Claude cd4c62042a perf: keyset/LATERAL SQL shapes, auth+blob-cache single-flight, spool buffers, DTO interning
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
2026-07-17 11:10:27 +00:00

362 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.");
}