From 945149d00ce66d2b118f6efcf5a28823b1940805 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:05:53 -0700 Subject: [PATCH] fix(selfhost): send conditional backfill requests on the scheduled cadence (#1942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1901 added ETag/If-Modified-Since validators for backfill segments, but fetchPagedSegment loaded the prior segment (and so built the conditional request) only when mode===resume. The scheduled cadence runs light/full, so the 304 fast-path never fired on the path that actually runs — every 30-min light crawl re-listed labels/open-issues/open-PRs at full cost. Load the prior segment for every mode so a light/full crawl sends If-None-Match and an unchanged single-page list returns a 0-body 304. Resume pagination stays gated on canResumePreviousScan; open-scan reconciliation segments still force allowEtag:false, so close-detection is unchanged. --- src/github/backfill.ts | 7 ++++++- test/unit/backfill.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/github/backfill.ts b/src/github/backfill.ts index 4a150ddc3a..bbce387f7d 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -1286,7 +1286,12 @@ async function fetchPagedSegment( supplementDescription?: string; } = {}, ): Promise<{ status: RepoSyncSegmentRecord["status"]; segment: RepoSyncSegmentRecord }> { - const previous = mode === "resume" ? await getRepoSyncSegment(env, repo.fullName, segmentName) : null; + // Load the prior segment for EVERY mode, not just resume (#1942): a scheduled light/full crawl can then send the + // stored ETag/If-Modified-Since as a conditional request, so an unchanged single-page list returns a 0-body 304 + // instead of a full re-list — the largest avoidable GitHub cost on the backfill cadence. Resume PAGINATION stays + // gated on `canResumePreviousScan` (mode === "resume") below, and the open-scan segments that must reconcile + // GitHub-side closes still force `allowEtag: false`, so this only enables the 304 fast-path where it is safe. + const previous = await getRepoSyncSegment(env, repo.fullName, segmentName); const requiresCurrentOpenScan = Boolean(options.reconcileOnComplete); const canResumePreviousScan = mode === "resume" && diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts index 1fc6ff03d2..2901273e4a 100644 --- a/test/unit/backfill.test.ts +++ b/test/unit/backfill.test.ts @@ -1283,6 +1283,41 @@ describe("GitHub backfill", () => { ); }); + it("validates unchanged single-page segments on the scheduled light cadence, not just resume (#1942)", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + const labelHeaders: Array<{ ifNoneMatch: string | null; ifModifiedSince: string | null }> = []; + let labelFetches = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") return githubTotalsResponse({ openIssues: 0, openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0, labels: 1 }); + if (url.includes("/labels?")) { + const headers = new Headers(init?.headers); + labelHeaders.push({ ifNoneMatch: headers.get("if-none-match"), ifModifiedSince: headers.get("if-modified-since") }); + labelFetches += 1; + if (labelFetches === 1) { + return Response.json([{ name: "bug", color: "cc0000", description: "Bug" }], { + headers: { etag: '"labels-v1"', "last-modified": "Tue, 26 May 2026 00:00:00 GMT" }, + }); + } + return new Response(null, { status: 304, headers: { etag: '"labels-v1"', "last-modified": "Tue, 26 May 2026 00:00:00 GMT" } }); + } + return new Response("not found", { status: 404 }); + }); + + const first = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "labels", mode: "light", force: true }); + const second = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "labels", mode: "light", force: true }); + + expect(first).toMatchObject({ status: "complete", fetchedCount: 1, expectedCount: 1 }); + // Before the fix, a light crawl loaded no prior segment, so the second pass sent no validators and re-listed in + // full ("complete"). The scheduled cadence now sends If-None-Match, and a 304 short-circuits to not_modified. + expect(second).toMatchObject({ status: "not_modified", fetchedCount: 1, expectedCount: 1 }); + expect(labelHeaders).toEqual([ + { ifNoneMatch: null, ifModifiedSince: null }, + { ifNoneMatch: '"labels-v1"', ifModifiedSince: "Tue, 26 May 2026 00:00:00 GMT" }, + ]); + }); + it("preserves stored validators when an unauthenticated fallback returns not modified without validators", async () => { const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); await seedRegisteredRepo(env);