From 2c684be5c9163c9d988d2f963c30f5ac4a65c199 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 24 Sep 2026 12:19:03 -0700 Subject: [PATCH] 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:/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. : "}; {"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. --- crates/common/src/cache/reload.rs | 200 ++++++++++++++++++++- crates/common/src/config/inner.rs | 2 + crates/common/src/lib.rs | 2 + crates/jmap-proto/src/method/set.rs | 20 +++ crates/jmap/src/registry/mapping/action.rs | 28 +-- crates/jmap/src/registry/set.rs | 44 ++--- tests/src/cluster/broadcast.rs | 45 ++++- tests/src/smtp/inbound/mod.rs | 57 ++++-- tests/src/system/auto_reload.rs | 189 +++++++++++++++++++ tests/src/system/mod.rs | 1 + 10 files changed, 520 insertions(+), 68 deletions(-) create mode 100644 tests/src/system/auto_reload.rs diff --git a/crates/common/src/cache/reload.rs b/crates/common/src/cache/reload.rs index 6efa4f0..b32c12b 100644 --- a/crates/common/src/cache/reload.rs +++ b/crates/common/src/cache/reload.rs @@ -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 { 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, +} + +#[derive(Default)] +struct SettingsReloadState { + completed: u64, + refused: Option, +} + +/// 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 { + 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> { + 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::>() + .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 +} diff --git a/crates/common/src/config/inner.rs b/crates/common/src/config/inner.rs index 094e73e..325aacc 100644 --- a/crates/common/src/config/inner.rs +++ b/crates/common/src/config/inner.rs @@ -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(), diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index ad0180a..d9538da 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -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, LogoCache>>, diff --git a/crates/jmap-proto/src/method/set.rs b/crates/jmap-proto/src/method/set.rs index 445f91f..3fd5828 100644 --- a/crates/jmap-proto/src/method/set.rs +++ b/crates/jmap-proto/src/method/set.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * 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 { #[serde(rename = "notDestroyed")] #[serde(skip_serializing_if = "VecMap::is_empty")] pub not_destroyed: VecMap, SetError>, + + // 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, +} + +/// 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, } impl<'de, T: JmapObject> DeserializeArguments<'de> for SetRequest<'de, T> { @@ -199,6 +218,7 @@ impl SetResponse { not_created: VecMap::new(), not_updated: VecMap::new(), not_destroyed: VecMap::new(), + settings_reload: None, }) } else { Err(trc::JmapEvent::RequestTooLarge.into_err()) diff --git a/crates/jmap/src/registry/mapping/action.rs b/crates/jmap/src/registry/mapping/action.rs index 800a889..2b0eb80 100644 --- a/crates/jmap/src/registry/mapping/action.rs +++ b/crates/jmap/src/registry/mapping/action.rs @@ -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) -> SetError { - 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::>() - .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) } diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index 1244219..0eda5fd 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -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 } diff --git a/tests/src/cluster/broadcast.rs b/tests/src/cluster/broadcast.rs index 7fb2f6f..541a73a 100644 --- a/tests/src/cluster/broadcast.rs +++ b/tests/src/cluster/broadcast.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -11,6 +13,7 @@ use crate::{ server::TestServerBuilder, }, }; +use common::BuildServer; use imap_proto::ResponseType; use registry::{ schema::{ @@ -18,7 +21,8 @@ use registry::{ prelude::{ObjectType, Property, SocketAddr}, structs::{ ClusterListenerGroup, ClusterListenerGroupProperties, ClusterRole, ClusterTaskGroup, - Coordinator, Imap, NatsCoordinator, NetworkListener, RedisStore, + Coordinator, Imap, MtaDeliverySchedule, MtaVirtualQueue, NatsCoordinator, + NetworkListener, RedisStore, }, }, types::map::Map, @@ -209,6 +213,45 @@ pub async fn cluster_tests() { Some("John Doe") ); + // inbuxa: a settings write applies on every node, no ReloadSettings + let queue_id = admin + .registry_create_object(MtaVirtualQueue { + name: "clusterq".into(), + threads_per_node: 1, + description: None, + }) + .await; + admin + .registry_create_object(MtaDeliverySchedule { + name: "cluster-autoreload".into(), + queue_id, + ..Default::default() + }) + .await; + for (node_id, test) in servers.iter().enumerate() { + let started = std::time::Instant::now(); + while !test + .server + .inner + .build_server() + .core + .smtp + .queue + .queue_strategy + .contains_key("cluster-autoreload") + { + assert!( + started.elapsed() < std::time::Duration::from_secs(5), + "node {node_id} didn't pick up the new delivery schedule" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + println!( + "Node {node_id} has the new delivery schedule after {} ms", + started.elapsed().as_millis() + ); + } + // Run IMAP idle tests across nodes let mut node1_client = imap_client("jdoe@example.com", "this is john's secret", 1).await; let mut node2_client = imap_client("jdoe@example.com", "this is john's secret", 2).await; diff --git a/tests/src/smtp/inbound/mod.rs b/tests/src/smtp/inbound/mod.rs index a3048dc..a6ae1de 100644 --- a/tests/src/smtp/inbound/mod.rs +++ b/tests/src/smtp/inbound/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::server::TestServer; @@ -40,15 +42,23 @@ pub mod vrfy; const EVENT_TIMEOUT: Duration = Duration::from_secs(5); impl TestServer { + // inbuxa: registry writes reload the settings, and each reload sends the + // queue a ReloadSettings; read_event, try_read_event and assert_no_events + // pass over those (expect_reload_settings still waits for one) pub async fn read_event(&mut self) -> QueueEvent { - if let Some(event) = self.queue_events.pop_front() { - return event; + while let Some(event) = self.queue_events.pop_front() { + if !event.is_reload_settings() { + return event; + } } - match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { - Ok(Some(event)) => event, - Ok(None) => panic!("Channel closed."), - Err(_) => panic!("No queue event received."), + loop { + match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { + Ok(Some(event)) if event.is_reload_settings() => (), + Ok(Some(event)) => return event, + Ok(None) => panic!("Channel closed."), + Err(_) => panic!("No queue event received."), + } } } @@ -78,26 +88,39 @@ impl TestServer { } pub async fn try_read_event(&mut self) -> Option { - if let Some(event) = self.queue_events.pop_front() { - return Some(event); + while let Some(event) = self.queue_events.pop_front() { + if !event.is_reload_settings() { + return Some(event); + } } - match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { - Ok(Some(event)) => Some(event), - Ok(None) => panic!("Channel closed."), - Err(_) => None, + loop { + match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { + Ok(Some(event)) if event.is_reload_settings() => (), + Ok(Some(event)) => return Some(event), + Ok(None) => panic!("Channel closed."), + Err(_) => return None, + } } } pub fn assert_no_events(&mut self) { - if let Some(event) = self.queue_events.pop_front() { + if let Some(event) = self + .queue_events + .iter() + .find(|event| !event.is_reload_settings()) + { panic!("Expected empty queue but got {event:?}"); } + self.queue_events.clear(); - match self.queue_rx.try_recv() { - Err(TryRecvError::Empty) => (), - Ok(event) => panic!("Expected empty queue but got {event:?}"), - Err(err) => panic!("Queue error: {err:?}"), + loop { + match self.queue_rx.try_recv() { + Ok(event) if event.is_reload_settings() => (), + Err(TryRecvError::Empty) => break, + Ok(event) => panic!("Expected empty queue but got {event:?}"), + Err(err) => panic!("Queue error: {err:?}"), + } } } diff --git a/tests/src/system/auto_reload.rs b/tests/src/system/auto_reload.rs new file mode 100644 index 0000000..c5cc36a --- /dev/null +++ b/tests/src/system/auto_reload.rs @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// inbuxa: a registry write to an object the running settings are built from +// applies without an x:Action ReloadSettings, and the set response says so. + +use crate::utils::{ + jmap::JmapResponse, + server::{TestServer, TestServerBuilder}, +}; +use common::BuildServer; +use registry::schema::{ + enums::TracingLevel, + prelude::ObjectType, + structs::{ + CertificateManagement, DkimManagement, DnsManagement, Domain, Expression, + MtaDeliverySchedule, MtaStageAuth, MtaVirtualQueue, Tracer, TracerStdout, + }, +}; +use serde_json::Value; + +#[tokio::test(flavor = "multi_thread")] +pub async fn settings_reload_tests() { + let mut test = TestServerBuilder::new("settings_reload_tests") + .await + .with_default_listeners() + .await + .with_object(MtaStageAuth { + require: Expression { + else_: "false".to_string(), + ..Default::default() + }, + ..Default::default() + }) + .await + .build() + .await; + + let admin = test + .create_user_account( + "admin", + "admin@example.org", + "these_pretzels_are_making_me_thirsty", + &[], + "Admin", + ) + .await; + test.account("admin") + .assign_roles_to_account(admin.id(), &["user", "system"]) + .await; + test.insert_account(admin); + + test_write_applies(&test).await; + + if test.is_reset() { + test.temp_dir.delete(); + } +} + +async fn test_write_applies(test: &TestServer) { + println!("Running settings reload after registry writes..."); + let admin = test.account("admin@example.org"); + + // A delivery schedule is in use as soon as it is saved + let response = admin + .registry_create([MtaVirtualQueue { + name: "autorld".into(), + threads_per_node: 2, + description: None, + }]) + .await; + assert_applied(&response); + let queue_id = response.created_id(0); + assert!(!has_schedule(test, "autoreload-schedule")); + let response = admin + .registry_create([MtaDeliverySchedule { + name: "autoreload-schedule".into(), + queue_id, + ..Default::default() + }]) + .await; + assert_applied(&response); + assert!(has_schedule(test, "autoreload-schedule")); + + // Destroyed, it's gone at once too + let schedule_id = response.created_id(0); + let response = admin + .registry_destroy(ObjectType::MtaDeliverySchedule, [schedule_id]) + .await; + assert_applied(&response); + assert!(!has_schedule(test, "autoreload-schedule")); + + // Concurrent writes all end up in the running settings + let names = (0..8) + .map(|i| format!("autoreload-{i}")) + .collect::>(); + let mut writes = Vec::new(); + for name in &names { + writes.push(admin.registry_create([MtaDeliverySchedule { + name: name.clone(), + queue_id, + ..Default::default() + }])); + } + let mut schedule_ids = Vec::new(); + for response in futures::future::join_all(writes).await { + assert_applied(&response); + schedule_ids.push(response.created_id(0)); + } + for name in &names { + assert!(has_schedule(test, name), "{name} missing"); + } + // Several objects in one request: one reload + let response = admin + .registry_destroy(ObjectType::MtaDeliverySchedule, schedule_ids.iter()) + .await; + assert_applied(&response); + for name in &names { + assert!(!has_schedule(test, name), "{name} still present"); + } + + // A write whose reload fails is stored, and the response says the + // settings weren't reloaded: only one console tracer is allowed. + let response = admin + .registry_create([ + Tracer::Stdout(TracerStdout { + enable: true, + level: TracingLevel::Error, + ..Default::default() + }), + Tracer::Stdout(TracerStdout { + enable: true, + level: TracingLevel::Error, + ..Default::default() + }), + ]) + .await; + let reload = settings_reload(&response).expect("x:settingsReload missing"); + assert_eq!(reload["applied"], Value::Bool(false), "{response:?}"); + let description = reload["description"].as_str().unwrap_or_default(); + assert!( + description.starts_with("Saved, but the running settings were not reloaded. ") + && description.contains("Only one console tracer is allowed"), + "{description}" + ); + let tracer_ids = [response.created_id(0), response.created_id(1)]; + let response = admin + .registry_destroy(ObjectType::Tracer, tracer_ids.iter()) + .await; + assert_applied(&response); + + // Data that isn't part of the running settings doesn't reload them + let response = admin + .registry_create([Domain { + name: "autoreload.example.org".into(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }]) + .await; + assert!(settings_reload(&response).is_none(), "{response:?}"); +} + +fn settings_reload(response: &JmapResponse) -> Option<&Value> { + response.pointer("/methodResponses/0/1/x:settingsReload") +} + +fn assert_applied(response: &JmapResponse) { + assert_eq!( + settings_reload(response), + Some(&serde_json::json!({"applied": true})), + "{response:?}" + ); +} + +fn has_schedule(test: &TestServer, name: &str) -> bool { + test.server + .inner + .build_server() + .core + .smtp + .queue + .queue_strategy + .contains_key(name) +} diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 8912e07..b48db1c 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -11,6 +11,7 @@ pub mod authentication; pub mod ai; pub mod ai_calibration; pub mod authorization; +pub mod auto_reload; // inbuxa: registry writes apply at once pub mod branding; pub mod crypto; pub mod delivery;