@@ -301,9 +301,17 @@ pub struct CreateContext<'a> {
301301///
302302/// The two-job lifecycle (create-response, complete-response) can race —
303303/// they're enqueued within ~50ms of each other and run on independent
304- /// underway queues. This helper tolerates either ordering: if the UPDATE
305- /// finds nothing, we synthesize the row with the supplied context and
306- /// retry the UPDATE.
304+ /// underway queues. Worse, underway's heartbeat-based reclamation can
305+ /// produce zombie attempts: the original worker keeps running after
306+ /// underway has marked the attempt failed and started a fresh one, so
307+ /// two attempts may modify the same row concurrently.
308+ ///
309+ /// This helper tolerates all of those orderings:
310+ /// - missing row → synthesize, then complete
311+ /// - row already in `completed`/`failed`/`canceled` → idempotent success
312+ /// (some other writer — typically a zombie attempt or a duplicate
313+ /// enqueue — has already done our work)
314+ /// - row in `processing` → straight UPDATE
307315pub async fn complete_response_idempotent < P : PoolProvider + Clone > (
308316 request_manager : & PostgresRequestManager < P , ReqwestHttpClient > ,
309317 dwctl_pool : & sqlx:: PgPool ,
@@ -314,76 +322,99 @@ pub async fn complete_response_idempotent<P: PoolProvider + Clone>(
314322) -> Result < ( ) , StoreError > {
315323 let id = parse_response_id ( response_id) ?;
316324
325+ // Fast path: row exists in `processing` — UPDATE matches and we're done.
317326 match request_manager. complete_request ( RequestId ( id) , response_body, status_code) . await {
318- Ok ( ( ) ) => Ok ( ( ) ) ,
319- Err ( fusillade:: FusilladeError :: RequestNotFound ( _) ) => {
320- // create-response hasn't run yet (or failed). Synthesize the row.
321- // create-response may also be racing us — if it wins between our
322- // failed UPDATE and our INSERT, the INSERT will hit a PK conflict.
323- // Treat that as "create-response got there first" and just retry
324- // the UPDATE.
327+ Ok ( ( ) ) => return Ok ( ( ) ) ,
328+ Err ( fusillade:: FusilladeError :: RequestStateConflict { current_state, .. } ) if is_terminal ( & current_state) => {
325329 tracing:: info!(
326330 response_id = %response_id,
327- model = %create_ctx. model,
328- endpoint = %create_ctx. endpoint,
329- "complete-response synthesizing row (create-response hasn't run yet)"
331+ final_state = %current_state,
332+ "complete-response: row already in terminal state — idempotent success"
330333 ) ;
331- if create_ctx. endpoint . is_empty ( ) {
332- // We'd create a row with an empty endpoint — that's broken
333- // upstream (responses middleware should always set the
334- // x-onwards-endpoint header). Better to fail loudly than
335- // silently insert a row that's hard to find later.
336- return Err ( StoreError :: StorageError (
337- "Cannot synthesize request row: empty endpoint in CreateContext (x-onwards-endpoint header missing upstream)" . into ( ) ,
338- ) ) ;
339- }
340- let created_by = lookup_created_by ( dwctl_pool, create_ctx. api_key ) . await ;
341- let batch_input = fusillade:: CreateSingleRequestBatchInput {
342- batch_id : Some ( create_ctx. batch_id ) ,
343- request_id : create_ctx. request_id ,
344- body : create_ctx. request_body . to_string ( ) ,
345- model : create_ctx. model . to_string ( ) ,
346- base_url : create_ctx. base_url . to_string ( ) ,
347- endpoint : create_ctx. endpoint . to_string ( ) ,
348- completion_window : "0s" . to_string ( ) ,
349- initial_state : "processing" . to_string ( ) ,
350- api_key : create_ctx. api_key . map ( String :: from) ,
351- created_by,
352- } ;
353- match request_manager. create_single_request_batch ( batch_input) . await {
354- Ok ( _) => {
355- tracing:: info!(
356- response_id = %response_id,
357- "Synthetic create from complete-response succeeded — row now exists in 'processing'"
358- ) ;
359- }
360- Err ( e) => {
361- // Don't fail loudly here — the next UPDATE attempt is the
362- // ground truth. If the row exists (we lost the race to
363- // create), UPDATE will succeed.
364- tracing:: info!(
365- response_id = %response_id,
366- error = %e,
367- "Synthetic create from complete-response failed (likely create-response won the race) — proceeding to UPDATE"
368- ) ;
369- }
370- }
334+ return Ok ( ( ) ) ;
335+ }
336+ Err ( fusillade:: FusilladeError :: RequestStateConflict { current_state, .. } ) => {
337+ // Row exists in some non-terminal, non-processing state (e.g.
338+ // 'pending' or 'claimed'). This shouldn't happen for the realtime
339+ // path, but it's not our place to force-complete. Bubble up.
340+ return Err ( StoreError :: StorageError ( format ! (
341+ "Row exists for response {response_id} in unexpected state '{current_state}'"
342+ ) ) ) ;
343+ }
344+ Err ( fusillade:: FusilladeError :: RequestNotFound ( _) ) => { } // synthesize below
345+ Err ( e) => return Err ( StoreError :: StorageError ( format ! ( "Failed to complete request: {e}" ) ) ) ,
346+ }
371347
372- match request_manager. complete_request ( RequestId ( id) , response_body, status_code) . await {
373- Ok ( ( ) ) => {
374- tracing:: info!( response_id = %response_id, "Second-attempt UPDATE succeeded — row now 'completed'" ) ;
375- Ok ( ( ) )
376- }
377- Err ( e) => {
378- tracing:: warn!( response_id = %response_id, error = %e, "Second-attempt UPDATE failed" ) ;
379- Err ( StoreError :: StorageError ( format ! ( "Failed to complete after create: {e}" ) ) )
380- }
381- }
348+ // Row doesn't exist — synthesize it. create-response may race us; if it
349+ // wins between our failed UPDATE and our INSERT, the INSERT hits a PK
350+ // conflict and the retry UPDATE below sorts it out.
351+ tracing:: info!(
352+ response_id = %response_id,
353+ model = %create_ctx. model,
354+ endpoint = %create_ctx. endpoint,
355+ "complete-response synthesizing row (create-response hasn't run yet)"
356+ ) ;
357+ if create_ctx. endpoint . is_empty ( ) {
358+ // Empty endpoint means an upstream header is missing; better to fail
359+ // loudly than silently insert a row the /responses lookup can't find.
360+ return Err ( StoreError :: StorageError (
361+ "Cannot synthesize request row: empty endpoint in CreateContext (x-onwards-endpoint header missing upstream)" . into ( ) ,
362+ ) ) ;
363+ }
364+ let created_by = lookup_created_by ( dwctl_pool, create_ctx. api_key ) . await ;
365+ let batch_input = fusillade:: CreateSingleRequestBatchInput {
366+ batch_id : Some ( create_ctx. batch_id ) ,
367+ request_id : create_ctx. request_id ,
368+ body : create_ctx. request_body . to_string ( ) ,
369+ model : create_ctx. model . to_string ( ) ,
370+ base_url : create_ctx. base_url . to_string ( ) ,
371+ endpoint : create_ctx. endpoint . to_string ( ) ,
372+ completion_window : "0s" . to_string ( ) ,
373+ initial_state : "processing" . to_string ( ) ,
374+ api_key : create_ctx. api_key . map ( String :: from) ,
375+ created_by,
376+ } ;
377+ match request_manager. create_single_request_batch ( batch_input) . await {
378+ Ok ( _) => tracing:: info!(
379+ response_id = %response_id,
380+ "Synthetic create from complete-response succeeded — row now exists in 'processing'"
381+ ) ,
382+ Err ( e) => tracing:: info!(
383+ response_id = %response_id,
384+ error = %e,
385+ "Synthetic create from complete-response failed (likely create-response won the race) — proceeding to UPDATE"
386+ ) ,
387+ }
388+
389+ // Retry the UPDATE. Same idempotency rules as the fast path: another
390+ // writer may have raced ahead to a terminal state in the window between
391+ // our first UPDATE and this retry.
392+ match request_manager. complete_request ( RequestId ( id) , response_body, status_code) . await {
393+ Ok ( ( ) ) => {
394+ tracing:: info!( response_id = %response_id, "Second-attempt UPDATE succeeded — row now 'completed'" ) ;
395+ Ok ( ( ) )
396+ }
397+ Err ( fusillade:: FusilladeError :: RequestStateConflict { current_state, .. } ) if is_terminal ( & current_state) => {
398+ tracing:: info!(
399+ response_id = %response_id,
400+ final_state = %current_state,
401+ "complete-response: row already terminal after synthesis — idempotent success"
402+ ) ;
403+ Ok ( ( ) )
404+ }
405+ Err ( e) => {
406+ tracing:: warn!( response_id = %response_id, error = %e, "Second-attempt UPDATE failed" ) ;
407+ Err ( StoreError :: StorageError ( format ! ( "Failed to complete after create: {e}" ) ) )
382408 }
383- Err ( e) => Err ( StoreError :: StorageError ( format ! ( "Failed to complete request: {e}" ) ) ) ,
384409 }
385410}
386411
412+ /// True when the row has reached a state where re-completing it would be a
413+ /// no-op — there's nothing left for us to do.
414+ fn is_terminal ( state : & str ) -> bool {
415+ matches ! ( state, "completed" | "failed" | "canceled" )
416+ }
417+
387418/// Poll a fusillade request until it reaches a terminal state (completed/failed/canceled).
388419pub async fn poll_until_complete < P : PoolProvider + Clone > (
389420 request_manager : & PostgresRequestManager < P , ReqwestHttpClient > ,
0 commit comments