Skip to content

Fix flaky grails-views-gson tests caused by shared static test state - #16033

Open
borinquenkid wants to merge 1 commit into
8.0.xfrom
fix/flaky-json-view-gson-shared-state
Open

Fix flaky grails-views-gson tests caused by shared static test state#16033
borinquenkid wants to merge 1 commit into
8.0.xfrom
fix/flaky-json-view-gson-shared-state

Conversation

@borinquenkid

Copy link
Copy Markdown
Member

Summary

~20 test methods across JsonViewHelperSpec, JsonViewTestSpec, JsonApiSpec, and
ExpandSpec (module :grails-views-gson:test) show the same failure+flakiness
pattern (~2% failures, ~1% flakiness per #16030) — one shared root
cause, not 20 independent bugs.

Root cause

Two confirmed leak vectors (an initial hypothesis about shared Groovy trait statics
was empirically disproven via isolated Groovy 5.0.7/Spock 2.4 probes — traits do not
share static storage across implementing classes in this version):

  1. ExpandSpec silently resolved Team/Player to JsonViewHelperSpec's compiled
    classes via an unqualified same-package reference (no import), so both specs
    registered the identical Class objects into independently-built
    KeyValueMappingContext instances — letting class-keyed GORM caches leak between
    unrelated specs.
  2. org.grails.validation.ConstraintEvalUtils is a genuine JVM-wide static cache keyed
    by System.identityHashCode(config). JsonApiSpec alone worked around this for its
    own fixtures via a reflection hack into Validateable's internal state; the other
    three specs had no equivalent reset, so whichever spec ran last in a shared test
    fork left stale cached constraint/association state for the next one — explaining
    the toMany/toOne/hasOne/expand assertion flakiness.

Fix

  • Hoisted cleanup into the shared JsonViewTest trait (implemented by ~19 specs) so
    new specs get correct isolation by default: cleanup() clears the
    ConstraintEvalUtils cache after every feature; a separate cleanupSpec() tears
    down any cached GrailsApplication once per spec class (split this way because
    per-feature teardown broke MapRenderSpec's once-per-spec datastore bean
    registration — found via isolated probes before touching real test files).
  • Gave ExpandSpec its own ExpandTeam/ExpandPlayer domain classes instead of
    implicitly borrowing another spec's.
  • Moved JsonApiSpec off the reflection hack onto the public
    SuperHero.clearConstraintsMapCache() API (available since 7.1); removed its now-
    redundant local cleanup().

No production code changed.

Testing

  • Full :grails-views-gson:test (178 tests): 0 failures, run to completion multiple
    times including with varied --tests orderings across the originally-flagged specs
    specifically to catch order-dependent regressions.
  • CodeNarc/Checkstyle: clean.

Related: #16030

CI's flaky-test dashboard (#16030) showed ~20 flaky test
methods across JsonViewHelperSpec, ExpandSpec, JsonApiSpec and
JsonViewTestSpec, all sharing a common root cause: static state that leaks
between specs when several of them execute in the same test JVM/fork.

Two concrete leaks were found:

1. ExpandSpec declared top-level `Team`/`Player` classes with no import,
   which silently resolved (same package, same simple names) to the
   *compiled `Team`/`Player` classes already defined by JsonViewHelperSpec*.
   Both specs then registered those identical Class objects into their own,
   independently-built KeyValueMappingContext instances, so any class-keyed
   GORM cache populated by one spec could be observed by the other. Fixed by
   giving ExpandSpec its own distinct `ExpandTeam`/`ExpandPlayer` domain
   classes (and updating the JSON/HAL assertions, whose type names and URLs
   are derived from the class name).

2. `org.grails.validation.ConstraintEvalUtils` memoizes the default GORM
   constraints map in a single JVM-wide static field keyed by
   `System.identityHashCode(config)`. JsonApiSpec already worked around this
   for its own SuperHero fixture with hand-rolled setup()/cleanup() logic
   (plus reflection into Validateable's internal static field), but
   JsonViewHelperSpec, ExpandSpec and JsonViewTestSpec had no equivalent
   reset, so a stale cache entry left by whichever spec ran first in a fork
   could be picked up by the next.

Generalized the reset by adding a `cleanup()` fixture method directly to the
`JsonViewTest` trait (grails-views-gson/src/main/.../test/JsonViewTest.groovy)
that clears the ConstraintEvalUtils cache after every feature, so every spec
implementing the trait gets it for free. A companion `cleanupSpec()` tears
down any GrailsApplication cached by org.grails.testing.GrailsUnitTest, but
only once per spec class (not per-feature): GrailsUnitTest intentionally
builds and reuses its GrailsApplication across an entire spec's features,
and some traits (e.g. DataTest) register beans into it once per spec, so
tearing it down after every feature broke DataTest-based specs
(MapRenderSpec) in testing. GrailsUnitTest itself is a test-only dependency
that this main-sourceSet trait cannot reference directly, so the call is
made dynamically only when the implementing spec actually has it.

JsonApiSpec's own setup()/cleanup() was simplified accordingly: the
reflection-based Validateable static field hack is replaced with the public
`SuperHero.clearConstraintsMapCache()` API (available since 7.1), and the
now-redundant ConstraintEvalUtils call is dropped since the trait handles it.

Verified empirically (via isolated Groovy/Spock trait-composition probes)
that a class overriding a trait-provided cleanup() fails to compile under
Groovy 5/Spock 2.4, which is why JsonApiSpec no longer defines cleanup()
itself. Also empirically confirmed that Groovy trait static fields are
*not* shared across implementing classes (contrary to the initial triage
hypothesis) - the actual leak vectors are the two described above.

Full :grails-views-gson:test suite (178 tests) passes repeatedly, including
reruns with --rerun-tasks and varied --tests subsets/orderings covering all
previously-flagged specs plus the other GrailsUnitTest+JsonViewTest specs.
codeStyle and aggregateStyleViolations report zero Checkstyle/CodeNarc
violations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 21, 2026 21:57

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

This PR addresses flakiness in :grails-views-gson:test by eliminating shared JVM/static test state leaks between specs, improving test isolation without changing production/runtime behavior.

Changes:

  • Added centralized per-feature cleanup to JsonViewTest to clear the JVM-wide ConstraintEvalUtils default-constraints cache.
  • Updated ExpandSpec to use its own dedicated @Entity domain classes (ExpandTeam/ExpandPlayer) to avoid accidental cross-spec class reuse and class-keyed cache leakage.
  • Simplified JsonApiSpec by removing the reflection-based cache reset and using Validateable’s public clearConstraintsMapCache() API via SuperHero.clearConstraintsMapCache().

Reviewed changes

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

File Description
grails-views-gson/src/test/groovy/grails/plugin/json/view/ExpandSpec.groovy Introduces dedicated domain classes for this spec and updates expected JSON/link values accordingly to prevent cross-spec cache leakage.
grails-views-gson/src/test/groovy/grails/plugin/json/view/api/JsonApiSpec.groovy Removes reflection-based cache manipulation and uses the public constraints-cache clear API for the Validateable fixture.
grails-views-gson/src/main/groovy/grails/plugin/json/view/test/JsonViewTest.groovy Adds standardized teardown hooks to clear shared validation constraint state after each feature and optionally tear down cached GrailsApplication after the spec.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@testlens-app

testlens-app Bot commented Jul 21, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 8a4a6eb
▶️ Tests: 16354 executed
⚪️ Checks: 60/60 completed


Learn more about TestLens at testlens.app.

@jamesfredley jamesfredley moved this to Todo in Apache Grails Jul 24, 2026
@borinquenkid borinquenkid added this to the grails:8.0.0-RC1 milestone Jul 25, 2026

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

Before taking a look at this, I had AI take a look. Here's it's comments:

Thanks for digging into #16030 — the triage write-up is genuinely useful, and disproving the trait-static hypothesis with isolated probes rather than assuming was the right instinct. Two things I'd like to resolve before this lands.

1. The JsonViewTest changes are in src/main. The PR body says "No production code changed", but grails-views-gson/src/main/groovy/grails/plugin/json/view/test/JsonViewTest.groovy ships in the grails-views-gson artifact and is the documented way applications test JSON views. Adding cleanup()/cleanupSpec() to it is a breaking API change for downstream specs (details inline — I reproduced the compile failure locally). I also believe both fixture methods are redundant with machinery that already exists in grails-testing-support-core; sources cited inline.

2. The ExpandSpec decoupling looks right, but it's applied to one of four specs with the same problem — and not to the two with the highest failure counts. Details inline on ExpandSpec.

On evidence: the counts in #16030 are worth a second look. Every flagged method within a spec has an identical count (all 8 JsonViewHelperSpec methods 20/2037, all 7 JsonApiSpec methods 20/2023, all 4 ExpandSpec methods 19/2034, JsonViewTestSpec 19/2029). Identical per-method counts across an entire spec is the signature of ~20 CI runs in which those specs failed wholesale — a fixture/setup() throw or a fork-level failure — rather than independent per-assertion flakiness. That's a different shape of bug than a cache returning stale data, and it's the strongest clue available. Could you pull the actual stack trace from one of those failing runs?

The reason I'm pushing on that: a green suite doesn't discriminate between hypotheses here. I ran :grails-views-gson:test on this branch (178/178) and then again three times with the JsonViewTest change reverted and only the ExpandSpec change kept, single JVM, -PforkEveryUnitTest=0 -PtestBisect to maximise shared state — 178/178 every time. 8.0.x passes ~99% of the time on its own, so neither result tells us whether the leak is closed.

What I'd suggest: land the ExpandSpec and JsonApiSpec changes (both are improvements on their own merits), extend the class-decoupling to the remaining specs, and drop the JsonViewTest trait change.

* Config}, a stale entry left behind by one spec can otherwise be picked up by another.
* This is cheap to recompute, so it is safe to clear after every feature.</p>
*/
void cleanup() {

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.

Blocking: this breaks downstream specs, and there is no workaround available to the user.

JsonViewTest is a published trait. Because a Groovy trait method becomes an interface method, an implementing class must declare it public — but Spock's AST transform lowers the visibility of fixture methods. The two requirements are irreconcilable, so any application spec that implements JsonViewTest and declares its own cleanup() no longer compiles. Reproduced on this branch by adding a spec with a cleanup() body to grails-views-gson/src/test:

> Task :grails-views-gson:compileTestGroovy FAILED
startup failed:
.../TmpUserCleanupProbeSpec.groovy: 29: The method cleanup should be public as it implements
the corresponding method from interface grails.plugin.json.view.test.JsonViewTest
. At [29:5]  @ line 29, column 5.
       void cleanup() {
       ^

cleanup() is one of the most commonly used Spock fixtures, and the user's only remedy is to delete theirs. The same applies to cleanupSpec() below. It's already biting inside this PR: JsonApiSpec had to give up its own cleanup(), not because the teardown was unnecessary but because it can no longer declare one.

If per-feature isolation is wanted for this module's specs, it belongs in a test-scoped fixture — a base spec or trait under grails-views-gson/src/test, or in grails-testing-support-views-gson, which is already a testImplementation dependency and is the natural home for test-lifecycle behaviour. That keeps the shipped API unchanged.

* This is cheap to recompute, so it is safe to clear after every feature.</p>
*/
void cleanup() {
ConstraintEvalUtils.clearDefaultConstraints()

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.

Separately from the API concern: I don't think this clearing is load-bearing, because the cache is already reset once per spec class today.

ConstraintEvalUtils' static initialiser registers its own reset as a preserved shutdown operation:

// grails-core/.../org/grails/validation/ConstraintEvalUtils.groovy:37
static {
    ShutdownOperations.addOperation({ clearDefaultConstraints() } as Runnable, true)
}

true is preserveForNextShutdown, so ShutdownOperations.runOperations() re-adds it after each run and it fires every time. GrailsUnitTest.cleanupGrailsApplication() calls runOperations() (GrailsUnitTest.groovy:184), and nothing in the repo calls ShutdownOperations.resetOperations() — the only thing that would drop a preserved operation. Since the cache can only be non-empty if ConstraintEvalUtils has been loaded, and loading is what registers the reset, any populated cache is guaranteed to be cleared at the end of the spec that populated it. So cross-spec leakage of this cache is already impossible for GrailsUnitTest specs; this change only moves it from per-spec to per-feature, and a single spec has a single Config.

The stated mechanism also doesn't fit the failure rate. getDefaultConstraints(config) recomputes unless configId == System.identityHashCode(config), so returning a stale map requires an identity-hash collision between two consecutive Config instances — roughly 1 in 2^31, not ~1%.

If you have a reproduction that shows otherwise I'd like to see it, since that would point at a real bug in ConstraintEvalUtils' identity-hash keying — which would be worth fixing there (e.g. a WeakHashMap keyed on the Config itself) rather than papered over from a test trait.

* {@code grails-views-gson} artifact, so the call is made dynamically only when present.</p>
*/
@CompileDynamic
void cleanupSpec() {

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.

This method is a no-op and can be dropped, along with the @CompileDynamic annotation and the groovy.transform.CompileDynamic import.

cleanupGrailsApplication() is already invoked after every spec class that implements GrailsUnitTest, via the global Spock extension in grails-testing-support-core:

// org/grails/testing/spock/TestingSupportExtension.groovy:51 (registered in
// META-INF/services/org.spockframework.runtime.extension.IGlobalExtension)
if (GrailsUnitTest.isAssignableFrom(spec.reflection)) {
    spec.addCleanupSpecInterceptor(cleanupContextInterceptor)
}

CleanupContextInterceptor calls cleanupGrailsApplication() in a finally block, and Spock attaches cleanup-spec interceptors to a synthetic CLEANUP_SPEC MethodInfo that runs for every spec whether or not one is declared (PlatformSpecRunner.createMethodForDoRunCleanupSpec, spock-core 2.4). So for a GrailsUnitTest spec this trait method runs inside invocation.proceed(), nulls _grailsApplication, and the interceptor's own call then finds null and no-ops — net behaviour identical to 8.0.x. For a spec that doesn't implement GrailsUnitTest, respondsTo is false and nothing happens at all.

That matters beyond tidiness: this is the half of the change that adds a second breaking fixture method to the published trait, for no behavioural gain.


void setup() {
mappingContext.addPersistentEntities(Team, Player)
mappingContext.addPersistentEntities(ExpandTeam, ExpandPlayer)

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.

This is the part of the PR I'd keep — an unqualified same-package reference silently binding to another spec's @Entity classes is a real trap, and giving ExpandSpec its own fixtures is the right shape of fix.

But it's applied to one of four specs doing exactly this, and not to the two with the worst numbers in #16030. Team and Player are declared once, in JsonViewHelperSpec.groovy:663 and :672, and after this PR they are still registered into three other independently-built KeyValueMappingContext instances:

  • IncludeAssociationsSpecimport grails.plugin.json.view.* plus addPersistentEntities(Player, Team)
  • HalEmbeddedSpec — same-package unqualified reference, addPersistentEntities(Team, Player)
  • IterableRenderSpec — same-package unqualified reference, addPersistentEntities(Player, Team), in five separate features

The same pattern holds for two other classes:

  • grails.plugin.json.view.api.Author is declared in api/JsonApiSpec.groovy:449 and also registered by api/JsonApiHandleAssociationsSpec (addPersistentEntities(Author, PublishedBook, Publisher))
  • Person is declared in EmbeddedAssociationsSpec.groovy:180 and also registered by HalEmbeddedSpec (addPersistentEntities(Person, Parent))

Per the dashboard the two worst specs are JsonViewHelperSpec (8 methods, 20 failures) and JsonApiSpec (7 methods, 20 failures) — and both still share entity classes with another spec after this change. ExpandSpec (19 failures) is the only one decoupled. So if class-keyed GORM state is the root cause, the flakiness should survive this PR.

Could you extend the same treatment to those specs? Given Team/Player are wanted by four specs, a shared read-only fixture file plus one mapping context, or per-spec copies as done here, would both work — the important thing is that no two independently-built mapping contexts see the same Class. A dedicated fixture source file would also make the ownership obvious and stop the next spec from picking them up by accident.

// JsonViewTest#cleanup() already resets the shared ConstraintEvalUtils cache after every
// feature, but SuperHero's cache is specific to this spec's own Validateable command
// object, so it is reset here too.
SuperHero.clearConstraintsMapCache()

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.

Good change — dropping the Validateable$Trait$StaticFieldHelper reflection in favour of the public clearConstraintsMapCache() is exactly right, and it matches how grails-validation's own specs do it (ValidateableTraitSpec:46, ValidateableTraitAdHocSpec:37). It also brings this spec in line with the project rule about testing through public APIs.

One consequence worth being explicit about: with the local cleanup() gone, SuperHero's cache is now only reset on the way in, so the last feature of this spec leaves it populated for the rest of the fork. That's harmless today — SuperHero is declared and used only in this file — but it's the opposite of the isolation-by-default goal, and note that you can't fix it by re-adding a cleanup() here as long as the trait declares one. Another argument for keeping the fixture methods out of the published trait.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

4 participants