Skip to content

Commit f4e487b

Browse files
committed
fix(worker): persist analysis reports with neon http
1 parent 0d4a050 commit f4e487b

2 files changed

Lines changed: 63 additions & 15 deletions

File tree

apps/worker/src/run-analysis/store.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,30 @@ describe("run analysis report store", () => {
2828
expect((await getRunAnalysisReport(db, value.runId))?.jira.research.state).toBe("pending");
2929
});
3030

31+
it("persists through a driver without interactive transaction support", async () => {
32+
const noTransactionDb = new Proxy(db, {
33+
get(target, property, receiver) {
34+
if (property === "transaction") {
35+
return async () => {
36+
throw new Error("No transactions support in neon-http driver");
37+
};
38+
}
39+
return Reflect.get(target, property, receiver);
40+
},
41+
});
42+
const value = report();
43+
44+
await db.insert(workflowRuns).values({
45+
runId: value.runId,
46+
workflowId: "wf_agent",
47+
workflowName: "Agent",
48+
analysisReport: null,
49+
});
50+
await expect(recordRunAnalysisReport(noTransactionDb, value)).resolves.toBeUndefined();
51+
await expect(recordRunAnalysisReport(noTransactionDb, value)).resolves.toBeUndefined();
52+
expect(await getRunAnalysisReport(db, value.runId)).toEqual(value);
53+
});
54+
3155
it("finalizes only the final usage slot", async () => {
3256
const value = report();
3357
await recordRunAnalysisReport(db, value);

apps/worker/src/run-analysis/store.ts

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { eq, sql } from "drizzle-orm";
1+
import { and, eq, isNull, sql } from "drizzle-orm";
22
import type { RunAnalysisReport, RunAnalysisUsageSnapshot } from "@shared/contracts";
33
import type { Db } from "../db/client.js";
44
import { workflowRuns } from "../db/schema.js";
@@ -17,6 +17,12 @@ const deliveryRank = {
1717
posted: 3,
1818
} as const;
1919

20+
const MAX_REPORT_WRITE_ATTEMPTS = 8;
21+
22+
function jsonbValue(value: unknown) {
23+
return sql`${JSON.stringify(value ?? null)}::jsonb`;
24+
}
25+
2026
function newerDelivery(
2127
current: RunAnalysisReport["jira"]["research"],
2228
incoming: RunAnalysisReport["jira"]["research"],
@@ -70,11 +76,11 @@ export async function recordRunAnalysisReport(
7076
db: Db,
7177
report: RunAnalysisReport,
7278
): Promise<void> {
73-
await db.transaction(async (tx) => {
74-
// The advisory transaction lock also serializes the first insert, where no
75-
// workflow_runs row exists yet for SELECT ... FOR UPDATE to lock.
76-
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${report.runId}, 0))`);
77-
const [row] = await tx
79+
// neon-http does not support interactive transactions. Compare-and-swap the
80+
// exact JSONB snapshot that was merged so a concurrent writer cannot be
81+
// overwritten; a conflicting insert follows the same retry path.
82+
for (let attempt = 0; attempt < MAX_REPORT_WRITE_ATTEMPTS; attempt += 1) {
83+
const [row] = await db
7884
.select({ analysisReport: workflowRuns.analysisReport })
7985
.from(workflowRuns)
8086
.where(eq(workflowRuns.runId, report.runId))
@@ -83,22 +89,40 @@ export async function recordRunAnalysisReport(
8389
parseStoredRunAnalysisReport(row?.analysisReport),
8490
report,
8591
);
86-
await tx
92+
if (row) {
93+
const snapshotCondition = row.analysisReport === null
94+
? isNull(workflowRuns.analysisReport)
95+
: sql`${workflowRuns.analysisReport} is not distinct from ${jsonbValue(row.analysisReport)}`;
96+
const [updated] = await db
97+
.update(workflowRuns)
98+
.set({ analysisReport: merged, updatedAt: sql`now()` })
99+
.where(
100+
and(
101+
eq(workflowRuns.runId, report.runId),
102+
snapshotCondition,
103+
),
104+
)
105+
.returning({ runId: workflowRuns.runId });
106+
if (updated) return;
107+
continue;
108+
}
109+
110+
const [inserted] = await db
87111
.insert(workflowRuns)
88112
.values({
89113
runId: report.runId,
90114
workflowId: "wf_agent",
91115
workflowName: "Agent",
92116
analysisReport: merged,
93117
})
94-
.onConflictDoUpdate({
95-
target: workflowRuns.runId,
96-
set: {
97-
analysisReport: merged,
98-
updatedAt: sql`now()`,
99-
},
100-
});
101-
});
118+
.onConflictDoNothing({ target: workflowRuns.runId })
119+
.returning({ runId: workflowRuns.runId });
120+
if (inserted) return;
121+
}
122+
123+
throw new Error(
124+
`Could not persist run analysis report for ${report.runId} after ${MAX_REPORT_WRITE_ATTEMPTS} attempts`,
125+
);
102126
}
103127

104128
/** Read JSONB through the structural parser; callers never trust a cast from

0 commit comments

Comments
 (0)