1- import { eq , sql } from "drizzle-orm" ;
1+ import { and , eq , isNull , sql } from "drizzle-orm" ;
22import type { RunAnalysisReport , RunAnalysisUsageSnapshot } from "@shared/contracts" ;
33import type { Db } from "../db/client.js" ;
44import { 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+
2026function 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