Skip to main content

asupersync/observability/
cancellation_debt_monitor.rs

1//! Cancellation Debt Accumulation Monitor
2//!
3//! Tracks when cancellation work accumulates faster than it can be processed,
4//! potentially leading to resource exhaustion or delayed cleanup. Provides
5//! early warning and debt management capabilities.
6
7use crate::types::{CancelKind, CancelReason};
8use parking_lot::Mutex;
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, HashMap, VecDeque};
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU8, AtomicU64, AtomicUsize, Ordering};
13use std::time::{Duration, SystemTime};
14
15/// br-asupersync-i40ap4 — maximum bytes of cancel-reason text retained per
16/// pending entry. Caps the per-entry memory cost so an attacker who controls
17/// `CancelReason` text cannot amplify the leak proportional to message
18/// length. Anything past this is truncated with a `…` suffix.
19const DEFAULT_MAX_CANCEL_REASON_BYTES: usize = 64;
20
21/// br-asupersync-af24n5 — Cardinality cap on the
22/// `entity_queue_depths` map computed in `get_debt_snapshot`.
23/// Keys come from the user-controllable `entity_id` of pending
24/// work; this cap prevents an attacker from driving unbounded
25/// HashMap growth on every snapshot.
26const MAX_QUEUE_DEPTH_ENTITIES: usize = 4096;
27
28/// br-asupersync-af24n5 — Sentinel key used when the entity-
29/// queue-depth cap is hit. Operators see the bucket explicitly so
30/// the cap activation is auditable rather than silent.
31const QUEUE_DEPTH_OVERFLOW_BUCKET: &str = "__overflow__";
32
33/// br-asupersync-i40ap4 — default cap on pending entries per `WorkType`.
34/// When `record_pending_work`/`queue_work` would exceed this, the oldest
35/// entry of that work type is evicted and an Emergency alert is generated.
36const DEFAULT_MAX_PENDING_PER_WORK_TYPE: usize = 10_000;
37
38fn saturating_system_time_sub(time: SystemTime, duration: Duration) -> SystemTime {
39    time.checked_sub(duration).unwrap_or(SystemTime::UNIX_EPOCH)
40}
41
42/// Truncate a string at a UTF-8 boundary not exceeding `max_bytes` bytes.
43/// If truncated, append `…` (which costs 3 bytes in UTF-8). The returned
44/// string therefore never exceeds `max_bytes + 3` bytes.
45fn truncate_to_bytes(s: &str, max_bytes: usize) -> String {
46    if s.len() <= max_bytes {
47        return s.to_string();
48    }
49    // Find last char boundary at or before max_bytes.
50    let mut end = max_bytes;
51    while end > 0 && !s.is_char_boundary(end) {
52        end -= 1;
53    }
54    let mut out = String::with_capacity(end + 3);
55    out.push_str(&s[..end]);
56    out.push('…');
57    out
58}
59
60/// Configuration for the cancellation debt monitor.
61#[derive(Debug, Clone)]
62pub struct CancellationDebtConfig {
63    /// Maximum queue depth before triggering debt alerts.
64    pub max_queue_depth: usize,
65    /// Maximum time cancellation work can remain pending.
66    pub max_pending_duration: Duration,
67    /// Sampling window for processing rate calculations.
68    pub rate_sampling_window: Duration,
69    /// Minimum processing rate (items/sec) before triggering alerts.
70    pub min_processing_rate: f64,
71    /// Debt threshold as percentage of queue capacity.
72    pub debt_threshold_percentage: f64,
73    /// Enable automatic debt relief mechanisms.
74    pub enable_auto_relief: bool,
75    /// Maximum memory for debt tracking.
76    pub max_tracking_memory_mb: usize,
77    /// br-asupersync-i40ap4 — Cap on pending entries per `WorkType`. When a
78    /// new entry would exceed this, the oldest entry of that work type is
79    /// evicted (and an Emergency alert fires once per overflow event).
80    pub max_pending_per_work_type: usize,
81    /// br-asupersync-i40ap4 — Maximum bytes of cancel-reason text retained
82    /// per pending entry. Bounds attacker amplification through long
83    /// `CancelReason` messages.
84    pub max_cancel_reason_bytes: usize,
85}
86
87impl Default for CancellationDebtConfig {
88    fn default() -> Self {
89        Self {
90            max_queue_depth: 10_000,
91            max_pending_duration: Duration::from_secs(30),
92            rate_sampling_window: Duration::from_secs(60),
93            min_processing_rate: 100.0,      // 100 items/sec minimum
94            debt_threshold_percentage: 75.0, // 75% of capacity
95            enable_auto_relief: false,       // Conservative default
96            max_tracking_memory_mb: 50,
97            max_pending_per_work_type: DEFAULT_MAX_PENDING_PER_WORK_TYPE,
98            max_cancel_reason_bytes: DEFAULT_MAX_CANCEL_REASON_BYTES,
99        }
100    }
101}
102
103/// Types of cancellation work that can accumulate debt.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
105pub enum WorkType {
106    /// Task cancellation cleanup.
107    TaskCleanup,
108    /// Region closure cleanup.
109    RegionCleanup,
110    /// Resource finalization.
111    ResourceFinalization,
112    /// Obligation settlement.
113    ObligationSettlement,
114    /// Waker cleanup.
115    WakerCleanup,
116    /// Channel cleanup.
117    ChannelCleanup,
118}
119
120/// A piece of cancellation work pending processing.
121#[derive(Debug, Clone)]
122pub struct PendingWork {
123    /// Unique identifier for this work item.
124    pub work_id: u64,
125    /// Type of work.
126    pub work_type: WorkType,
127    /// Entity responsible for the work.
128    pub entity_id: String,
129    /// When the work was queued.
130    pub queued_at: SystemTime,
131    /// Priority level (higher = more urgent).
132    pub priority: u32,
133    /// Estimated processing cost (arbitrary units).
134    pub estimated_cost: u32,
135    /// br-asupersync-i40ap4 — Cancellation reason text, truncated at
136    /// [`CancellationDebtConfig::max_cancel_reason_bytes`]. Was previously
137    /// `format!("{cancel_reason:?}")` of the full `CancelReason` (which
138    /// could be attacker-controlled and arbitrarily long).
139    pub cancel_reason: String,
140    /// br-asupersync-i40ap4 — Cancel kind stored as the typed enum (Copy,
141    /// no allocation). Was previously `format!("{cancel_kind:?}")`, which
142    /// allocated a fresh String per pending entry.
143    pub cancel_kind: CancelKind,
144    /// Dependencies that must complete first.
145    pub dependencies: Vec<u64>,
146}
147
148/// Snapshot of debt accumulation state.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct DebtSnapshot {
151    /// Current time of snapshot.
152    pub snapshot_time: SystemTime,
153    /// Total pending work items.
154    pub total_pending: usize,
155    /// Pending work by type.
156    pub pending_by_type: HashMap<WorkType, usize>,
157    /// Current debt percentage (0-100).
158    pub debt_percentage: f64,
159    /// Processing rate over last window.
160    pub processing_rate: f64,
161    /// Queue depth by entity.
162    pub entity_queue_depths: HashMap<String, usize>,
163    /// Oldest pending work age.
164    pub oldest_work_age: Duration,
165    /// Memory usage for debt tracking.
166    pub memory_usage_mb: f64,
167    /// Current alert level.
168    pub alert_level: DebtAlertLevel,
169}
170
171/// Alert levels for debt accumulation.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173pub enum DebtAlertLevel {
174    /// Normal operation, no issues.
175    Normal,
176    /// Elevated debt levels, monitoring recommended.
177    Watch,
178    /// High debt levels, intervention recommended.
179    Warning,
180    /// Critical debt levels, immediate action required.
181    Critical,
182    /// Debt overflow, system may be unstable.
183    Emergency,
184}
185
186impl DebtAlertLevel {
187    /// br-asupersync-37sffr — encode as u8 for lock-free `AtomicU8` storage
188    /// in [`CancellationDebtMonitor`].
189    #[must_use]
190    pub const fn as_u8(self) -> u8 {
191        match self {
192            Self::Normal => 0,
193            Self::Watch => 1,
194            Self::Warning => 2,
195            Self::Critical => 3,
196            Self::Emergency => 4,
197        }
198    }
199
200    /// br-asupersync-37sffr — decode from a u8 written by [`Self::as_u8`].
201    /// Out-of-range values cannot occur via this API but defensively
202    /// decode to `Normal`.
203    #[must_use]
204    pub const fn from_u8(v: u8) -> Self {
205        match v {
206            1 => Self::Watch,
207            2 => Self::Warning,
208            3 => Self::Critical,
209            4 => Self::Emergency,
210            _ => Self::Normal,
211        }
212    }
213}
214
215/// A debt alert notification.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct DebtAlert {
218    /// Alert level.
219    pub level: DebtAlertLevel,
220    /// Alert message.
221    pub message: String,
222    /// Affected work type.
223    pub work_type: Option<WorkType>,
224    /// Affected entity.
225    pub entity_id: Option<String>,
226    /// Current metric value.
227    pub metric_value: f64,
228    /// Threshold that was exceeded.
229    pub threshold: f64,
230    /// When alert was generated.
231    pub generated_at: SystemTime,
232    /// Suggested remediation actions.
233    pub remediation_suggestions: Vec<String>,
234}
235
236/// Statistics for processing rate calculation.
237#[derive(Debug)]
238struct ProcessingStats {
239    /// Items processed in the current window.
240    items_processed: VecDeque<(SystemTime, usize)>,
241    /// Total items processed since startup.
242    total_processed: AtomicU64,
243    /// Last processing rate calculation.
244    last_rate: f64,
245    /// Rate calculation timestamp.
246    last_rate_time: SystemTime,
247}
248
249impl ProcessingStats {
250    fn new() -> Self {
251        Self {
252            items_processed: VecDeque::new(),
253            total_processed: AtomicU64::new(0),
254            last_rate: 0.0,
255            last_rate_time: super::replayable_system_time(),
256        }
257    }
258
259    fn record_processing(&mut self, count: usize, now: SystemTime, window: Duration) {
260        self.items_processed.push_back((now, count));
261        self.total_processed
262            .fetch_add(count as u64, Ordering::Relaxed);
263
264        // Keep only samples within the rate window. This MUST match the window
265        // `calculate_rate` divides by: a hardcoded 60s prune here understated
266        // the rate by up to (window/60)x whenever rate_sampling_window > 60s
267        // (the numerator held <=60s of samples while the denominator was the
268        // full window), which could spuriously escalate debt alert levels.
269        let cutoff = saturating_system_time_sub(now, window);
270        while let Some(&(time, _)) = self.items_processed.front() {
271            if time < cutoff {
272                self.items_processed.pop_front();
273            } else {
274                break;
275            }
276        }
277    }
278
279    fn calculate_rate(&mut self, window: Duration, now: SystemTime) -> f64 {
280        // Only recalculate if enough time has passed
281        if now
282            .duration_since(self.last_rate_time)
283            .unwrap_or(Duration::ZERO)
284            < Duration::from_secs(5)
285        {
286            return self.last_rate;
287        }
288
289        let cutoff = saturating_system_time_sub(now, window);
290        let total_in_window: usize = self
291            .items_processed
292            .iter()
293            .filter(|&&(time, _)| time >= cutoff)
294            .map(|&(_, count)| count)
295            .sum();
296
297        let rate = if window.as_secs() > 0 {
298            total_in_window as f64 / window.as_secs() as f64
299        } else {
300            0.0
301        };
302
303        self.last_rate = rate;
304        self.last_rate_time = now;
305        rate
306    }
307}
308
309/// Cancellation debt accumulation monitor.
310///
311/// br-asupersync-37sffr — All four state guards now use `parking_lot::Mutex`
312/// (faster acquire/release than `std::sync::Mutex`, no poison panic on a
313/// monitor-internal panic). `current_alert_level` is a lock-free `AtomicU8`
314/// since the value is a 5-variant `Copy` enum.
315pub struct CancellationDebtMonitor {
316    config: CancellationDebtConfig,
317    /// Pending work by work type.
318    pending_work: Arc<Mutex<HashMap<WorkType, BTreeMap<u64, PendingWork>>>>,
319    /// Processing statistics by work type.
320    processing_stats: Arc<Mutex<HashMap<WorkType, ProcessingStats>>>,
321    /// Next work ID.
322    next_work_id: AtomicU64,
323    /// Current alert level (encoded via [`DebtAlertLevel::as_u8`] /
324    /// [`DebtAlertLevel::from_u8`]).
325    current_alert_level: AtomicU8,
326    /// Recent alerts.
327    recent_alerts: Arc<Mutex<VecDeque<DebtAlert>>>,
328    /// Total memory usage estimate.
329    memory_usage_bytes: AtomicUsize,
330    /// br-asupersync-i40ap4 — Count of evictions triggered by per-work-type
331    /// cap overflow since startup. Surfaced via [`Self::eviction_count`].
332    eviction_count: AtomicU64,
333    /// br-asupersync-p9wth4 — Count of monitoring-loop panics that
334    /// were recovered via `catch_unwind` instead of killing the
335    /// observability thread. Surfaced via
336    /// [`Self::monitoring_loop_panic_count`].
337    monitoring_loop_panic_count: AtomicU64,
338}
339
340impl CancellationDebtMonitor {
341    /// Creates a new debt monitor with the given configuration.
342    #[must_use]
343    pub fn new(config: CancellationDebtConfig) -> Self {
344        Self {
345            config,
346            pending_work: Arc::new(Mutex::new(HashMap::new())),
347            processing_stats: Arc::new(Mutex::new(HashMap::new())),
348            next_work_id: AtomicU64::new(1),
349            current_alert_level: AtomicU8::new(DebtAlertLevel::Normal.as_u8()),
350            recent_alerts: Arc::new(Mutex::new(VecDeque::new())),
351            memory_usage_bytes: AtomicUsize::new(0),
352            eviction_count: AtomicU64::new(0),
353            monitoring_loop_panic_count: AtomicU64::new(0),
354        }
355    }
356
357    /// br-asupersync-p9wth4 — Count of monitoring-loop panics
358    /// recovered by `DebtRuntimeIntegration::monitoring_loop`'s
359    /// `catch_unwind`. Operators can scrape this counter to detect
360    /// when the observability loop has been hit by a panic in the
361    /// alert callback or monitor accessors.
362    #[must_use]
363    pub fn monitoring_loop_panic_count(&self) -> u64 {
364        self.monitoring_loop_panic_count.load(Ordering::Relaxed)
365    }
366
367    /// br-asupersync-p9wth4 — Increment the monitoring-loop panic
368    /// counter. Called by
369    /// `DebtRuntimeIntegration::monitoring_loop` when a tick body
370    /// panics and is recovered via `catch_unwind`.
371    pub fn record_monitoring_loop_panic(&self) {
372        self.monitoring_loop_panic_count
373            .fetch_add(1, Ordering::Relaxed);
374    }
375
376    /// br-asupersync-i40ap4 — Number of pending-work entries evicted because
377    /// they would have exceeded `config.max_pending_per_work_type`.
378    #[must_use]
379    pub fn eviction_count(&self) -> u64 {
380        self.eviction_count.load(Ordering::Relaxed)
381    }
382
383    /// Creates a debt monitor with default configuration.
384    #[must_use]
385    pub fn default() -> Self {
386        Self::new(CancellationDebtConfig::default())
387    }
388
389    /// Queue a new piece of cancellation work.
390    ///
391    /// br-asupersync-i40ap4 — `cancel_reason` is truncated at
392    /// `config.max_cancel_reason_bytes` to bound per-entry memory cost
393    /// (an attacker who controls `CancelReason` text cannot amplify the
394    /// leak proportional to message length). `cancel_kind` is stored as
395    /// the typed enum (Copy) rather than a freshly-allocated Debug String.
396    /// If inserting this entry would exceed `config.max_pending_per_work_type`
397    /// for `work_type`, the oldest entry of that type is evicted first and
398    /// `eviction_count()` is incremented.
399    pub fn queue_work(
400        &self,
401        work_type: WorkType,
402        entity_id: String,
403        priority: u32,
404        estimated_cost: u32,
405        cancel_reason: &CancelReason,
406        cancel_kind: CancelKind,
407        dependencies: Vec<u64>,
408    ) -> u64 {
409        let work_id = self.next_work_id.fetch_add(1, Ordering::Relaxed);
410        let now = super::replayable_system_time();
411
412        let cancel_reason_text = truncate_to_bytes(
413            &format!("{cancel_reason}"),
414            self.config.max_cancel_reason_bytes,
415        );
416        let work = PendingWork {
417            work_id,
418            work_type,
419            entity_id,
420            queued_at: now,
421            priority,
422            estimated_cost,
423            cancel_reason: cancel_reason_text,
424            cancel_kind,
425            dependencies,
426        };
427
428        // Update memory usage estimate
429        let work_size =
430            std::mem::size_of::<PendingWork>() + work.entity_id.len() + work.cancel_reason.len();
431        self.memory_usage_bytes
432            .fetch_add(work_size, Ordering::Relaxed);
433
434        // Add to pending work — evicting the oldest entry of this work type
435        // if we would otherwise exceed the per-WorkType cap.
436        let evicted_for_alert = {
437            let mut pending = self.pending_work.lock();
438            let map = pending.entry(work_type).or_default();
439            let evicted = if map.len() >= self.config.max_pending_per_work_type {
440                // Find the oldest entry. Since we use a BTreeMap keyed by
441                // monotonically increasing `work_id`, `pop_first()` gives
442                // the oldest entry in O(log N) time instead of O(N).
443                map.pop_first().map(|(_, w)| {
444                    let evicted_size = std::mem::size_of::<PendingWork>()
445                        + w.entity_id.len()
446                        + w.cancel_reason.len();
447                    self.memory_usage_bytes
448                        .fetch_sub(evicted_size, Ordering::Relaxed);
449                    self.eviction_count.fetch_add(1, Ordering::Relaxed);
450                    w.work_id
451                })
452            } else {
453                None
454            };
455            map.insert(work_id, work);
456            evicted
457        };
458
459        if let Some(evicted_id) = evicted_for_alert {
460            self.generate_alert(DebtAlert {
461                level: DebtAlertLevel::Emergency,
462                message: format!(
463                    "evicted oldest pending {work_type:?} (work_id={evicted_id}) — \
464                     per-type cap of {} reached (br-asupersync-i40ap4)",
465                    self.config.max_pending_per_work_type
466                ),
467                work_type: Some(work_type),
468                entity_id: None,
469                metric_value: self.config.max_pending_per_work_type as f64,
470                threshold: self.config.max_pending_per_work_type as f64,
471                generated_at: now,
472                remediation_suggestions: vec![
473                    "Investigate why pending work is not being completed".to_string(),
474                    "Increase max_pending_per_work_type if eviction is benign".to_string(),
475                ],
476            });
477        }
478
479        // Check if we need to trigger debt alerts
480        self.check_debt_levels();
481
482        work_id
483    }
484
485    /// Mark work as completed and remove from pending.
486    pub fn complete_work(&self, work_id: u64) -> bool {
487        let now = super::replayable_system_time();
488        let mut found_work = None;
489
490        // Find and remove the work
491        {
492            let mut pending = self.pending_work.lock();
493            for (work_type, work_map) in pending.iter_mut() {
494                if let Some(work) = work_map.remove(&work_id) {
495                    found_work = Some((*work_type, work));
496                    break;
497                }
498            }
499        }
500
501        if let Some((work_type, work)) = found_work {
502            // Update memory usage
503            let work_size = std::mem::size_of::<PendingWork>()
504                + work.entity_id.len()
505                + work.cancel_reason.len()
506                /* br-asupersync-i40ap4: cancel_kind is now CancelKind enum (no allocation) */;
507            self.memory_usage_bytes
508                .fetch_sub(work_size, Ordering::Relaxed);
509
510            // Update processing statistics
511            {
512                let mut stats = self.processing_stats.lock();
513                stats
514                    .entry(work_type)
515                    .or_insert_with(ProcessingStats::new)
516                    .record_processing(1, now, self.config.rate_sampling_window);
517            }
518
519            true
520        } else {
521            false
522        }
523    }
524
525    /// Complete multiple work items at once (batch completion).
526    pub fn complete_work_batch(&self, work_ids: &[u64]) -> usize {
527        let now = super::replayable_system_time();
528        let mut completed_count = 0;
529        let mut completed_by_type: HashMap<WorkType, usize> = HashMap::new();
530
531        // Process completions
532        {
533            let mut pending = self.pending_work.lock();
534            for &work_id in work_ids {
535                for (work_type, work_map) in pending.iter_mut() {
536                    if let Some(work) = work_map.remove(&work_id) {
537                        completed_count += 1;
538                        *completed_by_type.entry(*work_type).or_default() += 1;
539
540                        // Update memory usage
541                        let work_size = std::mem::size_of::<PendingWork>()
542                            + work.entity_id.len()
543                            + work.cancel_reason.len()
544                            /* br-asupersync-i40ap4: cancel_kind is now CancelKind enum (no allocation) */;
545                        self.memory_usage_bytes
546                            .fetch_sub(work_size, Ordering::Relaxed);
547                        break;
548                    }
549                }
550            }
551        }
552
553        // Update processing statistics
554        {
555            let mut stats = self.processing_stats.lock();
556            for (work_type, count) in completed_by_type {
557                stats
558                    .entry(work_type)
559                    .or_insert_with(ProcessingStats::new)
560                    .record_processing(count, now, self.config.rate_sampling_window);
561            }
562        }
563
564        completed_count
565    }
566
567    /// Get current debt snapshot.
568    pub fn get_debt_snapshot(&self) -> DebtSnapshot {
569        let now = super::replayable_system_time();
570        let pending = self.pending_work.lock();
571
572        // Calculate totals
573        let mut total_pending = 0;
574        let mut pending_by_type = HashMap::new();
575        let mut entity_queue_depths = HashMap::new();
576        let mut oldest_work_age = Duration::ZERO;
577
578        for (work_type, work_map) in pending.iter() {
579            let type_count = work_map.len();
580            total_pending += type_count;
581            pending_by_type.insert(*work_type, type_count);
582
583            for work in work_map.values() {
584                // br-asupersync-af24n5 — entity_queue_depths is keyed
585                // by user-controllable entity_id; cap at
586                // MAX_QUEUE_DEPTH_ENTITIES with overflow folded into
587                // the QUEUE_DEPTH_OVERFLOW_BUCKET sentinel. Without
588                // the cap, a malicious or buggy producer with
589                // attacker-shaped entity_id can drive unbounded
590                // HashMap growth on every snapshot pass (DoS / OOM).
591                let key = if entity_queue_depths.contains_key(&work.entity_id)
592                    || entity_queue_depths.len() < MAX_QUEUE_DEPTH_ENTITIES
593                {
594                    work.entity_id.clone()
595                } else {
596                    QUEUE_DEPTH_OVERFLOW_BUCKET.to_string()
597                };
598                *entity_queue_depths.entry(key).or_default() += 1;
599
600                // Find oldest work
601                if let Ok(age) = now.duration_since(work.queued_at) {
602                    oldest_work_age = oldest_work_age.max(age);
603                }
604            }
605        }
606
607        // Calculate debt percentage
608        let debt_percentage = if self.config.max_queue_depth > 0 {
609            (total_pending as f64 / self.config.max_queue_depth as f64) * 100.0
610        } else {
611            0.0
612        };
613
614        // Calculate processing rate
615        let processing_rate = {
616            let mut stats = self.processing_stats.lock();
617            let mut total_rate = 0.0;
618            for stat in stats.values_mut() {
619                total_rate += stat.calculate_rate(self.config.rate_sampling_window, now);
620            }
621            total_rate
622        };
623
624        // Memory usage
625        let memory_usage_mb =
626            self.memory_usage_bytes.load(Ordering::Relaxed) as f64 / (1024.0 * 1024.0);
627
628        // Current alert level
629        let alert_level = DebtAlertLevel::from_u8(self.current_alert_level.load(Ordering::Relaxed));
630
631        DebtSnapshot {
632            snapshot_time: now,
633            total_pending,
634            pending_by_type,
635            debt_percentage,
636            processing_rate,
637            entity_queue_depths,
638            oldest_work_age,
639            memory_usage_mb,
640            alert_level,
641        }
642    }
643
644    /// Get pending work for a specific entity.
645    pub fn get_entity_pending_work(&self, entity_id: &str) -> Vec<PendingWork> {
646        let pending = self.pending_work.lock();
647        let mut result = Vec::new();
648
649        for work_map in pending.values() {
650            for work in work_map.values() {
651                if work.entity_id == entity_id {
652                    result.push(work.clone());
653                }
654            }
655        }
656
657        result.sort_by(|a, b| b.priority.cmp(&a.priority));
658        result
659    }
660
661    /// Get the highest priority pending work items.
662    pub fn get_priority_work(&self, limit: usize) -> Vec<PendingWork> {
663        let pending = self.pending_work.lock();
664        let mut result = Vec::new();
665
666        for work_map in pending.values() {
667            for work in work_map.values() {
668                result.push(work.clone());
669            }
670        }
671
672        result.sort_by(|a, b| {
673            // Sort by priority desc, then by age desc
674            match b.priority.cmp(&a.priority) {
675                std::cmp::Ordering::Equal => b.queued_at.cmp(&a.queued_at),
676                other => other,
677            }
678        });
679
680        result.truncate(limit);
681        result
682    }
683
684    /// Get recent debt alerts.
685    pub fn get_recent_alerts(&self, limit: usize) -> Vec<DebtAlert> {
686        let alerts = self.recent_alerts.lock();
687        alerts.iter().rev().take(limit).cloned().collect()
688    }
689
690    /// Clear old alerts beyond a certain age.
691    pub fn clear_old_alerts(&self, max_age: Duration) {
692        let cutoff = saturating_system_time_sub(super::replayable_system_time(), max_age);
693        let mut alerts = self.recent_alerts.lock();
694        alerts.retain(|alert| alert.generated_at > cutoff);
695    }
696
697    /// Force cleanup of old pending work (emergency debt relief).
698    pub fn emergency_cleanup(&self, max_age: Duration) -> usize {
699        let cutoff = saturating_system_time_sub(super::replayable_system_time(), max_age);
700        let mut cleaned_count = 0;
701
702        {
703            let mut pending = self.pending_work.lock();
704            for work_map in pending.values_mut() {
705                let before_count = work_map.len();
706                work_map.retain(|_, work| work.queued_at > cutoff);
707                cleaned_count += before_count - work_map.len();
708            }
709        }
710
711        if cleaned_count > 0 {
712            self.generate_alert(DebtAlert {
713                level: DebtAlertLevel::Emergency,
714                message: format!("Emergency cleanup removed {cleaned_count} stale work items"),
715                work_type: None,
716                entity_id: None,
717                metric_value: cleaned_count as f64,
718                threshold: 0.0,
719                generated_at: super::replayable_system_time(),
720                remediation_suggestions: vec![
721                    "Investigate why work items are not being processed".to_string(),
722                    "Check for deadlocks or blocked entities".to_string(),
723                    "Consider increasing processing capacity".to_string(),
724                ],
725            });
726        }
727
728        cleaned_count
729    }
730
731    /// Check current debt levels and trigger alerts if needed.
732    fn check_debt_levels(&self) {
733        let snapshot = self.get_debt_snapshot();
734        let new_alert_level = self.calculate_alert_level(&snapshot);
735
736        // br-asupersync-37sffr — atomic compare-and-update on the alert
737        // level. Multiple cancel hot-path callers may race here; only one
738        // observes the transition and emits the alert.
739        let new_byte = new_alert_level.as_u8();
740        let prev_byte = self.current_alert_level.swap(new_byte, Ordering::AcqRel);
741        if prev_byte != new_byte {
742            let old_level = DebtAlertLevel::from_u8(prev_byte);
743            self.generate_debt_level_alert(old_level, new_alert_level, &snapshot);
744        }
745
746        // Check for specific threshold violations
747        self.check_threshold_violations(&snapshot);
748    }
749
750    /// Calculate alert level based on current snapshot.
751    fn calculate_alert_level(&self, snapshot: &DebtSnapshot) -> DebtAlertLevel {
752        // Emergency: Memory usage > 90% or debt > 95%
753        if snapshot.memory_usage_mb > (self.config.max_tracking_memory_mb as f64 * 0.9)
754            || snapshot.debt_percentage > 95.0
755        {
756            return DebtAlertLevel::Emergency;
757        }
758
759        // Critical: Debt > 90% or very slow processing
760        if snapshot.debt_percentage > 90.0
761            || (snapshot.processing_rate < self.config.min_processing_rate * 0.1
762                && snapshot.total_pending > 100)
763        {
764            return DebtAlertLevel::Critical;
765        }
766
767        // Warning: Debt above threshold or slow processing
768        if snapshot.debt_percentage > self.config.debt_threshold_percentage
769            || snapshot.processing_rate < self.config.min_processing_rate * 0.5
770        {
771            return DebtAlertLevel::Warning;
772        }
773
774        // Watch: Debt > 50% or oldest work is aging
775        if snapshot.debt_percentage > 50.0
776            || snapshot.oldest_work_age > self.config.max_pending_duration * 2
777        {
778            return DebtAlertLevel::Watch;
779        }
780
781        DebtAlertLevel::Normal
782    }
783
784    /// Generate alert for debt level changes.
785    fn generate_debt_level_alert(
786        &self,
787        _old_level: DebtAlertLevel,
788        new_level: DebtAlertLevel,
789        snapshot: &DebtSnapshot,
790    ) {
791        let message = match new_level {
792            DebtAlertLevel::Emergency => {
793                "EMERGENCY: Cancellation debt overflow detected".to_string()
794            }
795            DebtAlertLevel::Critical => {
796                "CRITICAL: Severe cancellation debt accumulation".to_string()
797            }
798            DebtAlertLevel::Warning => "WARNING: Elevated cancellation debt levels".to_string(),
799            DebtAlertLevel::Watch => "WATCH: Cancellation debt increasing".to_string(),
800            DebtAlertLevel::Normal => "INFO: Cancellation debt levels normal".to_string(),
801        };
802
803        let remediation_suggestions = match new_level {
804            DebtAlertLevel::Emergency => vec![
805                "Execute emergency cleanup immediately".to_string(),
806                "Scale up processing capacity".to_string(),
807                "Investigate system bottlenecks".to_string(),
808            ],
809            DebtAlertLevel::Critical => vec![
810                "Increase cancellation processing rate".to_string(),
811                "Consider work prioritization".to_string(),
812                "Check for deadlocks or stuck entities".to_string(),
813            ],
814            DebtAlertLevel::Warning => vec![
815                "Monitor processing rates closely".to_string(),
816                "Optimize cancellation handlers".to_string(),
817                "Consider load shedding if applicable".to_string(),
818            ],
819            DebtAlertLevel::Watch => vec![
820                "Monitor debt accumulation trends".to_string(),
821                "Verify processing pipeline health".to_string(),
822            ],
823            DebtAlertLevel::Normal => vec!["Continue monitoring".to_string()],
824        };
825
826        self.generate_alert(DebtAlert {
827            level: new_level,
828            message,
829            work_type: None,
830            entity_id: None,
831            metric_value: snapshot.debt_percentage,
832            threshold: match new_level {
833                DebtAlertLevel::Emergency => 95.0,
834                DebtAlertLevel::Critical => 90.0,
835                DebtAlertLevel::Warning => self.config.debt_threshold_percentage,
836                DebtAlertLevel::Watch => 50.0,
837                DebtAlertLevel::Normal => 0.0,
838            },
839            generated_at: snapshot.snapshot_time,
840            remediation_suggestions,
841        });
842    }
843
844    /// Check for specific threshold violations.
845    fn check_threshold_violations(&self, snapshot: &DebtSnapshot) {
846        // Check processing rate violations by type
847        let stats = self.processing_stats.lock();
848        for (work_type, stat) in stats.iter() {
849            if stat.last_rate < self.config.min_processing_rate * 0.1 {
850                self.generate_alert(DebtAlert {
851                    level: DebtAlertLevel::Warning,
852                    message: format!(
853                        "Very slow processing rate for {:?}: {:.1}/sec",
854                        work_type, stat.last_rate
855                    ),
856                    work_type: Some(*work_type),
857                    entity_id: None,
858                    metric_value: stat.last_rate,
859                    threshold: self.config.min_processing_rate * 0.1,
860                    generated_at: snapshot.snapshot_time,
861                    remediation_suggestions: vec![
862                        format!("Optimize {:?} processing handlers", work_type),
863                        "Check for blocking operations".to_string(),
864                    ],
865                });
866            }
867        }
868
869        // Check for entities with excessive queue depths
870        for (entity_id, &depth) in &snapshot.entity_queue_depths {
871            if depth > 1000 {
872                self.generate_alert(DebtAlert {
873                    level: DebtAlertLevel::Warning,
874                    message: format!("Entity {entity_id} has excessive queue depth: {depth}"),
875                    work_type: None,
876                    entity_id: Some(entity_id.clone()),
877                    metric_value: depth as f64,
878                    threshold: 1000.0,
879                    generated_at: snapshot.snapshot_time,
880                    remediation_suggestions: vec![
881                        "Investigate entity-specific bottlenecks".to_string(),
882                        "Check for resource leaks in entity cleanup".to_string(),
883                    ],
884                });
885            }
886        }
887    }
888
889    /// Generate and store an alert.
890    #[allow(unused_variables)]
891    fn generate_alert(&self, alert: DebtAlert) {
892        {
893            let mut alerts = self.recent_alerts.lock();
894            alerts.push_back(alert.clone());
895
896            // Keep alerts bounded
897            while alerts.len() > 1000 {
898                alerts.pop_front();
899            }
900        }
901
902        crate::tracing_compat::warn!(
903            level = ?alert.level,
904            work_type = ?alert.work_type,
905            entity_id = ?alert.entity_id,
906            metric_value = alert.metric_value,
907            threshold = alert.threshold,
908            generated_at = ?alert.generated_at,
909            message = %alert.message,
910            "cancellation debt alert"
911        );
912    }
913}
914
915#[cfg(test)]
916mod tests {
917    #![allow(
918        clippy::pedantic,
919        clippy::nursery,
920        clippy::expect_fun_call,
921        clippy::map_unwrap_or,
922        clippy::cast_possible_wrap,
923        clippy::future_not_send
924    )]
925    use super::*;
926    use crate::types::{CancelKind, CancelReason};
927
928    #[test]
929    fn test_debt_monitor_creation() {
930        let config = CancellationDebtConfig::default();
931        let monitor = CancellationDebtMonitor::new(config);
932
933        let snapshot = monitor.get_debt_snapshot();
934        assert_eq!(snapshot.total_pending, 0);
935        assert_eq!(snapshot.debt_percentage, 0.0);
936    }
937
938    #[test]
939    fn test_work_lifecycle() {
940        let monitor = CancellationDebtMonitor::default();
941
942        let work_id = monitor.queue_work(
943            WorkType::TaskCleanup,
944            "test-task".to_string(),
945            10,
946            100,
947            &CancelReason::user("test"),
948            CancelKind::User,
949            Vec::new(),
950        );
951
952        let snapshot = monitor.get_debt_snapshot();
953        assert_eq!(snapshot.total_pending, 1);
954        assert!(
955            snapshot
956                .pending_by_type
957                .contains_key(&WorkType::TaskCleanup)
958        );
959
960        let completed = monitor.complete_work(work_id);
961        assert!(completed);
962
963        let snapshot = monitor.get_debt_snapshot();
964        assert_eq!(snapshot.total_pending, 0);
965    }
966
967    #[test]
968    fn test_debt_calculation() {
969        let mut config = CancellationDebtConfig::default();
970        config.max_queue_depth = 100;
971        let monitor = CancellationDebtMonitor::new(config);
972
973        // Queue 75 items (should trigger warning at 75% threshold)
974        for i in 0..75 {
975            monitor.queue_work(
976                WorkType::TaskCleanup,
977                format!("task-{}", i),
978                1,
979                10,
980                &CancelReason::user("test"),
981                CancelKind::User,
982                Vec::new(),
983            );
984        }
985
986        let snapshot = monitor.get_debt_snapshot();
987        assert_eq!(snapshot.total_pending, 75);
988        assert_eq!(snapshot.debt_percentage, 75.0);
989    }
990
991    #[test]
992    fn test_batch_completion() {
993        let monitor = CancellationDebtMonitor::default();
994
995        let work_ids: Vec<u64> = (0..5)
996            .map(|i| {
997                monitor.queue_work(
998                    WorkType::ResourceFinalization,
999                    format!("resource-{}", i),
1000                    1,
1001                    50,
1002                    &CancelReason::user("batch_test"),
1003                    CancelKind::User,
1004                    Vec::new(),
1005                )
1006            })
1007            .collect();
1008
1009        let completed = monitor.complete_work_batch(&work_ids);
1010        assert_eq!(completed, 5);
1011
1012        let snapshot = monitor.get_debt_snapshot();
1013        assert_eq!(snapshot.total_pending, 0);
1014    }
1015
1016    #[test]
1017    fn test_priority_work_retrieval() {
1018        let monitor = CancellationDebtMonitor::default();
1019
1020        // Queue work with different priorities
1021        monitor.queue_work(
1022            WorkType::TaskCleanup,
1023            "low-priority".to_string(),
1024            1,
1025            10,
1026            &CancelReason::user("test"),
1027            CancelKind::User,
1028            Vec::new(),
1029        );
1030
1031        monitor.queue_work(
1032            WorkType::TaskCleanup,
1033            "high-priority".to_string(),
1034            100,
1035            10,
1036            &CancelReason::user("test"),
1037            CancelKind::User,
1038            Vec::new(),
1039        );
1040
1041        let priority_work = monitor.get_priority_work(5);
1042        assert_eq!(priority_work.len(), 2);
1043        assert_eq!(priority_work[0].priority, 100); // High priority first
1044        assert_eq!(priority_work[1].priority, 1);
1045    }
1046
1047    #[test]
1048    fn test_emergency_cleanup() {
1049        let monitor = CancellationDebtMonitor::default();
1050
1051        // Queue some work and explicitly age it; relying on a 1 ms wall-clock
1052        // gap is scheduler- and platform-dependent.
1053        let work_id = monitor.queue_work(
1054            WorkType::ChannelCleanup,
1055            "old-work".to_string(),
1056            1,
1057            10,
1058            &CancelReason::user("test"),
1059            CancelKind::User,
1060            Vec::new(),
1061        );
1062        {
1063            let mut pending = monitor.pending_work.lock();
1064            let work = pending
1065                .get_mut(&WorkType::ChannelCleanup)
1066                .and_then(|work_map| work_map.get_mut(&work_id))
1067                .expect("queued work must be present");
1068            work.queued_at = SystemTime::UNIX_EPOCH;
1069        }
1070
1071        // Emergency cleanup with very short age (should clean everything)
1072        let cleaned = monitor.emergency_cleanup(Duration::from_millis(1));
1073        assert!(cleaned > 0);
1074
1075        let snapshot = monitor.get_debt_snapshot();
1076        assert_eq!(snapshot.total_pending, 0);
1077    }
1078
1079    /// br-asupersync-i40ap4 — long cancel-reason text is truncated at the
1080    /// configured byte cap so attacker-controlled reasons cannot amplify
1081    /// the per-entry memory footprint.
1082    #[test]
1083    fn cancel_reason_truncated_at_byte_cap() {
1084        let mut config = CancellationDebtConfig::default();
1085        config.max_cancel_reason_bytes = 16;
1086        let monitor = CancellationDebtMonitor::new(config);
1087
1088        let long = "A".repeat(10_000);
1089        let mut reason = CancelReason::new(CancelKind::User);
1090        reason.message = Some(long);
1091        let id = monitor.queue_work(
1092            WorkType::TaskCleanup,
1093            "entity".into(),
1094            1,
1095            1,
1096            &reason,
1097            CancelKind::User,
1098            Vec::new(),
1099        );
1100
1101        let work = monitor
1102            .get_priority_work(10)
1103            .into_iter()
1104            .find(|w| w.work_id == id)
1105            .expect("queued work present");
1106        // Truncated text is at most max_cancel_reason_bytes + 3 (ellipsis is
1107        // 3 bytes in UTF-8) and ends with the ellipsis character.
1108        assert!(
1109            work.cancel_reason.len() <= 16 + 3,
1110            "cancel_reason exceeded cap: {} bytes",
1111            work.cancel_reason.len()
1112        );
1113        assert!(
1114            work.cancel_reason.ends_with('…'),
1115            "expected ellipsis suffix on truncated reason: {:?}",
1116            work.cancel_reason
1117        );
1118    }
1119
1120    /// br-asupersync-i40ap4 — short cancel-reason text passes through
1121    /// unchanged (no spurious truncation).
1122    #[test]
1123    fn cancel_reason_short_passes_through() {
1124        let monitor = CancellationDebtMonitor::default();
1125        let reason = CancelReason::user("short");
1126        let id = monitor.queue_work(
1127            WorkType::TaskCleanup,
1128            "entity".into(),
1129            1,
1130            1,
1131            &reason,
1132            CancelKind::User,
1133            Vec::new(),
1134        );
1135        let work = monitor
1136            .get_priority_work(10)
1137            .into_iter()
1138            .find(|w| w.work_id == id)
1139            .unwrap();
1140        assert!(!work.cancel_reason.ends_with('…'));
1141        assert!(!work.cancel_reason.is_empty());
1142    }
1143
1144    /// br-asupersync-i40ap4 — once per-WorkType cap is reached, the oldest
1145    /// entry is evicted on each new insert and `eviction_count` advances.
1146    #[test]
1147    fn per_work_type_cap_evicts_oldest() {
1148        let mut config = CancellationDebtConfig::default();
1149        config.max_pending_per_work_type = 4;
1150        let monitor = CancellationDebtMonitor::new(config);
1151
1152        let mut ids = Vec::new();
1153        for i in 0..4 {
1154            ids.push(monitor.queue_work(
1155                WorkType::TaskCleanup,
1156                format!("task-{i}"),
1157                1,
1158                1,
1159                &CancelReason::user("x"),
1160                CancelKind::User,
1161                Vec::new(),
1162            ));
1163        }
1164        assert_eq!(monitor.get_debt_snapshot().total_pending, 4);
1165        assert_eq!(monitor.eviction_count(), 0);
1166
1167        // Inserting a 5th must evict an older entry.
1168        let _new_id = monitor.queue_work(
1169            WorkType::TaskCleanup,
1170            "task-5".into(),
1171            1,
1172            1,
1173            &CancelReason::user("x"),
1174            CancelKind::User,
1175            Vec::new(),
1176        );
1177        assert_eq!(monitor.get_debt_snapshot().total_pending, 4);
1178        assert_eq!(monitor.eviction_count(), 1);
1179
1180        // Sanity: a different WorkType is independently capped.
1181        for i in 0..4 {
1182            monitor.queue_work(
1183                WorkType::ChannelCleanup,
1184                format!("chan-{i}"),
1185                1,
1186                1,
1187                &CancelReason::user("x"),
1188                CancelKind::User,
1189                Vec::new(),
1190            );
1191        }
1192        assert_eq!(monitor.get_debt_snapshot().total_pending, 8);
1193        assert_eq!(monitor.eviction_count(), 1);
1194    }
1195
1196    /// br-asupersync-37sffr — the alert level transitions atomically through
1197    /// the AtomicU8 store; concurrent observers always see one of the
1198    /// well-formed enum variants, never a torn/poisoned state.
1199    #[test]
1200    fn alert_level_atomic_roundtrip() {
1201        for level in [
1202            DebtAlertLevel::Normal,
1203            DebtAlertLevel::Watch,
1204            DebtAlertLevel::Warning,
1205            DebtAlertLevel::Critical,
1206            DebtAlertLevel::Emergency,
1207        ] {
1208            assert_eq!(DebtAlertLevel::from_u8(level.as_u8()), level);
1209        }
1210        // Out-of-range bytes defensively decode to Normal.
1211        assert_eq!(DebtAlertLevel::from_u8(255), DebtAlertLevel::Normal);
1212        assert_eq!(DebtAlertLevel::from_u8(99), DebtAlertLevel::Normal);
1213    }
1214
1215    /// br-asupersync-i40ap4 — `truncate_to_bytes` respects UTF-8 character
1216    /// boundaries (does not split a multi-byte codepoint mid-sequence).
1217    #[test]
1218    fn truncate_to_bytes_respects_utf8_boundaries() {
1219        // 4-byte UTF-8 codepoint (😀 = U+1F600 = F0 9F 98 80).
1220        let s = "ABC😀DEF";
1221        // Cap of 5 forces truncation between A,B,C and the emoji.
1222        let out = truncate_to_bytes(s, 5);
1223        assert!(out.is_char_boundary(out.len()));
1224        // 4 bytes ABC + 3 bytes for ellipsis.
1225        // The truncation may stop after "ABC" (3 bytes) so the resulting
1226        // truncated portion is "ABC" + "…".
1227        assert!(out.starts_with("ABC"));
1228        assert!(out.ends_with('…'));
1229        // Cap exceeding the input length leaves it untouched.
1230        assert_eq!(truncate_to_bytes("hi", 100), "hi");
1231    }
1232
1233    #[test]
1234    fn system_time_windows_do_not_underflow_near_epoch() {
1235        let mut stats = ProcessingStats {
1236            items_processed: VecDeque::new(),
1237            total_processed: AtomicU64::new(0),
1238            last_rate: 0.0,
1239            last_rate_time: SystemTime::UNIX_EPOCH,
1240        };
1241
1242        stats.record_processing(1, SystemTime::UNIX_EPOCH, Duration::MAX);
1243        let rate = stats.calculate_rate(
1244            Duration::MAX,
1245            SystemTime::UNIX_EPOCH + Duration::from_secs(6),
1246        );
1247
1248        assert_eq!(
1249            saturating_system_time_sub(SystemTime::UNIX_EPOCH, Duration::MAX),
1250            SystemTime::UNIX_EPOCH
1251        );
1252        assert!(rate.is_finite());
1253    }
1254
1255    #[test]
1256    fn age_based_cleanup_tolerates_oversized_windows() {
1257        let monitor = CancellationDebtMonitor::default();
1258        let id = monitor.queue_work(
1259            WorkType::ChannelCleanup,
1260            "epoch-safe-work".to_string(),
1261            1,
1262            10,
1263            &CancelReason::user("epoch-safe"),
1264            CancelKind::User,
1265            Vec::new(),
1266        );
1267
1268        monitor.clear_old_alerts(Duration::MAX);
1269        let cleaned = monitor.emergency_cleanup(Duration::MAX);
1270
1271        assert_eq!(cleaned, 0);
1272        assert!(monitor.complete_work(id));
1273    }
1274
1275    /// br-asupersync-af24n5 — entity_queue_depths in DebtSnapshot
1276    /// MUST stay bounded when work is queued under attacker-shaped
1277    /// (high-cardinality) entity_ids. Excess entities fold into
1278    /// the `__overflow__` sentinel.
1279    #[test]
1280    fn af24n5_entity_queue_depths_cap_with_overflow_bucket() {
1281        let monitor = CancellationDebtMonitor::default();
1282        let cap = super::MAX_QUEUE_DEPTH_ENTITIES;
1283        let total = cap + 100;
1284        let reason = CancelReason::user("af24n5-cap-test");
1285        for i in 0..total {
1286            let _ = monitor.queue_work(
1287                WorkType::TaskCleanup,
1288                format!("entity_{i}"),
1289                10,
1290                1,
1291                &reason,
1292                CancelKind::User,
1293                Vec::new(),
1294            );
1295        }
1296        let snapshot = monitor.get_debt_snapshot();
1297        assert!(
1298            snapshot.entity_queue_depths.len() <= cap + 1,
1299            "entity_queue_depths grew past cap+overflow: {} (cap {cap})",
1300            snapshot.entity_queue_depths.len()
1301        );
1302        assert!(
1303            snapshot
1304                .entity_queue_depths
1305                .contains_key(super::QUEUE_DEPTH_OVERFLOW_BUCKET),
1306            "overflow sentinel must be present once cap is exceeded"
1307        );
1308    }
1309}