Skip to main content

tauri_queue/
lib.rs

1//! # Tauri Queue
2//!
3//! Tauri integration for the `agent-queue` background job processing system.
4//!
5//! This crate provides a [`TauriEventEmitter`] that bridges job-queue events
6//! to Tauri's frontend event system, plus re-exports of all core job-queue types.
7//!
8//! ## Quick Start
9//!
10//! 1. Define a job type implementing [`JobHandler`]
11//! 2. Create a [`QueueManager`] with a [`QueueConfig`]
12//! 3. Add jobs with [`QueueManager::add()`]
13//! 4. Spawn the executor with [`QueueManager::spawn()`] using a [`TauriEventEmitter`]
14
15// Re-export everything from job-queue for backward compatibility
16pub use job_queue::config::{self, QueueConfig, QueueConfigBuilder};
17pub use job_queue::db;
18pub use job_queue::error::{self, QueueError};
19pub use job_queue::events::{self, *};
20pub use job_queue::executor;
21pub use job_queue::queue::{self, QueueManager};
22pub use job_queue::types::{self, JobResult, QueueJob, QueueJobStatus, QueuePriority};
23pub use job_queue::{JobContext, JobHandler, QueueEventEmitter};
24
25use serde::{Deserialize, Serialize};
26use std::collections::{HashMap, VecDeque};
27use std::sync::{Arc, Mutex};
28use std::time::{Duration, Instant};
29use tauri::{AppHandle, Emitter};
30
31// Re-export canonical trace context from stack-ids
32pub use stack_ids::TraceCtx;
33
34/// Extract a canonical [`TraceCtx`] from a legacy `trace_id` string, if present.
35///
36/// This is the recommended way to obtain structured trace context from job-queue
37/// events whose `trace_id` field is still `Option<String>`.
38///
39/// Phase status: current / canonical bridge utility.
40pub fn trace_ctx_from_event_trace_id(trace_id: &Option<String>) -> Option<TraceCtx> {
41    trace_id.as_deref().map(TraceCtx::from_legacy_trace_id)
42}
43
44/// Policy for handling event overflow when the downstream consumer is slow.
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
46pub enum DropPolicy {
47    /// Drop the oldest pending event to make room for the new one.
48    DropOldest,
49    /// Drop the incoming event when the buffer is full.
50    #[default]
51    DropNewest,
52    /// Block the emitter until space is available (not recommended in UI contexts).
53    Block,
54}
55
56/// Configuration for event emission backpressure and coalescing.
57///
58/// When the frontend cannot consume events as fast as they are produced
59/// (e.g., rapid-fire progress updates), this config controls buffering
60/// and deduplication behavior.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct EmitterConfig {
63    /// Maximum number of pending events before the drop policy kicks in.
64    /// Default: 256.
65    pub buffer_size: usize,
66    /// What to do when the buffer is full. Default: DropNewest.
67    pub drop_policy: DropPolicy,
68    /// Minimum interval between events of the same type for the same job.
69    /// Events arriving faster than this interval are coalesced (only the
70    /// latest value is kept). Default: 50 ms.
71    pub coalesce_interval_ms: u64,
72    /// Whether to include a `trace_id` field on emitted events (if available).
73    /// Default: false (legacy — use `include_trace_ctx` instead).
74    ///
75    /// Phase status: compatibility / migration-only.
76    /// The `trace_id` is a plain string extracted from `job_queue::QueueJob`.
77    /// The canonical replacement is [`include_trace_ctx`](EmitterConfig::include_trace_ctx).
78    /// When `include_trace_ctx` is true, trace strings are preserved regardless
79    /// of this flag.
80    ///
81    /// **Removal condition**: removed when all UI consumers migrate to `TraceCtx`.
82    #[deprecated(since = "0.3.0", note = "Use include_trace_ctx instead")]
83    pub include_trace_id: bool,
84    /// Whether to propagate canonical trace context on emitted events.
85    /// Default: true.
86    ///
87    /// When true, the legacy `trace_id: Option<String>` field is preserved
88    /// on events so that downstream consumers can convert it to a
89    /// [`TraceCtx`] via [`trace_ctx_from_event_trace_id`].
90    ///
91    /// This flag is the canonical replacement for [`include_trace_id`](EmitterConfig::include_trace_id).
92    /// When both flags disagree, `include_trace_ctx` takes precedence:
93    /// if `include_trace_ctx` is true the trace string is kept even when
94    /// `include_trace_id` is false.
95    ///
96    /// Phase status: current / canonical.
97    pub include_trace_ctx: bool,
98}
99
100#[allow(deprecated)]
101impl Default for EmitterConfig {
102    fn default() -> Self {
103        Self {
104            buffer_size: 256,
105            drop_policy: DropPolicy::default(),
106            coalesce_interval_ms: 50,
107            include_trace_id: false,
108            include_trace_ctx: true,
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn trace_ctx_bridge_preserves_legacy_trace_id() {
119        let trace = Some("ui-trace-123".to_string());
120        let ctx = trace_ctx_from_event_trace_id(&trace).expect("trace ctx expected");
121        assert_eq!(ctx.trace_id, "ui-trace-123");
122    }
123
124    #[test]
125    fn trace_ctx_bridge_handles_missing_trace_id() {
126        assert!(trace_ctx_from_event_trace_id(&None).is_none());
127    }
128}
129
130impl EmitterConfig {
131    /// Returns true if the trace string should be kept on events.
132    ///
133    /// `include_trace_ctx` takes precedence over `include_trace_id`.
134    #[allow(deprecated)]
135    fn should_keep_trace(&self) -> bool {
136        self.include_trace_ctx || self.include_trace_id
137    }
138}
139
140/// A throttled event emitter that coalesces rapid-fire events.
141///
142/// Wraps any [`QueueEventEmitter`] and suppresses duplicate events for the
143/// same job that arrive within the configured coalesce interval.
144pub struct CoalescingEmitter {
145    inner: Arc<dyn QueueEventEmitter>,
146    config: EmitterConfig,
147    // One mutex owns all progress state so coalescing decisions cannot race
148    // across multiple lock acquisitions.
149    progress_state: Mutex<ProgressState>,
150}
151
152#[derive(Default)]
153struct ProgressState {
154    last_progress: HashMap<String, Instant>,
155    pending_progress: HashMap<String, JobProgressEvent>,
156    pending_order: VecDeque<String>,
157}
158
159impl ProgressState {
160    fn take_pending(&mut self, job_id: &str) -> Option<JobProgressEvent> {
161        let event = self.pending_progress.remove(job_id);
162        if event.is_some() {
163            self.pending_order
164                .retain(|queued_job_id| queued_job_id != job_id);
165        }
166        event
167    }
168
169    fn clear_job(&mut self, job_id: &str) -> Option<JobProgressEvent> {
170        let pending = self.take_pending(job_id);
171        self.last_progress.remove(job_id);
172        pending
173    }
174
175    fn enqueue_progress(
176        &mut self,
177        event: JobProgressEvent,
178        config: &EmitterConfig,
179    ) -> Option<JobProgressEvent> {
180        if self.pending_progress.contains_key(&event.job_id) {
181            self.pending_progress.insert(event.job_id.clone(), event);
182            return None;
183        }
184
185        if self.pending_progress.len() >= config.buffer_size.max(1) {
186            match config.drop_policy {
187                DropPolicy::DropNewest => return None,
188                DropPolicy::DropOldest => {
189                    if let Some(oldest_job_id) = self.pending_order.pop_front() {
190                        self.pending_progress.remove(&oldest_job_id);
191                    }
192                }
193                DropPolicy::Block => {
194                    let flushed = self
195                        .pending_order
196                        .pop_front()
197                        .and_then(|oldest_job_id| self.pending_progress.remove(&oldest_job_id));
198                    self.pending_order.push_back(event.job_id.clone());
199                    self.pending_progress.insert(event.job_id.clone(), event);
200                    return flushed;
201                }
202            }
203        }
204
205        self.pending_order.push_back(event.job_id.clone());
206        self.pending_progress.insert(event.job_id.clone(), event);
207        None
208    }
209}
210
211impl CoalescingEmitter {
212    pub fn new(inner: Arc<dyn QueueEventEmitter>, config: EmitterConfig) -> Self {
213        Self {
214            inner,
215            config,
216            progress_state: Mutex::new(ProgressState::default()),
217        }
218    }
219
220    pub fn arc(
221        inner: Arc<dyn QueueEventEmitter>,
222        config: EmitterConfig,
223    ) -> Arc<dyn QueueEventEmitter> {
224        Arc::new(Self::new(inner, config))
225    }
226
227    fn prepare_progress_emit(&self, event: JobProgressEvent) -> Vec<JobProgressEvent> {
228        let mut state = self
229            .progress_state
230            .lock()
231            .unwrap_or_else(|e| e.into_inner());
232        let interval = Duration::from_millis(self.config.coalesce_interval_ms);
233        let now = Instant::now();
234
235        if let Some(last) = state.last_progress.get(&event.job_id) {
236            if now.duration_since(*last) < interval {
237                return state
238                    .enqueue_progress(event, &self.config)
239                    .into_iter()
240                    .collect();
241            }
242        }
243
244        state.last_progress.insert(event.job_id.clone(), now);
245        let mut to_emit = Vec::with_capacity(2);
246        if let Some(pending) = state.take_pending(&event.job_id) {
247            to_emit.push(pending);
248        }
249        to_emit.push(event);
250        to_emit
251    }
252
253    /// Strip trace fields from an event when configured to omit them.
254    #[allow(deprecated)]
255    fn normalize_progress_event(&self, mut event: JobProgressEvent) -> JobProgressEvent {
256        if !self.config.should_keep_trace() {
257            event.trace_id = None;
258            event.trace_ctx = None;
259        }
260        event
261    }
262
263    fn take_terminal_pending(&self, job_id: &str) -> Option<JobProgressEvent> {
264        let mut state = self
265            .progress_state
266            .lock()
267            .unwrap_or_else(|e| e.into_inner());
268        state.clear_job(job_id)
269    }
270}
271
272#[allow(deprecated)]
273impl QueueEventEmitter for CoalescingEmitter {
274    fn emit_job_started(&self, event: JobStartedEvent) {
275        let mut event = event;
276        if !self.config.should_keep_trace() {
277            event.trace_id = None;
278            event.trace_ctx = None;
279        }
280        self.inner.emit_job_started(event);
281    }
282
283    fn emit_job_completed(&self, event: JobCompletedEvent) {
284        let mut event = event;
285        if !self.config.should_keep_trace() {
286            event.trace_id = None;
287            event.trace_ctx = None;
288        }
289        if let Some(pending) = self.take_terminal_pending(&event.job_id) {
290            self.inner.emit_job_progress(pending);
291        }
292        self.inner.emit_job_completed(event);
293    }
294
295    fn emit_job_failed(&self, event: JobFailedEvent) {
296        let mut event = event;
297        if !self.config.should_keep_trace() {
298            event.trace_id = None;
299            event.trace_ctx = None;
300        }
301        if let Some(pending) = self.take_terminal_pending(&event.job_id) {
302            self.inner.emit_job_progress(pending);
303        }
304        self.inner.emit_job_failed(event);
305    }
306
307    fn emit_job_progress(&self, event: JobProgressEvent) {
308        let event = self.normalize_progress_event(event);
309        for event in self.prepare_progress_emit(event) {
310            self.inner.emit_job_progress(event);
311        }
312    }
313
314    fn emit_job_cancelled(&self, event: JobCancelledEvent) {
315        let mut event = event;
316        if !self.config.should_keep_trace() {
317            event.trace_id = None;
318            event.trace_ctx = None;
319        }
320        if let Some(pending) = self.take_terminal_pending(&event.job_id) {
321            self.inner.emit_job_progress(pending);
322        }
323        self.inner.emit_job_cancelled(event);
324    }
325}
326
327/// Event emitter that bridges job-queue events to Tauri's frontend event system.
328///
329/// Wraps a `tauri::AppHandle` and emits events with the `queue:` prefix.
330pub struct TauriEventEmitter {
331    app_handle: AppHandle,
332}
333
334impl TauriEventEmitter {
335    /// Create a new Tauri event emitter from an app handle.
336    pub fn new(app_handle: AppHandle) -> Self {
337        Self { app_handle }
338    }
339
340    /// Create a new Tauri event emitter wrapped in an `Arc` for use with `QueueManager::spawn()`.
341    pub fn arc(app_handle: AppHandle) -> Arc<dyn QueueEventEmitter> {
342        Arc::new(Self::new(app_handle))
343    }
344}
345
346impl QueueEventEmitter for TauriEventEmitter {
347    fn emit_job_started(&self, event: JobStartedEvent) {
348        if let Err(e) = self.app_handle.emit("queue:job_started", event) {
349            tracing::debug!(error = %e, "Failed to emit queue:job_started event");
350        }
351    }
352
353    fn emit_job_completed(&self, event: JobCompletedEvent) {
354        if let Err(e) = self.app_handle.emit("queue:job_completed", event) {
355            tracing::debug!(error = %e, "Failed to emit queue:job_completed event");
356        }
357    }
358
359    fn emit_job_failed(&self, event: JobFailedEvent) {
360        if let Err(e) = self.app_handle.emit("queue:job_failed", event) {
361            tracing::debug!(error = %e, "Failed to emit queue:job_failed event");
362        }
363    }
364
365    fn emit_job_progress(&self, event: JobProgressEvent) {
366        if let Err(e) = self.app_handle.emit("queue:job_progress", event) {
367            tracing::debug!(error = %e, "Failed to emit queue:job_progress event");
368        }
369    }
370
371    fn emit_job_cancelled(&self, event: JobCancelledEvent) {
372        if let Err(e) = self.app_handle.emit("queue:job_cancelled", event) {
373            tracing::debug!(error = %e, "Failed to emit queue:job_cancelled event");
374        }
375    }
376}