Skip to content

Commit 680372f

Browse files
committed
test(face-clusters): add useGlobalRecluster tests; warn on multi-worker 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).
1 parent 6aa1fd8 commit 680372f

2 files changed

Lines changed: 182 additions & 0 deletions

File tree

backend/main.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
# Configure Uvicorn logging to use our custom formatter
4747
configure_uvicorn_logging("backend")
4848

49+
logger = get_logger("backend")
50+
4951
path = os.path.dirname(DATABASE_PATH)
5052
os.makedirs(path, exist_ok=True)
5153

@@ -64,6 +66,22 @@ async def lifespan(app: FastAPI):
6466
db_create_albums_table()
6567
db_create_album_images_table()
6668
db_create_metadata_table()
69+
# Model-download and global-reclustering job tracking is in-memory and
70+
# per-worker. With more than one worker, a job started in one worker is
71+
# invisible to the others, so status polls can miss it and duplicate jobs
72+
# can start. Warn loudly so deployments keep a single worker (WORKERS=1).
73+
try:
74+
worker_count = int(os.environ.get("WORKERS", "1"))
75+
except ValueError:
76+
worker_count = 1
77+
if worker_count > 1:
78+
logger.warning(
79+
"WORKERS=%s: model-download and global-reclustering job tracking is "
80+
"in-memory and per-worker. Run with a single worker (WORKERS=1) to "
81+
"avoid duplicate jobs and missed status polls.",
82+
worker_count,
83+
)
84+
6785
# Create ProcessPoolExecutor and attach it to app.state
6886
app.state.executor = ProcessPoolExecutor(max_workers=1)
6987

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import React from 'react';
2+
import { renderHook, act } from '@testing-library/react';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4+
import { useGlobalRecluster } from '@/hooks/useGlobalRecluster';
5+
import {
6+
startGlobalReclustering,
7+
getGlobalReclusterStatus,
8+
} from '@/api/api-functions/face_clusters';
9+
10+
jest.mock('@/api/api-functions/face_clusters', () => ({
11+
startGlobalReclustering: jest.fn(),
12+
getGlobalReclusterStatus: jest.fn(),
13+
}));
14+
15+
const mockStart = startGlobalReclustering as jest.MockedFunction<
16+
typeof startGlobalReclustering
17+
>;
18+
const mockStatus = getGlobalReclusterStatus as jest.MockedFunction<
19+
typeof getGlobalReclusterStatus
20+
>;
21+
22+
const POLL_INTERVAL_MS = 2000;
23+
24+
const startOk = { success: true, message: 'started', data: { task_id: 'abc' } };
25+
const running = {
26+
success: true,
27+
data: {
28+
status: 'running' as const,
29+
clusters_created: null,
30+
faces_skipped: null,
31+
},
32+
};
33+
const complete = {
34+
success: true,
35+
message: 'done',
36+
data: { status: 'complete' as const, clusters_created: 3, faces_skipped: 1 },
37+
};
38+
const errored = {
39+
success: false,
40+
message: 'reclustering failed',
41+
data: {
42+
status: 'error' as const,
43+
clusters_created: null,
44+
faces_skipped: null,
45+
},
46+
};
47+
48+
function setup() {
49+
const queryClient = new QueryClient({
50+
defaultOptions: { queries: { retry: false } },
51+
});
52+
const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
53+
const wrapper = ({ children }: { children: React.ReactNode }) => (
54+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
55+
);
56+
const utils = renderHook(() => useGlobalRecluster(), { wrapper });
57+
return { ...utils, invalidateSpy };
58+
}
59+
60+
// Flush pending promises and advance fake timers together.
61+
const flush = async (ms = 0) => {
62+
await act(async () => {
63+
await Promise.resolve();
64+
await jest.advanceTimersByTimeAsync(ms);
65+
});
66+
};
67+
68+
beforeEach(() => {
69+
jest.useFakeTimers();
70+
mockStart.mockReset();
71+
mockStatus.mockReset();
72+
});
73+
74+
afterEach(() => {
75+
jest.useRealTimers();
76+
});
77+
78+
describe('useGlobalRecluster', () => {
79+
test('polls until the job completes successfully', async () => {
80+
mockStart.mockResolvedValue(startOk);
81+
mockStatus.mockResolvedValueOnce(running).mockResolvedValueOnce(complete);
82+
83+
const { result, invalidateSpy } = setup();
84+
85+
act(() => {
86+
result.current.trigger();
87+
});
88+
expect(result.current.isPending).toBe(true);
89+
90+
await flush(); // start resolves, first poll -> running
91+
expect(mockStatus).toHaveBeenCalledTimes(1);
92+
expect(result.current.isPending).toBe(true);
93+
94+
await flush(POLL_INTERVAL_MS); // scheduled poll -> complete
95+
expect(result.current.isSuccess).toBe(true);
96+
expect(result.current.successData).toEqual(complete.data);
97+
expect(result.current.successMessage).toBe('done');
98+
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['clusters'] });
99+
});
100+
101+
test('sets an error when the job reports failure', async () => {
102+
mockStart.mockResolvedValue(startOk);
103+
mockStatus.mockResolvedValue(errored);
104+
105+
const { result, invalidateSpy } = setup();
106+
act(() => {
107+
result.current.trigger();
108+
});
109+
await flush();
110+
111+
expect(result.current.isError).toBe(true);
112+
expect(result.current.errorMessage).toBe('reclustering failed');
113+
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['clusters'] });
114+
});
115+
116+
test('sets an error when starting the job fails', async () => {
117+
mockStart.mockRejectedValue(new Error('network down'));
118+
119+
const { result } = setup();
120+
act(() => {
121+
result.current.trigger();
122+
});
123+
await flush();
124+
125+
expect(result.current.isError).toBe(true);
126+
expect(mockStatus).not.toHaveBeenCalled();
127+
});
128+
129+
test('stops polling after unmount', async () => {
130+
mockStart.mockResolvedValue(startOk);
131+
mockStatus.mockResolvedValue(running);
132+
133+
const { result, unmount } = setup();
134+
act(() => {
135+
result.current.trigger();
136+
});
137+
await flush(); // first poll
138+
expect(mockStatus).toHaveBeenCalledTimes(1);
139+
140+
unmount();
141+
await flush(POLL_INTERVAL_MS * 3);
142+
// No further polls once unmounted.
143+
expect(mockStatus).toHaveBeenCalledTimes(1);
144+
});
145+
146+
test('a rapid second trigger does not leave a second poll loop running', async () => {
147+
mockStart.mockResolvedValue(startOk);
148+
mockStatus.mockResolvedValue(running);
149+
150+
const { result } = setup();
151+
// Both triggers fire before startGlobalReclustering resolves.
152+
act(() => {
153+
result.current.trigger();
154+
result.current.trigger();
155+
});
156+
157+
await flush(); // both starts resolve; only the latest run may poll
158+
expect(mockStatus).toHaveBeenCalledTimes(1);
159+
160+
const callsBefore = mockStatus.mock.calls.length;
161+
await flush(POLL_INTERVAL_MS); // exactly one loop advances
162+
expect(mockStatus.mock.calls.length).toBe(callsBefore + 1);
163+
});
164+
});

0 commit comments

Comments
 (0)