perf: round 20 — iCal/vCard parse allocs, owned-DTO moves, Result-collect pre-size, NC etag/favorites emit

Benchmark-gated (benches/ROUND20.md), same rule as rounds 2-19: every change
ships with a BEFORE/AFTER counting-allocator micro-benchmark and a byte-value
equivalence gate; a non-winning AFTER is rolled back (never applied). The
rollback rule is encoded in the harness (GATE FAIL exit). All 8 sections pass.

Reproduce: cargo run --release --features bench --example bench_round20_micro

- A1 CalendarEvent iCal parse: replace the throwaway per-property
  HashMap<String,Vec<String>> (DTSTART/DTEND/RECURRENCE-ID) with a direct
  VALUE=DATE scan; prop_with_params kept #[cfg(test)] (6->2 allocs/event, 4.2x)
- A2 UserDto::from: add User::into_parts and MOVE image (<=512 KiB data URI)
  + ui_preferences JSON instead of cloning on every /api/auth/me (27->14 allocs)
- A3 parse_vcard: drop the per-line to_ascii_uppercase copy + the lines Vec;
  promote ascii_ci_contains to common::text and share it (8->1 allocs/contact)
- A4 Calendar/AddressBook DTO: into_parts move incl. custom_properties map (18->10)
- I1 file-listing repos: collect::<Result<Vec>>() size-hints to 0 and grows from
  capacity 0; pre-size with Vec::with_capacity (8->1 container reallocs, 4 sites)
- I4 plaintext_stream: lazy emit iterator instead of eager Vec collect (43x wall)
- C1 NC write_etag_element: borrowed pre-escaped quote events, no owned quoted
  String/escape re-alloc; byte-identical output (3->0 allocs/PROPFIND row)
- C3 NC favorites REPORT: map.remove() move instead of get().clone() (~7 allocs/fav)

Deferred (documented in ROUND20.md): NC oc:id/trashbin buffer reuse, I1 sibling
CardDAV/CalDAV listing paths, Contact JSONB Json<Vec<_>> decode, dedup
settle_batch &str bind, and a fast DoS-resistant hasher for hot trusted-key maps
(needs a dependency decision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsJjcVX9RoN96DMa35Wqzd
This commit is contained in:
Claude
2026-07-20 00:18:54 +00:00
parent 6525b9fbd1
commit 867e1fe259
18 changed files with 1502 additions and 162 deletions
+18 -15
View File
@@ -14,6 +14,7 @@ use crate::application::ports::carddav_ports::{
AddressBookUseCase, ContactStoragePort, ContactUseCase,
};
use crate::common::errors::DomainError;
use crate::common::text::ascii_ci_contains;
use crate::domain::entities::contact::{Address, AddressBook, Contact, ContactGroup, Email, Phone};
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
@@ -113,9 +114,11 @@ impl ContactService {
let mut contact = Contact::default();
let lines: Vec<&str> = vcard_data.lines().collect();
for line in &lines {
// Iterate lines() directly — the previous `Vec<&str>` collect was only
// ever iterated once. Per EMAIL/TEL/ADR line the `TYPE=` routing uses
// the allocation-free `ascii_ci_contains` instead of a throwaway
// `line.to_ascii_uppercase()` copy (benches/ROUND20.md §A3).
for line in vcard_data.lines() {
let line = line.trim();
if let Some(stripped) = line.strip_prefix("FN:") {
@@ -132,10 +135,10 @@ impl ContactService {
// from value parsing.
let value = line.split_once(':').map(|(_, v)| v.trim()).unwrap_or("");
if !value.is_empty() {
let params_upper = line.to_ascii_uppercase();
let email_type = if params_upper.contains("TYPE=HOME") {
let lb = line.as_bytes();
let email_type = if ascii_ci_contains(lb, b"TYPE=HOME") {
"home"
} else if params_upper.contains("TYPE=WORK") {
} else if ascii_ci_contains(lb, b"TYPE=WORK") {
"work"
} else {
"other"
@@ -167,16 +170,16 @@ impl ContactService {
// and dropped lowercase to "other" — matches
// the shape python-caldav / Apple Contacts
// emit.
let params_upper = line.to_ascii_uppercase();
let phone_type = if params_upper.contains("TYPE=CELL")
|| params_upper.contains("TYPE=MOBILE")
let lb = line.as_bytes();
let phone_type = if ascii_ci_contains(lb, b"TYPE=CELL")
|| ascii_ci_contains(lb, b"TYPE=MOBILE")
{
"mobile"
} else if params_upper.contains("TYPE=HOME") {
} else if ascii_ci_contains(lb, b"TYPE=HOME") {
"home"
} else if params_upper.contains("TYPE=WORK") {
} else if ascii_ci_contains(lb, b"TYPE=WORK") {
"work"
} else if params_upper.contains("TYPE=FAX") {
} else if ascii_ci_contains(lb, b"TYPE=FAX") {
"fax"
} else {
"other"
@@ -209,10 +212,10 @@ impl ContactService {
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
};
let params_upper = line.to_ascii_uppercase();
let addr_type = if params_upper.contains("TYPE=HOME") {
let lb = line.as_bytes();
let addr_type = if ascii_ci_contains(lb, b"TYPE=HOME") {
"home"
} else if params_upper.contains("TYPE=WORK") {
} else if ascii_ci_contains(lb, b"TYPE=WORK") {
"work"
} else {
"other"
+1 -14
View File
@@ -13,6 +13,7 @@ use crate::application::ports::content_index_ports::{ContentHitDto, ContentIndex
use crate::application::ports::inbound::SearchUseCase;
use crate::application::ports::storage_ports::FileReadPort;
use crate::common::errors::Result;
use crate::common::text::ascii_ci_contains;
use crate::domain::entities::folder::Folder;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
@@ -183,20 +184,6 @@ fn compute_relevance(name: &str, query_lower: &str) -> u32 {
}
}
/// ASCII case-insensitive substring test — the allocation-free equivalent of
/// `haystack_lower.contains(needle_lower)` when both are ASCII.
fn ascii_ci_contains(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
if needle.len() > haystack.len() {
return false;
}
haystack
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle))
}
/// Max content-index candidates fetched per search. Hydration re-filters
/// them in ONE SQL round-trip, so this bounds both index and DB work.
const CONTENT_HITS_LIMIT: usize = 200;