Files
Oxicloud/benches/ROUND19.md
T
Claude 9754aecfa9 perf: round 19 — auth/WOPI/vCard/PROPFIND per-request & per-row alloc cuts
Benchmark-gated (examples/bench_round19_micro.rs, benches/ROUND19.md): every
section ships a BEFORE/AFTER counting-allocator arm with a byte/-value
equivalence gate and a GATE-FAIL-rollback exit. All eight pass. No Postgres.

- M1 verify_basic_auth cache key: blake3::hash(format!("{u}:{p}")) → incremental
  Hasher (byte-identical key, 2→0 allocs on every Basic-auth DAV request)
- M2 WopiTokenService: prebuild Validation/DecodingKey/EncodingKey in new()
  instead of per-call (mirrors JwtTokenService; 16→12 allocs/validate)
- V1/V2 vCard emit (contact_to_vcard/generate_vcard): FN fallback drops the
  throwaway to_string, NOTE skips the escape copy for newline-free notes, REV
  uses new common::fmt::compact_ical_utc stack renderer (11.5× vs chrono
  strftime, 3→0 allocs); per-contact 9→4 allocs
- M4 trash_service::row_to_item_dto: move name/path/blob_hash out of the owned
  row instead of cloning (3 clones/file row gone)
- M5 search cache key: Uuid::hyphenated().encode_lower stack buffer instead of
  to_string (identical u64 key, 1→0 allocs/request)
- M6 streaming PROPFIND: reuse one href buffer across the page instead of a
  format! per child (native + NC handlers; 192→3 allocs on a 64-child page)
- M7 nextcloud extract_url_user: return Cow instead of forcing into_owned
  (zero-alloc on the common ASCII-username path)

common::fmt::compact_ical_utc added with chrono-parity unit tests (CASES +
60-year sweep). cargo fmt + clippy --all-targets clean; 526 lib unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ront9bk7YMoffVQkGG47gh
2026-07-19 22:29:48 +00:00

15 KiB
Raw Blame History

Round 19 — auth/WOPI/vCard/PROPFIND per-request & per-row alloc cuts

Benchmark-gated, same rule as ROUND2–18: every change ships with a BEFORE/AFTER benchmark and an equivalence/safety gate; an AFTER that doesn't beat its BEFORE is rolled back (never applied). The roll-back rule is encoded directly into the harness — a GATE FAIL … rollback non-zero exit if an AFTER arm fails to reduce allocations (or, for the CPU-only §V2 stamp, fails to beat BEFORE by the required wall ratio) — so a regression fails CI rather than shipping.

This round sweeps the per-request DAV/WOPI/NextCloud plumbing and two per-row emit loops the earlier rounds' handler passes left untouched. Every item mirrors an optimization the codebase already proved out elsewhere (JwtTokenService's prebuilt keys, common::fmt's stack date renderers, the favorites/recent/folder row mappers' move-not-clone, the CalDAV emitter's reused per-row buffers) but which never reached these specific paths.

Reproduce:

cargo run --release --features bench --example bench_round19_micro

All arms are no-Postgres (release-profile counting-allocator example).

Summary

# change key metric before → after
M1 AppPasswordService::verify_basic_auth built the moka cache key as blake3::hash(format!("{username}:{password}").as_bytes()) — one throwaway String per Basic-auth request (runs before the cache lookup, so even hits pay it; DAV sync clients hammer it on every request). Now streamed into an incremental blake3::Hasher — byte-identical 32-byte key. 20-byte creds 2 → 0 allocs/op · 1.66× wall (182.0 → 109.4 ns)
M2 WopiTokenService::validate_token/generate_token rebuilt a Validation (allocates a required_spec_claims HashSet + algorithms Vec) and a DecodingKey/EncodingKey (copies the secret into a fresh Vec) on every WOPI call — Office/Collabora poll continuously. Now all three are prebuilt struct fields in new() (exactly what JwtTokenService already does). HS256 validate 16 → 12 allocs/op · 1.07× wall
V1 contact_to_vcard/generate_vcard, per contact in every CardDAV REPORT/multiget/PROPFIND-with-address-data: FN fallback dropped the throwaway .to_string() copy of the trim slice; NOTE replace('\n', "\\n") is now guarded (contains('\n')) so a newline-free note writes borrowed; REV .format("%Y%m%dT%H%M%SZ") → common::fmt::compact_ical_utc. full vCard emit 9 → 4 allocs/op · 1.97× wall (548.2 → 277.7 ns)
V2 The REV/DTSTAMP stamp isolated: chrono .format("%Y%m%dT%H%M%SZ") runs the strftime interpreter and (measured) allocates 3× per call; the new common::fmt::compact_ical_utc renders YYYYMMDDTHHMMSSZ into a 16-byte stack buffer via the shared push2/push4 LUT. one stamp 3 → 0 allocs/op · 11.77× wall (216.9 → 18.4 ns)
M4 trash_service::row_to_item_dto clone()d name/path/blob_hash out of an owned row that is dropped at fn end — 2 clones/folder row, 3/file row, up to 200 rows//api/trash page. Now moved (the favorites/recent/folder mappers already move these). file row 10 → 7 allocs/op (3 clones gone)
M5 SearchUseCase::search built the cache-key user segment via user_id.to_string() — one heap String per search request to feed a hasher the fn doc even calls "zero-allocation". Now stack-encoded via Uuid::hyphenated().encode_lower(&mut [u8; 36]); byte-identical string ⇒ identical u64 key. 1 request 1 → 0 allocs/op · 1.30× wall
M6 Streaming WebDAV PROPFIND built each child href with a fresh format! per row — up to 500 rows/page, 4 loops across the native + NextCloud handlers, the single most-travelled DAV path. Now one buffer reused across the page (clear + push_str + extend/push_str). 64-child page 192 → 3 allocs/op · 2.74× wall (10.9 → 4.0 µs)
M7 nextcloud::session::extract_url_user forced .into_owned() on the urlencoding::decode Cow on every path-scoped NC DAV request, though a plain-ASCII username decodes to Cow::Borrowed. Now returns the Cow and compares by .as_ref(). ASCII user 1 → 0 allocs/op · 3.11× wall (25.5 → 8.2 ns)

Allocs/op is the deterministic primary gate (identical run to run). Wall figures are single-shot and noise-bounded; §V2 is the one CPU-only arm (both emit the same 0 allocs after the fix is measured against chrono's 3) and is gated on a ≥2× wall ratio — it clears it with 11.8×.

[M1] Basic-auth cache key — incremental hasher

verify_basic_auth runs on every WebDAV/CalDAV/CardDAV/NextCloud request that carries Basic auth — and DAV sync clients (DAVx5, Apple, Thunderbird, the Nextcloud desktop client) send credentials on every request, holding 4–8 parallel connections. The cache key is computed before the single-flight cache lookup, so it runs on hits too:

let cache_key: [u8; 32] =
    blake3::hash(format!("{}:{}", username, password).as_bytes()).into();

The format! heap-allocates one String per request purely to concatenate the two parts before handing the bytes to blake3. blake3 is a streaming hash — feeding username, then ":", then password into an incremental Hasher produces the identical digest with no intermediate buffer:

let cache_key: [u8; 32] = {
    let mut h = blake3::Hasher::new();
    h.update(username.as_bytes());
    h.update(b":");
    h.update(password.as_bytes());
    h.finalize().into()
};

The bench's equivalence gate asserts the two 32-byte keys are identical, so in-flight and cached entries collide exactly as before. 2 → 0 allocs/op, 1.66× wall — and note the format! version's second alloc is the String's grow, both gone.

[M2] WOPI token validate/generate — prebuilt keys

WopiTokenService mirrored none of the prebuilt-key discipline JwtTokenService adopted in an earlier round. Every validate_token (6 WOPI handler entry points — CheckFileInfo, GetFile, PutFile, Lock, …, polled continuously by the Office/Collabora host during an edit session) rebuilt:

let validation = Validation::new(Algorithm::HS256);      // HashSet + Vec
let token_data = decode::<WopiTokenClaims>(
    token,
    &DecodingKey::from_secret(self.secret.as_bytes()),   // fresh Vec copy of the secret
    &validation,
)…

Validation::new inserts "exp" into a fresh required_spec_claims HashSet and allocates an algorithms Vec; DecodingKey::from_secret copies the secret into a new Vec. generate_token did the same with EncodingKey::from_secret. All three are now built once in new() and stored as fields:

pub struct WopiTokenService {
    encoding_key: EncodingKey,
    decoding_key: DecodingKey,
    validation: Validation,
    token_ttl_secs: i64,
}

The secret field is dropped — nothing else read it. 16 → 12 allocs/op on validate (the remaining 12 are the JWT crate's own base64/JSON claim deserialization, paid by both arms). The four removed are exactly the Validation HashSet + its "exp" String + the algorithms Vec + the DecodingKey secret-copy. Existing wopi_token_service unit tests (generate→validate round-trip, wrong-secret reject, read-only) pin the behaviour.

[V1]/[V2] vCard per-contact emit — FN, NOTE, and the REV stamp renderer

contact_to_vcard (carddav_adapter.rs) and its twin generate_vcard (contact_service.rs) emit one vCard per contact in every CardDAV REPORT, addressbook-multiget, and collection PROPFIND that requests address-data (i.e. every real DAVx5 / Apple Contacts / Thunderbird sync). Three per-contact allocations:

  1. FN fallback (full_name absent) built the mandatory FN from the name parts and copied the trimmed slice into a second owned String:

    let fn_name = format!("{} {}", first, last).trim().to_string();
    

    The .to_string() is redundant — write!(vcard, "FN:{}\r\n", fn_name.trim()) writes the borrowed slice straight into the buffer. (The format! is kept: trimming across the join is subtle, and this arm is a fallback; dropping the copy is the unambiguously byte-identical win.)

  2. NOTE ran notes.replace('\n', "\\n") unconditionally — a full copy of the note even when it has no newline (the common case), then formatted into the buffer and dropped. Now guarded: a newline-free note writes its borrowed slice directly; only a genuine multi-line note pays the escaping copy.

  3. REV ran chrono's updated_at.format("%Y%m%dT%H%M%SZ") — and §V2 shows that DelayedFormat allocates 3× (not the 0 first assumed) while running the strftime spec interpreter. The new common::fmt::compact_ical_utc(buf, secs) renders the compact iCal/vCard UTC form YYYYMMDDTHHMMSSZ into a 16-byte stack buffer via the same push2/push4 LUT the RFC-3339/2822 renderers use, falling back to chrono for out-of-range seconds.

Isolated (§V2), the stamp renderer is 11.77× faster and 3 → 0 allocs (216.9 → 18.4 ns). Over the whole per-contact emit (§V1, a contact exercising all three shapes) that is 9 → 4 allocs/op, 1.97× wall (548.2 → 277.7 ns). Both updated_at fields are DateTime<Utc>, so compact_ical_utc(ts.timestamp()) is byte-for-byte the chrono output; common::fmt's existing chrono-parity sweep (every 6h13m across 60 years) now covers compact_ical_utc too.

[M4] trash row → DTO — move, don't clone

row_to_item_dto takes an owned TrashResourceRow (consumed, dropped at fn end) yet cloned its String fields into the DTO — path and name on a folder row, plus blob_hash and name on a file row — up to 200 rows per GET /api/trash/resources page:

let path = row.path.clone().unwrap_or_default();
…
name: row.name.clone(),
…
let content_hash = row.blob_hash.clone().unwrap_or_default();

Because row is owned, each field can be moved (row.path.unwrap_or_default(), name: row.name, row.blob_hash.unwrap_or_default()). This is precisely what the sibling favorites_handler / recent_handler / folder_handler row mappers already do (with explicit "move it instead of cloning" comments); the trash path was simply missed. 10 → 7 allocs/op on the file branch (the remaining 7 are id.to_string(), the interned display fields, and the File::compute_etag stand-in — all unavoidable).

[M5] search cache key — stack-encode the UUID

SearchUseCase::search's create_cache_key hashes the criteria + a &str user id; the caller fed it user_id.to_string():

let user_id_str = user_id.to_string();                       // heap, per request
let cache_key = Self::create_cache_key(&criteria, &user_id_str);

Uuid::hyphenated().encode_lower(&mut [u8; 36]) writes the identical 36-char lowercase form into a stack buffer, so the hasher sees the same bytes ⇒ the same u64 key — the equivalence gate asserts it — with no allocation. The fn's own doc-comment already claimed "zero-allocation hashing"; this makes it true. 1 → 0 allocs/op.

[M6] streaming PROPFIND per-child href — one reused buffer

The streaming folder PROPFIND is the single most-travelled WebDAV path (every folder listing, every desktop-sync descent). Both the native (webdav_handler.rs) and NextCloud (nextcloud/webdav_handler.rs) handlers built each child's href with a fresh format! per row — 4 loops, each up to PROPFIND_BATCH_SIZE (500) rows/page:

for file in batch.iter() {
    let href = format!("{}{}", base_href, utf8_percent_encode(&file.name, …));
    …
}

One String per child. A single buffer hoisted out of the loop and rebuilt in place (href.clear(); href.push_str(base); href.extend(encode(name));) keeps its capacity across the page — the CalDAV/CardDAV emitters already thread reused href/etag buffers exactly this way. On a 64-child page: 192 → 3 allocs/op, 2.74× wall (10.9 → 4.0 µs); the 3 remaining are the buffer's initial grows to the widest href. The equivalence gate asserts the emitted href set is byte-identical.

[M7] NextCloud extract_url_user — keep the Cow

Every path-scoped NC DAV request (/remote.php/dav/{files,uploads,trashbin}/ {user}/…) cross-checks the URL {user} segment against the session's raw_username. The extractor forced an owned String:

urlencoding::decode(user_seg).ok().map(|s| s.into_owned())

urlencoding::decode returns Cow::Borrowed for a username with no percent-escapes (the overwhelming common case), so .into_owned() allocates a String on every request for nothing. Returning the Cow and comparing url_user.as_ref() != session.raw_username.as_str() is zero-alloc on the common path; only a percent-encoded username owns. 1 → 0 allocs/op, 3.11× wall.

Not shipped — deferred to a later round

Surfaced during the Round-19 audit but not landed (each needs Postgres, a schema/DTO change, or its own decision):

  • CardDAV vCard etag buffer (carddav_adapter::write_contact_response): the quoted getetag allocates a String per contact; the CalDAV emitter threads a reused &mut String etag buffer across the page but CardDAV's write_contacts_report_page never got the equivalent. Wants the buffer threaded through write_contact_response / write_collection_contact_page — a multi-signature change, deferred to keep this round's diff per-item-local.
  • CardDAV whole-book GET buffer (carddav_handler::handle_get): the text/vcard export accumulates into a String::new() (repeated grows) and each contact_to_vcard allocates a per-contact throwaway String copied into it. Wants a write_vcard_into(&mut String, …) variant so the per-contact String disappears — an API addition, deferred.
  • BDAY stamp (%Y-%m-%d / %Y%m%d): a NaiveDate date-only analogue of compact_ical_utc; only fires for contacts-with-birthday, so lower-priority than REV (every contact). A compact_date helper is the natural follow-up.
  • Search suggest DTO over-build (search_service::suggest_with_perms): builds a full FileDto/FolderDto per candidate (≤20) on every keystroke only to copy out 5 fields — size_formatted/content_hash/etag are computed and dropped. Wants the fields pulled off the entity directly; deferred pending a small helper to avoid duplicating the display classifiers.
  • grant_handler shared-with-me deep clone (needs Postgres to bench the full path): each shared item does resource_id.to_string() to key a map and a full DTO .clone().without_hierarchy_info(); a remove-and-move is valid only if summaries hold unique resource ids — verify before applying.

Environment / methodology

  • cargo run --release --features bench --example bench_round19_micro — counting global allocator, no Postgres. Tunable: BENCH_ITERS (200000; §M6 uses a smaller default as each op is a whole 64-child page).
  • Each section is BEFORE (verbatim replica of the shipped-before shape) vs AFTER (the shipped function itself where reachable — common::fmt::compact_ical_utc, push_upper — else a verbatim replica of the shipped-after shape), with a byte/-value equivalence gate; the shipped source now matches each AFTER arm.
  • Roll-back rule encoded per section: the harness std::process::exit(1)s with GATE FAIL … rollback if an AFTER arm fails to reduce allocations (§M1, M2, V1, M4, M5, M6, M7) or, for the CPU-only §V2 stamp, fails to beat BEFORE by ≥2× wall. All eight sections pass.