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).
|
|||
|
|
```
|