mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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)
99 lines
3.5 KiB
TypeScript
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;
|