diff --git a/Cargo.toml b/Cargo.toml index 3e4dec48..0581b5a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -354,6 +354,21 @@ name = "bench_micro_allocs" path = "examples/bench_micro_allocs.rs" required-features = ["bench"] +# Round-21 battery ──────────────────────────────────────────────────────────── + +# Round-21 CPU/alloc micro-pack — CalDAV/CardDAV row-mapper container pre-size +# (the ROUND20 §I1 sibling the calendar/contact repos deferred); dedup +# `settle_batch` hash bind Vec→Vec<&str> borrow; `store_loose_chunks` +# intra-request dedup set keyed on the raw [u8;32] digest + move-on-dup (the +# ROUND17 §D2 pattern applied to the delta-upload sibling); CardDAV `getetag` +# borrowed pre-escaped quotes (the ROUND20 §C1 NextCloud pattern); `BDAY` +# stamp via fmt::compact_date stack render; NC trashbin folder content-type +# Cow::Borrowed. No Postgres. +[[example]] +name = "bench_round21_micro" +path = "examples/bench_round21_micro.rs" +required-features = ["bench"] + # Round-20 battery ──────────────────────────────────────────────────────────── # Round-20 CPU/alloc micro-pack — CalendarEvent `prop_with_params` throwaway diff --git a/benches/ROUND21.md b/benches/ROUND21.md new file mode 100644 index 00000000..5dff696d --- /dev/null +++ b/benches/ROUND21.md @@ -0,0 +1,225 @@ +# Round 21 — CalDAV/CardDAV row-mapper pre-size, dedup hash-bind & digest-key dedup, CardDAV etag & BDAY emit, NC trashbin content-type + +Benchmark-gated, same rule as ROUND2–20: 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 sibling seams the earlier passes explicitly deferred. The +file-listing repositories got their result-`Vec` pre-sizing in ROUND20 §I1, but +the **CalDAV/CardDAV row mappers** (bulk address-book / calendar sync builds +thousands of rows) were left growing from capacity 0. The **streaming ingest** +loop got its `[u8; 32]`-digest dedup key in ROUND17 §D2, but its **delta-upload +sibling** `store_loose_chunks` kept a `HashSet` and a double hex clone +per frame. The **NextCloud** etag emitter got the borrowed-pre-escaped-quote +treatment in ROUND20 §C1, but the **CardDAV** emitter still built a quoted +`String`. And two dedup/DAV emit micro-cuts the earlier rounds named but held +back: the `settle_batch` clone-to-bind and the `BDAY` strftime stamp. + +Reproduce: + +``` +cargo run --release --features bench --example bench_round21_micro +``` + +All arms are **no-Postgres** (release-profile counting-allocator example). + +## Summary + +| # | change | key metric | before → after | +|--:|---|---|---| +| **R1** | The CalDAV/CardDAV row-mapping repositories (`calendar_event_pg_repository`, `calendar_pg_repository`, `contact_pg_repository`, `contact_group_pg_repository`) built their result `Vec` with `let mut v = Vec::new(); for row in rows { v.push(map(row)?) }` — growing from capacity 0 (~⌈log₂N⌉ reallocations, each memcpy-ing the accumulated rows) on **every CalDAV/CardDAV listing, multiget & bulk sync**. Now `Vec::with_capacity(rows.len())` (the ROUND20 §I1 file-side pattern extended to the 16 calendar/contact sites it deferred). Plus one `HashMap` (`get_calendar_properties`). | 200-row listing | **7 → 1 allocs/op** (6 fewer) | +| **R2** | `DedupService::settle_batch` cloned every 64-char chunk hash into a `Vec` purely to `.bind()` it to the pin `UPDATE … WHERE hash = ANY($1)`, on **every settle batch of every upload** (~128 batches for a 1 GB fully-unique upload). Now binds a borrowed `Vec<&str>` — sqlx encodes `&[&str]` to `text[]` identically (`favorites_pg_repository.rs:271` already does this). | 32-chunk batch | **33 → 1 allocs/op · 39.4× wall** | +| **R3** | `DedupService::store_loose_chunks` — the delta-upload sibling of the ROUND17 §D2 ingest loop — kept an intra-request dedup `HashSet` and cloned the hex hash **twice per frame** (into `received` and into the set; the set clone dropped on the spot for a duplicate). Now keys the set on the raw `[u8; 32]` BLAKE3 digest (`Copy`, no per-distinct-chunk heap key) and moves the hex into `received` on a duplicate. Runs **per frame** on delta/sync uploads (thousands of frames for a large changed file). | 128 frames, 50% dup | **401 → 209 allocs/op (192 fewer) · 1.50× wall** | +| **R4** | `carddav_adapter::write_contact_response` built a `"…"`-quoted `String` for `getetag` then wrote it auto-escaped — `quick_xml` escapes the `"` → `"`, re-allocating an owned `Cow` — on **every contact of every CardDAV multiget/PROPFIND** (plus the per-address-book collection etag). Now emits the two quotes as borrowed pre-escaped `"` text events (the ROUND20 §C1 NextCloud pattern, via a shared `write_quoted_etag` helper covering all 4 CardDAV etag sites). | per-contact row | **3 → 0 allocs/op · 2.11× wall** | +| **R5** | `contact_to_vcard` stamped `BDAY` via `write!(…, "{}", bday.format("%Y-%m-%d"))`, running chrono's strftime interpreter per **contact-with-birthday**. Now renders the fixed `YYYY-MM-DD` on the stack via the new `fmt::compact_date` (the date-only companion to the §V2 `REV` renderer), chrono fallback for out-of-range years. | per bday contact | **2 → 0 allocs/op · 10.51× wall** | +| **R6** | The NextCloud trashbin PROPFIND row set `d:getcontenttype` for a folder to `"httpd/unix-directory".to_string()` — a heap `String` for a static constant, **per trashed folder row**. Now `Cow::Borrowed` (the ROUND16 §M1 `Cow<'static, str>` pattern); only the file branch (mime_guess) still owns its String. | per folder row | **1 → 0 allocs/op · 5.76× 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. + +## [R1] CalDAV/CardDAV row-mapper container pre-size + +`collect::>()` was ROUND20 §I1's target on the file side; the +CalDAV/CardDAV repos use the equivalent `Vec::new()` + `for row in rows { … }` +shape, which grows the container the same way — from capacity 0, reserving +nothing, so `push` reallocates ~⌈log₂N⌉ times and memcpy-s the accumulated +(Contact/Event-sized) rows on each grow. `rows` is a materialized `fetch_all` +result, so `rows.len()` is exact: + +```rust +let mut events = Vec::with_capacity(rows.len()); +for row in rows { + events.push(Self::row_to_event(row)?); // ? short-circuits identically +} +``` + +Applied to the 16 listing/multiget/paginated mappers across the four repos +(`calendar_event` ×6, `calendar` ×2 + the `get_calendar_properties` HashMap, +`contact` ×6, `contact_group` ×1). The `subject_group` and +`nextcloud_object_id` sibling mappers already pre-sized (`with_capacity(rows.len())`), +so they were left untouched. Byte-identical output; on a 200-row listing the +container allocations drop from **7 → 1** (the growth-from-0 reallocations +replaced by a single exact reserve). + +## [R2] settle_batch — bind borrowed `&str`, don't clone + +`settle_batch` runs once per flushed chunk batch of every upload. It built an +owned `Vec` of the batch's 64-char hashes only to `.bind()` it: + +```rust +let hashes: Vec = batch.iter().map(|(h, _)| h.clone()).collect(); // N heap Strings +// … .bind(&hashes) … WHERE hash = ANY($1) … +``` + +`batch` outlives the query (it is consumed two statements later), so the hashes +can be borrowed. sqlx encodes `&[&str]` to a PostgreSQL `text[]` identically to +the owned `Vec` (the pattern `favorites_pg_repository.rs:271` already +uses, with the comment *"sqlx binds `&[&str]` as text[], so no per-id String is +needed"*). The borrow is scoped in a block so it ends before `batch` is moved: + +```rust +let pinned: HashSet = { + let hashes: Vec<&str> = batch.iter().map(|(h, _)| h.as_str()).collect(); + sqlx::query_scalar::<_, String>("UPDATE … WHERE hash = ANY($1) RETURNING hash") + .bind(&hashes).fetch_all(pool.as_ref()).await?.into_iter().collect() +}; +``` + +Up to `FLUSH_MAX_CHUNKS` (=32) 64-byte `String` allocations removed per batch — +~4000 over a 1 GB fully-unique upload — for one pointer-only `Vec`. + +## [R3] store_loose_chunks — digest-keyed dedup set + move-on-duplicate + +The delta-upload ingest (`store_loose_chunks`) is the sibling ROUND17 §D2 didn't +reach. Per frame it allocated the 64-char hex hash and then cloned it twice: + +```rust +let hash = blake3::hash(&data).to_hex().to_string(); +received.push((hash.clone(), data.len() as u64)); // clone 1 (always) +if seen.insert(hash.clone()) { // clone 2 (always; HashSet) + new_rows.push((hash, len)); +} +``` + +`seen` is the **intra-request** dedup set (has this exact chunk already appeared +in *this* delta stream? — re-chunked near-duplicates, zero-padded regions). Keyed +on the raw 32-byte digest it needs no per-distinct-chunk `String`, and a +duplicate frame **moves** the hex into `received` instead of cloning: + +```rust +let digest = blake3::hash(&data); +let hash = digest.to_hex().to_string(); +let len = data.len(); +if seen.insert(*digest.as_bytes()) { // HashSet<[u8; 32]>, Copy key + self.backend.put_blob_from_bytes_unsynced(&hash, data).await?; + received.push((hash.clone(), len as u64)); + new_rows.push((hash, len as i64)); +} else { + received.push((hash, len as u64)); // move, no clone +} +``` + +hex ↔ digest is bijective, so membership and the `received`/`new_rows` +sequences are identical. On a 128-frame stream with 50 % intra-request dups the +per-frame hash clones drop from 3 to ~1.5. + +## [R4] CardDAV getetag — borrowed pre-escaped quotes + +`write_contact_response` (per contact of every CardDAV multiget/PROPFIND) built +a `"…"`-quoted `String` and wrote it auto-escaped; `quick_xml` escapes the `"` +to `"`, so the whole-string escape re-allocated an owned `Cow`. The new +shared `write_quoted_etag` helper emits the two quotes as **borrowed** +pre-escaped `"` text events around the escaped etag body — byte-identical +(the equivalence gate asserts it, including an etag with `&`/`<`/`"`), 0 +allocs/contact. Applied to all four CardDAV etag sites (2 per-contact + 2 +per-address-book collection), mirroring the NextCloud ROUND20 §C1 fix. + +## [R5] BDAY — stack-rendered `%Y-%m-%d` + +`contact_to_vcard` already stack-renders `REV` (ROUND19 §V2); `BDAY` still went +through chrono's strftime interpreter (`bday.format("%Y-%m-%d")`). The new +`fmt::compact_date(buf, year, month, day)` renders the fixed 10-byte +`YYYY-MM-DD` with the same `push4`/`push2` LUT the other `fmt` helpers use, and +returns `None` outside the 4-digit-year range (where chrono widens/sign-prefixes +`%Y`) so the caller keeps the chrono path as fallback. Byte-identical for every +representable birthday. + +## [R6] NC trashbin folder content-type — borrowed constant + +The trashbin PROPFIND folder branch `to_string()`-ed the static +`"httpd/unix-directory"` per row. `Cow::Borrowed` for the folder constant (the +file branch still owns its mime_guess String) drops that allocation per trashed +folder row — the ROUND16 §M1 `Cow<'static, str>` pattern the trashbin loop +missed. + +## Not shipped — deferred to a later round + +Surfaced by the Round-21 audit (three parallel sub-audits across the HTTP, +storage/dedup and application/parse layers), verified against current source, +but held back — each needs a signature/API decision, a Postgres fixture, or a +gate the deterministic alloc-counter can't provide: + +- **Hot GET handlers clone the whole request `HeaderMap`** (`file_handler` + list/download/thumbnail, `photos_handler`, NC `preview`/`avatar`): axum's + `HeaderMap` extractor does `parts.headers.clone()` (~2 allocs) purely to read + 1–3 headers — the exact cost `middleware/auth.rs` already eliminated (ROUND14 + §A4) but never propagated to the handlers. The fix takes `req: Request` last + and reads `req.headers()` by borrow; it's a **multi-handler signature refactor** + (each `_impl` + its wrapper + the route registration) that wants its own + validated pass, same class as the ROUND19/20 multi-signature deferrals. +- **`Query>` on the hot list/download paths** builds a + `HashMap` + key `String` per request to read one param; a typed + `Query` struct drops both (serde ignores unknown params). Same + signature-surface reason as above; pairs naturally with the HeaderMap pass. +- **Native WebDAV PROPFIND re-extracts the URI path** (`webdav_handler.rs:507`): + `extract_webdav_path(req.uri())` re-runs a percent-decode + `String` alloc that + the `path` parameter already holds at that point (the `:503` comment about the + prefix is stale). One decode + alloc per PROPFIND — but removing it needs a + careful href-equivalence proof across the chroot/scope resolution, so it wants + a dedicated correctness check, not a perf banner. +- **`music_service` public-playlist merge is O(owned·public)** (`Vec::any()` per + public item): a `HashSet` makes it O(owned+public). Because `PlaylistDto.id` is + a `String`, the set must own the ids (clone) — so the change trades N String + comparisons for N String clones: a **wall win that ADDS allocations**, which + the deterministic alloc gate can't score. Wants a wall-gated evaluation on the + opt-in `include_public` path. +- **WebDAV dead-props filter is O(N·D·R)** (`webdav_adapter.rs:616/705`): the + loop-invariant requested-props list is re-scanned per dead prop per resource; + a per-PROPFIND `HashSet<&QualifiedName>` makes it O(N·D). Only bites accounts + that accumulate client-set custom props (macOS Finder) over large listings — + and, like music_service, the HashSet build trades compares for an alloc, so + it's wall-gated. Queued with a synthetic-dead-props bench. +- **`verify_integrity` Phase 1 probes manifest chunks serially** while Phase 2 + is `buffer_unordered(16)` — on a remote backend that's O(total_chunks) serial + HEADs. Background/admin path; needs a remote-backend fixture to show the win. +- **`subject_group_service::remove_member` runs the same recursive-CTE + `list_transitive_users(child_id)` twice** for a nested group removal (the + intervening edge delete can't change the child's descendants). One DB + round-trip halved; low frequency (admin), needs Postgres. +- **`store_loose_chunks` final registration + `run_rollback` clone hashes to + bind** (`dedup_service.rs:887/212`), and the **`contact_pg` JSONB columns + decode through a throwaway `serde_json::Value`** — the R2/ROUND20 patterns + applied to once-per-upload / per-contact-read sites; both need Postgres to + bench end-to-end. +- **`GzipCompressionService::{compress,decompress}_data` copy the whole buffer + via `.to_vec()`** before `spawn_blocking` — forced by the `&[u8]` port + signature; a `Bytes`-taking port lets an owning caller move. Port API change, + gated (text > 50 KB), low heat. +- **Fast hasher for trusted-key internal maps** (ROUND20 flag stands): needs a + `Cargo.toml` dependency decision and must stay DoS-resistant for the + attacker-controlled delta-hash sets — worth a dedicated, wall-gated pass. + +## Environment / methodology + +- `cargo run --release --features bench --example bench_round21_micro` — + counting global allocator, no Postgres. Tunables (env): `BENCH_ITERS` (200000), + `R1_ROWS` (200), `R3_FRAMES` (128). +- 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. diff --git a/examples/bench_round21_micro.rs b/examples/bench_round21_micro.rs new file mode 100644 index 00000000..f37b9ec2 --- /dev/null +++ b/examples/bench_round21_micro.rs @@ -0,0 +1,520 @@ +//! Round-21 CPU/alloc micro-pack (no Postgres). +//! +//! Same rule as ROUND2–20: 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 +//! and a `GATE FAIL … rollback` check that `std::process::exit(1)`s if the AFTER +//! arm fails to beat its BEFORE — the round's roll-back rule encoded into the +//! benchmark. An AFTER that doesn't win is never applied to the source. +//! +//! [R1] The CalDAV/CardDAV row-mapping repositories build their result Vec +//! with `let mut v = Vec::new(); for row in rows { v.push(map(row)?) }`, +//! growing the container from capacity 0 (~⌈log₂N⌉ reallocations, each +//! memcpy-ing the accumulated rows). AFTER pre-sizes with +//! `Vec::with_capacity(rows.len())` — the file-side sibling ROUND20 §I1 +//! shipped, extended to the calendar/contact repos it deferred. +//! +//! [R2] `DedupService::settle_batch` cloned every 64-char chunk hash into a +//! `Vec` purely to `.bind()` it to the pin `UPDATE … = ANY($1)`. +//! AFTER binds a borrowed `Vec<&str>` — sqlx encodes `&[&str]` to +//! `text[]` identically (favorites_pg_repository.rs:271 already does +//! this), so the per-chunk hash `String` disappears. +//! +//! [R3] `DedupService::store_loose_chunks` (the delta-upload sibling of the +//! ROUND17 §D2 ingest loop) kept an intra-request dedup `HashSet` +//! and cloned the hex hash TWICE per frame (into `received` and into the +//! set). AFTER keys the set on the raw `[u8; 32]` BLAKE3 digest (`Copy`, +//! no heap key) and moves the hex into `received` on a duplicate. +//! +//! [R4] `carddav_adapter::write_contact_response` built a `"…"`-quoted +//! `String` for `getetag` then wrote it auto-escaped (quick_xml escapes +//! the `"` → `"`, re-allocating). AFTER emits the two quotes as +//! borrowed pre-escaped `"` text events (the NextCloud ROUND20 §C1 +//! pattern applied to the CardDAV emitter it missed). +//! +//! [R5] `contact_to_vcard` stamped `BDAY` via `write!(…, "{}", +//! bday.format("%Y-%m-%d"))`, running chrono's strftime interpreter per +//! contact-with-birthday. AFTER renders the fixed `YYYY-MM-DD` on the +//! stack via `fmt::compact_date` (the date-only companion to the §V2 REV +//! renderer), with the chrono fallback for out-of-range years. +//! +//! [R6] The NextCloud trashbin PROPFIND row set `d:getcontenttype` for a +//! folder to `"httpd/unix-directory".to_string()` — a heap String for a +//! static constant, per trashed folder row. AFTER borrows it via +//! `Cow::Borrowed` (the ROUND16 §M1 `Cow<'static, str>` pattern). +//! +//! Run: +//! cargo run --release --features bench --example bench_round21_micro +//! Tunables (env): BENCH_ITERS (200000), R1_ROWS (200), R3_FRAMES (128) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::borrow::Cow; +use std::collections::HashSet; +use std::env; +use std::hint::black_box; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use chrono::NaiveDate; +use quick_xml::Writer; +use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; + +static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0); + +struct CountingAlloc; + +unsafe impl GlobalAlloc for CountingAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.realloc(ptr, layout, new_size) } + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc_zeroed(layout) } + } +} + +#[global_allocator] +static GLOBAL: CountingAlloc = CountingAlloc; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +struct Measured { + wall_ns_per_op: f64, + allocs_per_op: f64, +} + +fn measure(iters: usize, mut f: F) -> Measured { + // Warm up (grow any reused buffers, prime caches) so the measured window + // reflects steady state, not first-touch growth. + for _ in 0..(iters / 20).max(1) { + f(); + } + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + for _ in 0..iters { + f(); + } + let wall = t.elapsed().as_nanos() as f64 / iters as f64; + let allocs = (ALLOC_CALLS.load(Ordering::Relaxed) - a0) as f64 / iters as f64; + Measured { + wall_ns_per_op: wall, + allocs_per_op: allocs, + } +} + +fn print_row(label: &str, m: &Measured) { + println!( + "| {:<50} | {:>12.1} | {:>10.2} |", + label, m.wall_ns_per_op, m.allocs_per_op + ); +} + +fn header_footer(name: &str, before: &Measured, after: &Measured) { + println!("| arm | ns/op | allocs/op |"); + print_row(&format!("BEFORE {name}"), before); + print_row(&format!("AFTER {name}"), after); + println!( + "# {:.2}x wall, {:.2} fewer allocs/op", + before.wall_ns_per_op / after.wall_ns_per_op, + before.allocs_per_op - after.allocs_per_op + ); +} + +fn gate_allocs(tag: &str, before: &Measured, after: &Measured) { + if after.allocs_per_op >= before.allocs_per_op { + eprintln!("GATE FAIL [{tag}]: AFTER did not reduce allocations — rollback"); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [R1] Row-mapper container pre-size — Vec::new()+push vs with_capacity+push +// ──────────────────────────────────────────────────────────────────────────── + +/// A Contact-sized (~192 B) mapped element so the container-realloc memcpy cost +/// is realistic. The per-element mapper allocates nothing in either arm, so the +/// measured alloc delta is exactly the container growth (the CalDAV/CardDAV +/// `row_to_*` allocs are identical in both arms and out of scope here). +type MappedRow = [u8; 192]; + +fn r1_before(rows: &[MappedRow]) -> Vec { + let mut out = Vec::new(); + for row in rows { + out.push(*row); + } + out +} + +fn r1_after(rows: &[MappedRow]) -> Vec { + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + out.push(*row); + } + out +} + +fn section_r1() { + let n: usize = env_or("R1_ROWS", 200); + let iters: usize = env_or("BENCH_ITERS", 200_000) / 20; // heavier op + let rows: Vec = (0..n).map(|i| [i as u8; 192]).collect(); + + assert_eq!(r1_before(&rows).len(), r1_after(&rows).len()); + + let before = measure(iters, || { + black_box(r1_before(black_box(&rows))); + }); + let after = measure(iters, || { + black_box(r1_after(black_box(&rows))); + }); + + println!("\n## [R1] CalDAV/CardDAV row-mapper pre-size ({n} contact-sized rows)"); + header_footer("Vec::new()+push vs with_capacity+push", &before, &after); + gate_allocs("R1", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [R2] settle_batch bind — Vec clone vs Vec<&str> borrow +// ──────────────────────────────────────────────────────────────────────────── + +/// BEFORE: clone every chunk hash into an owned `Vec` to `.bind()`. +fn r2_before(batch: &[(String, u64)]) -> Vec { + batch.iter().map(|(h, _)| h.clone()).collect() +} + +/// AFTER: borrow — sqlx encodes `&[&str]` to `text[]` identically. +fn r2_after(batch: &[(String, u64)]) -> Vec<&str> { + batch.iter().map(|(h, _)| h.as_str()).collect() +} + +fn section_r2() { + let n: usize = env_or("FLUSH_MAX_CHUNKS", 32); + let iters: usize = env_or("BENCH_ITERS", 200_000); + // A settle batch of 32 chunks, each a 64-char BLAKE3 hex hash. + let batch: Vec<(String, u64)> = (0..n) + .map(|i| { + ( + format!("{:064x}", i as u128 * 0x9E37_79B9_7F4A_7C15), + 65_536, + ) + }) + .collect(); + + // Equivalence: the borrowed &strs equal the owned String hashes. + let b = r2_before(&batch); + let a = r2_after(&batch); + assert_eq!(b.len(), a.len(), "R2 length differs"); + assert!( + b.iter().zip(&a).all(|(s, t)| s == t), + "R2 bound hashes differ" + ); + + let before = measure(iters, || { + black_box(r2_before(black_box(&batch))); + }); + let after = measure(iters, || { + black_box(r2_after(black_box(&batch))); + }); + + println!("\n## [R2] settle_batch hash bind ({n}-chunk batch)"); + header_footer("Vec clone vs Vec<&str> borrow", &before, &after); + gate_allocs("R2", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [R3] store_loose_chunks — HashSet+2 clones vs HashSet<[u8;32]>+move +// ──────────────────────────────────────────────────────────────────────────── + +/// `(received-in-order, distinct-new-rows)` — `store_loose_chunks`'s two +/// observable outputs. +type R3Out = (Vec<(String, u64)>, Vec<(String, i64)>); + +/// BEFORE: the shipped-before delta-upload loop — `HashSet` intra- +/// request dedup set, hex hash cloned into `received` AND into the set per frame. +/// Returns (received-in-order, distinct-new-rows) — the observable result. +fn r3_before(frames: &[([u8; 32], String)]) -> R3Out { + let mut received: Vec<(String, u64)> = Vec::new(); + let mut new_rows: Vec<(String, i64)> = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for (_digest, hex) in frames { + // Step 1 (common to both arms): the fresh per-frame hex String + // (`blake3::hash(&data).to_hex().to_string()`). + let hash = hex.clone(); + received.push((hash.clone(), 65_536)); + if seen.insert(hash.clone()) { + new_rows.push((hash, 65_536)); + } + } + (received, new_rows) +} + +/// AFTER: dedup set keyed on the raw 32-byte digest; hex moved into `received` +/// on a duplicate, cloned only on the first occurrence (needed by `new_rows`). +fn r3_after(frames: &[([u8; 32], String)]) -> R3Out { + let mut received: Vec<(String, u64)> = Vec::new(); + let mut new_rows: Vec<(String, i64)> = Vec::new(); + let mut seen: HashSet<[u8; 32]> = HashSet::new(); + for (digest, hex) in frames { + let hash = hex.clone(); // step 1, same as BEFORE + if seen.insert(*digest) { + received.push((hash.clone(), 65_536)); + new_rows.push((hash, 65_536)); + } else { + received.push((hash, 65_536)); + } + } + (received, new_rows) +} + +fn section_r3() { + let n: usize = env_or("R3_FRAMES", 128); + let iters: usize = env_or("BENCH_ITERS", 200_000) / 20; // heavier op + // A delta stream where every other frame repeats the previous chunk (a + // re-chunked near-duplicate / zero-padded region) → 50% intra-request dups. + let frames: Vec<([u8; 32], String)> = (0..n) + .map(|i| { + let key = i / 2; // pairs share a digest + let h = blake3::hash(&(key as u64).to_le_bytes()); + (*h.as_bytes(), h.to_hex().to_string()) + }) + .collect(); + + // Equivalence: identical received sequence and distinct new_rows. + let b = r3_before(&frames); + let a = r3_after(&frames); + assert_eq!(b.0, a.0, "R3 received sequence differs"); + assert_eq!(b.1, a.1, "R3 new_rows differ"); + + let before = measure(iters, || { + black_box(r3_before(black_box(&frames))); + }); + let after = measure(iters, || { + black_box(r3_after(black_box(&frames))); + }); + + println!("\n## [R3] store_loose_chunks dedup ({n} frames, 50% dup)"); + header_footer("HashSet+2 clones vs [u8;32]+move", &before, &after); + gate_allocs("R3", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [R4] CardDAV getetag — quoted String + escape vs borrowed pre-escaped +// ──────────────────────────────────────────────────────────────────────────── + +/// BEFORE: build a `"…"`-quoted `String`, then write it as an auto-escaped text +/// element — `quick_xml` escapes the `"` → `"`, re-allocating an owned Cow. +fn r4_before(buf: &mut Vec, etag: &str) { + let mut w = Writer::new(&mut *buf); + w.write_event(Event::Start(BytesStart::new("D:getetag"))) + .unwrap(); + let mut quoted = String::with_capacity(etag.len() + 2); + quoted.push('"'); + quoted.push_str(etag); + quoted.push('"'); + w.write_event(Event::Text(BytesText::new("ed))).unwrap(); + w.write_event(Event::End(BytesEnd::new("D:getetag"))) + .unwrap(); +} + +/// AFTER: emit the pre-escaped `"` quote literals as borrowed text events +/// around the escaped etag body — byte-identical output, zero owned strings. +fn r4_after(buf: &mut Vec, etag: &str) { + let mut w = Writer::new(&mut *buf); + w.write_event(Event::Start(BytesStart::new("D:getetag"))) + .unwrap(); + w.write_event(Event::Text(BytesText::from_escaped("""))) + .unwrap(); + w.write_event(Event::Text(BytesText::new(etag))).unwrap(); + w.write_event(Event::Text(BytesText::from_escaped("""))) + .unwrap(); + w.write_event(Event::End(BytesEnd::new("D:getetag"))) + .unwrap(); +} + +fn section_r4() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + let etag = "a1b2c3d4e5f6-1719792000"; // realistic contact etag + + // Equivalence: byte-identical output, incl. an etag with XML-special chars. + let (mut b1, mut b2) = (Vec::new(), Vec::new()); + r4_before(&mut b1, etag); + r4_after(&mut b2, etag); + assert_eq!(b1, b2, "R4 emitted bytes differ (hex etag)"); + let (mut s1, mut s2) = (Vec::new(), Vec::new()); + r4_before(&mut s1, "abc&def Option<&str> { + if !(0..=9999).contains(&year) { + return None; + } + push4(buf, 0, year as i64); + buf[4] = b'-'; + push2(buf, 5, month); + buf[7] = b'-'; + push2(buf, 8, day); + Some(std::str::from_utf8(&buf[..]).expect("ascii")) +} + +/// BEFORE: `write!(vcard, "BDAY:{}\r\n", bday.format("%Y-%m-%d"))` into the +/// reused buffer — chrono's strftime interpreter per contact-with-birthday. +fn r5_before(vcard: &mut String, bday: NaiveDate) { + use std::fmt::Write as _; + let _ = write!(vcard, "BDAY:{}\r\n", bday.format("%Y-%m-%d")); +} + +/// AFTER: stack render via `compact_date`, chrono fallback out of range. +fn r5_after(vcard: &mut String, bday: NaiveDate) { + use chrono::Datelike as _; + let mut buf = [0u8; 10]; + match bench_compact_date(&mut buf, bday.year(), bday.month(), bday.day()) { + Some(s) => { + vcard.push_str("BDAY:"); + vcard.push_str(s); + vcard.push_str("\r\n"); + } + None => { + use std::fmt::Write as _; + let _ = write!(vcard, "BDAY:{}\r\n", bday.format("%Y-%m-%d")); + } + } +} + +fn section_r5() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + let bday = NaiveDate::from_ymd_opt(1987, 3, 5).unwrap(); + + // Equivalence: byte-identical BDAY line. + let (mut b, mut a) = (String::new(), String::new()); + r5_before(&mut b, bday); + r5_after(&mut a, bday); + assert_eq!(b, a, "R5 BDAY line differs"); + assert_eq!(b, "BDAY:1987-03-05\r\n"); + + let mut buf = String::with_capacity(32); + let before = measure(iters, || { + buf.clear(); + r5_before(black_box(&mut buf), black_box(bday)); + }); + let after = measure(iters, || { + buf.clear(); + r5_after(black_box(&mut buf), black_box(bday)); + }); + + println!("\n## [R5] BDAY stamp (per contact-with-birthday)"); + header_footer("chrono %Y-%m-%d vs compact_date", &before, &after); + gate_allocs("R5", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [R6] trashbin folder content-type — String::to_string() vs Cow::Borrowed +// ──────────────────────────────────────────────────────────────────────────── + +/// BEFORE: heap a `String` for the static folder content-type constant. +fn r6_before(is_folder: bool, name: &str) -> String { + if is_folder { + "httpd/unix-directory".to_string() + } else { + // File branch (mime_guess) — allocates in both arms, out of scope. + format!("application/{}", name.rsplit('.').next().unwrap_or("octet")) + } +} + +/// AFTER: borrow the folder constant; only the file branch owns its String. +fn r6_after(is_folder: bool, name: &str) -> Cow<'static, str> { + if is_folder { + Cow::Borrowed("httpd/unix-directory") + } else { + Cow::Owned(format!( + "application/{}", + name.rsplit('.').next().unwrap_or("octet") + )) + } +} + +fn section_r6() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + + // Equivalence: same content-type string for a folder row. + assert_eq!(r6_before(true, "x"), r6_after(true, "x").as_ref()); + + let before = measure(iters, || { + black_box(r6_before(black_box(true), black_box("Documents"))); + }); + let after = measure(iters, || { + black_box(r6_after(black_box(true), black_box("Documents"))); + }); + + println!("\n## [R6] trashbin folder content-type (per trashed folder row)"); + header_footer("String::to_string() vs Cow::Borrowed", &before, &after); + gate_allocs("R6", &before, &after); +} + +fn main() { + println!("# Round-21 micro-pack — BEFORE/AFTER (counting allocator, release)"); + println!("# allocs/op is the deterministic gate; a non-winning AFTER exits 1 (rollback)."); + section_r1(); + section_r2(); + section_r3(); + section_r4(); + section_r6(); + // R5 (BDAY) last: it is the one section whose BEFORE (chrono's NaiveDate + // strftime) may or may not heap-allocate; ordering it last lets every other + // section print + gate before R5's gate can halt the run. + section_r5(); + println!("\nAll Round-21 sections passed their allocation gate."); +} diff --git a/src/application/adapters/carddav_adapter.rs b/src/application/adapters/carddav_adapter.rs index 945a1d0e..3728c8b6 100644 --- a/src/application/adapters/carddav_adapter.rs +++ b/src/application/adapters/carddav_adapter.rs @@ -17,6 +17,21 @@ use crate::application::adapters::webdav_adapter::{ use crate::application::dtos::address_book_dto::AddressBookDto; use crate::application::dtos::contact_dto::ContactDto; +/// Emit a WebDAV `getetag` value as `"…"` with the surrounding quotes written +/// as borrowed pre-escaped `"` text events around the escaped etag body. +/// +/// Byte-identical to escaping the whole `"{etag}"` String — `quick_xml` escapes +/// a literal `"` to `"`, so the one-String form re-allocated an owned `Cow` +/// on write — but with **0 heap allocs per contact** (the NextCloud +/// `write_etag_element` pattern, benches/ROUND20.md §C1). Called per contact on +/// the CardDAV multiget/PROPFIND emit path. +fn write_quoted_etag(xml_writer: &mut Writer, etag: &str) -> Result<()> { + xml_writer.write_event(Event::Text(BytesText::from_escaped(""")))?; + xml_writer.write_event(Event::Text(BytesText::new(etag)))?; + xml_writer.write_event(Event::Text(BytesText::from_escaped(""")))?; + Ok(()) +} + /// Render a requested property as a namespaced response element name, mapping /// the known namespaces to their response prefixes (`D:` for DAV, `CR:` for /// CardDAV). Used for the catch-all arms of the requested-property writers so @@ -386,7 +401,7 @@ impl CardDavAdapter { // getetag xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", book.id))))?; + write_quoted_etag(xml_writer, &book.id)?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; // getcontenttype @@ -468,8 +483,7 @@ impl CardDavAdapter { } ("DAV:", "getetag") => { xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer - .write_event(Event::Text(BytesText::new(&format!("\"{}\"", book.id))))?; + write_quoted_etag(xml_writer, &book.id)?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; } ("DAV:", "getcontenttype") => { @@ -755,11 +769,7 @@ impl CardDavAdapter { xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - let mut quoted = String::with_capacity(contact.etag.len() + 2); - quoted.push('"'); - quoted.push_str(&contact.etag); - quoted.push('"'); - xml_writer.write_event(Event::Text(BytesText::new("ed)))?; + write_quoted_etag(xml_writer, &contact.etag)?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; @@ -779,11 +789,7 @@ impl CardDavAdapter { } ("DAV:", "getetag") => { xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - let mut quoted = String::with_capacity(contact.etag.len() + 2); - quoted.push('"'); - quoted.push_str(&contact.etag); - quoted.push('"'); - xml_writer.write_event(Event::Text(BytesText::new("ed)))?; + write_quoted_etag(xml_writer, &contact.etag)?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; } ("DAV:", "getcontenttype") => { @@ -1017,7 +1023,23 @@ pub fn contact_to_vcard(contact: &ContactDto) -> String { } } if let Some(bday) = &contact.birthday { - let _ = write!(vcard, "BDAY:{}\r\n", bday.format("%Y-%m-%d")); + // Stack render (byte-identical to chrono's `%Y-%m-%d`) with the chrono + // fallback for out-of-range years — drops the strftime interpreter + a + // heap alloc per contact-with-birthday (fmt::compact_date is the + // date-only companion to the §V2 REV renderer above). + use chrono::Datelike as _; + let mut bday_buf = [0u8; 10]; + match crate::common::fmt::compact_date(&mut bday_buf, bday.year(), bday.month(), bday.day()) + { + Some(s) => { + vcard.push_str("BDAY:"); + vcard.push_str(s); + vcard.push_str("\r\n"); + } + None => { + let _ = write!(vcard, "BDAY:{}\r\n", bday.format("%Y-%m-%d")); + } + } } if let Some(photo) = &contact.photo_url { let _ = write!(vcard, "PHOTO;VALUE=URI:{}\r\n", photo); diff --git a/src/common/fmt.rs b/src/common/fmt.rs index fd887e14..10f0f4e1 100644 --- a/src/common/fmt.rs +++ b/src/common/fmt.rs @@ -241,6 +241,31 @@ pub fn compact_ical_utc(buf: &mut [u8; 16], secs: i64) -> Option<&str> { Some(std::str::from_utf8(&buf[..]).expect("ascii")) } +/// `chrono::NaiveDate::format("%Y-%m-%d")` for a calendar date: the vCard +/// `BDAY` / ISO date form `2026-07-17` (10 bytes) written into `buf`. +/// +/// The vCard emit path (`contact_to_vcard`) stamps `BDAY` per +/// contact-with-birthday, and `write!(…, "{}", date.format("%Y-%m-%d"))` runs +/// chrono's strftime interpreter and heap-allocates — the same interpreter cost +/// [`compact_ical_utc`] removed for the `REV` stamp (benches/ROUND19.md §V2: +/// 3→0 allocs). This is the date-only companion to that helper. +/// +/// Takes the pre-split `year`/`month`/`day` (so `fmt` stays chrono-free off the +/// test path); callers read them via `chrono::Datelike`. Returns `None` when +/// `year` is outside the fixed-width 4-digit range — where chrono widens or +/// sign-prefixes `%Y` — so callers keep the chrono path as fallback. +pub fn compact_date(buf: &mut [u8; 10], year: i32, month: u32, day: u32) -> Option<&str> { + if !(0..=9999).contains(&year) { + return None; + } + push4(buf, 0, year as i64); + buf[4] = b'-'; + push2(buf, 5, month); + buf[7] = b'-'; + push2(buf, 8, day); + Some(std::str::from_utf8(&buf[..]).expect("ascii")) +} + /// Append the upper-cased form of `s` to `buf` without a temporary `String`. /// /// Byte-identical to `buf.push_str(&s.to_uppercase())` — same @@ -349,14 +374,42 @@ mod tests { } } + #[test] + fn compact_date_matches_chrono() { + use chrono::{Datelike, NaiveDate}; + // Padding (day/month < 10), leap day, min/max in-range 4-digit year, + // 3-digit year (chrono zero-pads %Y to 4). + let cases = [ + (2026, 7, 17), + (2000, 2, 29), + (2005, 7, 1), + (1970, 1, 1), + (9999, 12, 31), + (1, 1, 1), + (876, 5, 9), + ]; + for (y, m, d) in cases { + let date = NaiveDate::from_ymd_opt(y, m, d).unwrap(); + let mut buf = [0u8; 10]; + assert_eq!( + compact_date(&mut buf, date.year(), date.month(), date.day()).expect("in range"), + date.format("%Y-%m-%d").to_string(), + "date={y}-{m}-{d}" + ); + } + } + #[test] fn out_of_range_falls_back() { let mut b3 = [0u8; 25]; let mut b2 = [0u8; 31]; let mut bc = [0u8; 16]; + let mut bd = [0u8; 10]; assert!(rfc3339_utc(&mut b3, -1).is_none()); assert!(rfc2822_utc(&mut b2, -1).is_none()); assert!(compact_ical_utc(&mut bc, -1).is_none()); + assert!(compact_date(&mut bd, -1, 1, 1).is_none()); + assert!(compact_date(&mut bd, 10000, 1, 1).is_none()); assert!(rfc3339_utc(&mut b3, MAX_4DIGIT_YEAR_SECS + 1).is_none()); assert!(compact_ical_utc(&mut bc, MAX_4DIGIT_YEAR_SECS + 1).is_none()); } diff --git a/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs b/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs index d137959c..3c801c9d 100644 --- a/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs +++ b/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs @@ -182,7 +182,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { DomainError::database_error(format!("Failed to get events in time range: {}", e)) })?; - let mut events = Vec::new(); + let mut events = Vec::with_capacity(rows.len()); for row in rows { let mut event = CalendarEvent::with_id( row.get("id"), @@ -288,7 +288,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { DomainError::database_error(format!("Failed to get events by calendar: {}", e)) })?; - let mut events = Vec::new(); + let mut events = Vec::with_capacity(rows.len()); for row in rows { let mut event = CalendarEvent::with_id( row.get("id"), @@ -341,7 +341,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { DomainError::database_error(format!("Failed to find events by summary: {}", e)) })?; - let mut events = Vec::new(); + let mut events = Vec::with_capacity(rows.len()); for row in rows { let mut event = CalendarEvent::with_id( row.get("id"), @@ -515,7 +515,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { DomainError::database_error(format!("Failed to get calendar events by UIDs: {}", e)) })?; - let mut events = Vec::new(); + let mut events = Vec::with_capacity(rows.len()); for row in rows { let mut event = CalendarEvent::with_id( row.get("id"), @@ -664,7 +664,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { )) })?; - let mut events = Vec::new(); + let mut events = Vec::with_capacity(rows.len()); for row in rows { let event = CalendarEvent::with_id( row.get("id"), @@ -719,7 +719,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { DomainError::database_error(format!("Failed to find recurring events in range: {}", e)) })?; - let mut events = Vec::new(); + let mut events = Vec::with_capacity(rows.len()); for row in rows { let event = CalendarEvent::with_id( row.get("id"), diff --git a/src/infrastructure/repositories/pg/calendar_pg_repository.rs b/src/infrastructure/repositories/pg/calendar_pg_repository.rs index f595e75d..fe0c213f 100644 --- a/src/infrastructure/repositories/pg/calendar_pg_repository.rs +++ b/src/infrastructure/repositories/pg/calendar_pg_repository.rs @@ -211,7 +211,7 @@ impl CalendarRepository for CalendarPgRepository { DomainError::database_error(format!("Failed to get calendars by owner: {}", e)) })?; - let mut calendars = Vec::new(); + let mut calendars = Vec::with_capacity(rows.len()); for row in rows { let calendar = Calendar::with_id( row.get("id"), @@ -292,7 +292,7 @@ impl CalendarRepository for CalendarPgRepository { DomainError::database_error(format!("Failed to get public calendars: {}", e)) })?; - let mut calendars = Vec::new(); + let mut calendars = Vec::with_capacity(rows.len()); for row in rows { let calendar = Calendar::with_id( row.get("id"), @@ -400,7 +400,7 @@ impl CalendarRepository for CalendarPgRepository { DomainError::database_error(format!("Failed to get calendar properties: {}", e)) })?; - let mut properties = std::collections::HashMap::new(); + let mut properties = std::collections::HashMap::with_capacity(rows.len()); for row in rows { properties.insert(row.get("name"), row.get("value")); } diff --git a/src/infrastructure/repositories/pg/contact_group_pg_repository.rs b/src/infrastructure/repositories/pg/contact_group_pg_repository.rs index a5d3cc46..fedc4db2 100644 --- a/src/infrastructure/repositories/pg/contact_group_pg_repository.rs +++ b/src/infrastructure/repositories/pg/contact_group_pg_repository.rs @@ -218,7 +218,7 @@ impl ContactGroupRepository for ContactGroupPgRepository { ) })?; - let mut contacts = Vec::new(); + let mut contacts = Vec::with_capacity(rows.len()); for row in &rows { let email_json: JsonValue = row.get("email"); let phone_json: JsonValue = row.get("phone"); diff --git a/src/infrastructure/repositories/pg/contact_pg_repository.rs b/src/infrastructure/repositories/pg/contact_pg_repository.rs index 45850e16..1edebbee 100644 --- a/src/infrastructure/repositories/pg/contact_pg_repository.rs +++ b/src/infrastructure/repositories/pg/contact_pg_repository.rs @@ -271,7 +271,7 @@ impl ContactRepository for ContactPgRepository { DomainError::database_error(format!("Failed to get contacts by uids: {}", e)) })?; - let mut contacts = Vec::new(); + let mut contacts = Vec::with_capacity(rows.len()); for row in &rows { contacts.push(Self::row_to_contact(row)?); } @@ -339,7 +339,7 @@ impl ContactRepository for ContactPgRepository { DomainError::database_error(format!("Failed to get contacts by address book: {}", e)) })?; - let mut contacts = Vec::new(); + let mut contacts = Vec::with_capacity(rows.len()); for row in &rows { contacts.push(Self::row_to_contact(row)?); } @@ -376,7 +376,7 @@ impl ContactRepository for ContactPgRepository { )) })?; - let mut contacts = Vec::new(); + let mut contacts = Vec::with_capacity(rows.len()); for row in &rows { contacts.push(Self::row_to_contact(row)?); } @@ -404,7 +404,7 @@ impl ContactRepository for ContactPgRepository { DomainError::database_error(format!("Failed to get contacts by email: {}", e)) })?; - let mut contacts = Vec::new(); + let mut contacts = Vec::with_capacity(rows.len()); for row in &rows { contacts.push(Self::row_to_contact(row)?); } @@ -434,7 +434,7 @@ impl ContactRepository for ContactPgRepository { DomainError::database_error(format!("Failed to get contacts by group: {}", e)) })?; - let mut contacts = Vec::new(); + let mut contacts = Vec::with_capacity(rows.len()); for row in &rows { contacts.push(Self::row_to_contact(row)?); } @@ -474,7 +474,7 @@ impl ContactRepository for ContactPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to search contacts: {}", e)))?; - let mut contacts = Vec::new(); + let mut contacts = Vec::with_capacity(rows.len()); for row in &rows { contacts.push(Self::row_to_contact(row)?); } diff --git a/src/infrastructure/services/dedup_service.rs b/src/infrastructure/services/dedup_service.rs index cef6bb8c..258c4416 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -860,7 +860,12 @@ impl DedupService { let mut received: Vec<(String, u64)> = Vec::new(); let mut new_rows: Vec<(String, i64)> = Vec::new(); - let mut seen: HashSet = HashSet::new(); + // Intra-request dedup set keyed on the raw 32-byte BLAKE3 digest + // (`[u8; 32]`, `Copy` — no per-distinct-chunk 64-byte `String` heap + // key), mirroring the streaming ingest loop (benches/ROUND17.md §D2). + // hex ↔ digest is bijective, so membership is identical to the old + // `HashSet`. + let mut seen: HashSet<[u8; 32]> = HashSet::new(); while let Some(frame) = frames.next().await { let data = frame?; @@ -870,14 +875,21 @@ impl DedupService { data.len() ))); } - let hash = blake3::hash(&data).to_hex().to_string(); - received.push((hash.clone(), data.len() as u64)); - if seen.insert(hash.clone()) { - let len = data.len() as i64; + let digest = blake3::hash(&data); + let hash = digest.to_hex().to_string(); + let len = data.len(); + if seen.insert(*digest.as_bytes()) { self.backend .put_blob_from_bytes_unsynced(&hash, data) .await?; - new_rows.push((hash, len)); + // First occurrence: `received` needs a copy, `new_rows` moves it. + received.push((hash.clone(), len as u64)); + new_rows.push((hash, len as i64)); + } else { + // Duplicate within this request — move the hex into `received` + // (no clone; the blob is already registered by its first + // occurrence). Same `received` sequence, input order preserved. + received.push((hash, len as u64)); } } @@ -1216,24 +1228,30 @@ impl DedupService { return Ok(()); } let mut guard = state.lock().await; - let hashes: Vec = batch.iter().map(|(h, _)| h.clone()).collect(); // Pin-or-classify in one statement: rows that exist take this // session's reference NOW; hashes not returned don't exist and are - // ours to write. - let pinned: HashSet = sqlx::query_scalar::<_, String>( - "UPDATE storage.blobs SET ref_count = ref_count + 1, orphaned_at = NULL - WHERE hash = ANY($1) - RETURNING hash", - ) - .bind(&hashes) - .fetch_all(pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("Dedup", format!("Failed to pin existing chunks: {e}")) - })? - .into_iter() - .collect(); + // ours to write. Bind borrowed `&str`s — sqlx encodes `&[&str]` to + // `text[]` identically to the owned Strings the old `.clone()` built, + // so no per-chunk hash String is allocated just to run the query + // (the pattern favorites_pg_repository.rs:271 already uses). The + // borrow is scoped so it ends before `batch` is moved below. + let pinned: HashSet = { + let hashes: Vec<&str> = batch.iter().map(|(h, _)| h.as_str()).collect(); + sqlx::query_scalar::<_, String>( + "UPDATE storage.blobs SET ref_count = ref_count + 1, orphaned_at = NULL + WHERE hash = ANY($1) + RETURNING hash", + ) + .bind(&hashes) + .fetch_all(pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("Dedup", format!("Failed to pin existing chunks: {e}")) + })? + .into_iter() + .collect() + }; let mut to_write: Vec<(String, Bytes)> = Vec::with_capacity(batch.len()); for (hash, data) in batch { diff --git a/src/interfaces/nextcloud/trashbin_handler.rs b/src/interfaces/nextcloud/trashbin_handler.rs index 92123672..04dc5fd6 100644 --- a/src/interfaces/nextcloud/trashbin_handler.rs +++ b/src/interfaces/nextcloud/trashbin_handler.rs @@ -465,11 +465,13 @@ fn write_trash_item_response( .map_err(|e| e.to_string())?; } - // d:getcontenttype - let content_type = if item.item_type == "folder" { - "httpd/unix-directory".to_string() + // d:getcontenttype — the folder constant is borrowed (`Cow::Borrowed`, 0 + // allocs per trashed folder row); only the file branch (mime_guess) still + // allocates its owned String (ROUND16 §M1 `Cow<'static, str>` pattern). + let content_type: std::borrow::Cow<'static, str> = if item.item_type == "folder" { + std::borrow::Cow::Borrowed("httpd/unix-directory") } else { - mime_from_name(&item.name) + std::borrow::Cow::Owned(mime_from_name(&item.name)) }; write_text_element(xml, "d:getcontenttype", &content_type)?;