Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ba3b299715 Cap MySQL statement cache and connection lifetime in storyteller-web
Production hit MySQL error 1461 (max_prepared_stmt_count = 16382), causing
fast 500s on /v1/prompt/batch and other endpoints. sqlx caches up to 100
prepared statements per connection by default, so 7 replicas x 50
connections could hold ~35k statements, more than twice the server cap.

- statement_cache_capacity defaults to 25 (MYSQL_STATEMENT_CACHE_CAPACITY),
  bringing the ceiling to ~8.75k at current scale.
- max_lifetime defaults to 10 minutes (MYSQL_MAX_LIFETIME_SECONDS), down from
  sqlx's 30, so statements orphaned by cancelled queries are released sooner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:12:52 -04:00
@@ -1,10 +1,11 @@
use std::str::FromStr;
use std::time::Duration;
use log::info;
use errors::AnyhowResult;
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
use sqlx::mysql::MySqlPoolOptions;
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions};
use sqlx::MySqlPool;
pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
@@ -20,20 +21,37 @@ pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
// shorter timeout sheds load and surfaces the problem instead of hiding it behind long waits.
let acquire_timeout_seconds = easyenv::get_env_num::<u64>("MYSQL_ACQUIRE_TIMEOUT_SECONDS", 10)?;
// NB: Every cached prepared statement stays open on the server until its connection evicts it
// or closes, and the server caps the total across ALL clients (`max_prepared_stmt_count`,
// 16382 on the managed DB). sqlx's default of 100 per connection let `replicas *
// MYSQL_MAX_CONNECTIONS * 100` blow past that cap, failing new prepares with MySQL error 1461.
// Keep `replicas * MYSQL_MAX_CONNECTIONS * MYSQL_STATEMENT_CACHE_CAPACITY` well under it.
let statement_cache_capacity = easyenv::get_env_num::<usize>("MYSQL_STATEMENT_CACHE_CAPACITY", 25)?;
// NB: Recycling connections releases any server-side statements orphaned by cancelled queries
// (eg. a client disconnect between prepare and close). sqlx's default lifetime is 30 minutes.
let max_lifetime_seconds = easyenv::get_env_num::<u64>("MYSQL_MAX_LIFETIME_SECONDS", 600)?;
// NB: Log the settings before building/connecting so a connect failure has context. We do NOT
// log the connection string (it contains credentials).
info!(
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}",
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}, \
statement_cache_capacity={}, max_lifetime_seconds={}",
max_connections, min_connections, acquire_timeout_seconds,
statement_cache_capacity, max_lifetime_seconds,
);
let connect_options = MySqlConnectOptions::from_str(&db_connection_string)?
.statement_cache_capacity(statement_cache_capacity);
let pool_options = MySqlPoolOptions::new()
.max_connections(max_connections)
.min_connections(min_connections)
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds));
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds))
.max_lifetime(Some(Duration::from_secs(max_lifetime_seconds)));
let pool = pool_options
.connect(&db_connection_string)
.connect_with(connect_options)
.await?;
Ok(pool)