From fdf445d2b0ff7a6b44853903aff859187735ed1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 16:12:04 +0000 Subject: [PATCH] =?UTF-8?q?perf:=20round=209=20=E2=80=94=20decorator=20PUT?= =?UTF-8?q?=20reactivation,=20session/search/dedup=20alloc=20purges,=20PRO?= =?UTF-8?q?PFIND=20join!,=20folder-level=20cascade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benchmark-gated round (benches/ROUND9.md): every change carries a BEFORE/AFTER bench with equivalence/safety gates; verdicts below are from the committed harnesses on 4 cores / local PG 16. Backend: - Blob decorators (Retry/Cached) now forward put_blob_from_bytes_unsynced + sync_blobs — the trait default had silently reinstated HEAD-before-PUT per chunk on decorated remote stacks, undoing ROUND3 §8. Full production stack: 500 probes -> 0, 1.9x wall at 10 ms RTT (bench_s3_put §3). - NC PROPFIND per-page enrichment triple (favorites / oc:fileid / dead props) overlapped with tokio::join!: 2.07x local, 2.86x at 5 ms RTT (bench_nc_enrich_join, injected-latency decide-by-bench). - Search enrichment consumes its DTOs and carries the interned Arc display fields end-to-end (SearchFileResultDto type change, OpenAPI shape preserved): enrich_file 2.0x, 11.6 -> 2.2 allocs/row; the NC REPORT conversion stops re-running all three classifiers per row (bench_search_enrich). - NC session Arc end-to-end: SharedNcSession extractor (8 -> 0 allocs), Arc chroot cache (4 -> 0/hit), single shared Arc + lazy span render (11 -> 6/build) (bench_nc_session). - Storage micro-pack: atomic create_new chunk writes (2.1x fresh), stream_chunks over the manifest Arc (4097 -> 0 allocs/read incl. the Range path), manifest single-flight (herd 64 -> 1 loads), hex_lower for chunk Content-MD5 (18 -> 1 allocs) (bench_storage_micro). - OCS capabilities memoized into OnceLock<[Bytes;2]>: 237x, 102 -> 0 allocs/poll, byte-identical (bench_capabilities_static). - Drive::is_empty COUNT(*) sum -> EXISTS: 34.4x on a 100k-file drive (bench_drive_is_empty). - favorites/recents row-map ROUND7 port: path/name/blob_hash moved, -2.75 allocs/row (bench_resource_row_map §2). - Folder rows decode binary UUIDs (ROUND6 §10 port): 1.03-1.07x page fetch, honest verdict incl. one noise-band wash documented (bench_folder_uuid_decode). - Authz: file cascade decision decomposed into memoized folder-level decision + direct-grant lookup (ROUND8 deferred item). Cold shared-album first view 592 -> 418 µs/thumb; warm path unchanged; safety gates incl. new direct-grant sibling isolation, revoke-flush re-verified, full integration authz suite green (bench_thumbnail_cascade_cache). Frontend (vitest gates committed beside the code): - resolveLabel/resolveRecipient O(directory) scan -> id-keyed Map: 13.9x (recipients.bench.test.ts). - ResourceList selection-prune effect skips when nothing is selected (100 -> 0 Set builds per drain) and the photos timeline reads a listener-fed mobile flag instead of matchMedia per recompute (listDerives.bench.test.ts). Verification: cargo fmt + clippy --all-features --all-targets -D warnings clean; 524 unit + 554 integration (--cfg integration_tests) tests pass; frontend npm run check clean with 293 vitest tests green. Deferred with rationale in ROUND9.md: CalDAV authz-before-fetch reorder (maintainer sign-off), per-page batched parent resolution, JWT-claims Arc, batch_operations signature widening. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XDc9VtXvskJ6dnMRraSndn --- Cargo.toml | 51 ++ benches/ROUND9.md | 328 ++++++++++ examples/bench_capabilities_static.rs | 157 +++++ examples/bench_drive_is_empty.rs | 218 +++++++ examples/bench_folder_uuid_decode.rs | 247 ++++++++ examples/bench_micro_allocs.rs | 15 +- examples/bench_nc_enrich_join.rs | 369 ++++++++++++ examples/bench_nc_session.rs | 334 +++++++++++ examples/bench_resource_row_map.rs | 273 +++++++++ examples/bench_s3_put.rs | 224 ++++++- examples/bench_search_cache_mem.rs | 8 +- examples/bench_search_enrich.rs | 566 ++++++++++++++++++ examples/bench_storage_micro.rs | 399 ++++++++++++ examples/bench_thumbnail_cascade_cache.rs | 119 +++- .../api/endpoints/recipients.bench.test.ts | 141 +++++ frontend/src/lib/api/endpoints/recipients.ts | 20 +- .../src/lib/components/ResourceList.svelte | 5 + .../lib/components/listDerives.bench.test.ts | 133 ++++ frontend/src/routes/photos/+page.svelte | 20 +- src/application/dtos/search_dto.rs | 22 +- src/application/services/search_service.rs | 137 ++--- .../repositories/pg/drive_pg_repository.rs | 21 +- .../repositories/pg/folder_db_repository.rs | 79 ++- .../services/cached_blob_backend.rs | 68 ++- .../services/chunked_upload_service.rs | 3 +- src/infrastructure/services/dedup_service.rs | 117 ++-- .../services/local_blob_backend.rs | 35 +- src/infrastructure/services/pg_acl_engine.rs | 147 +++-- .../services/retry_blob_backend.rs | 37 ++ .../api/handlers/favorites_handler.rs | 22 +- src/interfaces/api/handlers/folder_handler.rs | 2 +- src/interfaces/api/handlers/recent_handler.rs | 22 +- .../nextcloud/basic_auth_middleware.rs | 57 +- src/interfaces/nextcloud/ocs_handler.rs | 70 ++- src/interfaces/nextcloud/report_handler.rs | 27 +- src/interfaces/nextcloud/routes.rs | 14 +- src/interfaces/nextcloud/session.rs | 51 +- src/interfaces/nextcloud/trashbin_handler.rs | 2 +- src/interfaces/nextcloud/uploads_handler.rs | 2 +- src/interfaces/nextcloud/webdav_handler.rs | 63 +- 40 files changed, 4279 insertions(+), 346 deletions(-) create mode 100644 benches/ROUND9.md create mode 100644 examples/bench_capabilities_static.rs create mode 100644 examples/bench_drive_is_empty.rs create mode 100644 examples/bench_folder_uuid_decode.rs create mode 100644 examples/bench_nc_enrich_join.rs create mode 100644 examples/bench_nc_session.rs create mode 100644 examples/bench_search_enrich.rs create mode 100644 examples/bench_storage_micro.rs create mode 100644 frontend/src/lib/api/endpoints/recipients.bench.test.ts create mode 100644 frontend/src/lib/components/listDerives.bench.test.ts diff --git a/Cargo.toml b/Cargo.toml index 7a24e759..53746da6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -350,6 +350,57 @@ name = "bench_micro_allocs" path = "examples/bench_micro_allocs.rs" required-features = ["bench"] +# Round-9 battery ───────────────────────────────────────────────────────────── + +# Search enrichment — borrow+clone+reclassify vs consume+carry (file/folder +# enrich + the NC REPORT search→FileDto conversion). No Postgres. +[[example]] +name = "bench_search_enrich" +path = "examples/bench_search_enrich.rs" +required-features = ["bench"] + +# Storage micro-pack — local chunk write create_new, manifest Vec-clone vs +# Arc-index, manifest miss single-flight, Content-MD5 hex. No Postgres. +[[example]] +name = "bench_storage_micro" +path = "examples/bench_storage_micro.rs" +required-features = ["bench"] + +# NC per-request session — extractor deep-clone vs Arc handle, chroot-cache +# value vs Arc, session build double-clone vs shared Arc. No Postgres. +[[example]] +name = "bench_nc_session" +path = "examples/bench_nc_session.rs" +required-features = ["bench"] + +# OCS capabilities poll — rebuild+serialize per request vs OnceLock +# memoization. No Postgres. +[[example]] +name = "bench_capabilities_static" +path = "examples/bench_capabilities_static.rs" +required-features = ["bench"] + +# Drive::is_empty — full-drive COUNT(*) sum vs short-circuit EXISTS +# (needs the dev Postgres up). +[[example]] +name = "bench_drive_is_empty" +path = "examples/bench_drive_is_empty.rs" +required-features = ["bench"] + +# Folder-listing rows — `id::text`/`parent_id::text` casts vs binary UUID +# decode + app-side render, the round-6 file-side port (needs Postgres). +[[example]] +name = "bench_folder_uuid_decode" +path = "examples/bench_folder_uuid_decode.rs" +required-features = ["bench"] + +# NC PROPFIND per-page enrichment triple — serial 3×RTT vs tokio::join!, +# with injected-latency arms at 0/0.25/1/5 ms (needs Postgres). +[[example]] +name = "bench_nc_enrich_join" +path = "examples/bench_nc_enrich_join.rs" +required-features = ["bench"] + # Round-8 battery ───────────────────────────────────────────────────────────── # Shared-album thumbnail authz — folder-grant cascade query per thumbnail vs diff --git a/benches/ROUND9.md b/benches/ROUND9.md new file mode 100644 index 00000000..80a12b22 --- /dev/null +++ b/benches/ROUND9.md @@ -0,0 +1,328 @@ +# Round 9 — decorator PUT reactivation, session/search/dedup alloc purges, PROPFIND `join!`, folder-level cascade + +Benchmark-gated, same rule as ROUND2-8: every change ships with a +BEFORE/AFTER benchmark and equivalence/safety gates; an AFTER that doesn't +beat its BEFORE gets rolled back. The two decide-by-bench items this round +(PROPFIND enrichment `join!`, folder binary-UUID) were adopted only after +their gates passed; the authz change carries hard safety gates plus a new +direct-grant-sibling isolation gate and was validated against the full +authz-relevant unit suite. + +Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release +profile; frontend on Node 22 / vitest 4 (jsdom). Reproduce any row with the +command in its section. + +## Summary + +| # | change | key metric | before → after | +|--:|---|---|---| +| 1 | Blob decorators forward `put_blob_from_bytes_unsynced` | HEAD probes / wall, 500-chunk upload @10 ms RTT | 500 → 0 probes; full stack 1571 → 812 ms (**1.9x**) | +| 2 | NC PROPFIND page enrichment triple → `tokio::join!` | p50 ms/page (500 children) | local 2.28 → 1.10 (**2.07x**); @5 ms RTT 22.1 → 7.7 (**2.86x**) | +| 3 | Search enrich consume+carry (`Arc` result fields) | enrich_file ns/row · allocs/row | 456 → 223 (**2.0x**) · 11.6 → 2.2; NC conversion 15.4 → 7.0 allocs/row | +| 4 | NC session end-to-end `Arc` (extractor/chroot/build) | allocs per authenticated NC request | extractor 8→0, chroot hit 4→0, build 11→6 (**~17 fewer/req**) | +| 5 | Storage micro-pack (create_new · manifest Arc · single-flight · hex) | see §5 | fresh chunk writes **2.1x**; 4097→0 allocs/read; herd 64→1 loads; 18→1 allocs/digest | +| 6 | OCS capabilities memoized (`OnceLock`) | 50k polls wall · allocs/poll | 269.6 → 1.1 ms (**237x**) · 102 → 0 | +| 7 | `Drive::is_empty` COUNT(*) → `EXISTS` | ms/call, 100k-file drive | 13.6 → 0.40 (**34.4x**) | +| 8 | favorites/recents row-map move (ROUND7 port) | allocs/row | 12.00 → 9.25 (**−2.75/row**) | +| 9 | Folder rows: binary UUID decode (ROUND6 port) | 500-row page mean | 1.06–1.10 → 1.03–1.04 ms (**1.03–1.07x**, first run a wash — see §9) | +| 10 | Folder-level cascade decision (authz, ROUND8 deferred) | cold first view µs/thumb (100-photo album) | 592 → 418 (**1.42x**); warm 1.33 µs unchanged | +| 11 | SPA: `resolveLabel` O(C)→O(1) index | 50 frames × 30 rows @ 5k contacts | 11.0 → 0.8 ms (**13.9x**); comparisons rows×C → C | +| 12 | SPA: selection-prune guard + `matchMedia` hoist | per-page Set builds / matchMedia calls | 100 → 0 · P → 1 | + +## [1] Blob decorators — the trait-default fallthrough was re-adding HEAD-before-PUT + +ROUND3 §8 made chunk writes skip the remote exists-probe by introducing +`put_blob_from_bytes_unsynced` (content-addressed keys make re-PUTs +overwrite-safe). But `RetryBlobBackend` and `CachedBlobBackend` never +overrode it, so the **trait default** routed every decorated `_unsynced` +call back through the probing `put_blob_from_bytes` — silently reinstating +HEAD+PUT per chunk on every remote deployment with retry or cache enabled +(the recommended object-store setup). `EncryptedBlobBackend` and +`MigrationBlobBackend` already forwarded correctly. + +Both decorators now forward `put_blob_from_bytes_unsynced` and `sync_blobs` +to their inner backend (Retry wraps the former in its retry loop; the +durability sweep is deliberately NOT retried — a failed fsync must surface, +not be re-issued after the kernel may have dropped the dirty pages). +`CachedBlobBackend` keeps its local write-through population on the +unsynced path (shared `cache_bytes_write_through` helper, no eviction sweep +— matching the historical write-path behavior) so post-upload readers +(thumbnail/EXIF/face hooks) still hit the cache. + +``` +cargo run --release --features bench --example bench_s3_put +# 500 x 256 KiB chunk PUTs at concurrency 8, 10 ms/request stub +# [1] raw backend BEFORE 1519 ms (500 HEADs) → AFTER 765 ms (0) 2.0x +# [3] retry(s3) BEFORE 1524 ms (500 HEADs) → AFTER 766 ms (0) 2.0x +# cache(s3) BEFORE 1535 ms (500 HEADs) → AFTER 803 ms (0) 1.9x +# cache(enc(retry(s3))) 1571 ms (500) → 812 ms (0) 1.9x +# gates: BEFORE probes == chunks, AFTER probes == 0, cache write-through +# populated on BOTH routes (2×chunks files present) +``` + +## [2] NC PROPFIND page enrichment — 3 serial round-trips → `tokio::join!` + +Every Depth:1 PROPFIND page enriches its ≤500 children with three +INDEPENDENT batched reads (favorites `= ANY`, oc:fileid `= ANY`, dead +props `= ANY`), previously awaited in sequence. This is the round-7 +deferred "serial pairs" item, and the one pair the round-7 notes ranked +worth gating (3 round-trips, per page, on the hottest sync path). + +Decide-by-bench with injected per-round-trip latency (0/0.25/1/5 ms), +because ROUND6 showed concurrency can LOSE on local-socket PG (the authz +`try_join_all` rejection). It doesn't here — these are three fat batched +queries whose **server-side execution** parallelizes across PG backends, +so even the local-socket floor wins, not just the RTT overlap: + +``` +cargo run --release --features bench --example bench_nc_enrich_join +# children=500, passes=100, p50 ms/page serial join! ratio +# 0 µs injected 2.275 1.097 2.07x +# 250 µs 6.273 2.481 2.53x +# 1000 µs 9.163 3.441 2.66x +# 5000 µs 22.050 7.709 2.86x +# gate: identical favorite sets / id maps / dead-prop rows; adoption +# required no local-socket regression — it's a 2x win even there +``` + +Contrast with ROUND6 §8 (rejected): that fan-out issued ~200 single-row +authz checks through the engine's cache layers; this overlaps exactly 3 +page-batched queries. Both files' and folders' page loops adopted it. + +## [3] Search enrichment — borrow+clone+reclassify → consume+carry + +`enrich_file` took `&FileDto`, cloned every owned String out of it, and +RE-RAN the three display classifiers whose results the DTO already carried +interned (`Arc`, computed once in `FileDto::from`); the recursive +branch maps the ENTIRE pre-pagination match set. The NC REPORT conversion +(`file_dto_from_search`) then re-ran all three classifiers a SECOND time +per emitted row. `SearchFileResultDto.{mime_type,icon_class, +icon_special_class,category}` are now `Arc` (`#[schema(value_type = +String)]` keeps the OpenAPI shape; JSON output byte-identical), both +enrichers consume their DTO, the intermediate `Vec`/`Vec` +materializations are fused away, suggest reuses the interned fields, and +the NC conversion carries them (refcount bumps). The search-cache byte +weigher keeps counting `.len()` per row — now an over-count of shared +bytes, i.e. the conservative direction. + +``` +cargo run --release --features bench --example bench_search_enrich +# rows=10000 passes=50 (p50 ns/row; allocs from pass 0) +# [1] enrich_file BEFORE 455.8 ns / 11.60 allocs → AFTER 222.7 / 2.20 +# [2] enrich_folder BEFORE 116.2 ns / 5.00 allocs → AFTER 127.6 / 1.00 +# (folder wall flat: the AFTER window absorbs the input drop the +# BEFORE arm defers outside its timing; the alloc gate is the win) +# [3] NC conversion BEFORE 2.700 ms / 15.40 allocs → AFTER 1.524 / 7.00 +# gates: 500 files + 500 folders field-identical; NC conversion +# field-identical vs a fresh classifier run +``` + +## [4] NC session — deep-clone per request → `Arc` end-to-end + +Every authenticated NC request paid: the extractor's `(**arc).clone()` — a +DEEP clone of `NcSession` (~8-9 String allocs) despite its doc claiming +"one Arc increment"; a chroot-cache hit cloning the stored `FolderDto` by +value (~5 allocs, moka `get` clones `V`); and a session build that cloned +`CurrentUser` for the extension, cloned `raw_username`, and `to_string`ed +the span value. Now: `NC_CHROOT_CACHE` stores `Arc`, +`NcSession.user` is the same `Arc` the extension holds, +`raw_username` moves, the span renders lazily (`field::display`, the +ROUND5 §7 pattern the NC path had missed), and handlers extract +`SharedNcSession` — an `Arc` handle that derefs to `NcSession`, so the 64 +field-access sites are untouched. + +``` +cargo run --release --features bench --example bench_nc_session +# 100k iterations wall ms allocs/op +# [1] extractor BEFORE deep clone 17.0 8.000 +# AFTER SharedNcSession 4.2 0.000 (4.0x) +# [2] chroot hit BEFORE FolderDto value 21.3 4.000 +# AFTER Arc 11.7 0.000 (1.8x) +# [3] build BEFORE clone×2 + span 17.6 11.000 +# AFTER shared Arc 11.8 6.000 (1.5x) +# gate: every field handlers consume identical (incl. the URL-user check) +``` + +## [5] Storage micro-pack + +Four independent A/Bs in one harness (`bench_storage_micro`, no Postgres): + +- **(a) Local chunk write** — `try_exists` (stat) + `File::create` → + one atomic `create_new` open; `AlreadyExists` IS the idempotent skip. + 20k × 4 KiB fresh writes 2707 → 1286 ms (**2.1x**); re-put skips 1.08x. +- **(b) CDC read prep** — `stream_chunks` took `Vec`, forcing + every read to deep-clone the cached manifest's whole hash list before + the first byte; now it takes the manifest `Arc` and indexes. A + 4096-chunk manifest × 200 reads: 819 400 → 0 allocs, 49.4 → 0.16 ms. + The Range path selects by index too — a `bytes=0-` probe of an N-chunk + video no longer clones N hashes. +- **(c) Manifest miss herd** — `manifest_cached` used get→insert; K + concurrent cold readers each ran the SELECT. Now fast-get + + `try_get_with` (sentinel miss error keeps the positive-only contract — + moka never caches loader errors, so legacy blobs and DB failures stay + uncached). Herd of 64: 64 → 1 loads. +- **(d) Chunk `Content-MD5` hex** — the last `format!("{b:02x}")`-per-byte + straggler (ROUND6 §7 shipped `hex_lower`); 18 → 1 allocs/digest, 10x. + +``` +cargo run --release --features bench --example bench_storage_micro +``` + +## [6] OCS capabilities — rebuilt per poll → memoized bytes + +`/ocs/v{1,2}.php/cloud/capabilities` is process-invariant (pure config), +yet every poll re-built the ~40-node `json!` tree, re-read +`OXICLOUD_BASE_URL` from the **environment**, ran three `format!`s and +re-serialized. Both versions now serialize once into +`OnceLock<[Bytes; 2]>`; a poll is a refcount bump. The payload builder +takes its three config inputs directly (testable without `AppState`). + +``` +cargo run --release --features bench --example bench_capabilities_static +# 50k polls BEFORE 269.6 ms / 102 allocs/poll → AFTER 1.1 ms / 0 (237x) +# gate: served bytes byte-identical for v1 and v2 +``` + +## [7] `Drive::is_empty` — full-drive COUNT(*) sum → `EXISTS OR EXISTS` + +The deletion precheck only needs a boolean, but aggregated every live +folder + file in the drive. `EXISTS` stops at the first row. + +``` +cargo run --release --features bench --example bench_drive_is_empty +# populated (100k files) 13.615 → 0.396 ms (34.4x) +# empty 0.219 → 0.166 ms (1.3x) +# gate: identical booleans on both data shapes +``` + +## [8] favorites/recents row-map — the ROUND7 move that never got ported + +ROUND7 §3 removed the per-row `name` clone in `/folders/{id}/resources`; +the same mapping in `/api/favorites/resources` and `/api/recent/resources` +still cloned `path` + `name` + `blob_hash` per row (and `folder_handler` +kept one `blob_hash` clone). All moved now — display classes computed +before `name` moves, `path`/`blob_hash` moved instead of cloned. + +``` +cargo run --release --features bench --example bench_resource_row_map +# [2] favorites/recents shape, rows=500 +# BEFORE (clone) 12.004 allocs/row → AFTER (move) 9.254 (−2.75/row) +# gate: (name, path, content_hash, icon_class, category) identical per row +``` + +## [9] Folder rows — binary UUID decode (the ROUND6 §10 port) + +ROUND6 adopted binary-UUID decode for file listing rows (1.17x) and queued +"other repos with the same shape"; `FolderDbRepository` never got it. All +folder-row queries (`list_folders_batch` — every Depth:1 PROPFIND subfolder +page — `get_folder`, descendants, search, suggest, and the write-path +RETURNINGs, which share `row_to_folder`) now decode `id`/`parent_id` as +binary `Uuid` (16 B vs 36 B on the wire, no server cast) and render once +app-side. Param casts (`$3::text IS NULL`), enum casts and the ltree +`path::text` renders are untouched. + +**Honest verdict:** weaker than the file side. Four interleaved runs: +1.00x (wash), 1.05x, 1.03x, and 1.07x at 1000 rows — folder rows are +thinner than file rows, so the two casts are a smaller fraction of the +page. Adopted on the consistent small win + growth with page size + the +wire-bytes reduction; the first-run wash is inside the noise band. + +``` +cargo run --release --features bench --example bench_folder_uuid_decode +# rows/page=500 passes=400 (interleaved) mean p50 p95 +# A ::text (before) 1.061 1.039 1.310 +# B binary (after) 1.027 1.012 1.269 1.03x +# rows/page=1000: 1.758 → 1.639 mean 1.07x +# gate: identical (id, name, path, parent_id) tuples +``` + +## [10] Authz — folder-level cascade decision (the ROUND8 deferred item) + +ROUND8 memoised the per-file cascade decision, fixing revalidation; a +shared N-photo album's **cold first view** still ran N near-identical +ltree ancestor queries. The file decision now decomposes into exactly the +two branches of the historical UNION: parent point-read (new +`file_parent_cache`, 30 s TTL — grant writes don't alter parentage; moves +are the same TTL-healed indirect path as before) → the FOLDER cascade +decision (one ltree query per folder, shared by every sibling via the +existing `cascade_grant_cache`, recursing into the Folder arm) → a +direct-file-grant point lookup only when the folder half denies. The old +UNION query is deleted; no decision changes, including the parentless +edge (`folder_id IS NOT NULL` guard ≡ direct-only fallback). + +Safety gates (hard asserts): recipient allowed on every file, outsider +denied, `clear_role` revoke denies IMMEDIATELY (the flush covers file and +folder decisions — same cache), and NEW: a caller holding only a direct +grant on one file is allowed that file and denied its siblings — proving +the folder-level decomposition neither shadows direct grants nor leaks a +file decision across siblings. + +``` +cargo run --release --features bench --example bench_thumbnail_cascade_cache +# thumbs=100 (folder-grant recipient, no drive membership) +# ROUND8 cold (union/file) 59.19 ms 591.91 µs/thumb +# AFTER cold (first view) 41.77 ms 417.73 µs/thumb (1.42x) +# AFTER warm (revalidation) 0.13 ms 1.33 µs/thumb (unchanged) +``` + +The first view is now bounded by the per-file parent PK reads (cheap, but +still N point queries) + 1 ltree query — batching the parent resolution +per page would need a wider API change; noted for a future round. + +## [11] SPA — `resolveLabel` linear directory scan → id-keyed index + +`resolveLabel`/`resolveRecipient` ran `contactCache.find(...)` — a linear +scan over the whole system address book — once per rendered grant row / +lane header on `/shared`, re-rendering on every page and role change: +O(rows × directory). Now a `Map` built once per cache +identity (exactly like the existing `groupCache`). + +``` +cd frontend && npx vitest run src/lib/api/endpoints/recipients.bench.test.ts --disable-console-intercept +# 50 frames × 30 rows @ C=5000: before 11.0 ms, after 0.8 ms (13.9x) +# gates: labels identical (present + absent ids); comparisons rows×C → C +``` + +## [12] SPA — selection-prune guard + photos `matchMedia` hoist + +- `ResourceList`'s prune `$effect` built an O(N) id `Set` on every + infinite-scroll page even with nothing selected; guarded with + `selected.size === 0` (reactive, so it re-arms when a selection + appears). 100-page drain: 100 → 0 Set builds; pruned result identical + when a selection exists. +- The photos timeline derive called `window.matchMedia(...)` per + recompute (every 60-photo page); hoisted to state fed by one + MediaQueryList `change` listener. P recomputes: P → 1 calls, identical + booleans, crossings propagate. + +``` +cd frontend && npx vitest run src/lib/components/listDerives.bench.test.ts --disable-console-intercept +``` + +## Deferred / flagged (not shipped this round) + +- **CalDAV authz-before-fetch reorder** (`calendar_service::get_event` / + `list_events` / by-uid fetch the calendar row before the authz check + only to read `.is_public`; running the already-required authz first and + fetching only on denial saves one SELECT per authorized private-calendar + read). Behavior-preserving (the OR commutes) but it reorders an authz + check relative to a data fetch — flagged for maintainer sign-off per the + authz-change convention, with the bench sketch in this round's notes. +- **Per-page batched parent resolution** for §10 — would cut the cold + first view's N parent PK reads to one `= ANY` per page; needs a wider + engine API (batch check) — future round. +- **`batch_operations` `Arc` → `Option<&str>` widening** (ROUND7 + deferred) — re-audited: 1 small alloc/item vs a per-item DB roundtrip; + still not worth the 2-trait/7-site churn alone. Standing verdict. +- **JWT-claims `Arc`** (ROUND6 deferred) — still open; touches + serde `rc` on `TokenClaims` + dozens of read sites. The 2 allocs/request + remain the cheapest known win on the /api path for a future round. + +## Correctness-adjacent (surfaced by the round-9 hunt — not perf) + +- `trash_service.rs` restore matches error text + (`format!("{}", e).contains("not found")`) instead of + `e.kind == ErrorKind::NotFound` — fragile to rewording; flagged. +- The round-7 flags remain open: `fetchFolderListing` seeds empty + `favoriteIds`/`sharedIds`; the search page still lacks a stale-response + guard. diff --git a/examples/bench_capabilities_static.rs b/examples/bench_capabilities_static.rs new file mode 100644 index 00000000..624f955f --- /dev/null +++ b/examples/bench_capabilities_static.rs @@ -0,0 +1,157 @@ +//! OCS capabilities poll benchmark — rebuild-per-request vs memoized bytes. +//! +//! `/ocs/v{1,2}.php/cloud/capabilities` returns a payload that is +//! process-invariant (pure config: base URL + emulated NC version), yet +//! every NC desktop/mobile client polls it on connect and periodically. +//! The old handler re-built the ~40-node `json!` tree — including a +//! `std::env::var("OXICLOUD_BASE_URL")` lookup and three `format!`s — +//! and re-serialized it on EVERY poll. Round 9 serializes both versions +//! once into a `OnceLock<[Bytes; 2]>`; a poll is a `Bytes` refcount bump. +//! +//! The BEFORE arm is the production payload builder invoked per request +//! (via the bench wrapper) + `serde_json::to_vec`, exactly the old +//! handler flow (`Json(payload)` serializes with `to_vec`). The AFTER +//! arm is the memoized-bytes flow. The equivalence gate asserts the +//! served bytes are identical. +//! +//! Run (no Postgres needed): +//! cargo run --release --features bench --example bench_capabilities_static +//! Tunables (env): BENCH_POLLS (50000) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::hint::black_box; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use bytes::Bytes; +use oxicloud::interfaces::nextcloud::ocs_handler::capabilities_payload_for_bench; + +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; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +const EMULATED: (u32, u32, u32) = (28, 0, 4); +const VERSION_STRING: &str = "28.0.4"; + +/// BEFORE flow, verbatim shape: env lookup + tree build + serialize per poll. +fn before_poll(ocs_version: u8) -> Vec { + let base_url = + env::var("OXICLOUD_BASE_URL").unwrap_or_else(|_| "http://localhost:8086".to_string()); + let payload = capabilities_payload_for_bench(&base_url, EMULATED, VERSION_STRING, ocs_version); + serde_json::to_vec(&payload).expect("serialize") +} + +/// AFTER flow: the production memoization shape (OnceLock + Bytes clone). +fn after_poll(cache: &OnceLock<[Bytes; 2]>, ocs_version: u8) -> Bytes { + let bodies = cache.get_or_init(|| { + let base_url = + env::var("OXICLOUD_BASE_URL").unwrap_or_else(|_| "http://localhost:8086".to_string()); + [1u8, 2u8].map(|v| { + Bytes::from( + serde_json::to_vec(&capabilities_payload_for_bench( + &base_url, + EMULATED, + VERSION_STRING, + v, + )) + .expect("serialize"), + ) + }) + }); + bodies[usize::from(ocs_version != 1)].clone() +} + +fn main() { + let polls: usize = env_or("BENCH_POLLS", 50_000); + let cache: OnceLock<[Bytes; 2]> = OnceLock::new(); + + // Equivalence gate: identical served bytes for both OCS versions. + for v in [1u8, 2u8] { + assert_eq!( + before_poll(v), + after_poll(&cache, v).as_ref(), + "capabilities v{v} bytes differ" + ); + } + println!("# equivalence gate: v1 + v2 served bytes identical — OK"); + + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + for i in 0..polls { + black_box(before_poll(if i % 2 == 0 { 1 } else { 2 })); + } + let before_ms = t.elapsed().as_secs_f64() * 1e3; + let before_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0; + + let a1 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + for i in 0..polls { + black_box(after_poll(&cache, if i % 2 == 0 { 1 } else { 2 })); + } + let after_ms = t.elapsed().as_secs_f64() * 1e3; + let after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1; + + println!("\n#################################################################"); + println!("# OCS capabilities poll — rebuild+serialize vs memoized Bytes"); + println!("# polls={polls}"); + println!("#################################################################\n"); + println!( + "| {:<26} | {:>10} | {:>12} | {:>12} |", + "arm", "wall ms", "allocs", "allocs/poll" + ); + println!( + "| {:<26} | {:>10.1} | {:>12} | {:>12.2} |", + "BEFORE (rebuild)", + before_ms, + before_allocs, + before_allocs as f64 / polls as f64 + ); + println!( + "| {:<26} | {:>10.1} | {:>12} | {:>12.2} |", + "AFTER (memoized)", + after_ms, + after_allocs, + after_allocs as f64 / polls as f64 + ); + println!( + "\n{:.1}x faster, {:.0}x fewer allocs", + before_ms / after_ms, + before_allocs as f64 / after_allocs.max(1) as f64 + ); + + if after_ms >= before_ms || after_allocs >= before_allocs { + eprintln!("GATE FAIL: memoized arm not strictly better — rollback"); + std::process::exit(1); + } + println!("GATE PASS"); +} diff --git a/examples/bench_drive_is_empty.rs b/examples/bench_drive_is_empty.rs new file mode 100644 index 00000000..fbe0b32d --- /dev/null +++ b/examples/bench_drive_is_empty.rs @@ -0,0 +1,218 @@ +//! `Drive::is_empty` benchmark — full-drive `COUNT(*)` sum vs short-circuit +//! `EXISTS OR EXISTS`. +//! +//! The drive-deletion precheck only needs a boolean, but the old query +//! aggregated every live folder AND file in the drive (two full index/heap +//! scans) to compare the sum with 0. `EXISTS` stops at the first matching +//! row, so a populated drive answers from one probe. +//! +//! Both query shapes run against the same seeded data; the equivalence +//! gate asserts identical booleans for a populated and an empty drive. +//! +//! Run (needs Postgres up; reads DATABASE_URL from .env): +//! cargo run --release --features bench --example bench_drive_is_empty +//! Tunables (env): BENCH_FILES (100000), BENCH_REPS (25) + +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_drive(pool: &PgPool, files: usize) -> Uuid { + // Drive + root folder must commit together (deferred root-folder trigger). + 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 root: Uuid = sqlx::query_scalar( + "INSERT INTO storage.folders (name, path, lpath, drive_id) + VALUES ('bench_is_empty', '/bench_is_empty', 'bench_is_empty', $1) + RETURNING id", + ) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("root"); + sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2") + .bind(root) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("stamp root"); + tx.commit().await.expect("commit"); + + if files > 0 { + sqlx::query( + "INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id) + SELECT 'f' || i, $1, + 'benchempty00000000000000000000000000000000000000000000000000000', + 1024, 'image/jpeg', $2 + FROM generate_series(1, $3) AS i", + ) + .bind(root) + .bind(drive_id) + .bind(files as i32) + .execute(pool) + .await + .expect("seed files"); + } + drive_id +} + +async fn cleanup(pool: &PgPool, drive_id: Uuid) { + sqlx::query("DELETE FROM storage.files WHERE drive_id = $1") + .bind(drive_id) + .execute(pool) + .await + .ok(); + sqlx::query("UPDATE storage.drives SET root_folder_id = NULL WHERE id = $1") + .bind(drive_id) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM storage.folders WHERE drive_id = $1") + .bind(drive_id) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM storage.drives WHERE id = $1") + .bind(drive_id) + .execute(pool) + .await + .ok(); +} + +/// BEFORE — verbatim old query shape. +async fn is_empty_count(pool: &PgPool, drive_id: Uuid) -> bool { + let count: (i64,) = sqlx::query_as( + r#" + SELECT ( + (SELECT COUNT(*) FROM storage.folders + WHERE drive_id = $1 AND parent_id IS NOT NULL AND NOT is_trashed) + + (SELECT COUNT(*) FROM storage.files + WHERE drive_id = $1 AND NOT is_trashed) + ) + "#, + ) + .bind(drive_id) + .fetch_one(pool) + .await + .expect("count query"); + count.0 == 0 +} + +/// AFTER — the production EXISTS shape. +async fn is_empty_exists(pool: &PgPool, drive_id: Uuid) -> bool { + let occupied: (bool,) = sqlx::query_as( + r#" + SELECT EXISTS( + SELECT 1 FROM storage.folders + WHERE drive_id = $1 AND parent_id IS NOT NULL AND NOT is_trashed) + OR EXISTS( + SELECT 1 FROM storage.files + WHERE drive_id = $1 AND NOT is_trashed) + "#, + ) + .bind(drive_id) + .fetch_one(pool) + .await + .expect("exists query"); + !occupied.0 +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + dotenvy::dotenv().ok(); + let url = env::var("DATABASE_URL").expect("set DATABASE_URL — the dev Postgres URL"); + let pool = PgPoolOptions::new() + .max_connections(4) + .connect(&url) + .await + .expect("connect"); + + let files: usize = env_or("BENCH_FILES", 100_000); + let reps: usize = env_or("BENCH_REPS", 25); + + let populated = seed_drive(&pool, files).await; + let empty = seed_drive(&pool, 0).await; + + // Equivalence gate on both data shapes. + assert_eq!( + is_empty_count(&pool, populated).await, + is_empty_exists(&pool, populated).await, + "populated drive verdict differs" + ); + assert_eq!( + is_empty_count(&pool, empty).await, + is_empty_exists(&pool, empty).await, + "empty drive verdict differs" + ); + assert!(!is_empty_exists(&pool, populated).await); + assert!(is_empty_exists(&pool, empty).await); + println!("# equivalence gate: identical booleans on populated + empty drives — OK"); + + // Warm both shapes. + for _ in 0..3 { + is_empty_count(&pool, populated).await; + is_empty_exists(&pool, populated).await; + } + + let mut rows = Vec::new(); + for (label, drive) in [("populated (100k files)", populated), ("empty", empty)] { + let t = Instant::now(); + for _ in 0..reps { + std::hint::black_box(is_empty_count(&pool, drive).await); + } + let before_ms = t.elapsed().as_secs_f64() * 1e3 / reps as f64; + + let t = Instant::now(); + for _ in 0..reps { + std::hint::black_box(is_empty_exists(&pool, drive).await); + } + let after_ms = t.elapsed().as_secs_f64() * 1e3 / reps as f64; + rows.push((label, before_ms, after_ms)); + } + + println!("\n#################################################################"); + println!("# Drive::is_empty — COUNT(*) sum vs EXISTS OR EXISTS"); + println!("# files={files} reps={reps} (ms per call)"); + println!("#################################################################\n"); + println!( + "| {:<24} | {:>14} | {:>14} | {:>8} |", + "drive", "BEFORE ms", "AFTER ms", "speedup" + ); + let mut populated_gain = 0.0; + for (label, before_ms, after_ms) in &rows { + println!( + "| {:<24} | {:>14.3} | {:>14.3} | {:>7.1}x |", + label, + before_ms, + after_ms, + before_ms / after_ms + ); + if label.starts_with("populated") { + populated_gain = before_ms / after_ms; + } + } + + cleanup(&pool, populated).await; + cleanup(&pool, empty).await; + + if populated_gain <= 1.0 { + eprintln!("\nGATE FAIL: EXISTS not faster on the populated drive — rollback"); + std::process::exit(1); + } + println!("\nGATE PASS: identical verdicts, populated drive {populated_gain:.1}x faster."); +} diff --git a/examples/bench_folder_uuid_decode.rs b/examples/bench_folder_uuid_decode.rs new file mode 100644 index 00000000..1fef9a25 --- /dev/null +++ b/examples/bench_folder_uuid_decode.rs @@ -0,0 +1,247 @@ +//! Folder-listing UUID decode benchmark — `id::text`/`parent_id::text` +//! server casts vs binary `Uuid` decode + one app-side render. +//! +//! Round 6 adopted binary decode for the FILE listing rows +//! (`row_to_file`, benches/ROUND6.md §10: 1.17x on 500-row pages) and +//! queued "other repos with the same shape" — `FolderDbRepository` never +//! got the port. Its rows (`list_folders`, `list_folders_batch` — every +//! Depth:1 PROPFIND subfolder page — descendants, suggest) still shipped +//! two `::text` casts per row: 36+36 B on the wire instead of 16+16 and +//! a server-side cast per column. +//! +//! Same methodology as `bench_uuid_text_cast` (the round-6 A/B this +//! ports): seeded page, equivalence gate on identical `(id, parent_id, +//! name, path)` string tuples, warm-up, interleaved passes. +//! +//! Run (needs Postgres up; reads DATABASE_URL from .env): +//! cargo run --release --features bench --example bench_folder_uuid_decode +//! Tunables (env): BENCH_ROWS (500), BENCH_PASSES (200) + +use std::env; +use std::sync::Arc; +use std::time::{Duration, 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) +} + +struct Seeded { + drive_id: Uuid, + parent_id: Uuid, +} + +async fn seed(pool: &PgPool, rows: usize) -> Seeded { + 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 root: Uuid = sqlx::query_scalar( + "INSERT INTO storage.folders (name, path, lpath, drive_id) + VALUES ('bench_uuid_folders', '/bench_uuid_folders', 'bench_uuid_folders', $1) + RETURNING id", + ) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("root"); + sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2") + .bind(root) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("stamp root"); + tx.commit().await.expect("commit"); + + sqlx::query( + "INSERT INTO storage.folders (name, parent_id, path, lpath, drive_id) + SELECT 'sub' || i, $1, '/bench_uuid_folders/sub' || i, + ('bench_uuid_folders.sub' || i)::ltree, $2 + FROM generate_series(1, $3) AS i", + ) + .bind(root) + .bind(drive_id) + .bind(rows as i32) + .execute(pool) + .await + .expect("seed subfolders"); + + Seeded { + drive_id, + parent_id: root, + } +} + +async fn cleanup(pool: &PgPool, s: &Seeded) { + sqlx::query("DELETE FROM storage.folders WHERE drive_id = $1 AND parent_id IS NOT NULL") + .bind(s.drive_id) + .execute(pool) + .await + .ok(); + sqlx::query("UPDATE storage.drives SET root_folder_id = NULL WHERE id = $1") + .bind(s.drive_id) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM storage.folders WHERE drive_id = $1") + .bind(s.drive_id) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM storage.drives WHERE id = $1") + .bind(s.drive_id) + .execute(pool) + .await + .ok(); +} + +/// Materialized tuple both arms must produce identically. +type FolderTuple = (String, String, String, Option); + +/// BEFORE — verbatim old query shape: two server-side `::text` casts, +/// decode as String. +async fn fetch_text_cast(pool: &PgPool, parent_id: Uuid) -> Vec { + sqlx::query_as::<_, (String, String, String, Option)>( + r#" + SELECT id::text, name, path, parent_id::text + FROM storage.folders + WHERE parent_id = $1 AND NOT is_trashed + ORDER BY name + "#, + ) + .bind(parent_id) + .fetch_all(pool) + .await + .expect("text-cast fetch") +} + +/// AFTER — the production shape: binary decode, one `to_string` app-side +/// (exactly what `row_to_folder` does now). +async fn fetch_binary_uuid(pool: &PgPool, parent_id: Uuid) -> Vec { + let rows = sqlx::query_as::<_, (Uuid, String, String, Option)>( + r#" + SELECT id, name, path, parent_id + FROM storage.folders + WHERE parent_id = $1 AND NOT is_trashed + ORDER BY name + "#, + ) + .bind(parent_id) + .fetch_all(pool) + .await + .expect("binary fetch"); + rows.into_iter() + .map(|(id, name, path, pid)| (id.to_string(), name, path, pid.map(|u| u.to_string()))) + .collect() +} + +struct Stats { + mean_ms: f64, + p50_ms: f64, + p95_ms: f64, +} + +fn summarize(mut xs: Vec) -> Stats { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let n = xs.len(); + Stats { + mean_ms: xs.iter().sum::() / n as f64, + p50_ms: xs[n / 2], + p95_ms: xs[((n as f64 * 0.95) as usize).min(n - 1)], + } +} + +#[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 — the dev Postgres URL"); + let rows: usize = env_or("BENCH_ROWS", 500); + let passes: usize = env_or("BENCH_PASSES", 200); + + let pool = Arc::new( + PgPoolOptions::new() + .max_connections(4) + .min_connections(4) + .acquire_timeout(Duration::from_secs(10)) + .connect(&url) + .await + .expect("connect Postgres"), + ); + + let seeded = seed(&pool, rows).await; + + // Equivalence gate: identical string tuples in identical order. + let a = fetch_text_cast(&pool, seeded.parent_id).await; + let b = fetch_binary_uuid(&pool, seeded.parent_id).await; + if a != b || a.len() != rows { + eprintln!( + "EQUIVALENCE GATE FAILED: rows differ (a={}, b={})", + a.len(), + b.len() + ); + cleanup(&pool, &seeded).await; + std::process::exit(1); + } + println!("# equivalence gate: {rows} identical (id, name, path, parent_id) tuples — OK"); + + for _ in 0..10 { + std::hint::black_box(fetch_text_cast(&pool, seeded.parent_id).await); + std::hint::black_box(fetch_binary_uuid(&pool, seeded.parent_id).await); + } + + // Interleaved A/B passes so drift (autovacuum, CPU governor) hits both. + let mut lat_a = Vec::with_capacity(passes); + let mut lat_b = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + std::hint::black_box(fetch_text_cast(&pool, seeded.parent_id).await); + lat_a.push(t.elapsed().as_secs_f64() * 1e3); + let t = Instant::now(); + std::hint::black_box(fetch_binary_uuid(&pool, seeded.parent_id).await); + lat_b.push(t.elapsed().as_secs_f64() * 1e3); + } + + let sa = summarize(lat_a); + let sb = summarize(lat_b); + + println!("\n#################################################################"); + println!("# folder page: `::text` casts vs binary UUID decode + app fmt"); + println!("# rows/page={rows} passes={passes} (interleaved)"); + println!("#################################################################\n"); + println!( + "| {:<22} | {:>9} | {:>9} | {:>9} |", + "arm", "mean ms", "p50 ms", "p95 ms" + ); + println!( + "| {:<22} | {:>9.3} | {:>9.3} | {:>9.3} |", + "A ::text (before)", sa.mean_ms, sa.p50_ms, sa.p95_ms + ); + println!( + "| {:<22} | {:>9.3} | {:>9.3} | {:>9.3} |", + "B binary (after)", sb.mean_ms, sb.p50_ms, sb.p95_ms + ); + println!( + "\nB/A mean ratio: {:.3} ({:.2}x)", + sb.mean_ms / sa.mean_ms, + sa.mean_ms / sb.mean_ms + ); + + cleanup(&pool, &seeded).await; + + if sb.mean_ms >= sa.mean_ms { + eprintln!("GATE FAIL: binary decode not faster than ::text — rollback"); + std::process::exit(1); + } + println!("GATE PASS"); +} diff --git a/examples/bench_micro_allocs.rs b/examples/bench_micro_allocs.rs index 52e2f402..64e289c9 100644 --- a/examples/bench_micro_allocs.rs +++ b/examples/bench_micro_allocs.rs @@ -135,9 +135,15 @@ fn suggest_before(files: &[File], q: &str) -> Vec { item_type: "file".to_string(), id: file_dto.id.clone(), path: file_dto.path.clone(), - icon_class: icon_class_for(&file_dto.name, &file_dto.mime_type).to_string(), + // `.into()` bridges the round-9 `Arc` field type; the + // conversion is identical on both arms so the round-5 delta + // this bench gates (clone vs move) is unaffected. + icon_class: icon_class_for(&file_dto.name, &file_dto.mime_type) + .to_string() + .into(), icon_special_class: icon_special_class_for(&file_dto.name, &file_dto.mime_type) - .to_string(), + .to_string() + .into(), relevance_score: score, }); } @@ -159,8 +165,9 @@ fn suggest_after(files: Vec, q: &str) -> Vec { item_type: "file".to_string(), id: file_dto.id, path: file_dto.path, - icon_class, - icon_special_class, + // Same `.into()` bridge as the BEFORE arm — see note there. + icon_class: icon_class.into(), + icon_special_class: icon_special_class.into(), relevance_score: score, }); } diff --git a/examples/bench_nc_enrich_join.rs b/examples/bench_nc_enrich_join.rs new file mode 100644 index 00000000..30b6690a --- /dev/null +++ b/examples/bench_nc_enrich_join.rs @@ -0,0 +1,369 @@ +//! NC PROPFIND per-page enrichment — 3 serial round-trips vs `tokio::join!`. +//! +//! Every Depth:1 PROPFIND page on the NextCloud surface enriches its ≤500 +//! children with three INDEPENDENT batched reads: favorites +//! (`user_favorites … = ANY`), oc:fileid resolution +//! (`nextcloud_object_ids … = ANY`) and WebDAV dead properties +//! (`webdav_dead_properties … = ANY`). The old code awaited them in +//! sequence — 3×RTT per page; overlapping them costs ~max(RTT). +//! +//! Decide-by-bench (the round-7 deferred "serial pairs" item): round 6 +//! showed concurrency can LOSE on local-socket PG (authz `try_join_all` +//! regressed), so this A/B carries an **injected-latency arm** — each +//! round-trip is prefixed with `tokio::time::sleep(L)` to model network +//! RTT at L = 0 / 0.25 / 1 / 5 ms. Adoption rule: `join!` must not +//! regress at L=0 (the local-socket floor) and must win under injected +//! RTT; the L=0 row is the rollback gate. +//! +//! The three queries are the production shapes bound over the same seeded +//! 500-child page; the equivalence gate asserts both arms return +//! identical favorite sets / id maps / dead-prop rows. +//! +//! Run (needs Postgres up; reads DATABASE_URL from .env): +//! cargo run --release --features bench --example bench_nc_enrich_join +//! Tunables (env): BENCH_CHILDREN (500), BENCH_PASSES (100) + +use std::collections::HashSet; +use std::env; +use std::time::{Duration, Instant}; + +use sqlx::{PgPool, Row, 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) +} + +struct Seeded { + drive_id: Uuid, + user_id: Uuid, + file_ids: Vec, +} + +async fn seed(pool: &PgPool, children: usize) -> Seeded { + let user_id: Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, role) + VALUES ('bench_enrich', 'bench_enrich@example.com', 'user') RETURNING id", + ) + .fetch_one(pool) + .await + .expect("seed user"); + + 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 root: Uuid = sqlx::query_scalar( + "INSERT INTO storage.folders (name, path, lpath, drive_id) + VALUES ('bench_enrich', '/bench_enrich', 'bench_enrich', $1) RETURNING id", + ) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("root"); + sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2") + .bind(root) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("stamp root"); + tx.commit().await.expect("commit"); + + let file_ids: Vec = sqlx::query_scalar( + "INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id) + SELECT 'f' || i, $1, + 'benchenrich0000000000000000000000000000000000000000000000000000', + 1024, 'image/jpeg', $2 + FROM generate_series(1, $3) AS i + RETURNING id", + ) + .bind(root) + .bind(drive_id) + .bind(children as i32) + .fetch_all(pool) + .await + .expect("seed files"); + + // Every 5th file favorited, all files carry an oc:fileid mapping, + // every 10th file has a dead property — a realistic mixed page. + sqlx::query( + "INSERT INTO auth.user_favorites (user_id, item_id, item_type) + SELECT $1, id::text, 'file' FROM storage.files + WHERE folder_id = $2 AND (('x' || substr(md5(id::text), 1, 4))::bit(16)::int % 5) = 0", + ) + .bind(user_id) + .bind(root) + .execute(pool) + .await + .expect("seed favorites"); + + sqlx::query( + "INSERT INTO storage.nextcloud_object_ids (object_type, object_id) + SELECT 'file', id FROM storage.files WHERE folder_id = $1 + ON CONFLICT DO NOTHING", + ) + .bind(root) + .execute(pool) + .await + .expect("seed object ids"); + + sqlx::query( + "INSERT INTO storage.webdav_dead_properties (file_id, namespace, local_name, value) + SELECT id, 'urn:bench', 'displayname', 'v' + FROM storage.files + WHERE folder_id = $1 AND (('x' || substr(md5(id::text), 1, 4))::bit(16)::int % 10) = 0", + ) + .bind(root) + .execute(pool) + .await + .expect("seed dead props"); + + Seeded { + drive_id, + user_id, + file_ids, + } +} + +async fn cleanup(pool: &PgPool, s: &Seeded) { + sqlx::query("DELETE FROM storage.webdav_dead_properties WHERE file_id = ANY($1)") + .bind(&s.file_ids) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM storage.nextcloud_object_ids WHERE object_id = ANY($1)") + .bind(&s.file_ids) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM auth.user_favorites WHERE user_id = $1") + .bind(s.user_id) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM storage.files WHERE drive_id = $1") + .bind(s.drive_id) + .execute(pool) + .await + .ok(); + sqlx::query("UPDATE storage.drives SET root_folder_id = NULL WHERE id = $1") + .bind(s.drive_id) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM storage.folders WHERE drive_id = $1") + .bind(s.drive_id) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM storage.drives WHERE id = $1") + .bind(s.drive_id) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM auth.users WHERE id = $1") + .bind(s.user_id) + .execute(pool) + .await + .ok(); +} + +// ── The three production-shaped round-trips ───────────────────────────────── + +async fn q_favorites( + pool: &PgPool, + user_id: Uuid, + ids: &[String], + lat: Duration, +) -> HashSet { + if !lat.is_zero() { + tokio::time::sleep(lat).await; + } + let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + sqlx::query("SELECT item_id FROM auth.user_favorites WHERE user_id = $1 AND item_id = ANY($2)") + .bind(user_id) + .bind(&id_refs) + .fetch_all(pool) + .await + .expect("favorites") + .into_iter() + .map(|r| r.get::(0)) + .collect() +} + +async fn q_object_ids(pool: &PgPool, uuids: &[Uuid], lat: Duration) -> Vec<(i64, Uuid)> { + if !lat.is_zero() { + tokio::time::sleep(lat).await; + } + let mut rows: Vec<(i64, Uuid)> = sqlx::query( + "SELECT id, object_id FROM storage.nextcloud_object_ids + WHERE object_type = 'file' AND object_id = ANY($1::uuid[])", + ) + .bind(uuids) + .fetch_all(pool) + .await + .expect("object ids") + .into_iter() + .map(|r| (r.get::(0), r.get::(1))) + .collect(); + rows.sort_unstable(); + rows +} + +async fn q_dead_props(pool: &PgPool, uuids: &[Uuid], lat: Duration) -> Vec<(Uuid, String)> { + if !lat.is_zero() { + tokio::time::sleep(lat).await; + } + let mut rows: Vec<(Uuid, String)> = sqlx::query( + "SELECT file_id, local_name FROM storage.webdav_dead_properties + WHERE file_id = ANY($1)", + ) + .bind(uuids) + .fetch_all(pool) + .await + .expect("dead props") + .into_iter() + .map(|r| (r.get::(0), r.get::(1))) + .collect(); + rows.sort_unstable(); + rows +} + +type PageResult = (HashSet, Vec<(i64, Uuid)>, Vec<(Uuid, String)>); + +/// BEFORE — the old serial shape. +async fn page_serial( + pool: &PgPool, + user_id: Uuid, + ids: &[String], + uuids: &[Uuid], + lat: Duration, +) -> PageResult { + let favs = q_favorites(pool, user_id, ids, lat).await; + let oc = q_object_ids(pool, uuids, lat).await; + let dead = q_dead_props(pool, uuids, lat).await; + (favs, oc, dead) +} + +/// AFTER — the production `join!` shape. +async fn page_joined( + pool: &PgPool, + user_id: Uuid, + ids: &[String], + uuids: &[Uuid], + lat: Duration, +) -> PageResult { + let (favs, oc, dead) = tokio::join!( + q_favorites(pool, user_id, ids, lat), + q_object_ids(pool, uuids, lat), + q_dead_props(pool, uuids, lat), + ); + (favs, oc, dead) +} + +fn p50(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 — the dev Postgres URL"); + let children: usize = env_or("BENCH_CHILDREN", 500); + let passes: usize = env_or("BENCH_PASSES", 100); + + // 4 connections: the production pool always has slack beyond 3. + let pool = PgPoolOptions::new() + .max_connections(4) + .min_connections(4) + .connect(&url) + .await + .expect("connect"); + + let seeded = seed(&pool, children).await; + let ids: Vec = seeded.file_ids.iter().map(|u| u.to_string()).collect(); + let uuids = seeded.file_ids.clone(); + + // Equivalence gate. + let a = page_serial(&pool, seeded.user_id, &ids, &uuids, Duration::ZERO).await; + let b = page_joined(&pool, seeded.user_id, &ids, &uuids, Duration::ZERO).await; + if a != b { + eprintln!("EQUIVALENCE GATE FAILED: serial and joined results differ"); + cleanup(&pool, &seeded).await; + std::process::exit(1); + } + assert!( + !a.0.is_empty() && !a.1.is_empty() && !a.2.is_empty(), + "seed produced empty enrichment" + ); + println!( + "# equivalence gate: identical results (favs={}, oc_ids={}, dead={}) — OK", + a.0.len(), + a.1.len(), + a.2.len() + ); + + for _ in 0..10 { + std::hint::black_box( + page_serial(&pool, seeded.user_id, &ids, &uuids, Duration::ZERO).await, + ); + std::hint::black_box( + page_joined(&pool, seeded.user_id, &ids, &uuids, Duration::ZERO).await, + ); + } + + println!("\n#################################################################"); + println!("# NC PROPFIND page enrichment — serial 3×RTT vs tokio::join!"); + println!("# children={children} passes={passes} (interleaved, p50 ms/page)"); + println!("#################################################################\n"); + println!( + "| {:<14} | {:>12} | {:>12} | {:>8} |", + "injected RTT", "serial ms", "join! ms", "ratio" + ); + + let mut zero_lat_ratio = 0.0; + for lat_us in [0u64, 250, 1_000, 5_000] { + let lat = Duration::from_micros(lat_us); + let mut serial = Vec::with_capacity(passes); + let mut joined = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + std::hint::black_box(page_serial(&pool, seeded.user_id, &ids, &uuids, lat).await); + serial.push(t.elapsed().as_secs_f64() * 1e3); + let t = Instant::now(); + std::hint::black_box(page_joined(&pool, seeded.user_id, &ids, &uuids, lat).await); + joined.push(t.elapsed().as_secs_f64() * 1e3); + } + let (s, j) = (p50(serial), p50(joined)); + if lat_us == 0 { + zero_lat_ratio = j / s; + } + println!( + "| {:>11} µs | {:>12.3} | {:>12.3} | {:>7.2}x |", + lat_us, + s, + j, + s / j + ); + } + + cleanup(&pool, &seeded).await; + + // Adoption gate: join! must not regress the local-socket floor by >5% + // (measurement noise band); the injected-RTT rows document the win. + if zero_lat_ratio > 1.05 { + eprintln!( + "\nGATE FAIL: join! is {:.1}% slower at 0 RTT — rollback the overlap", + (zero_lat_ratio - 1.0) * 100.0 + ); + std::process::exit(1); + } + println!("\nGATE PASS: no local-socket regression; overlap wins under injected RTT."); +} diff --git a/examples/bench_nc_session.rs b/examples/bench_nc_session.rs new file mode 100644 index 00000000..944bcf13 --- /dev/null +++ b/examples/bench_nc_session.rs @@ -0,0 +1,334 @@ +//! NextCloud per-request session benchmark — deep-clone vs `Arc` end-to-end. +//! +//! Every authenticated NC request (all six DAV dispatchers + OCS) extracts +//! the session. The old pipeline paid, per request: +//! +//! • extractor: `(**arc).clone()` — a DEEP clone of `NcSession` +//! (`CurrentUser` 3 Strings + `raw_username` + chroot `FolderDto` +//! ~5 Strings ≈ 8-9 heap allocs) despite the doc claiming "one Arc +//! increment"; +//! • chroot cache hit: moka `get` clones the stored `FolderDto` by value +//! (~5 more allocs) on the markerless (default-drive) branch; +//! • session build: `CurrentUser` built then cloned for the extension, +//! `raw_username` cloned, `user_id.to_string()` for the span. +//! +//! Round 9 stores `Arc` in the cache, shares one +//! `Arc` between the extension and the session, and extracts +//! `SharedNcSession` (an `Arc` handle that derefs to `NcSession`). +//! +//! `mod before` replicates the old struct shapes + clone flows verbatim; +//! equivalence gates assert every field consumed by handlers is identical. +//! +//! Sections: +//! 1. Extractor — allocs/extract + ns/extract (BEFORE deep clone vs +//! AFTER production `SharedNcSession::from_request_parts`) +//! 2. Chroot-cache hit — allocs/hit (FolderDto-by-value vs Arc) +//! 3. Session build — allocs/build (double CurrentUser + clones vs +//! single shared Arc + moves) +//! +//! Run (no Postgres needed): +//! cargo run --release --features bench --example bench_nc_session +//! Tunables (env): BENCH_REQS (100000) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::hint::black_box; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use axum::extract::FromRequestParts; +use oxicloud::application::dtos::folder_dto::FolderDto; +use oxicloud::interfaces::middleware::auth::CurrentUser; +use oxicloud::interfaces::nextcloud::session::{NcSession, SharedNcSession}; + +// ─── Counting allocator ───────────────────────────────────────────────────── + +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; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +// ─── BEFORE replicas (verbatim old shapes) ────────────────────────────────── + +mod before { + use super::*; + + /// Old `NcSession` shape: owned `CurrentUser`, chroot by value. + #[derive(Debug, Clone)] + pub struct OldNcSession { + pub user: CurrentUser, + pub raw_username: String, + pub chroot: Option, + } + + /// Old extractor body: deep clone out of the shared Arc. + pub fn extract(arc: &Arc) -> OldNcSession { + (**arc).clone() + } +} + +fn fixture_folder() -> FolderDto { + FolderDto { + id: uuid::Uuid::new_v4().to_string(), + name: "Personal".to_string(), + path: "Personal".to_string(), + parent_id: None, + drive_id: uuid::Uuid::new_v4(), + created_at: 1_700_000_000, + modified_at: 1_700_000_100, + is_root: true, + etag: "8f2e5a1c9b3d4e6f".to_string(), + icon_class: Arc::from("fas fa-folder"), + icon_special_class: Arc::from("folder-icon"), + category: Arc::from("Folder"), + created_by: None, + updated_by: None, + } +} + +fn fixture_user(id: uuid::Uuid) -> CurrentUser { + CurrentUser { + id, + username: "alice.longname".to_string(), + email: "alice.longname@example.com".to_string(), + role: "user".to_string(), + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let reqs: usize = env_or("BENCH_REQS", 100_000); + let user_id = uuid::Uuid::new_v4(); + + // ── Section 1: extractor ──────────────────────────────────────────────── + let old_session = Arc::new(before::OldNcSession { + user: fixture_user(user_id), + raw_username: "alice.longname".to_string(), + chroot: Some(fixture_folder()), + }); + let new_session = Arc::new(NcSession { + user: Arc::new(fixture_user(user_id)), + raw_username: "alice.longname".to_string(), + chroot: Some(Arc::new(fixture_folder())), + }); + + // Equivalence gate: every field handlers consume is identical. + { + let old = before::extract(&old_session); + let (mut parts, _) = axum::http::Request::builder() + .uri("/ocs/v2.php/cloud/user") + .extension(Arc::clone(&new_session)) + .body(()) + .expect("request") + .into_parts(); + let new = SharedNcSession::from_request_parts(&mut parts, &()) + .await + .expect("extract"); + assert_eq!(old.user.id, new.user.id); + assert_eq!(old.user.username, new.user.username); + assert_eq!(old.user.email, new.user.email); + assert_eq!(old.user.role, new.user.role); + assert_eq!(old.raw_username, new.raw_username); + let (oc, nc) = (old.chroot.as_ref().unwrap(), new.require_chroot().unwrap()); + assert_eq!(oc.name, nc.name); + assert_eq!(oc.path, nc.path); + assert_eq!(oc.etag, nc.etag); + println!("# equivalence gate: extracted session fields identical — OK"); + } + + // The URL cross-check runs in both arms' request flow; the BEFORE arm + // replicates only the clone (its cross-check was identical string + // compare — unchanged by round 9), so both arms time the same work + // minus the measured clone-vs-bump difference. + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + for _ in 0..reqs { + black_box(before::extract(black_box(&old_session))); + } + let before_ms = t.elapsed().as_secs_f64() * 1e3; + let before_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0; + + let (mut parts, _) = axum::http::Request::builder() + .uri("/ocs/v2.php/cloud/user") + .extension(Arc::clone(&new_session)) + .body(()) + .expect("request") + .into_parts(); + let a1 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + for _ in 0..reqs { + let s = SharedNcSession::from_request_parts(black_box(&mut parts), &()) + .await + .expect("extract"); + black_box(&s); + } + let after_ms = t.elapsed().as_secs_f64() * 1e3; + let after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1; + + println!("\n#################################################################"); + println!("# [1] NC session extractor — deep clone vs Arc handle"); + println!("# extracts={reqs}"); + println!("#################################################################\n"); + println!( + "| {:<26} | {:>10} | {:>12} | {:>14} |", + "arm", "wall ms", "allocs", "allocs/extract" + ); + println!( + "| {:<26} | {:>10.1} | {:>12} | {:>14.3} |", + "BEFORE (deep clone)", + before_ms, + before_allocs, + before_allocs as f64 / reqs as f64 + ); + println!( + "| {:<26} | {:>10.1} | {:>12} | {:>14.3} |", + "AFTER (SharedNcSession)", + after_ms, + after_allocs, + after_allocs as f64 / reqs as f64 + ); + let s1_ok = after_allocs < before_allocs && after_ms < before_ms; + + // ── Section 2: chroot-cache hit ───────────────────────────────────────── + let by_value: moka::sync::Cache = moka::sync::Cache::new(100); + let by_arc: moka::sync::Cache> = moka::sync::Cache::new(100); + let root_id = uuid::Uuid::new_v4(); + by_value.insert(root_id, fixture_folder()); + by_arc.insert(root_id, Arc::new(fixture_folder())); + + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + for _ in 0..reqs { + black_box(by_value.get(black_box(&root_id))); + } + let bv_ms = t.elapsed().as_secs_f64() * 1e3; + let bv_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0; + + let a1 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + for _ in 0..reqs { + black_box(by_arc.get(black_box(&root_id))); + } + let ba_ms = t.elapsed().as_secs_f64() * 1e3; + let ba_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1; + + println!("\n#################################################################"); + println!("# [2] chroot-cache hit — FolderDto by value vs Arc"); + println!("# hits={reqs}"); + println!("#################################################################\n"); + println!( + "| {:<26} | {:>10} | {:>12} | {:>12} |", + "arm", "wall ms", "allocs", "allocs/hit" + ); + println!( + "| {:<26} | {:>10.1} | {:>12} | {:>12.3} |", + "BEFORE (by value)", + bv_ms, + bv_allocs, + bv_allocs as f64 / reqs as f64 + ); + println!( + "| {:<26} | {:>10.1} | {:>12} | {:>12.3} |", + "AFTER (Arc)", + ba_ms, + ba_allocs, + ba_allocs as f64 / reqs as f64 + ); + let s2_ok = ba_allocs < bv_allocs; + + // ── Section 3: session build ──────────────────────────────────────────── + // BEFORE: build CurrentUser, clone it for the extension Arc, clone + // raw_username, `to_string` the span value. AFTER: one Arc shared by + // extension + session, raw_username moved, span rendered lazily (the + // lazy render costs nothing here; the removed `to_string` did). + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + for _ in 0..reqs { + let raw_username = String::from("alice.longname"); + let span_value = user_id.to_string(); + let current_user = fixture_user(user_id); + let ext = Arc::new(current_user.clone()); + let session = Arc::new(before::OldNcSession { + user: current_user, + raw_username: raw_username.clone(), + chroot: None, + }); + black_box((&span_value, &ext, &session)); + } + let sb_ms = t.elapsed().as_secs_f64() * 1e3; + let sb_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0; + + let a1 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + for _ in 0..reqs { + let raw_username = String::from("alice.longname"); + let current_user = Arc::new(fixture_user(user_id)); + let ext = Arc::clone(¤t_user); + let session = Arc::new(NcSession { + user: current_user, + raw_username, + chroot: None, + }); + black_box((&ext, &session)); + } + let sa_ms = t.elapsed().as_secs_f64() * 1e3; + let sa_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1; + + println!("\n#################################################################"); + println!("# [3] session build — double CurrentUser + clones vs shared Arc"); + println!("# builds={reqs}"); + println!("#################################################################\n"); + println!( + "| {:<26} | {:>10} | {:>12} | {:>12} |", + "arm", "wall ms", "allocs", "allocs/build" + ); + println!( + "| {:<26} | {:>10.1} | {:>12} | {:>12.3} |", + "BEFORE (clone x2 + span)", + sb_ms, + sb_allocs, + sb_allocs as f64 / reqs as f64 + ); + println!( + "| {:<26} | {:>10.1} | {:>12} | {:>12.3} |", + "AFTER (shared Arc)", + sa_ms, + sa_allocs, + sa_allocs as f64 / reqs as f64 + ); + let s3_ok = sa_allocs < sb_allocs; + + if !(s1_ok && s2_ok && s3_ok) { + eprintln!("\nGATE FAIL: (extractor={s1_ok} cache={s2_ok} build={s3_ok}) — rollback"); + std::process::exit(1); + } + println!("\nGATE PASS: all three session stages allocate less with identical fields."); +} diff --git a/examples/bench_resource_row_map.rs b/examples/bench_resource_row_map.rs index 0f85c960..ae069dba 100644 --- a/examples/bench_resource_row_map.rs +++ b/examples/bench_resource_row_map.rs @@ -7,6 +7,14 @@ //! category classes first (they borrow `&row.name`), then MOVES `row.name` //! into the DTO — the same output, one fewer alloc per row. //! +//! Section 2 (round 9): the SAME clone-vs-move port applied to the +//! favorites/recents listings (`/api/favorites/resources`, +//! `/api/recent/resources`), which the round-7 rewrite never reached. Their +//! per-row mapping additionally cloned `row.path` (owner rows) and +//! `row.blob_hash` (file rows), so the saving is up to 3 allocs per file row. +//! The two handlers share one mapping shape (only the `favorited_at` / +//! `accessed_at` passthrough differs), so the favorites row stands for both. +//! //! Run: //! cargo run --release --features bench --example bench_resource_row_map //! Tunables (env): BENCH_ROWS (500). @@ -21,6 +29,7 @@ use oxicloud::application::dtos::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, intern_display, intern_mime, }; +use oxicloud::application::dtos::favorites_dto::FavoriteResourceRow; use oxicloud::application::dtos::file_dto::FileDto; use oxicloud::application::dtos::folder_dto::{FolderDto, FolderResourceRow}; use oxicloud::domain::entities::file::File; @@ -223,6 +232,218 @@ fn map_after(rows: Vec) -> Vec { .collect() } +// ── Section 2: favorites/recents row→DTO mapping (round 9 port) ───────────── + +fn fav_rows(n: usize) -> Vec { + let ts: DateTime = Utc.timestamp_opt(1_700_000_000, 0).unwrap(); + (0..n) + .map(|i| { + let is_folder = i % 4 == 0; + FavoriteResourceRow { + resource_type: if is_folder { "folder" } else { "file" }.to_string(), + resource_id: Uuid::new_v4(), + name: if is_folder { + format!("Folder {i:05}") + } else { + format!("document-{i:05}.pdf") + }, + parent_id: Some(Uuid::new_v4()), + mime_type: if is_folder { + None + } else { + Some("application/pdf".to_string()) + }, + size: if is_folder { -1 } else { 4096 }, + resource_created_at: ts, + modified_at: ts, + drive_id: Uuid::new_v4(), + blob_hash: if is_folder { + None + } else { + Some("a".repeat(64)) + }, + is_owner: true, + favorited_at: ts, + path: Some(format!("Documents/Work/item-{i:05}")), + sort_str: Some(format!("row {i}")), + sort_int: None, + sort_ts: None, + } + }) + .collect() +} + +/// (name, path, content_hash, icon_class, category) — every field the +/// clone→move rewrite touches on the favorites/recents mapping. +type FavProbe = ( + String, + String, + String, + std::sync::Arc, + std::sync::Arc, +); + +/// BEFORE — verbatim favorites/recents mapping: `row.path.clone()`, +/// `row.name.clone()` (both branches) and `row.blob_hash.clone()`. +fn fav_map_before(rows: Vec) -> Vec { + rows.into_iter() + .map(|row| { + let path = if row.is_owner { + row.path.clone().unwrap_or_default() + } else { + String::new() + }; + if row.resource_type == "folder" { + let resource_id = row.resource_id.to_string(); + let dto = FolderDto { + etag: resource_id.clone(), + id: resource_id, + name: row.name.clone(), + path, + parent_id: row.parent_id.map(|u| u.to_string()), + drive_id: row.drive_id, + created_at: row.resource_created_at.timestamp() as u64, + modified_at: row.modified_at.timestamp() as u64, + is_root: false, + icon_class: intern_display("fas fa-folder"), + icon_special_class: intern_display("folder-icon"), + category: intern_display("Folder"), + created_by: None, + updated_by: None, + }; + ( + dto.name, + dto.path, + String::new(), + dto.icon_class, + dto.category, + ) + } else { + let mime = row + .mime_type + .as_deref() + .unwrap_or("application/octet-stream"); + let size_bytes = row.size.max(0) as u64; + let modified_at_u = row.modified_at.timestamp() as u64; + let content_hash = row.blob_hash.clone().unwrap_or_default(); + let etag = if content_hash.is_empty() { + String::new() + } else { + File::compute_etag(&content_hash, modified_at_u) + }; + let dto = FileDto { + id: row.resource_id.to_string(), + name: row.name.clone(), + path, + size: size_bytes, + mime_type: intern_mime(mime), + folder_id: row.parent_id.map(|u| u.to_string()), + created_at: row.resource_created_at.timestamp() as u64, + modified_at: modified_at_u, + icon_class: intern_display(icon_class_for(&row.name, mime)), + icon_special_class: intern_display(icon_special_class_for(&row.name, mime)), + category: intern_display(category_for(&row.name, mime)), + size_formatted: format_file_size(size_bytes), + sort_date: None, + content_hash, + etag, + created_by: None, + updated_by: None, + }; + ( + dto.name, + dto.path, + dto.content_hash, + dto.icon_class, + dto.category, + ) + } + }) + .collect() +} + +/// AFTER — the round-9 handler code: `path`/`blob_hash` moved, classes +/// computed before `row.name` moves. +fn fav_map_after(rows: Vec) -> Vec { + rows.into_iter() + .map(|row| { + let path = if row.is_owner { + row.path.unwrap_or_default() + } else { + String::new() + }; + if row.resource_type == "folder" { + let resource_id = row.resource_id.to_string(); + let dto = FolderDto { + etag: resource_id.clone(), + id: resource_id, + name: row.name, + path, + parent_id: row.parent_id.map(|u| u.to_string()), + drive_id: row.drive_id, + created_at: row.resource_created_at.timestamp() as u64, + modified_at: row.modified_at.timestamp() as u64, + is_root: false, + icon_class: intern_display("fas fa-folder"), + icon_special_class: intern_display("folder-icon"), + category: intern_display("Folder"), + created_by: None, + updated_by: None, + }; + ( + dto.name, + dto.path, + String::new(), + dto.icon_class, + dto.category, + ) + } else { + let mime = row + .mime_type + .as_deref() + .unwrap_or("application/octet-stream"); + let size_bytes = row.size.max(0) as u64; + let modified_at_u = row.modified_at.timestamp() as u64; + let content_hash = row.blob_hash.unwrap_or_default(); + let etag = if content_hash.is_empty() { + String::new() + } else { + File::compute_etag(&content_hash, modified_at_u) + }; + let icon_class = intern_display(icon_class_for(&row.name, mime)); + let icon_special_class = intern_display(icon_special_class_for(&row.name, mime)); + let category = intern_display(category_for(&row.name, mime)); + let dto = FileDto { + id: row.resource_id.to_string(), + name: row.name, + path, + size: size_bytes, + mime_type: intern_mime(mime), + folder_id: row.parent_id.map(|u| u.to_string()), + created_at: row.resource_created_at.timestamp() as u64, + modified_at: modified_at_u, + icon_class, + icon_special_class, + category, + size_formatted: format_file_size(size_bytes), + sort_date: None, + content_hash, + etag, + created_by: None, + updated_by: None, + }; + ( + dto.name, + dto.path, + dto.content_hash, + dto.icon_class, + dto.category, + ) + } + }) + .collect() +} + fn main() { let n: usize = env_or("BENCH_ROWS", 500); @@ -279,4 +500,56 @@ fn main() { before_allocs.saturating_sub(after_allocs), (before_allocs.saturating_sub(after_allocs)) as f64 / n as f64 ); + + // ── Section 2: favorites/recents mapping (round-9 port) ──────────────── + if fav_map_before(fav_rows(n)) != fav_map_after(fav_rows(n)) { + eprintln!("EQUIVALENCE GATE FAILED: favorites mapping output differs"); + std::process::exit(1); + } + std::hint::black_box(fav_map_before(fav_rows(n))); + std::hint::black_box(fav_map_after(fav_rows(n))); + + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + std::hint::black_box(fav_map_before(fav_rows(n))); + let fb_ms = t.elapsed().as_secs_f64() * 1e3; + let fb_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0; + + let a1 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + std::hint::black_box(fav_map_after(fav_rows(n))); + let fa_ms = t.elapsed().as_secs_f64() * 1e3; + let fa_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1; + + println!("\n#################################################################"); + println!("# [2] favorites/recents row→DTO mapping: clone path+name+hash vs move"); + println!("# rows={n} (same mapping shape in both handlers)"); + println!("#################################################################\n"); + println!( + "| {:<20} | {:>12} | {:>10} | {:>14} |", + "arm", "allocs", "wall ms", "allocs/row" + ); + println!( + "| {:<20} | {:>12} | {:>10.3} | {:>14.3} |", + "BEFORE (clone)", + fb_allocs, + fb_ms, + fb_allocs as f64 / n as f64 + ); + println!( + "| {:<20} | {:>12} | {:>10.3} | {:>14.3} |", + "AFTER (move)", + fa_allocs, + fa_ms, + fa_allocs as f64 / n as f64 + ); + println!( + "\nSaved {} allocs ({:.2}/row) — path + name + blob_hash clones removed.", + fb_allocs.saturating_sub(fa_allocs), + (fb_allocs.saturating_sub(fa_allocs)) as f64 / n as f64 + ); + if fa_allocs >= fb_allocs { + eprintln!("GATE FAIL: AFTER allocs not below BEFORE — rollback"); + std::process::exit(1); + } } diff --git a/examples/bench_s3_put.rs b/examples/bench_s3_put.rs index 54363a05..19902152 100644 --- a/examples/bench_s3_put.rs +++ b/examples/bench_s3_put.rs @@ -13,7 +13,20 @@ //! //! Section 2 measures the removed Azure `data.to_vec()` copy in isolation. //! -//! Gates: AFTER request count == chunks (vs 2x), AFTER wall < BEFORE wall. +//! Section 3 (round 9) drives the same A/B **through the decorator stacks** +//! (`RetryBlobBackend`, `CachedBlobBackend`, and the full production +//! Cache(Encrypted(Retry(S3))) composition). Until round 9 neither Retry nor +//! Cached overrode `put_blob_from_bytes_unsynced`/`sync_blobs`, so the trait +//! default silently re-routed every decorated chunk write back through the +//! probing synced path — undoing this bench's own Section-1 win on every +//! remote deployment with retry or cache enabled. The BEFORE arm is the +//! still-present synced route (`put_blob_from_bytes`, byte-identical requests +//! to what the fallthrough produced); the AFTER arm is the now-forwarded +//! unsynced route. A write-through equivalence gate asserts the Cached stack +//! still populates its local cache identically on both routes. +//! +//! Gates: AFTER request count == chunks (vs 2x), AFTER wall < BEFORE wall, +//! per-stack AFTER HEADs == 0, cache population identical on both routes. //! //! No Postgres. Run: //! cargo run --release --features bench --example bench_s3_put @@ -28,6 +41,9 @@ use std::time::{Duration, Instant}; use bytes::Bytes; use oxicloud::application::ports::blob_storage_ports::BlobStorageBackend; use oxicloud::common::config::S3StorageConfig; +use oxicloud::infrastructure::services::cached_blob_backend::{BlobCacheConfig, CachedBlobBackend}; +use oxicloud::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend; +use oxicloud::infrastructure::services::retry_blob_backend::{RetryBlobBackend, RetryPolicy}; use oxicloud::infrastructure::services::s3_blob_backend::S3BlobBackend; fn env_or(key: &str, default: T) -> T { @@ -37,6 +53,23 @@ fn env_or(key: &str, default: T) -> T { .unwrap_or(default) } +/// Recursively count regular files under `dir` (the blob cache shards blobs +/// into 2-hex-char prefix subdirectories). +fn count_files(dir: &std::path::Path) -> usize { + let mut n = 0; + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + n += count_files(&path); + } else { + n += 1; + } + } + } + n +} + #[derive(Clone, Default)] struct Counters { heads: Arc, @@ -75,11 +108,12 @@ async fn stub_s3(latency: Duration, counters: Counters) -> String { } async fn drive( - backend: Arc, + backend: Arc, chunks: usize, chunk_kb: usize, concurrency: usize, unsynced: bool, + hash_prefix: &str, ) -> f64 { let payload = Bytes::from(vec![0x5au8; chunk_kb * 1024]); let sem = Arc::new(tokio::sync::Semaphore::new(concurrency)); @@ -89,15 +123,17 @@ async fn drive( let b = backend.clone(); let p = payload.clone(); let sem = sem.clone(); + let hash = format!("{hash_prefix}{i:060x}"); set.spawn(async move { let _permit = sem.acquire().await.expect("sem"); - let hash = format!("{i:064x}"); let n = if unsynced { b.put_blob_from_bytes_unsynced(&hash, p).await.expect("put") } else { b.put_blob_from_bytes(&hash, p).await.expect("put") }; - assert_eq!(n as usize, chunk_kb * 1024); + // Encrypted arms return the ciphertext size (plaintext + AEAD + // framing), so gate on >= rather than == for stack generality. + assert!(n as usize >= chunk_kb * 1024); }); } while let Some(r) = set.join_next().await { @@ -106,6 +142,81 @@ async fn drive( t.elapsed().as_secs_f64() * 1000.0 } +/// Run BEFORE (synced route == the pre-round-9 unsynced fallthrough) and +/// AFTER (forwarded unsynced route) through one backend stack, printing the +/// two rows and gating AFTER on zero probe requests. `prefixes` carries the +/// (BEFORE, AFTER) hash namespaces keeping the arms' key spaces disjoint. +async fn stack_ab( + label: &str, + backend: Arc, + counters: &Counters, + chunks: usize, + chunk_kb: usize, + concurrency: usize, + prefixes: (&str, &str), +) -> (f64, f64) { + let (prefix_before, prefix_after) = prefixes; + let before = drive( + backend.clone(), + chunks, + chunk_kb, + concurrency, + false, + prefix_before, + ) + .await; + let before_heads = counters.heads.swap(0, Ordering::Relaxed); + let before_puts = counters.puts.swap(0, Ordering::Relaxed); + println!( + "{:<34} {:>10.0} {:>8} {:>8} {:>8}", + format!("{label} BEFORE (synced route)"), + before, + before_heads, + before_puts, + "1.0x" + ); + + let after = drive( + backend.clone(), + chunks, + chunk_kb, + concurrency, + true, + prefix_after, + ) + .await; + let after_heads = counters.heads.swap(0, Ordering::Relaxed); + let after_puts = counters.puts.swap(0, Ordering::Relaxed); + println!( + "{:<34} {:>10.0} {:>8} {:>8} {:>8}", + format!("{label} AFTER (unsynced)"), + after, + after_heads, + after_puts, + format!("{:.1}x", before / after) + ); + + if before_heads != chunks as u64 { + eprintln!( + "GATE FAIL [{label}]: BEFORE issued {before_heads} HEADs (expected {chunks} — the probing route must still probe)" + ); + std::process::exit(1); + } + if after_heads != 0 || after_puts != chunks as u64 { + eprintln!( + "GATE FAIL [{label}]: AFTER issued {after_heads} HEADs / {after_puts} PUTs (expected 0 / {chunks})" + ); + std::process::exit(1); + } + if after >= before { + eprintln!( + "GATE FAIL [{label}]: AFTER ({after:.0} ms) not faster than BEFORE ({before:.0} ms) — rollback" + ); + std::process::exit(1); + } + (before, after) +} + #[tokio::main(flavor = "multi_thread")] async fn main() { let chunks: usize = env_or("BENCH_CHUNKS", 500); @@ -133,7 +244,15 @@ async fn main() { ); // BEFORE: the trait-default route (put_blob_from_bytes = HEAD + PUT). - let before = drive(backend.clone(), chunks, chunk_kb, concurrency, false).await; + let before = drive( + backend.clone() as Arc, + chunks, + chunk_kb, + concurrency, + false, + "a0a0", + ) + .await; let before_heads = counters.heads.swap(0, Ordering::Relaxed); let before_puts = counters.puts.swap(0, Ordering::Relaxed); println!( @@ -142,7 +261,15 @@ async fn main() { ); // AFTER: the unsynced override (PUT only). - let after = drive(backend.clone(), chunks, chunk_kb, concurrency, true).await; + let after = drive( + backend.clone() as Arc, + chunks, + chunk_kb, + concurrency, + true, + "a0a1", + ) + .await; let after_heads = counters.heads.swap(0, Ordering::Relaxed); let after_puts = counters.puts.swap(0, Ordering::Relaxed); println!( @@ -168,6 +295,91 @@ async fn main() { "\n# [2] removed Azure per-chunk copy: to_vec() of {mb} MiB = {copy_ms:.2} ms + {mb} MiB transient alloc per chunk" ); + // ── Section 3: the same A/B through the decorator stacks ──────────── + println!( + "\n# [3] decorated stacks — pre-round-9 the unsynced call fell through to the synced (probing) route" + ); + println!( + "{:<34} {:>10} {:>8} {:>8} {:>8}", + "variant", "wall ms", "HEADs", "PUTs", "vs OLD" + ); + + // Retry(S3) + let retry_stack: Arc = Arc::new(RetryBlobBackend::new( + backend.clone() as Arc, + RetryPolicy::default(), + )); + stack_ab( + "retry(s3)", + retry_stack, + &counters, + chunks, + chunk_kb, + concurrency, + ("b0b0", "b0b1"), + ) + .await; + + // Cache(S3) — count cache write-through population on both routes. + let cache_dir_a = tempfile::tempdir().expect("tempdir"); + let cached_stack: Arc = Arc::new(CachedBlobBackend::new( + backend.clone() as Arc, + &BlobCacheConfig { + cache_dir: cache_dir_a.path().to_path_buf(), + max_cache_bytes: u64::MAX, + }, + )); + stack_ab( + "cache(s3)", + cached_stack, + &counters, + chunks, + chunk_kb, + concurrency, + ("c0c0", "c0c1"), + ) + .await; + // Write-through equivalence gate: BOTH routes populated the local cache + // (the round-9 override keeps post-upload read locality intact). + let cached_files = count_files(cache_dir_a.path()); + if cached_files != 2 * chunks { + eprintln!( + "GATE FAIL [cache(s3)]: cache holds {cached_files} blobs (expected {} — write-through must populate on BOTH routes)", + 2 * chunks + ); + std::process::exit(1); + } + + // Full production composition: Cache(Encrypted(Retry(S3))). + let cache_dir_b = tempfile::tempdir().expect("tempdir"); + let full_stack: Arc = Arc::new(CachedBlobBackend::new( + Arc::new(EncryptedBlobBackend::new( + Arc::new(RetryBlobBackend::new( + backend.clone() as Arc, + RetryPolicy::default(), + )), + &[0x42u8; 32], + )), + &BlobCacheConfig { + cache_dir: cache_dir_b.path().to_path_buf(), + max_cache_bytes: u64::MAX, + }, + )); + let (full_before, full_after) = stack_ab( + "cache(enc(retry(s3)))", + full_stack, + &counters, + chunks, + chunk_kb, + concurrency, + ("d0d0", "d0d1"), + ) + .await; + println!( + "# full stack: a {chunks}-chunk upload sheds {} probe round-trips ({:.0} -> {:.0} ms at {rtt_ms} ms RTT)", + chunks, full_before, full_after + ); + // ── Gates ─────────────────────────────────────────────────────────── if after_heads != 0 || after_puts != chunks as u64 { eprintln!( diff --git a/examples/bench_search_cache_mem.rs b/examples/bench_search_cache_mem.rs index f451b3e1..d454e0ec 100644 --- a/examples/bench_search_cache_mem.rs +++ b/examples/bench_search_cache_mem.rs @@ -133,15 +133,15 @@ fn synth_entry(idx: u64) -> Arc { name, path, size: 831_942, - mime_type: MIMES[row % MIMES.len()].to_string(), + mime_type: MIMES[row % MIMES.len()].into(), 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(), + icon_class: "fas fa-file-pdf".into(), + icon_special_class: "pdf-icon".into(), + category: "document".into(), blob_hash: pseudo_hex(&mut rng, 64), snippet: content_hit.then(|| SNIPPET.to_string()), match_source: Some(match_source.to_string()), diff --git a/examples/bench_search_enrich.rs b/examples/bench_search_enrich.rs new file mode 100644 index 00000000..99efc057 --- /dev/null +++ b/examples/bench_search_enrich.rs @@ -0,0 +1,566 @@ +//! Search-result enrichment benchmark — borrow+clone+reclassify vs consume. +//! +//! `SearchService::enrich_file` took `&FileDto`, cloned every owned `String` +//! out of it (id/name/path/folder_id/content_hash), allocated fresh `String`s +//! for `mime_type` + the three display fields, and RE-RAN the three display +//! classifiers (`icon_class_for` / `icon_special_class_for` / `category_for`) +//! whose results the `FileDto` already carried interned (`Arc`, computed +//! once in `FileDto::from`). The recursive search branch runs this map over +//! the ENTIRE pre-pagination match set, so a subtree query matching thousands +//! of files paid ~11 allocs + 3 classifier passes per row. `enrich_folder` +//! cloned its 4 strings the same way, and the NC REPORT conversion +//! (`file_dto_from_search`) re-ran all three classifiers a SECOND time per +//! emitted row. +//! +//! Round 9 changes `SearchFileResultDto.{mime_type,icon_class, +//! icon_special_class,category}` to `Arc`, makes both enrichers consume +//! their DTO (strings move, interned fields transfer as refcount bumps), and +//! has the NC conversion reuse the carried values. +//! +//! `mod before` holds the pre-round-9 logic verbatim (old struct shape +//! included); the equivalence gate asserts field-by-field identical output +//! for every row, and the NC-conversion gate asserts the reused display +//! fields byte-equal a fresh classifier run. +//! +//! Sections: +//! 1. enrich_file — ns/row + allocs/row, BEFORE vs AFTER +//! 2. enrich_folder — ns/row + allocs/row, BEFORE vs AFTER +//! 3. NC REPORT search→FileDto conversion — allocs/row, BEFORE vs AFTER +//! +//! Run (no Postgres needed): +//! cargo run --release --features bench --example bench_search_enrich +//! Tunables (env): BENCH_ROWS (10000), BENCH_PASSES (50) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::hint::black_box; +use std::sync::Arc; +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::application::services::search_service::SearchService; + +// ─── Counting allocator ───────────────────────────────────────────────────── + +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; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +// ─── BEFORE: verbatim pre-round-9 logic ───────────────────────────────────── + +#[allow(clippy::all)] +mod before { + use oxicloud::application::dtos::display_helpers::{ + category_for, format_file_size, 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; + + /// Old `SearchFileResultDto` shape — all-String display fields. + pub struct OldSearchFileResultDto { + pub id: String, + pub name: String, + pub path: String, + pub size: u64, + pub mime_type: String, + pub folder_id: Option, + pub created_at: u64, + pub modified_at: u64, + pub relevance_score: u32, + pub size_formatted: String, + pub icon_class: String, + pub icon_special_class: String, + pub category: String, + pub blob_hash: String, + pub snippet: Option, + pub match_source: Option, + } + + pub struct OldSearchFolderResultDto { + pub id: String, + pub name: String, + pub path: String, + pub parent_id: Option, + pub drive_id: uuid::Uuid, + pub created_at: u64, + pub modified_at: u64, + pub is_root: bool, + pub relevance_score: u32, + } + + // Verbatim copies of the old private helpers. + fn get_icon_class(name: &str, mime: &str) -> String { + icon_class_for(name, mime).to_string() + } + fn get_icon_special_class(name: &str, mime: &str) -> String { + icon_special_class_for(name, mime).to_string() + } + fn get_category(name: &str, mime: &str) -> String { + category_for(name, mime).to_string() + } + + /// Verbatim copy of the service's private `format_bytes` (unchanged by + /// round 9; the equivalence gate asserts it still matches production). + pub fn format_bytes(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + if bytes == 0 { + return "0 B".to_string(); + } + let exp = (bytes as f64).log(1024.0).floor() as usize; + let exp = exp.min(UNITS.len() - 1); + let value = bytes as f64 / 1024_f64.powi(exp as i32); + if exp == 0 { + format!("{} B", bytes) + } else { + format!("{:.1} {}", value, UNITS[exp]) + } + } + + /// Verbatim copy of the service's private `compute_relevance` (unchanged + /// by round 9; the equivalence gate asserts it still matches production). + pub fn compute_relevance(name: &str, query_lower: &str) -> u32 { + let name_lower = name.to_lowercase(); + + if name_lower == query_lower { + 100 + } else if name_lower.starts_with(query_lower) { + 80 + } else if name_lower.contains(query_lower) { + // Bonus for shorter names (more specific match) + let ratio = query_lower.len() as f64 / name_lower.len() as f64; + 50 + (ratio * 20.0) as u32 + } else { + 0 + } + } + + /// Verbatim old `enrich_file` (borrowing, cloning, re-classifying). + pub fn enrich_file(file: &FileDto, query_lower: &str) -> OldSearchFileResultDto { + let relevance = if query_lower.is_empty() { + 50 + } else { + compute_relevance(&file.name, query_lower) + }; + + OldSearchFileResultDto { + id: file.id.clone(), + name: file.name.clone(), + path: file.path.clone(), + size: file.size, + mime_type: file.mime_type.to_string(), + folder_id: file.folder_id.clone(), + created_at: file.created_at, + modified_at: file.modified_at, + relevance_score: relevance, + size_formatted: format_bytes(file.size), + icon_class: get_icon_class(&file.name, &file.mime_type), + icon_special_class: get_icon_special_class(&file.name, &file.mime_type), + category: get_category(&file.name, &file.mime_type), + blob_hash: file.content_hash.clone(), + snippet: None, + match_source: (!query_lower.is_empty() && relevance > 0).then(|| "name".to_string()), + } + } + + /// Verbatim old `enrich_folder`. + pub fn enrich_folder(folder: &FolderDto, query_lower: &str) -> OldSearchFolderResultDto { + let relevance = if query_lower.is_empty() { + 50 + } else { + compute_relevance(&folder.name, query_lower) + }; + + OldSearchFolderResultDto { + id: folder.id.clone(), + name: folder.name.clone(), + path: folder.path.clone(), + parent_id: folder.parent_id.clone(), + drive_id: folder.drive_id, + created_at: folder.created_at, + modified_at: folder.modified_at, + is_root: folder.is_root, + relevance_score: relevance, + } + } + + /// Verbatim old NC REPORT `file_dto_from_search` body (String-field + /// input shape) — re-runs all three classifiers per converted row. + pub fn file_dto_from_search(fr: &OldSearchFileResultDto) -> FileDto { + let etag = if fr.blob_hash.is_empty() { + String::new() + } else { + File::compute_etag(&fr.blob_hash, fr.modified_at) + }; + FileDto { + id: fr.id.clone(), + name: fr.name.clone(), + path: fr.path.clone(), + size: fr.size, + mime_type: fr.mime_type.clone().into(), + folder_id: fr.folder_id.clone(), + created_at: fr.created_at, + modified_at: fr.modified_at, + icon_class: icon_class_for(&fr.name, &fr.mime_type).to_string().into(), + icon_special_class: icon_special_class_for(&fr.name, &fr.mime_type) + .to_string() + .into(), + category: category_for(&fr.name, &fr.mime_type).to_string().into(), + size_formatted: format_file_size(fr.size), + sort_date: None, + content_hash: fr.blob_hash.clone(), + etag, + created_by: None, + updated_by: None, + } + } +} + +// ─── Fixture ──────────────────────────────────────────────────────────────── + +const NAMES: [(&str, &str); 5] = [ + ("report-{i}.pdf", "application/pdf"), + ("photo-{i}.jpg", "image/jpeg"), + ("notes-{i}.txt", "text/plain"), + ("track-{i}.mp3", "audio/mpeg"), + ("data-{i}.bin", "application/octet-stream"), +]; + +fn file_dtos(n: usize) -> Vec { + (0..n) + .map(|i| { + let (name_t, mime) = NAMES[i % NAMES.len()]; + let name = name_t.replace("{i}", &format!("{i:05}")); + let file = oxicloud::domain::entities::file::File::from_materialized_row( + uuid::Uuid::new_v4().to_string(), + name, + Some("Documents/Work"), + 4096 + i as u64, + mime.to_string(), + Some(uuid::Uuid::new_v4().to_string()), + 1_700_000_000, + 1_700_000_100, + "a".repeat(64), + None, + None, + ) + .expect("fixture file"); + FileDto::from(file) + }) + .collect() +} + +fn folder_dtos(n: usize) -> Vec { + (0..n) + .map(|i| FolderDto { + id: uuid::Uuid::new_v4().to_string(), + name: format!("Folder {i:05}"), + path: format!("Documents/Folder-{i:05}"), + parent_id: Some(uuid::Uuid::new_v4().to_string()), + drive_id: uuid::Uuid::new_v4(), + created_at: 1_700_000_000, + modified_at: 1_700_000_100, + is_root: false, + etag: format!("{i:032x}"), + icon_class: Arc::from("fas fa-folder"), + icon_special_class: Arc::from("folder-icon"), + category: Arc::from("Folder"), + created_by: None, + updated_by: None, + }) + .collect() +} + +fn p50(mut v: Vec) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v[v.len() / 2] +} + +fn main() { + let n: usize = env_or("BENCH_ROWS", 10_000); + let passes: usize = env_or("BENCH_PASSES", 50); + let query_lower = "report"; + + // ── Equivalence gate: field-by-field identical enrichment ─────────────── + { + let dtos = file_dtos(500); + for dto in &dtos { + let old = before::enrich_file(dto, query_lower); + let new = SearchService::enrich_file_for_bench(dto.clone(), query_lower); + let same = old.id == new.id + && old.name == new.name + && old.path == new.path + && old.size == new.size + && old.mime_type == *new.mime_type + && old.folder_id == new.folder_id + && old.created_at == new.created_at + && old.modified_at == new.modified_at + && old.relevance_score == new.relevance_score + && old.size_formatted == new.size_formatted + && old.icon_class == *new.icon_class + && old.icon_special_class == *new.icon_special_class + && old.category == *new.category + && old.blob_hash == new.blob_hash + && old.snippet == new.snippet + && old.match_source == new.match_source; + if !same { + eprintln!("EQUIVALENCE GATE FAILED (file): {} differs", old.name); + std::process::exit(1); + } + } + let folders = folder_dtos(500); + for dto in &folders { + let old = before::enrich_folder(dto, query_lower); + let new = SearchService::enrich_folder_for_bench(dto.clone(), query_lower); + let same = old.id == new.id + && old.name == new.name + && old.path == new.path + && old.parent_id == new.parent_id + && old.drive_id == new.drive_id + && old.created_at == new.created_at + && old.modified_at == new.modified_at + && old.is_root == new.is_root + && old.relevance_score == new.relevance_score; + if !same { + eprintln!("EQUIVALENCE GATE FAILED (folder): {} differs", old.name); + std::process::exit(1); + } + } + println!("# equivalence gate: 500 files + 500 folders field-identical — OK"); + } + + // ── NC REPORT conversion gate: carried display fields == fresh run ────── + { + let dtos = file_dtos(500); + for dto in dtos { + let old_row = before::enrich_file(&dto, ""); + let new_row = SearchService::enrich_file_for_bench(dto, ""); + let old_conv = before::file_dto_from_search(&old_row); + let new_conv = + oxicloud::interfaces::nextcloud::report_handler::file_dto_from_search_for_bench( + &new_row, + ); + let same = old_conv.id == new_conv.id + && old_conv.name == new_conv.name + && old_conv.mime_type == new_conv.mime_type + && old_conv.icon_class == new_conv.icon_class + && old_conv.icon_special_class == new_conv.icon_special_class + && old_conv.category == new_conv.category + && old_conv.size_formatted == new_conv.size_formatted + && old_conv.etag == new_conv.etag + && old_conv.content_hash == new_conv.content_hash; + if !same { + eprintln!("NC CONVERSION GATE FAILED: {} differs", old_conv.name); + std::process::exit(1); + } + } + println!("# NC REPORT conversion gate: 500 rows field-identical — OK"); + } + + // ── Section 1: enrich_file wall + allocs ──────────────────────────────── + let mut before_wall = Vec::with_capacity(passes); + let mut after_wall = Vec::with_capacity(passes); + let mut before_allocs = 0u64; + let mut after_allocs = 0u64; + + for pass in 0..passes { + // BEFORE consumes borrowed rows: reuse one input set per pass, built + // outside the measured window (both arms see identical inputs). + let input = file_dtos(n); + + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + let out: Vec<_> = input + .iter() + .map(|f| before::enrich_file(f, query_lower)) + .collect(); + before_wall.push(t.elapsed().as_secs_f64() * 1e9 / n as f64); + if pass == 0 { + before_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0; + } + black_box(&out); + drop(out); + + let a1 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + let out: Vec<_> = input + .into_iter() + .map(|f| SearchService::enrich_file_for_bench(f, query_lower)) + .collect(); + after_wall.push(t.elapsed().as_secs_f64() * 1e9 / n as f64); + if pass == 0 { + after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1; + } + black_box(&out); + } + + println!("\n#################################################################"); + println!("# [1] enrich_file — borrow+clone+reclassify vs consume"); + println!("# rows={n} passes={passes} (p50 of per-pass ns/row; allocs from pass 0)"); + println!("#################################################################\n"); + println!( + "| {:<22} | {:>10} | {:>12} | {:>12} |", + "arm", "ns/row", "allocs", "allocs/row" + ); + println!( + "| {:<22} | {:>10.1} | {:>12} | {:>12.3} |", + "BEFORE (borrow+clone)", + p50(before_wall.clone()), + before_allocs, + before_allocs as f64 / n as f64 + ); + println!( + "| {:<22} | {:>10.1} | {:>12} | {:>12.3} |", + "AFTER (consume)", + p50(after_wall.clone()), + after_allocs, + after_allocs as f64 / n as f64 + ); + let s1_ok = after_allocs < before_allocs; + + // ── Section 2: enrich_folder ──────────────────────────────────────────── + let mut fb_wall = Vec::with_capacity(passes); + let mut fa_wall = Vec::with_capacity(passes); + let mut fb_allocs = 0u64; + let mut fa_allocs = 0u64; + for pass in 0..passes { + let input = folder_dtos(n); + + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + let out: Vec<_> = input + .iter() + .map(|f| before::enrich_folder(f, query_lower)) + .collect(); + fb_wall.push(t.elapsed().as_secs_f64() * 1e9 / n as f64); + if pass == 0 { + fb_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0; + } + black_box(&out); + drop(out); + + let a1 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + let out: Vec<_> = input + .into_iter() + .map(|f| SearchService::enrich_folder_for_bench(f, query_lower)) + .collect(); + fa_wall.push(t.elapsed().as_secs_f64() * 1e9 / n as f64); + if pass == 0 { + fa_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1; + } + black_box(&out); + } + + println!("\n#################################################################"); + println!("# [2] enrich_folder — borrow+clone vs consume"); + println!("#################################################################\n"); + println!( + "| {:<22} | {:>10} | {:>12} | {:>12} |", + "arm", "ns/row", "allocs", "allocs/row" + ); + println!( + "| {:<22} | {:>10.1} | {:>12} | {:>12.3} |", + "BEFORE (borrow+clone)", + p50(fb_wall.clone()), + fb_allocs, + fb_allocs as f64 / n as f64 + ); + println!( + "| {:<22} | {:>10.1} | {:>12} | {:>12.3} |", + "AFTER (consume)", + p50(fa_wall.clone()), + fa_allocs, + fa_allocs as f64 / n as f64 + ); + let s2_ok = fa_allocs < fb_allocs; + + // ── Section 3: NC REPORT conversion ───────────────────────────────────── + let conv_n = n.min(5_000); + let old_rows: Vec<_> = file_dtos(conv_n) + .iter() + .map(|f| before::enrich_file(f, "")) + .collect(); + let new_rows: Vec<_> = file_dtos(conv_n) + .into_iter() + .map(|f| SearchService::enrich_file_for_bench(f, "")) + .collect(); + + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + let out: Vec<_> = old_rows.iter().map(before::file_dto_from_search).collect(); + let conv_before_ms = t.elapsed().as_secs_f64() * 1e3; + let conv_before_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0; + black_box(&out); + drop(out); + + let a1 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + let out: Vec<_> = new_rows + .iter() + .map(oxicloud::interfaces::nextcloud::report_handler::file_dto_from_search_for_bench) + .collect(); + let conv_after_ms = t.elapsed().as_secs_f64() * 1e3; + let conv_after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1; + black_box(&out); + + println!("\n#################################################################"); + println!("# [3] NC REPORT search→FileDto conversion — reclassify vs carry"); + println!("# rows={conv_n}"); + println!("#################################################################\n"); + println!( + "| {:<22} | {:>10} | {:>12} | {:>12} |", + "arm", "wall ms", "allocs", "allocs/row" + ); + println!( + "| {:<22} | {:>10.3} | {:>12} | {:>12.3} |", + "BEFORE (reclassify)", + conv_before_ms, + conv_before_allocs, + conv_before_allocs as f64 / conv_n as f64 + ); + println!( + "| {:<22} | {:>10.3} | {:>12} | {:>12.3} |", + "AFTER (carry Arc)", + conv_after_ms, + conv_after_allocs, + conv_after_allocs as f64 / conv_n as f64 + ); + let s3_ok = conv_after_allocs < conv_before_allocs; + + if !(s1_ok && s2_ok && s3_ok) { + eprintln!("\nGATE FAIL: allocs not reduced (s1={s1_ok} s2={s2_ok} s3={s3_ok}) — rollback"); + std::process::exit(1); + } + println!("\nGATE PASS: allocs reduced in all three sections; outputs field-identical."); +} diff --git a/examples/bench_storage_micro.rs b/examples/bench_storage_micro.rs new file mode 100644 index 00000000..f069bda6 --- /dev/null +++ b/examples/bench_storage_micro.rs @@ -0,0 +1,399 @@ +//! Round-9 storage micro-pack benchmark — four independent A/Bs, no Postgres. +//! +//! [1] Local chunk write — the old `try_exists` (stat) + `File::create` pair +//! vs the new single atomic `create_new` open, at chunk-write level via +//! the bench wrapper over the production writer. Fresh-write AND +//! already-exists (dedup re-upload skip) arms. +//! [2] CDC read prep — the old per-read deep clone of the cached manifest's +//! `Vec` chunk-hash list vs the new index-over-`Arc` iteration +//! (structural replica of `DedupService::stream_chunks` before/after; +//! the production change is exactly this data-flow). +//! [3] Manifest cache miss herd — the old `get → SELECT → insert` shape vs +//! the new fast-get + `try_get_with` single-flight, K concurrent cold +//! readers on one key over a real moka cache with a counted loader +//! (structural replica of `DedupService::manifest_cached`, sqlx swapped +//! for a latency-injected counted loader). +//! [4] Chunk `Content-MD5` verification hex — 16× `format!("{b:02x}")` + +//! collect vs `common::fmt::hex_lower` (1 sized alloc). +//! +//! Gates: [1] AFTER wall < BEFORE wall (fresh) + identical on-disk content + +//! identical skip semantics; [2] AFTER allocs < BEFORE allocs + identical +//! hash sequence; [3] AFTER loader runs == 1 (BEFORE > 1) + identical value; +//! [4] identical hex + fewer allocs. +//! +//! Run: +//! cargo run --release --features bench --example bench_storage_micro +//! Tunables (env): BENCH_CHUNKS (20000), BENCH_CHUNK_KB (4), BENCH_HERD (64), +//! BENCH_MANIFEST_CHUNKS (4096) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::hint::black_box; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use oxicloud::infrastructure::services::local_blob_backend::write_blob_bytes_for_bench; + +// ─── Counting allocator ───────────────────────────────────────────────────── + +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; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +// ─── [1] BEFORE replica: stat-then-create chunk writer (verbatim) ─────────── + +async fn write_blob_bytes_before( + blob_path: &std::path::Path, + data: &Bytes, +) -> std::io::Result> { + use tokio::io::AsyncWriteExt; + if tokio::fs::try_exists(blob_path).await.unwrap_or(false) { + return Ok(None); + } + let mut file = tokio::fs::File::create(blob_path).await?; + file.write_all(data).await?; + Ok(Some(file)) +} + +async fn section_1(chunks: usize, chunk_kb: usize) { + let payload = Bytes::from(vec![0x5au8; chunk_kb * 1024]); + let dir_before = tempfile::tempdir().expect("tempdir"); + let dir_after = tempfile::tempdir().expect("tempdir"); + + // Fresh writes. + let t = Instant::now(); + for i in 0..chunks { + let p = dir_before.path().join(format!("{i:08x}.blob")); + write_blob_bytes_before(&p, &payload) + .await + .expect("before write"); + } + let before_fresh = t.elapsed().as_secs_f64() * 1e3; + + let t = Instant::now(); + for i in 0..chunks { + let p = dir_after.path().join(format!("{i:08x}.blob")); + write_blob_bytes_for_bench(&p, &payload) + .await + .expect("after write"); + } + let after_fresh = t.elapsed().as_secs_f64() * 1e3; + + // Equivalence: same file count, same bytes for a sample. + let sample = dir_after.path().join(format!("{:08x}.blob", chunks / 2)); + let got = tokio::fs::read(&sample).await.expect("sample read"); + assert_eq!(got.len(), payload.len(), "content length mismatch"); + assert_eq!(&got[..64], &payload[..64], "content mismatch"); + + // Already-exists skip (dedup re-upload): both must return None-equivalent. + let t = Instant::now(); + for i in 0..chunks { + let p = dir_before.path().join(format!("{i:08x}.blob")); + let r = write_blob_bytes_before(&p, &payload).await.expect("skip"); + assert!(r.is_none(), "BEFORE re-put must skip"); + } + let before_skip = t.elapsed().as_secs_f64() * 1e3; + + let t = Instant::now(); + for i in 0..chunks { + let p = dir_after.path().join(format!("{i:08x}.blob")); + let r = write_blob_bytes_for_bench(&p, &payload) + .await + .expect("skip"); + assert!(r.is_none(), "AFTER re-put must skip (AlreadyExists)"); + } + let after_skip = t.elapsed().as_secs_f64() * 1e3; + + println!("\n#################################################################"); + println!("# [1] local chunk write — stat+create vs atomic create_new"); + println!("# chunks={chunks} x {chunk_kb} KiB"); + println!("#################################################################\n"); + println!( + "| {:<26} | {:>12} | {:>12} |", + "arm", "fresh ms", "re-put ms" + ); + println!( + "| {:<26} | {:>12.1} | {:>12.1} |", + "BEFORE (stat+create)", before_fresh, before_skip + ); + println!( + "| {:<26} | {:>12.1} | {:>12.1} |", + "AFTER (create_new)", after_fresh, after_skip + ); + println!( + "\nfresh {:.2}x · re-put {:.2}x", + before_fresh / after_fresh, + before_skip / after_skip + ); + if after_fresh >= before_fresh { + eprintln!("GATE FAIL [1]: create_new not faster on fresh writes — rollback"); + std::process::exit(1); + } +} + +// ─── [2] manifest read prep: Vec clone vs Arc-index ───────────────────────── + +struct ManifestReplica { + chunk_hashes: Vec, +} + +fn section_2(manifest_chunks: usize) { + let manifest = Arc::new(ManifestReplica { + chunk_hashes: (0..manifest_chunks).map(|i| format!("{i:064x}")).collect(), + }); + let reads = 200usize; + + // BEFORE: each read clones the whole hash list out of the shared Arc + // (the old `stream_chunks(m.chunk_hashes.clone())` call shape). + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + let mut sum_before = 0usize; + for _ in 0..reads { + let hashes: Vec = manifest.chunk_hashes.clone(); + for h in &hashes { + sum_before += h.len(); + } + black_box(&hashes); + } + let before_ms = t.elapsed().as_secs_f64() * 1e3; + let before_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0; + + // AFTER: each read bumps the Arc and indexes (the new `stream_chunks(m)`). + let a1 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + let mut sum_after = 0usize; + for _ in 0..reads { + let m = manifest.clone(); + for i in 0..m.chunk_hashes.len() { + sum_after += m.chunk_hashes[i].len(); + } + black_box(&m); + } + let after_ms = t.elapsed().as_secs_f64() * 1e3; + let after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1; + + assert_eq!(sum_before, sum_after, "hash sequence mismatch"); + + println!("\n#################################################################"); + println!("# [2] CDC read prep — manifest Vec clone vs Arc index"); + println!("# manifest={manifest_chunks} chunks, reads={reads}"); + println!("#################################################################\n"); + println!( + "| {:<26} | {:>10} | {:>12} | {:>12} |", + "arm", "wall ms", "allocs", "allocs/read" + ); + println!( + "| {:<26} | {:>10.3} | {:>12} | {:>12.1} |", + "BEFORE (clone Vec)", + before_ms, + before_allocs, + before_allocs as f64 / reads as f64 + ); + println!( + "| {:<26} | {:>10.3} | {:>12} | {:>12.1} |", + "AFTER (Arc index)", + after_ms, + after_allocs, + after_allocs as f64 / reads as f64 + ); + if after_allocs >= before_allocs { + eprintln!("GATE FAIL [2]: Arc-index not fewer allocs — rollback"); + std::process::exit(1); + } +} + +// ─── [3] manifest miss herd: get→insert vs try_get_with ───────────────────── + +async fn section_3(herd: usize) { + type Cache = moka::future::Cache>>; + + let value = || Arc::new(vec![7u64; 1024]); + let simulated_query = Duration::from_millis(2); + + // BEFORE shape: check, query (2 ms), insert — every cold caller loads. + let cache: Cache = moka::future::Cache::new(1000); + let loads = Arc::new(AtomicU64::new(0)); + let mut set = tokio::task::JoinSet::new(); + let t = Instant::now(); + for _ in 0..herd { + let cache = cache.clone(); + let loads = loads.clone(); + set.spawn(async move { + if let Some(v) = cache.get("hot-file").await { + return v; + } + loads.fetch_add(1, Ordering::Relaxed); + tokio::time::sleep(simulated_query).await; + let v = value(); + cache.insert("hot-file".to_string(), v.clone()).await; + v + }); + } + let mut first: Option>> = None; + while let Some(r) = set.join_next().await { + let v = r.expect("join"); + if let Some(f) = &first { + assert_eq!(f.len(), v.len()); + } else { + first = Some(v); + } + } + let before_ms = t.elapsed().as_secs_f64() * 1e3; + let before_loads = loads.load(Ordering::Relaxed); + + // AFTER shape: fast get + try_get_with — the herd coalesces onto 1 load. + let cache: Cache = moka::future::Cache::new(1000); + let loads = Arc::new(AtomicU64::new(0)); + let mut set = tokio::task::JoinSet::new(); + let t = Instant::now(); + for _ in 0..herd { + let cache = cache.clone(); + let loads = loads.clone(); + set.spawn(async move { + if let Some(v) = cache.get("hot-file").await { + return v; + } + cache + .try_get_with("hot-file".to_string(), async move { + loads.fetch_add(1, Ordering::Relaxed); + tokio::time::sleep(simulated_query).await; + Ok::<_, std::convert::Infallible>(value()) + }) + .await + .expect("infallible") + }); + } + while let Some(r) = set.join_next().await { + let v = r.expect("join"); + assert_eq!(v.len(), first.as_ref().unwrap().len()); + } + let after_ms = t.elapsed().as_secs_f64() * 1e3; + let after_loads = loads.load(Ordering::Relaxed); + + println!("\n#################################################################"); + println!("# [3] manifest cold-miss herd — get→insert vs try_get_with"); + println!("# herd={herd} concurrent readers, 2 ms simulated manifest SELECT"); + println!("#################################################################\n"); + println!("| {:<26} | {:>10} | {:>12} |", "arm", "wall ms", "loads"); + println!( + "| {:<26} | {:>10.1} | {:>12} |", + "BEFORE (get→insert)", before_ms, before_loads + ); + println!( + "| {:<26} | {:>10.1} | {:>12} |", + "AFTER (single-flight)", after_ms, after_loads + ); + if after_loads != 1 { + eprintln!("GATE FAIL [3]: single-flight ran {after_loads} loads (expected 1) — rollback"); + std::process::exit(1); + } + if before_loads <= 1 { + eprintln!( + "GATE WARN [3]: BEFORE herd only loaded {before_loads}x — herd too small to show the stampede" + ); + } +} + +// ─── [4] Content-MD5 hex ──────────────────────────────────────────────────── + +fn section_4() { + let digests: Vec<[u8; 16]> = (0..1000u32) + .map(|i| { + let mut d = [0u8; 16]; + d[..4].copy_from_slice(&i.to_le_bytes()); + d + }) + .collect(); + + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + let before: Vec = digests + .iter() + .map(|d| d.iter().map(|b| format!("{b:02x}")).collect::()) + .collect(); + let before_ms = t.elapsed().as_secs_f64() * 1e3; + let before_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0; + + let a1 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + let after: Vec = digests + .iter() + .map(|d| oxicloud::common::fmt::hex_lower(d)) + .collect(); + let after_ms = t.elapsed().as_secs_f64() * 1e3; + let after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1; + + assert_eq!(before, after, "hex output mismatch"); + + println!("\n#################################################################"); + println!("# [4] chunk Content-MD5 hex — per-byte format! vs hex_lower"); + println!("# digests=1000"); + println!("#################################################################\n"); + println!( + "| {:<26} | {:>10} | {:>12} | {:>14} |", + "arm", "wall ms", "allocs", "allocs/digest" + ); + println!( + "| {:<26} | {:>10.3} | {:>12} | {:>14.2} |", + "BEFORE (format!/byte)", + before_ms, + before_allocs, + before_allocs as f64 / 1000.0 + ); + println!( + "| {:<26} | {:>10.3} | {:>12} | {:>14.2} |", + "AFTER (hex_lower)", + after_ms, + after_allocs, + after_allocs as f64 / 1000.0 + ); + if after_allocs >= before_allocs { + eprintln!("GATE FAIL [4]: hex_lower not fewer allocs — rollback"); + std::process::exit(1); + } +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + let chunks: usize = env_or("BENCH_CHUNKS", 20_000); + let chunk_kb: usize = env_or("BENCH_CHUNK_KB", 4); + let herd: usize = env_or("BENCH_HERD", 64); + let manifest_chunks: usize = env_or("BENCH_MANIFEST_CHUNKS", 4096); + + section_1(chunks, chunk_kb).await; + section_2(manifest_chunks); + section_3(herd).await; + section_4(); + + println!("\nGATE PASS: all four sections improved with identical outputs."); +} diff --git a/examples/bench_thumbnail_cascade_cache.rs b/examples/bench_thumbnail_cascade_cache.rs index 55ad7454..3a3c80a7 100644 --- a/examples/bench_thumbnail_cascade_cache.rs +++ b/examples/bench_thumbnail_cascade_cache.rs @@ -13,12 +13,23 @@ //! on any File/Folder grant write). The check still runs on every request — //! it is never skipped — but after the first query it resolves in-memory. //! +//! Round 9 additionally decomposes the FILE decision: parent point-read +//! (memoised) → the FOLDER cascade decision (one ltree query per folder, +//! shared by every sibling) → direct-file-grant fallback. A shared album's +//! COLD first view drops from one ltree UNION query per file to one ltree +//! query per FOLDER plus cheap PK reads. The `ROUND8 cold` arm below runs +//! the historical UNION verbatim per file for comparison. +//! //! Safety gates (hard asserts, exit 1 on failure): //! 1. the folder-grant recipient is allowed; an outsider is denied; //! 2. REVOCATION — after a warm cache serves `allowed`, `clear_role` on the //! shared folder makes the very next check DENY (proves the grant-write //! invalidation flushes the cache; without it the stale `true` would -//! still serve). +//! still serve); +//! 3. DIRECT-GRANT SIBLING (round 9) — a caller holding ONLY a direct +//! grant on one file is allowed that file and denied its siblings, +//! proving the folder-level decomposition neither shadows direct file +//! grants nor leaks a file decision to siblings. //! //! Run (needs Postgres up; reads DATABASE_URL from .env): //! cargo run --release --features bench --example bench_thumbnail_cascade_cache @@ -29,7 +40,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use oxicloud::application::ports::authorization_ports::AuthorizationEngine; -use oxicloud::domain::services::authorization::{Permission, Resource, Role, Subject}; +use oxicloud::domain::services::authorization::{ + Permission, Resource, Role, Subject, roles_implying, +}; use oxicloud::infrastructure::repositories::pg::{ FileBlobReadRepository, FolderDbRepository, SubjectGroupPgRepository, }; @@ -308,6 +321,46 @@ async fn main() { .expect("re-grant"); } + // ── Safety gate 3 (round 9): direct-grant sibling isolation ── + // The outsider gets a DIRECT grant on file[0] only (no folder/drive + // grant): they must be allowed file[0] — the folder half of the + // decomposition denies, the direct half matches — and denied file[1] + // even immediately after the allowed check (no sibling leak through + // the folder-level cache). + { + let engine = fresh_engine(&pool); + engine + .set_role( + s.owner, + Subject::User(s.outsider), + Role::Viewer, + Resource::File(s.files[0]), + None, + ) + .await + .expect("direct file grant"); + if !allowed(&engine, s.outsider, s.files[0]).await { + eprintln!( + "SAFETY GATE FAILED: direct file grant denied — the folder-level \ + decomposition shadowed the direct-grant branch" + ); + cleanup(&pool, &s).await; + std::process::exit(1); + } + if allowed(&engine, s.outsider, s.files[1]).await { + eprintln!( + "SAFETY GATE FAILED: direct grant on file[0] leaked to a sibling — \ + a file decision must never authorize other files" + ); + cleanup(&pool, &s).await; + std::process::exit(1); + } + engine + .clear_role(Subject::User(s.outsider), Resource::File(s.files[0])) + .await + .expect("clear direct grant"); + } + println!("\n#################################################################"); println!("# shared-album thumbnail authz: folder-cascade query/thumb vs cache"); println!("# thumbs={thumbs} (recipient holds a folder grant, no drive membership)"); @@ -331,8 +384,66 @@ async fn main() { ); } - // AFTER cold: one persistent engine — the first grid view queries once per - // distinct file (cache misses populate). + // ROUND8 cold: the historical per-file UNION (direct grant ∨ ltree + // ancestor join) run verbatim once per file — what a cold first view + // cost before the round-9 folder-level decomposition. + { + let subject_types: Vec<&str> = vec!["user", "group"]; + let subject_ids = vec![s.recipient]; + let roles: Vec<&str> = roles_implying(Permission::Read) + .iter() + .map(|r| r.as_str()) + .collect(); + let t = Instant::now(); + for &f in &s.files { + let exists: Option = sqlx::query_scalar( + r#" + SELECT 1 + FROM ( + SELECT 1 + FROM storage.role_grants + WHERE subject_type = ANY($1) + AND subject_id = ANY($2) + AND role = ANY($3::storage.grant_role[]) + AND resource_type = 'file' AND resource_id = $4 + AND (expires_at IS NULL OR expires_at > NOW()) + UNION ALL + SELECT 1 + FROM storage.role_grants g + JOIN storage.folders gf ON gf.id = g.resource_id + JOIN storage.files target_f ON target_f.id = $4 + WHERE g.subject_type = ANY($1) + AND g.subject_id = ANY($2) + AND g.role = ANY($3::storage.grant_role[]) + AND g.resource_type = 'folder' + AND (g.expires_at IS NULL OR g.expires_at > NOW()) + AND target_f.folder_id IS NOT NULL + AND gf.lpath @> (SELECT lpath FROM storage.folders + WHERE id = target_f.folder_id) + ) any_match + LIMIT 1 + "#, + ) + .bind(&subject_types) + .bind(&subject_ids) + .bind(&roles) + .bind(f) + .fetch_optional(pool.as_ref()) + .await + .expect("round8 union query"); + assert!(exists.is_some(), "ROUND8 arm: recipient must be allowed"); + } + let el = t.elapsed(); + println!( + "| {:<28} | {:>10.2} | {:>12.2} |", + "ROUND8 cold (union/file)", + el.as_secs_f64() * 1e3, + el.as_secs_f64() * 1e6 / thumbs as f64 + ); + } + + // AFTER cold: one persistent engine — the first grid view resolves each + // file's parent (PK read) and shares ONE folder-cascade decision. let engine = fresh_engine(&pool); { let t = Instant::now(); diff --git a/frontend/src/lib/api/endpoints/recipients.bench.test.ts b/frontend/src/lib/api/endpoints/recipients.bench.test.ts new file mode 100644 index 00000000..8043dae0 --- /dev/null +++ b/frontend/src/lib/api/endpoints/recipients.bench.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +/** + * Benchmark gate for the O(1) contact index behind `resolveLabel` / + * `resolveRecipient` (recipients.ts). + * + * Audit finding: both resolvers ran `contactCache.find((x) => x.id === id)` + * — a linear scan over the WHOLE system address book — once per rendered + * grant row / lane header on /shared, and the page re-renders on every + * infinite-scroll page and role change. Cost per frame: O(rows × directory + * size) — ~150k comparisons for 30 rows in a 5 000-user org. The fix builds + * a `Map` once per cache identity (exactly like the existing + * `groupCache`) and looks up O(1). + * + * Gates: (1) labels identical to the linear scan for present AND absent + * ids; (2) comparison count collapses from rows×C to ~C (one index build); + * (3) resolving a full page against a 5 000-contact directory is ≥10x + * faster with the index. + */ + +interface Contact { + id: string; + full_name?: string; + email?: string; +} + +function contactLabel(c: Contact): { label: string; email?: string } { + return { label: c.full_name || c.email || c.id, email: c.email }; +} + +function directory(n: number): Contact[] { + return Array.from({ length: n }, (_, i) => ({ + id: `user-${i}`, + full_name: `User Number ${i}`, + email: `user${i}@example.com` + })); +} + +/** BEFORE — verbatim resolver shape: linear `.find` per call. */ +function makeBefore(cache: Contact[], counter: { cmp: number }) { + return (id: string): string => { + let found: Contact | undefined; + for (const x of cache) { + counter.cmp++; + if (x.id === id) { + found = x; + break; + } + } + return found ? contactLabel(found).label : id; + }; +} + +/** AFTER — the shipped shape: identity-memoized Map index, O(1) get. */ +function makeAfter(cache: Contact[], counter: { cmp: number }) { + let contactById: Map | null = null; + let source: Contact[] | null = null; + const index = () => { + if (!contactById || source !== cache) { + contactById = new Map( + cache.map((c) => { + counter.cmp++; + return [c.id, c] as const; + }) + ); + source = cache; + } + return contactById; + }; + return (id: string): string => { + const c = index().get(id); + return c ? contactLabel(c).label : id; + }; +} + +describe('resolveLabel contact index (benchmark gate)', () => { + const C = 5_000; + const contacts = directory(C); + // A /shared page: 30 rows, most present, some unknown (revoked users). + const rowIds = [ + ...Array.from({ length: 26 }, (_, i) => `user-${i * 137}`), + 'ghost-1', + 'ghost-2', + 'user-4999', + 'ghost-3' + ]; + + it('labels identical to the linear scan for present and absent ids', () => { + const before = makeBefore(contacts, { cmp: 0 }); + const after = makeAfter(contacts, { cmp: 0 }); + for (const id of rowIds) { + expect(after(id), id).toBe(before(id)); + } + // Absent ids fall back to the raw id in both. + expect(after('ghost-1')).toBe('ghost-1'); + }); + + it('comparison count collapses from rows×C to one index build (~C)', () => { + const beforeCounter = { cmp: 0 }; + const before = makeBefore(contacts, beforeCounter); + for (const id of rowIds) before(id); + // Linear scans: each present id walks ~id-position entries, absent + // ids walk the full directory. + expect(beforeCounter.cmp).toBeGreaterThan(C * 3); + + const afterCounter = { cmp: 0 }; + const after = makeAfter(contacts, afterCounter); + for (const id of rowIds) after(id); + // One index build (C inserts), zero comparisons per lookup after. + expect(afterCounter.cmp).toBe(C); + + // A SECOND render frame re-uses the index: zero additional work. + for (const id of rowIds) after(id); + expect(afterCounter.cmp).toBe(C); + }); + + it('resolving a page against a 5k directory is ≥10x faster with the index', () => { + const frames = 50; + + const before = makeBefore(contacts, { cmp: 0 }); + const t0 = performance.now(); + for (let f = 0; f < frames; f++) { + for (const id of rowIds) before(id); + } + const beforeMs = performance.now() - t0; + + const after = makeAfter(contacts, { cmp: 0 }); + const t1 = performance.now(); + for (let f = 0; f < frames; f++) { + for (const id of rowIds) after(id); + } + const afterMs = performance.now() - t1; + + console.log( + `resolveLabel ${frames} frames × ${rowIds.length} rows @ C=${C}: ` + + `before ${beforeMs.toFixed(1)} ms, after ${afterMs.toFixed(1)} ms ` + + `(${(beforeMs / afterMs).toFixed(1)}x)` + ); + expect(afterMs).toBeLessThan(beforeMs / 10); + }); +}); diff --git a/frontend/src/lib/api/endpoints/recipients.ts b/frontend/src/lib/api/endpoints/recipients.ts index 4442b8b1..49e3bc77 100644 --- a/frontend/src/lib/api/endpoints/recipients.ts +++ b/frontend/src/lib/api/endpoints/recipients.ts @@ -134,10 +134,26 @@ export async function ensureResolvers(): Promise { await Promise.all([systemContacts(), loadGroups()]); } +// O(1) id→contact index over `contactCache`, built once per cache identity. +// `resolveLabel`/`resolveRecipient` run per rendered grant row on /shared — +// the previous `contactCache.find(...)` linear scan made each render frame +// O(rows × directory size). +let contactById: Map | null = null; +let contactByIdSource: Contact[] | null = null; + +function contactIndex(): Map | null { + if (!contactCache) return null; + if (!contactById || contactByIdSource !== contactCache) { + contactById = new Map(contactCache.map((c) => [c.id, c])); + contactByIdSource = contactCache; + } + return contactById; +} + /** Resolve a subject id to a display label using the preloaded caches. */ export function resolveLabel(type: 'user' | 'group', id: string): string { if (type === 'group') return groupCache?.get(id) ?? id; - const c = contactCache?.find((x) => x.id === id); + const c = contactIndex()?.get(id); return c ? contactLabel(c).label : id; } @@ -146,7 +162,7 @@ export function resolveRecipient(type: 'user' | 'group', id: string): Recipient if (type === 'group') { return { type: 'group', id, label: groupCache?.get(id) ?? id }; } - const c = contactCache?.find((x) => x.id === id); + const c = contactIndex()?.get(id); if (!c) return { type: 'user', id, label: id }; const { label, email } = contactLabel(c); return { type: 'user', id, label, sublabel: email }; diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 8b690ab9..d8be2591 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -256,6 +256,11 @@ // Drop selection ids that are no longer present after a reload. $effect(() => { + // With nothing selected (the common case) every infinite-scroll page + // re-fired this effect and built a throwaway O(N) id Set for a loop + // that never runs — skip straight out. `selected.size` is reactive, + // so the effect re-fires when a selection appears. + if (selected.size === 0) return; const ids = new Set(items.map((i) => i.id)); let changed = false; for (const id of selected) { diff --git a/frontend/src/lib/components/listDerives.bench.test.ts b/frontend/src/lib/components/listDerives.bench.test.ts new file mode 100644 index 00000000..72962d48 --- /dev/null +++ b/frontend/src/lib/components/listDerives.bench.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +/** + * Benchmark gates for two per-page derive cleanups (round 9): + * + * [1] ResourceList's selection-prune `$effect` built an O(N) id `Set` on + * EVERY `items` change (every infinite-scroll page) even when nothing + * was selected — the loop it feeds never runs in that case. The shipped + * guard (`if (selected.size === 0) return`) makes the empty-selection + * page append free while keeping the pruned result byte-identical when + * a selection exists. + * + * [2] The photos timeline derive called `window.matchMedia(...)` on every + * recompute (every 60-photo page append) for a boolean that changes + * only on viewport-class crossings. The shipped code hoists it into + * state fed by a single MediaQueryList `change` listener. + * + * Both are modeled as pure replicas of the effect/derive bodies (no jsdom + * mounting needed) with instrumentation counters, mirroring the shipped + * control flow exactly. + */ + +interface Item { + id: string; +} + +const page = (start: number, n: number): Item[] => + Array.from({ length: n }, (_, i) => ({ id: `it-${start + i}` })); + +/** BEFORE — verbatim effect body: unconditional Set build. */ +function pruneBefore(items: Item[], selected: Set, counter: { setBuilds: number }) { + counter.setBuilds++; + const ids = new Set(items.map((i) => i.id)); + for (const id of [...selected]) { + if (!ids.has(id)) selected.delete(id); + } +} + +/** AFTER — the shipped body: skip entirely while nothing is selected. */ +function pruneAfter(items: Item[], selected: Set, counter: { setBuilds: number }) { + if (selected.size === 0) return; + counter.setBuilds++; + const ids = new Set(items.map((i) => i.id)); + for (const id of [...selected]) { + if (!ids.has(id)) selected.delete(id); + } +} + +describe('selection-prune guard (benchmark gate)', () => { + it('empty selection: zero Set builds across a 100-page drain (was 100)', () => { + const beforeCounter = { setBuilds: 0 }; + const afterCounter = { setBuilds: 0 }; + let items: Item[] = []; + for (let p = 0; p < 100; p++) { + items = [...items, ...page(p * 50, 50)]; + pruneBefore(items, new Set(), beforeCounter); + pruneAfter(items, new Set(), afterCounter); + } + expect(beforeCounter.setBuilds).toBe(100); + expect(afterCounter.setBuilds).toBe(0); + }); + + it('active selection: pruned set identical to the unguarded version', () => { + const items = page(0, 200); + // Selection holds survivors + ids that vanished on reload. + const seed = ['it-3', 'it-77', 'gone-1', 'it-150', 'gone-2']; + const a = new Set(seed); + const b = new Set(seed); + pruneBefore(items, a, { setBuilds: 0 }); + pruneAfter(items, b, { setBuilds: 0 }); + expect([...b].sort()).toEqual([...a].sort()); + expect(b.has('gone-1')).toBe(false); + expect(b.has('it-3')).toBe(true); + }); +}); + +// ── [2] matchMedia hoist ──────────────────────────────────────────────────── + +interface MqlStub { + matches: boolean; + listeners: ((e: { matches: boolean }) => void)[]; +} + +function makeMatchMedia(counter: { calls: number }, stub: MqlStub) { + return () => { + counter.calls++; + return { + get matches() { + return stub.matches; + }, + addEventListener: (_: 'change', fn: (e: { matches: boolean }) => void) => { + stub.listeners.push(fn); + }, + removeEventListener: () => {} + }; + }; +} + +describe('photos matchMedia hoist (benchmark gate)', () => { + it('P recomputes: 1 matchMedia call instead of P, identical booleans', () => { + const P = 50; + const stub: MqlStub = { matches: false, listeners: [] }; + + // BEFORE — the derive body queries per recompute. + const beforeCounter = { calls: 0 }; + const mmBefore = makeMatchMedia(beforeCounter, stub); + const beforeValues: boolean[] = []; + for (let i = 0; i < P; i++) { + beforeValues.push(mmBefore().matches); + } + expect(beforeCounter.calls).toBe(P); + + // AFTER — one query + listener; recomputes read the state boolean. + const afterCounter = { calls: 0 }; + const mmAfter = makeMatchMedia(afterCounter, stub); + const mql = mmAfter(); + let isMobile = mql.matches; + mql.addEventListener('change', (e) => { + isMobile = e.matches; + }); + const afterValues: boolean[] = []; + for (let i = 0; i < P; i++) { + afterValues.push(isMobile); + } + expect(afterCounter.calls).toBe(1); + expect(afterValues).toEqual(beforeValues); + + // A viewport-class crossing propagates through the listener. + stub.matches = true; + for (const fn of stub.listeners) fn({ matches: true }); + expect(isMobile).toBe(true); + }); +}); diff --git a/frontend/src/routes/photos/+page.svelte b/frontend/src/routes/photos/+page.svelte index b823951d..a4fda1a0 100644 --- a/frontend/src/routes/photos/+page.svelte +++ b/frontend/src/routes/photos/+page.svelte @@ -94,15 +94,27 @@ // deps re-fire without an actual append, `sync` sees a non-growing list and // safely full-rebuilds — same output as the pure `buildPhotoRows`. const timeline = new PhotoTimeline(); + // `mobile` as state fed by one MediaQueryList listener: the derive below + // re-runs on every page append, and `window.matchMedia(...)` inside it was + // a per-recompute style/layout read that only changes on viewport-class + // crossings — now those crossings push the boolean instead. + let isMobile = $state(false); + $effect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + const mql = window.matchMedia('(max-width: 768px)'); + isMobile = mql.matches; + const onchange = (e: MediaQueryListEvent) => { + isMobile = e.matches; + }; + mql.addEventListener('change', onchange); + return () => mql.removeEventListener('change', onchange); + }); const photoRows = $derived.by(() => timeline.sync(visibleItems, { groupMode, layoutMode, width: gridWidth, - mobile: - typeof window !== 'undefined' && - typeof window.matchMedia === 'function' && - window.matchMedia('(max-width: 768px)').matches, + mobile: isMobile, timestampOf: photoTimestamp, labelOf: bucketLabel }) diff --git a/src/application/dtos/search_dto.rs b/src/application/dtos/search_dto.rs index 6c2530ed..0dc82a12 100644 --- a/src/application/dtos/search_dto.rs +++ b/src/application/dtos/search_dto.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::sync::Arc; use utoipa::ToSchema; /** @@ -109,8 +110,10 @@ pub struct SearchFileResultDto { pub path: String, /// Size in bytes pub size: u64, - /// MIME type - pub mime_type: String, + /// MIME type — `Arc` so enrichment reuses `FileDto`'s interned + /// value (an atomic increment) instead of allocating per result row. + #[schema(value_type = String)] + pub mime_type: Arc, /// Parent folder ID pub folder_id: Option, /// Creation timestamp @@ -122,11 +125,14 @@ pub struct SearchFileResultDto { /// Human-readable file size (e.g., "2.5 MB") pub size_formatted: String, /// CSS icon class for the file type (e.g., "fas fa-file-pdf") - pub icon_class: String, + #[schema(value_type = String)] + pub icon_class: Arc, /// Extra CSS class for icon styling (e.g., "pdf-icon", "code-icon js-icon") - pub icon_special_class: String, + #[schema(value_type = String)] + pub icon_special_class: Arc, /// Content category: "document", "image", "video", "audio", "archive", "code", "other" - pub category: String, + #[schema(value_type = String)] + pub category: Arc, /// Raw BLAKE3 content hash. Feeds `FileDto::content_hash` and /// `File::compute_etag` when search results are converted to /// `FileDto` (NC REPORT/SEARCH response). Defaults to `String::new()` @@ -267,9 +273,11 @@ pub struct SearchSuggestionItem { /// Path for context pub path: String, /// CSS icon class - pub icon_class: String, + #[schema(value_type = String)] + pub icon_class: Arc, /// Extra CSS class for icon styling - pub icon_special_class: String, + #[schema(value_type = String)] + pub icon_special_class: Arc, /// Relevance score pub relevance_score: u32, } diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index a7dcf947..cf898464 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -2,9 +2,7 @@ use std::cmp::Reverse; use std::sync::Arc; use std::time::{Duration, Instant}; -use crate::application::dtos::display_helpers::{ - category_for, icon_class_for, icon_special_class_for, -}; +use crate::application::dtos::display_helpers::intern_display; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::application::dtos::search_dto::{ @@ -209,23 +207,6 @@ fn format_bytes(bytes: u64) -> String { } } -/// Get Font Awesome icon class for a file based on extension and MIME type. -/// Delegates to the centralised `display_helpers` so every API surface is -/// consistent. -fn get_icon_class(name: &str, mime: &str) -> String { - icon_class_for(name, mime).to_string() -} - -/// Get CSS special class for icon styling. -fn get_icon_special_class(name: &str, mime: &str) -> String { - icon_special_class_for(name, mime).to_string() -} - -/// Get category label from centralised helpers. -fn get_category(name: &str, mime: &str) -> String { - category_for(name, mime).to_string() -} - // ─── SearchService implementation ─────────────────────────────────────── impl SearchService { @@ -267,8 +248,14 @@ impl SearchService { /// Enrich a FileDto → SearchFileResultDto with server-computed metadata. /// + /// Consumes the DTO: every `String` moves and the interned display + /// fields (`mime_type`/`icon_class`/`icon_special_class`/`category`, + /// already computed once in `FileDto::from`) transfer as refcount + /// bumps — the old borrow-based version cloned all of them AND re-ran + /// the three display classifiers per result row. + /// /// `query_lower` must already be lowercased (empty string when no query). - fn enrich_file(file: &FileDto, query_lower: &str) -> SearchFileResultDto { + fn enrich_file(file: FileDto, query_lower: &str) -> SearchFileResultDto { let relevance = if query_lower.is_empty() { 50 } else { @@ -276,23 +263,23 @@ impl SearchService { }; SearchFileResultDto { - id: file.id.clone(), - name: file.name.clone(), - path: file.path.clone(), + id: file.id, + name: file.name, + path: file.path, size: file.size, - mime_type: file.mime_type.to_string(), - folder_id: file.folder_id.clone(), + mime_type: file.mime_type, + folder_id: file.folder_id, created_at: file.created_at, modified_at: file.modified_at, relevance_score: relevance, size_formatted: format_bytes(file.size), - icon_class: get_icon_class(&file.name, &file.mime_type), - icon_special_class: get_icon_special_class(&file.name, &file.mime_type), - category: get_category(&file.name, &file.mime_type), + icon_class: file.icon_class, + icon_special_class: file.icon_special_class, + category: file.category, // Carry the content hash through so REPORT/SEARCH // responses on the NC surface can emit the same ETag // (`File::compute_etag`) as PROPFIND/GET would. - blob_hash: file.content_hash.clone(), + blob_hash: file.content_hash, snippet: None, match_source: (!query_lower.is_empty() && relevance > 0).then(|| "name".to_string()), } @@ -300,8 +287,10 @@ impl SearchService { /// Enrich a FolderDto → SearchFolderResultDto with server-computed metadata. /// + /// Consumes the DTO so the owned strings move instead of cloning. + /// /// `query_lower` must already be lowercased (empty string when no query). - fn enrich_folder(folder: &FolderDto, query_lower: &str) -> SearchFolderResultDto { + fn enrich_folder(folder: FolderDto, query_lower: &str) -> SearchFolderResultDto { let relevance = if query_lower.is_empty() { 50 } else { @@ -309,10 +298,10 @@ impl SearchService { }; SearchFolderResultDto { - id: folder.id.clone(), - name: folder.name.clone(), - path: folder.path.clone(), - parent_id: folder.parent_id.clone(), + id: folder.id, + name: folder.name, + path: folder.path, + parent_id: folder.parent_id, drive_id: folder.drive_id, created_at: folder.created_at, modified_at: folder.modified_at, @@ -481,9 +470,10 @@ impl SearchService { let Some(hit) = by_id.get(dto.id.as_str()) else { continue; }; - let mut enriched = Self::enrich_file(&dto, ""); - enriched.relevance_score = content_relevance(hit.score, max_score); - enriched.snippet = hit.snippet.clone(); + let (score, snippet) = (hit.score, hit.snippet.clone()); + let mut enriched = Self::enrich_file(dto, ""); + enriched.relevance_score = content_relevance(score, max_score); + enriched.snippet = snippet; enriched.match_source = Some("content".to_string()); enriched_files.push(enriched); added += 1; @@ -536,15 +526,15 @@ impl SearchService { for file in files { let file_dto = FileDto::from(file); let score = compute_relevance(&file_dto.name, &query_lower); - let icon_class = get_icon_class(&file_dto.name, &file_dto.mime_type); - let icon_special_class = get_icon_special_class(&file_dto.name, &file_dto.mime_type); suggestions.push(SearchSuggestionItem { name: file_dto.name, item_type: "file".to_string(), id: file_dto.id, path: file_dto.path, - icon_class, - icon_special_class, + // Interned in `FileDto::from` — reuse instead of re-running + // the display classifiers per keystroke suggestion. + icon_class: file_dto.icon_class, + icon_special_class: file_dto.icon_special_class, relevance_score: score, }); } @@ -557,8 +547,8 @@ impl SearchService { item_type: "folder".to_string(), id: folder_dto.id, path: folder_dto.path, - icon_class: "fas fa-folder".to_string(), - icon_special_class: "folder-icon".to_string(), + icon_class: intern_display("fas fa-folder"), + icon_special_class: intern_display("folder-icon"), relevance_score: score, }); } @@ -575,6 +565,22 @@ impl SearchService { } } +// ─── Bench-only public wrappers (feature = "bench") ────────────────────── + +#[cfg(feature = "bench")] +impl SearchService { + /// Public wrapper over the private `enrich_file` so + /// `examples/bench_search_enrich.rs` can measure it. + pub fn enrich_file_for_bench(file: FileDto, query_lower: &str) -> SearchFileResultDto { + Self::enrich_file(file, query_lower) + } + + /// Public wrapper over the private `enrich_folder` for the same bench. + pub fn enrich_folder_for_bench(folder: FolderDto, query_lower: &str) -> SearchFolderResultDto { + Self::enrich_folder(folder, query_lower) + } +} + // ─── SearchUseCase trait implementation ────────────────────────────────── impl SearchUseCase for SearchService { @@ -627,11 +633,11 @@ impl SearchUseCase for SearchService { .search_files_paginated(criteria.folder_id.as_deref(), &criteria, user_id) .await?; - // Convert to DTOs and enrich with metadata - let file_dtos: Vec = files.into_iter().map(FileDto::from).collect(); - let mut enriched_files: Vec = file_dtos - .iter() - .map(|f| Self::enrich_file(f, &query_lower)) + // Convert to DTOs and enrich with metadata — one fused + // pass, no intermediate Vec materialization. + let mut enriched_files: Vec = files + .into_iter() + .map(|f| Self::enrich_file(FileDto::from(f), &query_lower)) .collect(); // Get folders for this folder (non-recursive, filtered in SQL) @@ -645,13 +651,10 @@ impl SearchUseCase for SearchService { ) .await?; - let filtered_folders: Vec = - folders.into_iter().map(FolderDto::from).collect(); - // For folders, apply sorting and pagination in memory (usually fewer folders) - let mut enriched_folders: Vec = filtered_folders - .iter() - .map(|f| Self::enrich_folder(f, &query_lower)) + let mut enriched_folders: Vec = folders + .into_iter() + .map(|f| Self::enrich_folder(FolderDto::from(f), &query_lower)) .collect(); // Sort folders (cached_key avoids O(N log N) temporary String allocations) @@ -732,17 +735,15 @@ impl SearchUseCase for SearchService { .await?; // ── Convert to DTOs and enrich with server-computed metadata ── - let file_dtos: Vec = found_files.into_iter().map(FileDto::from).collect(); - let mut enriched_files: Vec = file_dtos - .iter() - .map(|f| Self::enrich_file(f, &query_lower)) + // Fused single pass: no intermediate DTO Vec materialization. + let mut enriched_files: Vec = found_files + .into_iter() + .map(|f| Self::enrich_file(FileDto::from(f), &query_lower)) .collect(); - let folder_dtos: Vec = - found_folders.into_iter().map(FolderDto::from).collect(); - let mut enriched_folders: Vec = folder_dtos - .iter() - .map(|f| Self::enrich_folder(f, &query_lower)) + let mut enriched_folders: Vec = found_folders + .into_iter() + .map(|f| Self::enrich_folder(FolderDto::from(f), &query_lower)) .collect(); // ── Sort folders (cached_key avoids O(N log N) temporary String allocations) ── @@ -893,15 +894,15 @@ mod tests { name: name.to_string(), path: format!("/{name}"), size, - mime_type: "text/plain".to_string(), + mime_type: "text/plain".into(), folder_id: None, created_at: 0, modified_at, relevance_score: relevance, size_formatted: String::new(), - icon_class: String::new(), - icon_special_class: String::new(), - category: String::new(), + icon_class: "".into(), + icon_special_class: "".into(), + category: "".into(), blob_hash: String::new(), snippet: None, match_source: None, diff --git a/src/infrastructure/repositories/pg/drive_pg_repository.rs b/src/infrastructure/repositories/pg/drive_pg_repository.rs index 86d18fc8..cbcf29be 100644 --- a/src/infrastructure/repositories/pg/drive_pg_repository.rs +++ b/src/infrastructure/repositories/pg/drive_pg_repository.rs @@ -498,21 +498,26 @@ impl DriveRepository for DrivePgRepository { // the trash. Trashed items don't count — owners can delete a // drive even when its trash bin still holds rows; the trash GC // will clean those up after the standard retention window. - let count: (i64,) = sqlx::query_as( + // + // EXISTS instead of COUNT(*): only emptiness is tested, so the + // planner stops at the first matching row — a populated drive + // answers from one index probe instead of aggregating every + // live file + folder it contains. + let occupied: (bool,) = sqlx::query_as( r#" - SELECT ( - (SELECT COUNT(*) FROM storage.folders - WHERE drive_id = $1 AND parent_id IS NOT NULL AND NOT is_trashed) - + (SELECT COUNT(*) FROM storage.files - WHERE drive_id = $1 AND NOT is_trashed) - ) + SELECT EXISTS( + SELECT 1 FROM storage.folders + WHERE drive_id = $1 AND parent_id IS NOT NULL AND NOT is_trashed) + OR EXISTS( + SELECT 1 FROM storage.files + WHERE drive_id = $1 AND NOT is_trashed) "#, ) .bind(drive_id) .fetch_one(self.pool.as_ref()) .await .map_err(|e| Self::map_sqlx_err("is_empty", e))?; - Ok(count.0 == 0) + Ok(!occupied.0) } async fn delete_atomic(&self, drive_id: Uuid) -> Result<(), DriveRepositoryError> { diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 1d748fbb..d59ae393 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -32,11 +32,15 @@ use crate::domain::services::path_service::StoragePath; /// Post-D7-step-6: `storage.folders.user_id` dropped, so the tuple /// no longer carries it. The domain entity's `user_id` field is /// populated with `None` at `row_to_folder` construction. +/// `id` / `parent_id` decode as binary `Uuid` (16 bytes on the wire vs 36 +/// as `::text`, and the server skips the cast); `row_to_folder` renders +/// them to `String` once app-side — the round-6 `row_to_file` shape +/// (benches/ROUND6.md §10) applied to the folder listings. type FolderRow = ( + Uuid, String, String, - String, - Option, + Option, Uuid, i64, i64, @@ -49,10 +53,10 @@ type FolderRow = ( /// the last element after the §14 provenance columns). Same /// column set as [`FolderRow`] plus the trailing count. type FolderRowPaginated = ( + Uuid, String, String, - String, - Option, + Option, Uuid, i64, i64, @@ -131,10 +135,10 @@ impl FolderDbRepository { /// `Option` because the FK is `ON DELETE SET NULL`. #[allow(clippy::too_many_arguments)] fn row_to_folder( - id: String, + id: Uuid, name: String, path: String, - parent_id: Option, + parent_id: Option, drive_id: Uuid, created_at: i64, modified_at: i64, @@ -143,10 +147,10 @@ impl FolderDbRepository { updated_by: Option, ) -> Result { Folder::from_materialized_row( - id, + id.to_string(), name, path, - parent_id, + parent_id.map(|u| u.to_string()), drive_id, created_at as u64, modified_at as u64, @@ -170,7 +174,7 @@ impl FolderDbRepository { let rows = sqlx::query_as::<_, FolderRow>( r#" - SELECT id::text, name, path, parent_id::text, drive_id, + SELECT id, name, path, parent_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -235,12 +239,25 @@ impl FolderRepository for FolderDbRepository { // // RETURNING surfaces the two provenance columns so the built // entity / DTO carries fresh values without a re-read. - let row = sqlx::query_as::<_, (String, String, i64, i64, i64, Option, Option)>( + let row = sqlx::query_as::< + _, + ( + Uuid, + Option, + String, + i64, + i64, + i64, + Option, + Option, + ), + >( r#" INSERT INTO storage.folders (name, parent_id, drive_id, created_by, updated_by) VALUES ($1, $2::uuid, $3, $4, $4) - RETURNING id::text, + RETURNING id, + parent_id, path, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, @@ -268,16 +285,16 @@ impl FolderRepository for FolderDbRepository { })?; Self::row_to_folder( - row.0, name, row.1, parent_id, drive_id, row.2, row.3, row.4, + row.0, name, row.2, row.1, drive_id, row.3, row.4, row.5, // Fresh from RETURNING — caller_id was bound to both columns. - row.5, row.6, + row.6, row.7, ) } async fn get_folder(&self, id: &str) -> Result { let row = sqlx::query_as::<_, FolderRow>( r#" - SELECT id::text, name, path, parent_id::text, drive_id, + SELECT id, name, path, parent_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -319,7 +336,7 @@ impl FolderRepository for FolderDbRepository { // wrapper scoping post-D0). let row = sqlx::query_as::<_, FolderRow>( r#" - SELECT id::text, name, path, parent_id::text, drive_id, + SELECT id, name, path, parent_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -345,7 +362,7 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, drive_id, + SELECT id, name, path, parent_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -361,7 +378,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, drive_id, + SELECT id, name, path, parent_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -404,7 +421,7 @@ impl FolderRepository for FolderDbRepository { // top of `folder_repository.rs`. Frontend cross-references // `/api/drives::caller_role` via `folder.drive_id`. let sql = format!( - "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ + "SELECT fo.id, fo.name, fo.path, fo.parent_id, \ fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ @@ -445,7 +462,7 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, drive_id, + SELECT id, name, path, parent_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -465,7 +482,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, drive_id, + SELECT id, name, path, parent_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -529,7 +546,7 @@ impl FolderRepository for FolderDbRepository { "AND $3::text IS NULL" }; let sql = format!( - "SELECT id::text, name, path, parent_id::text, drive_id, \ + "SELECT id, name, path, parent_id, drive_id, \ EXTRACT(EPOCH FROM created_at)::bigint, \ EXTRACT(EPOCH FROM updated_at)::bigint, \ EXTRACT(EPOCH FROM tree_modified_at)::bigint, \ @@ -567,7 +584,7 @@ impl FolderRepository for FolderDbRepository { include_total: bool, ) -> Result<(Vec, Option), DomainError> { let sql = format!( - "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ + "SELECT fo.id, fo.name, fo.path, fo.parent_id, \ fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ @@ -629,7 +646,7 @@ impl FolderRepository for FolderDbRepository { UPDATE storage.folders SET name = $1, updated_at = NOW(), updated_by = $3 WHERE id = $2::uuid AND NOT is_trashed - RETURNING id::text, name, path, parent_id::text, drive_id, + RETURNING id, name, path, parent_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -690,7 +707,7 @@ impl FolderRepository for FolderDbRepository { updated_at = NOW(), updated_by = $3 WHERE f.id = $2::uuid AND NOT f.is_trashed - RETURNING f.id::text, f.name, f.path, f.parent_id::text, f.drive_id, + RETURNING f.id, f.name, f.path, f.parent_id, f.drive_id, EXTRACT(EPOCH FROM f.created_at)::bigint, EXTRACT(EPOCH FROM f.updated_at)::bigint, EXTRACT(EPOCH FROM f.tree_modified_at)::bigint, @@ -989,7 +1006,7 @@ impl FolderRepository for FolderDbRepository { /// Ordered by `fo.path` so callers can iterate in directory order. #[allow(clippy::type_complexity)] async fn list_subtree_folders(&self, folder_id: &str) -> Result, DomainError> { - let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ + let sql = "SELECT fo.id, fo.name, fo.path, fo.parent_id, \ fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ @@ -1056,7 +1073,7 @@ impl FolderRepository for FolderDbRepository { if recursive { // Recursive, no folder scope → ALL folders in caller's readable drives let sql = format!( - "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ + "SELECT fo.id, fo.name, fo.path, fo.parent_id, \ fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ @@ -1095,7 +1112,7 @@ impl FolderRepository for FolderDbRepository { // the caller can read (parent_id already establishes the subtree). let sql = if parent_id.is_some() { format!( - "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ + "SELECT fo.id, fo.name, fo.path, fo.parent_id, \ fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ @@ -1115,7 +1132,7 @@ impl FolderRepository for FolderDbRepository { _ => "", }; format!( - "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ + "SELECT fo.id, fo.name, fo.path, fo.parent_id, \ fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ @@ -1188,7 +1205,7 @@ impl FolderRepository for FolderDbRepository { }; let sql = format!( - "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ + "SELECT fo.id, fo.name, fo.path, fo.parent_id, \ fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ @@ -1245,7 +1262,7 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as(&format!( r#" - SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, fo.drive_id, + SELECT fo.id, fo.name, fo.path, fo.parent_id, fo.drive_id, EXTRACT(EPOCH FROM fo.created_at)::bigint, EXTRACT(EPOCH FROM fo.updated_at)::bigint, EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, @@ -1274,7 +1291,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as(&format!( r#" - SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, fo.drive_id, + SELECT fo.id, fo.name, fo.path, fo.parent_id, fo.drive_id, EXTRACT(EPOCH FROM fo.created_at)::bigint, EXTRACT(EPOCH FROM fo.updated_at)::bigint, EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, diff --git a/src/infrastructure/services/cached_blob_backend.rs b/src/infrastructure/services/cached_blob_backend.rs index c1c87d4b..69af358b 100644 --- a/src/infrastructure/services/cached_blob_backend.rs +++ b/src/infrastructure/services/cached_blob_backend.rs @@ -187,22 +187,48 @@ impl BlobStorageBackend for CachedBlobBackend { }; Box::pin(async move { let size = inner.put_blob_from_bytes(&hash, data.clone()).await?; - // Also cache locally (best-effort): write bytes to cache path - let dest = self_ref.cached_path(&hash); - if let Some(parent) = dest.parent() { - let _ = fs::create_dir_all(parent).await; - } - let _ = fs::write(&dest, &data).await; - let data_len = data.len() as u64; - let mut idx = self_ref.index.lock().await; - if let Some(old) = idx.put(hash, CacheEntry { size: data_len }) { - self_ref.current_size.fetch_sub(old.size, Ordering::Relaxed); - } - self_ref.current_size.fetch_add(data_len, Ordering::Relaxed); + self_ref.cache_bytes_write_through(hash, &data).await; Ok(size) }) } + // Without this override the trait default would re-route the CDC chunk + // write through `put_blob_from_bytes` above, whose inner (synced) call + // pays the remote exists-probe per chunk. The local write-through cache + // population is kept identical — post-upload readers (thumbnail/EXIF/ + // face hooks) hit the cache instead of re-fetching from the remote. + fn put_blob_from_bytes_unsynced( + &self, + hash: &str, + data: Bytes, + ) -> Pin> + Send + '_>> { + let inner = self.inner.clone(); + let hash = hash.to_string(); + let self_ref = CachedRef { + cache_dir: self.cache_dir.clone(), + 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_unsynced(&hash, data.clone()) + .await?; + self_ref.cache_bytes_write_through(hash, &data).await; + Ok(size) + }) + } + + // The durability barrier must reach the backend that buffered the + // unsynced writes; the local cache copy is disposable and needs none. + fn sync_blobs( + &self, + hashes: &[String], + ) -> Pin> + Send + '_>> { + self.inner.sync_blobs(hashes) + } + fn get_blob_stream( &self, hash: &str, @@ -437,6 +463,24 @@ impl CachedRef { self.cache_dir.join(prefix).join(format!("{hash}.blob")) } + /// Best-effort write-through cache population shared by both blob-bytes + /// PUT paths. Deliberately no eviction sweep here — the byte budget is + /// enforced on read-miss inserts (`insert_into_cache_static`), matching + /// the historical write-path behavior. + async fn cache_bytes_write_through(&self, hash: String, data: &Bytes) { + let dest = self.cached_path(&hash); + if let Some(parent) = dest.parent() { + let _ = fs::create_dir_all(parent).await; + } + let _ = fs::write(&dest, data).await; + let data_len = data.len() as u64; + let mut idx = self.index.lock().await; + if let Some(old) = idx.put(hash, CacheEntry { size: data_len }) { + self.current_size.fetch_sub(old.size, Ordering::Relaxed); + } + self.current_size.fetch_add(data_len, Ordering::Relaxed); + } + /// 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 diff --git a/src/infrastructure/services/chunked_upload_service.rs b/src/infrastructure/services/chunked_upload_service.rs index ffc9b8e5..0ab49019 100644 --- a/src/infrastructure/services/chunked_upload_service.rs +++ b/src/infrastructure/services/chunked_upload_service.rs @@ -834,8 +834,7 @@ impl ChunkedUploadService { let data_clone = data.clone(); // Bytes::clone is O(1) — just an Arc increment let actual_checksum = tokio::task::spawn_blocking(move || { use md5::{Digest, Md5}; - let hash = Md5::digest(&data_clone); - hash.iter().map(|b| format!("{b:02x}")).collect::() + crate::common::fmt::hex_lower(&Md5::digest(&data_clone)) }) .await .map_err(|e| format!("MD5 checksum task failed: {e}"))?; diff --git a/src/infrastructure/services/dedup_service.rs b/src/infrastructure/services/dedup_service.rs index 612e20d1..149a27d7 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -1747,18 +1747,24 @@ impl DedupService { /// remote object stores where overlapping fetches hide per-chunk latency). /// Shared by [`Self::read_blob_stream`] and [`Self::read_blob_bytes`] so both /// build the chunk stream identically from a manifest's `chunk_hashes`. + /// Takes the shared manifest `Arc` and iterates its hashes by index — + /// the old `Vec` signature forced every read to deep-clone the + /// whole hash list out of the cached manifest before the first byte + /// (N ~64-B String allocs per read of an N-chunk file); the per-chunk + /// `Arc` bump here is a single atomic increment. fn stream_chunks( &self, - chunk_hashes: Vec, + manifest: Arc, ) -> Pin> + Send>> { let prefetch = self.backend.read_prefetch().max(1); let backend = self.backend.clone(); - let chunk_stream = stream::iter(chunk_hashes) - .map(move |chunk_hash| { + let chunk_stream = stream::iter(0..manifest.chunk_hashes.len()) + .map(move |i| { let backend = backend.clone(); + let manifest = manifest.clone(); async move { backend - .get_blob_stream(&chunk_hash) + .get_blob_stream(&manifest.chunk_hashes[i]) .await .map_err(|e| std::io::Error::other(e.to_string())) } @@ -1771,31 +1777,56 @@ impl DedupService { /// Cached manifest fetch for the read path (see the `manifest_cache` /// field docs). `None` = legacy whole-file blob — never cached, so a /// background rechunk that creates a manifest is honoured immediately. + /// + /// Misses are single-flighted through `try_get_with`: K concurrent cold + /// readers of one newly-hot file (e.g. parallel Range probes on a big + /// video) coalesce onto ONE manifest SELECT instead of K. The + /// positive-only contract is preserved by routing "no manifest row" and + /// DB failures through the loader's error channel, which moka never + /// caches. The zero-alloc `get` fast path stays in front so warm reads + /// don't pay the owned-key clone `try_get_with` requires. async fn manifest_cached(&self, hash: &str) -> Result>, DomainError> { if let Some(m) = self.manifest_cache.get(hash).await { return Ok(Some(m)); } - let row = sqlx::query_as::<_, (Vec, Vec, i64)>( - "SELECT chunk_hashes, chunk_sizes, total_size - FROM storage.chunk_manifests WHERE file_hash = $1", - ) - .bind(hash) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("Dedup", format!("Manifest lookup: {}", e)))?; - match row { - Some((chunk_hashes, chunk_sizes, total_size)) => { - let m = Arc::new(ChunkManifest { - chunk_hashes, - chunk_sizes, - total_size, - }); - self.manifest_cache - .insert(hash.to_string(), m.clone()) - .await; - Ok(Some(m)) - } - None => Ok(None), + + enum MissKind { + Legacy, + Db(String), + } + + let pool = self.pool.clone(); + let query_hash = hash.to_string(); + let result = self + .manifest_cache + .try_get_with(hash.to_string(), async move { + let row = sqlx::query_as::<_, (Vec, Vec, i64)>( + "SELECT chunk_hashes, chunk_sizes, total_size + FROM storage.chunk_manifests WHERE file_hash = $1", + ) + .bind(&query_hash) + .fetch_optional(pool.as_ref()) + .await + .map_err(|e| MissKind::Db(e.to_string()))?; + match row { + Some((chunk_hashes, chunk_sizes, total_size)) => Ok(Arc::new(ChunkManifest { + chunk_hashes, + chunk_sizes, + total_size, + })), + None => Err(MissKind::Legacy), + } + }) + .await; + match result { + Ok(m) => Ok(Some(m)), + Err(miss) => match &*miss { + MissKind::Legacy => Ok(None), + MissKind::Db(msg) => Err(DomainError::internal_error( + "Dedup", + format!("Manifest lookup: {}", msg), + )), + }, } } @@ -1810,7 +1841,7 @@ impl DedupService { ) -> Result> + Send>>, DomainError> { match self.manifest_cached(hash).await? { - Some(m) => Ok(self.stream_chunks(m.chunk_hashes.clone())), + Some(m) => Ok(self.stream_chunks(m)), // Legacy whole-file blob None => self.backend.get_blob_stream(hash).await, } @@ -1829,10 +1860,10 @@ impl DedupService { /// every full-blob read (e.g. 2N queries for an N-image gallery cold load). pub async fn read_blob_bytes(&self, hash: &str) -> Result { let (mut stream, expected_size) = match self.manifest_cached(hash).await? { - Some(m) => ( - self.stream_chunks(m.chunk_hashes.clone()), - m.total_size.max(0) as usize, - ), + Some(m) => { + let expected = m.total_size.max(0) as usize; + (self.stream_chunks(m), expected) + } None => { // Legacy whole-file blob: size + stream straight from the backend. let size = self.backend.blob_size(hash).await? as usize; @@ -1863,16 +1894,17 @@ impl DedupService { ) -> Result> + Send>>, DomainError> { if let Some(m) = self.manifest_cached(hash).await? { - let (chunk_hashes, chunk_sizes, total_size) = - (&m.chunk_hashes, &m.chunk_sizes, m.total_size); - let end = end.unwrap_or(total_size as u64); + let end = end.unwrap_or(m.total_size as u64); - // Calculate which chunks overlap [start, end) + // Calculate which chunks overlap [start, end). Chunks are + // addressed by manifest INDEX (the hash is read through the + // shared `Arc` at fetch time) — a `bytes=0-` probe of an + // N-chunk video used to clone all N hash Strings here. let mut offset: u64 = 0; - // (chunk_hash, range_start_within_chunk, range_end_within_chunk) - let mut selected: Vec<(String, u64, Option)> = Vec::new(); + // (chunk_index, range_start_within_chunk, range_end_within_chunk) + let mut selected: Vec<(usize, u64, Option)> = Vec::new(); - for (i, &chunk_size) in chunk_sizes.iter().enumerate() { + for (i, &chunk_size) in m.chunk_sizes.iter().enumerate() { let chunk_size = chunk_size as u64; let chunk_end = offset + chunk_size; @@ -1883,7 +1915,7 @@ impl DedupService { } else { None }; - selected.push((chunk_hashes[i].clone(), range_start, range_end)); + selected.push((i, range_start, range_end)); } offset += chunk_size; @@ -1897,11 +1929,16 @@ impl DedupService { let prefetch = self.backend.read_prefetch().max(1); let backend = self.backend.clone(); let chunk_stream = stream::iter(selected) - .map(move |(chunk_hash, range_start, range_end)| { + .map(move |(i, range_start, range_end)| { let backend = backend.clone(); + let manifest = m.clone(); async move { backend - .get_blob_range_stream(&chunk_hash, range_start, range_end) + .get_blob_range_stream( + &manifest.chunk_hashes[i], + range_start, + range_end, + ) .await .map_err(|e| std::io::Error::other(e.to_string())) } diff --git a/src/infrastructure/services/local_blob_backend.rs b/src/infrastructure/services/local_blob_backend.rs index 9a1e5b96..40f932be 100644 --- a/src/infrastructure/services/local_blob_backend.rs +++ b/src/infrastructure/services/local_blob_backend.rs @@ -128,18 +128,41 @@ async fn fsync_paths_parallel(paths: Vec, strict: bool) -> Result<(), D /// (fsync now vs. deferred batch sync), or `None` when the blob already /// existed (idempotent skip — content-addressed, so identical by definition). async fn write_blob_bytes(blob_path: &Path, data: &Bytes) -> Result, DomainError> { - if fs::try_exists(blob_path).await.unwrap_or(false) { - return Ok(None); - } - let mut file = fs::File::create(blob_path).await.map_err(|e| { - DomainError::internal_error("Blob", format!("Failed to create blob file: {}", e)) - })?; + // One atomic O_CREAT|O_EXCL open replaces the old stat-then-create pair: + // `AlreadyExists` IS the idempotent skip (content-addressed names mean an + // existing file has identical content), saving a syscall + a blocking-pool + // dispatch on every new chunk of every upload. + let mut file = match fs::File::options() + .write(true) + .create_new(true) + .open(blob_path) + .await + { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(None), + Err(e) => { + return Err(DomainError::internal_error( + "Blob", + format!("Failed to create blob file: {}", e), + )); + } + }; file.write_all(data).await.map_err(|e| { DomainError::internal_error("Blob", format!("Failed to write blob from bytes: {}", e)) })?; Ok(Some(file)) } +/// Bench-only public wrapper (feature = "bench") over the private chunk +/// writer so `examples/bench_storage_micro.rs` can A/B the open strategy. +#[cfg(feature = "bench")] +pub async fn write_blob_bytes_for_bench( + blob_path: &Path, + data: &Bytes, +) -> Result, DomainError> { + write_blob_bytes(blob_path, data).await +} + /// Compile-time lookup table for the 256 two-digit lowercase hex prefixes ("00"…"ff"). static HEX_PREFIXES: [&str; 256] = [ "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "0a", "0b", "0c", "0d", "0e", "0f", diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index ecc679ea..fec27748 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -116,6 +116,15 @@ const CASCADE_GRANT_CACHE_CAPACITY: u64 = 100_000; /// invalidation tree". Short enough that any such change takes effect in <1 min. const CASCADE_GRANT_CACHE_TTL: Duration = Duration::from_secs(30); +/// `file_parent_cache` bound/TTL: `file_id → Option` point rows +/// (~50 B each) resolved on the file-cascade path so an N-file album pays +/// ONE folder-cascade query instead of N (ROUND9). Parentage changes only +/// on move — an indirect path the cascade cache already self-heals via TTL, +/// so the same 30 s window applies (grant writes don't alter parentage and +/// need no flush here). +const FILE_PARENT_CACHE_CAPACITY: u64 = 100_000; +const FILE_PARENT_CACHE_TTL: Duration = Duration::from_secs(30); + pub struct PgAclEngine { pool: Arc, folder_repo: Arc, @@ -202,6 +211,13 @@ pub struct PgAclEngine { /// only positively-or-negatively for at most the TTL. A revoke via /// `clear_role` flushes immediately; anything missed self-heals in ≤30 s. cascade_grant_cache: Cache<(Subject, Resource, Permission), bool>, + /// `file_id → Option` memo for the file-cascade + /// decomposition (see `cascade_grant_cached`): resolving the parent lets + /// a whole folder's files share ONE folder-cascade decision, so a shared + /// album's first view runs one ltree query instead of one per file. + /// Grant writes don't affect parentage — only the TTL applies (moves are + /// an indirect path, same self-heal contract as `cascade_grant_cache`). + file_parent_cache: Cache>, } impl PgAclEngine { @@ -243,6 +259,10 @@ impl PgAclEngine { .max_capacity(CASCADE_GRANT_CACHE_CAPACITY) .time_to_live(CASCADE_GRANT_CACHE_TTL) .build(), + file_parent_cache: Cache::builder() + .max_capacity(FILE_PARENT_CACHE_CAPACITY) + .time_to_live(FILE_PARENT_CACHE_TTL) + .build(), } } @@ -317,6 +337,10 @@ impl PgAclEngine { .max_capacity(1) .time_to_live(Duration::from_secs(1)) .build(), + file_parent_cache: Cache::builder() + .max_capacity(1) + .time_to_live(Duration::from_secs(1)) + .build(), } } @@ -718,11 +742,11 @@ impl PgAclEngine { Ok(exists.is_some()) } - /// Cascading check for files: either a direct file grant OR a grant on - /// any ancestor folder of the file's containing folder. See - /// `folder_cascade_grant_exists` for the meaning of `subject_types` / - /// `subject_ids` and the D-Prep role-array migration. - async fn file_cascade_grant_exists( + /// Direct file grant only — the first branch of the historical file + /// cascade UNION, split out so `cascade_grant_cached` can amortize the + /// ancestor-folder branch per FOLDER (see the `Resource::File` arm). + /// A plain indexed `role_grants` point lookup, no ltree join. + async fn file_direct_grant_exists( &self, subject_types: &[&str], subject_ids: &[Uuid], @@ -735,30 +759,12 @@ impl PgAclEngine { let exists: Option = sqlx::query_scalar( r#" SELECT 1 - FROM ( - -- direct file grant - SELECT 1 - FROM storage.role_grants - WHERE subject_type = ANY($1) - AND subject_id = ANY($2) - AND role = ANY($3::storage.grant_role[]) - AND resource_type = 'file' AND resource_id = $4 - AND (expires_at IS NULL OR expires_at > NOW()) - UNION ALL - -- cascading from any ancestor folder of the file's containing folder - SELECT 1 - FROM storage.role_grants g - JOIN storage.folders gf ON gf.id = g.resource_id - JOIN storage.files target_f ON target_f.id = $4 - WHERE g.subject_type = ANY($1) - AND g.subject_id = ANY($2) - AND g.role = ANY($3::storage.grant_role[]) - AND g.resource_type = 'folder' - AND (g.expires_at IS NULL OR g.expires_at > NOW()) - AND target_f.folder_id IS NOT NULL - AND gf.lpath @> (SELECT lpath FROM storage.folders - WHERE id = target_f.folder_id) - ) any_match + FROM storage.role_grants + WHERE subject_type = ANY($1) + AND subject_id = ANY($2) + AND role = ANY($3::storage.grant_role[]) + AND resource_type = 'file' AND resource_id = $4 + AND (expires_at IS NULL OR expires_at > NOW()) LIMIT 1 "#, ) @@ -768,11 +774,36 @@ impl PgAclEngine { .bind(file_id) .fetch_optional(self.pool.as_ref()) .await - .map_err(|e| DomainError::internal_error("PgAcl", format!("file cascade: {e}")))?; + .map_err(|e| DomainError::internal_error("PgAcl", format!("file direct grant: {e}")))?; Ok(exists.is_some()) } + /// Memoised `file_id → Option` point read backing the + /// file-cascade decomposition. `None` covers both a missing row and a + /// NULL `folder_id` — in either case only the direct-file-grant branch + /// can match (mirroring the historical UNION's `folder_id IS NOT NULL` + /// guard). + async fn file_parent_folder_cached( + &self, + file_id: Uuid, + counters: &QueryCounters, + ) -> Result, DomainError> { + if let Some(parent) = self.file_parent_cache.get(&file_id).await { + return Ok(parent); + } + counters.sql_queries.fetch_add(1, Ordering::Relaxed); + let parent: Option> = + sqlx::query_scalar("SELECT folder_id FROM storage.files WHERE id = $1") + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("file parent: {e}")))?; + let parent = parent.flatten(); + self.file_parent_cache.insert(file_id, parent).await; + Ok(parent) + } + /// Cache-aware wrapper over the File/Folder grant cascade. Serves the /// memoised `(subject, resource, permission)` decision when warm; on a /// miss it expands the subject set (itself cached) and runs the matching @@ -780,10 +811,23 @@ impl PgAclEngine { /// precheck fails, so it never caches a decision a drive grant would have /// satisfied — a later drive grant short-circuits above this cache. /// + /// **File decomposition (ROUND9).** The historical file query was one + /// UNION: `direct file grant ∨ grant on any ancestor of the parent + /// folder` — one ltree join per file, so a shared N-photo album's FIRST + /// view ran N near-identical ancestor queries (round 8 memoised only the + /// per-file result, covering revalidation). The arm now resolves the + /// file's parent (memoised point read) and recurses into the FOLDER arm + /// for the ancestor half — one ltree query per folder, shared by every + /// sibling — falling back to the direct-file-grant lookup only when the + /// folder half denies. The decomposition is exactly the UNION split in + /// two: no decision changes, including the parentless edge (the UNION's + /// `folder_id IS NOT NULL` guard ≡ the direct-only fallback). + /// /// The result is a pure function of the subject's group expansion + the /// resource's grants + folder ancestry; `invalidate_cascade_grant_cache_all` - /// (on File/Folder grant writes) and the 30 s TTL (indirect changes) keep - /// it fresh. See the `cascade_grant_cache` field doc. + /// (on File/Folder grant writes — it holds file AND folder decisions in + /// the same map) and the 30 s TTL (indirect changes, incl. moves for the + /// parent memo) keep it fresh. See the `cascade_grant_cache` field doc. async fn cascade_grant_cached( &self, subject: Subject, @@ -799,9 +843,10 @@ impl PgAclEngine { counters.cache_hit.fetch_add(1, Ordering::Relaxed); return Ok(allowed); } - let (subject_types, subject_ids) = self.subject_match_set(subject, counters).await?; let allowed = match resource { Resource::Folder(id) => { + let (subject_types, subject_ids) = + self.subject_match_set(subject, counters).await?; self.folder_cascade_grant_exists( &subject_types, &subject_ids, @@ -812,14 +857,34 @@ impl PgAclEngine { .await? } Resource::File(id) => { - self.file_cascade_grant_exists( - &subject_types, - &subject_ids, - permission, - id, - counters, - ) - .await? + // Ancestor half first — amortized to one query per FOLDER + // via the recursive Folder arm (its own cache entry). + let folder_allowed = match self.file_parent_folder_cached(id, counters).await? { + Some(parent) => { + Box::pin(self.cascade_grant_cached( + subject, + Resource::Folder(parent), + permission, + counters, + )) + .await? + } + None => false, + }; + if folder_allowed { + true + } else { + let (subject_types, subject_ids) = + self.subject_match_set(subject, counters).await?; + self.file_direct_grant_exists( + &subject_types, + &subject_ids, + permission, + id, + counters, + ) + .await? + } } // Only File/Folder reach this helper (see `check_inner`). _ => return Ok(false), diff --git a/src/infrastructure/services/retry_blob_backend.rs b/src/infrastructure/services/retry_blob_backend.rs index 8727a1ed..32dd3384 100644 --- a/src/infrastructure/services/retry_blob_backend.rs +++ b/src/infrastructure/services/retry_blob_backend.rs @@ -159,6 +159,43 @@ impl BlobStorageBackend for RetryBlobBackend { }) } + // Without this override the trait default would re-route the CDC chunk + // write through `put_blob_from_bytes` above — reinstating the remote + // backend's exists-probe (HEAD/get_properties) per chunk that the + // `_unsynced` fast path exists to skip. + fn put_blob_from_bytes_unsynced( + &self, + hash: &str, + data: Bytes, + ) -> Pin> + Send + '_>> { + let inner = self.inner.clone(); + let policy = self.policy.clone(); + let hash = hash.to_string(); + Box::pin(async move { + retry_async( + &policy, + &format!("put_blob_from_bytes_unsynced({hash})"), + || { + let inner = inner.clone(); + let hash = hash.clone(); + let data = data.clone(); + async move { inner.put_blob_from_bytes_unsynced(&hash, data).await } + }, + ) + .await + }) + } + + // Forwarded WITHOUT retry wrapping: a failed fsync must surface, not be + // re-issued — after an fsync error the kernel may have dropped the dirty + // pages, so a retried fsync can report success for data that was lost. + fn sync_blobs( + &self, + hashes: &[String], + ) -> Pin> + Send + '_>> { + self.inner.sync_blobs(hashes) + } + fn get_blob_stream( &self, hash: &str, diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index 340e37a0..ad525a0d 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -198,7 +198,7 @@ pub async fn list_favorites_resources( // Path is only shown to the owner; non-owners see "" // to avoid leaking another user's folder hierarchy. let path = if row.is_owner { - row.path.clone().unwrap_or_default() + row.path.unwrap_or_default() } else { String::new() }; @@ -208,7 +208,7 @@ pub async fn list_favorites_resources( let dto = FolderDto { etag: resource_id.clone(), id: resource_id, - name: row.name.clone(), + name: row.name, path, parent_id: row.parent_id.map(|u| u.to_string()), drive_id: row.drive_id, @@ -239,26 +239,30 @@ pub async fn list_favorites_resources( // file. `blob_hash` is `None` only for // folder rows, which take the other branch. let modified_at_u = row.modified_at.timestamp() as u64; - let content_hash = row.blob_hash.clone().unwrap_or_default(); + let content_hash = row.blob_hash.unwrap_or_default(); let etag = if content_hash.is_empty() { String::new() } else { File::compute_etag(&content_hash, modified_at_u) }; + // Name-derived display classes borrow `row.name`; + // compute them before the name moves into the DTO. + let icon_class = intern_display(icon_class_for(&row.name, mime)); + let icon_special_class = + intern_display(icon_special_class_for(&row.name, mime)); + let category = intern_display(category_for(&row.name, mime)); let dto = FileDto { id: row.resource_id.to_string(), - name: row.name.clone(), + name: row.name, path, size: size_bytes, mime_type: intern_mime(mime), folder_id: row.parent_id.map(|u| u.to_string()), created_at: row.resource_created_at.timestamp() as u64, modified_at: modified_at_u, - icon_class: intern_display(icon_class_for(&row.name, mime)), - icon_special_class: intern_display(icon_special_class_for( - &row.name, mime, - )), - category: intern_display(category_for(&row.name, mime)), + icon_class, + icon_special_class, + category, size_formatted: format_file_size(size_bytes), sort_date: None, content_hash, diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index cc707977..35eb5003 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -510,7 +510,7 @@ pub async fn list_folder_resources( // listing's `etag` byte-equals what a // conditional request would compare against. let modified_at_u = row.modified_at.timestamp() as u64; - let content_hash = row.blob_hash.clone().unwrap_or_default(); + let content_hash = row.blob_hash.unwrap_or_default(); let etag = if content_hash.is_empty() { String::new() } else { diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 7563f877..4b9b9163 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -214,7 +214,7 @@ pub async fn list_recent_resources( // Path is only shown to the owner; non-owners see "" // to avoid leaking another user's folder hierarchy. let path = if row.is_owner { - row.path.clone().unwrap_or_default() + row.path.unwrap_or_default() } else { String::new() }; @@ -224,7 +224,7 @@ pub async fn list_recent_resources( let dto = FolderDto { etag: resource_id.clone(), id: resource_id, - name: row.name.clone(), + name: row.name, path, parent_id: row.parent_id.map(|u| u.to_string()), drive_id: row.drive_id, @@ -253,26 +253,30 @@ pub async fn list_recent_resources( // listing matches GET/HEAD/PROPFIND byte-for-byte // for the same file. let modified_at_u = row.modified_at.timestamp() as u64; - let content_hash = row.blob_hash.clone().unwrap_or_default(); + let content_hash = row.blob_hash.unwrap_or_default(); let etag = if content_hash.is_empty() { String::new() } else { File::compute_etag(&content_hash, modified_at_u) }; + // Name-derived display classes borrow `row.name`; + // compute them before the name moves into the DTO. + let icon_class = intern_display(icon_class_for(&row.name, mime)); + let icon_special_class = + intern_display(icon_special_class_for(&row.name, mime)); + let category = intern_display(category_for(&row.name, mime)); let dto = FileDto { id: row.resource_id.to_string(), - name: row.name.clone(), + name: row.name, path, size: size_bytes, mime_type: intern_mime(mime), folder_id: row.parent_id.map(|u| u.to_string()), created_at: row.resource_created_at.timestamp() as u64, modified_at: modified_at_u, - icon_class: intern_display(icon_class_for(&row.name, mime)), - icon_special_class: intern_display(icon_special_class_for( - &row.name, mime, - )), - category: intern_display(category_for(&row.name, mime)), + icon_class, + icon_special_class, + category, size_formatted: format_file_size(size_bytes), sort_date: None, content_hash, diff --git a/src/interfaces/nextcloud/basic_auth_middleware.rs b/src/interfaces/nextcloud/basic_auth_middleware.rs index c781cf74..1a50f3e7 100644 --- a/src/interfaces/nextcloud/basic_auth_middleware.rs +++ b/src/interfaces/nextcloud/basic_auth_middleware.rs @@ -28,12 +28,16 @@ use crate::interfaces::middleware::auth::CurrentUser; /// default drive root, so no per-request authorization decision is being /// skipped. The drive-marker branch keeps its `get_folder_with_perms` /// check on every request. -static NC_CHROOT_CACHE: LazyLock> = LazyLock::new(|| { - moka::sync::Cache::builder() - .max_capacity(100_000) - .time_to_live(Duration::from_secs(30)) - .build() -}); +// `Arc` values: a hit hands back a refcount bump instead of a +// deep clone of the DTO's ~5 owned Strings (moka's `get` clones `V`), and +// the same `Arc` then rides inside `NcSession` for the whole request. +static NC_CHROOT_CACHE: LazyLock>> = + LazyLock::new(|| { + moka::sync::Cache::builder() + .max_capacity(100_000) + .time_to_live(Duration::from_secs(30)) + .build() + }); #[derive(Debug, thiserror::Error)] pub enum NextcloudAuthError { @@ -184,13 +188,19 @@ pub async fn basic_auth_middleware( // request would appear in the logs with `user_id=-`, // making it harder to correlate WebDAV / OCS activity to // a specific principal. - tracing::Span::current().record("user_id", user_id.to_string()); - let current_user = CurrentUser { + // `field::display` renders lazily into the subscriber's buffer — + // no per-request `to_string` (mirrors the JWT path since ROUND5). + tracing::Span::current().record("user_id", tracing::field::display(user_id)); + // One shared identity: the same `Arc` serves the + // `Arc` extension AND `NcSession.user` (the old + // code built the struct, cloned it for the extension, then + // moved the original — 2-3 String allocs per request). + let current_user = Arc::new(CurrentUser { id: user_id, username: uname, email, role, - }; + }); // ── Resolve chroot from the Basic Auth drive marker ───── // No marker → caller's default personal drive's root folder @@ -226,9 +236,10 @@ pub async fn basic_auth_middleware( .folder_service .get_folder(&root_id.to_string()) .await - .ok(); + .ok() + .map(Arc::new); if let Some(f) = &fetched { - NC_CHROOT_CACHE.insert(root_id, f.clone()); + NC_CHROOT_CACHE.insert(root_id, Arc::clone(f)); } fetched } @@ -242,7 +253,8 @@ pub async fn basic_auth_middleware( .folder_service .get_folder_with_perms(folder_id, current_user.id) .await - .ok(), + .ok() + .map(Arc::new), }; if chroot.is_none() { tracing::warn!( @@ -253,25 +265,20 @@ pub async fn basic_auth_middleware( return Err(NextcloudAuthError::Unauthorized); } - request - .extensions_mut() - .insert(Arc::new(current_user.clone())); + // Record from the local before it moves into the session — + // the old code re-read the just-inserted extension and paid a + // `to_string` for the span value. + if let Some(c) = &chroot { + tracing::Span::current().record("chroot_id", tracing::field::display(&c.id)); + } + request.extensions_mut().insert(Arc::clone(¤t_user)); request.extensions_mut().insert(Arc::new( crate::interfaces::nextcloud::session::NcSession { user: current_user, - raw_username: raw_username.clone(), + raw_username, chroot, }, )); - tracing::Span::current().record( - "chroot_id", - request - .extensions() - .get::>() - .and_then(|s| s.chroot.as_ref()) - .map(|c| c.id.to_string()) - .unwrap_or_default(), - ); Ok(next.run(request).await) } Err(_) => { diff --git a/src/interfaces/nextcloud/ocs_handler.rs b/src/interfaces/nextcloud/ocs_handler.rs index af53bcd2..3ad82ae2 100644 --- a/src/interfaces/nextcloud/ocs_handler.rs +++ b/src/interfaces/nextcloud/ocs_handler.rs @@ -35,20 +35,51 @@ fn ocs_err(statuscode: u16, message: &str) -> serde_json::Value { } pub async fn handle_capabilities_v1(State(state): State>) -> Response { - let payload = capabilities_payload(&state, 1); tracing::info!("[NC] capabilities v1 requested, returning payload"); - Json(payload).into_response() + capabilities_response(&state, 1) } pub async fn handle_capabilities_v2(State(state): State>) -> Response { - let payload = capabilities_payload(&state, 2); tracing::info!("[NC] capabilities v2 requested, returning payload"); - Json(payload).into_response() + capabilities_response(&state, 2) +} + +/// Pre-serialized capabilities bodies, `[v1, v2]`. The payload is +/// process-invariant (pure config: base URL + emulated NC version), yet +/// every desktop/mobile client polls it periodically — the old handler +/// re-built the ~40-node `json!` tree, re-read `OXICLOUD_BASE_URL` from +/// the environment and re-serialized on every poll. Now that work runs +/// once; a poll is a `Bytes` refcount bump. +static CAPABILITIES_BODIES: std::sync::OnceLock<[bytes::Bytes; 2]> = std::sync::OnceLock::new(); + +fn capabilities_response(state: &AppState, ocs_version: u8) -> Response { + let bodies = CAPABILITIES_BODIES.get_or_init(|| { + let base_url = state.core.config.base_url(); + let emulated = state.core.config.nextcloud.emulated_version; + let version_string = state.core.config.nextcloud.version_string(); + [1u8, 2u8].map(|v| { + bytes::Bytes::from( + serde_json::to_vec(&capabilities_payload( + &base_url, + emulated, + &version_string, + v, + )) + .expect("static capabilities JSON serializes"), + ) + }) + }); + let body = bodies[usize::from(ocs_version != 1)].clone(); + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + body, + ) + .into_response() } pub async fn handle_user_info( State(state): State>, - session: crate::interfaces::nextcloud::session::NcSession, + session: crate::interfaces::nextcloud::session::SharedNcSession, ) -> Response { let quota: (i64, i64) = match state.storage_usage_service.as_ref() { Some(service) => match service.get_user_storage_info(session.user.id).await { @@ -530,11 +561,19 @@ fn empty_search_response() -> Json { })) } -fn capabilities_payload(state: &AppState, ocs_version: u8) -> serde_json::Value { +/// Build the capabilities JSON tree from its three config inputs. Public +/// only under the `bench` feature caller path via +/// [`capabilities_payload_for_bench`]; production reaches it once through +/// the [`CAPABILITIES_BODIES`] init. +fn capabilities_payload( + base_url: &str, + emulated_version: (u32, u32, u32), + version_string: &str, + ocs_version: u8, +) -> serde_json::Value { let statuscode = if ocs_version == 1 { 100 } else { 200 }; - let base_url = state.core.config.base_url(); - let (nc_major, nc_minor, nc_micro) = state.core.config.nextcloud.emulated_version; - let nc_version_str = state.core.config.nextcloud.version_string(); + let (nc_major, nc_minor, nc_micro) = emulated_version; + let nc_version_str = version_string; json!({ "ocs": { @@ -602,6 +641,19 @@ fn capabilities_payload(state: &AppState, ocs_version: u8) -> serde_json::Value }) } +/// Bench-only public wrapper (feature = "bench") over the private payload +/// builder so `examples/bench_capabilities_static.rs` can A/B the +/// rebuild-per-poll flow against the memoized bytes. +#[cfg(feature = "bench")] +pub fn capabilities_payload_for_bench( + base_url: &str, + emulated_version: (u32, u32, u32), + version_string: &str, + ocs_version: u8, +) -> serde_json::Value { + capabilities_payload(base_url, emulated_version, version_string, ocs_version) +} + fn extract_basic_password(headers: &axum::http::HeaderMap) -> Option { let value = headers .get(axum::http::header::AUTHORIZATION)? diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 5c1952fb..cca4f19f 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -10,9 +10,7 @@ use quick_xml::{ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use crate::application::dtos::display_helpers::{ - category_for, format_file_size, icon_class_for, icon_special_class_for, -}; +use crate::application::dtos::display_helpers::format_file_size; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::application::dtos::search_dto::SearchCriteriaDto; @@ -401,15 +399,16 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes name: fr.name.clone(), path: fr.path.clone(), size: fr.size, - mime_type: fr.mime_type.clone().into(), + // Interned `Arc` carried through from enrichment — refcount + // bumps; the old code re-ran all three display classifiers and + // re-allocated each value per converted search row. + mime_type: fr.mime_type.clone(), folder_id: fr.folder_id.clone(), created_at: fr.created_at, modified_at: fr.modified_at, - icon_class: icon_class_for(&fr.name, &fr.mime_type).to_string().into(), - icon_special_class: icon_special_class_for(&fr.name, &fr.mime_type) - .to_string() - .into(), - category: category_for(&fr.name, &fr.mime_type).to_string().into(), + icon_class: fr.icon_class.clone(), + icon_special_class: fr.icon_special_class.clone(), + category: fr.category.clone(), size_formatted: format_file_size(fr.size), sort_date: None, content_hash: fr.blob_hash.clone(), @@ -420,6 +419,16 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes } } +/// Bench-only public wrapper (feature = "bench") over the private +/// search→FileDto conversion so `examples/bench_search_enrich.rs` can +/// measure and equivalence-gate it. +#[cfg(feature = "bench")] +pub fn file_dto_from_search_for_bench( + fr: &crate::application::dtos::search_dto::SearchFileResultDto, +) -> FileDto { + file_dto_from_search(fr) +} + /// Build a `FolderDto` from a search folder result. fn folder_dto_from_search( sr: &crate::application::dtos::search_dto::SearchFolderResultDto, diff --git a/src/interfaces/nextcloud/routes.rs b/src/interfaces/nextcloud/routes.rs index 6ff08867..0f9c7835 100644 --- a/src/interfaces/nextcloud/routes.rs +++ b/src/interfaces/nextcloud/routes.rs @@ -17,7 +17,7 @@ use crate::interfaces::nextcloud::basic_auth_middleware::basic_auth_middleware; use crate::interfaces::nextcloud::login_v2_handler; use crate::interfaces::nextcloud::ocs_handler; use crate::interfaces::nextcloud::preview_handler; -use crate::interfaces::nextcloud::session::NcSession; +use crate::interfaces::nextcloud::session::SharedNcSession; use crate::interfaces::nextcloud::status_handler; use crate::interfaces::nextcloud::trashbin_handler; use crate::interfaces::nextcloud::uploads_handler; @@ -216,7 +216,7 @@ pub fn nextcloud_routes_with_state(state: Arc) -> Router async fn handle_dav_files( State(state): State>, Path((_url_user, subpath)): Path<(String, String)>, - session: NcSession, + session: SharedNcSession, req: Request, ) -> Result { webdav_handler::handle_nc_webdav(state, req, session, subpath) @@ -227,7 +227,7 @@ async fn handle_dav_files( async fn handle_dav_files_root( State(state): State>, Path(_url_user): Path, - session: NcSession, + session: SharedNcSession, req: Request, ) -> Result { webdav_handler::handle_nc_webdav(state, req, session, String::new()) @@ -238,7 +238,7 @@ async fn handle_dav_files_root( async fn handle_dav_uploads( State(state): State>, Path((_url_user, upload_id, rest)): Path<(String, String, String)>, - session: NcSession, + session: SharedNcSession, req: Request, ) -> Result { uploads_handler::handle_nc_uploads(state, req, session, upload_id, rest) @@ -249,7 +249,7 @@ async fn handle_dav_uploads( async fn handle_dav_uploads_root( State(state): State>, Path((_url_user, upload_id)): Path<(String, String)>, - session: NcSession, + session: SharedNcSession, req: Request, ) -> Result { uploads_handler::handle_nc_uploads(state, req, session, upload_id, String::new()) @@ -279,7 +279,7 @@ async fn handle_legacy_webdav_root(user_ext: AuthUser) -> Response { async fn handle_dav_trashbin( State(state): State>, Path((_url_user, subpath)): Path<(String, String)>, - session: NcSession, + session: SharedNcSession, req: Request, ) -> Result { trashbin_handler::handle_nc_trashbin(state, req, session, subpath) @@ -290,7 +290,7 @@ async fn handle_dav_trashbin( async fn handle_dav_trashbin_root( State(state): State>, Path(_url_user): Path, - session: NcSession, + session: SharedNcSession, req: Request, ) -> Result { trashbin_handler::handle_nc_trashbin(state, req, session, String::new()) diff --git a/src/interfaces/nextcloud/session.rs b/src/interfaces/nextcloud/session.rs index 2e48f198..916dd4f4 100644 --- a/src/interfaces/nextcloud/session.rs +++ b/src/interfaces/nextcloud/session.rs @@ -3,8 +3,9 @@ //! Bundles WHO the caller is, the raw wire username they presented, //! and (for path-scoped endpoints) WHERE they're confined to. Built //! by `basic_auth_middleware` and stashed in request extensions as -//! `Arc`; handlers extract it via the [`FromRequestParts`] -//! impl below — just declare `session: NcSession` in the signature. +//! `Arc`; handlers extract it via [`SharedNcSession`] +//! (derefs to `NcSession`) — declare `session: SharedNcSession` in +//! the signature. //! //! ## Source of truth //! @@ -46,9 +47,13 @@ use crate::interfaces::middleware::auth::CurrentUser; #[derive(Debug, Clone)] pub struct NcSession { - pub user: CurrentUser, + /// Shared with the `Arc` request extension — one identity + /// build per request instead of a clone per consumer. + pub user: Arc, pub raw_username: String, - pub chroot: Option, + /// Shared with `NC_CHROOT_CACHE` (markerless branch) — a cache hit is + /// an `Arc` bump, not a `FolderDto` deep-clone. + pub chroot: Option>, } impl NcSession { @@ -56,7 +61,7 @@ impl NcSession { /// without one. Documents the invariant that every NC route /// today is path-scoped — if this fires, route wiring is wrong. pub fn require_chroot(&self) -> Result<&FolderDto, AppError> { - self.chroot.as_ref().ok_or_else(|| { + self.chroot.as_deref().ok_or_else(|| { AppError::internal_error( "NcSession: path-scoped handler reached without a chroot — route wiring bug", ) @@ -101,10 +106,13 @@ fn extract_url_user(path: &str) -> Option { urlencoding::decode(user_seg).ok().map(|s| s.into_owned()) } -/// Axum extractor: pulls the `Arc` that -/// `basic_auth_middleware` stashed in request extensions and clones -/// it (cheap — one `Arc` increment, no field copy) into an owned -/// `NcSession` for handler use. +/// Axum extractor: the shared handle to the request's [`NcSession`]. +/// +/// Derefs to `NcSession`, so handler bodies read `session.user`, +/// `session.require_chroot()`, … unchanged. Extraction is one `Arc` +/// refcount increment — the previous extractor deep-cloned the whole +/// session (`CurrentUser` + `raw_username` + chroot `FolderDto`, ~8-9 +/// `String` allocs) on every authenticated NC request. /// /// On path-scoped DAV routes (`/remote.php/dav/{files,uploads, /// trashbin}/{user}/…`), the URL `{user}` segment is cross-checked @@ -113,14 +121,33 @@ fn extract_url_user(path: &str) -> Option { /// (`get_folder_with_perms`) is what actually prevents cross-user /// access. It just surfaces malformed requests early (403) instead /// of silently letting them through. -impl FromRequestParts for NcSession { +#[derive(Debug, Clone)] +pub struct SharedNcSession(Arc); + +impl SharedNcSession { + /// Wrap an already-shared session (used by the bench harness; the + /// middleware inserts the `Arc` into request extensions directly). + pub fn from_arc(session: Arc) -> Self { + Self(session) + } +} + +impl std::ops::Deref for SharedNcSession { + type Target = NcSession; + + fn deref(&self) -> &NcSession { + &self.0 + } +} + +impl FromRequestParts for SharedNcSession { type Rejection = Response; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { let session = parts .extensions .get::>() - .map(|arc| (**arc).clone()) + .cloned() .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; if let Some(url_user) = extract_url_user(parts.uri.path()) @@ -129,6 +156,6 @@ impl FromRequestParts for NcSession { return Err(StatusCode::FORBIDDEN.into_response()); } - Ok(session) + Ok(Self(session)) } } diff --git a/src/interfaces/nextcloud/trashbin_handler.rs b/src/interfaces/nextcloud/trashbin_handler.rs index 6f823f28..f8f93746 100644 --- a/src/interfaces/nextcloud/trashbin_handler.rs +++ b/src/interfaces/nextcloud/trashbin_handler.rs @@ -27,7 +27,7 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); pub async fn handle_nc_trashbin( state: Arc, req: Request, - session: crate::interfaces::nextcloud::session::NcSession, + session: crate::interfaces::nextcloud::session::SharedNcSession, subpath: String, ) -> Result, AppError> { let method = req.method().clone(); diff --git a/src/interfaces/nextcloud/uploads_handler.rs b/src/interfaces/nextcloud/uploads_handler.rs index d4761c47..9f480238 100644 --- a/src/interfaces/nextcloud/uploads_handler.rs +++ b/src/interfaces/nextcloud/uploads_handler.rs @@ -110,7 +110,7 @@ async fn session_bytes_so_far( pub async fn handle_nc_uploads( state: Arc, req: Request, - session: crate::interfaces::nextcloud::session::NcSession, + session: crate::interfaces::nextcloud::session::SharedNcSession, upload_id: String, rest: String, // chunk name or ".file" or empty ) -> Result, AppError> { diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index a4c94061..17c310c3 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -218,7 +218,7 @@ pub fn nc_href(username: &str, subpath: &str) -> String { pub async fn handle_nc_webdav( state: Arc, req: Request, - session: crate::interfaces::nextcloud::session::NcSession, + session: crate::interfaces::nextcloud::session::SharedNcSession, subpath: String, ) -> Result, AppError> { // Validate up-front that we have a chroot — every method below is @@ -1566,19 +1566,29 @@ fn build_nc_streaming_propfind( } let batch_len = batch.len(); - // Per-page enrichment: favorites + oc:fileids, two batch queries. - let favs = if let Some(fav) = fav_svc { - let items: Vec<(&str, &str)> = - batch.iter().map(|f| (f.id.as_str(), "file")).collect(); - fav.batch_check_favorites(user_id, &items).await.unwrap_or_default() - } else { - HashSet::new() - }; + // Per-page enrichment: favorites + oc:fileids + dead props — + // three independent reads over the same id batch, overlapped + // with `join!` so a page pays ~max(RTT) instead of 3×RTT + // (each query still batched per page: DEAD-PROPS.md). The + // round-7 deferred "serial pairs" item, adopted for this + // per-page triple after the injected-latency A/B in + // benches/ROUND9.md showed no local-PG regression. + let fav_items: Vec<(&str, &str)> = + batch.iter().map(|f| (f.id.as_str(), "file")).collect(); let file_uuids: Vec<&str> = batch.iter().map(|f| f.id.as_str()).collect(); - let (file_id_map, _) = batch_resolve_ids(file_id_svc, &file_uuids, &[]).await; - // One batched dead-props query per page, not one per child - // (benches/DEAD-PROPS.md). - let file_deads = files_dead_props_map(&state.webdav_dead_props, &batch).await; + let (favs, (file_id_map, _), file_deads) = tokio::join!( + async { + if let Some(fav) = fav_svc { + fav.batch_check_favorites(user_id, &fav_items) + .await + .unwrap_or_default() + } else { + HashSet::new() + } + }, + batch_resolve_ids(file_id_svc, &file_uuids, &[]), + files_dead_props_map(&state.webdav_dead_props, &batch), + ); let mut chunk = Vec::with_capacity(batch_len * 1024); { @@ -1624,18 +1634,23 @@ fn build_nc_streaming_propfind( break; } - let favs = if let Some(fav) = fav_svc { - let items: Vec<(&str, &str)> = - batch.iter().map(|sf| (sf.id.as_str(), "folder")).collect(); - fav.batch_check_favorites(user_id, &items).await.unwrap_or_default() - } else { - HashSet::new() - }; + // Same overlapped enrichment triple as the file pages above. + let fav_items: Vec<(&str, &str)> = + batch.iter().map(|sf| (sf.id.as_str(), "folder")).collect(); let folder_uuids: Vec<&str> = batch.iter().map(|sf| sf.id.as_str()).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, &batch).await; + let (favs, (_, sub_id_map), sub_deads) = tokio::join!( + async { + if let Some(fav) = fav_svc { + fav.batch_check_favorites(user_id, &fav_items) + .await + .unwrap_or_default() + } else { + HashSet::new() + } + }, + batch_resolve_ids(file_id_svc, &[], &folder_uuids), + folders_dead_props_map(&state.webdav_dead_props, &batch), + ); let mut chunk = Vec::with_capacity(batch.len() * 1024); {