diff --git a/crates/dbx-drivers/src/db/mysql.rs b/crates/dbx-drivers/src/db/mysql.rs index ff467cd1bd..c6e93955ae 100644 --- a/crates/dbx-drivers/src/db/mysql.rs +++ b/crates/dbx-drivers/src/db/mysql.rs @@ -1333,6 +1333,12 @@ impl MySqlTcpKeepaliveMode { const MYSQL_GROUP_CONCAT_MAX_LEN: u64 = 1_048_576; +/// Charset used by the built-in `SET NAMES` setup when the connection config carries no +/// `charset=` parameter, and the one written into MySQL connection URLs by the config layer. +const MYSQL_DEFAULT_CHARSET: &str = "utf8mb4"; +/// Charset older (pre-5.5.3) servers do know; the fallback for [`MYSQL_DEFAULT_CHARSET`]. +const MYSQL_LEGACY_CHARSET: &str = "utf8"; + impl MySqlSetupMode { fn group_concat_max_len_query(self, url: &str) -> Option { match self { @@ -1369,29 +1375,101 @@ async fn verify_pool_connection_with_setup_fallback( eof_mode: MySqlEofMode, tcp_keepalive_mode: MySqlTcpKeepaliveMode, ) -> Result { - match verify_pool_connection(&pool, timeout).await { - Ok(()) => Ok(pool), - Err(err) => { - let Some(fallback_mode) = mysql_group_concat_setup_fallback_mode(setup_mode, &err) else { - return Err(err); - }; - log::info!( - "MySQL server rejected optional group_concat_max_len setup; retrying with {fallback_mode:?} mode" - ); - let fallback_pool = create_pool( - url, - ca_cert_path, - max_connections, - idle_timeout_secs, - setup_database, - extra_setup_queries, - fallback_mode, - eof_mode, - tcp_keepalive_mode, - )?; - verify_pool_connection(&fallback_pool, timeout).await.map(|_| fallback_pool) + // The retry URL only changes when a fallback was actually applied, so a + // connection that works on the first attempt never pays for the extra pool. + let mut retry_url = url.to_string(); + let mut error = match verify_pool_connection(&pool, timeout).await { + Ok(()) => return Ok(pool), + Err(err) => err, + }; + + if let Some(charset_url) = mysql_legacy_charset_fallback_url(&retry_url, &error) { + log::info!("MySQL server does not support the built-in utf8mb4 charset; retrying with charset=utf8"); + let charset_pool = create_pool( + &charset_url, + ca_cert_path, + max_connections, + idle_timeout_secs, + setup_database, + extra_setup_queries, + setup_mode, + eof_mode, + tcp_keepalive_mode, + )?; + match verify_pool_connection(&charset_pool, timeout).await { + Ok(()) => return Ok(charset_pool), + Err(charset_error) => { + retry_url = charset_url; + error = charset_error; + } } } + + let Some(fallback_mode) = mysql_group_concat_setup_fallback_mode(setup_mode, &error) else { + return Err(error); + }; + log::info!("MySQL server rejected optional group_concat_max_len setup; retrying with {fallback_mode:?} mode"); + let fallback_pool = create_pool( + &retry_url, + ca_cert_path, + max_connections, + idle_timeout_secs, + setup_database, + extra_setup_queries, + fallback_mode, + eof_mode, + tcp_keepalive_mode, + )?; + verify_pool_connection(&fallback_pool, timeout).await.map(|_| fallback_pool) +} + +/// MySQL older than 5.5.3 has no `utf8mb4` charset, so the built-in `SET NAMES utf8mb4` +/// setup is rejected with `ERROR 1115 (42000): Unknown character set: 'utf8mb4'` and the +/// whole connection fails (issue #9285, MySQL 5.1.73). Retry such a connection with the +/// three-byte `utf8` charset those servers do know; newer servers keep `utf8mb4`. +/// +/// `utf8mb4` is dbx's own built-in default — it is what `SET NAMES` uses when the +/// connection config carries no `charset=` parameter, and the config layer writes it into +/// the URL for every MySQL connection. So the retry only applies to that default: a +/// connection that explicitly asks for another charset keeps it and reports the server +/// error as-is. +fn mysql_legacy_charset_fallback_url(url: &str, error: &str) -> Option { + if mysql_connection_charset(url).is_some_and(|charset| !charset.eq_ignore_ascii_case(MYSQL_DEFAULT_CHARSET)) { + return None; + } + + let lower = error.to_ascii_lowercase(); + // 1115 is ER_UNKNOWN_CHARACTER_SET; MySQL-compatible servers may report the + // same rejection with a different code but the same message. + if !lower.contains("unknown character set") && !lower.contains("1115") { + return None; + } + + Some(mysql_url_with_charset(url, MYSQL_LEGACY_CHARSET)) +} + +fn mysql_url_with_charset(url: &str, charset: &str) -> String { + let (base_url, fragment) = url.split_once('#').map_or((url, ""), |(base, fragment)| (base, fragment)); + let charset_param = format!("charset={charset}"); + let base = match base_url.split_once('?') { + Some((prefix, query)) => { + let mut params: Vec = query + .split('&') + .filter(|part| !part.trim().is_empty()) + .filter(|part| !part.split_once('=').is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("charset"))) + .map(str::to_string) + .collect(); + params.push(charset_param); + format!("{prefix}?{}", params.join("&")) + } + None => format!("{base_url}?{charset_param}"), + }; + + if fragment.is_empty() { + base + } else { + format!("{base}#{fragment}") + } } fn mysql_group_concat_setup_fallback_mode(setup_mode: MySqlSetupMode, error: &str) -> Option { @@ -1618,7 +1696,7 @@ fn mysql_setup_queries_for_database_with_mode( extra_setup_queries: &[String], setup_mode: MySqlSetupMode, ) -> Vec { - let charset = mysql_connection_charset(url).unwrap_or("utf8mb4"); + let charset = mysql_connection_charset(url).unwrap_or(MYSQL_DEFAULT_CHARSET); let database = setup_database.map(ToOwned::to_owned).or_else(|| mysql_connection_database(url)); let mut queries = Vec::new(); if let Some(database) = database.as_deref() { @@ -8058,6 +8136,89 @@ mod tests { } } + #[test] + fn mysql_legacy_charset_fallback_retries_with_utf8_for_old_servers() { + let error = "MySQL connection failed: Server error: `ERROR 1115 (42000): Unknown character set: 'utf8mb4''"; + + assert_eq!( + mysql_legacy_charset_fallback_url("mysql://root:pw@host:3306/app", error), + Some("mysql://root:pw@host:3306/app?charset=utf8".to_string()) + ); + assert_eq!( + mysql_legacy_charset_fallback_url("mysql://root:pw@host:3306/app?ssl-mode=disabled#session", error), + Some("mysql://root:pw@host:3306/app?ssl-mode=disabled&charset=utf8#session".to_string()) + ); + // MySQL-compatible servers may report the same rejection with another code. + assert!(mysql_legacy_charset_fallback_url( + "mysql://root@host:9030/app", + "Server error: Unknown character set: 'utf8mb4'" + ) + .is_some()); + } + + #[test] + fn mysql_legacy_charset_fallback_retries_the_built_in_default_charset() { + let error = "MySQL connection failed: Server error: `ERROR 1115 (42000): Unknown character set: 'utf8mb4''"; + + // The config layer writes the built-in default into every MySQL URL, so the + // default spelling must stay retryable. + assert_eq!( + mysql_legacy_charset_fallback_url( + "mysql://root:secret@127.0.0.1:3306/app?ssl-mode=disabled&charset=utf8mb4", + error + ), + Some("mysql://root:secret@127.0.0.1:3306/app?ssl-mode=disabled&charset=utf8".to_string()) + ); + assert_eq!( + mysql_legacy_charset_fallback_url("mysql://root@host/app?charset=UTF8MB4", error), + Some("mysql://root@host/app?charset=utf8".to_string()) + ); + } + + #[test] + fn mysql_legacy_charset_fallback_keeps_another_configured_charset() { + let error = "MySQL connection failed: Server error: `ERROR 1115 (42000): Unknown character set: 'utf8mb4''"; + + // A charset other than the built-in default is the user's own choice; the + // retry must not override it. + assert_eq!(mysql_legacy_charset_fallback_url("mysql://root@host/app?charset=latin1", error), None); + assert_eq!(mysql_legacy_charset_fallback_url("mysql://root@host/app?charset=gbk", error), None); + } + + #[test] + fn mysql_legacy_charset_fallback_ignores_unrelated_connection_errors() { + // A credential failure has nothing to do with the built-in charset setup. + assert_eq!( + mysql_legacy_charset_fallback_url( + "mysql://root@host/app", + "MySQL connection failed: Server error: `ERROR 1045 (28000): Access denied for user 'root'@'host'`" + ), + None + ); + + // A server-side rejection of another setup statement must not rewrite the charset. + assert_eq!( + mysql_legacy_charset_fallback_url( + "mysql://root@host/app", + "MySQL connection failed: Server error: `ERROR HY000 (1193): Unknown system variable,stmt:SET @@group_concat_max_len = 1048576'" + ), + None + ); + } + + #[test] + fn mysql_url_with_charset_replaces_an_existing_charset_param() { + assert_eq!(mysql_url_with_charset("mysql://root@host/app", "utf8"), "mysql://root@host/app?charset=utf8"); + assert_eq!( + mysql_url_with_charset("mysql://root@host/app?charset=utf8mb4&ssl-mode=disabled", "utf8"), + "mysql://root@host/app?ssl-mode=disabled&charset=utf8" + ); + assert_eq!( + mysql_url_with_charset("mysql://root@host/app?ssl-mode=disabled#session", "utf8"), + "mysql://root@host/app?ssl-mode=disabled&charset=utf8#session" + ); + } + #[test] fn mysql_index_metadata_query_has_expression_compatibility_fallback() { let with_expression = mysql_list_indexes_sql("db", "users", true); diff --git a/docs/screenshots/issue-9285-after-fallback-utf8.png b/docs/screenshots/issue-9285-after-fallback-utf8.png new file mode 100644 index 0000000000..572a4e2491 Binary files /dev/null and b/docs/screenshots/issue-9285-after-fallback-utf8.png differ diff --git a/docs/screenshots/issue-9285-before-utf8mb4-rejected.png b/docs/screenshots/issue-9285-before-utf8mb4-rejected.png new file mode 100644 index 0000000000..b7ca44a405 Binary files /dev/null and b/docs/screenshots/issue-9285-before-utf8mb4-rejected.png differ