Files
HealthLog/src/lib/insights/with-timeout.ts
T
Marc-André Bombeck b13721cc97 fix(insights): stop a timed-out warm from evicting its own freshly-warmed caches
The on-demand force-warm bounds the comprehensive briefing with a 45 s
timeout and then warms the per-status + generic-metric caches itself. The
timeout could not cancel the underlying generation, so a comprehensive that
resolved after the timeout reached its own per-status eviction and deleted
the rows the warm passes had just written — leaving the user back on a cold
cache.

Thread an AbortSignal into the comprehensive generation and abort it when the
timeout fires; the generation now bails out before its write + evict once the
signal is aborted, so a late resolve can never undo the warm.

Also align the force-warm gating with the route: the route admits the job on
the per-user assessment surface, so warm the per-status + generic-metric
cards whenever that surface is enabled, even when the global briefing switch
is off. Only the comprehensive briefing stays gated on the briefing switch.

Factor the per-status cache-action shape into a single helper so the key is
built in one place rather than hand-assembled across the generators, the
eviction sweep, and the ingest debounce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 75e0b5a9129893915f7a213ec96253df19341d15)
2026-06-02 17:34:52 +02:00

99 lines
3.5 KiB
TypeScript

import { annotate } from "@/lib/logging/context";
/**
* Race an awaited promise against a hard timeout.
*
* The seven `*-status.ts` routes await `provider.generateCompletion()`
* and need an upper bound so a stalled provider does not pin the
* InsightStatusCard behind React-Query's retries. `withTimeout` resolves
* with the wrapped promise's value when it lands inside the budget, or
* the `fallback` value otherwise.
*
* Returns a typed envelope so the call site can distinguish a real
* timeout from an upstream error from a normal value, and react
* differently to each — a timeout/error must NOT be persisted as a
* day-long cache entry the way a real assessment is.
*
* The budget matches the provider clients' own 60 s ceiling. The
* earlier 20 s cap fired below the providers' floor on cold starts and
* model warm-ups, converting healthy-but-slow generations into the
* generic fallback; aligning the two lets a slow-but-valid round-trip
* land instead of being discarded.
*/
export interface TimeoutEnvelope<T> {
/** True when the budget expired before the upstream settled. */
timedOut: boolean;
/** True when the upstream rejected before the budget expired. */
errored: boolean;
value: T;
}
export function withTimeout<T>(
start: () => Promise<T>,
ms: number,
fallback: T,
/**
* v1.9.0 — fired once when the budget expires before the upstream settles.
* `withTimeout` cannot cancel the underlying promise, so a caller that owns
* a cancellation handle (e.g. an `AbortController`) passes it here to cut
* the detached work off — preventing a late-resolving promise from running
* side effects the caller has already moved past.
*/
onTimeout?: () => void,
): Promise<TimeoutEnvelope<T>> {
let timer: ReturnType<typeof setTimeout> | null = null;
let settled = false;
return new Promise<TimeoutEnvelope<T>>((resolve) => {
timer = setTimeout(() => {
if (settled) return;
settled = true;
annotate({
action: { name: "insights.status.provider_timeout" },
meta: { timeout_ms: ms },
});
onTimeout?.();
resolve({ timedOut: true, errored: false, value: fallback });
}, ms);
start()
.then((value) => {
if (settled) return;
settled = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
resolve({ timedOut: false, errored: false, value });
})
.catch((error: unknown) => {
if (settled) return;
settled = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
// Upstream errored before the timeout fired. Surface it as a
// distinct `errored` envelope and annotate the reason so a real
// provider failure is observable in the wide events instead of
// being silently swallowed into the timeout path.
const reason =
error instanceof Error ? error.message : String(error ?? "unknown");
annotate({
action: { name: "insights.status.provider_error" },
meta: { reason: reason.slice(0, 240) },
});
resolve({ timedOut: false, errored: true, value: fallback });
});
});
}
/**
* Status-path provider budget, aligned with the provider clients' own
* 60 s ceiling. A round-trip that exceeds this is treated as a transient
* miss — the caller returns the deterministic fallback for this render
* WITHOUT persisting it as the day's assessment, so the next mount
* re-attempts a real generation.
*/
export const STATUS_PROVIDER_TIMEOUT_MS = 60_000;