v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
Vendored
+584
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user