perf(nextcloud): batch oc:fileid resolution to kill PROPFIND N+1
Resolving the stable numeric oc:fileid for every child in a NextCloud listing issued one `INSERT ... ON CONFLICT DO UPDATE` per entry — a write (row rewrite + WAL + dead tuple) even when the mapping already existed. A Depth:1 PROPFIND of a folder with N children meant N sequential write round-trips on a read-only operation that sync clients repeat constantly. - Repository: replace the single `get_or_create` (DO UPDATE) with `get_or_create_many` — one idempotent bulk `INSERT ... SELECT unnest(...) ON CONFLICT DO NOTHING` (existing rows untouched) plus a single `SELECT ... WHERE object_id = ANY(...)`. Two statements instead of N. - Service: add an Arc-backed moka cache (uuid -> i64; the mapping is immutable, so warm entries never go stale) and batch APIs `get_or_create_file_ids` / `get_or_create_folder_ids` that only query the misses. Warm listings cost zero queries. - Handlers (PROPFIND, REPORT favorites/search, trashbin, OCS unified search): pre-resolve all ids in two batched queries — file and folder run concurrently via `tokio::join!` — and turn the XML/JSON emission into a synchronous map lookup. https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
This commit is contained in:
@@ -1,12 +1,25 @@
|
||||
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 {
|
||||
@@ -14,6 +27,7 @@ impl NextcloudFileIdService {
|
||||
Self {
|
||||
repo: Some(repo),
|
||||
instance_id,
|
||||
cache: Cache::new(ID_CACHE_CAPACITY),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,29 +35,76 @@ impl NextcloudFileIdService {
|
||||
Self {
|
||||
repo: None,
|
||||
instance_id: "ocnca".to_string(),
|
||||
cache: Cache::new(ID_CACHE_CAPACITY),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_or_create_file_id(&self, file_id: &str) -> Result<i64> {
|
||||
let repo = self.repo.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
|
||||
})?;
|
||||
repo.get_or_create("file", file_id).await
|
||||
/// 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>> {
|
||||
self.get_or_create_many("file", file_ids).await
|
||||
}
|
||||
|
||||
pub async fn get_or_create_folder_id(&self, folder_id: &str) -> Result<i64> {
|
||||
let repo = self.repo.as_ref().ok_or_else(|| {
|
||||
/// 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>> {
|
||||
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>> {
|
||||
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();
|
||||
|
||||
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);
|
||||
} else {
|
||||
pending.entry(uuid).or_insert_with(|| raw.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !pending.is_empty() {
|
||||
let misses: Vec<Uuid> = pending.keys().copied().collect();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn repo(&self) -> Result<&Arc<NextcloudObjectIdRepository>> {
|
||||
self.repo.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
|
||||
})?;
|
||||
repo.get_or_create("folder", folder_id).await
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the OxiCloud file UUID from a Nextcloud numeric ID.
|
||||
pub async fn get_oxicloud_id(&self, nc_file_id: i64) -> Result<String> {
|
||||
let repo = self.repo.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
|
||||
})?;
|
||||
repo.get_object_id(nc_file_id, "file").await
|
||||
self.repo()?.get_object_id(nc_file_id, "file").await
|
||||
}
|
||||
|
||||
pub fn format_oc_id(&self, id: i64) -> String {
|
||||
@@ -59,6 +120,7 @@ impl NextcloudFileIdService {
|
||||
Self {
|
||||
repo: None,
|
||||
instance_id: instance_id.to_string(),
|
||||
cache: Cache::new(ID_CACHE_CAPACITY),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,4 +169,25 @@ mod tests {
|
||||
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".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
|
||||
@@ -12,29 +14,73 @@ impl NextcloudObjectIdRepository {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn get_or_create(&self, object_type: &str, object_id: &str) -> Result<i64> {
|
||||
let row = sqlx::query(
|
||||
/// Resolve — creating when absent — stable numeric IDs for a batch of
|
||||
/// object UUIDs sharing one `object_type`.
|
||||
///
|
||||
/// Two statements instead of one per id: an idempotent bulk insert that
|
||||
/// leaves existing rows untouched (`ON CONFLICT DO NOTHING` — no row
|
||||
/// rewrite, no WAL churn, no dead tuples, unlike the former `DO UPDATE`),
|
||||
/// followed by a single read of every requested mapping. The insert
|
||||
/// auto-commits before the read, so the read observes both our own rows
|
||||
/// and any created concurrently. Returns a map keyed by object UUID;
|
||||
/// unresolvable inputs are simply absent.
|
||||
pub async fn get_or_create_many(
|
||||
&self,
|
||||
object_type: &str,
|
||||
object_ids: &[Uuid],
|
||||
) -> Result<HashMap<Uuid, i64>> {
|
||||
if object_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
// 1. Create missing mappings only. `DO NOTHING` skips the write for
|
||||
// UUIDs that already map, eliminating the per-listing row rewrite.
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO storage.nextcloud_object_ids (object_type, object_id)
|
||||
VALUES ($1, $2::uuid)
|
||||
ON CONFLICT (object_type, object_id)
|
||||
DO UPDATE SET object_id = EXCLUDED.object_id
|
||||
RETURNING id
|
||||
SELECT $1, u FROM unnest($2::uuid[]) AS u
|
||||
ON CONFLICT (object_type, object_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(object_type)
|
||||
.bind(object_id)
|
||||
.fetch_one(&*self.pool)
|
||||
.bind(object_ids)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"NextcloudFileId",
|
||||
format!("Failed to get/create Nextcloud ID: {}", e),
|
||||
format!("Failed to create Nextcloud IDs: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(row.get::<i64, _>("id"))
|
||||
// 2. Read every requested mapping back in a single round-trip.
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, object_id
|
||||
FROM storage.nextcloud_object_ids
|
||||
WHERE object_type = $1 AND object_id = ANY($2::uuid[])
|
||||
"#,
|
||||
)
|
||||
.bind(object_type)
|
||||
.bind(object_ids)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"NextcloudFileId",
|
||||
format!("Failed to load Nextcloud IDs: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut map = HashMap::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let object_id: Uuid = row.get("object_id");
|
||||
let id: i64 = row.get("id");
|
||||
map.insert(object_id, id);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Get the OxiCloud object ID from a Nextcloud numeric ID.
|
||||
|
||||
@@ -5,6 +5,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
||||
@@ -384,6 +385,17 @@ pub async fn handle_search(
|
||||
|
||||
let file_id_svc = state.nextcloud.as_ref().map(|n| &n.file_ids);
|
||||
|
||||
// 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 {
|
||||
Some(svc) => svc
|
||||
.get_or_create_file_ids(&file_uuids)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
None => HashMap::new(),
|
||||
};
|
||||
|
||||
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// Map file results
|
||||
@@ -394,11 +406,7 @@ pub async fn handle_search(
|
||||
.unwrap_or(&file.path);
|
||||
let display_path = format!("/{}", display_path);
|
||||
|
||||
let numeric_id = if let Some(svc) = file_id_svc {
|
||||
svc.get_or_create_file_id(&file.id).await.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let numeric_id = file_id_map.get(&file.id).copied();
|
||||
|
||||
let thumbnail_url = match numeric_id {
|
||||
Some(nid) => format!("/index.php/core/preview?fileId={}&x=32&y=32", nid),
|
||||
|
||||
@@ -25,8 +25,7 @@ use crate::domain::entities::file::File;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
use crate::interfaces::nextcloud::webdav_handler::{
|
||||
format_oc_id, nc_href, resolve_file_id, resolve_folder_id, write_file_response,
|
||||
write_folder_response,
|
||||
batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response,
|
||||
};
|
||||
|
||||
/// Handle WebDAV REPORT and SEARCH methods for Nextcloud compatibility.
|
||||
@@ -87,56 +86,71 @@ async fn handle_filter_files(
|
||||
|
||||
let home_prefix = format!("My Folder - {}/", user.username);
|
||||
|
||||
// Pass 1: fetch the favorited DTOs (the per-item fetch is a separate
|
||||
// concern from the oc:fileid resolution batched below).
|
||||
let mut files: Vec<FileDto> = Vec::new();
|
||||
let mut folders: Vec<FolderDto> = Vec::new();
|
||||
for fav in &favorites {
|
||||
match fav.item_type.as_str() {
|
||||
"file" => {
|
||||
if let Ok(f) = file_service.get_file(&fav.item_id).await {
|
||||
files.push(f);
|
||||
}
|
||||
}
|
||||
"folder" => {
|
||||
if let Ok(f) = folder_service.get_folder(&fav.item_id).await {
|
||||
folders.push(f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 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_id_map, folder_id_map) =
|
||||
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
|
||||
|
||||
// Pass 3: write the multistatus XML (pure synchronous map lookups).
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut xml = Writer::new(&mut buf);
|
||||
|
||||
write_multistatus_start(&mut xml)?;
|
||||
|
||||
for fav in &favorites {
|
||||
match fav.item_type.as_str() {
|
||||
"file" => {
|
||||
let file = match file_service.get_file(&fav.item_id).await {
|
||||
Ok(f) => f,
|
||||
Err(_) => continue, // Deleted or inaccessible -- skip.
|
||||
};
|
||||
let subpath = strip_home_prefix(&file.path, &home_prefix);
|
||||
let href = nc_href(&user.username, subpath);
|
||||
let fid = resolve_file_id(file_id_svc, &file.id).await;
|
||||
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(),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
"folder" => {
|
||||
let folder = match folder_service.get_folder(&fav.item_id).await {
|
||||
Ok(f) => f,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let subpath = strip_home_prefix(&folder.path, &home_prefix);
|
||||
let href = format!("{}/", nc_href(&user.username, subpath));
|
||||
let fid = resolve_folder_id(file_id_svc, &folder.id).await;
|
||||
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(),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
for file in &files {
|
||||
let subpath = strip_home_prefix(&file.path, &home_prefix);
|
||||
let href = nc_href(&user.username, subpath);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
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(),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
|
||||
for folder in &folders {
|
||||
let subpath = strip_home_prefix(&folder.path, &home_prefix);
|
||||
let href = format!("{}/", nc_href(&user.username, subpath));
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
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(),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
@@ -192,6 +206,15 @@ async fn handle_search(
|
||||
// No favorite checking for search results -- pass an empty set.
|
||||
let favorite_ids: HashSet<String> = HashSet::new();
|
||||
|
||||
// Materialize DTOs, then resolve every oc:fileid in two batch queries
|
||||
// (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_id_map, folder_id_map) =
|
||||
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut xml = Writer::new(&mut buf);
|
||||
@@ -199,15 +222,14 @@ async fn handle_search(
|
||||
write_multistatus_start(&mut xml)?;
|
||||
|
||||
// Files.
|
||||
for fr in &results.files {
|
||||
let file = file_dto_from_search(fr);
|
||||
for file in &files {
|
||||
let subpath = strip_home_prefix(&file.path, &home_prefix);
|
||||
let href = nc_href(&user.username, subpath);
|
||||
let fid = resolve_file_id(file_id_svc, &file.id).await;
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
&file,
|
||||
file,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
@@ -218,15 +240,14 @@ async fn handle_search(
|
||||
}
|
||||
|
||||
// Folders.
|
||||
for sr in &results.folders {
|
||||
let folder = folder_dto_from_search(sr);
|
||||
for folder in &folders {
|
||||
let subpath = strip_home_prefix(&folder.path, &home_prefix);
|
||||
let href = format!("{}/", nc_href(&user.username, subpath));
|
||||
let fid = resolve_folder_id(file_id_svc, &folder.id).await;
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
&folder,
|
||||
folder,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
||||
use crate::interfaces::nextcloud::webdav_handler::{
|
||||
format_oc_id, resolve_file_id, resolve_folder_id, write_text_element,
|
||||
batch_resolve_ids, format_oc_id, write_text_element,
|
||||
};
|
||||
|
||||
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
|
||||
@@ -198,6 +198,7 @@ fn strip_home_prefix<'a>(original_path: &'a str, username: &str) -> &'a str {
|
||||
|
||||
use crate::application::dtos::trash_dto::TrashedItemDto;
|
||||
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin.
|
||||
async fn write_trashbin_multistatus<W: std::io::Write>(
|
||||
@@ -219,9 +220,25 @@ async fn write_trashbin_multistatus<W: std::io::Write>(
|
||||
// Root container entry for the trash collection itself.
|
||||
write_trash_root_response(&mut xml, username)?;
|
||||
|
||||
// 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();
|
||||
for item in items {
|
||||
if item.item_type == "folder" {
|
||||
folder_uuids.push(item.original_id.clone());
|
||||
} else {
|
||||
file_uuids.push(item.original_id.clone());
|
||||
}
|
||||
}
|
||||
let (mut id_map, folder_id_map) =
|
||||
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
|
||||
id_map.extend(folder_id_map);
|
||||
|
||||
// Individual trashed items.
|
||||
for item in items {
|
||||
write_trash_item_response(&mut xml, item, username, file_id_svc).await?;
|
||||
write_trash_item_response(&mut xml, item, username, file_id_svc, &id_map)?;
|
||||
}
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
@@ -267,11 +284,12 @@ fn write_trash_root_response<W: std::io::Write>(
|
||||
}
|
||||
|
||||
/// Write a single trashed item as a `<d:response>` element.
|
||||
async fn write_trash_item_response<W: std::io::Write>(
|
||||
fn write_trash_item_response<W: std::io::Write>(
|
||||
xml: &mut Writer<W>,
|
||||
item: &TrashedItemDto,
|
||||
username: &str,
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
id_map: &HashMap<String, i64>,
|
||||
) -> Result<(), String> {
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -318,12 +336,8 @@ async fn write_trash_item_response<W: std::io::Write>(
|
||||
// d:getcontentlength
|
||||
write_text_element(xml, "d:getcontentlength", "0")?;
|
||||
|
||||
// oc:fileid and oc:id — resolve numeric ID via file_id service
|
||||
let file_id = if item.item_type == "folder" {
|
||||
resolve_folder_id(file_id_svc, &item.original_id).await
|
||||
} else {
|
||||
resolve_file_id(file_id_svc, &item.original_id).await
|
||||
};
|
||||
// oc:fileid and oc:id — resolved up front in a batch query.
|
||||
let file_id = id_map.get(&item.original_id).copied();
|
||||
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);
|
||||
|
||||
@@ -9,7 +9,7 @@ use quick_xml::{
|
||||
Writer,
|
||||
events::{BytesEnd, BytesStart, BytesText, Event},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter};
|
||||
@@ -1001,6 +1001,26 @@ async fn write_nc_multistatus<W: std::io::Write>(
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
favorite_ids: &HashSet<String>,
|
||||
) -> Result<(), String> {
|
||||
// When folder is None, files are the target resource itself (single-file
|
||||
// PROPFIND) and must always be emitted. When folder is Some, files/subfolders
|
||||
// are children and should only be listed when depth > 0.
|
||||
let emit_children = folder.is_none() || depth != "0";
|
||||
|
||||
// Pre-resolve every oc:fileid up front in two batch queries (one per
|
||||
// object type) instead of one INSERT round-trip per child. The XML writing
|
||||
// below is then a pure synchronous map lookup.
|
||||
let mut file_uuids: Vec<String> = Vec::new();
|
||||
let mut folder_uuids: Vec<String> = Vec::new();
|
||||
if let Some(f) = folder {
|
||||
folder_uuids.push(f.id.clone());
|
||||
}
|
||||
if emit_children {
|
||||
file_uuids.extend(files.iter().map(|f| f.id.clone()));
|
||||
folder_uuids.extend(subfolders.iter().map(|sf| sf.id.clone()));
|
||||
}
|
||||
let (file_id_map, folder_id_map) =
|
||||
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
|
||||
|
||||
let mut xml = Writer::new(writer);
|
||||
|
||||
// Root element with all required namespaces.
|
||||
@@ -1015,7 +1035,7 @@ async fn write_nc_multistatus<W: std::io::Write>(
|
||||
// §5.2 + strict NC-client enforcement — see `nc_collection_href`).
|
||||
if let Some(f) = folder {
|
||||
let href = nc_collection_href(username, subpath);
|
||||
let file_id = resolve_folder_id(file_id_svc, &f.id).await;
|
||||
let file_id = folder_id_map.get(&f.id).copied();
|
||||
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
@@ -1028,11 +1048,6 @@ async fn write_nc_multistatus<W: std::io::Write>(
|
||||
)?;
|
||||
}
|
||||
|
||||
// When folder is None, files are the target resource itself (single-file
|
||||
// PROPFIND) and must always be emitted. When folder is Some, files/subfolders
|
||||
// are children and should only be listed when depth > 0.
|
||||
let emit_children = folder.is_none() || depth != "0";
|
||||
|
||||
if emit_children {
|
||||
// Files.
|
||||
for file in files {
|
||||
@@ -1045,7 +1060,7 @@ async fn write_nc_multistatus<W: std::io::Write>(
|
||||
format!("{}/{}", subpath.trim_end_matches('/'), file.name)
|
||||
};
|
||||
let href = nc_href(username, &child_sub);
|
||||
let file_id = resolve_file_id(file_id_svc, &file.id).await;
|
||||
let file_id = file_id_map.get(&file.id).copied();
|
||||
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
@@ -1066,7 +1081,7 @@ async fn write_nc_multistatus<W: std::io::Write>(
|
||||
format!("{}/{}", subpath.trim_end_matches('/'), sf.name)
|
||||
};
|
||||
let href = nc_collection_href(username, &child_sub);
|
||||
let file_id = resolve_folder_id(file_id_svc, &sf.id).await;
|
||||
let file_id = folder_id_map.get(&sf.id).copied();
|
||||
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
@@ -1273,20 +1288,24 @@ pub fn write_text_element<W: std::io::Write>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve_file_id(
|
||||
/// 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.
|
||||
pub async fn batch_resolve_ids(
|
||||
svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
file_uuid: &str,
|
||||
) -> Option<i64> {
|
||||
let svc = svc?;
|
||||
svc.get_or_create_file_id(file_uuid).await.ok()
|
||||
}
|
||||
|
||||
pub async fn resolve_folder_id(
|
||||
svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
folder_uuid: &str,
|
||||
) -> Option<i64> {
|
||||
let svc = svc?;
|
||||
svc.get_or_create_folder_id(folder_uuid).await.ok()
|
||||
file_uuids: &[String],
|
||||
folder_uuids: &[String],
|
||||
) -> (HashMap<String, i64>, HashMap<String, i64>) {
|
||||
let Some(svc) = svc else {
|
||||
return (HashMap::new(), HashMap::new());
|
||||
};
|
||||
let (files, folders) = tokio::join!(
|
||||
svc.get_or_create_file_ids(file_uuids),
|
||||
svc.get_or_create_folder_ids(folder_uuids),
|
||||
);
|
||||
(files.unwrap_or_default(), folders.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn format_oc_id(id: i64, svc: Option<&Arc<NextcloudFileIdService>>) -> String {
|
||||
|
||||
Reference in New Issue
Block a user