Skip to main content

featherbit/traffic/
purge.rs

1//! Finding and purging every backend that holds entries for a cache pair.
2//!
3//! Shared by all three triggers -- the Admin API, the MCP tool and the
4//! `phase: purge` node -- so they cannot drift on what "purge `products`"
5//! means.
6
7use std::sync::Arc;
8
9use crate::graph::CompiledGraph;
10use crate::traffic::cache::{CacheError, ResponseCache};
11
12/// One `proxy-cache` half's backend, as it described itself at compile time.
13#[derive(Clone)]
14pub struct CacheTarget {
15    /// The pair id this half belongs to.
16    pub id: String,
17    pub backend: Arc<dyn ResponseCache>,
18    /// `"local"` or `"redis"`, for the response and the metric label.
19    pub backend_label: &'static str,
20    /// The declared store's name for `redis`; empty for `local`.
21    pub store: String,
22}
23
24/// What one backend reported after a purge.
25#[derive(Debug, Clone, serde::Serialize)]
26pub struct PurgeOutcome {
27    #[serde(rename = "backend")]
28    pub backend_label: &'static str,
29    #[serde(skip_serializing_if = "String::is_empty")]
30    pub store: String,
31    pub removed: u64,
32}
33
34/// Every distinct backend holding entries for `id`, across all compiled graphs.
35///
36/// Deduplicated by `(backend_label, store)`: every `policy: local` half in the
37/// process shares one `LocalResponseCache`, and two policies can point at the
38/// same redis store. Purging a shared backend once per half would repeat the
39/// work and report a count that means nothing.
40pub fn collect_targets(graphs: &[Arc<CompiledGraph>], id: &str) -> Vec<CacheTarget> {
41    let mut out: Vec<CacheTarget> = Vec::new();
42    for g in graphs {
43        for t in g.cache_targets().iter().filter(|t| t.id == id) {
44            let dup = out
45                .iter()
46                .any(|o| o.backend_label == t.backend_label && o.store == t.store);
47            if !dup {
48                out.push(t.clone());
49            }
50        }
51    }
52    out
53}
54
55/// Purges each target in turn.
56///
57/// On failure, returns what succeeded before it alongside the error, so the
58/// caller can report both -- a half-completed purge is worth knowing about.
59pub async fn purge_targets(
60    targets: &[CacheTarget],
61) -> Result<Vec<PurgeOutcome>, (Vec<PurgeOutcome>, CacheError)> {
62    let mut done = Vec::with_capacity(targets.len());
63    for t in targets {
64        match t.backend.purge(&t.id).await {
65            Ok(removed) => done.push(PurgeOutcome {
66                backend_label: t.backend_label,
67                store: t.store.clone(),
68                removed,
69            }),
70            Err(e) => return Err((done, e)),
71        }
72    }
73    Ok(done)
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::graph::compile_policy;
80    use crate::plugins::resources::PluginResources;
81
82    fn graph(json: serde_json::Value) -> Arc<CompiledGraph> {
83        let mut value = json;
84        if let serde_json::Value::Object(ref mut map) = value {
85            map.entry("name").or_insert_with(|| serde_json::json!("p"));
86        }
87        let policy = serde_json::from_value(value).unwrap();
88        Arc::new(compile_policy(&policy, PluginResources::empty()).unwrap())
89    }
90
91    fn local_pair_policy(cache_id: &str) -> serde_json::Value {
92        serde_json::json!({
93            "nodes": [
94                { "id": "listener", "type": "listener", "config": {} },
95                { "id": "look", "type": "proxy-cache",
96                  "config": { "phase": "lookup", "id": cache_id, "policy": "local" } },
97                { "id": "up", "type": "upstream",
98                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
99                { "id": "keep", "type": "proxy-cache",
100                  "config": { "phase": "store", "id": cache_id, "policy": "local" } },
101                { "id": "client", "type": "client", "config": {} }
102            ],
103            "edges": [
104                { "from": "listener.out", "to": "look.in" },
105                { "from": "look.success", "to": "up.in" },
106                { "from": "look.hit", "to": "client.in" },
107                { "from": "up.success", "to": "keep.in" },
108                { "from": "keep.success", "to": "client.in" },
109                { "from": "keep.hit", "to": "client.in" }
110            ]
111        })
112    }
113
114    /// Both halves of a local pair, and two policies with the same pair,
115    /// all share ONE LocalResponseCache -- so a purge must hit it once.
116    /// Without deduplication the shared cache would be purged per half, and
117    /// the removed count would be nonsense.
118    #[tokio::test]
119    async fn test_collect_targets_deduplicates_the_shared_local_cache() {
120        let a = graph(local_pair_policy("products"));
121        let b = graph(local_pair_policy("products"));
122        let targets = collect_targets(&[a, b], "products");
123        assert_eq!(
124            targets.len(),
125            1,
126            "two policies, four halves, one local cache"
127        );
128        assert_eq!(targets[0].backend_label, "local");
129    }
130
131    /// An id no pair uses yields nothing -- which the API turns into a 404
132    /// rather than a successful flush of nothing.
133    #[tokio::test]
134    async fn test_collect_targets_for_an_unknown_id_is_empty() {
135        let g = graph(local_pair_policy("products"));
136        assert!(collect_targets(&[g], "prodcuts").is_empty());
137    }
138
139    /// A backend that never answers -- every call fails. Stands in for an
140    /// outage so the partial-failure branch is testable without a live store.
141    struct BrokenCache;
142
143    #[async_trait::async_trait]
144    impl ResponseCache for BrokenCache {
145        async fn get(
146            &self,
147            _key: &str,
148        ) -> Result<Option<crate::traffic::CachedResponse>, CacheError> {
149            Err(CacheError("backend down".to_string()))
150        }
151        async fn put(
152            &self,
153            _key: &str,
154            _entry: &crate::traffic::CachedResponse,
155            _ttl: std::time::Duration,
156        ) -> Result<(), CacheError> {
157            Err(CacheError("backend down".to_string()))
158        }
159        async fn purge(&self, _id: &str) -> Result<u64, CacheError> {
160            Err(CacheError("backend down".to_string()))
161        }
162    }
163
164    /// A half-completed purge is worth knowing about: when a later target
165    /// fails, `purge_targets` must return what succeeded before it, not just
166    /// the error.
167    #[tokio::test]
168    async fn test_purge_targets_returns_what_succeeded_before_a_failure() {
169        let local = Arc::new(crate::traffic::LocalResponseCache::default());
170        local
171            .put(
172                "products\u{1}/x",
173                &crate::traffic::CachedResponse {
174                    status: 200,
175                    headers: Default::default(),
176                    body: bytes::Bytes::from_static(b"x"),
177                },
178                std::time::Duration::from_secs(60),
179            )
180            .await
181            .unwrap();
182
183        let targets = vec![
184            CacheTarget {
185                id: "products".to_string(),
186                backend: local.clone(),
187                backend_label: "local",
188                store: String::new(),
189            },
190            CacheTarget {
191                id: "products".to_string(),
192                backend: Arc::new(BrokenCache),
193                backend_label: "redis",
194                store: "s".to_string(),
195            },
196        ];
197
198        let Err((done, e)) = purge_targets(&targets).await else {
199            panic!("a failing second target must return Err, not Ok");
200        };
201
202        assert_eq!(done.len(), 1, "the first target's success must be reported");
203        assert_eq!(done[0].removed, 1);
204        assert!(e.to_string().contains("backend down"), "{e}");
205    }
206
207    /// The whole point: a purge through the collected target removes what
208    /// the pair cached.
209    #[tokio::test]
210    async fn test_purge_targets_removes_the_pairs_entries() {
211        let g = graph(local_pair_policy("products"));
212        let targets = collect_targets(&[g], "products");
213        let cache = targets[0].backend.clone();
214        cache
215            .put(
216                "products\u{1}/x",
217                &crate::traffic::CachedResponse {
218                    status: 200,
219                    headers: Default::default(),
220                    body: bytes::Bytes::from_static(b"x"),
221                },
222                std::time::Duration::from_secs(60),
223            )
224            .await
225            .unwrap();
226
227        let outcomes = purge_targets(&targets).await.unwrap();
228
229        assert_eq!(outcomes.len(), 1);
230        assert_eq!(outcomes[0].removed, 1);
231        assert!(cache.get("products\u{1}/x").await.unwrap().is_none());
232    }
233}