saluki_components/transforms/mrf_gateway/
mod.rs

1//! MRF metrics gateway transform.
2
3use std::collections::HashSet;
4
5use agent_data_plane_config::{domains::multi_region_failover::MetricMirroring, Live};
6use async_trait::async_trait;
7use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
8use saluki_core::{
9    components::{
10        transforms::{Transform, TransformBuilder, TransformContext},
11        BuildContext,
12    },
13    data_model::event::{Event, EventType},
14    topology::{EventsBuffer, OutputDefinition},
15};
16use saluki_error::GenericError;
17use tokio::select;
18use tracing::{debug, error};
19
20/// Configuration for the MRF metrics gateway transform.
21///
22/// This transform sits between the enrichment stage and the MRF-specific encoder/forwarder, and owns all routing and
23/// filtering decisions for the MRF metrics pipeline:
24///
25/// - When multi-region failover is off, or metric mirroring is off, all events are dropped.
26/// - When both are on and no allowlist is configured, all events are forwarded.
27/// - When both are on and an allowlist is configured, only events whose metric name is in the allowlist are forwarded.
28pub struct MrfMetricsGatewayConfiguration {
29    enabled: bool,
30    metric_mirroring: Live<MetricMirroring>,
31}
32
33impl MrfMetricsGatewayConfiguration {
34    /// Creates a new `MrfMetricsGatewayConfiguration`.
35    ///
36    /// `enabled` is whether multi-region failover is on. It defaults to off in configuration and is read once, when
37    /// the topology is built, because the failover forwarder this transform feeds is wired at that point or not at all.
38    ///
39    /// `metric_mirroring` is whether metrics are mirrored to the failover region and which metric names are allowed to
40    /// be mirrored, defaulting to off and empty respectively. It is a live view: an operator can turn mirroring on, or
41    /// change the allowlist, without restarting, and the transform rebuilds its routing state from the new value. The
42    /// two settings arrive as one value, from one configuration version, so a rebuild cannot mix a fresh setting with a
43    /// stale one.
44    pub fn new(enabled: bool, metric_mirroring: Live<MetricMirroring>) -> Self {
45        Self {
46            enabled,
47            metric_mirroring,
48        }
49    }
50}
51
52/// Routing and filtering state for the MRF metrics gateway.
53#[derive(Debug)]
54enum GatewayMode {
55    /// Multi-region failover is off, or metric mirroring is off; drop all events.
56    Inactive,
57    /// Mirroring is on and no allowlist is configured; forward all events.
58    ForwardAll,
59    /// Mirroring is on and an allowlist is configured; forward only matching events.
60    FilteredForward { allowlist: HashSet<String> },
61}
62
63/// The current settings, and the routing state they imply.
64///
65/// This holds the mirroring settings by value rather than as a view. The transform's run loop awaits the view and hands
66/// the new value here, so the routing state is always rebuilt from one configuration version: the two settings the mode
67/// is derived from cannot be a fresh value and a stale one.
68struct Routing {
69    enabled: bool,
70    metric_mirroring: MetricMirroring,
71    mode: GatewayMode,
72}
73
74impl Routing {
75    fn new(enabled: bool, metric_mirroring: MetricMirroring) -> Self {
76        let mut routing = Self {
77            enabled,
78            metric_mirroring,
79            mode: GatewayMode::Inactive,
80        };
81        routing.rebuild_mode();
82
83        routing
84    }
85
86    fn set_metric_mirroring(&mut self, metric_mirroring: MetricMirroring) {
87        self.metric_mirroring = metric_mirroring;
88        self.rebuild_mode();
89        debug!(mode = ?self.mode, "MRF metrics gateway routing state rebuilt.");
90    }
91
92    fn rebuild_mode(&mut self) {
93        self.mode = if !(self.enabled && self.metric_mirroring.enabled) {
94            GatewayMode::Inactive
95        } else if self.metric_mirroring.allowlist.is_empty() {
96            GatewayMode::ForwardAll
97        } else {
98            GatewayMode::FilteredForward {
99                allowlist: self.metric_mirroring.allowlist.iter().cloned().collect(),
100            }
101        };
102    }
103
104    fn should_forward(&self, event: &Event) -> bool {
105        match &self.mode {
106            GatewayMode::Inactive => false,
107            GatewayMode::ForwardAll => true,
108            GatewayMode::FilteredForward { allowlist } => {
109                let Event::Metric(metric) = event else {
110                    return false;
111                };
112                allowlist.contains(metric.context().name().as_ref())
113            }
114        }
115    }
116
117    async fn process_event_batch(
118        &self, mut events: EventsBuffer, context: &mut TransformContext,
119    ) -> Result<(), GenericError> {
120        let input_count = events.len();
121        events.remove_if(|event| !self.should_forward(event));
122        let forwarded_count = events.len();
123        let dropped_count = input_count.saturating_sub(forwarded_count);
124
125        let sent_count = context.dispatcher().buffered()?.send_all(events).await?;
126        debug!(
127            forwarded_events = sent_count,
128            dropped_events = dropped_count,
129            "MRF metrics gateway processed event batch."
130        );
131
132        Ok(())
133    }
134}
135
136/// MRF metrics gateway transform.
137///
138/// Forwards the metrics permitted to reach the failover region and drops the rest, following the live mirroring
139/// settings it holds. It carries the view rather than a snapshot so that the run loop can await it.
140pub struct MrfMetricsGateway {
141    enabled: bool,
142    metric_mirroring: Live<MetricMirroring>,
143}
144
145impl MrfMetricsGateway {
146    fn new(config: &MrfMetricsGatewayConfiguration) -> Self {
147        Self {
148            enabled: config.enabled,
149            metric_mirroring: config.metric_mirroring.clone(),
150        }
151    }
152}
153
154#[async_trait]
155impl TransformBuilder for MrfMetricsGatewayConfiguration {
156    async fn build(&self, _context: BuildContext) -> Result<Box<dyn Transform + Send>, GenericError> {
157        Ok(Box::new(MrfMetricsGateway::new(self)))
158    }
159
160    fn input_event_type(&self) -> EventType {
161        EventType::Metric
162    }
163
164    fn outputs(&self) -> &[OutputDefinition<EventType>] {
165        static OUTPUTS: &[OutputDefinition<EventType>] = &[OutputDefinition::default_output(EventType::Metric)];
166        OUTPUTS
167    }
168}
169
170impl MemoryBounds for MrfMetricsGatewayConfiguration {
171    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
172        let allowlist = &self.metric_mirroring.allowlist;
173        builder
174            .minimum()
175            .with_single_value::<MrfMetricsGateway>("component struct")
176            .with_fixed_amount("hashset overhead", std::mem::size_of::<HashSet<String>>())
177            .with_fixed_amount(
178                // Three copies: the live view's snapshot, routing state, and hash set.
179                "allowlist strings",
180                allowlist
181                    .iter()
182                    .map(|name| name.len() + std::mem::size_of::<String>())
183                    .sum::<usize>()
184                    * 3,
185            )
186            .with_fixed_amount(
187                "hashset buckets",
188                allowlist.len() * std::mem::size_of::<Option<String>>() * 2,
189            );
190    }
191}
192
193#[async_trait]
194impl Transform for MrfMetricsGateway {
195    async fn run(self: Box<Self>, mut context: TransformContext) -> Result<(), GenericError> {
196        let mut health = context.take_health_handle();
197        // The view is moved out of the transform because `select!` awaits it while an arm body updates the routing
198        // state; keeping the two apart is what lets both happen without borrowing the same value.
199        let Self {
200            enabled,
201            mut metric_mirroring,
202        } = *self;
203        let mut routing = Routing::new(enabled, (*metric_mirroring).clone());
204
205        health.mark_ready();
206        debug!(mode = ?routing.mode, "MRF metrics gateway transform started.");
207
208        loop {
209            select! {
210                _ = health.live() => continue,
211                maybe_events = context.events().next() => match maybe_events {
212                    Some(events) => {
213                        if let Err(e) = routing.process_event_batch(events, &mut context).await {
214                            error!(error = %e, "MRF metrics gateway failed to process event batch.");
215                        }
216                    }
217                    None => {
218                        debug!("Event stream terminated, shutting down MRF metrics gateway transform.");
219                        break;
220                    }
221                },
222                new_metric_mirroring = metric_mirroring.changed() => {
223                    routing.set_metric_mirroring(new_metric_mirroring);
224                },
225            }
226        }
227
228        debug!("MRF metrics gateway transform stopped.");
229        Ok(())
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use std::{mem::size_of, sync::Arc};
236
237    use agent_data_plane_config::SalukiConfiguration;
238    use arc_swap::ArcSwap;
239    use saluki_core::{
240        accounting::ComponentRegistry,
241        data_model::event::{metric::Metric, Event},
242        support::SubsystemIdentifier,
243    };
244    use tokio::sync::watch;
245
246    use super::*;
247
248    /// A configuration the tests can replace, plus the live view projected out of it.
249    ///
250    /// This stands in for the configuration system: replacing the configuration and ticking the notification is what
251    /// the system does once it has translated an update.
252    struct LiveSource {
253        cell: Arc<ArcSwap<SalukiConfiguration>>,
254        tick: watch::Sender<()>,
255    }
256
257    impl LiveSource {
258        fn new(failover_metrics: bool, metric_allowlist: &[&str]) -> Self {
259            let (tick, _) = watch::channel(());
260
261            Self {
262                cell: Arc::new(ArcSwap::from_pointee(configuration(failover_metrics, metric_allowlist))),
263                tick,
264            }
265        }
266
267        fn metric_mirroring(&self) -> Live<MetricMirroring> {
268            Live::new_dynamic(Arc::clone(&self.cell), self.tick.subscribe(), |config| {
269                &config.domains.multi_region_failover.metric_mirroring
270            })
271        }
272
273        fn publish(&self, failover_metrics: bool, metric_allowlist: &[&str]) {
274            self.cell
275                .store(Arc::new(configuration(failover_metrics, metric_allowlist)));
276            self.tick.send(()).expect("a view still holds the receiver");
277        }
278    }
279
280    fn configuration(failover_metrics: bool, metric_allowlist: &[&str]) -> SalukiConfiguration {
281        let mut config = SalukiConfiguration::default();
282        let mirroring = &mut config.domains.multi_region_failover.metric_mirroring;
283        mirroring.enabled = failover_metrics;
284        mirroring.allowlist = metric_allowlist.iter().map(|name| (*name).to_string()).collect();
285
286        config
287    }
288
289    /// Builds the routing state the transform starts from, the way `run` does.
290    fn routing(enabled: bool, source: &LiveSource) -> Routing {
291        let config = MrfMetricsGatewayConfiguration::new(enabled, source.metric_mirroring());
292        let gateway = MrfMetricsGateway::new(&config);
293
294        Routing::new(gateway.enabled, (*gateway.metric_mirroring).clone())
295    }
296
297    fn counter(name: &'static str) -> Event {
298        Event::Metric(Metric::counter(name, 1.0))
299    }
300
301    /// Waits for `view` to process the published update, failing the test rather than hanging.
302    async fn await_update<T>(view: &mut Live<T>) -> T
303    where
304        T: Clone + PartialEq + 'static,
305    {
306        tokio::time::timeout(std::time::Duration::from_secs(2), view.changed())
307            .await
308            .expect("the published update should reach the view")
309    }
310
311    #[test]
312    fn memory_bounds_include_all_allowlist_copies() {
313        let allowlist = ["allowed.metric", "also.allowed"];
314        let source = LiveSource::new(true, &allowlist);
315        let config = MrfMetricsGatewayConfiguration::new(true, source.metric_mirroring());
316
317        let registry = ComponentRegistry::default();
318        config.specify_bounds(&mut registry.bounds_builder(&SubsystemIdentifier::from_dotted("test")));
319        let bounds = registry.as_bounds();
320
321        let allowlist_strings = allowlist
322            .iter()
323            .map(|name| name.len() + size_of::<String>())
324            .sum::<usize>();
325        let expected = size_of::<MrfMetricsGateway>()
326            + size_of::<HashSet<String>>()
327            + allowlist_strings * 3
328            + allowlist.len() * size_of::<Option<String>>() * 2;
329
330        assert_eq!(bounds.total_minimum_required_bytes(), expected);
331        assert_eq!(bounds.total_firm_limit_bytes(), expected);
332    }
333
334    #[tokio::test]
335    async fn failover_that_is_off_drops_everything() {
336        let source = LiveSource::new(true, &[]);
337        let routing = routing(false, &source);
338
339        assert!(!routing.should_forward(&counter("any.metric")));
340    }
341
342    #[tokio::test]
343    async fn mirroring_that_is_off_drops_everything() {
344        let source = LiveSource::new(false, &[]);
345        let routing = routing(true, &source);
346
347        assert!(!routing.should_forward(&counter("any.metric")));
348    }
349
350    #[tokio::test]
351    async fn an_empty_allowlist_forwards_everything() {
352        let source = LiveSource::new(true, &[]);
353        let routing = routing(true, &source);
354
355        assert!(routing.should_forward(&counter("any.metric")));
356    }
357
358    #[tokio::test]
359    async fn an_allowlist_forwards_only_matching_metrics() {
360        let source = LiveSource::new(true, &["allowed.metric"]);
361        let routing = routing(true, &source);
362
363        assert!(routing.should_forward(&counter("allowed.metric")));
364        assert!(!routing.should_forward(&counter("blocked.metric")));
365    }
366
367    #[tokio::test]
368    async fn a_mirroring_update_toggles_forwarding() {
369        let source = LiveSource::new(false, &[]);
370        let mut routing = routing(true, &source);
371        let mut view = source.metric_mirroring();
372
373        assert!(!routing.should_forward(&counter("any.metric")));
374
375        source.publish(true, &[]);
376        routing.set_metric_mirroring(await_update(&mut view).await);
377        assert!(routing.should_forward(&counter("any.metric")));
378
379        source.publish(false, &[]);
380        routing.set_metric_mirroring(await_update(&mut view).await);
381        assert!(!routing.should_forward(&counter("any.metric")));
382    }
383
384    #[tokio::test]
385    async fn an_allowlist_update_changes_filtering() {
386        let source = LiveSource::new(true, &[]);
387        let mut routing = routing(true, &source);
388        let mut view = source.metric_mirroring();
389
390        assert!(routing.should_forward(&counter("allowed.metric")));
391        assert!(routing.should_forward(&counter("also.allowed")));
392
393        source.publish(true, &["also.allowed"]);
394        routing.set_metric_mirroring(await_update(&mut view).await);
395
396        assert!(!routing.should_forward(&counter("allowed.metric")));
397        assert!(routing.should_forward(&counter("also.allowed")));
398        assert!(!routing.should_forward(&counter("blocked.metric")));
399    }
400}