Skip to main content

oximedia_workflow/
sla.rs

1//! Service Level Agreement (SLA) tracking for workflow orchestration.
2//!
3//! Provides SLA target definitions, violation detection, and reporting
4//! for workflow processing times, queue depth, and availability.
5
6#![allow(dead_code)]
7
8/// SLA target configuration.
9#[derive(Debug, Clone)]
10pub struct SlaTarget {
11    /// Maximum acceptable processing time in seconds.
12    pub max_processing_secs: u64,
13    /// Maximum acceptable queue depth (number of pending workflows).
14    pub max_queue_depth: u32,
15    /// Required availability percentage (e.g. 99.9).
16    pub availability_pct: f32,
17    /// Percentage of target at which to start sending notifications.
18    /// E.g. 0.8 means notify when processing time hits 80% of max.
19    pub notification_threshold_pct: f32,
20}
21
22impl SlaTarget {
23    /// Create a new SLA target.
24    #[must_use]
25    pub fn new(
26        max_processing_secs: u64,
27        max_queue_depth: u32,
28        availability_pct: f32,
29        notification_threshold_pct: f32,
30    ) -> Self {
31        Self {
32            max_processing_secs,
33            max_queue_depth,
34            availability_pct,
35            notification_threshold_pct,
36        }
37    }
38}
39
40impl Default for SlaTarget {
41    fn default() -> Self {
42        Self {
43            max_processing_secs: 3600,
44            max_queue_depth: 100,
45            availability_pct: 99.0,
46            notification_threshold_pct: 0.8,
47        }
48    }
49}
50
51/// Type of SLA violation.
52#[derive(Debug, Clone, PartialEq)]
53pub enum ViolationType {
54    /// Workflow took longer than the maximum allowed processing time.
55    ProcessingTimeout,
56    /// Queue depth exceeded the maximum allowed depth.
57    QueueDepthExceeded,
58    /// System availability fell below the required percentage.
59    AvailabilityBreach,
60}
61
62/// A recorded SLA violation.
63#[derive(Debug, Clone)]
64pub struct SlaViolation {
65    /// Workflow identifier associated with the violation.
66    pub workflow_id: String,
67    /// Type of violation.
68    pub violation_type: ViolationType,
69    /// Expected value (SLA limit).
70    pub expected: f64,
71    /// Actual measured value.
72    pub actual: f64,
73    /// Timestamp of the violation in milliseconds since epoch.
74    pub timestamp_ms: u64,
75}
76
77impl SlaViolation {
78    /// Create a new SLA violation record.
79    #[must_use]
80    pub fn new(
81        workflow_id: impl Into<String>,
82        violation_type: ViolationType,
83        expected: f64,
84        actual: f64,
85        timestamp_ms: u64,
86    ) -> Self {
87        Self {
88            workflow_id: workflow_id.into(),
89            violation_type,
90            expected,
91            actual,
92            timestamp_ms,
93        }
94    }
95}
96
97/// Completion record for a workflow.
98#[derive(Debug, Clone)]
99struct CompletionRecord {
100    workflow_id: String,
101    processing_secs: u64,
102    timestamp_ms: u64,
103    success: bool,
104}
105
106/// SLA tracker that records workflow completions and detects violations.
107#[derive(Debug)]
108pub struct SlaTracker {
109    target: SlaTarget,
110    completions: Vec<CompletionRecord>,
111    violations: Vec<SlaViolation>,
112    /// Total uptime periods (in ms)
113    total_uptime_ms: u64,
114    /// Total downtime periods (in ms)
115    total_downtime_ms: u64,
116    /// Epoch start of tracking window (ms)
117    tracking_start_ms: u64,
118}
119
120impl SlaTracker {
121    /// Create a new SLA tracker with the given target.
122    #[must_use]
123    pub fn new(target: SlaTarget) -> Self {
124        Self {
125            target,
126            completions: Vec::new(),
127            violations: Vec::new(),
128            total_uptime_ms: 0,
129            total_downtime_ms: 0,
130            tracking_start_ms: current_time_ms(),
131        }
132    }
133
134    /// Create an SLA tracker with default targets.
135    #[must_use]
136    pub fn with_default_target() -> Self {
137        Self::new(SlaTarget::default())
138    }
139
140    /// Record a workflow completion.
141    pub fn record_completion(&mut self, workflow_id: &str, processing_secs: u64) {
142        let now = current_time_ms();
143
144        self.completions.push(CompletionRecord {
145            workflow_id: workflow_id.to_string(),
146            processing_secs,
147            timestamp_ms: now,
148            success: true,
149        });
150
151        // Check for processing timeout violation
152        if processing_secs > self.target.max_processing_secs {
153            self.violations.push(SlaViolation::new(
154                workflow_id,
155                ViolationType::ProcessingTimeout,
156                self.target.max_processing_secs as f64,
157                processing_secs as f64,
158                now,
159            ));
160        }
161    }
162
163    /// Check if current queue depth exceeds SLA, returning a violation if so.
164    #[must_use]
165    pub fn check_queue_depth(&mut self, depth: u32) -> Option<SlaViolation> {
166        if depth > self.target.max_queue_depth {
167            let violation = SlaViolation::new(
168                "system",
169                ViolationType::QueueDepthExceeded,
170                f64::from(self.target.max_queue_depth),
171                f64::from(depth),
172                current_time_ms(),
173            );
174            self.violations.push(violation.clone());
175            Some(violation)
176        } else {
177            None
178        }
179    }
180
181    /// Get violations within a time window (last `window_ms` milliseconds).
182    #[must_use]
183    pub fn violations_in_window(&self, window_ms: u64) -> Vec<&SlaViolation> {
184        let now = current_time_ms();
185        let cutoff = now.saturating_sub(window_ms);
186        self.violations
187            .iter()
188            .filter(|v| v.timestamp_ms >= cutoff)
189            .collect()
190    }
191
192    /// Get all violations.
193    #[must_use]
194    pub fn all_violations(&self) -> &[SlaViolation] {
195        &self.violations
196    }
197
198    /// Record downtime (system unavailable for `duration_ms` milliseconds).
199    pub fn record_downtime(&mut self, duration_ms: u64) {
200        self.total_downtime_ms += duration_ms;
201
202        let now = current_time_ms();
203        let availability = self.current_availability();
204        if availability < self.target.availability_pct {
205            self.violations.push(SlaViolation::new(
206                "system",
207                ViolationType::AvailabilityBreach,
208                f64::from(self.target.availability_pct),
209                f64::from(availability),
210                now,
211            ));
212        }
213    }
214
215    /// Record uptime (system available for `duration_ms` milliseconds).
216    pub fn record_uptime(&mut self, duration_ms: u64) {
217        self.total_uptime_ms += duration_ms;
218    }
219
220    /// Calculate current availability as a percentage.
221    #[must_use]
222    pub fn current_availability(&self) -> f32 {
223        let total = self.total_uptime_ms + self.total_downtime_ms;
224        if total == 0 {
225            return 100.0;
226        }
227        (self.total_uptime_ms as f32 / total as f32) * 100.0
228    }
229
230    /// Get all processing times recorded.
231    #[must_use]
232    pub fn processing_times(&self) -> Vec<u64> {
233        self.completions.iter().map(|c| c.processing_secs).collect()
234    }
235
236    /// Get processing time at a given percentile (0.0 to 1.0).
237    #[must_use]
238    pub fn percentile_processing_secs(&self, percentile: f64) -> f64 {
239        let mut times: Vec<u64> = self.processing_times();
240        if times.is_empty() {
241            return 0.0;
242        }
243        times.sort_unstable();
244        let idx = ((times.len() as f64 * percentile).ceil() as usize).min(times.len()) - 1;
245        times[idx] as f64
246    }
247
248    /// Get the SLA target.
249    #[must_use]
250    pub fn target(&self) -> &SlaTarget {
251        &self.target
252    }
253
254    /// Total number of completions recorded.
255    #[must_use]
256    pub fn total_completions(&self) -> u64 {
257        self.completions.len() as u64
258    }
259}
260
261/// SLA report for a time window.
262#[derive(Debug, Clone)]
263pub struct SlaReport {
264    /// Time window in milliseconds covered by this report.
265    pub period_ms: u64,
266    /// Total number of workflows processed.
267    pub total_workflows: u64,
268    /// Number of SLA violations.
269    pub violations: u64,
270    /// Average processing time in seconds.
271    pub avg_processing_secs: f64,
272    /// 95th percentile processing time in seconds.
273    pub p95_processing_secs: f64,
274    /// Availability percentage.
275    pub availability_pct: f32,
276}
277
278impl SlaReport {
279    /// Generate an SLA report for the given tracker over the last `window_ms`.
280    #[must_use]
281    pub fn generate(tracker: &SlaTracker, window_ms: u64) -> Self {
282        let now = current_time_ms();
283        let cutoff = now.saturating_sub(window_ms);
284
285        let window_completions: Vec<&CompletionRecord> = tracker
286            .completions
287            .iter()
288            .filter(|c| c.timestamp_ms >= cutoff)
289            .collect();
290
291        let total_workflows = window_completions.len() as u64;
292
293        let avg_processing_secs = if total_workflows > 0 {
294            window_completions
295                .iter()
296                .map(|c| c.processing_secs as f64)
297                .sum::<f64>()
298                / total_workflows as f64
299        } else {
300            0.0
301        };
302
303        // Calculate P95 from window completions
304        let p95_processing_secs = if window_completions.is_empty() {
305            0.0
306        } else {
307            let mut times: Vec<u64> = window_completions
308                .iter()
309                .map(|c| c.processing_secs)
310                .collect();
311            times.sort_unstable();
312            let idx = ((times.len() as f64 * 0.95).ceil() as usize).min(times.len()) - 1;
313            times[idx] as f64
314        };
315
316        let violations = tracker
317            .violations
318            .iter()
319            .filter(|v| v.timestamp_ms >= cutoff)
320            .count() as u64;
321
322        SlaReport {
323            period_ms: window_ms,
324            total_workflows,
325            violations,
326            avg_processing_secs,
327            p95_processing_secs,
328            availability_pct: tracker.current_availability(),
329        }
330    }
331
332    /// Whether the SLA was met (no violations).
333    #[must_use]
334    pub fn is_compliant(&self) -> bool {
335        self.violations == 0
336    }
337}
338
339/// Get current time in milliseconds (monotonic-safe approximation via std).
340fn current_time_ms() -> u64 {
341    use std::time::{SystemTime, UNIX_EPOCH};
342    SystemTime::now()
343        .duration_since(UNIX_EPOCH)
344        .map(|d| d.as_millis() as u64)
345        .unwrap_or(0)
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    fn make_tracker() -> SlaTracker {
353        SlaTracker::new(SlaTarget::new(60, 10, 99.0, 0.8))
354    }
355
356    #[test]
357    fn test_sla_target_defaults() {
358        let target = SlaTarget::default();
359        assert_eq!(target.max_processing_secs, 3600);
360        assert_eq!(target.max_queue_depth, 100);
361        assert!((target.availability_pct - 99.0).abs() < f32::EPSILON);
362    }
363
364    #[test]
365    fn test_record_completion_no_violation() {
366        let mut tracker = make_tracker();
367        tracker.record_completion("wf-1", 30); // under 60s limit
368        assert_eq!(tracker.all_violations().len(), 0);
369        assert_eq!(tracker.total_completions(), 1);
370    }
371
372    #[test]
373    fn test_record_completion_with_violation() {
374        let mut tracker = make_tracker();
375        tracker.record_completion("wf-1", 120); // over 60s limit
376        let violations = tracker.all_violations();
377        assert_eq!(violations.len(), 1);
378        assert_eq!(
379            violations[0].violation_type,
380            ViolationType::ProcessingTimeout
381        );
382        assert_eq!(violations[0].expected, 60.0);
383        assert_eq!(violations[0].actual, 120.0);
384    }
385
386    #[test]
387    fn test_check_queue_depth_no_violation() {
388        let mut tracker = make_tracker();
389        let result = tracker.check_queue_depth(5); // under 10 limit
390        assert!(result.is_none());
391        assert_eq!(tracker.all_violations().len(), 0);
392    }
393
394    #[test]
395    fn test_check_queue_depth_violation() {
396        let mut tracker = make_tracker();
397        let result = tracker.check_queue_depth(15); // over 10 limit
398        assert!(result.is_some());
399        let violation = result.expect("should succeed in test");
400        assert_eq!(violation.violation_type, ViolationType::QueueDepthExceeded);
401        assert_eq!(violation.expected, 10.0);
402        assert_eq!(violation.actual, 15.0);
403    }
404
405    #[test]
406    fn test_violations_in_window() {
407        let mut tracker = make_tracker();
408        tracker.record_completion("wf-1", 120);
409        tracker.record_completion("wf-2", 200);
410
411        let violations = tracker.violations_in_window(60_000); // last 60 seconds
412        assert_eq!(violations.len(), 2);
413    }
414
415    #[test]
416    fn test_violations_in_empty_window() {
417        let tracker = make_tracker();
418        let violations = tracker.violations_in_window(0); // window of 0 ms
419        assert_eq!(violations.len(), 0);
420    }
421
422    #[test]
423    fn test_current_availability_no_data() {
424        let tracker = make_tracker();
425        assert!((tracker.current_availability() - 100.0).abs() < f32::EPSILON);
426    }
427
428    #[test]
429    fn test_current_availability_with_downtime() {
430        let mut tracker = make_tracker();
431        tracker.record_uptime(90_000);
432        tracker.record_downtime(10_000);
433        let availability = tracker.current_availability();
434        assert!((availability - 90.0).abs() < 0.01);
435    }
436
437    #[test]
438    fn test_percentile_processing() {
439        let mut tracker = make_tracker();
440        for i in 1..=100u64 {
441            tracker.record_completion(&format!("wf-{}", i), i);
442        }
443        let p95 = tracker.percentile_processing_secs(0.95);
444        assert!(p95 >= 90.0 && p95 <= 100.0);
445    }
446
447    #[test]
448    fn test_sla_report_generation() {
449        let mut tracker = make_tracker();
450        tracker.record_completion("wf-1", 30);
451        tracker.record_completion("wf-2", 90); // violation
452        tracker.record_uptime(3_600_000);
453
454        let report = SlaReport::generate(&tracker, 60_000);
455        assert_eq!(report.total_workflows, 2);
456        assert_eq!(report.violations, 1);
457        assert!(report.avg_processing_secs > 0.0);
458        assert_eq!(report.period_ms, 60_000);
459    }
460
461    #[test]
462    fn test_sla_report_compliant() {
463        let mut tracker = make_tracker();
464        tracker.record_completion("wf-1", 30);
465        tracker.record_completion("wf-2", 45);
466
467        let report = SlaReport::generate(&tracker, 60_000);
468        assert!(report.is_compliant());
469    }
470
471    #[test]
472    fn test_sla_report_empty() {
473        let tracker = make_tracker();
474        let report = SlaReport::generate(&tracker, 60_000);
475        assert_eq!(report.total_workflows, 0);
476        assert_eq!(report.violations, 0);
477        assert_eq!(report.avg_processing_secs, 0.0);
478        assert!(report.is_compliant());
479    }
480}