Skip to main content

common/
long_operation.rs

1// Generated by Qleany v1.8.0 from long_operation.tera
2//! This module provides a framework for managing long-running operations with the ability to track
3//! status, progress, and enable cancellation. It includes the infrastructure for defining, executing,
4//! and monitoring such operations. For undoable operations, it is recommended to use the Undo/Redo framework.
5//!
6//! # Components:
7//!
8//! - **OperationStatus**: Enum representing the state of an operation.
9//! - **OperationProgress**: Struct holding details about the progress of an operation.
10//! - **LongOperation**: Trait that must be implemented by any long-running operation.
11//! - **LongOperationManager**: Manager that orchestrates the execution, tracking, and cleanup of multiple operations.
12//!
13//! # Usage:
14//!
15//! 1. Implement the `LongOperation` trait for your task.
16//! 2. Use `LongOperationManager` to start, track, and manage your operations.
17//! 3. Access methods like:
18//!     - `start_operation` to start new operations.
19//!     - `get_operation_status`, `get_operation_progress` to query operation details.
20//!     - `cancel_operation` to cancel operations.
21//!     - `cleanup_finished_operations` to remove completed or cancelled operations.
22//!
23//! # Example:
24//!
25//! ```rust,ignore
26//! // Define your long-running operation
27//! use std::sync::Arc;
28//! use std::sync::atomic::{AtomicBool, Ordering};
29//! use std::thread;
30//! use std::time::Duration;
31//! use common::long_operation::{LongOperation, LongOperationManager, OperationProgress};
32//!
33//! pub struct MyOperation {
34//!     pub total_steps: usize,
35//! }
36//!
37//! impl LongOperation for MyOperation {
38//!     fn execute(
39//!         &self,
40//!         progress_callback: Box<dyn Fn(OperationProgress) + Send>,
41//!         cancel_flag: Arc<AtomicBool>,
42//!     ) -> Result<(), String> {
43//!         for i in 0..self.total_steps {
44//!             if cancel_flag.load(Ordering::Relaxed) {
45//!                 return Err("Operation cancelled".to_string());
46//!             }
47//!             thread::sleep(Duration::from_millis(500));
48//!             progress_callback(OperationProgress::new(
49//!                 (i as f32 / self.total_steps as f32) * 100.0,
50//!                 Some(format!("Step {}/{}", i + 1, self.total_steps)),
51//!             ));
52//!         }
53//!         Ok(())
54//!     }
55//! }
56//!
57//! let manager = LongOperationManager::new();
58//! let my_operation = MyOperation { total_steps: 5 };
59//! let operation_id = manager.start_operation(my_operation);
60//!
61//! while let Some(status) = manager.get_operation_status(&operation_id) {
62//!     println!("{:?}", status);
63//!     thread::sleep(Duration::from_millis(100));
64//! }
65//! ```
66//!
67//! # Notes:
68//!
69//! - Thread-safety is ensured through the use of `Arc<Mutex<T>>` and `AtomicBool`.
70//! - Operations run in their own threads, ensuring non-blocking execution.
71//! - Proper cleanup of finished operations is encouraged using `cleanup_finished_operations`.
72//!
73//! # Definitions:
74//!
75//! ## `OperationStatus`
76//! Represents the state of an operation. Possible states are:
77//! - `Running`: Operation is ongoing.
78//! - `Completed`: Operation finished successfully.
79//! - `Cancelled`: Operation was cancelled by the user.
80//! - `Failed(String)`: Operation failed with an error message.
81//!
82//! ## `OperationProgress`
83//! Describes the progress of an operation, including:
84//! - `percentage` (0.0 to 100.0): Indicates completion progress.
85//! - `message`: Optional user-defined progress description.
86//!
87//! ## `LongOperation` Trait
88//! Any custom long-running operation must implement this trait:
89//! - `execute`: Defines the operation logic, accepting a progress callback and cancellation flag.
90//!
91//! ## `LongOperationManager`
92//! Provides APIs to manage operations, including:
93//! - `start_operation`: Starts a new operation and returns its unique ID.
94//! - `get_operation_status`: Queries the current status of an operation.
95//! - `get_operation_progress`: Retrieves the progress of an operation.
96//! - `cancel_operation`: Cancels an operation.
97//! - `cleanup_finished_operations`: Removes completed or cancelled operations to free resources.
98//!
99//! ## Example Operation: FileProcessingOperation
100//! Represents a long-running operation to process files. Demonstrates typical usage of the framework.
101//!
102//! - **Fields**:
103//!     - `file_path`: Path of the file to process.
104//!     - `total_files`: Number of files to process.
105//! - **Behavior**:
106//!   Simulates file processing with periodic progress updates. Supports cancellation.
107//!
108//!
109
110// Generated by Qleany. Edit at your own risk! Be careful when regenerating this file
111// as changes will be lost.
112
113use crate::event::{Event, EventHub, LongOperationEvent, Origin};
114use anyhow::Result;
115use std::collections::{HashMap, HashSet};
116use std::sync::{
117    Arc, Condvar, Mutex,
118    atomic::{AtomicBool, Ordering},
119};
120use std::thread;
121use std::time::Duration;
122
123// Status of a long operation
124#[derive(Debug, Clone, PartialEq)]
125pub enum OperationStatus {
126    Running,
127    Completed,
128    Cancelled,
129    Failed(String),
130}
131
132// Progress information
133#[derive(Debug, Clone)]
134pub struct OperationProgress {
135    pub percentage: f32, // 0.0 to 100.0
136    pub message: Option<String>,
137}
138
139impl OperationProgress {
140    pub fn new(percentage: f32, message: Option<String>) -> Self {
141        Self {
142            percentage: percentage.clamp(0.0, 100.0),
143            message,
144        }
145    }
146}
147
148// Trait that long operations must implement
149pub trait LongOperation: Send + 'static {
150    type Output: Send + Sync + 'static + serde::Serialize;
151
152    fn execute(
153        &self,
154        progress_callback: Box<dyn Fn(OperationProgress) + Send>,
155        cancel_flag: Arc<AtomicBool>,
156    ) -> Result<Self::Output>;
157}
158
159// Trait for operation handles (type-erased)
160trait OperationHandleTrait: Send {
161    fn get_status(&self) -> OperationStatus;
162    fn get_progress(&self) -> OperationProgress;
163    fn cancel(&self);
164    fn is_finished(&self) -> bool;
165}
166
167/// Lock a mutex, recovering from poisoning by returning the inner value.
168///
169/// If a thread panicked while holding the lock, the data may be in an
170/// inconsistent state, but for status/progress tracking this is preferable
171/// to propagating the panic.
172fn lock_or_recover<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
173    mutex
174        .lock()
175        .unwrap_or_else(|poisoned| poisoned.into_inner())
176}
177
178/// The completion signal every operation publishes to when it ends.
179///
180/// This is what lets a caller **block until** an operation finishes instead of
181/// re-checking it on a timer. A worker marks its id finished here as the very
182/// last thing it does — after the result is stored, the event emitted, and the
183/// final status set — so a waiter woken by it is guaranteed to observe all
184/// three. In particular, `is_finished(id) == true` implies
185/// [`LongOperationManager::get_operation_result`] already has the result (for a
186/// `Completed` operation), which is what makes an event-free blocking wait
187/// correct.
188///
189/// Obtain one via [`LongOperationManager::completion_signal`] and block on it
190/// **after** releasing any lock guarding the manager — see that method.
191pub struct OperationCompletion {
192    /// Ids that reached a final status. Deliberately never pruned: it mirrors
193    /// the manager's `results` map, so a wait on an already-finished (even
194    /// cleaned-up) operation returns at once instead of blocking on a signal
195    /// that can no longer come.
196    finished: Mutex<HashSet<String>>,
197    condvar: Condvar,
198}
199
200impl OperationCompletion {
201    fn new() -> Self {
202        Self {
203            finished: Mutex::new(HashSet::new()),
204            condvar: Condvar::new(),
205        }
206    }
207
208    /// Publish `id` as finished and wake every waiter.
209    ///
210    /// `notify_all`, not `notify_one`: waiters block on one shared condvar but
211    /// each waits for its *own* id, so waking a single arbitrary thread could
212    /// wake one waiting on a different operation and leave the right one asleep.
213    fn mark_finished(&self, id: &str) {
214        let mut finished = lock_or_recover(&self.finished);
215        finished.insert(id.to_string());
216        drop(finished);
217        self.condvar.notify_all();
218    }
219
220    /// Has `id` already reached a final status?
221    pub fn is_finished(&self, id: &str) -> bool {
222        lock_or_recover(&self.finished).contains(id)
223    }
224
225    /// Block until `id` reaches a final status; `true` once it has.
226    ///
227    /// There is no lost-wakeup window: the finished-set is inspected while
228    /// holding the very mutex the condvar is paired with, and a worker must take
229    /// that same mutex to publish — so an operation that ends between a caller's
230    /// check and its wait is already recorded and returns immediately.
231    ///
232    /// `timeout` bounds one wait, not the whole call, and exists only as a
233    /// backstop for a signal that can never arrive (e.g. a worker killed before
234    /// publishing). `None` waits indefinitely.
235    pub fn wait_for(&self, id: &str, timeout: Option<Duration>) -> bool {
236        let mut finished = lock_or_recover(&self.finished);
237        loop {
238            if finished.contains(id) {
239                return true;
240            }
241            match timeout {
242                Some(t) => {
243                    let (guard, outcome) = self
244                        .condvar
245                        .wait_timeout(finished, t)
246                        .unwrap_or_else(|poisoned| poisoned.into_inner());
247                    finished = guard;
248                    if outcome.timed_out() {
249                        return finished.contains(id);
250                    }
251                }
252                None => {
253                    finished = self
254                        .condvar
255                        .wait(finished)
256                        .unwrap_or_else(|poisoned| poisoned.into_inner());
257                }
258            }
259        }
260    }
261}
262
263impl std::fmt::Debug for OperationCompletion {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        f.debug_struct("OperationCompletion")
266            .field("finished_len", &lock_or_recover(&self.finished).len())
267            .finish()
268    }
269}
270
271// Concrete handle implementation
272struct OperationHandle {
273    status: Arc<Mutex<OperationStatus>>,
274    progress: Arc<Mutex<OperationProgress>>,
275    cancel_flag: Arc<AtomicBool>,
276    _join_handle: thread::JoinHandle<()>,
277}
278
279impl OperationHandleTrait for OperationHandle {
280    fn get_status(&self) -> OperationStatus {
281        lock_or_recover(&self.status).clone()
282    }
283
284    fn get_progress(&self) -> OperationProgress {
285        lock_or_recover(&self.progress).clone()
286    }
287
288    fn cancel(&self) {
289        self.cancel_flag.store(true, Ordering::Relaxed);
290        let mut status = lock_or_recover(&self.status);
291        if matches!(*status, OperationStatus::Running) {
292            *status = OperationStatus::Cancelled;
293        }
294    }
295
296    fn is_finished(&self) -> bool {
297        matches!(
298            self.get_status(),
299            OperationStatus::Completed | OperationStatus::Cancelled | OperationStatus::Failed(_)
300        )
301    }
302}
303
304// Manager for long operations
305pub struct LongOperationManager {
306    operations: Arc<Mutex<HashMap<String, Box<dyn OperationHandleTrait>>>>,
307    next_id: Arc<Mutex<u64>>,
308    results: Arc<Mutex<HashMap<String, String>>>, // Store serialized results
309    event_hub: Option<Arc<EventHub>>,
310    /// Signalled by each worker as it ends; lets callers block until an
311    /// operation finishes rather than polling. See [`OperationCompletion`].
312    completion: Arc<OperationCompletion>,
313}
314
315impl std::fmt::Debug for LongOperationManager {
316    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317        let operations_len = lock_or_recover(&self.operations).len();
318        let next_id = *lock_or_recover(&self.next_id);
319        let results_len = lock_or_recover(&self.results).len();
320
321        f.debug_struct("LongOperationManager")
322            .field("operations_len", &operations_len)
323            .field("next_id", &next_id)
324            .field("results_len", &results_len)
325            .field("event_hub_set", &self.event_hub.is_some())
326            .finish()
327    }
328}
329
330impl LongOperationManager {
331    pub fn new() -> Self {
332        Self {
333            operations: Arc::new(Mutex::new(HashMap::new())),
334            next_id: Arc::new(Mutex::new(0)),
335            results: Arc::new(Mutex::new(HashMap::new())),
336            event_hub: None,
337            completion: Arc::new(OperationCompletion::new()),
338        }
339    }
340
341    /// Inject the event hub to allow sending long operation related events
342    pub fn set_event_hub(&mut self, event_hub: &Arc<EventHub>) {
343        self.event_hub = Some(Arc::clone(event_hub));
344    }
345
346    /// Start a new long operation and return its ID
347    pub fn start_operation<Op: LongOperation>(&self, operation: Op) -> String {
348        let id = {
349            let mut next_id = lock_or_recover(&self.next_id);
350            *next_id += 1;
351            format!("op_{}", *next_id)
352        };
353
354        // Emit started event
355        if let Some(event_hub) = &self.event_hub {
356            event_hub.send_event(Event {
357                origin: Origin::LongOperation(LongOperationEvent::Started),
358                ids: vec![],
359                data: Some(id.clone()),
360            });
361        }
362
363        let status = Arc::new(Mutex::new(OperationStatus::Running));
364        let progress = Arc::new(Mutex::new(OperationProgress::new(0.0, None)));
365        let cancel_flag = Arc::new(AtomicBool::new(false));
366
367        let status_clone = status.clone();
368        let progress_clone = progress.clone();
369        let cancel_flag_clone = cancel_flag.clone();
370        let results_clone = self.results.clone();
371        let id_clone = id.clone();
372        let event_hub_opt = self.event_hub.clone();
373        let completion_clone = self.completion.clone();
374
375        let join_handle = thread::spawn(move || {
376            let progress_callback = {
377                let progress = progress_clone.clone();
378                let event_hub_opt = event_hub_opt.clone();
379                let id_for_cb = id_clone.clone();
380                Box::new(move |prog: OperationProgress| {
381                    *lock_or_recover(&progress) = prog.clone();
382                    if let Some(event_hub) = &event_hub_opt {
383                        let payload = serde_json::json!({
384                            "id": id_for_cb,
385                            "percentage": prog.percentage,
386                            "message": prog.message,
387                        })
388                        .to_string();
389                        event_hub.send_event(Event {
390                            origin: Origin::LongOperation(LongOperationEvent::Progress),
391                            ids: vec![],
392                            data: Some(payload),
393                        });
394                    }
395                }) as Box<dyn Fn(OperationProgress) + Send>
396            };
397
398            // Catch a panic in the operation body so a background failure is
399            // reported as `Failed` (with the panic message) instead of leaving
400            // the operation stuck at `Running` and the thread silently dead.
401            let operation_result =
402                match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
403                    operation.execute(progress_callback, cancel_flag_clone.clone())
404                })) {
405                    Ok(result) => result,
406                    Err(payload) => {
407                        let msg = payload
408                            .downcast_ref::<&str>()
409                            .map(|s| s.to_string())
410                            .or_else(|| payload.downcast_ref::<String>().cloned())
411                            .unwrap_or_else(|| "operation panicked".to_string());
412                        Err(anyhow::anyhow!("operation panicked: {msg}"))
413                    }
414                };
415
416            let final_status = if cancel_flag_clone.load(Ordering::Relaxed) {
417                OperationStatus::Cancelled
418            } else {
419                match &operation_result {
420                    Ok(result) => {
421                        // Store the result
422                        if let Ok(serialized) = serde_json::to_string(result) {
423                            let mut results = lock_or_recover(&results_clone);
424                            results.insert(id_clone.clone(), serialized);
425                        }
426                        OperationStatus::Completed
427                    }
428                    Err(e) => OperationStatus::Failed(e.to_string()),
429                }
430            };
431
432            // Emit final status event
433            if let Some(event_hub) = &event_hub_opt {
434                let (event, data) = match &final_status {
435                    OperationStatus::Completed => (
436                        LongOperationEvent::Completed,
437                        serde_json::json!({"id": id_clone}).to_string(),
438                    ),
439                    OperationStatus::Cancelled => (
440                        LongOperationEvent::Cancelled,
441                        serde_json::json!({"id": id_clone}).to_string(),
442                    ),
443                    OperationStatus::Failed(err) => (
444                        LongOperationEvent::Failed,
445                        serde_json::json!({"id": id_clone, "error": err}).to_string(),
446                    ),
447                    OperationStatus::Running => (
448                        LongOperationEvent::Progress,
449                        serde_json::json!({"id": id_clone}).to_string(),
450                    ),
451                };
452                event_hub.send_event(Event {
453                    origin: Origin::LongOperation(event),
454                    ids: vec![],
455                    data: Some(data),
456                });
457            }
458
459            *lock_or_recover(&status_clone) = final_status;
460
461            // Publish completion **last** — after the result is stored, the
462            // event emitted and the final status written — so any thread woken
463            // by this observes a fully-settled operation rather than racing the
464            // bookkeeping that follows it.
465            completion_clone.mark_finished(&id_clone);
466        });
467
468        let handle = OperationHandle {
469            status,
470            progress,
471            cancel_flag,
472            _join_handle: join_handle,
473        };
474
475        lock_or_recover(&self.operations).insert(id.clone(), Box::new(handle));
476
477        id
478    }
479
480    /// Get the status of an operation
481    pub fn get_operation_status(&self, id: &str) -> Option<OperationStatus> {
482        let operations = lock_or_recover(&self.operations);
483        operations.get(id).map(|handle| handle.get_status())
484    }
485
486    /// Get the progress of an operation
487    pub fn get_operation_progress(&self, id: &str) -> Option<OperationProgress> {
488        let operations = lock_or_recover(&self.operations);
489        operations.get(id).map(|handle| handle.get_progress())
490    }
491
492    /// Cancel an operation
493    pub fn cancel_operation(&self, id: &str) -> bool {
494        let operations = lock_or_recover(&self.operations);
495        if let Some(handle) = operations.get(id) {
496            handle.cancel();
497            // Emit cancelled event immediately
498            if let Some(event_hub) = &self.event_hub {
499                let payload = serde_json::json!({"id": id}).to_string();
500                event_hub.send_event(Event {
501                    origin: Origin::LongOperation(LongOperationEvent::Cancelled),
502                    ids: vec![],
503                    data: Some(payload),
504                });
505            }
506            true
507        } else {
508            false
509        }
510    }
511
512    /// Check if an operation is finished
513    pub fn is_operation_finished(&self, id: &str) -> Option<bool> {
514        let operations = lock_or_recover(&self.operations);
515        operations.get(id).map(|handle| handle.is_finished())
516    }
517
518    /// Remove finished operations from memory
519    pub fn cleanup_finished_operations(&self) {
520        let mut operations = lock_or_recover(&self.operations);
521        operations.retain(|_, handle| !handle.is_finished());
522    }
523
524    /// Get list of all operation IDs
525    pub fn list_operations(&self) -> Vec<String> {
526        let operations = lock_or_recover(&self.operations);
527        operations.keys().cloned().collect()
528    }
529
530    /// Get summary of all operations
531    pub fn get_operations_summary(&self) -> Vec<(String, OperationStatus, OperationProgress)> {
532        let operations = lock_or_recover(&self.operations);
533        operations
534            .iter()
535            .map(|(id, handle)| (id.clone(), handle.get_status(), handle.get_progress()))
536            .collect()
537    }
538
539    /// Store an operation result
540    pub fn store_operation_result<T: serde::Serialize>(&self, id: &str, result: T) -> Result<()> {
541        let serialized = serde_json::to_string(&result)?;
542        let mut results = lock_or_recover(&self.results);
543        results.insert(id.to_string(), serialized);
544        Ok(())
545    }
546
547    /// Get an operation result
548    pub fn get_operation_result(&self, id: &str) -> Option<String> {
549        let results = lock_or_recover(&self.results);
550        results.get(id).cloned()
551    }
552
553    /// A cloneable handle to the completion signal — the supported way to
554    /// **block** until an operation finishes instead of re-checking it on a
555    /// timer.
556    ///
557    /// Handed out as an `Arc` by design. A manager is normally reached through a
558    /// shared lock, and blocking while holding that lock would stall every other
559    /// operation query for as long as the wait lasts. So take the handle, drop
560    /// the manager lock, *then* wait:
561    ///
562    /// ```rust,ignore
563    /// let completion = ctx.long_operation_manager.lock().completion_signal();
564    /// // manager lock released here
565    /// completion.wait_for(&op_id, Some(Duration::from_secs(30)));
566    /// let result = ctx.long_operation_manager.lock().get_operation_result(&op_id);
567    /// ```
568    pub fn completion_signal(&self) -> Arc<OperationCompletion> {
569        Arc::clone(&self.completion)
570    }
571
572    /// Block until `id` finishes; `true` once it has.
573    ///
574    /// Convenience for an owner holding the manager directly. **Do not call this
575    /// through a shared lock** — it blocks for the operation's whole duration and
576    /// would hold that lock the entire time; take
577    /// [`completion_signal`](Self::completion_signal) and wait on that instead.
578    pub fn wait_for_operation(&self, id: &str, timeout: Option<Duration>) -> bool {
579        self.completion.wait_for(id, timeout)
580    }
581}
582
583impl Default for LongOperationManager {
584    fn default() -> Self {
585        Self::new()
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use anyhow::anyhow;
593    use std::time::Duration;
594
595    // Example implementation of a long operation
596    pub struct FileProcessingOperation {
597        pub _file_path: String,
598        pub total_files: usize,
599    }
600
601    impl LongOperation for FileProcessingOperation {
602        type Output = ();
603
604        fn execute(
605            &self,
606            progress_callback: Box<dyn Fn(OperationProgress) + Send>,
607            cancel_flag: Arc<AtomicBool>,
608        ) -> Result<Self::Output> {
609            for i in 0..self.total_files {
610                // Check if operation was cancelled
611                if cancel_flag.load(Ordering::Relaxed) {
612                    return Err(anyhow!("Operation was cancelled".to_string()));
613                }
614
615                // Simulate work
616                thread::sleep(Duration::from_millis(500));
617
618                // Report progress
619                let percentage = (i as f32 / self.total_files as f32) * 100.0;
620                progress_callback(OperationProgress::new(
621                    percentage,
622                    Some(format!("Processing file {} of {}", i + 1, self.total_files)),
623                ));
624            }
625
626            // Final progress
627            progress_callback(OperationProgress::new(100.0, Some("Completed".to_string())));
628            Ok(())
629        }
630    }
631
632    /// An operation that finishes at once and returns a value — the shape of a
633    /// small import, and the case a blocking wait must add no latency to.
634    pub struct InstantOperation;
635
636    impl LongOperation for InstantOperation {
637        type Output = u32;
638
639        fn execute(
640            &self,
641            _progress_callback: Box<dyn Fn(OperationProgress) + Send>,
642            _cancel_flag: Arc<AtomicBool>,
643        ) -> Result<Self::Output> {
644            Ok(42)
645        }
646    }
647
648    /// The whole point of the completion signal: a caller is woken the moment the
649    /// worker publishes, with no polling interval and no fixed sleep floor. A
650    /// trivial operation must therefore settle in far less than a timer tick —
651    /// this is what stops N sequential small operations costing N × a poll period.
652    #[test]
653    fn wait_for_returns_as_soon_as_the_operation_finishes() {
654        let manager = LongOperationManager::new();
655        let completion = manager.completion_signal();
656        let started = std::time::Instant::now();
657        let op_id = manager.start_operation(InstantOperation);
658
659        assert!(completion.wait_for(&op_id, Some(Duration::from_secs(5))));
660        let elapsed = started.elapsed();
661
662        // The result is stored *before* completion is published, so a woken
663        // waiter can always read it straight back.
664        assert_eq!(
665            manager.get_operation_result(&op_id).as_deref(),
666            Some("42"),
667            "completion must imply the result is already retrievable"
668        );
669        assert!(
670            elapsed < Duration::from_millis(50),
671            "a trivial operation must not wait out a polling tick (took {elapsed:?})"
672        );
673    }
674
675    /// Waiting on an operation that has already finished returns at once, instead
676    /// of blocking for a signal that was sent before the caller arrived.
677    #[test]
678    fn wait_for_an_already_finished_operation_returns_immediately() {
679        let manager = LongOperationManager::new();
680        let completion = manager.completion_signal();
681        let op_id = manager.start_operation(InstantOperation);
682        assert!(completion.wait_for(&op_id, Some(Duration::from_secs(5))));
683
684        // The finished record persists, so a second wait is instant.
685        let started = std::time::Instant::now();
686        assert!(completion.wait_for(&op_id, Some(Duration::from_secs(5))));
687        assert!(started.elapsed() < Duration::from_millis(50));
688        assert!(completion.is_finished(&op_id));
689    }
690
691    /// The timeout bounds one wait; it is not a deadline for the operation. A
692    /// still-running operation reports `false` and leaves the caller free to wait
693    /// again.
694    #[test]
695    fn wait_for_times_out_while_the_operation_is_still_running() {
696        let manager = LongOperationManager::new();
697        let completion = manager.completion_signal();
698        let op_id = manager.start_operation(FileProcessingOperation {
699            _file_path: "/tmp/test".to_string(),
700            total_files: 5,
701        });
702
703        assert!(
704            !completion.wait_for(&op_id, Some(Duration::from_millis(50))),
705            "a running operation has published no completion"
706        );
707        assert!(!completion.is_finished(&op_id));
708        manager.cancel_operation(&op_id);
709    }
710
711    /// A cancelled operation must still publish completion — otherwise a caller
712    /// blocked on it would be stranded forever.
713    #[test]
714    fn a_cancelled_operation_still_publishes_completion() {
715        let manager = LongOperationManager::new();
716        let completion = manager.completion_signal();
717        let op_id = manager.start_operation(FileProcessingOperation {
718            _file_path: "/tmp/test".to_string(),
719            total_files: 5,
720        });
721        manager.cancel_operation(&op_id);
722
723        assert!(
724            completion.wait_for(&op_id, Some(Duration::from_secs(5))),
725            "cancellation must wake a blocked waiter, not strand it"
726        );
727    }
728
729    #[test]
730    fn test_operation_manager() {
731        let manager = LongOperationManager::new();
732
733        let operation = FileProcessingOperation {
734            _file_path: "/tmp/test".to_string(),
735            total_files: 5,
736        };
737
738        let op_id = manager.start_operation(operation);
739
740        // Check initial status
741        assert_eq!(
742            manager.get_operation_status(&op_id),
743            Some(OperationStatus::Running)
744        );
745
746        // Wait a bit and check progress
747        thread::sleep(Duration::from_millis(100));
748        let progress = manager.get_operation_progress(&op_id);
749        assert!(progress.is_some());
750
751        // Test cancellation
752        assert!(manager.cancel_operation(&op_id));
753        thread::sleep(Duration::from_millis(100));
754        assert_eq!(
755            manager.get_operation_status(&op_id),
756            Some(OperationStatus::Cancelled)
757        );
758    }
759}