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
15 KiB
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:
-
FN fallback (
full_nameabsent) built the mandatoryFNfrom the name parts and copied the trimmed slice into a second ownedString: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. (Theformat!is kept: trimming across the join is subtle, and this arm is a fallback; dropping the copy is the unambiguously byte-identical win.) -
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. -
REV ran chrono's
updated_at.format("%Y%m%dT%H%M%SZ")— and §V2 shows thatDelayedFormatallocates 3× (not the 0 first assumed) while running the strftime spec interpreter. The newcommon::fmt::compact_ical_utc(buf, secs)renders the compact iCal/vCard UTC formYYYYMMDDTHHMMSSZinto a 16-byte stack buffer via the samepush2/push4LUT 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 quotedgetetagallocates aStringper contact; the CalDAV emitter threads a reused&mut Stringetag buffer across the page but CardDAV'swrite_contacts_report_pagenever got the equivalent. Wants the buffer threaded throughwrite_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): thetext/vcardexport accumulates into aString::new()(repeated grows) and eachcontact_to_vcardallocates a per-contact throwawayStringcopied into it. Wants awrite_vcard_into(&mut String, …)variant so the per-contact String disappears — an API addition, deferred. - BDAY stamp (
%Y-%m-%d/%Y%m%d): aNaiveDatedate-only analogue ofcompact_ical_utc; only fires for contacts-with-birthday, so lower-priority than REV (every contact). Acompact_datehelper is the natural follow-up. - Search
suggestDTO over-build (search_service::suggest_with_perms): builds a fullFileDto/FolderDtoper candidate (≤20) on every keystroke only to copy out 5 fields —size_formatted/content_hash/etagare computed and dropped. Wants the fields pulled off the entity directly; deferred pending a small helper to avoid duplicating the display classifiers. grant_handlershared-with-me deep clone (needs Postgres to bench the full path): each shared item doesresource_id.to_string()to key a map and a full DTO.clone().without_hierarchy_info(); aremove-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 withGATE FAIL … rollbackif 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.