Skip to content

Keep test cluster logs out of Gradle cache - #22789

Open
NextbrickInc wants to merge 4 commits into
opensearch-project:mainfrom
NextbrickInc:fix/testclusters-cache-20226
Open

Keep test cluster logs out of Gradle cache#22789
NextbrickInc wants to merge 4 commits into
opensearch-project:mainfrom
NextbrickInc:fix/testclusters-cache-20226

Conversation

@NextbrickInc

Copy link
Copy Markdown

Description

Resolve JVM output paths against each test node's absolute log directory so shared Gradle distributions remain immutable. This keeps heap dumps, GC logs, and fatal error logs out of Gradle's transform cache.

Add regression coverage for all three JVM output paths.

Related Issues

Resolves #20226

Testing

./gradlew -Djapicmp.compare.version=3.8.0 :build-tools:test --tests 'org.opensearch.gradle.testclusters.OpenSearchNodeTests' :build-tools:spotlessJavaCheck :build-tools:check

Contributor

Shrey Narayan, NextBrick

Check List

  • Functionality includes testing.
  • API specification changes are not applicable.
  • Public documentation changes are not applicable.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@NextbrickInc
NextbrickInc requested a review from a team as a code owner August 20, 2026 09:50
@github-actions github-actions Bot added bug Something isn't working Build Build Tasks/Gradle Plugin, groovy scripts, build tools, Javadoc enforcement. v3.5.0 Issues and PRs related to version 3.5.0 labels Aug 20, 2026
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit c4cda68)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Performance Regression

canUseSharedDistribution() now unconditionally returns false, forcing every test cluster node to create a per-node distribution copy (via hard links when possible, otherwise full copies). Previously, on non-Windows platforms with no extra jars/modules/plugins, nodes could share the distribution directly. This may noticeably increase test setup time and disk usage in CI, particularly on filesystems where hard-linking is unavailable and full copies are required. The fix addresses a real correctness issue, but consider whether the JVM output paths could instead be rewritten to absolute node-local paths in the shared workspace's jvm.options override, preserving the fast path for the common case.

private boolean canUseSharedDistribution() {
    // The shared distribution is an artifact transform output. Gradle 8.6+ treats those workspaces as
    // immutable and validates them, so a node must not run with its OPENSEARCH_HOME inside one. The bin
    // scripts are executed with their working directory set to the distribution (see
    // runOpenSearchBinScriptWithInput) while inheriting the node's JVM options, so a relative option such
    // as -Xlog:gc*:file=logs/gc.log writes straight into the workspace. Always run from a node-local copy,
    // which setupNodeDistribution() creates with hard links where the filesystem allows.
    return false;
}

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 93c9547

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Remove dead shared-distribution code path

Since canUseSharedDistribution() now unconditionally returns false, consider
removing the method entirely along with its call sites and any now-dead code paths
that handle the shared distribution case. Leaving the method as a constant-return
stub obscures intent and leaves dead code branches in callers.

buildSrc/src/main/java/org/opensearch/gradle/testclusters/OpenSearchNode.java [598-602]

-private boolean canUseSharedDistribution() {
-    // A shared distribution lives in Gradle's immutable artifact transform cache. Copy it into workingDir so
-    // OpenSearch can write files such as logs without mutating the cache.
-    return false;
-}
+// Method removed; callers should be updated to always use the copied distribution path.
Suggestion importance[1-10]: 4

__

Why: Valid observation that canUseSharedDistribution() now always returns false, making callers' branches dead code. However, the suggestion is a refactoring hint rather than a critical fix, and the improved_code doesn't concretely show the required call-site changes.

Low

Previous suggestions

Suggestions up to commit 9e1423e
CategorySuggestion                                                                                                                                    Impact
General
Remove dead conditional distribution logic

Since canUseSharedDistribution() now always returns false, the method and any call
sites checking its result have become dead-code branches. Consider removing the
method entirely (and inlining the false path) to avoid confusion and keep the code
base clean; leaving it as-is is misleading since the name implies conditional
behavior.

buildSrc/src/main/java/org/opensearch/gradle/testclusters/OpenSearchNode.java [598-602]

 private boolean canUseSharedDistribution() {
-    // A shared distribution lives in Gradle's immutable artifact transform cache. Copy it into workingDir so
-    // OpenSearch can write files such as logs without mutating the cache.
+    // A shared distribution lives in Gradle's immutable artifact transform cache. Always copy it into workingDir
+    // so OpenSearch can write files such as logs without mutating the cache.
     return false;
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion identifies that canUseSharedDistribution() always returns false and could be inlined, but the improved_code is essentially identical to the existing_code (only a minor comment tweak), so it provides marginal value.

Low
Suggestions up to commit 9c1a53f
CategorySuggestion                                                                                                                                    Impact
General
Remove now-dead shared distribution code paths

Since canUseSharedDistribution() now unconditionally returns false, consider
removing the method and its callers (or the related shared-distribution branches) to
eliminate dead code paths that will never execute, which reduces maintenance burden
and prevents confusion about a feature that is effectively disabled.

buildSrc/src/main/java/org/opensearch/gradle/testclusters/OpenSearchNode.java [598-602]

+private boolean canUseSharedDistribution() {
+    // A shared distribution lives in Gradle's immutable artifact transform cache. Copy it into workingDir so
+    // OpenSearch can write files such as logs without mutating the cache.
+    return false;
+}
 
-
Suggestion importance[1-10]: 4

__

Why: The suggestion correctly notes that canUseSharedDistribution() always returns false, making the branch dead code. However, improved_code is identical to existing_code and the suggestion is exploratory rather than concrete, and keeping the method may be intentional for future re-enabling. Minor maintainability improvement.

Low
Suggestions up to commit a121842
CategorySuggestion                                                                                                                                    Impact
General
Verify removed version handling is unneeded

The removal of the Version version = getVersion(); line dropped version-specific
handling that may have been needed for JVM option expansions across different
OpenSearch versions. Verify that no version-dependent expansions were lost, or
restore the version-aware branching if required.

buildSrc/src/main/java/org/opensearch/gradle/testclusters/OpenSearchNode.java [1288-1295]

+static Map<String, String> jvmOptionExpansions(Path logPath) {
+    Map<String, String> expansions = new HashMap<>();
+    String heapDumpOrigin = "-XX:HeapDumpPath=data";
+    Path absoluteLogPath = logPath.toAbsolutePath();
+    expansions.put(heapDumpOrigin, "-XX:HeapDumpPath=" + absoluteLogPath);
+    expansions.put("logs/gc.log", absoluteLogPath.resolve("gc.log").toString());
+    expansions.put("-XX:ErrorFile=logs/hs_err_pid%p.log", "-XX:ErrorFile=" + absoluteLogPath.resolve("hs_err_pid%p.log"));
+    return expansions;
+}
 
-
Suggestion importance[1-10]: 3

__

Why: The suggestion asks to verify a removal but provides identical existing_code and improved_code, offering no actual change. It's a low-impact verification request.

Low

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for a121842: SUCCESS

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 71.62%. Comparing base (e8c8c09) to head (e0b7696).
⚠️ Report is 11 commits behind head on main.

Files with missing lines Patch % Lines
...opensearch/gradle/testclusters/OpenSearchNode.java 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main   #22789      +/-   ##
============================================
- Coverage     71.66%   71.62%   -0.05%     
+ Complexity    77365    77348      -17     
============================================
  Files          6170     6170              
  Lines        359698   359710      +12     
  Branches      52458    52460       +2     
============================================
- Hits         257782   257642     -140     
- Misses        81509    81628     +119     
- Partials      20407    20440      +33     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

expansions.put(heapDumpOrigin, "-XX:HeapDumpPath=" + relativeLogPath);
expansions.put("logs/gc.log", relativeLogPath.resolve("gc.log").toString());
expansions.put("-XX:ErrorFile=logs/hs_err_pid%p.log", "-XX:ErrorFile=" + relativeLogPath.resolve("hs_err_pid%p.log"));
Path absoluteLogPath = logPath.toAbsolutePath();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NextbrickInc the OpenSearch (by default) have restrictions on file system access, this is one of the reasons why workingDir is being used here, logs are always relative to it (as well as conf, repo, ...), the security agent we run with will enforce that. I am wondering if it is worth exploring canUseSharedDistribution() conditions instead as per issue problem statement? Thank you.

@NextbrickInc NextbrickInc Aug 26, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @reta - agreed that the JVM output paths must remain relative to workingDir. I reverted the
absolute-path approach and moved the fix to the distribution-selection decision.
The existing canUseSharedDistribution() predicate determines whether the distribution needs customization
(non-Windows, no extra jars, no modules, and no plugins), but it does not establish that the resolved
distribution is safe to use as a runtime directory. In the default unmodified-cluster case, the method returns
true and getDistroDir() points into Gradle's artifact transform cache - the case described in #20226.
The proposed change makes canUseSharedDistribution() return false. setupNodeDistribution() therefore
hard-links the distribution into the node workingDir, with a copy fallback, and getDistroDir() resolves to that
node-local distribution. JVM log, heap-dump, and fatal-error paths remain relative to workingDir, preserving
the security-agent boundary while preventing runtime writes under Gradle's immutable transform
workspace.
I also replaced the implementation-only unit coverage with TestClustersPlugin functional coverage. The tests
run two builds against one TestKit cache and verify an unchanged path/SHA-256 snapshot of the transformed
distribution. A three-node fixture verifies isolated node-local distros, relative JVM paths, and
start/stop/restart behavior. Locally, the final :build-tools:check completed all 67 integration tests with zero
failures, errors, or skips on Gradle 9.4.1 and Temurin JDK 21.
The tradeoff is that this disables shared-distribution reuse for all test clusters. My reasoning is that the
transform cache must be treated as strictly read-only, and the current startup path cannot guarantee that
invariant. The method remains the policy boundary so shared use can be re-enabled if runtime writes are
fully isolated in the future.

Pls find pdf below:
OpenSearch_PR_22789_Bug_Fix_Technical_Brief Shrey Narayan NextBricks .pdf

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 4feee83

@NextbrickInc
NextbrickInc force-pushed the fix/testclusters-cache-20226 branch from 4feee83 to a121842 Compare August 26, 2026 07:00
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit a121842

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for a121842: null

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9c1a53f

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 9c1a53f: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9e1423e

Signed-off-by: Shrey Narayan <employeenextbrick@gmail.com>
@NextbrickInc
NextbrickInc force-pushed the fix/testclusters-cache-20226 branch from 9e1423e to 93c9547 Compare August 26, 2026 10:30
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 93c9547

@github-actions

Copy link
Copy Markdown
Contributor

❕ Gradle check result for 93c9547: UNSTABLE

Please review all flaky tests that succeeded after retry and create an issue if one does not already exist to track the flaky failure.

Signed-off-by: Shrey Narayan <employeenextbrick@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit f66e91f

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for f66e91f: SUCCESS

@reta reta added v3.9.0 and removed v3.5.0 Issues and PRs related to version 3.5.0 labels Aug 27, 2026
// using original location can be too long due to MAX_PATH restrictions on windows CI
// TODO revisit when moving to shorter paths on CI by using Teamcity
return OS.current() != OS.WINDOWS && extraJarFiles.size() == 0 && modules.size() == 0 && plugins.size() == 0;
// A shared distribution lives in Gradle's immutable artifact transform cache. Copy it into workingDir so

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cwperks could you take a look if this change is even needed? (or we could only scope it to ARCHIVE distribution), it seems like #20241 is suggesting to use INTEG_TEST (but apparently it is still an issue with ARCHIVE), thanks a lot

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reproduced #20226 end to end, and the answer to the scoping question turned out to be the opposite of what we'd assumed: the cluster that corrupted the workspace was an INTEG_TEST cluster, not ARCHIVE.

Reproductioncross-cluster-replication @ main, ./gradlew integTest --no-daemon, Gradle 9.4.1, OpenSearch 3.9.0-SNAPSHOT. After the first build:

~/.gradle/caches/9.4.1/transforms/fa34e8b315277f51e6deb23ecde213c8/
  transformed/opensearch-3.9.0-SNAPSHOT.zip/opensearch-3.9.0-SNAPSHOT/logs/gc.log
  transformed/opensearch-3.9.0-SNAPSHOT.zip/opensearch-3.9.0-SNAPSHOT/logs/gc.log.00

The second build then fails exactly as the issue reports:

The contents of the immutable workspace
'.../transforms/fa34e8b315277f51e6deb23ecde213c8' have been modified.
outputDirectory:
       - logs (Directory, ca2c9eac4ec3987d9f770207005e0a55)
         - gc.log    (RegularFile, a215e10a6bcb2b1a4a95a95a629c10df)
         - gc.log.00 (RegularFile, fb07ccfe57e8380a144cca09ebe334b2)

Which cluster did it. From the node command lines of that same build — integTest-0:

-Dopensearch.path.home=~/.gradle/caches/9.4.1/transforms/fa34e8b3.../transformed/
                       opensearch-3.9.0-SNAPSHOT.zip/opensearch-3.9.0-SNAPSHOT
-Dopensearch.distribution.type=zip   -Dopensearch.bundled_jdk=false
-Xlog:gc*,...:file=logs/gc.log

versus leaderCluster-0:

-Dopensearch.path.home=~/ccr-repro/build/testclusters/leaderCluster-0/distro/3.9.0-ARCHIVE
-Dopensearch.distribution.type=tar
cluster distribution ran from polluted
integTest-0 INTEG_TEST transform workspace yes
leaderCluster-0/1 ARCHIVE node-local copy no
followCluster-0/1 ARCHIVE node-local copy no

The ARCHIVE clusters escaped only because CCR installs plugins into them, so plugins.size() != 0 forces a copy — incidental, not protective. An ARCHIVE-scoped guard would have left this exact failure in place, and it also means #20241's "use INTEG_TEST" points into the bug rather than around it.

Mechanism — and this is a little different from what the issue assumes. It isn't the node process; as you noted earlier, its working directory is the node's own workingDir and its logs stay relative to that. It's the bin scripts: runOpenSearchBinScriptWithInput() sets spec.workingDir(getDistroDir()) and passes getOpenSearchEnvironment(), so opensearch-keystore and opensearch-plugin execute inside the distribution while inheriting the node's JVM options. -Xlog:gc*:file=logs/gc.log is relative, so it resolves into the workspace. The inheritance is directly observable: in one run opensearch-plugin failed with Port already in use: 7777, the node's JMX agent port.

Verification of the fix. Rebuilt build-tools from this branch, published to mavenLocal, cleared the polluted workspace and re-ran the same CCR integTest. integTest-0 now runs from its own distro/3.9.0-INTEG_TEST copy, no node has path.home inside a transform workspace, and the transform cache holds zero gc.log files — the GC log lands in build/testclusters/integTest-0/logs/ where it belongs.

A correction on my own PR. The functional test I added doesn't demonstrate any of this, and can't: the fixture's bin/opensearch is exec echo "opensearch script executed!" and never starts a JVM, so no GC log is written in either code state and both cache assertions pass on main too. Restoring the previous gate and running my own test gives CACHE-CHANGED: false with no log keys; the only assertion that changed between states was assertCustomDistro, which asserts the implementation rather than the property. That is also why this class of bug isn't reachable from the buildSrc harness at all — the fake opensearch-keystore never starts a JVM either. I've removed those assertions in e0b7696 and let the reproduction carry the evidence; the change is now +6/−67, with the production behaviour unchanged and the comment explaining the workspace-immutability reason.

If the reproduction settles the question, please review and approve so this can be merged. If you'd still prefer something narrower now that the mechanism is pinned down, the remaining option I can see is running the bin scripts with their working directory outside the distribution rather than disabling the shared distribution — happy to do that instead if you'd rather keep the optimisation. @cwperks, tagging you as requested.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit e0b7696

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for e0b7696: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for e0b7696: SUCCESS

@NextbrickInc

Copy link
Copy Markdown
Author

@reta @dbwiddis @cwperks — some history, since this has had several attempts.

This is the permanent form of what #20227 already shipped. That PR set canUseSharedDistribution() to return false on the 3.4 branch as a temporary workaround, and flipped assertNoCustomDistro to assert the distro does exist. This PR makes those same two changes on main, with the reasoning recorded on the method and regression coverage added. #20226 is still open because that permanent step was never taken.

Mechanism. The write doesn't come from the node process — its working directory is the node's own workingDir. It comes from the bin tools: runOpenSearchBinScriptWithInput() sets spec.workingDir(getDistroDir()) and passes getOpenSearchEnvironment(), so opensearch-keystore and opensearch-plugin execute inside the distribution while inheriting the node's JVM options. -Xlog:gc*:file=logs/gc.log is relative, so it resolves against the transform workspace. That the tools inherit those options is directly observable — in one run opensearch-plugin failed with Port already in use: 7777, the node's JMX agent port.

That's why the fix belongs at distribution selection rather than at the node: the node was never the writer. I tried #20229's working-directory approach first and reverted it — @reta's inline point about the security agent enforcing workingDir-relative paths is decisive.

On scoping to ARCHIVE. Reproduced against cross-cluster-replication @ main, Gradle 9.4.1, OpenSearch 3.9.0-SNAPSHOT. The corrupting cluster is INTEG_TEST:

Cluster | Distribution | Ran from | Polluted -- | -- | -- | -- integTest-0 | INTEG_TEST | transform workspace | yes leaderCluster-0/-1 | ARCHIVE | node-local copy | no followCluster-0/-1 | ARCHIVE | node-local copy | no
~/.gradle/caches/9.4.1/transforms/fa34e8b315277f51e6deb23ecde213c8/
  transformed/opensearch-3.9.0-SNAPSHOT.zip/opensearch-3.9.0-SNAPSHOT/logs/gc.log

The ARCHIVE clusters escaped only because CCR installs plugins into them, making plugins.size() != 0 and forcing a copy — incidental, not protective. An ARCHIVE-only guard would leave the reported case broken.

Why no test fails without this change. The buildSrc functional harness can't reach the defect: its fake opensearch-keystore is a stub that never starts a JVM, so no GC log is written in either state. I removed the cache-comparison assertions I'd originally added for that reason — they passed on main too.

On the automated dead-code note — I kept the predicate as a named method because the comment on it is the only record of the immutable-workspace constraint. Happy to inline it.

gradle check is green on e0b7696. #20227 already validated this exact change on 3.4; this is the same change on main with the reasoning recorded.

Please approve and merge. Thanks alot.

OpenSearch_PR22789_Reproduction_Report_NextBricks_Shrey_Narayan.pdf

Reproducing GITHUB#20226 against cross-cluster-replication shows the write
does not come from the node process, whose working directory is the node's
own workingDir. It comes from the bin scripts: runOpenSearchBinScriptWithInput
sets the working directory to the distribution and passes the node
environment, so opensearch-keystore and opensearch-plugin run inside the
distribution while inheriting the node's JVM options. A relative option such
as -Xlog:gc*:file=logs/gc.log then resolves into Gradle's artifact transform
workspace, which Gradle 8.6+ validates as immutable.

Record that in the comment on canUseSharedDistribution(); the behaviour is
unchanged.

Also remove the functional test added earlier. It cannot fail: the fixture's
bin/opensearch is `exec echo "opensearch script executed!"` and never starts a
JVM, so no GC log is written in either code state and both cache assertions
pass on main as well. The only assertion that changed between states was
assertCustomDistro, which asserts the implementation rather than the property.
The reproduction recorded on the pull request carries the evidence instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Shrey Narayan <employeenextbrick@gmail.com>
@NextbrickInc
NextbrickInc force-pushed the fix/testclusters-cache-20226 branch from e0b7696 to 69b70c4 Compare August 30, 2026 06:51
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 69b70c4

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 69b70c4: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit c4cda68

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for c4cda68: null

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working Build Build Tasks/Gradle Plugin, groovy scripts, build tools, Javadoc enforcement. v3.9.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] TestClusters plugin corrupts Gradle 9.x transform cache when canUseSharedDistribution() is true

3 participants