Skip to main content

ai_batch_queue/
lib.rs

1//! # AI Batch Queue
2//!
3//! Model-aware batch processing queue with ETA estimation for Tauri applications.
4//!
5//! ## Key Features
6//!
7//! - **Resource-aware reordering** — automatically groups jobs by resource key
8//!   (e.g. model name) to minimize expensive swaps
9//! - **Size-bucketed ETA estimation** — tracks processing durations by
10//!   (resource, operation, size) for accurate time predictions
11//! - **Item-level status tracking** — each item has its own lifecycle
12//! - **Overwrite policies** — skip items that already have results
13//! - **Progressive completion with retry** — failed items can be retried
14//!   without re-processing successful ones
15//! - **Trace context propagation** — optional `stack-ids` `TraceCtx`,
16//!   `AttemptId`, and `TrialId` on each batch item for cross-crate
17//!   observability and retry lineage tracking
18//!
19//! ## Quick Start
20//!
21//! 1. Define your item data type
22//! 2. Implement [`BatchItemHandler`] for your processing logic
23//! 3. Create a [`BatchQueue`] and register it in Tauri state
24//! 4. Call [`executor::spawn()`] to start the background processor
25
26pub mod eta;
27pub mod executor;
28pub mod queue;
29pub mod types;
30
31pub use queue::BatchQueue;
32pub use types::{
33    BatchCompletionSummary, BatchItem, BatchItemStatus, BatchJob, BatchJobStatus, EtaConfidence,
34    EtaEstimate, ItemResult, OverwritePolicy, SchedulingConfig, SizeBucket,
35};
36
37/// Trait for processing individual items in a batch.
38///
39/// Implement this for your application to define:
40/// - How to process each item (`process`)
41/// - Whether an item should be skipped (`should_skip`)
42///
43/// # Type Parameter
44///
45/// `D` is the per-item data type (e.g. a file path, image reference, document ID).
46///
47/// # Example
48///
49/// ```ignore
50/// use ai_batch_queue::*;
51///
52/// struct MyProcessor;
53///
54/// impl BatchItemHandler<String> for MyProcessor {
55///     async fn process(
56///         &self,
57///         data: &String,
58///         resource_key: &str,
59///         operation: &str,
60///     ) -> anyhow::Result<ItemResult> {
61///         println!("Processing {} with {}", data, resource_key);
62///         Ok(ItemResult::success())
63///     }
64///
65///     fn should_skip(&self, data: &String, operation: &str) -> bool {
66///         false // never skip
67///     }
68/// }
69/// ```
70pub trait BatchItemHandler<D>: Send + Sync + 'static
71where
72    D: Clone + Send + Sync + serde::Serialize,
73{
74    /// Process a single item.
75    ///
76    /// # Arguments
77    /// * `data` — the item's user-defined data payload
78    /// * `resource_key` — the resource this batch uses (e.g. model name)
79    /// * `operation` — the operation label (e.g. "tag", "caption")
80    fn process(
81        &self,
82        data: &D,
83        resource_key: &str,
84        operation: &str,
85    ) -> impl std::future::Future<Output = anyhow::Result<ItemResult>> + Send;
86
87    /// Check if this item should be skipped when the overwrite policy is `Skip`.
88    ///
89    /// Return `true` to skip (item already has results).
90    /// Default implementation never skips.
91    fn should_skip(&self, _data: &D, _operation: &str) -> bool {
92        false
93    }
94}
95
96/// Trait for persisting batch queue state.
97///
98/// The default [`BatchQueue`] is in-memory only. Implement this trait to
99/// persist jobs across restarts (e.g., to SQLite or a file).
100pub trait BatchStore<D>: Send + Sync
101where
102    D: Clone + Send + Sync + serde::Serialize + serde::de::DeserializeOwned + 'static,
103{
104    /// Save or update a batch job.
105    fn save_job(&self, job: &BatchJob<D>) -> anyhow::Result<()>;
106
107    /// Load all jobs (for startup recovery).
108    fn load_all(&self) -> anyhow::Result<Vec<BatchJob<D>>>;
109
110    /// Delete a completed/cancelled job.
111    fn delete_job(&self, job_id: &str) -> anyhow::Result<()>;
112}
113
114/// Helper to build a [`BatchJob`] from a list of items.
115///
116/// Trace fields (`trace_ctx`, `attempt_id`, `trial_id`) are initialized to
117/// `None`. Callers that need trace propagation should set them after
118/// construction or use [`build_job_traced`].
119///
120/// # Example
121///
122/// ```
123/// use ai_batch_queue::*;
124///
125/// let job = build_job(
126///     "llava:13b",
127///     "tag",
128///     OverwritePolicy::Skip,
129///     vec![
130///         ("img-1".to_string(), "path/to/1.png".to_string(), SizeBucket::Medium),
131///         ("img-2".to_string(), "path/to/2.png".to_string(), SizeBucket::Large),
132///     ],
133/// );
134///
135/// assert_eq!(job.items.len(), 2);
136/// assert_eq!(job.resource_key, "llava:13b");
137/// ```
138pub fn build_job<D>(
139    resource_key: &str,
140    operation: &str,
141    overwrite_policy: OverwritePolicy,
142    items: Vec<(String, D, SizeBucket)>,
143) -> BatchJob<D>
144where
145    D: Clone + Send + Sync + serde::Serialize,
146{
147    let batch_items = items
148        .into_iter()
149        .map(|(id, data, bucket)| BatchItem {
150            id,
151            data,
152            status: BatchItemStatus::Pending,
153            error: None,
154            duration_ms: None,
155            size_bucket: bucket,
156            trace_ctx: None,
157            attempt_id: None,
158            trial_id: None,
159        })
160        .collect();
161
162    BatchJob {
163        id: String::new(),
164        resource_key: resource_key.to_string(),
165        operation: operation.to_string(),
166        overwrite_policy,
167        items: batch_items,
168        status: BatchJobStatus::Queued,
169        created_at: String::new(),
170        started_at: None,
171        completed_at: None,
172        reordered: false,
173        reorder_note: None,
174    }
175}
176
177/// Helper to build a [`BatchJob`] with trace context propagation.
178///
179/// Each item receives the provided `trace_ctx` and a freshly generated
180/// `AttemptId` (one per item, since each item is its own retry-owner
181/// boundary within the batch). `TrialId` is left `None` — it will be
182/// stamped by the executor on each concrete execution.
183pub fn build_job_traced<D>(
184    resource_key: &str,
185    operation: &str,
186    overwrite_policy: OverwritePolicy,
187    items: Vec<(String, D, SizeBucket)>,
188    trace_ctx: stack_ids::TraceCtx,
189) -> BatchJob<D>
190where
191    D: Clone + Send + Sync + serde::Serialize,
192{
193    let batch_items = items
194        .into_iter()
195        .map(|(id, data, bucket)| BatchItem {
196            id,
197            data,
198            status: BatchItemStatus::Pending,
199            error: None,
200            duration_ms: None,
201            size_bucket: bucket,
202            trace_ctx: Some(trace_ctx.clone()),
203            attempt_id: Some(stack_ids::AttemptId::generate()),
204            trial_id: None,
205        })
206        .collect();
207
208    BatchJob {
209        id: String::new(),
210        resource_key: resource_key.to_string(),
211        operation: operation.to_string(),
212        overwrite_policy,
213        items: batch_items,
214        status: BatchJobStatus::Queued,
215        created_at: String::new(),
216        started_at: None,
217        completed_at: None,
218        reordered: false,
219        reorder_note: None,
220    }
221}