Skip to content

Commit 6738fe1

Browse files
committed
fix(db): repair legacy reviews_synced_at markers stamped on failed syncs
A second AI review pass flagged a real backward-compatibility defect in the same PR: every pre-existing writer of reviews_synced_at (backfillOpenPullRequestDetails / refreshPullRequestDetails / backfillRepository) stamped it unconditionally on every sync pass, success or failure -- confirmed by diffing against origin/main, where `reviewsSyncedAt: syncedAt` is written with no success gate at all. This PR's new durable review cache (fetchAndStorePullRequestDetails's reviewsUpToDate check) treats ANY non-null reviews_synced_at as "fully synced, never refetch" until a pull_request_review webhook invalidates it. Existing rows whose review sync previously failed (rate limit, transient error) already carry a timestamp from that failed attempt, so the new cache would trust incomplete/stale review data indefinitely for any PR that doesn't happen to receive a new review action after this deploys. Added migration 0095: a one-time UPDATE clearing reviews_synced_at on every existing row. There's no reliable way to tell a trustworthy stamp from an untrustworthy one from the row alone (status reflects the aggregate files/reviews/checks outcome for a pass, not reviews specifically), so it clears unconditionally rather than guessing -- the cost is one redundant review refetch per already-correctly-synced PR, which is cheap and bounded. Pinned the migration's exact behavior with a dedicated regression test (mirroring the existing digest-subscription-normalization.test.ts pattern: apply the real migration files against a raw in-memory SQLite DB, seed legacy-shaped rows, assert only reviews_synced_at clears and everything else is untouched). Rebased again to keep pace with main.
1 parent 9517351 commit 6738fe1

2 files changed

Lines changed: 168 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- One-off repair for pull_request_detail_sync_state.reviews_synced_at (#2537 review fix).
2+
--
3+
-- ROOT CAUSE (already fixed in code, this same PR): every pre-existing writer of reviews_synced_at
4+
-- (backfillOpenPullRequestDetails / refreshPullRequestDetails / backfillRepository, all in
5+
-- src/github/backfill.ts) stamped it UNCONDITIONALLY on every sync pass, success or failure -- even a pass that
6+
-- ended with `status: 'partial'` due to a review-fetch error still wrote a fresh reviews_synced_at timestamp, as
7+
-- if the reviews had actually been captured. The column now gates a durable, head-independent read-through
8+
-- cache (fetchAndStorePullRequestDetails' reviewsUpToDate check): ANY non-null reviews_synced_at is trusted as
9+
-- "reviews are fully synced, skip refetching" until a `pull_request_review` webhook invalidates it.
10+
--
11+
-- Impact of leaving existing rows as-is: a PR whose review sync previously failed (rate limit, transient
12+
-- network error, etc.) already has a reviews_synced_at timestamp from that failed attempt. The new cache would
13+
-- treat that PR's reviews as permanently up to date -- silently serving incomplete/stale review data forever,
14+
-- unless that specific PR happens to receive a NEW review action after this deploys (the only thing that clears
15+
-- the marker going forward).
16+
--
17+
-- Repair strategy: there is no reliable way to tell, from the stored row alone, whether a PARTICULAR existing
18+
-- reviews_synced_at value came from a pass where reviews specifically succeeded (status reflects the aggregate
19+
-- outcome across files/reviews/checks together, not reviews alone). Clear it for every row instead of trying to
20+
-- guess -- the one-time cost is a single redundant review refetch for PRs that were already correctly synced,
21+
-- which is cheap and bounded; the alternative (leaving any ambiguous row as-is) risks silently trusting bad data
22+
-- indefinitely. The next sync pass for every tracked PR re-populates it correctly under the new,
23+
-- only-stamp-on-success semantics.
24+
UPDATE pull_request_detail_sync_state
25+
SET reviews_synced_at = NULL
26+
WHERE reviews_synced_at IS NOT NULL;
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { readFileSync } from "node:fs";
2+
import { dirname, join } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { DatabaseSync } from "node:sqlite";
5+
import { describe, expect, it } from "vitest";
6+
7+
// Regression coverage for migrations/0095, the one-time repair for pull_request_detail_sync_state.reviews_synced_at
8+
// (#2595 review fix). Every pre-existing writer (backfillOpenPullRequestDetails / refreshPullRequestDetails /
9+
// backfillRepository, all in src/github/backfill.ts) stamped this column UNCONDITIONALLY on every sync pass,
10+
// success or failure -- so an existing row's marker cannot be trusted once the new durable review cache starts
11+
// treating ANY non-null value as "reviews are fully synced, never refetch." These tests pin the repair
12+
// migration's exact behavior against the REAL migration file so a future edit can't silently narrow or widen
13+
// what gets cleared.
14+
const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "migrations");
15+
const migrationSql = (name: string) => readFileSync(join(migrationsDir, name), "utf8");
16+
17+
type Row = {
18+
id: string;
19+
status: string;
20+
files_synced_at: string | null;
21+
reviews_synced_at: string | null;
22+
checks_synced_at: string | null;
23+
head_sha: string | null;
24+
};
25+
26+
function freshDb(): DatabaseSync {
27+
const db = new DatabaseSync(":memory:");
28+
db.exec(migrationSql("0006_open_data_completeness.sql"));
29+
db.exec(migrationSql("0092_pull_request_detail_sync_head_sha.sql"));
30+
db.exec(migrationSql("0094_pull_request_detail_sync_pr_state.sql"));
31+
return db;
32+
}
33+
34+
function insert(
35+
db: DatabaseSync,
36+
row: {
37+
id: string;
38+
repo_full_name: string;
39+
pull_number: number;
40+
status?: string;
41+
files_synced_at?: string | null;
42+
reviews_synced_at?: string | null;
43+
checks_synced_at?: string | null;
44+
head_sha?: string | null;
45+
},
46+
): void {
47+
db.prepare(
48+
"INSERT INTO pull_request_detail_sync_state (id, repo_full_name, pull_number, status, files_synced_at, reviews_synced_at, checks_synced_at, head_sha) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
49+
).run(
50+
row.id,
51+
row.repo_full_name,
52+
row.pull_number,
53+
row.status ?? "never_synced",
54+
row.files_synced_at ?? null,
55+
row.reviews_synced_at ?? null,
56+
row.checks_synced_at ?? null,
57+
row.head_sha ?? null,
58+
);
59+
}
60+
61+
function allRows(db: DatabaseSync): Row[] {
62+
return db
63+
.prepare("SELECT id, status, files_synced_at, reviews_synced_at, checks_synced_at, head_sha FROM pull_request_detail_sync_state ORDER BY id")
64+
.all() as unknown as Row[];
65+
}
66+
67+
describe("0095 reviews_synced_at repair migration", () => {
68+
it("clears reviews_synced_at on every row that has it set, regardless of status, while leaving OTHER columns untouched", () => {
69+
const db = freshDb();
70+
// A "complete" row (reviews genuinely succeeded on the pass that set this) -- still cleared: there is no
71+
// reliable way to tell a trustworthy stamp from an untrustworthy one from the row alone (status reflects the
72+
// aggregate files/reviews/checks outcome, not reviews specifically), so the migration clears unconditionally
73+
// by design rather than guessing.
74+
insert(db, {
75+
id: "p1",
76+
repo_full_name: "o/r",
77+
pull_number: 1,
78+
status: "complete",
79+
files_synced_at: "2026-01-01T00:00:00Z",
80+
reviews_synced_at: "2026-01-01T00:00:00Z",
81+
checks_synced_at: "2026-01-01T00:00:00Z",
82+
head_sha: "sha1",
83+
});
84+
// A "partial" row -- the exact scenario the bug is about: reviews_synced_at was stamped even though this
85+
// pass (files, reviews, or checks) had a failure.
86+
insert(db, {
87+
id: "p2",
88+
repo_full_name: "o/r",
89+
pull_number: 2,
90+
status: "partial",
91+
files_synced_at: "2026-01-02T00:00:00Z",
92+
reviews_synced_at: "2026-01-02T00:00:00Z",
93+
checks_synced_at: null,
94+
head_sha: "sha2",
95+
});
96+
// A row that never had reviews synced at all -- must remain untouched (already NULL, nothing to clear).
97+
insert(db, { id: "p3", repo_full_name: "o/r", pull_number: 3, status: "never_synced" });
98+
99+
db.exec(migrationSql("0095_repair_reviews_synced_at.sql"));
100+
101+
const rows = allRows(db);
102+
expect(rows.find((r) => r.id === "p1")).toMatchObject({
103+
reviews_synced_at: null,
104+
files_synced_at: "2026-01-01T00:00:00Z",
105+
checks_synced_at: "2026-01-01T00:00:00Z",
106+
head_sha: "sha1",
107+
status: "complete",
108+
});
109+
expect(rows.find((r) => r.id === "p2")).toMatchObject({
110+
reviews_synced_at: null,
111+
files_synced_at: "2026-01-02T00:00:00Z",
112+
checks_synced_at: null,
113+
head_sha: "sha2",
114+
status: "partial",
115+
});
116+
expect(rows.find((r) => r.id === "p3")).toMatchObject({
117+
reviews_synced_at: null,
118+
files_synced_at: null,
119+
checks_synced_at: null,
120+
head_sha: null,
121+
status: "never_synced",
122+
});
123+
});
124+
125+
it("is a no-op on a table with no rows", () => {
126+
const db = freshDb();
127+
128+
expect(() => db.exec(migrationSql("0095_repair_reviews_synced_at.sql"))).not.toThrow();
129+
130+
expect(allRows(db)).toEqual([]);
131+
});
132+
133+
it("is idempotent — running it twice has the same effect as running it once", () => {
134+
const db = freshDb();
135+
insert(db, { id: "p1", repo_full_name: "o/r", pull_number: 1, status: "complete", reviews_synced_at: "2026-01-01T00:00:00Z" });
136+
137+
db.exec(migrationSql("0095_repair_reviews_synced_at.sql"));
138+
139+
expect(() => db.exec(migrationSql("0095_repair_reviews_synced_at.sql"))).not.toThrow();
140+
expect(allRows(db).find((r) => r.id === "p1")?.reviews_synced_at).toBeNull();
141+
});
142+
});

0 commit comments

Comments
 (0)