This is an automated email from the ASF dual-hosted git repository.

hossman pushed a commit to branch branch_9_9
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/branch_9_9 by this push:
     new f6c7a53aaf1 SOLR-17834: Fixed a bug preventing Config API set 
properties (aka: configoverlay.json) from being used in config file property 
substitution
f6c7a53aaf1 is described below

commit f6c7a53aaf1d86363b9123346583152cde512647
Author: Chris Hostetter <hoss...@apache.org>
AuthorDate: Thu Aug 14 09:51:32 2025 -0700

    SOLR-17834: Fixed a bug preventing Config API set properties (aka: 
configoverlay.json) from being used in config file property substitution
    
    (cherry picked from commit cbe0626912c48b5af468ec50be81608aeaa2c35c)
---
 solr/CHANGES.txt                                   |   3 +
 .../java/org/apache/solr/util/DataConfigNode.java  | 128 +++-----
 .../conf/solrconfig.xml                            |  17 +-
 .../solr/core/TestSetPropertyConfigApis.java       | 357 +++++++++++++++++++++
 4 files changed, 404 insertions(+), 101 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 30f464b6cd4..055af9cefd8 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -18,6 +18,9 @@ Bug Fixes
 ---------------------
 * SOLR-17831: ExitableDirectoryReader always initialized with QueryLimits.NONE 
(Andrzej BiaƂecki)
 
+* SOLR-17834: Fixed a bug preventing Config API set properties (aka: 
configoverlay.json) from being used in config file property
+  substitution (hossman)
+
 Dependency Upgrades
 ---------------------
 (No changes)
diff --git a/solr/core/src/java/org/apache/solr/util/DataConfigNode.java 
b/solr/core/src/java/org/apache/solr/util/DataConfigNode.java
index d6a885fd266..ae3e00e6759 100644
--- a/solr/core/src/java/org/apache/solr/util/DataConfigNode.java
+++ b/solr/core/src/java/org/apache/solr/util/DataConfigNode.java
@@ -17,32 +17,38 @@
 
 package org.apache.solr.util;
 
-import java.util.AbstractMap;
-import java.util.AbstractSet;
 import java.util.ArrayList;
-import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.function.BiConsumer;
 import java.util.function.Function;
 import java.util.function.Predicate;
+import java.util.stream.Collectors;
 import org.apache.solr.common.ConfigNode;
 import org.apache.solr.common.util.PropertiesUtil;
 
-/** ConfigNode impl that copies and maintains data internally from DOM */
+/**
+ * ConfigNode impl that applies property substitutions on access.
+ *
+ * <p>This class wraps another {@link ConfigNode} and applies property 
substitution immediately when
+ * the {@link #txt} or {@link #attributes} methods are called. Because 
property substition is based
+ * on ThreadLocal values, These methods <em>MUST</em> be called while those 
variables are "active"
+ *
+ * @see ConfigNode#SUBSTITUTES
+ * @see PropertiesUtil#substitute
+ */
 public class DataConfigNode implements ConfigNode {
-  public final String name;
-  public final Map<String, String> attributes;
-  public final Map<String, List<ConfigNode>> kids;
-  public final String textData;
+  private final String name;
+  private final Map<String, String> rawAttributes;
+  private final Map<String, List<ConfigNode>> kids;
+  private final String rawTextData;
 
   public DataConfigNode(ConfigNode root) {
     Map<String, List<ConfigNode>> kids = new LinkedHashMap<>();
     name = root.name();
-    attributes = wrapSubstituting(root.attributes());
-    textData = root.txt();
+    rawAttributes = root.attributes();
+    rawTextData = root.txt();
     root.forEachChild(
         it -> {
           List<ConfigNode> nodes = kids.computeIfAbsent(it.name(), k -> new 
ArrayList<>());
@@ -61,25 +67,38 @@ public class DataConfigNode implements ConfigNode {
     return PropertiesUtil.substitute(s, SUBSTITUTES.get());
   }
 
-  /** provides a substitute view, and read-only */
-  private static Map<String, String> wrapSubstituting(Map<String, String> 
delegate) {
-    if (delegate.size() == 0) return delegate; // avoid unnecessary object 
creation
-    return new SubstitutingMap(delegate);
-  }
-
   @Override
   public String name() {
     return name;
   }
 
+  /** Each call to this method returns a (new) copy of the original txt with 
substitions applied. */
   @Override
   public String txt() {
-    return substituteVal(textData);
+    return substituteVal(rawTextData);
   }
 
+  /**
+   * Each call to this method returns a (new) copy of the original Map with 
substitions applied to
+   * the values.
+   */
   @Override
   public Map<String, String> attributes() {
-    return attributes;
+    if (rawAttributes.isEmpty()) return rawAttributes; // avoid unnecessary 
object creation
+
+    // Note: using the the 4 arg toMap to force LinkedHashMap.
+    // Duplicate keys should be impossible, but toMap makes us specify a 
mergeFunction
+    return rawAttributes.entrySet().stream()
+        .collect(
+            Collectors.toMap(
+                Map.Entry::getKey,
+                e -> {
+                  return substituteVal(e.getValue());
+                },
+                (v, vv) -> {
+                  throw new IllegalStateException();
+                },
+                LinkedHashMap::new));
   }
 
   @Override
@@ -123,75 +142,4 @@ public class DataConfigNode implements ConfigNode {
           }
         });
   }
-
-  private static class SubstitutingMap extends AbstractMap<String, String> {
-
-    private final Map<String, String> delegate;
-
-    SubstitutingMap(Map<String, String> delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public String get(Object key) {
-      return substituteVal(delegate.get(key));
-    }
-
-    @Override
-    public int size() {
-      return delegate.size();
-    }
-
-    @Override
-    public Set<String> keySet() {
-      return delegate.keySet();
-    }
-
-    @Override
-    public void forEach(BiConsumer<? super String, ? super String> action) {
-      delegate.forEach((k, v) -> action.accept(k, substituteVal(v)));
-    }
-
-    @Override
-    public Set<Entry<String, String>> entrySet() {
-      return new AbstractSet<>() {
-        @Override
-        public Iterator<Entry<String, String>> iterator() {
-          // using delegate, return an iterator using Streams
-          return delegate.entrySet().stream()
-              .map(entry -> (Entry<String, String>) new 
SubstitutingEntry(entry))
-              .iterator();
-        }
-
-        @Override
-        public int size() {
-          return delegate.size();
-        }
-      };
-    }
-
-    private static class SubstitutingEntry implements Entry<String, String> {
-
-      private final Entry<String, String> delegateEntry;
-
-      SubstitutingEntry(Entry<String, String> delegateEntry) {
-        this.delegateEntry = delegateEntry;
-      }
-
-      @Override
-      public String getKey() {
-        return delegateEntry.getKey();
-      }
-
-      @Override
-      public String getValue() {
-        return substituteVal(delegateEntry.getValue());
-      }
-
-      @Override
-      public String setValue(String value) {
-        throw new UnsupportedOperationException();
-      }
-    }
-  }
 }
diff --git 
a/solr/core/src/test-files/solr/configsets/cloud-minimal-userproperties/conf/solrconfig.xml
 
b/solr/core/src/test-files/solr/configsets/cloud-minimal-userproperties/conf/solrconfig.xml
index 4e01dc85a64..4cec0dcb752 100644
--- 
a/solr/core/src/test-files/solr/configsets/cloud-minimal-userproperties/conf/solrconfig.xml
+++ 
b/solr/core/src/test-files/solr/configsets/cloud-minimal-userproperties/conf/solrconfig.xml
@@ -17,7 +17,6 @@
  limitations under the License.
 -->
 
-<!-- Minimal solrconfig.xml with /select, /admin and /update only -->
 <config>
 
   <dataDir>${solr.data.dir:}</dataDir>
@@ -32,24 +31,20 @@
     <commitWithin>
       <softCommit>${solr.commitwithin.softcommit:true}</softCommit>
     </commitWithin>
-
   </updateHandler>
+  
   <requestHandler name="/select" class="solr.SearchHandler">
     <lst name="defaults">
-      <str name="echoParams">explicit</str>
+      <str name="echoParams">${custom.echoParams:explicit}</str>
       <str name="indent">true</str>
       <str name="df">text</str>
        <str name="dummy1">${solr.core.name}</str>
        <str name="dummy2">${my.custom.prop}</str>
+       <str name="${my.custom.prop:dummy3}">custom-key-value</str>
     </lst>
-
   </requestHandler>
-
-  <initParams path="/select">
-    <lst name="defaults">
-      <str name="df">text</str>
-    </lst>
-  </initParams>
-
+  <updateRequestProcessorChain name="${my.custom.prop:some-name}" 
default="${update.autoCreateFields:true}">
+    <processor class="solr.RunUpdateProcessorFactory"/>
+  </updateRequestProcessorChain>
 </config>
 
diff --git 
a/solr/core/src/test/org/apache/solr/core/TestSetPropertyConfigApis.java 
b/solr/core/src/test/org/apache/solr/core/TestSetPropertyConfigApis.java
new file mode 100644
index 00000000000..489013ba237
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/core/TestSetPropertyConfigApis.java
@@ -0,0 +1,357 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.core;
+
+import static org.hamcrest.Matchers.startsWith;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.util.RestTestHarness;
+import org.junit.After;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * This class has multiple test methods that each create the same collection, 
using the same
+ * properties, but differ in how/when they set those properties, and then use 
{@link
+ * #checkCollection} to confirm each collection had the expected final config
+ */
+public class TestSetPropertyConfigApis extends SolrCloudTestCase {
+
+  // Re-used in each test
+  public static String CONFIGSET_NAME = "cloud-minimal-userproperties";
+
+  /**
+   * Props that can be re-used in multiple tests that want to make simple 
assertions
+   *
+   * @see #checkCollectionUsingSimpleProps
+   */
+  private static Map<String, String> SIMPLE_PROPS =
+      Map.of(
+          "my.custom.prop", "foo",
+          "custom.echoParams", "all",
+          "update.autoCreateFields", "false",
+          "solr.commitwithin.softcommit", "false");
+
+  /**
+   * For simplicity we use a single replica of a single shard on a single 
jetty So we know exactly
+   * where/who we have to wait on for any reloads
+   */
+  private static int SINGLE_CORE = 1;
+
+  /**
+   * @see #SINGLE_CORE
+   * @see SolrCore#close
+   */
+  private static SolrCore getCore(final String collectionName) {
+    final CoreContainer cc = 
cluster.getRandomJetty(random()).getCoreContainer();
+    final CoreDescriptor coreDesc =
+        cc.getCoreDescriptors().stream()
+            .filter(cd -> collectionName.equals(cd.getCollectionName()))
+            .findAny()
+            .get();
+    return cc.getCore(coreDesc.getName());
+  }
+
+  @BeforeClass
+  public static void createCluster() throws Exception {
+    configureCluster(SINGLE_CORE).addConfig(CONFIGSET_NAME, 
configset(CONFIGSET_NAME)).configure();
+  }
+
+  @After
+  public void cleanupAllCollectionsAndConfigSideEffects() throws Exception {
+    cluster.deleteAllCollections();
+
+    // parsed config files can be cached in the CoreContainer
+    
cluster.getRandomJetty(random()).getCoreContainer().getObjectCache().clear();
+
+    // In spite of what the API may say: the Config-API modifies the configset,
+    // not the collection -- so we need to ensure no remnants of any changes.
+    //
+    // https://issues.apache.org/jira/browse/SOLR-17862
+    cluster.deleteAllConfigSets();
+    cluster.uploadConfigSet(configset(CONFIGSET_NAME), CONFIGSET_NAME);
+  }
+
+  /**
+   * @see #checkCollection
+   * @see #SIMPLE_PROPS
+   */
+  private void checkCollectionUsingSimpleProps(final String collectionName) 
throws Exception {
+    checkCollection(collectionName, true, "foo", false, false);
+  }
+
+  /**
+   * Makes some very targeted assertions about a collection created with 
{@link #CONFIGSET_NAME}
+   *
+   * @see #checkCollectionUsingSimpleProps
+   */
+  private void checkCollection(
+      final String collectionName,
+      final boolean echoAllParams,
+      final String customProp,
+      final boolean commitWithinSoft,
+      final boolean customChainIsDefault)
+      throws Exception {
+
+    // Check the actual params used by the /select handler
+    final NamedList<Object> header =
+        cluster.getSolrClient().query(collectionName, params("q", 
"*:*")).getHeader();
+
+    @SuppressWarnings("unchecked")
+    final NamedList<String> params = (NamedList<String>) header.get("params");
+
+    assertEquals(
+        collectionName + " echoParams=all?", echoAllParams, 
"all".equals(params.get("echoParams")));
+    if (echoAllParams) {
+      assertThat(
+          params.get("dummy1"),
+          // Solr assigned implicit property
+          startsWith(collectionName));
+      // this tests that both keys and values were substituted correctly
+      assertEquals(collectionName + " customPropKey?", "custom-key-value", 
params.get(customProp));
+      assertEquals(collectionName + " customPropValue?", customProp, 
params.get("dummy2"));
+    }
+
+    // Introspect the update handler & processors of a random core of our 
collection.
+    try (SolrCore core = getCore(collectionName)) {
+      assertNotNull(core);
+
+      assertEquals(
+          collectionName + " commitWithinSoft?",
+          commitWithinSoft,
+          core.getSolrConfig().getUpdateHandlerInfo().commitWithinSoftCommit);
+
+      assertNotNull(
+          collectionName + " customChainName exists?", 
core.getUpdateProcessingChain(customProp));
+
+      assertEquals(
+          collectionName + " customChainNameIsDefault?",
+          customChainIsDefault,
+          
core.getUpdateProcessingChain(customProp).equals(core.getUpdateProcessingChain(null)));
+    }
+  }
+
+  @Test
+  public void testAllSystemProperties() throws Exception {
+    final String collectionName = getSaferTestName();
+    SIMPLE_PROPS.entrySet().stream().forEach(e -> 
System.setProperty(e.getKey(), e.getValue()));
+
+    processAndAssertSuccess(
+        CollectionAdminRequest.createCollection(
+            collectionName, CONFIGSET_NAME, SINGLE_CORE, SINGLE_CORE));
+
+    cluster.waitForActiveCollection(collectionName, SINGLE_CORE, SINGLE_CORE);
+
+    checkCollectionUsingSimpleProps(collectionName);
+  }
+
+  @Test
+  public void testAllCollectionCreationProperties() throws Exception {
+    final String collectionName = getSaferTestName();
+
+    final CollectionAdminRequest.Create op =
+        CollectionAdminRequest.createCollection(
+            collectionName, CONFIGSET_NAME, SINGLE_CORE, SINGLE_CORE);
+    SIMPLE_PROPS.entrySet().stream().forEach(e -> op.withProperty(e.getKey(), 
e.getValue()));
+    processAndAssertSuccess(op);
+
+    cluster.waitForActiveCollection(collectionName, SINGLE_CORE, SINGLE_CORE);
+
+    checkCollectionUsingSimpleProps(collectionName);
+  }
+
+  @Test
+  public void testConfigApiAfterCollectionCreation() throws Exception {
+    final String collectionName = getSaferTestName();
+    assertNull(System.getProperty("my.custom.prop"));
+
+    // We set this one system property, to a value that is garunteed to *NOT* 
match any
+    // of our expectations, before creating the collection, to ensure 
collection
+    // creation options take precendent over system properties
+    final String bogusInitialProp = "BOGUS__" + 
SIMPLE_PROPS.get("my.custom.prop");
+    System.setProperty("my.custom.prop", bogusInitialProp);
+
+    processAndAssertSuccess(
+        CollectionAdminRequest.createCollection(
+                collectionName, CONFIGSET_NAME, SINGLE_CORE, SINGLE_CORE)
+            // This should take precedence over the system property
+            // (and over any Config-API set value later)
+            .withProperty("my.custom.prop", "qqq"));
+
+    cluster.waitForActiveCollection(collectionName, SINGLE_CORE, SINGLE_CORE);
+
+    // Check that our initial collection matches our special startup situation
+    // (most of these come from the solrconfig.xml defaults since the props 
aren't set)
+    checkCollection(collectionName, false, "qqq", true, true);
+
+    // Now use the Config API to set *ALL* of the properties to their expected
+    // "simple" values and check that our collection is in good shape
+    // NOTE: custom 'qqq' should still override 'foo'
+    setUserPropertiesAndWaitForReload(collectionName, SIMPLE_PROPS);
+    checkCollection(collectionName, true, "qqq", false, false);
+  }
+
+  @Test
+  public void testTwoCollectionsWithDifferentProps() throws Exception {
+    final String collectionX = getSaferTestName() + "_x";
+    final String collectionY = getSaferTestName() + "_y";
+
+    // first create both collections
+
+    processAndAssertSuccess(
+        CollectionAdminRequest.createCollection(
+                collectionX, CONFIGSET_NAME, SINGLE_CORE, SINGLE_CORE)
+            .withProperty("my.custom.prop", "xxx")
+            .withProperty("custom.echoParams", "all"));
+
+    processAndAssertSuccess(
+        CollectionAdminRequest.createCollection(
+                collectionY, CONFIGSET_NAME, SINGLE_CORE, SINGLE_CORE)
+            .withProperty("my.custom.prop", "yyy")
+            .withProperty("solr.commitwithin.softcommit", "true"));
+
+    cluster.waitForActiveCollection(collectionX, SINGLE_CORE, SINGLE_CORE);
+    cluster.waitForActiveCollection(collectionY, SINGLE_CORE, SINGLE_CORE);
+
+    // now check both collections against the props we expected (or their 
defaults)
+    checkCollection(collectionX, true, "xxx", true, true);
+    checkCollection(collectionY, false, "yyy", true, true);
+
+    // In spite of what the API may say: the Config-API modifies the configset,
+    // not the collection.  So if we modify the user-properties of Y,
+    // we should see those changes in X as well...
+    //
+    // https://issues.apache.org/jira/browse/SOLR-17862
+    //
+    // ... EXCEPT!!! ... collection creation props take precedence over
+    // configset props.
+    final CountDownLatch firstReloadOfXandY = 
createSolrCoreCloseLatch(collectionX, collectionY);
+    setUserProperties(
+        collectionY,
+        Map.of(
+            "my.custom.prop", "zzz",
+            "custom.echoParams", "all",
+            "solr.commitwithin.softcommit", "false",
+            "update.autoCreateFields", "false"));
+    assertTrue(
+        "Gave up after waiting an excessive amount of time for both 
collections to reload (first time)",
+        firstReloadOfXandY.await(60, TimeUnit.SECONDS));
+
+    checkCollection(collectionX, true, "xxx", false, false);
+    checkCollection(collectionY, true, "yyy", true, false);
+
+    // modify config (via "X" this time) and check *both* collections again
+    final CountDownLatch secondReloadOfXandY = 
createSolrCoreCloseLatch(collectionX, collectionY);
+    setUserProperties(
+        collectionX,
+        Map.of(
+            "custom.echoParams", "explicit",
+            "solr.commitwithin.softcommit", "true",
+            "update.autoCreateFields", "true"));
+    assertTrue(
+        "Gave up after waiting an excessive amount of time for both 
collections to reload (again)",
+        secondReloadOfXandY.await(60, TimeUnit.SECONDS));
+    checkCollection(collectionX, true, "xxx", true, true);
+    checkCollection(collectionY, false, "yyy", true, true);
+  }
+
+  private static void processAndAssertSuccess(final 
CollectionAdminRequest.Create op)
+      throws Exception {
+    op.setWaitForFinalState(true);
+    assertTrue(op.process(cluster.getSolrClient()).isSuccess());
+  }
+
+  /**
+   * Use the Config API to set *ALL* of the properties to their expected 
values via a single REST
+   * command.
+   */
+  private static void setUserProperties(
+      final String collectionName, final Map<String, String> props) throws 
Exception {
+
+    try (RestTestHarness harness = makeRestHarness(collectionName)) {
+      final String cmd =
+          "{ 'set-user-property' : { "
+              + props.entrySet().stream()
+                  .map(e -> "'" + e.getKey() + "':'" + e.getValue() + "'")
+                  .collect(Collectors.joining(","))
+              + "}} ";
+      runConfigCommand(harness, cmd);
+    }
+  }
+
+  /**
+   * Use the Config API to set *ALL* of the properties to their expected 
values via a single REST
+   * command and wait for the core reload
+   *
+   * @see #SINGLE_CORE
+   */
+  private static void setUserPropertiesAndWaitForReload(
+      final String collectionName, final Map<String, String> props) throws 
Exception {
+
+    final CountDownLatch reloadLatch = 
createSolrCoreCloseLatch(collectionName);
+
+    setUserProperties(collectionName, props);
+    assertTrue(
+        "Gave up after waiting an excessive amount of time for the core 
reload",
+        reloadLatch.await(60, TimeUnit.SECONDS));
+  }
+
+  /**
+   * Given some collection names, registers a {@link CloseHook} on the current 
core of each
+   * collection, and returns a CountDownLatch that will fire when all of those 
cores have closed
+   * (ie: wait for reload or shutdown)
+   *
+   * @see #SINGLE_CORE
+   * @see SolrCore#addCloseHook
+   */
+  private static final CountDownLatch createSolrCoreCloseLatch(final String... 
collectionNames) {
+    final CountDownLatch latch = new CountDownLatch(collectionNames.length);
+    for (final String name : collectionNames) {
+      try (SolrCore core = getCore(name)) {
+        assertNotNull(core);
+
+        core.addCloseHook(
+            new CloseHook() {
+              public void postClose(SolrCore core) {
+                latch.countDown();
+              }
+            });
+      }
+    }
+    return latch;
+  }
+
+  private static RestTestHarness makeRestHarness(final String collectionName) {
+    return new RestTestHarness(
+        () -> cluster.getRandomJetty(random()).getBaseUrl().toString() + "/" + 
collectionName);
+  }
+
+  private static void runConfigCommand(RestTestHarness harness, String json) 
throws IOException {
+    String response = harness.post("/config", json);
+    Map<?, ?> map = (Map<?, ?>) Utils.fromJSONString(response);
+    assertNull(response, map.get("errorMessages"));
+    assertNull(response, map.get("errors"));
+  }
+}

Reply via email to