Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3b4c1d3
feat!: Report every error the server reports as a DbApiException with…
goloroden Oct 18, 2026
bc051b5
feat!: Report every stall with a ConnectionStalledException, and remo…
goloroden Oct 18, 2026
609fc23
feat!: Report a write that may have been stored nevertheless with an …
goloroden Oct 18, 2026
9385fe8
feat!: Name the type of an event type type, and tell whether it has e…
goloroden Oct 18, 2026
0860941
feat!: Read subjects with options that require recursive, as the v2 A…
goloroden Oct 18, 2026
8ef57a8
feat: Use the v2 API whenever the server offers it, and the v1 API ot…
goloroden Oct 18, 2026
c75c058
fix: Use the v1 API if the probe is answered with 404 or 405, whichev…
goloroden Oct 18, 2026
993a8f2
chore: Test that the subject alone is kept as the server names it, e.…
goloroden Oct 18, 2026
0050687
chore: Test against the database that the client chooses the API it o…
goloroden Oct 18, 2026
8c8b0c5
chore: Describe in the README how to upgrade from version 1.
goloroden Oct 18, 2026
5fc56c0
chore: Turn the event types of the v1 API into EventType within V1Tra…
goloroden Oct 18, 2026
071a4e2
chore: Test that a page with which a proxy answers the probe with 404…
goloroden Oct 18, 2026
9a28e03
fix: Report a write that could not connect as unknown if the JDK send…
goloroden Oct 18, 2026
b892a50
chore: Say that a write interrupted while the client still connects e…
goloroden Oct 18, 2026
9f9da32
fix: Close the body of a stream that is closed after its headers arri…
goloroden Oct 18, 2026
1fbce41
fix: Read whether the JDK sends writes again as the JDK does, from ne…
goloroden Oct 18, 2026
6f0e79d
fix: Replace a test container whose database does not become reachabl…
goloroden Oct 19, 2026
046fbe7
fix: Read whether the JDK sends writes again when a client is created…
goloroden Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 159 additions & 20 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@
import java.util.Base64;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import org.jspecify.annotations.Nullable;
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;

Expand Down Expand Up @@ -56,6 +59,16 @@ public final class Container extends GenericContainer<Container> {
// it.
Consumer<Container> starter = Container::startOnce;

// 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<Integer, WaitStrategy> pingWait =
port -> Wait.forHttp("/api/v1/ping").forPort(port).withStartupTimeout(Duration.ofSeconds(10));

// 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;

/**
* 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.
Expand Down Expand Up @@ -124,7 +137,7 @@ protected void configure() {
String.valueOf(internalPort)));

withExposedPorts(internalPort);
waitingFor(Wait.forHttp("/api/v1/ping").forPort(internalPort).withStartupTimeout(Duration.ofSeconds(10)));
waitingFor(new NotingWait(pingWait.apply(internalPort)));

if (signingKeyPair != null) {
withCopyToContainer(Transferable.of(pem(signingKeyPair.getPrivate()), 0777), SIGNING_KEY_PATH);
Expand Down Expand Up @@ -157,19 +170,19 @@ private static byte[] pem(PrivateKey privateKey) {
@Override
public void start() {
for (var attempt = 1; ; attempt++) {
wasUnreachableWhileRunning = false;
try {
starter.accept(this);
return;
} catch (RuntimeException ex) {
// A container that keeps running although starting it failed
// 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.
var isRunning = isRunning();
stop();

if (!isRunning) {
if (!wasUnreachableWhileRunning) {
throw ex;
}
if (attempt == MAX_START_ATTEMPTS) {
Expand All @@ -187,6 +200,32 @@ void startOnce() {
super.start();
}

// 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;

NotingWait(WaitStrategy wait) {
this.wait = wait;
}

@Override
public void waitUntilReady(WaitStrategyTarget target) {
try {
wait.waitUntilReady(target);
} catch (RuntimeException ex) {
wasUnreachableWhileRunning = target.isRunning();
throw ex;
}
}

@Override
public WaitStrategy withStartupTimeout(Duration startupTimeout) {
wait.withStartupTimeout(startupTimeout);
return this;
}
}

/**
* {@return the host name to reach the container at}
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@
import io.thenativeweb.eventsourcingdb.ClientOptions;
import io.thenativeweb.eventsourcingdb.EventCandidate;
import java.net.URI;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.testcontainers.DockerClientFactory;
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 tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.json.JsonMapper;

Expand Down Expand Up @@ -165,24 +169,58 @@ private static List<String> existing(List<String> containerIds) {
.toList();
}

// 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<String> containerIds = new ArrayList<>();
final List<ContainerLaunchException> 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;
}

assertTrue(target.isRunning());
var failure = new ContainerLaunchException("the database did not become reachable");
failures.add(failure);
throw failure;
}

@Override
public WaitStrategy withStartupTimeout(Duration startupTimeout) {
return this;
}
}

// 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.
@Test
void replacesAContainerWhoseDatabaseDoesNotBecomeReachable() {
var containerIds = new ArrayList<String>();
var container = new Container().withImageTag(ImageTag.get());

// Docker Desktop sometimes does not make the port of a container
// reachable from the host. The container keeps running, but starting
// it fails, as it does here twice.
container.starter = self -> {
self.startOnce();
containerIds.add(self.getContainerId());
if (containerIds.size() < 3) {
throw new ContainerLaunchException("the database did not become reachable");
}
var wait = new AtomicReference<UnreachableWait>();
container.pingWait = port -> {
var unreachableWait = new UnreachableWait(new Container().pingWait.apply(port), 2);
wait.compareAndSet(null, unreachableWait);
return wait.get();
};

container.start();
try {
var containerIds = wait.get().containerIds;
assertEquals(3, containerIds.size());
assertEquals(List.of(containerIds.get(2)), existing(containerIds));
container.getClient().ping();
Expand All @@ -193,29 +231,50 @@ void replacesAContainerWhoseDatabaseDoesNotBecomeReachable() {

@Test
void givesUpAfterThreeAttemptsAndSaysWhy() {
var containerIds = new ArrayList<String>();
var failures = new ArrayList<ContainerLaunchException>();
var container = new Container().withImageTag(ImageTag.get());

container.starter = self -> {
self.startOnce();
containerIds.add(self.getContainerId());
var failure = new ContainerLaunchException("the database did not become reachable");
failures.add(failure);
throw failure;
};
var wait = new UnreachableWait(new Container().pingWait.apply(3000), 3);
container.pingWait = port -> wait;

var exception = assertThrows(IllegalStateException.class, container::start);

assertEquals(
"failed to start container, the database did not become reachable in 3 attempts",
exception.getMessage());
assertEquals(failures.getLast(), exception.getCause());
assertEquals(3, containerIds.size());
assertEquals(List.of(), existing(containerIds));
assertTrue(causesOf(exception).contains(wait.failures.getLast()));
assertEquals(3, wait.containerIds.size());
assertEquals(List.of(), existing(wait.containerIds));
assertFalse(container.isRunning());
}

private static List<Throwable> causesOf(Throwable exception) {
var causes = new ArrayList<Throwable>();
for (var cause = exception.getCause(); cause != null; cause = cause.getCause()) {
causes.add(cause);
}
return causes;
}

// A startup timeout that is set on the container applies to its wait for
// pings.
@Test
void passesAStartupTimeoutOnToTheWaitForPings() {
var timeouts = new ArrayList<Duration>();
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;
}
});

assertEquals(wait, wait.withStartupTimeout(Duration.ofSeconds(30)));
assertEquals(List.of(Duration.ofSeconds(30)), timeouts);
}

@Test
void throwsAnyOtherFailureAtOnceAndRemovesTheContainer() {
var containerIds = new ArrayList<String>();
Expand Down
Loading
Loading