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

+8
View File
@@ -51,11 +51,15 @@ impl Cache {
.fetch_add(1, Ordering::Relaxed);
return false;
};
let scope_revision = self.scope_revisions
.lock().unwrap_or_else(|e| e.into_inner())
.get(&entry.scope).copied().unwrap_or(0);
self.cold_queue_items.fetch_add(1, Ordering::Relaxed);
if tx
.try_send(ColdCommand::Store(ColdDraft {
key,
revision,
scope_revision,
generation,
scope_generation,
key_generation,
@@ -186,9 +190,13 @@ impl Cache {
}),
}
});
let scope_revision = self.scope_revisions
.lock().unwrap_or_else(|e| e.into_inner())
.get(&entry.scope).copied().unwrap_or(0);
ColdDraft {
key,
revision,
scope_revision,
generation,
scope_generation,
key_generation,
+7 -1
View File
@@ -72,7 +72,13 @@ impl Cache {
continue;
};
let body_path = shard.join(&file.body_file);
if file.format != COLD_FORMAT_VERSION || file.revision != revision {
let expected_scope_revision = self.scope_revisions
.lock().unwrap_or_else(|e| e.into_inner())
.get(&file.scope).copied().unwrap_or(0);
if file.format != COLD_FORMAT_VERSION
|| file.revision != revision
|| file.scope_revision != expected_scope_revision
{
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&body_path);
continue;
+1
View File
@@ -121,6 +121,7 @@ impl Cache {
base_key: key_hex(&draft.entry.base_key),
vary: draft.entry.vary.clone(),
revision: draft.revision,
scope_revision: draft.scope_revision,
scope: draft.entry.scope.clone(),
scheme: draft.entry.scheme.clone(),
authority: draft.entry.authority.clone(),
+23
View File
@@ -8,6 +8,7 @@ impl Cache {
revision: AtomicU64::new(revision),
cold: Arc::new(Mutex::new(ColdStore::default())),
tier_settings: Mutex::new(Settings::default()),
scope_revisions: Mutex::new(HashMap::new()),
data_root: None,
cold_tx: None,
cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS)),
@@ -37,6 +38,16 @@ impl Cache {
data_root: Option<PathBuf>,
settings: Settings,
shutdown: CancellationToken,
) -> Arc<Self> {
Self::start_with_scope_revisions(revision, data_root, settings, shutdown, HashMap::new())
}
pub fn start_with_scope_revisions(
revision: u64,
data_root: Option<PathBuf>,
settings: Settings,
shutdown: CancellationToken,
scope_revisions: HashMap<String, u64>,
) -> Arc<Self> {
let (tx, rx) = mpsc::channel(COLD_QUEUE);
let cache = Arc::new(Self {
@@ -47,6 +58,7 @@ impl Cache {
revision: AtomicU64::new(revision),
cold: Arc::new(Mutex::new(ColdStore::default())),
tier_settings: Mutex::new(settings),
scope_revisions: Mutex::new(scope_revisions),
data_root,
cold_tx: Some(tx),
cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS)),
@@ -291,6 +303,17 @@ impl Cache {
}
}
/// Block requests bound to an old immutable routing snapshot from creating
/// cache tickets during a concurrent configuration publication. This does
/// not clear HOT or COLD and does not modify the representation revision.
pub fn set_control_revision(&self, revision: u64) {
self.store.lock().unwrap_or_else(|e| e.into_inner()).control_revision = revision;
}
pub fn replace_scope_revisions(&self, scopes: HashMap<String, u64>) {
*self.scope_revisions.lock().unwrap_or_else(|e| e.into_inner()) = scopes;
}
pub fn configure(&self, settings: Settings) {
*self.tier_settings.lock().unwrap_or_else(|e| e.into_inner()) = settings.clone();
// Hot-applied limit reductions take effect immediately for published HOT
+7
View File
@@ -201,6 +201,9 @@ struct ColdFile {
#[serde(default)]
vary: Vec<String>,
revision: u64,
/// 0 denotes legacy/unscoped entries; production vhosts require an exact match.
#[serde(default)]
scope_revision: u64,
scope: String,
#[serde(default = "default_cache_scheme")]
scheme: String,
@@ -296,6 +299,7 @@ struct ColdStore {
struct ColdDraft {
key: Key,
revision: u64,
scope_revision: u64,
generation: u64,
scope_generation: u64,
key_generation: u64,
@@ -321,6 +325,9 @@ struct Store {
sequence: u64,
generation: u64,
revision: u64,
/// Current persisted administrative config revision. Distinct from cache
/// representation revision: operational saves do not evict data.
control_revision: u64,
scope_generations: HashMap<String, u64>,
key_generations: HashMap<Key, u64>,
domains: BTreeMap<(String, String), DomainCounters>,
+2
View File
@@ -55,6 +55,8 @@ pub struct Cache {
revision: AtomicU64,
cold: Arc<Mutex<ColdStore>>,
tier_settings: Mutex<Settings>,
/// Per-vhost content compatibility used by persisted COLD metadata.
scope_revisions: Mutex<HashMap<String, u64>>,
data_root: Option<PathBuf>,
cold_tx: Option<mpsc::Sender<ColdCommand>>,
cold_delete_fallbacks: Arc<Semaphore>,
+35
View File
@@ -774,4 +774,39 @@ fn cold_path_cannot_escape_data_directory() {
settings.cold_path = "cache/cold".into();
assert!(settings.validate().is_ok());
}
#[tokio::test]
async fn cold_scope_fingerprint_preserves_unmodified_vhost_and_rejects_stale_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let mut fingerprints = HashMap::new();
fingerprints.insert("cold-test".to_owned(), 77);
let cache = Cache::start_with_scope_revisions(
11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone(),
fingerprints.clone(),
);
fill(&cache, &settings, "/one", "stable-contents").await;
fill(&cache, &settings, "/two", "second-contents").await;
wait_for_cold(&cache, 1).await;
shutdown.cancel();
drop(cache);
let restart = CancellationToken::new();
let same = Cache::start_with_scope_revisions(
11, Some(dir.path().to_path_buf()), settings.clone(), restart.clone(),
fingerprints.clone(),
);
assert_eq!(same.view().cold_entries, 1, "operational saves retain COLD");
restart.cancel();
drop(same);
fingerprints.insert("cold-test".to_owned(), 78);
let restart = CancellationToken::new();
let changed = Cache::start_with_scope_revisions(
11, Some(dir.path().to_path_buf()), settings.clone(), restart.clone(), fingerprints,
);
assert_eq!(changed.view().cold_entries, 0, "old COLD must never survive a content revision");
restart.cancel();
}
}
+16
View File
@@ -54,6 +54,22 @@ mod scope_tests {
fill(&cache, ticket("a")).await;
assert_eq!(cache.scope_view("a").entries, 1);
}
#[test]
fn stale_snapshot_cannot_issue_cache_ticket_after_config_commit() {
let cache = Cache::new(1);
cache.set_control_revision(7);
let ticket = |revision| cache.ticket_scoped_for_config(
revision, 1, "vhost-a", "http", "same.test", "main", "/resource", &HeaderMap::new()
);
assert!(ticket(7).is_some(), "the current snapshot can cache");
// An operational save only advances the config publication barrier.
// It must not change the representation revision or clear any objects.
cache.set_control_revision(8);
assert_eq!(cache.revision(), 1);
assert!(ticket(7).is_none(), "late request from the previous snapshot must bypass");
assert!(ticket(8).is_some(), "the new snapshot can reuse the same cache identity");
}
#[test]
fn per_vhost_settings_inherit_and_cannot_expand_shared_capacity() {
let global = Settings::default();
+36 -1
View File
@@ -22,6 +22,39 @@ impl Cache {
_route: &str,
target: &str,
headers: &HeaderMap,
) -> Option<Ticket> {
self.ticket_scoped_impl(None, revision, scope, scheme, authority, _route, target, headers)
}
/// Production requests bind the cache ticket to the immutable routing
/// configuration used to authorize/route the request. After a config save,
/// stale in-flight snapshots cannot re-fill a scope after its purge.
#[allow(clippy::too_many_arguments)]
pub fn ticket_scoped_for_config(
&self,
control_revision: u64,
revision: u64,
scope: &str,
scheme: &str,
authority: &str,
route: &str,
target: &str,
headers: &HeaderMap,
) -> Option<Ticket> {
self.ticket_scoped_impl(Some(control_revision), revision, scope, scheme, authority, route, target, headers)
}
#[allow(clippy::too_many_arguments)]
fn ticket_scoped_impl(
&self,
expected_control_revision: Option<u64>,
revision: u64,
scope: &str,
scheme: &str,
authority: &str,
_route: &str,
target: &str,
headers: &HeaderMap,
) -> Option<Ticket> {
if target.len() > 8192 || scope.len() > 64 {
return None;
@@ -50,7 +83,9 @@ impl Cache {
}
let key: Key = digest.finalize().into();
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
(store.revision == revision).then_some(Ticket {
(store.revision == revision
&& expected_control_revision.is_none_or(|expected| expected == store.control_revision))
.then_some(Ticket {
scheme: hash_scheme,
authority: hash_authority,
domain,