IGNITE-28738 Implement basic Rolling Upgrade test using testcontainers#13184
Conversation
0ce5477 to
b7493e9
Compare
4f23dfa to
7c2a550
Compare
This reverts commit 0b6e0ef.
…path, faster shutdown (#1) - testClassesJar: inject each listed class together with its nested classes (the plugin provider's anonymous classes) and add DisabledRollingUpgradeProcessor / DisabledValidationProcessor. Without this the containerized nodes fail to start (NoClassDefFoundError) and the test times out after 600s on a clean checkout. - OS-aware networking. Keep the macOS path (host<->container via published ports + ContainerAddressResolver + host.docker.internal) and add a direct Linux path: containers are reached at their Docker bridge IP, the host node is reached at the Docker network gateway. Removes the Docker-Desktop VM-proxy latency on Linux/CI. - Container shutdown wait 60s -> 10s. Graceful SIGTERM does not complete (the image entrypoint does not forward it), so the node is force-stopped anyway; this saved ~50s per node (~423s -> ~250s end to end on macOS). - SOURCE_COMMIT_HASH overridable via -Dru.source.commit.hash. - assertTrue on the post-upgrade topology waitForCondition (was silently ignored); compare verified values as boxed Integer to avoid an NPE on a missing key. Verified green on macOS. The Linux path compiles and its addressing primitives are validated, but a full Linux/CI run is still required. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- IgniteContainer.stop(): drop the unconditional "shut down gracefully" log. The image entrypoint does not forward SIGTERM, so the await times out and the node is force-stopped via super.stop(); the message contradicted the preceding "Proceeding with forceful stop." warning. - IgniteContainer.testClassesJar(): null-check File.listFiles() (returns null on an unreadable dir) so a missing class directory fails with a clear IOException instead of an NPE in the loop. - IgniteRebalanceOnUpgradeTest: compare the post-put value as a boxed Integer too (the get(1000)-loop already does), so a missing key fails the assertion instead of NPE-ing on unboxing. - Scope the "bind communication to loopback" comment to macOS (on Linux it binds to 0.0.0.0) and drop a trailing-whitespace blank line. No behavioral change on the macOS path. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
anton-vinogradov
left a comment
There was a problem hiding this comment.
Final review. Built locally on Java 17: the compatibility module test-compiles against testcontainers 2.0.5 and passes strict checkstyle (-Pcheckstyle, 0 errors); RAT is fine (it treats .sh as binary). I could not run the end-to-end test (no Docker on my machine, and the source image has to be built manually).
Inline I've left a handful of ready-to-apply suggestions (unused param, a misleading comment, an unclosed Files.list stream, and the missing ASF header in build_docker_image.sh) plus a few higher-level points: a unit test + log for the core TcpDiscoverySpi change, a Docker-availability guard so a stray mvn test doesn't fail hard, the WIP default for ru.source.commit.hash, and stale jars in the in-place libs swap. Nothing blocking — mostly polish. Thanks for the thorough DEVNOTES!
| if (!node.equals(locNode)) | ||
| addrs.removeIf(addr -> addr.getAddress().isLoopbackAddress() && locNode.socketAddresses().contains(addr)); | ||
| addrs.removeIf(addr -> addr.getAddress() == null || | ||
| (addr.getAddress().isLoopbackAddress() && locNode.socketAddresses().contains(addr))); |
There was a problem hiding this comment.
This is the only production change in the PR, and it's a sensible NPE guard for unresolved addresses. A few asks:
- It's currently exercised only by the Docker smoke test, which isn't part of RunAll — could you add a focused unit test for
getEffectiveNodeAddresseswith an unresolvedInetSocketAddress(getAddress() == null)? - Dropping the address silently makes a fully-unresolvable node hard to diagnose; a
log.debug(...)on the removed address would help. - Since the rest of the PR is test infrastructure, consider landing this core change as its own commit / sub-ticket so it's reviewed independently.
There was a problem hiding this comment.
Without this change, the test will fail:
[c1099d16-e7d7-49f4-925c-53329286c444] java.lang.NullPointerException: Cannot invoke "java.net.InetAddress.isLoopbackAddress()" because the return value of "java.net.InetSocketAddress.getAddress()" is null
[c1099d16-e7d7-49f4-925c-53329286c444] at org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi.lambda$getEffectiveNodeAddresses$0(TcpDiscoverySpi.java:1272)
[c1099d16-e7d7-49f4-925c-53329286c444] at java.base/java.util.ArrayList.removeIf(Unknown Source)
[c1099d16-e7d7-49f4-925c-53329286c444] at java.base/java.util.ArrayList.removeIf(Unknown Source)
[c1099d16-e7d7-49f4-925c-53329286c444] at org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi.getEffectiveNodeAddresses(TcpDiscoverySpi.java:1272)
[c1099d16-e7d7-49f4-925c-53329286c444] at org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi.getEffectiveNodeAddresses(TcpDiscoverySpi.java:1253)
[c1099d16-e7d7-49f4-925c-53329286c444] at org.apache.ignite.spi.discovery.tcp.ServerImpl$SocketReader.pingJoiningNode(ServerImpl.java:7665)
[c1099d16-e7d7-49f4-925c-53329286c444] at org.apache.ignite.spi.discovery.tcp.ServerImpl$SocketReader.processJoinRequestMessage(ServerImpl.java:7602)
[c1099d16-e7d7-49f4-925c-53329286c444] at org.apache.ignite.spi.discovery.tcp.ServerImpl$SocketReader.body(ServerImpl.java:7076)
[c1099d16-e7d7-49f4-925c-53329286c444] at org.apache.ignite.spi.IgniteSpiThread.run(IgniteSpiThread.java:58)
There was a problem hiding this comment.
Thanks, but the NPE being real was never the question — the guard is correct and I'm happy for it to land. My asks were about how it lands, not whether:
- Unit test. This production path is currently exercised only by the Docker RU smoke test, which isn't part of RunAll — so nothing guards it in CI. A focused
getEffectiveNodeAddressestest with an unresolvedInetSocketAddress(getAddress() == null) asserting "no NPE + address filtered out" would lock the behavior in. - Diagnostics. Silently dropping a fully-unresolvable node is hard to debug; a
log.debug(...)on the removed address would help — this very stack trace is a good example of why, since otherwise you only see the join failure downstream. - Own sub-ticket / commit. Since it's the only production change in an otherwise test-infra PR, splitting it out gets it a discovery-maintainer review independent of the harness.
And please drop this stack trace into the JIRA ticket / commit message as the reproducer — it's exactly the context a reviewer needs.
There was a problem hiding this comment.
Added unit test org.apache.ignite.spi.discovery.tcp.TcpDiscoverySelfTest#testGetEffectiveNodeAddressesFiltersUnresolvedAddressWithoutNpe
There was a problem hiding this comment.
Great, thanks — the test wraps the node so socketAddresses() returns an unresolved InetSocketAddress and asserts both no-NPE and that it's filtered out, which is exactly the case. That closes ask (1).
Still open, though non-blocking from my side: (2) a log.debug(...) on the dropped address for diagnosability, and (3) landing this core change under its own sub-ticket so it gets a discovery-maintainer review independent of the harness.
TCBot Test Analysis
Possible Blockers (0)No blockers found. New Tests (4)
|
maksaska
left a comment
There was a problem hiding this comment.
Step 5/11 : COPY apache-ignite* apache-ignite
When using COPY with more than one source file, the destination must be a directory and end with a /
Error during Docker image build
Fix step COPY apache-ignite* apache-ignite for x86_64
Could we build the Docker image using HEAD instead of a specific pinned commit?
Currently, to build an older Ignite version, I have to manually modify step 5 of the Dockerfile directly on that older commit. Using HEAD would make this process dynamic and much simpler.
| .setBackups(1) | ||
| .setAtomicityMode(CacheAtomicityMode.TRANSACTIONAL); | ||
|
|
||
| ClientCache<Integer, Integer> cache = client(cluster.containers().get(0).clientAddress()).createCache(cfg); |
There was a problem hiding this comment.
2 issues here:
- If test failed to removed the work dir from previous run, the next try will reuse the data. Hence the errors:
[2026-07-13T13:49:58,072][ERROR][main][] Test failed [test=IgniteRebalanceOnUpgradeTest#testRollingUpgrade, duration=99234]
org.apache.ignite.client.ClientException: Ignite failed to process request [3]: Failed to start cache (a cache with the same name is already started): ru-test-cache (server status code [1001])
...
org.apache.ignite.testframework.junits.GridAbstractTest$6.run(GridAbstractTest.java:2488)
at java.base/java.lang.Thread.run(Thread.java:840)
Caused by: org.apache.ignite.internal.client.thin.ClientServerError: Ignite failed to process request [3]: Failed to start cache (a cache with the same name is already started): ru-test-cache (server status code [1001])
at org.apache.ignite.internal.client.thin.TcpClientChannel.processNextMessage(TcpClientChannel.java:581)
at
...
- I fail to remove the local work dir with my user without root.
There was a problem hiding this comment.
Fixed.
On Linux, the container is now started with the current host user's UID:GID (detected via id -u:id -g).
Docker supports numeric uid:gid in --user without the user existing inside the container's /etc/passwd, so this works out of the box.
| LOGGER.info("Graceful stop of node {}", hostname); | ||
|
|
||
| getDockerClient().stopContainerCmd(getContainerId()) | ||
| .withTimeout(30) |
There was a problem hiding this comment.
This approach might cause issues with Ignite's internal lifecycle. We should explicitly stop Ignite first to ensure a predictable shutdown procedure.
Currently, the test log show 'Failed to check connection to previous node', which indicates that a backward connection check was initiated during the teardown.
There was a problem hiding this comment.
The root cause of "Failed to check connection to previous node" is not that docker stop bypasses Ignite's lifecycle — it's that the 30-second timeout was too short for Ignite to complete its graceful shutdown.
How shutdown actually works:
The container entrypoint (run.sh) uses exec java, meaning the JVM replaces the shell process and becomes PID 1. When docker stop sends SIGTERM, the JVM receives it directly and triggers all registered shutdown hooks. Ignite's internal lifecycle (persistence flush, cluster leave, discovery notification, socket close) runs as part of these hooks.
Why the warning appeared:
"Failed to check connection to previous node" is logged inside TcpDiscoverySpi's internal implementation (ServerImpl.SocketReader.checkConnection) on remaining neighbor nodes. When they detect that a node left the cluster, they attempt to verify the connection to it. If the stopping node gets SIGKILLED (after the 30s timeout) before it can properly leave the discovery ring, neighbors perceive it as a sudden disappearance and initiate this check — hence the warning.
Fix:
Increased the docker stop timeout from 30 → 120 seconds (the standard recommended value for docker-java shutdown hooks used by eclipse-temurin images). This gives Ignite enough time to:
- Flush persistence data to disk
- Leave the discovery ring cleanly
- Notify neighboring nodes
- Close all socket connections
The 120s value is configurable via the SHUTDOWN_TIMEOUT_SEC constant.
No explicit "stop Ignite first" is needed because SIGTERM to JVM PID 1 is the explicit stop signal — it's the standard Docker way to trigger application shutdown hooks.
| public void upgradeAndRestart() throws Exception { | ||
| LOGGER.info("Cleaning up old libs in container {}", hostname); | ||
|
|
||
| ExecResult result = execInContainer("sh", "-c", "rm -f " + LIBS_DIR_PATH + "*"); |
There was a problem hiding this comment.
Why we remove libs on alive ignite node?
There was a problem hiding this comment.
The short answer: because you can't exec into a stopped Docker container.
The current sequence is:
1. execInContainer("rm -rf libs/*") ← container must be running
2. stopGraceful() ← container stops
3. copy new libs + restart ← container starts again
Why not reverse the order (stop first, then clean)?
If we stop the container first, we could still copy new jars via the Docker API (copyFileToContainer works on stopped containers). The order could be:
1. stopGraceful()
2. exec is NOT possible → but copyFileToContainer OVERWRITES files anyway
3. restart
However, copyFileToContainer copies individual files — it doesn't clean up jars that existed in the source image but are absent in the target. If the source version has extra jars the target doesn't need, they would linger.
I've addressed both points in build_docker_image.sh:
|
TCBot Test Analysis
Possible Blockers (0)No blockers found. New Tests (4)
|
Thank you for submitting the pull request to the Apache Ignite.
In order to streamline the review of the contribution
we ask you to ensure the following steps have been taken:
The Contribution Checklist
The description explains WHAT and WHY was made instead of HOW.
The following pattern must be used:
IGNITE-XXXX Change summarywhereXXXX- number of JIRA issue.(see the Maintainers list)
the
green visaattached to the JIRA ticket (see tabPR Checkat TC.Bot - Instance 1 or TC.Bot - Instance 2)Notes
If you need any help, please email dev@ignite.apache.org or ask anу advice on http://asf.slack.com #ignite channel.