diff --git a/Cargo.lock b/Cargo.lock index 7f61eefc..99badd1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,37 +341,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-config" -version = "1.8.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-sdk-sso", - "aws-sdk-ssooidc", - "aws-sdk-sts", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand 2.4.1", - "hex", - "http 1.4.0", - "sha1 0.10.6", - "time", - "tokio", - "tracing", - "url", - "zeroize", -] - [[package]] name = "aws-credential-types" version = "1.2.14" @@ -470,82 +439,6 @@ dependencies = [ "url", ] -[[package]] -name = "aws-sdk-sso" -version = "1.102.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c82b3ac19f1431854f7ace3a7531674633e286bfdde21976893bfee36fd493b" -dependencies = [ - "arc-swap", - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand 2.4.1", - "http 0.2.12", - "http 1.4.0", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sdk-ssooidc" -version = "1.104.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321000d2b4c5519ee573f73167f612efd7329322d9b26969ad1979f0427f1913" -dependencies = [ - "arc-swap", - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand 2.4.1", - "http 0.2.12", - "http 1.4.0", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sdk-sts" -version = "1.107.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0d328ba962af23ecfa3c9f23b98d3d35e325fa218d7f13d17a6bf522f8a560" -dependencies = [ - "arc-swap", - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-query", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-smithy-xml", - "aws-types", - "fastrand 2.4.1", - "http 0.2.12", - "http 1.4.0", - "regex-lite", - "tracing", -] - [[package]] name = "aws-sigv4" version = "1.4.5" @@ -688,16 +581,6 @@ dependencies = [ "aws-smithy-runtime-api", ] -[[package]] -name = "aws-smithy-query" -version = "0.60.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" -dependencies = [ - "aws-smithy-types", - "urlencoding", -] - [[package]] name = "aws-smithy-runtime" version = "1.11.3" @@ -4231,9 +4114,7 @@ dependencies = [ "async-stream", "async-trait", "async_zip", - "aws-config", "aws-sdk-s3", - "aws-smithy-types", "axum", "azure_core", "azure_storage", diff --git a/Cargo.toml b/Cargo.toml index ec5c9b5b..bb006857 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,9 @@ default-run = "oxicloud" [dependencies] mimalloc = { version = "0.1.52", default-features = false } axum = { version = "0.8.9", features = ["multipart", "http1", "http2", "tokio", "macros"] } -tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs"] } +# "process" was previously enabled implicitly through aws-config's feature +# unification; ffmpeg_video_frame_service needs it, so declare it ourselves. +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs", "process"] } tokio-util = { version = "0.7.18", features = ["io", "codec", "compat"] } tokio-stream = { version = "0.1.18", features = ["fs", "sync"] } bytes = "1.11.1" @@ -86,9 +88,12 @@ dashmap = "6.2.1" socket2 = { version = "0.6.4", features = ["all"] } urlencoding = "2.1.3" utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] } +# NOTE: aws-config and aws-smithy-types were removed as direct deps in the +# round-3 perf pass — S3BlobBackend builds its client purely from +# aws_sdk_s3::config with static credentials; nothing referenced either +# crate, and aws-config alone pulled aws-sdk-sso/ssooidc/sts (~90 crates) +# into every build (benches/ROUND3.md). aws-sdk-s3 = "1.136.0" -aws-config = { version = "1.8.18", features = ["behavior-version-latest"] } -aws-smithy-types = "1.5.0" azure_core = { version = "0.21", default-features = false, features = ["enable_reqwest_rustls", "hmac_rust"] } azure_storage = { version = "0.21", default-features = false, features = ["enable_reqwest_rustls", "hmac_rust"] } azure_storage_blobs = { version = "0.21", default-features = false, features = ["enable_reqwest_rustls", "hmac_rust"] } @@ -278,6 +283,78 @@ name = "bench_owner_cache" path = "examples/bench_owner_cache.rs" required-features = ["bench"] +# Round-3 battery ───────────────────────────────────────────────────────────── + +# Web-UI folder listing — whole-folder rescan + top-N sort per page vs keyset +# pushdown into the UNION-ALL branches + (folder_id, LOWER(name), id) indexes +# (needs the dev Postgres up). +[[example]] +name = "bench_listing_keyset" +path = "examples/bench_listing_keyset.rs" +required-features = ["bench"] + +# Photos timeline — full-library scan + top-N above the grants join vs +# per-drive LATERAL top-N on the media-timeline index (needs Postgres). +[[example]] +name = "bench_photos_timeline" +path = "examples/bench_photos_timeline.rs" +required-features = ["bench"] + +# PROPFIND subfolder paging — LIMIT/OFFSET + COUNT(*) OVER() per page vs +# keyset batch, mirroring the files-side PROPFIND-PAGING fix (needs Postgres). +[[example]] +name = "bench_folder_keyset" +path = "examples/bench_folder_keyset.rs" +required-features = ["bench"] + +# Basic-auth thundering herd — K concurrent cache misses each paying Argon2id +# vs single-flight try_get_with (needs Postgres). +[[example]] +name = "bench_auth_herd" +path = "examples/bench_auth_herd.rs" +required-features = ["bench"] + +# CachedBlobBackend — miss stampede (N duplicate remote fetches racing on one +# .tmp) vs per-hash single-flight; warm-hit index throughput. No Postgres. +[[example]] +name = "bench_blob_cache" +path = "examples/bench_blob_cache.rs" +required-features = ["bench"] + +# Upload spool/assembly I/O — ReaderStream capacity sweep on part-file reads +# and BufWriter vs bare-File frame writes on the chunk spool path. No Postgres. +[[example]] +name = "bench_upload_spool" +path = "examples/bench_upload_spool.rs" +required-features = ["bench"] + +# S3 chunk PUT — HEAD-before-PUT vs unconditional PUT against a local axum +# stub with injected latency; Azure Bytes-vs-to_vec copy micro. No Postgres. +[[example]] +name = "bench_s3_put" +path = "examples/bench_s3_put.rs" +required-features = ["bench"] + +# File/Folder -> DTO mapping allocations — Arc interning of closed-set +# display fields, 1-alloc etag/size formatting. No Postgres. +[[example]] +name = "bench_dto_map" +path = "examples/bench_dto_map.rs" +required-features = ["bench"] + +# CardDAV REPORT — dead per-contact vCard pre-generation + O(N^2) uid scan vs +# single on-demand generation. No Postgres. +[[example]] +name = "bench_carddav_report" +path = "examples/bench_carddav_report.rs" +required-features = ["bench"] + +# Search-results cache RSS — entry-count capacity vs byte weigher. No Postgres. +[[example]] +name = "bench_search_cache_mem" +path = "examples/bench_search_cache_mem.rs" +required-features = ["bench"] + [profile.release] lto = "thin" codegen-units = 1 diff --git a/benches/ROUND3.md b/benches/ROUND3.md new file mode 100644 index 00000000..bd263bfa --- /dev/null +++ b/benches/ROUND3.md @@ -0,0 +1,266 @@ +# Round 3 — listing/timeline SQL shapes, auth herd, blob-cache stampede, spool I/O, DTO allocs + +Twelve benchmark-gated changes. Rule of the round (same as ROUND2): every +change ships with a BEFORE/AFTER benchmark; an AFTER that doesn't beat its +BEFORE gets rolled back — none did. Equivalence gates (byte-identical +output / identical row sequences) guard every behavior-preserving rewrite. + +Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release +profile. Reproduce any row with the command in its section. + +## Summary + +| # | change | key metric | before → after | +|--:|---|---|---| +| 1 | Web-UI listing keyset pushdown | ms/page p50, 20k-entry folder | 26.6 → 1.30 (**19.5x**) | +| 2 | Photos timeline LATERAL top-N | ms/page p50, 50k-photo library | 97.4 → 1.61 (**55.7x**) | +| 3 | PROPFIND subfolder keyset | full walk, 5k dirs | 79.7 → 17.9 ms (**4.5x**) | +| 4 | Basic-auth single-flight | herd CPU, 8 conns | 2620 → 300 ms (**8.7x**) | +| 5 | Blob-cache miss single-flight | remote fetches / wall | 16 → 1, 519 → 188 ms (**2.8x**) | +| 6 | Chunk-assembly read buffer 512K | wall / read syscalls | 251 → 109 ms (**2.3x**), 2580 → 340 | +| 7 | Chunk-spool BufWriter 512K | wall / write syscalls | 877 → 158 ms (**5.6x**), 12800 → 400 | +| 8 | S3/Azure unsynced PUT (no HEAD) | wall / requests, 500 chunks | 1604 → 868 ms (**1.8x**), 1000 → 500 | +| 9 | DTO mapping interning | allocs/row file / folder | 11.0 → 4.0, 11.8 → 1.0 | +| 10 | CardDAV REPORT dead work | 5k contacts, getetag | 55.7 → 5.7 ms (**9.8x**) | +| 11 | Search-cache byte weigher | retained RSS worst case | ~298 MiB → 31.9 MiB (bounded) | +| 12 | Drop aws-config/aws-smithy-types | dep-graph nodes | 1728 → 1646 | + +Frontend (gated by vitest, `frontend/src/lib/utils/formatDate.bench.test.ts`): +cached `Intl.DateTimeFormat` — 20k dates 2612 → 50.6 ms (**51.6x**), output +identity asserted across locales. + +--- + +## [1] Web-UI folder listing — whole-folder rescan → per-branch keyset — 19.5x + +`list_resources_paged` (SPA files view) applied its keyset cursor OUTSIDE +the folders/files UNION-ALL on computed columns (`sort_str = LOWER(name)`, +`folder_first`), so Postgres re-scanned and top-N-sorted every remaining +row of the folder on every page (EXPLAIN: Seq Scan, 17,999 rows removed by +filter, 29 ms / 565 buffers per 200-row page on a 20k-file folder). + +Now the cursor is pushed into each branch as a sargable row-value +comparison on base columns (`(LOWER(name), id) > ($str, $id)`), constants +folded per branch in Rust (a cursor in the file group drops the folder +branch outright), each branch pre-sorts + pre-limits, and the outer query +merges ≤ 2·limit rows. Two new expression indexes (migration +`20260918000000`): `idx_files_folder_lname (folder_id, LOWER(name), id)` +and `idx_folders_parent_lname (parent_id, LOWER(name), id)`, both partial +on `NOT is_trashed`. + +``` +cargo run --release --features bench --example bench_listing_keyset +# full drain, 20k files + 300 dirs, 200/page total ms p50/pg p99/pg +# name OLD/no-idx 2717.2 26.57 33.55 +# name OLD/idx (indexes alone don't help) 2786.8 27.83 35.62 +# name NEW/idx 139.6 1.30 1.81 19.5x +# modified_at OLD → NEW (no dedicated index) 1653.4 → 1367.5 1.2x +``` + +Equivalence: the drained `(type, id)` sequence is asserted identical across +all modes and both sort orders; the example exits 1 on mismatch. + +## [2] Photos timeline — full-library scan → per-drive LATERAL top-N — 55.7x + +`list_media_files` claimed `idx_files_media_timeline_by_drive` let LIMIT +stop the scan early; EXPLAIN refuted it — the folders/file_metadata joins +and the global sort sat ABOVE the `drive_id IN (grants)` nested loop, so +every page fed the ENTIRE media library through the join into a top-N +heapsort. Now the accessible drive ids materialise once, a +`CROSS JOIN LATERAL (… ORDER BY media_sort_date DESC LIMIT k)` per drive +does one bounded index scan each, and the joins run on the k emitted rows +only. + +``` +cargo run --release --features bench --example bench_photos_timeline +# 10 pages of 100, 50k photos, 3 drives total ms p50 ms/page +# OLD 1032.1 97.41 +# NEW 18.5 1.61 55.7x +``` + +Equivalence: page-by-page id sequences asserted identical (seed uses +strictly distinct capture dates so ties can't mask reordering). + +## [3] PROPFIND subfolder paging — LIMIT/OFFSET + COUNT(*) OVER() → keyset — 4.5x + +The exact quadratic shape PROPFIND-PAGING fixed for files still applied to +sub-folders on both DAV surfaces: every page window-aggregated and +re-scanned all N sub-folders, and the total was only used for `has_next`. +New `FolderRepository::list_folders_batch` (keyset `name > $last`, served +by the existing `idx_folders_unique_name`, no migration) wired into both +streaming PROPFIND walkers via `list_folders_batch_with_perms` (same +per-batch authz as before). + +``` +cargo run --release --features bench --example bench_folder_keyset +# full walk, 5k dirs, 500/page total ms p50 ms/page +# OFFSET 79.7 6.54 +# KEYSET 17.9 1.64 4.5x +``` + +## [4] Basic-auth cache — thundering herd → single-flight — 8.7x CPU + +Every DAV/NC request authenticates via `verify_basic_auth`. On a cache +miss each concurrent caller independently ran the full slow path — an +Argon2id verification (m=64 MiB, t=3, p=2 ≈ 290 ms CPU here) apiece. DAV +sync clients hold 4-8 parallel connections, so every TTL expiry (300 s) +fanned out K verifications: a recurring p99 spike + CPU/RAM burst. +`try_get_with` now coalesces concurrent misses; errors are never cached +(brute-force cost preserved), revocation via `invalidate_entries_if` +unchanged. + +``` +cargo run --release --features bench --example bench_auth_herd +# herd of 8, cold cache wall ms CPU ms verifications +# BEFORE (per-caller) 764 2620 9.0 +# AFTER (single-flight) 311 300 1.0 +# warm hit p50: 0.6 us +``` + +## [5] CachedBlobBackend — miss stampede → per-hash single-flight — 16 fetches → 1 + +K concurrent cold readers of one blob (video player's parallel Range +probes; N clients pulling the same new file) each downloaded the FULL blob +from S3/Azure — and raced truncating writes on ONE deterministic `.tmp` +path (a torn interleaving could be renamed into the cache). Fixes: a +per-hash DashMap gate (leader fetches, waiters re-check and serve +locally), plus unique `.{uuid}.tmp` names + error-path cleanup so a +corrupt file can never land at the final path. + +``` +cargo run --release --features bench --example bench_blob_cache +# 16 cold readers, 32 MiB blob, shared 1 GiB/s link wall ms fetches remote MiB +# BEFORE (per-caller) 519 16 512 +# AFTER (single-flight) 188 1 32 +# gates: fetch count == 1; BLAKE3 of served + durable cache file == source +``` + +## [6][7] Upload spool I/O — 64 KiB reads, unbuffered frame writes + +Assembly read (`stream_from_files`, the single read pass over every +completed chunked upload) used 64 KiB `ReaderStream` polls — one +blocking-pool dispatch + read(2) each — while every other blob path uses +256 KiB+. Capacity sweep picked 512 KiB. Chunk-spool writes +(`stream_body_to_path`, every chunk PUT on both surfaces) went straight to +a bare tokio File — one dispatch + write(2) per ~16-64 KiB HTTP frame; now +wrapped in `BufWriter::with_capacity(512 KiB)` like the dedup handler's +spool loop. + +``` +cargo run --release --features bench --example bench_upload_spool +# [1] read 16 x 10 MiB parts wall ms read syscalls +# 64K (BEFORE) 250.8 2580 +# 256K 125.1 660 +# 512K (AFTER) 108.8 340 2.3x +# 1M 111.3 180 +# [2] spool 640 x 16 KiB frames x 20 files +# bare File (BEFORE) 877.4 12800 syscw +# BufWriter 512K (AFTER) 157.9 400 syscw 5.6x +``` + +## [8] S3/Azure chunk writes — HEAD-before-PUT → unconditional PUT — 1.8x + +Neither remote backend overrode `put_blob_from_bytes_unsynced`, so the +dedup settle path (every NEW chunk of every upload) routed through +`put_blob_from_bytes` and its "idempotent" HEAD/get_properties probe — +2 round-trips per chunk for chunks the dedup layer already knows are new. +Content-addressed keys make re-PUTs overwrite-safe, so the new overrides +PUT directly. Azure additionally stopped copying every chunk +(`data.to_vec()` → `Bytes` into `azure_core::Body`): 0.44 ms + 4 MiB +transient alloc per 4 MiB chunk removed. + +``` +cargo run --release --features bench --example bench_s3_put +# 500 x 256 KiB chunks, concurrency 8, 10 ms/request stub +# BEFORE (HEAD+PUT) 1604 ms 500 HEADs + 500 PUTs +# AFTER (PUT only) 868 ms 500 PUTs 1.8x +``` + +## [9] Entity → DTO mapping — closed-set interning + 1-alloc formatting + +`Arc::::from(&'static str)` always allocates+copies, so every file +row paid 4 allocations for values drawn from a ~60-string closed set +(icon class, special class, category, mime), plus 2-alloc etag and 2-alloc +size formatting; FolderDto additionally built its etag twice and cloned 4 +Strings it could move. Now: `LazyLock` intern tables (lookup + refcount +bump; unknown values fall back to `Arc::from`, same bytes), single-alloc +`compute_etag`/`format_file_size`, and `Folder::into_parts()` moves. + +``` +cargo run --release --features bench --example bench_dto_map +# 10k rows ns/row allocs/row +# File→FileDto BEFORE 1229.2 10.96 +# File→FileDto AFTER 1004.9 3.96 +# Folder→FolderDto BEFORE 425.2 11.80 +# Folder→FolderDto AFTER 204.5 1.00 +# gate: all DTO fields byte-identical BEFORE vs AFTER (10k files + 10k folders) +``` + +## [10] CardDAV REPORT — dead double vCard generation + O(N²) scan — 9.8x + +`handle_report` pre-generated a vCard for EVERY contact; the adapter then +did a linear uid `find` per contact — O(N²) string compares — and +DISCARDED the result (`let _ = vcard`), regenerating on demand inside +`write_contact_response` anyway. Pure dead work, deleted; `contact_to_vcard` +also switched `push_str(&format!(…))` → `write!` (one temp String per +vCard line removed). + +``` +cargo run --release --features bench --example bench_carddav_report +# N=5000 getetag 55.7 → 5.7 ms 9.8x +# N=5000 getetag+address-data 76.2 → 15.3 ms 5.0x +# gate: REPORT XML byte-identical BEFORE vs AFTER for all prop sets +``` + +## [11] Search-results cache — entry count → byte weigher — bounded RSS + +The cache was capped at 1000 ENTRIES with a 300 s TTL; each entry holds up +to 500 enriched rows (~10 owned Strings each) and keys include +user+query+offset+limit, so every keystroke/page/user minted an entry — +~300 MiB of invisible RSS was reachable. Now a byte weigher + 32 MiB +budget (`OXICLOUD_SEARCH_CACHE_MAX_BYTES`), same TTL, same read latency. + +``` +cargo run --release --features bench --example bench_search_cache_mem +# 1000 pages x 500 rows retained bytes get() p50 +# BEFORE (1000 entries) ~298 MiB (9.3x) 155 ns +# AFTER (32 MiB weigher) 31.9 MiB 155 ns parity 1.00x +``` + +## [12] Cargo — drop aws-config + aws-smithy-types + +Both were direct dependencies with ZERO references in the codebase — +`S3BlobBackend` builds its client purely from `aws_sdk_s3::config` with +static credentials. `aws-config` alone dragged aws-sdk-sso, aws-sdk-ssooidc +and aws-sdk-sts into every build. Dependency-graph nodes: 1728 → 1646. +`tokio`'s `process` feature (used by the ffmpeg thumbnailer) was only +enabled transitively through aws-config's feature unification — it is now +declared explicitly. + +## Frontend — cached Intl.DateTimeFormat — 51.6x + +`formatDate` (and four sibling callsites) constructed a fresh +`Intl.DateTimeFormat` per call (~131 µs each here) — paid roughly twice +per row while rendering/scrolling file lists. Module-scope cache keyed by +(locale, options), invalidated on `languagechange`. + +``` +cd frontend && npx vitest run src/lib/utils/formatDate.bench.test.ts +# 20k dates: cached 50.6 ms vs per-call 2612.0 ms (51.6x); output-identity +# matrix across en/es/ar/ja and every option shape used by the app +``` + +## Audited but NOT adopted (for the record) + +- **Fat LTO / panic=abort / OpenAPI LazyLock**: refuted by the verification + pass (sub-1% plausible gain, or cold paths; `catch_unwind` shields + pdf-extract so panic=abort is off the table). +- **Chained clone-on-hit drive caches, localeCompare→Intl.Collator**: + measured previously — residual gains are noise or regressions + (benches/CHROOT-CACHE.md, benches/NPLUS1-AND-CACHES.md). +- **Follow-ups worth a future round** (confirmed real, not yet gated): + grouped/swimlane files view is unvirtualized (10k-row DOM); Azure + download path buffers whole blobs in RAM (needs an Azurite-gated bench); + face-indexing spawns unbounded per-image tasks; WebDAV drive-selector + resolution re-runs the grants join per request (cacheable like + CHROOT-CACHE); `make_file_path` split→rejoin + NFC copy per listing row. diff --git a/examples/bench_auth_herd.rs b/examples/bench_auth_herd.rs new file mode 100644 index 00000000..38de515d --- /dev/null +++ b/examples/bench_auth_herd.rs @@ -0,0 +1,209 @@ +//! Basic-auth thundering-herd benchmark — K concurrent cache misses. +//! +//! Every WebDAV/CalDAV/CardDAV/NextCloud request authenticates through +//! `AppPasswordService::verify_basic_auth`. The cache (TTL 300 s) used to be +//! a plain get/insert: when a sync client holding K parallel connections hit +//! an expired entry, all K in-flight requests missed simultaneously and each +//! ran the full slow path — an Argon2id verification at ~64 MiB / t=3 / p=2 +//! apiece (100-300 ms CPU each). `try_get_with` now coalesces concurrent +//! misses into ONE verification; failed verifications stay uncached. +//! +//! Sections: +//! BEFORE (emulated) — K concurrent bare Argon2id verifications, the exact +//! work the old code fanned out per herd +//! AFTER — K concurrent verify_basic_auth on a cold cache +//! (single-flight: 1 verification, K-1 waiters) +//! warm-hit — p50 of the cached path +//! +//! Gate: AFTER's process-CPU delta must be ~1 verification (< 2x a single +//! verify), while BEFORE burns ~K of them. All K results must be Ok and +//! identical. +//! +//! Run (needs Postgres up; reads DATABASE_URL / OXICLOUD_DB_CONNECTION_STRING +//! from .env): +//! cargo run --release --features bench --example bench_auth_herd +//! Tunables: BENCH_HERD (8) + +use std::env; +use std::sync::Arc; +use std::time::Instant; + +use oxicloud::application::services::app_password_service::AppPasswordService; +use oxicloud::infrastructure::repositories::pg::{AppPasswordPgRepository, UserPgRepository}; +use oxicloud::infrastructure::services::password_hasher::Argon2PasswordHasher; +use sqlx::postgres::PgPoolOptions; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +/// Process CPU time (utime + stime) in seconds, from /proc/self/stat. +fn cpu_seconds() -> f64 { + let stat = std::fs::read_to_string("/proc/self/stat").expect("stat"); + // utime/stime are fields 14/15 (1-indexed) — index past the comm field + // (it can contain spaces) via the closing paren. + let rest = &stat[stat.rfind(')').unwrap() + 2..]; + let fields: Vec<&str> = rest.split_whitespace().collect(); + let utime: f64 = fields[11].parse().expect("utime"); + let stime: f64 = fields[12].parse().expect("stime"); + let hz = 100.0; // USER_HZ on all mainstream Linux configs + (utime + stime) / hz +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + dotenvy::dotenv().ok(); + let url = env::var("DATABASE_URL") + .or_else(|_| env::var("OXICLOUD_DB_CONNECTION_STRING")) + .expect("set DATABASE_URL"); + let herd: usize = env_or("BENCH_HERD", 8); + + let pool = Arc::new( + PgPoolOptions::new() + .max_connections(10) + .connect(&url) + .await + .expect("connect"), + ); + + // ── Seed: user + NC-format app password (production Argon2 params) ── + let username = format!("bench_herd_{}", std::process::id()); + let user_id: uuid::Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, password_hash, role) + VALUES ($1, $2, '', 'user') RETURNING id", + ) + .bind(&username) + .bind(format!("{username}@bench.invalid")) + .fetch_one(pool.as_ref()) + .await + .expect("seed user"); + + // Production defaults: m=64 MiB, t=3, p=2 (config.rs auth defaults). + let hasher = Arc::new(Argon2PasswordHasher::new(65536, 3, 2)); + let svc = Arc::new(AppPasswordService::new( + Arc::new(AppPasswordPgRepository::new(pool.clone())), + hasher.clone(), + Arc::new(UserPgRepository::new(pool.clone())), + "http://localhost".into(), + )); + let (_ap_id, plain) = svc.create_nc(user_id, "bench").await.expect("create_nc"); + + // ── Single-verify baseline (what one Argon2id run costs here) ────── + use oxicloud::application::ports::auth_ports::PasswordHasherPort; + let ref_hash = hasher.hash_password("benchpw").await.expect("hash"); + let t = Instant::now(); + let c = cpu_seconds(); + assert!( + hasher + .verify_password("benchpw", &ref_hash) + .await + .expect("verify") + ); + let one_wall = t.elapsed().as_secs_f64(); + let one_cpu = cpu_seconds() - c; + println!( + "single Argon2id verify: {:.0} ms wall, {:.0} ms CPU", + one_wall * 1000.0, + one_cpu * 1000.0 + ); + + // ── BEFORE (emulated): K concurrent bare verifications ───────────── + let t = Instant::now(); + let c = cpu_seconds(); + let mut set = tokio::task::JoinSet::new(); + for _ in 0..herd { + let h = hasher.clone(); + let rh = ref_hash.clone(); + set.spawn(async move { h.verify_password("benchpw", &rh).await.expect("verify") }); + } + while let Some(r) = set.join_next().await { + assert!(r.expect("join")); + } + let before_wall = t.elapsed().as_secs_f64(); + let before_cpu = cpu_seconds() - c; + + // ── AFTER: K concurrent verify_basic_auth on a cold cache ────────── + let t = Instant::now(); + let c = cpu_seconds(); + let mut set = tokio::task::JoinSet::new(); + for _ in 0..herd { + let s = svc.clone(); + let u = username.clone(); + let p = plain.clone(); + set.spawn(async move { s.verify_basic_auth(&u, &p).await }); + } + let mut ids = Vec::new(); + while let Some(r) = set.join_next().await { + let (uid, uname, _, _) = r.expect("join").expect("verify_basic_auth"); + assert_eq!(uname, username); + ids.push(uid); + } + assert!(ids.iter().all(|&u| u == user_id)); + let after_wall = t.elapsed().as_secs_f64(); + let after_cpu = cpu_seconds() - c; + + // ── Warm hit p50 ──────────────────────────────────────────────────── + let mut lat = Vec::with_capacity(10_000); + for _ in 0..10_000 { + let t = Instant::now(); + let _ = svc + .verify_basic_auth(&username, &plain) + .await + .expect("warm hit"); + lat.push(t.elapsed().as_secs_f64() * 1e6); + } + lat.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let warm_p50 = lat[lat.len() / 2]; + + println!("\n# herd of {herd} concurrent Basic Auth verifications, cold cache"); + println!( + "{:<22} {:>10} {:>10} {:>14}", + "variant", "wall ms", "CPU ms", "verifications" + ); + println!( + "{:<22} {:>10.0} {:>10.0} {:>14.1}", + "BEFORE (per-caller)", + before_wall * 1000.0, + before_cpu * 1000.0, + before_cpu / one_cpu + ); + println!( + "{:<22} {:>10.0} {:>10.0} {:>14.1}", + "AFTER (single-flight)", + after_wall * 1000.0, + after_cpu * 1000.0, + after_cpu / one_cpu + ); + println!("warm cache hit p50: {warm_p50:.1} us"); + + // ── Cleanup ───────────────────────────────────────────────────────── + let _ = sqlx::query("DELETE FROM auth.app_passwords WHERE user_id = $1") + .bind(user_id) + .execute(pool.as_ref()) + .await; + let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1") + .bind(user_id) + .execute(pool.as_ref()) + .await; + + // ── Gate ──────────────────────────────────────────────────────────── + // AFTER must coalesce to ~1 verification's CPU; 2x headroom for + // scheduler noise. BEFORE must show the herd actually fanned out. + if after_cpu > one_cpu * 2.0 { + eprintln!( + "GATE FAIL: single-flight AFTER burned {:.1} verifications of CPU (expected ~1)", + after_cpu / one_cpu + ); + std::process::exit(1); + } + if before_cpu < one_cpu * (herd as f64) * 0.6 { + eprintln!( + "GATE WARN: BEFORE emulation did not saturate ({:.1} verifs)", + before_cpu / one_cpu + ); + } + println!("\nGATE PASS: cold-cache herd coalesced to ~1 Argon2id run"); +} diff --git a/examples/bench_blob_cache.rs b/examples/bench_blob_cache.rs new file mode 100644 index 00000000..4effe5b5 --- /dev/null +++ b/examples/bench_blob_cache.rs @@ -0,0 +1,279 @@ +//! CachedBlobBackend miss-stampede benchmark — duplicate remote fetches. +//! +//! K concurrent cold readers of ONE blob (a video player's parallel Range +//! probes on an uncached file, N sync clients pulling the same new file) +//! used to each download the FULL blob from the remote backend and race +//! their writes on one shared deterministic `.tmp` path. The per-hash +//! single-flight gate coalesces them onto one download; waiters serve the +//! leader's cached file. +//! +//! The mock inner backend counts `get_blob_stream` calls and serves a +//! 32 MiB blob with an injected 15 ms first-byte latency + paced chunks +//! (models a remote object store). +//! +//! BEFORE (emulated) — K concurrent direct inner fetches, each draining +//! the full stream (what the old miss path did) +//! AFTER — K concurrent `CachedBlobBackend::get_blob_stream` +//! on a cold cache +//! +//! Gates: AFTER's inner-fetch count == 1; the cached file must BLAKE3-match +//! the source; K x full-drain wall reported for both. +//! +//! No Postgres. Run: +//! cargo run --release --features bench --example bench_blob_cache +//! Tunables: BENCH_CONCURRENCY (16), BENCH_BLOB_MB (32) + +use std::env; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use futures::StreamExt; +use oxicloud::application::ports::blob_storage_ports::{ + BlobStorageBackend, BlobStream, StorageHealthStatus, +}; +use oxicloud::domain::errors::DomainError; +use oxicloud::infrastructure::services::cached_blob_backend::{BlobCacheConfig, CachedBlobBackend}; + +type BoxFut<'a, T> = std::pin::Pin + Send + 'a>>; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +/// Mock remote backend: one in-RAM blob, counted reads, and — crucially — +/// SHARED aggregate bandwidth: concurrent streams split one simulated +/// 1 GiB/s link (a real NIC/egress link doesn't hand every duplicate +/// download its own private lane, so duplicate fetches cost real wall +/// time, not just bytes). +struct MockRemote { + data: Bytes, + fetches: AtomicU64, + bytes_served: AtomicU64, + /// Virtual time (µs since bench start) when the shared link frees up. + link_busy_until_us: Arc>, + epoch: Instant, +} + +const LINK_BYTES_PER_SEC: u64 = 1024 * 1024 * 1024; // 1 GiB/s aggregate + +impl MockRemote { + fn new(data: Bytes) -> Self { + Self { + data, + fetches: AtomicU64::new(0), + bytes_served: AtomicU64::new(0), + link_busy_until_us: Arc::new(tokio::sync::Mutex::new(0)), + epoch: Instant::now(), + } + } + + fn stream(&self) -> BlobStream { + self.fetches.fetch_add(1, Ordering::Relaxed); + self.bytes_served + .fetch_add(self.data.len() as u64, Ordering::Relaxed); + let data = self.data.clone(); + let link = self.link_busy_until_us.clone(); + let epoch = self.epoch; + let s = async_stream::stream! { + // First-byte latency of a remote GET. + tokio::time::sleep(Duration::from_millis(15)).await; + let chunk = 4 * 1024 * 1024; + let mut off = 0usize; + while off < data.len() { + let end = (off + chunk).min(data.len()); + // Reserve this chunk's slot on the shared link, then sleep + // until the slot has elapsed — bandwidth divides across + // every in-flight stream. + let slot_us = (end - off) as u64 * 1_000_000 / LINK_BYTES_PER_SEC; + let wake_us = { + let mut busy = link.lock().await; + let now_us = epoch.elapsed().as_micros() as u64; + let start = (*busy).max(now_us); + *busy = start + slot_us; + *busy + }; + let now_us = epoch.elapsed().as_micros() as u64; + if wake_us > now_us { + tokio::time::sleep(Duration::from_micros(wake_us - now_us)).await; + } + yield Ok::(data.slice(off..end)); + off = end; + } + }; + Box::pin(s) + } +} + +impl BlobStorageBackend for MockRemote { + fn initialize(&self) -> BoxFut<'_, Result<(), DomainError>> { + Box::pin(async { Ok(()) }) + } + fn put_blob(&self, _hash: &str, _source_path: &Path) -> BoxFut<'_, Result> { + Box::pin(async { Ok(0) }) + } + fn put_blob_from_bytes( + &self, + _hash: &str, + data: Bytes, + ) -> BoxFut<'_, Result> { + Box::pin(async move { Ok(data.len() as u64) }) + } + fn get_blob_stream(&self, _hash: &str) -> BoxFut<'_, Result> { + let s = self.stream(); + Box::pin(async move { Ok(s) }) + } + fn get_blob_range_stream( + &self, + _hash: &str, + start: u64, + end: Option, + ) -> BoxFut<'_, Result> { + let data = self.data.clone(); + self.fetches.fetch_add(1, Ordering::Relaxed); + Box::pin(async move { + let end = end.unwrap_or(data.len() as u64).min(data.len() as u64); + let s = futures::stream::once(async move { + Ok::(data.slice(start as usize..end as usize)) + }); + Ok(Box::pin(s) as BlobStream) + }) + } + fn delete_blob(&self, _hash: &str) -> BoxFut<'_, Result<(), DomainError>> { + Box::pin(async { Ok(()) }) + } + fn blob_exists(&self, _hash: &str) -> BoxFut<'_, Result> { + Box::pin(async { Ok(true) }) + } + fn blob_size(&self, _hash: &str) -> BoxFut<'_, Result> { + let n = self.data.len() as u64; + Box::pin(async move { Ok(n) }) + } + fn health_check(&self) -> BoxFut<'_, Result> { + Box::pin(async { + Ok(StorageHealthStatus { + connected: true, + backend_type: "mock".into(), + message: "ok".into(), + available_bytes: None, + }) + }) + } + fn backend_type(&self) -> &'static str { + "mock" + } + fn local_blob_path(&self, _hash: &str) -> Option { + None + } +} + +async fn drain(mut s: BlobStream) -> (u64, [u8; 32]) { + let mut hasher = blake3::Hasher::new(); + let mut n = 0u64; + while let Some(chunk) = s.next().await { + let b = chunk.expect("chunk"); + n += b.len() as u64; + hasher.update(&b); + } + (n, hasher.finalize().into()) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + let k: usize = env_or("BENCH_CONCURRENCY", 16); + let blob_mb: usize = env_or("BENCH_BLOB_MB", 32); + + let data: Bytes = (0..blob_mb * 1024 * 1024) + .map(|i| (i * 37 % 249) as u8) + .collect::>() + .into(); + let ref_hash: [u8; 32] = blake3::hash(&data).into(); + let blob_len = data.len() as u64; + let hash = "benchblobcache00000000000000000000000000000000000000000000000000"; + + // ── BEFORE (emulated): K concurrent direct inner fetches ─────────── + let remote = Arc::new(MockRemote::new(data.clone())); + let t = Instant::now(); + let mut set = tokio::task::JoinSet::new(); + for _ in 0..k { + let r = remote.clone(); + set.spawn(async move { + let s = r.get_blob_stream(hash).await.expect("stream"); + drain(s).await + }); + } + while let Some(res) = set.join_next().await { + let (n, h) = res.expect("join"); + assert_eq!(n, blob_len); + assert_eq!(h, ref_hash); + } + let before_wall = t.elapsed().as_secs_f64() * 1000.0; + let before_fetches = remote.fetches.load(Ordering::Relaxed); + let before_mb = remote.bytes_served.load(Ordering::Relaxed) / (1024 * 1024); + + // ── AFTER: K concurrent CachedBlobBackend reads, cold cache ──────── + let remote = Arc::new(MockRemote::new(data.clone())); + let dir = tempfile::tempdir().expect("tempdir"); + let cached = Arc::new(CachedBlobBackend::new( + remote.clone(), + &BlobCacheConfig { + cache_dir: dir.path().to_path_buf(), + max_cache_bytes: 1 << 30, + }, + )); + cached.initialize().await.expect("init"); + + let t = Instant::now(); + let mut set = tokio::task::JoinSet::new(); + for _ in 0..k { + let c = cached.clone(); + set.spawn(async move { + let s = c.get_blob_stream(hash).await.expect("stream"); + drain(s).await + }); + } + while let Some(res) = set.join_next().await { + let (n, h) = res.expect("join"); + assert_eq!(n, blob_len); + assert_eq!(h, ref_hash, "cached read corrupted"); + } + let after_wall = t.elapsed().as_secs_f64() * 1000.0; + let after_fetches = remote.fetches.load(Ordering::Relaxed); + let after_mb = remote.bytes_served.load(Ordering::Relaxed) / (1024 * 1024); + + // Integrity of the durable cache file itself. + let (n, h) = drain(cached.get_blob_stream(hash).await.expect("warm")).await; + assert_eq!(n, blob_len); + assert_eq!(h, ref_hash, "durable cache file corrupted"); + let warm_fetches = remote.fetches.load(Ordering::Relaxed) - after_fetches; + + println!("# {k} concurrent cold readers of one {blob_mb} MiB blob (remote: 15 ms TTFB, paced)"); + println!( + "{:<24} {:>10} {:>14} {:>12}", + "variant", "wall ms", "inner fetches", "remote MiB" + ); + println!( + "{:<24} {:>10.0} {:>14} {:>12}", + "BEFORE (per-caller)", before_wall, before_fetches, before_mb + ); + println!( + "{:<24} {:>10.0} {:>14} {:>12}", + "AFTER (single-flight)", after_wall, after_fetches, after_mb + ); + + // ── Gates ─────────────────────────────────────────────────────────── + if after_fetches != 1 { + eprintln!("GATE FAIL: expected exactly 1 coalesced remote fetch, got {after_fetches}"); + std::process::exit(1); + } + if warm_fetches != 0 { + eprintln!("GATE FAIL: warm read hit the remote backend"); + std::process::exit(1); + } + println!("\nGATE PASS: {before_fetches} remote fetches -> 1, cache file verified"); +} diff --git a/examples/bench_carddav_report.rs b/examples/bench_carddav_report.rs new file mode 100644 index 00000000..9923d7f5 --- /dev/null +++ b/examples/bench_carddav_report.rs @@ -0,0 +1,531 @@ +//! CardDAV REPORT generation benchmark — dead double vCard generation + +//! O(N²) uid scan (BEFORE) vs single on-demand generation (AFTER). +//! +//! The old `handle_report` flow pre-generated a vCard for EVERY contact into a +//! `Vec<(uid, vcard)>`, then `generate_contacts_response` did a linear +//! `find(|(uid, _)| *uid == contact.uid)` per contact — O(N²) string compares +//! — and *discarded* the result (`let _ = vcard`), because +//! `write_contact_response` regenerates the vCard on demand anyway. The fix +//! deletes the pre-generation and the scan, and converts `contact_to_vcard` +//! from `push_str(&format!(…))` (one temp String per line) to +//! `write!(&mut String, …)`. +//! +//! `mod before` below is a verbatim copy of the OLD code (old +//! `contact_to_vcard`, old `generate_contacts_response` with the `vcards` +//! parameter, and the then-current `write_contact_response`), so one binary +//! measures both variants and byte-compares their output. +//! +//! Equivalence gate: BEFORE and AFTER XML must be byte-identical for every +//! (N, prop-set) combination, and the old/new `contact_to_vcard` must agree +//! byte-for-byte on every synthetic contact. Any mismatch exits 1 with the +//! first differing offset. +//! +//! Run (no Postgres needed): +//! cargo run --release --features bench --example bench_carddav_report +//! Tunables (env): +//! BENCH_REPS (5) median reported + +use std::env; +use std::time::Instant; + +use chrono::{NaiveDate, TimeZone, Utc}; +use oxicloud::application::adapters::carddav_adapter::{ + CardDavAdapter, CardDavReportType, contact_to_vcard, +}; +use oxicloud::application::adapters::webdav_adapter::QualifiedName; +use oxicloud::application::dtos::contact_dto::{AddressDto, ContactDto, EmailDto, PhoneDto}; + +/// Verbatim copy of the pre-fix production code (handler + adapter side), +/// kept here so the benchmark measures the real OLD flow, not a caricature. +mod before { + use std::io::Write; + + use oxicloud::application::adapters::carddav_adapter::CardDavReportType; + use oxicloud::application::adapters::webdav_adapter::QualifiedName; + use oxicloud::application::dtos::contact_dto::ContactDto; + use quick_xml::Writer; + use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; + + /// OLD `generate_contacts_response` — takes the pre-generated `vcards`, + /// does the O(N²) linear uid scan per contact, then throws the hit away. + pub fn generate_contacts_response( + writer: W, + contacts: &[ContactDto], + vcards: &[(String, String)], // (uid, vcard_data) + report: &CardDavReportType, + base_href: &str, + ) -> std::io::Result<()> { + let mut xml_writer = Writer::new(writer); + + xml_writer.write_event(Event::Start( + BytesStart::new("D:multistatus").with_attributes([ + ("xmlns:D", "DAV:"), + ("xmlns:CR", "urn:ietf:params:xml:ns:carddav"), + ]), + ))?; + + let props = match report { + CardDavReportType::AddressbookQuery { props } => props.clone(), + CardDavReportType::AddressbookMultiget { props, .. } => props.clone(), + CardDavReportType::SyncCollection { props, .. } => props.clone(), + }; + + for contact in contacts { + let href = format!("{}{}.vcf", base_href, contact.uid); + let vcard = vcards + .iter() + .find(|(uid, _)| *uid == contact.uid) + .map(|(_, data)| data.as_str()) + .unwrap_or(""); + write_contact_response(&mut xml_writer, contact, &props, &href)?; + // If address-data is requested, include vcard + if props.iter().any(|p| p.name == "address-data") || props.is_empty() { + // Already handled in write_contact_response + } + let _ = vcard; // suppress warning - used via contact_to_vcard fallback + } + + xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + Ok(()) + } + + /// Copy of the (unchanged) private `write_contact_response`, wired to the + /// OLD `contact_to_vcard` so the BEFORE variant is fully self-contained. + fn write_contact_response( + xml_writer: &mut Writer, + contact: &ContactDto, + props: &[QualifiedName], + href: &str, + ) -> std::io::Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(href)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + + if props.is_empty() { + // Return standard properties + xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "\"{}\"", + contact.etag + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer.write_event(Event::Text(BytesText::new("text/vcard; charset=utf-8")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + + // Include vCard data + let vcard = contact_to_vcard(contact); + xml_writer.write_event(Event::Start(BytesStart::new("CR:address-data")))?; + xml_writer.write_event(Event::Text(BytesText::new(&vcard)))?; + xml_writer.write_event(Event::End(BytesEnd::new("CR:address-data")))?; + } else { + for prop in props { + match (prop.namespace.as_str(), prop.name.as_str()) { + ("DAV:", "resourcetype") => { + xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; + } + ("DAV:", "getetag") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "\"{}\"", + contact.etag + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + } + ("DAV:", "getcontenttype") => { + xml_writer + .write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer.write_event(Event::Text(BytesText::new( + "text/vcard; charset=utf-8", + )))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + } + ("DAV:", "getlastmodified") => { + xml_writer + .write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + xml_writer.write_event(Event::Text(BytesText::new( + &contact.updated_at.to_rfc2822(), + )))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + } + ("urn:ietf:params:xml:ns:carddav", "address-data") => { + let vcard = contact_to_vcard(contact); + xml_writer.write_event(Event::Start(BytesStart::new("CR:address-data")))?; + xml_writer.write_event(Event::Text(BytesText::new(&vcard)))?; + xml_writer.write_event(Event::End(BytesEnd::new("CR:address-data")))?; + } + _ => { + let prop_name = if prop.namespace == "urn:ietf:params:xml:ns:carddav" { + format!("CR:{}", prop.name) + } else if prop.namespace == "DAV:" { + format!("D:{}", prop.name) + } else { + prop.name.clone() + }; + xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?; + } + } + } + } + + xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; + xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; + + xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; + + Ok(()) + } + + /// OLD `contact_to_vcard` — one `push_str(&format!(…))` temp String per line. + pub fn contact_to_vcard(contact: &ContactDto) -> String { + let mut vcard = String::from("BEGIN:VCARD\r\nVERSION:3.0\r\n"); + + vcard.push_str(&format!("UID:{}\r\n", contact.uid)); + + if let (Some(last), Some(first)) = (&contact.last_name, &contact.first_name) { + vcard.push_str(&format!("N:{};{};;;\r\n", last, first)); + } else if let Some(last) = &contact.last_name { + vcard.push_str(&format!("N:{};;;;\r\n", last)); + } else if let Some(first) = &contact.first_name { + vcard.push_str(&format!("N:;{};;;\r\n", first)); + } + + if let Some(fn_name) = &contact.full_name { + vcard.push_str(&format!("FN:{}\r\n", fn_name)); + } else { + // FN is mandatory in vCard 3.0 + let fn_name = format!( + "{} {}", + contact.first_name.as_deref().unwrap_or(""), + contact.last_name.as_deref().unwrap_or(""), + ) + .trim() + .to_string(); + if !fn_name.is_empty() { + vcard.push_str(&format!("FN:{}\r\n", fn_name)); + } else { + vcard.push_str("FN:Unknown\r\n"); + } + } + + if let Some(nickname) = &contact.nickname { + vcard.push_str(&format!("NICKNAME:{}\r\n", nickname)); + } + + for email in &contact.email { + vcard.push_str(&format!( + "EMAIL;TYPE={}:{}\r\n", + email.r#type.to_uppercase(), + email.email + )); + } + + for phone in &contact.phone { + vcard.push_str(&format!( + "TEL;TYPE={}:{}\r\n", + phone.r#type.to_uppercase(), + phone.number + )); + } + + for addr in &contact.address { + let adr = format!( + ";;{};{};{};{};{}", + addr.street.as_deref().unwrap_or(""), + addr.city.as_deref().unwrap_or(""), + addr.state.as_deref().unwrap_or(""), + addr.postal_code.as_deref().unwrap_or(""), + addr.country.as_deref().unwrap_or(""), + ); + vcard.push_str(&format!( + "ADR;TYPE={}:{}\r\n", + addr.r#type.to_uppercase(), + adr + )); + } + + if let Some(org) = &contact.organization { + vcard.push_str(&format!("ORG:{}\r\n", org)); + } + if let Some(title) = &contact.title { + vcard.push_str(&format!("TITLE:{}\r\n", title)); + } + if let Some(notes) = &contact.notes { + vcard.push_str(&format!("NOTE:{}\r\n", notes.replace('\n', "\\n"))); + } + if let Some(bday) = &contact.birthday { + vcard.push_str(&format!("BDAY:{}\r\n", bday.format("%Y-%m-%d"))); + } + if let Some(photo) = &contact.photo_url { + vcard.push_str(&format!("PHOTO;VALUE=URI:{}\r\n", photo)); + } + + vcard.push_str(&format!( + "REV:{}\r\n", + contact.updated_at.format("%Y%m%dT%H%M%SZ") + )); + vcard.push_str("END:VCARD\r\n"); + + vcard + } +} + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +/// Deterministic synthetic address book: every contact has 2 emails, 1 phone +/// and 1 address; optional fields (nickname, notes-with-newline, birthday, +/// photo, missing names → FN fallback) are cycled so the byte-equality gate +/// exercises every `contact_to_vcard` branch, not just the happy path. +fn make_contacts(n: usize) -> Vec { + let created = Utc.with_ymd_and_hms(2026, 1, 15, 9, 0, 0).unwrap(); + let updated = Utc.with_ymd_and_hms(2026, 6, 30, 18, 45, 12).unwrap(); + + (0..n) + .map(|i| { + let (full_name, first_name, last_name) = match i % 5 { + 0 => ( + Some(format!("Contact {i:05} Example")), + Some(format!("Contact{i:05}")), + Some("Example".to_string()), + ), + 1 => ( + None, + Some(format!("Contact{i:05}")), + Some("Example".to_string()), + ), + 2 => (None, None, Some("Example".to_string())), + 3 => (None, Some(format!("Contact{i:05}")), None), + _ => (None, None, None), // FN:Unknown fallback + }; + ContactDto { + id: format!("id-{i:05}"), + address_book_id: "bench-book".to_string(), + uid: format!("bench-contact-{i:05}@oxicloud"), + full_name, + first_name, + last_name, + nickname: (i % 7 == 0).then(|| format!("nick{i}")), + email: vec![ + EmailDto { + email: format!("contact{i:05}@example.com"), + r#type: "work".to_string(), + is_primary: true, + }, + EmailDto { + email: format!("contact{i:05}@home.example.org"), + r#type: "home".to_string(), + is_primary: false, + }, + ], + phone: vec![PhoneDto { + number: format!("+1-555-{:04}", i % 10_000), + r#type: "cell".to_string(), + is_primary: true, + }], + address: vec![AddressDto { + street: Some(format!("{} Main Street", i + 1)), + city: Some("Springfield".to_string()), + state: Some("IL".to_string()), + postal_code: Some(format!("{:05}", 60_000 + (i % 1_000))), + country: Some("USA".to_string()), + r#type: "home".to_string(), + is_primary: true, + }], + organization: Some("OxiCloud Benchmarks Inc.".to_string()), + title: Some("Engineer".to_string()), + notes: (i % 11 == 0).then(|| "line one\nline two & ".to_string()), + photo_url: (i % 13 == 0).then(|| format!("https://example.com/avatars/{i}.jpg")), + birthday: (i % 3 == 0).then(|| NaiveDate::from_ymd_opt(1990, 5, 17).unwrap()), + anniversary: None, + created_at: created, + updated_at: updated, + etag: format!("etag-{i:05}"), + } + }) + .collect() +} + +fn dav(name: &str) -> QualifiedName { + QualifiedName { + namespace: "DAV:".to_string(), + name: name.to_string(), + } +} + +fn carddav(name: &str) -> QualifiedName { + QualifiedName { + namespace: "urn:ietf:params:xml:ns:carddav".to_string(), + name: name.to_string(), + } +} + +/// OLD handler flow: pre-generate a vCard per contact, then generate the XML +/// (which re-generates every vCard on demand and never reads the pre-made ones). +fn run_before(contacts: &[ContactDto], report: &CardDavReportType, base_href: &str) -> Vec { + // Generate vCards (verbatim old handle_report pre-generation) + let vcards: Vec<(String, String)> = contacts + .iter() + .map(|c| (c.uid.clone(), before::contact_to_vcard(c))) + .collect(); + + let mut out = Vec::new(); + before::generate_contacts_response(&mut out, contacts, &vcards, report, base_href) + .expect("BEFORE XML generation failed"); + out +} + +/// NEW production path. +fn run_after(contacts: &[ContactDto], report: &CardDavReportType, base_href: &str) -> Vec { + let mut out = Vec::new(); + CardDavAdapter::generate_contacts_response(&mut out, contacts, report, base_href) + .expect("AFTER XML generation failed"); + out +} + +fn median(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[xs.len() / 2] +} + +fn first_diff(a: &[u8], b: &[u8]) -> Option { + if a == b { + return None; + } + Some( + a.iter() + .zip(b.iter()) + .position(|(x, y)| x != y) + .unwrap_or_else(|| a.len().min(b.len())), + ) +} + +fn context_snippet(bytes: &[u8], at: usize) -> String { + let start = at.saturating_sub(40); + let end = (at + 40).min(bytes.len()); + String::from_utf8_lossy(&bytes[start..end]).into_owned() +} + +fn main() { + let reps: usize = env_or("BENCH_REPS", 5); + let base_href = "/carddav/bench-book/"; + + let prop_sets: Vec<(&str, Vec)> = vec![ + ("getetag", vec![dav("getetag")]), + ( + "getetag + address-data", + vec![dav("getetag"), carddav("address-data")], + ), + // Not part of the timing table, but gated too: the empty-props + // default path also embeds address-data. + ("(empty = allprop default)", vec![]), + ]; + let sizes = [500usize, 5_000]; + + // ── Equivalence gate ──────────────────────────────────────────────── + let gate_contacts = make_contacts(*sizes.iter().max().unwrap()); + for c in &gate_contacts { + let old = before::contact_to_vcard(c); + let new = contact_to_vcard(c); + if old != new { + let at = first_diff(old.as_bytes(), new.as_bytes()).unwrap(); + eprintln!( + "EQUIVALENCE FAILURE: contact_to_vcard differs for uid={} at byte {}\n old: …{}…\n new: …{}…", + c.uid, + at, + context_snippet(old.as_bytes(), at), + context_snippet(new.as_bytes(), at), + ); + std::process::exit(1); + } + } + for &n in &sizes { + let contacts = &gate_contacts[..n]; + for (label, props) in &prop_sets { + let report = CardDavReportType::AddressbookQuery { + props: props.clone(), + }; + let old_xml = run_before(contacts, &report, base_href); + let new_xml = run_after(contacts, &report, base_href); + if let Some(at) = first_diff(&old_xml, &new_xml) { + eprintln!( + "EQUIVALENCE FAILURE: REPORT XML differs (N={}, props={}) at byte {} (before {} B, after {} B)\n before: …{}…\n after: …{}…", + n, + label, + at, + old_xml.len(), + new_xml.len(), + context_snippet(&old_xml, at), + context_snippet(&new_xml, at), + ); + std::process::exit(1); + } + } + } + println!( + "equivalence gate: BEFORE == AFTER byte-identical for all prop sets at N = {:?} (and all {} vCards match)\n", + sizes, + gate_contacts.len() + ); + + // ── Timing ────────────────────────────────────────────────────────── + println!("| N | props | BEFORE ms | AFTER ms | speedup |"); + println!("|------:|------------------------|----------:|---------:|--------:|"); + for &n in &sizes { + let contacts = &gate_contacts[..n]; + for (label, props) in prop_sets.iter().take(2) { + let report = CardDavReportType::AddressbookQuery { + props: props.clone(), + }; + + // Warm-up (allocator, caches) — result discarded. + let _ = run_before(contacts, &report, base_href); + let _ = run_after(contacts, &report, base_href); + + let mut before_ms = Vec::with_capacity(reps); + let mut after_ms = Vec::with_capacity(reps); + for _ in 0..reps { + let t0 = Instant::now(); + let out = run_before(contacts, &report, base_href); + before_ms.push(t0.elapsed().as_secs_f64() * 1_000.0); + std::hint::black_box(&out); + + let t1 = Instant::now(); + let out = run_after(contacts, &report, base_href); + after_ms.push(t1.elapsed().as_secs_f64() * 1_000.0); + std::hint::black_box(&out); + } + let b = median(before_ms); + let a = median(after_ms); + println!( + "| {:>5} | {:<22} | {:>9.3} | {:>8.3} | {:>6.2}x |", + n, + label, + b, + a, + b / a + ); + } + } + println!( + "\n(median of {} reps; BEFORE includes the old handler's vCard pre-generation loop,", + reps + ); + println!(" which the old code then discarded — the O(N²) uid scan dominates at large N)"); +} diff --git a/examples/bench_dto_map.rs b/examples/bench_dto_map.rs new file mode 100644 index 00000000..20ee7e5d --- /dev/null +++ b/examples/bench_dto_map.rs @@ -0,0 +1,589 @@ +//! File/Folder entity → DTO mapping benchmark — per-row allocation churn. +//! +//! Isolates the variables the DTO-mapping change touches: +//! +//! • `Arc::::from(&'static str)` for the closed-set display fields +//! (icon class, icon special class, category) — always alloc + copy — +//! vs interned `Arc` lookups (`intern_display` / `intern_mime`). +//! • `File::compute_etag` / `Folder::compute_etag` — `chars().take(16) +//! .collect::()` + `format!` (2 allocs) vs one sized buffer. +//! • `format_file_size` — two `format!` calls per row vs one buffer. +//! • `Folder → FolderDto` — per-getter `.to_string()` clones + a +//! double-allocated etag vs `into_parts()` moves. +//! +//! The OLD mapping logic is copied verbatim into `mod before` so one binary +//! reports BEFORE vs AFTER side by side, and an equivalence gate asserts the +//! two produce byte-identical DTOs for every row (exit 1 on any diff). +//! +//! Sections: +//! 1. File → FileDto wall time (p50 ns/row over BENCH_PASSES passes) +//! 2. Folder → FolderDto wall time (same) +//! 3. Alloc calls/row (counting global allocator wrapping System — the +//! lib crate sets no global allocator; mimalloc lives in main.rs only, +//! which examples do not link) +//! 4. Equivalence gate: BEFORE output == AFTER output, field by field +//! +//! Run (no Postgres needed): +//! cargo run --release --features bench --example bench_dto_map +//! Tunables (env): +//! BENCH_ROWS (10000) BENCH_PASSES (100) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::hint::black_box; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use oxicloud::application::dtos::file_dto::FileDto; +use oxicloud::application::dtos::folder_dto::FolderDto; +use oxicloud::domain::entities::file::File; +use oxicloud::domain::entities::folder::Folder; +use oxicloud::domain::services::path_service::StoragePath; +use uuid::Uuid; + +// ─── Counting allocator (Section 3) ───────────────────────────────────────── + +static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0); + +struct CountingAlloc; + +unsafe impl GlobalAlloc for CountingAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.realloc(ptr, layout, new_size) } + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc_zeroed(layout) } + } +} + +#[global_allocator] +static GLOBAL: CountingAlloc = CountingAlloc; + +// ─── BEFORE: verbatim copy of the pre-optimization mapping logic ──────────── + +/// Pre-optimization reference implementation. Copied verbatim from the old +/// `From for FileDto` / `From for FolderDto` bodies, the old +/// `File::compute_etag` / `Folder::compute_etag` formulas and the old +/// `format_file_size` — kept byte-for-byte in behaviour so the equivalence +/// gate proves the optimized paths change nothing observable. +#[allow(clippy::all)] +mod before { + use std::sync::Arc; + + use oxicloud::application::dtos::display_helpers::{ + category_for, icon_class_for, icon_special_class_for, + }; + use oxicloud::application::dtos::file_dto::FileDto; + use oxicloud::application::dtos::folder_dto::FolderDto; + use oxicloud::domain::entities::file::File; + use oxicloud::domain::entities::folder::Folder; + + /// Old `File::compute_etag`: intermediate `collect::()` + + /// `format!` — 2 allocations for one ~21-char string. + fn file_compute_etag(blob_hash: &str, modified_at: u64) -> String { + let prefix: String = blob_hash.chars().take(16).collect(); + format!("{}-{}", prefix, modified_at) + } + + /// Old `Folder::compute_etag` (same shape as the file formula). + fn folder_compute_etag(id: &str, tree_modified_at: u64) -> String { + let prefix: String = id.chars().take(16).collect(); + format!("{}-{}", prefix, tree_modified_at) + } + + /// Old `format_file_size`: two `format!` calls per row. + fn format_file_size(bytes: u64) -> String { + if bytes == 0 { + return "0 Bytes".to_string(); + } + + const K: f64 = 1024.0; + const SIZES: [&str; 5] = ["Bytes", "KB", "MB", "GB", "TB"]; + + let i = ((bytes as f64).ln() / K.ln()).floor() as usize; + let i = i.min(SIZES.len() - 1); + + let value = bytes as f64 / K.powi(i as i32); + + let formatted = format!("{:.2}", value); + let formatted = formatted.trim_end_matches('0').trim_end_matches('.'); + + format!("{} {}", formatted, SIZES[i]) + } + + /// Old `From for FileDto` body: `Arc::from(&str)` for the three + /// display fields and the mime type (alloc + copy each), 2-alloc etag, + /// 2-format size string. + pub fn file_to_dto(file: File) -> FileDto { + let etag = file_compute_etag(file.content_hash(), file.modified_at()); + let content_hash = file.content_hash().to_string(); + + let parts = file.into_parts(); + + let icon_class: Arc = Arc::from(icon_class_for(&parts.name, &parts.mime_type)); + let icon_special_class: Arc = + Arc::from(icon_special_class_for(&parts.name, &parts.mime_type)); + let category: Arc = Arc::from(category_for(&parts.name, &parts.mime_type)); + let size_formatted = format_file_size(parts.size); + let mime_type: Arc = Arc::from(parts.mime_type.as_str()); + + FileDto { + id: parts.id, + name: parts.name, + path: parts.path_string, + size: parts.size, + mime_type, + folder_id: parts.folder_id, + created_at: parts.created_at, + modified_at: parts.modified_at, + icon_class, + icon_special_class, + category, + size_formatted, + sort_date: None, + content_hash, + etag, + created_by: parts.created_by, + updated_by: parts.updated_by, + } + } + + /// Old `From for FolderDto` body: per-getter `.to_string()` + /// clones, `folder.etag().to_string()` (etag built then cloned — the + /// verbatim double alloc) and 3 fresh `Arc::from` constants per row. + pub fn folder_to_dto(folder: Folder) -> FolderDto { + let is_root = folder.parent_id().is_none(); + let etag = folder_compute_etag(folder.id(), folder.tree_modified_at()).to_string(); + + FolderDto { + id: folder.id().to_string(), + name: folder.name().to_string(), + path: folder.path_string().to_string(), + parent_id: folder.parent_id().map(String::from), + drive_id: folder.drive_id(), + created_at: folder.created_at(), + modified_at: folder.modified_at(), + is_root, + icon_class: Arc::from("fas fa-folder"), + icon_special_class: Arc::from("folder-icon"), + category: Arc::from("Folder"), + etag, + created_by: folder.created_by(), + updated_by: folder.updated_by(), + } + } +} + +// ─── Synthetic corpus ──────────────────────────────────────────────────────── + +/// (extension, mime) matrix: interned common types, generic MIMEs that +/// exercise the extension fallback, and exotic MIMEs that miss the intern +/// table so the fallback `Arc::from` path is measured too. +const KINDS: &[(&str, &str)] = &[ + ("jpg", "image/jpeg"), + ("png", "image/png"), + ("heic", "image/heic"), + ("mp4", "video/mp4"), + ("mov", "video/quicktime"), + ("mp3", "audio/mpeg"), + ("flac", "audio/flac"), + ("pdf", "application/pdf"), + ( + "docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ( + "xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + ("txt", "text/plain"), + ("md", "text/markdown"), + ("csv", "text/csv"), + ("json", "application/json"), + ("zip", "application/zip"), + ("gz", "application/gzip"), + // Extension fallback: generic MIME, type resolved from the name. + ("rs", "application/octet-stream"), + ("py", "application/octet-stream"), + ("svelte", "application/octet-stream"), + ("dmg", "application/octet-stream"), + ("bin", "application/octet-stream"), + // No extension + empty MIME: full-default path. + ("", ""), + // Exotic MIMEs: miss the intern table, fall back to Arc::from. + ("pdb", "chemical/x-pdb"), + ("xyz", "application/x-very-exotic-subtype+custom"), +]; + +const SIZES: &[u64] = &[ + 0, + 137, + 500, + 1_024, + 1_536, + 65_536, + 1_048_576, + 3_423_744, + 987_654_321, + 1_073_741_824, + 5_497_558_138_880, // ~5 TB +]; + +/// Deterministic xorshift64* — fake-but-plausible 64-char lowercase hex +/// BLAKE3 hashes. +fn next_seed(seed: &mut u64) -> u64 { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + seed.wrapping_mul(0x2545F4914F6CDD1D) +} + +fn fake_blake3(seed: &mut u64) -> String { + format!( + "{:016x}{:016x}{:016x}{:016x}", + next_seed(seed), + next_seed(seed), + next_seed(seed), + next_seed(seed) + ) +} + +fn build_files(rows: usize) -> Vec { + let mut seed = 0x9E3779B97F4A7C15u64; + (0..rows) + .map(|i| { + let (ext, mime) = KINDS[i % KINDS.len()]; + let name = if ext.is_empty() { + format!("file_{i:05}") + } else { + format!("file_{i:05}.{ext}") + }; + let path = StoragePath::from_string(&format!("/bench/dir_{}/{}", i % 37, name)); + let folder_id = if i % 3 == 0 { + None + } else { + Some(Uuid::from_u128(1000 + (i % 37) as u128).to_string()) + }; + let created_by = (i % 2 == 0).then(|| Uuid::from_u128(7 + (i % 5) as u128)); + let updated_by = (i % 4 == 0).then(|| Uuid::from_u128(11 + (i % 3) as u128)); + File::with_timestamps_blob_hash_and_provenance( + Uuid::from_u128(i as u128).to_string(), + name, + path, + SIZES[i % SIZES.len()], + mime.to_string(), + folder_id, + 1_600_000_000 + i as u64, + 1_700_000_000 + (i as u64 * 7) % 100_000, + fake_blake3(&mut seed), + created_by, + updated_by, + ) + .expect("valid synthetic file") + }) + .collect() +} + +fn build_folders(rows: usize) -> Vec { + (0..rows) + .map(|i| { + let name = format!("folder_{i:05}"); + let path = StoragePath::from_string(&format!("/bench/parent_{}/{}", i % 37, name)); + let parent_id = if i % 5 == 0 { + None + } else { + Some(Uuid::from_u128(2000 + (i % 37) as u128).to_string()) + }; + let created_by = (i % 2 == 0).then(|| Uuid::from_u128(7 + (i % 5) as u128)); + let updated_by = (i % 4 == 0).then(|| Uuid::from_u128(11 + (i % 3) as u128)); + Folder::with_timestamps_tree_and_provenance( + Uuid::from_u128(500_000 + i as u128).to_string(), + name, + path, + parent_id, + Uuid::from_u128(42 + (i % 4) as u128), + 1_600_000_000 + i as u64, + 1_700_000_000 + (i as u64 * 7) % 100_000, + 1_700_000_000 + (i as u64 * 11) % 100_000, + created_by, + updated_by, + ) + .expect("valid synthetic folder") + }) + .collect() +} + +// ─── Measurement helpers ───────────────────────────────────────────────────── + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn median(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[xs.len() / 2] +} + +/// p50 wall seconds per pass of `f` over `passes` passes. +fn p50_pass_secs(passes: usize, mut f: impl FnMut()) -> f64 { + f(); // warmup (also initializes LazyLock intern tables) + let mut xs = Vec::with_capacity(passes); + for _ in 0..passes { + let t0 = Instant::now(); + f(); + xs.push(t0.elapsed().as_secs_f64()); + } + median(xs) +} + +/// Allocation calls performed by one run of `f` (deterministic — the +/// mappings do no I/O and touch no shared caches beyond the intern tables, +/// which the warmup run already initialized). +fn allocs_of(mut f: impl FnMut()) -> u64 { + f(); // warmup so one-time lazy init isn't attributed to the variant + let start = ALLOC_CALLS.load(Ordering::Relaxed); + f(); + ALLOC_CALLS.load(Ordering::Relaxed) - start +} + +struct Row { + variant: &'static str, + ns_per_row: f64, + allocs_per_row: f64, +} + +// ─── Equivalence gate (Section 4) ──────────────────────────────────────────── + +macro_rules! cmp_field { + ($diffs:expr, $i:expr, $kind:expr, $b:expr, $a:expr, $field:ident) => { + if $b.$field != $a.$field { + $diffs += 1; + if $diffs <= 20 { + println!( + " DIFF {} row {}: {} BEFORE={:?} AFTER={:?}", + $kind, + $i, + stringify!($field), + $b.$field, + $a.$field + ); + } + } + }; +} + +fn diff_file(i: usize, b: &FileDto, a: &FileDto, diffs: &mut u64) { + cmp_field!(*diffs, i, "file", b, a, id); + cmp_field!(*diffs, i, "file", b, a, name); + cmp_field!(*diffs, i, "file", b, a, path); + cmp_field!(*diffs, i, "file", b, a, size); + cmp_field!(*diffs, i, "file", b, a, mime_type); + cmp_field!(*diffs, i, "file", b, a, folder_id); + cmp_field!(*diffs, i, "file", b, a, created_at); + cmp_field!(*diffs, i, "file", b, a, modified_at); + cmp_field!(*diffs, i, "file", b, a, icon_class); + cmp_field!(*diffs, i, "file", b, a, icon_special_class); + cmp_field!(*diffs, i, "file", b, a, category); + cmp_field!(*diffs, i, "file", b, a, size_formatted); + cmp_field!(*diffs, i, "file", b, a, sort_date); + cmp_field!(*diffs, i, "file", b, a, content_hash); + cmp_field!(*diffs, i, "file", b, a, etag); + cmp_field!(*diffs, i, "file", b, a, created_by); + cmp_field!(*diffs, i, "file", b, a, updated_by); +} + +fn diff_folder(i: usize, b: &FolderDto, a: &FolderDto, diffs: &mut u64) { + cmp_field!(*diffs, i, "folder", b, a, id); + cmp_field!(*diffs, i, "folder", b, a, name); + cmp_field!(*diffs, i, "folder", b, a, path); + cmp_field!(*diffs, i, "folder", b, a, parent_id); + cmp_field!(*diffs, i, "folder", b, a, drive_id); + cmp_field!(*diffs, i, "folder", b, a, created_at); + cmp_field!(*diffs, i, "folder", b, a, modified_at); + cmp_field!(*diffs, i, "folder", b, a, is_root); + cmp_field!(*diffs, i, "folder", b, a, icon_class); + cmp_field!(*diffs, i, "folder", b, a, icon_special_class); + cmp_field!(*diffs, i, "folder", b, a, category); + cmp_field!(*diffs, i, "folder", b, a, etag); + cmp_field!(*diffs, i, "folder", b, a, created_by); + cmp_field!(*diffs, i, "folder", b, a, updated_by); +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +fn main() { + let rows: usize = env_or("BENCH_ROWS", 10_000).max(1); + let passes: usize = env_or("BENCH_PASSES", 100).max(1); + + let files = build_files(rows); + let folders = build_folders(rows); + println!( + "corpus: {rows} files ({} kinds x {} sizes) + {rows} folders, {passes} timed passes", + KINDS.len(), + SIZES.len() + ); + println!( + "note: each measured pass pays one entity clone per row (mapping consumes the\n\ + entity); the clone-only baseline is measured separately and subtracted.\n" + ); + + // ── Section 1: File → FileDto wall time ───────────────────────────── + println!("── Section 1: File → FileDto (p50 wall, net of clone) ──"); + let file_base_s = p50_pass_secs(passes, || { + for f in &files { + black_box(f.clone()); + } + }); + let file_before_s = p50_pass_secs(passes, || { + for f in &files { + black_box(before::file_to_dto(f.clone())); + } + }); + let file_after_s = p50_pass_secs(passes, || { + for f in &files { + black_box(FileDto::from(f.clone())); + } + }); + let file_base_ns = file_base_s * 1e9 / rows as f64; + let file_before_ns = (file_before_s - file_base_s) * 1e9 / rows as f64; + let file_after_ns = (file_after_s - file_base_s) * 1e9 / rows as f64; + println!(" clone-only baseline: {file_base_ns:8.1} ns/row"); + println!(" BEFORE mapping: {file_before_ns:8.1} ns/row"); + println!(" AFTER mapping: {file_after_ns:8.1} ns/row\n"); + + // ── Section 2: Folder → FolderDto wall time ───────────────────────── + println!("── Section 2: Folder → FolderDto (p50 wall, net of clone) ──"); + let folder_base_s = p50_pass_secs(passes, || { + for f in &folders { + black_box(f.clone()); + } + }); + let folder_before_s = p50_pass_secs(passes, || { + for f in &folders { + black_box(before::folder_to_dto(f.clone())); + } + }); + let folder_after_s = p50_pass_secs(passes, || { + for f in &folders { + black_box(FolderDto::from(f.clone())); + } + }); + let folder_base_ns = folder_base_s * 1e9 / rows as f64; + let folder_before_ns = (folder_before_s - folder_base_s) * 1e9 / rows as f64; + let folder_after_ns = (folder_after_s - folder_base_s) * 1e9 / rows as f64; + println!(" clone-only baseline: {folder_base_ns:8.1} ns/row"); + println!(" BEFORE mapping: {folder_before_ns:8.1} ns/row"); + println!(" AFTER mapping: {folder_after_ns:8.1} ns/row\n"); + + // ── Section 3: allocation calls per row ───────────────────────────── + println!("── Section 3: allocator calls per row (net of clone) ──"); + let file_base_a = allocs_of(|| { + for f in &files { + black_box(f.clone()); + } + }) as f64 + / rows as f64; + let file_before_a = allocs_of(|| { + for f in &files { + black_box(before::file_to_dto(f.clone())); + } + }) as f64 + / rows as f64 + - file_base_a; + let file_after_a = allocs_of(|| { + for f in &files { + black_box(FileDto::from(f.clone())); + } + }) as f64 + / rows as f64 + - file_base_a; + let folder_base_a = allocs_of(|| { + for f in &folders { + black_box(f.clone()); + } + }) as f64 + / rows as f64; + let folder_before_a = allocs_of(|| { + for f in &folders { + black_box(before::folder_to_dto(f.clone())); + } + }) as f64 + / rows as f64 + - folder_base_a; + let folder_after_a = allocs_of(|| { + for f in &folders { + black_box(FolderDto::from(f.clone())); + } + }) as f64 + / rows as f64 + - folder_base_a; + println!(" file clone baseline: {file_base_a:6.2} allocs/row"); + println!(" file BEFORE mapping: {file_before_a:6.2} allocs/row"); + println!(" file AFTER mapping: {file_after_a:6.2} allocs/row"); + println!(" folder clone baseline: {folder_base_a:6.2} allocs/row"); + println!(" folder BEFORE mapping: {folder_before_a:6.2} allocs/row"); + println!(" folder AFTER mapping: {folder_after_a:6.2} allocs/row\n"); + + // ── Section 4: equivalence gate ───────────────────────────────────── + println!("── Section 4: equivalence gate (BEFORE == AFTER, field by field) ──"); + let mut diffs: u64 = 0; + for (i, f) in files.iter().enumerate() { + let b = before::file_to_dto(f.clone()); + let a = FileDto::from(f.clone()); + diff_file(i, &b, &a, &mut diffs); + } + for (i, f) in folders.iter().enumerate() { + let b = before::folder_to_dto(f.clone()); + let a = FolderDto::from(f.clone()); + diff_folder(i, &b, &a, &mut diffs); + } + if diffs > 0 { + println!(" FAILED: {diffs} field diffs between BEFORE and AFTER mappings"); + std::process::exit(1); + } + println!(" PASSED: {rows} files + {rows} folders map byte-identically\n"); + + // ── Markdown summary ───────────────────────────────────────────────── + let table = [ + Row { + variant: "File→FileDto BEFORE", + ns_per_row: file_before_ns, + allocs_per_row: file_before_a, + }, + Row { + variant: "File→FileDto AFTER", + ns_per_row: file_after_ns, + allocs_per_row: file_after_a, + }, + Row { + variant: "Folder→FolderDto BEFORE", + ns_per_row: folder_before_ns, + allocs_per_row: folder_before_a, + }, + Row { + variant: "Folder→FolderDto AFTER", + ns_per_row: folder_after_ns, + allocs_per_row: folder_after_a, + }, + ]; + println!("| variant | ns/row | allocs/row |"); + println!("|---|---:|---:|"); + for r in &table { + println!( + "| {} | {:.1} | {:.2} |", + r.variant, r.ns_per_row, r.allocs_per_row + ); + } +} diff --git a/examples/bench_folder_keyset.rs b/examples/bench_folder_keyset.rs new file mode 100644 index 00000000..a4579143 --- /dev/null +++ b/examples/bench_folder_keyset.rs @@ -0,0 +1,264 @@ +//! PROPFIND subfolder-paging benchmark — LIMIT/OFFSET + COUNT(*) OVER() vs +//! keyset, mirroring the files-side PROPFIND-PAGING fix. +//! +//! The streaming PROPFIND walkers (native WebDAV + NC-DAV) page a folder's +//! subfolders via `list_folders_paginated`, whose query is +//! `COUNT(*) OVER() … ORDER BY name LIMIT $2 OFFSET $3` — every page +//! window-aggregates and rescans ALL N subfolders (the total is only used +//! for has_next), so a full walk is O(N²/page) row visits. +//! +//! The AFTER shape is the same keyset used for files: `name > $last ORDER BY +//! name LIMIT k`, served by the existing UNIQUE index +//! `idx_folders_unique_name (parent_id, name, drive_id) WHERE NOT is_trashed +//! AND parent_id IS NOT NULL` — no migration needed. has_next falls out of +//! `rows.len() == limit`. +//! +//! Equivalence gate: the drained name sequence must be identical. +//! +//! Run (needs Postgres up; reads DATABASE_URL from .env): +//! cargo run --release --features bench --example bench_folder_keyset +//! Tunables: BENCH_DIRS (5000), BENCH_PAGE (500), BENCH_REPS (5) + +use std::env; +use std::time::Instant; + +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; +use uuid::Uuid; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +async fn seed(pool: &PgPool, dirs: usize) -> (Uuid, Uuid) { + let mut tx = pool.begin().await.expect("begin"); + let drive_id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.drives (kind, quota_bytes) VALUES ('shared', NULL) RETURNING id", + ) + .fetch_one(&mut *tx) + .await + .expect("drive"); + let folder_id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.folders (name, path, lpath, drive_id) + VALUES ('bench_folder_keyset', '/bench_folder_keyset', 'bench_folder_keyset', $1) + RETURNING id", + ) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("folder"); + sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2") + .bind(folder_id) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("stamp"); + tx.commit().await.expect("commit"); + + sqlx::query( + "INSERT INTO storage.folders (name, path, lpath, parent_id, drive_id) + SELECT 'Dir_' || LPAD(i::text, 6, '0'), + '/bench_folder_keyset/Dir_' || LPAD(i::text, 6, '0'), + ('bench_folder_keyset.d' || i)::ltree, + $1, $2 + FROM generate_series(1, $3) AS i", + ) + .bind(folder_id) + .bind(drive_id) + .bind(dirs as i32) + .execute(pool) + .await + .expect("dirs"); + sqlx::query("ANALYZE storage.folders") + .execute(pool) + .await + .ok(); + (drive_id, folder_id) +} + +const COLS: &str = "id::text, name, path, parent_id::text, drive_id, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by"; + +type Row = ( + String, + String, + String, + Option, + Uuid, + i64, + i64, + i64, + Option, + Option, +); +type RowWithTotal = ( + String, + String, + String, + Option, + Uuid, + i64, + i64, + i64, + Option, + Option, + i64, +); + +/// OLD: production `list_folders_paginated` shape — window total + OFFSET. +async fn walk_offset(pool: &PgPool, parent: Uuid, page: i64) -> (Vec, Vec) { + let mut offset = 0i64; + let mut names = Vec::new(); + let mut times = Vec::new(); + loop { + let t = Instant::now(); + let rows: Vec = sqlx::query_as(&format!( + "SELECT {COLS}, COUNT(*) OVER() AS total_count + FROM storage.folders + WHERE parent_id = $1::uuid AND NOT is_trashed + ORDER BY name + LIMIT $2 OFFSET $3" + )) + .bind(parent) + .bind(page) + .bind(offset) + .fetch_all(pool) + .await + .expect("offset page"); + times.push(t.elapsed().as_secs_f64() * 1000.0); + let n = rows.len(); + names.extend(rows.into_iter().map(|r| r.1)); + if (n as i64) < page { + break; + } + offset += n as i64; + } + (names, times) +} + +/// NEW: keyset on the existing unique index; has_next = rows.len() == limit. +async fn walk_keyset(pool: &PgPool, parent: Uuid, page: i64) -> (Vec, Vec) { + let mut after: Option = None; + let mut names = Vec::new(); + let mut times = Vec::new(); + loop { + let t = Instant::now(); + let rows: Vec = if let Some(a) = &after { + sqlx::query_as(&format!( + "SELECT {COLS} + FROM storage.folders + WHERE parent_id = $1::uuid AND NOT is_trashed AND name > $3 + ORDER BY name + LIMIT $2" + )) + .bind(parent) + .bind(page) + .bind(a) + .fetch_all(pool) + .await + } else { + sqlx::query_as(&format!( + "SELECT {COLS} + FROM storage.folders + WHERE parent_id = $1::uuid AND NOT is_trashed + ORDER BY name + LIMIT $2" + )) + .bind(parent) + .bind(page) + .fetch_all(pool) + .await + } + .expect("keyset page"); + times.push(t.elapsed().as_secs_f64() * 1000.0); + let n = rows.len(); + after = rows.last().map(|r| r.1.clone()); + names.extend(rows.into_iter().map(|r| r.1)); + if (n as i64) < page { + break; + } + } + (names, times) +} + +fn median(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[xs.len() / 2] +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + dotenvy::dotenv().ok(); + let url = env::var("DATABASE_URL").expect("set DATABASE_URL"); + let dirs: usize = env_or("BENCH_DIRS", 5_000); + let page: i64 = env_or("BENCH_PAGE", 500); + let reps: usize = env_or("BENCH_REPS", 5); + + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&url) + .await + .expect("connect"); + println!("seeding {dirs} subfolders (one-time)…"); + let (drive_id, folder_id) = seed(&pool, dirs).await; + + let (ref_names, _) = walk_offset(&pool, folder_id, page).await; + assert_eq!(ref_names.len(), dirs, "reference drain size"); + + println!("\n# full PROPFIND subfolder walk of a {dirs}-dir parent, {page}/page"); + println!( + "{:<12} {:>11} {:>11} {:>8}", + "mode", "total ms", "p50 ms/pg", "vs OLD" + ); + + let mut failures = 0usize; + let mut base: Option = None; + for mode in ["OFFSET", "KEYSET"] { + let mut totals = Vec::with_capacity(reps); + let mut per_page: Vec = Vec::new(); + for _ in 0..reps { + let t = Instant::now(); + let (names, times) = if mode == "OFFSET" { + walk_offset(&pool, folder_id, page).await + } else { + walk_keyset(&pool, folder_id, page).await + }; + totals.push(t.elapsed().as_secs_f64() * 1000.0); + if names != ref_names { + eprintln!("EQUIVALENCE FAILURE: {mode} drained a different sequence"); + failures += 1; + } + per_page = times; + } + let ms = median(totals); + let speedup = base + .map(|b| format!("{:.1}x", b / ms)) + .unwrap_or_else(|| "1.0x".into()); + if base.is_none() { + base = Some(ms); + } + println!( + "{:<12} {:>11.1} {:>11.2} {:>8}", + mode, + ms, + median(per_page.clone()), + speedup + ); + } + + let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1") + .bind(drive_id) + .execute(&pool) + .await; + + if failures > 0 { + eprintln!("\n{failures} equivalence failures — the NEW shape is NOT safe to adopt"); + std::process::exit(1); + } +} diff --git a/examples/bench_listing_keyset.rs b/examples/bench_listing_keyset.rs new file mode 100644 index 00000000..5b6ccc2b --- /dev/null +++ b/examples/bench_listing_keyset.rs @@ -0,0 +1,495 @@ +//! Web-UI folder listing benchmark — whole-folder rescan vs keyset pushdown. +//! +//! `list_resources_paged` (folder_db_repository.rs) pages the SPA files view +//! with a UNION-ALL CTE (folders + files) and applies the keyset cursor +//! OUTSIDE the CTE on computed columns (`sort_str = LOWER(name)`, +//! `folder_first`). Postgres therefore scans every remaining row of the +//! folder and top-N-sorts it on EVERY page — a 20k-file folder pays a full +//! rescan per 200-row page. +//! +//! The AFTER shape pushes the cursor into each branch as a sargable +//! row-value comparison (`(LOWER(name), id) > ($str, $id)`), gives each +//! branch its own `ORDER BY … LIMIT`, and adds two expression indexes: +//! idx_files_folder_lname (folder_id, LOWER(name), id) WHERE NOT is_trashed +//! idx_folders_parent_lname (parent_id, LOWER(name), id) WHERE NOT is_trashed +//! The outer query then merges ≤ 2·limit pre-sorted rows. +//! +//! Modes (full drain of the folder in default "name" order, plus a +//! modified_at parity check): +//! OLD/no-idx — the true BEFORE +//! OLD/idx — new indexes alone, old query shape +//! NEW/idx — the AFTER +//! +//! Equivalence gate: the drained (type, id) sequence must be identical +//! across all modes; a mismatch aborts with exit(1). +//! +//! Run (needs Postgres up; reads DATABASE_URL from .env): +//! cargo run --release --features bench --example bench_listing_keyset +//! Tunables: BENCH_FILES (20000), BENCH_DIRS (300), BENCH_PAGE (200), +//! BENCH_REPS (3) + +use std::env; +use std::time::Instant; + +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; +use uuid::Uuid; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +async fn seed(pool: &PgPool, files: usize, dirs: usize) -> (Uuid, Uuid) { + let mut tx = pool.begin().await.expect("begin"); + let drive_id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.drives (kind, quota_bytes) VALUES ('shared', NULL) RETURNING id", + ) + .fetch_one(&mut *tx) + .await + .expect("drive"); + let folder_id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.folders (name, path, lpath, drive_id) + VALUES ('bench_listing', '/bench_listing', 'bench_listing', $1) RETURNING id", + ) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("folder"); + sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2") + .bind(folder_id) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("stamp"); + tx.commit().await.expect("commit"); + + // Mixed-case names so LOWER() actually differs from the raw column. + sqlx::query( + "INSERT INTO storage.folders (name, path, lpath, parent_id, drive_id) + SELECT 'Dir_' || LPAD(i::text, 6, '0'), + '/bench_listing/Dir_' || LPAD(i::text, 6, '0'), + ('bench_listing.d' || i)::ltree, + $1, $2 + FROM generate_series(1, $3) AS i", + ) + .bind(folder_id) + .bind(drive_id) + .bind(dirs as i32) + .execute(pool) + .await + .expect("dirs"); + sqlx::query( + "INSERT INTO storage.files + (name, folder_id, blob_hash, size, mime_type, drive_id, + updated_at, category_order) + SELECT 'File_' || LPAD(i::text, 8, '0') || '.JPG', $1, + 'benchlisting0000000000000000000000000000000000000000000000000000', + 1024 + i, 'image/jpeg', $2, + NOW() - (i || ' seconds')::interval, + 3 + FROM generate_series(1, $3) AS i", + ) + .bind(folder_id) + .bind(drive_id) + .bind(files as i32) + .execute(pool) + .await + .expect("files"); + sqlx::query("ANALYZE storage.files") + .execute(pool) + .await + .ok(); + sqlx::query("ANALYZE storage.folders") + .execute(pool) + .await + .ok(); + (drive_id, folder_id) +} + +const FOLDER_BRANCH: &str = r#" + SELECT + 'folder'::text AS resource_type, + f.id, + f.name, + f.parent_id AS folder_id, + NULL::text AS mime_type, + -1::bigint AS size, + f.created_at, + f.updated_at AS modified_at, + f.drive_id, + NULL::text AS blob_hash, + LOWER(f.name) AS sort_str, + 0::bigint AS type_order, + 0::int AS folder_first + FROM storage.folders f + WHERE f.parent_id = $1::uuid AND NOT f.is_trashed +"#; + +const FILE_BRANCH: &str = r#" + SELECT + 'file'::text AS resource_type, + fm.id, + fm.name, + fm.folder_id, + fm.mime_type, + fm.size::bigint, + fm.created_at, + fm.updated_at AS modified_at, + fm.drive_id, + fm.blob_hash, + LOWER(fm.name) AS sort_str, + fm.category_order::bigint AS type_order, + 1::int AS folder_first + FROM storage.files fm + WHERE fm.folder_id = $1::uuid AND NOT fm.is_trashed +"#; + +const COLS: &str = "resource_type, id, name, folder_id, mime_type, size, \ + created_at, modified_at, drive_id, blob_hash, \ + sort_str, type_order, folder_first"; + +type Row = ( + String, + Uuid, + String, + Option, + Option, + i64, + chrono::DateTime, + chrono::DateTime, + Uuid, + Option, + String, + i64, + i32, +); + +/// Cursor state for the walks: (folder_first, sort_str, modified_at, id). +#[derive(Clone)] +struct Cur { + ff: i64, + sort_str: String, + ts: chrono::DateTime, + id: Uuid, +} + +/// OLD shape, "name" order — production SQL verbatim: cursor OUTSIDE the CTE. +async fn old_page_name(pool: &PgPool, parent: Uuid, cur: Option<&Cur>, limit: i64) -> Vec { + let sql = format!( + "WITH resources AS ({FOLDER_BRANCH} UNION ALL {FILE_BRANCH}) \ + SELECT {COLS} FROM resources \ + WHERE ($3::bigint IS NULL) \ + OR (folder_first::bigint > $3) \ + OR (folder_first::bigint = $3 AND sort_str > $2) \ + OR (folder_first::bigint = $3 AND sort_str = $2 AND id > $5::uuid) \ + ORDER BY folder_first ASC, sort_str ASC, id ASC \ + LIMIT $6" + ); + sqlx::query_as(&sql) + .bind(parent) + .bind(cur.map(|c| c.sort_str.clone())) + .bind(cur.map(|c| c.ff)) + .bind(cur.map(|c| c.ts)) + .bind(cur.map(|c| c.id)) + .bind(limit) + .fetch_all(pool) + .await + .expect("old name page") +} + +/// NEW shape, "name" order — cursor pushed into each branch as a sargable +/// row-value comparison; each branch pre-sorts and pre-limits. +async fn new_page_name(pool: &PgPool, parent: Uuid, cur: Option<&Cur>, limit: i64) -> Vec { + match cur { + None => { + let sql = format!( + "SELECT {COLS} FROM ( \ + (SELECT * FROM ({FOLDER_BRANCH}) fb \ + ORDER BY sort_str ASC, id ASC LIMIT $2) \ + UNION ALL \ + (SELECT * FROM ({FILE_BRANCH}) lb \ + ORDER BY sort_str ASC, id ASC LIMIT $2) \ + ) r ORDER BY folder_first ASC, sort_str ASC, id ASC LIMIT $2" + ); + sqlx::query_as(&sql) + .bind(parent) + .bind(limit) + .fetch_all(pool) + .await + .expect("new name page (first)") + } + Some(c) if c.ff == 0 => { + // Cursor sits in the folder group: folders continue after the + // row-value cursor; ALL files still follow. + let sql = format!( + "SELECT {COLS} FROM ( \ + (SELECT * FROM ({FOLDER_BRANCH} \ + AND (LOWER(f.name), f.id) > ($3, $4::uuid)) fb \ + ORDER BY sort_str ASC, id ASC LIMIT $2) \ + UNION ALL \ + (SELECT * FROM ({FILE_BRANCH}) lb \ + ORDER BY sort_str ASC, id ASC LIMIT $2) \ + ) r ORDER BY folder_first ASC, sort_str ASC, id ASC LIMIT $2" + ); + sqlx::query_as(&sql) + .bind(parent) + .bind(limit) + .bind(&c.sort_str) + .bind(c.id) + .fetch_all(pool) + .await + .expect("new name page (folder cursor)") + } + Some(c) => { + // Cursor sits in the file group: the folder branch is exhausted. + let sql = format!( + "SELECT {COLS} FROM ( \ + SELECT * FROM ({FILE_BRANCH} \ + AND (LOWER(fm.name), fm.id) > ($3, $4::uuid)) lb \ + ORDER BY sort_str ASC, id ASC LIMIT $2 \ + ) r ORDER BY folder_first ASC, sort_str ASC, id ASC LIMIT $2" + ); + sqlx::query_as(&sql) + .bind(parent) + .bind(limit) + .bind(&c.sort_str) + .bind(c.id) + .fetch_all(pool) + .await + .expect("new name page (file cursor)") + } + } +} + +/// OLD shape, "modified_at" order (newest first) — production SQL verbatim. +async fn old_page_modified(pool: &PgPool, parent: Uuid, cur: Option<&Cur>, limit: i64) -> Vec { + let sql = format!( + "WITH resources AS ({FOLDER_BRANCH} UNION ALL {FILE_BRANCH}) \ + SELECT {COLS} FROM resources \ + WHERE ($4::timestamptz IS NULL) \ + OR (modified_at < $4) \ + OR (modified_at = $4 AND id < $5::uuid) \ + ORDER BY modified_at DESC, id DESC \ + LIMIT $6" + ); + sqlx::query_as(&sql) + .bind(parent) + .bind(cur.map(|c| c.sort_str.clone())) + .bind(cur.map(|c| c.ff)) + .bind(cur.map(|c| c.ts)) + .bind(cur.map(|c| c.id)) + .bind(limit) + .fetch_all(pool) + .await + .expect("old modified page") +} + +/// NEW shape, "modified_at" order — per-branch row-value cursor + LIMIT. +async fn new_page_modified(pool: &PgPool, parent: Uuid, cur: Option<&Cur>, limit: i64) -> Vec { + match cur { + None => { + let sql = format!( + "SELECT {COLS} FROM ( \ + (SELECT * FROM ({FOLDER_BRANCH}) fb \ + ORDER BY modified_at DESC, id DESC LIMIT $2) \ + UNION ALL \ + (SELECT * FROM ({FILE_BRANCH}) lb \ + ORDER BY modified_at DESC, id DESC LIMIT $2) \ + ) r ORDER BY modified_at DESC, id DESC LIMIT $2" + ); + sqlx::query_as(&sql) + .bind(parent) + .bind(limit) + .fetch_all(pool) + .await + .expect("new modified page (first)") + } + Some(c) => { + let sql = format!( + "SELECT {COLS} FROM ( \ + (SELECT * FROM ({FOLDER_BRANCH} \ + AND (f.updated_at, f.id) < ($3, $4::uuid)) fb \ + ORDER BY modified_at DESC, id DESC LIMIT $2) \ + UNION ALL \ + (SELECT * FROM ({FILE_BRANCH} \ + AND (fm.updated_at, fm.id) < ($3, $4::uuid)) lb \ + ORDER BY modified_at DESC, id DESC LIMIT $2) \ + ) r ORDER BY modified_at DESC, id DESC LIMIT $2" + ); + sqlx::query_as(&sql) + .bind(parent) + .bind(limit) + .bind(c.ts) + .bind(c.id) + .fetch_all(pool) + .await + .expect("new modified page (cursor)") + } + } +} + +/// Drain the whole folder; returns ((type, id) sequence, per-page ms). +async fn drain( + pool: &PgPool, + parent: Uuid, + limit: i64, + new_shape: bool, + by_modified: bool, +) -> (Vec<(String, Uuid)>, Vec) { + let mut cur: Option = None; + let mut seq = Vec::new(); + let mut page_ms = Vec::new(); + loop { + let t = Instant::now(); + let rows = match (new_shape, by_modified) { + (false, false) => old_page_name(pool, parent, cur.as_ref(), limit).await, + (true, false) => new_page_name(pool, parent, cur.as_ref(), limit).await, + (false, true) => old_page_modified(pool, parent, cur.as_ref(), limit).await, + (true, true) => new_page_modified(pool, parent, cur.as_ref(), limit).await, + }; + page_ms.push(t.elapsed().as_secs_f64() * 1000.0); + let n = rows.len(); + if let Some(last) = rows.last() { + cur = Some(Cur { + ff: last.12 as i64, + sort_str: last.10.clone(), + ts: last.7, + id: last.1, + }); + } + seq.extend(rows.into_iter().map(|r| (r.0, r.1))); + if (n as i64) < limit { + break; + } + } + (seq, page_ms) +} + +async fn set_indexes(pool: &PgPool, on: bool) { + if on { + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_files_folder_lname + ON storage.files (folder_id, LOWER(name), id) WHERE NOT is_trashed", + ) + .execute(pool) + .await + .expect("files idx"); + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_folders_parent_lname + ON storage.folders (parent_id, LOWER(name), id) WHERE NOT is_trashed", + ) + .execute(pool) + .await + .expect("folders idx"); + } else { + sqlx::query("DROP INDEX IF EXISTS storage.idx_files_folder_lname") + .execute(pool) + .await + .ok(); + sqlx::query("DROP INDEX IF EXISTS storage.idx_folders_parent_lname") + .execute(pool) + .await + .ok(); + } +} + +fn median(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[xs.len() / 2] +} + +fn p99(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[(xs.len() as f64 * 0.99) as usize % xs.len()] +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + dotenvy::dotenv().ok(); + let url = env::var("DATABASE_URL").expect("set DATABASE_URL"); + let files: usize = env_or("BENCH_FILES", 20_000); + let dirs: usize = env_or("BENCH_DIRS", 300); + let page: i64 = env_or("BENCH_PAGE", 200); + let reps: usize = env_or("BENCH_REPS", 3); + + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&url) + .await + .expect("connect"); + println!("seeding {files} files + {dirs} dirs (one-time)…"); + let (drive_id, folder_id) = seed(&pool, files, dirs).await; + let total = files + dirs; + + // Reference sequences for the equivalence gate (computed once per mode). + set_indexes(&pool, false).await; + let (ref_name, _) = drain(&pool, folder_id, page, false, false).await; + let (ref_modified, _) = drain(&pool, folder_id, page, false, true).await; + assert_eq!(ref_name.len(), total, "name drain row count"); + assert_eq!(ref_modified.len(), total, "modified drain row count"); + + println!("\n# full SPA-listing drain of a {files}-file/{dirs}-dir folder, {page}/page"); + println!( + "{:<28} {:>11} {:>11} {:>11} {:>8}", + "mode", "total ms", "p50 ms/pg", "p99 ms/pg", "vs OLD" + ); + + let mut failures = 0usize; + for by_modified in [false, true] { + let label = if by_modified { "modified_at" } else { "name" }; + let reference = if by_modified { + &ref_modified + } else { + &ref_name + }; + let mut base: Option = None; + for (mode, new_shape, idx) in [ + ("OLD/no-idx", false, false), + ("OLD/idx", false, true), + ("NEW/idx", true, true), + ] { + set_indexes(&pool, idx).await; + let mut totals = Vec::with_capacity(reps); + let mut pages: Vec = Vec::new(); + for _ in 0..reps { + let t = Instant::now(); + let (seq, page_ms) = drain(&pool, folder_id, page, new_shape, by_modified).await; + totals.push(t.elapsed().as_secs_f64() * 1000.0); + if &seq != reference { + eprintln!("EQUIVALENCE FAILURE: {label}/{mode} drained a different sequence"); + failures += 1; + } + pages = page_ms; + } + let ms = median(totals); + let speedup = base + .map(|b| format!("{:.1}x", b / ms)) + .unwrap_or_else(|| "1.0x".into()); + if base.is_none() { + base = Some(ms); + } + println!( + "{:<28} {:>11.1} {:>11.2} {:>11.2} {:>8}", + format!("{label} {mode}"), + ms, + median(pages.clone()), + p99(pages.clone()), + speedup + ); + } + } + + let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1") + .bind(drive_id) + .execute(&pool) + .await; + // Leave the new indexes in place (they are the production migration). + + if failures > 0 { + eprintln!("\n{failures} equivalence failures — the NEW shape is NOT safe to adopt"); + std::process::exit(1); + } +} diff --git a/examples/bench_photos_timeline.rs b/examples/bench_photos_timeline.rs new file mode 100644 index 00000000..4f968485 --- /dev/null +++ b/examples/bench_photos_timeline.rs @@ -0,0 +1,356 @@ +//! Photos timeline benchmark — full-library scan vs per-drive LATERAL top-N. +//! +//! `list_media_files` (file_blob_read_repository.rs) filters by +//! `fi.drive_id IN ()`, joins folders + file_metadata, and +//! sorts globally by `media_sort_date DESC LIMIT k`. The doc comment claims +//! `idx_files_media_timeline_by_drive` lets LIMIT stop the scan early, but +//! the plan is a Nested Loop over the drive set feeding EVERY media row +//! through a Hash Left Join into a top-N heapsort ABOVE the join — the +//! index is drained to exhaustion on every page, so each timeline page +//! costs O(library), not O(page). +//! +//! The AFTER shape materialises the accessible drive ids once, then does a +//! `CROSS JOIN LATERAL (… ORDER BY media_sort_date DESC LIMIT k)` per drive +//! — each LATERAL is one bounded index scan — and merges `drives × k` rows. +//! The folders/file_metadata joins move OUTSIDE the top-N so only the k +//! emitted rows pay them. +//! +//! Equivalence gate: page-by-page id sequences must be identical (the seed +//! uses strictly distinct capture dates so ties cannot mask reordering). +//! +//! Run (needs Postgres up; reads DATABASE_URL from .env): +//! cargo run --release --features bench --example bench_photos_timeline +//! Tunables: BENCH_MEDIA (50000), BENCH_DRIVES (3), BENCH_PAGE (100), +//! BENCH_PAGES (10), BENCH_REPS (3) + +use std::env; +use std::time::Instant; + +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; +use uuid::Uuid; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +async fn seed(pool: &PgPool, media: usize, drives: usize) -> (Uuid, Vec) { + let caller = Uuid::new_v4(); + let mut drive_ids = Vec::with_capacity(drives); + for d in 0..drives { + let mut tx = pool.begin().await.expect("begin"); + let drive_id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.drives (kind, quota_bytes, policies) + VALUES ('shared', NULL, '{\"include_in_photo_index\": true}'::jsonb) + RETURNING id", + ) + .fetch_one(&mut *tx) + .await + .expect("drive"); + let folder_id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.folders (name, path, lpath, drive_id) + VALUES ($1, $2, $3::ltree, $4) RETURNING id", + ) + .bind(format!("bench_photos_{d}")) + .bind(format!("/bench_photos_{d}")) + .bind(format!("bench_photos_{d}")) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("folder"); + sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2") + .bind(folder_id) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("stamp"); + sqlx::query( + "INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) + VALUES ('user', $1, 'drive', $2, 'viewer', $1)", + ) + .bind(caller) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("grant"); + tx.commit().await.expect("commit"); + + // Strictly distinct capture dates (offset per drive) so the + // equivalence gate cannot be masked by tie reordering. + let per_drive = media / drives; + sqlx::query( + "INSERT INTO storage.files + (name, folder_id, blob_hash, size, mime_type, drive_id, media_sort_date) + SELECT 'IMG_' || LPAD(i::text, 8, '0') || '.jpg', $1, + 'benchphotos00000000000000000000000000000000000000000000000000000', + 2048, 'image/jpeg', $2, + TIMESTAMPTZ '2026-01-01 00:00:00Z' - ((i * $4 + $5) || ' seconds')::interval + FROM generate_series(1, $3) AS i", + ) + .bind(folder_id) + .bind(drive_id) + .bind(per_drive as i32) + .bind(drives as i32) + .bind(d as i32) + .execute(pool) + .await + .expect("files"); + drive_ids.push(drive_id); + } + sqlx::query("ANALYZE storage.files") + .execute(pool) + .await + .ok(); + sqlx::query("ANALYZE storage.role_grants") + .execute(pool) + .await + .ok(); + (caller, drive_ids) +} + +type MediaRow = ( + String, // id::text + String, // name + Option, // folder_id::text + Option, // fo.path + i64, // size + String, // mime_type + i64, // created_at epoch + i64, // updated_at epoch + String, // blob_hash + Option, // created_by + Option, // updated_by + i64, // sort_date epoch + Option, // width + Option, // height +); + +const GRANTS_SUBQ: &str = r#" + SELECT d.id + FROM storage.drives d + JOIN storage.role_grants g + ON g.resource_type = 'drive' + AND g.resource_id = d.id + WHERE ( + (g.subject_type = 'user' AND g.subject_id = $1) + OR (g.subject_type = 'group' AND g.subject_id IN + (SELECT storage.caller_group_ids($1))) + ) + AND (g.expires_at IS NULL OR g.expires_at > NOW()) + AND (d.policies->>'include_in_photo_index')::boolean = true +"#; + +/// OLD shape — production SQL verbatim. +async fn old_page( + pool: &PgPool, + caller: Uuid, + before: Option>, + limit: i64, +) -> Vec { + let cursor_pred = if before.is_some() { + "AND fi.media_sort_date < $2" + } else { + "AND $2::timestamptz IS NULL" + }; + let sql = format!( + r#" + SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, + fi.size, fi.mime_type, + EXTRACT(EPOCH FROM fi.created_at)::bigint, + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.blob_hash, + fi.created_by, fi.updated_by, + EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date, + fm.width, fm.height + FROM storage.files fi + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id + WHERE fi.drive_id IN ({GRANTS_SUBQ}) + AND NOT fi.is_trashed + AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%') + {cursor_pred} + ORDER BY fi.media_sort_date DESC + LIMIT $3 + "# + ); + sqlx::query_as(&sql) + .bind(caller) + .bind(before) + .bind(limit) + .fetch_all(pool) + .await + .expect("old page") +} + +/// NEW shape — accessible drives materialised once, per-drive LATERAL top-N +/// on the timeline index, folders/metadata joined only on the emitted rows. +async fn new_page( + pool: &PgPool, + caller: Uuid, + before: Option>, + limit: i64, +) -> Vec { + let cursor_pred = if before.is_some() { + "AND fi.media_sort_date < $2" + } else { + "AND $2::timestamptz IS NULL" + }; + let sql = format!( + r#" + WITH accessible AS MATERIALIZED ({GRANTS_SUBQ}) + SELECT top.id::text, top.name, top.folder_id::text, fo.path, + top.size, top.mime_type, + EXTRACT(EPOCH FROM top.created_at)::bigint, + EXTRACT(EPOCH FROM top.updated_at)::bigint, + top.blob_hash, + top.created_by, top.updated_by, + EXTRACT(EPOCH FROM top.media_sort_date)::bigint AS sort_date, + fm.width, fm.height + FROM ( + SELECT fi.* + FROM accessible a + CROSS JOIN LATERAL ( + SELECT fi.* + FROM storage.files fi + WHERE fi.drive_id = a.id + AND NOT fi.is_trashed + AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%') + {cursor_pred} + ORDER BY fi.media_sort_date DESC + LIMIT $3 + ) fi + ORDER BY fi.media_sort_date DESC + LIMIT $3 + ) top + LEFT JOIN storage.folders fo ON fo.id = top.folder_id + LEFT JOIN storage.file_metadata fm ON fm.file_id = top.id + ORDER BY top.media_sort_date DESC + "# + ); + sqlx::query_as(&sql) + .bind(caller) + .bind(before) + .bind(limit) + .fetch_all(pool) + .await + .expect("new page") +} + +fn median(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[xs.len() / 2] +} + +/// Walk `pages` cursor pages; returns (id sequence, per-page ms). +async fn walk( + pool: &PgPool, + caller: Uuid, + page: i64, + pages: usize, + new_shape: bool, +) -> (Vec, Vec) { + let mut before: Option> = None; + let mut ids = Vec::new(); + let mut times = Vec::new(); + for _ in 0..pages { + let t = Instant::now(); + let rows = if new_shape { + new_page(pool, caller, before, page).await + } else { + old_page(pool, caller, before, page).await + }; + times.push(t.elapsed().as_secs_f64() * 1000.0); + if rows.is_empty() { + break; + } + // Cursor semantics mirror production: whole-second epoch of the last + // row (list_media_files hands the epoch back to the client). + let last_epoch = rows.last().unwrap().11; + before = chrono::DateTime::from_timestamp(last_epoch, 0); + ids.extend(rows.into_iter().map(|r| r.0)); + } + (ids, times) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + dotenvy::dotenv().ok(); + let url = env::var("DATABASE_URL").expect("set DATABASE_URL"); + let media: usize = env_or("BENCH_MEDIA", 50_000); + let drives: usize = env_or("BENCH_DRIVES", 3); + let page: i64 = env_or("BENCH_PAGE", 100); + let pages: usize = env_or("BENCH_PAGES", 10); + let reps: usize = env_or("BENCH_REPS", 3); + + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&url) + .await + .expect("connect"); + println!("seeding {media} media rows across {drives} drives (one-time)…"); + let (caller, drive_ids) = seed(&pool, media, drives).await; + + let (ref_ids, _) = walk(&pool, caller, page, pages, false).await; + assert_eq!( + ref_ids.len(), + (page as usize) * pages, + "reference walk size" + ); + + println!("\n# {pages} timeline pages of {page} over a {media}-photo library ({drives} drives)"); + println!( + "{:<8} {:>11} {:>11} {:>8}", + "mode", "total ms", "p50 ms/pg", "vs OLD" + ); + + let mut failures = 0usize; + let mut base: Option = None; + for (mode, new_shape) in [("OLD", false), ("NEW", true)] { + let mut totals = Vec::with_capacity(reps); + let mut per_page: Vec = Vec::new(); + for _ in 0..reps { + let t = Instant::now(); + let (ids, times) = walk(&pool, caller, page, pages, new_shape).await; + totals.push(t.elapsed().as_secs_f64() * 1000.0); + if ids != ref_ids { + eprintln!("EQUIVALENCE FAILURE: {mode} walk drained different ids"); + failures += 1; + } + per_page = times; + } + let ms = median(totals); + let speedup = base + .map(|b| format!("{:.1}x", b / ms)) + .unwrap_or_else(|| "1.0x".into()); + if base.is_none() { + base = Some(ms); + } + println!( + "{:<8} {:>11.1} {:>11.2} {:>8}", + mode, + ms, + median(per_page.clone()), + speedup + ); + } + + for d in drive_ids { + let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1") + .bind(d) + .execute(&pool) + .await; + } + let _ = sqlx::query("DELETE FROM storage.role_grants WHERE subject_id = $1") + .bind(caller) + .execute(&pool) + .await; + + if failures > 0 { + eprintln!("\n{failures} equivalence failures — the NEW shape is NOT safe to adopt"); + std::process::exit(1); + } +} diff --git a/examples/bench_s3_put.rs b/examples/bench_s3_put.rs new file mode 100644 index 00000000..54363a05 --- /dev/null +++ b/examples/bench_s3_put.rs @@ -0,0 +1,190 @@ +//! S3 chunk-PUT benchmark — HEAD-before-PUT vs unconditional PUT. +//! +//! `DedupService::settle_batch` writes every NEW chunk of every upload via +//! `put_blob_from_bytes_unsynced`. S3/Azure never overrode it, so the trait +//! default routed it through `put_blob_from_bytes`, whose "idempotent" HEAD +//! probe made every chunk write pay 2 request round-trips. Content-addressed +//! keys make re-PUTs overwrite-safe, so the new override PUTs directly. +//! +//! The stub S3 endpoint (in-process axum, per-request latency injection) +//! counts HEAD/PUT requests: +//! BEFORE — put_blob_from_bytes (HEAD 404 + PUT per chunk) +//! AFTER — put_blob_from_bytes_unsynced (PUT per chunk) +//! +//! Section 2 measures the removed Azure `data.to_vec()` copy in isolation. +//! +//! Gates: AFTER request count == chunks (vs 2x), AFTER wall < BEFORE wall. +//! +//! No Postgres. Run: +//! cargo run --release --features bench --example bench_s3_put +//! Tunables: BENCH_CHUNKS (500), BENCH_CHUNK_KB (256), BENCH_CONCURRENCY (8), +//! BENCH_RTT_MS (10) + +use std::env; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +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::s3_blob_backend::S3BlobBackend; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +#[derive(Clone, Default)] +struct Counters { + heads: Arc, + puts: Arc, +} + +async fn stub_s3(latency: Duration, counters: Counters) -> String { + use axum::http::{Method, StatusCode}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("addr"); + let app = axum::Router::new().fallback(move |req: axum::extract::Request| { + let counters = counters.clone(); + async move { + tokio::time::sleep(latency).await; + match *req.method() { + Method::HEAD => { + counters.heads.fetch_add(1, Ordering::Relaxed); + StatusCode::NOT_FOUND + } + Method::PUT => { + // Drain the body like a real endpoint would. + let _ = axum::body::to_bytes(req.into_body(), usize::MAX).await; + counters.puts.fetch_add(1, Ordering::Relaxed); + StatusCode::OK + } + _ => StatusCode::OK, + } + } + }); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + format!("http://{addr}") +} + +async fn drive( + backend: Arc, + chunks: usize, + chunk_kb: usize, + concurrency: usize, + unsynced: bool, +) -> f64 { + let payload = Bytes::from(vec![0x5au8; chunk_kb * 1024]); + let sem = Arc::new(tokio::sync::Semaphore::new(concurrency)); + let t = Instant::now(); + let mut set = tokio::task::JoinSet::new(); + for i in 0..chunks { + let b = backend.clone(); + let p = payload.clone(); + let sem = sem.clone(); + 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); + }); + } + while let Some(r) = set.join_next().await { + r.expect("join"); + } + t.elapsed().as_secs_f64() * 1000.0 +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + let chunks: usize = env_or("BENCH_CHUNKS", 500); + let chunk_kb: usize = env_or("BENCH_CHUNK_KB", 256); + let concurrency: usize = env_or("BENCH_CONCURRENCY", 8); + let rtt_ms: u64 = env_or("BENCH_RTT_MS", 10); + + let counters = Counters::default(); + let endpoint = stub_s3(Duration::from_millis(rtt_ms), counters.clone()).await; + let backend = Arc::new(S3BlobBackend::new(&S3StorageConfig { + endpoint_url: Some(endpoint), + bucket: "bench".into(), + region: "us-east-1".into(), + access_key: "bench".into(), + secret_key: "bench".into(), + force_path_style: true, + })); + + println!( + "# {chunks} x {chunk_kb} KiB chunk PUTs at concurrency {concurrency}, {rtt_ms} ms/request stub" + ); + println!( + "{:<26} {:>10} {:>8} {:>8} {:>8}", + "variant", "wall ms", "HEADs", "PUTs", "vs OLD" + ); + + // BEFORE: the trait-default route (put_blob_from_bytes = HEAD + PUT). + let before = drive(backend.clone(), chunks, chunk_kb, concurrency, false).await; + let before_heads = counters.heads.swap(0, Ordering::Relaxed); + let before_puts = counters.puts.swap(0, Ordering::Relaxed); + println!( + "{:<26} {:>10.0} {:>8} {:>8} {:>8}", + "BEFORE (HEAD+PUT)", before, before_heads, before_puts, "1.0x" + ); + + // AFTER: the unsynced override (PUT only). + let after = drive(backend.clone(), chunks, chunk_kb, concurrency, true).await; + let after_heads = counters.heads.swap(0, Ordering::Relaxed); + let after_puts = counters.puts.swap(0, Ordering::Relaxed); + println!( + "{:<26} {:>10.0} {:>8} {:>8} {:>8}", + "AFTER (PUT only)", + after, + after_heads, + after_puts, + format!("{:.1}x", before / after) + ); + + // ── Section 2: the removed Azure to_vec() copy, in isolation ─────── + let mb = 4; + let data = Bytes::from(vec![0x77u8; mb * 1024 * 1024]); + let reps = 200; + let t = Instant::now(); + for _ in 0..reps { + let v = data.to_vec(); + std::hint::black_box(&v); + } + let copy_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64; + println!( + "\n# [2] removed Azure per-chunk copy: to_vec() of {mb} MiB = {copy_ms:.2} ms + {mb} MiB transient alloc per chunk" + ); + + // ── Gates ─────────────────────────────────────────────────────────── + if after_heads != 0 || after_puts != chunks as u64 { + eprintln!( + "GATE FAIL: AFTER issued {after_heads} HEADs / {after_puts} PUTs (expected 0 / {chunks})" + ); + std::process::exit(1); + } + if after >= before { + eprintln!( + "GATE FAIL: AFTER ({after:.0} ms) not faster than BEFORE ({before:.0} ms) — rollback" + ); + std::process::exit(1); + } + println!( + "GATE PASS: {}-request walk -> {} requests, {:.1}x faster", + before_heads + before_puts, + after_puts, + before / after + ); +} diff --git a/examples/bench_search_cache_mem.rs b/examples/bench_search_cache_mem.rs new file mode 100644 index 00000000..f451b3e1 --- /dev/null +++ b/examples/bench_search_cache_mem.rs @@ -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 { + 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 { + 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) -> String { + match v { + Some(kb) => format!("{:.1} MiB", kb as f64 / 1024.0), + None => "n/a".to_string(), + } +} + +fn fmt_kb_delta(start: Option, end: Option) -> 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, + hwm_end_kb: Option, + rss_start_kb: Option, + rss_end_kb: Option, + 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>) -> 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> = + 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."); +} diff --git a/examples/bench_upload_spool.rs b/examples/bench_upload_spool.rs new file mode 100644 index 00000000..46fe4dec --- /dev/null +++ b/examples/bench_upload_spool.rs @@ -0,0 +1,190 @@ +//! Upload spool/assembly I/O benchmark — buffer sizing on the chunk paths. +//! +//! Section 1 — assembly read (`stream_from_files`): every completed chunked +//! upload is read back once, part file by part file, through +//! `ReaderStream::with_capacity(file, N)`. Each poll is one blocking-pool +//! dispatch + one read(2) of N bytes; the shipped capacity was 64 KiB while +//! every other blob read path uses 256 KiB+. Sweeps N over +//! 64K/256K/512K/1M and reports wall time + read syscalls. +//! +//! Section 2 — chunk spool write (`stream_body_to_path`): the PUT handlers +//! wrote each HTTP frame (~16-64 KiB) straight to a bare tokio File — one +//! blocking-pool dispatch + write(2) per frame. Compares that against the +//! adopted `BufWriter::with_capacity(512 KiB)`. +//! +//! No Postgres. Run: +//! cargo run --release --features bench --example bench_upload_spool +//! Tunables: BENCH_PARTS (16), BENCH_PART_MB (10), BENCH_FRAME_KB (16), +//! BENCH_SPOOL_MB (10), BENCH_REPS (5) + +use std::env; +use std::path::PathBuf; +use std::time::Instant; + +use bytes::Bytes; +use futures::{StreamExt, TryStreamExt, stream}; +use tokio::io::AsyncWriteExt; +use tokio_util::io::ReaderStream; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +/// (read syscalls, write syscalls) from /proc/self/io. +fn io_counters() -> (u64, u64) { + let s = std::fs::read_to_string("/proc/self/io").expect("io"); + let get = |k: &str| { + s.lines() + .find(|l| l.starts_with(k)) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|v| v.parse().ok()) + .unwrap_or(0) + }; + (get("syscr:"), get("syscw:")) +} + +fn median(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[xs.len() / 2] +} + +/// The `stream_from_files` shape with a parameterized capacity. +async fn drain_parts(paths: Vec, cap: usize) -> (u64, [u8; 32]) { + let mut hasher = blake3::Hasher::new(); + let mut total = 0u64; + let s = stream::iter(paths.into_iter().map(Ok::<_, std::io::Error>)) + .and_then(|path| async move { + tokio::fs::File::open(path) + .await + .map(|file| ReaderStream::with_capacity(file, cap)) + }) + .try_flatten(); + let mut s = Box::pin(s); + while let Some(chunk) = s.next().await { + let chunk = chunk.expect("read"); + total += chunk.len() as u64; + hasher.update(&chunk); + } + (total, hasher.finalize().into()) +} + +/// The `stream_body_to_path` inner loop: frames -> file, optionally buffered. +async fn spool_frames(frames: &[Bytes], path: &std::path::Path, buffered: bool) { + let file = tokio::fs::File::create(path).await.expect("create"); + if buffered { + let mut w = tokio::io::BufWriter::with_capacity(512 * 1024, file); + for f in frames { + w.write_all(f).await.expect("write"); + } + w.flush().await.expect("flush"); + } else { + let mut w = file; + for f in frames { + w.write_all(f).await.expect("write"); + } + w.flush().await.expect("flush"); + } +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + let parts: usize = env_or("BENCH_PARTS", 16); + let part_mb: usize = env_or("BENCH_PART_MB", 10); + let frame_kb: usize = env_or("BENCH_FRAME_KB", 16); + let spool_mb: usize = env_or("BENCH_SPOOL_MB", 10); + let reps: usize = env_or("BENCH_REPS", 5); + + let dir = tempfile::tempdir().expect("tempdir"); + + // ── Section 1: assembly read capacity sweep ───────────────────────── + println!("# [1] assembly read: {parts} x {part_mb} MiB part files, warm page cache"); + let mut paths = Vec::with_capacity(parts); + let payload: Vec = (0..part_mb * 1024 * 1024) + .map(|i| (i * 31 % 251) as u8) + .collect(); + for i in 0..parts { + let p = dir.path().join(format!("part_{i:05}")); + tokio::fs::write(&p, &payload).await.expect("seed part"); + paths.push(p); + } + let expect_total = (parts * part_mb * 1024 * 1024) as u64; + let (_, ref_hash) = drain_parts(paths.clone(), 256 * 1024).await; + + println!( + "{:<10} {:>10} {:>12} {:>8}", + "capacity", "wall ms", "read sysc", "vs 64K" + ); + let mut base: Option = None; + for cap in [64 * 1024, 256 * 1024, 512 * 1024, 1024 * 1024] { + let mut walls = Vec::with_capacity(reps); + let mut syscr = 0u64; + for _ in 0..reps { + let (r0, _) = io_counters(); + let t = Instant::now(); + let (total, h) = drain_parts(paths.clone(), cap).await; + walls.push(t.elapsed().as_secs_f64() * 1000.0); + let (r1, _) = io_counters(); + syscr = r1 - r0; + assert_eq!(total, expect_total); + assert_eq!(h, ref_hash, "content mismatch at capacity {cap}"); + } + let ms = median(walls); + let speedup = base + .map(|b| format!("{:.2}x", b / ms)) + .unwrap_or_else(|| "1.00x".into()); + if base.is_none() { + base = Some(ms); + } + println!( + "{:<10} {:>10.1} {:>12} {:>8}", + format!("{}K", cap / 1024), + ms, + syscr, + speedup + ); + } + + // ── Section 2: chunk spool write, per-frame vs buffered ───────────── + let frames_n = spool_mb * 1024 / frame_kb; + println!( + "\n# [2] chunk spool: {frames_n} x {frame_kb} KiB frames ({spool_mb} MiB), 20 files/rep" + ); + let frame: Bytes = Bytes::from(vec![0xabu8; frame_kb * 1024]); + let frames: Vec = (0..frames_n).map(|_| frame.clone()).collect(); + + println!( + "{:<22} {:>10} {:>12} {:>8}", + "variant", "wall ms", "write sysc", "vs bare" + ); + let mut base: Option = None; + for (label, buffered) in [ + ("bare File (BEFORE)", false), + ("BufWriter 512K (AFTER)", true), + ] { + let mut walls = Vec::with_capacity(reps); + let mut syscw = 0u64; + for r in 0..reps { + let (_, w0) = io_counters(); + let t = Instant::now(); + for i in 0..20 { + let p = dir.path().join(format!("spool_{r}_{i}")); + spool_frames(&frames, &p, buffered).await; + tokio::fs::remove_file(&p).await.ok(); + } + walls.push(t.elapsed().as_secs_f64() * 1000.0); + let (_, w1) = io_counters(); + syscw = w1 - w0; + } + let ms = median(walls); + let speedup = base + .map(|b| format!("{:.2}x", b / ms)) + .unwrap_or_else(|| "1.00x".into()); + if base.is_none() { + base = Some(ms); + } + println!("{label:<22} {:>10.1} {:>12} {:>8}", ms, syscw, speedup); + } +} diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 09318d76..842393ed 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -10,7 +10,7 @@ import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import DrivePicker from '$lib/components/DrivePicker.svelte'; import Icon from '$lib/icons/Icon.svelte'; - import { iconNameFromClass } from '$lib/utils/display'; + import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display'; import { userInitials, avatarColorIndex } from '$lib/utils/avatar'; import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte'; import { apiFetch } from '$lib/api/client'; @@ -230,7 +230,7 @@ const currentLang = $derived(LANGUAGES.find((l) => l.code === i18n.locale) ?? LANGUAGES[0]); function formatTime(ms: number): string { - return new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + return dateTimeFormatFor(undefined, { hour: '2-digit', minute: '2-digit' }).format(ms); } function notifIcon(kind: string): string { diff --git a/frontend/src/lib/components/PhotoLightbox.svelte b/frontend/src/lib/components/PhotoLightbox.svelte index 7f2ab861..80e0009a 100644 --- a/frontend/src/lib/components/PhotoLightbox.svelte +++ b/frontend/src/lib/components/PhotoLightbox.svelte @@ -17,6 +17,7 @@ import { confirmDialog } from '$lib/stores/dialogs.svelte'; import { t } from '$lib/i18n/index.svelte'; import { errorToast } from '$lib/utils/errors'; + import { dateTimeFormatFor } from '$lib/utils/display'; import { isVideo, photoTimestamp } from '$lib/utils/media'; interface Props { @@ -47,13 +48,13 @@ }); function baseMeta(p: FileItem): string { - const dateStr = new Date(photoTimestamp(p)).toLocaleDateString(undefined, { + const dateStr = dateTimeFormatFor(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' - }); + }).format(photoTimestamp(p)); return p.size_formatted ? `${dateStr} · ${p.size_formatted}` : dateStr; } diff --git a/frontend/src/lib/utils/display.ts b/frontend/src/lib/utils/display.ts index 17dde94e..893395cd 100644 --- a/frontend/src/lib/utils/display.ts +++ b/frontend/src/lib/utils/display.ts @@ -56,6 +56,60 @@ export function fileIconKindClass(iconName: string): string { return `file-icon--${fileIconKind(iconName)}`; } +/** + * Module-scope cache of `Intl.DateTimeFormat` instances, keyed by + * `(locale, options signature)`. Constructing a formatter runs the full ICU + * locale/pattern resolution (~50–200µs) while a `format()` call is ~1µs, and + * {@link formatDate} runs roughly twice per row as large file lists render + * and scroll — so a construct-per-call implementation (what + * `toLocaleDateString(locale, options)` does under the hood) dominated list + * fill. Entries are keyed by the locale actually requested — never frozen at + * first use — so a runtime locale change just resolves a different entry. + */ +const dateTimeFormatCache = new Map(); + +// Entries built with `locale === undefined` snapshot the environment default +// locale at construction time. `toLocaleDateString(undefined, …)` re-reads the +// default on every call, so drop the cache if the default changes to keep the +// cached path behaviourally identical. +if (typeof window !== 'undefined') { + window.addEventListener('languagechange', () => dateTimeFormatCache.clear()); +} + +/** + * Cached equivalent of `new Intl.DateTimeFormat(locale, options)`. + * + * `date.toLocaleDateString(locale, options)` / `toLocaleTimeString(…)` are + * specified (ECMA-402) as building exactly this formatter per call — and + * their component defaulting is a no-op once `options` names any date/time + * component — so `dateTimeFormatFor(locale, options).format(date)` is + * output-identical while paying construction once per (locale, options). + * + * The options signature uses `JSON.stringify`, so pass options as a hoisted + * const or an inline literal (stable key order per callsite); a differently + * ordered but equal object would only create a redundant entry, never a wrong + * result. + */ +export function dateTimeFormatFor( + locale: string | undefined, + options?: Intl.DateTimeFormatOptions +): Intl.DateTimeFormat { + const key = `${locale ?? ''}|${options ? JSON.stringify(options) : ''}`; + let fmt = dateTimeFormatCache.get(key); + if (!fmt) { + fmt = new Intl.DateTimeFormat(locale, options); + dateTimeFormatCache.set(key, fmt); + } + return fmt; +} + +/** Options for {@link formatDate}, hoisted so every call shares one cache key. */ +const FORMAT_DATE_OPTS: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric' +}; + /** Format a timestamp (epoch seconds/ms or ISO-8601 string) as a local date. */ export function formatDate(value: number | string | null | undefined): string { if (value === null || value === undefined) return ''; @@ -67,5 +121,5 @@ export function formatDate(value: number | string | null | undefined): string { d = new Date(value); } if (Number.isNaN(d.getTime())) return ''; - return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + return dateTimeFormatFor(undefined, FORMAT_DATE_OPTS).format(d); } diff --git a/frontend/src/lib/utils/formatDate.bench.test.ts b/frontend/src/lib/utils/formatDate.bench.test.ts new file mode 100644 index 00000000..11bd36b1 --- /dev/null +++ b/frontend/src/lib/utils/formatDate.bench.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import { dateTimeFormatFor, formatDate } from './display'; + +/** + * Benchmark gate for the module-scope `Intl.DateTimeFormat` cache in + * `display.ts` ({@link formatDate} / {@link dateTimeFormatFor}). + * + * Audit finding: `formatDate` built a fresh `Intl.DateTimeFormat` on every + * call (`toLocaleDateString(undefined, opts)` constructs one internally), and + * it runs ~twice per row while file lists render and scroll — a 10k-item + * folder paid tens of thousands of ICU formatter constructions (~50–200µs + * each) during list fill. The fix caches formatters in a Map keyed by + * (locale, options signature). + * + * This gate asserts (1) the cached path is byte-identical to the + * construct-per-call code it replaced, across dates, option shapes, and + * locales (including an RTL one), and (2) it is decisively (≥3x) faster. If + * the perf assertion fails, the cache is not delivering and the change + * should be rolled back (it would be pure complexity). + */ + +/** The option shapes the app actually uses (display.ts + component callsites). */ +const DATE_OPTS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }; +const MONTH_OPTS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long' }; +const FULL_DATE_OPTS: Intl.DateTimeFormatOptions = { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' +}; +const DATE_TIME_OPTS: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' +}; +const TIME_OPTS: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' }; + +/** + * The pre-fix `formatDate`, verbatim: `toLocaleDateString` constructs a new + * `Intl.DateTimeFormat` internally on every call. This is the uncached + * reference the cached implementation must match and beat. + */ +function referenceFormatDate(value: number | string | null | undefined): string { + if (value === null || value === undefined) return ''; + let d: Date; + if (typeof value === 'number') { + // Heuristic: seconds vs milliseconds. + d = new Date(value < 1e12 ? value * 1000 : value); + } else { + d = new Date(value); + } + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleDateString(undefined, DATE_OPTS); +} + +/** ~20 inputs exercising the seconds/ms heuristic, ISO parsing, and edge cases. */ +const DATE_VALUES: Array = [ + 0, // epoch, seconds branch + 1, // seconds + 86_399, // seconds, last second of 1970-01-01 UTC + 951_782_400, // seconds, 2000-02-29 (leap day) + 1_700_000_000, // seconds + 999_999_999_999, // just under the 1e12 cutoff → seconds branch, far future + 1_000_000_000_000, // exactly 1e12 → milliseconds branch, 2001 + 1_700_000_000_000, // milliseconds + 1_766_620_800_000, // milliseconds, 2025-12-25 + Date.UTC(1999, 11, 31, 23, 59, 59), // ms, century boundary + Date.UTC(2038, 0, 19, 3, 14, 7), // ms, past the 32-bit epoch rollover + '2024-01-15', // date-only ISO (parsed as UTC midnight) + '2024-02-29T12:34:56Z', // leap day, UTC + '1999-12-31T23:59:59.999Z', + '2020-06-15T10:00:00+05:30', // non-UTC offset + '2031-11-05T08:15:30-05:00', + '0001-01-01T00:00:00Z', // extreme past + '2024-07-04T00:00:00', // no offset (local time) + 'definitely not a date', // invalid → '' + '', // invalid → '' + null, // → '' + undefined // → '' +]; + +/** Locales the app ships (see SUPPORTED_LOCALES); 'ar' renders RTL. */ +const SAMPLE_LOCALES = ['en', 'es', 'ar', 'ja'] as const; + +describe('cached Intl.DateTimeFormat (benchmark gate)', () => { + it('formatDate output is identical to the uncached reference', () => { + for (const value of DATE_VALUES) { + expect(formatDate(value), `formatDate(${JSON.stringify(value)})`).toBe( + referenceFormatDate(value) + ); + } + }); + + it('cached formatters match per-call construction across locales and option shapes', () => { + const dates = DATE_VALUES.filter((v): v is number | string => v !== null && v !== undefined) + .map((v) => (typeof v === 'number' ? new Date(v < 1e12 ? v * 1000 : v) : new Date(v))) + .filter((d) => !Number.isNaN(d.getTime())); + expect(dates.length).toBeGreaterThanOrEqual(18); + + for (const locale of SAMPLE_LOCALES) { + for (const d of dates) { + // Each toLocale*String call below is specified as constructing a + // fresh Intl.DateTimeFormat — the uncached reference behaviour. + expect(dateTimeFormatFor(locale, DATE_OPTS).format(d)).toBe( + d.toLocaleDateString(locale, DATE_OPTS) + ); + expect(dateTimeFormatFor(locale, MONTH_OPTS).format(d)).toBe( + d.toLocaleDateString(locale, MONTH_OPTS) + ); + expect(dateTimeFormatFor(locale, FULL_DATE_OPTS).format(d)).toBe( + d.toLocaleDateString(locale, FULL_DATE_OPTS) + ); + expect(dateTimeFormatFor(locale, DATE_TIME_OPTS).format(d)).toBe( + d.toLocaleDateString(locale, DATE_TIME_OPTS) + ); + expect(dateTimeFormatFor(locale, TIME_OPTS).format(d)).toBe( + d.toLocaleTimeString(locale, TIME_OPTS) + ); + expect(dateTimeFormatFor(undefined, DATE_OPTS).format(d)).toBe( + d.toLocaleDateString(undefined, DATE_OPTS) + ); + } + } + }); + + it('reuses one instance per (locale, options) and never freezes the first locale', () => { + // Same key → same instance (this is where the speedup comes from). + expect(dateTimeFormatFor('es', DATE_OPTS)).toBe(dateTimeFormatFor('es', DATE_OPTS)); + expect(dateTimeFormatFor(undefined, DATE_OPTS)).toBe(dateTimeFormatFor(undefined, DATE_OPTS)); + // Different locale or options → different instance: a runtime locale + // change must not keep formatting with the first locale seen. + expect(dateTimeFormatFor('ar', DATE_OPTS)).not.toBe(dateTimeFormatFor('es', DATE_OPTS)); + expect(dateTimeFormatFor('es', TIME_OPTS)).not.toBe(dateTimeFormatFor('es', DATE_OPTS)); + const d = new Date(Date.UTC(2024, 4, 17, 12, 0, 0)); + expect(dateTimeFormatFor('ar', DATE_OPTS).format(d)).toBe( + d.toLocaleDateString('ar', DATE_OPTS) + ); + expect(dateTimeFormatFor('es', DATE_OPTS).format(d)).toBe( + d.toLocaleDateString('es', DATE_OPTS) + ); + }); + + it( + 'formats 20k dates ≥3x faster than per-call construction (perf gate)', + { timeout: 30_000 }, + () => { + const N = 20_000; + const base = Date.UTC(2020, 0, 1); + // Deterministic spread of distinct ms timestamps across ~30 years. + const values = Array.from({ length: N }, (_, i) => base + i * 47_777_777); + + // Warm up both paths so JIT tiering and first-call construction sit + // outside the measured windows. `sink` defeats dead-code elimination. + let sink = 0; + for (let i = 0; i < 500; i++) { + sink += formatDate(values[i]).length; + sink += referenceFormatDate(values[i]).length; + } + + const t0 = performance.now(); + for (const v of values) sink += formatDate(v).length; + const cachedMs = performance.now() - t0; + + const t1 = performance.now(); + for (const v of values) sink += referenceFormatDate(v).length; + const uncachedMs = performance.now() - t1; + + expect(sink).toBeGreaterThan(0); + console.info( + `formatDate x ${N}: cached ${cachedMs.toFixed(1)} ms vs construct-per-call ${uncachedMs.toFixed(1)} ms (${(uncachedMs / cachedMs).toFixed(1)}x)` + ); + expect(cachedMs).toBeLessThan(uncachedMs / 3); + } + ); +}); diff --git a/frontend/src/routes/photos/+page.svelte b/frontend/src/routes/photos/+page.svelte index fbc844a3..7b4cdf07 100644 --- a/frontend/src/routes/photos/+page.svelte +++ b/frontend/src/routes/photos/+page.svelte @@ -15,6 +15,7 @@ import { t } from '$lib/i18n/index.svelte'; import { ui } from '$lib/stores/ui.svelte'; import { filterDotfiles } from '$lib/utils/dotfileFilter'; + import { dateTimeFormatFor } from '$lib/utils/display'; import { isVideo, photoTimestamp } from '$lib/utils/media'; type Tab = 'moments' | 'places' | 'people'; @@ -75,13 +76,13 @@ function bucketLabel(d: Date): string { if (groupMode === 'year') return `${d.getFullYear()}`; if (groupMode === 'month') - return d.toLocaleDateString(undefined, { year: 'numeric', month: 'long' }); - return d.toLocaleDateString(undefined, { + return dateTimeFormatFor(undefined, { year: 'numeric', month: 'long' }).format(d); + return dateTimeFormatFor(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' - }); + }).format(d); } const groups = $derived.by(() => { diff --git a/frontend/src/routes/shared/+page.svelte b/frontend/src/routes/shared/+page.svelte index 0fb1408c..64a877e7 100644 --- a/frontend/src/routes/shared/+page.svelte +++ b/frontend/src/routes/shared/+page.svelte @@ -27,7 +27,7 @@ import UserVignette from '$lib/components/UserVignette.svelte'; import { t } from '$lib/i18n/index.svelte'; import { ui } from '$lib/stores/ui.svelte'; - import { iconNameFromClass } from '$lib/utils/display'; + import { formatDate, iconNameFromClass } from '$lib/utils/display'; type GroupBy = 'items' | 'sharedWith'; @@ -158,9 +158,9 @@ } function expiryLabel(iso: string | null | undefined): string { if (!iso) return t('share.noExpiry', 'No expiry'); - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return ''; - return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + // Same semantics as before (`''` for unparseable dates), now via the + // shared util so it reuses the cached Intl.DateTimeFormat. + return formatDate(iso); } function isoToDate(iso: string | null | undefined): string { return iso ? String(iso).slice(0, 10) : ''; diff --git a/migrations/20260918000000_listing_lower_name_indexes.sql b/migrations/20260918000000_listing_lower_name_indexes.sql new file mode 100644 index 00000000..58f0db53 --- /dev/null +++ b/migrations/20260918000000_listing_lower_name_indexes.sql @@ -0,0 +1,24 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- Web-UI listing keyset — expression indexes for the default "name" sort +-- ════════════════════════════════════════════════════════════════════════════ +-- `list_resources_paged` (SPA files view) sorts case-insensitively on +-- `LOWER(name)` with an id tie-breaker. The old query applied its keyset +-- cursor OUTSIDE the folders/files UNION-ALL on computed columns, so every +-- page rescanned and top-N-sorted the whole folder (28 ms/page on a +-- 20k-entry folder). The query now pushes the cursor into each branch as a +-- sargable row-value comparison `(LOWER(name), id) > ($str, $id)` — these +-- two partial expression indexes let each branch answer that with one +-- bounded, pre-ordered index-range read (1.3 ms/page, 19.5x; +-- benches/LISTING-KEYSET.md). +-- +-- Sibling of `idx_files_folder_name (folder_id, name)` (migration +-- 20260917000000), which serves the byte-wise DAV ordering; the SPA orders +-- by LOWER(name), which that index cannot provide. + +CREATE INDEX IF NOT EXISTS idx_files_folder_lname + ON storage.files (folder_id, LOWER(name), id) + WHERE NOT is_trashed; + +CREATE INDEX IF NOT EXISTS idx_folders_parent_lname + ON storage.folders (parent_id, LOWER(name), id) + WHERE NOT is_trashed; diff --git a/src/application/adapters/carddav_adapter.rs b/src/application/adapters/carddav_adapter.rs index 2f6b66e4..1c5a13f6 100644 --- a/src/application/adapters/carddav_adapter.rs +++ b/src/application/adapters/carddav_adapter.rs @@ -651,7 +651,6 @@ impl CardDavAdapter { pub fn generate_contacts_response( writer: W, contacts: &[ContactDto], - vcards: &[(String, String)], // (uid, vcard_data) report: &CardDavReportType, base_href: &str, ) -> Result<()> { @@ -672,17 +671,9 @@ impl CardDavAdapter { for contact in contacts { let href = format!("{}{}.vcf", base_href, contact.uid); - let vcard = vcards - .iter() - .find(|(uid, _)| *uid == contact.uid) - .map(|(_, data)| data.as_str()) - .unwrap_or(""); + // `write_contact_response` generates the vCard on demand when (and + // only when) address-data is actually requested. Self::write_contact_response(&mut xml_writer, contact, &props, &href)?; - // If address-data is requested, include vcard - if props.iter().any(|p| p.name == "address-data") || props.is_empty() { - // Already handled in write_contact_response - } - let _ = vcard; // suppress warning - used via contact_to_vcard fallback } xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; @@ -868,20 +859,25 @@ impl CardDavAdapter { /// Convert a ContactDto to vCard 3.0 format pub fn contact_to_vcard(contact: &ContactDto) -> String { + // `write!` into a String is infallible; `let _ =` discards the Ok(()). + // Formatting straight into the buffer avoids one temporary String per + // vCard line compared to `push_str(&format!(…))`. + use std::fmt::Write as _; + let mut vcard = String::from("BEGIN:VCARD\r\nVERSION:3.0\r\n"); - vcard.push_str(&format!("UID:{}\r\n", contact.uid)); + let _ = write!(vcard, "UID:{}\r\n", contact.uid); if let (Some(last), Some(first)) = (&contact.last_name, &contact.first_name) { - vcard.push_str(&format!("N:{};{};;;\r\n", last, first)); + let _ = write!(vcard, "N:{};{};;;\r\n", last, first); } else if let Some(last) = &contact.last_name { - vcard.push_str(&format!("N:{};;;;\r\n", last)); + let _ = write!(vcard, "N:{};;;;\r\n", last); } else if let Some(first) = &contact.first_name { - vcard.push_str(&format!("N:;{};;;\r\n", first)); + let _ = write!(vcard, "N:;{};;;\r\n", first); } if let Some(fn_name) = &contact.full_name { - vcard.push_str(&format!("FN:{}\r\n", fn_name)); + let _ = write!(vcard, "FN:{}\r\n", fn_name); } else { // FN is mandatory in vCard 3.0 let fn_name = format!( @@ -892,68 +888,68 @@ pub fn contact_to_vcard(contact: &ContactDto) -> String { .trim() .to_string(); if !fn_name.is_empty() { - vcard.push_str(&format!("FN:{}\r\n", fn_name)); + let _ = write!(vcard, "FN:{}\r\n", fn_name); } else { vcard.push_str("FN:Unknown\r\n"); } } if let Some(nickname) = &contact.nickname { - vcard.push_str(&format!("NICKNAME:{}\r\n", nickname)); + let _ = write!(vcard, "NICKNAME:{}\r\n", nickname); } for email in &contact.email { - vcard.push_str(&format!( + let _ = write!( + vcard, "EMAIL;TYPE={}:{}\r\n", email.r#type.to_uppercase(), email.email - )); + ); } for phone in &contact.phone { - vcard.push_str(&format!( + let _ = write!( + vcard, "TEL;TYPE={}:{}\r\n", phone.r#type.to_uppercase(), phone.number - )); + ); } for addr in &contact.address { - let adr = format!( - ";;{};{};{};{};{}", + let _ = write!( + vcard, + "ADR;TYPE={}:;;{};{};{};{};{}\r\n", + addr.r#type.to_uppercase(), addr.street.as_deref().unwrap_or(""), addr.city.as_deref().unwrap_or(""), addr.state.as_deref().unwrap_or(""), addr.postal_code.as_deref().unwrap_or(""), addr.country.as_deref().unwrap_or(""), ); - vcard.push_str(&format!( - "ADR;TYPE={}:{}\r\n", - addr.r#type.to_uppercase(), - adr - )); } if let Some(org) = &contact.organization { - vcard.push_str(&format!("ORG:{}\r\n", org)); + let _ = write!(vcard, "ORG:{}\r\n", org); } if let Some(title) = &contact.title { - vcard.push_str(&format!("TITLE:{}\r\n", title)); + let _ = write!(vcard, "TITLE:{}\r\n", title); } if let Some(notes) = &contact.notes { - vcard.push_str(&format!("NOTE:{}\r\n", notes.replace('\n', "\\n"))); + let _ = write!(vcard, "NOTE:{}\r\n", notes.replace('\n', "\\n")); } if let Some(bday) = &contact.birthday { - vcard.push_str(&format!("BDAY:{}\r\n", bday.format("%Y-%m-%d"))); + let _ = write!(vcard, "BDAY:{}\r\n", bday.format("%Y-%m-%d")); } if let Some(photo) = &contact.photo_url { - vcard.push_str(&format!("PHOTO;VALUE=URI:{}\r\n", photo)); + let _ = write!(vcard, "PHOTO;VALUE=URI:{}\r\n", photo); } - vcard.push_str(&format!( + let _ = write!( + vcard, "REV:{}\r\n", contact.updated_at.format("%Y%m%dT%H%M%SZ") - )); + ); vcard.push_str("END:VCARD\r\n"); vcard diff --git a/src/application/adapters/carddav_adapter_test.rs b/src/application/adapters/carddav_adapter_test.rs index ac50a647..1ab4e7c1 100644 --- a/src/application/adapters/carddav_adapter_test.rs +++ b/src/application/adapters/carddav_adapter_test.rs @@ -484,10 +484,6 @@ mod tests { #[test] fn test_generate_contacts_response() { let contacts = vec![sample_contact()]; - let vcards = vec![( - "contact-001".to_string(), - contact_to_vcard(&sample_contact()), - )]; let report = CardDavReportType::AddressbookQuery { props: vec![ QualifiedName { @@ -505,7 +501,6 @@ mod tests { let result = CardDavAdapter::generate_contacts_response( &mut output, &contacts, - &vcards, &report, "/carddav/ab-001", ); @@ -528,14 +523,12 @@ mod tests { #[test] fn test_generate_empty_contacts_response() { let contacts: Vec = vec![]; - let vcards: Vec<(String, String)> = vec![]; let report = CardDavReportType::AddressbookQuery { props: vec![] }; let mut output = Vec::new(); let result = CardDavAdapter::generate_contacts_response( &mut output, &contacts, - &vcards, &report, "/carddav/ab-001", ); diff --git a/src/application/dtos/display_helpers.rs b/src/application/dtos/display_helpers.rs index 4ad0f059..d47f7035 100644 --- a/src/application/dtos/display_helpers.rs +++ b/src/application/dtos/display_helpers.rs @@ -8,6 +8,175 @@ //! then fall back to the file extension when the MIME is generic //! (`application/octet-stream` or empty). +use std::collections::HashMap; +use std::fmt::Write as _; +use std::sync::{Arc, LazyLock}; + +// ─── Arc interning for closed-set display values ──────────────── +// +// `FileDto` / `FolderDto` store their display fields as `Arc` so DTO +// clones are O(1). But `Arc::::from(&str)` always allocates + copies, +// so building the DTO paid 3-4 heap allocations per row even though the +// value space is a small closed set. Interning turns each conversion into +// a HashMap lookup + refcount bump. + +/// Every `&'static str` that [`icon_class_for`], [`icon_special_class_for`] +/// and [`category_for`] can return, plus the folder-DTO constants. +/// +/// Keep this table in sync when adding a value to those functions — a +/// missing entry is not a bug (callers fall back to `Arc::from`, same +/// bytes, one extra allocation), just a lost optimization. +static DISPLAY_INTERN: LazyLock>> = LazyLock::new(|| { + const CLOSED_SET: &[&str] = &[ + // icon_class_for + "fas fa-file-pdf", + "fas fa-file-word", + "fas fa-file-excel", + "fas fa-file-powerpoint", + "fas fa-file-archive", + "fas fa-file-code", + "fas fa-hdd", + "fas fa-file-image", + "fas fa-file-video", + "fas fa-file-audio", + "fas fa-file-alt", + "fas fa-terminal", + "fas fa-file", + // icon_special_class_for + "pdf-icon", + "doc-icon", + "spreadsheet-icon", + "presentation-icon", + "archive-icon", + "code-icon json-icon", + "code-icon js-icon", + "code-icon ts-icon", + "code-icon html-icon", + "code-icon sql-icon", + "code-icon config-icon", + "code-icon php-icon", + "script-icon", + "installer-icon", + "image-icon", + "video-icon", + "audio-icon", + "code-icon py-icon", + "code-icon rust-icon", + "code-icon", + "code-icon go-icon", + "code-icon ruby-icon", + "code-icon md-icon", + "code-icon css-icon", + "code-icon java-icon", + "code-icon c-icon", + "code-icon cs-icon", + "code-icon swift-icon", + "", + // category_for + "PDF", + "Document", + "Spreadsheet", + "Presentation", + "Archive", + "Code", + "Installer", + "Image", + "Video", + "Audio", + "Markdown", + "Text", + // FolderDto constants + "fas fa-folder", + "folder-icon", + "Folder", + ]; + CLOSED_SET.iter().map(|s| (*s, Arc::from(*s))).collect() +}); + +/// Returns a shared `Arc` for a display value from the closed sets +/// above (icon class, icon special class, category). Lookup + refcount +/// bump instead of alloc + copy; unknown values (future additions not +/// yet in the table) fall back to `Arc::from` with identical bytes. +pub fn intern_display(s: &'static str) -> Arc { + DISPLAY_INTERN + .get(s) + .cloned() + .unwrap_or_else(|| Arc::from(s)) +} + +/// The MIME types that dominate real storage rows. Exotic types fall back +/// to a per-row `Arc::from` — correctness is unaffected, only the alloc is. +static MIME_INTERN: LazyLock>> = LazyLock::new(|| { + const COMMON_MIMES: &[&str] = &[ + "", + "directory", + "application/octet-stream", + // Images + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", + "image/heic", + "image/heif", + "image/avif", + "image/bmp", + "image/tiff", + "image/x-icon", + // Video + "video/mp4", + "video/quicktime", + "video/webm", + "video/x-matroska", + "video/x-msvideo", + // Audio + "audio/mpeg", + "audio/mp4", + "audio/ogg", + "audio/flac", + "audio/wav", + "audio/x-wav", + "audio/aac", + // Documents + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + // Text / code + "text/plain", + "text/csv", + "text/html", + "text/css", + "text/markdown", + "text/xml", + "application/json", + "application/javascript", + "application/xml", + "application/x-yaml", + // Archives + "application/zip", + "application/gzip", + "application/x-tar", + "application/x-7z-compressed", + "application/x-rar-compressed", + ]; + COMMON_MIMES.iter().map(|s| (*s, Arc::from(*s))).collect() +}); + +/// Returns a shared `Arc` for the given MIME type. Common types hit +/// the intern table (refcount bump); exotic ones allocate as before. +pub fn intern_mime(mime: &str) -> Arc { + MIME_INTERN + .get(mime) + .cloned() + .unwrap_or_else(|| Arc::from(mime)) +} + // ─── Private: extract lowercase extension from a filename ──────────── fn ext_of(name: &str) -> Option<&str> { let name = name.rsplit('/').next().unwrap_or(name); // strip path @@ -388,11 +557,21 @@ pub fn format_file_size(bytes: u64) -> String { let value = bytes as f64 / K.powi(i as i32); - // Two decimal places, then strip trailing zeros (matches JS parseFloat behaviour) - let formatted = format!("{:.2}", value); - let formatted = formatted.trim_end_matches('0').trim_end_matches('.'); - - format!("{} {}", formatted, SIZES[i]) + // Single buffer: write the 2-decimal value, strip trailing zeros in + // place (matches JS parseFloat behaviour), then append the unit. + // 16 chars covers the worst case ("16777216 TB" for u64::MAX, + // "1023.99 Bytes" for the longest unit), so no realloc occurs. + let mut out = String::with_capacity(16); + let _ = write!(out, "{:.2}", value); + while out.ends_with('0') { + out.pop(); + } + if out.ends_with('.') { + out.pop(); + } + out.push(' '); + out.push_str(SIZES[i]); + out } #[cfg(test)] @@ -506,6 +685,50 @@ mod tests { ); } + /// Every value the closed-set display functions can return must hit + /// the intern table (same bytes, shared allocation) — a miss is only + /// a lost optimization, but this test keeps the table in sync. + #[test] + fn test_intern_display_covers_closed_sets_and_shares_storage() { + for s in [ + "fas fa-file-pdf", + "fas fa-file", + "fas fa-terminal", + "fas fa-folder", + "code-icon rust-icon", + "folder-icon", + "", + "PDF", + "Folder", + "Document", + "Markdown", + ] { + let a = intern_display(s); + let b = intern_display(s); + assert_eq!(&*a, s, "interned bytes must be identical"); + assert!( + Arc::ptr_eq(&a, &b), + "closed-set value {s:?} must come from the intern table" + ); + } + } + + #[test] + fn test_intern_mime_common_hits_table_exotic_falls_back() { + let a = intern_mime("image/jpeg"); + let b = intern_mime("image/jpeg"); + assert_eq!(&*a, "image/jpeg"); + assert!(Arc::ptr_eq(&a, &b), "common MIME must be interned"); + + let exotic = intern_mime("chemical/x-pdb"); + assert_eq!(&*exotic, "chemical/x-pdb"); + let exotic2 = intern_mime("chemical/x-pdb"); + assert!( + !Arc::ptr_eq(&exotic, &exotic2), + "exotic MIME falls back to a fresh Arc" + ); + } + #[test] fn test_ext_of() { assert_eq!(ext_of("file.txt"), Some("txt")); diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index 8097d887..19b9e053 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -6,7 +6,8 @@ use utoipa::ToSchema; use uuid::Uuid; use super::display_helpers::{ - category_for, format_file_size, icon_class_for, icon_special_class_for, + category_for, format_file_size, icon_class_for, icon_special_class_for, intern_display, + intern_mime, }; /// DTO for file responses @@ -101,11 +102,15 @@ impl From for FileDto { // for id, name, path, folder_id (previously 4× .to_string()). let parts = file.into_parts(); - let icon_class = Arc::from(icon_class_for(&parts.name, &parts.mime_type)); - let icon_special_class = Arc::from(icon_special_class_for(&parts.name, &parts.mime_type)); - let category = Arc::from(category_for(&parts.name, &parts.mime_type)); + // Display fields come from closed static tables and MIME values + // repeat massively across rows — intern instead of allocating a + // fresh Arc per row (`Arc::from(&str)` always allocs+copies). + let icon_class = intern_display(icon_class_for(&parts.name, &parts.mime_type)); + let icon_special_class = + intern_display(icon_special_class_for(&parts.name, &parts.mime_type)); + let category = intern_display(category_for(&parts.name, &parts.mime_type)); let size_formatted = format_file_size(parts.size); - let mime_type = Arc::from(parts.mime_type.as_str()); + let mime_type = intern_mime(&parts.mime_type); Self { id: parts.id, @@ -169,13 +174,13 @@ impl FileDto { name: "stub-file".to_string(), path: "/stub/path".to_string(), size: 0, - mime_type: Arc::from("application/octet-stream"), + mime_type: intern_mime("application/octet-stream"), folder_id: None, created_at: 0, modified_at: 0, - icon_class: Arc::from("fas fa-file"), - icon_special_class: Arc::from(""), - category: Arc::from("Document"), + icon_class: intern_display("fas fa-file"), + icon_special_class: intern_display(""), + category: intern_display("Document"), size_formatted: "0 Bytes".to_string(), content_hash: String::new(), etag: String::new(), diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 8221bba7..50451bc3 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor}; +use crate::application::dtos::display_helpers::intern_display; use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto}; use crate::domain::entities::folder::Folder; use crate::domain::services::authorization::ResourceKind; @@ -99,24 +100,33 @@ pub struct FolderDto { impl From for FolderDto { fn from(folder: Folder) -> Self { - let is_root = folder.parent_id().is_none(); - let etag = folder.etag().to_string(); + // Consume the entity by moving all fields — zero heap allocations + // for id, name, path, parent_id (previously 3-4× .to_string()). + let parts = folder.into_parts(); + + let is_root = parts.parent_id.is_none(); + // Single-allocation ETag straight from the owned parts. The old + // shape (`folder.etag().to_string()`) built the String and then + // cloned it — a pure double-alloc. + let etag = Folder::compute_etag(&parts.id, parts.tree_modified_at); Self { - id: folder.id().to_string(), - name: folder.name().to_string(), - path: folder.path_string().to_string(), - parent_id: folder.parent_id().map(String::from), - drive_id: folder.drive_id(), - created_at: folder.created_at(), - modified_at: folder.modified_at(), + id: parts.id, + name: parts.name, + path: parts.path_string, + parent_id: parts.parent_id, + drive_id: parts.drive_id, + created_at: parts.created_at, + modified_at: parts.modified_at, is_root, - icon_class: Arc::from("fas fa-folder"), - icon_special_class: Arc::from("folder-icon"), - category: Arc::from("Folder"), + // Constant display fields: refcount bump on interned statics + // instead of 3 fresh Arc allocations per row. + icon_class: intern_display("fas fa-folder"), + icon_special_class: intern_display("folder-icon"), + category: intern_display("Folder"), etag, - created_by: folder.created_by(), - updated_by: folder.updated_by(), + created_by: parts.created_by, + updated_by: parts.updated_by, } } } @@ -163,9 +173,9 @@ impl FolderDto { created_at: 0, modified_at: 0, is_root: true, - icon_class: Arc::from("fas fa-folder"), - icon_special_class: Arc::from("folder-icon"), - category: Arc::from("Folder"), + icon_class: intern_display("fas fa-folder"), + icon_special_class: intern_display("folder-icon"), + category: intern_display("Folder"), etag: String::new(), created_by: None, updated_by: None, diff --git a/src/application/ports/folder_ports.rs b/src/application/ports/folder_ports.rs index ac043caa..88acba32 100644 --- a/src/application/ports/folder_ports.rs +++ b/src/application/ports/folder_ports.rs @@ -77,6 +77,31 @@ pub trait FolderUseCase: Send + Sync + 'static { pagination: &crate::application::dtos::pagination::PaginationRequestDto, ) -> Result, DomainError>; + /// Keyset-paged sub-folder listing in name order, scoped to a caller — + /// `name > after_name LIMIT limit`, `has_next = len() == limit`. + /// + /// Used by streaming WebDAV/NC PROPFIND: O(page) per page off the + /// `idx_folders_unique_name` index instead of the quadratic + /// `COUNT(*) OVER() … LIMIT/OFFSET` walk (benches/FOLDER-KEYSET.md). + /// + /// The default implementation falls back to `list_folders_with_perms` + /// + in-memory slice so stubs and mocks compile without changes. + async fn list_folders_batch_with_perms( + &self, + parent_id: Option<&str>, + caller_id: Uuid, + after_name: Option<&str>, + limit: usize, + ) -> Result, DomainError> { + let mut all = self.list_folders_with_perms(parent_id, caller_id).await?; + all.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(all + .into_iter() + .filter(|f| after_name.is_none_or(|a| f.name.as_str() > a)) + .take(limit) + .collect()) + } + /// Renames a folder (ownership verified against caller_id) async fn rename_folder_with_perms( &self, diff --git a/src/application/services/app_password_service.rs b/src/application/services/app_password_service.rs index e603f65a..08dddda6 100644 --- a/src/application/services/app_password_service.rs +++ b/src/application/services/app_password_service.rs @@ -304,12 +304,45 @@ impl AppPasswordService { let cache_key: [u8; 32] = blake3::hash(format!("{}:{}", username, password).as_bytes()).into(); - // ── 2. Cache hit → return immediately ──────────────────────── - if let Some(cached) = self.auth_cache.get(&cache_key).await { - return Ok((cached.user_id, cached.username, cached.email, cached.role)); - } + // ── 2. Single-flight cache lookup ───────────────────────────── + // Concurrent misses on the same credential coalesce into ONE + // full verification: DAV sync clients hold 4-8 parallel + // connections, so an expiring cache entry used to fan out into + // K simultaneous Argon2id runs (~100-300 ms CPU + 64 MiB RAM + // apiece) every TTL — a recurring p99 spike on every DAV + // surface (8 -> 1 verifications, benches/AUTH-HERD.md). + // `try_get_with` caches only `Ok` results, so failed + // verifications are still never cached, preserving the full + // Argon2id cost as a brute-force deterrent. + let result = self + .auth_cache + .try_get_with( + cache_key, + self.verify_basic_auth_uncached(username, password), + ) + .await + .map_err( + |e: std::sync::Arc| match std::sync::Arc::try_unwrap(e) { + Ok(err) => err, + // Another coalesced waiter still holds the Arc — rebuild + // an equivalent error (the source chain isn't clonable). + Err(shared) => { + DomainError::new(shared.kind, shared.entity_type, shared.message.clone()) + } + }, + )?; + Ok((result.user_id, result.username, result.email, result.role)) + } - // ── 3. Cache miss → full verification ──────────────────────── + /// The uncached Basic Auth slow path: user lookup, prefix-scoped + /// candidate fetch, Argon2id verification. Runs at most once per + /// credential per TTL — `verify_basic_auth` coalesces concurrent + /// callers onto a single in-flight instance of this future. + async fn verify_basic_auth_uncached( + &self, + username: &str, + password: &str, + ) -> Result { let user = self .user_repo .get_user_by_username(username) @@ -363,15 +396,14 @@ impl AppPasswordService { { let _ = self.repo.touch_last_used(ap.id).await; - let result = CachedBasicAuthResult { + // Caching happens in `verify_basic_auth`: `try_get_with` + // stores this value under the blake3 key on return. + return Ok(CachedBasicAuthResult { user_id: user.id(), username: user.username().unwrap_or("").to_string(), email: user.email().to_string(), role: user.role().to_string(), - }; - - self.auth_cache.insert(cache_key, result.clone()).await; - return Ok((result.user_id, result.username, result.email, result.role)); + }); } } diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 69dfaf4c..c677ec0d 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -429,6 +429,62 @@ impl FolderUseCase for FolderService { Ok(response) } + /// Keyset-paged sub-folder listing (name order), caller-scoped. + /// + /// AuthZ mirrors `list_folders_paginated_with_perms`: one + /// `authz.require(Read)` on the parent per batch; root scope goes + /// through the caller's drive-membership listing. + async fn list_folders_batch_with_perms( + &self, + parent_id: Option<&str>, + caller_id: Uuid, + after_name: Option<&str>, + limit: usize, + ) -> Result, DomainError> { + match parent_id { + Some(pid) => { + self.authz + .require( + Subject::User(caller_id), + Permission::Read, + Self::folder_resource(pid)?, + ) + .await?; + let folders = self + .folder_storage + .list_folders_batch(parent_id, after_name, limit) + .await + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to batch-list folders in parent {pid}: {e}"), + ) + })?; + Ok(folders.into_iter().map(FolderDto::from).collect()) + } + None => { + // Root scope: one row per readable drive — a handful. + let mut all = self + .folder_storage + .list_root_folders_for_caller(caller_id) + .await + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to batch-list root folders for '{caller_id}': {e}"), + ) + })?; + all.sort_by(|a, b| a.name().cmp(b.name())); + Ok(all + .into_iter() + .filter(|f| after_name.is_none_or(|a| f.name() > a)) + .take(limit) + .map(FolderDto::from) + .collect()) + } + } + } + /// Lists folders with pagination, scoped to a specific owner. async fn list_folders_paginated_with_perms( &self, diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index ff1ccd11..dc13b160 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -67,9 +67,80 @@ pub struct SearchService { /// Lock-free concurrent cache with automatic TTL and LRU eviction (moka). /// Values are `Arc` so cache insert/hit is a single /// atomic ref-count increment (~1 ns) instead of cloning thousands of Strings. + /// + /// **Byte-bounded**, not entry-bounded: entries are weighed by + /// [`search_results_entry_weight`] and `max_capacity` is a byte budget. + /// Keys span user × query × offset × limit, and each page holds up to 500 + /// enriched rows (~500–900 B of owned Strings each) — an entry-count bound + /// let hundreds of MB of result pages accumulate invisibly. search_cache: moka::future::Cache>, } +// ─── Search-results cache (byte-bounded) ───────────────────────────────── + +/// Approximate heap bytes retained by one cached search page. +/// +/// With a `weigher` installed, moka's `max_capacity` is the sum of entry +/// *weights*, so this converts the cache bound from "number of entries" to +/// real bytes: the length of every owned `String` in each file/folder row, +/// plus a fixed per-row and per-entry overhead for struct fields, the 24-B +/// `String` headers, `Vec` slots and allocator slop. Same pattern as the +/// file-content cache and the dedup manifest cache. +/// +/// `pub` so `examples/bench_search_cache_mem.rs` can recompute retained +/// bytes with the exact production formula. +pub fn search_results_entry_weight(_key: &u64, value: &Arc) -> u32 { + /// Fixed per-row overhead: struct scalars + one 24-B header per `String` + /// field (12 on a file row, 4 on a folder row) + `Vec` slot + allocator + /// slop. Deliberately a round upper-ish estimate — under-weighing is the + /// failure mode that re-opens the memory hole. + const ROW_OVERHEAD: usize = 200; + /// Fixed per-entry overhead: `Arc` + `SearchResultsDto` scalars + `Vec` + /// headers + moka's own bookkeeping per entry. + const ENTRY_OVERHEAD: usize = 256; + + fn opt_len(s: &Option) -> usize { + s.as_deref().map_or(0, str::len) + } + + let mut bytes = ENTRY_OVERHEAD + value.sort_by.len(); + for f in &value.files { + bytes += ROW_OVERHEAD + + f.id.len() + + f.name.len() + + f.path.len() + + f.mime_type.len() + + opt_len(&f.folder_id) + + f.size_formatted.len() + + f.icon_class.len() + + f.icon_special_class.len() + + f.category.len() + + f.blob_hash.len() + + opt_len(&f.snippet) + + opt_len(&f.match_source); + } + for d in &value.folders { + bytes += ROW_OVERHEAD + d.id.len() + d.name.len() + d.path.len() + opt_len(&d.parent_id); + } + bytes.min(u32::MAX as usize) as u32 +} + +/// Build the search-results cache exactly as production wires it: a byte +/// budget enforced through [`search_results_entry_weight`], plus TTL. +/// +/// Shared with `examples/bench_search_cache_mem.rs` so the benchmark +/// measures the identical cache configuration that serves requests. +pub fn build_search_results_cache( + cache_ttl_secs: u64, + max_bytes: u64, +) -> moka::future::Cache> { + moka::future::Cache::builder() + .max_capacity(max_bytes) + .weigher(search_results_entry_weight) + .time_to_live(Duration::from_secs(cache_ttl_secs)) + .build() +} + // ─── Utility functions (pure, no self — computed on the server) ───────── /// Compute relevance score (0–100) for a name against a query. @@ -160,6 +231,10 @@ fn get_category(name: &str, mime: &str) -> String { impl SearchService { /** * Creates a new instance of the search service. + * + * `max_cache_bytes` is the byte budget for the results cache (weigher- + * bounded, see [`search_results_entry_weight`]) — it replaced the old + * entry-count capacity, which was blind to how big each cached page is. */ pub fn new( file_repository: Arc, @@ -168,12 +243,9 @@ impl SearchService { authorization: Option>, drive_repo: Option>, cache_ttl: u64, - max_cache_size: usize, + max_cache_bytes: u64, ) -> Self { - let search_cache = moka::future::Cache::builder() - .max_capacity(max_cache_size as u64) - .time_to_live(Duration::from_secs(cache_ttl)) - .build(); + let search_cache = build_search_results_cache(cache_ttl, max_cache_bytes); Self { file_repository, @@ -815,6 +887,88 @@ mod tests { } } + #[test] + fn entry_weight_counts_every_owned_string_plus_overheads() { + // Empty page: entry overhead + sort_by ("relevance" = 9 bytes). + let empty = Arc::new(SearchResultsDto::empty()); + let base = search_results_entry_weight(&0, &empty) as usize; + assert_eq!(base, 256 + 9); + + // One file row: base + row overhead + its owned string bytes + // (id 7 + name 7 + path 8 + mime 10; the rest are empty/None). + let one_file = Arc::new(SearchResultsDto::new( + vec![dto("abc.txt", 50, 10, 1)], + Vec::new(), + 100, + 0, + Some(1), + 0, + "relevance".to_string(), + )); + let w = search_results_entry_weight(&0, &one_file) as usize; + assert_eq!(w, base + 200 + 7 + 7 + 8 + 10); + + // Folder rows weigh too (id 2 + name 4 + path 5 + parent 6 = 17). + let one_folder = Arc::new(SearchResultsDto::new( + Vec::new(), + vec![SearchFolderResultDto { + id: "f1".to_string(), + name: "docs".to_string(), + path: "/docs".to_string(), + parent_id: Some("parent".to_string()), + drive_id: Uuid::nil(), + created_at: 0, + modified_at: 0, + is_root: false, + relevance_score: 50, + }], + 100, + 0, + Some(1), + 0, + "relevance".to_string(), + )); + let w = search_results_entry_weight(&0, &one_folder) as usize; + assert_eq!(w, base + 200 + 2 + 4 + 5 + 6); + } + + #[tokio::test] + async fn cache_evicts_down_to_the_byte_budget() { + // Budget fits ~2 of these entries; inserting 20 must never let the + // weighted size settle above the budget. + let entry = |i: usize| { + Arc::new(SearchResultsDto::new( + (0..50) + .map(|r| dto(&format!("file_{i}_{r}_{}", "x".repeat(100)), 50, 1, 1)) + .collect(), + Vec::new(), + 50, + 0, + Some(50), + 0, + "relevance".to_string(), + )) + }; + let per_entry = search_results_entry_weight(&0, &entry(0)) as u64; + let budget = per_entry * 2 + per_entry / 2; + + let cache = build_search_results_cache(300, budget); + for i in 0..20u64 { + cache.insert(i, entry(i as usize)).await; + } + cache.run_pending_tasks().await; + + let retained: u64 = cache + .iter() + .map(|(k, v)| search_results_entry_weight(&k, &v) as u64) + .sum(); + assert!( + retained <= budget, + "retained {retained} B exceeds budget {budget} B" + ); + assert!(cache.entry_count() <= 2); + } + #[test] fn merged_files_resort_by_relevance_and_by_column() { let mut files = vec![ diff --git a/src/common/config.rs b/src/common/config.rs index 6e8ce236..71df0b23 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -1250,6 +1250,33 @@ impl Default for ContentSearchConfig { } } +/// Search-results cache configuration — the per-user results-page cache +/// inside `SearchService`, not the Tantivy content index above. +/// +/// The cache is **byte-bounded**: each entry is weighed by the approximate +/// heap size of its result page (see `search_results_entry_weight`) and moka +/// evicts once the summed weight exceeds `max_bytes` — the same byte-budget +/// pattern the file-content cache and the dedup manifest cache use. This +/// replaced an entry-count capacity: with cache keys spanning +/// user × query × offset × limit and up to 500 enriched rows per page, an +/// entry count said nothing about resident memory (1000 entries could pin +/// ~300 MB for the TTL). No entry-count knob is kept — bytes are the only +/// dimension that matters here. +#[derive(Debug, Clone)] +pub struct SearchCacheConfig { + /// Byte budget for cached search-result pages. Default: 32 MiB. + /// Env: `OXICLOUD_SEARCH_CACHE_MAX_BYTES`. + pub max_bytes: u64, +} + +impl Default for SearchCacheConfig { + fn default() -> Self { + Self { + max_bytes: 32 * 1024 * 1024, + } + } +} + /// WASM plugin runtime configuration (M0 walking skeleton). /// /// The runtime is doubly gated: it is only compiled when the `plugins` cargo @@ -1375,6 +1402,8 @@ pub struct AppConfig { pub i18n: I18nConfig, /// Content-search configuration (embedded full-text index) pub content_search: ContentSearchConfig, + /// Search-results cache configuration (byte-bounded moka cache) + pub search_cache: SearchCacheConfig, /// WASM plugin runtime configuration pub plugins: PluginConfig, /// Face-recognition (People) model configuration @@ -1431,6 +1460,7 @@ impl Default for AppConfig { magic_link: MagicLinkConfig::default(), i18n: I18nConfig::default(), content_search: ContentSearchConfig::default(), + search_cache: SearchCacheConfig::default(), plugins: PluginConfig::default(), faces: FacesConfig::default(), } @@ -1934,6 +1964,13 @@ impl AppConfig { config.content_search.max_text_bytes = val; } + // Search-results cache (byte-bounded) + if let Ok(v) = env::var("OXICLOUD_SEARCH_CACHE_MAX_BYTES").map(|v| v.parse::()) + && let Ok(val) = v + { + config.search_cache.max_bytes = val; + } + // WASM plugin runtime if let Ok(v) = env::var("OXICLOUD_ENABLE_PLUGINS").map(|v| v.parse::()) && let Ok(val) = v diff --git a/src/common/di.rs b/src/common/di.rs index 75166a86..fdc1163a 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -656,8 +656,12 @@ impl AppServiceFactory { content_index_port, Some(authz.clone()), Some(drive_repo.clone()), - 300, // Cache TTL in seconds (5 minutes) - 1000, // Maximum cache entries + 300, // Cache TTL in seconds (5 minutes) + // Byte budget for cached result pages (weigher-bounded, 32 MiB + // default; env OXICLOUD_SEARCH_CACHE_MAX_BYTES). Replaces the old + // entry-count capacity, which let 500-row pages keyed by + // user×query×offset×limit pin hundreds of MB for the TTL. + self.config.search_cache.max_bytes, ))); tracing::info!("Application services initialized"); diff --git a/src/domain/entities/file.rs b/src/domain/entities/file.rs index e4a65884..769528ee 100644 --- a/src/domain/entities/file.rs +++ b/src/domain/entities/file.rs @@ -352,8 +352,25 @@ impl File { /// formula here changes it everywhere — that is the property /// we want. pub fn compute_etag(blob_hash: &str, modified_at: u64) -> String { - let prefix: String = blob_hash.chars().take(16).collect(); - format!("{}-{}", prefix, modified_at) + use std::fmt::Write as _; + + // Byte index just past the 16th char (whole string when shorter). + // `blob_hash` is lowercase hex ASCII in practice, so this is + // effectively `min(len, 16)`, but `char_indices` keeps the slice + // char-boundary-safe for exotic fixture values — byte-identical + // to the old `chars().take(16).collect::()` without the + // intermediate allocation. + let end = match blob_hash.char_indices().nth(16) { + Some((i, _)) => i, + None => blob_hash.len(), + }; + + // Single allocation: prefix + '-' + up to 20 digits (u64::MAX). + let mut etag = String::with_capacity(end + 1 + 20); + etag.push_str(&blob_hash[..end]); + etag.push('-'); + let _ = write!(etag, "{modified_at}"); + etag } // Getters diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index 452609ae..7b4d33bb 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -7,6 +7,30 @@ use crate::domain::services::path_service::{ // Re-export entity errors from the centralized module pub use super::entity_errors::{FolderError, FolderResult}; +/// Owned parts of a [`Folder`] entity, produced by [`Folder::into_parts()`]. +/// +/// Consuming a `Folder` into `FolderParts` **moves** every field without +/// cloning, eliminating the 3-4 heap allocations that previously occurred +/// when converting `Folder → FolderDto` via `.to_string()` on each getter. +/// Mirrors [`super::file::FileParts`]. +pub struct FolderParts { + pub id: String, + pub name: String, + pub storage_path: StoragePath, + pub path_string: String, + pub parent_id: Option, + /// Drive that owns this folder. See [`Folder::drive_id`]. + pub drive_id: Uuid, + pub created_at: u64, + pub modified_at: u64, + /// Descendant-rollup timestamp. See [`Folder::tree_modified_at`]. + pub tree_modified_at: u64, + /// §14 provenance: original creator. See [`Folder::created_by`]. + pub created_by: Option, + /// §14 provenance: most recent mutator. See [`Folder::updated_by`]. + pub updated_by: Option, +} + /// Represents a folder entity in the domain #[derive(Debug, Clone, PartialEq, Eq)] pub struct Folder { @@ -219,6 +243,26 @@ impl Folder { }) } + /// Consume the entity and return all fields by ownership. + /// + /// Use this when converting `Folder` into a DTO to avoid cloning + /// every `String` field (saves 3-4 heap allocations per folder). + pub fn into_parts(self) -> FolderParts { + FolderParts { + id: self.id, + name: self.name, + storage_path: self.storage_path, + path_string: self.path_string, + parent_id: self.parent_id, + drive_id: self.drive_id, + created_at: self.created_at, + modified_at: self.modified_at, + tree_modified_at: self.tree_modified_at, + created_by: self.created_by, + updated_by: self.updated_by, + } + } + // Getters pub fn id(&self) -> &str { &self.id @@ -326,8 +370,25 @@ impl Folder { /// changed; the folder's own value stays untouched /// (self-exclusion). pub fn compute_etag(id: &str, tree_modified_at: u64) -> String { - let prefix: String = id.chars().take(16).collect(); - format!("{}-{}", prefix, tree_modified_at) + use std::fmt::Write as _; + + // Byte index just past the 16th char (whole string when shorter). + // `id` is a UUID string (ASCII) in practice, so this is + // effectively `min(len, 16)`, but `char_indices` keeps the slice + // char-boundary-safe for exotic fixture values — byte-identical + // to the old `chars().take(16).collect::()` without the + // intermediate allocation. + let end = match id.char_indices().nth(16) { + Some((i, _)) => i, + None => id.len(), + }; + + // Single allocation: prefix + '-' + up to 20 digits (u64::MAX). + let mut etag = String::with_capacity(end + 1 + 20); + etag.push_str(&id[..end]); + etag.push('-'); + let _ = write!(etag, "{tree_modified_at}"); + etag } /// Creates a new Folder instance from a DTO diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index 011695ab..19cafab4 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -98,6 +98,32 @@ pub trait FolderRepository: Send + Sync + 'static { include_total: bool, ) -> Result<(Vec, Option), DomainError>; + /// Keyset-paged listing of `parent_id`'s direct sub-folders in name + /// order — `name > $after_name ORDER BY name LIMIT $limit`, one bounded + /// index-range read per page off the partial unique index + /// `idx_folders_unique_name`. Streaming PROPFIND drains sub-folders + /// with this instead of `COUNT(*) OVER() … LIMIT/OFFSET`, which + /// window-aggregated and rescanned all N sub-folders on every page + /// (4.5x on a 5k-dir parent, benches/FOLDER-KEYSET.md). `has_next` + /// falls out of `rows.len() == limit` — no total needed. + /// + /// The default implementation falls back to `list_folders` + in-memory + /// slice so stubs and mocks compile without changes. + async fn list_folders_batch( + &self, + parent_id: Option<&str>, + after_name: Option<&str>, + limit: usize, + ) -> Result, DomainError> { + let mut all = self.list_folders(parent_id).await?; + all.sort_by(|a, b| a.name().cmp(b.name())); + Ok(all + .into_iter() + .filter(|f| after_name.is_none_or(|a| f.name() > a)) + .take(limit) + .collect()) + } + /// Renames a folder. `caller_id` is stamped into `updated_by` /// alongside the `updated_at = NOW()` bump (§14 provenance). async fn rename_folder( diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index e631aa7c..7254b146 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -488,13 +488,21 @@ impl FileBlobReadRepository { /// `sort_date` epoch for each file (used as pagination cursor). /// /// Uses the denormalised `media_sort_date` column (synced from - /// `file_metadata.captured_at` by trigger) so no JOIN with - /// `file_metadata` is needed. The partial covering index - /// `idx_files_media_timeline_by_drive` (migration 20260901000001) - /// keys on `(drive_id, media_sort_date DESC)` filtered on non-trashed - /// image/video rows — Postgres does one IndexScan per in-scope - /// drive_id already ordered by capture date, so LIMIT stops the scan - /// early. Same O(LIMIT) shape as the pre-D7 `user_id`-keyed hot path. + /// `file_metadata.captured_at` by trigger). The accessible drive ids + /// are materialised once, then a `CROSS JOIN LATERAL (… ORDER BY + /// media_sort_date DESC LIMIT k)` per drive turns the partial covering + /// index `idx_files_media_timeline_by_drive` (migration 20260901000001, + /// `(drive_id, media_sort_date DESC)` filtered on non-trashed + /// image/video rows) into one BOUNDED index scan per drive; the outer + /// merge sorts `drives × k` rows. The folders / file_metadata joins sit + /// outside the top-N so only the k emitted rows pay them. + /// + /// The previous shape put the joins and the global `ORDER BY … LIMIT` + /// above a `drive_id IN (…)` nested loop — Postgres fed EVERY media row + /// through the join into a top-N heapsort, scanning the timeline index + /// to exhaustion on every page: O(library) per page, 97 ms on a + /// 50k-photo library vs 1.6 ms for this shape (55.7x, + /// benches/PHOTOS-TIMELINE.md). /// /// Scope (`docs/plan/drive.md` §15): drives with /// `policies.include_in_photo_index = true` where the caller has a @@ -537,37 +545,47 @@ impl FileBlobReadRepository { }; let sql = format!( r#" - SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, - fi.size, fi.mime_type, - EXTRACT(EPOCH FROM fi.created_at)::bigint, - EXTRACT(EPOCH FROM fi.updated_at)::bigint, - fi.blob_hash, - - fi.created_by, fi.updated_by, - EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date, + WITH accessible AS MATERIALIZED ( + SELECT d.id + FROM storage.drives d + JOIN storage.role_grants g + ON g.resource_type = 'drive' + AND g.resource_id = d.id + WHERE ( + (g.subject_type = 'user' AND g.subject_id = $1) + OR (g.subject_type = 'group' AND g.subject_id IN + (SELECT storage.caller_group_ids($1))) + ) + AND (g.expires_at IS NULL OR g.expires_at > NOW()) + AND (d.policies->>'include_in_photo_index')::boolean = true + ) + SELECT top.id::text, top.name, top.folder_id::text, fo.path, + top.size, top.mime_type, + EXTRACT(EPOCH FROM top.created_at)::bigint, + EXTRACT(EPOCH FROM top.updated_at)::bigint, + top.blob_hash, + top.created_by, top.updated_by, + EXTRACT(EPOCH FROM top.media_sort_date)::bigint AS sort_date, fm.width, fm.height - FROM storage.files fi - LEFT JOIN storage.folders fo ON fo.id = fi.folder_id - LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id - WHERE fi.drive_id IN ( - SELECT d.id - FROM storage.drives d - JOIN storage.role_grants g - ON g.resource_type = 'drive' - AND g.resource_id = d.id - WHERE ( - (g.subject_type = 'user' AND g.subject_id = $1) - OR (g.subject_type = 'group' AND g.subject_id IN - (SELECT storage.caller_group_ids($1))) - ) - AND (g.expires_at IS NULL OR g.expires_at > NOW()) - AND (d.policies->>'include_in_photo_index')::boolean = true - ) - AND NOT fi.is_trashed - AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%') - {cursor_pred} - ORDER BY fi.media_sort_date DESC - LIMIT $3 + FROM ( + SELECT fi.* + FROM accessible a + CROSS JOIN LATERAL ( + SELECT fi.* + FROM storage.files fi + WHERE fi.drive_id = a.id + AND NOT fi.is_trashed + AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%') + {cursor_pred} + ORDER BY fi.media_sort_date DESC + LIMIT $3 + ) fi + ORDER BY fi.media_sort_date DESC + LIMIT $3 + ) top + LEFT JOIN storage.folders fo ON fo.id = top.folder_id + LEFT JOIN storage.file_metadata fm ON fm.file_id = top.id + ORDER BY top.media_sort_date DESC "#, ); let rows: Vec = sqlx::query_as(&sql) diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 4dc65f9d..63656d97 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -501,6 +501,61 @@ impl FolderRepository for FolderDbRepository { Ok((folders?, total)) } + /// Keyset sub-folder page: `name > $after ORDER BY name LIMIT $limit`, + /// one bounded index-range read off `idx_folders_unique_name` — the + /// cursor predicate is only emitted when a cursor exists (a bound + /// disjunction would block the index condition under generic plans, + /// same rule as `list_files_batch`). Root scope (`parent_id = None`) + /// keeps the trait's in-memory default: roots are one-per-drive, a + /// handful of rows. + async fn list_folders_batch( + &self, + parent_id: Option<&str>, + after_name: Option<&str>, + limit: usize, + ) -> Result, DomainError> { + let Some(pid) = parent_id else { + let mut all = self.list_folders(None).await?; + all.sort_by(|a, b| a.name().cmp(b.name())); + return Ok(all + .into_iter() + .filter(|f| after_name.is_none_or(|a| f.name() > a)) + .take(limit) + .collect()); + }; + + let cursor_pred = if after_name.is_some() { + "AND name > $3" + } else { + "AND $3::text IS NULL" + }; + let sql = format!( + "SELECT id::text, name, path, parent_id::text, drive_id, \ + EXTRACT(EPOCH FROM created_at)::bigint, \ + EXTRACT(EPOCH FROM updated_at)::bigint, \ + EXTRACT(EPOCH FROM tree_modified_at)::bigint, \ + created_by, updated_by \ + FROM storage.folders \ + WHERE parent_id = $1::uuid AND NOT is_trashed \ + {cursor_pred} \ + ORDER BY name \ + LIMIT $2" + ); + let rows: Vec = sqlx::query_as(&sql) + .bind(pid) + .bind(limit as i64) + .bind(after_name) + .fetch_all(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("batch: {e}")))?; + + rows.into_iter() + .map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub) + }) + .collect() + } + /// Paginated companion to `list_root_folders_for_caller` — same /// drive-membership predicate, adds LIMIT/OFFSET and an optional /// window-function COUNT so total pages can be surfaced without a @@ -1391,13 +1446,6 @@ impl FolderDbRepository { WHERE fm.folder_id = $1::uuid AND NOT fm.is_trashed "#; - let cte_inner = match (include_folders, include_files) { - (true, true) => format!("{folder_branch} UNION ALL {file_branch}"), - (true, false) => folder_branch.to_owned(), - (false, true) => file_branch.to_owned(), - (false, false) => unreachable!(), - }; - // ── Cursor binds ───────────────────────────────────────────────────── // $1 = parent_id $2 = cursor_str $3 = cursor_int // $4 = cursor_ts $5 = cursor_id $6 = limit @@ -1406,112 +1454,184 @@ impl FolderDbRepository { let cursor_ts = cursor.and_then(|c| c.sort_ts); let cursor_id = cursor.map(|c| c.resource_id); - // ── Sort-specific WHERE + ORDER BY ─────────────────────────────────── - // Each arm produces two variants based on `reverse`. - // For "name": folder_first stays ASC in both directions (folders always - // precede files); only the alpha order within each group flips. - let (where_clause, order_clause) = match order_by { + // ── Per-branch cursor pushdown ─────────────────────────────────────── + // The cursor is applied INSIDE each UNION-ALL branch as a sargable + // row-value comparison on base columns — not on the CTE's computed + // columns — and every branch pre-sorts and pre-limits, so Postgres + // reads O(limit) rows per branch instead of rescanning and + // top-N-sorting the entire folder on every page (19.5x on a + // 20k-entry folder, benches/LISTING-KEYSET.md). The "name" sort is + // served by the expression indexes idx_files_folder_lname / + // idx_folders_parent_lname (migration 20260918000000). + // + // Sort-key columns that are CONSTANT within a branch (folder_first, + // the folder branch's type_order = 0 and size = -1) are folded in + // Rust: depending on which group the cursor points into, the branch + // predicate shortens to a row-value over the remaining keys, the + // branch keeps all its rows, or the branch drops out entirely. + enum BranchCursor { + /// The cursor has moved past every row this branch can produce. + Drop, + /// Every row in this branch sorts after the cursor. + All, + /// Row-value comparison over the branch's non-constant sort keys. + Pred(String), + } + use BranchCursor::{All, Drop, Pred}; + + let has_cursor = cursor.is_some(); + // (folder-branch cursor, file-branch cursor, per-branch ORDER BY on + // the branch's output aliases, outer merge ORDER BY) + let (folder_cur, file_cur, branch_order, outer_order) = match order_by { "type" => { - if reverse { - ( - r#"WHERE ($3::bigint IS NULL) - OR (type_order < $3) - OR (type_order = $3 AND sort_str < $2) - OR (type_order = $3 AND sort_str = $2 AND id < $5::uuid)"#, - "ORDER BY type_order DESC, sort_str DESC, id DESC", - ) + let (op, ord) = if reverse { + ("<", "ORDER BY type_order DESC, sort_str DESC, id DESC") } else { - ( - r#"WHERE ($3::bigint IS NULL) - OR (type_order > $3) - OR (type_order = $3 AND sort_str > $2) - OR (type_order = $3 AND sort_str = $2 AND id > $5::uuid)"#, - "ORDER BY type_order ASC, sort_str ASC, id ASC", - ) - } + (">", "ORDER BY type_order ASC, sort_str ASC, id ASC") + }; + let folder_cur = match cursor_int { + None => All, + // Folder rows have type_order = 0; a cursor sitting on a + // file (type_order > 0) either exhausts the folder group + // (ASC) or precedes all of it (DESC). + Some(c_to) if c_to > 0 => { + if reverse { + All + } else { + Drop + } + } + Some(_) => Pred(format!("(LOWER(f.name), f.id) {op} ($2, $5::uuid)")), + }; + let file_cur = if has_cursor { + Pred(format!( + "(fm.category_order::bigint, LOWER(fm.name), fm.id) {op} ($3, $2, $5::uuid)" + )) + } else { + All + }; + (folder_cur, file_cur, ord, ord) } "modified_at" => { - if reverse { - ( - r#"WHERE ($4::timestamptz IS NULL) - OR (modified_at > $4) - OR (modified_at = $4 AND id > $5::uuid)"#, - "ORDER BY modified_at ASC, id ASC", - ) + let (op, ord) = if reverse { + (">", "ORDER BY modified_at ASC, id ASC") } else { - ( - r#"WHERE ($4::timestamptz IS NULL) - OR (modified_at < $4) - OR (modified_at = $4 AND id < $5::uuid)"#, - "ORDER BY modified_at DESC, id DESC", - ) - } + ("<", "ORDER BY modified_at DESC, id DESC") + }; + let mk = |col: &str| { + if has_cursor { + Pred(format!("({col}.updated_at, {col}.id) {op} ($4, $5::uuid)")) + } else { + All + } + }; + (mk("f"), mk("fm"), ord, ord) } "created_at" => { - if reverse { - ( - r#"WHERE ($4::timestamptz IS NULL) - OR (created_at > $4) - OR (created_at = $4 AND id > $5::uuid)"#, - "ORDER BY created_at ASC, id ASC", - ) + let (op, ord) = if reverse { + (">", "ORDER BY created_at ASC, id ASC") } else { - ( - r#"WHERE ($4::timestamptz IS NULL) - OR (created_at < $4) - OR (created_at = $4 AND id < $5::uuid)"#, - "ORDER BY created_at DESC, id DESC", - ) - } + ("<", "ORDER BY created_at DESC, id DESC") + }; + let mk = |col: &str| { + if has_cursor { + Pred(format!("({col}.created_at, {col}.id) {op} ($4, $5::uuid)")) + } else { + All + } + }; + (mk("f"), mk("fm"), ord, ord) } "size" => { - if reverse { - ( - r#"WHERE ($3::bigint IS NULL) - OR (size < $3) - OR (size = $3 AND id < $5::uuid)"#, - "ORDER BY size DESC, id DESC", - ) + let (op, ord) = if reverse { + ("<", "ORDER BY size DESC, id DESC") } else { - ( - r#"WHERE ($3::bigint IS NULL) - OR (size > $3) - OR (size = $3 AND id > $5::uuid)"#, - "ORDER BY size ASC, id ASC", - ) - } + (">", "ORDER BY size ASC, id ASC") + }; + let folder_cur = match cursor_int { + None => All, + // Folder rows have size = -1; a cursor sitting on a file + // (size >= 0) exhausts the folder group (ASC) or precedes + // all of it (DESC). + Some(c_sz) if c_sz > -1 => { + if reverse { + All + } else { + Drop + } + } + Some(_) => Pred(format!("f.id {op} $5::uuid")), + }; + let file_cur = if has_cursor { + Pred(format!("(fm.size::bigint, fm.id) {op} ($3, $5::uuid)")) + } else { + All + }; + (folder_cur, file_cur, ord, ord) } _ => { - // "name" (default): folder_first stays ASC so folders always precede - // files; only the alpha order within each group flips when reversed. - if reverse { - ( - r#"WHERE ($3::bigint IS NULL) - OR (folder_first::bigint > $3) - OR (folder_first::bigint = $3 AND sort_str < $2) - OR (folder_first::bigint = $3 AND sort_str = $2 AND id < $5::uuid)"#, - "ORDER BY folder_first ASC, sort_str DESC, id DESC", - ) + // "name" (default): folder_first stays ASC so folders always + // precede files; only the alpha order within each group flips + // when reversed. cursor_int carries folder_first (0|1). + let op = if reverse { "<" } else { ">" }; + let branch_ord = if reverse { + "ORDER BY sort_str DESC, id DESC" } else { - ( - r#"WHERE ($3::bigint IS NULL) - OR (folder_first::bigint > $3) - OR (folder_first::bigint = $3 AND sort_str > $2) - OR (folder_first::bigint = $3 AND sort_str = $2 AND id > $5::uuid)"#, - "ORDER BY folder_first ASC, sort_str ASC, id ASC", - ) - } + "ORDER BY sort_str ASC, id ASC" + }; + let outer_ord = if reverse { + "ORDER BY folder_first ASC, sort_str DESC, id DESC" + } else { + "ORDER BY folder_first ASC, sort_str ASC, id ASC" + }; + let (folder_cur, file_cur) = match cursor_int { + None => (All, All), + // Cursor inside the folder group: folders continue after + // the row-value cursor; every file still follows. + Some(0) => ( + Pred(format!("(LOWER(f.name), f.id) {op} ($2, $5::uuid)")), + All, + ), + // Cursor inside the file group: the folder group is done. + Some(_) => ( + Drop, + Pred(format!("(LOWER(fm.name), fm.id) {op} ($2, $5::uuid)")), + ), + }; + (folder_cur, file_cur, branch_ord, outer_ord) } }; + let wrap = |branch: &str, cur: &BranchCursor| -> Option { + let extra = match cur { + Drop => return None, + All => String::new(), + Pred(p) => format!(" AND {p}"), + }; + Some(format!( + "(SELECT * FROM ({branch}{extra}) b {branch_order} LIMIT $6)" + )) + }; + let mut branches = Vec::with_capacity(2); + if include_folders && let Some(b) = wrap(folder_branch, &folder_cur) { + branches.push(b); + } + if include_files && let Some(b) = wrap(file_branch, &file_cur) { + branches.push(b); + } + // Every requested branch dropped out (e.g. folders-only listing with + // the cursor already past the folder group). + if branches.is_empty() { + return Ok(Vec::new()); + } + let inner = branches.join(" UNION ALL "); + let sql = format!( - "WITH resources AS ({cte_inner}) \ - SELECT resource_type, id, name, folder_id, mime_type, size, \ + "SELECT resource_type, id, name, folder_id, mime_type, size, \ created_at, modified_at, drive_id, blob_hash, \ sort_str, type_order, folder_first \ - FROM resources \ - {where_clause} \ - {order_clause} \ + FROM ({inner}) r \ + {outer_order} \ LIMIT $6" ); diff --git a/src/infrastructure/services/azure_blob_backend.rs b/src/infrastructure/services/azure_blob_backend.rs index 77f3faad..19f7c53c 100644 --- a/src/infrastructure/services/azure_blob_backend.rs +++ b/src/infrastructure/services/azure_blob_backend.rs @@ -130,7 +130,9 @@ impl BlobStorageBackend for AzureBlobBackend { return Ok(size); } - client.put_block_blob(data.to_vec()).await.map_err(|e| { + // `Bytes` converts into `azure_core::Body` by reference count — + // the old `data.to_vec()` copied every chunk once more. + client.put_block_blob(data).await.map_err(|e| { DomainError::internal_error("Azure", format!("Failed to upload blob {hash}: {e}")) })?; @@ -138,6 +140,26 @@ impl BlobStorageBackend for AzureBlobBackend { }) } + /// Dedup settle path: PUT unconditionally. Content-addressed keys make + /// re-PUTs idempotent, so the `get_properties` probe + /// `put_blob_from_bytes` pays is a pure extra round-trip on every NEW + /// chunk (2 RTTs -> 1, benches/S3-PUT.md — same shape as S3). + fn put_blob_from_bytes_unsynced( + &self, + hash: &str, + data: Bytes, + ) -> Pin> + Send + '_>> { + let hash = hash.to_owned(); + Box::pin(async move { + let client = self.blob_client(&hash); + let size = data.len() as u64; + client.put_block_blob(data).await.map_err(|e| { + DomainError::internal_error("Azure", format!("Failed to upload blob {hash}: {e}")) + })?; + Ok(size) + }) + } + fn get_blob_stream( &self, hash: &str, diff --git a/src/infrastructure/services/cached_blob_backend.rs b/src/infrastructure/services/cached_blob_backend.rs index 2457a763..c1c87d4b 100644 --- a/src/infrastructure/services/cached_blob_backend.rs +++ b/src/infrastructure/services/cached_blob_backend.rs @@ -13,12 +13,14 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use bytes::Bytes; +use dashmap::DashMap; use lru::LruCache; use std::num::NonZeroUsize; use tokio::fs; use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; use tokio::sync::Mutex; use tokio_util::io::ReaderStream; +use uuid::Uuid; use crate::application::ports::blob_storage_ports::{ BlobStorageBackend, BlobStream, StorageHealthStatus, @@ -56,6 +58,13 @@ pub struct CachedBlobBackend { max_cache_bytes: u64, index: Arc>>, current_size: Arc, + /// Per-hash single-flight gates for cache misses. K concurrent cold + /// readers of one blob (e.g. a video player's parallel Range probes) + /// used to each download the FULL blob from the remote backend — and + /// race their writes on one shared `.tmp` path. The gate coalesces + /// them onto one fetch; waiters re-check the cache and serve locally + /// (16 fetches -> 1, benches/BLOB-CACHE.md). + inflight: Arc>>>, } impl CachedBlobBackend { @@ -70,6 +79,7 @@ impl CachedBlobBackend { NonZeroUsize::new(1_000_000).unwrap(), ))), current_size: Arc::new(AtomicU64::new(0)), + inflight: Arc::new(DashMap::new()), } } @@ -150,6 +160,7 @@ impl BlobStorageBackend for CachedBlobBackend { max_cache_bytes: self.max_cache_bytes, index: self.index.clone(), current_size: self.current_size.clone(), + inflight: self.inflight.clone(), }; Box::pin(async move { // Write to inner backend @@ -172,6 +183,7 @@ impl BlobStorageBackend for CachedBlobBackend { max_cache_bytes: self.max_cache_bytes, index: self.index.clone(), current_size: self.current_size.clone(), + inflight: self.inflight.clone(), }; Box::pin(async move { let size = inner.put_blob_from_bytes(&hash, data.clone()).await?; @@ -203,6 +215,7 @@ impl BlobStorageBackend for CachedBlobBackend { let cache_dir = self.cache_dir.clone(); let max_cache_bytes = self.max_cache_bytes; let current_size = self.current_size.clone(); + let inflight = self.inflight.clone(); Box::pin(async move { // Check cache presence (and bump LRU recency) under a brief lock, // then release it BEFORE touching the filesystem so concurrent @@ -219,14 +232,17 @@ impl BlobStorageBackend for CachedBlobBackend { } } - // Cache miss — fetch from inner, spool to cache + // Cache miss — fetch from inner (single-flight), spool to cache let self_ref = CachedRef { cache_dir, max_cache_bytes, index: index.clone(), current_size: current_size.clone(), + inflight, }; - let dest = self_ref.fetch_and_cache_static(&hash, &*inner).await?; + let dest = self_ref + .fetch_and_cache_singleflight(&hash, &*inner, &cached) + .await?; let file = fs::File::open(&dest).await.map_err(|e| { DomainError::internal_error("BlobCache", format!("re-open cached: {e}")) })?; @@ -249,6 +265,7 @@ impl BlobStorageBackend for CachedBlobBackend { let cache_dir = self.cache_dir.clone(); let max_cache_bytes = self.max_cache_bytes; let current_size = self.current_size.clone(); + let inflight = self.inflight.clone(); Box::pin(async move { // Check cache presence (and bump LRU recency) under a brief lock, // then release it BEFORE the open()/seek() syscalls so concurrent @@ -271,14 +288,19 @@ impl BlobStorageBackend for CachedBlobBackend { } } - // Cache miss — fetch full blob into cache, then serve range + // Cache miss — fetch full blob into cache (single-flight: a + // player's parallel cold Range probes coalesce onto ONE remote + // download), then serve the range locally. let self_ref = CachedRef { cache_dir, max_cache_bytes, index: index.clone(), current_size: current_size.clone(), + inflight, }; - let dest = self_ref.fetch_and_cache_static(&hash, &*inner).await?; + let dest = self_ref + .fetch_and_cache_singleflight(&hash, &*inner, &cached) + .await?; let mut file = fs::File::open(&dest) .await .map_err(|e| DomainError::internal_error("BlobCache", format!("re-open: {e}")))?; @@ -406,6 +428,7 @@ struct CachedRef { max_cache_bytes: u64, index: Arc>>, current_size: Arc, + inflight: Arc>>>, } impl CachedRef { @@ -414,6 +437,37 @@ impl CachedRef { self.cache_dir.join(prefix).join(format!("{hash}.blob")) } + /// Single-flight wrapper around [`Self::fetch_and_cache_static`]: the + /// first caller for a hash becomes the leader and downloads; concurrent + /// callers queue on the per-hash gate, then re-check the cache and serve + /// the leader's file without touching the remote backend. Errors are not + /// cached — the gate entry is dropped, so the next caller retries. + async fn fetch_and_cache_singleflight( + &self, + hash: &str, + inner: &dyn BlobStorageBackend, + cached: &Path, + ) -> Result { + let gate = self + .inflight + .entry(hash.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + let _guard = gate.lock().await; + + // Re-check under the gate: if we queued behind the leader, the blob + // is on disk now and this turns into a local open. + if self.index.lock().await.get(hash).is_some() && fs::metadata(cached).await.is_ok() { + return Ok(cached.to_path_buf()); + } + + let result = self.fetch_and_cache_static(hash, inner).await; + // Drop the gate whether we succeeded or failed; a late-arriving + // caller after an error creates a fresh gate and retries the fetch. + self.inflight.remove(hash); + result + } + /// Pop LRU entries until the cache is back within its byte budget, /// returning the on-disk paths of the evicted blobs. /// @@ -486,31 +540,51 @@ impl CachedRef { })?; } - let tmp = dest.with_extension("tmp"); - let mut file = fs::File::create(&tmp) - .await - .map_err(|e| DomainError::internal_error("BlobCache", format!("create tmp: {e}")))?; - - use futures::StreamExt; - let mut stream = stream; - let mut total = 0u64; - while let Some(chunk) = stream.next().await { - let bytes = chunk.map_err(|e| { - DomainError::internal_error("BlobCache", format!("stream read: {e}")) + // Unique temp name: even if two fetches for one hash ever race + // (e.g. across processes sharing a cache dir), each writes its own + // inode and the rename is atomic — a torn/interleaved file can + // never land at the final path. + let tmp = dest.with_extension(format!("{}.tmp", Uuid::new_v4())); + let write_result: Result = async { + let mut file = fs::File::create(&tmp).await.map_err(|e| { + DomainError::internal_error("BlobCache", format!("create tmp: {e}")) })?; - total += bytes.len() as u64; - file.write_all(&bytes) - .await - .map_err(|e| DomainError::internal_error("BlobCache", format!("write: {e}")))?; - } - file.flush() - .await - .map_err(|e| DomainError::internal_error("BlobCache", format!("flush: {e}")))?; - drop(file); - fs::rename(&tmp, &dest) - .await - .map_err(|e| DomainError::internal_error("BlobCache", format!("rename: {e}")))?; + use futures::StreamExt; + let mut stream = stream; + let mut total = 0u64; + while let Some(chunk) = stream.next().await { + let bytes = chunk.map_err(|e| { + DomainError::internal_error("BlobCache", format!("stream read: {e}")) + })?; + total += bytes.len() as u64; + file.write_all(&bytes) + .await + .map_err(|e| DomainError::internal_error("BlobCache", format!("write: {e}")))?; + } + file.flush() + .await + .map_err(|e| DomainError::internal_error("BlobCache", format!("flush: {e}")))?; + Ok(total) + } + .await; + let total = match write_result { + Ok(total) => total, + Err(e) => { + // Unique tmp names never get overwritten by a later fetch — + // reap the partial file instead of leaking it. + let _ = fs::remove_file(&tmp).await; + return Err(e); + } + }; + + if let Err(e) = fs::rename(&tmp, &dest).await { + let _ = fs::remove_file(&tmp).await; + return Err(DomainError::internal_error( + "BlobCache", + format!("rename: {e}"), + )); + } let to_evict = { let mut idx = self.index.lock().await; diff --git a/src/infrastructure/services/s3_blob_backend.rs b/src/infrastructure/services/s3_blob_backend.rs index 98ca5968..7a910ea3 100644 --- a/src/infrastructure/services/s3_blob_backend.rs +++ b/src/infrastructure/services/s3_blob_backend.rs @@ -200,6 +200,38 @@ impl BlobStorageBackend for S3BlobBackend { }) } + /// Dedup settle path: PUT unconditionally. Keys are content-addressed + /// (BLAKE3), so a re-PUT writes identical bytes — overwrite-safe + /// idempotency without the HEAD probe `put_blob_from_bytes` pays. The + /// dedup layer already filtered out chunks the database knows about, + /// so the probe was a pure extra round-trip on every NEW chunk of + /// every upload (2 RTTs -> 1, benches/S3-PUT.md). + fn put_blob_from_bytes_unsynced( + &self, + hash: &str, + data: Bytes, + ) -> Pin> + Send + '_>> { + let hash = hash.to_owned(); + Box::pin(async move { + let key = Self::object_key(&hash); + let size = data.len() as u64; + self.client + .put_object() + .bucket(&self.bucket) + .key(&key) + .body(ByteStream::from(data)) + .send() + .await + .map_err(|e| { + DomainError::internal_error( + "S3", + format!("Failed to upload blob {}: {}", hash, e), + ) + })?; + Ok(size) + }) + } + fn get_blob_stream( &self, hash: &str, diff --git a/src/interfaces/api/handlers/carddav_handler.rs b/src/interfaces/api/handlers/carddav_handler.rs index b79be10b..b5fea2a1 100644 --- a/src/interfaces/api/handlers/carddav_handler.rs +++ b/src/interfaces/api/handlers/carddav_handler.rs @@ -385,7 +385,6 @@ async fn handle_propfind( CardDavAdapter::generate_contacts_response( &mut response_body, std::slice::from_ref(&contact), - &[(contact.uid.clone(), contact_to_vcard(&contact))], &report, base_href, ) @@ -449,22 +448,10 @@ async fn handle_report( .map_err(AppError::from)?, }; - // Generate vCards - let vcards: Vec<(String, String)> = contacts - .iter() - .map(|c| (c.uid.clone(), contact_to_vcard(c))) - .collect(); - let base_href = &format!("/carddav/{}/", address_book_id); let mut response_body = Vec::new(); - CardDavAdapter::generate_contacts_response( - &mut response_body, - &contacts, - &vcards, - &report, - base_href, - ) - .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; + CardDavAdapter::generate_contacts_response(&mut response_body, &contacts, &report, base_href) + .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; Ok(Response::builder() .status(StatusCode::MULTI_STATUS) diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 67c2af38..f1937180 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -781,25 +781,25 @@ async fn build_streaming_propfind_response( // ── Children (only if Depth == 1) ──────────────────────── if depth == "1" { - let pagination = crate::application::dtos::pagination::PaginationRequestDto { - page: 0, - page_size: PROPFIND_BATCH_SIZE as usize, - }; let fid_ref = folder_id.as_deref(); - // Stream sub-folders in pages (user-scoped) - let mut page = 0usize; + // Stream sub-folders in pages (user-scoped, keyset cursor — + // O(page) per page off idx_folders_unique_name instead of the + // quadratic COUNT(*) OVER() + LIMIT/OFFSET walk; 4.5x on a + // 5k-dir parent, benches/FOLDER-KEYSET.md). + let mut after_folder: Option = None; loop { - let pag = crate::application::dtos::pagination::PaginationRequestDto { - page, - page_size: pagination.page_size, - }; - let result = folder_service - .list_folders_paginated_with_perms(fid_ref, user_id, &pag) + let batch = folder_service + .list_folders_batch_with_perms( + fid_ref, + user_id, + after_folder.as_deref(), + PROPFIND_BATCH_SIZE as usize, + ) .await .map_err(|e| std::io::Error::other(e.to_string()))?; - if result.items.is_empty() { + if batch.is_empty() { break; } @@ -808,25 +808,25 @@ async fn build_streaming_propfind_response( // 1-4.5 s of pure DB chatter on a 2000-child folder // (measured in benches/DEAD-PROPS.md). let subfolder_deads = - folders_dead_props_map(&dead_props_store, &result.items).await; + folders_dead_props_map(&dead_props_store, &batch).await; - let mut chunk = Vec::with_capacity(result.items.len() * 800); + let mut chunk = Vec::with_capacity(batch.len() * 800); { let mut w = Writer::new(&mut chunk); - for subfolder in result.items.iter() { + for subfolder in batch.iter() { let child_dead = dead_props_for(&subfolder.id, &subfolder_deads); let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name)); WebDavAdapter::write_folder_entry_with_dead_props(&mut w, subfolder, &propfind_request, &href, child_dead, quota) .map_err(|e| std::io::Error::other(e.to_string()))?; } } - let has_more = result.pagination.has_next; + let has_more = (batch.len() as i64) == PROPFIND_BATCH_SIZE; + after_folder = batch.last().map(|f| f.name.clone()); yield Bytes::from(chunk); if !has_more { break; } - page += 1; } // Stream files in pages (user-scoped, keyset cursor — O(page) diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 0bd771f4..c977111f 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -16,7 +16,6 @@ use uuid::Uuid; use crate::application::adapters::webdav_adapter::{ PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property, }; -use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::file_ports::{ @@ -1584,38 +1583,42 @@ fn build_nc_streaming_propfind( after_name = batch.last().map(|f| f.name.clone()); } - // Subfolders in pages — also collections, same trailing-slash rule. - let mut page = 0usize; + // Subfolders in pages — also collections, same trailing-slash + // rule. Keyset cursor: O(page) per page off + // idx_folders_unique_name instead of the quadratic + // COUNT(*) OVER() + LIMIT/OFFSET walk (benches/FOLDER-KEYSET.md). + let mut after_folder: Option = None; loop { - let pag = PaginationRequestDto { - page, - page_size: PROPFIND_BATCH_SIZE as usize, - }; - let result = folder_service - .list_folders_paginated_with_perms(Some(&folder.id), user_id, &pag) + let batch = folder_service + .list_folders_batch_with_perms( + Some(&folder.id), + user_id, + after_folder.as_deref(), + PROPFIND_BATCH_SIZE as usize, + ) .await .map_err(|e| std::io::Error::other(e.to_string()))?; - if result.items.is_empty() { + if batch.is_empty() { break; } let favs = if let Some(fav) = fav_svc { let items: Vec<(&str, &str)> = - result.items.iter().map(|sf| (sf.id.as_str(), "folder")).collect(); + batch.iter().map(|sf| (sf.id.as_str(), "folder")).collect(); fav.batch_check_favorites(user_id, &items).await.unwrap_or_default() } else { HashSet::new() }; - let folder_uuids: Vec = result.items.iter().map(|sf| sf.id.clone()).collect(); + let folder_uuids: Vec = batch.iter().map(|sf| sf.id.clone()).collect(); let (_, sub_id_map) = batch_resolve_ids(file_id_svc, &[], &folder_uuids).await; // Batched — see benches/DEAD-PROPS.md. let sub_deads = - folders_dead_props_map(&state.webdav_dead_props, &result.items).await; + folders_dead_props_map(&state.webdav_dead_props, &batch).await; - let mut chunk = Vec::with_capacity(result.items.len() * 1024); + let mut chunk = Vec::with_capacity(batch.len() * 1024); { let mut xml = Writer::new(&mut chunk); - for sf in result.items.iter() { + for sf in batch.iter() { let dead = dead_props_for(&sf.id, &sub_deads); let child_sub = if subpath.is_empty() { sf.name.clone() @@ -1629,13 +1632,13 @@ fn build_nc_streaming_propfind( .map_err(std::io::Error::other)?; } } - let has_more = result.pagination.has_next; + let has_more = (batch.len() as i64) == PROPFIND_BATCH_SIZE; + after_folder = batch.last().map(|sf| sf.name.clone()); yield Bytes::from(chunk); if !has_more { break; } - page += 1; } } diff --git a/src/interfaces/upload_ingest.rs b/src/interfaces/upload_ingest.rs index c1ecf8a8..7a526684 100644 --- a/src/interfaces/upload_ingest.rs +++ b/src/interfaces/upload_ingest.rs @@ -273,11 +273,16 @@ pub fn multipart_field_stream( pub fn stream_from_files( paths: Vec, ) -> impl Stream> + Send { + // 512 KiB per poll: each ReaderStream poll on a tokio::fs::File is one + // blocking-pool dispatch + one read(2) of the buffer size. The old + // 64 KiB buffer paid 8x the dispatches/syscalls of every other blob + // read path (STREAM_CHUNK_SIZE = 256 KiB) for the single read pass + // over every completed chunked upload (benches/UPLOAD-SPOOL.md). stream::iter(paths.into_iter().map(Ok::<_, std::io::Error>)) .and_then(|path| async move { tokio::fs::File::open(path) .await - .map(|file| ReaderStream::with_capacity(file, 64 * 1024)) + .map(|file| ReaderStream::with_capacity(file, 512 * 1024)) }) .try_flatten() } @@ -319,9 +324,15 @@ pub async fn stream_body_to_path( max_bytes: usize, checksum_alg: Option, ) -> Result { - let mut file = tokio::fs::File::create(path) + // BufWriter coalesces the per-HTTP-frame writes (~16-64 KiB each) into + // 512 KiB write(2)s — a bare tokio File dispatches one blocking-pool op + // per frame (benches/UPLOAD-SPOOL.md). Same capacity as the dedup + // handler's spool loop. On the error paths below the partial file is + // removed, so silently dropping unflushed buffer contents is fine. + let file = tokio::fs::File::create(path) .await .map_err(|e| AppError::internal_error(format!("Failed to open chunk file: {e}")))?; + let mut file = tokio::io::BufWriter::with_capacity(512 * 1024, file); let mut total_bytes: usize = 0; let mut stream = BodyStream::new(body);