v0.34.0_p1

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-17 22:33:00 +02:00
1 parent 00f29f92c8
commit 0a8bc6f040
21 files changed
+672 -144

No files matched your search

+1 -1
View File
@@ -174,7 +174,7 @@
tr.append(target);
const keyTags = `${row.cache_key || "-"}${row.tags?.length ? ` · ${row.tags.join(", ")}` : ""}`;
tr.append(makeCopyCell(keyTags));
for (const value of [row.tier === "cold" ? t("cacheCold") : t("cacheHot"), fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value));
for (const value of [row.tier === "cold" ? t("cacheCold") : t("cacheHot"), fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.resident_age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value));
const action = el("td"), button = el("button", t("cachePurgeUrl"), "quiet"); button.type = "button"; button.addEventListener("click", () => void purgeObject(row)); action.append(button); tr.append(action); return tr;
}));
const bans = data.bans || [];
+1
View File
@@ -1413,6 +1413,7 @@
<th data-i18n="cacheOverhead">Overhead</th>
<th>HIT</th>
<th data-i18n="cacheAge">Age</th>
<th data-i18n="cacheResidentAgeValue">Time in RustProxy</th>
<th data-i18n="cacheTtlRemaining">Remaining TTL</th>
<th data-i18n="actions">Actions</th>
</tr>
+2
View File
@@ -362,6 +362,7 @@ impl Cache {
tier: "hot",
hits: e.hits,
age_seconds: age,
resident_age_seconds: hot_resident_age(e).as_secs(),
remaining_ttl_seconds: e.ttl.saturating_sub(age),
});
}
@@ -408,6 +409,7 @@ impl Cache {
tier: "cold",
hits: e.hits,
age_seconds: age,
resident_age_seconds: cold_resident_age(e),
remaining_ttl_seconds: e.ttl.saturating_sub(age),
});
}
+52 -43
View File
@@ -18,10 +18,15 @@ impl Cache {
return None;
}
if let Some(storage_key) = matching_hot_key(&store, ticket) {
let Some(mut entry) = store.remove(&storage_key) else {
return None;
};
if self.hot_entry_banned(&storage_key, &entry) {
// Don't remove and reinsert the entire Entry + variant index on
// every HOT hit. A cache hit changes only the LRU sequence, hits
// counter, and last-access clock; body/headers remain immutable.
let banned = store
.entries
.get(&storage_key)
.is_some_and(|entry| self.hot_entry_banned(&storage_key, entry));
if banned {
store.remove(&storage_key);
if count_miss {
self.counters.misses.fetch_add(1, Ordering::Relaxed);
}
@@ -29,7 +34,10 @@ impl Cache {
release_allocator_memory();
return None;
}
let resident_age_seconds = hot_resident_age(&entry).as_secs();
let Some(entry) = store.entries.get(&storage_key) else {
return None;
};
let resident_age_seconds = hot_resident_age(entry).as_secs();
let age = entry
.initial_age
.saturating_add(entry.received.elapsed())
@@ -44,49 +52,50 @@ impl Cache {
&ticket.request_headers,
);
if state.servable() {
entry.hits = entry.hits.saturating_add(1);
entry.last_access = Instant::now();
entry.sequence = store.next_sequence();
let mut response = Response::new(Body::from(entry.body.clone()));
*response.headers_mut() = entry.headers.clone();
if entry.transformed {
response
.extensions_mut()
.insert(crate::transform::CachedTransformed);
}
if entry.skip_compression {
response.extensions_mut().insert(crate::compression::Skip);
}
response.headers_mut().insert(
"age",
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
let result = Hit {
response,
body: entry.body.clone(),
hits: entry.hits,
transformed: entry.transformed,
remaining_ttl: entry.ttl.saturating_sub(age),
resident_age_seconds,
stale: state.stale(),
background_revalidate: state.background_revalidate(),
let sequence = store.next_sequence();
let (old_sequence, scope, domain, body_bytes, result) = {
let entry = store.entries.get_mut(&storage_key)?;
let old_sequence = entry.sequence;
entry.hits = entry.hits.saturating_add(1);
entry.last_access = Instant::now();
entry.sequence = sequence;
let mut response = Response::new(Body::from(entry.body.clone()));
*response.headers_mut() = entry.headers.clone();
if entry.transformed {
response
.extensions_mut()
.insert(crate::transform::CachedTransformed);
}
if entry.skip_compression {
response.extensions_mut().insert(crate::compression::Skip);
}
response.headers_mut().insert(
"age",
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
let result = Hit {
response,
body: entry.body.clone(),
hits: entry.hits,
transformed: entry.transformed,
remaining_ttl: entry.ttl.saturating_sub(age),
resident_age_seconds,
stale: state.stale(),
background_revalidate: state.background_revalidate(),
};
(old_sequence, entry.scope.clone(), entry.domain.clone(), entry.body.len(), result)
};
if let Some(c) = store.domain(&entry.scope, &entry.domain) {
c.saved_body_bytes = c.saved_body_bytes.saturating_add(entry.body.len() as u64);
store.lru.remove(&old_sequence);
store.lru.insert(sequence, storage_key);
// `store.bytes`, `store.logical_bytes` and the Vary schema index
// did not change. In particular, this path does not allocate a
// replacement variants Vec for every response.
if let Some(counter) = store.domain(&scope, &domain) {
counter.saved_body_bytes = counter.saved_body_bytes.saturating_add(body_bytes as u64);
}
store.bytes = store.bytes.saturating_add(entry.cost);
store.logical_bytes = store.logical_bytes.saturating_add(entry.body.len());
store.lru.insert(entry.sequence, storage_key);
store.index_variant(entry.base_key, storage_key);
store.entries.insert(storage_key, entry);
self.counters.hits.fetch_add(1, Ordering::Relaxed);
return Some(result);
}
store.bytes = store.bytes.saturating_add(entry.cost);
store.logical_bytes = store.logical_bytes.saturating_add(entry.body.len());
store.lru.insert(entry.sequence, storage_key);
store.index_variant(entry.base_key, storage_key);
store.entries.insert(storage_key, entry);
}
if count_miss {
self.counters.misses.fetch_add(1, Ordering::Relaxed);
+3
View File
@@ -28,7 +28,10 @@ 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,
}
#[derive(Debug, Clone, Serialize)]
+18 -15
View File
@@ -137,26 +137,29 @@ pub fn plan(
} else {
None
};
// Keep the freshness lifetime separate from validator/stale retention.
// `no-cache` may be stored with a zero freshness lifetime but must validate
// every reuse; it is retained by the bounded revalidation horizon.
let ttl = match declared_ttl {
Some(value) => value.min(settings.max_ttl_seconds),
None if requires_revalidation => 0,
None => return None,
};
let age = match headers.get("age") {
Some(v) => v.to_str().ok()?.parse::<u64>().ok()?,
None => 0,
};
// Keep sub-second precision internally and floor only when serializing the
// Age header. RFC 9111 adds response delay after correcting received age.
// 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();
// RFC 9111 current_age: corrected_received_age is the greater of Date-based
// apparent age and the received Age value; response delay is then added to
// that result (not only to the Age branch).
let corrected_received_age = apparent_age.max(Duration::from_secs(age));
let initial_age = corrected_received_age.saturating_add(response_delay);
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)),
None if requires_revalidation => 0,
None => return None,
};
if !requires_revalidation
&& initial_age.as_secs()
>= retention_deadline(
+45
View File
@@ -89,6 +89,51 @@ async fn selective_purge_removes_matching_cold_object() {
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();
+107 -1
View File
@@ -606,7 +606,7 @@ mod tests {
..Default::default()
};
let body = br#"{ "a": 1 }"#;
let mut response = Response::new(Body::from(body.as_slice()));
let mut response = Response::new(Body::from(Bytes::from_static(body)));
response
.headers_mut()
.insert("content-type", HeaderValue::from_static("application/json"));
@@ -1258,6 +1258,93 @@ mod tests {
.unwrap();
assert_eq!(fresh.initial_age.as_secs(), 0);
}
#[test]
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("age", HeaderValue::from_static("1553483"));
headers.insert("etag", HeaderValue::from_static("\"origin-etag\""));
let original = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap();
assert_eq!(original.initial_age.as_secs(), 1553483);
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
);
// 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());
}
#[test]
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("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));
assert!(p.initial_age >= Duration::from_secs(10));
h.insert("age", HeaderValue::from_static("40"));
let p = plan(StatusCode::OK, &h, &s, Duration::from_secs(2)).unwrap();
assert_eq!(p.initial_age.as_secs(), 42);
}
#[tokio::test]
async fn aged_transformed_css_remains_hot_and_visible_in_dashboard() {
let cache = Cache::new(1);
let settings = settings();
let ticket = ticket(&cache, &HeaderMap::new());
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();
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());
assert_eq!(transformed.headers()["age"], "1553483");
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");
assert!(hit.transformed);
assert!(hit.remaining_ttl >= 86_000);
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);
assert_eq!(dash.matching_objects, 1);
assert!(dash.objects[0].transformed);
assert!(dash.objects[0].age_seconds >= 1_553_483);
assert!(dash.objects[0].resident_age_seconds < 10);
assert!(dash.objects[0].remaining_ttl_seconds >= 86_000);
assert_eq!(dash.objects[0].hits, 1);
}
#[tokio::test]
async fn purged_inflight_fill_cannot_repopulate() {
let cache = Cache::new(1);
@@ -1290,6 +1377,25 @@ mod tests {
.is_none());
}
#[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 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();
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_eq!(cache.get(&c).unwrap().hits, 1);
assert_eq!(cache.view().evictions, 1);
assert_eq!(cache.view().entries, 2);
}
#[tokio::test]
async fn hot_byte_budget_evicts_before_another_fill_grows() {
let cache = Cache::new(1);
let s = Settings {