paulk-asert commented on PR #2736:
URL: https://github.com/apache/groovy/pull/2736#issuecomment-5137823765
AI read:
> @blackdrag asked how often the ancestor-metaclass pattern appears in
Grails. I looked at that — and, since the answer only matters if hierarchy
fan-out is load-bearing in the first place, I also tested that premise
empirically on this PR's head (`637bdaf`). Three parts, then a suggested way
forward.
>
> ## 1. Direct answers to the two open questions
>
> **"Are we even caching in that case? If we are then caching that would be
new in this pull request I assume."**
>
> Caching is pre-existing, but the cached thing is not a frozen miss. When
selection finds no method, `Selector.setMetaClassCallHandleIfNeeded` (unchanged
by this PR, `cache = true`) links a route through `MetaClass.invokeMethod`
bound to the receiver's MetaClass instance. Each invocation of that linked
handle re-runs `invokeMissingMethod` → `findMethodInClassHierarchy`
(`MetaClassImpl.java:950`), and the walk resolves ancestor MetaClasses
**freshly from the registry on every call** (`MetaClassImpl.java:3949`).
>
> **"I simply think … a simple uncached path through the metaclass would be
sufficient."**
>
> It's stronger than that: the cached route already *is* a live path. A
linked "miss" site observes ancestor EMC changes with **no invalidation at
all**.
>
> ## 2. Experiment: disable fan-out, test behaviour only
>
> Since the indy `Selector` consults only the receiver's own MetaClass
tables at link time, I reduced `invalidateClassHierarchy` to root-only (no
descendant walk) on the PR head and ran behaviour-only probes through hot call
sites (200 iterations to ensure linkage) — no `hasBeenInvalidated()`
assertions, only observable dispatch results:
>
> | Probe | fan-out OFF | fan-out ON |
> |---|---|---|
> | linked miss → parent EMC adds method (case 4 in the matrix above) | ✅ |
✅ |
> | linked miss on `String[]` → `Object[]` EMC adds method | ✅ | ✅ |
> | present child method vs parent adds more-specific overload | ✅ | ✅ |
> | linked hit via snapshot → parent EMC removed (expecting miss) | ❌ stale
| ❌ stale — **identical** |
>
> Observations:
>
> - The canonical "case that *does* need hierarchy SP" passes **without** it
— the dynamic route self-heals, including across the array lattice.
> - The one failing probe fails identically in both configurations: the
child MetaClass was constructed after the parent EMC add, so the method sits in
the child's own tables (construction-time snapshot). Re-linking re-selects from
those unchanged tables — no SwitchPoint policy can alter the outcome. This is
the same behaviour as @blackdrag's D/E and F/G scripts.
> - The 19 existing tests that fail with fan-out disabled all assert
**SwitchPoint retirement** (mechanism), not dispatch results.
>
> Why this holds generally: ancestor-dependent resolution reaches a linked
site in exactly two ways — the dynamic `invokeMethod` route (live per call) or
methods copied into the receiver's own MetaClass (snapshot / EMC inheritance,
where any refresh fires the *child's* own `incVersion`). Re-linking against an
unchanged receiver MetaClass reproduces the same target, so retiring subtype
SwitchPoints when only an ancestor changed appears **behaviourally inert** for
stock `MetaClassImpl`/EMC.
>
> Caveats, honestly stated: I probed method dispatch, not property sites;
and a custom MetaClass implementation that consults ancestor state during
*selection* could still go stale — but that unknown-MC case could be served by
bulk invalidation (rare event) rather than an indexed subtype walk. If there's
a counter-example where fan-out changes observable behaviour for stock
metaclasses, that would be the decisive artifact.
>
> <details>
> <summary>Probe test source (drop into <code>src/test/groovy/bugs/</code>
to reproduce)</summary>
>
> ```groovy
> package bugs
>
> import org.junit.jupiter.api.AfterEach
> import org.junit.jupiter.api.Test
>
> final class FanOutBehaviourExperiment {
>
> static class Parent {}
> static class Child extends Parent {}
> static class Base2 {}
> static class Sub2 extends Base2 {}
>
> @AfterEach
> void tearDown() {
> [Parent, Child, Base2, Sub2, Object[].class, String[].class].each {
> GroovySystem.metaClassRegistry.removeMetaClass(it)
> }
> }
>
> private static String probe(x) {
> try { return x.onlyOnParent() } catch (MissingMethodException e) {
return 'miss' }
> }
>
> @Test
> void linkedMissThenParentEmcAdd() {
> def c = new Child()
> 200.times { assert probe(c) == 'miss' } // hot-link the miss
route on Child
> Parent.metaClass.onlyOnParent = { -> 'now-visible' }
> assert probe(c) == 'now-visible' // passes with fan-out
disabled
> }
>
> private static String probe2(x) {
> try { return x.onlyOnBase() } catch (MissingMethodException e) {
return 'miss' }
> }
>
> @Test
> void linkedHitViaParentEmcThenRemove() {
> Base2.metaClass.onlyOnBase = { -> 'from-base' }
> def s = new Sub2() // Sub2 MC created
AFTER add → snapshot
> 200.times { assert probe2(s) == 'from-base' }
> GroovySystem.metaClassRegistry.removeMetaClass(Base2)
> assert probe2(s) == 'miss' // fails IDENTICALLY
with and without fan-out
> }
>
> private static String probeArr(x) {
> try { return x.arrHello() } catch (MissingMethodException e) {
return 'miss' }
> }
>
> @Test
> void linkedMissThenObjectArrayEmcAdd() {
> String[] arr = ['a', 'b']
> 200.times { assert probeArr(arr) == 'miss' }
> Object[].metaClass.arrHello = { -> 'array-visible' }
> assert probeArr(arr) == 'array-visible' // passes with fan-out
disabled
> }
> }
> ```
> </details>
>
> ## 3. Grails (the direct question)
>
> I audited `apache/grails-core` `8.0.x` (the monorepo — includes GORM, GSP,
testing-support; 87 modules).
>
> **How often does ancestor-EMC visibility occur? It's structural, not
rare:**
>
> - The codec system registers every codec's `encodeAsX`/`decodeX` onto the
EMCs of `[String, GStringImpl, StringBuffer, StringBuilder, Object]`
(`CodecMetaClassSupport.resolveDefaultMetaClasses()`). Every
`someValue.encodeAsHTML()` on an arbitrary receiver is exactly the
`Object`-ancestor pattern — registered at startup
(`DefaultCodecLookup.registerCodecs`) and per test class
(`GrailsWebUnitTest.mockCodec`).
> - Dev reload clears the MetaClasses of 15 common classes including
`Number` (`DefaultGrailsPluginManager.COMMON_CLASSES`).
>
> **But** these events fire at startup / reload / test-setup, and both
resolution modes they produce (construction snapshot; dynamic walk) behave
identically with fan-out disabled per §2. Also relevant: global EMC mode is
**off** in the modern runtime (`enableGlobally()` appears only in legacy
test-support), and GORM 8 is trait-based — `GormEnhancer` does runtime EMC
registration only for Java entities or explicit `dynamicEnhance`.
>
> **Where Grails hurts today is what the scoping *core* fixes:**
>
> - GSP tag dispatch registers tag methods into page/taglib/controller EMCs
on **first use of each tag per class, in production, at request time**
(`TagLibraryMetaUtils.methodMissingForTagLib`, `addMethodsToMetaClass =
!developmentMode`). A warming-up app fires many registry events while serving
traffic — each one a **process-wide** deopt under the current global
SwitchPoint, scoped to one leaf class under this PR.
> - Dev reload (`removeMetaClass` + fresh EMC per changed class, GSP
recompile) and test suites (`mockCodec` per test class, GORM `close()` per
entity) churn metaclasses constantly.
>
> That is exactly the shape of the CI JMH grails-suite result on this PR
(≈5.7× calibrated; `burstThenSteadyState` ≈112×) — and none of it depends on
hierarchy fan-out, which only ever *adds* invalidations.
>
> ## Suggested way forward
>
> - **Keep** the per-MetaClass SwitchPoint scoping core — that's the
measurable win, and Grails is its best witness.
> - **Drop** `ClassHierarchyIndex` and the hierarchy fan-out policy for
stock `MetaClassImpl`/EMC (with the mechanism tests that assert fan-out), per
@blackdrag's argument, which the experiment supports.
> - For unknown/custom MetaClass kinds, stay correctness-first via **bulk**
invalidation on those rare events instead of an indexed subtype walk.
> - Property-site probes analogous to §2 would be worth adding before merge
to close the remaining coverage gap.
>
> That's a strictly smaller machine with, as far as these experiments can
see, identical user-visible semantics — and it keeps the full performance story
for beta-1.
--
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]