Reduce cached codec metaclass registration overhead - #15800
Conversation
Assisted-by: Hephaestus:gpt-5.5
Assisted-by: Hephaestus:gpt-5.5
There was a problem hiding this comment.
Pull request overview
This pull request reduces startup/runtime overhead from repeated cached codec registration by making cached encodeAs* / decode* ExpandoMetaClass writes idempotent per (target class, codec method name, codec factory identity), while preserving non-cached (development/reload) behavior and distinct-factory semantics.
Changes:
- Add a weak-identity registration key and per-
ExpandoMetaClasssynchronization to skip duplicate cached meta-method writes. - Add focused Spock coverage for cached idempotence, metaclass replacement, distinct factories, and non-cached re-registration.
- Add a benchmark harness plus a
:grails-encoder:codecMetaClassBenchmarkGradle task to measure registration cost and write counts.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| grails-encoder/src/main/groovy/org/grails/encoder/CodecMetaClassSupport.groovy | Adds cached-registration dedupe bookkeeping and synchronized duplicate checks to avoid repeated ExpandoMetaClass mutation. |
| grails-encoder/src/test/groovy/org/grails/encoder/CodecMetaClassSupportSpec.groovy | Adds targeted tests covering idempotence, concurrency, metaclass replacement, stale-key pruning, and non-cached behavior. |
| grails-encoder/src/test/groovy/org/grails/encoder/CodecMetaClassBenchmark.groovy | Adds a runnable benchmark main for codec registration and encode-path timing/counters. |
| grails-encoder/build.gradle | Registers a JavaExec benchmark task and forwards grails.codec.benchmark.* system properties. |
| grails-test-suite-uber/src/test/groovy/org/grails/commons/DefaultGrailsCodecClassTests.groovy | Adds regression coverage calling configureCodecMethods() twice and validates public dynamic methods still work; improves metaclass cleanup. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #15800 +/- ##
==================================================
+ Coverage 52.3426% 52.3560% +0.0134%
- Complexity 18293 18307 +14
==================================================
Files 2036 2036
Lines 96346 96373 +27
Branches 16831 16838 +7
==================================================
+ Hits 50430 50457 +27
+ Misses 38492 38490 -2
- Partials 7424 7426 +2
🚀 New features to boost your workflow:
|
jdaugherty
left a comment
There was a problem hiding this comment.
I'm worried about the memory impacts this has on large applications. It's rolling it's own caching mechanism when elsewhere in the code base we've used other solutions like caffeine, etc.
Assisted-by: opencode:gpt-5.5
Assisted-by: opencode:gpt-5.5
|
From using fabled to review this: Review: PR #15800 — Reduce cached codec metaclass registration overheadFirst, confirming one premise: repeated Correctness / performance problems1. The distinct-factory path is now quadratic — and the PR's own control benchmark private static void removeStaleMetaMethodRegistrationKeys() {
CodecFactoryKey codecFactoryKey = (CodecFactoryKey) STALE_CODEC_FACTORIES.poll()
while (codecFactoryKey != null) {
REGISTERED_META_METHODS.remove(codecFactoryKey)
codecFactoryKey = (CodecFactoryKey) STALE_CODEC_FACTORIES.poll()
}
REGISTERED_META_METHODS.keySet().removeIf { CodecFactoryKey key -> key.stale }
}With N distinct factories that's O(N²), which is exactly why the PR's control case went 2. 3. The registration key uses the class name, not the class ( private static MetaMethodRegistrationKey registrationKey(ExpandoMetaClass emc, String methodName) {
new MetaMethodRegistrationKey(emc.getTheClass().getName(), methodName)
}Under dev reload / plugin classloaders, a reloaded class with the same name collides 4. The fallback check is name-only and cross-factory (line 219): registeredMetaMethodKeys(codecFactory).add(key) || emc.getMetaMethod(methodName, EMPTY_ARGS) == nullAfter a metaclass is replaced, factory A's re-registration is skipped if any factory 5. This is ~150 lines of hand-rolled weak-identity caching — custom 6. Is this even the right layer? Evidence problems7. The headline numbers aren't reproducible from the branch. The Test / process problems8. Tests violate the repo's own rules (CLAUDE.md rule 9 — test via public APIs) and private static Set registeredMetaMethodKeys() {
def field = CodecMetaClassSupport.getDeclaredField('REGISTERED_META_METHODS')
field.accessible = true
((Map) field.get(null)).keySet()
}Tests that can only assert via private internals are a signal the design isn't 9. Global-state test hygiene. The spec clears process-wide static registration state 10. Minor. Bottom lineThe problem is real, but I'd push back on the shape of the fix:
|
…class-registration-overhead
…class-registration-overhead
Keep synchronized metaclass mutation, avoid retaining replaced ExpandoMetaClass instances, and restore benchmark test JVM wiring. Assisted-by: Sisyphus:xai/grok-4.5 [gpt-coding]
|
The problem this targets is real - repeated
So I agree with the direction you outlined:
I'll rework it this way. Thanks for the thorough review - this one's a genuinely better design after the feedback. |
|
@jamesfredley let me know once all of these changes are pushed and I'll review again. |
Rely on InitializingBean.afterPropertiesSet for DefaultCodecLookup instead of calling reInitialize twice from CodecsConfiguration. Have mockCodec either reInitialize after adding the artefact or configure methods once, not both. Assisted-by: Sisyphus:xai/grok-4.5
Use Class identity instead of class name for registration keys so reloaded/plugin classloaders do not collide. Serialize claim-and-register with a Caffeine weakKeys per-factory lock instead of synchronizing on shared ExpandoMetaClass instances. Keep public-behavior coverage for idempotence and concurrent same-factory registration. Assisted-by: Sisyphus:xai/grok-4.5
Reshape after review feedbackPushed two commits on top of the previous approach:
Intentionally not done
VerificationBUILD SUCCESSFUL (7/7). Ready for another review pass. |
This comment has been minimized.
This comment has been minimized.
jdaugherty
left a comment
There was a problem hiding this comment.
Approving.
I traced the lifecycle rather than taking the description at face value, and the mechanism holds up:
BasicCodecLookup implements InitializingBeanwithafterPropertiesSet() -> reInitialize(), and this@Beanis the onlycodecLookupdefinition in the tree, so dropping the manualreInitialize()loses nothing. The legacy GSP harness registers its owncodecLookupdefinition (AbstractGrailsTagTests), so it still gets the Spring lifecycle too.cacheLookupis driven offEnvironment.isDevelopmentMode(), so the reload path is genuinely untouched.- The
getMetaMethod(...) == nullfallback is load-bearing, and the spec proves it: drop the fallback andre-adds methods after metaclass replacementfails. The tests pin the mechanism rather than a usage pattern, which is what I want to see here. - The cached closures capture the resolved encoder eagerly, but
DefaultGrailsCodecClass.initializeCodec()is one-shot guarded, so there is no stale-encoder path through the real factory implementation. - Lock ordering is consistently factory-lock -> EMC and never touches the globally shared metaclasses, so there is no deadlock to worry about.
One behaviour change worth calling out in the description: when two codecs register the same method name or alias, a second registration pass over a replaced metaclass now lets whichever factory arrives first win, where previously each pass re-asserted last-writer-wins. It takes a name collision plus a metaclass reset to observe, so I am not treating it as a blocker, but it is the one semantic edge the fallback introduces.
Comments below. I would like the two test gaps closed before this lands - CodecsConfiguration and the mockCodec false branch are both touched with nothing covering them - and a decision on where the benchmark lives now that #16071 is in flight. Approving so neither of those needs another review round trip.
The Problem
Fixes #15374.
Grails codec registration dynamically adds
encodeAs*anddecode*methods onto globally hot metaclasses (String,GStringImpl,StringBuffer,StringBuilder,Object). On Groovy Indy, repeatedly mutating thoseExpandoMetaClasstypes invalidates call sites and creates avoidable startup/runtime overhead. The clearest, fully compatible win is to stop re-adding the exact same cached codec method when the sameCodecFactoryis already registered for the same target class.The Fix (reshaped after review)
Two layers, deliberately simple:
1. Stop duplicate registration at the callers
CodecsConfiguration.codecLookupno longer callsreInitialize()manually.DefaultCodecLookupalready implementsInitializingBean; Spring'safterPropertiesSet()performs the single startup registration pass. This removes a full double-registration at bean creation.GrailsWebUnitTest.mockCodeceither adds the artefact andreInitialize()s, or callsconfigureCodecMethods()once - not both.2. Light registry only where still needed (
CodecMetaClassSupport)weakKeys()cache keyed on factory identity.Class<?>+ method name (not class name strings), so reloaded/plugin classloaders with the same simple name do not collide.weakKeys()cache - neversynchronized(emc)on globally shared metaclasses.What is deliberately preserved
value.encodeAsHTML(),value.encodeAsURL(),value.decodeSomeCodec()exactly as before.Impact framing
Problem shape (baseline): repeated same-factory EMC writes from:
CodecsConfigurationcallingreInitialize()and Spring then callingafterPropertiesSet()againmockCodeccallingconfigureCodecMethods()and thenreInitialize()which calls it againDefaultCodecLookup.reInitialize()passes over the same artefact instancesWhat this PR reduces: redundant same-factory writes via caller dedupe + Class-keyed Caffeine safety net.
Measurement notes: An opt-in microbenchmark remains at
:grails-encoder(grails.codec.benchmark.enabled=true). Earlier real-app timing numbers on this PR were outlier-sensitive (mean vs median gap); treat them as directional only. The durable claim is fewer redundant EMC mutations on the startup/reInitialize path, not a single wall-clock number.Scope
Codec-registration slice of the broader #15374 / Indy-metaclass performance topic. GORM dynamic methods, taglib dispatch, static compilation, artefact indexing, and metaclass freeze mechanisms are out of scope here.
Testing
:grails-encoder:test-CodecMetaClassSupportSpec(same-factory idempotence, decoder/aliases, concurrent same-factory registration, re-add after metaclass replacement, distinct-factory isolation, factory churn, non-cached reload behavior):grails-test-suite-uberDefaultGrailsCodecClassTestsstill covers doubleconfigureCodecMethodson the artefact path