perf: scale fixes for 1M+ photo libraries
Indexes: share_targets.target_id, duplicate_groups.status, GIN on stacks members + duplicate candidates JSONB, composite (owner_user_id, created_at DESC) on assets. N+1 elimination: batch metadata loading via find_by_assets(ids) using WHERE asset_id = ANY($1), used in timeline + sidecar export. Visibility: cache find_targets_for_user per request via OnceCell, extract filter_visible helper to reduce duplication. Streaming: FileStoragePort.open_file() returns (DataStream, u64), LocalFileStorage uses ReaderStream instead of loading full file. serve_file/serve_derivative use Body::from_stream(). Unbounded queries: sidecar full_export/import batched in 500-row chunks instead of u32::MAX. find_unresolved paginated with limit/offset. list_duplicates API accepts pagination params.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
CREATE INDEX idx_share_targets_target ON share_targets(target_id);
|
||||
CREATE INDEX idx_duplicate_groups_status ON duplicate_groups(status);
|
||||
CREATE INDEX idx_stacks_members ON asset_stacks USING GIN (members);
|
||||
CREATE INDEX idx_duplicate_candidates ON duplicate_groups USING GIN (candidates);
|
||||
CREATE INDEX idx_assets_created ON assets(owner_user_id, created_at DESC);
|
||||
@@ -336,6 +336,23 @@ impl AssetMetadataRepository for PostgresAssetMetadataRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn find_by_assets(
|
||||
&self,
|
||||
asset_ids: &[SystemId],
|
||||
) -> Result<Vec<AssetMetadata>, DomainError> {
|
||||
let uuids: Vec<Uuid> = asset_ids.iter().map(|id| *id.as_uuid()).collect();
|
||||
let rows = sqlx::query_as::<_, AssetMetadataRow>(
|
||||
"SELECT asset_id, metadata_source, data, updated_at
|
||||
FROM asset_metadata WHERE asset_id = ANY($1)",
|
||||
)
|
||||
.bind(&uuids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn find_by_asset_and_source(
|
||||
&self,
|
||||
asset_id: &SystemId,
|
||||
@@ -482,11 +499,18 @@ impl DuplicateRepository for PostgresDuplicateRepository {
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn find_unresolved(&self) -> Result<Vec<DuplicateGroup>, DomainError> {
|
||||
async fn find_unresolved(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<DuplicateGroup>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, GroupRow>(
|
||||
"SELECT group_id, detection_method, status, candidates
|
||||
FROM duplicate_groups WHERE status = 'unresolved'",
|
||||
FROM duplicate_groups WHERE status = 'unresolved'
|
||||
ORDER BY group_id LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
@@ -16,6 +16,7 @@ tracing = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs"] }
|
||||
tokio-util = { workspace = true }
|
||||
object_store = { version = "0.11" }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{FileEntry, FileStoragePort};
|
||||
use domain::ports::{DataStream, FileEntry, FileStoragePort};
|
||||
use futures::StreamExt;
|
||||
use std::path::PathBuf;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
pub struct LocalFileStorage {
|
||||
base_path: PathBuf,
|
||||
@@ -51,6 +53,25 @@ impl FileStoragePort for LocalFileStorage {
|
||||
Ok(Bytes::from(data))
|
||||
}
|
||||
|
||||
async fn open_file(&self, path: &str) -> Result<(DataStream, u64), DomainError> {
|
||||
let full = self.resolve(path)?;
|
||||
let meta = tokio::fs::metadata(&full)
|
||||
.await
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => DomainError::NotFound(path.to_string()),
|
||||
_ => DomainError::Internal(format!("Failed to stat file: {e}")),
|
||||
})?;
|
||||
let file = tokio::fs::File::open(&full)
|
||||
.await
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => DomainError::NotFound(path.to_string()),
|
||||
_ => DomainError::Internal(format!("Failed to open file: {e}")),
|
||||
})?;
|
||||
let stream = ReaderStream::new(file)
|
||||
.map(|r| r.map_err(|e| DomainError::Internal(format!("Read error: {e}"))));
|
||||
Ok((Box::pin(stream), meta.len()))
|
||||
}
|
||||
|
||||
async fn delete_file(&self, path: &str) -> Result<(), DomainError> {
|
||||
let full = self.resolve(path)?;
|
||||
match tokio::fs::remove_file(&full).await {
|
||||
|
||||
Reference in New Issue
Block a user