|
| 1 | +import asyncio |
1 | 2 | import logging |
2 | 3 | from binascii import Error as Base64Error |
3 | 4 | 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 |
5 | 8 | import uuid |
6 | 9 | import os |
7 | 10 | from fastapi import APIRouter, HTTPException, Query, status |
|
20 | 23 | ErrorResponse, |
21 | 24 | GetClustersResponse, |
22 | 25 | GetClustersData, |
23 | | - GlobalReclusterResponse, |
24 | | - GlobalReclusterData, |
| 26 | + GlobalReclusterStartData, |
| 27 | + GlobalReclusterStartResponse, |
| 28 | + GlobalReclusterStatusData, |
| 29 | + GlobalReclusterStatusResponse, |
25 | 30 | ClusterMetadata, |
26 | 31 | GetClusterImagesResponse, |
27 | 32 | GetClusterImagesData, |
|
38 | 43 | router = APIRouter() |
39 | 44 |
|
40 | 45 |
|
| 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 | + |
41 | 123 | @router.put( |
42 | 124 | "/{cluster_id}", |
43 | 125 | response_model=RenameClusterResponse, |
@@ -308,51 +390,95 @@ def face_tagging( |
308 | 390 |
|
309 | 391 | @router.post( |
310 | 392 | "/global-recluster", |
311 | | - response_model=GlobalReclusterResponse, |
| 393 | + status_code=status.HTTP_202_ACCEPTED, |
| 394 | + response_model=GlobalReclusterStartResponse, |
312 | 395 | responses={code: {"model": ErrorResponse} for code in [500]}, |
313 | 396 | ) |
314 | | -def trigger_global_reclustering(): |
| 397 | +async def trigger_global_reclustering(): |
315 | 398 | """ |
316 | | - Manually trigger global face reclustering. |
| 399 | + Start a global face reclustering job in the background. |
317 | 400 | 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). |
318 | 409 | """ |
319 | | - try: |
320 | | - logger.info("Starting manual global face reclustering...") |
| 410 | + global _active_recluster_task_id |
321 | 411 |
|
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), |
324 | 421 | ) |
325 | 422 |
|
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)) |
334 | 428 |
|
335 | | - logger.info("Global reclustering completed successfully") |
| 429 | + logger.info("Started manual global face reclustering (task_id=%s)", task_id) |
336 | 430 |
|
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 | + ) |
344 | 436 |
|
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: |
347 | 447 | raise HTTPException( |
348 | | - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| 448 | + status_code=status.HTTP_404_NOT_FOUND, |
349 | 449 | detail=ErrorResponse( |
350 | 450 | 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.", |
353 | 453 | ).model_dump(), |
354 | 454 | ) |
355 | 455 |
|
| 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 | + |
356 | 482 |
|
357 | 483 | @router.post( |
358 | 484 | "/multi-search", |
|
0 commit comments