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
15 KiB
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, NCpreview, sharedownload/access): swapheaders: HeaderMapforreq: Requestand readreq.headers().get(…)at the (single) use site. - Wrapper/
_implhandlers (get_thumbnail,download_file,list_files_query): the wrapper takesreq: Requestand passesreq.headers()into an_implwhose param becomesheaders: &HeaderMap. The_implreturn type gets+ use<>so the 2024-editionimpl Traitlifetime capture doesn't tie the (owned)Responseoutput 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):
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 escapeCow; 1 → 0 alloc/event).write_calendar_standard_props/write_calendar_requested_props— per calendar, which additionallyformat!-ed the value (2 → 0).
The etag bodies are bare Uuids (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:
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:
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_queryQuery<HashMap<String,String>>→ typedQuery<…>: the listing reads onlyfolder_id, so astruct ListFilesQuery { folder_id: Option<String> }drops theHashMaptable + the"folder_id"keyString(~3 → 1 allocs). Byte-identical for the frontend's actual usage, but a malformed?folder_id=a&folder_id=bdiverges (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
avatarHeaderMap clone:handle_avatarhas two callers (handle_dav_avatar+ a direct route), so theRequestconversion 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/… atshare_handler.rs:583+): these take aJsonbody (aFromRequestbody extractor), so a secondRequestextractor is impossible — they need a different borrow strategy. Lower frequency than the public download/access path shipped here. update_all_day/update_time_rangeall-day%Y%m%dstamp: no no-separator date helper exists (compact_ical_utcis date+time,compact_dateisYYYY-MM-DD); acompact_date_basic(YYYYMMDD) would close the remaining 2 all-day sites. Low heat.ContactService::generate_vcardBDAY (contact_service.rs:342) still usesbirthday.format("%Y%m%d")on the contact write path — same missing%Y%m%dhelper 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 thepathparameter 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 withGATE FAIL … rollbackif an AFTER arm fails to reduce allocations. - Verified beyond the bench:
cargo clippy --features bench --all-targets -D warningsclean,cargo fmt --all --checkclean, andcargo test --lib --features bench= 529 passed / 0 failed (incl. the OpenAPI-spec-validity test that guards the H1 utoipa-handler signature change).