Skip to content

Commit 2f1554f

Browse files
committed
fix(face-clusters): run global reclustering as async job; remove 10s axios cap (AOSSIE-Org#1345)
The shared axios client applied a hard 10s timeout to every backend request with no per-call override, so synchronous endpoints that scale with library size (notably global face reclustering) aborted in the UI after ~10s while the backend kept running to completion, leaving the UI and DB inconsistent. Backend - POST /face-clusters/global-recluster now starts the full DBSCAN pass as a background task and returns a task_id immediately (202 Accepted), running the blocking work via asyncio.to_thread so the event loop is not blocked. - New GET /face-clusters/global-recluster/{task_id} to poll the job status (running | complete | error). - Concurrency guard: a second trigger while one is running rejoins the in-flight task instead of starting a second pass that would race on the cluster tables. - Cleanup loop reaps finished task results after a TTL (registered in the app lifespan); running tasks are never reaped. Terminal results are not deleted on first poll, so repeated polls (multiple tabs/retries) stay consistent. Frontend - Raise the default axios timeout 10s -> 30s and add LONG_REQUEST_TIMEOUT_MS (120s) applied per-call to the face-search / multi-person-search endpoints. - Replace the one-shot reclustering mutation with a useGlobalRecluster polling hook wired into the Settings recluster button (same loader/toast UX). Scoped to the face-clustering side of the issue: memories endpoints are left untouched (handled separately), and a live progress bar / SSE is deferred to a follow-up.
1 parent b3eee2c commit 2f1554f

8 files changed

Lines changed: 364 additions & 65 deletions

File tree

backend/app/routes/face_clusters.py

Lines changed: 157 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import asyncio
12
import logging
23
from binascii import Error as Base64Error
34
import base64
4-
from typing import Annotated
5+
from dataclasses import dataclass, field
6+
from datetime import datetime, timezone
7+
from typing import Annotated, Dict, Optional
58
import uuid
69
import os
710
from fastapi import APIRouter, HTTPException, Query, status
@@ -20,8 +23,10 @@
2023
ErrorResponse,
2124
GetClustersResponse,
2225
GetClustersData,
23-
GlobalReclusterResponse,
24-
GlobalReclusterData,
26+
GlobalReclusterStartData,
27+
GlobalReclusterStartResponse,
28+
GlobalReclusterStatusData,
29+
GlobalReclusterStatusResponse,
2530
ClusterMetadata,
2631
GetClusterImagesResponse,
2732
GetClusterImagesData,
@@ -38,6 +43,83 @@
3843
router = APIRouter()
3944

4045

46+
# Global reclustering runs synchronously over every face embedding in the
47+
# library and can take well past any reasonable HTTP timeout on large
48+
# libraries, so it runs as a background task that the client polls instead
49+
# of blocking the request.
50+
@dataclass
51+
class ReclusterTask:
52+
status: str = "running" # running | complete | error
53+
clusters_created: Optional[int] = None
54+
faces_skipped: Optional[int] = None
55+
message: Optional[str] = None
56+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
57+
task: Optional[asyncio.Task] = None
58+
59+
60+
recluster_tasks: Dict[str, ReclusterTask] = {}
61+
62+
# Only one global reclustering job may run at a time: a full pass deletes and
63+
# rebuilds every cluster, so two concurrent runs would race on the same tables.
64+
# Holds the task_id of the in-flight job, or None when idle. Safe without a
65+
# lock because it is only read/written from the (single-threaded) event loop
66+
# with no await between check-and-set.
67+
_active_recluster_task_id: Optional[str] = None
68+
69+
# How long a finished task's result is retained for polling before the cleanup
70+
# loop reaps it. Running tasks are never reaped (they are bounded to one by the
71+
# concurrency guard above).
72+
RECLUSTER_TASK_TTL_MINUTES = 15
73+
74+
75+
async def _run_global_recluster(task_id: str):
76+
global _active_recluster_task_id
77+
entry = recluster_tasks[task_id]
78+
try:
79+
result, total_faces_skipped = await asyncio.to_thread(
80+
cluster_util_face_clusters_sync, force_full_reclustering=True
81+
)
82+
83+
entry.status = "complete"
84+
entry.clusters_created = result or 0
85+
entry.faces_skipped = total_faces_skipped
86+
entry.message = (
87+
"No faces found to cluster"
88+
if not result
89+
else "Global reclustering completed successfully."
90+
)
91+
logger.info("Global reclustering completed successfully (task_id=%s)", task_id)
92+
except Exception as e:
93+
logger.error(f"Global reclustering failed: {str(e)}")
94+
entry.status = "error"
95+
entry.message = f"Global reclustering failed: {str(e)}"
96+
finally:
97+
# Release the concurrency guard so a new job can be started, while the
98+
# finished result stays in recluster_tasks for the client to poll.
99+
if _active_recluster_task_id == task_id:
100+
_active_recluster_task_id = None
101+
102+
103+
async def _cleanup_stale_recluster_tasks():
104+
"""Periodically drop finished reclustering results once they age out.
105+
106+
Running tasks are left untouched (a legitimate recluster can run for a
107+
long time, and the concurrency guard already bounds them to one).
108+
"""
109+
while True:
110+
await asyncio.sleep(300) # run every 5 minutes
111+
now = datetime.now(timezone.utc)
112+
stale = [
113+
tid
114+
for tid, entry in recluster_tasks.items()
115+
if entry.status != "running"
116+
and (now - entry.created_at).total_seconds()
117+
> RECLUSTER_TASK_TTL_MINUTES * 60
118+
]
119+
for tid in stale:
120+
recluster_tasks.pop(tid, None)
121+
122+
41123
@router.put(
42124
"/{cluster_id}",
43125
response_model=RenameClusterResponse,
@@ -308,51 +390,95 @@ def face_tagging(
308390

309391
@router.post(
310392
"/global-recluster",
311-
response_model=GlobalReclusterResponse,
393+
status_code=status.HTTP_202_ACCEPTED,
394+
response_model=GlobalReclusterStartResponse,
312395
responses={code: {"model": ErrorResponse} for code in [500]},
313396
)
314-
def trigger_global_reclustering():
397+
async def trigger_global_reclustering():
315398
"""
316-
Manually trigger global face reclustering.
399+
Start a global face reclustering job in the background.
317400
This forces full reclustering regardless of the 24-hour rule.
401+
402+
Returns immediately with a task_id; poll
403+
GET /face-clusters/global-recluster/{task_id} for the result, since
404+
reclustering runs over every face embedding and can take a long time
405+
on large libraries.
406+
407+
If a reclustering job is already running, its task_id is returned instead
408+
of starting a second (concurrent runs would race on the cluster tables).
318409
"""
319-
try:
320-
logger.info("Starting manual global face reclustering...")
410+
global _active_recluster_task_id
321411

322-
result, total_faces_skipped = cluster_util_face_clusters_sync(
323-
force_full_reclustering=True
412+
if _active_recluster_task_id is not None:
413+
logger.info(
414+
"Global reclustering already in progress (task_id=%s); reusing it",
415+
_active_recluster_task_id,
416+
)
417+
return GlobalReclusterStartResponse(
418+
success=True,
419+
message="Global reclustering already in progress.",
420+
data=GlobalReclusterStartData(task_id=_active_recluster_task_id),
324421
)
325422

326-
if result == 0:
327-
return GlobalReclusterResponse(
328-
success=True,
329-
message="No faces found to cluster",
330-
data=GlobalReclusterData(
331-
clusters_created=0, faces_skipped=total_faces_skipped
332-
),
333-
)
423+
task_id = str(uuid.uuid4())
424+
entry = ReclusterTask()
425+
recluster_tasks[task_id] = entry
426+
_active_recluster_task_id = task_id
427+
entry.task = asyncio.create_task(_run_global_recluster(task_id))
334428

335-
logger.info("Global reclustering completed successfully")
429+
logger.info("Started manual global face reclustering (task_id=%s)", task_id)
336430

337-
return GlobalReclusterResponse(
338-
success=True,
339-
message="Global reclustering completed successfully.",
340-
data=GlobalReclusterData(
341-
clusters_created=result, faces_skipped=total_faces_skipped
342-
),
343-
)
431+
return GlobalReclusterStartResponse(
432+
success=True,
433+
message="Global reclustering started.",
434+
data=GlobalReclusterStartData(task_id=task_id),
435+
)
344436

345-
except Exception as e:
346-
logger.error(f"Global reclustering failed: {str(e)}")
437+
438+
@router.get(
439+
"/global-recluster/{task_id}",
440+
response_model=GlobalReclusterStatusResponse,
441+
responses={code: {"model": ErrorResponse} for code in [404]},
442+
)
443+
async def get_global_recluster_status(task_id: str):
444+
"""Poll the status of a previously started global reclustering job."""
445+
entry = recluster_tasks.get(task_id)
446+
if entry is None:
347447
raise HTTPException(
348-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
448+
status_code=status.HTTP_404_NOT_FOUND,
349449
detail=ErrorResponse(
350450
success=False,
351-
error="Internal server error",
352-
message=f"Global reclustering failed: {str(e)}",
451+
error="Task Not Found",
452+
message=f"Recluster task '{task_id}' not found or already consumed.",
353453
).model_dump(),
354454
)
355455

456+
if entry.status == "running":
457+
return GlobalReclusterStatusResponse(
458+
success=True,
459+
data=GlobalReclusterStatusData(status="running"),
460+
)
461+
462+
# Terminal state: leave the entry in place so repeated polls (multiple
463+
# tabs, retries) return the same result; _cleanup_stale_recluster_tasks
464+
# reaps it once it ages out.
465+
if entry.status == "error":
466+
return GlobalReclusterStatusResponse(
467+
success=False,
468+
message=entry.message,
469+
data=GlobalReclusterStatusData(status="error"),
470+
)
471+
472+
return GlobalReclusterStatusResponse(
473+
success=True,
474+
message=entry.message,
475+
data=GlobalReclusterStatusData(
476+
status="complete",
477+
clusters_created=entry.clusters_created,
478+
faces_skipped=entry.faces_skipped,
479+
),
480+
)
481+
356482

357483
@router.post(
358484
"/multi-search",

backend/app/schemas/face_clusters.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from pydantic import BaseModel
2-
from typing import List, Optional, Dict, Union, Any
2+
from typing import List, Literal, Optional, Dict, Union, Any
33

44

55
# Request Models
@@ -74,16 +74,32 @@ class GetClusterImagesResponse(BaseModel):
7474
data: Optional[GetClusterImagesData] = None
7575

7676

77-
class GlobalReclusterData(BaseModel):
77+
class GlobalReclusterStartData(BaseModel):
78+
task_id: str
79+
80+
81+
class GlobalReclusterStartResponse(BaseModel):
82+
"""Returned immediately when a global reclustering job is started."""
83+
84+
success: bool
85+
message: Optional[str] = None
86+
error: Optional[str] = None
87+
data: Optional[GlobalReclusterStartData] = None
88+
89+
90+
class GlobalReclusterStatusData(BaseModel):
91+
status: Literal["running", "complete", "error"]
7892
clusters_created: Optional[int] = None
7993
faces_skipped: Optional[int] = None
8094

8195

82-
class GlobalReclusterResponse(BaseModel):
96+
class GlobalReclusterStatusResponse(BaseModel):
97+
"""Polled to check on the progress of a global reclustering job."""
98+
8399
success: bool
84100
message: Optional[str] = None
85101
error: Optional[str] = None
86-
data: Optional[GlobalReclusterData] = None
102+
data: Optional[GlobalReclusterStatusData] = None
87103

88104

89105
class MultiPersonSearchRequest(BaseModel):

backend/main.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
from app.routes.folders import router as folders_router
2626
from app.routes.albums import router as albums_router
2727
from app.routes.images import router as images_router
28-
from app.routes.face_clusters import router as face_clusters_router
28+
from app.routes.face_clusters import (
29+
router as face_clusters_router,
30+
_cleanup_stale_recluster_tasks,
31+
)
2932
from app.routes.user_preferences import router as user_preferences_router
3033
from app.routes.memories import router as memories_router
3134
from app.routes.shutdown import router as shutdown_router
@@ -66,12 +69,17 @@ async def lifespan(app: FastAPI):
6669

6770
# Start the SSE model download cleanup task
6871
cleanup_task = asyncio.create_task(_cleanup_stale_tasks())
72+
# Start the global-reclustering finished-task cleanup loop
73+
recluster_cleanup_task = asyncio.create_task(_cleanup_stale_recluster_tasks())
6974

7075
try:
7176
yield
7277
finally:
7378
cleanup_task.cancel()
74-
await asyncio.gather(cleanup_task, return_exceptions=True)
79+
recluster_cleanup_task.cancel()
80+
await asyncio.gather(
81+
cleanup_task, recluster_cleanup_task, return_exceptions=True
82+
)
7583
app.state.executor.shutdown(wait=True)
7684

7785

frontend/src/api/api-functions/face_clusters.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { faceClustersEndpoints } from '../apiEndpoints';
2-
import { apiClient } from '../axiosConfig';
2+
import { apiClient, LONG_REQUEST_TIMEOUT_MS } from '../axiosConfig';
33
import { APIResponse } from '@/types/API';
44
import { BackendRes } from '@/hooks/useQueryExtension';
55
import type { Image } from '@/types/Media';
@@ -53,6 +53,7 @@ export const fetchSearchedFaces = async (
5353
const response = await apiClient.post<APIResponse>(
5454
faceClustersEndpoints.searchForFaces,
5555
request,
56+
{ timeout: LONG_REQUEST_TIMEOUT_MS },
5657
);
5758
return response.data;
5859
};
@@ -63,6 +64,7 @@ export const fetchSearchedFacesBase64 = async (
6364
const response = await apiClient.post<BackendRes<Image[]>>(
6465
faceClustersEndpoints.searchForFacesBase64,
6566
request,
67+
{ timeout: LONG_REQUEST_TIMEOUT_MS },
6668
);
6769
return response.data;
6870
};
@@ -72,15 +74,39 @@ export interface GlobalReclusterData {
7274
faces_skipped: number | null;
7375
}
7476

75-
export const triggerGlobalReclustering = async (): Promise<
76-
BackendRes<GlobalReclusterData>
77+
export interface GlobalReclusterStartData {
78+
task_id: string;
79+
}
80+
81+
export interface GlobalReclusterStatusData {
82+
status: 'running' | 'complete' | 'error';
83+
clusters_created: number | null;
84+
faces_skipped: number | null;
85+
}
86+
87+
// Global reclustering runs over every face embedding in the library and can
88+
// take well past any reasonable HTTP timeout, so the backend runs it as a
89+
// background job: this kicks it off and returns a task_id immediately.
90+
export const startGlobalReclustering = async (): Promise<
91+
BackendRes<GlobalReclusterStartData>
7792
> => {
78-
const response = await apiClient.post<BackendRes<GlobalReclusterData>>(
93+
const response = await apiClient.post<BackendRes<GlobalReclusterStartData>>(
7994
faceClustersEndpoints.globalRecluster,
8095
);
8196
return response.data;
8297
};
8398

99+
// Poll this with the task_id returned by startGlobalReclustering until
100+
// status is 'complete' or 'error'.
101+
export const getGlobalReclusterStatus = async (
102+
taskId: string,
103+
): Promise<BackendRes<GlobalReclusterStatusData>> => {
104+
const response = await apiClient.get<BackendRes<GlobalReclusterStatusData>>(
105+
faceClustersEndpoints.globalReclusterStatus(taskId),
106+
);
107+
return response.data;
108+
};
109+
84110
export interface MultiPersonSearchRequest {
85111
cluster_ids: string[];
86112
match_mode: 'match_any' | 'match_all';
@@ -92,6 +118,7 @@ export const fetchMultiPersonSearch = async (
92118
const response = await apiClient.post<APIResponse>(
93119
faceClustersEndpoints.multiPersonSearch,
94120
request,
121+
{ timeout: LONG_REQUEST_TIMEOUT_MS },
95122
);
96123
return response.data;
97124
};

frontend/src/api/apiEndpoints.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export const faceClustersEndpoints = {
1010
renameCluster: (clusterId: string) => `/face-clusters/${clusterId}`,
1111
getClusterImages: (clusterId: string) => `/face-clusters/${clusterId}/images`,
1212
globalRecluster: '/face-clusters/global-recluster',
13+
globalReclusterStatus: (taskId: string) =>
14+
`/face-clusters/global-recluster/${taskId}`,
1315
multiPersonSearch: '/face-clusters/multi-search',
1416
};
1517

0 commit comments

Comments
 (0)