perf: round 4 — one-pass row paths, drive-selector cache, CalDAV single-parse, streamed Azure, batched hydration

Nine benchmark-gated changes (benches/ROUND4.md; every one ships with a
BEFORE/AFTER bench + equivalence gate, rollback rule as ROUND2/3):

- Row→entity path build: one-pass StoragePath::from_folder_and_name /
  from_joined + normalize_storage_name_owned + alloc-free Display —
  743→417 ns/file-row (1.78x), −5 allocs/row on every listing surface.
- WebDAV drive-selector: per-user readable_cache (single-flight, 30 s
  TTL, explicit invalidation incl. membership + group changes) replaces
  the grants join per request — 441 µs → 0.8 µs (~550x), 0 queries warm.
- CalDAV from_ical/update_ical_data: 8 full IcalParser runs per VEVENT
  → 1 (7.1x per PUT, 4.4x on 50-event imports); alloc-free split_vevents,
  chunk scan without the whole-body uppercase copy (1.4x), borrowed-key
  UID grouping (1.3x), REPORT props no longer cloned.
- PROPFIND emit: partition Vecs dropped (single-pass 404 list) + stack
  rendered RFC 3339/2822 dates, sizes, quoted etags (common::fmt,
  chrono-byte-identical, sweep-tested) on both DAV surfaces — 1.22x
  per page, 17.9→12.0 allocs/row.
- Grant-listing hydration: calendars/address books/playlists batch
  hydrate via = ANY($1) — 15 serial queries → 1 (~13x per sync poll).
- user-flags cache: get→insert → try_get_with single-flight (32→1
  queries per cold herd).
- Azure downloads: whole-blob Vec buffering → streamed SDK pages —
  TTFB 349→4 ms (87x), peak heap 480→1.9 MiB (254x) on 256 MiB blobs;
  new OXICLOUD_AZURE_ENDPOINT_URL override (Azurite/bench hook).
- Face indexing: unbounded per-image tokio::spawn → core-count
  semaphore, permit before blob read — peak heap 1175→176 MiB (6.7x).

Checks: cargo fmt, clippy --all-features --all-targets -D warnings,
cargo test --workspace (523 passed) + --features test_utils. hurl API
suite and dockerized integration DB not runnable in this environment —
left to CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
This commit is contained in:
Claude
2026-07-17 13:48:37 +00:00
parent 8a73607229
commit 12dc648cff
44 changed files with 5092 additions and 402 deletions
+251
View File
@@ -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`).