build(test): size the test fork heap from an explicit budget - #16168
build(test): size the test fork heap from an explicit budget#16168jamesfredley wants to merge 1 commit into
Conversation
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 Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ 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 🚀 New features to boost your workflow:
|
✅ All tests passed ✅🏷️ Commit: dbe2551 Learn more about TestLens at testlens.app/docs. |
There was a problem hiding this comment.
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
testForkHeapMbcalculation in the rootbuild.gradlebased on physical RAM, daemon heap, and Gradle worker concurrency (with overrides via-PtestForkHeapMb). - Switches
gradle/test-config.gradletest tasks to use the computedtestForkHeapMbvalue instead of768m/1024mliterals. - Aligns
grails-test-suite-ubertest heap sizing with the shared budget by usingrootProject.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.
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.gradlehas always said: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.jvmargsdoes not limit them. Withorg.gradle.parallel=trueseveralTesttasks run concurrently, so the live fork count is bounded by Gradle's build-wide worker pool (maxWorkerCount), not by any one task'smaxParallelForks. A build with four tasks capped at two forks apiece still runs up tomaxWorkerCountforks 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
ActiveProcessorCountArgumentProvideralready uses for CPU count a few lines below inbuild.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 (
-Xmx2Geach viaCompilePlugin) 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 oforg.gradle.jvmargs- the build script runs in the daemon, so this sidesteps multiple-Xmxoptions (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
-PtestForkHeapMbandisCiBuildsettle the value before the probe runs, so the CI path performs no fallible work at all.Measured
Real resolved
Testtask properties, captured with a throwaway init script (since deleted), not hand arithmetic:maxHeapSize-Xmx5Gdaemon)1024m(ceiling)CI=true768mCI=true -PmaxTestParallel=4768m-PtestForkHeapMb=900900mCI=true -PtestForkHeapMb=900900m--max-workers=64512m(floor)Verified on both
grails-core:testandgrails-test-suite-uber:test.grails-test-suite-uber
That module carried its own copy of the same
768m/1024mliteral and never appliedgradle/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-persistencekeeps its deliberate2048m- 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-workerswhich 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:
512a safe floor for the heaviest suites, or should the fork count drop instead once the share falls that low?-Xmx2Gcompiler workers be subtracted explicitly?Scope
grails-gradleandgrails-forgeare separate Gradle builds with their own daemon settings, their ownconfiguredTestParalleland their own heap literals - deliberately a follow-up, not silent collateral.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 0CI=true ./gradlew help -PmaxTestParallel=4- EXIT 0./gradlew help -PtestForkHeapMb=900- EXIT 0./gradlew validateActions- EXIT 0Related
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.