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

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


The following commit(s) were added to refs/heads/main by this push:
     new 2602d6b02cb SOLR-18563: Apply additive Schema API changes without a 
core reload (#5194)
2602d6b02cb is described below

commit 2602d6b02cb4e97c5d7773309d542d4b4b0eaeb1
Author: linslee75 <[email protected]>
AuthorDate: Tue Nov 3 06:00:04 2026 +0800

    SOLR-18563: Apply additive Schema API changes without a core reload (#5194)
    
    Co-authored-by: David Smiley <[email protected]>
---
 .../SOLR-18563-additive-schema-api-no-reload.yml   |  12 ++
 .../java/org/apache/solr/schema/SchemaManager.java |  70 +++++++++
 .../apache/solr/rest/schema/TestBulkSchemaAPI.java |  55 +++++++
 .../apache/solr/schema/TestManagedSchemaAPI.java   | 160 +++++++++++++++++++++
 .../solr/schema/TestSchemaChangeDuringMerge.java   | 144 +++++++++++++++++++
 .../modules/indexing-guide/pages/schema-api.adoc   |   9 +-
 6 files changed, 448 insertions(+), 2 deletions(-)

diff --git a/changelog/unreleased/SOLR-18563-additive-schema-api-no-reload.yml 
b/changelog/unreleased/SOLR-18563-additive-schema-api-no-reload.yml
new file mode 100644
index 00000000000..05b3980ebb5
--- /dev/null
+++ b/changelog/unreleased/SOLR-18563-additive-schema-api-no-reload.yml
@@ -0,0 +1,12 @@
+title: >-
+  Schema API requests that contain only add-field, add-dynamic-field and 
add-copy-field no longer
+  reload the core. Indexing can use the new field as soon as the command 
returns. In standalone
+  mode queries can use it immediately. In SolrCloud, queries see it after the 
next commit or soft
+  commit. Other schema commands still reload the core.
+type: changed
+authors:
+  - name: Li Enlai
+    nick: linslee75
+links:
+  - name: SOLR-18563
+    url: https://issues.apache.org/jira/browse/SOLR-18563
diff --git a/solr/core/src/java/org/apache/solr/schema/SchemaManager.java 
b/solr/core/src/java/org/apache/solr/schema/SchemaManager.java
index 131e701199e..2a6e53ceaad 100644
--- a/solr/core/src/java/org/apache/solr/schema/SchemaManager.java
+++ b/solr/core/src/java/org/apache/solr/schema/SchemaManager.java
@@ -35,6 +35,8 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import org.apache.solr.client.api.model.AddCopyFieldOperation;
 import org.apache.solr.client.api.model.DeleteCopyFieldOperation;
@@ -106,15 +108,20 @@ public class SchemaManager {
 
   private List<Map<String, Object>> doOperations(List<SchemaChange> operations)
       throws InterruptedException, IOException, KeeperException {
+    if (operations.isEmpty()) {
+      return List.of();
+    }
     TimeOut timeOut = new TimeOut(updateTimeOut, TimeUnit.SECONDS, 
TimeSource.NANO_TIME);
     SolrCore core = req.getCore();
     String errorMsg = "Unable to persist managed schema. ";
     List<Map<String, Object>> errors = new ArrayList<>();
     int latestVersion = -1;
+    boolean appliedWithoutReload = false;
 
     synchronized (req.getSchema().getSchemaUpdateLock()) {
       while (!timeOut.hasTimedOut()) {
         managedIndexSchema = getFreshManagedSchema(req.getCore());
+        boolean additiveOnly = canApplyWithoutReload(operations, 
managedIndexSchema);
         for (SchemaChange op : operations) {
           OpType opType = OpType.get(op.operationType);
           try {
@@ -129,6 +136,21 @@ public class SchemaManager {
           }
         }
         if (!errors.isEmpty()) break;
+        if (additiveOnly) {
+          try {
+            managedIndexSchema.persistManagedSchema(false);
+            latestVersion = managedIndexSchema.getSchemaZkVersion();
+            core.setLatestSchema(managedIndexSchema);
+            appliedWithoutReload = true;
+          } catch (ManagedIndexSchema.SchemaChangedInZkException e) {
+            log.info("Schema was modified by another node. Retrying..");
+            continue;
+          } catch (SolrException e) {
+            log.warn(errorMsg);
+            errors = List.of(Map.of(CommandOperation.ERR_MSGS, errorMsg + 
e.getMessage()));
+          }
+          break;
+        }
         SolrResourceLoader loader = req.getCore().getResourceLoader();
         if (loader instanceof ZkSolrResourceLoader zkLoader) {
           StringWriter sw = new StringWriter();
@@ -184,6 +206,9 @@ public class SchemaManager {
       // replicas. This reduces the likelihood of a (time-limited) distributed 
deadlock during
       // concurrent schema updates.
       waitForOtherReplicasToUpdate(timeOut, latestVersion);
+    } else if (appliedWithoutReload) {
+      // Outside the schema update lock: autowarming can take a long time.
+      openSearcherWithLatestSchema(core);
     }
     if (errors.isEmpty() && timeOut.hasTimedOut()) {
       log.warn("{} Timed out", errorMsg);
@@ -192,6 +217,51 @@ public class SchemaManager {
     return errors;
   }
 
+  /**
+   * Whether the batch only adds schema elements, so it can be applied without 
a core reload. The v2
+   * single-field endpoint tags additions as upsert-field; such an operation 
is additive only when
+   * its target is absent from the schema this round started with. 
Classification is against that
+   * round-start schema, not the batch's intermediate state: what matters for 
the reload decision is
+   * the net effect on the schema the core had before the request.
+   */
+  private static boolean canApplyWithoutReload(List<SchemaChange> operations, 
IndexSchema schema) {
+    for (SchemaChange op : operations) {
+      OpType opType = OpType.get(op.operationType);
+      if (opType == OpType.UPSERT_FIELD) {
+        // note: explicit includes dynamic fields
+        opType =
+            schema.hasExplicitField(((UpsertFieldOperation) op).name)
+                ? OpType.REPLACE_FIELD
+                : OpType.ADD_FIELD;
+      }
+      if (opType != OpType.ADD_FIELD
+          && opType != OpType.ADD_DYNAMIC_FIELD
+          && opType != OpType.ADD_COPY_FIELD) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Lets queries see the new schema without a commit in standalone mode 
(SOLR-13795). In SolrCloud,
+   * replicas pick it up on their next searcher reopen.
+   */
+  private static void openSearcherWithLatestSchema(SolrCore core) throws 
InterruptedException {
+    Future<?>[] waitSearcher = new Future<?>[1];
+    try {
+      core.getSearcher(true, false, waitSearcher);
+      if (waitSearcher[0] != null) {
+        waitSearcher[0].get();
+      }
+    } catch (ExecutionException | SolrException e) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Schema was persisted, but the searcher could not be reopened",
+          e);
+    }
+  }
+
   private void waitForOtherReplicasToUpdate(TimeOut timeOut, int 
latestVersion) {
     SolrCore core = req.getCore();
     CoreDescriptor cd = core.getCoreDescriptor();
diff --git 
a/solr/core/src/test/org/apache/solr/rest/schema/TestBulkSchemaAPI.java 
b/solr/core/src/test/org/apache/solr/rest/schema/TestBulkSchemaAPI.java
index def647fe426..16f510a02d4 100644
--- a/solr/core/src/test/org/apache/solr/rest/schema/TestBulkSchemaAPI.java
+++ b/solr/core/src/test/org/apache/solr/rest/schema/TestBulkSchemaAPI.java
@@ -29,6 +29,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
+import java.util.UUID;
 import java.util.function.Consumer;
 import org.apache.commons.io.file.PathUtils;
 import org.apache.lucene.misc.SweetSpotSimilarity;
@@ -42,6 +43,7 @@ import org.apache.solr.common.SolrDocumentList;
 import org.apache.solr.core.CoreContainer;
 import org.apache.solr.core.SolrCore;
 import org.apache.solr.schema.AbstractSpatialPrefixTreeFieldType;
+import org.apache.solr.schema.IndexSchema;
 import org.apache.solr.schema.SimilarityFactory;
 import org.apache.solr.search.similarities.SchemaSimilarityFactory;
 import org.apache.solr.util.LogListener;
@@ -1232,6 +1234,59 @@ public class TestBulkSchemaAPI extends RestTestBase {
     assertEquals(1, size);
   }
 
+  @Test
+  public void testAdditiveChangesDoNotReloadCore() throws Exception {
+    getSolrClient().add(Arrays.asList(sdoc("id", "1", "term_s", "tux")));
+    getSolrClient().commit(true, true);
+    UUID coreIdBefore = coreUniqueId();
+
+    new SchemaRequest.AddField(Map.of("name", "live_field", "type", "string"))
+        .process(getSolrClient());
+    new SchemaRequest.AddDynamicField(Map.of("name", "*_live", "type", 
"string"))
+        .process(getSolrClient());
+    new SchemaRequest.AddCopyField("live_field", 
List.of("copy_live")).process(getSolrClient());
+
+    assertEquals(coreIdBefore, coreUniqueId());
+    try (SolrCore core = 
solrTestRule.getCoreContainer().getCore("collection1")) {
+      IndexSchema latest = core.getLatestSchema();
+      assertNotNull(latest.getFieldOrNull("live_field"));
+      assertNotNull(latest.getFieldOrNull("color_live"));
+      assertEquals(1, latest.getCopyFieldsList("live_field").size());
+      assertSame(latest, core.withSearcher(s -> s.getSchema()));
+    }
+
+    SolrQuery query = new SolrQuery("*:*");
+    query.addFacetField("live_field");
+    query.addFacetField("color_live");
+    assertEquals(1, getSolrClient().query(query).getResults().getNumFound());
+  }
+
+  @Test
+  public void testMixedCommandsStillReloadCore() throws Exception {
+    UUID coreIdBefore = coreUniqueId();
+    assertJPost(
+        "/schema",
+        "{\n"
+            + "  
'add-field-type':{'name':'mixed_type','class':'solr.StrField'},\n"
+            + "  'add-field':{'name':'mixed_field','type':'mixed_type'}\n"
+            + "}",
+        "/responseHeader/status==0");
+    assertNotEquals(coreIdBefore, coreUniqueId());
+  }
+
+  @Test
+  public void testEmptyRequestDoesNotReloadCore() throws Exception {
+    UUID coreIdBefore = coreUniqueId();
+    assertJPost("/schema", "{}", "/responseHeader/status==0");
+    assertEquals(coreIdBefore, coreUniqueId());
+  }
+
+  private static UUID coreUniqueId() {
+    try (SolrCore core = 
solrTestRule.getCoreContainer().getCore("collection1")) {
+      return core.uniqueId;
+    }
+  }
+
   @Test
   public void testDateRangeFieldDefaults() throws Exception {
     try (LogListener listener = 
LogListener.warn(AbstractSpatialPrefixTreeFieldType.class)) {
diff --git 
a/solr/core/src/test/org/apache/solr/schema/TestManagedSchemaAPI.java 
b/solr/core/src/test/org/apache/solr/schema/TestManagedSchemaAPI.java
index 2168c55c340..cbb1c1d556f 100644
--- a/solr/core/src/test/org/apache/solr/schema/TestManagedSchemaAPI.java
+++ b/solr/core/src/test/org/apache/solr/schema/TestManagedSchemaAPI.java
@@ -19,17 +19,26 @@ package org.apache.solr.schema;
 
 import java.io.IOException;
 import java.lang.invoke.MethodHandles;
+import java.util.HashMap;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.UUID;
+import java.util.function.Predicate;
 import org.apache.solr.client.solrj.SolrServerException;
 import org.apache.solr.client.solrj.impl.CloudSolrClient;
 import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.SolrQuery;
 import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.client.solrj.request.V2Request;
 import org.apache.solr.client.solrj.request.schema.SchemaRequest;
 import org.apache.solr.client.solrj.response.CollectionAdminResponse;
+import org.apache.solr.client.solrj.response.V2Response;
 import org.apache.solr.client.solrj.response.schema.SchemaResponse;
 import org.apache.solr.cloud.SolrCloudTestCase;
 import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.cloud.Replica;
+import org.apache.solr.core.SolrCore;
 import org.junit.BeforeClass;
 import org.junit.Test;
 import org.slf4j.Logger;
@@ -57,6 +66,157 @@ public class TestManagedSchemaAPI extends SolrCloudTestCase 
{
     testAddFieldAndDocument(collection);
   }
 
+  @Test
+  public void testAddFieldDoesNotReloadCore() throws Exception {
+    String collection = "addfield_noreload";
+    CloudSolrClient client = cluster.getSolrClient();
+    CollectionAdminRequest.createCollection(collection, "conf1", 1, 
2).process(client);
+    cluster.waitForActiveCollection(collection, 1, 2);
+    Map<String, UUID> coreIdsBefore = snapshotCoreIds(collection);
+
+    addStringField("live_field", collection, client);
+    // a reload creates a new SolrCore with a new uniqueId
+    assertEquals(coreIdsBefore, snapshotCoreIds(collection));
+    assertEveryReplicaLatestSchema(collection, s -> 
s.getFieldOrNull("live_field") != null);
+
+    client.add(collection, sdoc("id", "live-1", "live_field", "v"));
+    client.commit(collection);
+    assertEquals(
+        1, client.query(collection, new 
SolrQuery("live_field:v")).getResults().getNumFound());
+  }
+
+  @Test
+  public void testAddDynamicFieldAndCopyFieldDoNotReloadCore() throws 
Exception {
+    String collection = "adddynamic_copy_noreload";
+    CloudSolrClient client = cluster.getSolrClient();
+    CollectionAdminRequest.createCollection(collection, "conf1", 1, 
2).process(client);
+    cluster.waitForActiveCollection(collection, 1, 2);
+    Map<String, UUID> coreIdsBefore = snapshotCoreIds(collection);
+
+    assertSchemaUpdateSucceeded(
+        new SchemaRequest.AddDynamicField(Map.of("name", "*_live", "type", 
"string"))
+            .process(client, collection));
+    assertEquals(coreIdsBefore, snapshotCoreIds(collection));
+    assertEveryReplicaLatestSchema(collection, s -> 
s.getFieldOrNull("color_live") != null);
+
+    addStringField("copy_src", collection, client);
+    addStringField("copy_dst", collection, client);
+    assertSchemaUpdateSucceeded(
+        new SchemaRequest.AddCopyField("copy_src", List.of("copy_dst"))
+            .process(client, collection));
+    assertEquals(coreIdsBefore, snapshotCoreIds(collection));
+    assertEveryReplicaLatestSchema(
+        collection,
+        s ->
+            s.getCopyFieldsList("copy_src").stream()
+                .anyMatch(cf -> 
cf.getDestination().getName().equals("copy_dst")));
+
+    client.add(collection, sdoc("id", "live-2", "color_live", "red", 
"copy_src", "blue"));
+    client.commit(collection);
+    assertEquals(
+        1, client.query(collection, new 
SolrQuery("color_live:red")).getResults().getNumFound());
+    assertEquals(
+        1, client.query(collection, new 
SolrQuery("copy_dst:blue")).getResults().getNumFound());
+  }
+
+  @Test
+  public void testDeleteFieldStillReloadsCore() throws Exception {
+    String collection = "deletefield_reload";
+    CloudSolrClient client = cluster.getSolrClient();
+    CollectionAdminRequest.createCollection(collection, "conf1", 1, 
2).process(client);
+    cluster.waitForActiveCollection(collection, 1, 2);
+    addStringField("doomed_field", collection, client);
+    Map<String, UUID> coreIdsBefore = snapshotCoreIds(collection);
+
+    assertSchemaUpdateSucceeded(
+        new SchemaRequest.DeleteField("doomed_field").process(client, 
collection));
+
+    // The core handling the request reloads before responding. Whether other 
replicas also reload
+    // depends on whether their schema watch fires before the conf dir 
listener.
+    Map<String, UUID> coreIdsAfter = snapshotCoreIds(collection);
+    assertTrue(
+        coreIdsBefore.keySet().stream()
+            .anyMatch(name -> 
!coreIdsBefore.get(name).equals(coreIdsAfter.get(name))));
+    assertEveryReplicaLatestSchema(collection, s -> 
s.getFieldOrNull("doomed_field") == null);
+  }
+
+  @Test
+  public void testV2AddFieldDoesNotReloadCore() throws Exception {
+    String collection = "v2_addfield_noreload";
+    CloudSolrClient client = cluster.getSolrClient();
+    CollectionAdminRequest.createCollection(collection, "conf1", 1, 
2).process(client);
+    cluster.waitForActiveCollection(collection, 1, 2);
+    Map<String, UUID> coreIdsBefore = snapshotCoreIds(collection);
+
+    v2AddField(collection, "v2_live", Map.of("type", "string"));
+    assertEquals(coreIdsBefore, snapshotCoreIds(collection));
+    assertEveryReplicaLatestSchema(collection, s -> 
s.getFieldOrNull("v2_live") != null);
+
+    client.add(collection, sdoc("id", "v2-1", "v2_live", "v"));
+    client.commit(collection);
+    assertEquals(
+        1, client.query(collection, new 
SolrQuery("v2_live:v")).getResults().getNumFound());
+  }
+
+  @Test
+  public void testV2UpsertExistingFieldStillReloadsCore() throws Exception {
+    String collection = "v2_upsert_reload";
+    CloudSolrClient client = cluster.getSolrClient();
+    CollectionAdminRequest.createCollection(collection, "conf1", 1, 
2).process(client);
+    cluster.waitForActiveCollection(collection, 1, 2);
+    addStringField("existing_field", collection, client);
+    Map<String, UUID> coreIdsBefore = snapshotCoreIds(collection);
+
+    v2AddField(collection, "existing_field", Map.of("type", "string", 
"stored", true));
+
+    // The core handling the request reloads before responding. Whether other 
replicas also reload
+    // depends on whether their schema watch fires before the conf dir 
listener.
+    Map<String, UUID> coreIdsAfter = snapshotCoreIds(collection);
+    assertTrue(
+        coreIdsBefore.keySet().stream()
+            .anyMatch(name -> 
!coreIdsBefore.get(name).equals(coreIdsAfter.get(name))));
+    assertEveryReplicaLatestSchema(collection, s -> 
s.getFieldOrNull("existing_field").stored());
+  }
+
+  private void v2AddField(String collection, String fieldName, Map<String, 
Object> body)
+      throws IOException, SolrServerException {
+    V2Response rsp =
+        new V2Request.Builder("/collections/" + collection + "/schema/fields/" 
+ fieldName)
+            .PUT()
+            .withPayload(body)
+            .build()
+            .process(cluster.getSolrClient());
+    assertEquals(0, rsp.getStatus());
+  }
+
+  private void assertEveryReplicaLatestSchema(String collection, 
Predicate<IndexSchema> expectation)
+      throws IOException {
+    for (Replica replica : 
getCollectionState(collection).replicaStream().toList()) {
+      try (SolrCore core =
+          
cluster.getReplicaJetty(replica).getCoreContainer().getCore(replica.getCoreName()))
 {
+        assertNotNull(core);
+        assertTrue(replica.getName(), 
expectation.test(core.getLatestSchema()));
+      }
+    }
+  }
+
+  private static void 
assertSchemaUpdateSucceeded(SchemaResponse.UpdateResponse rsp) {
+    assertEquals(0, rsp.getStatus());
+    assertNull(rsp.getResponse().get("errors"));
+  }
+
+  private Map<String, UUID> snapshotCoreIds(String collection) {
+    Map<String, UUID> ids = new HashMap<>();
+    for (Replica replica : 
getCollectionState(collection).replicaStream().toList()) {
+      try (SolrCore core =
+          
cluster.getReplicaJetty(replica).getCoreContainer().getCore(replica.getCoreName()))
 {
+        assertNotNull(core);
+        ids.put(replica.getName(), core.uniqueId);
+      }
+    }
+    return ids;
+  }
+
   private void testReloadAndAddSimple(String collection) throws IOException, 
SolrServerException {
     CloudSolrClient cloudClient = cluster.getSolrClient();
 
diff --git 
a/solr/core/src/test/org/apache/solr/schema/TestSchemaChangeDuringMerge.java 
b/solr/core/src/test/org/apache/solr/schema/TestSchemaChangeDuringMerge.java
new file mode 100644
index 00000000000..2500a3718e2
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/schema/TestSchemaChangeDuringMerge.java
@@ -0,0 +1,144 @@
+/*
+ * 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.schema;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.apache.commons.io.file.PathUtils;
+import org.apache.lucene.index.MergePolicy;
+import org.apache.lucene.index.MergeScheduler;
+import org.apache.lucene.index.MergeTrigger;
+import org.apache.solr.client.solrj.request.schema.SchemaRequest;
+import org.apache.solr.common.util.ExecutorUtil;
+import org.apache.solr.common.util.SolrNamedThreadFactory;
+import org.apache.solr.common.util.TimeSource;
+import org.apache.solr.index.LogDocMergePolicyFactory;
+import org.apache.solr.util.RestTestBase;
+import org.apache.solr.util.TimeOut;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * A Schema API add-field must not block indexing while a long merge is 
running. A core reload
+ * replaces the IndexWriter, and closing the old one waits for running merges 
while holding the lock
+ * every update needs. A merge that never finishes on its own stands in for a 
slow one, such as
+ * merging large HNSW vector segments.
+ */
+public class TestSchemaChangeDuringMerge extends RestTestBase {
+  private static volatile CountDownLatch mergeStarted;
+  private static volatile CountDownLatch writerClosing;
+  private static volatile CountDownLatch releaseMerges;
+  private static volatile ExecutorService mergeExecutor;
+
+  private ExecutorService executor;
+
+  @Before
+  public void before() throws Exception {
+    mergeStarted = new CountDownLatch(1);
+    writerClosing = new CountDownLatch(1);
+    releaseMerges = new CountDownLatch(1);
+    mergeExecutor =
+        ExecutorUtil.newMDCAwareCachedThreadPool(new 
SolrNamedThreadFactory("blockingMerge"));
+    executor = ExecutorUtil.newMDCAwareCachedThreadPool(new 
SolrNamedThreadFactory("client"));
+
+    Path tmpSolrHome = createTempDir();
+    PathUtils.copyDirectory(TEST_HOME(), tmpSolrHome);
+    System.setProperty("managed.schema.mutable", "true");
+    System.setProperty("solr.index.updatelog.enabled", "false");
+    System.setProperty("solr.tests.mergeScheduler", 
BlockingMergeScheduler.class.getName());
+    System.setProperty("solr.tests.mergePolicyFactory", 
LogDocMergePolicyFactory.class.getName());
+    createJettyAndHarness(tmpSolrHome, "solrconfig-managed-schema.xml", 
"schema-rest.xml");
+  }
+
+  @After
+  public void after() throws Exception {
+    releaseMerges.countDown();
+    ExecutorUtil.shutdownAndAwaitTermination(executor);
+    solrTestRule.reset();
+    ExecutorUtil.shutdownAndAwaitTermination(mergeExecutor);
+    mergeExecutor = null;
+  }
+
+  @Test
+  public void testIndexingIsNotBlockedByAddFieldDuringMerge() throws Exception 
{
+    // LogDocMergePolicy merges once mergeFactor (10) segments of the same 
level exist.
+    for (int i = 0; i < 10; i++) {
+      getSolrClient().add(sdoc("id", "seg" + i));
+      getSolrClient().commit();
+    }
+    assertTrue("merge did not start", mergeStarted.await(30, 
TimeUnit.SECONDS));
+    // Core creation already closed a writer that initialized the empty index.
+    writerClosing = new CountDownLatch(1);
+
+    Future<?> addField =
+        executor.submit(
+            () ->
+                new SchemaRequest.AddField(Map.of("name", "new_field", "type", 
"string"))
+                    .process(getSolrClient()));
+
+    TimeOut timeOut = new TimeOut(30, TimeUnit.SECONDS, TimeSource.NANO_TIME);
+    timeOut.waitFor(
+        "add-field neither returned nor started closing the IndexWriter",
+        () -> addField.isDone() || writerClosing.getCount() == 0);
+
+    Future<?> update = executor.submit(() -> getSolrClient().add(sdoc("id", 
"during-merge")));
+    try {
+      update.get(10, TimeUnit.SECONDS);
+    } catch (TimeoutException e) {
+      fail(
+          "Indexing is blocked until the running merge finishes: add-field 
reloads the core, "
+              + "which waits for merges while closing the old IndexWriter");
+    }
+    addField.get(30, TimeUnit.SECONDS);
+  }
+
+  /** Holds every merge until the test releases it. */
+  public static class BlockingMergeScheduler extends MergeScheduler {
+    @Override
+    public void merge(MergeSource mergeSource, MergeTrigger trigger) {
+      if (trigger == MergeTrigger.CLOSING) {
+        writerClosing.countDown();
+      }
+      MergePolicy.OneMerge merge;
+      while ((merge = mergeSource.getNextMerge()) != null) {
+        MergePolicy.OneMerge oneMerge = merge;
+        mergeExecutor.execute(
+            () -> {
+              mergeStarted.countDown();
+              try {
+                releaseMerges.await();
+              } catch (InterruptedException ignored) {
+                // the merge must still run, or closing the IndexWriter hangs
+              }
+              try {
+                mergeSource.merge(oneMerge);
+              } catch (IOException e) {
+                throw new UncheckedIOException(e);
+              }
+            });
+      }
+    }
+  }
+}
diff --git a/solr/solr-ref-guide/modules/indexing-guide/pages/schema-api.adoc 
b/solr/solr-ref-guide/modules/indexing-guide/pages/schema-api.adoc
index af97bf91b8a..ec6a818f4c0 100644
--- a/solr/solr-ref-guide/modules/indexing-guide/pages/schema-api.adoc
+++ b/solr/solr-ref-guide/modules/indexing-guide/pages/schema-api.adoc
@@ -42,7 +42,11 @@ Prior to Solr 9, this xml file was referred to as 
`managed-schema` with no file
 The API allows two output modes for all calls: JSON or XML.
 When requesting the complete schema, there is another output mode which is XML 
modeled after the managed-schema.xml file itself, which is in XML format.
 
-When modifying the schema with the API, a core reload will automatically occur 
in order for the changes to be available immediately for documents indexed 
thereafter.
+A request containing only `add-field`, `add-dynamic-field`, and 
`add-copy-field` commands is applied without a core reload, which would 
otherwise pause indexing.
+Documents indexed after the command returns can use the new schema elements 
immediately.
+In user-managed (standalone) mode, queries can use them as soon as the command 
returns.
+In SolrCloud, queries can use them after the next commit or soft commit opens 
a new searcher.
+Any other command, including a request mixing other commands with these, 
reloads the core, so avoid issuing those while the collection is under 
sustained indexing load.
 Previously indexed documents will *not* be automatically updated - they *must* 
be reindexed if existing index data uses schema elements that you changed.
 
 .Reindex after schema modifications!
@@ -88,7 +92,8 @@ Commands are executed in the order in which they are 
specified.
 
 In each case, the response will include the status and the time to process the 
request, but will not include the entire schema.
 
-When modifying the schema with the API, a core reload will automatically occur 
in order for the changes to be available immediately for documents indexed 
thereafter.
+Requests containing only `add-field`, `add-dynamic-field`, and 
`add-copy-field` commands do not reload the core; other schema commands do.
+See <<Modify the Schema>> above for when the changes become visible to 
indexing and queries.
 Previously indexed documents will *not* be automatically handled - they *must* 
be reindexed if they used schema elements that you changed.
 
 === Add a New Field

Reply via email to