fix(insights): count failed fetches toward a bounded poll's ceiling

The status and advisor polls counted attempts on dataUpdateCount, which a
failed fetch does not move, while the last good payload stayed in place and
still said preparing. A request that kept failing polled an open page for
as long as it stayed open. Attempts now include the failed fetches.

The poll inventory moves from a comment into a guard held to every
refetchInterval in the tree; four polls were missing from the comment.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 10:06:11 +02:00
1 parent b12bece491
commit 985372f711
7 files changed
+282 -48

No files matched your search

@@ -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<Record<string, Poll>> = {
// ── 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);
});
});
+2 -1
View File
@@ -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,
),
});
@@ -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),
),
});
@@ -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
+6 -6
View File
@@ -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<InsightStatusData>(queryKey)?.dataUpdateCount ?? 0
);
const state = queryClient.getQueryState<InsightStatusData>(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,
),
});
@@ -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);
});
});
+23 -31
View File
@@ -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