962 lines
34 KiB
Rust
962 lines
34 KiB
Rust
mod v022_cache_tests {
|
|
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 transformed_fill_retains_identical_content_and_releases_fill_gauge() {
|
|
for body in [
|
|
Bytes::from(vec![b'x'; 4096]),
|
|
Bytes::from_static(b"unchanged static bytes"),
|
|
] {
|
|
let cache = Cache::new(22);
|
|
let settings = active_settings();
|
|
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.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();
|
|
assert_eq!(policy.expected_length, Some(body.len()));
|
|
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();
|
|
assert_eq!(cached, body);
|
|
assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
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 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.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();
|
|
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!(cache.fill_bytes_active.load(Ordering::Relaxed), 0);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[test]
|
|
fn proactive_refresh_pending_tasks_are_bounded() {
|
|
let cache = Cache::new(22);
|
|
let mut settings = active_settings();
|
|
settings.proactive_refresh_enabled = true;
|
|
settings.proactive_refresh_percent = 100;
|
|
settings.proactive_refresh_max_inflight = 1;
|
|
let mut guards = Vec::new();
|
|
for index in 0..64 {
|
|
let ticket = cache
|
|
.ticket(
|
|
22,
|
|
"http",
|
|
"refresh.example",
|
|
"origin",
|
|
&format!("/asset-{index}"),
|
|
&HeaderMap::new(),
|
|
)
|
|
.unwrap();
|
|
guards.push(
|
|
cache
|
|
.begin_proactive_schedule(&ticket, &settings)
|
|
.expect("schedule below pending limit"),
|
|
);
|
|
}
|
|
let overflow = cache
|
|
.ticket(
|
|
22,
|
|
"http",
|
|
"refresh.example",
|
|
"origin",
|
|
"/overflow",
|
|
&HeaderMap::new(),
|
|
)
|
|
.unwrap();
|
|
assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none());
|
|
assert!(cache.view().proactive_skipped > 0);
|
|
drop(guards);
|
|
}
|
|
|
|
#[test]
|
|
fn background_revalidation_set_has_a_hard_limit() {
|
|
let cache = Cache::new(22);
|
|
let base = Revalidation {
|
|
headers: HeaderMap::new(),
|
|
body: Bytes::new(),
|
|
body_allocated: 0,
|
|
_transient_leases: Vec::new(),
|
|
hits: 0,
|
|
transformed: false,
|
|
storage_key: [0; 32],
|
|
resident_age_at_lookup: Duration::ZERO,
|
|
skip_compression: false,
|
|
reusable_without_validation: false,
|
|
age: 0,
|
|
ttl: 0,
|
|
stale_if_error: 0,
|
|
must_revalidate: false,
|
|
proxy_revalidate: false,
|
|
origin_etag: None,
|
|
origin_last_modified: None,
|
|
};
|
|
let mut guards = Vec::new();
|
|
for index in 0..BACKGROUND_REVALIDATION_LIMIT {
|
|
let mut cached = base.clone();
|
|
cached.storage_key[0..8].copy_from_slice(&(index as u64).to_be_bytes());
|
|
guards.push(
|
|
cache
|
|
.begin_background_revalidation(&cached)
|
|
.expect("revalidation below hard limit"),
|
|
);
|
|
}
|
|
let mut overflow = base;
|
|
overflow.storage_key = [0xff; 32];
|
|
assert!(cache.begin_background_revalidation(&overflow).is_none());
|
|
drop(guards);
|
|
}
|
|
|
|
#[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()
|
|
}
|
|
let cache = Cache::new(22);
|
|
let one = store(
|
|
&cache,
|
|
"app",
|
|
"purge.example",
|
|
"/asset.js?v=1",
|
|
HeaderMap::new(),
|
|
&[("cache-tag", "release:one")],
|
|
"one",
|
|
)
|
|
.await;
|
|
let two = store(
|
|
&cache,
|
|
"app",
|
|
"purge.example",
|
|
"/asset.js?v=2",
|
|
HeaderMap::new(),
|
|
&[("cache-tag", "release:two")],
|
|
"two",
|
|
)
|
|
.await;
|
|
let result = cache
|
|
.invalidate(invalidation(
|
|
InvalidationOperation::Purge,
|
|
InvalidationSelector::Exact,
|
|
"/asset.js?v=1",
|
|
Some("purge.example"),
|
|
Some("app"),
|
|
))
|
|
.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 three = store(
|
|
&cache,
|
|
"app",
|
|
"purge.example",
|
|
"/assets/a.css",
|
|
HeaderMap::new(),
|
|
&[("cache-tag", "release:three")],
|
|
"three",
|
|
)
|
|
.await;
|
|
let four = store(
|
|
&cache,
|
|
"app",
|
|
"purge.example",
|
|
"/assets/b.css",
|
|
HeaderMap::new(),
|
|
&[("cache-tag", "release:four")],
|
|
"four",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
cache
|
|
.invalidate(invalidation(
|
|
InvalidationOperation::Purge,
|
|
InvalidationSelector::Prefix,
|
|
"/assets/",
|
|
Some("purge.example"),
|
|
Some("app"),
|
|
))
|
|
.unwrap()
|
|
.affected_entries,
|
|
2
|
|
);
|
|
assert!(cache.get(¤t_ticket(&cache, &three)).is_none());
|
|
assert!(cache.get(¤t_ticket(&cache, &four)).is_none());
|
|
assert!(cache.get(¤t_ticket(&cache, &two)).is_some());
|
|
|
|
let tagged = store(
|
|
&cache,
|
|
"app",
|
|
"purge.example",
|
|
"/tagged",
|
|
HeaderMap::new(),
|
|
&[("cache-tag", "release:tagged")],
|
|
"tagged",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
cache
|
|
.invalidate(invalidation(
|
|
InvalidationOperation::Purge,
|
|
InvalidationSelector::Tag,
|
|
"release:tagged",
|
|
Some("purge.example"),
|
|
Some("app"),
|
|
))
|
|
.unwrap()
|
|
.affected_entries,
|
|
1
|
|
);
|
|
assert!(cache.get(¤t_ticket(&cache, &tagged)).is_none());
|
|
assert!(cache.get(¤t_ticket(&cache, &two)).is_some());
|
|
|
|
let keyed = store(
|
|
&cache,
|
|
"app",
|
|
"purge.example",
|
|
"/by-key",
|
|
HeaderMap::new(),
|
|
&[],
|
|
"keyed",
|
|
)
|
|
.await;
|
|
let key = key_hex(&keyed.key);
|
|
assert_eq!(
|
|
cache
|
|
.invalidate(invalidation(
|
|
InvalidationOperation::Purge,
|
|
InvalidationSelector::CacheKey,
|
|
&key,
|
|
Some("purge.example"),
|
|
Some("app"),
|
|
))
|
|
.unwrap()
|
|
.affected_entries,
|
|
1
|
|
);
|
|
assert!(cache.get(¤t_ticket(&cache, &keyed)).is_none());
|
|
assert!(cache.get(¤t_ticket(&cache, &two)).is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn selective_purge_is_a_barrier_for_a_fill_started_before_the_purge() {
|
|
let cache = Cache::new(22);
|
|
let settings = active_settings();
|
|
let old_ticket = cache
|
|
.ticket_scoped(
|
|
22,
|
|
"app",
|
|
"http",
|
|
"purge.example",
|
|
"origin@127.0.0.1:8080",
|
|
"/late",
|
|
&HeaderMap::new(),
|
|
)
|
|
.unwrap();
|
|
let result = cache
|
|
.invalidate(invalidation(
|
|
InvalidationOperation::Purge,
|
|
InvalidationSelector::Tag,
|
|
"release:late",
|
|
Some("purge.example"),
|
|
Some("app"),
|
|
))
|
|
.unwrap();
|
|
assert_eq!(result.affected_entries, 0);
|
|
|
|
let mut response = Response::new(Body::from("old-fill"));
|
|
response.headers_mut().insert(
|
|
"cache-control",
|
|
HeaderValue::from_static("public, max-age=60"),
|
|
);
|
|
response
|
|
.headers_mut()
|
|
.insert("cache-tag", HeaderValue::from_static("release:late"));
|
|
let plan = plan(
|
|
StatusCode::OK,
|
|
response.headers(),
|
|
&settings,
|
|
Duration::ZERO,
|
|
)
|
|
.unwrap();
|
|
cache
|
|
.wrap(response, old_ticket, plan, &settings)
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(cache.view().entries, 0, "pre-purge fill must not republish");
|
|
|
|
let new_ticket = store(
|
|
&cache,
|
|
"app",
|
|
"purge.example",
|
|
"/late",
|
|
HeaderMap::new(),
|
|
&[("cache-tag", "release:late")],
|
|
"new-fill",
|
|
)
|
|
.await;
|
|
assert!(cache.get(&new_ticket).is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn purge_of_one_vary_variant_removes_the_whole_variant_family() {
|
|
let cache = Cache::new(22);
|
|
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 = store(
|
|
&cache,
|
|
"app",
|
|
"vary.example",
|
|
"/localized",
|
|
en_headers,
|
|
&[("vary", "Accept-Language"), ("cache-tag", "variant:en")],
|
|
"hello",
|
|
)
|
|
.await;
|
|
let pl = store(
|
|
&cache,
|
|
"app",
|
|
"vary.example",
|
|
"/localized",
|
|
pl_headers,
|
|
&[("vary", "Accept-Language"), ("cache-tag", "variant:pl")],
|
|
"czesc",
|
|
)
|
|
.await;
|
|
let result = cache
|
|
.invalidate(invalidation(
|
|
InvalidationOperation::Purge,
|
|
InvalidationSelector::Tag,
|
|
"variant:en",
|
|
Some("vary.example"),
|
|
Some("app"),
|
|
))
|
|
.unwrap();
|
|
assert_eq!(result.affected_entries, 2);
|
|
assert!(cache.get(&en).is_none());
|
|
assert!(cache.get(&pl).is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ban_is_lazy_blocks_old_object_allows_new_fill_and_can_be_removed() {
|
|
let cache = Cache::new(22);
|
|
let old = store(
|
|
&cache,
|
|
"app",
|
|
"ban.example",
|
|
"/page",
|
|
HeaderMap::new(),
|
|
&[],
|
|
"old",
|
|
)
|
|
.await;
|
|
assert_eq!(cache.view().entries, 1);
|
|
let result = cache
|
|
.invalidate(invalidation(
|
|
InvalidationOperation::Ban,
|
|
InvalidationSelector::Exact,
|
|
"/page",
|
|
Some("ban.example"),
|
|
Some("app"),
|
|
))
|
|
.unwrap();
|
|
let ban_id = result.ban_id.unwrap();
|
|
assert_eq!(result.affected_entries, 0);
|
|
assert_eq!(cache.view().entries, 1, "BAN must not scan storage eagerly");
|
|
assert!(cache.get(&old).is_none());
|
|
assert_eq!(cache.view().entries, 0);
|
|
|
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
|
let fresh = store(
|
|
&cache,
|
|
"app",
|
|
"ban.example",
|
|
"/page",
|
|
HeaderMap::new(),
|
|
&[],
|
|
"fresh",
|
|
)
|
|
.await;
|
|
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());
|
|
assert!(!cache.remove_ban(ban_id));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn regex_and_root_prefix_require_explicit_wide_confirmation() {
|
|
let cache = Cache::new(22);
|
|
store(
|
|
&cache,
|
|
"app",
|
|
"wide.example",
|
|
"/one",
|
|
HeaderMap::new(),
|
|
&[],
|
|
"one",
|
|
)
|
|
.await;
|
|
let regex = invalidation(
|
|
InvalidationOperation::Purge,
|
|
InvalidationSelector::Regex,
|
|
".*",
|
|
None,
|
|
None,
|
|
);
|
|
assert!(cache.invalidate(regex.clone()).is_err());
|
|
let scoped_regex = invalidation(
|
|
InvalidationOperation::Purge,
|
|
InvalidationSelector::Regex,
|
|
"^/one$",
|
|
Some("wide.example"),
|
|
Some("app"),
|
|
);
|
|
assert!(cache.invalidate(scoped_regex).is_err());
|
|
let mut confirmed = regex;
|
|
confirmed.confirm_wide = true;
|
|
assert_eq!(cache.invalidate(confirmed).unwrap().affected_entries, 1);
|
|
|
|
let prefix = invalidation(
|
|
InvalidationOperation::Ban,
|
|
InvalidationSelector::Prefix,
|
|
"/",
|
|
Some("wide.example"),
|
|
Some("app"),
|
|
);
|
|
assert!(cache.invalidate(prefix.clone()).is_err());
|
|
let mut confirmed_prefix = prefix;
|
|
confirmed_prefix.confirm_wide = true;
|
|
assert!(cache.invalidate(confirmed_prefix).unwrap().ban_id.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn many_distinct_selective_purges_do_not_retain_tombstones_forever() {
|
|
let cache = Cache::new(22);
|
|
store(
|
|
&cache,
|
|
"app",
|
|
"memory.example",
|
|
"/stable",
|
|
HeaderMap::new(),
|
|
&[],
|
|
"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(
|
|
&cache,
|
|
"app",
|
|
"memory.example",
|
|
&target,
|
|
HeaderMap::new(),
|
|
&[],
|
|
"x",
|
|
)
|
|
.await;
|
|
assert!(cache.ticket_current(&old));
|
|
let result = cache
|
|
.invalidate(invalidation(
|
|
InvalidationOperation::Purge,
|
|
InvalidationSelector::Exact,
|
|
&target,
|
|
Some("memory.example"),
|
|
Some("app"),
|
|
))
|
|
.unwrap();
|
|
assert_eq!(result.affected_entries, 1);
|
|
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",
|
|
"memory.example",
|
|
"/unique-0",
|
|
HeaderMap::new(),
|
|
&[],
|
|
"new",
|
|
)
|
|
.await;
|
|
assert!(cache.ticket_current(&new));
|
|
assert!(cache.get(&new).is_some());
|
|
let stable = cache
|
|
.ticket_scoped(
|
|
22,
|
|
"app",
|
|
"http",
|
|
"memory.example",
|
|
"origin@127.0.0.1:8080",
|
|
"/stable",
|
|
&HeaderMap::new(),
|
|
)
|
|
.unwrap();
|
|
assert!(cache.get(&stable).is_some(), "unrelated entries must survive");
|
|
}
|
|
}
|