You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
mysql-connection_max_age_ms does not bound the age of a backend connection. It bounds the time since the connection was last reset, so a connection that keeps being picked up, used and recycled can survive indefinitely and never be closed.
The same bug exists on the PostgreSQL side with pgsql-connection_max_age_ms, in a slightly worse form (see below).
Reported by @takaidohigasi in #5653: #5653 (comment) — observed in production as a small population of backend connections surviving indefinitely, visible as a steady Com_backend_change_user rate.
Root cause
MySQL_Connection::reset() stamps the creation time (lib/mysql_connection.cpp:3305):
So an aged-out connection takes the COM_CHANGE_USER branch, reset() refreshes creation_time, and the connection returns to the pool looking brand new. The age counter restarts on every recycle.
Why the one-line fix is a regression
The obvious fix — drop the creation_time assignment from reset() — is exactly the behaviour that #1393 fixed in 2018:
ec7e9e496 2018-02-27 "Reset connection creation time on CHANGE USER #1393"
Because creation_time is not reset, if mysql-connection_max_age_ms is set the connection is continuously reset when this age is reached.
The loop is still reachable in current code:
MySQL_Data_Stream::return_MySQL_Connection_To_Pool() sees the connection aged out → create_new_session_and_reset_connection()
→ new session with status = RESETTING_CONNECTION → MySQL_Session::handler_again___status_RESETTING_CONNECTION() (lib/MySQL_Session.cpp:2233)
→ async_change_user() succeeds → reset() → return_MySQL_Connection_To_Pool() is called again (lib/MySQL_Session.cpp:2246-2249)
With creation_time preserved, step 3 re-evaluates the same predicate, it is still true, and the connection goes straight back to step 1. The sess->status != PINGING_SERVER guard does not break the cycle: set_status(session_status___NONE) runs after the inner return_MySQL_Connection_To_Pool(), so the status is still RESETTING_CONNECTION at that point.
The 2018 change stopped the loop by making aged connections effectively immortal, rather than by closing them. That is the underlying design problem.
Three recycling paths, not one
Any real fix has to account for every place that recycles a connection instead of closing it:
MySQL_HostGroups_Manager::destroy_MyConn_from_pool() (lib/MySQL_HostGroups_Manager.cpp:2605). If the server is ONLINE, c->send_quit is set, the reset queue is not full and the connection is ASYNC_IDLE, it does to_del = false; queue.add(c) instead of deleting. The HGCU_thread_run() consumer then calls myconn->reset() and MyHGM->push_MyConn_to_pool(myconn) (lib/MySQL_HostGroups_Manager.cpp:227).
This means destroy_MySQL_Connection_From_Pool(true) does not reliably destroy anything — so simply routing aged-out connections to the "destroy" branch is not sufficient either. They land on the reset queue and come back.
The only path that genuinely deletes aged connections is the idle purge in MySQL_HostGroups_Manager (lib/MySQL_HostGroups_Manager.cpp:3031), and it only reaches connections that sit idle in the pool long enough to be scanned. That is why a busy subset survives forever, which matches the production report.
PgSQL_Data_Stream::return_MySQL_Connection_To_Pool() (lib/PgSQL_Data_Stream.cpp:1222) has noreset_connection_algorithm / healthy guard at all — it always takes the reset path.
PgSQL_Session::handler_again___status_RESETTING_CONNECTION() (lib/PgSQL_Session.cpp:1150) mirrors the MySQL flow: async_reset_session() → reset() → return_MySQL_Connection_To_Pool().
Proposed fix
Treat "expired" as a property checked at every recycle decision point, meaning close, never recycle:
reset() no longer touches creation_time, in both protocols. It resets session state; it does not create a new connection.
Add is_expired(now) to MySQL_Connection / PgSQL_Connection, wrapping the connection_max_age_ms comparison so the policy lives in one place.
return_MySQL_Connection_To_Pool(): separate the two triggers that currently share one action. too_many_stmts keeps the COM_CHANGE_USER path (loop-free — CHANGE_USER deallocates the server-side prepared statements, so the predicate goes false). An expired connection is closed.
destroy_MyConn_from_pool(): never place an expired connection on the reset queue.
Mirror 3 and 4 on the PgSQL side.
Termination argument: with (1) and (4), an expired connection can never re-enter the pool, so the #1393 loop cannot recur.
Caveats
send_quit is overloaded.destroy_MyConn_from_pool() gates reset-queue eligibility on c->send_quit, so destroy_MySQL_Connection_From_Pool(false) closes the connection but also skips the graceful COM_QUIT. The fix should decouple "close, don't recycle" from "don't send COM_QUIT" rather than abuse sq = false.
Behaviour change: an aged-but-busy connection is now closed at its next return to the pool instead of being reset, so reconnect rates rise, bounded by connection_max_age_ms. This should be called out in the changelog. @takaidohigasi anticipated this in [feature request] connection lifetime for backend connection #5653.
Test plan
There is currently no TAP coverage for connection_max_age_ms (nothing in test/tap/ exercises it). Given that the failure mode of getting this wrong is a reset loop, the regression test matters more than usual and should assert both directions:
connections actually age out — Server_Connections_created grows under sustained traffic with a short connection_max_age_ms;
Summary
mysql-connection_max_age_msdoes not bound the age of a backend connection. It bounds the time since the connection was last reset, so a connection that keeps being picked up, used and recycled can survive indefinitely and never be closed.The same bug exists on the PostgreSQL side with
pgsql-connection_max_age_ms, in a slightly worse form (see below).Reported by @takaidohigasi in #5653: #5653 (comment) — observed in production as a small population of backend connections surviving indefinitely, visible as a steady
Com_backend_change_userrate.Root cause
MySQL_Connection::reset()stamps the creation time (lib/mysql_connection.cpp:3305):and the age predicate is relative to that value (
lib/mysql_data_stream.cpp:1822):So an aged-out connection takes the
COM_CHANGE_USERbranch,reset()refreshescreation_time, and the connection returns to the pool looking brand new. The age counter restarts on every recycle.Why the one-line fix is a regression
The obvious fix — drop the
creation_timeassignment fromreset()— is exactly the behaviour that #1393 fixed in 2018:and #1393 describes the resulting symptom:
The loop is still reachable in current code:
MySQL_Data_Stream::return_MySQL_Connection_To_Pool()sees the connection aged out →create_new_session_and_reset_connection()status = RESETTING_CONNECTION→MySQL_Session::handler_again___status_RESETTING_CONNECTION()(lib/MySQL_Session.cpp:2233)async_change_user()succeeds →reset()→return_MySQL_Connection_To_Pool()is called again (lib/MySQL_Session.cpp:2246-2249)With
creation_timepreserved, step 3 re-evaluates the same predicate, it is still true, and the connection goes straight back to step 1. Thesess->status != PINGING_SERVERguard does not break the cycle:set_status(session_status___NONE)runs after the innerreturn_MySQL_Connection_To_Pool(), so the status is stillRESETTING_CONNECTIONat that point.The 2018 change stopped the loop by making aged connections effectively immortal, rather than by closing them. That is the underlying design problem.
Three recycling paths, not one
Any real fix has to account for every place that recycles a connection instead of closing it:
MySQL_Data_Stream::return_MySQL_Connection_To_Pool()→create_new_session_and_reset_connection()→COM_CHANGE_USER, inline (lib/mysql_data_stream.cpp:1822).MySQL_HostGroups_Manager::destroy_MyConn_from_pool()(lib/MySQL_HostGroups_Manager.cpp:2605). If the server is ONLINE,c->send_quitis set, the reset queue is not full and the connection isASYNC_IDLE, it doesto_del = false; queue.add(c)instead of deleting. TheHGCU_thread_run()consumer then callsmyconn->reset()andMyHGM->push_MyConn_to_pool(myconn)(lib/MySQL_HostGroups_Manager.cpp:227).This means
destroy_MySQL_Connection_From_Pool(true)does not reliably destroy anything — so simply routing aged-out connections to the "destroy" branch is not sufficient either. They land on the reset queue and come back.PgSQL:
PgSQL_Data_Stream::destroy_MySQL_Connection_From_Pool(true)(lib/PgSQL_Data_Stream.cpp:1256) callscreate_new_session_and_reset_connection()directly.The only path that genuinely deletes aged connections is the idle purge in
MySQL_HostGroups_Manager(lib/MySQL_HostGroups_Manager.cpp:3031), and it only reaches connections that sit idle in the pool long enough to be scanned. That is why a busy subset survives forever, which matches the production report.PostgreSQL side
Same bug, worse shape:
PgSQL_Connection::reset()stampscreation_time(lib/PgSQL_Connection.cpp:2601).PgSQL_Data_Stream::return_MySQL_Connection_To_Pool()(lib/PgSQL_Data_Stream.cpp:1222) has noreset_connection_algorithm/healthyguard at all — it always takes the reset path.PgSQL_Session::handler_again___status_RESETTING_CONNECTION()(lib/PgSQL_Session.cpp:1150) mirrors the MySQL flow:async_reset_session()→reset()→return_MySQL_Connection_To_Pool().Proposed fix
Treat "expired" as a property checked at every recycle decision point, meaning close, never recycle:
reset()no longer touchescreation_time, in both protocols. It resets session state; it does not create a new connection.is_expired(now)toMySQL_Connection/PgSQL_Connection, wrapping theconnection_max_age_mscomparison so the policy lives in one place.return_MySQL_Connection_To_Pool(): separate the two triggers that currently share one action.too_many_stmtskeeps theCOM_CHANGE_USERpath (loop-free —CHANGE_USERdeallocates the server-side prepared statements, so the predicate goes false). An expired connection is closed.destroy_MyConn_from_pool(): never place an expired connection on the reset queue.Termination argument: with (1) and (4), an expired connection can never re-enter the pool, so the #1393 loop cannot recur.
Caveats
send_quitis overloaded.destroy_MyConn_from_pool()gates reset-queue eligibility onc->send_quit, sodestroy_MySQL_Connection_From_Pool(false)closes the connection but also skips the gracefulCOM_QUIT. The fix should decouple "close, don't recycle" from "don't send COM_QUIT" rather than abusesq = false.connection_max_age_ms. This should be called out in the changelog. @takaidohigasi anticipated this in [feature request] connection lifetime for backend connection #5653.Test plan
There is currently no TAP coverage for
connection_max_age_ms(nothing intest/tap/exercises it). Given that the failure mode of getting this wrong is a reset loop, the regression test matters more than usual and should assert both directions:Server_Connections_createdgrows under sustained traffic with a shortconnection_max_age_ms;Com_backend_change_userstays bounded (this is what would catch a Connection creation time is not reset when the connection itself is reset #1393 regression);reset_connection_algorithm1 and 2, and for the PgSQL equivalent.