perf: round 14 — faces narrow projection, auth per-request allocs, CalDAV emit buffers, frontend set churn

Benchmark-gated (benches/ROUND14.md); every change ships a BEFORE/AFTER
benchmark with an equivalence gate and is rolled back on regression (the
rule is encoded as a GATE FAIL exit / threshold expect).

Backend
- Q1 faces_for_file → narrow face_boxes_for_file(id, person_id, bbox) with the
  caller filter pushed into SQL: drops the 2 KiB embedding BYTEA + 6 unused
  columns per face. 15-face lightbox open 0.312→0.219 ms, 32 KB→840 B/req.
- A1 cookie auth uses the borrow-only extract_cookie_str (already backs CSRF)
  instead of extract_cookie_value's owned String: -1 alloc/cookie request.
- A2 compute_relevance ASCII case-fold fast path vs name.to_lowercase() per
  result row (Unicode fallback preserved): 1.40x, 12→3 allocs/page.
- A3 sub pre-parsed to Uuid at decode time (TokenClaims.sub_id) vs re-parsing
  the 36-char claim on every request incl. cache hits: 22.7→0.7 ns.
- A4 auth + NextCloud middlewares borrow request.headers() instead of taking
  axum's HeaderMap extractor (a full map clone): 2→0 allocs/authed request.
- A5 CalDAV getlastmodified via the stack rfc2822_utc (byte-identical to
  chrono) vs a per-event to_rfc2822() heap String: 5→0 allocs.
- A6 CalDAV per-event href + quoted etag written into reused page buffers vs a
  fresh format! pair per event: 3.48x, 240→6 allocs/40-event page.

Frontend
- F1 t() shares one frozen EMPTY_PARAMS for the no-interpolation call forms vs
  a throwaway {} per call: -1 alloc/call.
- F2 favorites favoriteIds is a persistent SvelteSet with per-page add (clear
  on reset) vs a brand-new set over the whole accumulated list each page:
  22.3x over a 40-page drain (O(N^2)→O(N)).

Verified: cargo check --all-targets, cargo clippy -D warnings, both bench
packs (GATE PASS), frontend npm run check + vitest (4/4). ROUND14.md also
records the investigated-but-deferred backlog (music N+1, contact vcard
over-fetch, CachedBlobBackend syscalls, ResourceList.sections builder, etc.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PymgCdK78NzUF3oRAQCJfN
This commit is contained in:
Claude
2026-07-19 10:22:12 +00:00
parent 3d578e4fc2
commit c930f865b0
19 changed files with 1345 additions and 73 deletions
+69 -24
View File
@@ -789,11 +789,9 @@ impl CalDavAdapter {
xml_writer.write_event(Event::Text(BytesText::new(&calendar.name)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
// Last modified
// Last modified (stack render, benches/ROUND14.md §A5)
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
xml_writer.write_event(Event::Text(BytesText::new(
&calendar.updated_at.to_rfc2822(),
)))?;
Self::write_lastmodified_text(xml_writer, calendar.updated_at)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
// ETag
@@ -920,9 +918,8 @@ impl CalDavAdapter {
}
("DAV:", "getlastmodified") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
xml_writer.write_event(Event::Text(BytesText::new(
&calendar.updated_at.to_rfc2822(),
)))?;
// Stack render (benches/ROUND14.md §A5).
Self::write_lastmodified_text(xml_writer, calendar.updated_at)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
}
("DAV:", "getetag") => {
@@ -1097,11 +1094,34 @@ impl CalDavAdapter {
/// response per DB row made clients dedupe the shared href and the
/// exception appeared to vanish. Callers guarantee same-UID rows
/// arrive within a single page.
/// Emit an RFC 2822 `getlastmodified` text node with the allocation-free
/// stack renderer (byte-identical to `chrono::to_rfc2822` — the parity
/// gate lives in `common::fmt`), falling back to chrono only for
/// out-of-4-digit-year timestamps. Mirrors the CardDAV emitter; replaces
/// the per-event `updated_at.to_rfc2822()` heap `String`
/// (benches/ROUND14.md §A5).
fn write_lastmodified_text<W: Write>(
xml_writer: &mut Writer<W>,
ts: DateTime<Utc>,
) -> Result<()> {
let mut buf = [0u8; 31];
match crate::common::fmt::rfc2822_utc(&mut buf, ts.timestamp()) {
Some(s) => xml_writer.write_event(Event::Text(BytesText::new(s)))?,
None => xml_writer.write_event(Event::Text(BytesText::new(&ts.to_rfc2822())))?,
}
Ok(())
}
pub fn write_collection_event_page<W: Write>(
xml_writer: &mut Writer<W>,
events: &[CalendarEventDto],
base_href: &str,
) -> Result<()> {
// Reused per-event buffers (cleared each iteration) so a whole PROPFIND
// page allocates the href/etag storage once instead of twice per event
// (benches/ROUND14.md §A6).
let mut event_href = String::with_capacity(base_href.len() + 48);
let mut etag = String::new();
for bundle in group_events_by_uid(events) {
// The master (sorted first by group_events_by_uid)
// supplies the ETag anchor + getlastmodified. If
@@ -1111,7 +1131,11 @@ impl CalDavAdapter {
Some(e) => *e,
None => continue,
};
let event_href = format!("{}{}.ics", base_href, anchor.ical_uid);
event_href.clear();
let _ = std::fmt::Write::write_fmt(
&mut event_href,
format_args!("{}{}.ics", base_href, anchor.ical_uid),
);
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
@@ -1124,9 +1148,11 @@ impl CalDavAdapter {
// resourcetype (empty for non-collection)
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
// getetag — anchor row's id
// getetag — anchor row's id (reused buffer, benches/ROUND14.md §A6)
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?;
etag.clear();
let _ = std::fmt::Write::write_fmt(&mut etag, format_args!("\"{}\"", anchor.id));
xml_writer.write_event(Event::Text(BytesText::new(&etag)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
// getcontenttype
@@ -1136,9 +1162,9 @@ impl CalDavAdapter {
)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
// getlastmodified — anchor row's updated_at
// getlastmodified — anchor row's updated_at (stack render, §A5)
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
xml_writer.write_event(Event::Text(BytesText::new(&anchor.updated_at.to_rfc2822())))?;
Self::write_lastmodified_text(xml_writer, anchor.updated_at)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
@@ -1189,13 +1215,21 @@ impl CalDavAdapter {
CalDavReportType::CalendarMultiget { props, .. } => props,
CalDavReportType::SyncCollection { props, .. } => props,
};
// Reused per-event href + etag buffers for the whole REPORT page
// (benches/ROUND14.md §A6).
let mut href = String::with_capacity(base_href.len() + 48);
let mut etag = String::new();
for bundle in group_events_by_uid(events) {
let anchor = match bundle.first() {
Some(e) => *e,
None => continue,
};
let href = format!("{}{}.ics", base_href, anchor.ical_uid);
Self::write_event_response(xml_writer, &bundle, props, &href)?;
href.clear();
let _ = std::fmt::Write::write_fmt(
&mut href,
format_args!("{}{}.ics", base_href, anchor.ical_uid),
);
Self::write_event_response(xml_writer, &bundle, props, &href, &mut etag)?;
}
Ok(())
}
@@ -1232,6 +1266,7 @@ impl CalDavAdapter {
bundle: &[&CalendarEventDto],
props: &[QualifiedName],
href: &str,
etag: &mut String,
) -> Result<()> {
let anchor = bundle
.first()
@@ -1254,10 +1289,10 @@ impl CalDavAdapter {
// If no specific props requested, return all common ones
if props.is_empty() {
Self::write_event_standard_props(xml_writer, anchor, bundle)?;
Self::write_event_standard_props(xml_writer, anchor, bundle, etag)?;
} else {
// Write specifically requested properties
Self::write_event_requested_props(xml_writer, anchor, bundle, props)?;
Self::write_event_requested_props(xml_writer, anchor, bundle, props, etag)?;
}
// End prop
@@ -1285,6 +1320,7 @@ impl CalDavAdapter {
xml_writer: &mut Writer<W>,
anchor: &CalendarEventDto,
bundle: &[&CalendarEventDto],
etag: &mut String,
) -> Result<()> {
// Common WebDAV properties
@@ -1293,8 +1329,13 @@ impl CalDavAdapter {
// ETag anchored on the master (or first exception in
// a master-less bundle — pathological state today).
// Reused buffer (benches/ROUND14.md §A6).
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?;
etag.clear();
etag.push('"');
etag.push_str(&anchor.id);
etag.push('"');
xml_writer.write_event(Event::Text(BytesText::new(etag.as_str())))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
// Content type
@@ -1304,9 +1345,9 @@ impl CalDavAdapter {
)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
// Last modified
// Last modified (stack render, benches/ROUND14.md §A5)
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
xml_writer.write_event(Event::Text(BytesText::new(&anchor.updated_at.to_rfc2822())))?;
Self::write_lastmodified_text(xml_writer, anchor.updated_at)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
// CalDAV calendar-data — the whole bundle emitted as one
@@ -1329,6 +1370,7 @@ impl CalDavAdapter {
anchor: &CalendarEventDto,
bundle: &[&CalendarEventDto],
props: &[QualifiedName],
etag: &mut String,
) -> Result<()> {
for prop in props {
match (prop.namespace.as_str(), prop.name.as_str()) {
@@ -1338,8 +1380,12 @@ impl CalDavAdapter {
}
("DAV:", "getetag") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer
.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?;
// Reused buffer (benches/ROUND14.md §A6).
etag.clear();
etag.push('"');
etag.push_str(&anchor.id);
etag.push('"');
xml_writer.write_event(Event::Text(BytesText::new(etag.as_str())))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
}
("DAV:", "getcontenttype") => {
@@ -1351,9 +1397,8 @@ impl CalDavAdapter {
}
("DAV:", "getlastmodified") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
xml_writer.write_event(Event::Text(BytesText::new(
&anchor.updated_at.to_rfc2822(),
)))?;
// Stack render (benches/ROUND14.md §A5).
Self::write_lastmodified_text(xml_writer, anchor.updated_at)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
}
+7
View File
@@ -37,6 +37,13 @@ pub trait PasswordHasherPort: Send + Sync + 'static {
pub struct TokenClaims {
/// Subject identifier (user ID)
pub sub: String,
/// `sub` pre-parsed to a `Uuid` at decode time so the auth middleware
/// reads it as a `Copy` on every request instead of re-parsing the
/// 36-char string per request — even on validation-cache hits, which
/// return the same `Arc<TokenClaims>` (benches/ROUND14.md §A3). Nil only
/// if a verified token somehow carried a non-UUID `sub` (unreachable for
/// tokens we sign); the middleware rejects nil defensively.
pub sub_id: Uuid,
/// Expiration timestamp (seconds since Unix epoch)
pub exp: i64,
/// Issued at timestamp (seconds since Unix epoch)
+11 -2
View File
@@ -4,7 +4,7 @@ use async_trait::async_trait;
use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::entities::face::{DetectedFace, Face, Person};
use crate::domain::entities::face::{DetectedFace, Face, FaceBox, Person};
/// Detects faces in an image and produces an aligned, L2-normalized embedding
/// for each. Takes raw encoded bytes (it decodes internally) so the
@@ -29,7 +29,16 @@ pub trait FaceAnalyzerPort: Send + Sync + 'static {
pub trait FaceRepository: Send + Sync + 'static {
// ── faces ──────────────────────────────────────────────────────
async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError>;
async fn faces_for_file(&self, file_id: Uuid) -> Result<Vec<Face>, DomainError>;
/// Face boxes for a photo, caller-scoped — the lightbox tagging overlay
/// needs only `(id, person_id, bbox)`, so this narrow projection drops the
/// 2 KiB embedding BYTEA (+ det_score/quality/blob_hash/created_at) a full
/// `Face` fetch hydrates, and pushes the caller filter into SQL instead of
/// filtering in Rust. See benches/ROUND14.md §Q1.
async fn face_boxes_for_file(
&self,
file_id: Uuid,
user_id: Uuid,
) -> Result<Vec<FaceBox>, DomainError>;
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError>;
async fn faces_for_user(&self, user_id: Uuid) -> Result<Vec<Face>, DomainError>;
/// Faces previously computed for any file sharing this content hash —
+4 -3
View File
@@ -245,10 +245,11 @@ impl PeopleService {
caller_id: Uuid,
file_id: Uuid,
) -> Result<Vec<FaceBoxDto>, DomainError> {
let faces = self.repo.faces_for_file(file_id).await?;
Ok(faces
// The narrow projection scopes to the caller in SQL (WHERE user_id),
// so no post-filter is needed here. See benches/ROUND14.md §Q1.
let boxes = self.repo.face_boxes_for_file(file_id, caller_id).await?;
Ok(boxes
.into_iter()
.filter(|f| f.user_id == caller_id)
.map(|f| FaceBoxDto {
id: f.id.to_string(),
person_id: f.person_id.map(|u| u.to_string()),
+46 -11
View File
@@ -146,22 +146,57 @@ pub fn build_search_results_cache(
///
/// `query_lower` **must** already be lowercased by the caller so that the
/// allocation happens once per search, not once per result.
///
/// The overwhelmingly common all-ASCII filename takes an allocation-free
/// ASCII case-fold fast path — `name.to_lowercase()` (full Unicode) is pure
/// waste there, and it ran once *per result row* (and per keystroke on the
/// suggest path). Non-ASCII names fall back to the exact Unicode-lowercase
/// comparison, so behavior is unchanged (for ASCII, lowercasing preserves
/// length, so the `contains` length ratio is identical). See benches/ROUND14.md §A2.
fn compute_relevance(name: &str, query_lower: &str) -> u32 {
let name_lower = name.to_lowercase();
if name_lower == query_lower {
100
} else if name_lower.starts_with(query_lower) {
80
} else if name_lower.contains(query_lower) {
// Bonus for shorter names (more specific match)
let ratio = query_lower.len() as f64 / name_lower.len() as f64;
50 + (ratio * 20.0) as u32
if name.is_ascii() {
let (nb, qb) = (name.as_bytes(), query_lower.as_bytes());
if nb.eq_ignore_ascii_case(qb) {
100
} else if nb.len() >= qb.len() && nb[..qb.len()].eq_ignore_ascii_case(qb) {
80
} else if ascii_ci_contains(nb, qb) {
// Bonus for shorter names (more specific match). ASCII lowercase
// preserves length, so `name.len()` == the old `name_lower.len()`.
let ratio = query_lower.len() as f64 / name.len() as f64;
50 + (ratio * 20.0) as u32
} else {
0
}
} else {
0
let name_lower = name.to_lowercase();
if name_lower == query_lower {
100
} else if name_lower.starts_with(query_lower) {
80
} else if name_lower.contains(query_lower) {
let ratio = query_lower.len() as f64 / name_lower.len() as f64;
50 + (ratio * 20.0) as u32
} else {
0
}
}
}
/// 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;