Registry writes apply to the running settings without ReloadSettings
A 3-node rehearsal found that saving an MtaDeliverySchedule left it
unknown to the queue ("Queue strategy not found") until someone ran
x:Action ReloadSettings; only Directory and Authentication writes
reloaded (DIR-17). The admin UI has to remember a separate reload after
every save, and a script or API client that doesn't gets a server
running stale settings.
x:<Object>/set now reloads the running settings when it created,
updated or destroyed an object they are built from, and broadcasts the
same RegistryChange::Reload over the coordinator as ReloadSettings, so
every node applies it:
- Settings objects (MTA, spam filter, listeners, tracers, Sieve system
scripts, cluster roles, directories, ...: the object types the core,
telemetry, listener and directory builders read) get a full reload.
- Certificates, lookup stores and blocked/allowed IPs get their own
targeted reloads.
- Accounts, domains, roles and other data read as needed, stores (they
take a restart) and applications (their own reload action) get none.
Full reloads are coalesced: a write waits for a reload that started
after it was stored and joins one if it can, so a burst of writes, or
a request with many objects, costs one or two reloads, not one each.
The write itself is never undone. When the reload is refused (build
errors in objects that were working, the rule from the previous
commit), the set response says so in a new x:settingsReload field,
{"applied": false, "description": "Saved, but the running settings
were not reloaded. <object>: <error>"}; {"applied": true} otherwise.
The field is absent when the write needs no reload. The description
helper is shared with ReloadSettings' refusal.
Each reload sends the queue a ReloadSettings event, so the SMTP test
harness's read_event, try_read_event and assert_no_events now pass over
those; expect_reload_settings still waits for one.
system::auto_reload::settings_reload_tests (new): an MtaVirtualQueue
and an MtaDeliverySchedule created over JMAP are in the running
settings with no ReloadSettings, and gone once destroyed; eight
concurrent creates all land; a write whose reload fails is stored and
reported applied: false with the error; a domain write carries no
x:settingsReload. On main the new schedule is missing. The cluster
broadcast test (three nodes, PostgreSQL + NATS) now checks that every
node has a schedule created on node 0 without a reload.
This commit is contained in:
Vendored
+199
-1
@@ -13,7 +13,7 @@ use crate::{
|
||||
storage::Storage,
|
||||
telemetry::Telemetry,
|
||||
},
|
||||
ipc::{QueueEvent, RegistryChange},
|
||||
ipc::{BroadcastEvent, QueueEvent, RegistryChange},
|
||||
network::security::{BlockedIps, IpWithTtl},
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
@@ -232,3 +232,201 @@ fn error_object(error: &Error) -> Option<ObjectId> {
|
||||
Error::Internal { object_id, .. } => *object_id,
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: upstream applied a registry write to the running settings only on
|
||||
// an explicit x:Action ReloadSettings (Directory and Authentication aside), so
|
||||
// a new MtaDeliverySchedule, say, stayed unknown ("Queue strategy not found")
|
||||
// until someone reloaded. Writes to objects the settings are built from now
|
||||
// reload them, here and across the cluster, as ReloadSettings does.
|
||||
|
||||
/// Coalesces the full reloads that registry writes trigger: a write waits for
|
||||
/// a reload that started after it was stored, and joins one if it can, so a
|
||||
/// burst of writes costs a reload or two rather than one each.
|
||||
#[derive(Default)]
|
||||
pub struct SettingsReloadGate {
|
||||
requested: std::sync::atomic::AtomicU64,
|
||||
state: tokio::sync::Mutex<SettingsReloadState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SettingsReloadState {
|
||||
completed: u64,
|
||||
refused: Option<String>,
|
||||
}
|
||||
|
||||
/// The reload a write to `object` calls for: the object to reload, or None
|
||||
/// when the running settings don't hold that object (accounts, domains and
|
||||
/// other data read as needed, stores, which take a restart, and objects with
|
||||
/// reload actions of their own, such as applications).
|
||||
pub fn write_reload_target(object: ObjectType) -> Option<ObjectType> {
|
||||
match object {
|
||||
ObjectType::Certificate => Some(ObjectType::Certificate),
|
||||
ObjectType::MemoryLookupKey
|
||||
| ObjectType::MemoryLookupKeyValue
|
||||
| ObjectType::HttpLookup
|
||||
| ObjectType::StoreLookup => Some(ObjectType::StoreLookup),
|
||||
ObjectType::BlockedIp | ObjectType::AllowedIp => Some(ObjectType::BlockedIp),
|
||||
ObjectType::AcmeProvider
|
||||
| ObjectType::AddressBook
|
||||
| ObjectType::AiModel
|
||||
| ObjectType::Asn
|
||||
| ObjectType::Authentication
|
||||
| ObjectType::Cache
|
||||
| ObjectType::Calendar
|
||||
| ObjectType::CalendarAlarm
|
||||
| ObjectType::CalendarScheduling
|
||||
| ObjectType::ClusterRole
|
||||
| ObjectType::DataRetention
|
||||
| ObjectType::Directory
|
||||
| ObjectType::DkimReportSettings
|
||||
| ObjectType::DmarcReportSettings
|
||||
| ObjectType::DnsResolver
|
||||
| ObjectType::DsnReportSettings
|
||||
| ObjectType::Email
|
||||
| ObjectType::EventTracingLevel
|
||||
| ObjectType::FileStorage
|
||||
| ObjectType::Http
|
||||
| ObjectType::HttpForm
|
||||
| ObjectType::Imap
|
||||
| ObjectType::Jmap
|
||||
| ObjectType::Metrics
|
||||
| ObjectType::MtaConnectionStrategy
|
||||
| ObjectType::MtaDeliverySchedule
|
||||
| ObjectType::MtaExtensions
|
||||
| ObjectType::MtaHook
|
||||
| ObjectType::MtaInboundSession
|
||||
| ObjectType::MtaInboundThrottle
|
||||
| ObjectType::MtaMilter
|
||||
| ObjectType::MtaOutboundStrategy
|
||||
| ObjectType::MtaOutboundThrottle
|
||||
| ObjectType::MtaQueueQuota
|
||||
| ObjectType::MtaRoute
|
||||
| ObjectType::MtaStageAuth
|
||||
| ObjectType::MtaStageConnect
|
||||
| ObjectType::MtaStageData
|
||||
| ObjectType::MtaStageEhlo
|
||||
| ObjectType::MtaStageMail
|
||||
| ObjectType::MtaStageRcpt
|
||||
| ObjectType::MtaSts
|
||||
| ObjectType::MtaTlsStrategy
|
||||
| ObjectType::MtaVirtualQueue
|
||||
| ObjectType::NetworkListener
|
||||
| ObjectType::OidcProvider
|
||||
| ObjectType::ReportSettings
|
||||
| ObjectType::Search
|
||||
| ObjectType::Security
|
||||
| ObjectType::SenderAuth
|
||||
| ObjectType::Sharing
|
||||
| ObjectType::SieveSystemInterpreter
|
||||
| ObjectType::SieveSystemScript
|
||||
| ObjectType::SieveUserInterpreter
|
||||
| ObjectType::SieveUserScript
|
||||
| ObjectType::SpamClassifier
|
||||
| ObjectType::SpamDnsblServer
|
||||
| ObjectType::SpamDnsblSettings
|
||||
| ObjectType::SpamFileExtension
|
||||
| ObjectType::SpamPyzor
|
||||
| ObjectType::SpamRule
|
||||
| ObjectType::SpamSettings
|
||||
| ObjectType::SpamTag
|
||||
| ObjectType::SpfReportSettings
|
||||
| ObjectType::SystemSettings
|
||||
| ObjectType::TaskManager
|
||||
| ObjectType::TlsReportSettings
|
||||
| ObjectType::Tracer
|
||||
| ObjectType::WebDav
|
||||
| ObjectType::WebHook => Some(object),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Applies a stored registry write to `object` to the running settings,
|
||||
/// and on success tells the other nodes to do the same. Returns None when
|
||||
/// the write needs no reload, Some(Ok(())) when it was applied, and
|
||||
/// Some(Err(reason)) when the reload was refused (the write stays stored;
|
||||
/// ReloadSettings reports the same errors).
|
||||
pub async fn reload_after_write(&self, object: ObjectType) -> Option<Result<(), String>> {
|
||||
let target = write_reload_target(object)?;
|
||||
let change = RegistryChange::Reload(target);
|
||||
|
||||
if matches!(
|
||||
target,
|
||||
ObjectType::Certificate | ObjectType::StoreLookup | ObjectType::BlockedIp
|
||||
) {
|
||||
// Cheap, and limited to their own objects
|
||||
let result = self.reload_and_broadcast(change).await;
|
||||
return Some(result);
|
||||
}
|
||||
|
||||
let gate = &self.inner.data.settings_reload;
|
||||
let ticket = gate
|
||||
.requested
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
|
||||
+ 1;
|
||||
let mut state = gate.state.lock().await;
|
||||
if state.completed >= ticket {
|
||||
// A reload that started after this write was stored has run
|
||||
return Some(state.refused.clone().map_or(Ok(()), Err));
|
||||
}
|
||||
let covers = gate.requested.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let result = self.reload_and_broadcast(change).await;
|
||||
state.completed = covers;
|
||||
state.refused = result.clone().err();
|
||||
Some(result)
|
||||
}
|
||||
|
||||
async fn reload_and_broadcast(&self, change: RegistryChange) -> Result<(), String> {
|
||||
match Box::pin(self.reload_registry(change)).await {
|
||||
Ok(reload) if !reload.has_errors() => {
|
||||
reload.log();
|
||||
self.cluster_broadcast(BroadcastEvent::RegistryChange(change))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
Ok(reload) => {
|
||||
reload.log();
|
||||
let reason = describe_reload_errors(&reload.errors);
|
||||
trc::event!(
|
||||
Registry(trc::RegistryEvent::BuildWarning),
|
||||
Details = "Settings didn't reload after a registry write",
|
||||
Reason = reason.clone(),
|
||||
);
|
||||
Err(reason)
|
||||
}
|
||||
Err(err) => {
|
||||
let reason = err.to_string();
|
||||
trc::error!(err.details("Failed to reload settings after a registry write"));
|
||||
Err(reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// inbuxa: a refused reload's errors in a sentence: the first one, naming its
|
||||
/// object, and how many more there are.
|
||||
pub fn describe_reload_errors(errors: &[Error]) -> String {
|
||||
let mut description = match errors.first() {
|
||||
Some(Error::Build { object_id, message }) => format!("{object_id}: {message}"),
|
||||
Some(Error::Validation { object_id, errors }) => format!(
|
||||
"{object_id}: {}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|err| err.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
),
|
||||
Some(Error::Internal {
|
||||
object_id: Some(object_id),
|
||||
error,
|
||||
}) => format!("{object_id}: {error}"),
|
||||
Some(Error::Internal { error, .. }) => error.to_string(),
|
||||
Some(Error::NotFound { object_id }) => format!("{object_id} was not found"),
|
||||
None => String::new(),
|
||||
};
|
||||
let more = errors.len().saturating_sub(1);
|
||||
if more > 0 {
|
||||
description.push_str(&format!(" ({more} more in the server log.)"));
|
||||
}
|
||||
description
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ impl Data {
|
||||
registry_id_gen: id_generator.clone(),
|
||||
span_id_gen: id_generator,
|
||||
queue_status: true.into(),
|
||||
settings_reload: Default::default(),
|
||||
applications,
|
||||
logos: Default::default(),
|
||||
smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"),
|
||||
@@ -235,6 +236,7 @@ impl Default for Data {
|
||||
span_id_gen: Default::default(),
|
||||
registry_id_gen: Default::default(),
|
||||
queue_status: true.into(),
|
||||
settings_reload: Default::default(),
|
||||
applications: WebApplications::new(),
|
||||
logos: Default::default(),
|
||||
smtp_connectors: TlsConnectors::try_new().unwrap(),
|
||||
|
||||
@@ -161,6 +161,8 @@ pub struct Data {
|
||||
pub span_id_gen: SnowflakeIdGenerator,
|
||||
pub registry_id_gen: SnowflakeIdGenerator,
|
||||
pub queue_status: AtomicBool,
|
||||
// inbuxa: coalesces the settings reloads registry writes trigger
|
||||
pub settings_reload: cache::reload::SettingsReloadGate,
|
||||
|
||||
pub applications: WebApplications,
|
||||
pub logos: Mutex<AHashMap<Box<str>, LogoCache>>,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::ahash_is_empty;
|
||||
@@ -71,6 +73,23 @@ pub struct SetResponse<T: JmapObject> {
|
||||
#[serde(rename = "notDestroyed")]
|
||||
#[serde(skip_serializing_if = "VecMap::is_empty")]
|
||||
pub not_destroyed: VecMap<MaybeInvalid<Id>, SetError<T::Property>>,
|
||||
|
||||
// inbuxa: on a registry write that changes the running settings, whether
|
||||
// the server applied it
|
||||
#[serde(rename = "x:settingsReload")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub settings_reload: Option<SettingsReload>,
|
||||
}
|
||||
|
||||
/// inbuxa: the settings reload that followed a registry write.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SettingsReload {
|
||||
/// The running settings (here and, through the cluster, on every node)
|
||||
/// include the write.
|
||||
pub applied: bool,
|
||||
/// Why they don't, when they don't.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl<'de, T: JmapObject> DeserializeArguments<'de> for SetRequest<'de, T> {
|
||||
@@ -199,6 +218,7 @@ impl<T: JmapObject> SetResponse<T> {
|
||||
not_created: VecMap::new(),
|
||||
not_updated: VecMap::new(),
|
||||
not_destroyed: VecMap::new(),
|
||||
settings_reload: None,
|
||||
})
|
||||
} else {
|
||||
Err(trc::JmapEvent::RequestTooLarge.into_err())
|
||||
|
||||
@@ -580,29 +580,9 @@ async fn dmarc_troubleshoot(
|
||||
/// settings weren't applied; upstream passed on the first error's bare message
|
||||
/// ("Invalid address: ..."), which read like a problem with the request.
|
||||
fn reload_refused(errors: Vec<registry::types::error::Error>) -> SetError<Property> {
|
||||
use registry::types::error::Error;
|
||||
let more = errors.len().saturating_sub(1);
|
||||
let mut description = match errors.first() {
|
||||
Some(Error::Build { object_id, message }) => format!("{object_id}: {message}"),
|
||||
Some(Error::Validation { object_id, errors }) => format!(
|
||||
"{object_id}: {}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|err| err.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
),
|
||||
Some(Error::Internal {
|
||||
object_id: Some(object_id),
|
||||
error,
|
||||
}) => format!("{object_id}: {error}"),
|
||||
Some(Error::Internal { error, .. }) => error.to_string(),
|
||||
Some(Error::NotFound { object_id }) => format!("{object_id} was not found"),
|
||||
None => String::new(),
|
||||
};
|
||||
description.insert_str(0, "Settings were not reloaded. ");
|
||||
if more > 0 {
|
||||
description.push_str(&format!(" ({more} more in the server log.)"));
|
||||
}
|
||||
let description = format!(
|
||||
"Settings were not reloaded. {}",
|
||||
common::cache::reload::describe_reload_errors(&errors)
|
||||
);
|
||||
map_bootstrap_error(errors).with_description(description)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ use directory::core::secret::{hash_secret, is_password_hash};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
method::set::{SetRequest, SetResponse},
|
||||
method::set::{SetRequest, SetResponse, SettingsReload},
|
||||
object::registry::Registry,
|
||||
references::resolve::ResolveCreatedReference,
|
||||
request::{IntoValid, MaybeInvalid},
|
||||
@@ -931,34 +931,28 @@ impl RegistrySet for Server {
|
||||
}
|
||||
};
|
||||
|
||||
// inbuxa: DIR-17: a directory or the server default applies on the
|
||||
// next request, here and on every node
|
||||
if matches!(
|
||||
object_type,
|
||||
ObjectType::Directory | ObjectType::Authentication
|
||||
) && let Ok(response) = &result
|
||||
// inbuxa: a write to an object the running settings are built from
|
||||
// applies at once, here and on every node (DIR-17 did this for
|
||||
// directories and the server default; now it covers every such object)
|
||||
let mut result = result;
|
||||
if let Ok(response) = &mut result
|
||||
&& (!response.created.is_empty()
|
||||
|| !response.updated.is_empty()
|
||||
|| !response.destroyed.is_empty())
|
||||
&& let Some(reload) = self.reload_after_write(object_type).await
|
||||
{
|
||||
let change = common::ipc::RegistryChange::Reload(ObjectType::Directory);
|
||||
match Box::pin(self.reload_registry(change)).await {
|
||||
Ok(reload) if !reload.has_errors() => {
|
||||
self.cluster_broadcast(common::ipc::BroadcastEvent::RegistryChange(change))
|
||||
.await;
|
||||
}
|
||||
Ok(reload) => {
|
||||
// inbuxa: name what stopped it
|
||||
reload.log();
|
||||
trc::event!(
|
||||
Registry(trc::RegistryEvent::BuildWarning),
|
||||
Details = "Settings didn't reload after a directory change",
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(err.details("Failed to reload directories"));
|
||||
}
|
||||
}
|
||||
response.settings_reload = Some(match reload {
|
||||
Ok(()) => SettingsReload {
|
||||
applied: true,
|
||||
description: None,
|
||||
},
|
||||
Err(reason) => SettingsReload {
|
||||
applied: false,
|
||||
description: Some(format!(
|
||||
"Saved, but the running settings were not reloaded. {reason}"
|
||||
)),
|
||||
},
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user