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
+42
View File
@@ -49,6 +49,48 @@ pub struct Calendar {
custom_properties: std::collections::HashMap<String, String>,
}
/// Owned decomposition of a [`Calendar`] (mirrors `FileParts`/`UserParts`).
/// Lets `CalendarDto::from` MOVE the heap fields — notably the
/// `custom_properties` map — instead of cloning them on every CalDAV discovery
/// listing (benches/ROUND20.md §A4).
pub struct CalendarParts {
pub id: Uuid,
pub name: String,
pub owner_id: Uuid,
pub description: Option<String>,
pub color: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub custom_properties: std::collections::HashMap<String, String>,
}
impl Calendar {
/// Decompose into [`CalendarParts`], moving every owned field out
/// (exhaustive destructure — compiler-checked against added fields).
pub fn into_parts(self) -> CalendarParts {
let Calendar {
id,
name,
owner_id,
description,
color,
created_at,
updated_at,
custom_properties,
} = self;
CalendarParts {
id,
name,
owner_id,
description,
color,
created_at,
updated_at,
custom_properties,
}
}
}
impl Calendar {
/**
* Creates a new calendar with the given properties.
+60 -36
View File
@@ -297,8 +297,12 @@ impl CalendarEvent {
// rather than scanning the raw property line. The pre-parser-
// rewrite substring scan couldn't see param-carrying lines at
// all — see #528.
let (dtstart_value, dtstart_params) = Self::prop_with_params(&event, "DTSTART")
.ok_or_else(|| {
// DTSTART carries the value AND the all-day flag: a `VALUE=DATE`
// parameter (RFC 5545 §3.3.4) means date-only. Strict — only "DATE"
// (case-insensitive) counts; "DATE-TIME" and anything else is timed.
// The flag drives both the DTSTART and the DTEND datetime parse below.
let (dtstart_value, all_day) =
Self::prop_value_and_is_date(&event, "DTSTART").ok_or_else(|| {
DomainError::new(
ErrorKind::InvalidInput,
"CalendarEvent",
@@ -306,25 +310,14 @@ impl CalendarEvent {
)
})?;
let (dtend_value, _dtend_params) =
Self::prop_with_params(&event, "DTEND").ok_or_else(|| {
DomainError::new(
ErrorKind::InvalidInput,
"CalendarEvent",
"Missing DTEND in iCalendar data",
)
})?;
// All-day detection: `VALUE=DATE` parameter on DTSTART.
// Falls back to `false` when the parameter is absent, matching
// RFC 5545 §3.3.4 ("If the property permits, multiple 'VALUE'
// parameters can be specified as a comma-separated list") —
// we're strict: only "DATE" (case-insensitive) counts, "DATE-TIME"
// and anything else means timed.
let all_day = dtstart_params
.get("VALUE")
.map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
.unwrap_or(false);
// DTEND needs only its value (the all-day flag comes from DTSTART).
let dtend_value = Self::prop_value(&event, "DTEND").ok_or_else(|| {
DomainError::new(
ErrorKind::InvalidInput,
"CalendarEvent",
"Missing DTEND in iCalendar data",
)
})?;
let start_time = Self::parse_ical_datetime(&dtstart_value, all_day).map_err(|e| {
DomainError::new(
@@ -359,14 +352,8 @@ impl CalendarEvent {
// gets stored, just as a plain event (worst case a client sync
// treats it as a new master, which the DB uniqueness will
// refuse; better a persistence error than a silent split).
let recurrence_id = match Self::prop_with_params(&event, "RECURRENCE-ID") {
Some((value, params)) => {
let is_date = params
.get("VALUE")
.map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
.unwrap_or(false);
Self::parse_ical_datetime(&value, is_date).ok()
}
let recurrence_id = match Self::prop_value_and_is_date(&event, "RECURRENCE-ID") {
Some((value, is_date)) => Self::parse_ical_datetime(&value, is_date).ok(),
None => None,
};
@@ -701,23 +688,20 @@ impl CalendarEvent {
// (they need to know whether the value is a date or a datetime).
let dtstart_pair = event
.as_ref()
.and_then(|e| Self::prop_with_params(e, "DTSTART"));
.and_then(|e| Self::prop_value_and_is_date(e, "DTSTART"));
let all_day = dtstart_pair
.as_ref()
.and_then(|(_v, params)| params.get("VALUE"))
.map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
.map(|(_v, is_date)| *is_date)
.unwrap_or(false);
self.all_day = all_day;
if let Some((value, _params)) = &dtstart_pair
if let Some((value, _is_date)) = &dtstart_pair
&& let Ok(start_time) = Self::parse_ical_datetime(value, all_day)
{
self.start_time = start_time;
}
if let Some((value, _params)) = event
.as_ref()
.and_then(|e| Self::prop_with_params(e, "DTEND"))
if let Some(value) = event.as_ref().and_then(|e| Self::prop_value(e, "DTEND"))
&& let Ok(end_time) = Self::parse_ical_datetime(&value, all_day)
{
self.end_time = end_time;
@@ -861,12 +845,52 @@ impl CalendarEvent {
Some(trimmed.to_string())
}
/// Read a property's trimmed value plus whether it carries a
/// case-insensitive `VALUE=DATE` parameter (the all-day / date-only
/// marker) — the ONLY thing `from_ical` / `update_ical_data` ever asked the
/// parameter map for. Scans `prop.params` directly, so DTSTART / DTEND /
/// RECURRENCE-ID no longer build a throwaway
/// `HashMap<String, Vec<String>>` (uppercased keys + cloned value Vecs) per
/// event on every CalDAV PUT / iCal import (benches/ROUND20.md §A1).
///
/// `.rev().find(...)` preserves the old map's last-insert-wins semantics for
/// the (pathological) duplicate-`VALUE` case, so the flag is byte-identical.
fn prop_value_and_is_date(
event: &ical::parser::ical::component::IcalEvent,
property_name: &str,
) -> Option<(String, bool)> {
let prop = event
.properties
.iter()
.find(|p| p.name.eq_ignore_ascii_case(property_name))?;
let trimmed = prop.value.as_deref()?.trim();
if trimmed.is_empty() {
return None;
}
let is_date = prop
.params
.as_ref()
.and_then(|list| {
list.iter()
.rev()
.find(|(n, _)| n.eq_ignore_ascii_case("VALUE"))
})
.map(|(_, vs)| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
.unwrap_or(false);
Some((trimmed.to_string(), is_date))
}
/// Read a property's trimmed value AND parameter map from an
/// already-parsed VEVENT. The map is keyed by parameter name
/// (`"VALUE"`, `"TZID"`, `"CN"`, …) whose value is the list of
/// parameter values (parameters can be multi-valued —
/// `MEMBER="mailto:a@x","mailto:b@x"` — hence the `Vec<String>`
/// per key).
///
/// Retained only for the `#[cfg(test)]` `extract_ical_property_with_params`
/// wrapper; production parses once and uses [`Self::prop_value_and_is_date`]
/// / [`Self::prop_value`].
#[cfg(test)]
fn prop_with_params(
event: &ical::parser::ical::component::IcalEvent,
property_name: &str,
+42
View File
@@ -13,6 +13,48 @@ pub struct AddressBook {
updated_at: DateTime<Utc>,
}
/// Owned decomposition of an [`AddressBook`] (mirrors `FileParts`/`UserParts`).
/// Lets `AddressBookDto::from` MOVE `name`/`description`/`color`/`owner_id`
/// instead of cloning them on every CardDAV discovery listing
/// (benches/ROUND20.md §A4).
pub struct AddressBookParts {
pub id: Uuid,
pub name: String,
pub owner_id: String,
pub description: Option<String>,
pub color: Option<String>,
pub is_public: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl AddressBook {
/// Decompose into [`AddressBookParts`], moving every owned field out
/// (exhaustive destructure — compiler-checked against added fields).
pub fn into_parts(self) -> AddressBookParts {
let AddressBook {
id,
name,
owner_id,
description,
color,
is_public,
created_at,
updated_at,
} = self;
AddressBookParts {
id,
name,
owner_id,
description,
color,
is_public,
created_at,
updated_at,
}
}
}
impl AddressBook {
/// Creates a new AddressBook with generated id and timestamps
pub fn new(
+81
View File
@@ -135,7 +135,88 @@ pub struct User {
ui_preferences: serde_json::Value,
}
/// Owned decomposition of a [`User`] (mirrors `FileParts` / `FolderParts` /
/// `ContactParts`). Lets a consumer MOVE the heap fields out instead of cloning
/// them through the borrowing accessors — notably `image` (a data URI up to
/// 512 KiB) and `ui_preferences` (a JSON tree). See `UserDto::from`
/// (benches/ROUND20.md §A2).
pub struct UserParts {
pub id: Uuid,
pub username: Option<String>,
pub email: String,
pub password_hash: Option<String>,
pub role: UserRole,
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
pub oidc_provider: Option<String>,
pub oidc_subject: Option<String>,
pub image: Option<String>,
pub is_external: bool,
pub given_name: Option<String>,
pub family_name: Option<String>,
pub email_verified_at: Option<DateTime<Utc>>,
pub preferred_locale: Option<String>,
pub notify_on_share: bool,
pub ui_preferences: serde_json::Value,
}
impl User {
/// Decompose into [`UserParts`], moving every owned field out. The
/// exhaustive destructure is compiler-checked, so a future field can't be
/// silently dropped.
pub fn into_parts(self) -> UserParts {
let User {
id,
username,
email,
password_hash,
role,
storage_quota_bytes,
storage_used_bytes,
created_at,
updated_at,
last_login_at,
active,
oidc_provider,
oidc_subject,
image,
is_external,
given_name,
family_name,
email_verified_at,
preferred_locale,
notify_on_share,
ui_preferences,
} = self;
UserParts {
id,
username,
email,
password_hash,
role,
storage_quota_bytes,
storage_used_bytes,
created_at,
updated_at,
last_login_at,
active,
oidc_provider,
oidc_subject,
image,
is_external,
given_name,
family_name,
email_verified_at,
preferred_locale,
notify_on_share,
ui_preferences,
}
}
/// Create a new user.
///
/// One unified constructor for every kind of user (internal, OIDC-linked,