Skip to main content

mctx_core/
context.rs

1#[cfg(feature = "metrics")]
2use crate::metrics::ContextMetricsSnapshot;
3use crate::{MctxError, Publication, PublicationConfig, PublicationId, SendReport};
4use socket2::Socket;
5use std::net::UdpSocket;
6#[cfg(feature = "metrics")]
7use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
8#[cfg(feature = "metrics")]
9use std::time::SystemTime;
10
11#[cfg(feature = "metrics")]
12#[derive(Debug, Default)]
13struct ContextMetricsInner {
14    publications_added: AtomicU64,
15    publications_removed: AtomicU64,
16    total_send_calls: AtomicU64,
17    total_packets_sent: AtomicU64,
18    total_bytes_sent: AtomicU64,
19    total_send_errors: AtomicU64,
20}
21
22/// Small owner for a set of multicast publication sockets.
23#[derive(Debug)]
24pub struct Context {
25    publications: Vec<Publication>,
26    next_id: u64,
27    #[cfg(feature = "metrics")]
28    metrics: ContextMetricsInner,
29}
30
31impl Default for Context {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl Context {
38    #[cfg(feature = "metrics")]
39    fn record_send_success(&self, bytes_sent: usize) {
40        self.metrics.total_send_calls.fetch_add(1, Relaxed);
41        self.metrics.total_packets_sent.fetch_add(1, Relaxed);
42        self.metrics
43            .total_bytes_sent
44            .fetch_add(bytes_sent as u64, Relaxed);
45    }
46
47    #[cfg(feature = "metrics")]
48    fn record_send_error(&self) {
49        self.metrics.total_send_calls.fetch_add(1, Relaxed);
50        self.metrics.total_send_errors.fetch_add(1, Relaxed);
51    }
52
53    fn ensure_publication_config_is_unique(
54        &self,
55        config: &PublicationConfig,
56    ) -> Result<(), MctxError> {
57        if self
58            .publications
59            .iter()
60            .any(|publication| publication.config() == config)
61        {
62            return Err(MctxError::DuplicatePublication);
63        }
64
65        Ok(())
66    }
67
68    fn insert_publication(&mut self, publication: Publication) -> PublicationId {
69        let id = publication.id();
70        self.publications.push(publication);
71
72        #[cfg(feature = "metrics")]
73        self.metrics.publications_added.fetch_add(1, Relaxed);
74
75        id
76    }
77
78    fn finish_publication_removal(&mut self, index: usize) -> Publication {
79        let publication = self.publications.swap_remove(index);
80
81        #[cfg(feature = "metrics")]
82        self.metrics.publications_removed.fetch_add(1, Relaxed);
83
84        publication
85    }
86
87    /// Creates an empty multicast sender context.
88    pub fn new() -> Self {
89        Self {
90            publications: Vec::new(),
91            next_id: 1,
92            #[cfg(feature = "metrics")]
93            metrics: ContextMetricsInner::default(),
94        }
95    }
96
97    /// Returns the number of tracked publications.
98    pub fn publication_count(&self) -> usize {
99        self.publications.len()
100    }
101
102    /// Returns whether a publication ID exists in the context.
103    pub fn contains_publication(&self, id: PublicationId) -> bool {
104        self.publications
105            .iter()
106            .any(|publication| publication.id() == id)
107    }
108
109    /// Returns an immutable reference to one publication.
110    pub fn get_publication(&self, id: PublicationId) -> Option<&Publication> {
111        self.publications
112            .iter()
113            .find(|publication| publication.id() == id)
114    }
115
116    /// Returns a mutable reference to one publication.
117    pub fn get_publication_mut(&mut self, id: PublicationId) -> Option<&mut Publication> {
118        self.publications
119            .iter_mut()
120            .find(|publication| publication.id() == id)
121    }
122
123    /// Creates a new publication socket from configuration and stores it.
124    pub fn add_publication(
125        &mut self,
126        config: PublicationConfig,
127    ) -> Result<PublicationId, MctxError> {
128        self.ensure_publication_config_is_unique(&config)?;
129
130        let id = self.next_publication_id();
131        let publication = Publication::new(id, config)?;
132        Ok(self.insert_publication(publication))
133    }
134
135    /// Stores an existing socket as a publication after configuring it.
136    pub fn add_publication_with_socket(
137        &mut self,
138        config: PublicationConfig,
139        socket: Socket,
140    ) -> Result<PublicationId, MctxError> {
141        self.ensure_publication_config_is_unique(&config)?;
142
143        let id = self.next_publication_id();
144        let publication = Publication::new_with_socket(id, config, socket)?;
145        Ok(self.insert_publication(publication))
146    }
147
148    /// Stores an existing standard-library UDP socket as a publication after configuring it.
149    pub fn add_publication_with_udp_socket(
150        &mut self,
151        config: PublicationConfig,
152        socket: UdpSocket,
153    ) -> Result<PublicationId, MctxError> {
154        self.ensure_publication_config_is_unique(&config)?;
155
156        let id = self.next_publication_id();
157        let publication = Publication::new_with_udp_socket(id, config, socket)?;
158        Ok(self.insert_publication(publication))
159    }
160
161    /// Removes one publication and drops its socket.
162    pub fn remove_publication(&mut self, id: PublicationId) -> bool {
163        self.take_publication(id).is_some()
164    }
165
166    /// Extracts one publication from the context.
167    pub fn take_publication(&mut self, id: PublicationId) -> Option<Publication> {
168        let index = self
169            .publications
170            .iter()
171            .position(|publication| publication.id() == id)?;
172
173        Some(self.finish_publication_removal(index))
174    }
175
176    /// Returns all tracked publications.
177    pub fn publications(&self) -> &[Publication] {
178        &self.publications
179    }
180
181    /// Returns all tracked publications mutably.
182    pub fn publications_mut(&mut self) -> &mut [Publication] {
183        &mut self.publications
184    }
185
186    /// Sends one payload through the selected publication.
187    pub fn send(&self, id: PublicationId, payload: &[u8]) -> Result<SendReport, MctxError> {
188        let publication = self
189            .get_publication(id)
190            .ok_or(MctxError::PublicationNotFound)?;
191
192        match publication.send(payload) {
193            Ok(report) => {
194                #[cfg(feature = "metrics")]
195                self.record_send_success(report.bytes_sent);
196
197                Ok(report)
198            }
199            Err(error) => {
200                #[cfg(feature = "metrics")]
201                self.record_send_error();
202
203                Err(error)
204            }
205        }
206    }
207
208    /// Sends the same payload through every publication and pushes reports into `out`.
209    ///
210    /// If one publication fails, reports already written into `out` are preserved.
211    pub fn send_all(&self, payload: &[u8], out: &mut Vec<SendReport>) -> Result<usize, MctxError> {
212        let before = out.len();
213        out.reserve(self.publications.len());
214
215        for publication in &self.publications {
216            match publication.send(payload) {
217                Ok(report) => {
218                    #[cfg(feature = "metrics")]
219                    self.record_send_success(report.bytes_sent);
220
221                    out.push(report);
222                }
223                Err(error) => {
224                    #[cfg(feature = "metrics")]
225                    self.record_send_error();
226
227                    return Err(error);
228                }
229            }
230        }
231
232        Ok(out.len() - before)
233    }
234
235    /// Returns a snapshot of the context's current metrics.
236    ///
237    /// Counter fields such as `total_packets_sent` are cumulative for the
238    /// lifetime of the context for send activity issued through `Context`
239    /// methods. They are not recomputed from the currently active publications,
240    /// and they do not decrease when a publication is removed.
241    #[cfg(feature = "metrics")]
242    pub fn metrics_snapshot(&self) -> ContextMetricsSnapshot {
243        ContextMetricsSnapshot {
244            publications_added: self.metrics.publications_added.load(Relaxed),
245            publications_removed: self.metrics.publications_removed.load(Relaxed),
246            active_publications: self.publications.len(),
247            total_send_calls: self.metrics.total_send_calls.load(Relaxed),
248            total_packets_sent: self.metrics.total_packets_sent.load(Relaxed),
249            total_bytes_sent: self.metrics.total_bytes_sent.load(Relaxed),
250            total_send_errors: self.metrics.total_send_errors.load(Relaxed),
251            captured_at: SystemTime::now(),
252        }
253    }
254
255    fn next_publication_id(&mut self) -> PublicationId {
256        let id = PublicationId(self.next_id);
257        self.next_id += 1;
258        id
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    #[cfg(feature = "metrics")]
266    use crate::metrics::ContextMetricsSampler;
267    use crate::test_support::{TEST_GROUP, recv_payload, test_multicast_receiver};
268    use std::net::Ipv4Addr;
269
270    #[test]
271    fn context_send_reaches_a_local_receiver() {
272        let (receiver, port) = test_multicast_receiver();
273        let mut context = Context::new();
274        let config = PublicationConfig::new(TEST_GROUP, port);
275        let id = context.add_publication(config).unwrap();
276
277        let report = context.send(id, b"context hello").unwrap();
278        let payload = recv_payload(&receiver);
279
280        assert_eq!(report.bytes_sent, b"context hello".len());
281        assert_eq!(payload, b"context hello");
282    }
283
284    #[test]
285    fn duplicate_publications_are_rejected() {
286        let mut context = Context::new();
287        let config = PublicationConfig::new(Ipv4Addr::new(239, 1, 2, 3), 5000);
288
289        context.add_publication(config.clone()).unwrap();
290        let result = context.add_publication(config);
291
292        assert!(matches!(result, Err(MctxError::DuplicatePublication)));
293    }
294
295    #[cfg(feature = "metrics")]
296    #[test]
297    fn context_metrics_track_successful_sends() {
298        let (_receiver, port) = test_multicast_receiver();
299        let mut context = Context::new();
300        let id = context
301            .add_publication(PublicationConfig::new(TEST_GROUP, port))
302            .unwrap();
303        let mut sampler = ContextMetricsSampler::new(&context);
304
305        assert!(sampler.sample().is_none());
306        context.send(id, b"metrics").unwrap();
307
308        let snapshot = context.metrics_snapshot();
309        let delta = sampler.sample().unwrap();
310
311        assert_eq!(snapshot.publications_added, 1);
312        assert_eq!(snapshot.publications_removed, 0);
313        assert_eq!(snapshot.active_publications, 1);
314        assert_eq!(snapshot.total_send_calls, 1);
315        assert_eq!(snapshot.total_packets_sent, 1);
316        assert_eq!(snapshot.total_bytes_sent, b"metrics".len() as u64);
317        assert_eq!(snapshot.total_send_errors, 0);
318        assert_eq!(delta.publications_added, 0);
319        assert_eq!(delta.publications_removed, 0);
320        assert_eq!(delta.send_calls, 1);
321        assert_eq!(delta.packets_sent, 1);
322        assert_eq!(delta.bytes_sent, b"metrics".len() as u64);
323        assert_eq!(delta.send_errors, 0);
324    }
325
326    #[cfg(feature = "metrics")]
327    #[test]
328    fn context_metrics_totals_survive_publication_removal() {
329        let (_receiver, port) = test_multicast_receiver();
330        let mut context = Context::new();
331        let id = context
332            .add_publication(PublicationConfig::new(TEST_GROUP, port))
333            .unwrap();
334
335        context.send(id, b"lifetime").unwrap();
336        let before_removal = context.metrics_snapshot();
337        assert!(context.remove_publication(id));
338
339        let after_removal = context.metrics_snapshot();
340
341        assert_eq!(before_removal.total_packets_sent, 1);
342        assert_eq!(before_removal.total_bytes_sent, b"lifetime".len() as u64);
343        assert_eq!(after_removal.total_packets_sent, 1);
344        assert_eq!(after_removal.total_bytes_sent, b"lifetime".len() as u64);
345        assert_eq!(after_removal.active_publications, 0);
346        assert_eq!(after_removal.publications_removed, 1);
347    }
348
349    #[cfg(feature = "metrics")]
350    #[test]
351    fn context_is_sync_with_metrics_enabled() {
352        fn assert_sync<T: Sync>() {}
353
354        assert_sync::<Context>();
355    }
356}