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        match self
230            .pending_parses
231            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| current.checked_sub(1))
232        {
233            Ok(previous) => previous.saturating_sub(1),
234            Err(current) => current,
235        }
236    }
237
238    /// Determine whether the current pending parse count exceeds the threshold.
239    pub fn is_parse_storm(&self) -> bool {
240        self.pending_count() > self.parse_storm_threshold
241    }
242
243    /// Get the parse-storm threshold.
244    pub fn parse_storm_threshold(&self) -> usize {
245        self.parse_storm_threshold
246    }
247}
248
249impl Default for IndexMetrics {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255#[derive(Debug)]
256struct IndexInstrumentationState {
257    current_state: IndexStateKind,
258    current_phase: IndexPhase,
259    state_started_at: Instant,
260    phase_started_at: Instant,
261    state_durations_ms: HashMap<IndexStateKind, u64>,
262    phase_durations_ms: HashMap<IndexPhase, u64>,
263    state_transition_counts: HashMap<IndexStateTransition, u64>,
264    phase_transition_counts: HashMap<IndexPhaseTransition, u64>,
265    early_exit_counts: HashMap<EarlyExitReason, u64>,
266    last_early_exit: Option<EarlyExitRecord>,
267}
268
269impl IndexInstrumentationState {
270    fn new() -> Self {
271        let now = Instant::now();
272        Self {
273            current_state: IndexStateKind::Building,
274            current_phase: IndexPhase::Idle,
275            state_started_at: now,
276            phase_started_at: now,
277            state_durations_ms: HashMap::new(),
278            phase_durations_ms: HashMap::new(),
279            state_transition_counts: HashMap::new(),
280            phase_transition_counts: HashMap::new(),
281            early_exit_counts: HashMap::new(),
282            last_early_exit: None,
283        }
284    }
285}
286
287/// Index lifecycle instrumentation for state durations and transitions.
288#[derive(Debug)]
289pub struct IndexInstrumentation {
290    inner: Mutex<IndexInstrumentationState>,
291}
292
293impl IndexInstrumentation {
294    /// Create a new instrumentation tracker.
295    pub fn new() -> Self {
296        Self { inner: Mutex::new(IndexInstrumentationState::new()) }
297    }
298
299    /// Record a state transition.
300    pub fn record_state_transition(&self, from: IndexStateKind, to: IndexStateKind) {
301        let now = Instant::now();
302        let mut inner = self.inner.lock();
303        let elapsed_ms = now.duration_since(inner.state_started_at).as_millis() as u64;
304        *inner.state_durations_ms.entry(from).or_default() += elapsed_ms;
305
306        let transition = IndexStateTransition { from, to };
307        *inner.state_transition_counts.entry(transition).or_default() += 1;
308
309        if from == IndexStateKind::Building {
310            let phase_elapsed = now.duration_since(inner.phase_started_at).as_millis() as u64;
311            let current_phase = inner.current_phase;
312            *inner.phase_durations_ms.entry(current_phase).or_default() += phase_elapsed;
313        }
314
315        inner.current_state = to;
316        inner.state_started_at = now;
317
318        if to == IndexStateKind::Building || from == IndexStateKind::Building {
319            inner.current_phase = IndexPhase::Idle;
320            inner.phase_started_at = now;
321        }
322    }
323
324    /// Record a build-phase transition.
325    pub fn record_phase_transition(&self, from: IndexPhase, to: IndexPhase) {
326        let now = Instant::now();
327        let mut inner = self.inner.lock();
328        let elapsed_ms = now.duration_since(inner.phase_started_at).as_millis() as u64;
329        *inner.phase_durations_ms.entry(from).or_default() += elapsed_ms;
330
331        let transition = IndexPhaseTransition { from, to };
332        *inner.phase_transition_counts.entry(transition).or_default() += 1;
333
334        inner.current_phase = to;
335        inner.phase_started_at = now;
336    }
337
338    /// Record an early-exit event.
339    pub fn record_early_exit(&self, record: EarlyExitRecord) {
340        let mut inner = self.inner.lock();
341        *inner.early_exit_counts.entry(record.reason).or_default() += 1;
342        inner.last_early_exit = Some(record);
343    }
344
345    /// Return a current snapshot including elapsed time in the active state/phase.
346    pub fn snapshot(&self) -> IndexInstrumentationSnapshot {
347        let now = Instant::now();
348        let inner = self.inner.lock();
349        let mut state_durations_ms = inner.state_durations_ms.clone();
350        let mut phase_durations_ms = inner.phase_durations_ms.clone();
351
352        let state_elapsed = now.duration_since(inner.state_started_at).as_millis() as u64;
353        *state_durations_ms.entry(inner.current_state).or_default() += state_elapsed;
354
355        if inner.current_state == IndexStateKind::Building {
356            let phase_elapsed = now.duration_since(inner.phase_started_at).as_millis() as u64;
357            *phase_durations_ms.entry(inner.current_phase).or_default() += phase_elapsed;
358        }
359
360        IndexInstrumentationSnapshot {
361            state_durations_ms,
362            phase_durations_ms,
363            state_transition_counts: inner.state_transition_counts.clone(),
364            phase_transition_counts: inner.phase_transition_counts.clone(),
365            early_exit_counts: inner.early_exit_counts.clone(),
366            last_early_exit: inner.last_early_exit.clone(),
367        }
368    }
369}
370
371impl Default for IndexInstrumentation {
372    fn default() -> Self {
373        Self::new()
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use anyhow::Result;
381    use std::thread;
382    use std::time::Duration;
383
384    #[test]
385    fn test_metrics_threshold_and_parse_storm_detection() -> Result<()> {
386        let metrics = IndexMetrics::with_threshold(2);
387        assert_eq!(metrics.pending_count(), 0);
388        assert!(!metrics.is_parse_storm());
389
390        assert_eq!(metrics.increment_pending_parses(), 1);
391        assert_eq!(metrics.increment_pending_parses(), 2);
392        assert!(!metrics.is_parse_storm());
393
394        assert_eq!(metrics.increment_pending_parses(), 3);
395        assert!(metrics.is_parse_storm());
396        assert_eq!(metrics.parse_storm_threshold(), 2);
397
398        assert_eq!(metrics.decrement_pending_parses(), 2);
399        assert!(!metrics.is_parse_storm());
400        Ok(())
401    }
402
403    #[test]
404    fn test_instrumentation_records_transitions_and_early_exits() -> Result<()> {
405        let instrumentation = IndexInstrumentation::new();
406
407        instrumentation.record_phase_transition(IndexPhase::Idle, IndexPhase::Scanning);
408        thread::sleep(Duration::from_millis(1));
409        instrumentation.record_phase_transition(IndexPhase::Scanning, IndexPhase::Indexing);
410
411        instrumentation.record_state_transition(IndexStateKind::Building, IndexStateKind::Ready);
412
413        let record = EarlyExitRecord {
414            reason: EarlyExitReason::FileLimit,
415            elapsed_ms: 17,
416            indexed_files: 100,
417            total_files: 200,
418        };
419        instrumentation.record_early_exit(record.clone());
420
421        let snapshot = instrumentation.snapshot();
422
423        assert_eq!(
424            snapshot
425                .phase_transition_counts
426                .get(&IndexPhaseTransition { from: IndexPhase::Idle, to: IndexPhase::Scanning }),
427            Some(&1)
428        );
429        assert_eq!(
430            snapshot.phase_transition_counts.get(&IndexPhaseTransition {
431                from: IndexPhase::Scanning,
432                to: IndexPhase::Indexing,
433            }),
434            Some(&1)
435        );
436        assert_eq!(
437            snapshot.state_transition_counts.get(&IndexStateTransition {
438                from: IndexStateKind::Building,
439                to: IndexStateKind::Ready,
440            }),
441            Some(&1)
442        );
443        assert_eq!(snapshot.early_exit_counts.get(&EarlyExitReason::FileLimit), Some(&1));
444        assert_eq!(snapshot.last_early_exit, Some(record));
445
446        Ok(())
447    }
448
449    #[test]
450    fn test_snapshot_includes_active_state_duration() -> Result<()> {
451        let instrumentation = IndexInstrumentation::new();
452        thread::sleep(Duration::from_millis(1));
453        let snapshot = instrumentation.snapshot();
454
455        let building_duration =
456            snapshot.state_durations_ms.get(&IndexStateKind::Building).copied().unwrap_or_default();
457        let idle_phase_duration =
458            snapshot.phase_durations_ms.get(&IndexPhase::Idle).copied().unwrap_or_default();
459
460        assert!(building_duration >= 1);
461        assert!(idle_phase_duration >= 1);
462        Ok(())
463    }
464}