Skip to content

Commit 238682f

Browse files
committed
Add indexed cache path-prefix purge
1 parent ea51fca commit 238682f

4 files changed

Lines changed: 573 additions & 92 deletions

File tree

ROADMAP.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -649,8 +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 is implemented through the admin API. Path-prefix/wildcard purge
653-
and a background purger for complete disk cleanup are still planned;
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;
654655
- startup cache-index loading that is incremental and bounded so large
655656
disk caches do not block the gateway;
656657
- byte-range/slice caching for large immutable files, with explicit

src/admin.rs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,17 @@ impl AdminApp {
253253
header_value(headers, "x-fluxheim-cache-limit")
254254
.or_else(|| query_param(query, "limit")),
255255
),
256+
("POST", "/_fluxheim/cache/purge-prefix") => self.cache_purge_prefix_response(
257+
header_value(headers, "x-fluxheim-cache-vhost")
258+
.or_else(|| query_param(query, "vhost")),
259+
header_value(headers, "x-fluxheim-cache-route")
260+
.or_else(|| query_param(query, "route")),
261+
header_value(headers, "x-fluxheim-cache-path-prefix")
262+
.or_else(|| query_param(query, "path_prefix"))
263+
.or_else(|| query_param(query, "prefix")),
264+
header_value(headers, "x-fluxheim-cache-limit")
265+
.or_else(|| query_param(query, "limit")),
266+
),
256267
("POST", "/_fluxheim/snapshot") => {
257268
self.create_snapshot_response(header_value(headers, "x-fluxheim-message"))
258269
}
@@ -276,6 +287,7 @@ impl AdminApp {
276287
| "/_fluxheim/cache/purge"
277288
| "/_fluxheim/cache/purge-bulk"
278289
| "/_fluxheim/cache/purge-index"
290+
| "/_fluxheim/cache/purge-prefix"
279291
| "/_fluxheim/snapshot"
280292
| "/_fluxheim/rollback"
281293
| "/_fluxheim/reload",
@@ -664,6 +676,65 @@ impl AdminApp {
664676
}
665677
}
666678

679+
#[cfg(feature = "cache")]
680+
fn cache_purge_prefix_response(
681+
&self,
682+
vhost: Option<&str>,
683+
route: Option<&str>,
684+
path_prefix: Option<&str>,
685+
limit: Option<&str>,
686+
) -> AdminResponse {
687+
let Some(vhost) = vhost.map(str::trim).filter(|vhost| !vhost.is_empty()) else {
688+
return error_response(
689+
StatusCode::BAD_REQUEST,
690+
"cache path-prefix purge vhost is required",
691+
);
692+
};
693+
let path_prefix = match validated_cache_purge_path_prefix(path_prefix) {
694+
Ok(path_prefix) => path_prefix,
695+
Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
696+
};
697+
let limit = match validated_cache_indexed_purge_limit(limit) {
698+
Ok(limit) => limit,
699+
Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
700+
};
701+
702+
match self.proxy.purge_indexed_image_cache_path_prefix(
703+
crate::proxy::CacheIndexedPathPrefixPurgeRequest {
704+
vhost,
705+
route: route.filter(|route| !route.trim().is_empty()),
706+
path_prefix,
707+
limit,
708+
},
709+
) {
710+
Ok(result) => {
711+
let body = format!(
712+
r#"{{"status":"ok","matched":{},"purged":{},"truncated":{},"vhost":"{}","route":{},"path_prefix":"{}","memory_matched":{},"memory_purged":{},"memory_truncated":{},"disk_matched":{},"disk_purged":{},"disk_truncated":{}}}"#,
713+
result.matched(),
714+
result.purged(),
715+
result.truncated(),
716+
json_escape(&result.vhost),
717+
cache_route_json(result.route.as_deref()),
718+
json_escape(path_prefix),
719+
result.memory_matched,
720+
result.memory_purged,
721+
result.memory_truncated,
722+
result.disk_matched,
723+
result.disk_purged,
724+
result.disk_truncated
725+
);
726+
json_response(StatusCode::OK, body.as_bytes())
727+
}
728+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
729+
error_response(StatusCode::NOT_FOUND, &error.to_string())
730+
}
731+
Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => {
732+
error_response(StatusCode::BAD_REQUEST, &error.to_string())
733+
}
734+
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
735+
}
736+
}
737+
667738
#[cfg(not(feature = "cache"))]
668739
fn cache_purge_response(
669740
&self,
@@ -700,6 +771,17 @@ impl AdminApp {
700771
error_response(StatusCode::BAD_REQUEST, "cache support is not compiled in")
701772
}
702773

774+
#[cfg(not(feature = "cache"))]
775+
fn cache_purge_prefix_response(
776+
&self,
777+
_vhost: Option<&str>,
778+
_route: Option<&str>,
779+
_path_prefix: Option<&str>,
780+
_limit: Option<&str>,
781+
) -> AdminResponse {
782+
error_response(StatusCode::BAD_REQUEST, "cache support is not compiled in")
783+
}
784+
703785
fn apply_snapshot(
704786
&self,
705787
snapshot: &ConfigSnapshot,
@@ -1619,6 +1701,18 @@ fn validate_cache_purge_path_value(path: &str) -> Result<(), &'static str> {
16191701
Ok(())
16201702
}
16211703

1704+
#[cfg(feature = "cache")]
1705+
fn validated_cache_purge_path_prefix(prefix: Option<&str>) -> Result<&str, &'static str> {
1706+
let Some(prefix) = prefix.map(str::trim).filter(|prefix| !prefix.is_empty()) else {
1707+
return Err("cache path-prefix purge prefix is required and must start with /");
1708+
};
1709+
validate_cache_purge_path_value(prefix)?;
1710+
if prefix == "/" {
1711+
return Err("cache path-prefix purge prefix must not be /; use scope purge instead");
1712+
}
1713+
Ok(prefix)
1714+
}
1715+
16221716
#[cfg(feature = "cache")]
16231717
fn path_contains_traversal_segment(path: &str) -> bool {
16241718
path.split('/').any(|segment| matches!(segment, "." | ".."))
@@ -2007,6 +2101,62 @@ mod tests {
20072101
assert!(body.contains(r#""truncated":false"#));
20082102
}
20092103

2104+
#[cfg(feature = "cache")]
2105+
#[test]
2106+
fn cache_purge_prefix_endpoint_accepts_path_prefix() {
2107+
let config = Config {
2108+
vhosts: vec![VhostConfig {
2109+
name: "cached".to_owned(),
2110+
hosts: vec!["cached.example".to_owned()],
2111+
max_request_body_bytes: None,
2112+
acme_challenge: crate::config::VhostAcmeChallengeConfig::default(),
2113+
redirect: crate::config::VhostRedirectConfig::default(),
2114+
tls: crate::config::VhostTlsConfig::default(),
2115+
proxy: ProxyConfig::default(),
2116+
cache: CacheConfig {
2117+
enabled: true,
2118+
memory: crate::config::CacheMemoryConfig {
2119+
enabled: true,
2120+
max_size_bytes: ByteSize::from_bytes(2048),
2121+
},
2122+
max_object_bytes: ByteSize::from_bytes(512),
2123+
..CacheConfig::default()
2124+
},
2125+
headers: crate::config::VhostHeaderPolicyConfig::default(),
2126+
web: WebConfig::default(),
2127+
routes: Vec::new(),
2128+
}],
2129+
..Config::default()
2130+
};
2131+
let app = app_with_config(config);
2132+
2133+
let response = app.handle(
2134+
"POST",
2135+
"/_fluxheim/cache/purge-prefix",
2136+
Some("vhost=cached&path_prefix=/assets/&limit=16"),
2137+
&auth_headers(),
2138+
);
2139+
2140+
assert_eq!(response.status, StatusCode::OK);
2141+
let body = String::from_utf8(response.body).unwrap();
2142+
assert!(body.contains(r#""vhost":"cached""#));
2143+
assert!(body.contains(r#""path_prefix":"/assets/""#));
2144+
assert!(body.contains(r#""matched":0"#));
2145+
}
2146+
2147+
#[cfg(feature = "cache")]
2148+
#[test]
2149+
fn cache_purge_prefix_endpoint_rejects_root_prefix() {
2150+
let response = app().handle(
2151+
"POST",
2152+
"/_fluxheim/cache/purge-prefix",
2153+
Some("vhost=cached&path_prefix=/"),
2154+
&auth_headers(),
2155+
);
2156+
2157+
assert_eq!(response.status, StatusCode::BAD_REQUEST);
2158+
}
2159+
20102160
#[cfg(feature = "cache")]
20112161
#[test]
20122162
fn cache_purge_endpoint_rejects_missing_identity() {

0 commit comments

Comments
 (0)