Skip to content

build(ci): bound concurrent JVMs on the macOS runner - #16167

Merged
jamesfredley merged 5 commits into
8.0.xfrom
build/ci-daemon-heap-vs-runner-ram
Sep 4, 2026
Merged

build(ci): bound concurrent JVMs on the macOS runner#16167
jamesfredley merged 5 commits into
8.0.xfrom
build/ci-daemon-heap-vs-runner-ram

Conversation

@jamesfredley

@jamesfredley jamesfredley commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

What

Make the CI memory budget explicit, and stop the macOS runner from being over-committed.

org.gradle.jvmargs sizes the Gradle daemon only. Test forks are separate child JVMs and take their heap from maxHeapSize in gradle/test-config.gradle. Those two numbers are set in different files and have never been reasoned about together, so nothing stops their sum from exceeding the runner.

The real peak of a CI job is:

daemon -Xmx  +  (concurrent test forks x per-fork maxHeapSize)  +  OS

The number of concurrent forks is not maxParallelForks. With org.gradle.parallel=true several Test tasks run at once, each entitled to its own forks, so the true bound is Gradle's global worker pool (--max-workers, defaulting to the CPU count).

Against the GitHub-hosted runner specs:

Runner CPU RAM Default workers Worst-case peak
ubuntu-latest / windows-latest 4 16 GB 4 5G + 4x768m = 8.0 GB
macos-latest (M1) 3 7 GB 3 5G + 3x768m = 7.25 GB

Linux and Windows have the headroom. macOS does not: the worst case exceeds the machine.

Change

  • .github/workflows/gradle.yml: the macOS entry - and only that entry - now passes -PmaxTestParallel=2 --max-workers=2, via a new runner_arguments matrix key that is empty for every other entry. Capping --max-workers is the part that actually bounds concurrent forks; -PmaxTestParallel alone would not, for the reason above. New peak: 5G + 2x768m = 6.5 GB, which fits in 7 GB.
  • gradle.properties: document the arithmetic next to org.gradle.jvmargs, including the daemon-vs-fork distinction and the worker-pool bound, so the next person changing either number can see both sides of the budget.

The daemon stays at 5 GB deliberately: groovydoc is what needs it, and shrinking it would trade a memory problem for a slower build.

Wall-clock trade

The macOS runner has 3 CPUs, so going from 3 workers to 2 costs some wall-clock on that job. That is accepted: the alternative (a smaller daemon plus more workers) would slow groovydoc. This was opened as a draft to discuss that trade; the cap is staying.

Scope

  • No test is added, removed, skipped, or weakened. This changes concurrency only, not which tests run.
  • No other runner changes behaviour: runner_arguments is undefined (empty) for the Ubuntu and Windows entries. GitHub Actions interpolates a missing matrix key as an empty string; this is the same pattern already used for shard_arguments.
  • grails-gradle and grails-forge are separate Gradle builds with their own daemon settings and are not touched here.

Verification

  • ./gradlew help -PmaxTestParallel=2 succeeds (property is honored by configuredTestParallel in the root build.gradle).
  • This PR's own CI already ran Ubuntu and Windows jobs that do not define runner_arguments; they did not fail at expression evaluation.

Related

Follow-up to #16158, which capped the CPUs a forked test JVM believes it has. That PR deliberately changed CPU only; this one is the separate memory question it named as out of scope.

Follow-up after review (2026-09-04)

Independent check of the review comments, after merging current 8.0.x:

  • Copilot (runner_arguments must be defined on every matrix row): not valid. GitHub's context docs: a missing property evaluates to an empty string. matrix.shard_arguments already uses that pattern on Ubuntu/macOS. This PR's CI already proved Ubuntu/Windows run without the key.
  • Groovy 5.1.1 / Upgrade Groovy to 5.1.1 #16235: a different bug. 8.0.x is still on Groovy 5.1.0. This PR opened on 2026-08-18, before Upgrade Groovy to 5.1.1 #16235. The 5.1.1 failures on run 33062440061 are java.lang.OutOfMemoryError: Java heap space inside the Gradle daemon (groovydoc on Forge; jacocoTestReport on Grails-Core) on Ubuntu (16 GB), Windows, and macOS. A macOS-only --max-workers cap cannot fix a 5G daemon heap that OOMs on a 16 GB machine. That needs GROOVY-12316: compose the parser DFA cache threshold with the GC canary groovy#2840 or a daemon -Xmx bump, not this PR.
  • Keep the cap. macos-latest is still 3 CPU / 7 GB. Configured floor without the cap is still 5G + 3x768m = 7.25G. Compiler forks are a separate -Xmx2G (CompilePlugin) and are called out in the gradle.properties comment as excluded from that simplified floor. The cap is a runner-RAM budget for concurrent child JVMs, not a Groovy parser/docs heap fix.

org.gradle.jvmargs sizes the Gradle daemon only. Test forks are separate
child JVMs that take their heap from maxHeapSize in gradle/test-config.gradle,
so a job's configured heap is the daemon -Xmx plus the concurrent test forks
times the per-fork heap. Those two numbers live in different files and have
never been reasoned about together.

The concurrent fork count is not maxParallelForks. With org.gradle.parallel=true
several Test tasks run at once, so the live JVM count is bounded by Gradle's
global worker pool, which defaults to the CPU count. On the 4-CPU, ~16 GB Linux
and Windows runners that floor is 5G + 4x768m = 8G and fits. On the 3-CPU,
~7 GB macOS runner it is 5G + 3x768m = 7.25G and does not.

Cap --max-workers on the macOS leg, since that is what actually limits
concurrent test and compiler JVMs, and keep maxTestParallel alongside it so no
single task exceeds the same cap. Both are passed through a new runner_arguments
matrix key that is undefined, and therefore empty, for every other entry.

The daemon stays at 5 GB: groovydoc is what needs it, and shrinking it would
trade a memory problem for a slower build. Document the arithmetic next to
org.gradle.jvmargs as a simplified configured-heap floor, explicitly excluding
metaspace, native memory and the forked compiler workers that CompilePlugin
gives their own -Xmx2G, so it is not mistaken for a true peak.

This changes concurrency only. No test is added, removed, skipped or weakened.

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 54.8337%. Comparing base (5670a65) to head (854938f).

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16167        +/-   ##
==================================================
+ Coverage     54.8238%   54.8337%   +0.0099%     
- Complexity      20521      20523         +2     
==================================================
  Files            2104       2104                
  Lines          101102     101102                
  Branches        17932      17932                
==================================================
+ Hits            55428      55438        +10     
+ Misses          37787      37774        -13     
- Partials         7887       7890         +3     

see 1 file 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.

@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 makes Gradle CI memory usage on macOS deterministic by explicitly capping Gradle’s global worker pool (and test fork parallelism) to prevent over-committing the smaller macOS GitHub-hosted runner.

Changes:

  • Add macOS-only Gradle CLI caps (--max-workers=2 and -PmaxTestParallel=2) via a new runner_arguments matrix field.
  • Document how org.gradle.jvmargs (daemon) and test fork heaps combine into an overall CI memory budget.

Reviewed changes

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

File Description
gradle.properties Adds documentation explaining the combined daemon + test fork heap budgeting rationale.
.github/workflows/gradle.yml Adds macOS-only runner arguments and wires them into the Gradle invocation.

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

Comment on lines 142 to 157
java: 21
job_name: macOS JDK 21
gradle_task: 'build :grails-shell-cli:installDist groovydoc'
# The macOS runner has ~7 GB of RAM and 3 CPUs, against ~16 GB and 4 CPUs on the
# Linux and Windows runners, while org.gradle.jvmargs still asks for a 5 GB daemon
# (groovydoc needs it). Capping only maxTestParallel would not help: that is a
# per-Test-task limit, and with org.gradle.parallel=true several projects' test
# tasks run at once, so the number of live forks is bounded by Gradle's global
# worker pool - which defaults to the 3 CPUs here. --max-workers is therefore the
# setting that actually limits concurrent test and compiler JVMs; maxTestParallel
# is kept alongside it so no single task exceeds that cap either. This reduces
# memory pressure on the smallest runner rather than proving the job fits.
runner_arguments: '--max-workers=2 -PmaxTestParallel=2'
cache_writer: true
- os: windows-latest
java: 25

@jamesfredley jamesfredley Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not valid. GitHub Actions interpolates a missing matrix property as an empty string (see the contexts reference: "If you attempt to dereference a nonexistent property, it will evaluate to an empty string").

This workflow already depends on that: matrix.shard_arguments is only set on the Windows shards, and Ubuntu/macOS still ${{ matrix.shard_arguments }} today. This PR's own CI ran the Ubuntu and Windows jobs without runner_arguments defined; they did not fail at expression evaluation.

Leaving the other rows undefined matches the existing shard_arguments pattern. No change.

-PonlyCoreTests
-PskipCodeStyle
${{ matrix.shard_arguments }}
${{ matrix.runner_arguments }}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not valid. GitHub Actions interpolates a missing matrix property as an empty string (see the contexts reference: "If you attempt to dereference a nonexistent property, it will evaluate to an empty string").

This workflow already depends on that: matrix.shard_arguments is only set on the Windows shards, and Ubuntu/macOS still ${{ matrix.shard_arguments }} today. This PR's own CI ran the Ubuntu and Windows jobs without runner_arguments defined; they did not fail at expression evaluation.

Leaving the other rows undefined matches the existing shard_arguments pattern. No change.

-PonlyCoreTests
-PskipCodeStyle
${{ matrix.shard_arguments }}
${{ matrix.runner_arguments }}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not valid. GitHub Actions interpolates a missing matrix property as an empty string (see the contexts reference: "If you attempt to dereference a nonexistent property, it will evaluate to an empty string").

This workflow already depends on that: matrix.shard_arguments is only set on the Windows shards, and Ubuntu/macOS still ${{ matrix.shard_arguments }} today. This PR's own CI ran the Ubuntu and Windows jobs without runner_arguments defined; they did not fail at expression evaluation.

Leaving the other rows undefined matches the existing shard_arguments pattern. No change.

@sbglasius sbglasius 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.

Since upgrading to Groovy 5.1.1 is causing issues memory wise (#16235), I approve this to get it to 8.0.x

@sbglasius sbglasius mentioned this pull request Aug 28, 2026
3 tasks

@jdaugherty jdaugherty 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.

I dug into whether this cap is needed, both on its own terms and as the potential fix for the Groovy 5.1.1 OOM it's referenced against in #16235.

The mechanics check out. -PmaxTestParallel is honored (configuredTestParallel in the root build.gradle), CI test forks get 768m from gradle/test-config.gradle, and the point that concurrent forks are bounded by the global worker pool rather than maxParallelForks is correct.

But there's no observed macOS failure to fix. The last 10 Build Grails-Core (macOS JDK 21) runs on 8.0.x (Aug 28 – Sep 1) are all green; the 8.0.x failures in that window were Forge and Functional Test jobs. The 7.25G-vs-7G arithmetic sums configured -Xmx ceilings, not resident memory — the daemon only approaches 5G during groovydoc, forks rarely sit at max heap simultaneously, and the runner has swap. The yml comment itself concedes this: it "reduces memory pressure ... rather than proving the job fits."

And it wouldn't fix the 5.1.1 blocker. I pulled the logs from #16235's failing run (33062440061): the OOM is java.lang.OutOfMemoryError: Java heap space inside the Gradle daemon:grails-data-hibernate5-docs:groovydoc, :grails-data-docs-stage:aggregateDataMappingGroovydoc, and a cascade of jacocoTestReport tasks — and it failed the same way on Ubuntu (16 GB) and Windows, not just macOS. That's the daemon's 5G heap being exhausted by Groovy 5.1.1's memory regression, not runner RAM over-commit. A macOS-only --max-workers cap can't address an OOM that reproduces at 4 workers on a 16 GB machine. The fix for that is apache/groovy#2840 or a daemon -Xmx bump.

Suggestion: land the gradle.properties documentation block on its own — it's accurate and useful. Hold the --max-workers cap until either macOS actually fails on memory, or the 5.1.1 resolution raises the daemon heap past 5G, at which point the macOS budget becomes genuinely tight and the cap belongs in that change, with the wall-clock cost measured (this PR's 1h6m macOS job looks encouraging, but it's one sample against a 78–115 min baseline).

@jamesfredley

Copy link
Copy Markdown
Contributor Author

Looked at this independently against the workflow, build.gradle / gradle/test-config.gradle / CompilePlugin, current runner specs, and the #16235 logs — not from the review text.

Mechanics. Confirmed: -PmaxTestParallel feeds configuredTestParallel, CI test forks are 768m, and with org.gradle.parallel=true the live fork count is bounded by --max-workers (CPU count), not by maxParallelForks. Compiler forks are a separate -Xmx2G in CompilePlugin; the gradle.properties comment already excludes them from the simplified floor.

The Groovy 5.1.1 OOM is a different bug. 8.0.x is still on Groovy 5.1.0. This PR opened 2026-08-18; #16235 is later. I pulled run 33062440061: the failures are java.lang.OutOfMemoryError: Java heap space inside the Gradle daemon (groovydoc on Forge, then a cascade of jacocoTestReport on Grails-Core) on Ubuntu (16 GB), Windows, and macOS. A macOS-only --max-workers cap cannot fix a 5G daemon that OOMs on a 16 GB machine. That is apache/groovy#2840 or a daemon -Xmx bump — not this change. sbglasius linking the two PRs mixed them.

The cap stays. macos-latest is still 3 CPU / 7 GB per current GitHub-hosted runner specs. Without the cap the configured floor is still 5G + 3x768m = 7.25G, which does not fit. That is runner RAM for concurrent child JVMs, independent of Groovy 5.1.1's in-daemon heap regression. Green macOS jobs on 5.1.0 do not make the budget arithmetic wrong; they mean RSS is usually under the -Xmx ceilings. The yml comment already says this reduces pressure rather than proving a fit. Merged current 8.0.x onto the branch.

@jdaugherty jdaugherty 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.

We need to fix the CI builds so they're functional again - the OOM due to groovy is a real issue.

Hibernate 7 publishes jboss-logging as a runtime-only transitive.
Groovydoc Class.forName's referenced types, so
:grails-data-hibernate7-dbmigration-core:groovydoc failed in CI
(Forge/e2e publish) with NoClassDefFoundError: org/jboss/logging/Logger.
@testlens-app

testlens-app Bot commented Sep 4, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 854938f
▶️ Tests: 69670 executed
⚪️ Checks: 90/90 completed


Learn more about TestLens at testlens.app/docs.

@jamesfredley
jamesfredley merged commit 17b9ded into 8.0.x Sep 4, 2026
91 of 93 checks passed
@jamesfredley
jamesfredley deleted the build/ci-daemon-heap-vs-runner-ram branch September 4, 2026 21:02
@jamesfredley jamesfredley mentioned this pull request Sep 4, 2026
16 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants