mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Guard the operator's cap on fallback hops and reap dead summary promises
- The provider-chain walker now checks the day's recorded spend against OPERATOR_COST_CAP before invoking an operator-funded admin-* entry. A request whose primary provider runs on the user's own key reserves under the generous user-plan ceiling; a fallback hop (or health-ledger reorder) onto the operator's key could then spend past the operator's cap unchecked. An exhausted cap skips the candidate like a failed hop. - summaryState PENDING gets a giver-upper: the detail serialiser degrades a PENDING older than an hour to UNAVAILABLE (which offers the manual generate action), and an hourly reaper persists the same heal following the orphan-ImportJob reconcile precedent — a job dead past its retry budget no longer leaves the sheet saying "generating" forever.
This commit is contained in:
1 parent
f67fcec1c9
commit
6ebd5e8711
8 files changed
+333
-4
No files matched your search
@@ -68,7 +68,7 @@ describe("serialiseDocumentDetail — summary state", () => {
|
||||
expect(dto.summaryGeneratedAt).toBe("2026-02-15T09:00:00.000Z");
|
||||
});
|
||||
|
||||
it.each(["NONE", "PENDING", "WITHHELD", "UNAVAILABLE"] as const)(
|
||||
it.each(["NONE", "WITHHELD", "UNAVAILABLE"] as const)(
|
||||
"passes %s through with no summary",
|
||||
(summaryState) => {
|
||||
const dto = serialiseDocumentDetail(row({ summaryState }), []);
|
||||
@@ -78,6 +78,30 @@ describe("serialiseDocumentDetail — summary state", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("passes a FRESH PENDING through — the job is credibly in flight", () => {
|
||||
const dto = serialiseDocumentDetail(
|
||||
row({ summaryState: "PENDING", updatedAt: new Date() }),
|
||||
[],
|
||||
);
|
||||
expect(dto.summaryState).toBe("PENDING");
|
||||
});
|
||||
|
||||
// Watched red: with the stale-PENDING branch removed from
|
||||
// `serialiseDocumentDetail` (the pre-v1.37.19 serialiser), this case fails —
|
||||
// a summary job dead past its retry budget left the sheet saying
|
||||
// "generating" forever, with no manual action offered.
|
||||
it("degrades a PENDING older than the TTL to UNAVAILABLE", () => {
|
||||
const dto = serialiseDocumentDetail(
|
||||
row({
|
||||
summaryState: "PENDING",
|
||||
updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
expect(dto.summary).toBeNull();
|
||||
expect(dto.summaryState).toBe("UNAVAILABLE");
|
||||
});
|
||||
|
||||
it("degrades an undecryptable READY row to UNAVAILABLE", () => {
|
||||
// A rotated-away key. There is no summary to show, so the DTO must not
|
||||
// keep promising one — and the ciphertext is never returned either.
|
||||
|
||||
@@ -46,6 +46,16 @@ export type DocumentContentCodec = (typeof DOCUMENT_CONTENT_CODECS)[number];
|
||||
/** The codec every NEW upload is written with. */
|
||||
export const ACTIVE_DOCUMENT_CODEC: DocumentContentCodec = "binary2";
|
||||
|
||||
/**
|
||||
* How long a `summaryState: "PENDING"` claim stays credible. The summary job
|
||||
* retries twice within minutes; a PENDING whose row has not been touched for
|
||||
* an hour is a dead promise (worker crash past the retry budget, or a
|
||||
* delete-during-queue then restore). Shared by the read-time heal in
|
||||
* `serialiseDocumentDetail` and the hourly persistence sweep
|
||||
* (`jobs/document-summary-reaper.ts`) so the two ends cannot drift.
|
||||
*/
|
||||
export const SUMMARY_PENDING_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Encrypt the raw document bytes into the `Bytes` payload the schema stores. */
|
||||
export function encryptDocumentToBytes(bytes: Buffer): Uint8Array<ArrayBuffer> {
|
||||
const ciphertext = encrypt(bytes.toString("base64"));
|
||||
@@ -291,6 +301,17 @@ export function serialiseDocumentDetail(
|
||||
// READY with no ciphertext should be unreachable; trust the bytes, not the
|
||||
// flag, rather than promising a summary that is not there.
|
||||
summaryState = "UNAVAILABLE";
|
||||
} else if (
|
||||
summaryState === "PENDING" &&
|
||||
Date.now() - doc.updatedAt.getTime() > SUMMARY_PENDING_TTL_MS
|
||||
) {
|
||||
// A PENDING older than the TTL is a dead promise — the job died past its
|
||||
// retry budget and nothing will resolve it. Degrade to UNAVAILABLE so the
|
||||
// sheet stops saying "generating" forever and offers the manual generate
|
||||
// action instead. The hourly reaper persists the same heal
|
||||
// (`jobs/document-summary-reaper.ts`); this read-time arm just makes the
|
||||
// very next open honest without waiting for the tick.
|
||||
summaryState = "UNAVAILABLE";
|
||||
}
|
||||
return {
|
||||
...serialiseDocument(
|
||||
|
||||
Reference in new issue
Block a user