perf(grants,favorites): batch resource resolution to kill N+1 and pool fan-out
Three list endpoints resolved each resource with one query per id: - GET /api/grants/incoming and /api/grants/outgoing used join_all(ids.map(get_file)) + join_all(ids.map(get_folder)), so a single page (limit ≤ 200) could demand ~200 concurrent connections from the 20-connection primary pool, causing acquire-timeouts and head-of-line blocking under load. - The NextCloud favorites REPORT (oc:filter-files) fetched get_file/ get_folder once per favorite — up to N serial round-trips per sync. Add by-ids batch reads that mirror the existing get_file/get_folder column mapping and NOT is_trashed filter: - FileBlobReadRepository::get_files_by_ids / FolderDbRepository::get_folders_by_ids (one SELECT ... WHERE id = ANY($1)), exposed as FileRetrievalService:: get_files_by_ids / FolderService::get_folders_by_ids returning DTOs. - Both grant handlers and the favorites REPORT now issue two batch queries total and look results up by id, preserving original order. Missing ids (stale grants whose resource was deleted, or trashed/removed favorites) drop out exactly as before. No auth-semantics change: these paths already resolved ids vetted by the authorization engine / favorites table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TAzLEQDaLak3dnrEN3YT35
This commit is contained in:
@@ -255,6 +255,52 @@ impl FileBlobReadRepository {
|
||||
})
|
||||
}
|
||||
|
||||
/// Batch-fetch files by id — the by-ids counterpart of [`get_file`],
|
||||
/// used to resolve a page of ACL grants or favorites in ONE round-trip
|
||||
/// instead of one query per id (the previous `join_all(ids.map(get_file))`
|
||||
/// could fan out to ~200 concurrent pooled connections per page). Applies
|
||||
/// the same `NOT is_trashed` filter and identical column mapping as
|
||||
/// `get_file`. Ids that are missing or trashed simply drop out, so callers
|
||||
/// must re-associate results by id; ordering is not guaranteed.
|
||||
pub async fn get_files_by_ids(&self, ids: &[String]) -> Result<Vec<File>, DomainError> {
|
||||
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
|
||||
if uuid_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, FileRow>(
|
||||
"SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, \
|
||||
fi.size, fi.mime_type, \
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
|
||||
fi.blob_hash, \
|
||||
fi.user_id \
|
||||
FROM storage.files fi \
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
|
||||
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
|
||||
)
|
||||
.bind(&uuid_ids)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("get_files_by_ids: {e}"))
|
||||
})?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FileBlobRead",
|
||||
format!("get_files_by_ids mapping: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the user_id (owner) for a given file ID.
|
||||
/// Mirrors `FolderDbRepository::get_folder_user_id`.
|
||||
/// Used by the AuthorizationEngine for owner short-circuit.
|
||||
|
||||
@@ -109,6 +109,37 @@ impl FolderDbRepository {
|
||||
)
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
|
||||
}
|
||||
|
||||
/// Batch-fetch folders by id — the by-ids counterpart of `get_folder`,
|
||||
/// resolving a page of ACL grants or favorites in ONE query instead of
|
||||
/// one per id. Same `NOT is_trashed` filter and column mapping as
|
||||
/// `get_folder`; missing or trashed ids drop out and callers re-associate
|
||||
/// by id; ordering is not guaranteed.
|
||||
pub async fn get_folders_by_ids(&self, ids: &[String]) -> Result<Vec<Folder>, DomainError> {
|
||||
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
|
||||
if uuid_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
FROM storage.folders
|
||||
WHERE id = ANY($1) AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(&uuid_ids)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl FolderRepository for FolderDbRepository {
|
||||
|
||||
Reference in New Issue
Block a user