Skip to content

Commit 90fbc57

Browse files
committed
discovery-index(query): stop caching a GitHub-failure-truncated candidate set for the full TTL and serving it as complete
Fixes #10031
1 parent 79d7e03 commit 90fbc57

2 files changed

Lines changed: 85 additions & 6 deletions

File tree

packages/discovery-index/src/discovery-query.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,17 @@ function surfaceGithubWarnings(source: string, warnings: string[]): void {
147147
}
148148
}
149149

150-
async function computeCandidates(query: DiscoveryIndexQuery, deps: DiscoveryQueryDeps): Promise<DiscoveryIndexCandidate[]> {
150+
interface ComputeCandidatesResult {
151+
candidates: DiscoveryIndexCandidate[];
152+
/** False when any fetchRepoIssues/searchIssues call returned warnings — an incomplete live snapshot is
153+
* inconclusive, not evidence, and must not be promoted into the shared result cache. */
154+
complete: boolean;
155+
}
156+
157+
async function computeCandidates(query: DiscoveryIndexQuery, deps: DiscoveryQueryDeps): Promise<ComputeCandidatesResult> {
151158
const seen = new Set<string>();
152159
const candidates: DiscoveryIndexCandidate[] = [];
160+
let complete = true;
153161

154162
const addCandidate = (repoFullName: string, issue: GitHubIssue, verdict: AiPolicyVerdict): void => {
155163
const candidate = buildCandidate(repoFullName, issue, verdict);
@@ -174,28 +182,31 @@ async function computeCandidates(query: DiscoveryIndexQuery, deps: DiscoveryQuer
174182
const verdict = await resolveRepoAiPolicy(repoFullName, deps);
175183
if (!verdict.allowed) continue;
176184
const { issues, warnings } = await deps.github.fetchRepoIssues(repoFullName);
185+
if (warnings.length > 0) complete = false;
177186
surfaceGithubWarnings(repoFullName, warnings);
178187
for (const issue of issues) addCandidate(repoFullName, issue, verdict);
179188
}
180189

181190
for (const org of query.orgs) {
182191
const searchQuery = `org:${org} state:open type:issue`;
183192
const { issues, warnings } = await deps.github.searchIssues(searchQuery);
193+
if (warnings.length > 0) complete = false;
184194
surfaceGithubWarnings(searchQuery, warnings);
185195
await addFromSearch(issues);
186196
}
187197

188198
for (const term of query.searchTerms) {
189199
const searchQuery = `${term} state:open type:issue`;
190200
const { issues, warnings } = await deps.github.searchIssues(searchQuery);
201+
if (warnings.length > 0) complete = false;
191202
surfaceGithubWarnings(searchQuery, warnings);
192203
await addFromSearch(issues);
193204
}
194205

195206
// Deterministic ordering so pagination offsets are stable across a cache lifetime (and identical for two
196-
// requests that happen to race a cache miss — see computeCandidates' getOrCompute caller).
207+
// requests that happen to race a cache miss — see runDiscoveryQuery's result-cache caller).
197208
candidates.sort((a, b) => (a.repoFullName === b.repoFullName ? a.issueNumber - b.issueNumber : a.repoFullName.localeCompare(b.repoFullName)));
198-
return candidates;
209+
return { candidates, complete };
199210
}
200211

201212
/**
@@ -210,10 +221,17 @@ async function computeCandidates(query: DiscoveryIndexQuery, deps: DiscoveryQuer
210221
export async function runDiscoveryQuery(query: DiscoveryIndexQuery, deps: DiscoveryQueryDeps): Promise<DiscoveryIndexResponse> {
211222
const scopeKey = scopeCacheKey(query);
212223
let missed = false;
213-
const allCandidates = await deps.resultCache.getOrCompute(scopeKey, deps.cacheTtlMs, () => {
224+
let allCandidates = deps.resultCache.get(scopeKey);
225+
if (allCandidates === undefined) {
214226
missed = true;
215-
return computeCandidates(query, deps);
216-
});
227+
const computed = await computeCandidates(query, deps);
228+
allCandidates = computed.candidates;
229+
// An incomplete live snapshot is inconclusive, not evidence — return it to this caller but never
230+
// promote it into the shared result cache (mirrors listMigrationFilenamesAtRef's truncated-tree posture).
231+
if (computed.complete) {
232+
deps.resultCache.set(scopeKey, allCandidates, deps.cacheTtlMs);
233+
}
234+
}
217235
incr("discovery_index_cache_lookups_total", { cache: "result", outcome: missed ? "miss" : "hit" });
218236
const offset = decodeCursor(query.cursor);
219237
const page = allCandidates.slice(offset, offset + query.limit);

test/unit/discovery-index/discovery-query.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,4 +359,65 @@ describe("discovery-index runDiscoveryQuery (#7164)", () => {
359359
await runDiscoveryQuery(query({ repos: ["acme/one"] }), makeDeps(github));
360360
expect(errorSpy).not.toHaveBeenCalled();
361361
});
362+
363+
it("caches a complete pass and serves the second identical query from the result cache", async () => {
364+
const { github, calls } = makeStubGitHub({
365+
issuesByRepo: { "owner/repo": [{ number: 42, title: "Cached issue" }] },
366+
filesByRepo: { "owner/repo": { "AI-USAGE.md": ALLOWED_AI_USAGE } },
367+
});
368+
const deps = makeDeps(github);
369+
const first = await runDiscoveryQuery(query({ repos: ["owner/repo"] }), deps);
370+
const second = await runDiscoveryQuery(query({ repos: ["owner/repo"] }), deps);
371+
expect(calls.filter((c) => c.method === "fetchRepoIssues")).toHaveLength(1);
372+
expect(second).toEqual(first);
373+
expect(first.candidates).toHaveLength(1);
374+
expect(first.candidates[0]?.issueNumber).toBe(42);
375+
});
376+
377+
it("REGRESSION: a GitHub-failure-truncated candidate set is not cached for the TTL", async () => {
378+
const oneIssue: GitHubIssue = { number: 1, title: "Partial page" };
379+
const { github, calls } = makeStubGitHub({
380+
issuesByRepo: { "owner/repo": [oneIssue] },
381+
warningsByRepo: { "owner/repo": ["GitHub returned 500 for owner/repo issues"] },
382+
filesByRepo: { "owner/repo": { "AI-USAGE.md": ALLOWED_AI_USAGE } },
383+
});
384+
const deps = makeDeps(github);
385+
const first = await runDiscoveryQuery(query({ repos: ["owner/repo"] }), deps);
386+
const second = await runDiscoveryQuery(query({ repos: ["owner/repo"] }), deps);
387+
expect(calls.filter((c) => c.method === "fetchRepoIssues")).toHaveLength(2);
388+
expect(first.candidates).toHaveLength(1);
389+
expect(second.candidates).toEqual(first.candidates);
390+
expect(counterValue("discovery_index_cache_lookups_total", { cache: "result", outcome: "miss" })).toBe(2);
391+
expect(counterValue("discovery_index_cache_lookups_total", { cache: "result", outcome: "hit" })).toBe(0);
392+
});
393+
394+
it("does not cache org-search results when searchIssues returns warnings", async () => {
395+
const { github, calls } = makeStubGitHub({
396+
searchResults: {
397+
"org:acme state:open type:issue": [{ number: 7, title: "Org hit", repository_url: "https://api.github.com/repos/acme/one" }],
398+
},
399+
warningsBySearch: { "org:acme state:open type:issue": ["GitHub returned 500 for search"] },
400+
filesByRepo: { "acme/one": { "AI-USAGE.md": ALLOWED_AI_USAGE } },
401+
});
402+
const deps = makeDeps(github);
403+
const q = query({ orgs: ["acme"] });
404+
await runDiscoveryQuery(q, deps);
405+
await runDiscoveryQuery(q, deps);
406+
expect(calls.filter((c) => c.method === "searchIssues")).toHaveLength(2);
407+
});
408+
409+
it("does not cache search-term results when searchIssues returns warnings", async () => {
410+
const { github, calls } = makeStubGitHub({
411+
searchResults: {
412+
"flaky test state:open type:issue": [{ number: 9, title: "Term hit", repository_url: "https://api.github.com/repos/acme/one" }],
413+
},
414+
warningsBySearch: { "flaky test state:open type:issue": ["GitHub returned 503 for search"] },
415+
filesByRepo: { "acme/one": { "AI-USAGE.md": ALLOWED_AI_USAGE } },
416+
});
417+
const deps = makeDeps(github);
418+
const q = query({ searchTerms: ["flaky test"] });
419+
await runDiscoveryQuery(q, deps);
420+
await runDiscoveryQuery(q, deps);
421+
expect(calls.filter((c) => c.method === "searchIssues")).toHaveLength(2);
422+
});
362423
});

0 commit comments

Comments
 (0)