0.34.6_repo_cleanup
This commit is contained in:
1 parent
ce1a90397c
commit
133d2f0f91
273 files changed
+5969
-6926
No files matched your search
+288
-123
@@ -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());
|
||||
Vendored
-8
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+955
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
+14
-12
@@ -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
|
||||
}
|
||||
|
||||
Vendored
-2
@@ -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();
|
||||
|
||||
Vendored
-5
@@ -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 {
|
||||
|
||||
Vendored
+10
-34
@@ -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
|
||||
|
||||
Vendored
+21
-10
@@ -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);
|
||||
Vendored
+24
-37
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+9
-3
@@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
Vendored
+12
-19
@@ -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 {
|
||||
|
||||
Vendored
+36
-41
@@ -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 {
|
||||
|
||||
Vendored
+30
-15
@@ -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
|
||||
|
||||
+81
-32
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
-3
@@ -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);
|
||||
}
|
||||
|
||||
Vendored
+9
-7
@@ -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);
|
||||
|
||||
Vendored
-6
@@ -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");
|
||||
|
||||
Vendored
+1
-24
@@ -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();
|
||||
|
||||
Vendored
+20
-24
@@ -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,
|
||||
}
|
||||
|
||||
Vendored
+10
-5
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
+5
-9
@@ -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
|
||||
|
||||
Vendored
+5
-59
@@ -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)
|
||||
|
||||
Vendored
-5
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+5
-24
@@ -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";
|
||||
|
||||
Vendored
+10
-22
@@ -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");
|
||||
Vendored
+3
-5
@@ -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();
|
||||
|
||||
Vendored
+28
-20
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Vendored
+7
-11
@@ -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");
|
||||
Vendored
-857
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
+14
-14
@@ -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) {
|
||||
|
||||
+212
-46
@@ -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(¤t_ticket(&cache, &one)).is_none());
|
||||
let survivor = cache.get(¤t_ticket(&cache, &two)).expect("unrelated query remains stored");
|
||||
assert_eq!(survivor.response.into_body().collect().await.unwrap().to_bytes(), "two");
|
||||
let survivor = cache
|
||||
.get(¤t_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user