Skip to content

Commit 10501d8

Browse files
authored
Merge branch 'main' into feat/miner-ci-poller-2323
2 parents a72bd1a + 84b9a50 commit 10501d8

2 files changed

Lines changed: 37 additions & 1 deletion

File tree

src/db/repositories.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3071,8 +3071,15 @@ export async function upsertPullRequestFile(env: Env, file: PullRequestFileRecor
30713071
payloadJson: jsonString(file.payload),
30723072
updatedAt: nowIso(),
30733073
})
3074+
// Target the PRIMARY KEY, not the (repoFullName, pullNumber, path) unique index it's derived from. `id`
3075+
// is a pure function of those same 3 fields, so under a single execution the two are always in lockstep —
3076+
// but on the self-host Postgres backend, ON CONFLICT only protects against a race on the SPECIFIED arbiter
3077+
// index; a genuinely concurrent second writer (e.g. two overlapping detail-sync passes for the same PR,
3078+
// both racing past the "no existing row yet" check) can still hit a raw duplicate-key error on `id` because
3079+
// that constraint isn't the one Postgres is arbitrating. Targeting `id` directly makes Postgres's upsert
3080+
// machinery cover the constraint that's actually racing.
30743081
.onConflictDoUpdate({
3075-
target: [pullRequestFiles.repoFullName, pullRequestFiles.pullNumber, pullRequestFiles.path],
3082+
target: pullRequestFiles.id,
30763083
set: {
30773084
status: file.status,
30783085
additions: file.additions,

test/unit/db-persistence.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,35 @@ describe("database persistence helpers", () => {
193193
const cappedPathSet = new Set(cappedPaths.map((entry) => entry.path));
194194
expect(graph.paths.every((entry) => cappedPathSet.has(entry.path))).toBe(true);
195195
});
196+
197+
it("REGRESSION: upsertPullRequestFile targets the id PRIMARY KEY in its ON CONFLICT clause, not the secondary unique index", async () => {
198+
// `id` is a pure function of (repoFullName, pullNumber, path) — the exact same fields the secondary
199+
// unique index covers — so targeting that index instead of `id` leaves the primary key unprotected by
200+
// Postgres's upsert machinery on the self-host Postgres backend (see #977's pg-adapter): a genuinely
201+
// concurrent second writer can still raise a raw duplicate-key error on `pull_request_files_pkey` even
202+
// though the composite fields "agree." Asserting the generated SQL's conflict target pins the fix so a
203+
// future revert back to the composite target doesn't silently reopen the race.
204+
const env = createTestEnv();
205+
const realPrepare = env.DB.prepare.bind(env.DB);
206+
const conflictClauses: string[] = [];
207+
env.DB.prepare = ((sql: string) => {
208+
if (/insert\s+into\s+["'`]?pull_request_files/i.test(sql)) {
209+
const match = /on\s+conflict\s*\(([^)]*)\)/i.exec(sql);
210+
if (match) conflictClauses.push(match[1]!.trim());
211+
}
212+
return realPrepare(sql);
213+
}) as typeof env.DB.prepare;
214+
215+
await upsertPullRequestFile(env, pullRequestFile("owner/repo", 1, "src/a.ts"));
216+
217+
expect(conflictClauses).toHaveLength(1);
218+
expect(conflictClauses[0]).toMatch(/^["'`]?(pull_request_files["'`]?\.["'`]?)?id["'`]?$/i);
219+
220+
// Functional guard: a same-key upsert still updates the existing row in place rather than duplicating it.
221+
await upsertPullRequestFile(env, { ...pullRequestFile("owner/repo", 1, "src/a.ts"), additions: 99 });
222+
const rows = await listRepoPullRequestFilePaths(env, "owner/repo", { pullNumbers: [1] });
223+
expect(rows).toHaveLength(1);
224+
});
196225
});
197226

198227
function pullRequestFile(repoFullName: string, pullNumber: number, path: string): PullRequestFileRecord {

0 commit comments

Comments
 (0)