v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
+421
@@ -0,0 +1,421 @@
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
fn active_settings() -> Settings {
|
||||
Settings {
|
||||
enabled: true,
|
||||
..Settings::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn store(
|
||||
cache: &Arc<Cache>,
|
||||
scope: &str,
|
||||
host: &str,
|
||||
target: &str,
|
||||
request_headers: HeaderMap,
|
||||
extra_response_headers: &[(&str, &str)],
|
||||
body: &'static str,
|
||||
) -> Ticket {
|
||||
let ticket = cache
|
||||
.ticket_scoped(
|
||||
22,
|
||||
scope,
|
||||
"http",
|
||||
host,
|
||||
"origin@127.0.0.1:8080",
|
||||
target,
|
||||
&request_headers,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = active_settings();
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=120, stale-if-error=60"),
|
||||
);
|
||||
for (name, value) in extra_response_headers {
|
||||
response.headers_mut().insert(
|
||||
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(value).unwrap(),
|
||||
);
|
||||
}
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap(response, ticket.clone(), plan, &settings)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
ticket
|
||||
}
|
||||
|
||||
fn invalidation(
|
||||
operation: InvalidationOperation,
|
||||
selector: InvalidationSelector,
|
||||
value: &str,
|
||||
host: Option<&str>,
|
||||
vhost: Option<&str>,
|
||||
) -> InvalidationRequest {
|
||||
InvalidationRequest {
|
||||
operation,
|
||||
selector,
|
||||
value: value.to_owned(),
|
||||
host: host.map(str::to_owned),
|
||||
vhost: vhost.map(str::to_owned),
|
||||
ttl_seconds: None,
|
||||
confirm_wide: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_single_range_returns_206_and_preserves_validators() {
|
||||
let cache = Cache::new(22);
|
||||
let ticket = store(
|
||||
&cache,
|
||||
"range",
|
||||
"range.example",
|
||||
"/asset.bin",
|
||||
HeaderMap::new(),
|
||||
&[
|
||||
("etag", "\"asset-v1\""),
|
||||
("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"),
|
||||
],
|
||||
"abcdefghij",
|
||||
)
|
||||
.await;
|
||||
let mut request_headers = HeaderMap::new();
|
||||
request_headers.insert("range", HeaderValue::from_static("bytes=2-5"));
|
||||
let range = match request_range(&Method::GET, &request_headers) {
|
||||
RangeSelection::Single(range) => range,
|
||||
other => panic!("unexpected range selection: {other:?}"),
|
||||
};
|
||||
let hit = cache.get(&ticket).unwrap().apply_range(range, None);
|
||||
assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(hit.response.headers()["content-range"], "bytes 2-5/10");
|
||||
assert_eq!(hit.response.headers()["content-length"], "4");
|
||||
assert_eq!(hit.response.headers()["accept-ranges"], "bytes");
|
||||
assert_eq!(hit.response.headers()["etag"], "\"asset-v1\"");
|
||||
assert!(hit.response.headers().contains_key("age"));
|
||||
let body = hit.response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, Bytes::from_static(b"cdef"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_range_supports_suffix_open_unsatisfied_and_if_range() {
|
||||
let cache = Cache::new(22);
|
||||
let ticket = store(
|
||||
&cache,
|
||||
"range",
|
||||
"range.example",
|
||||
"/asset.bin",
|
||||
HeaderMap::new(),
|
||||
&[
|
||||
("etag", "\"asset-v1\""),
|
||||
("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"),
|
||||
],
|
||||
"abcdefghij",
|
||||
)
|
||||
.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"),
|
||||
];
|
||||
for (range, expected, content_range) in cases {
|
||||
let hit = cache.get(&ticket).unwrap().apply_range(range, None);
|
||||
assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(hit.response.headers()["content-range"], content_range);
|
||||
let body = hit.response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, Bytes::copy_from_slice(expected.as_bytes()));
|
||||
}
|
||||
|
||||
let unsatisfied = cache.get(&ticket).unwrap().apply_range(
|
||||
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(),
|
||||
0
|
||||
);
|
||||
|
||||
let mismatch = HeaderValue::from_static("\"asset-v2\"");
|
||||
let full = cache.get(&ticket).unwrap().apply_range(
|
||||
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(),
|
||||
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) },
|
||||
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() {
|
||||
let mut multipart = HeaderMap::new();
|
||||
multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5"));
|
||||
assert_eq!(
|
||||
request_range(&Method::GET, &multipart),
|
||||
RangeSelection::Unsupported
|
||||
);
|
||||
|
||||
let mut duplicate = HeaderMap::new();
|
||||
duplicate.append("range", HeaderValue::from_static("bytes=0-1"));
|
||||
duplicate.append("range", HeaderValue::from_static("bytes=4-5"));
|
||||
assert_eq!(
|
||||
request_range(&Method::GET, &duplicate),
|
||||
RangeSelection::Unsupported
|
||||
);
|
||||
|
||||
let mut response_headers = HeaderMap::new();
|
||||
response_headers.insert(
|
||||
"last-modified",
|
||||
HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"),
|
||||
);
|
||||
let newer_if_range = HeaderValue::from_static("Tue, 27 Jan 2026 23:11:40 GMT");
|
||||
assert!(if_range_matches(&response_headers, Some(&newer_if_range)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_miss_single_flight_waits_until_fill_is_published() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 500;
|
||||
let ticket = cache
|
||||
.ticket_scoped(
|
||||
22,
|
||||
"collapse",
|
||||
"http",
|
||||
"collapse.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
"/same",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let leader = match cache.claim_fill(&ticket, &settings).await {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
_ => panic!("first request must become fill leader"),
|
||||
};
|
||||
let follower_cache = cache.clone();
|
||||
let follower_ticket = ticket.clone();
|
||||
let follower_settings = settings.clone();
|
||||
let follower = tokio::spawn(async move {
|
||||
match follower_cache
|
||||
.claim_fill(&follower_ticket, &follower_settings)
|
||||
.await
|
||||
{
|
||||
FillClaim::Waited => follower_cache.get(&follower_ticket).is_some(),
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if cache.view().collapse_waiters == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
assert_eq!(cache.view().collapse_waiters, 1);
|
||||
assert!(!follower.is_finished());
|
||||
|
||||
let mut response = Response::new(Body::from("collapsed-payload"));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=60"),
|
||||
);
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap_with_fill(response, ticket.clone(), plan, &settings, Some(leader))
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(follower.await.unwrap());
|
||||
assert_eq!(cache.view().collapsed_fills, 1);
|
||||
assert_eq!(cache.view().collapse_waiters, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_flight_timeout_and_leader_drop_release_the_key() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 10;
|
||||
let ticket = cache
|
||||
.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_eq!(cache.view().collapse_timeouts, 1);
|
||||
drop(leader);
|
||||
assert!(matches!(
|
||||
cache.claim_fill(&ticket, &settings).await,
|
||||
FillClaim::Leader(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_single_flight_waiter_releases_waiter_metric() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 5_000;
|
||||
let ticket = cache
|
||||
.ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new())
|
||||
.unwrap();
|
||||
let leader = match cache.claim_fill(&ticket, &settings).await {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
_ => panic!("missing leader"),
|
||||
};
|
||||
let follower_cache = cache.clone();
|
||||
let follower_ticket = ticket.clone();
|
||||
let follower_settings = settings.clone();
|
||||
let follower = tokio::spawn(async move {
|
||||
follower_cache
|
||||
.claim_fill(&follower_ticket, &follower_settings)
|
||||
.await
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if cache.view().collapse_waiters == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
assert_eq!(cache.view().collapse_waiters, 1);
|
||||
follower.abort();
|
||||
let _ = follower.await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(cache.view().collapse_waiters, 0);
|
||||
drop(leader);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_flight_retries_after_another_vary_representation_is_published() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 500;
|
||||
let mut en_headers = HeaderMap::new();
|
||||
en_headers.insert("accept-language", HeaderValue::from_static("en"));
|
||||
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)
|
||||
.unwrap();
|
||||
let pl = cache
|
||||
.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 {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
_ => panic!("first representation must become leader"),
|
||||
};
|
||||
let follower_cache = cache.clone();
|
||||
let follower_ticket = pl.clone();
|
||||
let follower_settings = settings.clone();
|
||||
let follower = tokio::spawn(async move {
|
||||
assert!(matches!(
|
||||
follower_cache
|
||||
.claim_fill(&follower_ticket, &follower_settings)
|
||||
.await,
|
||||
FillClaim::Waited
|
||||
));
|
||||
assert!(follower_cache.get(&follower_ticket).is_none());
|
||||
matches!(
|
||||
follower_cache
|
||||
.claim_fill(&follower_ticket, &follower_settings)
|
||||
.await,
|
||||
FillClaim::Leader(_)
|
||||
)
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if cache.view().collapse_waiters == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
|
||||
let mut response = Response::new(Body::from("hello"));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=60"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("vary", HeaderValue::from_static("Accept-Language"));
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap_with_fill(response, en, plan, &settings, Some(leader))
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(follower.await.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_if_error_policy_is_configurable_by_error_class_and_status() {
|
||||
let mut settings = Settings::default();
|
||||
settings.stale_if_error_timeout = false;
|
||||
settings.stale_if_error_dns = false;
|
||||
settings.stale_if_error_statuses = vec![500, 503];
|
||||
assert!(!settings.allows_stale_error_class(StaleErrorClass::Timeout));
|
||||
assert!(!settings.allows_stale_error_class(StaleErrorClass::Dns));
|
||||
assert!(settings.allows_stale_error_class(StaleErrorClass::Connection));
|
||||
assert!(settings.allows_stale_error_class(StaleErrorClass::Tls));
|
||||
assert!(settings.allows_stale_error_status(StatusCode::INTERNAL_SERVER_ERROR));
|
||||
assert!(settings.allows_stale_error_status(StatusCode::SERVICE_UNAVAILABLE));
|
||||
assert!(!settings.allows_stale_error_status(StatusCode::BAD_GATEWAY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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())
|
||||
.unwrap();
|
||||
let mut settings = active_settings();
|
||||
settings.proactive_refresh_enabled = true;
|
||||
settings.proactive_refresh_percent = 100;
|
||||
settings.proactive_refresh_max_inflight = 1;
|
||||
let scheduled = cache
|
||||
.begin_proactive_schedule(&ticket, &settings)
|
||||
.expect("first schedule");
|
||||
assert!(cache.begin_proactive_schedule(&ticket, &settings).is_none());
|
||||
let running = cache
|
||||
.try_begin_proactive_refresh(&settings)
|
||||
.expect("first inflight refresh");
|
||||
assert!(cache.try_begin_proactive_refresh(&settings).is_none());
|
||||
assert_eq!(cache.view().proactive_inflight, 1);
|
||||
drop(running);
|
||||
drop(scheduled);
|
||||
assert!(cache.begin_proactive_schedule(&ticket, &settings).is_some());
|
||||
assert_eq!(cache.view().proactive_inflight, 0);
|
||||
}
|
||||
Reference in new issue
Block a user