Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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 { .. })
));
}
2 changes: 2 additions & 0 deletions ephemeral-postgres-tests/tests/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion ephemeral-postgres/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
29 changes: 24 additions & 5 deletions ephemeral-postgres/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cluster>` is dropped. Each
`Database` holds an `Arc<Cluster>`, 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.
Expand All @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions ephemeral-postgres/src/attach_params.rs
Original file line number Diff line number Diff line change
@@ -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<String>) -> Self {
Self {
base_url: base_url.into(),
readiness_timeout: READINESS_TIMEOUT,
}
}
}
71 changes: 47 additions & 24 deletions ephemeral-postgres/src/cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,23 +23,17 @@ const POSTGRES_HOST: &str = "127.0.0.1";
pub struct Cluster {
admin_pool: PgPool,
base_url: String,
container: Arc<PostgresContainer>,
server: Arc<ClusterServer>,
}

impl Cluster {
pub async fn start(params: ClusterParams) -> Result<Self, EphemeralPostgresError> {
let ClusterParams {
image,
pub async fn attach(params: AttachParams) -> Result<Self, EphemeralPostgresError> {
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)]
Expand All @@ -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<Self, EphemeralPostgresError> {
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<Database, EphemeralPostgresError> {
Expand Down Expand Up @@ -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<Self, EphemeralPostgresError> {
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),
})
}
}
3 changes: 1 addition & 2 deletions ephemeral-postgres/src/cluster_params.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
7 changes: 7 additions & 0 deletions ephemeral-postgres/src/cluster_server.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
use crate::postgres_container::PostgresContainer;

#[doc(hidden)]
pub enum ClusterServer {
Container(Box<PostgresContainer>),
External,
}
10 changes: 5 additions & 5 deletions ephemeral-postgres/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PostgresContainer>,
server: Arc<ClusterServer>,
database_url: String,
db_name: String,
pool: PgPool,
Expand All @@ -19,13 +19,13 @@ impl Database {
#[doc(hidden)]
#[must_use]
pub fn new(
container: Arc<PostgresContainer>,
server: Arc<ClusterServer>,
database_url: String,
db_name: String,
pool: PgPool,
) -> Self {
Self {
container,
server,
database_url,
db_name,
pool,
Expand Down
3 changes: 3 additions & 0 deletions ephemeral-postgres/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions ephemeral-postgres/src/readiness_timeout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
use std::time::Duration;

pub(crate) const READINESS_TIMEOUT: Duration = Duration::from_secs(30);
Loading