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:
Claude
2026-07-18 09:03:33 +00:00
parent 61c9470981
commit 9729f033b2
24 changed files with 2012 additions and 154 deletions
+4 -3
View File
@@ -411,8 +411,8 @@ pub async fn handle_search(
// Pre-resolve numeric ids for every file result in a single batch query
// (was one INSERT round-trip per result).
let file_uuids: Vec<String> = results.files.iter().map(|f| f.id.clone()).collect();
let file_id_map: HashMap<String, i64> = match file_id_svc {
let file_uuids: Vec<&str> = results.files.iter().map(|f| f.id.as_str()).collect();
let file_id_map: HashMap<uuid::Uuid, i64> = match file_id_svc {
Some(svc) => svc
.get_or_create_file_ids(&file_uuids)
.await
@@ -435,7 +435,8 @@ pub async fn handle_search(
crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&file.path);
let display_path = format!("/{}", display_path);
let numeric_id = file_id_map.get(&file.id).copied();
let numeric_id =
crate::interfaces::nextcloud::webdav_handler::nc_id_of(&file_id_map, &file.id);
let thumbnail_url = match numeric_id {
Some(nid) => format!("/index.php/core/preview?fileId={}&x=32&y=32", nid),
+9 -9
View File
@@ -26,7 +26,7 @@ use crate::interfaces::api::handlers::webdav_handler::{
};
use crate::interfaces::errors::AppError;
use crate::interfaces::nextcloud::webdav_handler::{
batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response,
batch_resolve_ids, format_oc_id, nc_href, nc_id_of, write_file_response, write_folder_response,
};
/// Handle WebDAV REPORT and SEARCH methods for Nextcloud compatibility.
@@ -150,8 +150,8 @@ async fn handle_filter_files(
}
// Pass 2: resolve every oc:fileid in two batch queries (was one per item).
let file_uuids: Vec<String> = files.iter().map(|f| f.id.clone()).collect();
let folder_uuids: Vec<String> = folders.iter().map(|f| f.id.clone()).collect();
let file_uuids: Vec<&str> = files.iter().map(|f| f.id.as_str()).collect();
let folder_uuids: Vec<&str> = folders.iter().map(|f| f.id.as_str()).collect();
let (file_id_map, folder_id_map) =
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
@@ -184,7 +184,7 @@ async fn handle_filter_files(
continue;
};
let href = nc_href(url_user, subpath);
let fid = file_id_map.get(&file.id).copied();
let fid = nc_id_of(&file_id_map, &file.id);
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
let dead = dead_props_for(&file.id, &file_deads);
write_file_response(
@@ -210,7 +210,7 @@ async fn handle_filter_files(
continue;
};
let href = format!("{}/", nc_href(url_user, subpath));
let fid = folder_id_map.get(&folder.id).copied();
let fid = nc_id_of(&folder_id_map, &folder.id);
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
let dead = dead_props_for(&folder.id, &folder_deads);
write_folder_response(
@@ -297,8 +297,8 @@ async fn handle_search(
// (was one INSERT round-trip per result).
let files: Vec<FileDto> = results.files.iter().map(file_dto_from_search).collect();
let folders: Vec<FolderDto> = results.folders.iter().map(folder_dto_from_search).collect();
let file_uuids: Vec<String> = files.iter().map(|f| f.id.clone()).collect();
let folder_uuids: Vec<String> = folders.iter().map(|f| f.id.clone()).collect();
let file_uuids: Vec<&str> = files.iter().map(|f| f.id.as_str()).collect();
let folder_uuids: Vec<&str> = folders.iter().map(|f| f.id.as_str()).collect();
let (file_id_map, folder_id_map) =
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
@@ -325,7 +325,7 @@ async fn handle_search(
continue;
};
let href = nc_href(url_user, subpath);
let fid = file_id_map.get(&file.id).copied();
let fid = nc_id_of(&file_id_map, &file.id);
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
let dead = dead_props_for(&file.id, &file_deads);
write_file_response(
@@ -352,7 +352,7 @@ async fn handle_search(
continue;
};
let href = format!("{}/", nc_href(url_user, subpath));
let fid = folder_id_map.get(&folder.id).copied();
let fid = nc_id_of(&folder_id_map, &folder.id);
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
let dead = dead_props_for(&folder.id, &folder_deads);
write_folder_response(
+9 -8
View File
@@ -15,7 +15,7 @@ use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::nextcloud::webdav_handler::{
batch_resolve_ids, extract_nc_subpath_from_dest, format_oc_id, nc_to_internal_path,
batch_resolve_ids, extract_nc_subpath_from_dest, format_oc_id, nc_id_of, nc_to_internal_path,
write_text_element,
};
@@ -308,6 +308,7 @@ fn strip_home_prefix<'a>(
use crate::application::dtos::trash_dto::TrashedItemDto;
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
use std::collections::HashMap;
use uuid::Uuid;
/// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin.
///
@@ -337,14 +338,14 @@ async fn write_trashbin_multistatus<W: std::io::Write>(
// Pre-resolve every oc:fileid in two batch queries by object type (was one
// INSERT round-trip per item). File and folder UUIDs are disjoint, so the
// two maps merge cleanly into one keyed by original_id.
let mut file_uuids: Vec<String> = Vec::new();
let mut folder_uuids: Vec<String> = Vec::new();
// two maps merge cleanly into one keyed by parsed original-id UUID.
let mut file_uuids: Vec<&str> = Vec::new();
let mut folder_uuids: Vec<&str> = Vec::new();
for item in items {
if item.item_type == "folder" {
folder_uuids.push(item.original_id.clone());
folder_uuids.push(item.original_id.as_str());
} else {
file_uuids.push(item.original_id.clone());
file_uuids.push(item.original_id.as_str());
}
}
let (mut id_map, folder_id_map) =
@@ -427,7 +428,7 @@ fn write_trash_item_response<W: std::io::Write>(
username: &str,
chroot: &crate::application::dtos::folder_dto::FolderDto,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
id_map: &HashMap<String, i64>,
id_map: &HashMap<Uuid, i64>,
) -> Result<(), String> {
xml.write_event(Event::Start(BytesStart::new("d:response")))
.map_err(|e| e.to_string())?;
@@ -475,7 +476,7 @@ fn write_trash_item_response<W: std::io::Write>(
write_text_element(xml, "d:getcontentlength", "0")?;
// oc:fileid and oc:id — resolved up front in a batch query.
let file_id = id_map.get(&item.original_id).copied();
let file_id = nc_id_of(id_map, &item.original_id);
if let Some(id) = file_id {
write_text_element(xml, "oc:fileid", &id.to_string())?;
let oc_id = format_oc_id(id, file_id_svc);
+20 -15
View File
@@ -1445,8 +1445,7 @@ async fn write_nc_file_multistatus<W: std::io::Write>(
extras: (&HashSet<String>, &[(QualifiedName, Option<String>)]),
) -> Result<(), String> {
let (favorite_ids, dead_props) = extras;
let (file_id_map, _) =
batch_resolve_ids(file_id_svc, std::slice::from_ref(&file.id), &[]).await;
let (file_id_map, _) = batch_resolve_ids(file_id_svc, &[file.id.as_str()], &[]).await;
let mut xml = Writer::new(writer);
write_nc_multistatus_open(&mut xml)?;
@@ -1457,7 +1456,7 @@ async fn write_nc_file_multistatus<W: std::io::Write>(
// shares the requested URL's prefix. `username` is the canonical
// identity for the `oc:owner-id` field.
let href = nc_href(url_user, subpath);
let file_id = file_id_map.get(&file.id).copied();
let file_id = nc_id_of(&file_id_map, &file.id);
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
write_file_response(
&mut xml,
@@ -1509,7 +1508,7 @@ fn build_nc_streaming_propfind(
HashSet::new()
};
let (_, folder_id_map) =
batch_resolve_ids(file_id_svc, &[], std::slice::from_ref(&folder.id)).await;
batch_resolve_ids(file_id_svc, &[], &[folder.id.as_str()]).await;
let folder_dead = folder_dead_props(&state.webdav_dead_props, &folder).await;
let mut buf = Vec::with_capacity(4096);
@@ -1517,7 +1516,7 @@ fn build_nc_streaming_propfind(
let mut xml = Writer::new(&mut buf);
write_nc_multistatus_open(&mut xml).map_err(std::io::Error::other)?;
let href = nc_collection_href(&username, &subpath);
let fid = folder_id_map.get(&folder.id).copied();
let fid = nc_id_of(&folder_id_map, &folder.id);
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(&mut xml, &folder, &href, (fid, oc_id.as_deref()), &username, &folder_favs, quota, &folder_dead)
.map_err(std::io::Error::other)?;
@@ -1565,7 +1564,7 @@ fn build_nc_streaming_propfind(
} else {
HashSet::new()
};
let file_uuids: Vec<String> = batch.iter().map(|f| f.id.clone()).collect();
let file_uuids: Vec<&str> = batch.iter().map(|f| f.id.as_str()).collect();
let (file_id_map, _) = batch_resolve_ids(file_id_svc, &file_uuids, &[]).await;
// One batched dead-props query per page, not one per child
// (benches/DEAD-PROPS.md).
@@ -1582,7 +1581,7 @@ fn build_nc_streaming_propfind(
// re-encoded both for every child).
let href =
format!("{}{}", child_href_prefix, urlencoding::encode(&file.name));
let fid = file_id_map.get(&file.id).copied();
let fid = nc_id_of(&file_id_map, &file.id);
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_file_response(&mut xml, file, &href, (fid, oc_id.as_deref()), &username, &favs, dead)
.map_err(std::io::Error::other)?;
@@ -1622,7 +1621,7 @@ fn build_nc_streaming_propfind(
} else {
HashSet::new()
};
let folder_uuids: Vec<String> = batch.iter().map(|sf| sf.id.clone()).collect();
let folder_uuids: Vec<&str> = batch.iter().map(|sf| sf.id.as_str()).collect();
let (_, sub_id_map) = batch_resolve_ids(file_id_svc, &[], &folder_uuids).await;
// Batched — see benches/DEAD-PROPS.md.
let sub_deads =
@@ -1637,7 +1636,7 @@ fn build_nc_streaming_propfind(
// precomputed once like the file loop above.
let href =
format!("{}{}/", child_href_prefix, urlencoding::encode(&sf.name));
let fid = sub_id_map.get(&sf.id).copied();
let fid = nc_id_of(&sub_id_map, &sf.id);
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(&mut xml, sf, &href, (fid, oc_id.as_deref()), &username, &favs, quota, dead)
.map_err(std::io::Error::other)?;
@@ -1949,14 +1948,15 @@ pub fn write_text_element<W: std::io::Write>(
/// Resolve every `oc:fileid` for a listing in two batch queries (one per
/// object type) instead of one INSERT round-trip per child. Returns
/// `(file_map, folder_map)` keyed by object UUID; entries are absent when the
/// service is disabled or an id can't be resolved, mirroring the previous
/// per-call `Option` behaviour. The two batches run concurrently.
/// `(file_map, folder_map)` keyed by parsed object UUID; entries are absent
/// when the service is disabled or an id can't be resolved, mirroring the
/// previous per-call `Option` behaviour. The two batches run concurrently.
/// Borrowed inputs + `Uuid` keys keep the whole resolution alloc-free.
pub async fn batch_resolve_ids(
svc: Option<&Arc<NextcloudFileIdService>>,
file_uuids: &[String],
folder_uuids: &[String],
) -> (HashMap<String, i64>, HashMap<String, i64>) {
file_uuids: &[&str],
folder_uuids: &[&str],
) -> (HashMap<Uuid, i64>, HashMap<Uuid, i64>) {
let Some(svc) = svc else {
return (HashMap::new(), HashMap::new());
};
@@ -1967,6 +1967,11 @@ pub async fn batch_resolve_ids(
(files.unwrap_or_default(), folders.unwrap_or_default())
}
/// Look up a batch-resolved `oc:fileid` by a DTO's string UUID.
pub fn nc_id_of(map: &HashMap<Uuid, i64>, id: &str) -> Option<i64> {
Uuid::parse_str(id).ok().and_then(|u| map.get(&u).copied())
}
pub fn format_oc_id(id: i64, svc: Option<&Arc<NextcloudFileIdService>>) -> String {
match svc {
Some(s) => s.format_oc_id(id),