Files
Oxicloud/benches/ROUND4.md
T
Claude 12dc648cff 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
2026-07-17 13:48:37 +00:00

13 KiB
Raw Blame History

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).