From d4efea6d1392451cdcee4c755b5bf007e337f7e7 Mon Sep 17 00:00:00 2001 From: claudespice Date: Fri, 7 Aug 2026 14:32:25 -0700 Subject: [PATCH] feat(config): parse MultiSubnetFailover instead of dropping it An Always-On availability group listener commonly resolves to one address per subnet, only one of which accepts connections. A client that tries them sequentially stalls on each unreachable address until the OS gives up, so a caller with its own connect deadline can time out before the live replica is ever tried. MultiSubnetFailover is the ADO.NET keyword that asks for those attempts to be made concurrently. The key parsed into the connection-string dictionary and was then dropped: Config exposed no way to read it, so a caller could not honour it even though the user had asked for it. Carry it on Config with a setter and a getter, populated from both the ADO.NET and JDBC strings (one lookup serves both, since each parser lower-cases its keys). A non-boolean value is a conversion error rather than a silent false. Opening the connection stays the caller's job -- this only makes the requested setting readable. --- src/client/config.rs | 109 +++++++++++++++++++++++++++++++++++ src/client/config/ado_net.rs | 49 ++++++++++++++++ src/client/config/jdbc.rs | 27 +++++++++ 3 files changed, 185 insertions(+) diff --git a/src/client/config.rs b/src/client/config.rs index fff68bc15..36084ee78 100644 --- a/src/client/config.rs +++ b/src/client/config.rs @@ -32,6 +32,7 @@ pub struct Config { pub(crate) trust: TrustConfig, pub(crate) auth: AuthMethod, pub(crate) readonly: bool, + pub(crate) multi_subnet_failover: bool, } #[derive(Clone, Debug)] @@ -65,6 +66,7 @@ impl Default for Config { trust: TrustConfig::Default, auth: AuthMethod::None, readonly: false, + multi_subnet_failover: false, } } } @@ -171,6 +173,34 @@ impl Config { self.readonly = readnoly; } + /// Sets whether the client should try every address the server name + /// resolves to concurrently, rather than one at a time. + /// + /// An Always-On availability group listener commonly resolves to one + /// address per subnet, of which only the one in front of the primary + /// replica accepts connections. Attempting them sequentially stalls on each + /// unreachable address until the OS gives up, so a caller with its own + /// connect deadline can time out before the live replica is ever tried. + /// + /// This only records the intent -- opening the connection is the caller's + /// job, and [`Config::get_multi_subnet_failover`] is how it reads the + /// setting back. + /// + /// - Defaults to `false`. + /// + /// [`Config::get_multi_subnet_failover`]: #method.get_multi_subnet_failover + pub fn multi_subnet_failover(&mut self, multi_subnet_failover: bool) { + self.multi_subnet_failover = multi_subnet_failover; + } + + /// Whether `MultiSubnetFailover` was requested, either through the + /// connection string or [`Config::multi_subnet_failover`]. + /// + /// [`Config::multi_subnet_failover`]: #method.multi_subnet_failover + pub fn get_multi_subnet_failover(&self) -> bool { + self.multi_subnet_failover + } + pub(crate) fn get_host(&self) -> &str { self.host .as_deref() @@ -212,7 +242,9 @@ impl Config { /// |`TrustServerCertificateCA`|``|Path to a `pem`, `crt` or `der` certificate file. Cannot be used together with `TrustServerCertificate`| /// |`encrypt`|`true`,`false`,`yes`,`no`,`DANGER_PLAINTEXT`|Specifies whether the driver uses TLS to encrypt communication.| /// |`Application Name`, `ApplicationName`|``|Sets the application name for the connection.| + /// |`MultiSubnetFailover`|`true`,`false`,`yes`,`no`|Records that the client should try every address the server name resolves to concurrently, for an Always-On listener spanning subnets. Read back with [`get_multi_subnet_failover`]; establishing the connection is the caller's job.| /// + /// [`get_multi_subnet_failover`]: struct.Config.html#method.get_multi_subnet_failover /// [ADO.NET connection string]: https://docs.microsoft.com/en-us/dotnet/framework/data/adonet/connection-strings pub fn from_ado_string(s: &str) -> crate::Result { let ado: AdoNetConfig = s.parse()?; @@ -269,6 +301,8 @@ impl Config { builder.readonly(s.readonly()); + builder.multi_subnet_failover(s.multi_subnet_failover()?); + Ok(builder) } } @@ -388,4 +422,79 @@ pub(crate) trait ConfigString { .filter(|val| *val == "ReadOnly") .is_some() } + + /// `MultiSubnetFailover` in an ADO.NET string, `multiSubnetFailover` in a + /// JDBC one -- both parsers lower-case their keys, so one lookup serves + /// both. + fn multi_subnet_failover(&self) -> crate::Result { + self.dict() + .get("multisubnetfailover") + .map(Self::parse_bool) + .unwrap_or(Ok(false)) + } +} + +#[cfg(test)] +mod tests { + use super::Config; + + /// The whole point of the setting: it has to survive the public + /// connection-string entry point, which is the only way a caller that did + /// not build the `Config` by hand can see it. + #[test] + fn multi_subnet_failover_survives_from_ado_string() -> crate::Result<()> { + let config = Config::from_ado_string( + "server=tcp:my-listener.com,1433;MultiSubnetFailover=Yes;TrustServerCertificate=true", + )?; + + assert!(config.get_multi_subnet_failover()); + + Ok(()) + } + + #[test] + fn multi_subnet_failover_survives_from_jdbc_string() -> crate::Result<()> { + let config = Config::from_jdbc_string( + "jdbc:sqlserver://my-listener.com:1433;multiSubnetFailover=true", + )?; + + assert!(config.get_multi_subnet_failover()); + + Ok(()) + } + + /// The regression guard: before this setting existed the key was parsed + /// into the dictionary and dropped, so a caller reading the `Config` saw + /// the same value whether or not it was asked for. + #[test] + fn multi_subnet_failover_defaults_off_and_is_not_implied() -> crate::Result<()> { + let config = Config::from_ado_string("server=tcp:my-server.com,1433")?; + assert!(!config.get_multi_subnet_failover()); + + let config = + Config::from_ado_string("server=tcp:my-server.com,1433;MultiSubnetFailover=No")?; + assert!(!config.get_multi_subnet_failover()); + + assert!(!Config::new().get_multi_subnet_failover()); + + Ok(()) + } + + #[test] + fn multi_subnet_failover_can_be_set_directly() { + let mut config = Config::new(); + config.multi_subnet_failover(true); + + assert!(config.get_multi_subnet_failover()); + } + + /// A non-boolean value fails the whole connection string rather than + /// silently connecting sequentially. + #[test] + fn multi_subnet_failover_rejects_a_non_boolean() { + assert!(Config::from_ado_string( + "server=tcp:my-server.com,1433;MultiSubnetFailover=sometimes" + ) + .is_err()); + } } diff --git a/src/client/config/ado_net.rs b/src/client/config/ado_net.rs index 94df9ca38..187cbea50 100644 --- a/src/client/config/ado_net.rs +++ b/src/client/config/ado_net.rs @@ -484,4 +484,53 @@ mod tests { Ok(()) } + + #[test] + fn multi_subnet_failover_parsing() -> crate::Result<()> { + for (test_str, expected) in [ + ("MultiSubnetFailover=Yes", true), + ("MultiSubnetFailover=true", true), + ("MultiSubnetFailover=No", false), + ("MultiSubnetFailover=false", false), + ] { + let ado: AdoNetConfig = test_str.parse()?; + assert_eq!(expected, ado.multi_subnet_failover()?, "{test_str}"); + } + + Ok(()) + } + + /// The key is matched case-insensitively, like every other key the parser + /// handles. + #[test] + fn multi_subnet_failover_parsing_is_case_insensitive() -> crate::Result<()> { + for test_str in ["multisubnetfailover=yes", "MULTISUBNETFAILOVER=yes"] { + let ado: AdoNetConfig = test_str.parse()?; + assert!(ado.multi_subnet_failover()?, "{test_str}"); + } + + Ok(()) + } + + /// An absent key leaves the sequential connect behaviour untouched. + #[test] + fn multi_subnet_failover_parsing_missing() -> crate::Result<()> { + let test_str = "server=tcp:my-server.com,4200"; + let ado: AdoNetConfig = test_str.parse()?; + + assert!(!ado.multi_subnet_failover()?); + + Ok(()) + } + + /// A value that is not a boolean is a configuration error rather than a + /// silent `false` -- the caller asked for something the driver could not + /// honour. + #[test] + fn multi_subnet_failover_parsing_rejects_a_non_boolean() { + let test_str = "MultiSubnetFailover=sometimes"; + let ado: AdoNetConfig = test_str.parse().expect("parses as a connection string"); + + assert!(ado.multi_subnet_failover().is_err()); + } } diff --git a/src/client/config/jdbc.rs b/src/client/config/jdbc.rs index 4168cf975..bade66265 100644 --- a/src/client/config/jdbc.rs +++ b/src/client/config/jdbc.rs @@ -333,4 +333,31 @@ mod tests { Ok(()) } + + /// JDBC spells the key `multiSubnetFailover`; it reaches the same lookup as + /// the ADO.NET `MultiSubnetFailover`. + #[test] + fn multi_subnet_failover_parsing() -> crate::Result<()> { + let test_str = "jdbc:sqlserver://my-server.com:4200;multiSubnetFailover=true"; + let jdbc: JdbcConfig = test_str.parse()?; + + assert!(jdbc.multi_subnet_failover()?); + + let test_str = "jdbc:sqlserver://my-server.com:4200;multiSubnetFailover=false"; + let jdbc: JdbcConfig = test_str.parse()?; + + assert!(!jdbc.multi_subnet_failover()?); + + Ok(()) + } + + #[test] + fn multi_subnet_failover_parsing_missing() -> crate::Result<()> { + let test_str = "jdbc:sqlserver://my-server.com:4200"; + let jdbc: JdbcConfig = test_str.parse()?; + + assert!(!jdbc.multi_subnet_failover()?); + + Ok(()) + } }