Skip to content

Resolve URL mapping names from the match rather than the request - #16150

Open
codeconsole wants to merge 5 commits into
apache:8.0.xfrom
codeconsole:perf/urlmapping-name-resolution-8.0.x
Open

Resolve URL mapping names from the match rather than the request#16150
codeconsole wants to merge 5 commits into
apache:8.0.xfrom
codeconsole:perf/urlmapping-name-resolution-8.0.x

Conversation

@codeconsole

Copy link
Copy Markdown
Contributor

Resolves captured URL mapping names from the match itself rather than from the dispatching
request, which lets collectControllerMappings stop rebuilding the parameter map for every
candidate mapping on every request.

Measured

Benchmark before ns/op after ns/op
ControllerMappingCollectionBenchmark.oneCandidate 385.9 219.9 −43%
ControllerMappingCollectionBenchmark.twoCandidates 599.8 282.9 −53%
ControllerMappingCollectionBenchmark.fourCandidates 1241.6 777.4 −37%
UrlMappingBenchmark.matchCachedHit 2.49 2.56 unchanged

collectControllerMappings runs in full on every request even when the URL mapping cache hits,
so this is on the always-on path. It still costs ~88x the cached match it wraps; what remains is
ControllerKey allocation, the controller map lookup and the sort.

Why it was slow

A mapping's controllerName / actionName / namespace could be a Closure that read
RequestContextHolder.currentRequestAttributes().getParams() — reaching for thread-local state to
read a value the mapping already held in its own params. To ask a candidate "which controller are
you?", the framework had to call webRequest.resetParams() and info.configure(webRequest) first,
per candidate, cloning the parameter map each time.

Those names now resolve from the match. configure() is still called for mappings whose names are
genuinely request-dependent.

Deliberately unchanged

action = { params.goHere } is a documented feature — those closures are meant to read the request,
and they still do, taking the old path.

Behaviour change

A request parameter no longer stands in for a token the URI does not capture. Under
"/$controller/$action?", /article?action=gallery now routes to the default action rather than
gallery; a query string could previously steer which action ran. params.action is still bound
either way. Documented as section 47 of the 8.0 upgrade guide and in the embedded-variables guide.

This required changing one existing assertion, in UrlMappingParameterTests.testNotEqual, which is
worth a reviewer's attention. That test originally asserted the mapping did not match
(b84ef59, GRAILS-2297); when it moved to UrlMappingsUnitTest the fall-through to the default
mapping made info non-null and a controllerName == 'foo' assertion was substituted — 'foo' being
a value seeded into the request params by the test itself, i.e. an artifact of the thread-local
resolution rather than the notEqual constraint the test is named for. It now asserts the value the
URI actually captured.

Notes

  • Adds grails-web-benchmarks (opt-in, not part of build/check). The same module is added by
    Reduce per-request work across the request path #16149; if that lands first this rebases and drops that commit.
  • UrlMappingInfo gains isNameResolutionRequestDependent() as a default method returning true,
    so third-party implementations keep current behaviour.

Adds a `grails-web-benchmarks` module containing JMH benchmarks for the
HTTP request-processing hot path, ported from the sibling performance
branch. The module lives in its own `jmh` source set, is not published,
and is not wired into `build`/`check` - the only way to run it is the
explicit task:

    ./gradlew :grails-web-benchmarks:jmh
    ./gradlew :grails-web-benchmarks:jmh -PjmhArgs="-wi 1 -i 1 -f 1 UrlMapping"

`ControllerMappingCollectionBenchmark` measures
`GrailsControllerUrlMappings.matchAll`, which is what
`UrlMappingsHandlerMapping.getHandlerInternal` calls on every request,
and `UrlMappingBenchmark` measures the cached matcher underneath it, so
the cost of the uncached wrapper can be read against the work it wraps.
A mapping such as "/$controller/$action?/$id?" holds a closure for each
name it captures, and until now that closure answered by reaching for the
parameters bound to the current thread:

    GrailsWebRequest webRequest = (GrailsWebRequest) RequestContextHolder.currentRequestAttributes();
    return webRequest.getParams().get(name);

The value it was reaching for is one the match already holds. Because the
mapping is built once and shared by every request it matches, the closure
could not read it directly, so `collectControllerMappings` had to call
`webRequest.resetParams()` and `info.configure(webRequest)` for *every*
candidate just to be able to read `info.controllerName` and look the
candidate up - rebuilding the parameter map once per candidate, and then
once more in `UrlMappingsHandlerMapping` for the winner, because after the
loop the parameters described the last candidate rather than the winner.

The evaluator now carries only the name of the token it resolves;
`AbstractUrlMappingInfo`, which is created per match and does hold the
captured values, resolves it from its own parameters. Candidates are
identified without touching the request, so the parameter map is built
once per request, for the winner.

Mappings that compute a name with a closure of their own - the documented

    "/$controller" { action = { params.goHere } }

- still read request state, and still have the request configured before
their names are read. `UrlMappingInfo.isNameResolutionRequestDependent()`
is what tells the two apart; it defaults to true, so an implementation
outside the framework keeps the behaviour it has today.

Behaviour change: a request parameter no longer stands in for a token the
URI did not capture. Under "/$controller/$action?", a request for
`/article?action=gallery` now routes to the controller's default action
instead of to `gallery`.
Adds an upgrade note for the routing change - a request parameter no
longer stands in for a token the URI did not capture - with the before
and after for `/article?action=gallery`, and states the rule where the
guide introduces dynamic controller and action names.
…anch

The request-path performance branch adds sections 45 and 46, so this becomes 47 and
the two can merge in either order without a docs conflict.
@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.22222% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.6442%. Comparing base (a83f874) to head (be305f9).
⚠️ Report is 58 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
.../org/grails/web/mapping/DefaultUrlMappingInfo.java 33.3333% 0 Missing and 2 partials ⚠️
...grails/web/mapping/RuntimeConstraintEvaluator.java 66.6667% 2 Missing ⚠️
...main/groovy/grails/web/mapping/UrlMappingInfo.java 0.0000% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16150        +/-   ##
==================================================
+ Coverage     52.6311%   52.6442%   +0.0131%     
- Complexity      18436      18450        +14     
==================================================
  Files            2037       2038         +1     
  Lines           96500      96514        +14     
  Branches        16860      16864         +4     
==================================================
+ Hits            50789      50809        +20     
+ Misses          38284      38274        -10     
- Partials         7427       7431         +4     
Files with missing lines Coverage Δ
...org/grails/web/mapping/AbstractUrlMappingInfo.java 80.8219% <100.0000%> (+1.7174%) ⬆️
...groovy/org/grails/web/mapping/RegexUrlMapping.java 66.4729% <100.0000%> (-0.1295%) ⬇️
...ing/mvc/AbstractGrailsControllerUrlMappings.groovy 60.1942% <100.0000%> (ø)
...main/groovy/grails/web/mapping/UrlMappingInfo.java 0.0000% <0.0000%> (ø)
.../org/grails/web/mapping/DefaultUrlMappingInfo.java 37.9032% <33.3333%> (-0.1133%) ⬇️
...grails/web/mapping/RuntimeConstraintEvaluator.java 66.6667% <66.6667%> (ø)

... and 3 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.

@testlens-app

testlens-app Bot commented Aug 15, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: be305f9
▶️ Tests: 59510 executed
⚪️ Checks: 79/79 completed


Learn more about TestLens at testlens.app.

@codeconsole
codeconsole requested review from jdaugherty, matrei and sbglasius and removed request for jdaugherty and matrei August 15, 2026 19:09
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.

1 participant