Five benchmark-gated optimizations from a fresh six-way audit (benches/ROUND25.md), each with a BEFORE/AFTER gate that rolls back if AFTER does not beat BEFORE: - M1 EncryptedBlobBackend::decrypt_bytes: replace split_off (a fresh Vec + full ciphertext memcpy on every decrypted chunk, contradicting its own "in place" doc) with in-place detached decrypt + a zero-copy Bytes::slice past the nonce. Peak RAM per read drops from ~2x to ~1x the payload (-262KB/op at 256KiB; scales with blob size). Plaintext byte-identical; tamper/wrong-key tests pass. - M2 delta commit: move-unzip the owned chunk list instead of a third per-occurrence hash clone (-4000 allocs on a 4000-chunk commit). - M3 folder ZIP download: drop the dead Query<HashMap> extractor it never read (byte-identical response; 5->0 allocs/request). - Q1 public-playlist listing: fold the per-playlist COUNT(*) N+1 into one LEFT JOIN ... GROUP BY via a new inherent repo method (101 -> 1 round-trips, 36x wall on a 100-playlist page). - Q2 contact REST listings (paginated/search/by-group): stop over-fetching the multi-KB vcard TEXT the ContactDto discards, via a shared lite row mapper and narrowed SELECTs (6.4x wall on 1000 contacts with 8KiB vcards). The whole-book vCard export and CardDAV sync paths keep the column. Adds bench_round25_micro (counting allocator tracking count+bytes) and bench_round25_queries (live Postgres), both with equivalence gates and a rollback exit(1). Verified: cargo fmt clean, cargo clippy -D warnings clean, cargo test --lib --features bench = 529 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L8gs91AhmazoxMsDcNk3KT
16 KiB
Round 25 — encrypted-read in-place decrypt (RAM), delta-commit hash move, dead folder-Query, public-playlist N+1 fold, contact vcard over-fetch
This round lands a cross-cutting perf pass surfaced by a fresh six-way audit of
the tree (dedup/upload, blob-I/O, DB query-shape, HTTP/DAV emitters, auth/global
config, frontend), cross-referenced against everything ROUND2–24 already shipped
so nothing here re-treads landed work. Five items ship, each behind a
BEFORE/AFTER benchmark that std::process::exit(1)s ("GATE FAIL … rollback")
unless AFTER strictly beats BEFORE — the round's roll-back rule encoded into the
benchmark, so an AFTER that doesn't win is never applied to the source.
Reproduce:
# M1–M3 — counting global allocator (count + BYTES), no Postgres
RUSTFLAGS="-C target-cpu=x86-64-v3" \
cargo run --release --features bench --example bench_round25_micro
# Q1–Q2 — live dev Postgres (reads DATABASE_URL from .env)
RUSTFLAGS="-C target-cpu=x86-64-v3" \
cargo run --release --features bench --example bench_round25_queries
The two headline items match the owner's top priorities: M1 halves peak RAM on every encrypted blob read, and Q1 collapses the public-playlist gallery from 101 DB round-trips to 1.
[M1] EncryptedBlobBackend::decrypt_bytes — full ciphertext copy → in-place detached decrypt (RAM)
decrypt_bytes claimed in its own doc comment to decrypt "in place … the
ciphertext buffer is reused for the plaintext instead of allocating a second
copy." It did not:
let mut ciphertext = encrypted.split_off(NONCE_SIZE); // allocates + memcpy's the whole tail
Vec::split_off(12) allocates a fresh Vec sized len-12 and ptr::copys the
entire ciphertext+tag into it — so every decrypted CDC chunk (≤ 1 MiB), and
every legacy whole-file blob, paid one full-payload allocation + memcpy on read.
ROUND11 §15 fixed the encrypt side (encrypt_in_place_detached) but the
decrypt side was never given the same treatment; the stale doc comment is the
tell that it was believed already done.
AFTER lifts the 12-byte nonce and 16-byte GCM tag to the stack, decrypts the
middle in place via decrypt_in_place_detached (the detached API already used by
the encrypt side), and returns a zero-copy Bytes::slice past the nonce — no
extra allocation, no full-payload copy. Plaintext bytes are identical.
| arm | allocs/op | bytes/op | note |
|---|---|---|---|
| BEFORE | 3.00 | 524 356 | input clone + split_off copy + Bytes::from |
| AFTER | 2.00 | 262 196 | input clone + Bytes::from only |
−262 160 bytes/op at a 256 KiB payload — the copied ciphertext eliminated;
peak heap on a decrypt drops from ~2× to ~1× the payload. The win scales with
payload, so a legacy whole-file blob read no longer transiently doubles a
multi-hundred-MB allocation. Gate: AFTER bytes/op strictly lower (it is). The
equivalence arm asserts the decrypted plaintext is byte-identical to the
split_off path across the short-input edge, 64 KiB and 1 MiB.
[M2] Delta commit — third per-occurrence hash clone → move-unzip (dedup allocations)
delta_upload_service::commit_with_perms owns request: DeltaCommitRequest, yet
materialized the per-occurrence chunk-hash list a third time at the manifest
bind (after the distinct set and the verification tuple):
let chunk_hashes: Vec<String> = request.chunks.iter().map(|c| c.h.clone()).collect();
let chunk_sizes: Vec<u64> = request.chunks.iter().map(|c| c.s).collect();
request.chunks is dead after this line (only request.file_hash is read
below), so AFTER moves the hashes out instead of cloning each 64-char hash:
let (chunk_hashes, chunk_sizes): (Vec<String>, Vec<u64>) =
request.chunks.into_iter().map(|c| (c.h, c.s)).unzip();
| arm | allocs/op (4000 chunks) | bytes/op |
|---|---|---|
| BEFORE | 8 003.00 | 768 000 |
| AFTER | 4 003.00 | 512 000 |
−4000 allocs/op (the N hash-String clones) on the flagship "upload only what
changed" path. Gate: AFTER allocs/op strictly lower. Equivalence: the produced
(chunk_hashes, chunk_sizes) are element-equal to the clone-collect arms.
[M3] folder_handler::download_folder_zip — dead Query<HashMap> extractor removed (allocations)
Both the route wrapper and download_folder_zip_impl bound
Query<HashMap<String,String>> as _params and discarded it — the handler reads
only the path id. axum's Query extractor parses the whole query string into a
HashMap plus an owned String key and value per param, all dropped unread. AFTER
deletes the extractor; axum ignores any query string when none is present, so the
response is byte-identical.
| arm | ns/op | allocs/op | bytes/op |
|---|---|---|---|
| BEFORE | 212.1 | 5.00 | 268 |
| AFTER | 0.3 | 0.00 | 0 |
Pure dead-work elimination (614× wall, 5 → 0 allocs) whenever a client appends any query string (cache-buster, tracking param). Gate: AFTER allocs/op strictly lower.
[Q1] Public-playlist listing — 1 + N COUNT(*) → one LEFT JOIN … GROUP BY (DB round-trips)
MusicStorageAdapter::list_public_playlists ran one listing SELECT then one
SELECT COUNT(*) FROM audio.playlist_items WHERE playlist_id = $1 per returned
playlist — up to 101 serial round-trips for a limit=100 gallery page.
AFTER folds the count into the listing with a single
LEFT JOIN audio.playlist_items … GROUP BY p.id, exposed as a new inherent
PlaylistPgRepository::list_public_playlists_with_counts returning
(Playlist, track_count) — backed by the existing
idx_playlist_items_playlist_id. (The adapter holds the concrete repo type, so
no trait change was needed; the two sibling 1+N adapter methods have no live
caller and are left untouched.)
Live Postgres, 100 public playlists (varying track counts), p50 over 30 passes:
| arm | p50 ms | round-trips |
|---|---|---|
| BEFORE | 16.572 | 101 |
| AFTER | 0.458 | 1 |
36.2× wall, 101 → 1 round-trips. Equivalence: the (playlist → track_count)
map is identical BEFORE vs AFTER (asserted; mismatch exit(1)s). Gate: AFTER p50
strictly lower. On a remote/managed Postgres, where each round-trip is a network
RTT rather than a local socket hop, the win is far larger than the localhost 36×.
[Q2] Contact REST listings — stop over-fetching the multi-KB vcard TEXT (bandwidth)
get_contacts_by_address_book_paginated, search_contacts and
get_contacts_by_group all SELECT … vcard … — the full serialized vCard TEXT,
the largest column (can embed a base64 PHOTO of tens of KB). But every caller
maps Contact → ContactDto, which has no vcard field, so it is fetched,
shipped over the wire, decoded into a String and immediately dropped. AFTER
adds a row_to_contact_lite mapper (shared row_to_contact_with_vcard core, no
duplication) that supplies an empty vcard, and narrows those three SELECTs to
omit the column. The shared get_contacts_by_address_book (also used by the
whole-book vCard export) and the CardDAV sync/multiget paths keep the column.
Live Postgres, 1000 contacts each carrying an 8 KiB vCard, p50 over 20 passes:
| arm | p50 ms | note |
|---|---|---|
| BEFORE | 10.010 | SELECT incl. vcard, decoded + dropped |
| AFTER | 1.570 | SELECT without vcard |
6.4× wall — and the win is bytes-on-the-wire + per-row String allocation,
both of which grow with vCard size (photos push these to tens of KB each).
Equivalence: the kept DTO fields (id, full_name, photo_url) are identical
across the change (asserted). Gate: AFTER p50 strictly lower.
Not shipped — verified this round, deferred to a later pass
The six-way audit surfaced far more than shipped here; the following were verified real against current source and carry a benchmark plan, but each needs a multi-signature change, a remote-backend fixture, an operator-facing decision, or its own validated pass. Grouped by area for the next rounds.
Blob-I/O / disk (owner priority)
CachedBlobBackend::initializenever pre-creates the 256 shard dirs (the line-122 comment says it does; it only makescache_dir), so all three cache writes pay a per-chunkcreate_dir_all(parent)— a wastedmkdirat(EEXIST)+ component stat + blocking-pool dispatch on cached-remote deployments. Fix mirrorsLocalBlobBackend::initialize'sHEX_PREFIXESloop; gate on astrace -cmkdiratcount + wall on tmpfs. (conf 0.9)- Eviction listener unlinks with a blocking
std::fs::remove_fileon the tokio worker (cached_blob_backend.rs:98) —moka::syncruns the listener inline on the inserting worker; every write-through eviction blocks a reactor thread onunlink(2). Hand off viaspawn_blocking/a drain task; gate on p99 scheduling delay under eviction pressure. (conf 0.85) - S3 reads copy every served byte through
into_async_read()+ReaderStream(s3_blob_backend.rs:261/298) while Azure already forwards SDKBytesframes zero-copy — needs a MinIO/stub fixture to gate. (conf 0.6) store_loose_chunkswrites loose chunks to the backend serially while the main ingest overlaps 8 (buffer_unordered); on a remote backend the delta path serializes RTTs the main path hides. Needs a latency-stub backend. (conf 0.5)local_blob_pathdoes a synchronouspath.exists()stat on the reactor — wants an async port variant. (conf 0.6)PLAINTEXT_EMIT_SIZE= 64 KiB vs the 256 KiB every other backend streams — quarters the encrypted-read frame count; the "parity" comment justifying 64 KiB is factually wrong. Wants a streaming A/B (frame count + wall). (conf 0.5)
DB query-shape
- Drive-policy reads decode through a throwaway
serde_json::ValueDOM (drive_pg_repository.rs4 methods) — ROUND23 §J2 removed only the clone, not the DOM; fold tosqlx::types::Json<DrivePolicies>like §J1. Fires on move/copy and every share/grant create. (conf 0.75) - Contact create/update build a throwaway
Valuebefore binding JSONB (the write-side twin of ROUND23 §J1) — bindsqlx::types::Json(&dtos)directly. (conf 0.6)
Dedup / upload
attach_manifestreshapes chunk sizes into a throwawayVec<i64>per upload — carry sizes asi64end-to-end (validated in an earlier draft ofbench_round25_micro§M4; deferred because it threads a type change throughChunkIngestOutcome, the delta/stream/legacy paths and thetotal_sizesums — its own pass). (conf 0.5)store_from_streamrebuilds the distinct-hash set the CDC loop already held aspinned ∪ written(dedup_service.rs:499/distinct_hashes) — return the list the ingest already owns instead of an O(N) rescan + HashSet + N clones. ROUND14 deferred. (conf 0.55)- Whole-file dedup-hit fast path is 3 serial manifest round-trips (owner-check +
metadata SELECT + ref-bump UPDATE) — fold metadata+bump into one
UPDATE … RETURNING(3→2), or the whole thing into one atomic statement (3→1, also closes a TOCTOU). Authz-sensitive; needs a validated pass. (conf 0.55) - Ownership checks bind the caller UUID as text (
to_string()+$2::uuid) instead of a nativeUuid, unlike the sibling claimable/pin queries. (conf 0.5) - Delta-download authorize is 2 round-trips (entitlement then sizes) foldable into one entitlement-JOIN-blobs query. (conf 0.55)
HTTP / DAV emitters (allocations)
format_oc_idallocates a freshStringper NC PROPFIND/REPORT/trashbin row — thread aformat_oc_id_into(&mut String, …)buffer like the href buffer already in those loops. Multi-signature; ROUND20 deferred. (conf 0.85)search_service::suggest_with_permsbuilds a fullFileDto/FolderDtoper candidate to read 5 fields, computing (and dropping)etag+size_formattedStrings on every keystroke. (conf 0.75)- CardDAV whole-book GET accumulates a throwaway per-contact vCard
Stringinto an unsized buffer — wants awrite_vcard_into(&mut String, …). (conf 0.7) - NC REPORT/trashbin per-row href + NC avatar
HeaderMapclone +list_files_queryQuery<HashMap>— the remaining H1/href-buffer items ROUND22 left. (conf 0.55–0.65)
Auth / global config
foldhashis already in the lockfile transitively (via hashbrown), so the long-deferred fast-hasher lead is nearly free:foldhash::quality::RandomState(random-seeded, DoS-safe) for the attacker-controlled delta-upload hash sets, andfoldhash::fastfor the trusted-key NC PROPFINDfavorite_ids/nc_idmaps. Wall-gated (a hasher swap changes 0 allocations). (conf 0.7)tracinghas norelease_max_levelfeature — per-requestdebug!s in the auth middleware and the authzrequire()granted path compile into release and pay a runtime level check.release_max_level_infocompiles them out (binary-size- hot-path win) but silently disables
RUST_LOG=debugon release builds — an operator-facing tradeoff that wants a maintainer decision, so it is flagged, not shipped. (conf 0.65)
- hot-path win) but silently disables
profile.releaseuseslto = "thin"whileprofile.benchalready trustslto = "fat"— a last-slice hot-path + size win at the cost of link time. (conf 0.55)panic = "abort"— VERIFIED UNSAFE, do not apply.text_extractor.rs:177relies oncatch_unwindto survivepdf-extractpanics on malformed PDFs, and tokio's per-task panic isolation itself needs unwinding; under abort a single hostile PDF (or any handler.unwrap()) becomes a whole-process crash. Keeppanic = "unwind". (Recorded so a future pass doesn't re-open it.) (conf 0.9)
Frontend
- Client folder-listing cache (
getCachedFolder/cacheFolder+ ETag) is dead code — never called; every folder navigation refetches the full body withcache:'no-store'and noIf-None-Match. Wire the SWR cache in (bandwidth + instant paint on revisits). (conf 0.6) - Grouped listing views mount one
VirtualWindowper section — O(sections) scroll listeners +getBoundingClientRectreads per scroll frame; hoist to one shared tracker (the deferred "unify onto VirtualRows"). (conf 0.6) VirtualRows.offsetsprefix-sum, flat dotfile filter O(N²),typeLabelper-call 13-entry object — the residual per-page frontend rebuilds. (conf 0.5–0.65)
Environment / methodology
- M1–M3: counting global allocator tracking BOTH alloc count and bytes
(
examples/bench_round25_micro.rs), no Postgres. Each section is BEFORE (verbatim replica of the shipped-before shape) vs AFTER (replica of the shipped-after shape, which the source now matches), with a value-equivalence assertion and aGATE FAIL … rollbackexit(1)if the AFTER arm fails to beat BEFORE on its gate metric (M1 gates on bytes/op — the RAM win; M2/M3 on allocs/op). Tunables:M1_ITERS(2000),PAYLOAD(262144),CHUNKS(4000),BENCH_ITERS(200000). - Q1–Q2: live dev PostgreSQL 16 (schema from
migrations/), readsDATABASE_URLfrom.env. Each section seeds its own fixture (bench25_*/bench25-*markers, torn down around the run), asserts an equivalence gate (result set identical BEFORE vs AFTER — mismatchexit(1)s), and gates on p50 wall strictly decreasing. Q1'splaylist_items.file_idFK is bypassed during seeding withsession_replication_role = replica(superuser) purely to isolate the query shape without astorage.filesfixture. Tunables:Q1_PLAYLISTS(100),Q1_PASSES(30),Q2_CONTACTS(1000),Q2_PASSES(20),Q2_VCARD_KB(8). - Built with
RUSTFLAGS="-C target-cpu=x86-64-v3"(the checked-in.cargo/config.tomlpinstarget-cpu=native, whichSIGILLs on this session's host under AVX-512 — see ROUND23/24; local build-flag override only, the config is unchanged). - Verified beyond the benches:
cargo fmt --all --checkclean,cargo clippy --features bench -- -D warningsclean, and the contact / playlist / encrypted-backend / delta-upload unit tests pass.