From ca885f120ef8d7df8484b7a3fa462ef7e0b9ba52 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Mon, 19 Oct 2026 13:37:07 +0200 Subject: [PATCH 1/6] feat: Wait for the test container up to a configurable startup timeout, replace one that does not become reachable, and remove one whose start fails. start() now follows the start contract that all client SDKs share: withStartupTimeout(Duration) sets the time per container, 30 seconds by default, counted from creating the container, without pulling the image. The ping must answer 200, and the port is read anew for every ping. A container that runs but does not become reachable is replaced, up to 3 containers. A container that exits, or a Docker error, fails at once. Each attempt labels its container, and a failed attempt removes the containers with its label. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH --- README.md | 9 + .../testcontainers/Attempt.java | 85 ++++ .../testcontainers/Container.java | 337 ++++++++++--- .../testcontainers/AttemptTest.java | 146 ++++++ .../testcontainers/ContainerTest.java | 455 +++++++++++++----- 5 files changed, 853 insertions(+), 179 deletions(-) create mode 100644 eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java create mode 100644 eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java diff --git a/README.md b/README.md index a1bcf48..b392497 100644 --- a/README.md +++ b/README.md @@ -800,6 +800,8 @@ To check if the test container is running, call the `isRunning` method: var isRunning = container.isRunning(); ``` +`start` waits until the database answers a ping, for up to 30 seconds from creating the container. Pulling the image does not count. If the container runs, but the database does not become reachable in time, `start` replaces the container with a new one, up to 3 containers in total. If Docker fails, or if the container exits, `start` fails at once, and in the latter case names the last lines of the container's log. Whenever a container fails to start, `start` removes it. + To get a client with options of your own, e.g. the data mapper of your application, hand them to `getClient`: ```java @@ -834,6 +836,13 @@ var container = new Container() .withApiToken("secret"); ``` +To change how long `start` waits for the database in each container, call the `withStartupTimeout` method. The default is 30 seconds: + +```java +var container = new Container() + .withStartupTimeout(Duration.ofSeconds(60)); +``` + If you want to sign events, call the `withSigningKey` method. This generates a new signing and verification key pair, and hands the signing key to the container: ```java diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java new file mode 100644 index 0000000..0abd4e5 --- /dev/null +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java @@ -0,0 +1,85 @@ +package io.thenativeweb.eventsourcingdb.testcontainers; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.locks.LockSupport; +import java.util.function.IntPredicate; +import java.util.function.Supplier; +import org.jspecify.annotations.Nullable; +import org.testcontainers.containers.ContainerLaunchException; + +// Attempt is one attempt of Container.start() to start a container. Its clock +// for the startup timeout starts once it is created, which is right before +// Testcontainers asks Docker to create the container, so that pulling the +// image does not count. While Testcontainers starts the container, Container +// notes here what it learns about the container, so that an attempt that +// failed can tell whether to replace the container. +final class Attempt { + // How long the wait for the database pauses between two pings. + private static final Duration POLL_INTERVAL = Duration.ofMillis(100); + + // The id of the attempt, which the label of its container carries. + final String id = UUID.randomUUID().toString(); + + private final long deadline; + + // Whether the wait for the database began. Testcontainers begins it only + // once Docker published the port of the container. + boolean hasWaitBegun; + + // Whether the wait for the database ran out of time. + boolean hasRunOutOfTime; + + // Whether the container was running when Testcontainers gave up on it. + boolean wasRunning; + + // The last lines of the log of the container, if it exited. + @Nullable + String exitLog; + + Attempt(Duration startupTimeout) { + deadline = System.nanoTime() + startupTimeout.toNanos(); + } + + // Waits until the database answers a ping, until the container exits, or + // until the startup timeout runs out. It observes the container anew for + // every ping, including the port that Docker published for the database, + // as Docker sometimes publishes it late. A failure to observe the + // container is one of Docker, which ends the wait at once, and does not + // count as running out of time. + void waitUntilReachable(Supplier observe, IntPredicate answersPing) { + hasWaitBegun = true; + + while (true) { + var observation = observe.get(); + if (observation.hasExited()) { + throw new ContainerLaunchException("the container exited"); + } + if (System.nanoTime() - deadline >= 0) { + hasRunOutOfTime = true; + throw new ContainerLaunchException("the database did not become reachable in time"); + } + + var mappedPort = observation.mappedPort(); + if (mappedPort != null && answersPing.test(mappedPort)) { + return; + } + + LockSupport.parkNanos(POLL_INTERVAL.toNanos()); + } + } + + // Tells whether the container was running, but the database in it did + // not become reachable in time. That is the case if the wait for the + // database ran out of time, and also if the container was running when + // Testcontainers gave up waiting for Docker to publish its port, which it + // does after a fixed time of its own. + boolean isUnreachable() { + return hasRunOutOfTime || (wasRunning && !hasWaitBegun); + } + + // Observation is what a look at the container tells: whether it exited, + // and the port on the host that Docker published for the database, if it + // did yet. + record Observation(boolean hasExited, @Nullable Integer mappedPort) {} +} diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java index e9843d2..3913167 100644 --- a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java @@ -2,26 +2,38 @@ import static java.nio.charset.StandardCharsets.UTF_8; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.Ports; import io.thenativeweb.eventsourcingdb.Client; import io.thenativeweb.eventsourcingdb.ClientOptions; +import java.math.BigDecimal; import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.PublicKey; import java.time.Duration; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.function.Consumer; -import java.util.function.Function; +import java.util.stream.Stream; import org.jspecify.annotations.Nullable; +import org.testcontainers.containers.ContainerLaunchException; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.containers.wait.strategy.WaitStrategy; -import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; import org.testcontainers.images.builder.Transferable; import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.DockerStatus; /** * An EventSourcingDB instance in a Docker container, for integration tests. @@ -47,27 +59,41 @@ public final class Container extends GenericContainer { private static final String IMAGE_NAME = "thenativeweb/eventsourcingdb"; private static final String SIGNING_KEY_PATH = "/etc/esdb/signing-key.pem"; - // How many times start() starts the container at most, as it replaces each - // container whose database does not become reachable. - private static final int MAX_START_ATTEMPTS = 3; + // How many containers start() starts at most, as it replaces each + // container that runs, but in which the database does not become + // reachable within the startup timeout. + static final int MAX_START_ATTEMPTS = 3; + + // The label that start() gives the container of each attempt, with the id + // of the attempt as its value, so that the containers of an attempt that + // failed can be found and removed, even one that Testcontainers does not + // know of. + static final String START_LABEL = "io.eventsourcingdb.container-start"; + + // How many lines from the end of its log the exception names for a + // container that exited. + static final int LOG_TAIL_LINES = 20; + + // How long a ping of the database may take before it counts as + // unanswered. + private static final Duration PING_TIMEOUT = Duration.ofSeconds(1); private int internalPort = 3000; private String apiToken = "secret"; private @Nullable KeyPair signingKeyPair; - // Starts the container once. It is a field only so that tests can replace - // it. - Consumer starter = Container::startOnce; + // How long each attempt waits for the database to become reachable. It + // is not private only so that tests can read its default. + Duration startupTimeout = Duration.ofSeconds(30); - // Waits until the database answers pings on the given port inside the - // container. It is a field only so that tests can replace it. - Function pingWait = - port -> Wait.forHttp("/api/v1/ping").forPort(port).withStartupTimeout(Duration.ofSeconds(10)); + // The attempt that start() makes right now, if any. The methods that + // Testcontainers calls while it starts the container note in it what they + // learn about the container. + private @Nullable Attempt attempt; - // Whether the container was running when the wait for pings failed. Once - // the wait fails, Testcontainers stops the container before it reports - // the failure, so this can only be told while the wait fails. - private volatile boolean wasUnreachableWhileRunning; + // Removes the containers that carry the label of the attempt with the + // given id. It is a field only so that tests can replace it. + Consumer containerRemover = this::removeContainersWithLabel; /** * Creates a container that uses the {@code latest} tag of the official EventSourcingDB image, port 3000 inside the @@ -122,6 +148,19 @@ public Container withSigningKey() { return this; } + /** + * Sets how long {@link #start()} waits for the instance in a container to answer pings, from creating the + * container on. Pulling the image does not count. The default is 30 seconds. + * + * @param startupTimeout how long to wait for the instance in a container to answer pings + * @return this container + */ + @Override + public Container withStartupTimeout(Duration startupTimeout) { + this.startupTimeout = startupTimeout; + return this; + } + // Applies the configuration right before the container starts, so that the // with methods may be called in any order. @Override @@ -137,7 +176,6 @@ protected void configure() { String.valueOf(internalPort))); withExposedPorts(internalPort); - waitingFor(new NotingWait(pingWait.apply(internalPort))); if (signingKeyPair != null) { withCopyToContainer(Transferable.of(pem(signingKeyPair.getPrivate()), 0777), SIGNING_KEY_PATH); @@ -157,75 +195,236 @@ private static byte[] pem(PrivateKey privateKey) { } /** - * Starts the container, and waits until the instance answers pings. + * Starts the container, and waits until the instance answers pings, for the startup timeout at most. + * + *

Docker sometimes publishes the port of a container late, or does not make it reachable from the host at all. + * If the container runs, but the instance does not answer pings within the startup timeout, the container is + * replaced by a new one, up to three containers in total. If the container exits before the instance answers + * pings, or if Docker fails, the start fails at once. Either way, the containers of a start that failed are + * removed. * - *

Docker Desktop sometimes does not make the port it maps a container to reachable from the host. If the - * database in a running container does not become reachable, the container is replaced by a new one, up to three - * times. A container that fails to start is removed either way. + *

An interrupt does not cut the start short. The interrupt status stays set for the caller. * - * @throws IllegalStateException if the database did not become reachable in any of three containers - * @throws RuntimeException if the container does not start for another reason, e.g. because the image does not - * exist + * @throws IllegalStateException if the instance did not answer pings in any of three containers + * @throws ContainerLaunchException if the container exited before the instance answered pings, or if Docker + * failed, e.g. because the image does not exist + * @see #withStartupTimeout(Duration) */ @Override public void start() { - for (var attempt = 1; ; attempt++) { - wasUnreachableWhileRunning = false; - try { - starter.accept(this); - return; - } catch (RuntimeException ex) { - // A container that was running when its wait for pings failed - // is one whose database can not 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. - stop(); - - if (!wasUnreachableWhileRunning) { - 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); - } + // The start runs on a thread of its own, which the caller can not + // interrupt: an interrupt would cut a call to Docker short, and Docker + // might still create a container for a call that was cut short, after + // the start looked for the containers to remove. join() waits for the + // thread even if the caller is interrupted, and keeps the interrupt + // status for the caller. + CompletableFuture.supplyAsync(this::startAttempts, Container::runOnThreadOfItsOwn) + .join() + .ifPresent(failure -> { + throw failure; + }); + } + + private static void runOnThreadOfItsOwn(Runnable task) { + Thread.ofPlatform().daemon().name("eventsourcingdb-container-start").start(task); + } + + // Starts containers until the database in one of them becomes reachable, + // and returns the failure otherwise. It pulls the image before the first + // attempt, so that pulling it does not count toward the startup timeout. + // Testcontainers keeps the image it resolved, so the attempts do not pull + // it again. + private Optional startAttempts() { + try { + getDockerImageName(); + } catch (RuntimeException ex) { + return Optional.of(dockerFailed(ex)); + } + + var removalFailures = new ArrayList(); + for (var attemptNumber = 1; ; attemptNumber++) { + var currentAttempt = new Attempt(startupTimeout); + var failure = startContainerOf(currentAttempt); + if (failure.isEmpty()) { + return Optional.empty(); + } + + removeContainersOf(currentAttempt).ifPresent(removalFailures::add); + + var exitLog = currentAttempt.exitLog; + if (exitLog != null) { + return failed(exited(exitLog, failure.get()), removalFailures); + } + if (!currentAttempt.isUnreachable()) { + return failed(dockerFailed(failure.get()), removalFailures); + } + if (attemptNumber == MAX_START_ATTEMPTS) { + return failed(unreachable(failure.get()), removalFailures); } } } - void startOnce() { - super.start(); + // Starts the container of the given attempt with Testcontainers, and + // returns the failure if that fails. + private Optional startContainerOf(Attempt currentAttempt) { + attempt = currentAttempt; + withLabel(START_LABEL, currentAttempt.id); + + try { + super.start(); + return Optional.empty(); + } catch (RuntimeException ex) { + return Optional.of(ex); + } finally { + attempt = null; + } } - // NotingWait waits as the given wait does, and notes whether the - // container was still running if that wait fails. - final class NotingWait implements WaitStrategy { - private final WaitStrategy wait; + // Testcontainers calls this once Docker published the port of the + // container, to wait until the container is ready. + @Override + protected void waitUntilContainerStarted() { + try (var httpClient = + HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build()) { + Objects.requireNonNull(attempt) + .waitUntilReachable( + this::observe, + port -> answersPing( + httpClient, URI.create("http://" + super.getHost() + ":" + port + "/api/v1/ping"))); + } + } + + // Looks at the container as Docker sees it right now, rather than as + // Testcontainers saw it when it started the container. + private Attempt.Observation observe() { + var containerInfo = getCurrentContainerInfo(); + var bindings = + containerInfo.getNetworkSettings().getPorts().getBindings().get(ExposedPort.tcp(internalPort)); + + return new Attempt.Observation( + DockerStatus.isContainerStopped(containerInfo.getState()), + Stream.ofNullable(bindings) + .flatMap(Arrays::stream) + .filter(Objects::nonNull) + .map(Ports.Binding::getHostPortSpec) + .map(Integer::valueOf) + .findFirst() + .orElse(null)); + } - NotingWait(WaitStrategy wait) { - this.wait = wait; + // Tells whether the database answers a ping with 200 OK. Any other + // answer, and no answer at all, mean that it is not reachable yet. + static boolean answersPing(HttpClient httpClient, URI pingUrl) { + var request = HttpRequest.newBuilder(pingUrl).timeout(PING_TIMEOUT).build(); + + try { + return httpClient + .sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .join() + .statusCode() + == 200; + } catch (CompletionException ex) { + return false; } + } - @Override - public void waitUntilReady(WaitStrategyTarget target) { - try { - wait.waitUntilReady(target); - } catch (RuntimeException ex) { - wasUnreachableWhileRunning = target.isRunning(); - throw ex; - } + // Testcontainers calls this before it removes a container, also one whose + // start failed, before it reports the failure. Only until then can it be + // told whether the container was running or exited, and what it logged. + @Override + protected void containerIsStopping(InspectContainerResponse containerInfo) { + var currentAttempt = attempt; + if (currentAttempt == null) { + return; } - @Override - public WaitStrategy withStartupTimeout(Duration startupTimeout) { - wait.withStartupTimeout(startupTimeout); - return this; + var state = getCurrentContainerInfo().getState(); + currentAttempt.wasRunning = Boolean.TRUE.equals(state.getRunning()); + if (DockerStatus.isContainerStopped(state)) { + currentAttempt.exitLog = lastLines(getLogs()); } } + // Removes the containers of a failed attempt, and returns the failure if + // that fails, so that it can be reported along with the failure of the + // start, without changing its message. + private Optional removeContainersOf(Attempt failedAttempt) { + // Testcontainers removes a container whose start failed itself, but + // stopping it releases whatever Testcontainers may still hold for it. + stop(); + + try { + containerRemover.accept(failedAttempt.id); + return Optional.empty(); + } catch (RuntimeException ex) { + return Optional.of(ex); + } + } + + private void removeContainersWithLabel(String attemptId) { + var dockerClient = getDockerClient(); + + dockerClient + .listContainersCmd() + .withShowAll(true) + .withLabelFilter(Map.of(START_LABEL, attemptId)) + .exec() + .forEach(container -> dockerClient + .removeContainerCmd(container.getId()) + .withForce(true) + .withRemoveVolumes(true) + .exec()); + } + + private static Optional failed(RuntimeException failure, List removalFailures) { + removalFailures.forEach(failure::addSuppressed); + return Optional.of(failure); + } + + private IllegalStateException unreachable(RuntimeException cause) { + return new IllegalStateException( + "failed to start the container, the database did not become reachable within " + + seconds(startupTimeout) + + "s in any of " + + MAX_START_ATTEMPTS + + " containers", + cause); + } + + private static ContainerLaunchException exited(String exitLog, RuntimeException cause) { + return new ContainerLaunchException( + "failed to start the container, it exited before the database became reachable: " + exitLog, cause); + } + + // Names the error of Docker, which Testcontainers wraps in exceptions of + // its own, as the innermost cause. Docker ends its errors with a line + // break, which the message leaves out. + private static ContainerLaunchException dockerFailed(RuntimeException failure) { + Throwable dockerError = failure; + for (var cause = failure.getCause(); cause != null; cause = cause.getCause()) { + dockerError = cause; + } + + return new ContainerLaunchException( + "failed to start the container: " + + Objects.toString(dockerError.getMessage()).stripTrailing(), + failure); + } + + // Describes a duration in seconds, as a whole number if it is one, and + // with as few decimals as needed otherwise, e.g. 1.5. + static String seconds(Duration duration) { + return BigDecimal.valueOf(duration.getSeconds()) + .add(BigDecimal.valueOf(duration.getNano(), 9)) + .stripTrailingZeros() + .toPlainString(); + } + + static String lastLines(String log) { + var lines = log.stripTrailing().lines().toList(); + return String.join("\n", lines.subList(Math.max(0, lines.size() - LOG_TAIL_LINES), lines.size())); + } + /** * {@return the host name to reach the container at} * diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java new file mode 100644 index 0000000..a7ca08e --- /dev/null +++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java @@ -0,0 +1,146 @@ +package io.thenativeweb.eventsourcingdb.testcontainers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ContainerLaunchException; + +class AttemptTest { + private static final Duration ENOUGH_TIME = Duration.ofMinutes(1); + + // Docker publishes the port only for the second observation, and the + // database answers the ping only on the port of the third, as if Docker + // had published the port anew. + @Test + void readsThePortAnewForEveryPing() { + var attempt = new Attempt(ENOUGH_TIME); + var observations = new ArrayList<>(List.of( + new Attempt.Observation(false, null), + new Attempt.Observation(false, 1001), + new Attempt.Observation(false, 1002))); + var pingedPorts = new ArrayList(); + + attempt.waitUntilReachable(observations::removeFirst, port -> { + pingedPorts.add(port); + return port == 1002; + }); + + assertEquals(List.of(1001, 1002), pingedPorts); + assertTrue(attempt.hasWaitBegun); + assertFalse(attempt.hasRunOutOfTime); + } + + @Test + void endsTheWaitIfTheContainerExits() { + var attempt = new Attempt(ENOUGH_TIME); + + assertThrows( + ContainerLaunchException.class, + () -> attempt.waitUntilReachable(() -> new Attempt.Observation(true, 1001), port -> true)); + + assertFalse(attempt.hasRunOutOfTime); + } + + @Test + void endsTheWaitOnceTheStartupTimeoutRanOut() { + var attempt = new Attempt(Duration.ofMillis(300)); + var pingCount = new int[] {0}; + + var startedAt = System.nanoTime(); + assertThrows( + ContainerLaunchException.class, + () -> attempt.waitUntilReachable(() -> new Attempt.Observation(false, 1001), port -> { + pingCount[0]++; + return false; + })); + + assertTrue(attempt.hasRunOutOfTime); + assertTrue(Duration.ofNanos(System.nanoTime() - startedAt).compareTo(Duration.ofMillis(300)) >= 0); + assertNotEquals(0, pingCount[0]); + } + + // The clock starts once the attempt is created, so time that passes + // before the wait begins, e.g. while Docker creates the container, + // counts. + @Test + void countsTheTimeBeforeTheWaitBegins() { + var attempt = new Attempt(Duration.ZERO); + + assertThrows( + ContainerLaunchException.class, + () -> attempt.waitUntilReachable(() -> new Attempt.Observation(false, 1001), port -> true)); + + assertTrue(attempt.hasRunOutOfTime); + } + + // A failure to look at the container is one of Docker. It ends the wait + // at once, and does not count as running out of time. + @Test + void endsTheWaitAtOnceIfDockerFails() { + var attempt = new Attempt(ENOUGH_TIME); + var dockerFailure = new IllegalStateException("Docker failed"); + + var exception = assertThrows( + IllegalStateException.class, + () -> attempt.waitUntilReachable( + () -> { + throw dockerFailure; + }, + port -> true)); + + assertSame(dockerFailure, exception); + assertFalse(attempt.hasRunOutOfTime); + assertFalse(attempt.isUnreachable()); + } + + @Test + void isUnreachableIfTheWaitRanOutOfTime() { + var attempt = new Attempt(ENOUGH_TIME); + attempt.hasWaitBegun = true; + attempt.wasRunning = true; + attempt.hasRunOutOfTime = true; + + assertTrue(attempt.isUnreachable()); + } + + // Testcontainers gives up waiting for Docker to publish the port of the + // container after a fixed time of its own, before the wait begins. + @Test + void isUnreachableIfTheContainerWasRunningWhenTestcontainersGaveUpBeforeTheWait() { + var attempt = new Attempt(ENOUGH_TIME); + attempt.wasRunning = true; + + assertTrue(attempt.isUnreachable()); + } + + @Test + void isNotUnreachableIfTheContainerWasNotRunning() { + var attempt = new Attempt(ENOUGH_TIME); + + assertFalse(attempt.isUnreachable()); + } + + // The wait for the database ended before it ran out of time, e.g. + // because Docker failed while the container was running. + @Test + void isNotUnreachableIfTheWaitEndedForAnotherReason() { + var attempt = new Attempt(ENOUGH_TIME); + attempt.hasWaitBegun = true; + attempt.wasRunning = true; + + assertFalse(attempt.isUnreachable()); + } + + @Test + void hasAnIdOfItsOwn() { + assertNotEquals(new Attempt(ENOUGH_TIME).id, new Attempt(ENOUGH_TIME).id); + } +} diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java index 5d1e8c5..ce3835d 100644 --- a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java +++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java @@ -1,29 +1,46 @@ package io.thenativeweb.eventsourcingdb.testcontainers; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.sun.net.httpserver.HttpServer; import io.thenativeweb.eventsourcingdb.ClientOptions; import io.thenativeweb.eventsourcingdb.EventCandidate; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; import java.net.URI; +import java.net.http.HttpClient; import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.locks.LockSupport; import org.junit.jupiter.api.Test; import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.ContainerFetchException; import org.testcontainers.containers.ContainerLaunchException; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.WaitStrategy; -import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; +import org.testcontainers.containers.output.ToStringConsumer; +import org.testcontainers.containers.output.WaitingConsumer; import tools.jackson.databind.PropertyNamingStrategies; import tools.jackson.databind.json.JsonMapper; class ContainerTest { + private static final String EXITED_PREFIX = + "failed to start the container, it exited before the database became reachable: "; + @Test void startsHandsOutAClientAndStops() { var container = new Container().withImageTag(ImageTag.get()); @@ -118,14 +135,6 @@ void handsOutAClientWithTheGivenOptions() { } } - @Test - void failsToStartWithAnImageThatDoesNotExist() { - var container = new Container().withImageTag("does-not-exist"); - - assertThrows(RuntimeException.class, container::start); - assertFalse(container.isRunning()); - } - @Test void reportsWhatItDoesNotHave() { var container = new Container(); @@ -155,144 +164,370 @@ 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 waitsThirtySecondsForEachContainerByDefault() { + assertEquals(Duration.ofSeconds(30), new Container().startupTimeout); } - // A wait for pings that fails the given number of times while the - // container runs, as if its port were not reachable from the host, and - // then waits as the container does. It notes the containers it waited - // for, and the failures it threw. - private static final class UnreachableWait implements WaitStrategy { - private final WaitStrategy ping; - private final int failureCount; - final List containerIds = new ArrayList<>(); - final List failures = new ArrayList<>(); - - UnreachableWait(WaitStrategy ping, int failureCount) { - this.ping = ping; - this.failureCount = failureCount; - } - - @Override - public void waitUntilReady(WaitStrategyTarget target) { - containerIds.add(target.getContainerId()); - if (containerIds.size() > failureCount) { - ping.waitUntilReady(target); - return; + // The database listens on another port than the one that Docker + // publishes, so it runs, but can not be reached, as if Docker did not + // make the published port reachable from the host. + @Test + void replacesAContainerInWhichTheDatabaseDoesNotBecomeReachable() { + var container = new Container().withImageTag(ImageTag.get()).withStartupTimeout(Duration.ofSeconds(5)); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> { + if (labels.size() == 1) { + listenOnAnotherPort(command); } + }); + + container.start(); + try { + assertEquals(2, labels.size()); + assertNotEquals(labels.get(0), labels.get(1)); + assertEquals(List.of(), existing(labels.subList(0, 1))); + assertEquals(1, existing(labels.subList(1, 2)).size()); - assertTrue(target.isRunning()); - var failure = new ContainerLaunchException("the database did not become reachable"); - failures.add(failure); - throw failure; + container.getClient().ping(); + } finally { + container.stop(); } - @Override - public WaitStrategy withStartupTimeout(Duration startupTimeout) { - return this; + assertEquals(List.of(), existing(labels)); + } + + @Test + void givesUpAfterThreeContainersInWhichTheDatabaseDoesNotBecomeReachable() { + var container = new Container().withImageTag(ImageTag.get()).withStartupTimeout(Duration.ofMillis(1500)); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(ContainerTest::listenOnAnotherPort); + + var startedAt = System.nanoTime(); + var exception = assertThrows(IllegalStateException.class, container::start); + var elapsed = Duration.ofNanos(System.nanoTime() - startedAt); + + assertEquals( + "failed to start the container, the database did not become reachable within 1.5s in any of 3" + + " containers", + exception.getMessage()); + assertEquals(3, labels.size()); + assertEquals(List.of(), existing(labels)); + assertFalse(container.isRunning()); + + // Each container gets the startup timeout that was set, rather than + // the default, but not more than that by far. + assertTrue(elapsed.compareTo(Duration.ofMillis(3 * 1500)) >= 0, elapsed::toString); + assertTrue(elapsed.compareTo(Duration.ofSeconds(30)) < 0, elapsed::toString); + } + + // A create that takes longer than the startup timeout, as the first one + // does here, leaves no time to wait for the database, as the time counts + // from creating the container on. The container that Docker creates after + // the time ran out is replaced and removed all the same. + @Test + void countsCreatingTheContainerTowardTheStartupTimeout() { + var container = new Container().withImageTag(ImageTag.get()).withStartupTimeout(Duration.ofSeconds(5)); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> { + if (labels.size() == 1) { + pause(Duration.ofMillis(5500)); + } + }); + + container.start(); + try { + assertEquals(2, labels.size()); + assertEquals(List.of(), existing(labels.subList(0, 1))); + container.getClient().ping(); + } finally { + container.stop(); } } - // Docker Desktop sometimes does not make the port of a container - // reachable from the host. The container keeps running, but its wait - // fails, as it does here twice. Testcontainers stops a container whose - // wait fails before it reports the failure. + // Pulling the image takes longer than the startup timeout here, which + // must not count. @Test - void replacesAContainerWhoseDatabaseDoesNotBecomeReachable() { - var container = new Container().withImageTag(ImageTag.get()); - var wait = new AtomicReference(); - container.pingWait = port -> { - var unreachableWait = new UnreachableWait(new Container().pingWait.apply(port), 2); - wait.compareAndSet(null, unreachableWait); - return wait.get(); - }; + void doesNotCountPullingTheImageTowardTheStartupTimeout() { + var container = new Container().withStartupTimeout(Duration.ofSeconds(5)); + container.setImage(CompletableFuture.supplyAsync( + () -> "thenativeweb/eventsourcingdb:" + ImageTag.get(), + CompletableFuture.delayedExecutor(6, TimeUnit.SECONDS))); + var labels = labelsOf(container); container.start(); try { - var containerIds = wait.get().containerIds; - assertEquals(3, containerIds.size()); - assertEquals(List.of(containerIds.get(2)), existing(containerIds)); + assertEquals(1, labels.size()); container.getClient().ping(); } finally { container.stop(); } } + // EventSourcingDB writes its error and its usage to different streams, + // so where the error ends up in the log varies. The test compares with + // the whole log, which it follows while the container runs. + @Test + void failsAtOnceIfTheContainerExits() throws TimeoutException { + var log = new ToStringConsumer(); + var logEnd = new WaitingConsumer(); + var container = new Container().withImageTag(ImageTag.get()).withLogConsumer(log.andThen(logEnd)); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(ContainerTest::passAnInvalidFlag); + + var exception = assertThrows(ContainerLaunchException.class, container::start); + + logEnd.waitUntilEnd(30, TimeUnit.SECONDS); + var lines = log.toUtf8String().stripTrailing().lines().toList(); + assertTrue(lines.size() > Container.LOG_TAIL_LINES, log::toUtf8String); + assertEquals( + EXITED_PREFIX + String.join("\n", lines.subList(lines.size() - Container.LOG_TAIL_LINES, lines.size())), + exception.getMessage()); + + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); + assertFalse(container.isRunning()); + } + + // Docker rejects the name of the container when it is asked to create + // it, without asking a registry. @Test - void givesUpAfterThreeAttemptsAndSaysWhy() { + void failsAtOnceIfDockerFails() { var container = new Container().withImageTag(ImageTag.get()); - var wait = new UnreachableWait(new Container().pingWait.apply(3000), 3); - container.pingWait = port -> wait; + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> command.withName("not a valid name")); - var exception = assertThrows(IllegalStateException.class, container::start); + var exception = assertThrows(ContainerLaunchException.class, container::start); + + assertEquals( + "failed to start the container: " + + innermostCauseOf(exception).getMessage().stripTrailing(), + exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid container name"), exception::getMessage); + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); + assertFalse(container.isRunning()); + } + + @Test + void failsAtOnceIfTheImageDoesNotExist() { + var container = new Container().withImageTag("does-not-exist"); + var labels = labelsOf(container); + + var exception = assertThrows(ContainerLaunchException.class, container::start); assertEquals( - "failed to start container, the database did not become reachable in 3 attempts", + "failed to start the container: " + + innermostCauseOf(exception).getMessage().stripTrailing(), exception.getMessage()); - assertTrue(causesOf(exception).contains(wait.failures.getLast())); - assertEquals(3, wait.containerIds.size()); - assertEquals(List.of(), existing(wait.containerIds)); + assertInstanceOf(ContainerFetchException.class, exception.getCause()); + assertEquals(List.of(), labels); assertFalse(container.isRunning()); } - private static List causesOf(Throwable exception) { - var causes = new ArrayList(); - for (var cause = exception.getCause(); cause != null; cause = cause.getCause()) { - causes.add(cause); + // A container that carries the label of an attempt, but that + // Testcontainers does not know of, e.g. one that Docker created for a call + // that failed, is removed as well. + @Test + void removesTheContainersOfAFailedAttemptThatTestcontainersDoesNotKnowOf() { + var dockerClient = DockerClientFactory.instance().client(); + var container = new Container().withImageTag(ImageTag.get()); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> dockerClient + .createContainerCmd(command.getImage()) + .withLabels(Map.of(Container.START_LABEL, command.getLabels().get(Container.START_LABEL))) + .exec()); + container.withCreateContainerCmdModifier(ContainerTest::passAnInvalidFlag); + + try { + assertThrows(ContainerLaunchException.class, container::start); + + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); + } finally { + remove(labels); } - return causes; } - // A startup timeout that is set on the container applies to its wait for - // pings. @Test - void passesAStartupTimeoutOnToTheWaitForPings() { - var timeouts = new ArrayList(); - var container = new Container(); - var wait = container.new NotingWait(new WaitStrategy() { - @Override - public void waitUntilReady(WaitStrategyTarget target) {} - - @Override - public WaitStrategy withStartupTimeout(Duration startupTimeout) { - timeouts.add(startupTimeout); - return this; - } + void reportsAFailedRemovalWithoutChangingTheMessage() { + var removalFailure = new IllegalStateException("removal failed"); + var container = new Container().withImageTag(ImageTag.get()); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(ContainerTest::passAnInvalidFlag); + container.containerRemover = attemptId -> { + throw removalFailure; + }; + + var exception = assertThrows(ContainerLaunchException.class, container::start); + + assertTrue(exception.getMessage().startsWith(EXITED_PREFIX), exception::getMessage); + assertFalse(exception.getMessage().contains("removal failed"), exception::getMessage); + assertArrayEquals(new Throwable[] {removalFailure}, exception.getSuppressed()); + + // Testcontainers removed the container it started on its own. + assertEquals(List.of(), existing(labels)); + } + + // An interrupt while Docker creates the container would cut that call + // short, so the start lets it finish, and keeps the interrupt for the + // caller. + @Test + void keepsAnInterruptForTheCallerWithoutCuttingTheStartShort() { + var caller = Thread.currentThread(); + var container = new Container().withImageTag(ImageTag.get()); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> { + caller.interrupt(); + pause(Duration.ofMillis(200)); }); - assertEquals(wait, wait.withStartupTimeout(Duration.ofSeconds(30))); - assertEquals(List.of(Duration.ofSeconds(30)), timeouts); + boolean wasInterrupted; + try { + container.start(); + } finally { + wasInterrupted = Thread.interrupted(); + } + + try { + assertTrue(wasInterrupted); + assertEquals(1, labels.size()); + container.getClient().ping(); + } finally { + container.stop(); + } + + assertEquals(List.of(), existing(labels)); + } + + @Test + void takesOnly200OkAsAnAnswerToAPing() throws IOException { + assertTrue(answersPingWith(200)); + assertFalse(answersPingWith(204)); + assertFalse(answersPingWith(503)); + assertFalse(answersPingWithoutServer()); + } + + @Test + void describesDurationsInSeconds() { + assertEquals("30", Container.seconds(Duration.ofSeconds(30))); + assertEquals("1.5", Container.seconds(Duration.ofMillis(1500))); + assertEquals("0.25", Container.seconds(Duration.ofMillis(250))); + assertEquals("0", Container.seconds(Duration.ZERO)); } @Test - void throwsAnyOtherFailureAtOnceAndRemovesTheContainer() { - var containerIds = new ArrayList(); - - // Without an API token, EventSourcingDB exits right away. - var container = new Container().withImageTag(ImageTag.get()).withApiToken(""); - container.starter = self -> { - try { - self.startOnce(); - } finally { - containerIds.add(self.getContainerId()); + void takesTheLastLinesOfALog() { + var lines = new ArrayList(); + for (var number = 1; number <= Container.LOG_TAIL_LINES + 5; number++) { + lines.add("line " + number); + } + + assertEquals( + String.join("\n", lines.subList(5, lines.size())), + Container.lastLines(String.join("\n", lines) + "\n\n\n")); + assertEquals("line 1\nline 2", Container.lastLines("line 1\r\nline 2\n")); + assertEquals("", Container.lastLines("")); + } + + // Notes the label of each attempt as Testcontainers asks Docker to create + // its container. + private static List labelsOf(Container container) { + var labels = new CopyOnWriteArrayList(); + container.withCreateContainerCmdModifier( + command -> labels.add(command.getLabels().get(Container.START_LABEL))); + return labels; + } + + private static void listenOnAnotherPort(CreateContainerCmd command) { + var arguments = command.getCmd().clone(); + for (var index = 0; index < arguments.length - 1; index++) { + if (arguments[index].equals("--http-port")) { + arguments[index + 1] = String.valueOf(Integer.parseInt(arguments[index + 1]) + 1); } - }; + } + command.withCmd(arguments); + } + + private static void passAnInvalidFlag(CreateContainerCmd command) { + var arguments = new ArrayList<>(List.of(command.getCmd())); + arguments.add("--no-such-flag"); + command.withCmd(arguments); + } - assertThrows(ContainerLaunchException.class, container::start); + private static void pause(Duration duration) { + var deadline = System.nanoTime() + duration.toNanos(); + for (var remaining = duration.toNanos(); remaining > 0; remaining = deadline - System.nanoTime()) { + LockSupport.parkNanos(remaining); + } + } - assertEquals(1, containerIds.size()); - assertEquals(List.of(), existing(containerIds)); - assertFalse(container.isRunning()); + // Lists the ids of the containers that carry the label of one of the + // given attempts, running or not. It matches the exact id of each + // attempt, since the tests of other SDKs use the same label on the same + // Docker. + private static List existing(List labels) { + var dockerClient = DockerClientFactory.instance().client(); + + return labels.stream() + .flatMap(label -> dockerClient + .listContainersCmd() + .withShowAll(true) + .withLabelFilter(Map.of(Container.START_LABEL, label)) + .exec() + .stream()) + .map(container -> container.getId()) + .toList(); + } + + private static void remove(List labels) { + var dockerClient = DockerClientFactory.instance().client(); + for (var containerId : existing(labels)) { + dockerClient + .removeContainerCmd(containerId) + .withForce(true) + .withRemoveVolumes(true) + .exec(); + } + } + + private static Throwable innermostCauseOf(Throwable exception) { + var innermostCause = exception; + while (innermostCause.getCause() != null) { + innermostCause = innermostCause.getCause(); + } + return innermostCause; + } + + // Pings a server that answers with the given status code, on the same + // path as the database. + private static boolean answersPingWith(int statusCode) throws IOException { + var server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/api/v1/ping", exchange -> { + exchange.sendResponseHeaders(statusCode, -1); + exchange.close(); + }); + server.start(); + + try (var httpClient = HttpClient.newHttpClient()) { + return Container.answersPing( + httpClient, + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/api/v1/ping")); + } finally { + server.stop(0); + } + } + + // Pings a port that nothing listens on, as a port that was free a moment + // ago is. + private static boolean answersPingWithoutServer() throws IOException { + int port; + try (var socket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress())) { + port = socket.getLocalPort(); + } + + try (var httpClient = HttpClient.newHttpClient()) { + return Container.answersPing(httpClient, URI.create("http://127.0.0.1:" + port + "/api/v1/ping")); + } } } From a2d6dadc6c8255abb5b32acbbfb49fbf8f86ea57 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Mon, 19 Oct 2026 14:20:17 +0200 Subject: [PATCH 2/6] feat: Give up starting the test container once the caller is interrupted, and reject a startup timeout that is not positive. An interrupt now cancels the start: start() waits for it to give up for a grace period of five seconds at most, keeps the interrupt status, and throws an IllegalStateException with the message "failed to start the container: interrupted". What the start has not removed by then, e.g. a container that Docker is still creating, it removes in the background. withStartupTimeout rejects zero and negative durations with an IllegalArgumentException, like the stall timeout of the client. The Javadoc of start() keeps the exceptions it named in 2.0, and says that a container that exits before Docker publishes its port fails only after the fixed wait of Testcontainers for the port. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH --- .../testcontainers/Attempt.java | 24 ++-- .../testcontainers/Container.java | 91 +++++++++++---- .../testcontainers/AttemptTest.java | 42 +++++-- .../testcontainers/ContainerTest.java | 109 +++++++++++++++--- 4 files changed, 212 insertions(+), 54 deletions(-) diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java index 0abd4e5..87fc08a 100644 --- a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java @@ -3,6 +3,7 @@ import java.time.Duration; import java.util.UUID; import java.util.concurrent.locks.LockSupport; +import java.util.function.BooleanSupplier; import java.util.function.IntPredicate; import java.util.function.Supplier; import org.jspecify.annotations.Nullable; @@ -23,6 +24,10 @@ final class Attempt { private final long deadline; + // Tells whether the start was canceled, e.g. because the caller was + // interrupted. + private final BooleanSupplier isCanceled; + // Whether the wait for the database began. Testcontainers begins it only // once Docker published the port of the container. boolean hasWaitBegun; @@ -37,20 +42,25 @@ final class Attempt { @Nullable String exitLog; - Attempt(Duration startupTimeout) { + Attempt(Duration startupTimeout, BooleanSupplier isCanceled) { deadline = System.nanoTime() + startupTimeout.toNanos(); + this.isCanceled = isCanceled; } - // Waits until the database answers a ping, until the container exits, or - // until the startup timeout runs out. It observes the container anew for - // every ping, including the port that Docker published for the database, - // as Docker sometimes publishes it late. A failure to observe the - // container is one of Docker, which ends the wait at once, and does not - // count as running out of time. + // Waits until the database answers a ping, until the container exits, + // until the startup timeout runs out, or until the start is canceled. It + // observes the container anew for every ping, including the port that + // Docker published for the database, as Docker sometimes publishes it + // late. A failure to observe the container is one of Docker, which ends + // the wait at once, and does not count as running out of time. void waitUntilReachable(Supplier observe, IntPredicate answersPing) { hasWaitBegun = true; while (true) { + if (isCanceled.getAsBoolean()) { + throw new ContainerLaunchException("the start was canceled"); + } + var observation = observe.get(); if (observation.hasExited()) { throw new ContainerLaunchException("the container exited"); diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java index 3913167..ee6606d 100644 --- a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java @@ -26,6 +26,9 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; @@ -78,6 +81,11 @@ public final class Container extends GenericContainer { // unanswered. private static final Duration PING_TIMEOUT = Duration.ofSeconds(1); + // How long start() waits at most for the start to give up once the caller + // was interrupted, before it returns, and leaves the rest to the + // background, e.g. removing a container that Docker is still creating. + static final Duration INTERRUPT_GRACE_PERIOD = Duration.ofSeconds(5); + private int internalPort = 3000; private String apiToken = "secret"; private @Nullable KeyPair signingKeyPair; @@ -154,9 +162,15 @@ public Container withSigningKey() { * * @param startupTimeout how long to wait for the instance in a container to answer pings * @return this container + * @throws IllegalArgumentException if the startup timeout is zero or negative */ @Override public Container withStartupTimeout(Duration startupTimeout) { + // A startup timeout of zero or less would fail every attempt at once. + if (startupTimeout.isZero() || startupTimeout.isNegative()) { + throw new IllegalArgumentException("startup timeout must be positive"); + } + this.startupTimeout = startupTimeout; return this; } @@ -200,29 +214,49 @@ private static byte[] pem(PrivateKey privateKey) { *

Docker sometimes publishes the port of a container late, or does not make it reachable from the host at all. * If the container runs, but the instance does not answer pings within the startup timeout, the container is * replaced by a new one, up to three containers in total. If the container exits before the instance answers - * pings, or if Docker fails, the start fails at once. Either way, the containers of a start that failed are - * removed. + * pings, or if Docker fails, the start fails at once. A container that exits before Docker publishes its port + * fails only once Testcontainers stops waiting for the port, which takes about five seconds. Either way, the + * containers of a start that failed are removed. * - *

An interrupt does not cut the start short. The interrupt status stays set for the caller. + *

If the calling thread is interrupted before the instance answers pings, the start gives up within five + * seconds, and throws an {@link IllegalStateException}, and the interrupt status stays set. Containers that the + * start has not removed by then, e.g. because Docker is still creating one, it removes in the background. * - * @throws IllegalStateException if the instance did not answer pings in any of three containers - * @throws ContainerLaunchException if the container exited before the instance answered pings, or if Docker - * failed, e.g. because the image does not exist + * @throws IllegalStateException if the database did not become reachable in any of three containers + * @throws RuntimeException if the container does not start for another reason, e.g. because the image does not + * exist * @see #withStartupTimeout(Duration) */ @Override public void start() { - // The start runs on a thread of its own, which the caller can not - // interrupt: an interrupt would cut a call to Docker short, and Docker - // might still create a container for a call that was cut short, after - // the start looked for the containers to remove. join() waits for the - // thread even if the caller is interrupted, and keeps the interrupt - // status for the caller. - CompletableFuture.supplyAsync(this::startAttempts, Container::runOnThreadOfItsOwn) - .join() - .ifPresent(failure -> { - throw failure; - }); + // The start runs on a thread of its own, so that an interrupt of the + // caller does not cut a call to Docker short: Docker might still create + // a container for a call that was cut short, after the start looked + // for the containers to remove. Instead, the interrupt cancels the + // start, and the caller waits for it to give up, which it finishes in + // the background if that takes longer than the grace period. + var interruption = new AtomicReference<@Nullable InterruptedException>(); + var started = CompletableFuture.supplyAsync(() -> startAttempts(interruption), Container::runOnThreadOfItsOwn); + + Optional failure; + try { + failure = started.get(); + } catch (InterruptedException ex) { + interruption.set(ex); + failure = started.completeOnTimeout( + Optional.of(interrupted(ex)), INTERRUPT_GRACE_PERIOD.toNanos(), TimeUnit.NANOSECONDS) + .join(); + Thread.currentThread().interrupt(); + } catch (ExecutionException ex) { + // The start returns every failure it handles, so only an error + // that it does not handle ends up here, which is passed on as + // CompletableFuture.join() would pass it on. + throw new CompletionException(ex.getCause()); + } + + failure.ifPresent(startFailure -> { + throw startFailure; + }); } private static void runOnThreadOfItsOwn(Runnable task) { @@ -230,11 +264,11 @@ private static void runOnThreadOfItsOwn(Runnable task) { } // Starts containers until the database in one of them becomes reachable, - // and returns the failure otherwise. It pulls the image before the first - // attempt, so that pulling it does not count toward the startup timeout. - // Testcontainers keeps the image it resolved, so the attempts do not pull - // it again. - private Optional startAttempts() { + // and returns the failure otherwise, or once the given interruption of + // the caller is set. It pulls the image before the first attempt, so that + // pulling it does not count toward the startup timeout. Testcontainers + // keeps the image it resolved, so the attempts do not pull it again. + private Optional startAttempts(AtomicReference<@Nullable InterruptedException> interruption) { try { getDockerImageName(); } catch (RuntimeException ex) { @@ -243,7 +277,7 @@ private Optional startAttempts() { var removalFailures = new ArrayList(); for (var attemptNumber = 1; ; attemptNumber++) { - var currentAttempt = new Attempt(startupTimeout); + var currentAttempt = new Attempt(startupTimeout, () -> interruption.get() != null); var failure = startContainerOf(currentAttempt); if (failure.isEmpty()) { return Optional.empty(); @@ -251,6 +285,13 @@ private Optional startAttempts() { removeContainersOf(currentAttempt).ifPresent(removalFailures::add); + // Once the caller was interrupted, the start gives up, whatever + // else made the attempt fail. + var interruptionOfCaller = interruption.get(); + if (interruptionOfCaller != null) { + return failed(interrupted(interruptionOfCaller), removalFailures); + } + var exitLog = currentAttempt.exitLog; if (exitLog != null) { return failed(exited(exitLog, failure.get()), removalFailures); @@ -391,6 +432,10 @@ private IllegalStateException unreachable(RuntimeException cause) { cause); } + private static IllegalStateException interrupted(InterruptedException cause) { + return new IllegalStateException("failed to start the container: interrupted", cause); + } + private static ContainerLaunchException exited(String exitLog, RuntimeException cause) { return new ContainerLaunchException( "failed to start the container, it exited before the database became reachable: " + exitLog, cause); diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java index a7ca08e..d08e4e4 100644 --- a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java +++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java @@ -21,7 +21,7 @@ class AttemptTest { // had published the port anew. @Test void readsThePortAnewForEveryPing() { - var attempt = new Attempt(ENOUGH_TIME); + var attempt = new Attempt(ENOUGH_TIME, () -> false); var observations = new ArrayList<>(List.of( new Attempt.Observation(false, null), new Attempt.Observation(false, 1001), @@ -40,7 +40,7 @@ void readsThePortAnewForEveryPing() { @Test void endsTheWaitIfTheContainerExits() { - var attempt = new Attempt(ENOUGH_TIME); + var attempt = new Attempt(ENOUGH_TIME, () -> false); assertThrows( ContainerLaunchException.class, @@ -51,7 +51,7 @@ void endsTheWaitIfTheContainerExits() { @Test void endsTheWaitOnceTheStartupTimeoutRanOut() { - var attempt = new Attempt(Duration.ofMillis(300)); + var attempt = new Attempt(Duration.ofMillis(300), () -> false); var pingCount = new int[] {0}; var startedAt = System.nanoTime(); @@ -72,7 +72,7 @@ void endsTheWaitOnceTheStartupTimeoutRanOut() { // counts. @Test void countsTheTimeBeforeTheWaitBegins() { - var attempt = new Attempt(Duration.ZERO); + var attempt = new Attempt(Duration.ZERO, () -> false); assertThrows( ContainerLaunchException.class, @@ -85,7 +85,7 @@ void countsTheTimeBeforeTheWaitBegins() { // at once, and does not count as running out of time. @Test void endsTheWaitAtOnceIfDockerFails() { - var attempt = new Attempt(ENOUGH_TIME); + var attempt = new Attempt(ENOUGH_TIME, () -> false); var dockerFailure = new IllegalStateException("Docker failed"); var exception = assertThrows( @@ -101,9 +101,31 @@ void endsTheWaitAtOnceIfDockerFails() { assertFalse(attempt.isUnreachable()); } + // An interrupt of the caller cancels the start, which ends the wait + // without another look at the container, and does not count as running + // out of time. + @Test + void endsTheWaitAtOnceIfTheStartIsCanceled() { + var attempt = new Attempt(ENOUGH_TIME, () -> true); + var observationCount = new int[] {0}; + + assertThrows( + ContainerLaunchException.class, + () -> attempt.waitUntilReachable( + () -> { + observationCount[0]++; + return new Attempt.Observation(false, 1001); + }, + port -> true)); + + assertEquals(0, observationCount[0]); + assertFalse(attempt.hasRunOutOfTime); + assertFalse(attempt.isUnreachable()); + } + @Test void isUnreachableIfTheWaitRanOutOfTime() { - var attempt = new Attempt(ENOUGH_TIME); + var attempt = new Attempt(ENOUGH_TIME, () -> false); attempt.hasWaitBegun = true; attempt.wasRunning = true; attempt.hasRunOutOfTime = true; @@ -115,7 +137,7 @@ void isUnreachableIfTheWaitRanOutOfTime() { // container after a fixed time of its own, before the wait begins. @Test void isUnreachableIfTheContainerWasRunningWhenTestcontainersGaveUpBeforeTheWait() { - var attempt = new Attempt(ENOUGH_TIME); + var attempt = new Attempt(ENOUGH_TIME, () -> false); attempt.wasRunning = true; assertTrue(attempt.isUnreachable()); @@ -123,7 +145,7 @@ void isUnreachableIfTheContainerWasRunningWhenTestcontainersGaveUpBeforeTheWait( @Test void isNotUnreachableIfTheContainerWasNotRunning() { - var attempt = new Attempt(ENOUGH_TIME); + var attempt = new Attempt(ENOUGH_TIME, () -> false); assertFalse(attempt.isUnreachable()); } @@ -132,7 +154,7 @@ void isNotUnreachableIfTheContainerWasNotRunning() { // because Docker failed while the container was running. @Test void isNotUnreachableIfTheWaitEndedForAnotherReason() { - var attempt = new Attempt(ENOUGH_TIME); + var attempt = new Attempt(ENOUGH_TIME, () -> false); attempt.hasWaitBegun = true; attempt.wasRunning = true; @@ -141,6 +163,6 @@ void isNotUnreachableIfTheWaitEndedForAnotherReason() { @Test void hasAnIdOfItsOwn() { - assertNotEquals(new Attempt(ENOUGH_TIME).id, new Attempt(ENOUGH_TIME).id); + assertNotEquals(new Attempt(ENOUGH_TIME, () -> false).id, new Attempt(ENOUGH_TIME, () -> false).id); } } diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java index ce3835d..91d391c 100644 --- a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java +++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -23,9 +24,11 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.LockSupport; import org.junit.jupiter.api.Test; import org.testcontainers.DockerClientFactory; @@ -369,34 +372,112 @@ void reportsAFailedRemovalWithoutChangingTheMessage() { assertEquals(List.of(), existing(labels)); } - // An interrupt while Docker creates the container would cut that call - // short, so the start lets it finish, and keeps the interrupt for the - // caller. + // The database does not become reachable here, so the start is still + // waiting for it when the caller is interrupted. @Test - void keepsAnInterruptForTheCallerWithoutCuttingTheStartShort() { + void givesUpOnAnInterruptWhileItWaitsForTheDatabase() { var caller = Thread.currentThread(); - var container = new Container().withImageTag(ImageTag.get()); + var interruptedAt = new AtomicLong(); + var container = new Container().withImageTag(ImageTag.get()).withStartupTimeout(Duration.ofMinutes(1)); var labels = labelsOf(container); - container.withCreateContainerCmdModifier(command -> { - caller.interrupt(); - pause(Duration.ofMillis(200)); - }); + container.withCreateContainerCmdModifier(ContainerTest::listenOnAnotherPort); + container.withCreateContainerCmdModifier(command -> CompletableFuture.delayedExecutor(2, TimeUnit.SECONDS) + .execute(() -> { + interruptedAt.set(System.nanoTime()); + caller.interrupt(); + })); + IllegalStateException exception; boolean wasInterrupted; try { - container.start(); + exception = assertThrows(IllegalStateException.class, container::start); } finally { wasInterrupted = Thread.interrupted(); } + var elapsed = Duration.ofNanos(System.nanoTime() - interruptedAt.get()); + + assertEquals("failed to start the container: interrupted", exception.getMessage()); + assertInstanceOf(InterruptedException.class, exception.getCause()); + assertTrue(wasInterrupted); + assertTrue(elapsed.compareTo(Container.INTERRUPT_GRACE_PERIOD) < 0, elapsed::toString); + // The start gave up within the grace period, so it removed the + // container before it returned, and did not replace it. + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); + } + + // Docker takes longer than the grace period to create the container here, + // and creates it only after the start gave up, so the start removes it in + // the background. + @Test + void givesUpOnAnInterruptWhileDockerCreatesTheContainerAndRemovesItLater() throws TimeoutException { + var caller = Thread.currentThread(); + var interruptedAt = new AtomicLong(); + var logEnd = new WaitingConsumer(); + var container = new Container().withImageTag(ImageTag.get()).withLogConsumer(logEnd); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> { + interruptedAt.set(System.nanoTime()); + caller.interrupt(); + pause(Container.INTERRUPT_GRACE_PERIOD.plusSeconds(2)); + }); + + IllegalStateException exception; + boolean wasInterrupted; try { - assertTrue(wasInterrupted); - assertEquals(1, labels.size()); - container.getClient().ping(); + exception = assertThrows(IllegalStateException.class, container::start); } finally { - container.stop(); + wasInterrupted = Thread.interrupted(); + } + var elapsed = Duration.ofNanos(System.nanoTime() - interruptedAt.get()); + + assertEquals("failed to start the container: interrupted", exception.getMessage()); + assertInstanceOf(InterruptedException.class, exception.getCause()); + assertTrue(wasInterrupted); + assertTrue(elapsed.compareTo(Container.INTERRUPT_GRACE_PERIOD) >= 0, elapsed::toString); + assertTrue(elapsed.compareTo(Container.INTERRUPT_GRACE_PERIOD.plusSeconds(2)) < 0, elapsed::toString); + + // The log of the container ends once Testcontainers removed it, which + // shows that Docker did create it. + logEnd.waitUntilEnd(60, TimeUnit.SECONDS); + var deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos(); + while (!existing(labels).isEmpty() && System.nanoTime() - deadline < 0) { + pause(Duration.ofMillis(100)); } + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); + } + + @Test + void rejectsAStartupTimeoutThatIsNotPositive() { + var container = new Container(); + + var zeroException = + assertThrows(IllegalArgumentException.class, () -> container.withStartupTimeout(Duration.ZERO)); + assertEquals("startup timeout must be positive", zeroException.getMessage()); + + var negativeException = assertThrows( + IllegalArgumentException.class, () -> container.withStartupTimeout(Duration.ofSeconds(-1))); + assertEquals("startup timeout must be positive", negativeException.getMessage()); + } + + // An error is no failure that the start handles, so it passes it on, as + // the start does not run on the thread of the caller. + @Test + void passesOnAnErrorThatEndsTheStart() { + var error = new AssertionError("broken"); + var container = new Container().withImageTag(ImageTag.get()); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> { + throw error; + }); + + var exception = assertThrows(CompletionException.class, container::start); + + assertSame(error, exception.getCause()); + assertEquals(1, labels.size()); assertEquals(List.of(), existing(labels)); } From cde74a10b1eedd888c6bbe69792589df069f2739 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Mon, 19 Oct 2026 14:51:56 +0200 Subject: [PATCH 3/6] fix: Leave a container that Testcontainers reuses to Testcontainers, pass errors of the start on as they are, and let an interrupted start finish before the next start or stop. While Testcontainers reuses containers, start() gives the container no label of its attempt, which would keep Testcontainers from finding it again, and neither replaces nor removes it. An error that ends the start reaches the caller as it is, instead of wrapped in a CompletionException. A thread that is interrupted already does not even pull the image, and an interrupt while pulling keeps the start from creating a container. start() and stop() wait, for 30 seconds at most, for what an interrupted start still does in the background. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH --- .../build.gradle.kts | 6 + .../testcontainers/Attempt.java | 5 +- .../testcontainers/Container.java | 200 ++++++++++++----- .../testcontainers/ContainerTest.java | 207 ++++++++++++++++-- 4 files changed, 344 insertions(+), 74 deletions(-) diff --git a/eventsourcingdb-testcontainers/build.gradle.kts b/eventsourcingdb-testcontainers/build.gradle.kts index d319796..d10ed6b 100644 --- a/eventsourcingdb-testcontainers/build.gradle.kts +++ b/eventsourcingdb-testcontainers/build.gradle.kts @@ -20,3 +20,9 @@ dependencies { api(project(":eventsourcingdb")) api(libs.testcontainers) } + +// Testcontainers reuses containers only if the environment allows it, which +// the tests of reuse need. It affects only containers that ask for reuse. +tasks.test { + environment("TESTCONTAINERS_REUSE_ENABLE", "true") +} diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java index 87fc08a..67ec367 100644 --- a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java @@ -2,6 +2,7 @@ import java.time.Duration; import java.util.UUID; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.LockSupport; import java.util.function.BooleanSupplier; import java.util.function.IntPredicate; @@ -43,7 +44,9 @@ final class Attempt { String exitLog; Attempt(Duration startupTimeout, BooleanSupplier isCanceled) { - deadline = System.nanoTime() + startupTimeout.toNanos(); + // Converting saturates, so that a startup timeout too long to count in + // nanoseconds does not fail. + deadline = System.nanoTime() + TimeUnit.NANOSECONDS.convert(startupTimeout); this.isCanceled = isCanceled; } diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java index ee6606d..127e236 100644 --- a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java @@ -29,6 +29,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; import java.util.function.Consumer; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; @@ -37,6 +38,7 @@ import org.testcontainers.images.builder.Transferable; import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.DockerStatus; +import org.testcontainers.utility.TestcontainersConfiguration; /** * An EventSourcingDB instance in a Docker container, for integration tests. @@ -86,6 +88,12 @@ public final class Container extends GenericContainer { // background, e.g. removing a container that Docker is still creating. static final Duration INTERRUPT_GRACE_PERIOD = Duration.ofSeconds(5); + // How long start() and stop() wait at most for what an interrupted start + // still does in the background. That leaves Docker time to answer a call + // that is still pending, and Testcontainers time for its own fixed waits, + // e.g. the one for the port of a container. + static final Duration BACKGROUND_START_LIMIT = Duration.ofSeconds(30); + private int internalPort = 3000; private String apiToken = "secret"; private @Nullable KeyPair signingKeyPair; @@ -103,6 +111,21 @@ public final class Container extends GenericContainer { // given id. It is a field only so that tests can replace it. Consumer containerRemover = this::removeContainersWithLabel; + // Tells whether the environment allows Testcontainers to reuse + // containers. It is a field only so that tests can replace it, as the + // environment can not change while they run. + BooleanSupplier environmentSupportsReuse = + () -> TestcontainersConfiguration.getInstance().environmentSupportsReuse(); + + // The latest start, which may still run in the background after the + // caller was interrupted. + private CompletableFuture> backgroundStart = + CompletableFuture.completedFuture(Optional.empty()); + + // The thread that runs the latest start, so that stop() can tell a call + // of Testcontainers from within that start. + private volatile @Nullable Thread startThread; + /** * Creates a container that uses the {@code latest} tag of the official EventSourcingDB image, port 3000 inside the * container, and the API token {@code secret}, and does not sign events. @@ -216,11 +239,14 @@ private static byte[] pem(PrivateKey privateKey) { * replaced by a new one, up to three containers in total. If the container exits before the instance answers * pings, or if Docker fails, the start fails at once. A container that exits before Docker publishes its port * fails only once Testcontainers stops waiting for the port, which takes about five seconds. Either way, the - * containers of a start that failed are removed. + * containers of a start that failed are removed. While Testcontainers reuses containers, as + * {@link #withReuse(boolean)} and the environment ask for, the start neither labels, nor replaces, nor removes a + * container of its own accord, just as Testcontainers does not. * *

If the calling thread is interrupted before the instance answers pings, the start gives up within five * seconds, and throws an {@link IllegalStateException}, and the interrupt status stays set. Containers that the - * start has not removed by then, e.g. because Docker is still creating one, it removes in the background. + * start has not removed by then, e.g. because Docker is still creating one, it removes in the background, which a + * following call of {@code start} or {@link #stop()} waits for. * * @throws IllegalStateException if the database did not become reachable in any of three containers * @throws RuntimeException if the container does not start for another reason, e.g. because the image does not @@ -229,29 +255,46 @@ private static byte[] pem(PrivateKey privateKey) { */ @Override public void start() { + awaitBackgroundStart(); + + // A thread that is interrupted already does not even pull the image. + if (Thread.currentThread().isInterrupted()) { + throw interrupted(new InterruptedException()); + } + // The start runs on a thread of its own, so that an interrupt of the // caller does not cut a call to Docker short: Docker might still create // a container for a call that was cut short, after the start looked // for the containers to remove. Instead, the interrupt cancels the // start, and the caller waits for it to give up, which it finishes in - // the background if that takes longer than the grace period. + // the background if that takes longer than the grace period. The caller + // waits for a copy of the start, which the grace period may complete, + // so that the start itself tells when the background is done. var interruption = new AtomicReference<@Nullable InterruptedException>(); - var started = CompletableFuture.supplyAsync(() -> startAttempts(interruption), Container::runOnThreadOfItsOwn); + backgroundStart = CompletableFuture.supplyAsync(() -> startAttempts(interruption), this::runOnThreadOfItsOwn); + var started = backgroundStart.copy(); - Optional failure; + Optional failure = null; + var wasInterrupted = false; try { - failure = started.get(); - } catch (InterruptedException ex) { - interruption.set(ex); - failure = started.completeOnTimeout( - Optional.of(interrupted(ex)), INTERRUPT_GRACE_PERIOD.toNanos(), TimeUnit.NANOSECONDS) - .join(); - Thread.currentThread().interrupt(); - } catch (ExecutionException ex) { - // The start returns every failure it handles, so only an error - // that it does not handle ends up here, which is passed on as - // CompletableFuture.join() would pass it on. - throw new CompletionException(ex.getCause()); + while (failure == null) { + try { + failure = started.get(); + } catch (InterruptedException ex) { + interruption.set(ex); + wasInterrupted = true; + started.completeOnTimeout( + Optional.of(interrupted(ex)), INTERRUPT_GRACE_PERIOD.toNanos(), TimeUnit.NANOSECONDS); + } catch (ExecutionException ex) { + // The start returns every exception it handles, so only an + // error ends up here, which is passed on as it is. + throw (Error) Objects.requireNonNull(ex.getCause()); + } + } + } finally { + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } } failure.ifPresent(startFailure -> { @@ -259,62 +302,111 @@ public void start() { }); } - private static void runOnThreadOfItsOwn(Runnable task) { - Thread.ofPlatform().daemon().name("eventsourcingdb-container-start").start(task); + /** + * Stops and removes the container. + * + *

If a start that was interrupted still removes containers in the background, this waits for it first. + */ + @Override + public void stop() { + // Testcontainers stops a container whose start failed itself, from + // within the start, which must not wait for itself. + if (Thread.currentThread() != startThread) { + awaitBackgroundStart(); + } + + super.stop(); + } + + // Waits until the latest start is done, for a limit of time at most, so + // that what follows a start that was interrupted does not race with what + // the start still does in the background. + private void awaitBackgroundStart() { + backgroundStart + .handle((failure, error) -> true) + .completeOnTimeout(true, BACKGROUND_START_LIMIT.toNanos(), TimeUnit.NANOSECONDS) + .join(); + } + + private void runOnThreadOfItsOwn(Runnable task) { + var thread = Thread.ofPlatform() + .daemon() + .name("eventsourcingdb-container-start") + .unstarted(task); + startThread = thread; + thread.start(); } // Starts containers until the database in one of them becomes reachable, // and returns the failure otherwise, or once the given interruption of // the caller is set. It pulls the image before the first attempt, so that // pulling it does not count toward the startup timeout. Testcontainers - // keeps the image it resolved, so the attempts do not pull it again. + // keeps the image it resolved, so the attempts do not pull it again. It + // returns every exception, also one that it does not expect, so that only + // errors end the start otherwise. private Optional startAttempts(AtomicReference<@Nullable InterruptedException> interruption) { try { getDockerImageName(); - } catch (RuntimeException ex) { - return Optional.of(dockerFailed(ex)); - } - - var removalFailures = new ArrayList(); - for (var attemptNumber = 1; ; attemptNumber++) { - var currentAttempt = new Attempt(startupTimeout, () -> interruption.get() != null); - var failure = startContainerOf(currentAttempt); - if (failure.isEmpty()) { - return Optional.empty(); - } - - removeContainersOf(currentAttempt).ifPresent(removalFailures::add); - // Once the caller was interrupted, the start gives up, whatever - // else made the attempt fail. - var interruptionOfCaller = interruption.get(); - if (interruptionOfCaller != null) { - return failed(interrupted(interruptionOfCaller), removalFailures); + var interruptionWhilePulling = interruption.get(); + if (interruptionWhilePulling != null) { + return Optional.of(interrupted(interruptionWhilePulling)); } - var exitLog = currentAttempt.exitLog; - if (exitLog != null) { - return failed(exited(exitLog, failure.get()), removalFailures); - } - if (!currentAttempt.isUnreachable()) { - return failed(dockerFailed(failure.get()), removalFailures); - } - if (attemptNumber == MAX_START_ATTEMPTS) { - return failed(unreachable(failure.get()), removalFailures); + var isReused = isShouldBeReused() && environmentSupportsReuse.getAsBoolean(); + var removalFailures = new ArrayList(); + for (var attemptNumber = 1; ; attemptNumber++) { + var currentAttempt = new Attempt(startupTimeout, () -> interruption.get() != null); + var failure = startContainerOf(currentAttempt, isReused); + if (failure.isEmpty()) { + return Optional.empty(); + } + + // Testcontainers' reuse keeps containers on purpose, so the + // start leaves removing them to Testcontainers then. + if (!isReused) { + removeContainersOf(currentAttempt).ifPresent(removalFailures::add); + } + + // Once the caller was interrupted, the start gives up, whatever + // else made the attempt fail. + var interruptionOfCaller = interruption.get(); + if (interruptionOfCaller != null) { + return failed(interrupted(interruptionOfCaller), removalFailures); + } + + var exitLog = currentAttempt.exitLog; + if (exitLog != null) { + return failed(exited(exitLog, failure.get()), removalFailures); + } + if (isReused || !currentAttempt.isUnreachable()) { + return failed(dockerFailed(failure.get()), removalFailures); + } + if (attemptNumber == MAX_START_ATTEMPTS) { + return failed(unreachable(failure.get()), removalFailures); + } } + } catch (RuntimeException ex) { + return Optional.of(dockerFailed(ex)); } } // Starts the container of the given attempt with Testcontainers, and - // returns the failure if that fails. - private Optional startContainerOf(Attempt currentAttempt) { + // returns the failure if that fails. A container that Testcontainers + // reuses gets no label, which would keep Testcontainers from finding it + // again. Testcontainers throws checked exceptions without declaring them, + // e.g. if a container it depends on fails, so the failure is any + // exception. + private Optional startContainerOf(Attempt currentAttempt, boolean isReused) { attempt = currentAttempt; - withLabel(START_LABEL, currentAttempt.id); + if (!isReused) { + withLabel(START_LABEL, currentAttempt.id); + } try { super.start(); return Optional.empty(); - } catch (RuntimeException ex) { + } catch (Exception ex) { return Optional.of(ex); } finally { attempt = null; @@ -422,7 +514,7 @@ private static Optional failed(RuntimeException failure, List< return Optional.of(failure); } - private IllegalStateException unreachable(RuntimeException cause) { + private IllegalStateException unreachable(Exception cause) { return new IllegalStateException( "failed to start the container, the database did not become reachable within " + seconds(startupTimeout) @@ -436,7 +528,7 @@ private static IllegalStateException interrupted(InterruptedException cause) { return new IllegalStateException("failed to start the container: interrupted", cause); } - private static ContainerLaunchException exited(String exitLog, RuntimeException cause) { + private static ContainerLaunchException exited(String exitLog, Exception cause) { return new ContainerLaunchException( "failed to start the container, it exited before the database became reachable: " + exitLog, cause); } @@ -444,7 +536,7 @@ private static ContainerLaunchException exited(String exitLog, RuntimeException // Names the error of Docker, which Testcontainers wraps in exceptions of // its own, as the innermost cause. Docker ends its errors with a line // break, which the message leaves out. - private static ContainerLaunchException dockerFailed(RuntimeException failure) { + private static ContainerLaunchException dockerFailed(Exception failure) { Throwable dockerError = failure; for (var cause = failure.getCause(); cause != null; cause = cause.getCause()) { dockerError = cause; diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java index 91d391c..8ba2ea2 100644 --- a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java +++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -21,14 +22,17 @@ import java.net.http.HttpClient; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.LockSupport; import org.junit.jupiter.api.Test; import org.testcontainers.DockerClientFactory; @@ -41,6 +45,10 @@ import tools.jackson.databind.json.JsonMapper; class ContainerTest { + // The label that tells the containers of a test of reuse apart, so that + // they reuse only each other. + private static final String REUSE_TEST_LABEL = "io.eventsourcingdb.client-java.reuse-test"; + private static final String EXITED_PREFIX = "failed to start the container, it exited before the database became reachable: "; @@ -210,6 +218,7 @@ void givesUpAfterThreeContainersInWhichTheDatabaseDoesNotBecomeReachable() { var exception = assertThrows(IllegalStateException.class, container::start); var elapsed = Duration.ofNanos(System.nanoTime() - startedAt); + assertEquals(IllegalStateException.class, exception.getClass()); assertEquals( "failed to start the container, the database did not become reachable within 1.5s in any of 3" + " containers", @@ -307,6 +316,7 @@ void failsAtOnceIfDockerFails() { + innermostCauseOf(exception).getMessage().stripTrailing(), exception.getMessage()); assertTrue(exception.getMessage().contains("Invalid container name"), exception::getMessage); + assertEquals(ContainerLaunchException.class, exception.getClass()); assertEquals(1, labels.size()); assertEquals(List.of(), existing(labels)); assertFalse(container.isRunning()); @@ -409,18 +419,27 @@ void givesUpOnAnInterruptWhileItWaitsForTheDatabase() { // Docker takes longer than the grace period to create the container here, // and creates it only after the start gave up, so the start removes it in - // the background. + // the background. Starting again waits for that. @Test - void givesUpOnAnInterruptWhileDockerCreatesTheContainerAndRemovesItLater() throws TimeoutException { + void givesUpOnAnInterruptWhileDockerCreatesTheContainerAndRemovesItLater() { var caller = Thread.currentThread(); var interruptedAt = new AtomicLong(); - var logEnd = new WaitingConsumer(); - var container = new Container().withImageTag(ImageTag.get()).withLogConsumer(logEnd); + var hasFirstCreateGoneOn = new AtomicBoolean(); + var hadFirstCreateGoneOnAtSecond = new AtomicBoolean(); + var firstContainersAtSecond = new AtomicReference>(); + var container = new Container().withImageTag(ImageTag.get()); var labels = labelsOf(container); container.withCreateContainerCmdModifier(command -> { - interruptedAt.set(System.nanoTime()); - caller.interrupt(); - pause(Container.INTERRUPT_GRACE_PERIOD.plusSeconds(2)); + if (labels.size() == 1) { + interruptedAt.set(System.nanoTime()); + caller.interrupt(); + pause(Container.INTERRUPT_GRACE_PERIOD.plusSeconds(2)); + hasFirstCreateGoneOn.set(true); + return; + } + + hadFirstCreateGoneOnAtSecond.set(hasFirstCreateGoneOn.get()); + firstContainersAtSecond.set(existing(labels.subList(0, 1))); }); IllegalStateException exception; @@ -432,24 +451,156 @@ void givesUpOnAnInterruptWhileDockerCreatesTheContainerAndRemovesItLater() throw } var elapsed = Duration.ofNanos(System.nanoTime() - interruptedAt.get()); + assertEquals(IllegalStateException.class, exception.getClass()); assertEquals("failed to start the container: interrupted", exception.getMessage()); assertInstanceOf(InterruptedException.class, exception.getCause()); assertTrue(wasInterrupted); assertTrue(elapsed.compareTo(Container.INTERRUPT_GRACE_PERIOD) >= 0, elapsed::toString); assertTrue(elapsed.compareTo(Container.INTERRUPT_GRACE_PERIOD.plusSeconds(2)) < 0, elapsed::toString); - // The log of the container ends once Testcontainers removed it, which - // shows that Docker did create it. - logEnd.waitUntilEnd(60, TimeUnit.SECONDS); - var deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos(); - while (!existing(labels).isEmpty() && System.nanoTime() - deadline < 0) { - pause(Duration.ofMillis(100)); + container.start(); + try { + assertTrue(hadFirstCreateGoneOnAtSecond.get()); + assertEquals(List.of(), firstContainersAtSecond.get()); + container.getClient().ping(); + } finally { + container.stop(); } - assertEquals(1, labels.size()); + assertEquals(2, labels.size()); assertEquals(List.of(), existing(labels)); } + // Pulling the image takes longer than the grace period here. The start + // goes on pulling in the background, but creates no container after it, + // and stopping waits for that. + @Test + void givesUpOnAnInterruptWhileItPullsTheImageWithoutCreatingAContainer() { + var caller = Thread.currentThread(); + var pull = CompletableFuture.supplyAsync( + () -> "thenativeweb/eventsourcingdb:" + ImageTag.get(), + CompletableFuture.delayedExecutor( + Container.INTERRUPT_GRACE_PERIOD.plusSeconds(3).toMillis(), TimeUnit.MILLISECONDS)); + var container = new Container(); + container.setImage(pull); + var labels = labelsOf(container); + CompletableFuture.delayedExecutor(1, TimeUnit.SECONDS).execute(caller::interrupt); + + IllegalStateException exception; + boolean wasInterrupted; + try { + exception = assertThrows(IllegalStateException.class, container::start); + } finally { + wasInterrupted = Thread.interrupted(); + } + + assertEquals("failed to start the container: interrupted", exception.getMessage()); + assertTrue(wasInterrupted); + assertFalse(pull.isDone()); + + container.stop(); + + assertTrue(pull.isDone()); + assertEquals(List.of(), labels); + } + + @Test + void givesUpAtOnceIfTheThreadIsInterruptedAlready() { + var container = new Container(); + container.setImage(CompletableFuture.supplyAsync( + () -> "thenativeweb/eventsourcingdb:" + ImageTag.get(), + CompletableFuture.delayedExecutor(1, TimeUnit.MINUTES))); + var labels = labelsOf(container); + + Thread.currentThread().interrupt(); + var startedAt = System.nanoTime(); + IllegalStateException exception; + boolean wasInterrupted; + try { + exception = assertThrows(IllegalStateException.class, container::start); + } finally { + wasInterrupted = Thread.interrupted(); + } + var elapsed = Duration.ofNanos(System.nanoTime() - startedAt); + + assertEquals("failed to start the container: interrupted", exception.getMessage()); + assertInstanceOf(InterruptedException.class, exception.getCause()); + assertTrue(wasInterrupted); + assertTrue(elapsed.compareTo(Duration.ofSeconds(1)) < 0, elapsed::toString); + assertEquals(List.of(), labels); + } + + // Testcontainers finds a container to reuse by the configuration of the + // container, which a label of each attempt would make differ every time. + @Test + void leavesAContainerThatTestcontainersReusesToTestcontainers() { + var reuseId = UUID.randomUUID().toString(); + var first = reusableContainer(reuseId); + var second = reusableContainer(reuseId); + var labels = labelsOf(second); + + first.start(); + try { + second.start(); + + assertEquals(first.getContainerId(), second.getContainerId()); + assertEquals(Collections.singletonList(null), labels); + assertFalse(DockerClientFactory.instance() + .client() + .inspectContainerCmd(first.getContainerId()) + .exec() + .getConfig() + .getLabels() + .containsKey(Container.START_LABEL)); + second.getClient().ping(); + } finally { + first.stop(); + second.stop(); + } + + assertEquals(List.of(), reusableContainers(reuseId)); + } + + // Testcontainers removes a reused container whose start fails on its own, + // and does not replace it. + @Test + void neitherReplacesNorRemovesAContainerThatTestcontainersReuses() { + var reuseId = UUID.randomUUID().toString(); + var container = reusableContainer(reuseId).withStartupTimeout(Duration.ofMillis(1500)); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(ContainerTest::listenOnAnotherPort); + + var exception = assertThrows(ContainerLaunchException.class, container::start); + + assertEquals(ContainerLaunchException.class, exception.getClass()); + assertEquals( + "failed to start the container: the database did not become reachable in time", exception.getMessage()); + assertEquals(Collections.singletonList(null), labels); + assertEquals(List.of(), reusableContainers(reuseId)); + } + + // Asked for reuse, Testcontainers does not reuse containers unless the + // environment allows it, so the start labels, replaces, and removes them + // as usual. + @Test + void labelsAContainerThatAsksForReuseIfTheEnvironmentDoesNotAllowIt() { + var reuseId = UUID.randomUUID().toString(); + var container = reusableContainer(reuseId); + container.environmentSupportsReuse = () -> false; + var labels = labelsOf(container); + + container.start(); + try { + assertEquals(1, labels.size()); + assertNotNull(labels.getFirst()); + assertEquals(1, existing(labels).size()); + } finally { + container.stop(); + } + + assertEquals(List.of(), reusableContainers(reuseId)); + } + @Test void rejectsAStartupTimeoutThatIsNotPositive() { var container = new Container(); @@ -463,8 +614,8 @@ void rejectsAStartupTimeoutThatIsNotPositive() { assertEquals("startup timeout must be positive", negativeException.getMessage()); } - // An error is no failure that the start handles, so it passes it on, as - // the start does not run on the thread of the caller. + // An error is no failure that the start handles, so it passes it on as it + // is, although the start does not run on the thread of the caller. @Test void passesOnAnErrorThatEndsTheStart() { var error = new AssertionError("broken"); @@ -474,9 +625,9 @@ void passesOnAnErrorThatEndsTheStart() { throw error; }); - var exception = assertThrows(CompletionException.class, container::start); + var exception = assertThrows(AssertionError.class, container::start); - assertSame(error, exception.getCause()); + assertSame(error, exception); assertEquals(1, labels.size()); assertEquals(List.of(), existing(labels)); } @@ -520,6 +671,24 @@ private static List labelsOf(Container container) { return labels; } + // A container that asks Testcontainers for reuse, and that only containers + // with the same id of reuse match. + private static Container reusableContainer(String reuseId) { + return new Container().withImageTag(ImageTag.get()).withReuse(true).withLabel(REUSE_TEST_LABEL, reuseId); + } + + private static List reusableContainers(String reuseId) { + return DockerClientFactory.instance() + .client() + .listContainersCmd() + .withShowAll(true) + .withLabelFilter(Map.of(REUSE_TEST_LABEL, reuseId)) + .exec() + .stream() + .map(container -> container.getId()) + .toList(); + } + private static void listenOnAnotherPort(CreateContainerCmd command) { var arguments = command.getCmd().clone(); for (var index = 0; index < arguments.length - 1; index++) { From 7ffb3f9e1df7d107f3526826a509f71e5495a239 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Mon, 19 Oct 2026 15:23:53 +0200 Subject: [PATCH 4/6] feat: Start the test container on the thread of the caller, and remove the containers of a start that an interrupt ended once more later. start() runs on the thread of the caller again, so that modifiers run where Testcontainers runs them, and relies on docker-java ending a call that is pending once the thread is interrupted. After an interrupt, the start clears the interrupt status, removes the containers of its attempt by their label, restores the status, and throws. As Docker may still create a container for a call to create one that the interrupt cut short, a task in the background removes the containers of the attempt once more, five seconds later. It knows the attempt only by its label, and touches nothing else, so start() and stop() no longer wait for it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH --- .../testcontainers/Attempt.java | 25 +- .../testcontainers/Container.java | 242 +++++++----------- .../testcontainers/AttemptTest.java | 42 +-- .../testcontainers/ContainerTest.java | 155 +++++++---- 4 files changed, 204 insertions(+), 260 deletions(-) diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java index 67ec367..aea0264 100644 --- a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java @@ -4,7 +4,6 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.LockSupport; -import java.util.function.BooleanSupplier; import java.util.function.IntPredicate; import java.util.function.Supplier; import org.jspecify.annotations.Nullable; @@ -25,10 +24,6 @@ final class Attempt { private final long deadline; - // Tells whether the start was canceled, e.g. because the caller was - // interrupted. - private final BooleanSupplier isCanceled; - // Whether the wait for the database began. Testcontainers begins it only // once Docker published the port of the container. boolean hasWaitBegun; @@ -43,27 +38,23 @@ final class Attempt { @Nullable String exitLog; - Attempt(Duration startupTimeout, BooleanSupplier isCanceled) { + Attempt(Duration startupTimeout) { // Converting saturates, so that a startup timeout too long to count in // nanoseconds does not fail. deadline = System.nanoTime() + TimeUnit.NANOSECONDS.convert(startupTimeout); - this.isCanceled = isCanceled; } - // Waits until the database answers a ping, until the container exits, - // until the startup timeout runs out, or until the start is canceled. It - // observes the container anew for every ping, including the port that - // Docker published for the database, as Docker sometimes publishes it - // late. A failure to observe the container is one of Docker, which ends - // the wait at once, and does not count as running out of time. + // Waits until the database answers a ping, until the container exits, or + // until the startup timeout runs out. It observes the container anew for + // every ping, including the port that Docker published for the database, + // as Docker sometimes publishes it late. A failure to observe the + // container is one of Docker, which ends the wait at once, and does not + // count as running out of time. That includes an interrupt, which cuts + // the call to Docker short. void waitUntilReachable(Supplier observe, IntPredicate answersPing) { hasWaitBegun = true; while (true) { - if (isCanceled.getAsBoolean()) { - throw new ContainerLaunchException("the start was canceled"); - } - var observation = observe.get(); if (observation.hasExited()) { throw new ContainerLaunchException("the container exited"); diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java index 127e236..4f9146e 100644 --- a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java @@ -26,13 +26,12 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.BooleanSupplier; import java.util.function.Consumer; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; +import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.ContainerLaunchException; import org.testcontainers.containers.GenericContainer; import org.testcontainers.images.builder.Transferable; @@ -83,16 +82,11 @@ public final class Container extends GenericContainer { // unanswered. private static final Duration PING_TIMEOUT = Duration.ofSeconds(1); - // How long start() waits at most for the start to give up once the caller - // was interrupted, before it returns, and leaves the rest to the - // background, e.g. removing a container that Docker is still creating. - static final Duration INTERRUPT_GRACE_PERIOD = Duration.ofSeconds(5); - - // How long start() and stop() wait at most for what an interrupted start - // still does in the background. That leaves Docker time to answer a call - // that is still pending, and Testcontainers time for its own fixed waits, - // e.g. the one for the port of a container. - static final Duration BACKGROUND_START_LIMIT = Duration.ofSeconds(30); + // How long after an interrupt ended a start the containers of its attempt + // are looked for once more. The interrupt cuts a call to Docker short, and + // Docker may still create a container for a call to create one that was + // cut short, after the start looked for the containers to remove. + static final Duration LATE_REMOVAL_DELAY = Duration.ofSeconds(5); private int internalPort = 3000; private String apiToken = "secret"; @@ -109,7 +103,7 @@ public final class Container extends GenericContainer { // Removes the containers that carry the label of the attempt with the // given id. It is a field only so that tests can replace it. - Consumer containerRemover = this::removeContainersWithLabel; + Consumer containerRemover = Container::removeContainersWithLabel; // Tells whether the environment allows Testcontainers to reuse // containers. It is a field only so that tests can replace it, as the @@ -117,15 +111,6 @@ public final class Container extends GenericContainer { BooleanSupplier environmentSupportsReuse = () -> TestcontainersConfiguration.getInstance().environmentSupportsReuse(); - // The latest start, which may still run in the background after the - // caller was interrupted. - private CompletableFuture> backgroundStart = - CompletableFuture.completedFuture(Optional.empty()); - - // The thread that runs the latest start, so that stop() can tell a call - // of Testcontainers from within that start. - private volatile @Nullable Thread startThread; - /** * Creates a container that uses the {@code latest} tag of the official EventSourcingDB image, port 3000 inside the * container, and the API token {@code secret}, and does not sign events. @@ -243,10 +228,10 @@ private static byte[] pem(PrivateKey privateKey) { * {@link #withReuse(boolean)} and the environment ask for, the start neither labels, nor replaces, nor removes a * container of its own accord, just as Testcontainers does not. * - *

If the calling thread is interrupted before the instance answers pings, the start gives up within five - * seconds, and throws an {@link IllegalStateException}, and the interrupt status stays set. Containers that the - * start has not removed by then, e.g. because Docker is still creating one, it removes in the background, which a - * following call of {@code start} or {@link #stop()} waits for. + *

If the calling thread is interrupted before the instance answers pings, the start gives up, removes the + * containers of its attempt, and throws an {@link IllegalStateException}, and the interrupt status stays set. As + * Docker may still create a container for a call that the interrupt cut short, the start looks for containers to + * remove once more a few seconds later, in the background. * * @throws IllegalStateException if the database did not become reachable in any of three containers * @throws RuntimeException if the container does not start for another reason, e.g. because the image does not @@ -255,142 +240,67 @@ private static byte[] pem(PrivateKey privateKey) { */ @Override public void start() { - awaitBackgroundStart(); - // A thread that is interrupted already does not even pull the image. if (Thread.currentThread().isInterrupted()) { - throw interrupted(new InterruptedException()); + throw interrupted(null); } - // The start runs on a thread of its own, so that an interrupt of the - // caller does not cut a call to Docker short: Docker might still create - // a container for a call that was cut short, after the start looked - // for the containers to remove. Instead, the interrupt cancels the - // start, and the caller waits for it to give up, which it finishes in - // the background if that takes longer than the grace period. The caller - // waits for a copy of the start, which the grace period may complete, - // so that the start itself tells when the background is done. - var interruption = new AtomicReference<@Nullable InterruptedException>(); - backgroundStart = CompletableFuture.supplyAsync(() -> startAttempts(interruption), this::runOnThreadOfItsOwn); - var started = backgroundStart.copy(); - - Optional failure = null; - var wasInterrupted = false; + // Pulling the image does not count toward the startup timeout, so it + // happens before the first attempt. Testcontainers keeps the image it + // resolved, so the attempts do not pull it again. try { - while (failure == null) { - try { - failure = started.get(); - } catch (InterruptedException ex) { - interruption.set(ex); - wasInterrupted = true; - started.completeOnTimeout( - Optional.of(interrupted(ex)), INTERRUPT_GRACE_PERIOD.toNanos(), TimeUnit.NANOSECONDS); - } catch (ExecutionException ex) { - // The start returns every exception it handles, so only an - // error ends up here, which is passed on as it is. - throw (Error) Objects.requireNonNull(ex.getCause()); - } - } - } finally { - if (wasInterrupted) { - Thread.currentThread().interrupt(); - } - } - - failure.ifPresent(startFailure -> { - throw startFailure; - }); - } - - /** - * Stops and removes the container. - * - *

If a start that was interrupted still removes containers in the background, this waits for it first. - */ - @Override - public void stop() { - // Testcontainers stops a container whose start failed itself, from - // within the start, which must not wait for itself. - if (Thread.currentThread() != startThread) { - awaitBackgroundStart(); + getDockerImageName(); + } catch (RuntimeException ex) { + throw wasInterrupted(ex) ? interrupted(ex) : dockerFailed(ex); } - super.stop(); - } + var isReused = isShouldBeReused() && environmentSupportsReuse.getAsBoolean(); + var removalFailures = new ArrayList(); + for (var attemptNumber = 1; ; attemptNumber++) { + var currentAttempt = new Attempt(startupTimeout); + var failure = startContainerOf(currentAttempt, isReused); + if (failure.isEmpty()) { + return; + } - // Waits until the latest start is done, for a limit of time at most, so - // that what follows a start that was interrupted does not race with what - // the start still does in the background. - private void awaitBackgroundStart() { - backgroundStart - .handle((failure, error) -> true) - .completeOnTimeout(true, BACKGROUND_START_LIMIT.toNanos(), TimeUnit.NANOSECONDS) - .join(); - } + var isInterrupted = wasInterrupted(failure.get()); - private void runOnThreadOfItsOwn(Runnable task) { - var thread = Thread.ofPlatform() - .daemon() - .name("eventsourcingdb-container-start") - .unstarted(task); - startThread = thread; - thread.start(); - } - - // Starts containers until the database in one of them becomes reachable, - // and returns the failure otherwise, or once the given interruption of - // the caller is set. It pulls the image before the first attempt, so that - // pulling it does not count toward the startup timeout. Testcontainers - // keeps the image it resolved, so the attempts do not pull it again. It - // returns every exception, also one that it does not expect, so that only - // errors end the start otherwise. - private Optional startAttempts(AtomicReference<@Nullable InterruptedException> interruption) { - try { - getDockerImageName(); + // Testcontainers' reuse keeps containers on purpose, so the start + // leaves removing them to Testcontainers then. + if (!isReused) { + removeContainersOf(currentAttempt, isInterrupted).ifPresent(removalFailures::add); + } - var interruptionWhilePulling = interruption.get(); - if (interruptionWhilePulling != null) { - return Optional.of(interrupted(interruptionWhilePulling)); + // Once the caller was interrupted, the start gives up, whatever + // else made the attempt fail. + if (isInterrupted) { + throw failed(interrupted(failure.get()), removalFailures); } - var isReused = isShouldBeReused() && environmentSupportsReuse.getAsBoolean(); - var removalFailures = new ArrayList(); - for (var attemptNumber = 1; ; attemptNumber++) { - var currentAttempt = new Attempt(startupTimeout, () -> interruption.get() != null); - var failure = startContainerOf(currentAttempt, isReused); - if (failure.isEmpty()) { - return Optional.empty(); - } - - // Testcontainers' reuse keeps containers on purpose, so the - // start leaves removing them to Testcontainers then. - if (!isReused) { - removeContainersOf(currentAttempt).ifPresent(removalFailures::add); - } - - // Once the caller was interrupted, the start gives up, whatever - // else made the attempt fail. - var interruptionOfCaller = interruption.get(); - if (interruptionOfCaller != null) { - return failed(interrupted(interruptionOfCaller), removalFailures); - } - - var exitLog = currentAttempt.exitLog; - if (exitLog != null) { - return failed(exited(exitLog, failure.get()), removalFailures); - } - if (isReused || !currentAttempt.isUnreachable()) { - return failed(dockerFailed(failure.get()), removalFailures); - } - if (attemptNumber == MAX_START_ATTEMPTS) { - return failed(unreachable(failure.get()), removalFailures); - } + var exitLog = currentAttempt.exitLog; + if (exitLog != null) { + throw failed(exited(exitLog, failure.get()), removalFailures); + } + if (isReused || !currentAttempt.isUnreachable()) { + throw failed(dockerFailed(failure.get()), removalFailures); + } + if (attemptNumber == MAX_START_ATTEMPTS) { + throw failed(unreachable(failure.get()), removalFailures); } - } catch (RuntimeException ex) { - return Optional.of(dockerFailed(ex)); } } + // Tells whether the thread was interrupted, and clears its interrupt + // status, so that the interrupt does not cut the cleanup that follows + // short as well. Testcontainers clears the status itself in some places, + // and reports the interrupt as the cause of its failure instead. + private static boolean wasInterrupted(Throwable failure) { + var hadInterruptStatus = Thread.interrupted(); + return hadInterruptStatus + || Stream.iterate(failure, Objects::nonNull, Throwable::getCause) + .anyMatch(InterruptedException.class::isInstance); + } + // Starts the container of the given attempt with Testcontainers, and // returns the failure if that fails. A container that Testcontainers // reuses gets no label, which would keep Testcontainers from finding it @@ -480,12 +390,18 @@ protected void containerIsStopping(InspectContainerResponse containerInfo) { // Removes the containers of a failed attempt, and returns the failure if // that fails, so that it can be reported along with the failure of the - // start, without changing its message. - private Optional removeContainersOf(Attempt failedAttempt) { + // start, without changing its message. After an interrupt, it looks for + // them once more later. + private Optional removeContainersOf(Attempt failedAttempt, boolean isInterrupted) { // Testcontainers removes a container whose start failed itself, but - // stopping it releases whatever Testcontainers may still hold for it. + // stopping it releases whatever Testcontainers may still hold for it, + // and removes it if an interrupt kept Testcontainers from doing so. stop(); + if (isInterrupted) { + removeContainersLater(failedAttempt.id); + } + try { containerRemover.accept(failedAttempt.id); return Optional.empty(); @@ -494,8 +410,16 @@ private Optional removeContainersOf(Attempt failedAttempt) { } } - private void removeContainersWithLabel(String attemptId) { - var dockerClient = getDockerClient(); + // Removes the containers of an attempt after a delay, in the background. + // It knows the attempt only by its id, so that it does not touch the + // container, which may be started again by then. + private static void removeContainersLater(String attemptId) { + CompletableFuture.delayedExecutor(LATE_REMOVAL_DELAY.toNanos(), TimeUnit.NANOSECONDS) + .execute(() -> removeContainersWithLabel(attemptId)); + } + + private static void removeContainersWithLabel(String attemptId) { + var dockerClient = DockerClientFactory.instance().client(); dockerClient .listContainersCmd() @@ -509,9 +433,9 @@ private void removeContainersWithLabel(String attemptId) { .exec()); } - private static Optional failed(RuntimeException failure, List removalFailures) { + private static RuntimeException failed(RuntimeException failure, List removalFailures) { removalFailures.forEach(failure::addSuppressed); - return Optional.of(failure); + return failure; } private IllegalStateException unreachable(Exception cause) { @@ -524,8 +448,16 @@ private IllegalStateException unreachable(Exception cause) { cause); } - private static IllegalStateException interrupted(InterruptedException cause) { - return new IllegalStateException("failed to start the container: interrupted", cause); + // Restores the interrupt status, which the start cleared to clean up, + // and returns the exception for a start that an interrupt ended. The + // interrupt is its cause, which names the failure that the interrupt + // caused, if any. + private static IllegalStateException interrupted(@Nullable Throwable failure) { + Thread.currentThread().interrupt(); + + var interrupt = new InterruptedException(); + interrupt.initCause(failure); + return new IllegalStateException("failed to start the container: interrupted", interrupt); } private static ContainerLaunchException exited(String exitLog, Exception cause) { diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java index d08e4e4..a7ca08e 100644 --- a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java +++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java @@ -21,7 +21,7 @@ class AttemptTest { // had published the port anew. @Test void readsThePortAnewForEveryPing() { - var attempt = new Attempt(ENOUGH_TIME, () -> false); + var attempt = new Attempt(ENOUGH_TIME); var observations = new ArrayList<>(List.of( new Attempt.Observation(false, null), new Attempt.Observation(false, 1001), @@ -40,7 +40,7 @@ void readsThePortAnewForEveryPing() { @Test void endsTheWaitIfTheContainerExits() { - var attempt = new Attempt(ENOUGH_TIME, () -> false); + var attempt = new Attempt(ENOUGH_TIME); assertThrows( ContainerLaunchException.class, @@ -51,7 +51,7 @@ void endsTheWaitIfTheContainerExits() { @Test void endsTheWaitOnceTheStartupTimeoutRanOut() { - var attempt = new Attempt(Duration.ofMillis(300), () -> false); + var attempt = new Attempt(Duration.ofMillis(300)); var pingCount = new int[] {0}; var startedAt = System.nanoTime(); @@ -72,7 +72,7 @@ void endsTheWaitOnceTheStartupTimeoutRanOut() { // counts. @Test void countsTheTimeBeforeTheWaitBegins() { - var attempt = new Attempt(Duration.ZERO, () -> false); + var attempt = new Attempt(Duration.ZERO); assertThrows( ContainerLaunchException.class, @@ -85,7 +85,7 @@ void countsTheTimeBeforeTheWaitBegins() { // at once, and does not count as running out of time. @Test void endsTheWaitAtOnceIfDockerFails() { - var attempt = new Attempt(ENOUGH_TIME, () -> false); + var attempt = new Attempt(ENOUGH_TIME); var dockerFailure = new IllegalStateException("Docker failed"); var exception = assertThrows( @@ -101,31 +101,9 @@ void endsTheWaitAtOnceIfDockerFails() { assertFalse(attempt.isUnreachable()); } - // An interrupt of the caller cancels the start, which ends the wait - // without another look at the container, and does not count as running - // out of time. - @Test - void endsTheWaitAtOnceIfTheStartIsCanceled() { - var attempt = new Attempt(ENOUGH_TIME, () -> true); - var observationCount = new int[] {0}; - - assertThrows( - ContainerLaunchException.class, - () -> attempt.waitUntilReachable( - () -> { - observationCount[0]++; - return new Attempt.Observation(false, 1001); - }, - port -> true)); - - assertEquals(0, observationCount[0]); - assertFalse(attempt.hasRunOutOfTime); - assertFalse(attempt.isUnreachable()); - } - @Test void isUnreachableIfTheWaitRanOutOfTime() { - var attempt = new Attempt(ENOUGH_TIME, () -> false); + var attempt = new Attempt(ENOUGH_TIME); attempt.hasWaitBegun = true; attempt.wasRunning = true; attempt.hasRunOutOfTime = true; @@ -137,7 +115,7 @@ void isUnreachableIfTheWaitRanOutOfTime() { // container after a fixed time of its own, before the wait begins. @Test void isUnreachableIfTheContainerWasRunningWhenTestcontainersGaveUpBeforeTheWait() { - var attempt = new Attempt(ENOUGH_TIME, () -> false); + var attempt = new Attempt(ENOUGH_TIME); attempt.wasRunning = true; assertTrue(attempt.isUnreachable()); @@ -145,7 +123,7 @@ void isUnreachableIfTheContainerWasRunningWhenTestcontainersGaveUpBeforeTheWait( @Test void isNotUnreachableIfTheContainerWasNotRunning() { - var attempt = new Attempt(ENOUGH_TIME, () -> false); + var attempt = new Attempt(ENOUGH_TIME); assertFalse(attempt.isUnreachable()); } @@ -154,7 +132,7 @@ void isNotUnreachableIfTheContainerWasNotRunning() { // because Docker failed while the container was running. @Test void isNotUnreachableIfTheWaitEndedForAnotherReason() { - var attempt = new Attempt(ENOUGH_TIME, () -> false); + var attempt = new Attempt(ENOUGH_TIME); attempt.hasWaitBegun = true; attempt.wasRunning = true; @@ -163,6 +141,6 @@ void isNotUnreachableIfTheWaitEndedForAnotherReason() { @Test void hasAnIdOfItsOwn() { - assertNotEquals(new Attempt(ENOUGH_TIME, () -> false).id, new Attempt(ENOUGH_TIME, () -> false).id); + assertNotEquals(new Attempt(ENOUGH_TIME).id, new Attempt(ENOUGH_TIME).id); } } diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java index 8ba2ea2..44a75ea 100644 --- a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java +++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java @@ -30,9 +30,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.LockSupport; import org.junit.jupiter.api.Test; import org.testcontainers.DockerClientFactory; @@ -49,6 +47,10 @@ class ContainerTest { // they reuse only each other. private static final String REUSE_TEST_LABEL = "io.eventsourcingdb.client-java.reuse-test"; + // How long a start may take at most to give up once the caller was + // interrupted. + private static final Duration INTERRUPT_BOUND = Duration.ofSeconds(5); + private static final String EXITED_PREFIX = "failed to start the container, it exited before the database became reachable: "; @@ -409,37 +411,78 @@ void givesUpOnAnInterruptWhileItWaitsForTheDatabase() { assertEquals("failed to start the container: interrupted", exception.getMessage()); assertInstanceOf(InterruptedException.class, exception.getCause()); assertTrue(wasInterrupted); - assertTrue(elapsed.compareTo(Container.INTERRUPT_GRACE_PERIOD) < 0, elapsed::toString); + assertTrue(elapsed.compareTo(INTERRUPT_BOUND) < 0, elapsed::toString); - // The start gave up within the grace period, so it removed the - // container before it returned, and did not replace it. + // The start removed the container before it returned, and did not + // replace it. assertEquals(1, labels.size()); assertEquals(List.of(), existing(labels)); } - // Docker takes longer than the grace period to create the container here, - // and creates it only after the start gave up, so the start removes it in - // the background. Starting again waits for that. + // The interrupt cuts the call to create the container short. Docker may + // still create the container afterwards, after the start looked for the + // containers to remove, which the test does itself here a second later. + // The start looks for them once more later, and removes it then. @Test - void givesUpOnAnInterruptWhileDockerCreatesTheContainerAndRemovesItLater() { - var caller = Thread.currentThread(); - var interruptedAt = new AtomicLong(); - var hasFirstCreateGoneOn = new AtomicBoolean(); - var hadFirstCreateGoneOnAtSecond = new AtomicBoolean(); - var firstContainersAtSecond = new AtomicReference>(); + void removesAContainerThatDockerCreatesAfterAnInterruptCutTheCreateShort() { + var dockerClient = DockerClientFactory.instance().client(); + var lateContainerId = new CompletableFuture(); var container = new Container().withImageTag(ImageTag.get()); var labels = labelsOf(container); container.withCreateContainerCmdModifier(command -> { - if (labels.size() == 1) { - interruptedAt.set(System.nanoTime()); - caller.interrupt(); - pause(Container.INTERRUPT_GRACE_PERIOD.plusSeconds(2)); - hasFirstCreateGoneOn.set(true); - return; + var label = command.getLabels().get(Container.START_LABEL); + CompletableFuture.delayedExecutor(1, TimeUnit.SECONDS) + .execute(() -> lateContainerId.complete(dockerClient + .createContainerCmd(command.getImage()) + .withLabels(Map.of(Container.START_LABEL, label)) + .exec() + .getId())); + Thread.currentThread().interrupt(); + }); + + try { + var startedAt = System.nanoTime(); + IllegalStateException exception; + boolean wasInterrupted; + try { + exception = assertThrows(IllegalStateException.class, container::start); + } finally { + wasInterrupted = Thread.interrupted(); } + var elapsed = Duration.ofNanos(System.nanoTime() - startedAt); + + assertEquals(IllegalStateException.class, exception.getClass()); + assertEquals("failed to start the container: interrupted", exception.getMessage()); + assertInstanceOf(InterruptedException.class, exception.getCause()); + assertTrue(wasInterrupted); + assertTrue(elapsed.compareTo(INTERRUPT_BOUND) < 0, elapsed::toString); + + var lateContainer = lateContainerId.orTimeout(30, TimeUnit.SECONDS).join(); + assertEquals(List.of(lateContainer), existing(labels)); + + var deadline = System.nanoTime() + + Container.LATE_REMOVAL_DELAY.plusSeconds(10).toNanos(); + while (!existing(labels).isEmpty() && System.nanoTime() - deadline < 0) { + pause(Duration.ofMillis(100)); + } + + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); + } finally { + remove(labels); + } + } - hadFirstCreateGoneOnAtSecond.set(hasFirstCreateGoneOn.get()); - firstContainersAtSecond.set(existing(labels.subList(0, 1))); + // The attempt fails here without a call to Docker that would cut the + // interrupt short, so the interrupt status is still set once the attempt + // failed. The start removes the containers of the attempt all the same. + @Test + void removesTheContainersOfAnAttemptThatAnInterruptEnded() { + var container = new Container().withImageTag(ImageTag.get()); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while configuring the container"); }); IllegalStateException exception; @@ -449,43 +492,27 @@ void givesUpOnAnInterruptWhileDockerCreatesTheContainerAndRemovesItLater() { } finally { wasInterrupted = Thread.interrupted(); } - var elapsed = Duration.ofNanos(System.nanoTime() - interruptedAt.get()); - assertEquals(IllegalStateException.class, exception.getClass()); assertEquals("failed to start the container: interrupted", exception.getMessage()); - assertInstanceOf(InterruptedException.class, exception.getCause()); assertTrue(wasInterrupted); - assertTrue(elapsed.compareTo(Container.INTERRUPT_GRACE_PERIOD) >= 0, elapsed::toString); - assertTrue(elapsed.compareTo(Container.INTERRUPT_GRACE_PERIOD.plusSeconds(2)) < 0, elapsed::toString); - - container.start(); - try { - assertTrue(hadFirstCreateGoneOnAtSecond.get()); - assertEquals(List.of(), firstContainersAtSecond.get()); - container.getClient().ping(); - } finally { - container.stop(); - } - - assertEquals(2, labels.size()); + assertArrayEquals(new Throwable[0], exception.getSuppressed()); + assertEquals(1, labels.size()); assertEquals(List.of(), existing(labels)); } - // Pulling the image takes longer than the grace period here. The start - // goes on pulling in the background, but creates no container after it, - // and stopping waits for that. + // Testcontainers waits for the image here, which takes a minute, and + // reports the interrupt as the cause of its failure. @Test - void givesUpOnAnInterruptWhileItPullsTheImageWithoutCreatingAContainer() { + void givesUpOnAnInterruptWhileItPullsTheImage() { var caller = Thread.currentThread(); - var pull = CompletableFuture.supplyAsync( - () -> "thenativeweb/eventsourcingdb:" + ImageTag.get(), - CompletableFuture.delayedExecutor( - Container.INTERRUPT_GRACE_PERIOD.plusSeconds(3).toMillis(), TimeUnit.MILLISECONDS)); var container = new Container(); - container.setImage(pull); + container.setImage(CompletableFuture.supplyAsync( + () -> "thenativeweb/eventsourcingdb:" + ImageTag.get(), + CompletableFuture.delayedExecutor(1, TimeUnit.MINUTES))); var labels = labelsOf(container); CompletableFuture.delayedExecutor(1, TimeUnit.SECONDS).execute(caller::interrupt); + var startedAt = System.nanoTime(); IllegalStateException exception; boolean wasInterrupted; try { @@ -493,23 +520,39 @@ void givesUpOnAnInterruptWhileItPullsTheImageWithoutCreatingAContainer() { } finally { wasInterrupted = Thread.interrupted(); } + var elapsed = Duration.ofNanos(System.nanoTime() - startedAt); assertEquals("failed to start the container: interrupted", exception.getMessage()); + assertInstanceOf(InterruptedException.class, exception.getCause()); assertTrue(wasInterrupted); - assertFalse(pull.isDone()); + assertTrue(elapsed.compareTo(INTERRUPT_BOUND) < 0, elapsed::toString); + assertEquals(List.of(), labels); + } - container.stop(); + // Users configure the container with modifiers, which must run where + // Testcontainers runs them, on the thread that starts the container. + @Test + void runsModifiersOnTheThreadOfTheCaller() { + var modifierThreads = new CopyOnWriteArrayList(); + var container = new Container() + .withImageTag(ImageTag.get()) + .withCreateContainerCmdModifier(command -> modifierThreads.add(Thread.currentThread())); - assertTrue(pull.isDone()); - assertEquals(List.of(), labels); + container.start(); + try { + assertEquals(List.of(Thread.currentThread()), modifierThreads); + } finally { + container.stop(); + } } + // The container resolved its image already, so the start needs no call to + // Docker that the interrupt could cut short before it asks Docker to + // create a container. @Test void givesUpAtOnceIfTheThreadIsInterruptedAlready() { - var container = new Container(); - container.setImage(CompletableFuture.supplyAsync( - () -> "thenativeweb/eventsourcingdb:" + ImageTag.get(), - CompletableFuture.delayedExecutor(1, TimeUnit.MINUTES))); + var container = new Container().withImageTag(ImageTag.get()); + container.getDockerImageName(); var labels = labelsOf(container); Thread.currentThread().interrupt(); @@ -615,7 +658,7 @@ void rejectsAStartupTimeoutThatIsNotPositive() { } // An error is no failure that the start handles, so it passes it on as it - // is, although the start does not run on the thread of the caller. + // is. @Test void passesOnAnErrorThatEndsTheStart() { var error = new AssertionError("broken"); From 8386387ff8ec3231191ad460ea308116710cd6e6 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Mon, 19 Oct 2026 15:33:24 +0200 Subject: [PATCH 5/6] chore: Remove the late container of the interrupt test also if the test fails before it is created. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH --- .../eventsourcingdb/testcontainers/ContainerTest.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java index 44a75ea..0d46f2a 100644 --- a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java +++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java @@ -469,6 +469,12 @@ void removesAContainerThatDockerCreatesAfterAnInterruptCutTheCreateShort() { assertEquals(1, labels.size()); assertEquals(List.of(), existing(labels)); } finally { + // The test creates the late container itself, so it waits for it + // before it cleans up, even if an assertion failed earlier. + lateContainerId + .orTimeout(30, TimeUnit.SECONDS) + .exceptionally(error -> "") + .join(); remove(labels); } } From 300aedfabb4e8bfeaa4322743e42c81a118375d1 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Mon, 19 Oct 2026 15:52:55 +0200 Subject: [PATCH 6/6] chore: Say in the Javadoc of start that an interrupt while Testcontainers pulls the image can get lost. Testcontainers catches an interrupt while it waits for a pull, which clears the interrupt status, and pulls the image again. An interrupt 700 ms into a real pull of an image that was not there let the start succeed, with the interrupt status cleared. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH --- .../thenativeweb/eventsourcingdb/testcontainers/Container.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java index 4f9146e..b48bc83 100644 --- a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java @@ -231,7 +231,8 @@ private static byte[] pem(PrivateKey privateKey) { *

If the calling thread is interrupted before the instance answers pings, the start gives up, removes the * containers of its attempt, and throws an {@link IllegalStateException}, and the interrupt status stays set. As * Docker may still create a container for a call that the interrupt cut short, the start looks for containers to - * remove once more a few seconds later, in the background. + * remove once more a few seconds later, in the background. Testcontainers pulls the image again after an interrupt, + * though, and clears the interrupt status, so an interrupt while it pulls the image can get lost. * * @throws IllegalStateException if the database did not become reachable in any of three containers * @throws RuntimeException if the container does not start for another reason, e.g. because the image does not