Repository navigation
Read the flat key first and treat a key past a value that is not a map as absent - #16732
codeconsole wants to merge 52 commits into
Conversation
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.
…x' into perf/config-lookup-9.0.x
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 Report❌ Patch coverage is Additional details and impacted files@@ 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
🚀 New features to boost your workflow:
|
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
…x' into perf/config-lookup-9.0.x
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
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 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:
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:
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 third run over the same paths with the containers |
jamesfredley
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
✅ All tests passed ✅🏷️ Commit: 5e3e25e Learn more about TestLens at testlens.app/docs. |
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')withserver.url: http://localhostreturned the URL,getProperty('grails.gorm.default.constraints.nullable')with a closure atgrails.gorm.default.constraintsreturned the closure, andgetProperty('a.b.0')with a list ata.breturned the list;containsPropertyof each wastrue. Now each isnull, the default is returned when one is given, andcontainsPropertyisfalse.navigate()on a config andCodeGenConfig.navigateanswer the same way, so the lookup paths no longer disagree.The flat key is read before the nested maps are walked.
mergeand 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: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 whatconfig['hello.evaluatedName']returned on every released version. The value is now resolved once, through amergedValuehook thatNavigableMapasks 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 keya.band overwrote the real property. Every nested dotted key passed tomergedid this, for exampleconfig.merge([logging: [level: ['org.springframework': 'DEBUG']]])also wrote a rootorg.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 asx.a.bonly, andgetProperty('a.b')staysroot.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 arows[0]block in the nested map, sonavigate('a', 'rows')andconfig.a.rowsdid not see the list thatgetProperty('a.rows', List)returned. A loadedapplication.ymlread correctly only because its property source is merged into the config a second time, which turned the block into a list element; a directmergedid not. An indexed key is now merged whole: the list is one object in both representations, anda.rows[0]anda.rows[0].nameare read by their flat keys. A list merged as a value,config.merge([a: [rows: [[name: 'row']]]])orconfig.a.rows = [...], writes those element keys too and drops the ones of the list it replaces, sogetProperty('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, sogetProperty('a.rows[0].name')answers from the current element or is absent. A listapplication.groovysupplies as immutable,['one'].asImmutable()orList.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
mergeleaves 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'])answersserver.url.portwithy(wasx), andput('a', 'scalar'); put('a.b', 1)answersa.bwith1(wasscalar). 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: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
containsProperty.navigatestops at a value that is not a map (NavigableMap.navigateMap, whichCodeGenConfig.navigateuses).NavigableMapSpringProfilesSpec.getProperty('server.url', Map)andnavigateagree aftermerge(['server.url': 'x'])thenmerge(['server.url.port': 'y']).putAtonNavigableMap, soconfig.a['b'] = valuewrites both representations likeconfig.a.b = value.merge(['x.a': 'old'])thenmerge([x: ['a.b': 'new']])readsx.aas the block through every lookup (found by @jamesfredley in review).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).nullremoves 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.rows[0]block beside it;NavigableMapPropertySourcepresents such a list through its elements only (found by @jamesfredley in review, in the nested form).GrailsAppstarted from anapplication.groovythat holds one (found by @jamesfredley in review).