@@ -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+
168202async 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 ( ) ;
0 commit comments