Files
Oxicloud/benches/ROUND22.md
T
Claude 992bdae898 perf: round 22 — hot-GET HeaderMap borrow, native-WebDAV/CalDAV etag borrowed quotes, FileDto content_hash move, CalendarEvent stamp, ShareItemType case-fold
Benchmark-gated, same rule as ROUND2-21: every change ships with a
BEFORE/AFTER counting-allocator benchmark and a byte/-value equivalence
gate; an AFTER that fails to reduce allocations exits non-zero (rollback).
See benches/ROUND22.md and examples/bench_round22_micro.rs. All arms
no-Postgres.

- H1: the hot GET handlers (get_thumbnail, download_file, list_files_query,
  list_photos, NextCloud preview, public-share download/access) take
  `req: Request` last and read `req.headers()` by borrow instead of axum's
  HeaderMap extractor, whose FromRequestParts impl clones the whole request
  header table just to read 1-3 headers (the ROUND14 §A4 middleware pattern,
  finally propagated to the handlers). 2 -> 0 allocs/req · 9.95x wall.
- W1: native WebDAV write_etag_quoted — the etag emitter for every /webdav/
  PROPFIND row (per file AND per folder, up to 500/page) — emits the quotes
  as borrowed pre-escaped " text events instead of escaping a "{etag}"
  String (the ROUND20 §C1 / ROUND21 §R4 pattern). 3 -> 0 allocs/row.
- C1: CalDAV getetag routed through a shared write_quoted_etag helper across
  all 5 sites (3 per-event + 2 per-calendar); the now-dead etag: &mut String
  buffer threaded through write_event_response/standard/requested props + the
  two per-page buffers removed. 2 -> 0 allocs/row.
- D1: FileDto::from reuses the moved parts.blob_hash instead of cloning it
  via the content_hash() getter (the ROUND19/20 move-not-clone sweep missed
  it — hash/etag are read before into_parts()). Per file row of every
  listing. 1 -> 0 allocs/row.
- E1: CalendarEvent::update_time_range/update_all_day stamp timed
  DTSTART/DTEND via fmt::compact_ical_utc stack render (chrono fallback out
  of range) instead of the %Y%m%dT%H%M%SZ strftime interpreter. 4 -> 0.
- S1: ShareItemType::try_from uses eq_ignore_ascii_case instead of a
  throwaway to_lowercase() String. 1 -> 0 allocs/parse.

Verified: cargo clippy --features bench --all-targets -D warnings clean,
cargo fmt --all --check clean, cargo test --lib --features bench = 529
passed / 0 failed (incl. the OpenAPI-spec-validity test guarding the H1
utoipa-handler signature change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKyQ4AnYtgp1JtjzweyMeo
2026-07-20 13:48:47 +00:00

217 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 22 — hot-GET HeaderMap borrow, native-WebDAV & CalDAV etag borrowed quotes, FileDto content_hash move, CalendarEvent stamp, ShareItemType case-fold
Benchmark-gated, same rule as ROUND2–21: every change ships with a BEFORE/AFTER
benchmark and a byte/-value equivalence gate; an AFTER that doesn't beat its
BEFORE is rolled back (never applied). The roll-back rule is encoded directly in
the harness — a `GATE FAIL … rollback` non-zero exit if an AFTER arm fails to
reduce allocations — so a regression fails CI rather than shipping.
This round drains the two biggest items the ROUND21 audit explicitly deferred —
the hot-GET-handler `HeaderMap` extractor clone and the two DAV etag emitters the
borrowed-pre-escaped-quote sweep never reached (native WebDAV + CalDAV) — plus
the `FileDto::from` `content_hash` clone the ROUND19/20 move-not-clone sweep
missed (it is computed *before* `into_parts()`), and two low-heat strftime /
case-fold cuts.
Reproduce:
```
cargo run --release --features bench --example bench_round22_micro
```
All arms are **no-Postgres** (release-profile counting-allocator example).
## Summary
| # | change | key metric | before → after |
|--:|---|---|---|
| **H1** | The hot GET handlers (`get_thumbnail`, `download_file`, `list_files_query`, `list_photos`, NextCloud `preview`, public-share `download`/`access`) took axum's `HeaderMap` extractor, whose `FromRequestParts` impl does `parts.headers.clone()` — an owned clone of the **whole** request header table — purely to read 1–3 headers (`If-None-Match` / `Accept` / `Range` / unlock cookie). Now they take `req: Request` last and read `req.headers()` by borrow (the ROUND14 §A4 middleware pattern, finally propagated to the handlers). The 3 wrapper/`_impl` file handlers pass `req.headers()` into an `_impl` that now takes `&HeaderMap` (`+ use<>` on the return so the 2024-edition `impl Trait` capture doesn't tie the owned `Response` to the borrow). | realistic 13-header req | **2 → 0 allocs/op · 9.95× wall** |
| **W1** | `webdav_adapter::write_etag_quoted` — the etag emitter for **every** native `/webdav/` PROPFIND row (per file AND per folder, up to `PROPFIND_BATCH_SIZE`=500/page — the most-travelled DAV path) — built a sized `"{etag}"` `String` then wrote it auto-escaped; `quick_xml` escapes the `"` → `&quot;`, re-allocating an owned `Cow`. Now emits the two quotes as borrowed pre-escaped `&quot;` text events around the escaped body (the ROUND20 §C1 / ROUND21 §R4 pattern the native adapter never got). Byte-identical for any etag. | per PROPFIND row | **3 → 0 allocs/op · 1.59× wall** |
| **C1** | The CalDAV `getetag` emit — per event of every calendar-query/multiget/sync REPORT + depth-1 collection PROPFIND (the DAVx5/Apple/Thunderbird sync path), and per calendar of the home-set PROPFIND — still escaped a `"…"` value: the event sites paid the escape `Cow` over the ROUND14 reused buffer (1 alloc/event); the two calendar sites `format!`-ed as well (2 allocs). All **five** sites now route through a new `write_quoted_etag` helper (the CardDAV twin), and the now-dead `etag: &mut String` buffer threaded through `write_event_response`/`write_event_standard_props`/`write_event_requested_props` + the two page buffers are dropped. | per event row | **2 → 0 allocs/op · 1.63× wall** |
| **D1** | `FileDto::from` computed `content_hash = file.content_hash().to_string()` (a clone of `blob_hash`) and then `into_parts()` **moved** that same `blob_hash` into `parts.blob_hash`, which was dropped unused in the `Self { … }` ctor. The ROUND19/20 move-not-clone sweep fixed id/name/path/folder_id but missed this one because the etag/hash are read *before* `into_parts()`. Now `content_hash: parts.blob_hash` reuses the moved `String`; `etag` still computes first from the live entity. Runs **per file row on every listing** (folder browse, streaming PROPFIND, search/favorites/recent hydration). | per file row | **1 → 0 allocs/op · 2.91× wall** |
| **E1** | `CalendarEvent::update_time_range` / `update_all_day` stamped **timed** DTSTART/DTEND via `format!("{}", t.format("%Y%m%dT%H%M%SZ"))` — chrono's strftime `DelayedFormat` interpreter. Now stack-renders via the shipped `fmt::compact_ical_utc` and passes the `&str` straight to `update_ical_property`, with the chrono `format!` kept as the out-of-range fallback and the all-day `%Y%m%d` form untouched. Per event-edit PUT. | per timed stamp | **4 → 0 allocs/op · 14.49× wall** |
| **S1** | `ShareItemType::try_from` matched `s.to_lowercase().as_str()` — a throwaway Unicode-lowercased `String` — against the two ASCII literals `"file"`/`"folder"`. Now `s.eq_ignore_ascii_case("file")` / `("folder")`: byte-identical acceptance for the ASCII targets, no allocation. | per parse | **1 → 0 allocs/op · 7.81× wall** |
> Allocs/op is the deterministic primary gate (identical run to run). Wall
> figures are single-shot and noise-bounded. Every section carries a
> byte/-value equivalence gate; the shipped source now matches each AFTER arm.
## [H1] Hot GET handler `HeaderMap` extractor → `Request` + borrow
axum 0.8's `impl FromRequestParts for HeaderMap` is literally
`Ok(parts.headers.clone())` — cloning the whole request header table (its
`entries` + `indices` backing vectors; the counting allocator measures exactly
2 allocs on a realistic 13-header browser request). The handlers below read only
1–3 headers out of it, so the clone is pure waste — the exact cost
`middleware/auth.rs` removed in ROUND14 §A4 (`request.headers().get(…)` by
borrow) but which was never propagated to the handlers.
The fix takes `req: Request` as the **last** extractor (all the others —
`State`, `AuthUser`, `Path`, `Query` — are `FromRequestParts`, so they coexist
with a single trailing `FromRequest`), and reads `req.headers()` by borrow:
- **Standalone handlers** (`list_photos`, NC `preview`, share `download`/`access`):
swap `headers: HeaderMap` for `req: Request` and read `req.headers().get(…)`
at the (single) use site.
- **Wrapper/`_impl` handlers** (`get_thumbnail`, `download_file`,
`list_files_query`): the wrapper takes `req: Request` and passes
`req.headers()` into an `_impl` whose param becomes `headers: &HeaderMap`. The
`_impl` return type gets `+ use<>` so the 2024-edition `impl Trait` lifetime
capture doesn't tie the (owned) `Response` output to the header borrow — the
future still borrows the headers during its inline `.await`, but the response
it yields captures nothing.
Byte-identical: every call site reads the same header by `.get()`. The
`openapi_spec_is_valid_and_has_expected_structure` test confirms the
utoipa-annotated handlers still emit a valid spec after the signature change.
NextCloud `avatar` (dual caller `handle_dav_avatar` → `handle_avatar` + dual
route) and the share-management handlers (`create`/`update`/… take a `Json`
body, so no second `Request` extractor is possible) were left for a dedicated
pass — see *Not shipped*.
## [W1] Native WebDAV `getetag` — borrowed pre-escaped quotes
`write_etag_quoted` is the single helper behind all four native PROPFIND etag
sites (`webdav_adapter.rs:857/935/1004/1089` — file + folder, allprop + named).
It built a `String::with_capacity(etag.len()+2)` `"{etag}"` and wrote it via
`BytesText::new`, which escapes the `"` → `&quot;` and re-allocates an owned
`Cow`. Now (the ROUND20 §C1 / ROUND21 §R4 shape):
```rust
xml_writer.write_event(Event::Text(BytesText::from_escaped("&quot;")))?; // borrowed
xml_writer.write_event(Event::Text(BytesText::new(etag)))?; // escaped body
xml_writer.write_event(Event::Text(BytesText::from_escaped("&quot;")))?;
```
`escape` maps `"`→`&quot;` per char, so `&quot;{escape(etag)}&quot;` is
byte-identical to escaping `"{etag}"` for **any** etag (the equivalence gate
asserts it, including an etag carrying `&`/`<`/`"`). One helper body fixes all
four call sites — 0 allocs/row on the hottest native-WebDAV path.
## [C1] CalDAV `getetag` — shared `write_quoted_etag` helper (5 sites)
The CalDAV adapter was the last DAV emitter still escaping a quoted etag value.
A new file-local `write_quoted_etag` (identical to the shipped CardDAV twin)
replaces the manual quote-and-escape at all five sites:
- `write_event_standard_props` / `write_event_requested_props` /
`write_collection_event_page` — **per event bundle** (the reused ROUND14
buffer was already amortized, so the remaining cost was the escape `Cow`;
1 → 0 alloc/event).
- `write_calendar_standard_props` / `write_calendar_requested_props` — **per
calendar**, which additionally `format!`-ed the value (2 → 0).
The etag bodies are bare `Uuid`s (`anchor.id` / `calendar.id`), so
`BytesText::new(id)` is itself a borrow (0 allocs). With the emit no longer
needing a scratch `String`, the `etag: &mut String` buffer threaded through
`write_event_response` → `write_event_standard_props` /
`write_event_requested_props` and the two per-page `String::new()` buffers were
removed. The 34 caldav-adapter unit tests (PROPFIND/REPORT output) pass
unchanged.
## [D1] `FileDto::from` — reuse the moved `blob_hash`, don't clone it
The per-row DTO builder computed the ETag and the content hash from the live
entity, then consumed it:
```rust
let etag = file.etag();
let content_hash = file.content_hash().to_string(); // clone of self.blob_hash
let parts = file.into_parts(); // MOVES self.blob_hash → parts.blob_hash
// … Self { …, content_hash, etag, … } // parts.blob_hash dropped unused
```
`etag` genuinely must run against the live entity (it borrows `blob_hash` +
`modified_at`), but `content_hash` is just the raw hash — and `into_parts()`
already hands it over by ownership. Now `content_hash: parts.blob_hash` reuses
that `String`; the getter clone (one 64-byte hex `String` per row) is gone. This
is the file-side twin of the fields `FolderDto::from` already moves, on the
single most-travelled API path. Byte-identical: `parts.blob_hash` **is** the
`String` the getter cloned.
## [E1] `CalendarEvent` timed DTSTART/DTEND — `compact_ical_utc` stack render
The timed branches of `update_time_range` / `update_all_day` stamped
`format!("{}", t.format("%Y%m%dT%H%M%SZ"))`, running chrono's strftime
interpreter (4 allocs measured). `fmt::compact_ical_utc` already renders exactly
`YYYYMMDDTHHMMSSZ` on the stack (the ROUND19 §V2 helper), and the property
setter takes a `&str`, so the render is passed straight through with no owned
`String`:
```rust
let start_str: &str = if self.all_day {
start_owned = format!("{}T000000Z", start_time.format("%Y%m%d")); &start_owned
} else if let Some(s) = fmt::compact_ical_utc(&mut sbuf, start_time.timestamp()) {
s // 0 allocs, the common case
} else {
start_owned = format!("{}", start_time.format("%Y%m%dT%H%M%SZ")); &start_owned // fallback
};
```
The all-day `%Y%m%d` + literal-suffix form is unchanged (no existing
no-separator helper covers it — see *Not shipped*). The 20 calendar_event unit
tests (iCal round-trip, exception handling) pass unchanged.
## [S1] `ShareItemType::try_from` — `eq_ignore_ascii_case`
`match s.to_lowercase().as_str()` allocated a Unicode-lowercased `String` per
call only to compare against `"file"`/`"folder"`. `eq_ignore_ascii_case` folds
only ASCII A–Z — but the targets are pure ASCII, and any input whose
`to_lowercase()` equals `"file"`/`"folder"` is by definition an ASCII case
variant of it, so acceptance is byte-identical (the gate checks mixed-case +
invalid inputs). 0 allocs.
## Not shipped — deferred to a later round
Surfaced by the Round-22 audit (three parallel sub-audits across the HTTP, DAV
and application/parse layers), verified against current source, but held back —
each needs a signature/API decision or a gate the deterministic alloc-counter
can't provide:
- **`list_files_query` `Query<HashMap<String,String>>` → typed `Query<…>`**: the
listing reads only `folder_id`, so a `struct ListFilesQuery { folder_id:
Option<String> }` drops the `HashMap` table + the `"folder_id"` key `String`
(~3 → 1 allocs). Byte-identical for the frontend's actual usage, but a
**malformed** `?folder_id=a&folder_id=b` diverges (HashMap last-wins vs serde
field-decode), so it wants its own byte-identity proof before shipping — the
H1 half of this handler is unimpeachable and shipped alone.
- **NextCloud `avatar` HeaderMap clone**: `handle_avatar` has two callers
(`handle_dav_avatar` + a direct route), so the `Request` conversion is a
dual-signature change, not the clean leaf swap the other H1 handlers were.
Low frequency (avatars revalidate hourly).
- **Share-management HeaderMap clones** (`create`/`update`/`verify`/… at
`share_handler.rs:583+`): these take a `Json` body (a `FromRequest` body
extractor), so a second `Request` extractor is impossible — they need a
different borrow strategy. Lower frequency than the public download/access
path shipped here.
- **`update_all_day` / `update_time_range` all-day `%Y%m%d` stamp**: no
no-separator date helper exists (`compact_ical_utc` is date+time,
`compact_date` is `YYYY-MM-DD`); a `compact_date_basic` (`YYYYMMDD`) would
close the remaining 2 all-day sites. Low heat.
- **`ContactService::generate_vcard` BDAY** (`contact_service.rs:342`) still uses
`birthday.format("%Y%m%d")` on the contact write path — same missing
`%Y%m%d` helper as above; the per-contact *read* twin was already fixed
(ROUND21 §R5). Low heat.
- **`extract_webdav_path(req.uri())`** (`webdav_handler.rs:507`): a per-PROPFIND
percent-decode + `String`, but byte-identity is **unproven** — the code
comment states the `path` parameter carries a home-folder prefix that is
wrong for WebDAV hrefs, directly contradicting ROUND21's "stale comment"
note. Needs a dedicated href-equivalence proof, not a perf banner.
## Environment / methodology
- `cargo run --release --features bench --example bench_round22_micro` —
counting global allocator, no Postgres. Tunable (env): `BENCH_ITERS` (200000).
- Each section is BEFORE (verbatim replica of the shipped-before shape) vs AFTER
(verbatim replica of the shipped-after shape, which the source is then made to
match), 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.
- Verified beyond the bench: `cargo clippy --features bench --all-targets -D
warnings` clean, `cargo fmt --all --check` clean, and `cargo test --lib
--features bench` = **529 passed / 0 failed** (incl. the OpenAPI-spec-validity
test that guards the H1 utoipa-handler signature change).
```