aster_forge_test/
mysql.rs

1//! Shared reusable `MySQL` container for integration tests.
2//!
3//! The container provides a root connection to the default `mysql` system database. Creating
4//! per-test databases and granting product users stays with the product test harness, which can
5//! register database names via [`crate::state::SharedContainerState::remember_resource`] so stale
6//! databases are pruned on later runs.
7
8use crate::database::connect_with_retry;
9use crate::state::{ContainerLease, ContainerStateLock, SharedContainerEndpoint};
10use crate::suite::TestContainerSuite;
11use sea_orm::{ConnectionTrait, Database};
12use testcontainers::core::{ContainerAsync, IntoContainerPort};
13use testcontainers::{GenericImage, ImageExt, ReuseDirective, runners::AsyncRunner};
14
15/// Table-definition cache used by the shared `MySQL` integration-test container.
16///
17/// Large test binaries may create hundreds of isolated schemas concurrently. `MySQL`'s default
18/// cache is too small for that workload and can exhaust prepared-statement reprepare attempts.
19pub const MYSQL_TEST_TABLE_DEFINITION_CACHE: u64 = 32_768;
20/// Maximum simultaneous connections for nextest's process-per-test database pools.
21pub const MYSQL_TEST_MAX_CONNECTIONS: u64 = 1_024;
22
23/// Handle to the suite's shared `MySQL` container.
24pub struct MysqlTestContainer {
25    root_url: String,
26    suite: TestContainerSuite,
27    stale_resources: Vec<String>,
28    _container: Option<ContainerAsync<GenericImage>>,
29    _lease: ContainerLease,
30}
31
32impl MysqlTestContainer {
33    /// Starts (or reuses) the shared `MySQL` container with `root`/`rootpass` credentials.
34    ///
35    /// # Panics
36    ///
37    /// Panics when shared state, container startup, port discovery, readiness connection, or
38    /// readiness connection shutdown fails.
39    pub async fn start(suite: &TestContainerSuite) -> Self {
40        let lock = ContainerStateLock::acquire(suite, "mysql");
41        let mut state = lock.load();
42        let stale_resources = state.prune_stale_before_current_execution();
43        state.register_current_process();
44        for resource in &stale_resources {
45            state.remember_current_process_resource(resource);
46        }
47        let endpoint_identity = format!(
48            "mysql:8.4/table-cache={MYSQL_TEST_TABLE_DEFINITION_CACHE}/max-connections={MYSQL_TEST_MAX_CONNECTIONS}/{}",
49            suite.container_name("mysql")
50        );
51
52        if let Some(port) = state
53            .endpoint()
54            .filter(|endpoint| endpoint.matches(&endpoint_identity))
55            .map(SharedContainerEndpoint::port)
56        {
57            let root_url = root_url(port);
58            if let Ok(root) = Database::connect(&root_url).await
59                && root.close().await.is_ok()
60            {
61                lock.save(&state);
62                drop(lock);
63                return Self {
64                    root_url,
65                    suite: suite.clone(),
66                    stale_resources,
67                    _container: None,
68                    _lease: ContainerLease::new(suite.clone(), "mysql"),
69                };
70            }
71            state.clear_endpoint();
72        }
73
74        let container = GenericImage::new("mysql", "8.4")
75            .with_exposed_port(IntoContainerPort::tcp(3306))
76            .with_container_name(suite.container_name("mysql"))
77            .with_reuse(ReuseDirective::Always)
78            .with_env_var("MYSQL_ROOT_PASSWORD", "rootpass")
79            .start()
80            .await
81            .expect("failed to start MySQL test container");
82        let port = container
83            .get_host_port_ipv4(IntoContainerPort::tcp(3306))
84            .await
85            .expect("MySQL test port should be exposed");
86        let root_url = root_url(port);
87        let root = connect_with_retry(&root_url, "MySQL").await;
88        root.execute_unprepared(&format!(
89            "SET GLOBAL table_definition_cache = {MYSQL_TEST_TABLE_DEFINITION_CACHE}"
90        ))
91        .await
92        .expect("failed to configure MySQL test table definition cache");
93        root.execute_unprepared(&format!(
94            "SET GLOBAL max_connections = {MYSQL_TEST_MAX_CONNECTIONS}"
95        ))
96        .await
97        .expect("failed to configure MySQL test max connections");
98        root.close()
99            .await
100            .expect("failed to close MySQL readiness probe connection");
101        state.set_endpoint(SharedContainerEndpoint::new(endpoint_identity, port));
102        lock.save(&state);
103        drop(lock);
104
105        Self {
106            root_url,
107            suite: suite.clone(),
108            stale_resources,
109            _container: Some(container),
110            _lease: ContainerLease::new(suite.clone(), "mysql"),
111        }
112    }
113
114    /// Returns the root URL pointing at the `mysql` system database.
115    #[must_use]
116    pub fn root_url(&self) -> &str {
117        &self.root_url
118    }
119
120    /// Returns a stable identity for the running suite container.
121    #[must_use]
122    pub fn container_identity(&self) -> &str {
123        &self.root_url
124    }
125
126    /// Builds a URL for a database created inside this container.
127    #[must_use]
128    pub fn database_url(&self, database: &str) -> String {
129        self.root_url.rsplit_once('/').map_or_else(
130            || self.root_url.clone(),
131            |(base, _)| format!("{base}/{database}"),
132        )
133    }
134
135    /// Returns resources left by test processes that no longer exist.
136    #[must_use]
137    pub fn stale_resources(&self) -> &[String] {
138        &self.stale_resources
139    }
140
141    /// Registers a product-owned resource, such as a per-test database name.
142    pub fn remember_resource(&self, resource: &str) {
143        let lock = ContainerStateLock::acquire(&self.suite, "mysql");
144        let mut state = lock.load();
145        state.remember_current_process_resource(resource);
146        lock.save(&state);
147    }
148
149    /// Removes a resource after the product test harness cleaned it up.
150    pub fn forget_resource(&self, resource: &str) {
151        let lock = ContainerStateLock::acquire(&self.suite, "mysql");
152        let mut state = lock.load();
153        state.forget_resource(std::process::id(), resource);
154        lock.save(&state);
155    }
156
157    /// Removes multiple resources after the product test harness cleaned all of them up.
158    pub fn forget_resources(&self, resources: &[String]) {
159        if resources.is_empty() {
160            return;
161        }
162        let lock = ContainerStateLock::acquire(&self.suite, "mysql");
163        let mut state = lock.load();
164        for resource in resources {
165            state.forget_resource(std::process::id(), resource);
166        }
167        lock.save(&state);
168    }
169
170    /// Registers a suite-scoped product fixture that must outlive the producer process.
171    ///
172    /// This is for product-owned migrated template schemas. Products must pair it with their own
173    /// fingerprint-based invalidation and call [`Self::forget_shared_resource`] after dropping a
174    /// superseded fixture.
175    pub fn remember_shared_resource(&self, resource: &str) {
176        let lock = ContainerStateLock::acquire(&self.suite, "mysql");
177        let mut state = lock.load();
178        state.remember_shared_resource(resource);
179        lock.save(&state);
180    }
181
182    /// Removes a suite-scoped product fixture after it was explicitly cleaned up.
183    pub fn forget_shared_resource(&self, resource: &str) {
184        let lock = ContainerStateLock::acquire(&self.suite, "mysql");
185        let mut state = lock.load();
186        state.forget_shared_resource(resource);
187        lock.save(&state);
188    }
189
190    /// Creates a suite-scoped database for a product-owned reusable fixture.
191    ///
192    /// Products own user grants, migrations, and fingerprint validation. This helper owns only
193    /// the root-level database lifecycle and shared-resource registration.
194    ///
195    /// # Panics
196    ///
197    /// Panics when the name is invalid or database creation, connection, or shutdown fails.
198    pub async fn create_shared_database(&self, name: &str) {
199        assert_valid_database_name(name);
200        self.remember_shared_resource(name);
201
202        let root = connect_with_retry(&self.root_url, "MySQL").await;
203        root.execute_unprepared(&format!("CREATE DATABASE {}", quote_identifier(name)))
204            .await
205            .unwrap_or_else(|error| {
206                panic!("failed to create shared MySQL test database {name}: {error}")
207            });
208        root.close().await.unwrap_or_else(|error| {
209            panic!("failed to close MySQL shared database admin connection: {error}")
210        });
211    }
212
213    /// Drops a suite-scoped fixture database and unregisters it.
214    ///
215    /// # Panics
216    ///
217    /// Panics when the name is invalid or database cleanup, connection, or shutdown fails.
218    pub async fn drop_shared_database(&self, name: &str) {
219        assert_valid_database_name(name);
220        let root = connect_with_retry(&self.root_url, "MySQL").await;
221        root.execute_unprepared(&format!(
222            "DROP DATABASE IF EXISTS {}",
223            quote_identifier(name)
224        ))
225        .await
226        .unwrap_or_else(|error| {
227            panic!("failed to drop shared MySQL test database {name}: {error}")
228        });
229        root.close().await.unwrap_or_else(|error| {
230            panic!("failed to close MySQL shared database admin connection: {error}")
231        });
232        self.forget_shared_resource(name);
233    }
234}
235
236fn root_url(port: u16) -> String {
237    format!("mysql://root:rootpass@127.0.0.1:{port}/mysql")
238}
239
240fn quote_identifier(value: &str) -> String {
241    format!("`{}`", value.replace('`', "``"))
242}
243
244fn assert_valid_database_name(name: &str) {
245    assert!(
246        !name.is_empty()
247            && name.len() <= 64
248            && name
249                .bytes()
250                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'),
251        "MySQL test database name must be 1-64 ASCII alphanumeric or '_' characters: {name:?}"
252    );
253}