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
+72 -28
View File
@@ -278,6 +278,27 @@ impl CardDavAdapter {
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
Self::write_collection_head(&mut xml_writer, address_book, request, base_href)?;
// Write contacts if depth > 0
if depth != "0" {
Self::write_collection_contact_page(&mut xml_writer, contacts, base_href)?;
}
Self::write_carddav_multistatus_end(&mut xml_writer)
}
/// Multistatus opening (DAV + CardDAV + CalendarServer namespaces)
/// plus the address book's own `D:response` — the head of a depth-1
/// collection PROPFIND. Streaming emitters call this once, then
/// [`Self::write_collection_contact_page`] per cursor page, then
/// [`Self::write_carddav_multistatus_end`].
pub fn write_collection_head<W: Write>(
xml_writer: &mut Writer<W>,
address_book: &AddressBookDto,
request: &PropFindRequest,
base_href: &str,
) -> Result<()> {
xml_writer.write_event(Event::Start(
BytesStart::new("D:multistatus").with_attributes([
("xmlns:D", "DAV:"),
@@ -285,19 +306,25 @@ impl CardDavAdapter {
("xmlns:CS", "http://calendarserver.org/ns/"),
]),
))?;
Self::write_addressbook_response(xml_writer, address_book, request, base_href)
}
// Write the address book itself
Self::write_addressbook_response(&mut xml_writer, address_book, request, base_href)?;
// Write contacts if depth > 0
if depth != "0" {
for contact in contacts {
let contact_href = format!("{}{}.vcf", base_href, contact.uid);
Self::write_contact_response(&mut xml_writer, contact, &[], &contact_href)?;
}
/// One depth-1 collection page of contact entries (standard props;
/// href buffer reused across the page).
pub fn write_collection_contact_page<W: Write>(
xml_writer: &mut Writer<W>,
contacts: &[ContactDto],
base_href: &str,
) -> Result<()> {
let mut href = String::with_capacity(base_href.len() + 48);
for contact in contacts {
href.clear();
let _ = std::fmt::Write::write_fmt(
&mut href,
format_args!("{}{}.vcf", base_href, contact.uid),
);
Self::write_contact_response(xml_writer, contact, &[], &href)?;
}
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
Ok(())
}
@@ -648,32 +675,39 @@ impl CardDavAdapter {
}
/// Generate response for contacts (for REPORT)
pub fn generate_contacts_response<W: Write>(
writer: W,
contacts: &[ContactDto],
report: &CardDavReportType,
base_href: &str,
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
/// REPORT `<D:multistatus>` opening tag (DAV + CardDAV namespaces).
/// Streaming emitters call this once, then
/// [`Self::write_contacts_report_page`] per cursor page, then
/// [`Self::write_carddav_multistatus_end`].
pub fn write_report_multistatus_start<W: Write>(xml_writer: &mut Writer<W>) -> Result<()> {
xml_writer.write_event(Event::Start(
BytesStart::new("D:multistatus").with_attributes([
("xmlns:D", "DAV:"),
("xmlns:CR", "urn:ietf:params:xml:ns:carddav"),
]),
))?;
Ok(())
}
// Borrowed straight out of the request — the old `clone()` copied
// the whole Vec of owned QualifiedName strings per REPORT (same
// fix the CalDAV surface got in ROUND4).
/// Close a multistatus opened by either start writer.
pub fn write_carddav_multistatus_end<W: Write>(xml_writer: &mut Writer<W>) -> Result<()> {
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
Ok(())
}
/// One REPORT page of contact responses. Props are borrowed from
/// the request; one href buffer is reused across the page.
pub fn write_contacts_report_page<W: Write>(
xml_writer: &mut Writer<W>,
contacts: &[ContactDto],
report: &CardDavReportType,
base_href: &str,
) -> Result<()> {
let props = match report {
CardDavReportType::AddressbookQuery { props } => props,
CardDavReportType::AddressbookMultiget { props, .. } => props,
CardDavReportType::SyncCollection { props, .. } => props,
};
// One reused href buffer for the whole listing instead of a
// fresh String per contact.
let mut href = String::with_capacity(base_href.len() + 48);
for contact in contacts {
href.clear();
@@ -683,13 +717,23 @@ impl CardDavAdapter {
);
// `write_contact_response` generates the vCard on demand when (and
// only when) address-data is actually requested.
Self::write_contact_response(&mut xml_writer, contact, props, &href)?;
Self::write_contact_response(xml_writer, contact, props, &href)?;
}
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
Ok(())
}
pub fn generate_contacts_response<W: Write>(
writer: W,
contacts: &[ContactDto],
report: &CardDavReportType,
base_href: &str,
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
Self::write_report_multistatus_start(&mut xml_writer)?;
Self::write_contacts_report_page(&mut xml_writer, contacts, report, base_href)?;
Self::write_carddav_multistatus_end(&mut xml_writer)
}
/// Write a single contact response element
fn write_contact_response<W: Write>(
xml_writer: &mut Writer<W>,
+15
View File
@@ -66,6 +66,12 @@ pub trait ContactStoragePort: Send + Sync + 'static {
&self,
address_book_id: &Uuid,
) -> Result<Vec<Contact>, DomainError>;
/// Cursor stream over the book's contacts in listing order — feeds
/// the streaming CardDAV emitters.
fn stream_contacts_by_book(
&self,
address_book_id: Uuid,
) -> futures::stream::BoxStream<'static, Result<Contact, DomainError>>;
async fn get_contacts_by_address_book_paginated(
&self,
address_book_id: &Uuid,
@@ -174,6 +180,15 @@ pub trait ContactUseCase: Send + Sync + 'static {
/// List contacts in an address book. `limit`/`offset` bound the
/// result for paginated callers (REST API); `None` returns the full
/// book, which the CardDAV listing/sync paths rely on.
/// Streaming support: cursor over the book's contacts (same Read
/// gate as [`Self::list_contacts`], checked once before the cursor
/// opens).
async fn stream_contacts_by_book(
&self,
address_book_id: &str,
user_id: Uuid,
) -> Result<futures::stream::BoxStream<'static, Result<ContactDto, DomainError>>, DomainError>;
async fn list_contacts(
&self,
address_book_id: &str,
@@ -825,6 +825,25 @@ impl ContactUseCase for ContactService {
Ok(contacts.into_iter().map(ContactDto::from).collect())
}
async fn stream_contacts_by_book(
&self,
address_book_id: &str,
user_id: Uuid,
) -> Result<futures::stream::BoxStream<'static, Result<ContactDto, DomainError>>, DomainError>
{
use futures::StreamExt;
let id = Uuid::parse_str(address_book_id)
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
// Same Read gate as `list_contacts`, once, before the cursor.
self.require_address_book_read_or_public(&id, &user_id)
.await?;
Ok(Box::pin(
self.contact_storage
.stream_contacts_by_book(id)
.map(|r| r.map(ContactDto::from)),
))
}
async fn list_contacts(
&self,
address_book_id: &str,
@@ -145,6 +145,12 @@ impl FavoritesUseCase for FavoritesService {
// valid (partial success would leak the same oracle we
// closed on the single-item path). See
// `docs/plan/authz_audit/rest_storage.md`.
//
// Deliberately serial: a `try_join_all` fan-out measured WORSE
// on both the cold (drive_of point-SELECTs) and warm (all-moka)
// paths — future orchestration + pool-acquire contention cost
// more than the local round trips they overlap. Rejected by
// `bench_favorites_authz`; numbers in benches/ROUND6.md.
for (item_id, item_type) in items {
let resource = Resource::parse(item_type, item_id)?;
self.authorization
@@ -41,55 +41,50 @@ impl NextcloudFileIdService {
/// Resolve — creating when absent — stable numeric file IDs for many
/// UUIDs at once. Cache hits cost nothing; the misses are resolved with a
/// single backing query. The returned map is keyed by the caller's
/// original id strings; unresolvable inputs are simply absent (mirroring
/// the `.ok()` behaviour the callers relied on).
pub async fn get_or_create_file_ids(
&self,
file_ids: &[String],
) -> Result<HashMap<String, i64>> {
/// single backing query. The returned map is keyed by parsed UUID;
/// unparseable/unresolvable inputs are simply absent (mirroring the
/// `.ok()` behaviour the callers relied on).
pub async fn get_or_create_file_ids(&self, file_ids: &[&str]) -> Result<HashMap<Uuid, i64>> {
self.get_or_create_many("file", file_ids).await
}
/// Folder counterpart of [`Self::get_or_create_file_ids`].
pub async fn get_or_create_folder_ids(
&self,
folder_ids: &[String],
) -> Result<HashMap<String, i64>> {
folder_ids: &[&str],
) -> Result<HashMap<Uuid, i64>> {
self.get_or_create_many("folder", folder_ids).await
}
async fn get_or_create_many(
&self,
object_type: &str,
raw_ids: &[String],
) -> Result<HashMap<String, i64>> {
raw_ids: &[&str],
) -> Result<HashMap<Uuid, i64>> {
let mut result = HashMap::with_capacity(raw_ids.len());
// Parsed-UUID → caller's original string; also dedupes the miss list.
let mut pending: HashMap<Uuid, String> = HashMap::new();
let mut misses: Vec<Uuid> = Vec::new();
for raw in raw_ids {
let Ok(uuid) = Uuid::parse_str(raw) else {
continue; // Unparseable ids never had a mapping — skip silently.
};
if let Some(id) = self.cache.get(&uuid).await {
result.insert(raw.clone(), id);
result.insert(uuid, id);
} else {
pending.entry(uuid).or_insert_with(|| raw.clone());
misses.push(uuid);
}
}
if !pending.is_empty() {
let misses: Vec<Uuid> = pending.keys().copied().collect();
if !misses.is_empty() {
misses.sort_unstable();
misses.dedup();
let resolved = self
.repo()?
.get_or_create_many(object_type, &misses)
.await?;
for (uuid, id) in resolved {
self.cache.insert(uuid, id).await;
if let Some(original) = pending.get(&uuid) {
result.insert(original.clone(), id);
}
result.insert(uuid, id);
}
}
@@ -184,10 +179,7 @@ mod tests {
#[tokio::test]
async fn test_get_or_create_file_ids_skips_unparseable() {
let svc = NextcloudFileIdService::new_stub();
let map = svc
.get_or_create_file_ids(&["not-a-uuid".to_string()])
.await
.unwrap();
let map = svc.get_or_create_file_ids(&["not-a-uuid"]).await.unwrap();
assert!(map.is_empty());
}
}
+32
View File
@@ -170,11 +170,43 @@ pub fn i64_str(buf: &mut [u8; 21], v: i64) -> &str {
std::str::from_utf8(&buf[start..]).expect("ascii")
}
/// Lower-case hex of `bytes` into one preallocated `String`.
///
/// Replaces the `.map(|b| format!("{b:02x}")).collect()` shape, which heap-
/// allocates a 2-byte `String` per digest byte (16 for MD5, 32 for SHA-256)
/// before collect concatenates them.
pub fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
/// `hex_lower` must match the `format!("{b:02x}")`-per-byte shape it
/// replaced, byte for byte.
#[test]
fn hex_lower_matches_format() {
let cases: [&[u8]; 5] = [
&[],
&[0x00],
&[0xff, 0x00, 0xab],
&(0u8..=255).collect::<Vec<u8>>(),
b"The quick brown fox",
];
for bytes in cases {
let reference: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex_lower(bytes), reference);
}
}
/// Edge-heavy corpus: epoch, single-digit day (padding!), leap day,
/// end-of-year, DST-irrelevant midsummer, far future, max in-range.
const CASES: [i64; 12] = [
@@ -25,6 +25,14 @@ pub trait ContactRepository: Send + Sync + 'static {
address_book_id: &Uuid,
uids: &[String],
) -> ContactRepositoryResult<Vec<Contact>>;
/// Cursor stream over every contact of the book in the listing
/// order (`full_name, first_name, last_name`) — ONE scan+sort on
/// the server; the streaming CardDAV emitters page over it.
fn stream_contacts_by_book(
&self,
address_book_id: Uuid,
) -> futures::stream::BoxStream<'static, ContactRepositoryResult<Contact>>;
async fn get_contacts_by_address_book(
&self,
address_book_id: &Uuid,
@@ -146,6 +146,14 @@ impl ContactStoragePort for ContactStorageAdapter {
.await
}
fn stream_contacts_by_book(
&self,
address_book_id: Uuid,
) -> futures::stream::BoxStream<'static, Result<Contact, DomainError>> {
self.contact_repository
.stream_contacts_by_book(address_book_id)
}
async fn get_contacts_by_address_book_paginated(
&self,
address_book_id: &Uuid,
@@ -278,6 +278,45 @@ impl ContactRepository for ContactPgRepository {
Ok(contacts)
}
fn stream_contacts_by_book(
&self,
address_book_id: Uuid,
) -> futures::stream::BoxStream<'static, ContactRepositoryResult<Contact>> {
// ONE ordered scan served through a PG cursor — the CardDAV
// multistatus emitters page over this stream so only a page of
// contacts is resident (same design as the CalDAV round-5
// cursor; contacts have no master/exception bundling, so pages
// can cut anywhere).
let pool = self.pool.clone();
let stream: futures::stream::BoxStream<'static, ContactRepositoryResult<Contact>> =
Box::pin(async_stream::try_stream! {
let mut conn = pool.acquire().await.map_err(|e| {
DomainError::database_error(format!("Failed to acquire connection: {}", e))
})?;
let mut rows = sqlx::query(
r#"
SELECT
id, address_book_id, uid, full_name, first_name, last_name, nickname,
email, phone, address, organization, title, notes, photo_url,
birthday, anniversary, vcard, etag, created_at, updated_at
FROM carddav.contacts
WHERE address_book_id = $1
ORDER BY full_name, first_name, last_name
"#,
)
.bind(address_book_id)
.fetch(&mut *conn);
use futures::TryStreamExt;
while let Some(row) = rows.try_next().await.map_err(|e| {
DomainError::database_error(format!("Failed to stream contacts: {}", e))
})? {
yield Self::row_to_contact(&row)?;
}
});
stream
}
async fn get_contacts_by_address_book(
&self,
address_book_id: &Uuid,
@@ -259,8 +259,9 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
return Ok(HashSet::new());
}
// Collect just the IDs for the IN clause
let ids: Vec<String> = item_ids.iter().map(|(id, _)| id.to_string()).collect();
// Collect just the IDs for the IN clause — sqlx binds `&[&str]` as
// text[], so no per-id String is needed.
let ids: Vec<&str> = item_ids.iter().map(|(id, _)| *id).collect();
let rows = sqlx::query(
"SELECT item_id FROM auth.user_favorites WHERE user_id = $1 AND item_id = ANY($2)",
@@ -11,9 +11,9 @@
/// Post-D7-step-6: `storage.files.user_id` dropped, so it's no
/// longer projected.
type MediaFileRow = (
String, // id
Uuid, // id (binary decode; benches/ROUND6.md §10)
String, // name
Option<String>, // folder_id
Option<Uuid>, // folder_id
Option<String>, // folder path
i64, // size
String, // mime_type
@@ -83,9 +83,9 @@ const CALLER_CAN_READ_DRIVE: &str = "EXISTS (\
/// longer part of the tuple; `row_to_file` populates the entity's
/// legacy `user_id` field with `None`.
type FileRow = (
Uuid,
String,
String,
Option<String>,
Option<Uuid>,
Option<String>,
i64,
String,
@@ -269,7 +269,7 @@ impl FileBlobReadRepository {
let where_clause = conditions.join(" AND ");
let sql = format!(
"SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, \
"SELECT fi.id, fi.name, fi.folder_id, fo.path, \
fi.size, fi.mime_type, \
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
@@ -319,7 +319,7 @@ impl FileBlobReadRepository {
}
let rows = sqlx::query_as::<_, FileRow>(
"SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, \
"SELECT fi.id, fi.name, fi.folder_id, fo.path, \
fi.size, fi.mime_type, \
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
@@ -415,9 +415,9 @@ impl FileBlobReadRepository {
#[allow(clippy::too_many_arguments)]
fn row_to_file(
id: String,
id: Uuid,
name: String,
folder_id: Option<String>,
folder_id: Option<Uuid>,
folder_path: Option<String>,
size: i64,
mime_type: String,
@@ -428,12 +428,12 @@ impl FileBlobReadRepository {
updated_by: Option<Uuid>,
) -> Result<File, DomainError> {
File::from_materialized_row(
id,
id.to_string(),
name,
folder_path.as_deref(),
size as u64,
mime_type,
folder_id,
folder_id.map(|u| u.to_string()),
created_at as u64,
modified_at as u64,
blob_hash,
@@ -550,7 +550,7 @@ impl FileBlobReadRepository {
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (d.policies->>'include_in_photo_index')::boolean = true
)
SELECT top.id::text, top.name, top.folder_id::text, fo.path,
SELECT top.id, top.name, top.folder_id, fo.path,
top.size, top.mime_type,
EXTRACT(EPOCH FROM top.created_at)::bigint,
EXTRACT(EPOCH FROM top.updated_at)::bigint,
@@ -679,9 +679,9 @@ impl FileReadPort for FileBlobReadRepository {
let row = sqlx::query_as::<
_,
(
String, // id
Uuid, // id (binary decode)
String, // name
Option<String>, // folder_id
Option<Uuid>, // folder_id
Option<String>, // folder path
i64, // size
String, // mime_type
@@ -693,7 +693,7 @@ impl FileReadPort for FileBlobReadRepository {
),
>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
@@ -726,9 +726,9 @@ impl FileReadPort for FileBlobReadRepository {
let row = sqlx::query_as::<
_,
(
Uuid,
String,
String,
Option<String>,
Option<Uuid>,
Option<String>,
i64,
String,
@@ -740,7 +740,7 @@ impl FileReadPort for FileBlobReadRepository {
),
>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
@@ -768,7 +768,7 @@ impl FileReadPort for FileBlobReadRepository {
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
@@ -787,7 +787,7 @@ impl FileReadPort for FileBlobReadRepository {
} else {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
@@ -848,7 +848,7 @@ impl FileReadPort for FileBlobReadRepository {
};
let sql = format!(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
@@ -1014,9 +1014,9 @@ impl FileReadPort for FileBlobReadRepository {
sqlx::query_as::<
_,
(
Uuid,
String,
String,
Option<String>,
Option<Uuid>,
Option<String>,
i64,
String,
@@ -1028,7 +1028,7 @@ impl FileReadPort for FileBlobReadRepository {
),
>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
@@ -1052,9 +1052,9 @@ impl FileReadPort for FileBlobReadRepository {
sqlx::query_as::<
_,
(
Uuid,
String,
String,
Option<String>,
Option<Uuid>,
Option<String>,
i64,
String,
@@ -1066,7 +1066,7 @@ impl FileReadPort for FileBlobReadRepository {
),
>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
@@ -1107,12 +1107,12 @@ impl FileReadPort for FileBlobReadRepository {
let stream = async_stream::try_stream! {
let mut row_stream = sqlx::query_as::<_, (
String, String, Option<String>, Option<String>,
Uuid, String, Option<Uuid>, Option<String>,
i64, String, i64, i64, String,
Option<Uuid>, Option<Uuid>, // created_by, updated_by (§14)
)>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
@@ -1195,7 +1195,7 @@ impl FileReadPort for FileBlobReadRepository {
let offset_bind = bind_idx + 2;
let sql = format!(
"SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, \
"SELECT fi.id, fi.name, fi.folder_id, fo.path, \
fi.size, fi.mime_type, \
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
@@ -1214,9 +1214,9 @@ impl FileReadPort for FileBlobReadRepository {
let mut query = sqlx::query_as::<
_,
(
Uuid,
String,
String,
Option<String>,
Option<Uuid>,
Option<String>,
i64,
String,
@@ -1327,7 +1327,7 @@ impl FileReadPort for FileBlobReadRepository {
// ── Single query with COUNT(*) OVER() ──
let sql = format!(
"SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, \
"SELECT fi.id, fi.name, fi.folder_id, fo.path, \
fi.size, fi.mime_type, \
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
@@ -1346,9 +1346,9 @@ impl FileReadPort for FileBlobReadRepository {
let mut query = sqlx::query_as::<
_,
(
Uuid,
String,
String,
Option<String>,
Option<Uuid>,
Option<String>,
i64,
String,
@@ -1420,7 +1420,7 @@ impl FileReadPort for FileBlobReadRepository {
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
@@ -1450,7 +1450,7 @@ impl FileReadPort for FileBlobReadRepository {
} else {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
+196 -19
View File
@@ -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);
+9 -8
View File
@@ -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
+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),
+2 -2
View File
@@ -419,8 +419,8 @@ impl IncrementalHasher {
fn finalize_hex(self) -> String {
match self {
Self::Md5(h) => h.finalize().iter().map(|b| format!("{b:02x}")).collect(),
Self::Sha256(h) => h.finalize().iter().map(|b| format!("{b:02x}")).collect(),
Self::Md5(h) => crate::common::fmt::hex_lower(&h.finalize()),
Self::Sha256(h) => crate::common::fmt::hex_lower(&h.finalize()),
Self::Blake3(h) => h.finalize().to_hex().to_string(),
}
}