1use cidr_map::CidrSet;
2use data_loader::KeySource;
3use dns_resolver::IpLookupStrategy;
4use mailexchanger::MailExchanger;
5#[cfg(feature = "lua")]
6use mlua::prelude::*;
7use openssl::ssl::SslOptions;
8use ordermap::OrderMap;
9use rfc5321::SmtpClientTimeouts;
10use rustls::crypto::aws_lc_rs::ALL_CIPHER_SUITES;
11use rustls::SupportedCipherSuite;
12use serde::{Deserialize, Deserializer, Serialize};
13use std::collections::BTreeMap;
14use std::fmt::Write;
15use std::time::Duration;
16use throttle::{LimitSpec, ThrottleSpec};
17use utoipa::ToSchema;
18
19#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Copy)]
20pub enum Tls {
21 Opportunistic,
25 OpportunisticInsecure,
29 Required,
31 RequiredInsecure,
35 Disabled,
37}
38
39impl Tls {
40 pub fn allow_insecure(&self) -> bool {
41 match self {
42 Self::OpportunisticInsecure | Self::RequiredInsecure => true,
43 _ => false,
44 }
45 }
46
47 pub fn is_opportunistic(&self) -> bool {
48 match self {
49 Self::OpportunisticInsecure | Self::Opportunistic => true,
50 _ => false,
51 }
52 }
53}
54
55impl Default for Tls {
56 fn default() -> Self {
57 Self::Opportunistic
58 }
59}
60
61#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Copy, Default)]
64pub enum TlsMode {
65 #[default]
68 StartTls,
69 ImplicitTls,
74}
75
76pub fn parse_openssl_options(option_list: &str) -> anyhow::Result<SslOptions> {
77 let mut result = SslOptions::empty();
78
79 for option in option_list.split('|') {
80 match SslOptions::from_name(option) {
81 Some(opt) => {
82 result.insert(opt);
83 }
84 None => {
85 let mut allowed: Vec<_> = SslOptions::all()
86 .iter_names()
87 .map(|(name, _)| format!("`{name}`"))
88 .collect();
89 allowed.sort();
90 let allowed = allowed.join(", ");
91 anyhow::bail!(
92 "`{option}` is not a valid SslOption name. \
93 Possible values are {allowed} joined together by the pipe `|` character."
94 );
95 }
96 }
97 }
98
99 Ok(result)
100}
101
102fn deserialize_nonzero_usize<'de, D>(deserializer: D) -> Result<usize, D::Error>
105where
106 D: Deserializer<'de>,
107{
108 use serde::de::Error;
109 let value = usize::deserialize(deserializer)?;
110 if value == 0 {
111 return Err(D::Error::custom("value must be at least 1"));
112 }
113 Ok(value)
114}
115
116fn deserialize_ssl_options<'de, D>(deserializer: D) -> Result<Option<SslOptions>, D::Error>
117where
118 D: Deserializer<'de>,
119{
120 use serde::de::Error;
121 let maybe_options = Option::<String>::deserialize(deserializer)?;
122
123 match maybe_options {
124 None => Ok(None),
125 Some(option_list) => match parse_openssl_options(&option_list) {
126 Ok(options) => Ok(Some(options)),
127 Err(err) => Err(D::Error::custom(format!("{err:#}"))),
128 },
129 }
130}
131
132fn deserialize_supported_ciphersuite<'de, D>(
133 deserializer: D,
134) -> Result<Vec<SupportedCipherSuite>, D::Error>
135where
136 D: Deserializer<'de>,
137{
138 use serde::de::Error;
139 let suites = Vec::<String>::deserialize(deserializer)?;
140 let mut result = vec![];
141
142 for s in suites {
143 match find_rustls_cipher_suite(&s) {
144 Some(s) => {
145 result.push(s);
146 }
147 None => {
148 return Err(D::Error::custom(format!(
149 "`{s}` is not a valid rustls cipher suite"
150 )));
151 }
152 }
153 }
154
155 Ok(result)
156}
157
158pub fn find_rustls_cipher_suite(name: &str) -> Option<SupportedCipherSuite> {
159 for suite in ALL_CIPHER_SUITES {
160 let sname = format!("{:?}", suite.suite());
161 if sname.eq_ignore_ascii_case(name) {
162 return Some(*suite);
163 }
164 }
165 None
166}
167
168#[derive(Deserialize, Serialize, Debug, Clone, Default, Copy, PartialEq, Eq)]
169#[cfg_attr(feature = "lua", derive(FromLua))]
170pub enum WakeupStrategy {
171 #[default]
172 Aggressive,
173 Relaxed,
174}
175
176#[derive(Deserialize, Serialize, Debug, Clone, Default, Copy, PartialEq, Eq)]
177#[cfg_attr(feature = "lua", derive(FromLua))]
178pub enum MemoryReductionPolicy {
179 #[default]
180 ShrinkDataAndMeta,
181 ShrinkData,
182 NoShrink,
183}
184
185#[derive(Deserialize, Serialize, Debug, Clone, Default, Copy, PartialEq, Eq)]
186#[cfg_attr(feature = "lua", derive(FromLua))]
187pub enum ConfigRefreshStrategy {
188 #[default]
189 Ttl,
190 Epoch,
191}
192
193#[derive(Deserialize, Serialize, Debug, Clone, Default, Copy, PartialEq, Eq)]
194#[cfg_attr(feature = "lua", derive(FromLua))]
195pub enum ReconnectStrategy {
196 TerminateSession,
200 ReconnectSameHost,
203 #[default]
206 ConnectNextHost,
207}
208
209#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
210#[cfg_attr(feature = "lua", derive(FromLua))]
211#[serde(deny_unknown_fields)]
212pub struct EgressPathConfig {
213 #[serde(default = "EgressPathConfig::default_connection_limit")]
214 pub connection_limit: LimitSpec,
215
216 #[serde(default)]
217 pub additional_connection_limits: OrderMap<String, LimitSpec>,
218
219 #[serde(default)]
220 pub enable_tls: Tls,
221
222 #[serde(default)]
223 pub tls_mode: TlsMode,
224
225 #[serde(default = "EgressPathConfig::default_enable_mta_sts")]
226 pub enable_mta_sts: bool,
227
228 #[serde(default = "EgressPathConfig::default_enable_dane")]
229 pub enable_dane: bool,
230
231 #[serde(default = "EgressPathConfig::default_enable_pipelining")]
232 pub enable_pipelining: bool,
233
234 #[serde(default = "EgressPathConfig::default_enable_rset")]
235 pub enable_rset: bool,
236
237 #[serde(default)]
238 pub tls_prefer_openssl: bool,
239
240 #[serde(default)]
241 pub tls_certificate: Option<KeySource>,
242
243 #[serde(default)]
244 pub tls_private_key: Option<KeySource>,
245
246 #[serde(default)]
247 pub openssl_cipher_list: Option<String>,
248 #[serde(default)]
249 pub openssl_cipher_suites: Option<String>,
250 #[serde(
251 default,
252 deserialize_with = "deserialize_ssl_options",
253 skip_serializing )]
255 pub openssl_options: Option<SslOptions>,
256
257 #[serde(
258 default,
259 deserialize_with = "deserialize_supported_ciphersuite",
260 skip_serializing )]
262 pub rustls_cipher_suites: Vec<SupportedCipherSuite>,
263
264 #[serde(flatten)]
265 pub client_timeouts: SmtpClientTimeouts,
266
267 #[serde(default, with = "duration_serde")]
271 pub system_shutdown_timeout: Option<Duration>,
272
273 #[serde(default = "EgressPathConfig::default_max_ready")]
274 pub max_ready: usize,
275
276 #[serde(default = "EgressPathConfig::default_consecutive_connection_failures_before_delay")]
277 pub consecutive_connection_failures_before_delay: usize,
278
279 #[serde(default = "EgressPathConfig::default_smtp_port")]
280 pub smtp_port: u16,
281
282 #[serde(default)]
283 pub smtp_auth_plain_username: Option<String>,
284
285 #[serde(default)]
286 pub smtp_auth_plain_password: Option<KeySource>,
287
288 #[serde(default)]
289 pub allow_smtp_auth_plain_without_tls: bool,
290
291 #[serde(default)]
298 pub allow_smtp_auth_plain_without_valid_certificate: bool,
299
300 #[serde(default)]
301 pub max_message_rate: Option<ThrottleSpec>,
302
303 #[serde(default)]
304 pub additional_message_rate_throttles: OrderMap<String, ThrottleSpec>,
305
306 #[serde(default)]
307 pub source_selection_rate: Option<ThrottleSpec>,
308
309 #[serde(default)]
310 pub additional_source_selection_rates: OrderMap<String, ThrottleSpec>,
311
312 #[serde(default)]
313 pub max_connection_rate: Option<ThrottleSpec>,
314
315 #[serde(default = "EgressPathConfig::default_max_deliveries_per_connection")]
316 pub max_deliveries_per_connection: usize,
317
318 #[serde(default = "EgressPathConfig::default_max_recipients_per_batch")]
319 pub max_recipients_per_batch: usize,
320
321 #[serde(default = "CidrSet::default_prohibited_hosts")]
322 pub prohibited_hosts: CidrSet,
323
324 #[serde(default)]
325 pub skip_hosts: CidrSet,
326
327 #[serde(default)]
328 pub ip_lookup_strategy: IpLookupStrategy,
329
330 #[serde(
341 default = "EgressPathConfig::default_max_mx_plan_size",
342 deserialize_with = "deserialize_nonzero_usize"
343 )]
344 pub max_mx_plan_size: usize,
345
346 #[serde(
355 default = "EgressPathConfig::default_max_mx_addresses_per_host",
356 deserialize_with = "deserialize_nonzero_usize"
357 )]
358 pub max_mx_addresses_per_host: usize,
359
360 #[serde(default)]
361 pub ehlo_domain: Option<String>,
362
363 #[serde(default)]
365 pub aggressive_connection_opening: bool,
366
367 #[serde(
372 default = "EgressPathConfig::default_refresh_interval",
373 with = "duration_serde"
374 )]
375 pub refresh_interval: Duration,
376 #[serde(default)]
377 pub refresh_strategy: ConfigRefreshStrategy,
378
379 #[serde(default)]
380 pub dispatcher_wakeup_strategy: WakeupStrategy,
381 #[serde(default)]
382 pub maintainer_wakeup_strategy: WakeupStrategy,
383
384 #[serde(default)]
388 pub provider_name: Option<String>,
389
390 #[serde(default, with = "duration_serde")]
395 pub remember_broken_tls: Option<Duration>,
396
397 #[serde(default)]
401 pub opportunistic_tls_reconnect_on_failed_handshake: bool,
402
403 #[serde(default)]
405 pub use_lmtp: bool,
406
407 #[serde(default)]
410 pub reconnect_strategy: ReconnectStrategy,
411
412 #[serde(default)]
414 pub readyq_pool_name: Option<String>,
415
416 #[serde(default)]
418 pub low_memory_reduction_policy: MemoryReductionPolicy,
419
420 #[serde(default)]
422 pub no_memory_reduction_policy: MemoryReductionPolicy,
423
424 #[serde(default)]
428 pub try_next_host_on_transport_error: bool,
429
430 #[serde(default)]
434 pub ignore_8bit_checks: bool,
435
436 #[serde(default, with = "duration_serde")]
447 pub dispatcher_progress_watchdog_timeout: Option<Duration>,
448}
449
450#[cfg(feature = "lua")]
451impl LuaUserData for EgressPathConfig {
452 fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
453 config::impl_pairs_and_index(methods);
454 }
455}
456
457impl Default for EgressPathConfig {
458 fn default() -> Self {
459 Self {
460 connection_limit: Self::default_connection_limit(),
461 tls_prefer_openssl: false,
462 enable_tls: Tls::default(),
463 tls_mode: TlsMode::default(),
464 enable_mta_sts: Self::default_enable_mta_sts(),
465 enable_dane: Self::default_enable_dane(),
466 enable_rset: Self::default_enable_rset(),
467 enable_pipelining: Self::default_enable_pipelining(),
468 max_ready: Self::default_max_ready(),
469 consecutive_connection_failures_before_delay:
470 Self::default_consecutive_connection_failures_before_delay(),
471 smtp_port: Self::default_smtp_port(),
472 max_message_rate: None,
473 max_connection_rate: None,
474 max_deliveries_per_connection: Self::default_max_deliveries_per_connection(),
475 max_recipients_per_batch: Self::default_max_recipients_per_batch(),
476 client_timeouts: SmtpClientTimeouts::default(),
477 system_shutdown_timeout: None,
478 prohibited_hosts: CidrSet::default_prohibited_hosts(),
479 skip_hosts: CidrSet::default(),
480 ehlo_domain: None,
481 allow_smtp_auth_plain_without_tls: false,
482 allow_smtp_auth_plain_without_valid_certificate: false,
483 smtp_auth_plain_username: None,
484 smtp_auth_plain_password: None,
485 aggressive_connection_opening: false,
486 rustls_cipher_suites: vec![],
487 tls_certificate: None,
488 tls_private_key: None,
489 openssl_cipher_list: None,
490 openssl_cipher_suites: None,
491 openssl_options: None,
492 refresh_interval: Self::default_refresh_interval(),
493 refresh_strategy: ConfigRefreshStrategy::default(),
494 additional_message_rate_throttles: OrderMap::default(),
495 additional_connection_limits: OrderMap::default(),
496 source_selection_rate: None,
497 additional_source_selection_rates: OrderMap::default(),
498 provider_name: None,
499 remember_broken_tls: None,
500 opportunistic_tls_reconnect_on_failed_handshake: false,
501 use_lmtp: false,
502 reconnect_strategy: ReconnectStrategy::default(),
503 readyq_pool_name: None,
504 low_memory_reduction_policy: MemoryReductionPolicy::default(),
505 no_memory_reduction_policy: MemoryReductionPolicy::default(),
506 maintainer_wakeup_strategy: WakeupStrategy::default(),
507 dispatcher_wakeup_strategy: WakeupStrategy::default(),
508 try_next_host_on_transport_error: false,
509 ignore_8bit_checks: false,
510 ip_lookup_strategy: IpLookupStrategy::default(),
511 max_mx_plan_size: Self::default_max_mx_plan_size(),
512 max_mx_addresses_per_host: Self::default_max_mx_addresses_per_host(),
513 dispatcher_progress_watchdog_timeout: None,
514 }
515 }
516}
517
518impl EgressPathConfig {
519 fn default_connection_limit() -> LimitSpec {
520 LimitSpec::new(32)
521 }
522
523 fn default_enable_mta_sts() -> bool {
524 true
525 }
526
527 fn default_enable_pipelining() -> bool {
528 true
529 }
530
531 fn default_enable_rset() -> bool {
532 true
533 }
534
535 fn default_enable_dane() -> bool {
536 false
537 }
538
539 fn default_max_ready() -> usize {
540 1024
541 }
542
543 fn default_consecutive_connection_failures_before_delay() -> usize {
544 100
545 }
546
547 fn default_smtp_port() -> u16 {
548 25
549 }
550
551 fn default_max_deliveries_per_connection() -> usize {
552 1024
553 }
554
555 fn default_max_recipients_per_batch() -> usize {
556 100
557 }
558
559 fn default_max_mx_plan_size() -> usize {
560 50
561 }
562
563 fn default_max_mx_addresses_per_host() -> usize {
564 10
565 }
566
567 fn default_refresh_interval() -> Duration {
568 Duration::from_secs(60)
569 }
570
571 pub fn compute_constraints(
581 &self,
582 additional: Option<&EffectiveConstraints>,
583 ) -> EffectiveConstraints {
584 let mut constraints = self.compute_path_constraints();
585 if let Some(extra) = additional {
586 constraints.merge(extra);
587 }
588 constraints
589 }
590
591 fn compute_path_constraints(&self) -> EffectiveConstraints {
592 let max_concurrent_dispatchers = {
593 let mut best = EffectiveCeiling {
594 value: self.connection_limit.limit as f64,
595 source: CeilingSource::Primary,
596 display: self.connection_limit.limit.to_string(),
597 };
598 for (name, spec) in &self.additional_connection_limits {
599 let value = spec.limit as f64;
600 if value < best.value {
601 best = EffectiveCeiling {
602 value,
603 source: CeilingSource::Additional { name: name.clone() },
604 display: spec.limit.to_string(),
605 };
606 }
607 }
608 best
609 };
610
611 let mut msg_candidates: Vec<EffectiveCeiling> = vec![];
614 if let Some(spec) = &self.max_message_rate {
615 msg_candidates.push(throttle_ceiling(spec, CeilingSource::Primary));
616 }
617 for (name, spec) in &self.additional_message_rate_throttles {
618 msg_candidates.push(throttle_ceiling(
619 spec,
620 CeilingSource::Additional { name: name.clone() },
621 ));
622 }
623 if let Some(spec) = &self.max_connection_rate {
624 let k = self.max_deliveries_per_connection as u64;
632 let total = ThrottleSpec {
633 limit: k.saturating_mul(spec.limit),
634 period: spec.period,
635 max_burst: None,
636 force_local: false,
637 };
638 msg_candidates.push(EffectiveCeiling {
639 value: throttle_rate_per_sec(spec) * k as f64,
640 source: CeilingSource::ReconnectCycling,
641 display: format!("{k} × {spec} = {total}"),
642 });
643 }
644 let max_message_rate = msg_candidates
648 .iter()
649 .min_by(|a, b| a.value.total_cmp(&b.value))
650 .cloned();
651
652 let max_message_rate_declared = match (&max_message_rate, &self.max_message_rate) {
659 (Some(ceiling), Some(declared))
660 if !matches!(ceiling.source, CeilingSource::Primary) =>
661 {
662 Some(declared.to_string())
663 }
664 _ => None,
665 };
666
667 let max_connection_rate = self
668 .max_connection_rate
669 .as_ref()
670 .map(|spec| throttle_ceiling(spec, CeilingSource::Primary));
671
672 let max_source_selection_rate = {
673 let mut candidates: Vec<EffectiveCeiling> = vec![];
674 if let Some(spec) = &self.source_selection_rate {
675 candidates.push(throttle_ceiling(spec, CeilingSource::Primary));
676 }
677 for (name, spec) in &self.additional_source_selection_rates {
678 candidates.push(throttle_ceiling(
679 spec,
680 CeilingSource::Additional { name: name.clone() },
681 ));
682 }
683 candidates
684 .into_iter()
685 .min_by(|a, b| a.value.total_cmp(&b.value))
686 };
687
688 EffectiveConstraints {
689 max_concurrent_dispatchers,
690 max_message_rate,
691 max_message_rate_declared,
692 max_connection_rate,
693 max_source_selection_rate,
694 }
695 }
696}
697
698fn throttle_rate_per_sec(spec: &ThrottleSpec) -> f64 {
699 spec.limit as f64 / spec.period as f64
700}
701
702fn throttle_ceiling(spec: &ThrottleSpec, source: CeilingSource) -> EffectiveCeiling {
703 EffectiveCeiling {
704 value: throttle_rate_per_sec(spec),
705 source,
706 display: spec.to_string(),
707 }
708}
709
710#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
715pub struct EffectiveCeiling {
716 pub value: f64,
719 pub source: CeilingSource,
720 pub display: String,
727}
728
729#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
733#[serde(tag = "kind", rename_all = "snake_case")]
734pub enum CeilingSource {
735 Primary,
739 Additional { name: String },
741 ReconnectCycling,
749 Other { name: String },
754}
755
756#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
760pub struct MxResolution {
761 pub site_name: String,
764
765 pub by_preference: BTreeMap<u16, Vec<String>>,
769
770 pub is_mx: bool,
773
774 pub is_domain_literal: bool,
776
777 pub is_secure: bool,
779}
780
781impl From<&MailExchanger> for MxResolution {
782 fn from(mx: &MailExchanger) -> Self {
783 Self {
784 site_name: mx.site_name.clone(),
785 by_preference: mx.by_pref.clone(),
786 is_mx: mx.is_mx,
787 is_domain_literal: mx.is_domain_literal,
788 is_secure: mx.is_secure,
789 }
790 }
791}
792
793impl MxResolution {
794 pub fn render(&self, out: &mut dyn Write) -> std::fmt::Result {
799 let mut flags: Vec<&str> = vec![];
800 if !self.is_mx {
801 flags.push("synthesised");
802 }
803 if self.is_domain_literal {
804 flags.push("domain literal");
805 }
806 if self.is_secure {
807 flags.push("dnssec verified");
808 }
809 write!(out, "mx (site: {}", self.site_name)?;
810 if !flags.is_empty() {
811 write!(out, ", {}", flags.join(", "))?;
812 }
813 writeln!(out, "):")?;
814 for (pref, hosts) in &self.by_preference {
815 for host in hosts {
816 writeln!(out, " {pref:>4} {host}")?;
817 }
818 }
819 Ok(())
820 }
821
822 pub fn to_human_string(&self) -> String {
823 let mut s = String::new();
824 let _ = self.render(&mut s);
825 s
826 }
827}
828
829#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
838pub struct EffectiveConstraints {
839 pub max_concurrent_dispatchers: EffectiveCeiling,
840 pub max_message_rate: Option<EffectiveCeiling>,
844 pub max_message_rate_declared: Option<String>,
849 pub max_connection_rate: Option<EffectiveCeiling>,
850 pub max_source_selection_rate: Option<EffectiveCeiling>,
851}
852
853impl EffectiveConstraints {
854 pub fn render(&self, out: &mut dyn Write) -> std::fmt::Result {
859 let label_axis = |w: &mut dyn Write, label: &str, value: &str| -> std::fmt::Result {
860 writeln!(w, " {label:<24}{value}")
861 };
862 let label_source =
863 |w: &mut dyn Write, src: &CeilingSource, primary: &str| -> std::fmt::Result {
864 let s = match src {
865 CeilingSource::Primary => primary,
866 CeilingSource::Additional { name } => name.as_str(),
867 CeilingSource::ReconnectCycling => {
868 "max_deliveries_per_connection × max_connection_rate"
869 }
870 CeilingSource::Other { name } => name.as_str(),
871 };
872 writeln!(w, " source: {s}")
873 };
874
875 writeln!(out, "ceilings:")?;
876
877 label_axis(
878 out,
879 "concurrent dispatchers:",
880 &self.max_concurrent_dispatchers.display,
881 )?;
882 label_source(
883 out,
884 &self.max_concurrent_dispatchers.source,
885 "connection_limit",
886 )?;
887
888 if let Some(c) = &self.max_message_rate {
889 label_axis(out, "message rate:", &c.display)?;
890 label_source(out, &c.source, "max_message_rate")?;
891 if let Some(declared) = &self.max_message_rate_declared {
892 writeln!(
893 out,
894 " declared: max_message_rate = {declared} ← effectively unreachable"
895 )?;
896 }
897 }
898
899 if let Some(c) = &self.max_connection_rate {
900 label_axis(out, "connection rate:", &c.display)?;
901 label_source(out, &c.source, "max_connection_rate")?;
902 }
903
904 if let Some(c) = &self.max_source_selection_rate {
905 label_axis(out, "source selection rate:", &c.display)?;
906 label_source(out, &c.source, "source_selection_rate")?;
907 }
908
909 Ok(())
910 }
911
912 pub fn to_human_string(&self) -> String {
913 let mut s = String::new();
914 let _ = self.render(&mut s);
917 s
918 }
919
920 pub fn merge(&mut self, other: &EffectiveConstraints) {
927 let path_declared = self
931 .max_message_rate
932 .as_ref()
933 .filter(|c| matches!(c.source, CeilingSource::Primary))
934 .map(|c| c.display.clone())
935 .or_else(|| self.max_message_rate_declared.clone());
936
937 merge_axis(
938 &mut self.max_concurrent_dispatchers,
939 &other.max_concurrent_dispatchers,
940 );
941 merge_optional(&mut self.max_message_rate, &other.max_message_rate);
942 merge_optional(&mut self.max_connection_rate, &other.max_connection_rate);
943 merge_optional(
944 &mut self.max_source_selection_rate,
945 &other.max_source_selection_rate,
946 );
947
948 if let Some(merged) = &self.max_message_rate {
949 if !matches!(merged.source, CeilingSource::Primary) {
950 if let Some(declared) = path_declared {
951 self.max_message_rate_declared = Some(declared);
952 }
953 }
954 }
955 }
956}
957
958fn merge_axis(a: &mut EffectiveCeiling, b: &EffectiveCeiling) {
959 if b.value.total_cmp(&a.value).is_lt() {
960 *a = b.clone();
961 }
962}
963
964fn merge_optional(a: &mut Option<EffectiveCeiling>, b: &Option<EffectiveCeiling>) {
965 match (a.as_mut(), b) {
966 (Some(av), Some(bv)) if bv.value.total_cmp(&av.value).is_lt() => *av = bv.clone(),
967 (None, Some(bv)) => *a = Some(bv.clone()),
968 _ => {}
969 }
970}
971
972#[cfg(test)]
973mod constraints_tests {
974 use super::*;
975
976 fn cfg() -> EgressPathConfig {
977 EgressPathConfig::default()
978 }
979
980 fn value_from_display(display: &str) -> f64 {
988 if let Some((_, total)) = display.split_once(" = ") {
989 let spec = ThrottleSpec::try_from(total).unwrap();
990 return spec.limit as f64 / spec.period as f64;
991 }
992 if let Ok(spec) = ThrottleSpec::try_from(display) {
993 return spec.limit as f64 / spec.period as f64;
994 }
995 display.parse::<u64>().unwrap() as f64
996 }
997
998 fn primary(display: &str) -> EffectiveCeiling {
999 EffectiveCeiling {
1000 value: value_from_display(display),
1001 source: CeilingSource::Primary,
1002 display: display.to_string(),
1003 }
1004 }
1005
1006 fn additional(name: &str, display: &str) -> EffectiveCeiling {
1007 EffectiveCeiling {
1008 value: value_from_display(display),
1009 source: CeilingSource::Additional {
1010 name: name.to_string(),
1011 },
1012 display: display.to_string(),
1013 }
1014 }
1015
1016 fn other(name: &str, display: &str) -> EffectiveCeiling {
1017 EffectiveCeiling {
1018 value: value_from_display(display),
1019 source: CeilingSource::Other {
1020 name: name.to_string(),
1021 },
1022 display: display.to_string(),
1023 }
1024 }
1025
1026 fn reconnect_cycling(display: &str) -> EffectiveCeiling {
1027 EffectiveCeiling {
1028 value: value_from_display(display),
1029 source: CeilingSource::ReconnectCycling,
1030 display: display.to_string(),
1031 }
1032 }
1033
1034 fn throttle(s: &str) -> ThrottleSpec {
1035 ThrottleSpec::try_from(s).unwrap()
1036 }
1037
1038 #[test]
1039 fn rust_compact_round_trips() {
1040 let original = EgressPathConfig::default();
1045 let s = mod_serde::toml_encode_pretty_compact(&original).unwrap();
1046 let parsed: EgressPathConfig = toml::from_str(&s).unwrap();
1047 assert_eq!(parsed, original);
1048 }
1049
1050 #[test]
1051 fn zero_mx_plan_limits_are_rejected() {
1052 let err = toml::from_str::<EgressPathConfig>("max_mx_plan_size = 0").unwrap_err();
1055 k9::snapshot!(
1056 err.to_string(),
1057 "
1058TOML parse error at line 1, column 20
1059 |
10601 | max_mx_plan_size = 0
1061 | ^
1062value must be at least 1
1063
1064"
1065 );
1066 let err = toml::from_str::<EgressPathConfig>("max_mx_addresses_per_host = 0").unwrap_err();
1067 k9::snapshot!(
1068 err.to_string(),
1069 "
1070TOML parse error at line 1, column 29
1071 |
10721 | max_mx_addresses_per_host = 0
1073 | ^
1074value must be at least 1
1075
1076"
1077 );
1078 }
1079
1080 #[test]
1081 fn defaults_only_concurrency() {
1082 assert_eq!(
1083 cfg().compute_constraints(None),
1084 EffectiveConstraints {
1085 max_concurrent_dispatchers: primary("32"),
1086 max_message_rate: None,
1087 max_message_rate_declared: None,
1088 max_connection_rate: None,
1089 max_source_selection_rate: None,
1090 }
1091 );
1092 }
1093
1094 #[test]
1095 fn additional_connection_limit_wins() {
1096 let mut p = cfg();
1097 p.additional_connection_limits
1098 .insert("provider_shared".to_string(), LimitSpec::new(10));
1099 assert_eq!(
1100 p.compute_constraints(None),
1101 EffectiveConstraints {
1102 max_concurrent_dispatchers: additional("provider_shared", "10"),
1103 max_message_rate: None,
1104 max_message_rate_declared: None,
1105 max_connection_rate: None,
1106 max_source_selection_rate: None,
1107 }
1108 );
1109 }
1110
1111 #[test]
1112 fn primary_connection_limit_wins_when_smaller() {
1113 let mut p = cfg();
1114 p.connection_limit = LimitSpec::new(4);
1115 p.additional_connection_limits
1116 .insert("large_pool".to_string(), LimitSpec::new(100));
1117 assert_eq!(
1118 p.compute_constraints(None),
1119 EffectiveConstraints {
1120 max_concurrent_dispatchers: primary("4"),
1121 max_message_rate: None,
1122 max_message_rate_declared: None,
1123 max_connection_rate: None,
1124 max_source_selection_rate: None,
1125 }
1126 );
1127 }
1128
1129 #[test]
1130 fn primary_message_rate_only() {
1131 let mut p = cfg();
1132 p.max_message_rate = Some(throttle("1000/s"));
1133 assert_eq!(
1134 p.compute_constraints(None),
1135 EffectiveConstraints {
1136 max_concurrent_dispatchers: primary("32"),
1137 max_message_rate: Some(primary("1000/s")),
1138 max_message_rate_declared: None,
1139 max_connection_rate: None,
1140 max_source_selection_rate: None,
1141 }
1142 );
1143 }
1144
1145 #[test]
1146 fn additional_message_rate_wins() {
1147 let mut p = cfg();
1148 p.max_message_rate = Some(throttle("1000/s"));
1149 p.additional_message_rate_throttles
1150 .insert("provider_cap".to_string(), throttle("250/s"));
1151 assert_eq!(
1152 p.compute_constraints(None),
1153 EffectiveConstraints {
1154 max_concurrent_dispatchers: primary("32"),
1155 max_message_rate: Some(additional("provider_cap", "250/s")),
1156 max_message_rate_declared: Some("1000/s".to_string()),
1157 max_connection_rate: None,
1158 max_source_selection_rate: None,
1159 }
1160 );
1161 }
1162
1163 #[test]
1164 fn additional_message_rate_throttles_mixed_periods() {
1165 let mut p = cfg();
1170 p.additional_message_rate_throttles
1171 .insert("per_hour".to_string(), throttle("10/hr"));
1172 p.additional_message_rate_throttles
1173 .insert("per_minute".to_string(), throttle("8/min"));
1174 p.additional_message_rate_throttles
1175 .insert("per_second".to_string(), throttle("5/s"));
1176 assert_eq!(
1177 p.compute_constraints(None),
1178 EffectiveConstraints {
1179 max_concurrent_dispatchers: primary("32"),
1180 max_message_rate: Some(additional("per_hour", "10/h")),
1181 max_message_rate_declared: None,
1182 max_connection_rate: None,
1183 max_source_selection_rate: None,
1184 }
1185 );
1186 }
1187
1188 #[test]
1189 fn hourly_rate_preserves_units() {
1190 let mut p = cfg();
1195 p.max_message_rate = Some(throttle("10000/hr"));
1196 assert_eq!(
1197 p.compute_constraints(None),
1198 EffectiveConstraints {
1199 max_concurrent_dispatchers: primary("32"),
1200 max_message_rate: Some(primary("10000/h")),
1201 max_message_rate_declared: None,
1202 max_connection_rate: None,
1203 max_source_selection_rate: None,
1204 }
1205 );
1206 }
1207
1208 #[test]
1209 fn reconnect_cycling_wins() {
1210 let mut p = cfg();
1213 p.max_message_rate = Some(throttle("1000/s"));
1214 p.max_deliveries_per_connection = 10;
1215 p.max_connection_rate = Some(throttle("10/s"));
1216 assert_eq!(
1217 p.compute_constraints(None),
1218 EffectiveConstraints {
1219 max_concurrent_dispatchers: primary("32"),
1220 max_message_rate: Some(reconnect_cycling("10 × 10/s = 100/s")),
1221 max_message_rate_declared: Some("1000/s".to_string()),
1222 max_connection_rate: Some(primary("10/s")),
1223 max_source_selection_rate: None,
1224 }
1225 );
1226 }
1227
1228 #[test]
1229 fn reconnect_cycling_does_not_bind_with_large_k() {
1230 let mut p = cfg();
1234 p.max_message_rate = Some(throttle("1000/s"));
1235 p.max_connection_rate = Some(throttle("10/s"));
1236 assert_eq!(
1237 p.compute_constraints(None),
1238 EffectiveConstraints {
1239 max_concurrent_dispatchers: primary("32"),
1240 max_message_rate: Some(primary("1000/s")),
1241 max_message_rate_declared: None,
1242 max_connection_rate: Some(primary("10/s")),
1243 max_source_selection_rate: None,
1244 }
1245 );
1246 }
1247
1248 #[test]
1249 fn reconnect_cycling_alone_sets_message_rate() {
1250 let mut p = cfg();
1254 p.max_deliveries_per_connection = 5;
1255 p.max_connection_rate = Some(throttle("2/s"));
1256 assert_eq!(
1257 p.compute_constraints(None),
1258 EffectiveConstraints {
1259 max_concurrent_dispatchers: primary("32"),
1260 max_message_rate: Some(reconnect_cycling("5 × 2/s = 10/s")),
1261 max_message_rate_declared: None,
1262 max_connection_rate: Some(primary("2/s")),
1263 max_source_selection_rate: None,
1264 }
1265 );
1266 }
1267
1268 #[test]
1269 fn merge_external_message_rate_wins() {
1270 let mut p = cfg();
1275 p.max_message_rate = Some(throttle("1000/s"));
1276 let external = EffectiveConstraints {
1277 max_concurrent_dispatchers: EffectiveCeiling {
1278 value: f64::INFINITY,
1279 source: CeilingSource::Other {
1280 name: "queue config".to_string(),
1281 },
1282 display: "∞".to_string(),
1283 },
1284 max_message_rate: Some(other("scheduled queue max_message_rate", "100/s")),
1285 max_message_rate_declared: None,
1286 max_connection_rate: None,
1287 max_source_selection_rate: None,
1288 };
1289 assert_eq!(
1290 p.compute_constraints(Some(&external)),
1291 EffectiveConstraints {
1292 max_concurrent_dispatchers: primary("32"),
1293 max_message_rate: Some(other("scheduled queue max_message_rate", "100/s")),
1294 max_message_rate_declared: Some("1000/s".to_string()),
1295 max_connection_rate: None,
1296 max_source_selection_rate: None,
1297 }
1298 );
1299 }
1300
1301 #[test]
1302 fn merge_external_does_not_bind() {
1303 let mut p = cfg();
1306 p.max_message_rate = Some(throttle("100/s"));
1307 let external = EffectiveConstraints {
1308 max_concurrent_dispatchers: EffectiveCeiling {
1309 value: f64::INFINITY,
1310 source: CeilingSource::Other {
1311 name: "queue config".to_string(),
1312 },
1313 display: "∞".to_string(),
1314 },
1315 max_message_rate: Some(other("scheduled queue max_message_rate", "1000/s")),
1316 max_message_rate_declared: None,
1317 max_connection_rate: None,
1318 max_source_selection_rate: None,
1319 };
1320 assert_eq!(
1321 p.compute_constraints(Some(&external)),
1322 EffectiveConstraints {
1323 max_concurrent_dispatchers: primary("32"),
1324 max_message_rate: Some(primary("100/s")),
1325 max_message_rate_declared: None,
1326 max_connection_rate: None,
1327 max_source_selection_rate: None,
1328 }
1329 );
1330 }
1331
1332 #[test]
1333 fn render_external_wins_with_annotation() {
1334 let mut p = cfg();
1335 p.max_message_rate = Some(throttle("1000/s"));
1336 let external = EffectiveConstraints {
1337 max_concurrent_dispatchers: EffectiveCeiling {
1338 value: f64::INFINITY,
1339 source: CeilingSource::Other {
1340 name: "queue config".to_string(),
1341 },
1342 display: "∞".to_string(),
1343 },
1344 max_message_rate: Some(other("scheduled queue max_message_rate", "100/s")),
1345 max_message_rate_declared: None,
1346 max_connection_rate: None,
1347 max_source_selection_rate: None,
1348 };
1349 let c = p.compute_constraints(Some(&external));
1350 k9::snapshot!(
1351 c.to_human_string(),
1352 "
1353ceilings:
1354 concurrent dispatchers: 32
1355 source: connection_limit
1356 message rate: 100/s
1357 source: scheduled queue max_message_rate
1358 declared: max_message_rate = 1000/s ← effectively unreachable
1359
1360"
1361 );
1362 }
1363
1364 #[test]
1365 fn source_selection_rate() {
1366 let mut p = cfg();
1367 p.source_selection_rate = Some(throttle("5/s"));
1368 assert_eq!(
1369 p.compute_constraints(None),
1370 EffectiveConstraints {
1371 max_concurrent_dispatchers: primary("32"),
1372 max_message_rate: None,
1373 max_message_rate_declared: None,
1374 max_connection_rate: None,
1375 max_source_selection_rate: Some(primary("5/s")),
1376 }
1377 );
1378 }
1379
1380 #[test]
1381 fn render_defaults() {
1382 let c = cfg().compute_constraints(None);
1383 k9::snapshot!(
1384 c.to_human_string(),
1385 "
1386ceilings:
1387 concurrent dispatchers: 32
1388 source: connection_limit
1389
1390"
1391 );
1392 }
1393
1394 #[test]
1395 fn render_primary_message_rate_no_annotation() {
1396 let mut p = cfg();
1399 p.max_message_rate = Some(throttle("1000/s"));
1400 let c = p.compute_constraints(None);
1401 k9::snapshot!(
1402 c.to_human_string(),
1403 "
1404ceilings:
1405 concurrent dispatchers: 32
1406 source: connection_limit
1407 message rate: 1000/s
1408 source: max_message_rate
1409
1410"
1411 );
1412 }
1413
1414 #[test]
1415 fn render_reconnect_cycling_with_annotation() {
1416 let mut p = cfg();
1417 p.max_message_rate = Some(throttle("1000/s"));
1418 p.max_deliveries_per_connection = 10;
1419 p.max_connection_rate = Some(throttle("10/s"));
1420 let c = p.compute_constraints(None);
1421 k9::snapshot!(
1422 c.to_human_string(),
1423 "
1424ceilings:
1425 concurrent dispatchers: 32
1426 source: connection_limit
1427 message rate: 10 × 10/s = 100/s
1428 source: max_deliveries_per_connection × max_connection_rate
1429 declared: max_message_rate = 1000/s ← effectively unreachable
1430 connection rate: 10/s
1431 source: max_connection_rate
1432
1433"
1434 );
1435 }
1436
1437 #[test]
1438 fn render_additional_throttle_winning() {
1439 let mut p = cfg();
1440 p.max_message_rate = Some(throttle("1000/s"));
1441 p.additional_message_rate_throttles
1442 .insert("provider_cap".to_string(), throttle("250/s"));
1443 let c = p.compute_constraints(None);
1444 k9::snapshot!(
1445 c.to_human_string(),
1446 "
1447ceilings:
1448 concurrent dispatchers: 32
1449 source: connection_limit
1450 message rate: 250/s
1451 source: provider_cap
1452 declared: max_message_rate = 1000/s ← effectively unreachable
1453
1454"
1455 );
1456 }
1457}