This commit is contained in:
gru committed 2026-10-03 00:27:09 +01:00
commit 5e23e9af02
240 files changed
+99957

No files matched your search

+584
View File
@@ -0,0 +1,584 @@
use anyhow::Context as _;
const MAX_BAN_RULES: usize = 1024;
const DEFAULT_BAN_TTL_SECONDS: u64 = 3600;
const MAX_BAN_TTL_SECONDS: u64 = 604_800;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum InvalidationOperation {
Purge,
Ban,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum InvalidationSelector {
Exact,
Prefix,
Tag,
Regex,
CacheKey,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InvalidationRequest {
pub operation: InvalidationOperation,
pub selector: InvalidationSelector,
pub value: String,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub vhost: Option<String>,
#[serde(default)]
pub ttl_seconds: Option<u64>,
#[serde(default)]
pub confirm_wide: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct InvalidationResult {
pub operation: InvalidationOperation,
pub selector: InvalidationSelector,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vhost: Option<String>,
pub affected_entries: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub ban_id: Option<u64>,
pub elapsed_ms: u64,
pub status: &'static str,
}
#[derive(Debug, Clone, Serialize)]
pub struct BanView {
pub id: u64,
pub selector: InvalidationSelector,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub scheme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vhost: Option<String>,
pub created_at_ms: u64,
pub expires_at_ms: u64,
}
#[derive(Clone)]
struct SelectorMatcher {
selector: InvalidationSelector,
value: String,
scheme: Option<String>,
host: Option<String>,
vhost: Option<String>,
regex: Option<regex::Regex>,
}
#[derive(Clone)]
struct BanRule {
id: u64,
matcher: SelectorMatcher,
created_at: SystemTime,
expires_at: SystemTime,
}
fn canonical_url(scheme: &str, authority: &str, target: &str) -> String {
format!("{scheme}://{authority}{target}")
}
fn normalized_matcher(request: &InvalidationRequest) -> Result<SelectorMatcher> {
ensure!(!request.value.is_empty(), "invalidation value is required");
ensure!(request.value.len() <= 8192, "invalidation value is too long");
if let Some(vhost) = &request.vhost {
ensure!(!vhost.is_empty() && vhost.len() <= 64, "invalid vhost selector");
}
if let Some(host) = &request.host {
ensure!(!host.is_empty() && host.len() <= 253, "invalid host selector");
}
let mut value = request.value.trim().to_owned();
let mut scheme = None;
let mut host = request.host.as_ref().map(|value| value.to_ascii_lowercase());
let regex = match request.selector {
InvalidationSelector::Exact | InvalidationSelector::Prefix => {
if value.starts_with("http://") || value.starts_with("https://") {
let url = url::Url::parse(&value).context("invalid purge URL")?;
ensure!(url.username().is_empty() && url.password().is_none(), "purge URL must not contain credentials");
ensure!(url.fragment().is_none(), "purge URL must not contain a fragment");
scheme = Some(url.scheme().to_ascii_lowercase());
let authority = match url.port() {
Some(port) => format!("{}:{port}", url.host_str().context("purge URL host missing")?),
None => url.host_str().context("purge URL host missing")?.to_owned(),
};
host = Some(authority.to_ascii_lowercase());
value = url.path().to_owned();
if let Some(query) = url.query() {
value.push('?');
value.push_str(query);
}
}
ensure!(value.starts_with('/'), "exact/prefix invalidation must use an absolute URL or /path target");
None
}
InvalidationSelector::Tag => {
ensure!(value.len() <= 128, "cache tag is too long");
ensure!(value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/')), "invalid cache tag");
None
}
InvalidationSelector::Regex => {
ensure!(value.len() <= 512, "regex is too long");
Some(
regex::RegexBuilder::new(&value)
.size_limit(1 << 20)
.build()
.context("invalid or too expensive regex")?,
)
}
InvalidationSelector::CacheKey => {
ensure!(parse_key_hex(&value).is_some(), "cache_key must be 64 hexadecimal characters");
value.make_ascii_lowercase();
None
}
};
Ok(SelectorMatcher {
selector: request.selector,
value,
scheme,
host,
vhost: request.vhost.clone(),
regex,
})
}
fn requires_wide_confirmation(matcher: &SelectorMatcher) -> bool {
match matcher.selector {
InvalidationSelector::Prefix => matcher.value == "/",
InvalidationSelector::Regex => true,
_ => false,
}
}
fn host_matches(matcher: &SelectorMatcher, scheme: &str, authority: &str, domain: &str) -> bool {
if matcher
.scheme
.as_ref()
.is_some_and(|wanted| !wanted.eq_ignore_ascii_case(scheme))
{
return false;
}
matcher.host.as_ref().is_none_or(|wanted| {
wanted.eq_ignore_ascii_case(authority) || wanted.eq_ignore_ascii_case(domain)
})
}
fn matcher_matches(
matcher: &SelectorMatcher,
storage_key: &Key,
base_key: &Key,
scope: &str,
scheme: &str,
authority: &str,
domain: &str,
target: &str,
tags: &[String],
) -> bool {
if matcher.vhost.as_ref().is_some_and(|wanted| wanted != scope) {
return false;
}
match matcher.selector {
InvalidationSelector::Exact => {
host_matches(matcher, scheme, authority, domain) && target == matcher.value
}
InvalidationSelector::Prefix => {
host_matches(matcher, scheme, authority, domain) && target.starts_with(&matcher.value)
}
InvalidationSelector::Tag => {
host_matches(matcher, scheme, authority, domain)
&& tags.iter().any(|tag| tag == &matcher.value)
},
InvalidationSelector::Regex => {
if !host_matches(matcher, scheme, authority, domain) {
return false;
}
matcher
.regex
.as_ref()
.is_some_and(|regex| regex.is_match(&canonical_url(scheme, authority, target)))
}
InvalidationSelector::CacheKey => {
host_matches(matcher, scheme, authority, domain)
&& (key_hex(storage_key) == matcher.value || key_hex(base_key) == matcher.value)
}
}
}
impl BanRule {
fn expired(&self, now: SystemTime) -> bool {
now >= self.expires_at
}
fn matches_hot(&self, key: &Key, entry: &Entry, now: SystemTime) -> bool {
!self.expired(now)
&& entry.created_at <= self.created_at
&& matcher_matches(
&self.matcher,
key,
&entry.base_key,
&entry.scope,
&entry.scheme,
&entry.authority,
&entry.domain,
&entry.target,
&entry.tags,
)
}
fn matches_cold(&self, key: &Key, entry: &ColdEntry, now: SystemTime) -> bool {
!self.expired(now)
&& entry.created_at <= self.created_at
&& matcher_matches(
&self.matcher,
key,
&entry.base_key,
&entry.scope,
&entry.scheme,
&entry.authority,
&entry.domain,
&entry.target,
&entry.tags,
)
}
fn view(&self) -> BanView {
BanView {
id: self.id,
selector: self.matcher.selector,
value: self.matcher.value.clone(),
scheme: self.matcher.scheme.clone(),
host: self.matcher.host.clone(),
vhost: self.matcher.vhost.clone(),
created_at_ms: self
.created_at
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64,
expires_at_ms: self
.expires_at
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64,
}
}
}
impl Cache {
fn active_bans(&self) -> Vec<BanRule> {
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
bans.clone()
}
fn hot_entry_banned(&self, key: &Key, entry: &Entry) -> bool {
let now = SystemTime::now();
let banned = self
.active_bans()
.iter()
.any(|rule| rule.matches_hot(key, entry, now));
if banned {
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
}
banned
}
fn cold_entry_banned(&self, key: &Key, entry: &ColdEntry) -> bool {
let now = SystemTime::now();
let banned = self
.active_bans()
.iter()
.any(|rule| rule.matches_cold(key, entry, now));
if banned {
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
}
banned
}
fn entry_banned_before_insert(&self, key: &Key, entry: &Entry) -> bool {
let now = SystemTime::now();
self.active_bans()
.iter()
.any(|rule| rule.matches_hot(key, entry, now))
}
pub fn bans(&self) -> Vec<BanView> {
self.active_bans().iter().map(BanRule::view).collect()
}
pub fn remove_ban(&self, id: u64) -> bool {
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
let before = bans.len();
bans.retain(|rule| rule.id != id);
let removed = bans.len() != before;
if removed {
self.counters.bans_removed.fetch_add(1, Ordering::Relaxed);
}
removed
}
pub fn invalidate(&self, request: InvalidationRequest) -> Result<InvalidationResult> {
let started = Instant::now();
let matcher = normalized_matcher(&request)?;
if requires_wide_confirmation(&matcher) {
ensure!(request.confirm_wide, "wide invalidation requires confirm_wide=true");
}
match request.operation {
InvalidationOperation::Ban => {
let ttl = request.ttl_seconds.unwrap_or(DEFAULT_BAN_TTL_SECONDS);
ensure!((1..=MAX_BAN_TTL_SECONDS).contains(&ttl), "BAN ttl_seconds must be 1..604800");
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
ensure!(bans.len() < MAX_BAN_RULES, "active BAN rule limit reached");
let id = self.ban_sequence.fetch_add(1, Ordering::Relaxed).wrapping_add(1);
bans.push(BanRule {
id,
matcher: matcher.clone(),
created_at: now,
expires_at: now
.checked_add(Duration::from_secs(ttl))
.unwrap_or(now),
});
self.counters.bans_created.fetch_add(1, Ordering::Relaxed);
Ok(InvalidationResult {
operation: request.operation,
selector: request.selector,
value: matcher.value,
host: matcher.host,
vhost: matcher.vhost,
affected_entries: 0,
ban_id: Some(id),
elapsed_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64,
status: "active",
})
}
InvalidationOperation::Purge => {
// A selective purge is also a publication barrier for every fill that
// started before this operation. Existing unrelated objects remain
// reusable, but an old in-flight response cannot repopulate a target
// that was not yet identifiable (for example Cache-Tag before headers).
{
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
store.generation = store.generation.wrapping_add(1);
}
let affected = self.purge_matcher(&matcher);
self.counters.purges.fetch_add(1, Ordering::Relaxed);
self.counters
.purge_entries
.fetch_add(affected as u64, Ordering::Relaxed);
Ok(InvalidationResult {
operation: request.operation,
selector: request.selector,
value: matcher.value,
host: matcher.host,
vhost: matcher.vhost,
affected_entries: affected,
ban_id: None,
elapsed_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64,
status: "completed",
})
}
}
}
fn purge_matcher(&self, matcher: &SelectorMatcher) -> usize {
let mut base_keys = HashSet::new();
{
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
for (key, entry) in &store.entries {
if matcher_matches(
matcher,
key,
&entry.base_key,
&entry.scope,
&entry.scheme,
&entry.authority,
&entry.domain,
&entry.target,
&entry.tags,
) {
base_keys.insert(entry.base_key);
}
}
}
{
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
for (key, entry) in &cold.entries {
if matcher_matches(
matcher,
key,
&entry.base_key,
&entry.scope,
&entry.scheme,
&entry.authority,
&entry.domain,
&entry.target,
&entry.tags,
) {
base_keys.insert(entry.base_key);
}
}
}
let matching_flights = {
let flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
flights
.iter()
.filter_map(|(key, flight)| {
let empty_tags: &[String] = &[];
matcher_matches(
matcher,
key,
key,
&flight.scope,
&flight.scheme,
&flight.authority,
&crate::config::request_host(&flight.authority)
.unwrap_or_else(|_| flight.authority.clone()),
&flight.target,
empty_tags,
)
.then_some((*key, flight.id))
})
.collect::<Vec<_>>()
};
for (key, _) in &matching_flights {
base_keys.insert(*key);
}
if !base_keys.is_empty() {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
for key in &base_keys {
let generation = store.key_generations.entry(*key).or_default();
*generation = generation.wrapping_add(1);
}
}
let hot_removed = {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let keys = store
.entries
.iter()
.filter_map(|(key, entry)| base_keys.contains(&entry.base_key).then_some(*key))
.collect::<Vec<_>>();
for key in &keys {
store.remove(key);
}
keys.len()
};
let mut delete = Vec::new();
let cold_removed = {
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
let keys = cold
.entries
.iter()
.filter_map(|(key, entry)| base_keys.contains(&entry.base_key).then_some(*key))
.collect::<Vec<_>>();
for key in &keys {
if let Some(entry) = cold.remove(key) {
delete.extend([entry.meta_path, entry.body_path]);
}
}
keys.len()
};
if !delete.is_empty() {
if let Some(tx) = &self.cold_tx {
let _ = tx.try_send(ColdCommand::Delete(delete));
}
}
let flights = {
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
matching_flights
.into_iter()
.filter_map(|(key, id)| {
if flights.get(&key).is_some_and(|flight| flight.id == id) {
flights.remove(&key)
} else {
None
}
})
.collect::<Vec<_>>()
};
for flight in flights {
flight.done.send_replace(true);
}
if hot_removed > 0 {
release_allocator_memory();
}
hot_removed + cold_removed
}
pub fn gc_banned(&self, limit: usize) -> usize {
if limit == 0 {
return 0;
}
let rules = self.active_bans();
if rules.is_empty() {
return 0;
}
let now = SystemTime::now();
let mut removed = 0usize;
{
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let keys = store
.entries
.iter()
.filter_map(|(key, entry)| {
(removed < limit && rules.iter().any(|rule| rule.matches_hot(key, entry, now)))
.then_some(*key)
})
.take(limit)
.collect::<Vec<_>>();
for key in keys {
if store.remove(&key).is_some() {
removed += 1;
}
}
}
let mut delete = Vec::new();
if removed < limit {
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
let keys = cold
.entries
.iter()
.filter_map(|(key, entry)| {
rules
.iter()
.any(|rule| rule.matches_cold(key, entry, now))
.then_some(*key)
})
.take(limit - removed)
.collect::<Vec<_>>();
for key in keys {
if let Some(entry) = cold.remove(&key) {
delete.extend([entry.meta_path, entry.body_path]);
removed += 1;
}
}
}
if !delete.is_empty() {
if let Some(tx) = &self.cold_tx {
let _ = tx.try_send(ColdCommand::Delete(delete));
}
}
if removed > 0 {
self.counters
.ban_matches
.fetch_add(removed as u64, Ordering::Relaxed);
release_allocator_memory();
}
removed
}
}