1use std::sync::Arc;
8
9use crate::graph::CompiledGraph;
10use crate::traffic::cache::{CacheError, ResponseCache};
11
12#[derive(Clone)]
14pub struct CacheTarget {
15 pub id: String,
17 pub backend: Arc<dyn ResponseCache>,
18 pub backend_label: &'static str,
20 pub store: String,
22}
23
24#[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
34pub 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
55pub 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 #[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 #[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 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 #[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 #[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}