Skip to content

fix(face-clusters): run global reclustering as async job; remove 10s axios timeout cap (#1345) - #1349

Open
VanshajPoonia wants to merge 14 commits into
AOSSIE-Org:mainfrom
VanshajPoonia:fix/1345-recluster-timeout
Open

fix(face-clusters): run global reclustering as async job; remove 10s axios timeout cap (#1345)#1349
VanshajPoonia wants to merge 14 commits into
AOSSIE-Org:mainfrom
VanshajPoonia:fix/1345-recluster-timeout

Conversation

@VanshajPoonia

@VanshajPoonia VanshajPoonia commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Addressed Issues:

Fixes #1345

Screenshots/Recordings:

No visual UI change. The fix is behavioural: previously the Recluster Faces action in Settings (and other long calls) failed in the UI after about 10s on a large library while the backend kept running to completion. Now reclustering runs as a background job and the UI waits for it via polling, showing the existing loader and a success/failure toast on completion.

Additional Notes:

The shared axios client applied a hard 10s timeout to every backend request (frontend/src/api/axiosConfig.ts) with no per-call override. Synchronous endpoints that scale with library size, most notably global face reclustering, run past 10s on a realistic library, so the client aborted with a timeout error 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 job status (running, complete, or error).
  • Concurrency guard: a second trigger while a job is running rejoins the in-flight task_id instead of starting a second pass that would race on the cluster tables (a full recluster deletes and rebuilds every cluster).
  • Cleanup loop reaps finished task results after a TTL, measured from completion time so a long job's result is not reaped right after it finishes (registered in the app lifespan, mirroring the model-download cleanup in models.py). Running tasks are never reaped, and terminal results are not deleted on first poll, so repeated polls (multiple tabs or retries) stay consistent.

Frontend

  • Raise the default axios timeout from 10s to 30s, and add LONG_REQUEST_TIMEOUT_MS (120s) applied per-call to the face-search and 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 as before). The hook polls with a self-scheduling timeout and a run-id guard so overlapping requests and stale runs cannot update state or leak timers.

Scope and deliberate deferrals

  • Polling, not SSE. The issue suggested SSE; the clustering call emits no progress to stream, so SSE would carry the same running-then-done information as polling with more machinery. A real progress bar (which also needs progress reporting added inside the clustering function) is left for a separate issue.
  • Memories endpoints are intentionally left untouched because that work is owned by a GSoC project. memories/generate, timeline and locations are not modified in this PR; they still benefit from the global 10s to 30s default change, and any deeper async/persistence work on them is left to the GSoC effort.
  • Known follow-up: the background-job machinery added here (ReclusterTask, recluster_tasks, _cleanup_stale_recluster_tasks) duplicates the structure already in models.py. It works correctly with no runtime cost, but could be unified into a shared utility in a later refactor; it is deferred here to avoid touching the working model-download code.

Testing

  • Frontend: tsc --noEmit, ESLint, Prettier, and the full Jest suite (383/383 across 42 suites) pass, including 6 new useGlobalRecluster tests covering success, failure, cancellation on unmount, and rapid re-triggers.
  • Backend: full pytest suite passes (1114), with new endpoint tests for starting, polling, job reuse, and 404 on an unknown task. black and ruff clean on changed files.
  • Reproduced the bug and the fix end to end on a seeded library of 4000 faces, both at the HTTP level and by clicking the real Recluster Faces button with the network activity recorded. On main the request aborts at 10001ms while the backend keeps working and finishes at 16.18s; on this branch the POST returns 202 in 1ms and the polls report completion at 16.07s. Details and captures in the comment below.

AI Usage Disclosure:

We encourage contributors to use AI tools responsibly when creating Pull Requests. While AI can be a valuable aid, it is essential to ensure that your contributions meet the task requirements, build successfully, include relevant tests, and pass all linters. Submissions that do not meet these standards may be closed without warning to maintain the quality and integrity of the project. Please take the time to understand the changes you are proposing and their impact. AI slop is strongly discouraged and may lead to banning and blocking. Do not spam our repos with AI slop.

Check one of the checkboxes below:

  • This PR does not contain AI-generated code at all.
  • This PR contains AI-generated code. I have read the AI Usage Policy and this PR complies with this policy. I have tested the code locally and I am responsible for it.

I have used the following AI models and tools: Codex

Checklist

  • My PR addresses a single issue, fixes a single bug or makes a single improvement.
  • My code follows the project's code style and conventions
  • If applicable, I have made corresponding changes or additions to the documentation
  • If applicable, I have made corresponding changes or additions to tests
  • My changes generate no new warnings or errors
  • I have joined the Discord server and I will share a link to this PR with the project maintainers there
  • I have read the Contribution Guidelines
  • Once I submit my PR, CodeRabbit AI will automatically review it and I will address CodeRabbit's comments.
  • I have filled this PR template completely and carefully, and I understand that my PR may be closed without review otherwise.

Summary by CodeRabbit

  • New Features

    • Global face reclustering now runs as a background job that returns a task_id.
    • Added polling via a new status endpoint to track running, complete, or error, including clusters_created and faces_skipped.
  • Bug Fixes

    • Prevented concurrent global reclustering from starting multiple jobs; existing in-progress runs are reused.
    • Long-running face operations now use longer request timeouts for improved reliability.
  • Tests

    • Added backend endpoint tests for starting, polling, reuse, and 404 handling.
    • Added frontend hook tests covering success, failure, cancellation, and rapid re-triggers.

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Global face reclustering now starts as a background task and is checked through a status endpoint. The backend adds task tracking, cleanup, and async execution; the frontend adds new API calls, polling, timeout updates, tests, and revised Settings-page wiring.

Changes

Async Global Reclustering Flow

Layer / File(s) Summary
Backend schema contracts
backend/app/schemas/face_clusters.py
Replaces the synchronous reclustering response with separate start and status models containing a task ID, status, and result counters.
Backend task infrastructure and endpoints
backend/app/routes/face_clusters.py, backend/main.py, backend/run-server.ps1, backend/run.sh, backend/tests/test_face_clusters.py
Adds task tracking, async execution, concurrency reuse, TTL cleanup, lifecycle wiring, single-worker startup, and endpoint coverage.
Frontend HTTP timeouts and reclustering API
frontend/src/api/axiosConfig.ts, frontend/src/api/apiEndpoints.ts, frontend/src/api/api-functions/face_clusters.ts
Adds shared timeout constants, longer face-search request timeouts, and start/status API calls for reclustering.
Polling hook and Settings wiring
frontend/src/hooks/useGlobalRecluster.tsx, frontend/src/hooks/__tests__/useGlobalRecluster.test.tsx, frontend/src/pages/SettingsPage/components/ApplicationControlsCard.tsx
Adds guarded polling with success/error handling and tests, then connects the hook to Settings-page feedback and skipped-face alerts.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SettingsPage
  participant ReclusterHook
  participant FaceClustersAPI
  participant BackendJob
  participant ClusterCache

  SettingsPage->>ReclusterHook: trigger()
  ReclusterHook->>FaceClustersAPI: startGlobalReclustering()
  FaceClustersAPI->>BackendJob: PUT /global-recluster
  BackendJob-->>FaceClustersAPI: 202 { task_id }
  loop Poll until terminal status
    ReclusterHook->>FaceClustersAPI: getGlobalReclusterStatus(task_id)
    FaceClustersAPI->>BackendJob: GET /global-recluster/{task_id}
    BackendJob-->>FaceClustersAPI: running / complete / error
  end
  ReclusterHook->>ClusterCache: invalidate ['clusters']
  ReclusterHook-->>SettingsPage: success or error feedback
Loading

Possibly related PRs

Suggested labels: Python, TypeScript/JavaScript

Suggested reviewers: rohan-pandeyy, rahulharpal1603

Poem

🐇 A task hops off, then status calls sing,
Clusters settle while timers ping.
Skipped faces leave a tiny trace,
And fresh results brighten the place.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses the issue’s core fix by raising timeouts, adding per-request search timeouts, and making global reclustering async with polling.
Out of Scope Changes check ✅ Passed The worker-count and cleanup changes support the new in-memory job flow and remain within the PR’s scope.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: asynchronous global reclustering and removal of the 10-second Axios timeout cap.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added backend bug Something isn't working labels Jun 28, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
frontend/src/hooks/useGlobalRecluster.tsx (1)

40-116: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add automated coverage for the polling lifecycle.

This hook now owns the core reclustering UX, but the PR does not include tests for success, error, unmount cleanup, or repeated-trigger behavior. Those are the paths most likely to regress loaders, dialogs, and interval cleanup. As per path instructions, "Ensure that test code is automated, comprehensive, and follows testing best practices" and "Verify that all critical functionality is covered by tests."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/hooks/useGlobalRecluster.tsx` around lines 40 - 116, Add
automated tests for useGlobalRecluster to cover the full polling lifecycle:
successful completion, error handling from both startGlobalReclustering and
getGlobalReclusterStatus, cleanup via stopPolling on unmount, and repeated
trigger calls preventing duplicate intervals. Use the useGlobalRecluster hook
and its trigger/stopPolling behavior as the primary targets, and assert
queryClient.invalidateQueries is called on completion and that state transitions
to isSuccess/isError are set correctly. Ensure the tests mock the interval/timer
behavior and verify polling is cleared in all termination paths.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/app/routes/face_clusters.py`:
- Around line 56-57: The task lifecycle in face_clusters should track when the
job finished, not when the tracker object was created, because cleanup uses that
timestamp to age out results too early. Update the tracker state used by the
recluster flow (the class/fields around created_at, task, and the cleanup logic
in the polling/cleanup path) so terminal results are retained from completion
time, and make the cleanup code compare against a completion timestamp rather
than the creation timestamp.
- Around line 60-67: The global recluster tracking in face_clusters.py is
worker-local, so `recluster_tasks` and `_active_recluster_task_id` can diverge
across multiple backend workers and allow duplicate jobs or broken polling.
Update the recluster flow around `recluster_tasks`, `_active_recluster_task_id`,
and the task creation/polling handlers to use a shared coordination mechanism
such as a distributed lock or shared persistence layer, or otherwise enforce a
single-worker deployment in the runtime startup scripts/config.

In `@frontend/src/hooks/useGlobalRecluster.tsx`:
- Around line 54-113: The polling logic in useGlobalRecluster’s trigger is
vulnerable to overlapping status requests and stale runs because
setInterval(async ...) can stack calls and an older trigger() can keep running
after a newer one starts. Replace the interval-based polling with a
self-scheduling timeout loop, and add a monotonically increasing run id/ref
inside trigger, stopPolling, and the polling callback so only the latest run can
update state or clear polling. Make sure getGlobalReclusterStatus, setState, and
queryClient.invalidateQueries only execute for the active request.

---

Nitpick comments:
In `@frontend/src/hooks/useGlobalRecluster.tsx`:
- Around line 40-116: Add automated tests for useGlobalRecluster to cover the
full polling lifecycle: successful completion, error handling from both
startGlobalReclustering and getGlobalReclusterStatus, cleanup via stopPolling on
unmount, and repeated trigger calls preventing duplicate intervals. Use the
useGlobalRecluster hook and its trigger/stopPolling behavior as the primary
targets, and assert queryClient.invalidateQueries is called on completion and
that state transitions to isSuccess/isError are set correctly. Ensure the tests
mock the interval/timer behavior and verify polling is cleared in all
termination paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0aa7d84e-f074-4372-a926-3c8fca0f1a52

📥 Commits

Reviewing files that changed from the base of the PR and between b3eee2c and cd74151.

📒 Files selected for processing (8)
  • backend/app/routes/face_clusters.py
  • backend/app/schemas/face_clusters.py
  • backend/main.py
  • frontend/src/api/api-functions/face_clusters.ts
  • frontend/src/api/apiEndpoints.ts
  • frontend/src/api/axiosConfig.ts
  • frontend/src/hooks/useGlobalRecluster.tsx
  • frontend/src/pages/SettingsPage/components/ApplicationControlsCard.tsx

Comment thread backend/app/routes/face_clusters.py
Comment thread backend/app/routes/face_clusters.py
Comment thread frontend/src/hooks/useGlobalRecluster.tsx
…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.
@VanshajPoonia
VanshajPoonia force-pushed the fix/1345-recluster-timeout branch from cd74151 to 2f1554f Compare June 28, 2026 11:09
…l loop

Addresses review feedback on the async reclustering flow.

- ReclusterTask now records finished_at, and the cleanup loop ages terminal
  results from completion time instead of creation time. Previously a job that
  ran close to the TTL could be reaped almost immediately after finishing,
  making polling clients see a 404 instead of the result.

- useGlobalRecluster now polls with a self-scheduling setTimeout (the next tick
  is queued only after the current request resolves, so status requests can't
  stack/overlap) and guards every async callback with a monotonically
  increasing run id. A newer trigger() — or unmount — invalidates older runs so
  they cannot keep polling or overwrite state, fixing the orphaned-interval leak
  on repeated triggers.
…er setup

- Add unit tests for the useGlobalRecluster polling hook covering the success
  lifecycle, error from start, error reported by the status poll, polling
  cleanup on unmount, and that a rapid second trigger does not leave a second
  poll loop running.

- Warn at startup when WORKERS > 1, since model-download and global-reclustering
  job tracking is in-memory and per-worker; a job started in one worker is
  invisible to others, so deployments should keep a single worker. This is a
  non-breaking safeguard (no behaviour change to the default single-worker run).
- main.py already defines a module-level logger; remove the duplicate one
  added for the worker warning (the lifespan resolves the existing logger at
  runtime).
- Remove the now-unused GlobalReclusterData interface from the frontend API
  module (superseded by the start/status data interfaces).
…king

Model-download and global-reclustering job state lives in per-worker memory, so
running multiple workers would let a job start in one worker while status polls
hit another (404s, duplicate jobs). Hardcode the server to a single worker in
run.sh and run-server.ps1 (removing the WORKERS override) and document the
constraint at the in-memory state. Replaces the previous best-effort startup
warning, which could not observe the real worker count.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/hooks/__tests__/useGlobalRecluster.test.tsx`:
- Around line 101-127: The `useGlobalRecluster` test suite is missing coverage
for the rejected-status path from `getGlobalReclusterStatus`. Add a test
alongside the existing `sets an error when the job reports failure` and `sets an
error when starting the job fails` cases that makes `mockStatus` reject (for
example, a missing/aged-out task), then assert the hook sets `isError`, surfaces
the failure message, and stops polling/invalidation behavior as expected via
`trigger()` and `flush()`. Keep the new case aligned with the existing `setup`,
`mockStart`, and `mockStatus` helpers so the cleanup/TTL contract is covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7d92f44c-69f1-47db-843d-a7914af5c785

📥 Commits

Reviewing files that changed from the base of the PR and between 2f1554f and a422cc2.

📒 Files selected for processing (7)
  • backend/app/routes/face_clusters.py
  • backend/main.py
  • backend/run-server.ps1
  • backend/run.sh
  • frontend/src/api/api-functions/face_clusters.ts
  • frontend/src/hooks/__tests__/useGlobalRecluster.test.tsx
  • frontend/src/hooks/useGlobalRecluster.tsx
💤 Files with no reviewable changes (1)
  • frontend/src/api/api-functions/face_clusters.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • frontend/src/hooks/useGlobalRecluster.tsx
  • backend/main.py
  • backend/app/routes/face_clusters.py

Comment thread frontend/src/hooks/__tests__/useGlobalRecluster.test.tsx
…ster

Addresses a CodeRabbit review comment: the suite only covered the
{success: false, status: 'error'} payload path, not a rejected status
request (e.g. 404 for a missing/aged-out task ID).
The async conversion changed the global-recluster contract (200 -> 202,
new response shape, new status endpoint) and added a concurrency guard,
but no backend test exercised any of it, so CI passing said nothing
about the new behaviour.

Adds coverage for the job contract: start returns 202 with a task_id and
polling yields the result; a second trigger rejoins the in-flight job
instead of starting a racing second pass; a failed job is reported as an
error result to the poller rather than a failed request; and an unknown
task_id is a 404.

Also corrects the 404 message, which said a task was "already consumed" -
terminal results are no longer deleted on first poll, they expire via TTL.
@VanshajPoonia
VanshajPoonia force-pushed the fix/1345-recluster-timeout branch from 2d8c463 to 7e3d640 Compare July 20, 2026 15:21

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
backend/tests/test_face_clusters.py (1)

530-537: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider adding coverage for TTL-based expiry.

The suite covers start→complete, reuse, error, and unknown-id 404, but not the TTL cleanup path — the very behavior the corrected 404 message now advertises ("not found or expired"). A focused test would assert that a finished task whose finished_at is aged beyond RECLUSTER_TASK_TTL_MINUTES is reaped by _cleanup_stale_recluster_tasks (and subsequently returns 404), without waiting on the 300s loop.

Want me to draft this test (stamping finished_at in the past and invoking the reap logic directly)?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_face_clusters.py` around lines 530 - 537, Add a focused
TTL-expiry test near test_status_unknown_task_id_returns_404 that creates or
completes a recluster task, sets its finished_at beyond
RECLUSTER_TASK_TTL_MINUTES, and invokes _cleanup_stale_recluster_tasks directly
without waiting for the background loop. Assert the task is reaped and polling
its ID subsequently returns 404.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@backend/tests/test_face_clusters.py`:
- Around line 530-537: Add a focused TTL-expiry test near
test_status_unknown_task_id_returns_404 that creates or completes a recluster
task, sets its finished_at beyond RECLUSTER_TASK_TTL_MINUTES, and invokes
_cleanup_stale_recluster_tasks directly without waiting for the background loop.
Assert the task is reaped and polling its ID subsequently returns 404.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 63838638-eb84-4268-9c30-02401ebbaec0

📥 Commits

Reviewing files that changed from the base of the PR and between 689fc83 and 2d8c463.

📒 Files selected for processing (2)
  • backend/app/routes/face_clusters.py
  • backend/tests/test_face_clusters.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/app/routes/face_clusters.py

- Merge upstream/main so the PR builds against current main instead of
  a 3-week-old base (brings in the semantic-search work, restructured
  backend/scripts/ layout, etc.)
- black-format backend/scripts/reset_database.py: pre-existing drift on
  main (added by AOSSIE-Org#1378 after this branch forked) that only surfaced
  once merged, since the file didn't exist on this branch before now
- Add a TTL-expiry test for the reclustering cleanup loop, per
  CodeRabbit's review: a finished task past RECLUSTER_TASK_TTL_MINUTES
  is reaped by _cleanup_stale_recluster_tasks and then 404s on poll,
  exercised without waiting on the loop's real 5-minute interval
Several comments added by this PR ran 3-5 lines. Rewritten to keep the
essential why in <=2 lines each; secondary detail that was inferable
from the code (e.g. why the concurrency guard needs no lock) was cut
rather than kept at the cost of length.

Pre-existing section-banner comments (the repo's existing "# ===...
Title ...===" convention) are left alone, including the one this PR
added, so the new banner stays consistent with the seven already in
the file.
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ This PR has merge conflicts.

Please resolve the merge conflicts before review.

Your PR will only be reviewed by a maintainer after all conflicts have been resolved.

📺 Watch this video to understand why conflicts occur and how to resolve them:
https://www.youtube.com/watch?v=Sqsz1-o7nXk

@rohan-pandeyy

Copy link
Copy Markdown
Member

How was it that we could test this @VanshajPoonia?

@VanshajPoonia

Copy link
Copy Markdown
Contributor Author

Can you suggest a method @rohan-pandeyy so we are referring to the same?
I am happy to complete this

@rohan-pandeyy

Copy link
Copy Markdown
Member

Dev tools?
Let's check a before and after... in the network tab possibly?

VanshajPoonia and others added 4 commits August 5, 2026 20:11
Resolve conflicts in backend/main.py and app/routes/face_clusters.py.

Both sides were purely additive in the conflicting regions:

- main.py: upstream added the videos router import; this branch added the
  _cleanup_stale_recluster_tasks import. Kept both.
- face_clusters.py: upstream added the memory-rescore helpers
  (_log_rescore_outcome, _rescore_memories_for_cluster) and their
  concurrent.futures imports; this branch added the background recluster
  job machinery (ReclusterTask, recluster_tasks, _run_global_recluster,
  _cleanup_stale_recluster_tasks) and its dataclass/datetime/typing
  imports. Kept both, imports merged.

Verified: backend pytest 1007 passed; black + ruff clean on changed files;
frontend tsc, eslint, prettier and jest 357/357 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@VanshajPoonia

Copy link
Copy Markdown
Contributor Author

Before/after for the 10s timeout fix

@rohan-pandeyy here's the before/after you asked for. I made it reproducible rather than
relying on my own photo library, so you can run the same thing.

Method

The bug needs a recluster that runs longer than 10s. Measured on my machine, a full
recluster crosses that at roughly 3000 faces:

faces clusters recluster time
1000 125 0.95s
2000 250 3.40s
4000 500 13.90s
8000 1000 47.96s

Growth is quadratic, which matches the code: cosine_distances builds an N×N matrix,
DBSCAN consumes it precomputed, and _merge_similar_clusters loops over cluster pairs.

So rather than needing a big library, I seeded 4000 synthetic face embeddings grouped
into identities, into an isolated test database. Both runs then use:

  • the project's real face_clusters router and cluster_util_face_clusters_sync, unmodified
  • the project's own axios, configured exactly as axiosConfig.ts does on each branch
    (10000ms on main, 30000ms on the branch)

No product code was stubbed or slowed down. The only instrumentation is wall-clock request
tracing on the test server, because uvicorn's access log has no timestamps and only prints
once a response is sent, which is the very thing in question.

Before (main, 10s axios timeout)

[client 21:50:46 t+0.01s] client configured with timeout=10000ms (before)
[client 21:50:46 t+0.01s] POST /face-clusters/global-recluster
[client 21:50:56 t+10.02s] REQUEST FAILED code=ECONNABORTED message="timeout of 10000ms exceeded"

Server, same run:

[server 21:50:46.028] --> POST /face-clusters/global-recluster
[server 21:51:02.204] <-- POST /face-clusters/global-recluster 200 after 16.18s

The client gave up at 21:50:56. The server finished successfully at 21:51:02, 6
seconds later, and returned a 200 that nobody was listening for. It wrote all 500
clusters to the database. That gap is the bug: the UI reports failure while the backend
completes the work, so the two disagree about what happened.

After (this branch, async job + polling)

[client 21:51:24 t+0.00s] POST /face-clusters/global-recluster
[client 21:51:24 t+0.02s] POST resolved 202 {"success":true,"message":"Global reclustering started.","data":{"task_id":"38358d08-..."}}
[client 21:51:26 t+2.03s] GET .../38358d08-... -> 200 running
[client 21:51:28 t+4.03s] GET .../38358d08-... -> 200 running
[client 21:51:30 t+6.04s] GET .../38358d08-... -> 200 running
[client 21:51:32 t+8.05s] GET .../38358d08-... -> 200 running
[client 21:51:34 t+10.05s] GET .../38358d08-... -> 200 running    <- past the old 10s cap
[client 21:51:36 t+12.06s] GET .../38358d08-... -> 200 running
[client 21:51:38 t+14.06s] GET .../38358d08-... -> 200 running
[client 21:51:40 t+16.07s] GET .../38358d08-... -> 200 complete
[client 21:51:40 t+16.07s] final payload: {"success":true,"message":"Global reclustering completed successfully.","data":{"status":"complete","clusters_created":500,"faces_skipped":0}}

Server side, every request returns immediately; none of them block:

[server 21:51:24.422] --> POST /face-clusters/global-recluster
[server 21:51:24.423] <-- POST /face-clusters/global-recluster 202 after 0.00s
[server 21:51:26.429] --> GET  /face-clusters/global-recluster/38358d08-...
[server 21:51:26.430] <-- GET  /face-clusters/global-recluster/38358d08-... 200 after 0.00s

Same 16 seconds of clustering work. The difference is that no single HTTP request is held
open across it, so no timeout can cut it short, and the UI learns the real outcome:
clusters_created: 500.

The other two behaviours

Concurrency guard, two rapid triggers:

1st: {"success":true,"message":"Global reclustering started.","data":{"task_id":"03a84da6-..."}}
2nd: {"success":true,"message":"Global reclustering already in progress.","data":{"task_id":"03a84da6-..."}}

The second trigger rejoins the in-flight job instead of starting a second full recluster
that would race on the cluster tables.

Unknown or aged-out task id returns 404, which is what the hook surfaces as an error:

GET /face-clusters/global-recluster/does-not-exist -> status=404

In the actual UI

I also drove the real Settings page in a browser and recorded the network activity, since
that's closer to what you asked for. Same frontend code, same backend routes, clicking the
real Recluster Faces button.

Before (main frontend + main backend), network entries:

POST /face-clusters/global-recluster -> FAILED net::ERR_ABORTED (10001ms)
POST /face-clusters/global-recluster -> FAILED net::ERR_ABORTED (10001ms)

After (this branch), network entries:

POST /face-clusters/global-recluster                 -> 202 (1ms)
GET  /face-clusters/global-recluster/c95c7f2b-...    -> 200 (2ms)
GET  /face-clusters/global-recluster/c95c7f2b-...    -> 200 (10ms)
...9 polls total, every 2s, none over 10ms...
GET  /face-clusters/global-recluster/c95c7f2b-...    -> 200 (3ms)

The UI ends on Reclustering Completed — Global reclustering completed successfully.
On main it was still showing Starting global face reclustering... 31 seconds after the
click.

The retry storm this also fixes

This one I did not expect. usePictoMutation retries a failed mutation twice with a 500ms
delay (useQueryExtension.ts:46-53). Because the timeout counts as a failure, one click
on main starts three full reclusters
, each racing the others on the same cluster tables:

[server 22:03:35.578] --> POST /face-clusters/global-recluster
[server 22:03:46.080] --> POST /face-clusters/global-recluster
[server 22:03:54.282] <-- POST /face-clusters/global-recluster 200 after 18.70s
[server 22:03:56.581] --> POST /face-clusters/global-recluster
[server 22:04:14.547] <-- POST /face-clusters/global-recluster 200 after 28.47s
[server 22:04:21.405] <-- POST /face-clusters/global-recluster 200 after 24.82s

Three overlapping runs, each deleting and rebuilding every cluster. On this branch the same
click produces exactly one:

[server 22:07:58.178] --> POST /face-clusters/global-recluster
[server 22:07:58.180] <-- POST /face-clusters/global-recluster 202 after 0.00s

Nothing to retry, because nothing fails, and the concurrency guard would rejoin the
in-flight job even if something did.

Caveats, stated plainly

  • The embeddings are synthetic, so the clusters are not meaningful people. Only the
    runtime and the request/response behaviour are being demonstrated.
  • The seeded faces have no backing image files, so per-cluster thumbnail generation is
    skipped. Real libraries do that work too, which means these timings are a lower bound
    and a real library crosses 10s sooner than 3000 faces.
  • Job state is in-memory and per-worker, which is why this branch also pins the backend to
    a single worker in run.sh and run-server.ps1.
  • The UI capture runs the frontend in Chrome rather than the Tauri webview, so window.isTauri
    and a minimal __TAURI_INTERNALS__ are shimmed to get past the browser gate in main.tsx.
    The test backend serves only the face-clusters routes, so unrelated startup calls
    (tagging status, folders) fail and raise a dialog I dismiss before clicking. Neither
    affects the reclustering request path being measured.
before-1-settings after-1-settings before-2-in-progress after-2-in-progress before-3-at-12s after-3-at-12s before-4-final after-4-final

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ This PR has merge conflicts.

Please resolve the merge conflicts before review.

Your PR will only be reviewed by a maintainer after all conflicts have been resolved.

📺 Watch this video to understand why conflicts occur and how to resolve them:
https://www.youtube.com/watch?v=Sqsz1-o7nXk

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: Frontend aborts long operations after 10s (global axios timeout) - reclustering, memory generation & search fail on large libraries

2 participants