From 726bbd53b8f095657c1618aa33e23533a4769f70 Mon Sep 17 00:00:00 2001 From: "developing-today[bot]" <5454422+developing-today[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:14:37 -0600 Subject: [PATCH 1/3] fix(aggregator): refresh stale quota on demand (#1171) --- pm-stack/aggregator/src/quota-refresh.ts | 151 ++++++++++++++ pm-stack/aggregator/src/render.ts | 17 ++ pm-stack/aggregator/src/server.ts | 25 ++- pm-stack/aggregator/test/nav.test.ts | 2 +- .../test/quota-refresh-1171.test.ts | 184 ++++++++++++++++++ 5 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 pm-stack/aggregator/src/quota-refresh.ts create mode 100644 pm-stack/aggregator/test/quota-refresh-1171.test.ts diff --git a/pm-stack/aggregator/src/quota-refresh.ts b/pm-stack/aggregator/src/quota-refresh.ts new file mode 100644 index 000000000..9cdd57c96 --- /dev/null +++ b/pm-stack/aggregator/src/quota-refresh.ts @@ -0,0 +1,151 @@ +// Lazy quota refresh (#1171): a page/API load whose newest measurement is +// older than five minutes asks the existing gatherer to run again, without +// blocking the response on provider calls. Concurrent loads share one request +// and keep a refreshing status until the data advances or the unit timeout. +// +// The trigger reuses the deployed gatherer unit (quota-gatherer.service) +// rather than fetching providers here — provider-fetch logic lives only in +// scripts/gather-quota.ts. + +import type { QuotaView } from "./model.ts"; + +/** The issue's freshness bound: five minutes. */ +export const STALE_AFTER_MS = 5 * 60_000; +/** Gatherer systemd TimeoutStartSec is 300s; add one minute of grace. */ +export const MAX_REFRESH_MS = 6 * 60_000; +/** Avoid rapid retries after a trigger command fails to start. */ +export const TRIGGER_COOLDOWN_MS = 60_000; + +export interface RefreshStatus { + /** Newest measurement was older than maxAgeMs (or absent). */ + stale: boolean; + /** A gatherer run is expected to be in flight. */ + refreshing: boolean; + /** This call started the gatherer. */ + triggered: boolean; + /** ISO time of the most recent trigger, or null. */ + lastTriggerAt: string | null; +} + +export type RefreshTrigger = (reason: "stale" | "manual") => void | Promise; + +/** Newest measuredAt/gatherer tick in the view, in epoch ms, or null. */ +export function newestMeasurement(view: QuotaView, nowMs = Date.now()): number | null { + const times: number[] = []; + const add = (raw: string | null | undefined) => { + if (!raw) return; + const t = Date.parse(raw); + // Future timestamps (clock skew) count as fresh, not as very stale. + if (!Number.isNaN(t)) times.push(Math.min(t, nowMs)); + }; + for (const pool of view.pools ?? []) { + add(pool.freshness?.measuredAt); + for (const credential of pool.credentials ?? []) add(credential.freshness?.measuredAt); + } + if (view.gatherer) { + add(view.gatherer.generatedAt); + add(view.gatherer.tickAt); + for (const provider of view.gatherer.providers ?? []) add(provider.measuredAt); + } + return times.length ? Math.max(...times) : null; +} + +/** True when the data is missing or older than maxAgeMs. */ +export function isQuotaStale(view: QuotaView, nowMs = Date.now(), maxAgeMs = STALE_AFTER_MS): boolean { + const newest = newestMeasurement(view, nowMs); + return newest === null || nowMs - newest > maxAgeMs; +} + +export interface QuotaRefresher { + /** Trigger when stale; safe to call on every load. */ + request(view: QuotaView, nowMs?: number): RefreshStatus; + /** Trigger regardless of freshness (manual refresh-now control). */ + manual(view: QuotaView, nowMs?: number): RefreshStatus; + /** Test/introspection hook: is a run expected to be in flight? */ + refreshing(): boolean; +} + +export function createQuotaRefresher(opts: { + trigger: RefreshTrigger; + now?: () => number; + maxAgeMs?: number; + maxRefreshMs?: number; + cooldownMs?: number; +}): QuotaRefresher { + const now = opts.now ?? (() => Date.now()); + const maxAgeMs = opts.maxAgeMs ?? STALE_AFTER_MS; + const maxRefreshMs = opts.maxRefreshMs ?? MAX_REFRESH_MS; + const cooldownMs = opts.cooldownMs ?? TRIGGER_COOLDOWN_MS; + // Track the gatherer result, not the short-lived systemctl --no-block child. + let active: { startedAt: number; baseline: number | null } | null = null; + let lastTriggerAt: number | null = null; + + const iso = () => (lastTriggerAt === null ? null : new Date(lastTriggerAt).toISOString()); + const status = (stale: boolean, refreshing: boolean, triggered = false): RefreshStatus => ({ + stale, + refreshing, + triggered, + lastTriggerAt: iso(), + }); + + function observe(view: QuotaView, nowMs: number): boolean { + if (!active) return false; + const measurement = newestMeasurement(view, nowMs); + const dataAdvanced = measurement !== null && (active.baseline === null || measurement > active.baseline); + if (dataAdvanced || nowMs - active.startedAt >= maxRefreshMs) { + active = null; + return false; + } + return true; + } + + function kick(reason: "stale" | "manual", view: QuotaView, nowMs: number): void { + const run = { startedAt: nowMs, baseline: newestMeasurement(view, nowMs) }; + // Set synchronously so concurrent request handlers share the trigger. + active = run; + lastTriggerAt = nowMs; + const fail = (err: unknown) => { + console.error("aggregator: quota refresh trigger failed (" + reason + "):", err); + if (active === run) active = null; + }; + try { + // The trigger only asks systemd to queue the existing gatherer; it does + // not wait for provider calls or the gatherer's five-minute run. + void Promise.resolve(opts.trigger(reason)).catch(fail); + } catch (err) { + fail(err); + } + } + + return { + request(view, nowMs = now()) { + const stale = isQuotaStale(view, nowMs, maxAgeMs); + const refreshing = observe(view, nowMs); + if (!stale) return status(false, refreshing); + if (refreshing) return status(true, true); + if (lastTriggerAt !== null && nowMs - lastTriggerAt < cooldownMs) return status(true, false); + kick("stale", view, nowMs); + return status(true, true, true); + }, + manual(view, nowMs = now()) { + const stale = isQuotaStale(view, nowMs, maxAgeMs); + if (observe(view, nowMs)) return status(stale, true); + kick("manual", view, nowMs); + return status(stale, true, true); + }, + refreshing: () => active !== null, + }; +} + +/** Queue the deployed gatherer; provider fetching remains in that service. */ +export function systemdTrigger( + command = process.env.QUOTA_REFRESH_CMD ?? "systemctl --user start --no-block quota-gatherer.service", +): RefreshTrigger { + return async () => { + const argv = command.trim().split(/\s+/); + if (argv.length === 0 || !argv[0]) throw new Error("empty refresh command"); + const proc = Bun.spawn(argv, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }); + const exitCode = await proc.exited; + if (exitCode !== 0) throw new Error("systemd gatherer start exited with code " + exitCode); + }; +} diff --git a/pm-stack/aggregator/src/render.ts b/pm-stack/aggregator/src/render.ts index a8ef1393a..a5da9d2a5 100644 --- a/pm-stack/aggregator/src/render.ts +++ b/pm-stack/aggregator/src/render.ts @@ -2342,11 +2342,27 @@ function chargesStripHtml(usage: UsageView): string { return `

Charges this period

Evidence, not accounting — charges observed on cards/receipts in the local records (full balances stay in the finance tooling). Total observed: $${total.toFixed(2)}.

${rows}
Charges this period
DateChargeAmountMethodEvidence
`; } +/** Lazy-refresh strip (#1171): a visible "refreshing" marker while a + * gatherer run is in flight, plus the manual refresh-now control. Both + * use the same trigger path; neither blocks the page on provider calls. */ +function quotaRefreshHtml(refresh?: import("./quota-refresh.ts").RefreshStatus): string { + if (!refresh) return ""; + const marker = refresh.refreshing + ? `refreshing — quotas were stale, the gatherer is running; reload for fresh data` + : refresh.stale + ? `quota data is older than five minutes` + : ""; + return `
${marker || "quota data is fresh"}
`; +} + export function renderQuota( view: QuotaView, provenance: Provenance, usage?: UsageView, detail?: ProvenanceDetail, + /** Lazy-refresh state (#1171): renders the "refreshing" marker and the + * manual refresh-now control. Absent means no request was made. */ + refresh?: import("./quota-refresh.ts").RefreshStatus, ): string { const credentialLifecycle = view.credentialLifecycle?.length ? `

Credential lifecycle

${view.credentialLifecycle @@ -2451,6 +2467,7 @@ ${burnAndChoices}`, `

Usage & billing

Token quotas, PAYG dollars, and SaaS usage vs limits — from dated local records; every card carries its measured-at stamp, and stale cards dim. Machine-first: /api/quota.json.

${attentionStripHtml(usage)} ${credentialLifecycle} +${quotaRefreshHtml(refresh)}

Token quotas

${pools} ${paygCardHtml(usage)}

SaaS & services

${groups || '

No services extracted yet.

'} diff --git a/pm-stack/aggregator/src/server.ts b/pm-stack/aggregator/src/server.ts index f00a427a0..cc35aaa84 100644 --- a/pm-stack/aggregator/src/server.ts +++ b/pm-stack/aggregator/src/server.ts @@ -7,6 +7,7 @@ // (GitHub, Kaneo, Grist) stays read-only from here — the fleet jobs // apply actions; the dashboard only records them. import { stat } from "node:fs/promises"; +import { createQuotaRefresher, systemdTrigger } from "./quota-refresh.ts"; import { decodePathSegment } from "./path-segment.ts"; import { errorBoundaryResponse, notFoundResponse } from "./error-boundary.ts"; import { ActionsStore, validateNewAction, type DashAction } from "./actions.ts"; @@ -241,6 +242,10 @@ function pickSource(): Source { const source = pickSource(); +/** Lazy quota refresh (#1171): stale page/API loads ask the deployed + * gatherer unit to run again; single-flight + cooldown live in the module. */ +const quotaRefresher = createQuotaRefresher({ trigger: systemdTrigger() }); + // #1279: prefer the latest-comment-per-ref read so a busy batch cannot // truncate an item's most recent activity out of its brief. const briefCommentsFor = async (refs: RefKey[]): Promise => @@ -1344,7 +1349,16 @@ async function handler(req: Request): Promise { source.quota().catch(() => emptyQuotaView()), source.usage().catch(() => emptyUsageView()), ]); - return html(renderQuota(quota, prov(), usage, provDetail())); + // Never block on providers (#1171): decide from the stored measurement, + // ask the gatherer to run in the background, render what we have. + const refresh = quotaRefresher.request(quota); + return html(renderQuota(quota, prov(), usage, provDetail(), refresh)); + } + + if (req.method === "POST" && path === "/quota/refresh") { + const quota = await source.quota().catch(() => emptyQuotaView()); + quotaRefresher.manual(quota); + return new Response(null, { status: 303, headers: { Location: "/quota" } }); } const serviceMatch = /^\/quota\/service\/([\w-]+)$/.exec(path); @@ -1678,8 +1692,12 @@ async function handler(req: Request): Promise { // hub sections ride as additional top-level keys. Snapshot history // stays out of this twin — it is per-service, on the service twin. const [quota, usage] = await Promise.all([source.quota(), source.usage()]); + // #1171: a stale read returns immediately with refresh state instead of + // waiting on provider calls; the gatherer runs in the background. + const refresh = quotaRefresher.request(quota); return json({ ...quota, + refresh, saas: usage.services, payg: usage.payg, charges: usage.charges, @@ -1688,6 +1706,11 @@ async function handler(req: Request): Promise { subscriptions: usage.subscriptions, }); } + if (req.method === "POST" && path === "/api/quota/refresh") { + const quota = await source.quota().catch(() => emptyQuotaView()); + const refresh = quotaRefresher.manual(quota); + return json({ requested: refresh.triggered, refresh }); + } if (path === "/api/quota/service.json") { const id = url.searchParams.get("id") ?? ""; const usage = await source.usage(); diff --git a/pm-stack/aggregator/test/nav.test.ts b/pm-stack/aggregator/test/nav.test.ts index 582f2cc4f..c43b751b3 100644 --- a/pm-stack/aggregator/test/nav.test.ts +++ b/pm-stack/aggregator/test/nav.test.ts @@ -37,7 +37,7 @@ function serverPaths(): { exact: string[]; prefixes: string[] } { /** Routes that are not pages a person navigates to: JSON twins, POST * endpoints, datastar fragments, static assets, and the /board alias. */ -const NON_PAGE = /^\/api\/|^\/frag\/|^\/assets\/|^\/sw\.js$|^\/board$|^\/manifest\.webmanifest$/; +const NON_PAGE = /^\/api\/|^\/frag\/|^\/assets\/|^\/sw\.js$|^\/board$|^\/manifest\.webmanifest$|^\/quota\/refresh$/; describe("the manifest groups every page (#1135)", () => { test("every page names one of PAGE_GROUPS, and groups have no page-less id", () => { diff --git a/pm-stack/aggregator/test/quota-refresh-1171.test.ts b/pm-stack/aggregator/test/quota-refresh-1171.test.ts new file mode 100644 index 000000000..3d481aab4 --- /dev/null +++ b/pm-stack/aggregator/test/quota-refresh-1171.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test"; +import type { QuotaView } from "../src/model.ts"; +import { + MAX_REFRESH_MS, + STALE_AFTER_MS, + createQuotaRefresher, + isQuotaStale, + newestMeasurement, + type RefreshTrigger, +} from "../src/quota-refresh.ts"; + +const NOW = Date.parse("2026-11-03T14:00:00Z"); +const iso = (offsetMs: number) => new Date(NOW - offsetMs).toISOString(); + +function view(measuredAt: string | null, extra: { tickAt?: string | null } = {}): QuotaView { + return { + pools: [ + { + id: "p1", + provider: "test", + plan: "test", + state: "ok" as const, + windows: [ + { + name: "week", + unit: "tokens" as const, + used: 10, + limit: 100, + resetsAt: iso(-86_400_000), + }, + ], + ...(measuredAt ? { freshness: { measuredAt, source: "quota-current.json", state: "ok" as const } } : {}), + }, + ], + ...(extra.tickAt === undefined + ? {} + : { + gatherer: { + generatedAt: extra.tickAt ?? "", + source: "quota-current.json", + tickAt: extra.tickAt ?? null, + ok: true, + providers: [], + }, + }), + } as unknown as QuotaView; +} + +function harness(nowMs = NOW) { + const calls: Array<"stale" | "manual"> = []; + let release: (() => void) | null = null; + const trigger: RefreshTrigger = (reason) => { + calls.push(reason); + return new Promise((resolve) => { + release = resolve; + }); + }; + let clock = nowMs; + const refresher = createQuotaRefresher({ + trigger, + now: () => clock, + cooldownMs: 60_000, + }); + return { + calls, + refresher, + advance: (ms: number) => { + clock += ms; + }, + finish: async () => { + release?.(); + release = null; + // Let the promise chain clear the in-flight marker. + await Promise.resolve(); + await Promise.resolve(); + }, + }; +} + +describe("quota freshness (#1171)", () => { + test("newest measurement wins across windows and gatherer tick", () => { + const v = view(iso(10 * 60_000), { tickAt: iso(60_000) }); + expect(newestMeasurement(v, NOW)).toBe(NOW - 60_000); + }); + + test("missing measurements count as stale", () => { + expect(isQuotaStale(view(null), NOW)).toBe(true); + }); + + test("older than five minutes is stale, newer is fresh", () => { + expect(isQuotaStale(view(iso(STALE_AFTER_MS + 1_000)), NOW)).toBe(true); + expect(isQuotaStale(view(iso(STALE_AFTER_MS - 1_000)), NOW)).toBe(false); + }); + + test("future timestamps are treated as fresh, not stale", () => { + expect(isQuotaStale(view(iso(-60 * 60_000)), NOW)).toBe(false); + }); +}); + +describe("lazy refresh trigger (#1171)", () => { + test("fresh data does not trigger", () => { + const h = harness(); + const status = h.refresher.request(view(iso(30_000))); + expect(status).toMatchObject({ stale: false, refreshing: false, triggered: false }); + expect(h.calls).toEqual([]); + }); + + test("stale load triggers exactly one gatherer run", () => { + const h = harness(); + const status = h.refresher.request(view(iso(10 * 60_000))); + expect(status).toMatchObject({ stale: true, refreshing: true, triggered: true }); + expect(h.calls).toEqual(["stale"]); + }); + + test("concurrent stale loads dedupe to the in-flight run", () => { + const h = harness(); + h.refresher.request(view(iso(10 * 60_000))); + const second = h.refresher.request(view(iso(10 * 60_000))); + expect(second).toMatchObject({ stale: true, refreshing: true, triggered: false }); + expect(h.calls).toEqual(["stale"]); + }); + + test("refresh stays single-flight until data advances", async () => { + const h = harness(); + h.refresher.request(view(iso(10 * 60_000))); + await h.finish(); // systemctl returned; the gatherer may still be running. + h.advance(61_000); + const stillRunning = h.refresher.request(view(iso(10 * 60_000))); + expect(stillRunning).toMatchObject({ stale: true, refreshing: true, triggered: false }); + expect(h.calls).toEqual(["stale"]); + + const fresh = h.refresher.request(view(iso(30_000))); + expect(fresh).toMatchObject({ stale: false, refreshing: false, triggered: false }); + const laterStale = h.refresher.request(view(iso(10 * 60_000))); + expect(laterStale.triggered).toBe(true); + expect(h.calls).toEqual(["stale", "stale"]); + }); + + test("refresh times out after the gatherer's maximum runtime", () => { + const h = harness(); + h.refresher.request(view(iso(10 * 60_000))); + h.advance(MAX_REFRESH_MS); + const retried = h.refresher.request(view(iso(10 * 60_000))); + expect(retried.triggered).toBe(true); + expect(h.calls).toEqual(["stale", "stale"]); + }); + + test("manual refresh uses the same path and bypasses freshness", () => { + const h = harness(); + const status = h.refresher.manual(view(iso(1_000))); + expect(status).toMatchObject({ stale: false, refreshing: true, triggered: true }); + expect(h.calls).toEqual(["manual"]); + }); + + test("manual refresh is single-flight too", () => { + const h = harness(); + h.refresher.manual(view(iso(1_000))); + const second = h.refresher.manual(view(iso(1_000))); + expect(second.triggered).toBe(false); + expect(h.calls).toEqual(["manual"]); + }); + + test("a failing trigger fails soft and clears in-flight", async () => { + const calls: string[] = []; + const refresher = createQuotaRefresher({ + trigger: () => { + calls.push("boom"); + throw new Error("no systemd"); + }, + now: () => NOW, + }); + const originalError = console.error; + console.error = () => {}; + try { + refresher.request(view(iso(10 * 60_000))); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(calls).toEqual(["boom"]); + } finally { + console.error = originalError; + } + }); +}); From 7caef50cca3728206c887c90aaadb52a76459f27 Mon Sep 17 00:00:00 2001 From: "developing-today[bot]" <5454422+developing-today[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:55:12 -0600 Subject: [PATCH 2/3] fix(aggregator): keep gatherer ticks out of quota freshness --- pm-stack/aggregator/src/quota-refresh.ts | 4 +--- pm-stack/aggregator/src/render.ts | 2 +- pm-stack/aggregator/test/quota-refresh-1171.test.ts | 12 ++++++++++-- pm-stack/aggregator/test/render.test.ts | 11 +++++++++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/pm-stack/aggregator/src/quota-refresh.ts b/pm-stack/aggregator/src/quota-refresh.ts index 9cdd57c96..d84b54e74 100644 --- a/pm-stack/aggregator/src/quota-refresh.ts +++ b/pm-stack/aggregator/src/quota-refresh.ts @@ -29,7 +29,7 @@ export interface RefreshStatus { export type RefreshTrigger = (reason: "stale" | "manual") => void | Promise; -/** Newest measuredAt/gatherer tick in the view, in epoch ms, or null. */ +/** Newest provider/pool measurement in the view, in epoch ms, or null. */ export function newestMeasurement(view: QuotaView, nowMs = Date.now()): number | null { const times: number[] = []; const add = (raw: string | null | undefined) => { @@ -43,8 +43,6 @@ export function newestMeasurement(view: QuotaView, nowMs = Date.now()): number | for (const credential of pool.credentials ?? []) add(credential.freshness?.measuredAt); } if (view.gatherer) { - add(view.gatherer.generatedAt); - add(view.gatherer.tickAt); for (const provider of view.gatherer.providers ?? []) add(provider.measuredAt); } return times.length ? Math.max(...times) : null; diff --git a/pm-stack/aggregator/src/render.ts b/pm-stack/aggregator/src/render.ts index a5da9d2a5..f609c43ee 100644 --- a/pm-stack/aggregator/src/render.ts +++ b/pm-stack/aggregator/src/render.ts @@ -2348,7 +2348,7 @@ function chargesStripHtml(usage: UsageView): string { function quotaRefreshHtml(refresh?: import("./quota-refresh.ts").RefreshStatus): string { if (!refresh) return ""; const marker = refresh.refreshing - ? `refreshing — quotas were stale, the gatherer is running; reload for fresh data` + ? `refreshing — the quota gatherer is running; reload for updated data` : refresh.stale ? `quota data is older than five minutes` : ""; diff --git a/pm-stack/aggregator/test/quota-refresh-1171.test.ts b/pm-stack/aggregator/test/quota-refresh-1171.test.ts index 3d481aab4..ad1526e2a 100644 --- a/pm-stack/aggregator/test/quota-refresh-1171.test.ts +++ b/pm-stack/aggregator/test/quota-refresh-1171.test.ts @@ -78,9 +78,10 @@ function harness(nowMs = NOW) { } describe("quota freshness (#1171)", () => { - test("newest measurement wins across windows and gatherer tick", () => { + test("gatherer attempt timestamps do not make old measurements fresh", () => { const v = view(iso(10 * 60_000), { tickAt: iso(60_000) }); - expect(newestMeasurement(v, NOW)).toBe(NOW - 60_000); + expect(newestMeasurement(v, NOW)).toBe(NOW - 10 * 60_000); + expect(isQuotaStale(v, NOW)).toBe(true); }); test("missing measurements count as stale", () => { @@ -112,6 +113,13 @@ describe("lazy refresh trigger (#1171)", () => { expect(h.calls).toEqual(["stale"]); }); + test("a recent gatherer tick does not suppress refresh for stale measurements", () => { + const h = harness(); + const status = h.refresher.request(view(iso(10 * 60_000), { tickAt: iso(60_000) })); + expect(status).toMatchObject({ stale: true, refreshing: true, triggered: true }); + expect(h.calls).toEqual(["stale"]); + }); + test("concurrent stale loads dedupe to the in-flight run", () => { const h = harness(); h.refresher.request(view(iso(10 * 60_000))); diff --git a/pm-stack/aggregator/test/render.test.ts b/pm-stack/aggregator/test/render.test.ts index 3e656c6ec..2cbd65752 100644 --- a/pm-stack/aggregator/test/render.test.ts +++ b/pm-stack/aggregator/test/render.test.ts @@ -299,6 +299,17 @@ describe("render smoke (fixtures)", () => { expect(html).toContain("Headroom for a new session"); expect(html).toContain("Antigravity"); }); + test("fresh manual refresh renders a neutral refreshing marker", async () => { + const src = fixtureSource(); + const html = renderQuota(await src.quota(), src.provenance(), await src.usage(), undefined, { + stale: false, + refreshing: true, + triggered: true, + lastTriggerAt: "2026-11-03T14:00:00.000Z", + }); + expect(html).toContain("refreshing — the quota gatherer is running; reload for updated data"); + expect(html).not.toContain("quotas were stale"); + }); test("attention view renders the tiered needs-you page", async () => { const src = fixtureSource(); const view = buildAttention(await src.attention(), await src.orchestration()); From 4b148f272ef47edb1ef9fa6dc03c8347a7c9f5cd Mon Sep 17 00:00:00 2001 From: "developing-today[bot]" <5454422+developing-today[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:02:43 -0600 Subject: [PATCH 3/3] fix(aggregator): ignore failed quota reads for freshness --- pm-stack/aggregator/src/quota-refresh.ts | 14 ++++---- .../test/quota-refresh-1171.test.ts | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/pm-stack/aggregator/src/quota-refresh.ts b/pm-stack/aggregator/src/quota-refresh.ts index d84b54e74..a586174e4 100644 --- a/pm-stack/aggregator/src/quota-refresh.ts +++ b/pm-stack/aggregator/src/quota-refresh.ts @@ -29,21 +29,23 @@ export interface RefreshStatus { export type RefreshTrigger = (reason: "stale" | "manual") => void | Promise; -/** Newest provider/pool measurement in the view, in epoch ms, or null. */ +/** Newest non-error provider/pool measurement in the view, in epoch ms, or null. */ export function newestMeasurement(view: QuotaView, nowMs = Date.now()): number | null { const times: number[] = []; - const add = (raw: string | null | undefined) => { - if (!raw) return; + const add = (raw: string | null | undefined, state?: "ok" | "stale" | "error") => { + if (!raw || state === "error") return; const t = Date.parse(raw); // Future timestamps (clock skew) count as fresh, not as very stale. if (!Number.isNaN(t)) times.push(Math.min(t, nowMs)); }; for (const pool of view.pools ?? []) { - add(pool.freshness?.measuredAt); - for (const credential of pool.credentials ?? []) add(credential.freshness?.measuredAt); + add(pool.freshness?.measuredAt, pool.freshness?.state); + for (const credential of pool.credentials ?? []) { + add(credential.freshness?.measuredAt, credential.freshness?.state); + } } if (view.gatherer) { - for (const provider of view.gatherer.providers ?? []) add(provider.measuredAt); + for (const provider of view.gatherer.providers ?? []) add(provider.measuredAt, provider.state); } return times.length ? Math.max(...times) : null; } diff --git a/pm-stack/aggregator/test/quota-refresh-1171.test.ts b/pm-stack/aggregator/test/quota-refresh-1171.test.ts index ad1526e2a..bbdd636c5 100644 --- a/pm-stack/aggregator/test/quota-refresh-1171.test.ts +++ b/pm-stack/aggregator/test/quota-refresh-1171.test.ts @@ -84,6 +84,40 @@ describe("quota freshness (#1171)", () => { expect(isQuotaStale(v, NOW)).toBe(true); }); + test("failed gatherer attempts do not mask older successful measurements", () => { + const v = view(iso(10 * 60_000), { tickAt: iso(60_000) }); + v.pools.push({ + ...v.pools[0], + id: "p2", + }); + v.pools[0].freshness = { measuredAt: iso(60_000), source: "quota-current.json", state: "error" }; + v.pools[0].credentials = [ + { + label: "failed credential", + providerId: "test", + windows: [], + freshness: { measuredAt: iso(60_000), source: "quota-current.json", state: "error" }, + }, + ]; + v.gatherer!.providers = [ + { + providerId: "test", + serviceId: "p1", + state: "error", + measuredAt: iso(60_000), + source: "quota-current.json", + }, + ]; + + expect(newestMeasurement(v, NOW)).toBe(NOW - 10 * 60_000); + expect(isQuotaStale(v, NOW)).toBe(true); + + const h = harness(); + const status = h.refresher.request(v); + expect(status).toMatchObject({ stale: true, refreshing: true, triggered: true }); + expect(h.calls).toEqual(["stale"]); + }); + test("missing measurements count as stale", () => { expect(isQuotaStale(view(null), NOW)).toBe(true); });