From 1b6900afd907ecd193793d98dfcd9d515f08f4e9 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Fri, 23 Oct 2026 18:21:05 +0200 Subject: [PATCH 1/2] fix: Wait for the database in the test container through the v2 API, and through the v1 API only where the server has no v2 API. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiuYmw4N6SSzfFyrwftUEj --- .../testcontainers/Container.java | 48 ++++-- .../testcontainers/ContainerTest.java | 163 +++++++++++++++--- 2 files changed, 178 insertions(+), 33 deletions(-) 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 b48bc83..4a5a4c2 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 @@ -78,9 +78,14 @@ public final class Container extends GenericContainer { // container that exited. static final int LOG_TAIL_LINES = 20; + // The paths of the pings of the v2 and the v1 API, neither of which + // needs the API token. + private static final String V2_PING_PATH = "/api/v2/system/ping"; + private static final String V1_PING_PATH = "/api/v1/ping"; + // How long a ping of the database may take before it counts as // unanswered. - private static final Duration PING_TIMEOUT = Duration.ofSeconds(1); + static final Duration PING_TIMEOUT = Duration.ofSeconds(1); // 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 @@ -333,8 +338,7 @@ protected void waitUntilContainerStarted() { Objects.requireNonNull(attempt) .waitUntilReachable( this::observe, - port -> answersPing( - httpClient, URI.create("http://" + super.getHost() + ":" + port + "/api/v1/ping"))); + port -> answersPing(httpClient, URI.create("http://" + super.getHost() + ":" + port))); } } @@ -356,22 +360,40 @@ private Attempt.Observation observe() { .orElse(null)); } - // 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(); - + // Tells whether the database answers a ping with 200 OK. It pings the v2 + // API, and the v1 API only if the server answers that with 404 or 405, as + // a server without the v2 API does, so that the wait works against + // servers with either API, or both. Any other answer, and no answer at + // all, tell nothing about the API, and mean only that the database is not + // reachable yet. Every call starts with the v2 API, rather than + // remembering that the server had none: that costs one more ping per + // attempt against such a server, and spares the attempts any state that + // they share. + static boolean answersPing(HttpClient httpClient, URI baseUrl) { try { - return httpClient - .sendAsync(request, HttpResponse.BodyHandlers.discarding()) - .join() - .statusCode() - == 200; + return switch (statusOfPing(httpClient, baseUrl.resolve(V2_PING_PATH))) { + case 200 -> true; + case 404, 405 -> statusOfPing(httpClient, baseUrl.resolve(V1_PING_PATH)) == 200; + default -> false; + }; } catch (CompletionException ex) { return false; } } + // Returns the status code of the answer to a ping, and throws a + // CompletionException if no answer arrives, e.g. because the connection + // is refused while the database starts, or because the answer takes + // longer than the ping may. + private static int statusOfPing(HttpClient httpClient, URI pingUrl) { + var request = HttpRequest.newBuilder(pingUrl).timeout(PING_TIMEOUT).build(); + + return httpClient + .sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .join() + .statusCode(); + } + // 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. 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 0d46f2a..162dfd4 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 @@ -22,17 +22,23 @@ import java.net.http.HttpClient; import java.time.Duration; import java.util.ArrayList; +import java.util.Arrays; 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.ConcurrentLinkedDeque; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; 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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.ContainerFetchException; import org.testcontainers.containers.ContainerLaunchException; @@ -54,6 +60,14 @@ class ContainerTest { private static final String EXITED_PREFIX = "failed to start the container, it exited before the database became reachable: "; + private static final String V1_PING_PATH = "/api/v1/ping"; + private static final String V2_PING_PATH = "/api/v2/system/ping"; + + // How long a test waits for a fake database, which answers at once, + // except for a ping it answers too late, so that a wait that never ends + // fails within seconds. + private static final Duration FAKE_DATABASE_STARTUP_TIMEOUT = Container.PING_TIMEOUT.multipliedBy(5); + @Test void startsHandsOutAClientAndStops() { var container = new Container().withImageTag(ImageTag.get()); @@ -683,12 +697,67 @@ void passesOnAnErrorThatEndsTheStart() { @Test void takesOnly200OkAsAnAnswerToAPing() throws IOException { - assertTrue(answersPingWith(200)); - assertFalse(answersPingWith(204)); - assertFalse(answersPingWith(503)); + assertTrue(answersPingWith(Map.of(V2_PING_PATH, answers(200)))); + assertFalse(answersPingWith(Map.of(V2_PING_PATH, answers(204)))); + assertFalse(answersPingWith(Map.of(V2_PING_PATH, answers(404), V1_PING_PATH, answers(204)))); assertFalse(answersPingWithoutServer()); } + // A server without the v2 API, such as EventSourcingDB 1.2, answers its + // ping with 404 or 405. + @ParameterizedTest + @ValueSource(ints = {404, 405}) + void waitsThroughTheV1PingIfTheServerHasNoV2Api(int statusCode) throws IOException { + var pingedPaths = pingUntilReachable(Map.of(V2_PING_PATH, answers(statusCode), V1_PING_PATH, answers(200))); + + assertEquals(List.of(V2_PING_PATH, V1_PING_PATH), pingedPaths); + } + + @Test + void waitsThroughTheV2PingWithoutAskingTheV1Ping() throws IOException { + var pingedPaths = pingUntilReachable(Map.of(V2_PING_PATH, answers(200), V1_PING_PATH, answers(200))); + + assertEquals(List.of(V2_PING_PATH), pingedPaths); + } + + // A server without the v1 API answers its ping with 404. + @Test + void waitsThroughTheV2PingIfTheServerHasNoV1Api() throws IOException { + var pingedPaths = pingUntilReachable(Map.of(V2_PING_PATH, answers(200), V1_PING_PATH, answers(404))); + + assertEquals(List.of(V2_PING_PATH), pingedPaths); + } + + // Only 404 and 405 tell that the server has no v2 API. Any other answer + // to the v2 ping tells only that the database is not reachable yet. + @Test + void pingsTheV2ApiAgainWithoutAskingTheV1PingIfTheV2PingFails() throws IOException { + var pingedPaths = pingUntilReachable(Map.of(V2_PING_PATH, answers(503, 200), V1_PING_PATH, answers(200))); + + assertEquals(List.of(V2_PING_PATH, V2_PING_PATH), pingedPaths); + } + + // The fake database answers the first v2 ping with 404 once the ping + // gave up on it, which would lead to the v1 ping if the wait took it. + @Test + void pingsTheV2ApiAgainWithoutAskingTheV1PingIfTheV2PingIsNotAnsweredInTime() throws IOException { + var lateAnswer = new Answer(404, Container.PING_TIMEOUT.multipliedBy(2)); + + var pingedPaths = pingUntilReachable( + Map.of(V2_PING_PATH, List.of(lateAnswer, new Answer(200, Duration.ZERO)), V1_PING_PATH, answers(200))); + + assertEquals(List.of(V2_PING_PATH, V2_PING_PATH), pingedPaths); + } + + // Each attempt asks the v2 API first, rather than remembering that the + // server did not offer it. + @Test + void startsEachAttemptWithTheV2Ping() throws IOException { + var pingedPaths = pingUntilReachable(Map.of(V2_PING_PATH, answers(404), V1_PING_PATH, answers(503, 200))); + + assertEquals(List.of(V2_PING_PATH, V1_PING_PATH, V2_PING_PATH, V1_PING_PATH), pingedPaths); + } + @Test void describesDurationsInSeconds() { assertEquals("30", Container.seconds(Duration.ofSeconds(30))); @@ -798,22 +867,12 @@ private static Throwable innermostCauseOf(Throwable exception) { 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); + // Asks a fake database with the given answers whether it answers a + // ping, once. + private static boolean answersPingWith(Map> answers) throws IOException { + try (var database = new FakeDatabase(answers); + var httpClient = HttpClient.newHttpClient()) { + return Container.answersPing(httpClient, URI.create("http://127.0.0.1:" + database.port())); } } @@ -826,7 +885,71 @@ private static boolean answersPingWithoutServer() throws IOException { } try (var httpClient = HttpClient.newHttpClient()) { - return Container.answersPing(httpClient, URI.create("http://127.0.0.1:" + port + "/api/v1/ping")); + return Container.answersPing(httpClient, URI.create("http://127.0.0.1:" + port)); + } + } + + // Waits for a fake database with the given answers, as the start waits + // for the database in a container, and returns the paths of the pings in + // the order they arrived. + private static List pingUntilReachable(Map> answers) throws IOException { + try (var database = new FakeDatabase(answers); + var httpClient = HttpClient.newHttpClient()) { + new Attempt(FAKE_DATABASE_STARTUP_TIMEOUT) + .waitUntilReachable( + () -> new Attempt.Observation(false, database.port()), + port -> Container.answersPing(httpClient, URI.create("http://127.0.0.1:" + port))); + + return List.copyOf(database.pingedPaths); + } + } + + private static List answers(int... statusCodes) { + return Arrays.stream(statusCodes) + .mapToObj(statusCode -> new Answer(statusCode, Duration.ZERO)) + .toList(); + } + + // An answer of the fake database to a ping, which it sends after the + // given delay. + private record Answer(int statusCode, Duration delay) {} + + // A server in place of the database, which gives the answers of a path + // to its pings in turn, the last one to every ping once the others ran + // out, and which notes the path of each ping as it arrives. Each ping + // runs on a thread of its own, so that a late answer neither holds up + // the next ping nor stopping the server. + private static final class FakeDatabase implements AutoCloseable { + private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + private final HttpServer server; + final List pingedPaths = new CopyOnWriteArrayList<>(); + + FakeDatabase(Map> answers) throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + answers.forEach((path, pathAnswers) -> { + var remainingAnswers = new ConcurrentLinkedDeque<>(pathAnswers); + server.createContext(path, exchange -> { + pingedPaths.add(exchange.getRequestURI().getPath()); + + var answer = + remainingAnswers.size() > 1 ? remainingAnswers.removeFirst() : remainingAnswers.getFirst(); + pause(answer.delay()); + exchange.sendResponseHeaders(answer.statusCode(), -1); + exchange.close(); + }); + }); + server.setExecutor(executor); + server.start(); + } + + int port() { + return server.getAddress().getPort(); + } + + @Override + public void close() { + server.stop(0); + executor.shutdown(); } } } From 1b8802716dfc1d4ade57a033baf0a349e9e46423 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Fri, 23 Oct 2026 18:50:09 +0200 Subject: [PATCH 2/2] chore: Call a round of pings in the wait a call rather than an attempt, and fix the wording of a comment on the fake database. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiuYmw4N6SSzfFyrwftUEj --- .../eventsourcingdb/testcontainers/Container.java | 7 +++---- .../eventsourcingdb/testcontainers/ContainerTest.java | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) 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 4a5a4c2..40fa12d 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 @@ -365,10 +365,9 @@ private Attempt.Observation observe() { // a server without the v2 API does, so that the wait works against // servers with either API, or both. Any other answer, and no answer at // all, tell nothing about the API, and mean only that the database is not - // reachable yet. Every call starts with the v2 API, rather than - // remembering that the server had none: that costs one more ping per - // attempt against such a server, and spares the attempts any state that - // they share. + // reachable yet. Each call starts with the v2 API, rather than + // remembering that the server had none: that costs one more ping per call + // against such a server, and spares the calls any state that they share. static boolean answersPing(HttpClient httpClient, URI baseUrl) { try { return switch (statusOfPing(httpClient, baseUrl.resolve(V2_PING_PATH))) { 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 162dfd4..f829985 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 @@ -749,10 +749,10 @@ void pingsTheV2ApiAgainWithoutAskingTheV1PingIfTheV2PingIsNotAnsweredInTime() th assertEquals(List.of(V2_PING_PATH, V2_PING_PATH), pingedPaths); } - // Each attempt asks the v2 API first, rather than remembering that the - // server did not offer it. + // Each call of answersPing asks the v2 API first, rather than + // remembering that the server did not offer it. @Test - void startsEachAttemptWithTheV2Ping() throws IOException { + void startsEachCallWithTheV2Ping() throws IOException { var pingedPaths = pingUntilReachable(Map.of(V2_PING_PATH, answers(404), V1_PING_PATH, answers(503, 200))); assertEquals(List.of(V2_PING_PATH, V1_PING_PATH, V2_PING_PATH, V1_PING_PATH), pingedPaths); @@ -918,7 +918,7 @@ private record Answer(int statusCode, Duration delay) {} // to its pings in turn, the last one to every ping once the others ran // out, and which notes the path of each ping as it arrives. Each ping // runs on a thread of its own, so that a late answer neither holds up - // the next ping nor stopping the server. + // the next ping nor holds up stopping the server. private static final class FakeDatabase implements AutoCloseable { private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); private final HttpServer server;