Skip to main content

ai_batch_queue/
queue.rs

1use std::sync::Mutex;
2use std::time::Instant;
3
4use stack_ids::TrialId;
5
6use crate::eta::EtaTracker;
7use crate::types::*;
8
9/// Mutable scheduling state that tracks resource-switch heuristics.
10///
11/// These three fields are combined into a single struct so they can be
12/// protected by one `Mutex`, preventing the split-lock inconsistency
13/// that occurred when each was locked independently (ABQ-2).
14struct SchedulingState {
15    last_resource_key: Option<String>,
16    consecutive_same_key: usize,
17    last_resource_switch: Option<Instant>,
18}
19
20/// In-memory batch queue with model-aware reordering and ETA estimation.
21///
22/// The queue automatically groups jobs by `resource_key` to minimize expensive
23/// resource swaps (e.g. GPU model loads). It also tracks per-item processing
24/// durations bucketed by size for accurate ETA predictions.
25pub 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    /// Create a new empty batch queue.
49    pub fn new() -> Self {
50        Self::with_scheduling(SchedulingConfig::default())
51    }
52
53    /// Create a new queue with explicit scheduling controls.
54    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    /// Add a new batch job and perform resource-aware reordering.
68    /// Returns the assigned job ID.
69    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    /// Reorder only queued jobs to group by resource_key (minimizes resource swaps).
86    ///
87    /// For example, if you queue jobs for models A, B, A, this reorders to A, A, B
88    /// so the GPU only loads each model once instead of switching back and forth.
89    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    /// Get the next queued job (without removing it).
125    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    /// Mark a job as running and set its started_at timestamp.
172    ///
173    /// Both the job status and scheduling metadata are updated while
174    /// holding the jobs lock, ensuring they cannot become inconsistent
175    /// (ABQ-2 fix: previously the jobs lock was dropped before the
176    /// scheduling locks were acquired).
177    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        // Update scheduling state while still holding the jobs lock so
192        // that job status and scheduling metadata stay consistent.
193        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    /// Update a single item's status within a job.
212    ///
213    /// If the item completed successfully and `duration_ms` is provided,
214    /// the ETA tracker is automatically updated with the new data point.
215    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); // Release jobs lock before eta lock
289            self.eta.record(&resource_key, &operation, bucket, ms);
290        }
291        Ok(())
292    }
293
294    /// Mark a job as completed and produce a completion summary.
295    ///
296    /// Automatically determines whether it's `Completed` or `CompletedWithErrors`
297    /// based on item statuses.
298    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    /// Cancel a single pending item within a job.
365    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    /// Cancel an entire batch job. Running items finish; pending items are cancelled.
385    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    /// Stamp a fresh `TrialId` on a batch item before execution.
402    ///
403    /// Called by the executor immediately before processing each item.
404    /// Each concrete execution gets a unique `TrialId` for diagnostics
405    /// correlation. Silently no-ops if the job or item is not found
406    /// (the executor will detect this via status checks).
407    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    /// Retry all failed items in a completed job by resetting them to Pending.
418    /// The job is re-queued and reordering is applied.
419    ///
420    /// Retry lineage: each retried item keeps its existing `attempt_id`
421    /// (same logical retry family) but clears its `trial_id` so the executor
422    /// will mint a fresh one on the next execution. This keeps batch-item
423    /// retries distinguishable from outer job-level retries.
424    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                    // Clear trial_id so the executor mints a fresh one.
440                    // Keep attempt_id stable (same retry family).
441                    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    /// Get all jobs (cloned snapshot).
452    pub fn list_jobs(&self) -> Vec<BatchJob<D>> {
453        self.jobs.lock().map(|j| j.clone()).unwrap_or_default()
454    }
455
456    /// Get a specific job by ID.
457    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    /// Estimate remaining processing time for a job in milliseconds.
467    /// Returns `None` if no historical data is available.
468    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    /// Estimate remaining processing time with confidence and sample metadata.
474    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    /// Check if any batch job is currently running.
502    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    /// Get the number of ETA samples for a specific resource/operation/size combination.
510    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    /// Get the number of queued (waiting) jobs.
520    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        // After reordering: model-a first, then model-b jobs
708        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        // Running job should not be reordered
720        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"); // running, stays first
725        assert_eq!(jobs[0].status, BatchJobStatus::Running);
726        // Queued jobs reordered: model-a before model-b
727        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        // No ETA data yet
752        assert!(queue.estimate_remaining_ms(&id).is_none());
753
754        // Complete first item with timing
755        queue
756            .update_item(&id, "item-0", BatchItemStatus::Completed, None, Some(1000))
757            .unwrap();
758
759        // Now we have data: 2 remaining items * 1000ms avg = 2000ms
760        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        // Items start with no trial_id
770        let job = queue.get_job(&id).unwrap();
771        assert!(job.items[0].trial_id.is_none());
772
773        // Stamp a trial_id
774        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        // Stamping again produces a different trial_id
780        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        // Manually set attempt_id and trial_id on item-1 to simulate traced execution
796        {
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        // Complete item-0, fail item-1
804        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        // Capture the attempt_id before retry
819        let attempt_before = queue.get_job(&id).unwrap().items[1].attempt_id.clone();
820        assert!(attempt_before.is_some());
821
822        // Retry
823        queue.retry_failed(&id).unwrap();
824        let job = queue.get_job(&id).unwrap();
825
826        // attempt_id preserved, trial_id cleared
827        assert_eq!(job.items[1].attempt_id, attempt_before);
828        assert!(job.items[1].trial_id.is_none());
829    }
830}