From c703015b5e4decc72885d38d5d91c90cf66d8c22 Mon Sep 17 00:00:00 2001 From: Robin Schroer Date: Sun, 24 Aug 2025 12:41:50 +0900 Subject: [PATCH 1/2] Clear the MySQL connection statement cache on error Some cloud offerings like AWS Aurora allow for "zero-downtime" restarts for patches, which preserves existing TCP connections but wipes out a lot of server state, including the statement cache. In that scenario, trying to execute a previously prepared statement causes an error response with ``` HY000 Unknown prepared statement handler () given to mysql_stmt_precheck ``` which appears to be the only way to detect this scenario. To avoid subsequent errors for the same connection, we can clear the statement cache on the client side, causing all queries to get re-prepared. This is basically what ActiveRecord does,[0] which we can confirm is not vulnerable to the same issue. An even better solution would be to re-prepare and -try the query right there, like ActiveRecord does. [0]: https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/mysql2/database_statements.rb#L66-L78 --- sqlx-mysql/src/connection/executor.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sqlx-mysql/src/connection/executor.rs b/sqlx-mysql/src/connection/executor.rs index 2b660b94b3..071ca8b95e 100644 --- a/sqlx-mysql/src/connection/executor.rs +++ b/sqlx-mysql/src/connection/executor.rs @@ -183,7 +183,11 @@ impl MySqlConnection { loop { // query response is a meta-packet which may be one of: // Ok, Err, ResultSet, or (unhandled) LocalInfileRequest - let mut packet = self.inner.stream.recv_packet().await?; + let mut packet = self.inner.stream.recv_packet().await.inspect_err(|_| { + // if a prepared statement vanished on the server side, we get an error here + // clear the statement cache in case the connection got reset to cause re-preparing + self.inner.cache_statement.clear(); + })?; if packet[0] == 0x00 || packet[0] == 0xff { // first packet in a query response is OK or ERR From f1fd80a0fdc73cdacbb03c9474a4fea57fe14709 Mon Sep 17 00:00:00 2001 From: Robin Schroer Date: Sun, 24 Aug 2025 12:41:50 +0900 Subject: [PATCH 2/2] Add a bad integration test for zero-downtime patches I think it's best to test this issue through integration tests, but there seems to be no way to clear the server-side prepared statements without access to the raw packet stream, as they're not named and thus cannot be deleted via DEALLOCATE PREPARED. To make the test work, additional interfaces have to be made available, which should not be shipped as part of the library for regular use. --- sqlx-core/src/common/statement_cache.rs | 4 ++++ sqlx-mysql/src/connection/mod.rs | 13 +++++++++++++ tests/mysql/mysql.rs | 24 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/sqlx-core/src/common/statement_cache.rs b/sqlx-core/src/common/statement_cache.rs index eb800ca623..0aad15354a 100644 --- a/sqlx-core/src/common/statement_cache.rs +++ b/sqlx-core/src/common/statement_cache.rs @@ -72,4 +72,8 @@ impl StatementCache { pub fn is_enabled(&self) -> bool { self.capacity() > 0 } + + pub fn iter(&self) -> impl Iterator { + self.inner.iter().map(|(_, v)| v) + } } diff --git a/sqlx-mysql/src/connection/mod.rs b/sqlx-mysql/src/connection/mod.rs index 569ad32722..d5a23603e3 100644 --- a/sqlx-mysql/src/connection/mod.rs +++ b/sqlx-mysql/src/connection/mod.rs @@ -57,6 +57,19 @@ impl MySqlConnection { .status_flags .intersects(Status::SERVER_STATUS_IN_TRANS) } + + pub async fn nuke_cached_statements(&mut self) -> Result<(), Error> { + for (statement_id, _) in self.inner.cache_statement.iter() { + self.inner + .stream + .send_packet(StmtClose { + statement: *statement_id, + }) + .await?; + } + + Ok(()) + } } impl Debug for MySqlConnection { diff --git a/tests/mysql/mysql.rs b/tests/mysql/mysql.rs index 5d6a5ef233..fb07657bdc 100644 --- a/tests/mysql/mysql.rs +++ b/tests/mysql/mysql.rs @@ -54,6 +54,30 @@ async fn it_maths() -> anyhow::Result<()> { Ok(()) } +#[sqlx_macros::test] +async fn it_clears_statement_cache_on_error() -> anyhow::Result<()> { + setup_if_needed(); + + let query = "SELECT 1"; + + let mut conn = new::().await?; + let _ = sqlx::query(query).fetch_one(&mut conn).await?; + assert_eq!(1, conn.cached_statements_size()); + + // clear cached statements only on the server side + conn.nuke_cached_statements().await?; + assert_eq!(1, conn.cached_statements_size()); + + // one query fails as the statement is not cached server-side any more, client-side cache is cleared + assert!(sqlx::query(query).fetch_one(&mut conn).await.is_err()); + assert_eq!(0, conn.cached_statements_size()); + + // next query succeeds again + let _ = sqlx::query(query).fetch_one(&mut conn).await?; + + Ok(()) +} + #[sqlx_macros::test] async fn it_can_fail_at_querying() -> anyhow::Result<()> { let mut conn = new::().await?;