From 7d9ce1e9ad9a65d5c66f3b447342e8cc683cbc5e Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Fri, 25 Sep 2026 18:58:56 +0200 Subject: [PATCH] Attach to a running Postgres server so separate test processes can share one --- Cargo.lock | 2 +- ...h_creates_databases_in_a_running_server.rs | 25 +++++++ ..._attach_errors_when_readiness_times_out.rs | 19 +++++ ephemeral-postgres-tests/tests/main.rs | 2 + ephemeral-postgres/Cargo.toml | 2 +- ephemeral-postgres/README.md | 29 ++++++-- ephemeral-postgres/src/attach_params.rs | 18 +++++ ephemeral-postgres/src/cluster.rs | 71 ++++++++++++------- ephemeral-postgres/src/cluster_params.rs | 3 +- ephemeral-postgres/src/cluster_server.rs | 7 ++ ephemeral-postgres/src/database.rs | 10 +-- ephemeral-postgres/src/lib.rs | 3 + ephemeral-postgres/src/readiness_timeout.rs | 3 + 13 files changed, 156 insertions(+), 38 deletions(-) create mode 100644 ephemeral-postgres-tests/tests/cluster_attach_creates_databases_in_a_running_server.rs create mode 100644 ephemeral-postgres-tests/tests/cluster_attach_errors_when_readiness_times_out.rs create mode 100644 ephemeral-postgres/src/attach_params.rs create mode 100644 ephemeral-postgres/src/cluster_server.rs create mode 100644 ephemeral-postgres/src/readiness_timeout.rs diff --git a/Cargo.lock b/Cargo.lock index 6d123dd..2457868 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -549,7 +549,7 @@ dependencies = [ [[package]] name = "ephemeral-postgres" -version = "0.3.0" +version = "0.3.1" dependencies = [ "sqlx", "testcontainers-modules", diff --git a/ephemeral-postgres-tests/tests/cluster_attach_creates_databases_in_a_running_server.rs b/ephemeral-postgres-tests/tests/cluster_attach_creates_databases_in_a_running_server.rs new file mode 100644 index 0000000..230a9c9 --- /dev/null +++ b/ephemeral-postgres-tests/tests/cluster_attach_creates_databases_in_a_running_server.rs @@ -0,0 +1,25 @@ +use ephemeral_postgres::attach_params::AttachParams; +use ephemeral_postgres::cluster::Cluster; +use ephemeral_postgres::cluster_params::ClusterParams; +use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; +use sqlx::Row; + +#[tokio::test] +async fn attached_cluster_creates_databases_in_the_running_server() { + let running = Cluster::start(ClusterParams::new(postgres_test_image())) + .await + .unwrap(); + let attached = Cluster::attach(AttachParams::new(running.base_url())) + .await + .unwrap(); + let database = attached.create_database().await.unwrap(); + + let result: i32 = sqlx::query("SELECT 1::int AS value") + .fetch_one(database.pool()) + .await + .unwrap() + .get("value"); + + assert_eq!(attached.base_url(), running.base_url()); + assert_eq!(result, 1); +} diff --git a/ephemeral-postgres-tests/tests/cluster_attach_errors_when_readiness_times_out.rs b/ephemeral-postgres-tests/tests/cluster_attach_errors_when_readiness_times_out.rs new file mode 100644 index 0000000..0aea24c --- /dev/null +++ b/ephemeral-postgres-tests/tests/cluster_attach_errors_when_readiness_times_out.rs @@ -0,0 +1,19 @@ +use std::time::Duration; + +use ephemeral_postgres::attach_params::AttachParams; +use ephemeral_postgres::cluster::Cluster; +use ephemeral_postgres::ephemeral_postgres_error::EphemeralPostgresError; + +#[tokio::test] +async fn attach_errors_when_the_server_does_not_become_ready() { + let result = Cluster::attach(AttachParams { + readiness_timeout: Duration::ZERO, + ..AttachParams::new("postgres://postgres@127.0.0.1:1") + }) + .await; + + assert!(matches!( + result, + Err(EphemeralPostgresError::ReadinessTimeout { .. }) + )); +} diff --git a/ephemeral-postgres-tests/tests/main.rs b/ephemeral-postgres-tests/tests/main.rs index 8887129..f89d3cc 100644 --- a/ephemeral-postgres-tests/tests/main.rs +++ b/ephemeral-postgres-tests/tests/main.rs @@ -1,3 +1,5 @@ +mod cluster_attach_creates_databases_in_a_running_server; +mod cluster_attach_errors_when_readiness_times_out; mod cluster_errors_when_image_invalid; mod cluster_errors_when_readiness_times_out; mod cluster_finalize_errors_when_mapped_port_unavailable; diff --git a/ephemeral-postgres/Cargo.toml b/ephemeral-postgres/Cargo.toml index 9a14332..6a6d3f7 100644 --- a/ephemeral-postgres/Cargo.toml +++ b/ephemeral-postgres/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ephemeral-postgres" -version = "0.3.0" +version = "0.3.1" edition = "2024" description = "Ephemeral PostgreSQL instances for Rust integration tests, backed by testcontainers (Docker)." license = "Apache-2.0" diff --git a/ephemeral-postgres/README.md b/ephemeral-postgres/README.md index c6e6ff5..e9790c1 100644 --- a/ephemeral-postgres/README.md +++ b/ephemeral-postgres/README.md @@ -52,13 +52,32 @@ async fn each_test_gets_an_isolated_database() { share the container. - `database.pool()` returns an `sqlx::PgPool` connected to that database. +## Attaching to a running server + +When many test processes should share one server (for example a test runner that starts a separate +process per test), start the server once outside the tests and attach to it from each test. Every +attached cluster still carves out its own isolated databases: + +```rust +use ephemeral_postgres::attach_params::AttachParams; +use ephemeral_postgres::cluster::Cluster; + +let cluster = Cluster::attach(AttachParams::new("postgres://postgres@127.0.0.1:5432")).await?; +let database = cluster.create_database().await?; +``` + +`AttachParams::new(base_url)` takes the server URL without a database name, waits for the server +to accept connections exactly like `Cluster::start`, and never stops the server: whoever started +it owns its lifetime. + ## Cleanup Cleanup is automatic and tied to ownership — you never close a cluster manually: -- The container is stopped and removed as soon as the last `Arc` is dropped. Each - `Database` holds an `Arc`, so the container outlives the databases carved from it and - disappears once the cluster and all of its databases go out of scope at the end of the test. +- The container is stopped and removed as soon as the cluster that started it and every + `Database` carved from it are dropped. Each `Database` shares ownership of the container, so the + container outlives the cluster value if databases are still in use, and disappears once all of + them go out of scope at the end of the test. - Keep the cluster in a local binding for the duration of the test. Never store a `Cluster` or `Database` in a `static`, `OnceLock`, or `lazy_static`: statics are never dropped, so the container would leak for the whole lifetime of the test process. @@ -68,8 +87,8 @@ Cleanup is automatic and tied to ownership — you never close a cluster manuall ## Configuration -`ClusterParams::new(image)` waits up to 30 seconds for the server to accept connections. Override -the readiness timeout with struct-update syntax: +`ClusterParams::new(image)` and `AttachParams::new(base_url)` wait up to 30 seconds for the server +to accept connections. Override the readiness timeout with struct-update syntax: ```rust use std::time::Duration; diff --git a/ephemeral-postgres/src/attach_params.rs b/ephemeral-postgres/src/attach_params.rs new file mode 100644 index 0000000..89b4316 --- /dev/null +++ b/ephemeral-postgres/src/attach_params.rs @@ -0,0 +1,18 @@ +use std::time::Duration; + +use crate::readiness_timeout::READINESS_TIMEOUT; + +pub struct AttachParams { + pub base_url: String, + pub readiness_timeout: Duration, +} + +impl AttachParams { + #[must_use] + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + readiness_timeout: READINESS_TIMEOUT, + } + } +} diff --git a/ephemeral-postgres/src/cluster.rs b/ephemeral-postgres/src/cluster.rs index 397e714..9f0725b 100644 --- a/ephemeral-postgres/src/cluster.rs +++ b/ephemeral-postgres/src/cluster.rs @@ -10,7 +10,9 @@ use testcontainers_modules::testcontainers::ContainerAsync; use testcontainers_modules::testcontainers::runners::AsyncRunner; use uuid::Uuid; +use crate::attach_params::AttachParams; use crate::cluster_params::ClusterParams; +use crate::cluster_server::ClusterServer; use crate::database::Database; use crate::ephemeral_postgres_error::EphemeralPostgresError; use crate::postgres_container::PostgresContainer; @@ -21,23 +23,17 @@ const POSTGRES_HOST: &str = "127.0.0.1"; pub struct Cluster { admin_pool: PgPool, base_url: String, - container: Arc, + server: Arc, } impl Cluster { - pub async fn start(params: ClusterParams) -> Result { - let ClusterParams { - image, + pub async fn attach(params: AttachParams) -> Result { + let AttachParams { + base_url, readiness_timeout, } = params; - let container = image - .into_container_request() - .start() - .await - .map_err(|source| EphemeralPostgresError::ContainerStart { source })?; - - Self::from_started_container(container, readiness_timeout).await + Self::connect(base_url, ClusterServer::External, readiness_timeout).await } #[doc(hidden)] @@ -48,18 +44,32 @@ impl Cluster { let container = PostgresContainer::new(container); let port = container.mapped_host_port().await?; - let base_url = format!("postgres://postgres@{POSTGRES_HOST}:{port}"); - let admin_pool = wait_until_postgres_admin_pool_ready( - &format!("{base_url}/postgres"), + Self::connect( + format!("postgres://postgres@{POSTGRES_HOST}:{port}"), + ClusterServer::Container(Box::new(container)), readiness_timeout, ) - .await?; + .await + } - Ok(Self { - admin_pool, - base_url, - container: Arc::new(container), - }) + pub async fn start(params: ClusterParams) -> Result { + let ClusterParams { + image, + readiness_timeout, + } = params; + + let container = image + .into_container_request() + .start() + .await + .map_err(|source| EphemeralPostgresError::ContainerStart { source })?; + + Self::from_started_container(container, readiness_timeout).await + } + + #[must_use] + pub fn base_url(&self) -> &str { + &self.base_url } pub async fn create_database(&self) -> Result { @@ -100,15 +110,28 @@ impl Cluster { })?; Ok(Database::new( - Arc::clone(&self.container), + Arc::clone(&self.server), database_url, db_name, pool, )) } - #[must_use] - pub fn base_url(&self) -> &str { - &self.base_url + async fn connect( + base_url: String, + server: ClusterServer, + readiness_timeout: Duration, + ) -> Result { + let admin_pool = wait_until_postgres_admin_pool_ready( + &format!("{base_url}/postgres"), + readiness_timeout, + ) + .await?; + + Ok(Self { + admin_pool, + base_url, + server: Arc::new(server), + }) } } diff --git a/ephemeral-postgres/src/cluster_params.rs b/ephemeral-postgres/src/cluster_params.rs index fac5784..afe8c76 100644 --- a/ephemeral-postgres/src/cluster_params.rs +++ b/ephemeral-postgres/src/cluster_params.rs @@ -1,8 +1,7 @@ use std::time::Duration; use crate::postgres_image::PostgresImage; - -const READINESS_TIMEOUT: Duration = Duration::from_secs(30); +use crate::readiness_timeout::READINESS_TIMEOUT; pub struct ClusterParams { pub image: PostgresImage, diff --git a/ephemeral-postgres/src/cluster_server.rs b/ephemeral-postgres/src/cluster_server.rs new file mode 100644 index 0000000..8c67559 --- /dev/null +++ b/ephemeral-postgres/src/cluster_server.rs @@ -0,0 +1,7 @@ +use crate::postgres_container::PostgresContainer; + +#[doc(hidden)] +pub enum ClusterServer { + Container(Box), + External, +} diff --git a/ephemeral-postgres/src/database.rs b/ephemeral-postgres/src/database.rs index b719a9c..408463d 100644 --- a/ephemeral-postgres/src/database.rs +++ b/ephemeral-postgres/src/database.rs @@ -2,14 +2,14 @@ use std::sync::Arc; use sqlx::PgPool; -use crate::postgres_container::PostgresContainer; +use crate::cluster_server::ClusterServer; pub struct Database { #[expect( dead_code, - reason = "container Arc keeps the postgres container alive while this database is in use" + reason = "server Arc keeps an owned postgres container alive while this database is in use" )] - container: Arc, + server: Arc, database_url: String, db_name: String, pool: PgPool, @@ -19,13 +19,13 @@ impl Database { #[doc(hidden)] #[must_use] pub fn new( - container: Arc, + server: Arc, database_url: String, db_name: String, pool: PgPool, ) -> Self { Self { - container, + server, database_url, db_name, pool, diff --git a/ephemeral-postgres/src/lib.rs b/ephemeral-postgres/src/lib.rs index 3362882..42c6a83 100644 --- a/ephemeral-postgres/src/lib.rs +++ b/ephemeral-postgres/src/lib.rs @@ -1,7 +1,10 @@ +pub mod attach_params; pub mod cluster; pub mod cluster_params; +pub mod cluster_server; pub mod database; pub mod ephemeral_postgres_error; pub mod postgres_container; pub mod postgres_image; +mod readiness_timeout; mod wait_until_postgres_admin_pool_ready; diff --git a/ephemeral-postgres/src/readiness_timeout.rs b/ephemeral-postgres/src/readiness_timeout.rs new file mode 100644 index 0000000..dd5cb8d --- /dev/null +++ b/ephemeral-postgres/src/readiness_timeout.rs @@ -0,0 +1,3 @@ +use std::time::Duration; + +pub(crate) const READINESS_TIMEOUT: Duration = Duration::from_secs(30);