mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
The per-document summary job is enqueued at upload time and no-ops while the documentsAutoAiRead opt-in is OFF. Nothing ever re-enqueued it, so the switch only applied to documents uploaded AFTER the flip — a user who filled the vault first and opted in later had documents that would never be read, and the setting looked inert. A genuine OFF->ON transition now schedules one bounded catch-up pass over the user's un-summarised documents. It grants nothing of its own: it only enqueues the ordinary summary job, which still re-reads the opt-in, re-asserts the per-document egress consent and reserves the daily token budget. The pass itself re-reads the opt-in first, so a toggle flipped back OFF before it runs egresses nothing. Bounded by an id-cursor walk capped at 200 documents per pass (a summary is a provider call, unlike the local-compute thumbnail backfill). Idempotent at four levels: the trigger fires only on a real transition, the per-user singleton collapses a double toggle, the candidate set excludes summarised documents, and the summary job skips one that already has a summary.
165 lines
6.2 KiB
TypeScript
165 lines
6.2 KiB
TypeScript
/**
|
|
* Catch-up pass for documents stored BEFORE the auto-read opt-in was turned on.
|
|
*
|
|
* The per-document summary job is enqueued at UPLOAD time and no-ops when
|
|
* `documentsAutoAiRead` is OFF. That leaves an obvious hole: a user who uploads
|
|
* their vault first and flips the toggle afterwards gets nothing. Every document
|
|
* they already hold was enqueued while the flag was OFF, the job no-opped, and
|
|
* nothing ever re-enqueues them — so the switch reads as broken. It only ever
|
|
* worked for documents uploaded after the flip.
|
|
*
|
|
* A genuine OFF→ON transition on `PATCH /api/auth/me/documents-auto-ai-read`
|
|
* enqueues one pass of this job, which walks the user's un-summarised documents
|
|
* and enqueues the ordinary per-document summary job for each.
|
|
*
|
|
* What this job does NOT do is grant anything. It only enqueues; every gate
|
|
* still runs inside `runDocumentSummaryJob` — the opt-in is re-read, the egress
|
|
* consent is re-asserted per document, and the daily token budget is reserved
|
|
* and reconciled exactly as on the upload path. Retroactive work is not a reason
|
|
* to skip a consent receipt or a budget ceiling, so it does not.
|
|
*
|
|
* Bounded and idempotent:
|
|
* - the candidate set is documents with NO summary, so a document drops out
|
|
* the moment its summary lands and a re-run cannot redo finished work;
|
|
* - an id-cursor forward walk capped at `MAX_ENQUEUES_PER_RUN` (see the
|
|
* constant for why that number) keeps one flip from queueing a whole vault;
|
|
* - the per-user `singletonKey` plus the queue's `short` policy collapses a
|
|
* second flip while a pass is still queued, and the per-document enqueue
|
|
* carries its own singleton on top;
|
|
* - `runDocumentSummaryJob` skips any document that already has a summary, so
|
|
* even a duplicate that slips past both singletons is a cheap no-op.
|
|
*
|
|
* The queue MUST be registered in the maintenance registrar
|
|
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
|
|
*/
|
|
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
|
|
import { prisma } from "@/lib/db";
|
|
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
|
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
|
|
import { annotate } from "@/lib/logging/context";
|
|
|
|
export const DOCUMENT_SUMMARY_CATCHUP_QUEUE = "document-summary-catchup";
|
|
|
|
/** Serial concurrency — cheap discovery + fan-out enqueues. */
|
|
export const DOCUMENT_SUMMARY_CATCHUP_CONCURRENCY = 1;
|
|
|
|
/** Discovery page size (id-only). */
|
|
const PAGE_SIZE = 100;
|
|
|
|
/**
|
|
* Max per-document summary jobs one catch-up pass enqueues.
|
|
*
|
|
* Deliberately far below the thumbnail backfill's cap: a thumbnail is local
|
|
* compute, a summary is a provider call against the user's daily token budget.
|
|
* The budget gate already stops runaway spend (every job past the ceiling
|
|
* no-ops), so this cap is about not dumping an entire vault onto the queue in
|
|
* one go — a large library converges over repeated flips and the ordinary
|
|
* upload path rather than in a single burst. Raising it costs queue depth, not
|
|
* money.
|
|
*/
|
|
export const MAX_ENQUEUES_PER_RUN = 200;
|
|
|
|
export interface SummaryCatchUpPayload {
|
|
userId: string;
|
|
enqueuedAt?: string;
|
|
}
|
|
|
|
export interface SummaryCatchUpSummary {
|
|
enqueued: number;
|
|
/** True when the pass stopped at the cap with candidates still outstanding. */
|
|
capped: boolean;
|
|
}
|
|
|
|
/**
|
|
* Enqueue summary jobs for one user's un-summarised documents. Re-reads the
|
|
* opt-in first: a user who flipped the toggle back OFF between the PATCH and
|
|
* this job running gets nothing, which keeps the pass fail-closed against a
|
|
* consent race the same way the per-document job is.
|
|
*/
|
|
export async function runSummaryCatchUpForUser(
|
|
userId: string,
|
|
): Promise<SummaryCatchUpSummary> {
|
|
if (!userId) return { enqueued: 0, capped: false };
|
|
|
|
// Fail-closed on the toggle. The PATCH that scheduled this pass is not
|
|
// authority enough — the flag is re-read at run time, exactly as the
|
|
// per-document job does before it egresses anything.
|
|
if (!(await documentAutoReadEnabled(userId))) {
|
|
annotate({
|
|
action: { name: "documents.autoRead.catchUpSkipped" },
|
|
meta: { reason: "opt-out" },
|
|
});
|
|
return { enqueued: 0, capped: false };
|
|
}
|
|
|
|
let enqueued = 0;
|
|
let capped = false;
|
|
let cursor: string | null = null;
|
|
|
|
for (;;) {
|
|
if (enqueued >= MAX_ENQUEUES_PER_RUN) {
|
|
capped = true;
|
|
break;
|
|
}
|
|
const rows: { id: string }[] = await prisma.inboundDocument.findMany({
|
|
where: { userId, deletedAt: null, summaryEncrypted: null },
|
|
select: { id: true },
|
|
orderBy: { id: "asc" },
|
|
take: PAGE_SIZE,
|
|
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
|
});
|
|
if (rows.length === 0) break;
|
|
|
|
for (const { id } of rows) {
|
|
if (enqueued >= MAX_ENQUEUES_PER_RUN) {
|
|
capped = true;
|
|
break;
|
|
}
|
|
const { enqueued: ok } = await enqueueDocumentSummary(userId, id);
|
|
if (ok) enqueued += 1;
|
|
}
|
|
|
|
cursor = rows[rows.length - 1]!.id;
|
|
if (rows.length < PAGE_SIZE) break;
|
|
}
|
|
|
|
annotate({
|
|
action: { name: "documents.autoRead.catchUp" },
|
|
meta: { enqueued, capped },
|
|
});
|
|
return { enqueued, capped };
|
|
}
|
|
|
|
/**
|
|
* Enqueue one catch-up pass for a user. Called on a genuine OFF→ON flip of the
|
|
* auto-read opt-in. Coalesced by `singletonKey` per user so flipping the toggle
|
|
* twice cannot queue two passes. Fire-and-forget — a missing boss (worker not
|
|
* up) is a no-op, and a `boss.send` failure is swallowed, so the catch-up can
|
|
* never fail the settings write that scheduled it.
|
|
*/
|
|
export async function enqueueSummaryCatchUp(
|
|
userId: string,
|
|
): Promise<{ enqueued: boolean }> {
|
|
const boss = getGlobalBoss();
|
|
if (!boss) return { enqueued: false };
|
|
const payload: SummaryCatchUpPayload = {
|
|
userId,
|
|
enqueuedAt: new Date().toISOString(),
|
|
};
|
|
try {
|
|
const jobId = await boss.send(DOCUMENT_SUMMARY_CATCHUP_QUEUE, payload, {
|
|
retryLimit: 2,
|
|
retryDelay: 60,
|
|
retryBackoff: true,
|
|
singletonKey: `document-summary-catchup|${userId}`,
|
|
});
|
|
return { enqueued: Boolean(jobId) };
|
|
} catch (err) {
|
|
annotate({
|
|
action: { name: "documents.autoRead.catchUpEnqueueFailed" },
|
|
meta: { reason: err instanceof Error ? err.name : "unknown" },
|
|
});
|
|
return { enqueued: false };
|
|
}
|
|
}
|