Skip to content

Read the flat key first and treat a key past a value that is not a map as absent - #16732

Open
codeconsole wants to merge 52 commits into
apache:9.0.xfrom
codeconsole:perf/config-lookup-9.0.x
Open

codeconsole wants to merge 52 commits into
apache:9.0.xfrom
codeconsole:perf/config-lookup-9.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What

The two configuration lookup changes that #16130 left out of 8.1.x because they change the answer for existing keys, as asked for in its review, plus the paths that have to agree with them. Builds on #16130: this branch merges it, so until #16130 is merged and 8.1.x is merged up the diff shows its commits too; the commits of this PR start at the cherry-pick.

A dotted key that runs past a value that is not a map is absent. In Grails 8, getProperty('server.url.port') with server.url: http://localhost returned the URL, getProperty('grails.gorm.default.constraints.nullable') with a closure at grails.gorm.default.constraints returned the closure, and getProperty('a.b.0') with a list at a.b returned the list; containsProperty of each was true. Now each is null, the default is returned when one is given, and containsProperty is false. navigate() on a config and CodeGenConfig.navigate answer the same way, so the lookup paths no longer disagree.

// server.url: http://localhost
config.getProperty('server.url.port')                  // was 'http://localhost', now null
config.getProperty('server.url.port', String, 'none')  // 'none'
config.containsProperty('server.url.port')             // false
config.navigate('server', 'url', 'port')               // null

The flat key is read before the nested maps are walked. merge and dot-notation assignment write every nested value twice, as a flat dotted key on the root and as an entry of the nested map, so the flat key answers nearly every lookup with one map read and the walk only runs for a key that has no flat form. Where the two representations have been made to disagree, the flat key wins:

config.merge([a: [b: 'merged']])
config.put('a.b', 'flat')          // a raw root put: now read by getProperty, as it always was by config['a.b']
config.getProperty('a.b')          // 'flat' (was 'merged')

config.a.put('b', 'nested')        // a raw sub-map put: no longer read by getProperty
config.getProperty('a.b')          // 'flat' (was 'nested'); config.a.b is 'nested'

config.a.b = 'assigned'            // dot-notation assignment writes both, so every read agrees
config.getProperty('a.b')          // 'assigned'

Reading the flat key first also exposed that the flat key never went through the placeholder hook: a YAML value evaluatedName: ${name} was resolved in the nested entry but kept the raw ${name} in the flat key, which is what config['hello.evaluatedName'] returned on every released version. The value is now resolved once, through a mergedValue hook that NavigableMap asks for the value it stores, and both representations hold that one result, so a value such as ${random.uuid} is the same through every read.

A second pre-existing merge bug surfaced the same way (found by @jamesfredley in review): a dotted key inside a nested map, x: ['a.b': 'nested'], was flattened a second time with only its own path, so it wrote the root key a.b and overwrote the real property. Every nested dotted key passed to merge did this, for example config.merge([logging: [level: ['org.springframework': 'DEBUG']]]) also wrote a root org.springframework; the configuration files are flattened before they are merged, so they never took that path. The second pass now carries the parent path, so the key is flattened as x.a.b only, and getProperty('a.b') stays root.

A third (found by @jamesfredley in review): an indexed key with a property after the index, a.rows[0].name, built the list under its flat key and a rows[0] block in the nested map, so navigate('a', 'rows') and config.a.rows did not see the list that getProperty('a.rows', List) returned. A loaded application.yml read correctly only because its property source is merged into the config a second time, which turned the block into a list element; a direct merge did not. An indexed key is now merged whole: the list is one object in both representations, and a.rows[0] and a.rows[0].name are read by their flat keys. A list merged as a value, config.merge([a: [rows: [[name: 'row']]]]) or config.a.rows = [...], writes those element keys too and drops the ones of the list it replaces, so getProperty('a.rows[0].name') answers from the current list. The element keys follow what they describe: an element replaced whole, config.merge(['a.rows[0]': [name: 'new']]), and a value written over the list drop the keys of what they replace, so getProperty('a.rows[0].name') answers from the current element or is absent. A list application.groovy supplies as immutable, ['one'].asImmutable() or List.of, is read and replayed without a write into it, and an indexed key written over one replaces it with a copy; the replay of the new element keys had thrown and the application did not start (found by @jamesfredley in review).

The stale flat key that merge leaves when a higher-precedence scalar replaces a lower-precedence block, which is what property-source layering produces at startup, is read too: merge(['server.url.port': 'y', 'server.url': 'x']) answers server.url.port with y (was x), and put('a', 'scalar'); put('a.b', 1) answers a.b with 1 (was scalar). The absent-past-non-map change alone already gives both of those the flat answer, so flat-first adds only which raw single-representation write wins.

Measurements

The harness from #16130 (two million getProperty(key, String) calls over four keys, three configured and one absent, best of five, 60 environment variables), run on this branch before and after the flat-first commit:

version ns per call
this branch with #16130 and the absent-past-non-map walk 129
plus reading the flat key first 77

Behaviour changes

Both are described in section 20 of the 9.0 upgrade guide, with the required actions: read the parent instead of a child of a scalar, list or closure, and assign through dot notation instead of writing directly into a nested map when the value is read back through getProperty. The what's new entry for the faster lookups gained a sentence pointing there.

Commits

  • The cherry-pick of the absent-past-scalar commit that Index the process environment once per config instead of probing it per lookup #16130 reverted, extended to List and closure values and containsProperty.
  • navigate stops at a value that is not a map (NavigableMap.navigateMap, which CodeGenConfig.navigate uses).
  • The flat key read before the walk, with the four disagreement cases pinned.
  • Docs.
  • Placeholders resolved in the flat key as well, which the full grails-core suite asked for through NavigableMapSpringProfilesSpec.
  • A dotted key inside a nested map flattened under its parent path, not at the root, with a sentence in the upgrade guide.
  • The flat key replaced when a block is merged over a scalar, so getProperty('server.url', Map) and navigate agree after merge(['server.url': 'x']) then merge(['server.url.port': 'y']).
  • putAt on NavigableMap, so config.a['b'] = value writes both representations like config.a.b = value.
  • A placeholder resolved once for both representations, and the flat alias of an indexed list kept on the nested list (both found by @jamesfredley in review).
  • The flat alias of a block that a nested dotted merge creates written at the root under its full path, so merge(['x.a': 'old']) then merge([x: ['a.b': 'new']]) reads x.a as the block through every lookup (found by @jamesfredley in review).
  • The flat alias replaced whenever a dotted merge creates a block, so a block recreated after a scalar destroyed it, merge(['a.b.c': 1]), merge([a: 'scalar']), merge(['a.b.d': 2]), is read as [d: 2] through every lookup (found by @jamesfredley in review).
  • NavigableMap.clone() copies the nested maps and makes the copy of a root the root of its own configuration, so a merge or a dot-notation write through the copy never reaches the original (found by @jamesfredley in review).
  • Three more, found by probing every sequence of up to three writes over ten write paths for a disagreement between the representations: setting a property to null removes the flat keys of that property only, where it used to remove every flat key under the parent, or none at all at the root; the clone copies the lists of indexed keys too; and a dotted key inside a nested map no longer leaves a copy of itself in the sub-map that later writes left stale.
  • An indexed key with a property after the index merged whole, so the list is held by the nested map and under its flat key as one object, with a flat key for each element and for the value and no rows[0] block beside it; NavigableMapPropertySource presents such a list through its elements only (found by @jamesfredley in review, in the nested form).
  • A list merged as a value writes the flat keys of its elements and drops those of the list it replaces, found by probing every single write and every pair of writes over seventeen indexed and list write paths.
  • An immutable list or map left as it is when its element keys are replayed, and replaced by a copy when an indexed key writes into it, with a GrailsApp started from an application.groovy that holds one (found by @jamesfredley in review).
  • The element keys of what a write replaces removed: an element replaced whole, a value written over a list, a container made anew as the other kind and a block created where a list was (the first found by @jamesfredley in review, the rest by the probe).
  • Docs for these in section 20 and the upgrade skill.

codeconsole and others added 25 commits August 10, 2026 18:59
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.
…er lookup

Config.getProperty resolved every key against the process environment first,
on every call, by probing System.getenv with up to eight spellings of the key
and allocating six intermediate strings along the way. The answer cannot
change: the environment of a running JVM is fixed, and nearly every key is not
an environment variable at all, so most of the cost went into proving a miss.

Each config now snapshots the environment when it is created and indexes the
variable names by their canonical form (upper-cased, with dots and hyphens
folded to underscores). Every spelling the lookup probes shares that canonical
form, so a key whose canonical form belongs to no variable is rejected with one
hash lookup. A key whose canonical form is present runs the same
spelling-by-spelling resolution as before against the snapshot, so which
variable wins when several match is unchanged. This is how Spring Boot's
binder treats the system environment: as an immutable source copied once.

The memory this takes is proportional to the number of environment variables,
not to the size of the configuration or to the number of distinct keys looked
up, and there is nothing to evict. The per-key memoization added earlier on
this branch is removed, as is the token-list cache: the dotted-key walk now
splits the key with indexOf instead of a StringTokenizer and a list.

Upper-casing uses Locale.ROOT, as Spring's SystemEnvironmentPropertySource
does, so the resolution no longer depends on the default locale.

The environment spec installs each variable before building the config it
reads it through, since a config observes the environment it was created
with; a running JVM cannot gain variables, only the reflective test helper can.
getProperty('server.url.port') returned the value of server.url when that
value was a string: the dotted walk stopped descending at the first value
that is not a map but kept whatever it had reached, so a key naming a child
of a scalar resolved to the scalar itself. Dot navigation fails on such a
key and Spring's Environment resolves it to nothing, so the walk now reports
it as absent and the default applies.
get carried @CompileDynamic since the 2018 subscript-access change, when its
body matched the key against a regex and indexed the matcher dynamically.
That logic moved out the same month, leaving a one-line delegateMap.get(key)
under dynamic dispatch. Every segment of a dotted getProperty walk goes
through this method, so the dispatch cost was paid several times per lookup.
System.getenv(String) answers from a case-insensitive copy of the environment
on Windows, where the operating system treats variable names that way, while
the System.getenv() map the index snapshots keeps the names as given. The
snapshot now goes into a case-insensitive map on Windows, so a variable
spelled Grails_Foo still satisfies grails.foo there, as it did when every
lookup asked System.getenv directly.

The environment spec's Windows helper installed a variable only into the
case-insensitive copy, which the probes used to read; it now installs into
both, since the index reads the other. The cases that depend on exact-case
matching run only where names are case-sensitive, and a Windows-only case
covers the case-insensitive match.
…ty-lookup-8.0.x

Carries the 8.0.x commits that 8.1.x has not merged up yet. Two of them
touch forge templates that 8.1.x moved from rocker to GSP, so their changes
are applied to the GSP files: the swagger-ui exclude for the openapi feature
goes into buildGradle.gsp, and gradleWrapperProperties.gsp moves to Gradle
9.8.2 with gradle.properties and .sdkmanrc.
This reverts commit 9adc514.

A key that runs past a non-Map value has been answered with that value
on every released version, and containsProperty of it has been true.
Changing that answer is a change for a major release, so it moves to a
9.0.x pull request together with the List and closure cases, the
flat-key states on which the two representations disagree, and the
navigate paths that still return the parent. The indexOf walk stays; it
leaves the value untouched when a segment lands on a non-Map, as the
StringTokenizer walk did.
PropertySourcesConfig.refresh() merges the property sources again,
which brings in Spring's live systemEnvironment source under the raw
variable names, but the environment index was built once with the
config, so after a refresh the dotted spelling of a variable installed
since still resolved against the old snapshot. The index is now a
volatile field that refreshSystemEnvironment() re-captures, and
refresh() calls it before it merges. That also gives a test which
installs a variable after the context exists a public way to see it
through grailsApplication.config.
The pre-filter built the canonical name of the key with toUpperCase and
two replace calls, which allocate for a typical lower-case dotted key,
and then hashed the fresh string. It now folds each character to its
canonical form while accumulating String.hashCode over them and looks
the hash up in the sorted hashes of the variables' canonical names;
only a key whose hash is present builds the name for the exact check
before the spelling-by-spelling resolution. A character above ASCII can
upper-case to more than one character, as ß does, so a key that
contains one takes the exact check directly. The two collections are
sized up front. On the description's harness a lookup goes from 141 to
104 ns here.
…t spec

Two behaviour changes the description lists had no feature: a null key
resolves to the default value rather than the NullPointerException that
System.getenv(null) raised, and variable names are upper-cased with
Locale.ROOT, so a key containing i matches its upper-cased variable
under a Turkish default locale. The precedence table also gave every
row the same value, so a row could not tell its own spelling from a
sibling's or from a variable leaked by an earlier feature; each row now
carries its own.
… new

The snapshot is visible to a test that installs environment variables
after the application context exists, so the upgrade guide says to
install them before the context or to call refresh() on the
configuration, and notes the Locale.ROOT upper-casing and the null key
resolving to the default. The what's new page gets a short entry for the
faster lookup.
A key with a character above ASCII, such as ß, cannot be folded per
character because its upper-case form has two letters, so the
pre-filter builds the canonical name for such a key instead. Nothing
exercised that path, so deleting it would have kept every spec green.
straße.x now has to match STRASSE_X.
The revert restored the answer but nothing asserted it, so the walk
could have changed it again without a failing test. The 9.0.x change
that makes such a key absent will flip this assertion on purpose.
The locale feature set tr-TR before it touched the reflective
environment, so a failure to open the environment would have left tr-TR
as the default for the rest of the test JVM. The locale is now the last
thing the feature changes and the first thing its cleanup restores.
Only getProperty consults the environment, so the section no longer
says the configuration resolves every property against it. refresh()
is on the configuration implementation rather than on the Config
interface, so it is a call for dynamically compiled test code; it
merges the property sources again, which replaces a value a test set
directly wherever a source defines the key; and it has to be called
again once the variables are removed, because the configuration keeps
the environment it last read.
getProperty('server.url.port') returned the value of server.url when that
value was a string: the dotted walk stopped descending at the first value
that is not a map but kept whatever it had reached, so a key naming a child
of a scalar resolved to the scalar itself. Dot navigation fails on such a
key and Spring's Environment resolves it to nothing, so the walk now reports
it as absent and the default applies.

(cherry picked from commit 9adc514)
The 8.1 pin of the old answer goes. A key that names a child of a
scalar, of a List or of a closure, the value shapes application.yml
and application.groovy produce, is absent, and containsProperty says
so.
navigateMap answered a path that ran past a scalar or a list with that
value, as getProperty used to. It now answers null, so navigate() on a
config, and CodeGenConfig.navigate with it, agree with getProperty.
merge and dot-notation assignment write every nested value twice, as a
flat dotted key on the root and as an entry of the nested map, so the
flat key answers nearly every lookup with one map read and the walk
only runs for a key that has no flat form. Where the two
representations have been made to disagree the flat key wins: a raw
put of a dotted key on the root is now read, as config['a.b'] always
read it, and a raw put into a nested map no longer is, while dot
navigation still reads it. The stale flat key that merge leaves when a
scalar replaces a nested block is read as well, which the absent walk
already answered that way. The four cases are pinned.
Section 17 of the 9.0 upgrade guide: a key that runs past a scalar, a
list or a closure is absent, navigate agrees, the flat key is read
before the walk, which direct write each read sees, and the required
actions. The what's new entry for the faster lookups points there.
A nested value is written twice by a merge, as a flat dotted key on
the root and as an entry of the nested map, but only the nested write
went through the mergeMapEntry hook with which NavigableMapConfig
resolves placeholders, so the flat key kept the raw ${...} text. That
is what config['hello.evaluatedName'] returned, and what getProperty
returned once it read the flat key first, which the full suite showed
through NavigableMapSpringProfilesSpec. The flat write now goes through
the same hook, so both representations hold the resolved value.
@codecov

codecov Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.77778% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.4856%. Comparing base (5175f69) to head (5e3e25e).

Files with missing lines Patch % Lines
.../main/groovy/org/grails/config/NavigableMap.groovy 88.4354% 1 Missing and 16 partials ⚠️
...n/groovy/org/grails/config/NavigableMapConfig.java 94.3662% 0 Missing and 4 partials ⚠️
...rg/grails/config/NavigableMapPropertySource.groovy 66.6667% 0 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                 Coverage Diff                 @@
##                9.0.x     #16732         +/-   ##
===================================================
+ Coverage     45.6019%   64.4856%   +18.8837%     
- Complexity        880      30239      +29359     
===================================================
  Files              89       2370       +2281     
  Lines            5059     116775     +111716     
  Branches          886      21340      +20454     
===================================================
+ Hits             2307      75303      +72996     
- Misses           2440      31829      +29389     
- Partials          312       9643       +9331     
Files with missing lines Coverage Δ
...roovy/org/grails/config/PropertySourcesConfig.java 91.0256% <100.0000%> (ø)
...rg/grails/config/NavigableMapPropertySource.groovy 86.6667% <66.6667%> (ø)
...n/groovy/org/grails/config/NavigableMapConfig.java 81.9149% <94.3662%> (ø)
.../main/groovy/org/grails/config/NavigableMap.groovy 76.5217% <88.4354%> (ø)

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

The subscript branch of mergeMapEntry wrote the list into the target
map only, so merge(['app.items[0]': 'old']) followed by
merge([app: ['items[0]': 'new']]) updated the nested list and left the
root flat app.items on the list the first merge had created. The branch
now writes the parent-path alias on the root as well, with the same
list, so both representations hold one list. Found by James Fredley on
apache#16732.
…-9.0.x

# Conflicts:
#	grails-doc/src/en/guide/upgrading/upgrading90x.adoc
The skill mirrors every section of the Grails 9 upgrade guide, so the
new section 20 gets its entry: a key past a value that is not a map is
absent, the flat key is read first, which direct writes each read sees,
and the nested dotted key that no longer writes a root key.

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

The two earlier comments are fixed. A placeholder is resolved once for both representations, and getProperty('app.items', List) follows the nested list. Those threads are resolved. Not approving. An intermediate flat alias can still point at the old scalar.

…root

navigateSubMap wrote the alias of each block it created relative to the
map it was called on, so for a dotted key inside a nested map, such as
x: ['a.b': 'new'], the alias landed inside the x sub-map and the root
alias x.a kept the scalar an earlier merge had left there. getProperty
read that scalar first while navigate returned the block.

The alias is now written at the configuration root under the block's
full path, whichever map the walk started on, and a scalar there is
replaced as it is when the block is merged at the root. A sub-map no
longer carries a relative alias for an intermediate block, which only
this path ever wrote. The same path serves a dot-notation write below
an existing block, config.x.a.b = value, which now leaves the root
alias x.a as a merge does; GrailsConfigSpec pinned the missing alias
and expects it now, as its map-syntax sibling already did.

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

The earlier comment is fixed. merge(['x.a': 'old']) then merge([x: ['a.b': 'new']]) leaves getProperty('x.a', Map) as [b: 'new']. That thread is resolved. Not approving. A later merge can still leave a stale block alias, and a clone can write into its original.

value = getValueWithDotNotatedKeySupport(configMap, key);
// merge and dot-notation assignment write the flat key as well as the nested entry, so the flat
// key answers nearly every lookup without a walk; where the two disagree the flat key wins.
value = configMap.get(key);

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.

merge(['a.b.c': 1]), then merge([a: 'scalar']), then merge(['a.b.d': 2]) leaves the nested a.b as [d: 2] while the flat alias stays [c: 1]. getProperty('a.b', Map) returns the old block.

Replace an existing map alias when the branch is recreated, before flat lookup treats it as current.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 850c97b. navigateSubMap kept the root alias of a block it created whenever the alias already held a map, which is right only while that map is the nested block; after merge([a: 'scalar']) destroyed the nested branch the alias a.b still held the old block, and the recreation left it there. A block merged as a map value already replaced its alias unconditionally, and the dotted key path now does the same: whatever the alias holds when the nested block has to be created is stale. After merge(['a.b.c': 1]), merge([a: 'scalar']), merge(['a.b.d': 2]), getProperty('a.b', Map) and navigate('a', 'b') are both [d: 2]. The flat a.b.c of the destroyed block stays readable as 1, as the description says a lower-precedence key does when a higher-precedence scalar replaces its parent; the recreated block does not pick it up. Pinned in PropertySourcesConfigSpec and NavigableMapSpec.

rootMap.put(fullPath, newMap)
// the flat alias of a new block lives at the configuration root under the block's full path,
// whichever map the walk started on; a scalar an earlier merge left there is replaced
NavigableMap root = currentMap.getRootConfig()

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.

Cloning an empty map and merging ['new.block.value': 1] into the copy inserts new and new.block into the original, because the alias write uses getRootConfig() and the clone still points at the source.

Write those aliases on the copy. The original should stay empty.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f4f6485. clone() copied the entries into a map whose rootConfig was still the original, and shared the nested maps with it, so once the alias write followed getRootConfig() a merge into the copy wrote the original's root, and a dot-notation write below a shared block, copy.a.b.d = 2, had always written the original's nested map and its root alias. The copy of a root is now the root of its own configuration and its nested maps are copies under it, with a map the original holds twice, as the nested entry and the flat alias of a block, copied once so the copy keeps both representations on one map; the copy of a nested map stays under the root of the original, as before. Merging ['new.block.value': 1] into the clone of an empty map leaves the original empty, and copy.a.b.d = 2, copy.merge(['a.e.f': 3]) and copy.put('g', 4) leave the original's nested maps and flat keys untouched while the copy reads each through both representations. Pinned in NavigableMapSpec; CodeGenConfig.clone() builds its copy by merging and is unchanged.

navigateSubMap kept the root alias of a block it created when the alias
already held a map, so merge(['a.b.c': 1]), merge([a: 'scalar']) and
merge(['a.b.d': 2]) left the alias a.b on the block the scalar had
destroyed while the nested a.b was the recreated block, and
getProperty('a.b', Map) answered with the old block. A block merged as
a map value already replaced its alias unconditionally; the dotted key
path now does the same, since whatever the alias holds when the nested
block has to be created is stale.
clone() copied the entries into a map whose rootConfig was still the
original and shared the nested maps with it, so once the flat alias of
a block followed getRootConfig() a merge into the copy wrote the
original's root, and a dot-notation write below a shared block had
always written the original's nested map and its root alias.

The copy of a root is now the root of its own configuration and its
nested maps are copies under it; a map the original holds twice, as the
nested entry and the flat alias of a block, is copied once, so the copy
keeps both representations on one map. The copy of a nested map stays
under the root of the original, as before.
Setting a nested property to null removed every flat key under its
parent, so config.a.e = null took config['a.b.c'] with it, while setting
a root property to null removed no flat key below it, so config.a =
null left config['a.b'] and the flat-first read kept answering it. The
removal now takes the flat key of the property itself, the keys below
it and its indexed aliases, wherever the property sits, and nothing
else.
The copy shared the lists that indexed keys create, and the map
elements inside them, so an indexed merge into the copy wrote the
original's list in place. Lists and plain maps are copied as the nested
maps are, each once, so the nested entry and the flat alias of the copy
share one list as the original's do.
The first pass over a dotted key wrote the key as given into the map
being merged into. At the root that is the flat key. Below the root,
for a dotted key inside a nested map such as merge([a: ['b.c': v]]),
it was a copy of the key inside the a sub-map that no other write kept
current, so after merge(['a.b.c': w]) getProperty('a', Map) still
showed the old value under b.c beside the new one under b. The first
pass now runs at the root only; the nested write already writes the
flat key under its full path.
@codeconsole

codeconsole commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Checked the two representations against each other over every ordered sequence of up to three writes drawn from ten write paths (a flat dotted key, a nested map, a dotted key inside a nested map, a map under a dotted key, dot-notation set of a scalar, of a map and of null, an indexed key at the root and inside a nested map, and subscript assignment) over the keys a, a.b, a.b.c, a.b.d and a.e, 75k sequences. Each result was walked for a nested block or list that is not the value of its flat alias, a nested scalar that differs from its flat key, a dotted flat key with no nested entry, and a clone written after each prefix that changed the original. The probe is a throwaway spec and is not in the PR.

Besides the states the layering rule leaves on purpose (the flat key of a block that a later scalar replaced stays readable), it found three pre-existing bug classes, each fixed in its own commit with failing-first tests:

  • d1914b1: setting a nested property to null removed every flat key under its parent, so config.a.e = null took config['a.b.c'] with it, while setting a root property to null removed no flat key below it, so config.a = null left config['a.b'] for the flat-first read to answer. The removal now takes the flat key of the property, the keys below it and its indexed aliases, wherever the property sits, and nothing else.
  • d25bee0: the clone shared the lists that indexed keys create, so an indexed merge into a copy wrote the original's list in place. Lists and the plain maps inside them are copied as the nested maps are, each once.
  • 6d3ba8d: a dotted key inside a nested map, merge([a: ['b.c': v]]), was also written as b.c into the a sub-map, a copy no other write kept current, so after merge(['a.b.c': w]) getProperty('a', Map) showed the old value under b.c beside the new one under b. The first pass over a dotted key now runs at the root only, where the dotted key is the flat key itself.

After the three fixes only the layering states remain: the flat key of a block that a scalar replaced, both while the scalar is there and after a later merge has recreated the block without that key, as in the reply above. The description lists the fixes.

Checked the indexed keys the same way after the review comment on them, over every single write and every ordered pair from seventeen write paths: a.rows[0].name at the root, inside the parent map and two levels down, an index alone, a dotted container name rows.name[0], two indexes, two indexes and a property, a map subscript and a property, a list merged as a value at the root, inside the parent map and by dot notation, and dot notation of an indexed key, each at the root and inside the parent map where both exist; plus a re-merge of each result with parseFlatKeys off, which is how PropertySourcesConfig takes in a loaded file. Two more bug classes, both older than this PR:

  • 1f16fd0: an indexed key with a property after the index built the list under its flat key and a rows[0] block in the nested map, so navigate('a', 'rows') was null on the map a YAML sequence of maps produces. A loaded application.yml read correctly only because its property source is merged into the config a second time, which turned the block into a list element; a direct merge did not, and after 6d3ba8d the nested form built nothing at all. An indexed key is now merged whole: the list is one object in both representations, with a flat key for each element and for the value, and NavigableMapPropertySource presents such a list through its elements only.
  • 9810580: a list merged as a value, merge([a: [rows: [[name: 'new']]]]) or config.a.rows = [...], wrote no element keys, so after it replaced a list written by indexed keys getProperty('a.rows[0].name') still answered from the old elements. It now writes the element keys and drops those of the list it replaces.

After those two the probe still reported the element keys of an element or a list that a later write replaced, 258 over the 289 pairs, which I had filed under the layering rule; the review comment on a.rows[0] replaced by a map is one of them. 6fe7bfd makes the element keys follow what they describe: an element replaced whole, a value written over a list, a container made anew as the other kind and a block created where a list was each drop the element keys of what they replace, and the probe now reports none outside the block rule: the dotted flat key of a block's entry stays when a value replaces the block, as before, and its element key stays with it, since that key still holds the list.

A third run over the same paths with the containers application.groovy can supply, asImmutable(), List.of and Arrays.asList lists and immutable element maps, found the replay of the element keys writing into them when the config merges the property source, which failed startup, and an indexed key written over them throwing on 9.0.x before this PR as well. 12f1420: a container that already holds the object is left as it is, and one that refuses the write is replaced by a copy.

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

The two earlier comments are fixed. The three-merge case leaves a.b as [d: 2] in both lookups, and merging into a clone no longer writes into the original. Those threads are resolved. Not approving. A nested indexed property is no longer built into a list.

An indexed key with a property after the index, such as a.rows[0].name,
was split at its dots like any other key: the first pass built the list
under the flat key a.rows, and the second created a rows[0] block in
the nested map in place of the list, so navigate('a', 'rows') was null
for the form a YAML sequence of maps takes. The previous commit, which
skips the first pass below the root, then left merge([a: ['rows[0].name':
v]]) with no list at all. A key with a subscript is now merged whole:
the container is held by the nested map that owns it and under its flat
key at the root as one object, with a flat key for each element the
subscripts step through and for the value, and no block beside it.
NavigableMapPropertySource presents a map through its entries and a
list through its elements whichever flat keys the map holds for them.
A list merged as a value, merge([a: [rows: [[name: 'new']]]]) or
config.a.rows = [...], wrote the list under its flat key and the nested
entry and nothing for its elements, while a list written by indexed
keys, as a configuration file writes it, has a flat key for each
element and for each entry of an element that is a map. So after a list
merged as a value replaced one written by indexed keys,
getProperty('a.rows[0].name') still answered from the old elements. The
element keys are now written for a list merged as a value too, once
those of the list it replaces are removed.
Section 20 and the grails-9-upgrade skill entry say that an indexed key
with a property after the index builds its list in both
representations, and that a list merged as a value writes the element
keys and replaces those of the list it replaces.

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

The earlier indexed-list comment is fixed. merge([a: ['rows[0].name': 'row']], true) builds [[name: 'row']] for both lookups. That thread is resolved. Not approving. An immutable list from application.groovy can now fail startup, and replacing one indexed element leaves a stale child alias.

Comment thread grails-bootstrap/src/main/groovy/org/grails/config/NavigableMap.groovy Outdated
Comment thread grails-bootstrap/src/main/groovy/org/grails/config/NavigableMap.groovy Outdated
The element keys a list merged as a value writes, a.names[0] for each
element, are merged again when a config merges the map of a property
source. That write set the element into the list the map holds, so a
list application.groovy built with asImmutable() or List.of threw
UnsupportedOperationException and the application did not start. A
container that already holds the object is now left as it is, and one
that refuses the write, as when an indexed key in a higher property
source writes an element of an immutable list, is replaced by a copy
that holds it, in both representations.
An indexed element replaced whole, merge(['a.rows[0]': [name: 'new']]),
set the element into the list and wrote its own flat key, and left the
a.rows[0].name of the element it replaced, so getProperty read the old
name beside the new element. The same for a value written over the
list, a container made anew as the other kind, a map where a list was
or a list where a map was, and a block that a nested write created
where a list was.

The element keys now follow what they describe. A write that replaces
an element, a value or a container removes the element keys below it
before its own are written, so the value is read by its keys and by
none of the old one's; a copy made for a container that refused the
write keeps them, since it holds what the container held. The dotted
flat key of a block's entry stays when a value replaces the block, as
it always has, and its element key stays with it.

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

The two earlier comments are fixed. An immutable list no longer fails startup, and replacing an indexed element drops the old child aliases. Those threads are resolved. Not approving. Changing a list element in place leaves a stale alias that a later merge writes back.

private void writeElements(NavigableMap rootMap, String flatKey, Object value) {
if (value instanceof List) {
int index = 0
for (Object element in (List) value) {

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.

map.app.names = ['one'] then map.app.names[0] = 'two' leaves the generated app.names[0] alias as 'one'. toProperties() then reports both values, and other.merge(map, true) replays the stale alias and puts 'one' back.

Do not let a snapshot alias override the current contents of a mutable list. Please cover that assignment and the merge that follows.

@testlens-app

testlens-app Bot commented Oct 24, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 5e3e25e
▶️ Tests: 136164 executed
⚪️ Checks: 97/97 completed


Learn more about TestLens at testlens.app/docs.

This branch has not been deployed

No deployments
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