Skip to content

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. - #24

Merged
goloroden merged 6 commits into
mainfrom
feat/container-start-contract
Oct 19, 2026
Merged

goloroden merged 6 commits into
mainfrom
feat/container-start-contract

Conversation

@goloroden

@goloroden goloroden commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Implements the common start contract for the test container.

  • Readiness. start pings GET /api/v1/ping until it answers 200, and reads the mapped port anew from Docker on every poll.
  • Time. Each attempt gets the startup timeout, 30 seconds by default, from creating the container on. The image is pulled before the first attempt, so pulling does not count. withStartupTimeout(Duration) now changes it, and rejects zero and negative durations like the stall timeout of the client; before, configure() replaced the wait, so the setting had no effect.
  • Replacement. A container that runs, but whose database does not become reachable in time, is replaced, up to 3 containers in total. That includes Testcontainers giving up on a port that Docker does not publish, after its own fixed wait. A container that exits fails at once with the last 20 lines of its log, and a Docker error fails at once with Docker's error, chained as the cause.
  • Cleanup. Each attempt labels its container with io.eventsourcingdb.container-start=<random id>, and a failed attempt removes every container with that label, also one that Testcontainers does not know of. A failed removal is added as a suppressed exception, so the message stays as it is. While Testcontainers reuses containers (withReuse(true) and the environment allow it), the start adds no label, and neither replaces nor removes the container, as Testcontainers' reuse does not.
  • Interrupts. start runs on the caller's thread, so modifiers run where Testcontainers runs them, and an interrupt ends a pending call to Docker at once. The start then clears the interrupt status, so that its cleanup is not cut short, removes the attempt's containers by their label, restores the status, and throws an IllegalStateException with failed to start the container: interrupted. As Docker may still create a container for a create call that the interrupt cut short, a background task removes the attempt's containers once more five seconds later, by the label alone. A thread that is interrupted already fails before the pull. An error that ends the start reaches the caller as it is.

The tests use real Docker with short startup timeouts, plus unit tests for the wait and the ping. Mutations of each part of the change (accepting any 2xx, reading the port once, counting the pull, starting the clock at the wait, counting a Docker error as unreachable, skipping the label removal or the delayed one, keeping the interrupt status during cleanup, labeling a reused container) each make a test fail. make qa is green locally against latest and preview.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH

…t, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
@goloroden
goloroden requested a review from a team as a code owner October 19, 2026 11:37
@goloroden goloroden self-assigned this Oct 19, 2026
goloroden and others added 5 commits October 19, 2026 14:20
…ted, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…e 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…st fails before it is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…ners 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
@goloroden
goloroden merged commit a9e5b94 into main Oct 19, 2026
3 checks passed
@goloroden
goloroden deleted the feat/container-start-contract branch October 19, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant