992bdae898
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
217 lines
15 KiB
Markdown
217 lines
15 KiB
Markdown
# 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 `"` → `"`, re-allocating an owned `Cow`. Now emits the two quotes as borrowed pre-escaped `"` 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 `"` → `"` and re-allocates an owned
|
||
`Cow`. Now (the ROUND20 §C1 / ROUND21 §R4 shape):
|
||
|
||
```rust
|
||
xml_writer.write_event(Event::Text(BytesText::from_escaped(""")))?; // borrowed
|
||
xml_writer.write_event(Event::Text(BytesText::new(etag)))?; // escaped body
|
||
xml_writer.write_event(Event::Text(BytesText::from_escaped(""")))?;
|
||
```
|
||
|
||
`escape` maps `"`→`"` per char, so `"{escape(etag)}"` 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).
|
||
```
|