mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(insights): make per-category status cards read-only so navigation never blocks on the LLM
Opening an Insights category page froze the whole UI: the per-metric status card fired a cold, uncapped LLM fetch on mount with no client abort, so the request hung up to the server's 60s ceiling and the pending/resolve re-renders thrashed the tab strip and ate touch events. Timeouts were never negatively cached, so every visit re-blocked. Fix the class holistically: - Bound every status GET on the client. The shared use-insight-status hook and the inline medication-compliance query now wrap fetch in an 8s AbortController and stop retrying inline; navigation can no longer await an uncapped round-trip. - Make the status routes read-only. On a cache miss the generator enqueues an out-of-band pg-boss job (insight-status-generate) and returns a preparing envelope; the client polls a bounded interval until the worker warms the cache. Models the dashboard snapshot's ready/preparing pattern. The queue is registered in the worker allQueues loop + a boss.work handler, with a guard test. - Fix the nightly warm-pass regression: warm every supported locale (the client reads against the active UI locale, not the persisted User.locale) and warm on a cached comprehensive outcome too (its write still evicts the per-status caches). Add a short-TTL negative cache on the timeout path so a transient provider stall no longer makes every subsequent visit re-block or re-enqueue. - Add loading.tsx for the insights segment + the root so any future RSC await streams a skeleton instead of blocking the transition. - Stabilise the advisor regenerate callback so a status-query flip can't re-render the memoised tab strip mid-gesture. New i18n key insights.assessmentPreparing across all six locales.
This commit is contained in:
1 parent
6a603ad587
commit
35800becbb
47 files changed
+1297
-75
No files matched your search
@@ -1382,6 +1382,7 @@
|
||||
"bmiEmptyDescription": "Für den BMI wird deine Körpergröße benötigt. Hinterlege sie unter Einstellungen → Account.",
|
||||
"bmiEmptyAction": "Account-Einstellungen öffnen",
|
||||
"noAnalysisYet": "Noch keine Analyse vorhanden.",
|
||||
"assessmentPreparing": "Einschätzung wird vorbereitet…",
|
||||
"noProviderConfigured": "Auswertung nicht verfügbar.",
|
||||
"startAnalysis": "Analyse starten",
|
||||
"refreshAnalysis": "Analyse aktualisieren",
|
||||
|
||||
@@ -1376,6 +1376,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"noAnalysisYet": "No analysis yet.",
|
||||
"assessmentPreparing": "Preparing your assessment…",
|
||||
"emptyTitle": "Not enough data yet",
|
||||
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
|
||||
"emptyAddMeasurement": "Add a measurement",
|
||||
|
||||
@@ -1376,6 +1376,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"noAnalysisYet": "Aún no hay análisis.",
|
||||
"assessmentPreparing": "Preparando tu evaluación…",
|
||||
"emptyTitle": "Aún no hay datos suficientes",
|
||||
"emptyDescription": "Los insights aparecen en cuanto registres algunas mediciones. Empieza por peso o tensión arterial.",
|
||||
"emptyAddMeasurement": "Registrar medición",
|
||||
|
||||
@@ -1376,6 +1376,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"noAnalysisYet": "Pas encore d’analyse.",
|
||||
"assessmentPreparing": "Préparation de votre évaluation…",
|
||||
"emptyTitle": "Pas encore assez de données",
|
||||
"emptyDescription": "Les insights apparaissent dès que tu auras saisi quelques mesures. Commence par le poids ou la tension.",
|
||||
"emptyAddMeasurement": "Saisir une mesure",
|
||||
|
||||
@@ -1376,6 +1376,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"noAnalysisYet": "Ancora nessuna analisi.",
|
||||
"assessmentPreparing": "Preparazione della valutazione…",
|
||||
"emptyTitle": "Dati ancora insufficienti",
|
||||
"emptyDescription": "Gli insights compaiono appena registri qualche misurazione. Inizia da peso o pressione.",
|
||||
"emptyAddMeasurement": "Registra misurazione",
|
||||
|
||||
@@ -1376,6 +1376,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"noAnalysisYet": "Brak analizy.",
|
||||
"assessmentPreparing": "Przygotowywanie oceny…",
|
||||
"emptyTitle": "Jeszcze za mało danych",
|
||||
"emptyDescription": "Insights pojawią się, gdy zarejestrujesz kilka pomiarów. Zacznij od wagi lub ciśnienia.",
|
||||
"emptyAddMeasurement": "Zarejestruj pomiar",
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveBloodPressureStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateBloodPressureStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.blood-pressure-status" } });
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveBmiStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateBmiStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.bmi-status" } });
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveMedicationComplianceStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateMedicationComplianceStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.medication-compliance-status" } });
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveMoodStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateMoodStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.mood-status" } });
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolvePulseStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generatePulseStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.pulse-status" } });
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveWeightStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateWeightStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.weight-status" } });
|
||||
|
||||
@@ -124,6 +124,7 @@ export default function InsightsBlutdruckPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
|
||||
@@ -133,6 +133,7 @@ export default function InsightsBmiPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* v1.8.3 — route-level loading skeleton for every `/insights/*` segment.
|
||||
*
|
||||
* Defense-in-depth for the navigation-freeze class: any RSC await on an
|
||||
* insights route (now or in a future refactor) streams this skeleton
|
||||
* instead of blocking the transition. The geometry mirrors `SubPageShell`
|
||||
* (heading + chart card + assessment card) so the post-load swap reflows
|
||||
* by less than a row. Pure server component — no client hooks, no data
|
||||
* fetch; Next.js renders it instantly while the segment streams.
|
||||
*/
|
||||
export default function InsightsLoading() {
|
||||
return (
|
||||
<div data-slot="insights-subpage-loading" className="space-y-6">
|
||||
<header className="space-y-2">
|
||||
<div className="bg-muted h-7 w-48 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted/70 h-4 w-72 animate-pulse rounded motion-reduce:animate-none" />
|
||||
</header>
|
||||
|
||||
{/* Chart card */}
|
||||
<div className="bg-card rounded-xl border p-4">
|
||||
<div className="bg-muted mb-4 h-4 w-32 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted/60 h-56 w-full animate-pulse rounded-lg motion-reduce:animate-none" />
|
||||
</div>
|
||||
|
||||
{/* Assessment card */}
|
||||
<div className="bg-card space-y-2 rounded-xl border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted h-5 w-5 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted h-4 w-40 animate-pulse rounded motion-reduce:animate-none" />
|
||||
</div>
|
||||
<div className="bg-muted h-3.5 w-full animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted h-3.5 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted h-3.5 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,8 +58,18 @@ interface MedicationComplianceStatusData {
|
||||
medications: Array<{ medicationId: string; text: string }>;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
// v1.8.3 — read-only route returns preparing:true with summary:null on a
|
||||
// cache miss while the worker warms the assessment; the card polls until
|
||||
// it lands. See `use-insight-status.ts` for the shared rationale.
|
||||
preparing?: boolean;
|
||||
}
|
||||
|
||||
// v1.8.3 — client ceiling + preparing-poll cadence, mirroring the shared
|
||||
// `use-insight-status` hook so the medication-compliance card never awaits
|
||||
// an uncapped LLM round-trip on navigation.
|
||||
const MED_STATUS_TIMEOUT_MS = 8_000;
|
||||
const MED_STATUS_POLL_MS = 4_000;
|
||||
|
||||
interface MedicationDailyData {
|
||||
expected: number;
|
||||
taken: number;
|
||||
@@ -91,15 +101,28 @@ export default function InsightsMedikamentePage() {
|
||||
const { data: status, isLoading: isStatusLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsMedicationComplianceStatus(locale),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/insights/medication-compliance-status?locale=${locale}`,
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(
|
||||
() => controller.abort(),
|
||||
MED_STATUS_TIMEOUT_MS,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as MedicationComplianceStatusData;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/insights/medication-compliance-status?locale=${locale}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as MedicationComplianceStatusData;
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
retry: 0,
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.preparing ? MED_STATUS_POLL_MS : false,
|
||||
});
|
||||
|
||||
const medications = comprehensive?.medications ?? [];
|
||||
@@ -253,6 +276,7 @@ export default function InsightsMedikamentePage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
{/* v1.4.25 W4d — GLP-1 therapy timeline. Self-hides for users
|
||||
|
||||
@@ -115,6 +115,7 @@ export default function InsightsStimmungPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
|
||||
@@ -136,6 +136,7 @@ export default function InsightsPulsPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
{/* v1.4.25 W16a — VO2 max sits on the cardio sub-page because it
|
||||
|
||||
@@ -97,6 +97,7 @@ export default function InsightsGewichtPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* v1.8.3 — root-level loading skeleton (dashboard segment + any top-level
|
||||
* route without its own `loading.tsx`).
|
||||
*
|
||||
* Defense-in-depth for the navigation-freeze class: a segment that streams
|
||||
* paints this tile-grid skeleton instead of a blank frame. Pure server
|
||||
* component — no client hooks, no data fetch. More specific segments (e.g.
|
||||
* `/insights`) provide their own skeleton and take precedence.
|
||||
*/
|
||||
export default function RootLoading() {
|
||||
return (
|
||||
<div data-slot="dashboard-loading" className="space-y-6 p-4">
|
||||
<div className="bg-muted h-7 w-44 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="bg-card rounded-xl border p-4">
|
||||
<div className="bg-muted mb-3 h-4 w-24 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted/60 h-24 w-full animate-pulse rounded-lg motion-reduce:animate-none" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,13 @@ interface InsightStatusCardProps {
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
loading?: boolean;
|
||||
/**
|
||||
* v1.8.3 — the read-only status route enqueued an out-of-band generation
|
||||
* and the assessment isn't warm yet. Render the same skeleton geometry as
|
||||
* `loading` but with a "preparing" caption so the user understands the
|
||||
* card is being assembled, not stuck. The client polls until text lands.
|
||||
*/
|
||||
preparing?: boolean;
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────
|
||||
@@ -30,6 +37,7 @@ export function InsightStatusCard({
|
||||
cached,
|
||||
updatedAt,
|
||||
loading = false,
|
||||
preparing = false,
|
||||
}: InsightStatusCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const flags = useFeatureFlags();
|
||||
@@ -38,6 +46,36 @@ export function InsightStatusCard({
|
||||
// suppressed in full so the layout collapses naturally.
|
||||
if (!flags.insightStatus) return null;
|
||||
|
||||
// v1.8.3 — preparing: the read-only route enqueued a generation and the
|
||||
// client is polling. Render the loading skeleton geometry with a
|
||||
// preparing caption so the card reads as "assembling", not stuck. Only
|
||||
// shown when a provider is configured (the route returns the no-key
|
||||
// fallback otherwise).
|
||||
if (preparing && hasProvider && !text) {
|
||||
return (
|
||||
<Card
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
data-testid="insight-status-card-preparing"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="bg-muted h-3.5 w-full animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted h-3.5 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted h-3.5 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.assessmentPreparing")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
// v1.4.37 — structured skeleton over the centred spinner. When the
|
||||
// per-metric status route had to fall back to the 20 s provider
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
import {
|
||||
@@ -176,12 +177,20 @@ export function useInsightsAdvisorQuery(
|
||||
},
|
||||
});
|
||||
|
||||
// v1.8.3 — stabilise the `regenerate` callback so the memoised
|
||||
// `<InsightsTabStrip>` (which receives it as `onRegenerate`) is not
|
||||
// re-rendered on every shell render by a fresh arrow reference. A
|
||||
// status-query flip on a sub-page that re-renders the shell would
|
||||
// otherwise re-reconcile the whole strip mid-gesture and eat taps.
|
||||
const { mutate } = mutation;
|
||||
const regenerate = useCallback(() => mutate(), [mutate]);
|
||||
|
||||
return {
|
||||
payload: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: (query.error as Error | null) ?? null,
|
||||
regenerate: () => mutation.mutate(),
|
||||
regenerate,
|
||||
isRegenerating: mutation.isPending,
|
||||
regenerateError: (mutation.error as Error | null) ?? null,
|
||||
};
|
||||
|
||||
@@ -23,8 +23,32 @@ export interface InsightStatusData {
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
/**
|
||||
* v1.8.3 — the route is read-only: a cache miss enqueues an out-of-band
|
||||
* generation and returns `preparing: true` with `text: null`. The card
|
||||
* shows a preparing state and the hook polls (bounded) until the worker
|
||||
* warms the cache. Absent / false means the payload is terminal.
|
||||
*/
|
||||
preparing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.3 — client-side ceiling on the status GET. The route is now
|
||||
* read-only (sub-second cache read), so a slow response means the network
|
||||
* itself is degraded, not the LLM. Mirror the advisor hook's 8 s
|
||||
* `ADVISOR_TIMEOUT_MS`: abort and surface the empty / preparing state
|
||||
* rather than letting a hung request pin the navigation. No user
|
||||
* navigation may ever await an uncapped round-trip.
|
||||
*/
|
||||
const STATUS_TIMEOUT_MS = 8_000;
|
||||
|
||||
/**
|
||||
* Poll interval while a card is `preparing`. Bounded by `staleTime` so a
|
||||
* settled (terminal) payload never re-fetches on a timer; only the
|
||||
* preparing state keeps polling, and it stops as soon as text lands.
|
||||
*/
|
||||
const STATUS_POLL_MS = 4_000;
|
||||
|
||||
/**
|
||||
* Metric slugs the sub-pages render. Each slug is paired with the
|
||||
* Insights-status endpoint and the matching `queryKeys.*` factory so
|
||||
@@ -71,17 +95,37 @@ export function useInsightStatus(metric: InsightStatusMetric) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEY_FACTORY[metric](locale),
|
||||
queryFn: async (): Promise<InsightStatusData> => {
|
||||
const res = await fetch(`/api/insights/${metric}-status?locale=${locale}`);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = (await res.json()) as { data: InsightStatusData };
|
||||
return json.data;
|
||||
// v1.8.3 — bound the fetch on the client. AbortController + 8 s
|
||||
// timeout so a degraded network can't pin the navigation thread.
|
||||
// The card surfaces its preparing / empty state on abort exactly as
|
||||
// it does for a `preparing` payload, and the bounded poll retries.
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(
|
||||
() => controller.abort(),
|
||||
STATUS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/insights/${metric}-status?locale=${locale}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = (await res.json()) as { data: InsightStatusData };
|
||||
return json.data;
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
// v1.4.28 FB-D2 — the status route now returns a deterministic
|
||||
// cached-style envelope on provider timeout. Retrying that response
|
||||
// re-fires the same expensive upstream call and the user perceives
|
||||
// a longer hang. Drop retries to surface the fallback immediately.
|
||||
// v1.4.28 FB-D2 — the status route returns a deterministic envelope on
|
||||
// a miss/timeout. Retrying inline re-fires work and lengthens the
|
||||
// perceived hang; the bounded `preparing` poll below covers the warm-up.
|
||||
retry: 0,
|
||||
// v1.8.3 — poll only while the worker is preparing the assessment.
|
||||
// Returns false (no timer) for any terminal payload so a settled card
|
||||
// never re-fetches on an interval.
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.preparing ? STATUS_POLL_MS : false,
|
||||
});
|
||||
}
|
||||
@@ -152,7 +152,19 @@ describe("generateBloodPressureStatusForUser — timeout/error never persists",
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBeNull();
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
|
||||
// but a short-TTL negative stub IS written so the read-only route does
|
||||
// not re-enqueue on every navigation while the provider is degraded.
|
||||
// The stub is a timeout marker that `readFreshStatusText` rejects.
|
||||
await Promise.resolve();
|
||||
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
|
||||
const details = JSON.parse(
|
||||
(call[0] as { data: { details: string } }).data.details,
|
||||
);
|
||||
expect(details.timeout === true || details.model === "timeout-stub").toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -121,7 +121,19 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => {
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBeNull();
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
|
||||
// but a short-TTL negative stub IS written so the read-only route does
|
||||
// not re-enqueue on every navigation while the provider is degraded.
|
||||
// The stub is a timeout marker that `readFreshStatusText` rejects.
|
||||
await Promise.resolve();
|
||||
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
|
||||
const details = JSON.parse(
|
||||
(call[0] as { data: { details: string } }).data.details,
|
||||
);
|
||||
expect(details.timeout === true || details.model === "timeout-stub").toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("serves the fallback without writing a cache row on provider error", async () => {
|
||||
@@ -140,7 +152,19 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => {
|
||||
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.updatedAt).toBeNull();
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
|
||||
// but a short-TTL negative stub IS written so the read-only route does
|
||||
// not re-enqueue on every navigation while the provider is degraded.
|
||||
// The stub is a timeout marker that `readFreshStatusText` rejects.
|
||||
await Promise.resolve();
|
||||
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
|
||||
const details = JSON.parse(
|
||||
(call[0] as { data: { details: string } }).data.details,
|
||||
);
|
||||
expect(details.timeout === true || details.model === "timeout-stub").toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -162,7 +162,19 @@ describe("generateGeneralStatusForUser — timeout/error never persists", () =>
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBeNull();
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
|
||||
// but a short-TTL negative stub IS written so the read-only route does
|
||||
// not re-enqueue on every navigation while the provider is degraded.
|
||||
// The stub is a timeout marker that `readFreshStatusText` rejects.
|
||||
await Promise.resolve();
|
||||
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
|
||||
const details = JSON.parse(
|
||||
(call[0] as { data: { details: string } }).data.details,
|
||||
);
|
||||
expect(details.timeout === true || details.model === "timeout-stub").toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -155,7 +155,19 @@ describe("generateMedicationComplianceStatusForUser — timeout never persists",
|
||||
expect(result.medications).toEqual([]);
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBeNull();
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
|
||||
// but a short-TTL negative stub IS written so the read-only route does
|
||||
// not re-enqueue on every navigation while the provider is degraded.
|
||||
// The stub is a timeout marker that `readFreshStatusText` rejects.
|
||||
await Promise.resolve();
|
||||
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
|
||||
const details = JSON.parse(
|
||||
(call[0] as { data: { details: string } }).data.details,
|
||||
);
|
||||
expect(details.timeout === true || details.model === "timeout-stub").toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -118,7 +118,19 @@ describe("generateMoodStatusForUser — timeout/error never persists", () => {
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBeNull();
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
|
||||
// but a short-TTL negative stub IS written so the read-only route does
|
||||
// not re-enqueue on every navigation while the provider is degraded.
|
||||
// The stub is a timeout marker that `readFreshStatusText` rejects.
|
||||
await Promise.resolve();
|
||||
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
|
||||
const details = JSON.parse(
|
||||
(call[0] as { data: { details: string } }).data.details,
|
||||
);
|
||||
expect(details.timeout === true || details.model === "timeout-stub").toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
* Provider-timeout fallback for the pulse-status route. When the
|
||||
* provider chain does not resolve inside `STATUS_PROVIDER_TIMEOUT_MS`
|
||||
* the route returns the deterministic no-key fallback so the
|
||||
* InsightStatusCard renders instead of spinning — but it must NOT
|
||||
* persist the fallback. The earlier behaviour cached a `timeout-stub`
|
||||
* row keyed to today; that stub stuck until midnight and hid the real
|
||||
* data-driven assessment. The fix treats a timeout as a transient miss:
|
||||
* serve the fallback for this render, write nothing, re-attempt next
|
||||
* mount.
|
||||
* InsightStatusCard renders instead of spinning. It must NOT persist the
|
||||
* fallback AS AN ASSESSMENT (the pre-v1.4.28 stick-until-midnight bug);
|
||||
* `updatedAt` stays null and the served text is never a real assessment.
|
||||
*
|
||||
* v1.8.3 — the timeout path now writes a *short-TTL negative stub*
|
||||
* (`{ timeout:true, model:"timeout-stub", retryAt }`). It is explicitly
|
||||
* rejected by `readFreshStatusText`, so it can never hide the real
|
||||
* assessment; its sole purpose is to stop the read-only route re-enqueuing
|
||||
* generation on every navigation while a provider is degraded.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -64,8 +67,23 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => {
|
||||
expect(result.cached).toBe(true);
|
||||
expect(typeof result.text).toBe("string");
|
||||
expect(result.text?.length ?? 0).toBeGreaterThan(0);
|
||||
// No persisted row — the sticky-stub bug is gone.
|
||||
// No real assessment persisted — `updatedAt` stays null so the card
|
||||
// never mislabels the fallback as a fresh assessment.
|
||||
expect(result.updatedAt).toBeNull();
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
|
||||
// v1.8.3 — a short-TTL negative stub IS persisted (fire-and-forget) so
|
||||
// the read-only route doesn't re-enqueue on every navigation while the
|
||||
// provider is degraded. It is marked as a timeout stub, which
|
||||
// `readFreshStatusText` rejects, so it never hides the real assessment.
|
||||
// The write is fire-and-forget (`void`), so flush the microtask queue.
|
||||
await Promise.resolve();
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const persisted = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
|
||||
data: { details: string };
|
||||
};
|
||||
const stub = JSON.parse(persisted.data.details);
|
||||
expect(stub.timeout).toBe(true);
|
||||
expect(stub.model).toBe("timeout-stub");
|
||||
expect(typeof stub.retryAt).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,22 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const hasUsableStatusProvider = vi.fn();
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
hasUsableStatusProvider: (...a: unknown[]) => hasUsableStatusProvider(...a),
|
||||
}));
|
||||
|
||||
const enqueueStatusGeneration = vi.fn();
|
||||
vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
|
||||
enqueueStatusGeneration: (...a: unknown[]) => enqueueStatusGeneration(...a),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isTimeoutStub, readFreshStatusText } from "../status-cache";
|
||||
import {
|
||||
isTimeoutStub,
|
||||
readFreshStatusText,
|
||||
resolveReadOnlyStatusMiss,
|
||||
} from "../status-cache";
|
||||
|
||||
const TODAY = "2026-05-31";
|
||||
|
||||
@@ -121,3 +135,71 @@ describe("readFreshStatusText", () => {
|
||||
expect(hit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReadOnlyStatusMiss", () => {
|
||||
it("returns no-provider without enqueuing when the user has no provider", async () => {
|
||||
hasUsableStatusProvider.mockResolvedValue(false);
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId: "u1",
|
||||
metric: "weight",
|
||||
locale: "en",
|
||||
});
|
||||
expect(outcome).toBe("no-provider");
|
||||
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enqueues generation and returns preparing on a clean miss", async () => {
|
||||
hasUsableStatusProvider.mockResolvedValue(true);
|
||||
// No negative stub present.
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId: "u1",
|
||||
metric: "pulse",
|
||||
locale: "de",
|
||||
});
|
||||
expect(outcome).toBe("preparing");
|
||||
expect(enqueueStatusGeneration).toHaveBeenCalledWith({
|
||||
userId: "u1",
|
||||
metric: "pulse",
|
||||
locale: "de",
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses re-enqueue while a fresh timeout stub exists", async () => {
|
||||
hasUsableStatusProvider.mockResolvedValue(true);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
|
||||
cacheRow({
|
||||
dateKey: TODAY,
|
||||
timeout: true,
|
||||
model: "timeout-stub",
|
||||
retryAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
}) as never,
|
||||
);
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId: "u1",
|
||||
metric: "mood",
|
||||
locale: "en",
|
||||
});
|
||||
expect(outcome).toBe("preparing");
|
||||
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-enqueues once the negative stub's retryAt has passed", async () => {
|
||||
hasUsableStatusProvider.mockResolvedValue(true);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
|
||||
cacheRow({
|
||||
dateKey: TODAY,
|
||||
timeout: true,
|
||||
model: "timeout-stub",
|
||||
retryAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
}) as never,
|
||||
);
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId: "u1",
|
||||
metric: "bmi",
|
||||
locale: "de",
|
||||
});
|
||||
expect(outcome).toBe("preparing");
|
||||
expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -126,7 +126,19 @@ describe("generateWeightStatusForUser — timeout/error never persists", () => {
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBeNull();
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
|
||||
// but a short-TTL negative stub IS written so the read-only route does
|
||||
// not re-enqueue on every navigation while the provider is degraded.
|
||||
// The stub is a timeout marker that `readFreshStatusText` rejects.
|
||||
await Promise.resolve();
|
||||
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
|
||||
const details = JSON.parse(
|
||||
(call[0] as { data: { details: string } }).data.details,
|
||||
);
|
||||
expect(details.timeout === true || details.model === "timeout-stub").toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,7 +42,10 @@ import {
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { readFreshStatusText } from "@/lib/insights/status-cache";
|
||||
import {
|
||||
readFreshStatusText,
|
||||
resolveReadOnlyStatusMiss,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
@@ -94,15 +97,19 @@ export async function generateBloodPressureStatusForUser(
|
||||
options?: {
|
||||
locale?: string | null;
|
||||
force?: boolean;
|
||||
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
|
||||
readOnly?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
preparing?: boolean;
|
||||
}> {
|
||||
const locale = normalizeLocale(options?.locale);
|
||||
const force = options?.force === true;
|
||||
const readOnly = options?.readOnly === true;
|
||||
const cacheAction = `insights.blood-pressure-status.${locale}`;
|
||||
const todayKey = toBerlinDayKey(new Date());
|
||||
|
||||
@@ -121,6 +128,29 @@ export async function generateBloodPressureStatusForUser(
|
||||
};
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId,
|
||||
metric: "blood-pressure",
|
||||
locale,
|
||||
});
|
||||
if (outcome === "no-provider") {
|
||||
return {
|
||||
hasProvider: false,
|
||||
text: getNoKeyBloodPressureStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: null,
|
||||
cached: false,
|
||||
updatedAt: null,
|
||||
preparing: true,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -523,6 +553,8 @@ export async function generateBloodPressureStatusForUser(
|
||||
return returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason: outcome.kind,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyBloodPressureStatusText(locale),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { readFreshStatusText } from "@/lib/insights/status-cache";
|
||||
import {
|
||||
readFreshStatusText,
|
||||
resolveReadOnlyStatusMiss,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
@@ -32,15 +35,19 @@ export async function generateBmiStatusForUser(
|
||||
options?: {
|
||||
locale?: string | null;
|
||||
force?: boolean;
|
||||
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
|
||||
readOnly?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
preparing?: boolean;
|
||||
}> {
|
||||
const locale = normalizeLocale(options?.locale);
|
||||
const force = options?.force === true;
|
||||
const readOnly = options?.readOnly === true;
|
||||
const cacheAction = `insights.bmi-status.${locale}`;
|
||||
const todayKey = toBerlinDayKey(new Date());
|
||||
|
||||
@@ -59,6 +66,29 @@ export async function generateBmiStatusForUser(
|
||||
};
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId,
|
||||
metric: "bmi",
|
||||
locale,
|
||||
});
|
||||
if (outcome === "no-provider") {
|
||||
return {
|
||||
hasProvider: false,
|
||||
text: getNoKeyBmiStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: null,
|
||||
cached: false,
|
||||
updatedAt: null,
|
||||
preparing: true,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -227,6 +257,8 @@ export async function generateBmiStatusForUser(
|
||||
return returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason: outcome.kind,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyBmiStatusText(locale),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { readFreshStatusText } from "@/lib/insights/status-cache";
|
||||
import {
|
||||
readFreshStatusText,
|
||||
resolveReadOnlyStatusMiss,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
@@ -47,15 +50,19 @@ export async function generateGeneralStatusForUser(
|
||||
options?: {
|
||||
locale?: string | null;
|
||||
force?: boolean;
|
||||
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
|
||||
readOnly?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
preparing?: boolean;
|
||||
}> {
|
||||
const locale = normalizeLocale(options?.locale);
|
||||
const force = options?.force === true;
|
||||
const readOnly = options?.readOnly === true;
|
||||
const cacheAction = `insights.general-status.${locale}`;
|
||||
const todayKey = toBerlinDayKey(new Date());
|
||||
|
||||
@@ -74,6 +81,29 @@ export async function generateGeneralStatusForUser(
|
||||
};
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId,
|
||||
metric: "general",
|
||||
locale,
|
||||
});
|
||||
if (outcome === "no-provider") {
|
||||
return {
|
||||
hasProvider: false,
|
||||
text: getNoKeyGeneralStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: null,
|
||||
cached: false,
|
||||
updatedAt: null,
|
||||
preparing: true,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -348,6 +378,8 @@ export async function generateGeneralStatusForUser(
|
||||
return returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason: outcome.kind,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyGeneralStatusText(locale),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
round,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { isTimeoutStub } from "@/lib/insights/status-cache";
|
||||
import {
|
||||
isTimeoutStub,
|
||||
resolveReadOnlyStatusMiss,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
@@ -46,6 +49,8 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
options?: {
|
||||
locale?: string | null;
|
||||
force?: boolean;
|
||||
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
|
||||
readOnly?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
hasProvider: boolean;
|
||||
@@ -53,9 +58,11 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
medications: MedicationSummaryItem[];
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
preparing?: boolean;
|
||||
}> {
|
||||
const locale = normalizeLocale(options?.locale);
|
||||
const force = options?.force === true;
|
||||
const readOnly = options?.readOnly === true;
|
||||
const cacheAction = `insights.medication-compliance-status.${locale}`;
|
||||
const todayKey = toBerlinDayKey(new Date());
|
||||
|
||||
@@ -106,6 +113,33 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.8.3 — read-only navigation path: never block on the provider.
|
||||
// Enqueue generation out of band and return preparing / no-provider.
|
||||
if (readOnly) {
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId,
|
||||
metric: "medication-compliance",
|
||||
locale,
|
||||
});
|
||||
if (outcome === "no-provider") {
|
||||
return {
|
||||
hasProvider: false,
|
||||
summary: getNoKeyMedicationComplianceStatusText(locale),
|
||||
medications: [],
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
hasProvider: true,
|
||||
summary: null,
|
||||
medications: [],
|
||||
cached: false,
|
||||
updatedAt: null,
|
||||
preparing: true,
|
||||
};
|
||||
}
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
@@ -338,6 +372,8 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason: outcome.kind,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyMedicationComplianceStatusText(locale),
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { readFreshStatusText } from "@/lib/insights/status-cache";
|
||||
import {
|
||||
readFreshStatusText,
|
||||
resolveReadOnlyStatusMiss,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
@@ -70,15 +73,19 @@ export async function generateMoodStatusForUser(
|
||||
options?: {
|
||||
locale?: string | null;
|
||||
force?: boolean;
|
||||
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
|
||||
readOnly?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
preparing?: boolean;
|
||||
}> {
|
||||
const locale = normalizeLocale(options?.locale);
|
||||
const force = options?.force === true;
|
||||
const readOnly = options?.readOnly === true;
|
||||
const cacheAction = `insights.mood-status.${locale}`;
|
||||
const todayKey = toBerlinDayKey(new Date());
|
||||
|
||||
@@ -97,6 +104,29 @@ export async function generateMoodStatusForUser(
|
||||
};
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId,
|
||||
metric: "mood",
|
||||
locale,
|
||||
});
|
||||
if (outcome === "no-provider") {
|
||||
return {
|
||||
hasProvider: false,
|
||||
text: getNoKeyMoodStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: null,
|
||||
cached: false,
|
||||
updatedAt: null,
|
||||
preparing: true,
|
||||
};
|
||||
}
|
||||
|
||||
// v1.4.28 FB-D2 — cap at 90 entries (~3 months) for the mood
|
||||
// surface. The downstream `applyPayloadBudget` slims further.
|
||||
// Order desc + reverse so the per-row consumers downstream (which
|
||||
@@ -382,6 +412,8 @@ export async function generateMoodStatusForUser(
|
||||
return returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason: outcome.kind,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyMoodStatusText(locale),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { readFreshStatusText } from "@/lib/insights/status-cache";
|
||||
import {
|
||||
readFreshStatusText,
|
||||
resolveReadOnlyStatusMiss,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
@@ -38,15 +41,19 @@ export async function generatePulseStatusForUser(
|
||||
options?: {
|
||||
locale?: string | null;
|
||||
force?: boolean;
|
||||
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
|
||||
readOnly?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
preparing?: boolean;
|
||||
}> {
|
||||
const locale = normalizeLocale(options?.locale);
|
||||
const force = options?.force === true;
|
||||
const readOnly = options?.readOnly === true;
|
||||
const cacheAction = `insights.pulse-status.${locale}`;
|
||||
const todayKey = toBerlinDayKey(new Date());
|
||||
|
||||
@@ -65,6 +72,29 @@ export async function generatePulseStatusForUser(
|
||||
};
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId,
|
||||
metric: "pulse",
|
||||
locale,
|
||||
});
|
||||
if (outcome === "no-provider") {
|
||||
return {
|
||||
hasProvider: false,
|
||||
text: getNoKeyPulseStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: null,
|
||||
cached: false,
|
||||
updatedAt: null,
|
||||
preparing: true,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -270,6 +300,8 @@ export async function generatePulseStatusForUser(
|
||||
return returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason: outcome.kind,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyPulseStatusText(locale),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
|
||||
import {
|
||||
enqueueStatusGeneration,
|
||||
type InsightStatusMetric,
|
||||
} from "@/lib/jobs/insight-status-generate-shared";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Shared cache-read for the seven `*-status.ts` insight generators.
|
||||
@@ -31,6 +37,8 @@ interface ParsedStatusCache {
|
||||
model?: string;
|
||||
tokensUsed?: number | null;
|
||||
timeout?: boolean;
|
||||
/** v1.8.3 — ISO timestamp before which a timeout stub suppresses re-enqueue. */
|
||||
retryAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,3 +100,91 @@ export async function readFreshStatusText(args: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of the read-only cache-miss resolution. The generators map this
|
||||
* onto their public return shape:
|
||||
* - `no-provider` → `{ hasProvider: false, text: <no-key fallback> }`
|
||||
* - `preparing` → `{ hasProvider: true, text: null, preparing: true }`
|
||||
*/
|
||||
export type ReadOnlyMissOutcome = "no-provider" | "preparing";
|
||||
|
||||
/**
|
||||
* v1.8.3 — resolve what a read-only status generation should return on a
|
||||
* cache miss WITHOUT running the heavy SQL gather or the blocking LLM
|
||||
* round-trip. This is the core of the holistic freeze fix: a navigation
|
||||
* request must never await an uncapped provider call.
|
||||
*
|
||||
* On a miss the route either:
|
||||
* - finds no usable provider → returns `no-provider` (the card shows the
|
||||
* no-key fallback; nothing to generate), or
|
||||
* - finds a provider → fire-and-forget enqueues a generation job and
|
||||
* returns `preparing` (the card shows a preparing state and the client
|
||||
* polls until the worker warms the cache).
|
||||
*
|
||||
* The provider probe is a cheap chain-resolve (no completion), so the GET
|
||||
* stays sub-second even on a cold cache.
|
||||
*/
|
||||
export async function resolveReadOnlyStatusMiss(args: {
|
||||
userId: string;
|
||||
metric: InsightStatusMetric;
|
||||
locale: "de" | "en";
|
||||
}): Promise<ReadOnlyMissOutcome> {
|
||||
const hasProvider = await hasUsableStatusProvider(args.userId);
|
||||
if (!hasProvider) return "no-provider";
|
||||
|
||||
const cacheAction = `insights.${args.metric}-status.${args.locale}`;
|
||||
|
||||
// v1.8.3 — honour the short-TTL negative cache. If the worker recently
|
||||
// hit a provider stall it wrote a `retryAt` stub; re-enqueuing on every
|
||||
// navigation while the provider is still degraded would be a storm. While
|
||||
// the stub is fresh, stay in `preparing` without enqueuing; once it goes
|
||||
// stale (or never existed) enqueue a fresh generation.
|
||||
if (await hasFreshTimeoutStub({ userId: args.userId, cacheAction })) {
|
||||
annotate({
|
||||
action: { name: "insights.status.preparing" },
|
||||
meta: { metric: args.metric, suppressed_enqueue: true },
|
||||
});
|
||||
return "preparing";
|
||||
}
|
||||
|
||||
// Enqueue out of band — do NOT await an LLM here. The enqueue itself is
|
||||
// best-effort and de-duped per (user, metric, locale).
|
||||
await enqueueStatusGeneration({
|
||||
userId: args.userId,
|
||||
metric: args.metric,
|
||||
locale: args.locale,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "insights.status.preparing" },
|
||||
meta: { metric: args.metric },
|
||||
});
|
||||
return "preparing";
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the most recent cache row for `(userId, cacheAction)` is a
|
||||
* timeout stub whose `retryAt` is still in the future. Used by the
|
||||
* read-only resolver to suppress a re-enqueue storm while a provider is
|
||||
* degraded. A stub without `retryAt` (legacy) is treated as stale so the
|
||||
* resolver retries, matching the pre-v1.8.3 "transient miss" behaviour.
|
||||
*/
|
||||
async function hasFreshTimeoutStub(args: {
|
||||
userId: string;
|
||||
cacheAction: string;
|
||||
}): Promise<boolean> {
|
||||
const latest = await prisma.auditLog.findFirst({
|
||||
where: { userId: args.userId, action: args.cacheAction },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { details: true },
|
||||
});
|
||||
if (!latest?.details) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(latest.details) as ParsedStatusCache;
|
||||
if (!isTimeoutStub(parsed)) return false;
|
||||
if (typeof parsed.retryAt !== "string") return false;
|
||||
return new Date(parsed.retryAt).getTime() > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,22 @@ async function resolveStatusChain(
|
||||
return [{ providerType: "admin-openai", instance: legacy }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap provider-availability probe for the read-only status path.
|
||||
*
|
||||
* v1.8.3 — when a status route runs in read-only mode (serve cache, never
|
||||
* block on the LLM) it still has to tell the difference between "no
|
||||
* provider — show the no-key fallback" and "provider configured but the
|
||||
* assessment isn't warm yet — show preparing + enqueue a generation". This
|
||||
* resolves the same chain `runStatusCompletion` would, but does NOT run a
|
||||
* completion, so the navigation request never awaits an LLM round-trip.
|
||||
*/
|
||||
export async function hasUsableStatusProvider(
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
return (await resolveStatusChain(userId)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a status generation across the user's provider chain, bounded by
|
||||
* `STATUS_PROVIDER_TIMEOUT_MS`. The result discriminates between
|
||||
|
||||
@@ -1,32 +1,79 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Shared timeout / provider-error fallback for the InsightStatus routes.
|
||||
*
|
||||
* When the upstream provider call exceeds `STATUS_PROVIDER_TIMEOUT_MS`
|
||||
* (or rejects outright) the route still has to return a deterministic
|
||||
* envelope so the InsightStatusCard renders the no-key fallback text
|
||||
* instead of spinning. It used to ALSO persist a sentinel `auditLog`
|
||||
* row keyed to today's Berlin day. That stub carried the generic
|
||||
* fallback under the same `text` field a real assessment uses, the
|
||||
* cache-read served it as valid, and it stuck until midnight — hiding
|
||||
* the real, data-driven assessment for the rest of the day on any
|
||||
* account that hit a single transient stall.
|
||||
* (or rejects outright) the generator still has to return a deterministic
|
||||
* envelope. It used to persist a sentinel row carrying the generic
|
||||
* fallback under the same `text` field a real assessment uses; the
|
||||
* cache-read served it as valid and it stuck until midnight — hiding the
|
||||
* real, data-driven assessment for the rest of the day on any account that
|
||||
* hit a single transient stall. v1.4.28 removed that persist entirely.
|
||||
*
|
||||
* The fix removes the persist entirely: a timeout / error is treated as
|
||||
* a transient miss. The current render still gets the fallback text, but
|
||||
* nothing is written, so the next mount re-attempts a real generation.
|
||||
* The shared cache-read (`status-cache.ts`) additionally rejects any
|
||||
* legacy stub rows still in the table by their `model:"timeout-stub"` /
|
||||
* `timeout:true` markers.
|
||||
*
|
||||
* `cached:true` is kept so the UI does not mislabel the fallback as a
|
||||
* fresh assessment, and `updatedAt:null` signals "no persisted row".
|
||||
* v1.8.3 — re-introduce a *negative* cache, but a strictly bounded one. The
|
||||
* status route is now read-only: a cache miss enqueues an out-of-band
|
||||
* generation. If the provider stalls inside the worker, nothing stops the
|
||||
* next navigation from enqueuing again, and again — a stalled provider
|
||||
* turns into a re-enqueue storm. So the timeout path writes a short-TTL
|
||||
* negative stub (`{ timeout:true, model:"timeout-stub", retryAt }`). The
|
||||
* read-only resolver honours it: while the stub is fresh it returns
|
||||
* `preparing` WITHOUT re-enqueuing; once `retryAt` passes the stub is stale
|
||||
* and the next visit re-attempts. The stub is NEVER served as assessment
|
||||
* text — `readFreshStatusText` still rejects every timeout stub by marker,
|
||||
* so a transient stall can't hide the real assessment for the day.
|
||||
*/
|
||||
|
||||
/** Short retry window after a provider stall before a re-enqueue is allowed. */
|
||||
export const TIMEOUT_NEGATIVE_CACHE_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Persist a short-TTL negative-cache stub for `(userId, cacheAction)`.
|
||||
* Best-effort: a failure here just means the read-only resolver may
|
||||
* re-enqueue sooner than the window intended.
|
||||
*/
|
||||
export async function persistTimeoutNegativeStub(args: {
|
||||
userId: string;
|
||||
cacheAction: string;
|
||||
todayKey: string;
|
||||
reason: "timeout" | "error";
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: args.userId,
|
||||
action: args.cacheAction,
|
||||
details: JSON.stringify({
|
||||
dateKey: args.todayKey,
|
||||
timeout: true,
|
||||
model: "timeout-stub",
|
||||
reason: args.reason,
|
||||
retryAt: new Date(
|
||||
Date.now() + TIMEOUT_NEGATIVE_CACHE_MS,
|
||||
).toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Negative cache is best-effort — never throw out of the timeout path.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the deterministic fallback envelope for a timeout / error. The
|
||||
* current render still gets the fallback text, marked `cached:true` so the
|
||||
* UI does not mislabel it as a fresh assessment and `updatedAt:null` to
|
||||
* signal "no persisted assessment". When `userId` + `todayKey` are present
|
||||
* (the worker-side generation path) a short-TTL negative stub is persisted
|
||||
* so a stalled provider doesn't trigger a re-enqueue storm.
|
||||
*/
|
||||
export function returnTimeoutFallback(input: {
|
||||
cacheAction: string;
|
||||
reason: "timeout" | "error";
|
||||
stubText: string;
|
||||
userId?: string;
|
||||
todayKey?: string;
|
||||
}): {
|
||||
hasProvider: true;
|
||||
text: string;
|
||||
@@ -37,6 +84,15 @@ export function returnTimeoutFallback(input: {
|
||||
action: { name: "insights.status.fallback_served" },
|
||||
meta: { cacheAction: input.cacheAction, reason: input.reason },
|
||||
});
|
||||
if (input.userId && input.todayKey) {
|
||||
// Fire-and-forget — the caller's render must not wait on the write.
|
||||
void persistTimeoutNegativeStub({
|
||||
userId: input.userId,
|
||||
cacheAction: input.cacheAction,
|
||||
todayKey: input.todayKey,
|
||||
reason: input.reason,
|
||||
});
|
||||
}
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: input.stubText,
|
||||
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { readFreshStatusText } from "@/lib/insights/status-cache";
|
||||
import {
|
||||
readFreshStatusText,
|
||||
resolveReadOnlyStatusMiss,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
@@ -81,15 +84,25 @@ export async function generateWeightStatusForUser(
|
||||
options?: {
|
||||
locale?: string | null;
|
||||
force?: boolean;
|
||||
/**
|
||||
* v1.8.3 — read-only mode for the navigation path. On a cache miss the
|
||||
* generator does NOT run the SQL gather + blocking LLM inline; it
|
||||
* enqueues an out-of-band generation and returns a `preparing` shape so
|
||||
* the request never awaits the provider. The worker (and the nightly
|
||||
* pre-generate cron) call with `readOnly: false` to actually generate.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
preparing?: boolean;
|
||||
}> {
|
||||
const locale = normalizeLocale(options?.locale);
|
||||
const force = options?.force === true;
|
||||
const readOnly = options?.readOnly === true;
|
||||
const cacheAction = `insights.weight-status.${locale}`;
|
||||
const todayKey = toBerlinDayKey(new Date());
|
||||
|
||||
@@ -111,6 +124,31 @@ export async function generateWeightStatusForUser(
|
||||
};
|
||||
}
|
||||
|
||||
// v1.8.3 — read-only navigation path: never block on the provider.
|
||||
// Enqueue generation out of band and return preparing / no-provider.
|
||||
if (readOnly) {
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId,
|
||||
metric: "weight",
|
||||
locale,
|
||||
});
|
||||
if (outcome === "no-provider") {
|
||||
return {
|
||||
hasProvider: false,
|
||||
text: getNoKeyWeightStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: null,
|
||||
cached: false,
|
||||
updatedAt: null,
|
||||
preparing: true,
|
||||
};
|
||||
}
|
||||
|
||||
// v1.4.28 FB-D2 — cap the snapshot input. BP captures three types
|
||||
// per day so 1095 = 365 d × 3 channels. Order desc + reverse so we
|
||||
// keep the most recent year of rows.
|
||||
@@ -414,6 +452,8 @@ export async function generateWeightStatusForUser(
|
||||
return returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason: outcome.kind,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyWeightStatusText(locale),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ describe("runInsightPregenerate — per-metric warm pass", () => {
|
||||
return vi.fn().mockResolvedValue(result);
|
||||
}
|
||||
|
||||
it("forces all seven status generators after a successful comprehensive generation", async () => {
|
||||
it("forces all seven status generators in BOTH locales after a successful comprehensive generation", async () => {
|
||||
const { prisma } = makePrisma([{ id: "u1", locale: "de" }]);
|
||||
const generate = vi
|
||||
.fn()
|
||||
@@ -223,13 +223,34 @@ describe("runInsightPregenerate — per-metric warm pass", () => {
|
||||
statusGenerators,
|
||||
});
|
||||
|
||||
// Every status generator forced with the resolved locale.
|
||||
// v1.8.3 — each generator is forced once per supported locale (de + en)
|
||||
// so the cache the client reads against (active UI locale, not the
|
||||
// persisted User.locale) is always warm.
|
||||
for (const g of statusGenerators) {
|
||||
expect(g).toHaveBeenCalledTimes(1);
|
||||
expect(g).toHaveBeenCalledTimes(2);
|
||||
expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true });
|
||||
expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: true });
|
||||
}
|
||||
// Seven fresh, provider-backed assessments warmed.
|
||||
expect(result.assessmentsWarmed).toBe(7);
|
||||
// Seven generators × two locales = 14 fresh, provider-backed assessments.
|
||||
expect(result.assessmentsWarmed).toBe(14);
|
||||
});
|
||||
|
||||
it("warms when the comprehensive pass returned `cached` (its write still evicted the per-status caches)", async () => {
|
||||
const { prisma } = makePrisma([{ id: "u1", locale: "de" }]);
|
||||
const generate = vi.fn().mockResolvedValue({ status: "cached" });
|
||||
const statusGenerators = Array.from({ length: 7 }, () =>
|
||||
warmGen({ hasProvider: true, cached: false }),
|
||||
);
|
||||
|
||||
const result = await runInsightPregenerate(prisma as never, {
|
||||
generate,
|
||||
statusGenerators,
|
||||
});
|
||||
|
||||
for (const g of statusGenerators) {
|
||||
expect(g).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
expect(result.assessmentsWarmed).toBe(14);
|
||||
});
|
||||
|
||||
it("does NOT warm when the comprehensive pass skipped (no provider)", async () => {
|
||||
@@ -290,11 +311,13 @@ describe("runInsightPregenerate — per-metric warm pass", () => {
|
||||
statusGenerators,
|
||||
});
|
||||
|
||||
// The throw must not abort the loop — every generator was attempted.
|
||||
// The throw must not abort the loop — every generator was attempted
|
||||
// once per supported locale (de + en).
|
||||
for (const g of statusGenerators) {
|
||||
expect(g).toHaveBeenCalledTimes(1);
|
||||
expect(g).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
expect(result.assessmentsWarmed).toBe(2);
|
||||
// Two counting generators × two locales = 4 fresh assessments.
|
||||
expect(result.assessmentsWarmed).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* v1.8.3 — unit tests for the on-demand per-metric status generation queue.
|
||||
*
|
||||
* Covers:
|
||||
* - the dispatch runs the matching generator with force:true;
|
||||
* - an unknown metric is annotated and skipped, not thrown;
|
||||
* - the enqueue helper no-ops without a global boss instance and de-dupes
|
||||
* with a singletonKey when one is present;
|
||||
* - the queue is registered in `allQueues` AND wired to a `boss.work`
|
||||
* handler in reminder-worker.ts (the v1.4.37 W10 unregistered-queue
|
||||
* catch — a queue built but never registered silently never drains).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const getGlobalBoss = vi.fn();
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: (...a: unknown[]) => getGlobalBoss(...a),
|
||||
}));
|
||||
// The dispatch module imports the seven generators transitively; stub them
|
||||
// so the test never reaches a live provider.
|
||||
vi.mock("@/lib/insights/general-status", () => ({
|
||||
generateGeneralStatusForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/blood-pressure-status", () => ({
|
||||
generateBloodPressureStatusForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/weight-status", () => ({
|
||||
generateWeightStatusForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/pulse-status", () => ({
|
||||
generatePulseStatusForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/bmi-status", () => ({
|
||||
generateBmiStatusForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/mood-status", () => ({
|
||||
generateMoodStatusForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/medication-compliance-status", () => ({
|
||||
generateMedicationComplianceStatusForUser: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
runInsightStatusGenerate,
|
||||
enqueueStatusGeneration,
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
INSIGHT_STATUS_METRICS,
|
||||
} from "../insight-status-generate";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("runInsightStatusGenerate", () => {
|
||||
it("forces the matching generator with the payload locale", async () => {
|
||||
const weight = vi.fn().mockResolvedValue(undefined);
|
||||
const generators = {
|
||||
general: vi.fn(),
|
||||
"blood-pressure": vi.fn(),
|
||||
weight,
|
||||
pulse: vi.fn(),
|
||||
bmi: vi.fn(),
|
||||
mood: vi.fn(),
|
||||
"medication-compliance": vi.fn(),
|
||||
};
|
||||
await runInsightStatusGenerate(
|
||||
{ userId: "u1", metric: "weight", locale: "en" },
|
||||
generators,
|
||||
);
|
||||
expect(weight).toHaveBeenCalledWith("u1", { locale: "en", force: true });
|
||||
// No other generator ran.
|
||||
expect(generators.pulse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips (does not throw) an unknown metric", async () => {
|
||||
await expect(
|
||||
runInsightStatusGenerate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ userId: "u1", metric: "nope" as any, locale: "de" },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{} as any,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes the seven canonical metrics", () => {
|
||||
expect([...INSIGHT_STATUS_METRICS].sort()).toEqual(
|
||||
[
|
||||
"general",
|
||||
"blood-pressure",
|
||||
"weight",
|
||||
"pulse",
|
||||
"bmi",
|
||||
"mood",
|
||||
"medication-compliance",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueStatusGeneration", () => {
|
||||
it("no-ops when no global boss is available", async () => {
|
||||
getGlobalBoss.mockReturnValue(null);
|
||||
await expect(
|
||||
enqueueStatusGeneration({ userId: "u1", metric: "weight", locale: "de" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends with a per-(user,metric,locale) singletonKey when boss exists", async () => {
|
||||
const send = vi.fn().mockResolvedValue("job-id");
|
||||
getGlobalBoss.mockReturnValue({ send });
|
||||
await enqueueStatusGeneration({
|
||||
userId: "u1",
|
||||
metric: "mood",
|
||||
locale: "en",
|
||||
});
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
{ userId: "u1", metric: "mood", locale: "en" },
|
||||
expect.objectContaining({ singletonKey: "u1:mood:en" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("swallows a boss.send rejection (best-effort enqueue)", async () => {
|
||||
const send = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
getGlobalBoss.mockReturnValue({ send });
|
||||
await expect(
|
||||
enqueueStatusGeneration({ userId: "u1", metric: "bmi", locale: "de" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("queue registration", () => {
|
||||
const workerSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, "../reminder-worker.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("registers the queue name in the allQueues createQueue loop", () => {
|
||||
const match = workerSrc.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![1]).toMatch(/\bINSIGHT_STATUS_GENERATE_QUEUE\b/);
|
||||
});
|
||||
|
||||
it("registers a boss.work handler for the queue", () => {
|
||||
expect(workerSrc).toMatch(
|
||||
/boss\.work[\s\S]{0,120}INSIGHT_STATUS_GENERATE_QUEUE/,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes a stable queue name", () => {
|
||||
expect(INSIGHT_STATUS_GENERATE_QUEUE).toBe("insight-status-generate");
|
||||
});
|
||||
});
|
||||
@@ -156,12 +156,20 @@ const DEFAULT_STATUS_GENERATORS: ReadonlyArray<StatusGenerator> = [
|
||||
*/
|
||||
async function warmPerStatusCaches(
|
||||
userId: string,
|
||||
locale: "de" | "en",
|
||||
locales: ReadonlyArray<"de" | "en">,
|
||||
generators: ReadonlyArray<StatusGenerator>,
|
||||
): Promise<number> {
|
||||
const limit = pLimit(WARM_PASS_CONCURRENCY);
|
||||
const results = await Promise.all(
|
||||
generators.map((generate) =>
|
||||
// v1.8.3 — warm every locale the user might request, not just the
|
||||
// User.locale. The per-status cache key is
|
||||
// `insights.<metric>-status.<locale>`, but the client sends the active
|
||||
// UI-cookie / Accept-Language locale (narrowed to de|en), which can
|
||||
// differ from the persisted User.locale (e.g. null → "de" in the cron
|
||||
// while the browser cookie is "en"). Warming only the User.locale left
|
||||
// the locale the client actually reads cold, so the first visit still
|
||||
// ran a live generation per card — the exact freeze this release closes.
|
||||
const tasks = generators.flatMap((generate) =>
|
||||
locales.map((locale) =>
|
||||
limit(async () => {
|
||||
try {
|
||||
const outcome = await generate(userId, { locale, force: true });
|
||||
@@ -174,6 +182,7 @@ async function warmPerStatusCaches(
|
||||
}),
|
||||
),
|
||||
);
|
||||
const results = await Promise.all(tasks);
|
||||
return results.reduce((sum: number, n) => sum + n, 0);
|
||||
}
|
||||
|
||||
@@ -301,18 +310,23 @@ export async function runInsightPregenerate(
|
||||
}
|
||||
|
||||
// Warm the per-metric assessment caches the comprehensive generator
|
||||
// evicts (`evictPerStatusInsightCache`) but does not re-fill. Only
|
||||
// do this when the comprehensive pass actually produced an insight:
|
||||
// a `skipped` (no-provider) user has nothing to warm, and a `failed`
|
||||
// pass means the provider chain is unhealthy this run — re-running
|
||||
// it seven more times would only multiply the failure. A `cached`
|
||||
// outcome can't happen here (we always force), but if a future
|
||||
// change reintroduces it the per-metric caches were not evicted, so
|
||||
// there is nothing to warm.
|
||||
if (outcome.status === "generated") {
|
||||
// evicts (`evictPerStatusInsightCache`) but does not re-fill.
|
||||
//
|
||||
// v1.8.3 — warm on `generated` OR `cached`. A `cached` outcome means
|
||||
// the comprehensive insight is fresh-enough that its write already
|
||||
// evicted the per-status caches, so they are cold and need re-filling
|
||||
// exactly as on a fresh `generated`. The pre-v1.8.3 `=== "generated"`
|
||||
// gate left those caches cold whenever the comprehensive pass short-
|
||||
// circuited to `cached`, so the morning visit still ran a live
|
||||
// generation per card. `skipped` (no provider) has nothing to warm and
|
||||
// `failed` means the chain is unhealthy this run — re-running it seven
|
||||
// more times would only multiply the failure.
|
||||
if (outcome.status === "generated" || outcome.status === "cached") {
|
||||
result.assessmentsWarmed += await warmPerStatusCaches(
|
||||
candidate.id,
|
||||
locale,
|
||||
// Warm both supported locales — the client reads against the active
|
||||
// UI locale, which can differ from the persisted User.locale.
|
||||
["de", "en"],
|
||||
statusGenerators,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* v1.8.3 — shared contract for the on-demand per-metric status generation
|
||||
* queue, free of any generator import.
|
||||
*
|
||||
* The status generators import `enqueueStatusGeneration` from here (via
|
||||
* `status-cache.ts`). Keeping the enqueue + the queue name + the metric
|
||||
* vocabulary in a generator-free module breaks the import cycle that would
|
||||
* otherwise form: generator → status-cache → (enqueue) → generator. The
|
||||
* worker-only dispatch (`runInsightStatusGenerate`, which references the
|
||||
* concrete generators) lives in `insight-status-generate.ts` and is
|
||||
* imported solely by the worker boot file.
|
||||
*/
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const INSIGHT_STATUS_GENERATE_QUEUE = "insight-status-generate";
|
||||
|
||||
/**
|
||||
* Low concurrency keeps the on-demand warm pass from saturating the
|
||||
* Prisma pool or fanning out an unbounded number of concurrent provider
|
||||
* calls when several cards request generation on the same first visit.
|
||||
*/
|
||||
export const INSIGHT_STATUS_GENERATE_CONCURRENCY = 2;
|
||||
|
||||
/**
|
||||
* The seven per-metric status scopes the queue can generate. Mirrors the
|
||||
* `cacheAction` slug each generator uses (`insights.<metric>-status.<locale>`)
|
||||
* so the queue payload, the route, and the client all speak one vocabulary.
|
||||
*/
|
||||
export type InsightStatusMetric =
|
||||
| "general"
|
||||
| "blood-pressure"
|
||||
| "weight"
|
||||
| "pulse"
|
||||
| "bmi"
|
||||
| "mood"
|
||||
| "medication-compliance";
|
||||
|
||||
export const INSIGHT_STATUS_METRICS: ReadonlyArray<InsightStatusMetric> = [
|
||||
"general",
|
||||
"blood-pressure",
|
||||
"weight",
|
||||
"pulse",
|
||||
"bmi",
|
||||
"mood",
|
||||
"medication-compliance",
|
||||
];
|
||||
|
||||
export interface InsightStatusGeneratePayload {
|
||||
userId: string;
|
||||
metric: InsightStatusMetric;
|
||||
/** Resolved client locale — the cache key the route reads against. */
|
||||
locale: "de" | "en";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget enqueue used by the read-only status route on a cache
|
||||
* miss. A `singletonKey` per `(user, metric, locale)` collapses repeated
|
||||
* polls into one queued job, so a client polling every few seconds while
|
||||
* the provider works can't pile up duplicate generations. No-ops cleanly
|
||||
* when the global boss instance is not available (e.g. the web process
|
||||
* runs without an embedded worker) — the nightly pre-generate cron and
|
||||
* the daily status crons remain the catch-net.
|
||||
*/
|
||||
export async function enqueueStatusGeneration(
|
||||
payload: InsightStatusGeneratePayload,
|
||||
): Promise<void> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
annotate({
|
||||
action: { name: "insights.status.generate.no_boss" },
|
||||
meta: { metric: payload.metric },
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await boss.send(INSIGHT_STATUS_GENERATE_QUEUE, payload, {
|
||||
singletonKey: `${payload.userId}:${payload.metric}:${payload.locale}`,
|
||||
// De-dupe within a short window so a polling client doesn't enqueue
|
||||
// a fresh job on every tick while a generation is already running.
|
||||
singletonSeconds: 120,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "insights.status.generate.enqueued" },
|
||||
meta: { metric: payload.metric },
|
||||
});
|
||||
} catch {
|
||||
// Enqueue is best-effort; a failure here just means the card stays
|
||||
// in `preparing` until the next poll / nightly cron warms the cache.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* v1.8.3 — on-demand per-metric status generation queue (worker dispatch).
|
||||
*
|
||||
* Before this, every `/insights/<metric>` sub-page fetched its status
|
||||
* card from `GET /api/insights/<metric>-status`, and that GET ran the
|
||||
* full SQL gather + a blocking LLM round-trip inline. The provider call
|
||||
* was capped server-side at 60 s, so a cold cache on a slow provider
|
||||
* pinned the navigation request — and, because the timeout fallback is
|
||||
* deliberately not persisted, every subsequent visit re-blocked.
|
||||
*
|
||||
* This queue moves the heavy gather+LLM off the request path. The route
|
||||
* now reads the cache only; on a miss it fire-and-forget enqueues a job
|
||||
* (via `enqueueStatusGeneration` in the generator-free shared module) and
|
||||
* returns a `preparing` envelope. The worker runs the matching generator
|
||||
* with `force: true`, which persists the assessment cache row; the client
|
||||
* polls the read-only GET until it lands.
|
||||
*
|
||||
* Recurring/event pg-boss task — never runs inside an HTTP request and
|
||||
* never shells out to `tsx` (CLAUDE.md DO-NOTs). Registered in the
|
||||
* reminder-worker `allQueues` loop + a `boss.work` handler; a queue built
|
||||
* but never registered silently never drains (the v1.4.37 W10 incident),
|
||||
* so a guard test pins the registration.
|
||||
*
|
||||
* The queue name, payload type, metric vocabulary, and the enqueue helper
|
||||
* live in `insight-status-generate-shared.ts` so the status generators can
|
||||
* import the enqueue without pulling the concrete generators back in (an
|
||||
* import cycle). This module re-exports them for the worker's convenience.
|
||||
*/
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { generateGeneralStatusForUser } from "@/lib/insights/general-status";
|
||||
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
|
||||
import { generateWeightStatusForUser } from "@/lib/insights/weight-status";
|
||||
import { generatePulseStatusForUser } from "@/lib/insights/pulse-status";
|
||||
import { generateBmiStatusForUser } from "@/lib/insights/bmi-status";
|
||||
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
|
||||
import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status";
|
||||
import {
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
INSIGHT_STATUS_GENERATE_CONCURRENCY,
|
||||
INSIGHT_STATUS_METRICS,
|
||||
enqueueStatusGeneration,
|
||||
type InsightStatusMetric,
|
||||
type InsightStatusGeneratePayload,
|
||||
} from "@/lib/jobs/insight-status-generate-shared";
|
||||
|
||||
export {
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
INSIGHT_STATUS_GENERATE_CONCURRENCY,
|
||||
INSIGHT_STATUS_METRICS,
|
||||
enqueueStatusGeneration,
|
||||
};
|
||||
export type { InsightStatusMetric, InsightStatusGeneratePayload };
|
||||
|
||||
/**
|
||||
* Narrow generator shape: the queue only forces a fresh generation and
|
||||
* discards the payload (the generator persists the cache row itself).
|
||||
*/
|
||||
type StatusGenerator = (
|
||||
userId: string,
|
||||
options: { locale: "de" | "en"; force: boolean },
|
||||
) => Promise<unknown>;
|
||||
|
||||
const GENERATORS: Record<InsightStatusMetric, StatusGenerator> = {
|
||||
general: generateGeneralStatusForUser,
|
||||
"blood-pressure": generateBloodPressureStatusForUser,
|
||||
weight: generateWeightStatusForUser,
|
||||
pulse: generatePulseStatusForUser,
|
||||
bmi: generateBmiStatusForUser,
|
||||
mood: generateMoodStatusForUser,
|
||||
"medication-compliance": generateMedicationComplianceStatusForUser,
|
||||
};
|
||||
|
||||
/**
|
||||
* Run one on-demand status generation. Pure of pg-boss so the unit test
|
||||
* can drive it directly. Forces a fresh generation so the cache row the
|
||||
* route's read-only path missed gets written; an unknown metric is
|
||||
* annotated and skipped rather than thrown.
|
||||
*/
|
||||
export async function runInsightStatusGenerate(
|
||||
payload: InsightStatusGeneratePayload,
|
||||
generators: Record<InsightStatusMetric, StatusGenerator> = GENERATORS,
|
||||
): Promise<void> {
|
||||
const generate = generators[payload.metric];
|
||||
if (!generate) {
|
||||
annotate({
|
||||
action: { name: "insights.status.generate.unknown_metric" },
|
||||
meta: { metric: payload.metric },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await generate(payload.userId, { locale: payload.locale, force: true });
|
||||
}
|
||||
@@ -67,6 +67,12 @@ import {
|
||||
runInsightPregenerate,
|
||||
type InsightPregeneratePayload,
|
||||
} from "@/lib/jobs/insight-pregenerate";
|
||||
import {
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
INSIGHT_STATUS_GENERATE_CONCURRENCY,
|
||||
runInsightStatusGenerate,
|
||||
type InsightStatusGeneratePayload,
|
||||
} from "@/lib/jobs/insight-status-generate";
|
||||
import {
|
||||
INTAKE_AUTO_SKIP_QUEUE,
|
||||
INTAKE_AUTO_SKIP_CRON,
|
||||
@@ -1700,6 +1706,32 @@ async function handleInsightPregenerateJob(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.3 — on-demand per-metric status generation. The read-only status
|
||||
* route enqueues one job per cold card; this handler runs the matching
|
||||
* generator with `force: true` so the assessment cache row lands and the
|
||||
* polling client picks it up. Each job carries `{ userId, metric, locale }`.
|
||||
*/
|
||||
async function handleInsightStatusGenerate(
|
||||
jobs: Job<InsightStatusGeneratePayload>[],
|
||||
) {
|
||||
await withBackgroundEvent("job.insight_status_generate", async (evt) => {
|
||||
for (const job of jobs) {
|
||||
if (!job.data?.userId || !job.data?.metric) continue;
|
||||
try {
|
||||
await runInsightStatusGenerate(job.data);
|
||||
evt.addMeta("status_generated", `${job.data.metric}:${job.data.locale}`);
|
||||
} catch (err) {
|
||||
evt.addWarning(
|
||||
`insight-status-generate failed for ${job.data.metric}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.46 — hourly auto-skip for stale unmarked medication intakes.
|
||||
*
|
||||
@@ -2055,6 +2087,12 @@ export async function startReminderWorker() {
|
||||
// this entry the 04:30 schedule silently no-ops and every briefing
|
||||
// falls back to the lazy on-demand generation it was meant to retire.
|
||||
INSIGHT_PREGENERATE_QUEUE,
|
||||
// v1.8.3 — on-demand per-metric status generation. The read-only
|
||||
// status route enqueues here on a cold card; without this entry
|
||||
// pg-boss never provisions the queue and the enqueue silently drops,
|
||||
// leaving every status card stuck on "preparing". No cron schedule —
|
||||
// it is a send-only queue driven by navigation.
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
// v1.7.0 — soft-deleted measurement tombstone prune. Same createQueue
|
||||
// contract as the other cleanup jobs; without this entry the daily
|
||||
// schedule silently no-ops and pruned-past-retention tombstones pile
|
||||
@@ -2306,6 +2344,15 @@ export async function startReminderWorker() {
|
||||
{ localConcurrency: 1 },
|
||||
handleInsightPregenerateJob,
|
||||
);
|
||||
// v1.8.3 — on-demand per-metric status generation enqueued by the
|
||||
// read-only status route on a cold card. Low concurrency so a first
|
||||
// visit that cold-misses several cards can't saturate the Prisma pool
|
||||
// or fan out an unbounded number of concurrent provider calls.
|
||||
await boss.work<InsightStatusGeneratePayload>(
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
{ localConcurrency: INSIGHT_STATUS_GENERATE_CONCURRENCY },
|
||||
handleInsightStatusGenerate,
|
||||
);
|
||||
// v1.4.46 — hourly auto-skip for stale unmarked intakes.
|
||||
// Single-flight: two ticks racing against the same row pile is wasted
|
||||
// work, and the underlying `updateMany` is the canonical idempotent
|
||||
|
||||
Reference in new issue
Block a user