perf(insights): generate the per-metric status cards in one batched call nightly

The 02:00 status cron now runs generateStatusBatchForUser for every eligible
user: it builds the seven per-metric snapshots once and issues a single
provider call for the metrics still needing the model, fanning each summary
back into the same per-metric cache rows the standalone generators write.

The six later per-metric crons (02:05-02:30) keep their drivers but now hit
the calendar-day cache the batch already filled, so they resolve as cache
reads and only cover the edges — a user discovered after 02:00, a metric the
batch omitted, or a batch-call failure (which the batch falls back per metric).
Reproducibility and the consent/grounding gates are unchanged: the batch runs
through runStatusCompletion, which still pins the reference seed.

Drops the now-unused per-metric general generator and recordRun plumbing.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 00:55:56 +02:00
1 parent 49add3f43b
commit e18bd3e23a
1 file changed
+66 -8
+66 -8
View File
@@ -19,14 +19,15 @@ import {
runInsightStatusGenerate,
} from "@/lib/jobs/insight-status-generate";
import { withBackgroundEvent } from "@/lib/logging/background";
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 { generateStatusBatchForUser } from "@/lib/insights/status-batch";
import { findStatusCronCandidates } from "@/lib/jobs/status-cron-candidates";
import { annotate } from "@/lib/logging/context";
import { getWorkerPrisma } from "./shared";
export interface GeneralStatusPayload {
@@ -74,12 +75,10 @@ export async function runStatusCronGenerate(
userId: string,
options: { locale: string | null; force: boolean },
) => Promise<unknown>,
options: { recordRun?: boolean } = {},
): Promise<void> {
await withBackgroundEvent(taskName, async (evt) => {
const prisma = getWorkerPrisma();
try {
if (options.recordRun) recordInsightsRun();
const users = await findStatusCronCandidates(prisma);
if (users.length === 0) return;
@@ -112,13 +111,72 @@ export async function runStatusCronGenerate(
});
}
/**
* v1.18.11 (P2) — nightly status batch. The 02:00 anchor cron runs
* `generateStatusBatchForUser` for every candidate: it builds all seven
* per-metric snapshots once and issues ONE provider call for the metrics
* still needing the LLM (seed-pinned + grounded inside `runStatusCompletion`,
* unchanged), fanning the response into the SAME per-metric cache rows the
* standalone generators wrote.
*
* The six later per-metric crons (02:05–02:30) stay registered and keep their
* own drivers: a card the batch wrote today resolves inside their `prepare`
* step as a calendar-day cache hit (`served`, no provider call, no snapshot
* rebuild), so they cost a cache read and cover the edge cases the batch
* can't — a user discovered after 02:00, a metric the batch omitted, or a
* batch-call failure (which falls each metric back to its single-card path
* inside the batch itself). Net effect: the nightly ladder pays one call per
* user instead of seven, with no queue/registry churn.
*/
async function runStatusBatchCron(taskName: string): Promise<void> {
await withBackgroundEvent(taskName, async (evt) => {
const prisma = getWorkerPrisma();
try {
recordInsightsRun();
const users = await findStatusCronCandidates(prisma);
if (users.length === 0) return;
let generated = 0;
let served = 0;
let failed = 0;
for (const user of users) {
try {
const result = await generateStatusBatchForUser(user.id, {
// The batch needs a concrete `de`/`en`; the per-metric generators
// normalise the same way (de stays de, everything else English).
locale: user.locale === "de" ? "de" : "en",
force: false,
});
generated += result.batched + result.fellBack;
served += result.served;
} catch (error) {
failed++;
evt.addWarning(
`${taskName} batch failed for user ${user.id}: ${error}`,
);
}
}
annotate({
action: { name: "insights.status.batch.cron" },
meta: { generated, served, failed, total: users.length },
});
evt.setBackground({
task_name: taskName,
result: { generated, served, failed, total: users.length },
});
} catch (err) {
evt.setError(err);
recordError();
await reportWorkerError(taskName, err);
throw err;
}
});
}
export function handleGeneralStatusGenerate(jobs: Job<GeneralStatusPayload>[]) {
void jobs;
return runStatusCronGenerate(
"job.insights.general",
generateGeneralStatusForUser,
{ recordRun: true },
);
return runStatusBatchCron("job.insights.batch");
}
export function handleBloodPressureStatusGenerate(