Skip to main content

perl_workspace/monitoring/
mod.rs

1//! Monitoring, limits, and lifecycle instrumentation primitives.
2//!
3//! This module is the observability/control-plane side of workspace indexing:
4//! it tracks state/phase transitions, budgets, and degradation signals while the
5//! core [`workspace`](crate::workspace) module focuses on symbol extraction.
6//!
7//! # Design split
8//!
9//! - **`workspace`** owns indexing data structures and query behavior.
10//! - **`monitoring`** owns metrics, limits, and lifecycle telemetry.
11//!
12//! Keeping these concerns separate avoids mixing mutation-heavy indexing logic
13//! with metrics/reporting code and gives downstream crates a small, doc-friendly
14//! surface for operations dashboards.
15//!
16//! # Main building blocks
17//!
18//! - [`IndexResourceLimits`] and [`IndexPerformanceCaps`] define hard/soft budgets.
19//! - [`IndexMetrics`] provides lock-free counters for parse storm detection.
20//! - [`IndexInstrumentation`] tracks aggregate state durations and transitions.
21//! - [`DegradationReason`] and [`ResourceKind`] classify graceful-degradation paths.
22
23use parking_lot::Mutex;
24use std::collections::HashMap;
25use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
26use std::time::Instant;
27
28/// Build phase while the index is in `Building` state.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
30pub enum IndexPhase {
31    /// No scan has started yet.
32    Idle,
33    /// Workspace file discovery is in progress.
34    Scanning,
35    /// Symbol indexing is in progress.
36    Indexing,
37}
38
39/// Coarse index state kinds for instrumentation and transition tracking.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41pub enum IndexStateKind {
42    /// Index is being built.
43    Building,
44    /// Index is ready for full queries.
45    Ready,
46    /// Index is degraded and serving partial results.
47    Degraded,
48}
49
50/// A state transition for index lifecycle instrumentation.
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
52pub struct IndexStateTransition {
53    /// Transition start state.
54    pub from: IndexStateKind,
55    /// Transition end state.
56    pub to: IndexStateKind,
57}
58
59/// A phase transition while building the workspace index.
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
61pub struct IndexPhaseTransition {
62    /// Transition start phase.
63    pub from: IndexPhase,
64    /// Transition end phase.
65    pub to: IndexPhase,
66}
67
68/// Early-exit reasons for workspace indexing.
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
70pub enum EarlyExitReason {
71    /// Initial scan exceeded the configured time budget.
72    InitialTimeBudget,
73    /// Incremental update exceeded the configured time budget.
74    IncrementalTimeBudget,
75    /// Workspace contained too many files to index within limits.
76    FileLimit,
77}
78
79/// Record describing the latest early-exit event.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct EarlyExitRecord {
82    /// Why the early exit occurred.
83    pub reason: EarlyExitReason,
84    /// Elapsed time in milliseconds when the exit occurred.
85    pub elapsed_ms: u64,
86    /// Files indexed when the exit occurred.
87    pub indexed_files: usize,
88    /// Total files discovered when the exit occurred.
89    pub total_files: usize,
90}
91
92/// Snapshot of index lifecycle instrumentation.
93#[derive(Clone, Debug)]
94pub struct IndexInstrumentationSnapshot {
95    /// Accumulated time spent per state (milliseconds).
96    pub state_durations_ms: HashMap<IndexStateKind, u64>,
97    /// Accumulated time spent per build phase (milliseconds).
98    pub phase_durations_ms: HashMap<IndexPhase, u64>,
99    /// Counts of state transitions.
100    pub state_transition_counts: HashMap<IndexStateTransition, u64>,
101    /// Counts of phase transitions.
102    pub phase_transition_counts: HashMap<IndexPhaseTransition, u64>,
103    /// Counts of early exit reasons.
104    pub early_exit_counts: HashMap<EarlyExitReason, u64>,
105    /// Most recent early exit record.
106    pub last_early_exit: Option<EarlyExitRecord>,
107}
108
109/// Type of resource limit that was exceeded.
110#[derive(Clone, Debug, PartialEq)]
111pub enum ResourceKind {
112    /// Maximum number of files in index exceeded.
113    MaxFiles,
114    /// Maximum total symbols exceeded.
115    MaxSymbols,
116    /// Maximum AST cache bytes exceeded.
117    MaxCacheBytes,
118}
119
120/// Reason for index degradation.
121#[derive(Clone, Debug)]
122pub enum DegradationReason {
123    /// Parse storm (too many simultaneous changes).
124    ParseStorm {
125        /// Number of pending parse operations.
126        pending_parses: usize,
127    },
128    /// IO error during indexing.
129    IoError {
130        /// Error message for diagnostics.
131        message: String,
132    },
133    /// Timeout during workspace scan.
134    ScanTimeout {
135        /// Elapsed time in milliseconds.
136        elapsed_ms: u64,
137    },
138    /// Resource limits exceeded.
139    ResourceLimit {
140        /// Which resource limit was exceeded.
141        kind: ResourceKind,
142    },
143}
144
145/// Configurable resource limits for workspace index.
146#[derive(Clone, Debug)]
147pub struct IndexResourceLimits {
148    /// Maximum files to index (default: 10,000).
149    pub max_files: usize,
150    /// Maximum symbols per file (default: 5,000).
151    pub max_symbols_per_file: usize,
152    /// Maximum total symbols (default: 500,000).
153    pub max_total_symbols: usize,
154    /// Maximum AST cache size in bytes (default: 256MB).
155    pub max_ast_cache_bytes: usize,
156    /// Maximum AST cache items (default: 100).
157    pub max_ast_cache_items: usize,
158    /// Maximum workspace scan duration in milliseconds (default: 30,000ms = 30s).
159    pub max_scan_duration_ms: u64,
160}
161
162impl Default for IndexResourceLimits {
163    fn default() -> Self {
164        Self {
165            max_files: 10_000,
166            max_symbols_per_file: 5_000,
167            max_total_symbols: 500_000,
168            max_ast_cache_bytes: 256 * 1024 * 1024,
169            max_ast_cache_items: 100,
170            max_scan_duration_ms: 30_000,
171        }
172    }
173}
174
175/// Performance caps for workspace indexing operations.
176#[derive(Clone, Debug)]
177pub struct IndexPerformanceCaps {
178    /// Initial workspace scan budget in milliseconds (default: 500ms).
179    pub initial_scan_budget_ms: u64,
180    /// Incremental update budget in milliseconds (default: 10ms).
181    pub incremental_budget_ms: u64,
182}
183
184impl Default for IndexPerformanceCaps {
185    fn default() -> Self {
186        Self { initial_scan_budget_ms: 500, incremental_budget_ms: 10 }
187    }
188}
189
190/// Metrics for index lifecycle management and degradation detection.
191pub struct IndexMetrics {
192    pending_parses: AtomicUsize,
193    parse_storm_threshold: usize,
194    #[allow(dead_code)]
195    last_indexed: AtomicU64,
196}
197
198impl IndexMetrics {
199    /// Create new metrics with default threshold (10 pending parses).
200    pub fn new() -> Self {
201        Self {
202            pending_parses: AtomicUsize::new(0),
203            parse_storm_threshold: 10,
204            last_indexed: AtomicU64::new(0),
205        }
206    }
207
208    /// Create new metrics with custom parse storm threshold.
209    pub fn with_threshold(threshold: usize) -> Self {
210        Self {
211            pending_parses: AtomicUsize::new(0),
212            parse_storm_threshold: threshold,
213            last_indexed: AtomicU64::new(0),
214        }
215    }
216
217    /// Get current pending parse count (lock-free).
218    pub fn pending_count(&self) -> usize {
219        self.pending_parses.load(Ordering::SeqCst)
220    }
221
222    /// Increment pending parse count and return the new value.
223    pub fn increment_pending_parses(&self) -> usize {
224        self.pending_parses.fetch_add(1, Ordering::SeqCst) + 1
225    }
226
227    /// Decrement pending parse count and return the new value.
228    pub fn decrement_pending_parses(&self) -> usize {
229        self.pending_parses.fetch_sub(1, Ordering::SeqCst) - 1
230    }
231
232    /// Determine whether the current pending parse count exceeds the threshold.
233    pub fn is_parse_storm(&self) -> bool {
234        self.pending_count() > self.parse_storm_threshold
235    }
236
237    /// Get the parse-storm threshold.
238    pub fn parse_storm_threshold(&self) -> usize {
239        self.parse_storm_threshold
240    }
241}
242
243impl Default for IndexMetrics {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249#[derive(Debug)]
250struct IndexInstrumentationState {
251    current_state: IndexStateKind,
252    current_phase: IndexPhase,
253    state_started_at: Instant,
254    phase_started_at: Instant,
255    state_durations_ms: HashMap<IndexStateKind, u64>,
256    phase_durations_ms: HashMap<IndexPhase, u64>,
257    state_transition_counts: HashMap<IndexStateTransition, u64>,
258    phase_transition_counts: HashMap<IndexPhaseTransition, u64>,
259    early_exit_counts: HashMap<EarlyExitReason, u64>,
260    last_early_exit: Option<EarlyExitRecord>,
261}
262
263impl IndexInstrumentationState {
264    fn new() -> Self {
265        let now = Instant::now();
266        Self {
267            current_state: IndexStateKind::Building,
268            current_phase: IndexPhase::Idle,
269            state_started_at: now,
270            phase_started_at: now,
271            state_durations_ms: HashMap::new(),
272            phase_durations_ms: HashMap::new(),
273            state_transition_counts: HashMap::new(),
274            phase_transition_counts: HashMap::new(),
275            early_exit_counts: HashMap::new(),
276            last_early_exit: None,
277        }
278    }
279}
280
281/// Index lifecycle instrumentation for state durations and transitions.
282#[derive(Debug)]
283pub struct IndexInstrumentation {
284    inner: Mutex<IndexInstrumentationState>,
285}
286
287impl IndexInstrumentation {
288    /// Create a new instrumentation tracker.
289    pub fn new() -> Self {
290        Self { inner: Mutex::new(IndexInstrumentationState::new()) }
291    }
292
293    /// Record a state transition.
294    pub fn record_state_transition(&self, from: IndexStateKind, to: IndexStateKind) {
295        let now = Instant::now();
296        let mut inner = self.inner.lock();
297        let elapsed_ms = now.duration_since(inner.state_started_at).as_millis() as u64;
298        *inner.state_durations_ms.entry(from).or_default() += elapsed_ms;
299
300        let transition = IndexStateTransition { from, to };
301        *inner.state_transition_counts.entry(transition).or_default() += 1;
302
303        if from == IndexStateKind::Building {
304            let phase_elapsed = now.duration_since(inner.phase_started_at).as_millis() as u64;
305            let current_phase = inner.current_phase;
306            *inner.phase_durations_ms.entry(current_phase).or_default() += phase_elapsed;
307        }
308
309        inner.current_state = to;
310        inner.state_started_at = now;
311
312        if to == IndexStateKind::Building || from == IndexStateKind::Building {
313            inner.current_phase = IndexPhase::Idle;
314            inner.phase_started_at = now;
315        }
316    }
317
318    /// Record a build-phase transition.
319    pub fn record_phase_transition(&self, from: IndexPhase, to: IndexPhase) {
320        let now = Instant::now();
321        let mut inner = self.inner.lock();
322        let elapsed_ms = now.duration_since(inner.phase_started_at).as_millis() as u64;
323        *inner.phase_durations_ms.entry(from).or_default() += elapsed_ms;
324
325        let transition = IndexPhaseTransition { from, to };
326        *inner.phase_transition_counts.entry(transition).or_default() += 1;
327
328        inner.current_phase = to;
329        inner.phase_started_at = now;
330    }
331
332    /// Record an early-exit event.
333    pub fn record_early_exit(&self, record: EarlyExitRecord) {
334        let mut inner = self.inner.lock();
335        *inner.early_exit_counts.entry(record.reason).or_default() += 1;
336        inner.last_early_exit = Some(record);
337    }
338
339    /// Return a current snapshot including elapsed time in the active state/phase.
340    pub fn snapshot(&self) -> IndexInstrumentationSnapshot {
341        let now = Instant::now();
342        let inner = self.inner.lock();
343        let mut state_durations_ms = inner.state_durations_ms.clone();
344        let mut phase_durations_ms = inner.phase_durations_ms.clone();
345
346        let state_elapsed = now.duration_since(inner.state_started_at).as_millis() as u64;
347        *state_durations_ms.entry(inner.current_state).or_default() += state_elapsed;
348
349        if inner.current_state == IndexStateKind::Building {
350            let phase_elapsed = now.duration_since(inner.phase_started_at).as_millis() as u64;
351            *phase_durations_ms.entry(inner.current_phase).or_default() += phase_elapsed;
352        }
353
354        IndexInstrumentationSnapshot {
355            state_durations_ms,
356            phase_durations_ms,
357            state_transition_counts: inner.state_transition_counts.clone(),
358            phase_transition_counts: inner.phase_transition_counts.clone(),
359            early_exit_counts: inner.early_exit_counts.clone(),
360            last_early_exit: inner.last_early_exit.clone(),
361        }
362    }
363}
364
365impl Default for IndexInstrumentation {
366    fn default() -> Self {
367        Self::new()
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use anyhow::Result;
375    use std::thread;
376    use std::time::Duration;
377
378    #[test]
379    fn test_metrics_threshold_and_parse_storm_detection() -> Result<()> {
380        let metrics = IndexMetrics::with_threshold(2);
381        assert_eq!(metrics.pending_count(), 0);
382        assert!(!metrics.is_parse_storm());
383
384        assert_eq!(metrics.increment_pending_parses(), 1);
385        assert_eq!(metrics.increment_pending_parses(), 2);
386        assert!(!metrics.is_parse_storm());
387
388        assert_eq!(metrics.increment_pending_parses(), 3);
389        assert!(metrics.is_parse_storm());
390        assert_eq!(metrics.parse_storm_threshold(), 2);
391
392        assert_eq!(metrics.decrement_pending_parses(), 2);
393        assert!(!metrics.is_parse_storm());
394        Ok(())
395    }
396
397    #[test]
398    fn test_instrumentation_records_transitions_and_early_exits() -> Result<()> {
399        let instrumentation = IndexInstrumentation::new();
400
401        instrumentation.record_phase_transition(IndexPhase::Idle, IndexPhase::Scanning);
402        thread::sleep(Duration::from_millis(1));
403        instrumentation.record_phase_transition(IndexPhase::Scanning, IndexPhase::Indexing);
404
405        instrumentation.record_state_transition(IndexStateKind::Building, IndexStateKind::Ready);
406
407        let record = EarlyExitRecord {
408            reason: EarlyExitReason::FileLimit,
409            elapsed_ms: 17,
410            indexed_files: 100,
411            total_files: 200,
412        };
413        instrumentation.record_early_exit(record.clone());
414
415        let snapshot = instrumentation.snapshot();
416
417        assert_eq!(
418            snapshot
419                .phase_transition_counts
420                .get(&IndexPhaseTransition { from: IndexPhase::Idle, to: IndexPhase::Scanning }),
421            Some(&1)
422        );
423        assert_eq!(
424            snapshot.phase_transition_counts.get(&IndexPhaseTransition {
425                from: IndexPhase::Scanning,
426                to: IndexPhase::Indexing,
427            }),
428            Some(&1)
429        );
430        assert_eq!(
431            snapshot.state_transition_counts.get(&IndexStateTransition {
432                from: IndexStateKind::Building,
433                to: IndexStateKind::Ready,
434            }),
435            Some(&1)
436        );
437        assert_eq!(snapshot.early_exit_counts.get(&EarlyExitReason::FileLimit), Some(&1));
438        assert_eq!(snapshot.last_early_exit, Some(record));
439
440        Ok(())
441    }
442
443    #[test]
444    fn test_snapshot_includes_active_state_duration() -> Result<()> {
445        let instrumentation = IndexInstrumentation::new();
446        thread::sleep(Duration::from_millis(1));
447        let snapshot = instrumentation.snapshot();
448
449        let building_duration =
450            snapshot.state_durations_ms.get(&IndexStateKind::Building).copied().unwrap_or_default();
451        let idle_phase_duration =
452            snapshot.phase_durations_ms.get(&IndexPhase::Idle).copied().unwrap_or_default();
453
454        assert!(building_duration >= 1);
455        assert!(idle_phase_duration >= 1);
456        Ok(())
457    }
458}