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