0.34.6_repo_cleanup

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 09:31:15 +02:00
1 parent ce1a90397c
commit 133d2f0f91
273 files changed
+5969 -6926

No files matched your search

+288 -123
View File
@@ -75,13 +75,25 @@ mod tests {
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::from("zero-freshness"));
*response.headers_mut() = headers(control);
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO)
.expect("valid zero freshness is distinct from a no-store response");
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.expect("valid zero freshness is distinct from a no-store response");
assert_eq!(plan.ttl, 0, "{control}");
cache.wrap(response, ticket.clone(), plan, &settings)
.into_body().collect().await.unwrap();
cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.view().entries, 1, "{control}");
assert!(cache.get(&ticket).is_none(), "zero-TTL {control} cannot be an unconditional HIT");
assert!(
cache.get(&ticket).is_none(),
"zero-TTL {control} cannot be an unconditional HIT"
);
}
}
#[test]
@@ -180,7 +192,14 @@ mod tests {
let mut single_range = HeaderMap::new();
single_range.insert("range", HeaderValue::from_static("bytes=0-3"));
assert_eq!(
request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()),
request_policy(
&Method::GET,
&single_range,
true,
"/asset.bin",
false,
&settings()
),
RequestPolicy::Use
);
assert!(request_cacheable(
@@ -193,7 +212,14 @@ mod tests {
));
single_range.insert("range", HeaderValue::from_static("bytes=0-3,7-9"));
assert_eq!(
request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()),
request_policy(
&Method::GET,
&single_range,
true,
"/asset.bin",
false,
&settings()
),
RequestPolicy::Bypass("request_range_unsupported")
);
}
@@ -218,11 +244,23 @@ mod tests {
fn request_max_stale_is_bounded_and_respects_revalidation_flags() {
let unbounded = headers("max-stale");
let bounded = headers("max-stale=15");
assert_eq!(stale_mode(120, 60, 0, false, false, false, &unbounded), Some(false));
assert_eq!(stale_mode(70, 60, 0, false, false, false, &bounded), Some(false));
assert_eq!(
stale_mode(120, 60, 0, false, false, false, &unbounded),
Some(false)
);
assert_eq!(
stale_mode(70, 60, 0, false, false, false, &bounded),
Some(false)
);
assert_eq!(stale_mode(80, 60, 0, false, false, false, &bounded), None);
assert_eq!(stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()), Some(true));
assert_eq!(stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()), None);
assert_eq!(
stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()),
Some(true)
);
assert_eq!(
stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()),
None
);
assert_eq!(stale_mode(70, 60, 30, true, false, false, &unbounded), None);
assert_eq!(stale_mode(70, 60, 30, false, true, false, &unbounded), None);
}
@@ -268,9 +306,21 @@ mod tests {
origin_last_modified: None,
};
assert!(base.can_stale_if_error());
assert!(!Revalidation { age: 91, ..base.clone() }.can_stale_if_error());
assert!(!Revalidation { must_revalidate: true, ..base.clone() }.can_stale_if_error());
assert!(!Revalidation { proxy_revalidate: true, ..base }.can_stale_if_error());
assert!(!Revalidation {
age: 91,
..base.clone()
}
.can_stale_if_error());
assert!(!Revalidation {
must_revalidate: true,
..base.clone()
}
.can_stale_if_error());
assert!(!Revalidation {
proxy_revalidate: true,
..base
}
.can_stale_if_error());
}
#[test]
@@ -378,14 +428,10 @@ mod tests {
json: true,
..Default::default()
};
let transformed = crate::transform::apply(
response,
"/data.json",
&transform_settings,
false,
)
.await
.unwrap();
let transformed =
crate::transform::apply(response, "/data.json", &transform_settings, false)
.await
.unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
@@ -403,7 +449,9 @@ mod tests {
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&first[..], br#"{"message":"hello"}"#);
let hit = cache.get(&ticket).expect("transformed representation should be cached");
let hit = cache
.get(&ticket)
.expect("transformed representation should be cached");
assert_eq!(hit.response.headers()["etag"], expected_etag);
assert_eq!(
hit.response.headers()["content-length"].to_str().unwrap(),
@@ -460,10 +508,9 @@ mod tests {
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
response.headers_mut().insert(
"content-type",
HeaderValue::from_str(content_type).unwrap(),
);
response
.headers_mut()
.insert("content-type", HeaderValue::from_str(content_type).unwrap());
response.headers_mut().insert(
"content-length",
HeaderValue::from_str(&origin_body.len().to_string()).unwrap(),
@@ -496,14 +543,9 @@ mod tests {
"json" => transform_settings.json = true,
_ => unreachable!(),
}
let transformed = crate::transform::apply(
response,
path,
&transform_settings,
false,
)
.await
.unwrap();
let transformed = crate::transform::apply(response, path, &transform_settings, false)
.await
.unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
@@ -521,9 +563,15 @@ mod tests {
);
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
assert!(!first.is_empty(), "{kind}");
assert_ne!(first.as_ref(), origin_body, "{kind} should change the chosen fixture");
assert_ne!(
first.as_ref(),
origin_body,
"{kind} should change the chosen fixture"
);
let hit = cache.get(&ticket).expect("transformed representation should be cached");
let hit = cache
.get(&ticket)
.expect("transformed representation should be cached");
assert_eq!(hit.response.headers()["etag"], expected_etag, "{kind}");
assert_eq!(
hit.response.headers()["content-length"],
@@ -701,7 +749,9 @@ mod tests {
let mut s = Settings::default();
s.transient_max_bytes = 128 * 1024;
s.cold_queue_max_bytes = 64 * 1024;
let first = cache.try_reserve_transient(96 * 1024, &s).expect("first reservation");
let first = cache
.try_reserve_transient(96 * 1024, &s)
.expect("first reservation");
assert!(cache.try_reserve_transient(40 * 1024, &s).is_none());
assert_eq!(cache.view().transient_bytes, 96 * 1024);
drop(first);
@@ -756,9 +806,7 @@ mod tests {
must_revalidate: false,
proxy_revalidate: false,
origin_etag: Some(HeaderValue::from_static("W/\"origin\"")),
origin_last_modified: Some(HeaderValue::from_static(
"Mon, 26 Jan 2026 23:11:40 GMT",
)),
origin_last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
};
let mut matching = HeaderMap::new();
matching.insert("if-none-match", HeaderValue::from_static("\"origin\""));
@@ -781,13 +829,7 @@ mod tests {
let mut response_headers = headers("public, max-age=60");
response_headers.insert("etag", public_etag.clone());
response_headers.insert("last-modified", modified.clone());
let plan = plan(
StatusCode::OK,
&response_headers,
&settings,
Duration::ZERO,
)
.unwrap();
let plan = plan(StatusCode::OK, &response_headers, &settings, Duration::ZERO).unwrap();
let mut response = Response::new(Body::from("transformed"));
*response.headers_mut() = response_headers;
response
@@ -818,9 +860,15 @@ mod tests {
assert_eq!(client_conditional["if-modified-since"], modified);
let mut validation = HeaderMap::new();
validation.insert("cache-control", HeaderValue::from_static("public, max-age=60"));
validation.insert(
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
validation.insert("etag", HeaderValue::from_static("\"origin-new\""));
validation.insert("last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT"));
validation.insert(
"last-modified",
HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT"),
);
let merged = cached.merge_validation_headers(&validation);
assert_eq!(merged["etag"], public_etag);
}
@@ -915,18 +963,13 @@ mod tests {
let mut origin_headers = headers("public, max-age=60");
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding"));
origin_headers.insert("content-length", HeaderValue::from_static("7"));
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO)
.expect("origin plan");
let origin_plan =
plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan");
let mut transformed_headers = headers("public, max-age=60");
transformed_headers.insert("content-length", HeaderValue::from_static("7"));
let transformed_plan = plan(
StatusCode::OK,
&transformed_headers,
&s,
Duration::ZERO,
)
.expect("transformed plan");
let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO)
.expect("transformed plan");
let cache_plan = origin_plan
.without_vary("accept-encoding")
.restrict(transformed_plan);
@@ -962,21 +1005,13 @@ mod tests {
dark.insert("x-theme", HeaderValue::from_static("dark"));
let mut origin_headers = headers("public, max-age=60");
origin_headers.insert(
"vary",
HeaderValue::from_static("Accept-Encoding, X-Theme"),
);
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO)
.expect("origin plan");
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding, X-Theme"));
let origin_plan =
plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan");
let mut transformed_headers = headers("public, max-age=60");
transformed_headers.insert("vary", HeaderValue::from_static("X-Theme"));
let transformed_plan = plan(
StatusCode::OK,
&transformed_headers,
&s,
Duration::ZERO,
)
.expect("transformed plan");
let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO)
.expect("transformed plan");
let cache_plan = origin_plan
.without_vary("accept-encoding")
.restrict(transformed_plan);
@@ -1067,11 +1102,20 @@ mod tests {
let entry = store.entries.values_mut().next().expect("stored object");
entry.received = Instant::now() - Duration::from_secs(2);
}
assert!(cache.get(&initial).is_none(), "normal request must not receive stale data");
assert_eq!(cache.view().entries, 1, "freshness expiry must not physically evict the object");
assert!(
cache.get(&initial).is_none(),
"normal request must not receive stale data"
);
assert_eq!(
cache.view().entries,
1,
"freshness expiry must not physically evict the object"
);
let stale = ticket(&cache, &headers("max-stale"));
let hit = cache.get(&stale).expect("explicit max-stale may reuse retained stale data");
let hit = cache
.get(&stale)
.expect("explicit max-stale may reuse retained stale data");
assert!(hit.stale);
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
@@ -1107,7 +1151,14 @@ mod tests {
validation.insert("etag", HeaderValue::from_static("\"origin\""));
let merged = candidate.merge_validation_headers(&validation);
let hit = cache
.publish_revalidated(t.clone(), &candidate, merged, &validation, &s, Duration::ZERO)
.publish_revalidated(
t.clone(),
&candidate,
merged,
&validation,
&s,
Duration::ZERO,
)
.expect("validated body can be delivered");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
@@ -1150,7 +1201,14 @@ mod tests {
validation.insert("etag", HeaderValue::from_static("\"origin\""));
let merged = candidate.merge_validation_headers(&validation);
let hit = cache
.publish_revalidated(t, &candidate, merged, &validation, &s, Duration::from_secs(2))
.publish_revalidated(
t,
&candidate,
merged,
&validation,
&s,
Duration::from_secs(2),
)
.expect("validated object");
assert!(hit.resident_age_seconds >= 32);
}
@@ -1262,7 +1320,10 @@ mod tests {
fn aged_upstream_freshness_caps_remaining_not_total_age() {
let settings = settings();
let mut headers = headers("public, max-age=2592000");
headers.insert("date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap());
headers.insert(
"date",
httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(),
);
headers.insert("age", HeaderValue::from_static("1553483"));
headers.insert("etag", HeaderValue::from_static("\"origin-etag\""));
let original = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap();
@@ -1270,20 +1331,29 @@ mod tests {
assert_eq!(original.remaining_ttl(), settings.max_ttl_seconds);
assert_eq!(original.ttl, 1553483 + settings.max_ttl_seconds);
// Local cap never overrides the remaining origin lifetime.
headers.insert("age", HeaderValue::from_static("2591990"));
let almost_expired = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap();
assert_eq!(almost_expired.remaining_ttl(), 10);
headers.insert("age", HeaderValue::from_static("2592010"));
assert_eq!(
plan(StatusCode::OK, &headers, &settings, Duration::ZERO)
.unwrap().remaining_ttl(), 0
.unwrap()
.remaining_ttl(),
0
);
headers.insert(
"cache-control",
HeaderValue::from_static("public, no-cache, max-age=2592000"),
);
assert!(
plan(StatusCode::OK, &headers, &settings, Duration::ZERO)
.unwrap()
.requires_revalidation
);
headers.insert(
"cache-control",
HeaderValue::from_static("private, max-age=2592000"),
);
// No-cache and private/no-store restrictions must not be relaxed.
headers.insert("cache-control", HeaderValue::from_static("public, no-cache, max-age=2592000"));
assert!(plan(StatusCode::OK, &headers, &settings, Duration::ZERO)
.unwrap().requires_revalidation);
headers.insert("cache-control", HeaderValue::from_static("private, max-age=2592000"));
assert!(plan(StatusCode::OK, &headers, &settings, Duration::ZERO).is_none());
}
@@ -1291,8 +1361,12 @@ mod tests {
fn age_uses_rfc9111_max_of_apparent_and_corrected_age() {
let s = settings();
let mut h = headers("public, max-age=3600");
h.insert("date", httpdate::fmt_http_date(SystemTime::now()
- Duration::from_secs(10)).parse().unwrap());
h.insert(
"date",
httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10))
.parse()
.unwrap(),
);
h.insert("age", HeaderValue::from_static("3"));
let p = plan(StatusCode::OK, &h, &s, Duration::from_secs(2)).unwrap();
assert!(p.initial_age < Duration::from_secs(12));
@@ -1310,30 +1384,76 @@ mod tests {
let original_body = b"body { color: red; margin: 0px; }";
let mut upstream = Response::new(Body::from(Bytes::from_static(original_body)));
*upstream.headers_mut() = headers("public, max-age=2592000");
upstream.headers_mut().insert("date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap());
upstream.headers_mut().insert("age", HeaderValue::from_static("1553483"));
upstream.headers_mut().insert("content-type", HeaderValue::from_static("text/css"));
upstream.headers_mut().insert("content-length",
HeaderValue::from_str(&original_body.len().to_string()).unwrap());
upstream.headers_mut().insert("etag", HeaderValue::from_static("\"upstream\""));
let origin_plan = plan(StatusCode::OK, upstream.headers(), &settings, Duration::ZERO).unwrap();
upstream.headers_mut().insert(
"date",
httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(),
);
upstream
.headers_mut()
.insert("age", HeaderValue::from_static("1553483"));
upstream
.headers_mut()
.insert("content-type", HeaderValue::from_static("text/css"));
upstream.headers_mut().insert(
"content-length",
HeaderValue::from_str(&original_body.len().to_string()).unwrap(),
);
upstream
.headers_mut()
.insert("etag", HeaderValue::from_static("\"upstream\""));
let origin_plan = plan(
StatusCode::OK,
upstream.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let transformed = crate::transform::apply(
upstream, "/theme.css", &crate::transform::Settings {
enabled: true, css: true, ..Default::default()
}, false
).await.unwrap();
assert!(transformed.extensions().get::<crate::transform::Applied>().is_some());
upstream,
"/theme.css",
&crate::transform::Settings {
enabled: true,
css: true,
..Default::default()
},
false,
)
.await
.unwrap();
assert!(transformed
.extensions()
.get::<crate::transform::Applied>()
.is_some());
assert_eq!(transformed.headers()["age"], "1553483");
let transformed_plan = plan(StatusCode::OK, transformed.headers(), &settings,
Duration::ZERO).unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let combined = origin_plan.restrict(transformed_plan);
assert!(combined.remaining_ttl() > 86_000);
let first = cache.wrap(transformed, ticket.clone(), combined, &settings)
.into_body().collect().await.unwrap().to_bytes();
let hit = cache.get(&ticket).expect("aged, fresh CSS should be cached after transform");
let first = cache
.wrap(transformed, ticket.clone(), combined, &settings)
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
let hit = cache
.get(&ticket)
.expect("aged, fresh CSS should be cached after transform");
assert!(hit.transformed);
assert!(hit.remaining_ttl >= 86_000);
assert!(hit.response.headers()["age"].to_str().unwrap().parse::<u64>().unwrap() >= 1553483);
assert!(
hit.response.headers()["age"]
.to_str()
.unwrap()
.parse::<u64>()
.unwrap()
>= 1553483
);
let second = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(first, second);
let dash = cache.dashboard(None, 0, 10);
@@ -1379,18 +1499,34 @@ mod tests {
#[tokio::test]
async fn hot_hit_updates_lru_without_replacing_the_entry_or_losing_hit_counter() {
let cache = Cache::new(1);
let settings = Settings { max_entries: 2, ..settings() };
let settings = Settings {
max_entries: 2,
..settings()
};
let h = HeaderMap::new();
let a = cache.ticket(1, "http", "example.test", "app", "/a", &h).unwrap();
let b = cache.ticket(1, "http", "example.test", "app", "/b", &h).unwrap();
let c = cache.ticket(1, "http", "example.test", "app", "/c", &h).unwrap();
let a = cache
.ticket(1, "http", "example.test", "app", "/a", &h)
.unwrap();
let b = cache
.ticket(1, "http", "example.test", "app", "/b", &h)
.unwrap();
let c = cache
.ticket(1, "http", "example.test", "app", "/c", &h)
.unwrap();
fill(&cache, a.clone(), "payload-a", &settings).await;
fill(&cache, b.clone(), "payload-b", &settings).await;
assert_eq!(cache.get(&a).unwrap().hits, 1);
assert_eq!(cache.get(&a).unwrap().hits, 2);
fill(&cache, c.clone(), "payload-c", &settings).await;
assert!(cache.get(&b).is_none(), "the least recently used object is evicted");
assert_eq!(cache.get(&a).unwrap().hits, 3, "HOT hits do not reset on LRU refresh");
assert!(
cache.get(&b).is_none(),
"the least recently used object is evicted"
);
assert_eq!(
cache.get(&a).unwrap().hits,
3,
"HOT hits do not reset on LRU refresh"
);
assert_eq!(cache.get(&c).unwrap().hits, 1);
assert_eq!(cache.view().evictions, 1);
assert_eq!(cache.view().entries, 2);
@@ -1423,7 +1559,10 @@ mod tests {
assert!(after.hot_allocated_bytes <= s.max_bytes);
assert_eq!(after.fill_bytes_active, 0);
assert_eq!(after.transient_bytes, 0);
assert!(cache.get(&first).is_none(), "LRU entry should make room before the next fill allocation");
assert!(
cache.get(&first).is_none(),
"LRU entry should make room before the next fill allocation"
);
assert!(cache.get(&second).is_some());
assert!(after.evictions >= 1);
}
@@ -1477,8 +1616,6 @@ mod tests {
&a,
)
.unwrap();
// Compare unrelated headers/member choice within the same frontend
// scheme. HTTP and HTTPS intentionally have distinct cache identities.
let other_member = cache
.ticket(
1,
@@ -1559,12 +1696,17 @@ mod tests {
assert_eq!(first, Bytes::from_static(b"first-"));
drop(body);
assert!(cache.get(&t).is_none(), "partial body must never become a HIT");
assert!(
cache.get(&t).is_none(),
"partial body must never become a HIT"
);
assert_eq!(cache.view().fills_active, 0);
assert_eq!(cache.view().rejected_fills, 1);
fill(&cache, t.clone(), "complete", &s).await;
let hit = cache.get(&t).expect("a later complete GET can fill the cache");
let hit = cache
.get(&t)
.expect("a later complete GET can fill the cache");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"complete")
@@ -1604,11 +1746,18 @@ mod tests {
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600");
response.headers_mut().insert("content-length", HeaderValue::from_static("0"));
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("0"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let response = cache.wrap(response, ticket.clone(), plan, &settings);
assert!(response.body().is_end_stream());
// HTTP/1.1 Content-Length: 0 does not ask the response body for frames.
drop(response);
assert!(cache.get(&ticket).is_some());
assert_eq!(cache.view().entries, 1);
@@ -1623,8 +1772,16 @@ mod tests {
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600");
response.headers_mut().insert("content-length", HeaderValue::from_static("6"));
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("6"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
drop(cache.wrap(response, ticket.clone(), plan, &settings));
assert!(cache.get(&ticket).is_none());
assert_eq!(cache.view().fills_active, 0);
@@ -1638,8 +1795,16 @@ mod tests {
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600");
response.headers_mut().insert("content-length", HeaderValue::from_static("0"));
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("0"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache.purge(None);
drop(cache.wrap(response, ticket.clone(), plan, &settings));
assert!(cache.get(&ticket).is_none());
-8
View File
@@ -14,10 +14,6 @@ impl Cache {
}
}
}
// Do not create an unbounded number of detached delete tasks when the
// writer queue is saturated. A tiny fallback pool absorbs short bursts;
// sustained saturation falls back to synchronous removal so stale files
// cannot be resurrected on restart at the cost of bounded backpressure.
if let Ok(permit) = self.cold_delete_fallbacks.clone().try_acquire_owned() {
tokio::spawn(async move {
let _permit = permit;
@@ -51,8 +47,6 @@ impl Cache {
let mut candidates = BinaryHeap::with_capacity(requested_limit.saturating_add(1));
let mut after_sequence = 0u64;
// Copy candidate metadata in bounded chunks. Request-path lookups only
// contend with this mutex for a small snapshot, never for sorting or I/O.
loop {
let batch: Vec<_> = {
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
@@ -152,8 +146,6 @@ impl Cache {
}
}
}
// Keep pending deletion metadata bounded to one selection batch; a
// 10k-entry manual cleanup must not build one large Vec<PathBuf>.
self.enqueue_cold_delete(std::mem::take(&mut delete));
tokio::task::yield_now().await;
}
+955
View File
@@ -0,0 +1,955 @@
mod cold_tier_tests {
use super::*;
use http_body_util::BodyExt;
fn cold_settings() -> Settings {
Settings {
enabled: true,
max_entries: 1,
max_bytes: 256 * 1024,
max_object_bytes: 64 * 1024,
cold_enabled: true,
cold_path: "cache/cold-test".into(),
cold_max_bytes: 2 * 1024 * 1024,
cold_max_entries: 16,
cold_after_seconds: 300,
..Settings::default()
}
}
async fn fill(
cache: &Arc<Cache>,
settings: &Settings,
path: &str,
body: &'static str,
) -> Ticket {
let ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
path,
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"cold-v1\""));
let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap();
cache
.wrap(response, ticket.clone(), plan, settings)
.into_body()
.collect()
.await
.unwrap();
ticket
}
async fn wait_for_cold(cache: &Cache, expected: usize) {
for _ in 0..100 {
if cache.view().cold_entries >= expected {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("cold tier did not persist in time");
}
#[tokio::test]
async fn selective_purge_removes_matching_cold_object() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one?v=1", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().cold_entries, 1);
let result = cache
.invalidate(InvalidationRequest {
operation: InvalidationOperation::Purge,
selector: InvalidationSelector::Exact,
value: "/one?v=1".into(),
host: Some("cold.example".into()),
vhost: Some("cold-test".into()),
ttl_seconds: None,
confirm_wide: false,
})
.unwrap();
assert_eq!(result.affected_entries, 1);
assert_eq!(cache.view().cold_entries, 0);
assert!(cache.get_tiered(&first, &settings).await.is_none());
shutdown.cancel();
}
#[tokio::test]
async fn aged_transformed_css_remains_fresh_when_demoted_to_cold() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/aged-theme.css",
&HeaderMap::new(),
)
.unwrap();
let body = b"body { color: red; margin: 0px; }";
let mut origin = Response::new(Body::from(Bytes::from_static(body)));
origin.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=2592000"),
);
origin
.headers_mut()
.insert("age", HeaderValue::from_static("1553483"));
origin.headers_mut().insert(
"date",
httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(),
);
origin
.headers_mut()
.insert("content-type", HeaderValue::from_static("text/css"));
origin.headers_mut().insert(
"content-length",
HeaderValue::from_str(&body.len().to_string()).unwrap(),
);
origin
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin\""));
let origin_plan =
plan(StatusCode::OK, origin.headers(), &settings, Duration::ZERO).unwrap();
let transformed = crate::transform::apply(
origin,
"/aged-theme.css",
&crate::transform::Settings {
enabled: true,
css: true,
..Default::default()
},
false,
)
.await
.unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
transformed,
ticket.clone(),
origin_plan.restrict(transformed_plan),
&settings,
)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.view().entries, 1);
let _another = fill(&cache, &settings, "/another", "another-payload").await;
wait_for_cold(&cache, 1).await;
let row = cache
.dashboard(Some("cold-test"), 0, 20)
.objects
.into_iter()
.find(|v| v.path == "/aged-theme.css")
.unwrap();
assert_eq!(row.tier, "cold");
assert!(row.transformed);
assert!(row.remaining_ttl_seconds >= 86_000);
shutdown.cancel();
drop(cache);
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let hit = cache
.get_tiered(&ticket, &settings)
.await
.expect("aged transformed COLD should HIT after restart");
assert!(hit.transformed);
assert!(hit.remaining_ttl >= 86_000);
let transformed_body = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_ne!(transformed_body.as_ref(), &body[..]);
shutdown.cancel();
}
#[tokio::test]
async fn lru_demotion_persists_and_survives_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
settings.validate().unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 1);
let cold = cache.dashboard(Some("cold-test"), 0, 50);
assert!(cold
.objects
.iter()
.any(|row| row.path == "/one" && row.tier == "cold"));
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 1);
let hit = restarted
.get_tiered(&first, &settings)
.await
.expect("cold object should be readable after restart");
let payload = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(payload, Bytes::from_static(b"first-payload"));
assert_eq!(restarted.view().cold_hits, 1);
assert_eq!(restarted.view().cold_promotions, 1);
restart_shutdown.cancel();
}
#[tokio::test]
async fn dashboard_lists_retained_expired_cold_objects_without_permitting_fresh_hits() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
{
let mut cold = cache.cold.lock().unwrap();
for entry in cold.entries.values_mut() {
entry.stored_at = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
}
}
let dashboard = cache.dashboard(Some("cold-test"), 0, 50);
assert_eq!(dashboard.matching_objects, 2);
let row = dashboard
.objects
.iter()
.find(|row| row.path == "/one")
.unwrap();
assert_eq!(row.tier, "cold");
assert_eq!(row.remaining_ttl_seconds, 0);
assert!(row.age_seconds >= 2 * 60 * 60);
assert_eq!(row.storage_bytes, cache.view().cold_bytes);
assert!(cache.get_tiered(&first, &settings).await.is_none());
let mut stale = first;
stale
.request_headers
.insert("cache-control", HeaderValue::from_static("max-stale"));
let hit = cache
.get_tiered(&stale, &settings)
.await
.expect("explicit max-stale can reuse the retained disk object");
assert!(hit.stale);
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
"first-payload"
);
shutdown.cancel();
}
#[tokio::test]
async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let transformed_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/transformed.json",
&HeaderMap::new(),
)
.unwrap();
let mut transformed = Response::new(Body::from(r#"{"a":1}"#));
transformed.headers_mut().insert(
"cache-control",
HeaderValue::from_static(
"public, max-age=120, stale-while-revalidate=30, stale-if-error=90",
),
);
transformed.headers_mut().insert(
"etag",
HeaderValue::from_static("\"rustproxy-representation\""),
);
transformed
.headers_mut()
.insert("content-length", HeaderValue::from_static("7"));
transformed
.extensions_mut()
.insert(crate::transform::OriginValidators {
etag: Some(HeaderValue::from_static("\"origin-v1\"")),
last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
});
transformed
.extensions_mut()
.insert(crate::transform::Applied {
kind: crate::transform::Kind::Json,
original_bytes: 9,
transformed_bytes: 7,
});
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
transformed,
transformed_ticket.clone(),
transformed_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
let protected_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/protected.css",
&HeaderMap::new(),
)
.unwrap();
let original_body = "body { color: red; }";
let mut protected = Response::new(Body::from(original_body));
protected.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120, immutable, no-transform"),
);
protected
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin-css\""));
protected.extensions_mut().insert(crate::compression::Skip);
let protected_plan = plan(
StatusCode::OK,
protected.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
protected,
protected_ticket.clone(),
protected_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.flush_hot_to_cold().await, 2);
wait_for_cold(&cache, 2).await;
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
let (key, transformed) = {
let cold = restarted.cold.lock().unwrap();
cold.entries
.iter()
.find(|(_, entry)| entry.path == "/transformed.json")
.map(|(key, entry)| (*key, entry.clone()))
.expect("transformed cold entry")
};
assert_eq!(transformed.stale_while_revalidate, 30);
assert_eq!(transformed.stale_if_error, 90);
assert!(transformed.has_validator);
assert!(transformed.transformed);
let metadata = read_cold_metadata(&key, &transformed)
.await
.expect("persisted transformed metadata");
assert_eq!(metadata.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
assert_eq!(
metadata.origin_last_modified.as_ref().unwrap(),
"Mon, 26 Jan 2026 23:11:40 GMT"
);
assert_eq!(metadata.headers["etag"], "\"rustproxy-representation\"");
let transformed_hit = restarted
.get_tiered(&transformed_ticket, &settings)
.await
.expect("transformed object after restart");
assert_eq!(
transformed_hit.response.headers()["etag"],
"\"rustproxy-representation\""
);
assert!(transformed_hit.transformed);
assert!(transformed_hit
.response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some());
assert_eq!(
transformed_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(br#"{"a":1}"#)
);
let protected_hit = restarted
.get_tiered(&protected_ticket, &settings)
.await
.expect("no-transform object after restart");
assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\"");
assert!(crate::transform::no_transform(
protected_hit.response.headers()
));
assert!(protected_hit
.response
.extensions()
.get::<crate::compression::Skip>()
.is_some());
assert_eq!(
protected_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"body { color: red; }")
);
restart_shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_lru_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.cold_on_lru = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
tokio::time::sleep(Duration::from_millis(30)).await;
let view = cache.view();
assert_eq!(view.entries, 1);
assert_eq!(view.cold_entries, 0);
assert_eq!(view.evictions, 1);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_idle_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/idle", "idle-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.last_access = Instant::now() - Duration::from_secs(600);
}
}
cache.demote_idle();
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
settings.cold_on_idle = true;
cache.configure(settings.clone());
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_demote_by_total_hot_residency_age() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
settings.cold_on_age = true;
settings.cold_max_hot_seconds = 5;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/aged", "aged-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.resident_since = Instant::now() - Duration::from_secs(60);
entry.last_access = Instant::now();
}
}
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
assert_eq!(cache.view().entries, 2);
assert!(cache.view().resident_bytes > 0);
assert_eq!(cache.view().cold_entries, 0);
assert_eq!(cache.flush_hot_to_cold().await, 2);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.hot_logical_bytes, 0);
assert_eq!(
view.transient_bytes, 0,
"persisted ColdEntry must not retain the body allocation"
);
assert_eq!(view.cold_entries, 2);
shutdown.cancel();
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
restart_shutdown.cancel();
}
#[tokio::test]
async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/update", "update-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.flush_hot_to_cold_for_update().await, 1);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_can_be_disabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_cache_uses_two_hex_character_shards() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let root = dir.path().join(&settings.cold_path);
let mut shards = Vec::new();
for item in std::fs::read_dir(&root).unwrap() {
let item = item.unwrap();
let name = item.file_name().to_string_lossy().to_string();
assert!(
item.path().is_dir(),
"cold root must contain shard directories only"
);
assert_eq!(name.len(), 2);
assert!(name.bytes().all(|b| b.is_ascii_hexdigit()));
shards.push(item.path());
}
assert!(!shards.is_empty());
assert!(shards.iter().any(|shard| {
std::fs::read_dir(shard)
.unwrap()
.filter_map(|entry| entry.ok())
.any(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
name.ends_with(".json") || name.ends_with(".body")
})
}));
shutdown.cancel();
}
#[tokio::test]
async fn legacy_flat_cold_cache_is_not_migrated() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let root = dir.path().join(&settings.cold_path);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("legacy.body"), b"old").unwrap();
std::fs::write(root.join("legacy.json"), b"{}").unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
assert_eq!(cache.view().cold_entries, 0);
assert!(!root.join("legacy.body").exists());
assert!(!root.join("legacy.json").exists());
shutdown.cancel();
}
#[tokio::test]
async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
fill(&cache, &settings, "/three", "third-payload").await;
wait_for_cold(&cache, 2).await;
let job = cache
.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
})
.unwrap();
for _ in 0..100 {
let current = cache.cold_cleanup_job(job.id).unwrap();
if current.status == "done" {
assert_eq!(current.removed_entries, 1);
assert!(current.freed_bytes > 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("cold cleanup did not finish");
}
#[tokio::test]
async fn manual_cold_cleanup_is_single_flight() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap();
let result = cache.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
});
assert!(result.is_err());
shutdown.cancel();
}
#[tokio::test]
async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let key = {
let mut cold = cache.cold.lock().unwrap();
let key = *cold.entries.keys().next().unwrap();
cold.entries.get_mut(&key).unwrap().pinned_reads = 1;
key
};
let request = ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
};
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 0);
assert!(cache.cold.lock().unwrap().entries.contains_key(&key));
cache
.cold
.lock()
.unwrap()
.entries
.get_mut(&key)
.unwrap()
.pinned_reads = 0;
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 1);
assert!(!cache.cold.lock().unwrap().entries.contains_key(&key));
shutdown.cancel();
}
#[test]
fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() {
let cache = Cache::new(1);
let key = [7_u8; 32];
let template = ColdEntry {
base_key: key,
vary: Vec::new(),
revision: 1,
scope: "app".to_owned(),
scheme: "https".to_owned(),
authority: "example.test".to_owned(),
domain: "example.test".to_owned(),
path: "/asset".to_owned(),
target: "https://example.test/asset".to_owned(),
tags: Vec::new(),
created_at: SystemTime::now(),
body_path: PathBuf::from("old.body"),
meta_path: PathBuf::from("old.json"),
body_bytes: 1,
body_sha256: [0u8; 32],
disk_bytes: 2,
stored_at: SystemTime::now(),
last_access: SystemTime::now(),
age_at_store_seconds: 0,
resident_age_at_store_seconds: 0,
ttl: 60,
stale_while_revalidate: 0,
stale_if_error: 0,
must_revalidate: false,
proxy_revalidate: false,
has_validator: false,
hits: 0,
transformed: false,
sequence: 1,
generation: 1,
skip_compression: false,
requires_revalidation: false,
pinned_reads: 1,
};
cache
.cold
.lock()
.unwrap()
.entries
.insert(key, template.clone());
let old_guard = ColdReadGuard {
cache: &cache,
key,
generation: 1,
};
let mut replacement = template;
replacement.sequence = 2;
replacement.generation = 2;
replacement.pinned_reads = 1;
cache.cold.lock().unwrap().entries.insert(key, replacement);
drop(old_guard);
let cold = cache.cold.lock().unwrap();
let replacement = cold.entries.get(&key).unwrap();
assert_eq!(replacement.generation, 2);
assert_eq!(replacement.pinned_reads, 1);
}
#[test]
fn cold_path_cannot_escape_data_directory() {
let mut settings = Settings::default();
settings.cold_path = "../outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "/tmp/outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "cache/cold".into();
assert!(settings.validate().is_ok());
}
#[tokio::test]
async fn cold_scope_fingerprint_preserves_unmodified_vhost_and_rejects_stale_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let mut fingerprints = HashMap::new();
fingerprints.insert("cold-test".to_owned(), 77);
let cache = Cache::start_with_scope_revisions(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
fingerprints.clone(),
);
fill(&cache, &settings, "/one", "stable-contents").await;
fill(&cache, &settings, "/two", "second-contents").await;
wait_for_cold(&cache, 1).await;
shutdown.cancel();
drop(cache);
let restart = CancellationToken::new();
let same = Cache::start_with_scope_revisions(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart.clone(),
fingerprints.clone(),
);
assert_eq!(same.view().cold_entries, 1, "operational saves retain COLD");
restart.cancel();
drop(same);
fingerprints.insert("cold-test".to_owned(), 78);
let restart = CancellationToken::new();
let changed = Cache::start_with_scope_revisions(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart.clone(),
fingerprints,
);
assert_eq!(
changed.view().cold_entries,
0,
"old COLD must never survive a content revision"
);
restart.cancel();
}
}
+14 -12
View File
@@ -51,9 +51,13 @@ impl Cache {
.fetch_add(1, Ordering::Relaxed);
return false;
};
let scope_revision = self.scope_revisions
.lock().unwrap_or_else(|e| e.into_inner())
.get(&entry.scope).copied().unwrap_or(0);
let scope_revision = self
.scope_revisions
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&entry.scope)
.copied()
.unwrap_or(0);
self.cold_queue_items.fetch_add(1, Ordering::Relaxed);
if tx
.try_send(ColdCommand::Store(ColdDraft {
@@ -138,9 +142,6 @@ impl Cache {
drop(store);
release_allocator_memory();
}
/// Best-effort graceful shutdown handoff from RAM to the persistent cold
/// tier. The cold worker shares the process shutdown token, so this path
/// writes directly instead of depending on the background queue.
async fn flush_hot_to_cold_inner(&self, force_shutdown_policy: bool) -> usize {
let settings = self
.tier_settings
@@ -190,9 +191,13 @@ impl Cache {
}),
}
});
let scope_revision = self.scope_revisions
.lock().unwrap_or_else(|e| e.into_inner())
.get(&entry.scope).copied().unwrap_or(0);
let scope_revision = self
.scope_revisions
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&entry.scope)
.copied()
.unwrap_or(0);
ColdDraft {
key,
revision,
@@ -215,9 +220,6 @@ impl Cache {
pub async fn flush_hot_to_cold(&self) -> usize {
self.flush_hot_to_cold_inner(false).await
}
/// Managed software updates explicitly request a hot-to-cold handoff. The
/// cold tier must still be enabled, but the ordinary cold_on_shutdown toggle
/// does not disable update preservation.
pub async fn flush_hot_to_cold_for_update(&self) -> usize {
self.flush_hot_to_cold_inner(true).await
}
-2
View File
@@ -27,8 +27,6 @@ impl Cache {
return;
};
// v0.21 intentionally does not migrate the legacy flat cold-cache layout.
// Remove only known cache artifacts from the old root and rebuild naturally.
let mut shard_dirs = Vec::new();
for item in read_dir.flatten() {
let path = item.path();
-5
View File
@@ -29,9 +29,6 @@ impl Cache {
loop {
let command = tokio::select! {
_ = shutdown.cancelled() => {
// The listener shutdown token is shared with the cache. Drain
// already accepted demotions before exiting so a restart does
// not discard buffers that were already handed to cold storage.
rx.close();
while let Ok(command) = rx.try_recv() {
let Some(cache) = weak.upgrade() else { return; };
@@ -58,8 +55,6 @@ impl Cache {
self.cold_write_bytes_active
.fetch_sub(bytes, Ordering::Relaxed);
self.cold_gc_pass().await;
// The draft body is out of scope before trimming. `Bytes` has released its
// reference and glibc is explicitly asked to return releasable pages.
release_allocator_memory();
}
async fn persist_cold_inner(&self, draft: ColdDraft) -> bool {
+10 -34
View File
@@ -36,10 +36,7 @@ impl Drop for CollapseWaiterGuard {
impl Drop for FillLeaderGuard {
fn drop(&mut self) {
if let Some(cache) = self.cache.upgrade() {
let mut flights = cache
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut flights = cache.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
let current = flights
.get(&self.key)
.is_some_and(|flight| flight.id == self.id);
@@ -47,25 +44,17 @@ impl Drop for FillLeaderGuard {
flights.remove(&self.key);
}
}
// Publish completion even when a purge already removed this flight from the map.
self.done.send_replace(true);
}
}
impl Cache {
pub async fn claim_fill(
self: &Arc<Self>,
ticket: &Ticket,
settings: &Settings,
) -> FillClaim {
pub async fn claim_fill(self: &Arc<Self>, ticket: &Ticket, settings: &Settings) -> FillClaim {
if !settings.collapse_enabled {
return FillClaim::Disabled;
}
let existing = {
let mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
if let Some(flight) = flights.get(&ticket.key) {
Some(flight.clone())
} else {
@@ -96,8 +85,6 @@ impl Cache {
let Some(flight) = existing else {
unreachable!("existing fill resolved above")
};
// A watch channel retains the completed state, so a leader finishing between
// map lookup and follower polling cannot lose the wakeup.
let mut done = flight.done.subscribe();
self.collapse_waiters.fetch_add(1, Ordering::Relaxed);
let _waiter = CollapseWaiterGuard {
@@ -131,11 +118,11 @@ impl Cache {
fn cancel_all_fill_flights(&self) {
let flights = {
let mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
flights.drain().map(|(_, flight)| flight).collect::<Vec<_>>()
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
flights
.drain()
.map(|(_, flight)| flight)
.collect::<Vec<_>>()
};
for flight in flights {
flight.done.send_replace(true);
@@ -144,10 +131,7 @@ impl Cache {
fn cancel_scope_fill_flights(&self, scope: &str) {
let removed = {
let mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
let keys = flights
.iter()
.filter(|(_, flight)| flight.scope == scope)
@@ -210,9 +194,6 @@ impl Cache {
.proactive_scheduled_keys
.lock()
.unwrap_or_else(|e| e.into_inner());
// Each scheduled key owns a sleeping Tokio task plus refresh context.
// Bound the pending set independently from the cache entry count so a
// large cache with long TTLs cannot turn into thousands of resident tasks.
let pending_limit = settings
.proactive_refresh_max_inflight
.saturating_mul(64)
@@ -250,12 +231,7 @@ impl Cache {
}
if self
.proactive_inflight
.compare_exchange_weak(
current,
current + 1,
Ordering::AcqRel,
Ordering::Relaxed,
)
.compare_exchange_weak(current, current + 1, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
self.counters
+21 -10
View File
@@ -99,14 +99,22 @@ impl Cache {
("bans_removed_total", "counter", view.bans_removed),
("ban_matches_total", "counter", view.ban_matches),
("active_bans", "gauge", view.active_bans as u64),
("proactive_scheduled_total", "counter", view.proactive_scheduled),
(
"proactive_scheduled_total",
"counter",
view.proactive_scheduled,
),
("proactive_started_total", "counter", view.proactive_started),
("proactive_skipped_total", "counter", view.proactive_skipped),
("proactive_inflight", "gauge", view.proactive_inflight),
("entries", "gauge", view.entries as u64),
("resident_bytes", "gauge", view.resident_bytes as u64),
("hot_logical_bytes", "gauge", view.hot_logical_bytes as u64),
("hot_allocated_bytes", "gauge", view.hot_allocated_bytes as u64),
(
"hot_allocated_bytes",
"gauge",
view.hot_allocated_bytes as u64,
),
("hot_limit_bytes", "gauge", view.hot_limit_bytes as u64),
(
"transient_limit_bytes",
@@ -121,7 +129,11 @@ impl Cache {
("cold_entries", "gauge", view.cold_entries as u64),
("cold_bytes", "gauge", view.cold_bytes),
("cold_limit_bytes", "gauge", view.cold_limit_bytes),
("cold_limit_entries", "gauge", view.cold_limit_entries as u64),
(
"cold_limit_entries",
"gauge",
view.cold_limit_entries as u64,
),
("cold_hits_total", "counter", view.cold_hits),
("cold_promotions_total", "counter", view.cold_promotions),
("hot_demotions_total", "counter", view.hot_demotions),
@@ -161,7 +173,6 @@ impl Cache {
pub fn bypass(&self) {
self.counters.bypasses.fetch_add(1, Ordering::Relaxed);
}
/// Clear all keys and cancel publication of older in-flight fills.
pub fn purge(&self, revision: Option<u64>) -> usize {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let mut count = store.entries.len();
@@ -198,11 +209,11 @@ impl Cache {
let _ = tx.try_send(ColdCommand::PurgeDirectory(root));
}
self.counters.purges.fetch_add(1, Ordering::Relaxed);
self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed);
self.counters
.purge_entries
.fetch_add(count as u64, Ordering::Relaxed);
count
}
/// Purge one vhost without evicting other hosts. Generation bumps also stop
/// an already streaming MISS from repopulating the just-purged scope.
pub fn purge_scope(&self, scope: &str) -> usize {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let keys: Vec<_> = store
@@ -239,7 +250,9 @@ impl Cache {
let _ = tx.try_send(ColdCommand::Delete(delete));
}
self.counters.purges.fetch_add(1, Ordering::Relaxed);
self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed);
self.counters
.purge_entries
.fetch_add(count as u64, Ordering::Relaxed);
count
}
pub fn scope_view(&self, scope: &str) -> ScopeView {
@@ -303,8 +316,6 @@ impl Cache {
continue;
};
let age = e.initial_age.saturating_add(e.received.elapsed()).as_secs();
// This is an inventory of retained objects, not a freshness lookup.
// Expired representations still occupy RAM and may be revalidated.
if !scope.is_none_or(|s| s == e.scope) {
continue;
}
@@ -91,9 +91,14 @@ mod dashboard_tests {
assert!(dashboard.objects[0].age_seconds >= 2 * 60 * 60);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().expired, 0);
assert!(cache.get(&ticket).is_none(), "inventory visibility cannot permit unconditional stale delivery");
assert!(
cache.get(&ticket).is_none(),
"inventory visibility cannot permit unconditional stale delivery"
);
let mut stale = ticket;
stale.request_headers.insert("cache-control", HeaderValue::from_static("max-stale"));
stale
.request_headers
.insert("cache-control", HeaderValue::from_static("max-stale"));
assert!(cache.get(&stale).unwrap().stale);
}
#[test]
@@ -144,13 +149,22 @@ mod dashboard_tests {
};
let mut headers = HeaderMap::new();
headers.insert("x-cache", HeaderValue::from_static("untrusted-upstream"));
headers.insert("cache-status", HeaderValue::from_static("untrusted-upstream"));
headers.insert(
"cache-status",
HeaderValue::from_static("untrusted-upstream"),
);
global.annotate_reason(&mut headers, "MISS", 0, 0, "response_headers_policy");
global.annotate_transformed(&mut headers, true);
for name in ["x-cache", "x-cache-hits", "cache-status", "x-cache-reason", "x-cache-resident-age"] {
for name in [
"x-cache",
"x-cache-hits",
"cache-status",
"x-cache-reason",
"x-cache-resident-age",
] {
assert!(!headers.contains_key(name), "{name} leaked on MISS");
}
assert_eq!(headers["via"], "RustProxy"); // independent proxy policy
assert_eq!(headers["via"], "RustProxy");
global.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie");
assert!(!headers.contains_key("cache-status"));
global.annotate_reason(&mut headers, "HIT", 4, 60, "cache_hit");
@@ -159,11 +173,19 @@ mod dashboard_tests {
assert_eq!(headers["x-cache-hits"], "4");
assert!(headers["cache-status"].to_str().unwrap().contains("; hit;"));
assert_eq!(headers["x-cache-resident-age"], "10");
let mut override_on = Overrides { expose_only_hits: Some(false), ..Default::default() };
let mut override_on = Overrides {
expose_only_hits: Some(false),
..Default::default()
};
let normal = override_on.resolve(&global).unwrap();
assert!(!normal.expose_only_hits);
override_on.expose_only_hits = Some(true);
assert!(override_on.resolve(&Settings::default()).unwrap().expose_only_hits);
assert!(
override_on
.resolve(&Settings::default())
.unwrap()
.expose_only_hits
);
}
#[test]
fn via_header_is_configurable_and_preserves_upstream_chain() {
@@ -178,7 +200,10 @@ mod dashboard_tests {
.collect();
assert_eq!(values, vec!["1.1 upstream", "RustProxy"]);
let disabled = Settings { via_header: None, ..Settings::default() };
let disabled = Settings {
via_header: None,
..Settings::default()
};
let mut headers = HeaderMap::new();
disabled.annotate(&mut headers, "MISS", 0, 0);
assert!(!headers.contains_key("via"));
@@ -218,7 +243,6 @@ mod dashboard_tests {
#[test]
fn cache_domain_metrics_do_not_lock_hot_store_and_preserve_reasons() {
let cache = Cache::new(7);
// A BYPASS must be recordable even while the HOT/LRU mutex is held.
let store = cache.store.lock().unwrap();
cache.record("alpha", "a.example", "BYPASS", "cache_disabled");
cache.record_reason("alpha", "a.example", "background_refresh_failure");
@@ -247,7 +271,12 @@ mod dashboard_tests {
assert_eq!(counters.reasons["cache_hit"], workers * each);
assert_eq!(counters.reasons["background_refresh_failure"], 1);
assert_eq!(cache.purge_scope("alpha"), 0);
assert_eq!(cache.dashboard(Some("alpha"), 0, 1).domains[0].counters.hits, workers * each);
assert_eq!(
cache.dashboard(Some("alpha"), 0, 1).domains[0]
.counters
.hits,
workers * each
);
cache.purge(Some(8));
assert!(cache.dashboard(None, 0, 1).domains.is_empty());
cache.record("alpha", "a.example", "MISS", "cache_miss");
@@ -260,7 +289,9 @@ mod dashboard_tests {
#[test]
fn first_hit_saved_bytes_registers_domain_even_without_prior_record() {
let cache = Cache::new(7);
cache.domain_stats.saved_body_hit("cold", "restored.example", 1040);
cache
.domain_stats
.saved_body_hit("cold", "restored.example", 1040);
let result = cache.dashboard(Some("cold"), 0, 1);
assert_eq!(result.domains.len(), 1);
assert_eq!(result.domains[0].counters.saved_body_bytes, 1040);
+24 -37
View File
@@ -63,8 +63,6 @@ struct Candidate {
plan: Plan,
headers: HeaderMap,
data: Vec<u8>,
// A transformed response with one uniquely owned Bytes frame can share its
// allocation with the downstream response, avoiding an extra Vec copy.
shared: Option<Bytes>,
shared_allocated: usize,
fill_gauge: FillGauge,
@@ -78,11 +76,9 @@ struct Candidate {
}
impl Candidate {
fn finish(mut self) {
if self
.plan
.expected_length
.is_some_and(|length| self.shared.as_ref().map_or(self.data.len(), Bytes::len) != length)
{
if self.plan.expected_length.is_some_and(|length| {
self.shared.as_ref().map_or(self.data.len(), Bytes::len) != length
}) {
let reason = if self.transformed {
"transform_length_mismatch"
} else {
@@ -101,7 +97,6 @@ impl Candidate {
self.headers.insert("date", value);
}
}
// Each downstream exchange receives a new ID, never a cached origin ID.
for name in [
"x-request-id",
"x-cache",
@@ -113,9 +108,6 @@ impl Candidate {
self.headers.remove(name);
}
let vary = std::mem::take(&mut self.plan.vary);
// Keep the actual backing capacity as the HOT charge. Vec::reserve_exact
// avoids geometric multi-MiB overshoot while Bytes::from(Vec) transfers
// the allocation without copying.
let (body, body_allocation) = if let Some(body) = self.shared.take() {
(body, self.shared_allocated)
} else {
@@ -201,10 +193,6 @@ impl HttpBody for FillBody {
match Pin::new(&mut this.inner).poll_frame(cx) {
Poll::Ready(Some(Ok(frame))) => {
let mut frame = frame;
// Only the transformed single-frame case is eligible. Taking
// unique ownership lets Bytes reveal its true allocation
// capacity; we must never undercharge an arbitrary shared
// Bytes slice that could retain a much larger parent buffer.
if this.inner.is_end_stream() {
if let Some(candidate) = &mut this.candidate {
let single_frame = candidate.transformed
@@ -216,26 +204,26 @@ impl HttpBody for FillBody {
});
if single_frame {
match frame.into_data() {
Ok(bytes) => {
match bytes.try_into_mut() {
Ok(mutable) => {
let allocation = mutable.capacity();
let bytes = mutable.freeze();
// A very oversized backing Vec is cheaper to
// copy into a smaller HOT allocation instead.
let acceptable_spare = bytes.len().saturating_div(8).min(64 * 1024);
if allocation <= bytes.len().saturating_add(acceptable_spare)
&& allocation <= candidate.settings.max_object_bytes
&& candidate.fill_gauge.grow(allocation, &candidate.settings)
{
candidate.shared = Some(bytes.clone());
candidate.shared_allocated = allocation;
}
frame = Frame::data(bytes);
Ok(bytes) => match bytes.try_into_mut() {
Ok(mutable) => {
let allocation = mutable.capacity();
let bytes = mutable.freeze();
let acceptable_spare =
bytes.len().saturating_div(8).min(64 * 1024);
if allocation
<= bytes.len().saturating_add(acceptable_spare)
&& allocation <= candidate.settings.max_object_bytes
&& candidate
.fill_gauge
.grow(allocation, &candidate.settings)
{
candidate.shared = Some(bytes.clone());
candidate.shared_allocated = allocation;
}
Err(bytes) => frame = Frame::data(bytes),
frame = Frame::data(bytes);
}
}
Err(bytes) => frame = Frame::data(bytes),
},
Err(original) => frame = original,
}
}
@@ -243,7 +231,6 @@ impl HttpBody for FillBody {
}
if let Some(candidate) = &mut this.candidate {
if candidate.shared.is_some() {
// The cache now owns the very same backing allocation.
} else if let Some(data) = frame.data_ref() {
let needed = candidate.data.len().saturating_add(data.len());
if needed <= candidate.settings.max_object_bytes {
@@ -284,7 +271,8 @@ impl HttpBody for FillBody {
this.candidate = None;
} else {
let after = candidate.data.capacity();
let unbudgeted = after.saturating_sub(before.saturating_add(required_growth));
let unbudgeted =
after.saturating_sub(before.saturating_add(required_growth));
if unbudgeted > 0
&& !candidate.fill_gauge.grow(unbudgeted, &candidate.settings)
{
@@ -328,7 +316,7 @@ impl HttpBody for FillBody {
.rejected_fills
.fetch_add(1, Ordering::Relaxed);
this.candidate = None;
} // Do not cache responses with trailers.
}
}
if this.inner.is_end_stream() {
this.done = true;
@@ -391,4 +379,3 @@ impl Drop for FillBody {
}
}
}
@@ -4,13 +4,20 @@ mod http_invalidation_tests {
fn default_deny_and_strict_ipv4_ipv6_cidr_validation() {
let mut cache = Settings::default();
assert!(!cache.permits_http_invalidation("127.0.0.1".parse().unwrap()));
cache.http_invalidation_allowed_cidrs = vec!["192.0.2.42".into(), "2001:db8:10::/64".into()];
cache.http_invalidation_allowed_cidrs =
vec!["192.0.2.42".into(), "2001:db8:10::/64".into()];
cache.validate().unwrap();
assert!(cache.permits_http_invalidation("192.0.2.42".parse().unwrap()));
assert!(!cache.permits_http_invalidation("192.0.2.43".parse().unwrap()));
assert!(cache.permits_http_invalidation("2001:db8:10::1".parse().unwrap()));
assert!(!cache.permits_http_invalidation("2001:db8:11::1".parse().unwrap()));
for invalid in ["0.0.0.0/0", "::/0", "127.0.0.1; 8.8.8.8", "not-an-ip", "10.0.0.0/33"] {
for invalid in [
"0.0.0.0/0",
"::/0",
"127.0.0.1; 8.8.8.8",
"not-an-ip",
"10.0.0.0/33",
] {
cache.http_invalidation_allowed_cidrs = vec![invalid.into()];
assert!(cache.validate().is_err(), "must reject {invalid}");
}
@@ -37,5 +44,4 @@ mod http_invalidation_tests {
assert!(!host_matches(&matcher, "http", "a.example", "a.example"));
assert!(!host_matches(&matcher, "https", "b.example", "b.example"));
}
}
+12 -19
View File
@@ -6,7 +6,8 @@ impl Cache {
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
self.ban_rule_count.store(bans.len() as u64, Ordering::Release);
self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
bans.clone()
}
@@ -17,7 +18,8 @@ impl Cache {
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
self.ban_rule_count.store(bans.len() as u64, Ordering::Release);
self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
let banned = bans.iter().any(|rule| rule.matches_hot(key, entry, now));
if banned {
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
@@ -32,7 +34,8 @@ impl Cache {
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
self.ban_rule_count.store(bans.len() as u64, Ordering::Release);
self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
let banned = bans.iter().any(|rule| rule.matches_cold(key, entry, now));
if banned {
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
@@ -47,7 +50,8 @@ impl Cache {
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
self.ban_rule_count.store(bans.len() as u64, Ordering::Release);
self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
bans.iter().any(|rule| rule.matches_hot(key, entry, now))
}
@@ -60,7 +64,8 @@ impl Cache {
let before = bans.len();
bans.retain(|rule| rule.id != id);
let removed = bans.len() != before;
self.ban_rule_count.store(bans.len() as u64, Ordering::Release);
self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
if removed {
self.counters.bans_removed.fetch_add(1, Ordering::Relaxed);
}
@@ -97,7 +102,8 @@ impl Cache {
created_at: now,
expires_at: now.checked_add(Duration::from_secs(ttl)).unwrap_or(now),
});
self.ban_rule_count.store(bans.len() as u64, Ordering::Release);
self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
self.counters.bans_created.fetch_add(1, Ordering::Relaxed);
Ok(InvalidationResult {
operation: request.operation,
@@ -112,22 +118,9 @@ impl Cache {
})
}
InvalidationOperation::Purge => {
// A selective purge is also a publication barrier for every fill that
// started before this operation. Existing unrelated objects remain
// reusable, but an old in-flight response cannot repopulate a target
// that was not yet identifiable (for example Cache-Tag before headers).
{
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
store.generation = store.generation.wrapping_add(1);
// This global publication barrier invalidates every older
// ticket and pending COLD demotion. Per-key generations only
// need to cover the current purge, not every key ever
// purged over the lifetime of the process. Without clearing
// them, repeated PURGEs of distinct URLs grow this map
// indefinitely even after the objects have been evicted.
// Release an unusually large index's backing allocation
// after mass invalidations instead of retaining its peak
// bucket capacity for the rest of the process lifetime.
if store.key_generations.capacity() > 256 {
store.key_generations = Default::default();
} else {
+36 -41
View File
@@ -111,7 +111,9 @@ impl Cache {
let weak = Arc::downgrade(&cache);
tokio::spawn(async move {
loop {
let Some(cache) = weak.upgrade() else { break; };
let Some(cache) = weak.upgrade() else {
break;
};
let interval = cache
.tier_settings
.lock()
@@ -130,9 +132,6 @@ impl Cache {
cache
}
fn fill_headroom_bytes(max_bytes: usize) -> usize {
// Allocation rounding is allowed a small global cushion, never a whole
// max_object allocation. Use roughly 1/64 of HOT, cap it at 1 MiB and
// never let the cushion itself exceed 1/8 of a very small HOT budget.
let proportional = (max_bytes / 64).clamp(4 * 1024, 1024 * 1024);
proportional.min((max_bytes / 8).max(1))
}
@@ -164,17 +163,14 @@ impl Cache {
}
}
/// Reserve real allocation for an in-progress fill before growing its buffer.
/// Existing HOT objects are LRU-evicted/demoted first so HOT + active fill
/// allocation cannot race arbitrarily above the configured HOT budget.
fn reserve_fill_allocation(&self, bytes: usize, settings: &Settings) -> Option<TransientLease> {
if bytes == 0 {
return self.try_reserve_transient(0, settings);
}
let lease = self.try_reserve_transient(bytes, settings)?;
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let fill_now = usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed))
.unwrap_or(usize::MAX);
let fill_now =
usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed)).unwrap_or(usize::MAX);
let allowed = settings
.max_bytes
.saturating_add(Self::fill_headroom_bytes(settings.max_bytes));
@@ -182,20 +178,14 @@ impl Cache {
let generation = store.generation;
let mut removed = 0usize;
let mut released_hot = false;
while store
.bytes
.saturating_add(fill_now)
.saturating_add(bytes)
> allowed
{
while store.bytes.saturating_add(fill_now).saturating_add(bytes) > allowed {
if removed >= settings.hot_eviction_max_objects {
self.counters
.hot_eviction_limited
.fetch_add(1, Ordering::Relaxed);
break;
}
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else {
break;
};
let Some(old) = store.remove(&key) else {
@@ -227,11 +217,7 @@ impl Cache {
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
}
let fits = store
.bytes
.saturating_add(fill_now)
.saturating_add(bytes)
<= allowed;
let fits = store.bytes.saturating_add(fill_now).saturating_add(bytes) <= allowed;
if fits {
self.fill_bytes_active
.fetch_add(bytes as u64, Ordering::Relaxed);
@@ -251,10 +237,6 @@ impl Cache {
}
}
/// Bound long-lived HOT RSS without flushing the working set. Only entries
/// idle for the configured age are considered, and only while HOT usage is
/// above the configured target waterline.
fn trim_hot_idle(&self) {
let settings = self
.tier_settings
@@ -285,17 +267,31 @@ impl Cache {
.get(&key)
.is_some_and(|entry| entry.last_access.elapsed() >= idle_cutoff);
if !idle {
// LRU order is also last-access order, so newer entries cannot qualify.
break;
}
let Some(entry) = store.remove(&key) else { continue; };
let Some(entry) = store.remove(&key) else {
continue;
};
removed += 1;
let scope_generation = store.scope_generations.get(&entry.scope).copied().unwrap_or(0);
let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0);
let scope_generation = store
.scope_generations
.get(&entry.scope)
.copied()
.unwrap_or(0);
let key_generation = store
.key_generations
.get(&entry.base_key)
.copied()
.unwrap_or(0);
if !(settings.cold_enabled
&& settings.cold_on_lru
&& self.enqueue_demotion(
key, entry, revision, generation, scope_generation, key_generation,
key,
entry,
revision,
generation,
scope_generation,
key_generation,
))
{
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
@@ -307,29 +303,28 @@ impl Cache {
}
}
/// Block requests bound to an old immutable routing snapshot from creating
/// cache tickets during a concurrent configuration publication. This does
/// not clear HOT or COLD and does not modify the representation revision.
pub fn set_control_revision(&self, revision: u64) {
self.store.lock().unwrap_or_else(|e| e.into_inner()).control_revision = revision;
self.store
.lock()
.unwrap_or_else(|e| e.into_inner())
.control_revision = revision;
}
pub fn replace_scope_revisions(&self, scopes: HashMap<String, u64>) {
*self.scope_revisions.lock().unwrap_or_else(|e| e.into_inner()) = scopes;
*self
.scope_revisions
.lock()
.unwrap_or_else(|e| e.into_inner()) = scopes;
}
pub fn configure(&self, settings: Settings) {
*self.tier_settings.lock().unwrap_or_else(|e| e.into_inner()) = settings.clone();
// Hot-applied limit reductions take effect immediately for published HOT
// objects. Demotion is best effort and remains bounded by transient/COLD
// queue budgets; otherwise the LRU object is dropped.
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let revision = store.revision;
let generation = store.generation;
let mut released_hot = false;
while store.entries.len() > settings.max_entries || store.bytes > settings.max_bytes {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else {
break;
};
let Some(old) = store.remove(&key) else {
+30 -15
View File
@@ -1,20 +1,22 @@
impl Cache {
fn get_hot(&self, ticket: &Ticket, count_miss: bool) -> Option<Hit> {
// Only metadata, hit counters and exact LRU ordering require the store
// mutex. Materializing an HTTP response may allocate (headers, Body,
// Age formatting) and must be done *after* dropping that mutex.
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
if store.revision != ticket.revision
|| store.generation != ticket.generation
|| store.scope_generations.get(&ticket.scope).copied().unwrap_or(0)
|| store
.scope_generations
.get(&ticket.scope)
.copied()
.unwrap_or(0)
!= ticket.scope_generation
|| store.key_generations.get(&ticket.key).copied().unwrap_or(0)
!= ticket.key_generation
|| store.key_generations.get(&ticket.key).copied().unwrap_or(0) != ticket.key_generation
{
return None;
}
if let Some(storage_key) = matching_hot_key(&store, ticket) {
let banned = store.entries.get(&storage_key)
let banned = store
.entries
.get(&storage_key)
.is_some_and(|entry| self.hot_entry_banned(&storage_key, entry));
if banned {
store.remove(&storage_key);
@@ -27,7 +29,10 @@ impl Cache {
}
let entry = store.entries.get(&storage_key)?;
let resident_age_seconds = hot_resident_age(entry).as_secs();
let age = entry.initial_age.saturating_add(entry.received.elapsed()).as_secs();
let age = entry
.initial_age
.saturating_add(entry.received.elapsed())
.as_secs();
let state = cache_state(
age,
entry.ttl,
@@ -39,8 +44,16 @@ impl Cache {
);
if state.servable() {
let sequence = store.next_sequence();
let (old_sequence, headers, body, hits, transformed, skip_compression,
remaining_ttl, body_bytes) = {
let (
old_sequence,
headers,
body,
hits,
transformed,
skip_compression,
remaining_ttl,
body_bytes,
) = {
let entry = store.entries.get_mut(&storage_key)?;
let old_sequence = entry.sequence;
entry.hits = entry.hits.saturating_add(1);
@@ -59,15 +72,15 @@ impl Cache {
};
store.lru.remove(&old_sequence);
store.lru.insert(sequence, storage_key);
// Preserve the exact LRU and domain-byte counters, without
// allocating a replacement variant index or String keys.
self.counters.hits.fetch_add(1, Ordering::Relaxed);
drop(store);
let mut response = Response::new(Body::from(body.clone()));
*response.headers_mut() = headers.as_ref().clone();
if transformed {
response.extensions_mut().insert(crate::transform::CachedTransformed);
response
.extensions_mut()
.insert(crate::transform::CachedTransformed);
}
if skip_compression {
response.extensions_mut().insert(crate::compression::Skip);
@@ -76,7 +89,8 @@ impl Cache {
"age",
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
self.domain_stats.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes);
self.domain_stats
.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes);
return Some(Hit {
response,
body,
@@ -274,7 +288,8 @@ impl Cache {
"age",
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
self.domain_stats.saved_body_hit(&ticket.scope, &ticket.domain, entry.body_bytes);
self.domain_stats
.saved_body_hit(&ticket.scope, &ticket.domain, entry.body_bytes);
self.counters.hits.fetch_add(1, Ordering::Relaxed);
self.counters.cold_hits.fetch_add(1, Ordering::Relaxed);
self.counters
@@ -3,7 +3,10 @@ mod hot_hit_034_tests {
use http_body_util::BodyExt;
fn settings() -> Settings {
Settings { enabled: true, ..Settings::default() }
Settings {
enabled: true,
..Settings::default()
}
}
async fn publish(
@@ -13,16 +16,25 @@ mod hot_hit_034_tests {
body: &'static str,
transformed: bool,
) -> Ticket {
let ticket = cache.ticket_scoped(
34, "app", "http", "cache.example", "origin@127.0.0.1:8080",
path, &HeaderMap::new(),
).expect("cache ticket");
let ticket = cache
.ticket_scoped(
34,
"app",
"http",
"cache.example",
"origin@127.0.0.1:8080",
path,
&HeaderMap::new(),
)
.expect("cache ticket");
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
"cache-control", HeaderValue::from_static("public, max-age=31536000"),
"cache-control",
HeaderValue::from_static("public, max-age=31536000"),
);
response.headers_mut().insert(
"etag", HeaderValue::from_static("\"cached-representation\""),
"etag",
HeaderValue::from_static("\"cached-representation\""),
);
if transformed {
response.extensions_mut().insert(crate::transform::Applied {
@@ -33,8 +45,12 @@ mod hot_hit_034_tests {
}
let policy = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO)
.expect("public static object cacheable");
cache.wrap(response, ticket.clone(), policy, settings)
.into_body().collect().await.unwrap();
cache
.wrap(response, ticket.clone(), policy, settings)
.into_body()
.collect()
.await
.unwrap();
ticket
}
@@ -52,12 +68,21 @@ mod hot_hit_034_tests {
let hit = cache.get(&ticket).expect("HOT cache hit");
assert_eq!(hit.hits, i);
assert!(hit.transformed);
assert!(hit.response.extensions().get::<crate::transform::CachedTransformed>().is_some());
assert!(hit
.response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some());
assert_eq!(hit.response.headers()["etag"], "\"cached-representation\"");
let mut independent = hit.response;
independent.headers_mut().insert("etag", HeaderValue::from_static("\"client-only\""));
independent
.headers_mut()
.insert("etag", HeaderValue::from_static("\"client-only\""));
if i == 500 {
assert_eq!(independent.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"body{color:red}"));
assert_eq!(
independent.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"body{color:red}")
);
}
}
let after = cache.view();
@@ -73,7 +98,10 @@ mod hot_hit_034_tests {
let dashboard = cache.dashboard(Some("app"), 0, 10);
assert_eq!(dashboard.objects[0].hits, 500);
assert_eq!(dashboard.domains[0].counters.saved_body_bytes, 500 * 15);
assert_eq!(cache.get(&ticket).unwrap().response.headers()["etag"], "\"cached-representation\"");
assert_eq!(
cache.get(&ticket).unwrap().response.headers()["etag"],
"\"cached-representation\""
);
}
#[tokio::test]
@@ -96,9 +124,15 @@ mod hot_hit_034_tests {
hits
}));
}
let mut counts: Vec<_> = threads.into_iter().flat_map(|t| t.join().unwrap()).collect();
let mut counts: Vec<_> = threads
.into_iter()
.flat_map(|t| t.join().unwrap())
.collect();
counts.sort_unstable();
assert_eq!(counts, (1..=(workers * per_worker) as u64).collect::<Vec<_>>());
assert_eq!(
counts,
(1..=(workers * per_worker) as u64).collect::<Vec<_>>()
);
assert_eq!(cache.view().hits, (workers * per_worker) as u64);
let store = cache.store.lock().unwrap();
assert_eq!(store.lru.len(), 1);
@@ -108,7 +142,10 @@ mod hot_hit_034_tests {
#[tokio::test]
async fn hot_lru_eviction_and_ban_are_preserved() {
let cache = Cache::new(34);
let settings = Settings { max_entries: 2, ..settings() };
let settings = Settings {
max_entries: 2,
..settings()
};
let a = publish(&cache, &settings, "/a.css", "AAAA", false).await;
let b = publish(&cache, &settings, "/b.css", "BBBB", false).await;
assert!(cache.get(&a).is_some(), "A becomes most recently used");
@@ -117,15 +154,17 @@ mod hot_hit_034_tests {
assert!(cache.get(&b).is_none(), "B is least recently used");
assert!(cache.get(&c).is_some());
let ban = cache.invalidate(InvalidationRequest {
operation: InvalidationOperation::Ban,
selector: InvalidationSelector::Exact,
value: "/a.css".into(),
host: Some("cache.example".into()),
vhost: Some("app".into()),
ttl_seconds: Some(3600),
confirm_wide: false,
}).unwrap();
let ban = cache
.invalidate(InvalidationRequest {
operation: InvalidationOperation::Ban,
selector: InvalidationSelector::Exact,
value: "/a.css".into(),
host: Some("cache.example".into()),
vhost: Some("app".into()),
ttl_seconds: Some(3600),
confirm_wide: false,
})
.unwrap();
assert_eq!(cache.ban_rule_count.load(Ordering::Acquire), 1);
assert!(cache.get(&a).is_none(), "BAN must block cached A");
assert!(cache.remove_ban(ban.ban_id.unwrap()));
@@ -147,8 +186,11 @@ mod hot_hit_034_tests {
std::hint::black_box(cache.get(&ticket).unwrap());
}
let duration = start.elapsed();
eprintln!("HOT lookup incl. response materialization: {:.1} ns/op ({} hits); NOT network RPS",
duration.as_secs_f64() * 1e9 / iterations as f64, iterations);
eprintln!(
"HOT lookup incl. response materialization: {:.1} ns/op ({} hits); NOT network RPS",
duration.as_secs_f64() * 1e9 / iterations as f64,
iterations
);
assert_eq!(cache.view().hits, iterations + 1_000);
}
@@ -161,12 +203,19 @@ mod hot_hit_034_tests {
store.entries.values_mut().next().unwrap().received =
Instant::now() - Duration::from_secs(700_000);
}
assert!(cache.get(&ticket).is_none(), "expired freshness is not an unconditional HIT");
assert_eq!(cache.view().entries, 1, "stale representation must survive for revalidation");
let mut stale_ticket = ticket;
stale_ticket.request_headers.insert(
"cache-control", HeaderValue::from_static("max-stale"),
assert!(
cache.get(&ticket).is_none(),
"expired freshness is not an unconditional HIT"
);
assert_eq!(
cache.view().entries,
1,
"stale representation must survive for revalidation"
);
let mut stale_ticket = ticket;
stale_ticket
.request_headers
.insert("cache-control", HeaderValue::from_static("max-stale"));
assert!(cache.get(&stale_ticket).unwrap().stale);
}
}
-3
View File
@@ -44,9 +44,6 @@ impl Cache {
{
return false;
}
// Do not keep objects for incompatible Vary schemas under the same
// Varnish-style host+URL hash. A newer representation policy replaces
// the older schema while preserving sibling values of the same Vary set.
for key in incompatible_hot_variants(&store, &ticket.key, &vary_schema) {
store.remove(&key);
}
+9 -7
View File
@@ -170,10 +170,15 @@ impl Cache {
Some(validators) => validators.last_modified.clone(),
None => headers.get("last-modified").cloned(),
};
let transformed = parts.extensions.get::<crate::transform::Applied>().is_some();
// A transformed, fully buffered body normally arrives as one owned
// frame. Do not allocate a second fill buffer before inspecting it.
let initial_capacity = if transformed { 0 } else { fill_size.min(64 * 1024) };
let transformed = parts
.extensions
.get::<crate::transform::Applied>()
.is_some();
let initial_capacity = if transformed {
0
} else {
fill_size.min(64 * 1024)
};
let Some(mut fill_gauge) = FillGauge::new(self.clone(), initial_capacity, settings) else {
self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed);
self.record_reason(&ticket.scope, &ticket.domain, "hot_memory_pressure");
@@ -205,9 +210,6 @@ impl Cache {
_permit: permit,
_fill_leader: fill_leader,
};
// A genuinely finished body (including an empty local file) may never
// be polled by Hyper. EOS is the proof here, not Content-Length alone;
// finish still enforces expected length, privacy and ticket validity.
if body.is_end_stream() {
candidate.finish();
return Response::from_parts(parts, body);
-6
View File
@@ -1,9 +1,3 @@
//! Bounded, conservative shared two-tier cache. Fresh objects live in a fast RAM
//! hot tier; optionally, idle/LRU objects are persisted in a bounded cold tier.
//! Bodies are streamed while a bounded copy is collected; only complete successful
//! bodies are published. The implementation is split by responsibility so storage,
//! policy and streaming-fill code can be reviewed independently.
include!("model.rs");
include!("stats.rs");
include!("lifecycle.rs");
+1 -24
View File
@@ -28,9 +28,7 @@ pub struct ObjectView {
pub storage_bytes: u64,
pub tier: &'static str,
pub hits: u64,
/// RFC 9111 Age, including time before the object entered this cache.
pub age_seconds: u64,
/// Total time held by RustProxy HOT/COLD; excludes any upstream CDN Age.
pub resident_age_seconds: u64,
pub remaining_ttl_seconds: u64,
}
@@ -50,11 +48,8 @@ pub struct Dashboard {
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ColdCleanupRequest {
/// Remove objects not used for at least this many seconds.
pub unused_for_seconds: Option<u64>,
/// Additionally remove up to N oldest matching objects.
pub oldest: Option<usize>,
/// Limit the selection to objects which were never served.
pub hits_zero: bool,
}
impl Default for ColdCleanupRequest {
@@ -113,7 +108,6 @@ struct Entry {
created_at: SystemTime,
headers: Arc<HeaderMap>,
body: Bytes,
/// Backing allocation retained by `body`; can exceed `body.len()`.
body_allocated: usize,
received: Instant,
last_access: Instant,
@@ -135,7 +129,7 @@ struct Entry {
requires_revalidation: bool,
}
#[allow(clippy::ptr_arg)] // String/Vec capacity is intentionally part of HOT accounting.
#[allow(clippy::ptr_arg)]
fn hot_entry_cost(
body_allocated: usize,
headers: &HeaderMap,
@@ -150,11 +144,6 @@ fn hot_entry_cost(
origin_etag: Option<&HeaderValue>,
origin_last_modified: Option<&HeaderValue>,
) -> usize {
// The public containers do not expose an exact per-entry allocator charge.
// Account their retained index memory conservatively: HOT HashMap key/control,
// the LRU BTreeMap item/node share and the variants index (including spare
// Vec<Key> capacity). This keeps `max_bytes` closer to the real retained
// allocation instead of pretending Entry/body are the only HOT owners.
let index_heap = std::mem::size_of::<Key>()
.saturating_add(std::mem::size_of::<(u64, Key)>())
.saturating_add(32)
@@ -171,8 +160,6 @@ fn hot_entry_cost(
let header_heap = headers
.iter()
.map(|(name, value)| {
// HeaderMap does not expose its bucket capacity. Charge a conservative
// per-field bucket/node allowance in addition to visible name/value bytes.
name.as_str()
.len()
.saturating_add(value.as_bytes().len())
@@ -181,7 +168,6 @@ fn hot_entry_cost(
.sum::<usize>();
body_allocated
.saturating_add(std::mem::size_of::<Entry>())
// Arc<HeaderMap> owns the actual map on a separate allocation.
.saturating_add(std::mem::size_of::<HeaderMap>())
.saturating_add(2 * std::mem::size_of::<usize>())
.saturating_add(index_heap)
@@ -207,7 +193,6 @@ struct ColdFile {
#[serde(default)]
vary: Vec<String>,
revision: u64,
/// 0 denotes legacy/unscoped entries; production vhosts require an exact match.
#[serde(default)]
scope_revision: u64,
scope: String,
@@ -284,9 +269,6 @@ struct ColdEntry {
hits: u64,
transformed: bool,
sequence: u64,
// Stable identity of this in-memory cold entry. Unlike `sequence`, this
// never changes on access, so an old async reader cannot unpin a newer
// replacement stored under the same cache key.
generation: u64,
skip_compression: bool,
requires_revalidation: bool,
@@ -311,8 +293,6 @@ struct ColdDraft {
key_generation: u64,
entry: Entry,
root: PathBuf,
/// Keeps this full in-memory object charged to the bounded transient pool
/// until the COLD write completes and the payload is dropped.
_memory_lease: TransientLease,
}
@@ -331,8 +311,6 @@ struct Store {
sequence: u64,
generation: u64,
revision: u64,
/// Current persisted administrative config revision. Distinct from cache
/// representation revision: operational saves do not evict data.
control_revision: u64,
scope_generations: HashMap<String, u64>,
key_generations: HashMap<Key, u64>,
@@ -361,7 +339,6 @@ impl Store {
Some(entry)
}
fn next_sequence(&mut self) -> u64 {
// No practical wrapping, but a wrap must not corrupt LRU bookkeeping.
if self.sequence == u64::MAX {
self.entries.clear();
self.variants.clear();
+20 -24
View File
@@ -167,7 +167,6 @@ impl Settings {
.any(|item| item.eq_ignore_ascii_case(extension))
}
pub fn annotate(&self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64) {
// Origin values must never masquerade as this proxy's diagnostics.
for name in [
"x-cache",
"x-cache-hits",
@@ -178,15 +177,15 @@ impl Settings {
] {
headers.remove(name);
}
// A vhost that explicitly disables cache must look like a normal reverse proxy.
// Keep counters internally, but never expose cache diagnostics for that response.
if !self.enabled {
return;
}
if let Some(value) = &self.via_header {
headers.append(
http::header::VIA,
value.parse::<HeaderValue>().expect("validated cache Via header"),
value
.parse::<HeaderValue>()
.expect("validated cache Via header"),
);
}
if self.expose_only_hits && outcome != "HIT" {
@@ -226,10 +225,15 @@ impl Settings {
}
}
pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) {
if !self.enabled || !self.expose_cache_status || !transformed
|| (self.expose_only_hits && !headers.get("cache-status")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.starts_with("RustProxy; hit"))) {
if !self.enabled
|| !self.expose_cache_status
|| !transformed
|| (self.expose_only_hits
&& !headers
.get("cache-status")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.starts_with("RustProxy; hit")))
{
return;
}
let base = headers
@@ -246,7 +250,6 @@ impl Settings {
headers.insert("cache-status", value);
}
}
/// Reasons are fixed internal tokens: never echo request values or secrets.
pub fn annotate_reason(
&self,
headers: &mut HeaderMap,
@@ -284,8 +287,6 @@ fn valid_relative_cache_path(value: &str) -> bool {
.all(|part| matches!(part, Component::Normal(_)))
}
/// Explicit, per-vhost-only request-side cache override. It never weakens the
/// response-side privacy guards (Set-Cookie/private/no-store/Vary rules).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct UnsafeForce {
@@ -294,7 +295,10 @@ pub struct UnsafeForce {
}
impl UnsafeForce {
pub fn validate(&self) -> Result<()> {
ensure!(self.targets.len() <= 32, "forced cache supports at most 32 target prefixes");
ensure!(
self.targets.len() <= 32,
"forced cache supports at most 32 target prefixes"
);
for target in &self.targets {
ensure!(
!target.is_empty()
@@ -305,7 +309,10 @@ impl UnsafeForce {
"forced cache targets must be 1..2048 byte origin-form path/query prefixes"
);
let path = target.split('?').next().unwrap_or(target);
ensure!(crate::acl::routing_path(path)? == path, "forced cache target path must be normalized");
ensure!(
crate::acl::routing_path(path)? == path,
"forced cache target path must be normalized"
);
}
Ok(())
}
@@ -323,8 +330,6 @@ impl UnsafeForce {
}
}
/// Per-vhost behavior, not independent memory pools. None inherits the global
/// default; the global resident-byte/entry/object ceilings remain hard limits.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Overrides {
@@ -341,15 +346,12 @@ pub struct Overrides {
pub expose_cache_status: Option<bool>,
pub expose_resident_age: Option<bool>,
pub expose_only_hits: Option<bool>,
/// Deliberately unavailable in the global cache configuration.
pub unsafe_force: UnsafeForce,
}
impl Overrides {
pub fn resolve(&self, global: &Settings) -> Result<Settings> {
self.unsafe_force.validate()?;
let mut result = global.clone();
// A vhost may narrow the global policy, but can never re-enable a cache
// that the administrator disabled globally.
result.enabled = global.enabled && self.enabled.unwrap_or(true);
if let Some(value) = self.default_ttl_seconds {
result.default_ttl_seconds = value;
@@ -444,7 +446,6 @@ pub struct View {
pub hot_eviction_limited: u64,
pub purges: u64,
pub entries: usize,
/// Compatibility alias for HOT allocated/charged bytes.
pub resident_bytes: usize,
pub hot_logical_bytes: usize,
pub hot_allocated_bytes: usize,
@@ -474,16 +475,11 @@ pub struct View {
pub proactive_skipped: u64,
pub proactive_inflight: u64,
pub fills_active: usize,
/// Bytes currently allocated by in-progress response cache fills.
pub fill_bytes_active: u64,
/// Demoted objects accepted by the bounded cold-writer queue.
pub cold_queue_items: u64,
pub cold_queue_bytes: u64,
/// Body bytes currently owned by the cold writer after leaving the queue.
pub cold_write_bytes_active: u64,
/// Full cold bodies currently read into RAM for promotion.
pub cold_read_bytes_active: u64,
/// RustProxy-accounted cache RAM outside the published hot store.
pub transient_bytes: u64,
pub hit_ratio: f64,
}
+10 -5
View File
@@ -1,11 +1,11 @@
// A strict, human-editable IP/CIDR policy. The caller supplies only a client IP
// produced by the existing forwarding trust boundary, never an arbitrary header.
fn parse_invalidation_cidr(value: &str) -> anyhow::Result<ipnet::IpNet> {
let value = value.trim();
if let Ok(ip) = value.parse::<std::net::IpAddr>() {
return Ok(ipnet::IpNet::from(ip));
}
value.parse::<ipnet::IpNet>().map_err(|_| anyhow::anyhow!("invalid HTTP invalidation IP/CIDR: {value}"))
value
.parse::<ipnet::IpNet>()
.map_err(|_| anyhow::anyhow!("invalid HTTP invalidation IP/CIDR: {value}"))
}
impl Settings {
fn validate_http_invalidation_allowlist(&self) -> anyhow::Result<()> {
@@ -17,7 +17,10 @@ impl Settings {
for input in &self.http_invalidation_allowed_cidrs {
ensure!(input.len() <= 64, "HTTP invalidation address too long");
let net = parse_invalidation_cidr(input)?;
ensure!(net.prefix_len() > 0, "HTTP invalidation cannot authorize the entire Internet");
ensure!(
net.prefix_len() > 0,
"HTTP invalidation cannot authorize the entire Internet"
);
ensure!(networks.insert(net), "duplicate HTTP invalidation IP/CIDR");
}
Ok(())
@@ -25,7 +28,9 @@ impl Settings {
pub fn permits_http_invalidation(&self, client: std::net::IpAddr) -> bool {
let client = crate::vhost::canonical_ip(client);
self.http_invalidation_allowed_cidrs.iter().any(|value| {
parse_invalidation_cidr(value).ok().is_some_and(|network| network.contains(&client))
parse_invalidation_cidr(value)
.ok()
.is_some_and(|network| network.contains(&client))
})
}
}
+5 -9
View File
@@ -7,8 +7,6 @@ impl ColdStore {
}
fn next_sequence(&mut self) -> u64 {
if self.sequence == u64::MAX {
// Practically unreachable. Prefer dropping the in-memory index over
// risking ambiguous LRU ordering after wrap.
self.lru.clear();
self.entries.clear();
self.variants.clear();
@@ -56,7 +54,6 @@ pub struct Cache {
revision: AtomicU64,
cold: Arc<Mutex<ColdStore>>,
tier_settings: Mutex<Settings>,
/// Per-vhost content compatibility used by persisted COLD metadata.
scope_revisions: Mutex<HashMap<String, u64>>,
data_root: Option<PathBuf>,
cold_tx: Option<mpsc::Sender<ColdCommand>>,
@@ -143,8 +140,6 @@ pub struct Revalidation {
headers: HeaderMap,
body: Bytes,
body_allocated: usize,
// COLD revalidation bodies remain charged to transient RAM until every
// cloned revalidation handle is dropped or promoted. HOT revalidations use none.
_transient_leases: Vec<TransientLease>,
hits: u64,
transformed: bool,
@@ -171,9 +166,6 @@ impl Revalidation {
&self.headers
}
pub fn apply_validators(&self, headers: &mut HeaderMap) -> bool {
// Client validators refer to the representation RustProxy served. If that
// representation was transformed, translate a matching validator back to
// the origin validator rather than leaking the generated proxy ETag upstream.
if headers.contains_key("if-none-match") {
let Some(public_etag) = self.headers.get("etag").and_then(|v| v.to_str().ok()) else {
return false;
@@ -349,7 +341,11 @@ fn hot_resident_age(entry: &Entry) -> Duration {
}
fn matching_hot_key(store: &Store, ticket: &Ticket) -> Option<Key> {
if store.entries.get(&ticket.key).is_some_and(|entry| entry.vary.is_empty()) {
if store
.entries
.get(&ticket.key)
.is_some_and(|entry| entry.vary.is_empty())
{
return Some(ticket.key);
}
store
+5 -59
View File
@@ -24,8 +24,6 @@ const FILL_SLOTS: usize = 8;
const COLD_DELETE_FALLBACKS: usize = 2;
const COLD_CLEANUP_SLOTS: usize = 1;
const BACKGROUND_REVALIDATION_LIMIT: usize = 8;
// Cold writes can contain complete response bodies. Keep the queue deliberately
// small so demotion cannot become a second, hidden RAM cache while disk is slow.
const COLD_QUEUE: usize = 16;
const COLD_FORMAT_VERSION: u8 = 3;
type Key = [u8; 32];
@@ -43,10 +41,6 @@ struct CleanupCandidate {
key: Key,
}
/// `Bytes` releases its allocation when the last reference is dropped, but
/// glibc can keep large freed arenas mapped. After bulk cache demotions/purges,
/// return releasable pages to the OS instead of letting RSS stay artificially
/// high. This is intentionally a no-op on other allocators/platforms.
fn release_allocator_memory() {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
unsafe {
@@ -93,81 +87,44 @@ pub enum ColdPreset {
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub enabled: bool,
/// UI sizing profile last explicitly selected by the administrator. The
/// actual numeric limits remain authoritative; this only preserves intent
/// across reloads so the dashboard can show which preset was applied.
pub memory_preset: Option<MemoryPreset>,
/// Hard RAM budget for published HOT entries, charged by backing allocation
/// plus directly owned metadata rather than only logical body length.
pub max_bytes: usize,
/// Maximum logical size of a single cacheable object body.
pub max_object_bytes: usize,
/// Hot tier object count.
pub max_entries: usize,
/// Bounded Varnish-style transient RAM used by in-progress fills and full
/// bodies moving between HOT and COLD. This is independent of `max_bytes`.
pub transient_max_bytes: usize,
/// Byte ceiling for demoted objects waiting in the COLD writer queue.
pub cold_queue_max_bytes: usize,
/// Maximum number of LRU objects removed during one admission attempt.
/// Reaching the limit makes the new object pass through uncached.
pub hot_eviction_max_objects: usize,
/// Automatically release idle HOT objects once the tier is above the target waterline.
pub hot_auto_trim_enabled: bool,
/// Minimum idle age before automatic HOT trimming can evict an object.
pub hot_auto_trim_after_seconds: u64,
/// Target HOT usage percentage reached by each automatic trim pass.
pub hot_auto_trim_target_percent: u8,
/// Persistent cold tier. Disabled by default to avoid implicit disk writes.
pub cold_enabled: bool,
/// Last explicitly selected COLD policy profile, persisted with the cache
/// configuration for the same reason as `memory_preset`.
pub cold_preset: Option<ColdPreset>,
/// Relative to the RustProxy data directory.
pub cold_path: String,
pub cold_max_bytes: u64,
pub cold_max_entries: usize,
/// Interval between asynchronous cold-cache GC checks.
pub cold_gc_interval_seconds: u64,
/// Maximum number of objects removed by one GC pass.
pub cold_gc_max_delete: usize,
/// Hot objects not touched for this long are demoted to disk.
pub cold_after_seconds: u64,
/// Demote objects which have been idle for `cold_after_seconds`.
pub cold_on_idle: bool,
/// Demote objects after this much continuous residency in the hot tier
/// when `cold_on_age` is enabled, even when they are still being read.
pub cold_max_hot_seconds: u64,
/// Apply `cold_max_hot_seconds` as a separate hot-tier age policy.
pub cold_on_age: bool,
/// Preserve objects evicted from the hot LRU in the cold tier.
pub cold_on_lru: bool,
/// Best-effort flush of hot objects to cold storage on graceful shutdown.
pub cold_on_shutdown: bool,
/// Only used for responses explicitly marked public and without origin TTL.
pub default_ttl_seconds: u64,
pub max_ttl_seconds: u64,
pub bypass_query: bool,
pub bypass_prefixes: Vec<String>,
/// Actual client IP/CIDR allowlist for HTTP BAN/PURGE. Empty disables these
/// public-listener methods (independent of authenticated admin API).
pub http_invalidation_allowed_cidrs: Vec<String>,
/// For explicitly selected static extensions, ignore request Cookie when
/// deciding cache eligibility and when building the shared cache key.
pub static_ignore_cookies: bool,
pub static_extensions: Vec<String>,
/// Collapse concurrent foreground cache MISS/revalidation fills for the same public cache key.
pub collapse_enabled: bool,
/// Maximum time a follower waits for the current fill before applying the configured fallback.
pub collapse_wait_timeout_ms: u64,
pub collapse_timeout_fallback: CollapseTimeoutFallback,
/// Fine-grained stale-if-error classes. Defaults preserve the 0.21 behavior.
pub stale_if_error_timeout: bool,
pub stale_if_error_connection: bool,
pub stale_if_error_dns: bool,
pub stale_if_error_tls: bool,
pub stale_if_error_statuses: Vec<u16>,
/// Schedule bounded validator refresh before normal freshness expires.
pub proactive_refresh_enabled: bool,
pub proactive_refresh_before_expiry_seconds: u64,
pub proactive_refresh_percent: u8,
@@ -176,10 +133,7 @@ pub struct Settings {
pub expose_hits: bool,
pub expose_cache_status: bool,
pub expose_resident_age: bool,
/// Emit cache diagnostics only for actual HIT responses, not MISS or BYPASS.
pub expose_only_hits: bool,
/// Optional response Via field value added by RustProxy cache handling.
/// `None` disables the header; the default is `Via: RustProxy`.
pub via_header: Option<String>,
}
impl Default for Settings {
@@ -214,21 +168,13 @@ impl Default for Settings {
bypass_query: false,
bypass_prefixes: Vec::new(),
http_invalidation_allowed_cidrs: Vec::new(),
// Static assets are normally representation-identical for logged-in and
// anonymous visitors. Keep this enabled by default once the shared cache
// itself is enabled; response-side privacy guards still reject Set-Cookie,
// private/no-store and Vary: Cookie before anything is stored.
static_ignore_cookies: true,
static_extensions: vec![
// styles / scripts
"css", "js", "mjs", "cjs", "wasm", // images
"png", "jpg", "jpeg", "jfif", "jxl", "gif", "webp", "avif", "apng", "svg", "ico",
"cur", "bmp", "tif", "tiff", "heic", "heif", // fonts
"woff", "woff2", "ttf", "otf", "eot", // video
"mp4", "webm", "ogv", "mov", "m4v", // audio
"mp3", "ogg", "oga", "wav", "m4a", "aac", "flac", "opus",
// common static downloads
"pdf", "txt", "zip", "gz", "tgz", "bz2", "xz", "7z", "rar", "tar",
"css", "js", "mjs", "cjs", "wasm", "png", "jpg", "jpeg", "jfif", "jxl", "gif",
"webp", "avif", "apng", "svg", "ico", "cur", "bmp", "tif", "tiff", "heic", "heif",
"woff", "woff2", "ttf", "otf", "eot", "mp4", "webm", "ogv", "mov", "m4v", "mp3",
"ogg", "oga", "wav", "m4a", "aac", "flac", "opus", "pdf", "txt", "zip", "gz",
"tgz", "bz2", "xz", "7z", "rar", "tar",
]
.into_iter()
.map(str::to_owned)
-5
View File
@@ -73,11 +73,6 @@ fn retention_deadline(
_stale_if_error: u64,
_has_validator: bool,
) -> u64 {
// Freshness and storage retention are deliberately separate. Expiry only
// changes whether an object can be served without validation; it must not
// physically evict the representation because a later request may carry
// Cache-Control: max-stale. Capacity/LRU and Cold Cache GC remain the
// mechanisms that decide how long an otherwise valid object is retained.
u64::MAX
}
+5 -24
View File
@@ -1,4 +1,3 @@
/// Reject malformed/duplicate directives instead of guessing their meaning.
fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
let mut result = HashMap::new();
for header in headers.get_all("cache-control") {
@@ -11,8 +10,6 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
Some((k, v)) => {
let v = v.trim();
let value = if v.starts_with('"') {
// Fail closed on truncated/escaped/compound values. Quoted
// commas need not be supported by this conservative cache.
let inner = v.strip_prefix('"')?.strip_suffix('"')?;
if inner.contains(['"', '\\']) {
return None;
@@ -39,10 +36,6 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
return None;
}
if key == "immutable" {
// RFC 8246: immutable has no meaningful argument and duplicate
// occurrences are equivalent to one occurrence. Preserve only
// the presence of the directive instead of invalidating the
// complete Cache-Control field.
result.entry(key).or_insert(None);
} else if result.insert(key, value).is_some() {
return None;
@@ -111,7 +104,11 @@ pub fn forced_request_policy(
}
for key in ["if-match", "if-unmodified-since", "upgrade"] {
if headers.contains_key(key) {
return RequestPolicy::Bypass(if key == "upgrade" { "request_upgrade" } else { "request_precondition" });
return RequestPolicy::Bypass(if key == "upgrade" {
"request_upgrade"
} else {
"request_precondition"
});
}
}
RequestPolicy::Use
@@ -150,8 +147,6 @@ pub fn request_policy(
for (key, reason) in [
("authorization", "request_authorization"),
("cookie", "request_cookie"),
// Preconditions other than cache validators can produce 412 and must be
// evaluated by the origin until RustProxy implements the full precondition set.
("if-match", "request_precondition"),
("if-unmodified-since", "request_precondition"),
("upgrade", "request_upgrade"),
@@ -166,15 +161,9 @@ pub fn request_policy(
let Some(control) = directives(headers) else {
return RequestPolicy::Bypass("request_cache_control_invalid");
};
// RFC 9111: request no-store forbids using a stored response and storing this
// exchange. It is the hard client-side cache bypass.
if control.contains_key("no-store") {
return RequestPolicy::Bypass("request_no_store");
}
// no-cache does not mean no-store. It requires successful validation before a
// stored response can be reused. max-age/min-fresh are evaluated against the
// cached representation during lookup; only max-age=0 is an unconditional
// request-side validation signal.
if control.contains_key("no-cache") {
return RequestPolicy::Revalidate("request_no_cache");
}
@@ -200,8 +189,6 @@ pub fn request_policy(
return RequestPolicy::Bypass("request_cache_control_invalid");
}
}
// HTTP/1.0 Pragma: no-cache is a revalidation request when Cache-Control did
// not already define the policy. Other Pragma values do not disable caching.
if !headers.contains_key("cache-control") && pragma_no_cache(headers) {
return RequestPolicy::Revalidate("request_pragma_no_cache");
}
@@ -295,8 +282,6 @@ fn single_if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool {
&& token != "*"
&& weak_etag(token).is_some_and(|candidate| candidate == target)
}
/// Varnish-like client-side conditional delivery for a selected cached response.
/// If-None-Match takes precedence over If-Modified-Since.
pub fn conditional_not_modified(request: &HeaderMap, response: &HeaderMap) -> bool {
if request.contains_key("if-none-match") {
return response
@@ -326,11 +311,8 @@ pub fn make_not_modified(mut response: Response) -> Response {
}
response
}
/// Merge a successful backend 304 into the stored representation metadata.
pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap {
let mut merged = cached.clone();
// A new validation starts a new age calculation. Preserve origin Age only if
// it is explicitly supplied by the 304 response.
if !validation.contains_key("age") {
merged.remove("age");
}
@@ -364,7 +346,6 @@ pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool {
})
}
/// Explains rejected storage; it never relaxes origin or transformed policies.
pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str {
if status != StatusCode::OK {
return "response_status";
+10 -22
View File
@@ -4,7 +4,11 @@ impl Plan {
}
pub fn with_vary(mut self, name: &str) -> Self {
if !self.vary.iter().any(|value| value.eq_ignore_ascii_case(name)) {
if !self
.vary
.iter()
.any(|value| value.eq_ignore_ascii_case(name))
{
self.vary.push(name.to_ascii_lowercase());
self.vary.sort();
}
@@ -28,14 +32,7 @@ impl Plan {
self.vary.extend(transformed.vary);
self.vary.sort();
self.vary.dedup();
// The transformed response is the representation that is actually
// written to cache. Its Content-Length therefore replaces the origin
// length; comparing transformed bytes with the origin length rejects a
// valid fill and causes a permanent MISS -> MISS loop.
self.expected_length = transformed.expected_length;
// Express both age clocks at the later observation point before taking
// the stricter value. This avoids resetting or double-counting Age when
// origin headers are transformed inside the proxy.
if transformed.received >= self.received {
let carried = self
.initial_age
@@ -87,9 +84,6 @@ pub fn plan(
{
return None;
}
// Cache-policy and validator fields govern reuse rather than representation
// identity. Response Vary drives a secondary key, matching Varnish-style
// variants without fragmenting on unrelated request headers.
let vary = vary_names(headers)?;
let control = directives(headers)?;
if ["no-store", "private", "must-understand"]
@@ -141,22 +135,16 @@ pub fn plan(
Some(v) => v.to_str().ok()?.parse::<u64>().ok()?,
None => 0,
};
// RFC 9111 Section 4.2.3: the corrected initial age is the greater of
// the Date-derived apparent age and the received Age plus response delay.
// An upstream/CDN Age must be preserved on both transformed and normal HITs.
let apparent_age = now.duration_since(date).unwrap_or_default();
let corrected_age_value = Duration::from_secs(age).saturating_add(response_delay);
let initial_age = apparent_age.max(corrected_age_value);
// `ttl` is an absolute freshness lifetime (measured from the origin's
// Age clock), not the time since insertion into our HOT/COLD cache.
// Capping it directly to max_ttl_seconds makes an otherwise fresh 30-day
// upstream response with Age=18 days look expired when our own cap is 1 day.
// Limit the REMAINING freshness from admission, but preserve the Age clock.
// Origin freshness still wins: an origin response that has already expired
// must never become fresh here. `no-cache` continues to require validation.
let ttl = match declared_ttl {
Some(value) => value.min(initial_age.as_secs().saturating_add(settings.max_ttl_seconds)),
Some(value) => value.min(
initial_age
.as_secs()
.saturating_add(settings.max_ttl_seconds),
),
None if requires_revalidation => 0,
None => return None,
};
@@ -62,8 +62,6 @@ mod scope_tests {
revision, 1, "vhost-a", "http", "same.test", "main", "/resource", &HeaderMap::new()
);
assert!(ticket(7).is_some(), "the current snapshot can cache");
// An operational save only advances the config publication barrier.
// It must not change the representation revision or clear any objects.
cache.set_control_revision(8);
assert_eq!(cache.revision(), 1);
assert!(ticket(7).is_none(), "late request from the previous snapshot must bypass");
+3 -5
View File
@@ -16,13 +16,9 @@ pub fn request_range(method: &Method, headers: &HeaderMap) -> RangeSelection {
let Some(value) = values.next() else {
return RangeSelection::None;
};
// Multiple field lines are equivalent to a combined range-set; RustProxy
// intentionally delegates those to the origin with multipart ranges.
if values.next().is_some() || headers.get_all("if-range").iter().count() > 1 {
return RangeSelection::Unsupported;
}
// RustProxy serves one byte range itself. HEAD, unknown units, malformed
// syntax and multipart requests remain transparent origin pass-through.
if method != Method::GET {
return RangeSelection::Unsupported;
}
@@ -132,7 +128,9 @@ impl Hit {
self.response
.headers_mut()
.insert("accept-ranges", HeaderValue::from_static("bytes"));
self.response.extensions_mut().insert(crate::compression::Skip);
self.response
.extensions_mut()
.insert(crate::compression::Skip);
let Some((start, end)) = resolve_byte_range(range, full_len) else {
*self.response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
*self.response.body_mut() = Body::empty();
+28 -20
View File
@@ -1,14 +1,12 @@
// Lock-free read path for bounded, per-domain cache dashboard counters.
//
// Cache-disabled proxy requests still contribute to BYPASS and reason totals,
// but must never lock the HOT object/LRU store. Published domain/reason indexes
// are immutable; individual counters are atomics. Only first sight of a new
// domain/reason acquires the registration mutex.
use arc_swap::ArcSwap;
const DOMAIN_STATS_LIMIT: usize = 4096;
const FAST_REASONS: [&str; 4] = ["cache_disabled", "cache_hit", "cache_miss", "request_cookie"];
const FAST_REASONS: [&str; 4] = [
"cache_disabled",
"cache_hit",
"cache_miss",
"request_cookie",
];
type ReasonIndex = BTreeMap<&'static str, Arc<AtomicU64>>;
type DomainIndex = BTreeMap<String, BTreeMap<String, Arc<DomainStats>>>;
@@ -51,13 +49,12 @@ impl DomainStats {
"HIT" => &self.hits,
"MISS" => &self.misses,
_ => &self.bypasses,
}.fetch_add(1, Ordering::Relaxed);
}
.fetch_add(1, Ordering::Relaxed);
self.record_reason(reason);
}
fn record_reason(&self, reason: &'static str) {
// Cache OFF is the highest-volume case; avoid reason map lookup and
// any shared lock/allocator activity for its dashboard reason.
if reason == "cache_disabled" {
self.fast_reasons[0].fetch_add(1, Ordering::Relaxed);
return;
@@ -72,7 +69,10 @@ impl DomainStats {
return;
}
drop(published);
let _guard = self.register_reason.lock().unwrap_or_else(|e| e.into_inner());
let _guard = self
.register_reason
.lock()
.unwrap_or_else(|e| e.into_inner());
let published = self.reasons.load_full();
if let Some(counter) = published.get(reason) {
counter.fetch_add(1, Ordering::Relaxed);
@@ -123,16 +123,22 @@ impl Default for DomainStatsStore {
impl DomainStatsStore {
fn with(&self, scope: &str, domain: &str, action: impl FnOnce(&DomainStats)) {
let published = self.domains.load();
if let Some(counters) = published.index.get(scope).and_then(|hosts| hosts.get(domain)) {
if let Some(counters) = published
.index
.get(scope)
.and_then(|hosts| hosts.get(domain))
{
action(counters);
return;
}
drop(published);
// Rare path: register only 4096 distinct (scope, domain) pairs. The
// published map is cloned on first insertion, never on a request hit.
let _guard = self.register.lock().unwrap_or_else(|e| e.into_inner());
let published = self.domains.load_full();
if let Some(counters) = published.index.get(scope).and_then(|hosts| hosts.get(domain)) {
if let Some(counters) = published
.index
.get(scope)
.and_then(|hosts| hosts.get(domain))
{
action(counters);
return;
}
@@ -141,7 +147,9 @@ impl DomainStatsStore {
}
let mut next = (*published).clone();
let counters = Arc::new(DomainStats::default());
next.index.entry(scope.to_owned()).or_default()
next.index
.entry(scope.to_owned())
.or_default()
.insert(domain.to_owned(), Arc::clone(&counters));
next.count += 1;
self.domains.store(Arc::new(next));
@@ -163,10 +171,10 @@ impl DomainStatsStore {
}
fn saved_body_hit(&self, scope: &str, domain: &str, bytes: usize) {
// COLD entries loaded from persisted metadata may be served before a
// domain is registered. Preserve first-hit byte accounting as well.
self.with(scope, domain, |counters| {
counters.saved_body_bytes.fetch_add(bytes as u64, Ordering::Relaxed);
counters
.saved_body_bytes
.fetch_add(bytes as u64, Ordering::Relaxed);
});
}
+7 -11
View File
@@ -1,24 +1,20 @@
#[cfg(test)]
include!("tests/tests.rs");
include!("cache_regression_tests.rs");
#[cfg(test)]
include!("tests/scope_tests.rs");
include!("policy/scope_tests.rs");
#[cfg(test)]
include!("tests/dashboard_tests.rs");
include!("dashboard_tests.rs");
#[cfg(test)]
include!("tests/v022_cache_tests.rs");
include!("v022_cache_tests.rs");
#[cfg(test)]
include!("tests/cold_tier_tests.rs");
include!("cold/cold_tier_tests.rs");
#[cfg(test)]
include!("tests/http_invalidation_tests.rs");
include!("invalidation/http_invalidation_tests.rs");
#[cfg(test)]
include!("tests/hot_hit_034_tests.rs");
include!("lookup/hot_hit_034_tests.rs");
-857
View File
@@ -1,857 +0,0 @@
mod cold_tier_tests {
use super::*;
use http_body_util::BodyExt;
fn cold_settings() -> Settings {
Settings {
enabled: true,
max_entries: 1,
max_bytes: 256 * 1024,
max_object_bytes: 64 * 1024,
cold_enabled: true,
cold_path: "cache/cold-test".into(),
cold_max_bytes: 2 * 1024 * 1024,
cold_max_entries: 16,
cold_after_seconds: 300,
..Settings::default()
}
}
async fn fill(cache: &Arc<Cache>, settings: &Settings, path: &str, body: &'static str) -> Ticket {
let ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
path,
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"cold-v1\""));
let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap();
cache
.wrap(response, ticket.clone(), plan, settings)
.into_body()
.collect()
.await
.unwrap();
ticket
}
async fn wait_for_cold(cache: &Cache, expected: usize) {
for _ in 0..100 {
if cache.view().cold_entries >= expected {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("cold tier did not persist in time");
}
#[tokio::test]
async fn selective_purge_removes_matching_cold_object() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one?v=1", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().cold_entries, 1);
let result = cache
.invalidate(InvalidationRequest {
operation: InvalidationOperation::Purge,
selector: InvalidationSelector::Exact,
value: "/one?v=1".into(),
host: Some("cold.example".into()),
vhost: Some("cold-test".into()),
ttl_seconds: None,
confirm_wide: false,
})
.unwrap();
assert_eq!(result.affected_entries, 1);
assert_eq!(cache.view().cold_entries, 0);
assert!(cache.get_tiered(&first, &settings).await.is_none());
shutdown.cancel();
}
#[tokio::test]
async fn aged_transformed_css_remains_fresh_when_demoted_to_cold() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone());
let ticket = cache.ticket_scoped(
11, "cold-test", "http", "cold.example", "origin@127.0.0.1:8080",
"/aged-theme.css", &HeaderMap::new(),
).unwrap();
let body = b"body { color: red; margin: 0px; }";
let mut origin = Response::new(Body::from(Bytes::from_static(body)));
origin.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=2592000"));
origin.headers_mut().insert("age", HeaderValue::from_static("1553483"));
origin.headers_mut().insert("date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap());
origin.headers_mut().insert("content-type", HeaderValue::from_static("text/css"));
origin.headers_mut().insert("content-length", HeaderValue::from_str(&body.len().to_string()).unwrap());
origin.headers_mut().insert("etag", HeaderValue::from_static("\"origin\""));
let origin_plan = plan(StatusCode::OK, origin.headers(), &settings, Duration::ZERO).unwrap();
let transformed = crate::transform::apply(origin, "/aged-theme.css",
&crate::transform::Settings { enabled: true, css: true, ..Default::default() }, false
).await.unwrap();
let transformed_plan = plan(StatusCode::OK, transformed.headers(), &settings, Duration::ZERO).unwrap();
cache.wrap(transformed, ticket.clone(), origin_plan.restrict(transformed_plan), &settings)
.into_body().collect().await.unwrap();
assert_eq!(cache.view().entries, 1);
let _another = fill(&cache, &settings, "/another", "another-payload").await;
wait_for_cold(&cache, 1).await;
let row = cache.dashboard(Some("cold-test"), 0, 20).objects
.into_iter().find(|v| v.path == "/aged-theme.css").unwrap();
assert_eq!(row.tier, "cold");
assert!(row.transformed);
assert!(row.remaining_ttl_seconds >= 86_000);
shutdown.cancel();
drop(cache);
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone());
let hit = cache.get_tiered(&ticket, &settings).await.expect("aged transformed COLD should HIT after restart");
assert!(hit.transformed);
assert!(hit.remaining_ttl >= 86_000);
let transformed_body = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_ne!(transformed_body.as_ref(), &body[..]);
shutdown.cancel();
}
#[tokio::test]
async fn lru_demotion_persists_and_survives_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
settings.validate().unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 1);
let cold = cache.dashboard(Some("cold-test"), 0, 50);
assert!(cold
.objects
.iter()
.any(|row| row.path == "/one" && row.tier == "cold"));
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 1);
let hit = restarted
.get_tiered(&first, &settings)
.await
.expect("cold object should be readable after restart");
let payload = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(payload, Bytes::from_static(b"first-payload"));
assert_eq!(restarted.view().cold_hits, 1);
assert_eq!(restarted.view().cold_promotions, 1);
restart_shutdown.cancel();
}
#[tokio::test]
async fn dashboard_lists_retained_expired_cold_objects_without_permitting_fresh_hits() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone());
let first = fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
{
let mut cold = cache.cold.lock().unwrap();
for entry in cold.entries.values_mut() {
entry.stored_at = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
}
}
let dashboard = cache.dashboard(Some("cold-test"), 0, 50);
assert_eq!(dashboard.matching_objects, 2);
let row = dashboard.objects.iter().find(|row| row.path == "/one").unwrap();
assert_eq!(row.tier, "cold");
assert_eq!(row.remaining_ttl_seconds, 0);
assert!(row.age_seconds >= 2 * 60 * 60);
assert_eq!(row.storage_bytes, cache.view().cold_bytes);
assert!(cache.get_tiered(&first, &settings).await.is_none());
let mut stale = first;
stale.request_headers.insert("cache-control", HeaderValue::from_static("max-stale"));
let hit = cache.get_tiered(&stale, &settings).await.expect("explicit max-stale can reuse the retained disk object");
assert!(hit.stale);
assert_eq!(hit.response.into_body().collect().await.unwrap().to_bytes(), "first-payload");
shutdown.cancel();
}
#[tokio::test]
async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let transformed_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/transformed.json",
&HeaderMap::new(),
)
.unwrap();
let mut transformed = Response::new(Body::from(r#"{"a":1}"#));
transformed.headers_mut().insert(
"cache-control",
HeaderValue::from_static(
"public, max-age=120, stale-while-revalidate=30, stale-if-error=90",
),
);
transformed.headers_mut().insert(
"etag",
HeaderValue::from_static("\"rustproxy-representation\""),
);
transformed
.headers_mut()
.insert("content-length", HeaderValue::from_static("7"));
transformed
.extensions_mut()
.insert(crate::transform::OriginValidators {
etag: Some(HeaderValue::from_static("\"origin-v1\"")),
last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
});
transformed
.extensions_mut()
.insert(crate::transform::Applied {
kind: crate::transform::Kind::Json,
original_bytes: 9,
transformed_bytes: 7,
});
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
transformed,
transformed_ticket.clone(),
transformed_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
let protected_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/protected.css",
&HeaderMap::new(),
)
.unwrap();
let original_body = "body { color: red; }";
let mut protected = Response::new(Body::from(original_body));
protected.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120, immutable, no-transform"),
);
protected
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin-css\""));
protected.extensions_mut().insert(crate::compression::Skip);
let protected_plan = plan(
StatusCode::OK,
protected.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
protected,
protected_ticket.clone(),
protected_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.flush_hot_to_cold().await, 2);
wait_for_cold(&cache, 2).await;
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
// COLD entries intentionally keep validators and headers on disk to bound RAM.
// Inspect them through the same verified metadata loader used on COLD hits.
let (key, transformed) = {
let cold = restarted.cold.lock().unwrap();
cold.entries
.iter()
.find(|(_, entry)| entry.path == "/transformed.json")
.map(|(key, entry)| (*key, entry.clone()))
.expect("transformed cold entry")
};
assert_eq!(transformed.stale_while_revalidate, 30);
assert_eq!(transformed.stale_if_error, 90);
assert!(transformed.has_validator);
assert!(transformed.transformed);
let metadata = read_cold_metadata(&key, &transformed)
.await
.expect("persisted transformed metadata");
assert_eq!(metadata.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
assert_eq!(
metadata.origin_last_modified.as_ref().unwrap(),
"Mon, 26 Jan 2026 23:11:40 GMT"
);
assert_eq!(metadata.headers["etag"], "\"rustproxy-representation\"");
let transformed_hit = restarted
.get_tiered(&transformed_ticket, &settings)
.await
.expect("transformed object after restart");
assert_eq!(
transformed_hit.response.headers()["etag"],
"\"rustproxy-representation\""
);
assert!(transformed_hit.transformed);
assert!(transformed_hit
.response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some());
assert_eq!(
transformed_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(br#"{"a":1}"#)
);
let protected_hit = restarted
.get_tiered(&protected_ticket, &settings)
.await
.expect("no-transform object after restart");
assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\"");
assert!(crate::transform::no_transform(
protected_hit.response.headers()
));
assert!(protected_hit
.response
.extensions()
.get::<crate::compression::Skip>()
.is_some());
assert_eq!(
protected_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"body { color: red; }")
);
restart_shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_lru_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.cold_on_lru = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
tokio::time::sleep(Duration::from_millis(30)).await;
let view = cache.view();
assert_eq!(view.entries, 1);
assert_eq!(view.cold_entries, 0);
assert_eq!(view.evictions, 1);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_idle_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/idle", "idle-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.last_access = Instant::now() - Duration::from_secs(600);
}
}
cache.demote_idle();
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
settings.cold_on_idle = true;
cache.configure(settings.clone());
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_demote_by_total_hot_residency_age() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
settings.cold_on_age = true;
settings.cold_max_hot_seconds = 5;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/aged", "aged-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.resident_since = Instant::now() - Duration::from_secs(60);
entry.last_access = Instant::now();
}
}
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
assert_eq!(cache.view().entries, 2);
assert!(cache.view().resident_bytes > 0);
assert_eq!(cache.view().cold_entries, 0);
assert_eq!(cache.flush_hot_to_cold().await, 2);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.hot_logical_bytes, 0);
assert_eq!(
view.transient_bytes, 0,
"persisted ColdEntry must not retain the body allocation"
);
assert_eq!(view.cold_entries, 2);
shutdown.cancel();
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
restart_shutdown.cancel();
}
#[tokio::test]
async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/update", "update-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.flush_hot_to_cold_for_update().await, 1);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_can_be_disabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_cache_uses_two_hex_character_shards() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let root = dir.path().join(&settings.cold_path);
let mut shards = Vec::new();
for item in std::fs::read_dir(&root).unwrap() {
let item = item.unwrap();
let name = item.file_name().to_string_lossy().to_string();
assert!(
item.path().is_dir(),
"cold root must contain shard directories only"
);
assert_eq!(name.len(), 2);
assert!(name.bytes().all(|b| b.is_ascii_hexdigit()));
shards.push(item.path());
}
assert!(!shards.is_empty());
assert!(shards.iter().any(|shard| {
std::fs::read_dir(shard)
.unwrap()
.filter_map(|entry| entry.ok())
.any(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
name.ends_with(".json") || name.ends_with(".body")
})
}));
shutdown.cancel();
}
#[tokio::test]
async fn legacy_flat_cold_cache_is_not_migrated() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let root = dir.path().join(&settings.cold_path);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("legacy.body"), b"old").unwrap();
std::fs::write(root.join("legacy.json"), b"{}").unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
assert_eq!(cache.view().cold_entries, 0);
assert!(!root.join("legacy.body").exists());
assert!(!root.join("legacy.json").exists());
shutdown.cancel();
}
#[tokio::test]
async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
fill(&cache, &settings, "/three", "third-payload").await;
wait_for_cold(&cache, 2).await;
let job = cache
.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
})
.unwrap();
for _ in 0..100 {
let current = cache.cold_cleanup_job(job.id).unwrap();
if current.status == "done" {
assert_eq!(current.removed_entries, 1);
assert!(current.freed_bytes > 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("cold cleanup did not finish");
}
#[tokio::test]
async fn manual_cold_cleanup_is_single_flight() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap();
let result = cache.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
});
assert!(result.is_err());
shutdown.cancel();
}
#[tokio::test]
async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let key = {
let mut cold = cache.cold.lock().unwrap();
let key = *cold.entries.keys().next().unwrap();
cold.entries.get_mut(&key).unwrap().pinned_reads = 1;
key
};
let request = ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
};
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 0);
assert!(cache.cold.lock().unwrap().entries.contains_key(&key));
cache
.cold
.lock()
.unwrap()
.entries
.get_mut(&key)
.unwrap()
.pinned_reads = 0;
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 1);
assert!(!cache.cold.lock().unwrap().entries.contains_key(&key));
shutdown.cancel();
}
#[test]
fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() {
let cache = Cache::new(1);
let key = [7_u8; 32];
let template = ColdEntry {
base_key: key,
vary: Vec::new(),
revision: 1,
scope: "app".to_owned(),
scheme: "https".to_owned(),
authority: "example.test".to_owned(),
domain: "example.test".to_owned(),
path: "/asset".to_owned(),
target: "https://example.test/asset".to_owned(),
tags: Vec::new(),
created_at: SystemTime::now(),
body_path: PathBuf::from("old.body"),
meta_path: PathBuf::from("old.json"),
body_bytes: 1,
body_sha256: [0u8; 32],
disk_bytes: 2,
stored_at: SystemTime::now(),
last_access: SystemTime::now(),
age_at_store_seconds: 0,
resident_age_at_store_seconds: 0,
ttl: 60,
stale_while_revalidate: 0,
stale_if_error: 0,
must_revalidate: false,
proxy_revalidate: false,
has_validator: false,
hits: 0,
transformed: false,
sequence: 1,
generation: 1,
skip_compression: false,
requires_revalidation: false,
pinned_reads: 1,
};
cache
.cold
.lock()
.unwrap()
.entries
.insert(key, template.clone());
let old_guard = ColdReadGuard {
cache: &cache,
key,
generation: 1,
};
let mut replacement = template;
replacement.sequence = 2;
replacement.generation = 2;
replacement.pinned_reads = 1;
cache.cold.lock().unwrap().entries.insert(key, replacement);
drop(old_guard);
let cold = cache.cold.lock().unwrap();
let replacement = cold.entries.get(&key).unwrap();
assert_eq!(replacement.generation, 2);
assert_eq!(replacement.pinned_reads, 1);
}
#[test]
fn cold_path_cannot_escape_data_directory() {
let mut settings = Settings::default();
settings.cold_path = "../outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "/tmp/outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "cache/cold".into();
assert!(settings.validate().is_ok());
}
#[tokio::test]
async fn cold_scope_fingerprint_preserves_unmodified_vhost_and_rejects_stale_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let mut fingerprints = HashMap::new();
fingerprints.insert("cold-test".to_owned(), 77);
let cache = Cache::start_with_scope_revisions(
11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone(),
fingerprints.clone(),
);
fill(&cache, &settings, "/one", "stable-contents").await;
fill(&cache, &settings, "/two", "second-contents").await;
wait_for_cold(&cache, 1).await;
shutdown.cancel();
drop(cache);
let restart = CancellationToken::new();
let same = Cache::start_with_scope_revisions(
11, Some(dir.path().to_path_buf()), settings.clone(), restart.clone(),
fingerprints.clone(),
);
assert_eq!(same.view().cold_entries, 1, "operational saves retain COLD");
restart.cancel();
drop(same);
fingerprints.insert("cold-test".to_owned(), 78);
let restart = CancellationToken::new();
let changed = Cache::start_with_scope_revisions(
11, Some(dir.path().to_path_buf()), settings.clone(), restart.clone(), fingerprints,
);
assert_eq!(changed.view().cold_entries, 0, "old COLD must never survive a content revision");
restart.cancel();
}
}
+14 -14
View File
@@ -1,7 +1,4 @@
impl Cache {
/// Varnish-style primary hash: public URL/host identity first, with response
/// `Vary` handled as a secondary variant key. Irrelevant request headers and
/// load-balanced backend members therefore do not fragment the cache.
pub fn ticket(
&self,
revision: u64,
@@ -23,12 +20,11 @@ impl Cache {
target: &str,
headers: &HeaderMap,
) -> Option<Ticket> {
self.ticket_scoped_impl(None, revision, scope, scheme, authority, _route, target, headers)
self.ticket_scoped_impl(
None, revision, scope, scheme, authority, _route, target, headers,
)
}
/// Production requests bind the cache ticket to the immutable routing
/// configuration used to authorize/route the request. After a config save,
/// stale in-flight snapshots cannot re-fill a scope after its purge.
#[allow(clippy::too_many_arguments)]
pub fn ticket_scoped_for_config(
&self,
@@ -41,7 +37,16 @@ impl Cache {
target: &str,
headers: &HeaderMap,
) -> Option<Ticket> {
self.ticket_scoped_impl(Some(control_revision), revision, scope, scheme, authority, route, target, headers)
self.ticket_scoped_impl(
Some(control_revision),
revision,
scope,
scheme,
authority,
route,
target,
headers,
)
}
#[allow(clippy::too_many_arguments)]
@@ -61,9 +66,6 @@ impl Cache {
}
let domain = crate::config::request_host(authority)
.unwrap_or_else(|_| authority.chars().take(253).collect());
// Varnish built-in hashing lowercases Host before hashing, but otherwise
// keeps the authority (including an explicit port) distinct. RustProxy has
// already validated the authority before this point.
let hash_scheme = scheme.to_ascii_lowercase();
let hash_authority = authority.to_ascii_lowercase();
let mut digest = Sha256::new();
@@ -85,7 +87,7 @@ impl Cache {
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
(store.revision == revision
&& expected_control_revision.is_none_or(|expected| expected == store.control_revision))
.then_some(Ticket {
.then_some(Ticket {
scheme: hash_scheme,
authority: hash_authority,
domain,
@@ -336,8 +338,6 @@ impl Cache {
self: &Arc<Self>,
cached: &Revalidation,
) -> Option<BackgroundRevalidationGuard> {
// Vary variants share a base Ticket key. Deduplicate only the exact cached
// representation so an SWR refresh for one variant cannot suppress another.
let key = cached.storage_key;
let mut active = self.revalidating.lock().unwrap_or_else(|e| e.into_inner());
if active.len() >= BACKGROUND_REVALIDATION_LIMIT && !active.contains(&key) {
@@ -83,21 +83,55 @@ mod v022_cache_tests {
] {
let cache = Cache::new(22);
let settings = active_settings();
let ticket = cache.ticket(22, "http", "example.test", "origin", "/transform.js", &HeaderMap::new()).unwrap();
let ticket = cache
.ticket(
22,
"http",
"example.test",
"origin",
"/transform.js",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from(body.clone()));
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120"));
response.headers_mut().insert("content-length", HeaderValue::from_str(&body.len().to_string()).unwrap());
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response.headers_mut().insert(
"content-length",
HeaderValue::from_str(&body.len().to_string()).unwrap(),
);
response.extensions_mut().insert(crate::transform::Applied {
kind: crate::transform::Kind::Javascript,
original_bytes: body.len(),
transformed_bytes: body.len(),
});
let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
let policy = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(policy.expected_length, Some(body.len()));
let downstream = cache.wrap(response, ticket.clone(), policy, &settings)
.into_body().collect().await.unwrap().to_bytes();
let downstream = cache
.wrap(response, ticket.clone(), policy, &settings)
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(downstream, body);
let cached = cache.get(&ticket).unwrap().response.into_body().collect().await.unwrap().to_bytes();
let cached = cache
.get(&ticket)
.unwrap()
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(cached, body);
assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0);
}
@@ -107,26 +141,55 @@ mod v022_cache_tests {
async fn uniquely_owned_transformed_frame_is_cached_without_body_copy() {
let cache = Cache::new(22);
let settings = active_settings();
let ticket = cache.ticket(22, "http", "example.test", "origin", "/owned.js", &HeaderMap::new()).unwrap();
let ticket = cache
.ticket(
22,
"http",
"example.test",
"origin",
"/owned.js",
&HeaderMap::new(),
)
.unwrap();
let payload = Bytes::from(vec![b'z'; 8192]);
let original_allocation = payload.as_ptr();
let mut response = Response::new(Body::from(payload));
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120"));
response.headers_mut().insert("content-length", HeaderValue::from_static("8192"));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("8192"));
response.extensions_mut().insert(crate::transform::Applied {
kind: crate::transform::Kind::Javascript,
original_bytes: 8192,
transformed_bytes: 8192,
});
let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
let downstream = cache.wrap(response, ticket.clone(), policy, &settings)
.into_body().collect().await.unwrap().to_bytes();
let policy = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let downstream = cache
.wrap(response, ticket.clone(), policy, &settings)
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(downstream.len(), 8192);
assert_eq!(downstream.as_ptr(), original_allocation);
let hit = cache.get(&ticket).unwrap();
let cached = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(cached, downstream);
assert_eq!(cached.as_ptr(), original_allocation, "cache fill copied the transformed body");
assert_eq!(
cached.as_ptr(),
original_allocation,
"cache fill copied the transformed body"
);
assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0);
}
@@ -180,8 +243,22 @@ mod v022_cache_tests {
)
.await;
let cases = [
(ByteRange { start: None, end: Some(3) }, "hij", "bytes 7-9/10"),
(ByteRange { start: Some(7), end: None }, "hij", "bytes 7-9/10"),
(
ByteRange {
start: None,
end: Some(3),
},
"hij",
"bytes 7-9/10",
),
(
ByteRange {
start: Some(7),
end: None,
},
"hij",
"bytes 7-9/10",
),
];
for (range, expected, content_range) in cases {
let hit = cache.get(&ticket).unwrap().apply_range(range, None);
@@ -192,38 +269,66 @@ mod v022_cache_tests {
}
let unsatisfied = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(50), end: None },
ByteRange {
start: Some(50),
end: None,
},
None,
);
assert_eq!(unsatisfied.response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(unsatisfied.response.headers()["content-range"], "bytes */10");
assert_eq!(
unsatisfied.response.into_body().collect().await.unwrap().to_bytes().len(),
unsatisfied.response.status(),
StatusCode::RANGE_NOT_SATISFIABLE
);
assert_eq!(
unsatisfied.response.headers()["content-range"],
"bytes */10"
);
assert_eq!(
unsatisfied
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.len(),
0
);
let mismatch = HeaderValue::from_static("\"asset-v2\"");
let full = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(0), end: Some(2) },
ByteRange {
start: Some(0),
end: Some(2),
},
Some(&mismatch),
);
assert_eq!(full.response.status(), StatusCode::OK);
assert!(!full.response.headers().contains_key("content-range"));
assert_eq!(
full.response.into_body().collect().await.unwrap().to_bytes(),
full.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"abcdefghij")
);
let matched = HeaderValue::from_static("\"asset-v1\"");
let partial = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(0), end: Some(2) },
ByteRange {
start: Some(0),
end: Some(2),
},
Some(&matched),
);
assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT);
}
#[test]
fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation() {
fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation(
) {
let mut multipart = HeaderMap::new();
multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5"));
assert_eq!(
@@ -318,13 +423,23 @@ mod v022_cache_tests {
let mut settings = active_settings();
settings.collapse_wait_timeout_ms = 10;
let ticket = cache
.ticket(22, "http", "collapse.example", "origin", "/timeout", &HeaderMap::new())
.ticket(
22,
"http",
"collapse.example",
"origin",
"/timeout",
&HeaderMap::new(),
)
.unwrap();
let leader = match cache.claim_fill(&ticket, &settings).await {
FillClaim::Leader(guard) => guard,
_ => panic!("missing leader"),
};
assert!(matches!(cache.claim_fill(&ticket, &settings).await, FillClaim::Timeout));
assert!(matches!(
cache.claim_fill(&ticket, &settings).await,
FillClaim::Timeout
));
assert_eq!(cache.view().collapse_timeouts, 1);
drop(leader);
assert!(matches!(
@@ -339,7 +454,14 @@ mod v022_cache_tests {
let mut settings = active_settings();
settings.collapse_wait_timeout_ms = 5_000;
let ticket = cache
.ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new())
.ticket(
22,
"http",
"collapse.example",
"origin",
"/cancel",
&HeaderMap::new(),
)
.unwrap();
let leader = match cache.claim_fill(&ticket, &settings).await {
FillClaim::Leader(guard) => guard,
@@ -377,10 +499,24 @@ mod v022_cache_tests {
let mut pl_headers = HeaderMap::new();
pl_headers.insert("accept-language", HeaderValue::from_static("pl"));
let en = cache
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &en_headers)
.ticket(
22,
"http",
"vary-collapse.example",
"origin",
"/page",
&en_headers,
)
.unwrap();
let pl = cache
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &pl_headers)
.ticket(
22,
"http",
"vary-collapse.example",
"origin",
"/page",
&pl_headers,
)
.unwrap();
assert_eq!(en.key, pl.key, "Vary is learned from the response");
let leader = match cache.claim_fill(&en, &settings).await {
@@ -455,7 +591,14 @@ mod v022_cache_tests {
fn proactive_refresh_guards_deduplicate_and_bound_parallel_work() {
let cache = Cache::new(22);
let ticket = cache
.ticket(22, "http", "refresh.example", "origin", "/asset", &HeaderMap::new())
.ticket(
22,
"http",
"refresh.example",
"origin",
"/asset",
&HeaderMap::new(),
)
.unwrap();
let mut settings = active_settings();
settings.proactive_refresh_enabled = true;
@@ -511,7 +654,9 @@ mod v022_cache_tests {
&HeaderMap::new(),
)
.unwrap();
assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none());
assert!(cache
.begin_proactive_schedule(&overflow, &settings)
.is_none());
assert!(cache.view().proactive_skipped > 0);
drop(guards);
}
@@ -557,11 +702,17 @@ mod v022_cache_tests {
#[tokio::test]
async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() {
fn current_ticket(cache: &Cache, old: &Ticket) -> Ticket {
// Each post-PURGE request obtains the current publication generation.
// Reusing an old ticket would reject unrelated entries too and make
// assertions about removed objects pass without testing the selector.
cache.ticket_scoped(22, &old.scope, &old.scheme, &old.authority,
"origin@127.0.0.1:8080", &old.target, &old.request_headers).unwrap()
cache
.ticket_scoped(
22,
&old.scope,
&old.scheme,
&old.authority,
"origin@127.0.0.1:8080",
&old.target,
&old.request_headers,
)
.unwrap()
}
let cache = Cache::new(22);
let one = store(
@@ -595,8 +746,19 @@ mod v022_cache_tests {
.unwrap();
assert_eq!(result.affected_entries, 1);
assert!(cache.get(&current_ticket(&cache, &one)).is_none());
let survivor = cache.get(&current_ticket(&cache, &two)).expect("unrelated query remains stored");
assert_eq!(survivor.response.into_body().collect().await.unwrap().to_bytes(), "two");
let survivor = cache
.get(&current_ticket(&cache, &two))
.expect("unrelated query remains stored");
assert_eq!(
survivor
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
"two"
);
let three = store(
&cache,
@@ -832,7 +994,10 @@ mod v022_cache_tests {
"fresh",
)
.await;
assert!(cache.get(&fresh).is_some(), "old BAN must not reject newer objects");
assert!(
cache.get(&fresh).is_some(),
"old BAN must not reject newer objects"
);
assert_eq!(cache.bans().len(), 1);
assert!(cache.remove_ban(ban_id));
assert!(cache.bans().is_empty());
@@ -898,9 +1063,6 @@ mod v022_cache_tests {
"unchanged",
)
.await;
// A production cache may see an unbounded stream of PURGEs for URLs
// that will never be cached again. The invalidated-key index must
// not remember all of those removed objects indefinitely.
for index in 0..512 {
let target = format!("/unique-{index}");
let old = store(
@@ -924,15 +1086,16 @@ mod v022_cache_tests {
))
.unwrap();
assert_eq!(result.affected_entries, 1);
assert!(!cache.ticket_current(&old), "pre-purge ticket must remain stale");
assert!(
!cache.ticket_current(&old),
"pre-purge ticket must remain stale"
);
let state = cache.store.lock().unwrap_or_else(|e| e.into_inner());
assert!(
state.key_generations.len() <= 1,
"key generations must not accumulate for already purged URLs"
);
}
// A new ticket after the invalidation barrier is valid and can fill
// the same URL again, preserving ordinary cache behavior.
let new = store(
&cache,
"app",
@@ -956,6 +1119,9 @@ mod v022_cache_tests {
&HeaderMap::new(),
)
.unwrap();
assert!(cache.get(&stable).is_some(), "unrelated entries must survive");
assert!(
cache.get(&stable).is_some(),
"unrelated entries must survive"
);
}
}