saluki_components/sources/dogstatsd/replay/
capture.rs

1use std::{
2    path::{Path, PathBuf},
3    sync::{Arc, Mutex},
4    time::Duration,
5};
6
7use saluki_env::WorkloadProvider;
8use saluki_error::{generic_error, GenericError};
9
10use super::writer::{CaptureRecord, CaptureTargetDir, TrafficCaptureWriter};
11
12const UNAVAILABLE_CAPTURE_CONTROL_ERROR: &str =
13    "DogStatsD capture control is unavailable because the source is not running.";
14
15/// Owns capture lifecycle for a running DogStatsD source instance.
16#[derive(Clone)]
17pub(crate) struct TrafficCapture {
18    inner: Arc<TrafficCaptureInner>,
19}
20
21struct TrafficCaptureInner {
22    writer: TrafficCaptureWriter,
23    default_capture_dir: PathBuf,
24}
25
26impl TrafficCapture {
27    /// Creates a new capture controller with the given default directory and queue depth.
28    #[cfg(test)]
29    pub(crate) fn new(default_capture_dir: PathBuf, queue_depth: usize) -> Self {
30        Self::with_workload_provider(default_capture_dir, queue_depth, None)
31    }
32
33    /// Creates a new capture controller with the given default directory, queue depth, and optional workload provider.
34    pub(crate) fn with_workload_provider(
35        default_capture_dir: PathBuf, queue_depth: usize,
36        workload_provider: Option<Arc<dyn WorkloadProvider + Send + Sync>>,
37    ) -> Self {
38        Self {
39            inner: Arc::new(TrafficCaptureInner {
40                writer: TrafficCaptureWriter::with_workload_provider(queue_depth, workload_provider),
41                default_capture_dir,
42            }),
43        }
44    }
45
46    /// Returns whether a capture session is currently active.
47    pub(crate) fn is_ongoing(&self) -> bool {
48        self.inner.writer.is_ongoing()
49    }
50
51    /// Starts a new capture session.
52    pub(crate) fn start_capture(
53        &self, requested_dir: Option<&Path>, duration: Duration, compressed: bool,
54    ) -> Result<PathBuf, GenericError> {
55        let target_dir = match requested_dir {
56            Some(path) => CaptureTargetDir::Explicit(path.to_path_buf()),
57            None => CaptureTargetDir::Implicit(self.inner.default_capture_dir.clone()),
58        };
59
60        self.inner.writer.start_capture(target_dir, duration, compressed)
61    }
62
63    /// Stops the current capture session, if one is running.
64    pub(crate) fn stop_capture(&self) {
65        self.inner.writer.stop_capture();
66    }
67
68    /// Enqueues a captured packet for persistence.
69    pub(crate) fn enqueue(&self, record: CaptureRecord) -> bool {
70        self.inner.writer.enqueue(record)
71    }
72}
73
74/// Shared control handle for starting and stopping DogStatsD traffic capture.
75///
76/// This handle is created before the source is built, then bound to the live capture runtime during source construction.
77/// That lets other parts of the process hold a stable handle without reaching into the source internals.
78#[derive(Clone, Default)]
79pub struct DogStatsDCaptureControl {
80    inner: Arc<Mutex<Option<TrafficCapture>>>,
81}
82
83impl DogStatsDCaptureControl {
84    /// Creates a new, unbound capture control handle.
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Binds the control handle to a running capture runtime.
90    pub(crate) fn bind(&self, capture: TrafficCapture) {
91        let mut state = self.inner.lock().expect("capture control mutex poisoned");
92        *state = Some(capture);
93    }
94
95    /// Returns whether the bound capture runtime currently has an active capture session.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if the DogStatsD source hasn't been built and bound to this control handle yet.
100    pub fn is_ongoing(&self) -> Result<bool, GenericError> {
101        Ok(self.bound_capture()?.is_ongoing())
102    }
103
104    /// Starts a new capture session on the bound DogStatsD source.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if the DogStatsD source hasn't been built yet, or if the underlying capture runtime rejects
109    /// the start request.
110    pub fn start_capture(
111        &self, requested_dir: Option<&Path>, duration: Duration, compressed: bool,
112    ) -> Result<PathBuf, GenericError> {
113        self.bound_capture()?.start_capture(requested_dir, duration, compressed)
114    }
115
116    /// Stops the current capture session on the bound DogStatsD source.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if the DogStatsD source hasn't been built yet.
121    pub fn stop_capture(&self) -> Result<(), GenericError> {
122        self.bound_capture()?.stop_capture();
123        Ok(())
124    }
125
126    fn bound_capture(&self) -> Result<TrafficCapture, GenericError> {
127        let state = self.inner.lock().expect("capture control mutex poisoned");
128        state
129            .clone()
130            .ok_or_else(|| generic_error!("{}", UNAVAILABLE_CAPTURE_CONTROL_ERROR))
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use std::{fs, time::Duration};
137
138    use super::super::test_support::{unique_dir, wait_until_inactive};
139    use super::{DogStatsDCaptureControl, TrafficCapture, UNAVAILABLE_CAPTURE_CONTROL_ERROR};
140
141    #[test]
142    fn control_requires_bound_runtime() {
143        let control = DogStatsDCaptureControl::new();
144
145        let error = control
146            .start_capture(None, Duration::from_millis(25), false)
147            .expect_err("unbound control should fail");
148
149        assert_eq!(error.to_string(), UNAVAILABLE_CAPTURE_CONTROL_ERROR);
150    }
151
152    #[test]
153    fn control_drives_bound_capture_runtime() {
154        let control = DogStatsDCaptureControl::new();
155        let target_dir = unique_dir("capture-control");
156        let capture = TrafficCapture::new(target_dir.clone(), 1);
157        control.bind(capture);
158
159        let capture_path = control
160            .start_capture(None, Duration::from_millis(250), false)
161            .expect("capture should start");
162
163        let error = control
164            .start_capture(None, Duration::from_millis(250), false)
165            .expect_err("second capture should fail");
166        assert!(error.to_string().contains("capture already in progress"));
167
168        control.stop_capture().expect("stop should succeed");
169        control.stop_capture().expect("second stop should be safe");
170        wait_until_inactive(|| control.is_ongoing().expect("control should be bound"));
171
172        assert!(capture_path.exists());
173
174        let _ = fs::remove_dir_all(target_dir);
175    }
176}