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}