Skip to content

Commit 92b3012

Browse files
committed
Add startReviewingRepos concurrency test wiring real ports
The lease store's own test proves the underlying compare-and-swap works in isolation; this proves startReviewingRepos itself settles on exactly one grant and one trigger when its ports are bound the way apps/hub/src/index.ts actually binds them -- a bare grant insert, a real RepoReviewLeaseStore, a real WebhookTriggerStore.ensure() -- racing two calls for the same repo the way a double-click or a client retrying an in-flight request would, against a scratch database.
1 parent 183db9a commit 92b3012

3 files changed

Lines changed: 227 additions & 1 deletion

File tree

‎bun.lock‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/workflow-catalog/package.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,16 @@
2525
"@intx/hub-api": "workspace:*",
2626
"@workbench/hub-client": "workspace:*",
2727
"arktype": "catalog:",
28-
"hono": "^4.11.9"
28+
"hono": "^4.11.9",
29+
"postgres": "catalog:"
2930
},
3031
"devDependencies": {
3132
"@corbits/workflow-freeze": "workspace:*",
33+
"@intx/crypto": "0.3.0",
34+
"@intx/db": "workspace:*",
3235
"@types/bun": "catalog:",
3336
"@workbench/connections": "workspace:*",
37+
"drizzle-orm": "catalog:",
3438
"typescript": "catalog:"
3539
}
3640
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// CL-7242: reconstructs the audit's own reproduction -- two concurrent
2+
// `startReviewingRepos` calls for the same repo -- against real,
3+
// database-backed ports rather than plain fakes. `hasRepoGrant`/
4+
// `mintRepoGrant` here fake a plain read-then-insert against the
5+
// `grant` table directly, standing in for `apps/hub/src/index.ts`'s
6+
// real binding (HTTP calls through `native-repo-grants.ts`, see
7+
// CL-7242 follow-up "Mint workbench tenants and repo grants via
8+
// Interchange HTTP") without a live hub-api server -- what this test
9+
// actually proves is that `startReviewingRepos`' lease serializes any
10+
// such hasRepoGrant/mintRepoGrant pair correctly, which is exactly
11+
// what makes the real HTTP-bound versions safe too. The lease store's
12+
// own test (`@corbits/webhook-triggers`'s
13+
// `repo-review-lease.drizzle.test.ts`) proves the underlying
14+
// compare-and-swap works; this proves `startReviewingRepos` itself
15+
// settles on exactly one grant and one trigger, racing the two calls
16+
// a double-click or a client retry would produce. DB-gated: skipped
17+
// when DATABASE_URL is unset. Runs against its own scratch database
18+
// (mirroring `@corbits/webhook-triggers`' own `store.drizzle.test.ts`),
19+
// never the developer's or the walking-skeleton suite's.
20+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
21+
import { drizzle } from "drizzle-orm/postgres-js";
22+
import { and, eq } from "drizzle-orm";
23+
import postgres from "postgres";
24+
25+
import { runMigrations } from "@intx/db";
26+
import * as intxSchema from "@intx/db/schema";
27+
import { grant as grantTable, role as roleTable } from "@intx/db/schema";
28+
import { createNoopCredentialCipher } from "@intx/crypto";
29+
import {
30+
createDrizzleRepoReviewLeaseStore,
31+
createDrizzleWebhookTriggerStore,
32+
applyWebhookTriggersMigrations,
33+
} from "@corbits/webhook-triggers";
34+
import type { GitHubRepoSummary } from "@corbits/github-tools";
35+
36+
import { e2eDatabaseUrl } from "../../../scripts/e2e/harness";
37+
import {
38+
startReviewingRepos,
39+
webhookTriggerName,
40+
type ConnectGithubSetupPorts,
41+
} from "../src/connect-github-setup";
42+
43+
function scratchUrlFor(e2eUrl: string): string {
44+
const url = new URL(e2eUrl);
45+
const database = url.pathname.replace(/^\//, "");
46+
url.pathname = `/${database}_connect_github_race_test`;
47+
return url.toString();
48+
}
49+
50+
const databaseUrl = e2eDatabaseUrl();
51+
const describeIfDb = databaseUrl === undefined ? describe.skip : describe;
52+
53+
const REPO: GitHubRepoSummary = {
54+
id: "1",
55+
name: "acme/widgets",
56+
};
57+
const TENANT_ID = "tnt_race";
58+
const DEFINITION_ID = "def_code_review";
59+
60+
describeIfDb("startReviewingRepos under real concurrency (CL-7242)", () => {
61+
const scratchUrl = scratchUrlFor(
62+
databaseUrl ?? "postgres://localhost:5432/unused",
63+
);
64+
const scratchTarget = new URL(scratchUrl);
65+
const scratchDatabase = scratchTarget.pathname.replace(/^\//, "");
66+
67+
beforeAll(async () => {
68+
const maintenanceUrl = new URL(scratchUrl);
69+
maintenanceUrl.pathname = "/postgres";
70+
const maintenance = postgres(maintenanceUrl.toString(), {
71+
max: 1,
72+
onnotice: () => undefined,
73+
});
74+
try {
75+
await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`);
76+
await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`);
77+
} finally {
78+
await maintenance.end();
79+
}
80+
const parsed = new URL(scratchUrl);
81+
await runMigrations(
82+
{
83+
host: parsed.hostname,
84+
port: Number(parsed.port || 5432),
85+
user: decodeURIComponent(parsed.username),
86+
password: decodeURIComponent(parsed.password),
87+
database: scratchDatabase,
88+
},
89+
{ schema: "public" },
90+
);
91+
await applyWebhookTriggersMigrations(scratchUrl);
92+
});
93+
94+
afterAll(async () => {
95+
const maintenanceUrl = new URL(scratchUrl);
96+
maintenanceUrl.pathname = "/postgres";
97+
const maintenance = postgres(maintenanceUrl.toString(), {
98+
max: 1,
99+
onnotice: () => undefined,
100+
});
101+
try {
102+
await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`);
103+
} finally {
104+
await maintenance.end();
105+
}
106+
});
107+
108+
test("two concurrent calls for the same repo mint exactly one grant and one trigger", async () => {
109+
const client = postgres(scratchUrl, {
110+
max: 10,
111+
onnotice: () => undefined,
112+
});
113+
try {
114+
const db = drizzle(client, { schema: intxSchema });
115+
const webhookStore = createDrizzleWebhookTriggerStore(
116+
db,
117+
createNoopCredentialCipher(),
118+
);
119+
const leaseStore = createDrizzleRepoReviewLeaseStore(db);
120+
121+
const roleId = "role_race_member";
122+
await client`INSERT INTO "tenant" (id, name, slug, domain) VALUES (${TENANT_ID}, 'Acme', 'acme-race', 'acme-race.example')`;
123+
await client`INSERT INTO "role" (id, tenant_id, name) VALUES (${roleId}, ${TENANT_ID}, 'member')`;
124+
125+
// Mirrors apps/hub/src/index.ts's real wiring exactly: the lease
126+
// is acquired first, and mintRepoGrant is a bare insert against
127+
// Interchange's own `grant` table -- no onConflict, no index of
128+
// ours on their table. Only the lease serializes the two racing
129+
// calls below.
130+
const buildPorts = (): ConnectGithubSetupPorts => ({
131+
acquireRepoReviewLease: (repo) =>
132+
leaseStore.acquire(TENANT_ID, repo.name),
133+
releaseRepoReviewLease: (repo) =>
134+
leaseStore.release(TENANT_ID, repo.name),
135+
hasRepoGrant: async (repo) => {
136+
const existing = await db.query.grant.findFirst({
137+
where: and(
138+
eq(grantTable.tenantId, TENANT_ID),
139+
eq(grantTable.resource, `repo:${repo.name}`),
140+
eq(grantTable.action, "read"),
141+
),
142+
columns: { id: true },
143+
});
144+
return existing !== undefined;
145+
},
146+
mintRepoGrant: async (repo) => {
147+
const memberRole = await db.query.role.findFirst({
148+
where: and(
149+
eq(roleTable.tenantId, TENANT_ID),
150+
eq(roleTable.name, "member"),
151+
),
152+
columns: { id: true },
153+
});
154+
if (memberRole === undefined) throw new Error("no member role");
155+
await db.insert(grantTable).values({
156+
id: `grant_${crypto.randomUUID()}`,
157+
tenantId: TENANT_ID,
158+
roleId: memberRole.id,
159+
resource: `repo:${repo.name}`,
160+
action: "read",
161+
effect: "allow",
162+
origin: "system",
163+
});
164+
},
165+
hasWebhookTrigger: async (repo) => {
166+
const triggers = await webhookStore.list(TENANT_ID);
167+
const name = webhookTriggerName(repo);
168+
return triggers.some(
169+
(t) => t.workflowDefinitionId === DEFINITION_ID && t.name === name,
170+
);
171+
},
172+
createWebhookTrigger: async (repo) => {
173+
const row = await webhookStore.ensure({
174+
id: `wht_${crypto.randomUUID()}`,
175+
tenantId: TENANT_ID,
176+
name: webhookTriggerName(repo),
177+
workflowDefinitionId: DEFINITION_ID,
178+
inputTemplate:
179+
"Review the pull request at {{pull_request.html_url}}",
180+
secret: crypto.randomUUID(),
181+
createdBy: "user_1",
182+
});
183+
return { id: row.id };
184+
},
185+
persistSelectedRepos: async () => {},
186+
});
187+
188+
const [first, second] = await Promise.all([
189+
startReviewingRepos(["1"], [REPO], buildPorts()),
190+
startReviewingRepos(["1"], [REPO], buildPorts()),
191+
]);
192+
193+
// Whether the second call gets skipped by the lease or simply
194+
// finds the work already done (CL-7134's fast path) depends on
195+
// real, non-deterministic timing between the two real Postgres
196+
// round trips -- both are correct outcomes. The property this
197+
// test actually cares about is that at most one trigger is ever
198+
// created, and the database never ends up with a duplicate.
199+
const totalCreated =
200+
first.createdTriggerIds.length + second.createdTriggerIds.length;
201+
expect(totalCreated).toBe(1);
202+
203+
const grantRows = await client`
204+
SELECT id FROM "grant"
205+
WHERE tenant_id = ${TENANT_ID} AND resource = 'repo:acme/widgets'
206+
`;
207+
expect(grantRows).toHaveLength(1);
208+
209+
const triggerRows = await client`
210+
SELECT id FROM "webhook_triggers"."webhook_trigger"
211+
WHERE tenant_id = ${TENANT_ID} AND workflow_definition_id = ${DEFINITION_ID}
212+
`;
213+
expect(triggerRows).toHaveLength(1);
214+
} finally {
215+
await client.end();
216+
}
217+
});
218+
});

0 commit comments

Comments
 (0)