perf: round 6 backend — CardDAV cursor streaming, borrowed NC id chain, binary UUID decode, one-alloc hex
Benchmark-gated (equivalence + BEFORE/AFTER in examples/bench_*, results and reproduce commands in benches/ROUND6.md): - CardDAV whole-book REPORT + depth-1 PROPFIND stream through a PG cursor (stream_contacts_by_book, 500-contact pages) instead of materialising every vCard twice: 8 000 contacts TTFB 37.4 → 7.6 ms (4.9x), peak heap 19.0 → 7.0 MiB (2.7x), wall -23%; REPORT and PROPFIND byte-identical to the buffered writers. - NC numeric-id chain fully borrowed: get_or_create_file_ids/folder_ids take &[&str] and return HashMap<Uuid, i64>; batch_resolve_ids callers (PROPFIND pages, REPORT, trashbin, OCS search) pass id slices and look up via nc_id_of. 2.006 → 0.006 allocs/child (334x), 1.53x wall per 500-child page. batch_check_favorites binds &[&str] as text[]. - file_blob_read_repository listing SELECTs drop id::text/folder_id::text server casts: rows decode binary Uuid (16 vs 36 bytes on the wire) and render once in row_to_file. A/B on 500-row pages: 1.225 → 1.044 ms mean (1.17x), p95 1.686 → 1.345 (bench_uuid_text_cast; single-row, param and min() sites left as-is deliberately). - IncrementalHasher::finalize_hex renders through common::fmt::hex_lower instead of one format! per digest byte: 18 → 1 (md5) / 35 → 1 (sha256) allocs per chunk finalize, 14-15x wall. - Share landing overlaps the access-count UPDATE with the unlock fetch via tokio::join! (one round-trip off every public link hit). - REJECTED by benchmark and reverted: try_join_all fan-out of the batch-favorites authz pre-check — 42.6 → 56.4 ms cold, 0.15 → 0.23 ms warm against local-socket PG (bench_favorites_authz kept as evidence). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
This commit is contained in:
@@ -22,7 +22,8 @@ use axum::{
|
||||
http::{HeaderName, Request, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use bytes::Buf;
|
||||
use bytes::{Buf, Bytes};
|
||||
use quick_xml::Writer;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::adapters::carddav_adapter::{
|
||||
@@ -31,7 +32,7 @@ use crate::application::adapters::carddav_adapter::{
|
||||
use crate::application::adapters::uid_from_multiget_href;
|
||||
use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType};
|
||||
use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto};
|
||||
use crate::application::dtos::contact_dto::CreateContactVCardDto;
|
||||
use crate::application::dtos::contact_dto::{ContactDto, CreateContactVCardDto};
|
||||
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
||||
use crate::application::services::contact_service::ContactService;
|
||||
use crate::common::di::AppState;
|
||||
@@ -187,6 +188,164 @@ fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactService>, App
|
||||
})
|
||||
}
|
||||
|
||||
/// Rows per emitted page for the streaming CardDAV emitters — contacts
|
||||
/// carry no master/exception bundling, so pages cut anywhere.
|
||||
const CARDDAV_STREAM_PAGE_CONTACTS: usize = 500;
|
||||
|
||||
/// Streamed multistatus REPORT: header, one chunk per cursor page,
|
||||
/// footer. Byte-compatible with the buffered
|
||||
/// `generate_contacts_response` output; TTFB becomes the first page and
|
||||
/// the whole-book DTO Vec is never materialised.
|
||||
fn build_streaming_contacts_report(
|
||||
contact_svc: Arc<ContactService>,
|
||||
address_book_id: String,
|
||||
report: CardDavReportType,
|
||||
base_href: String,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Response<Body> {
|
||||
let stream = async_stream::try_stream! {
|
||||
let mut buf = Vec::with_capacity(160);
|
||||
{
|
||||
let mut w = Writer::new(&mut buf);
|
||||
CardDavAdapter::write_report_multistatus_start(&mut w)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
yield Bytes::from(buf);
|
||||
|
||||
{
|
||||
use futures::TryStreamExt;
|
||||
let mut rows = contact_svc
|
||||
.stream_contacts_by_book(&address_book_id, user_id)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
let mut page: Vec<ContactDto> =
|
||||
Vec::with_capacity(CARDDAV_STREAM_PAGE_CONTACTS);
|
||||
loop {
|
||||
let next = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
let flush = match &next {
|
||||
Some(_) => page.len() >= CARDDAV_STREAM_PAGE_CONTACTS,
|
||||
None => !page.is_empty(),
|
||||
};
|
||||
if flush {
|
||||
let mut chunk = Vec::with_capacity(page.len() * 256 + 64);
|
||||
{
|
||||
let mut w = Writer::new(&mut chunk);
|
||||
CardDavAdapter::write_contacts_report_page(
|
||||
&mut w, &page, &report, &base_href,
|
||||
)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
page.clear();
|
||||
yield Bytes::from(chunk);
|
||||
}
|
||||
match next {
|
||||
Some(c) => page.push(c),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut buf = Vec::with_capacity(32);
|
||||
{
|
||||
let mut w = Writer::new(&mut buf);
|
||||
CardDavAdapter::write_carddav_multistatus_end(&mut w)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
yield Bytes::from(buf);
|
||||
};
|
||||
|
||||
use futures::TryStreamExt;
|
||||
let stream = stream
|
||||
.map_err(|e: std::io::Error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) });
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Streamed depth-1 address-book PROPFIND: head (multistatus + the
|
||||
/// book's own response), one chunk per cursor page, footer.
|
||||
fn build_streaming_book_propfind(
|
||||
contact_svc: Arc<ContactService>,
|
||||
address_book: crate::application::dtos::address_book_dto::AddressBookDto,
|
||||
propfind_request: PropFindRequest,
|
||||
address_book_id: String,
|
||||
base_href: String,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Response<Body> {
|
||||
let stream = async_stream::try_stream! {
|
||||
let mut buf = Vec::with_capacity(2048);
|
||||
{
|
||||
let mut w = Writer::new(&mut buf);
|
||||
CardDavAdapter::write_collection_head(
|
||||
&mut w,
|
||||
&address_book,
|
||||
&propfind_request,
|
||||
&base_href,
|
||||
)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
yield Bytes::from(buf);
|
||||
|
||||
{
|
||||
use futures::TryStreamExt;
|
||||
let mut rows = contact_svc
|
||||
.stream_contacts_by_book(&address_book_id, user_id)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
let mut page: Vec<ContactDto> =
|
||||
Vec::with_capacity(CARDDAV_STREAM_PAGE_CONTACTS);
|
||||
loop {
|
||||
let next = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
let flush = match &next {
|
||||
Some(_) => page.len() >= CARDDAV_STREAM_PAGE_CONTACTS,
|
||||
None => !page.is_empty(),
|
||||
};
|
||||
if flush {
|
||||
let mut chunk = Vec::with_capacity(page.len() * 512 + 64);
|
||||
{
|
||||
let mut w = Writer::new(&mut chunk);
|
||||
CardDavAdapter::write_collection_contact_page(&mut w, &page, &base_href)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
page.clear();
|
||||
yield Bytes::from(chunk);
|
||||
}
|
||||
match next {
|
||||
Some(c) => page.push(c),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut buf = Vec::with_capacity(32);
|
||||
{
|
||||
let mut w = Writer::new(&mut buf);
|
||||
CardDavAdapter::write_carddav_multistatus_end(&mut w)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
yield Bytes::from(buf);
|
||||
};
|
||||
|
||||
use futures::TryStreamExt;
|
||||
let stream = stream
|
||||
.map_err(|e: std::io::Error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) });
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn get_contact_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
|
||||
state.contact_use_case.as_ref().ok_or_else(|| {
|
||||
AppError::new(
|
||||
@@ -334,14 +493,19 @@ async fn handle_propfind(
|
||||
.await
|
||||
.map_err(|e| AppError::not_found(format!("Address book not found: {}", e)))?;
|
||||
|
||||
let contacts = if depth != "0" {
|
||||
contact_svc
|
||||
.list_contacts(address_book_id, None, None, user.id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
// Depth-1 streams the contact listing page by page; depth-0
|
||||
// has no contact section and keeps the tiny buffered path.
|
||||
if depth != "0" {
|
||||
let base_href = format!("/carddav/{}/", address_book_id);
|
||||
return Ok(build_streaming_book_propfind(
|
||||
contact_svc.clone(),
|
||||
address_book,
|
||||
propfind_request,
|
||||
address_book_id.to_string(),
|
||||
base_href,
|
||||
user.id,
|
||||
));
|
||||
}
|
||||
|
||||
let base_href = &format!("/carddav/{}/", address_book_id);
|
||||
let mut response_body = Vec::new();
|
||||
@@ -349,7 +513,7 @@ async fn handle_propfind(
|
||||
CardDavAdapter::generate_addressbook_collection_propfind(
|
||||
&mut response_body,
|
||||
&address_book,
|
||||
&contacts,
|
||||
&[],
|
||||
&propfind_request,
|
||||
base_href,
|
||||
&depth,
|
||||
@@ -423,11 +587,25 @@ async fn handle_report(
|
||||
return Err(AppError::bad_request("Address book ID required in path"));
|
||||
}
|
||||
|
||||
// Whole-book shapes stream; bounded multiget keeps the buffered path.
|
||||
if matches!(
|
||||
&report,
|
||||
CardDavReportType::AddressbookQuery { .. } | CardDavReportType::SyncCollection { .. }
|
||||
) {
|
||||
let base_href = format!("/carddav/{}/", address_book_id);
|
||||
return Ok(build_streaming_contacts_report(
|
||||
contact_svc.clone(),
|
||||
address_book_id.to_string(),
|
||||
report,
|
||||
base_href,
|
||||
user.id,
|
||||
));
|
||||
}
|
||||
|
||||
let contacts = match &report {
|
||||
CardDavReportType::AddressbookQuery { .. } => contact_svc
|
||||
.list_contacts(address_book_id, None, None, user.id)
|
||||
.await
|
||||
.map_err(AppError::from)?,
|
||||
CardDavReportType::AddressbookQuery { .. } => {
|
||||
unreachable!("addressbook-query streams above")
|
||||
}
|
||||
CardDavReportType::AddressbookMultiget { hrefs, .. } => {
|
||||
// Indexed batch lookup (`uid = ANY(...)`) — a multiget for a
|
||||
// handful of contacts must not pay for listing the whole
|
||||
@@ -442,10 +620,9 @@ async fn handle_report(
|
||||
.await
|
||||
.map_err(AppError::from)?
|
||||
}
|
||||
CardDavReportType::SyncCollection { .. } => contact_svc
|
||||
.list_contacts(address_book_id, None, None, user.id)
|
||||
.await
|
||||
.map_err(AppError::from)?,
|
||||
CardDavReportType::SyncCollection { .. } => {
|
||||
unreachable!("sync-collection streams above")
|
||||
}
|
||||
};
|
||||
|
||||
let base_href = &format!("/carddav/{}/", address_book_id);
|
||||
|
||||
@@ -232,17 +232,18 @@ pub async fn access_shared_item(
|
||||
Path(token): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
// Register the access
|
||||
let _ = share_use_case.register_shared_link_access(&token).await;
|
||||
|
||||
// Honour an unlock cookie if one was issued by a prior `/verify` call.
|
||||
let unlock_jwt = unlock_jwt_from_headers(&headers, &token);
|
||||
|
||||
// Get the shared link
|
||||
match share_use_case
|
||||
.get_shared_link_with_unlock(&token, unlock_jwt.as_deref())
|
||||
.await
|
||||
{
|
||||
// The access-count increment doesn't gate the fetch — run both
|
||||
// round-trips concurrently instead of serially (one RTT saved on
|
||||
// every public share landing).
|
||||
let (_, item) = tokio::join!(
|
||||
share_use_case.register_shared_link_access(&token),
|
||||
share_use_case.get_shared_link_with_unlock(&token, unlock_jwt.as_deref()),
|
||||
);
|
||||
|
||||
match item {
|
||||
Ok(item) => (StatusCode::OK, Json(item)).into_response(),
|
||||
Err(err) => {
|
||||
// Special handling for share access errors
|
||||
|
||||
Reference in New Issue
Block a user