Skip to content

Fix resolveWith() for overloaded keys with delayed merge - #856

Open
arimu1 wants to merge 2 commits into
lightbend:mainfrom
arimu1:fix/855-resolveWith-delayed-merge
Open

Fix resolveWith() for overloaded keys with delayed merge#856
arimu1 wants to merge 2 commits into
lightbend:mainfrom
arimu1:fix/855-resolveWith-delayed-merge

Conversation

@arimu1

@arimu1 arimu1 commented Aug 13, 2026

Copy link
Copy Markdown

Summary

  • Fix ConfigException.BugOrBroken when calling resolveWith() on a config that has duplicate keys where the later value is a substitution (creating a ConfigDelayedMerge).
  • Track the parent chain for configs being resolved even when the substitution lookup root is a different object (as resolveWith() does).
  • Preserve the external substitution root during delayed-merge parent replacements so ${variable} still resolves from the resolveWith source.

Reproduces and fixes #855:

Config conf = ConfigFactory.parseString("{ \"one\": \"first\", \"one\": ${variable}}")
    .resolveWith(ConfigFactory.parseString("{ \"variable\": \"second\"}"));
// expected: conf.getString("one") == "second"

Test plan

  • Added resolveWithDelayedMergeOverloadedValue regression test in ConfigTest
  • sbt config/test — 553 tests passed (JDK 17)

Fixes #855

Made with Cursor

@lightbend-cla-validator

Copy link
Copy Markdown
Collaborator

Hi @arimu1,

Thank you for your contribution! We really value the time you've taken to put this together.

Before we proceed with reviewing this pull request, please sign the Akka Contributors License Agreement:

https://contribute.akka.io/contribute/cla

When resolveWith() uses an external substitution root, parent-chain
tracking was skipped and ConfigDelayedMerge could not replace itself
during inner substitution resolution, throwing BugOrBroken.

Fixes lightbend#855
@arimu1
arimu1 force-pushed the fix/855-resolveWith-delayed-merge branch from 1c11f1a to dadf7f2 Compare August 13, 2026 00:20

@johanandren johanandren left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for tracking this down, the diagnosis is right, and the behaviour the PR produces is right too.

The main feedback is to move the fix down to the branch that actually throws, that will be twice as fast and it leaves pushParent's documented invariant and its ***** BUG ***** trace in place.

Also, we'd need a signed CLA to be able to merge this.


// https://github.com/lightbend/config/issues/855
@Test
def resolveWithDelayedMergeOverloadedValue(): Unit = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think this would be a more descriptive/correct test name

Suggested change
def resolveWithDelayedMergeOverloadedValue(): Unit = {
def resolveWithDuplicateKeyAndSubstitution(): Unit = {

Comment on lines 270 to 278

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Put the fix here instead, splitting the combined old == root && replacement instanceof Container condition is what makes the two cases distinguishable: old == root keeps main's behaviour exactly, including the throw when the replacement isn't a Container; only "not a descendant of root at all" becomes a no-op.

  } else if (old == root) {
    if (replacement instanceof Container) {
      return new ResolveSource(rootMustBeObj((Container) replacement));
    } else {
      throw new ConfigException.BugOrBroken("replace in parent not possible " + old + " with " + replacement + " in " + this);
    }
  } else {
    // pathFromRoot is null so we aren't proceeding from the root (see
    // pushParent); resolveWith() resolves a value that isn't a descendant
    // of the substitution root, so root can't contain "old" and there's
    // nothing to replace.
    return this;
  }

The comment is doing the : replaceWithinCurrentParent exists so a delayed merge can hide itself from its own substitutions, and when the lookup root doesn't contain the merge stack there is nothing to hide.

@arimu1

arimu1 commented Aug 14, 2026

Copy link
Copy Markdown
Author

Addressed the review feedback in 30c2252:

  • Restored pushParent invariant and ***** BUG ***** trace
  • Moved the no-op fix to replaceWithinCurrentParent (only non-descendant case returns this; old == root keeps main's throw behavior)
  • Renamed test to resolveWithDuplicateKeyAndSubstitution

sbt config/test — 553 passed.

CLA still needs to be signed by the human contributor before merge.

Keep old==root throw when replacement is not a Container; return this
when old is not a descendant of the substitution root. Rename test to
resolveWithDuplicateKeyAndSubstitution.
@arimu1
arimu1 force-pushed the fix/855-resolveWith-delayed-merge branch from 30c2252 to 167717f Compare August 14, 2026 00:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Variable substitution fails unexpectedly with overloaded values using resolveWith()

3 participants