Skip to main content

common/
long_operation.rs

1// Generated by Qleany v1.11.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 — and
168/// **clearing** the poison, so recovery is permanent rather than per-call.
169///
170/// If a thread panicked while holding the lock the data may be in an
171/// inconsistent state, but for the transaction handles, event buffers and
172/// status/progress trackers this guards, that is strictly preferable to
173/// propagating the panic: the release profile unwinds, so a panic inside a long
174/// operation is caught and reported as `Failed` — and every *later* use of the
175/// same mutex would otherwise panic again, turning one reported failure into a
176/// dead process.
177///
178/// `clear_poison` matches the `RwLock` counterparts in
179/// `database::hashmap_store` (`read_or_recover` / `write_or_recover`).
180/// `PoisonError::into_inner` alone leaves the flag set, so a plain
181/// `.lock().unwrap()` elsewhere in the process would still trip on a lock this
182/// function had already recovered.
183pub fn lock_or_recover<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
184    match mutex.lock() {
185        Ok(guard) => guard,
186        Err(poisoned) => {
187            let guard = poisoned.into_inner();
188            mutex.clear_poison();
189            guard
190        }
191    }
192}
193
194/// The completion signal every operation publishes to when it ends.
195///
196/// This is what lets a caller **block until** an operation finishes instead of
197/// re-checking it on a timer. A worker marks its id finished here as the very
198/// last thing it does — after the result is stored, the event emitted, and the
199/// final status set — so a waiter woken by it is guaranteed to observe all
200/// three. In particular, `is_finished(id) == true` implies
201/// [`LongOperationManager::get_operation_result`] already has the result (for a
202/// `Completed` operation), which is what makes an event-free blocking wait
203/// correct.
204///
205/// Obtain one via [`LongOperationManager::completion_signal`] and block on it
206/// **after** releasing any lock guarding the manager — see that method.
207pub struct OperationCompletion {
208    /// Ids that reached a final status. Deliberately never pruned: it mirrors
209    /// the manager's `results` map, so a wait on an already-finished (even
210    /// cleaned-up) operation returns at once instead of blocking on a signal
211    /// that can no longer come.
212    finished: Mutex<HashSet<String>>,
213    condvar: Condvar,
214}
215
216impl OperationCompletion {
217    fn new() -> Self {
218        Self {
219            finished: Mutex::new(HashSet::new()),
220            condvar: Condvar::new(),
221        }
222    }
223
224    /// Publish `id` as finished and wake every waiter.
225    ///
226    /// `notify_all`, not `notify_one`: waiters block on one shared condvar but
227    /// each waits for its *own* id, so waking a single arbitrary thread could
228    /// wake one waiting on a different operation and leave the right one asleep.
229    fn mark_finished(&self, id: &str) {
230        let mut finished = lock_or_recover(&self.finished);
231        finished.insert(id.to_string());
232        drop(finished);
233        self.condvar.notify_all();
234    }
235
236    /// Has `id` already reached a final status?
237    pub fn is_finished(&self, id: &str) -> bool {
238        lock_or_recover(&self.finished).contains(id)
239    }
240
241    /// Block until `id` reaches a final status; `true` once it has.
242    ///
243    /// There is no lost-wakeup window: the finished-set is inspected while
244    /// holding the very mutex the condvar is paired with, and a worker must take
245    /// that same mutex to publish — so an operation that ends between a caller's
246    /// check and its wait is already recorded and returns immediately.
247    ///
248    /// `timeout` bounds one wait, not the whole call, and exists only as a
249    /// backstop for a signal that can never arrive (e.g. a worker killed before
250    /// publishing). `None` waits indefinitely.
251    pub fn wait_for(&self, id: &str, timeout: Option<Duration>) -> bool {
252        let mut finished = lock_or_recover(&self.finished);
253        loop {
254            if finished.contains(id) {
255                return true;
256            }
257            match timeout {
258                Some(t) => {
259                    let (guard, outcome) = self
260                        .condvar
261                        .wait_timeout(finished, t)
262                        .unwrap_or_else(|poisoned| poisoned.into_inner());
263                    finished = guard;
264                    if outcome.timed_out() {
265                        return finished.contains(id);
266                    }
267                }
268                None => {
269                    finished = self
270                        .condvar
271                        .wait(finished)
272                        .unwrap_or_else(|poisoned| poisoned.into_inner());
273                }
274            }
275        }
276    }
277}
278
279impl std::fmt::Debug for OperationCompletion {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        f.debug_struct("OperationCompletion")
282            .field("finished_len", &lock_or_recover(&self.finished).len())
283            .finish()
284    }
285}
286
287// Concrete handle implementation
288struct OperationHandle {
289    status: Arc<Mutex<OperationStatus>>,
290    progress: Arc<Mutex<OperationProgress>>,
291    cancel_flag: Arc<AtomicBool>,
292    _join_handle: thread::JoinHandle<()>,
293}
294
295impl OperationHandleTrait for OperationHandle {
296    fn get_status(&self) -> OperationStatus {
297        lock_or_recover(&self.status).clone()
298    }
299
300    fn get_progress(&self) -> OperationProgress {
301        lock_or_recover(&self.progress).clone()
302    }
303
304    fn cancel(&self) {
305        self.cancel_flag.store(true, Ordering::Relaxed);
306        let mut status = lock_or_recover(&self.status);
307        if matches!(*status, OperationStatus::Running) {
308            *status = OperationStatus::Cancelled;
309        }
310    }
311
312    fn is_finished(&self) -> bool {
313        matches!(
314            self.get_status(),
315            OperationStatus::Completed | OperationStatus::Cancelled | OperationStatus::Failed(_)
316        )
317    }
318}
319
320// Manager for long operations
321pub struct LongOperationManager {
322    operations: Arc<Mutex<HashMap<String, Box<dyn OperationHandleTrait>>>>,
323    next_id: Arc<Mutex<u64>>,
324    results: Arc<Mutex<HashMap<String, String>>>, // Store serialized results
325    event_hub: Option<Arc<EventHub>>,
326    /// Signalled by each worker as it ends; lets callers block until an
327    /// operation finishes rather than polling. See [`OperationCompletion`].
328    completion: Arc<OperationCompletion>,
329}
330
331impl std::fmt::Debug for LongOperationManager {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        let operations_len = lock_or_recover(&self.operations).len();
334        let next_id = *lock_or_recover(&self.next_id);
335        let results_len = lock_or_recover(&self.results).len();
336
337        f.debug_struct("LongOperationManager")
338            .field("operations_len", &operations_len)
339            .field("next_id", &next_id)
340            .field("results_len", &results_len)
341            .field("event_hub_set", &self.event_hub.is_some())
342            .finish()
343    }
344}
345
346impl LongOperationManager {
347    pub fn new() -> Self {
348        Self {
349            operations: Arc::new(Mutex::new(HashMap::new())),
350            next_id: Arc::new(Mutex::new(0)),
351            results: Arc::new(Mutex::new(HashMap::new())),
352            event_hub: None,
353            completion: Arc::new(OperationCompletion::new()),
354        }
355    }
356
357    /// Inject the event hub to allow sending long operation related events
358    pub fn set_event_hub(&mut self, event_hub: &Arc<EventHub>) {
359        self.event_hub = Some(Arc::clone(event_hub));
360    }
361
362    /// Start a new long operation and return its ID
363    pub fn start_operation<Op: LongOperation>(&self, operation: Op) -> String {
364        let id = {
365            let mut next_id = lock_or_recover(&self.next_id);
366            *next_id += 1;
367            format!("op_{}", *next_id)
368        };
369
370        // Emit started event
371        if let Some(event_hub) = &self.event_hub {
372            event_hub.send_event(Event {
373                origin: Origin::LongOperation(LongOperationEvent::Started),
374                ids: vec![],
375                data: Some(id.clone()),
376            });
377        }
378
379        let status = Arc::new(Mutex::new(OperationStatus::Running));
380        let progress = Arc::new(Mutex::new(OperationProgress::new(0.0, None)));
381        let cancel_flag = Arc::new(AtomicBool::new(false));
382
383        let status_clone = status.clone();
384        let progress_clone = progress.clone();
385        let cancel_flag_clone = cancel_flag.clone();
386        let results_clone = self.results.clone();
387        let id_clone = id.clone();
388        let event_hub_opt = self.event_hub.clone();
389        let completion_clone = self.completion.clone();
390
391        let join_handle = thread::spawn(move || {
392            let progress_callback = {
393                let progress = progress_clone.clone();
394                let event_hub_opt = event_hub_opt.clone();
395                let id_for_cb = id_clone.clone();
396                Box::new(move |prog: OperationProgress| {
397                    *lock_or_recover(&progress) = prog.clone();
398                    if let Some(event_hub) = &event_hub_opt {
399                        let payload = serde_json::json!({
400                            "id": id_for_cb,
401                            "percentage": prog.percentage,
402                            "message": prog.message,
403                        })
404                        .to_string();
405                        event_hub.send_event(Event {
406                            origin: Origin::LongOperation(LongOperationEvent::Progress),
407                            ids: vec![],
408                            data: Some(payload),
409                        });
410                    }
411                }) as Box<dyn Fn(OperationProgress) + Send>
412            };
413
414            // Catch a panic in the operation body so a background failure is
415            // reported as `Failed` (with the panic message) instead of leaving
416            // the operation stuck at `Running` and the thread silently dead.
417            let operation_result =
418                match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
419                    operation.execute(progress_callback, cancel_flag_clone.clone())
420                })) {
421                    Ok(result) => result,
422                    Err(payload) => {
423                        let msg = payload
424                            .downcast_ref::<&str>()
425                            .map(|s| s.to_string())
426                            .or_else(|| payload.downcast_ref::<String>().cloned())
427                            .unwrap_or_else(|| "operation panicked".to_string());
428                        Err(anyhow::anyhow!("operation panicked: {msg}"))
429                    }
430                };
431
432            let final_status = if cancel_flag_clone.load(Ordering::Relaxed) {
433                OperationStatus::Cancelled
434            } else {
435                match &operation_result {
436                    Ok(result) => {
437                        // Store the result
438                        if let Ok(serialized) = serde_json::to_string(result) {
439                            let mut results = lock_or_recover(&results_clone);
440                            results.insert(id_clone.clone(), serialized);
441                        }
442                        OperationStatus::Completed
443                    }
444                    Err(e) => OperationStatus::Failed(e.to_string()),
445                }
446            };
447
448            // Emit final status event
449            if let Some(event_hub) = &event_hub_opt {
450                let (event, data) = match &final_status {
451                    OperationStatus::Completed => (
452                        LongOperationEvent::Completed,
453                        serde_json::json!({"id": id_clone}).to_string(),
454                    ),
455                    OperationStatus::Cancelled => (
456                        LongOperationEvent::Cancelled,
457                        serde_json::json!({"id": id_clone}).to_string(),
458                    ),
459                    OperationStatus::Failed(err) => (
460                        LongOperationEvent::Failed,
461                        serde_json::json!({"id": id_clone, "error": err}).to_string(),
462                    ),
463                    OperationStatus::Running => (
464                        LongOperationEvent::Progress,
465                        serde_json::json!({"id": id_clone}).to_string(),
466                    ),
467                };
468                event_hub.send_event(Event {
469                    origin: Origin::LongOperation(event),
470                    ids: vec![],
471                    data: Some(data),
472                });
473            }
474
475            *lock_or_recover(&status_clone) = final_status;
476
477            // Publish completion **last** — after the result is stored, the
478            // event emitted and the final status written — so any thread woken
479            // by this observes a fully-settled operation rather than racing the
480            // bookkeeping that follows it.
481            completion_clone.mark_finished(&id_clone);
482        });
483
484        let handle = OperationHandle {
485            status,
486            progress,
487            cancel_flag,
488            _join_handle: join_handle,
489        };
490
491        lock_or_recover(&self.operations).insert(id.clone(), Box::new(handle));
492
493        id
494    }
495
496    /// Get the status of an operation
497    pub fn get_operation_status(&self, id: &str) -> Option<OperationStatus> {
498        let operations = lock_or_recover(&self.operations);
499        operations.get(id).map(|handle| handle.get_status())
500    }
501
502    /// Get the progress of an operation
503    pub fn get_operation_progress(&self, id: &str) -> Option<OperationProgress> {
504        let operations = lock_or_recover(&self.operations);
505        operations.get(id).map(|handle| handle.get_progress())
506    }
507
508    /// Cancel an operation
509    pub fn cancel_operation(&self, id: &str) -> bool {
510        let operations = lock_or_recover(&self.operations);
511        if let Some(handle) = operations.get(id) {
512            handle.cancel();
513            // Emit cancelled event immediately
514            if let Some(event_hub) = &self.event_hub {
515                let payload = serde_json::json!({"id": id}).to_string();
516                event_hub.send_event(Event {
517                    origin: Origin::LongOperation(LongOperationEvent::Cancelled),
518                    ids: vec![],
519                    data: Some(payload),
520                });
521            }
522            true
523        } else {
524            false
525        }
526    }
527
528    /// Check if an operation is finished
529    pub fn is_operation_finished(&self, id: &str) -> Option<bool> {
530        let operations = lock_or_recover(&self.operations);
531        operations.get(id).map(|handle| handle.is_finished())
532    }
533
534    /// Remove finished operations from memory
535    pub fn cleanup_finished_operations(&self) {
536        let mut operations = lock_or_recover(&self.operations);
537        operations.retain(|_, handle| !handle.is_finished());
538    }
539
540    /// Get list of all operation IDs
541    pub fn list_operations(&self) -> Vec<String> {
542        let operations = lock_or_recover(&self.operations);
543        operations.keys().cloned().collect()
544    }
545
546    /// Get summary of all operations
547    pub fn get_operations_summary(&self) -> Vec<(String, OperationStatus, OperationProgress)> {
548        let operations = lock_or_recover(&self.operations);
549        operations
550            .iter()
551            .map(|(id, handle)| (id.clone(), handle.get_status(), handle.get_progress()))
552            .collect()
553    }
554
555    /// Store an operation result
556    pub fn store_operation_result<T: serde::Serialize>(&self, id: &str, result: T) -> Result<()> {
557        let serialized = serde_json::to_string(&result)?;
558        let mut results = lock_or_recover(&self.results);
559        results.insert(id.to_string(), serialized);
560        Ok(())
561    }
562
563    /// Get an operation result
564    pub fn get_operation_result(&self, id: &str) -> Option<String> {
565        let results = lock_or_recover(&self.results);
566        results.get(id).cloned()
567    }
568
569    /// A cloneable handle to the completion signal — the supported way to
570    /// **block** until an operation finishes instead of re-checking it on a
571    /// timer.
572    ///
573    /// Handed out as an `Arc` by design. A manager is normally reached through a
574    /// shared lock, and blocking while holding that lock would stall every other
575    /// operation query for as long as the wait lasts. So take the handle, drop
576    /// the manager lock, *then* wait:
577    ///
578    /// ```rust,ignore
579    /// let completion = ctx.long_operation_manager.lock().completion_signal();
580    /// // manager lock released here
581    /// completion.wait_for(&op_id, Some(Duration::from_secs(30)));
582    /// let result = ctx.long_operation_manager.lock().get_operation_result(&op_id);
583    /// ```
584    pub fn completion_signal(&self) -> Arc<OperationCompletion> {
585        Arc::clone(&self.completion)
586    }
587
588    /// Block until `id` finishes; `true` once it has.
589    ///
590    /// Convenience for an owner holding the manager directly. **Do not call this
591    /// through a shared lock** — it blocks for the operation's whole duration and
592    /// would hold that lock the entire time; take
593    /// [`completion_signal`](Self::completion_signal) and wait on that instead.
594    pub fn wait_for_operation(&self, id: &str, timeout: Option<Duration>) -> bool {
595        self.completion.wait_for(id, timeout)
596    }
597}
598
599impl Default for LongOperationManager {
600    fn default() -> Self {
601        Self::new()
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use anyhow::anyhow;
609    use std::time::Duration;
610
611    // Example implementation of a long operation
612    pub struct FileProcessingOperation {
613        pub _file_path: String,
614        pub total_files: usize,
615    }
616
617    impl LongOperation for FileProcessingOperation {
618        type Output = ();
619
620        fn execute(
621            &self,
622            progress_callback: Box<dyn Fn(OperationProgress) + Send>,
623            cancel_flag: Arc<AtomicBool>,
624        ) -> Result<Self::Output> {
625            for i in 0..self.total_files {
626                // Check if operation was cancelled
627                if cancel_flag.load(Ordering::Relaxed) {
628                    return Err(anyhow!("Operation was cancelled".to_string()));
629                }
630
631                // Simulate work
632                thread::sleep(Duration::from_millis(500));
633
634                // Report progress
635                let percentage = (i as f32 / self.total_files as f32) * 100.0;
636                progress_callback(OperationProgress::new(
637                    percentage,
638                    Some(format!("Processing file {} of {}", i + 1, self.total_files)),
639                ));
640            }
641
642            // Final progress
643            progress_callback(OperationProgress::new(100.0, Some("Completed".to_string())));
644            Ok(())
645        }
646    }
647
648    /// An operation that finishes at once and returns a value — the shape of a
649    /// small import, and the case a blocking wait must add no latency to.
650    pub struct InstantOperation;
651
652    impl LongOperation for InstantOperation {
653        type Output = u32;
654
655        fn execute(
656            &self,
657            _progress_callback: Box<dyn Fn(OperationProgress) + Send>,
658            _cancel_flag: Arc<AtomicBool>,
659        ) -> Result<Self::Output> {
660            Ok(42)
661        }
662    }
663
664    /// The whole point of the completion signal: a caller is woken the moment the
665    /// worker publishes, with no polling interval and no fixed sleep floor. A
666    /// trivial operation must therefore settle in far less than a timer tick —
667    /// this is what stops N sequential small operations costing N × a poll period.
668    #[test]
669    fn wait_for_returns_as_soon_as_the_operation_finishes() {
670        let manager = LongOperationManager::new();
671        let completion = manager.completion_signal();
672        let started = std::time::Instant::now();
673        let op_id = manager.start_operation(InstantOperation);
674
675        assert!(completion.wait_for(&op_id, Some(Duration::from_secs(5))));
676        let elapsed = started.elapsed();
677
678        // The result is stored *before* completion is published, so a woken
679        // waiter can always read it straight back.
680        assert_eq!(
681            manager.get_operation_result(&op_id).as_deref(),
682            Some("42"),
683            "completion must imply the result is already retrievable"
684        );
685        assert!(
686            elapsed < Duration::from_millis(50),
687            "a trivial operation must not wait out a polling tick (took {elapsed:?})"
688        );
689    }
690
691    /// Waiting on an operation that has already finished returns at once, instead
692    /// of blocking for a signal that was sent before the caller arrived.
693    #[test]
694    fn wait_for_an_already_finished_operation_returns_immediately() {
695        let manager = LongOperationManager::new();
696        let completion = manager.completion_signal();
697        let op_id = manager.start_operation(InstantOperation);
698        assert!(completion.wait_for(&op_id, Some(Duration::from_secs(5))));
699
700        // The finished record persists, so a second wait is instant.
701        let started = std::time::Instant::now();
702        assert!(completion.wait_for(&op_id, Some(Duration::from_secs(5))));
703        assert!(started.elapsed() < Duration::from_millis(50));
704        assert!(completion.is_finished(&op_id));
705    }
706
707    /// The timeout bounds one wait; it is not a deadline for the operation. A
708    /// still-running operation reports `false` and leaves the caller free to wait
709    /// again.
710    #[test]
711    fn wait_for_times_out_while_the_operation_is_still_running() {
712        let manager = LongOperationManager::new();
713        let completion = manager.completion_signal();
714        let op_id = manager.start_operation(FileProcessingOperation {
715            _file_path: "/tmp/test".to_string(),
716            total_files: 5,
717        });
718
719        assert!(
720            !completion.wait_for(&op_id, Some(Duration::from_millis(50))),
721            "a running operation has published no completion"
722        );
723        assert!(!completion.is_finished(&op_id));
724        manager.cancel_operation(&op_id);
725    }
726
727    /// A cancelled operation must still publish completion — otherwise a caller
728    /// blocked on it would be stranded forever.
729    #[test]
730    fn a_cancelled_operation_still_publishes_completion() {
731        let manager = LongOperationManager::new();
732        let completion = manager.completion_signal();
733        let op_id = manager.start_operation(FileProcessingOperation {
734            _file_path: "/tmp/test".to_string(),
735            total_files: 5,
736        });
737        manager.cancel_operation(&op_id);
738
739        assert!(
740            completion.wait_for(&op_id, Some(Duration::from_secs(5))),
741            "cancellation must wake a blocked waiter, not strand it"
742        );
743    }
744
745    #[test]
746    fn test_operation_manager() {
747        let manager = LongOperationManager::new();
748
749        let operation = FileProcessingOperation {
750            _file_path: "/tmp/test".to_string(),
751            total_files: 5,
752        };
753
754        let op_id = manager.start_operation(operation);
755
756        // Check initial status
757        assert_eq!(
758            manager.get_operation_status(&op_id),
759            Some(OperationStatus::Running)
760        );
761
762        // Wait a bit and check progress
763        thread::sleep(Duration::from_millis(100));
764        let progress = manager.get_operation_progress(&op_id);
765        assert!(progress.is_some());
766
767        // Test cancellation
768        assert!(manager.cancel_operation(&op_id));
769        thread::sleep(Duration::from_millis(100));
770        assert_eq!(
771            manager.get_operation_status(&op_id),
772            Some(OperationStatus::Cancelled)
773        );
774    }
775}