Skip to content

Memoize system environment resolution in NavigableMapConfig - #16130

Draft
codeconsole wants to merge 1 commit into
apache:8.0.xfrom
codeconsole:perf/config-property-lookup-8.0.x
Draft

Memoize system environment resolution in NavigableMapConfig#16130
codeconsole wants to merge 1 commit into
apache:8.0.xfrom
codeconsole:perf/config-property-lookup-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

Config.getProperty resolves every key against the process environment from scratch on each call.

findInSystemEnvironment asks resolvePropertyName for the environment spelling of the key, and checkPropertyName probes System.getenv up to four times per casing — as-is, dots replaced with underscores, hyphens replaced with underscores, both replaced — then repeats the whole sequence against the uppercased key:

private String checkPropertyName(String name) {
    if (containsKey(name)) return name;                       // containsKey == System.getenv(name) != null
    String noDotName = name.replace('.', '_');
    if (!name.equals(noDotName) && containsKey(noDotName)) return noDotName;
    String noHyphenName = name.replace('-', '_');
    ...
}

That is up to eight getenv probes and six intermediate strings per lookup, and the answer cannot change — the process environment is fixed for the lifetime of a config.

This memoizes the resolution per config instance, along with the token list a dotted key splits into:

config.getProperty('grails.views.gsp.encoding', String)   // resolves the environment once
config.getProperty('grails.views.gsp.encoding', String)   // subsequent calls reuse it

Property values are not cached, so configuration mutated at runtime is still observed:

config.getProperty('some.nested.value')          // 'original'
config.merge(['some.nested.value': 'updated'])
config.getProperty('some.nested.value')          // 'updated'

The cache is per-instance rather than static on purpose. SystemEnvironmentConfigSpec installs environment variables reflectively and then builds a fresh config, which must observe the environment as it stands at that point; a static cache would leak a stale answer across those specs.

Why it matters

Callers resolve configuration inside render loops. On a scaffolded page rendering 100 rows, FormFieldsTemplateService (getShouldCache, findTemplate, getTemplateFor) and asset-pipeline resolve configuration per rendered property, so this sits on a hot path that scales with rows × properties.

Measurements

before after
getProperty, 2M lookups over a 4-key set 355.3 ns/op 91.6 ns/op
NavigableMapConfig share of JFR execution samples, 100-row scaffolded page under load 7.01% 1.16%

Limitations

  • End-to-end request latency improvement was not demonstrated. The CPU work is measurably removed (both figures above), but on the machine used for this the run-to-run variance across JVM restarts was larger than the effect, so wall-clock A/B could not resolve it. The dominant remaining costs on that page are ExpandoMetaClass read-lock contention (~9–10% of samples) and reflective tag dispatch, neither of which this touches.
  • The caches are bounded at 2048 entries each. Configuration keys come from a small fixed set of source literals; the bound only guards against a caller synthesising unbounded key strings at runtime.

On the @Deprecated marker

NavigableMapConfig carries a class-level @Deprecated since #11554 (May 2020), pointing at grails.config.Config. That target is the interface this class implements, and no alternative implementation exists in the tree, so the note reads as "code against the interface" rather than "a replacement is available".

The class is on the live path today:

  • PropertySourcesConfig extends NavigableMapConfig and is not itself deprecated.
  • AbstractGrailsApplication builds a PropertySourcesConfig, so this is grailsApplication.config for every running application.
  • Profiling a running 8.0.0-M5 application put NavigableMapConfig at 7.01% of execution samples, entered through getProperty.

If the NavigableMap-backed implementation is replaced, this change goes with it. It is offered as a cost reduction on the path applications actually execute for the six years the marker has been in place, and is easy to drop if a replacement is in flight.

Every Config.getProperty call resolved the key against the process
environment from scratch. findInSystemEnvironment asked
resolvePropertyName for the environment spelling of the key, and
checkPropertyName probed System.getenv up to four times per casing -
as-is, dots replaced, hyphens replaced, both replaced - then repeated
the whole sequence against the uppercased key. That is up to eight
getenv probes and six intermediate strings per lookup, and the answer
never changes: the process environment is fixed for the lifetime of a
config.

Resolution is now memoized per config instance, as is the token list a
dotted key splits into. Property values themselves are not cached, so
configuration that changes at runtime is still observed.

The cache is deliberately per-instance rather than static. Tests
install environment variables reflectively and then build a fresh
config, which must see the environment as it stands at that point.

Measured on the grails-core config specs: getProperty drops from 355.3
to 91.6 ns/op over two million lookups. On a scaffolded page rendering
100 rows, NavigableMapConfig falls from 7.01% to 1.16% of JFR execution
samples, the fields plugin being the caller that resolves configuration
per rendered property.
@codeconsole
codeconsole marked this pull request as draft August 11, 2026 00:12
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.42105% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.4622%. Comparing base (b9f5c1f) to head (5ed8094).
⚠️ Report is 45 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...n/groovy/org/grails/config/NavigableMapConfig.java 68.4210% 1 Missing and 5 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16130        +/-   ##
==================================================
+ Coverage     52.3676%   52.4622%   +0.0947%     
- Complexity      18313      18322         +9     
==================================================
  Files            2036       2032         -4     
  Lines           96365      96315        -50     
  Branches        16832      16840         +8     
==================================================
+ Hits            50464      50529        +65     
+ Misses          38480      38354       -126     
- Partials         7421       7432        +11     
Files with missing lines Coverage Δ
...n/groovy/org/grails/config/NavigableMapConfig.java 68.0233% <68.4210%> (+0.3017%) ⬆️

... and 12 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 11, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 5ed8094
▶️ Tests: 59170 executed
⚪️ Checks: 75/75 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

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant