Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/api/src/tools/task-board/checks-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
extractPreviewUrlFromDeployment,
headShaFromPrGet,
headShaFromStatus,
isAwaitingPreview,
isRateLimitError,
extractPreviewUrlFromCheckRuns,
extractPreviewUrlFromComments,
Expand Down Expand Up @@ -642,3 +643,28 @@ describe("previewMatchesHead", () => {
expect(previewMatchesHead([])).toBe(true);
});
});

describe("isAwaitingPreview", () => {
it("only a running-CI card with no preview keeps refreshing", () => {
expect(
isAwaitingPreview({ previewUrl: null, checksStatus: "pending" }),
).toBe(true);
// Preview found — nothing left to wait for.
expect(
isAwaitingPreview({
previewUrl: "https://x.vtex.app",
checksStatus: "pending",
}),
).toBe(false);
// CI settled without ever posting one; refreshing forever would not help.
expect(
isAwaitingPreview({ previewUrl: null, checksStatus: "passing" }),
).toBe(false);
expect(
isAwaitingPreview({ previewUrl: null, checksStatus: "failing" }),
).toBe(false);
expect(isAwaitingPreview({ previewUrl: null, checksStatus: null })).toBe(
false,
);
});
});
79 changes: 79 additions & 0 deletions apps/api/src/tools/task-board/pr-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,82 @@ describe("fetchOrPlaceholder", () => {
});
});
});

describe("per-entry revalidate override (a value still awaiting something)", () => {
test("fetch: a not-ready stored value goes stale immediately", async () => {
const clock = { now: 0 };
const cache = await cacheAt(clock);
let calls = 0;
const pending: Promise<void>[] = [];
const read = (ready: boolean) =>
cache.fetch({
namespace: "conn_1",
key: "deployment",
fetchLive: async () => {
calls++;
return { url: ready ? "https://x.vtex.app" : null };
},
onRevalidation: (p) => pending.push(p),
// Not ready -> 0, so the very next read revalidates.
revalidateAfterMs: (stored) =>
(stored as { url: string | null }).url === null ? 0 : 55_000,
});

await read(false);
expect(calls).toBe(1);

// One tick later the default window (55s) would still be a HIT; the
// override makes it stale, so the deploy's url is picked up on this poll.
clock.now = 1_000;
await read(true);
await Promise.all(pending);
expect(calls).toBe(2);

// Now that it IS ready, the default window applies again.
clock.now = 2_000;
await read(true);
await Promise.all(pending);
expect(calls).toBe(2);
});

test("fetchOrPlaceholder: an incomplete card is never a hit", async () => {
const clock = { now: 0 };
const cache = new JetStreamKVPrCache(
PR_CARDS_CACHE,
{ getJetStream: () => null },
() => clock.now,
);
await cache.init(fakeKv());

let calls = 0;
const get = (previewUrl: string | null) =>
cache.fetchOrPlaceholder<{ previewUrl: string | null }>({
namespace: "org_1",
key: "task_1",
fetchLive: async () => {
calls++;
return { previewUrl };
},
placeholder: { previewUrl: null },
revalidateAfterMs: (card) =>
card.previewUrl === null ? 0 : PR_CARDS_CACHE.revalidateAfterMs,
});

await get(null); // placeholder + detached fill
await Bun.sleep(0);
expect(calls).toBe(1);

// Inside the 30s default window, so unpatched this was a hit and the
// preview waited for the window to age out.
clock.now = 1_000;
await get("https://x.vtex.app");
await Bun.sleep(0);
expect(calls).toBe(2);

// Complete card: back to the normal window, no rebuild per poll.
clock.now = 2_000;
await get("https://x.vtex.app");
await Bun.sleep(0);
expect(calls).toBe(2);
});
});
20 changes: 18 additions & 2 deletions apps/api/src/tools/task-board/pr-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ export interface PrCacheFetch {
/** Receives the background revalidation so the caller can keep its MCP client
* open until it settles. */
onRevalidation: (promise: Promise<void>) => void;
/** Per-entry override of the hit window, computed from the STORED value.
* A read whose value says "not ready yet" — a deploy with no url published —
* should go stale fast, so the next poll refetches instead of serving the
* not-ready answer for the full config window. Omit for the default. */
revalidateAfterMs?: (stored: unknown) => number;
}

export class JetStreamKVPrCache {
Expand Down Expand Up @@ -162,7 +167,8 @@ export class JetStreamKVPrCache {

async fetch(params: PrCacheFetch): Promise<unknown> {
const { namespace, key: rawKey, fetchLive, onRevalidation } = params;
const { cache, revalidateAfterMs, maxStaleMs } = this.config;
const { cache, maxStaleMs } = this.config;
const defaultRevalidateAfterMs = this.config.revalidateAfterMs;
if (!this.kv) {
return this.fallback.fetch({
type: "tools/call",
Expand All @@ -189,6 +195,9 @@ export class JetStreamKVPrCache {
return value;
}

const revalidateAfterMs = params.revalidateAfterMs
? params.revalidateAfterMs(stored.value)
: defaultRevalidateAfterMs;
if (age > revalidateAfterMs && !this.revalidating.has(key)) {
cacheCounter.add(1, { cache, outcome: "stale" });
this.revalidating.add(key);
Expand Down Expand Up @@ -228,15 +237,22 @@ export class JetStreamKVPrCache {
key: string;
fetchLive: () => Promise<T>;
placeholder: T;
/** Per-entry override of the hit window, computed from the STORED value —
* see {@link PrCacheFetch.revalidateAfterMs}. */
revalidateAfterMs?: (stored: T) => number;
}): Promise<{ value: T; live: boolean }> {
const { namespace, key: rawKey, fetchLive, placeholder } = params;
const { cache, revalidateAfterMs, maxStaleMs } = this.config;
const { cache, maxStaleMs } = this.config;
const key = this.storageKey(namespace, rawKey);
const stored = await this.read(key);
const age = stored
? this.now() - stored.storedAt
: Number.POSITIVE_INFINITY;
const usable = stored != null && age <= maxStaleMs;
const revalidateAfterMs =
stored && params.revalidateAfterMs
? params.revalidateAfterMs(stored.value as T)
: this.config.revalidateAfterMs;

if (usable && age <= revalidateAfterMs) {
cacheCounter.add(1, { cache, outcome: "hit" });
Expand Down
43 changes: 42 additions & 1 deletion apps/api/src/tools/task-board/prs-get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,27 @@ export function isRateLimitError(err: unknown): boolean {
* only moves to Done once a poll observes `merged`, and a minute of "did my ship
* button work?" is exactly the confusion this cache must not introduce.
*/
import { getPrCardCache, getPrReadCache } from "./pr-cache";
import {
getPrCardCache,
getPrReadCache,
PR_CARDS_CACHE,
PR_READS_CACHE,
} from "./pr-cache";

/** Hit window for a cached value that is still waiting on something — a deploy
* with no published url, or a card with no preview while checks run. Zero, so
* the next poll always revalidates (detached, off the request path) instead of
* serving the not-ready answer for the full window. */
const AWAITING_PREVIEW_REVALIDATE_MS = 0;

/** A card that should keep refreshing: CI is still running and no preview URL
* has been found yet. Once either settles the card caches normally. */
export function isAwaitingPreview(card: {
previewUrl: string | null;
checksStatus: ChecksStatus;
}): boolean {
return card.previewUrl === null && card.checksStatus === "pending";
}

export function invalidatePrReads(connectionId: string): Promise<void> {
return getPrReadCache().invalidate(connectionId);
Expand Down Expand Up @@ -168,11 +188,13 @@ async function cachedPrRead(
args: Record<string, unknown>,
describe: string,
pending: Promise<void>[],
revalidateAfterMs?: (stored: unknown) => number,
): Promise<Record<string, unknown> | null> {
try {
const raw = await getPrReadCache().fetch({
namespace: connectionId,
key: JSON.stringify({ name, args }),
revalidateAfterMs,
fetchLive: () =>
retry(
async () => {
Expand Down Expand Up @@ -867,6 +889,16 @@ async function fetchPrStatusExtras(
{ owner: pr.repoOwner, repo: pr.repoName, sha: headSha },
`${prLabel(pr)} (deployment preview)`,
pending,
// An in-flight deploy answers "no environment url yet". Holding that
// for the full read window means the card keeps rebuilding from a
// stale not-ready answer even once GitHub has the url, so the preview
// shows up minutes after the deploy finished.
(stored) =>
extractPreviewUrlFromDeployment(
stored as Record<string, unknown> | null,
) === null
? AWAITING_PREVIEW_REVALIDATE_MS
: PR_READS_CACHE.revalidateAfterMs,
),
);
}
Expand Down Expand Up @@ -1265,6 +1297,15 @@ export const TASK_BOARD_ITEM_PRS_GET = defineTool({
namespace: organizationId,
key: taskBoardItemId,
fetchLive: assemble,
// A card whose deploy is still running has no preview yet. At the default
// window that card is a cache HIT for 30s, so the url can be a poll or
// two late even after the read above refreshes. Go stale immediately
// instead: the revalidation is detached, so this costs a background
// rebuild per poll on exactly the cards that are still missing something.
revalidateAfterMs: (cards) =>
cards.some(isAwaitingPreview)
? AWAITING_PREVIEW_REVALIDATE_MS
: PR_CARDS_CACHE.revalidateAfterMs,
placeholder: linked.map((pr) => ({
url: pr.url,
number: pr.number,
Expand Down
Loading