Skip to content

Commit ea51fca

Browse files
committed
Add indexed cache scope purge
1 parent 1a696ee commit ea51fca

4 files changed

Lines changed: 351 additions & 3 deletions

File tree

ROADMAP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -648,9 +648,9 @@ without parsing text fixtures for every module.
648648
`Set-Cookie`, allowed only when the route is explicitly marked static or
649649
otherwise personalized-content safe;
650650
- bounded cache-key indexing for memory and disk tiers is implemented as
651-
the foundation for broader invalidation. Prefix/tag/wildcard purge API
652-
support and a background purger for complete disk cleanup are still
653-
planned;
651+
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;
654654
- startup cache-index loading that is incremental and bounded so large
655655
disk caches do not block the gateway;
656656
- byte-range/slice caching for large immutable files, with explicit

src/admin.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ const MAX_CACHE_PURGE_PATH_BYTES: usize = 4096;
3838
const MAX_CACHE_PURGE_QUERY_BYTES: usize = 8192;
3939
#[cfg(feature = "cache")]
4040
const MAX_CACHE_PURGE_BULK_PATHS: usize = 256;
41+
#[cfg(feature = "cache")]
42+
const DEFAULT_CACHE_INDEXED_PURGE_LIMIT: usize = 1024;
43+
#[cfg(feature = "cache")]
44+
const MAX_CACHE_INDEXED_PURGE_LIMIT: usize = 10_000;
4145

4246
#[derive(Clone)]
4347
pub struct AdminApp {
@@ -241,6 +245,14 @@ impl AdminApp {
241245
.or_else(|| query_param(query, "url_query"))
242246
.or_else(|| query_param(query, "cache_query")),
243247
),
248+
("POST", "/_fluxheim/cache/purge-index") => self.cache_purge_index_response(
249+
header_value(headers, "x-fluxheim-cache-vhost")
250+
.or_else(|| query_param(query, "vhost")),
251+
header_value(headers, "x-fluxheim-cache-route")
252+
.or_else(|| query_param(query, "route")),
253+
header_value(headers, "x-fluxheim-cache-limit")
254+
.or_else(|| query_param(query, "limit")),
255+
),
244256
("POST", "/_fluxheim/snapshot") => {
245257
self.create_snapshot_response(header_value(headers, "x-fluxheim-message"))
246258
}
@@ -263,6 +275,7 @@ impl AdminApp {
263275
| "/_fluxheim/self-heal/report"
264276
| "/_fluxheim/cache/purge"
265277
| "/_fluxheim/cache/purge-bulk"
278+
| "/_fluxheim/cache/purge-index"
266279
| "/_fluxheim/snapshot"
267280
| "/_fluxheim/rollback"
268281
| "/_fluxheim/reload",
@@ -599,6 +612,58 @@ impl AdminApp {
599612
}
600613
}
601614

615+
#[cfg(feature = "cache")]
616+
fn cache_purge_index_response(
617+
&self,
618+
vhost: Option<&str>,
619+
route: Option<&str>,
620+
limit: Option<&str>,
621+
) -> AdminResponse {
622+
let Some(vhost) = vhost.map(str::trim).filter(|vhost| !vhost.is_empty()) else {
623+
return error_response(
624+
StatusCode::BAD_REQUEST,
625+
"cache indexed purge vhost is required",
626+
);
627+
};
628+
let limit = match validated_cache_indexed_purge_limit(limit) {
629+
Ok(limit) => limit,
630+
Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
631+
};
632+
633+
match self
634+
.proxy
635+
.purge_indexed_image_cache(crate::proxy::CacheIndexedPurgeRequest {
636+
vhost,
637+
route: route.filter(|route| !route.trim().is_empty()),
638+
limit,
639+
}) {
640+
Ok(result) => {
641+
let body = format!(
642+
r#"{{"status":"ok","matched":{},"purged":{},"truncated":{},"vhost":"{}","route":{},"memory_matched":{},"memory_purged":{},"memory_truncated":{},"disk_matched":{},"disk_purged":{},"disk_truncated":{}}}"#,
643+
result.matched(),
644+
result.purged(),
645+
result.truncated(),
646+
json_escape(&result.vhost),
647+
cache_route_json(result.route.as_deref()),
648+
result.memory_matched,
649+
result.memory_purged,
650+
result.memory_truncated,
651+
result.disk_matched,
652+
result.disk_purged,
653+
result.disk_truncated
654+
);
655+
json_response(StatusCode::OK, body.as_bytes())
656+
}
657+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
658+
error_response(StatusCode::NOT_FOUND, &error.to_string())
659+
}
660+
Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => {
661+
error_response(StatusCode::BAD_REQUEST, &error.to_string())
662+
}
663+
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
664+
}
665+
}
666+
602667
#[cfg(not(feature = "cache"))]
603668
fn cache_purge_response(
604669
&self,
@@ -625,6 +690,16 @@ impl AdminApp {
625690
error_response(StatusCode::BAD_REQUEST, "cache support is not compiled in")
626691
}
627692

693+
#[cfg(not(feature = "cache"))]
694+
fn cache_purge_index_response(
695+
&self,
696+
_vhost: Option<&str>,
697+
_route: Option<&str>,
698+
_limit: Option<&str>,
699+
) -> AdminResponse {
700+
error_response(StatusCode::BAD_REQUEST, "cache support is not compiled in")
701+
}
702+
628703
fn apply_snapshot(
629704
&self,
630705
snapshot: &ConfigSnapshot,
@@ -1570,6 +1645,20 @@ fn validated_cache_purge_query(query: Option<&str>) -> Result<Option<&str>, &'st
15701645
Ok(Some(query))
15711646
}
15721647

1648+
#[cfg(feature = "cache")]
1649+
fn validated_cache_indexed_purge_limit(limit: Option<&str>) -> Result<usize, &'static str> {
1650+
let Some(limit) = limit.map(str::trim).filter(|limit| !limit.is_empty()) else {
1651+
return Ok(DEFAULT_CACHE_INDEXED_PURGE_LIMIT);
1652+
};
1653+
let limit = limit
1654+
.parse::<usize>()
1655+
.map_err(|_| "cache indexed purge limit is invalid")?;
1656+
if limit == 0 || limit > MAX_CACHE_INDEXED_PURGE_LIMIT {
1657+
return Err("cache indexed purge limit is out of range");
1658+
}
1659+
Ok(limit)
1660+
}
1661+
15731662
fn truthy_header(headers: &HeaderMap, name: &str) -> bool {
15741663
header_value(headers, name).is_some_and(truthy)
15751664
}
@@ -1874,6 +1963,50 @@ mod tests {
18741963
assert!(body.contains(r#""route":"assets""#));
18751964
}
18761965

1966+
#[cfg(feature = "cache")]
1967+
#[test]
1968+
fn cache_purge_index_endpoint_accepts_vhost_scope() {
1969+
let config = Config {
1970+
vhosts: vec![VhostConfig {
1971+
name: "cached".to_owned(),
1972+
hosts: vec!["cached.example".to_owned()],
1973+
max_request_body_bytes: None,
1974+
acme_challenge: crate::config::VhostAcmeChallengeConfig::default(),
1975+
redirect: crate::config::VhostRedirectConfig::default(),
1976+
tls: crate::config::VhostTlsConfig::default(),
1977+
proxy: ProxyConfig::default(),
1978+
cache: CacheConfig {
1979+
enabled: true,
1980+
memory: crate::config::CacheMemoryConfig {
1981+
enabled: true,
1982+
max_size_bytes: ByteSize::from_bytes(2048),
1983+
},
1984+
max_object_bytes: ByteSize::from_bytes(512),
1985+
..CacheConfig::default()
1986+
},
1987+
headers: crate::config::VhostHeaderPolicyConfig::default(),
1988+
web: WebConfig::default(),
1989+
routes: Vec::new(),
1990+
}],
1991+
..Config::default()
1992+
};
1993+
let app = app_with_config(config);
1994+
1995+
let response = app.handle(
1996+
"POST",
1997+
"/_fluxheim/cache/purge-index",
1998+
Some("vhost=cached&limit=16"),
1999+
&auth_headers(),
2000+
);
2001+
2002+
assert_eq!(response.status, StatusCode::OK);
2003+
let body = String::from_utf8(response.body).unwrap();
2004+
assert!(body.contains(r#""vhost":"cached""#));
2005+
assert!(body.contains(r#""matched":0"#));
2006+
assert!(body.contains(r#""purged":0"#));
2007+
assert!(body.contains(r#""truncated":false"#));
2008+
}
2009+
18772010
#[cfg(feature = "cache")]
18782011
#[test]
18792012
fn cache_purge_endpoint_rejects_missing_identity() {

src/cache.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,14 @@ pub struct CachePurgeIndexEntry {
309309
pub user_tag: String,
310310
}
311311

312+
#[cfg(feature = "proxy")]
313+
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
314+
pub struct CacheIndexedPurgeResult {
315+
pub matched: usize,
316+
pub purged: usize,
317+
pub truncated: bool,
318+
}
319+
312320
#[cfg(feature = "proxy")]
313321
impl CachePurgeIndex {
314322
pub fn new(max_entries: usize) -> Self {
@@ -507,6 +515,33 @@ impl PingoraMemoryStorage {
507515
existed
508516
}
509517

518+
pub fn purge_indexed_user_tag(&self, user_tag: &str, limit: usize) -> CacheIndexedPurgeResult {
519+
let mut entries = self
520+
.purge_index
521+
.entries_for_user_tag(user_tag, limit.saturating_add(1));
522+
let truncated = entries.len() > limit;
523+
entries.truncate(limit);
524+
525+
let mut purged = 0;
526+
for entry in &entries {
527+
if self.inner.get(&entry.combined_key).is_some() {
528+
purged += 1;
529+
}
530+
self.inner.invalidate(&entry.combined_key);
531+
self.purge_index.remove_combined(&entry.combined_key);
532+
}
533+
self.inner.run_pending_tasks();
534+
if purged > 0 {
535+
self.activity.purge();
536+
}
537+
538+
CacheIndexedPurgeResult {
539+
matched: entries.len(),
540+
purged,
541+
truncated,
542+
}
543+
}
544+
510545
fn lookup_object(&self, key: &pingora::cache::CacheKey) -> Option<PingoraStoredObject> {
511546
self.inner.get(&key.combined())
512547
}
@@ -672,6 +707,33 @@ impl PingoraDiskStorage {
672707
Ok(purged)
673708
}
674709

710+
pub fn purge_indexed_user_tag(
711+
&self,
712+
user_tag: &str,
713+
limit: usize,
714+
) -> std::io::Result<CacheIndexedPurgeResult> {
715+
let mut entries = self
716+
.purge_index
717+
.entries_for_user_tag(user_tag, limit.saturating_add(1));
718+
let truncated = entries.len() > limit;
719+
entries.truncate(limit);
720+
721+
let mut purged = 0;
722+
for entry in &entries {
723+
let path = self.path_for_combined_key(&entry.combined_key);
724+
if self.purge_object_path(path)? {
725+
purged += 1;
726+
}
727+
self.purge_index.remove_combined(&entry.combined_key);
728+
}
729+
730+
Ok(CacheIndexedPurgeResult {
731+
matched: entries.len(),
732+
purged,
733+
truncated,
734+
})
735+
}
736+
675737
fn purge_object_path(&self, path: PathBuf) -> std::io::Result<bool> {
676738
match remove_disk_cache_object(&self.root, &path) {
677739
Ok(true) => {
@@ -2807,6 +2869,43 @@ mod tests {
28072869
assert!(storage.purge_index.is_empty());
28082870
}
28092871

2872+
#[cfg(feature = "proxy")]
2873+
#[test]
2874+
fn pingora_memory_storage_purges_indexed_user_tag() {
2875+
use pingora::cache::Storage;
2876+
2877+
let storage = super::pingora_memory_storage_from_plan(super::MemoryTierPlan {
2878+
max_size_bytes: ByteSize::from_bytes(2048),
2879+
max_object_bytes: ByteSize::from_bytes(512),
2880+
object_slots: 4,
2881+
});
2882+
let first = pingora::cache::CacheKey::new("fluxheim-test", "first", "vhost-a");
2883+
let second = pingora::cache::CacheKey::new("fluxheim-test", "second", "vhost-a");
2884+
let other = pingora::cache::CacheKey::new("fluxheim-test", "other", "vhost-b");
2885+
let span = pingora::cache::trace::Span::inactive().handle();
2886+
let meta = pingora_meta("max-age=60");
2887+
2888+
for key in [&first, &second, &other] {
2889+
let mut miss = block_on(storage.get_miss_handler(key, &meta, &span)).unwrap();
2890+
block_on(miss.write_body(Bytes::from_static(b"body"), true)).unwrap();
2891+
block_on(miss.finish()).unwrap();
2892+
}
2893+
2894+
let result = storage.purge_indexed_user_tag("vhost-a", 8);
2895+
2896+
assert_eq!(
2897+
result,
2898+
super::CacheIndexedPurgeResult {
2899+
matched: 2,
2900+
purged: 2,
2901+
truncated: false,
2902+
}
2903+
);
2904+
assert!(block_on(storage.lookup(&first, &span)).unwrap().is_none());
2905+
assert!(block_on(storage.lookup(&second, &span)).unwrap().is_none());
2906+
assert!(block_on(storage.lookup(&other, &span)).unwrap().is_some());
2907+
}
2908+
28102909
#[cfg(feature = "proxy")]
28112910
#[test]
28122911
fn pingora_disk_storage_round_trips_cached_body() {

0 commit comments

Comments
 (0)