Skip to content

Commit 76eb047

Browse files
committed
Add indexed cache wildcard purge
1 parent 320e00b commit 76eb047

5 files changed

Lines changed: 510 additions & 5 deletions

File tree

ROADMAP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -649,9 +649,9 @@ without parsing text fixtures for every module.
649649
otherwise personalized-content safe;
650650
- bounded cache-key indexing for memory and disk tiers is implemented as
651651
the foundation for broader invalidation. Indexed vhost/route scope
652-
purge and path-prefix purge are implemented through the admin API.
653-
Wildcard purge and a background purger for complete disk cleanup are
654-
still planned;
652+
purge, path-prefix purge, and wildcard path-pattern purge are
653+
implemented through the admin API. A background purger for complete disk
654+
cleanup is still planned;
655655
- startup cache-index loading that is incremental and bounded so large
656656
disk caches do not block the gateway;
657657
- byte-range/slice caching for large immutable files, with explicit

docs/cache-backends.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,15 @@ internal cache implementation.
178178
or route whose normalized request path starts with `path_prefix` / `prefix` /
179179
`x-fluxheim-cache-path-prefix`. Prefix purge requires a non-root prefix such
180180
as `/assets/`; `/` is rejected so complete cache clears stay explicit through
181-
scope purge. Both indexed endpoints accept `limit` /
181+
scope purge. `POST /_fluxheim/cache/purge-wildcard` invalidates indexed
182+
entries by absolute path pattern using `*`, for example `/assets/*.png`.
183+
Whole-cache patterns such as `/*` are rejected for the same reason. Indexed
184+
endpoints accept `limit` /
182185
`x-fluxheim-cache-limit`, default to a bounded batch size, and return
183186
`truncated = true` when more indexed entries remain for the requested scope.
184187
The index is bounded in memory, mirrors disk-tier writes, and is designed for
185188
operational invalidation rather than as a complete filesystem scan.
186-
Wildcard purge and a background disk purger remain planned.
189+
A background disk purger remains planned.
187190

188191
Example admin cache invalidation requests:
189192

@@ -193,6 +196,9 @@ curl -X POST -H "Authorization: Bearer $FLUXHEIM_ADMIN_TOKEN" \
193196

194197
curl -X POST -H "Authorization: Bearer $FLUXHEIM_ADMIN_TOKEN" \
195198
"http://127.0.0.1:9090/_fluxheim/cache/purge-prefix?vhost=repoheim.eu&path_prefix=/assets/&limit=500"
199+
200+
curl -X POST -H "Authorization: Bearer $FLUXHEIM_ADMIN_TOKEN" \
201+
"http://127.0.0.1:9090/_fluxheim/cache/purge-wildcard?vhost=repoheim.eu&pattern=/assets/*.png&limit=500"
196202
```
197203

198204
Example: `cache.memory.max_size_bytes = "1GiB"` with

src/admin.rs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,18 @@ impl AdminApp {
264264
header_value(headers, "x-fluxheim-cache-limit")
265265
.or_else(|| query_param(query, "limit")),
266266
),
267+
("POST", "/_fluxheim/cache/purge-wildcard") => self.cache_purge_wildcard_response(
268+
header_value(headers, "x-fluxheim-cache-vhost")
269+
.or_else(|| query_param(query, "vhost")),
270+
header_value(headers, "x-fluxheim-cache-route")
271+
.or_else(|| query_param(query, "route")),
272+
header_value(headers, "x-fluxheim-cache-path-pattern")
273+
.or_else(|| query_param(query, "path_pattern"))
274+
.or_else(|| query_param(query, "pattern"))
275+
.or_else(|| query_param(query, "wildcard")),
276+
header_value(headers, "x-fluxheim-cache-limit")
277+
.or_else(|| query_param(query, "limit")),
278+
),
267279
("POST", "/_fluxheim/snapshot") => {
268280
self.create_snapshot_response(header_value(headers, "x-fluxheim-message"))
269281
}
@@ -288,6 +300,7 @@ impl AdminApp {
288300
| "/_fluxheim/cache/purge-bulk"
289301
| "/_fluxheim/cache/purge-index"
290302
| "/_fluxheim/cache/purge-prefix"
303+
| "/_fluxheim/cache/purge-wildcard"
291304
| "/_fluxheim/snapshot"
292305
| "/_fluxheim/rollback"
293306
| "/_fluxheim/reload",
@@ -735,6 +748,65 @@ impl AdminApp {
735748
}
736749
}
737750

751+
#[cfg(feature = "cache")]
752+
fn cache_purge_wildcard_response(
753+
&self,
754+
vhost: Option<&str>,
755+
route: Option<&str>,
756+
path_pattern: Option<&str>,
757+
limit: Option<&str>,
758+
) -> AdminResponse {
759+
let Some(vhost) = vhost.map(str::trim).filter(|vhost| !vhost.is_empty()) else {
760+
return error_response(
761+
StatusCode::BAD_REQUEST,
762+
"cache wildcard purge vhost is required",
763+
);
764+
};
765+
let path_pattern = match validated_cache_purge_path_pattern(path_pattern) {
766+
Ok(path_pattern) => path_pattern,
767+
Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
768+
};
769+
let limit = match validated_cache_indexed_purge_limit(limit) {
770+
Ok(limit) => limit,
771+
Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
772+
};
773+
774+
match self.proxy.purge_indexed_image_cache_path_pattern(
775+
crate::proxy::CacheIndexedPathPatternPurgeRequest {
776+
vhost,
777+
route: route.filter(|route| !route.trim().is_empty()),
778+
path_pattern,
779+
limit,
780+
},
781+
) {
782+
Ok(result) => {
783+
let body = format!(
784+
r#"{{"status":"ok","matched":{},"purged":{},"truncated":{},"vhost":"{}","route":{},"path_pattern":"{}","memory_matched":{},"memory_purged":{},"memory_truncated":{},"disk_matched":{},"disk_purged":{},"disk_truncated":{}}}"#,
785+
result.matched(),
786+
result.purged(),
787+
result.truncated(),
788+
json_escape(&result.vhost),
789+
cache_route_json(result.route.as_deref()),
790+
json_escape(path_pattern),
791+
result.memory_matched,
792+
result.memory_purged,
793+
result.memory_truncated,
794+
result.disk_matched,
795+
result.disk_purged,
796+
result.disk_truncated
797+
);
798+
json_response(StatusCode::OK, body.as_bytes())
799+
}
800+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
801+
error_response(StatusCode::NOT_FOUND, &error.to_string())
802+
}
803+
Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => {
804+
error_response(StatusCode::BAD_REQUEST, &error.to_string())
805+
}
806+
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
807+
}
808+
}
809+
738810
#[cfg(not(feature = "cache"))]
739811
fn cache_purge_response(
740812
&self,
@@ -782,6 +854,17 @@ impl AdminApp {
782854
error_response(StatusCode::BAD_REQUEST, "cache support is not compiled in")
783855
}
784856

857+
#[cfg(not(feature = "cache"))]
858+
fn cache_purge_wildcard_response(
859+
&self,
860+
_vhost: Option<&str>,
861+
_route: Option<&str>,
862+
_path_pattern: Option<&str>,
863+
_limit: Option<&str>,
864+
) -> AdminResponse {
865+
error_response(StatusCode::BAD_REQUEST, "cache support is not compiled in")
866+
}
867+
785868
fn apply_snapshot(
786869
&self,
787870
snapshot: &ConfigSnapshot,
@@ -1713,6 +1796,28 @@ fn validated_cache_purge_path_prefix(prefix: Option<&str>) -> Result<&str, &'sta
17131796
Ok(prefix)
17141797
}
17151798

1799+
#[cfg(feature = "cache")]
1800+
fn validated_cache_purge_path_pattern(pattern: Option<&str>) -> Result<&str, &'static str> {
1801+
let Some(pattern) = pattern.map(str::trim).filter(|pattern| !pattern.is_empty()) else {
1802+
return Err("cache wildcard purge pattern is required and must start with /");
1803+
};
1804+
validate_cache_purge_path_value(pattern)?;
1805+
if !pattern.contains('*') {
1806+
return Err("cache wildcard purge pattern must contain *");
1807+
}
1808+
if pattern
1809+
.chars()
1810+
.filter(|character| *character != '*')
1811+
.collect::<String>()
1812+
== "/"
1813+
{
1814+
return Err(
1815+
"cache wildcard purge pattern must not target the whole cache; use scope purge instead",
1816+
);
1817+
}
1818+
Ok(pattern)
1819+
}
1820+
17161821
#[cfg(feature = "cache")]
17171822
fn path_contains_traversal_segment(path: &str) -> bool {
17181823
path.split('/').any(|segment| matches!(segment, "." | ".."))
@@ -2157,6 +2262,67 @@ mod tests {
21572262
assert_eq!(response.status, StatusCode::BAD_REQUEST);
21582263
}
21592264

2265+
#[cfg(feature = "cache")]
2266+
#[test]
2267+
fn cache_purge_wildcard_endpoint_accepts_path_pattern() {
2268+
let config = Config {
2269+
vhosts: vec![VhostConfig {
2270+
name: "cached".to_owned(),
2271+
hosts: vec!["cached.example".to_owned()],
2272+
max_request_body_bytes: None,
2273+
acme_challenge: crate::config::VhostAcmeChallengeConfig::default(),
2274+
redirect: crate::config::VhostRedirectConfig::default(),
2275+
tls: crate::config::VhostTlsConfig::default(),
2276+
proxy: ProxyConfig::default(),
2277+
cache: CacheConfig {
2278+
enabled: true,
2279+
memory: crate::config::CacheMemoryConfig {
2280+
enabled: true,
2281+
max_size_bytes: ByteSize::from_bytes(2048),
2282+
},
2283+
max_object_bytes: ByteSize::from_bytes(512),
2284+
..CacheConfig::default()
2285+
},
2286+
headers: crate::config::VhostHeaderPolicyConfig::default(),
2287+
web: WebConfig::default(),
2288+
routes: Vec::new(),
2289+
}],
2290+
..Config::default()
2291+
};
2292+
let app = app_with_config(config);
2293+
2294+
let response = app.handle(
2295+
"POST",
2296+
"/_fluxheim/cache/purge-wildcard",
2297+
Some("vhost=cached&pattern=/assets/*.png&limit=16"),
2298+
&auth_headers(),
2299+
);
2300+
2301+
assert_eq!(response.status, StatusCode::OK);
2302+
let body = String::from_utf8(response.body).unwrap();
2303+
assert!(body.contains(r#""vhost":"cached""#));
2304+
assert!(body.contains(r#""path_pattern":"/assets/*.png""#));
2305+
assert!(body.contains(r#""matched":0"#));
2306+
}
2307+
2308+
#[cfg(feature = "cache")]
2309+
#[test]
2310+
fn cache_purge_wildcard_endpoint_rejects_root_pattern() {
2311+
for query in [
2312+
Some("vhost=cached&pattern=/*"),
2313+
Some("vhost=cached&pattern=/***"),
2314+
] {
2315+
let response = app().handle(
2316+
"POST",
2317+
"/_fluxheim/cache/purge-wildcard",
2318+
query,
2319+
&auth_headers(),
2320+
);
2321+
2322+
assert_eq!(response.status, StatusCode::BAD_REQUEST);
2323+
}
2324+
}
2325+
21602326
#[cfg(feature = "cache")]
21612327
#[test]
21622328
fn cache_purge_endpoint_rejects_missing_identity() {

0 commit comments

Comments
 (0)