From 21cc2e95a5aa25e57cea57bdd167ca000d6cdfb6 Mon Sep 17 00:00:00 2001 From: a Date: Sun, 28 Jun 2026 01:51:20 +0300 Subject: [PATCH] feat(ai): tighten reindex-progress polling on the reindexing flag Make the "Indexed N of N" counter update near-realtime during a reindex by tracking the server's active-run state instead of a pure time window: - Set REINDEX_POLL_INTERVAL to 5000ms (kept bounded by the cap). - Extract two pure, exported, unit-tested helpers: - nextReindexPollInterval: keep polling while the server reports an ACTIVE run (reindexing===true) OR within the deadline and not yet done; stop once the run is finished AND fully indexed (reindexing===false && indexed>=total) or the deadline cap is hit (the cap always wins, so a stuck/never-clearing progress record can't poll forever). - isReindexComplete: deadline-clear predicate mirroring that stop condition. - Wire the refetchInterval and the deadline-clearing effect to those helpers. - Keep the Reindex button spinner active for the whole run (loading also while settings.reindexing), reusing the existing loading prop; also blocks a redundant mid-run re-trigger (server de-dupes regardless). No SSE/websockets: polling keyed on the reindexing flag is the intended scope. The counter now tracks the actual active-reindex state and stops promptly when the server reports the run is done. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ai-provider-settings.spec.tsx | 105 ++++++++++++++++++ .../components/ai-provider-settings.tsx | 89 ++++++++++++--- 2 files changed, 176 insertions(+), 18 deletions(-) diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx index 3b7c9335..147c426d 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx @@ -3,6 +3,8 @@ import { resolveCardStatus, isEndpointConfigured, resolveKeyField, + nextReindexPollInterval, + isReindexComplete, } from './ai-provider-settings'; describe('resolveCardStatus', () => { @@ -71,3 +73,106 @@ describe('resolveKeyField (write-only key payload)', () => { expect(resolveKeyField('', false)).toEqual({ set: false }); }); }); + +describe('nextReindexPollInterval', () => { + const INTERVAL = 5000; + const base = { now: 1_000, intervalMs: INTERVAL }; + + it('does not poll when no reindex deadline is set', () => { + expect( + nextReindexPollInterval({ + ...base, + deadline: null, + status: { reindexing: true, indexedPages: 0, totalPages: 478 }, + }), + ).toBe(false); + }); + + it('keeps polling while the server reports an active run', () => { + expect( + nextReindexPollInterval({ + ...base, + deadline: 10_000, + status: { reindexing: true, indexedPages: 120, totalPages: 478 }, + }), + ).toBe(INTERVAL); + }); + + it('keeps polling during an active run even if counts momentarily look full', () => { + // The run clears its progress record only at the very end, so a transient + // indexed==total while reindexing is still true must NOT stop polling. + expect( + nextReindexPollInterval({ + ...base, + deadline: 10_000, + status: { reindexing: true, indexedPages: 478, totalPages: 478 }, + }), + ).toBe(INTERVAL); + }); + + it('stops once the run is finished AND fully indexed', () => { + expect( + nextReindexPollInterval({ + ...base, + deadline: 10_000, + status: { reindexing: false, indexedPages: 478, totalPages: 478 }, + }), + ).toBe(false); + }); + + it('keeps polling within the deadline when not yet done and no active flag', () => { + // First poll right after enqueue, before the worker publishes progress. + expect( + nextReindexPollInterval({ + ...base, + deadline: 10_000, + status: { reindexing: false, indexedPages: 0, totalPages: 478 }, + }), + ).toBe(INTERVAL); + }); + + it('cap always wins: stops once past the deadline even if still reindexing', () => { + expect( + nextReindexPollInterval({ + deadline: 1_000, + now: 2_000, // past the deadline + intervalMs: INTERVAL, + status: { reindexing: true, indexedPages: 200, totalPages: 478 }, + }), + ).toBe(false); + }); + + it('stops on an empty workspace (0 of 0) once the run is finished', () => { + expect( + nextReindexPollInterval({ + ...base, + deadline: 10_000, + status: { reindexing: false, indexedPages: 0, totalPages: 0 }, + }), + ).toBe(false); + }); +}); + +describe('isReindexComplete', () => { + it('false when no status yet', () => { + expect(isReindexComplete(undefined)).toBe(false); + }); + + it('false while a run is still active (even at indexed==total)', () => { + expect( + isReindexComplete({ reindexing: true, indexedPages: 478, totalPages: 478 }), + ).toBe(false); + }); + + it('false when finished but not yet fully indexed', () => { + expect( + isReindexComplete({ reindexing: false, indexedPages: 120, totalPages: 478 }), + ).toBe(false); + }); + + it('true once finished and fully indexed', () => { + expect( + isReindexComplete({ reindexing: false, indexedPages: 478, totalPages: 478 }), + ).toBe(true); + }); +}); diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx index 811c2610..a06d1e0f 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx @@ -37,6 +37,7 @@ import { } from "@/features/workspace/queries/ai-settings-query.ts"; import { AiTestCapability, + IAiSettings, IAiSettingsUpdate, SttApiStyle, ChatApiStyle, @@ -169,6 +170,51 @@ export function resolveKeyField( return { set: false }; } +// Subset of the status payload that drives the reindex poll decisions. +type ReindexStatus = Pick< + IAiSettings, + "reindexing" | "indexedPages" | "totalPages" +>; + +/** + * Decide the TanStack Query `refetchInterval` while a reindex may be running. + * Returns the poll interval (ms) to keep polling, or `false` to stop. + * + * Polls while the server reports an ACTIVE run (`reindexing === true`) OR we are + * still within the deadline window and not yet fully indexed. Stops once the run + * has finished AND everything is indexed (server cleared its progress record and + * fell back to the DB coverage count), or the deadline cap is hit — the cap + * always wins so a stuck/never-clearing progress record can't poll forever. + */ +export function nextReindexPollInterval(args: { + deadline: number | null; + now: number; + intervalMs: number; + status?: ReindexStatus; +}): number | false { + const { deadline, now, intervalMs, status } = args; + if (deadline === null) return false; + // Cap always wins. + if (now > deadline) return false; + // Active run → keep polling even if the momentary counts already look full. + if (status?.reindexing) return intervalMs; + // Finished and fully indexed (incl. an empty workspace, 0 >= 0) → stop. + if (status && status.indexedPages >= status.totalPages) return false; + // Within the deadline and not yet done → keep polling. + return intervalMs; +} + +/** + * Whether the reindex poll deadline should be cleared: the server reports no + * active run AND the count is complete. Mirrors the stop condition of + * `nextReindexPollInterval` (sans the cap, which the effect handles via time). + */ +export function isReindexComplete(status?: ReindexStatus): boolean { + return ( + !!status && !status.reindexing && status.indexedPages >= status.totalPages + ); +} + // Translate the dot's tooltip label. Kept in one place so all three endpoint // cards share identical wording. function cardStatusLabel(status: CardStatus, t: (k: string) => string): string { @@ -215,31 +261,34 @@ export default function AiProviderSettings() { // PRE-job counts immediately, so the only way the "Indexed X of Y" counter // visibly climbs is to keep polling the settings query while the job runs. // `reindexDeadline` is the timestamp until which we poll (set on reindex - // success); polling stops early once indexed === total. Bounded so a stuck - // job can never poll forever. - const REINDEX_POLL_INTERVAL = 3000; // ms between refetches while indexing + // success). Polling tracks the server's `reindexing` flag: it keeps going for + // the whole active run and stops promptly once the server reports the run is + // finished. Bounded by the cap so a stuck/never-clearing progress record can + // never poll forever. + const REINDEX_POLL_INTERVAL = 5000; // ms between refetches while indexing const REINDEX_POLL_CAP_MS = 120000; // ~2 min hard cap const [reindexDeadline, setReindexDeadline] = useState(null); // Only admins may read the (masked) AI settings; the server enforces this too. - const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) => { - if (reindexDeadline === null) return false; - // Past the cap → stop polling (cleared via the effect below too). - if (Date.now() > reindexDeadline) return false; - const data = query.state.data; - // Stop once everything is indexed; otherwise keep polling. - if (data && data.indexedPages >= data.totalPages) return false; - return REINDEX_POLL_INTERVAL; - }); + const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) => + nextReindexPollInterval({ + deadline: reindexDeadline, + now: Date.now(), + intervalMs: REINDEX_POLL_INTERVAL, + status: query.state.data, + }), + ); - // Stop polling once the work is done or the cap is reached. Also clears on + // Stop polling once the run is finished or the cap is reached. Also clears on // unmount because the deadline state goes away with the component. useEffect(() => { if (reindexDeadline === null) return; - // "Done" matches the refetchInterval stop condition (indexed >= total), - // including an empty workspace (0 >= 0), so the deadline clears promptly - // instead of waiting out the cap. - if (settings && settings.indexedPages >= settings.totalPages) { + // "Done" matches the refetchInterval stop condition: the server reports no + // active run AND the count is complete (indexed >= total, incl. an empty + // workspace 0 >= 0), so the deadline clears promptly instead of waiting out + // the cap. While `reindexing` is still true we keep the deadline so polling + // continues for the whole run. + if (isReindexComplete(settings)) { setReindexDeadline(null); return; } @@ -1031,7 +1080,11 @@ export default function AiProviderSettings() {