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
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
//! 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.");
|
||||
}
|
||||
Reference in New Issue
Block a user