Fix Cache: Invalidate negative email caches when an account is created

This commit is contained in:
Maurus Decimus
2026-06-20 09:38:59 +02:00
parent 0958d97925
commit 097e3ffe94
8 changed files with 110 additions and 15 deletions

View File

@@ -44,6 +44,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If
- OIDC: Add default domain name to groups that are not email addresses.
- RocksDB: Enable blob garbage collection to reclaim disk space from deleted blobs.
- Sieve: `include` statements ignore capitalisation of sub-script names (#1643)
- Cache: Invalidate negative email caches when an account is created.
- Troubleshoot tool: Use the configured source IP address when connecting to remote servers (#2867).
## [0.16.9] - 2026-06-15

View File

@@ -4,9 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
Server, auth::DomainCache, cache::invalidate::CacheInvalidationBuilder, ipc::BroadcastEvent,
};
use crate::{Server, auth::DomainCache, cache::invalidate::CacheInvalidationBuilder};
use registry::{
schema::{
prelude::{Object, ObjectType},
@@ -169,8 +167,6 @@ impl Server {
enabled: true,
description: None,
});
self.invalidate_local_negative_account_cache(local, alias_domain.id);
}
}
let mut member_group_ids = Vec::with_capacity(account.groups.len());
@@ -222,9 +218,11 @@ impl Server {
.caused_by(trc::location!())?
{
RegistryWriteResult::Success(id) => {
self.invalidate_local_negative_account_cache(local, domain.id);
self.cluster_broadcast(BroadcastEvent::CacheInvalidateNegative)
.await;
let mut invalidator = CacheInvalidationBuilder::default();
invalidator.process_create(&account);
self.invalidate_caches(invalidator)
.await
.caused_by(trc::location!())?;
Ok(AccountWithId {
id: id.document_id(),
@@ -343,8 +341,6 @@ impl Server {
enabled: true,
description: None,
});
self.invalidate_local_negative_account_cache(local, alias_domain.id);
}
}
@@ -377,7 +373,11 @@ impl Server {
.caused_by(trc::location!())?
{
RegistryWriteResult::Success(id) => {
self.invalidate_local_negative_account_cache(local, domain.id);
let mut invalidator = CacheInvalidationBuilder::default();
invalidator.process_create(&account);
self.invalidate_caches(invalidator)
.await
.caused_by(trc::location!())?;
Ok(id.document_id())
}

View File

@@ -13,7 +13,7 @@ use ahash::AHashSet;
use registry::{
schema::{
prelude::{Object, ObjectInner, ObjectType},
structs::Account,
structs::{Account, EmailAlias},
},
types::id::ObjectId,
};
@@ -57,6 +57,10 @@ impl CacheInvalidationBuilder {
self.invalidate(CacheInvalidation::Account(id));
}
if was_renamed || aliases_changed {
self.invalidate_negative_email(&new_object.inner);
}
if tenant_changed
|| groups_changed
|| credentials_changed
@@ -94,6 +98,10 @@ impl CacheInvalidationBuilder {
self.invalidate(CacheInvalidation::Account(id));
}
if was_renamed || aliases_changed {
self.invalidate_negative_email(&new_object.inner);
}
if tenant_changed || roles_changed || permissions_changed {
self.invalidate(CacheInvalidation::AccessToken(id));
}
@@ -158,6 +166,12 @@ impl CacheInvalidationBuilder {
|| (current.domain_id != new.domain_id) =>
{
self.invalidate(CacheInvalidation::List(id));
if (current.aliases != new.aliases)
|| (current.name != new.name)
|| (current.domain_id != new.domain_id)
{
self.invalidate_negative_email(&new_object.inner);
}
}
_ => {}
}
@@ -194,6 +208,34 @@ impl CacheInvalidationBuilder {
}
}
pub fn process_create(&mut self, object: &Object) {
self.invalidate_negative_email(&object.inner);
}
fn invalidate_negative_email(&mut self, object: &ObjectInner) {
let (name, domain_id, aliases) = match object {
ObjectInner::Account(Account::User(account)) => {
(&account.name, account.domain_id, &account.aliases)
}
ObjectInner::Account(Account::Group(account)) => {
(&account.name, account.domain_id, &account.aliases)
}
ObjectInner::MailingList(list) => (&list.name, list.domain_id, &list.aliases),
_ => return,
};
self.invalidate(CacheInvalidation::EmailNegative {
domain_id: domain_id.document_id(),
local_part_hash: hash_local_part(name),
});
for alias in aliases.iter().filter(|alias: &&EmailAlias| alias.enabled) {
self.invalidate(CacheInvalidation::EmailNegative {
domain_id: alias.domain_id.document_id(),
local_part_hash: hash_local_part(&alias.name),
});
}
}
pub fn invalidate(&mut self, change: CacheInvalidation) {
self.changes.insert(change);
}
@@ -298,15 +340,21 @@ impl Server {
self.inner.cache.emails_negative.clear();
}
pub fn invalidate_local_negative_account_cache(&self, local_part: &str, domain_id: u32) {
pub fn invalidate_local_negative_account_cache(
&self,
local_part: &str,
domain_id: u32,
) -> bool {
self.inner
.cache
.emails_negative
.remove(&EmailAddressRef::new(local_part, domain_id));
.remove(&EmailAddressRef::new(local_part, domain_id))
.is_some()
}
pub async fn invalidate_local_caches(&self, changes: &[CacheInvalidation]) {
let cache = &self.inner.cache;
let mut negative_emails: AHashSet<(u32, u32)> = AHashSet::new();
for change in changes {
match change {
@@ -370,11 +418,28 @@ impl Server {
.lock()
.retain(|_, v| v.tenant_id != Some(*id));
}
CacheInvalidation::EmailNegative {
domain_id,
local_part_hash,
} => {
negative_emails.insert((*domain_id, *local_part_hash));
}
}
}
if !negative_emails.is_empty() {
cache.emails_negative.retain(|key| {
!negative_emails.contains(&(key.domain_id, hash_local_part(&key.local_part)))
});
}
}
}
#[inline(always)]
fn hash_local_part(local_part: &str) -> u32 {
xxhash_rust::xxh3::xxh3_64(local_part.as_bytes()) as u32
}
impl From<CacheInvalidation> for CacheInvalidationBuilder {
fn from(invalidation: CacheInvalidation) -> Self {
let mut builder = CacheInvalidationBuilder::default();

View File

@@ -99,6 +99,10 @@ pub enum CacheInvalidation {
List(u32),
DomainLogo(u32),
TenantLogo(u32),
EmailNegative {
domain_id: u32,
local_part_hash: u32,
},
}
#[derive(Debug)]

View File

@@ -602,6 +602,7 @@ impl RegistrySet for Server {
Modification::Create { client_id, .. },
RegistryWriteResult::Success(id),
) => {
cache_invalidator.process_create(&new_object);
response.object.insert(Property::Id, RegistryValue::Id(id));
set.response
.created

View File

@@ -107,6 +107,15 @@ impl BroadcastBatch<Vec<BroadcastEvent>> {
CacheInvalidation::List(id) => (7u8, *id),
CacheInvalidation::DomainLogo(id) => (8u8, *id),
CacheInvalidation::TenantLogo(id) => (9u8, *id),
CacheInvalidation::EmailNegative {
domain_id,
local_part_hash,
} => {
serialized.push(10u8);
let _ = serialized.write_leb128(*domain_id);
let _ = serialized.write_leb128(*local_part_hash);
continue;
}
};
serialized.push(marker);
@@ -240,6 +249,14 @@ where
7 => CacheInvalidation::List(id),
8 => CacheInvalidation::DomainLogo(id),
9 => CacheInvalidation::TenantLogo(id),
10 => {
let local_part_hash =
self.messages.next_leb128::<u32>().ok_or(())?;
CacheInvalidation::EmailNegative {
domain_id: id,
local_part_hash,
}
}
_ => return Err(()),
});
}

View File

@@ -183,6 +183,11 @@ impl<K: Eq + Hash + CacheItemWeight, V: Clone + CacheItemWeight> CacheWithTtl<K,
self.0.remove(key).map(|(_, v)| v.value)
}
#[inline(always)]
pub fn retain(&self, f: impl Fn(&K) -> bool) {
self.0.retain(|key, _| f(key));
}
#[inline(always)]
pub fn clear(&self) {
self.0.clear();

View File

@@ -211,5 +211,7 @@ async fn rcpt() {
session.ehlo("mx1.foobar.org").await;
session.mail_from("idn2@example.net", "250").await;
session.rcpt_to("nobody@straß6.de", "550 5.1.2").await;
session.rcpt_to("nobody@xn--stra6-oqa.de", "550 5.1.2").await;
session
.rcpt_to("nobody@xn--stra6-oqa.de", "550 5.1.2")
.await;
}