Skip to content

Commit 9643136

Browse files
committed
fix(responses): make complete-response idempotent against terminal-state rows
The complete-response underway job returned `Failed to complete after create: Request not found` whenever the fusillade row had been moved out of `processing` state by another writer — typically a zombie task attempt that kept executing after underway reclaimed it on heartbeat loss, or a duplicate enqueue. The "request not found" message was misleading: the row was actually in `completed` state already. `complete_response_idempotent` now matches the new `FusilladeError::RequestStateConflict` variant explicitly: when the row is in a terminal state (`completed` / `failed` / `canceled`) we log it and return success, since whatever needed doing has already been done. Non-terminal unexpected states (`pending` / `claimed`, which shouldn't happen on the realtime path) still surface as errors. Requires fusillade 16.9.0 for the new error variant.
1 parent fa79316 commit 9643136

3 files changed

Lines changed: 106 additions & 77 deletions

File tree

Cargo.lock

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

dwctl/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ embedded-db = ["dep:postgresql_embedded"]
1919

2020
[dependencies]
2121
axum = { version = "0.8", features = ["multipart"] }
22-
fusillade = { version = "16.8.0" }
22+
fusillade = { version = "16.9.0" }
2323
tokio = { version = "1.0", features = ["full"] }
2424
tokio-stream = { version = "0.1", features = ["sync"] }
2525
tokio-util = "0.7"

dwctl/src/responses/store.rs

Lines changed: 95 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -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
307315
pub 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).
388419
pub async fn poll_until_complete<P: PoolProvider + Clone>(
389420
request_manager: &PostgresRequestManager<P, ReqwestHttpClient>,

0 commit comments

Comments
 (0)