1#![expect(
2 clippy::cast_possible_truncation,
3 reason = "runtime collection lengths are bounded by the u32 job permutation contract"
4)]
5
6use std::collections::VecDeque;
7
8use roaring::RoaringBitmap;
9use uuid::Uuid;
10use wowlab_common::{
11 ClaimToken, RuntimeChunkId, RuntimeWorkItem, RuntimeWorkResult, WorkContextHash,
12};
13use wowlab_types::sim::FastMap;
14
15use super::{
16 strategy::RuntimeStrategyState,
17 tournament::{Phase, TournamentRuntime},
18};
19use crate::{
20 scheduler::{
21 runtime_aggregate::{SingleRuntime, StatWeightsRuntime},
22 runtime_breakdown::RuntimeMemoryBreakdown,
23 runtime_completion_log::{CompletionLog, completion_hash},
24 runtime_factorial::FactorialRuntime,
25 },
26 strategy::{FinalOutput, StrategyError},
27};
28
29const BATCH_FULL_FIDELITY: usize = 1;
30const BATCH_TIER_SMALL_MAX_ITERS: u32 = 2_000;
31const BATCH_TIER_MEDIUM_MAX_ITERS: u32 = 10_000;
32const BATCH_TIER_SMALL: usize = 500;
33const BATCH_TIER_MEDIUM: usize = 120;
34const BATCH_TIER_LARGE: usize = 10;
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37#[non_exhaustive]
38pub enum JobRuntimeStatus {
39 Pending,
40 Running,
41 Completed,
42 Finalizing,
43 Failed,
44}
45
46#[derive(Clone, Debug, Eq, PartialEq)]
47#[non_exhaustive]
48pub(crate) enum CompletionOutcome {
49 Accepted { job_complete: bool },
50 Idempotent,
51 Conflict(String),
52 Stale(String),
53}
54
55#[derive(Clone, Debug)]
57pub struct ClaimRecord {
58 pub chunk_id: RuntimeChunkId,
59 pub node_public_key: String,
60 pub work_context_hash: WorkContextHash,
61 pub work_items: Vec<RuntimeWorkItem>,
62 pub claimed_at_ms: u64,
63 pub reclaim_count: u32,
64}
65#[derive(Clone, Copy, Debug)]
68pub struct TopPermutation {
70 pub perm_idx: u32,
71 pub mean_dps_x10: u32,
72 pub iterations: u32,
73}
74
75#[derive(Clone, Debug)]
76pub struct ProgressSnapshot {
77 pub phase: u32,
78 pub phase_count: u32,
79 pub permutations_active: u32,
80 pub top_mean_dps_x10: u32,
81 pub top_k: Vec<TopPermutation>,
82}
83
84#[derive(Debug)]
85pub struct AssignedBatch {
87 pub chunk_id: RuntimeChunkId,
88 pub claim_token: ClaimToken,
89 pub work_context_hash: WorkContextHash,
90 pub work_items: Vec<RuntimeWorkItem>,
91}
92
93#[derive(Debug)]
95pub struct JobRuntime {
96 pub job_id: Uuid,
97 pub user_id: Uuid,
98 pub strategy_name: String,
99 pub base_sim_config: String,
100 pub sentinel_config: String,
101 pub payload_bytes: Vec<u8>,
103 pub work_context_hash: WorkContextHash,
104 pub status: JobRuntimeStatus,
105 pub priority: i32,
106 strategy: RuntimeStrategyState,
107 in_flight: FastMap<ClaimToken, ClaimRecord>,
108 pending_reclaims: VecDeque<RuntimeWorkItem>,
109 recent_completions: CompletionLog,
110 failed_items: u32,
111 next_chunk_id: u64,
112 next_item_id: u64,
113}
114#[derive(Debug, Default)]
117pub struct ReclaimOutcome {
118 pub reclaimed: Vec<ClaimRecord>,
119 pub failed: Vec<ClaimRecord>,
121}
122
123#[derive(Debug)]
124pub struct JobRuntimeInit {
125 pub job_id: Uuid,
126 pub user_id: Uuid,
127 pub base_sim_config: String,
128 pub sentinel_config: String,
129 pub payload_bytes: Vec<u8>,
131 pub priority: i32,
132}
133
134impl JobRuntime {
135 fn new(init: JobRuntimeInit, strategy_name: &str, strategy: RuntimeStrategyState) -> Self {
136 let work_context_hash = WorkContextHash::compute(
137 &init.base_sim_config,
138 &init.payload_bytes,
139 &init.sentinel_config,
140 );
141
142 Self {
143 job_id: init.job_id,
144 user_id: init.user_id,
145 strategy_name: strategy_name.to_owned(),
146 base_sim_config: init.base_sim_config,
147 sentinel_config: init.sentinel_config,
148 payload_bytes: init.payload_bytes,
149 work_context_hash,
150 status: JobRuntimeStatus::Pending,
151 priority: init.priority,
152 strategy,
153 in_flight: FastMap::default(),
154 pending_reclaims: VecDeque::new(),
155 recent_completions: CompletionLog::new(),
156 failed_items: 0,
157 next_chunk_id: 0,
158 next_item_id: 0,
159 }
160 }
161
162 pub fn tournament(init: JobRuntimeInit, phases: Vec<Phase>, to_test: RoaringBitmap) -> Self {
163 let payload = init.payload_bytes.clone();
164 let strategy = RuntimeStrategyState::Tournament(Box::new(TournamentRuntime::new(
165 phases, to_test, payload,
166 )));
167
168 Self::new(init, "tournament", strategy)
169 }
170
171 pub fn tournament_with_factorial(
172 init: JobRuntimeInit,
173 phases: Vec<Phase>,
174 factorial: FactorialRuntime,
175 ) -> Self {
176 let payload = init.payload_bytes.clone();
177 let strategy = RuntimeStrategyState::Tournament(Box::new(
178 TournamentRuntime::with_factorial(phases, payload, factorial),
179 ));
180
181 Self::new(init, "tournament", strategy)
182 }
183
184 pub fn single(init: JobRuntimeInit, chunk_iterations: &[u32]) -> Self {
185 let strategy = RuntimeStrategyState::Single(Box::new(SingleRuntime::new(chunk_iterations)));
186
187 Self::new(init, "single", strategy)
188 }
189
190 pub fn stat_weights(init: JobRuntimeInit, runs: &[(u64, Vec<u32>)]) -> Self {
192 let strategy = RuntimeStrategyState::StatWeights(StatWeightsRuntime::new(runs));
193
194 Self::new(init, "stat_weights", strategy)
195 }
196
197 pub fn in_flight_len(&self) -> usize {
198 self.in_flight.len()
199 }
200
201 pub fn in_flight_claims(&self) -> impl Iterator<Item = &ClaimRecord> {
202 self.in_flight.values()
203 }
204
205 pub fn suggested_batch_size(&self) -> usize {
206 let (iterations, full) = self.strategy.peek_next_work_shape();
207
208 if full {
209 return BATCH_FULL_FIDELITY;
210 }
211
212 match iterations {
213 i if i <= BATCH_TIER_SMALL_MAX_ITERS => BATCH_TIER_SMALL,
214 i if i <= BATCH_TIER_MEDIUM_MAX_ITERS => BATCH_TIER_MEDIUM,
215 _ => BATCH_TIER_LARGE,
216 }
217 }
218
219 pub fn progress_snapshot(&self) -> Option<ProgressSnapshot> {
221 match &self.strategy {
222 RuntimeStrategyState::Tournament(t) => Some(t.progress()),
223 _ => None,
224 }
225 }
226
227 pub fn claim_batch(
229 &mut self,
230 node_public_key: &str,
231 max_items: usize,
232 now_ms: u64,
233 ) -> Option<AssignedBatch> {
234 self.claim_batch_with(node_public_key, max_items, now_ms, || {
235 ClaimToken::new(Uuid::new_v4().to_string())
236 })
237 }
238
239 pub fn authorize_context(
240 &self,
241 claim_token: &ClaimToken,
242 hash: &WorkContextHash,
243 node_public_key: &str,
244 ) -> bool {
245 self.in_flight.get(claim_token).is_some_and(|claim| {
246 claim.node_public_key == node_public_key
247 && &claim.work_context_hash == hash
248 && &self.work_context_hash == hash
249 })
250 }
251
252 pub fn reclaim_stale(
253 &mut self,
254 now_ms: u64,
255 timeout_ms: u64,
256 max_attempts: u32,
257 ) -> ReclaimOutcome {
258 let stale: Vec<ClaimToken> = self
259 .in_flight
260 .iter()
261 .filter(|(_, c)| now_ms.saturating_sub(c.claimed_at_ms) >= timeout_ms)
262 .map(|(t, _)| t.clone())
263 .collect();
264
265 let mut outcome = ReclaimOutcome::default();
266
267 for token in stale {
268 if let Some(mut claim) = self.in_flight.remove(&token) {
269 claim.reclaim_count += 1;
270
271 if claim.reclaim_count > max_attempts {
272 self.failed_items += claim.work_items.len() as u32;
273 outcome.failed.push(claim);
274 } else {
275 self.pending_reclaims
276 .extend(claim.work_items.iter().cloned());
277 outcome.reclaimed.push(claim);
278 }
279 }
280 }
281
282 outcome
283 }
284
285 pub fn has_failures(&self) -> bool {
286 self.failed_items > 0
287 }
288
289 pub fn has_outstanding_work(&self) -> bool {
290 !self.in_flight.is_empty()
291 || !self.pending_reclaims.is_empty()
292 || self.strategy.has_pending()
293 }
294
295 pub fn is_complete(&self) -> bool {
296 self.strategy.is_complete()
297 }
298
299 pub fn memory_breakdown(&self) -> RuntimeMemoryBreakdown {
300 let mut breakdown = self.strategy.memory_breakdown();
301
302 let claim_record = size_of::<ClaimRecord>();
303 let work_item = size_of::<RuntimeWorkItem>();
304
305 let mut in_flight_bytes = 0usize;
306 let mut in_flight_items = 0usize;
307
308 for claim in self.in_flight.values() {
309 in_flight_bytes += claim_record
310 + claim.work_items.capacity() * work_item
311 + claim.node_public_key.capacity();
312 in_flight_items += claim.work_items.len();
313 }
314
315 in_flight_bytes += self.in_flight.capacity() * (size_of::<ClaimToken>() + claim_record);
316 breakdown.in_flight_bytes = in_flight_bytes;
317 breakdown.in_flight_items = in_flight_items;
318
319 breakdown.in_flight_bytes += self.pending_reclaims.capacity() * work_item;
320
321 breakdown.completion_log_bytes = self.recent_completions.memory_bytes();
322
323 breakdown
324 }
325
326 pub(in crate::scheduler) fn claim_batch_with<F>(
327 &mut self,
328 node_public_key: &str,
329 max_items: usize,
330 now_ms: u64,
331 claim_token: F,
332 ) -> Option<AssignedBatch>
333 where
334 F: FnOnce() -> ClaimToken,
335 {
336 let mut items = Vec::with_capacity(max_items);
337 let mut reclaim_count = 0u32;
338
339 while items.len() < max_items {
340 if let Some(item) = self.pending_reclaims.pop_front() {
341 reclaim_count = reclaim_count.max(1);
342 items.push(item);
343 } else {
344 break;
345 }
346 }
347
348 if items.len() < max_items {
349 let fresh = self
350 .strategy
351 .next_work(max_items - items.len(), &mut self.next_item_id);
352
353 items.extend(fresh);
354 }
355
356 if items.is_empty() {
357 return None;
358 }
359
360 let chunk_id = RuntimeChunkId::new(self.next_chunk_id);
361
362 self.next_chunk_id += 1;
363 let claim_token = claim_token();
364
365 self.status = JobRuntimeStatus::Running;
366 self.in_flight.insert(
367 claim_token.clone(),
368 ClaimRecord {
369 chunk_id,
370 node_public_key: node_public_key.to_owned(),
371 work_context_hash: self.work_context_hash,
372 work_items: items.clone(),
373 claimed_at_ms: now_ms,
374 reclaim_count,
375 },
376 );
377
378 Some(AssignedBatch {
379 chunk_id,
380 claim_token,
381 work_context_hash: self.work_context_hash,
382 work_items: items,
383 })
384 }
385
386 pub(crate) fn complete(
387 &mut self,
388 chunk_id: RuntimeChunkId,
389 claim_token: &ClaimToken,
390 work_context_hash: &WorkContextHash,
391 node_public_key: &str,
392 results: &[RuntimeWorkResult],
393 ) -> Result<CompletionOutcome, StrategyError> {
394 let hash = completion_hash(chunk_id, work_context_hash, node_public_key, results);
395
396 if let Some(prev) = self.recent_completions.get(claim_token) {
397 return Ok(if *prev == hash {
398 CompletionOutcome::Idempotent
399 } else {
400 CompletionOutcome::Conflict("claim already completed with different results".into())
401 });
402 }
403
404 let Some(claim) = self.in_flight.get(claim_token) else {
405 return Ok(CompletionOutcome::Stale("no active claim for token".into()));
406 };
407
408 if claim.chunk_id != chunk_id
409 || &claim.work_context_hash != work_context_hash
410 || claim.node_public_key != node_public_key
411 {
412 return Ok(CompletionOutcome::Conflict(
413 "claim metadata mismatch".into(),
414 ));
415 }
416
417 if !results_match_claim(claim, results) {
418 return Ok(CompletionOutcome::Conflict(
419 "completion results do not match assigned work items".into(),
420 ));
421 }
422
423 self.strategy.ingest(results)?;
424 self.in_flight.remove(claim_token);
425 self.recent_completions.record(claim_token.clone(), hash);
426
427 let job_complete = self.strategy.is_complete();
428
429 if job_complete {
430 self.status = JobRuntimeStatus::Completed;
431 }
432
433 Ok(CompletionOutcome::Accepted { job_complete })
434 }
435
436 pub(crate) fn finalize(&self) -> Result<FinalOutput, StrategyError> {
437 self.strategy.finalize()
438 }
439}
440
441fn results_match_claim(claim: &ClaimRecord, results: &[RuntimeWorkResult]) -> bool {
442 if claim.work_items.len() != results.len() {
443 return false;
444 }
445
446 let mut by_item_id = FastMap::default();
447
448 for result in results {
449 if by_item_id.insert(result.item_id, result).is_some() {
450 return false;
451 }
452 }
453
454 claim.work_items.iter().all(|item| {
455 by_item_id.get(&item.item_id).is_some_and(|result| {
456 result.tag == item.tag
457 && result.iterations == item.iterations
458 && (!matches!(item.fidelity, wowlab_common::RuntimeWorkFidelity::Full)
459 || result.telemetry_pb.is_some())
460 })
461 })
462}
463
464#[derive(Debug, Default)]
465pub struct JobRuntimeStore {
466 jobs: FastMap<Uuid, JobRuntime>,
467}
468
469impl JobRuntimeStore {
470 pub fn new() -> Self {
471 Self::default()
472 }
473
474 pub fn insert(&mut self, runtime: JobRuntime) {
475 self.jobs.insert(runtime.job_id, runtime);
476 }
477
478 pub fn contains(&self, job_id: &Uuid) -> bool {
479 self.jobs.contains_key(job_id)
480 }
481
482 pub fn get(&self, job_id: &Uuid) -> Option<&JobRuntime> {
483 self.jobs.get(job_id)
484 }
485
486 pub fn get_mut(&mut self, job_id: &Uuid) -> Option<&mut JobRuntime> {
487 self.jobs.get_mut(job_id)
488 }
489
490 pub fn remove(&mut self, job_id: &Uuid) -> Option<JobRuntime> {
491 self.jobs.remove(job_id)
492 }
493
494 pub fn len(&self) -> usize {
495 self.jobs.len()
496 }
497
498 pub fn is_empty(&self) -> bool {
499 self.jobs.is_empty()
500 }
501
502 pub fn iter(&self) -> impl Iterator<Item = &JobRuntime> {
503 self.jobs.values()
504 }
505
506 pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut JobRuntime> {
507 self.jobs.values_mut()
508 }
509}
510
511impl<'a> IntoIterator for &'a JobRuntimeStore {
512 type Item = &'a JobRuntime;
513 type IntoIter = std::collections::hash_map::Values<'a, Uuid, JobRuntime>;
514
515 fn into_iter(self) -> Self::IntoIter {
516 self.jobs.values()
517 }
518}
519
520impl<'a> IntoIterator for &'a mut JobRuntimeStore {
521 type Item = &'a mut JobRuntime;
522 type IntoIter = std::collections::hash_map::ValuesMut<'a, Uuid, JobRuntime>;
523
524 fn into_iter(self) -> Self::IntoIter {
525 self.jobs.values_mut()
526 }
527}