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:
Claude
2026-07-17 11:10:27 +00:00
parent 7d95a19907
commit cd4c62042a
43 changed files with 5290 additions and 439 deletions
+361
View File
@@ -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.");
}