jdaugherty commented on code in PR #16142:
URL: https://github.com/apache/grails-core/pull/16142#discussion_r3775617129
##########
grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy:
##########
@@ -215,11 +215,26 @@ class GroovyPageCompiler {
File gspgroovyfile = new File(new
File(generatedGroovyPagesDirectory, packageDir), className + '.groovy')
// gspgroovyfile.getParentFile().mkdirs()
- gspfile.withInputStream { InputStream gspinput ->
+ byte[] gspSource = gspfile.bytes
+ // closing a ByteArrayInputStream is a no-op, so there is nothing
to release here
+ String sourceChecksum = GroovyPageParser.checksumOf(new
ByteArrayInputStream(gspSource))
+
+ new ByteArrayInputStream(gspSource).withStream { InputStream
gspinput ->
GroovyPageParser gpp = new GroovyPageParser(viewuri - '.gsp',
viewuri, gspfile.absolutePath, gspinput, encoding, expressionCodec, configMap)
gpp.packageName = packageName
gpp.className = className
- gpp.lastModified = gspfile.lastModified()
+ // Record what the source *is*, not when it was last touched.
LAST_MODIFIED is emitted as a
+ // `static final long`, so it belongs to the class's ABI and
is inlined into callers, which
+ // even Gradle's COMPILE_CLASSPATH normalization cannot see
past. Git stores no modification
+ // times, so every checkout gave each .gsp a new one and
identical sources compiled to
+ // different bytes, costing every downstream consumer of the
jar its build cache.
+ //
+ // SOURCE_CHECKSUM answers what the timestamp was only ever a
proxy for -- has the source
+ // changed? -- and answers it identically on every machine.
GroovyPageMetaInfo prefers it and
+ // falls back to LAST_MODIFIED for pages compiled by earlier
versions, so the zero here means
+ // "no timestamp recorded", never "never reload".
+ gpp.lastModified = 0L
Review Comment:
A compatibility note worth capturing in the release notes: `LAST_MODIFIED =
0` only means "nothing recorded" to a runtime that contains this change. A
pre-7.0.16 `grails-gsp` reading a class precompiled by this compiler evaluates
the old condition — `|currentLastmodified − 0| > granularity`, true for any
real mtime — so with reload enabled every such page is declared stale on its
first check and recompiled from source at runtime (e.g. a plugin precompiled
with 7.0.16+ consumed by an app still resolving an older 7.0.x).
Self-correcting and reload-only, but surprising when it hits.
Related: `GroovyPageMetaInfo`'s constructor still reads `LAST_MODIFIED`
unguarded (`GroovyPageMetaInfo.java:132`), unlike the null-guarded
`SOURCE_CHECKSUM` read — so the constant is now permanent ABI that must keep
being emitted even though its value is always `0`. A sentence on the constant
would keep a future cleanup from dropping it.
##########
grails-doc/src/en/guide/theWebLayer/gsp/makingChangesToADeployedApplication.adoc:
##########
@@ -46,8 +46,10 @@ There are also some system properties to control GSP
reloading:
|===
|Name|Description|Default
|grails.gsp.enable.reload|system property for enabling the GSP reload mode
(alternative to adding it in the file-based application configuration|
-|grails.gsp.reload.interval|interval between checking the lastmodified time of
the gsp source file, unit is milliseconds|5000
-|grails.gsp.reload.granularity|the number of milliseconds leeway to give
before deciding a file is out of date. this is needed because different
roundings usually cause a 1000ms difference in lastmodified times|1000
+|grails.gsp.reload.interval|interval between checks of the gsp source file,
unit is milliseconds|5000
+|grails.gsp.reload.granularity|the number of milliseconds leeway to give
before deciding a file is out of date. this is needed because different
roundings usually cause a 1000ms difference in lastmodified times. Applies only
to pages compared by modification time — see below|2000
|===
GSP reloading is supported for precompiled GSPs since Grails 1.3.5.
+
+A precompiled GSP records a checksum of the page it was compiled from, and is
reloaded when the source no longer matches that checksum. Comparing content
rather than modification times means a page that was copied or checked out
afresh — and so carries a new modification time but the same content — is not
needlessly recompiled, and an edit is detected however close together two
writes fall. Pages compiled at runtime are still compared by modification time,
and so is any page precompiled by Grails 7.0 or earlier.
Review Comment:
This ships in a 7.0.x release, so "precompiled by Grails 7.0 or earlier"
reads as including the very version that introduces the checksum. Suggest
bounding it at the actual version:
```suggestion
A precompiled GSP records a checksum of the page it was compiled from, and
is reloaded when the source no longer matches that checksum. Comparing content
rather than modification times means a page that was copied or checked out
afresh — and so carries a new modification time but the same content — is not
needlessly recompiled, and an edit is detected however close together two
writes fall. Pages compiled at runtime are still compared by modification time,
and so is any page precompiled by a version of Grails earlier than 7.0.16.
```
##########
grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageParser.java:
##########
@@ -1380,6 +1391,54 @@ public void setLastModified(long lastModified) {
this.lastModified = lastModified;
}
+ /**
+ * Computes the checksum recorded in the {@code SOURCE_CHECKSUM} constant
of a generated page.
+ * <p>
+ * Both the compiler that writes the constant and the runtime that
compares against it use this method, so
+ * that the two can never disagree on how a GSP source is digested. The
stream is read to the end but is
+ * not closed; closing it remains the caller's responsibility.
+ *
+ * @param source the raw bytes of the GSP source
+ * @return the checksum as a lower-case hex string
+ * @throws IOException if the source cannot be read
+ * @since 7.0.16
+ */
+ public static String checksumOf(InputStream source) throws IOException {
Review Comment:
The javadoc already describes the parameter as "the raw bytes of the GSP
source" — and both callers actually hold the bytes: `GroovyPageCompiler` reads
`gspfile.bytes` and wraps them in a `ByteArrayInputStream` solely to fit this
signature, and `establishChecksum` could use `resource.getContentAsByteArray()`
(spring-core 6.0.5+). A `checksumOf(byte[])` delegating to
`MessageDigest.digest(byte[])` would drop the manual read loop, both stream
wrappers in the compiler, and the "closing a ByteArrayInputStream is a no-op"
comment — and shrink the new public surface to something harder to misuse.
Whichever shape stays, it is worth spelling out that the input must be the
raw stored bytes of the page — not the decorated/re-encoded source the runtime
parse path works with — since that is the invariant that keeps a compile-time
checksum comparable with `establishChecksum`'s raw read at reload time.
##########
grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPageMetaInfo.java:
##########
@@ -474,10 +518,21 @@ public Resource checkIfReloadableResourceHasChanged(final
PrivilegedAction<Resou
public Resource call() {
Resource resource = resourceCallable.run();
if (resource != null && resource.exists()) {
+ // A page compiled by GroovyPageCompiler records a
checksum of its source rather than the
+ // source's modification time, which git does not preserve
across a checkout. Comparing
+ // content answers the question directly: a page that was
merely touched is not stale, and
+ // an edit is caught however close together the writes
fall.
+ if (sourceChecksum != null) {
+ String currentChecksum = establishChecksum(resource);
Review Comment:
Each staleness check for a checksum-bearing page now opens and fully reads
the source to hash it, where the timestamp path cost one stat. It is gated per
page per `grails.gsp.reload.interval` (5 s), but in the reload-enabled deployed
scenario this feature targets — many views, sources on NFS/shared storage —
that is a full file read per hot page per interval on request threads,
indefinitely, even when nothing changes.
A cheap pre-check would restore stat-level steady-state cost: remember the
`(lastModified, contentLength)` observed when the checksum was last computed
and only re-hash when either moves. The only edit that slips through is one
preserving both mtime and length — still strictly better than the old 2000 ms
granularity window, and the spec's "edit within the granularity window" feature
keeps passing since its edit changes the length. Not blocking, just worth
weighing before this meets an app with hundreds of views.
##########
grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPageMetaInfo.java:
##########
@@ -474,10 +518,21 @@ public Resource checkIfReloadableResourceHasChanged(final
PrivilegedAction<Resou
public Resource call() {
Resource resource = resourceCallable.run();
if (resource != null && resource.exists()) {
+ // A page compiled by GroovyPageCompiler records a
checksum of its source rather than the
+ // source's modification time, which git does not preserve
across a checkout. Comparing
+ // content answers the question directly: a page that was
merely touched is not stale, and
+ // an edit is caught however close together the writes
fall.
+ if (sourceChecksum != null) {
+ String currentChecksum = establishChecksum(resource);
+ return (currentChecksum != null &&
!sourceChecksum.equals(currentChecksum)) ? resource : null;
+ }
+ // Pages compiled before SOURCE_CHECKSUM existed, and
pages compiled at runtime, still carry a
+ // real timestamp. A lastModified of 0 means none was
recorded, so there is nothing to compare.
long currentLastmodified = establishLastModified(resource);
// granularity is required since lastmodified information
is rounded somewhere in copying & war (zip) file information
// usually the lastmodified time is 1000L apart in files
and in files extracted from the zip (war) file
- if (currentLastmodified > 0 &&
Math.abs(currentLastmodified - lastModified) > LASTMODIFIED_CHECK_GRANULARITY) {
+ if (currentLastmodified > 0 && lastModified > 0 &&
Review Comment:
`establishLastModified` returns `-1` when the resource's timestamp cannot be
read (`IOException`/`FileNotFoundException`), and `File.lastModified()` returns
`0` on I/O error — `applyLastModifiedFromResource` stores whichever it got. A
runtime-compiled page (no checksum) that recorded such a value used to
self-heal here: `|current − (−1)|` always exceeded the granularity, so the
first check that could read a real mtime reloaded the page once and re-recorded
a valid timestamp. With `lastModified > 0`, that page can never reload until
restart, and the failure is silent.
The trigger is narrow (a transient I/O failure while the meta info is built,
or a URL resource that reports no timestamp), but the precompiler's deliberate
sentinel is only ever `0`, so `-1` can keep its old meaning — e.g.
`lastModified != 0` preserves the recovery path while still treating `0` as
"nothing recorded".
##########
grails-gsp/core/src/test/groovy/org/grails/gsp/compiler/GroovyPageCompilerReproducibilitySpec.groovy:
##########
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.gsp.compiler
+
+import spock.lang.Specification
+import spock.lang.TempDir
+
+import org.grails.gsp.GroovyPageMetaInfo
+
+/**
+ * Precompiled GSPs must not vary with the modification time of their source.
+ *
+ * Git records no modification times, so every fresh clone or CI checkout
gives each .gsp a new one. Baking
+ * that into the generated class made otherwise identical jars differ on every
checkout, and because
+ * {@code LAST_MODIFIED} was a compile-time constant the difference survived
Gradle's compile-classpath
+ * normalization, so every downstream task missed the build cache.
+ */
+class GroovyPageCompilerReproducibilitySpec extends Specification {
+
+ private static final String PAGE_CONTENT = '<html><body><g:if
test="${flag}">Hello</g:if></body></html>'
+
+ @TempDir
+ File tempDir
+
+ private File viewsDir
+ private File page
+
+ void setup() {
+ this.viewsDir = new File(this.tempDir, 'views')
+ this.page = new File(this.viewsDir, 'index.gsp')
+ this.page.parentFile.mkdirs()
+ this.page.text = PAGE_CONTENT
+ }
+
+ void 'a source compiled at two different modification times produces
identical classes'() {
+ when: 'the same page is compiled twice, as two checkouts of one commit
would'
+ this.page.setLastModified(1_000_000_000_000L)
Review Comment:
`File.setLastModified()` fails silently by returning `false` on filesystems
that reject explicit mtime changes (container overlay FS, some CI volumes). If
that happens here and at line 57, both compilations see the same mtime and
`first == second` passes vacuously — the exact regression this spec exists to
catch would ship undetected. Asserting the return value makes the environment
problem loud instead:
```suggestion
assert this.page.setLastModified(1_000_000_000_000L)
```
(same at line 57)
##########
grails-gsp/core/src/test/groovy/org/grails/gsp/compiler/GroovyPageCompilerReproducibilitySpec.groovy:
##########
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.gsp.compiler
+
+import spock.lang.Specification
+import spock.lang.TempDir
+
+import org.grails.gsp.GroovyPageMetaInfo
+
+/**
+ * Precompiled GSPs must not vary with the modification time of their source.
+ *
+ * Git records no modification times, so every fresh clone or CI checkout
gives each .gsp a new one. Baking
+ * that into the generated class made otherwise identical jars differ on every
checkout, and because
+ * {@code LAST_MODIFIED} was a compile-time constant the difference survived
Gradle's compile-classpath
+ * normalization, so every downstream task missed the build cache.
+ */
+class GroovyPageCompilerReproducibilitySpec extends Specification {
+
+ private static final String PAGE_CONTENT = '<html><body><g:if
test="${flag}">Hello</g:if></body></html>'
+
+ @TempDir
+ File tempDir
+
+ private File viewsDir
+ private File page
+
+ void setup() {
+ this.viewsDir = new File(this.tempDir, 'views')
+ this.page = new File(this.viewsDir, 'index.gsp')
+ this.page.parentFile.mkdirs()
+ this.page.text = PAGE_CONTENT
+ }
+
+ void 'a source compiled at two different modification times produces
identical classes'() {
+ when: 'the same page is compiled twice, as two checkouts of one commit
would'
+ this.page.setLastModified(1_000_000_000_000L)
+ byte[] first = compileToBytes('first')
+
+ and:
+ this.page.setLastModified(1_700_000_000_000L)
+ byte[] second = compileToBytes('second')
+
+ then: 'the jar built from them is byte-identical, so downstream tasks
keep their cache hits'
+ first == second
+ }
+
+ void 'a compiled page records a checksum of its source instead of a
modification time'() {
+ when:
+ GroovyPageMetaInfo metaInfo = compileToMetaInfo('recorded')
+
+ then: 'the checksum identifies the content'
+ metaInfo.sourceChecksum ==~ /[0-9a-f]{64}/
+
+ and: 'no modification time is baked in for a fresh checkout to
invalidate'
+ metaInfo.lastModified == 0L
+ }
+
+ void 'an edited source produces a different checksum'() {
+ given:
+ GroovyPageMetaInfo before = compileToMetaInfo('before')
+
+ when:
+ this.page.text = '<html><body>something else entirely</body></html>'
+ GroovyPageMetaInfo after = compileToMetaInfo('after')
+
+ then: 'the runtime can still tell that the page changed'
+ before.sourceChecksum != after.sourceChecksum
+ }
+
+ private Map compile(File targetDir) {
+ targetDir.mkdirs()
+ GroovyPageCompiler compiler = new GroovyPageCompiler()
+ compiler.viewsDir = this.viewsDir
+ compiler.srcFiles = [this.page]
+ compiler.targetDir = targetDir
+ compiler.generatedGroovyPagesDirectory = new File(this.tempDir,
'generated').tap { mkdirs() }
+ compiler.compile()
+ }
+
+ private byte[] compileToBytes(String name) {
+ File targetDir = new File(this.tempDir, name)
+ Map results = compile(targetDir)
+ new File(targetDir, "${results.values().first()}.class").bytes
+ }
+
+ private GroovyPageMetaInfo compileToMetaInfo(String name) {
+ File targetDir = new File(this.tempDir, name)
+ Map results = compile(targetDir)
+ ClassLoader loader = new URLClassLoader([targetDir.toURI().toURL()] as
URL[], getClass().classLoader)
+ new GroovyPageMetaInfo(loader.loadClass(results.values().first() as
String))
Review Comment:
`URLClassLoader` is `Closeable`, and each call here leaks one loader (with
its open file handles) for the life of the test JVM. On Windows those handles
can block `@TempDir` cleanup, failing an otherwise green spec. Both uses of the
loader complete before the closure returns, so it can be closed immediately:
```suggestion
new URLClassLoader([targetDir.toURI().toURL()] as URL[],
getClass().classLoader).withCloseable { URLClassLoader loader ->
new GroovyPageMetaInfo(loader.loadClass(results.values().first()
as String))
}
```
##########
grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPageMetaInfoReloadSpec.groovy:
##########
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.gsp
+
+import java.security.PrivilegedAction
+
+import spock.lang.Specification
+import spock.lang.TempDir
+
+import org.springframework.core.io.FileSystemResource
+import org.springframework.core.io.Resource
+
+import org.grails.gsp.compiler.GroovyPageParser
+
+/**
+ * Reload-staleness behaviour of {@link GroovyPageMetaInfo}.
+ *
+ * A page compiled by {@code GroovyPageCompiler} records a checksum of its
source rather than the source's
+ * modification time, which git does not preserve across a checkout. Staleness
is therefore decided by
+ * comparing content, falling back to the timestamp for pages compiled before
the checksum existed.
+ *
+ * Each feature uses a fresh {@code GroovyPageMetaInfo}, because the result of
a check is cached for
+ * {@code grails.gsp.reload.interval} milliseconds.
+ */
+class GroovyPageMetaInfoReloadSpec extends Specification {
+
+ private static final String PAGE_CONTENT = '<html><body>hi</body></html>'
+
+ @TempDir
+ File tempDir
+
+ private Resource sourcePage(String content = PAGE_CONTENT) {
+ File page = new File(this.tempDir, 'index.gsp')
+ page.text = content
+ new FileSystemResource(page)
+ }
+
+ private static String checksumOf(Resource resource) {
+ resource.inputStream.withStream { InputStream input ->
GroovyPageParser.checksumOf(input) }
+ }
+
+ private static PrivilegedAction<Resource> callableFor(Resource resource) {
+ { -> resource } as PrivilegedAction
+ }
+
+ void 'a page whose recorded checksum matches its source is not reported as
stale'() {
+ given: 'a precompiled page recording the checksum of the source on
disk'
+ Resource resource = sourcePage()
+ GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo()
+ metaInfo.sourceChecksum = checksumOf(resource)
+
+ expect:
+ !metaInfo.shouldReload(callableFor(resource))
+ }
+
+ void 'a page whose source no longer matches its recorded checksum is
reported as stale'() {
+ given: 'a precompiled page whose source has since been edited'
+ Resource resource = sourcePage()
+ GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo()
+ metaInfo.sourceChecksum = checksumOf(resource)
+ resource.getFile().text = '<html><body>edited</body></html>'
+
+ expect:
+ metaInfo.shouldReload(callableFor(resource))
+ }
+
+ void 'a source that was touched but not edited is not reported as stale'()
{
+ given: 'a page whose source carries a modification time nothing like
the one it was compiled at'
+ Resource resource = sourcePage()
+ GroovyPageMetaInfo metaInfo = new GroovyPageMetaInfo()
+ metaInfo.sourceChecksum = checksumOf(resource)
+ resource.getFile().setLastModified(resource.getFile().lastModified() +
86_400_000L)
Review Comment:
Same `setLastModified()` caveat as in the reproducibility spec, but here it
erodes the premise rather than the assertion: this feature (and the pins back
to `originalTimestamp` at lines 113 and 126) still pass via the checksum path
if the call silently returns `false`, but then they no longer exercise the
scenario their names document — "touched but not edited" runs with an unmoved
mtime, and "edit within the granularity window" runs with an mtime that moved
well past the granularity, which the old timestamp path already caught. An
`assert` on all three keeps the coverage honest.
--
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]