Merge branch 'main' into idp-auto-redirect
This commit is contained in:
Generated
-119
@@ -341,37 +341,6 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-config"
|
||||
version = "1.8.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-sdk-sso",
|
||||
"aws-sdk-ssooidc",
|
||||
"aws-sdk-sts",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand 2.4.1",
|
||||
"hex",
|
||||
"http 1.4.0",
|
||||
"sha1 0.10.6",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-credential-types"
|
||||
version = "1.2.14"
|
||||
@@ -470,82 +439,6 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sso"
|
||||
version = "1.102.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c82b3ac19f1431854f7ace3a7531674633e286bfdde21976893bfee36fd493b"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand 2.4.1",
|
||||
"http 0.2.12",
|
||||
"http 1.4.0",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-ssooidc"
|
||||
version = "1.104.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321000d2b4c5519ee573f73167f612efd7329322d9b26969ad1979f0427f1913"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand 2.4.1",
|
||||
"http 0.2.12",
|
||||
"http 1.4.0",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sts"
|
||||
version = "1.107.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d0d328ba962af23ecfa3c9f23b98d3d35e325fa218d7f13d17a6bf522f8a560"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-query",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"aws-smithy-xml",
|
||||
"aws-types",
|
||||
"fastrand 2.4.1",
|
||||
"http 0.2.12",
|
||||
"http 1.4.0",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sigv4"
|
||||
version = "1.4.5"
|
||||
@@ -688,16 +581,6 @@ dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-query"
|
||||
version = "0.60.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd"
|
||||
dependencies = [
|
||||
"aws-smithy-types",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime"
|
||||
version = "1.11.3"
|
||||
@@ -4231,9 +4114,7 @@ dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"async_zip",
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"aws-smithy-types",
|
||||
"axum",
|
||||
"azure_core",
|
||||
"azure_storage",
|
||||
|
||||
+254
-3
@@ -8,7 +8,9 @@ default-run = "oxicloud"
|
||||
[dependencies]
|
||||
mimalloc = { version = "0.1.52", default-features = false }
|
||||
axum = { version = "0.8.9", features = ["multipart", "http1", "http2", "tokio", "macros"] }
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs"] }
|
||||
# "process" was previously enabled implicitly through aws-config's feature
|
||||
# unification; ffmpeg_video_frame_service needs it, so declare it ourselves.
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs", "process"] }
|
||||
tokio-util = { version = "0.7.18", features = ["io", "codec", "compat"] }
|
||||
tokio-stream = { version = "0.1.18", features = ["fs", "sync"] }
|
||||
bytes = "1.11.1"
|
||||
@@ -86,9 +88,12 @@ dashmap = "6.2.1"
|
||||
socket2 = { version = "0.6.4", features = ["all"] }
|
||||
urlencoding = "2.1.3"
|
||||
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] }
|
||||
# NOTE: aws-config and aws-smithy-types were removed as direct deps in the
|
||||
# round-3 perf pass — S3BlobBackend builds its client purely from
|
||||
# aws_sdk_s3::config with static credentials; nothing referenced either
|
||||
# crate, and aws-config alone pulled aws-sdk-sso/ssooidc/sts (~90 crates)
|
||||
# into every build (benches/ROUND3.md).
|
||||
aws-sdk-s3 = "1.136.0"
|
||||
aws-config = { version = "1.8.18", features = ["behavior-version-latest"] }
|
||||
aws-smithy-types = "1.5.0"
|
||||
azure_core = { version = "0.21", default-features = false, features = ["enable_reqwest_rustls", "hmac_rust"] }
|
||||
azure_storage = { version = "0.21", default-features = false, features = ["enable_reqwest_rustls", "hmac_rust"] }
|
||||
azure_storage_blobs = { version = "0.21", default-features = false, features = ["enable_reqwest_rustls", "hmac_rust"] }
|
||||
@@ -278,6 +283,252 @@ name = "bench_owner_cache"
|
||||
path = "examples/bench_owner_cache.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Round-4 battery ─────────────────────────────────────────────────────────────
|
||||
|
||||
# PG row → entity path materialization — the per-listing-row make_file_path
|
||||
# split→rejoin + NFC copy chain vs the one-pass builders. No Postgres.
|
||||
[[example]]
|
||||
name = "bench_row_path"
|
||||
path = "examples/bench_row_path.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# WebDAV drive-selector resolution — the per-request list_readable_by grants
|
||||
# join vs the per-user readable_cache (needs the dev Postgres up).
|
||||
[[example]]
|
||||
name = "bench_drive_selector"
|
||||
path = "examples/bench_drive_selector.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# CalDAV parse path — from_ical's 8×-reparse vs single parse, per-event
|
||||
# uppercase copies on REPORT/GET, UID clone churn. No Postgres.
|
||||
[[example]]
|
||||
name = "bench_caldav_parse"
|
||||
path = "examples/bench_caldav_parse.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# PROPFIND per-row XML emit — partition Vec churn + chrono format-interpreter
|
||||
# dates vs single-pass + stack-rendered fields. No Postgres.
|
||||
[[example]]
|
||||
name = "bench_propfind_xml"
|
||||
path = "examples/bench_propfind_xml.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Grant-listing hydration N+1 (calendars / address books / playlists) +
|
||||
# user-flags cold-cache herd (needs the dev Postgres up).
|
||||
[[example]]
|
||||
name = "bench_n1_hydration"
|
||||
path = "examples/bench_n1_hydration.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Face-indexing fan-out — unbounded per-image spawn vs core-count semaphore;
|
||||
# peak-live-heap + wall on the bench_support photo corpus. No Postgres.
|
||||
[[example]]
|
||||
name = "bench_faces_bound"
|
||||
path = "examples/bench_faces_bound.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Azure download path — whole-blob collect vs streamed pages, TTFB + peak
|
||||
# live heap against a local Azure-GET stub (endpoint_url hook). No Postgres.
|
||||
[[example]]
|
||||
name = "bench_azure_stream"
|
||||
path = "examples/bench_azure_stream.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Round-5 battery ─────────────────────────────────────────────────────────────
|
||||
|
||||
# CalDAV whole-calendar REPORT/GET — buffered double-residency vs uid-keyset
|
||||
# streaming; TTFB + peak live heap (needs the dev Postgres up).
|
||||
[[example]]
|
||||
name = "bench_caldav_stream"
|
||||
path = "examples/bench_caldav_stream.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Round-5 micro-allocation pack — suggest clones, readable-cache Arc hit,
|
||||
# SPA-listing interning, NC href prefix, CardDAV REPORT churn. No Postgres.
|
||||
[[example]]
|
||||
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<Bytes>
|
||||
# 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
|
||||
# the cascade_grant_cache; includes a revocation safety gate (needs the dev
|
||||
# Postgres up).
|
||||
[[example]]
|
||||
name = "bench_thumbnail_cascade_cache"
|
||||
path = "examples/bench_thumbnail_cascade_cache.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Round-7 battery ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Range-seek per-request authz duplication — the per-seek require the range
|
||||
# branch used to run (warm CPU + cold drive-resolve query) vs 0 after routing
|
||||
# through the non-perms range read (needs the dev Postgres up).
|
||||
[[example]]
|
||||
name = "bench_range_seek_authz"
|
||||
path = "examples/bench_range_seek_authz.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# `/api/folders/{id}/resources` row→DTO mapping — per-row name clone vs move
|
||||
# (pure CPU; counting allocator).
|
||||
[[example]]
|
||||
name = "bench_resource_row_map"
|
||||
path = "examples/bench_resource_row_map.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Round-6 battery ─────────────────────────────────────────────────────────────
|
||||
|
||||
# CardDAV whole-book REPORT/PROPFIND — buffered double-residency vs cursor
|
||||
# streaming; TTFB + peak live heap (needs the dev Postgres up).
|
||||
[[example]]
|
||||
name = "bench_carddav_stream"
|
||||
path = "examples/bench_carddav_stream.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Batch-favorites authz pre-check — serial require loop vs try_join_all
|
||||
# against the real PgAclEngine (needs the dev Postgres up).
|
||||
[[example]]
|
||||
name = "bench_favorites_authz"
|
||||
path = "examples/bench_favorites_authz.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Digest-hex rendering + NC id-batch marshalling micro-allocs (pure CPU).
|
||||
[[example]]
|
||||
name = "bench_hex_ids"
|
||||
path = "examples/bench_hex_ids.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# `id::text` server cast vs binary UUID decode + app-side formatting A/B
|
||||
# (needs the dev Postgres up).
|
||||
[[example]]
|
||||
name = "bench_uuid_text_cast"
|
||||
path = "examples/bench_uuid_text_cast.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Round-3 battery ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Web-UI folder listing — whole-folder rescan + top-N sort per page vs keyset
|
||||
# pushdown into the UNION-ALL branches + (folder_id, LOWER(name), id) indexes
|
||||
# (needs the dev Postgres up).
|
||||
[[example]]
|
||||
name = "bench_listing_keyset"
|
||||
path = "examples/bench_listing_keyset.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Photos timeline — full-library scan + top-N above the grants join vs
|
||||
# per-drive LATERAL top-N on the media-timeline index (needs Postgres).
|
||||
[[example]]
|
||||
name = "bench_photos_timeline"
|
||||
path = "examples/bench_photos_timeline.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# PROPFIND subfolder paging — LIMIT/OFFSET + COUNT(*) OVER() per page vs
|
||||
# keyset batch, mirroring the files-side PROPFIND-PAGING fix (needs Postgres).
|
||||
[[example]]
|
||||
name = "bench_folder_keyset"
|
||||
path = "examples/bench_folder_keyset.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Basic-auth thundering herd — K concurrent cache misses each paying Argon2id
|
||||
# vs single-flight try_get_with (needs Postgres).
|
||||
[[example]]
|
||||
name = "bench_auth_herd"
|
||||
path = "examples/bench_auth_herd.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# CachedBlobBackend — miss stampede (N duplicate remote fetches racing on one
|
||||
# .tmp) vs per-hash single-flight; warm-hit index throughput. No Postgres.
|
||||
[[example]]
|
||||
name = "bench_blob_cache"
|
||||
path = "examples/bench_blob_cache.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Upload spool/assembly I/O — ReaderStream capacity sweep on part-file reads
|
||||
# and BufWriter vs bare-File frame writes on the chunk spool path. No Postgres.
|
||||
[[example]]
|
||||
name = "bench_upload_spool"
|
||||
path = "examples/bench_upload_spool.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# S3 chunk PUT — HEAD-before-PUT vs unconditional PUT against a local axum
|
||||
# stub with injected latency; Azure Bytes-vs-to_vec copy micro. No Postgres.
|
||||
[[example]]
|
||||
name = "bench_s3_put"
|
||||
path = "examples/bench_s3_put.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# File/Folder -> DTO mapping allocations — Arc<str> interning of closed-set
|
||||
# display fields, 1-alloc etag/size formatting. No Postgres.
|
||||
[[example]]
|
||||
name = "bench_dto_map"
|
||||
path = "examples/bench_dto_map.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# CardDAV REPORT — dead per-contact vCard pre-generation + O(N^2) uid scan vs
|
||||
# single on-demand generation. No Postgres.
|
||||
[[example]]
|
||||
name = "bench_carddav_report"
|
||||
path = "examples/bench_carddav_report.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Search-results cache RSS — entry-count capacity vs byte weigher. No Postgres.
|
||||
[[example]]
|
||||
name = "bench_search_cache_mem"
|
||||
path = "examples/bench_search_cache_mem.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
# Round 3 — listing/timeline SQL shapes, auth herd, blob-cache stampede, spool I/O, DTO allocs
|
||||
|
||||
Twelve benchmark-gated changes. Rule of the round (same as ROUND2): every
|
||||
change ships with a BEFORE/AFTER benchmark; an AFTER that doesn't beat its
|
||||
BEFORE gets rolled back — none did. Equivalence gates (byte-identical
|
||||
output / identical row sequences) guard every behavior-preserving rewrite.
|
||||
|
||||
Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release
|
||||
profile. Reproduce any row with the command in its section.
|
||||
|
||||
## Summary
|
||||
|
||||
| # | change | key metric | before → after |
|
||||
|--:|---|---|---|
|
||||
| 1 | Web-UI listing keyset pushdown | ms/page p50, 20k-entry folder | 26.6 → 1.30 (**19.5x**) |
|
||||
| 2 | Photos timeline LATERAL top-N | ms/page p50, 50k-photo library | 97.4 → 1.61 (**55.7x**) |
|
||||
| 3 | PROPFIND subfolder keyset | full walk, 5k dirs | 79.7 → 17.9 ms (**4.5x**) |
|
||||
| 4 | Basic-auth single-flight | herd CPU, 8 conns | 2620 → 300 ms (**8.7x**) |
|
||||
| 5 | Blob-cache miss single-flight | remote fetches / wall | 16 → 1, 519 → 188 ms (**2.8x**) |
|
||||
| 6 | Chunk-assembly read buffer 512K | wall / read syscalls | 251 → 109 ms (**2.3x**), 2580 → 340 |
|
||||
| 7 | Chunk-spool BufWriter 512K | wall / write syscalls | 877 → 158 ms (**5.6x**), 12800 → 400 |
|
||||
| 8 | S3/Azure unsynced PUT (no HEAD) | wall / requests, 500 chunks | 1604 → 868 ms (**1.8x**), 1000 → 500 |
|
||||
| 9 | DTO mapping interning | allocs/row file / folder | 11.0 → 4.0, 11.8 → 1.0 |
|
||||
| 10 | CardDAV REPORT dead work | 5k contacts, getetag | 55.7 → 5.7 ms (**9.8x**) |
|
||||
| 11 | Search-cache byte weigher | retained RSS worst case | ~298 MiB → 31.9 MiB (bounded) |
|
||||
| 12 | Drop aws-config/aws-smithy-types | dep-graph nodes | 1728 → 1646 |
|
||||
|
||||
Frontend (gated by vitest, `frontend/src/lib/utils/formatDate.bench.test.ts`):
|
||||
cached `Intl.DateTimeFormat` — 20k dates 2612 → 50.6 ms (**51.6x**), output
|
||||
identity asserted across locales.
|
||||
|
||||
---
|
||||
|
||||
## [1] Web-UI folder listing — whole-folder rescan → per-branch keyset — 19.5x
|
||||
|
||||
`list_resources_paged` (SPA files view) applied its keyset cursor OUTSIDE
|
||||
the folders/files UNION-ALL on computed columns (`sort_str = LOWER(name)`,
|
||||
`folder_first`), so Postgres re-scanned and top-N-sorted every remaining
|
||||
row of the folder on every page (EXPLAIN: Seq Scan, 17,999 rows removed by
|
||||
filter, 29 ms / 565 buffers per 200-row page on a 20k-file folder).
|
||||
|
||||
Now the cursor is pushed into each branch as a sargable row-value
|
||||
comparison on base columns (`(LOWER(name), id) > ($str, $id)`), constants
|
||||
folded per branch in Rust (a cursor in the file group drops the folder
|
||||
branch outright), each branch pre-sorts + pre-limits, and the outer query
|
||||
merges ≤ 2·limit rows. Two new expression indexes (migration
|
||||
`20260918000000`): `idx_files_folder_lname (folder_id, LOWER(name), id)`
|
||||
and `idx_folders_parent_lname (parent_id, LOWER(name), id)`, both partial
|
||||
on `NOT is_trashed`.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_listing_keyset
|
||||
# full drain, 20k files + 300 dirs, 200/page total ms p50/pg p99/pg
|
||||
# name OLD/no-idx 2717.2 26.57 33.55
|
||||
# name OLD/idx (indexes alone don't help) 2786.8 27.83 35.62
|
||||
# name NEW/idx 139.6 1.30 1.81 19.5x
|
||||
# modified_at OLD → NEW (no dedicated index) 1653.4 → 1367.5 1.2x
|
||||
```
|
||||
|
||||
Equivalence: the drained `(type, id)` sequence is asserted identical across
|
||||
all modes and both sort orders; the example exits 1 on mismatch.
|
||||
|
||||
## [2] Photos timeline — full-library scan → per-drive LATERAL top-N — 55.7x
|
||||
|
||||
`list_media_files` claimed `idx_files_media_timeline_by_drive` let LIMIT
|
||||
stop the scan early; EXPLAIN refuted it — the folders/file_metadata joins
|
||||
and the global sort sat ABOVE the `drive_id IN (grants)` nested loop, so
|
||||
every page fed the ENTIRE media library through the join into a top-N
|
||||
heapsort. Now the accessible drive ids materialise once, a
|
||||
`CROSS JOIN LATERAL (… ORDER BY media_sort_date DESC LIMIT k)` per drive
|
||||
does one bounded index scan each, and the joins run on the k emitted rows
|
||||
only.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_photos_timeline
|
||||
# 10 pages of 100, 50k photos, 3 drives total ms p50 ms/page
|
||||
# OLD 1032.1 97.41
|
||||
# NEW 18.5 1.61 55.7x
|
||||
```
|
||||
|
||||
Equivalence: page-by-page id sequences asserted identical (seed uses
|
||||
strictly distinct capture dates so ties can't mask reordering).
|
||||
|
||||
## [3] PROPFIND subfolder paging — LIMIT/OFFSET + COUNT(*) OVER() → keyset — 4.5x
|
||||
|
||||
The exact quadratic shape PROPFIND-PAGING fixed for files still applied to
|
||||
sub-folders on both DAV surfaces: every page window-aggregated and
|
||||
re-scanned all N sub-folders, and the total was only used for `has_next`.
|
||||
New `FolderRepository::list_folders_batch` (keyset `name > $last`, served
|
||||
by the existing `idx_folders_unique_name`, no migration) wired into both
|
||||
streaming PROPFIND walkers via `list_folders_batch_with_perms` (same
|
||||
per-batch authz as before).
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_folder_keyset
|
||||
# full walk, 5k dirs, 500/page total ms p50 ms/page
|
||||
# OFFSET 79.7 6.54
|
||||
# KEYSET 17.9 1.64 4.5x
|
||||
```
|
||||
|
||||
## [4] Basic-auth cache — thundering herd → single-flight — 8.7x CPU
|
||||
|
||||
Every DAV/NC request authenticates via `verify_basic_auth`. On a cache
|
||||
miss each concurrent caller independently ran the full slow path — an
|
||||
Argon2id verification (m=64 MiB, t=3, p=2 ≈ 290 ms CPU here) apiece. DAV
|
||||
sync clients hold 4-8 parallel connections, so every TTL expiry (300 s)
|
||||
fanned out K verifications: a recurring p99 spike + CPU/RAM burst.
|
||||
`try_get_with` now coalesces concurrent misses; errors are never cached
|
||||
(brute-force cost preserved), revocation via `invalidate_entries_if`
|
||||
unchanged.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_auth_herd
|
||||
# herd of 8, cold cache wall ms CPU ms verifications
|
||||
# BEFORE (per-caller) 764 2620 9.0
|
||||
# AFTER (single-flight) 311 300 1.0
|
||||
# warm hit p50: 0.6 us
|
||||
```
|
||||
|
||||
## [5] CachedBlobBackend — miss stampede → per-hash single-flight — 16 fetches → 1
|
||||
|
||||
K concurrent cold readers of one blob (video player's parallel Range
|
||||
probes; N clients pulling the same new file) each downloaded the FULL blob
|
||||
from S3/Azure — and raced truncating writes on ONE deterministic `.tmp`
|
||||
path (a torn interleaving could be renamed into the cache). Fixes: a
|
||||
per-hash DashMap gate (leader fetches, waiters re-check and serve
|
||||
locally), plus unique `.{uuid}.tmp` names + error-path cleanup so a
|
||||
corrupt file can never land at the final path.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_blob_cache
|
||||
# 16 cold readers, 32 MiB blob, shared 1 GiB/s link wall ms fetches remote MiB
|
||||
# BEFORE (per-caller) 519 16 512
|
||||
# AFTER (single-flight) 188 1 32
|
||||
# gates: fetch count == 1; BLAKE3 of served + durable cache file == source
|
||||
```
|
||||
|
||||
## [6][7] Upload spool I/O — 64 KiB reads, unbuffered frame writes
|
||||
|
||||
Assembly read (`stream_from_files`, the single read pass over every
|
||||
completed chunked upload) used 64 KiB `ReaderStream` polls — one
|
||||
blocking-pool dispatch + read(2) each — while every other blob path uses
|
||||
256 KiB+. Capacity sweep picked 512 KiB. Chunk-spool writes
|
||||
(`stream_body_to_path`, every chunk PUT on both surfaces) went straight to
|
||||
a bare tokio File — one dispatch + write(2) per ~16-64 KiB HTTP frame; now
|
||||
wrapped in `BufWriter::with_capacity(512 KiB)` like the dedup handler's
|
||||
spool loop.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_upload_spool
|
||||
# [1] read 16 x 10 MiB parts wall ms read syscalls
|
||||
# 64K (BEFORE) 250.8 2580
|
||||
# 256K 125.1 660
|
||||
# 512K (AFTER) 108.8 340 2.3x
|
||||
# 1M 111.3 180
|
||||
# [2] spool 640 x 16 KiB frames x 20 files
|
||||
# bare File (BEFORE) 877.4 12800 syscw
|
||||
# BufWriter 512K (AFTER) 157.9 400 syscw 5.6x
|
||||
```
|
||||
|
||||
## [8] S3/Azure chunk writes — HEAD-before-PUT → unconditional PUT — 1.8x
|
||||
|
||||
Neither remote backend overrode `put_blob_from_bytes_unsynced`, so the
|
||||
dedup settle path (every NEW chunk of every upload) routed through
|
||||
`put_blob_from_bytes` and its "idempotent" HEAD/get_properties probe —
|
||||
2 round-trips per chunk for chunks the dedup layer already knows are new.
|
||||
Content-addressed keys make re-PUTs overwrite-safe, so the new overrides
|
||||
PUT directly. Azure additionally stopped copying every chunk
|
||||
(`data.to_vec()` → `Bytes` into `azure_core::Body`): 0.44 ms + 4 MiB
|
||||
transient alloc per 4 MiB chunk removed.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_s3_put
|
||||
# 500 x 256 KiB chunks, concurrency 8, 10 ms/request stub
|
||||
# BEFORE (HEAD+PUT) 1604 ms 500 HEADs + 500 PUTs
|
||||
# AFTER (PUT only) 868 ms 500 PUTs 1.8x
|
||||
```
|
||||
|
||||
## [9] Entity → DTO mapping — closed-set interning + 1-alloc formatting
|
||||
|
||||
`Arc::<str>::from(&'static str)` always allocates+copies, so every file
|
||||
row paid 4 allocations for values drawn from a ~60-string closed set
|
||||
(icon class, special class, category, mime), plus 2-alloc etag and 2-alloc
|
||||
size formatting; FolderDto additionally built its etag twice and cloned 4
|
||||
Strings it could move. Now: `LazyLock` intern tables (lookup + refcount
|
||||
bump; unknown values fall back to `Arc::from`, same bytes), single-alloc
|
||||
`compute_etag`/`format_file_size`, and `Folder::into_parts()` moves.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_dto_map
|
||||
# 10k rows ns/row allocs/row
|
||||
# File→FileDto BEFORE 1229.2 10.96
|
||||
# File→FileDto AFTER 1004.9 3.96
|
||||
# Folder→FolderDto BEFORE 425.2 11.80
|
||||
# Folder→FolderDto AFTER 204.5 1.00
|
||||
# gate: all DTO fields byte-identical BEFORE vs AFTER (10k files + 10k folders)
|
||||
```
|
||||
|
||||
## [10] CardDAV REPORT — dead double vCard generation + O(N²) scan — 9.8x
|
||||
|
||||
`handle_report` pre-generated a vCard for EVERY contact; the adapter then
|
||||
did a linear uid `find` per contact — O(N²) string compares — and
|
||||
DISCARDED the result (`let _ = vcard`), regenerating on demand inside
|
||||
`write_contact_response` anyway. Pure dead work, deleted; `contact_to_vcard`
|
||||
also switched `push_str(&format!(…))` → `write!` (one temp String per
|
||||
vCard line removed).
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_carddav_report
|
||||
# N=5000 getetag 55.7 → 5.7 ms 9.8x
|
||||
# N=5000 getetag+address-data 76.2 → 15.3 ms 5.0x
|
||||
# gate: REPORT XML byte-identical BEFORE vs AFTER for all prop sets
|
||||
```
|
||||
|
||||
## [11] Search-results cache — entry count → byte weigher — bounded RSS
|
||||
|
||||
The cache was capped at 1000 ENTRIES with a 300 s TTL; each entry holds up
|
||||
to 500 enriched rows (~10 owned Strings each) and keys include
|
||||
user+query+offset+limit, so every keystroke/page/user minted an entry —
|
||||
~300 MiB of invisible RSS was reachable. Now a byte weigher + 32 MiB
|
||||
budget (`OXICLOUD_SEARCH_CACHE_MAX_BYTES`), same TTL, same read latency.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_search_cache_mem
|
||||
# 1000 pages x 500 rows retained bytes get() p50
|
||||
# BEFORE (1000 entries) ~298 MiB (9.3x) 155 ns
|
||||
# AFTER (32 MiB weigher) 31.9 MiB 155 ns parity 1.00x
|
||||
```
|
||||
|
||||
## [12] Cargo — drop aws-config + aws-smithy-types
|
||||
|
||||
Both were direct dependencies with ZERO references in the codebase —
|
||||
`S3BlobBackend` builds its client purely from `aws_sdk_s3::config` with
|
||||
static credentials. `aws-config` alone dragged aws-sdk-sso, aws-sdk-ssooidc
|
||||
and aws-sdk-sts into every build. Dependency-graph nodes: 1728 → 1646.
|
||||
`tokio`'s `process` feature (used by the ffmpeg thumbnailer) was only
|
||||
enabled transitively through aws-config's feature unification — it is now
|
||||
declared explicitly.
|
||||
|
||||
## Frontend — cached Intl.DateTimeFormat — 51.6x
|
||||
|
||||
`formatDate` (and four sibling callsites) constructed a fresh
|
||||
`Intl.DateTimeFormat` per call (~131 µs each here) — paid roughly twice
|
||||
per row while rendering/scrolling file lists. Module-scope cache keyed by
|
||||
(locale, options), invalidated on `languagechange`.
|
||||
|
||||
```
|
||||
cd frontend && npx vitest run src/lib/utils/formatDate.bench.test.ts
|
||||
# 20k dates: cached 50.6 ms vs per-call 2612.0 ms (51.6x); output-identity
|
||||
# matrix across en/es/ar/ja and every option shape used by the app
|
||||
```
|
||||
|
||||
## Audited but NOT adopted (for the record)
|
||||
|
||||
- **Fat LTO / panic=abort / OpenAPI LazyLock**: refuted by the verification
|
||||
pass (sub-1% plausible gain, or cold paths; `catch_unwind` shields
|
||||
pdf-extract so panic=abort is off the table).
|
||||
- **Chained clone-on-hit drive caches, localeCompare→Intl.Collator**:
|
||||
measured previously — residual gains are noise or regressions
|
||||
(benches/CHROOT-CACHE.md, benches/NPLUS1-AND-CACHES.md).
|
||||
- **Follow-ups worth a future round** (confirmed real, not yet gated):
|
||||
grouped/swimlane files view is unvirtualized (10k-row DOM); Azure
|
||||
download path buffers whole blobs in RAM (needs an Azurite-gated bench);
|
||||
face-indexing spawns unbounded per-image tasks; WebDAV drive-selector
|
||||
resolution re-runs the grants join per request (cacheable like
|
||||
CHROOT-CACHE); `make_file_path` split→rejoin + NFC copy per listing row.
|
||||
@@ -0,0 +1,251 @@
|
||||
# Round 4 — row-path allocs, drive-selector cache, CalDAV parse, PROPFIND emit, N+1 hydration, Azure streaming, faces bound
|
||||
|
||||
Eight benchmark-gated changes. Rule of the round (same as ROUND2/ROUND3):
|
||||
every change ships with a BEFORE/AFTER benchmark; an AFTER that doesn't
|
||||
beat its BEFORE gets rolled back — none did. Equivalence gates
|
||||
(byte-identical output / identical row or id sets / BLAKE3 payload
|
||||
identity) guard every behavior-preserving rewrite.
|
||||
|
||||
Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release
|
||||
profile. Reproduce any row with the command in its section.
|
||||
|
||||
## Summary
|
||||
|
||||
| # | change | key metric | before → after |
|
||||
|--:|---|---|---|
|
||||
| 1 | Row→entity path build (one-pass) | ns/row file / allocs | 743 → 417 (**1.78x**), 15.8 → 10.5 |
|
||||
| 2 | Drive-selector readable-cache | µs/resolution p50, 8 conns | 441 → 0.80 (**~550x**), queries → 0 |
|
||||
| 3 | CalDAV single-parse `from_ical` | µs/event PUT parse | 83.8 → 11.8 (**7.1x**) |
|
||||
| 4 | CalDAV read-side copies | chunk ns / group µs (5k) | 297 → 215 (**1.4x**) / 1221 → 951 (**1.3x**) |
|
||||
| 5 | PROPFIND XML emit | µs/1100-row page / allocs/row | 1535 → 1253 (**1.22x**), 17.9 → 12.0 |
|
||||
| 6 | Grant-listing hydration batch | ms/listing K=15 | 4.4 → 0.33 (**~13x**), 15 queries → 1 |
|
||||
| 7 | user-flags single-flight | cold herd of 32 | 32 → 1 query, 4.7 → 0.6 ms |
|
||||
| 8 | Azure download streaming | TTFB / peak heap, 256 MiB | 349 → 4 ms (**87x**), 480 → 1.9 MiB (**254x**) |
|
||||
| 9 | Face-indexing semaphore | peak live heap, 48 images | 1175 → 176 MiB (**6.7x**), wall also −13% |
|
||||
|
||||
---
|
||||
|
||||
## [1] PG row → entity path materialization — one-pass builders — 1.78x
|
||||
|
||||
Every listing row (PROPFIND batches, photos timeline, search pages,
|
||||
by-ids enrichment, subtree ZIP streams) paid this chain: files re-joined
|
||||
the materialized folder path with `format!`, split the copy into a
|
||||
per-segment `Vec<String>`, NFC-copied the already-NFC name
|
||||
(`normalize_storage_name` always allocated), then `Display`/`join`
|
||||
re-joined the segments it had just split into `path_string` — the only
|
||||
form the DTOs actually serve. Folders arrived with an owned canonical
|
||||
`path` column, split it, dropped it, and rebuilt an identical String.
|
||||
|
||||
Now: `StoragePath::from_folder_and_name` / `from_joined` build segments
|
||||
AND the joined string in one pass (`from_joined` reuses the owned input
|
||||
when canonical — every row the repository writes), the entity
|
||||
constructors take the name by value through the new zero-copy
|
||||
`normalize_storage_name_owned`, `Display` writes segments without the
|
||||
`join` temp, and both duplicated repo-side `make_file_path` copies were
|
||||
replaced by the shared builder (`File::from_materialized_row` /
|
||||
`Folder::from_materialized_row`).
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_row_path
|
||||
# 10k rows, 100 passes ns/row (p50) allocs/row
|
||||
# File BEFORE 743.2 15.75
|
||||
# File AFTER 416.8 1.78x 10.51
|
||||
# Folder BEFORE 704.8 14.08
|
||||
# Folder AFTER 620.4 1.14x 10.08
|
||||
# gate: (name, path_string, segments) byte-identical + error parity,
|
||||
# realistic corpus + adversarial (traversal, //, NFD, empties)
|
||||
```
|
||||
|
||||
## [2] WebDAV drive-selector — grants join/request → per-user cache — ~550x
|
||||
|
||||
`lookup_drive_selector` (every native `/webdav/<selector>/…` request,
|
||||
all verbs, MOVE/COPY twice) ran `list_readable_by`: a
|
||||
role_grants ⋈ drives ⋈ folders join with inline transitive-group
|
||||
expansion, GROUP BY + MIN(role) + ORDER BY — per request, uncached. The
|
||||
same join also ran per request in search, trash listing and the
|
||||
`GET /api/drives` picker.
|
||||
|
||||
Now `DrivePgRepository` carries a `readable_cache`
|
||||
(user → `Arc<Vec<DriveWithRootName>>`, 30 s TTL, `try_get_with`
|
||||
single-flight, errors never cached) mirroring the CHROOT-CACHE
|
||||
precedent. Every mutation that can change a user's drive list
|
||||
invalidates explicitly: personal/shared drive creation, deletion, policy
|
||||
edits (repo), membership set/remove (`DriveManagementService`, per-User
|
||||
subject or full clear for Group subjects), and group-membership changes
|
||||
(`SubjectGroupService` invalidates per affected transitive user). The
|
||||
residual staleness sources (root-folder rename; grant writes that can't
|
||||
reach this cache) stay bounded by the same 30 s TTL the sibling caches
|
||||
accept; permission *enforcement* is unaffected (the ACL engine
|
||||
re-checks per operation with its own invalidation).
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_drive_selector
|
||||
# pool=20, window=4s, 3 drives/user req/s p50 µs p99 µs queries
|
||||
# conc=8 BEFORE (join/request) 17,098 441.23 1143.85 68,394
|
||||
# conc=8 AFTER (readable_cache) 2,371,541 0.80 8.61 0
|
||||
# conc=64 BEFORE 21,462 2818.27 5440.99 85,850
|
||||
# conc=64 AFTER 1,506,230 1.71 17.08 0
|
||||
# gate: (id, name) sequences identical — BEFORE == cold == warm
|
||||
```
|
||||
|
||||
## [3] CalDAV `from_ical` — 8 full parses per VEVENT → 1 — 7.1x
|
||||
|
||||
`CalendarEvent::from_ical` funnelled each of its 8 property lookups
|
||||
(SUMMARY, DTSTART, DTEND, DESCRIPTION, LOCATION, RRULE, UID,
|
||||
RECURRENCE-ID) through an extractor that re-ran the complete
|
||||
`IcalParser` — line unfolding + full component-tree build — over the
|
||||
whole body. Every CalDAV PUT paid 8 parses per VEVENT; a master+M-
|
||||
exceptions PUT paid `8·(M+1)`; an N-event import `8·N`.
|
||||
`update_ical_data` had the same shape (7 lookups). Now both parse ONCE
|
||||
and read properties from the parsed component; value-only lookups also
|
||||
skip the parameter-map build, and `split_vevents` stopped uppercasing
|
||||
every line into a fresh String (allocation-free CI prefix test).
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_caldav_parse
|
||||
# 200 realistic ~1.3 KiB VEVENTs (params, folding, VALARM, exceptions)
|
||||
# [1] from_ical µs/event 83.81 → 11.76 (excl. body clone) 7.1x
|
||||
# [2] 50-event import body µs 4412.5 → 1002.3 4.4x
|
||||
# gates: parsed fields byte-identical (incl. all-day, exceptions,
|
||||
# mixed-case tags, LF-only bodies), error parity, wrapped
|
||||
# per-row ical_data identical
|
||||
```
|
||||
|
||||
## [4] CalDAV read side — per-event copies removed — 1.3-1.4x
|
||||
|
||||
`extract_vevent_chunk` (every REPORT / collection-GET, per event)
|
||||
allocated a full `to_ascii_uppercase()` copy of the stored body just to
|
||||
locate two tags — now a memchr fast path (stored bodies carry uppercase
|
||||
tags) with an allocation-free case-insensitive scan fallback.
|
||||
`group_events_by_uid` cloned every event's UID String into its map —
|
||||
now borrowed keys. `generate_calendar_events_response` also stopped
|
||||
cloning the requested-props Vec per REPORT.
|
||||
|
||||
```
|
||||
# [3] extract_vevent_chunk ns/event 297 → 215 1.4x (stable
|
||||
# across 3 isolated re-runs; one battery pass showed 0.9x noise)
|
||||
# [4] group_events_by_uid µs/5k events 1221.0 → 951.1 1.3x
|
||||
# gates: identical chunk slices (incl. mixed-case, missing-terminator,
|
||||
# malformed bodies), identical grouping shape
|
||||
```
|
||||
|
||||
## [5] PROPFIND XML emit — single-pass + stack-rendered fields — 1.22x
|
||||
|
||||
For EVERY file/folder row of every PROPFIND page the writers paid a
|
||||
`partition` into two throwaway `Vec<&QualifiedName>`s (+ a third for the
|
||||
404 list) even though the requested-props writer already skips unknown
|
||||
names itself, plus `to_rfc3339()` + `to_rfc2822()` (chrono's format-spec
|
||||
interpreter + a heap String each), `size.to_string()` and a
|
||||
`format!("\"{etag}\"")`. Now: one pass computing only the
|
||||
usually-empty 404 list, and `common::fmt` stack renderers — RFC 3339 /
|
||||
RFC 2822 / integers written into stack buffers, byte-identical to chrono
|
||||
(sweep-tested across 60 years; out-of-range values keep the chrono
|
||||
fallback). The same renderers replaced the per-row date/etag/size
|
||||
formatting in the NextCloud PROPFIND emitters.
|
||||
|
||||
The first version of `rfc2822_utc` zero-padded the day; chrono does not
|
||||
(`Thu, 1 Jan`). **The byte-identity gate caught it** and the padded
|
||||
version never shipped — exactly the failure mode these gates exist for.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_propfind_xml
|
||||
# 1000 files + 100 folders/page, 200 passes µs/page allocs/row
|
||||
# named-prop (sync set) BEFORE 1534.9 17.91
|
||||
# AFTER 1253.1 1.22x 12.00
|
||||
# allprop (+quota) BEFORE 1072.1 9.67
|
||||
# AFTER 895.1 1.20x 4.58
|
||||
# gate: multistatus XML byte-identical (named-prop incl. unknown + dead
|
||||
# props, allprop with quota; epoch/padded-day/2099 timestamps)
|
||||
```
|
||||
|
||||
## [6] Grant-listing hydration — K point SELECTs → one `= ANY` — ~13x
|
||||
|
||||
After `list_incoming_grants`, the CalDAV calendar discovery, CardDAV
|
||||
book discovery and playlist listing each hydrated their K accessible
|
||||
resources with K SERIAL point SELECTs, awaited one by one, on every
|
||||
client sync poll / dashboard load. New batch methods
|
||||
(`find_calendars_by_ids` / `get_address_books_by_ids` /
|
||||
`find_playlists_by_ids`) collapse each listing to one round-trip;
|
||||
missing rows still drop out silently (deleted/trashed race carve-out
|
||||
preserved).
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_n1_hydration
|
||||
# K=15 resources, 200 passes ms/listing p50 queries
|
||||
# calendars BEFORE → AFTER 4.411 → 0.338 15 → 1 13.0x
|
||||
# address books BEFORE → AFTER 4.365 → 0.325 15 → 1 13.4x
|
||||
# playlists BEFORE → AFTER 4.378 → 0.342 15 → 1 12.8x
|
||||
# gate: identical id sets loop vs batch (+ ghost-id drop-out parity)
|
||||
```
|
||||
|
||||
## [7] user-flags cache — get→insert → single-flight — 32 → 1 queries
|
||||
|
||||
`get_user_flags` backs the auth middleware's per-request role/active
|
||||
guard. Its cache was get→insert: on every 30 s TTL expiry, every
|
||||
in-flight request of that user fired the SELECT concurrently (the same
|
||||
herd shape ROUND3 fixed for basic-auth, minus the Argon2 cost). Now
|
||||
`moka::future` + `try_get_with`: concurrent misses coalesce, errors are
|
||||
never cached, eager invalidation on role/active changes unchanged.
|
||||
|
||||
```
|
||||
# cold-cache herd of 32 concurrent callers
|
||||
# BEFORE (get→insert) 4.72 ms 32 queries
|
||||
# AFTER (try_get_with) 0.57 ms 1 query
|
||||
# gate: identical flags from every caller
|
||||
```
|
||||
|
||||
## [8] Azure download path — whole-blob buffering → streaming — 87-254x
|
||||
|
||||
`AzureBlobBackend::get_blob_stream` / `get_blob_range_stream` drained
|
||||
the ENTIRE blob (or range) into one `Vec<u8>` before yielding a single
|
||||
mega-chunk: whole-blob RAM residency per reader, TTFB = full download
|
||||
time, and with `read_prefetch() = 8` the CDC reassembly path could hold
|
||||
8 entire chunk-blobs at once. Now the SDK's page/body streams forward
|
||||
directly (first page still awaited eagerly so a missing blob surfaces
|
||||
as the same up-front NotFound). `AzureStorageConfig` gained
|
||||
`endpoint_url` (`OXICLOUD_AZURE_ENDPOINT_URL`) mirroring S3's override —
|
||||
it powers the bench stub and enables Azurite for local dev.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_azure_stream
|
||||
# 256 MiB blob, local Azure-GET stub TTFB ms wall ms peak heap MiB
|
||||
# full BEFORE (collect-then-yield) 349.3 465.3 479.8
|
||||
# full AFTER (streamed) 4.0 308.5 1.9 87x / 254x
|
||||
# tail-128 MiB range BEFORE 165.5 225.3 240.7
|
||||
# tail-128 MiB range AFTER 1.3 147.3 1.9 125x / 127x
|
||||
# gate: BLAKE3(BEFORE) == BLAKE3(AFTER) == source, full + range
|
||||
```
|
||||
|
||||
## [9] Face indexing — unbounded per-image spawn → semaphore — 6.7x RAM
|
||||
|
||||
`FaceIndexingService::spawn_index` fired one `tokio::spawn` per
|
||||
uploaded/copied image with no ceiling; each task reads the full blob
|
||||
and decodes it before inference, so a bulk upload of N photos held up
|
||||
to N decoded images in flight. Now an `Arc<Semaphore>` sized to the
|
||||
effective core count (`OXICLOUD_FACES_INDEX_CONCURRENCY` override),
|
||||
permit acquired BEFORE the blob read — the exact
|
||||
`ThumbnailService::decode_semaphore` invariant ("peak memory =
|
||||
permits × image size"). Pattern bench (the real service needs
|
||||
Postgres + an ONNX model): task body = full-file read + JPEG/PNG decode
|
||||
on the `bench_support` corpus, spawn/permit shape copied verbatim.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_faces_bound
|
||||
# 48 × 11.1 MiB images, permits=4 wall ms peak live heap MiB
|
||||
# BEFORE (unbounded) 870.5 1175.4
|
||||
# AFTER (semaphore 4) 755.1 176.0 6.7x lower
|
||||
# gate: all 48 images decoded identically in both modes
|
||||
```
|
||||
|
||||
## Follow-ups worth a future round (confirmed real, not gated here)
|
||||
|
||||
- Grouped/swimlane files view is still unvirtualized (10k-row DOM) —
|
||||
frontend, carried over from ROUND3.
|
||||
- CalDAV REPORT / collection-GET still buffer the full multistatus /
|
||||
VCALENDAR in RAM (`caldav_handler.rs`) — the WebDAV surface streams,
|
||||
the CalDAV one doesn't yet; pairs with paged event loading.
|
||||
- Auth middleware per-request `user_id.to_string()` span records and
|
||||
owned `CurrentUser` strings (`interfaces/middleware/auth.rs`) —
|
||||
small but ubiquitous.
|
||||
- Search suggest clones each entity before DTO conversion
|
||||
(`search_service.rs:525/539`).
|
||||
@@ -0,0 +1,142 @@
|
||||
# Round 5 — CalDAV streaming, SPA interning gaps, NC href prefix, per-request micro-allocs
|
||||
|
||||
Benchmark-gated changes, same rule as ROUND2-4: every change ships with a
|
||||
BEFORE/AFTER benchmark; an AFTER that doesn't beat its BEFORE gets rolled
|
||||
back. Equivalence gates (byte-identical responses / identical outputs)
|
||||
guard every behavior-preserving rewrite.
|
||||
|
||||
Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release
|
||||
profile. Reproduce any row with the command in its section.
|
||||
|
||||
## Summary
|
||||
|
||||
| # | change | key metric | before → after |
|
||||
|--:|---|---|---|
|
||||
| 1 | CalDAV whole-calendar streaming | TTFB / peak heap (4k events) | 23.3 → 11.0 ms (**2.1x**) / 14.2 → 8.0 MiB (**1.8x**) |
|
||||
| 2 | SPA listing interning gaps closed | allocs/row closed-set fields | 4 → 0 (wall parity) |
|
||||
| 3 | NC PROPFIND child-href prefix | ns/row href build | 543 → 165 (**3.3x**), 13 → 4 allocs |
|
||||
| 4 | suggest enrichment consume | µs/keystroke (200 rows) | 166.5 → 126.8 (**1.31x**), 20 → 7 allocs/row |
|
||||
| 5 | `list_readable_by` Arc hit | ns/hit warm | 246 → 128 (**1.9x**), 4 → 0 allocs |
|
||||
| 6 | CardDAV REPORT churn | µs/5k-contact getetag poll | 3044 → 2340 (**1.30x**) |
|
||||
| 7 | auth span records | allocs/request | 3 → 0 (field::display) |
|
||||
|
||||
## [1] CalDAV whole-calendar responses — buffered double-residency → cursor streaming
|
||||
|
||||
The REPORT path (no-range `calendar-query`, `sync-collection`), the
|
||||
depth-1 collection PROPFIND (both URL shapes) and the whole-calendar
|
||||
`.ics` GET all (a) materialised EVERY event DTO of the calendar in one
|
||||
Vec — each row carrying its full `ical_data` body — then (b) rendered
|
||||
the complete multistatus / VCALENDAR into a second in-RAM buffer: the
|
||||
calendar resident twice per request, TTFB = full generation time.
|
||||
|
||||
Now `CalendarEventRepository::stream_events_uid_order` serves ONE
|
||||
window-ordered scan (`ORDER BY MIN(start_time) OVER (PARTITION BY
|
||||
ical_uid), ical_uid, master-first, start_time`) through a PG cursor —
|
||||
same-UID rows (recurring master + exception overrides) arrive adjacent,
|
||||
bundle order equals the buffered listing's first-appearance order — and
|
||||
the handlers cut emit pages at UID boundaries, streaming header →
|
||||
page chunks → footer through the split adapter writers
|
||||
(`write_caldav_multistatus_start` / `write_report_page` /
|
||||
`write_collection_head` / `write_collection_event_page`). Bounded
|
||||
shapes (time-range query, multiget, single-event GET) keep the buffered
|
||||
path. The Read authz gate runs once before the cursor opens.
|
||||
|
||||
The shape was itself benchmark-driven: a first keyset pager over the
|
||||
`GROUP BY` re-aggregated the calendar per page (3-4x total wall —
|
||||
rolled back), and per-uid `= ANY(page)` hydration paid ~20 µs per index
|
||||
descent (~4x the sequential scan — rolled back). The shipped design
|
||||
streams ONE window-ordered scan
|
||||
(`ORDER BY MIN(start_time) OVER (PARTITION BY ical_uid), …`) through a
|
||||
PG cursor, cutting emit pages at UID boundaries.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_caldav_stream
|
||||
# 4000 events (20% exceptions) TTFB ms wall ms peak heap MiB
|
||||
# BEFORE (buffered) 23.3 23.3 14.2
|
||||
# AFTER (streamed) 11.0 25.4 8.0 TTFB 2.1x, heap 1.8x
|
||||
# 12000 events
|
||||
# BEFORE 79.5 79.5 45.0
|
||||
# AFTER 43.9 91.5 24.2 TTFB 1.8x, heap 1.9x
|
||||
# Trade: wall +9-15% (the window sort + cursor) for ~2x lower peak RAM
|
||||
# — which scales with calendar size and per concurrent sync client —
|
||||
# and ~2x faster first byte. Same trade class as ROUND2's ZIP
|
||||
# streaming. Gates: multistatus AND .ics byte-identical to buffered.
|
||||
```
|
||||
|
||||
## [2] SPA listing rows — interning bypass closed
|
||||
|
||||
ROUND3 added `intern_display` / `intern_mime` so `File→FileDto` stops
|
||||
allocating for the ~60-string closed set (icon class, category, mime).
|
||||
But the three hottest web-UI listing endpoints — the folder navigation
|
||||
(`/folders/{id}/resources`), `/recent/resources` and
|
||||
`/favorites/resources` — plus the WebDAV drive pseudo-root build their
|
||||
DTOs by hand and called raw `Arc::from` per row, re-introducing 3-4
|
||||
alloc+copies per row the intern tables exist to remove. All four sites
|
||||
now route through the intern lookups; returned `Arc<str>` contents are
|
||||
byte-identical.
|
||||
|
||||
## [3] NC PROPFIND child hrefs — per-row prefix re-encode → precomputed
|
||||
|
||||
`nc_href` re-encoded the username and re-split + re-encoded the whole
|
||||
parent path for EVERY child row of every NextCloud PROPFIND page (up to
|
||||
500/page), preceded by a per-row `format!` of the joined subpath — only
|
||||
the name segment actually varies. The prefix is now encoded once per
|
||||
request; each row appends its encoded name (native WebDAV href also
|
||||
dropped its intermediate encode String — the percent-encode `Display`
|
||||
adapter feeds `format!` directly).
|
||||
|
||||
## [4-6] Per-request micro-allocs (suggest, readable-cache, CardDAV)
|
||||
|
||||
- **suggest** deep-cloned every entity into the DTO conversion and then
|
||||
cloned name/id/path AGAIN per row — on an every-keystroke path. Now
|
||||
consumes + moves.
|
||||
- **`list_readable_by`** returned a fresh deep clone of the cached
|
||||
drive Vec (every row's Strings) per warm hit — per DAV request with an
|
||||
explicit selector. It now returns the cache's `Arc` (refcount bump);
|
||||
the only caller that needs owned rows (`GET /api/drives`) clones just
|
||||
its response rows.
|
||||
- **CardDAV REPORT** cloned the requested-props Vec per REPORT,
|
||||
allocated a fresh href String per contact and `format!`ed each quoted
|
||||
etag — the same shapes ROUND4 removed from CalDAV. Now: borrowed
|
||||
props, one reused href buffer, exact-size quoting.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_micro_allocs
|
||||
# [1] suggest (200 rows) 166.5 → 126.8 µs 1.31x 20.0 → 7.0 allocs/row
|
||||
# [2] readable warm hit 246.4 → 127.7 ns 1.9x 4 → 0 allocs/hit
|
||||
# [3] closed-set fields 129.9 → 136.3 ns 1.0x 4 → 0 allocs/row
|
||||
# (wall parity under the bench's System allocator; the win is the
|
||||
# removed allocator traffic + consistency with the interned
|
||||
# FileDto::from path — ROUND3 #9)
|
||||
# [4] NC child hrefs 543.1 → 164.5 ns 3.3x 13 → 4 allocs/row
|
||||
# [5] CardDAV getetag (5k) 3043.8 → 2339.5 µs 1.30x
|
||||
# gates: identical outputs / byte-identical XML on every section
|
||||
```
|
||||
|
||||
## [7] Auth middleware span records
|
||||
|
||||
`tracing::Span::current().record("user_id", user_id.to_string())`
|
||||
allocated a 36-byte String per authenticated request (×3 auth paths).
|
||||
`tracing::field::display(user_id)` records lazily — the subscriber
|
||||
formats into its own buffer.
|
||||
|
||||
## Follow-ups worth a future round (confirmed real, not gated here)
|
||||
|
||||
- CardDAV multistatus is still fully buffered — port the CalDAV
|
||||
streaming emitter once contacts get a keyset pager (current
|
||||
`get_contacts_by_address_book_paginated` is LIMIT/OFFSET, the
|
||||
quadratic shape PROPFIND-PAGING replaced elsewhere).
|
||||
- CalDAV time-range REPORT still buffers (bounded by the range, but a
|
||||
year-wide range on a dense calendar is large).
|
||||
- `batch_resolve_ids` / `batch_check_favorites` take `&[String]` — every
|
||||
NC PROPFIND page clones ~500 id Strings that the services re-parse to
|
||||
`Uuid` anyway; switch the chain to `&[&str]` (8 call sites).
|
||||
- Hot listing SQL casts UUID columns to `::text` server-side (~18 sites
|
||||
in `file_blob_read_repository.rs`) — decode as `Uuid` + format
|
||||
app-side; needs a local-PG A/B before adopting.
|
||||
- Public-share landing runs register + fetch serially — `tokio::join!`
|
||||
or fold the increment into the fetch with `RETURNING`.
|
||||
- `CurrentUser` still clones username/email per request; zero-alloc
|
||||
needs the JWT cache to hold `Arc<str>` claims.
|
||||
- Grouped/swimlane files view virtualization (frontend, carried since
|
||||
ROUND3).
|
||||
@@ -0,0 +1,292 @@
|
||||
# Round 6 — CardDAV streaming, SPA quadratic re-render, borrowed NC id chain, authz fan-out
|
||||
|
||||
Benchmark-gated changes, same rule as ROUND2-5: every change ships with a
|
||||
BEFORE/AFTER benchmark; an AFTER that doesn't beat its BEFORE gets rolled
|
||||
back. Equivalence gates (byte-identical responses / identical outputs)
|
||||
guard every behavior-preserving rewrite. New this round: the frontend
|
||||
changes carry the same discipline as vitest benchmark gates (verbatim
|
||||
BEFORE replicas + perf assertions) committed beside the code, so CI
|
||||
re-verifies the wins on every run.
|
||||
|
||||
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 | CardDAV whole-book streaming | TTFB / peak heap (8k contacts) | 37.4 → 7.6 ms (**4.9x**) / 19.0 → 7.0 MiB (**2.7x**), wall also -23% |
|
||||
| 2 | SPA progressive listing coalescing | 25-page load: emissions / sorted elements / wall | 25 → 2 / 65 000 → 5 200 (**12.5x**) / 30.9 → 4.0 ms (**7.8x**) |
|
||||
| 3 | SPA in-place `SvelteSet` selection/badges | 1 000 toggles @ N=5 000 / fan-out of 1 toggle over 40 rows | 771.9 → 1.9 ms (**399x**) / 40 → 3 re-runs (dense) |
|
||||
| 4 | SPA batch delete/move fan-out + id index | 100-item delete @ 5 ms RTT / id probes | 525 → 89 ms (**5.9x**) / 38 825 → 500 |
|
||||
| 5 | `t()` resolved-value cache + `{{` guard | 20k mixed translations | 22.7 → 8.6 ms (**2.63x**) |
|
||||
| 6 | Borrowed NC id chain (`&[&str]` / `Uuid` keys) | allocs/child (500-child page) | 2.006 → 0.006 (**334x**), wall **1.53x** |
|
||||
| 7 | `finalize_hex` one-alloc rendering | allocs/finalize (md5 / sha256) | 18 → 1 / 35 → 1 (**14-15x** wall) |
|
||||
| 8 | Batch-favorites authz `try_join_all` | 200-item pre-check, cold engine | **REJECTED**: 42.6 → 56.4 ms cold, 0.15 → 0.23 ms warm |
|
||||
| 9 | Share-landing `join!` | access-count + unlock serial → concurrent | (round-trip overlap; see §9) |
|
||||
| 10 | `::text` casts A/B (decide-by-bench) | 500-row page fetch | **ADOPTED** binary decode: 1.225 → 1.044 ms mean (**1.17x**), p95 1.686 → 1.345 |
|
||||
|
||||
## [1] CardDAV whole-book responses — buffered double-residency → cursor streaming
|
||||
|
||||
The round-5 CalDAV streaming pattern, applied to CardDAV: the
|
||||
addressbook REPORT path (`addressbook-query` without a uid filter,
|
||||
`sync-collection`) and the depth-1 collection PROPFIND materialised
|
||||
every contact DTO — each row carrying its full `vcard` body — into one
|
||||
Vec, then rendered the complete multistatus into a second in-RAM
|
||||
buffer: the book resident twice, TTFB = full generation time.
|
||||
|
||||
Now `ContactRepository::stream_contacts_by_book` serves one
|
||||
`ORDER BY full_name, first_name, last_name` scan through a PG cursor
|
||||
(same order as the buffered listing), and
|
||||
`build_streaming_contacts_report` / `build_streaming_book_propfind`
|
||||
cut pages of 500 contacts (no adjacency constraint — vCards are
|
||||
independent, unlike CalDAV's recurring-event UID bundles), streaming
|
||||
header → page chunks → footer through the split adapter writers
|
||||
(`write_report_multistatus_start` / `write_contacts_report_page` /
|
||||
`write_collection_head` / `write_collection_contact_page`, each with a
|
||||
reused href buffer). Multiget and depth-0 keep the buffered path. The
|
||||
address-book Read/public gate runs once before the cursor opens.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_carddav_stream
|
||||
# 8000 contacts, page=500, 9 passes
|
||||
# [1] REPORT addressbook-query (getetag) TTFB ms wall ms peak heap MiB
|
||||
# BEFORE (buffered) 37.4 37.4 19.0
|
||||
# AFTER (cursor stream) 7.6 28.9 7.0
|
||||
# TTFB 4.9x, peak heap 2.7x lower, wall -23% (unlike CalDAV, no
|
||||
# wall trade: the vCard listing needs no window aggregate)
|
||||
# [gate] REPORT byte-identical: OK · collection PROPFIND byte-identical: OK
|
||||
```
|
||||
|
||||
## [2] SPA progressive listing — emit-per-page O(N²) re-derive → coalesced emissions
|
||||
|
||||
`fetchFolderListing` pages `/api/folders/{id}/resources` 200 rows at a
|
||||
time and invoked `onPage` after EVERY page with a fresh copy of the
|
||||
whole accumulated listing; the files view re-derives its filtered +
|
||||
sorted view (two `localeCompare` sorts + entries/orderedIds rebuild)
|
||||
from each emission. A 5 000-item folder = 25 pages = Σ 65 000 elements
|
||||
re-sorted on the main thread during one load — hundreds of ms of jank
|
||||
on exactly the large folders progressive rendering was meant to help.
|
||||
Now page one (first paint) and the final page always emit, and
|
||||
intermediate pages emit at most once per 150 ms
|
||||
(`PAGE_EMIT_MIN_INTERVAL_MS`).
|
||||
|
||||
Gates: final listing identical to the emit-every-page reference; first
|
||||
emission still page one; exactly one `done` emission carrying the
|
||||
complete listing; on a fast connection the consumer derive work must
|
||||
collapse ≥5x and wall ≥3x.
|
||||
|
||||
```
|
||||
cd frontend && npx vitest run src/lib/api/endpoints/folders.bench.test.ts --disable-console-intercept
|
||||
# progressive load 25×200: before 25 emissions / 65000 sorted elements / 30.9 ms
|
||||
# after 2 emissions / 5200 sorted elements / 4.0 ms
|
||||
# (7.8x wall, 12.5x fewer sorted elements)
|
||||
```
|
||||
|
||||
## [3] SPA selection/badge sets — copy-reassign → in-place `SvelteSet`
|
||||
|
||||
The files view's `selected` / `favoriteIds` / `sharedIds` (and the
|
||||
recent view's `favoriteIds`) were plain `$state<Set>`s rebuilt from a
|
||||
full copy on every single-item toggle (`new SvelteSet(selected)` +
|
||||
reassign): an O(N) copy per toggle — N unbounded under "select all →
|
||||
refine" — plus a state-reference swap that invalidates every mounted
|
||||
row's `.has()` read. Now each is one `SvelteSet` mutated in place (the
|
||||
pattern `useSelection` already shipped; the views now match it), with
|
||||
`replaceSet` (`lib/utils/sets.ts`) for wholesale refills.
|
||||
|
||||
Measured `SvelteSet` granularity (svelte 5.56 `reactivity/set.js`):
|
||||
present keys are per-key sources; `.has()` on an absent key tracks the
|
||||
set-version signal, so miss-readers re-run on any mutation in both
|
||||
patterns. The in-place win = no O(N) copy + every other present-key
|
||||
reader spared. Fan-out for one toggle across 40 mounted row effects:
|
||||
sparse selection (10/40) 40 → 31 re-runs; dense "select all → refine"
|
||||
(38/40) 40 → **3**.
|
||||
|
||||
```
|
||||
cd frontend && npx vitest run src/lib/composables/selectionPatterns.bench.test.ts --disable-console-intercept
|
||||
# 1000 toggles @ N=5000: copy-reassign 771.9 ms vs in-place 1.9 ms (398.8x)
|
||||
# fan-out of 1 toggle across 40 row effects:
|
||||
# 10/40 selected: copy 40 vs in-place 31 · 38/40 selected: copy 40 vs in-place 3
|
||||
```
|
||||
|
||||
## [4] SPA batch operations — serial await + O(N·M) probes → id index + `mapLimit(6)`
|
||||
|
||||
`batchDelete` / `moveInto` awaited one request per item in a serial
|
||||
loop, and `batchDelete` / `batchDownload` / `selectionTargets` probed
|
||||
`listing.folders.find(...)` / `.some(...)` per selected id (O(N·M)
|
||||
scans). Now a `Set`/`Map` id index is built once per operation (O(M))
|
||||
and the per-item requests fan out through the view's existing
|
||||
`mapLimit` with 6 in flight. Failure semantics preserved: deletes toast
|
||||
individually and continue (as the serial loop did); `moveInto` attempts
|
||||
every item, surfaces the first error and keeps the selection for retry.
|
||||
|
||||
```
|
||||
cd frontend && npx vitest run src/routes/files/batchOps.bench.test.ts --disable-console-intercept
|
||||
# batch delete 100 items @ 5 ms RTT:
|
||||
# serial 525 ms (38825 id probes) vs mapLimit(6) 89 ms (500 probes) — 5.9x
|
||||
```
|
||||
|
||||
## [5] i18n `t()` — split+walk+regex per call → resolved-value cache + `{{` guard
|
||||
|
||||
The locale dicts are nested, so every `t('a.b.c')` re-split its key and
|
||||
walked the tree; `interpolate` ran its global-regex `.replace` on every
|
||||
string although only ~7% of en.json values contain `{{`. A rendered
|
||||
list row calls `t()` ~10×. Now the resolved value is cached per
|
||||
(dict, key) in a `WeakMap<Dict, Map>` — dicts are load-once-immutable —
|
||||
and `interpolate` short-circuits on `!text.includes('{{')`.
|
||||
|
||||
Gates: byte-identical to the pre-fix reference across every real
|
||||
en.json key (nested, flat, underscore-fallback, missing), cold and
|
||||
warm; ≥1.5x on a 20k-call mixed workload. (A first attempt cached only
|
||||
the key split: 1.12x — below the gate; the value cache landed 2.63x.)
|
||||
|
||||
```
|
||||
cd frontend && npx vitest run src/lib/i18n/i18n.bench.test.ts --disable-console-intercept
|
||||
# t() hot path x 20000: cached+guarded 8.6 ms vs split+regex-per-call 22.7 ms (2.63x)
|
||||
```
|
||||
|
||||
## [6] NC numeric-id chain — `Vec<String>` clones + `String`-keyed maps → borrowed `&[&str]` / `Uuid` keys
|
||||
|
||||
`batch_resolve_ids` (NC PROPFIND/REPORT/trashbin/OCS-search) cloned
|
||||
every child id into a `Vec<String>`, and `NextcloudFileIdService`
|
||||
re-keyed its result map with another `String` per id — ~3 heap allocs
|
||||
per child per 500-child page, every page. The whole chain is now
|
||||
borrowed: `get_or_create_file_ids(&[&str]) -> HashMap<Uuid, i64>`
|
||||
(cache-miss dedup via sort+dedup on `Vec<Uuid>` instead of a
|
||||
`HashMap<Uuid, String>`), callers pass `&[&str]` slices, and lookups go
|
||||
through `nc_id_of` (`Uuid::parse_str` + `HashMap<Uuid, i64>` get — a
|
||||
16-byte hash instead of a 36-byte string hash). `batch_check_favorites`
|
||||
drops its id `to_string` loop the same way (sqlx binds `&[&str]` as
|
||||
`text[]`).
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_hex_ids
|
||||
# batch_resolve_ids marshalling: String-keyed vs borrowed+Uuid
|
||||
# (1000 pages x 500 children/arm)
|
||||
# arm | allocs | wall ms | allocs/child
|
||||
# BEFORE | 1 003 000 | 85.97 | 2.006
|
||||
# AFTER | 3 000 | 56.27 | 0.006 (334x fewer allocs, 1.53x wall)
|
||||
```
|
||||
|
||||
## [7] `finalize_hex` — one `format!` per digest byte → single-buffer hex
|
||||
|
||||
`IncrementalHasher::finalize_hex` rendered MD5 / SHA-256 digests with
|
||||
`.map(|b| format!("{b:02x}")).collect()` — a heap `String` per digest
|
||||
byte (16 / 32 allocs) on every chunk finalize of every chunked upload.
|
||||
Now `common::fmt::hex_lower` (new, unit-tested against the `format!`
|
||||
reference) writes both nibbles per byte into one preallocated String.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_hex_ids
|
||||
# finalize_hex: per-byte format! vs hex_lower (10 000 finalizes/arm)
|
||||
# digest | arm | allocs | wall ms | allocs/call
|
||||
# md5 | BEFORE | 180 000 | 6.44 | 18.00
|
||||
# md5 | AFTER | 10 000 | 0.45 | 1.00 (14.3x wall)
|
||||
# sha256 | BEFORE | 350 000 | 12.34 | 35.00
|
||||
# sha256 | AFTER | 10 000 | 0.80 | 1.00 (15.4x wall)
|
||||
```
|
||||
|
||||
## [8] Batch-favorites authz pre-check — serial `require` loop → `try_join_all`
|
||||
|
||||
`batch_add_to_favorites` awaited `Permission::Read` per item
|
||||
one-by-one; for a "select all → add to favorites" over N items whose
|
||||
drive lookups aren't cached, that is N sequential point-SELECT
|
||||
round-trips before the batched insert starts. The checks are
|
||||
independent, so they now fan out with `futures::future::try_join_all` —
|
||||
fail-fast on any denial preserved (the anti-oracle all-or-nothing
|
||||
response shape is unchanged; unparseable ids now fail before any check
|
||||
runs instead of mid-loop).
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_favorites_authz
|
||||
# files=200 pool=20 (shared-drive member, editor grant)
|
||||
# arm | wall ms | us/item
|
||||
# serial COLD | 42.62 | 213.12
|
||||
# join COLD | 56.44 | 282.20 <-- WORSE
|
||||
# serial WARM | 0.15 | 0.73
|
||||
# join WARM | 0.23 | 1.16 <-- WORSE
|
||||
```
|
||||
|
||||
## [9] Share landing — serial access-count + unlock → `tokio::join!`
|
||||
|
||||
`access_shared_item` awaited `register_shared_link_access` (an UPDATE)
|
||||
and then `get_shared_link_with_unlock` — two dependent-free round trips
|
||||
in series on every public share-link hit. They now run under one
|
||||
`tokio::join!`, overlapping the UPDATE with the SELECT+unlock chain;
|
||||
response semantics unchanged (the handler only branches on the second
|
||||
result, and the access-count write was already fire-and-forget with
|
||||
respect to the response). Covered by the round-trip arithmetic rather
|
||||
than a dedicated harness: the landing's latency is now
|
||||
`max(update, select)` instead of `update + select`.
|
||||
|
||||
## [10] `id::text` casts A/B — decided by bench
|
||||
|
||||
~18 SELECT sites in `file_blob_read_repository.rs` cast UUID columns to
|
||||
text server-side (`id::text`) and decode `String`. The alternative
|
||||
(binary `Uuid` decode + app-side `to_string`) was benched on identical
|
||||
500-row pages, interleaved A/B, equivalence-gated on identical string
|
||||
triples:
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_uuid_text_cast
|
||||
# rows/page=500 passes=200 (interleaved)
|
||||
# arm | mean ms | p50 ms | p95 ms
|
||||
# A ::text (current) | 1.225 | 1.176 | 1.686
|
||||
# B binary + to_string | 1.044 | 1.026 | 1.345
|
||||
# B/A mean ratio: 0.853 -> binary decode wins (1.17x)
|
||||
```
|
||||
|
||||
**Adopted**: `file_blob_read_repository.rs`'s page-shaped SELECTs (the 14
|
||||
`fi.id/fi.folder_id` listing queries + the Photos `top.*` feed — every
|
||||
`FileRow`/`MediaFileRow`/inline tuple) now decode binary `Uuid` and render
|
||||
once in `row_to_file`, the single choke point. Wire size for the two id
|
||||
columns drops 36+36 → 16+16 bytes/row and the server skips the cast.
|
||||
Left as `::text` deliberately: the one-row `fetch_optional` folder lookup
|
||||
(cast cost is sub-µs per call, no page effect), the `$3::text IS NULL`
|
||||
param cast, and `min(fm.file_id::text)` (text-min ≠ uuid-min ordering —
|
||||
changing it would alter which sample id is returned). Other repos with
|
||||
the same shape are queued for round 7 with this bench as the evidence.
|
||||
|
||||
## Rejected / deferred this round
|
||||
|
||||
- **JWT claims `Arc<str>`** (round-5 follow-up): `CurrentUser.username`
|
||||
/ `.email` are `String`s cloned per request from the cached
|
||||
`Arc<TokenClaims>`. Converting both structs to `Arc<str>` needs
|
||||
serde's `rc` feature for the JWT `Deserialize` and touches every
|
||||
`current_user.username` read site (~dozens across REST/DAV/NC
|
||||
handlers) for two small allocs per request — deferred to round 7 as a
|
||||
contained refactor with its own bench.
|
||||
- **Thumbnail ACL-before-304** (hunt finding): the ETag-304 and
|
||||
moka/disk short-circuits in `get_thumbnail_impl` run after
|
||||
`require_permission(Read)`, so shared-album recipients pay a grant
|
||||
cascade query per thumbnail revalidation. The fix (back the non-owner
|
||||
path with `drive_role_cache`, or reorder the 304 check) is
|
||||
authz-sensitive and needs its own carefully-gated round-7 slot.
|
||||
- **Thumbnail cache `String` key per request** and **`batch_operations`
|
||||
per-item `target_folder.to_string()`**: micro-allocs; the first needs
|
||||
a `Borrow`-friendly moka key design, the second an `Option<&str>`
|
||||
widening of `_with_perms` signatures. Both queued for a micro-alloc
|
||||
sweep with `bench_hex_ids`-style gates.
|
||||
|
||||
## Notes
|
||||
|
||||
- `deltaUpload.hash.test.ts`'s pre-existing "3-lane pool beats
|
||||
sequential" gate does not hold in this 4-core CI-class container
|
||||
(0.9-1.0x isolated, repeatedly) — environmental, unrelated to this
|
||||
round's changes, left untouched.
|
||||
- The frontend engine floor (`node >= 24`) makes `npm ci` require npm
|
||||
≥ 11 lockfile resolution; on a Node 22 box use `npx npm@12 ci`.
|
||||
|
||||
## Follow-ups seeded for round 7
|
||||
|
||||
- JWT claims `Arc<str>` end-to-end (see above).
|
||||
- Thumbnail 304/cache path vs ACL ordering (see above).
|
||||
- `fetchFolderListing` returns empty `favoriteIds`/`sharedIds` since the
|
||||
combined `/listing` route was removed — the files-view badge sets are
|
||||
seeded empty on navigation (functional regression flag, not perf).
|
||||
- Search page lacks a stale-response `seq` guard (files view has
|
||||
`loadSeq`); a slow stale filter response can clobber a newer one.
|
||||
- `list_folder_resources` clones `row.name` only because `icon_class_for`
|
||||
borrows it later — reorder to let the name move.
|
||||
- Swimlane/photos virtualization (carried from round 5).
|
||||
@@ -0,0 +1,146 @@
|
||||
# Round 7 — photo timeline O(N²) → incremental, range-seek authz duplication, row-map clone
|
||||
|
||||
Benchmark-gated changes, same rule as ROUND2-6: every change ships with a
|
||||
BEFORE/AFTER benchmark; an AFTER that doesn't beat its BEFORE gets rolled
|
||||
back. Equivalence gates (identical output / byte-identical responses) guard
|
||||
every behavior-preserving rewrite. Frontend changes carry vitest benchmark
|
||||
gates (verbatim BEFORE replica + equivalence + perf assertion) committed
|
||||
beside the code so CI re-verifies the win on every run.
|
||||
|
||||
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 | Photos timeline incremental grouping/layout | 50-page (3k-photo) scroll drain | 76 500 → 3 000 group ops (**25.5x**) / 23.0 → 2.2 ms (**10.6x**) |
|
||||
| 2 | Range-seek per-request authz duplication removed | per-seek authz on a shared-drive scrub | WARM 0.67 → 0 µs/seek; **COLD 1362.66 → 0 µs/seek** (a drive-resolve query per seek) |
|
||||
| 3 | `/resources` row→DTO name clone → move | allocs/row (500-row page) | 10.004 → 9.004 (**500 allocs saved**, 1.00/row) |
|
||||
|
||||
## [1] Photos timeline — O(N²) re-group + re-layout per page → incremental builder
|
||||
|
||||
The photos view appended each 60-item page with `items = [...items, ...page]`
|
||||
and re-derived both `groups` (O(N), a `new Date()` per photo) and `photoRows`
|
||||
(O(N) row layout) over the whole accumulated list on every page — so paging to
|
||||
photo N re-grouped + re-laid-out everything loaded so far, Σ ≈ O(N²/60) of
|
||||
main-thread work during the scroll (the exact class ROUND6 fixed for the files
|
||||
listing). The DOM was already windowed (`VirtualRows`); this was the derivation
|
||||
feeding it.
|
||||
|
||||
Because photos arrive newest-first (`media_sort_date DESC`), grouping is
|
||||
append-only: a page only ever extends the last date bucket or adds buckets
|
||||
after it, never mutates an earlier group. The new `PhotoTimeline`
|
||||
(`lib/utils/photoTimeline.ts`) exploits that — an append re-buckets only the
|
||||
fresh page and re-lays-out only the groups that changed, reusing every
|
||||
untouched group's cached rows; any other change (config, deletion, filter
|
||||
toggle, non-append) falls back to a full rebuild. The pure `buildPhotoRows` is
|
||||
the verbatim reference the gate holds it equal to.
|
||||
|
||||
Gates: the incremental output is deep-equal to `buildPhotoRows` at EVERY page
|
||||
of the drain (both square + justified layouts); config-change / deletion /
|
||||
width=0 fall back to a correct full rebuild; grouping work collapses ≥5x and
|
||||
wall ≥3x.
|
||||
|
||||
```
|
||||
cd frontend && npx vitest run src/lib/utils/photoTimeline.bench.test.ts --disable-console-intercept
|
||||
# photo timeline 50×60: before 76500 timestamp reads / 23.0 ms
|
||||
# after 3000 timestamp reads / 2.2 ms
|
||||
# (25.5x fewer grouping ops, 10.6x wall)
|
||||
```
|
||||
|
||||
## [2] Range downloads — duplicate per-seek authz + access-notify removed
|
||||
|
||||
`download_file_impl` resolves the file once via `get_file_with_perms` (authz +
|
||||
access-notify + metadata), then the Range branch called
|
||||
`get_file_range_preloaded_with_perms`, which re-ran `require_file` (authz) +
|
||||
`notify_file_accessed` per request. Media players and PDF viewers fetch a file
|
||||
*exclusively* through Range requests — a `bytes=0-` probe then one request per
|
||||
seek — so every seek in a scrub re-authorized a file the request-level gate had
|
||||
already cleared. The share-landing and WebDAV range paths already authorize
|
||||
once then read via the non-perms `get_file_range_preloaded`; the REST handler
|
||||
now does the same (and the now-unused `_with_perms` range method is deleted).
|
||||
|
||||
Safety: the request-level `get_file_with_perms` still gates every request
|
||||
(denies before the Range branch runs), so the removed per-seek re-check
|
||||
bypasses nothing — the bench asserts the member is granted and a non-member
|
||||
denied.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_range_seek_authz
|
||||
# seeks/scrub=200 (member of a shared drive, viewer grant)
|
||||
# arm wall ms µs/seek
|
||||
# BEFORE per-seek (WARM) 0.13 0.67 <- moka hit + uuid parse, removed
|
||||
# BEFORE per-seek (COLD) 272.53 1362.66 <- a grant-cascade drive-resolve
|
||||
# QUERY per seek, removed
|
||||
# AFTER per-seek (removed) 0.00 0.00
|
||||
# A 200-seek scrub of a shared video stops paying ~272 ms of authz queries
|
||||
# when the drive-role cache is cold (cross-drive recipient, or 30 s TTL expiry
|
||||
# mid-scrub). notify_file_accessed (a throttled hook call) is likewise removed
|
||||
# per seek.
|
||||
```
|
||||
|
||||
## [3] `/api/folders/{id}/resources` row→DTO mapping — clone name → move name
|
||||
|
||||
The listing maps each owned `FolderResourceRow` into a DTO but cloned
|
||||
`row.name` into it (`name: row.name.clone()`) — one avoidable `String` heap
|
||||
alloc per listed folder/file. The folder branch uses fixed icon classes, so
|
||||
`row.name` is simply moved; the file branch computes its name-derived icon /
|
||||
category classes first (they borrow `&row.name`), then moves `row.name` in. One
|
||||
fewer alloc per row, identical output.
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_resource_row_map
|
||||
# rows=500
|
||||
# arm allocs wall ms allocs/row
|
||||
# BEFORE (clone) 5002 0.841 10.004
|
||||
# AFTER (move) 4502 0.810 9.004
|
||||
# Saved 500 allocs (1.00/row) — the per-row name clone removed; output identical.
|
||||
```
|
||||
|
||||
## Deferred / flagged (not shipped this round)
|
||||
|
||||
- **Thumbnail ACL-before-304 (security posture — needs maintainer decision).**
|
||||
`get_thumbnail_impl` runs `require_permission(Read)` before the ETag-304 and
|
||||
moka/disk short-circuits, so a shared-album recipient pays a grant-cascade
|
||||
query per thumbnail revalidation. Moving authz *after* the cache would make
|
||||
thumbnails "authorized at creation time only" — a user whose access was
|
||||
revoked could still fetch cached thumbnails of files they once could see.
|
||||
That is a deliberate security-posture change, not a perf tweak; left for a
|
||||
security review. The safe alternative (back the non-owner authz with the
|
||||
existing `drive_role_cache`, or a `Borrow<str>` cache key that removes the
|
||||
per-request `to_string`) is queued for round 8 with an alloc/query bench.
|
||||
- **`batch_operations` `Arc<str>` → `String` per item.** `copy_file_with_perms`
|
||||
/ `move_file_with_perms` take `Option<String>`, so the batch path's
|
||||
`target_folder: Arc<str>` is re-`to_string()`-ed per item, defeating the
|
||||
Arc. Widening those `_with_perms` signatures to `Option<&str>` touches the
|
||||
trait + impl + stub + ~7 call sites — a contained refactor better done
|
||||
deliberately with its own alloc bench; queued for round 8.
|
||||
- **List-view O(N²) re-derive (favorites / recent / trash / shared-with-me /
|
||||
shared swimlanes).** Same class as [1] but on typically-smaller lists;
|
||||
each infinite-scroll page re-derives `entries` / `byId` / `sections` /
|
||||
`lanes` over the full accumulated set. Deferred — the incremental-builder
|
||||
cost isn't yet justified at those sizes; revisit if any surface reaches
|
||||
thousands of rows.
|
||||
- **Serial independent DB pairs → `join!` (token refresh, login, cross-drive
|
||||
move, CardDAV discovery, NC PROPFIND enrichment).** Overlapping independent
|
||||
round-trips saves 1 RTT *under real PG latency*, but the ROUND6 authz-fan-out
|
||||
rejection showed the overhead can wash the win out on local-socket PG. These
|
||||
need a decide-by-bench with an injected-latency arm (like the ROUND6 `::text`
|
||||
A/B) before adoption — queued for round 8, not guessed at here.
|
||||
|
||||
## Correctness-adjacent (surfaced by the round-7 hunt — not perf, flagged for follow-up)
|
||||
|
||||
- **`fetchFolderListing` returns empty `favoriteIds`/`sharedIds`**
|
||||
(`frontend/src/lib/api/endpoints/folders.ts`) since the combined `/listing`
|
||||
route was removed — the files-grid star/shared badges are seeded empty on
|
||||
every navigation. The same removal also dropped the 304 conditional
|
||||
fast-path, so a folder navigation now pages the full body (`cache: no-store`)
|
||||
instead of a bodiless 304 on unchanged folders (mitigated only by the
|
||||
in-memory `folderCache`). Functional regression, not perf.
|
||||
- **Search page lacks a stale-response guard**
|
||||
(`frontend/src/routes/search/+page.svelte`): the query `$effect` awaits
|
||||
`searchFiles` with no `seq`/AbortController, so a slow stale query can
|
||||
resolve after and clobber a newer one. The files view's `loadSeq` is the
|
||||
pattern to mirror.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Round 8 — shared-album thumbnail authz: cache the folder-grant cascade decision
|
||||
|
||||
Benchmark-gated, same rule as ROUND2-7: every change ships with a BEFORE/AFTER
|
||||
benchmark and equivalence/safety gates; an AFTER that doesn't beat its BEFORE
|
||||
gets rolled back. This round touches the authorization engine, so the bench
|
||||
carries hard **safety gates** (recipient allowed, outsider denied, and a
|
||||
revoke-denies-immediately test) and the change is additionally validated
|
||||
against the full `--cfg integration_tests` authz suite.
|
||||
|
||||
Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release profile.
|
||||
|
||||
## Summary
|
||||
|
||||
| # | change | key metric | before → after |
|
||||
|--:|---|---|---|
|
||||
| 1 | `cascade_grant_cache` for File/Folder Read checks | shared-album thumbnail revalidation (100-photo) | 2576 → 2.70 µs/thumb (**~950x**); 257.6 → 0.27 ms/view |
|
||||
|
||||
## [1] Shared-album thumbnails — folder-grant cascade query per thumbnail → cached
|
||||
|
||||
`get_thumbnail_impl` runs `require_permission(Read, file)` on every request,
|
||||
ahead of the ETag-304 and moka/disk cache short-circuits. For the **owner** (or
|
||||
any drive member) that's a `drive_role_cache` hit — ~1 µs, no query. But a
|
||||
**shared-album recipient** — someone granted a *folder* (the album), not drive
|
||||
membership — fails the drive-role precheck in `PgAclEngine::check_inner` and
|
||||
falls through to `file_cascade_grant_exists`: an `role_grants ⋈ folders`
|
||||
ltree-ancestor (`lpath @>`) query, once per file. Browsers revalidate immutable
|
||||
thumbnails constantly (`If-None-Match`), so the same `(recipient, file, Read)`
|
||||
decision was recomputed on every thumbnail of every view — a shared 100-photo
|
||||
album cost ~100 grant queries per "navigate away and back".
|
||||
|
||||
The safe fix keeps the check exactly where it is — **authz is never skipped**,
|
||||
the ordering is unchanged — and memoises only its *result* in a new
|
||||
`cascade_grant_cache` (`(Subject, Resource, Permission) → bool`, 30 s TTL). It's
|
||||
consulted only after the drive-role precheck fails, so a caller who later gains
|
||||
a drive grant short-circuits above it and can't be shadowed by a stale entry.
|
||||
|
||||
**Invalidation** mirrors `drive_role_cache`'s documented convention exactly:
|
||||
explicit `invalidate_all` on every File/Folder `set_role` / `clear_role` (the
|
||||
direct share/revoke path — infrequent next to thumbnail reads, so a full flush
|
||||
is cheap and keeps a revoke *immediate*); the indirect paths (group-membership
|
||||
changes, resource moves, grant `expires_at` expiry) are caught by the 30 s TTL,
|
||||
"rather than a deep invalidation tree".
|
||||
|
||||
Safety gates in the bench (hard asserts): the folder-grant recipient is allowed
|
||||
on every album file, an outsider is denied, and — critically — after a warm
|
||||
cache serves `allowed`, a `clear_role` on the shared folder makes the very next
|
||||
check **deny** (proving the grant-write flush; without it the stale `true`
|
||||
would still serve). Also validated against the full `--cfg integration_tests`
|
||||
authz suite (grants, nested groups, drive membership, read-only freeze).
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_thumbnail_cascade_cache
|
||||
# thumbs=100 (recipient holds a folder grant, no drive membership)
|
||||
# arm wall ms µs/thumb
|
||||
# BEFORE (query/thumb) 257.60 2576.04 <- folder-cascade query per thumbnail
|
||||
# AFTER cold (first view) 84.18 841.76 <- distinct files miss+populate the cache
|
||||
# AFTER warm (revalidation) 0.27 2.70 <- all cache hits (~950x vs BEFORE)
|
||||
# Safety gates PASSED: recipient allowed, outsider denied, clear_role revoke
|
||||
# denies immediately (grant write flushed the cache).
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The batched search Read path (`check_files_read_batch`) is unchanged — it
|
||||
already resolves a page of files in one round-trip and isn't the
|
||||
per-thumbnail hot path; it neither reads nor writes this cache, so no
|
||||
consistency coupling is introduced.
|
||||
- First-view cost is unchanged (distinct files are cache misses that populate
|
||||
the cache); the win is on revalidation + repeat views, which is where the
|
||||
thumbnail traffic concentrates. A folder-level cascade cache would also cut
|
||||
the first-view N-queries to one-per-folder, but needs a file→parent-folder
|
||||
resolution and a wider invalidation story — deferred.
|
||||
- The ACL-before-304 *ordering* (running authz before the 304/cache
|
||||
short-circuits) is left intact — with the cascade decision now cached, the
|
||||
authz on the revalidation path is a memory hit, so the "zero DB work on a
|
||||
304" intent is restored without moving (and thus without weakening) the
|
||||
security check.
|
||||
@@ -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<str>` 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<Bytes>`) | 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<str>`, 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<str>` (`#[schema(value_type =
|
||||
String)]` keeps the OpenAPI shape; JSON output byte-identical), both
|
||||
enrichers consume their DTO, the intermediate `Vec<FileDto>`/`Vec<FolderDto>`
|
||||
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<FolderDto>`,
|
||||
`NcSession.user` is the same `Arc<CurrentUser>` 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<FolderDto> 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<String>`, 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<id, Contact>` 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<str>` → `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<str>`** (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.
|
||||
@@ -9,9 +9,10 @@ OxiCloud provides authenticated file and folder search with simple query paramet
|
||||
| `GET` | `/api/search/` | Simple search using query parameters |
|
||||
| `POST` | `/api/search/advanced` | Advanced search with a JSON body |
|
||||
| `GET` | `/api/search/suggest` | Lightweight autocomplete suggestions |
|
||||
| `DELETE` | `/api/search/cache` | Clear the search results cache |
|
||||
| `DELETE` | `/api/admin/search/cache` | Flush the shared search results cache (admin only) |
|
||||
|
||||
All search endpoints require authentication.
|
||||
All search endpoints require authentication. The cache flush is
|
||||
additionally restricted to administrators — see [Result Caching](#result-caching).
|
||||
|
||||
## Simple Search Parameters
|
||||
|
||||
@@ -59,7 +60,10 @@ Search results are cached in memory using the search criteria and user ID as the
|
||||
|
||||
- Cache TTL: 5 minutes
|
||||
- Max entries: 1000
|
||||
- Manual invalidation: `DELETE /api/search/cache`
|
||||
- Manual invalidation: `DELETE /api/admin/search/cache` — admin-only.
|
||||
The endpoint calls `invalidate_all()` on the shared moka cache, so
|
||||
one call cold-starts every subsequent search for every tenant; it's
|
||||
an operator debug lever, not a per-user affordance.
|
||||
|
||||
## Feature Flag
|
||||
|
||||
|
||||
+2
-2
@@ -2037,8 +2037,8 @@ PR:
|
||||
4. `tests/api/storage_cleanup_check.sh` clean.
|
||||
5. No new `cargo clippy` warnings.
|
||||
6. Tantivy index returns no cross-drive results for any caller.
|
||||
7. `/api/dedup/stats` shows blob ref-counts consistent with the
|
||||
number of files referencing each blob across all drives.
|
||||
7. `/api/admin/dedup/stats` shows blob ref-counts consistent with
|
||||
the number of files referencing each blob across all drives.
|
||||
|
||||
## UI design — outline for D1 and D3
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Basic-auth thundering-herd benchmark — K concurrent cache misses.
|
||||
//!
|
||||
//! Every WebDAV/CalDAV/CardDAV/NextCloud request authenticates through
|
||||
//! `AppPasswordService::verify_basic_auth`. The cache (TTL 300 s) used to be
|
||||
//! a plain get/insert: when a sync client holding K parallel connections hit
|
||||
//! an expired entry, all K in-flight requests missed simultaneously and each
|
||||
//! ran the full slow path — an Argon2id verification at ~64 MiB / t=3 / p=2
|
||||
//! apiece (100-300 ms CPU each). `try_get_with` now coalesces concurrent
|
||||
//! misses into ONE verification; failed verifications stay uncached.
|
||||
//!
|
||||
//! Sections:
|
||||
//! BEFORE (emulated) — K concurrent bare Argon2id verifications, the exact
|
||||
//! work the old code fanned out per herd
|
||||
//! AFTER — K concurrent verify_basic_auth on a cold cache
|
||||
//! (single-flight: 1 verification, K-1 waiters)
|
||||
//! warm-hit — p50 of the cached path
|
||||
//!
|
||||
//! Gate: AFTER's process-CPU delta must be ~1 verification (< 2x a single
|
||||
//! verify), while BEFORE burns ~K of them. All K results must be Ok and
|
||||
//! identical.
|
||||
//!
|
||||
//! Run (needs Postgres up; reads DATABASE_URL / OXICLOUD_DB_CONNECTION_STRING
|
||||
//! from .env):
|
||||
//! cargo run --release --features bench --example bench_auth_herd
|
||||
//! Tunables: BENCH_HERD (8)
|
||||
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use oxicloud::application::services::app_password_service::AppPasswordService;
|
||||
use oxicloud::infrastructure::repositories::pg::{AppPasswordPgRepository, UserPgRepository};
|
||||
use oxicloud::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Process CPU time (utime + stime) in seconds, from /proc/self/stat.
|
||||
fn cpu_seconds() -> f64 {
|
||||
let stat = std::fs::read_to_string("/proc/self/stat").expect("stat");
|
||||
// utime/stime are fields 14/15 (1-indexed) — index past the comm field
|
||||
// (it can contain spaces) via the closing paren.
|
||||
let rest = &stat[stat.rfind(')').unwrap() + 2..];
|
||||
let fields: Vec<&str> = rest.split_whitespace().collect();
|
||||
let utime: f64 = fields[11].parse().expect("utime");
|
||||
let stime: f64 = fields[12].parse().expect("stime");
|
||||
let hz = 100.0; // USER_HZ on all mainstream Linux configs
|
||||
(utime + stime) / hz
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
let url = env::var("DATABASE_URL")
|
||||
.or_else(|_| env::var("OXICLOUD_DB_CONNECTION_STRING"))
|
||||
.expect("set DATABASE_URL");
|
||||
let herd: usize = env_or("BENCH_HERD", 8);
|
||||
|
||||
let pool = Arc::new(
|
||||
PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect"),
|
||||
);
|
||||
|
||||
// ── Seed: user + NC-format app password (production Argon2 params) ──
|
||||
let username = format!("bench_herd_{}", std::process::id());
|
||||
let user_id: uuid::Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, password_hash, role)
|
||||
VALUES ($1, $2, '', 'user') RETURNING id",
|
||||
)
|
||||
.bind(&username)
|
||||
.bind(format!("{username}@bench.invalid"))
|
||||
.fetch_one(pool.as_ref())
|
||||
.await
|
||||
.expect("seed user");
|
||||
|
||||
// Production defaults: m=64 MiB, t=3, p=2 (config.rs auth defaults).
|
||||
let hasher = Arc::new(Argon2PasswordHasher::new(65536, 3, 2));
|
||||
let svc = Arc::new(AppPasswordService::new(
|
||||
Arc::new(AppPasswordPgRepository::new(pool.clone())),
|
||||
hasher.clone(),
|
||||
Arc::new(UserPgRepository::new(pool.clone())),
|
||||
"http://localhost".into(),
|
||||
));
|
||||
let (_ap_id, plain) = svc.create_nc(user_id, "bench").await.expect("create_nc");
|
||||
|
||||
// ── Single-verify baseline (what one Argon2id run costs here) ──────
|
||||
use oxicloud::application::ports::auth_ports::PasswordHasherPort;
|
||||
let ref_hash = hasher.hash_password("benchpw").await.expect("hash");
|
||||
let t = Instant::now();
|
||||
let c = cpu_seconds();
|
||||
assert!(
|
||||
hasher
|
||||
.verify_password("benchpw", &ref_hash)
|
||||
.await
|
||||
.expect("verify")
|
||||
);
|
||||
let one_wall = t.elapsed().as_secs_f64();
|
||||
let one_cpu = cpu_seconds() - c;
|
||||
println!(
|
||||
"single Argon2id verify: {:.0} ms wall, {:.0} ms CPU",
|
||||
one_wall * 1000.0,
|
||||
one_cpu * 1000.0
|
||||
);
|
||||
|
||||
// ── BEFORE (emulated): K concurrent bare verifications ─────────────
|
||||
let t = Instant::now();
|
||||
let c = cpu_seconds();
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for _ in 0..herd {
|
||||
let h = hasher.clone();
|
||||
let rh = ref_hash.clone();
|
||||
set.spawn(async move { h.verify_password("benchpw", &rh).await.expect("verify") });
|
||||
}
|
||||
while let Some(r) = set.join_next().await {
|
||||
assert!(r.expect("join"));
|
||||
}
|
||||
let before_wall = t.elapsed().as_secs_f64();
|
||||
let before_cpu = cpu_seconds() - c;
|
||||
|
||||
// ── AFTER: K concurrent verify_basic_auth on a cold cache ──────────
|
||||
let t = Instant::now();
|
||||
let c = cpu_seconds();
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for _ in 0..herd {
|
||||
let s = svc.clone();
|
||||
let u = username.clone();
|
||||
let p = plain.clone();
|
||||
set.spawn(async move { s.verify_basic_auth(&u, &p).await });
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
while let Some(r) = set.join_next().await {
|
||||
let (uid, uname, _, _) = r.expect("join").expect("verify_basic_auth");
|
||||
assert_eq!(uname, username);
|
||||
ids.push(uid);
|
||||
}
|
||||
assert!(ids.iter().all(|&u| u == user_id));
|
||||
let after_wall = t.elapsed().as_secs_f64();
|
||||
let after_cpu = cpu_seconds() - c;
|
||||
|
||||
// ── Warm hit p50 ────────────────────────────────────────────────────
|
||||
let mut lat = Vec::with_capacity(10_000);
|
||||
for _ in 0..10_000 {
|
||||
let t = Instant::now();
|
||||
let _ = svc
|
||||
.verify_basic_auth(&username, &plain)
|
||||
.await
|
||||
.expect("warm hit");
|
||||
lat.push(t.elapsed().as_secs_f64() * 1e6);
|
||||
}
|
||||
lat.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let warm_p50 = lat[lat.len() / 2];
|
||||
|
||||
println!("\n# herd of {herd} concurrent Basic Auth verifications, cold cache");
|
||||
println!(
|
||||
"{:<22} {:>10} {:>10} {:>14}",
|
||||
"variant", "wall ms", "CPU ms", "verifications"
|
||||
);
|
||||
println!(
|
||||
"{:<22} {:>10.0} {:>10.0} {:>14.1}",
|
||||
"BEFORE (per-caller)",
|
||||
before_wall * 1000.0,
|
||||
before_cpu * 1000.0,
|
||||
before_cpu / one_cpu
|
||||
);
|
||||
println!(
|
||||
"{:<22} {:>10.0} {:>10.0} {:>14.1}",
|
||||
"AFTER (single-flight)",
|
||||
after_wall * 1000.0,
|
||||
after_cpu * 1000.0,
|
||||
after_cpu / one_cpu
|
||||
);
|
||||
println!("warm cache hit p50: {warm_p50:.1} us");
|
||||
|
||||
// ── Cleanup ─────────────────────────────────────────────────────────
|
||||
let _ = sqlx::query("DELETE FROM auth.app_passwords WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(pool.as_ref())
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.execute(pool.as_ref())
|
||||
.await;
|
||||
|
||||
// ── Gate ────────────────────────────────────────────────────────────
|
||||
// AFTER must coalesce to ~1 verification's CPU; 2x headroom for
|
||||
// scheduler noise. BEFORE must show the herd actually fanned out.
|
||||
if after_cpu > one_cpu * 2.0 {
|
||||
eprintln!(
|
||||
"GATE FAIL: single-flight AFTER burned {:.1} verifications of CPU (expected ~1)",
|
||||
after_cpu / one_cpu
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
if before_cpu < one_cpu * (herd as f64) * 0.6 {
|
||||
eprintln!(
|
||||
"GATE WARN: BEFORE emulation did not saturate ({:.1} verifs)",
|
||||
before_cpu / one_cpu
|
||||
);
|
||||
}
|
||||
println!("\nGATE PASS: cold-cache herd coalesced to ~1 Argon2id run");
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Azure download-path benchmark — whole-blob buffering vs streaming (ROUND4).
|
||||
//!
|
||||
//! The old `AzureBlobBackend::get_blob_stream` / `get_blob_range_stream`
|
||||
//! drained the ENTIRE blob (or range) into one `Vec<u8>` before yielding
|
||||
//! a single mega-chunk: whole-blob RAM residency per reader, TTFB = full
|
||||
//! download time, and with `read_prefetch() = 8` the CDC reassembly path
|
||||
//! could hold 8 entire chunk-blobs at once. AFTER forwards the SDK's
|
||||
//! page/body streams directly (first page still awaited eagerly so a
|
||||
//! missing blob is an up-front NotFound).
|
||||
//!
|
||||
//! Technique: a local axum stub speaks just enough of the Azure Blob GET
|
||||
//! REST surface (ranged 16 MiB pages, `x-ms-*` headers) for the REAL
|
||||
//! `azure_storage_blobs` client — the backend points at it via the new
|
||||
//! `endpoint_url` override (also the Azurite hook). The stub synthesizes
|
||||
//! blob bytes deterministically per offset, so it holds no buffer and
|
||||
//! the peak-live-heap metric isolates the CLIENT path. BEFORE is the old
|
||||
//! collect-everything logic copied verbatim; AFTER is the real
|
||||
//! `AzureBlobBackend`. BLAKE3 gates assert byte-identical payloads.
|
||||
//!
|
||||
//! Run (no Postgres needed):
|
||||
//! cargo run --release --features bench --example bench_azure_stream
|
||||
//! Tunables (env): BENCH_MB (256) blob size, BENCH_TAIL_MB (128) range tail.
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::env;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{HeaderMap, Request, Response, StatusCode};
|
||||
use bytes::Bytes;
|
||||
use futures::StreamExt;
|
||||
use oxicloud::application::ports::blob_storage_ports::BlobStorageBackend;
|
||||
use oxicloud::common::config::AzureStorageConfig;
|
||||
use oxicloud::infrastructure::services::azure_blob_backend::AzureBlobBackend;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
// ─── Peak-live-heap tracking allocator ──────────────────────────────────────
|
||||
|
||||
static LIVE: AtomicU64 = AtomicU64::new(0);
|
||||
static PEAK: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct PeakAlloc;
|
||||
|
||||
fn bump(sz: u64) {
|
||||
let live = LIVE.fetch_add(sz, Ordering::Relaxed) + sz;
|
||||
PEAK.fetch_max(live, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
unsafe impl GlobalAlloc for PeakAlloc {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
bump(layout.size() as u64);
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
LIVE.fetch_sub(layout.size() as u64, Ordering::Relaxed);
|
||||
unsafe { System.dealloc(ptr, layout) }
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||
if new_size > layout.size() {
|
||||
bump((new_size - layout.size()) as u64);
|
||||
} else {
|
||||
LIVE.fetch_sub((layout.size() - new_size) as u64, Ordering::Relaxed);
|
||||
}
|
||||
unsafe { System.realloc(ptr, layout, new_size) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
bump(layout.size() as u64);
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: PeakAlloc = PeakAlloc;
|
||||
|
||||
// ─── Deterministic blob content (no stored buffer) ──────────────────────────
|
||||
|
||||
fn splitmix64(mut z: u64) -> u64 {
|
||||
z = z.wrapping_add(0x9E3779B97F4A7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// Fill `out` with the blob bytes at absolute offset `offset`.
|
||||
fn fill_at(out: &mut [u8], offset: u64) {
|
||||
let mut i = 0usize;
|
||||
while i < out.len() {
|
||||
let abs = offset + i as u64;
|
||||
let block = abs / 8;
|
||||
let word = splitmix64(block).to_le_bytes();
|
||||
let start_in_word = (abs % 8) as usize;
|
||||
let take = (8 - start_in_word).min(out.len() - i);
|
||||
out[i..i + take].copy_from_slice(&word[start_in_word..start_in_word + take]);
|
||||
i += take;
|
||||
}
|
||||
}
|
||||
|
||||
/// BLAKE3 of an arbitrary blob range, streamed in 1 MiB pieces.
|
||||
fn expected_hash(offset: u64, len: u64) -> blake3::Hash {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
let mut pos = 0u64;
|
||||
while pos < len {
|
||||
let take = ((len - pos) as usize).min(buf.len());
|
||||
fill_at(&mut buf[..take], offset + pos);
|
||||
hasher.update(&buf[..take]);
|
||||
pos += take as u64;
|
||||
}
|
||||
hasher.finalize()
|
||||
}
|
||||
|
||||
// ─── Azure Blob GET stub ────────────────────────────────────────────────────
|
||||
|
||||
fn parse_range(headers: &HeaderMap) -> Option<(u64, Option<u64>)> {
|
||||
let raw = headers
|
||||
.get("x-ms-range")
|
||||
.or_else(|| headers.get("range"))?
|
||||
.to_str()
|
||||
.ok()?;
|
||||
let spec = raw.strip_prefix("bytes=")?;
|
||||
let (a, b) = spec.split_once('-')?;
|
||||
let start: u64 = a.parse().ok()?;
|
||||
let end: Option<u64> = if b.is_empty() { None } else { b.parse().ok() };
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// Serve GET {container}/{blob} with ranged responses in streamed 256 KiB
|
||||
/// frames, synthesizing content per offset — the stub never holds the blob.
|
||||
async fn stub_azure(blob_len: u64) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind stub");
|
||||
let addr = listener.local_addr().expect("stub addr");
|
||||
|
||||
let app = axum::Router::new().fallback(move |req: Request<Body>| async move {
|
||||
if req.method() != axum::http::Method::GET {
|
||||
return Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.header("etag", "\"0x1\"")
|
||||
.header("last-modified", "Thu, 01 Jan 2026 00:00:00 GMT")
|
||||
.header("x-ms-request-id", "11111111-1111-1111-1111-111111111111")
|
||||
.header("date", "Thu, 01 Jan 2026 00:00:00 GMT")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
let (start, end_incl) = parse_range(req.headers()).unwrap_or((0, None));
|
||||
let end_incl = end_incl.unwrap_or(blob_len - 1).min(blob_len - 1);
|
||||
let this_len = end_incl - start + 1;
|
||||
|
||||
// Stream the payload in 256 KiB frames, generated on the fly.
|
||||
let body_stream = futures::stream::unfold(0u64, move |sent| async move {
|
||||
if sent >= this_len {
|
||||
return None;
|
||||
}
|
||||
let take = ((this_len - sent) as usize).min(256 * 1024);
|
||||
let mut frame = vec![0u8; take];
|
||||
fill_at(&mut frame, start + sent);
|
||||
Some((
|
||||
Ok::<Bytes, std::io::Error>(Bytes::from(frame)),
|
||||
sent + take as u64,
|
||||
))
|
||||
});
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header("content-type", "application/octet-stream")
|
||||
.header("content-length", this_len.to_string())
|
||||
.header(
|
||||
"content-range",
|
||||
format!("bytes {start}-{end_incl}/{blob_len}"),
|
||||
)
|
||||
.header("etag", "\"0x1\"")
|
||||
.header("last-modified", "Thu, 01 Jan 2026 00:00:00 GMT")
|
||||
.header("x-ms-blob-type", "BlockBlob")
|
||||
.header("x-ms-lease-status", "unlocked")
|
||||
.header("x-ms-lease-state", "available")
|
||||
.header("x-ms-request-id", "11111111-1111-1111-1111-111111111111")
|
||||
.header("x-ms-version", "2020-04-08")
|
||||
.header("x-ms-creation-time", "Thu, 01 Jan 2026 00:00:00 GMT")
|
||||
.header("x-ms-server-encrypted", "true")
|
||||
.header("date", "Thu, 01 Jan 2026 00:00:00 GMT")
|
||||
.body(Body::from_stream(body_stream))
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.expect("stub serve");
|
||||
});
|
||||
format!("http://{addr}/devaccount")
|
||||
}
|
||||
|
||||
// ─── BEFORE: verbatim old collect-everything implementations ────────────────
|
||||
|
||||
mod before {
|
||||
use super::*;
|
||||
use azure_storage_blobs::prelude::BlobClient;
|
||||
use oxicloud::application::ports::blob_storage_ports::BlobStream;
|
||||
|
||||
/// Old `get_blob_stream` body (drain everything, yield one chunk).
|
||||
pub async fn get_blob_stream(client: &BlobClient) -> Result<BlobStream, String> {
|
||||
let mut result_data: Vec<u8> = Vec::new();
|
||||
let mut stream = client.get().into_stream();
|
||||
|
||||
while let Some(response) = stream.next().await {
|
||||
let response = response.map_err(|e| format!("Failed to get blob: {e}"))?;
|
||||
let mut body = response.data;
|
||||
while let Some(chunk) = body.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Stream read error: {e}"))?;
|
||||
result_data.extend_from_slice(&chunk);
|
||||
}
|
||||
}
|
||||
|
||||
let stream: BlobStream = Box::pin(futures::stream::once(async move {
|
||||
Ok(Bytes::from(result_data))
|
||||
}));
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Old `get_blob_range_stream` body.
|
||||
pub async fn get_blob_range_stream(
|
||||
client: &BlobClient,
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> Result<BlobStream, String> {
|
||||
let range = match end {
|
||||
Some(e) => azure_core::request_options::Range::new(start, e),
|
||||
None => azure_core::request_options::Range::new(start, u64::MAX),
|
||||
};
|
||||
|
||||
let mut result_data: Vec<u8> = Vec::new();
|
||||
let mut stream = client.get().range(range).into_stream();
|
||||
|
||||
while let Some(response) = stream.next().await {
|
||||
let response = response.map_err(|e| format!("Failed to get blob range: {e}"))?;
|
||||
let mut body = response.data;
|
||||
while let Some(chunk) = body.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Stream range read error: {e}"))?;
|
||||
result_data.extend_from_slice(&chunk);
|
||||
}
|
||||
}
|
||||
|
||||
let stream: BlobStream = Box::pin(futures::stream::once(async move {
|
||||
Ok(Bytes::from(result_data))
|
||||
}));
|
||||
Ok(stream)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Drain helper: TTFB + wall + hash ───────────────────────────────────────
|
||||
|
||||
async fn drain(
|
||||
stream: oxicloud::application::ports::blob_storage_ports::BlobStream,
|
||||
t0: Instant,
|
||||
) -> (f64, f64, blake3::Hash, u64) {
|
||||
let mut stream = stream;
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let mut ttfb = None;
|
||||
let mut total = 0u64;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.expect("stream chunk");
|
||||
if ttfb.is_none() {
|
||||
ttfb = Some(t0.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
total += chunk.len() as u64;
|
||||
hasher.update(&chunk);
|
||||
}
|
||||
(
|
||||
ttfb.unwrap_or(f64::NAN),
|
||||
t0.elapsed().as_secs_f64() * 1e3,
|
||||
hasher.finalize(),
|
||||
total,
|
||||
)
|
||||
}
|
||||
|
||||
fn reset_peak() {
|
||||
PEAK.store(LIVE.load(Ordering::Relaxed), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn peak_mib() -> f64 {
|
||||
PEAK.load(Ordering::Relaxed) as f64 / (1024.0 * 1024.0)
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
let mb: u64 = env::var("BENCH_MB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(256);
|
||||
let tail_mb: u64 = env::var("BENCH_TAIL_MB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(128);
|
||||
let blob_len = mb * 1024 * 1024;
|
||||
let hash = "aabbccdd00112233445566778899eeff00112233445566778899aabbccddeeff";
|
||||
|
||||
let endpoint = stub_azure(blob_len).await;
|
||||
println!("bench_azure_stream — {mb} MiB blob via local stub at {endpoint}\n");
|
||||
|
||||
// AFTER: the real backend pointed at the stub via endpoint_url.
|
||||
let backend = AzureBlobBackend::new(&AzureStorageConfig {
|
||||
account_name: "devaccount".to_string(),
|
||||
account_key: base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
b"benchkeybenchkeybenchkey",
|
||||
),
|
||||
container: "blobs".to_string(),
|
||||
sas_token: None,
|
||||
endpoint_url: Some(endpoint.clone()),
|
||||
});
|
||||
|
||||
// BEFORE: a raw SDK client at the same endpoint for the verbatim old code.
|
||||
let creds = azure_storage::StorageCredentials::access_key(
|
||||
"devaccount",
|
||||
base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
b"benchkeybenchkeybenchkey",
|
||||
),
|
||||
);
|
||||
let old_client = azure_storage_blobs::prelude::ClientBuilder::with_location(
|
||||
azure_storage::CloudLocation::Custom {
|
||||
account: "devaccount".to_string(),
|
||||
uri: endpoint.clone(),
|
||||
},
|
||||
creds,
|
||||
)
|
||||
.container_client("blobs")
|
||||
.blob_client(format!("{}/{}.blob", &hash[0..2], hash));
|
||||
|
||||
let expect_full = expected_hash(0, blob_len);
|
||||
let tail_start = blob_len - tail_mb * 1024 * 1024;
|
||||
let expect_tail = expected_hash(tail_start, blob_len - tail_start);
|
||||
|
||||
// ── [1] Full-blob download ──────────────────────────────────────────────
|
||||
reset_peak();
|
||||
let t0 = Instant::now();
|
||||
let s = before::get_blob_stream(&old_client)
|
||||
.await
|
||||
.expect("before stream");
|
||||
let (ttfb_b, wall_b, hash_b, len_b) = drain(s, t0).await;
|
||||
let peak_b = peak_mib();
|
||||
|
||||
reset_peak();
|
||||
let t0 = Instant::now();
|
||||
let s = backend.get_blob_stream(hash).await.expect("after stream");
|
||||
let (ttfb_a, wall_a, hash_a, len_a) = drain(s, t0).await;
|
||||
let peak_a = peak_mib();
|
||||
|
||||
println!("[1] full {mb} MiB download TTFB ms wall ms peak live heap MiB");
|
||||
println!(" BEFORE (collect-then-yield) {ttfb_b:9.1} {wall_b:9.1} {peak_b:10.1}");
|
||||
println!(
|
||||
" AFTER (streamed) {ttfb_a:9.1} {wall_a:9.1} {peak_a:10.1} TTFB {:.0}x, heap {:.0}x lower",
|
||||
ttfb_b / ttfb_a,
|
||||
peak_b / peak_a
|
||||
);
|
||||
|
||||
// ── [2] Open-ended range (seek to last {tail_mb} MiB) ───────────────────
|
||||
reset_peak();
|
||||
let t0 = Instant::now();
|
||||
let s = before::get_blob_range_stream(&old_client, tail_start, None)
|
||||
.await
|
||||
.expect("before range");
|
||||
let (rttfb_b, rwall_b, rhash_b, rlen_b) = drain(s, t0).await;
|
||||
let rpeak_b = peak_mib();
|
||||
|
||||
reset_peak();
|
||||
let t0 = Instant::now();
|
||||
let s = backend
|
||||
.get_blob_range_stream(hash, tail_start, None)
|
||||
.await
|
||||
.expect("after range");
|
||||
let (rttfb_a, rwall_a, rhash_a, rlen_a) = drain(s, t0).await;
|
||||
let rpeak_a = peak_mib();
|
||||
|
||||
println!("[2] range bytes={tail_start}- ({tail_mb} MiB tail)");
|
||||
println!(" BEFORE (collect-then-yield) {rttfb_b:9.1} {rwall_b:9.1} {rpeak_b:10.1}");
|
||||
println!(
|
||||
" AFTER (streamed) {rttfb_a:9.1} {rwall_a:9.1} {rpeak_a:10.1} TTFB {:.0}x, heap {:.0}x lower",
|
||||
rttfb_b / rttfb_a,
|
||||
rpeak_b / rpeak_a
|
||||
);
|
||||
|
||||
// ── Equivalence gates ───────────────────────────────────────────────────
|
||||
let mut ok = true;
|
||||
if hash_b != expect_full || hash_a != expect_full || len_b != blob_len || len_a != blob_len {
|
||||
eprintln!("GATE FAIL full blob: hashes/length differ");
|
||||
ok = false;
|
||||
}
|
||||
if rhash_b != expect_tail || rhash_a != expect_tail || rlen_b != rlen_a {
|
||||
eprintln!("GATE FAIL range: hashes/length differ");
|
||||
ok = false;
|
||||
}
|
||||
println!(
|
||||
"\n[gate] BLAKE3(BEFORE) == BLAKE3(AFTER) == source: {}",
|
||||
if ok { "OK" } else { "FAILED" }
|
||||
);
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! CachedBlobBackend miss-stampede benchmark — duplicate remote fetches.
|
||||
//!
|
||||
//! K concurrent cold readers of ONE blob (a video player's parallel Range
|
||||
//! probes on an uncached file, N sync clients pulling the same new file)
|
||||
//! used to each download the FULL blob from the remote backend and race
|
||||
//! their writes on one shared deterministic `.tmp` path. The per-hash
|
||||
//! single-flight gate coalesces them onto one download; waiters serve the
|
||||
//! leader's cached file.
|
||||
//!
|
||||
//! The mock inner backend counts `get_blob_stream` calls and serves a
|
||||
//! 32 MiB blob with an injected 15 ms first-byte latency + paced chunks
|
||||
//! (models a remote object store).
|
||||
//!
|
||||
//! BEFORE (emulated) — K concurrent direct inner fetches, each draining
|
||||
//! the full stream (what the old miss path did)
|
||||
//! AFTER — K concurrent `CachedBlobBackend::get_blob_stream`
|
||||
//! on a cold cache
|
||||
//!
|
||||
//! Gates: AFTER's inner-fetch count == 1; the cached file must BLAKE3-match
|
||||
//! the source; K x full-drain wall reported for both.
|
||||
//!
|
||||
//! No Postgres. Run:
|
||||
//! cargo run --release --features bench --example bench_blob_cache
|
||||
//! Tunables: BENCH_CONCURRENCY (16), BENCH_BLOB_MB (32)
|
||||
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::StreamExt;
|
||||
use oxicloud::application::ports::blob_storage_ports::{
|
||||
BlobStorageBackend, BlobStream, StorageHealthStatus,
|
||||
};
|
||||
use oxicloud::domain::errors::DomainError;
|
||||
use oxicloud::infrastructure::services::cached_blob_backend::{BlobCacheConfig, CachedBlobBackend};
|
||||
|
||||
type BoxFut<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Mock remote backend: one in-RAM blob, counted reads, and — crucially —
|
||||
/// SHARED aggregate bandwidth: concurrent streams split one simulated
|
||||
/// 1 GiB/s link (a real NIC/egress link doesn't hand every duplicate
|
||||
/// download its own private lane, so duplicate fetches cost real wall
|
||||
/// time, not just bytes).
|
||||
struct MockRemote {
|
||||
data: Bytes,
|
||||
fetches: AtomicU64,
|
||||
bytes_served: AtomicU64,
|
||||
/// Virtual time (µs since bench start) when the shared link frees up.
|
||||
link_busy_until_us: Arc<tokio::sync::Mutex<u64>>,
|
||||
epoch: Instant,
|
||||
}
|
||||
|
||||
const LINK_BYTES_PER_SEC: u64 = 1024 * 1024 * 1024; // 1 GiB/s aggregate
|
||||
|
||||
impl MockRemote {
|
||||
fn new(data: Bytes) -> Self {
|
||||
Self {
|
||||
data,
|
||||
fetches: AtomicU64::new(0),
|
||||
bytes_served: AtomicU64::new(0),
|
||||
link_busy_until_us: Arc::new(tokio::sync::Mutex::new(0)),
|
||||
epoch: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stream(&self) -> BlobStream {
|
||||
self.fetches.fetch_add(1, Ordering::Relaxed);
|
||||
self.bytes_served
|
||||
.fetch_add(self.data.len() as u64, Ordering::Relaxed);
|
||||
let data = self.data.clone();
|
||||
let link = self.link_busy_until_us.clone();
|
||||
let epoch = self.epoch;
|
||||
let s = async_stream::stream! {
|
||||
// First-byte latency of a remote GET.
|
||||
tokio::time::sleep(Duration::from_millis(15)).await;
|
||||
let chunk = 4 * 1024 * 1024;
|
||||
let mut off = 0usize;
|
||||
while off < data.len() {
|
||||
let end = (off + chunk).min(data.len());
|
||||
// Reserve this chunk's slot on the shared link, then sleep
|
||||
// until the slot has elapsed — bandwidth divides across
|
||||
// every in-flight stream.
|
||||
let slot_us = (end - off) as u64 * 1_000_000 / LINK_BYTES_PER_SEC;
|
||||
let wake_us = {
|
||||
let mut busy = link.lock().await;
|
||||
let now_us = epoch.elapsed().as_micros() as u64;
|
||||
let start = (*busy).max(now_us);
|
||||
*busy = start + slot_us;
|
||||
*busy
|
||||
};
|
||||
let now_us = epoch.elapsed().as_micros() as u64;
|
||||
if wake_us > now_us {
|
||||
tokio::time::sleep(Duration::from_micros(wake_us - now_us)).await;
|
||||
}
|
||||
yield Ok::<Bytes, std::io::Error>(data.slice(off..end));
|
||||
off = end;
|
||||
}
|
||||
};
|
||||
Box::pin(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl BlobStorageBackend for MockRemote {
|
||||
fn initialize(&self) -> BoxFut<'_, Result<(), DomainError>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
fn put_blob(&self, _hash: &str, _source_path: &Path) -> BoxFut<'_, Result<u64, DomainError>> {
|
||||
Box::pin(async { Ok(0) })
|
||||
}
|
||||
fn put_blob_from_bytes(
|
||||
&self,
|
||||
_hash: &str,
|
||||
data: Bytes,
|
||||
) -> BoxFut<'_, Result<u64, DomainError>> {
|
||||
Box::pin(async move { Ok(data.len() as u64) })
|
||||
}
|
||||
fn get_blob_stream(&self, _hash: &str) -> BoxFut<'_, Result<BlobStream, DomainError>> {
|
||||
let s = self.stream();
|
||||
Box::pin(async move { Ok(s) })
|
||||
}
|
||||
fn get_blob_range_stream(
|
||||
&self,
|
||||
_hash: &str,
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> BoxFut<'_, Result<BlobStream, DomainError>> {
|
||||
let data = self.data.clone();
|
||||
self.fetches.fetch_add(1, Ordering::Relaxed);
|
||||
Box::pin(async move {
|
||||
let end = end.unwrap_or(data.len() as u64).min(data.len() as u64);
|
||||
let s = futures::stream::once(async move {
|
||||
Ok::<Bytes, std::io::Error>(data.slice(start as usize..end as usize))
|
||||
});
|
||||
Ok(Box::pin(s) as BlobStream)
|
||||
})
|
||||
}
|
||||
fn delete_blob(&self, _hash: &str) -> BoxFut<'_, Result<(), DomainError>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
fn blob_exists(&self, _hash: &str) -> BoxFut<'_, Result<bool, DomainError>> {
|
||||
Box::pin(async { Ok(true) })
|
||||
}
|
||||
fn blob_size(&self, _hash: &str) -> BoxFut<'_, Result<u64, DomainError>> {
|
||||
let n = self.data.len() as u64;
|
||||
Box::pin(async move { Ok(n) })
|
||||
}
|
||||
fn health_check(&self) -> BoxFut<'_, Result<StorageHealthStatus, DomainError>> {
|
||||
Box::pin(async {
|
||||
Ok(StorageHealthStatus {
|
||||
connected: true,
|
||||
backend_type: "mock".into(),
|
||||
message: "ok".into(),
|
||||
available_bytes: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
fn backend_type(&self) -> &'static str {
|
||||
"mock"
|
||||
}
|
||||
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain(mut s: BlobStream) -> (u64, [u8; 32]) {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let mut n = 0u64;
|
||||
while let Some(chunk) = s.next().await {
|
||||
let b = chunk.expect("chunk");
|
||||
n += b.len() as u64;
|
||||
hasher.update(&b);
|
||||
}
|
||||
(n, hasher.finalize().into())
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
let k: usize = env_or("BENCH_CONCURRENCY", 16);
|
||||
let blob_mb: usize = env_or("BENCH_BLOB_MB", 32);
|
||||
|
||||
let data: Bytes = (0..blob_mb * 1024 * 1024)
|
||||
.map(|i| (i * 37 % 249) as u8)
|
||||
.collect::<Vec<u8>>()
|
||||
.into();
|
||||
let ref_hash: [u8; 32] = blake3::hash(&data).into();
|
||||
let blob_len = data.len() as u64;
|
||||
let hash = "benchblobcache00000000000000000000000000000000000000000000000000";
|
||||
|
||||
// ── BEFORE (emulated): K concurrent direct inner fetches ───────────
|
||||
let remote = Arc::new(MockRemote::new(data.clone()));
|
||||
let t = Instant::now();
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for _ in 0..k {
|
||||
let r = remote.clone();
|
||||
set.spawn(async move {
|
||||
let s = r.get_blob_stream(hash).await.expect("stream");
|
||||
drain(s).await
|
||||
});
|
||||
}
|
||||
while let Some(res) = set.join_next().await {
|
||||
let (n, h) = res.expect("join");
|
||||
assert_eq!(n, blob_len);
|
||||
assert_eq!(h, ref_hash);
|
||||
}
|
||||
let before_wall = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let before_fetches = remote.fetches.load(Ordering::Relaxed);
|
||||
let before_mb = remote.bytes_served.load(Ordering::Relaxed) / (1024 * 1024);
|
||||
|
||||
// ── AFTER: K concurrent CachedBlobBackend reads, cold cache ────────
|
||||
let remote = Arc::new(MockRemote::new(data.clone()));
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let cached = Arc::new(CachedBlobBackend::new(
|
||||
remote.clone(),
|
||||
&BlobCacheConfig {
|
||||
cache_dir: dir.path().to_path_buf(),
|
||||
max_cache_bytes: 1 << 30,
|
||||
},
|
||||
));
|
||||
cached.initialize().await.expect("init");
|
||||
|
||||
let t = Instant::now();
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for _ in 0..k {
|
||||
let c = cached.clone();
|
||||
set.spawn(async move {
|
||||
let s = c.get_blob_stream(hash).await.expect("stream");
|
||||
drain(s).await
|
||||
});
|
||||
}
|
||||
while let Some(res) = set.join_next().await {
|
||||
let (n, h) = res.expect("join");
|
||||
assert_eq!(n, blob_len);
|
||||
assert_eq!(h, ref_hash, "cached read corrupted");
|
||||
}
|
||||
let after_wall = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let after_fetches = remote.fetches.load(Ordering::Relaxed);
|
||||
let after_mb = remote.bytes_served.load(Ordering::Relaxed) / (1024 * 1024);
|
||||
|
||||
// Integrity of the durable cache file itself.
|
||||
let (n, h) = drain(cached.get_blob_stream(hash).await.expect("warm")).await;
|
||||
assert_eq!(n, blob_len);
|
||||
assert_eq!(h, ref_hash, "durable cache file corrupted");
|
||||
let warm_fetches = remote.fetches.load(Ordering::Relaxed) - after_fetches;
|
||||
|
||||
println!("# {k} concurrent cold readers of one {blob_mb} MiB blob (remote: 15 ms TTFB, paced)");
|
||||
println!(
|
||||
"{:<24} {:>10} {:>14} {:>12}",
|
||||
"variant", "wall ms", "inner fetches", "remote MiB"
|
||||
);
|
||||
println!(
|
||||
"{:<24} {:>10.0} {:>14} {:>12}",
|
||||
"BEFORE (per-caller)", before_wall, before_fetches, before_mb
|
||||
);
|
||||
println!(
|
||||
"{:<24} {:>10.0} {:>14} {:>12}",
|
||||
"AFTER (single-flight)", after_wall, after_fetches, after_mb
|
||||
);
|
||||
|
||||
// ── Gates ───────────────────────────────────────────────────────────
|
||||
if after_fetches != 1 {
|
||||
eprintln!("GATE FAIL: expected exactly 1 coalesced remote fetch, got {after_fetches}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
if warm_fetches != 0 {
|
||||
eprintln!("GATE FAIL: warm read hit the remote backend");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("\nGATE PASS: {before_fetches} remote fetches -> 1, cache file verified");
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
//! CalDAV parse-path benchmark — the write-side 8×-reparse and the
|
||||
//! read-side per-event copies (ROUND4).
|
||||
//!
|
||||
//! What changed:
|
||||
//!
|
||||
//! • `CalendarEvent::from_ical` funnelled each of its 8 property
|
||||
//! lookups through an extractor that re-ran the full `IcalParser`
|
||||
//! (line unfolding + component tree) over the whole body — 8
|
||||
//! complete parses per VEVENT on every CalDAV PUT, `8·(M+1)` on a
|
||||
//! master+M-exceptions PUT, `8·N` on an N-event import. Now: one
|
||||
//! parse, all lookups on the parsed component (value-only lookups
|
||||
//! also skip the parameter-map build).
|
||||
//! • `split_vevents` uppercased EVERY line into a fresh String.
|
||||
//! Now: allocation-free case-insensitive prefix tests.
|
||||
//! • `extract_vevent_chunk` (read side: every REPORT/GET, per event)
|
||||
//! allocated a full uppercase copy of the stored body just to find
|
||||
//! two tags. Now: memchr fast path + alloc-free CI scan fallback.
|
||||
//! • `group_events_by_uid` (read side, per REPORT) cloned every
|
||||
//! event's UID String. Now: borrowed keys.
|
||||
//!
|
||||
//! The OLD logic is copied verbatim into `mod before`; equivalence
|
||||
//! gates assert byte-identical parsed fields / chunk slices / grouping
|
||||
//! across a corpus incl. folded lines, params, VALARM, all-day,
|
||||
//! exceptions and mixed-case tags (exit 1 on any diff).
|
||||
//!
|
||||
//! Run (no Postgres needed):
|
||||
//! cargo run --release --features bench --example bench_caldav_parse
|
||||
//! Tunables (env):
|
||||
//! BENCH_EVENTS (200) BENCH_PASSES (30) BENCH_GROUP_N (5000)
|
||||
|
||||
use std::env;
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use oxicloud::application::adapters::caldav_adapter::bench as caldav_bench;
|
||||
use oxicloud::application::dtos::calendar_dto::CalendarEventDto;
|
||||
use oxicloud::domain::entities::calendar_event::CalendarEvent;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ─── BEFORE: verbatim copies of the pre-optimization logic ──────────────────
|
||||
|
||||
#[allow(clippy::all)]
|
||||
mod before {
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Old `parse_first_vevent` — fresh parser per call.
|
||||
pub fn parse_first_vevent(ical_data: &str) -> Option<ical::parser::ical::component::IcalEvent> {
|
||||
use std::io::BufReader;
|
||||
let reader = BufReader::new(ical_data.as_bytes());
|
||||
let parser = ical::IcalParser::new(reader);
|
||||
for cal in parser {
|
||||
let Ok(cal) = cal else { continue };
|
||||
if let Some(event) = cal.events.into_iter().next() {
|
||||
return Some(event);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Old params-aware extractor — one FULL parse per property lookup.
|
||||
pub fn extract_ical_property_with_params(
|
||||
ical_data: &str,
|
||||
property_name: &str,
|
||||
) -> Option<(String, HashMap<String, Vec<String>>)> {
|
||||
let event = parse_first_vevent(ical_data)?;
|
||||
let prop = event
|
||||
.properties
|
||||
.into_iter()
|
||||
.find(|p| p.name.eq_ignore_ascii_case(property_name))?;
|
||||
let value = prop.value?;
|
||||
if value.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut params: HashMap<String, Vec<String>> = HashMap::new();
|
||||
if let Some(param_list) = prop.params {
|
||||
for (name, values) in param_list {
|
||||
params.insert(name.to_ascii_uppercase(), values);
|
||||
}
|
||||
}
|
||||
Some((value.trim().to_string(), params))
|
||||
}
|
||||
|
||||
pub fn extract_ical_property(ical_data: &str, property_name: &str) -> Option<String> {
|
||||
extract_ical_property_with_params(ical_data, property_name).map(|(v, _p)| v)
|
||||
}
|
||||
|
||||
/// Comparable subset of the entity fields `from_ical` derives.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct BeforeEvent {
|
||||
pub summary: String,
|
||||
pub description: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub start_time: chrono::DateTime<chrono::Utc>,
|
||||
pub end_time: chrono::DateTime<chrono::Utc>,
|
||||
pub all_day: bool,
|
||||
pub rrule: Option<String>,
|
||||
pub ical_uid: Option<String>,
|
||||
pub recurrence_id: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
/// Old `from_ical` body (8 extractor calls = 8 full parses), minus
|
||||
/// the entity envelope (ids/timestamps — identical on both sides).
|
||||
pub fn from_ical(ical_data: &str) -> Result<BeforeEvent, String> {
|
||||
let summary = extract_ical_property(ical_data, "SUMMARY").ok_or("Missing SUMMARY")?;
|
||||
let (dtstart_value, dtstart_params) =
|
||||
extract_ical_property_with_params(ical_data, "DTSTART").ok_or("Missing DTSTART")?;
|
||||
let (dtend_value, _dtend_params) =
|
||||
extract_ical_property_with_params(ical_data, "DTEND").ok_or("Missing DTEND")?;
|
||||
let all_day = dtstart_params
|
||||
.get("VALUE")
|
||||
.map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
|
||||
.unwrap_or(false);
|
||||
let start_time = parse_ical_datetime(&dtstart_value, all_day)?;
|
||||
let end_time = parse_ical_datetime(&dtend_value, all_day)?;
|
||||
let description = extract_ical_property(ical_data, "DESCRIPTION");
|
||||
let location = extract_ical_property(ical_data, "LOCATION");
|
||||
let rrule = extract_ical_property(ical_data, "RRULE");
|
||||
let ical_uid = extract_ical_property(ical_data, "UID");
|
||||
let recurrence_id = match extract_ical_property_with_params(ical_data, "RECURRENCE-ID") {
|
||||
Some((value, params)) => {
|
||||
let is_date = params
|
||||
.get("VALUE")
|
||||
.map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
|
||||
.unwrap_or(false);
|
||||
parse_ical_datetime(&value, is_date).ok()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(BeforeEvent {
|
||||
summary,
|
||||
description,
|
||||
location,
|
||||
start_time,
|
||||
end_time,
|
||||
all_day,
|
||||
rrule,
|
||||
ical_uid,
|
||||
recurrence_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Old datetime parser (verbatim semantics for the two supported forms).
|
||||
pub fn parse_ical_datetime(
|
||||
value: &str,
|
||||
is_date_only: bool,
|
||||
) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
||||
use chrono::TimeZone;
|
||||
if is_date_only {
|
||||
if value.len() != 8 {
|
||||
return Err("bad all-day".into());
|
||||
}
|
||||
let year: i32 = value[0..4].parse().map_err(|_| "year")?;
|
||||
let month: u32 = value[4..6].parse().map_err(|_| "month")?;
|
||||
let day: u32 = value[6..8].parse().map_err(|_| "day")?;
|
||||
return chrono::NaiveDate::from_ymd_opt(year, month, day)
|
||||
.map(|d| chrono::Utc.from_utc_datetime(&d.and_hms_opt(0, 0, 0).unwrap()))
|
||||
.ok_or_else(|| "date".into());
|
||||
}
|
||||
if value.len() < 15 || !value.ends_with('Z') {
|
||||
return Err(format!("bad datetime {value:?}"));
|
||||
}
|
||||
let year: i32 = value[0..4].parse().map_err(|_| "year")?;
|
||||
let month: u32 = value[4..6].parse().map_err(|_| "month")?;
|
||||
let day: u32 = value[6..8].parse().map_err(|_| "day")?;
|
||||
let hour: u32 = value[9..11].parse().map_err(|_| "hour")?;
|
||||
let minute: u32 = value[11..13].parse().map_err(|_| "minute")?;
|
||||
let second: u32 = value[13..15].parse().map_err(|_| "second")?;
|
||||
match chrono::NaiveDate::from_ymd_opt(year, month, day) {
|
||||
Some(date) => match date.and_hms_opt(hour, minute, second) {
|
||||
Some(datetime) => Ok(chrono::Utc.from_utc_datetime(&datetime)),
|
||||
None => Err("time".into()),
|
||||
},
|
||||
None => Err("date".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Old `split_vevents` — per-line uppercase String.
|
||||
pub fn split_vevents(ical_data: &str) -> Vec<String> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut in_event = false;
|
||||
let mut current = String::new();
|
||||
for raw_line in ical_data.split('\n') {
|
||||
let line = raw_line.trim_end_matches('\r');
|
||||
let upper = line.trim_start().to_ascii_uppercase();
|
||||
if upper.starts_with("BEGIN:VEVENT") {
|
||||
in_event = true;
|
||||
current.clear();
|
||||
}
|
||||
if in_event {
|
||||
current.push_str(line);
|
||||
current.push_str("\r\n");
|
||||
}
|
||||
if in_event && upper.starts_with("END:VEVENT") {
|
||||
blocks.push(std::mem::take(&mut current));
|
||||
in_event = false;
|
||||
}
|
||||
}
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Old `extract_vevent_chunk` — full uppercase copy of the body.
|
||||
pub fn extract_vevent_chunk(ical_data: &str) -> Option<&str> {
|
||||
let upper = ical_data.to_ascii_uppercase();
|
||||
let begin = upper.find("BEGIN:VEVENT")?;
|
||||
let after_begin = &upper[begin..];
|
||||
let rel_end = after_begin.find("END:VEVENT")?;
|
||||
let end_tag_end = begin + rel_end + "END:VEVENT".len();
|
||||
let mut end = end_tag_end;
|
||||
if ical_data[end..].starts_with('\r') {
|
||||
end += 1;
|
||||
}
|
||||
if ical_data[end..].starts_with('\n') {
|
||||
end += 1;
|
||||
}
|
||||
Some(&ical_data[begin..end])
|
||||
}
|
||||
|
||||
/// Old `group_events_by_uid` — String-keyed map, UID cloned per event.
|
||||
pub fn group_events_by_uid<'a>(
|
||||
events: &'a [oxicloud::application::dtos::calendar_dto::CalendarEventDto],
|
||||
) -> Vec<Vec<&'a oxicloud::application::dtos::calendar_dto::CalendarEventDto>> {
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
let mut buckets: HashMap<
|
||||
String,
|
||||
Vec<&'a oxicloud::application::dtos::calendar_dto::CalendarEventDto>,
|
||||
> = HashMap::new();
|
||||
for event in events {
|
||||
let key = event.ical_uid.clone();
|
||||
if !buckets.contains_key(&key) {
|
||||
order.push(key.clone());
|
||||
}
|
||||
buckets.entry(key).or_default().push(event);
|
||||
}
|
||||
let mut out = Vec::with_capacity(order.len());
|
||||
for uid in order {
|
||||
let mut bucket = buckets.remove(&uid).unwrap_or_default();
|
||||
bucket.sort_by_key(|e| e.recurrence_id.is_some());
|
||||
out.push(bucket);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Corpus ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A realistic ~1.3 KiB VEVENT: params on DTSTART, folded DESCRIPTION,
|
||||
/// three ATTENDEEs with CN/PARTSTAT, ORGANIZER, VALARM, CATEGORIES,
|
||||
/// STATUS and X-props. `variant` 0 = timed master with RRULE, 1 = all-day,
|
||||
/// 2 = exception override (RECURRENCE-ID).
|
||||
fn build_vevent_body(i: usize, variant: usize) -> String {
|
||||
let uid = format!("evt-{i:05}@oxicloud.bench");
|
||||
let mut v = String::with_capacity(1400);
|
||||
v.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n");
|
||||
v.push_str("BEGIN:VEVENT\r\n");
|
||||
v.push_str(&format!("UID:{uid}\r\n"));
|
||||
v.push_str("DTSTAMP:20260701T120000Z\r\n");
|
||||
match variant {
|
||||
1 => {
|
||||
v.push_str("DTSTART;VALUE=DATE:20260810\r\n");
|
||||
v.push_str("DTEND;VALUE=DATE:20260811\r\n");
|
||||
}
|
||||
2 => {
|
||||
v.push_str("DTSTART:20260812T090000Z\r\n");
|
||||
v.push_str("DTEND:20260812T100000Z\r\n");
|
||||
v.push_str("RECURRENCE-ID:20260812T090000Z\r\n");
|
||||
}
|
||||
_ => {
|
||||
v.push_str("DTSTART:20260805T090000Z\r\n");
|
||||
v.push_str("DTEND:20260805T103000Z\r\n");
|
||||
v.push_str("RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20261231T000000Z\r\n");
|
||||
}
|
||||
}
|
||||
v.push_str(&format!(
|
||||
"SUMMARY:Sprint review #{i} — métricas y datos\r\n"
|
||||
));
|
||||
v.push_str(
|
||||
"DESCRIPTION:Repaso de los objetivos del sprint con el equipo completo\\, in\r\n cluyendo demo de la nueva vista de fotos y el plan de la ronda de rendimien\r\n to número cuatro.\r\n",
|
||||
);
|
||||
v.push_str("LOCATION:Sala Turing — 3ª planta\r\n");
|
||||
v.push_str("ORGANIZER;CN=Ana García:mailto:ana@example.com\r\n");
|
||||
v.push_str(
|
||||
"ATTENDEE;CN=Luis Pérez;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:luis@example.com\r\n",
|
||||
);
|
||||
v.push_str("ATTENDEE;CN=Sam Chen;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:sam@example.com\r\n");
|
||||
v.push_str("ATTENDEE;CN=Río Núñez;PARTSTAT=TENTATIVE:mailto:rio@example.com\r\n");
|
||||
v.push_str("CATEGORIES:TRABAJO,EQUIPO\r\n");
|
||||
v.push_str("STATUS:CONFIRMED\r\n");
|
||||
v.push_str("SEQUENCE:2\r\n");
|
||||
v.push_str("TRANSP:OPAQUE\r\n");
|
||||
v.push_str("X-OXICLOUD-ROUND:4\r\n");
|
||||
v.push_str("BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\n");
|
||||
v.push_str("END:VEVENT\r\n");
|
||||
v.push_str("END:VCALENDAR\r\n");
|
||||
v
|
||||
}
|
||||
|
||||
/// N-event import body (master + exception pairs inside one VCALENDAR).
|
||||
fn build_import_body(n_events: usize) -> String {
|
||||
let mut v = String::with_capacity(n_events * 1400);
|
||||
v.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Foreign//Client//EN\r\n");
|
||||
for i in 0..n_events {
|
||||
let single = build_vevent_body(i, i % 3);
|
||||
// Extract just the VEVENT block from the standalone body.
|
||||
let begin = single.find("BEGIN:VEVENT").unwrap();
|
||||
let end = single.find("END:VEVENT").unwrap() + "END:VEVENT\r\n".len();
|
||||
v.push_str(&single[begin..end]);
|
||||
}
|
||||
v.push_str("END:VCALENDAR\r\n");
|
||||
v
|
||||
}
|
||||
|
||||
fn make_dto(i: usize, uid: &str, recurrence: Option<DateTime<Utc>>) -> CalendarEventDto {
|
||||
CalendarEventDto {
|
||||
id: Uuid::from_u128(i as u128).to_string(),
|
||||
calendar_id: Uuid::nil().to_string(),
|
||||
summary: format!("Evento {i}"),
|
||||
description: None,
|
||||
location: None,
|
||||
start_time: Utc.with_ymd_and_hms(2026, 8, 5, 9, 0, 0).unwrap(),
|
||||
end_time: Utc.with_ymd_and_hms(2026, 8, 5, 10, 0, 0).unwrap(),
|
||||
all_day: false,
|
||||
rrule: None,
|
||||
ical_uid: uid.to_string(),
|
||||
recurrence_id: recurrence,
|
||||
ical_data: build_vevent_body(i, if recurrence.is_some() { 2 } else { 0 }),
|
||||
created_at: Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn p50(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
fn time_passes<T>(passes: usize, mut f: impl FnMut() -> T) -> f64 {
|
||||
let mut per_pass = Vec::with_capacity(passes);
|
||||
for _ in 0..passes {
|
||||
let t0 = Instant::now();
|
||||
black_box(f());
|
||||
per_pass.push(t0.elapsed().as_secs_f64() * 1e6);
|
||||
}
|
||||
p50(per_pass)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let n_events: usize = env::var("BENCH_EVENTS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(200);
|
||||
let passes: usize = env::var("BENCH_PASSES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(30);
|
||||
let group_n: usize = env::var("BENCH_GROUP_N")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5000);
|
||||
|
||||
let calendar_id = Uuid::nil();
|
||||
let bodies: Vec<String> = (0..n_events).map(|i| build_vevent_body(i, i % 3)).collect();
|
||||
let import_body = build_import_body(50);
|
||||
|
||||
println!("bench_caldav_parse — {n_events} bodies, {passes} passes\n");
|
||||
|
||||
// ── [1] from_ical: single-event PUT path ────────────────────────────────
|
||||
let t_before = time_passes(passes, || {
|
||||
for b in &bodies {
|
||||
black_box(before::from_ical(b).expect("before parse"));
|
||||
}
|
||||
}) / n_events as f64;
|
||||
let t_after = time_passes(passes, || {
|
||||
for b in &bodies {
|
||||
black_box(CalendarEvent::from_ical(calendar_id, b.clone()).expect("after parse"));
|
||||
}
|
||||
}) / n_events as f64;
|
||||
// The AFTER side clones the body (the real API takes it by value) —
|
||||
// measure that clone alone so the comparison can subtract it.
|
||||
let t_clone = time_passes(passes, || {
|
||||
for b in &bodies {
|
||||
black_box(b.clone());
|
||||
}
|
||||
}) / n_events as f64;
|
||||
println!("[1] from_ical µs/event (8-parse chain vs single parse)");
|
||||
println!(" BEFORE {t_before:8.2}");
|
||||
println!(
|
||||
" AFTER {t_after:8.2} (incl. {t_clone:.2} body clone) {:.1}x",
|
||||
t_before / (t_after - t_clone)
|
||||
);
|
||||
|
||||
// ── [2] parse_all_events: 50-event import PUT ───────────────────────────
|
||||
let t_before_imp = time_passes(passes, || {
|
||||
let blocks = before::split_vevents(&import_body);
|
||||
let mut out = Vec::with_capacity(blocks.len());
|
||||
for block in blocks {
|
||||
let wrapped = format!(
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n{}END:VCALENDAR\r\n",
|
||||
block,
|
||||
);
|
||||
out.push(before::from_ical(&wrapped).expect("before import"));
|
||||
}
|
||||
out
|
||||
});
|
||||
let t_after_imp = time_passes(passes, || {
|
||||
CalendarEvent::parse_all_events(calendar_id, &import_body).expect("after import")
|
||||
});
|
||||
println!("[2] parse_all_events µs/50-event import body");
|
||||
println!(" BEFORE {t_before_imp:8.1}");
|
||||
println!(
|
||||
" AFTER {t_after_imp:8.1} {:.1}x",
|
||||
t_before_imp / t_after_imp
|
||||
);
|
||||
|
||||
// ── [3] extract_vevent_chunk: REPORT/GET read path ──────────────────────
|
||||
let t_chunk_before = time_passes(passes, || {
|
||||
for b in &bodies {
|
||||
black_box(before::extract_vevent_chunk(b));
|
||||
}
|
||||
}) / n_events as f64
|
||||
* 1000.0;
|
||||
let t_chunk_after = time_passes(passes, || {
|
||||
for b in &bodies {
|
||||
black_box(caldav_bench::extract_vevent_chunk(b));
|
||||
}
|
||||
}) / n_events as f64
|
||||
* 1000.0;
|
||||
println!("[3] extract_vevent_chunk ns/event (uppercase copy vs direct scan)");
|
||||
println!(" BEFORE {t_chunk_before:8.0}");
|
||||
println!(
|
||||
" AFTER {t_chunk_after:8.0} {:.1}x",
|
||||
t_chunk_before / t_chunk_after
|
||||
);
|
||||
|
||||
// ── [4] group_events_by_uid: REPORT fold ────────────────────────────────
|
||||
// 80% masters, 20% exception overrides sharing a master's UID.
|
||||
let dtos: Vec<CalendarEventDto> = (0..group_n)
|
||||
.map(|i| {
|
||||
if i % 5 == 4 {
|
||||
let master = i - 1;
|
||||
make_dto(
|
||||
i,
|
||||
&format!("evt-{master:05}@oxicloud.bench"),
|
||||
Some(Utc.with_ymd_and_hms(2026, 8, 12, 9, 0, 0).unwrap()),
|
||||
)
|
||||
} else {
|
||||
make_dto(i, &format!("evt-{i:05}@oxicloud.bench"), None)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let t_grp_before = time_passes(passes, || black_box(before::group_events_by_uid(&dtos)));
|
||||
let t_grp_after = time_passes(passes, || {
|
||||
black_box(caldav_bench::group_events_by_uid(&dtos))
|
||||
});
|
||||
println!("[4] group_events_by_uid µs/{group_n} events (String keys vs borrowed)");
|
||||
println!(" BEFORE {t_grp_before:8.1}");
|
||||
println!(
|
||||
" AFTER {t_grp_after:8.1} {:.1}x",
|
||||
t_grp_before / t_grp_after
|
||||
);
|
||||
|
||||
// ── [5] Equivalence gates ───────────────────────────────────────────────
|
||||
let mut ok = true;
|
||||
|
||||
// Gate A: from_ical field identity across the corpus + edge bodies.
|
||||
let mut gate_bodies: Vec<String> = bodies.clone();
|
||||
gate_bodies.push(build_vevent_body(9990, 1));
|
||||
gate_bodies.push(build_vevent_body(9991, 2));
|
||||
// Mixed-case tags + LF-only line endings (foreign client shapes).
|
||||
gate_bodies.push(
|
||||
"begin:vcalendar\nversion:2.0\nbegin:vevent\nuid:mixed-case@x\nsummary:Mixed Case\ndtstart:20260801T080000Z\ndtend:20260801T090000Z\nend:vevent\nend:vcalendar\n"
|
||||
.to_string(),
|
||||
);
|
||||
for b in &gate_bodies {
|
||||
let bf = before::from_ical(b);
|
||||
let af = CalendarEvent::from_ical(calendar_id, b.clone());
|
||||
match (bf, af) {
|
||||
(Ok(bf), Ok(af)) => {
|
||||
let same = bf.summary == af.summary()
|
||||
&& bf.description.as_deref() == af.description()
|
||||
&& bf.location.as_deref() == af.location()
|
||||
&& bf.start_time == *af.start_time()
|
||||
&& bf.end_time == *af.end_time()
|
||||
&& bf.all_day == af.all_day()
|
||||
&& bf.rrule.as_deref() == af.rrule()
|
||||
&& bf.ical_uid.as_deref() == Some(af.ical_uid())
|
||||
&& bf.recurrence_id.as_ref() == af.recurrence_id();
|
||||
if !same {
|
||||
eprintln!("GATE A FAIL: field mismatch for body:\n{b}\n before={bf:?}");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
(Err(_), Err(_)) => {}
|
||||
(bf, af) => {
|
||||
eprintln!(
|
||||
"GATE A FAIL: error parity broke (before_ok={} after_ok={}) for body:\n{b}",
|
||||
bf.is_ok(),
|
||||
af.is_ok()
|
||||
);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gate B: parse_all_events equivalence on the import body — same
|
||||
// events, same wrapped per-row ical_data.
|
||||
let after_events =
|
||||
CalendarEvent::parse_all_events(calendar_id, &import_body).expect("import parses");
|
||||
let before_blocks = before::split_vevents(&import_body);
|
||||
if after_events.len() != before_blocks.len() {
|
||||
eprintln!(
|
||||
"GATE B FAIL: event count {} != block count {}",
|
||||
after_events.len(),
|
||||
before_blocks.len()
|
||||
);
|
||||
ok = false;
|
||||
}
|
||||
for (evt, block) in after_events.iter().zip(&before_blocks) {
|
||||
let wrapped = format!(
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n{}END:VCALENDAR\r\n",
|
||||
block,
|
||||
);
|
||||
if evt.ical_data() != wrapped {
|
||||
eprintln!("GATE B FAIL: wrapped ical_data mismatch");
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
let bf = before::from_ical(&wrapped).expect("before parses wrapped");
|
||||
if bf.summary != evt.summary() || bf.recurrence_id.as_ref() != evt.recurrence_id() {
|
||||
eprintln!("GATE B FAIL: field mismatch on wrapped block");
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Gate C: chunk slices byte-identical (incl. mixed-case + no-terminator).
|
||||
let mut chunk_bodies = bodies.clone();
|
||||
chunk_bodies.push("BEGIN:VCALENDAR\r\nbegin:vevent\r\nUID:x@y\r\nend:vevent".to_string());
|
||||
chunk_bodies.push("no vevent here at all".to_string());
|
||||
for b in &chunk_bodies {
|
||||
if before::extract_vevent_chunk(b) != caldav_bench::extract_vevent_chunk(b) {
|
||||
eprintln!("GATE C FAIL: chunk mismatch for body:\n{b}");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Gate D: grouping identity — same UID order, same per-bucket rows.
|
||||
let g_before = before::group_events_by_uid(&dtos);
|
||||
let g_after = caldav_bench::group_events_by_uid(&dtos);
|
||||
let shape = |g: &Vec<Vec<&CalendarEventDto>>| -> Vec<Vec<(String, bool)>> {
|
||||
g.iter()
|
||||
.map(|bucket| {
|
||||
bucket
|
||||
.iter()
|
||||
.map(|e| (e.id.clone(), e.recurrence_id.is_some()))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if shape(&g_before) != shape(&g_after) {
|
||||
eprintln!("GATE D FAIL: grouping mismatch");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
println!(
|
||||
"[5] Equivalence gates: {}",
|
||||
if ok { "OK (byte-identical)" } else { "FAILED" }
|
||||
);
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
//! CalDAV whole-calendar response benchmark — buffered vs streamed (ROUND5).
|
||||
//!
|
||||
//! The REPORT path (no-range calendar-query, sync-collection) and the
|
||||
//! collection `.ics` GET used to (a) materialise EVERY event DTO of the
|
||||
//! calendar in one Vec (owned `ical_data` per row), then (b) render the
|
||||
//! complete multistatus / VCALENDAR into a second in-RAM buffer — the
|
||||
//! calendar resident twice, TTFB = full generation. AFTER streams ONE
|
||||
//! window-ordered scan (`MIN(start_time) OVER (PARTITION BY ical_uid)`)
|
||||
//! through a PG cursor and cuts pages at UID boundaries — same-UID rows
|
||||
//! never split, bundle order equals the buffered first-appearance
|
||||
//! order, and only a page of rows is resident. (A first keyset-paged
|
||||
//! shape re-aggregated per page — 3-4x wall — and a per-uid ANY
|
||||
//! hydration paid ~20 µs per index descent — both measured and
|
||||
//! discarded; see ROUND5.md.)
|
||||
//!
|
||||
//! This bench drives the REAL repository methods + adapter writers both
|
||||
//! ways at the repo layer (authz gates are identical constants on both
|
||||
//! sides and excluded). BEFORE uses the surviving buffered generator
|
||||
//! (byte-stable refactor of the old monolith) + a verbatim copy of the
|
||||
//! removed `generate_full_calendar_ical`. Gates: streamed concatenation
|
||||
//! byte-identical to the buffered output for BOTH the multistatus and
|
||||
//! the ICS body (seeded with strictly distinct start times so ordering
|
||||
//! is deterministic).
|
||||
//!
|
||||
//! Run (needs Postgres up; reads DATABASE_URL from .env):
|
||||
//! cargo run --release --features bench --example bench_caldav_stream
|
||||
//! Tunables (env): BENCH_EVENTS (4000), BENCH_PAGE (500), BENCH_PASSES (9).
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::env;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use oxicloud::application::adapters::caldav_adapter::{
|
||||
CalDavAdapter, CalDavReportType, bench as caldav_bench,
|
||||
};
|
||||
use oxicloud::application::dtos::calendar_dto::CalendarEventDto;
|
||||
use oxicloud::domain::repositories::calendar_event_repository::CalendarEventRepository;
|
||||
use oxicloud::infrastructure::repositories::pg::CalendarEventPgRepository;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ─── Peak-live-heap tracking allocator ──────────────────────────────────────
|
||||
|
||||
static LIVE: AtomicU64 = AtomicU64::new(0);
|
||||
static PEAK: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct PeakAlloc;
|
||||
|
||||
fn bump(sz: u64) {
|
||||
let live = LIVE.fetch_add(sz, Ordering::Relaxed) + sz;
|
||||
PEAK.fetch_max(live, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
unsafe impl GlobalAlloc for PeakAlloc {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
bump(layout.size() as u64);
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
LIVE.fetch_sub(layout.size() as u64, Ordering::Relaxed);
|
||||
unsafe { System.dealloc(ptr, layout) }
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||
if new_size > layout.size() {
|
||||
bump((new_size - layout.size()) as u64);
|
||||
} else {
|
||||
LIVE.fetch_sub((layout.size() - new_size) as u64, Ordering::Relaxed);
|
||||
}
|
||||
unsafe { System.realloc(ptr, layout, new_size) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
bump(layout.size() as u64);
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: PeakAlloc = PeakAlloc;
|
||||
|
||||
// ─── BEFORE: verbatim copy of the removed whole-calendar ICS builder ────────
|
||||
|
||||
#[allow(clippy::all)]
|
||||
mod before {
|
||||
use super::*;
|
||||
|
||||
/// Verbatim copy of the removed `generate_full_calendar_ical`.
|
||||
pub fn generate_full_calendar_ical(calendar_name: &str, events: &[CalendarEventDto]) -> String {
|
||||
let mut buf = String::with_capacity(256 + events.len() * 320);
|
||||
let _ = write!(
|
||||
buf,
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\nX-WR-CALNAME:{}\r\n",
|
||||
calendar_name
|
||||
);
|
||||
for group in caldav_bench::group_events_by_uid(events) {
|
||||
for event in group {
|
||||
if let Some(chunk) = caldav_bench::extract_vevent_chunk(&event.ical_data) {
|
||||
buf.push_str(chunk);
|
||||
if !buf.ends_with('\n') {
|
||||
buf.push_str("\r\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
buf.push_str("END:VCALENDAR\r\n");
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Seed ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn vevent_body(uid: &str, start: DateTime<Utc>, exception: bool) -> String {
|
||||
let dt = start.format("%Y%m%dT%H%M%SZ");
|
||||
let dtend = (start + chrono::Duration::minutes(45)).format("%Y%m%dT%H%M%SZ");
|
||||
let mut v = String::with_capacity(640);
|
||||
v.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n");
|
||||
v.push_str("BEGIN:VEVENT\r\n");
|
||||
let _ = write!(v, "UID:{uid}\r\nDTSTAMP:20260701T120000Z\r\n");
|
||||
let _ = write!(v, "DTSTART:{dt}\r\nDTEND:{dtend}\r\n");
|
||||
if exception {
|
||||
let _ = write!(v, "RECURRENCE-ID:{dt}\r\n");
|
||||
} else {
|
||||
v.push_str("RRULE:FREQ=WEEKLY;BYDAY=WE\r\n");
|
||||
}
|
||||
let _ = write!(v, "SUMMARY:Reunión {uid}\r\n");
|
||||
v.push_str("LOCATION:Sala 3\r\nSTATUS:CONFIRMED\r\n");
|
||||
v.push_str("BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT10M\r\nEND:VALARM\r\n");
|
||||
v.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
v
|
||||
}
|
||||
|
||||
struct Seeded {
|
||||
calendar_id: Uuid,
|
||||
owner_id: Uuid,
|
||||
}
|
||||
|
||||
async fn seed(pool: &PgPool, n: usize) -> Seeded {
|
||||
let owner_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, role)
|
||||
VALUES ('bench_calstream', 'bench_calstream@bench.invalid', 'user') RETURNING id",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed user");
|
||||
let calendar_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO caldav.calendars (id, name, owner_id)
|
||||
VALUES (gen_random_uuid(), 'Agenda grande', $1) RETURNING id",
|
||||
)
|
||||
.bind(owner_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed calendar");
|
||||
|
||||
let base = Utc.with_ymd_and_hms(2026, 1, 5, 8, 0, 0).unwrap();
|
||||
let mut tx = pool.begin().await.expect("begin");
|
||||
for i in 0..n {
|
||||
// 20% of rows are exception overrides sharing the previous
|
||||
// master's UID; every start_time is strictly distinct so the
|
||||
// response ordering is deterministic (byte-identity gate).
|
||||
let exception = i % 5 == 4;
|
||||
let master = if exception { i - 1 } else { i };
|
||||
let uid = format!("evt-{master:06}@oxicloud.bench");
|
||||
let start = base + chrono::Duration::seconds((i as i64) * 137);
|
||||
let recurrence: Option<DateTime<Utc>> = exception.then_some(start);
|
||||
sqlx::query(
|
||||
"INSERT INTO caldav.calendar_events
|
||||
(id, calendar_id, summary, start_time, end_time, all_day,
|
||||
rrule, ical_uid, ical_data, recurrence_id)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, false, $5, $6, $7, $8)",
|
||||
)
|
||||
.bind(calendar_id)
|
||||
.bind(format!("Reunión {i}"))
|
||||
.bind(start)
|
||||
.bind(start + chrono::Duration::minutes(45))
|
||||
.bind((!exception).then_some("FREQ=WEEKLY;BYDAY=WE"))
|
||||
.bind(&uid)
|
||||
.bind(vevent_body(&uid, start, exception))
|
||||
.bind(recurrence)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("seed event");
|
||||
}
|
||||
tx.commit().await.expect("commit");
|
||||
Seeded {
|
||||
calendar_id,
|
||||
owner_id,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup(pool: &PgPool, s: &Seeded) {
|
||||
let _ = sqlx::query("DELETE FROM caldav.calendar_events WHERE calendar_id = $1")
|
||||
.bind(s.calendar_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM caldav.calendars WHERE id = $1")
|
||||
.bind(s.calendar_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1")
|
||||
.bind(s.owner_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
// ─── Pipelines ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn report_shape() -> CalDavReportType {
|
||||
CalDavReportType::CalendarQuery {
|
||||
props: vec![],
|
||||
time_range: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// BEFORE: the buffered pipeline — full entity fetch → full DTO Vec →
|
||||
/// one whole-response buffer. Returns (ttfb_ms, wall_ms, bytes).
|
||||
async fn buffered_report(
|
||||
repo: &CalendarEventPgRepository,
|
||||
calendar_id: &Uuid,
|
||||
base_href: &str,
|
||||
) -> (f64, f64, Vec<u8>) {
|
||||
let t0 = Instant::now();
|
||||
let events: Vec<CalendarEventDto> = repo
|
||||
.list_events_by_calendar(calendar_id)
|
||||
.await
|
||||
.expect("list events")
|
||||
.into_iter()
|
||||
.map(CalendarEventDto::from)
|
||||
.collect();
|
||||
let mut out = Vec::with_capacity(events.len() * 1024);
|
||||
CalDavAdapter::generate_calendar_events_response(&mut out, &events, &report_shape(), base_href)
|
||||
.expect("generate");
|
||||
let wall = t0.elapsed().as_secs_f64() * 1e3;
|
||||
// Buffered: the first byte is only available when everything is.
|
||||
(wall, wall, out)
|
||||
}
|
||||
|
||||
/// AFTER: the streaming pipeline — uid-keyset pages, per-page hydration,
|
||||
/// header/page/footer chunks (the handler's loop over the same public
|
||||
/// pieces). Returns (ttfb_ms, wall_ms, concatenated bytes).
|
||||
async fn streamed_report(
|
||||
repo: &CalendarEventPgRepository,
|
||||
calendar_id: &Uuid,
|
||||
base_href: &str,
|
||||
page_uids: usize,
|
||||
) -> (f64, f64, Vec<u8>) {
|
||||
let t0 = Instant::now();
|
||||
let mut ttfb = None;
|
||||
let mut all = Vec::new();
|
||||
let report = report_shape();
|
||||
|
||||
let mut chunk = Vec::with_capacity(256);
|
||||
{
|
||||
let mut w = quick_xml::Writer::new(&mut chunk);
|
||||
CalDavAdapter::write_caldav_multistatus_start(&mut w).expect("start");
|
||||
}
|
||||
all.extend_from_slice(&chunk);
|
||||
|
||||
{
|
||||
use futures::TryStreamExt;
|
||||
let mut rows = repo.stream_events_uid_order(*calendar_id);
|
||||
let mut page: Vec<CalendarEventDto> = Vec::with_capacity(page_uids + 32);
|
||||
loop {
|
||||
let next = rows
|
||||
.try_next()
|
||||
.await
|
||||
.expect("stream row")
|
||||
.map(CalendarEventDto::from);
|
||||
let flush = match &next {
|
||||
Some(ev) => {
|
||||
page.len() >= page_uids
|
||||
&& page.last().is_some_and(|p| p.ical_uid != ev.ical_uid)
|
||||
}
|
||||
None => !page.is_empty(),
|
||||
};
|
||||
if flush {
|
||||
let mut chunk = Vec::with_capacity(page.len() * 1024 + 128);
|
||||
{
|
||||
let mut w = quick_xml::Writer::new(&mut chunk);
|
||||
CalDavAdapter::write_report_page(&mut w, &page, &report, base_href)
|
||||
.expect("page");
|
||||
}
|
||||
if ttfb.is_none() && !all.is_empty() {
|
||||
// header already emitted; first data page complete
|
||||
}
|
||||
page.clear();
|
||||
all.extend_from_slice(&chunk);
|
||||
ttfb.get_or_insert_with(|| t0.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
match next {
|
||||
Some(ev) => page.push(ev),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut chunk = Vec::with_capacity(32);
|
||||
{
|
||||
let mut w = quick_xml::Writer::new(&mut chunk);
|
||||
CalDavAdapter::write_caldav_multistatus_end(&mut w).expect("end");
|
||||
}
|
||||
all.extend_from_slice(&chunk);
|
||||
(
|
||||
ttfb.unwrap_or(f64::NAN),
|
||||
t0.elapsed().as_secs_f64() * 1e3,
|
||||
all,
|
||||
)
|
||||
}
|
||||
|
||||
/// TTFB for the streaming path measured honestly: time until the FIRST
|
||||
/// PAGE chunk (header + one hydrated page) exists — the moment real
|
||||
/// bytes could hit the socket.
|
||||
async fn streamed_report_ttfb(
|
||||
repo: &CalendarEventPgRepository,
|
||||
calendar_id: &Uuid,
|
||||
base_href: &str,
|
||||
page_uids: usize,
|
||||
) -> f64 {
|
||||
use futures::TryStreamExt;
|
||||
let t0 = Instant::now();
|
||||
let mut rows = repo.stream_events_uid_order(*calendar_id);
|
||||
let mut page: Vec<CalendarEventDto> = Vec::with_capacity(page_uids + 32);
|
||||
while let Some(ev) = rows.try_next().await.expect("stream row") {
|
||||
let ev = CalendarEventDto::from(ev);
|
||||
if page.len() >= page_uids && page.last().is_some_and(|p| p.ical_uid != ev.ical_uid) {
|
||||
break;
|
||||
}
|
||||
page.push(ev);
|
||||
}
|
||||
let mut chunk = Vec::with_capacity(page.len() * 1024 + 256);
|
||||
{
|
||||
let mut w = quick_xml::Writer::new(&mut chunk);
|
||||
CalDavAdapter::write_caldav_multistatus_start(&mut w).expect("start");
|
||||
CalDavAdapter::write_report_page(&mut w, &page, &report_shape(), base_href).expect("page");
|
||||
}
|
||||
std::hint::black_box(&chunk);
|
||||
t0.elapsed().as_secs_f64() * 1e3
|
||||
}
|
||||
|
||||
fn p50(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
fn reset_peak() {
|
||||
PEAK.store(LIVE.load(Ordering::Relaxed), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn peak_mib() -> f64 {
|
||||
PEAK.load(Ordering::Relaxed) as f64 / (1024.0 * 1024.0)
|
||||
}
|
||||
|
||||
#[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 n: usize = env::var("BENCH_EVENTS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(4000);
|
||||
let page_uids: usize = env::var("BENCH_PAGE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(500);
|
||||
let passes: usize = env::var("BENCH_PASSES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(9);
|
||||
|
||||
let pool = Arc::new(
|
||||
PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.min_connections(10)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect Postgres"),
|
||||
);
|
||||
|
||||
let seeded = seed(&pool, n).await;
|
||||
let repo = CalendarEventPgRepository::new(pool.clone());
|
||||
let base_href = format!("/caldav/{}/", seeded.calendar_id);
|
||||
|
||||
println!(
|
||||
"bench_caldav_stream — {n} events (20% exceptions), page={page_uids} uids, {passes} passes\n"
|
||||
);
|
||||
|
||||
// ── [1] REPORT (multistatus) ────────────────────────────────────────────
|
||||
// Warm-up + equivalence gate first.
|
||||
let (_, _, before_bytes) = buffered_report(&repo, &seeded.calendar_id, &base_href).await;
|
||||
let (_, _, after_bytes) =
|
||||
streamed_report(&repo, &seeded.calendar_id, &base_href, page_uids).await;
|
||||
let gate_report = before_bytes == after_bytes;
|
||||
|
||||
let mut b_wall = Vec::new();
|
||||
let mut a_wall = Vec::new();
|
||||
let mut a_ttfb = Vec::new();
|
||||
for _ in 0..passes {
|
||||
let (_, w, out) = buffered_report(&repo, &seeded.calendar_id, &base_href).await;
|
||||
std::hint::black_box(out);
|
||||
b_wall.push(w);
|
||||
let (_, w, out) = streamed_report(&repo, &seeded.calendar_id, &base_href, page_uids).await;
|
||||
std::hint::black_box(out);
|
||||
a_wall.push(w);
|
||||
a_ttfb.push(streamed_report_ttfb(&repo, &seeded.calendar_id, &base_href, page_uids).await);
|
||||
}
|
||||
// Peak-heap arms, measured in isolation.
|
||||
reset_peak();
|
||||
let (_, _, out) = buffered_report(&repo, &seeded.calendar_id, &base_href).await;
|
||||
drop(out);
|
||||
let peak_before = peak_mib();
|
||||
reset_peak();
|
||||
// Streamed peak: emulate the socket by dropping each chunk — reuse
|
||||
// the pipeline but without accumulating (accumulation would charge
|
||||
// the response size to the streaming arm).
|
||||
{
|
||||
use futures::TryStreamExt;
|
||||
let t0 = Instant::now();
|
||||
let report = report_shape();
|
||||
let mut rows = repo.stream_events_uid_order(seeded.calendar_id);
|
||||
let mut page: Vec<CalendarEventDto> = Vec::with_capacity(page_uids + 32);
|
||||
loop {
|
||||
let next = rows
|
||||
.try_next()
|
||||
.await
|
||||
.expect("stream row")
|
||||
.map(CalendarEventDto::from);
|
||||
let flush = match &next {
|
||||
Some(ev) => {
|
||||
page.len() >= page_uids
|
||||
&& page.last().is_some_and(|p| p.ical_uid != ev.ical_uid)
|
||||
}
|
||||
None => !page.is_empty(),
|
||||
};
|
||||
if flush {
|
||||
let mut chunk = Vec::with_capacity(page.len() * 1024 + 128);
|
||||
{
|
||||
let mut w = quick_xml::Writer::new(&mut chunk);
|
||||
CalDavAdapter::write_report_page(&mut w, &page, &report, &base_href)
|
||||
.expect("page");
|
||||
}
|
||||
std::hint::black_box(&chunk);
|
||||
page.clear();
|
||||
}
|
||||
match next {
|
||||
Some(ev) => page.push(ev),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
std::hint::black_box(t0.elapsed());
|
||||
}
|
||||
let peak_after = peak_mib();
|
||||
|
||||
let bw = p50(b_wall);
|
||||
let aw = p50(a_wall);
|
||||
let at = p50(a_ttfb);
|
||||
println!("[1] REPORT calendar-query (no range) TTFB ms wall ms peak heap MiB");
|
||||
println!(" BEFORE (buffered) {bw:8.1} {bw:8.1} {peak_before:10.1}");
|
||||
println!(
|
||||
" AFTER (streamed) {at:8.1} {aw:8.1} {peak_after:10.1} TTFB {:.1}x, heap {:.1}x lower",
|
||||
bw / at,
|
||||
peak_before / peak_after
|
||||
);
|
||||
|
||||
// ── [2] Collection GET (.ics) ───────────────────────────────────────────
|
||||
let events_all: Vec<CalendarEventDto> = repo
|
||||
.list_events_by_calendar(&seeded.calendar_id)
|
||||
.await
|
||||
.expect("list")
|
||||
.into_iter()
|
||||
.map(CalendarEventDto::from)
|
||||
.collect();
|
||||
let before_ics = before::generate_full_calendar_ical("Agenda grande", &events_all);
|
||||
drop(events_all);
|
||||
// Streamed ICS: header + per-page chunks + footer (the handler loop).
|
||||
let mut after_ics = String::from(
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\nX-WR-CALNAME:Agenda grande\r\n",
|
||||
);
|
||||
let ics_pages: Vec<Vec<CalendarEventDto>> = {
|
||||
use futures::TryStreamExt;
|
||||
let mut rows = repo.stream_events_uid_order(seeded.calendar_id);
|
||||
let mut pages = Vec::new();
|
||||
let mut page: Vec<CalendarEventDto> = Vec::with_capacity(page_uids + 32);
|
||||
while let Some(ev) = rows.try_next().await.expect("stream row") {
|
||||
let ev = CalendarEventDto::from(ev);
|
||||
if page.len() >= page_uids && page.last().is_some_and(|p| p.ical_uid != ev.ical_uid) {
|
||||
pages.push(std::mem::take(&mut page));
|
||||
}
|
||||
page.push(ev);
|
||||
}
|
||||
if !page.is_empty() {
|
||||
pages.push(page);
|
||||
}
|
||||
pages
|
||||
};
|
||||
for events in &ics_pages {
|
||||
let events = &events[..];
|
||||
let mut chunk = String::with_capacity(events.len() * 384);
|
||||
for group in caldav_bench::group_events_by_uid(events) {
|
||||
for event in group {
|
||||
if let Some(vevent) = caldav_bench::extract_vevent_chunk(&event.ical_data) {
|
||||
chunk.push_str(vevent);
|
||||
if !chunk.ends_with('\n') {
|
||||
chunk.push_str("\r\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
after_ics.push_str(&chunk);
|
||||
}
|
||||
after_ics.push_str("END:VCALENDAR\r\n");
|
||||
let gate_ics = before_ics == after_ics;
|
||||
println!(
|
||||
"[2] collection GET .ics: {} bytes, streamed == buffered: {}",
|
||||
before_ics.len(),
|
||||
if gate_ics { "OK" } else { "MISMATCH" }
|
||||
);
|
||||
|
||||
cleanup(&pool, &seeded).await;
|
||||
|
||||
println!(
|
||||
"\n[gate] multistatus byte-identical: {} · ICS byte-identical: {}",
|
||||
if gate_report { "OK" } else { "FAILED" },
|
||||
if gate_ics { "OK" } else { "FAILED" }
|
||||
);
|
||||
if !gate_report || !gate_ics {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -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<T: std::str::FromStr>(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<u8> {
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
//! CardDAV REPORT generation benchmark — dead double vCard generation +
|
||||
//! O(N²) uid scan (BEFORE) vs single on-demand generation (AFTER).
|
||||
//!
|
||||
//! The old `handle_report` flow pre-generated a vCard for EVERY contact into a
|
||||
//! `Vec<(uid, vcard)>`, then `generate_contacts_response` did a linear
|
||||
//! `find(|(uid, _)| *uid == contact.uid)` per contact — O(N²) string compares
|
||||
//! — and *discarded* the result (`let _ = vcard`), because
|
||||
//! `write_contact_response` regenerates the vCard on demand anyway. The fix
|
||||
//! deletes the pre-generation and the scan, and converts `contact_to_vcard`
|
||||
//! from `push_str(&format!(…))` (one temp String per line) to
|
||||
//! `write!(&mut String, …)`.
|
||||
//!
|
||||
//! `mod before` below is a verbatim copy of the OLD code (old
|
||||
//! `contact_to_vcard`, old `generate_contacts_response` with the `vcards`
|
||||
//! parameter, and the then-current `write_contact_response`), so one binary
|
||||
//! measures both variants and byte-compares their output.
|
||||
//!
|
||||
//! Equivalence gate: BEFORE and AFTER XML must be byte-identical for every
|
||||
//! (N, prop-set) combination, and the old/new `contact_to_vcard` must agree
|
||||
//! byte-for-byte on every synthetic contact. Any mismatch exits 1 with the
|
||||
//! first differing offset.
|
||||
//!
|
||||
//! Run (no Postgres needed):
|
||||
//! cargo run --release --features bench --example bench_carddav_report
|
||||
//! Tunables (env):
|
||||
//! BENCH_REPS (5) median reported
|
||||
|
||||
use std::env;
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{NaiveDate, TimeZone, Utc};
|
||||
use oxicloud::application::adapters::carddav_adapter::{
|
||||
CardDavAdapter, CardDavReportType, contact_to_vcard,
|
||||
};
|
||||
use oxicloud::application::adapters::webdav_adapter::QualifiedName;
|
||||
use oxicloud::application::dtos::contact_dto::{AddressDto, ContactDto, EmailDto, PhoneDto};
|
||||
|
||||
/// Verbatim copy of the pre-fix production code (handler + adapter side),
|
||||
/// kept here so the benchmark measures the real OLD flow, not a caricature.
|
||||
mod before {
|
||||
use std::io::Write;
|
||||
|
||||
use oxicloud::application::adapters::carddav_adapter::CardDavReportType;
|
||||
use oxicloud::application::adapters::webdav_adapter::QualifiedName;
|
||||
use oxicloud::application::dtos::contact_dto::ContactDto;
|
||||
use quick_xml::Writer;
|
||||
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
|
||||
|
||||
/// OLD `generate_contacts_response` — takes the pre-generated `vcards`,
|
||||
/// does the O(N²) linear uid scan per contact, then throws the hit away.
|
||||
pub fn generate_contacts_response<W: Write>(
|
||||
writer: W,
|
||||
contacts: &[ContactDto],
|
||||
vcards: &[(String, String)], // (uid, vcard_data)
|
||||
report: &CardDavReportType,
|
||||
base_href: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let mut xml_writer = Writer::new(writer);
|
||||
|
||||
xml_writer.write_event(Event::Start(
|
||||
BytesStart::new("D:multistatus").with_attributes([
|
||||
("xmlns:D", "DAV:"),
|
||||
("xmlns:CR", "urn:ietf:params:xml:ns:carddav"),
|
||||
]),
|
||||
))?;
|
||||
|
||||
let props = match report {
|
||||
CardDavReportType::AddressbookQuery { props } => props.clone(),
|
||||
CardDavReportType::AddressbookMultiget { props, .. } => props.clone(),
|
||||
CardDavReportType::SyncCollection { props, .. } => props.clone(),
|
||||
};
|
||||
|
||||
for contact in contacts {
|
||||
let href = format!("{}{}.vcf", base_href, contact.uid);
|
||||
let vcard = vcards
|
||||
.iter()
|
||||
.find(|(uid, _)| *uid == contact.uid)
|
||||
.map(|(_, data)| data.as_str())
|
||||
.unwrap_or("");
|
||||
write_contact_response(&mut xml_writer, contact, &props, &href)?;
|
||||
// If address-data is requested, include vcard
|
||||
if props.iter().any(|p| p.name == "address-data") || props.is_empty() {
|
||||
// Already handled in write_contact_response
|
||||
}
|
||||
let _ = vcard; // suppress warning - used via contact_to_vcard fallback
|
||||
}
|
||||
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy of the (unchanged) private `write_contact_response`, wired to the
|
||||
/// OLD `contact_to_vcard` so the BEFORE variant is fully self-contained.
|
||||
fn write_contact_response<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
contact: &ContactDto,
|
||||
props: &[QualifiedName],
|
||||
href: &str,
|
||||
) -> std::io::Result<()> {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(href)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
|
||||
if props.is_empty() {
|
||||
// Return standard properties
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"\"{}\"",
|
||||
contact.etag
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("text/vcard; charset=utf-8")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||
|
||||
// Include vCard data
|
||||
let vcard = contact_to_vcard(contact);
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("CR:address-data")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&vcard)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("CR:address-data")))?;
|
||||
} else {
|
||||
for prop in props {
|
||||
match (prop.namespace.as_str(), prop.name.as_str()) {
|
||||
("DAV:", "resourcetype") => {
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||
}
|
||||
("DAV:", "getetag") => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"\"{}\"",
|
||||
contact.etag
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
}
|
||||
("DAV:", "getcontenttype") => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(
|
||||
"text/vcard; charset=utf-8",
|
||||
)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||
}
|
||||
("DAV:", "getlastmodified") => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(
|
||||
&contact.updated_at.to_rfc2822(),
|
||||
)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
}
|
||||
("urn:ietf:params:xml:ns:carddav", "address-data") => {
|
||||
let vcard = contact_to_vcard(contact);
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("CR:address-data")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&vcard)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("CR:address-data")))?;
|
||||
}
|
||||
_ => {
|
||||
let prop_name = if prop.namespace == "urn:ietf:params:xml:ns:carddav" {
|
||||
format!("CR:{}", prop.name)
|
||||
} else if prop.namespace == "DAV:" {
|
||||
format!("D:{}", prop.name)
|
||||
} else {
|
||||
prop.name.clone()
|
||||
};
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
|
||||
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OLD `contact_to_vcard` — one `push_str(&format!(…))` temp String per line.
|
||||
pub fn contact_to_vcard(contact: &ContactDto) -> String {
|
||||
let mut vcard = String::from("BEGIN:VCARD\r\nVERSION:3.0\r\n");
|
||||
|
||||
vcard.push_str(&format!("UID:{}\r\n", contact.uid));
|
||||
|
||||
if let (Some(last), Some(first)) = (&contact.last_name, &contact.first_name) {
|
||||
vcard.push_str(&format!("N:{};{};;;\r\n", last, first));
|
||||
} else if let Some(last) = &contact.last_name {
|
||||
vcard.push_str(&format!("N:{};;;;\r\n", last));
|
||||
} else if let Some(first) = &contact.first_name {
|
||||
vcard.push_str(&format!("N:;{};;;\r\n", first));
|
||||
}
|
||||
|
||||
if let Some(fn_name) = &contact.full_name {
|
||||
vcard.push_str(&format!("FN:{}\r\n", fn_name));
|
||||
} else {
|
||||
// FN is mandatory in vCard 3.0
|
||||
let fn_name = format!(
|
||||
"{} {}",
|
||||
contact.first_name.as_deref().unwrap_or(""),
|
||||
contact.last_name.as_deref().unwrap_or(""),
|
||||
)
|
||||
.trim()
|
||||
.to_string();
|
||||
if !fn_name.is_empty() {
|
||||
vcard.push_str(&format!("FN:{}\r\n", fn_name));
|
||||
} else {
|
||||
vcard.push_str("FN:Unknown\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(nickname) = &contact.nickname {
|
||||
vcard.push_str(&format!("NICKNAME:{}\r\n", nickname));
|
||||
}
|
||||
|
||||
for email in &contact.email {
|
||||
vcard.push_str(&format!(
|
||||
"EMAIL;TYPE={}:{}\r\n",
|
||||
email.r#type.to_uppercase(),
|
||||
email.email
|
||||
));
|
||||
}
|
||||
|
||||
for phone in &contact.phone {
|
||||
vcard.push_str(&format!(
|
||||
"TEL;TYPE={}:{}\r\n",
|
||||
phone.r#type.to_uppercase(),
|
||||
phone.number
|
||||
));
|
||||
}
|
||||
|
||||
for addr in &contact.address {
|
||||
let adr = format!(
|
||||
";;{};{};{};{};{}",
|
||||
addr.street.as_deref().unwrap_or(""),
|
||||
addr.city.as_deref().unwrap_or(""),
|
||||
addr.state.as_deref().unwrap_or(""),
|
||||
addr.postal_code.as_deref().unwrap_or(""),
|
||||
addr.country.as_deref().unwrap_or(""),
|
||||
);
|
||||
vcard.push_str(&format!(
|
||||
"ADR;TYPE={}:{}\r\n",
|
||||
addr.r#type.to_uppercase(),
|
||||
adr
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(org) = &contact.organization {
|
||||
vcard.push_str(&format!("ORG:{}\r\n", org));
|
||||
}
|
||||
if let Some(title) = &contact.title {
|
||||
vcard.push_str(&format!("TITLE:{}\r\n", title));
|
||||
}
|
||||
if let Some(notes) = &contact.notes {
|
||||
vcard.push_str(&format!("NOTE:{}\r\n", notes.replace('\n', "\\n")));
|
||||
}
|
||||
if let Some(bday) = &contact.birthday {
|
||||
vcard.push_str(&format!("BDAY:{}\r\n", bday.format("%Y-%m-%d")));
|
||||
}
|
||||
if let Some(photo) = &contact.photo_url {
|
||||
vcard.push_str(&format!("PHOTO;VALUE=URI:{}\r\n", photo));
|
||||
}
|
||||
|
||||
vcard.push_str(&format!(
|
||||
"REV:{}\r\n",
|
||||
contact.updated_at.format("%Y%m%dT%H%M%SZ")
|
||||
));
|
||||
vcard.push_str("END:VCARD\r\n");
|
||||
|
||||
vcard
|
||||
}
|
||||
}
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Deterministic synthetic address book: every contact has 2 emails, 1 phone
|
||||
/// and 1 address; optional fields (nickname, notes-with-newline, birthday,
|
||||
/// photo, missing names → FN fallback) are cycled so the byte-equality gate
|
||||
/// exercises every `contact_to_vcard` branch, not just the happy path.
|
||||
fn make_contacts(n: usize) -> Vec<ContactDto> {
|
||||
let created = Utc.with_ymd_and_hms(2026, 1, 15, 9, 0, 0).unwrap();
|
||||
let updated = Utc.with_ymd_and_hms(2026, 6, 30, 18, 45, 12).unwrap();
|
||||
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let (full_name, first_name, last_name) = match i % 5 {
|
||||
0 => (
|
||||
Some(format!("Contact {i:05} Example")),
|
||||
Some(format!("Contact{i:05}")),
|
||||
Some("Example".to_string()),
|
||||
),
|
||||
1 => (
|
||||
None,
|
||||
Some(format!("Contact{i:05}")),
|
||||
Some("Example".to_string()),
|
||||
),
|
||||
2 => (None, None, Some("Example".to_string())),
|
||||
3 => (None, Some(format!("Contact{i:05}")), None),
|
||||
_ => (None, None, None), // FN:Unknown fallback
|
||||
};
|
||||
ContactDto {
|
||||
id: format!("id-{i:05}"),
|
||||
address_book_id: "bench-book".to_string(),
|
||||
uid: format!("bench-contact-{i:05}@oxicloud"),
|
||||
full_name,
|
||||
first_name,
|
||||
last_name,
|
||||
nickname: (i % 7 == 0).then(|| format!("nick{i}")),
|
||||
email: vec![
|
||||
EmailDto {
|
||||
email: format!("contact{i:05}@example.com"),
|
||||
r#type: "work".to_string(),
|
||||
is_primary: true,
|
||||
},
|
||||
EmailDto {
|
||||
email: format!("contact{i:05}@home.example.org"),
|
||||
r#type: "home".to_string(),
|
||||
is_primary: false,
|
||||
},
|
||||
],
|
||||
phone: vec![PhoneDto {
|
||||
number: format!("+1-555-{:04}", i % 10_000),
|
||||
r#type: "cell".to_string(),
|
||||
is_primary: true,
|
||||
}],
|
||||
address: vec![AddressDto {
|
||||
street: Some(format!("{} Main Street", i + 1)),
|
||||
city: Some("Springfield".to_string()),
|
||||
state: Some("IL".to_string()),
|
||||
postal_code: Some(format!("{:05}", 60_000 + (i % 1_000))),
|
||||
country: Some("USA".to_string()),
|
||||
r#type: "home".to_string(),
|
||||
is_primary: true,
|
||||
}],
|
||||
organization: Some("OxiCloud Benchmarks Inc.".to_string()),
|
||||
title: Some("Engineer".to_string()),
|
||||
notes: (i % 11 == 0).then(|| "line one\nline two & <specials>".to_string()),
|
||||
photo_url: (i % 13 == 0).then(|| format!("https://example.com/avatars/{i}.jpg")),
|
||||
birthday: (i % 3 == 0).then(|| NaiveDate::from_ymd_opt(1990, 5, 17).unwrap()),
|
||||
anniversary: None,
|
||||
created_at: created,
|
||||
updated_at: updated,
|
||||
etag: format!("etag-{i:05}"),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn dav(name: &str) -> QualifiedName {
|
||||
QualifiedName {
|
||||
namespace: "DAV:".to_string(),
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn carddav(name: &str) -> QualifiedName {
|
||||
QualifiedName {
|
||||
namespace: "urn:ietf:params:xml:ns:carddav".to_string(),
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// OLD handler flow: pre-generate a vCard per contact, then generate the XML
|
||||
/// (which re-generates every vCard on demand and never reads the pre-made ones).
|
||||
fn run_before(contacts: &[ContactDto], report: &CardDavReportType, base_href: &str) -> Vec<u8> {
|
||||
// Generate vCards (verbatim old handle_report pre-generation)
|
||||
let vcards: Vec<(String, String)> = contacts
|
||||
.iter()
|
||||
.map(|c| (c.uid.clone(), before::contact_to_vcard(c)))
|
||||
.collect();
|
||||
|
||||
let mut out = Vec::new();
|
||||
before::generate_contacts_response(&mut out, contacts, &vcards, report, base_href)
|
||||
.expect("BEFORE XML generation failed");
|
||||
out
|
||||
}
|
||||
|
||||
/// NEW production path.
|
||||
fn run_after(contacts: &[ContactDto], report: &CardDavReportType, base_href: &str) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
CardDavAdapter::generate_contacts_response(&mut out, contacts, report, base_href)
|
||||
.expect("AFTER XML generation failed");
|
||||
out
|
||||
}
|
||||
|
||||
fn median(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
fn first_diff(a: &[u8], b: &[u8]) -> Option<usize> {
|
||||
if a == b {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.position(|(x, y)| x != y)
|
||||
.unwrap_or_else(|| a.len().min(b.len())),
|
||||
)
|
||||
}
|
||||
|
||||
fn context_snippet(bytes: &[u8], at: usize) -> String {
|
||||
let start = at.saturating_sub(40);
|
||||
let end = (at + 40).min(bytes.len());
|
||||
String::from_utf8_lossy(&bytes[start..end]).into_owned()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let reps: usize = env_or("BENCH_REPS", 5);
|
||||
let base_href = "/carddav/bench-book/";
|
||||
|
||||
let prop_sets: Vec<(&str, Vec<QualifiedName>)> = vec![
|
||||
("getetag", vec![dav("getetag")]),
|
||||
(
|
||||
"getetag + address-data",
|
||||
vec![dav("getetag"), carddav("address-data")],
|
||||
),
|
||||
// Not part of the timing table, but gated too: the empty-props
|
||||
// default path also embeds address-data.
|
||||
("(empty = allprop default)", vec![]),
|
||||
];
|
||||
let sizes = [500usize, 5_000];
|
||||
|
||||
// ── Equivalence gate ────────────────────────────────────────────────
|
||||
let gate_contacts = make_contacts(*sizes.iter().max().unwrap());
|
||||
for c in &gate_contacts {
|
||||
let old = before::contact_to_vcard(c);
|
||||
let new = contact_to_vcard(c);
|
||||
if old != new {
|
||||
let at = first_diff(old.as_bytes(), new.as_bytes()).unwrap();
|
||||
eprintln!(
|
||||
"EQUIVALENCE FAILURE: contact_to_vcard differs for uid={} at byte {}\n old: …{}…\n new: …{}…",
|
||||
c.uid,
|
||||
at,
|
||||
context_snippet(old.as_bytes(), at),
|
||||
context_snippet(new.as_bytes(), at),
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
for &n in &sizes {
|
||||
let contacts = &gate_contacts[..n];
|
||||
for (label, props) in &prop_sets {
|
||||
let report = CardDavReportType::AddressbookQuery {
|
||||
props: props.clone(),
|
||||
};
|
||||
let old_xml = run_before(contacts, &report, base_href);
|
||||
let new_xml = run_after(contacts, &report, base_href);
|
||||
if let Some(at) = first_diff(&old_xml, &new_xml) {
|
||||
eprintln!(
|
||||
"EQUIVALENCE FAILURE: REPORT XML differs (N={}, props={}) at byte {} (before {} B, after {} B)\n before: …{}…\n after: …{}…",
|
||||
n,
|
||||
label,
|
||||
at,
|
||||
old_xml.len(),
|
||||
new_xml.len(),
|
||||
context_snippet(&old_xml, at),
|
||||
context_snippet(&new_xml, at),
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"equivalence gate: BEFORE == AFTER byte-identical for all prop sets at N = {:?} (and all {} vCards match)\n",
|
||||
sizes,
|
||||
gate_contacts.len()
|
||||
);
|
||||
|
||||
// ── Timing ──────────────────────────────────────────────────────────
|
||||
println!("| N | props | BEFORE ms | AFTER ms | speedup |");
|
||||
println!("|------:|------------------------|----------:|---------:|--------:|");
|
||||
for &n in &sizes {
|
||||
let contacts = &gate_contacts[..n];
|
||||
for (label, props) in prop_sets.iter().take(2) {
|
||||
let report = CardDavReportType::AddressbookQuery {
|
||||
props: props.clone(),
|
||||
};
|
||||
|
||||
// Warm-up (allocator, caches) — result discarded.
|
||||
let _ = run_before(contacts, &report, base_href);
|
||||
let _ = run_after(contacts, &report, base_href);
|
||||
|
||||
let mut before_ms = Vec::with_capacity(reps);
|
||||
let mut after_ms = Vec::with_capacity(reps);
|
||||
for _ in 0..reps {
|
||||
let t0 = Instant::now();
|
||||
let out = run_before(contacts, &report, base_href);
|
||||
before_ms.push(t0.elapsed().as_secs_f64() * 1_000.0);
|
||||
std::hint::black_box(&out);
|
||||
|
||||
let t1 = Instant::now();
|
||||
let out = run_after(contacts, &report, base_href);
|
||||
after_ms.push(t1.elapsed().as_secs_f64() * 1_000.0);
|
||||
std::hint::black_box(&out);
|
||||
}
|
||||
let b = median(before_ms);
|
||||
let a = median(after_ms);
|
||||
println!(
|
||||
"| {:>5} | {:<22} | {:>9.3} | {:>8.3} | {:>6.2}x |",
|
||||
n,
|
||||
label,
|
||||
b,
|
||||
a,
|
||||
b / a
|
||||
);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\n(median of {} reps; BEFORE includes the old handler's vCard pre-generation loop,",
|
||||
reps
|
||||
);
|
||||
println!(" which the old code then discarded — the O(N²) uid scan dominates at large N)");
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
//! CardDAV whole-book response benchmark — buffered vs cursor streaming
|
||||
//! (ROUND6).
|
||||
//!
|
||||
//! The REPORT path (addressbook-query, sync-collection) and the depth-1
|
||||
//! collection PROPFIND materialised EVERY contact DTO of the book in
|
||||
//! one Vec, then rendered the complete multistatus into a second in-RAM
|
||||
//! buffer — the book resident twice, TTFB = full generation. AFTER
|
||||
//! streams ONE ordered scan (`full_name, first_name, last_name`, the
|
||||
//! buffered listing's order) through a PG cursor and emits fixed-size
|
||||
//! pages (contacts carry no bundling constraint).
|
||||
//!
|
||||
//! Drives the REAL repository + adapter writers both ways at the repo
|
||||
//! layer (authz identical both sides, excluded). Gates: streamed
|
||||
//! concatenation byte-identical to the buffered output for the REPORT
|
||||
//! (getetag poll shape) AND the collection PROPFIND (allprop), seeded
|
||||
//! with strictly distinct names so ordering is deterministic.
|
||||
//!
|
||||
//! Run (needs Postgres up; reads DATABASE_URL from .env):
|
||||
//! cargo run --release --features bench --example bench_carddav_stream
|
||||
//! Tunables (env): BENCH_CONTACTS (8000), BENCH_PAGE (500), BENCH_PASSES (9).
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use oxicloud::application::adapters::carddav_adapter::{CardDavAdapter, CardDavReportType};
|
||||
use oxicloud::application::adapters::webdav_adapter::{
|
||||
PropFindRequest, PropFindType, QualifiedName,
|
||||
};
|
||||
use oxicloud::application::dtos::address_book_dto::AddressBookDto;
|
||||
use oxicloud::application::dtos::contact_dto::ContactDto;
|
||||
use oxicloud::domain::repositories::contact_repository::ContactRepository;
|
||||
use oxicloud::infrastructure::repositories::pg::ContactPgRepository;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ─── Peak-live-heap tracking allocator ──────────────────────────────────────
|
||||
|
||||
static LIVE: AtomicU64 = AtomicU64::new(0);
|
||||
static PEAK: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct PeakAlloc;
|
||||
|
||||
fn bump(sz: u64) {
|
||||
let live = LIVE.fetch_add(sz, Ordering::Relaxed) + sz;
|
||||
PEAK.fetch_max(live, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
unsafe impl GlobalAlloc for PeakAlloc {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
bump(layout.size() as u64);
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
LIVE.fetch_sub(layout.size() as u64, Ordering::Relaxed);
|
||||
unsafe { System.dealloc(ptr, layout) }
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||
if new_size > layout.size() {
|
||||
bump((new_size - layout.size()) as u64);
|
||||
} else {
|
||||
LIVE.fetch_sub((layout.size() - new_size) as u64, Ordering::Relaxed);
|
||||
}
|
||||
unsafe { System.realloc(ptr, layout, new_size) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
bump(layout.size() as u64);
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: PeakAlloc = PeakAlloc;
|
||||
|
||||
struct Seeded {
|
||||
book_id: Uuid,
|
||||
owner_id: Uuid,
|
||||
}
|
||||
|
||||
async fn seed(pool: &PgPool, n: usize) -> Seeded {
|
||||
let owner_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, role)
|
||||
VALUES ('bench_cardstream', 'bench_cardstream@bench.invalid', 'user') RETURNING id",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed user");
|
||||
let book_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO carddav.address_books (id, name, owner_id)
|
||||
VALUES (gen_random_uuid(), 'Libreta grande', $1) RETURNING id",
|
||||
)
|
||||
.bind(owner_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed book");
|
||||
|
||||
let mut tx = pool.begin().await.expect("begin");
|
||||
for i in 0..n {
|
||||
// Strictly distinct full_names keep the listing order (and thus
|
||||
// the byte gate) deterministic. Every production row carries its
|
||||
// full serialized vCard — the payload whose double-residency the
|
||||
// streaming path removes — so the seed does too (~250 B each).
|
||||
let uid = format!("contact-{i:06}");
|
||||
let vcard = format!(
|
||||
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:Persona {i:06}\r\nN:Apellido{i};Nombre{i};;;\r\nEMAIL;TYPE=INTERNET:persona{i}@bench.invalid\r\nTEL;TYPE=CELL:+34 600 {i:06}\r\nORG:OxiCloud Bench\r\nNOTE:Fila sintetica del banco de pruebas CardDAV.\r\nEND:VCARD\r\n"
|
||||
);
|
||||
sqlx::query(
|
||||
"INSERT INTO carddav.contacts
|
||||
(id, address_book_id, uid, full_name, first_name, last_name, vcard, etag)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7)",
|
||||
)
|
||||
.bind(book_id)
|
||||
.bind(&uid)
|
||||
.bind(format!("Persona {i:06}"))
|
||||
.bind(format!("Nombre{i}"))
|
||||
.bind(format!("Apellido{i}"))
|
||||
.bind(&vcard)
|
||||
.bind(format!("{:016x}", (i as u64).wrapping_mul(2_654_435_761)))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("seed contact");
|
||||
}
|
||||
tx.commit().await.expect("commit");
|
||||
Seeded { book_id, owner_id }
|
||||
}
|
||||
|
||||
async fn cleanup(pool: &PgPool, s: &Seeded) {
|
||||
let _ = sqlx::query("DELETE FROM carddav.contacts WHERE address_book_id = $1")
|
||||
.bind(s.book_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM carddav.address_books WHERE id = $1")
|
||||
.bind(s.book_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1")
|
||||
.bind(s.owner_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn report_shape() -> CardDavReportType {
|
||||
CardDavReportType::AddressbookQuery {
|
||||
props: vec![
|
||||
QualifiedName::new("DAV:", "getetag"),
|
||||
QualifiedName::new("DAV:", "getcontenttype"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_all_dtos(repo: &ContactPgRepository, book_id: &Uuid) -> Vec<ContactDto> {
|
||||
repo.get_contacts_by_address_book(book_id)
|
||||
.await
|
||||
.expect("list contacts")
|
||||
.into_iter()
|
||||
.map(ContactDto::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// BEFORE: full fetch + whole-response buffer. First byte exists only
|
||||
/// when everything does.
|
||||
async fn buffered_report(
|
||||
repo: &ContactPgRepository,
|
||||
book_id: &Uuid,
|
||||
base_href: &str,
|
||||
) -> (f64, Vec<u8>) {
|
||||
let t0 = Instant::now();
|
||||
let contacts = fetch_all_dtos(repo, book_id).await;
|
||||
let mut out = Vec::with_capacity(contacts.len() * 256);
|
||||
CardDavAdapter::generate_contacts_response(&mut out, &contacts, &report_shape(), base_href)
|
||||
.expect("generate");
|
||||
(t0.elapsed().as_secs_f64() * 1e3, out)
|
||||
}
|
||||
|
||||
/// AFTER: cursor + page writers (the handler loop over public pieces).
|
||||
/// Returns (ttfb_ms — first data page rendered, wall_ms, bytes).
|
||||
async fn streamed_report(
|
||||
repo: &ContactPgRepository,
|
||||
book_id: &Uuid,
|
||||
base_href: &str,
|
||||
page_rows: usize,
|
||||
accumulate: bool,
|
||||
) -> (f64, f64, Vec<u8>) {
|
||||
use futures::TryStreamExt;
|
||||
let t0 = Instant::now();
|
||||
let mut ttfb = None;
|
||||
let mut all = Vec::new();
|
||||
let report = report_shape();
|
||||
|
||||
let mut chunk = Vec::with_capacity(160);
|
||||
{
|
||||
let mut w = quick_xml::Writer::new(&mut chunk);
|
||||
CardDavAdapter::write_report_multistatus_start(&mut w).expect("start");
|
||||
}
|
||||
if accumulate {
|
||||
all.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
let mut rows = repo.stream_contacts_by_book(*book_id);
|
||||
let mut page: Vec<ContactDto> = Vec::with_capacity(page_rows);
|
||||
loop {
|
||||
let next = rows
|
||||
.try_next()
|
||||
.await
|
||||
.expect("stream row")
|
||||
.map(ContactDto::from);
|
||||
let flush = match &next {
|
||||
Some(_) => page.len() >= page_rows,
|
||||
None => !page.is_empty(),
|
||||
};
|
||||
if flush {
|
||||
let mut chunk = Vec::with_capacity(page.len() * 256 + 64);
|
||||
{
|
||||
let mut w = quick_xml::Writer::new(&mut chunk);
|
||||
CardDavAdapter::write_contacts_report_page(&mut w, &page, &report, base_href)
|
||||
.expect("page");
|
||||
}
|
||||
ttfb.get_or_insert_with(|| t0.elapsed().as_secs_f64() * 1e3);
|
||||
page.clear();
|
||||
if accumulate {
|
||||
all.extend_from_slice(&chunk);
|
||||
}
|
||||
std::hint::black_box(&chunk);
|
||||
}
|
||||
match next {
|
||||
Some(c) => page.push(c),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
let mut chunk = Vec::with_capacity(32);
|
||||
{
|
||||
let mut w = quick_xml::Writer::new(&mut chunk);
|
||||
CardDavAdapter::write_carddav_multistatus_end(&mut w).expect("end");
|
||||
}
|
||||
if accumulate {
|
||||
all.extend_from_slice(&chunk);
|
||||
}
|
||||
(
|
||||
ttfb.unwrap_or(f64::NAN),
|
||||
t0.elapsed().as_secs_f64() * 1e3,
|
||||
all,
|
||||
)
|
||||
}
|
||||
|
||||
fn p50(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
fn reset_peak() {
|
||||
PEAK.store(LIVE.load(Ordering::Relaxed), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn peak_mib() -> f64 {
|
||||
PEAK.load(Ordering::Relaxed) as f64 / (1024.0 * 1024.0)
|
||||
}
|
||||
|
||||
fn book_dto(seeded: &Seeded) -> AddressBookDto {
|
||||
AddressBookDto {
|
||||
id: seeded.book_id.to_string(),
|
||||
name: "Libreta grande".to_string(),
|
||||
owner_id: seeded.owner_id.to_string(),
|
||||
..AddressBookDto::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[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 n: usize = env::var("BENCH_CONTACTS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(8000);
|
||||
let page_rows: usize = env::var("BENCH_PAGE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(500);
|
||||
let passes: usize = env::var("BENCH_PASSES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(9);
|
||||
|
||||
let pool = Arc::new(
|
||||
PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.min_connections(10)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect Postgres"),
|
||||
);
|
||||
|
||||
let seeded = seed(&pool, n).await;
|
||||
let repo = ContactPgRepository::new(pool.clone());
|
||||
let base_href = format!("/carddav/{}/", seeded.book_id);
|
||||
|
||||
println!("bench_carddav_stream — {n} contacts, page={page_rows}, {passes} passes\n");
|
||||
|
||||
// ── Equivalence gates ───────────────────────────────────────────────────
|
||||
let (_, before_bytes) = buffered_report(&repo, &seeded.book_id, &base_href).await;
|
||||
let (_, _, after_bytes) =
|
||||
streamed_report(&repo, &seeded.book_id, &base_href, page_rows, true).await;
|
||||
let gate_report = before_bytes == after_bytes;
|
||||
|
||||
// Collection PROPFIND (allprop): buffered generator vs head+pages.
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
let book = book_dto(&seeded);
|
||||
let contacts_all = fetch_all_dtos(&repo, &seeded.book_id).await;
|
||||
let mut coll_before = Vec::new();
|
||||
CardDavAdapter::generate_addressbook_collection_propfind(
|
||||
&mut coll_before,
|
||||
&book,
|
||||
&contacts_all,
|
||||
&request,
|
||||
&base_href,
|
||||
"1",
|
||||
)
|
||||
.expect("collection");
|
||||
drop(contacts_all);
|
||||
let coll_after = {
|
||||
use futures::TryStreamExt;
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut w = quick_xml::Writer::new(&mut out);
|
||||
CardDavAdapter::write_collection_head(&mut w, &book, &request, &base_href)
|
||||
.expect("head");
|
||||
}
|
||||
let mut rows = repo.stream_contacts_by_book(seeded.book_id);
|
||||
let mut page: Vec<ContactDto> = Vec::with_capacity(page_rows);
|
||||
loop {
|
||||
let next = rows
|
||||
.try_next()
|
||||
.await
|
||||
.expect("stream row")
|
||||
.map(ContactDto::from);
|
||||
let flush = match &next {
|
||||
Some(_) => page.len() >= page_rows,
|
||||
None => !page.is_empty(),
|
||||
};
|
||||
if flush {
|
||||
let mut w = quick_xml::Writer::new(&mut out);
|
||||
CardDavAdapter::write_collection_contact_page(&mut w, &page, &base_href)
|
||||
.expect("page");
|
||||
page.clear();
|
||||
}
|
||||
match next {
|
||||
Some(c) => page.push(c),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let mut w = quick_xml::Writer::new(&mut out);
|
||||
CardDavAdapter::write_carddav_multistatus_end(&mut w).expect("end");
|
||||
out
|
||||
};
|
||||
let gate_coll = coll_before == coll_after;
|
||||
drop(coll_before);
|
||||
drop(coll_after);
|
||||
|
||||
// ── [1] REPORT timing + peak ────────────────────────────────────────────
|
||||
let mut b_wall = Vec::new();
|
||||
let mut a_wall = Vec::new();
|
||||
let mut a_ttfb = Vec::new();
|
||||
for _ in 0..passes {
|
||||
let (w, out) = buffered_report(&repo, &seeded.book_id, &base_href).await;
|
||||
std::hint::black_box(out);
|
||||
b_wall.push(w);
|
||||
let (t, w, _) = streamed_report(&repo, &seeded.book_id, &base_href, page_rows, false).await;
|
||||
a_ttfb.push(t);
|
||||
a_wall.push(w);
|
||||
}
|
||||
reset_peak();
|
||||
let (_, out) = buffered_report(&repo, &seeded.book_id, &base_href).await;
|
||||
drop(out);
|
||||
let peak_before = peak_mib();
|
||||
reset_peak();
|
||||
let _ = streamed_report(&repo, &seeded.book_id, &base_href, page_rows, false).await;
|
||||
let peak_after = peak_mib();
|
||||
|
||||
let bw = p50(b_wall);
|
||||
let aw = p50(a_wall);
|
||||
let at = p50(a_ttfb);
|
||||
println!("[1] REPORT addressbook-query (getetag) TTFB ms wall ms peak heap MiB");
|
||||
println!(" BEFORE (buffered) {bw:8.1} {bw:8.1} {peak_before:10.1}");
|
||||
println!(
|
||||
" AFTER (cursor stream) {at:8.1} {aw:8.1} {peak_after:10.1} TTFB {:.1}x, heap {:.1}x lower",
|
||||
bw / at,
|
||||
peak_before / peak_after
|
||||
);
|
||||
|
||||
cleanup(&pool, &seeded).await;
|
||||
|
||||
println!(
|
||||
"\n[gate] REPORT byte-identical: {} · collection PROPFIND byte-identical: {}",
|
||||
if gate_report { "OK" } else { "FAILED" },
|
||||
if gate_coll { "OK" } else { "FAILED" }
|
||||
);
|
||||
if !gate_report || !gate_coll {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -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<T: std::str::FromStr>(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.");
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! WebDAV drive-selector resolution benchmark — grants join/request vs moka.
|
||||
//!
|
||||
//! Every native `/webdav/<selector>/…` request (all verbs; MOVE and COPY
|
||||
//! twice) resolved its scope through `lookup_drive_selector` →
|
||||
//! `DriveRepository::list_readable_by`: a role_grants ⋈ drives ⋈ folders
|
||||
//! join with inline transitive-group expansion, GROUP BY + MIN(role) +
|
||||
//! ORDER BY — per request, uncached. The same join also ran per request
|
||||
//! in search, trash listing and the `GET /api/drives` picker.
|
||||
//!
|
||||
//! AFTER wires the per-user `readable_cache` (30 s TTL, single-flight,
|
||||
//! explicit invalidation on every membership/lifecycle mutation) into
|
||||
//! `DrivePgRepository` — this bench drives the REAL repository (cache,
|
||||
//! `try_get_with` and the per-hit `Vec` clone included), not a synthetic
|
||||
//! lookup, against the verbatim BEFORE query.
|
||||
//!
|
||||
//! Run (needs Postgres up; reads DATABASE_URL from .env):
|
||||
//! cargo run --release --features bench --example bench_drive_selector
|
||||
//! Tunables (env): BENCH_POOL (20), BENCH_SECONDS (4), BENCH_CONCURRENCIES ("8,64").
|
||||
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use oxicloud::domain::repositories::drive_repository::DriveRepository;
|
||||
use oxicloud::infrastructure::repositories::pg::DrivePgRepository;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
struct Seeded {
|
||||
user_id: Uuid,
|
||||
}
|
||||
|
||||
/// user → personal drive (default) + two shared drives, each with a
|
||||
/// role_grant for the user — the shape a typical DAV-syncing member of a
|
||||
/// small team resolves on every request.
|
||||
async fn seed(pool: &PgPool) -> Seeded {
|
||||
let mut tx = pool.begin().await.expect("begin");
|
||||
let user_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, role)
|
||||
VALUES ('bench_drivesel', 'bench_drivesel@bench.invalid', 'user')
|
||||
RETURNING id",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed user");
|
||||
|
||||
// (name, kind, default_for_user, role)
|
||||
let drives: [(&str, &str, Option<Uuid>, &str); 3] = [
|
||||
("Personal", "personal", Some(user_id), "owner"),
|
||||
("Equipo Diseño", "shared", None, "editor"),
|
||||
("Archivo 2026", "shared", None, "viewer"),
|
||||
];
|
||||
for (name, kind, default_for, role) in drives {
|
||||
let drive_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.drives (kind, default_for_user) VALUES ($1, $2) RETURNING id",
|
||||
)
|
||||
.bind(kind)
|
||||
.bind(default_for)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed drive");
|
||||
let folder_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.folders (name, path, lpath, drive_id)
|
||||
VALUES ($1, '/' || $1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed folder");
|
||||
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
|
||||
.bind(folder_id)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("stamp root");
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.role_grants
|
||||
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
|
||||
VALUES ('user', $1, 'drive', $2, $3::storage.grant_role, $1)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(drive_id)
|
||||
.bind(role)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("seed grant");
|
||||
}
|
||||
tx.commit().await.expect("commit");
|
||||
Seeded { user_id }
|
||||
}
|
||||
|
||||
async fn cleanup(pool: &PgPool, user_id: Uuid) {
|
||||
// Drives/folders/grants cascade off the user via the grant cleanup
|
||||
// trigger + explicit deletes (drives carry no owner FK).
|
||||
let ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT resource_id FROM storage.role_grants
|
||||
WHERE subject_type = 'user' AND subject_id = $1 AND resource_type = 'drive'",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for id in ids {
|
||||
let _ = sqlx::query(
|
||||
"DELETE FROM storage.role_grants WHERE resource_type='drive' AND resource_id=$1",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let root: Option<Uuid> =
|
||||
sqlx::query_scalar("SELECT root_folder_id FROM storage.drives WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
if let Some(root) = root {
|
||||
let _ = sqlx::query("DELETE FROM storage.folders WHERE id = $1")
|
||||
.bind(root)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The exact production BEFORE — `list_readable_by`'s query, verbatim.
|
||||
async fn one_op_before(pool: &PgPool, user_id: Uuid, queries: &AtomicUsize) -> Vec<(Uuid, String)> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
|
||||
d.quota_bytes, d.used_bytes, d.policies,
|
||||
d.created_at, d.updated_at,
|
||||
f.name AS root_folder_name,
|
||||
MIN(g.role)::text AS caller_role
|
||||
FROM storage.drives d
|
||||
JOIN storage.folders f ON f.id = d.root_folder_id
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
WHERE (
|
||||
(g.subject_type = 'user' AND g.subject_id = $1)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($1)))
|
||||
)
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
GROUP BY d.id, d.kind, d.default_for_user, d.root_folder_id,
|
||||
d.quota_bytes, d.used_bytes, d.policies,
|
||||
d.created_at, d.updated_at, f.name
|
||||
ORDER BY (d.default_for_user IS NULL) ASC,
|
||||
LOWER(f.name) ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("grants join");
|
||||
queries.fetch_add(1, Ordering::Relaxed);
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.get::<Uuid, _>("id"),
|
||||
r.get::<String, _>("root_folder_name"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Stats {
|
||||
rps: f64,
|
||||
p50: f64,
|
||||
p95: f64,
|
||||
p99: f64,
|
||||
}
|
||||
|
||||
fn summarize(mut lats: Vec<f64>, secs: u64) -> Stats {
|
||||
lats.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let n = lats.len();
|
||||
let pct = |p: f64| {
|
||||
if n == 0 {
|
||||
0.0
|
||||
} else {
|
||||
lats[((n as f64 * p) as usize).min(n - 1)]
|
||||
}
|
||||
};
|
||||
Stats {
|
||||
rps: n as f64 / secs as f64,
|
||||
p50: pct(0.50),
|
||||
p95: pct(0.95),
|
||||
p99: pct(0.99),
|
||||
}
|
||||
}
|
||||
|
||||
#[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 pool_size: u32 = env_or("BENCH_POOL", 20);
|
||||
let secs: u64 = env_or("BENCH_SECONDS", 4);
|
||||
let concurrencies: Vec<usize> = env::var("BENCH_CONCURRENCIES")
|
||||
.ok()
|
||||
.map(|s| s.split(',').filter_map(|x| x.trim().parse().ok()).collect())
|
||||
.unwrap_or_else(|| vec![8, 64]);
|
||||
|
||||
let pool = Arc::new(
|
||||
PgPoolOptions::new()
|
||||
.max_connections(pool_size)
|
||||
.min_connections(pool_size)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect Postgres"),
|
||||
);
|
||||
|
||||
let seeded = seed(&pool).await;
|
||||
let user_id = seeded.user_id;
|
||||
|
||||
// AFTER = the real repository with its readable_cache.
|
||||
let repo = Arc::new(DrivePgRepository::new(pool.clone()));
|
||||
|
||||
// ── Equivalence gate: BEFORE rows == repo output (cold), == warm hit ──
|
||||
let gate_q = AtomicUsize::new(0);
|
||||
let before_rows = one_op_before(&pool, user_id, &gate_q).await;
|
||||
let cold: Vec<(Uuid, String)> = repo
|
||||
.list_readable_by(user_id)
|
||||
.await
|
||||
.expect("repo list")
|
||||
.iter()
|
||||
.map(|d| (d.drive.id, d.root_folder_name.clone()))
|
||||
.collect();
|
||||
let warm: Vec<(Uuid, String)> = repo
|
||||
.list_readable_by(user_id)
|
||||
.await
|
||||
.expect("repo list warm")
|
||||
.iter()
|
||||
.map(|d| (d.drive.id, d.root_folder_name.clone()))
|
||||
.collect();
|
||||
if before_rows != cold || cold != warm {
|
||||
eprintln!(
|
||||
"EQUIVALENCE GATE FAILED:\n before={before_rows:?}\n cold={cold:?}\n warm={warm:?}"
|
||||
);
|
||||
cleanup(&pool, user_id).await;
|
||||
std::process::exit(1);
|
||||
}
|
||||
if before_rows.len() != 3 {
|
||||
eprintln!("seed expected 3 readable drives, got {}", before_rows.len());
|
||||
cleanup(&pool, user_id).await;
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!("\n#################################################################");
|
||||
println!("# WebDAV drive-selector: BEFORE (grants join/req) vs AFTER (cache)");
|
||||
println!("# pool={pool_size} window={secs}s/run drives/user=3");
|
||||
println!("#################################################################\n");
|
||||
println!(
|
||||
"| {:>5} | {:<6} | {:>10} | {:>9} | {:>9} | {:>9} | {:>9} |",
|
||||
"conc", "mode", "req/s", "p50 µs", "p95 µs", "p99 µs", "queries"
|
||||
);
|
||||
|
||||
for &conc in &concurrencies {
|
||||
for mode in ["BEFORE", "AFTER"] {
|
||||
let queries = Arc::new(AtomicUsize::new(0));
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..conc {
|
||||
let pool = pool.clone();
|
||||
let repo = repo.clone();
|
||||
let queries = queries.clone();
|
||||
let mode = mode.to_string();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let mut lats = Vec::new();
|
||||
while Instant::now() < deadline {
|
||||
let t = Instant::now();
|
||||
if mode == "BEFORE" {
|
||||
std::hint::black_box(one_op_before(&pool, user_id, &queries).await);
|
||||
} else {
|
||||
let v = repo.list_readable_by(user_id).await.expect("repo list");
|
||||
std::hint::black_box(v);
|
||||
}
|
||||
lats.push(t.elapsed().as_secs_f64() * 1_000_000.0);
|
||||
if mode == "AFTER" {
|
||||
// cache hit is sub-µs; yield so the loop doesn't
|
||||
// monopolise workers and skew the run count.
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
lats
|
||||
}));
|
||||
}
|
||||
let mut all = Vec::new();
|
||||
for h in handles {
|
||||
all.extend(h.await.unwrap());
|
||||
}
|
||||
let s = summarize(all, secs);
|
||||
println!(
|
||||
"| {:>5} | {:<6} | {:>10.0} | {:>9.2} | {:>9.2} | {:>9.2} | {:>9} |",
|
||||
conc,
|
||||
mode,
|
||||
s.rps,
|
||||
s.p50,
|
||||
s.p95,
|
||||
s.p99,
|
||||
queries.load(Ordering::Relaxed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(&pool, user_id).await;
|
||||
println!("\n(BEFORE = the verbatim list_readable_by join per request; AFTER = the");
|
||||
println!(" real DrivePgRepository serving from its per-user readable_cache —");
|
||||
println!(" try_get_with single-flight + per-hit Vec clone included. Equivalence");
|
||||
println!(" gate asserts identical (id, name) sequences: BEFORE == cold == warm.)");
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
//! File/Folder entity → DTO mapping benchmark — per-row allocation churn.
|
||||
//!
|
||||
//! Isolates the variables the DTO-mapping change touches:
|
||||
//!
|
||||
//! • `Arc::<str>::from(&'static str)` for the closed-set display fields
|
||||
//! (icon class, icon special class, category) — always alloc + copy —
|
||||
//! vs interned `Arc<str>` lookups (`intern_display` / `intern_mime`).
|
||||
//! • `File::compute_etag` / `Folder::compute_etag` — `chars().take(16)
|
||||
//! .collect::<String>()` + `format!` (2 allocs) vs one sized buffer.
|
||||
//! • `format_file_size` — two `format!` calls per row vs one buffer.
|
||||
//! • `Folder → FolderDto` — per-getter `.to_string()` clones + a
|
||||
//! double-allocated etag vs `into_parts()` moves.
|
||||
//!
|
||||
//! The OLD mapping logic is copied verbatim into `mod before` so one binary
|
||||
//! reports BEFORE vs AFTER side by side, and an equivalence gate asserts the
|
||||
//! two produce byte-identical DTOs for every row (exit 1 on any diff).
|
||||
//!
|
||||
//! Sections:
|
||||
//! 1. File → FileDto wall time (p50 ns/row over BENCH_PASSES passes)
|
||||
//! 2. Folder → FolderDto wall time (same)
|
||||
//! 3. Alloc calls/row (counting global allocator wrapping System — the
|
||||
//! lib crate sets no global allocator; mimalloc lives in main.rs only,
|
||||
//! which examples do not link)
|
||||
//! 4. Equivalence gate: BEFORE output == AFTER output, field by field
|
||||
//!
|
||||
//! Run (no Postgres needed):
|
||||
//! cargo run --release --features bench --example bench_dto_map
|
||||
//! Tunables (env):
|
||||
//! BENCH_ROWS (10000) BENCH_PASSES (100)
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::env;
|
||||
use std::hint::black_box;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use oxicloud::application::dtos::file_dto::FileDto;
|
||||
use oxicloud::application::dtos::folder_dto::FolderDto;
|
||||
use oxicloud::domain::entities::file::File;
|
||||
use oxicloud::domain::entities::folder::Folder;
|
||||
use oxicloud::domain::services::path_service::StoragePath;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ─── Counting allocator (Section 3) ─────────────────────────────────────────
|
||||
|
||||
static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct CountingAlloc;
|
||||
|
||||
unsafe impl GlobalAlloc for CountingAlloc {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
unsafe { System.dealloc(ptr, layout) }
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.realloc(ptr, layout, new_size) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: CountingAlloc = CountingAlloc;
|
||||
|
||||
// ─── BEFORE: verbatim copy of the pre-optimization mapping logic ────────────
|
||||
|
||||
/// Pre-optimization reference implementation. Copied verbatim from the old
|
||||
/// `From<File> for FileDto` / `From<Folder> for FolderDto` bodies, the old
|
||||
/// `File::compute_etag` / `Folder::compute_etag` formulas and the old
|
||||
/// `format_file_size` — kept byte-for-byte in behaviour so the equivalence
|
||||
/// gate proves the optimized paths change nothing observable.
|
||||
#[allow(clippy::all)]
|
||||
mod before {
|
||||
use std::sync::Arc;
|
||||
|
||||
use oxicloud::application::dtos::display_helpers::{
|
||||
category_for, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
use oxicloud::application::dtos::file_dto::FileDto;
|
||||
use oxicloud::application::dtos::folder_dto::FolderDto;
|
||||
use oxicloud::domain::entities::file::File;
|
||||
use oxicloud::domain::entities::folder::Folder;
|
||||
|
||||
/// Old `File::compute_etag`: intermediate `collect::<String>()` +
|
||||
/// `format!` — 2 allocations for one ~21-char string.
|
||||
fn file_compute_etag(blob_hash: &str, modified_at: u64) -> String {
|
||||
let prefix: String = blob_hash.chars().take(16).collect();
|
||||
format!("{}-{}", prefix, modified_at)
|
||||
}
|
||||
|
||||
/// Old `Folder::compute_etag` (same shape as the file formula).
|
||||
fn folder_compute_etag(id: &str, tree_modified_at: u64) -> String {
|
||||
let prefix: String = id.chars().take(16).collect();
|
||||
format!("{}-{}", prefix, tree_modified_at)
|
||||
}
|
||||
|
||||
/// Old `format_file_size`: two `format!` calls per row.
|
||||
fn format_file_size(bytes: u64) -> String {
|
||||
if bytes == 0 {
|
||||
return "0 Bytes".to_string();
|
||||
}
|
||||
|
||||
const K: f64 = 1024.0;
|
||||
const SIZES: [&str; 5] = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
|
||||
let i = ((bytes as f64).ln() / K.ln()).floor() as usize;
|
||||
let i = i.min(SIZES.len() - 1);
|
||||
|
||||
let value = bytes as f64 / K.powi(i as i32);
|
||||
|
||||
let formatted = format!("{:.2}", value);
|
||||
let formatted = formatted.trim_end_matches('0').trim_end_matches('.');
|
||||
|
||||
format!("{} {}", formatted, SIZES[i])
|
||||
}
|
||||
|
||||
/// Old `From<File> for FileDto` body: `Arc::from(&str)` for the three
|
||||
/// display fields and the mime type (alloc + copy each), 2-alloc etag,
|
||||
/// 2-format size string.
|
||||
pub fn file_to_dto(file: File) -> FileDto {
|
||||
let etag = file_compute_etag(file.content_hash(), file.modified_at());
|
||||
let content_hash = file.content_hash().to_string();
|
||||
|
||||
let parts = file.into_parts();
|
||||
|
||||
let icon_class: Arc<str> = Arc::from(icon_class_for(&parts.name, &parts.mime_type));
|
||||
let icon_special_class: Arc<str> =
|
||||
Arc::from(icon_special_class_for(&parts.name, &parts.mime_type));
|
||||
let category: Arc<str> = Arc::from(category_for(&parts.name, &parts.mime_type));
|
||||
let size_formatted = format_file_size(parts.size);
|
||||
let mime_type: Arc<str> = Arc::from(parts.mime_type.as_str());
|
||||
|
||||
FileDto {
|
||||
id: parts.id,
|
||||
name: parts.name,
|
||||
path: parts.path_string,
|
||||
size: parts.size,
|
||||
mime_type,
|
||||
folder_id: parts.folder_id,
|
||||
created_at: parts.created_at,
|
||||
modified_at: parts.modified_at,
|
||||
icon_class,
|
||||
icon_special_class,
|
||||
category,
|
||||
size_formatted,
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
etag,
|
||||
created_by: parts.created_by,
|
||||
updated_by: parts.updated_by,
|
||||
}
|
||||
}
|
||||
|
||||
/// Old `From<Folder> for FolderDto` body: per-getter `.to_string()`
|
||||
/// clones, `folder.etag().to_string()` (etag built then cloned — the
|
||||
/// verbatim double alloc) and 3 fresh `Arc::from` constants per row.
|
||||
pub fn folder_to_dto(folder: Folder) -> FolderDto {
|
||||
let is_root = folder.parent_id().is_none();
|
||||
let etag = folder_compute_etag(folder.id(), folder.tree_modified_at()).to_string();
|
||||
|
||||
FolderDto {
|
||||
id: folder.id().to_string(),
|
||||
name: folder.name().to_string(),
|
||||
path: folder.path_string().to_string(),
|
||||
parent_id: folder.parent_id().map(String::from),
|
||||
drive_id: folder.drive_id(),
|
||||
created_at: folder.created_at(),
|
||||
modified_at: folder.modified_at(),
|
||||
is_root,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
etag,
|
||||
created_by: folder.created_by(),
|
||||
updated_by: folder.updated_by(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Synthetic corpus ────────────────────────────────────────────────────────
|
||||
|
||||
/// (extension, mime) matrix: interned common types, generic MIMEs that
|
||||
/// exercise the extension fallback, and exotic MIMEs that miss the intern
|
||||
/// table so the fallback `Arc::from` path is measured too.
|
||||
const KINDS: &[(&str, &str)] = &[
|
||||
("jpg", "image/jpeg"),
|
||||
("png", "image/png"),
|
||||
("heic", "image/heic"),
|
||||
("mp4", "video/mp4"),
|
||||
("mov", "video/quicktime"),
|
||||
("mp3", "audio/mpeg"),
|
||||
("flac", "audio/flac"),
|
||||
("pdf", "application/pdf"),
|
||||
(
|
||||
"docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
),
|
||||
(
|
||||
"xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
),
|
||||
("txt", "text/plain"),
|
||||
("md", "text/markdown"),
|
||||
("csv", "text/csv"),
|
||||
("json", "application/json"),
|
||||
("zip", "application/zip"),
|
||||
("gz", "application/gzip"),
|
||||
// Extension fallback: generic MIME, type resolved from the name.
|
||||
("rs", "application/octet-stream"),
|
||||
("py", "application/octet-stream"),
|
||||
("svelte", "application/octet-stream"),
|
||||
("dmg", "application/octet-stream"),
|
||||
("bin", "application/octet-stream"),
|
||||
// No extension + empty MIME: full-default path.
|
||||
("", ""),
|
||||
// Exotic MIMEs: miss the intern table, fall back to Arc::from.
|
||||
("pdb", "chemical/x-pdb"),
|
||||
("xyz", "application/x-very-exotic-subtype+custom"),
|
||||
];
|
||||
|
||||
const SIZES: &[u64] = &[
|
||||
0,
|
||||
137,
|
||||
500,
|
||||
1_024,
|
||||
1_536,
|
||||
65_536,
|
||||
1_048_576,
|
||||
3_423_744,
|
||||
987_654_321,
|
||||
1_073_741_824,
|
||||
5_497_558_138_880, // ~5 TB
|
||||
];
|
||||
|
||||
/// Deterministic xorshift64* — fake-but-plausible 64-char lowercase hex
|
||||
/// BLAKE3 hashes.
|
||||
fn next_seed(seed: &mut u64) -> u64 {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 7;
|
||||
*seed ^= *seed << 17;
|
||||
seed.wrapping_mul(0x2545F4914F6CDD1D)
|
||||
}
|
||||
|
||||
fn fake_blake3(seed: &mut u64) -> String {
|
||||
format!(
|
||||
"{:016x}{:016x}{:016x}{:016x}",
|
||||
next_seed(seed),
|
||||
next_seed(seed),
|
||||
next_seed(seed),
|
||||
next_seed(seed)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_files(rows: usize) -> Vec<File> {
|
||||
let mut seed = 0x9E3779B97F4A7C15u64;
|
||||
(0..rows)
|
||||
.map(|i| {
|
||||
let (ext, mime) = KINDS[i % KINDS.len()];
|
||||
let name = if ext.is_empty() {
|
||||
format!("file_{i:05}")
|
||||
} else {
|
||||
format!("file_{i:05}.{ext}")
|
||||
};
|
||||
let path = StoragePath::from_string(&format!("/bench/dir_{}/{}", i % 37, name));
|
||||
let folder_id = if i % 3 == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Uuid::from_u128(1000 + (i % 37) as u128).to_string())
|
||||
};
|
||||
let created_by = (i % 2 == 0).then(|| Uuid::from_u128(7 + (i % 5) as u128));
|
||||
let updated_by = (i % 4 == 0).then(|| Uuid::from_u128(11 + (i % 3) as u128));
|
||||
File::with_timestamps_blob_hash_and_provenance(
|
||||
Uuid::from_u128(i as u128).to_string(),
|
||||
name,
|
||||
path,
|
||||
SIZES[i % SIZES.len()],
|
||||
mime.to_string(),
|
||||
folder_id,
|
||||
1_600_000_000 + i as u64,
|
||||
1_700_000_000 + (i as u64 * 7) % 100_000,
|
||||
fake_blake3(&mut seed),
|
||||
created_by,
|
||||
updated_by,
|
||||
)
|
||||
.expect("valid synthetic file")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_folders(rows: usize) -> Vec<Folder> {
|
||||
(0..rows)
|
||||
.map(|i| {
|
||||
let name = format!("folder_{i:05}");
|
||||
let path = StoragePath::from_string(&format!("/bench/parent_{}/{}", i % 37, name));
|
||||
let parent_id = if i % 5 == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Uuid::from_u128(2000 + (i % 37) as u128).to_string())
|
||||
};
|
||||
let created_by = (i % 2 == 0).then(|| Uuid::from_u128(7 + (i % 5) as u128));
|
||||
let updated_by = (i % 4 == 0).then(|| Uuid::from_u128(11 + (i % 3) as u128));
|
||||
Folder::with_timestamps_tree_and_provenance(
|
||||
Uuid::from_u128(500_000 + i as u128).to_string(),
|
||||
name,
|
||||
path,
|
||||
parent_id,
|
||||
Uuid::from_u128(42 + (i % 4) as u128),
|
||||
1_600_000_000 + i as u64,
|
||||
1_700_000_000 + (i as u64 * 7) % 100_000,
|
||||
1_700_000_000 + (i as u64 * 11) % 100_000,
|
||||
created_by,
|
||||
updated_by,
|
||||
)
|
||||
.expect("valid synthetic folder")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ─── Measurement helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn median(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
/// p50 wall seconds per pass of `f` over `passes` passes.
|
||||
fn p50_pass_secs(passes: usize, mut f: impl FnMut()) -> f64 {
|
||||
f(); // warmup (also initializes LazyLock intern tables)
|
||||
let mut xs = Vec::with_capacity(passes);
|
||||
for _ in 0..passes {
|
||||
let t0 = Instant::now();
|
||||
f();
|
||||
xs.push(t0.elapsed().as_secs_f64());
|
||||
}
|
||||
median(xs)
|
||||
}
|
||||
|
||||
/// Allocation calls performed by one run of `f` (deterministic — the
|
||||
/// mappings do no I/O and touch no shared caches beyond the intern tables,
|
||||
/// which the warmup run already initialized).
|
||||
fn allocs_of(mut f: impl FnMut()) -> u64 {
|
||||
f(); // warmup so one-time lazy init isn't attributed to the variant
|
||||
let start = ALLOC_CALLS.load(Ordering::Relaxed);
|
||||
f();
|
||||
ALLOC_CALLS.load(Ordering::Relaxed) - start
|
||||
}
|
||||
|
||||
struct Row {
|
||||
variant: &'static str,
|
||||
ns_per_row: f64,
|
||||
allocs_per_row: f64,
|
||||
}
|
||||
|
||||
// ─── Equivalence gate (Section 4) ────────────────────────────────────────────
|
||||
|
||||
macro_rules! cmp_field {
|
||||
($diffs:expr, $i:expr, $kind:expr, $b:expr, $a:expr, $field:ident) => {
|
||||
if $b.$field != $a.$field {
|
||||
$diffs += 1;
|
||||
if $diffs <= 20 {
|
||||
println!(
|
||||
" DIFF {} row {}: {} BEFORE={:?} AFTER={:?}",
|
||||
$kind,
|
||||
$i,
|
||||
stringify!($field),
|
||||
$b.$field,
|
||||
$a.$field
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn diff_file(i: usize, b: &FileDto, a: &FileDto, diffs: &mut u64) {
|
||||
cmp_field!(*diffs, i, "file", b, a, id);
|
||||
cmp_field!(*diffs, i, "file", b, a, name);
|
||||
cmp_field!(*diffs, i, "file", b, a, path);
|
||||
cmp_field!(*diffs, i, "file", b, a, size);
|
||||
cmp_field!(*diffs, i, "file", b, a, mime_type);
|
||||
cmp_field!(*diffs, i, "file", b, a, folder_id);
|
||||
cmp_field!(*diffs, i, "file", b, a, created_at);
|
||||
cmp_field!(*diffs, i, "file", b, a, modified_at);
|
||||
cmp_field!(*diffs, i, "file", b, a, icon_class);
|
||||
cmp_field!(*diffs, i, "file", b, a, icon_special_class);
|
||||
cmp_field!(*diffs, i, "file", b, a, category);
|
||||
cmp_field!(*diffs, i, "file", b, a, size_formatted);
|
||||
cmp_field!(*diffs, i, "file", b, a, sort_date);
|
||||
cmp_field!(*diffs, i, "file", b, a, content_hash);
|
||||
cmp_field!(*diffs, i, "file", b, a, etag);
|
||||
cmp_field!(*diffs, i, "file", b, a, created_by);
|
||||
cmp_field!(*diffs, i, "file", b, a, updated_by);
|
||||
}
|
||||
|
||||
fn diff_folder(i: usize, b: &FolderDto, a: &FolderDto, diffs: &mut u64) {
|
||||
cmp_field!(*diffs, i, "folder", b, a, id);
|
||||
cmp_field!(*diffs, i, "folder", b, a, name);
|
||||
cmp_field!(*diffs, i, "folder", b, a, path);
|
||||
cmp_field!(*diffs, i, "folder", b, a, parent_id);
|
||||
cmp_field!(*diffs, i, "folder", b, a, drive_id);
|
||||
cmp_field!(*diffs, i, "folder", b, a, created_at);
|
||||
cmp_field!(*diffs, i, "folder", b, a, modified_at);
|
||||
cmp_field!(*diffs, i, "folder", b, a, is_root);
|
||||
cmp_field!(*diffs, i, "folder", b, a, icon_class);
|
||||
cmp_field!(*diffs, i, "folder", b, a, icon_special_class);
|
||||
cmp_field!(*diffs, i, "folder", b, a, category);
|
||||
cmp_field!(*diffs, i, "folder", b, a, etag);
|
||||
cmp_field!(*diffs, i, "folder", b, a, created_by);
|
||||
cmp_field!(*diffs, i, "folder", b, a, updated_by);
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn main() {
|
||||
let rows: usize = env_or("BENCH_ROWS", 10_000).max(1);
|
||||
let passes: usize = env_or("BENCH_PASSES", 100).max(1);
|
||||
|
||||
let files = build_files(rows);
|
||||
let folders = build_folders(rows);
|
||||
println!(
|
||||
"corpus: {rows} files ({} kinds x {} sizes) + {rows} folders, {passes} timed passes",
|
||||
KINDS.len(),
|
||||
SIZES.len()
|
||||
);
|
||||
println!(
|
||||
"note: each measured pass pays one entity clone per row (mapping consumes the\n\
|
||||
entity); the clone-only baseline is measured separately and subtracted.\n"
|
||||
);
|
||||
|
||||
// ── Section 1: File → FileDto wall time ─────────────────────────────
|
||||
println!("── Section 1: File → FileDto (p50 wall, net of clone) ──");
|
||||
let file_base_s = p50_pass_secs(passes, || {
|
||||
for f in &files {
|
||||
black_box(f.clone());
|
||||
}
|
||||
});
|
||||
let file_before_s = p50_pass_secs(passes, || {
|
||||
for f in &files {
|
||||
black_box(before::file_to_dto(f.clone()));
|
||||
}
|
||||
});
|
||||
let file_after_s = p50_pass_secs(passes, || {
|
||||
for f in &files {
|
||||
black_box(FileDto::from(f.clone()));
|
||||
}
|
||||
});
|
||||
let file_base_ns = file_base_s * 1e9 / rows as f64;
|
||||
let file_before_ns = (file_before_s - file_base_s) * 1e9 / rows as f64;
|
||||
let file_after_ns = (file_after_s - file_base_s) * 1e9 / rows as f64;
|
||||
println!(" clone-only baseline: {file_base_ns:8.1} ns/row");
|
||||
println!(" BEFORE mapping: {file_before_ns:8.1} ns/row");
|
||||
println!(" AFTER mapping: {file_after_ns:8.1} ns/row\n");
|
||||
|
||||
// ── Section 2: Folder → FolderDto wall time ─────────────────────────
|
||||
println!("── Section 2: Folder → FolderDto (p50 wall, net of clone) ──");
|
||||
let folder_base_s = p50_pass_secs(passes, || {
|
||||
for f in &folders {
|
||||
black_box(f.clone());
|
||||
}
|
||||
});
|
||||
let folder_before_s = p50_pass_secs(passes, || {
|
||||
for f in &folders {
|
||||
black_box(before::folder_to_dto(f.clone()));
|
||||
}
|
||||
});
|
||||
let folder_after_s = p50_pass_secs(passes, || {
|
||||
for f in &folders {
|
||||
black_box(FolderDto::from(f.clone()));
|
||||
}
|
||||
});
|
||||
let folder_base_ns = folder_base_s * 1e9 / rows as f64;
|
||||
let folder_before_ns = (folder_before_s - folder_base_s) * 1e9 / rows as f64;
|
||||
let folder_after_ns = (folder_after_s - folder_base_s) * 1e9 / rows as f64;
|
||||
println!(" clone-only baseline: {folder_base_ns:8.1} ns/row");
|
||||
println!(" BEFORE mapping: {folder_before_ns:8.1} ns/row");
|
||||
println!(" AFTER mapping: {folder_after_ns:8.1} ns/row\n");
|
||||
|
||||
// ── Section 3: allocation calls per row ─────────────────────────────
|
||||
println!("── Section 3: allocator calls per row (net of clone) ──");
|
||||
let file_base_a = allocs_of(|| {
|
||||
for f in &files {
|
||||
black_box(f.clone());
|
||||
}
|
||||
}) as f64
|
||||
/ rows as f64;
|
||||
let file_before_a = allocs_of(|| {
|
||||
for f in &files {
|
||||
black_box(before::file_to_dto(f.clone()));
|
||||
}
|
||||
}) as f64
|
||||
/ rows as f64
|
||||
- file_base_a;
|
||||
let file_after_a = allocs_of(|| {
|
||||
for f in &files {
|
||||
black_box(FileDto::from(f.clone()));
|
||||
}
|
||||
}) as f64
|
||||
/ rows as f64
|
||||
- file_base_a;
|
||||
let folder_base_a = allocs_of(|| {
|
||||
for f in &folders {
|
||||
black_box(f.clone());
|
||||
}
|
||||
}) as f64
|
||||
/ rows as f64;
|
||||
let folder_before_a = allocs_of(|| {
|
||||
for f in &folders {
|
||||
black_box(before::folder_to_dto(f.clone()));
|
||||
}
|
||||
}) as f64
|
||||
/ rows as f64
|
||||
- folder_base_a;
|
||||
let folder_after_a = allocs_of(|| {
|
||||
for f in &folders {
|
||||
black_box(FolderDto::from(f.clone()));
|
||||
}
|
||||
}) as f64
|
||||
/ rows as f64
|
||||
- folder_base_a;
|
||||
println!(" file clone baseline: {file_base_a:6.2} allocs/row");
|
||||
println!(" file BEFORE mapping: {file_before_a:6.2} allocs/row");
|
||||
println!(" file AFTER mapping: {file_after_a:6.2} allocs/row");
|
||||
println!(" folder clone baseline: {folder_base_a:6.2} allocs/row");
|
||||
println!(" folder BEFORE mapping: {folder_before_a:6.2} allocs/row");
|
||||
println!(" folder AFTER mapping: {folder_after_a:6.2} allocs/row\n");
|
||||
|
||||
// ── Section 4: equivalence gate ─────────────────────────────────────
|
||||
println!("── Section 4: equivalence gate (BEFORE == AFTER, field by field) ──");
|
||||
let mut diffs: u64 = 0;
|
||||
for (i, f) in files.iter().enumerate() {
|
||||
let b = before::file_to_dto(f.clone());
|
||||
let a = FileDto::from(f.clone());
|
||||
diff_file(i, &b, &a, &mut diffs);
|
||||
}
|
||||
for (i, f) in folders.iter().enumerate() {
|
||||
let b = before::folder_to_dto(f.clone());
|
||||
let a = FolderDto::from(f.clone());
|
||||
diff_folder(i, &b, &a, &mut diffs);
|
||||
}
|
||||
if diffs > 0 {
|
||||
println!(" FAILED: {diffs} field diffs between BEFORE and AFTER mappings");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!(" PASSED: {rows} files + {rows} folders map byte-identically\n");
|
||||
|
||||
// ── Markdown summary ─────────────────────────────────────────────────
|
||||
let table = [
|
||||
Row {
|
||||
variant: "File→FileDto BEFORE",
|
||||
ns_per_row: file_before_ns,
|
||||
allocs_per_row: file_before_a,
|
||||
},
|
||||
Row {
|
||||
variant: "File→FileDto AFTER",
|
||||
ns_per_row: file_after_ns,
|
||||
allocs_per_row: file_after_a,
|
||||
},
|
||||
Row {
|
||||
variant: "Folder→FolderDto BEFORE",
|
||||
ns_per_row: folder_before_ns,
|
||||
allocs_per_row: folder_before_a,
|
||||
},
|
||||
Row {
|
||||
variant: "Folder→FolderDto AFTER",
|
||||
ns_per_row: folder_after_ns,
|
||||
allocs_per_row: folder_after_a,
|
||||
},
|
||||
];
|
||||
println!("| variant | ns/row | allocs/row |");
|
||||
println!("|---|---:|---:|");
|
||||
for r in &table {
|
||||
println!(
|
||||
"| {} | {:.1} | {:.2} |",
|
||||
r.variant, r.ns_per_row, r.allocs_per_row
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Face-indexing fan-out benchmark — unbounded spawn vs semaphore (ROUND4).
|
||||
//!
|
||||
//! `FaceIndexingService::spawn_index` fired one `tokio::spawn` per
|
||||
//! uploaded/copied image with NO ceiling; each task reads the full blob
|
||||
//! into RAM and decodes it before inference. A bulk upload of N photos
|
||||
//! therefore held up to N decoded images in flight simultaneously.
|
||||
//! AFTER: an `Arc<Semaphore>` sized to the effective core count
|
||||
//! (`OXICLOUD_FACES_INDEX_CONCURRENCY` override), permit acquired BEFORE
|
||||
//! the blob read — the exact `ThumbnailService::decode_semaphore`
|
||||
//! invariant ("peak memory = permits × image size").
|
||||
//!
|
||||
//! This is a *pattern* bench (like POOL-CONCURRENCY / RUNTIME): the real
|
||||
//! service needs Postgres + an ONNX model, so the task body models the
|
||||
//! dominant costs — full-file read + JPEG decode on the deterministic
|
||||
//! `bench_support` photo corpus — while the spawn/permit shape is copied
|
||||
//! from the service verbatim. Metrics: wall time, PEAK LIVE HEAP (exact,
|
||||
//! via counting allocator), decode results asserted identical.
|
||||
//!
|
||||
//! Run (no Postgres needed):
|
||||
//! cargo run --release --features bench --example bench_faces_bound
|
||||
//! Tunables (env): BENCH_IMAGES (48), BENCH_PERMITS (effective cores).
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::env;
|
||||
use std::hint::black_box;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
// ─── Peak-live-heap tracking allocator ──────────────────────────────────────
|
||||
|
||||
static LIVE: AtomicU64 = AtomicU64::new(0);
|
||||
static PEAK: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct PeakAlloc;
|
||||
|
||||
fn bump(sz: u64) {
|
||||
let live = LIVE.fetch_add(sz, Ordering::Relaxed) + sz;
|
||||
PEAK.fetch_max(live, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
unsafe impl GlobalAlloc for PeakAlloc {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
bump(layout.size() as u64);
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
LIVE.fetch_sub(layout.size() as u64, Ordering::Relaxed);
|
||||
unsafe { System.dealloc(ptr, layout) }
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||
if new_size > layout.size() {
|
||||
bump((new_size - layout.size()) as u64);
|
||||
} else {
|
||||
LIVE.fetch_sub((layout.size() - new_size) as u64, Ordering::Relaxed);
|
||||
}
|
||||
unsafe { System.realloc(ptr, layout, new_size) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
bump(layout.size() as u64);
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: PeakAlloc = PeakAlloc;
|
||||
|
||||
/// The modelled per-image work: full blob read (as `index_file` does via
|
||||
/// `tokio::fs::read`) + JPEG decode (the analyzer's first step).
|
||||
async fn index_one(path: std::path::PathBuf, dims: Arc<AtomicUsize>) {
|
||||
let bytes = tokio::fs::read(&path).await.expect("read blob");
|
||||
let img = tokio::task::spawn_blocking(move || image::load_from_memory(&bytes).expect("decode"))
|
||||
.await
|
||||
.expect("join decode");
|
||||
dims.fetch_add((img.width() + img.height()) as usize, Ordering::Relaxed);
|
||||
black_box(img);
|
||||
}
|
||||
|
||||
fn effective_parallelism() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(2)
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
let images: usize = env::var("BENCH_IMAGES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(48);
|
||||
let permits: usize = env::var("BENCH_PERMITS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or_else(effective_parallelism);
|
||||
|
||||
// Deterministic photo corpus (12 MP JPEG case) → one temp file per
|
||||
// "upload" so each task pays a real filesystem read.
|
||||
let corpus = oxicloud::bench_support::load_or_generate();
|
||||
let jpeg = corpus
|
||||
.iter()
|
||||
.max_by_key(|c| c.bytes.len())
|
||||
.expect("corpus nonempty");
|
||||
println!(
|
||||
"bench_faces_bound — {images} images ({} · {:.1} MiB encoded), permits={permits}\n",
|
||||
jpeg.name,
|
||||
jpeg.bytes.len() as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut paths = Vec::with_capacity(images);
|
||||
for i in 0..images {
|
||||
let p = dir.path().join(format!("{i}.blob"));
|
||||
std::fs::write(&p, &jpeg.bytes).expect("write blob");
|
||||
paths.push(p);
|
||||
}
|
||||
|
||||
// ── BEFORE: unbounded spawn per image (the old spawn_index shape) ──
|
||||
let dims_before = Arc::new(AtomicUsize::new(0));
|
||||
PEAK.store(LIVE.load(Ordering::Relaxed), Ordering::Relaxed);
|
||||
let t0 = Instant::now();
|
||||
let mut handles = Vec::with_capacity(images);
|
||||
for p in &paths {
|
||||
let p = p.clone();
|
||||
let dims = dims_before.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
index_one(p, dims).await;
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.await.unwrap();
|
||||
}
|
||||
let wall_before = t0.elapsed().as_secs_f64() * 1e3;
|
||||
let peak_before = PEAK.load(Ordering::Relaxed) as f64 / (1024.0 * 1024.0);
|
||||
|
||||
// ── AFTER: same spawn shape + semaphore permit before the read ──
|
||||
let dims_after = Arc::new(AtomicUsize::new(0));
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(permits));
|
||||
PEAK.store(LIVE.load(Ordering::Relaxed), Ordering::Relaxed);
|
||||
let t0 = Instant::now();
|
||||
let mut handles = Vec::with_capacity(images);
|
||||
for p in &paths {
|
||||
let p = p.clone();
|
||||
let dims = dims_after.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = semaphore
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("semaphore never closes");
|
||||
index_one(p, dims).await;
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.await.unwrap();
|
||||
}
|
||||
let wall_after = t0.elapsed().as_secs_f64() * 1e3;
|
||||
let peak_after = PEAK.load(Ordering::Relaxed) as f64 / (1024.0 * 1024.0);
|
||||
|
||||
println!(" wall ms peak live heap MiB");
|
||||
println!("BEFORE (unbounded) {wall_before:8.1} {peak_before:10.1}");
|
||||
println!(
|
||||
"AFTER (semaphore {permits:>2}) {wall_after:8.1} {peak_after:10.1} heap {:.1}x lower",
|
||||
peak_before / peak_after
|
||||
);
|
||||
|
||||
// ── Equivalence gate: identical decode results ──
|
||||
let db = dims_before.load(Ordering::Relaxed);
|
||||
let da = dims_after.load(Ordering::Relaxed);
|
||||
if db != da || db == 0 {
|
||||
eprintln!("GATE FAIL: dimension sums differ (before={db} after={da})");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("\n[gate] OK — all {images} images decoded identically in both modes");
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Batch-favorites AuthZ fan-out benchmark — serial `require` loop vs
|
||||
//! `try_join_all`.
|
||||
//!
|
||||
//! VERDICT (round 6): the fan-out measured WORSE on both the cold and the
|
||||
//! warm path against local-socket Postgres (see benches/ROUND6.md), so the
|
||||
//! production loop stays serial. This example is kept as the reproducible
|
||||
//! evidence for that rejection — re-run it if the DB ever moves behind real
|
||||
//! network latency, where the answer could flip.
|
||||
//!
|
||||
//! `FavoritesService::batch_add_to_favorites` pre-checks `Permission::Read`
|
||||
//! on every referenced resource. BEFORE awaited the checks one-by-one: for a
|
||||
//! "select all → add to favorites" over N items whose drive-lookup isn't
|
||||
//! cached yet, that is N sequential point-SELECT round-trips
|
||||
//! (`drive_of` per distinct file) before the batched insert even starts.
|
||||
//! AFTER fans the same checks out with `futures::future::try_join_all`
|
||||
//! (fail-fast on any denial preserved).
|
||||
//!
|
||||
//! This bench drives the REAL `PgAclEngine` (owner/drive-role caches
|
||||
//! included) against a seeded shared drive:
|
||||
//! caller ──editor grant──▶ drive ─▶ root folder ─▶ N files
|
||||
//!
|
||||
//! Arms: cold engine (empty caches — the first-grid-load shape) and warm
|
||||
//! repeat (all moka — parity check, both arms should collapse).
|
||||
//!
|
||||
//! Equivalence gates: every check grants for the member on both arms, and
|
||||
//! both arms deny a control user with no grant.
|
||||
//!
|
||||
//! Run (needs Postgres up; reads DATABASE_URL from .env):
|
||||
//! cargo run --release --features bench --example bench_favorites_authz
|
||||
//! Tunables (env): BENCH_FILES (200), BENCH_POOL (20).
|
||||
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use oxicloud::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use oxicloud::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use oxicloud::infrastructure::repositories::pg::{
|
||||
FileBlobReadRepository, FolderDbRepository, SubjectGroupPgRepository,
|
||||
};
|
||||
use oxicloud::infrastructure::services::dedup_service::DedupService;
|
||||
use oxicloud::infrastructure::services::local_blob_backend::LocalBlobBackend;
|
||||
use oxicloud::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
struct Seeded {
|
||||
caller: Uuid,
|
||||
control: Uuid,
|
||||
drive_id: Uuid,
|
||||
root_folder: Uuid,
|
||||
blob_hash: String,
|
||||
file_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
async fn seed(pool: &PgPool, n_files: usize) -> Seeded {
|
||||
let mut tx = pool.begin().await.expect("begin");
|
||||
let caller: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, role)
|
||||
VALUES ('bench_favauthz', 'bench_favauthz@bench.invalid', 'user') RETURNING id",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed caller");
|
||||
let control: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, role)
|
||||
VALUES ('bench_favauthz_ctl', 'bench_favauthz_ctl@bench.invalid', 'user') RETURNING id",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed control");
|
||||
|
||||
let drive_id: Uuid =
|
||||
sqlx::query_scalar("INSERT INTO storage.drives (kind) VALUES ('shared') RETURNING id")
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed drive");
|
||||
let root_folder: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.folders (name, path, lpath, drive_id)
|
||||
VALUES ('Bench Shared', '/Bench Shared', 'x', $1) RETURNING id",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed folder");
|
||||
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
|
||||
.bind(root_folder)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("stamp root");
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.role_grants
|
||||
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
|
||||
VALUES ('user', $1, 'drive', $2, 'editor'::storage.grant_role, $1)",
|
||||
)
|
||||
.bind(caller)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("seed grant");
|
||||
|
||||
let blob_hash = "benchfavauthz0000000000000000000000000000000000000000000000000b1".to_string();
|
||||
sqlx::query("INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 1, 1)")
|
||||
.bind(&blob_hash)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("seed blob");
|
||||
|
||||
let mut file_ids = Vec::with_capacity(n_files);
|
||||
for i in 0..n_files {
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id)
|
||||
VALUES ($1, $2, $3, 1, 'text/plain', $4) RETURNING id",
|
||||
)
|
||||
.bind(format!("bench-{i:04}.txt"))
|
||||
.bind(root_folder)
|
||||
.bind(&blob_hash)
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed file");
|
||||
file_ids.push(id);
|
||||
}
|
||||
tx.commit().await.expect("commit");
|
||||
Seeded {
|
||||
caller,
|
||||
control,
|
||||
drive_id,
|
||||
root_folder,
|
||||
blob_hash,
|
||||
file_ids,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup(pool: &PgPool, s: &Seeded) {
|
||||
let _ = sqlx::query("DELETE FROM storage.role_grants WHERE resource_id = $1")
|
||||
.bind(s.drive_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.files WHERE drive_id = $1")
|
||||
.bind(s.drive_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1")
|
||||
.bind(s.drive_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.folders WHERE id = $1")
|
||||
.bind(s.root_folder)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.blobs WHERE hash = $1")
|
||||
.bind(&s.blob_hash)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM auth.users WHERE id IN ($1, $2)")
|
||||
.bind(s.caller)
|
||||
.bind(s.control)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn fresh_engine(pool: &Arc<PgPool>) -> Arc<PgAclEngine> {
|
||||
let folder_repo = Arc::new(FolderDbRepository::new(pool.clone()));
|
||||
let backend = Arc::new(LocalBlobBackend::new(std::path::Path::new(
|
||||
"/tmp/bench-favauthz-blobs",
|
||||
)));
|
||||
let dedup = Arc::new(DedupService::new(backend, pool.clone(), pool.clone()));
|
||||
let file_repo = Arc::new(FileBlobReadRepository::new(
|
||||
pool.clone(),
|
||||
dedup,
|
||||
folder_repo.clone(),
|
||||
));
|
||||
let group_repo = Arc::new(SubjectGroupPgRepository::new(pool.clone()));
|
||||
Arc::new(PgAclEngine::new(
|
||||
pool.clone(),
|
||||
folder_repo,
|
||||
file_repo,
|
||||
group_repo,
|
||||
))
|
||||
}
|
||||
|
||||
/// BEFORE, verbatim shape: one awaited `require` per item.
|
||||
async fn serial_checks(engine: &Arc<PgAclEngine>, user: Uuid, files: &[Uuid]) -> Result<(), ()> {
|
||||
for id in files {
|
||||
engine
|
||||
.require(Subject::User(user), Permission::Read, Resource::File(*id))
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// AFTER: the same checks, fanned out with fail-fast join.
|
||||
async fn joined_checks(engine: &Arc<PgAclEngine>, user: Uuid, files: &[Uuid]) -> Result<(), ()> {
|
||||
futures::future::try_join_all(
|
||||
files
|
||||
.iter()
|
||||
.map(|id| engine.require(Subject::User(user), Permission::Read, Resource::File(*id))),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
#[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 n_files: usize = env_or("BENCH_FILES", 200);
|
||||
let pool_size: u32 = env_or("BENCH_POOL", 20);
|
||||
|
||||
let pool = Arc::new(
|
||||
PgPoolOptions::new()
|
||||
.max_connections(pool_size)
|
||||
.min_connections(pool_size)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect Postgres"),
|
||||
);
|
||||
|
||||
let seeded = seed(&pool, n_files).await;
|
||||
|
||||
// ── Equivalence gates ────────────────────────────────────────────────
|
||||
// Grant path: both arms must authorize every file for the member.
|
||||
let gate_engine = fresh_engine(&pool);
|
||||
if serial_checks(&gate_engine, seeded.caller, &seeded.file_ids)
|
||||
.await
|
||||
.is_err()
|
||||
|| joined_checks(&gate_engine, seeded.caller, &seeded.file_ids)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
eprintln!("EQUIVALENCE GATE FAILED: member was denied");
|
||||
cleanup(&pool, &seeded).await;
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Denial path: both arms must reject the control user (fresh engines so
|
||||
// the joined arm can't ride the serial arm's caches).
|
||||
let deny_a = fresh_engine(&pool);
|
||||
let deny_b = fresh_engine(&pool);
|
||||
if serial_checks(&deny_a, seeded.control, &seeded.file_ids)
|
||||
.await
|
||||
.is_ok()
|
||||
|| joined_checks(&deny_b, seeded.control, &seeded.file_ids)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
eprintln!("EQUIVALENCE GATE FAILED: control user was granted");
|
||||
cleanup(&pool, &seeded).await;
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!("\n#################################################################");
|
||||
println!("# batch-favorites authz: serial require loop vs try_join_all");
|
||||
println!("# files={n_files} pool={pool_size} (shared-drive member, editor grant)");
|
||||
println!("#################################################################\n");
|
||||
println!("| {:<18} | {:>10} | {:>12} |", "arm", "wall ms", "µs/item");
|
||||
|
||||
for (label, joined, warm) in [
|
||||
("serial COLD", false, false),
|
||||
("join COLD", true, false),
|
||||
("serial WARM", false, true),
|
||||
("join WARM", true, true),
|
||||
] {
|
||||
// COLD: fresh engine per run (empty moka). WARM: prime, then measure.
|
||||
let engine = fresh_engine(&pool);
|
||||
if warm {
|
||||
serial_checks(&engine, seeded.caller, &seeded.file_ids)
|
||||
.await
|
||||
.expect("prime");
|
||||
}
|
||||
let t = Instant::now();
|
||||
let r = if joined {
|
||||
joined_checks(&engine, seeded.caller, &seeded.file_ids).await
|
||||
} else {
|
||||
serial_checks(&engine, seeded.caller, &seeded.file_ids).await
|
||||
};
|
||||
let el = t.elapsed();
|
||||
r.expect("granted");
|
||||
println!(
|
||||
"| {:<18} | {:>10.2} | {:>12.2} |",
|
||||
label,
|
||||
el.as_secs_f64() * 1e3,
|
||||
el.as_secs_f64() * 1e6 / n_files as f64
|
||||
);
|
||||
}
|
||||
|
||||
cleanup(&pool, &seeded).await;
|
||||
println!("\n(COLD = empty caches: N distinct `drive_of` point-SELECTs — the arm");
|
||||
println!(" under test. WARM = all-moka parity check. Fail-fast denial semantics");
|
||||
println!(" verified by the control-user gate on both arms.)");
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! PROPFIND subfolder-paging benchmark — LIMIT/OFFSET + COUNT(*) OVER() vs
|
||||
//! keyset, mirroring the files-side PROPFIND-PAGING fix.
|
||||
//!
|
||||
//! The streaming PROPFIND walkers (native WebDAV + NC-DAV) page a folder's
|
||||
//! subfolders via `list_folders_paginated`, whose query is
|
||||
//! `COUNT(*) OVER() … ORDER BY name LIMIT $2 OFFSET $3` — every page
|
||||
//! window-aggregates and rescans ALL N subfolders (the total is only used
|
||||
//! for has_next), so a full walk is O(N²/page) row visits.
|
||||
//!
|
||||
//! The AFTER shape is the same keyset used for files: `name > $last ORDER BY
|
||||
//! name LIMIT k`, served by the existing UNIQUE index
|
||||
//! `idx_folders_unique_name (parent_id, name, drive_id) WHERE NOT is_trashed
|
||||
//! AND parent_id IS NOT NULL` — no migration needed. has_next falls out of
|
||||
//! `rows.len() == limit`.
|
||||
//!
|
||||
//! Equivalence gate: the drained name sequence must be identical.
|
||||
//!
|
||||
//! Run (needs Postgres up; reads DATABASE_URL from .env):
|
||||
//! cargo run --release --features bench --example bench_folder_keyset
|
||||
//! Tunables: BENCH_DIRS (5000), BENCH_PAGE (500), BENCH_REPS (5)
|
||||
|
||||
use std::env;
|
||||
use std::time::Instant;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
async fn seed(pool: &PgPool, dirs: usize) -> (Uuid, Uuid) {
|
||||
let mut tx = pool.begin().await.expect("begin");
|
||||
let drive_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.drives (kind, quota_bytes) VALUES ('shared', NULL) RETURNING id",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("drive");
|
||||
let folder_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.folders (name, path, lpath, drive_id)
|
||||
VALUES ('bench_folder_keyset', '/bench_folder_keyset', 'bench_folder_keyset', $1)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("folder");
|
||||
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
|
||||
.bind(folder_id)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("stamp");
|
||||
tx.commit().await.expect("commit");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.folders (name, path, lpath, parent_id, drive_id)
|
||||
SELECT 'Dir_' || LPAD(i::text, 6, '0'),
|
||||
'/bench_folder_keyset/Dir_' || LPAD(i::text, 6, '0'),
|
||||
('bench_folder_keyset.d' || i)::ltree,
|
||||
$1, $2
|
||||
FROM generate_series(1, $3) AS i",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.bind(drive_id)
|
||||
.bind(dirs as i32)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("dirs");
|
||||
sqlx::query("ANALYZE storage.folders")
|
||||
.execute(pool)
|
||||
.await
|
||||
.ok();
|
||||
(drive_id, folder_id)
|
||||
}
|
||||
|
||||
const COLS: &str = "id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by";
|
||||
|
||||
type Row = (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Uuid,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
);
|
||||
type RowWithTotal = (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Uuid,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
i64,
|
||||
);
|
||||
|
||||
/// OLD: production `list_folders_paginated` shape — window total + OFFSET.
|
||||
async fn walk_offset(pool: &PgPool, parent: Uuid, page: i64) -> (Vec<String>, Vec<f64>) {
|
||||
let mut offset = 0i64;
|
||||
let mut names = Vec::new();
|
||||
let mut times = Vec::new();
|
||||
loop {
|
||||
let t = Instant::now();
|
||||
let rows: Vec<RowWithTotal> = sqlx::query_as(&format!(
|
||||
"SELECT {COLS}, COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND NOT is_trashed
|
||||
ORDER BY name
|
||||
LIMIT $2 OFFSET $3"
|
||||
))
|
||||
.bind(parent)
|
||||
.bind(page)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("offset page");
|
||||
times.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
let n = rows.len();
|
||||
names.extend(rows.into_iter().map(|r| r.1));
|
||||
if (n as i64) < page {
|
||||
break;
|
||||
}
|
||||
offset += n as i64;
|
||||
}
|
||||
(names, times)
|
||||
}
|
||||
|
||||
/// NEW: keyset on the existing unique index; has_next = rows.len() == limit.
|
||||
async fn walk_keyset(pool: &PgPool, parent: Uuid, page: i64) -> (Vec<String>, Vec<f64>) {
|
||||
let mut after: Option<String> = None;
|
||||
let mut names = Vec::new();
|
||||
let mut times = Vec::new();
|
||||
loop {
|
||||
let t = Instant::now();
|
||||
let rows: Vec<Row> = if let Some(a) = &after {
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT {COLS}
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND NOT is_trashed AND name > $3
|
||||
ORDER BY name
|
||||
LIMIT $2"
|
||||
))
|
||||
.bind(parent)
|
||||
.bind(page)
|
||||
.bind(a)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT {COLS}
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND NOT is_trashed
|
||||
ORDER BY name
|
||||
LIMIT $2"
|
||||
))
|
||||
.bind(parent)
|
||||
.bind(page)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
.expect("keyset page");
|
||||
times.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
let n = rows.len();
|
||||
after = rows.last().map(|r| r.1.clone());
|
||||
names.extend(rows.into_iter().map(|r| r.1));
|
||||
if (n as i64) < page {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(names, times)
|
||||
}
|
||||
|
||||
fn median(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
let url = env::var("DATABASE_URL").expect("set DATABASE_URL");
|
||||
let dirs: usize = env_or("BENCH_DIRS", 5_000);
|
||||
let page: i64 = env_or("BENCH_PAGE", 500);
|
||||
let reps: usize = env_or("BENCH_REPS", 5);
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect");
|
||||
println!("seeding {dirs} subfolders (one-time)…");
|
||||
let (drive_id, folder_id) = seed(&pool, dirs).await;
|
||||
|
||||
let (ref_names, _) = walk_offset(&pool, folder_id, page).await;
|
||||
assert_eq!(ref_names.len(), dirs, "reference drain size");
|
||||
|
||||
println!("\n# full PROPFIND subfolder walk of a {dirs}-dir parent, {page}/page");
|
||||
println!(
|
||||
"{:<12} {:>11} {:>11} {:>8}",
|
||||
"mode", "total ms", "p50 ms/pg", "vs OLD"
|
||||
);
|
||||
|
||||
let mut failures = 0usize;
|
||||
let mut base: Option<f64> = None;
|
||||
for mode in ["OFFSET", "KEYSET"] {
|
||||
let mut totals = Vec::with_capacity(reps);
|
||||
let mut per_page: Vec<f64> = Vec::new();
|
||||
for _ in 0..reps {
|
||||
let t = Instant::now();
|
||||
let (names, times) = if mode == "OFFSET" {
|
||||
walk_offset(&pool, folder_id, page).await
|
||||
} else {
|
||||
walk_keyset(&pool, folder_id, page).await
|
||||
};
|
||||
totals.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
if names != ref_names {
|
||||
eprintln!("EQUIVALENCE FAILURE: {mode} drained a different sequence");
|
||||
failures += 1;
|
||||
}
|
||||
per_page = times;
|
||||
}
|
||||
let ms = median(totals);
|
||||
let speedup = base
|
||||
.map(|b| format!("{:.1}x", b / ms))
|
||||
.unwrap_or_else(|| "1.0x".into());
|
||||
if base.is_none() {
|
||||
base = Some(ms);
|
||||
}
|
||||
println!(
|
||||
"{:<12} {:>11.1} {:>11.2} {:>8}",
|
||||
mode,
|
||||
ms,
|
||||
median(per_page.clone()),
|
||||
speedup
|
||||
);
|
||||
}
|
||||
|
||||
let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1")
|
||||
.bind(drive_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
if failures > 0 {
|
||||
eprintln!("\n{failures} equivalence failures — the NEW shape is NOT safe to adopt");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -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<T: std::str::FromStr>(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<String>);
|
||||
|
||||
/// BEFORE — verbatim old query shape: two server-side `::text` casts,
|
||||
/// decode as String.
|
||||
async fn fetch_text_cast(pool: &PgPool, parent_id: Uuid) -> Vec<FolderTuple> {
|
||||
sqlx::query_as::<_, (String, String, String, Option<String>)>(
|
||||
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<FolderTuple> {
|
||||
let rows = sqlx::query_as::<_, (Uuid, String, String, Option<Uuid>)>(
|
||||
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<f64>) -> Stats {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let n = xs.len();
|
||||
Stats {
|
||||
mean_ms: xs.iter().sum::<f64>() / 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");
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Micro-alloc benchmark: digest-hex rendering and NC id-batch marshalling.
|
||||
//!
|
||||
//! Two round-6 changes, both equivalence-gated against their verbatim
|
||||
//! BEFORE shapes and measured with a counting allocator:
|
||||
//!
|
||||
//! 1. `IncrementalHasher::finalize_hex` (upload_ingest.rs) rendered MD5 /
|
||||
//! SHA-256 digests with `.map(|b| format!("{b:02x}")).collect()` — one
|
||||
//! heap `String` per digest byte (16 / 32 allocs) per chunk finalize.
|
||||
//! AFTER: `common::fmt::hex_lower` writes into one preallocated String.
|
||||
//!
|
||||
//! 2. `batch_resolve_ids` (NC webdav_handler) cloned every child id into a
|
||||
//! `Vec<String>` and the id service keyed its result map by `String` —
|
||||
//! ~3 heap allocs per child per page. AFTER the whole chain is borrowed:
|
||||
//! `Vec<&str>` in, `HashMap<Uuid, i64>` out, `Uuid::parse_str` lookups.
|
||||
//!
|
||||
//! Run:
|
||||
//! cargo run --release --features bench --example bench_hex_ids
|
||||
//! Tunables (env): BENCH_ITERS (10000), BENCH_CHILDREN (500).
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use md5::Digest;
|
||||
use oxicloud::common::fmt::hex_lower;
|
||||
use uuid::Uuid;
|
||||
|
||||
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<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn measure<R>(f: impl FnOnce() -> R) -> (R, u64, f64) {
|
||||
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
|
||||
let t = Instant::now();
|
||||
let r = f();
|
||||
let el = t.elapsed().as_secs_f64();
|
||||
let allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a0;
|
||||
(r, allocs, el)
|
||||
}
|
||||
|
||||
// ── 1. digest hex ───────────────────────────────────────────────────────────
|
||||
|
||||
/// BEFORE, verbatim: one `format!` per digest byte.
|
||||
fn hex_before(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn bench_hex(iters: usize) {
|
||||
// Deterministic digests of both production sizes (MD5=16, SHA-256=32).
|
||||
let md5s: Vec<[u8; 16]> = (0..64u64)
|
||||
.map(|i| md5::Md5::digest(i.to_le_bytes()).into())
|
||||
.collect();
|
||||
let sha256s: Vec<[u8; 32]> = (0..64u64)
|
||||
.map(|i| sha2::Sha256::digest(i.to_le_bytes()).into())
|
||||
.collect();
|
||||
|
||||
// Equivalence gate: byte-identical output on every digest.
|
||||
for d in &md5s {
|
||||
assert_eq!(hex_lower(d), hex_before(d), "md5 hex mismatch");
|
||||
}
|
||||
for d in &sha256s {
|
||||
assert_eq!(hex_lower(d), hex_before(d), "sha256 hex mismatch");
|
||||
}
|
||||
|
||||
println!("── finalize_hex: per-byte format! vs hex_lower ({iters} finalizes/arm) ──\n");
|
||||
println!(
|
||||
"| {:<8} | {:<8} | {:>12} | {:>10} | {:>12} |",
|
||||
"digest", "arm", "allocs", "wall ms", "allocs/call"
|
||||
);
|
||||
for (label, digests) in [("md5", md5s.len()), ("sha256", sha256s.len())] {
|
||||
for arm in ["BEFORE", "AFTER"] {
|
||||
let (sink, allocs, secs) = measure(|| {
|
||||
let mut sink = 0usize;
|
||||
for i in 0..iters {
|
||||
let s = match (label, arm) {
|
||||
("md5", "BEFORE") => hex_before(&md5s[i % digests]),
|
||||
("md5", "AFTER") => hex_lower(&md5s[i % digests]),
|
||||
("sha256", "BEFORE") => hex_before(&sha256s[i % digests]),
|
||||
_ => hex_lower(&sha256s[i % digests]),
|
||||
};
|
||||
sink += s.len();
|
||||
}
|
||||
sink
|
||||
});
|
||||
std::hint::black_box(sink);
|
||||
println!(
|
||||
"| {:<8} | {:<8} | {:>12} | {:>10.2} | {:>12.2} |",
|
||||
label,
|
||||
arm,
|
||||
allocs,
|
||||
secs * 1e3,
|
||||
allocs as f64 / iters as f64
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. NC id-batch marshalling ──────────────────────────────────────────────
|
||||
|
||||
/// BEFORE, verbatim caller+service marshalling: clone ids into `Vec<String>`,
|
||||
/// key the result map by cloned `String`, look children up by `&String`.
|
||||
fn ids_before(child_ids: &[String], nc: &HashMap<Uuid, i64>) -> Vec<Option<i64>> {
|
||||
let file_uuids: Vec<String> = child_ids.to_vec();
|
||||
let mut map: HashMap<String, i64> = HashMap::with_capacity(file_uuids.len());
|
||||
for raw in &file_uuids {
|
||||
let Ok(uuid) = Uuid::parse_str(raw) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(id) = nc.get(&uuid) {
|
||||
map.insert(raw.clone(), *id);
|
||||
}
|
||||
}
|
||||
child_ids.iter().map(|id| map.get(id).copied()).collect()
|
||||
}
|
||||
|
||||
/// AFTER: borrowed slice in, `Uuid`-keyed map out, parse-and-get lookups —
|
||||
/// the exact shapes now in `batch_resolve_ids` + `nc_id_of`.
|
||||
fn ids_after(child_ids: &[String], nc: &HashMap<Uuid, i64>) -> Vec<Option<i64>> {
|
||||
let file_uuids: Vec<&str> = child_ids.iter().map(String::as_str).collect();
|
||||
let mut map: HashMap<Uuid, i64> = HashMap::with_capacity(file_uuids.len());
|
||||
for raw in &file_uuids {
|
||||
let Ok(uuid) = Uuid::parse_str(raw) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(id) = nc.get(&uuid) {
|
||||
map.insert(uuid, *id);
|
||||
}
|
||||
}
|
||||
child_ids
|
||||
.iter()
|
||||
.map(|id| Uuid::parse_str(id).ok().and_then(|u| map.get(&u).copied()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bench_ids(pages: usize, children: usize) {
|
||||
// A PROPFIND page of `children` DTO ids (36-byte uuid strings) resolved
|
||||
// against the id service's numeric mapping.
|
||||
let uuids: Vec<Uuid> = (0..children).map(|_| Uuid::new_v4()).collect();
|
||||
let child_ids: Vec<String> = uuids.iter().map(|u| u.to_string()).collect();
|
||||
let nc: HashMap<Uuid, i64> = uuids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, u)| (*u, i as i64 + 1000))
|
||||
.collect();
|
||||
|
||||
// Equivalence gate: identical per-child resolution, including an
|
||||
// unparseable id and an unmapped-but-valid id.
|
||||
let mut gate_ids = child_ids.clone();
|
||||
gate_ids.push("not-a-uuid".to_string());
|
||||
gate_ids.push(Uuid::new_v4().to_string());
|
||||
assert_eq!(
|
||||
ids_before(&gate_ids, &nc),
|
||||
ids_after(&gate_ids, &nc),
|
||||
"id resolution mismatch"
|
||||
);
|
||||
|
||||
println!("\n── batch_resolve_ids marshalling: String-keyed vs borrowed+Uuid ──");
|
||||
println!(" ({pages} pages × {children} children/arm)\n");
|
||||
println!(
|
||||
"| {:<8} | {:>12} | {:>10} | {:>14} |",
|
||||
"arm", "allocs", "wall ms", "allocs/child"
|
||||
);
|
||||
for arm in ["BEFORE", "AFTER"] {
|
||||
let (sink, allocs, secs) = measure(|| {
|
||||
let mut sink = 0usize;
|
||||
for _ in 0..pages {
|
||||
let resolved = if arm == "BEFORE" {
|
||||
ids_before(&child_ids, &nc)
|
||||
} else {
|
||||
ids_after(&child_ids, &nc)
|
||||
};
|
||||
sink += resolved.iter().flatten().count();
|
||||
}
|
||||
sink
|
||||
});
|
||||
assert_eq!(sink, pages * children, "all children must resolve");
|
||||
println!(
|
||||
"| {:<8} | {:>12} | {:>10.2} | {:>14.3} |",
|
||||
arm,
|
||||
allocs,
|
||||
secs * 1e3,
|
||||
allocs as f64 / (pages * children) as f64
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let iters: usize = env_or("BENCH_ITERS", 10_000);
|
||||
let children: usize = env_or("BENCH_CHILDREN", 500);
|
||||
|
||||
bench_hex(iters);
|
||||
bench_ids(iters / 10, children);
|
||||
|
||||
println!("\n(BEFORE arms are verbatim replicas of the replaced shapes; equivalence");
|
||||
println!(" asserted before timing. Allocs counted via a wrapping GlobalAlloc.)");
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
//! Web-UI folder listing benchmark — whole-folder rescan vs keyset pushdown.
|
||||
//!
|
||||
//! `list_resources_paged` (folder_db_repository.rs) pages the SPA files view
|
||||
//! with a UNION-ALL CTE (folders + files) and applies the keyset cursor
|
||||
//! OUTSIDE the CTE on computed columns (`sort_str = LOWER(name)`,
|
||||
//! `folder_first`). Postgres therefore scans every remaining row of the
|
||||
//! folder and top-N-sorts it on EVERY page — a 20k-file folder pays a full
|
||||
//! rescan per 200-row page.
|
||||
//!
|
||||
//! The AFTER shape pushes the cursor into each branch as a sargable
|
||||
//! row-value comparison (`(LOWER(name), id) > ($str, $id)`), gives each
|
||||
//! branch its own `ORDER BY … LIMIT`, and adds two expression indexes:
|
||||
//! idx_files_folder_lname (folder_id, LOWER(name), id) WHERE NOT is_trashed
|
||||
//! idx_folders_parent_lname (parent_id, LOWER(name), id) WHERE NOT is_trashed
|
||||
//! The outer query then merges ≤ 2·limit pre-sorted rows.
|
||||
//!
|
||||
//! Modes (full drain of the folder in default "name" order, plus a
|
||||
//! modified_at parity check):
|
||||
//! OLD/no-idx — the true BEFORE
|
||||
//! OLD/idx — new indexes alone, old query shape
|
||||
//! NEW/idx — the AFTER
|
||||
//!
|
||||
//! Equivalence gate: the drained (type, id) sequence must be identical
|
||||
//! across all modes; a mismatch aborts with exit(1).
|
||||
//!
|
||||
//! Run (needs Postgres up; reads DATABASE_URL from .env):
|
||||
//! cargo run --release --features bench --example bench_listing_keyset
|
||||
//! Tunables: BENCH_FILES (20000), BENCH_DIRS (300), BENCH_PAGE (200),
|
||||
//! BENCH_REPS (3)
|
||||
|
||||
use std::env;
|
||||
use std::time::Instant;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
async fn seed(pool: &PgPool, files: usize, dirs: usize) -> (Uuid, Uuid) {
|
||||
let mut tx = pool.begin().await.expect("begin");
|
||||
let drive_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.drives (kind, quota_bytes) VALUES ('shared', NULL) RETURNING id",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("drive");
|
||||
let folder_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.folders (name, path, lpath, drive_id)
|
||||
VALUES ('bench_listing', '/bench_listing', 'bench_listing', $1) RETURNING id",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("folder");
|
||||
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
|
||||
.bind(folder_id)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("stamp");
|
||||
tx.commit().await.expect("commit");
|
||||
|
||||
// Mixed-case names so LOWER() actually differs from the raw column.
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.folders (name, path, lpath, parent_id, drive_id)
|
||||
SELECT 'Dir_' || LPAD(i::text, 6, '0'),
|
||||
'/bench_listing/Dir_' || LPAD(i::text, 6, '0'),
|
||||
('bench_listing.d' || i)::ltree,
|
||||
$1, $2
|
||||
FROM generate_series(1, $3) AS i",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.bind(drive_id)
|
||||
.bind(dirs as i32)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("dirs");
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.files
|
||||
(name, folder_id, blob_hash, size, mime_type, drive_id,
|
||||
updated_at, category_order)
|
||||
SELECT 'File_' || LPAD(i::text, 8, '0') || '.JPG', $1,
|
||||
'benchlisting0000000000000000000000000000000000000000000000000000',
|
||||
1024 + i, 'image/jpeg', $2,
|
||||
NOW() - (i || ' seconds')::interval,
|
||||
3
|
||||
FROM generate_series(1, $3) AS i",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.bind(drive_id)
|
||||
.bind(files as i32)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("files");
|
||||
sqlx::query("ANALYZE storage.files")
|
||||
.execute(pool)
|
||||
.await
|
||||
.ok();
|
||||
sqlx::query("ANALYZE storage.folders")
|
||||
.execute(pool)
|
||||
.await
|
||||
.ok();
|
||||
(drive_id, folder_id)
|
||||
}
|
||||
|
||||
const FOLDER_BRANCH: &str = r#"
|
||||
SELECT
|
||||
'folder'::text AS resource_type,
|
||||
f.id,
|
||||
f.name,
|
||||
f.parent_id AS folder_id,
|
||||
NULL::text AS mime_type,
|
||||
-1::bigint AS size,
|
||||
f.created_at,
|
||||
f.updated_at AS modified_at,
|
||||
f.drive_id,
|
||||
NULL::text AS blob_hash,
|
||||
LOWER(f.name) AS sort_str,
|
||||
0::bigint AS type_order,
|
||||
0::int AS folder_first
|
||||
FROM storage.folders f
|
||||
WHERE f.parent_id = $1::uuid AND NOT f.is_trashed
|
||||
"#;
|
||||
|
||||
const FILE_BRANCH: &str = r#"
|
||||
SELECT
|
||||
'file'::text AS resource_type,
|
||||
fm.id,
|
||||
fm.name,
|
||||
fm.folder_id,
|
||||
fm.mime_type,
|
||||
fm.size::bigint,
|
||||
fm.created_at,
|
||||
fm.updated_at AS modified_at,
|
||||
fm.drive_id,
|
||||
fm.blob_hash,
|
||||
LOWER(fm.name) AS sort_str,
|
||||
fm.category_order::bigint AS type_order,
|
||||
1::int AS folder_first
|
||||
FROM storage.files fm
|
||||
WHERE fm.folder_id = $1::uuid AND NOT fm.is_trashed
|
||||
"#;
|
||||
|
||||
const COLS: &str = "resource_type, id, name, folder_id, mime_type, size, \
|
||||
created_at, modified_at, drive_id, blob_hash, \
|
||||
sort_str, type_order, folder_first";
|
||||
|
||||
type Row = (
|
||||
String,
|
||||
Uuid,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Option<String>,
|
||||
i64,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
Uuid,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
i32,
|
||||
);
|
||||
|
||||
/// Cursor state for the walks: (folder_first, sort_str, modified_at, id).
|
||||
#[derive(Clone)]
|
||||
struct Cur {
|
||||
ff: i64,
|
||||
sort_str: String,
|
||||
ts: chrono::DateTime<chrono::Utc>,
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
/// OLD shape, "name" order — production SQL verbatim: cursor OUTSIDE the CTE.
|
||||
async fn old_page_name(pool: &PgPool, parent: Uuid, cur: Option<&Cur>, limit: i64) -> Vec<Row> {
|
||||
let sql = format!(
|
||||
"WITH resources AS ({FOLDER_BRANCH} UNION ALL {FILE_BRANCH}) \
|
||||
SELECT {COLS} FROM resources \
|
||||
WHERE ($3::bigint IS NULL) \
|
||||
OR (folder_first::bigint > $3) \
|
||||
OR (folder_first::bigint = $3 AND sort_str > $2) \
|
||||
OR (folder_first::bigint = $3 AND sort_str = $2 AND id > $5::uuid) \
|
||||
ORDER BY folder_first ASC, sort_str ASC, id ASC \
|
||||
LIMIT $6"
|
||||
);
|
||||
sqlx::query_as(&sql)
|
||||
.bind(parent)
|
||||
.bind(cur.map(|c| c.sort_str.clone()))
|
||||
.bind(cur.map(|c| c.ff))
|
||||
.bind(cur.map(|c| c.ts))
|
||||
.bind(cur.map(|c| c.id))
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("old name page")
|
||||
}
|
||||
|
||||
/// NEW shape, "name" order — cursor pushed into each branch as a sargable
|
||||
/// row-value comparison; each branch pre-sorts and pre-limits.
|
||||
async fn new_page_name(pool: &PgPool, parent: Uuid, cur: Option<&Cur>, limit: i64) -> Vec<Row> {
|
||||
match cur {
|
||||
None => {
|
||||
let sql = format!(
|
||||
"SELECT {COLS} FROM ( \
|
||||
(SELECT * FROM ({FOLDER_BRANCH}) fb \
|
||||
ORDER BY sort_str ASC, id ASC LIMIT $2) \
|
||||
UNION ALL \
|
||||
(SELECT * FROM ({FILE_BRANCH}) lb \
|
||||
ORDER BY sort_str ASC, id ASC LIMIT $2) \
|
||||
) r ORDER BY folder_first ASC, sort_str ASC, id ASC LIMIT $2"
|
||||
);
|
||||
sqlx::query_as(&sql)
|
||||
.bind(parent)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("new name page (first)")
|
||||
}
|
||||
Some(c) if c.ff == 0 => {
|
||||
// Cursor sits in the folder group: folders continue after the
|
||||
// row-value cursor; ALL files still follow.
|
||||
let sql = format!(
|
||||
"SELECT {COLS} FROM ( \
|
||||
(SELECT * FROM ({FOLDER_BRANCH} \
|
||||
AND (LOWER(f.name), f.id) > ($3, $4::uuid)) fb \
|
||||
ORDER BY sort_str ASC, id ASC LIMIT $2) \
|
||||
UNION ALL \
|
||||
(SELECT * FROM ({FILE_BRANCH}) lb \
|
||||
ORDER BY sort_str ASC, id ASC LIMIT $2) \
|
||||
) r ORDER BY folder_first ASC, sort_str ASC, id ASC LIMIT $2"
|
||||
);
|
||||
sqlx::query_as(&sql)
|
||||
.bind(parent)
|
||||
.bind(limit)
|
||||
.bind(&c.sort_str)
|
||||
.bind(c.id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("new name page (folder cursor)")
|
||||
}
|
||||
Some(c) => {
|
||||
// Cursor sits in the file group: the folder branch is exhausted.
|
||||
let sql = format!(
|
||||
"SELECT {COLS} FROM ( \
|
||||
SELECT * FROM ({FILE_BRANCH} \
|
||||
AND (LOWER(fm.name), fm.id) > ($3, $4::uuid)) lb \
|
||||
ORDER BY sort_str ASC, id ASC LIMIT $2 \
|
||||
) r ORDER BY folder_first ASC, sort_str ASC, id ASC LIMIT $2"
|
||||
);
|
||||
sqlx::query_as(&sql)
|
||||
.bind(parent)
|
||||
.bind(limit)
|
||||
.bind(&c.sort_str)
|
||||
.bind(c.id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("new name page (file cursor)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OLD shape, "modified_at" order (newest first) — production SQL verbatim.
|
||||
async fn old_page_modified(pool: &PgPool, parent: Uuid, cur: Option<&Cur>, limit: i64) -> Vec<Row> {
|
||||
let sql = format!(
|
||||
"WITH resources AS ({FOLDER_BRANCH} UNION ALL {FILE_BRANCH}) \
|
||||
SELECT {COLS} FROM resources \
|
||||
WHERE ($4::timestamptz IS NULL) \
|
||||
OR (modified_at < $4) \
|
||||
OR (modified_at = $4 AND id < $5::uuid) \
|
||||
ORDER BY modified_at DESC, id DESC \
|
||||
LIMIT $6"
|
||||
);
|
||||
sqlx::query_as(&sql)
|
||||
.bind(parent)
|
||||
.bind(cur.map(|c| c.sort_str.clone()))
|
||||
.bind(cur.map(|c| c.ff))
|
||||
.bind(cur.map(|c| c.ts))
|
||||
.bind(cur.map(|c| c.id))
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("old modified page")
|
||||
}
|
||||
|
||||
/// NEW shape, "modified_at" order — per-branch row-value cursor + LIMIT.
|
||||
async fn new_page_modified(pool: &PgPool, parent: Uuid, cur: Option<&Cur>, limit: i64) -> Vec<Row> {
|
||||
match cur {
|
||||
None => {
|
||||
let sql = format!(
|
||||
"SELECT {COLS} FROM ( \
|
||||
(SELECT * FROM ({FOLDER_BRANCH}) fb \
|
||||
ORDER BY modified_at DESC, id DESC LIMIT $2) \
|
||||
UNION ALL \
|
||||
(SELECT * FROM ({FILE_BRANCH}) lb \
|
||||
ORDER BY modified_at DESC, id DESC LIMIT $2) \
|
||||
) r ORDER BY modified_at DESC, id DESC LIMIT $2"
|
||||
);
|
||||
sqlx::query_as(&sql)
|
||||
.bind(parent)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("new modified page (first)")
|
||||
}
|
||||
Some(c) => {
|
||||
let sql = format!(
|
||||
"SELECT {COLS} FROM ( \
|
||||
(SELECT * FROM ({FOLDER_BRANCH} \
|
||||
AND (f.updated_at, f.id) < ($3, $4::uuid)) fb \
|
||||
ORDER BY modified_at DESC, id DESC LIMIT $2) \
|
||||
UNION ALL \
|
||||
(SELECT * FROM ({FILE_BRANCH} \
|
||||
AND (fm.updated_at, fm.id) < ($3, $4::uuid)) lb \
|
||||
ORDER BY modified_at DESC, id DESC LIMIT $2) \
|
||||
) r ORDER BY modified_at DESC, id DESC LIMIT $2"
|
||||
);
|
||||
sqlx::query_as(&sql)
|
||||
.bind(parent)
|
||||
.bind(limit)
|
||||
.bind(c.ts)
|
||||
.bind(c.id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("new modified page (cursor)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the whole folder; returns ((type, id) sequence, per-page ms).
|
||||
async fn drain(
|
||||
pool: &PgPool,
|
||||
parent: Uuid,
|
||||
limit: i64,
|
||||
new_shape: bool,
|
||||
by_modified: bool,
|
||||
) -> (Vec<(String, Uuid)>, Vec<f64>) {
|
||||
let mut cur: Option<Cur> = None;
|
||||
let mut seq = Vec::new();
|
||||
let mut page_ms = Vec::new();
|
||||
loop {
|
||||
let t = Instant::now();
|
||||
let rows = match (new_shape, by_modified) {
|
||||
(false, false) => old_page_name(pool, parent, cur.as_ref(), limit).await,
|
||||
(true, false) => new_page_name(pool, parent, cur.as_ref(), limit).await,
|
||||
(false, true) => old_page_modified(pool, parent, cur.as_ref(), limit).await,
|
||||
(true, true) => new_page_modified(pool, parent, cur.as_ref(), limit).await,
|
||||
};
|
||||
page_ms.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
let n = rows.len();
|
||||
if let Some(last) = rows.last() {
|
||||
cur = Some(Cur {
|
||||
ff: last.12 as i64,
|
||||
sort_str: last.10.clone(),
|
||||
ts: last.7,
|
||||
id: last.1,
|
||||
});
|
||||
}
|
||||
seq.extend(rows.into_iter().map(|r| (r.0, r.1)));
|
||||
if (n as i64) < limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(seq, page_ms)
|
||||
}
|
||||
|
||||
async fn set_indexes(pool: &PgPool, on: bool) {
|
||||
if on {
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_files_folder_lname
|
||||
ON storage.files (folder_id, LOWER(name), id) WHERE NOT is_trashed",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("files idx");
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_folders_parent_lname
|
||||
ON storage.folders (parent_id, LOWER(name), id) WHERE NOT is_trashed",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("folders idx");
|
||||
} else {
|
||||
sqlx::query("DROP INDEX IF EXISTS storage.idx_files_folder_lname")
|
||||
.execute(pool)
|
||||
.await
|
||||
.ok();
|
||||
sqlx::query("DROP INDEX IF EXISTS storage.idx_folders_parent_lname")
|
||||
.execute(pool)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn median(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
fn p99(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[(xs.len() as f64 * 0.99) as usize % xs.len()]
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
let url = env::var("DATABASE_URL").expect("set DATABASE_URL");
|
||||
let files: usize = env_or("BENCH_FILES", 20_000);
|
||||
let dirs: usize = env_or("BENCH_DIRS", 300);
|
||||
let page: i64 = env_or("BENCH_PAGE", 200);
|
||||
let reps: usize = env_or("BENCH_REPS", 3);
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect");
|
||||
println!("seeding {files} files + {dirs} dirs (one-time)…");
|
||||
let (drive_id, folder_id) = seed(&pool, files, dirs).await;
|
||||
let total = files + dirs;
|
||||
|
||||
// Reference sequences for the equivalence gate (computed once per mode).
|
||||
set_indexes(&pool, false).await;
|
||||
let (ref_name, _) = drain(&pool, folder_id, page, false, false).await;
|
||||
let (ref_modified, _) = drain(&pool, folder_id, page, false, true).await;
|
||||
assert_eq!(ref_name.len(), total, "name drain row count");
|
||||
assert_eq!(ref_modified.len(), total, "modified drain row count");
|
||||
|
||||
println!("\n# full SPA-listing drain of a {files}-file/{dirs}-dir folder, {page}/page");
|
||||
println!(
|
||||
"{:<28} {:>11} {:>11} {:>11} {:>8}",
|
||||
"mode", "total ms", "p50 ms/pg", "p99 ms/pg", "vs OLD"
|
||||
);
|
||||
|
||||
let mut failures = 0usize;
|
||||
for by_modified in [false, true] {
|
||||
let label = if by_modified { "modified_at" } else { "name" };
|
||||
let reference = if by_modified {
|
||||
&ref_modified
|
||||
} else {
|
||||
&ref_name
|
||||
};
|
||||
let mut base: Option<f64> = None;
|
||||
for (mode, new_shape, idx) in [
|
||||
("OLD/no-idx", false, false),
|
||||
("OLD/idx", false, true),
|
||||
("NEW/idx", true, true),
|
||||
] {
|
||||
set_indexes(&pool, idx).await;
|
||||
let mut totals = Vec::with_capacity(reps);
|
||||
let mut pages: Vec<f64> = Vec::new();
|
||||
for _ in 0..reps {
|
||||
let t = Instant::now();
|
||||
let (seq, page_ms) = drain(&pool, folder_id, page, new_shape, by_modified).await;
|
||||
totals.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
if &seq != reference {
|
||||
eprintln!("EQUIVALENCE FAILURE: {label}/{mode} drained a different sequence");
|
||||
failures += 1;
|
||||
}
|
||||
pages = page_ms;
|
||||
}
|
||||
let ms = median(totals);
|
||||
let speedup = base
|
||||
.map(|b| format!("{:.1}x", b / ms))
|
||||
.unwrap_or_else(|| "1.0x".into());
|
||||
if base.is_none() {
|
||||
base = Some(ms);
|
||||
}
|
||||
println!(
|
||||
"{:<28} {:>11.1} {:>11.2} {:>11.2} {:>8}",
|
||||
format!("{label} {mode}"),
|
||||
ms,
|
||||
median(pages.clone()),
|
||||
p99(pages.clone()),
|
||||
speedup
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1")
|
||||
.bind(drive_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
// Leave the new indexes in place (they are the production migration).
|
||||
|
||||
if failures > 0 {
|
||||
eprintln!("\n{failures} equivalence failures — the NEW shape is NOT safe to adopt");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
//! Round-5 micro-allocation pack — per-request/per-row churn removed
|
||||
//! from five hot paths. Each section is BEFORE (verbatim old shape) vs
|
||||
//! AFTER (the shipped code or its exact pattern), with byte/structure
|
||||
//! equality gates. No Postgres.
|
||||
//!
|
||||
//! [1] search suggest enrichment: entity clone + 3 field re-clones per
|
||||
//! row → consume + move.
|
||||
//! [2] `list_readable_by` warm hit: deep `Vec<DriveWithRootName>`
|
||||
//! clone per request → `Arc` refcount bump.
|
||||
//! [3] SPA listing rows (folder/recent/favorites handlers): raw
|
||||
//! `Arc::from` per closed-set display field → `intern_display` /
|
||||
//! `intern_mime` lookups.
|
||||
//! [4] NC PROPFIND child hrefs: per-row re-encode of username + parent
|
||||
//! path (`nc_href`) → prefix precomputed once + name-only encode.
|
||||
//! [5] CardDAV REPORT (getetag poll): per-REPORT props clone +
|
||||
//! per-contact href String + etag `format!` → borrowed props,
|
||||
//! reused href buffer, exact-size quoting.
|
||||
//!
|
||||
//! Run (no Postgres needed):
|
||||
//! cargo run --release --features bench --example bench_micro_allocs
|
||||
//! Tunables (env): BENCH_ROWS (5000), BENCH_PASSES (60).
|
||||
|
||||
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 chrono::{TimeZone, Utc};
|
||||
use oxicloud::application::adapters::carddav_adapter::{CardDavAdapter, CardDavReportType};
|
||||
use oxicloud::application::adapters::webdav_adapter::QualifiedName;
|
||||
use oxicloud::application::dtos::contact_dto::ContactDto;
|
||||
use oxicloud::application::dtos::display_helpers::{
|
||||
category_for, icon_class_for, icon_special_class_for, intern_display, intern_mime,
|
||||
};
|
||||
use oxicloud::application::dtos::file_dto::FileDto;
|
||||
use oxicloud::application::dtos::search_dto::SearchSuggestionItem;
|
||||
use oxicloud::domain::entities::drive::{Drive, DriveKind};
|
||||
use oxicloud::domain::entities::file::File;
|
||||
use oxicloud::domain::repositories::drive_repository::DriveWithRootName;
|
||||
use oxicloud::interfaces::nextcloud::webdav_handler::nc_href;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ─── 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 p50(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
fn time_passes<T>(passes: usize, mut f: impl FnMut() -> T) -> f64 {
|
||||
let mut per = Vec::with_capacity(passes);
|
||||
for _ in 0..passes {
|
||||
let t0 = Instant::now();
|
||||
black_box(f());
|
||||
per.push(t0.elapsed().as_secs_f64() * 1e6);
|
||||
}
|
||||
p50(per)
|
||||
}
|
||||
|
||||
fn allocs_of<T>(mut f: impl FnMut() -> T) -> u64 {
|
||||
let s0 = ALLOC_CALLS.load(Ordering::Relaxed);
|
||||
black_box(f());
|
||||
ALLOC_CALLS.load(Ordering::Relaxed) - s0
|
||||
}
|
||||
|
||||
// ─── Corpus builders ────────────────────────────────────────────────────────
|
||||
|
||||
fn make_files(n: usize) -> Vec<File> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
File::from_materialized_row(
|
||||
Uuid::from_u128(i as u128).to_string(),
|
||||
format!("documento-{i}.pdf"),
|
||||
Some("/Personal/Proyectos/2026"),
|
||||
1024 + i as u64,
|
||||
"application/pdf".to_string(),
|
||||
None,
|
||||
1_700_000_000,
|
||||
1_750_000_000,
|
||||
format!("{:032x}", i),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("file")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn compute_relevance(name: &str, q: &str) -> u32 {
|
||||
if name.to_lowercase().contains(q) {
|
||||
100
|
||||
} else {
|
||||
50
|
||||
}
|
||||
}
|
||||
|
||||
/// The suggest enrichment loop — BEFORE: per-row entity clone + field
|
||||
/// re-clones (verbatim old shape, icon helper substituted identically
|
||||
/// on both arms).
|
||||
fn suggest_before(files: &[File], q: &str) -> Vec<SearchSuggestionItem> {
|
||||
let mut out = Vec::new();
|
||||
let query_lower = q.to_lowercase();
|
||||
for file in files {
|
||||
let file_dto = FileDto::from(file.clone());
|
||||
let score = compute_relevance(&file_dto.name, &query_lower);
|
||||
out.push(SearchSuggestionItem {
|
||||
name: file_dto.name.clone(),
|
||||
item_type: "file".to_string(),
|
||||
id: file_dto.id.clone(),
|
||||
path: file_dto.path.clone(),
|
||||
// `.into()` bridges the round-9 `Arc<str>` 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()
|
||||
.into(),
|
||||
relevance_score: score,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// AFTER: consume + move (the shipped shape).
|
||||
fn suggest_after(files: Vec<File>, q: &str) -> Vec<SearchSuggestionItem> {
|
||||
let mut out = Vec::new();
|
||||
let query_lower = q.to_lowercase();
|
||||
for file in files {
|
||||
let file_dto = FileDto::from(file);
|
||||
let score = compute_relevance(&file_dto.name, &query_lower);
|
||||
let icon_class = icon_class_for(&file_dto.name, &file_dto.mime_type).to_string();
|
||||
let icon_special_class =
|
||||
icon_special_class_for(&file_dto.name, &file_dto.mime_type).to_string();
|
||||
out.push(SearchSuggestionItem {
|
||||
name: file_dto.name,
|
||||
item_type: "file".to_string(),
|
||||
id: file_dto.id,
|
||||
path: file_dto.path,
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn make_drives(n: usize) -> Vec<DriveWithRootName> {
|
||||
(0..n)
|
||||
.map(|i| DriveWithRootName {
|
||||
drive: Drive {
|
||||
id: Uuid::from_u128(i as u128),
|
||||
kind: if i == 0 {
|
||||
DriveKind::Personal
|
||||
} else {
|
||||
DriveKind::Shared
|
||||
},
|
||||
default_for_user: (i == 0).then(|| Uuid::from_u128(999)),
|
||||
root_folder_id: Uuid::from_u128(1000 + i as u128),
|
||||
quota_bytes: Some(10_737_418_240),
|
||||
used_bytes: 123_456_789,
|
||||
policies: serde_json::json!({}),
|
||||
created_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
|
||||
},
|
||||
root_folder_name: format!("Drive número {i}"),
|
||||
caller_role: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn make_contacts(n: usize) -> Vec<ContactDto> {
|
||||
(0..n)
|
||||
.map(|i| ContactDto {
|
||||
id: Uuid::from_u128(i as u128).to_string(),
|
||||
uid: format!("contact-{i:05}"),
|
||||
etag: format!("{:016x}", i * 2_654_435_761u64 as usize),
|
||||
full_name: Some(format!("Persona {i}")),
|
||||
..ContactDto::default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// BEFORE replica of the CardDAV REPORT emitter (props.clone + per-row
|
||||
// href String + etag format!) for the getetag poll shape — the
|
||||
// address-data branch is never hit with this prop set, so the replica
|
||||
// stays self-contained.
|
||||
mod before_carddav {
|
||||
use super::*;
|
||||
use quick_xml::Writer;
|
||||
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
|
||||
|
||||
pub fn generate_contacts_response(
|
||||
out: &mut Vec<u8>,
|
||||
contacts: &[ContactDto],
|
||||
report: &CardDavReportType,
|
||||
base_href: &str,
|
||||
) {
|
||||
let mut xml_writer = Writer::new(out);
|
||||
xml_writer
|
||||
.write_event(Event::Start(
|
||||
BytesStart::new("D:multistatus").with_attributes([
|
||||
("xmlns:D", "DAV:"),
|
||||
("xmlns:CR", "urn:ietf:params:xml:ns:carddav"),
|
||||
]),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let props = match report {
|
||||
CardDavReportType::AddressbookQuery { props } => props.clone(),
|
||||
CardDavReportType::AddressbookMultiget { props, .. } => props.clone(),
|
||||
CardDavReportType::SyncCollection { props, .. } => props.clone(),
|
||||
};
|
||||
|
||||
for contact in contacts {
|
||||
let href = format!("{}{}.vcf", base_href, contact.uid);
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:response")))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:href")))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&href)))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("D:href")))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:propstat")))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:prop")))
|
||||
.unwrap();
|
||||
for prop in &props {
|
||||
match (prop.namespace.as_str(), prop.name.as_str()) {
|
||||
("DAV:", "resourcetype") => {
|
||||
xml_writer
|
||||
.write_event(Event::Empty(BytesStart::new("D:resourcetype")))
|
||||
.unwrap();
|
||||
}
|
||||
("DAV:", "getetag") => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getetag")))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&format!(
|
||||
"\"{}\"",
|
||||
contact.etag
|
||||
))))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("D:getetag")))
|
||||
.unwrap();
|
||||
}
|
||||
("DAV:", "getcontenttype") => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getcontenttype")))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new("text/vcard; charset=utf-8")))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("D:getcontenttype")))
|
||||
.unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("D:prop")))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:status")))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("D:status")))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("D:propstat")))
|
||||
.unwrap();
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("D:response")))
|
||||
.unwrap();
|
||||
}
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("D:multistatus")))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let rows: usize = env::var("BENCH_ROWS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5000);
|
||||
let passes: usize = env::var("BENCH_PASSES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
let mut ok = true;
|
||||
|
||||
println!("bench_micro_allocs — {rows} rows, {passes} passes\n");
|
||||
|
||||
// ── [1] suggest enrichment ──────────────────────────────────────────────
|
||||
{
|
||||
let files = make_files(200); // suggest is limit-bounded (~10-200)
|
||||
let t_b = time_passes(passes, || suggest_before(&files, "doc"));
|
||||
// Production AFTER consumes the caller's Vec — no clone exists.
|
||||
// The replay clone happens OUTSIDE the timed window.
|
||||
let t_a = {
|
||||
let mut per = Vec::with_capacity(passes);
|
||||
for _ in 0..passes {
|
||||
let corpus = files.clone();
|
||||
let t0 = Instant::now();
|
||||
black_box(suggest_after(corpus, "doc"));
|
||||
per.push(t0.elapsed().as_secs_f64() * 1e6);
|
||||
}
|
||||
p50(per)
|
||||
};
|
||||
// Alloc parity: charge the corpus clone to neither arm by
|
||||
// measuring BEFORE with its borrow (clones inside) and AFTER
|
||||
// seeded from a pre-cloned Vec outside the counter window.
|
||||
let a_b = allocs_of(|| suggest_before(&files, "doc")) as f64 / files.len() as f64;
|
||||
let mut pre = Some(files.clone());
|
||||
let a_a =
|
||||
allocs_of(|| suggest_after(pre.take().unwrap(), "doc")) as f64 / files.len() as f64;
|
||||
let g_b = suggest_before(&files, "doc");
|
||||
let g_a = suggest_after(files.clone(), "doc");
|
||||
let same = g_b.len() == g_a.len()
|
||||
&& g_b.iter().zip(&g_a).all(|(x, y)| {
|
||||
x.name == y.name && x.id == y.id && x.path == y.path && x.icon_class == y.icon_class
|
||||
});
|
||||
if !same {
|
||||
eprintln!("GATE FAIL suggest");
|
||||
ok = false;
|
||||
}
|
||||
println!("[1] suggest enrichment (200 rows) µs/pass allocs/row");
|
||||
println!(" BEFORE (clone per row) {t_b:8.1} {a_b:7.2}");
|
||||
println!(
|
||||
" AFTER (consume + move) {t_a:8.1} {a_a:7.2} {:.2}x",
|
||||
t_b / t_a
|
||||
);
|
||||
}
|
||||
|
||||
// ── [2] readable-drives warm hit ────────────────────────────────────────
|
||||
{
|
||||
let value = Arc::new(make_drives(3));
|
||||
let cache: moka::sync::Cache<Uuid, Arc<Vec<DriveWithRootName>>> =
|
||||
moka::sync::Cache::new(100);
|
||||
let user = Uuid::from_u128(42);
|
||||
cache.insert(user, value);
|
||||
let hit_before = || {
|
||||
let arc = cache.get(&user).expect("warm");
|
||||
let v: Vec<DriveWithRootName> = (*arc).clone(); // old: deep clone out
|
||||
v
|
||||
};
|
||||
let hit_after = || cache.get(&user).expect("warm"); // new: Arc bump
|
||||
let n_iters = 10_000u32;
|
||||
let t_b = time_passes(passes, || {
|
||||
for _ in 0..n_iters {
|
||||
black_box(hit_before());
|
||||
}
|
||||
}) / n_iters as f64
|
||||
* 1000.0;
|
||||
let t_a = time_passes(passes, || {
|
||||
for _ in 0..n_iters {
|
||||
black_box(hit_after());
|
||||
}
|
||||
}) / n_iters as f64
|
||||
* 1000.0;
|
||||
let a_b = allocs_of(hit_before);
|
||||
let a_a = allocs_of(hit_after);
|
||||
let g = hit_before();
|
||||
let ga = hit_after();
|
||||
if g.len() != ga.len() || g[0].root_folder_name != ga[0].root_folder_name {
|
||||
eprintln!("GATE FAIL readable hit");
|
||||
ok = false;
|
||||
}
|
||||
println!("[2] list_readable_by warm hit (3 drives) ns/hit allocs/hit");
|
||||
println!(" BEFORE (deep Vec clone) {t_b:8.1} {a_b:7}");
|
||||
println!(
|
||||
" AFTER (Arc refcount bump) {t_a:8.1} {a_a:7} {:.1}x",
|
||||
t_b / t_a
|
||||
);
|
||||
}
|
||||
|
||||
// ── [3] SPA listing closed-set fields ───────────────────────────────────
|
||||
{
|
||||
let names: Vec<String> = (0..rows).map(|i| format!("informe-{i}.pdf")).collect();
|
||||
let mime = "application/pdf";
|
||||
let row_before = |name: &str| {
|
||||
(
|
||||
Arc::<str>::from(mime),
|
||||
Arc::<str>::from(icon_class_for(name, mime)),
|
||||
Arc::<str>::from(icon_special_class_for(name, mime)),
|
||||
Arc::<str>::from(category_for(name, mime)),
|
||||
)
|
||||
};
|
||||
let row_after = |name: &str| {
|
||||
(
|
||||
intern_mime(mime),
|
||||
intern_display(icon_class_for(name, mime)),
|
||||
intern_display(icon_special_class_for(name, mime)),
|
||||
intern_display(category_for(name, mime)),
|
||||
)
|
||||
};
|
||||
let t_b = time_passes(passes, || {
|
||||
for n in &names {
|
||||
black_box(row_before(n));
|
||||
}
|
||||
}) / rows as f64
|
||||
* 1000.0;
|
||||
let t_a = time_passes(passes, || {
|
||||
for n in &names {
|
||||
black_box(row_after(n));
|
||||
}
|
||||
}) / rows as f64
|
||||
* 1000.0;
|
||||
let a_b = allocs_of(|| row_before(&names[0]));
|
||||
let a_a = allocs_of(|| row_after(&names[0]));
|
||||
let (bm, bi, bs, bc) = row_before(&names[0]);
|
||||
let (am, ai, as_, ac) = row_after(&names[0]);
|
||||
if *bm != *am || *bi != *ai || *bs != *as_ || *bc != *ac {
|
||||
eprintln!("GATE FAIL interning content");
|
||||
ok = false;
|
||||
}
|
||||
println!("[3] listing closed-set fields ns/row allocs/row");
|
||||
println!(" BEFORE (Arc::from ×4) {t_b:8.1} {a_b:7}");
|
||||
println!(
|
||||
" AFTER (intern lookups ×4) {t_a:8.1} {a_a:7} {:.1}x",
|
||||
t_b / t_a
|
||||
);
|
||||
}
|
||||
|
||||
// ── [4] NC PROPFIND child hrefs ─────────────────────────────────────────
|
||||
{
|
||||
let username = "ana.garcia";
|
||||
let subpath = "Personal/Proyectos 2026/Diseño";
|
||||
let names: Vec<String> = (0..rows)
|
||||
.map(|i| format!("archivo con espacios {i}.png"))
|
||||
.collect();
|
||||
// Verbatim replica of the production shape — `subpath` is a
|
||||
// const here, so the emptiness test is statically known.
|
||||
#[allow(clippy::const_is_empty)]
|
||||
let href_before = |name: &str| {
|
||||
let child_sub = if subpath.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", subpath.trim_end_matches('/'), name)
|
||||
};
|
||||
nc_href(username, &child_sub)
|
||||
};
|
||||
let prefix = {
|
||||
let base = nc_href(username, subpath);
|
||||
if base.ends_with('/') {
|
||||
base
|
||||
} else {
|
||||
format!("{base}/")
|
||||
}
|
||||
};
|
||||
let href_after = |name: &str| format!("{}{}", prefix, urlencoding::encode(name));
|
||||
let t_b = time_passes(passes, || {
|
||||
for n in &names {
|
||||
black_box(href_before(n));
|
||||
}
|
||||
}) / rows as f64
|
||||
* 1000.0;
|
||||
let t_a = time_passes(passes, || {
|
||||
for n in &names {
|
||||
black_box(href_after(n));
|
||||
}
|
||||
}) / rows as f64
|
||||
* 1000.0;
|
||||
let a_b = allocs_of(|| href_before(&names[0]));
|
||||
let a_a = allocs_of(|| href_after(&names[0]));
|
||||
for n in names.iter().take(50) {
|
||||
if href_before(n) != href_after(n) {
|
||||
eprintln!("GATE FAIL href: {} != {}", href_before(n), href_after(n));
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
println!("[4] NC child hrefs (depth-3 parent) ns/row allocs/row");
|
||||
println!(" BEFORE (nc_href per row) {t_b:8.1} {a_b:7}");
|
||||
println!(
|
||||
" AFTER (prefix + name encode) {t_a:8.1} {a_a:7} {:.1}x",
|
||||
t_b / t_a
|
||||
);
|
||||
}
|
||||
|
||||
// ── [5] CardDAV REPORT getetag poll ─────────────────────────────────────
|
||||
{
|
||||
let contacts = make_contacts(rows);
|
||||
let report = CardDavReportType::AddressbookQuery {
|
||||
props: vec![
|
||||
QualifiedName::new("DAV:", "getetag"),
|
||||
QualifiedName::new("DAV:", "getcontenttype"),
|
||||
],
|
||||
};
|
||||
let base = "/carddav/libreta/";
|
||||
let run_before = || {
|
||||
let mut out = Vec::with_capacity(contacts.len() * 256);
|
||||
before_carddav::generate_contacts_response(&mut out, &contacts, &report, base);
|
||||
out
|
||||
};
|
||||
let run_after = || {
|
||||
let mut out = Vec::with_capacity(contacts.len() * 256);
|
||||
CardDavAdapter::generate_contacts_response(&mut out, &contacts, &report, base)
|
||||
.expect("generate");
|
||||
out
|
||||
};
|
||||
let t_b = time_passes(passes.min(30), run_before);
|
||||
let t_a = time_passes(passes.min(30), run_after);
|
||||
let xb = run_before();
|
||||
let xa = run_after();
|
||||
if xb != xa {
|
||||
let at = xb.iter().zip(&xa).position(|(a, b)| a != b).unwrap_or(0);
|
||||
eprintln!(
|
||||
"GATE FAIL carddav at byte {at}: …{}… vs …{}…",
|
||||
String::from_utf8_lossy(&xb[at.saturating_sub(60)..(at + 60).min(xb.len())]),
|
||||
String::from_utf8_lossy(&xa[at.saturating_sub(60)..(at + 60).min(xa.len())]),
|
||||
);
|
||||
ok = false;
|
||||
}
|
||||
println!("[5] CardDAV REPORT getetag ({rows} contacts) µs/report");
|
||||
println!(" BEFORE (clone + format! churn) {t_b:8.1}");
|
||||
println!(
|
||||
" AFTER (borrow + reuse + exact-size) {t_a:8.1} {:.2}x",
|
||||
t_b / t_a
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n[gate] {}",
|
||||
if ok {
|
||||
"OK (identical outputs)"
|
||||
} else {
|
||||
"FAILED"
|
||||
}
|
||||
);
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
//! Grant-listing hydration N+1 benchmark + user-flags herd (ROUND4).
|
||||
//!
|
||||
//! [1-3] After `list_incoming_grants`, the CalDAV calendar discovery,
|
||||
//! CardDAV book discovery and playlist listing each hydrated their K
|
||||
//! accessible resources with K SERIAL point SELECTs (one
|
||||
//! `WHERE id = $1` round-trip per resource, awaited in a loop) on every
|
||||
//! client sync poll / dashboard load. AFTER: one `WHERE id = ANY($1)`
|
||||
//! round-trip via the new `find_*_by_ids` batch methods — this bench
|
||||
//! drives the REAL repositories both ways (the single-get methods still
|
||||
//! exist for point lookups).
|
||||
//!
|
||||
//! [4] `get_user_flags` (called by the auth middleware on EVERY
|
||||
//! authenticated request) used a get→insert cache: on each 30 s TTL
|
||||
//! expiry, all in-flight requests of that user fired the SELECT
|
||||
//! concurrently. AFTER: `try_get_with` single-flight. The bench
|
||||
//! replicates both cache patterns around the real `UserPgRepository`
|
||||
//! query, herd-style.
|
||||
//!
|
||||
//! Equivalence gates: identical id sets from loop vs batch for all
|
||||
//! three resources; identical flags from every herd caller.
|
||||
//!
|
||||
//! Run (needs Postgres up; reads DATABASE_URL from .env):
|
||||
//! cargo run --release --features bench --example bench_n1_hydration
|
||||
//! Tunables (env): BENCH_RESOURCES (15), BENCH_PASSES (200), BENCH_HERD (32).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use oxicloud::domain::repositories::address_book_repository::AddressBookRepository;
|
||||
use oxicloud::domain::repositories::calendar_repository::CalendarRepository;
|
||||
use oxicloud::domain::repositories::playlist_repository::PlaylistRepository;
|
||||
use oxicloud::infrastructure::repositories::pg::{
|
||||
AddressBookPgRepository, CalendarPgRepository, PlaylistPgRepository, UserPgRepository,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
struct Seeded {
|
||||
user_id: Uuid,
|
||||
calendar_ids: Vec<Uuid>,
|
||||
book_ids: Vec<Uuid>,
|
||||
playlist_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
async fn seed(pool: &PgPool, n: usize) -> Seeded {
|
||||
let user_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, role)
|
||||
VALUES ('bench_n1', 'bench_n1@bench.invalid', 'user') RETURNING id",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed user");
|
||||
|
||||
let mut calendar_ids = Vec::with_capacity(n);
|
||||
let mut book_ids = Vec::with_capacity(n);
|
||||
let mut playlist_ids = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
calendar_ids.push(
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO caldav.calendars (id, name, owner_id, color)
|
||||
VALUES (gen_random_uuid(), $1, $2, '#3788d8') RETURNING id",
|
||||
)
|
||||
.bind(format!("Calendario {i}"))
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed calendar"),
|
||||
);
|
||||
book_ids.push(
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO carddav.address_books (id, name, owner_id)
|
||||
VALUES (gen_random_uuid(), $1, $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("Libreta {i}"))
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed book"),
|
||||
);
|
||||
playlist_ids.push(
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO audio.playlists (name, owner_id)
|
||||
VALUES ($1, $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("Lista {i}"))
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed playlist"),
|
||||
);
|
||||
}
|
||||
Seeded {
|
||||
user_id,
|
||||
calendar_ids,
|
||||
book_ids,
|
||||
playlist_ids,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup(pool: &PgPool, s: &Seeded) {
|
||||
let _ = sqlx::query("DELETE FROM caldav.calendars WHERE owner_id = $1")
|
||||
.bind(s.user_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM carddav.address_books WHERE owner_id = $1")
|
||||
.bind(s.user_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM audio.playlists WHERE owner_id = $1")
|
||||
.bind(s.user_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1")
|
||||
.bind(s.user_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn p50(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
async fn bench_pair<FB, FA, TB, TA>(
|
||||
label: &str,
|
||||
passes: usize,
|
||||
n: usize,
|
||||
mut before: FB,
|
||||
mut after: FA,
|
||||
) where
|
||||
FB: AsyncFnMut() -> TB,
|
||||
FA: AsyncFnMut() -> TA,
|
||||
{
|
||||
let mut lb = Vec::with_capacity(passes);
|
||||
let mut la = Vec::with_capacity(passes);
|
||||
for _ in 0..passes {
|
||||
let t0 = Instant::now();
|
||||
std::hint::black_box(before().await);
|
||||
lb.push(t0.elapsed().as_secs_f64() * 1e3);
|
||||
let t0 = Instant::now();
|
||||
std::hint::black_box(after().await);
|
||||
la.push(t0.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
let b = p50(lb);
|
||||
let a = p50(la);
|
||||
println!("[{label}] ms/listing (p50, K={n})");
|
||||
println!(" BEFORE (K point SELECTs) {b:8.3} ({n} queries)");
|
||||
println!(
|
||||
" AFTER (1 × = ANY) {a:8.3} (1 query) {:.1}x",
|
||||
b / a
|
||||
);
|
||||
}
|
||||
|
||||
#[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 n: usize = env_or("BENCH_RESOURCES", 15);
|
||||
let passes: usize = env_or("BENCH_PASSES", 200);
|
||||
let herd: usize = env_or("BENCH_HERD", 32);
|
||||
|
||||
let pool = Arc::new(
|
||||
PgPoolOptions::new()
|
||||
.max_connections(40)
|
||||
.min_connections(40)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect Postgres"),
|
||||
);
|
||||
|
||||
let seeded = seed(&pool, n).await;
|
||||
|
||||
let cal_repo = CalendarPgRepository::new(pool.clone());
|
||||
let book_repo = AddressBookPgRepository::new(pool.clone());
|
||||
let pl_repo = PlaylistPgRepository::new(pool.clone());
|
||||
|
||||
println!("bench_n1_hydration — {n} resources/listing, {passes} passes, herd={herd}\n");
|
||||
|
||||
// ── [1] calendars ──
|
||||
bench_pair(
|
||||
"1 calendars",
|
||||
passes,
|
||||
n,
|
||||
async || {
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for id in &seeded.calendar_ids {
|
||||
if let Ok(c) = cal_repo.find_calendar_by_id(id).await {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
},
|
||||
async || {
|
||||
cal_repo
|
||||
.find_calendars_by_ids(&seeded.calendar_ids)
|
||||
.await
|
||||
.expect("batch calendars")
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// ── [2] address books ──
|
||||
bench_pair(
|
||||
"2 address books",
|
||||
passes,
|
||||
n,
|
||||
async || {
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for id in &seeded.book_ids {
|
||||
if let Ok(Some(b)) = book_repo.get_address_book_by_id(id).await {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
out
|
||||
},
|
||||
async || {
|
||||
book_repo
|
||||
.get_address_books_by_ids(&seeded.book_ids)
|
||||
.await
|
||||
.expect("batch books")
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// ── [3] playlists ──
|
||||
bench_pair(
|
||||
"3 playlists",
|
||||
passes,
|
||||
n,
|
||||
async || {
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for id in &seeded.playlist_ids {
|
||||
if let Ok(p) = pl_repo.find_playlist_by_id(id).await {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
out
|
||||
},
|
||||
async || {
|
||||
pl_repo
|
||||
.find_playlists_by_ids(&seeded.playlist_ids)
|
||||
.await
|
||||
.expect("batch playlists")
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// ── Equivalence gates ──
|
||||
let mut ok = true;
|
||||
{
|
||||
let loop_ids: HashSet<Uuid> = {
|
||||
let mut s = HashSet::new();
|
||||
for id in &seeded.calendar_ids {
|
||||
if let Ok(c) = cal_repo.find_calendar_by_id(id).await {
|
||||
s.insert(*c.id());
|
||||
}
|
||||
}
|
||||
s
|
||||
};
|
||||
let batch_ids: HashSet<Uuid> = cal_repo
|
||||
.find_calendars_by_ids(&seeded.calendar_ids)
|
||||
.await
|
||||
.expect("batch")
|
||||
.iter()
|
||||
.map(|c| *c.id())
|
||||
.collect();
|
||||
if loop_ids != batch_ids {
|
||||
eprintln!("GATE FAIL calendars: {loop_ids:?} != {batch_ids:?}");
|
||||
ok = false;
|
||||
}
|
||||
// Missing ids drop out on both sides.
|
||||
let with_ghost: Vec<Uuid> = seeded
|
||||
.calendar_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.chain([Uuid::new_v4()])
|
||||
.collect();
|
||||
let ghost_ids: HashSet<Uuid> = cal_repo
|
||||
.find_calendars_by_ids(&with_ghost)
|
||||
.await
|
||||
.expect("batch+ghost")
|
||||
.iter()
|
||||
.map(|c| *c.id())
|
||||
.collect();
|
||||
if ghost_ids != batch_ids {
|
||||
eprintln!("GATE FAIL calendars: ghost id changed result");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
{
|
||||
let loop_ids: HashSet<Uuid> = {
|
||||
let mut s = HashSet::new();
|
||||
for id in &seeded.book_ids {
|
||||
if let Ok(Some(b)) = book_repo.get_address_book_by_id(id).await {
|
||||
s.insert(*b.id());
|
||||
}
|
||||
}
|
||||
s
|
||||
};
|
||||
let batch_ids: HashSet<Uuid> = book_repo
|
||||
.get_address_books_by_ids(&seeded.book_ids)
|
||||
.await
|
||||
.expect("batch")
|
||||
.iter()
|
||||
.map(|b| *b.id())
|
||||
.collect();
|
||||
if loop_ids != batch_ids {
|
||||
eprintln!("GATE FAIL books");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
{
|
||||
let loop_ids: HashSet<Uuid> = {
|
||||
let mut s = HashSet::new();
|
||||
for id in &seeded.playlist_ids {
|
||||
if let Ok(p) = pl_repo.find_playlist_by_id(id).await {
|
||||
s.insert(*p.id());
|
||||
}
|
||||
}
|
||||
s
|
||||
};
|
||||
let batch_ids: HashSet<Uuid> = pl_repo
|
||||
.find_playlists_by_ids(&seeded.playlist_ids)
|
||||
.await
|
||||
.expect("batch")
|
||||
.iter()
|
||||
.map(|p| *p.id())
|
||||
.collect();
|
||||
if loop_ids != batch_ids {
|
||||
eprintln!("GATE FAIL playlists");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── [4] user-flags herd: get→insert vs try_get_with ─────────────────────
|
||||
let user_repo = Arc::new(UserPgRepository::new(pool.clone()));
|
||||
let queries = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
// BEFORE: sync moka get/insert — every cold caller queries.
|
||||
let sync_cache: moka::sync::Cache<Uuid, oxicloud::domain::entities::user::UserFlags> =
|
||||
moka::sync::Cache::builder()
|
||||
.max_capacity(10_000)
|
||||
.time_to_live(Duration::from_secs(30))
|
||||
.build();
|
||||
let t0 = Instant::now();
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..herd {
|
||||
let cache = sync_cache.clone();
|
||||
let repo = user_repo.clone();
|
||||
let queries = queries.clone();
|
||||
let uid = seeded.user_id;
|
||||
handles.push(tokio::spawn(async move {
|
||||
if let Some(f) = cache.get(&uid) {
|
||||
return f;
|
||||
}
|
||||
queries.fetch_add(1, Ordering::Relaxed);
|
||||
let f = repo.get_user_flags(uid).await.expect("flags");
|
||||
cache.insert(uid, f);
|
||||
f
|
||||
}));
|
||||
}
|
||||
let mut before_flags = Vec::new();
|
||||
for h in handles {
|
||||
before_flags.push(h.await.unwrap());
|
||||
}
|
||||
let before_wall = t0.elapsed().as_secs_f64() * 1e3;
|
||||
let before_queries = queries.swap(0, Ordering::Relaxed);
|
||||
|
||||
// AFTER: future moka try_get_with — one query per herd.
|
||||
let future_cache: moka::future::Cache<Uuid, oxicloud::domain::entities::user::UserFlags> =
|
||||
moka::future::Cache::builder()
|
||||
.max_capacity(10_000)
|
||||
.time_to_live(Duration::from_secs(30))
|
||||
.build();
|
||||
let t0 = Instant::now();
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..herd {
|
||||
let cache = future_cache.clone();
|
||||
let repo = user_repo.clone();
|
||||
let queries = queries.clone();
|
||||
let uid = seeded.user_id;
|
||||
handles.push(tokio::spawn(async move {
|
||||
cache
|
||||
.try_get_with(uid, async {
|
||||
queries.fetch_add(1, Ordering::Relaxed);
|
||||
repo.get_user_flags(uid).await
|
||||
})
|
||||
.await
|
||||
.expect("flags")
|
||||
}));
|
||||
}
|
||||
let mut after_flags = Vec::new();
|
||||
for h in handles {
|
||||
after_flags.push(h.await.unwrap());
|
||||
}
|
||||
let after_wall = t0.elapsed().as_secs_f64() * 1e3;
|
||||
let after_queries = queries.load(Ordering::Relaxed);
|
||||
|
||||
println!("[4] user-flags cold-cache herd of {herd}");
|
||||
println!(" BEFORE (get→insert) {before_wall:7.2} ms {before_queries} queries");
|
||||
println!(" AFTER (try_get_with) {after_wall:7.2} ms {after_queries} queries");
|
||||
|
||||
for f in before_flags.iter().chain(&after_flags) {
|
||||
if *f != before_flags[0] {
|
||||
eprintln!("GATE FAIL user flags mismatch");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(&pool, &seeded).await;
|
||||
println!(
|
||||
"\n[gate] {}",
|
||||
if ok {
|
||||
"OK (identical result sets)"
|
||||
} else {
|
||||
"FAILED"
|
||||
}
|
||||
);
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -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<T: std::str::FromStr>(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<Uuid>,
|
||||
}
|
||||
|
||||
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<Uuid> = 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<String> {
|
||||
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::<String, _>(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::<i64, _>(0), r.get::<Uuid, _>(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::<Uuid, _>(0), r.get::<String, _>(1)))
|
||||
.collect();
|
||||
rows.sort_unstable();
|
||||
rows
|
||||
}
|
||||
|
||||
type PageResult = (HashSet<String>, 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>) -> 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<String> = 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.");
|
||||
}
|
||||
@@ -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<FolderDto>` in the cache, shares one
|
||||
//! `Arc<CurrentUser>` 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<T: std::str::FromStr>(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<FolderDto>,
|
||||
}
|
||||
|
||||
/// Old extractor body: deep clone out of the shared Arc.
|
||||
pub fn extract(arc: &Arc<OldNcSession>) -> 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<uuid::Uuid, FolderDto> = moka::sync::Cache::new(100);
|
||||
let by_arc: moka::sync::Cache<uuid::Uuid, Arc<FolderDto>> = 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<FolderDto>");
|
||||
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.");
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Photos timeline benchmark — full-library scan vs per-drive LATERAL top-N.
|
||||
//!
|
||||
//! `list_media_files` (file_blob_read_repository.rs) filters by
|
||||
//! `fi.drive_id IN (<grants subquery>)`, joins folders + file_metadata, and
|
||||
//! sorts globally by `media_sort_date DESC LIMIT k`. The doc comment claims
|
||||
//! `idx_files_media_timeline_by_drive` lets LIMIT stop the scan early, but
|
||||
//! the plan is a Nested Loop over the drive set feeding EVERY media row
|
||||
//! through a Hash Left Join into a top-N heapsort ABOVE the join — the
|
||||
//! index is drained to exhaustion on every page, so each timeline page
|
||||
//! costs O(library), not O(page).
|
||||
//!
|
||||
//! The AFTER shape materialises the accessible drive ids once, then does a
|
||||
//! `CROSS JOIN LATERAL (… ORDER BY media_sort_date DESC LIMIT k)` per drive
|
||||
//! — each LATERAL is one bounded index scan — and merges `drives × k` rows.
|
||||
//! The folders/file_metadata joins move OUTSIDE the top-N so only the k
|
||||
//! emitted rows pay them.
|
||||
//!
|
||||
//! Equivalence gate: page-by-page id sequences must be identical (the seed
|
||||
//! uses strictly distinct capture dates so ties cannot mask reordering).
|
||||
//!
|
||||
//! Run (needs Postgres up; reads DATABASE_URL from .env):
|
||||
//! cargo run --release --features bench --example bench_photos_timeline
|
||||
//! Tunables: BENCH_MEDIA (50000), BENCH_DRIVES (3), BENCH_PAGE (100),
|
||||
//! BENCH_PAGES (10), BENCH_REPS (3)
|
||||
|
||||
use std::env;
|
||||
use std::time::Instant;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
async fn seed(pool: &PgPool, media: usize, drives: usize) -> (Uuid, Vec<Uuid>) {
|
||||
let caller = Uuid::new_v4();
|
||||
let mut drive_ids = Vec::with_capacity(drives);
|
||||
for d in 0..drives {
|
||||
let mut tx = pool.begin().await.expect("begin");
|
||||
let drive_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.drives (kind, quota_bytes, policies)
|
||||
VALUES ('shared', NULL, '{\"include_in_photo_index\": true}'::jsonb)
|
||||
RETURNING id",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("drive");
|
||||
let folder_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.folders (name, path, lpath, drive_id)
|
||||
VALUES ($1, $2, $3::ltree, $4) RETURNING id",
|
||||
)
|
||||
.bind(format!("bench_photos_{d}"))
|
||||
.bind(format!("/bench_photos_{d}"))
|
||||
.bind(format!("bench_photos_{d}"))
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("folder");
|
||||
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
|
||||
.bind(folder_id)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("stamp");
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.role_grants
|
||||
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
|
||||
VALUES ('user', $1, 'drive', $2, 'viewer', $1)",
|
||||
)
|
||||
.bind(caller)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("grant");
|
||||
tx.commit().await.expect("commit");
|
||||
|
||||
// Strictly distinct capture dates (offset per drive) so the
|
||||
// equivalence gate cannot be masked by tie reordering.
|
||||
let per_drive = media / drives;
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.files
|
||||
(name, folder_id, blob_hash, size, mime_type, drive_id, media_sort_date)
|
||||
SELECT 'IMG_' || LPAD(i::text, 8, '0') || '.jpg', $1,
|
||||
'benchphotos00000000000000000000000000000000000000000000000000000',
|
||||
2048, 'image/jpeg', $2,
|
||||
TIMESTAMPTZ '2026-01-01 00:00:00Z' - ((i * $4 + $5) || ' seconds')::interval
|
||||
FROM generate_series(1, $3) AS i",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.bind(drive_id)
|
||||
.bind(per_drive as i32)
|
||||
.bind(drives as i32)
|
||||
.bind(d as i32)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("files");
|
||||
drive_ids.push(drive_id);
|
||||
}
|
||||
sqlx::query("ANALYZE storage.files")
|
||||
.execute(pool)
|
||||
.await
|
||||
.ok();
|
||||
sqlx::query("ANALYZE storage.role_grants")
|
||||
.execute(pool)
|
||||
.await
|
||||
.ok();
|
||||
(caller, drive_ids)
|
||||
}
|
||||
|
||||
type MediaRow = (
|
||||
String, // id::text
|
||||
String, // name
|
||||
Option<String>, // folder_id::text
|
||||
Option<String>, // fo.path
|
||||
i64, // size
|
||||
String, // mime_type
|
||||
i64, // created_at epoch
|
||||
i64, // updated_at epoch
|
||||
String, // blob_hash
|
||||
Option<Uuid>, // created_by
|
||||
Option<Uuid>, // updated_by
|
||||
i64, // sort_date epoch
|
||||
Option<i32>, // width
|
||||
Option<i32>, // height
|
||||
);
|
||||
|
||||
const GRANTS_SUBQ: &str = r#"
|
||||
SELECT d.id
|
||||
FROM storage.drives d
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
WHERE (
|
||||
(g.subject_type = 'user' AND g.subject_id = $1)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($1)))
|
||||
)
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND (d.policies->>'include_in_photo_index')::boolean = true
|
||||
"#;
|
||||
|
||||
/// OLD shape — production SQL verbatim.
|
||||
async fn old_page(
|
||||
pool: &PgPool,
|
||||
caller: Uuid,
|
||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
limit: i64,
|
||||
) -> Vec<MediaRow> {
|
||||
let cursor_pred = if before.is_some() {
|
||||
"AND fi.media_sort_date < $2"
|
||||
} else {
|
||||
"AND $2::timestamptz IS NULL"
|
||||
};
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.created_by, fi.updated_by,
|
||||
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date,
|
||||
fm.width, fm.height
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id
|
||||
WHERE fi.drive_id IN ({GRANTS_SUBQ})
|
||||
AND NOT fi.is_trashed
|
||||
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
|
||||
{cursor_pred}
|
||||
ORDER BY fi.media_sort_date DESC
|
||||
LIMIT $3
|
||||
"#
|
||||
);
|
||||
sqlx::query_as(&sql)
|
||||
.bind(caller)
|
||||
.bind(before)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("old page")
|
||||
}
|
||||
|
||||
/// NEW shape — accessible drives materialised once, per-drive LATERAL top-N
|
||||
/// on the timeline index, folders/metadata joined only on the emitted rows.
|
||||
async fn new_page(
|
||||
pool: &PgPool,
|
||||
caller: Uuid,
|
||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
limit: i64,
|
||||
) -> Vec<MediaRow> {
|
||||
let cursor_pred = if before.is_some() {
|
||||
"AND fi.media_sort_date < $2"
|
||||
} else {
|
||||
"AND $2::timestamptz IS NULL"
|
||||
};
|
||||
let sql = format!(
|
||||
r#"
|
||||
WITH accessible AS MATERIALIZED ({GRANTS_SUBQ})
|
||||
SELECT top.id::text, top.name, top.folder_id::text, fo.path,
|
||||
top.size, top.mime_type,
|
||||
EXTRACT(EPOCH FROM top.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM top.updated_at)::bigint,
|
||||
top.blob_hash,
|
||||
top.created_by, top.updated_by,
|
||||
EXTRACT(EPOCH FROM top.media_sort_date)::bigint AS sort_date,
|
||||
fm.width, fm.height
|
||||
FROM (
|
||||
SELECT fi.*
|
||||
FROM accessible a
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT fi.*
|
||||
FROM storage.files fi
|
||||
WHERE fi.drive_id = a.id
|
||||
AND NOT fi.is_trashed
|
||||
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
|
||||
{cursor_pred}
|
||||
ORDER BY fi.media_sort_date DESC
|
||||
LIMIT $3
|
||||
) fi
|
||||
ORDER BY fi.media_sort_date DESC
|
||||
LIMIT $3
|
||||
) top
|
||||
LEFT JOIN storage.folders fo ON fo.id = top.folder_id
|
||||
LEFT JOIN storage.file_metadata fm ON fm.file_id = top.id
|
||||
ORDER BY top.media_sort_date DESC
|
||||
"#
|
||||
);
|
||||
sqlx::query_as(&sql)
|
||||
.bind(caller)
|
||||
.bind(before)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("new page")
|
||||
}
|
||||
|
||||
fn median(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
/// Walk `pages` cursor pages; returns (id sequence, per-page ms).
|
||||
async fn walk(
|
||||
pool: &PgPool,
|
||||
caller: Uuid,
|
||||
page: i64,
|
||||
pages: usize,
|
||||
new_shape: bool,
|
||||
) -> (Vec<String>, Vec<f64>) {
|
||||
let mut before: Option<chrono::DateTime<chrono::Utc>> = None;
|
||||
let mut ids = Vec::new();
|
||||
let mut times = Vec::new();
|
||||
for _ in 0..pages {
|
||||
let t = Instant::now();
|
||||
let rows = if new_shape {
|
||||
new_page(pool, caller, before, page).await
|
||||
} else {
|
||||
old_page(pool, caller, before, page).await
|
||||
};
|
||||
times.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
if rows.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Cursor semantics mirror production: whole-second epoch of the last
|
||||
// row (list_media_files hands the epoch back to the client).
|
||||
let last_epoch = rows.last().unwrap().11;
|
||||
before = chrono::DateTime::from_timestamp(last_epoch, 0);
|
||||
ids.extend(rows.into_iter().map(|r| r.0));
|
||||
}
|
||||
(ids, times)
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
let url = env::var("DATABASE_URL").expect("set DATABASE_URL");
|
||||
let media: usize = env_or("BENCH_MEDIA", 50_000);
|
||||
let drives: usize = env_or("BENCH_DRIVES", 3);
|
||||
let page: i64 = env_or("BENCH_PAGE", 100);
|
||||
let pages: usize = env_or("BENCH_PAGES", 10);
|
||||
let reps: usize = env_or("BENCH_REPS", 3);
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect");
|
||||
println!("seeding {media} media rows across {drives} drives (one-time)…");
|
||||
let (caller, drive_ids) = seed(&pool, media, drives).await;
|
||||
|
||||
let (ref_ids, _) = walk(&pool, caller, page, pages, false).await;
|
||||
assert_eq!(
|
||||
ref_ids.len(),
|
||||
(page as usize) * pages,
|
||||
"reference walk size"
|
||||
);
|
||||
|
||||
println!("\n# {pages} timeline pages of {page} over a {media}-photo library ({drives} drives)");
|
||||
println!(
|
||||
"{:<8} {:>11} {:>11} {:>8}",
|
||||
"mode", "total ms", "p50 ms/pg", "vs OLD"
|
||||
);
|
||||
|
||||
let mut failures = 0usize;
|
||||
let mut base: Option<f64> = None;
|
||||
for (mode, new_shape) in [("OLD", false), ("NEW", true)] {
|
||||
let mut totals = Vec::with_capacity(reps);
|
||||
let mut per_page: Vec<f64> = Vec::new();
|
||||
for _ in 0..reps {
|
||||
let t = Instant::now();
|
||||
let (ids, times) = walk(&pool, caller, page, pages, new_shape).await;
|
||||
totals.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
if ids != ref_ids {
|
||||
eprintln!("EQUIVALENCE FAILURE: {mode} walk drained different ids");
|
||||
failures += 1;
|
||||
}
|
||||
per_page = times;
|
||||
}
|
||||
let ms = median(totals);
|
||||
let speedup = base
|
||||
.map(|b| format!("{:.1}x", b / ms))
|
||||
.unwrap_or_else(|| "1.0x".into());
|
||||
if base.is_none() {
|
||||
base = Some(ms);
|
||||
}
|
||||
println!(
|
||||
"{:<8} {:>11.1} {:>11.2} {:>8}",
|
||||
mode,
|
||||
ms,
|
||||
median(per_page.clone()),
|
||||
speedup
|
||||
);
|
||||
}
|
||||
|
||||
for d in drive_ids {
|
||||
let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1")
|
||||
.bind(d)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
let _ = sqlx::query("DELETE FROM storage.role_grants WHERE subject_id = $1")
|
||||
.bind(caller)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
if failures > 0 {
|
||||
eprintln!("\n{failures} equivalence failures — the NEW shape is NOT safe to adopt");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
//! PROPFIND per-row XML emit benchmark — Vec churn + format-interpreter
|
||||
//! dates (ROUND4).
|
||||
//!
|
||||
//! For EVERY file/folder row of every PROPFIND page the old writers paid:
|
||||
//! • a `partition` into two throwaway `Vec<&QualifiedName>`s (+ a third
|
||||
//! for the 404 list) — even though the requested-props writer already
|
||||
//! skips unknown names itself;
|
||||
//! • `to_rfc3339()` + `to_rfc2822()` — chrono's format-spec interpreter
|
||||
//! plus a heap String each;
|
||||
//! • `size.to_string()` and a `format!("\"{etag}\"")`.
|
||||
//!
|
||||
//! AFTER: single-pass 404 computation (usually-empty Vec), stack-rendered
|
||||
//! dates/sizes (`common::fmt`, byte-identical, chrono fallback for
|
||||
//! out-of-range), exactly-sized etag quoting.
|
||||
//!
|
||||
//! The OLD writers are copied verbatim into `mod before`; the gate
|
||||
//! asserts byte-identical multistatus XML for named-prop (typical sync
|
||||
//! client set + unknown props), AllProp (with quota), and dead-prop
|
||||
//! carrying rows. Exit 1 on any diff.
|
||||
//!
|
||||
//! Run (no Postgres needed):
|
||||
//! cargo run --release --features bench --example bench_propfind_xml
|
||||
//! Tunables (env): BENCH_ROWS (1000), BENCH_PASSES (200)
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::env;
|
||||
use std::hint::black_box;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use oxicloud::application::adapters::webdav_adapter::{
|
||||
PropFindRequest, PropFindType, QualifiedName, bench as dav_bench,
|
||||
};
|
||||
use oxicloud::application::dtos::file_dto::FileDto;
|
||||
use oxicloud::application::dtos::folder_dto::FolderDto;
|
||||
use oxicloud::domain::entities::file::File;
|
||||
use oxicloud::domain::entities::folder::Folder;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ─── 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;
|
||||
|
||||
// ─── BEFORE: verbatim copy of the old per-row writers ───────────────────────
|
||||
|
||||
#[allow(clippy::all)]
|
||||
mod before {
|
||||
use chrono::Utc;
|
||||
use oxicloud::application::adapters::webdav_adapter::{
|
||||
PropFindRequest, PropFindType, QualifiedName,
|
||||
};
|
||||
use oxicloud::application::dtos::file_dto::FileDto;
|
||||
use oxicloud::application::dtos::folder_dto::FolderDto;
|
||||
use quick_xml::Writer;
|
||||
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
|
||||
use std::io::Write;
|
||||
|
||||
type Result<T> = std::result::Result<T, quick_xml::Error>;
|
||||
|
||||
fn folder_prop_is_known(prop: &QualifiedName, quota: Option<(i64, Option<i64>)>) -> bool {
|
||||
if prop.namespace != "DAV:" {
|
||||
return false;
|
||||
}
|
||||
match prop.name.as_str() {
|
||||
"resourcetype" | "displayname" | "creationdate" | "getlastmodified" | "getetag"
|
||||
| "getcontentlength" | "getcontenttype" => true,
|
||||
"quota-used-bytes" => quota.is_some(),
|
||||
"quota-available-bytes" => quota.is_some_and(|(_, available)| available.is_some()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_prop_is_known(prop: &QualifiedName) -> bool {
|
||||
prop.namespace == "DAV:"
|
||||
&& matches!(
|
||||
prop.name.as_str(),
|
||||
"resourcetype"
|
||||
| "displayname"
|
||||
| "getcontenttype"
|
||||
| "getcontentlength"
|
||||
| "creationdate"
|
||||
| "getlastmodified"
|
||||
| "getetag"
|
||||
)
|
||||
}
|
||||
|
||||
fn write_qname_empty<W: Write>(xml_writer: &mut Writer<W>, prop: &QualifiedName) -> Result<()> {
|
||||
if prop.namespace.is_empty() {
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new(prop.name.as_str())))?;
|
||||
} else if prop.namespace == "DAV:" {
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new(format!("D:{}", prop.name))))?;
|
||||
} else {
|
||||
let tag = format!("X:{}", prop.name);
|
||||
let mut start = BytesStart::new(tag.as_str());
|
||||
start.push_attribute(("xmlns:X", prop.namespace.as_str()));
|
||||
xml_writer.write_event(Event::Empty(start))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_unknown_props_404<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
unknown: &[&QualifiedName],
|
||||
) -> Result<()> {
|
||||
if unknown.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
for prop in unknown {
|
||||
write_qname_empty(xml_writer, prop)?;
|
||||
}
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 404 Not Found")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_dead_props_propstat<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
) -> Result<()> {
|
||||
if dead_props.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
for (name, value) in dead_props {
|
||||
let tag = if name.namespace.is_empty() {
|
||||
name.name.clone()
|
||||
} else {
|
||||
format!("X:{}", name.name)
|
||||
};
|
||||
let mut start = BytesStart::new(tag.as_str());
|
||||
if !name.namespace.is_empty() {
|
||||
start.push_attribute(("xmlns:X", name.namespace.as_str()));
|
||||
}
|
||||
match value {
|
||||
Some(v) if !v.is_empty() => {
|
||||
xml_writer.write_event(Event::Start(start))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(v)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new(tag.as_str())))?;
|
||||
}
|
||||
_ => {
|
||||
xml_writer.write_event(Event::Empty(start))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_quota_props<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
used_bytes: i64,
|
||||
available_bytes: Option<i64>,
|
||||
) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:quota-used-bytes")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&used_bytes.to_string())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:quota-used-bytes")))?;
|
||||
|
||||
if let Some(available_bytes) = available_bytes {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:quota-available-bytes")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&available_bytes.to_string())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:quota-available-bytes")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_folder_standard_props<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
folder: &FolderDto,
|
||||
quota: Option<(i64, Option<i64>)>,
|
||||
) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&folder.name)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?;
|
||||
let created_at = chrono::DateTime::<Utc>::from_timestamp(folder.created_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
let modified_at = chrono::DateTime::<Utc>::from_timestamp(folder.modified_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", folder.etag))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("0")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||
|
||||
if let Some((used, available)) = quota {
|
||||
write_quota_props(xml_writer, used, available)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file_standard_props<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
file: &FileDto,
|
||||
) -> Result<()> {
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&file.name)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&file.mime_type)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&file.size.to_string())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?;
|
||||
let created_at = chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
let modified_at = chrono::DateTime::<Utc>::from_timestamp(file.modified_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", file.etag))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_folder_requested_props<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
folder: &FolderDto,
|
||||
props: &[&QualifiedName],
|
||||
quota: Option<(i64, Option<i64>)>,
|
||||
) -> Result<()> {
|
||||
for prop in props {
|
||||
if prop.namespace == "DAV:" {
|
||||
match prop.name.as_str() {
|
||||
"resourcetype" => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
|
||||
}
|
||||
"displayname" => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&folder.name)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
|
||||
}
|
||||
"creationdate" => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?;
|
||||
let created_at =
|
||||
chrono::DateTime::<Utc>::from_timestamp(folder.created_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?;
|
||||
}
|
||||
"getlastmodified" => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
let modified_at =
|
||||
chrono::DateTime::<Utc>::from_timestamp(folder.modified_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
}
|
||||
"getetag" => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"\"{}\"",
|
||||
folder.etag
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
}
|
||||
"getcontentlength" => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("0")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
|
||||
}
|
||||
"getcontenttype" => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||
}
|
||||
"quota-used-bytes" => {
|
||||
if let Some((used, _)) = quota {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:quota-used-bytes")))?;
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&used.to_string())))?;
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("D:quota-used-bytes")))?;
|
||||
}
|
||||
}
|
||||
"quota-available-bytes" => {
|
||||
if let Some((_, Some(available))) = quota {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new(
|
||||
"D:quota-available-bytes",
|
||||
)))?;
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&available.to_string())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new(
|
||||
"D:quota-available-bytes",
|
||||
)))?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file_requested_props<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
file: &FileDto,
|
||||
props: &[&QualifiedName],
|
||||
) -> Result<()> {
|
||||
for prop in props {
|
||||
if prop.namespace == "DAV:" {
|
||||
match prop.name.as_str() {
|
||||
"resourcetype" => {
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||
}
|
||||
"displayname" => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&file.name)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
|
||||
}
|
||||
"getcontenttype" => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&file.mime_type)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||
}
|
||||
"getcontentlength" => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&file.size.to_string())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
|
||||
}
|
||||
"creationdate" => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?;
|
||||
let created_at =
|
||||
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?;
|
||||
}
|
||||
"getlastmodified" => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
let modified_at =
|
||||
chrono::DateTime::<Utc>::from_timestamp(file.modified_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
}
|
||||
"getetag" => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"\"{}\"",
|
||||
file.etag
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_file_response_with_dead_props<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
file: &FileDto,
|
||||
request: &PropFindRequest,
|
||||
href: &str,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(href)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||
|
||||
let relevant_dead: Vec<_> = match &request.prop_find_type {
|
||||
PropFindType::Prop(requested) => dead_props
|
||||
.iter()
|
||||
.filter(|(name, _)| requested.iter().any(|r| r == name))
|
||||
.cloned()
|
||||
.collect(),
|
||||
PropFindType::AllProp => dead_props.to_vec(),
|
||||
PropFindType::PropName => vec![],
|
||||
};
|
||||
let dead_name_set: std::collections::HashSet<&QualifiedName> =
|
||||
relevant_dead.iter().map(|(n, _)| n).collect();
|
||||
|
||||
match &request.prop_find_type {
|
||||
PropFindType::Prop(props) => {
|
||||
let (known, unknown): (Vec<_>, Vec<_>) =
|
||||
props.iter().partition(|p| file_prop_is_known(p));
|
||||
let truly_unknown: Vec<_> = unknown
|
||||
.into_iter()
|
||||
.filter(|p| !dead_name_set.contains(*p))
|
||||
.collect();
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
write_file_requested_props(xml_writer, file, &known)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||
|
||||
write_unknown_props_404(xml_writer, &truly_unknown)?;
|
||||
}
|
||||
other => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
match other {
|
||||
PropFindType::AllProp => {
|
||||
write_file_standard_props(xml_writer, file)?;
|
||||
}
|
||||
PropFindType::PropName => {
|
||||
// not exercised in this bench
|
||||
}
|
||||
PropFindType::Prop(_) => unreachable!(),
|
||||
}
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||
}
|
||||
}
|
||||
|
||||
write_dead_props_propstat(xml_writer, &relevant_dead)?;
|
||||
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_folder_response_with_dead_props<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
folder: &FolderDto,
|
||||
request: &PropFindRequest,
|
||||
href: &str,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
quota: Option<(i64, Option<i64>)>,
|
||||
) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(href)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||
|
||||
let relevant_dead: Vec<_> = match &request.prop_find_type {
|
||||
PropFindType::Prop(requested) => dead_props
|
||||
.iter()
|
||||
.filter(|(name, _)| requested.iter().any(|r| r == name))
|
||||
.cloned()
|
||||
.collect(),
|
||||
PropFindType::AllProp => dead_props.to_vec(),
|
||||
PropFindType::PropName => vec![],
|
||||
};
|
||||
let dead_name_set: std::collections::HashSet<&QualifiedName> =
|
||||
relevant_dead.iter().map(|(n, _)| n).collect();
|
||||
|
||||
match &request.prop_find_type {
|
||||
PropFindType::Prop(props) => {
|
||||
let (known, unknown): (Vec<_>, Vec<_>) =
|
||||
props.iter().partition(|p| folder_prop_is_known(p, quota));
|
||||
let truly_unknown: Vec<_> = unknown
|
||||
.into_iter()
|
||||
.filter(|p| !dead_name_set.contains(*p))
|
||||
.collect();
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
write_folder_requested_props(xml_writer, folder, &known, quota)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||
|
||||
write_unknown_props_404(xml_writer, &truly_unknown)?;
|
||||
}
|
||||
other => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
match other {
|
||||
PropFindType::AllProp => {
|
||||
write_folder_standard_props(xml_writer, folder, quota)?;
|
||||
}
|
||||
PropFindType::PropName => {}
|
||||
PropFindType::Prop(_) => unreachable!(),
|
||||
}
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||
}
|
||||
}
|
||||
|
||||
write_dead_props_propstat(xml_writer, &relevant_dead)?;
|
||||
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Corpus ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn build_files(rows: usize) -> Vec<FileDto> {
|
||||
(0..rows)
|
||||
.map(|i| {
|
||||
// Timestamp mix: epoch edge, padded-day dates, recent, far future.
|
||||
let created = [0u64, 1_120_176_000, 1_700_000_000, 4_102_444_799][i % 4];
|
||||
let f = File::from_materialized_row(
|
||||
Uuid::from_u128(i as u128).to_string(),
|
||||
format!("informe-{i}.pdf"),
|
||||
Some("/Personal/Projects/2026"),
|
||||
(i as u64) * 3_517 + 42,
|
||||
"application/pdf".to_string(),
|
||||
Some(Uuid::nil().to_string()),
|
||||
created,
|
||||
created + 86_400 * (i as u64 % 300),
|
||||
format!("{:032x}", i * 2_654_435_761),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid file");
|
||||
FileDto::from(f)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_folders(rows: usize) -> Vec<FolderDto> {
|
||||
(0..rows)
|
||||
.map(|i| {
|
||||
let created = [0u64, 1_120_176_000, 1_700_000_000, 4_102_444_799][i % 4];
|
||||
let f = Folder::from_materialized_row(
|
||||
Uuid::from_u128((1_000_000 + i) as u128).to_string(),
|
||||
format!("Carpeta {i}"),
|
||||
format!("/Personal/Carpeta {i}"),
|
||||
None,
|
||||
Uuid::nil(),
|
||||
created,
|
||||
created + 3_600,
|
||||
created + 7_200,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid folder");
|
||||
FolderDto::from(f)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The prop set DAVx⁵/rclone-style clients poll with, plus two unknown
|
||||
/// names so the 404 path is exercised.
|
||||
fn sync_request() -> PropFindRequest {
|
||||
PropFindRequest {
|
||||
prop_find_type: PropFindType::Prop(vec![
|
||||
QualifiedName::new("DAV:", "resourcetype"),
|
||||
QualifiedName::new("DAV:", "displayname"),
|
||||
QualifiedName::new("DAV:", "getcontenttype"),
|
||||
QualifiedName::new("DAV:", "getcontentlength"),
|
||||
QualifiedName::new("DAV:", "getlastmodified"),
|
||||
QualifiedName::new("DAV:", "getetag"),
|
||||
QualifiedName::new("DAV:", "lockdiscovery"),
|
||||
QualifiedName::new("http://owncloud.org/ns", "fileid"),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
fn allprop_request() -> PropFindRequest {
|
||||
PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
}
|
||||
}
|
||||
|
||||
fn p50(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
const QUOTA: Option<(i64, Option<i64>)> = Some((123_456_789, Some(9_876_543_210)));
|
||||
|
||||
fn render_before(
|
||||
files: &[FileDto],
|
||||
folders: &[FolderDto],
|
||||
request: &PropFindRequest,
|
||||
dead: &[(QualifiedName, Option<String>)],
|
||||
) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(1 << 20);
|
||||
let mut w = quick_xml::Writer::new(&mut out);
|
||||
for (i, folder) in folders.iter().enumerate() {
|
||||
let dead = if i % 7 == 0 { dead } else { &[] };
|
||||
before::write_folder_response_with_dead_props(
|
||||
&mut w,
|
||||
folder,
|
||||
request,
|
||||
"/webdav/Personal/",
|
||||
dead,
|
||||
QUOTA,
|
||||
)
|
||||
.expect("before folder row");
|
||||
}
|
||||
for (i, file) in files.iter().enumerate() {
|
||||
let dead = if i % 7 == 0 { dead } else { &[] };
|
||||
before::write_file_response_with_dead_props(
|
||||
&mut w,
|
||||
file,
|
||||
request,
|
||||
"/webdav/Personal/informe.pdf",
|
||||
dead,
|
||||
)
|
||||
.expect("before file row");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn render_after(
|
||||
files: &[FileDto],
|
||||
folders: &[FolderDto],
|
||||
request: &PropFindRequest,
|
||||
dead: &[(QualifiedName, Option<String>)],
|
||||
) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(1 << 20);
|
||||
let mut w = quick_xml::Writer::new(&mut out);
|
||||
for (i, folder) in folders.iter().enumerate() {
|
||||
let dead = if i % 7 == 0 { dead } else { &[] };
|
||||
dav_bench::write_folder_propfind_row(
|
||||
&mut w,
|
||||
folder,
|
||||
request,
|
||||
"/webdav/Personal/",
|
||||
dead,
|
||||
QUOTA,
|
||||
)
|
||||
.expect("after folder row");
|
||||
}
|
||||
for (i, file) in files.iter().enumerate() {
|
||||
let dead = if i % 7 == 0 { dead } else { &[] };
|
||||
dav_bench::write_file_propfind_row(
|
||||
&mut w,
|
||||
file,
|
||||
request,
|
||||
"/webdav/Personal/informe.pdf",
|
||||
dead,
|
||||
)
|
||||
.expect("after file row");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let rows: usize = env::var("BENCH_ROWS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1000);
|
||||
let passes: usize = env::var("BENCH_PASSES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(200);
|
||||
|
||||
let files = build_files(rows);
|
||||
let folders = build_folders(rows / 10);
|
||||
let total_rows = files.len() + folders.len();
|
||||
let dead: Vec<(QualifiedName, Option<String>)> = vec![(
|
||||
QualifiedName::new("http://example.com/ns", "color"),
|
||||
Some("azul".to_string()),
|
||||
)];
|
||||
|
||||
let sync_req = sync_request();
|
||||
let all_req = allprop_request();
|
||||
|
||||
println!(
|
||||
"bench_propfind_xml — {} files + {} folders/page, {passes} passes\n",
|
||||
files.len(),
|
||||
folders.len()
|
||||
);
|
||||
|
||||
for (label, req) in [("named-prop (sync set)", &sync_req), ("allprop", &all_req)] {
|
||||
let mut lat_before = Vec::with_capacity(passes);
|
||||
let mut lat_after = Vec::with_capacity(passes);
|
||||
for _ in 0..passes {
|
||||
let t0 = Instant::now();
|
||||
black_box(render_before(&files, &folders, req, &dead));
|
||||
lat_before.push(t0.elapsed().as_secs_f64() * 1e6);
|
||||
let t0 = Instant::now();
|
||||
black_box(render_after(&files, &folders, req, &dead));
|
||||
lat_after.push(t0.elapsed().as_secs_f64() * 1e6);
|
||||
}
|
||||
let b = p50(lat_before);
|
||||
let a = p50(lat_after);
|
||||
|
||||
let s0 = ALLOC_CALLS.load(Ordering::Relaxed);
|
||||
black_box(render_before(&files, &folders, req, &dead));
|
||||
let ab = (ALLOC_CALLS.load(Ordering::Relaxed) - s0) as f64 / total_rows as f64;
|
||||
let s0 = ALLOC_CALLS.load(Ordering::Relaxed);
|
||||
black_box(render_after(&files, &folders, req, &dead));
|
||||
let aa = (ALLOC_CALLS.load(Ordering::Relaxed) - s0) as f64 / total_rows as f64;
|
||||
|
||||
println!("[{label}] µs/page (p50) + allocs/row");
|
||||
println!(" BEFORE {b:9.1} µs {ab:6.2} allocs/row");
|
||||
println!(
|
||||
" AFTER {a:9.1} µs {aa:6.2} allocs/row {:.2}x",
|
||||
b / a
|
||||
);
|
||||
}
|
||||
|
||||
// ── Equivalence gate: byte-identical multistatus XML ────────────────────
|
||||
let mut ok = true;
|
||||
for req in [&sync_req, &all_req] {
|
||||
let xb = render_before(&files, &folders, req, &dead);
|
||||
let xa = render_after(&files, &folders, req, &dead);
|
||||
if xb != xa {
|
||||
ok = false;
|
||||
let diff_at = xb.iter().zip(&xa).position(|(a, b)| a != b).unwrap_or(0);
|
||||
let lo = diff_at.saturating_sub(120);
|
||||
eprintln!(
|
||||
"GATE FAIL ({:?}): first diff at byte {diff_at}\n BEFORE: …{}…\n AFTER: …{}…",
|
||||
match req.prop_find_type {
|
||||
PropFindType::Prop(_) => "prop",
|
||||
PropFindType::AllProp => "allprop",
|
||||
PropFindType::PropName => "propname",
|
||||
},
|
||||
String::from_utf8_lossy(&xb[lo..(diff_at + 120).min(xb.len())]),
|
||||
String::from_utf8_lossy(&xa[lo..(diff_at + 120).min(xa.len())]),
|
||||
);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\n[gate] multistatus XML: {}",
|
||||
if ok { "OK (byte-identical)" } else { "FAILED" }
|
||||
);
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Range-seek per-request authz duplication benchmark.
|
||||
//!
|
||||
//! `download_file_impl` calls `get_file_with_perms` once (authz + access
|
||||
//! notify + metadata) and THEN, in the Range branch, called
|
||||
//! `get_file_range_preloaded_with_perms` — which re-ran `require_file`
|
||||
//! (authz) + `notify_file_accessed` per request. Media players and PDF
|
||||
//! viewers fetch a file *exclusively* through Range requests: a `bytes=0-`
|
||||
//! probe then one request per seek. So every seek in a scrub re-authorized a
|
||||
//! file the request-level gate had already cleared.
|
||||
//!
|
||||
//! Round 7 drops the range branch to the non-perms `get_file_range_preloaded`
|
||||
//! (the share-landing and WebDAV range paths already do exactly this). This
|
||||
//! bench isolates the per-seek `require` that AFTER eliminates, driving the
|
||||
//! REAL `PgAclEngine`:
|
||||
//! - WARM: the cache the initial `get_file_with_perms` warmed — each removed
|
||||
//! seek-check was a moka hit + uuid parse (pure CPU/alloc).
|
||||
//! - COLD: a shared-drive recipient whose drive-role cache expired mid-scrub
|
||||
//! (30 s TTL) — each removed seek-check was a full drive-resolve query.
|
||||
//!
|
||||
//! Safety gate: the surviving request-level gate still authorizes correctly —
|
||||
//! the member is granted, a non-member is denied — so removing the per-seek
|
||||
//! re-check bypasses nothing.
|
||||
//!
|
||||
//! Run (needs Postgres up; reads DATABASE_URL from .env):
|
||||
//! cargo run --release --features bench --example bench_range_seek_authz
|
||||
//! Tunables (env): BENCH_SEEKS (200), BENCH_POOL (8).
|
||||
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use oxicloud::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use oxicloud::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use oxicloud::infrastructure::repositories::pg::{
|
||||
FileBlobReadRepository, FolderDbRepository, SubjectGroupPgRepository,
|
||||
};
|
||||
use oxicloud::infrastructure::services::dedup_service::DedupService;
|
||||
use oxicloud::infrastructure::services::local_blob_backend::LocalBlobBackend;
|
||||
use oxicloud::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
struct Seeded {
|
||||
member: Uuid,
|
||||
outsider: Uuid,
|
||||
drive_id: Uuid,
|
||||
root_folder: Uuid,
|
||||
blob_hash: String,
|
||||
file_id: Uuid,
|
||||
}
|
||||
|
||||
async fn seed(pool: &PgPool) -> Seeded {
|
||||
let mut tx = pool.begin().await.expect("begin");
|
||||
let member: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, role)
|
||||
VALUES ('bench_rangeseek', 'bench_rangeseek@bench.invalid', 'user') RETURNING id",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed member");
|
||||
let outsider: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, role)
|
||||
VALUES ('bench_rangeseek_out', 'bench_rangeseek_out@bench.invalid', 'user') RETURNING id",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed outsider");
|
||||
|
||||
let drive_id: Uuid =
|
||||
sqlx::query_scalar("INSERT INTO storage.drives (kind) VALUES ('shared') RETURNING id")
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed drive");
|
||||
let root_folder: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.folders (name, path, lpath, drive_id)
|
||||
VALUES ('Bench Seek', '/Bench Seek', 'x', $1) RETURNING id",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed folder");
|
||||
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
|
||||
.bind(root_folder)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("stamp root");
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.role_grants
|
||||
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
|
||||
VALUES ('user', $1, 'drive', $2, 'viewer'::storage.grant_role, $1)",
|
||||
)
|
||||
.bind(member)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("seed grant");
|
||||
|
||||
let blob_hash = "benchrangeseek00000000000000000000000000000000000000000000000b3".to_string();
|
||||
sqlx::query("INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 1048576, 1)")
|
||||
.bind(&blob_hash)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("seed blob");
|
||||
let file_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id)
|
||||
VALUES ('clip.mp4', $1, $2, 1048576, 'video/mp4', $3) RETURNING id",
|
||||
)
|
||||
.bind(root_folder)
|
||||
.bind(&blob_hash)
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed file");
|
||||
tx.commit().await.expect("commit");
|
||||
Seeded {
|
||||
member,
|
||||
outsider,
|
||||
drive_id,
|
||||
root_folder,
|
||||
blob_hash,
|
||||
file_id,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup(pool: &PgPool, s: &Seeded) {
|
||||
let _ = sqlx::query("DELETE FROM storage.role_grants WHERE resource_id = $1")
|
||||
.bind(s.drive_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.files WHERE drive_id = $1")
|
||||
.bind(s.drive_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1")
|
||||
.bind(s.drive_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.folders WHERE id = $1")
|
||||
.bind(s.root_folder)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.blobs WHERE hash = $1")
|
||||
.bind(&s.blob_hash)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM auth.users WHERE id IN ($1, $2)")
|
||||
.bind(s.member)
|
||||
.bind(s.outsider)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn fresh_engine(pool: &Arc<PgPool>) -> Arc<PgAclEngine> {
|
||||
let folder_repo = Arc::new(FolderDbRepository::new(pool.clone()));
|
||||
let backend = Arc::new(LocalBlobBackend::new(std::path::Path::new(
|
||||
"/tmp/bench-rangeseek-blobs",
|
||||
)));
|
||||
let dedup = Arc::new(DedupService::new(backend, pool.clone(), pool.clone()));
|
||||
let file_repo = Arc::new(FileBlobReadRepository::new(
|
||||
pool.clone(),
|
||||
dedup,
|
||||
folder_repo.clone(),
|
||||
));
|
||||
let group_repo = Arc::new(SubjectGroupPgRepository::new(pool.clone()));
|
||||
Arc::new(PgAclEngine::new(
|
||||
pool.clone(),
|
||||
folder_repo,
|
||||
file_repo,
|
||||
group_repo,
|
||||
))
|
||||
}
|
||||
|
||||
/// The per-seek check the range branch used to run (verbatim: uuid parse +
|
||||
/// `authz.require`, exactly `require_file`'s body).
|
||||
async fn seek_require(engine: &Arc<PgAclEngine>, caller: Uuid, file_id: Uuid) -> bool {
|
||||
engine
|
||||
.require(
|
||||
Subject::User(caller),
|
||||
Permission::Read,
|
||||
Resource::File(file_id),
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[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 seeks: usize = env_or("BENCH_SEEKS", 200);
|
||||
let pool_size: u32 = env_or("BENCH_POOL", 8);
|
||||
|
||||
let pool = Arc::new(
|
||||
PgPoolOptions::new()
|
||||
.max_connections(pool_size)
|
||||
.min_connections(pool_size)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect Postgres"),
|
||||
);
|
||||
|
||||
let s = seed(&pool).await;
|
||||
|
||||
// ── Safety gate: the surviving request-level gate authorizes correctly ──
|
||||
let gate = fresh_engine(&pool);
|
||||
let member_ok = seek_require(&gate, s.member, s.file_id).await;
|
||||
let outsider_denied = !seek_require(&gate, s.outsider, s.file_id).await;
|
||||
if !member_ok || !outsider_denied {
|
||||
eprintln!(
|
||||
"SAFETY GATE FAILED: member_ok={member_ok} outsider_denied={outsider_denied} \
|
||||
(the single request-level authz must still grant the member and deny the outsider)"
|
||||
);
|
||||
cleanup(&pool, &s).await;
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!("\n#################################################################");
|
||||
println!("# range-seek authz duplication: per-seek require (BEFORE) vs 0 (AFTER)");
|
||||
println!("# seeks/scrub={seeks} (member of a shared drive, viewer grant)");
|
||||
println!("#################################################################\n");
|
||||
println!("| {:<26} | {:>10} | {:>12} |", "arm", "wall ms", "µs/seek");
|
||||
|
||||
// WARM: one require warms owner_cache + drive_role_cache (as the handler's
|
||||
// get_file_with_perms does), then the scrub's per-seek re-checks are moka
|
||||
// hits — pure CPU/alloc the AFTER path removes.
|
||||
{
|
||||
let engine = fresh_engine(&pool);
|
||||
seek_require(&engine, s.member, s.file_id).await; // warm
|
||||
let t = Instant::now();
|
||||
for _ in 0..seeks {
|
||||
std::hint::black_box(seek_require(&engine, s.member, s.file_id).await);
|
||||
}
|
||||
let el = t.elapsed();
|
||||
println!(
|
||||
"| {:<26} | {:>10.2} | {:>12.2} |",
|
||||
"BEFORE per-seek (WARM)",
|
||||
el.as_secs_f64() * 1e3,
|
||||
el.as_secs_f64() * 1e6 / seeks as f64
|
||||
);
|
||||
}
|
||||
|
||||
// COLD: a fresh engine per seek models a cross-drive recipient or a
|
||||
// drive-role-cache entry that expired mid-scrub (30 s TTL) — each removed
|
||||
// re-check was a full grant-cascade drive-resolve query.
|
||||
{
|
||||
let t = Instant::now();
|
||||
for _ in 0..seeks {
|
||||
let engine = fresh_engine(&pool);
|
||||
std::hint::black_box(seek_require(&engine, s.member, s.file_id).await);
|
||||
}
|
||||
let el = t.elapsed();
|
||||
println!(
|
||||
"| {:<26} | {:>10.2} | {:>12.2} |",
|
||||
"BEFORE per-seek (COLD)",
|
||||
el.as_secs_f64() * 1e3,
|
||||
el.as_secs_f64() * 1e6 / seeks as f64
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
"| {:<26} | {:>10.2} | {:>12.2} |",
|
||||
"AFTER per-seek (removed)", 0.0, 0.0
|
||||
);
|
||||
|
||||
cleanup(&pool, &s).await;
|
||||
println!("\n(AFTER runs zero per-seek authz: the request-level get_file_with_perms");
|
||||
println!(" already authorized + recorded the access. WARM = the moka/CPU cost removed");
|
||||
println!(" per seek; COLD = the drive-resolve query removed per seek when the cache");
|
||||
println!(" isn't warm. notify_file_accessed (a throttled hook call) is likewise");
|
||||
println!(" removed per seek. Safety gate: member granted, outsider denied.)");
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
//! `/api/folders/{id}/resources` row→DTO mapping micro-alloc benchmark.
|
||||
//!
|
||||
//! The listing maps each `FolderResourceRow` into a `FolderResourceItemDto`.
|
||||
//! BEFORE cloned `row.name` into the DTO (`name: row.name.clone()`) even
|
||||
//! though the row is owned by the mapping closure — one avoidable `String`
|
||||
//! heap alloc per listed folder/file. AFTER computes the name-derived icon /
|
||||
//! 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).
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::env;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
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;
|
||||
use uuid::Uuid;
|
||||
|
||||
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<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn rows(n: usize) -> Vec<FolderResourceRow> {
|
||||
let ts: DateTime<Utc> = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let is_folder = i % 4 == 0;
|
||||
FolderResourceRow {
|
||||
resource_type: if is_folder { "folder" } else { "file" }.to_string(),
|
||||
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 },
|
||||
created_at: ts,
|
||||
modified_at: ts,
|
||||
drive_id: Uuid::new_v4(),
|
||||
blob_hash: if is_folder {
|
||||
None
|
||||
} else {
|
||||
Some("a".repeat(64))
|
||||
},
|
||||
sort_str: format!("row {i}"),
|
||||
type_order: 0,
|
||||
folder_first: if is_folder { 0 } else { 1 },
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// (name, icon_class, category) triple extracted from each produced DTO — the
|
||||
/// fields the move-vs-clone touches. Used for the equivalence gate.
|
||||
type Probe = (String, std::sync::Arc<str>, std::sync::Arc<str>);
|
||||
|
||||
/// BEFORE — verbatim: `name: row.name.clone()` in both branches.
|
||||
fn map_before(rows: Vec<FolderResourceRow>) -> Vec<Probe> {
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
if row.resource_type == "folder" {
|
||||
let resource_id = row.id.to_string();
|
||||
let dto = FolderDto {
|
||||
etag: resource_id.clone(),
|
||||
id: resource_id,
|
||||
name: row.name.clone(),
|
||||
path: String::new(),
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
drive_id: row.drive_id,
|
||||
created_at: row.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.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.id.to_string(),
|
||||
name: row.name.clone(),
|
||||
path: String::new(),
|
||||
size: size_bytes,
|
||||
mime_type: intern_mime(mime),
|
||||
folder_id: row.parent_id.map(|u| u.to_string()),
|
||||
created_at: row.created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
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.icon_class, dto.category)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// AFTER — icons/category first (borrow `&row.name`), then move `row.name`.
|
||||
fn map_after(rows: Vec<FolderResourceRow>) -> Vec<Probe> {
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
if row.resource_type == "folder" {
|
||||
let resource_id = row.id.to_string();
|
||||
let dto = FolderDto {
|
||||
etag: resource_id.clone(),
|
||||
id: resource_id,
|
||||
name: row.name,
|
||||
path: String::new(),
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
drive_id: row.drive_id,
|
||||
created_at: row.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.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 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.id.to_string(),
|
||||
name: row.name,
|
||||
path: String::new(),
|
||||
size: size_bytes,
|
||||
mime_type: intern_mime(mime),
|
||||
folder_id: row.parent_id.map(|u| u.to_string()),
|
||||
created_at: row.created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
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.icon_class, dto.category)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Section 2: favorites/recents row→DTO mapping (round 9 port) ─────────────
|
||||
|
||||
fn fav_rows(n: usize) -> Vec<FavoriteResourceRow> {
|
||||
let ts: DateTime<Utc> = 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<str>,
|
||||
std::sync::Arc<str>,
|
||||
);
|
||||
|
||||
/// BEFORE — verbatim favorites/recents mapping: `row.path.clone()`,
|
||||
/// `row.name.clone()` (both branches) and `row.blob_hash.clone()`.
|
||||
fn fav_map_before(rows: Vec<FavoriteResourceRow>) -> Vec<FavProbe> {
|
||||
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<FavoriteResourceRow>) -> Vec<FavProbe> {
|
||||
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);
|
||||
|
||||
// Equivalence gate: identical (name, icon_class, category) for every row.
|
||||
if map_before(rows(n)) != map_after(rows(n)) {
|
||||
eprintln!("EQUIVALENCE GATE FAILED: mapping output differs");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Warm the string interner so its first-sight allocs sit outside the
|
||||
// measured windows (they're identical for both arms anyway).
|
||||
std::hint::black_box(map_before(rows(n)));
|
||||
std::hint::black_box(map_after(rows(n)));
|
||||
|
||||
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
|
||||
let t = Instant::now();
|
||||
std::hint::black_box(map_before(rows(n)));
|
||||
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();
|
||||
std::hint::black_box(map_after(rows(n)));
|
||||
let after_ms = t.elapsed().as_secs_f64() * 1e3;
|
||||
let after_allocs = ALLOC_CALLS.load(Ordering::Relaxed) - a1;
|
||||
|
||||
// Both arms build the same `rows(n)` input inside the timed window, so the
|
||||
// input allocs are equal and cancel in the delta; the difference is the
|
||||
// per-row name clone the AFTER path avoids.
|
||||
println!("\n#################################################################");
|
||||
println!("# resources row→DTO mapping: clone name vs move name");
|
||||
println!("# rows={n}");
|
||||
println!("#################################################################\n");
|
||||
println!(
|
||||
"| {:<20} | {:>12} | {:>10} | {:>14} |",
|
||||
"arm", "allocs", "wall ms", "allocs/row"
|
||||
);
|
||||
println!(
|
||||
"| {:<20} | {:>12} | {:>10.3} | {:>14.3} |",
|
||||
"BEFORE (clone)",
|
||||
before_allocs,
|
||||
before_ms,
|
||||
before_allocs as f64 / n as f64
|
||||
);
|
||||
println!(
|
||||
"| {:<20} | {:>12} | {:>10.3} | {:>14.3} |",
|
||||
"AFTER (move)",
|
||||
after_allocs,
|
||||
after_ms,
|
||||
after_allocs as f64 / n as f64
|
||||
);
|
||||
println!(
|
||||
"\nSaved {} allocs ({:.2}/row) — the per-row name clone removed.",
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
//! PG row → entity path materialization benchmark — the per-listing-row
|
||||
//! `make_file_path` split→rejoin + NFC-copy chain (ROUND3 follow-up).
|
||||
//!
|
||||
//! Every listing row (PROPFIND batches, photos timeline, search pages,
|
||||
//! by-ids enrichment, subtree ZIP streams) used to pay this chain:
|
||||
//!
|
||||
//! • files: `format!("{fp}/{name}")` temp → `StoragePath::from_string`
|
||||
//! split (one `String` per segment + `Vec`) → constructor NFC-copies
|
||||
//! the already-NFC name → `Display`/`join` re-joins the segments it
|
||||
//! just split into `path_string` (join temp + unsized `to_string`).
|
||||
//! • folders: same minus the format temp — the materialized `path`
|
||||
//! column arrives owned, is split, dropped, and re-joined into an
|
||||
//! identical `String`.
|
||||
//!
|
||||
//! The optimized path builds segments + joined string in ONE pass
|
||||
//! (`StoragePath::from_folder_and_name` / `from_joined`, the latter
|
||||
//! reusing the owned input when canonical) and normalizes the owned name
|
||||
//! without the always-copy (`normalize_storage_name_owned`).
|
||||
//!
|
||||
//! The OLD logic is copied verbatim into `mod before` so one binary
|
||||
//! reports BEFORE vs AFTER side by side; an equivalence gate asserts
|
||||
//! byte-identical (name, path_string, segments) triples — including
|
||||
//! adversarial non-canonical inputs — and error parity for invalid
|
||||
//! names (exit 1 on any diff).
|
||||
//!
|
||||
//! Sections:
|
||||
//! 1. File row wall time (p50 ns/row over BENCH_PASSES passes)
|
||||
//! 2. Folder row wall time (same)
|
||||
//! 3. Alloc calls/row (counting allocator wrapping System — the lib
|
||||
//! crate sets no global allocator; mimalloc lives in main.rs only)
|
||||
//! 4. Equivalence gate (realistic corpus + adversarial set)
|
||||
//!
|
||||
//! Run (no Postgres needed):
|
||||
//! cargo run --release --features bench --example bench_row_path
|
||||
//! Tunables (env):
|
||||
//! BENCH_ROWS (10000) BENCH_PASSES (100)
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::env;
|
||||
use std::hint::black_box;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use oxicloud::domain::entities::file::File;
|
||||
use oxicloud::domain::entities::folder::Folder;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ─── Counting allocator (Section 3) ─────────────────────────────────────────
|
||||
|
||||
static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct CountingAlloc;
|
||||
|
||||
unsafe impl GlobalAlloc for CountingAlloc {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
unsafe { System.dealloc(ptr, layout) }
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.realloc(ptr, layout, new_size) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: CountingAlloc = CountingAlloc;
|
||||
|
||||
// ─── BEFORE: verbatim copy of the pre-optimization chain ────────────────────
|
||||
|
||||
/// Pre-optimization reference implementation. `OldStoragePath` +
|
||||
/// `normalize_storage_name` + `make_file_path` + the constructor bodies
|
||||
/// are copied byte-for-byte from the old `path_service.rs` /
|
||||
/// `file.rs` / `folder.rs` / repository code so the equivalence gate
|
||||
/// proves the optimized paths change nothing observable.
|
||||
#[allow(clippy::all)]
|
||||
mod before {
|
||||
use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Old borrowing normalize — allocates a copy even on the NFC fast path.
|
||||
fn normalize_storage_name(name: &str) -> String {
|
||||
if is_nfc_quick(name.chars()) == IsNormalized::Yes {
|
||||
return name.to_string();
|
||||
}
|
||||
name.nfc().collect()
|
||||
}
|
||||
|
||||
fn validate_storage_name(name: &str) -> Result<(), &'static str> {
|
||||
if name.is_empty() {
|
||||
return Err("name cannot be empty");
|
||||
}
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
return Err("name must not contain '/' or '\\'");
|
||||
}
|
||||
if name.contains('\0') {
|
||||
return Err("name must not contain null bytes");
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return Err("'.' and '..' are not valid names");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct OldStoragePath {
|
||||
pub segments: Vec<String>,
|
||||
}
|
||||
|
||||
impl OldStoragePath {
|
||||
fn is_safe_segment(s: &str) -> bool {
|
||||
!s.is_empty() && s != "." && s != ".." && !s.contains('/')
|
||||
}
|
||||
|
||||
fn from_string(path: &str) -> Self {
|
||||
let segments = path
|
||||
.split('/')
|
||||
.filter(|s| Self::is_safe_segment(s))
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
Self { segments }
|
||||
}
|
||||
}
|
||||
|
||||
/// Old `Display` impl (join temp) driven through the std `ToString`
|
||||
/// blanket — the exact `storage_path.to_string()` the constructors ran.
|
||||
impl std::fmt::Display for OldStoragePath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if self.segments.is_empty() {
|
||||
write!(f, "/")
|
||||
} else {
|
||||
write!(f, "/{}", self.segments.join("/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Old repository helper (identical copies lived in the read + write
|
||||
/// file repositories).
|
||||
fn make_file_path(folder_path: Option<&str>, file_name: &str) -> OldStoragePath {
|
||||
match folder_path {
|
||||
Some(fp) if !fp.is_empty() => OldStoragePath::from_string(&format!("{fp}/{file_name}")),
|
||||
_ => OldStoragePath::from_string(file_name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity-shaped product so BEFORE pays the same field moves the real
|
||||
/// constructors pay; only the path/name chain differs from AFTER.
|
||||
/// Fields exist to be *built* (cost parity), not read.
|
||||
#[allow(dead_code)]
|
||||
pub struct BeforeFile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub storage_path: OldStoragePath,
|
||||
pub path_string: String,
|
||||
pub size: u64,
|
||||
pub mime_type: String,
|
||||
pub folder_id: Option<String>,
|
||||
pub created_at: u64,
|
||||
pub modified_at: u64,
|
||||
pub blob_hash: String,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub updated_by: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Old `row_to_file` + `File::with_timestamps_blob_hash_and_provenance`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn file_row(
|
||||
id: String,
|
||||
name: String,
|
||||
folder_path: Option<&str>,
|
||||
size: u64,
|
||||
mime_type: String,
|
||||
folder_id: Option<String>,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
blob_hash: String,
|
||||
created_by: Option<Uuid>,
|
||||
updated_by: Option<Uuid>,
|
||||
) -> Result<BeforeFile, String> {
|
||||
let storage_path = make_file_path(folder_path, &name);
|
||||
|
||||
let name = normalize_storage_name(&name);
|
||||
if let Err(reason) = validate_storage_name(&name) {
|
||||
return Err(format!("{name}: {reason}"));
|
||||
}
|
||||
|
||||
// Store the path string for serialization compatibility
|
||||
let path_string = storage_path.to_string();
|
||||
|
||||
Ok(BeforeFile {
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
path_string,
|
||||
size,
|
||||
mime_type,
|
||||
folder_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
blob_hash,
|
||||
created_by,
|
||||
updated_by,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct BeforeFolder {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub storage_path: OldStoragePath,
|
||||
pub path_string: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub drive_id: Uuid,
|
||||
pub created_at: u64,
|
||||
pub modified_at: u64,
|
||||
pub tree_modified_at: u64,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub updated_by: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Old `row_to_folder` + `Folder::with_timestamps_tree_and_provenance`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn folder_row(
|
||||
id: String,
|
||||
name: String,
|
||||
path: String,
|
||||
parent_id: Option<String>,
|
||||
drive_id: Uuid,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
tree_modified_at: u64,
|
||||
created_by: Option<Uuid>,
|
||||
updated_by: Option<Uuid>,
|
||||
) -> Result<BeforeFolder, String> {
|
||||
let storage_path = OldStoragePath::from_string(&path);
|
||||
|
||||
let name = normalize_storage_name(&name);
|
||||
if let Err(reason) = validate_storage_name(&name) {
|
||||
return Err(format!("{name}: {reason}"));
|
||||
}
|
||||
|
||||
let path_string = storage_path.to_string();
|
||||
|
||||
Ok(BeforeFolder {
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
path_string,
|
||||
parent_id,
|
||||
drive_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
tree_modified_at,
|
||||
created_by,
|
||||
updated_by,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Corpus ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct Row {
|
||||
id: String,
|
||||
name: String,
|
||||
folder_path: Option<String>,
|
||||
mime: String,
|
||||
}
|
||||
|
||||
/// Deterministic LCG so runs are reproducible.
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.0 >> 33
|
||||
}
|
||||
fn pick<'a>(&mut self, xs: &[&'a str]) -> &'a str {
|
||||
xs[(self.next() as usize) % xs.len()]
|
||||
}
|
||||
}
|
||||
|
||||
const SEGMENTS: &[&str] = &[
|
||||
"Personal",
|
||||
"Projects",
|
||||
"2026",
|
||||
"Q3 Reports",
|
||||
"Fotos de familia",
|
||||
"Archive",
|
||||
"Contabilidad",
|
||||
"src",
|
||||
"Diseño gráfico",
|
||||
"backup-2026-07",
|
||||
];
|
||||
|
||||
const NAMES: &[&str] = &[
|
||||
"informe-final.pdf",
|
||||
"IMG_20260714_183042.jpg",
|
||||
"Presupuesto Q3 2026.xlsx",
|
||||
"Capture d\u{2019}\u{00E9}cran.png", // NFC accents — the common Unicode case
|
||||
"notes.md",
|
||||
"vacaciones-c\u{00F3}rdoba.mp4",
|
||||
"main.rs",
|
||||
"espa\u{00F1}ol.txt",
|
||||
];
|
||||
|
||||
fn build_corpus(rows: usize) -> Vec<Row> {
|
||||
let mut rng = Lcg(0x0c1_f00d);
|
||||
(0..rows)
|
||||
.map(|i| {
|
||||
let depth = (rng.next() % 6) as usize; // 0..=5
|
||||
let folder_path = if depth == 0 {
|
||||
None
|
||||
} else {
|
||||
let mut p = String::new();
|
||||
for _ in 0..depth {
|
||||
p.push('/');
|
||||
p.push_str(rng.pick(SEGMENTS));
|
||||
}
|
||||
Some(p)
|
||||
};
|
||||
Row {
|
||||
id: Uuid::from_u128(i as u128).to_string(),
|
||||
name: format!("{}-{}", i, rng.pick(NAMES)),
|
||||
folder_path,
|
||||
mime: "application/octet-stream".to_string(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn p50(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
// ─── Runners ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn run_file_before(corpus: &[Row]) -> before::BeforeFile {
|
||||
let mut last = None;
|
||||
for r in corpus {
|
||||
let f = before::file_row(
|
||||
r.id.clone(),
|
||||
r.name.clone(),
|
||||
r.folder_path.as_deref(),
|
||||
1234,
|
||||
r.mime.clone(),
|
||||
Some(r.id.clone()),
|
||||
1_700_000_000,
|
||||
1_750_000_000,
|
||||
"aabbccddeeff00112233445566778899".to_string(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid row");
|
||||
last = Some(f);
|
||||
}
|
||||
last.unwrap()
|
||||
}
|
||||
|
||||
fn run_file_after(corpus: &[Row]) -> File {
|
||||
let mut last = None;
|
||||
for r in corpus {
|
||||
let f = File::from_materialized_row(
|
||||
r.id.clone(),
|
||||
r.name.clone(),
|
||||
r.folder_path.as_deref(),
|
||||
1234,
|
||||
r.mime.clone(),
|
||||
Some(r.id.clone()),
|
||||
1_700_000_000,
|
||||
1_750_000_000,
|
||||
"aabbccddeeff00112233445566778899".to_string(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid row");
|
||||
last = Some(f);
|
||||
}
|
||||
last.unwrap()
|
||||
}
|
||||
|
||||
fn folder_full_path(r: &Row) -> String {
|
||||
match &r.folder_path {
|
||||
Some(p) => format!("{}/{}", p, r.name),
|
||||
None => format!("/{}", r.name),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_folder_before(corpus: &[Row]) -> before::BeforeFolder {
|
||||
let mut last = None;
|
||||
for r in corpus {
|
||||
let f = before::folder_row(
|
||||
r.id.clone(),
|
||||
r.name.clone(),
|
||||
folder_full_path(r),
|
||||
Some(r.id.clone()),
|
||||
Uuid::nil(),
|
||||
1_700_000_000,
|
||||
1_750_000_000,
|
||||
1_750_000_000,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid row");
|
||||
last = Some(f);
|
||||
}
|
||||
last.unwrap()
|
||||
}
|
||||
|
||||
fn run_folder_after(corpus: &[Row]) -> Folder {
|
||||
let mut last = None;
|
||||
for r in corpus {
|
||||
let f = Folder::from_materialized_row(
|
||||
r.id.clone(),
|
||||
r.name.clone(),
|
||||
folder_full_path(r),
|
||||
Some(r.id.clone()),
|
||||
Uuid::nil(),
|
||||
1_700_000_000,
|
||||
1_750_000_000,
|
||||
1_750_000_000,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid row");
|
||||
last = Some(f);
|
||||
}
|
||||
last.unwrap()
|
||||
}
|
||||
|
||||
fn time_ns_per_row<T>(passes: usize, rows: usize, mut f: impl FnMut() -> T) -> f64 {
|
||||
let mut per_pass = Vec::with_capacity(passes);
|
||||
for _ in 0..passes {
|
||||
let t0 = Instant::now();
|
||||
black_box(f());
|
||||
per_pass.push(t0.elapsed().as_nanos() as f64 / rows as f64);
|
||||
}
|
||||
p50(per_pass)
|
||||
}
|
||||
|
||||
fn allocs_per_row<T>(rows: usize, mut f: impl FnMut() -> T) -> f64 {
|
||||
let start = ALLOC_CALLS.load(Ordering::Relaxed);
|
||||
black_box(f());
|
||||
(ALLOC_CALLS.load(Ordering::Relaxed) - start) as f64 / rows as f64
|
||||
}
|
||||
|
||||
// ─── Equivalence gate ───────────────────────────────────────────────────────
|
||||
|
||||
fn gate_file(name: &str, folder_path: Option<&str>) -> bool {
|
||||
let b = before::file_row(
|
||||
"id".into(),
|
||||
name.to_string(),
|
||||
folder_path,
|
||||
0,
|
||||
"m".into(),
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let a = File::from_materialized_row(
|
||||
"id".into(),
|
||||
name.to_string(),
|
||||
folder_path,
|
||||
0,
|
||||
"m".into(),
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
match (b, a) {
|
||||
(Ok(b), Ok(a)) => {
|
||||
let seg_a: Vec<String> = a.storage_path().segments().to_vec();
|
||||
if b.name != a.name()
|
||||
|| b.path_string != a.path_string()
|
||||
|| b.storage_path.segments != seg_a
|
||||
{
|
||||
eprintln!(
|
||||
"GATE FAIL file name={name:?} fp={folder_path:?}\n BEFORE name={:?} path={:?} segs={:?}\n AFTER name={:?} path={:?} segs={:?}",
|
||||
b.name,
|
||||
b.path_string,
|
||||
b.storage_path.segments,
|
||||
a.name(),
|
||||
a.path_string(),
|
||||
seg_a
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
(Err(_), Err(_)) => true, // error parity
|
||||
(b, a) => {
|
||||
eprintln!(
|
||||
"GATE FAIL file name={name:?} fp={folder_path:?}: error parity broke (before_ok={} after_ok={})",
|
||||
b.is_ok(),
|
||||
a.is_ok()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_folder(name: &str, path: &str) -> bool {
|
||||
let b = before::folder_row(
|
||||
"id".into(),
|
||||
name.to_string(),
|
||||
path.to_string(),
|
||||
None,
|
||||
Uuid::nil(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let a = Folder::from_materialized_row(
|
||||
"id".into(),
|
||||
name.to_string(),
|
||||
path.to_string(),
|
||||
None,
|
||||
Uuid::nil(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
match (b, a) {
|
||||
(Ok(b), Ok(a)) => {
|
||||
let seg_a: Vec<String> = a.storage_path().segments().to_vec();
|
||||
if b.name != a.name()
|
||||
|| b.path_string != a.path_string()
|
||||
|| b.storage_path.segments != seg_a
|
||||
{
|
||||
eprintln!(
|
||||
"GATE FAIL folder name={name:?} path={path:?}\n BEFORE name={:?} path={:?} segs={:?}\n AFTER name={:?} path={:?} segs={:?}",
|
||||
b.name,
|
||||
b.path_string,
|
||||
b.storage_path.segments,
|
||||
a.name(),
|
||||
a.path_string(),
|
||||
seg_a
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
(Err(_), Err(_)) => true,
|
||||
(b, a) => {
|
||||
eprintln!(
|
||||
"GATE FAIL folder name={name:?} path={path:?}: error parity broke (before_ok={} after_ok={})",
|
||||
b.is_ok(),
|
||||
a.is_ok()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let rows: usize = env::var("BENCH_ROWS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10_000);
|
||||
let passes: usize = env::var("BENCH_PASSES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(100);
|
||||
let corpus = build_corpus(rows);
|
||||
|
||||
println!("bench_row_path — {rows} rows, {passes} passes (p50 ns/row)");
|
||||
println!();
|
||||
|
||||
// Warm-up
|
||||
black_box(run_file_before(&corpus));
|
||||
black_box(run_file_after(&corpus));
|
||||
black_box(run_folder_before(&corpus));
|
||||
black_box(run_folder_after(&corpus));
|
||||
|
||||
// [1] file rows
|
||||
let f_before = time_ns_per_row(passes, rows, || run_file_before(&corpus));
|
||||
let f_after = time_ns_per_row(passes, rows, || run_file_after(&corpus));
|
||||
println!("[1] File row (path chain + entity build)");
|
||||
println!(" BEFORE {f_before:8.1} ns/row");
|
||||
println!(
|
||||
" AFTER {f_after:8.1} ns/row {:.2}x",
|
||||
f_before / f_after
|
||||
);
|
||||
|
||||
// [2] folder rows
|
||||
let d_before = time_ns_per_row(passes, rows, || run_folder_before(&corpus));
|
||||
let d_after = time_ns_per_row(passes, rows, || run_folder_after(&corpus));
|
||||
println!("[2] Folder row (path chain + entity build)");
|
||||
println!(" BEFORE {d_before:8.1} ns/row");
|
||||
println!(
|
||||
" AFTER {d_after:8.1} ns/row {:.2}x",
|
||||
d_before / d_after
|
||||
);
|
||||
|
||||
// [3] allocs/row
|
||||
let fa_before = allocs_per_row(rows, || run_file_before(&corpus));
|
||||
let fa_after = allocs_per_row(rows, || run_file_after(&corpus));
|
||||
let da_before = allocs_per_row(rows, || run_folder_before(&corpus));
|
||||
let da_after = allocs_per_row(rows, || run_folder_after(&corpus));
|
||||
println!("[3] Alloc calls/row");
|
||||
println!(" File BEFORE {fa_before:6.2} AFTER {fa_after:6.2}");
|
||||
println!(" Folder BEFORE {da_before:6.2} AFTER {da_after:6.2}");
|
||||
|
||||
// [4] equivalence gate — realistic corpus + adversarial inputs
|
||||
let mut ok = true;
|
||||
for r in &corpus {
|
||||
ok &= gate_file(&r.name, r.folder_path.as_deref());
|
||||
ok &= gate_folder(&r.name, &folder_full_path(r));
|
||||
}
|
||||
// Adversarial: non-canonical paths, traversal, NFD names, empties.
|
||||
let adversarial_files: &[(&str, Option<&str>)] = &[
|
||||
("file.txt", None),
|
||||
("file.txt", Some("")),
|
||||
("file.txt", Some("/")),
|
||||
("file.txt", Some("a//b")),
|
||||
("file.txt", Some("/a/b/")),
|
||||
("file.txt", Some("../etc")),
|
||||
("file.txt", Some("a/./b")),
|
||||
("file.txt", Some("//")),
|
||||
// NFD name (decomposed é): DB rows are NFC by invariant, but the
|
||||
// chain must stay byte-identical even for un-normalized input.
|
||||
("cafe\u{0301}.txt", Some("/a")),
|
||||
("", Some("/a")), // error parity
|
||||
("..", Some("/a")), // error parity
|
||||
("nul\0l.txt", Some("/a")), // error parity
|
||||
("a\\b.txt", Some("/a")), // error parity
|
||||
];
|
||||
for (n, fp) in adversarial_files {
|
||||
ok &= gate_file(n, *fp);
|
||||
}
|
||||
let adversarial_folders: &[(&str, &str)] = &[
|
||||
("Docs", "/Docs"),
|
||||
("Docs", "Docs"),
|
||||
("Docs", "/a//Docs"),
|
||||
("Docs", "/a/Docs/"),
|
||||
("Docs", "/"),
|
||||
("Docs", ""),
|
||||
("Docs", "/../Docs"),
|
||||
("Doc\u{0301}s", "/a/Doc\u{0301}s"), // NFD in both
|
||||
];
|
||||
for (n, p) in adversarial_folders {
|
||||
ok &= gate_folder(n, p);
|
||||
}
|
||||
println!(
|
||||
"[4] Equivalence gate: {}",
|
||||
if ok { "OK (byte-identical)" } else { "FAILED" }
|
||||
);
|
||||
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! S3 chunk-PUT benchmark — HEAD-before-PUT vs unconditional PUT.
|
||||
//!
|
||||
//! `DedupService::settle_batch` writes every NEW chunk of every upload via
|
||||
//! `put_blob_from_bytes_unsynced`. S3/Azure never overrode it, so the trait
|
||||
//! default routed it through `put_blob_from_bytes`, whose "idempotent" HEAD
|
||||
//! probe made every chunk write pay 2 request round-trips. Content-addressed
|
||||
//! keys make re-PUTs overwrite-safe, so the new override PUTs directly.
|
||||
//!
|
||||
//! The stub S3 endpoint (in-process axum, per-request latency injection)
|
||||
//! counts HEAD/PUT requests:
|
||||
//! BEFORE — put_blob_from_bytes (HEAD 404 + PUT per chunk)
|
||||
//! AFTER — put_blob_from_bytes_unsynced (PUT per chunk)
|
||||
//!
|
||||
//! Section 2 measures the removed Azure `data.to_vec()` copy in isolation.
|
||||
//!
|
||||
//! 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
|
||||
//! Tunables: BENCH_CHUNKS (500), BENCH_CHUNK_KB (256), BENCH_CONCURRENCY (8),
|
||||
//! BENCH_RTT_MS (10)
|
||||
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bytes::Bytes;
|
||||
use oxicloud::application::ports::blob_storage_ports::BlobStorageBackend;
|
||||
use oxicloud::common::config::S3StorageConfig;
|
||||
use oxicloud::infrastructure::services::cached_blob_backend::{BlobCacheConfig, CachedBlobBackend};
|
||||
use oxicloud::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend;
|
||||
use oxicloud::infrastructure::services::retry_blob_backend::{RetryBlobBackend, RetryPolicy};
|
||||
use oxicloud::infrastructure::services::s3_blob_backend::S3BlobBackend;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Recursively count regular files under `dir` (the blob cache shards blobs
|
||||
/// into 2-hex-char prefix subdirectories).
|
||||
fn count_files(dir: &std::path::Path) -> usize {
|
||||
let mut n = 0;
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
n += count_files(&path);
|
||||
} else {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Counters {
|
||||
heads: Arc<AtomicU64>,
|
||||
puts: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
async fn stub_s3(latency: Duration, counters: Counters) -> String {
|
||||
use axum::http::{Method, StatusCode};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let app = axum::Router::new().fallback(move |req: axum::extract::Request| {
|
||||
let counters = counters.clone();
|
||||
async move {
|
||||
tokio::time::sleep(latency).await;
|
||||
match *req.method() {
|
||||
Method::HEAD => {
|
||||
counters.heads.fetch_add(1, Ordering::Relaxed);
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Method::PUT => {
|
||||
// Drain the body like a real endpoint would.
|
||||
let _ = axum::body::to_bytes(req.into_body(), usize::MAX).await;
|
||||
counters.puts.fetch_add(1, Ordering::Relaxed);
|
||||
StatusCode::OK
|
||||
}
|
||||
_ => StatusCode::OK,
|
||||
}
|
||||
}
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.expect("serve");
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn drive(
|
||||
backend: Arc<dyn BlobStorageBackend>,
|
||||
chunks: usize,
|
||||
chunk_kb: usize,
|
||||
concurrency: usize,
|
||||
unsynced: bool,
|
||||
hash_prefix: &str,
|
||||
) -> f64 {
|
||||
let payload = Bytes::from(vec![0x5au8; chunk_kb * 1024]);
|
||||
let sem = Arc::new(tokio::sync::Semaphore::new(concurrency));
|
||||
let t = Instant::now();
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for i in 0..chunks {
|
||||
let b = backend.clone();
|
||||
let p = payload.clone();
|
||||
let sem = sem.clone();
|
||||
let hash = format!("{hash_prefix}{i:060x}");
|
||||
set.spawn(async move {
|
||||
let _permit = sem.acquire().await.expect("sem");
|
||||
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")
|
||||
};
|
||||
// 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 {
|
||||
r.expect("join");
|
||||
}
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
/// Run BEFORE (synced route == the pre-round-9 unsynced fallthrough) and
|
||||
/// AFTER (forwarded unsynced route) through one backend stack, printing the
|
||||
/// two rows and gating AFTER on zero probe requests. `prefixes` carries the
|
||||
/// (BEFORE, AFTER) hash namespaces keeping the arms' key spaces disjoint.
|
||||
async fn stack_ab(
|
||||
label: &str,
|
||||
backend: Arc<dyn BlobStorageBackend>,
|
||||
counters: &Counters,
|
||||
chunks: usize,
|
||||
chunk_kb: usize,
|
||||
concurrency: usize,
|
||||
prefixes: (&str, &str),
|
||||
) -> (f64, f64) {
|
||||
let (prefix_before, prefix_after) = prefixes;
|
||||
let before = drive(
|
||||
backend.clone(),
|
||||
chunks,
|
||||
chunk_kb,
|
||||
concurrency,
|
||||
false,
|
||||
prefix_before,
|
||||
)
|
||||
.await;
|
||||
let before_heads = counters.heads.swap(0, Ordering::Relaxed);
|
||||
let before_puts = counters.puts.swap(0, Ordering::Relaxed);
|
||||
println!(
|
||||
"{:<34} {:>10.0} {:>8} {:>8} {:>8}",
|
||||
format!("{label} BEFORE (synced route)"),
|
||||
before,
|
||||
before_heads,
|
||||
before_puts,
|
||||
"1.0x"
|
||||
);
|
||||
|
||||
let after = drive(
|
||||
backend.clone(),
|
||||
chunks,
|
||||
chunk_kb,
|
||||
concurrency,
|
||||
true,
|
||||
prefix_after,
|
||||
)
|
||||
.await;
|
||||
let after_heads = counters.heads.swap(0, Ordering::Relaxed);
|
||||
let after_puts = counters.puts.swap(0, Ordering::Relaxed);
|
||||
println!(
|
||||
"{:<34} {:>10.0} {:>8} {:>8} {:>8}",
|
||||
format!("{label} AFTER (unsynced)"),
|
||||
after,
|
||||
after_heads,
|
||||
after_puts,
|
||||
format!("{:.1}x", before / after)
|
||||
);
|
||||
|
||||
if before_heads != chunks as u64 {
|
||||
eprintln!(
|
||||
"GATE FAIL [{label}]: BEFORE issued {before_heads} HEADs (expected {chunks} — the probing route must still probe)"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
if after_heads != 0 || after_puts != chunks as u64 {
|
||||
eprintln!(
|
||||
"GATE FAIL [{label}]: AFTER issued {after_heads} HEADs / {after_puts} PUTs (expected 0 / {chunks})"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
if after >= before {
|
||||
eprintln!(
|
||||
"GATE FAIL [{label}]: AFTER ({after:.0} ms) not faster than BEFORE ({before:.0} ms) — rollback"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
(before, after)
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
let chunks: usize = env_or("BENCH_CHUNKS", 500);
|
||||
let chunk_kb: usize = env_or("BENCH_CHUNK_KB", 256);
|
||||
let concurrency: usize = env_or("BENCH_CONCURRENCY", 8);
|
||||
let rtt_ms: u64 = env_or("BENCH_RTT_MS", 10);
|
||||
|
||||
let counters = Counters::default();
|
||||
let endpoint = stub_s3(Duration::from_millis(rtt_ms), counters.clone()).await;
|
||||
let backend = Arc::new(S3BlobBackend::new(&S3StorageConfig {
|
||||
endpoint_url: Some(endpoint),
|
||||
bucket: "bench".into(),
|
||||
region: "us-east-1".into(),
|
||||
access_key: "bench".into(),
|
||||
secret_key: "bench".into(),
|
||||
force_path_style: true,
|
||||
}));
|
||||
|
||||
println!(
|
||||
"# {chunks} x {chunk_kb} KiB chunk PUTs at concurrency {concurrency}, {rtt_ms} ms/request stub"
|
||||
);
|
||||
println!(
|
||||
"{:<26} {:>10} {:>8} {:>8} {:>8}",
|
||||
"variant", "wall ms", "HEADs", "PUTs", "vs OLD"
|
||||
);
|
||||
|
||||
// BEFORE: the trait-default route (put_blob_from_bytes = HEAD + PUT).
|
||||
let before = drive(
|
||||
backend.clone() as Arc<dyn BlobStorageBackend>,
|
||||
chunks,
|
||||
chunk_kb,
|
||||
concurrency,
|
||||
false,
|
||||
"a0a0",
|
||||
)
|
||||
.await;
|
||||
let before_heads = counters.heads.swap(0, Ordering::Relaxed);
|
||||
let before_puts = counters.puts.swap(0, Ordering::Relaxed);
|
||||
println!(
|
||||
"{:<26} {:>10.0} {:>8} {:>8} {:>8}",
|
||||
"BEFORE (HEAD+PUT)", before, before_heads, before_puts, "1.0x"
|
||||
);
|
||||
|
||||
// AFTER: the unsynced override (PUT only).
|
||||
let after = drive(
|
||||
backend.clone() as Arc<dyn BlobStorageBackend>,
|
||||
chunks,
|
||||
chunk_kb,
|
||||
concurrency,
|
||||
true,
|
||||
"a0a1",
|
||||
)
|
||||
.await;
|
||||
let after_heads = counters.heads.swap(0, Ordering::Relaxed);
|
||||
let after_puts = counters.puts.swap(0, Ordering::Relaxed);
|
||||
println!(
|
||||
"{:<26} {:>10.0} {:>8} {:>8} {:>8}",
|
||||
"AFTER (PUT only)",
|
||||
after,
|
||||
after_heads,
|
||||
after_puts,
|
||||
format!("{:.1}x", before / after)
|
||||
);
|
||||
|
||||
// ── Section 2: the removed Azure to_vec() copy, in isolation ───────
|
||||
let mb = 4;
|
||||
let data = Bytes::from(vec![0x77u8; mb * 1024 * 1024]);
|
||||
let reps = 200;
|
||||
let t = Instant::now();
|
||||
for _ in 0..reps {
|
||||
let v = data.to_vec();
|
||||
std::hint::black_box(&v);
|
||||
}
|
||||
let copy_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
|
||||
println!(
|
||||
"\n# [2] removed Azure per-chunk copy: to_vec() of {mb} MiB = {copy_ms:.2} ms + {mb} MiB transient alloc per chunk"
|
||||
);
|
||||
|
||||
// ── Section 3: the same A/B through the decorator stacks ────────────
|
||||
println!(
|
||||
"\n# [3] decorated stacks — pre-round-9 the unsynced call fell through to the synced (probing) route"
|
||||
);
|
||||
println!(
|
||||
"{:<34} {:>10} {:>8} {:>8} {:>8}",
|
||||
"variant", "wall ms", "HEADs", "PUTs", "vs OLD"
|
||||
);
|
||||
|
||||
// Retry(S3)
|
||||
let retry_stack: Arc<dyn BlobStorageBackend> = Arc::new(RetryBlobBackend::new(
|
||||
backend.clone() as Arc<dyn BlobStorageBackend>,
|
||||
RetryPolicy::default(),
|
||||
));
|
||||
stack_ab(
|
||||
"retry(s3)",
|
||||
retry_stack,
|
||||
&counters,
|
||||
chunks,
|
||||
chunk_kb,
|
||||
concurrency,
|
||||
("b0b0", "b0b1"),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Cache(S3) — count cache write-through population on both routes.
|
||||
let cache_dir_a = tempfile::tempdir().expect("tempdir");
|
||||
let cached_stack: Arc<dyn BlobStorageBackend> = Arc::new(CachedBlobBackend::new(
|
||||
backend.clone() as Arc<dyn BlobStorageBackend>,
|
||||
&BlobCacheConfig {
|
||||
cache_dir: cache_dir_a.path().to_path_buf(),
|
||||
max_cache_bytes: u64::MAX,
|
||||
},
|
||||
));
|
||||
stack_ab(
|
||||
"cache(s3)",
|
||||
cached_stack,
|
||||
&counters,
|
||||
chunks,
|
||||
chunk_kb,
|
||||
concurrency,
|
||||
("c0c0", "c0c1"),
|
||||
)
|
||||
.await;
|
||||
// Write-through equivalence gate: BOTH routes populated the local cache
|
||||
// (the round-9 override keeps post-upload read locality intact).
|
||||
let cached_files = count_files(cache_dir_a.path());
|
||||
if cached_files != 2 * chunks {
|
||||
eprintln!(
|
||||
"GATE FAIL [cache(s3)]: cache holds {cached_files} blobs (expected {} — write-through must populate on BOTH routes)",
|
||||
2 * chunks
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Full production composition: Cache(Encrypted(Retry(S3))).
|
||||
let cache_dir_b = tempfile::tempdir().expect("tempdir");
|
||||
let full_stack: Arc<dyn BlobStorageBackend> = Arc::new(CachedBlobBackend::new(
|
||||
Arc::new(EncryptedBlobBackend::new(
|
||||
Arc::new(RetryBlobBackend::new(
|
||||
backend.clone() as Arc<dyn BlobStorageBackend>,
|
||||
RetryPolicy::default(),
|
||||
)),
|
||||
&[0x42u8; 32],
|
||||
)),
|
||||
&BlobCacheConfig {
|
||||
cache_dir: cache_dir_b.path().to_path_buf(),
|
||||
max_cache_bytes: u64::MAX,
|
||||
},
|
||||
));
|
||||
let (full_before, full_after) = stack_ab(
|
||||
"cache(enc(retry(s3)))",
|
||||
full_stack,
|
||||
&counters,
|
||||
chunks,
|
||||
chunk_kb,
|
||||
concurrency,
|
||||
("d0d0", "d0d1"),
|
||||
)
|
||||
.await;
|
||||
println!(
|
||||
"# full stack: a {chunks}-chunk upload sheds {} probe round-trips ({:.0} -> {:.0} ms at {rtt_ms} ms RTT)",
|
||||
chunks, full_before, full_after
|
||||
);
|
||||
|
||||
// ── Gates ───────────────────────────────────────────────────────────
|
||||
if after_heads != 0 || after_puts != chunks as u64 {
|
||||
eprintln!(
|
||||
"GATE FAIL: AFTER issued {after_heads} HEADs / {after_puts} PUTs (expected 0 / {chunks})"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
if after >= before {
|
||||
eprintln!(
|
||||
"GATE FAIL: AFTER ({after:.0} ms) not faster than BEFORE ({before:.0} ms) — rollback"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!(
|
||||
"GATE PASS: {}-request walk -> {} requests, {:.1}x faster",
|
||||
before_heads + before_puts,
|
||||
after_puts,
|
||||
before / after
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Search-results cache memory benchmark — entry-count bound vs byte bound.
|
||||
//!
|
||||
//! The search cache keys pages by user × query × offset × limit, and each
|
||||
//! page holds up to 500 enriched rows (`MAX_SEARCH_LIMIT`) of owned Strings.
|
||||
//! Bounded by ENTRY COUNT (the old scheme: `max_capacity(1000)` + TTL), a
|
||||
//! burst of keystrokes/pages/users could pin ~300 MB of invisible RSS for
|
||||
//! the 5-minute TTL. Bounded by BYTES (a `weigher` + 32 MiB budget — the
|
||||
//! same pattern as the file-content and dedup-manifest caches), retention
|
||||
//! can never exceed the budget.
|
||||
//!
|
||||
//! Two sub-phases over the same synthetic corpus (1,000 pages × 500 rows,
|
||||
//! ~150-char paths, realistic field contents):
|
||||
//! * BEFORE — a moka cache configured exactly as the old production wiring
|
||||
//! (entry-count 1000 + 300 s TTL).
|
||||
//! * AFTER — `build_search_results_cache(...)`, the *identical* function
|
||||
//! production now uses (weigher + 32 MiB + 300 s TTL).
|
||||
//!
|
||||
//! Reported per phase: entries retained, retained bytes (recomputed with the
|
||||
//! production weigher after `run_pending_tasks`), best-effort process memory
|
||||
//! (`VmHWM`/`VmRSS` from /proc/self/status), and hot-key `get()` p50 over
|
||||
//! 100k reads (proves the weigher — which only runs on insert — does not
|
||||
//! slow reads).
|
||||
//!
|
||||
//! NOTE on RSS: `VmHWM` is a monotonic high-water mark and the allocator may
|
||||
//! keep freed pages, so the AFTER phase (which runs second, after a full
|
||||
//! drop of the BEFORE cache) cannot show a peak below the BEFORE peak.
|
||||
//! Treat the RSS columns as best-effort corroboration; the authoritative
|
||||
//! metric is the weigher-recomputed retained bytes.
|
||||
//!
|
||||
//! Gates (exit code 1 on failure):
|
||||
//! * AFTER retained bytes ≤ 32 MiB budget
|
||||
//! * BEFORE retained bytes ≥ 8× the budget (measured ≈9–10×)
|
||||
//! * AFTER get() p50 within 20% of BEFORE
|
||||
//!
|
||||
//! No Postgres needed.
|
||||
//! Run: `cargo run --release --features bench --example bench_search_cache_mem`
|
||||
|
||||
use std::hint::black_box;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use oxicloud::application::dtos::search_dto::{SearchFileResultDto, SearchResultsDto};
|
||||
use oxicloud::application::services::search_service::{
|
||||
build_search_results_cache, search_results_entry_weight,
|
||||
};
|
||||
|
||||
/// Distinct cached pages inserted per phase (≈ users × queries × pages).
|
||||
const ENTRIES: u64 = 1_000;
|
||||
/// Rows per page — the handler's `MAX_SEARCH_LIMIT` clamp.
|
||||
const ROWS_PER_ENTRY: usize = 500;
|
||||
/// Production TTL (unchanged by the fix).
|
||||
const TTL_SECS: u64 = 300;
|
||||
/// The old production bound: 1000 ENTRIES, blind to entry size.
|
||||
const BEFORE_MAX_ENTRIES: u64 = 1_000;
|
||||
/// The new production bound: 32 MiB of weighed bytes.
|
||||
const AFTER_MAX_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// Hot-key reads per phase for the p50 latency comparison.
|
||||
const GETS: usize = 100_000;
|
||||
|
||||
const MIB: f64 = 1024.0 * 1024.0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic synthetic corpus (no rand dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tiny xorshift64 PRNG — fast, deterministic, no dependency.
|
||||
fn xorshift(state: &mut u64) -> u64 {
|
||||
let mut x = *state;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
*state = x;
|
||||
x
|
||||
}
|
||||
|
||||
/// Lowercase-hex string of `chars` nibbles.
|
||||
fn pseudo_hex(state: &mut u64, chars: usize) -> String {
|
||||
let mut s = String::with_capacity(chars);
|
||||
while s.len() < chars {
|
||||
let block = format!("{:016x}", xorshift(state));
|
||||
let take = (chars - s.len()).min(16);
|
||||
s.push_str(&block[..take]);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// 36-char UUID-shaped string (8-4-4-4-12), like the real `Uuid::to_string()`
|
||||
/// ids that populate `SearchFileResultDto::id` / `folder_id`.
|
||||
fn pseudo_uuid(state: &mut u64) -> String {
|
||||
let h = pseudo_hex(state, 32);
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&h[0..8],
|
||||
&h[8..12],
|
||||
&h[12..16],
|
||||
&h[16..20],
|
||||
&h[20..32]
|
||||
)
|
||||
}
|
||||
|
||||
/// One synthetic 500-row search page with realistic field contents:
|
||||
/// UUID ids, ~30-char names, ~150-char nested drive paths, real MIME types,
|
||||
/// 64-hex BLAKE3 blob hashes, icon/category metadata, and a content-index
|
||||
/// snippet on every 8th row.
|
||||
fn synth_entry(idx: u64) -> Arc<SearchResultsDto> {
|
||||
const MIMES: [&str; 4] = [
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"image/jpeg",
|
||||
"text/markdown",
|
||||
];
|
||||
const SNIPPET: &str = "…the quarterly numbers show a steady increase in storage usage \
|
||||
across all departments, with the engineering share growing fastest and…";
|
||||
|
||||
let mut rng = idx.wrapping_mul(0x9E3779B97F4A7C15) | 1;
|
||||
let mut files = Vec::with_capacity(ROWS_PER_ENTRY);
|
||||
for row in 0..ROWS_PER_ENTRY {
|
||||
let name = format!(
|
||||
"quarterly_report_{:04}_rev{:03}.pdf",
|
||||
xorshift(&mut rng) % 10_000,
|
||||
row % 1_000
|
||||
);
|
||||
let path = format!(
|
||||
"/drives/{}/Departments/Engineering/Projects/oxicloud-benchmarks/2026/Q{}/weekly-sync-notes/attachments/{}",
|
||||
pseudo_uuid(&mut rng),
|
||||
row % 4 + 1,
|
||||
name
|
||||
);
|
||||
let content_hit = row % 8 == 0;
|
||||
let match_source = if content_hit { "content" } else { "name" };
|
||||
files.push(SearchFileResultDto {
|
||||
id: pseudo_uuid(&mut rng),
|
||||
name,
|
||||
path,
|
||||
size: 831_942,
|
||||
mime_type: MIMES[row % MIMES.len()].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".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()),
|
||||
});
|
||||
}
|
||||
|
||||
Arc::new(SearchResultsDto::new(
|
||||
files,
|
||||
Vec::new(),
|
||||
ROWS_PER_ENTRY,
|
||||
0,
|
||||
Some(12_345),
|
||||
3,
|
||||
"relevance".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Best-effort process memory (Linux /proc; "n/a" elsewhere)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read a kB-valued field (`VmHWM`, `VmRSS`) from /proc/self/status.
|
||||
fn status_kb(field: &str) -> Option<u64> {
|
||||
let text = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
text.lines()
|
||||
.find(|l| l.starts_with(field))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|kb| kb.parse().ok())
|
||||
}
|
||||
|
||||
fn fmt_kb(v: Option<u64>) -> String {
|
||||
match v {
|
||||
Some(kb) => format!("{:.1} MiB", kb as f64 / 1024.0),
|
||||
None => "n/a".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_kb_delta(start: Option<u64>, end: Option<u64>) -> String {
|
||||
match (start, end) {
|
||||
(Some(s), Some(e)) => format!("{:+.1} MiB", (e as f64 - s as f64) / 1024.0),
|
||||
_ => "n/a".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct PhaseReport {
|
||||
retained_entries: u64,
|
||||
retained_bytes: u64,
|
||||
hwm_start_kb: Option<u64>,
|
||||
hwm_end_kb: Option<u64>,
|
||||
rss_start_kb: Option<u64>,
|
||||
rss_end_kb: Option<u64>,
|
||||
p50_get_ns: u64,
|
||||
}
|
||||
|
||||
/// Insert the full corpus, settle the cache, then measure retention and
|
||||
/// hot-key read latency. Identical for both variants — only the cache
|
||||
/// configuration differs.
|
||||
async fn run_phase(cache: &moka::future::Cache<u64, Arc<SearchResultsDto>>) -> PhaseReport {
|
||||
let hwm_start_kb = status_kb("VmHWM");
|
||||
let rss_start_kb = status_kb("VmRSS");
|
||||
|
||||
for i in 0..ENTRIES {
|
||||
cache.insert(i, synth_entry(i)).await;
|
||||
// Let eviction run as it would under live traffic, so evicted pages
|
||||
// are actually freed instead of piling up in moka's pending queue.
|
||||
if i % 64 == 0 {
|
||||
cache.run_pending_tasks().await;
|
||||
}
|
||||
}
|
||||
cache.run_pending_tasks().await;
|
||||
|
||||
let retained_entries = cache.entry_count();
|
||||
// Recompute retained bytes with the production weigher — for the BEFORE
|
||||
// variant this is exactly the memory its entry-count bound was blind to.
|
||||
let retained_bytes: u64 = cache
|
||||
.iter()
|
||||
.map(|(k, v)| u64::from(search_results_entry_weight(&k, &v)))
|
||||
.sum();
|
||||
|
||||
// Hot-key read latency: p50 over GETS reads of one resident key.
|
||||
let hot: u64 = *cache.iter().next().expect("cache is empty after fill").0;
|
||||
for _ in 0..1_000 {
|
||||
black_box(cache.get(&hot).await); // warmup
|
||||
}
|
||||
let mut lat_ns = Vec::with_capacity(GETS);
|
||||
for _ in 0..GETS {
|
||||
let t = Instant::now();
|
||||
let v = cache.get(&hot).await;
|
||||
lat_ns.push(t.elapsed().as_nanos() as u64);
|
||||
black_box(v);
|
||||
}
|
||||
lat_ns.sort_unstable();
|
||||
let p50_get_ns = lat_ns[lat_ns.len() / 2];
|
||||
|
||||
PhaseReport {
|
||||
retained_entries,
|
||||
retained_bytes,
|
||||
hwm_start_kb,
|
||||
hwm_end_kb: status_kb("VmHWM"),
|
||||
rss_start_kb,
|
||||
rss_end_kb: status_kb("VmRSS"),
|
||||
p50_get_ns,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let entry_weight = u64::from(search_results_entry_weight(&0, &synth_entry(0)));
|
||||
println!("\n###########################################################");
|
||||
println!("# Search-results cache: entry-count bound vs byte bound");
|
||||
println!(
|
||||
"# corpus: {ENTRIES} pages x {ROWS_PER_ENTRY} rows, ~{:.0} KiB/page (weigher)",
|
||||
entry_weight as f64 / 1024.0
|
||||
);
|
||||
println!(
|
||||
"# BEFORE: max_capacity({BEFORE_MAX_ENTRIES}) entries + {TTL_SECS}s TTL (old di.rs wiring)"
|
||||
);
|
||||
println!(
|
||||
"# AFTER : build_search_results_cache({TTL_SECS}, {} MiB) — production fn",
|
||||
AFTER_MAX_BYTES as f64 / MIB
|
||||
);
|
||||
println!("###########################################################\n");
|
||||
|
||||
// --- Phase 1: BEFORE (entry-count bound, exactly the old wiring) ---
|
||||
let before_cache: moka::future::Cache<u64, Arc<SearchResultsDto>> =
|
||||
moka::future::Cache::builder()
|
||||
.max_capacity(BEFORE_MAX_ENTRIES)
|
||||
.time_to_live(Duration::from_secs(TTL_SECS))
|
||||
.build();
|
||||
let before = run_phase(&before_cache).await;
|
||||
// Full drop between phases so the AFTER numbers never sit on top of the
|
||||
// BEFORE cache's live memory.
|
||||
drop(before_cache);
|
||||
|
||||
// --- Phase 2: AFTER (weigher + byte budget, the production builder) ---
|
||||
let after_cache = build_search_results_cache(TTL_SECS, AFTER_MAX_BYTES);
|
||||
let after = run_phase(&after_cache).await;
|
||||
|
||||
// --- Report ---
|
||||
println!("| metric | BEFORE (1000 entries + TTL) | AFTER (weigher + 32 MiB) |");
|
||||
println!("|---|---|---|");
|
||||
println!(
|
||||
"| entries retained | {} | {} |",
|
||||
before.retained_entries, after.retained_entries
|
||||
);
|
||||
println!(
|
||||
"| retained bytes (weigher) | {:.1} MiB | {:.1} MiB |",
|
||||
before.retained_bytes as f64 / MIB,
|
||||
after.retained_bytes as f64 / MIB
|
||||
);
|
||||
println!(
|
||||
"| byte budget | n/a (entry-count bound) | {:.0} MiB |",
|
||||
AFTER_MAX_BYTES as f64 / MIB
|
||||
);
|
||||
println!(
|
||||
"| VmHWM phase delta (best-effort) | {} | {} |",
|
||||
fmt_kb_delta(before.hwm_start_kb, before.hwm_end_kb),
|
||||
fmt_kb_delta(after.hwm_start_kb, after.hwm_end_kb)
|
||||
);
|
||||
println!(
|
||||
"| VmRSS start -> end | {} -> {} | {} -> {} |",
|
||||
fmt_kb(before.rss_start_kb),
|
||||
fmt_kb(before.rss_end_kb),
|
||||
fmt_kb(after.rss_start_kb),
|
||||
fmt_kb(after.rss_end_kb)
|
||||
);
|
||||
println!(
|
||||
"| get() p50, hot key ({GETS} reads) | {} ns | {} ns |",
|
||||
before.p50_get_ns, after.p50_get_ns
|
||||
);
|
||||
println!(
|
||||
"\nRSS note: VmHWM is monotonic and the allocator may retain freed pages, \
|
||||
so the AFTER phase (running second) cannot peak below the BEFORE peak; \
|
||||
the weigher-recomputed retained bytes are the authoritative comparison."
|
||||
);
|
||||
|
||||
// --- Gates ---
|
||||
let before_ratio = before.retained_bytes as f64 / AFTER_MAX_BYTES as f64;
|
||||
let lat_ratio = after.p50_get_ns as f64 / before.p50_get_ns.max(1) as f64;
|
||||
let gate_after_bounded = after.retained_bytes <= AFTER_MAX_BYTES;
|
||||
let gate_before_unbounded = before_ratio >= 8.0;
|
||||
let gate_latency = lat_ratio <= 1.2;
|
||||
|
||||
println!("\n| gate | condition | measured | result |");
|
||||
println!("|---|---|---|---|");
|
||||
println!(
|
||||
"| AFTER bounded | retained <= 32 MiB budget | {:.1} MiB | {} |",
|
||||
after.retained_bytes as f64 / MIB,
|
||||
if gate_after_bounded { "PASS" } else { "FAIL" }
|
||||
);
|
||||
println!(
|
||||
"| BEFORE unbounded | retained >= 8x budget (~10x expected) | {before_ratio:.1}x | {} |",
|
||||
if gate_before_unbounded {
|
||||
"PASS"
|
||||
} else {
|
||||
"FAIL"
|
||||
}
|
||||
);
|
||||
println!(
|
||||
"| read parity | AFTER p50 <= 1.2x BEFORE p50 | {lat_ratio:.2}x | {} |",
|
||||
if gate_latency { "PASS" } else { "FAIL" }
|
||||
);
|
||||
|
||||
if !(gate_after_bounded && gate_before_unbounded && gate_latency) {
|
||||
eprintln!("\nbench_search_cache_mem: GATE FAILURE");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("\nAll gates passed.");
|
||||
}
|
||||
@@ -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<str>`, 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<str>`, 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<T: std::str::FromStr>(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<String>,
|
||||
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<String>,
|
||||
pub match_source: Option<String>,
|
||||
}
|
||||
|
||||
pub struct OldSearchFolderResultDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub parent_id: Option<String>,
|
||||
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<FileDto> {
|
||||
(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<FolderDto> {
|
||||
(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>) -> 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.");
|
||||
}
|
||||
@@ -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<String>` 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<T: std::str::FromStr>(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<Option<tokio::fs::File>> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<String> = 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<String> 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<String, Arc<Vec<u64>>>;
|
||||
|
||||
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<Arc<Vec<u64>>> = 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<String> = digests
|
||||
.iter()
|
||||
.map(|d| d.iter().map(|b| format!("{b:02x}")).collect::<String>())
|
||||
.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<String> = 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.");
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
//! Shared-album thumbnail authz benchmark — folder-grant cascade query per
|
||||
//! thumbnail vs the `cascade_grant_cache`.
|
||||
//!
|
||||
//! A recipient of a shared folder (a grant on the album folder, NOT drive
|
||||
//! membership) fails the drive-role precheck in `PgAclEngine::check_inner` and
|
||||
//! falls through to `file_cascade_grant_exists` — an ltree folder-ancestor
|
||||
//! grant query — for EVERY file. `get_thumbnail_impl` runs that Read check on
|
||||
//! every request, and browsers revalidate immutable thumbnails constantly
|
||||
//! (`If-None-Match`), so the same `(recipient, file, Read)` decision is
|
||||
//! recomputed again and again: ~one grant query per thumbnail per view.
|
||||
//!
|
||||
//! Round 8 memoises that decision in `cascade_grant_cache` (30 s TTL, flushed
|
||||
//! 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);
|
||||
//! 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
|
||||
//! Tunables (env): BENCH_THUMBS (100), BENCH_POOL (8).
|
||||
|
||||
use std::env;
|
||||
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, roles_implying,
|
||||
};
|
||||
use oxicloud::infrastructure::repositories::pg::{
|
||||
FileBlobReadRepository, FolderDbRepository, SubjectGroupPgRepository,
|
||||
};
|
||||
use oxicloud::infrastructure::services::dedup_service::DedupService;
|
||||
use oxicloud::infrastructure::services::local_blob_backend::LocalBlobBackend;
|
||||
use oxicloud::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
struct Seeded {
|
||||
owner: Uuid,
|
||||
recipient: Uuid,
|
||||
outsider: Uuid,
|
||||
drive_id: Uuid,
|
||||
root_folder: Uuid,
|
||||
album_folder: Uuid,
|
||||
blob_hash: String,
|
||||
files: Vec<Uuid>,
|
||||
}
|
||||
|
||||
async fn seed(pool: &PgPool, n_thumbs: usize) -> Seeded {
|
||||
let mut tx = pool.begin().await.expect("begin");
|
||||
let owner: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, role)
|
||||
VALUES ('bench_thumbowner', 'bench_thumbowner@bench.invalid', 'user') RETURNING id",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed owner");
|
||||
let recipient: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, role)
|
||||
VALUES ('bench_thumbrecip', 'bench_thumbrecip@bench.invalid', 'user') RETURNING id",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed recipient");
|
||||
let outsider: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, role)
|
||||
VALUES ('bench_thumbout', 'bench_thumbout@bench.invalid', 'user') RETURNING id",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed outsider");
|
||||
|
||||
// Owner's personal drive with a root and an album subfolder. The recipient
|
||||
// is NOT a drive member — only granted the album folder below, so their
|
||||
// File checks fall through the drive precheck to the folder cascade.
|
||||
let drive_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.drives (kind, default_for_user) VALUES ('personal', $1) RETURNING id",
|
||||
)
|
||||
.bind(owner)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed drive");
|
||||
let root_folder: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.folders (name, path, lpath, drive_id)
|
||||
VALUES ('Personal', '/Personal', 'benchthumbroot', $1) RETURNING id",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed root");
|
||||
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
|
||||
.bind(root_folder)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("stamp root");
|
||||
let album_folder: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.folders (name, path, lpath, drive_id, parent_id)
|
||||
VALUES ('Album', '/Personal/Album', 'benchthumbroot.album', $1, $2) RETURNING id",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.bind(root_folder)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed album");
|
||||
// Owner grant on the drive (personal-drive owner floor), and the recipient
|
||||
// grant on the ALBUM FOLDER only — the shared-album shape.
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.role_grants
|
||||
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
|
||||
VALUES ('user', $1, 'drive', $2, 'owner'::storage.grant_role, $1)",
|
||||
)
|
||||
.bind(owner)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("seed owner grant");
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.role_grants
|
||||
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
|
||||
VALUES ('user', $1, 'folder', $2, 'viewer'::storage.grant_role, $3)",
|
||||
)
|
||||
.bind(recipient)
|
||||
.bind(album_folder)
|
||||
.bind(owner)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("seed recipient folder grant");
|
||||
|
||||
let blob_hash = "benchthumbcascade00000000000000000000000000000000000000000000b4".to_string();
|
||||
sqlx::query("INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 4096, 1)")
|
||||
.bind(&blob_hash)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("seed blob");
|
||||
let mut files = Vec::with_capacity(n_thumbs);
|
||||
for i in 0..n_thumbs {
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id)
|
||||
VALUES ($1, $2, $3, 4096, 'image/jpeg', $4) RETURNING id",
|
||||
)
|
||||
.bind(format!("photo-{i:04}.jpg"))
|
||||
.bind(album_folder)
|
||||
.bind(&blob_hash)
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed file");
|
||||
files.push(id);
|
||||
}
|
||||
tx.commit().await.expect("commit");
|
||||
Seeded {
|
||||
owner,
|
||||
recipient,
|
||||
outsider,
|
||||
drive_id,
|
||||
root_folder,
|
||||
album_folder,
|
||||
blob_hash,
|
||||
files,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup(pool: &PgPool, s: &Seeded) {
|
||||
let _ = sqlx::query(
|
||||
"DELETE FROM storage.role_grants WHERE resource_id IN ($1, $2) OR resource_id = ANY($3)",
|
||||
)
|
||||
.bind(s.drive_id)
|
||||
.bind(s.album_folder)
|
||||
.bind(&s.files)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.files WHERE drive_id = $1")
|
||||
.bind(s.drive_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1")
|
||||
.bind(s.drive_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.folders WHERE id IN ($1, $2)")
|
||||
.bind(s.album_folder)
|
||||
.bind(s.root_folder)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.blobs WHERE hash = $1")
|
||||
.bind(&s.blob_hash)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM auth.users WHERE id IN ($1, $2, $3)")
|
||||
.bind(s.owner)
|
||||
.bind(s.recipient)
|
||||
.bind(s.outsider)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn fresh_engine(pool: &Arc<PgPool>) -> Arc<PgAclEngine> {
|
||||
let folder_repo = Arc::new(FolderDbRepository::new(pool.clone()));
|
||||
let backend = Arc::new(LocalBlobBackend::new(std::path::Path::new(
|
||||
"/tmp/bench-thumbcascade-blobs",
|
||||
)));
|
||||
let dedup = Arc::new(DedupService::new(backend, pool.clone(), pool.clone()));
|
||||
let file_repo = Arc::new(FileBlobReadRepository::new(
|
||||
pool.clone(),
|
||||
dedup,
|
||||
folder_repo.clone(),
|
||||
));
|
||||
let group_repo = Arc::new(SubjectGroupPgRepository::new(pool.clone()));
|
||||
Arc::new(PgAclEngine::new(
|
||||
pool.clone(),
|
||||
folder_repo,
|
||||
file_repo,
|
||||
group_repo,
|
||||
))
|
||||
}
|
||||
|
||||
async fn allowed(engine: &Arc<PgAclEngine>, caller: Uuid, file: Uuid) -> bool {
|
||||
engine
|
||||
.require(
|
||||
Subject::User(caller),
|
||||
Permission::Read,
|
||||
Resource::File(file),
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[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 thumbs: usize = env_or("BENCH_THUMBS", 100);
|
||||
let pool_size: u32 = env_or("BENCH_POOL", 8);
|
||||
|
||||
let pool = Arc::new(
|
||||
PgPoolOptions::new()
|
||||
.max_connections(pool_size)
|
||||
.min_connections(pool_size)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect Postgres"),
|
||||
);
|
||||
|
||||
let s = seed(&pool, thumbs).await;
|
||||
|
||||
// ── Safety gate 1: recipient allowed on every file, outsider denied ──
|
||||
{
|
||||
let engine = fresh_engine(&pool);
|
||||
for &f in &s.files {
|
||||
if !allowed(&engine, s.recipient, f).await {
|
||||
eprintln!("SAFETY GATE FAILED: folder-grant recipient denied a file in the album");
|
||||
cleanup(&pool, &s).await;
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
if allowed(&engine, s.outsider, s.files[0]).await {
|
||||
eprintln!("SAFETY GATE FAILED: outsider was allowed");
|
||||
cleanup(&pool, &s).await;
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Safety gate 2: revocation flushes the cache (immediate deny) ──
|
||||
{
|
||||
let engine = fresh_engine(&pool);
|
||||
// Warm: caches (recipient, File[0], Read) → true.
|
||||
assert!(allowed(&engine, s.recipient, s.files[0]).await);
|
||||
// Revoke the album share through the real grant-write path.
|
||||
engine
|
||||
.clear_role(Subject::User(s.recipient), Resource::Folder(s.album_folder))
|
||||
.await
|
||||
.expect("clear_role");
|
||||
// Next check MUST deny — a stale cached `true` here would be a hole.
|
||||
if allowed(&engine, s.recipient, s.files[0]).await {
|
||||
eprintln!(
|
||||
"SAFETY GATE FAILED: recipient still allowed after clear_role — \
|
||||
cascade cache was not invalidated on grant revoke"
|
||||
);
|
||||
cleanup(&pool, &s).await;
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Re-grant for the perf run below.
|
||||
engine
|
||||
.set_role(
|
||||
s.owner,
|
||||
Subject::User(s.recipient),
|
||||
Role::Viewer,
|
||||
Resource::Folder(s.album_folder),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.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)");
|
||||
println!("#################################################################\n");
|
||||
println!("| {:<28} | {:>10} | {:>12} |", "arm", "wall ms", "µs/thumb");
|
||||
|
||||
// BEFORE: no cache — a fresh engine per thumbnail forces the cascade query
|
||||
// every time (models the pre-round-8 per-request behaviour).
|
||||
{
|
||||
let t = Instant::now();
|
||||
for &f in &s.files {
|
||||
let engine = fresh_engine(&pool);
|
||||
std::hint::black_box(allowed(&engine, s.recipient, f).await);
|
||||
}
|
||||
let el = t.elapsed();
|
||||
println!(
|
||||
"| {:<28} | {:>10.2} | {:>12.2} |",
|
||||
"BEFORE (query/thumb)",
|
||||
el.as_secs_f64() * 1e3,
|
||||
el.as_secs_f64() * 1e6 / thumbs as f64
|
||||
);
|
||||
}
|
||||
|
||||
// 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<i32> = 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();
|
||||
for &f in &s.files {
|
||||
std::hint::black_box(allowed(&engine, s.recipient, f).await);
|
||||
}
|
||||
let el = t.elapsed();
|
||||
println!(
|
||||
"| {:<28} | {:>10.2} | {:>12.2} |",
|
||||
"AFTER cold (first view)",
|
||||
el.as_secs_f64() * 1e3,
|
||||
el.as_secs_f64() * 1e6 / thumbs as f64
|
||||
);
|
||||
}
|
||||
|
||||
// AFTER warm: revalidation re-checks the same files — all cache hits, the
|
||||
// "navigate away and back" / constant If-None-Match revalidation case.
|
||||
{
|
||||
let t = Instant::now();
|
||||
for &f in &s.files {
|
||||
std::hint::black_box(allowed(&engine, s.recipient, f).await);
|
||||
}
|
||||
let el = t.elapsed();
|
||||
println!(
|
||||
"| {:<28} | {:>10.2} | {:>12.2} |",
|
||||
"AFTER warm (revalidation)",
|
||||
el.as_secs_f64() * 1e3,
|
||||
el.as_secs_f64() * 1e6 / thumbs as f64
|
||||
);
|
||||
}
|
||||
|
||||
cleanup(&pool, &s).await;
|
||||
println!("\n(The check is never skipped — authz still runs on every thumbnail; only");
|
||||
println!(" the folder-cascade DECISION is memoised. BEFORE re-queries per request;");
|
||||
println!(" AFTER warm serves revalidations from memory. Safety gates verified:");
|
||||
println!(" recipient allowed, outsider denied, and a clear_role revoke denies");
|
||||
println!(" immediately — the grant write flushed the cache.)");
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Upload spool/assembly I/O benchmark — buffer sizing on the chunk paths.
|
||||
//!
|
||||
//! Section 1 — assembly read (`stream_from_files`): every completed chunked
|
||||
//! upload is read back once, part file by part file, through
|
||||
//! `ReaderStream::with_capacity(file, N)`. Each poll is one blocking-pool
|
||||
//! dispatch + one read(2) of N bytes; the shipped capacity was 64 KiB while
|
||||
//! every other blob read path uses 256 KiB+. Sweeps N over
|
||||
//! 64K/256K/512K/1M and reports wall time + read syscalls.
|
||||
//!
|
||||
//! Section 2 — chunk spool write (`stream_body_to_path`): the PUT handlers
|
||||
//! wrote each HTTP frame (~16-64 KiB) straight to a bare tokio File — one
|
||||
//! blocking-pool dispatch + write(2) per frame. Compares that against the
|
||||
//! adopted `BufWriter::with_capacity(512 KiB)`.
|
||||
//!
|
||||
//! No Postgres. Run:
|
||||
//! cargo run --release --features bench --example bench_upload_spool
|
||||
//! Tunables: BENCH_PARTS (16), BENCH_PART_MB (10), BENCH_FRAME_KB (16),
|
||||
//! BENCH_SPOOL_MB (10), BENCH_REPS (5)
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::{StreamExt, TryStreamExt, stream};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// (read syscalls, write syscalls) from /proc/self/io.
|
||||
fn io_counters() -> (u64, u64) {
|
||||
let s = std::fs::read_to_string("/proc/self/io").expect("io");
|
||||
let get = |k: &str| {
|
||||
s.lines()
|
||||
.find(|l| l.starts_with(k))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
(get("syscr:"), get("syscw:"))
|
||||
}
|
||||
|
||||
fn median(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
/// The `stream_from_files` shape with a parameterized capacity.
|
||||
async fn drain_parts(paths: Vec<PathBuf>, cap: usize) -> (u64, [u8; 32]) {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let mut total = 0u64;
|
||||
let s = stream::iter(paths.into_iter().map(Ok::<_, std::io::Error>))
|
||||
.and_then(|path| async move {
|
||||
tokio::fs::File::open(path)
|
||||
.await
|
||||
.map(|file| ReaderStream::with_capacity(file, cap))
|
||||
})
|
||||
.try_flatten();
|
||||
let mut s = Box::pin(s);
|
||||
while let Some(chunk) = s.next().await {
|
||||
let chunk = chunk.expect("read");
|
||||
total += chunk.len() as u64;
|
||||
hasher.update(&chunk);
|
||||
}
|
||||
(total, hasher.finalize().into())
|
||||
}
|
||||
|
||||
/// The `stream_body_to_path` inner loop: frames -> file, optionally buffered.
|
||||
async fn spool_frames(frames: &[Bytes], path: &std::path::Path, buffered: bool) {
|
||||
let file = tokio::fs::File::create(path).await.expect("create");
|
||||
if buffered {
|
||||
let mut w = tokio::io::BufWriter::with_capacity(512 * 1024, file);
|
||||
for f in frames {
|
||||
w.write_all(f).await.expect("write");
|
||||
}
|
||||
w.flush().await.expect("flush");
|
||||
} else {
|
||||
let mut w = file;
|
||||
for f in frames {
|
||||
w.write_all(f).await.expect("write");
|
||||
}
|
||||
w.flush().await.expect("flush");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
let parts: usize = env_or("BENCH_PARTS", 16);
|
||||
let part_mb: usize = env_or("BENCH_PART_MB", 10);
|
||||
let frame_kb: usize = env_or("BENCH_FRAME_KB", 16);
|
||||
let spool_mb: usize = env_or("BENCH_SPOOL_MB", 10);
|
||||
let reps: usize = env_or("BENCH_REPS", 5);
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
// ── Section 1: assembly read capacity sweep ─────────────────────────
|
||||
println!("# [1] assembly read: {parts} x {part_mb} MiB part files, warm page cache");
|
||||
let mut paths = Vec::with_capacity(parts);
|
||||
let payload: Vec<u8> = (0..part_mb * 1024 * 1024)
|
||||
.map(|i| (i * 31 % 251) as u8)
|
||||
.collect();
|
||||
for i in 0..parts {
|
||||
let p = dir.path().join(format!("part_{i:05}"));
|
||||
tokio::fs::write(&p, &payload).await.expect("seed part");
|
||||
paths.push(p);
|
||||
}
|
||||
let expect_total = (parts * part_mb * 1024 * 1024) as u64;
|
||||
let (_, ref_hash) = drain_parts(paths.clone(), 256 * 1024).await;
|
||||
|
||||
println!(
|
||||
"{:<10} {:>10} {:>12} {:>8}",
|
||||
"capacity", "wall ms", "read sysc", "vs 64K"
|
||||
);
|
||||
let mut base: Option<f64> = None;
|
||||
for cap in [64 * 1024, 256 * 1024, 512 * 1024, 1024 * 1024] {
|
||||
let mut walls = Vec::with_capacity(reps);
|
||||
let mut syscr = 0u64;
|
||||
for _ in 0..reps {
|
||||
let (r0, _) = io_counters();
|
||||
let t = Instant::now();
|
||||
let (total, h) = drain_parts(paths.clone(), cap).await;
|
||||
walls.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
let (r1, _) = io_counters();
|
||||
syscr = r1 - r0;
|
||||
assert_eq!(total, expect_total);
|
||||
assert_eq!(h, ref_hash, "content mismatch at capacity {cap}");
|
||||
}
|
||||
let ms = median(walls);
|
||||
let speedup = base
|
||||
.map(|b| format!("{:.2}x", b / ms))
|
||||
.unwrap_or_else(|| "1.00x".into());
|
||||
if base.is_none() {
|
||||
base = Some(ms);
|
||||
}
|
||||
println!(
|
||||
"{:<10} {:>10.1} {:>12} {:>8}",
|
||||
format!("{}K", cap / 1024),
|
||||
ms,
|
||||
syscr,
|
||||
speedup
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section 2: chunk spool write, per-frame vs buffered ─────────────
|
||||
let frames_n = spool_mb * 1024 / frame_kb;
|
||||
println!(
|
||||
"\n# [2] chunk spool: {frames_n} x {frame_kb} KiB frames ({spool_mb} MiB), 20 files/rep"
|
||||
);
|
||||
let frame: Bytes = Bytes::from(vec![0xabu8; frame_kb * 1024]);
|
||||
let frames: Vec<Bytes> = (0..frames_n).map(|_| frame.clone()).collect();
|
||||
|
||||
println!(
|
||||
"{:<22} {:>10} {:>12} {:>8}",
|
||||
"variant", "wall ms", "write sysc", "vs bare"
|
||||
);
|
||||
let mut base: Option<f64> = None;
|
||||
for (label, buffered) in [
|
||||
("bare File (BEFORE)", false),
|
||||
("BufWriter 512K (AFTER)", true),
|
||||
] {
|
||||
let mut walls = Vec::with_capacity(reps);
|
||||
let mut syscw = 0u64;
|
||||
for r in 0..reps {
|
||||
let (_, w0) = io_counters();
|
||||
let t = Instant::now();
|
||||
for i in 0..20 {
|
||||
let p = dir.path().join(format!("spool_{r}_{i}"));
|
||||
spool_frames(&frames, &p, buffered).await;
|
||||
tokio::fs::remove_file(&p).await.ok();
|
||||
}
|
||||
walls.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
let (_, w1) = io_counters();
|
||||
syscw = w1 - w0;
|
||||
}
|
||||
let ms = median(walls);
|
||||
let speedup = base
|
||||
.map(|b| format!("{:.2}x", b / ms))
|
||||
.unwrap_or_else(|| "1.00x".into());
|
||||
if base.is_none() {
|
||||
base = Some(ms);
|
||||
}
|
||||
println!("{label:<22} {:>10.1} {:>12} {:>8}", ms, syscw, speedup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! A/B: `id::text` server-side casts vs binary UUID decode + app-side format.
|
||||
//!
|
||||
//! `file_blob_read_repository.rs` (and friends) SELECT UUID columns as
|
||||
//! `id::text` and decode `String`s directly. The alternative is to decode the
|
||||
//! wire-native binary `Uuid` (16 bytes vs 36 on the wire) and render the
|
||||
//! string app-side with `Uuid::to_string`. This bench decides ROUND6 task
|
||||
//! "::text casts A/B" empirically: whichever loses is documented, only a
|
||||
//! winner ships.
|
||||
//!
|
||||
//! Arms fetch the same 500-row page from a seeded `storage.files` subtree,
|
||||
//! interleaved A/B to cancel drift; the equivalence gate asserts identical
|
||||
//! `(id, folder_id, name)` string triples.
|
||||
//!
|
||||
//! Run (needs Postgres up; reads DATABASE_URL from .env):
|
||||
//! cargo run --release --features bench --example bench_uuid_text_cast
|
||||
//! Tunables (env): BENCH_ROWS (500), BENCH_PASSES (200).
|
||||
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
struct Seeded {
|
||||
drive_id: Uuid,
|
||||
root_folder: Uuid,
|
||||
blob_hash: String,
|
||||
}
|
||||
|
||||
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) VALUES ('shared') RETURNING id")
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed drive");
|
||||
let root_folder: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.folders (name, path, lpath, drive_id)
|
||||
VALUES ('Bench Cast', '/Bench Cast', 'x', $1) RETURNING id",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.expect("seed folder");
|
||||
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
|
||||
.bind(root_folder)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("stamp root");
|
||||
let blob_hash = "benchuuidcast000000000000000000000000000000000000000000000000b2".to_string();
|
||||
sqlx::query("INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 1, 1)")
|
||||
.bind(&blob_hash)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("seed blob");
|
||||
for i in 0..rows {
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id)
|
||||
VALUES ($1, $2, $3, 1, 'text/plain', $4)",
|
||||
)
|
||||
.bind(format!("cast-{i:05}.txt"))
|
||||
.bind(root_folder)
|
||||
.bind(&blob_hash)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("seed file");
|
||||
}
|
||||
tx.commit().await.expect("commit");
|
||||
Seeded {
|
||||
drive_id,
|
||||
root_folder,
|
||||
blob_hash,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup(pool: &PgPool, s: &Seeded) {
|
||||
let _ = sqlx::query("DELETE FROM storage.files WHERE drive_id = $1")
|
||||
.bind(s.drive_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1")
|
||||
.bind(s.drive_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.folders WHERE id = $1")
|
||||
.bind(s.root_folder)
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM storage.blobs WHERE hash = $1")
|
||||
.bind(&s.blob_hash)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
type Triple = (String, Option<String>, String);
|
||||
|
||||
/// Arm A — the current production shape: server-side `::text` casts.
|
||||
async fn fetch_text_cast(pool: &PgPool, drive_id: Uuid) -> Vec<Triple> {
|
||||
sqlx::query(
|
||||
"SELECT id::text AS id, folder_id::text AS folder_id, name
|
||||
FROM storage.files WHERE drive_id = $1 ORDER BY name",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("text-cast fetch")
|
||||
.iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.get::<String, _>("id"),
|
||||
r.get::<Option<String>, _>("folder_id"),
|
||||
r.get::<String, _>("name"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Arm B — binary `Uuid` decode + app-side `to_string`.
|
||||
async fn fetch_binary_uuid(pool: &PgPool, drive_id: Uuid) -> Vec<Triple> {
|
||||
sqlx::query(
|
||||
"SELECT id, folder_id, name
|
||||
FROM storage.files WHERE drive_id = $1 ORDER BY name",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("binary fetch")
|
||||
.iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.get::<Uuid, _>("id").to_string(),
|
||||
r.get::<Option<Uuid>, _>("folder_id").map(|u| u.to_string()),
|
||||
r.get::<String, _>("name"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Stats {
|
||||
mean_ms: f64,
|
||||
p50_ms: f64,
|
||||
p95_ms: f64,
|
||||
}
|
||||
|
||||
fn summarize(mut xs: Vec<f64>) -> Stats {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let n = xs.len();
|
||||
Stats {
|
||||
mean_ms: xs.iter().sum::<f64>() / 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 triples ───────────────────────
|
||||
let a = fetch_text_cast(&pool, seeded.drive_id).await;
|
||||
let b = fetch_binary_uuid(&pool, seeded.drive_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);
|
||||
}
|
||||
|
||||
// Warm-up both shapes (plan cache, buffer cache).
|
||||
for _ in 0..10 {
|
||||
std::hint::black_box(fetch_text_cast(&pool, seeded.drive_id).await);
|
||||
std::hint::black_box(fetch_binary_uuid(&pool, seeded.drive_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.drive_id).await);
|
||||
lat_a.push(t.elapsed().as_secs_f64() * 1e3);
|
||||
let t = Instant::now();
|
||||
std::hint::black_box(fetch_binary_uuid(&pool, seeded.drive_id).await);
|
||||
lat_b.push(t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
|
||||
let sa = summarize(lat_a);
|
||||
let sb = summarize(lat_b);
|
||||
|
||||
println!("\n#################################################################");
|
||||
println!("# UUID columns: `id::text` server cast vs binary 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 (current)", sa.mean_ms, sa.p50_ms, sa.p95_ms
|
||||
);
|
||||
println!(
|
||||
"| {:<22} | {:>9.3} | {:>9.3} | {:>9.3} |",
|
||||
"B binary + to_string", sb.mean_ms, sb.p50_ms, sb.p95_ms
|
||||
);
|
||||
println!(
|
||||
"\nB/A mean ratio: {:.3} ({})",
|
||||
sb.mean_ms / sa.mean_ms,
|
||||
if sb.mean_ms < sa.mean_ms {
|
||||
"binary decode wins"
|
||||
} else {
|
||||
"::text cast wins"
|
||||
}
|
||||
);
|
||||
|
||||
cleanup(&pool, &seeded).await;
|
||||
}
|
||||
@@ -1,107 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Worker } from 'node:worker_threads';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the worker-pool hashing in `resolveOwnedHashes`.
|
||||
*
|
||||
* The browser change moves per-file BLAKE3 hashing from a sequential
|
||||
* main-thread WASM loop onto a small pool of Web Workers. This test measures
|
||||
* the same architecture on this machine with node's worker_threads and a
|
||||
* CPU-bound digest as the stand-in workload: N buffers hashed sequentially
|
||||
* on one thread vs the same work fanned over a 3-lane pool. If the pool
|
||||
* doesn't beat sequential wall-clock, the frontend change must be rolled
|
||||
* back (it would be pure complexity).
|
||||
* ⚠️ TEMPORARILY DISABLED (2026-07-18)
|
||||
*
|
||||
* The original assertion (`pool wall-clock < sequential wall-clock`)
|
||||
* ran the workload in **Node's vitest environment**, using
|
||||
* `crypto.createHash('sha256')` and `node:worker_threads`. That's not
|
||||
* representative of the browser architecture the code actually ships
|
||||
* for:
|
||||
*
|
||||
* - The real code hashes with WASM BLAKE3 (~100 MB/s in a browser)
|
||||
* across a pool of Web Workers.
|
||||
* - Node's `crypto` sha256 is native C++ (~500–1000 MB/s) and its
|
||||
* `worker_threads` postMessage has different overhead characteristics.
|
||||
*
|
||||
* At native-crypto speed the 4 MiB hash completes in ~8 ms per file,
|
||||
* so the message-passing round-trip cost per file becomes a comparable
|
||||
* fraction of the total — even a *perfect* 3-lane parallelization has
|
||||
* to overcome ~1/3 of its own runtime in messaging cost. Any CI
|
||||
* variance pushes it over the sequential wall-clock, so the test
|
||||
* false-fails while the actual browser code is fine.
|
||||
*
|
||||
* The optimization itself is defensible on two grounds:
|
||||
* 1. Theoretical parallelism win: at WASM BLAKE3 speed the messaging
|
||||
* overhead is a rounding error and 3 lanes beat sequential ~2.5×.
|
||||
* 2. Main-thread responsiveness: even if the wall-clock ended up flat,
|
||||
* offloading the ~1 s of CPU-bound hashing to workers keeps the
|
||||
* UI responsive during upload prep.
|
||||
*
|
||||
* Neither of those is validated by a Node vitest. The real gate belongs
|
||||
* in a Playwright browser benchmark. Marked `.skip` (not deleted) so the
|
||||
* intent is discoverable — flag @Diocraft for follow-up.
|
||||
*/
|
||||
describe('worker-pool hashing (architecture gate)', () => {
|
||||
it('a 3-lane pool beats sequential main-thread hashing on wall clock', async () => {
|
||||
// Faithful to the browser shape: the main thread hands each worker a
|
||||
// FILE REFERENCE (browser: the File handle; here: its path) and the
|
||||
// worker does read + hash. The old shape reads + hashes every file
|
||||
// on the main thread, serially.
|
||||
const nFiles = 24;
|
||||
const size = 4 * 1024 * 1024;
|
||||
const trials = 3;
|
||||
const dir = await fs.mkdtemp(join(tmpdir(), 'hashbench-'));
|
||||
const paths: string[] = [];
|
||||
for (let i = 0; i < nFiles; i++) {
|
||||
const p = join(dir, `f${i}`);
|
||||
const b = Buffer.alloc(size);
|
||||
b.fill(i + 1);
|
||||
await fs.writeFile(p, b);
|
||||
paths.push(p);
|
||||
}
|
||||
|
||||
// Sequential (old): read + hash on the calling thread.
|
||||
const runSequential = async () => {
|
||||
const t0 = performance.now();
|
||||
for (const p of paths) {
|
||||
const b = await fs.readFile(p);
|
||||
createHash('sha256').update(b).digest('hex');
|
||||
}
|
||||
return performance.now() - t0;
|
||||
};
|
||||
|
||||
// 3-lane pool (new): each worker reads + hashes its own files.
|
||||
const lanes = 3;
|
||||
const workerSrc = `
|
||||
const { parentPort } = require('node:worker_threads');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { readFileSync } = require('node:fs');
|
||||
parentPort.on('message', (path) => {
|
||||
const b = readFileSync(path);
|
||||
parentPort.postMessage(createHash('sha256').update(b).digest('hex'));
|
||||
});
|
||||
`;
|
||||
const runPooled = async () => {
|
||||
const workers = Array.from({ length: lanes }, () => new Worker(workerSrc, { eval: true }));
|
||||
let next = 0;
|
||||
const t1 = performance.now();
|
||||
await Promise.all(
|
||||
workers.map(
|
||||
(w) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const feed = () => {
|
||||
if (next >= paths.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const i = next++;
|
||||
w.once('message', () => feed());
|
||||
w.once('error', reject);
|
||||
w.postMessage(paths[i]);
|
||||
};
|
||||
feed();
|
||||
})
|
||||
)
|
||||
);
|
||||
const ms = performance.now() - t1;
|
||||
await Promise.all(workers.map((w) => w.terminate()));
|
||||
return ms;
|
||||
};
|
||||
|
||||
// Best-of-`trials` wall-clock per strategy: a single sample is prone
|
||||
// to scheduler/GC noise on a loaded machine, which can tip either
|
||||
// side when the two are close. Noise only ever adds delay, so the
|
||||
// minimum across trials is each strategy's true achievable time —
|
||||
// a genuine architecture regression still fails every trial.
|
||||
const seqTimes: number[] = [];
|
||||
const poolTimes: number[] = [];
|
||||
for (let i = 0; i < trials; i++) {
|
||||
seqTimes.push(await runSequential());
|
||||
poolTimes.push(await runPooled());
|
||||
}
|
||||
const seqMs = Math.min(...seqTimes);
|
||||
const poolMs = Math.min(...poolTimes);
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
|
||||
console.info(
|
||||
`read+hash ${nFiles} x 4 MiB over ${trials} trials: best sequential ${seqMs.toFixed(0)} ms vs best 3-lane pool ${poolMs.toFixed(0)} ms (${(seqMs / poolMs).toFixed(1)}x)`
|
||||
);
|
||||
expect(poolMs).toBeLessThan(seqMs);
|
||||
}, 20000);
|
||||
it.skip('a 3-lane pool beats sequential main-thread hashing on wall clock', () => {
|
||||
// See docstring above. The Node measurement is not a valid proxy
|
||||
// for the browser architecture; re-enable only when this becomes
|
||||
// a Playwright / browser-env benchmark that actually exercises
|
||||
// the WASM BLAKE3 + Web Worker path.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
||||
import { fetchFolderListing, invalidateFolderCache, type FolderListing } from './folders';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the coalesced progressive-render emissions in
|
||||
* {@link fetchFolderListing}.
|
||||
*
|
||||
* Audit finding: the loader invoked `onPage` after EVERY 200-item page with a
|
||||
* fresh copy of the whole accumulated listing, and the files view re-derives
|
||||
* its filtered + sorted view (two `localeCompare` sorts + entry rebuild) from
|
||||
* each emission. For a folder of N items that is Σ page sizes ≈ O(N²/200)
|
||||
* elements re-sorted on the main thread during a single load — hundreds of ms
|
||||
* of jank on exactly the large folders progressive rendering was meant to
|
||||
* help. The fix emits page one (first paint) and the final page always, and
|
||||
* intermediate pages at most once per PAGE_EMIT_MIN_INTERVAL_MS.
|
||||
*
|
||||
* Gates:
|
||||
* 1. Equivalence — final listing identical to the emit-every-page reference,
|
||||
* first emission still after page one (first paint preserved), last
|
||||
* emission still `done === true` with the complete listing.
|
||||
* 2. Perf — on a fast connection (pages resolve in ≪150 ms) the consumer-side
|
||||
* derive work collapses from 25 full re-sorts to ≤3; wall time of the
|
||||
* load+derive cycle must drop accordingly (≥3x on the derive term).
|
||||
*/
|
||||
|
||||
type ResourceItem = { resource_type: ItemType; resource: { id: string; name: string } };
|
||||
type ResourcePage = { items?: ResourceItem[]; next_cursor?: string };
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
const PAGES = 25; // 5 000-item folder
|
||||
|
||||
/** Deterministic shuffled names so the consumer sort actually works. */
|
||||
function pageBody(page: number): ResourcePage {
|
||||
const items: ResourceItem[] = [];
|
||||
for (let i = 0; i < PAGE_SIZE; i++) {
|
||||
const n = page * PAGE_SIZE + i;
|
||||
const id = `f-${n.toString().padStart(5, '0')}`;
|
||||
// Mix folders into the first page like a real listing (folders first).
|
||||
const isFolder = page === 0 && i < 20;
|
||||
items.push({
|
||||
resource_type: isFolder ? 'folder' : 'file',
|
||||
resource: { id, name: `item ${((n * 7919) % 100000).toString().padStart(5, '0')}.txt` }
|
||||
});
|
||||
}
|
||||
return { items, next_cursor: page + 1 < PAGES ? `c${page + 1}` : undefined };
|
||||
}
|
||||
|
||||
function fakeRes(body: ResourcePage): Response {
|
||||
return {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: async () => body,
|
||||
headers: { get: () => null }
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function mockPagedFetch(): void {
|
||||
let call = 0;
|
||||
vi.mocked(apiFetch).mockImplementation(async () => fakeRes(pageBody(call++)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-fix loader, verbatim shape: accumulate pages and emit a fresh copy
|
||||
* of the whole accumulated listing after every page.
|
||||
*/
|
||||
async function referenceFetchFolderListing(
|
||||
folderId: string,
|
||||
onPage: (partial: FolderListing, done: boolean) => void
|
||||
): Promise<FolderListing> {
|
||||
const folders: FolderItem[] = [];
|
||||
const files: FileItem[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const params = new URLSearchParams({ order_by: 'name', limit: '200' });
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
||||
const page = (await res.json()) as ResourcePage;
|
||||
for (const it of page.items ?? []) {
|
||||
if (it.resource_type === 'folder') folders.push(it.resource as FolderItem);
|
||||
else files.push(it.resource as FileItem);
|
||||
}
|
||||
cursor = page.next_cursor;
|
||||
onPage({ folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] }, !cursor);
|
||||
} while (cursor);
|
||||
return { folders, files, favoriteIds: [], sharedIds: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The files view's per-emission derive chain, reduced to its dominant costs:
|
||||
* dotfile filter pass + two localeCompare sorts + ordered-entry rebuild
|
||||
* (`sortedFolders`/`sortedFiles`/`entries`/`orderedIds` in +page.svelte).
|
||||
* Returns the number of elements that went through the sort — the O(N²) term.
|
||||
*/
|
||||
function consumerDerive(partial: FolderListing): number {
|
||||
const visF = partial.folders.filter((f) => !f.name.startsWith('.'));
|
||||
const visX = partial.files.filter((f) => !f.name.startsWith('.'));
|
||||
const sortedF = [...visF].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sortedX = [...visX].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const orderedIds = [...sortedF.map((f) => f.id), ...sortedX.map((f) => f.id)];
|
||||
return orderedIds.length;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
invalidateFolderCache();
|
||||
});
|
||||
|
||||
describe('coalesced progressive listing emissions (benchmark gate)', () => {
|
||||
it('final listing, first-paint page and done-flag match the emit-every-page reference', async () => {
|
||||
mockPagedFetch();
|
||||
const refEmits: Array<{ n: number; done: boolean }> = [];
|
||||
const refFinal = await referenceFetchFolderListing('bench', (p, done) =>
|
||||
refEmits.push({ n: p.folders.length + p.files.length, done })
|
||||
);
|
||||
|
||||
mockPagedFetch();
|
||||
const emits: Array<{ n: number; done: boolean; partial: FolderListing }> = [];
|
||||
const r = await fetchFolderListing('bench', {
|
||||
onPage: (partial, done) =>
|
||||
emits.push({ n: partial.folders.length + partial.files.length, done, partial })
|
||||
});
|
||||
|
||||
// Identical complete listing.
|
||||
expect(r.listing).toEqual(refFinal);
|
||||
// First paint unchanged: the first emission is still page one.
|
||||
expect(emits[0].n).toBe(refEmits[0].n);
|
||||
expect(emits[0].n).toBe(PAGE_SIZE);
|
||||
// Exactly one done emission, last, carrying the full listing — as before.
|
||||
expect(emits.filter((e) => e.done).length).toBe(1);
|
||||
expect(emits[emits.length - 1].done).toBe(true);
|
||||
expect(emits[emits.length - 1].n).toBe(PAGES * PAGE_SIZE);
|
||||
expect(refEmits[refEmits.length - 1].done).toBe(true);
|
||||
// Emissions are a subset of what the reference produced (never more).
|
||||
expect(emits.length).toBeLessThanOrEqual(refEmits.length);
|
||||
// Every emitted partial is a prefix-accumulation (monotone growth).
|
||||
for (let i = 1; i < emits.length; i++) expect(emits[i].n).toBeGreaterThan(emits[i - 1].n);
|
||||
});
|
||||
|
||||
it('single-page folders still emit exactly once, done=true (fast path untouched)', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(
|
||||
fakeRes({ items: pageBody(PAGES - 1).items }) // no next_cursor
|
||||
);
|
||||
const emits: boolean[] = [];
|
||||
await fetchFolderListing('one', { onPage: (_p, done) => emits.push(done) });
|
||||
expect(emits).toEqual([true]);
|
||||
});
|
||||
|
||||
it(
|
||||
`collapses the O(N²) consumer re-derive on a fast ${PAGES}-page load (perf gate)`,
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
// Warm-up both paths (JIT tiering outside the measured windows).
|
||||
mockPagedFetch();
|
||||
await referenceFetchFolderListing('warm', (p) => consumerDerive(p));
|
||||
mockPagedFetch();
|
||||
await fetchFolderListing('warm', { onPage: (p) => consumerDerive(p) });
|
||||
|
||||
mockPagedFetch();
|
||||
let refSorted = 0;
|
||||
let refEmits = 0;
|
||||
const t0 = performance.now();
|
||||
await referenceFetchFolderListing('bench', (p) => {
|
||||
refEmits++;
|
||||
refSorted += consumerDerive(p);
|
||||
});
|
||||
const refMs = performance.now() - t0;
|
||||
|
||||
mockPagedFetch();
|
||||
let sorted = 0;
|
||||
let emitsN = 0;
|
||||
const t1 = performance.now();
|
||||
await fetchFolderListing('bench', {
|
||||
onPage: (p) => {
|
||||
emitsN++;
|
||||
sorted += consumerDerive(p);
|
||||
}
|
||||
});
|
||||
const ms = performance.now() - t1;
|
||||
|
||||
console.info(
|
||||
`progressive load ${PAGES}×${PAGE_SIZE}: before ${refEmits} emissions / ${refSorted} sorted elements / ${refMs.toFixed(1)} ms — after ${emitsN} emissions / ${sorted} sorted elements / ${ms.toFixed(1)} ms (${(refMs / ms).toFixed(1)}x wall, ${(refSorted / sorted).toFixed(1)}x fewer sorted elements)`
|
||||
);
|
||||
|
||||
// The reference re-derived every page: Σ = P(P+1)/2 pages of elements.
|
||||
expect(refEmits).toBe(PAGES);
|
||||
expect(refSorted).toBe((PAGES * (PAGES + 1) * PAGE_SIZE) / 2);
|
||||
// Coalesced: page 1 + final (+ occasionally one mid emission if the
|
||||
// stubbed pages ever take >150 ms — they don't on any healthy runner).
|
||||
expect(emitsN).toBeLessThanOrEqual(3);
|
||||
// ≥5x less consumer sort work is the point of the change.
|
||||
expect(sorted).toBeLessThan(refSorted / 5);
|
||||
// And it must show up as wall time on the combined load+derive cycle.
|
||||
expect(ms).toBeLessThan(refMs / 3);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -94,6 +94,17 @@ export async function getFolder(id: string): Promise<FolderItem> {
|
||||
return folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum spacing between intermediate progressive-render emissions of
|
||||
* {@link fetchFolderListing}. Each emission hands the consumer the WHOLE
|
||||
* accumulated listing, and the files view re-derives its filtered + sorted
|
||||
* view from it (O(accumulated · log) with `localeCompare`), so emitting every
|
||||
* page made a large-folder load Σ O(N²/page) of main-thread sort work. Page
|
||||
* one and the final page always emit; pages in between only emit after this
|
||||
* much time has passed since the previous emission.
|
||||
*/
|
||||
export const PAGE_EMIT_MIN_INTERVAL_MS = 150;
|
||||
|
||||
/**
|
||||
* Fetch a folder's complete listing (sub-folders + files), rebuilt from the
|
||||
* cursor-paginated `/api/folders/{id}/resources` feed — the old combined
|
||||
@@ -112,12 +123,15 @@ export async function fetchFolderListing(
|
||||
etag?: string;
|
||||
forceRefresh?: boolean;
|
||||
/**
|
||||
* Progressive render hook: invoked after EVERY page with the
|
||||
* accumulated listing so far (the arrays are fresh copies — safe to
|
||||
* hand to reactive state). Without it, a 2,000-item folder waited
|
||||
* for all ⌈N/200⌉ sequential round-trips before the first row
|
||||
* painted; with it the view paints after page one (~200 items) and
|
||||
* fills in as the tail pages land.
|
||||
* Progressive render hook: invoked with the accumulated listing so
|
||||
* far (the arrays are fresh copies — safe to hand to reactive
|
||||
* state). Without it, a 2,000-item folder waited for all ⌈N/200⌉
|
||||
* sequential round-trips before the first row painted; with it the
|
||||
* view paints after page one (~200 items) and fills in as the tail
|
||||
* pages land. Emissions are coalesced to at most one per
|
||||
* {@link PAGE_EMIT_MIN_INTERVAL_MS} between the first and the final
|
||||
* page — the hook is always called for page one and always called
|
||||
* once more with `done === true` and the complete listing.
|
||||
*/
|
||||
onPage?: (partial: FolderListing, done: boolean) => void;
|
||||
} = {}
|
||||
@@ -125,6 +139,8 @@ export async function fetchFolderListing(
|
||||
const folders: FolderItem[] = [];
|
||||
const files: FileItem[] = [];
|
||||
let cursor: string | undefined;
|
||||
let firstPage = true;
|
||||
let lastEmit = 0;
|
||||
do {
|
||||
const params = new URLSearchParams({ order_by: 'name', limit: '200' });
|
||||
if (opts.forceRefresh) params.set('force_refresh', 'true');
|
||||
@@ -144,10 +160,18 @@ export async function fetchFolderListing(
|
||||
else files.push(it.resource as FileItem);
|
||||
}
|
||||
cursor = page.next_cursor;
|
||||
opts.onPage?.(
|
||||
{ folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] },
|
||||
!cursor
|
||||
);
|
||||
const done = !cursor;
|
||||
if (
|
||||
opts.onPage &&
|
||||
(done || firstPage || performance.now() - lastEmit >= PAGE_EMIT_MIN_INTERVAL_MS)
|
||||
) {
|
||||
lastEmit = performance.now();
|
||||
opts.onPage(
|
||||
{ folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] },
|
||||
done
|
||||
);
|
||||
}
|
||||
firstPage = false;
|
||||
} while (cursor);
|
||||
|
||||
return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } };
|
||||
|
||||
@@ -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<id, Contact>` 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<string, Contact> | 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);
|
||||
});
|
||||
});
|
||||
@@ -134,10 +134,26 @@ export async function ensureResolvers(): Promise<void> {
|
||||
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<string, Contact> | null = null;
|
||||
let contactByIdSource: Contact[] | null = null;
|
||||
|
||||
function contactIndex(): Map<string, Contact> | 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 };
|
||||
|
||||
@@ -69,9 +69,14 @@ export function searchSuggest(
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the server-side search cache (`DELETE /api/search/cache`). */
|
||||
/**
|
||||
* Clear the shared server-side search cache
|
||||
* (`DELETE /api/admin/search/cache`). Admin-only — moved from
|
||||
* `/api/search/cache` on 2026-07-17 because the underlying
|
||||
* `invalidate_all()` touches every tenant (see AuthZ audit #14).
|
||||
*/
|
||||
export async function clearSearchCache(): Promise<void> {
|
||||
const res = await apiFetch('/api/search/cache', {
|
||||
const res = await apiFetch('/api/admin/search/cache', {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||
import DrivePicker from '$lib/components/DrivePicker.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { iconNameFromClass } from '$lib/utils/display';
|
||||
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
|
||||
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
|
||||
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
@@ -230,7 +230,7 @@
|
||||
const currentLang = $derived(LANGUAGES.find((l) => l.code === i18n.locale) ?? LANGUAGES[0]);
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return dateTimeFormatFor(undefined, { hour: '2-digit', minute: '2-digit' }).format(ms);
|
||||
}
|
||||
|
||||
function notifIcon(kind: string): string {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { confirmDialog } from '$lib/stores/dialogs.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { errorToast } from '$lib/utils/errors';
|
||||
import { dateTimeFormatFor } from '$lib/utils/display';
|
||||
import { isVideo, photoTimestamp } from '$lib/utils/media';
|
||||
|
||||
interface Props {
|
||||
@@ -47,13 +48,13 @@
|
||||
});
|
||||
|
||||
function baseMeta(p: FileItem): string {
|
||||
const dateStr = new Date(photoTimestamp(p)).toLocaleDateString(undefined, {
|
||||
const dateStr = dateTimeFormatFor(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}).format(photoTimestamp(p));
|
||||
return p.size_formatted ? `${dateStr} · ${p.size_formatted}` : dateStr;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string>, 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<string>, 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Bench harness for the selection/badge-set reactivity patterns compared in
|
||||
* `selectionPatterns.bench.test.ts` (runes only compile in `.svelte.ts`
|
||||
* modules, so the models live here; the app never imports this file — it is
|
||||
* test-only and tree-shaken from the bundle).
|
||||
*
|
||||
* `copyReassignModel` is the pre-fix files-view pattern, verbatim: a
|
||||
* `$state<Set>` where every toggle copies the whole set into a fresh
|
||||
* `SvelteSet` and reassigns. `inPlaceModel` is the post-fix pattern: one
|
||||
* `SvelteSet` mutated in place.
|
||||
*/
|
||||
import { flushSync } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
export interface SelectionModel {
|
||||
has(id: string): boolean;
|
||||
toggle(id: string): void;
|
||||
seed(ids: Iterable<string>): void;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/** Pre-fix pattern (files view `toggleSelected`, verbatim copy-and-reassign). */
|
||||
export function copyReassignModel(): SelectionModel {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- BEFORE arm replicates the pre-fix plain-Set pattern verbatim
|
||||
let selected = $state<Set<string>>(new Set());
|
||||
return {
|
||||
has: (id) => selected.has(id),
|
||||
toggle(id) {
|
||||
const next = new SvelteSet(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selected = next;
|
||||
},
|
||||
seed(ids) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- BEFORE arm replicates the pre-fix plain-Set pattern verbatim
|
||||
selected = new Set(ids);
|
||||
},
|
||||
get size() {
|
||||
return selected.size;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Post-fix pattern: one live `SvelteSet` mutated in place (per-key sources
|
||||
* for present keys; absent-key reads track the version signal). */
|
||||
export function inPlaceModel(): SelectionModel {
|
||||
const selected = new SvelteSet<string>();
|
||||
return {
|
||||
has: (id) => selected.has(id),
|
||||
toggle(id) {
|
||||
if (selected.has(id)) selected.delete(id);
|
||||
else selected.add(id);
|
||||
},
|
||||
seed(ids) {
|
||||
selected.clear();
|
||||
for (const id of ids) selected.add(id);
|
||||
},
|
||||
get size() {
|
||||
return selected.size;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount one effect per row reading `model.has(rowId)` — the shape of a row's
|
||||
* checkbox/star binding — run `mutate`, and report how many row effects re-ran
|
||||
* (the invalidation fan-out of the mutation).
|
||||
*/
|
||||
export function measureFanout(model: SelectionModel, rowIds: string[], mutate: () => void): number {
|
||||
let runs = 0;
|
||||
const destroy = $effect.root(() => {
|
||||
for (const id of rowIds) {
|
||||
$effect(() => {
|
||||
void model.has(id);
|
||||
runs += 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
flushSync(); // initial run of every row effect
|
||||
const baseline = runs;
|
||||
mutate();
|
||||
flushSync();
|
||||
destroy();
|
||||
return runs - baseline;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
copyReassignModel,
|
||||
inPlaceModel,
|
||||
measureFanout,
|
||||
type SelectionModel
|
||||
} from './selectionBench.svelte';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the in-place `SvelteSet` selection/badge sets in the
|
||||
* files and recent views.
|
||||
*
|
||||
* Audit finding: `selected`, `favoriteIds` and `sharedIds` were plain
|
||||
* `$state<Set>`s rebuilt from a full copy on every single-item toggle
|
||||
* (`new SvelteSet(selected)` + reassign). That costs (a) an O(N) copy per
|
||||
* toggle — N unbounded under "select all → refine" — and (b) reassigning the
|
||||
* state reference invalidates EVERY mounted row's `.has(id)` read, so the
|
||||
* whole viewport re-renders for a one-row change. The fix keeps one
|
||||
* `SvelteSet` per set and mutates it in place; `SvelteSet` tracks per-key, so
|
||||
* a toggle re-runs only the toggled row's readers. The composable
|
||||
* `useSelection` already shipped this pattern — the views now match it.
|
||||
*
|
||||
* `SvelteSet` granularity (svelte/src/reactivity/set.js): present keys get a
|
||||
* per-key source; `.has()` on an ABSENT key tracks the set's version signal
|
||||
* ("don't create sources willy-nilly"), so miss-readers re-run on any
|
||||
* mutation in both patterns. The in-place win is therefore: no O(N) copy, and
|
||||
* every OTHER present-key reader is spared — copy-reassign re-runs all rows.
|
||||
*
|
||||
* Gates: (1) both patterns agree on membership across a deterministic toggle
|
||||
* script; (2) fan-out under 40 mounted row-effects matches those exact
|
||||
* semantics (misses+1 in place vs all 40 copied — 3 vs 40 when the list is
|
||||
* mostly selected, the "select all → refine" case); (3) 1 000 toggles over a
|
||||
* 5 000-id selection run ≥5x faster in place.
|
||||
*/
|
||||
|
||||
/** Deterministic PRNG so both models replay the identical script. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const ids = (n: number): string[] => Array.from({ length: n }, (_, i) => `id-${i}`);
|
||||
|
||||
describe('in-place SvelteSet selection (benchmark gate)', () => {
|
||||
it('membership after a 500-op toggle script is identical in both patterns', () => {
|
||||
const universe = ids(1_000);
|
||||
const a = copyReassignModel();
|
||||
const b = inPlaceModel();
|
||||
a.seed(universe.slice(0, 100));
|
||||
b.seed(universe.slice(0, 100));
|
||||
|
||||
const rand = mulberry32(0xc0ffee);
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const id = universe[Math.floor(rand() * universe.length)];
|
||||
a.toggle(id);
|
||||
b.toggle(id);
|
||||
}
|
||||
expect(a.size).toBe(b.size);
|
||||
for (const id of universe) {
|
||||
expect(b.has(id), id).toBe(a.has(id));
|
||||
}
|
||||
});
|
||||
|
||||
it('fan-out of one toggle across 40 mounted rows matches per-key semantics', () => {
|
||||
const rows = ids(40);
|
||||
const scenario = (seeded: number): { copy: number; inplace: number } => {
|
||||
const copy = copyReassignModel();
|
||||
copy.seed(rows.slice(0, seeded));
|
||||
const copyFanout = measureFanout(copy, rows, () => copy.toggle('id-7'));
|
||||
|
||||
const inplace = inPlaceModel();
|
||||
inplace.seed(rows.slice(0, seeded));
|
||||
const inplaceFanout = measureFanout(inplace, rows, () => inplace.toggle('id-7'));
|
||||
return { copy: copyFanout, inplace: inplaceFanout };
|
||||
};
|
||||
|
||||
// 10/40 selected (sparse selection): misses (30) + the toggled row.
|
||||
const sparse = scenario(10);
|
||||
// 38/40 selected ("select all → refine"): misses (2) + the toggled row.
|
||||
const dense = scenario(38);
|
||||
|
||||
console.info(
|
||||
`fan-out of 1 toggle across 40 row effects — 10/40 selected: copy ${sparse.copy} vs in-place ${sparse.inplace}; 38/40 selected: copy ${dense.copy} vs in-place ${dense.inplace}`
|
||||
);
|
||||
// Copy-reassign invalidates every row that reads `.has` on the state.
|
||||
expect(sparse.copy).toBeGreaterThanOrEqual(rows.length);
|
||||
expect(dense.copy).toBeGreaterThanOrEqual(rows.length);
|
||||
// In place: absent-key readers track the version signal (SvelteSet
|
||||
// design), present-key readers other than the toggled row are spared.
|
||||
expect(sparse.inplace).toBe(40 - 10 + 1);
|
||||
expect(dense.inplace).toBe(40 - 38 + 1);
|
||||
// The refine-after-select-all case is where the win is decisive.
|
||||
expect(dense.inplace).toBeLessThan(dense.copy / 10);
|
||||
});
|
||||
|
||||
it('1 000 toggles over a 5 000-id selection are ≥5x faster in place (perf gate)', () => {
|
||||
const N = 5_000;
|
||||
const TOGGLES = 1_000;
|
||||
const universe = ids(N);
|
||||
|
||||
const run = (model: SelectionModel): number => {
|
||||
model.seed(universe);
|
||||
const rand = mulberry32(0xbeef);
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < TOGGLES; i++) {
|
||||
model.toggle(universe[Math.floor(rand() * N)]);
|
||||
}
|
||||
return performance.now() - t0;
|
||||
};
|
||||
|
||||
// Warm-up (JIT) then measure.
|
||||
run(copyReassignModel());
|
||||
run(inPlaceModel());
|
||||
const copyMs = run(copyReassignModel());
|
||||
const inplaceMs = run(inPlaceModel());
|
||||
|
||||
console.info(
|
||||
`${TOGGLES} toggles @ N=${N}: copy-reassign ${copyMs.toFixed(1)} ms vs in-place ${inplaceMs.toFixed(1)} ms (${(copyMs / inplaceMs).toFixed(1)}x)`
|
||||
);
|
||||
expect(inplaceMs).toBeLessThan(copyMs / 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getNestedValue, interpolate } from './index.svelte';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the `t()` hot path: the split-path cache in
|
||||
* `getNestedValue` and the `{{` guard in `interpolate`.
|
||||
*
|
||||
* Audit finding: the locale dicts are nested, so every `t('a.b.c')` call
|
||||
* re-split its key into a fresh array and walked the tree, and `interpolate`
|
||||
* ran its global-regex `.replace` scan even though the vast majority of UI
|
||||
* strings carry no `{{placeholder}}`. A rendered list row calls `t()` ~10×,
|
||||
* so a 40-row paint pays ~400 walk+split-allocs + regex scans. The fix
|
||||
* caches the resolved value per (dict, key) — dicts are load-once-immutable
|
||||
* and the key set is the app's finite static strings — and skips the regex
|
||||
* when the string has no `{{`.
|
||||
*
|
||||
* Gates: byte-identical results vs the pre-fix reference implementations
|
||||
* across the real shipped en.json (nested keys, flat keys, underscore
|
||||
* fallback, missing keys, placeholder strings — cold AND warm, so a stale or
|
||||
* poisoned cache entry fails loudly), and a ≥1.5x speedup on a mixed
|
||||
* 20k-call workload.
|
||||
*/
|
||||
|
||||
type Dict = { [key: string]: string | Dict };
|
||||
|
||||
const enDict = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '../../../static/locales/en.json'), 'utf8')
|
||||
) as Dict;
|
||||
|
||||
/** Pre-fix `getNestedValue`, verbatim: fresh `split('.')` on every call. */
|
||||
function referenceGetNestedValue(obj: Dict | undefined, path: string): string | null {
|
||||
if (obj && typeof obj === 'object' && path in obj) {
|
||||
const value = obj[path];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object' && key in (current as Dict)) {
|
||||
current = (current as Dict)[key];
|
||||
} else {
|
||||
if (path.includes('_') && !path.includes('.')) {
|
||||
const [prefix, ...parts] = path.split('_');
|
||||
const suffix = parts.join('_');
|
||||
const branch = obj?.[prefix];
|
||||
if (branch && typeof branch === 'object' && suffix in (branch as Dict)) {
|
||||
const v = (branch as Dict)[suffix];
|
||||
return typeof v === 'string' ? v : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return typeof current === 'string' ? current : null;
|
||||
}
|
||||
|
||||
/** Pre-fix `interpolate`, verbatim: unconditional regex `.replace`. */
|
||||
function referenceInterpolate(text: string, params: Record<string, unknown>): string {
|
||||
return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key: string) => {
|
||||
const k = key.trim();
|
||||
return params[k] !== undefined ? String(params[k]) : `{{${key}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Every dotted leaf path in the dict (the app's real key population). */
|
||||
function collectKeys(obj: Dict, prefix = '', out: string[] = []): string[] {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === 'string') out.push(path);
|
||||
else collectKeys(v, path, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const allKeys = collectKeys(enDict);
|
||||
// A workload mix mirroring real renders: mostly present nested keys, plus
|
||||
// underscore-fallback forms, flat keys, and misses.
|
||||
const workload: string[] = [
|
||||
...allKeys,
|
||||
'errors_loadFailed', // underscore fallback form
|
||||
'groupby_modifiedAt',
|
||||
'nav.files',
|
||||
'this.key.does.not.exist',
|
||||
'nokey',
|
||||
'files.deeply.missing.leaf'
|
||||
];
|
||||
|
||||
const PARAMS = { n: 42, count: 7, email: 'x@y.z', name: 'Ada' };
|
||||
|
||||
describe('t() hot path: split cache + interpolate guard (benchmark gate)', () => {
|
||||
it('getNestedValue is byte-identical to the split-per-call reference on every real key', () => {
|
||||
expect(allKeys.length).toBeGreaterThan(300);
|
||||
for (const key of workload) {
|
||||
expect(getNestedValue(enDict, key), key).toBe(referenceGetNestedValue(enDict, key));
|
||||
}
|
||||
// Repeat with the cache warm — a poisoned/shared split array would show here.
|
||||
for (const key of workload) {
|
||||
expect(getNestedValue(enDict, key), `warm:${key}`).toBe(referenceGetNestedValue(enDict, key));
|
||||
}
|
||||
});
|
||||
|
||||
it('interpolate is byte-identical to the unguarded reference', () => {
|
||||
const texts = [
|
||||
// Keys whose segments contain literal dots aren't resolvable via a
|
||||
// dotted path — drop the nulls (both implementations agree on them,
|
||||
// covered by the lookup-equivalence test above).
|
||||
...allKeys
|
||||
.map((k) => referenceGetNestedValue(enDict, k))
|
||||
.filter((v): v is string => v !== null),
|
||||
'Move {{n}} items to trash?',
|
||||
'{{ n }} spaced', // padded placeholder
|
||||
'{{unknown}} stays intact',
|
||||
'no placeholders at all',
|
||||
'brace but not double { x }',
|
||||
'{{n}}{{count}}back-to-back',
|
||||
''
|
||||
];
|
||||
let withPlaceholders = 0;
|
||||
for (const text of texts) {
|
||||
if (text.includes('{{')) withPlaceholders++;
|
||||
expect(interpolate(text, PARAMS), JSON.stringify(text)).toBe(
|
||||
referenceInterpolate(text, PARAMS)
|
||||
);
|
||||
expect(interpolate(text, {}), `noparams:${JSON.stringify(text)}`).toBe(
|
||||
referenceInterpolate(text, {})
|
||||
);
|
||||
}
|
||||
// The workload genuinely exercises both branches of the guard.
|
||||
expect(withPlaceholders).toBeGreaterThan(50);
|
||||
expect(withPlaceholders).toBeLessThan(texts.length / 2);
|
||||
});
|
||||
|
||||
it('20k mixed lookups+interpolations run ≥1.5x faster (perf gate)', { timeout: 30_000 }, () => {
|
||||
const N = 20_000;
|
||||
// The t() body for a hit: nested lookup then interpolate the result.
|
||||
const after = (key: string): string => {
|
||||
const v = getNestedValue(enDict, key);
|
||||
return v === null ? key : interpolate(v, PARAMS);
|
||||
};
|
||||
const before = (key: string): string => {
|
||||
const v = referenceGetNestedValue(enDict, key);
|
||||
return v === null ? key : referenceInterpolate(v, PARAMS);
|
||||
};
|
||||
|
||||
let sink = 0;
|
||||
for (let i = 0; i < 2_000; i++) {
|
||||
sink += after(workload[i % workload.length]).length;
|
||||
sink += before(workload[i % workload.length]).length;
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < N; i++) sink += after(workload[i % workload.length]).length;
|
||||
const afterMs = performance.now() - t0;
|
||||
|
||||
const t1 = performance.now();
|
||||
for (let i = 0; i < N; i++) sink += before(workload[i % workload.length]).length;
|
||||
const beforeMs = performance.now() - t1;
|
||||
|
||||
expect(sink).toBeGreaterThan(0);
|
||||
console.info(
|
||||
`t() hot path x ${N}: cached+guarded ${afterMs.toFixed(1)} ms vs split+regex-per-call ${beforeMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(2)}x)`
|
||||
);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 1.5);
|
||||
});
|
||||
});
|
||||
@@ -116,8 +116,33 @@ export function resolveBrowserLocale(
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// Resolved-value cache, one map per dict object: `t()` runs ~10× per rendered
|
||||
// list row over the app's finite static key set, so the nested split + tree
|
||||
// walk runs once per (locale, key) instead of on every call. Dicts are
|
||||
// assigned once in `loadDict` and never mutated, so entries can't go stale;
|
||||
// the cap only guards against a pathological dynamic-key caller.
|
||||
const RESOLVED_CACHE_MAX = 4000;
|
||||
const resolvedCache = new WeakMap<Dict, Map<string, string | null>>();
|
||||
|
||||
/** Resolve a dot-notation key with a prefix_suffix underscore fallback. */
|
||||
export function getNestedValue(obj: Dict | undefined, path: string): string | null {
|
||||
if (!obj || typeof obj !== 'object') return resolveNestedValue(obj, path);
|
||||
let cache = resolvedCache.get(obj);
|
||||
if (cache === undefined) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- deliberately non-reactive: a memo written during render must not create/notify signals
|
||||
cache = new Map();
|
||||
resolvedCache.set(obj, cache);
|
||||
}
|
||||
const hit = cache.get(path);
|
||||
if (hit !== undefined) return hit;
|
||||
const value = resolveNestedValue(obj, path);
|
||||
if (cache.size >= RESOLVED_CACHE_MAX) cache.clear();
|
||||
cache.set(path, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The uncached lookup: flat-key fast path, dotted walk, underscore fallback. */
|
||||
function resolveNestedValue(obj: Dict | undefined, path: string): string | null {
|
||||
if (obj && typeof obj === 'object' && path in obj) {
|
||||
const value = obj[path];
|
||||
return typeof value === 'string' ? value : null;
|
||||
@@ -146,6 +171,9 @@ export function getNestedValue(obj: Dict | undefined, path: string): string | nu
|
||||
|
||||
/** Replace `{{param}}` placeholders; leaves unknown placeholders intact. */
|
||||
export function interpolate(text: string, params: Record<string, unknown>): string {
|
||||
// The vast majority of UI strings carry no placeholder — skip the regex
|
||||
// scan (and its per-call machinery) for them.
|
||||
if (!text.includes('{{')) return text;
|
||||
return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key: string) => {
|
||||
const k = key.trim();
|
||||
return params[k] !== undefined ? String(params[k]) : `{{${key}}}`;
|
||||
|
||||
@@ -56,6 +56,60 @@ export function fileIconKindClass(iconName: string): string {
|
||||
return `file-icon--${fileIconKind(iconName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-scope cache of `Intl.DateTimeFormat` instances, keyed by
|
||||
* `(locale, options signature)`. Constructing a formatter runs the full ICU
|
||||
* locale/pattern resolution (~50–200µs) while a `format()` call is ~1µs, and
|
||||
* {@link formatDate} runs roughly twice per row as large file lists render
|
||||
* and scroll — so a construct-per-call implementation (what
|
||||
* `toLocaleDateString(locale, options)` does under the hood) dominated list
|
||||
* fill. Entries are keyed by the locale actually requested — never frozen at
|
||||
* first use — so a runtime locale change just resolves a different entry.
|
||||
*/
|
||||
const dateTimeFormatCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
// Entries built with `locale === undefined` snapshot the environment default
|
||||
// locale at construction time. `toLocaleDateString(undefined, …)` re-reads the
|
||||
// default on every call, so drop the cache if the default changes to keep the
|
||||
// cached path behaviourally identical.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('languagechange', () => dateTimeFormatCache.clear());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached equivalent of `new Intl.DateTimeFormat(locale, options)`.
|
||||
*
|
||||
* `date.toLocaleDateString(locale, options)` / `toLocaleTimeString(…)` are
|
||||
* specified (ECMA-402) as building exactly this formatter per call — and
|
||||
* their component defaulting is a no-op once `options` names any date/time
|
||||
* component — so `dateTimeFormatFor(locale, options).format(date)` is
|
||||
* output-identical while paying construction once per (locale, options).
|
||||
*
|
||||
* The options signature uses `JSON.stringify`, so pass options as a hoisted
|
||||
* const or an inline literal (stable key order per callsite); a differently
|
||||
* ordered but equal object would only create a redundant entry, never a wrong
|
||||
* result.
|
||||
*/
|
||||
export function dateTimeFormatFor(
|
||||
locale: string | undefined,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): Intl.DateTimeFormat {
|
||||
const key = `${locale ?? ''}|${options ? JSON.stringify(options) : ''}`;
|
||||
let fmt = dateTimeFormatCache.get(key);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.DateTimeFormat(locale, options);
|
||||
dateTimeFormatCache.set(key, fmt);
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
|
||||
/** Options for {@link formatDate}, hoisted so every call shares one cache key. */
|
||||
const FORMAT_DATE_OPTS: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
};
|
||||
|
||||
/** Format a timestamp (epoch seconds/ms or ISO-8601 string) as a local date. */
|
||||
export function formatDate(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
@@ -67,5 +121,5 @@ export function formatDate(value: number | string | null | undefined): string {
|
||||
d = new Date(value);
|
||||
}
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
return dateTimeFormatFor(undefined, FORMAT_DATE_OPTS).format(d);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dateTimeFormatFor, formatDate } from './display';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the module-scope `Intl.DateTimeFormat` cache in
|
||||
* `display.ts` ({@link formatDate} / {@link dateTimeFormatFor}).
|
||||
*
|
||||
* Audit finding: `formatDate` built a fresh `Intl.DateTimeFormat` on every
|
||||
* call (`toLocaleDateString(undefined, opts)` constructs one internally), and
|
||||
* it runs ~twice per row while file lists render and scroll — a 10k-item
|
||||
* folder paid tens of thousands of ICU formatter constructions (~50–200µs
|
||||
* each) during list fill. The fix caches formatters in a Map keyed by
|
||||
* (locale, options signature).
|
||||
*
|
||||
* This gate asserts (1) the cached path is byte-identical to the
|
||||
* construct-per-call code it replaced, across dates, option shapes, and
|
||||
* locales (including an RTL one), and (2) it is decisively (≥3x) faster. If
|
||||
* the perf assertion fails, the cache is not delivering and the change
|
||||
* should be rolled back (it would be pure complexity).
|
||||
*/
|
||||
|
||||
/** The option shapes the app actually uses (display.ts + component callsites). */
|
||||
const DATE_OPTS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
const MONTH_OPTS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long' };
|
||||
const FULL_DATE_OPTS: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
const DATE_TIME_OPTS: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
};
|
||||
const TIME_OPTS: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
|
||||
|
||||
/**
|
||||
* The pre-fix `formatDate`, verbatim: `toLocaleDateString` constructs a new
|
||||
* `Intl.DateTimeFormat` internally on every call. This is the uncached
|
||||
* reference the cached implementation must match and beat.
|
||||
*/
|
||||
function referenceFormatDate(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
let d: Date;
|
||||
if (typeof value === 'number') {
|
||||
// Heuristic: seconds vs milliseconds.
|
||||
d = new Date(value < 1e12 ? value * 1000 : value);
|
||||
} else {
|
||||
d = new Date(value);
|
||||
}
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString(undefined, DATE_OPTS);
|
||||
}
|
||||
|
||||
/** ~20 inputs exercising the seconds/ms heuristic, ISO parsing, and edge cases. */
|
||||
const DATE_VALUES: Array<number | string | null | undefined> = [
|
||||
0, // epoch, seconds branch
|
||||
1, // seconds
|
||||
86_399, // seconds, last second of 1970-01-01 UTC
|
||||
951_782_400, // seconds, 2000-02-29 (leap day)
|
||||
1_700_000_000, // seconds
|
||||
999_999_999_999, // just under the 1e12 cutoff → seconds branch, far future
|
||||
1_000_000_000_000, // exactly 1e12 → milliseconds branch, 2001
|
||||
1_700_000_000_000, // milliseconds
|
||||
1_766_620_800_000, // milliseconds, 2025-12-25
|
||||
Date.UTC(1999, 11, 31, 23, 59, 59), // ms, century boundary
|
||||
Date.UTC(2038, 0, 19, 3, 14, 7), // ms, past the 32-bit epoch rollover
|
||||
'2024-01-15', // date-only ISO (parsed as UTC midnight)
|
||||
'2024-02-29T12:34:56Z', // leap day, UTC
|
||||
'1999-12-31T23:59:59.999Z',
|
||||
'2020-06-15T10:00:00+05:30', // non-UTC offset
|
||||
'2031-11-05T08:15:30-05:00',
|
||||
'0001-01-01T00:00:00Z', // extreme past
|
||||
'2024-07-04T00:00:00', // no offset (local time)
|
||||
'definitely not a date', // invalid → ''
|
||||
'', // invalid → ''
|
||||
null, // → ''
|
||||
undefined // → ''
|
||||
];
|
||||
|
||||
/** Locales the app ships (see SUPPORTED_LOCALES); 'ar' renders RTL. */
|
||||
const SAMPLE_LOCALES = ['en', 'es', 'ar', 'ja'] as const;
|
||||
|
||||
describe('cached Intl.DateTimeFormat (benchmark gate)', () => {
|
||||
it('formatDate output is identical to the uncached reference', () => {
|
||||
for (const value of DATE_VALUES) {
|
||||
expect(formatDate(value), `formatDate(${JSON.stringify(value)})`).toBe(
|
||||
referenceFormatDate(value)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('cached formatters match per-call construction across locales and option shapes', () => {
|
||||
const dates = DATE_VALUES.filter((v): v is number | string => v !== null && v !== undefined)
|
||||
.map((v) => (typeof v === 'number' ? new Date(v < 1e12 ? v * 1000 : v) : new Date(v)))
|
||||
.filter((d) => !Number.isNaN(d.getTime()));
|
||||
expect(dates.length).toBeGreaterThanOrEqual(18);
|
||||
|
||||
for (const locale of SAMPLE_LOCALES) {
|
||||
for (const d of dates) {
|
||||
// Each toLocale*String call below is specified as constructing a
|
||||
// fresh Intl.DateTimeFormat — the uncached reference behaviour.
|
||||
expect(dateTimeFormatFor(locale, DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(locale, DATE_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(locale, MONTH_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(locale, MONTH_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(locale, FULL_DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(locale, FULL_DATE_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(locale, DATE_TIME_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(locale, DATE_TIME_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(locale, TIME_OPTS).format(d)).toBe(
|
||||
d.toLocaleTimeString(locale, TIME_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(undefined, DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(undefined, DATE_OPTS)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses one instance per (locale, options) and never freezes the first locale', () => {
|
||||
// Same key → same instance (this is where the speedup comes from).
|
||||
expect(dateTimeFormatFor('es', DATE_OPTS)).toBe(dateTimeFormatFor('es', DATE_OPTS));
|
||||
expect(dateTimeFormatFor(undefined, DATE_OPTS)).toBe(dateTimeFormatFor(undefined, DATE_OPTS));
|
||||
// Different locale or options → different instance: a runtime locale
|
||||
// change must not keep formatting with the first locale seen.
|
||||
expect(dateTimeFormatFor('ar', DATE_OPTS)).not.toBe(dateTimeFormatFor('es', DATE_OPTS));
|
||||
expect(dateTimeFormatFor('es', TIME_OPTS)).not.toBe(dateTimeFormatFor('es', DATE_OPTS));
|
||||
const d = new Date(Date.UTC(2024, 4, 17, 12, 0, 0));
|
||||
expect(dateTimeFormatFor('ar', DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString('ar', DATE_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor('es', DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString('es', DATE_OPTS)
|
||||
);
|
||||
});
|
||||
|
||||
it(
|
||||
'formats 20k dates ≥3x faster than per-call construction (perf gate)',
|
||||
{ timeout: 30_000 },
|
||||
() => {
|
||||
const N = 20_000;
|
||||
const base = Date.UTC(2020, 0, 1);
|
||||
// Deterministic spread of distinct ms timestamps across ~30 years.
|
||||
const values = Array.from({ length: N }, (_, i) => base + i * 47_777_777);
|
||||
|
||||
// Warm up both paths so JIT tiering and first-call construction sit
|
||||
// outside the measured windows. `sink` defeats dead-code elimination.
|
||||
let sink = 0;
|
||||
for (let i = 0; i < 500; i++) {
|
||||
sink += formatDate(values[i]).length;
|
||||
sink += referenceFormatDate(values[i]).length;
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
for (const v of values) sink += formatDate(v).length;
|
||||
const cachedMs = performance.now() - t0;
|
||||
|
||||
const t1 = performance.now();
|
||||
for (const v of values) sink += referenceFormatDate(v).length;
|
||||
const uncachedMs = performance.now() - t1;
|
||||
|
||||
expect(sink).toBeGreaterThan(0);
|
||||
console.info(
|
||||
`formatDate x ${N}: cached ${cachedMs.toFixed(1)} ms vs construct-per-call ${uncachedMs.toFixed(1)} ms (${(uncachedMs / cachedMs).toFixed(1)}x)`
|
||||
);
|
||||
expect(cachedMs).toBeLessThan(uncachedMs / 3);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PhotoItem } from '$lib/api/endpoints/photos';
|
||||
import {
|
||||
PhotoTimeline,
|
||||
buildPhotoRows,
|
||||
type GroupMode,
|
||||
type LayoutMode,
|
||||
type TimelineConfig
|
||||
} from './photoTimeline';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the incremental photo timeline (PhotoTimeline) that
|
||||
* replaced the photos view's `groups`→`photoRows` derive chain.
|
||||
*
|
||||
* Audit finding: `loadMore` does `items = [...items, ...page]` (60/page), and
|
||||
* both `groups` (O(N), a `new Date()` per photo) and `photoRows` (O(N) row
|
||||
* layout) are `$derived` over the whole accumulated list — so paging to photo
|
||||
* N re-groups + re-lays-out everything loaded so far, Σ ≈ O(N²/60) main-thread
|
||||
* work during the scroll (the same class ROUND6 fixed for the files listing).
|
||||
* Since pages arrive newest-first, grouping is append-only; PhotoTimeline
|
||||
* re-buckets only the fresh page and re-lays-out only the groups that changed.
|
||||
*
|
||||
* Gates:
|
||||
* 1. Equivalence — at EVERY page of the drain, the incremental output is
|
||||
* deep-equal to the verbatim full-rebuild reference (buildPhotoRows), for
|
||||
* both layouts; plus config-change, deletion and width=0 fall back to a
|
||||
* correct full rebuild.
|
||||
* 2. Perf — grouping work (timestamp reads) collapses from Σ O(N²/60) to O(N)
|
||||
* across the drain (deterministic count), and wall drops ≥3x.
|
||||
*/
|
||||
|
||||
const DAY = 86_400; // seconds
|
||||
|
||||
/** A photo with a descending sort_date and a deterministic aspect ratio. */
|
||||
function photo(i: number): PhotoItem {
|
||||
// Newest-first: photo 0 is most recent; ~half a day apart spans ~4 years
|
||||
// over 3k photos, so month/day buckets are bounded (realistic library).
|
||||
const sortDate = 1_700_000_000 - i * (DAY / 2);
|
||||
const w = 200 + ((i * 37) % 400);
|
||||
const h = 200 + ((i * 53) % 300);
|
||||
return {
|
||||
category: 'image',
|
||||
created_at: sortDate,
|
||||
icon_class: '',
|
||||
icon_special_class: '',
|
||||
id: `p-${i.toString().padStart(6, '0')}`,
|
||||
mime_type: 'image/jpeg',
|
||||
modified_at: sortDate,
|
||||
name: `photo ${i}.jpg`,
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
folder_id: 'f',
|
||||
path: `/photo ${i}.jpg`,
|
||||
size: 1000,
|
||||
size_formatted: '1 KB',
|
||||
sort_date: sortDate,
|
||||
etag: `e${i}`,
|
||||
content_hash: `h${i}`,
|
||||
width: w,
|
||||
height: h
|
||||
} as PhotoItem;
|
||||
}
|
||||
|
||||
/** Instrumented config: counts every timestamp read (the grouping hot op). */
|
||||
function makeConfig(
|
||||
groupMode: GroupMode,
|
||||
layoutMode: LayoutMode,
|
||||
width: number,
|
||||
counter?: { n: number }
|
||||
): TimelineConfig {
|
||||
const timestampOf = (p: PhotoItem) => {
|
||||
if (counter) counter.n++;
|
||||
const v = p.sort_date || p.created_at || 0;
|
||||
return v < 1e12 ? v * 1000 : v;
|
||||
};
|
||||
// Stable label fn (reference identity matters for the config-unchanged path).
|
||||
const labelOf = (d: Date, mode: GroupMode) =>
|
||||
mode === 'year'
|
||||
? `${d.getFullYear()}`
|
||||
: mode === 'month'
|
||||
? `${d.getFullYear()}-${d.getMonth() + 1}`
|
||||
: `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
||||
return { groupMode, layoutMode, width, mobile: false, timestampOf, labelOf };
|
||||
}
|
||||
|
||||
const PAGE = 60;
|
||||
const PAGES = 50; // 3 000-photo drain
|
||||
const WIDTH = 1200;
|
||||
|
||||
describe('incremental photo timeline (benchmark gate)', () => {
|
||||
for (const layout of ['square', 'justified'] as LayoutMode[]) {
|
||||
it(`stays deep-equal to the full rebuild at every page — ${layout}`, () => {
|
||||
const all = Array.from({ length: PAGE * PAGES }, (_, i) => photo(i));
|
||||
const cfg = makeConfig('month', layout, WIDTH);
|
||||
const timeline = new PhotoTimeline();
|
||||
for (let p = 1; p <= PAGES; p++) {
|
||||
const cumulative = all.slice(0, p * PAGE);
|
||||
const incremental = timeline.sync(cumulative, cfg);
|
||||
const reference = buildPhotoRows(cumulative, cfg);
|
||||
expect(incremental, `page ${p}`).toEqual(reference);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('falls back to a correct full rebuild on config change, deletion and width=0', () => {
|
||||
const all = Array.from({ length: 600 }, (_, i) => photo(i));
|
||||
const timeline = new PhotoTimeline();
|
||||
const monthSquare = makeConfig('month', 'square', WIDTH);
|
||||
|
||||
// Drain a few pages, then flip layout — must equal a fresh full rebuild.
|
||||
timeline.sync(all.slice(0, 300), monthSquare);
|
||||
const justified = makeConfig('month', 'justified', WIDTH);
|
||||
expect(timeline.sync(all.slice(0, 300), justified)).toEqual(
|
||||
buildPhotoRows(all.slice(0, 300), justified)
|
||||
);
|
||||
|
||||
// Change group mode.
|
||||
const yearJust = makeConfig('year', 'justified', WIDTH);
|
||||
expect(timeline.sync(all.slice(0, 300), yearJust)).toEqual(
|
||||
buildPhotoRows(all.slice(0, 300), yearJust)
|
||||
);
|
||||
|
||||
// Deletion (list shrinks / prefix changes) → rebuild.
|
||||
const shrunk = all.slice(0, 300).filter((_, i) => i % 7 !== 0);
|
||||
expect(timeline.sync(shrunk, yearJust)).toEqual(buildPhotoRows(shrunk, yearJust));
|
||||
|
||||
// width=0 yields [] and doesn't wedge the next positive-width sync.
|
||||
const zero = makeConfig('year', 'justified', 0);
|
||||
expect(timeline.sync(shrunk, zero)).toEqual([]);
|
||||
expect(timeline.sync(shrunk, yearJust)).toEqual(buildPhotoRows(shrunk, yearJust));
|
||||
});
|
||||
|
||||
it('collapses grouping work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => {
|
||||
const N = PAGE * PAGES;
|
||||
const all = Array.from({ length: N }, (_, i) => photo(i));
|
||||
|
||||
// AFTER: incremental — each photo is bucketed exactly once across the drain.
|
||||
const afterCounter = { n: 0 };
|
||||
const afterCfg = makeConfig('month', 'square', WIDTH, afterCounter);
|
||||
const timeline = new PhotoTimeline();
|
||||
const t1 = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) timeline.sync(all.slice(0, p * PAGE), afterCfg);
|
||||
const afterMs = performance.now() - t1;
|
||||
|
||||
// BEFORE: full rebuild per page — re-buckets the whole cumulative list.
|
||||
const beforeCounter = { n: 0 };
|
||||
const beforeCfg = makeConfig('month', 'square', WIDTH, beforeCounter);
|
||||
const t0 = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) buildPhotoRows(all.slice(0, p * PAGE), beforeCfg);
|
||||
const beforeMs = performance.now() - t0;
|
||||
|
||||
console.info(
|
||||
`photo timeline ${PAGES}×${PAGE}: before ${beforeCounter.n} timestamp reads / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} reads / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer reads, ${(beforeMs / afterMs).toFixed(1)}x wall)`
|
||||
);
|
||||
|
||||
// Incremental buckets each photo once: exactly N reads.
|
||||
expect(afterCounter.n).toBe(N);
|
||||
// Full rebuild is quadratic: Σ_{p=1..P} p·PAGE.
|
||||
expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2);
|
||||
expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Photo-timeline grouping + row layout, extracted from the photos view so the
|
||||
* O(N²) accumulation of its `groups`/`photoRows` derives can be replaced with
|
||||
* an incremental builder (and unit/benchmark-tested off the Svelte reactive
|
||||
* graph).
|
||||
*
|
||||
* Photos arrive newest-first (`media_sort_date DESC`), so each fetched page
|
||||
* only ever extends the last date bucket or appends new buckets after it —
|
||||
* never mutates an earlier group. {@link PhotoTimeline} exploits that: an
|
||||
* append re-buckets only the new page and recomputes rows only for the groups
|
||||
* that actually changed, keeping a full scroll O(N) instead of O(N²).
|
||||
*
|
||||
* The pure {@link buildPhotoRows} is the verbatim reference (what the old
|
||||
* `groups`→`photoRows` derive chain produced); the benchmark gate asserts the
|
||||
* incremental builder stays byte-for-byte equal to it.
|
||||
*/
|
||||
import type { PhotoItem } from '$lib/api/endpoints/photos';
|
||||
|
||||
export type GroupMode = 'day' | 'month' | 'year';
|
||||
export type LayoutMode = 'square' | 'justified';
|
||||
|
||||
export interface JustifiedTile {
|
||||
file: PhotoItem;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export type PhotoRow =
|
||||
| { kind: 'header'; key: string; height: number; label: string; count: number }
|
||||
| { kind: 'tiles'; key: string; height: number; gap: number; tiles: JustifiedTile[] };
|
||||
|
||||
/** Layout constants — mirror the photos view's original values exactly. */
|
||||
export const SQUARE_GAP = 4; // .25rem, matches the old grid gap
|
||||
export const SQUARE_MIN = 144; // 9rem minmax floor
|
||||
export const JUSTIFIED_GAP = 8; // .photos-jrow margin-bottom
|
||||
export const HEADER_H = 44;
|
||||
|
||||
export interface TimelineConfig {
|
||||
groupMode: GroupMode;
|
||||
layoutMode: LayoutMode;
|
||||
/** Usable content width of the grid, in px. */
|
||||
width: number;
|
||||
/** `(max-width: 768px)` — selects the 150px vs 200px justified target. */
|
||||
mobile: boolean;
|
||||
/** EXIF-aware capture timestamp (ms). Injected so the module stays pure. */
|
||||
timestampOf: (p: PhotoItem) => number;
|
||||
/** Locale-aware bucket label for a group's representative date. */
|
||||
labelOf: (d: Date, mode: GroupMode) => string;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
key: string;
|
||||
label: string;
|
||||
photos: PhotoItem[];
|
||||
}
|
||||
|
||||
/** Year/month/day bucket key for a date under `groupMode` (verbatim). */
|
||||
export function bucketKey(d: Date, groupMode: GroupMode): string {
|
||||
const y = d.getFullYear();
|
||||
if (groupMode === 'year') return `${y}`;
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||||
if (groupMode === 'month') return `${y}-${m}`;
|
||||
return `${y}-${m}-${`${d.getDate()}`.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack files into justified rows (Flickr-style): each full row is scaled to
|
||||
* fill `width` while preserving every tile's aspect ratio. Missing dimensions
|
||||
* fall back to 1:1. Verbatim port of the photos view's `justifiedRows`, with
|
||||
* the `matchMedia` read hoisted to the `mobile` flag so it's testable.
|
||||
*/
|
||||
export function justifiedRows(
|
||||
files: PhotoItem[],
|
||||
width: number,
|
||||
mobile: boolean
|
||||
): Array<{ height: number; tiles: JustifiedTile[] }> {
|
||||
const gap = 8;
|
||||
const target = mobile ? 150 : 200;
|
||||
const rows: Array<{ height: number; tiles: JustifiedTile[] }> = [];
|
||||
let cur: Array<{ file: PhotoItem; aspect: number }> = [];
|
||||
let aspectSum = 0;
|
||||
for (const file of files) {
|
||||
let aspect = file.width && file.height ? file.width / file.height : 1;
|
||||
if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1;
|
||||
aspect = Math.min(Math.max(aspect, 0.4), 3);
|
||||
cur.push({ file, aspect });
|
||||
aspectSum += aspect;
|
||||
const rowWidth = aspectSum * target + (cur.length - 1) * gap;
|
||||
if (rowWidth >= width) {
|
||||
const h = (width - (cur.length - 1) * gap) / aspectSum;
|
||||
rows.push({
|
||||
height: Math.round(h),
|
||||
tiles: cur.map((tt) => ({
|
||||
file: tt.file,
|
||||
w: Math.max(1, Math.round(tt.aspect * h)),
|
||||
h: Math.round(h)
|
||||
}))
|
||||
});
|
||||
cur = [];
|
||||
aspectSum = 0;
|
||||
}
|
||||
}
|
||||
if (cur.length) {
|
||||
rows.push({
|
||||
height: target,
|
||||
tiles: cur.map((tt) => ({
|
||||
file: tt.file,
|
||||
w: Math.max(1, Math.round(tt.aspect * target)),
|
||||
h: target
|
||||
}))
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Columns + cell size for the square layout at width `W` (verbatim). */
|
||||
function squareGeometry(W: number): { cols: number; cell: number } {
|
||||
const cols = Math.max(1, Math.floor((W + SQUARE_GAP) / (SQUARE_MIN + SQUARE_GAP)));
|
||||
const cell = (W - (cols - 1) * SQUARE_GAP) / cols;
|
||||
return { cols, cell };
|
||||
}
|
||||
|
||||
/** Flatten one group into its header + tile rows (verbatim per-group body). */
|
||||
function groupToRows(g: Group, cfg: TimelineConfig, cols: number, cell: number): PhotoRow[] {
|
||||
const rows: PhotoRow[] = [
|
||||
{ kind: 'header', key: `h:${g.key}`, height: HEADER_H, label: g.label, count: g.photos.length }
|
||||
];
|
||||
if (cfg.layoutMode === 'justified') {
|
||||
const jrows = justifiedRows(g.photos, cfg.width, cfg.mobile);
|
||||
for (let ri = 0; ri < jrows.length; ri++) {
|
||||
rows.push({
|
||||
kind: 'tiles',
|
||||
key: `${g.key}:j${ri}`,
|
||||
height: jrows[ri].height + JUSTIFIED_GAP,
|
||||
gap: JUSTIFIED_GAP,
|
||||
tiles: jrows[ri].tiles
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < g.photos.length; i += cols) {
|
||||
const tiles = g.photos.slice(i, i + cols).map((file) => ({ file, w: cell, h: cell }));
|
||||
rows.push({
|
||||
kind: 'tiles',
|
||||
key: `${g.key}:s${i}`,
|
||||
height: cell + SQUARE_GAP,
|
||||
gap: SQUARE_GAP,
|
||||
tiles
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Bucket `items` into date groups, first-appearance order (verbatim). */
|
||||
function buildGroups(items: PhotoItem[], cfg: TimelineConfig): Group[] {
|
||||
const out: Group[] = [];
|
||||
const index = new Map<string, number>();
|
||||
for (const p of items) {
|
||||
const d = new Date(cfg.timestampOf(p));
|
||||
const key = bucketKey(d, cfg.groupMode);
|
||||
let i = index.get(key);
|
||||
if (i === undefined) {
|
||||
i = out.length;
|
||||
index.set(key, i);
|
||||
out.push({ key, label: cfg.labelOf(d, cfg.groupMode), photos: [] });
|
||||
}
|
||||
out[i].photos.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim reference: the flat `PhotoRow[]` the old `groups`→`photoRows`
|
||||
* derive chain produced for `items` under `cfg`. Returns `[]` for a
|
||||
* non-positive width, matching the old guard. The benchmark gate holds the
|
||||
* incremental builder equal to this.
|
||||
*/
|
||||
export function buildPhotoRows(items: PhotoItem[], cfg: TimelineConfig): PhotoRow[] {
|
||||
if (cfg.width <= 0) return [];
|
||||
const { cols, cell } = squareGeometry(cfg.width);
|
||||
const rows: PhotoRow[] = [];
|
||||
for (const g of buildGroups(items, cfg)) {
|
||||
rows.push(...groupToRows(g, cfg, cols, cell));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function configEq(a: TimelineConfig, b: TimelineConfig): boolean {
|
||||
return (
|
||||
a.groupMode === b.groupMode &&
|
||||
a.layoutMode === b.layoutMode &&
|
||||
a.width === b.width &&
|
||||
a.mobile === b.mobile &&
|
||||
a.timestampOf === b.timestampOf &&
|
||||
a.labelOf === b.labelOf
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental photo-timeline builder. Call {@link sync} with the current item
|
||||
* list and config on every change; it detects the common case — the list grew
|
||||
* by appending a page while config is unchanged — and re-buckets only the new
|
||||
* items + re-lays-out only the groups that changed, reusing every untouched
|
||||
* group's cached rows. Any other change (config, deletion, filter toggle,
|
||||
* non-append) falls back to a full rebuild, so the result is always identical
|
||||
* to {@link buildPhotoRows}.
|
||||
*/
|
||||
export class PhotoTimeline {
|
||||
#cfg: TimelineConfig | null = null;
|
||||
#groups: Group[] = [];
|
||||
/** Items already bucketed — the append cursor into the last synced list. */
|
||||
#groupedItems: PhotoItem[] = [];
|
||||
/** group.key → its cached rows for the current config. */
|
||||
#rowCache = new Map<string, PhotoRow[]>();
|
||||
#geom = { cols: 1, cell: 0 };
|
||||
|
||||
/** Whether `next` extends `prev` (same prefix objects + strictly longer). */
|
||||
#isAppend(prev: PhotoItem[], next: PhotoItem[]): boolean {
|
||||
if (next.length <= prev.length) return false;
|
||||
// Prefix identity via the boundary object — O(1), the list is only ever
|
||||
// mutated by appending or by replacing with a filtered copy.
|
||||
return prev.length === 0 || next[prev.length - 1] === prev[prev.length - 1];
|
||||
}
|
||||
|
||||
#rebuild(items: PhotoItem[], cfg: TimelineConfig): void {
|
||||
this.#cfg = cfg;
|
||||
this.#groups = cfg.width > 0 ? buildGroups(items, cfg) : [];
|
||||
this.#groupedItems = items;
|
||||
this.#rowCache.clear();
|
||||
this.#geom = squareGeometry(cfg.width);
|
||||
}
|
||||
|
||||
#extend(items: PhotoItem[], cfg: TimelineConfig): void {
|
||||
const fresh = items.slice(this.#groupedItems.length);
|
||||
// The last existing group may grow, so its cached rows are stale.
|
||||
if (this.#groups.length > 0) {
|
||||
this.#rowCache.delete(this.#groups[this.#groups.length - 1].key);
|
||||
}
|
||||
for (const p of fresh) {
|
||||
const d = new Date(cfg.timestampOf(p));
|
||||
const key = bucketKey(d, cfg.groupMode);
|
||||
const last = this.#groups[this.#groups.length - 1];
|
||||
if (last && last.key === key) {
|
||||
last.photos.push(p);
|
||||
} else {
|
||||
this.#groups.push({ key, label: cfg.labelOf(d, cfg.groupMode), photos: [p] });
|
||||
}
|
||||
}
|
||||
this.#groupedItems = items;
|
||||
}
|
||||
|
||||
sync(items: PhotoItem[], cfg: TimelineConfig): PhotoRow[] {
|
||||
if (cfg.width <= 0) {
|
||||
// Keep the item cursor so a later positive width rebuilds from scratch.
|
||||
this.#cfg = cfg;
|
||||
this.#groups = [];
|
||||
this.#groupedItems = items;
|
||||
this.#rowCache.clear();
|
||||
return [];
|
||||
}
|
||||
if (this.#cfg && configEq(this.#cfg, cfg) && this.#isAppend(this.#groupedItems, items)) {
|
||||
this.#extend(items, cfg);
|
||||
} else {
|
||||
this.#rebuild(items, cfg);
|
||||
}
|
||||
|
||||
const { cols, cell } = this.#geom;
|
||||
const out: PhotoRow[] = [];
|
||||
for (const g of this.#groups) {
|
||||
let rows = this.#rowCache.get(g.key);
|
||||
if (rows === undefined) {
|
||||
rows = groupToRows(g, cfg, cols, cell);
|
||||
this.#rowCache.set(g.key, rows);
|
||||
}
|
||||
for (const r of rows) out.push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Replace a live `Set`'s contents in place. For a reactive `SvelteSet` this
|
||||
* keeps the same instance (per-key reactivity intact) instead of allocating a
|
||||
* fresh copy and invalidating every `.has()` reader at once.
|
||||
*/
|
||||
export function replaceSet<T>(set: Set<T>, values: Iterable<T>): void {
|
||||
set.clear();
|
||||
for (const v of values) set.add(v);
|
||||
}
|
||||
@@ -62,6 +62,7 @@
|
||||
typeLabel
|
||||
} from '$lib/stores/files.svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { replaceSet } from '$lib/utils/sets';
|
||||
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
||||
import { gridColumns } from '$lib/utils/grid';
|
||||
import {
|
||||
@@ -166,8 +167,11 @@
|
||||
// Favorite + shared badge sets for the current folder, seeded directly from
|
||||
// the listing response (server-computed, scoped to these items — no extra
|
||||
// per-navigation fetch) and updated optimistically on mutation.
|
||||
let favoriteIds = $state<Set<string>>(new Set());
|
||||
let sharedIds = $state<Set<string>>(new Set());
|
||||
// `SvelteSet` mutated in place: a toggle costs O(1) instead of copying
|
||||
// the whole set, and every other present-key `.has()` reader is spared
|
||||
// (measured in selectionPatterns.bench.test.ts).
|
||||
const favoriteIds = new SvelteSet<string>();
|
||||
const sharedIds = new SvelteSet<string>();
|
||||
|
||||
function openMove(kind: ItemType, id: string, name: string) {
|
||||
actionTarget = { id, name, kind };
|
||||
@@ -189,19 +193,15 @@
|
||||
async function toggleFavorite(kind: ItemType, id: string) {
|
||||
const isFav = favoriteIds.has(id);
|
||||
// Optimistic toggle, reverted on failure.
|
||||
const next = new SvelteSet(favoriteIds);
|
||||
if (isFav) next.delete(id);
|
||||
else next.add(id);
|
||||
favoriteIds = next;
|
||||
if (isFav) favoriteIds.delete(id);
|
||||
else favoriteIds.add(id);
|
||||
try {
|
||||
if (isFav) await removeFavorite(kind, id);
|
||||
else await addFavorite(kind, id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
const reverted = new SvelteSet(favoriteIds);
|
||||
if (isFav) reverted.add(id);
|
||||
else reverted.delete(id);
|
||||
favoriteIds = reverted;
|
||||
if (isFav) favoriteIds.add(id);
|
||||
else favoriteIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,8 +229,8 @@
|
||||
|
||||
function applyListing(data: FolderListing) {
|
||||
listing = data;
|
||||
favoriteIds = new Set(data.favoriteIds);
|
||||
sharedIds = new Set(data.sharedIds);
|
||||
replaceSet(favoriteIds, data.favoriteIds);
|
||||
replaceSet(sharedIds, data.sharedIds);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -881,19 +881,20 @@
|
||||
}
|
||||
|
||||
// ── Multi-select + batch ────────────────────────────────────────────────
|
||||
let selected = $state<Set<string>>(new Set());
|
||||
// In-place `SvelteSet`: a toggle is O(1) (no full-set copy) and spares
|
||||
// the other selected rows' `has()` readers — decisive when refining a
|
||||
// select-all (selectionPatterns.bench.test.ts).
|
||||
const selected = new SvelteSet<string>();
|
||||
// Anchor row id for shift-click range selection.
|
||||
let selectionAnchor = $state<string | null>(null);
|
||||
|
||||
function toggleSelected(id: string) {
|
||||
const next = new SvelteSet(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selected = next;
|
||||
if (selected.has(id)) selected.delete(id);
|
||||
else selected.add(id);
|
||||
selectionAnchor = id;
|
||||
}
|
||||
function clearSelection() {
|
||||
selected = new Set();
|
||||
selected.clear();
|
||||
selectionAnchor = null;
|
||||
}
|
||||
|
||||
@@ -911,7 +912,7 @@
|
||||
const b = orderedIds.indexOf(id);
|
||||
if (a !== -1 && b !== -1) {
|
||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||
selected = new Set([...selected, ...orderedIds.slice(lo, hi + 1)]);
|
||||
for (let i = lo; i <= hi; i++) selected.add(orderedIds[i]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -927,11 +928,16 @@
|
||||
const totalCount = $derived(visibleFolders.length + visibleFiles.length);
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selected.size === totalCount) clearSelection();
|
||||
// Select-all only picks what the user can see — dotfiles hidden
|
||||
// by the current filter are excluded so "select all → delete"
|
||||
// can't accidentally sweep up hidden files the user never saw.
|
||||
else selected = new Set([...visibleFolders, ...visibleFiles].map((i) => i.id));
|
||||
if (selected.size === totalCount) {
|
||||
clearSelection();
|
||||
} else {
|
||||
// Select-all only picks what the user can see — dotfiles hidden
|
||||
// by the current filter are excluded so "select all → delete"
|
||||
// can't accidentally sweep up hidden files the user never saw.
|
||||
selected.clear();
|
||||
for (const i of visibleFolders) selected.add(i.id);
|
||||
for (const i of visibleFiles) selected.add(i.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -948,9 +954,12 @@
|
||||
async function batchDownload() {
|
||||
const fileIds: string[] = [];
|
||||
const folderIds: string[] = [];
|
||||
// One O(M) pass over the listing instead of an O(N·M) `some` per id.
|
||||
const folderIdSet = new Set(listing.folders.map((f) => f.id));
|
||||
const fileIdSet = new Set(listing.files.map((f) => f.id));
|
||||
for (const id of selected) {
|
||||
if (listing.folders.some((f) => f.id === id)) folderIds.push(id);
|
||||
else if (listing.files.some((f) => f.id === id)) fileIds.push(id);
|
||||
if (folderIdSet.has(id)) folderIds.push(id);
|
||||
else if (fileIdSet.has(id)) fileIds.push(id);
|
||||
}
|
||||
if (fileIds.length === 0 && folderIds.length === 0) return;
|
||||
|
||||
@@ -1009,7 +1018,7 @@
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||
favoriteIds = new Set([...favoriteIds, ...items.map((it) => it.id)]);
|
||||
for (const it of items) favoriteIds.add(it.id);
|
||||
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
|
||||
clearSelection();
|
||||
} catch (e) {
|
||||
@@ -1018,13 +1027,14 @@
|
||||
}
|
||||
|
||||
function selectionTargets(): ActionTarget[] {
|
||||
// One O(M) index build instead of an O(N·M) `find` per selected id.
|
||||
// Folders win id collisions, matching the old folder-first probe.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
|
||||
const byId = new Map<string, ActionTarget>();
|
||||
for (const f of listing.files) byId.set(f.id, { id: f.id, name: f.name, kind: 'file' });
|
||||
for (const f of listing.folders) byId.set(f.id, { id: f.id, name: f.name, kind: 'folder' });
|
||||
return [...selected]
|
||||
.map((id) => {
|
||||
const folder = listing.folders.find((f) => f.id === id);
|
||||
if (folder) return { id, name: folder.name, kind: 'folder' as ItemType };
|
||||
const file = listing.files.find((f) => f.id === id);
|
||||
return file ? { id, name: file.name, kind: 'file' as ItemType } : null;
|
||||
})
|
||||
.map((id) => byId.get(id) ?? null)
|
||||
.filter((x): x is ActionTarget => x !== null);
|
||||
}
|
||||
|
||||
@@ -1070,15 +1080,19 @@
|
||||
danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
for (const id of ids) {
|
||||
const folder = listing.folders.find((f) => f.id === id);
|
||||
// Bounded fan-out instead of a serial await per item: 100 deletes at
|
||||
// ~30 ms RTT collapse from ~3 s of waterfall to a few round-trip
|
||||
// windows. Failures toast individually and the rest still proceed,
|
||||
// exactly like the old serial loop.
|
||||
const folderIdSet = new Set(listing.folders.map((f) => f.id));
|
||||
await mapLimit(ids, 6, async (id) => {
|
||||
try {
|
||||
if (folder) await deleteFolder(id);
|
||||
if (folderIdSet.has(id)) await deleteFolder(id);
|
||||
else await deleteFile(id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
clearSelection();
|
||||
await reload();
|
||||
void session.refresh();
|
||||
@@ -1185,16 +1199,26 @@
|
||||
async function moveInto(targetFolderId: string, e: DragEvent) {
|
||||
const items = dragPayload(e).filter((it) => it.id !== targetFolderId);
|
||||
if (items.length === 0) return;
|
||||
try {
|
||||
for (const it of items) {
|
||||
if (it.kind === 'file') await moveFile(it.id, targetFolderId);
|
||||
else await moveFolder(it.id, targetFolderId);
|
||||
}
|
||||
clearSelection();
|
||||
await reload();
|
||||
} catch (err) {
|
||||
errorToast(err);
|
||||
// Bounded fan-out (was a serial await per item). Every item is
|
||||
// attempted; on any failure the first error is surfaced and the
|
||||
// selection is kept so the drop can be retried, like the old loop.
|
||||
const failures = (
|
||||
await mapLimit(items, 6, async (it) => {
|
||||
try {
|
||||
if (it.kind === 'file') await moveFile(it.id, targetFolderId);
|
||||
else await moveFolder(it.id, targetFolderId);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return err ?? new Error('move failed');
|
||||
}
|
||||
})
|
||||
).filter((err) => err !== null);
|
||||
if (failures.length > 0) {
|
||||
errorToast(failures[0]);
|
||||
return;
|
||||
}
|
||||
clearSelection();
|
||||
await reload();
|
||||
}
|
||||
|
||||
function onFolderDrop(e: DragEvent, folder: FolderItem) {
|
||||
@@ -2244,11 +2268,7 @@
|
||||
{/if}
|
||||
{#if shareDialog.component}
|
||||
{@const ShareDialog = shareDialog.component}
|
||||
<ShareDialog
|
||||
bind:open={shareOpen}
|
||||
item={actionTarget}
|
||||
onshared={(id) => (sharedIds = new SvelteSet(sharedIds).add(id))}
|
||||
/>
|
||||
<ShareDialog bind:open={shareOpen} item={actionTarget} onshared={(id) => sharedIds.add(id)} />
|
||||
{/if}
|
||||
{#if fileViewer.component}
|
||||
{@const FileViewer = fileViewer.component}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the files view's batch-operation rework
|
||||
* (`batchDelete` / `moveInto` / `selectionTargets` / `batchDownload` in
|
||||
* `[...path]/+page.svelte`).
|
||||
*
|
||||
* Audit finding: multi-item delete/move awaited one request per item in a
|
||||
* serial loop — at ~30 ms RTT a 100-item delete is ~3 s of waterfall — and
|
||||
* every per-id classification ran `listing.folders.find(...)` /
|
||||
* `listing.files.some(...)`, an O(N·M) scan over the listing per selected id.
|
||||
* The fix builds an id index once (O(M)) and fans the requests out through
|
||||
* the view's existing `mapLimit` with 6 in flight.
|
||||
*
|
||||
* The functions are component-internal, so — like the Rust bench modules that
|
||||
* replicate handler internals — this bench replicates BEFORE verbatim and
|
||||
* AFTER (index + `mapLimit`, the exact shapes now in the component) against a
|
||||
* stubbed per-item endpoint with simulated latency.
|
||||
*
|
||||
* Gates: (1) both arms attempt the identical (id, kind) operation set —
|
||||
* folder-first classification preserved; (2) a 100-item batch at 5 ms
|
||||
* simulated RTT completes ≥3x faster; (3) the classification scan count
|
||||
* drops from O(N·M) to one pass.
|
||||
*/
|
||||
|
||||
const M = 2_000; // listing size
|
||||
const N = 100; // selection size
|
||||
const RTT_MS = 5;
|
||||
|
||||
const listing = {
|
||||
folders: Array.from({ length: M / 4 }, (_, i) => ({ id: `d-${i}`, name: `dir ${i}` })),
|
||||
files: Array.from({ length: (3 * M) / 4 }, (_, i) => ({ id: `f-${i}`, name: `file ${i}` }))
|
||||
};
|
||||
// Selection interleaves folders and files, like a shift-range over a mixed view.
|
||||
const selectedIds = [
|
||||
...listing.folders.slice(40, 40 + N / 4).map((f) => f.id),
|
||||
...listing.files.slice(900, 900 + (3 * N) / 4).map((f) => f.id)
|
||||
];
|
||||
|
||||
/** Stubbed per-item endpoint: RTT_MS latency, records the attempted op. */
|
||||
function makeOps() {
|
||||
const attempted: Array<{ id: string; kind: 'file' | 'folder' }> = [];
|
||||
let comparisons = 0;
|
||||
return {
|
||||
attempted,
|
||||
countCmp: () => comparisons++,
|
||||
get comparisons() {
|
||||
return comparisons;
|
||||
},
|
||||
deleteFolder: async (id: string) => {
|
||||
attempted.push({ id, kind: 'folder' });
|
||||
await new Promise((r) => setTimeout(r, RTT_MS));
|
||||
},
|
||||
deleteFile: async (id: string) => {
|
||||
attempted.push({ id, kind: 'file' });
|
||||
await new Promise((r) => setTimeout(r, RTT_MS));
|
||||
}
|
||||
};
|
||||
}
|
||||
type Ops = ReturnType<typeof makeOps>;
|
||||
|
||||
/** BEFORE, verbatim shape: serial await + `find` per id. */
|
||||
async function batchDeleteBefore(ids: string[], ops: Ops): Promise<void> {
|
||||
for (const id of ids) {
|
||||
const folder = listing.folders.find((f) => {
|
||||
ops.countCmp();
|
||||
return f.id === id;
|
||||
});
|
||||
if (folder) await ops.deleteFolder(id);
|
||||
else await ops.deleteFile(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** The view's `mapLimit`, verbatim. */
|
||||
async function mapLimit<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const out = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < items.length) {
|
||||
const i = next++;
|
||||
out[i] = await fn(items[i]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** AFTER, verbatim shape: one O(M) index pass + bounded fan-out of 6. */
|
||||
async function batchDeleteAfter(ids: string[], ops: Ops): Promise<void> {
|
||||
const folderIdSet = new Set(
|
||||
listing.folders.map((f) => {
|
||||
ops.countCmp();
|
||||
return f.id;
|
||||
})
|
||||
);
|
||||
await mapLimit(ids, 6, async (id) => {
|
||||
if (folderIdSet.has(id)) await ops.deleteFolder(id);
|
||||
else await ops.deleteFile(id);
|
||||
});
|
||||
}
|
||||
|
||||
const opKey = (o: { id: string; kind: string }) => `${o.kind}:${o.id}`;
|
||||
|
||||
describe('files-view batch operations (benchmark gate)', () => {
|
||||
it(
|
||||
'both arms attempt the identical operation set, ≥3x faster fanned out',
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const before = makeOps();
|
||||
const t0 = performance.now();
|
||||
await batchDeleteBefore(selectedIds, before);
|
||||
const beforeMs = performance.now() - t0;
|
||||
|
||||
const after = makeOps();
|
||||
const t1 = performance.now();
|
||||
await batchDeleteAfter(selectedIds, after);
|
||||
const afterMs = performance.now() - t1;
|
||||
|
||||
// Equivalence: same ops, same folder/file classification. Order is
|
||||
// not part of the contract (the ops are independent single-item
|
||||
// endpoints); compare as sets and sizes.
|
||||
expect(after.attempted.length).toBe(before.attempted.length);
|
||||
expect(new Set(after.attempted.map(opKey))).toEqual(new Set(before.attempted.map(opKey)));
|
||||
expect(before.attempted.filter((o) => o.kind === 'folder').length).toBe(N / 4);
|
||||
|
||||
// Scan work: O(N·M) probes collapse to one O(M) pass.
|
||||
expect(after.comparisons).toBe(listing.folders.length);
|
||||
expect(before.comparisons).toBeGreaterThan(after.comparisons * 10);
|
||||
|
||||
console.info(
|
||||
`batch delete ${N} items @ ${RTT_MS} ms RTT: serial ${beforeMs.toFixed(0)} ms (${before.comparisons} id probes) vs mapLimit(6) ${afterMs.toFixed(0)} ms (${after.comparisons} probes) — ${(beforeMs / afterMs).toFixed(1)}x`
|
||||
);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 3);
|
||||
}
|
||||
);
|
||||
|
||||
it('selectionTargets index matches the per-id find, folder-first on collision', () => {
|
||||
// BEFORE: folder probed first per id. AFTER: files inserted first so
|
||||
// folders overwrite → folder wins collisions. Same observable result.
|
||||
const shadow = { id: listing.files[0].id, name: 'shadow-folder' };
|
||||
const foldersPlus = [...listing.folders, shadow];
|
||||
const wanted = [shadow.id, listing.folders[5].id, listing.files[10].id, 'missing-id'];
|
||||
|
||||
const beforeTargets = wanted
|
||||
.map((id) => {
|
||||
const folder = foldersPlus.find((f) => f.id === id);
|
||||
if (folder) return { id, name: folder.name, kind: 'folder' as const };
|
||||
const file = listing.files.find((f) => f.id === id);
|
||||
return file ? { id, name: file.name, kind: 'file' as const } : null;
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||
|
||||
const byId = new Map<string, { id: string; name: string; kind: 'file' | 'folder' }>();
|
||||
for (const f of listing.files) byId.set(f.id, { id: f.id, name: f.name, kind: 'file' });
|
||||
for (const f of foldersPlus) byId.set(f.id, { id: f.id, name: f.name, kind: 'folder' });
|
||||
const afterTargets = wanted
|
||||
.map((id) => byId.get(id) ?? null)
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||
|
||||
expect(afterTargets).toEqual(beforeTargets);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,14 @@
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { filterDotfiles } from '$lib/utils/dotfileFilter';
|
||||
import { dateTimeFormatFor } from '$lib/utils/display';
|
||||
import { isVideo, photoTimestamp } from '$lib/utils/media';
|
||||
import {
|
||||
PhotoTimeline,
|
||||
type GroupMode,
|
||||
type LayoutMode,
|
||||
type PhotoRow
|
||||
} from '$lib/utils/photoTimeline';
|
||||
|
||||
type Tab = 'moments' | 'places' | 'people';
|
||||
let tab = $state<Tab>('moments');
|
||||
@@ -48,8 +55,6 @@
|
||||
/** Usable content width of the grid, for the justified layout. */
|
||||
let gridWidth = $state(0);
|
||||
|
||||
type GroupMode = 'day' | 'month' | 'year';
|
||||
type LayoutMode = 'square' | 'justified';
|
||||
const GROUP_KEY = 'oxi-photos-group';
|
||||
const LAYOUT_KEY = 'oxi-photos-layout';
|
||||
let groupMode = $state<GroupMode>('month');
|
||||
@@ -63,153 +68,57 @@
|
||||
else if (tab === 'people') void peopleView.load();
|
||||
});
|
||||
|
||||
/** EXIF-aware timestamp (seconds → ms), matching the OLD grouping logic. */
|
||||
function bucketKey(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
if (groupMode === 'year') return `${y}`;
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||||
if (groupMode === 'month') return `${y}-${m}`;
|
||||
return `${y}-${m}-${`${d.getDate()}`.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function bucketLabel(d: Date): string {
|
||||
if (groupMode === 'year') return `${d.getFullYear()}`;
|
||||
if (groupMode === 'month')
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
|
||||
return d.toLocaleDateString(undefined, {
|
||||
/** Locale-aware label for a bucket's representative date. */
|
||||
function bucketLabel(d: Date, mode: GroupMode): string {
|
||||
if (mode === 'year') return `${d.getFullYear()}`;
|
||||
if (mode === 'month')
|
||||
return dateTimeFormatFor(undefined, { year: 'numeric', month: 'long' }).format(d);
|
||||
return dateTimeFormatFor(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const out: Array<{ key: string; label: string; photos: PhotoItem[] }> = [];
|
||||
// Transient scratch map built inside $derived.by and discarded — not reactive state.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const index = new Map<string, number>();
|
||||
for (const p of visibleItems) {
|
||||
const d = new Date(photoTimestamp(p));
|
||||
const key = bucketKey(d);
|
||||
let i = index.get(key);
|
||||
if (i === undefined) {
|
||||
i = out.length;
|
||||
index.set(key, i);
|
||||
out.push({ key, label: bucketLabel(d), photos: [] });
|
||||
}
|
||||
out[i].photos.push(p);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
interface JustifiedTile {
|
||||
file: PhotoItem;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack files into justified rows (Flickr-style): each full row is scaled to
|
||||
* fill `width` while preserving every tile's aspect ratio. Missing dimensions
|
||||
* fall back to 1:1.
|
||||
*/
|
||||
function justifiedRows(
|
||||
files: PhotoItem[],
|
||||
width: number
|
||||
): Array<{ height: number; tiles: JustifiedTile[] }> {
|
||||
const gap = 8;
|
||||
const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200;
|
||||
const rows: Array<{ height: number; tiles: JustifiedTile[] }> = [];
|
||||
let cur: Array<{ file: PhotoItem; aspect: number }> = [];
|
||||
let aspectSum = 0;
|
||||
for (const file of files) {
|
||||
let aspect = file.width && file.height ? file.width / file.height : 1;
|
||||
if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1;
|
||||
aspect = Math.min(Math.max(aspect, 0.4), 3);
|
||||
cur.push({ file, aspect });
|
||||
aspectSum += aspect;
|
||||
const rowWidth = aspectSum * target + (cur.length - 1) * gap;
|
||||
if (rowWidth >= width) {
|
||||
const h = (width - (cur.length - 1) * gap) / aspectSum;
|
||||
rows.push({
|
||||
height: Math.round(h),
|
||||
tiles: cur.map((tt) => ({
|
||||
file: tt.file,
|
||||
w: Math.max(1, Math.round(tt.aspect * h)),
|
||||
h: Math.round(h)
|
||||
}))
|
||||
});
|
||||
cur = [];
|
||||
aspectSum = 0;
|
||||
}
|
||||
}
|
||||
if (cur.length) {
|
||||
rows.push({
|
||||
height: target,
|
||||
tiles: cur.map((tt) => ({
|
||||
file: tt.file,
|
||||
w: Math.max(1, Math.round(tt.aspect * target)),
|
||||
h: target
|
||||
}))
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
// ── Virtualized row model ────────────────────────────────────────────────
|
||||
// Flatten the groups into a single list of fixed-height rows (a date header
|
||||
// or a strip of sized tiles), so VirtualRows can window the whole timeline —
|
||||
// only the rows near the viewport are mounted, regardless of library size.
|
||||
const SQUARE_GAP = 4; // .25rem, matches the old grid gap
|
||||
const SQUARE_MIN = 144; // 9rem minmax floor
|
||||
const JUSTIFIED_GAP = 8; // .photos-jrow margin-bottom
|
||||
const HEADER_H = 44;
|
||||
|
||||
type PhotoRow =
|
||||
| { kind: 'header'; key: string; height: number; label: string; count: number }
|
||||
| { kind: 'tiles'; key: string; height: number; gap: number; tiles: JustifiedTile[] };
|
||||
|
||||
const photoRows = $derived.by<PhotoRow[]>(() => {
|
||||
const W = gridWidth;
|
||||
if (W <= 0) return [];
|
||||
const rows: PhotoRow[] = [];
|
||||
const cols = Math.max(1, Math.floor((W + SQUARE_GAP) / (SQUARE_MIN + SQUARE_GAP)));
|
||||
const cell = (W - (cols - 1) * SQUARE_GAP) / cols;
|
||||
for (const g of groups) {
|
||||
rows.push({
|
||||
kind: 'header',
|
||||
key: `h:${g.key}`,
|
||||
height: HEADER_H,
|
||||
label: g.label,
|
||||
count: g.photos.length
|
||||
});
|
||||
if (layoutMode === 'justified') {
|
||||
const jrows = justifiedRows(g.photos, W);
|
||||
for (let ri = 0; ri < jrows.length; ri++) {
|
||||
rows.push({
|
||||
kind: 'tiles',
|
||||
key: `${g.key}:j${ri}`,
|
||||
height: jrows[ri].height + JUSTIFIED_GAP,
|
||||
gap: JUSTIFIED_GAP,
|
||||
tiles: jrows[ri].tiles
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < g.photos.length; i += cols) {
|
||||
const tiles = g.photos.slice(i, i + cols).map((file) => ({ file, w: cell, h: cell }));
|
||||
rows.push({
|
||||
kind: 'tiles',
|
||||
key: `${g.key}:s${i}`,
|
||||
height: cell + SQUARE_GAP,
|
||||
gap: SQUARE_GAP,
|
||||
tiles
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
// Flatten the date groups into a single list of fixed-height rows (a header
|
||||
// or a strip of sized tiles) that VirtualRows windows. Because pages arrive
|
||||
// newest-first, each append only extends the last group or adds new ones, so
|
||||
// PhotoTimeline re-buckets only the fresh page and re-lays-out only the
|
||||
// groups that changed — a full scroll stays O(N), not O(N²) (the old
|
||||
// `groups`→`photoRows` derive chain re-grouped + re-packed the whole library
|
||||
// on every 60-item page). See photoGrouping.bench.test.ts.
|
||||
// `sync` mutates the timeline's (non-reactive) internal group/row caches and
|
||||
// returns the flat rows. Driven from `$derived.by` for idempotence: if the
|
||||
// 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<PhotoRow[]>(() =>
|
||||
timeline.sync(visibleItems, {
|
||||
groupMode,
|
||||
layoutMode,
|
||||
width: gridWidth,
|
||||
mobile: isMobile,
|
||||
timestampOf: photoTimestamp,
|
||||
labelOf: bucketLabel
|
||||
})
|
||||
);
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || exhausted) return;
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
import { filterDotfiles } from '$lib/utils/dotfileFilter';
|
||||
import { replaceSet } from '$lib/utils/sets';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
|
||||
let raw = $state<RecentResourceItem[]>([]);
|
||||
@@ -37,7 +38,9 @@
|
||||
let groupBy = $state('');
|
||||
let reversed = $state(false);
|
||||
const owners = useOwnerCache(resolveOwnerName);
|
||||
let favoriteIds = $state<Set<string>>(new Set());
|
||||
// In-place reactive set — a star toggle skips the full-set copy and
|
||||
// spares the other favorited rows' readers.
|
||||
const favoriteIds = new SvelteSet<string>();
|
||||
|
||||
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
|
||||
|
||||
@@ -109,7 +112,10 @@
|
||||
async function loadFavoriteIds() {
|
||||
try {
|
||||
const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] });
|
||||
favoriteIds = new Set(favs.items.map((f) => f.resource.id));
|
||||
replaceSet(
|
||||
favoriteIds,
|
||||
favs.items.map((f) => f.resource.id)
|
||||
);
|
||||
} catch {
|
||||
// non-fatal — stars just default to off
|
||||
}
|
||||
@@ -169,18 +175,15 @@
|
||||
|
||||
async function toggleFavorite(entry: ResourceEntry) {
|
||||
const isFav = favoriteIds.has(entry.id);
|
||||
const next = new SvelteSet(favoriteIds);
|
||||
if (isFav) next.delete(entry.id);
|
||||
else next.add(entry.id);
|
||||
favoriteIds = next;
|
||||
// Optimistic in-place toggle, reverted on failure.
|
||||
if (isFav) favoriteIds.delete(entry.id);
|
||||
else favoriteIds.add(entry.id);
|
||||
try {
|
||||
if (isFav) await removeFavorite(entry.kind, entry.id);
|
||||
else await addFavorite(entry.kind, entry.id);
|
||||
} catch (e) {
|
||||
// revert on failure
|
||||
favoriteIds = isFav
|
||||
? new Set([...favoriteIds, entry.id])
|
||||
: new Set([...favoriteIds].filter((id) => id !== entry.id));
|
||||
if (isFav) favoriteIds.add(entry.id);
|
||||
else favoriteIds.delete(entry.id);
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
import UserVignette from '$lib/components/UserVignette.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { iconNameFromClass } from '$lib/utils/display';
|
||||
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
||||
|
||||
type GroupBy = 'items' | 'sharedWith';
|
||||
|
||||
@@ -158,9 +158,9 @@
|
||||
}
|
||||
function expiryLabel(iso: string | null | undefined): string {
|
||||
if (!iso) return t('share.noExpiry', 'No expiry');
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
// Same semantics as before (`''` for unparseable dates), now via the
|
||||
// shared util so it reuses the cached Intl.DateTimeFormat.
|
||||
return formatDate(iso);
|
||||
}
|
||||
function isoToDate(iso: string | null | undefined): string {
|
||||
return iso ? String(iso).slice(0, 10) : '';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Web-UI listing keyset — expression indexes for the default "name" sort
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- `list_resources_paged` (SPA files view) sorts case-insensitively on
|
||||
-- `LOWER(name)` with an id tie-breaker. The old query applied its keyset
|
||||
-- cursor OUTSIDE the folders/files UNION-ALL on computed columns, so every
|
||||
-- page rescanned and top-N-sorted the whole folder (28 ms/page on a
|
||||
-- 20k-entry folder). The query now pushes the cursor into each branch as a
|
||||
-- sargable row-value comparison `(LOWER(name), id) > ($str, $id)` — these
|
||||
-- two partial expression indexes let each branch answer that with one
|
||||
-- bounded, pre-ordered index-range read (1.3 ms/page, 19.5x;
|
||||
-- benches/LISTING-KEYSET.md).
|
||||
--
|
||||
-- Sibling of `idx_files_folder_name (folder_id, name)` (migration
|
||||
-- 20260917000000), which serves the byte-wise DAV ordering; the SPA orders
|
||||
-- by LOWER(name), which that index cannot provide.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_files_folder_lname
|
||||
ON storage.files (folder_id, LOWER(name), id)
|
||||
WHERE NOT is_trashed;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_parent_lname
|
||||
ON storage.folders (parent_id, LOWER(name), id)
|
||||
WHERE NOT is_trashed;
|
||||
@@ -56,14 +56,32 @@ fn parse_caldav_datetime(value: &str) -> Option<DateTime<Utc>> {
|
||||
/// `None` if either tag is missing (malformed body) so callers
|
||||
/// can fall back safely.
|
||||
pub(crate) fn extract_vevent_chunk(ical_data: &str) -> Option<&str> {
|
||||
let upper = ical_data.to_ascii_uppercase();
|
||||
let begin = upper.find("BEGIN:VEVENT")?;
|
||||
// End marker: the line-start of END:VEVENT after `begin`, plus
|
||||
// the length of "END:VEVENT" itself, then find the next CRLF/LF
|
||||
// to include the terminator line.
|
||||
let after_begin = &upper[begin..];
|
||||
let rel_end = after_begin.find("END:VEVENT")?;
|
||||
let end_tag_end = begin + rel_end + "END:VEVENT".len();
|
||||
// Byte index of the first ASCII-case-insensitive occurrence of
|
||||
// `needle` in `hay` at or after `from`. Every stored body OxiCloud
|
||||
// itself writes carries uppercase tags, so try the memchr-backed
|
||||
// exact `find` first; only genuinely mixed-case foreign bodies pay
|
||||
// the manual scan. Either way this replaces the old
|
||||
// `to_ascii_uppercase()` of the ENTIRE body — one full-copy String
|
||||
// allocation per event per REPORT/GET, done purely to locate two
|
||||
// tags.
|
||||
fn find_ci(hay: &str, needle: &str, from: usize) -> Option<usize> {
|
||||
if let Some(i) = hay[from..].find(needle) {
|
||||
return Some(from + i);
|
||||
}
|
||||
let h = hay.as_bytes();
|
||||
let n = needle.as_bytes();
|
||||
if h.len() < n.len() {
|
||||
return None;
|
||||
}
|
||||
(from..=h.len() - n.len()).find(|&i| h[i..i + n.len()].eq_ignore_ascii_case(n))
|
||||
}
|
||||
|
||||
let begin = find_ci(ical_data, "BEGIN:VEVENT", 0)?;
|
||||
// End marker: the first END:VEVENT after `begin`, plus the length
|
||||
// of "END:VEVENT" itself, then any immediate CRLF/LF to include
|
||||
// the terminator line.
|
||||
let rel_end = find_ci(ical_data, "END:VEVENT", begin)?;
|
||||
let end_tag_end = rel_end + "END:VEVENT".len();
|
||||
// Include any immediate line terminator so the chunk stays a
|
||||
// well-formed line even when the caller concatenates.
|
||||
let mut end = end_tag_end;
|
||||
@@ -88,21 +106,27 @@ pub(crate) fn extract_vevent_chunk(ical_data: &str) -> Option<&str> {
|
||||
pub(crate) fn group_events_by_uid<'a>(
|
||||
events: &'a [CalendarEventDto],
|
||||
) -> Vec<Vec<&'a CalendarEventDto>> {
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
let mut buckets: std::collections::HashMap<String, Vec<&'a CalendarEventDto>> =
|
||||
// Keys borrow from the DTO slice (which outlives every local) — the
|
||||
// old String-keyed map cloned every event's UID (twice for first
|
||||
// appearances) on every REPORT / collection PROPFIND / GET.
|
||||
let mut order: Vec<&'a str> = Vec::new();
|
||||
let mut buckets: std::collections::HashMap<&'a str, Vec<&'a CalendarEventDto>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for event in events {
|
||||
let key = event.ical_uid.clone();
|
||||
if !buckets.contains_key(&key) {
|
||||
order.push(key.clone());
|
||||
let key = event.ical_uid.as_str();
|
||||
match buckets.entry(key) {
|
||||
std::collections::hash_map::Entry::Vacant(slot) => {
|
||||
order.push(key);
|
||||
slot.insert(vec![event]);
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(mut slot) => slot.get_mut().push(event),
|
||||
}
|
||||
buckets.entry(key).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(order.len());
|
||||
for uid in order {
|
||||
let mut bucket = buckets.remove(&uid).unwrap_or_default();
|
||||
let mut bucket = buckets.remove(uid).unwrap_or_default();
|
||||
// Master first (recurrence_id None), exceptions in insertion order.
|
||||
bucket.sort_by_key(|e| e.recurrence_id.is_some());
|
||||
out.push(bucket);
|
||||
@@ -1040,71 +1064,142 @@ impl CalDavAdapter {
|
||||
// Write the calendar collection itself
|
||||
Self::write_calendar_response(&mut xml_writer, calendar, request, base_href, caller_id)?;
|
||||
|
||||
// If depth > 0, include event resources — folded per UID
|
||||
// so a recurring event's master + per-instance exception
|
||||
// overrides share ONE D:response (RFC 4791 §4.1 + RFC
|
||||
// 5545 §3.6.1). Pre-fix this loop emitted one D:response
|
||||
// per DB row, and since master + exception share the
|
||||
// same href (base + uid.ics) clients saw a duplicate
|
||||
// href and deduped — the exception appeared to have
|
||||
// vanished.
|
||||
// If depth > 0, include event resources — see
|
||||
// `write_collection_event_page`, which the streaming emitter
|
||||
// reuses page by page.
|
||||
if depth != "0" {
|
||||
for bundle in group_events_by_uid(events) {
|
||||
// The master (sorted first by group_events_by_uid)
|
||||
// supplies the ETag anchor + getlastmodified. If
|
||||
// the bundle is all exceptions (no master row),
|
||||
// fall back to the first exception.
|
||||
let anchor = match bundle.first() {
|
||||
Some(e) => *e,
|
||||
None => continue,
|
||||
};
|
||||
let event_href = format!("{}{}.ics", base_href, anchor.ical_uid);
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&event_href)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
|
||||
// resourcetype (empty for non-collection)
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||
|
||||
// getetag — anchor row's id
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
|
||||
// getcontenttype
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(
|
||||
"text/calendar; component=vevent",
|
||||
)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||
|
||||
// getlastmodified — anchor row's updated_at
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&anchor.updated_at.to_rfc2822())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
|
||||
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
|
||||
}
|
||||
Self::write_collection_event_page(&mut xml_writer, events, base_href)?;
|
||||
}
|
||||
|
||||
Self::write_caldav_multistatus_end(&mut xml_writer)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Multistatus opening + the calendar collection's own
|
||||
/// `D:response` — the head of a depth-1 collection PROPFIND. The
|
||||
/// streaming emitter calls this once, then
|
||||
/// [`Self::write_collection_event_page`] per hydrated UID page,
|
||||
/// then [`Self::write_caldav_multistatus_end`].
|
||||
pub fn write_collection_head<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
calendar: &CalendarDto,
|
||||
request: &PropFindRequest,
|
||||
base_href: &str,
|
||||
caller_id: &str,
|
||||
) -> Result<()> {
|
||||
Self::write_caldav_multistatus_start(xml_writer)?;
|
||||
Self::write_calendar_response(xml_writer, calendar, request, base_href, caller_id)
|
||||
}
|
||||
|
||||
/// One depth-1 collection page: event resources folded per UID so a
|
||||
/// recurring master + per-instance exception overrides share ONE
|
||||
/// `D:response` (RFC 4791 §4.1 + RFC 5545 §3.6.1) — emitting one
|
||||
/// response per DB row made clients dedupe the shared href and the
|
||||
/// exception appeared to vanish. Callers guarantee same-UID rows
|
||||
/// arrive within a single page.
|
||||
pub fn write_collection_event_page<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
events: &[CalendarEventDto],
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
for bundle in group_events_by_uid(events) {
|
||||
// The master (sorted first by group_events_by_uid)
|
||||
// supplies the ETag anchor + getlastmodified. If
|
||||
// the bundle is all exceptions (no master row),
|
||||
// fall back to the first exception.
|
||||
let anchor = match bundle.first() {
|
||||
Some(e) => *e,
|
||||
None => continue,
|
||||
};
|
||||
let event_href = format!("{}{}.ics", base_href, anchor.ical_uid);
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&event_href)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
|
||||
// resourcetype (empty for non-collection)
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||
|
||||
// getetag — anchor row's id
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
|
||||
// getcontenttype
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(
|
||||
"text/calendar; component=vevent",
|
||||
)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||
|
||||
// getlastmodified — anchor row's updated_at
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&anchor.updated_at.to_rfc2822())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
|
||||
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the CalDAV `<D:multistatus>` opening tag (DAV + CalDAV +
|
||||
/// CalendarServer namespaces). Streaming emitters call this once,
|
||||
/// then [`Self::write_report_page`] per hydrated UID page, then
|
||||
/// [`Self::write_caldav_multistatus_end`].
|
||||
pub fn write_caldav_multistatus_start<W: Write>(xml_writer: &mut Writer<W>) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(
|
||||
BytesStart::new("D:multistatus").with_attributes([
|
||||
("xmlns:D", "DAV:"),
|
||||
("xmlns:C", "urn:ietf:params:xml:ns:caldav"),
|
||||
("xmlns:CS", "http://calendarserver.org/ns/"),
|
||||
]),
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the multistatus opened by
|
||||
/// [`Self::write_caldav_multistatus_start`].
|
||||
pub fn write_caldav_multistatus_end<W: Write>(xml_writer: &mut Writer<W>) -> Result<()> {
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One REPORT page: group `events` per UID and emit one
|
||||
/// `D:response` per bundle. Callers guarantee same-UID rows arrive
|
||||
/// within a single page (the uid-keyset pager does).
|
||||
pub fn write_report_page<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
events: &[CalendarEventDto],
|
||||
request: &CalDavReportType,
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
let props = match request {
|
||||
CalDavReportType::CalendarQuery { props, .. } => props,
|
||||
CalDavReportType::CalendarMultiget { props, .. } => props,
|
||||
CalDavReportType::SyncCollection { props, .. } => props,
|
||||
};
|
||||
for bundle in group_events_by_uid(events) {
|
||||
let anchor = match bundle.first() {
|
||||
Some(e) => *e,
|
||||
None => continue,
|
||||
};
|
||||
let href = format!("{}{}.ics", base_href, anchor.ical_uid);
|
||||
Self::write_event_response(xml_writer, &bundle, props, &href)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a response for calendar events
|
||||
pub fn generate_calendar_events_response<W: Write>(
|
||||
writer: W,
|
||||
@@ -1114,40 +1209,15 @@ impl CalDavAdapter {
|
||||
) -> Result<()> {
|
||||
let mut xml_writer = Writer::new(writer);
|
||||
|
||||
// Start multistatus response
|
||||
xml_writer.write_event(Event::Start(
|
||||
BytesStart::new("D:multistatus").with_attributes([
|
||||
("xmlns:D", "DAV:"),
|
||||
("xmlns:C", "urn:ietf:params:xml:ns:caldav"),
|
||||
("xmlns:CS", "http://calendarserver.org/ns/"),
|
||||
]),
|
||||
))?;
|
||||
Self::write_caldav_multistatus_start(&mut xml_writer)?;
|
||||
|
||||
// Determine which properties to include based on request type
|
||||
let props = match request {
|
||||
CalDavReportType::CalendarQuery { props, .. } => props.clone(),
|
||||
CalDavReportType::CalendarMultiget { props, .. } => props.clone(),
|
||||
CalDavReportType::SyncCollection { props, .. } => props.clone(),
|
||||
};
|
||||
// Responses folded per UID so a recurring master + exception
|
||||
// overrides share ONE D:response (RFC 4791 §4.1) — see
|
||||
// `write_report_page`, which the streaming emitters reuse
|
||||
// page by page.
|
||||
Self::write_report_page(&mut xml_writer, events, request, base_href)?;
|
||||
|
||||
// Add responses for events — folded per UID so a
|
||||
// recurring master + per-instance exception overrides
|
||||
// share ONE D:response with all VEVENTs concatenated
|
||||
// into the calendar-data payload (RFC 4791 §4.1). Pre-
|
||||
// fix this loop emitted one D:response per DB row, so
|
||||
// master + exception carried duplicate hrefs and clients
|
||||
// deduped, hiding the exception from the resulting sync.
|
||||
for bundle in group_events_by_uid(events) {
|
||||
let anchor = match bundle.first() {
|
||||
Some(e) => *e,
|
||||
None => continue,
|
||||
};
|
||||
let href = format!("{}{}.ics", base_href, anchor.ical_uid);
|
||||
Self::write_event_response(&mut xml_writer, &bundle, &props, &href)?;
|
||||
}
|
||||
|
||||
// End multistatus
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
|
||||
Self::write_caldav_multistatus_end(&mut xml_writer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1418,6 +1488,26 @@ impl CalDavAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Bench support
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Thin public wrappers over the `pub(crate)` read-side helpers so
|
||||
/// `examples/bench_caldav_parse.rs` can measure them. Gated behind the
|
||||
/// `bench` feature — adds nothing to prod builds.
|
||||
#[cfg(feature = "bench")]
|
||||
pub mod bench {
|
||||
use super::*;
|
||||
|
||||
pub fn extract_vevent_chunk(ical_data: &str) -> Option<&str> {
|
||||
super::extract_vevent_chunk(ical_data)
|
||||
}
|
||||
|
||||
pub fn group_events_by_uid(events: &[CalendarEventDto]) -> Vec<Vec<&CalendarEventDto>> {
|
||||
super::group_events_by_uid(events)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -278,6 +278,27 @@ impl CardDavAdapter {
|
||||
) -> Result<()> {
|
||||
let mut xml_writer = Writer::new(writer);
|
||||
|
||||
Self::write_collection_head(&mut xml_writer, address_book, request, base_href)?;
|
||||
|
||||
// Write contacts if depth > 0
|
||||
if depth != "0" {
|
||||
Self::write_collection_contact_page(&mut xml_writer, contacts, base_href)?;
|
||||
}
|
||||
|
||||
Self::write_carddav_multistatus_end(&mut xml_writer)
|
||||
}
|
||||
|
||||
/// Multistatus opening (DAV + CardDAV + CalendarServer namespaces)
|
||||
/// plus the address book's own `D:response` — the head of a depth-1
|
||||
/// collection PROPFIND. Streaming emitters call this once, then
|
||||
/// [`Self::write_collection_contact_page`] per cursor page, then
|
||||
/// [`Self::write_carddav_multistatus_end`].
|
||||
pub fn write_collection_head<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
address_book: &AddressBookDto,
|
||||
request: &PropFindRequest,
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(
|
||||
BytesStart::new("D:multistatus").with_attributes([
|
||||
("xmlns:D", "DAV:"),
|
||||
@@ -285,19 +306,25 @@ impl CardDavAdapter {
|
||||
("xmlns:CS", "http://calendarserver.org/ns/"),
|
||||
]),
|
||||
))?;
|
||||
Self::write_addressbook_response(xml_writer, address_book, request, base_href)
|
||||
}
|
||||
|
||||
// Write the address book itself
|
||||
Self::write_addressbook_response(&mut xml_writer, address_book, request, base_href)?;
|
||||
|
||||
// Write contacts if depth > 0
|
||||
if depth != "0" {
|
||||
for contact in contacts {
|
||||
let contact_href = format!("{}{}.vcf", base_href, contact.uid);
|
||||
Self::write_contact_response(&mut xml_writer, contact, &[], &contact_href)?;
|
||||
}
|
||||
/// One depth-1 collection page of contact entries (standard props;
|
||||
/// href buffer reused across the page).
|
||||
pub fn write_collection_contact_page<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
contacts: &[ContactDto],
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
let mut href = String::with_capacity(base_href.len() + 48);
|
||||
for contact in contacts {
|
||||
href.clear();
|
||||
let _ = std::fmt::Write::write_fmt(
|
||||
&mut href,
|
||||
format_args!("{}{}.vcf", base_href, contact.uid),
|
||||
);
|
||||
Self::write_contact_response(xml_writer, contact, &[], &href)?;
|
||||
}
|
||||
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -648,47 +675,65 @@ impl CardDavAdapter {
|
||||
}
|
||||
|
||||
/// Generate response for contacts (for REPORT)
|
||||
pub fn generate_contacts_response<W: Write>(
|
||||
writer: W,
|
||||
contacts: &[ContactDto],
|
||||
vcards: &[(String, String)], // (uid, vcard_data)
|
||||
report: &CardDavReportType,
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
let mut xml_writer = Writer::new(writer);
|
||||
|
||||
/// REPORT `<D:multistatus>` opening tag (DAV + CardDAV namespaces).
|
||||
/// Streaming emitters call this once, then
|
||||
/// [`Self::write_contacts_report_page`] per cursor page, then
|
||||
/// [`Self::write_carddav_multistatus_end`].
|
||||
pub fn write_report_multistatus_start<W: Write>(xml_writer: &mut Writer<W>) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(
|
||||
BytesStart::new("D:multistatus").with_attributes([
|
||||
("xmlns:D", "DAV:"),
|
||||
("xmlns:CR", "urn:ietf:params:xml:ns:carddav"),
|
||||
]),
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let props = match report {
|
||||
CardDavReportType::AddressbookQuery { props } => props.clone(),
|
||||
CardDavReportType::AddressbookMultiget { props, .. } => props.clone(),
|
||||
CardDavReportType::SyncCollection { props, .. } => props.clone(),
|
||||
};
|
||||
|
||||
for contact in contacts {
|
||||
let href = format!("{}{}.vcf", base_href, contact.uid);
|
||||
let vcard = vcards
|
||||
.iter()
|
||||
.find(|(uid, _)| *uid == contact.uid)
|
||||
.map(|(_, data)| data.as_str())
|
||||
.unwrap_or("");
|
||||
Self::write_contact_response(&mut xml_writer, contact, &props, &href)?;
|
||||
// If address-data is requested, include vcard
|
||||
if props.iter().any(|p| p.name == "address-data") || props.is_empty() {
|
||||
// Already handled in write_contact_response
|
||||
}
|
||||
let _ = vcard; // suppress warning - used via contact_to_vcard fallback
|
||||
}
|
||||
|
||||
/// Close a multistatus opened by either start writer.
|
||||
pub fn write_carddav_multistatus_end<W: Write>(xml_writer: &mut Writer<W>) -> Result<()> {
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One REPORT page of contact responses. Props are borrowed from
|
||||
/// the request; one href buffer is reused across the page.
|
||||
pub fn write_contacts_report_page<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
contacts: &[ContactDto],
|
||||
report: &CardDavReportType,
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
let props = match report {
|
||||
CardDavReportType::AddressbookQuery { props } => props,
|
||||
CardDavReportType::AddressbookMultiget { props, .. } => props,
|
||||
CardDavReportType::SyncCollection { props, .. } => props,
|
||||
};
|
||||
let mut href = String::with_capacity(base_href.len() + 48);
|
||||
for contact in contacts {
|
||||
href.clear();
|
||||
let _ = std::fmt::Write::write_fmt(
|
||||
&mut href,
|
||||
format_args!("{}{}.vcf", base_href, contact.uid),
|
||||
);
|
||||
// `write_contact_response` generates the vCard on demand when (and
|
||||
// only when) address-data is actually requested.
|
||||
Self::write_contact_response(xml_writer, contact, props, &href)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn generate_contacts_response<W: Write>(
|
||||
writer: W,
|
||||
contacts: &[ContactDto],
|
||||
report: &CardDavReportType,
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
let mut xml_writer = Writer::new(writer);
|
||||
Self::write_report_multistatus_start(&mut xml_writer)?;
|
||||
Self::write_contacts_report_page(&mut xml_writer, contacts, report, base_href)?;
|
||||
Self::write_carddav_multistatus_end(&mut xml_writer)
|
||||
}
|
||||
|
||||
/// Write a single contact response element
|
||||
fn write_contact_response<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
@@ -710,10 +755,11 @@ impl CardDavAdapter {
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"\"{}\"",
|
||||
contact.etag
|
||||
))))?;
|
||||
let mut quoted = String::with_capacity(contact.etag.len() + 2);
|
||||
quoted.push('"');
|
||||
quoted.push_str(&contact.etag);
|
||||
quoted.push('"');
|
||||
xml_writer.write_event(Event::Text(BytesText::new("ed)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||
@@ -733,10 +779,11 @@ impl CardDavAdapter {
|
||||
}
|
||||
("DAV:", "getetag") => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"\"{}\"",
|
||||
contact.etag
|
||||
))))?;
|
||||
let mut quoted = String::with_capacity(contact.etag.len() + 2);
|
||||
quoted.push('"');
|
||||
quoted.push_str(&contact.etag);
|
||||
quoted.push('"');
|
||||
xml_writer.write_event(Event::Text(BytesText::new("ed)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
}
|
||||
("DAV:", "getcontenttype") => {
|
||||
@@ -868,20 +915,25 @@ impl CardDavAdapter {
|
||||
|
||||
/// Convert a ContactDto to vCard 3.0 format
|
||||
pub fn contact_to_vcard(contact: &ContactDto) -> String {
|
||||
// `write!` into a String is infallible; `let _ =` discards the Ok(()).
|
||||
// Formatting straight into the buffer avoids one temporary String per
|
||||
// vCard line compared to `push_str(&format!(…))`.
|
||||
use std::fmt::Write as _;
|
||||
|
||||
let mut vcard = String::from("BEGIN:VCARD\r\nVERSION:3.0\r\n");
|
||||
|
||||
vcard.push_str(&format!("UID:{}\r\n", contact.uid));
|
||||
let _ = write!(vcard, "UID:{}\r\n", contact.uid);
|
||||
|
||||
if let (Some(last), Some(first)) = (&contact.last_name, &contact.first_name) {
|
||||
vcard.push_str(&format!("N:{};{};;;\r\n", last, first));
|
||||
let _ = write!(vcard, "N:{};{};;;\r\n", last, first);
|
||||
} else if let Some(last) = &contact.last_name {
|
||||
vcard.push_str(&format!("N:{};;;;\r\n", last));
|
||||
let _ = write!(vcard, "N:{};;;;\r\n", last);
|
||||
} else if let Some(first) = &contact.first_name {
|
||||
vcard.push_str(&format!("N:;{};;;\r\n", first));
|
||||
let _ = write!(vcard, "N:;{};;;\r\n", first);
|
||||
}
|
||||
|
||||
if let Some(fn_name) = &contact.full_name {
|
||||
vcard.push_str(&format!("FN:{}\r\n", fn_name));
|
||||
let _ = write!(vcard, "FN:{}\r\n", fn_name);
|
||||
} else {
|
||||
// FN is mandatory in vCard 3.0
|
||||
let fn_name = format!(
|
||||
@@ -892,68 +944,68 @@ pub fn contact_to_vcard(contact: &ContactDto) -> String {
|
||||
.trim()
|
||||
.to_string();
|
||||
if !fn_name.is_empty() {
|
||||
vcard.push_str(&format!("FN:{}\r\n", fn_name));
|
||||
let _ = write!(vcard, "FN:{}\r\n", fn_name);
|
||||
} else {
|
||||
vcard.push_str("FN:Unknown\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(nickname) = &contact.nickname {
|
||||
vcard.push_str(&format!("NICKNAME:{}\r\n", nickname));
|
||||
let _ = write!(vcard, "NICKNAME:{}\r\n", nickname);
|
||||
}
|
||||
|
||||
for email in &contact.email {
|
||||
vcard.push_str(&format!(
|
||||
let _ = write!(
|
||||
vcard,
|
||||
"EMAIL;TYPE={}:{}\r\n",
|
||||
email.r#type.to_uppercase(),
|
||||
email.email
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
for phone in &contact.phone {
|
||||
vcard.push_str(&format!(
|
||||
let _ = write!(
|
||||
vcard,
|
||||
"TEL;TYPE={}:{}\r\n",
|
||||
phone.r#type.to_uppercase(),
|
||||
phone.number
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
for addr in &contact.address {
|
||||
let adr = format!(
|
||||
";;{};{};{};{};{}",
|
||||
let _ = write!(
|
||||
vcard,
|
||||
"ADR;TYPE={}:;;{};{};{};{};{}\r\n",
|
||||
addr.r#type.to_uppercase(),
|
||||
addr.street.as_deref().unwrap_or(""),
|
||||
addr.city.as_deref().unwrap_or(""),
|
||||
addr.state.as_deref().unwrap_or(""),
|
||||
addr.postal_code.as_deref().unwrap_or(""),
|
||||
addr.country.as_deref().unwrap_or(""),
|
||||
);
|
||||
vcard.push_str(&format!(
|
||||
"ADR;TYPE={}:{}\r\n",
|
||||
addr.r#type.to_uppercase(),
|
||||
adr
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(org) = &contact.organization {
|
||||
vcard.push_str(&format!("ORG:{}\r\n", org));
|
||||
let _ = write!(vcard, "ORG:{}\r\n", org);
|
||||
}
|
||||
if let Some(title) = &contact.title {
|
||||
vcard.push_str(&format!("TITLE:{}\r\n", title));
|
||||
let _ = write!(vcard, "TITLE:{}\r\n", title);
|
||||
}
|
||||
if let Some(notes) = &contact.notes {
|
||||
vcard.push_str(&format!("NOTE:{}\r\n", notes.replace('\n', "\\n")));
|
||||
let _ = write!(vcard, "NOTE:{}\r\n", notes.replace('\n', "\\n"));
|
||||
}
|
||||
if let Some(bday) = &contact.birthday {
|
||||
vcard.push_str(&format!("BDAY:{}\r\n", bday.format("%Y-%m-%d")));
|
||||
let _ = write!(vcard, "BDAY:{}\r\n", bday.format("%Y-%m-%d"));
|
||||
}
|
||||
if let Some(photo) = &contact.photo_url {
|
||||
vcard.push_str(&format!("PHOTO;VALUE=URI:{}\r\n", photo));
|
||||
let _ = write!(vcard, "PHOTO;VALUE=URI:{}\r\n", photo);
|
||||
}
|
||||
|
||||
vcard.push_str(&format!(
|
||||
let _ = write!(
|
||||
vcard,
|
||||
"REV:{}\r\n",
|
||||
contact.updated_at.format("%Y%m%dT%H%M%SZ")
|
||||
));
|
||||
);
|
||||
vcard.push_str("END:VCARD\r\n");
|
||||
|
||||
vcard
|
||||
|
||||
@@ -484,10 +484,6 @@ mod tests {
|
||||
#[test]
|
||||
fn test_generate_contacts_response() {
|
||||
let contacts = vec![sample_contact()];
|
||||
let vcards = vec![(
|
||||
"contact-001".to_string(),
|
||||
contact_to_vcard(&sample_contact()),
|
||||
)];
|
||||
let report = CardDavReportType::AddressbookQuery {
|
||||
props: vec![
|
||||
QualifiedName {
|
||||
@@ -505,7 +501,6 @@ mod tests {
|
||||
let result = CardDavAdapter::generate_contacts_response(
|
||||
&mut output,
|
||||
&contacts,
|
||||
&vcards,
|
||||
&report,
|
||||
"/carddav/ab-001",
|
||||
);
|
||||
@@ -528,14 +523,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_generate_empty_contacts_response() {
|
||||
let contacts: Vec<ContactDto> = vec![];
|
||||
let vcards: Vec<(String, String)> = vec![];
|
||||
let report = CardDavReportType::AddressbookQuery { props: vec![] };
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_contacts_response(
|
||||
&mut output,
|
||||
&contacts,
|
||||
&vcards,
|
||||
&report,
|
||||
"/carddav/ab-001",
|
||||
);
|
||||
|
||||
@@ -627,17 +627,19 @@ impl WebDavAdapter {
|
||||
// RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat.
|
||||
// Props found in the dead store are returned in the dead 200 propstat,
|
||||
// so exclude them from the 404 propstat to avoid duplicate reporting.
|
||||
let (known, unknown): (Vec<_>, Vec<_>) = props
|
||||
// Single pass: the requested-props writer skips unknown
|
||||
// names itself (its match arms mirror
|
||||
// `folder_prop_is_known` exactly), so only the usually
|
||||
// empty 404 list needs materialising — the old
|
||||
// `partition` built two throwaway Vecs per row.
|
||||
let truly_unknown: Vec<_> = props
|
||||
.iter()
|
||||
.partition(|p| Self::folder_prop_is_known(p, quota));
|
||||
let truly_unknown: Vec<_> = unknown
|
||||
.into_iter()
|
||||
.filter(|p| !dead_name_set.contains(*p))
|
||||
.filter(|p| !Self::folder_prop_is_known(p, quota) && !dead_name_set.contains(p))
|
||||
.collect();
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
Self::write_folder_requested_props(xml_writer, folder, &known, quota)?;
|
||||
Self::write_folder_requested_props(xml_writer, folder, props, quota)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||
@@ -714,16 +716,19 @@ impl WebDavAdapter {
|
||||
// RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat.
|
||||
// Props found in the dead store are returned in the dead 200 propstat,
|
||||
// so exclude them from the 404 propstat to avoid duplicate reporting.
|
||||
let (known, unknown): (Vec<_>, Vec<_>) =
|
||||
props.iter().partition(|p| Self::file_prop_is_known(p));
|
||||
let truly_unknown: Vec<_> = unknown
|
||||
.into_iter()
|
||||
.filter(|p| !dead_name_set.contains(*p))
|
||||
// Single pass: the requested-props writer skips unknown
|
||||
// names itself (its match arms mirror `file_prop_is_known`
|
||||
// exactly), so only the usually empty 404 list needs
|
||||
// materialising — the old `partition` built two throwaway
|
||||
// Vecs per row.
|
||||
let truly_unknown: Vec<_> = props
|
||||
.iter()
|
||||
.filter(|p| !Self::file_prop_is_known(p) && !dead_name_set.contains(p))
|
||||
.collect();
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
Self::write_file_requested_props(xml_writer, file, &known)?;
|
||||
Self::write_file_requested_props(xml_writer, file, props)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||
@@ -759,6 +764,71 @@ impl WebDavAdapter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Per-row formatted-value writers (stack-rendered) ─────────────
|
||||
//
|
||||
// PROPFIND emits two formatted dates, a size and a quoted etag for
|
||||
// EVERY row of every listing. `to_rfc3339()`/`to_rfc2822()` ran
|
||||
// chrono's format-spec interpreter and allocated a String each;
|
||||
// `to_string()`/`format!` added two more. These render the same
|
||||
// bytes from stack buffers (`common::fmt`); out-of-range timestamps
|
||||
// keep the old chrono path as a byte-identical fallback.
|
||||
|
||||
fn write_creationdate<W: Write>(xml_writer: &mut Writer<W>, secs: u64) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?;
|
||||
let secs = secs as i64;
|
||||
let mut buf = [0u8; 25];
|
||||
match crate::common::fmt::rfc3339_utc(&mut buf, secs) {
|
||||
Some(s) => xml_writer.write_event(Event::Text(BytesText::new(s)))?,
|
||||
None => {
|
||||
let s = chrono::DateTime::<Utc>::from_timestamp(secs, 0)
|
||||
.unwrap_or_else(Utc::now)
|
||||
.to_rfc3339();
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&s)))?;
|
||||
}
|
||||
}
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_lastmodified<W: Write>(xml_writer: &mut Writer<W>, secs: u64) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
let secs = secs as i64;
|
||||
let mut buf = [0u8; 31];
|
||||
match crate::common::fmt::rfc2822_utc(&mut buf, secs) {
|
||||
Some(s) => xml_writer.write_event(Event::Text(BytesText::new(s)))?,
|
||||
None => {
|
||||
let s = chrono::DateTime::<Utc>::from_timestamp(secs, 0)
|
||||
.unwrap_or_else(Utc::now)
|
||||
.to_rfc2822();
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&s)))?;
|
||||
}
|
||||
}
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_etag_quoted<W: Write>(xml_writer: &mut Writer<W>, etag: &str) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
// One exactly-sized allocation instead of format!'s grow-from-empty.
|
||||
let mut quoted = String::with_capacity(etag.len() + 2);
|
||||
quoted.push('"');
|
||||
quoted.push_str(etag);
|
||||
quoted.push('"');
|
||||
xml_writer.write_event(Event::Text(BytesText::new("ed)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_contentlength<W: Write>(xml_writer: &mut Writer<W>, size: u64) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
|
||||
let mut buf = [0u8; 20];
|
||||
xml_writer.write_event(Event::Text(BytesText::new(crate::common::fmt::u64_str(
|
||||
&mut buf, size,
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write standard folder properties
|
||||
fn write_folder_standard_props<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
@@ -776,31 +846,15 @@ impl WebDavAdapter {
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
|
||||
|
||||
// Creation date
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?;
|
||||
|
||||
// Convert u64 timestamp to DateTime
|
||||
let created_at = chrono::DateTime::<Utc>::from_timestamp(folder.created_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?;
|
||||
Self::write_creationdate(xml_writer, folder.created_at)?;
|
||||
|
||||
// Last modified
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
|
||||
// Convert u64 timestamp to DateTime
|
||||
let modified_at = chrono::DateTime::<Utc>::from_timestamp(folder.modified_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
Self::write_lastmodified(xml_writer, folder.modified_at)?;
|
||||
|
||||
// ETag — routes through `FolderDto::etag` (= `Folder::etag()`)
|
||||
// so every WebDAV emitter and HEAD response agree on a single
|
||||
// value for the same folder.
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", folder.etag))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
Self::write_etag_quoted(xml_writer, &folder.etag)?;
|
||||
|
||||
// Content length (0 for directories)
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
|
||||
@@ -829,13 +883,19 @@ impl WebDavAdapter {
|
||||
used_bytes: i64,
|
||||
available_bytes: Option<i64>,
|
||||
) -> Result<()> {
|
||||
let mut buf = [0u8; 21];
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:quota-used-bytes")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&used_bytes.to_string())))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(crate::common::fmt::i64_str(
|
||||
&mut buf, used_bytes,
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:quota-used-bytes")))?;
|
||||
|
||||
if let Some(available_bytes) = available_bytes {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:quota-available-bytes")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&available_bytes.to_string())))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(crate::common::fmt::i64_str(
|
||||
&mut buf,
|
||||
available_bytes,
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:quota-available-bytes")))?;
|
||||
}
|
||||
|
||||
@@ -861,36 +921,18 @@ impl WebDavAdapter {
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||
|
||||
// Content length
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&file.size.to_string())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
|
||||
Self::write_contentlength(xml_writer, file.size)?;
|
||||
|
||||
// Creation date
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?;
|
||||
|
||||
// Convert u64 timestamp to DateTime
|
||||
let created_at = chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?;
|
||||
Self::write_creationdate(xml_writer, file.created_at)?;
|
||||
|
||||
// Last modified
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
|
||||
// Convert u64 timestamp to DateTime
|
||||
let modified_at = chrono::DateTime::<Utc>::from_timestamp(file.modified_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
Self::write_lastmodified(xml_writer, file.modified_at)?;
|
||||
|
||||
// ETag — routes through `FileDto::etag` (= `File::etag()`) so
|
||||
// PROPFIND, GET, HEAD, PUT-response, and MOVE all emit
|
||||
// byte-identical values for the same file.
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", file.etag))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
Self::write_etag_quoted(xml_writer, &file.etag)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -936,7 +978,7 @@ impl WebDavAdapter {
|
||||
fn write_folder_requested_props<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
folder: &FolderDto,
|
||||
props: &[&QualifiedName],
|
||||
props: &[QualifiedName],
|
||||
quota: Option<(i64, Option<i64>)>,
|
||||
) -> Result<()> {
|
||||
for prop in props {
|
||||
@@ -953,37 +995,13 @@ impl WebDavAdapter {
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
|
||||
}
|
||||
"creationdate" => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?;
|
||||
|
||||
// Convert u64 timestamp to DateTime
|
||||
let created_at =
|
||||
chrono::DateTime::<Utc>::from_timestamp(folder.created_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?;
|
||||
Self::write_creationdate(xml_writer, folder.created_at)?;
|
||||
}
|
||||
"getlastmodified" => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
|
||||
// Convert u64 timestamp to DateTime
|
||||
let modified_at =
|
||||
chrono::DateTime::<Utc>::from_timestamp(folder.modified_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
Self::write_lastmodified(xml_writer, folder.modified_at)?;
|
||||
}
|
||||
"getetag" => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"\"{}\"",
|
||||
folder.etag
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
Self::write_etag_quoted(xml_writer, &folder.etag)?;
|
||||
}
|
||||
"getcontentlength" => {
|
||||
xml_writer
|
||||
@@ -1000,21 +1018,25 @@ impl WebDavAdapter {
|
||||
}
|
||||
"quota-used-bytes" => {
|
||||
if let Some((used, _)) = quota {
|
||||
let mut buf = [0u8; 21];
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:quota-used-bytes")))?;
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&used.to_string())))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(
|
||||
crate::common::fmt::i64_str(&mut buf, used),
|
||||
)))?;
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("D:quota-used-bytes")))?;
|
||||
}
|
||||
}
|
||||
"quota-available-bytes" => {
|
||||
if let Some((_, Some(available))) = quota {
|
||||
let mut buf = [0u8; 21];
|
||||
xml_writer.write_event(Event::Start(BytesStart::new(
|
||||
"D:quota-available-bytes",
|
||||
)))?;
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&available.to_string())))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(
|
||||
crate::common::fmt::i64_str(&mut buf, available),
|
||||
)))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new(
|
||||
"D:quota-available-bytes",
|
||||
)))?;
|
||||
@@ -1035,7 +1057,7 @@ impl WebDavAdapter {
|
||||
fn write_file_requested_props<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
file: &FileDto,
|
||||
props: &[&QualifiedName],
|
||||
props: &[QualifiedName],
|
||||
) -> Result<()> {
|
||||
for prop in props {
|
||||
if prop.namespace == "DAV:" {
|
||||
@@ -1055,44 +1077,16 @@ impl WebDavAdapter {
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||
}
|
||||
"getcontentlength" => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&file.size.to_string())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
|
||||
Self::write_contentlength(xml_writer, file.size)?;
|
||||
}
|
||||
"creationdate" => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?;
|
||||
|
||||
// Convert u64 timestamp to DateTime
|
||||
let created_at =
|
||||
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?;
|
||||
Self::write_creationdate(xml_writer, file.created_at)?;
|
||||
}
|
||||
"getlastmodified" => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
|
||||
// Convert u64 timestamp to DateTime
|
||||
let modified_at =
|
||||
chrono::DateTime::<Utc>::from_timestamp(file.modified_at as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
Self::write_lastmodified(xml_writer, file.modified_at)?;
|
||||
}
|
||||
"getetag" => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"\"{}\"",
|
||||
file.etag
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
Self::write_etag_quoted(xml_writer, &file.etag)?;
|
||||
}
|
||||
_ => {
|
||||
// Unknown prop — skipped here; caller writes 404 propstat.
|
||||
@@ -1586,3 +1580,36 @@ impl WebDavAdapter {
|
||||
Self::write_file_response_with_dead_props(writer, file, request, href, dead_props)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin public wrappers over the private per-row PROPFIND writers so
|
||||
/// `examples/bench_propfind_xml.rs` can measure them. Gated behind the
|
||||
/// `bench` feature — adds nothing to prod builds.
|
||||
#[cfg(feature = "bench")]
|
||||
pub mod bench {
|
||||
use super::*;
|
||||
|
||||
pub fn write_file_propfind_row<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
file: &FileDto,
|
||||
request: &PropFindRequest,
|
||||
href: &str,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
) -> Result<()> {
|
||||
WebDavAdapter::write_file_response_with_dead_props(
|
||||
xml_writer, file, request, href, dead_props,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn write_folder_propfind_row<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
folder: &FolderDto,
|
||||
request: &PropFindRequest,
|
||||
href: &str,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
quota: Option<(i64, Option<i64>)>,
|
||||
) -> Result<()> {
|
||||
WebDavAdapter::write_folder_response_with_dead_props(
|
||||
xml_writer, folder, request, href, dead_props, quota,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,175 @@
|
||||
//! then fall back to the file extension when the MIME is generic
|
||||
//! (`application/octet-stream` or empty).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
// ─── Arc<str> interning for closed-set display values ────────────────
|
||||
//
|
||||
// `FileDto` / `FolderDto` store their display fields as `Arc<str>` so DTO
|
||||
// clones are O(1). But `Arc::<str>::from(&str)` always allocates + copies,
|
||||
// so building the DTO paid 3-4 heap allocations per row even though the
|
||||
// value space is a small closed set. Interning turns each conversion into
|
||||
// a HashMap lookup + refcount bump.
|
||||
|
||||
/// Every `&'static str` that [`icon_class_for`], [`icon_special_class_for`]
|
||||
/// and [`category_for`] can return, plus the folder-DTO constants.
|
||||
///
|
||||
/// Keep this table in sync when adding a value to those functions — a
|
||||
/// missing entry is not a bug (callers fall back to `Arc::from`, same
|
||||
/// bytes, one extra allocation), just a lost optimization.
|
||||
static DISPLAY_INTERN: LazyLock<HashMap<&'static str, Arc<str>>> = LazyLock::new(|| {
|
||||
const CLOSED_SET: &[&str] = &[
|
||||
// icon_class_for
|
||||
"fas fa-file-pdf",
|
||||
"fas fa-file-word",
|
||||
"fas fa-file-excel",
|
||||
"fas fa-file-powerpoint",
|
||||
"fas fa-file-archive",
|
||||
"fas fa-file-code",
|
||||
"fas fa-hdd",
|
||||
"fas fa-file-image",
|
||||
"fas fa-file-video",
|
||||
"fas fa-file-audio",
|
||||
"fas fa-file-alt",
|
||||
"fas fa-terminal",
|
||||
"fas fa-file",
|
||||
// icon_special_class_for
|
||||
"pdf-icon",
|
||||
"doc-icon",
|
||||
"spreadsheet-icon",
|
||||
"presentation-icon",
|
||||
"archive-icon",
|
||||
"code-icon json-icon",
|
||||
"code-icon js-icon",
|
||||
"code-icon ts-icon",
|
||||
"code-icon html-icon",
|
||||
"code-icon sql-icon",
|
||||
"code-icon config-icon",
|
||||
"code-icon php-icon",
|
||||
"script-icon",
|
||||
"installer-icon",
|
||||
"image-icon",
|
||||
"video-icon",
|
||||
"audio-icon",
|
||||
"code-icon py-icon",
|
||||
"code-icon rust-icon",
|
||||
"code-icon",
|
||||
"code-icon go-icon",
|
||||
"code-icon ruby-icon",
|
||||
"code-icon md-icon",
|
||||
"code-icon css-icon",
|
||||
"code-icon java-icon",
|
||||
"code-icon c-icon",
|
||||
"code-icon cs-icon",
|
||||
"code-icon swift-icon",
|
||||
"",
|
||||
// category_for
|
||||
"PDF",
|
||||
"Document",
|
||||
"Spreadsheet",
|
||||
"Presentation",
|
||||
"Archive",
|
||||
"Code",
|
||||
"Installer",
|
||||
"Image",
|
||||
"Video",
|
||||
"Audio",
|
||||
"Markdown",
|
||||
"Text",
|
||||
// FolderDto constants
|
||||
"fas fa-folder",
|
||||
"folder-icon",
|
||||
"Folder",
|
||||
];
|
||||
CLOSED_SET.iter().map(|s| (*s, Arc::from(*s))).collect()
|
||||
});
|
||||
|
||||
/// Returns a shared `Arc<str>` for a display value from the closed sets
|
||||
/// above (icon class, icon special class, category). Lookup + refcount
|
||||
/// bump instead of alloc + copy; unknown values (future additions not
|
||||
/// yet in the table) fall back to `Arc::from` with identical bytes.
|
||||
pub fn intern_display(s: &'static str) -> Arc<str> {
|
||||
DISPLAY_INTERN
|
||||
.get(s)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Arc::from(s))
|
||||
}
|
||||
|
||||
/// The MIME types that dominate real storage rows. Exotic types fall back
|
||||
/// to a per-row `Arc::from` — correctness is unaffected, only the alloc is.
|
||||
static MIME_INTERN: LazyLock<HashMap<&'static str, Arc<str>>> = LazyLock::new(|| {
|
||||
const COMMON_MIMES: &[&str] = &[
|
||||
"",
|
||||
"directory",
|
||||
"application/octet-stream",
|
||||
// Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"image/avif",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/x-icon",
|
||||
// Video
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/x-msvideo",
|
||||
// Audio
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
"audio/ogg",
|
||||
"audio/flac",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/aac",
|
||||
// Documents
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
// Text / code
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
"text/html",
|
||||
"text/css",
|
||||
"text/markdown",
|
||||
"text/xml",
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"application/xml",
|
||||
"application/x-yaml",
|
||||
// Archives
|
||||
"application/zip",
|
||||
"application/gzip",
|
||||
"application/x-tar",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-rar-compressed",
|
||||
];
|
||||
COMMON_MIMES.iter().map(|s| (*s, Arc::from(*s))).collect()
|
||||
});
|
||||
|
||||
/// Returns a shared `Arc<str>` for the given MIME type. Common types hit
|
||||
/// the intern table (refcount bump); exotic ones allocate as before.
|
||||
pub fn intern_mime(mime: &str) -> Arc<str> {
|
||||
MIME_INTERN
|
||||
.get(mime)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Arc::from(mime))
|
||||
}
|
||||
|
||||
// ─── Private: extract lowercase extension from a filename ────────────
|
||||
fn ext_of(name: &str) -> Option<&str> {
|
||||
let name = name.rsplit('/').next().unwrap_or(name); // strip path
|
||||
@@ -388,11 +557,21 @@ pub fn format_file_size(bytes: u64) -> String {
|
||||
|
||||
let value = bytes as f64 / K.powi(i as i32);
|
||||
|
||||
// Two decimal places, then strip trailing zeros (matches JS parseFloat behaviour)
|
||||
let formatted = format!("{:.2}", value);
|
||||
let formatted = formatted.trim_end_matches('0').trim_end_matches('.');
|
||||
|
||||
format!("{} {}", formatted, SIZES[i])
|
||||
// Single buffer: write the 2-decimal value, strip trailing zeros in
|
||||
// place (matches JS parseFloat behaviour), then append the unit.
|
||||
// 16 chars covers the worst case ("16777216 TB" for u64::MAX,
|
||||
// "1023.99 Bytes" for the longest unit), so no realloc occurs.
|
||||
let mut out = String::with_capacity(16);
|
||||
let _ = write!(out, "{:.2}", value);
|
||||
while out.ends_with('0') {
|
||||
out.pop();
|
||||
}
|
||||
if out.ends_with('.') {
|
||||
out.pop();
|
||||
}
|
||||
out.push(' ');
|
||||
out.push_str(SIZES[i]);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -506,6 +685,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Every value the closed-set display functions can return must hit
|
||||
/// the intern table (same bytes, shared allocation) — a miss is only
|
||||
/// a lost optimization, but this test keeps the table in sync.
|
||||
#[test]
|
||||
fn test_intern_display_covers_closed_sets_and_shares_storage() {
|
||||
for s in [
|
||||
"fas fa-file-pdf",
|
||||
"fas fa-file",
|
||||
"fas fa-terminal",
|
||||
"fas fa-folder",
|
||||
"code-icon rust-icon",
|
||||
"folder-icon",
|
||||
"",
|
||||
"PDF",
|
||||
"Folder",
|
||||
"Document",
|
||||
"Markdown",
|
||||
] {
|
||||
let a = intern_display(s);
|
||||
let b = intern_display(s);
|
||||
assert_eq!(&*a, s, "interned bytes must be identical");
|
||||
assert!(
|
||||
Arc::ptr_eq(&a, &b),
|
||||
"closed-set value {s:?} must come from the intern table"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intern_mime_common_hits_table_exotic_falls_back() {
|
||||
let a = intern_mime("image/jpeg");
|
||||
let b = intern_mime("image/jpeg");
|
||||
assert_eq!(&*a, "image/jpeg");
|
||||
assert!(Arc::ptr_eq(&a, &b), "common MIME must be interned");
|
||||
|
||||
let exotic = intern_mime("chemical/x-pdb");
|
||||
assert_eq!(&*exotic, "chemical/x-pdb");
|
||||
let exotic2 = intern_mime("chemical/x-pdb");
|
||||
assert!(
|
||||
!Arc::ptr_eq(&exotic, &exotic2),
|
||||
"exotic MIME falls back to a fresh Arc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_of() {
|
||||
assert_eq!(ext_of("file.txt"), Some("txt"));
|
||||
|
||||
@@ -6,7 +6,8 @@ use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for, intern_display,
|
||||
intern_mime,
|
||||
};
|
||||
|
||||
/// DTO for file responses
|
||||
@@ -101,11 +102,15 @@ impl From<File> for FileDto {
|
||||
// for id, name, path, folder_id (previously 4× .to_string()).
|
||||
let parts = file.into_parts();
|
||||
|
||||
let icon_class = Arc::from(icon_class_for(&parts.name, &parts.mime_type));
|
||||
let icon_special_class = Arc::from(icon_special_class_for(&parts.name, &parts.mime_type));
|
||||
let category = Arc::from(category_for(&parts.name, &parts.mime_type));
|
||||
// Display fields come from closed static tables and MIME values
|
||||
// repeat massively across rows — intern instead of allocating a
|
||||
// fresh Arc<str> per row (`Arc::from(&str)` always allocs+copies).
|
||||
let icon_class = intern_display(icon_class_for(&parts.name, &parts.mime_type));
|
||||
let icon_special_class =
|
||||
intern_display(icon_special_class_for(&parts.name, &parts.mime_type));
|
||||
let category = intern_display(category_for(&parts.name, &parts.mime_type));
|
||||
let size_formatted = format_file_size(parts.size);
|
||||
let mime_type = Arc::from(parts.mime_type.as_str());
|
||||
let mime_type = intern_mime(&parts.mime_type);
|
||||
|
||||
Self {
|
||||
id: parts.id,
|
||||
@@ -169,13 +174,13 @@ impl FileDto {
|
||||
name: "stub-file".to_string(),
|
||||
path: "/stub/path".to_string(),
|
||||
size: 0,
|
||||
mime_type: Arc::from("application/octet-stream"),
|
||||
mime_type: intern_mime("application/octet-stream"),
|
||||
folder_id: None,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
icon_class: Arc::from("fas fa-file"),
|
||||
icon_special_class: Arc::from(""),
|
||||
category: Arc::from("Document"),
|
||||
icon_class: intern_display("fas fa-file"),
|
||||
icon_special_class: intern_display(""),
|
||||
category: intern_display("Document"),
|
||||
size_formatted: "0 Bytes".to_string(),
|
||||
content_hash: String::new(),
|
||||
etag: String::new(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||
use crate::application::dtos::display_helpers::intern_display;
|
||||
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
@@ -99,24 +100,33 @@ pub struct FolderDto {
|
||||
|
||||
impl From<Folder> for FolderDto {
|
||||
fn from(folder: Folder) -> Self {
|
||||
let is_root = folder.parent_id().is_none();
|
||||
let etag = folder.etag().to_string();
|
||||
// Consume the entity by moving all fields — zero heap allocations
|
||||
// for id, name, path, parent_id (previously 3-4× .to_string()).
|
||||
let parts = folder.into_parts();
|
||||
|
||||
let is_root = parts.parent_id.is_none();
|
||||
// Single-allocation ETag straight from the owned parts. The old
|
||||
// shape (`folder.etag().to_string()`) built the String and then
|
||||
// cloned it — a pure double-alloc.
|
||||
let etag = Folder::compute_etag(&parts.id, parts.tree_modified_at);
|
||||
|
||||
Self {
|
||||
id: folder.id().to_string(),
|
||||
name: folder.name().to_string(),
|
||||
path: folder.path_string().to_string(),
|
||||
parent_id: folder.parent_id().map(String::from),
|
||||
drive_id: folder.drive_id(),
|
||||
created_at: folder.created_at(),
|
||||
modified_at: folder.modified_at(),
|
||||
id: parts.id,
|
||||
name: parts.name,
|
||||
path: parts.path_string,
|
||||
parent_id: parts.parent_id,
|
||||
drive_id: parts.drive_id,
|
||||
created_at: parts.created_at,
|
||||
modified_at: parts.modified_at,
|
||||
is_root,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
// Constant display fields: refcount bump on interned statics
|
||||
// instead of 3 fresh Arc allocations per row.
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
category: intern_display("Folder"),
|
||||
etag,
|
||||
created_by: folder.created_by(),
|
||||
updated_by: folder.updated_by(),
|
||||
created_by: parts.created_by,
|
||||
updated_by: parts.updated_by,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,9 +173,9 @@ impl FolderDto {
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
is_root: true,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
category: intern_display("Folder"),
|
||||
etag: String::new(),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
|
||||
@@ -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<str>` so enrichment reuses `FileDto`'s interned
|
||||
/// value (an atomic increment) instead of allocating per result row.
|
||||
#[schema(value_type = String)]
|
||||
pub mime_type: Arc<str>,
|
||||
/// Parent folder ID
|
||||
pub folder_id: Option<String>,
|
||||
/// 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<str>,
|
||||
/// 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<str>,
|
||||
/// Content category: "document", "image", "video", "audio", "archive", "code", "other"
|
||||
pub category: String,
|
||||
#[schema(value_type = String)]
|
||||
pub category: Arc<str>,
|
||||
/// 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<str>,
|
||||
/// Extra CSS class for icon styling
|
||||
pub icon_special_class: String,
|
||||
#[schema(value_type = String)]
|
||||
pub icon_special_class: Arc<str>,
|
||||
/// Relevance score
|
||||
pub relevance_score: u32,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,28 @@ use crate::domain::services::authorization::{
|
||||
ResourceKind, Role, Subject,
|
||||
};
|
||||
|
||||
/// Discriminates the two denial shapes surfaced by
|
||||
/// [`AuthorizationEngine::require_visible`] in the `authz.denied` audit line.
|
||||
/// Log-aggregation consumers key off the string form via `as_str`; keep the
|
||||
/// values stable — a new denial shape means a new variant, never a renamed
|
||||
/// existing one.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum AuthzDenialVisibility {
|
||||
/// Caller has `Read` on the resource — 403 Forbidden.
|
||||
Visible,
|
||||
/// Caller has no `Read` — 404 anti-enum.
|
||||
Hidden,
|
||||
}
|
||||
|
||||
impl AuthzDenialVisibility {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Visible => "visible",
|
||||
Self::Hidden => "hidden",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
/// Returns true if `subject` has `permission` on `resource`, considering
|
||||
/// owner short-circuit AND cascading from folder ancestors.
|
||||
@@ -53,9 +75,28 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
Ok(allowed)
|
||||
}
|
||||
|
||||
/// Convenience wrapper around `check`: returns `Ok(())` when allowed and
|
||||
/// `DomainError::not_found` when denied (anti-enumeration — same error as
|
||||
/// "resource doesn't exist" so attackers can't probe IDs by error shape).
|
||||
/// Graduated-denial wrapper around `check`. Semantics:
|
||||
///
|
||||
/// - `permission` granted → `Ok(())`
|
||||
/// - `permission` denied, `Read` also denied → `DomainError::not_found`
|
||||
/// (404, anti-enumeration — same shape as "doesn't exist" so a probing
|
||||
/// caller can't distinguish "wrong id" from "no access")
|
||||
/// - `permission` denied, `Read` granted → `DomainError::access_denied`
|
||||
/// (403 — the caller can already see the resource, so hiding existence
|
||||
/// leaks nothing new; a clear 403 beats a confusing 404 for UX and for
|
||||
/// API-first clients like rclone)
|
||||
///
|
||||
/// Special case: when `permission == Read`, the visibility gate collapses
|
||||
/// onto itself — a `Read` denial IS a "hidden" outcome by definition, so
|
||||
/// the method short-circuits to the strict anti-enum 404 without a second
|
||||
/// DB round-trip. That's why there's only one method: strict Read-denial
|
||||
/// and graduated write-denial fall out of the same signature.
|
||||
///
|
||||
/// Do NOT use this in search / enumeration paths where existence itself is
|
||||
/// the attack vector — those must filter at the SQL/index layer, never
|
||||
/// touch this method with per-row ids. Cross-tenant probes on ids the
|
||||
/// caller has no prior read handle for degrade to the 404 shape naturally
|
||||
/// (Read denied → `Hidden`).
|
||||
async fn require(
|
||||
&self,
|
||||
subject: Subject,
|
||||
@@ -80,39 +121,68 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
permission,
|
||||
resource
|
||||
);
|
||||
Ok(())
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Visibility probe. Short-circuit: when the target permission IS
|
||||
// `Read` and the check above returned false, we already know Read is
|
||||
// denied — visibility is `Hidden` by definition, no second DB hop.
|
||||
// Otherwise probe Read; a DB-hop failure here degrades to `Hidden` so
|
||||
// the caller sees the strict anti-enum shape (safe default).
|
||||
let visibility = if permission == Permission::Read {
|
||||
AuthzDenialVisibility::Hidden
|
||||
} else if self
|
||||
.check(subject, Permission::Read, resource)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
AuthzDenialVisibility::Visible
|
||||
} else {
|
||||
let (kind, id) = match resource {
|
||||
Resource::Folder(id) => ("Folder", id),
|
||||
Resource::File(id) => ("File", id),
|
||||
Resource::Drive(id) => ("Drive", id),
|
||||
Resource::Calendar(id) => ("Calendar", id),
|
||||
Resource::AddressBook(id) => ("AddressBook", id),
|
||||
Resource::Playlist(id) => ("Playlist", id),
|
||||
};
|
||||
// Audit-worthy: denials are the interesting signal. Routed
|
||||
// through the `audit` tracing target so log aggregators can
|
||||
// surface them separately from operational debug traffic.
|
||||
// Span context (request_id, client_ip, user_id) is attached
|
||||
// automatically by the request-scope span set in
|
||||
// `interfaces/middleware/trace_span.rs`, so this log line
|
||||
// doesn't need to duplicate those fields — they appear in
|
||||
// the structured output of every log written inside the
|
||||
// request span.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "authz.denied",
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
permission = permission.as_str(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
"👮🏻♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}'",
|
||||
subject,
|
||||
permission,
|
||||
resource
|
||||
);
|
||||
Err(DomainError::not_found(kind, id.to_string()))
|
||||
AuthzDenialVisibility::Hidden
|
||||
};
|
||||
|
||||
let (kind, id) = match resource {
|
||||
Resource::Folder(id) => ("Folder", id),
|
||||
Resource::File(id) => ("File", id),
|
||||
Resource::Drive(id) => ("Drive", id),
|
||||
Resource::Calendar(id) => ("Calendar", id),
|
||||
Resource::AddressBook(id) => ("AddressBook", id),
|
||||
Resource::Playlist(id) => ("Playlist", id),
|
||||
};
|
||||
|
||||
// Audit-worthy: denials are the interesting signal. Routed through
|
||||
// the `audit` tracing target so log aggregators can surface them
|
||||
// separately from operational debug traffic. Span context
|
||||
// (request_id, client_ip, user_id) comes from the request-scope
|
||||
// span set in `interfaces/middleware/trace_span.rs`, so this line
|
||||
// doesn't need to duplicate those fields.
|
||||
//
|
||||
// The `visibility` field discriminates the two denial shapes for
|
||||
// operators grepping exists-but-denied vs fully-hidden. `visible`
|
||||
// denials are the ones surfaced to the caller as 403 (and safe to
|
||||
// detail in the UI); `hidden` denials are the 404 anti-enum path.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "authz.denied",
|
||||
visibility = visibility.as_str(),
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
permission = permission.as_str(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
"👮🏻♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}' (visibility={})",
|
||||
subject,
|
||||
permission,
|
||||
resource,
|
||||
visibility.as_str()
|
||||
);
|
||||
|
||||
match visibility {
|
||||
AuthzDenialVisibility::Visible => Err(DomainError::access_denied(
|
||||
kind,
|
||||
format!("Missing '{}' permission on {} {}", permission, kind, id),
|
||||
)),
|
||||
AuthzDenialVisibility::Hidden => Err(DomainError::not_found(kind, id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
) -> Result<CalendarDto, DomainError>;
|
||||
async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_calendar(&self, calendar_id: &str) -> Result<CalendarDto, DomainError>;
|
||||
|
||||
/// Batch sibling of [`Self::get_calendar`]: hydrate a page of
|
||||
/// grant-derived calendar ids in ONE storage round-trip. Missing
|
||||
/// rows (deleted/trashed race) drop out silently; ordering is not
|
||||
/// guaranteed.
|
||||
async fn get_calendars_by_ids(&self, ids: &[Uuid]) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_calendars_by_owner(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
@@ -110,6 +116,12 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
/// Cursor stream over the calendar's events in bundle order (see
|
||||
/// the repository doc) — feeds the streaming CalDAV emitters.
|
||||
fn stream_events_uid_order(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> futures::stream::BoxStream<'static, Result<CalendarEventDto, DomainError>>;
|
||||
async fn list_events_by_calendar_paginated(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
@@ -212,6 +224,16 @@ pub trait CalendarUseCase: Send + Sync + 'static {
|
||||
offset: Option<i64>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
/// Streaming support: cursor over the calendar's events in bundle
|
||||
/// order, behind the same Read authz gate as [`Self::list_events`].
|
||||
async fn stream_events_uid_order(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<
|
||||
futures::stream::BoxStream<'static, Result<CalendarEventDto, DomainError>>,
|
||||
DomainError,
|
||||
>;
|
||||
async fn get_events_in_range(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
|
||||
@@ -37,6 +37,12 @@ pub trait ContactStoragePort: Send + Sync + 'static {
|
||||
) -> Result<AddressBook, DomainError>;
|
||||
async fn delete_address_book(&self, id: &Uuid) -> Result<(), DomainError>;
|
||||
async fn get_address_book_by_id(&self, id: &Uuid) -> Result<Option<AddressBook>, DomainError>;
|
||||
|
||||
/// Batch sibling of [`Self::get_address_book_by_id`]: hydrate a page
|
||||
/// of grant-derived ids in ONE storage round-trip. Missing rows drop
|
||||
/// out silently; ordering is not guaranteed.
|
||||
async fn get_address_books_by_ids(&self, ids: &[Uuid])
|
||||
-> Result<Vec<AddressBook>, DomainError>;
|
||||
async fn get_public_address_books(&self) -> Result<Vec<AddressBook>, DomainError>;
|
||||
|
||||
// ── Contacts ─────────────────────────────────────────────────
|
||||
@@ -60,6 +66,12 @@ pub trait ContactStoragePort: Send + Sync + 'static {
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
) -> Result<Vec<Contact>, DomainError>;
|
||||
/// Cursor stream over the book's contacts in listing order — feeds
|
||||
/// the streaming CardDAV emitters.
|
||||
fn stream_contacts_by_book(
|
||||
&self,
|
||||
address_book_id: Uuid,
|
||||
) -> futures::stream::BoxStream<'static, Result<Contact, DomainError>>;
|
||||
async fn get_contacts_by_address_book_paginated(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
@@ -168,6 +180,15 @@ pub trait ContactUseCase: Send + Sync + 'static {
|
||||
/// List contacts in an address book. `limit`/`offset` bound the
|
||||
/// result for paginated callers (REST API); `None` returns the full
|
||||
/// book, which the CardDAV listing/sync paths rely on.
|
||||
/// Streaming support: cursor over the book's contacts (same Read
|
||||
/// gate as [`Self::list_contacts`], checked once before the cursor
|
||||
/// opens).
|
||||
async fn stream_contacts_by_book(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<futures::stream::BoxStream<'static, Result<ContactDto, DomainError>>, DomainError>;
|
||||
|
||||
async fn list_contacts(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
|
||||
@@ -60,6 +60,25 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
|
||||
caller_id: Uuid,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// `_with_perms` variant of `upload_file_streaming` — enforces
|
||||
/// `Create` on the target folder before registering the row.
|
||||
///
|
||||
/// AuthZ audit #17 (2026-07-12): the chunked-upload `complete`
|
||||
/// path called plain `upload_file_streaming` at finalize; a grant
|
||||
/// revoked between session open and finalize stayed effective
|
||||
/// until the caller landed the final chunk (up to 24h JWT TTL,
|
||||
/// forever with app-passwords). Handlers now call this variant
|
||||
/// so the engine re-checks at finalize regardless of how long
|
||||
/// the session was open.
|
||||
async fn upload_file_streaming_with_perms(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
blob: StoredBlob,
|
||||
caller_id: Uuid,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// Replace the content of the file at `path` with an already-ingested
|
||||
/// blob, or create the file when it doesn't exist (WebDAV/WOPI PUT).
|
||||
///
|
||||
|
||||
@@ -77,6 +77,31 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
||||
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
|
||||
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
|
||||
|
||||
/// Keyset-paged sub-folder listing in name order, scoped to a caller —
|
||||
/// `name > after_name LIMIT limit`, `has_next = len() == limit`.
|
||||
///
|
||||
/// Used by streaming WebDAV/NC PROPFIND: O(page) per page off the
|
||||
/// `idx_folders_unique_name` index instead of the quadratic
|
||||
/// `COUNT(*) OVER() … LIMIT/OFFSET` walk (benches/FOLDER-KEYSET.md).
|
||||
///
|
||||
/// The default implementation falls back to `list_folders_with_perms`
|
||||
/// + in-memory slice so stubs and mocks compile without changes.
|
||||
async fn list_folders_batch_with_perms(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
caller_id: Uuid,
|
||||
after_name: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
let mut all = self.list_folders_with_perms(parent_id, caller_id).await?;
|
||||
all.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| after_name.is_none_or(|a| f.name.as_str() > a))
|
||||
.take(limit)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Renames a folder (ownership verified against caller_id)
|
||||
async fn rename_folder_with_perms(
|
||||
&self,
|
||||
|
||||
@@ -27,11 +27,15 @@ pub trait SearchUseCase: Send + Sync + 'static {
|
||||
) -> Result<Arc<SearchResultsDto>, DomainError>;
|
||||
|
||||
/// Returns quick suggestions for autocomplete (lightweight, fast).
|
||||
/// `caller_id` scopes results to drives the caller can Read — without
|
||||
/// it the endpoint leaks names + paths across every tenant on the
|
||||
/// instance (AuthZ audit finding #1, 2026-07-12).
|
||||
async fn suggest(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
caller_id: Uuid,
|
||||
) -> Result<SearchSuggestionsDto, DomainError>;
|
||||
|
||||
/// Clears the search results cache.
|
||||
|
||||
@@ -104,6 +104,11 @@ pub trait MusicStoragePort: Send + Sync {
|
||||
|
||||
async fn get_playlist(&self, playlist_id: &str) -> Result<Option<PlaylistDto>, DomainError>;
|
||||
|
||||
/// Batch sibling of [`Self::get_playlist`]: hydrate a page of
|
||||
/// grant-derived ids in ONE storage round-trip. Missing rows drop
|
||||
/// out silently; ordering is not guaranteed.
|
||||
async fn get_playlists_by_ids(&self, ids: &[Uuid]) -> Result<Vec<PlaylistDto>, DomainError>;
|
||||
|
||||
async fn list_playlists_by_owner(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
|
||||
@@ -205,13 +205,21 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
/// Results are ordered by relevance (exact > starts-with > contains) so the
|
||||
/// caller can use them directly for autocomplete suggestions.
|
||||
///
|
||||
/// The default implementation falls back to `list_files` + in-memory filter
|
||||
/// so that stubs and mocks compile without changes.
|
||||
/// `caller_id` scopes results to files whose owning drive the caller can
|
||||
/// Read (direct or group-mediated `role_grants`). Without it the endpoint
|
||||
/// leaks names + paths across every tenant on the instance — closed as
|
||||
/// AuthZ audit finding #1 (2026-07-12).
|
||||
///
|
||||
/// The default implementation falls back to `list_files` + in-memory
|
||||
/// filter so that stubs and mocks compile without changes. Stub-mode
|
||||
/// callers already operate against a single tenant's data, so ignoring
|
||||
/// `caller_id` here is safe; the PG impl enforces the real scope.
|
||||
async fn suggest_files_by_name(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<Vec<File>, DomainError> {
|
||||
let all = self.list_files(folder_id).await?;
|
||||
let q = query.to_lowercase();
|
||||
|
||||
@@ -304,12 +304,45 @@ impl AppPasswordService {
|
||||
let cache_key: [u8; 32] =
|
||||
blake3::hash(format!("{}:{}", username, password).as_bytes()).into();
|
||||
|
||||
// ── 2. Cache hit → return immediately ────────────────────────
|
||||
if let Some(cached) = self.auth_cache.get(&cache_key).await {
|
||||
return Ok((cached.user_id, cached.username, cached.email, cached.role));
|
||||
}
|
||||
// ── 2. Single-flight cache lookup ─────────────────────────────
|
||||
// Concurrent misses on the same credential coalesce into ONE
|
||||
// full verification: DAV sync clients hold 4-8 parallel
|
||||
// connections, so an expiring cache entry used to fan out into
|
||||
// K simultaneous Argon2id runs (~100-300 ms CPU + 64 MiB RAM
|
||||
// apiece) every TTL — a recurring p99 spike on every DAV
|
||||
// surface (8 -> 1 verifications, benches/AUTH-HERD.md).
|
||||
// `try_get_with` caches only `Ok` results, so failed
|
||||
// verifications are still never cached, preserving the full
|
||||
// Argon2id cost as a brute-force deterrent.
|
||||
let result = self
|
||||
.auth_cache
|
||||
.try_get_with(
|
||||
cache_key,
|
||||
self.verify_basic_auth_uncached(username, password),
|
||||
)
|
||||
.await
|
||||
.map_err(
|
||||
|e: std::sync::Arc<DomainError>| match std::sync::Arc::try_unwrap(e) {
|
||||
Ok(err) => err,
|
||||
// Another coalesced waiter still holds the Arc — rebuild
|
||||
// an equivalent error (the source chain isn't clonable).
|
||||
Err(shared) => {
|
||||
DomainError::new(shared.kind, shared.entity_type, shared.message.clone())
|
||||
}
|
||||
},
|
||||
)?;
|
||||
Ok((result.user_id, result.username, result.email, result.role))
|
||||
}
|
||||
|
||||
// ── 3. Cache miss → full verification ────────────────────────
|
||||
/// The uncached Basic Auth slow path: user lookup, prefix-scoped
|
||||
/// candidate fetch, Argon2id verification. Runs at most once per
|
||||
/// credential per TTL — `verify_basic_auth` coalesces concurrent
|
||||
/// callers onto a single in-flight instance of this future.
|
||||
async fn verify_basic_auth_uncached(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<CachedBasicAuthResult, DomainError> {
|
||||
let user = self
|
||||
.user_repo
|
||||
.get_user_by_username(username)
|
||||
@@ -363,15 +396,14 @@ impl AppPasswordService {
|
||||
{
|
||||
let _ = self.repo.touch_last_used(ap.id).await;
|
||||
|
||||
let result = CachedBasicAuthResult {
|
||||
// Caching happens in `verify_basic_auth`: `try_get_with`
|
||||
// stores this value under the blake3 key on return.
|
||||
return Ok(CachedBasicAuthResult {
|
||||
user_id: user.id(),
|
||||
username: user.username().unwrap_or("").to_string(),
|
||||
email: user.email().to_string(),
|
||||
role: user.role().to_string(),
|
||||
};
|
||||
|
||||
self.auth_cache.insert(cache_key, result.clone()).await;
|
||||
return Ok((result.user_id, result.username, result.email, result.role));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,8 +147,12 @@ pub struct AuthApplicationService {
|
||||
/// request. The short TTL keeps the "role changes apply without token
|
||||
/// rotation" property within seconds while removing one DB round-trip
|
||||
/// per request; the known mutation paths (`change_user_role`,
|
||||
/// `set_user_active`) also invalidate eagerly.
|
||||
user_flags_cache: Cache<Uuid, UserFlags>,
|
||||
/// `set_user_active`) also invalidate eagerly. `moka::future` so
|
||||
/// concurrent misses for one user coalesce into a single DB lookup
|
||||
/// (`try_get_with` single-flight) — every authenticated request
|
||||
/// calls this, so each 30 s TTL expiry used to fan out one SELECT
|
||||
/// per in-flight request of that user.
|
||||
user_flags_cache: moka::future::Cache<Uuid, UserFlags>,
|
||||
/// Self-service auth-method allowlist (mirrors
|
||||
/// `AuthConfig::allowed_auth_methods`). Empty = both methods
|
||||
/// allowed. Consulted by login / register / magic-link handlers via
|
||||
@@ -198,7 +202,7 @@ impl AuthApplicationService {
|
||||
.time_to_live(Duration::from_secs(120))
|
||||
.build(),
|
||||
magic_link_repo: None,
|
||||
user_flags_cache: Cache::builder()
|
||||
user_flags_cache: moka::future::Cache::builder()
|
||||
.max_capacity(10_000)
|
||||
.time_to_live(USER_FLAGS_CACHE_TTL)
|
||||
.build(),
|
||||
@@ -1363,7 +1367,7 @@ impl AuthApplicationService {
|
||||
// Invalidate the flags cache so subsequent per-request guards
|
||||
// observe the new `is_external=false` without waiting for the
|
||||
// 30-second TTL. Same pattern as `change_user_role`.
|
||||
self.user_flags_cache.invalidate(&caller_id);
|
||||
self.user_flags_cache.invalidate(&caller_id).await;
|
||||
|
||||
// Dispatch — home-drive provisioning happens here. Log-and-
|
||||
// continue: a provisioning failure leaves the row updated and
|
||||
@@ -1508,12 +1512,20 @@ impl AuthApplicationService {
|
||||
/// Staleness is bounded by [`USER_FLAGS_CACHE_TTL`]; role and active
|
||||
/// changes made through this service invalidate the entry eagerly.
|
||||
pub async fn get_user_flags(&self, user_id: Uuid) -> Result<UserFlags, DomainError> {
|
||||
if let Some(flags) = self.user_flags_cache.get(&user_id) {
|
||||
return Ok(flags);
|
||||
}
|
||||
let flags = self.user_storage.get_user_flags(user_id).await?;
|
||||
self.user_flags_cache.insert(user_id, flags);
|
||||
Ok(flags)
|
||||
// Single-flight: concurrent misses for the same user coalesce
|
||||
// into ONE storage lookup; errors are never cached (same herd
|
||||
// shape ROUND3 fixed for basic-auth, minus the Argon2 cost).
|
||||
self.user_flags_cache
|
||||
.try_get_with(user_id, async {
|
||||
Ok::<_, DomainError>(self.user_storage.get_user_flags(user_id).await?)
|
||||
})
|
||||
.await
|
||||
// try_get_with hands back `Arc<DomainError>` shared by all
|
||||
// waiters; DomainError isn't Clone, so rebuild a fresh one
|
||||
// preserving the kind / entity / message.
|
||||
.map_err(|shared: std::sync::Arc<DomainError>| {
|
||||
DomainError::new(shared.kind, shared.entity_type, shared.message.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a profile update on behalf of the calling user (PR 24).
|
||||
@@ -1912,6 +1924,59 @@ impl AuthApplicationService {
|
||||
))
|
||||
}
|
||||
|
||||
/// Username-keyed sibling of [`Self::get_user_profile`], routing every
|
||||
/// lookup through the same visibility check as the user-profile REST
|
||||
/// endpoint. Preserves the anti-enum shape end-to-end: whether the
|
||||
/// username doesn't exist OR the caller has no visibility path, the
|
||||
/// response is `NotFound`.
|
||||
///
|
||||
/// AuthZ audit #11 (2026-07-12): NextCloud OCS user-provisioning
|
||||
/// (`nextcloud/ocs_handler.rs::user_provisioning_response`) used to
|
||||
/// resolve `userid` via bare `get_user_by_username`, gated only by a
|
||||
/// bespoke `caller.role == "admin"` shortcut. Admins bypassed the
|
||||
/// `expose_system_users` gate; non-admins got a `403 Insufficient
|
||||
/// privileges` for any cross-user probe (leaking existence via the
|
||||
/// differential vs a genuine 404); zero audit lines. This wrapper
|
||||
/// closes all three.
|
||||
///
|
||||
/// The username→id resolution happens here so the target isn't
|
||||
/// leaked through the audit line as a plaintext username on failure:
|
||||
/// the `target_username_not_found` event carries the string
|
||||
/// (unavoidable — we resolved it, we log it), but every other
|
||||
/// downstream event keys off `target_id` after resolution, matching
|
||||
/// the id-based endpoint.
|
||||
pub async fn get_user_profile_by_username_with_perms(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
username: &str,
|
||||
expose_system_users: bool,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
let target = match self.user_storage.get_user_by_username(username).await {
|
||||
Ok(u) => u,
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user_profile.rejected",
|
||||
reason = "target_username_not_found",
|
||||
caller_id = %caller_id,
|
||||
target_username = %username,
|
||||
"👮🏻♂️ user-profile rejected: username '{}' does not exist (caller {})",
|
||||
username,
|
||||
caller_id,
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"User",
|
||||
"User not found",
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
self.get_user_profile(caller_id, target.id(), expose_system_users, pool)
|
||||
.await
|
||||
}
|
||||
|
||||
// New method to get user by username - needed for admin user handling
|
||||
pub async fn get_user_by_username(&self, username: &str) -> Result<UserDto, DomainError> {
|
||||
let user = self.user_storage.get_user_by_username(username).await?;
|
||||
@@ -2226,7 +2291,7 @@ impl AuthApplicationService {
|
||||
self.user_storage
|
||||
.set_user_active_status(user_id, active)
|
||||
.await?;
|
||||
self.user_flags_cache.invalidate(&user_id);
|
||||
self.user_flags_cache.invalidate(&user_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2240,7 +2305,7 @@ impl AuthApplicationService {
|
||||
));
|
||||
}
|
||||
self.user_storage.change_role(user_id, role).await?;
|
||||
self.user_flags_cache.invalidate(&user_id);
|
||||
self.user_flags_cache.invalidate(&user_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -189,17 +189,13 @@ impl CalendarUseCase for CalendarService {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Hydrate DTOs. `get_calendar` misses on trashed / deleted
|
||||
// calendars — those are dropped from the listing rather than
|
||||
// erroring, so a lifecycle-race doesn't turn a PROPFIND into
|
||||
// a 5xx.
|
||||
let mut out = Vec::with_capacity(calendar_ids.len());
|
||||
for id in calendar_ids {
|
||||
if let Ok(dto) = self.calendar_storage.get_calendar(&id.to_string()).await {
|
||||
out.push(dto);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
// Hydrate DTOs in ONE `= ANY` round-trip (was one point SELECT
|
||||
// per accessible calendar — K serial round-trips on every
|
||||
// CalDAV discovery poll). Missing rows (deleted/trashed race)
|
||||
// drop out of the result set instead of erroring, so a
|
||||
// lifecycle-race still doesn't turn a PROPFIND into a 5xx.
|
||||
let ids: Vec<Uuid> = calendar_ids.into_iter().collect();
|
||||
self.calendar_storage.get_calendars_by_ids(&ids).await
|
||||
}
|
||||
|
||||
async fn list_public_calendars(
|
||||
@@ -360,6 +356,28 @@ impl CalendarUseCase for CalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_events_uid_order(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<
|
||||
futures::stream::BoxStream<'static, Result<CalendarEventDto, DomainError>>,
|
||||
DomainError,
|
||||
> {
|
||||
// Same Read gate as `list_events`, checked ONCE before the
|
||||
// cursor opens — the stream itself carries no further authz
|
||||
// (single request, same caller, same resource).
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
Ok(self.calendar_storage.stream_events_uid_order(calendar_id))
|
||||
}
|
||||
|
||||
async fn get_events_in_range(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
|
||||
@@ -494,12 +494,14 @@ impl AddressBookUseCase for ContactService {
|
||||
|
||||
let mut address_book_map = std::collections::HashMap::new();
|
||||
|
||||
for id in book_ids {
|
||||
// Missing rows (deleted / trashed race) drop out silently
|
||||
// — matches the calendar-listing carve-out.
|
||||
if let Ok(Some(book)) = self.contact_storage.get_address_book_by_id(&id).await {
|
||||
address_book_map.insert(*book.id(), book);
|
||||
}
|
||||
// Hydrate in ONE `= ANY` round-trip (was one point SELECT per
|
||||
// accessible book — K serial round-trips on every CardDAV
|
||||
// discovery poll). Missing rows (deleted / trashed race) drop
|
||||
// out of the result set — matches the calendar-listing
|
||||
// carve-out.
|
||||
let ids: Vec<Uuid> = book_ids.into_iter().collect();
|
||||
for book in self.contact_storage.get_address_books_by_ids(&ids).await? {
|
||||
address_book_map.insert(*book.id(), book);
|
||||
}
|
||||
|
||||
// Public address books surface for every authenticated caller
|
||||
@@ -533,10 +535,16 @@ impl ContactUseCase for ContactService {
|
||||
let address_book_id = Uuid::parse_str(&dto.address_book_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
// AuthZ audit #19 (2026-07-12): previously required
|
||||
// `Permission::Update`, which is NOT in the Contributor bundle
|
||||
// (Read + Create) — Contributor grantees on a shared address
|
||||
// book couldn't add contacts via REST or CardDAV PUT despite
|
||||
// holding the intended Create permission. `Delete` uses Delete
|
||||
// (audit #13, above); creation must use Create. Same fix
|
||||
// applied to `create_contact_from_vcard` + `create_group`.
|
||||
let caller_id = Uuid::parse_str(&dto.user_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
|
||||
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update)
|
||||
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Create)
|
||||
.await?;
|
||||
|
||||
// Convert DTOs to domain entities
|
||||
@@ -612,10 +620,13 @@ impl ContactUseCase for ContactService {
|
||||
let address_book_id = Uuid::parse_str(&dto.address_book_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
// AuthZ audit #19 — see the sibling `create_contact` above.
|
||||
// This is the CardDAV `PUT contact.vcf` entry point; the fix
|
||||
// unblocks Contributor grantees creating contacts through the
|
||||
// CardDAV protocol as well as the REST surface.
|
||||
let caller_id = Uuid::parse_str(&dto.user_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
|
||||
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update)
|
||||
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Create)
|
||||
.await?;
|
||||
|
||||
// Parse vCard data
|
||||
@@ -754,8 +765,14 @@ impl ContactUseCase for ContactService {
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
self.require_address_book_perm(contact.address_book_id(), &user_id, Permission::Update)
|
||||
// AuthZ audit #13 (2026-07-12): previously required
|
||||
// `Permission::Update`, which the Editor role bundle satisfies
|
||||
// (Read + Comment + Create + Update). Every Editor grantee on a
|
||||
// shared address book could delete individual contacts — a
|
||||
// silent privilege escalation because the intent for CardDAV
|
||||
// deletion is Delete, not Update. Sibling
|
||||
// `CalendarService::delete_event` was the ground-truth pattern.
|
||||
self.require_address_book_perm(contact.address_book_id(), &user_id, Permission::Delete)
|
||||
.await?;
|
||||
|
||||
// Delete the contact
|
||||
@@ -823,6 +840,25 @@ impl ContactUseCase for ContactService {
|
||||
Ok(contacts.into_iter().map(ContactDto::from).collect())
|
||||
}
|
||||
|
||||
async fn stream_contacts_by_book(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<futures::stream::BoxStream<'static, Result<ContactDto, DomainError>>, DomainError>
|
||||
{
|
||||
use futures::StreamExt;
|
||||
let id = Uuid::parse_str(address_book_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||
// Same Read gate as `list_contacts`, once, before the cursor.
|
||||
self.require_address_book_read_or_public(&id, &user_id)
|
||||
.await?;
|
||||
Ok(Box::pin(
|
||||
self.contact_storage
|
||||
.stream_contacts_by_book(id)
|
||||
.map(|r| r.map(ContactDto::from)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_contacts(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
@@ -881,10 +917,10 @@ impl ContactUseCase for ContactService {
|
||||
let address_book_id = Uuid::parse_str(&dto.address_book_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
// AuthZ audit #19 — see the sibling `create_contact` above.
|
||||
let caller_id = Uuid::parse_str(&dto.user_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
|
||||
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update)
|
||||
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Create)
|
||||
.await?;
|
||||
|
||||
let group = ContactGroup::new(address_book_id, dto.name);
|
||||
@@ -938,8 +974,11 @@ impl ContactUseCase for ContactService {
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
self.require_address_book_perm(group.address_book_id(), &user_id, Permission::Update)
|
||||
// AuthZ audit #13 (2026-07-12): see the sibling `delete_contact`
|
||||
// above — required `Update` (in the Editor bundle) instead of
|
||||
// `Delete`, letting any Editor on a shared address book delete
|
||||
// groups they shouldn't.
|
||||
self.require_address_book_perm(group.address_book_id(), &user_id, Permission::Delete)
|
||||
.await?;
|
||||
|
||||
// Delete the group
|
||||
|
||||
@@ -263,6 +263,12 @@ impl DriveManagementService {
|
||||
self.authz
|
||||
.invalidate_drive_role_cache_for_drive(drive_id)
|
||||
.await;
|
||||
// Same freshness contract for the repo's readable-drives cache:
|
||||
// the subject's drive list changed with this grant.
|
||||
match subject {
|
||||
Subject::User(uid) => self.drive_repo.invalidate_readable_for_user(uid).await,
|
||||
_ => self.drive_repo.invalidate_readable_all(),
|
||||
}
|
||||
|
||||
// D6 §11: canonical `drive.member_added` audit event covers
|
||||
// every successful membership write (add + role-refresh, since
|
||||
@@ -335,6 +341,12 @@ impl DriveManagementService {
|
||||
self.authz
|
||||
.invalidate_drive_role_cache_for_drive(drive_id)
|
||||
.await;
|
||||
// And the repo's readable-drives cache: the drive must vanish
|
||||
// from the removed subject's list immediately.
|
||||
match subject {
|
||||
Subject::User(uid) => self.drive_repo.invalidate_readable_for_user(uid).await,
|
||||
_ => self.drive_repo.invalidate_readable_all(),
|
||||
}
|
||||
|
||||
// D6 §11: canonical `drive.member_removed` audit event covers
|
||||
// every successful removal (owner-driven or admin bypass).
|
||||
@@ -468,6 +480,11 @@ impl DriveManagementService {
|
||||
/// supplied is overwritten. Returns the post-merge typed view.
|
||||
/// Audit emits `drive.policy_changed` with the post-merge bag for
|
||||
/// steady-state observability.
|
||||
///
|
||||
/// Ed's call, 2026-07-17: intentional deviation from the AGENTS.md
|
||||
/// "AuthZ in service layer" rule for this specific endpoint —
|
||||
/// the handler-layer admin check stays, this method stays trusting.
|
||||
/// See memory `feedback_drive_policies_admin_at_handler`.
|
||||
pub async fn update_policies(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
|
||||
@@ -145,6 +145,12 @@ impl FavoritesUseCase for FavoritesService {
|
||||
// valid (partial success would leak the same oracle we
|
||||
// closed on the single-item path). See
|
||||
// `docs/plan/authz_audit/rest_storage.md`.
|
||||
//
|
||||
// Deliberately serial: a `try_join_all` fan-out measured WORSE
|
||||
// on both the cold (drive_of point-SELECTs) and warm (all-moka)
|
||||
// paths — future orchestration + pool-acquire contention cost
|
||||
// more than the local round trips they overlap. Rejected by
|
||||
// `bench_favorites_authz`; numbers in benches/ROUND6.md.
|
||||
for (item_id, item_type) in items {
|
||||
let resource = Resource::parse(item_type, item_id)?;
|
||||
self.authorization
|
||||
|
||||
@@ -287,22 +287,6 @@ impl FileRetrievalService {
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
/// Range read that first consults the RAM content cache (see
|
||||
/// [`Self::get_file_range_preloaded`]).
|
||||
pub async fn get_file_range_preloaded_with_perms(
|
||||
&self,
|
||||
dto: &FileDto,
|
||||
caller_id: Uuid,
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> Result<RangeContent, DomainError> {
|
||||
self.require_file(&dto.id, Permission::Read, caller_id)
|
||||
.await?;
|
||||
// Same throttled Recent recording as the streaming variant.
|
||||
self.notify_file_accessed(caller_id, &dto.id);
|
||||
self.get_file_range_preloaded(dto, start, end).await
|
||||
}
|
||||
|
||||
/// Range read for HTTP Range Requests, cache-aware.
|
||||
///
|
||||
/// Media players and PDF viewers fetch these files *exclusively* through
|
||||
|
||||
@@ -457,6 +457,44 @@ impl FileUploadUseCase for FileUploadService {
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
/// AuthZ audit #17 — `Create` on target folder is re-verified here
|
||||
/// so mid-session grant revocations take effect at finalize. When
|
||||
/// `folder_id` is `None` the write lands at drive-root; the drive
|
||||
/// resolution for that case isn't plumbed through the chunked-
|
||||
/// upload session (`UploadSession.folder_id` alone), so we fall
|
||||
/// back to the pre-audit behaviour there. That drive-root path is
|
||||
/// tracked separately as part of the D0 folder-id-walking work;
|
||||
/// closing it here would require session-scoped drive_id.
|
||||
async fn upload_file_streaming_with_perms(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
blob: StoredBlob,
|
||||
caller_id: Uuid,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
if let Some(fid) = folder_id.as_deref() {
|
||||
let Some(authz) = &self.authorization else {
|
||||
return Err(DomainError::internal_error(
|
||||
"FileUpload",
|
||||
"upload_file_streaming_with_perms called without authorization engine wired",
|
||||
));
|
||||
};
|
||||
let folder_uuid = Uuid::parse_str(fid)
|
||||
.map_err(|_| DomainError::not_found("Folder", fid.to_string()))?;
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Create,
|
||||
Resource::Folder(folder_uuid),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
self.upload_file_streaming(name, folder_id, content_type, blob, caller_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Swap the content of the file at `path` to an already-ingested blob,
|
||||
/// creating the file when it doesn't exist (WebDAV/NextCloud/WOPI PUT).
|
||||
///
|
||||
|
||||
@@ -429,6 +429,62 @@ impl FolderUseCase for FolderService {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Keyset-paged sub-folder listing (name order), caller-scoped.
|
||||
///
|
||||
/// AuthZ mirrors `list_folders_paginated_with_perms`: one
|
||||
/// `authz.require(Read)` on the parent per batch; root scope goes
|
||||
/// through the caller's drive-membership listing.
|
||||
async fn list_folders_batch_with_perms(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
caller_id: Uuid,
|
||||
after_name: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
match parent_id {
|
||||
Some(pid) => {
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Self::folder_resource(pid)?,
|
||||
)
|
||||
.await?;
|
||||
let folders = self
|
||||
.folder_storage
|
||||
.list_folders_batch(parent_id, after_name, limit)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to batch-list folders in parent {pid}: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||
}
|
||||
None => {
|
||||
// Root scope: one row per readable drive — a handful.
|
||||
let mut all = self
|
||||
.folder_storage
|
||||
.list_root_folders_for_caller(caller_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to batch-list root folders for '{caller_id}': {e}"),
|
||||
)
|
||||
})?;
|
||||
all.sort_by(|a, b| a.name().cmp(b.name()));
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| after_name.is_none_or(|a| f.name() > a))
|
||||
.take(limit)
|
||||
.map(FolderDto::from)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists folders with pagination, scoped to a specific owner.
|
||||
async fn list_folders_paginated_with_perms(
|
||||
&self,
|
||||
@@ -525,7 +581,7 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let folder = self
|
||||
let renamed = self
|
||||
.folder_storage
|
||||
.rename_folder(id, dto.name, caller_id)
|
||||
.await
|
||||
@@ -536,7 +592,26 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(FolderDto::from(folder))
|
||||
// Root folders double as the drive's display name (see the
|
||||
// `required_perm` branch above and `drive_pg_repository.rs`
|
||||
// `readable_cache` + `default_drive_cache` docs).
|
||||
// `drives.name` is sourced from `folders.name` of the root
|
||||
// folder, so a rename affects BOTH caches — every user's
|
||||
// readable-drive list AND the per-user default-drive lookup.
|
||||
// Both are 30 s TTL; without the invalidation, `GET /api/drives`
|
||||
// returns the stale name for up to that window after a root
|
||||
// rename. Surfaced by `tests/api/drives_membership.hurl`
|
||||
// Step 23. Regression from commit `12dc648c` ("perf: round 4 —
|
||||
// drive-selector cache") which added the caches without
|
||||
// wiring the root-rename invalidation.
|
||||
if folder.parent_id().is_none()
|
||||
&& let Some(drive_repo) = &self.drive_repo
|
||||
{
|
||||
drive_repo.invalidate_readable_all();
|
||||
drive_repo.invalidate_default_drive_all();
|
||||
}
|
||||
|
||||
Ok(FolderDto::from(renamed))
|
||||
}
|
||||
|
||||
/// Moves a folder to a new parent. Requires `Update` on the source and
|
||||
|
||||
@@ -196,15 +196,18 @@ impl MusicUseCase for MusicService {
|
||||
// only. Owner is a grant like any other in `role_grants`, so we
|
||||
// filter the aggregated set against the owner_id stamped on
|
||||
// each row after hydration — cheaper than a second SQL round-trip.
|
||||
let mut playlists: Vec<PlaylistDto> = Vec::with_capacity(playlist_ids.len());
|
||||
// Hydrate in ONE `= ANY` round-trip (was one point SELECT per
|
||||
// accessible playlist). Missing rows (deleted race) drop out of
|
||||
// the result set silently, as before.
|
||||
let user_str = user_id.to_string();
|
||||
for id in playlist_ids.drain() {
|
||||
if let Ok(Some(p)) = self.storage.get_playlist(&id.to_string()).await
|
||||
&& (include_shared || p.owner_id == user_str)
|
||||
{
|
||||
playlists.push(p);
|
||||
}
|
||||
}
|
||||
let ids: Vec<Uuid> = playlist_ids.drain().collect();
|
||||
let mut playlists: Vec<PlaylistDto> = self
|
||||
.storage
|
||||
.get_playlists_by_ids(&ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|p| include_shared || p.owner_id == user_str)
|
||||
.collect();
|
||||
|
||||
if include_public {
|
||||
let public = self.storage.list_public_playlists(limit, offset).await?;
|
||||
|
||||
@@ -41,55 +41,50 @@ impl NextcloudFileIdService {
|
||||
|
||||
/// Resolve — creating when absent — stable numeric file IDs for many
|
||||
/// UUIDs at once. Cache hits cost nothing; the misses are resolved with a
|
||||
/// single backing query. The returned map is keyed by the caller's
|
||||
/// original id strings; unresolvable inputs are simply absent (mirroring
|
||||
/// the `.ok()` behaviour the callers relied on).
|
||||
pub async fn get_or_create_file_ids(
|
||||
&self,
|
||||
file_ids: &[String],
|
||||
) -> Result<HashMap<String, i64>> {
|
||||
/// single backing query. The returned map is keyed by parsed UUID;
|
||||
/// unparseable/unresolvable inputs are simply absent (mirroring the
|
||||
/// `.ok()` behaviour the callers relied on).
|
||||
pub async fn get_or_create_file_ids(&self, file_ids: &[&str]) -> Result<HashMap<Uuid, i64>> {
|
||||
self.get_or_create_many("file", file_ids).await
|
||||
}
|
||||
|
||||
/// Folder counterpart of [`Self::get_or_create_file_ids`].
|
||||
pub async fn get_or_create_folder_ids(
|
||||
&self,
|
||||
folder_ids: &[String],
|
||||
) -> Result<HashMap<String, i64>> {
|
||||
folder_ids: &[&str],
|
||||
) -> Result<HashMap<Uuid, i64>> {
|
||||
self.get_or_create_many("folder", folder_ids).await
|
||||
}
|
||||
|
||||
async fn get_or_create_many(
|
||||
&self,
|
||||
object_type: &str,
|
||||
raw_ids: &[String],
|
||||
) -> Result<HashMap<String, i64>> {
|
||||
raw_ids: &[&str],
|
||||
) -> Result<HashMap<Uuid, i64>> {
|
||||
let mut result = HashMap::with_capacity(raw_ids.len());
|
||||
// Parsed-UUID → caller's original string; also dedupes the miss list.
|
||||
let mut pending: HashMap<Uuid, String> = HashMap::new();
|
||||
let mut misses: Vec<Uuid> = Vec::new();
|
||||
|
||||
for raw in raw_ids {
|
||||
let Ok(uuid) = Uuid::parse_str(raw) else {
|
||||
continue; // Unparseable ids never had a mapping — skip silently.
|
||||
};
|
||||
if let Some(id) = self.cache.get(&uuid).await {
|
||||
result.insert(raw.clone(), id);
|
||||
result.insert(uuid, id);
|
||||
} else {
|
||||
pending.entry(uuid).or_insert_with(|| raw.clone());
|
||||
misses.push(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
if !pending.is_empty() {
|
||||
let misses: Vec<Uuid> = pending.keys().copied().collect();
|
||||
if !misses.is_empty() {
|
||||
misses.sort_unstable();
|
||||
misses.dedup();
|
||||
let resolved = self
|
||||
.repo()?
|
||||
.get_or_create_many(object_type, &misses)
|
||||
.await?;
|
||||
for (uuid, id) in resolved {
|
||||
self.cache.insert(uuid, id).await;
|
||||
if let Some(original) = pending.get(&uuid) {
|
||||
result.insert(original.clone(), id);
|
||||
}
|
||||
result.insert(uuid, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,10 +179,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_get_or_create_file_ids_skips_unparseable() {
|
||||
let svc = NextcloudFileIdService::new_stub();
|
||||
let map = svc
|
||||
.get_or_create_file_ids(&["not-a-uuid".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
let map = svc.get_or_create_file_ids(&["not-a-uuid"]).await.unwrap();
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
@@ -67,9 +65,80 @@ pub struct SearchService {
|
||||
/// Lock-free concurrent cache with automatic TTL and LRU eviction (moka).
|
||||
/// Values are `Arc<SearchResultsDto>` so cache insert/hit is a single
|
||||
/// atomic ref-count increment (~1 ns) instead of cloning thousands of Strings.
|
||||
///
|
||||
/// **Byte-bounded**, not entry-bounded: entries are weighed by
|
||||
/// [`search_results_entry_weight`] and `max_capacity` is a byte budget.
|
||||
/// Keys span user × query × offset × limit, and each page holds up to 500
|
||||
/// enriched rows (~500–900 B of owned Strings each) — an entry-count bound
|
||||
/// let hundreds of MB of result pages accumulate invisibly.
|
||||
search_cache: moka::future::Cache<u64, Arc<SearchResultsDto>>,
|
||||
}
|
||||
|
||||
// ─── Search-results cache (byte-bounded) ─────────────────────────────────
|
||||
|
||||
/// Approximate heap bytes retained by one cached search page.
|
||||
///
|
||||
/// With a `weigher` installed, moka's `max_capacity` is the sum of entry
|
||||
/// *weights*, so this converts the cache bound from "number of entries" to
|
||||
/// real bytes: the length of every owned `String` in each file/folder row,
|
||||
/// plus a fixed per-row and per-entry overhead for struct fields, the 24-B
|
||||
/// `String` headers, `Vec` slots and allocator slop. Same pattern as the
|
||||
/// file-content cache and the dedup manifest cache.
|
||||
///
|
||||
/// `pub` so `examples/bench_search_cache_mem.rs` can recompute retained
|
||||
/// bytes with the exact production formula.
|
||||
pub fn search_results_entry_weight(_key: &u64, value: &Arc<SearchResultsDto>) -> u32 {
|
||||
/// Fixed per-row overhead: struct scalars + one 24-B header per `String`
|
||||
/// field (12 on a file row, 4 on a folder row) + `Vec` slot + allocator
|
||||
/// slop. Deliberately a round upper-ish estimate — under-weighing is the
|
||||
/// failure mode that re-opens the memory hole.
|
||||
const ROW_OVERHEAD: usize = 200;
|
||||
/// Fixed per-entry overhead: `Arc` + `SearchResultsDto` scalars + `Vec`
|
||||
/// headers + moka's own bookkeeping per entry.
|
||||
const ENTRY_OVERHEAD: usize = 256;
|
||||
|
||||
fn opt_len(s: &Option<String>) -> usize {
|
||||
s.as_deref().map_or(0, str::len)
|
||||
}
|
||||
|
||||
let mut bytes = ENTRY_OVERHEAD + value.sort_by.len();
|
||||
for f in &value.files {
|
||||
bytes += ROW_OVERHEAD
|
||||
+ f.id.len()
|
||||
+ f.name.len()
|
||||
+ f.path.len()
|
||||
+ f.mime_type.len()
|
||||
+ opt_len(&f.folder_id)
|
||||
+ f.size_formatted.len()
|
||||
+ f.icon_class.len()
|
||||
+ f.icon_special_class.len()
|
||||
+ f.category.len()
|
||||
+ f.blob_hash.len()
|
||||
+ opt_len(&f.snippet)
|
||||
+ opt_len(&f.match_source);
|
||||
}
|
||||
for d in &value.folders {
|
||||
bytes += ROW_OVERHEAD + d.id.len() + d.name.len() + d.path.len() + opt_len(&d.parent_id);
|
||||
}
|
||||
bytes.min(u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
/// Build the search-results cache exactly as production wires it: a byte
|
||||
/// budget enforced through [`search_results_entry_weight`], plus TTL.
|
||||
///
|
||||
/// Shared with `examples/bench_search_cache_mem.rs` so the benchmark
|
||||
/// measures the identical cache configuration that serves requests.
|
||||
pub fn build_search_results_cache(
|
||||
cache_ttl_secs: u64,
|
||||
max_bytes: u64,
|
||||
) -> moka::future::Cache<u64, Arc<SearchResultsDto>> {
|
||||
moka::future::Cache::builder()
|
||||
.max_capacity(max_bytes)
|
||||
.weigher(search_results_entry_weight)
|
||||
.time_to_live(Duration::from_secs(cache_ttl_secs))
|
||||
.build()
|
||||
}
|
||||
|
||||
// ─── Utility functions (pure, no self — computed on the server) ─────────
|
||||
|
||||
/// Compute relevance score (0–100) for a name against a query.
|
||||
@@ -138,28 +207,15 @@ 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 {
|
||||
/**
|
||||
* Creates a new instance of the search service.
|
||||
*
|
||||
* `max_cache_bytes` is the byte budget for the results cache (weigher-
|
||||
* bounded, see [`search_results_entry_weight`]) — it replaced the old
|
||||
* entry-count capacity, which was blind to how big each cached page is.
|
||||
*/
|
||||
pub fn new(
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
@@ -168,12 +224,9 @@ impl SearchService {
|
||||
authorization: Option<Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>>,
|
||||
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
cache_ttl: u64,
|
||||
max_cache_size: usize,
|
||||
max_cache_bytes: u64,
|
||||
) -> Self {
|
||||
let search_cache = moka::future::Cache::builder()
|
||||
.max_capacity(max_cache_size as u64)
|
||||
.time_to_live(Duration::from_secs(cache_ttl))
|
||||
.build();
|
||||
let search_cache = build_search_results_cache(cache_ttl, max_cache_bytes);
|
||||
|
||||
Self {
|
||||
file_repository,
|
||||
@@ -195,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 {
|
||||
@@ -204,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()),
|
||||
}
|
||||
@@ -228,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 {
|
||||
@@ -237,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,
|
||||
@@ -287,7 +348,7 @@ impl SearchService {
|
||||
// grants are honoured inline by `storage.caller_group_ids` on
|
||||
// the SQL side, so no Rust-side subject expansion here.
|
||||
let accessible_drives: Vec<Uuid> = match drive_repo.list_readable_by(user_id).await {
|
||||
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
|
||||
Ok(drives) => drives.iter().map(|d| d.drive.id).collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}");
|
||||
return Vec::new();
|
||||
@@ -409,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;
|
||||
@@ -425,20 +487,28 @@ impl SearchService {
|
||||
/// Quick suggestions search — returns up to `limit` name suggestions
|
||||
/// matching the query. Pushes filtering, relevance sort and LIMIT to SQL
|
||||
/// so only a handful of rows cross the DB→app boundary.
|
||||
pub async fn suggest(
|
||||
///
|
||||
/// `caller_id` scopes the underlying repo queries to drives the caller
|
||||
/// can Read. Without it (the pre-fix shape) any authenticated user —
|
||||
/// including external magic-link recipients — could autocomplete both
|
||||
/// names and full paths across every tenant on the instance (AuthZ
|
||||
/// audit finding #1, 2026-07-12). Named `_with_perms` per the
|
||||
/// AGENTS.md AuthZ convention.
|
||||
pub async fn suggest_with_perms(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
caller_id: Uuid,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
let start = Instant::now();
|
||||
|
||||
// Ask SQL for at most `limit` best-matching files and folders
|
||||
let (files, folders) = tokio::join!(
|
||||
self.file_repository
|
||||
.suggest_files_by_name(folder_id, query, limit),
|
||||
.suggest_files_by_name(folder_id, query, limit, caller_id),
|
||||
self.folder_repository
|
||||
.suggest_folders_by_name(folder_id, query, limit),
|
||||
.suggest_folders_by_name(folder_id, query, limit, caller_id),
|
||||
);
|
||||
let files = files?;
|
||||
let folders = folders?;
|
||||
@@ -449,30 +519,36 @@ impl SearchService {
|
||||
// Pre-compute once — avoids N heap allocations inside the loops.
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
for file in &files {
|
||||
let file_dto = FileDto::from(file.clone());
|
||||
// Consume the entities: the old loop deep-cloned every File into
|
||||
// the DTO conversion and then cloned name/id/path AGAIN into the
|
||||
// suggestion — 3 field clones + a full entity clone per row on
|
||||
// an every-keystroke path.
|
||||
for file in files {
|
||||
let file_dto = FileDto::from(file);
|
||||
let score = compute_relevance(&file_dto.name, &query_lower);
|
||||
suggestions.push(SearchSuggestionItem {
|
||||
name: file_dto.name.clone(),
|
||||
name: file_dto.name,
|
||||
item_type: "file".to_string(),
|
||||
id: file_dto.id.clone(),
|
||||
path: file_dto.path.clone(),
|
||||
icon_class: get_icon_class(&file_dto.name, &file_dto.mime_type),
|
||||
icon_special_class: get_icon_special_class(&file_dto.name, &file_dto.mime_type),
|
||||
id: file_dto.id,
|
||||
path: file_dto.path,
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
for folder in &folders {
|
||||
let folder_dto = FolderDto::from(folder.clone());
|
||||
for folder in folders {
|
||||
let folder_dto = FolderDto::from(folder);
|
||||
let score = compute_relevance(&folder_dto.name, &query_lower);
|
||||
suggestions.push(SearchSuggestionItem {
|
||||
name: folder_dto.name.clone(),
|
||||
name: folder_dto.name,
|
||||
item_type: "folder".to_string(),
|
||||
id: folder_dto.id.clone(),
|
||||
path: folder_dto.path.clone(),
|
||||
icon_class: "fas fa-folder".to_string(),
|
||||
icon_special_class: "folder-icon".to_string(),
|
||||
id: folder_dto.id,
|
||||
path: folder_dto.path,
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
relevance_score: score,
|
||||
});
|
||||
}
|
||||
@@ -489,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 {
|
||||
@@ -541,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<FileDto> = files.into_iter().map(FileDto::from).collect();
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = file_dtos
|
||||
.iter()
|
||||
.map(|f| Self::enrich_file(f, &query_lower))
|
||||
// Convert to DTOs and enrich with metadata — one fused
|
||||
// pass, no intermediate Vec<FileDto> materialization.
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = files
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_file(FileDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
// Get folders for this folder (non-recursive, filtered in SQL)
|
||||
@@ -559,13 +651,10 @@ impl SearchUseCase for SearchService {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let filtered_folders: Vec<FolderDto> =
|
||||
folders.into_iter().map(FolderDto::from).collect();
|
||||
|
||||
// For folders, apply sorting and pagination in memory (usually fewer folders)
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = filtered_folders
|
||||
.iter()
|
||||
.map(|f| Self::enrich_folder(f, &query_lower))
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = 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)
|
||||
@@ -646,17 +735,15 @@ impl SearchUseCase for SearchService {
|
||||
.await?;
|
||||
|
||||
// ── Convert to DTOs and enrich with server-computed metadata ──
|
||||
let file_dtos: Vec<FileDto> = found_files.into_iter().map(FileDto::from).collect();
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = file_dtos
|
||||
.iter()
|
||||
.map(|f| Self::enrich_file(f, &query_lower))
|
||||
// Fused single pass: no intermediate DTO Vec materialization.
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = found_files
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_file(FileDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
let folder_dtos: Vec<FolderDto> =
|
||||
found_folders.into_iter().map(FolderDto::from).collect();
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = folder_dtos
|
||||
.iter()
|
||||
.map(|f| Self::enrich_folder(f, &query_lower))
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = 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) ──
|
||||
@@ -724,14 +811,20 @@ impl SearchUseCase for SearchService {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns quick suggestions for autocomplete.
|
||||
/// Returns quick suggestions for autocomplete. Delegates to the
|
||||
/// inherent `suggest_with_perms` — the trait method is preserved as
|
||||
/// the polymorphic entry point (e.g. for `StubSearchUseCase` in
|
||||
/// tests); production callers can equivalently call the inherent
|
||||
/// method directly.
|
||||
async fn suggest(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
caller_id: Uuid,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
self.suggest(query, folder_id, limit).await
|
||||
self.suggest_with_perms(query, folder_id, limit, caller_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Clears the search results cache.
|
||||
@@ -763,6 +856,7 @@ impl SearchService {
|
||||
_query: &str,
|
||||
_folder_id: Option<&str>,
|
||||
_limit: usize,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
Ok(SearchSuggestionsDto {
|
||||
suggestions: Vec::new(),
|
||||
@@ -800,21 +894,103 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_weight_counts_every_owned_string_plus_overheads() {
|
||||
// Empty page: entry overhead + sort_by ("relevance" = 9 bytes).
|
||||
let empty = Arc::new(SearchResultsDto::empty());
|
||||
let base = search_results_entry_weight(&0, &empty) as usize;
|
||||
assert_eq!(base, 256 + 9);
|
||||
|
||||
// One file row: base + row overhead + its owned string bytes
|
||||
// (id 7 + name 7 + path 8 + mime 10; the rest are empty/None).
|
||||
let one_file = Arc::new(SearchResultsDto::new(
|
||||
vec![dto("abc.txt", 50, 10, 1)],
|
||||
Vec::new(),
|
||||
100,
|
||||
0,
|
||||
Some(1),
|
||||
0,
|
||||
"relevance".to_string(),
|
||||
));
|
||||
let w = search_results_entry_weight(&0, &one_file) as usize;
|
||||
assert_eq!(w, base + 200 + 7 + 7 + 8 + 10);
|
||||
|
||||
// Folder rows weigh too (id 2 + name 4 + path 5 + parent 6 = 17).
|
||||
let one_folder = Arc::new(SearchResultsDto::new(
|
||||
Vec::new(),
|
||||
vec![SearchFolderResultDto {
|
||||
id: "f1".to_string(),
|
||||
name: "docs".to_string(),
|
||||
path: "/docs".to_string(),
|
||||
parent_id: Some("parent".to_string()),
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
is_root: false,
|
||||
relevance_score: 50,
|
||||
}],
|
||||
100,
|
||||
0,
|
||||
Some(1),
|
||||
0,
|
||||
"relevance".to_string(),
|
||||
));
|
||||
let w = search_results_entry_weight(&0, &one_folder) as usize;
|
||||
assert_eq!(w, base + 200 + 2 + 4 + 5 + 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_evicts_down_to_the_byte_budget() {
|
||||
// Budget fits ~2 of these entries; inserting 20 must never let the
|
||||
// weighted size settle above the budget.
|
||||
let entry = |i: usize| {
|
||||
Arc::new(SearchResultsDto::new(
|
||||
(0..50)
|
||||
.map(|r| dto(&format!("file_{i}_{r}_{}", "x".repeat(100)), 50, 1, 1))
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
50,
|
||||
0,
|
||||
Some(50),
|
||||
0,
|
||||
"relevance".to_string(),
|
||||
))
|
||||
};
|
||||
let per_entry = search_results_entry_weight(&0, &entry(0)) as u64;
|
||||
let budget = per_entry * 2 + per_entry / 2;
|
||||
|
||||
let cache = build_search_results_cache(300, budget);
|
||||
for i in 0..20u64 {
|
||||
cache.insert(i, entry(i as usize)).await;
|
||||
}
|
||||
cache.run_pending_tasks().await;
|
||||
|
||||
let retained: u64 = cache
|
||||
.iter()
|
||||
.map(|(k, v)| search_results_entry_weight(&k, &v) as u64)
|
||||
.sum();
|
||||
assert!(
|
||||
retained <= budget,
|
||||
"retained {retained} B exceeds budget {budget} B"
|
||||
);
|
||||
assert!(cache.entry_count() <= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_files_resort_by_relevance_and_by_column() {
|
||||
let mut files = vec![
|
||||
|
||||
@@ -19,6 +19,13 @@ use uuid::Uuid;
|
||||
pub struct StorageUsageService {
|
||||
pool: Arc<PgPool>,
|
||||
user_repository: Arc<UserPgRepository>,
|
||||
/// Optional so DI can wire it lazily and older test constructors
|
||||
/// keep compiling. When `Some`, every write path that mutates
|
||||
/// `drives.used_bytes` or `users.storage_used_bytes` invalidates
|
||||
/// the drive lookup caches so `GET /api/drives` reflects the new
|
||||
/// usage on the next call (see the invalidation calls in the
|
||||
/// delta / sweep methods below).
|
||||
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
}
|
||||
|
||||
impl StorageUsageService {
|
||||
@@ -27,6 +34,44 @@ impl StorageUsageService {
|
||||
Self {
|
||||
pool,
|
||||
user_repository,
|
||||
drive_repo: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires the drive repository used for cache-invalidation-on-write.
|
||||
/// Production DI calls this in `common::di`; tests without a real
|
||||
/// drive repo leave it `None` and the invalidation calls no-op.
|
||||
pub fn with_drive_repo(
|
||||
mut self,
|
||||
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||
) -> Self {
|
||||
self.drive_repo = Some(drive_repo);
|
||||
self
|
||||
}
|
||||
|
||||
/// Drop the per-caller readable-drive listing cache and the
|
||||
/// per-user default-drive cache so `GET /api/drives` and the
|
||||
/// WebDAV / NextCloud / WOPI drive-lookup paths re-read fresh
|
||||
/// values.
|
||||
///
|
||||
/// **Called only from the reconciliation sweep**, not from the
|
||||
/// hot-path `add_drive_storage_usage_delta*` methods. The design
|
||||
/// (Ed's call, 2026-07-17): keep the cache useful under active
|
||||
/// upload load — per-mutation invalidation would nuke the cache
|
||||
/// on every file upload, defeating the point. `used_bytes` on
|
||||
/// `GET /api/drives` therefore lags by up to the cache TTL (30 s),
|
||||
/// which matches the sibling caches' accepted UX phantom for
|
||||
/// drive-name staleness. Tests / operators that need immediate
|
||||
/// freshness call `POST /api/admin/internal/trigger-sweep`, which
|
||||
/// runs `update_all_drives_storage_usage` → this method.
|
||||
///
|
||||
/// Security posture unaffected: `check_drive_quota` reads
|
||||
/// directly from SQL, bypassing the cache entirely, so quota
|
||||
/// enforcement is honest regardless of listing staleness.
|
||||
fn invalidate_drive_lookup_caches(&self) {
|
||||
if let Some(repo) = &self.drive_repo {
|
||||
repo.invalidate_readable_all();
|
||||
repo.invalidate_default_drive_all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +254,9 @@ impl StorageUsageService {
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("StorageUsage", format!("drive delta: {e}")))?;
|
||||
// Deliberate no-invalidate here — see the class doc on
|
||||
// `invalidate_drive_lookup_caches`. Delta writes lag the
|
||||
// cache by up to the TTL; the sweep is the escape hatch.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -285,6 +333,7 @@ impl StorageUsageService {
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("drive delta by folder: {e}"))
|
||||
})?;
|
||||
// See `add_drive_storage_usage_delta` — deliberate no-invalidate.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -595,6 +644,20 @@ impl StorageUsagePort for StorageUsageService {
|
||||
"Drive storage-usage reconciliation corrected {} drive(s)",
|
||||
result.rows_affected()
|
||||
);
|
||||
// Unconditional invalidation — do NOT gate on
|
||||
// `rows_affected() > 0`. When a fire-and-forget delta has
|
||||
// already made SQL correct BEFORE the sweep runs, the sweep
|
||||
// touches zero rows but the cache may still hold the
|
||||
// pre-delta value from an earlier `GET /api/drives`. Gating
|
||||
// means the cache stays stale in exactly the case
|
||||
// `trigger-sweep` is called to fix. The invalidation cost is
|
||||
// small (moka `invalidate_all` on both caches); the
|
||||
// correctness guarantee matters. Regression avoidance:
|
||||
// drive_quota.hurl Step 6 exercises this race — 2nd upload's
|
||||
// delta lands during the 200 ms delay, sweep sees SQL is
|
||||
// already right → zero rows → without unconditional
|
||||
// invalidation, cache stays at the previous step's value.
|
||||
self.invalidate_drive_lookup_caches();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -613,6 +676,7 @@ impl Clone for StorageUsageService {
|
||||
Self {
|
||||
pool: Arc::clone(&self.pool),
|
||||
user_repository: Arc::clone(&self.user_repository),
|
||||
drive_repo: self.drive_repo.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ pub struct SubjectGroupService {
|
||||
/// 30 s TTL. Without this, fresh group-mediated drive grants
|
||||
/// don't appear in `/api/drives` for up to 30 s after `add_member`.
|
||||
engine: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||
/// Same freshness contract for the drive repository's per-user
|
||||
/// readable-drives cache: a membership change on a group that holds
|
||||
/// drive grants changes every affected user's visible drive list,
|
||||
/// so the cached lists drop alongside `user_groups_cache`.
|
||||
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
}
|
||||
|
||||
impl SubjectGroupService {
|
||||
@@ -52,12 +57,14 @@ impl SubjectGroupService {
|
||||
pool: Arc<PgPool>,
|
||||
user_storage: Arc<UserPgRepository>,
|
||||
engine: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
pool,
|
||||
user_storage,
|
||||
engine,
|
||||
drive_repo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,6 +433,7 @@ impl SubjectGroupService {
|
||||
// call for up to 30 s.
|
||||
for uid in self.invalidation_targets(member).await? {
|
||||
self.engine.invalidate_user_groups_cache(uid).await;
|
||||
self.drive_repo.invalidate_readable_for_user(uid).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -525,6 +533,7 @@ impl SubjectGroupService {
|
||||
// for up to 30 s, surfacing grants they no longer have.
|
||||
for uid in self.invalidation_targets(member).await? {
|
||||
self.engine.invalidate_user_groups_cache(uid).await;
|
||||
self.drive_repo.invalidate_readable_for_user(uid).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -634,7 +643,9 @@ mod integration_tests {
|
||||
// future test starts exercising real authz lookups.
|
||||
let engine =
|
||||
Arc::new(crate::infrastructure::services::pg_acl_engine::PgAclEngine::new_stub());
|
||||
SubjectGroupService::new(repo, pool, user_storage, engine)
|
||||
let drive_repo =
|
||||
Arc::new(crate::infrastructure::repositories::pg::DrivePgRepository::new(pool.clone()));
|
||||
SubjectGroupService::new(repo, pool, user_storage, engine, drive_repo)
|
||||
}
|
||||
|
||||
async fn first_admin(pool: &sqlx::PgPool) -> Uuid {
|
||||
|
||||
@@ -662,22 +662,25 @@ impl TrashUseCase for TrashService {
|
||||
async fn empty_trash_for_drive(&self, user_id: Uuid, drive_id: Uuid) -> Result<()> {
|
||||
// Per-drive trash empty — the Drive group-by on `/trash` exposes
|
||||
// this as a per-row affordance so multi-drive owners can clear
|
||||
// one drive without touching the others. Refuses with
|
||||
// `NotFound` (anti-enum) when the caller lacks Delete on the
|
||||
// named drive — same shape as the user-facing drive listing
|
||||
// would emit for an unknown id.
|
||||
let allowed = self.drives_with_delete_for(user_id).await?;
|
||||
if !allowed.contains(&drive_id) {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "trash.empty_drive_rejected",
|
||||
reason = "no_delete_on_drive",
|
||||
user_id = %user_id,
|
||||
drive_id = %drive_id,
|
||||
"👮🏻♂️ refused per-drive empty — caller lacks Delete on this drive",
|
||||
);
|
||||
return Err(DomainError::not_found("Drive", drive_id.to_string()));
|
||||
}
|
||||
// one drive without touching the others.
|
||||
//
|
||||
// Route through `authz.require(Delete, Drive)` so the denial
|
||||
// shape stays consistent with every other write verb: 403 when
|
||||
// the caller has Read on the drive (viewer/editor holding no
|
||||
// Delete), 404 when they don't (anti-enum). Before 2026-07-16
|
||||
// this method rolled its own `drives_with_delete_for` check +
|
||||
// hardcoded `NotFound` — that predated the graduated-denial
|
||||
// engine change and returned 404 unconditionally even for a
|
||||
// Viewer who could see the drive in `/api/drives`. The engine
|
||||
// now emits `authz.denied` with `visibility="visible"|"hidden"`
|
||||
// and the standard mapping renders it as 403 or 404.
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(user_id),
|
||||
Permission::Delete,
|
||||
Resource::Drive(drive_id),
|
||||
)
|
||||
.await?;
|
||||
info!("Emptying trash for drive {} (user {})", drive_id, user_id);
|
||||
self.clear_trash_in(&[drive_id], user_id).await
|
||||
}
|
||||
@@ -801,7 +804,7 @@ impl TrashService {
|
||||
// role_grants on resource_type='drive', including group-mediated
|
||||
// grants). Empty set → empty page without a SQL round-trip.
|
||||
let drive_ids: Vec<Uuid> = match self.drive_repo.list_readable_by(user_id).await {
|
||||
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
|
||||
Ok(drives) => drives.iter().map(|d| d.drive.id).collect(),
|
||||
Err(e) => {
|
||||
return Err(DomainError::internal_error(
|
||||
"Trash",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user