Files
Oxicloud/benches/ROUND10.md
T
Claude c51af68432 perf: round 10 — auth alloc purge, parent-herd batching, query-shape pack, NC 304s
Benchmark-gated (benches/ROUND10.md; every change carries a BEFORE/AFTER
harness with equivalence/safety gates — two designs were rejected or
rewritten by their own benches before adoption):

- Auth hot path: TokenClaims/CurrentUser display fields to Arc<str>, role
  to inline SmolStr end-to-end (Bearer, cookie, Basic-auth cache) — 4→1
  allocs per authenticated request, 3→0 per warm DAV request; JWT
  Encoding/Decoding/Validation built once.
- Cold shared-album herd: leader-inline parent batching in PgAclEngine
  (+ cascade try_get_with single-flight) — 100→2 parent queries per
  100-thumb cold herd, herd wall 1.9x, sequential + warm paths unchanged,
  all ROUND8/9 safety gates plus new herd-equivalence gates.
- Query-shape pack: share download double-fetch 2→1 (2.18x), contact-group
  COUNT(*) 14.9x, save_faces UNNEST 3.9x, playlist reorder UNNEST 63.7x
  (now atomic), search files∥folders join! 1.45x, move drive-lookup join!
  2.14x, trash partial (drive_id, trashed_at) indexes, CalDAV event-gate
  narrow read, favorites/recents binary-decode port, dead count_files
  removed.
- NC surface: preview + avatar honour If-None-Match (e2e: 5 KB and 197 KB
  → 0 bytes per revalidation), avatar WebP→PNG transcode memoised,
  PROPFIND/trashbin integer+date emits on stack formatters, folder-header
  enrichment join!, chunk-PUT retry stat folded into create_new open.
- common::fmt integer rendering rewritten on the std 2-digit LUT after the
  round's own bench caught the div-loop losing to to_string (16.1 ns vs
  22.5; speeds every prior-round call site).
- Micro-pack: WebDAV scope probe borrow-only, ShareService base_url
  snapshot, cookie_secure OnceLock, Arc'd AES-GCM cipher, stack request-id,
  tantivy analyzer clone dropped.
- SPA: search stale-guard + AbortController (10→1 completed round-trips,
  stale-clobber gone), getFolder in-flight dedup, gridColumns matchMedia
  hoist (10k→0 style reads).

Backend: cargo fmt + clippy -D warnings clean, 524 tests green.
Frontend: npm run check clean, 301 vitest green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DdM7V7M3QPW7HEHg3gLov
2026-07-18 20:33:50 +00:00

18 KiB

Round 10 — auth alloc purge, parent-resolution herd batching, query-shape pack, NC conditional revalidation

Benchmark-gated, same rule as ROUND2-9: every change ships with a BEFORE/AFTER benchmark and equivalence/safety gates; an AFTER that doesn't beat its BEFORE gets rolled back or redesigned. Two items this round went through exactly that loop: the stack integer formatters first benchmarked SLOWER than to_string() and were rewritten (§13) before adoption, and the first parent-batching design (a channel task) measured 66 µs of pure hop overhead per sequential miss and was replaced by the leader-inline protocol (§10) before adoption.

Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release profile; frontend on Node 26 / vitest 4 (jsdom). Reproduce any row with the command in its section.

Summary

# change key metric before → after
1 Authenticated-request identity build (Arc<str> claims + inline SmolStr role) allocs / ns per request 4 → 1 allocs · 77 → 59 ns
2 Basic-auth cache hit (Arc<str> cached identity) allocs / ns per DAV request 3 → 0 allocs · 62 → 41 ns
3 Share download metadata double-fetch → _preloaded queries / ms per download 2 → 1 · 0.700 → 0.321 ms (2.18x)
4 Contact-group summary → COUNT(*) ms, 500-member group 5.76 → 0.39 (14.9x)
5 save_faces per-face INSERT loop → UNNEST batch ms, 30-face image 5.90 → 1.52 (3.9x)
6 Playlist reorder per-track UPDATE loop → UNNEST ms, 500-track reorder 167.0 → 2.6 (63.7x), now atomic
7 Search page files∥folders tokio::join! ms per search 4.16 → 2.87 (1.45x)
8 Move pre-check drive lookups join! ms per move 0.664 → 0.311 (2.14x)
9 Trash listing partial (drive_id, trashed_at) WHERE is_trashed ms per page (30-drive box) 0.615 → 0.496 (1.2x)
10 Parent-resolution herd batching (leader-inline) parent queries, 100-thumb cold herd 100 → 2 · herd wall 65.5 → 35.4 ms (1.9x)
11 Folder-cascade single-flight (try_get_with) ltree queries, same-folder cold herd K → 1 (rode along with §10's gates)
12 NC preview + avatar honour If-None-Match bytes/req on revalidation (e2e) preview 5 004 → 0 · avatar 196 992 → 0
13 common::fmt integer LUT rewrite ns/op vs to_string() i64: 33.7 → 16.1 (std: 22.5)
14 NC PROPFIND int props + trashbin dates → stack fmt allocs per 500-row page / 2000-item bin 1 501 → 1 · 4 002 → 2 (wall 1.11x / 1.13x)
15 WebDAV scope probe, base_url snapshot, cookie OnceLock, JWT keys, cipher Arc, request-id see §15 e.g. base_url listing 71.5 µs → 1 ns
16 CalDAV update/delete gate narrow read ms (11 KB ical_data row) 0.323 → 0.308 (1.05x + 11 KB less wire)
17 Legacy favorites/recents rows: binary UUID decode ms per 500-row page 2.80 → 2.58 (1.09x)
18 SPA: search stale-guard + AbortController completed round-trips, 10-query burst 10 → 1; stale-clobber eliminated
19 SPA: getFolder in-flight dedup requests per cold deep-link 2 → 1
20 SPA: gridColumns matchMedia hoist MQL constructions / 10k calls 10 000 → 0 · 13.4 → 2.6 ms (5.2x)

Plus: count_files (a dead port method whose impl ran the full paginated search) deleted outright; NC chunk PUT retry-probe folded into the open (create_new, one stat less per chunk); tantivy per-query analyzer double-clone dropped.

[1][2] Auth hot path — the ROUND6/9 deferred "cheapest known win"

Every authenticated request built CurrentUser by deep-cloning username/email out of the cached Arc<TokenClaims> and to_stringing the live role — the exact 2-allocs-per-request item deferred since ROUND6, plus two more nobody had counted (flags.role.to_string() in decide_live_role, and the Basic-auth cache handing out 3 owned Strings per moka hit on every DAV request).

Now: TokenClaims.username/email are Arc<str> (serde rc, same one allocation at decode time), CurrentUser.username/email are Arc<str> (refcount bumps), CurrentUser.role is an inline SmolStr fed by LiveRole::Active(SmolStr) + the new UserRole::as_str() (&'static, zero alloc), and CachedBasicAuthResult carries the same types so a Basic hit is bumps + a 24-byte memcpy. JSON wire shape is unchanged (byte-identity gated); OpenAPI keeps String via value_type.

cargo run --release --features bench --example bench_round10_micro
# [1] identity build  BEFORE 77.2 ns / 4.00 allocs → AFTER 59.0 / 1.00
#     gate: fields + serialized JSON byte-identical
# [2] basic-auth hit  BEFORE 62.2 ns / 3.00 allocs → AFTER 40.9 / 0.00

The JWT service also stopped rebuilding EncodingKey/DecodingKey/ Validation per call (now fields; the verify-miss path drops 4 allocs, §15), and generate_access_token's format!("{}", role) became as_str().to_string().

[3] Share download — the handler already had the DTO

serve_share_file fetched the file DTO for ETag/Range handling, then called get_file_optimized, which re-ran the same metadata query. The authenticated download path already used get_file_optimized_preloaded; the public-share path now does too (the DTO is moved, not cloned — the one later use of size is captured first).

cargo run --release --features bench --example bench_round10_queries
# [1] BEFORE 2 queries 0.700 ms/download → AFTER 1 query 0.321 ms (2.18x)

[4] Contact-group summary — 500 vCards hydrated to compute len()

get_group called get_contacts_in_group — full rows (vCard TEXT that can carry base64 photos + 3 JSONB arrays parsed per contact) — and kept only the count. New count_contacts_in_group port method backed by SELECT COUNT(*) on group_memberships.

# [3] 500 members: BEFORE hydrate-all 5.762 ms → AFTER COUNT(*) 0.387 (14.9x)

[5][6] Write-path N+1 loops → one UNNEST statement

  • save_faces: one INSERT per face inside a transaction → a single multi-row INSERT … SELECT FROM unnest(...) (the bbox float4[] rides as 4 parallel component arrays, reassembled server-side). 30-face image: 5.90 → 1.52 ms (3.9x); gate re-reads a stored row field-by-field.
  • reorder_items: one autocommit UPDATE per track (non-atomic — a mid-loop failure left a half-applied order) → one UPDATE … FROM unnest($1) WITH ORDINALITY. 500-track reorder: 167.0 → 2.6 ms (63.7x); gate compares every final position.

[7][8] Independent awaits overlapped (join!, decide-by-bench)

  • SearchService::search awaited the content-index lookup, the file page and the folder query serially in both branches; suggest_with_perms had the correct shape since ROUND4. All three are independent; the two SQL arms measured 4.16 → 2.87 ms (1.45x) with identical results. (Content-index enabled widens the win — the Tantivy arm is the long pole and now overlaps both queries.)
  • File/folder move pre-check ran the source-drive-policies and destination-drive point reads serially before comparing: 0.664 → 0.311 ms (2.14x). Adopted per the ROUND6 protocol (these are two independent point reads whose server-side execution parallelizes — the shape that wins even on a local socket).
  • The NC PROPFIND folder-HEADER trio (favorites + oc:fileid + dead props for the folder's own entry, on the TTFB critical path of every folder PROPFIND) got the same join! ROUND9 gave the per-page child triples.

[9] Trash listing — the dropped-index gap

Migration 20260904 removed user_id and with it the only trash-listing index; what remained forced either a live-rows scan of the drive (idx_files_drive_id) or an all-tenants trash scan (idx_files_trash_expiry). New partial pair (drive_id, trashed_at) WHERE is_trashed (migration 20260920000000) bounds the read to the caller's drives' trashed rows, pre-ordered for the trashed_at/deletion_date keysets. On a 30-drive box (3 000 live + 25 trashed each): 0.615 → 0.496 ms (1.2x); the gap widens with drive size since the BEFORE plan scans live rows. Identical row sets gated; the retention sweeper keeps its global expiry index.

[10][11] Cold-album herd — parent batching + cascade single-flight

ROUND9 §10 left the cold first view paying one parent PK read per photo and noted batching "needs a wider engine API". It doesn't: the browser fires its thumbnail requests near-simultaneously, so the batching can live INSIDE file_parent_folder_cached:

  • Leader-inline protocol (parent_batch slot): an idle miss marks itself leader (one mutex op) and runs its point query exactly as before — the sequential path is unchanged (a channel-task design measured ~66 µs/miss of hop overhead and was REJECTED). Misses arriving while the leader is in flight park a oneshot; the leader serves them all with ONE id = ANY($1) charity batch after its own read; a second wave is handed to a detached drainer so the leader's response is never delayed by more than one batch. A cancelled leader's guard wakes every parked waiter to re-elect; waiters that exhaust retries fall back to the inline point read. Requested-but-absent ids memoise as None, matching the point read's semantics.
  • cascade_grant_cached → try_get_with (the ROUND3 auth-herd pattern): K concurrent files of one album all recurse into the SAME folder decision; get→compute→insert let each run the ltree query. Single-flight collapses that to one loader; moka never caches loader errors, preserving error semantics.
cargo run --release --features bench --example bench_thumbnail_cascade_cache
# thumbs=100 (folder-grant recipient, no drive membership)
# ROUND8 cold (union/file)      65.59 ms   655.90 µs/thumb
# AFTER cold sequential         65.53 ms   655.33 µs/thumb  (parity — no
#   sequential regression from the protocol; this box's high per-query
#   latency compresses the R9 decomposition margin visible on faster I/O)
# AFTER warm (revalidation)      0.14 ms     1.42 µs/thumb  (unchanged)
# AFTER herd (concurrent cold)  35.35 ms   353.47 µs/thumb  (~1.9x vs
#   sequential cold — and the real shape of a grid's first view)
#   parent queries for the herd: 2   (was 100)
# gates: all original ROUND8/9 safety gates (outsider denied, clear_role
#   revoke denies immediately, direct-grant sibling isolation) plus NEW:
#   herd answers == point-read answers per file, parent queries < K/4

[12] NC preview + avatar — ETag existed, nobody compared it

  • /index.php/core/preview set an immutable ETag but never read If-None-Match — every gallery revalidation re-ran NC-id resolve, file fetch, authz, blob-hash query, thumbnail cache read and full body. The handler now answers 304 right after the authz check (never before it).
  • /index.php/avatar/{user}/{size} had no ETag at all, and re-decoded the stored data URI on every request (for WebP avatars: a full image decode
    • PNG encode per request). Now: content-hash ETag (over the stored URI, computed before any decode), 304 on match, and the WebP→PNG transcode memoised in a 32-entry moka keyed by content hash.

End-to-end (real server + curl loops, the PHOTOS-ETAG methodology; 60 requests per arm):

# preview  200: 5 004 bytes/req  1.35 ms   →  304: 0 bytes  1.25 ms
# avatar   200: 196 992 bytes/req 2.30 ms  →  304: 0 bytes  1.97 ms
# gates: fresh GET 200 with ETag; matching If-None-Match → 304 empty;
#        stale If-None-Match → full 200. All six pass.

Per NC client per cache-lapse this removes ~197 KB (avatar) + ~5 KB/photo (previews) of transfer plus the per-request DB/disk work behind them.

[13] common::fmt — the bench caught our own helpers losing

The round's first micro run showed the PROPFIND int-field port SLOWER on wall despite 1 500 fewer allocs. An isolated interleaved probe confirmed: the byte-at-a-time div-by-10 loop in u64_str (33.7 ns) lost to u64::to_string() (22.5 ns) — std renders via a 2-digit lookup table. Rewrote u64_str/i64_str (and the date helpers' push2) on the same DEC_LUT technique, dropping i64_str's temp-buffer copy:

# interleaved probe, 20M ops/arm
# to_string 22.5 ns   i64_str BEFORE 33.7 ns → AFTER 16.1 ns
# to_rfc2822 42.7 ns  rfc2822_utc 33.6 ns

This speeds every existing ROUND4-9 call site (d:getcontentlength, oc:size, digest lengths, dates) as well as the new ones.

[14] NC PROPFIND / trashbin emit stragglers

With §13 in place, the remaining to_string()/to_rfc2822() fields moved to the stack helpers: oc:fileid, nc:creation_time, nc:upload_time, quota bytes (files + folders writers), and the trashbin's per-item modified/deletion-time/fileid (which still ran the chrono interpreter).

# [3] 500-row page  BEFORE 95.5 µs / 1501 allocs → AFTER 86.3 / 1   (1.11x)
# [4] 2000-item bin BEFORE 318.5 µs / 4002 allocs → AFTER 280.7 / 2 (1.13x)
# gates: XML byte-identical in both harnesses

[15] Micro-pack (each gated in bench_round10_micro)

  • WebDAV scope probe: format!("{prefix}/") per request → borrow-only strip_prefix pair. 38 → 3.5 ns, 1 → 0 allocs, identical routing.
  • ShareService.base_url: env::var("OXICLOUD_BASE_URL") + rebuild PER DTO ROW → constructor snapshot. 500-row listing: 71.5 µs → 1 ns.
  • cookie_secure: 4 env-var resolutions + duplicate SECURITY log lines per login → OnceLock (process-invariant by definition).
  • JWT verify miss: fresh Validation + DecodingKey per decode → service fields. 4 748 → 4 527 ns, 17 → 13 allocs (HMAC dominates).
  • EncryptedBlobBackend: per-op clone of the expanded AES-256 round keys → Arc bump. 30.8 → 13.3 ns per hand-off.
  • Request-id header: Uuid::to_string + HeaderValue::from_str → stack-encode. 85 → 46 ns, 2 → 1 allocs, identical bytes.
  • NC chunk PUT: the retry-detection stat per chunk folded into the open — stream_body_to_path now opens create_new first and reports created_fresh (AlreadyExists → truncate-open), the ROUND9 §5a pattern applied to the NC surface.

[16] CalDAV update/delete gate — narrow calendar_id read

The service fetched the FULL event row (with ical_data — 11 KB in the benched shape, unbounded with attendees/VALARMs) only to read .calendar_id for the authz gate. New find_calendar_id_by_event_id scalar. 0.323 → 0.308 ms on a local socket (1.05x) — adopted for the direction: the win is the row width off the wire, which grows with event size and network distance. (This is NOT the deferred authz-reorder — the gate still runs before the mutation, same order.)

[17] Legacy favorites/recents rows — the ROUND6 §10 port, with a catch

The two legacy listing methods still shipped ::TEXT casts. Porting them to binary decode surfaced that auth.user_favorites.id is a SERIAL integer, not a UUID — the bench's identity gate caught the wrong decode before it could ship (decode as i32, render app-side). 500-row page: 2.80 → 2.58 ms (1.09x), rendered tuples identical.

[18][19][20] SPA pack (vitest gates)

cd frontend && npx vitest run src/routes/search/staleGuard.bench.test.ts \
  src/lib/api/endpoints/folderDedup.bench.test.ts src/lib/utils/grid.bench.test.ts
  • Search stale-response guard (the flag open since ROUND7): rapid-fire query/sort/filter changes had no seq token and no abort — a slow older response could clobber a newer one, and superseded recursive searches ran to completion server-side. run() now carries a sequence token + AbortController (threaded through searchFiles/searchSuggest); AppShell's suggest box got the same guard. 10-query burst: completed round-trips 10 → 1; final result provably fresh (BEFORE ends on the STALE query).
  • getFolder in-flight dedup (the resolveUser pattern): cold deep-links fired the same folder-metadata GET twice (breadcrumbs + drive-id resolver). Concurrent duplicates now share one request; sequential calls still refetch (freshness unchanged, gated).
  • gridColumns: a fresh matchMedia (style read) per call inside the grid windowing derives → one module-level MQL fed by its change listener (the photos-timeline fix applied to the shared util). 10k calls: 10 000 → 0 MQL constructions, 13.4 → 2.6 ms; output identity gated across the breakpoint, crossings propagate via the listener.

Rejected / reworked this round (the discipline working)

  • Channel-task parent batcher: correct, but the mpsc+oneshot round-trip measured 65.9 µs per sequential miss — a pure regression for the non-concurrent case. Replaced with leader-inline (§10).
  • First stack-formatter port: slower than to_string() on wall (§13); adopted only after the LUT rewrite made it faster on BOTH axes.
  • uf.id as binary UUID: wrong type entirely (SERIAL int) — the equivalence gate caught it; shipped as i32 decode instead.

Deferred / flagged (not shipped this round)

  • CalDAV authz-before-fetch reorder — still awaiting maintainer sign-off per the authz-change convention (ROUND9 flag stands).
  • Grouped file/grid views are unvirtualized (files group-by and ResourceList grid sections mount every row; the flat/list paths are windowed) — a UI-behaviour change big enough to want its own pass.
  • search_files_paginated's COUNT(*) OVER() + OFFSET — keyset would change the API's total-count contract; needs a product decision on whether search totals can become approximate/capped.
  • ResourceList.selectedEntries recomputes an O(N) filter per selection toggle once the toolbar is visible; hosts often shadow it with their own copy. Needs a small API rework (getter or id-index).
  • Chunk-upload progress.bin full rewrite per chunk (REST surface) — debouncing trades crash-resume granularity; flagged for discussion.
  • CachedBlobBackend::local_blob_path sync stat on the reactor (remote-backend deployments' media hooks) — needs an async variant of the port method; low urgency.