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

15 KiB
Raw Blame History

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):

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