Skip to content

Commit 8a55fdd

Browse files
committed
fix(prism): queue submits when lium b200s sell out
1 parent 9b5b373 commit 8a55fdd

8 files changed

Lines changed: 264 additions & 22 deletions

File tree

bins/prism-challenge/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,7 +782,7 @@ fn spawn_orchestrator(
782782
spawn_rate_limit_recovery(store, gating);
783783
}
784784

785-
/// Re-queue last-6h failed rows that died on Lium HTTP 429 (no `retry_count` burn).
785+
/// Re-queue failed Lium 429 (6h window) and no_capacity / B200 sold-out rows.
786786
async fn recover_rate_limited(
787787
store: &Arc<dyn PrismStore>,
788788
gating: Option<&Arc<dyn GatingStore>>,

crates/lium-rent-pool/src/lib.rs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,22 @@
22
//!
33
//! Live Prism eval is miner-funded (`X-Lium-Api-Key`). Each miner key has its
44
//! own Lium rate budget — there is **no** process-wide rent serialize queue.
5-
//! This crate only classifies 429 bodies and decides autonomous recovery.
5+
//! This crate classifies 429 / no-capacity rent failures and recovery.
66
77
#![forbid(unsafe_code)]
88
#![allow(clippy::missing_errors_doc)]
99

1010
/// Autonomous recovery looks back this far for failed 429 submissions.
1111
pub const RECOVERY_WINDOW_MS: u64 = 6 * 60 * 60 * 1000;
1212

13-
/// True when a failed row should re-enter the rent queue (429 within window).
13+
/// Miner-facing text when Lium has no matching 1× B200 offer.
14+
pub const CAPACITY_NOTE: &str =
15+
"B200s are currently out of capacity on Lium; this job is queued until an offer appears.";
16+
17+
/// Always-on policy (intake / recipe / `/v1/status`).
18+
pub const CAPACITY_POLICY: &str = "When Lium has no matching 1× B200 offer, the job stays queued and retries until an offer appears (sold out is not Score(0)). Bad ZIP, auth, and template-permission errors still fail.";
19+
20+
/// True when a failed row should re-enter the rent queue.
1421
#[must_use]
1522
pub fn should_recover(error_detail: &str, updated_at_ms: u64, now_ms: u64) -> bool {
1623
let l = error_detail.to_ascii_lowercase();
@@ -19,6 +26,9 @@ pub fn should_recover(error_detail: &str, updated_at_ms: u64, now_ms: u64) -> bo
1926
if l.contains("huggingface") || l.contains("fineweb") || l.contains("\"stage\": \"dataset\"") {
2027
return false;
2128
}
29+
if is_no_capacity(error_detail) {
30+
return true;
31+
}
2232
is_rate_limited(error_detail) && now_ms.saturating_sub(updated_at_ms) <= RECOVERY_WINDOW_MS
2333
}
2434

@@ -58,6 +68,36 @@ pub fn is_rate_limited(msg: &str) -> bool {
5868
l.contains("429") || l.contains("too many requests") || l.contains("rate limit")
5969
}
6070

71+
/// Auth / template-permission / missing BYOK — never treat as sold-out.
72+
#[must_use]
73+
pub fn is_auth_or_permission(msg: &str) -> bool {
74+
let l = msg.to_ascii_lowercase();
75+
l.contains("permission")
76+
|| l.contains("unauthorized")
77+
|| l.contains("forbidden")
78+
|| l.contains("401")
79+
|| l.contains("invalid api key")
80+
|| l.contains("missing_lium_api_key")
81+
|| l.contains("api key missing")
82+
}
83+
84+
/// No matching Lium offer / B200 sold out (not miner ZIP, not auth).
85+
#[must_use]
86+
pub fn is_no_capacity(msg: &str) -> bool {
87+
if is_auth_or_permission(msg) {
88+
return false;
89+
}
90+
let l = msg.to_ascii_lowercase();
91+
l.contains("no_capacity")
92+
|| l.contains("no lium offer")
93+
|| l.contains("no offer matches")
94+
|| l.contains("no matching offer")
95+
|| l.contains("sold out")
96+
|| l.contains("out of capacity")
97+
|| l.contains("lack of b200")
98+
|| (l.contains("b200") && (l.contains("unavailable") || l.contains("no offer")))
99+
}
100+
61101
#[cfg(test)]
62102
mod tests {
63103
#![allow(clippy::unwrap_used)]
@@ -81,5 +121,22 @@ mod tests {
81121
100,
82122
100 + 1
83123
));
124+
assert!(is_no_capacity(
125+
"measure: provision: no Lium offer matches GPU preference and price caps (no_capacity)"
126+
));
127+
assert!(is_no_capacity("lium rent sold out: no matching offer"));
128+
assert!(!is_no_capacity(
129+
"lium api: POST /rent -> 400 permission to rent this template"
130+
));
131+
assert!(should_recover(
132+
"provision: no_capacity (no matching B200 offer)",
133+
0,
134+
RECOVERY_WINDOW_MS + 1
135+
));
136+
assert!(!should_recover(
137+
"lium api: 400 permission to rent this template",
138+
100,
139+
100 + 1
140+
));
84141
}
85142
}

crates/prism-challenge/src/api.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -304,14 +304,7 @@ async fn post_submission(
304304
}
305305
}
306306
}
307-
(
308-
StatusCode::ACCEPTED,
309-
Json(prism_pipeline::SubmissionAccepted {
310-
submission_id: id,
311-
status: "accepted".into(),
312-
}),
313-
)
314-
.into_response()
307+
(StatusCode::ACCEPTED, Json(json!({"submission_id": id, "status": "accepted", "note": lium_rent_pool::CAPACITY_POLICY}))).into_response()
315308
}
316309
Err(StoreError::Backend(e)) if e.contains("duplicate") || e.contains("unique") => {
317310
if let (Some(vault), Some(key)) = (&st.payer_vault, miner_lium_key.as_ref()) {
@@ -529,6 +522,7 @@ async fn get_status(State(st): State<Arc<AppState>>) -> Response {
529522
"queues": {"queued": queued, "active": active},
530523
"recent_terminal": done_24h,
531524
"recipe_pin": prism_recipe::recipe_pin_hex(),
525+
"lium_capacity_note": lium_rent_pool::CAPACITY_POLICY,
532526
}))
533527
.into_response()
534528
}
@@ -1040,6 +1034,7 @@ mod tests {
10401034
)
10411035
.await;
10421036
assert_eq!(s, StatusCode::ACCEPTED, "{v}");
1037+
assert_eq!(v["note"], lium_rent_pool::CAPACITY_POLICY);
10431038
let id = v["submission_id"].as_str().unwrap().to_owned();
10441039

10451040
let (s2, v2) = call(
@@ -1073,6 +1068,7 @@ mod tests {
10731068
.await;
10741069
assert_eq!(s, StatusCode::OK);
10751070
assert_eq!(v["backend"], "sim");
1071+
assert_eq!(v["lium_capacity_note"], lium_rent_pool::CAPACITY_POLICY);
10761072
let (s, v) = call(
10771073
app.clone(),
10781074
Request::get("/v1/recipe").body(Body::empty()).unwrap(),

crates/prism-challenge/src/orchestrator.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -284,38 +284,47 @@ impl<C: ChainClient + Send> Orchestrator<C> {
284284

285285
async fn maybe_auto_retry(&self, row: &SubmissionState, class: &str, msg: &str) -> bool {
286286
let rate = lium_rent_pool::is_rate_limited(msg);
287-
if !rate && row.retry_count >= self.cfg.auto_retry_max {
287+
let capacity = lium_rent_pool::is_no_capacity(msg);
288+
let skip_burn = rate || capacity;
289+
if !skip_burn && row.retry_count >= self.cfg.auto_retry_max {
288290
return false;
289291
}
290292
warn!(
291293
submission_id = %row.id,
292294
class,
293295
rate_limited = rate,
296+
no_capacity = capacity,
294297
attempt = row.retry_count + 1,
295298
max = self.cfg.auto_retry_max,
296299
error = %msg,
297300
"auto-retrying submission after infra failure"
298301
);
299-
let _ = self.store.reset_for_retry(&row.id, !rate).await;
302+
let _ = self.store.reset_for_retry(&row.id, !skip_burn).await;
303+
let note = capacity.then_some(lium_rent_pool::CAPACITY_NOTE);
300304
let _ = self
301305
.store
302306
.apply(
303307
&row.id,
304-
&StatePatch::default(),
308+
&StatePatch {
309+
error_detail: note.map(str::to_owned),
310+
..StatePatch::default()
311+
},
305312
Some(&StageEvent {
306313
stage: Stage::Queued,
307314
detail: Some(serde_json::json!({
308315
"auto_retry": true,
309316
"class": class,
310317
"rate_limited": rate,
318+
"no_capacity": capacity,
319+
"note": note,
311320
"attempt": row.retry_count + 1,
312321
"error": msg,
313322
})),
314323
at_ms: 0,
315324
}),
316325
)
317326
.await;
318-
if !rate {
327+
if !skip_burn {
319328
if let Some(g) = &self.gating {
320329
let _ = g
321330
.bump_attempt(
@@ -376,8 +385,8 @@ impl<C: ChainClient + Send> Orchestrator<C> {
376385
// `train_script` training crash) additionally fail terminal under
377386
// their own class, which grants unbounded resubmit. Every other
378387
// EVAL_FAIL phase (eval / battery / score) stays the historical
379-
// windowed `install` class. Non-EVAL_FAIL failures are Lium infra and
380-
// keep `install` + operator-paid auto-retry.
388+
// windowed `install` class. Non-EVAL_FAIL failures are Lium infra:
389+
// 429 / `no_capacity` requeue without burn; other infra uses auto-retry.
381390
if msg.contains("EVAL_FAIL") {
382391
let class = classify_eval_fail(&msg);
383392
fail_terminal(self.store.as_ref(), self.gating.as_ref(), row, class, &msg).await;

crates/prism-challenge/tests/e2e_orchestrate_sim.rs

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use std::sync::Arc;
88

9+
use async_trait::async_trait;
910
use chain::{AxonInfo, ChainError, Metagraph, WeightsTlockPayload};
1011
use chain::{ChainClient, FakeChain, FakeChainConfig};
1112
use challenge_agentic::SimAgent;
@@ -15,7 +16,10 @@ use prism_challenge::{
1516
example_valid_request, submission_id, GatewayClient, GatewayClientConfig, MemoryPrismStore,
1617
Orchestrator, OrchestratorConfig, PrismStore, ScoringMode, Stage, StatePatch, SubmissionState,
1718
};
18-
use prism_lium::{EvalJobBackend, SimLiumBackend};
19+
use prism_lium::{
20+
CostGuardrailError, EvalJobBackend, Instance, InstanceSpec, LiumError, Offer, RemoteExecResult,
21+
SimLiumBackend,
22+
};
1923
use prism_review::SimReviewer;
2024
use std::sync::Mutex;
2125

@@ -337,3 +341,162 @@ async fn emit_and_submit_covers_expected_set() {
337341
assert_eq!(tip.epoch, 7);
338342
assert_eq!(store.emit_cursor(541).await.unwrap(), Some(7));
339343
}
344+
345+
/// Provision always fails with a fixed Lium error (no rent).
346+
struct ProvisionFail(&'static str);
347+
348+
#[async_trait]
349+
impl EvalJobBackend for ProvisionFail {
350+
async fn list_offers(&self, _: Option<f64>) -> Result<Vec<Offer>, LiumError> {
351+
Ok(Vec::new())
352+
}
353+
async fn provision(&self, _: &InstanceSpec) -> Result<Instance, LiumError> {
354+
if self.0 == "capacity" {
355+
return Err(CostGuardrailError::NoCapacity.into());
356+
}
357+
Err(LiumError::Api(
358+
"POST /executors/x/rent -> 400 You don't have permission to rent this template.".into(),
359+
))
360+
}
361+
async fn terminate(&self, _: &str) -> Result<(), LiumError> {
362+
Ok(())
363+
}
364+
async fn verify_terminated(&self, _: &str) -> Result<bool, LiumError> {
365+
Ok(true)
366+
}
367+
async fn exec_eval(
368+
&self,
369+
_: &str,
370+
_: &str,
371+
_: &str,
372+
_: Option<&[u8]>,
373+
) -> Result<RemoteExecResult, LiumError> {
374+
Err(LiumError::Exec("unreachable".into()))
375+
}
376+
}
377+
378+
fn row_from_example(id: &str, req: &prism_challenge::SubmissionRequest) -> SubmissionState {
379+
SubmissionState {
380+
id: id.to_owned(),
381+
miner_hotkey: req.miner_hotkey.clone(),
382+
miner_coldkey: None,
383+
epoch: 7,
384+
netuid: 541,
385+
status: Stage::Queued,
386+
architecture_py: req.architecture_py.clone(),
387+
training_py: req.training_py.clone(),
388+
tree_blob: None,
389+
label: req.label.clone(),
390+
pod_id: None,
391+
pod_provider: None,
392+
receipt: None,
393+
metrics_json: None,
394+
bpb: None,
395+
arch_id: None,
396+
review: None,
397+
similarity: None,
398+
final_score: None,
399+
retry_count: 0,
400+
error_detail: None,
401+
created_at_ms: 1,
402+
updated_at_ms: 1,
403+
}
404+
}
405+
406+
fn orch_with_backend(
407+
store: &Arc<MemoryPrismStore>,
408+
chain: &Arc<LockedFake>,
409+
backend: Arc<dyn EvalJobBackend>,
410+
) -> Orchestrator<LockedFake> {
411+
let gateway = Arc::new(
412+
GatewayClient::new(GatewayClientConfig {
413+
base_url: "dry-run".into(),
414+
max_attempts: 1,
415+
backoff: std::time::Duration::from_millis(1),
416+
})
417+
.unwrap(),
418+
);
419+
Orchestrator::new(
420+
OrchestratorConfig {
421+
netuid: 541,
422+
scoring_mode: ScoringMode::Shadow,
423+
auto_retry_max: 0,
424+
claim_poll: std::time::Duration::from_millis(10),
425+
..Default::default()
426+
},
427+
Arc::clone(store) as Arc<dyn PrismStore>,
428+
backend,
429+
Arc::new(SimReviewer::new()),
430+
Arc::new(SimAgent::new()),
431+
&gateway,
432+
Arc::clone(chain),
433+
sk(),
434+
)
435+
}
436+
437+
#[tokio::test]
438+
async fn no_capacity_requeues_with_b200_note() {
439+
let store = Arc::new(MemoryPrismStore::new());
440+
let chain = Arc::new(LockedFake(Mutex::new(fake_chain())));
441+
let orch = orch_with_backend(
442+
&store,
443+
&chain,
444+
Arc::new(ProvisionFail("capacity")) as Arc<dyn EvalJobBackend>,
445+
);
446+
let req = example_valid_request();
447+
let id = submission_id(&req);
448+
store
449+
.insert_queued(&row_from_example(&id, &req))
450+
.await
451+
.unwrap();
452+
453+
assert!(orch.cycle_once().await.unwrap());
454+
let row = store.get(&id).await.unwrap().expect("row");
455+
assert_eq!(row.status, Stage::Queued, "{row:?}");
456+
assert_eq!(row.retry_count, 0, "sold-out must not burn retry_count");
457+
assert!(row.final_score.is_none());
458+
let detail = row.error_detail.unwrap_or_default();
459+
assert!(
460+
detail.contains("B200s are currently out of capacity on Lium"),
461+
"{detail}"
462+
);
463+
let events = store.events(&id).await.unwrap();
464+
let queued_note = events.iter().any(|e| {
465+
e.stage == Stage::Queued
466+
&& e.detail.as_ref().is_some_and(|d| {
467+
d.get("no_capacity") == Some(&serde_json::json!(true))
468+
&& d.get("note")
469+
.and_then(|n| n.as_str())
470+
.is_some_and(|n| n.contains("B200s are currently out of capacity"))
471+
})
472+
});
473+
assert!(queued_note, "events={events:?}");
474+
475+
assert!(orch.cycle_once().await.unwrap());
476+
let row = store.get(&id).await.unwrap().expect("row");
477+
assert_eq!(row.status, Stage::Queued, "next tick still queued");
478+
}
479+
480+
#[tokio::test]
481+
async fn template_permission_stays_failed() {
482+
let store = Arc::new(MemoryPrismStore::new());
483+
let chain = Arc::new(LockedFake(Mutex::new(fake_chain())));
484+
let orch = orch_with_backend(
485+
&store,
486+
&chain,
487+
Arc::new(ProvisionFail("permission")) as Arc<dyn EvalJobBackend>,
488+
);
489+
let req = example_valid_request();
490+
let id = submission_id(&req);
491+
store
492+
.insert_queued(&row_from_example(&id, &req))
493+
.await
494+
.unwrap();
495+
496+
assert!(orch.cycle_once().await.unwrap());
497+
let row = store.get(&id).await.unwrap().expect("row");
498+
assert_eq!(row.status, Stage::Failed, "{row:?}");
499+
let detail = row.error_detail.unwrap_or_default();
500+
assert!(detail.contains("permission"), "{detail}");
501+
assert!(!detail.contains("B200s are currently out of capacity"));
502+
}

crates/prism-store/src/store.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ pub trait PrismStore: Send + Sync + std::fmt::Debug {
4040
/// post-run infra retry resumes without another GPU run; an incomplete
4141
/// measure attempt is cleared so provisioning starts cleanly.
4242
/// `bump_retry` increments `retry_count` (manual/auto infra). Pass
43-
/// `false` for Lium 429 autonomous requeue (do not burn attempt budget).
43+
/// `false` for Lium 429 / `no_capacity` requeue (do not burn attempt budget).
4444
async fn reset_for_retry(
4545
&self,
4646
id: &str,

0 commit comments

Comments
 (0)