Skip to main content

featherbit/mcp/tools/
cache.rs

1//! `purge_cache` -- the Admin API's DELETE /api/cache/{id}, for agents.
2
3use schemars::JsonSchema;
4use serde::Deserialize;
5use serde_json::Value;
6
7use super::ToolError;
8use crate::state::SharedState;
9use crate::traffic::{collect_targets, purge_targets};
10
11#[derive(Debug, Deserialize, JsonSchema)]
12pub struct PurgeCacheArgs {
13    /// The `proxy-cache` pair id (the `id` config key shared by its halves).
14    pub id: String,
15    /// List the backends a purge would hit without deleting anything. Default false.
16    #[serde(default)]
17    pub dry_run: bool,
18}
19
20/// Purges every backend holding entries for `id`, or -- with `dry_run` --
21/// just lists them. `404` (`not_found`) when no compiled policy has a pair
22/// with that id, exactly like the Admin API's DELETE. On a backend failure,
23/// reports what was purged before it as a `cache_purge_failed` error.
24pub async fn purge_cache(state: &SharedState, a: PurgeCacheArgs) -> Result<Value, ToolError> {
25    let graphs: Vec<_> = state
26        .routes
27        .read()
28        .await
29        .iter()
30        .map(|(_, g)| g.clone())
31        .collect();
32    let targets = collect_targets(&graphs, &a.id);
33    if targets.is_empty() {
34        return Err(ToolError::not_found("proxy-cache pair", &a.id));
35    }
36
37    if a.dry_run {
38        let would: Vec<Value> = targets
39            .iter()
40            .map(|t| {
41                let mut v = serde_json::json!({ "backend": t.backend_label });
42                if !t.store.is_empty() {
43                    v["store"] = Value::String(t.store.clone());
44                }
45                v
46            })
47            .collect();
48        return Ok(serde_json::json!({ "id": a.id, "dry_run": true, "purged": would }));
49    }
50
51    match purge_targets(&targets).await {
52        Ok(purged) => Ok(serde_json::json!({ "id": a.id, "purged": purged })),
53        Err((purged, e)) => {
54            let mut err = ToolError::new(
55                "cache_purge_failed",
56                format!("purging pair '{}' failed on a backend", a.id),
57            );
58            err.errors = vec![e.to_string()];
59            err.hint = Some(format!(
60                "{} backend(s) were purged before the failure",
61                purged.len()
62            ));
63            Err(err)
64        }
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use std::sync::Arc;
71
72    use crate::mcp::tools::call;
73    use crate::mcp::tools::test_support::{obj, state};
74    use crate::state::SharedState;
75
76    const LOCAL_PAIR_GATEWAY: &str = r#"
77routes:
78  - name: products
79    match: { path: /products }
80    policy: products-policy
81policies:
82  - name: products-policy
83    nodes:
84      - { id: listener, type: listener, config: {} }
85      - { id: look, type: proxy-cache, config: { phase: lookup, id: products, policy: local } }
86      - { id: up, type: upstream, config: { targets: [{ host: h, port: 80 }] } }
87      - { id: keep, type: proxy-cache, config: { phase: store, id: products, policy: local } }
88      - { id: client, type: client, config: {} }
89    edges:
90      - { from: listener.out, to: look.in }
91      - { from: look.success, to: up.in }
92      - { from: look.hit, to: client.in }
93      - { from: up.success, to: keep.in }
94      - { from: keep.success, to: client.in }
95      - { from: keep.hit, to: client.in }
96"#;
97
98    async fn state_with_local_cache_pair() -> Arc<SharedState> {
99        state("{}", LOCAL_PAIR_GATEWAY)
100    }
101
102    /// The backend the compiled pair actually uses -- found the same way the
103    /// tool finds it, so the test seeds exactly where the purge will look.
104    async fn backend(s: &SharedState) -> Arc<dyn crate::traffic::ResponseCache> {
105        let graphs: Vec<_> = s
106            .routes
107            .read()
108            .await
109            .iter()
110            .map(|(_, g)| g.clone())
111            .collect();
112        crate::traffic::collect_targets(&graphs, "products")
113            .remove(0)
114            .backend
115    }
116
117    async fn seed(s: &SharedState, key: &str) {
118        let entry = crate::traffic::CachedResponse {
119            status: 200,
120            headers: Default::default(),
121            body: bytes::Bytes::from_static(b"x"),
122        };
123        backend(s)
124            .await
125            .put(key, &entry, std::time::Duration::from_secs(60))
126            .await
127            .unwrap();
128    }
129
130    async fn still_cached(s: &SharedState, key: &str) -> bool {
131        backend(s).await.get(key).await.unwrap().is_some()
132    }
133
134    /// dry_run shows what a purge would hit and removes nothing -- an agent
135    /// should be able to see the blast radius before committing to it.
136    #[tokio::test]
137    async fn purge_cache_dry_run_lists_targets_and_removes_nothing() {
138        let s = state_with_local_cache_pair().await;
139        seed(&s, "products\u{1}/x").await;
140
141        let v = call(
142            &s,
143            "purge_cache",
144            obj(serde_json::json!({ "id": "products", "dry_run": true })),
145        )
146        .await
147        .unwrap();
148
149        assert_eq!(v["purged"].as_array().unwrap().len(), 1);
150        assert!(
151            v["purged"][0].get("removed").is_none(),
152            "dry_run must not report a count: {v}"
153        );
154        assert!(
155            still_cached(&s, "products\u{1}/x").await,
156            "dry_run must not delete"
157        );
158    }
159
160    #[tokio::test]
161    async fn purge_cache_removes_and_counts() {
162        let s = state_with_local_cache_pair().await;
163        seed(&s, "products\u{1}/x").await;
164
165        let v = call(
166            &s,
167            "purge_cache",
168            obj(serde_json::json!({ "id": "products" })),
169        )
170        .await
171        .unwrap();
172
173        assert_eq!(v["purged"][0]["removed"], 1);
174        assert!(!still_cached(&s, "products\u{1}/x").await);
175    }
176
177    /// The same 404 the Admin API gives: a typo is not a successful flush.
178    #[tokio::test]
179    async fn purge_cache_unknown_id_is_not_found() {
180        let s = state_with_local_cache_pair().await;
181        let err = call(
182            &s,
183            "purge_cache",
184            obj(serde_json::json!({ "id": "prodcuts" })),
185        )
186        .await
187        .unwrap_err();
188        assert_eq!(err.code, "not_found");
189        assert!(err.message.contains("prodcuts"), "{}", err.message);
190    }
191}