Skip to main content

praxis_protocol/
pipelines.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Hot-swappable pipeline storage for protocol adapters.
5//!
6//! [`ListenerPipelines`] lives in the protocol crate because it is
7//! the interface between protocol adapters (which invoke filter
8//! execution) and the filter engine (which owns [`FilterPipeline`]).
9//!
10//! Each pipeline is wrapped in [`ArcSwap`] for lock-free atomic
11//! replacement during hot reloads. In-flight requests hold an
12//! [`Arc`] guard to the old pipeline, so they drain safely while
13//! new requests pick up the replacement.
14//!
15//! [`FilterPipeline`]: praxis_filter::FilterPipeline
16//! [`ArcSwap`]: arc_swap::ArcSwap
17
18use std::{collections::HashMap, sync::Arc};
19
20use arc_swap::ArcSwap;
21use praxis_filter::FilterPipeline;
22
23// -----------------------------------------------------------------------------
24// ListenerPipelines
25// -----------------------------------------------------------------------------
26
27/// Maps listener names to their resolved [`FilterPipeline`]s.
28///
29/// Each pipeline is wrapped in [`ArcSwap`] so it can be atomically
30/// replaced at runtime without blocking in-flight requests.
31///
32/// ```
33/// use std::{collections::HashMap, sync::Arc};
34///
35/// use praxis_filter::{FilterPipeline, FilterRegistry};
36/// use praxis_protocol::ListenerPipelines;
37///
38/// let registry = FilterRegistry::with_builtins();
39/// let pipeline = Arc::new(FilterPipeline::build(&mut [], &registry).unwrap());
40///
41/// let mut map = HashMap::new();
42/// map.insert("web".to_owned(), pipeline);
43/// let pipelines = ListenerPipelines::new(map);
44///
45/// assert!(pipelines.get("web").is_some());
46/// assert!(pipelines.get("missing").is_none());
47/// ```
48///
49/// [`ArcSwap`]: arc_swap::ArcSwap
50pub struct ListenerPipelines {
51    /// Maps listener names to their swappable filter pipelines.
52    pipelines: HashMap<String, Arc<ArcSwap<FilterPipeline>>>,
53}
54
55impl ListenerPipelines {
56    /// Create from a map of listener name to pipeline.
57    pub fn new(pipelines: HashMap<String, Arc<FilterPipeline>>) -> Self {
58        let swappable = pipelines
59            .into_iter()
60            .map(|(name, p)| (name, Arc::new(ArcSwap::from(p))))
61            .collect();
62        Self { pipelines: swappable }
63    }
64
65    /// Get the swappable pipeline for a listener by name.
66    pub fn get(&self, listener_name: &str) -> Option<&Arc<ArcSwap<FilterPipeline>>> {
67        self.pipelines.get(listener_name)
68    }
69
70    /// Atomically replace the pipeline for a listener.
71    ///
72    /// No-op if the listener name is not present.
73    ///
74    /// ```
75    /// use std::{collections::HashMap, sync::Arc};
76    ///
77    /// use praxis_filter::{FilterPipeline, FilterRegistry};
78    /// use praxis_protocol::ListenerPipelines;
79    ///
80    /// let registry = FilterRegistry::with_builtins();
81    /// let old = Arc::new(FilterPipeline::build(&mut [], &registry).unwrap());
82    /// let new = Arc::new(FilterPipeline::build(&mut [], &registry).unwrap());
83    ///
84    /// let mut map = HashMap::new();
85    /// map.insert("web".to_owned(), old);
86    /// let pipelines = ListenerPipelines::new(map);
87    ///
88    /// pipelines.swap("web", new);
89    /// pipelines.swap(
90    ///     "nonexistent",
91    ///     Arc::new(FilterPipeline::build(&mut [], &registry).unwrap()),
92    /// );
93    /// ```
94    pub fn swap(&self, listener_name: &str, new_pipeline: Arc<FilterPipeline>) {
95        if let Some(slot) = self.pipelines.get(listener_name) {
96            slot.store(new_pipeline);
97        }
98    }
99
100    /// Returns an iterator over listener names.
101    pub fn listener_names(&self) -> impl Iterator<Item = &str> {
102        self.pipelines.keys().map(String::as_str)
103    }
104}
105
106// -----------------------------------------------------------------------------
107// Tests
108// -----------------------------------------------------------------------------
109
110#[cfg(test)]
111#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
112#[allow(
113    clippy::unwrap_used,
114    clippy::expect_used,
115    clippy::indexing_slicing,
116    clippy::too_many_lines,
117    reason = "tests"
118)]
119mod tests {
120    use std::{collections::HashMap, sync::Arc};
121
122    use arc_swap::ArcSwap;
123    use praxis_filter::{FilterPipeline, FilterRegistry};
124
125    use super::*;
126
127    #[test]
128    fn get_returns_pipeline() {
129        let pipelines = make_pipelines(&["web"]);
130        assert!(pipelines.get("web").is_some(), "should find 'web' pipeline");
131    }
132
133    #[test]
134    fn get_returns_none_for_missing() {
135        let pipelines = make_pipelines(&["web"]);
136        assert!(pipelines.get("missing").is_none(), "should return None for missing");
137    }
138
139    #[test]
140    fn swap_replaces_pipeline_pointer() {
141        let pipelines = make_pipelines(&["web"]);
142        let old_ptr = Arc::as_ptr(&pipelines.get("web").unwrap().load());
143
144        let registry = FilterRegistry::with_builtins();
145        let new_pipeline = Arc::new(FilterPipeline::build(&mut [], &registry).unwrap());
146        pipelines.swap("web", Arc::clone(&new_pipeline));
147
148        let new_ptr = Arc::as_ptr(&pipelines.get("web").unwrap().load());
149        assert_ne!(old_ptr, new_ptr, "swap should replace the pipeline pointer");
150    }
151
152    #[test]
153    fn old_guard_remains_valid_after_swap() {
154        let pipelines = make_pipelines(&["web"]);
155        let old_guard = pipelines.get("web").unwrap().load();
156        let old_ptr = Arc::as_ptr(&old_guard);
157
158        let registry = FilterRegistry::with_builtins();
159        let new_pipeline = Arc::new(FilterPipeline::build(&mut [], &registry).unwrap());
160        pipelines.swap("web", new_pipeline);
161
162        let still_old_ptr = Arc::as_ptr(&old_guard);
163        assert_eq!(
164            old_ptr, still_old_ptr,
165            "old guard should still point to the original pipeline"
166        );
167    }
168
169    #[test]
170    fn swap_nonexistent_is_noop() {
171        let pipelines = make_pipelines(&["web"]);
172        let registry = FilterRegistry::with_builtins();
173        let new_pipeline = Arc::new(FilterPipeline::build(&mut [], &registry).unwrap());
174        pipelines.swap("nonexistent", new_pipeline);
175        assert!(pipelines.get("web").is_some(), "existing pipeline should be unaffected");
176    }
177
178    #[test]
179    fn get_returns_arcswap_reference() {
180        let pipelines = make_pipelines(&["web"]);
181        let slot: &Arc<ArcSwap<FilterPipeline>> = pipelines.get("web").unwrap();
182        let _loaded: arc_swap::Guard<Arc<FilterPipeline>> = slot.load();
183    }
184
185    // -------------------------------------------------------------------------
186    // Test Utilities
187    // -------------------------------------------------------------------------
188
189    /// Build [`ListenerPipelines`] with empty pipelines for the given names.
190    fn make_pipelines(names: &[&str]) -> ListenerPipelines {
191        let registry = FilterRegistry::with_builtins();
192        let mut map = HashMap::new();
193        for name in names {
194            let pipeline = Arc::new(FilterPipeline::build(&mut [], &registry).unwrap());
195            map.insert((*name).to_owned(), pipeline);
196        }
197        ListenerPipelines::new(map)
198    }
199}