Skip to main content

mocra_core/common/
status_tracker.rs

1#![allow(unused)]
2
3/// Multi-level error tracking used by task, module, and request workflows.
4///
5/// Core semantics:
6/// - Download failures increment request/module/task counters.
7/// - Parse failures use independent counters.
8/// - Success clears request-local cached state and does not rewrite historical task/module failures.
9/// - Thresholds can lead to `Skip` (request) or `Terminate` (module/task).
10use crate::cacheable::{CacheAble, CacheService};
11use crate::errors::{CacheError, Error, Result};
12use crate::utils::lock::DistributedLockManager;
13use log::warn;
14use serde::{Deserialize, Serialize};
15use std::sync::Arc;
16use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
17
18/// High-level category assigned to an error record.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum ErrorCategory {
21    /// Download and transport failures.
22    Download,
23    /// Parsing and extraction failures.
24    Parse,
25    /// Authentication or authorization failures.
26    Auth,
27    /// Quota and throttling failures.
28    RateLimit,
29    /// Any category not explicitly covered above.
30    Other,
31}
32
33/// Severity level used for decisioning.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35pub enum ErrorSeverity {
36    /// Low impact and usually retryable.
37    Minor,
38    /// Significant but not immediately fatal.
39    Major,
40    /// Fatal condition that should terminate execution.
41    Fatal,
42}
43
44/// Immutable single error entry.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ErrorRecord {
47    pub timestamp: u64,
48    pub category: ErrorCategory,
49    pub severity: ErrorSeverity,
50    pub message: String,
51    pub retry_count: usize,
52}
53impl CacheAble for ErrorRecord {
54    fn field() -> impl AsRef<str> {
55        "error_record".to_string()
56    }
57}
58/// Aggregated counters and state flags for an error key.
59#[derive(Debug, Clone, Serialize, Deserialize, Default)]
60pub struct ErrorStats {
61    /// Total error count.
62    pub total_errors: usize,
63    /// Total success count.
64    pub success_count: usize,
65    /// Current streak of consecutive errors.
66    pub consecutive_errors: usize,
67    /// Error counts grouped by category.
68    pub errors_by_category: std::collections::HashMap<ErrorCategory, usize>,
69    /// UNIX timestamp of the latest error.
70    pub last_error_time: Option<u64>,
71    /// UNIX timestamp of the latest success.
72    pub last_success_time: Option<u64>,
73    pub is_task_terminated: bool,
74    pub is_module_terminated: bool,
75}
76
77impl CacheAble for ErrorStats {
78    fn field() -> impl AsRef<str> {
79        "error_stats".to_string()
80    }
81}
82
83impl ErrorStats {
84    /// Returns error ratio in `[0.0, 1.0]`.
85    pub fn error_rate(&self) -> f64 {
86        let total = self.total_errors + self.success_count;
87        if total == 0 {
88            0.0
89        } else {
90            self.total_errors as f64 / total as f64
91        }
92    }
93
94    /// Computes a bounded health score where `1.0` is healthiest.
95    ///
96    /// # Examples
97    ///
98    /// ```
99    /// use mocra_core::common::status_tracker::ErrorStats;
100    ///
101    /// let mut stats = ErrorStats::default();
102    /// stats.total_errors = 3;
103    /// stats.success_count = 7;
104    /// assert_eq!(stats.error_rate(), 0.3);
105    /// assert!(stats.health_score() > 0.6);
106    ///
107    /// stats.consecutive_errors = 5;
108    /// assert!(stats.health_score() < 0.5);
109    /// ```
110    pub fn health_score(&self) -> f64 {
111        // Health score in [0.0, 1.0].
112        let error_rate = self.error_rate();
113        let consecutive_penalty = (self.consecutive_errors as f64 * 0.1).min(0.5);
114        (1.0 - error_rate - consecutive_penalty).max(0.0)
115    }
116}
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct ModuleLocker {
119    pub is_locker: bool,
120    pub ts: u64,
121}
122impl CacheAble for ModuleLocker {
123    fn field() -> impl AsRef<str> {
124        "module_locker".to_string()
125    }
126}
127/// Execution decision made after recording or checking errors.
128#[derive(Debug, Clone)]
129pub enum ErrorDecision {
130    /// Continue normal execution.
131    Continue,
132    /// Retry after a delay.
133    RetryAfter(Duration),
134    /// Skip current item (typically request-level).
135    Skip,
136    /// Terminate the current scope (module or task).
137    Terminate(String),
138}
139
140/// Runtime thresholds and behaviors for [`StatusTracker`].
141#[derive(Debug, Clone)]
142pub struct ErrorTrackerConfig {
143    /// Max task-level errors before termination.
144    pub task_max_errors: usize,
145    /// Max module-level errors before termination.
146    pub module_max_errors: usize,
147    /// Max request-level retries for download errors.
148    pub request_max_retries: usize,
149    /// Max request-level retries for parse errors.
150    pub parse_max_retries: usize,
151
152    /// Whether to apply decay after successful execution.
153    pub enable_success_decay: bool,
154    /// Error decrement amount when decay is enabled.
155    pub success_decay_amount: usize,
156
157    /// Whether rolling time-window logic is enabled.
158    pub enable_time_window: bool,
159    /// Time-window size in seconds.
160    pub time_window_seconds: u64,
161
162    /// Consecutive-error threshold used by health scoring.
163    pub consecutive_error_threshold: usize,
164
165    /// Error TTL in seconds for persisted tracker data.
166    pub error_ttl: u64,
167}
168
169impl Default for ErrorTrackerConfig {
170    fn default() -> Self {
171        Self {
172            task_max_errors: 100,
173            module_max_errors: 10,
174            request_max_retries: 3,
175            parse_max_retries: 3,
176            enable_success_decay: true,
177            success_decay_amount: 1,
178            enable_time_window: false,
179            time_window_seconds: 3600,
180            consecutive_error_threshold: 3,
181            error_ttl: 3600,
182        }
183    }
184}
185
186use dashmap::DashMap;
187
188/// Tracks and evaluates failures across request/module/task levels.
189#[derive(Clone)]
190pub struct StatusTracker {
191    cache_service: Arc<CacheService>,
192    config: ErrorTrackerConfig,
193    locker: Arc<DistributedLockManager>,
194    cache: Arc<DashMap<String, (ErrorStats, Instant)>>,
195}
196
197impl StatusTracker {
198    pub fn new(
199        cache_service: Arc<CacheService>,
200        config: ErrorTrackerConfig,
201        locker: Arc<DistributedLockManager>,
202    ) -> Self {
203        Self {
204            cache_service,
205            config,
206            locker,
207            cache: Arc::new(DashMap::new()),
208        }
209    }
210
211    /// Records a download error and returns the next execution decision.
212    ///
213    /// Affects request, module, and task counters.
214    pub async fn record_download_error(
215        &self,
216        task_id: &str,
217        module_id: &str,
218        request_id: &str,
219        error: &Error,
220    ) -> Result<ErrorDecision> {
221        let category = ErrorCategory::Download;
222        let severity = self.classify_error_severity(error);
223
224        let request_key = format!("request:{}:download", request_id);
225        let module_key = format!("module:{}:download", module_id);
226        let task_key = format!("task:{}:total", task_id);
227
228        // Parallel update for performance
229        let (request_res, module_res, task_res) = tokio::join!(
230            self.increment_error(&request_key, category, severity),
231            self.increment_error(&module_key, category, severity),
232            self.increment_error(&task_key, category, severity)
233        );
234
235        let request_errors = request_res?;
236        let module_errors = module_res?;
237        let task_errors = task_res?;
238
239        // Decision stage.
240        if severity == ErrorSeverity::Fatal {
241            return Ok(ErrorDecision::Terminate(format!(
242                "Fatal error encountered: {}",
243                error
244            )));
245        }
246
247        if task_errors >= self.config.task_max_errors {
248            return Ok(ErrorDecision::Terminate(format!(
249                "Task {} reached max errors: {}/{}",
250                task_id, task_errors, self.config.task_max_errors
251            )));
252        }
253
254        if module_errors >= self.config.module_max_errors {
255            // Release lock when module threshold is reached.
256            self.release_module_locker(module_id).await;
257
258            return Ok(ErrorDecision::Terminate(format!(
259                "Module {} reached max errors: {}/{}",
260                module_id, module_errors, self.config.module_max_errors
261            )));
262        }
263
264        if request_errors >= self.config.request_max_retries {
265            return Ok(ErrorDecision::Skip);
266        }
267
268        // Delay strategy by category.
269        let delay = match category {
270            ErrorCategory::RateLimit => Duration::from_secs(60),
271            ErrorCategory::Auth => Duration::from_secs(10),
272            _ => Duration::from_secs(2u64.pow(request_errors as u32).min(30)),
273        };
274
275        Ok(ErrorDecision::RetryAfter(delay))
276    }
277
278    /// Records a download success for request-local state.
279    ///
280    /// This intentionally does not decrement historical module/task counters.
281    pub async fn record_download_success(&self, request_id: &str) -> Result<()> {
282        let request_key = format!("request:{}:download", request_id);
283
284        // [OPTIMIZATION]
285        // If the request succeeded, we don't need to load stats from the cache just to update an in-memory object we throw away.
286        // We only need to clear any cached error state for this request.
287        // If there were errors in the cache, they will expire naturally (TTL).
288
289        if self.cache.contains_key(&request_key) {
290            self.cache.remove(&request_key);
291        }
292
293        Ok(())
294    }
295
296    /// Records a parse error using counters independent from download errors.
297    pub async fn record_parse_error(
298        &self,
299        task_id: &str,
300        module_id: &str,
301        request_id: &str,
302        error: &Error,
303    ) -> Result<ErrorDecision> {
304        let category = ErrorCategory::Parse;
305        let severity = self.classify_error_severity(error);
306
307        log::debug!(
308            "[ErrorTracker] record_parse_error: task_id={} module_id={} request_id={} error={}",
309            task_id,
310            module_id,
311            request_id,
312            error
313        );
314
315        // Parse counters use separate keys while still updating three levels.
316        let request_key = format!("request:{}:parse", request_id);
317        let module_key = format!("module:{}:parse", module_id);
318        let task_key = format!("task:{}:total", task_id);
319
320        let (request_res, module_res, task_res) = tokio::join!(
321            self.increment_error(&request_key, category, severity),
322            self.increment_error(&module_key, category, severity),
323            self.increment_error(&task_key, category, severity)
324        );
325
326        let request_errors = request_res?;
327        let module_errors = module_res?;
328        let task_errors = task_res?;
329
330        log::debug!(
331            "[ErrorTracker] parse error counts: task={}/{} module={}/{} request={}/{}",
332            task_errors,
333            self.config.task_max_errors,
334            module_errors,
335            self.config.module_max_errors,
336            request_errors,
337            self.config.parse_max_retries
338        );
339
340        // Decision logic mirrors download handling.
341        if task_errors >= self.config.task_max_errors {
342            return Ok(ErrorDecision::Terminate(format!(
343                "Task {} reached max errors: {}/{}",
344                task_id, task_errors, self.config.task_max_errors
345            )));
346        }
347
348        if module_errors >= self.config.module_max_errors {
349            // Release lock when module threshold is reached.
350            self.release_module_locker(module_id).await;
351
352            warn!(
353                "[ErrorTracker] module parse errors exceeded threshold: module_id={} errors={}/{}",
354                module_id, module_errors, self.config.module_max_errors
355            );
356
357            return Ok(ErrorDecision::Terminate(format!(
358                "Module {} reached max errors: {}/{}",
359                module_id, module_errors, self.config.module_max_errors
360            )));
361        }
362
363        if request_errors >= self.config.parse_max_retries {
364            return Ok(ErrorDecision::Skip);
365        }
366
367        Ok(ErrorDecision::RetryAfter(Duration::from_secs(1)))
368    }
369
370    /// Records a parse success by clearing request-local cached parse state.
371    pub async fn record_parse_success(&self, request_id: &str) -> Result<()> {
372        let request_key = format!("request:{}:parse", request_id);
373
374        // [OPTIMIZATION] Skip loading stats from the cache. Just clear local cache.
375        if self.cache.contains_key(&request_key) {
376            self.cache.remove(&request_key);
377        }
378
379        Ok(())
380    }
381
382    /// Checks whether a module should continue execution.
383    ///
384    /// Automatically releases module lock on termination threshold.
385    pub async fn should_module_continue(&self, module_id: &str) -> Result<ErrorDecision> {
386        // [OPTIMIZATION] Check cache first
387        if let Some(entry) = self.cache.get(module_id) {
388            let (stats, _) = entry.value();
389            if stats.is_module_terminated {
390                return Ok(ErrorDecision::Terminate(format!(
391                    "Module {} has been terminated (cached)",
392                    module_id
393                )));
394            }
395        }
396
397        let download_key = format!("module:{}:download", module_id);
398        let parse_key = format!("module:{}:parse", module_id);
399
400        let (terminated_res, download_res, parse_res) = tokio::join!(
401            self.is_module_terminated(module_id),
402            self.get_error_count(&download_key),
403            self.get_error_count(&parse_key)
404        );
405
406        // Check explicit termination marker first.
407        if terminated_res? {
408            warn!(
409                "[ErrorTracker] module already terminated: module_id={}",
410                module_id
411            );
412            return Ok(ErrorDecision::Terminate(format!(
413                "Module {} has been terminated",
414                module_id
415            )));
416        }
417
418        let download_errors = download_res?;
419        let parse_errors = parse_res?;
420        let total_errors = download_errors + parse_errors;
421
422        log::debug!(
423            "[ErrorTracker] should_module_continue: module_id={} total_errors={}/{} (download={}, parse={})",
424            module_id,
425            total_errors,
426            self.config.module_max_errors,
427            download_errors,
428            parse_errors
429        );
430
431        if total_errors >= self.config.module_max_errors {
432            // Mark terminated and release lock when threshold is reached.
433            self.mark_module_terminated(module_id).await?;
434            self.release_module_locker(module_id).await;
435
436            log::error!(
437                "[ErrorTracker] module exceeded error threshold: module_id={} total_errors={}/{} (download={}, parse={})",
438                module_id,
439                total_errors,
440                self.config.module_max_errors,
441                download_errors,
442                parse_errors
443            );
444
445            Ok(ErrorDecision::Terminate(format!(
446                "Module {} total errors: {}/{} (download: {}, parse: {})",
447                module_id,
448                total_errors,
449                self.config.module_max_errors,
450                download_errors,
451                parse_errors
452            )))
453        } else {
454            Ok(ErrorDecision::Continue)
455        }
456    }
457
458    /// Checks whether a task should continue execution.
459    pub async fn should_task_continue(&self, task_id: &str) -> Result<ErrorDecision> {
460        // [OPTIMIZATION] Check cache first
461        if let Some(entry) = self.cache.get(task_id) {
462            let (stats, _) = entry.value();
463            if stats.is_task_terminated {
464                return Ok(ErrorDecision::Terminate(format!(
465                    "Task {} has been terminated (cached)",
466                    task_id
467                )));
468            }
469        }
470
471        let task_key = format!("task:{}:total", task_id);
472
473        let (terminated_res, count_res) = tokio::join!(
474            self.is_task_terminated(task_id),
475            self.get_error_count(&task_key)
476        );
477
478        // Check explicit termination marker first.
479        if terminated_res? {
480            return Ok(ErrorDecision::Terminate(format!(
481                "Task {} has been terminated",
482                task_id
483            )));
484        }
485
486        let task_errors = count_res?;
487
488        if task_errors >= self.config.task_max_errors {
489            // Mark terminated when threshold is reached.
490            self.mark_task_terminated(task_id).await?;
491
492            Ok(ErrorDecision::Terminate(format!(
493                "Task {} reached max errors: {}/{}",
494                task_id, task_errors, self.config.task_max_errors
495            )))
496        } else {
497            Ok(ErrorDecision::Continue)
498        }
499    }
500
501    /// Loads stats from in-memory cache first, then storage.
502    pub async fn get_stats(&self, key: &str) -> Result<ErrorStats> {
503        if let Some(entry) = self.cache.get(key) {
504            let (stats, expires_at) = entry.value();
505            if Instant::now() < *expires_at {
506                // log::debug!("[StatusTracker] Cache HIT for {}", key);
507                return Ok(stats.clone());
508            } else {
509                // log::debug!("[StatusTracker] Cache EXPIRED for {}", key);
510            }
511        } else {
512            // log::debug!("[StatusTracker] Cache MISS for {}", key);
513        }
514        self.get_stats_no_cache(key).await
515    }
516
517    pub async fn get_stats_no_cache(&self, key: &str) -> Result<ErrorStats> {
518        let stats_res = ErrorStats::sync(key, &self.cache_service).await;
519
520        match stats_res {
521            Ok(Some(stats)) => {
522                self.cache.insert(
523                    key.to_string(),
524                    (stats.clone(), Instant::now() + Duration::from_secs(10)),
525                );
526                Ok(stats)
527            }
528            Ok(None) => {
529                // [OPTIMIZATION] Cache default (empty) stats to prevent cache penetration for non-existent keys
530                // This is critical for "is_terminated" checks which query random/empty keys frequently
531                let stats = ErrorStats::default();
532                self.cache.insert(
533                    key.to_string(),
534                    (stats.clone(), Instant::now() + Duration::from_secs(5)),
535                );
536                Ok(stats)
537            }
538            Err(e) => Err(Error::from(e)),
539        }
540    }
541
542    /// Marks a task as terminated.
543    pub async fn mark_task_terminated(&self, task_id: &str) -> Result<()> {
544        let cache_id = ErrorStats::cache_id(task_id, &self.cache_service);
545        self.locker
546            .with_lock(&cache_id, 10, Duration::from_secs(10), async {
547                let mut error_stats = self.get_stats_no_cache(task_id).await.unwrap_or_default();
548                error_stats.is_task_terminated = true;
549                error_stats.send(task_id, &self.cache_service).await
550            })
551            .await
552            .ok();
553
554        Ok(())
555    }
556
557    /// Returns whether a task is marked as terminated.
558    pub async fn is_task_terminated(&self, task_id: &str) -> Result<bool> {
559        match self.get_stats(task_id).await {
560            Ok(stats) => Ok(stats.is_task_terminated),
561            Err(e) => {
562                // NotFound means no record exists yet; treat as not terminated.
563                if let Some(cache_err) = e
564                    .inner
565                    .source
566                    .as_ref()
567                    .and_then(|s| s.downcast_ref::<CacheError>())
568                    && matches!(cache_err, CacheError::NotFound)
569                {
570                    return Ok(false);
571                }
572                warn!(
573                    "[ErrorTracker] task terminated check failed: task_id={}, error: {}",
574                    task_id, e
575                );
576                Err(e)
577            }
578        }
579    }
580
581    /// Marks a module as terminated.
582    pub async fn mark_module_terminated(&self, module_id: &str) -> Result<()> {
583        let cache_id = ErrorStats::cache_id(module_id, &self.cache_service);
584        self.locker
585            .with_lock(&cache_id, 10, Duration::from_secs(10), async {
586                let mut error_stats = self.get_stats_no_cache(module_id).await.unwrap_or_default();
587                error_stats.is_module_terminated = true;
588                error_stats.send(module_id, &self.cache_service).await
589            })
590            .await
591            .ok();
592
593        Ok(())
594    }
595
596    /// Returns whether a module is marked as terminated.
597    pub async fn is_module_terminated(&self, module_id: &str) -> Result<bool> {
598        match self.get_stats(module_id).await {
599            Ok(stats) => Ok(stats.is_module_terminated),
600            Err(e) => {
601                // NotFound means no record exists yet; treat as not terminated.
602                if let Some(cache_err) = e
603                    .inner
604                    .source
605                    .as_ref()
606                    .and_then(|s| s.downcast_ref::<CacheError>())
607                    && matches!(cache_err, CacheError::NotFound)
608                {
609                    return Ok(false);
610                }
611                warn!(
612                    "[ErrorTracker] module terminated check failed: module_id={}, error: {}",
613                    module_id, e
614                );
615                Err(e)
616            }
617        }
618    }
619
620    // Private helpers.
621
622    /// Increments error counters using atomic cache operations.
623    async fn increment_error(
624        &self,
625        key: &str,
626        category: ErrorCategory,
627        _severity: ErrorSeverity,
628    ) -> Result<usize> {
629        // Request level: pure in-memory/cache simple update (as before)
630        if key.starts_with("request:") {
631            // For requests, we can just skip persistence for max performance or use short TTL
632            // Simple INCR is fine
633            let count_key = format!("{}:total_errors", key);
634            // Use 1 hour TTL for request stats
635            let total = self.cache_service.incr(&count_key, 1).await? as usize;
636            return Ok(total);
637        }
638
639        // Global level (Task/Module): Use Atomic INCR to avoid locks
640        // Optimization: Use separate keys for counters to avoid read-modify-write cycle of big JSON
641        let count_key = format!("{}:total_errors", key);
642        let total = self.cache_service.incr(&count_key, 1).await? as usize;
643
644        let consecutive_key = format!("{}:consecutive_errors", key);
645        let _ = self.cache_service.incr(&consecutive_key, 1).await;
646
647        // SKIP UPDATING BIG JSON.
648        // We sacrifice "errors_by_category" visibility in the cache for speed.
649
650        Ok(total)
651    }
652
653    /// Decrements total error counters.
654    async fn decrement_error(&self, key: &str, amount: usize) -> Result<()> {
655        let count_key = format!("{}:total_errors", key);
656        // Atomic Decrement
657        let _ = self.cache_service.incr(&count_key, -(amount as i64)).await;
658
659        let consecutive_key = format!("{}:consecutive_errors", key);
660        // Reset consecutive errors
661        let _ = self.cache_service.set(&consecutive_key, b"0", None).await;
662
663        Ok(())
664    }
665
666    /// Increments success counters.
667    async fn increment_success(&self, key: &str) -> Result<()> {
668        let mut stats = self.get_stats(key).await.unwrap_or_default();
669        stats.success_count += 1;
670        stats.last_success_time = Some(Self::now());
671        self.save_stats_with_ttl(key, &stats).await.ok();
672        Ok(())
673    }
674
675    /// Resets consecutive error counters.
676    async fn reset_consecutive_errors(&self, key: &str) -> Result<()> {
677        let mut stats = self.get_stats(key).await.unwrap_or_default();
678        stats.consecutive_errors = 0;
679        self.save_stats_with_ttl(key, &stats).await.ok();
680        Ok(())
681    }
682
683    /// Reads the total error count for a key.
684    async fn get_error_count(&self, key: &str) -> Result<usize> {
685        let count_key = format!("{}:total_errors", key);
686        if let Some(val) = self.cache_service.get(&count_key).await? {
687            // the cache backend may return a string for INCR
688            let s = String::from_utf8(val).unwrap_or_default();
689            let count = s.parse::<usize>().unwrap_or(0);
690            return Ok(count);
691        }
692
693        // Fallback: If not found in atomic counter, check maybe it's in old JSON format (migration support)
694        // Or just return 0. Returning 0 is safer/faster.
695        Ok(0)
696    }
697
698    /// Saves stats using default TTL behavior.
699    async fn save_stats(&self, key: &str, stats: &ErrorStats) -> Result<()> {
700        self.save_stats_with_ttl(key, stats).await
701    }
702
703    /// Saves stats and refreshes short-lived in-memory cache.
704    async fn save_stats_with_ttl(&self, key: &str, stats: &ErrorStats) -> Result<()> {
705        self.cache.insert(
706            key.to_string(),
707            (stats.clone(), Instant::now() + Duration::from_secs(10)),
708        );
709        stats
710            .send(key, &self.cache_service)
711            .await
712            .map_err(Error::from)
713    }
714
715    /// Classifies raw error text into severity tiers.
716    fn classify_error_severity(&self, error: &Error) -> ErrorSeverity {
717        let error_str = error.to_string().to_lowercase();
718
719        if error_str.contains("auth") || error_str.contains("unauthorized") {
720            ErrorSeverity::Fatal
721        } else if error_str.contains("rate limit") || error_str.contains("too many") {
722            ErrorSeverity::Major
723        } else {
724            ErrorSeverity::Minor
725        }
726    }
727
728    fn now() -> u64 {
729        SystemTime::now()
730            .duration_since(UNIX_EPOCH)
731            .unwrap()
732            .as_secs()
733    }
734
735    pub async fn lock_module(&self, module_id: &str) {
736        let ts = SystemTime::now()
737            .duration_since(SystemTime::UNIX_EPOCH)
738            .unwrap()
739            .as_secs();
740        let module_locker = ModuleLocker {
741            is_locker: true,
742            ts,
743        };
744        module_locker
745            .send(module_id, &self.cache_service)
746            .await
747            .ok();
748    }
749    pub async fn is_module_locker(&self, module_id: &str, ttl: u64) -> bool {
750        ModuleLocker::sync(module_id, &self.cache_service)
751            .await
752            .ok()
753            .flatten()
754            .is_some_and(|locker| {
755                if locker.is_locker {
756                    let now = SystemTime::now()
757                        .duration_since(SystemTime::UNIX_EPOCH)
758                        .unwrap()
759                        .as_secs();
760                    now - locker.ts <= ttl
761                } else {
762                    false
763                }
764            })
765    }
766    pub async fn release_module_locker(&self, module_id: &str) {
767        ModuleLocker::delete(module_id, &self.cache_service)
768            .await
769            .ok();
770    }
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776
777    #[test]
778    fn test_error_stats_calculations() {
779        let mut stats = ErrorStats::default();
780        stats.total_errors = 3;
781        stats.success_count = 7;
782
783        assert_eq!(stats.error_rate(), 0.3);
784        assert!(stats.health_score() > 0.6);
785
786        stats.consecutive_errors = 5;
787        assert!(stats.health_score() < 0.5);
788    }
789}