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:
Claude
2026-07-18 16:12:04 +00:00
parent 2317d594e3
commit fdf445d2b0
40 changed files with 4279 additions and 346 deletions
+218 -6
View File
@@ -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!(