1use std::sync::Mutex;
2use std::time::Instant;
3
4use stack_ids::TrialId;
5
6use crate::eta::EtaTracker;
7use crate::types::*;
8
9struct SchedulingState {
15 last_resource_key: Option<String>,
16 consecutive_same_key: usize,
17 last_resource_switch: Option<Instant>,
18}
19
20pub struct BatchQueue<D>
26where
27 D: Clone + Send + Sync + serde::Serialize + 'static,
28{
29 jobs: Mutex<Vec<BatchJob<D>>>,
30 pub(crate) eta: EtaTracker,
31 scheduling: SchedulingConfig,
32 scheduling_state: Mutex<SchedulingState>,
33}
34
35impl<D> Default for BatchQueue<D>
36where
37 D: Clone + Send + Sync + serde::Serialize + 'static,
38{
39 fn default() -> Self {
40 Self::new()
41 }
42}
43
44impl<D> BatchQueue<D>
45where
46 D: Clone + Send + Sync + serde::Serialize + 'static,
47{
48 pub fn new() -> Self {
50 Self::with_scheduling(SchedulingConfig::default())
51 }
52
53 pub fn with_scheduling(scheduling: SchedulingConfig) -> Self {
55 Self {
56 jobs: Mutex::new(Vec::new()),
57 eta: EtaTracker::new(),
58 scheduling,
59 scheduling_state: Mutex::new(SchedulingState {
60 last_resource_key: None,
61 consecutive_same_key: 0,
62 last_resource_switch: None,
63 }),
64 }
65 }
66
67 pub fn enqueue(&self, mut job: BatchJob<D>) -> anyhow::Result<String> {
70 let mut jobs = self.jobs.lock().map_err(|e| anyhow::anyhow!("{}", e))?;
71
72 if job.id.is_empty() {
73 job.id = uuid::Uuid::new_v4().to_string();
74 }
75 job.status = BatchJobStatus::Queued;
76 job.created_at = chrono::Utc::now().to_rfc3339();
77
78 let job_id = job.id.clone();
79 jobs.push(job);
80
81 Self::reorder_queued_jobs(&mut jobs, &self.scheduling);
82 Ok(job_id)
83 }
84
85 fn reorder_queued_jobs(jobs: &mut [BatchJob<D>], scheduling: &SchedulingConfig) {
90 if !scheduling.enable_reordering {
91 return;
92 }
93
94 let queued_indices: Vec<usize> = jobs
95 .iter()
96 .enumerate()
97 .filter(|(_, j)| j.status == BatchJobStatus::Queued)
98 .map(|(i, _)| i)
99 .collect();
100
101 if queued_indices.len() < 2 {
102 return;
103 }
104
105 let mut queued_jobs: Vec<BatchJob<D>> =
106 queued_indices.iter().map(|&i| jobs[i].clone()).collect();
107
108 let original_order: Vec<String> = queued_jobs.iter().map(|j| j.id.clone()).collect();
109 queued_jobs.sort_by(|a, b| a.resource_key.cmp(&b.resource_key));
110 let new_order: Vec<String> = queued_jobs.iter().map(|j| j.id.clone()).collect();
111
112 if original_order != new_order {
113 for job in &mut queued_jobs {
114 job.reordered = true;
115 job.reorder_note =
116 Some("Reordered: grouping by resource to minimize swaps".to_string());
117 }
118 for (slot_idx, job) in queued_indices.iter().zip(queued_jobs) {
119 jobs[*slot_idx] = job;
120 }
121 }
122 }
123
124 pub fn next_queued(&self) -> Option<BatchJob<D>> {
126 let jobs = self.jobs.lock().ok()?;
127 let queued: Vec<&BatchJob<D>> = jobs
128 .iter()
129 .filter(|j| j.status == BatchJobStatus::Queued)
130 .collect();
131 if queued.is_empty() {
132 return None;
133 }
134
135 let sched = self
136 .scheduling_state
137 .lock()
138 .unwrap_or_else(|e| e.into_inner());
139
140 if let Some(ref last_key) = sched.last_resource_key {
141 let same_resource = queued
142 .iter()
143 .find(|job| job.resource_key == *last_key)
144 .copied();
145 let different_resource = queued
146 .iter()
147 .find(|job| job.resource_key != *last_key)
148 .copied();
149
150 if let Some(different_resource) = different_resource {
151 let cooldown_active = sched
152 .last_resource_switch
153 .map(|ts| ts.elapsed() < self.scheduling.resource_switch_cooldown)
154 .unwrap_or(false);
155
156 if sched.consecutive_same_key >= self.scheduling.max_consecutive_same_key
157 && !cooldown_active
158 {
159 return Some(different_resource.clone());
160 }
161 }
162
163 if let Some(same_resource) = same_resource {
164 return Some(same_resource.clone());
165 }
166 }
167
168 queued.first().cloned().cloned()
169 }
170
171 pub fn mark_running(&self, job_id: &str) -> anyhow::Result<()> {
178 let mut jobs = self.jobs.lock().map_err(|e| anyhow::anyhow!("{}", e))?;
179 let job = jobs
180 .iter_mut()
181 .find(|j| j.id == job_id)
182 .ok_or_else(|| anyhow::anyhow!("job {} not found", job_id))?;
183 if job.status != BatchJobStatus::Queued {
184 anyhow::bail!("job {} must be queued before it can run", job_id);
185 }
186
187 let resource_key = job.resource_key.clone();
188 job.status = BatchJobStatus::Running;
189 job.started_at = Some(chrono::Utc::now().to_rfc3339());
190
191 let mut sched = self
194 .scheduling_state
195 .lock()
196 .unwrap_or_else(|e| e.into_inner());
197
198 match sched.last_resource_key.as_ref() {
199 Some(current) if *current == resource_key => {
200 sched.consecutive_same_key += 1;
201 }
202 _ => {
203 sched.last_resource_key = Some(resource_key);
204 sched.consecutive_same_key = 1;
205 sched.last_resource_switch = Some(Instant::now());
206 }
207 }
208 Ok(())
209 }
210
211 pub fn update_item(
216 &self,
217 job_id: &str,
218 item_id: &str,
219 status: BatchItemStatus,
220 error: Option<String>,
221 duration_ms: Option<u64>,
222 ) -> anyhow::Result<()> {
223 let mut jobs = self.jobs.lock().map_err(|e| anyhow::anyhow!("{}", e))?;
224 let job = jobs
225 .iter_mut()
226 .find(|j| j.id == job_id)
227 .ok_or_else(|| anyhow::anyhow!("job {} not found", job_id))?;
228 if matches!(
229 job.status,
230 BatchJobStatus::Completed
231 | BatchJobStatus::CompletedWithErrors
232 | BatchJobStatus::Cancelled
233 ) {
234 anyhow::bail!("job {} is no longer mutable", job_id);
235 }
236 let item = job
237 .items
238 .iter_mut()
239 .find(|i| i.id == item_id)
240 .ok_or_else(|| anyhow::anyhow!("item {} not found in job {}", item_id, job_id))?;
241
242 if item.status == BatchItemStatus::Cancelled && status != BatchItemStatus::Cancelled {
243 return Ok(());
244 }
245
246 match status {
247 BatchItemStatus::Running if item.status != BatchItemStatus::Pending => {
248 anyhow::bail!("item {} must be pending before running", item_id);
249 }
250 BatchItemStatus::Cancelled
251 if item.status != BatchItemStatus::Pending
252 && item.status != BatchItemStatus::Running =>
253 {
254 anyhow::bail!(
255 "item {} cannot be cancelled from {:?}",
256 item_id,
257 item.status
258 );
259 }
260 BatchItemStatus::Completed | BatchItemStatus::Failed | BatchItemStatus::Skipped
261 if item.status != BatchItemStatus::Running
262 && item.status != BatchItemStatus::Pending =>
263 {
264 anyhow::bail!(
265 "item {} cannot transition from {:?} to {:?}",
266 item_id,
267 item.status,
268 status
269 );
270 }
271 BatchItemStatus::Pending => {
272 anyhow::bail!("item {} cannot be moved back to pending directly", item_id);
273 }
274 _ => {}
275 }
276
277 let should_record = status == BatchItemStatus::Completed && duration_ms.is_some();
278 let resource_key = job.resource_key.clone();
279 let operation = job.operation.clone();
280 let bucket = item.size_bucket;
281
282 item.status = status;
283 item.error = error;
284 item.duration_ms = duration_ms;
285
286 if should_record {
287 let ms = duration_ms.unwrap();
288 drop(jobs); self.eta.record(&resource_key, &operation, bucket, ms);
290 }
291 Ok(())
292 }
293
294 pub fn mark_completed(&self, job_id: &str) -> anyhow::Result<Option<BatchCompletionSummary>> {
299 let mut jobs = self.jobs.lock().map_err(|e| anyhow::anyhow!("{}", e))?;
300 let job = jobs
301 .iter_mut()
302 .find(|j| j.id == job_id)
303 .ok_or_else(|| anyhow::anyhow!("job {} not found", job_id))?;
304 if job.status != BatchJobStatus::Running && job.status != BatchJobStatus::Cancelled {
305 anyhow::bail!(
306 "job {} must be running or cancelled before completion",
307 job_id
308 );
309 }
310
311 let failed = job
312 .items
313 .iter()
314 .filter(|i| i.status == BatchItemStatus::Failed)
315 .count();
316 let succeeded = job
317 .items
318 .iter()
319 .filter(|i| i.status == BatchItemStatus::Completed)
320 .count();
321 let skipped = job
322 .items
323 .iter()
324 .filter(|i| {
325 i.status == BatchItemStatus::Cancelled || i.status == BatchItemStatus::Skipped
326 })
327 .count();
328
329 if job.status == BatchJobStatus::Cancelled
330 || job
331 .items
332 .iter()
333 .all(|item| item.status == BatchItemStatus::Cancelled)
334 {
335 job.status = BatchJobStatus::Cancelled;
336 } else if failed > 0 {
337 job.status = BatchJobStatus::CompletedWithErrors;
338 } else {
339 job.status = BatchJobStatus::Completed;
340 }
341 job.completed_at = Some(chrono::Utc::now().to_rfc3339());
342
343 let total_ms: u64 = job.items.iter().filter_map(|i| i.duration_ms).sum();
344 let processed = succeeded + failed;
345 let avg_ms = if processed > 0 {
346 total_ms / processed as u64
347 } else {
348 0
349 };
350
351 Ok(Some(BatchCompletionSummary {
352 job_id: job.id.clone(),
353 operation: job.operation.clone(),
354 resource_key: job.resource_key.clone(),
355 total: job.items.len(),
356 succeeded,
357 failed,
358 skipped,
359 total_duration_ms: total_ms,
360 avg_duration_ms: avg_ms,
361 }))
362 }
363
364 pub fn cancel_item(&self, job_id: &str, item_id: &str) -> anyhow::Result<()> {
366 let mut jobs = self.jobs.lock().map_err(|e| anyhow::anyhow!("{}", e))?;
367 let job = jobs
368 .iter_mut()
369 .find(|j| j.id == job_id)
370 .ok_or_else(|| anyhow::anyhow!("job {} not found", job_id))?;
371 let item = job
372 .items
373 .iter_mut()
374 .find(|i| i.id == item_id)
375 .ok_or_else(|| anyhow::anyhow!("item {} not found in job {}", item_id, job_id))?;
376
377 if item.status == BatchItemStatus::Completed || item.status == BatchItemStatus::Failed {
378 anyhow::bail!("item {} is already finalized", item_id);
379 }
380 item.status = BatchItemStatus::Cancelled;
381 Ok(())
382 }
383
384 pub fn cancel_job(&self, job_id: &str) -> anyhow::Result<()> {
386 let mut jobs = self.jobs.lock().map_err(|e| anyhow::anyhow!("{}", e))?;
387 let job = jobs
388 .iter_mut()
389 .find(|j| j.id == job_id)
390 .ok_or_else(|| anyhow::anyhow!("job {} not found", job_id))?;
391 for item in &mut job.items {
392 if item.status == BatchItemStatus::Pending || item.status == BatchItemStatus::Running {
393 item.status = BatchItemStatus::Cancelled;
394 }
395 }
396 job.status = BatchJobStatus::Cancelled;
397 job.completed_at = Some(chrono::Utc::now().to_rfc3339());
398 Ok(())
399 }
400
401 pub(crate) fn stamp_trial_id(&self, job_id: &str, item_id: &str) {
408 if let Ok(mut jobs) = self.jobs.lock() {
409 if let Some(job) = jobs.iter_mut().find(|j| j.id == job_id) {
410 if let Some(item) = job.items.iter_mut().find(|i| i.id == item_id) {
411 item.trial_id = Some(TrialId::generate());
412 }
413 }
414 }
415 }
416
417 pub fn retry_failed(&self, job_id: &str) -> anyhow::Result<()> {
425 let mut jobs = self.jobs.lock().map_err(|e| anyhow::anyhow!("{}", e))?;
426 if let Some(job) = jobs.iter_mut().find(|j| j.id == job_id) {
427 let has_failed = job
428 .items
429 .iter()
430 .any(|i| i.status == BatchItemStatus::Failed);
431 if !has_failed {
432 anyhow::bail!("No failed items to retry in job {}", job_id);
433 }
434 for item in &mut job.items {
435 if item.status == BatchItemStatus::Failed {
436 item.status = BatchItemStatus::Pending;
437 item.error = None;
438 item.duration_ms = None;
439 item.trial_id = None;
442 }
443 }
444 job.status = BatchJobStatus::Queued;
445 job.completed_at = None;
446 Self::reorder_queued_jobs(&mut jobs, &self.scheduling);
447 }
448 Ok(())
449 }
450
451 pub fn list_jobs(&self) -> Vec<BatchJob<D>> {
453 self.jobs.lock().map(|j| j.clone()).unwrap_or_default()
454 }
455
456 pub fn get_job(&self, job_id: &str) -> Option<BatchJob<D>> {
458 self.jobs
459 .lock()
460 .ok()?
461 .iter()
462 .find(|j| j.id == job_id)
463 .cloned()
464 }
465
466 pub fn estimate_remaining_ms(&self, job_id: &str) -> Option<u64> {
469 self.estimate_remaining(job_id)
470 .map(|estimate| estimate.remaining_ms)
471 }
472
473 pub fn estimate_remaining(&self, job_id: &str) -> Option<EtaEstimate> {
475 let jobs = self.jobs.lock().ok()?;
476 let job = jobs.iter().find(|j| j.id == job_id)?;
477
478 let remaining_buckets: Vec<SizeBucket> = job
479 .items
480 .iter()
481 .filter(|i| {
482 i.status == BatchItemStatus::Pending || i.status == BatchItemStatus::Running
483 })
484 .map(|i| i.size_bucket)
485 .collect();
486
487 if remaining_buckets.is_empty() {
488 return Some(EtaEstimate {
489 remaining_ms: 0,
490 items_remaining: 0,
491 avg_item_ms: 0,
492 confidence: EtaConfidence::High,
493 sample_count: 0,
494 });
495 }
496
497 self.eta
498 .estimate(&job.resource_key, &job.operation, &remaining_buckets)
499 }
500
501 pub fn has_running_job(&self) -> bool {
503 self.jobs
504 .lock()
505 .map(|j| j.iter().any(|job| job.status == BatchJobStatus::Running))
506 .unwrap_or(false)
507 }
508
509 pub fn eta_sample_count(
511 &self,
512 resource_key: &str,
513 operation: &str,
514 size_bucket: SizeBucket,
515 ) -> u64 {
516 self.eta.sample_count(resource_key, operation, size_bucket)
517 }
518
519 pub fn queued_count(&self) -> usize {
521 self.jobs
522 .lock()
523 .map(|j| {
524 j.iter()
525 .filter(|job| job.status == BatchJobStatus::Queued)
526 .count()
527 })
528 .unwrap_or(0)
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 fn make_items(count: usize) -> Vec<BatchItem<String>> {
537 (0..count)
538 .map(|i| BatchItem {
539 id: format!("item-{}", i),
540 data: format!("data-{}", i),
541 status: BatchItemStatus::Pending,
542 error: None,
543 duration_ms: None,
544 size_bucket: SizeBucket::Medium,
545 trace_ctx: None,
546 attempt_id: None,
547 trial_id: None,
548 })
549 .collect()
550 }
551
552 fn make_job(resource: &str, op: &str, count: usize) -> BatchJob<String> {
553 BatchJob {
554 id: String::new(),
555 resource_key: resource.to_string(),
556 operation: op.to_string(),
557 overwrite_policy: OverwritePolicy::Skip,
558 items: make_items(count),
559 status: BatchJobStatus::Queued,
560 created_at: String::new(),
561 started_at: None,
562 completed_at: None,
563 reordered: false,
564 reorder_note: None,
565 }
566 }
567
568 #[test]
569 fn test_enqueue_assigns_id() {
570 let queue: BatchQueue<String> = BatchQueue::new();
571 let job = make_job("model-a", "tag", 3);
572 let id = queue.enqueue(job).unwrap();
573 assert!(!id.is_empty());
574 }
575
576 #[test]
577 fn test_next_queued() {
578 let queue: BatchQueue<String> = BatchQueue::new();
579 assert!(queue.next_queued().is_none());
580
581 let job = make_job("model-a", "tag", 2);
582 let id = queue.enqueue(job).unwrap();
583
584 let next = queue.next_queued().unwrap();
585 assert_eq!(next.id, id);
586 }
587
588 #[test]
589 fn test_mark_running() {
590 let queue: BatchQueue<String> = BatchQueue::new();
591 let id = queue.enqueue(make_job("model-a", "tag", 1)).unwrap();
592
593 queue.mark_running(&id).unwrap();
594 let job = queue.get_job(&id).unwrap();
595 assert_eq!(job.status, BatchJobStatus::Running);
596 assert!(job.started_at.is_some());
597 }
598
599 #[test]
600 fn test_update_item_and_complete() {
601 let queue: BatchQueue<String> = BatchQueue::new();
602 let id = queue.enqueue(make_job("model-a", "tag", 2)).unwrap();
603 queue.mark_running(&id).unwrap();
604
605 queue
606 .update_item(&id, "item-0", BatchItemStatus::Completed, None, Some(1000))
607 .unwrap();
608 queue
609 .update_item(&id, "item-1", BatchItemStatus::Completed, None, Some(2000))
610 .unwrap();
611
612 let summary = queue.mark_completed(&id).unwrap().unwrap();
613 assert_eq!(summary.succeeded, 2);
614 assert_eq!(summary.failed, 0);
615 assert_eq!(summary.total_duration_ms, 3000);
616 assert_eq!(summary.avg_duration_ms, 1500);
617 }
618
619 #[test]
620 fn test_completed_with_errors() {
621 let queue: BatchQueue<String> = BatchQueue::new();
622 let id = queue.enqueue(make_job("model-a", "tag", 2)).unwrap();
623 queue.mark_running(&id).unwrap();
624
625 queue
626 .update_item(&id, "item-0", BatchItemStatus::Completed, None, Some(1000))
627 .unwrap();
628 queue
629 .update_item(
630 &id,
631 "item-1",
632 BatchItemStatus::Failed,
633 Some("timeout".to_string()),
634 Some(5000),
635 )
636 .unwrap();
637
638 let summary = queue.mark_completed(&id).unwrap().unwrap();
639 assert_eq!(summary.succeeded, 1);
640 assert_eq!(summary.failed, 1);
641
642 let job = queue.get_job(&id).unwrap();
643 assert_eq!(job.status, BatchJobStatus::CompletedWithErrors);
644 }
645
646 #[test]
647 fn test_cancel_job() {
648 let queue: BatchQueue<String> = BatchQueue::new();
649 let id = queue.enqueue(make_job("model-a", "tag", 3)).unwrap();
650
651 queue.cancel_job(&id).unwrap();
652 let job = queue.get_job(&id).unwrap();
653 assert_eq!(job.status, BatchJobStatus::Cancelled);
654 assert!(job
655 .items
656 .iter()
657 .all(|i| i.status == BatchItemStatus::Cancelled));
658 }
659
660 #[test]
661 fn test_cancel_single_item() {
662 let queue: BatchQueue<String> = BatchQueue::new();
663 let id = queue.enqueue(make_job("model-a", "tag", 3)).unwrap();
664
665 queue.cancel_item(&id, "item-1").unwrap();
666 let job = queue.get_job(&id).unwrap();
667 assert_eq!(job.items[0].status, BatchItemStatus::Pending);
668 assert_eq!(job.items[1].status, BatchItemStatus::Cancelled);
669 assert_eq!(job.items[2].status, BatchItemStatus::Pending);
670 }
671
672 #[test]
673 fn test_retry_failed() {
674 let queue: BatchQueue<String> = BatchQueue::new();
675 let id = queue.enqueue(make_job("model-a", "tag", 2)).unwrap();
676 queue.mark_running(&id).unwrap();
677
678 queue
679 .update_item(&id, "item-0", BatchItemStatus::Completed, None, Some(1000))
680 .unwrap();
681 queue
682 .update_item(
683 &id,
684 "item-1",
685 BatchItemStatus::Failed,
686 Some("err".to_string()),
687 None,
688 )
689 .unwrap();
690 queue.mark_completed(&id).unwrap();
691
692 queue.retry_failed(&id).unwrap();
693 let job = queue.get_job(&id).unwrap();
694 assert_eq!(job.status, BatchJobStatus::Queued);
695 assert_eq!(job.items[1].status, BatchItemStatus::Pending);
696 assert!(job.items[1].error.is_none());
697 }
698
699 #[test]
700 fn test_model_aware_reordering() {
701 let queue: BatchQueue<String> = BatchQueue::new();
702 queue.enqueue(make_job("model-b", "tag", 1)).unwrap();
703 queue.enqueue(make_job("model-a", "caption", 1)).unwrap();
704 queue.enqueue(make_job("model-b", "caption", 1)).unwrap();
705
706 let jobs = queue.list_jobs();
707 assert_eq!(jobs[0].resource_key, "model-a");
709 assert_eq!(jobs[1].resource_key, "model-b");
710 assert_eq!(jobs[2].resource_key, "model-b");
711 }
712
713 #[test]
714 fn test_reorder_preserves_running_jobs() {
715 let queue: BatchQueue<String> = BatchQueue::new();
716 let id1 = queue.enqueue(make_job("model-b", "tag", 1)).unwrap();
717 queue.mark_running(&id1).unwrap();
718
719 queue.enqueue(make_job("model-a", "tag", 1)).unwrap();
721 queue.enqueue(make_job("model-b", "tag", 1)).unwrap();
722
723 let jobs = queue.list_jobs();
724 assert_eq!(jobs[0].resource_key, "model-b"); assert_eq!(jobs[0].status, BatchJobStatus::Running);
726 assert_eq!(jobs[1].resource_key, "model-a");
728 assert_eq!(jobs[2].resource_key, "model-b");
729 }
730
731 #[test]
732 fn test_list_and_count() {
733 let queue: BatchQueue<String> = BatchQueue::new();
734 assert_eq!(queue.queued_count(), 0);
735 assert!(!queue.has_running_job());
736
737 let id = queue.enqueue(make_job("model-a", "tag", 1)).unwrap();
738 assert_eq!(queue.queued_count(), 1);
739
740 queue.mark_running(&id).unwrap();
741 assert!(queue.has_running_job());
742 assert_eq!(queue.queued_count(), 0);
743 }
744
745 #[test]
746 fn test_eta_integration() {
747 let queue: BatchQueue<String> = BatchQueue::new();
748 let id = queue.enqueue(make_job("model-a", "tag", 3)).unwrap();
749 queue.mark_running(&id).unwrap();
750
751 assert!(queue.estimate_remaining_ms(&id).is_none());
753
754 queue
756 .update_item(&id, "item-0", BatchItemStatus::Completed, None, Some(1000))
757 .unwrap();
758
759 let eta = queue.estimate_remaining_ms(&id);
761 assert_eq!(eta, Some(2000));
762 }
763
764 #[test]
765 fn test_stamp_trial_id() {
766 let queue: BatchQueue<String> = BatchQueue::new();
767 let id = queue.enqueue(make_job("model-a", "tag", 2)).unwrap();
768
769 let job = queue.get_job(&id).unwrap();
771 assert!(job.items[0].trial_id.is_none());
772
773 queue.stamp_trial_id(&id, "item-0");
775 let job = queue.get_job(&id).unwrap();
776 assert!(job.items[0].trial_id.is_some());
777 assert!(job.items[1].trial_id.is_none());
778
779 let first_trial = job.items[0].trial_id.clone().unwrap();
781 queue.stamp_trial_id(&id, "item-0");
782 let job = queue.get_job(&id).unwrap();
783 let second_trial = job.items[0].trial_id.clone().unwrap();
784 assert_ne!(first_trial, second_trial);
785 }
786
787 #[test]
788 fn test_retry_clears_trial_preserves_attempt() {
789 use stack_ids::AttemptId;
790
791 let queue: BatchQueue<String> = BatchQueue::new();
792 let id = queue.enqueue(make_job("model-a", "tag", 2)).unwrap();
793 queue.mark_running(&id).unwrap();
794
795 {
797 let mut jobs = queue.jobs.lock().unwrap();
798 let job = jobs.iter_mut().find(|j| j.id == id).unwrap();
799 job.items[1].attempt_id = Some(AttemptId::generate());
800 job.items[1].trial_id = Some(TrialId::generate());
801 }
802
803 queue
805 .update_item(&id, "item-0", BatchItemStatus::Completed, None, Some(1000))
806 .unwrap();
807 queue
808 .update_item(
809 &id,
810 "item-1",
811 BatchItemStatus::Failed,
812 Some("err".to_string()),
813 None,
814 )
815 .unwrap();
816 queue.mark_completed(&id).unwrap();
817
818 let attempt_before = queue.get_job(&id).unwrap().items[1].attempt_id.clone();
820 assert!(attempt_before.is_some());
821
822 queue.retry_failed(&id).unwrap();
824 let job = queue.get_job(&id).unwrap();
825
826 assert_eq!(job.items[1].attempt_id, attempt_before);
828 assert!(job.items[1].trial_id.is_none());
829 }
830}