mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user