Summary
GroovyPageCompiler bakes the .gsp source file's modification time into every generated page
class as a static final long LAST_MODIFIED constant. Because a fresh checkout gives each .gsp
a new mtime, identical sources compiled on two machines produce different bytes. Any jar bundling
precompiled GSPs therefore changes on every checkout, and Gradle's build cache cannot reuse
anything downstream of it.
The effect is invisible in the sense that matters: builds succeed and output is correct. They are
just slower, on every fresh clone and every CI run.
Why it is not hidden by classpath normalization
Gradle's COMPILE_CLASSPATH normalization hashes only the ABI and ignores everything else. A
static final constant is part of the ABI — constants are inlined into callers — so consumers
re-key even under that mode. Archive-level reproducibility does not help either: the jars already
use normalized entry timestamps (1980-02-01), and the divergence is inside the class bytes.
Evidence
Two CI builds of this project at the same commit (01037bdf), on the ASF Develocity instance:
https://develocity.apache.org/c/kat5373gl55fw/lenhcqeavcokm/task-inputs
For :grails-fields:jar and :grails-spring-security:jar, compileGroovy, compileJava and
processResources are identical. The only diverging input is build/gsp-classes/main.
Reproduced locally by changing only the source mtimes, with file content unchanged:
|
mtimes |
resulting class bytes |
| before |
3 distinct |
3 distinct sets |
after a fixed LAST_MODIFIED |
3 distinct |
byte-identical |
A sibling closure class produced by the same task in the same run
(gsp_..._table_gsp$_run_closure1.class), which carries no LAST_MODIFIED constant, is
byte-identical in every run. That isolates the timestamp as the sole cause rather than general
compiler nondeterminism.
Measured cost
Over 2026-07-29 → 2026-08-05 on develocity.apache.org, roughly 616 hours of avoidable CI task
re-execution across this project trace to jars carrying precompiled GSPs, the largest single
contributor being :grails-test-examples-spring-security-ui-simple:assetCompile. These are summed
task re-executions rather than wall-clock, since much of the work runs in parallel.
Note on the reload path
LAST_MODIFIED is not unused. GroovyPageMetaInfo.checkIfReloadableResourceHasChanged reads the
field directly and compares it against the live source timestamp to decide whether a precompiled
page is stale, so any fix that fixes the value must also handle the "no timestamp recorded" case —
otherwise every check reports a change. For GSPs in binary plugin jars the reload path is already
unreachable (DefaultGroovyPageLocator.resolveViewInBinaryPlugin nulls the resource callable, and
the jars ship no .gsp sources), but an application's own precompiled pages with reloading enabled
would be affected.
Environment
- Branch: reproduced against
8.0.x; the relevant sources are byte-identical on 7.0.x
GroovyPageCompiler.groovy:222, GroovyPageParser.java:905, GroovyPageMetaInfo.java:480
Attribution
This was found while trialling Gradle's Build Caching Optimizer
(https://develocity.ai/product/build-caching-optimizer/), an agent that locates avoidable
build-cache misses from Develocity data. Generative tooling was used in preparing this report and
the accompanying analysis; the findings were verified against the sources and build scans linked
above.
Summary
GroovyPageCompilerbakes the.gspsource file's modification time into every generated pageclass as a
static final long LAST_MODIFIEDconstant. Because a fresh checkout gives each.gspa new mtime, identical sources compiled on two machines produce different bytes. Any jar bundling
precompiled GSPs therefore changes on every checkout, and Gradle's build cache cannot reuse
anything downstream of it.
The effect is invisible in the sense that matters: builds succeed and output is correct. They are
just slower, on every fresh clone and every CI run.
Why it is not hidden by classpath normalization
Gradle's
COMPILE_CLASSPATHnormalization hashes only the ABI and ignores everything else. Astatic finalconstant is part of the ABI — constants are inlined into callers — so consumersre-key even under that mode. Archive-level reproducibility does not help either: the jars already
use normalized entry timestamps (
1980-02-01), and the divergence is inside the class bytes.Evidence
Two CI builds of this project at the same commit (
01037bdf), on the ASF Develocity instance:https://develocity.apache.org/c/kat5373gl55fw/lenhcqeavcokm/task-inputs
For
: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:
LAST_MODIFIEDA sibling closure class produced by the same task in the same run
(
gsp_..._table_gsp$_run_closure1.class), which carries noLAST_MODIFIEDconstant, isbyte-identical in every run. That isolates the timestamp as the sole cause rather than general
compiler nondeterminism.
Measured cost
Over 2026-07-29 → 2026-08-05 on
develocity.apache.org, roughly 616 hours of avoidable CI taskre-execution across this project trace to jars carrying precompiled GSPs, the largest single
contributor being
:grails-test-examples-spring-security-ui-simple:assetCompile. These are summedtask re-executions rather than wall-clock, since much of the work runs in parallel.
Note on the reload path
LAST_MODIFIEDis not unused.GroovyPageMetaInfo.checkIfReloadableResourceHasChangedreads thefield directly and compares it against the live source timestamp to decide whether a precompiled
page is stale, so any fix that fixes the value must also handle the "no timestamp recorded" case —
otherwise every check reports a change. For GSPs in binary plugin jars the reload path is already
unreachable (
DefaultGroovyPageLocator.resolveViewInBinaryPluginnulls the resource callable, andthe jars ship no
.gspsources), but an application's own precompiled pages with reloading enabledwould be affected.
Environment
8.0.x; the relevant sources are byte-identical on7.0.xGroovyPageCompiler.groovy:222,GroovyPageParser.java:905,GroovyPageMetaInfo.java:480Attribution
This was found while trialling Gradle's Build Caching Optimizer
(https://develocity.ai/product/build-caching-optimizer/), an agent that locates avoidable
build-cache misses from Develocity data. Generative tooling was used in preparing this report and
the accompanying analysis; the findings were verified against the sources and build scans linked
above.