<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[BaoKhang's publication]]></title><description><![CDATA[BaoKhang's publication]]></description><link>https://baokhang.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a5b81d55ebd45e7725fb67c/c57a65de-8d67-4299-8613-15ded8757f5e.png</url><title>BaoKhang&apos;s publication</title><link>https://baokhang.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 09 Nov 2026 12:40:15 GMT</lastBuildDate><atom:link href="https://baokhang.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Predictive Test Selection Without SaaS Lock-In: Storing TIA Indexes in GitHub Actions Cache or S3]]></title><description><![CDATA[Self-hosted test impact analysis for Maven CI: store the index in GitHub Actions cache or your own S3 bucket. Tested setup and gotchas.]]></description><link>https://baokhang.hashnode.dev/predictive-test-selection-without-saas-lock-in-storing-tia-indexes-in-github-actions-cache-or-s3</link><guid isPermaLink="true">https://baokhang.hashnode.dev/predictive-test-selection-without-saas-lock-in-storing-tia-indexes-in-github-actions-cache-or-s3</guid><category><![CDATA[Java]]></category><category><![CDATA[maven]]></category><category><![CDATA[Testing]]></category><category><![CDATA[Devops]]></category><category><![CDATA[GitHub Actions]]></category><category><![CDATA[AWS]]></category><dc:creator><![CDATA[BaoKhang]]></dc:creator><pubDate>Mon, 09 Nov 2026 10:18:16 GMT</pubDate><content:encoded><![CDATA[<p>Hosted predictive test selection products solve a real problem, and they come with a procurement conversation: another vendor, another bill, and a security review about sending build and test metadata to someone else's servers. Some teams clear that review. Plenty do not, or cannot justify the budget for a suite that is only slow, not on fire.</p>
<p>There is a smaller, older idea that needs none of it: <strong>test impact analysis from a saved dependency map, stored wherever you already store build state.</strong> This post shows a complete self-hosted CI architecture for it with <a href="https://github.com/baokhang83/blastradius">blastradius</a>, using either the GitHub Actions cache or a private S3 bucket. I ran both paths end to end on fresh clones so the setup, and the places it breaks, are tested rather than described.</p>
<h2>What actually gets stored</h2>
<p>Before you decide where it can live, look at what it is. After a base-branch build, blastradius writes an index that maps each test to the production classes it loaded. A single entry looks like this:</p>
<pre><code class="language-json">{
  "test": { "className": "demo.ParserCase34Test", "methodName": "applies" },
  "dependsOnClasses": ["demo.ParserCase34Test", "demo.Parser"]
}
</code></pre>
<p>The whole file also holds the commit SHA the index was built from, a timestamp, direct-invocation records, and a list of "ambient" classes (JDK and framework classes, mostly). It is class names and test names. It is not source code, test output or data. For my 120-test fixture the index was about 175 KB, and about 53 KB compressed in a cache tarball.</p>
<p>That is still metadata about your codebase's structure, so treat it as internal. Two practical rules from the project docs: do not put credentials or other secrets under <code>.blastradius/</code>, and remember that GitHub Actions caches are readable by pull-request workflows.</p>
<p>On "nothing leaves your infrastructure": the tool reads and writes a local directory or the S3 bucket you configure. I searched the plugin, core and S3 modules for hard-coded service endpoints and found none beyond the AWS SDK itself. That is a grep, not an audit, so do your own review if your policy requires one.</p>
<h2>The architecture</h2>
<pre><code> push to main                                   pull request
 ─────────────                                  ────────────
 mvn verify                                     restore index
   └─ TRACK: full suite + agent                   └─ SELECT: diff vs merge base
        │                                              │
        ▼                                              ▼
   .blastradius/&lt;main-sha&gt;/index.json ──store──▶  run only matching tests
        (GitHub Actions cache  or  S3 bucket)         (missing index → full suite)
</code></pre>
<p>Two jobs, one artifact:</p>
<ol>
<li><strong>Trunk job (TRACK).</strong> Runs the full suite with a Java agent attached and writes the index for that commit.</li>
<li><strong>PR job (SELECT).</strong> Restores the newest trunk index, diffs the PR against its Git merge base, and narrows Surefire/Failsafe to the tests whose recorded classes changed. If no usable index exists, it runs the full suite.</li>
</ol>
<p>Everything else is plumbing: how the index travels from job 1 to job 2.</p>
<h2>Option 1: GitHub Actions cache (no extra infrastructure)</h2>
<p>For most teams this is the right default. No bucket, no IAM, nothing outside GitHub.</p>
<pre><code class="language-yaml">name: build
on:
  push:
    branches: [main]
  pull_request:

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0            # the merge base must be reachable

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
          cache: maven

      - name: Restore Blastradius index
        id: blastradius-index
        uses: actions/cache/restore@v4
        with:
          path: .blastradius
          key: blastradius-index-${{ runner.os }}-${{ github.sha }}
          restore-keys: |
            blastradius-index-${{ runner.os }}-

      - run: mvn -B --no-transfer-progress verify

      - name: Save Blastradius index (main only)
        if: ${{ github.ref == 'refs/heads/main' &amp;&amp; success() &amp;&amp; steps.blastradius-index.outputs.cache-hit != 'true' }}
        uses: actions/cache/save@v4
        with:
          path: .blastradius
          key: blastradius-index-${{ runner.os }}-${{ github.sha }}
</code></pre>
<p>Why it is shaped this way:</p>
<ul>
<li><strong>Save only from successful <code>main</code> builds.</strong> PRs read an index made by the trusted base branch and never write one.</li>
<li><strong>Key by commit SHA.</strong> Each main build saves an immutable snapshot. A PR has a new SHA, so the exact lookup misses and <code>restore-keys</code> falls back to the newest <code>main</code> snapshot, which is what you want.</li>
<li><strong><code>success()</code> before saving.</strong> A failed build should not become tomorrow's baseline.</li>
</ul>
<p>And the plugin, with the base reference set to <code>origin/main</code> (see the first gotcha below for why):</p>
<pre><code class="language-xml">&lt;plugin&gt;
  &lt;groupId&gt;io.github.baokhang83.blastradius&lt;/groupId&gt;
  &lt;artifactId&gt;blastradius-maven-plugin&lt;/artifactId&gt;
  &lt;version&gt;0.4.0&lt;/version&gt;
  &lt;executions&gt;
    &lt;execution&gt;
      &lt;phase&gt;process-test-classes&lt;/phase&gt;
      &lt;goals&gt;&lt;goal&gt;select&lt;/goal&gt;&lt;/goals&gt;
    &lt;/execution&gt;
  &lt;/executions&gt;
  &lt;configuration&gt;
    &lt;baseRef&gt;origin/main&lt;/baseRef&gt;
  &lt;/configuration&gt;
&lt;/plugin&gt;
</code></pre>
<h2>Option 2: a private S3 bucket</h2>
<p>Use S3 when your runners cannot share a reliable workspace cache: self-hosted runners on different machines, several CI systems, or a cache you do not trust to stay warm. The index is written to and read from the bucket directly, so no cache steps are needed:</p>
<pre><code class="language-xml">&lt;configuration&gt;
  &lt;baseRef&gt;origin/main&lt;/baseRef&gt;
  &lt;indexStore&gt;s3&lt;/indexStore&gt;
  &lt;s3Bucket&gt;ci-dependency-indexes&lt;/s3Bucket&gt;
  &lt;s3Prefix&gt;blastradius&lt;/s3Prefix&gt;
  &lt;s3Region&gt;eu-central-1&lt;/s3Region&gt;
  &lt;!-- &lt;s3Endpoint&gt;https://minio.example.com&lt;/s3Endpoint&gt;  optional, S3-compatible stores --&gt;
&lt;/configuration&gt;
</code></pre>
<p>The object key is derived from the commit: <code>blastradius/.blastradius/&lt;commit-sha&gt;/index.json</code>. Credentials come from the standard AWS credential chain, so nothing secret goes in the POM.</p>
<p>For GitHub Actions, prefer OIDC and a short-lived role over long-lived access keys. The plugin needs only <code>s3:GetObject</code> and <code>s3:PutObject</code> on the prefix, and does not need to list the bucket:</p>
<pre><code class="language-json">{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:GetObject", "s3:PutObject"],
    "Resource": "arn:aws:s3:::&lt;BUCKET&gt;/&lt;PREFIX&gt;/*"
  }]
}
</code></pre>
<p>Give the <code>main</code> job a role that can write, and give PR jobs a separate read-only role (<code>s3:GetObject</code> only) if PRs may read the bucket at all. Do not grant either to untrusted fork pull requests; let those fall back to the full suite.</p>
<pre><code class="language-yaml">permissions:
  contents: read
  id-token: write

steps:
  - uses: actions/checkout@v4
    with:
      fetch-depth: 0
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::&lt;ACCOUNT_ID&gt;:role/blastradius-index-writer
      aws-region: eu-central-1
  - run: mvn -B --no-transfer-progress verify
</code></pre>
<h2>What I tested</h2>
<p>All runs used a small synthetic fixture (six utility classes, 60 two-test JUnit 5 classes, so 120 tests), Maven 3.9.12, JDK 25 and blastradius 0.4.0. Each job ran in a <strong>fresh clone with no shared files</strong>, mimicking <code>actions/checkout</code> on an empty runner. The PR changes one line in one utility class.</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>How the index travelled</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>Main job</td>
<td>tracking build on a fresh clone</td>
<td>TRACK, 120 / 120 tests, index written</td>
</tr>
<tr>
<td>PR job, cache restored</td>
<td><code>.blastradius/</code> extracted from the main job's tarball</td>
<td><strong>SELECT, 20 / 120 tests (83.3% skipped)</strong></td>
</tr>
<tr>
<td>PR job, cache miss</td>
<td>nothing restored</td>
<td><strong>FALLBACK</strong>, 120 / 120 tests, build passes</td>
</tr>
<tr>
<td>PR job, S3</td>
<td>read from the bucket the main job wrote to</td>
<td><strong>SELECT, 20 / 120 tests (83.3% skipped)</strong></td>
</tr>
</tbody></table>
<p>Caveats, so you read this correctly:</p>
<ul>
<li>The cache path was simulated by tarring and untarring <code>.blastradius/</code>, which is what <code>actions/cache</code> does. I did not run it on real GitHub-hosted runners.</li>
<li>The S3 path ran against a local S3-compatible server (moto), not real AWS S3. I did not test IAM, OIDC role assumption or bucket policies. The endpoint, bucket and prefix behavior is the same code path as MinIO or any S3-compatible store, but verify your IAM setup separately.</li>
<li>83.3% reflects an evenly partitioned toy project. The realistic figure is the <a href="https://baokhang.hashnode.dev/how-we-cut-maven-junit5-test-runs-61-percent-apache-repos">historical replay</a>: 61.4% fewer test executions across 800 commits on four open-source projects, counted as executions rather than wall-clock time.</li>
</ul>
<p>The cache-miss row is the safety property that matters for this architecture: <strong>losing the index costs you speed, never correctness.</strong> A missing, unreadable or mismatched index produces a full run, not a guess.</p>
<h2>Gotchas I hit</h2>
<p><strong>1. <code>actions/checkout</code> on a PR has no local <code>main</code>, and the build fails.</strong> On a pull request, <code>actions/checkout</code> leaves you on a detached commit with only remote-tracking refs. With <code>&lt;baseRef&gt;main&lt;/baseRef&gt;</code> the build failed outright:</p>
<pre><code>base reference "main" does not resolve
</code></pre>
<p>Setting <code>&lt;baseRef&gt;origin/main&lt;/baseRef&gt;</code> fixed it, and it still behaves as the base reference on the push-to-main job, where it is the current commit. Pair it with <code>fetch-depth: 0</code> so the merge base is reachable. Note that a <code>-DbaseRef=origin/main</code> flag did not override a <code>&lt;baseRef&gt;</code> hard-coded in the POM in my test, so set it in the POM (or make the POM read a property).</p>
<p><strong>2. Cache eviction turns into full runs.</strong> GitHub evicts caches that have not been accessed for a while and enforces a per-repository size limit. If <code>main</code> is quiet for long enough, the next PR misses and runs everything. That is safe but slow, so make sure something on <code>main</code> runs regularly (a scheduled workflow works) to keep a fresh snapshot.</p>
<p><strong>3. Tracking can fail without failing the build.</strong> If the tracking agent is not attached to the test JVM, the build passes and logs <code>tracking failed ... falling back to the full suite</code>, and no index is saved. I found this with JaCoCo plus an explicit <code>@{argLine}</code> in Surefire; the fix is in the JaCoCo post in this series. Whatever your storage choice, grep the main job's log for <code>[blastradius] TRACK</code> and for the absence of <code>tracking failed</code>.</p>
<p><strong>4. Forks.</strong> Fork PRs do not receive secrets or OIDC roles, so they cannot use S3, and they may not restore your cache. They fall back to the full suite, which is the correct behavior.</p>
<h2>Cache or S3?</h2>
<table>
<thead>
<tr>
<th></th>
<th>GitHub Actions cache</th>
<th>Private S3 bucket</th>
</tr>
</thead>
<tbody><tr>
<td>Extra infrastructure</td>
<td>none</td>
<td>bucket, IAM, optionally OIDC</td>
</tr>
<tr>
<td>Works across different runner fleets</td>
<td>no</td>
<td>yes</td>
</tr>
<tr>
<td>Survives long quiet periods on <code>main</code></td>
<td>no (eviction)</td>
<td>yes (until you delete it)</td>
</tr>
<tr>
<td>Access control</td>
<td>repo-level, readable by PR workflows</td>
<td>IAM policy per role</td>
</tr>
<tr>
<td>Best for</td>
<td>most GitHub-hosted setups</td>
<td>self-hosted runners, multi-CI, stricter access control</td>
</tr>
</tbody></table>
<p>Start with the cache. Move to S3 when eviction, runner topology or access control gives you a reason.</p>
<h2>What you give up versus a hosted service</h2>
<p>Be honest about the trade. A dependency-based tool like this one is deterministic and self-contained, and it has no model that learns from failure history, no hosted dashboard and no cross-project analytics. You own index freshness, and you own the policy for when to fall back. If those hosted features are what you need, and your security and budget allow it, a hosted product may be the better fit. If your constraint is "no new vendor, no data leaving our accounts", this is a workable alternative.</p>
<p>Either way, keep a regular full-suite run (daily is the recommendation) as a backstop. Selection is sound by default, not a guarantee.</p>
<h2>FAQ</h2>
<h3>Can I run predictive test selection in Maven without a SaaS product?</h3>
<p>Yes. Dependency-based test impact analysis needs only a saved map of which classes each test loads. Blastradius builds that map on your base branch and stores it in a local directory, the GitHub Actions cache or an S3 bucket you own.</p>
<h3>What data does the index contain?</h3>
<p>Test class and method names, the production class names each test loaded, the commit SHA and a timestamp. It does not contain source code, test output or data. It is still internal metadata, so keep it out of public buckets and do not store secrets alongside it.</p>
<h3>Do I need S3?</h3>
<p>No. The default file store plus a GitHub Actions cache is enough on GitHub-hosted runners. S3 is for runners that cannot share a cache, or when you want IAM-level access control.</p>
<h3>What happens if the cache or bucket is empty?</h3>
<p>The PR build runs in <code>FALLBACK</code> mode and executes the full suite. Nothing is skipped on a guess.</p>
<h3>Why does my PR build fail with "base reference does not resolve"?</h3>
<p><code>actions/checkout</code> on a pull request provides no local <code>main</code> branch. Use <code>origin/main</code> as the base reference and <code>fetch-depth: 0</code>.</p>
<h3>Does it work with Gradle?</h3>
<p>There is a Gradle plugin in the repository with the same cache and S3 settings, but at the time of writing it is not yet published to the Gradle Plugin Portal. The Maven plugin is on Maven Central.</p>
<h3>Is skipping tests safe?</h3>
<p>Selection is sound by default but not guaranteed. Reflection, generated code and string-dispatched APIs can escape dependency tracking. Keep a scheduled full-suite run as a safety net.</p>
<p><em>Disclosure: I maintain blastradius.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to Run Test Impact Analysis in Maven Without Breaking JaCoCo Code Coverage]]></title><description><![CDATA[Test impact analysis for Maven that keeps JaCoCo intact: measured results, the argLine setup that breaks tracking, and the fix.]]></description><link>https://baokhang.hashnode.dev/maven-test-impact-analysis-without-breaking-jacoco</link><guid isPermaLink="true">https://baokhang.hashnode.dev/maven-test-impact-analysis-without-breaking-jacoco</guid><category><![CDATA[Java]]></category><category><![CDATA[maven]]></category><category><![CDATA[Testing]]></category><category><![CDATA[Devops]]></category><category><![CDATA[CICDPipeline]]></category><dc:creator><![CDATA[BaoKhang]]></dc:creator><pubDate>Mon, 09 Nov 2026 09:59:02 GMT</pubDate><content:encoded><![CDATA[<p>Test impact analysis (TIA) and code coverage both want the same thing from your build: a seat on the test JVM's command line. Coverage tools attach a Java agent through <code>argLine</code>. Many TIA tools do too. When two agents, or two plugins writing the same property, meet in one <code>argLine</code>, the usual outcomes are a corrupted <code>jacoco.exec</code>, an empty report, or a team quietly turning coverage off on pull requests.</p>
<p>This post walks through how <a href="https://github.com/baokhang83/blastradius">blastradius</a> avoids that on pull requests, where the one setup that <em>does</em> collide on the base branch is, and the one-property fix. Everything here comes from a small fixture I built and ran, so the results are reproducible rather than asserted.</p>
<h2>Where the collision comes from</h2>
<p>JaCoCo's <code>prepare-agent</code> goal writes a <code>-javaagent:</code> option into a Maven property, <code>argLine</code> by default. Surefire then reads that property when it forks the test JVM. A TIA tool that records which classes each test loads needs its own agent in the same fork, so both want to write <code>argLine</code>.</p>
<p>Where they disagree is <em>when</em> each one needs it:</p>
<ul>
<li>Recording dependencies needs the agent <strong>once</strong>, on a trusted baseline build.</li>
<li>Using the recording needs <strong>no agent at all</strong>. It only needs to decide which tests to run.</li>
</ul>
<p>Blastradius is built around that split.</p>
<h2>TRACK on the base branch, SELECT on pull requests</h2>
<p><strong>TRACK (base branch).</strong> The select goal forks a nested <code>mvn clean test</code> with the dependency-recording agent appended to <code>argLine</code>. During that run the agent records, for each test, the production classes it loaded. The result is saved as an index under <code>.blastradius/</code>. This is the only point where blastradius adds a Java agent.</p>
<p><strong>SELECT (pull requests).</strong> Blastradius diffs the commit against the Git merge base, compares the changed classes with the saved index, and narrows Surefire with the standard <code>test</code> filter, the same property <code>-Dtest=</code> sets. If nothing is selected it sets <code>skipTests</code>. It does not touch <code>argLine</code>, so your JaCoCo agent, your custom flags, and any other tooling run exactly as they do in a normal build.</p>
<p>That is the part worth emphasising: on the builds developers wait for, the only change is <em>which tests</em> run, not <em>how the test JVM is launched</em>.</p>
<h2>What I measured</h2>
<p>The fixture is one Maven module with six small utility classes and 60 two-test JUnit 5 classes (120 tests). Each test class exercises exactly one utility class. Tooling: Maven 3.9.12, JDK 25, Surefire 3.5.2, JaCoCo 0.8.13, JUnit 5.10.2, blastradius 0.4.0. It is synthetic on purpose, so treat the numbers as an illustration of behavior, not a benchmark.</p>
<h3>Does base-branch tracking survive JaCoCo?</h3>
<p>I ran the TRACK build on <code>main</code> with four configurations:</p>
<table>
<thead>
<tr>
<th>Setup</th>
<th>JaCoCo</th>
<th>Surefire <code>argLine</code></th>
<th>Tracking result</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>none</td>
<td>default</td>
<td>index built</td>
</tr>
<tr>
<td>B</td>
<td><code>prepare-agent</code> (defaults)</td>
<td>default (not set)</td>
<td><strong>index built</strong>, JaCoCo 6/6 lines</td>
</tr>
<tr>
<td>C</td>
<td><code>prepare-agent</code> (defaults)</td>
<td><code>&lt;argLine&gt;@{argLine} -Dcustom.flag=on&lt;/argLine&gt;</code></td>
<td><strong>tracking failed, no index</strong></td>
</tr>
<tr>
<td>D</td>
<td><code>prepare-agent</code> with <code>propertyName=jacocoArgLine</code></td>
<td><code>&lt;argLine&gt;@{jacocoArgLine} ${argLine} -Dcustom.flag=on&lt;/argLine&gt;</code></td>
<td><strong>index built</strong>, JaCoCo 6/6 lines</td>
</tr>
</tbody></table>
<p>Setup C is the one to watch, because it is also the most common way people write a JaCoCo-plus-custom-flags configuration. With it, blastradius logs:</p>
<pre><code class="language-plaintext">[WARNING] [blastradius] tracking failed; discarding its incomplete dependency data and falling back to the full suite
</code></pre>
<p>The build still passes and the tests still run, so nothing breaks loudly. But no index is saved, which means every pull request build afterwards has nothing to select against and runs the full suite. You keep your coverage and lose the speedup, which is easy to miss.</p>
<h3>Why setup C fails</h3>
<p>In the forked tracking build, blastradius passes its agent on the command line as <code>-DargLine=...</code>. JaCoCo's <code>prepare-agent</code> reads and writes the <em>project</em> property <code>argLine</code>, which does not include that command-line value. It sets <code>argLine</code> to the JaCoCo agent alone, and an explicit <code>&lt;argLine&gt;@{argLine} ...&lt;/argLine&gt;</code> in Surefire then resolves to exactly that. The tracking agent never reaches the test JVM, nothing is recorded, and blastradius correctly refuses to trust an empty recording.</p>
<p>(I confirmed this by running the same nested command by hand: the build logged <code>argLine set to -javaagent:...jacoco...</code> with no tracking agent in it, and no records were written.)</p>
<h3>The fix: give JaCoCo its own property</h3>
<p>Setup D separates the two concerns. JaCoCo writes to its own property, and your Surefire <code>argLine</code> lists it explicitly next to whatever <code>-DargLine</code> the build was given:</p>
<pre><code class="language-xml">&lt;properties&gt;
  &lt;!-- default so ${argLine} resolves in a normal build --&gt;
  &lt;argLine&gt;&lt;/argLine&gt;
&lt;/properties&gt;

&lt;build&gt;
  &lt;plugins&gt;
    &lt;plugin&gt;
      &lt;groupId&gt;org.jacoco&lt;/groupId&gt;
      &lt;artifactId&gt;jacoco-maven-plugin&lt;/artifactId&gt;
      &lt;version&gt;0.8.13&lt;/version&gt;
      &lt;executions&gt;
        &lt;execution&gt;
          &lt;goals&gt;&lt;goal&gt;prepare-agent&lt;/goal&gt;&lt;/goals&gt;
          &lt;configuration&gt;
            &lt;propertyName&gt;jacocoArgLine&lt;/propertyName&gt;
          &lt;/configuration&gt;
        &lt;/execution&gt;
        &lt;execution&gt;
          &lt;id&gt;report&lt;/id&gt;
          &lt;phase&gt;verify&lt;/phase&gt;
          &lt;goals&gt;&lt;goal&gt;report&lt;/goal&gt;&lt;/goals&gt;
        &lt;/execution&gt;
      &lt;/executions&gt;
    &lt;/plugin&gt;

    &lt;plugin&gt;
      &lt;artifactId&gt;maven-surefire-plugin&lt;/artifactId&gt;
      &lt;version&gt;3.5.2&lt;/version&gt;
      &lt;configuration&gt;
        &lt;argLine&gt;@{jacocoArgLine} ${argLine} -Dcustom.flag=on&lt;/argLine&gt;
      &lt;/configuration&gt;
    &lt;/plugin&gt;
  &lt;/plugins&gt;
&lt;/build&gt;
</code></pre>
<p>In a normal build <code>${argLine}</code> is the empty default. In blastradius's tracking build it carries the tracking agent. JaCoCo's agent and the tracking agent both end up in the fork. If you do not set a custom <code>argLine</code> at all (setup B), the default behavior already works and you need no change.</p>
<h2>What coverage looks like on a pull request</h2>
<p>Here is the part to plan for. On a pull request, blastradius runs fewer tests, so JaCoCo reports coverage for fewer tests. The report is valid, not corrupted, but it only reflects what ran.</p>
<p>I measured it on the fixture with a one-line change to <code>MathUtil</code>, using setup D:</p>
<table>
<thead>
<tr>
<th>Run</th>
<th align="right">Tests run</th>
<th align="right">JaCoCo lines covered</th>
</tr>
</thead>
<tbody><tr>
<td>Full run on the PR (no blastradius)</td>
<td align="right">120</td>
<td align="right">6 / 6</td>
</tr>
<tr>
<td>blastradius SELECT on the PR</td>
<td align="right"><strong>20</strong></td>
<td align="right"><strong>1 / 6</strong> (<code>MathUtil</code> only)</td>
</tr>
</tbody></table>
<p>The fixture is the extreme case, because each test touches only one class. Real suites overlap far more. But the principle holds in general:</p>
<ul>
<li><strong>Project-wide coverage percentages on a PR will drop</strong> if you run selection on that build. A <code>jacoco:check</code> rule with a project-wide threshold, evaluated on the PR build, can fail for a reason that has nothing to do with the change.</li>
<li><strong>Coverage of the changed code is still meaningful</strong>, because the tests that load a changed class are the ones that get selected. In the fixture, the changed class <code>MathUtil</code> is fully covered. If you use a diff-coverage tool, point it at the changed lines.</li>
<li><strong>Treat the base branch as the source of truth for overall coverage.</strong> Publish your coverage badge and project-wide gates from the full-suite build on <code>main</code> (the TRACK build is a full run), not from PRs.</li>
</ul>
<p>If a team needs a project-wide coverage gate on every PR, that team should run the full suite for that gate and use selection only for the fast feedback build. That is a policy decision, not a limitation of the report.</p>
<h2>A setup you can copy</h2>
<ol>
<li><p>Use setup B (no explicit <code>argLine</code>) or setup D above for JaCoCo.</p>
</li>
<li><p>Add the plugin:</p>
<pre><code class="language-xml">&lt;plugin&gt;
  &lt;groupId&gt;io.github.baokhang83.blastradius&lt;/groupId&gt;
  &lt;artifactId&gt;blastradius-maven-plugin&lt;/artifactId&gt;
  &lt;version&gt;0.4.0&lt;/version&gt;
  &lt;executions&gt;
    &lt;execution&gt;
      &lt;phase&gt;process-test-classes&lt;/phase&gt;
      &lt;goals&gt;&lt;goal&gt;select&lt;/goal&gt;&lt;/goals&gt;
    &lt;/execution&gt;
  &lt;/executions&gt;
  &lt;configuration&gt;&lt;baseRef&gt;main&lt;/baseRef&gt;&lt;/configuration&gt;
&lt;/plugin&gt;
</code></pre>
</li>
<li><p>On <code>main</code>, run your normal <code>mvn verify</code>. The first run tracks and saves <code>.blastradius/</code>.</p>
</li>
<li><p>Persist <code>.blastradius/</code> with your CI cache between runs.</p>
</li>
<li><p><strong>Check that tracking succeeded</strong>: look for <code>[blastradius] TRACK</code> and the absence of <code>tracking failed</code> in the log. A failure is silent as far as the build status goes.</p>
</li>
<li><p>On pull requests, run the same command. Blastradius selects tests from the saved index.</p>
</li>
<li><p>Keep a regular full-suite run (daily is the recommendation) as a safety net, since selection is sound by default but not guaranteed.</p>
</li>
</ol>
<h2>FAQ</h2>
<h3>Why do test impact analysis tools conflict with JaCoCo?</h3>
<p>Both need a Java agent in the forked test JVM, and both usually set it through the <code>argLine</code> property. If either overwrites the other's value, you lose one of the two. The collision is in how the property is written, not in the agents themselves.</p>
<h3>Does blastradius change <code>argLine</code> on pull requests?</h3>
<p>No. In SELECT mode it sets Surefire's <code>test</code> filter (the same property as <code>-Dtest=</code>), or <code>skipTests</code> when nothing needs to run. It only adds a Java agent in the forked tracking build on the base branch.</p>
<h3>My Maven build passes but PRs still run every test. Why?</h3>
<p>Check the base-branch log for <code>tracking failed</code>. If your Surefire <code>&lt;argLine&gt;</code> uses <code>@{argLine}</code> with JaCoCo's default <code>prepare-agent</code>, the tracking agent can be overwritten and no index is saved. Switching JaCoCo to its own <code>propertyName</code> as shown above fixes it.</p>
<h3>Will my JaCoCo coverage report be correct on pull requests?</h3>
<p>It will be valid but partial: it reflects the tests that ran. Project-wide percentages drop when fewer tests run, while coverage of the changed code stays meaningful. Run overall coverage and any project-wide <code>jacoco:check</code> gate on the base-branch full run.</p>
<h3>Does this work with Failsafe and integration tests?</h3>
<p>Selection narrows both Surefire and Failsafe. I only measured Surefire for this post, so check your integration-test setup with the same TRACK log check before relying on it.</p>
<h3>Is selection safe?</h3>
<p>It is sound by default, not a guarantee. Reflection, generated code and string-dispatched APIs can escape dependency tracking, so keep a regular full-suite run as a backstop. The first post in this series has the replay numbers: <a href="https://baokhang.hashnode.dev/how-we-cut-maven-junit5-test-runs-61-percent-apache-repos">61.4% fewer test executions across 800 commits, with the exclusions and caveats listed</a>.</p>
<p><em>Disclosure: I maintain blastradius.</em></p>
]]></content:encoded></item><item><title><![CDATA[Why Maven Build Cache Still Re-Runs Your Entire Test Suite on One-Line Edits]]></title><description><![CDATA[Maven build cache works per module, so one edited class re-runs every JUnit 5 test. Measured result and how class-level selection fixes it.]]></description><link>https://baokhang.hashnode.dev/why-maven-build-cache-reruns-entire-test-suite</link><guid isPermaLink="true">https://baokhang.hashnode.dev/why-maven-build-cache-reruns-entire-test-suite</guid><category><![CDATA[Java]]></category><category><![CDATA[maven]]></category><category><![CDATA[Testing]]></category><category><![CDATA[Devops]]></category><category><![CDATA[junit 5]]></category><category><![CDATA[junit]]></category><category><![CDATA[CICDPipeline]]></category><dc:creator><![CDATA[BaoKhang]]></dc:creator><pubDate>Mon, 09 Nov 2026 08:43:14 GMT</pubDate><content:encoded><![CDATA[<p>You install <a href="https://maven.apache.org/extensions/maven-build-cache-extension/"><code>maven-build-cache-extension</code></a>, your unchanged modules start restoring from cache in seconds, and PR builds feel fast. Then you edit one line in a utility class inside your biggest module and the build runs every test in that module again.</p>
<p>That is not a bug in the cache. It is a granularity mismatch, and it is worth understanding precisely, because the two tools solve different halves of the problem and work well together.</p>
<h2>What the build cache does</h2>
<p>The extension fingerprints a module's inputs (sources, dependencies, plugin configuration) into a checksum. If it has seen that checksum before, it restores the module's outputs and skips the build. If it has not, it runs the module's whole lifecycle, Surefire included.</p>
<p>So the cache's smallest unit is <strong>the module</strong>. Anything inside it that changes, even a single character in one class, produces a new checksum for the module. There is no "mostly the same" in a checksum.</p>
<p>For a repository with many small modules that is a great trade. For a repository with one large module (a monolith, a <code>core</code> or <code>common</code> module, a service with its tests in the same artifact), it means a one-line edit costs the module's entire test suite.</p>
<h2>Measuring it</h2>
<p>I built a small fixture rather than assert this. It is deliberately simple and synthetic, so read the numbers as an illustration of the mechanism, not a benchmark:</p>
<ul>
<li>One Maven module, JDK 25 toolchain, Maven 3.9.12, <code>maven-build-cache-extension</code> 1.2.0, Surefire 3.5.2, JUnit 5.10.2.</li>
<li>Six small utility classes in <code>src/main</code>.</li>
<li>60 JUnit 5 test classes, 2 tests each: <strong>120 tests</strong>. Each test class exercises exactly one utility class, so every utility has 10 test classes (20 tests) that depend on it.</li>
</ul>
<p>Steps and results:</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>What changed</th>
<th>Cache extension</th>
<th align="right">Tests run</th>
</tr>
</thead>
<tbody><tr>
<td>Base branch build</td>
<td>nothing</td>
<td>miss (first build)</td>
<td align="right">120</td>
</tr>
<tr>
<td>Rebuild, same inputs</td>
<td>nothing</td>
<td><strong>hit</strong>, module restored</td>
<td align="right">0</td>
</tr>
<tr>
<td>Branch A</td>
<td>one line in <code>StringUtil</code></td>
<td><strong>miss</strong></td>
<td align="right"><strong>120</strong></td>
</tr>
</tbody></table>
<p>The unchanged rebuild is the cache doing its job. Branch A is the problem: <code>StringUtil</code> is exercised by 20 of the 120 tests, and all 120 ran.</p>
<h2>The other half: which tests does this change actually reach?</h2>
<p>Narrowing inside the module needs information the cache does not have: which tests touch which production classes. <a href="https://github.com/baokhang83/blastradius">blastradius</a> gets it from the JVM instead of guessing from names or packages:</p>
<ol>
<li><strong>Track.</strong> On the base branch it runs the suite once with a <code>java.lang.instrument</code> agent attached. Each time a class is loaded while a test runs, it records the pair (test, class). That gives a ground-truth map from every test to the production classes it loaded.</li>
<li><strong>Diff.</strong> On a change branch it diffs the commit against the Git merge base to find the changed classes.</li>
<li><strong>Select.</strong> A test is selected if one of its recorded classes changed, if the test is new or modified, if an executed class reaches a changed class through one or two recorded static invocation edges, or if a non-source change forces the conservative full-suite fallback.</li>
<li><strong>Gate.</strong> The selection becomes a standard Surefire/Failsafe <code>-Dtest=</code> filter.</li>
</ol>
<p>The same fixture, with blastradius added alongside the cache extension:</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>What changed</th>
<th>Cache extension</th>
<th align="right">Tests run</th>
</tr>
</thead>
<tbody><tr>
<td>Base branch build (tracking)</td>
<td>nothing</td>
<td>miss</td>
<td align="right">120</td>
</tr>
<tr>
<td>Branch B</td>
<td>one line in <code>MathUtil</code></td>
<td><strong>miss</strong></td>
<td align="right"><strong>20 of 120</strong></td>
</tr>
<tr>
<td>Rebuild branch B, same inputs</td>
<td>nothing</td>
<td><strong>hit</strong>, module restored</td>
<td align="right">0</td>
</tr>
</tbody></table>
<p>The cache still misses, because the module's checksum changed. That is correct, and blastradius does not try to change it. What changes is what the missed build runs: only the 20 tests, from the 10 classes whose recorded dependencies include <code>MathUtil</code>, instead of all 120 (83.3% skipped in this fixture).</p>
<p>The two are complementary:</p>
<ul>
<li>The <strong>cache</strong> removes whole modules that did not change.</li>
<li><strong>blastradius</strong> removes tests inside the modules that did.</li>
</ul>
<h2>Is this just a toy result?</h2>
<p>The fixture is evenly partitioned on purpose, so a clean 20/120 is what you would expect. Real suites are messier: shared fixtures, base classes and widely used utilities make some changes reach many tests. That is why I would not quote the fixture number, and why the historical replay matters more.</p>
<p>blastradius has been replayed over 800 consecutive commits on four open-source projects (apache/shenyu, apache/httpcomponents-client, jhy/jsoup, apache/commons-io). Across those replays, the tool selected 966,368 of 2,501,105 test executions: <strong>61.4% fewer</strong>, with the per-project range running from 45.0% (jsoup) to 71.0% (shenyu). A module-level cache has to treat each of those commits that touches a module's sources as a full rerun of that module. The replay figures are counts of test executions, not wall-clock CI time, and the full methodology, exclusions and caveats are in the <a href="https://baokhang.hashnode.dev/ow-we-cut-maven-junit5-test-runs-61-percent-apache-repos">first post in this series</a> and in <a href="https://github.com/baokhang83/blastradius/blob/main/HISTORICAL_REPLAY_ANALYSIS.md">HISTORICAL_REPLAY_ANALYSIS.md</a>.</p>
<h2>Setup notes for using both</h2>
<p>Two things I hit while building the fixture, so you do not have to:</p>
<ol>
<li><strong>The base-branch (tracking) build must actually run the tests.</strong> Blastradius records dependencies by running the suite in a forked Maven build. If the cache extension restores that build from cache, no tests execute, nothing is recorded, and blastradius reports <code>tracking failed; ... falling back to the full suite</code>. That fallback is safe (you get a full run), but you get no index. The fix that worked for me was to disable the cache for the tracking build with <code>MAVEN_OPTS="-Dmaven.build.cache.enabled=false"</code>. Passing the flag only on the outer command line did not reach the forked build.</li>
<li><strong>Persist the</strong> <code>.blastradius/</code> <strong>directory between runs</strong>, the same way you persist the cache. On a change branch, blastradius needs the saved index from the base branch; without one it runs the full suite.</li>
</ol>
<p>Example plugin configuration, as used in the fixture:</p>
<pre><code class="language-xml">&lt;plugin&gt;
  &lt;groupId&gt;io.github.baokhang83.blastradius&lt;/groupId&gt;
  &lt;artifactId&gt;blastradius-maven-plugin&lt;/artifactId&gt;
  &lt;version&gt;0.4.0&lt;/version&gt;
  &lt;executions&gt;
    &lt;execution&gt;
      &lt;phase&gt;process-test-classes&lt;/phase&gt;
      &lt;goals&gt;&lt;goal&gt;select&lt;/goal&gt;&lt;/goals&gt;
    &lt;/execution&gt;
  &lt;/executions&gt;
  &lt;configuration&gt;&lt;baseRef&gt;main&lt;/baseRef&gt;&lt;/configuration&gt;
&lt;/plugin&gt;
</code></pre>
<h2>Where this does not help</h2>
<ul>
<li><strong>Modules that are already small.</strong> If your tests are split across many modules and each module's tests are fast, the cache is already close to optimal.</li>
<li><strong>Changes that reach everything.</strong> A change to a class loaded by every test selects nearly every test, and a build-file or resource change triggers the full-suite fallback by design.</li>
<li><strong>Code reached through reflection or string-dispatched APIs.</strong> Selection is sound by default, not guaranteed. Selector engines, expression languages and query parsers are the known hard case (jsoup showed this in the replay and drove a fix), so keep a regular full-suite run, daily is the recommendation, as a backstop.</li>
</ul>
<h2>FAQ</h2>
<h3>Why does Maven Build Cache re-run all tests after a one-line change?</h3>
<p>The extension caches at module granularity. Any change to a module's inputs produces a new checksum, so the module is rebuilt in full, including all of its Surefire tests. The cache has no record of which tests depend on which classes.</p>
<h3>Can the Maven Build Cache Extension skip individual tests?</h3>
<p>Not by itself: it restores or rebuilds whole modules. To run only the tests a change can affect, you need per-test dependency information, which is what test impact analysis tools such as blastradius record.</p>
<h3>How does blastradius know which tests a class change affects?</h3>
<p>On the base branch a Java agent records every class each test loads at runtime. On a change branch, tests whose recorded classes include a changed class are selected, along with new or modified tests and a bounded set of tests that reach the changed class through one or two static invocation edges.</p>
<h3>Do I have to choose between the cache and blastradius?</h3>
<p>No. The cache skips unchanged modules; blastradius narrows tests inside changed ones. One setup note: run the base-branch tracking build with the cache disabled, otherwise the restored build runs no tests and nothing is recorded.</p>
<h3>Is it safe to skip tests?</h3>
<p>Selection is sound by default but not a guarantee. Reflection, generated code and string-dispatched APIs can escape it. Run the full suite on a regular cadence as a safety net; the recommendation is daily.</p>
<h3>Does 83% fewer tests mean 83% faster builds?</h3>
<p>No. The fixture counts tests run, not time, and it is synthetic. Real savings depend on which tests are slow, fixed costs such as compilation and packaging, and how widely your changes reach.</p>
<h3>What do I need to try it?</h3>
<p>Maven, JDK 21 or later, a Git repository and JUnit 5. The plugin is on Maven Central as <code>io.github.baokhang83.blastradius:blastradius-maven-plugin</code>. The fixture in this post is easy to recreate: six utility classes, 60 two-test classes with one per utility, and the two plugins configured as shown.</p>
<p><em>Disclosure: I am the author of blastradius.</em></p>
]]></content:encoded></item><item><title><![CDATA[How We Cut Maven & JUnit 5 Test Runs by 61% Across Apache Repos Without Missing Regressions]]></title><description><![CDATA[Replaying 800 commits on 4 Apache/OSS repos: Java test impact analysis skipped 61.4% of test runs with 0 missed regressions.]]></description><link>https://baokhang.hashnode.dev/how-we-cut-maven-junit5-test-runs-61-percent-apache-repos</link><guid isPermaLink="true">https://baokhang.hashnode.dev/how-we-cut-maven-junit5-test-runs-61-percent-apache-repos</guid><category><![CDATA[Java]]></category><category><![CDATA[maven]]></category><category><![CDATA[junit 5]]></category><category><![CDATA[Testing]]></category><category><![CDATA[Devops]]></category><category><![CDATA[CICDPipeline]]></category><dc:creator><![CDATA[BaoKhang]]></dc:creator><pubDate>Mon, 09 Nov 2026 03:57:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a5b81d55ebd45e7725fb67c/117c93e0-c758-45e7-a15f-ecdd2fc6b89e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most "predictive test selection" write-ups for Java come from vendors, and most of them give you a percentage with no way to check it. This one comes with the commit hashes. We replayed 800 consecutive commits from four open-source Java projects and counted what a dependency-based test selector would have run, what it would have skipped, and whether any skipped test would have caught a real bug.</p>
<p>The tool is <a href="https://github.com/baokhang83/blastradius">blastradius</a>, an Apache-2.0 test impact analysis plugin for Maven (and Gradle) with JUnit 5. The headline: <strong>61.4% fewer test executions across the four replays, with 0 would-miss cases after the exclusions described below.</strong> The rest of this post is about what those two numbers do and do not mean.</p>
<h2>The idea: record what tests actually load</h2>
<p>Test impact analysis (TIA) answers one question on a pull request: which tests could be affected by this diff? Static guesses (package names, module graphs) are cheap but loose. Blastradius records ground truth instead:</p>
<ol>
<li><strong>Track.</strong> On a base-branch build, a <code>java.lang.instrument</code> agent watches every class loaded while each test runs, and records which production classes each test really touched.</li>
<li><strong>Diff.</strong> On a change branch, it diffs the commit against the Git merge base, so only the PR's own changes count, not whatever landed on <code>main</code> after it diverged.</li>
<li><strong>Select.</strong> A test runs if one of its recorded dependencies changed, if it is new or modified, if an executed class reaches a changed class through one or two recorded static invocation edges, or if a non-source change (build files, resources) triggers the conservative "run everything" fallback.</li>
<li><strong>Gate.</strong> The selection narrows Surefire/Failsafe through the standard <code>-Dtest=</code> filter, so it keeps working with JaCoCo and custom <code>argLine</code> setups.</li>
</ol>
<p>Missing or unusable baseline data always means a full run.</p>
<h2>The experiment</h2>
<p>For each project, the validation harness replays <strong>200 consecutive commits</strong>, treating each commit as the change it introduced over the one before it. For every pair it asks the selector what it would run, then compares against ground truth. A <strong>would-miss</strong> is a test that caught a real regression or an injected mutant but that selection chose not to run. That is the failure that matters.</p>
<p>On the same window, the harness also runs <strong>bounded mutation validation</strong>: it injects synthetic faults into the head commit and checks whether the selected tests catch them.</p>
<h2>Results: test executions skipped</h2>
<table>
<thead>
<tr>
<th>Project</th>
<th>Commit range</th>
<th align="center">Pairs (excluded)</th>
<th align="right">Would-miss</th>
<th align="right">Selected / full</th>
<th align="right">Skipped</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://github.com/apache/shenyu">apache/shenyu</a></td>
<td><a href="https://github.com/apache/shenyu/commit/69cd1d5721647a60007584983d96fc94452a4f6b"><code>69cd1d5</code></a> → <a href="https://github.com/apache/shenyu/commit/3a411e017acfc47636e2bbfeb2958108d1f15a05"><code>3a411e0</code></a></td>
<td align="center">200 (0)</td>
<td align="right">0</td>
<td align="right">153,142 / 527,508</td>
<td align="right"><strong>71.0%</strong></td>
</tr>
<tr>
<td><a href="https://github.com/apache/httpcomponents-client">apache/httpcomponents-client</a></td>
<td><a href="https://github.com/apache/httpcomponents-client/commit/ef34bfa8fd6f2f6181ba2e41051050ed877e56df"><code>ef34bfa</code></a> → <a href="https://github.com/apache/httpcomponents-client/commit/4dae8da4a365f639e42fea84822285f345be7755"><code>4dae8da</code></a></td>
<td align="center">200 (12)</td>
<td align="right">0</td>
<td align="right">180,198 / 443,593</td>
<td align="right"><strong>59.4%</strong></td>
</tr>
<tr>
<td><a href="https://github.com/jhy/jsoup">jhy/jsoup</a></td>
<td><a href="https://github.com/jhy/jsoup/commit/b62e362d3945e86725c817101332b66277aff9e4"><code>b62e362</code></a> → <a href="https://github.com/jhy/jsoup/commit/9d2241ff467d03accbf902a650adc60513bf5c11"><code>9d2241f</code></a></td>
<td align="center">200 (10)</td>
<td align="right">0</td>
<td align="right">193,635 / 351,882</td>
<td align="right"><strong>45.0%</strong></td>
</tr>
<tr>
<td><a href="https://github.com/apache/commons-io">apache/commons-io</a></td>
<td><a href="https://github.com/apache/commons-io/commit/d4032376f0103c3e31a884e2358b58978e183e5f"><code>d403237</code></a> → <a href="https://github.com/apache/commons-io/commit/ffd9ddae6e637c0be9ab63e3dfa5ca98f2230bc5"><code>ffd9dda</code></a></td>
<td align="center">200 (12)</td>
<td align="right">0</td>
<td align="right">439,393 / 1,178,122</td>
<td align="right"><strong>62.7%</strong></td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td></td>
<td align="center"><strong>800 (34)</strong></td>
<td align="right"><strong>0</strong></td>
<td align="right"><strong>966,368 / 2,501,105</strong></td>
<td align="right"><strong>61.4%</strong></td>
</tr>
</tbody></table>
<p>The 61.4% is the aggregate: 966,368 selected executions out of 2,501,105 in the full suites. Note what it is: <strong>a count of test executions in a replay, not measured CI wall-clock time.</strong> Slow tests and fast tests count the same, and your savings will depend on where your slow tests sit.</p>
<h2>Results: injected faults</h2>
<table>
<thead>
<tr>
<th>Project</th>
<th align="right">Mutants (compilable)</th>
<th align="right">Caught by the full suite</th>
<th align="right">Killing tests selected</th>
</tr>
</thead>
<tbody><tr>
<td>shenyu</td>
<td align="right">872 (778)</td>
<td align="right">339</td>
<td align="right">943 / 943</td>
</tr>
<tr>
<td>httpcomponents-client</td>
<td align="right">916 (916)</td>
<td align="right">551</td>
<td align="right">75,380 / 75,380</td>
</tr>
<tr>
<td>jsoup</td>
<td align="right">940 (931)</td>
<td align="right">606</td>
<td align="right">53,300 / 53,300 (see below)</td>
</tr>
<tr>
<td>commons-io</td>
<td align="right">901 (901)</td>
<td align="right">850</td>
<td align="right">30,637 / 31,648 (see below)</td>
</tr>
</tbody></table>
<p>A "killing test" is one that passed on head, failed on the mutant, and stayed failed on confirmation. Those are the tests selection must not skip. Counts are per mutant, so a test that kills five mutants counts five times.</p>
<h2>What we excluded, and what did not come out clean</h2>
<p>A benchmark is only worth reading if the awkward parts are on the page. Here they are.</p>
<ul>
<li><strong>Excluded commit pairs (34 of 800).</strong> Pairs the harness could not measure count neither for nor against selection. In httpcomponents-client, 11 of 12 hit the build timeout on a slow multi-module rebuild and 1 hit a Surefire report containing an XML-illegal character (since fixed). In jsoup, all 10 exceeded the 40-minute mutation-build timeout. In commons-io, 11 hit the mutation-validation timeout and 1 failed to build under the harness.</li>
<li><strong>Flaky tests were excluded from the verdict.</strong> <code>HttpClientPluginConfigurationTest</code> (shenyu) and <code>TestTlsHandshakeTimeout#testTimeout</code> (httpcomponents-client, flaky under parallel build load) were removed. Excluding the second is what brings httpcomponents-client's would-miss to 0.</li>
<li><strong>commons-io had an unresolved case.</strong> A first recorded run showed a would-miss on <code>URIOriginTest#testGetInputStream</code>, a test parameterized on a live network fixture. A plain <code>mvn clean test</code> at that commit passed (6,376 tests, 0 failures), but the pair then failed to build under the harness and could not be root-caused. It is excluded, so the consolidated would-miss is 0, but that is "not a confirmed miss," not "proven clean."</li>
<li><strong>commons-io skipped 3.2% of killing executions.</strong> 1,011 of 31,648 killing test executions were skipped, spread over 42 commit pairs on the diff-targeted or whole-tree-fallback path rather than dependency-match selection. Every one of the 850 killed mutants still had at least one killing test selected, so the gap is partial under-selection, not a blind spot. This figure is provisional.</li>
<li><strong>jsoup exposed a real gap, which we then fixed.</strong> The first 200-pair run skipped 5,808 killing executions: 5,682 from 12 <code>QueryParser</code> mutations and 126 from two <code>Normalizer</code> mutations. jsoup's CSS selector engine is a string DSL, which is exactly where class-load tracking is weakest: a selector string reaches code that the test never loads directly. Adding a bounded two-hop static-invocation rule fixed it: targeted replays of all 14 mutation identities selected all 16,642 killing executions. Those were targeted replays, not a fresh full 200-pair rerun.</li>
</ul>
<p>The lesson from jsoup generalizes. Reflection, string-dispatched APIs, generated code, and call paths deeper than two edges sit outside the evidence. If your project is built around selector engines, expression languages or query parsers, weigh that before adopting.</p>
<h2>So, "without missing regressions"?</h2>
<p>Within this window and these exclusions, blastradius had 0 would-miss cases across 800 commit pairs on four projects, and it selected a killing test for every mutant the full suites caught. That is the claim, and it is a measured one rather than a guarantee. We recommend running the full suite on a regular cadence (daily is the recommendation) as a safety net, so an occasional gap is caught within a day rather than never. Fast, sound-by-default selection on every build, backstopped by a full run, is the intended trust model.</p>
<h2>Try it on your project</h2>
<p>Add the plugin from Maven Central:</p>
<pre><code class="language-xml">&lt;plugin&gt;
  &lt;groupId&gt;io.github.baokhang83.blastradius&lt;/groupId&gt;
  &lt;artifactId&gt;blastradius-maven-plugin&lt;/artifactId&gt;
  &lt;version&gt;0.4.0&lt;/version&gt;
  &lt;executions&gt;
    &lt;execution&gt;
      &lt;phase&gt;process-test-classes&lt;/phase&gt;
      &lt;goals&gt;&lt;goal&gt;select&lt;/goal&gt;&lt;/goals&gt;
    &lt;/execution&gt;
  &lt;/executions&gt;
  &lt;configuration&gt;
    &lt;baseRef&gt;main&lt;/baseRef&gt;
  &lt;/configuration&gt;
&lt;/plugin&gt;
</code></pre>
<p>On your base branch it tracks; on other branches it selects. For separate CI runners, persist and restore the <code>.blastradius/</code> directory with your CI cache.</p>
<p>If you would rather check the mechanism against your own history before changing CI, the <a href="https://github.com/baokhang83/blastradius/tree/main/blastradius-validator">validator harness</a> is the same shadow-mode tool that produced the numbers above: it replays your repository's commits, reports what would have been skipped, and flags would-miss cases without touching your build.</p>
<ul>
<li>Repository: <a href="https://github.com/baokhang83/blastradius">github.com/baokhang83/blastradius</a></li>
<li>Full methodology and footnotes: <a href="https://github.com/baokhang83/blastradius/blob/main/HISTORICAL_REPLAY_ANALYSIS.md">HISTORICAL_REPLAY_ANALYSIS.md</a></li>
<li>Maven guide: <a href="https://github.com/baokhang83/blastradius/blob/main/docs/maven-junit5-test-impact-analysis.md">docs/maven-junit5-test-impact-analysis.md</a></li>
<li>Maturity and trust model: <a href="https://github.com/baokhang83/blastradius/blob/main/MATURITY.md">MATURITY.md</a></li>
</ul>
<h2>FAQ</h2>
<h3>What is test impact analysis (predictive test selection) for Java?</h3>
<p>It is a way to run only the tests a change could affect, instead of the whole suite, on every pull request. Blastradius does it by recording which production classes each test loads on the base branch, then selecting the tests whose recorded dependencies overlap the diff.</p>
<h3>How is this different from running tests by module or package?</h3>
<p>Module and package filters guess from project structure. Blastradius uses classes actually loaded at runtime during each test, so a test in one module that exercises code in another is still selected when that code changes.</p>
<h3>Does it work with JaCoCo and custom <code>argLine</code> settings?</h3>
<p>Yes. Selection works through the standard Surefire/Failsafe <code>-Dtest=</code> filter, so it does not replace your test runner or conflict with JaCoCo or a custom <code>argLine</code>.</p>
<h3>What happens when it cannot decide safely?</h3>
<p>It runs everything. A missing or unusable baseline, or a non-source change such as a build file or resource, triggers the full-suite fallback. New and modified tests are always selected.</p>
<h3>Do I still need to run the full suite?</h3>
<p>Yes. Selection is sound by default, not guaranteed. We recommend a full-suite run on a regular cadence (daily) as a backstop, so any gap is caught within a day.</p>
<h3>Does 61.4% fewer test executions mean 61.4% faster CI?</h3>
<p>No. The figure counts test executions in a replay, not wall-clock time. Your speedup depends on where your slow tests fall, plus fixed costs such as compilation and startup. The tracking build on your base branch also runs the full suite once.</p>
<h3>Where does it struggle?</h3>
<p>Code reached only through string-dispatched APIs, reflection or generated code, or through call paths deeper than two recorded edges, sits outside the evidence. jsoup's selector engine exposed exactly this in the replay. Projects built around query parsers, expression languages or selector DSLs should weigh it.</p>
<h3>Does it support Gradle and Kotlin?</h3>
<p>Kotlin in the conventional <code>src/main/kotlin</code> and <code>src/test/kotlin</code> layout is supported, with a full-suite fallback for Kotlin inline functions. The Gradle plugin is in the repository, but at the time of writing it is not yet published to the Gradle Plugin Portal. The Maven plugin is on Maven Central.</p>
<h3>Can I run it on separate CI runners?</h3>
<p>Yes. Persist and restore the <code>.blastradius/</code> directory with your CI cache. An S3-compatible shared index store is available as an option if that suits your runners better.</p>
<h3>Can I check it against my own repository before adopting it?</h3>
<p>Yes. The validator harness replays your own commit history in shadow mode, reports what would have been skipped, and flags would-miss cases without changing your build.</p>
]]></content:encoded></item><item><title><![CDATA[Why Kubernetes In-Place Memory Resize Gets Stuck "In Progress" on JVM Pods]]></title><description><![CDATA[Pod resize stuck on PodResizeInProgress with "below current usage"? A JVM keeps its heap. See real kubectl output and how to unblock it.]]></description><link>https://baokhang.hashnode.dev/kubernetes-pod-resize-stuck-in-progress-jvm</link><guid isPermaLink="true">https://baokhang.hashnode.dev/kubernetes-pod-resize-stuck-in-progress-jvm</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Java]]></category><category><![CDATA[jvm]]></category><category><![CDATA[kubectl]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[BaoKhang]]></dc:creator><pubDate>Sun, 08 Nov 2026 18:59:34 GMT</pubDate><content:encoded><![CDATA[<p>You lowered a JVM pod's memory with an in-place resize, and the pod now shows <code>PodResizeInProgress</code>, an <code>Error</code> reason, and an old memory limit that never changes. The kubelet is not broken. It refuses to lower a memory limit below the container's current usage, and a JVM keeps its committed heap even after the objects in it are garbage. The resize completes as soon as the JVM gives the memory back, which I confirmed on a Kubernetes 1.35.8 cluster. Below are the real outputs, the fix, and how long it took.</p>
<h2>The short answer</h2>
<ul>
<li><strong>Cause:</strong> the kubelet will not apply a memory-limit decrease while the container's current usage is above the new limit. A JVM's usage stays high after garbage collection because it keeps the heap committed.</li>
<li><strong>Symptom:</strong> condition <code>PodResizeInProgress=True</code> with reason <code>Error</code>. <code>containerStatuses[].allocatedResources</code> shows the new value, but <code>containerStatuses[].resources</code> still shows the old one.</li>
<li><strong>It retries.</strong> The kubelet retried about every 11 to 15 seconds. The pod was not OOM-killed and did not restart. The check protected the JVM.</li>
<li><strong>Fix:</strong> make the JVM release memory, then let the resize finish. Releasing after the patch finished the resize on the next retry (about 20 s in my run). Releasing before the patch finished it 2 seconds after the patch, and the error never appeared.</li>
<li><strong><code>PodResizePending</code> is a different problem.</strong> I saw it only when I asked for more memory than the node has (<code>Infeasible</code>).</li>
</ul>
<h2>What it looks like</h2>
<p>I ran a JVM pod on a single-node kind cluster, Kubernetes v1.35.8. The pod has Guaranteed QoS with a 900Mi memory request and limit. The JVM (Temurin 21, ZGC, <code>-Xmx600m</code>) allocates 300 MB, holds it for 10 seconds, then drops all of it and idles. Inside the container, <code>memory.current</code> read 417 MB.</p>
<p>I shrank it to 300Mi through the <code>resize</code> subresource:</p>
<pre><code class="language-bash">kubectl patch pod jvm --subresource resize -p \
  '{"spec":{"containers":[{"name":"app","resources":{"requests":{"memory":"300Mi"},"limits":{"memory":"300Mi"}}}]}}'
</code></pre>
<p>A few minutes later, <code>kubectl get pod jvm -o yaml</code> (trimmed) showed:</p>
<pre><code class="language-yaml">status:
  conditions:
  - message: 'cannot decrease memory limits: [attempting to set pod memory limit (314572800)
      below current usage (435347456), attempting to set container "app" memory limit
      (314572800) below current usage (435347456)]'
    observedGeneration: 2
    reason: Error
    status: "True"
    type: PodResizeInProgress
  containerStatuses:
  - name: app
    allocatedResources:
      cpu: 500m
      memory: 300Mi        # what the kubelet accepted
    resources:
      limits:
        cpu: 500m
        memory: 900Mi      # what is actually applied
    restartCount: 0
</code></pre>
<p>At +10 s the condition was <code>PodResizeInProgress=True</code> with no reason. By +31 s the reason was <code>Error</code>. The cgroup never changed: <code>memory.max</code> stayed at 900 MB, and <code>memory.current</code> stayed at 417 MB for the whole minute I watched.</p>
<p>The events show a repeating error, and the kubelet log says why:</p>
<pre><code>Normal   ResizeStarted   Pod resize started: {... "memory":"300Mi" ... "generation":2}
Warning  ResizeError     Pod resize error: {... "error":"cannot decrease memory limits: [attempting to set pod memory limit (314572800) below current usage (435347456) ...]"}
Warning  ResizeError     (repeats)

kubelet: "Allocated pod resize is not currently feasible" err="cannot decrease memory limits: ..."
kubelet: ... ResizePodInPlaceError ... (retries at 18:50:18, :33, :47, :58, 18:51:13, :25)
</code></pre>
<p>That matches what the Kubernetes docs say: before decreasing a container's memory limit, if memory usage exceeds the requested limit, the resize is skipped and the status remains in an "In Progress" state. They call this best-effort because usage can spike right after the check. What the docs don't show is the <code>Error</code> reason and the repeating <code>ResizeError</code> events, which look alarming but are the kubelet waiting.</p>
<h2>Why a JVM does this</h2>
<p>The kubelet compares the container's current memory usage with the new limit. A JVM's usage is dominated by committed heap, and the JVM does not return committed heap just because the objects in it are gone.</p>
<p>In my test the JVM sat idle for more than a minute with <code>ZUncommitDelay=1</code> and gave nothing back. The likely reason is that no garbage-collection cycle ran after the data was dropped, so there was nothing to trigger an uncommit. I did not confirm that, and the behavior depends on the collector. Don't count on a JVM to release by itself.</p>
<h2><code>PodResizeInProgress</code> vs <code>PodResizePending</code></h2>
<table>
<thead>
<tr>
<th>Condition</th>
<th>What it means</th>
<th>What I saw</th>
</tr>
</thead>
<tbody><tr>
<td><code>PodResizeInProgress</code></td>
<td>The kubelet accepted the new resources and is applying them.</td>
<td>With <code>Error</code> and "below current usage", the kubelet is retrying a memory decrease.</td>
</tr>
<tr>
<td><code>PodResizePending</code></td>
<td>The resize can't be started yet.</td>
<td><code>reason=Infeasible</code>, message <code>Node didn't have enough capacity: memory, requested: 68719476736, capacity: 8321712128</code>, when I asked for 64Gi on an 8 GB node.</td>
</tr>
</tbody></table>
<p>If you see <code>Infeasible</code> or <code>Deferred</code>, the problem is node capacity. If you see <code>InProgress</code> plus "below current usage", the problem is the container's own memory, and the fix is below.</p>
<h2>The fix: release memory from inside the JVM</h2>
<p>On ZGC, lower the soft ceiling and force a collection:</p>
<pre><code class="language-bash">kubectl exec jvm -- jcmd 1 VM.set_flag SoftMaxHeapSize 104857600
kubectl exec jvm -- jcmd 1 GC.run
</code></pre>
<p><strong>Releasing while the resize was pending.</strong> I ran the two commands against the stuck pod. <code>memory.current</code> fell from 417 MB to 96 MB. The kubelet applied the resize on its next retry, 20 seconds after I started the release. <code>memory.max</code> became 300 MB, <code>restartCount</code> stayed 0, the condition cleared, and a <code>ResizeCompleted</code> event appeared.</p>
<p><strong>Releasing before the patch.</strong> I recreated the pod, released first (424 MB → 101 MB), then patched. The resize applied 2 seconds after the patch. For this pod, the events were <code>ResizeStarted</code> and <code>ResizeCompleted</code> only. The <code>Error</code> condition never appeared.</p>
<p>Releasing first is cleaner: no error condition, no wait for a retry. But if you are already stuck, you don't need to re-patch. Free the memory and the kubelet finishes the job.</p>
<h2>When it stays stuck</h2>
<p>The resize stays "In Progress" if the JVM can't get below the new limit:</p>
<ul>
<li><strong>Live data is bigger than the new limit.</strong> If the heap holds more live objects than the limit, I'd expect no collection to free enough, and the resize to stay pending. I didn't test this case. Pick a limit that fits.</li>
<li><strong>The ceiling is fixed.</strong> <code>-Xmx</code> can't change on a running JVM. The new limit must be above <code>-Xmx</code> plus metaspace, code cache, thread stacks and direct buffers. If it isn't, you need a restart, which is what <code>resizePolicy: RestartContainer</code> is for.</li>
<li><strong>Memory outside the heap.</strong> <code>SoftMaxHeapSize</code> only moves the heap. Native memory and direct buffers stay.</li>
</ul>
<h2>A debugging checklist</h2>
<pre><code class="language-bash"># 1. Which condition? InProgress or Pending, and the reason
kubectl get pod &lt;pod&gt; -o jsonpath='{range .status.conditions[?(@.type=="PodResizeInProgress")]}{.reason}: {.message}{"\n"}{end}'
kubectl get pod &lt;pod&gt; -o jsonpath='{range .status.conditions[?(@.type=="PodResizePending")]}{.reason}: {.message}{"\n"}{end}'

# 2. What is accepted vs actually applied
kubectl get pod &lt;pod&gt; -o jsonpath='allocated={.status.containerStatuses[0].allocatedResources.memory} applied={.status.containerStatuses[0].resources.limits.memory}{"\n"}'

# 3. What the container is really using
kubectl exec &lt;pod&gt; -- sh -c 'cat /sys/fs/cgroup/memory.current /sys/fs/cgroup/memory.max'

# 4. The events
kubectl get events --field-selector involvedObject.name=&lt;pod&gt;
</code></pre>
<p>If <code>memory.current</code> is above the new limit, the JVM is the thing to fix.</p>
<p>One gotcha from my own test: use the default strategic merge patch, as in the command above. With <code>--type merge</code>, the whole <code>containers</code> list is replaced, and the API server rejects it with "resource requests cannot be removed".</p>
<h2>What I didn't test</h2>
<ul>
<li>Collectors other than ZGC in a cluster. I tested ZGC, Shenandoah and G1 in a container separately, in <a href="https://baokhang.hashnode.dev/g1-release-memory-kubernetes-in-place-pod-resize">another article</a>, but not through a kubelet.</li>
<li>A multi-container pod, or any version other than v1.35.8.</li>
<li>Whether the retry interval changes under load, or what happens if usage spikes right after the kubelet's check.</li>
<li>How long a release takes on a large heap under real traffic.</li>
</ul>
<h2>Reproduce it</h2>
<p>You need Docker, <code>kind</code> and <code>kubectl</code>. <code>Hold.java</code>:</p>
<pre><code class="language-java">import java.util.*;
public class Hold {
  public static void main(String[] a) throws Exception {
    List&lt;byte[]&gt; l = new ArrayList&lt;&gt;();
    for (int i = 0; i &lt; 300; i++) { byte[] b = new byte[1024*1024]; Arrays.fill(b,(byte)1); l.add(b); }
    System.out.println("allocated 300MB"); System.out.flush();
    Thread.sleep(10000);
    l.clear(); l = null;
    System.out.println("released refs"); System.out.flush();
    Thread.sleep(600000);
  }
}
</code></pre>
<p><code>pod.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: jvm
spec:
  containers:
  - name: app
    image: eclipse-temurin:21-jdk
    imagePullPolicy: IfNotPresent
    command: ["java","-XX:+UseZGC","-XX:ZUncommitDelay=1","-Xmx600m","/w/Hold.java"]
    volumeMounts: [{name: w, mountPath: /w}]
    resources:
      requests: {cpu: 500m, memory: 900Mi}
      limits:   {cpu: 500m, memory: 900Mi}
    resizePolicy:
    - {resourceName: memory, restartPolicy: NotRequired}
    - {resourceName: cpu, restartPolicy: NotRequired}
  volumes:
  - name: w
    configMap: {name: hold}
</code></pre>
<pre><code class="language-bash">kind create cluster --name resize-test --image kindest/node:v1.35.8
kubectl create configmap hold --from-file=Hold.java
kubectl apply -f pod.yaml
kubectl wait --for=condition=Ready pod/jvm --timeout=240s
# wait ~15 s until the JVM logs "released refs", then:
kubectl patch pod jvm --subresource resize -p \
  '{"spec":{"containers":[{"name":"app","resources":{"requests":{"memory":"300Mi"},"limits":{"memory":"300Mi"}}}]}}'
kubectl get pod jvm -o yaml        # PodResizeInProgress, reason Error
kubectl exec jvm -- jcmd 1 VM.set_flag SoftMaxHeapSize 104857600
kubectl exec jvm -- jcmd 1 GC.run  # the kubelet finishes on its next retry
kind delete cluster --name resize-test
</code></pre>
<p>The first image pull took about 90 seconds on my machine. The megabyte numbers will differ a little on yours.</p>
<h2>FAQ</h2>
<h3>Why is my Kubernetes pod resize stuck in progress?</h3>
<p>The kubelet will not lower a memory limit below the container's current usage, and it keeps retrying. Check <code>kubectl get pod &lt;pod&gt; -o yaml</code> for <code>PodResizeInProgress</code> with a message saying "below current usage". Free memory inside the container and the resize finishes.</p>
<h3>What is the difference between PodResizePending and PodResizeInProgress?</h3>
<p><code>PodResizePending</code> means the resize can't start yet. I saw it with <code>reason=Infeasible</code> when the request exceeded the node's capacity. <code>PodResizeInProgress</code> means the kubelet accepted the resize and is applying it. For a memory decrease it stays that way while usage is above the new limit.</p>
<h3>Why won't the kubelet complete an in-place memory decrease for my Java pod?</h3>
<p>A JVM keeps its committed heap after garbage collection, so the container's usage stays above the new limit. On ZGC or Shenandoah, lower <code>SoftMaxHeapSize</code> and run <code>GC.run</code>. On G1, use <code>GC.run</code> or <code>G1PeriodicGCInterval</code>.</p>
<h3>Will the pod be OOM-killed while the resize is stuck?</h3>
<p>Not in my test. The pod kept running at 900Mi, with 0 restarts, until I freed memory. The Kubernetes docs say the check is best-effort, with no guarantees.</p>
<h3>Do I need to patch again after freeing memory?</h3>
<p>No. The kubelet retries on its own. In my run it completed 20 seconds after I released memory, with no new patch.</p>
<h3>How long does the kubelet take to apply the resize after the JVM releases memory?</h3>
<p>20 seconds in the run where I released after the patch (the kubelet was retrying about every 11 to 15 seconds). If you release before the patch, it applied 2 seconds after.</p>
<h2>Where Warden fits</h2>
<p>I maintain <a href="https://github.com/baokhang83/mnemo-jvm-warden">mnemo-jvm-warden</a>, an open-source Kubernetes controller and sidecar that does the release-then-resize order. The sidecar lowers the soft heap ceiling where the collector supports it, forces <code>GC.run</code>, and checks that the process's resident memory dropped. Only then does the resize reach the kubelet. That order is the second case in this article, where the error never appears. I haven't tested that exact path in the cluster in this article.</p>
<p>You don't need a tool for the fix: the two <code>jcmd</code> commands above work on their own.</p>
]]></content:encoded></item><item><title><![CDATA[Your G1 Pod Ignores SoftMaxHeapSize. Here's What Works Instead]]></title><description><![CDATA[Tested G1, ZGC and Shenandoah in a Linux container: which flag returns memory, and which one only says it did.]]></description><link>https://baokhang.hashnode.dev/your-g1-pod-ignores-softmaxheapsize-here-s-what-works-instead</link><guid isPermaLink="true">https://baokhang.hashnode.dev/your-g1-pod-ignores-softmaxheapsize-here-s-what-works-instead</guid><category><![CDATA[Java]]></category><category><![CDATA[jvm]]></category><category><![CDATA[garbagecollection]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[BaoKhang]]></dc:creator><pubDate>Sun, 08 Nov 2026 18:36:15 GMT</pubDate><content:encoded><![CDATA[<p>When a pod shrinks in place, the JVM has to hand memory back to the operating system before the new limit applies. I tested how G1, ZGC and Shenandoah do that in a Linux container. All three released memory quickly in my test, but they differ in what triggers the release and in what <code>SoftMaxHeapSize</code> does. G1 accepts that flag and silently ignores it.</p>
<h2>The short answer</h2>
<ul>
<li><strong>ZGC and Shenandoah:</strong> <code>SoftMaxHeapSize</code> works at runtime. Lower it, run <code>GC.run</code>, and memory drops.</li>
<li><strong>G1:</strong> <code>SoftMaxHeapSize</code> is accepted by <code>jcmd</code> and has no effect. By default nothing returns memory. Two things did, in my test: an explicit <code>GC.run</code>, and <code>G1PeriodicGCInterval</code> set to a few seconds.</li>
<li><strong>Release speed:</strong> I measured no real difference. Each collector released a 300 MB working set within about 1 to 7 seconds. I did not measure large heaps or busy applications, which is where I'd expect differences, so treat "G1 is slower" as unproven.</li>
<li><strong>The real G1 difference</strong> is the missing ceiling. Nothing holds G1's heap below a target after you release, so you size <code>-Xmx</code> for the peak and accept that it can grow back.</li>
<li><strong>Lowering the limit first</strong> killed all three (OOMKilled, exit 137). Release first, then resize.</li>
</ul>
<h2>What each collector gives you</h2>
<table>
<thead>
<tr>
<th></th>
<th>ZGC</th>
<th>Shenandoah</th>
<th>G1</th>
</tr>
</thead>
<tbody><tr>
<td><code>SoftMaxHeapSize</code> changes behavior</td>
<td>Yes</td>
<td>Yes</td>
<td><strong>No</strong> (flag accepted, no effect)</td>
</tr>
<tr>
<td>Returns memory by default</td>
<td>After <code>ZUncommitDelay</code> (300 s by default)</td>
<td>After <code>ShenandoahUncommitDelay</code></td>
<td><strong>No</strong>, not without a trigger</td>
</tr>
<tr>
<td>What triggers a release</td>
<td><code>SoftMaxHeapSize</code> + <code>GC.run</code>, or the uncommit delay</td>
<td>Same</td>
<td><code>GC.run</code>, or <code>G1PeriodicGCInterval</code></td>
</tr>
<tr>
<td>Pause cost of the trigger</td>
<td>Not measured</td>
<td>Not measured</td>
<td><code>GC.run</code> is a full, stop-the-world GC. Not measured at scale.</td>
</tr>
</tbody></table>
<h2>The test</h2>
<p>The setup is the same as in my <a href="https://baokhang.hashnode.dev/jvm-resizepolicy-restartcontainer-not-required-zgc-shenandoah">earlier test of <code>RestartContainer</code></a>: Eclipse Temurin 21.0.13 in a Linux container on Docker Desktop, cgroup v2, and a small program that allocates and touches 300 MB, holds it for 10 seconds, then drops all of it and idles. <code>docker update --memory=300m</code> lowers the cgroup limit on the running container in place. That is the kernel mechanism a pod resize uses, but there is no kubelet.</p>
<p>For each collector, I ran two cases. <strong>Naive:</strong> lower the limit straight away. <strong>Ordered:</strong> set <code>SoftMaxHeapSize=100MB</code>, run <code>GC.run</code>, wait until <code>memory.current</code> falls, then lower the limit.</p>
<table>
<thead>
<tr>
<th>Collector</th>
<th><code>memory.current</code> before</th>
<th>Naive: lower limit to 300 MB</th>
<th>Ordered: release first, then lower</th>
</tr>
</thead>
<tbody><tr>
<td>ZGC (<code>-Xmx600m</code>, limit 900 MB)</td>
<td>403 MB</td>
<td><strong>OOMKilled</strong>, exit 137</td>
<td>404 → <strong>89 MB</strong> in 3 s, then <strong>still running</strong></td>
</tr>
<tr>
<td>Shenandoah (<code>-Xmx500m</code>, limit 700 MB)</td>
<td>372 MB</td>
<td><strong>OOMKilled</strong>, exit 137</td>
<td>361 → <strong>64 MB</strong> in 1 s, then <strong>still running</strong></td>
</tr>
<tr>
<td>G1 (<code>-Xmx600m</code>, 4 MB regions, limit 900 MB)</td>
<td>456 MB</td>
<td><strong>OOMKilled</strong>, exit 137</td>
<td>463 → <strong>143 MB</strong> in 2 s, then <strong>still running</strong></td>
</tr>
</tbody></table>
<p>G1 passes the ordered case, but I want to be exact about why. In that run I set <code>SoftMaxHeapSize</code> too, and it did nothing. The explicit <code>GC.run</code> did the work.</p>
<h3>What actually released memory on G1</h3>
<p>I tested each G1 lever on its own, starting from about 460 to 530 MB in use:</p>
<table>
<thead>
<tr>
<th>Lever on G1</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>Nothing (default), wait 25 s</td>
<td>459 → 459 MB. <strong>No release.</strong></td>
</tr>
<tr>
<td><code>SoftMaxHeapSize=100MB</code> only, wait 20 s</td>
<td>535 → 535 MB. <strong>No effect.</strong></td>
</tr>
<tr>
<td><code>GC.run</code> only</td>
<td>472 → <strong>150 MB</strong></td>
</tr>
<tr>
<td><code>G1PeriodicGCInterval=3000</code> set live, no <code>GC.run</code></td>
<td>518 → <strong>156 MB</strong> in 7 s</td>
</tr>
</tbody></table>
<p><code>SoftMaxHeapSize</code> is listed as a manageable flag on G1 in JDK 21, so <code>jcmd VM.set_flag</code> returns "Command executed successfully". That success message is misleading. Nothing in G1 reads the flag. If your automation sets the flag and then checks that the command succeeded, it will believe it lowered the ceiling on G1 when it did not.</p>
<h2>How to make G1 release memory back to the OS</h2>
<p>Pick one of these. I tested the first two.</p>
<ol>
<li><strong>Run <code>GC.run</code> from outside.</strong> <code>jcmd &lt;pid&gt; GC.run</code> triggers a full GC, after which G1 shrinks its heap. It is immediate, and it is a stop-the-world collection, so its pause grows with the live set.</li>
<li><strong>Enable periodic GC.</strong> <code>G1PeriodicGCInterval</code> is a manageable flag, default <code>0</code> (off). Set it at startup, or live: <code>jcmd &lt;pid&gt; VM.set_flag G1PeriodicGCInterval 3000</code> (milliseconds). In my test, memory fell within 7 seconds on an idle JVM. A related flag, <code>G1PeriodicGCSystemLoadThreshold</code>, is also manageable and, per JEP 346, can hold the periodic GC back while the system is busy. I did not test it.</li>
<li><strong>Tune <code>MinHeapFreeRatio</code> and <code>MaxHeapFreeRatio</code>.</strong> These are manageable and control how much free heap G1 keeps after a collection. I did not test them here.</li>
</ol>
<h2>What this means for in-place resize</h2>
<ul>
<li><strong>Release before you resize, on every collector.</strong> The failure was identical across all three: lowering the limit while the JVM held committed memory got it killed.</li>
<li><strong>Don't trust "command succeeded" on G1.</strong> Verify that <code>memory.current</code> actually dropped, then resize. In Kubernetes, the kubelet's check also compares current usage to the new limit and holds the resize while usage is higher, although the docs say that check is best-effort.</li>
<li><strong>Plan the ceiling on G1.</strong> With ZGC and Shenandoah, <code>SoftMaxHeapSize</code> keeps the heap near your target after release. On G1 nothing does, so a G1 heap that was just released can grow back toward <code>-Xmx</code> before the limit drops. I did not test how fast. The practical rule is the one from my earlier article: the smallest limit must exceed <code>-Xmx</code> plus non-heap memory, and on G1 you cannot rely on a soft ceiling to enforce it.</li>
<li><strong>Mind the delay on ZGC.</strong> <code>ZUncommitDelay</code> defaults to 300 seconds. I shortened it to 1 second for the test. Without that, a ZGC release can take minutes unless you also lower the soft ceiling and wait for the uncommit.</li>
</ul>
<h2>What I didn't test</h2>
<ul>
<li>Heap sizes above about 600 MB, live data that doesn't die, or JVMs under load. A full GC on G1 can cost far more at scale, and I haven't measured it.</li>
<li>Whether G1 re-expands after release.</li>
<li><code>G1PeriodicGCSystemLoadThreshold</code>, <code>MinHeapFreeRatio</code> and <code>MaxHeapFreeRatio</code>.</li>
<li>A real kubelet. I used <code>docker update</code>.</li>
<li>G1 with the default region size. I set 4 MB regions because my test allocates 1 MB arrays, which are "humongous" objects in G1's default configuration, and the first G1 run ran out of heap.</li>
</ul>
<h2>Reproduce it</h2>
<p>You need Docker. Save the files below in an empty folder.</p>
<p><code>Hold.java</code>:</p>
<pre><code class="language-java">import java.util.*;
public class Hold {
  public static void main(String[] a) throws Exception {
    List&lt;byte[]&gt; l = new ArrayList&lt;&gt;();
    for (int i = 0; i &lt; 300; i++) { byte[] b = new byte[1024*1024]; Arrays.fill(b,(byte)1); l.add(b); }
    System.out.println("allocated 300MB"); System.out.flush();
    Thread.sleep(10000);
    l.clear(); l = null;
    System.out.println("released refs"); System.out.flush();
    Thread.sleep(600000);
  }
}
</code></pre>
<p><code>run.sh</code> runs the naive and ordered cases for any collector:</p>
<pre><code class="language-bash">#!/bin/bash
# usage: ./run.sh &lt;naive|ordered&gt; &lt;jvm flags...&gt;      (run from the folder containing Hold.java)
# Example: LIM=900 XMX=600 ./run.sh ordered -XX:+UseZGC -XX:ZUncommitDelay=1
set -u
mode=$1; shift; n=warden-test
docker rm -f $n &gt;/dev/null 2&gt;&amp;1
docker run --rm -v "$PWD":/w -w /w eclipse-temurin:21-jdk javac Hold.java
docker run -d --name $n --memory=${LIM:-700}m --memory-swap=${LIM:-700}m -v "$PWD":/w -w /w \
  eclipse-temurin:21-jdk java "$@" -Xmx${XMX:-500}m Hold &gt;/dev/null
for i in $(seq 1 60); do docker logs $n 2&gt;&amp;1 | grep -q 'released refs' &amp;&amp; break; sleep 1; done; sleep 2
mem() { echo $(( $(docker exec $n cat /sys/fs/cgroup/memory.current) / 1048576 )); }
echo "memory.current after the app dropped its data: $(mem) MB"
if [ "$mode" = ordered ]; then
  docker exec $n jcmd 1 VM.set_flag SoftMaxHeapSize 104857600   # 100 MB soft ceiling
  docker exec $n jcmd 1 GC.run
  for i in $(seq 1 30); do [ "$(mem)" -lt 250 ] &amp;&amp; break; sleep 1; done
  echo "memory.current after SoftMaxHeapSize + GC.run: $(mem) MB"
fi
docker update --memory=300m --memory-swap=300m $n        # in-place cgroup change, no restart
sleep 8
docker inspect -f 'running={{.State.Running}} OOMKilled={{.State.OOMKilled}} exit={{.State.ExitCode}}' $n
docker rm -f $n &gt;/dev/null 2&gt;&amp;1
</code></pre>
<pre><code class="language-bash">chmod +x run.sh
LIM=900 XMX=600 ./run.sh naive   -XX:+UseG1GC -XX:G1HeapRegionSize=4m
LIM=900 XMX=600 ./run.sh ordered -XX:+UseG1GC -XX:G1HeapRegionSize=4m
LIM=900 XMX=600 ./run.sh naive   -XX:+UseZGC -XX:ZUncommitDelay=1
LIM=900 XMX=600 ./run.sh ordered -XX:+UseZGC -XX:ZUncommitDelay=1
LIM=700 XMX=500 ./run.sh naive   -XX:+UseShenandoahGC -XX:+UnlockExperimentalVMOptions -XX:ShenandoahUncommitDelay=1000
LIM=700 XMX=500 ./run.sh ordered -XX:+UseShenandoahGC -XX:+UnlockExperimentalVMOptions -XX:ShenandoahUncommitDelay=1000
</code></pre>
<p><code>g1-probe.sh</code> tests each G1 lever on its own:</p>
<pre><code class="language-bash">#!/bin/bash
# Which G1 lever actually returns memory? Run from the folder containing Hold.java.
set -u
docker run --rm -v "$PWD":/w -w /w eclipse-temurin:21-jdk javac Hold.java
start() { docker rm -f $1 &gt;/dev/null 2&gt;&amp;1
  docker run -d --name $1 --memory=900m --memory-swap=900m -v "$PWD":/w -w /w eclipse-temurin:21-jdk \
    java -XX:+UseG1GC -XX:G1HeapRegionSize=4m -Xmx600m Hold &gt;/dev/null
  for i in $(seq 1 60); do docker logs $1 2&gt;&amp;1 | grep -q 'released refs' &amp;&amp; break; sleep 1; done; sleep 2; }
mem() { echo $(( $(docker exec $1 cat /sys/fs/cgroup/memory.current) / 1048576 )); }

echo "1) nothing (default), wait 25s";           start g1; b=$(mem g1); sleep 25; echo "   $b MB -&gt; $(mem g1) MB"
echo "2) SoftMaxHeapSize=100MB only, wait 20s";  start g1; b=$(mem g1)
docker exec g1 jcmd 1 VM.set_flag SoftMaxHeapSize 104857600 &gt;/dev/null; sleep 20; echo "   $b MB -&gt; $(mem g1) MB"
echo "3) GC.run only";                           start g1; b=$(mem g1)
docker exec g1 jcmd 1 GC.run &gt;/dev/null; sleep 4; echo "   $b MB -&gt; $(mem g1) MB"
echo "4) G1PeriodicGCInterval=3000 set live";    start g1; b=$(mem g1)
docker exec g1 jcmd 1 VM.set_flag G1PeriodicGCInterval 3000 &gt;/dev/null
for i in $(seq 1 40); do [ "$(mem g1)" -lt 250 ] &amp;&amp; break; sleep 1; done; echo "   $b MB -&gt; $(mem g1) MB (after ${i}s)"
docker rm -f g1 &gt;/dev/null 2&gt;&amp;1
</code></pre>
<p>Expect <code>OOMKilled=true exit=137</code> for naive runs and <code>running=true</code> for ordered ones. The megabyte numbers will differ a little on your machine.</p>
<h2>FAQ</h2>
<h3>Which garbage collector is best for Kubernetes in-place pod resize?</h3>
<p>In my test, ZGC and Shenandoah are the easiest, because <code>SoftMaxHeapSize</code> changes their behavior and keeps the heap near a target. G1 also works if something triggers a release, but it has no soft ceiling. Pick the collector your latency requirements need first, and then use the matching release method.</p>
<h3>How do I make G1 release memory back to the OS?</h3>
<p>Run <code>jcmd &lt;pid&gt; GC.run</code>, or set <code>G1PeriodicGCInterval</code> to a value in milliseconds (default <code>0</code>, off). In my test both returned memory within a few seconds on a small idle heap.</p>
<h3>Does <code>SoftMaxHeapSize</code> work with G1?</h3>
<p>No. <code>jcmd VM.set_flag SoftMaxHeapSize</code> succeeds on G1 in JDK 21, but I saw no change in memory over 20 seconds, so I conclude G1 ignores it. It works on ZGC and Shenandoah.</p>
<h3>Why was my pod OOMKilled after an in-place memory decrease?</h3>
<p>The limit dropped while the JVM still held more committed memory than the new limit allows. In my test this killed the JVM on G1, ZGC and Shenandoah every time. Release memory first, check the resident memory, then lower the limit.</p>
<h3>Is <code>GC.run</code> safe to use in production?</h3>
<p>It triggers a full collection. On G1 that is stop-the-world, and its pause grows with the live set. I only tested a 600 MB heap on an idle JVM, so measure the pause on your own heap before relying on it.</p>
<h3>Do I still need <code>RestartContainer</code>?</h3>
<p>Only if the heap ceiling has to move. See my <a href="https://baokhang.hashnode.dev/jvm-resizepolicy-restartcontainer-not-required-zgc-shenandoah">earlier article on <code>RestartContainer</code></a> for the case where it is and isn't required.</p>
<h2>Where Warden fits</h2>
<p>I maintain <a href="https://github.com/baokhang83/mnemo-jvm-warden">mnemo-jvm-warden</a>, an open-source Kubernetes controller and sidecar that does the release-then-resize order for you. From its code and docs, it adjusts to the collector like this:</p>
<ul>
<li><strong>Detection:</strong> the agent reads the target's GC beans and records, per collector, whether a soft max and uncommit are supported. ZGC and Shenandoah support both. G1 supports uncommit only. Serial, Parallel and Epsilon are treated as unsupported, and the agent stays read-only for them.</li>
<li><strong>Release:</strong> it sets <code>SoftMaxHeapSize</code> only where the collector has it, so on G1 that step is skipped instead of being sent and ignored. It then forces <code>GC.run</code> and polls the committed heap until it drops and holds steady. It checks the process's resident memory before it shrinks the pod.</li>
<li><strong>Boost on G1:</strong> a G1 workload that wants a Boost lease must already launch with <code>-Xmx</code> covering the largest <code>heapTarget</code> it will request, because there is no soft ceiling to raise later. The agent rejects a heap target above the launch-time <code>-Xmx</code> for every collector, and the Boost docs note that G1 has no live soft-max knob, so a G1 Boost only ever confirms that the allocation change landed.</li>
</ul>
<p>The conclusions above do not depend on any tool. The <code>jcmd</code> commands work without it.</p>
]]></content:encoded></item><item><title><![CDATA[Why Kubernetes 1.35 resizePolicy: RestartContainer Isn't Required for ZGC/Shenandoah]]></title><description><![CDATA[A lot of current advice says to set resizePolicy: RestartContainer for memory on every JVM container. For growing the heap ceiling, that advice is right…]]></description><link>https://baokhang.hashnode.dev/why-kubernetes-1-35-resizepolicy-restartcontainer-isn-t-required-for-zgc-shenandoah</link><guid isPermaLink="true">https://baokhang.hashnode.dev/why-kubernetes-1-35-resizepolicy-restartcontainer-isn-t-required-for-zgc-shenandoah</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Java]]></category><category><![CDATA[jvm]]></category><category><![CDATA[Devops]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[BaoKhang]]></dc:creator><pubDate>Sun, 08 Nov 2026 17:39:27 GMT</pubDate><content:encoded><![CDATA[<p>A lot of current advice says to set <code>resizePolicy: RestartContainer</code> for memory on every JVM container. For growing the heap ceiling, that advice is right. For shrinking and regrowing within a ceiling you chose at launch, I tested ZGC and Shenandoah in a Linux container and found a restart isn't needed, as long as the JVM releases memory before the limit drops.</p>
<h2>The short version</h2>
<ul>
<li>Kubernetes 1.35 can change a running container's memory in place (the <code>pods/resize</code> subresource is GA).</li>
<li>A JVM fixes its maximum heap at startup. Raising the limit in place does not raise the heap. Two popular articles are right about this.</li>
<li>That does <strong>not</strong> mean every memory change needs a restart. Three different operations hide inside "resize memory", and only one of them requires it.</li>
<li>On ZGC and Shenandoah you can lower and raise the heap's soft ceiling at runtime. In my test, releasing memory first let a container's limit drop from 900 MB (or 700 MB) to 300 MB with no restart. Lowering the limit first got the JVM OOM-killed every time.</li>
</ul>
<h2>What the advice says, and where it's right</h2>
<p>Rack2Cloud's <a href="https://www.rack2cloud.com/vertical-pod-autoscaler-in-place-resize-production/">VPA in production article</a> says: "<code>-Xmx</code> is a startup parameter. The JVM reads it once at initialization and sets the maximum heap ceiling for the lifetime of the process." It concludes that <code>resizePolicy: RestartContainer</code> for memory "is the correct setting for JVM services, not <code>NotRequired</code>". Its <a href="https://www.rack2cloud.com/kubernetes-1-35-in-place-pod-resize-production/">Kubernetes 1.35 article</a> lists <code>RestartContainer</code> as appropriate "for memory decreases on workloads where the allocator won't release memory without a restart".</p>
<p>Shubham Chepe's <a href="https://shubhamchepe.com/blog/kubernetes-in-place-pod-resize-java-jvm.html">post on the JVM and in-place resize</a> gives the same prescription: "CPU <code>NotRequired</code>, memory <code>RestartContainer</code>". He explains why: "If you raise the memory limit in place, the heap ceiling doesn't grow." On shrinking he says it is "riskier still, since the JVM may already be using more than the new limit allows."</p>
<p>Both are correct that:</p>
<ul>
<li>the heap ceiling comes from <code>-Xmx</code> or <code>-XX:MaxRAMPercentage</code> at launch, and</li>
<li>a bigger cgroup limit does not make a running JVM use it.</li>
</ul>
<p>If you need the heap ceiling itself to move, you need a restart. This article doesn't dispute that.</p>
<h2>Three operations, not one</h2>
<table>
<thead>
<tr>
<th>Operation</th>
<th>Needs a restart?</th>
</tr>
</thead>
<tbody><tr>
<td>Grow <strong>beyond</strong> the <code>-Xmx</code> the JVM launched with</td>
<td>Yes. The ceiling is fixed.</td>
</tr>
<tr>
<td><strong>Shrink</strong> the container's memory</td>
<td>Not on ZGC or Shenandoah, if the JVM releases memory first.</td>
</tr>
<tr>
<td>Grow <strong>back</strong> up to the launch-time ceiling</td>
<td>No. Raise the container limit, then raise the soft ceiling.</td>
</tr>
</tbody></table>
<p>The advice treats all three as the first one. That is the right call if you size <code>-Xmx</code> to today's limit. It is the wrong one if you set <code>-Xmx</code> for the peak and treat memory <em>below</em> that as adjustable.</p>
<p>ZGC and Shenandoah make this possible because of <code>SoftMaxHeapSize</code>, a manageable flag. You can change it on a running JVM:</p>
<pre><code class="language-plaintext">jcmd &lt;pid&gt; VM.set_flag SoftMaxHeapSize &lt;bytes&gt;
jcmd &lt;pid&gt; GC.run
</code></pre>
<p>The collector tries to stay under the soft limit and uncommits what it frees. <code>-Xmx</code> remains the hard ceiling.</p>
<h2>The test</h2>
<p>I wanted to see what actually happens, so I ran it. The setup:</p>
<ul>
<li>Eclipse Temurin 21.0.13 in a Linux container on Docker Desktop, on cgroup v2 (<code>/sys/fs/cgroup/memory.current</code> was present).</li>
<li>A small program that allocates and touches 300 MB, holds it for 10 seconds, then drops all references and idles.</li>
<li><code>docker update --memory=300m --memory-swap=300m</code> to lower the cgroup limit on the running container. This changes <code>memory.max</code> in place, which is the same kernel mechanism a pod resize uses, but it is not the kubelet.</li>
<li>Two runs per collector. <strong>Naive:</strong> lower the limit straight away. <strong>Ordered:</strong> set <code>SoftMaxHeapSize=100MB</code>, run <code>GC.run</code>, wait for <code>memory.current</code> to fall, and only then lower the limit.</li>
<li>Uncommit delays were shortened (<code>ZUncommitDelay=1</code>, <code>ShenandoahUncommitDelay=1000</code>) so the test finishes quickly. Defaults are longer.</li>
</ul>
<p>Results:</p>
<table>
<thead>
<tr>
<th>Collector</th>
<th><code>memory.current</code> before</th>
<th>Naive: lower limit to 300 MB</th>
<th>Ordered: release first, then lower</th>
</tr>
</thead>
<tbody><tr>
<td>ZGC (<code>-Xmx600m</code>, limit 900 MB)</td>
<td>403 MB</td>
<td><strong>OOMKilled</strong>, exit 137</td>
<td>404 → <strong>89 MB</strong> in 3 s, then limit lowered: <strong>still running</strong></td>
</tr>
<tr>
<td>Shenandoah (<code>-Xmx500m</code>, limit 700 MB)</td>
<td>372 MB</td>
<td><strong>OOMKilled</strong>, exit 137</td>
<td>361 → <strong>64 MB</strong> in 1 s, then limit lowered: <strong>still running</strong></td>
</tr>
</tbody></table>
<p>With the ordered sequence, <code>memory.max</code> read back as 314572800 (300 MB) and the container never restarted.</p>
<h2>What the kubelet adds</h2>
<p>My test used Docker, so it had no kubelet. In a real cluster, the kubelet tries to avoid an OOM kill when a memory limit goes down: according to the Kubernetes docs, if current usage is above the requested limit, the resize is skipped and stays "In Progress". The docs call this best-effort, with no guarantee, and note a race where usage can spike right after the check.</p>
<p>For a JVM, that has a practical consequence. Committed heap counts as memory in use even when most of it is free, so the resize usually sits pending until the JVM gives memory back. Releasing first is what gets usage under the new limit, so the kubelet applies the resize. The ordered sequence works with the kubelet's check, not against it.</p>
<h2>When RestartContainer is still the right answer</h2>
<ul>
<li>You need the heap ceiling to change. <code>-Xmx</code> is fixed per process.</li>
<li>You run G1 and haven't enabled periodic GC. G1 has no soft ceiling and returns heap mainly at a full GC or concurrent cycle, so release is slower and less predictable. I didn't test it here.</li>
<li>You can accept the restart. A restart discards compiled code, loaded classes, pools and caches, and a service with few replicas loses capacity while it warms up. If that is fine for you, <code>RestartContainer</code> is simpler and needs no extra tooling.</li>
<li>Your limit is already close to <code>-Xmx</code> plus non-heap memory. Shrinking safely needs room: the smallest limit must exceed <code>-Xmx</code> plus metaspace, code cache, thread stacks and direct buffers.</li>
</ul>
<h2>What this test does not show</h2>
<ul>
<li>It is a synthetic program, not a production service, and I tested ZGC and Shenandoah on one JDK build.</li>
<li>ZGC and Shenandoah used slightly different heap and limit sizes (600/900 MB and 500/700 MB). My first ZGC run at 500 MB ran out of heap while filling 300 MB, so I gave it more room.</li>
<li>It was not run through a kubelet. The kubelet's check will change what you see on a real cluster.</li>
<li>I haven't measured how long a release takes on a large heap under real load.</li>
</ul>
<h2>Reproduce it</h2>
<p>You need Docker. Save the two files below in an empty folder.</p>
<p><code>Hold.java</code> allocates and touches 300 MB, holds it for 10 seconds, then drops it:</p>
<pre><code class="language-java">import java.util.*;
public class Hold {
  public static void main(String[] a) throws Exception {
    List&lt;byte[]&gt; l = new ArrayList&lt;&gt;();
    for (int i = 0; i &lt; 300; i++) { byte[] b = new byte[1024*1024]; Arrays.fill(b,(byte)1); l.add(b); }
    System.out.println("allocated 300MB"); System.out.flush();
    Thread.sleep(10000);
    l.clear(); l = null;
    System.out.println("released refs"); System.out.flush();
    Thread.sleep(600000);
  }
}
</code></pre>
<p><code>run.sh</code> starts a container, optionally releases memory first, then lowers the cgroup limit in place:</p>
<pre><code class="language-bash">#!/bin/bash
# usage: ./run.sh &lt;naive|ordered&gt; &lt;jvm flags...&gt;      (run from the folder containing Hold.java)
# Example: LIM=900 XMX=600 ./run.sh ordered -XX:+UseZGC -XX:ZUncommitDelay=1
set -u
mode=$1; shift; n=warden-test
docker rm -f $n &gt;/dev/null 2&gt;&amp;1
docker run --rm -v "$PWD":/w -w /w eclipse-temurin:21-jdk javac Hold.java
docker run -d --name $n --memory=${LIM:-700}m --memory-swap=${LIM:-700}m -v "$PWD":/w -w /w \
  eclipse-temurin:21-jdk java "$@" -Xmx${XMX:-500}m Hold &gt;/dev/null
for i in $(seq 1 60); do docker logs $n 2&gt;&amp;1 | grep -q 'released refs' &amp;&amp; break; sleep 1; done; sleep 2
mem() { echo $(( $(docker exec $n cat /sys/fs/cgroup/memory.current) / 1048576 )); }
echo "memory.current after the app dropped its data: $(mem) MB"
if [ "$mode" = ordered ]; then
  docker exec $n jcmd 1 VM.set_flag SoftMaxHeapSize 104857600   # 100 MB soft ceiling
  docker exec $n jcmd 1 GC.run
  for i in $(seq 1 30); do [ "$(mem)" -lt 250 ] &amp;&amp; break; sleep 1; done
  echo "memory.current after SoftMaxHeapSize + GC.run: $(mem) MB"
fi
docker update --memory=300m --memory-swap=300m $n        # in-place cgroup change, no restart
sleep 8
docker inspect -f 'running={{.State.Running}} OOMKilled={{.State.OOMKilled}} exit={{.State.ExitCode}}' $n
docker rm -f $n &gt;/dev/null 2&gt;&amp;1
</code></pre>
<p>Then run the four cases:</p>
<pre><code class="language-bash">chmod +x run.sh
LIM=900 XMX=600 ./run.sh naive   -XX:+UseZGC -XX:ZUncommitDelay=1
LIM=900 XMX=600 ./run.sh ordered -XX:+UseZGC -XX:ZUncommitDelay=1
LIM=700 XMX=500 ./run.sh naive   -XX:+UseShenandoahGC -XX:+UnlockExperimentalVMOptions -XX:ShenandoahUncommitDelay=1000
LIM=700 XMX=500 ./run.sh ordered -XX:+UseShenandoahGC -XX:+UnlockExperimentalVMOptions -XX:ShenandoahUncommitDelay=1000
</code></pre>
<p>Expect <code>OOMKilled=true exit=137</code> for the naive runs and <code>running=true OOMKilled=false</code> for the ordered ones. The exact megabyte numbers will differ a little on your machine.</p>
<h2>FAQ</h2>
<h3>Do I need <code>resizePolicy: RestartContainer</code> for JVM memory?</h3>
<p>Only if the heap ceiling has to move. A JVM fixes its maximum heap at startup, so growing beyond the <code>-Xmx</code> it launched with needs a restart. Shrinking, and growing back up to the launch ceiling, did not need one on ZGC or Shenandoah in my test, as long as the JVM released memory before the limit dropped.</p>
<h3>Why doesn't raising the container's memory limit give the JVM more heap?</h3>
<p>The JVM computes its maximum heap once, from <code>-Xmx</code> or from <code>-XX:MaxRAMPercentage</code> applied to the limit it saw at startup. A later in-place change to the cgroup limit does not change that number.</p>
<h3>Can I change <code>-Xmx</code> on a running JVM?</h3>
<p>No. What you can change on ZGC and Shenandoah is the soft ceiling, <code>SoftMaxHeapSize</code>, which sits at or below <code>-Xmx</code>.</p>
<h3>What happens if I lower the memory limit without releasing memory first?</h3>
<p>In my test the JVM was OOM-killed (exit code 137) every time, on both ZGC and Shenandoah. On a real cluster the kubelet makes a best-effort attempt to avoid this and may keep the resize pending while usage is above the new limit. The Kubernetes docs say this check gives no guarantees, because usage can spike right after it.</p>
<h3>How do I make a JVM give memory back without restarting it?</h3>
<p>On ZGC and Shenandoah, lower the soft ceiling and force a collection:</p>
<pre><code>jcmd &lt;pid&gt; VM.set_flag SoftMaxHeapSize &lt;bytes&gt;
jcmd &lt;pid&gt; GC.run
</code></pre>
<p>Then check that the process's resident memory dropped before you lower the limit. The collector's uncommit delay applies: I shortened it for the test, and the defaults are longer.</p>
<h3>Does this work with G1?</h3>
<p>I didn't test it. G1 has no soft heap ceiling, so you cannot use the same flag. Plan for a slower, less predictable release, or use <code>RestartContainer</code>.</p>
<h3>What is the default <code>resizePolicy</code>?</h3>
<p><code>NotRequired</code> for both CPU and memory, which means the change is applied without restarting the container. Setting <code>RestartContainer</code> for memory restarts the container, not the whole pod.</p>
<h3>How small can the memory limit go?</h3>
<p>The smallest limit must stay above <code>-Xmx</code> plus non-heap memory (metaspace, code cache, thread stacks, direct buffers) plus working-set headroom. For a typical Spring Boot service the non-heap part is commonly 100 to 150 MiB.</p>
<h2>Where Warden fits</h2>
<p>I maintain <a href="https://github.com/baokhang83/mnemo-jvm-warden">mnemo-jvm-warden</a>, an open-source Kubernetes controller and sidecar that runs this order for you: lower the soft ceiling, force a GC, verify the process's resident memory dropped, and only then shrink the pod in place. To grow, it reverses the order. It does not change <code>-Xmx</code>, so the same sizing rule applies. There is a <a href="https://github.com/baokhang83/mnemo-jvm-warden/blob/main/docs/shrink-jvm-memory-kubernetes.md">guide</a> with the full reasoning, and a <a href="https://baokhang.hashnode.dev/shrink-jvm-memory-kubernetes-in-place-resize">walkthrough on a kind cluster</a>.</p>
<p>The point of this article doesn't depend on any tool. The <code>jcmd</code> commands above work without it.</p>
]]></content:encoded></item><item><title><![CDATA[Your JVMs Are Burning Money Overnight — Here's How to Shrink Them Safely in Kubernetes]]></title><description><![CDATA[Java pods pay for peak memory all night. See how to shrink the JVM first, verify it, then resize the pod in place, with no restarts.]]></description><link>https://baokhang.hashnode.dev/shrink-jvm-memory-kubernetes-in-place-resize</link><guid isPermaLink="true">https://baokhang.hashnode.dev/shrink-jvm-memory-kubernetes-in-place-resize</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Java]]></category><category><![CDATA[jvm]]></category><category><![CDATA[Devops]]></category><category><![CDATA[finops]]></category><dc:creator><![CDATA[BaoKhang]]></dc:creator><pubDate>Sun, 08 Nov 2026 10:37:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a5b81d55ebd45e7725fb67c/01948f36-634f-4081-8b14-0478fc0804a7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A hands-on walkthrough: three replicas of a Spring Boot service, resized live by <a href="https://github.com/baokhang83/mnemo-jvm-warden">mnemo-jvm-warden</a> on a two-minute cycle, the way you'd actually run it in production.</p>
<p><strong>Quick answer:</strong> To shrink a Java pod's memory in Kubernetes without OOM kills, release memory inside the JVM first (lower the soft heap ceiling, force a GC), verify that the resident memory actually dropped, and only then lower the pod's memory request and limit with Kubernetes in-place resize (GA in 1.35). mnemo-jvm-warden automates this order on a schedule, and it grows memory in the reverse order.</p>
<h2>TL;DR</h2>
<ul>
<li>Java services that are busy during the day and idle at night keep paying for their peak-hour memory 24/7, because Kubernetes doesn't know the difference.</li>
<li>Just lowering the memory limit doesn't work — the JVM doesn't give heap back on its own, and a limit drop before it does gets you an OOM kill.</li>
<li><strong>mnemo-jvm-warden</strong> sequences both sides correctly: shrink the JVM first, verify it actually gave memory back, <em>then</em> shrink the pod. Grow is the mirror image, cgroup first.</li>
<li>This walkthrough runs that against a real 3-replica Deployment, one <code>WardenPolicy</code>, one controller — and ends with a sizing rule that will OOM your pods if you skip it.</li>
</ul>
<h2>The problem Kubernetes can't see</h2>
<p>Plenty of Java services have a daily rhythm: busy from 9 to 6, nearly idle overnight. During the day they need a real heap to handle peak load. At 3 a.m. they're using a fraction of it.</p>
<p>Kubernetes has no idea. A pod's memory request is whatever you wrote in the manifest, and it stays reserved — unused or not — until something changes it. Multiply that by a fleet, and you're paying for peak capacity around the clock for load that only shows up eight hours a day.</p>
<p>The obvious fix — just lower the memory limit at night — doesn't work. The JVM doesn't hand committed heap back to the OS on its own schedule; it hands it back when <em>it</em> decides to, if you ask it to. Shrink the container before the JVM has actually released that memory, and the kernel OOM-kills the process the moment it tries to use heap that no longer fits.</p>
<p>Warden's whole job is getting the order right:</p>
<ol>
<li>Ask the JVM to release memory — lower its soft heap ceiling, force a GC, wait for pages to actually leave the process.</li>
<li>Verify the drop happened (actual resident memory, not a hope).</li>
<li>Only then shrink the pod's memory request/limit via Kubernetes' in-place resize.</li>
</ol>
<p>Growing back for the morning runs the same sequence in reverse: raise the pod's memory <em>first</em>, then raise the JVM's ceiling — headroom before demand, never after.</p>
<p>By the end of this walkthrough you'll have watched three pods breathe in and out on a two-minute cycle, and you'll know every moving part well enough to point it at your own service.</p>
<h2>The mental model: a brain and three pairs of hands</h2>
<p>Warden splits into two roles.</p>
<p><strong>The controller is the brain.</strong> One per cluster. It watches <code>WardenPolicy</code> objects, decides when a resize should happen based on the schedule, and tells the relevant pods what to do. It never touches a JVM directly.</p>
<p><strong>The agent is the hands.</strong> One per pod, running as a sidecar. It knows nothing about cron schedules or business hours — its only job is to carry out a safe resize for <em>its own</em> JVM when told to, and report back.</p>
<img src="https://raw.githubusercontent.com/baokhang83/mnemo-jvm-warden/main/docs/assets/blog/warden-architecture.webp" alt="Flow diagram: the WardenPolicy defines schedule and memory profiles, read by the warden-controller (the brain), which patches target-request-bytes and target-limit-bytes annotations onto each pod. Inside each of the three replica pods, the warden sidecar (the hands) polls its own annotations, drives GC, verifies RSS, and resizes the pod in place around the app container." style="display:block;margin:0 auto" />

<p>This split is what makes it scale. Ten pods or ten thousand, the scheduling logic stays in one place, and every resize stays local to the pod that's actually doing it — no coordinator bottleneck, no pod needing to know about any other pod.</p>
<h2>What you'll need</h2>
<ul>
<li>A Kubernetes cluster on <strong>1.35+</strong>. In-place pod resizing — the <code>pods/resize</code> subresource that changes a running pod's memory without restarting it — went GA there. Earlier versions need the <code>InPlacePodVerticalScaling</code> feature gate turned on by hand.</li>
<li><code>kubectl</code>, <code>kind</code>, and <code>helm</code> on your machine.</li>
</ul>
<pre><code class="language-bash">% kubectl version --client &amp;&amp; kind version &amp;&amp; helm version

Client Version: v1.36.1
Kustomize Version: v5.8.1
kind v0.33.0-alpha+4158cd8dc9ebbf go1.26.3 darwin/amd64
</code></pre>
<p>Spin up a disposable cluster:</p>
<pre><code class="language-bash">% kind create cluster --name warden-demo
</code></pre>
<p>This creates a single-node cluster as a Docker container and points <code>kubectl</code> at it — the context is <code>kind-warden-demo</code>.</p>
<h2>Step 1 — Install the controller (once per cluster)</h2>
<p>The controller is a cluster-wide install, done exactly once. After this, any number of applications can opt in just by shipping a <code>WardenPolicy</code>.</p>
<pre><code class="language-bash">% helm repo add warden https://baokhang83.github.io/mnemo-jvm-warden/
% helm repo update
% helm install warden warden/warden
</code></pre>
<ul>
<li><code>helm repo add</code> registers the chart repo under the local alias <code>warden</code>.</li>
<li><code>helm repo update</code> pulls the current chart index from it.</li>
<li><code>helm install warden warden/warden</code> installs the <code>warden</code> chart, naming this release <code>warden</code>.</li>
</ul>
<p>Under the hood this creates: a <code>Deployment</code> running the controller, the <code>WardenPolicy</code> CRD (so the API server understands the new object type), and the RBAC — a <code>ServiceAccount</code>, <code>ClusterRole</code>, and <code>ClusterRoleBinding</code> — letting the controller watch policies and patch pod annotations cluster-wide.</p>
<p>One install, every Warden-managed workload in the cluster. You don't stand up a second controller per app.</p>
<h2>Step 2 — The fleet manifest</h2>
<p>This is the actual demo: a <code>Deployment</code> with three replicas, not three hand-named pods. That distinction matters more than it looks:</p>
<ul>
<li>A bare <code>Pod</code> needs a name you pick yourself. Three pods means three manifests, three names, and nothing like how you'd actually run this.</li>
<li>A <code>Deployment</code> just says "keep N copies of this template running" — Kubernetes names the replicas for you (<code>warden-demo-fleet-5c6c98c9dc-74gqv</code> and so on). You name the Deployment once.</li>
</ul>
<p>The manifest has three parts: <strong>permissions</strong> for the agent to resize its own pod, the <strong>application</strong> itself, and the <strong>policy</strong> that drives the schedule.</p>
<pre><code class="language-yaml">apiVersion: v1
kind: ServiceAccount
metadata:
  name: warden-demo-fleet
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: warden-demo-fleet-resize
rules:
  - apiGroups: [""]
    resources: ["pods/resize"]
    verbs: ["patch"]
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: warden-demo-fleet-resize
subjects:
  - kind: ServiceAccount
    name: warden-demo-fleet
roleRef:
  kind: Role
  name: warden-demo-fleet-resize
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: warden-demo-fleet
  labels:
    app.kubernetes.io/name: warden-demo-fleet
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: warden-demo-fleet
  template:
    metadata:
      labels:
        app.kubernetes.io/name: warden-demo-fleet
    spec:
      serviceAccountName: warden-demo-fleet
      shareProcessNamespace: true
      volumes:
        - name: host-cgroup
          hostPath:
            path: /sys/fs/cgroup
            type: Directory
      initContainers:
        - name: warden
          image: ghcr.io/baokhang83/mnemo-jvm-warden:latest
          imagePullPolicy: IfNotPresent
          restartPolicy: Always
          ports:
            - name: health
              containerPort: 8080
          env:
            - name: WARDEN_POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: WARDEN_TARGET_CONTAINER_NAME
              value: app
            - name: WARDEN_INTENT_POLL_INTERVAL_SECONDS
              value: "2"
          volumeMounts:
            - name: host-cgroup
              mountPath: /host-cgroup
              readOnly: true
          livenessProbe:
            httpGet: { path: /healthz, port: health }
            periodSeconds: 10
          readinessProbe:
            httpGet: { path: /readyz, port: health }
            periodSeconds: 5
          resources:
            requests: { cpu: 25m, memory: 64Mi }
            limits: { memory: 256Mi }
      containers:
        - name: app
          image: ghcr.io/baokhang83/warden-spring-demo:latest
          imagePullPolicy: IfNotPresent
          command:
            - java
            - -XX:+UseG1GC
            - -Xmx200m
            - -Dcom.sun.management.jmxremote.port=9999
            - -Dcom.sun.management.jmxremote.rmi.port=9999
            - -Dcom.sun.management.jmxremote.host=127.0.0.1
            - -Dcom.sun.management.jmxremote.authenticate=false
            - -Dcom.sun.management.jmxremote.ssl=false
            - -Djava.rmi.server.hostname=127.0.0.1
            - -jar
            - /app/app.jar
          ports:
            - name: http
              containerPort: 8081
          readinessProbe:
            httpGet: { path: /actuator/health, port: http }
            periodSeconds: 5
          resources:
            requests: { cpu: 100m, memory: 450Mi }
            limits: { memory: 512Mi }
          resizePolicy:
            - resourceName: memory
              restartPolicy: NotRequired
---
apiVersion: warden.mnemo.io/v1alpha1
kind: WardenPolicy
metadata:
  name: warden-demo-fleet-policy
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: warden-demo-fleet
  timezone: UTC
  profiles:
    off-peak: { request: 350Mi, limit: 400Mi }
    peak:     { request: 450Mi, limit: 512Mi }
  schedule:
    - cron: "*/2 * * * *"
      profile: off-peak
    - cron: "1-59/2 * * * *"
      profile: peak
</code></pre>
<p>Save it as <code>fleet.yaml</code>. The RBAC only lets the agent patch its own pod's resize subresource — it can't touch anything else in the cluster.</p>
<h3>The Deployment: two containers, one job each</h3>
<p>The application container knows nothing about schedules. The agent container knows nothing about business logic. Each pod runs both, and that split is Warden's architecture in miniature, repeated three times.</p>
<p>A few fields are load-bearing, not boilerplate:</p>
<ul>
<li><code>warden</code> <strong>as a native sidecar.</strong> Declared as an <code>initContainer</code> with <code>restartPolicy: Always</code> — Kubernetes' native sidecar pattern, stable since 1.29. It starts before the app, lives for the whole pod lifecycle, and only exits after the app does. That ordering guarantees the agent is attached before the JVM ever starts.</li>
<li><code>app</code> <strong>is the thing actually getting resized.</strong> A small Spring Boot service exposing the JMX interface Warden drives — standing in for any real production workload whose heap needs to move.</li>
<li><code>shareProcessNamespace: true</code> lets the sidecar find the JVM's PID through <code>/proc</code>. Once it has the PID, it talks to the JVM over local JMX, configured by the app's own <code>-Dcom.sun.management.jmxremote.*</code> flags.</li>
<li><strong>The</strong> <code>host-cgroup</code> <strong>volume.</strong> A read-only mount of the node's <code>/sys/fs/cgroup</code> inside the sidecar. Warden reads cgroup memory accounting to confirm the JVM's RSS actually dropped before it ever asks Kubernetes to shrink the container. This is the one real privilege the demo needs — clusters with strict PodSecurity policies may need an exception for it.</li>
<li><strong>Loopback-only JMX.</strong> Bound to <code>127.0.0.1</code>. Both containers share the pod's network namespace, so the sidecar reaches it over localhost — nothing outside the pod can. That's also why disabling JMX auth here is fine: there's no network path to it from anywhere else.</li>
<li><code>-XX:+UseG1GC</code><strong>.</strong> At these small heap sizes the JVM would default to the Serial collector, which Warden deliberately doesn't support. G1 guarantees heap resizing actually works in this demo.</li>
<li><code>resizePolicy: { memory: NotRequired }</code><strong>.</strong> Tells Kubernetes a memory change doesn't need a container restart. Without it, every resize would recreate the container — which defeats the entire point.</li>
</ul>
<p>Notice only the <code>app</code> container's size moves. The sidecar stays a flat 64 MiB the whole time. And <code>app</code>'s own ceiling — <code>-Xmx200m</code> — is deliberately <em>smaller</em> than even the leanest profile's 400 MiB limit. That gap isn't an accident; it's the subject of the next section, and skipping it is how you OOM-kill your own fleet.</p>
<h3>The WardenPolicy: the actual rules</h3>
<p>Three fields do all the work:</p>
<ul>
<li><code>targetRef</code> points at the <code>Deployment</code>, not a <code>Pod</code>. That's what makes this fan out: the controller reads the Deployment's label selector, finds every matching pod, and drives each one independently. Point it at a Deployment and scaling to N replicas just works — no extra config.</li>
<li><code>profiles</code> are named memory configurations. <code>off-peak</code> is lean (350Mi request / 400Mi limit); <code>peak</code> is roomy (450Mi / 512Mi). The number that matters is the <strong>request</strong> — that's what Kubernetes schedules and bills against. Lowering only the limit saves nothing; lowering the request is what frees capacity a cluster autoscaler can actually reclaim.</li>
<li><code>schedule</code> maps cron expressions to profiles. This demo alternates every minute (<code>*/2</code> = even minutes → off-peak, <code>1-59/2</code> = odd minutes → peak) purely so you don't have to wait for an actual overnight window. In production these would be real clock times — <code>0 20 * * *</code> → off-peak at 8 p.m., <code>0 6 * * *</code> → peak at 6 a.m. — read in whatever <code>timezone</code> you set.</li>
</ul>
<h2>The one rule that will OOM-kill you if you skip it</h2>
<blockquote>
<p><strong>The memory limit of your smallest profile must exceed the JVM's</strong> <code>-Xmx</code> <strong>plus its non-heap overhead.</strong></p>
</blockquote>
<p>Warden can lower a JVM's soft heap ceiling and reclaim committed memory. It <strong>cannot</strong> change <code>-Xmx</code> on a running process — max heap is fixed at JVM startup, full stop. Every profile you write has to be compatible with that fixed ceiling, not just with whatever Warden can do at runtime.</p>
<p>Here's why it bites: a container's memory limit covers the JVM's entire resident set, not just the heap.</p>
<pre><code class="language-plaintext">RSS ≈ live heap + metaspace + code cache + thread stacks + direct/off-heap buffers
</code></pre>
<p>For a typical Spring Boot service, that non-heap slice is commonly 100–150 MiB. Which makes <code>-Xmx400m</code> paired with a 350 MiB off-peak limit a trap, not a tight configuration. Warden will successfully shrink the <em>current</em> heap until RSS fits inside 350 MiB — that part works fine. But <code>-Xmx</code> is still 400 MiB as far as the JVM is concerned, because nothing changed it. The moment the application's working set grows again, heap plus non-heap blows past the container's limit, and the kernel OOM-kills the pod.</p>
<p>The resize isn't the bug. Warden verifies the JVM actually released enough memory <em>before</em> it ever asks Kubernetes to shrink anything — that part is safe by construction. The failure shows up later, once the app resumes normal work with no headroom left to grow into.</p>
<p>Which is exactly why this demo pairs <code>-Xmx200m</code> with a 400 MiB floor:</p>
<pre><code class="language-plaintext">200 MiB   maximum heap
130 MiB   typical non-heap
─────────
330 MiB   expected RSS
 70 MiB   headroom left over
</code></pre>
<p>As a rule of thumb:</p>
<pre><code class="language-plaintext">smallest profile limit
    ≥ -Xmx
    + non-heap overhead (usually 100–150 MiB)
    + working-set headroom
</code></pre>
<p>Warden doesn't save memory by cramming an application into a smaller container than it needs. It saves memory when the application's <em>actual</em> working set shrinks during quiet hours — and the container shrinks to match, with enough headroom left for the JVM to keep running safely in between.</p>
<h2>Step 3 — Apply it and watch the fleet breathe</h2>
<pre><code class="language-bash">% kubectl apply -f fleet.yaml
% kubectl rollout status deployment/warden-demo-fleet --timeout=150s
</code></pre>
<p><code>kubectl apply</code> creates all five objects. <code>kubectl rollout status</code> blocks and prints progress until all three replicas are up and passing readiness:</p>
<pre><code class="language-plaintext">Waiting for deployment "warden-demo-fleet" rollout to finish: 0 of 3 updated replicas are available...
Waiting for deployment "warden-demo-fleet" rollout to finish: 2 of 3 updated replicas are available...
deployment "warden-demo-fleet" successfully rolled out
</code></pre>
<p>Pull up the cluster in k9s:</p>
<img src="https://raw.githubusercontent.com/baokhang83/mnemo-jvm-warden/main/docs/assets/blog/k9s-overview.webp" alt="k9s pod view: warden-controller plus all three warden-demo-fleet replicas Running at 2/2, confirming the rollout completed cleanly" style="display:block;margin:0 auto" />

<p>Drop into one of the replicas during a peak window and the memory request reads 450Mi:</p>
<img src="https://raw.githubusercontent.com/baokhang83/mnemo-jvm-warden/main/docs/assets/blog/peak-window.webp" alt="k9s container view during the peak window: the app container's MEM/RL reads 450:512, matching the peak profile's request/limit" style="display:block;margin:0 auto" />

<p>Wait out the schedule, and the same pod's request shrinks to 350Mi, with no restart and no dropped connections:</p>
<img src="https://raw.githubusercontent.com/baokhang83/mnemo-jvm-warden/main/docs/assets/blog/offpeak-window.webp" alt="k9s container view during the off-peak window: the same pod's app container has shrunk to MEM/RL 350:400, live, with no restart" style="display:block;margin:0 auto" />

<h2>What this actually proves</h2>
<p>Run this end to end and you've seen the whole value proposition, at fleet scale, not toy scale:</p>
<ul>
<li><strong>One controller, one policy, N pods.</strong> Three here, fifty in production — no per-pod configuration, and new replicas get picked up automatically.</li>
<li><strong>Live, safe JVM shrinking.</strong> The JVM genuinely releases memory, RSS is verified before any resize, and no pod is ever restarted or OOM-killed to get there.</li>
<li><strong>Savings that are real, not cosmetic.</strong> It's the <em>request</em> that shrinks — the number the scheduler and the cluster autoscaler actually look at.</li>
<li><strong>Safety beats schedule.</strong> A pod under real load at the "wrong" time refuses to shrink, decided per-pod from its actual memory, not the calendar.</li>
</ul>
<h2>Where to go from here</h2>
<p>Swap the demo app for your own service — add the <code>warden</code> sidecar to its pod template, ideally via the published <code>warden-sidecar</code> Helm library chart instead of hand-copying YAML. Set real off-peak/peak windows in your own timezone. And before this touches anything that matters, add the admission policy that scopes the resize permission per-pod, rather than running on trust.</p>
<h2>Before you adopt this</h2>
<p>None of the following is hard to understand from the walkthrough above — it's the kind of thing that turns a working demo into a ticket for platform or security review. Screen against this before you promise anyone a timeline:</p>
<ul>
<li><strong>Kubernetes 1.35+.</strong> In-place pod resize needs to actually be GA (<code>pods/resize</code>), not just enabled via the <code>InPlacePodVerticalScaling</code> feature gate on an older version. Check your managed cluster's version and upgrade policy before assuming this applies today.</li>
<li><code>shareProcessNamespace: true</code> <strong>plus a host-cgroup</strong> <code>hostPath</code> <strong>mount.</strong> This is a real pod-level privilege, not a toggle — it's how the sidecar finds the JVM's PID and reads genuine cgroup memory accounting. Expect this to need a security review, and check it against your cluster's Pod Security Admission policy before rolling it out anywhere that isn't a sandbox.</li>
<li><strong>Loopback JMX.</strong> The app container needs <code>-Dcom.sun.management.jmxremote.*</code> flags enabled, auth disabled (safe here only because it's bound to <code>127.0.0.1</code> inside a network namespace shared with nothing untrusted). If your org has a blanket "no JMX" policy, that's a conversation to have before the rollout, not a surprise during it.</li>
<li><strong>The collector you're actually running changes how precisely this behaves.</strong> ZGC and Shenandoah give Warden a live, settable soft-heap ceiling it can push and verify quickly. G1 — used in this demo only because of its small heap size — has no such knob; shrinking relies on forcing a GC and polling committed memory until it actually drops, which can take longer and settle less predictably. Still safe either way, just slower on G1.</li>
</ul>
<h2>FAQ</h2>
<h3>Can I just lower a Kubernetes pod's memory limit to save money on Java services?</h3>
<p>No. The JVM keeps committed heap until it is asked to release it. If the container limit drops first, the kernel OOM-kills the process when it touches memory that no longer fits. The JVM has to give memory back first, and the pod limit shrinks only after that is verified.</p>
<h3>Does Kubernetes in-place pod resize restart the container?</h3>
<p>Not when the container's <code>resizePolicy</code> for memory is <code>NotRequired</code>. The pod's memory request and limit change on the running container through the <code>pods/resize</code> subresource, which is GA in Kubernetes 1.35.</p>
<h3>Why does the memory request matter more than the limit?</h3>
<p>The request is what the scheduler reserves and what a cluster autoscaler reasons about. Lowering only the limit frees no schedulable capacity. Lowering the request is what makes room for other workloads or lets nodes be removed.</p>
<h3>Can I change <code>-Xmx</code> on a running JVM?</h3>
<p>No. Maximum heap is fixed at JVM startup. Your smallest memory profile therefore has to cover <code>-Xmx</code> plus non-heap overhead (typically 100–150 MiB for a Spring Boot service) plus working-set headroom.</p>
<h3>Which garbage collectors work with Warden?</h3>
<p>ZGC and Shenandoah expose a settable soft heap ceiling, so shrinking is fast and easy to verify. G1 has no such setting, so Warden forces a GC and polls committed memory until it drops. That is safe, but slower. The Serial collector is not supported.</p>
<h3>Does this work for more than one pod?</h3>
<p>Yes. A <code>WardenPolicy</code> targets a Deployment. The controller finds every matching pod and the per-pod sidecar agent resizes its own JVM independently, so the same policy covers 3 replicas or 50.</p>
]]></content:encoded></item></channel></rss>