9729f033b2
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
186 lines
5.7 KiB
Rust
186 lines
5.7 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use moka::future::Cache;
|
|
use uuid::Uuid;
|
|
|
|
use crate::common::errors::{DomainError, ErrorKind, Result};
|
|
use crate::infrastructure::repositories::pg::NextcloudObjectIdRepository;
|
|
|
|
/// Capacity of the in-memory UUID→numeric-id cache. The mapping is immutable
|
|
/// once created, so a warm entry never goes stale and eviction only costs a
|
|
/// re-query; ~100k entries is a few MB.
|
|
const ID_CACHE_CAPACITY: u64 = 100_000;
|
|
|
|
#[derive(Clone)]
|
|
pub struct NextcloudFileIdService {
|
|
repo: Option<Arc<NextcloudObjectIdRepository>>,
|
|
instance_id: String,
|
|
/// Object-UUID → stable numeric id. `moka` caches are `Arc`-backed, so all
|
|
/// clones of the service share one cache and the per-child resolution in a
|
|
/// listing costs zero queries once warm.
|
|
cache: Cache<Uuid, i64>,
|
|
}
|
|
|
|
impl NextcloudFileIdService {
|
|
pub fn new(repo: Arc<NextcloudObjectIdRepository>, instance_id: String) -> Self {
|
|
Self {
|
|
repo: Some(repo),
|
|
instance_id,
|
|
cache: Cache::new(ID_CACHE_CAPACITY),
|
|
}
|
|
}
|
|
|
|
pub fn new_stub() -> Self {
|
|
Self {
|
|
repo: None,
|
|
instance_id: "ocnca".to_string(),
|
|
cache: Cache::new(ID_CACHE_CAPACITY),
|
|
}
|
|
}
|
|
|
|
/// 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 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: &[&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: &[&str],
|
|
) -> Result<HashMap<Uuid, i64>> {
|
|
let mut result = HashMap::with_capacity(raw_ids.len());
|
|
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(uuid, id);
|
|
} else {
|
|
misses.push(uuid);
|
|
}
|
|
}
|
|
|
|
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;
|
|
result.insert(uuid, id);
|
|
}
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
fn repo(&self) -> Result<&Arc<NextcloudObjectIdRepository>> {
|
|
self.repo.as_ref().ok_or_else(|| {
|
|
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
|
|
})
|
|
}
|
|
|
|
/// Get the OxiCloud file UUID from a Nextcloud numeric ID.
|
|
pub async fn get_oxicloud_id(&self, nc_file_id: i64) -> Result<String> {
|
|
self.repo()?.get_object_id(nc_file_id, "file").await
|
|
}
|
|
|
|
pub fn format_oc_id(&self, id: i64) -> String {
|
|
format!("{:08}{}", id, self.instance_id)
|
|
}
|
|
|
|
pub fn instance_id(&self) -> &str {
|
|
&self.instance_id
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn new_test(instance_id: &str) -> Self {
|
|
Self {
|
|
repo: None,
|
|
instance_id: instance_id.to_string(),
|
|
cache: Cache::new(ID_CACHE_CAPACITY),
|
|
}
|
|
}
|
|
|
|
pub fn ensure_ready(&self) -> Result<()> {
|
|
if self.repo.is_none() {
|
|
return Err(DomainError::new(
|
|
ErrorKind::InternalError,
|
|
"NextcloudFileId",
|
|
"Repository not initialized",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_format_oc_id_default_instance() {
|
|
let svc = NextcloudFileIdService::new_stub();
|
|
assert_eq!(svc.format_oc_id(42), "00000042ocnca");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_oc_id_custom_instance() {
|
|
let svc = NextcloudFileIdService::new_test("myinst");
|
|
assert_eq!(svc.format_oc_id(1), "00000001myinst");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_oc_id_large_number() {
|
|
let svc = NextcloudFileIdService::new_stub();
|
|
assert_eq!(svc.format_oc_id(123456789), "123456789ocnca");
|
|
}
|
|
|
|
#[test]
|
|
fn test_instance_id() {
|
|
let svc = NextcloudFileIdService::new_stub();
|
|
assert_eq!(svc.instance_id(), "ocnca");
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_ready_fails_on_stub() {
|
|
let svc = NextcloudFileIdService::new_stub();
|
|
assert!(svc.ensure_ready().is_err());
|
|
}
|
|
|
|
// Empty input resolves to an empty map without ever touching the repo, so
|
|
// it succeeds even on the repo-less stub.
|
|
#[tokio::test]
|
|
async fn test_get_or_create_file_ids_empty_is_noop() {
|
|
let svc = NextcloudFileIdService::new_stub();
|
|
let map = svc.get_or_create_file_ids(&[]).await.unwrap();
|
|
assert!(map.is_empty());
|
|
}
|
|
|
|
// Unparseable ids never had a mapping, so they are skipped before any repo
|
|
// call — the stub (no repo) must not error on them.
|
|
#[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"]).await.unwrap();
|
|
assert!(map.is_empty());
|
|
}
|
|
}
|