Compare commits
9
Commits
999ae12cc7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d86e7639ac | ||
|
|
fcef4b1c3f | ||
|
|
89860aa5cc | ||
|
|
6e50ba25a9 | ||
|
|
127ef5701d | ||
|
|
1543ea5a9e | ||
|
|
4cb42f28f3 | ||
|
|
2c684be5c9 | ||
|
|
19eb25a426 |
Vendored
+204
-1
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
storage::Storage,
|
storage::Storage,
|
||||||
telemetry::Telemetry,
|
telemetry::Telemetry,
|
||||||
},
|
},
|
||||||
ipc::{QueueEvent, RegistryChange},
|
ipc::{BroadcastEvent, QueueEvent, RegistryChange},
|
||||||
network::security::{BlockedIps, IpWithTtl},
|
network::security::{BlockedIps, IpWithTtl},
|
||||||
};
|
};
|
||||||
use ahash::AHashMap;
|
use ahash::AHashMap;
|
||||||
@@ -232,3 +232,206 @@ fn error_object(error: &Error) -> Option<ObjectId> {
|
|||||||
Error::Internal { object_id, .. } => *object_id,
|
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). Blocked IPs have a
|
||||||
|
/// reload of their own; allowed IPs take the full one.
|
||||||
|
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 => Some(ObjectType::BlockedIp),
|
||||||
|
// Allowed IPs are part of the core's security settings
|
||||||
|
// (Security::parse), which only a full reload rebuilds; the blocked-IP
|
||||||
|
// reload doesn't touch them
|
||||||
|
ObjectType::AllowedIp
|
||||||
|
| 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(),
|
registry_id_gen: id_generator.clone(),
|
||||||
span_id_gen: id_generator,
|
span_id_gen: id_generator,
|
||||||
queue_status: true.into(),
|
queue_status: true.into(),
|
||||||
|
settings_reload: Default::default(),
|
||||||
applications,
|
applications,
|
||||||
logos: Default::default(),
|
logos: Default::default(),
|
||||||
smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"),
|
smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"),
|
||||||
@@ -235,6 +236,7 @@ impl Default for Data {
|
|||||||
span_id_gen: Default::default(),
|
span_id_gen: Default::default(),
|
||||||
registry_id_gen: Default::default(),
|
registry_id_gen: Default::default(),
|
||||||
queue_status: true.into(),
|
queue_status: true.into(),
|
||||||
|
settings_reload: Default::default(),
|
||||||
applications: WebApplications::new(),
|
applications: WebApplications::new(),
|
||||||
logos: Default::default(),
|
logos: Default::default(),
|
||||||
smtp_connectors: TlsConnectors::try_new().unwrap(),
|
smtp_connectors: TlsConnectors::try_new().unwrap(),
|
||||||
|
|||||||
@@ -345,8 +345,13 @@ pub struct TaskLocks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TaskLocks {
|
impl TaskLocks {
|
||||||
/// How long a task lock lasts, in seconds, unless it is released first.
|
/// How long a task lock lasts, in seconds, unless it is released first
|
||||||
pub const DEFAULT_EXPIRY: u64 = 60 * 60;
|
/// or renewed. inbuxa: upstream held a lock for an hour, so a killed
|
||||||
|
/// node's tasks waited that long; the lock is now a five-minute lease
|
||||||
|
/// that the task manager renews every third of it while the task runs
|
||||||
|
/// (renew_task_locks), so a dead node's tasks run elsewhere within
|
||||||
|
/// minutes.
|
||||||
|
pub const DEFAULT_EXPIRY: u64 = 5 * 60;
|
||||||
|
|
||||||
pub fn is_stopping(&self) -> bool {
|
pub fn is_stopping(&self) -> bool {
|
||||||
self.stopping.load(Ordering::Acquire)
|
self.stopping.load(Ordering::Acquire)
|
||||||
@@ -370,6 +375,16 @@ impl TaskLocks {
|
|||||||
self.held.lock().len()
|
self.held.lock().len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: the tasks this node holds, to renew their locks.
|
||||||
|
pub fn held_ids(&self) -> Vec<u64> {
|
||||||
|
self.held.lock().iter().copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: whether this node holds (and is running) the task.
|
||||||
|
pub fn is_held(&self, id: u64) -> bool {
|
||||||
|
self.held.lock().contains(&id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn expiry(&self) -> u64 {
|
pub fn expiry(&self) -> u64 {
|
||||||
self.expiry.load(Ordering::Relaxed)
|
self.expiry.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ pub struct Data {
|
|||||||
pub span_id_gen: SnowflakeIdGenerator,
|
pub span_id_gen: SnowflakeIdGenerator,
|
||||||
pub registry_id_gen: SnowflakeIdGenerator,
|
pub registry_id_gen: SnowflakeIdGenerator,
|
||||||
pub queue_status: AtomicBool,
|
pub queue_status: AtomicBool,
|
||||||
|
// inbuxa: coalesces the settings reloads registry writes trigger
|
||||||
|
pub settings_reload: cache::reload::SettingsReloadGate,
|
||||||
|
|
||||||
pub applications: WebApplications,
|
pub applications: WebApplications,
|
||||||
pub logos: Mutex<AHashMap<Box<str>, LogoCache>>,
|
pub logos: Mutex<AHashMap<Box<str>, LogoCache>>,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::ahash_is_empty;
|
use super::ahash_is_empty;
|
||||||
@@ -71,6 +73,23 @@ pub struct SetResponse<T: JmapObject> {
|
|||||||
#[serde(rename = "notDestroyed")]
|
#[serde(rename = "notDestroyed")]
|
||||||
#[serde(skip_serializing_if = "VecMap::is_empty")]
|
#[serde(skip_serializing_if = "VecMap::is_empty")]
|
||||||
pub not_destroyed: VecMap<MaybeInvalid<Id>, SetError<T::Property>>,
|
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> {
|
impl<'de, T: JmapObject> DeserializeArguments<'de> for SetRequest<'de, T> {
|
||||||
@@ -199,6 +218,7 @@ impl<T: JmapObject> SetResponse<T> {
|
|||||||
not_created: VecMap::new(),
|
not_created: VecMap::new(),
|
||||||
not_updated: VecMap::new(),
|
not_updated: VecMap::new(),
|
||||||
not_destroyed: VecMap::new(),
|
not_destroyed: VecMap::new(),
|
||||||
|
settings_reload: None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Err(trc::JmapEvent::RequestTooLarge.into_err())
|
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
|
/// settings weren't applied; upstream passed on the first error's bare message
|
||||||
/// ("Invalid address: ..."), which read like a problem with the request.
|
/// ("Invalid address: ..."), which read like a problem with the request.
|
||||||
fn reload_refused(errors: Vec<registry::types::error::Error>) -> SetError<Property> {
|
fn reload_refused(errors: Vec<registry::types::error::Error>) -> SetError<Property> {
|
||||||
use registry::types::error::Error;
|
let description = format!(
|
||||||
let more = errors.len().saturating_sub(1);
|
"Settings were not reloaded. {}",
|
||||||
let mut description = match errors.first() {
|
common::cache::reload::describe_reload_errors(&errors)
|
||||||
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.)"));
|
|
||||||
}
|
|
||||||
map_bootstrap_error(errors).with_description(description)
|
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 http_proto::HttpSessionData;
|
||||||
use jmap_proto::{
|
use jmap_proto::{
|
||||||
error::set::{SetError, SetErrorType},
|
error::set::{SetError, SetErrorType},
|
||||||
method::set::{SetRequest, SetResponse},
|
method::set::{SetRequest, SetResponse, SettingsReload},
|
||||||
object::registry::Registry,
|
object::registry::Registry,
|
||||||
references::resolve::ResolveCreatedReference,
|
references::resolve::ResolveCreatedReference,
|
||||||
request::{IntoValid, MaybeInvalid},
|
request::{IntoValid, MaybeInvalid},
|
||||||
@@ -931,34 +931,28 @@ impl RegistrySet for Server {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// inbuxa: DIR-17: a directory or the server default applies on the
|
// inbuxa: a write to an object the running settings are built from
|
||||||
// next request, here and on every node
|
// applies at once, here and on every node (DIR-17 did this for
|
||||||
if matches!(
|
// directories and the server default; now it covers every such object)
|
||||||
object_type,
|
let mut result = result;
|
||||||
ObjectType::Directory | ObjectType::Authentication
|
if let Ok(response) = &mut result
|
||||||
) && let Ok(response) = &result
|
|
||||||
&& (!response.created.is_empty()
|
&& (!response.created.is_empty()
|
||||||
|| !response.updated.is_empty()
|
|| !response.updated.is_empty()
|
||||||
|| !response.destroyed.is_empty())
|
|| !response.destroyed.is_empty())
|
||||||
|
&& let Some(reload) = self.reload_after_write(object_type).await
|
||||||
{
|
{
|
||||||
let change = common::ipc::RegistryChange::Reload(ObjectType::Directory);
|
response.settings_reload = Some(match reload {
|
||||||
match Box::pin(self.reload_registry(change)).await {
|
Ok(()) => SettingsReload {
|
||||||
Ok(reload) if !reload.has_errors() => {
|
applied: true,
|
||||||
self.cluster_broadcast(common::ipc::BroadcastEvent::RegistryChange(change))
|
description: None,
|
||||||
.await;
|
},
|
||||||
}
|
Err(reason) => SettingsReload {
|
||||||
Ok(reload) => {
|
applied: false,
|
||||||
// inbuxa: name what stopped it
|
description: Some(format!(
|
||||||
reload.log();
|
"Saved, but the running settings were not reloaded. {reason}"
|
||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,3 +82,42 @@ pub async fn release_task_locks(server: &Server) -> usize {
|
|||||||
}
|
}
|
||||||
ids.len()
|
ids.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: renews the lease on every task this node is running, so it stays
|
||||||
|
/// claimed for as long as it runs while a node that dies loses its claims
|
||||||
|
/// within one lock lifetime. Returns how many leases were renewed and how
|
||||||
|
/// many were found lost (expired, perhaps taken by another node).
|
||||||
|
pub async fn renew_task_locks(server: &Server) -> (usize, usize) {
|
||||||
|
let locks = &server.inner.ipc.task_locks;
|
||||||
|
let expiry = locks.expiry();
|
||||||
|
let (mut renewed, mut lost) = (0, 0);
|
||||||
|
for id in locks.held_ids() {
|
||||||
|
match server
|
||||||
|
.in_memory_store()
|
||||||
|
.renew_lock(KV_LOCK_TASK, &id.to_be_bytes(), expiry)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(true) => renewed += 1,
|
||||||
|
Ok(false) => {
|
||||||
|
// Still held here as far as this node knows; the task
|
||||||
|
// finishes and its lock is removed as usual
|
||||||
|
if locks.is_held(id) {
|
||||||
|
lost += 1;
|
||||||
|
trc::event!(
|
||||||
|
TaskManager(TaskManagerEvent::TaskLocked),
|
||||||
|
Id = id,
|
||||||
|
Details = "Task lock expired while the task was running",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
trc::error!(
|
||||||
|
err.details("Failed to renew task lock")
|
||||||
|
.ctx(trc::Key::Id, id)
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(renewed, lost)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::task_manager::dkim::DkimManagementTask;
|
|||||||
use crate::task_manager::dns::DnsManagementTask;
|
use crate::task_manager::dns::DnsManagementTask;
|
||||||
use crate::task_manager::imip::SendImipTask;
|
use crate::task_manager::imip::SendImipTask;
|
||||||
use crate::task_manager::index::SearchIndexTask;
|
use crate::task_manager::index::SearchIndexTask;
|
||||||
use crate::task_manager::lock::TaskLockManager;
|
use crate::task_manager::lock::{TaskLockManager, renew_task_locks};
|
||||||
use crate::task_manager::maintenance::MaintenanceTask;
|
use crate::task_manager::maintenance::MaintenanceTask;
|
||||||
use crate::task_manager::merge_threads::MergeThreadsTask;
|
use crate::task_manager::merge_threads::MergeThreadsTask;
|
||||||
use crate::task_manager::report::{self, SubmitReportTask};
|
use crate::task_manager::report::{self, SubmitReportTask};
|
||||||
@@ -24,6 +24,7 @@ use crate::task_manager::{
|
|||||||
TaskJob, TaskManagerIpc, TaskResult,
|
TaskJob, TaskManagerIpc, TaskResult,
|
||||||
};
|
};
|
||||||
use common::BuildServer;
|
use common::BuildServer;
|
||||||
|
use common::config::network::ClusterRoles;
|
||||||
use common::config::server::{DEFAULT_TLS_TIMEOUT, ServerProtocol};
|
use common::config::server::{DEFAULT_TLS_TIMEOUT, ServerProtocol};
|
||||||
use common::network::limiter::ConcurrencyLimiter;
|
use common::network::limiter::ConcurrencyLimiter;
|
||||||
use common::network::{ServerInstance, TcpAcceptor};
|
use common::network::{ServerInstance, TcpAcceptor};
|
||||||
@@ -58,10 +59,12 @@ pub fn spawn_task_manager(inner: Arc<Inner>) {
|
|||||||
let server = inner.build_server();
|
let server = inner.build_server();
|
||||||
let roles = &server.core.network.roles;
|
let roles = &server.core.network.roles;
|
||||||
|
|
||||||
|
// inbuxa: outbound_mta too, which now governs report tasks
|
||||||
if !roles.account_maintenance
|
if !roles.account_maintenance
|
||||||
&& !roles.store_maintenance
|
&& !roles.store_maintenance
|
||||||
&& !roles.search_indexing
|
&& !roles.search_indexing
|
||||||
&& !roles.spam_training
|
&& !roles.spam_training
|
||||||
|
&& !roles.outbound_mta
|
||||||
&& !roles.task_manager
|
&& !roles.task_manager
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -72,6 +75,28 @@ pub fn spawn_task_manager(inner: Arc<Inner>) {
|
|||||||
|
|
||||||
trc::event!(TaskManager(TaskManagerEvent::ManagerStarted));
|
trc::event!(TaskManager(TaskManagerEvent::ManagerStarted));
|
||||||
|
|
||||||
|
// inbuxa: keep the leases of running tasks alive, every third of a lock
|
||||||
|
// lifetime, until the node stops
|
||||||
|
{
|
||||||
|
let inner = inner.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut renewed_at = Instant::now();
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
let locks = &inner.ipc.task_locks;
|
||||||
|
if locks.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if renewed_at.elapsed() >= Duration::from_secs((locks.expiry() / 3).max(1)) {
|
||||||
|
renewed_at = Instant::now();
|
||||||
|
if locks.held() > 0 {
|
||||||
|
renew_task_locks(&inner.build_server()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create dummy server instance for alarms
|
// Create dummy server instance for alarms
|
||||||
let server_instance = Arc::new(ServerInstance {
|
let server_instance = Arc::new(ServerInstance {
|
||||||
id: "_local".to_string(),
|
id: "_local".to_string(),
|
||||||
@@ -289,26 +314,13 @@ impl TaskQueueManager for Server {
|
|||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
.ctx(trc::Key::Value, value)
|
.ctx(trc::Key::Value, value)
|
||||||
})?;
|
})?;
|
||||||
let enabled = match task_type {
|
// inbuxa: running here under a lease this node
|
||||||
TaskType::IndexDocument
|
// renews; don't hand it to a worker again
|
||||||
| TaskType::UnindexDocument
|
if task_locks.is_held(task_id) {
|
||||||
| TaskType::IndexTrace => roles.search_indexing,
|
return Ok(true);
|
||||||
TaskType::AccountMaintenance
|
}
|
||||||
| TaskType::TenantMaintenance
|
|
||||||
| TaskType::DestroyAccount => roles.account_maintenance,
|
let enabled = task_enabled(roles, task_type);
|
||||||
TaskType::StoreMaintenance => roles.store_maintenance,
|
|
||||||
TaskType::SpamFilterMaintenance => roles.spam_training,
|
|
||||||
TaskType::CalendarAlarmEmail
|
|
||||||
| TaskType::CalendarAlarmNotification
|
|
||||||
| TaskType::CalendarItipMessage
|
|
||||||
| TaskType::MergeThreads
|
|
||||||
| TaskType::DmarcReport
|
|
||||||
| TaskType::TlsReport
|
|
||||||
| TaskType::RestoreArchivedItem
|
|
||||||
| TaskType::AcmeRenewal
|
|
||||||
| TaskType::DkimManagement
|
|
||||||
| TaskType::DnsManagement => true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !enabled {
|
if !enabled {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
@@ -437,6 +449,48 @@ impl TaskQueueManager for Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: whether this node's cluster role lets it run a task type. Upstream
|
||||||
|
/// checked the dedicated roles (search indexing, account and store
|
||||||
|
/// maintenance, spam training) and let every node with a task manager run
|
||||||
|
/// the rest, whatever its taskQueueProcessing setting. Every task type now
|
||||||
|
/// answers to one ClusterTaskType:
|
||||||
|
///
|
||||||
|
/// - IndexDocument, UnindexDocument, IndexTrace: searchIndexing
|
||||||
|
/// - AccountMaintenance, TenantMaintenance, DestroyAccount: accountMaintenance
|
||||||
|
/// - StoreMaintenance: storeMaintenance
|
||||||
|
/// - SpamFilterMaintenance: spamClassifierTraining
|
||||||
|
/// - DmarcReport, TlsReport: outboundMta. They build and send reports to
|
||||||
|
/// other domains (TLS reports can go straight to an HTTPS endpoint), which
|
||||||
|
/// is the outbound MTA's business.
|
||||||
|
/// - CalendarAlarmEmail, CalendarAlarmNotification, CalendarItipMessage,
|
||||||
|
/// MergeThreads, RestoreArchivedItem, AcmeRenewal, DkimManagement,
|
||||||
|
/// DnsManagement: taskQueueProcessing, the role for queue tasks with no
|
||||||
|
/// role of their own.
|
||||||
|
///
|
||||||
|
/// A node that may not run a task leaves it unclaimed, so a node that may
|
||||||
|
/// picks it up.
|
||||||
|
pub fn task_enabled(roles: &ClusterRoles, task_type: TaskType) -> bool {
|
||||||
|
match task_type {
|
||||||
|
TaskType::IndexDocument | TaskType::UnindexDocument | TaskType::IndexTrace => {
|
||||||
|
roles.search_indexing
|
||||||
|
}
|
||||||
|
TaskType::AccountMaintenance | TaskType::TenantMaintenance | TaskType::DestroyAccount => {
|
||||||
|
roles.account_maintenance
|
||||||
|
}
|
||||||
|
TaskType::StoreMaintenance => roles.store_maintenance,
|
||||||
|
TaskType::SpamFilterMaintenance => roles.spam_training,
|
||||||
|
TaskType::DmarcReport | TaskType::TlsReport => roles.outbound_mta,
|
||||||
|
TaskType::CalendarAlarmEmail
|
||||||
|
| TaskType::CalendarAlarmNotification
|
||||||
|
| TaskType::CalendarItipMessage
|
||||||
|
| TaskType::MergeThreads
|
||||||
|
| TaskType::RestoreArchivedItem
|
||||||
|
| TaskType::AcmeRenewal
|
||||||
|
| TaskType::DkimManagement
|
||||||
|
| TaskType::DnsManagement => roles.task_manager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_task(
|
async fn run_task(
|
||||||
server: &Server,
|
server: &Server,
|
||||||
task: &Task,
|
task: &Task,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
@@ -16,7 +18,7 @@ impl MysqlStore {
|
|||||||
key: &[u8],
|
key: &[u8],
|
||||||
range: Range<usize>,
|
range: Range<usize>,
|
||||||
) -> trc::Result<Option<Vec<u8>>> {
|
) -> trc::Result<Option<Vec<u8>>> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn
|
let s = conn
|
||||||
.prep("SELECT v FROM t WHERE k = ?")
|
.prep("SELECT v FROM t WHERE k = ?")
|
||||||
.await
|
.await
|
||||||
@@ -39,7 +41,7 @@ impl MysqlStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> {
|
pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn
|
let s = conn
|
||||||
.prep("INSERT INTO t (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)")
|
.prep("INSERT INTO t (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)")
|
||||||
.await
|
.await
|
||||||
@@ -51,7 +53,7 @@ impl MysqlStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result<bool> {
|
pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result<bool> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn
|
let s = conn
|
||||||
.prep("DELETE FROM t WHERE k = ?")
|
.prep("DELETE FROM t WHERE k = ?")
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use mysql_async::{Params, Row, prelude::Queryable};
|
use mysql_async::{Params, Row, prelude::Queryable};
|
||||||
@@ -16,7 +18,7 @@ impl MysqlStore {
|
|||||||
query: &str,
|
query: &str,
|
||||||
params: &[Value<'_>],
|
params: &[Value<'_>],
|
||||||
) -> trc::Result<T> {
|
) -> trc::Result<T> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn.prep(query).await.map_err(into_error)?;
|
let s = conn.prep(query).await.map_err(into_error)?;
|
||||||
let params = Params::Positional(params.iter().map(Into::into).collect());
|
let params = Params::Positional(params.iter().map(Into::into).collect());
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ impl MysqlStore {
|
|||||||
.max_allowed_packet(config.max_allowed_packet.map(|v| v as usize))
|
.max_allowed_packet(config.max_allowed_packet.map(|v| v as usize))
|
||||||
.wait_timeout(config.timeout.map(|t| t.as_secs() as usize))
|
.wait_timeout(config.timeout.map(|t| t.as_secs() as usize))
|
||||||
.client_found_rows(true)
|
.client_found_rows(true)
|
||||||
|
// inbuxa: notice a server that went away without closing the
|
||||||
|
// connection in minutes, not the system default of two hours
|
||||||
|
.tcp_keepalive(Some(super::POOL_KEEPALIVE_IDLE))
|
||||||
.tcp_port(config.port as u16);
|
.tcp_port(config.port as u16);
|
||||||
|
|
||||||
if config.use_tls {
|
if config.use_tls {
|
||||||
@@ -95,7 +98,7 @@ impl MysqlStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn create_storage_tables(&self) -> trc::Result<()> {
|
pub(crate) async fn create_storage_tables(&self) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
|
|
||||||
for table in [
|
for table in [
|
||||||
SUBSPACE_ACL,
|
SUBSPACE_ACL,
|
||||||
@@ -169,7 +172,7 @@ impl MysqlStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn create_search_tables(&self) -> trc::Result<()> {
|
pub(crate) async fn create_search_tables(&self) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
|
|
||||||
create_search_tables::<EmailSearchField>(&mut conn).await?;
|
create_search_tables::<EmailSearchField>(&mut conn).await?;
|
||||||
create_search_tables::<CalendarSearchField>(&mut conn).await?;
|
create_search_tables::<CalendarSearchField>(&mut conn).await?;
|
||||||
|
|||||||
@@ -27,6 +27,33 @@ pub struct MysqlStore {
|
|||||||
pub(crate) conn_pool: Pool,
|
pub(crate) conn_pool: Pool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: how long a request waits for a pooled connection (including
|
||||||
|
/// opening one). mysql_async's pool has no wait timeout, so upstream waited
|
||||||
|
/// forever when the server stopped answering.
|
||||||
|
pub(crate) const POOL_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
/// inbuxa: idle time before TCP keepalive probes start.
|
||||||
|
pub(crate) const POOL_KEEPALIVE_IDLE: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
|
||||||
|
impl MysqlStore {
|
||||||
|
/// inbuxa: a pooled connection, or an error once POOL_WAIT_TIMEOUT has
|
||||||
|
/// passed without one.
|
||||||
|
pub(crate) async fn conn(&self) -> trc::Result<mysql_async::Conn> {
|
||||||
|
pool_conn(&self.conn_pool, POOL_WAIT_TIMEOUT).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn pool_conn(
|
||||||
|
pool: &Pool,
|
||||||
|
wait: std::time::Duration,
|
||||||
|
) -> trc::Result<mysql_async::Conn> {
|
||||||
|
match tokio::time::timeout(wait, pool.get_conn()).await {
|
||||||
|
Ok(result) => result.map_err(into_error),
|
||||||
|
Err(_) => Err(trc::StoreEvent::MysqlError
|
||||||
|
.reason("Timed out waiting for a database connection")
|
||||||
|
.details(format!("No connection within {} s", wait.as_secs()))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
pub(crate) fn into_error(err: impl Display) -> trc::Error {
|
pub(crate) fn into_error(err: impl Display) -> trc::Error {
|
||||||
trc::StoreEvent::MysqlError.reason(err)
|
trc::StoreEvent::MysqlError.reason(err)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{MysqlStore, into_error, is_timeout_error};
|
use super::{MysqlStore, into_error, is_timeout_error};
|
||||||
@@ -14,7 +16,7 @@ impl MysqlStore {
|
|||||||
where
|
where
|
||||||
U: Deserialize + 'static,
|
U: Deserialize + 'static,
|
||||||
{
|
{
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn
|
let s = conn
|
||||||
.prep(format!(
|
.prep(format!(
|
||||||
"SELECT v FROM {} WHERE k = ?",
|
"SELECT v FROM {} WHERE k = ?",
|
||||||
@@ -36,7 +38,7 @@ impl MysqlStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result<bool> {
|
pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result<bool> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn
|
let s = conn
|
||||||
.prep(format!(
|
.prep(format!(
|
||||||
"SELECT 1 FROM {} WHERE k = ?",
|
"SELECT 1 FROM {} WHERE k = ?",
|
||||||
@@ -56,7 +58,7 @@ impl MysqlStore {
|
|||||||
params: IterateParams<T>,
|
params: IterateParams<T>,
|
||||||
mut cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result<bool> + Sync + Send,
|
mut cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result<bool> + Sync + Send,
|
||||||
) -> trc::Result<()> {
|
) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let table = char::from(params.begin.subspace());
|
let table = char::from(params.begin.subspace());
|
||||||
let begin = params.begin.serialize(0);
|
let begin = params.begin.serialize(0);
|
||||||
let end = params.end.serialize(0);
|
let end = params.end.serialize(0);
|
||||||
@@ -155,7 +157,7 @@ impl MysqlStore {
|
|||||||
let key = key.into();
|
let key = key.into();
|
||||||
let table = char::from(key.subspace());
|
let table = char::from(key.subspace());
|
||||||
let key = key.serialize(0);
|
let key = key.serialize(0);
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn
|
let s = conn
|
||||||
.prep(format!("SELECT v FROM {table} WHERE k = ?"))
|
.prep(format!("SELECT v FROM {table} WHERE k = ?"))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use std::fmt::Write;
|
|||||||
|
|
||||||
impl MysqlStore {
|
impl MysqlStore {
|
||||||
pub async fn index(&self, documents: Vec<IndexDocument>) -> trc::Result<()> {
|
pub async fn index(&self, documents: Vec<IndexDocument>) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let mut tx_opts = TxOpts::default();
|
let mut tx_opts = TxOpts::default();
|
||||||
tx_opts
|
tx_opts
|
||||||
.with_consistent_snapshot(false)
|
.with_consistent_snapshot(false)
|
||||||
@@ -96,7 +96,7 @@ impl MysqlStore {
|
|||||||
build_sort(&mut query, sort);
|
build_sort(&mut query, sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn.prep(query).await.map_err(into_error)?;
|
let s = conn.prep(query).await.map_err(into_error)?;
|
||||||
|
|
||||||
conn.exec::<i64, _, _>(s, params)
|
conn.exec::<i64, _, _>(s, params)
|
||||||
@@ -110,7 +110,7 @@ impl MysqlStore {
|
|||||||
let mut query = format!("DELETE FROM {table} ");
|
let mut query = format!("DELETE FROM {table} ");
|
||||||
let params = build_filter(&mut query, &filter.filters);
|
let params = build_filter(&mut query, &filter.filters);
|
||||||
|
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn.prep(&query).await.map_err(into_error)?;
|
let s = conn.prep(&query).await.map_err(into_error)?;
|
||||||
|
|
||||||
match conn.exec_drop(s, params.clone()).await {
|
match conn.exec_drop(s, params.clone()).await {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, MysqlStore, into_error, is_timeout_error};
|
use super::{DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, MysqlStore, into_error, is_timeout_error};
|
||||||
@@ -29,7 +31,7 @@ impl MysqlStore {
|
|||||||
pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result<AssignedIds> {
|
pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result<AssignedIds> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut retry_count = 0;
|
let mut retry_count = 0;
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let err = match self.write_trx(&mut conn, &mut batch).await {
|
let err = match self.write_trx(&mut conn, &mut batch).await {
|
||||||
@@ -382,7 +384,7 @@ impl MysqlStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn purge_store(&self) -> trc::Result<()> {
|
pub(crate) async fn purge_store(&self) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] {
|
for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] {
|
||||||
purge_table(&mut conn, char::from(subspace)).await?;
|
purge_table(&mut conn, char::from(subspace)).await?;
|
||||||
}
|
}
|
||||||
@@ -391,7 +393,7 @@ impl MysqlStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> {
|
pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let table = char::from(from.subspace());
|
let table = char::from(from.subspace());
|
||||||
let mut from = from.serialize(0);
|
let mut from = from.serialize(0);
|
||||||
let to = to.serialize(0);
|
let to = to.serialize(0);
|
||||||
|
|||||||
@@ -22,11 +22,34 @@ use crate::{
|
|||||||
use ::registry::schema::{enums::PostgreSqlRecyclingMethod, structs};
|
use ::registry::schema::{enums::PostgreSqlRecyclingMethod, structs};
|
||||||
use ahash::AHashSet;
|
use ahash::AHashSet;
|
||||||
use deadpool_postgres::{
|
use deadpool_postgres::{
|
||||||
Config, ManagerConfig, Object, Pool, PoolConfig, RecyclingMethod, Runtime,
|
Config, ManagerConfig, Object, Pool, PoolConfig, RecyclingMethod, Runtime, Timeouts,
|
||||||
};
|
};
|
||||||
|
use std::time::Duration;
|
||||||
use tokio_postgres::NoTls;
|
use tokio_postgres::NoTls;
|
||||||
use utils::tls::rustls_client_config;
|
use utils::tls::rustls_client_config;
|
||||||
|
|
||||||
|
/// inbuxa: how long a request waits for a pooled connection.
|
||||||
|
pub(crate) const POOL_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
/// inbuxa: how long opening a connection may take when the store sets no
|
||||||
|
/// timeout of its own.
|
||||||
|
pub(crate) const POOL_CREATE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
/// inbuxa: how long checking a pooled connection before reuse may take.
|
||||||
|
pub(crate) const POOL_RECYCLE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
/// inbuxa: idle time before TCP keepalive probes start.
|
||||||
|
pub(crate) const POOL_KEEPALIVE_IDLE: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// inbuxa: the pool's timeouts. Opening a connection is bounded by the
|
||||||
|
/// store's own timeout when it has one; waiting for one covers at least that
|
||||||
|
/// long, so a slow connect isn't cut short by the wait.
|
||||||
|
pub(crate) fn pool_timeouts(connect_timeout: Option<Duration>) -> Timeouts {
|
||||||
|
let create = connect_timeout.unwrap_or(POOL_CREATE_TIMEOUT);
|
||||||
|
Timeouts {
|
||||||
|
wait: POOL_WAIT_TIMEOUT.max(create).into(),
|
||||||
|
create: create.into(),
|
||||||
|
recycle: POOL_RECYCLE_TIMEOUT.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl PostgresStore {
|
impl PostgresStore {
|
||||||
pub async fn open(config: structs::PostgreSqlStore) -> Result<Store, String> {
|
pub async fn open(config: structs::PostgreSqlStore) -> Result<Store, String> {
|
||||||
// inbuxa: ST-15: where the primary is, to tell a replica from it
|
// inbuxa: ST-15: where the primary is, to tell a replica from it
|
||||||
@@ -46,9 +69,20 @@ impl PostgresStore {
|
|||||||
PostgreSqlRecyclingMethod::Clean => RecyclingMethod::Clean,
|
PostgreSqlRecyclingMethod::Clean => RecyclingMethod::Clean,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if let Some(max_conn) = config.pool_max_connections {
|
// inbuxa: upstream set no pool timeouts, so a request waited for a
|
||||||
cfg.pool = PoolConfig::new(max_conn as usize).into();
|
// free connection, or for one to be made or recycled, for as long as
|
||||||
}
|
// it took: forever when the server stopped answering. A worker now
|
||||||
|
// gets an error instead and the task or request is retried.
|
||||||
|
let mut pool = config
|
||||||
|
.pool_max_connections
|
||||||
|
.map(|max_conn| PoolConfig::new(max_conn as usize))
|
||||||
|
.unwrap_or_default();
|
||||||
|
pool.timeouts = pool_timeouts(cfg.connect_timeout);
|
||||||
|
cfg.pool = pool.into();
|
||||||
|
// Notice a server that went away without closing the connection in
|
||||||
|
// minutes rather than the system default of two hours
|
||||||
|
cfg.keepalives = true.into();
|
||||||
|
cfg.keepalives_idle = POOL_KEEPALIVE_IDLE.into();
|
||||||
|
|
||||||
let primary_pool = if config.use_tls {
|
let primary_pool = if config.use_tls {
|
||||||
cfg.create_pool(
|
cfg.create_pool(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{RedisPool, RedisStore, into_error};
|
use super::{RedisPool, RedisStore, into_error};
|
||||||
@@ -79,6 +81,30 @@ impl RedisStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: see InMemoryStore::renew_lock
|
||||||
|
pub async fn renew_lock(&self, key: &[u8], expires: u64) -> trc::Result<bool> {
|
||||||
|
match &self.pool {
|
||||||
|
RedisPool::Single(pool) => {
|
||||||
|
with_conn(pool, async |conn| {
|
||||||
|
Self::renew_lock_(conn, key, expires).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
RedisPool::Cluster(pool) => {
|
||||||
|
with_conn(pool, async |conn| {
|
||||||
|
Self::renew_lock_(conn, key, expires).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
RedisPool::Sentinel(pool) => {
|
||||||
|
with_conn(pool, async |conn| {
|
||||||
|
Self::renew_lock_(conn, key, expires).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn key_delete(&self, key: &[u8]) -> trc::Result<()> {
|
pub async fn key_delete(&self, key: &[u8]) -> trc::Result<()> {
|
||||||
match &self.pool {
|
match &self.pool {
|
||||||
RedisPool::Single(pool) => {
|
RedisPool::Single(pool) => {
|
||||||
@@ -226,6 +252,22 @@ impl RedisStore {
|
|||||||
.map(|reply| reply.is_some())
|
.map(|reply| reply.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn renew_lock_(
|
||||||
|
conn: &mut impl AsyncCommands,
|
||||||
|
key: &[u8],
|
||||||
|
expires: u64,
|
||||||
|
) -> RedisResult<bool> {
|
||||||
|
redis::cmd("SET")
|
||||||
|
.arg(key)
|
||||||
|
.arg(now() + expires)
|
||||||
|
.arg("XX")
|
||||||
|
.arg("EX")
|
||||||
|
.arg(expires as i64)
|
||||||
|
.query_async::<Option<String>>(conn)
|
||||||
|
.await
|
||||||
|
.map(|reply| reply.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
async fn key_delete_(conn: &mut impl AsyncCommands, key: &[u8]) -> RedisResult<()> {
|
async fn key_delete_(conn: &mut impl AsyncCommands, key: &[u8]) -> RedisResult<()> {
|
||||||
conn.del(key).await
|
conn.del(key).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,6 +401,57 @@ impl InMemoryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: extends a lock this node holds to `duration` seconds from now.
|
||||||
|
/// Returns false when the lock is gone or has expired: it may have been
|
||||||
|
/// taken by someone else since, so it is left alone.
|
||||||
|
pub async fn renew_lock(&self, prefix: u8, key: &[u8], duration: u64) -> trc::Result<bool> {
|
||||||
|
match self {
|
||||||
|
InMemoryStore::Store(store) => {
|
||||||
|
let key = KeyValue::<()>::build_key(prefix, key);
|
||||||
|
let key = ValueClass::InMemory(InMemoryClass::Key(key));
|
||||||
|
let Some(lock_expiry) = store
|
||||||
|
.get_value::<u64>(ValueKey::from(key.clone()))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let now = now();
|
||||||
|
if lock_expiry <= now {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.assert_value(key.clone(), AssertValue::U64(lock_expiry));
|
||||||
|
batch.set(key, (now + duration).serialize());
|
||||||
|
match store.write(batch.build_all()).await {
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(err) if err.is_assertion_failure() => Ok(false),
|
||||||
|
Err(err) => Err(err
|
||||||
|
.details("Failed to renew lock.")
|
||||||
|
.caused_by(trc::location!())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InMemoryStore::Sharded(store) => {
|
||||||
|
Box::pin(
|
||||||
|
store
|
||||||
|
.member(&KeyValue::<()>::build_key(prefix, key))
|
||||||
|
.renew_lock(prefix, key, duration),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
#[cfg(feature = "redis")]
|
||||||
|
InMemoryStore::Redis(store) => {
|
||||||
|
store
|
||||||
|
.renew_lock(&KeyValue::<()>::build_key(prefix, key), duration)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
InMemoryStore::Static(_) | InMemoryStore::Http(_) => {
|
||||||
|
Err(trc::StoreEvent::NotSupported.into_err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn remove_lock(&self, prefix: u8, key: &[u8]) -> trc::Result<()> {
|
pub async fn remove_lock(&self, prefix: u8, key: &[u8]) -> trc::Result<()> {
|
||||||
self.key_delete(KeyValue::<()>::build_key(prefix, key))
|
self.key_delete(KeyValue::<()>::build_key(prefix, key))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -11,6 +13,7 @@ use crate::{
|
|||||||
server::TestServerBuilder,
|
server::TestServerBuilder,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use common::BuildServer;
|
||||||
use imap_proto::ResponseType;
|
use imap_proto::ResponseType;
|
||||||
use registry::{
|
use registry::{
|
||||||
schema::{
|
schema::{
|
||||||
@@ -18,7 +21,8 @@ use registry::{
|
|||||||
prelude::{ObjectType, Property, SocketAddr},
|
prelude::{ObjectType, Property, SocketAddr},
|
||||||
structs::{
|
structs::{
|
||||||
ClusterListenerGroup, ClusterListenerGroupProperties, ClusterRole, ClusterTaskGroup,
|
ClusterListenerGroup, ClusterListenerGroupProperties, ClusterRole, ClusterTaskGroup,
|
||||||
Coordinator, Imap, NatsCoordinator, NetworkListener, RedisStore,
|
Coordinator, Imap, MtaDeliverySchedule, MtaVirtualQueue, NatsCoordinator,
|
||||||
|
NetworkListener, RedisStore,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
types::map::Map,
|
types::map::Map,
|
||||||
@@ -209,6 +213,45 @@ pub async fn cluster_tests() {
|
|||||||
Some("John Doe")
|
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
|
// Run IMAP idle tests across nodes
|
||||||
let mut node1_client = imap_client("[email protected]", "this is john's secret", 1).await;
|
let mut node1_client = imap_client("[email protected]", "this is john's secret", 1).await;
|
||||||
let mut node2_client = imap_client("[email protected]", "this is john's secret", 2).await;
|
let mut node2_client = imap_client("[email protected]", "this is john's secret", 2).await;
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ pub mod broadcast;
|
|||||||
#[cfg(feature = "nats")]
|
#[cfg(feature = "nats")]
|
||||||
pub mod coordinator; // inbuxa: coordinator reconnects
|
pub mod coordinator; // inbuxa: coordinator reconnects
|
||||||
pub mod stress;
|
pub mod stress;
|
||||||
|
pub mod task_roles; // inbuxa: task types follow cluster roles
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Two task managers with different cluster roles over one shared store:
|
||||||
|
//! each runs only the task types its role allows, and a task one node may
|
||||||
|
//! not run is left for the node that may. Needs a store both nodes can open
|
||||||
|
//! (STORE=PostgreSql or MySql).
|
||||||
|
|
||||||
|
use crate::utils::server::TestServerBuilder;
|
||||||
|
use common::Server;
|
||||||
|
use registry::{
|
||||||
|
schema::{
|
||||||
|
enums::{ClusterTaskType, IndexDocumentType},
|
||||||
|
structs::{
|
||||||
|
ClusterListenerGroup, ClusterRole, ClusterTaskGroup, ClusterTaskGroupProperties, Task,
|
||||||
|
TaskDnsManagement, TaskIndexDocument, TaskStatus, TaskTlsReport,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
types::map::Map,
|
||||||
|
};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use store::{
|
||||||
|
ValueKey,
|
||||||
|
write::{BatchBuilder, TaskQueueClass, ValueClass},
|
||||||
|
};
|
||||||
|
use utils::snowflake::SnowflakeIdGenerator;
|
||||||
|
|
||||||
|
const QUEUE_ROLE: &str = "tasks_queue";
|
||||||
|
const INDEX_MTA_ROLE: &str = "tasks_index_mta";
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
pub async fn task_role_tests() {
|
||||||
|
if matches!(
|
||||||
|
std::env::var("STORE").as_deref(),
|
||||||
|
Ok("RocksDb" | "Sqlite") | Err(_)
|
||||||
|
) {
|
||||||
|
println!("Skipping task role tests: they need a store both nodes can open.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"Running task role tests on {}...",
|
||||||
|
std::env::var("STORE").unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
// The roles, stored by a node that runs no services of its own (a node
|
||||||
|
// looks its role up when it starts)
|
||||||
|
let seed = TestServerBuilder::new("task_roles_seed")
|
||||||
|
.await
|
||||||
|
.with_object(role(QUEUE_ROLE, &[ClusterTaskType::TaskQueueProcessing]))
|
||||||
|
.await
|
||||||
|
.with_object(role(
|
||||||
|
INDEX_MTA_ROLE,
|
||||||
|
&[
|
||||||
|
ClusterTaskType::SearchIndexing,
|
||||||
|
ClusterTaskType::OutboundMta,
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.disable_services()
|
||||||
|
.build()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Node A runs queue tasks (taskQueueProcessing) only
|
||||||
|
let node_a = TestServerBuilder::new_with_role(
|
||||||
|
"task_roles_a",
|
||||||
|
"node-a.example.com".into(),
|
||||||
|
Some(QUEUE_ROLE.into()),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.build_with_opts(false)
|
||||||
|
.await;
|
||||||
|
let server_a = node_a.server.clone();
|
||||||
|
let roles = &server_a.core.network.roles;
|
||||||
|
assert!(roles.task_manager && !roles.search_indexing && !roles.outbound_mta);
|
||||||
|
|
||||||
|
// A DNS task (taskQueueProcessing), an unindex task (searchIndexing) and
|
||||||
|
// a TLS report (outboundMta), all due now
|
||||||
|
let [dns, unindex, report] = new_task_ids();
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch
|
||||||
|
.schedule_task_with_id(
|
||||||
|
dns,
|
||||||
|
Task::DnsManagement(TaskDnsManagement {
|
||||||
|
status: TaskStatus::now(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.schedule_task_with_id(
|
||||||
|
unindex,
|
||||||
|
Task::UnindexDocument(TaskIndexDocument {
|
||||||
|
account_id: 0u32.into(),
|
||||||
|
document_id: u32::MAX.into(),
|
||||||
|
document_type: IndexDocumentType::File,
|
||||||
|
status: TaskStatus::now(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.schedule_task_with_id(
|
||||||
|
report,
|
||||||
|
Task::TlsReport(TaskTlsReport {
|
||||||
|
report_id: u64::MAX.into(),
|
||||||
|
status: TaskStatus::now(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
server_a.store().write(batch.build_all()).await.unwrap();
|
||||||
|
server_a.notify_task_queue();
|
||||||
|
|
||||||
|
// Node A runs the DNS task and leaves the other two alone. Upstream ran
|
||||||
|
// the TLS report here too: report tasks ran on any node with a task
|
||||||
|
// manager.
|
||||||
|
wait_until_run(&server_a, &[dns], Duration::from_secs(20)).await;
|
||||||
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
server_a.notify_task_queue();
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
assert!(
|
||||||
|
is_pending(&server_a, unindex).await,
|
||||||
|
"unindex ran on node A"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
is_pending(&server_a, report).await,
|
||||||
|
"TLS report ran on node A"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Node B (search indexing and outbound MTA) comes up and picks up what
|
||||||
|
// node A left
|
||||||
|
let node_b = TestServerBuilder::new_with_role(
|
||||||
|
"task_roles_b",
|
||||||
|
"node-b.example.com".into(),
|
||||||
|
Some(INDEX_MTA_ROLE.into()),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.build_with_opts(false)
|
||||||
|
.await;
|
||||||
|
let server_b = node_b.server.clone();
|
||||||
|
let roles = &server_b.core.network.roles;
|
||||||
|
assert!(!roles.task_manager && roles.search_indexing && roles.outbound_mta);
|
||||||
|
server_b.notify_task_queue();
|
||||||
|
wait_until_run(&server_b, &[unindex, report], Duration::from_secs(20)).await;
|
||||||
|
|
||||||
|
// A queue task scheduled now still runs, on node A: node B may not
|
||||||
|
// claim it
|
||||||
|
let [dns] = new_task_ids();
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.schedule_task_with_id(
|
||||||
|
dns,
|
||||||
|
Task::DnsManagement(TaskDnsManagement {
|
||||||
|
status: TaskStatus::now(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
server_b.store().write(batch.build_all()).await.unwrap();
|
||||||
|
server_b.notify_task_queue();
|
||||||
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
assert!(is_pending(&server_b, dns).await, "DNS task ran on node B");
|
||||||
|
server_a.notify_task_queue();
|
||||||
|
wait_until_run(&server_a, &[dns], Duration::from_secs(20)).await;
|
||||||
|
|
||||||
|
if seed.is_reset() {
|
||||||
|
seed.temp_dir.delete();
|
||||||
|
node_a.temp_dir.delete();
|
||||||
|
node_b.temp_dir.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role(name: &str, tasks: &[ClusterTaskType]) -> ClusterRole {
|
||||||
|
ClusterRole {
|
||||||
|
name: name.into(),
|
||||||
|
description: None,
|
||||||
|
listeners: ClusterListenerGroup::EnableAll,
|
||||||
|
tasks: ClusterTaskGroup::EnableSome(ClusterTaskGroupProperties {
|
||||||
|
task_types: Map::new(tasks.to_vec()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_task_ids<const N: usize>() -> [u64; N] {
|
||||||
|
std::array::from_fn(|_| SnowflakeIdGenerator::global_id().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Still due and never run: present, and pending.
|
||||||
|
async fn is_pending(server: &Server, id: u64) -> bool {
|
||||||
|
matches!(
|
||||||
|
server
|
||||||
|
.store()
|
||||||
|
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
|
||||||
|
TaskQueueClass::Task { id },
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.map(|task| task.status().clone()),
|
||||||
|
Some(TaskStatus::Pending(_))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_until_run(server: &Server, ids: &[u64], within: Duration) {
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
let mut left = 0;
|
||||||
|
for id in ids {
|
||||||
|
if is_pending(server, *id).await {
|
||||||
|
left += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if left == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < within,
|
||||||
|
"{left} task(s) still pending after {:?}",
|
||||||
|
started.elapsed()
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::utils::server::TestServer;
|
use crate::utils::server::TestServer;
|
||||||
@@ -40,15 +42,23 @@ pub mod vrfy;
|
|||||||
const EVENT_TIMEOUT: Duration = Duration::from_secs(5);
|
const EVENT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
impl TestServer {
|
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 {
|
pub async fn read_event(&mut self) -> QueueEvent {
|
||||||
if let Some(event) = self.queue_events.pop_front() {
|
while let Some(event) = self.queue_events.pop_front() {
|
||||||
return event;
|
if !event.is_reload_settings() {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await {
|
loop {
|
||||||
Ok(Some(event)) => event,
|
match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await {
|
||||||
Ok(None) => panic!("Channel closed."),
|
Ok(Some(event)) if event.is_reload_settings() => (),
|
||||||
Err(_) => panic!("No queue event received."),
|
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<QueueEvent> {
|
pub async fn try_read_event(&mut self) -> Option<QueueEvent> {
|
||||||
if let Some(event) = self.queue_events.pop_front() {
|
while let Some(event) = self.queue_events.pop_front() {
|
||||||
return Some(event);
|
if !event.is_reload_settings() {
|
||||||
|
return Some(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await {
|
loop {
|
||||||
Ok(Some(event)) => Some(event),
|
match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await {
|
||||||
Ok(None) => panic!("Channel closed."),
|
Ok(Some(event)) if event.is_reload_settings() => (),
|
||||||
Err(_) => None,
|
Ok(Some(event)) => return Some(event),
|
||||||
|
Ok(None) => panic!("Channel closed."),
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn assert_no_events(&mut self) {
|
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:?}");
|
panic!("Expected empty queue but got {event:?}");
|
||||||
}
|
}
|
||||||
|
self.queue_events.clear();
|
||||||
|
|
||||||
match self.queue_rx.try_recv() {
|
loop {
|
||||||
Err(TryRecvError::Empty) => (),
|
match self.queue_rx.try_recv() {
|
||||||
Ok(event) => panic!("Expected empty queue but got {event:?}"),
|
Ok(event) if event.is_reload_settings() => (),
|
||||||
Err(err) => panic!("Queue error: {err:?}"),
|
Err(TryRecvError::Empty) => break,
|
||||||
|
Ok(event) => panic!("Expected empty queue but got {event:?}"),
|
||||||
|
Err(err) => panic!("Queue error: {err:?}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ pub mod blob;
|
|||||||
pub mod import_export;
|
pub mod import_export;
|
||||||
pub mod lookup;
|
pub mod lookup;
|
||||||
pub mod ops;
|
pub mod ops;
|
||||||
|
#[cfg(any(feature = "postgres", feature = "mysql"))]
|
||||||
|
pub mod pool_timeout; // inbuxa: SQL pools give up instead of hanging
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! A database that accepts connections and then says nothing (a hung or
|
||||||
|
//! half-dead server, a black-holed failover) gives a worker an error within
|
||||||
|
//! the pool's timeouts. Upstream's pools had none, so the worker waited for
|
||||||
|
//! good. No database is needed: a local listener that never answers plays
|
||||||
|
//! the server.
|
||||||
|
|
||||||
|
use registry::schema::structs::DataStore;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use store::{Store, ValueKey, write::ValueClass};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
/// Accepts connections on a local port and never sends a byte.
|
||||||
|
async fn silent_server() -> u16 {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut held = Vec::new();
|
||||||
|
while let Ok((socket, _)) = listener.accept().await {
|
||||||
|
held.push(socket);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
port
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the store and reads a key; both must end, with an error for the
|
||||||
|
/// read, well within `limit`.
|
||||||
|
async fn assert_times_out(data_store: DataStore, limit: Duration) {
|
||||||
|
let started = Instant::now();
|
||||||
|
let result = tokio::time::timeout(limit, async {
|
||||||
|
match Store::build(data_store).await {
|
||||||
|
Ok(store) => store
|
||||||
|
.get_value::<u64>(ValueKey::from(ValueClass::Property(0)))
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|err| err.to_string()),
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
match result {
|
||||||
|
Ok(Err(err)) => println!("Got {err} after {elapsed:?}"),
|
||||||
|
Ok(Ok(())) => panic!("a silent server answered?"),
|
||||||
|
Err(_) => panic!("still waiting for a connection after {elapsed:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
pub async fn postgres_pool_timeout() {
|
||||||
|
use registry::schema::structs::PostgreSqlStore;
|
||||||
|
|
||||||
|
let port = silent_server().await;
|
||||||
|
println!("Running PostgreSQL pool timeout test...");
|
||||||
|
// The store's own timeout bounds opening a connection, handshake
|
||||||
|
// included (tokio-postgres's connect_timeout covers only the TCP connect)
|
||||||
|
assert_times_out(
|
||||||
|
DataStore::PostgreSql(PostgreSqlStore {
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: port as u64,
|
||||||
|
database: "none".into(),
|
||||||
|
timeout: Some(Duration::from_secs(2).into()),
|
||||||
|
use_tls: false,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
Duration::from_secs(20),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "mysql")]
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
pub async fn mysql_pool_timeout() {
|
||||||
|
use registry::schema::structs::MySqlStore;
|
||||||
|
|
||||||
|
let port = silent_server().await;
|
||||||
|
println!("Running MySQL pool timeout test...");
|
||||||
|
// mysql_async has no pool timeout; the store waits 30 s for a connection
|
||||||
|
assert_times_out(
|
||||||
|
DataStore::MySql(MySqlStore {
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: port as u64,
|
||||||
|
database: "none".into(),
|
||||||
|
use_tls: false,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
Duration::from_secs(60),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
@@ -79,7 +79,33 @@ pub async fn task_lock_tests() {
|
|||||||
"ran before the other node's locks expired: {elapsed:?}"
|
"ran before the other node's locks expired: {elapsed:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. A graceful stop releases the locks this node holds: another node
|
// 3. A task that runs longer than a lock lifetime keeps its claim: the
|
||||||
|
// task manager renews the lease while this node holds it, and the claim
|
||||||
|
// ends when the task does. (Before, a lock simply lasted an hour.)
|
||||||
|
let [id] = new_task_ids(1)[..] else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert!(server.try_lock_task(id).await, "claim {id}");
|
||||||
|
tokio::time::sleep(Duration::from_secs(LOCK_EXPIRY + LOCK_EXPIRY / 2)).await;
|
||||||
|
assert!(
|
||||||
|
!foreign_lock(&server, id, LOCK_EXPIRY).await,
|
||||||
|
"lease lapsed while the task ran"
|
||||||
|
);
|
||||||
|
server.remove_index_lock(id).await;
|
||||||
|
assert!(
|
||||||
|
foreign_lock(&server, id, LOCK_EXPIRY).await,
|
||||||
|
"released when the task ended"
|
||||||
|
);
|
||||||
|
let _ = server
|
||||||
|
.in_memory_store()
|
||||||
|
.remove_lock(KV_LOCK_TASK, &id.to_be_bytes())
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
common::ipc::TaskLocks::DEFAULT_EXPIRY <= 5 * 60,
|
||||||
|
"a dead node's tasks wait no more than a few minutes"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. A graceful stop releases the locks this node holds: another node
|
||||||
// can claim those tasks at once, and this one claims nothing more
|
// can claim those tasks at once, and this one claims nothing more
|
||||||
let ids = new_task_ids(3);
|
let ids = new_task_ids(3);
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/*
|
||||||
|
* 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::{
|
||||||
|
AllowedIp, CertificateManagement, DkimManagement, DnsManagement, Domain, Expression,
|
||||||
|
MtaDeliverySchedule, MtaStageAuth, MtaVirtualQueue, Tracer, TracerStdout,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
types::ipmask::IpAddrOrMask,
|
||||||
|
};
|
||||||
|
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",
|
||||||
|
"[email protected]",
|
||||||
|
"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("[email protected]");
|
||||||
|
|
||||||
|
// 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::<Vec<_>>();
|
||||||
|
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);
|
||||||
|
|
||||||
|
// An allowed IP is live as soon as it is saved, and gone once
|
||||||
|
// destroyed. It lives in the core's security settings, which the
|
||||||
|
// blocked-IP reload it used to get doesn't rebuild.
|
||||||
|
let ip: std::net::IpAddr = "198.51.100.7".parse().unwrap();
|
||||||
|
assert!(!is_allowed(test, ip));
|
||||||
|
let response = admin
|
||||||
|
.registry_create([AllowedIp {
|
||||||
|
address: IpAddrOrMask::from_ip(ip),
|
||||||
|
reason: Some("autoreload".into()),
|
||||||
|
..Default::default()
|
||||||
|
}])
|
||||||
|
.await;
|
||||||
|
assert_applied(&response);
|
||||||
|
assert!(
|
||||||
|
is_allowed(test, ip),
|
||||||
|
"allowed IP not in the running settings"
|
||||||
|
);
|
||||||
|
let allowed_id = response.created_id(0);
|
||||||
|
let response = admin
|
||||||
|
.registry_destroy(ObjectType::AllowedIp, [allowed_id])
|
||||||
|
.await;
|
||||||
|
assert_applied(&response);
|
||||||
|
assert!(!is_allowed(test, ip), "destroyed allowed IP still live");
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_allowed(test: &TestServer, ip: std::net::IpAddr) -> bool {
|
||||||
|
test.server.inner.build_server().is_ip_allowed(ip)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod authentication;
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod ai_calibration;
|
pub mod ai_calibration;
|
||||||
pub mod authorization;
|
pub mod authorization;
|
||||||
|
pub mod auto_reload; // inbuxa: registry writes apply at once
|
||||||
pub mod branding;
|
pub mod branding;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod delivery;
|
pub mod delivery;
|
||||||
|
|||||||
Reference in New Issue
Block a user