Restore build-cache reuse for plugins with precompiled GSPs - #16132
Draft
maczikasz wants to merge 1 commit into
Draft
Restore build-cache reuse for plugins with precompiled GSPs#16132maczikasz wants to merge 1 commit into
maczikasz wants to merge 1 commit into
Conversation
GroovyPageCompiler baked the .gsp source file's modification time into every generated page class. GroovyPageParser emits that value as a `static final long LAST_MODIFIED` constant, so it forms part of the compiled class's ABI. A fresh checkout gives every .gsp a new modification time, so identical sources compiled on two machines produce different bytes. Because the divergence is ABI-level it is not hidden by classpath normalization: consumers re-key even under COMPILE_CLASSPATH, which otherwise ignores everything but the ABI. Any jar bundling precompiled GSPs therefore invalidates the build cache for every downstream task on every fresh checkout. Archive reproducibility was not the gap -- the jars already use normalized entry timestamps. Emit a fixed LAST_MODIFIED so precompilation is reproducible. Verified by compiling the same sources with only the mtime changed: before: 3 distinct mtimes -> 3 distinct sets of class bytes after: 3 distinct mtimes -> byte-identical output A sibling closure class from the same task, carrying no LAST_MODIFIED constant, was byte-identical in every run both before and after, confirming the timestamp is the sole source of divergence. The value is read at runtime, so fixing it is not sufficient on its own. GroovyPageMetaInfo.checkIfReloadableResourceHasChanged compares the field against the live source timestamp to decide whether a precompiled page is stale; a fixed value would make that comparison always report a change. Guard it so that a lastModified of 0 means "no source timestamp recorded" and staleness detection is skipped rather than firing on every check. Behaviour for pages carrying a real timestamp is unchanged. For GSPs in binary plugin jars the reload path was already unreachable -- DefaultGroovyPageLocator.resolveViewInBinaryPlugin nulls the resource callable, and those jars ship no .gsp sources. The guard covers an application's own precompiled pages with reloading enabled. The LAST_MODIFIED field is retained rather than removed because GroovyPageMetaInfo resolves it reflectively via findField. Fixes apache#16131
✅ All tests passed ✅🏷️ Commit: 5079430 Learn more about TestLens at testlens.app. |
Contributor
|
Thanks @maczikasz for looking into this! I expanded on your solution a bit here #16142 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Fixes #16131.
Grails plugins that ship precompiled GSPs currently produce a different jar on every checkout,
even when the source is byte-for-byte identical. Nothing in the page changed — only the file's
timestamp on disk, which every fresh
git cloneor CI checkout resets.Gradle's build cache decides whether it can reuse earlier output by hashing a task's inputs. A jar
whose bytes change on every checkout invalidates that hash for every task downstream of it, so the
cache can never serve those tasks and they re-run from scratch. Contributors rebuild downstream
modules on every fresh clone; CI never benefits from an earlier run. It is invisible in the way
that matters — the build succeeds and the output is correct, it is only slower.
Over 2026-07-29 → 2026-08-05 on
develocity.apache.org, roughly 616 hours of avoidable CI taskre-execution in this project trace to jars carrying precompiled GSPs, the largest single
contributor being
:grails-test-examples-spring-security-ui-simple:assetCompile. Those are summedtask re-executions rather than wall-clock, since much of the work runs in parallel.
Root cause
GroovyPageCompilerbakes the.gspsource file's modification time into every generated pageclass:
GroovyPageParseremits that value as astatic final long LAST_MODIFIEDconstant, making it partof the compiled class's ABI. Being ABI-level is what makes this expensive rather than cosmetic:
Gradle's
COMPILE_CLASSPATHnormalization hashes only the public API and ignores everything else,but a changed constant survives even that, because constants are inlined into callers.
Archive reproducibility is not the gap — the jars already use normalized entry timestamps
(
1980-02-01). The divergence is inside the class bytes.The reload path, and why this is two changes rather than one
LAST_MODIFIEDis not unused at runtime.GroovyPageMetaInfo.checkIfReloadableResourceHasChangedreads the field directly and compares it against the live source timestamp to decide whether a
precompiled page is stale. Fixing the emitted value alone would make that comparison always report
a change, so this PR also guards it:
0now means "no source timestamp recorded", so staleness detection is skipped rather than firingon every check. Behaviour for pages carrying a real timestamp is unchanged.
Blast radius, for reviewers:
unreachable:
DefaultGroovyPageLocator.resolveViewInBinaryPluginnulls the resource callable, andthe plugin jars ship no
.gspsources to resolve.enabled.
getLastModified()is public API and will now return0for reproducibly compiled pages. Nothingin this repository calls it. Please tell me if you would like an Upgrade Note for that.
The
LAST_MODIFIEDfield is deliberately retained rather than removed, becauseGroovyPageMetaInforesolves it reflectively via
findField.Evidence
Two CI builds of this project at the same commit (
01037bdf), compared on the ASF Develocityinstance — publicly readable, no login required:
→ Task-input comparison:
kat5373gl55fwvslenhcqeavcokmFor
:grails-fields:jarand:grails-spring-security:jar,compileGroovy,compileJavaandprocessResourcesare identical. The only diverging input isbuild/gsp-classes/main.Reproduced locally by changing only the source mtimes, with file content unchanged:
A sibling closure class emitted by the same task in the same run
(
gsp_..._table_gsp$_run_closure1.class), which carries noLAST_MODIFIEDconstant, wasbyte-identical in every run both before and after. That rules out general Groovy compiler
nondeterminism and isolates the timestamp as the sole cause.
Contributor Checklist
Issue and Scope
it has not yet been acknowledged by the project team. Happy to wait for triage before you
spend review time.
7.0.x, as a bug fix with no API additions.Code Quality
GroovyPageMetaInfoReloadSpeccovers the reload semantics in both directions.Reverting the guard makes exactly the "no recorded timestamp" case fail and leaves the others
passing, so it is a genuine regression guard.
./gradlew build --rerun-tasks— not run in full. I ran./gradlew clean aggregateViolations :grails-test-report:check --continue, which completedwith 9 failures, all
integrationTesttasks failing onContainerLaunchException: Container startup failed for image selenium/standalone-chrome.That image publishes no
linux/arm64manifest and cannot start on Apple Silicon; the failuresare environmental and unrelated to this change. All unit tests pass — see below.
checkstyleMain,checkstyleTest,codenarcMain,codenarcTestclean forgrails-gsp-core, and the aggregateCHECKSTYLE,CODENARC,PMDandSPOTBUGSviolationreports all report "No violations found".
Licensing and Attribution
header.
ASF policy on generative tooling.
The cache miss and the diverging input were located by Gradle's
Build Caching Optimizer, an agent
that analyses Develocity build data; it is still in development and is being trialled against
open-source builds. The root cause, the fix and the verification above were established
against the sources and the build scans linked here.
Documentation
fix with no API addition. Tell me if you would prefer one.
getLastModified()returning0forprecompiled pages; I did not judge that user-visible enough to warrant one, but I will add it
if you disagree.