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:
@@ -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")))?;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user