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 [], ®istry).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 [], ®istry).unwrap());
82 /// let new = Arc::new(FilterPipeline::build(&mut [], ®istry).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 [], ®istry).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 [], ®istry).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 [], ®istry).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 [], ®istry).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 [], ®istry).unwrap());
195 map.insert((*name).to_owned(), pipeline);
196 }
197 ListenerPipelines::new(map)
198 }
199}