v0.34.0_1

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-17 20:34:13 +02:00
1 parent 9b3d6731cc
commit 00f29f92c8
68 files changed
+2599 -513

No files matched your search

+3 -1
View File
@@ -98,12 +98,14 @@ impl App {
.as_ref()
.and_then(|path| path.parent())
.map(std::path::Path::to_path_buf);
let cache = crate::cache::Cache::start(
let cache = crate::cache::Cache::start_with_scope_revisions(
cache_revision(&snapshot.envelope.config),
cache_data_root,
snapshot.envelope.config.cache.clone(),
shutdown.clone(),
vhost_cache_revisions(&snapshot.envelope.config),
);
cache.set_control_revision(snapshot.envelope.revision);
let active_listeners = snapshot.listeners.clone();
let active_streams = snapshot.envelope.config.streams.clone();
let stream_metrics = active_streams
+48 -1
View File
@@ -7,6 +7,17 @@ mod cache_revision_tests {
let base = config::demo_config("127.0.0.1:9000".parse().unwrap());
let identity = cache_revision(&base);
let mut file_transport = base.clone();
file_transport.web_services.push(crate::web_server::Service {
id: "static-test".into(), name: "Static Test".into(),
document_root: "/var/www/static-test".into(),
..Default::default()
});
let mut zero_copy = file_transport.clone();
zero_copy.web_services[0].sendfile = crate::web_server::SendfileMode::Auto;
assert_eq!(cache_revision(&file_transport), cache_revision(&zero_copy));
assert!(matches!(cache_invalidation(&file_transport, &zero_copy), CacheInvalidation::None));
let mut logging = base.clone();
logging.logging.file.path = "/tmp/rustproxy-test-access.jsonl".into();
assert_eq!(cache_revision(&logging), identity);
@@ -107,8 +118,44 @@ mod cache_revision_tests {
CacheInvalidation::All(value) if value == cache_identity
));
let mut operational = base.clone();
operational.vhosts[0].load_balancing.keepalive.enabled = false;
operational.vhosts[0].load_balancing.keepalive.idle_timeout_seconds = 90;
operational.vhosts[0].load_balancing.keepalive.max_idle_per_host = 32;
operational.vhosts[0].load_balancing.keepalive.tcp_keepalive_seconds = 90;
operational.vhosts[0].load_balancing.keepalive.pool_recycle_requests = 1_000;
operational.vhosts[0].load_balancing.keepalive.pool_recycle_seconds = 1_800;
operational.vhosts[0].load_balancing.keepalive.http1_max_buffer_bytes = 65_536;
operational.vhosts[0].backends[0].weight += 1;
operational.vhosts[0].backends[0].max_concurrent_requests = 16;
operational.vhosts[0].backends[0].queue_timeout_ms = 100;
operational.vhosts[0].timing_headers = true;
operational.vhosts[0].acl_profile_id = Some("test-only-acl".into());
operational.vhosts[0].user_agent_blocking = Default::default();
operational.vhosts[0].proxy_timeouts.read_timeout_seconds = Some(180);
assert_eq!(cache_revision(&operational), identity);
assert_eq!(vhost_cache_revision(&operational.vhosts[0]), vhost_cache_revision(&base.vhosts[0]));
assert!(matches!(cache_invalidation(&base, &operational), CacheInvalidation::None));
assert!(matches!(cache_invalidation(&operational, &base), CacheInvalidation::None));
let mut vhost = base.clone();
vhost.vhosts[0].backends[0].url = "http://127.0.0.1:9001".into();
assert_ne!(cache_revision(&vhost), identity);
assert_eq!(cache_revision(&vhost), identity);
let affected_id = base.vhosts[0].id.clone();
assert!(matches!(cache_invalidation(&base, &vhost), CacheInvalidation::Scopes(ids) if ids == vec![affected_id]));
// A content change must not invalidate another independent vhost.
let mut multiple = base.clone();
let mut another = multiple.vhosts[0].clone();
another.id = "other-scope".into();
another.frontend.hosts = vec!["other.example.test".into()];
multiple.vhosts.push(another);
let mut changed_one = multiple.clone();
changed_one.vhosts[0].backends[0].url = "http://127.0.0.1:9002".into();
assert!(matches!(cache_invalidation(&multiple, &changed_one), CacheInvalidation::Scopes(ids)
if ids == vec![base.vhosts[0].id.clone()]));
let mut changed_global = multiple.clone();
changed_global.transform.max_body_bytes += 1;
assert!(matches!(cache_invalidation(&multiple, &changed_global), CacheInvalidation::All(_)));
}
}
+86 -30
View File
@@ -78,25 +78,19 @@ impl IntoResponse for ApiError {
enum CacheInvalidation {
None,
All(u64),
Scopes(Vec<String>),
}
/// Stable cache compatibility identity. Unlike the configuration revision, this
/// changes only when a setting can alter an origin request or cached response.
/// Keeping it stable across logging/TLS/admin saves lets the persistent cold
/// tier survive a restart after unrelated control-plane edits.
/// Global compatibility identity for HOT/COLD cache entries. Vhost-specific
/// representation changes use individual scope generations and COLD fingerprints.
/// Connection-pool/LB knobs do not enter either identity.
fn cache_revision(config: &config::Config) -> u64 {
// Preset fields are UI intent metadata. Persist them, but do not rotate the
// cache compatibility identity (and purge HOT/COLD) when only the label
// changes while the effective cache limits stay identical.
let mut cache = config.cache.clone();
cache.memory_preset = None;
cache.cold_preset = None;
// Request bypass switches and response-only diagnostic headers do not alter
// already cached representations. Keep cache objects across these policy
// edits; the new behavior applies immediately on the next request.
// These are request admission or response diagnostics, never stored objects.
cache.bypass_query = false;
cache.bypass_prefixes.clear();
// Remote maintenance permissions cannot change stored object representations.
cache.http_invalidation_allowed_cidrs.clear();
cache.expose_status = false;
cache.expose_hits = false;
@@ -104,43 +98,105 @@ fn cache_revision(config: &config::Config) -> u64 {
cache.expose_resident_age = false;
cache.expose_only_hits = false;
cache.via_header = None;
// Resource-only transform controls decide how much optimization work may
// happen concurrently, but do not change the transformed representation.
// Keep those out of the cache identity while including representation and
// eligibility controls such as enabled kinds, extensions and max body.
let mut transform = config.transform.clone();
transform.preset = None;
transform.memory_budget_bytes = 0;
transform.max_inflight = 0;
// A per-vhost diagnostics override is also presentation-only and must not
// invalidate cached objects when switched from inherited to HIT-only.
let mut vhosts = config.vhosts.clone();
for vhost in &mut vhosts {
vhost.cache.expose_only_hits = None;
// Frontend ALPN/QUIC switches decide how a client reaches a vhost,
// not which representation the origin returns. Hot and cold entries
// must survive HTTP/2 or HTTP/3 opt-in/out saves.
vhost.http2_enabled = true;
vhost.http3_enabled = true;
// Web services and FPM profiles can change the bytes served by a location.
// Until their dependency graph is split into scopes, invalidate globally.
let mut web_services = config.web_services.clone();
for service in &mut web_services {
// Kernel write strategy never changes the HTTP representation.
service.sendfile = crate::web_server::SendfileMode::Off;
}
let encoded = serde_json::to_vec(&(
cache_digest(&(
&cache,
&transform,
&config.headers,
&config.forwarding,
&vhosts,
&web_services,
&config.php_fpm_profiles,
))
.expect("serializable cache compatibility view");
}
fn cache_digest<T: serde::Serialize>(value: &T) -> u64 {
let encoded = serde_json::to_vec(value).expect("serializable cache compatibility view");
let digest = Sha256::digest(encoded);
u64::from_be_bytes(digest[..8].try_into().expect("sha256 prefix"))
}
/// Only effective origin/content policies contribute to this scope identity.
/// A vhost keepalive or load-balancer control save must retain both cache tiers.
fn vhost_cache_revision(vhost: &crate::vhost::Vhost) -> u64 {
let mut view = vhost.clone();
view.websockets = false;
view.websocket_legacy_support = false;
view.websocket_rfc8441 = false;
view.websocket_rfc9220 = false;
view.http2_enabled = true;
view.http3_enabled = true;
view.enabled = true;
view.force_ssl = false;
view.www_redirect = false;
view.www_redirect_target = None;
view.timing_headers = false;
view.backend_header = false;
view.backend_timing_header = false;
view.certificate_id = None;
view.tls_policy = None;
view.server_header = None;
view.diagnostic_headers = None;
view.rate_limit = Default::default();
view.method_policy = Default::default();
view.basic_auth = Default::default();
view.tinyauth = Default::default();
view.acl = None;
view.acl_profile_id = None;
view.user_agent_blocking = Default::default();
view.proxy_timeouts = Default::default();
view.frontend.listener_ids.clear();
view.cache.expose_only_hits = None;
// The complete LB policy (keepalive, recycling, weights, retries, health,
// affinity and queues) governs connection selection, not cache identity.
// Sticky authentication checks and bypass still run before the cache lookup.
view.load_balancing = Default::default();
for member in &mut view.backends {
member.weight = 1;
member.backup = false;
member.drain = false;
member.health_check_enabled = true;
member.health_check = None;
member.max_concurrent_requests = 0;
member.queue_timeout_ms = 0;
}
cache_digest(&view)
}
fn vhost_cache_revisions(config: &config::Config) -> HashMap<String, u64> {
config.vhosts.iter().map(|vhost| (vhost.id.clone(), vhost_cache_revision(vhost))).collect()
}
fn cache_invalidation(previous: &config::Config, next: &config::Config) -> CacheInvalidation {
let before = cache_revision(previous);
let after = cache_revision(next);
if before == after {
if before != after {
return CacheInvalidation::All(after);
}
let old = vhost_cache_revisions(previous);
let new = vhost_cache_revisions(next);
let mut changed: Vec<_> = old
.iter()
.filter(|(id, revision)| new.get(*id) != Some(*revision))
.map(|(id, _)| id.clone())
.collect();
// New scopes do not have previous cache objects, but include the set for
// completeness if an ID is ever reused within a single config transaction.
changed.extend(new.keys().filter(|id| !old.contains_key(*id)).cloned());
changed.sort();
changed.dedup();
if changed.is_empty() {
CacheInvalidation::None
} else {
CacheInvalidation::All(after)
CacheInvalidation::Scopes(changed)
}
}
+12
View File
@@ -147,12 +147,24 @@ impl App {
// intentionally decoupled from the configuration revision, so unrelated
// control-plane saves do not invalidate hot/cold objects.
let envelope = prepared.envelope.clone();
// After commit, reject late cache tickets from the old routing
// snapshot before any scope purge. Existing unaffected objects stay.
this.cache.set_control_revision(envelope.revision);
match invalidation {
CacheInvalidation::All(cache_revision) => {
this.cache.purge(Some(cache_revision));
}
CacheInvalidation::Scopes(scopes) => {
for scope in scopes {
this.cache.purge_scope(&scope);
}
}
CacheInvalidation::None => {}
}
// A COLD entry is usable after restart only if its scope still has
// the same origin/content policy. Publish the fingerprints after
// the cache generation barriers but before the routing snapshot.
this.cache.replace_scope_revisions(vhost_cache_revisions(&envelope.config));
this.cache.configure(envelope.config.cache.clone());
this.rate_limiter.reset();
this.logs.configure(envelope.config.logging.clone());