Skip to content

Improve hot path virtual-thread and codec performance#15828

Open
jamesfredley wants to merge 7 commits into
8.0.xfrom
perf/vthread-static-compile-8.0.x
Open

Improve hot path virtual-thread and codec performance#15828
jamesfredley wants to merge 7 commits into
8.0.xfrom
perf/vthread-static-compile-8.0.x

Conversation

@jamesfredley

@jamesfredley jamesfredley commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR makes two Grails 8 hot paths friendlier to the JDK 21 runtime without changing the public API surface:

  • Replaces GrailsHttpSession wrapper monitor synchronization with a private ReentrantLock.
  • Static-compiles grails-codecs-core codec extension helpers and removes dynamic self-dispatch inside the codec implementations.
  • Keeps existing Groovy extension method binary descriptors intact by preserving Object return types and the existing mutable HEXDIGITS property shape.

Motivation

Virtual-thread friendliness

GrailsHttpSession previously serialized wrapper access with synchronized (this). That object monitor can pin a virtual-thread carrier if the servlet container or request/session access blocks while the monitor is held. A private ReentrantLock preserves the wrapper-level serialization semantics while avoiding monitor pinning, and it also makes lazy session creation occur under the same lock as subsequent session access.

Codec hot-path static compilation

The codec extension methods are small, frequently used helpers. Static compilation removes runtime Groovy dispatch inside MD5, SHA-1, SHA-256, Base64, Hex, and shared digest conversion paths. The implementation still keeps the extension methods compatible for existing compiled callers by preserving the original public method descriptors.

Compatibility notes

  • decodeHex keeps the previous Groovy truth behavior for falsy targets by using Groovy's runtime truth conversion helper under @CompileStatic.
  • String, primitive byte arrays, wrapper byte arrays, number lists, and null/NullObject cases remain covered.
  • No new dependencies or user-facing configuration changes are introduced.

Review feedback addressed

  • Restored the old decodeHex Groovy truth guard behavior for falsy inputs after Copilot review feedback.
  • Added regression coverage for falsy values against the static extension implementation.

Verification

  • ./gradlew :grails-codecs-core:test --tests "org.grails.web.codecs.HexCodecTests" :grails-codecs-core:codeStyle --rerun-tasks
  • ./gradlew :grails-codecs-core:test :grails-codecs-core:codeStyle --rerun-tasks
  • ./gradlew :grails-codecs-core:test :grails-web-common:test --tests "grails.web.servlet.mvc.GrailsHttpSessionSpec" :grails-codecs-core:codeStyle :grails-web-common:codeStyle --rerun-tasks
  • ./gradlew clean aggregateViolations :grails-test-report:check --continue
    • Violation reports were clean: Checkstyle, CodeNarc, PMD, SpotBugs.
    • The full gate still hits pre-existing unrelated failures on clean origin/8.0.x: grails-validation:compileGroovy, grails-data-graphql-core:compileGroovy, and MongoDB/Testcontainers environment failures. The two compile failures reproduce in a separate clean origin/8.0.x worktree.

Review gate

  • Current full branch review gate passed for diff hash 8102545e609fd576dfc616e73c59e4c7da847dbf with both Oracle and Codex reviewers green.
  • Copilot follow-up commit review gate passed for staged diff hash fcb2c9b25edffd17c4910e79aa23047e3169e608 with both Oracle and Codex reviewers green.

Use a ReentrantLock inside GrailsHttpSession so wrapper-level session access no longer pins virtual-thread carrier threads on the object monitor.

Static-compile the codecs-core extension helpers while preserving existing Groovy extension method descriptors.

Assisted-by: Hephaestus:openai/gpt-5.5 codex-review
Copilot AI review requested due to automatic review settings July 5, 2026 02:28

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 optimizes two hot paths in Grails 8: (1) GrailsHttpSession synchronization to be more virtual-thread friendly, and (2) common codec extension methods to reduce Groovy dynamic-dispatch overhead while keeping the public extension surface stable.

Changes:

  • Replace synchronized (this) session wrapper serialization with a private ReentrantLock and lock-protected lazy session creation.
  • Add a new spec asserting lazy servlet-session creation and correct delegation for attribute operations.
  • Static-compile and refactor codecs (Hex, Base64, MD5, SHA-1, SHA-256) to use direct helper calls and shared byte-conversion logic.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
grails-web-common/src/main/groovy/grails/web/servlet/mvc/GrailsHttpSession.java Switch wrapper synchronization to ReentrantLock and lock-protect lazy session creation/access.
grails-web-common/src/test/groovy/grails/web/servlet/mvc/GrailsHttpSessionSpec.groovy Add coverage for lazy session creation + delegation for attribute operations.
grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/DigestUtils.groovy Static-compile and centralize Objectbyte[] conversion to support codec hot paths.
grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy Static-compile + rewrite encode/decode loops for performance; preserve HEXDIGITS shape.
grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/Base64CodecExtensionMethods.groovy Static-compile and route Byte[]/byte[] paths through shared byte conversion.
grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/MD5CodecExtensionMethods.groovy Static-compile and replace dynamic chaining with direct helper calls.
grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/MD5BytesCodecExtensionMethods.groovy Static-compile and keep digest behavior via DigestUtils.
grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA1CodecExtensionMethods.groovy Static-compile and replace dynamic chaining with direct helper calls.
grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA1BytesCodecExtensionMethods.groovy Static-compile and keep digest behavior via DigestUtils.
grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA256CodecExtensionMethods.groovy Static-compile and replace dynamic chaining with direct helper calls.
grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/SHA256BytesCodecExtensionMethods.groovy Static-compile and keep digest behavior via DigestUtils.

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

@bito-code-review

Copy link
Copy Markdown

The current implementation of decodeHex uses a restrictive guard (theTarget == null || theTarget instanceof NullObject || theTarget.toString().length() == 0) which deviates from the previous Groovy-truthiness check (if (!theTarget) return null). This change can cause exceptions or unexpected behavior for inputs that were previously considered falsy in Groovy, such as 0 or empty collections. To restore the original contract and ensure compatibility with Groovy-truthiness, the guard should be updated to use the !theTarget check.

grails-codecs-core/src/main/groovy/org/grails/plugins/codecs/HexCodecExtensionMethods.groovy

static Object decodeHex(Object theTarget) {
        if (!theTarget) return null

jamesfredley and others added 2 commits July 5, 2026 08:57
Preserve the pre-existing Groovy truth guard under static compilation by using Groovy's runtime truth conversion helper.

Add Hex codec regression coverage for falsy inputs.

Assisted-by: Hephaestus:openai/gpt-5.5 codex-review

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

I don't see anything major with this at first glance. I asked Fabled to take a look and the results are attached to the review.

The GrailsHttpSession lock conversion looks correct — the remaining synchronized (this) blocks are all in commented-out dead code, lazy session creation now happens under the same lock as access (an improvement over the old racy pattern), and the new spec covers the lazy-create/delegate path. The codec static compilation is a nice win too; my comments are mostly about a few places where the rewrite narrows the accepted input types compared to the old dynamic coercion, plus some test-convention and hot-loop nits.

}

private static byte toByte(Object value) {
return ((Number) value).byteValue()

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 narrows the accepted element types compared to the old dynamic code. Previously src[i] = v went through Groovy's DefaultTypeTransformation coercion, which also accepted Character elements (and char[] arrays via the array path); now any non-Number element throws ClassCastException. If preserving the old tolerance matters, DefaultTypeTransformation.castToNumber(value).byteValue() keeps the coercion while staying statically compiled. If the narrowing is intentional, it's probably worth a line in the PR compatibility notes.

return md.digest()
}

protected static byte[] toByteArray(Object data) {

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.

Nit: protected reads as "for subclasses", but the actual consumers (Base64CodecExtensionMethods, HexCodecExtensionMethods) rely on protected's package-access side effect. @PackageScope (or a brief comment) would make the intent clearer.

theTarget.each() {
result << HexCodecExtensionMethods.HEXDIGITS[(it & 0xF0) >> 4]
result << HexCodecExtensionMethods.HEXDIGITS[it & 0x0F]
byte[] bytes = theTarget instanceof String ? ((String) theTarget).getBytes(StandardCharsets.UTF_8) : DigestUtils.toByteArray(theTarget)

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.

Behavior change for iterable targets that aren't Lists or arrays (e.g. a Set<Integer> or an Iterator): the old code element-wise hex-encoded anything via theTarget.each { ... }, but DigestUtils.toByteArray only special-cases byte[]/Byte[]/List/arrays and otherwise falls through to toString().getBytes(UTF_8) — so a Set of numbers now silently encodes its string representation instead of its elements. Consider handling Collection/Iterable in toByteArray (or at least calling this out in the compatibility notes, since the failure mode is silent wrong output rather than an exception).

for (int i = 0; i < str.size(); i += 2) {
int high = hexDigits.indexOf((int) str.charAt(i))
int low = hexDigits.indexOf((int) str.charAt(i + 1))
result[i.intdiv(2)] = (byte) ((high << 4) | low)

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.

Nit for a perf-focused PR: i.intdiv(2) boxes on every iteration; i >> 1 (and str.length() >> 1 on line 58) avoids it. Same for str.size() vs plain str.length(). Also, on line 51 the explicit null/NullObject checks are redundant — DefaultTypeTransformation.castToBoolean already returns false for both.

static encodeAsMD5(theTarget) {
theTarget.encodeAsMD5Bytes()?.encodeAsHex()
static Object encodeAsMD5(Object theTarget) {
byte[] digest = (byte[]) MD5BytesCodecExtensionMethods.encodeAsMD5Bytes(theTarget)

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.

Just confirming this is intended: the old theTarget.encodeAsMD5Bytes()?.encodeAsHex() dispatched dynamically, so a runtime metaclass override of encodeAsMD5Bytes (e.g. an application-supplied codec replacing the default) would also change what encodeAsMD5 produced. The direct static call hardwires the default implementation. Same applies to the SHA-1/SHA-256 variants. Probably fine — it's the whole point of removing dynamic dispatch — but worth a note in the compatibility section.

Comment on lines +54 to +58
assertEquals(null, HexCodecExtensionMethods.decodeHex(''))
assertEquals(null, HexCodecExtensionMethods.decodeHex(0))
assertEquals(null, HexCodecExtensionMethods.decodeHex(false))
assertEquals(null, HexCodecExtensionMethods.decodeHex([]))
assertEquals(null, HexCodecExtensionMethods.decodeHex(new byte[0]))

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.

Per the repo rule to test via public APIs, these could use the extension-method form users actually call — ''.decodeHex(), 0.decodeHex(), false.decodeHex(), [].decodeHex(), new byte[0].decodeHex() — which also exercises the extension-module registration path rather than the static class directly (matching the existing null.decodeHex() assertion above).

assertEquals(null, HexCodecExtensionMethods.decodeHex(new byte[0]))
}

void testRoundtrip() {

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.

Pre-existing, but since this file is being touched: testRoundtrip has no @Test annotation, so it never runs — and it's exactly the round-trip coverage the new static implementations want. Note that enabling it as-is will likely fail on the first assertion: assertIterableEquals compares Integer expected values against the Byte elements from decodeHex(...).toList(), and Integer(65).equals(Byte(65)) is false — the expectations need a byte-typed list.

return Base64.decodeBase64(DigestUtils.toByteArray(theTarget))
}

return Base64.decodeBase64(theTarget.toString().getBytes(StandardCharsets.UTF_8))

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.

I think you should take the opportunity to make UTF_8 an added method argument that's defaulted

@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.63855% with 126 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.4312%. Comparing base (2650b8c) to head (6cfb084).
⚠️ Report is 164 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...lugins/testing/GrailsMockHttpServletRequest.groovy 15.3846% 20 Missing and 2 partials ⚠️
...oovy/grails/web/servlet/mvc/GrailsHttpSession.java 58.8235% 21 Missing ⚠️
...rg/grails/cli/boot/GrailsDependencyVersions.groovy 76.6234% 8 Missing and 10 partials ⚠️
...roovy/org/grails/plugins/codecs/DigestUtils.groovy 63.8889% 10 Missing and 3 partials ⚠️
...ils/plugins/codecs/HexCodecExtensionMethods.groovy 35.2941% 8 Missing and 3 partials ⚠️
...org/grails/web/mapping/DefaultLinkGenerator.groovy 81.6326% 2 Missing and 7 partials ⚠️
...ns/i18n/GrailsLocaleResolverAutoConfiguration.java 66.6667% 4 Missing and 3 partials ⚠️
.../grails/plugins/web/taglib/UrlMappingTagLib.groovy 42.8571% 4 Missing ⚠️
...org/grails/plugins/i18n/I18nAutoConfiguration.java 76.4706% 1 Missing and 3 partials ⚠️
...y/org/grails/web/mapping/CachingLinkGenerator.java 78.9474% 1 Missing and 3 partials ⚠️
... and 7 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #15828        +/-   ##
==================================================
+ Coverage     49.2998%   49.4312%   +0.1314%     
- Complexity      16727      16836       +109     
==================================================
  Files            1985       1993         +8     
  Lines           93252      93530       +278     
  Branches        16316      16371        +55     
==================================================
+ Hits            45973      46233       +260     
+ Misses          40166      40160         -6     
- Partials         7113       7137        +24     
Files with missing lines Coverage Δ
...lugins/codecs/MD5BytesCodecExtensionMethods.groovy 75.0000% <ø> (ø)
...ils/plugins/codecs/MD5CodecExtensionMethods.groovy 100.0000% <100.0000%> (ø)
...ugins/codecs/SHA1BytesCodecExtensionMethods.groovy 75.0000% <ø> (ø)
...ls/plugins/codecs/SHA1CodecExtensionMethods.groovy 75.0000% <100.0000%> (ø)
...ins/codecs/SHA256BytesCodecExtensionMethods.groovy 75.0000% <ø> (ø)
.../plugins/codecs/SHA256CodecExtensionMethods.groovy 75.0000% <100.0000%> (+25.0000%) ⬆️
...efact/controller/support/ResponseRedirector.groovy 0.0000% <ø> (ø)
.../web/controllers/ControllersAutoConfiguration.java 94.6429% <100.0000%> (+11.0065%) ⬆️
...lers/GrailsFormContentFilterAutoConfiguration.java 100.0000% <100.0000%> (ø)
...ntrollers/GrailsViewResolverAutoConfiguration.java 100.0000% <100.0000%> (ø)
... and 27 more

... and 18 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Fix Base64 charset handling, empty hex decoding, preserve DigestUtils
visibility, and document codec notes.

Assisted-by: Sisyphus:xai/grok-4.5 [gpt-coding]
@testlens-app

testlens-app Bot commented Jul 10, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 6cfb084
▶️ Tests: 9388 executed
⚪️ Checks: 59/59 completed


Learn more about TestLens at testlens.app.

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

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants