Skip to main content

ai_batch_queue/
executor.rs

1use std::time::{Duration, Instant};
2
3use serde::Serialize;
4use tauri::{AppHandle, Emitter, Manager};
5
6use crate::queue::BatchQueue;
7use crate::types::*;
8use crate::BatchItemHandler;
9
10const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
11
12// -- Tauri event payloads --
13
14#[derive(Debug, Clone, Serialize)]
15#[serde(rename_all = "camelCase")]
16struct BatchJobStartedEvent {
17    job_id: String,
18    operation: String,
19    resource_key: String,
20    total_items: usize,
21}
22
23#[derive(Debug, Clone, Serialize)]
24#[serde(rename_all = "camelCase")]
25struct BatchItemProgressEvent {
26    job_id: String,
27    item_id: String,
28    status: BatchItemStatus,
29    completed: usize,
30    total: usize,
31    error: Option<String>,
32    duration_ms: Option<u64>,
33    eta_remaining_ms: Option<u64>,
34    eta: Option<EtaEstimate>,
35}
36
37#[derive(Debug, Clone, Serialize)]
38#[serde(rename_all = "camelCase")]
39struct BatchJobCompletedEvent {
40    summary: BatchCompletionSummary,
41}
42
43/// Spawn the background batch executor as a tokio task.
44///
45/// The executor polls the queue at `poll_interval` (default 2s) and
46/// processes one batch at a time. Progress events are emitted for each item.
47///
48/// The `BatchQueue<D>` must be registered in Tauri's managed state.
49pub fn spawn<D, H>(app_handle: AppHandle, handler: H)
50where
51    D: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + 'static,
52    H: BatchItemHandler<D> + 'static,
53{
54    spawn_with_interval(app_handle, handler, DEFAULT_POLL_INTERVAL);
55}
56
57/// Spawn with a custom poll interval.
58pub fn spawn_with_interval<D, H>(app_handle: AppHandle, handler: H, poll_interval: Duration)
59where
60    D: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + 'static,
61    H: BatchItemHandler<D> + 'static,
62{
63    tauri::async_runtime::spawn(async move {
64        run_loop(app_handle, handler, poll_interval).await;
65    });
66}
67
68async fn run_loop<D, H>(app_handle: AppHandle, handler: H, poll_interval: Duration)
69where
70    D: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + 'static,
71    H: BatchItemHandler<D>,
72{
73    loop {
74        tokio::time::sleep(poll_interval).await;
75
76        let queue = match app_handle.try_state::<BatchQueue<D>>() {
77            Some(q) => q,
78            None => continue,
79        };
80
81        if queue.has_running_job() {
82            continue;
83        }
84
85        let job = match queue.next_queued() {
86            Some(j) => j,
87            None => continue,
88        };
89
90        process_batch_job(&app_handle, &queue, &handler, &job).await;
91    }
92}
93
94async fn process_batch_job<D, H>(
95    app_handle: &AppHandle,
96    queue: &BatchQueue<D>,
97    handler: &H,
98    job: &BatchJob<D>,
99) where
100    D: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + 'static,
101    H: BatchItemHandler<D>,
102{
103    let job_id = job.id.clone();
104
105    if let Err(e) = queue.mark_running(&job_id) {
106        tracing::error!(job_id = %job_id, error = %e, "Failed to mark job as running");
107        return;
108    }
109
110    let _ = app_handle.emit(
111        "ai_batch:job_started",
112        BatchJobStartedEvent {
113            job_id: job_id.clone(),
114            operation: job.operation.clone(),
115            resource_key: job.resource_key.clone(),
116            total_items: job.items.len(),
117        },
118    );
119
120    let total = job.items.len();
121    let mut completed_count: usize = 0;
122
123    for item in &job.items {
124        // Check if the item or job was cancelled
125        if let Some(current_job) = queue.get_job(&job_id) {
126            if let Some(ci) = current_job.items.iter().find(|i| i.id == item.id) {
127                if ci.status == BatchItemStatus::Cancelled {
128                    completed_count += 1;
129                    continue;
130                }
131            }
132            if current_job.status == BatchJobStatus::Cancelled {
133                break;
134            }
135        }
136
137        // Check overwrite/skip policy
138        if job.overwrite_policy == OverwritePolicy::Skip
139            && handler.should_skip(&item.data, &job.operation)
140        {
141            let _ = queue.update_item(
142                &job_id,
143                &item.id,
144                BatchItemStatus::Skipped,
145                Some("Skipped: already has data".to_string()),
146                None,
147            );
148            completed_count += 1;
149
150            let eta = queue.estimate_remaining(&job_id);
151            let _ = app_handle.emit(
152                "ai_batch:item_progress",
153                BatchItemProgressEvent {
154                    job_id: job_id.clone(),
155                    item_id: item.id.clone(),
156                    status: BatchItemStatus::Skipped,
157                    completed: completed_count,
158                    total,
159                    error: Some("Skipped".to_string()),
160                    duration_ms: None,
161                    eta_remaining_ms: eta.as_ref().map(|estimate| estimate.remaining_ms),
162                    eta,
163                },
164            );
165            continue;
166        }
167
168        // Stamp a fresh TrialId for this concrete execution attempt.
169        // This happens regardless of whether the item has an AttemptId —
170        // the TrialId tracks "this specific run" for diagnostics.
171        queue.stamp_trial_id(&job_id, &item.id);
172
173        // Mark item as running
174        let _ = queue.update_item(&job_id, &item.id, BatchItemStatus::Running, None, None);
175
176        // Process the item
177        let start = Instant::now();
178        let result = handler
179            .process(&item.data, &job.resource_key, &job.operation)
180            .await;
181        let duration_ms = start.elapsed().as_millis() as u64;
182
183        let (status, error) = match result {
184            Ok(item_result) => {
185                if item_result.success {
186                    (BatchItemStatus::Completed, None)
187                } else {
188                    (
189                        BatchItemStatus::Failed,
190                        item_result.error.or(Some("Unknown error".to_string())),
191                    )
192                }
193            }
194            Err(e) => (BatchItemStatus::Failed, Some(format!("{:#}", e))),
195        };
196
197        let _ = queue.update_item(
198            &job_id,
199            &item.id,
200            status.clone(),
201            error.clone(),
202            Some(duration_ms),
203        );
204
205        completed_count += 1;
206        let eta = queue.estimate_remaining(&job_id);
207        let _ = app_handle.emit(
208            "ai_batch:item_progress",
209            BatchItemProgressEvent {
210                job_id: job_id.clone(),
211                item_id: item.id.clone(),
212                status,
213                completed: completed_count,
214                total,
215                error,
216                duration_ms: Some(duration_ms),
217                eta_remaining_ms: eta.as_ref().map(|estimate| estimate.remaining_ms),
218                eta,
219            },
220        );
221    }
222
223    match queue.mark_completed(&job_id) {
224        Ok(Some(summary)) => {
225            let _ = app_handle.emit("ai_batch:job_completed", BatchJobCompletedEvent { summary });
226        }
227        Ok(None) => {}
228        Err(e) => tracing::error!(job_id = %job_id, error = %e, "Failed to mark job completed"),
229    }
230}