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

@@ -203,12 +203,12 @@ pub async fn serve_file(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &result.mime_type)
.header(header::CONTENT_LENGTH, result.data.len())
.header(header::CONTENT_LENGTH, result.size)
.header(
header::CONTENT_DISPOSITION,
format!("inline; filename=\"{}\"", result.filename),
)
.body(Body::from(result.data))
.body(Body::from_stream(result.stream))
.map_err(|e| AppError::from(domain::errors::DomainError::Internal(e.to_string())))
}
@@ -256,9 +256,9 @@ pub async fn serve_derivative(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &result.mime_type)
.header(header::CONTENT_LENGTH, result.data.len())
.header(header::CONTENT_LENGTH, result.size)
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.body(Body::from(result.data))
.body(Body::from_stream(result.stream))
.map_err(|e| AppError::from(DomainError::Internal(e.to_string())))
}

View File

@@ -1,23 +1,35 @@
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
use crate::{
constants::{DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE},
errors::AppError,
extractors::JwtClaims,
state::AppState,
};
use api_types::{requests::ResolveDuplicateRequest, responses::DuplicateGroupResponse};
use application::catalog::{ListDuplicatesQuery, ResolveDuplicateCommand};
use axum::{
Json,
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
};
use domain::value_objects::SystemId;
#[derive(Debug, serde::Deserialize)]
pub struct ListDuplicatesParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
pub async fn list_duplicates(
State(state): State<AppState>,
claims: JwtClaims,
Query(params): Query<ListDuplicatesParams>,
) -> Result<Json<Vec<DuplicateGroupResponse>>, AppError> {
super::require_admin(&claims)?;
let groups = state
.catalog
.list_duplicates
.execute(ListDuplicatesQuery)
.await?;
let query = ListDuplicatesQuery {
limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE),
offset: params.offset.unwrap_or(0),
};
let groups = state.catalog.list_duplicates.execute(query).await?;
let resp = groups
.iter()
.map(DuplicateGroupResponse::from_domain)