perf: round 11 finale — benchmark verdicts, geo min(uuid) rollback, ROUND11.md

- benches/ROUND11.md: final BEFORE/AFTER numbers for all 21 micro
  sections, the 5 query sections, the 4 log-writer arms, the SPA gates,
  and the two cross-round regression guards (bench_row_path 2.52x,
  bench_dto_map — both byte-identical)
- ROLLBACK (gate-caught): min(fm.file_id)::text geo-cluster cast —
  PostgreSQL has no min(uuid) aggregate; the bench section now reproduces
  the rejection and the per-row-cast original stays
- REJECTED (bench-measured): tracing-appender non-blocking writer —
  slower than sync on fast sinks (1.41M vs 0.99M ev/s, worse tail) and
  the slow-sink drain gate showed shutdown tail-loss risk for the audit
  channel; dep moved to dev-dependencies (harness only)
- RateLimiter final form: lock-free get + insert (8.0 → 6.0 allocs, wall
  neutral; and_upsert_with variant rejected at 2 365 ns)
- fix: TrashedItemParts insertion had stolen TrashedItem's derive line
  (caught by clippy --all-targets)
- grant_role enum values corrected in the queries bench

Validation: cargo fmt + clippy --all-features --all-targets -D warnings
clean; cargo test --workspace 524 passed / 0 failed; frontend npm run
check 0 errors + vitest 306 passed (1 pre-existing round-7 wall-clock
gate flaked only under concurrent Rust-build CPU contention; passes in
isolation)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABhTEHuGujvwoodh67Kga7
This commit is contained in:
Claude
2026-07-18 22:46:20 +00:00
parent 221c1f31b0
commit b01633791b
6 changed files with 269 additions and 108 deletions
+4 -4
View File
@@ -20,10 +20,6 @@ tower-http = { version = "0.6.11", features = ["fs", "compression-gzip", "compre
flate2 = "1.1.9" flate2 = "1.1.9"
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
# Round-11: moves log formatting+write off the async workers. `lossy(false)`
# so audit lines are never dropped (emitters block only when the 128k-line
# channel is full — backpressure, not loss). See benches/ROUND11.md.
tracing-appender = "0.2"
chrono = { version = "0.4.45", features = ["serde"] } chrono = { version = "0.4.45", features = ["serde"] }
# RFC 5545 iCalendar parser + emitter. # RFC 5545 iCalendar parser + emitter.
# #
@@ -146,6 +142,10 @@ bench = []
[dev-dependencies] [dev-dependencies]
criterion = "0.5" criterion = "0.5"
# Round-11 L1 harness only (bench_log_writer): the non-blocking writer was
# REJECTED for production — slower than sync on fast sinks and can lose
# buffered tail lines at shutdown on slow ones (benches/ROUND11.md).
tracing-appender = "0.2"
[lints.rust] [lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] } unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
+235 -82
View File
@@ -2,115 +2,268 @@
Benchmark-gated, same rule as ROUND2-10: every change ships with a Benchmark-gated, same rule as ROUND2-10: every change ships with a
BEFORE/AFTER benchmark and an equivalence/safety gate; an AFTER that doesn't BEFORE/AFTER benchmark and an equivalence/safety gate; an AFTER that doesn't
beat its BEFORE gets rolled back or redesigned. Three candidates went beat its BEFORE gets rolled back or redesigned. Four candidates went through
through exactly that loop this round (§Rejected): the moka `and_upsert_with` exactly that loop this round (§Rejected below): the moka `and_upsert_with`
rate-limiter rewrite measured SLOWER than the two-op shape it was meant to rate-limiter rewrite, the GET/HEAD `Last-Modified` stack-render port, the
replace and was redesigned as a lock-free `get`+`insert`; the GET/HEAD `min(uuid)` geo-cluster cast (PostgreSQL has no such aggregate — the gate
`Last-Modified` stack-render port measured neutral-to-worse (the chrono caught it before it could ship broken), and the `tracing-appender`
String is already the terminal allocation) and was dropped; the first non-blocking log writer (slower than sync on fast sinks, tail-loss risk on
search-page `drain(range)` model lost to `to_vec` on wall and was reshaped slow ones).
as `into_iter().skip().take()`.
Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release 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 profile; frontend on Node 22 / vitest 4 (jsdom). Reproduce any row with the
command in its section. command in its section (`benches/ROUND11.md` §Environment).
## Summary ## Summary
(numbers filled from the final runs below)
| # | change | key metric | before → after | | # | change | key metric | before → after |
|--:|---|---|---| |--:|---|---|---|
| 1 | REST download: dead `FileDto` clone → capture mime/size + move | allocs per download | 7 → 0 (dead clone removed) | | 1 | REST download: dead `FileDto` clone → capture mime/size + move | ns / allocs per download | 295.3 → 20.7 ns · 7 → 0 allocs |
| 2 | `StoragePath` → single canonical joined `String` (segments derived lazily; entity `path_string` duplicate field removed) | allocs / wall per 500-row page | TBD | | 2 | `StoragePath` → single canonical joined `String`; `File`/`Folder` drop the duplicate `path_string` field | 500-row page (depth 4) | 121.0 → 80.3 µs (**1.51x**) · 4 000 → 1 000 allocs |
| 3 | Display classifier fusion (`classify_display`, stack-lowered ext shared by the three trees) | ns / allocs per listing row | TBD | | 3 | Display classifier fusion (`classify_display`, one stack-lowered ext for the three trees) | 13-row mixed corpus | 4 390 → 2 074 ns (**2.12x**) · 21 → 0 allocs |
| 4 | `/status.php` → `OnceLock<Bytes>` | ns / allocs per poll | TBD | | 4 | `/status.php` → `OnceLock<Bytes>` | per NC client poll | 836 → 28.8 ns (**29x**) · 14 → 0 allocs |
| 5 | `/openapi.json` → `OnceLock<Bytes>` (was: rebuild 171 KiB spec per request) | ns per request | TBD | | 5 | `/openapi.json` → `OnceLock<Bytes>` (was rebuilding the 171 KiB spec per request) | per request | 2.77 ms → 18.5 ns · 12 474 → 0 allocs |
| 6 | NC upload-session PROPFIND: `write!` + capacity + stack dates | ns / allocs per 256-chunk PROPFIND | TBD | | 6 | NC upload-session PROPFIND: `write!` + pre-sized body + stack dates | 256-chunk session | 203.7 → 75.4 µs (**2.70x**) · 2 582 → 772 allocs |
| 7 | CSRF header token borrow compare | ns / allocs per state-changing request | TBD | | 7 | CSRF token: borrow-only compare (+ borrowed cookie extraction) | per state-changing request | 55.2 → 2.2 ns · 1 → 0 allocs |
| 8 | Thumbnail/preview ETag: `as_str` push (Debug-identical bytes) | ns per thumbnail request | TBD | | 8 | Thumbnail/preview ETag via `as_str` (Debug-identical bytes — cached client ETags stay valid) | per thumbnail request | 150.5 → 87.7 ns · 3 → 2 allocs |
| 9 | Recent-handler id: stack `encode_lower` | ns / allocs per call | TBD | | 9 | Recent-handler id: stack `encode_lower` | per record/remove | 52.2 → 10.7 ns · 1 → 0 allocs |
| 10 | 4xx body: borrowed serialize + `ErrorKind::as_str` + `not_found` clone kill | ns / allocs per 404 | TBD | | 10 | 4xx body: borrowed `ErrorResponse` + `ErrorKind::as_str` + `not_found`/`already_exists` clone kill | per 404 | 426 → 367 ns · 11 → 8 allocs |
| 11 | vCard emit: `write!` (+ borrowed address fields) | ns / allocs per vCard | TBD | | 11 | vCard emit: `write!` + borrowed address fields | per contact create/update | 804 → 386 ns (**2.08x**) · 21 → 5 allocs |
| 12 | Search page: `into_iter().skip().take()` move | allocs per 50-item page | TBD | | 12 | Search page: `into_iter().skip().take()` move (both branches) | 50-item page | 108.3 → 89.4 µs · −301 allocs |
| 13 | Content-hit verify: parse-once pairs | ns per 100-hit page | TBD | | 13 | Content-hit verify: parse each UUID once | 100-hit page | 7.90 → 5.20 µs (**1.52x**) |
| 14 | Group last-user check: HashSet probe | ns per 500×500 check | TBD | | 14 | Group last-user check: HashSet probe | 500×500 check | 105.3 → 17.1 µs (**6.1x**) |
| 15 | Retry op-label: lazy closure | ns / allocs per blob op | TBD | | 15 | Retry op-label: lazy closure (success path never formats) | per blob op | 71.8 → 0.7 ns · 2 → 0 allocs |
| 16 | `encrypt_bytes`: in-place detached (write side now mirrors the in-place read) | ns / allocs per 256 KiB chunk | TBD | | 16 | `encrypt_bytes`: in-place detached, single buffer (write now mirrors the in-place read) | 256 KiB chunk | 231.1 → 208.1 µs (**1.11x**) · 2 → 1 allocs |
| 17 | Encrypted `collect_stream`: chunk-sized reserve | allocs per 1 MiB read | TBD | | 17 | Encrypted `collect_stream`: chunk-sized reserve | 1 MiB blob, 4 KiB frames | 60.4 → 53.2 µs · 9 → 1 allocs |
| 18 | Recluster cosine: precomputed norms (bit-identical) | ns per 200-face pass | TBD | | 18 | Recluster cosine: norms precomputed once (bit-identical gate over all pairs) | 200 faces × 512-dim pass | 7.70 → 7.17 ms (**1.07x**) |
| 19 | `CalendarEventDto`: `into_parts` move (11 KiB `ical_data` copy gone) | ns / allocs per event | TBD | | 19 | `CalendarEventDto`: `into_parts` move (the ~11 KiB `ical_data` memcpy gone) | per CalDAV event row | 497 → 283 ns (**1.76x**) · 14 → 8 allocs |
| 20 | RateLimiter: lock-free `get` + `insert` | ns / allocs per limited request | TBD | | 20 | RateLimiter: lock-free `get` (borrows the key) + `insert` | per limited request | allocs 8.0 → 6.0 · wall neutral (1 657 vs 1 695 ns, within run variance) |
| Q1 | Deferred upload registration: 3 round-trips → 1 CTE insert | ms per uploaded file | TBD | | Q1 | Deferred upload registration: 3 round-trips → 1 CTE insert (the `persist_file` template) | per uploaded file (incl. cleanup DELETE) | 1.83 → 1.32 ms · gates: identical path/drive, missing-parent → not-found |
| Q2 | Calendar/AddressBook/Playlist authz `direct_grant_cache` | ms per DAV check | TBD | | Q2 | Calendar/AddressBook/Playlist authz `direct_grant_cache` (single-flight, invalidated on `set_role`/`clear_role`) | per DAV check | 0.197 ms → 0.2 µs on hit (**~1000x**) · revocation-flip gate OK |
| Q3 | `expand_user`: `tokio::join!` the 2 independent queries | ms per cold expansion | TBD | | Q3 | `expand_user`: `tokio::join!` the `is_external` read + groups CTE | per cold expansion | 0.426 → 0.204 ms (**2.1x**) |
| Q4 | Geo clusters: `min(file_id)::text` (cast per cluster, not per row) | ms per viewport | TBD | | Q5 | Recluster persistence: per-face UPDATE loop → one UNNEST batch | 200-face apply (incl. reset) | 80.0 → 8.7 ms (**9.2x**) · final column state identical |
| Q5 | Recluster persistence: per-face UPDATEs → one UNNEST batch | ms per 200-face apply | TBD | | S1 | SPA `ResourceList.selectedEntries`: O(N)×2 per toggle → id-index O(k·log k); hosts consume the snippet param | comparisons per 51-toggle gesture (N=2 000) | 204 000 → <2 602 · identical output/order gated |
| L1 | Log writer: `tracing-appender` non_blocking (lossy=false) | p99 emit µs under contention | TBD | | S2 | SPA Recent: star reads the new `favoriteIds` prop — mapper no longer depends on the set | rows re-mapped per star click (N=400) | 400 → 0 · identical star states gated |
| S1 | SPA `ResourceList.selectedEntries`: O(N)×2 per toggle → O(k·log k), hosts consume the snippet param | comparisons per toggle | 2N → k | | S3 | SPA admin `timeAgo` >30d: cached `Intl.DateTimeFormat` | constructions per 1 000 formats | ≤1 · output equals `toLocaleDateString()` |
| S2 | SPA Recent: star reads `favoriteIds` prop, mapper no longer set-dependent | rows re-mapped per star click | N → 0 |
| S3 | SPA admin `timeAgo`: cached `Intl.DateTimeFormat` | constructions per 1000 formats | ≤1 (was 1000) |
Also shipped without a dedicated row: `already_exists` clone kill (same Also shipped without a dedicated row: CardDAV `getlastmodified` per-contact
shape as `not_found`), CardDAV `getlastmodified` stack render (per-contact stack render (ROUND10-§13 helper + chrono fallback), NC capabilities poll
REPORT path, ROUND10-§13 helper + fallback), NC capabilities poll logs logs demoted to `debug` (each poll forced a formatted line + locked stdout
demoted to `debug` (INFO forced a locked-stdout write per client poll), write), trash `to_dto` `into_parts` move + fused/interned display fields
trash `to_dto` `into_parts` move + interned display fields (trash listing (trash listing and path-resolver rows now share the ROUND9 interning),
+ path-resolver rows now share the ROUND9 interning). `TrashedItemDto` name/path moves.
Cross-round regression guards re-run after the StoragePath / classifier
rework — both gates PASS byte-identical, and the shipped code now beats the
numbers those rounds recorded:
- `bench_row_path` (round 4): file row 705 → 280 ns/row (2.52x), allocs
15.75 → 6.00; folder row 684 → 365 ns (1.87x), 14.08 → 5.24.
- `bench_dto_map` (round 3): File→FileDto 839 → 606 ns/row, 10.09 → 3.08
allocs; Folder→FolderDto 314 → 161 ns, 11.80 → 1.00 allocs.
## [1] REST download dead clone → move
```
cargo run --release --features bench --example bench_round11_micro # §1
```
`download_file_impl` cloned the whole `FileDto` (7 owned Strings) into
`get_file_optimized_preloaded` on every authenticated download, purely to
read `mime_type`/`size` afterwards — the share path already captured+moved
(ROUND10 §3 fixed its double-*fetch*, not this clone). Now: one `Arc<str>`
bump + a `u64` copy, then move. 295.3 → 20.7 ns, 7 → 0 allocs per download.
## [2] StoragePath joined-only representation
```
cargo run --release --features bench --example bench_round11_micro # §20
cargo run --release --features bench --example bench_row_path # cross-round gate
```
`StoragePath` stored `segments: Vec<String>` — one heap String per path
component built on EVERY hydrated row — while the DTO path only ever
consumed the joined form, and the entities carried a second `path_string`
duplicate. The value object now stores the canonical joined `String` alone
(`"/"` or `/seg(/seg)*`); `file_name`/`parent`/`segments()`/`Display`
derive on demand; `File`/`Folder` lost the duplicate field (`path_string()`
borrows). `.segments()` had zero external callers — verified before the
rework. Equivalence gates: identical `path_string`, `file_name`, `parent`,
`Display` across the corpus, plus the round-4 harness's byte-identical
gate. 500-row page: 121.0 → 80.3 µs, 4 000 → 1 000 allocs; per-row RAM
drops by the Vec + per-segment String headers + the duplicate path.
## [3] Display classifier fusion
```
cargo run --release --features bench --example bench_round11_micro # §21
```
Every listed file ran the full MIME classification three times
(`icon_class_for`, `icon_special_class_for`, `category_for`), each
heap-allocating its own `to_ascii_lowercase()` on the extension-fallback
path. The three decision trees are byte-for-byte preserved (they diverge
deliberately, so no merged tree); `classify_display` lowers the extension
once into a 16-byte stack buffer shared by all three. Extensions longer
than any table entry short-circuit to the same `_`-arm defaults (gated).
Call sites: `FileDto::from`, folder/favorites/recent handlers, trash
listing (×2), path-resolver — the last two also gained the ROUND9
interning they had missed (`Arc::from` per row → refcount bump).
13-row corpus: 4 390 → 2 074 ns, 21 → 0 allocs.
## [4][5] Memoized process-invariant bodies
```
cargo run --release --features bench --example bench_round11_micro # §3, §18
```
`/status.php` rebuilt its `json!` tree per NC client poll (836 ns / 14
allocs → 28.8 ns / 0). `/openapi.json` was the extreme case: utoipa
reconstructed and re-serialized the whole 171 KiB spec on every request —
2.77 ms / 12 474 allocs → 18.5 ns / 0 via the same `OnceLock<Bytes>`
pattern as ROUND9's capabilities memoization. Byte-identical gates on both.
## [6] NC upload-session PROPFIND emit
The last hand-built XML handler: `String::new()` + `push_str(&format!(…))`
per element per chunk + chrono `to_rfc2822()` per chunk. Now pre-sized +
`write!` + `common::fmt::rfc2822_utc` (chrono fallback out-of-range).
Byte-identical gates at 16 and 256 chunks (escape distributes over
concatenation; RFC 2822 output has no XML-special chars). 16 chunks:
13.2 → 5.2 µs; 256 chunks: 203.7 → 75.4 µs, 2 582 → 772 allocs.
## [Q1] Deferred upload registration 3 → 1
```
cargo run --release --features bench --example bench_round11_queries # §1
```
The write-behind REST upload path ran parent-drive SELECT → INSERT →
parent-path SELECT, the first and third re-reading the identical
`storage.folders` row. Ported to the `WITH parent AS (…) INSERT … SELECT
… RETURNING (SELECT path FROM parent)` template `persist_file` has used
since ROUND2 — 0 rows ⇒ the same `not_found("Folder")` the old first query
produced (gated, anti-enum shape preserved). Root uploads (no parent)
keep their previous two-step shape.
## [Q2] direct_grant_cache
Calendar/AddressBook/Playlist were the only `check()` arms with no result
cache — every CalDAV/CardDAV/music request re-ran the `role_grants` point
query, and DAV clients poll continuously. Added
`direct_grant_cache: Cache<(Subject, Resource, Permission), bool>`
(30 s TTL / 100k, `try_get_with` single-flight — the ROUND8/10 pattern),
flushed on `set_role`/`clear_role` for those resource types; group/expiry
churn self-heals within the TTL exactly like `cascade_grant_cache`.
Gates: identical verdict; a revocation + flush flips the next check.
0.197 ms → 0.2 µs on hit.
## [Q3] expand_user join!
The `is_external` point read and the recursive groups CTE are independent;
serial await paid two round-trips end-to-end on every cold expansion
(per user per 30 s TTL window). `tokio::join!`: 0.426 → 0.204 ms.
## [Q5] Recluster UNNEST batch
`POST /api/people/recluster` issued one `UPDATE faces.faces SET person_id`
per face sequentially (both the unassign-small-clusters and assign loops).
Assignments now accumulate and apply as a single
`UPDATE … FROM unnest($1::uuid[], $2::uuid[])` (the ROUND10 `save_faces`
pattern) — 200-face library: 80.0 → 8.7 ms, final column state identical.
Pairs with §18's norm precomputation on the CPU side (7.70 → 7.17 ms for
the O(N²) pass, bit-identical similarity gated over every pair).
## [S1][S2][S3] SPA pack (vitest gates)
```
cd frontend && npx vitest run src/lib/components/round11.bench.test.ts
```
- **`ResourceList.selectedEntries`** (the ROUND10 flagged item): the
component re-filtered the ENTIRE items array per selection change, and
favorites/recent ignored the snippet param and recomputed their own
`entries.filter(…)` shadow — two full O(N) scans per toggle, O(N²)-ish
across a shift-range. Now an id→index Map (rebuilt only when `items`
changes) projects the selection in O(k·log k) preserving item order;
hosts consume the snippet param and their dead `selectedIds` mirror is
gone (the component's prune effect already self-heals on reload).
51-toggle gesture on 2 000 items: 204 000 → <2 602 comparisons.
- **Recent favorite star**: the entry mapper read `favoriteIds.has(id)`,
subscribing the whole O(N) map to the SvelteSet — one star click rebuilt
all N entries and re-rendered every visible row. `ResourceList` gained a
`favoriteIds` prop read directly by the star widget; the mapper is
set-independent. Star click: N → 0 rows re-mapped, star states gated
identical.
- **admin `timeAgo`**: the >30-day fallback called `toLocaleDateString()`
(a fresh `Intl.DateTimeFormat` per call); now the app-wide cached
formatter — output equality gated, ≤1 construction per 1 000 formats.
## Rejected / reworked this round (the discipline working) ## Rejected / reworked this round (the discipline working)
- **RateLimiter `entry().and_upsert_with`**: 1 846.5 → 1 862.1 ns and - **`min(fm.file_id)::text` geo-cluster cast (Q4)**: PostgreSQL has **no
8.0 → 9.1 allocs/op — moka's compute-entry machinery costs more than the `min(uuid)` aggregate** — the "cast once per cluster" rewrite fails to
two-op shape it replaced. Redesigned as lock-free `get` (borrows the parse (`42883`). The gate caught it before it could ship broken; the
key, no alloc) + `insert`; identical counter sequence gated. per-row-cast original stays (22.6 ms per 5k-row viewport, admin-shaped
traffic), and a custom `CREATE AGGREGATE` was judged schema surface this
query doesn't justify. The bench section now reproduces the rejection.
- **RateLimiter `entry().and_upsert_with`**: 1 657 → 2 365 ns and
8.0 → 9.1 allocs — moka's compute-entry machinery costs more than the
two ops it replaced. Redesigned as lock-free `get` (borrows the key, no
alloc) + `insert`: 6.0 allocs, wall within variance; identical counter
sequence gated. Adopted in that form.
- **GET/HEAD `Last-Modified` stack-render port**: chrono's `to_rfc2822()` - **GET/HEAD `Last-Modified` stack-render port**: chrono's `to_rfc2822()`
String IS the terminal allocation the header needs (44.3 ns incl. the String IS the terminal allocation the header needs (38.7 ns incl. alloc
alloc vs 46.5 ns for stack render + the same alloc). Only body-emit vs 47.0 ns stack render + the same alloc). Only body-emit sites (where
sites (where `write!` lands in an existing buffer) benefit — those were `write!` lands in an existing buffer, 31.8 ns / 0 allocs) benefit —
ported (§6, CardDAV); the header sites were left on chrono. those were ported (§6 + CardDAV); header sites stay on chrono.
- **Search page `drain(range).collect()`**: −300 allocs but slower on - **`tracing-appender` non-blocking log writer (L1)**: on a fast sink
wall than `to_vec` in the first model (tail memmove). Reshaped as (stdout→/dev/null — the containerized default) sync sustains 1.41M ev/s
`into_iter().skip().take().collect()` — moves the page, drops the rest, vs 0.99M non-blocking, with a better tail (p999 76 vs 151 µs, max 0.8
no tail shift. vs 8.3 ms — the channel hop costs more than the write). Non-blocking
only wins on a slow sink (20 µs/line: 6.4x wall, p50 22 → 1.9 µs), but
there the drain gate FAILED — buffered tail lines can be lost at
shutdown, unacceptable for the audit channel. Not adopted;
`tracing-appender` stays as a dev-dependency for the reproducible
harness (`bench_log_writer`, 4 arms via `BENCH_LOG_ARM`/`_WRITER`).
- **First search-page model (`drain(range)`)**: −300 allocs but slower on
wall (tail memmove). Reshaped as `into_iter().skip().take().collect()`
— moves the page, drops the rest, wins both axes (§12 row in Summary).
## Deferred / flagged (not shipped this round) ## Deferred / flagged (not shipped this round)
- **NC preview 304 still runs `get_file`** (`preview_handler.rs`): the - **NC preview 304 still runs `get_file`**: dropping the fetch on the
object-id → file fetch on the revalidation path is only needed for revalidation path changes existence semantics (deleted file → 304
existence semantics (a deleted file must 404, not 304). Dropping it is instead of 404) — needs maintainer sign-off, same class as the standing
a behavior decision — same class as the standing CalDAV CalDAV authz-before-fetch reorder (ROUND9/10 flag).
authz-before-fetch reorder — flagged for maintainer sign-off.
- **`CachedBlobBackend`'s `Mutex<LruCache>` index** serializes every - **`CachedBlobBackend`'s `Mutex<LruCache>` index** serializes every
cached read; a moka byte-weigher migration (the file-content-cache cached read on remote+cache deployments; the moka byte-weigher
pattern) is the natural fix but touches eviction-unlink semantics — migration (file-content-cache pattern) deserves its own round with a
deserves its own round with a concurrency bench. concurrency bench and eviction-unlink care.
- **Capture-metadata extraction reads each media file 2-3×** - **Capture-metadata extraction reads each media file 2-3×**
(`media_metadata_service.rs`: kamadak full read + nom-exif path re-read (`media_metadata_service`: kamadak full read + nom-exif path re-read +
+ track fallback re-read). Feeding nom-exif from the in-memory buffer track fallback). Feeding nom-exif from the in-memory buffer needs its
needs its `MediaSource` API verified on the pinned version. `MediaSource` API verified on the pinned version.
- **`CachedBlobBackend::local_blob_path` sync `stat`** (ROUND10 flag - **`CachedBlobBackend::local_blob_path` sync `stat`** (ROUND10 flag
stands): needs an async port variant. stands — needs an async port variant).
- **Azure SDK 0.21 stack** drags duplicate dependency trees (h2 0.3+0.4, - **Azure SDK 0.21 stack** drags duplicate dependency trees (h2 0.3+0.4,
two hashbrown generations, base64 0.13) into the binary; an SDK bump is three hashbrown generations, base64 0.13, getrandom 0.1) into every
a dedicated migration, not a perf tweak. build — an SDK bump is a dedicated migration.
- **`AudioMetadataRepository::list_by_{artist,album,genre}`** are dead - **`AudioMetadataRepository::list_by_{artist,album,genre}`** are dead
code (never called) with seq-scan `ILIKE` shapes — flag for deletion code with seq-scan `ILIKE` shapes — flag for deletion, not indexing.
rather than indexing.
- **`CachedBlobBackend::put_blob` cache population** silently fails for - **`CachedBlobBackend::put_blob` cache population** silently fails for
S3/Azure whole-file puts (the inner backend deletes the source before S3/Azure whole-file puts (inner backend deletes the source before the
the cache copy runs) — correctness note for maintainers, not perf. cache copy) — correctness note for maintainers.
- **CalDAV authz-before-fetch reorder** — ROUND9/10 flag stands. - **Grouped file/grid views unvirtualized** (ROUND10 flag stands).
## Environment / methodology ## Environment / methodology
- `cargo run --release --features bench --example bench_round11_micro` - `cargo run --release --features bench --example bench_round11_micro`
(pure CPU, counting allocator, BEFORE replicas vs shipped code). — 21 sections, counting allocator, BEFORE replicas vs shipped shapes,
equivalence gates inline (`BENCH_ITERS`, default 100k).
- `cargo run --release --features bench --example bench_round11_queries` - `cargo run --release --features bench --example bench_round11_queries`
(Postgres; seeds + sweeps its own fixtures). — needs Postgres; seeds and sweeps its own fixtures (`BENCH_PASSES`).
- `BENCH_LOG_ARM=sync|nonblocking [BENCH_LOG_WRITER=slow] cargo run - `BENCH_LOG_ARM=sync|nonblocking [BENCH_LOG_WRITER=slow] cargo run
--release --features bench --example bench_log_writer >/dev/null`. --release --features bench --example bench_log_writer >/dev/null`.
- `cd frontend && npx vitest run src/lib/components/round11.bench.test.ts`. - `cd frontend && npx vitest run src/lib/components/round11.bench.test.ts`.
- Regression guards from earlier rounds re-run after the StoragePath / - Cross-round guards: `bench_row_path`, `bench_dto_map`.
classifier changes: `bench_row_path` (round-4 gates) and `bench_dto_map`
(round-3 gates).
+6 -3
View File
@@ -8,14 +8,17 @@
//! Sections (all pure CPU, no Postgres): //! Sections (all pure CPU, no Postgres):
//! 1. REST download `FileDto` dead clone vs mime/size capture + move //! 1. REST download `FileDto` dead clone vs mime/size capture + move
//! 2. Single-resource GET/HEAD `Last-Modified`: chrono `to_rfc2822()` //! 2. Single-resource GET/HEAD `Last-Modified`: chrono `to_rfc2822()`
//! vs `common::fmt::rfc2822_utc` stack render (gate: byte-identical) //! vs `common::fmt::rfc2822_utc` stack render (gate: byte-identical).
//! VERDICT: header port REJECTED — the chrono String is already the
//! terminal allocation; only body-emit sites benefit.
//! 3. `/status.php` poll: rebuild `json!` + serialize vs `OnceLock<Bytes>` //! 3. `/status.php` poll: rebuild `json!` + serialize vs `OnceLock<Bytes>`
//! (gate: byte-identical) //! (gate: byte-identical)
//! 4. NC chunk-upload session PROPFIND: `push_str(&format!)` + chrono //! 4. NC chunk-upload session PROPFIND: `push_str(&format!)` + chrono
//! per chunk vs `with_capacity` + `write!` + stack dates //! per chunk vs `with_capacity` + `write!` + stack dates
//! (gate: byte-identical XML) //! (gate: byte-identical XML)
//! 5. RateLimiter: 2 key allocs + entry+insert vs 1 alloc + single //! 5. RateLimiter: 2 key allocs + entry+insert vs (a) `and_upsert_with`
//! `and_upsert_with` (gate: identical allow/deny + counts) //! [REJECTED: slower + more allocs] vs (b) lock-free get + insert
//! [ADOPTED] (gate: identical counter sequences)
//! 6. CSRF header token: `to_string` vs borrow compare (gate: same bool) //! 6. CSRF header token: `to_string` vs borrow compare (gate: same bool)
//! 7. Thumbnail ETag: `{:?}` Debug enums vs `as_str` + push (gate: bytes) //! 7. Thumbnail ETag: `{:?}` Debug enums vs `as_str` + push (gate: bytes)
//! 8. Recent-handler id: `Uuid::to_string` vs stack `encode_lower` //! 8. Recent-handler id: `Uuid::to_string` vs stack `encode_lower`
+17 -14
View File
@@ -312,7 +312,13 @@ async fn direct_grant_query(pool: &PgPool, subject: Uuid, cal: Uuid) -> bool {
) )
.bind(vec!["user"]) .bind(vec!["user"])
.bind(vec![subject]) .bind(vec![subject])
.bind(vec!["reader", "contributor", "manager", "owner"]) .bind(vec![
"owner",
"editor",
"contributor",
"commenter",
"viewer",
])
.bind("calendar") .bind("calendar")
.bind(cal) .bind(cal)
.fetch_optional(pool) .fetch_optional(pool)
@@ -546,11 +552,15 @@ async fn section_geo(pool: &PgPool, s: &Seed, passes: usize) {
} }
tx.commit().await.expect("commit geo seed"); tx.commit().await.expect("commit geo seed");
let mut b = geo_query(pool, s.owner, "min(fm.file_id::text)").await; // REJECTED BY GATE: PostgreSQL has no `min(uuid)` aggregate — the
let mut a = geo_query(pool, s.owner, "min(fm.file_id)::text").await; // planned `min(fm.file_id)::text` (cast per cluster) fails to parse, so
b.sort_by(|x, y| x.3.cmp(&y.3)); // the per-row-cast original stays. Verify the rejection reproducibly
a.sort_by(|x, y| x.3.cmp(&y.3)); // and record the BEFORE for the doc.
gate("cluster rows identical", a == b); let min_uuid_err = sqlx::query("SELECT min(fm.file_id)::text FROM storage.file_metadata fm")
.fetch_optional(pool)
.await
.is_err();
gate("min(uuid) unsupported → AFTER rejected", min_uuid_err);
let (ms_b, _) = timed(passes.min(60), || async { let (ms_b, _) = timed(passes.min(60), || async {
geo_query(pool, s.owner, "min(fm.file_id::text)") geo_query(pool, s.owner, "min(fm.file_id::text)")
@@ -558,14 +568,7 @@ async fn section_geo(pool: &PgPool, s: &Seed, passes: usize) {
.len() .len()
}) })
.await; .await;
let (ms_a, _) = timed(passes.min(60), || async { println!(" BEFORE min(file_id::text) p50 {ms_b:.3} ms (AFTER rejected — see gate)");
geo_query(pool, s.owner, "min(fm.file_id)::text")
.await
.len()
})
.await;
println!(" BEFORE min(file_id::text) p50 {ms_b:.3} ms");
println!(" AFTER min(file_id)::text p50 {ms_a:.3} ms");
let _ = sqlx::query("DELETE FROM storage.files WHERE drive_id = $1 AND name LIKE 'geo-%'") let _ = sqlx::query("DELETE FROM storage.files WHERE drive_id = $1 AND name LIKE 'geo-%'")
.bind(s.drive) .bind(s.drive)
+1 -1
View File
@@ -7,7 +7,6 @@ pub enum TrashedItemType {
Folder, Folder,
} }
#[derive(Debug, Clone)]
/// Owned decomposition of a [`TrashedItem`] (see /// Owned decomposition of a [`TrashedItem`] (see
/// [`TrashedItem::into_parts`]). /// [`TrashedItem::into_parts`]).
pub struct TrashedItemParts { pub struct TrashedItemParts {
@@ -19,6 +18,7 @@ pub struct TrashedItemParts {
pub trashed_at: DateTime<Utc>, pub trashed_at: DateTime<Utc>,
} }
#[derive(Debug, Clone)]
pub struct TrashedItem { pub struct TrashedItem {
id: Uuid, id: Uuid,
original_id: Uuid, original_id: Uuid,
@@ -625,10 +625,12 @@ impl FileBlobReadRepository {
SELECT count(*) AS n, SELECT count(*) AS n,
avg(fm.longitude) AS clng, avg(fm.longitude) AS clng,
avg(fm.latitude) AS clat, avg(fm.latitude) AS clat,
-- Cast once per cluster, not once per row: uuid byte -- NOTE: `min(fm.file_id)::text` (cast per cluster, not
-- order == canonical-text order, so the chosen sample -- per row) was attempted in ROUND11 §Q4 and REJECTED by
-- is identical (benches/ROUND11.md §Q4). -- its benchmark gate: PostgreSQL has no `min(uuid)`
min(fm.file_id)::text AS sample_id -- aggregate, and adding a custom one is schema surface
-- this viewport query doesn't justify.
min(fm.file_id::text) AS sample_id
FROM storage.file_metadata fm FROM storage.file_metadata fm
JOIN storage.files fi ON fi.id = fm.file_id JOIN storage.files fi ON fi.id = fm.file_id
WHERE fi.drive_id IN ( WHERE fi.drive_id IN (