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:
@@ -31,15 +31,18 @@ impl Default for AddressBookDto {
|
||||
|
||||
impl From<AddressBook> for AddressBookDto {
|
||||
fn from(book: AddressBook) -> Self {
|
||||
// Owned entity → move the owned fields instead of cloning through the
|
||||
// borrowing accessors (benches/ROUND20.md §A4).
|
||||
let p = book.into_parts();
|
||||
Self {
|
||||
id: book.id().to_string(),
|
||||
name: book.name().to_string(),
|
||||
owner_id: book.owner_id().to_string(),
|
||||
description: book.description().map(|s| s.to_string()),
|
||||
color: book.color().map(|s| s.to_string()),
|
||||
is_public: book.is_public(),
|
||||
created_at: *book.created_at(),
|
||||
updated_at: *book.updated_at(),
|
||||
id: p.id.to_string(),
|
||||
name: p.name,
|
||||
owner_id: p.owner_id,
|
||||
description: p.description,
|
||||
color: p.color,
|
||||
is_public: p.is_public,
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,16 +36,20 @@ impl Default for CalendarDto {
|
||||
|
||||
impl From<Calendar> for CalendarDto {
|
||||
fn from(calendar: Calendar) -> Self {
|
||||
// `calendar` is owned and dropped here — move the heap fields (notably
|
||||
// the `custom_properties` HashMap) instead of cloning them through the
|
||||
// borrowing accessors (benches/ROUND20.md §A4).
|
||||
let p = calendar.into_parts();
|
||||
Self {
|
||||
id: calendar.id().to_string(),
|
||||
name: calendar.name().to_string(),
|
||||
owner_id: calendar.owner_id().to_string(),
|
||||
description: calendar.description().map(|s| s.to_string()),
|
||||
color: calendar.color().map(|s| s.to_string()),
|
||||
id: p.id.to_string(),
|
||||
name: p.name,
|
||||
owner_id: p.owner_id.to_string(),
|
||||
description: p.description,
|
||||
color: p.color,
|
||||
is_public: false, // This needs to be set separately as it's not part of the domain entity
|
||||
created_at: *calendar.created_at(),
|
||||
updated_at: *calendar.updated_at(),
|
||||
custom_properties: calendar.custom_properties().clone(),
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
custom_properties: p.custom_properties,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,27 +75,37 @@ pub struct UserDto {
|
||||
|
||||
impl From<User> for UserDto {
|
||||
fn from(user: User) -> Self {
|
||||
// `user` is owned and dropped here, so every owned field is MOVED out
|
||||
// via `into_parts` rather than cloned through the borrowing accessors —
|
||||
// the accessor form deep-cloned `image` (a data URI up to 512 KiB) and
|
||||
// the whole `ui_preferences` JSON tree on every `/api/auth/me` and admin
|
||||
// user listing (benches/ROUND20.md §A2). The two derived values read the
|
||||
// entity before the move.
|
||||
let role = format!("{}", user.role());
|
||||
let can_edit_image = !user.is_oidc_user();
|
||||
let p = user.into_parts();
|
||||
Self {
|
||||
id: user.id().to_string(),
|
||||
username: user.username().map(str::to_string),
|
||||
email: user.email().to_string(),
|
||||
role: format!("{}", user.role()),
|
||||
storage_quota_bytes: user.storage_quota_bytes(),
|
||||
storage_used_bytes: user.storage_used_bytes(),
|
||||
created_at: user.created_at(),
|
||||
updated_at: user.updated_at(),
|
||||
last_login_at: user.last_login_at(),
|
||||
active: user.is_active(),
|
||||
auth_provider: user.oidc_provider().unwrap_or("local").to_string(),
|
||||
image: user.image().map(|s| s.to_string()),
|
||||
can_edit_image: !user.is_oidc_user(),
|
||||
is_external: user.is_external(),
|
||||
given_name: user.given_name().map(str::to_string),
|
||||
family_name: user.family_name().map(str::to_string),
|
||||
email_verified_at: user.email_verified_at(),
|
||||
preferred_locale: user.preferred_locale().map(str::to_string),
|
||||
notify_on_share: user.notify_on_share(),
|
||||
ui_preferences: user.ui_preferences().clone(),
|
||||
id: p.id.to_string(),
|
||||
username: p.username,
|
||||
email: p.email,
|
||||
role,
|
||||
storage_quota_bytes: p.storage_quota_bytes,
|
||||
storage_used_bytes: p.storage_used_bytes,
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
last_login_at: p.last_login_at,
|
||||
active: p.active,
|
||||
// Some(provider) moves the String; None still allocates "local".
|
||||
auth_provider: p.oidc_provider.unwrap_or_else(|| "local".to_string()),
|
||||
image: p.image,
|
||||
can_edit_image,
|
||||
is_external: p.is_external,
|
||||
given_name: p.given_name,
|
||||
family_name: p.family_name,
|
||||
email_verified_at: p.email_verified_at,
|
||||
preferred_locale: p.preferred_locale,
|
||||
notify_on_share: p.notify_on_share,
|
||||
ui_preferences: p.ui_preferences,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user