jamesfredley opened a new pull request, #15800:
URL: https://github.com/apache/grails-core/pull/15800

   # Reduce cached codec metaclass registration overhead
   
   ## Summary
   
   This pull request reduces repeated `encodeAs*` and `decode*` 
ExpandoMetaClass writes during cached codec registration, adds focused 
compatibility coverage, and includes repeatable benchmark support plus real 
Grails application validation. Re-registering the same cached codec factory for 
the same target class and codec method now avoids mutating the target metaclass 
after the first successful registration.
   
   The public Grails codec surface remains unchanged. Existing applications 
continue to declare codecs and call methods such as `value.encodeAsHTML()`, 
`value.encodeAsURL()`, and `value.decodeSomeCodec()` in the same way as before.
   
   Fixes apache/grails-core#15374.
   
   ## Problem
   
   Grails codec registration dynamically adds `encodeAs*` and `decode*` methods 
to globally hot metaclasses:
   
   - `String`
   - `GStringImpl`
   - `StringBuffer`
   - `StringBuilder`
   - `Object`
   
   On Groovy Indy, repeated ExpandoMetaClass mutation of these globally used 
types can invalidate call sites and create avoidable startup/runtime overhead. 
The most important compatible win is to stop adding the exact same cached codec 
method repeatedly when the same `CodecFactory` is already registered for the 
same target metaclass.
   
   ## What changed
   
   This is the codec-registration slice of the broader #15374 / Tier 1 / Tier 4 
investigation. The research also examined GORM dynamic methods, taglib 
dispatch, static compilation opportunities, artefact indexing, and metaclass 
freeze or warning mechanisms, but those areas are not changed here because they 
carry a larger compatibility and review surface. This PR keeps the shipped code 
focused on the automatic, backward-compatible codec metaclass registration win 
that produced measurable before/after gains.
   
   ### Runtime behavior
   
   - `CodecMetaClassSupport` now tracks cached metaclass registrations by 
target class, codec method name, and `CodecFactory` identity.
   - `CodecFactory` identity is represented by a weak identity key, not by 
codec name and not by raw `identityHashCode` alone. This avoids collapsing 
distinct factories that happen to expose the same codec name.
   - Repeated cached registration for the same factory and method skips the 
second and later ExpandoMetaClass write.
   - The first registration still adds the normal `encodeAs*` / `decode*` 
method closures.
   - If a target metaclass was removed and recreated, registration re-adds the 
method even when the same factory identity was seen before.
   - Cached duplicate checks are synchronized per target `ExpandoMetaClass` so 
parallel registration cannot race into duplicate writes for the same metaclass.
   - Internal test/benchmark support can clear the registration state. This is 
not a new user-facing lifecycle API.
   
   ### Compatibility-preserving cases
   
   - Non-cached registration is unchanged. This preserves development/reload 
behavior where codecs may need to be resolved and reattached repeatedly.
   - Distinct `CodecFactory` instances are still treated as distinct even if 
they expose the same codec name. This avoids collapsing factories that might 
intentionally provide different encoder/decoder instances behind the same codec 
identifier.
   - Existing aliases continue to be registered through the same path as 
primary codec names.
   - No application code changes are required.
   
   ### Verification support
   
   - Added `CodecMetaClassSupportSpec` coverage for:
     - cached same-factory registration idempotence
     - cached registration after metaclass replacement
     - distinct cached factories remaining isolated
     - non-cached registration continuing to re-register
   - Added a `CodecMetaClassBenchmark` test main plus 
`:grails-encoder:codecMetaClassBenchmark` Gradle task to make the before/after 
registration cost observable.
   - The benchmark task forwards `grails.codec.benchmark.*` system properties 
to its JavaExec fork so registration counts and modes can be adjusted from the 
command line.
   
   ## Backward compatibility for Grails applications
   
   This change is intended to be fully backward compatible for Grails 
applications upgrading to a release that includes it.
   
   ### Required app changes
   
   None.
   
   Applications do not need to change codec declarations, GSPs, taglibs, 
controllers, services, or calls to `encodeAs*` / `decode*`.
   
   ### Public API compatibility
   
   - The user-facing dynamic methods are unchanged.
   - The codec lookup and registration entry points are unchanged.
   - No dependency changes were made.
   - No configuration property changes were made.
   - No migration step is required.
   
   ### Runtime compatibility
   
   - Cached production-style codec lookup still resolves the encoder/decoder 
once and installs the same closures as before.
   - Development/non-cached codec lookup still resolves codecs at invocation 
time and still re-registers. This preserves reload-oriented behavior.
   - Distinct codec factory instances are not deduplicated against each other. 
This is intentionally conservative for plugins or applications that build 
factories dynamically.
   - If a metaclass is replaced, the method is re-added on the new metaclass, 
which avoids stale static bookkeeping preventing a fresh metaclass from 
receiving the codec method.
   
   ### Behavioral compatibility
   
   The expected rendered/encoded values are unchanged. Functional GSP coverage 
verifies that HTML and URL encoding still works through the browser-facing 
application surface.
   
   ## Documentation
   
   No user-facing documentation change is required because this PR does not add 
a new feature, configuration option, public API, or migration step. The 
existing codec usage documentation remains accurate because application code 
still uses the same `encodeAs*` and `decode*` methods.
   
   Documentation-related notes for reviewers:
   
   - There is no new setting to document.
   - There is no new user workflow to document.
   - There is no application upgrade action to document.
   - There is no changed codec syntax to document.
   - The benchmark task is test/developer verification infrastructure, not an 
end-user feature.
   
   If the project wants a release note, the suggested wording is:
   
   > Improve codec metaclass registration performance by avoiding repeated 
cached `encodeAs*` / `decode*` ExpandoMetaClass writes while preserving 
existing codec behavior and development reload semantics.
   
   ## Testing and verification
   
   ### Unit and module tests
   
   Passed:
   
   ```text
   ./gradlew :grails-encoder:clean :grails-encoder:test -PmaxTestParallel=1
   ```
   
   Also rerun after the final hardening pass:
   
   ```text
   ./gradlew :grails-encoder:test -PmaxTestParallel=1
   ```
   
   The encoder module test run includes the new `CodecMetaClassSupportSpec` 
coverage plus existing encoder, HTML encoder, JavaScript codec, chained 
encoder, and `StreamCharBuffer` coverage.
   
   ### Functional surface test
   
   Passed:
   
   ```text
   ./gradlew :grails-test-examples-gsp-layout:integrationTest \
     --tests "GspTagLibSpec.encodeAsHTML prevents XSS" \
     --tests "GspTagLibSpec.encodeAsURL encodes URL parameters" \
     -PmaxTestParallel=1 \
     --rerun-tasks
   ```
   
   This verifies the change through a real Grails application surface using GSP 
rendering and browser-driven Geb/Testcontainers execution.
   
   Note: an earlier GSP attempt failed while starting Selenium/Testcontainers 
and left orphaned `selenium/standalone-chrome:4.43.0` containers. After 
removing those orphaned containers, the targeted GSP codec specs passed.
   
   ### Benchmarks
   
   Benchmark command shape:
   
   ```text
   ./gradlew -q \
     "-Dgrails.codec.benchmark.registrationIterations=10000" \
     "-Dgrails.codec.benchmark.encodeWarmupIterations=100000" \
     "-Dgrails.codec.benchmark.encodeIterations=1000000" \
     :grails-encoder:codecMetaClassBenchmark
   ```
   
   Distinct-factory control adds:
   
   ```text
   "-Dgrails.codec.benchmark.newFactoryEachRegistration=true"
   ```
   
   Observed local results:
   
   | Scenario | Branch | registrationNanos | registrationNanosPerOp | 
codecMetaMethodRegistrations |
   | --- | --- | ---: | ---: | ---: |
   | same factory, 10,000 registrations | baseline `origin/8.0.x` with copied 
harness | 7,988,652,900 | 798,865.29 | counter unavailable |
   | same factory, 10,000 registrations | this branch | 1,233,321,800 | 
123,332.18 | 2 |
   | new factory per registration, 10,000 registrations | baseline 
`origin/8.0.x` with copied harness | 9,961,345,100 | 996,134.51 | counter 
unavailable |
   | new factory per registration, 10,000 registrations | this branch | 
14,439,459,900 | 1,443,945.99 | 20,000 |
   
   The compatible same-factory cached path improved from about `7.99s` to about 
`1.23s` for 10,000 registrations in this local harness, roughly a 6.5x 
reduction in registration time. The final branch also confirms only two 
metaclass method writes for the single encoder-only benchmark codec in 
same-factory mode.
   
   The new-factory control still performs 20,000 method writes for 10,000 
registrations because each iteration uses a distinct factory and the PR 
intentionally preserves that compatibility behavior. That control is not the 
optimized compatibility path; it exists to prove distinct factories are not 
collapsed.
   
   ### Real Grails application benchmark
   
   To verify the improvement was visible outside the micro-harness, I added and 
ran an opt-in real-app benchmark against `grails-test-examples-app1`. This is a 
full Grails application using the web plugin, GSP plugin, Hibernate 5, cache, 
scaffolding, Sitemesh/layout support, local test plugins, and the existing 
`CodecTestController` integration surface.
   
   The benchmark starts the actual app with `server.port=0`, then exercises:
   
   - full application startup and codec registration
   - direct dynamic codec calls inside the initialized app
   - HTTP requests against all existing codec controller endpoints
   
   The same benchmark harness was copied into an `origin/8.0.x` baseline 
worktree for apples-to-apples comparison.
   
   Combined workload command shape:
   
   ```text
   ./gradlew -q \
     "-Dgrails.codec.realapp.startupIterations=5" \
     "-Dgrails.codec.realapp.httpRounds=10" \
     "-Dgrails.codec.realapp.directRounds=20000" \
     :grails-test-examples-app1:realAppCodecBenchmark
   ```
   
   This workload performs 5 full app starts, 900 total HTTP codec endpoint 
requests, and 800,000 total direct dynamic codec operations.
   
   | Metric | Baseline `origin/8.0.x` | This branch | Improvement |
   | --- | ---: | ---: | ---: |
   | Startup mean, 5 app starts | `16.36s` | `11.26s` | 31.2% lower, 1.45x 
faster |
   | Startup median, 5 app starts | `10.03s` | `5.86s` | 41.6% lower, 1.71x 
faster |
   | Direct dynamic codec median per startup, 160,000 ops/startup | `8.11s` | 
`6.48s` | 20.1% lower, 1.25x faster |
   | HTTP codec endpoint median per startup, 180 requests/startup | `2.10s` | 
`1.53s` | 26.8% lower, 1.37x faster |
   
   Startup-only command shape:
   
   ```text
   ./gradlew -q \
     "-Dgrails.codec.realapp.startupIterations=8" \
     "-Dgrails.codec.realapp.httpRounds=0" \
     "-Dgrails.codec.realapp.directRounds=0" \
     :grails-test-examples-app1:realAppCodecBenchmark
   ```
   
   | Metric | Baseline `origin/8.0.x` | This branch | Improvement |
   | --- | ---: | ---: | ---: |
   | Startup mean, 8 app starts | `16.94s` | `10.48s` | 38.1% lower, 1.62x 
faster |
   | Startup median, 8 app starts | `9.55s` | `5.93s` | 37.9% lower, 1.61x 
faster |
   
   Branch-side registration counter evidence from the real app:
   
   - 5-start combined workload: `realAppMetaMethodRegistrationsDelta=550`
   - 8-start startup-only workload: `realAppMetaMethodRegistrationsDelta=880`
   
   That is exactly 110 codec metaclass writes per full `app1` startup on this 
branch. The baseline does not expose the counter, so the direct before/after 
comparison uses startup and workload timings.
   
   ### Static/diff checks
   
   Passed:
   
   ```text
   git diff --check
   ```
   
   LSP diagnostics were run on the changed Groovy/Gradle files. The Gradle file 
was clean. The Groovy diagnostics still report unresolved classpath warnings 
for standard Groovy/Grails/Spock classes in this workspace, while Gradle 
compilation and tests pass.
   
   ## Risk assessment
   
   ### Low-risk aspects
   
   - No user-facing API changed.
   - No dependency changed.
   - No application configuration changed.
   - The optimization is limited to cached lookup mode.
   - Existing non-cached behavior is preserved.
   - Distinct factory instances remain distinct.
   
   ### Main risks considered
   
   #### Metaclass replacement
   
   Risk: static bookkeeping could incorrectly skip registration after a target 
metaclass is removed and recreated.
   
   Mitigation: the registration check also verifies that the target metaclass 
currently has the method. A dedicated test removes the target metaclass and 
confirms the same cached factory re-adds the method.
   
   #### Parallel registration
   
   Risk: concurrent cached registration could race and both threads could write 
the same method.
   
   Mitigation: duplicate checks and writes are synchronized per target 
`ExpandoMetaClass`.
   
   #### Dynamic factory behavior
   
   Risk: two different factories with the same codec name could intentionally 
behave differently.
   
   Mitigation: the registration key includes weak `CodecFactory` identity, so 
distinct live factories are still registered separately without retaining 
factory instances solely for bookkeeping.
   
   #### Static registry lifecycle
   
   Risk: a JVM-wide registration registry can outlive individual tests or 
application contexts.
   
   Mitigation: tests clear the internal registration state and remove touched 
metaclasses. The registry uses weak factory references so bookkeeping does not 
keep codec factories alive. Stale key objects can remain until the internal 
state is cleared, but they do not retain the factory and do not collapse a 
later distinct live factory.
   
   #### Development reload behavior
   
   Risk: deduplication could prevent changed codecs from being reattached in 
development-style non-cached lookup.
   
   Mitigation: deduplication is not applied when `cacheLookup` is false. The 
new tests verify re-registration in non-cached mode.
   
   ## Reviewer notes
   
   - The benchmark counter is intentionally unavailable on baseline because the 
counter is part of this PR. Baseline timing is still comparable because the 
same benchmark harness was copied into a baseline worktree without the 
production optimization.
   - `metaClassChangeEvents` remained zero in the benchmark because Groovy's 
registry listener did not observe these ExpandoMetaClass method additions in 
the local harness. The PR therefore uses an internal registration counter for 
branch-side write-count evidence.
   - The optimization targets repeated registration overhead. It does not 
attempt to redesign Grails codec dispatch, remove dynamic codec methods, or 
change how codecs are discovered.
   


-- 
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]

Reply via email to