Skip to main content

perl_workspace/slo/
mod.rs

1//! Service Level Objectives (SLOs) for workspace index operations.
2//!
3//! This module defines performance targets and monitoring infrastructure
4//! for critical workspace index operations. SLOs provide measurable
5//! quality targets for production deployments.
6//!
7//! # SLO Targets
8//!
9//! - **Index Initialization**: <5s for 10K files (P95)
10//! - **Incremental Update**: <100ms for single file change (P95)
11//! - **Definition Lookup**: <50ms (P95)
12//! - **Completion**: <100ms (P95)
13//! - **Hover**: <50ms (P95)
14//!
15//! # Performance Monitoring
16//!
17//! - Latency tracking with percentiles (P50, P95, P99)
18//! - Error rate monitoring
19//! - Throughput metrics
20//! - SLO compliance reporting
21//!
22//! # Usage
23//!
24//! ```rust
25//! use perl_workspace::slo::{SloConfig, SloTracker};
26//! use perl_workspace::slo::{OperationResult, OperationType};
27//!
28//! let config = SloConfig::default();
29//! let tracker = SloTracker::new(config);
30//!
31//! let start = tracker.start_operation(OperationType::DefinitionLookup);
32//! // ... perform operation ...
33//! tracker.record_operation_type(OperationType::DefinitionLookup, start, OperationResult::Success);
34//! ```
35
36use parking_lot::Mutex;
37use perl_parser_core::percentile::nearest_rank_percentile;
38use std::collections::{HashMap, VecDeque};
39use std::sync::Arc;
40use std::time::{Duration, Instant};
41
42/// SLO configuration for workspace index operations.
43///
44/// Defines latency targets and monitoring parameters for each operation type.
45#[derive(Clone, Debug)]
46pub struct SloConfig {
47    /// Target latency for index initialization (P95)
48    pub index_init_p95_ms: u64,
49    /// Target latency for incremental updates (P95)
50    pub incremental_update_p95_ms: u64,
51    /// Target latency for definition lookup (P95)
52    pub definition_lookup_p95_ms: u64,
53    /// Target latency for completion (P95)
54    pub completion_p95_ms: u64,
55    /// Target latency for hover (P95)
56    pub hover_p95_ms: u64,
57    /// Maximum acceptable error rate (0.0 to 1.0)
58    pub max_error_rate: f64,
59    /// Number of samples to keep for percentile calculation
60    pub sample_window_size: usize,
61}
62
63impl Default for SloConfig {
64    fn default() -> Self {
65        Self {
66            index_init_p95_ms: 5000,        // 5 seconds
67            incremental_update_p95_ms: 100, // 100ms
68            definition_lookup_p95_ms: 50,   // 50ms
69            completion_p95_ms: 100,         // 100ms
70            hover_p95_ms: 50,               // 50ms
71            max_error_rate: 0.01,           // 1% error rate
72            sample_window_size: 1000,
73        }
74    }
75}
76
77/// Operation types tracked by SLO monitoring.
78#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
79pub enum OperationType {
80    /// Initial workspace index construction
81    IndexInitialization,
82    /// Incremental index update for file changes
83    IncrementalUpdate,
84    /// Go-to-definition lookup
85    DefinitionLookup,
86    /// Code completion request
87    Completion,
88    /// Hover information request
89    Hover,
90    /// Find references request
91    FindReferences,
92    /// Workspace symbol search
93    WorkspaceSymbols,
94    /// File indexing operation
95    FileIndexing,
96}
97
98impl OperationType {
99    /// Get the SLO target for this operation type.
100    pub fn slo_target_ms(&self, config: &SloConfig) -> u64 {
101        match self {
102            OperationType::IndexInitialization => config.index_init_p95_ms,
103            OperationType::IncrementalUpdate => config.incremental_update_p95_ms,
104            OperationType::DefinitionLookup => config.definition_lookup_p95_ms,
105            OperationType::Completion => config.completion_p95_ms,
106            OperationType::Hover => config.hover_p95_ms,
107            OperationType::FindReferences => config.definition_lookup_p95_ms, // Same as definition
108            OperationType::WorkspaceSymbols => config.definition_lookup_p95_ms, // Same as definition
109            OperationType::FileIndexing => config.incremental_update_p95_ms, // Same as incremental
110        }
111    }
112
113    /// Get a human-readable name for this operation.
114    pub fn name(&self) -> &'static str {
115        match self {
116            OperationType::IndexInitialization => "index_initialization",
117            OperationType::IncrementalUpdate => "incremental_update",
118            OperationType::DefinitionLookup => "definition_lookup",
119            OperationType::Completion => "completion",
120            OperationType::Hover => "hover",
121            OperationType::FindReferences => "find_references",
122            OperationType::WorkspaceSymbols => "workspace_symbols",
123            OperationType::FileIndexing => "file_indexing",
124        }
125    }
126}
127
128/// Runtime phase for an SLO operation sample.
129///
130/// Regime tags let scorecards distinguish startup/indexing work from warm
131/// interactive requests and edit-triggered incremental updates.
132#[non_exhaustive]
133#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
134pub enum Regime {
135    /// Startup and initial indexing operations.
136    Cold,
137    /// Post-index interactive operations.
138    Warm,
139    /// Edit-triggered incremental operations.
140    Incremental,
141}
142
143impl Regime {
144    const ALL: [Self; 3] = [Self::Cold, Self::Warm, Self::Incremental];
145
146    /// Get a human-readable name for this regime.
147    pub fn name(&self) -> &'static str {
148        match self {
149            Self::Cold => "cold",
150            Self::Warm => "warm",
151            Self::Incremental => "incremental",
152        }
153    }
154}
155
156/// Result of an SLO operation.
157#[derive(Clone, Debug)]
158pub enum OperationResult {
159    /// Operation succeeded
160    Success,
161    /// Operation failed with an error message
162    Failure(String),
163}
164
165impl OperationResult {
166    /// Check if the operation was successful.
167    pub fn is_success(&self) -> bool {
168        matches!(self, OperationResult::Success)
169    }
170}
171
172impl<T, E> From<Result<T, E>> for OperationResult
173where
174    E: std::fmt::Display,
175{
176    fn from(result: Result<T, E>) -> Self {
177        match result {
178            Ok(_) => OperationResult::Success,
179            Err(e) => OperationResult::Failure(e.to_string()),
180        }
181    }
182}
183
184/// Latency sample for SLO tracking.
185#[derive(Clone, Debug)]
186struct LatencySample {
187    /// Duration of the operation
188    duration: Duration,
189    /// Whether the operation succeeded
190    success: bool,
191    /// Runtime phase where the operation occurred
192    regime: Regime,
193}
194
195/// SLO statistics for a specific operation type.
196#[derive(Clone, Debug)]
197pub struct SloStatistics {
198    /// Total number of operations
199    pub total_count: u64,
200    /// Number of successful operations
201    pub success_count: u64,
202    /// Number of failed operations
203    pub failure_count: u64,
204    /// Error rate (failures / total)
205    pub error_rate: f64,
206    /// P50 latency (median)
207    pub p50_ms: u64,
208    /// P95 latency
209    pub p95_ms: u64,
210    /// P99 latency
211    pub p99_ms: u64,
212    /// Average latency
213    pub avg_ms: f64,
214    /// Whether SLO is being met
215    pub slo_met: bool,
216}
217
218impl Default for SloStatistics {
219    fn default() -> Self {
220        Self {
221            total_count: 0,
222            success_count: 0,
223            failure_count: 0,
224            error_rate: 0.0,
225            p50_ms: 0,
226            p95_ms: 0,
227            p99_ms: 0,
228            avg_ms: 0.0,
229            slo_met: true,
230        }
231    }
232}
233
234/// Per-operation SLO tracker.
235#[derive(Debug)]
236struct OperationSloTracker {
237    /// Operation type being tracked
238    _operation_type: OperationType,
239    /// Latency samples (most recent first)
240    samples: VecDeque<LatencySample>,
241    /// SLO target for this operation
242    slo_target_ms: u64,
243    /// Maximum error rate
244    max_error_rate: f64,
245    /// Maximum number of samples to keep
246    max_samples: usize,
247}
248
249impl OperationSloTracker {
250    /// Create a new operation SLO tracker.
251    fn new(operation_type: OperationType, config: &SloConfig) -> Self {
252        Self {
253            _operation_type: operation_type,
254            samples: VecDeque::with_capacity(config.sample_window_size),
255            slo_target_ms: operation_type.slo_target_ms(config),
256            max_error_rate: config.max_error_rate,
257            max_samples: config.sample_window_size,
258        }
259    }
260
261    /// Record an operation result using the default warm regime.
262    fn record(&mut self, duration: Duration, result: OperationResult) {
263        self.record_with_regime(duration, result, Regime::Warm);
264    }
265
266    /// Record an operation result with an explicit regime tag.
267    fn record_with_regime(&mut self, duration: Duration, result: OperationResult, regime: Regime) {
268        let success = result.is_success();
269        let sample = LatencySample { duration, success, regime };
270
271        // Add sample
272        if self.samples.len() >= self.max_samples {
273            self.samples.pop_front();
274        }
275        self.samples.push_back(sample);
276    }
277
278    /// Calculate SLO statistics for this operation.
279    fn statistics(&self) -> SloStatistics {
280        let samples: Vec<&LatencySample> = self.samples.iter().collect();
281        self.statistics_for_samples(&samples)
282    }
283
284    /// Calculate SLO statistics for one regime.
285    fn statistics_for_regime(&self, regime: Regime) -> SloStatistics {
286        let samples: Vec<&LatencySample> =
287            self.samples.iter().filter(|sample| sample.regime == regime).collect();
288        self.statistics_for_samples(&samples)
289    }
290
291    fn statistics_for_samples(&self, samples: &[&LatencySample]) -> SloStatistics {
292        if samples.is_empty() {
293            return SloStatistics::default();
294        }
295
296        let total_count = samples.len() as u64;
297        let success_count = samples.iter().filter(|s| s.success).count() as u64;
298        let failure_count = total_count - success_count;
299        let error_rate =
300            if total_count > 0 { failure_count as f64 / total_count as f64 } else { 0.0 };
301
302        // Calculate percentiles
303        let mut durations_ms: Vec<u64> =
304            samples.iter().map(|s| s.duration.as_millis() as u64).collect();
305        durations_ms.sort_unstable();
306
307        let p50_ms = nearest_rank_percentile(&durations_ms, 50);
308        let p95_ms = nearest_rank_percentile(&durations_ms, 95);
309        let p99_ms = nearest_rank_percentile(&durations_ms, 99);
310
311        let avg_ms =
312            durations_ms.iter().map(|&d| d as f64).sum::<f64>() / durations_ms.len() as f64;
313
314        // Check if SLO is met
315        let slo_met = p95_ms <= self.slo_target_ms && error_rate <= self.max_error_rate;
316
317        SloStatistics {
318            total_count,
319            success_count,
320            failure_count,
321            error_rate,
322            p50_ms,
323            p95_ms,
324            p99_ms,
325            avg_ms,
326            slo_met,
327        }
328    }
329}
330
331/// SLO tracker for workspace index operations.
332///
333/// Tracks latency and success/failure for all operation types,
334/// providing SLO compliance monitoring and reporting.
335pub struct SloTracker {
336    /// SLO configuration
337    config: SloConfig,
338    /// Per-operation trackers
339    trackers: Arc<Mutex<std::collections::HashMap<OperationType, OperationSloTracker>>>,
340}
341
342impl SloTracker {
343    /// Create a new SLO tracker with the given configuration.
344    ///
345    /// # Arguments
346    ///
347    /// * `config` - SLO configuration
348    ///
349    /// # Returns
350    ///
351    /// A new SLO tracker instance.
352    ///
353    /// # Examples
354    ///
355    /// ```rust
356    /// use perl_workspace::slo::{SloConfig, SloTracker};
357    ///
358    /// let config = SloConfig::default();
359    /// let tracker = SloTracker::new(config);
360    /// ```
361    pub fn new(config: SloConfig) -> Self {
362        let mut trackers = std::collections::HashMap::new();
363
364        // Initialize trackers for all operation types
365        for op_type in [
366            OperationType::IndexInitialization,
367            OperationType::IncrementalUpdate,
368            OperationType::DefinitionLookup,
369            OperationType::Completion,
370            OperationType::Hover,
371            OperationType::FindReferences,
372            OperationType::WorkspaceSymbols,
373            OperationType::FileIndexing,
374        ] {
375            trackers.insert(op_type, OperationSloTracker::new(op_type, &config));
376        }
377
378        Self { config, trackers: Arc::new(Mutex::new(trackers)) }
379    }
380
381    /// Start tracking an operation.
382    ///
383    /// Returns an [`Instant`] to pass to [`Self::record_operation_type`].
384    /// The `operation_type` parameter is accepted for call-site readability
385    /// but is not encoded in the returned timestamp — always pair with
386    /// `record_operation_type` using the same type.
387    ///
388    /// # Examples
389    ///
390    /// ```rust
391    /// use perl_workspace::slo::{OperationResult, OperationType, SloTracker};
392    ///
393    /// let tracker = SloTracker::default();
394    /// let start = tracker.start_operation(OperationType::DefinitionLookup);
395    /// // ... perform operation ...
396    /// tracker.record_operation_type(OperationType::DefinitionLookup, start, OperationResult::Success);
397    /// ```
398    pub fn start_operation(&self, _operation_type: OperationType) -> Instant {
399        Instant::now()
400    }
401
402    /// Record the completion of an operation.
403    ///
404    /// # Deprecation
405    ///
406    /// This method records the duration to **every** operation-type tracker
407    /// simultaneously, which pollutes all per-type statistics.  Use
408    /// [`Self::record_operation_type`] instead to target the correct tracker.
409    ///
410    /// # Examples
411    ///
412    /// ```rust
413    /// use perl_workspace::slo::{SloTracker, OperationType, OperationResult};
414    ///
415    /// let tracker = SloTracker::default();
416    /// let start = tracker.start_operation(OperationType::DefinitionLookup);
417    /// // ... perform operation ...
418    /// // Prefer: tracker.record_operation_type(OperationType::DefinitionLookup, start, OperationResult::Success);
419    /// #[allow(deprecated)]
420    /// tracker.record_operation(start, OperationResult::Success);
421    /// ```
422    #[deprecated(
423        since = "0.13.0",
424        note = "records to every operation-type tracker at once — use record_operation_type instead"
425    )]
426    pub fn record_operation(&self, start: Instant, result: OperationResult) {
427        let duration = start.elapsed();
428        let mut trackers = self.trackers.lock();
429
430        for tracker in trackers.values_mut() {
431            tracker.record(duration, result.clone());
432        }
433    }
434
435    /// Record the completion of a specific operation type.
436    ///
437    /// # Arguments
438    ///
439    /// * `operation_type` - Type of operation
440    /// * `start` - Timestamp returned from `start_operation`
441    /// * `result` - Operation result
442    ///
443    /// # Examples
444    ///
445    /// ```rust
446    /// use perl_workspace::slo::{SloTracker, OperationType, OperationResult};
447    ///
448    /// let tracker = SloTracker::default();
449    /// let start = tracker.start_operation(OperationType::DefinitionLookup);
450    /// // ... perform operation ...
451    /// tracker.record_operation_type(OperationType::DefinitionLookup, start, OperationResult::Success);
452    /// ```
453    pub fn record_operation_type(
454        &self,
455        operation_type: OperationType,
456        start: Instant,
457        result: OperationResult,
458    ) {
459        self.record_operation_type_with_regime(operation_type, start, result, Regime::Warm);
460    }
461
462    /// Record the completion of a specific operation type with a regime tag.
463    pub fn record_operation_type_with_regime(
464        &self,
465        operation_type: OperationType,
466        start: Instant,
467        result: OperationResult,
468        regime: Regime,
469    ) {
470        let duration = start.elapsed();
471        let mut trackers = self.trackers.lock();
472
473        if let Some(tracker) = trackers.get_mut(&operation_type) {
474            tracker.record_with_regime(duration, result, regime);
475        }
476    }
477
478    /// Get SLO statistics for a specific operation type.
479    ///
480    /// # Arguments
481    ///
482    /// * `operation_type` - Type of operation
483    ///
484    /// # Returns
485    ///
486    /// SLO statistics for the operation type.
487    ///
488    /// # Examples
489    ///
490    /// ```rust
491    /// use perl_workspace::slo::{SloTracker, OperationType};
492    ///
493    /// let tracker = SloTracker::default();
494    /// let stats = tracker.statistics(OperationType::DefinitionLookup);
495    /// ```
496    pub fn statistics(&self, operation_type: OperationType) -> SloStatistics {
497        let trackers = self.trackers.lock();
498        trackers.get(&operation_type).map(|t| t.statistics()).unwrap_or_default()
499    }
500
501    /// Get SLO statistics for all operation types.
502    ///
503    /// # Returns
504    ///
505    /// A map of operation type to SLO statistics.
506    ///
507    /// # Examples
508    ///
509    /// ```rust
510    /// use perl_workspace::slo::SloTracker;
511    ///
512    /// let tracker = SloTracker::default();
513    /// let all_stats = tracker.all_statistics();
514    /// ```
515    pub fn all_statistics(&self) -> HashMap<OperationType, SloStatistics> {
516        let trackers = self.trackers.lock();
517        trackers.iter().map(|(op_type, tracker)| (*op_type, tracker.statistics())).collect()
518    }
519
520    /// Get SLO statistics for a specific operation type grouped by regime.
521    pub fn statistics_by_regime(
522        &self,
523        operation_type: OperationType,
524    ) -> HashMap<Regime, SloStatistics> {
525        let trackers = self.trackers.lock();
526        Regime::ALL
527            .into_iter()
528            .map(|regime| {
529                let statistics =
530                    trackers.get(&operation_type).map_or_else(SloStatistics::default, |tracker| {
531                        tracker.statistics_for_regime(regime)
532                    });
533                (regime, statistics)
534            })
535            .collect()
536    }
537
538    /// Get the number of samples for a specific operation type and regime.
539    pub fn sample_count_by_regime(&self, operation_type: OperationType, regime: Regime) -> usize {
540        let trackers = self.trackers.lock();
541        trackers.get(&operation_type).map_or(0, |tracker| {
542            tracker.samples.iter().filter(|sample| sample.regime == regime).count()
543        })
544    }
545
546    /// Check if all SLOs are being met.
547    ///
548    /// # Returns
549    ///
550    /// `true` if all operation types are meeting their SLOs, `false` otherwise.
551    ///
552    /// # Examples
553    ///
554    /// ```rust
555    /// use perl_workspace::slo::SloTracker;
556    ///
557    /// let tracker = SloTracker::default();
558    /// let all_met = tracker.all_slos_met();
559    /// ```
560    pub fn all_slos_met(&self) -> bool {
561        let trackers = self.trackers.lock();
562        trackers.values().all(|t| t.statistics().slo_met)
563    }
564
565    /// Return the number of samples currently in the window for `operation_type`.
566    ///
567    /// Useful for testing and monitoring — confirms that samples are being
568    /// recorded without computing full percentile statistics.
569    ///
570    /// # Examples
571    ///
572    /// ```rust
573    /// use perl_workspace::slo::{OperationResult, OperationType, SloTracker};
574    ///
575    /// let tracker = SloTracker::default();
576    /// assert_eq!(tracker.sample_count(OperationType::DefinitionLookup), 0);
577    /// let start = tracker.start_operation(OperationType::DefinitionLookup);
578    /// tracker.record_operation_type(OperationType::DefinitionLookup, start, OperationResult::Success);
579    /// assert_eq!(tracker.sample_count(OperationType::DefinitionLookup), 1);
580    /// ```
581    pub fn sample_count(&self, operation_type: OperationType) -> usize {
582        let trackers = self.trackers.lock();
583        trackers.get(&operation_type).map_or(0, |t| t.samples.len())
584    }
585
586    /// Get the SLO configuration.
587    ///
588    /// # Returns
589    ///
590    /// The SLO configuration.
591    pub fn config(&self) -> &SloConfig {
592        &self.config
593    }
594
595    /// Reset all statistics.
596    ///
597    /// # Examples
598    ///
599    /// ```rust
600    /// use perl_workspace::slo::SloTracker;
601    ///
602    /// let tracker = SloTracker::default();
603    /// tracker.reset();
604    /// ```
605    pub fn reset(&self) {
606        let mut trackers = self.trackers.lock();
607        for tracker in trackers.values_mut() {
608            tracker.samples.clear();
609        }
610    }
611}
612
613impl Default for SloTracker {
614    fn default() -> Self {
615        Self::new(SloConfig::default())
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn test_slo_tracker_record() {
625        let tracker = SloTracker::default();
626        let start = tracker.start_operation(OperationType::DefinitionLookup);
627        tracker.record_operation_type(
628            OperationType::DefinitionLookup,
629            start,
630            OperationResult::Success,
631        );
632
633        let stats = tracker.statistics(OperationType::DefinitionLookup);
634        assert_eq!(stats.total_count, 1);
635        assert_eq!(stats.success_count, 1);
636    }
637
638    #[test]
639    fn test_slo_statistics() {
640        let tracker = SloTracker::default();
641
642        // Record some operations
643        for _ in 0..10 {
644            let start = tracker.start_operation(OperationType::DefinitionLookup);
645            std::thread::sleep(Duration::from_millis(1));
646            tracker.record_operation_type(
647                OperationType::DefinitionLookup,
648                start,
649                OperationResult::Success,
650            );
651        }
652
653        let stats = tracker.statistics(OperationType::DefinitionLookup);
654        assert_eq!(stats.total_count, 10);
655        assert_eq!(stats.success_count, 10);
656        assert!(stats.p50_ms > 0);
657        assert!(stats.p95_ms > 0);
658    }
659
660    #[test]
661    fn test_slo_met() {
662        let tracker = SloTracker::default();
663
664        // Record fast operations (should meet SLO)
665        for _ in 0..10 {
666            let start = tracker.start_operation(OperationType::DefinitionLookup);
667            std::thread::sleep(Duration::from_millis(1));
668            tracker.record_operation_type(
669                OperationType::DefinitionLookup,
670                start,
671                OperationResult::Success,
672            );
673        }
674
675        let stats = tracker.statistics(OperationType::DefinitionLookup);
676        assert!(stats.slo_met);
677    }
678
679    #[test]
680    fn test_operation_type_name() {
681        assert_eq!(OperationType::DefinitionLookup.name(), "definition_lookup");
682        assert_eq!(OperationType::Completion.name(), "completion");
683    }
684
685    #[test]
686    fn test_slo_target_ms() {
687        let config = SloConfig::default();
688        assert_eq!(OperationType::DefinitionLookup.slo_target_ms(&config), 50);
689        assert_eq!(OperationType::Completion.slo_target_ms(&config), 100);
690    }
691
692    #[test]
693    fn test_operation_result() {
694        assert!(OperationResult::Success.is_success());
695        assert!(!OperationResult::Failure("error".to_string()).is_success());
696    }
697
698    #[test]
699    fn test_percentile() {
700        let values = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
701        assert_eq!(nearest_rank_percentile(&values, 50), 5); // Median
702        assert_eq!(nearest_rank_percentile(&values, 95), 10); // P95
703    }
704}