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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number | null>(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() {
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
loading={reindexMutation.isPending}
|
||||
// Spin for the WHOLE run: the POST resolves immediately, but the
|
||||
// background job keeps running, so also stay loading while the
|
||||
// server reports `reindexing` (this also blocks a redundant
|
||||
// re-trigger mid-run; the server de-dupes regardless).
|
||||
loading={reindexMutation.isPending || settings?.reindexing === true}
|
||||
onClick={() =>
|
||||
reindexMutation.mutate(undefined, {
|
||||
// Begin bounded polling so the counter climbs as the async
|
||||
|
||||
Reference in New Issue
Block a user