maczikasz opened a new pull request, #16132:
URL: https://github.com/apache/grails-core/pull/16132
## 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 clone` or 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 task
re-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 summed
task re-executions rather than wall-clock, since much of the work runs in
parallel.
### Root cause
`GroovyPageCompiler` bakes the `.gsp` source file's modification time into
every generated page
class:
```groovy
gpp.lastModified = gspfile.lastModified() // GroovyPageCompiler.groovy
```
`GroovyPageParser` emits that value as a `static final long LAST_MODIFIED`
constant, making it part
of the compiled class's **ABI**. Being ABI-level is what makes this
expensive rather than cosmetic:
Gradle's `COMPILE_CLASSPATH` normalization 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_MODIFIED` is **not** unused at runtime.
`GroovyPageMetaInfo.checkIfReloadableResourceHasChanged`
reads 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:
```java
if (currentLastmodified > 0 && lastModified > 0 &&
Math.abs(currentLastmodified - lastModified) >
LASTMODIFIED_CHECK_GRANULARITY) {
```
`0` now means "no source timestamp recorded", so staleness detection is
skipped rather than firing
on every check. Behaviour for pages carrying a real timestamp is unchanged.
Blast radius, for reviewers:
- For GSPs in **binary plugin jars** — the case this PR is about — the
reload path was already
unreachable: `DefaultGroovyPageLocator.resolveViewInBinaryPlugin` nulls
the resource callable, and
the plugin jars ship no `.gsp` sources to resolve.
- The guard covers the remaining case: an application's **own** precompiled
pages with reloading
enabled.
- `getLastModified()` is public API and will now return `0` for reproducibly
compiled pages. Nothing
in this repository calls it. Please tell me if you would like an Upgrade
Note for that.
The `LAST_MODIFIED` field is deliberately retained rather than removed,
because `GroovyPageMetaInfo`
resolves it reflectively via `findField`.
### Evidence
Two CI builds of this project at the same commit (`01037bdf`), compared on
the ASF Develocity
instance — publicly readable, no login required:
**→ [Task-input comparison: `kat5373gl55fw` vs
`lenhcqeavcokm`](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 | 3 distinct | byte-identical |
A sibling closure class emitted by the same task in the same run
(`gsp_..._table_gsp$_run_closure1.class`), which carries no `LAST_MODIFIED`
constant, was
byte-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
- [x] This PR is linked to an existing issue — #16131. **In fairness: I
filed that issue myself and
it has not yet been acknowledged by the project team.** Happy to wait
for triage before you
spend review time.
- [x] This PR addresses the complete scope of the linked issue.
- [x] This PR contains a single, focused change.
- [x] This PR targets the correct branch: `7.0.x`, as a bug fix with no API
additions.
### Code Quality
- [x] Added tests: `GroovyPageMetaInfoReloadSpec` covers 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 completed
with 9 failures, all `integrationTest` tasks failing on
`ContainerLaunchException: Container startup failed for image
selenium/standalone-chrome`.
That image publishes no `linux/arm64` manifest and cannot start on
Apple Silicon; the failures
are environmental and unrelated to this change. All unit tests pass —
see below.
- [x] Code style: `checkstyleMain`, `checkstyleTest`, `codenarcMain`,
`codenarcTest` clean for
`grails-gsp-core`, and the aggregate `CHECKSTYLE`, `CODENARC`, `PMD`
and `SPOTBUGS` violation
reports all report "No violations found".
- [x] No mass reformatting, style-only changes, or large-scale refactoring.
- [x] Generative AI tooling was used; see Attribution below.
### Licensing and Attribution
- [x] Contributed under the Apache License 2.0; the new source file carries
the Apache license
header.
- [x] I have the necessary rights to submit this contribution.
- [x] Generative AI tooling was used in preparing this contribution,
following the
[ASF policy on generative
tooling](https://www.apache.org/legal/generative-tooling.html).
The cache miss and the diverging input were located by Gradle's
[Build Caching
Optimizer](https://develocity.ai/product/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
- [ ] No user-facing documentation change included — this is an internal
compilation and caching
fix with no API addition. Tell me if you would prefer one.
- [ ] Not a new feature, so no **What's New** entry.
- [ ] No **Upgrade Note** included. The one candidate is `getLastModified()`
returning `0` for
precompiled pages; I did not judge that user-visible enough to warrant
one, but I will add it
if you disagree.
- [x] The description explains what was changed and why.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]