Skip to content

build(test): size the test fork heap from an explicit budget - #16168

Open
jamesfredley wants to merge 1 commit into
fix/test-fork-oversubscriptionfrom
build/test-fork-heap-budget
Open

build(test): size the test fork heap from an explicit budget#16168
jamesfredley wants to merge 1 commit into
fix/test-fork-oversubscriptionfrom
build/test-fork-heap-budget

Conversation

@jamesfredley

Copy link
Copy Markdown
Contributor

Note

Stacked on #16158 - this branch is based on it, so review that one first. The diff here is only the heap change.

What

Replace the hardcoded per-fork test heap with an explicit budget derived from the machine.

gradle/test-config.gradle has always said:

maxHeapSize = isCiBuild ? '768m' : '1024m'

Those literals ignore the machine. Every forked test JVM claimed a gigabyte locally no matter how much RAM the developer actually had, and nothing tied that number to how many forks could be alive at once.

The bound

Forked test JVMs are children of the daemon, so org.gradle.jvmargs does not limit them. With org.gradle.parallel=true several Test tasks run concurrently, so the live fork count is bounded by Gradle's build-wide worker pool (maxWorkerCount), not by any one task's maxParallelForks. A build with four tasks capped at two forks apiece still runs up to maxWorkerCount forks at once, so budgeting off the per-task cap would hand every fork a heap the machine cannot honour.

This is the same bound - and the same reasoning - that ActiveProcessorCountArgumentProvider already uses for CPU count a few lines below in build.gradle. This PR applies it to memory.

The budget: physical RAM, minus the daemon's own heap, halved to leave room for the OS, the compiler workers (-Xmx2G each via CompilePlugin) and the Docker containers the mongodb/redis/geb suites start per fork, divided by the worker count, clamped to [512, 1024].

The daemon heap is read from Runtime.runtime.maxMemory() rather than parsed out of org.gradle.jvmargs - the build script runs in the daemon, so this sidesteps multiple -Xmx options (the JVM honours the last) and unit-parsing entirely.

CI is unchanged, by construction

On CI the value is still exactly 768. This is load-bearing: the PR makes the policy explicit and fixes local overcommit only. Changing CI numbers belongs in a later, measured PR - mixing them here would make any CI flake impossible to attribute ("was it the heap or the CPU cap?").

Both -PtestForkHeapMb and isCiBuild settle the value before the probe runs, so the CI path performs no fallible work at all.

Measured

Real resolved Test task properties, captured with a throwaway init script (since deleted), not hand arithmetic:

Scenario workers forks maxHeapSize
local (64 GB, -Xmx5G daemon) 20 10 1024m (ceiling)
CI=true 20 4 768m
CI=true -PmaxTestParallel=4 20 4 768m
-PtestForkHeapMb=900 20 10 900m
CI=true -PtestForkHeapMb=900 20 4 900m
local --max-workers=64 64 10 512m (floor)

Verified on both grails-core:test and grails-test-suite-uber:test.

grails-test-suite-uber

That module carried its own copy of the same 768m/1024m literal and never applied gradle/test-config.gradle, so the heaviest suite in the build would have been the one module exempt from the budget. It now reads the same property. grails-test-suite-persistence keeps its deliberate 2048m - that is a specialized limit, not a copy-paste default.

Known limit (deliberately not papered over)

When the computed share falls below the 512m floor, the floor wins and the forks collectively still exceed the budget. Heap alone cannot fix that: a fork below ~512m cannot run these suites. The knob that has to come down on such a host is the fork count (-PmaxTestParallel, or --max-workers which bounds them build-wide). This budget lowers memory pressure; it does not by itself prove a constrained machine fits. The code says so where it clamps.

Why draft

The constants are judgement calls, not measurements, and I would rather agree the policy than defend the numbers:

  • Is 512 a safe floor for the heaviest suites, or should the fork count drop instead once the share falls that low?
  • Should the reserve be "half the remainder", or should the -Xmx2G compiler workers be subtracted explicitly?

Scope

  • Root build only. grails-gradle and grails-forge are separate Gradle builds with their own daemon settings, their own configuredTestParallel and their own heap literals - deliberately a follow-up, not silent collateral.
  • No test is added, removed, skipped or weakened. Heap sizing does not change test selection.
  • Reading physical RAM degrades safely: a failed probe keeps the previous 1024, and a successful probe on a machine whose RAM sits inside the daemon heap drops to the floor rather than pretending the probe failed.

Verification

  • ./gradlew help - EXIT 0
  • CI=true ./gradlew help -PmaxTestParallel=4 - EXIT 0
  • ./gradlew help -PtestForkHeapMb=900 - EXIT 0
  • ./gradlew validateActions - EXIT 0

Related

Follow-up to #16158 (CPU oversubscription) and #16167 (daemon heap vs runner RAM). Three separate knobs, deliberately three separate PRs: CPU, daemon heap, fork heap.

gradle/test-config.gradle hardcoded maxHeapSize to 768m on CI and 1024m
locally. Those literals ignore the machine: every forked test JVM claimed a
gigabyte regardless of how much memory the developer actually had, and nothing
tied that number to how many forks could be alive at once.

Forked test JVMs are children of the daemon, so org.gradle.jvmargs does not
limit them. With org.gradle.parallel=true several Test tasks run concurrently,
so the live fork count is bounded by the build-wide worker pool, not by any one
task's maxParallelForks - the same bound ActiveProcessorCountArgumentProvider
already uses for processor count a few lines below.

Derive the per-fork heap from that bound: physical memory, minus the daemon's
own max heap, halved to leave room for the OS, the compiler workers and the
containers the mongodb, redis and geb suites start per fork, divided by the
worker count, then clamped to [768, 1024].

The floor is 768 rather than something smaller because GrailsGradlePlugin gives
every Grails test task minHeapSize = 768m when nothing else sets one, so a
smaller maximum would produce -Xms768m -Xmx<less> and the JVM would refuse to
start. Setting minHeapSize here instead was rejected: it would newly pin
committed heap on plain Test tasks that currently leave it unset. For the same
reason an explicit -PtestForkHeapMb below 768 now fails fast with a message
pointing at -PmaxTestParallel and --max-workers, rather than being silently
clamped to a value the caller did not ask for.

The daemon heap is read from Runtime.maxMemory() rather than parsed out of
org.gradle.jvmargs, since this script runs in the daemon; that sidesteps
multiple -Xmx options and unit parsing entirely.

CI keeps its measured 768m exactly, so this commit is CI-neutral by
construction; the local overcommit is the only behaviour that changes. Both the
override and isCiBuild settle the value before the memory probe runs, so the CI
path does no fallible work.

grails-test-suite-uber carried its own copy of the same literal and never
applied the shared test configuration, which would have left the heaviest suite
in the build as the one module exempt from the budget, so it reads the same
property now. The mongodb configurations assign jvmArgs wholesale with their own
-Xmx and grails-test-suite-persistence sets a deliberate 2048m; those keep their
values and the boundary is documented where the budget is applied, because
folding them in would change CI heaps.

Reading physical memory degrades safely: a failed probe keeps the previous
1024m, while a successful probe on a machine whose RAM sits inside the daemon
heap drops to the floor instead of pretending the probe failed.

grails-gradle and grails-forge are separate Gradle builds and are left for a
follow-up; porting this rule to grails-forge would move its CI forks from 2G to
the budget, which is a CI change this commit deliberately avoids.

Assisted-by: claude-code:claude-opus-5
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 52.3211%. Comparing base (ccca92f) to head (dbe2551).

Additional details and impacted files

Impacted file tree graph

@@                           Coverage Diff                            @@
##             fix/test-fork-oversubscription     #16168        +/-   ##
========================================================================
- Coverage                           52.3323%   52.3211%   -0.0113%     
+ Complexity                            18547      18535        -12     
========================================================================
  Files                                  2039       2039                
  Lines                                 97521      97498        -23     
  Branches                              17143      17138         -5     
========================================================================
- Hits                                  51035      51012        -23     
- Misses                                39000      39001         +1     
+ Partials                               7486       7485         -1     

see 11 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@testlens-app

testlens-app Bot commented Aug 18, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: dbe2551
▶️ Tests: 52400 executed
⚪️ Checks: 73/73 completed


Learn more about TestLens at testlens.app/docs.

@jamesfredley
jamesfredley marked this pull request as ready for review August 19, 2026 19:32
Copilot AI lite review requested due to automatic review settings August 19, 2026 19:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

This PR replaces hardcoded per-fork test JVM heap sizes with a budget-derived value shared across the build, keeping CI behavior pinned to the existing 768m default while improving local memory sizing.

Changes:

  • Introduces testForkHeapMb calculation in the root build.gradle based on physical RAM, daemon heap, and Gradle worker concurrency (with overrides via -PtestForkHeapMb).
  • Switches gradle/test-config.gradle test tasks to use the computed testForkHeapMb value instead of 768m/1024m literals.
  • Aligns grails-test-suite-uber test heap sizing with the shared budget by using rootProject.ext.testForkHeapMb.

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated no comments.

File Description
build.gradle Adds logic to compute and expose testForkHeapMb from machine/daemon/workers, with CI defaulting to 768m.
gradle/test-config.gradle Updates default test task heap sizing to use the computed testForkHeapMb.
grails-test-suite-uber/build.gradle Removes local literal heap sizing and uses the shared root testForkHeapMb budget.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants