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

270 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```rust
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:
```rust
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:
```rust
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:
```rust
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`:
```rust
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:
```rust
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()`:
```rust
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:
```rust
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`:
```rust
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.