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, 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 { 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; 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" ); } 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" ); } }