From c385a0915c9eec8455cba09ff87698b4ce03d110 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Wed, 7 Oct 2026 18:14:48 +0200 Subject: [PATCH] fix: Replace a test container whose database does not become reachable, and remove every container that failed to start. Docker Desktop sometimes does not make the port it maps a container to reachable from the host. The container keeps running, but the database in it can never be reached, so start() failed after waiting 10 seconds, and left the container behind. This made tests fail now and then. start() now replaces such a container with a new one, up to three times, and then gives up with an exception that says why. Every other failure, e.g. an image that does not exist, is thrown at once, as it is. Either way, the container that failed to start is removed. This is what the Go SDK does since its #188. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hc2MPSLu7HDHTm8HiBsrDz --- .../testcontainers/Container.java | 43 ++++++++- .../testcontainers/ContainerTest.java | 89 +++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/eventsourcingdb-testcontainers/src/main/java/io/eventsourcingdb/testcontainers/Container.java b/eventsourcingdb-testcontainers/src/main/java/io/eventsourcingdb/testcontainers/Container.java index bd984cf..7e3f22a 100644 --- a/eventsourcingdb-testcontainers/src/main/java/io/eventsourcingdb/testcontainers/Container.java +++ b/eventsourcingdb-testcontainers/src/main/java/io/eventsourcingdb/testcontainers/Container.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.Base64; import java.util.List; +import java.util.function.Consumer; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.images.builder.Transferable; @@ -21,12 +22,19 @@ public final class Container { private static final String IMAGE_NAME = "thenativeweb/eventsourcingdb"; private static final String SIGNING_KEY_PATH = "/etc/esdb/signing-key.pem"; + // How many containers start() starts at most, as it replaces each + // container whose database does not become reachable. + private static final int MAX_START_ATTEMPTS = 3; + private String imageTag = "latest"; private int internalPort = 3000; private String apiToken = "secret"; private KeyPair signingKeyPair; private GenericContainer container; + // Starts a container. It is a field only so that tests can replace it. + Consumer> starter = GenericContainer::start; + public Container withImageTag(String tag) { imageTag = tag; return this; @@ -49,6 +57,38 @@ public Container withSigningKey() { } public void start() { + for (var attempt = 1; ; attempt++) { + var container = newContainer(); + + try { + starter.accept(container); + this.container = container; + return; + } catch (RuntimeException ex) { + // Docker Desktop sometimes does not make the port it maps a + // container to reachable from the host. The container keeps + // running, but the database in it can never be reached, so it + // is replaced by a new one. Every other failure is thrown at + // once. Either way, the container that failed to start is + // removed, so that it is not left behind. + var isRunning = container.isRunning(); + container.stop(); + + if (!isRunning) { + throw ex; + } + if (attempt == MAX_START_ATTEMPTS) { + throw new IllegalStateException( + "failed to start container, the database did not become reachable in " + + MAX_START_ATTEMPTS + + " attempts", + ex); + } + } + } + } + + private GenericContainer newContainer() { var command = new ArrayList<>(List.of( "run", "--api-token", @@ -70,9 +110,8 @@ public void start() { } container.withCommand(command.toArray(String[]::new)); - container.start(); - this.container = container; + return container; } private static byte[] pem(PrivateKey privateKey) { diff --git a/eventsourcingdb-testcontainers/src/test/java/io/eventsourcingdb/testcontainers/ContainerTest.java b/eventsourcingdb-testcontainers/src/test/java/io/eventsourcingdb/testcontainers/ContainerTest.java index 548c50c..132a07b 100644 --- a/eventsourcingdb-testcontainers/src/test/java/io/eventsourcingdb/testcontainers/ContainerTest.java +++ b/eventsourcingdb-testcontainers/src/test/java/io/eventsourcingdb/testcontainers/ContainerTest.java @@ -7,9 +7,12 @@ import io.eventsourcingdb.EventCandidate; import java.net.URI; +import java.util.ArrayList; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.ContainerLaunchException; class ContainerTest { @Test @@ -109,4 +112,90 @@ void stopsWithoutFailingIfItIsNotRunning() { assertFalse(container.isRunning()); } + + // Lists which of the given containers still exist in Docker, running or + // not. + private static List existing(List containerIds) { + return DockerClientFactory.instance() + .client() + .listContainersCmd() + .withShowAll(true) + .withIdFilter(containerIds) + .exec() + .stream() + .map(container -> container.getId()) + .toList(); + } + + @Test + void replacesAContainerWhoseDatabaseDoesNotBecomeReachable() { + var containerIds = new ArrayList(); + var container = new Container().withImageTag(ImageTag.fromDockerfile()); + + // Docker Desktop sometimes does not make the port of a container + // reachable from the host. The container keeps running, but starting + // it fails, as it does here twice. + container.starter = genericContainer -> { + genericContainer.start(); + containerIds.add(genericContainer.getContainerId()); + if (containerIds.size() < 3) { + throw new ContainerLaunchException("the database did not become reachable"); + } + }; + + container.start(); + try { + assertEquals(3, containerIds.size()); + assertEquals(List.of(containerIds.get(2)), existing(containerIds)); + container.getClient().ping(); + } finally { + container.stop(); + } + } + + @Test + void givesUpAfterThreeAttemptsAndSaysWhy() { + var containerIds = new ArrayList(); + var failures = new ArrayList(); + var container = new Container().withImageTag(ImageTag.fromDockerfile()); + + container.starter = genericContainer -> { + genericContainer.start(); + containerIds.add(genericContainer.getContainerId()); + var failure = new ContainerLaunchException("the database did not become reachable"); + failures.add(failure); + throw failure; + }; + + var exception = assertThrows(IllegalStateException.class, container::start); + + assertEquals( + "failed to start container, the database did not become reachable in 3 attempts", + exception.getMessage()); + assertEquals(failures.getLast(), exception.getCause()); + assertEquals(3, containerIds.size()); + assertEquals(List.of(), existing(containerIds)); + assertFalse(container.isRunning()); + } + + @Test + void throwsAnyOtherFailureAtOnceAndRemovesTheContainer() { + var containerIds = new ArrayList(); + + // Without an API token, EventSourcingDB exits right away. + var container = new Container().withImageTag(ImageTag.fromDockerfile()).withApiToken(""); + container.starter = genericContainer -> { + try { + genericContainer.start(); + } finally { + containerIds.add(genericContainer.getContainerId()); + } + }; + + assertThrows(ContainerLaunchException.class, container::start); + + assertEquals(1, containerIds.size()); + assertEquals(List.of(), existing(containerIds)); + assertFalse(container.isRunning()); + } }