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
13 KiB
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 ownedCurrentUserstrings (interfaces/middleware/auth.rs) — small but ubiquitous. - Search suggest clones each entity before DTO conversion
(
search_service.rs:525/539).