diff --git a/src/__tests__/client-poll-inventory-guard.test.ts b/src/__tests__/client-poll-inventory-guard.test.ts new file mode 100644 index 000000000..c3e14afa2 --- /dev/null +++ b/src/__tests__/client-poll-inventory-guard.test.ts @@ -0,0 +1,197 @@ +/** + * Structural guard: every client poll is in the inventory, in one of the + * three shapes `src/lib/queries/refetch-interval.ts` describes. + * + * The inventory used to be a comment, and a comment cannot fail: three polls + * (the Insights shell's briefing, the medication compliance card and two of + * the status hooks' cards) were missing from it. Here it is data, held to + * every `refetchInterval:` in the tree, with the number each file carries, so + * a new poll in a listed file fails as surely as one in a new file. + * + * A bounded poll counts its attempts with `pollAttemptCount`, failed fetches + * included: on `dataUpdateCount` alone a request that kept failing kept the + * last good payload (still `preparing`) and polled for as long as the page + * stayed open. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + requireFloor, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +type Shape = "standing" | "until-done" | "bounded"; + +interface Poll { + /** How many `refetchInterval:` the file holds. */ + sites: number; + shape: Shape; + /** The interval, and why that figure; or what ends the poll. */ + cadence: string; +} + +const INVENTORY: Readonly> = { + // ── Standing: a fixed interval while mounted, paused on a hidden tab ── + "lib/queries/use-dashboard-snapshot.ts": { + sites: 1, + shape: "standing", + cadence: + "120 s, DASHBOARD_REFETCH_INTERVAL_MS; matches the snapshot cache TTL", + }, + "lib/queries/use-analytics-query.ts": { + sites: 1, + shape: "standing", + cadence: "120 s, DASHBOARD_REFETCH_INTERVAL_MS", + }, + "lib/queries/use-daily-digest.ts": { + sites: 1, + shape: "standing", + cadence: "120 s, DASHBOARD_REFETCH_INTERVAL_MS", + }, + "components/settings/notification-status-card.tsx": { + sites: 1, + shape: "standing", + cadence: "30 s; last-delivery results move while the panel is open", + }, + "components/admin/app-log-preview-section.tsx": { + sites: 1, + shape: "standing", + cadence: "30 s; an operator watching a live log", + }, + "components/gamification/achievement-unlock-notifier.tsx": { + sites: 1, + shape: "standing", + cadence: "5 min; out-of-band unlocks, an app-global poll", + }, + "components/admin/host-metrics-chart.tsx": { + sites: 1, + shape: "standing", + cadence: "5 min; the sampler's own cadence", + }, + // ── Until done: stops on a terminal state the server always reaches ── + "components/settings/import-panel/apple-health-import-card.tsx": { + sites: 1, + shape: "until-done", + cadence: "2 s until the job reports a terminal status", + }, + "components/settings/import-panel/health-connect-import-card.tsx": { + sites: 1, + shape: "until-done", + cadence: "2 s until the job reports a terminal status", + }, + "components/admin/backups-section.tsx": { + sites: 1, + shape: "until-done", + cadence: "2 s until no restore is active", + }, + "components/documents/documents-view.tsx": { + sites: 1, + shape: "until-done", + cadence: + "4 s until no document is inside its 3-minute upload window without an index", + }, + "components/insights/coach-panel/coach-conversation.tsx": { + sites: 1, + shape: "until-done", + cadence: + "2.5 s until the attachment is indexed or its upload window passed", + }, + "components/admin/pulse-day-mean-card.tsx": { + sites: 1, + shape: "until-done", + cadence: "10 s until the recompute reports done", + }, + "components/admin/measurement-maintenance-card.tsx": { + sites: 1, + shape: "until-done", + cadence: "10 s until the run reports done", + }, + "components/insights/period-narrative-card.tsx": { + sites: 1, + shape: "until-done", + cadence: + "5 s until the narrative arrives or `revalidating` clears; only mounted while looked at", + }, + // ── Bounded: model text on its way, with an attempt ceiling ── + "hooks/use-insight-status.ts": { + sites: 3, + shape: "bounded", + cadence: + "4 s, nextStatusPollInterval, 12 attempts (metric, biomarker, general cards)", + }, + "app/insights/medications/page.tsx": { + sites: 1, + shape: "bounded", + cadence: "4 s, nextStatusPollInterval, 12 attempts (compliance card)", + }, + "components/insights/use-insights-advisor.ts": { + sites: 1, + shape: "bounded", + cadence: "25 s, nextAdvisorPollInterval, 10 attempts (advisor)", + }, + "components/insights/insights-layout-shell.tsx": { + sites: 1, + shape: "bounded", + cadence: "25 s, nextAdvisorPollInterval, 10 attempts (briefing body)", + }, +}; + +const SITE = /\brefetchInterval\s*:/g; + +const files = walkSourceFiles(SRC, { floor: 3000 }).filter( + (f) => + !f.startsWith("generated/") && + !f.includes("__tests__/") && + !/\.test\.tsx?$/.test(f), +); +const code = new Map( + files.map((f) => [f, stripComments(readFileSync(join(SRC, f), "utf8"))]), +); +const sitesIn = (text: string) => text.match(SITE)?.length ?? 0; + +describe("client polls", () => { + it("lists every poll in the tree, with the number each file holds", () => { + const found = requireFloor( + "refetchInterval files", + files.filter((f) => sitesIn(code.get(f)!) > 0), + 15, + ); + const drift = found + .filter((f) => INVENTORY[f]?.sites !== sitesIn(code.get(f)!)) + .map((f) => `${f}: ${sitesIn(code.get(f)!)}`); + expect(drift).toEqual([]); + const gone = Object.keys(INVENTORY).filter((f) => !found.includes(f)); + expect(gone).toEqual([]); + }); + + it("counts a bounded poll's attempts with the failed fetches", () => { + const bounded = Object.entries(INVENTORY).filter( + ([, p]) => p.shape === "bounded", + ); + const problems = bounded.flatMap(([f, p]) => { + const text = code.get(f) ?? ""; + const counted = + text.match(/pollAttemptCount\(query\.state\)/g)?.length ?? 0; + return [ + ...(counted < p.sites + ? [`${f}: ${counted} of ${p.sites} counted`] + : []), + ...(/\.state\.dataUpdateCount\b/.test(text) + ? [`${f}: reads dataUpdateCount alone`] + : []), + ]; + }); + expect(problems).toEqual([]); + }); + + it("catches an unlisted poll", () => { + expect(sitesIn("useQuery({ refetchInterval: 5_000 })")).toBe(1); + expect(sitesIn("// refetchInterval: 5_000")).toBe(1); + expect(sitesIn(stripComments("// refetchInterval: 5_000"))).toBe(0); + }); +}); diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 1545dc5e5..08fccaf84 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -7,6 +7,7 @@ import { Loader2, Pill } from "lucide-react"; import { useAiCapability } from "@/hooks/use-ai-capability"; import { useAuth } from "@/hooks/use-auth"; import { nextStatusPollInterval } from "@/hooks/use-insight-status"; +import { pollAttemptCount } from "@/lib/queries/refetch-interval"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; import { Badge } from "@/components/ui/badge"; @@ -142,7 +143,7 @@ export default function InsightsMedikamentePage() { refetchInterval: (query) => nextStatusPollInterval( query.state.data?.preparing, - query.state.dataUpdateCount, + pollAttemptCount(query.state), query.state.data?.revalidating, ), }); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 4414792c7..6659c5a45 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -13,6 +13,7 @@ import { nextAdvisorPollInterval, useInsightsAdvisorQuery, } from "@/components/insights/use-insights-advisor"; +import { pollAttemptCount } from "@/lib/queries/refetch-interval"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; import { queryKeys } from "@/lib/query-keys"; @@ -89,7 +90,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { refetchInterval: (query) => nextAdvisorPollInterval( query.state.data?.revalidating, - query.state.dataUpdateCount, + pollAttemptCount(query.state), ), }); diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 34bf5fd10..e19700589 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -2,6 +2,7 @@ import { useCallback, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { pollAttemptCount } from "@/lib/queries/refetch-interval"; import type { InsightResult } from "@/lib/ai/types"; // Type-only — the runtime schemas load lazily inside `fetchAdvisor` so // the zod module graph (`@/lib/ai/schema` is ~550 lines of zod builders) @@ -462,7 +463,7 @@ export function useInsightsAdvisorQuery( refetchInterval: (query) => nextAdvisorPollInterval( query.state.data?.payload?.revalidating, - query.state.dataUpdateCount, + pollAttemptCount(query.state), settling, ), }); @@ -470,14 +471,12 @@ export function useInsightsAdvisorQuery( // `dataUpdateCount` lives on the query STATE (the refetchInterval callback // reads it there); the observer result only exposes `dataUpdatedAt`. Read // it imperatively from the cache where the settle machinery needs it. - const readDataUpdateCount = useCallback( - () => - queryClient - .getQueryCache() - .find({ queryKey: queryKeys.insightsAdvisor() })?.state - .dataUpdateCount ?? 0, - [queryClient], - ); + const readDataUpdateCount = useCallback(() => { + const state = queryClient + .getQueryCache() + .find({ queryKey: queryKeys.insightsAdvisor() })?.state; + return state ? pollAttemptCount(state) : 0; + }, [queryClient]); // Apply the settle resolution the pure seam proves. Each poll result lands // in `query.data` (the poll's own cache write IS the fresh payload — no diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index cfb5be945..40c5b2096 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAiCapability } from "@/hooks/use-ai-capability"; import { useAuth } from "@/hooks/use-auth"; +import { pollAttemptCount } from "@/lib/queries/refetch-interval"; import { useTranslations } from "@/lib/i18n/context"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import type { AssessmentStatus } from "@/lib/insights/status-shared"; @@ -221,9 +222,8 @@ async function fetchStatusData( function useStatusPollAttemptCount(queryKey: readonly unknown[]): number { const queryClient = useQueryClient(); - return ( - queryClient.getQueryState(queryKey)?.dataUpdateCount ?? 0 - ); + const state = queryClient.getQueryState(queryKey); + return state ? pollAttemptCount(state) : 0; } /** @@ -324,7 +324,7 @@ export function useInsightStatus(metric: InsightStatusMetric) { refetchInterval: (query) => nextStatusPollInterval( query.state.data?.preparing, - query.state.dataUpdateCount, + pollAttemptCount(query.state), query.state.data?.revalidating, ), }); @@ -383,7 +383,7 @@ export function useInsightMetricStatus( refetchInterval: (query) => nextStatusPollInterval( query.state.data?.preparing, - query.state.dataUpdateCount, + pollAttemptCount(query.state), query.state.data?.revalidating, ), }); @@ -438,7 +438,7 @@ export function useInsightBiomarkerAssessment( refetchInterval: (query) => nextStatusPollInterval( query.state.data?.preparing, - query.state.dataUpdateCount, + pollAttemptCount(query.state), query.state.data?.revalidating, ), }); diff --git a/src/lib/queries/__tests__/poll-attempt-count.test.ts b/src/lib/queries/__tests__/poll-attempt-count.test.ts new file mode 100644 index 000000000..8a20cf42d --- /dev/null +++ b/src/lib/queries/__tests__/poll-attempt-count.test.ts @@ -0,0 +1,44 @@ +/** + * A bounded poll counts failed fetches as attempts. On `dataUpdateCount` + * alone a request that kept failing left the last `preparing` payload in + * place and polled an open page for as long as it stayed open. + */ +import { describe, expect, it } from "vitest"; + +import { + STATUS_POLL_MAX_ATTEMPTS, + nextStatusPollInterval, +} from "@/hooks/use-insight-status"; +import { nextAdvisorPollInterval } from "@/components/insights/use-insights-advisor"; +import { pollAttemptCount } from "../refetch-interval"; + +describe("pollAttemptCount", () => { + it("adds the failed fetches to the settled ones", () => { + expect(pollAttemptCount({ dataUpdateCount: 2, errorUpdateCount: 3 })).toBe( + 5, + ); + }); + + it("ends a status poll whose fetches keep failing", () => { + // One good `preparing` answer, then nothing but errors. + const failing = { dataUpdateCount: 1, errorUpdateCount: 0 }; + let polls = 0; + while (nextStatusPollInterval(true, pollAttemptCount(failing)) !== false) { + failing.errorUpdateCount += 1; + polls += 1; + expect(polls).toBeLessThanOrEqual(STATUS_POLL_MAX_ATTEMPTS); + } + expect(polls).toBe(STATUS_POLL_MAX_ATTEMPTS - 1); + }); + + it("ends an advisor poll whose fetches keep failing", () => { + const failing = { dataUpdateCount: 1, errorUpdateCount: 0 }; + let polls = 0; + while (nextAdvisorPollInterval(true, pollAttemptCount(failing)) !== false) { + failing.errorUpdateCount += 1; + polls += 1; + expect(polls).toBeLessThan(50); + } + expect(polls).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/queries/refetch-interval.ts b/src/lib/queries/refetch-interval.ts index b2fc5b0de..2965cdfb0 100644 --- a/src/lib/queries/refetch-interval.ts +++ b/src/lib/queries/refetch-interval.ts @@ -14,53 +14,45 @@ */ export const DASHBOARD_REFETCH_INTERVAL_MS = 120_000; +/** + * How many fetches a bounded poll has made: the ones that settled with data + * and the ones that failed. TanStack Query's `dataUpdateCount` alone does not + * move on a failed fetch, and the last good payload stays in place, so a + * poll counted on it kept going for as long as the request kept failing. + */ +export function pollAttemptCount(state: { + dataUpdateCount: number; + errorUpdateCount: number; +}): number { + return state.dataUpdateCount + state.errorUpdateCount; +} + /* * ── Every client poll, in one place ───────────────────────────────────── * * The web client polls in three shapes, and each poll belongs to exactly - * one of them. A new poll picks its shape here, and is added to this list. + * one of them. The inventory of every poll, with its interval and why, is + * `src/__tests__/client-poll-inventory-guard.test.ts`: it is held to every + * `refetchInterval:` in the tree, so a new poll is added there or the suite + * fails. * * 1. STANDING — a fixed interval while the surface is mounted. TanStack * Query pauses it while the tab is hidden (`refetchIntervalInBackground` - * defaults to false), so it costs nothing on a background tab. - * - * | Surface | Interval | Why this figure | - * | ------------------------------------ | -------- | ------------------------------------------------- | - * | dashboard snapshot, analytics, digest | 120 s | this constant; matches the snapshot cache TTL | - * | version poller (`version-poller.tsx`) | 60 s | a deploy notice within a minute; plain timer | - * | notification channel status | 30 s | last-delivery results move while the panel is open | - * | admin app-log preview | 30 s | an operator watching a live log | - * | achievement unlock notifier | 5 min | out-of-band unlocks; an app-global poll | - * | admin host metrics chart | 5 min | the sampler's own cadence | + * defaults to false), so it costs nothing on a background tab. The + * version poller (`version-poller.tsx`, 60 s) is a plain timer of the + * same shape. * * 2. UNTIL-DONE — polls while a server job is visibly running and stops on * the terminal state the response reports. The interval is short because * a person is watching a progress indicator; the stop condition is what * bounds it, so it must be a state the server always reaches. * - * | Surface | Interval | Stops when | - * | ------------------------------------- | -------- | -------------------------------------------- | - * | Apple Health / Health Connect import | 2 s | the job reports a terminal status | - * | admin backup restore list | 2 s | no restore is active | - * | documents list | 4 s | no document is inside its 3-minute upload | - * | | | processing window without an index | - * | Coach staged attachment | 2.5 s | the same: index exists or window passed | - * | admin pulse day-mean / maintenance | 10 s | the recompute reports done | - * * 3. BOUNDED — polls while the server says fresh model text is on its way * (`preparing` / `revalidating`), with an attempt ceiling, because a * generation that keeps failing would otherwise poll an open page - * forever. The ceilings live beside the deciders and are unit-tested. - * - * | Surface | Interval | Ceiling | - * | ----------------------------- | -------- | ------------------------------------------ | - * | status cards (`use-insight-status.ts`, `nextStatusPollInterval`) | 4 s | 12 attempts | - * | advisor / briefing (`nextAdvisorPollInterval`) | 25 s | 10 attempts | - * | period narrative | 5 s | stops once the narrative arrives | - * - * The period narrative has no attempt ceiling: it stops on a state the - * server reaches (the warm writes text or clears `revalidating`), and it - * is only mounted while a person is looking at it. + * forever. The ceilings live beside the deciders + * (`nextStatusPollInterval`, `nextAdvisorPollInterval`) and count + * attempts with {@link pollAttemptCount}, failed fetches included. * * A document that is never indexed is the reason the two document polls are * bounded by the upload window (`RECENT_UPLOAD_WINDOW_MS` in