Merge v1.25.3: v1253-briefing-timeout

This commit is contained in:
Marc-André Bombeck committed 2026-06-29 15:30:33 +02:00
commit d3057e4a92
18 files changed
+426 -41

No files matched your search

@@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({
import {
readBriefingFailure,
recordBriefingFailure,
classifyBriefingFailure,
BRIEFING_FAILURE_ACTION,
} from "../briefing-failure-marker";
@@ -45,6 +46,34 @@ describe("readBriefingFailure", () => {
);
});
it("derives a failure class from the recorded reason + httpStatus (v1.25.3)", async () => {
findFirst.mockResolvedValue({
createdAt: new Date("2026-06-28T10:00:00Z"),
details: JSON.stringify({ reason: "provider-error", httpStatus: 401 }),
});
const result = await readBriefingFailure({
userId: "u1",
since: new Date("2026-06-28T09:00:00Z"),
});
expect(result?.failureClass).toBe("auth");
});
it("classes a status-less failure as a timeout (the slow-backend case)", async () => {
findFirst.mockResolvedValue({
createdAt: new Date("2026-06-28T10:00:00Z"),
details: JSON.stringify({ reason: "provider-error" }),
});
const result = await readBriefingFailure({
userId: "u1",
since: new Date("2026-06-28T09:00:00Z"),
});
expect(result?.failureClass).toBe("timeout");
});
it("treats a marker older than the last success as superseded", async () => {
findFirst.mockResolvedValue({
createdAt: new Date("2026-06-28T08:00:00Z"),
@@ -89,4 +118,45 @@ describe("recordBriefingFailure", () => {
recordBriefingFailure({ userId: "u1", reason: "timeout" }),
).resolves.toBeUndefined();
});
it("persists the upstream httpStatus when one is supplied (v1.25.3)", async () => {
create.mockResolvedValue({});
await recordBriefingFailure({
userId: "u1",
reason: "provider-error",
httpStatus: 429,
});
const details = create.mock.calls[0][0].data.details as string;
expect(JSON.parse(details).httpStatus).toBe(429);
});
});
describe("classifyBriefingFailure", () => {
it("maps invalid-json to format regardless of status", () => {
expect(classifyBriefingFailure({ reason: "invalid-json" })).toBe("format");
});
it("maps 429 to rate-limit, other 4xx to auth, 5xx to provider", () => {
expect(
classifyBriefingFailure({ reason: "provider-error", httpStatus: 429 }),
).toBe("rate-limit");
expect(
classifyBriefingFailure({ reason: "provider-error", httpStatus: 403 }),
).toBe("auth");
expect(
classifyBriefingFailure({ reason: "provider-error", httpStatus: 503 }),
).toBe("provider");
});
it("maps a missing / sentinel-zero status to timeout", () => {
expect(classifyBriefingFailure({ reason: "provider-error" })).toBe(
"timeout",
);
expect(
classifyBriefingFailure({
reason: "all-providers-failed",
httpStatus: 0,
}),
).toBe("timeout");
});
});
+60 -2
View File
@@ -19,6 +19,46 @@ import { prisma } from "@/lib/db";
*/
export const BRIEFING_FAILURE_ACTION = "insights.briefing-failure";
/**
* Coarse class of a briefing-generation failure, used by the read path to
* point the empty-state hint at the right lever:
* - `timeout` — the generation ran out of time (no upstream HTTP status,
* or a transport/abort error). Lever: raise the AI response
* timeout for a slow local model.
* - `auth` — the provider rejected the request (4xx that is not 429).
* Lever: re-check the provider / API key in Settings.
* - `rate-limit` — the provider returned 429.
* - `provider` — a 5xx / generic upstream failure.
* - `format` — the model returned unparseable output (invalid JSON).
* - `unknown` — anything that does not classify.
*/
export type BriefingFailureClass =
"timeout" | "auth" | "rate-limit" | "provider" | "format" | "unknown";
/**
* Classify a failure from its recorded reason and (optional) upstream HTTP
* status. A reason of `invalid-json` is always a format miss; a 401/403 (or any
* other non-429 4xx) is an auth / configuration problem; 429 is a rate limit; a
* 5xx is a generic provider failure. With no status at all — the timeout /
* transport / abort path, which is the dominant briefing failure on a slow
* self-hosted backend — the most useful lever is the response-timeout, so it
* classifies as `timeout`.
*/
export function classifyBriefingFailure(args: {
reason: string;
httpStatus?: number | null;
}): BriefingFailureClass {
if (args.reason === "invalid-json") return "format";
const status = args.httpStatus;
if (typeof status === "number" && status > 0) {
if (status === 429) return "rate-limit";
if (status >= 400 && status < 500) return "auth";
if (status >= 500) return "provider";
}
// No / sentinel-zero upstream status: a transport-level timeout or abort.
return "timeout";
}
/** UTC YYYY-MM-DD calendar-day key for the marker payload. */
function dateKey(at: Date = new Date()): string {
return at.toISOString().slice(0, 10);
@@ -32,6 +72,12 @@ export async function recordBriefingFailure(args: {
userId: string;
reason: string;
locale?: string;
/**
* v1.25.3 — upstream HTTP status when the failure carried one, so the read
* path can tell an auth / rate-limit / provider failure apart from a plain
* timeout and point the hint at the right lever.
*/
httpStatus?: number | null;
}): Promise<void> {
try {
await prisma.auditLog.create({
@@ -41,6 +87,7 @@ export async function recordBriefingFailure(args: {
details: JSON.stringify({
dateKey: dateKey(),
reason: args.reason,
httpStatus: args.httpStatus ?? null,
locale: args.locale ?? null,
triedAt: new Date().toISOString(),
}),
@@ -54,6 +101,8 @@ export async function recordBriefingFailure(args: {
export interface BriefingFailureState {
triedAt: string;
reason: string;
/** v1.25.3 — coarse class for the empty-state hint (see classifyBriefingFailure). */
failureClass: BriefingFailureClass;
}
/**
@@ -77,13 +126,22 @@ export async function readBriefingFailure(args: {
if (args.since && latest.createdAt <= args.since) return null;
let reason = "unknown";
let httpStatus: number | null = null;
if (latest.details) {
try {
const parsed = JSON.parse(latest.details) as { reason?: unknown };
const parsed = JSON.parse(latest.details) as {
reason?: unknown;
httpStatus?: unknown;
};
if (typeof parsed.reason === "string") reason = parsed.reason;
if (typeof parsed.httpStatus === "number") httpStatus = parsed.httpStatus;
} catch {
// Malformed marker payload — still report the failure, generic reason.
}
}
return { triedAt: latest.createdAt.toISOString(), reason };
return {
triedAt: latest.createdAt.toISOString(),
reason,
failureClass: classifyBriefingFailure({ reason, httpStatus }),
};
}
+6 -1
View File
@@ -1049,7 +1049,12 @@ export async function generateComprehensiveInsight(
// discreet "couldn't refresh" hint alongside the preserved last-good
// briefing. No cache row is written here, so `insightsCachedText` (the
// last good payload) stays intact and the surface never blanks.
void recordBriefingFailure({ userId, reason, locale });
void recordBriefingFailure({
userId,
reason,
locale,
httpStatus: typeof err.httpStatus === "number" ? err.httpStatus : null,
});
return { status: "failed", reason };
}
@@ -82,13 +82,21 @@ import {
runInsightPregenerate,
forceWarmUser,
findPregenerateCandidates,
comprehensiveWarmBudgetMs,
PREGENERATE_STALE_MS,
FORCE_WARM_DAILY_LIMIT,
INSIGHT_PREGENERATE_QUEUE,
INSIGHT_PREGENERATE_CRON,
} from "../insight-pregenerate";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
function makePrisma(users: Array<{ id: string; locale: string | null }>) {
function makePrisma(
users: Array<{
id: string;
locale: string | null;
aiResponseTimeoutSeconds?: number | null;
}>,
) {
const findMany = vi.fn().mockResolvedValue(users);
// forceWarmUser reads `insightsCachedAt` / `insightsWarmFailedAt` at job
// start and maintains the failure marker; default: never warmed, never
@@ -296,8 +304,9 @@ describe("runInsightPregenerate — outcome tally", () => {
observedSignal = opts.signal;
setTimeout(
() => resolve({ status: "generated", providerType: "x" }),
// v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the
// bounded budget fires before this "slow" generation settles.
// v1.25.3 — above the default warm budget (~150 s = the 120 s
// comprehensive provider budget + headroom) so the bounded budget
// fires before this "slow" generation settles.
200_000,
);
}),
@@ -314,7 +323,7 @@ describe("runInsightPregenerate — outcome tally", () => {
statusGenerators,
warmGenericMetrics,
});
await vi.advanceTimersByTimeAsync(140_000);
await vi.advanceTimersByTimeAsync(160_000);
const result = await promise;
// The timeout fired, aborted the still-running generation (so its
@@ -789,8 +798,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
// advance past it so the warm continues.
setTimeout(
() => resolve({ status: "generated", providerType: "x" }),
// v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the
// bounded budget fires before this "slow" generation settles.
// v1.25.3 — above the default warm budget (~150 s = the 120 s
// comprehensive provider budget + headroom) so the bounded budget
// fires before this "slow" generation settles.
200_000,
);
}),
@@ -808,7 +818,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
warmGenericMetrics,
});
// Advance past the comprehensive budget so withTimeout fires.
await vi.advanceTimersByTimeAsync(140_000);
await vi.advanceTimersByTimeAsync(160_000);
const result = await promise;
for (const g of statusGenerators) {
@@ -906,8 +916,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
observedSignal = opts.signal;
setTimeout(
() => resolve({ status: "generated", providerType: "x" }),
// v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the
// bounded budget fires before this "slow" generation settles.
// v1.25.3 — above the default warm budget (~150 s = the 120 s
// comprehensive provider budget + headroom) so the bounded budget
// fires before this "slow" generation settles.
200_000,
);
}),
@@ -924,7 +935,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
statusGenerators,
warmGenericMetrics,
});
await vi.advanceTimersByTimeAsync(140_000);
await vi.advanceTimersByTimeAsync(160_000);
const result = await promise;
// The timeout fired and aborted the still-running generation.
@@ -975,3 +986,77 @@ describe("queue registration", () => {
expect(INSIGHT_PREGENERATE_CRON).toMatch(/^\d+ \d+ \* \* \*$/);
});
});
describe("comprehensiveWarmBudgetMs — warm budget scales with the response timeout (v1.25.3)", () => {
const HEADROOM_MS = 30_000;
const DEFAULT_MS = AI_BUDGETS.comprehensive.timeoutMs;
it("falls back to the comprehensive surface budget + headroom for an unset value", () => {
expect(comprehensiveWarmBudgetMs(null)).toBe(DEFAULT_MS + HEADROOM_MS);
expect(comprehensiveWarmBudgetMs(undefined)).toBe(DEFAULT_MS + HEADROOM_MS);
// A non-positive stored value is treated as unset.
expect(comprehensiveWarmBudgetMs(0)).toBe(DEFAULT_MS + HEADROOM_MS);
});
it("scales the budget with a raised setting and stays above the old fixed 130 s cap", () => {
// The regression: any account whose response timeout was >= ~130 s had its
// comprehensive warm clipped by the old flat 130 s cap, never landing.
const budget = comprehensiveWarmBudgetMs(300);
expect(budget).toBe(300_000 + HEADROOM_MS);
expect(budget).toBeGreaterThan(130_000);
});
it("clamps to an absolute ceiling that still sits above the 600 s write-time maximum", () => {
// The 600 s max a user can configure is honoured (not clipped below it);
// the ceiling adds the headroom so a legitimate 600 s setting fits.
expect(comprehensiveWarmBudgetMs(600)).toBe(600_000 + HEADROOM_MS);
// A value beyond the write-time bound can never reach here, but the clamp
// is the structural backstop against a wedged call regardless.
expect(comprehensiveWarmBudgetMs(100_000)).toBe(600_000 + HEADROOM_MS);
});
});
describe("runInsightPregenerate — warm budget honours the response timeout (v1.25.3)", () => {
it("does NOT clip a slow comprehensive for an account whose response timeout is raised past the old cap", async () => {
const { prisma } = makePrisma([
{ id: "u1", locale: "de", aiResponseTimeoutSeconds: 300 },
]);
let observedSignal: AbortSignal | undefined;
const generate = vi.fn().mockImplementation(
(_userId: string, opts: { signal?: AbortSignal }) =>
new Promise((resolve) => {
observedSignal = opts.signal;
// 200 s: above the OLD fixed 130 s cap (which would have aborted it)
// but well within the 330 s budget a 300 s setting now yields.
setTimeout(
() => resolve({ status: "generated", providerType: "x" }),
200_000,
);
}),
);
const statusGenerators = Array.from({ length: 7 }, () =>
vi.fn().mockResolvedValue({ hasProvider: true, cached: false }),
);
const warmGenericMetrics = vi.fn().mockResolvedValue(0);
vi.useFakeTimers();
try {
const promise = runInsightPregenerate(prisma as never, {
generate,
statusGenerators,
warmGenericMetrics,
});
// Advance past the slow generation but below the 330 s warm budget.
await vi.advanceTimersByTimeAsync(210_000);
const result = await promise;
// The generation landed instead of being clipped: counted as generated,
// not failed, and the abort never fired.
expect(result.generated).toBe(1);
expect(result.failed).toBe(0);
expect(observedSignal?.aborted).toBe(false);
} finally {
vi.useRealTimers();
}
});
});
+92 -7
View File
@@ -67,6 +67,8 @@ import {
type MetricStatusMetricId,
} from "@/lib/insights/metric-status-registry";
import { withTimeout } from "@/lib/insights/with-timeout";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
import {
INSIGHT_PREGENERATE_QUEUE,
enqueueForceWarm,
@@ -164,15 +166,72 @@ const WARM_PASS_CONCURRENCY = 3;
* the provider's own abort governs a genuine completion while this stays the
* backstop against a wedged call. The per-user budget gate + the WARM_PASS
* concurrency cap still bound total nightly cost.
*
* v1.25.3 — the cap is no longer a fixed constant. The per-user
* `User.aiResponseTimeoutSeconds` setting (bounded 10–600 s at write-time) is
* the operator's lever for slow self-hosted / local backends, and the AI
* settings UI tells users to RAISE it for a model that loads slowly. The old
* fixed 130 s cap sat below the upper end of that range, so any account whose
* response timeout was ≥ ~130 s had every comprehensive warm clipped by the
* warm bound — not the provider — before the generation could land, leaving the
* briefing on its hard empty state. The warm budget now derives from the
* resolved effective per-request timeout plus a fixed headroom for feature
* extraction / prompt assembly / IO, so raising the response timeout actually
* extends the warm/cron budget. A sane absolute ceiling (the 600 s write-time
* maximum plus the same headroom) keeps a single wedged call from pinning the
* worker forever, while staying ABOVE the largest value a user can configure.
*/
const COMPREHENSIVE_WARM_TIMEOUT_MS = 130_000;
/**
* Fixed slack added on top of the resolved upstream provider timeout when
* sizing the warm/cron outer abort: bounded-feature extraction, prompt
* assembly, and the cache write all run inside the warm wrapper but outside the
* single provider round-trip the response-timeout governs.
*/
const COMPREHENSIVE_WARM_HEADROOM_MS = 30_000;
/**
* Largest value `User.aiResponseTimeoutSeconds` accepts (see the 10–600 s
* write-time bound in `POST /api/user/ai-provider`), in ms. The warm ceiling is
* derived from it so the ceiling is always ABOVE the maximum a user can set.
*/
const AI_RESPONSE_TIMEOUT_MAX_MS = 600_000;
/**
* Absolute backstop on the warm outer abort. A misconfigured 600 s response
* timeout still cannot pin the worker beyond this; the ceiling sits above the
* write-time maximum (by exactly the headroom) so a legitimate 600 s setting is
* honoured rather than silently clipped.
*/
const COMPREHENSIVE_WARM_CEILING_MS =
AI_RESPONSE_TIMEOUT_MAX_MS + COMPREHENSIVE_WARM_HEADROOM_MS;
/**
* Outer-abort budget (ms) for one comprehensive warm, derived from the user's
* resolved effective upstream timeout. `effectiveTimeoutMs + headroom`, clamped
* to the absolute ceiling. A null/unset setting falls back to the comprehensive
* surface budget (`AI_BUDGETS.comprehensive.timeoutMs`), so a default account
* gets `120 s + headroom` rather than the old flat 130 s.
*/
export function comprehensiveWarmBudgetMs(
aiResponseTimeoutSeconds: number | null | undefined,
): number {
const effectiveTimeoutMs = resolveEffectiveTimeoutMs(
aiResponseTimeoutSeconds,
AI_BUDGETS.comprehensive.timeoutMs,
);
return Math.min(
effectiveTimeoutMs + COMPREHENSIVE_WARM_HEADROOM_MS,
COMPREHENSIVE_WARM_CEILING_MS,
);
}
/** Per-user result of one forced full warm. */
export interface ForceWarmResult {
/**
* Outcome of the comprehensive-insight generation. `"timeout"` is
* distinct from `"failed"`: the comprehensive step exceeded its own
* bounded budget (see `COMPREHENSIVE_WARM_TIMEOUT_MS`) and was
* bounded budget (see `comprehensiveWarmBudgetMs`) and was
* abandoned so the per-status + generic-metric passes could still run.
*
* v1.16.8 — three pre-flight outcomes that never reach the generator:
@@ -384,6 +443,12 @@ async function warmGenericMetricCaches(
interface PregenerateCandidate {
id: string;
locale: string | null;
/**
* v1.25.3 — per-user response-timeout (seconds), feeds the warm outer
* abort so a raised setting is not clipped by a fixed cap. Null = the
* comprehensive surface default.
*/
aiResponseTimeoutSeconds: number | null;
}
/**
@@ -410,7 +475,9 @@ export async function findPregenerateCandidates(
// never-generated users sort ahead of merely-stale ones.
orderBy: { insightsCachedAt: "asc" },
take: cap,
select: { id: true, locale: true },
// v1.25.3 — carry the per-user response-timeout so the warm outer abort
// scales with the operator's setting instead of a fixed cap.
select: { id: true, locale: true, aiResponseTimeoutSeconds: true },
});
}
@@ -531,6 +598,12 @@ export async function runInsightPregenerate(
// late resolve would still write a cache row + timestamp after the
// loop moved on (the same race the forced single-user warm closes).
const controller = new AbortController();
// v1.25.3 — size the outer abort from the candidate's resolved
// response-timeout so a raised setting extends the warm budget
// instead of being clipped by a fixed cap.
const warmBudgetMs = comprehensiveWarmBudgetMs(
candidate.aiResponseTimeoutSeconds,
);
const bounded = await withTimeout(
() =>
generate(candidate.id, {
@@ -538,7 +611,7 @@ export async function runInsightPregenerate(
force: true,
signal: controller.signal,
}),
COMPREHENSIVE_WARM_TIMEOUT_MS,
warmBudgetMs,
null,
() => controller.abort(),
);
@@ -657,7 +730,7 @@ export async function runInsightPregenerate(
* actually reads warms lazily through the read-path enqueue.
*
* The three sections are decoupled: the comprehensive step runs under its
* own bounded budget (`COMPREHENSIVE_WARM_TIMEOUT_MS`) and its
* own bounded budget (`comprehensiveWarmBudgetMs`) and its
* failure / timeout / skip is non-fatal — the per-status (7) and
* generic-metric (~30) warm passes ALWAYS run afterwards. The earlier
* design short-circuited both passes on a non-`generated`/`cached`
@@ -760,11 +833,17 @@ export async function forceWarmUser(
let freshness: {
insightsCachedAt: Date | null;
insightsWarmFailedAt: Date | null;
// v1.25.3 — feeds the warm outer abort (see comprehensiveWarmBudgetMs).
aiResponseTimeoutSeconds: number | null;
} | null = null;
try {
freshness = await prisma.user.findUnique({
where: { id: userId },
select: { insightsCachedAt: true, insightsWarmFailedAt: true },
select: {
insightsCachedAt: true,
insightsWarmFailedAt: true,
aiResponseTimeoutSeconds: true,
},
});
} catch {
// Best-effort pre-flight — on a read failure fall through to the
@@ -807,6 +886,12 @@ export async function forceWarmUser(
});
} else {
const controller = new AbortController();
// v1.25.3 — size the outer abort from the user's resolved
// response-timeout (read with the freshness gate above) so a raised
// setting extends the warm budget instead of being clipped.
const warmBudgetMs = comprehensiveWarmBudgetMs(
freshness?.aiResponseTimeoutSeconds,
);
const comprehensive = await withTimeout(
() =>
generate(userId, {
@@ -814,7 +899,7 @@ export async function forceWarmUser(
force: true,
signal: controller.signal,
}),
COMPREHENSIVE_WARM_TIMEOUT_MS,
warmBudgetMs,
null,
() => controller.abort(),
);
+1 -1
View File
@@ -339,7 +339,7 @@ const aiProviderResponse = z
.int()
.nullable()
.describe(
"v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the generous built-in default (~180 s). Mainly for slow local/self-hosted backends.",
"v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.",
),
ocrEnabled: z
.boolean()