Skip to content

Commit abd7f23

Browse files
committed
feat(prism): operator retry route for failed submissions
- `POST /v1/submissions/{id}/retry`: requeue a failed row by clearing pod, receipt, bpb, review, similarity, score and error columns (new `reset_prism_submission_for_retry` SQL + `PrismStore::reset_for_retry`). - Guarded by `AppState.retry_max` (= orchestrator max_attempts, 2). - Journal entry `{"op":"retry"}` appended to the stage log.
1 parent 968c89d commit abd7f23

5 files changed

Lines changed: 181 additions & 2 deletions

File tree

bins/prism-challenge/src/main.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ use prism_challenge::{
2929
};
3030
use prism_lium::{EvalJobBackend, LiumClient, LiumSshConfig, SimLiumBackend};
3131
use prism_review::{OpenRouterClient, ReviewBackend, SimReviewer};
32+
const MAX_ATTEMPTS: u32 = 2;
33+
3234
use tokio::net::TcpListener;
3335
use tokio::sync::Semaphore;
3436
use trustroot::encode_hex;
@@ -266,6 +268,7 @@ async fn cmd_serve(cli: Cli) -> Result<(), String> {
266268
backend_mode: Box::leak(
267269
format!("{backend_mode}/{reviewer_mode}/{store_mode}").into_boxed_str(),
268270
),
271+
retry_max: MAX_ATTEMPTS,
269272
});
270273
let app = submission_router(Arc::clone(&state));
271274

@@ -287,7 +290,7 @@ async fn cmd_serve(cli: Cli) -> Result<(), String> {
287290
ssh_public_keys: ssh_pks,
288291
image_digest: None,
289292
claim_poll: Duration::from_millis(750),
290-
max_attempts: 2,
293+
max_attempts: MAX_ATTEMPTS,
291294
similarity_corpus_limit: 6,
292295
llm_weight: cli.llm_weight,
293296
stuck_grace_secs: 7 * 3600,

crates/db/src/prism_store.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,25 @@ pub async fn update_prism_submission(
215215
Ok(row)
216216
}
217217

218+
/// Reset a failed row for a retry: clears all execution/score fields and
219+
/// re-queues it. `retry_count` is bumped (policy enforced by the caller).
220+
///
221+
/// # Errors
222+
/// SQL error / 0 rows for id.
223+
pub async fn reset_prism_submission_for_retry(
224+
pool: &PgPool,
225+
id: &str,
226+
) -> Result<PrismSubmissionRow, DbError> {
227+
let q = format!(
228+
"UPDATE prism_submission SET status = 'queued', pod_id = NULL, pod_provider = NULL, receipt_json = NULL, metrics_json = NULL, bpb = NULL, review_json = NULL, similarity_json = NULL, kind = NULL, score = NULL, absence_reason = NULL, error_detail = NULL, retry_count = retry_count + 1, updated_at = now() WHERE id = $1 RETURNING {COLS}"
229+
);
230+
let row = sqlx::query_as::<_, PrismSubmissionRow>(&q)
231+
.bind(id)
232+
.fetch_one(pool)
233+
.await?;
234+
Ok(row)
235+
}
236+
218237
/// Append a stage event.
219238
///
220239
/// # Errors

crates/prism-challenge/src/api.rs

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ pub struct AppState {
4343
pub netuid: u16,
4444
/// Eval backend label (`lium` / `sim`) for the status view.
4545
pub backend_mode: &'static str,
46+
/// Max orchestrator attempts per submission (retry guard).
47+
pub retry_max: u32,
4648
}
4749

4850
/// Router over the full API surface.
@@ -53,6 +55,7 @@ pub fn submission_router(state: Arc<AppState>) -> Router {
5355
.route("/v1/submissions", get(list_submissions))
5456
.route("/v1/submissions/{id}", get(get_submission))
5557
.route("/v1/submissions/{id}/events", get(get_events))
58+
.route("/v1/submissions/{id}/retry", post(post_retry))
5659
.route("/v1/status", get(get_status))
5760
.route("/v1/jobs", get(get_jobs))
5861
.route("/v1/recipe", get(get_recipe))
@@ -165,6 +168,37 @@ async fn get_events(State(st): State<Arc<AppState>>, Path(id): Path<String>) ->
165168
}
166169
}
167170

171+
/// `POST /v1/submissions/{id}/retry` — requeue a failed row (guard: max attempts).
172+
async fn post_retry(State(st): State<Arc<AppState>>, Path(id): Path<String>) -> Response {
173+
let row = match st.store.get(&id).await {
174+
Ok(Some(r)) => r,
175+
Ok(None) => return json_err(StatusCode::NOT_FOUND, "unknown_submission", &id),
176+
Err(e) => return json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()),
177+
};
178+
if row.status != Stage::Failed {
179+
return json_err(
180+
StatusCode::CONFLICT,
181+
"not_failed",
182+
&format!("status={}", row.status.as_str()),
183+
);
184+
}
185+
if row.retry_count >= st.retry_max {
186+
return json_err(
187+
StatusCode::CONFLICT,
188+
"retry_exhausted",
189+
&format!("retry_count={} max={}", row.retry_count, st.retry_max),
190+
);
191+
}
192+
match st.store.reset_for_retry(&id).await {
193+
Ok(_row) => (
194+
StatusCode::ACCEPTED,
195+
Json(json!({"submission_id": id, "status": "queued"})),
196+
)
197+
.into_response(),
198+
Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()),
199+
}
200+
}
201+
168202
async fn get_status(State(st): State<Arc<AppState>>) -> Response {
169203
let queued = st
170204
.store
@@ -331,7 +365,7 @@ mod tests {
331365
use axum::body::Body;
332366
use axum::http::Request;
333367
use http_body_util::BodyExt;
334-
use prism_store::MemoryPrismStore;
368+
use prism_store::{MemoryPrismStore, StatePatch};
335369
use tower::ServiceExt;
336370

337371
fn state() -> Arc<AppState> {
@@ -340,9 +374,87 @@ mod tests {
340374
epoch: std::sync::atomic::AtomicU64::new(7),
341375
netuid: 541,
342376
backend_mode: "sim",
377+
retry_max: 2,
343378
})
344379
}
345380

381+
#[tokio::test]
382+
async fn retry_requeues_failed_then_guard_blocks() {
383+
let st = state();
384+
let app = submission_router(Arc::clone(&st));
385+
let id = crate::submission::submission_id(&crate::example_valid_request());
386+
// Seed via POST.
387+
let body = serde_json::to_vec(&crate::example_valid_request()).unwrap();
388+
let (_s, v) = call(
389+
app.clone(),
390+
Request::post("/v1/submissions")
391+
.header("content-type", "application/json")
392+
.body(Body::from(body))
393+
.unwrap(),
394+
)
395+
.await;
396+
assert_eq!(v["submission_id"], id);
397+
// Force failed.
398+
st.store
399+
.apply(
400+
&id,
401+
&StatePatch {
402+
status: Some(Stage::Failed),
403+
..StatePatch::default()
404+
},
405+
None,
406+
)
407+
.await
408+
.unwrap();
409+
let (s, v) = call(
410+
app.clone(),
411+
Request::post(format!("/v1/submissions/{id}/retry"))
412+
.body(Body::empty())
413+
.unwrap(),
414+
)
415+
.await;
416+
assert_eq!(s, StatusCode::ACCEPTED, "{v}");
417+
// Re-fail and retry again → retry_max=2 blocks the third.
418+
st.store
419+
.apply(
420+
&id,
421+
&StatePatch {
422+
status: Some(Stage::Failed),
423+
..StatePatch::default()
424+
},
425+
None,
426+
)
427+
.await
428+
.unwrap();
429+
let (s, _v) = call(
430+
app.clone(),
431+
Request::post(format!("/v1/submissions/{id}/retry"))
432+
.body(Body::empty())
433+
.unwrap(),
434+
)
435+
.await;
436+
assert_eq!(s, StatusCode::ACCEPTED);
437+
st.store
438+
.apply(
439+
&id,
440+
&StatePatch {
441+
status: Some(Stage::Failed),
442+
..StatePatch::default()
443+
},
444+
None,
445+
)
446+
.await
447+
.unwrap();
448+
let (s, _v) = call(
449+
app,
450+
Request::post(format!("/v1/submissions/{id}/retry"))
451+
.body(Body::empty())
452+
.unwrap(),
453+
)
454+
.await;
455+
assert_eq!(s, StatusCode::CONFLICT);
456+
}
457+
346458
async fn call(app: Router, req: Request<Body>) -> (StatusCode, Value) {
347459
let res = app.oneshot(req).await.unwrap();
348460
let status = res.status();

crates/prism-store/src/dbprism.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,23 @@ impl PrismStore for DbPrismStore {
237237
Ok(row_to_state(row))
238238
}
239239

240+
async fn reset_for_retry(&self, id: &str) -> Result<SubmissionState, StoreError> {
241+
let row = dbs::reset_prism_submission_for_retry(&self.pool, id)
242+
.await
243+
.map_err(|e| StoreError::Backend(e.to_string()))?;
244+
dbs::insert_prism_stage_event(
245+
&self.pool,
246+
&dbs::NewPrismStageEvent {
247+
submission_id: id,
248+
stage: "queued",
249+
detail: Some(serde_json::json!({"op": "retry"})),
250+
},
251+
)
252+
.await
253+
.map_err(|e| StoreError::Backend(e.to_string()))?;
254+
Ok(row_to_state(row))
255+
}
256+
240257
async fn list(
241258
&self,
242259
status: Option<&str>,

crates/prism-store/src/store.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,11 @@ pub trait PrismStore: Send + Sync + std::fmt::Debug {
196196
event: Option<&StageEvent>,
197197
) -> Result<SubmissionState, StoreError>;
198198

199+
/// Retry reset: clears exec/score fields and re-queues a failed row.
200+
/// Implementations MUST actually null the pod/receipt/score columns
201+
/// (SQL) or reset the in-memory row mirror-equivalently.
202+
async fn reset_for_retry(&self, id: &str) -> Result<SubmissionState, StoreError>;
203+
199204
/// Newsfeed listing for the API.
200205
async fn list(
201206
&self,
@@ -325,6 +330,29 @@ impl PrismStore for MemoryPrismStore {
325330
Ok(out)
326331
}
327332

333+
async fn reset_for_retry(&self, id: &str) -> Result<SubmissionState, StoreError> {
334+
let mut rows = self
335+
.rows
336+
.lock()
337+
.map_err(|_| StoreError::Backend("poison".into()))?;
338+
let row = rows
339+
.iter_mut()
340+
.find(|r| r.id == id)
341+
.ok_or(StoreError::NotFound)?;
342+
row.status = Stage::Queued;
343+
row.pod_id = None;
344+
row.pod_provider = None;
345+
row.receipt = None;
346+
row.bpb = None;
347+
row.review = None;
348+
row.similarity = None;
349+
row.final_score = None;
350+
row.error_detail = None;
351+
row.retry_count = row.retry_count.saturating_add(1);
352+
row.updated_at_ms = now_ms();
353+
Ok(row.clone())
354+
}
355+
328356
async fn list(
329357
&self,
330358
status: Option<&str>,

0 commit comments

Comments
 (0)