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:
2026-05-31 22:40:25 +02:00
parent d879fd6437
commit bcaf49cc81
21 changed files with 263 additions and 123 deletions

View File

@@ -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);

View File

@@ -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()?;