You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ge...@apache.org on 2024/01/08 17:35:38 UTC

(solr) branch branch_9x updated: SOLR-16397: Tweak v2 'MERGEINDEXES' API to be more REST-ful (#2128)

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

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


The following commit(s) were added to refs/heads/branch_9x by this push:
     new 1b29fc1acd8 SOLR-16397: Tweak v2 'MERGEINDEXES' API to be more REST-ful (#2128)
1b29fc1acd8 is described below

commit 1b29fc1acd8654838c4ddf8b579a90aad210c96c
Author: Sanjay Dutt <sa...@gmail.com>
AuthorDate: Mon Jan 8 22:57:34 2024 +0530

    SOLR-16397: Tweak v2 'MERGEINDEXES' API to be more REST-ful (#2128)
    
    This commit changes the v2 "mergeindices" API to be more in line
    with the REST-ful design we're targeting for Solr's v2 APIs.
    
    Following these changes, the v2 API now appears as:
        `POST /cores/targetCoreName/merge-indices {...}`
    
    This commit also converts the API to the JAX-RS framework.
    
    ---------
    
    Co-authored-by: iamsanjay <sa...@yahoo.com>
---
 solr/CHANGES.txt                                   |   3 +
 .../solr/client/api/endpoint/MergeIndexesApi.java  |  52 +++++
 .../client/api/model/MergeIndexesRequestBody.java  |  38 ++++
 .../solr/handler/admin/CoreAdminHandler.java       |   6 +-
 .../apache/solr/handler/admin/MergeIndexesOp.java  | 128 ++-----------
 .../solr/handler/admin/api/MergeIndexes.java       | 211 +++++++++++++++++++++
 .../solr/handler/admin/api/MergeIndexesAPI.java    |  88 ---------
 .../solr/handler/admin/api/MergeIndexesTest.java   | 115 +++++++++++
 .../handler/admin/api/V2CoreAPIMappingTest.java    |  27 ---
 .../configuration-guide/pages/coreadmin-api.adoc   |  53 +++++-
 10 files changed, 483 insertions(+), 238 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 0f731656493..c3c5577e571 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -61,6 +61,9 @@ Improvements
 
 * SOLR-15960: Unified use of system properties and environment variables (janhoy)
 
+* SOLR-16397: The MERGEINDEXES v2 endpoint has been updated to be more REST-ful.
+  MERGEINDEXES is now available at `POST /api/cores/coreName/merge-indices` (Sanjay Dutt via Jason Gerlowski)
+
 Optimizations
 ---------------------
 * SOLR-17084: LBSolrClient (used by CloudSolrClient) now returns the count of core tracked as not live AKA zombies
diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/MergeIndexesApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/MergeIndexesApi.java
new file mode 100644
index 00000000000..dbc913395de
--- /dev/null
+++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/MergeIndexesApi.java
@@ -0,0 +1,52 @@
+/*
+ * 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.client.api.endpoint;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import org.apache.solr.client.api.model.MergeIndexesRequestBody;
+import org.apache.solr.client.api.model.SolrJerseyResponse;
+
+/**
+ * V2 API for merging one or more indexes(either from multiple Solr cores or multiple index
+ * directories) to another index.
+ *
+ * <p>The new API (POST /api/cores/coreName/merge-indices {...}) is equivalent to the v1
+ * /admin/cores?action=mergeindexes command.
+ *
+ * @see MergeIndexesRequestBody
+ */
+@Path("/cores/{coreName}/merge-indices")
+public interface MergeIndexesApi {
+  @POST
+  @Operation(
+      summary = "The MERGEINDEXES action merges one or more indexes to another index.",
+      tags = {"cores"})
+  SolrJerseyResponse mergeIndexes(
+      @Parameter(
+              description = "The core that the specified indices are merged into.",
+              required = true)
+          @PathParam("coreName")
+          String coreName,
+      @RequestBody(description = "Additional properties for merge indexes.")
+          MergeIndexesRequestBody mergeIndexesRequestBody)
+      throws Exception;
+}
diff --git a/solr/api/src/java/org/apache/solr/client/api/model/MergeIndexesRequestBody.java b/solr/api/src/java/org/apache/solr/client/api/model/MergeIndexesRequestBody.java
new file mode 100644
index 00000000000..87b80109693
--- /dev/null
+++ b/solr/api/src/java/org/apache/solr/client/api/model/MergeIndexesRequestBody.java
@@ -0,0 +1,38 @@
+/*
+ * 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.client.api.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+
+/** Request body for endpoints {@link org.apache.solr.client.api.endpoint.MergeIndexesApi} */
+public class MergeIndexesRequestBody {
+  @Schema(description = "Multi-valued, directories that would be merged.", name = "indexDirs")
+  @JsonProperty
+  public List<String> indexDirs;
+
+  @Schema(description = "Multi-valued, source cores that would be merged.", name = "srcCores")
+  @JsonProperty
+  public List<String> srcCores;
+
+  @Schema(description = "Request ID to track this action which will be processed asynchronously.")
+  @JsonProperty
+  public String async;
+
+  @JsonProperty public String updateChain;
+}
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/CoreAdminHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/CoreAdminHandler.java
index e45a250f81b..415343422d1 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/CoreAdminHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/CoreAdminHandler.java
@@ -59,7 +59,7 @@ import org.apache.solr.handler.admin.api.CoreSnapshot;
 import org.apache.solr.handler.admin.api.CreateCoreAPI;
 import org.apache.solr.handler.admin.api.CreateCoreBackup;
 import org.apache.solr.handler.admin.api.InstallCoreData;
-import org.apache.solr.handler.admin.api.MergeIndexesAPI;
+import org.apache.solr.handler.admin.api.MergeIndexes;
 import org.apache.solr.handler.admin.api.OverseerOperationAPI;
 import org.apache.solr.handler.admin.api.PrepareCoreRecoveryAPI;
 import org.apache.solr.handler.admin.api.RejoinLeaderElectionAPI;
@@ -383,7 +383,6 @@ public class CoreAdminHandler extends RequestHandlerBase implements PermissionNa
     apis.addAll(AnnotatedApi.getApis(new CreateCoreAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new RejoinLeaderElectionAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new OverseerOperationAPI(this)));
-    apis.addAll(AnnotatedApi.getApis(new MergeIndexesAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new SplitCoreAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new RequestCoreCommandStatusAPI(this)));
     // Internal APIs
@@ -406,7 +405,8 @@ public class CoreAdminHandler extends RequestHandlerBase implements PermissionNa
         ReloadCore.class,
         UnloadCore.class,
         SwapCores.class,
-        RenameCore.class);
+        RenameCore.class,
+        MergeIndexes.class);
   }
 
   public interface CoreAdminOp {
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/MergeIndexesOp.java b/solr/core/src/java/org/apache/solr/handler/admin/MergeIndexesOp.java
index ff13c39a366..d1a6b4b8713 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/MergeIndexesOp.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/MergeIndexesOp.java
@@ -18,30 +18,14 @@
 package org.apache.solr.handler.admin;
 
 import java.lang.invoke.MethodHandles;
-import java.nio.file.Paths;
-import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.apache.lucene.index.DirectoryReader;
-import org.apache.lucene.store.Directory;
-import org.apache.lucene.util.IOUtils;
-import org.apache.solr.common.SolrException;
+import java.util.Optional;
+import org.apache.solr.client.api.model.MergeIndexesRequestBody;
 import org.apache.solr.common.params.CoreAdminParams;
 import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.common.params.UpdateParams;
-import org.apache.solr.core.CachingDirectoryFactory;
-import org.apache.solr.core.DirectoryFactory;
-import org.apache.solr.core.SolrCore;
-import org.apache.solr.request.LocalSolrQueryRequest;
-import org.apache.solr.request.SolrQueryRequest;
-import org.apache.solr.search.SolrIndexSearcher;
-import org.apache.solr.update.MergeIndexesCommand;
-import org.apache.solr.update.processor.UpdateRequestProcessor;
-import org.apache.solr.update.processor.UpdateRequestProcessorChain;
-import org.apache.solr.util.RefCounted;
+import org.apache.solr.handler.admin.api.MergeIndexes;
+import org.apache.solr.handler.api.V2ApiUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -52,98 +36,16 @@ class MergeIndexesOp implements CoreAdminHandler.CoreAdminOp {
   public void execute(CoreAdminHandler.CallInfo it) throws Exception {
     SolrParams params = it.req.getParams();
     String cname = params.required().get(CoreAdminParams.CORE);
-    SolrCore core = it.handler.coreContainer.getCore(cname);
-    SolrQueryRequest wrappedReq = null;
-    if (core == null) return;
-
-    List<SolrCore> sourceCores = new ArrayList<>();
-    List<RefCounted<SolrIndexSearcher>> searchers = new ArrayList<>();
-    // stores readers created from indexDir param values
-    List<DirectoryReader> readersToBeClosed = new ArrayList<>();
-    Map<Directory, Boolean> dirsToBeReleased = new HashMap<>();
-
-    try {
-      String[] dirNames = params.getParams(CoreAdminParams.INDEX_DIR);
-      if (dirNames == null || dirNames.length == 0) {
-        String[] sources = params.getParams("srcCore");
-        if (sources == null || sources.length == 0)
-          throw new SolrException(
-              SolrException.ErrorCode.BAD_REQUEST,
-              "At least one indexDir or srcCore must be specified");
-
-        for (int i = 0; i < sources.length; i++) {
-          String source = sources[i];
-          SolrCore srcCore = it.handler.coreContainer.getCore(source);
-          if (srcCore == null)
-            throw new SolrException(
-                SolrException.ErrorCode.BAD_REQUEST, "Core: " + source + " does not exist");
-          sourceCores.add(srcCore);
-        }
-      } else {
-        // Validate each 'indexDir' input as valid
-        Arrays.stream(dirNames)
-            .forEach(indexDir -> core.getCoreContainer().assertPathAllowed(Paths.get(indexDir)));
-        DirectoryFactory dirFactory = core.getDirectoryFactory();
-        for (int i = 0; i < dirNames.length; i++) {
-          boolean markAsDone = false;
-          if (dirFactory instanceof CachingDirectoryFactory) {
-            if (!((CachingDirectoryFactory) dirFactory).getLivePaths().contains(dirNames[i])) {
-              markAsDone = true;
-            }
-          }
-          Directory dir =
-              dirFactory.get(
-                  dirNames[i],
-                  DirectoryFactory.DirContext.DEFAULT,
-                  core.getSolrConfig().indexConfig.lockType);
-          dirsToBeReleased.put(dir, markAsDone);
-          // TODO: why doesn't this use the IR factory? what is going on here?
-          readersToBeClosed.add(DirectoryReader.open(dir));
-        }
-      }
-
-      List<DirectoryReader> readers = null;
-      if (readersToBeClosed.size() > 0) {
-        readers = readersToBeClosed;
-      } else {
-        readers = new ArrayList<>();
-        for (SolrCore solrCore : sourceCores) {
-          // record the searchers so that we can decref
-          RefCounted<SolrIndexSearcher> searcher = solrCore.getSearcher();
-          searchers.add(searcher);
-          readers.add(searcher.get().getRawReader());
-        }
-      }
-
-      UpdateRequestProcessorChain processorChain =
-          core.getUpdateProcessingChain(params.get(UpdateParams.UPDATE_CHAIN));
-      wrappedReq = new LocalSolrQueryRequest(core, it.req.getParams());
-      UpdateRequestProcessor processor = processorChain.createProcessor(wrappedReq, it.rsp);
-      processor.processMergeIndexes(new MergeIndexesCommand(readers, it.req));
-    } catch (Exception e) {
-      // log and rethrow so that if the finally fails we don't lose the original problem
-      log.error("ERROR executing merge:", e);
-      throw e;
-    } finally {
-      for (RefCounted<SolrIndexSearcher> searcher : searchers) {
-        if (searcher != null) searcher.decref();
-      }
-      for (SolrCore solrCore : sourceCores) {
-        if (solrCore != null) solrCore.close();
-      }
-      IOUtils.closeWhileHandlingException(readersToBeClosed);
-      Set<Map.Entry<Directory, Boolean>> entries = dirsToBeReleased.entrySet();
-      for (Map.Entry<Directory, Boolean> entry : entries) {
-        DirectoryFactory dirFactory = core.getDirectoryFactory();
-        Directory dir = entry.getKey();
-        boolean markAsDone = entry.getValue();
-        if (markAsDone) {
-          dirFactory.doneWithDirectory(dir);
-        }
-        dirFactory.release(dir);
-      }
-      if (wrappedReq != null) wrappedReq.close();
-      core.close();
-    }
+    final var requestBody = new MergeIndexesRequestBody();
+    Optional.ofNullable(params.getParams(CoreAdminParams.INDEX_DIR))
+        .ifPresent(val -> requestBody.indexDirs = Arrays.asList(val));
+    Optional.ofNullable(params.getParams(CoreAdminParams.SRC_CORE))
+        .ifPresent(val -> requestBody.srcCores = Arrays.asList(val));
+    requestBody.updateChain = params.get(UpdateParams.UPDATE_CHAIN);
+    final var mergeIndexesApi =
+        new MergeIndexes(
+            it.handler.coreContainer, it.handler.coreAdminAsyncTracker, it.req, it.rsp);
+    final var response = mergeIndexesApi.mergeIndexes(cname, requestBody);
+    V2ApiUtils.squashIntoSolrResponseWithoutHeader(it.rsp, response);
   }
 }
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/MergeIndexes.java b/solr/core/src/java/org/apache/solr/handler/admin/api/MergeIndexes.java
new file mode 100644
index 00000000000..ff93b3a2b23
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/MergeIndexes.java
@@ -0,0 +1,211 @@
+/*
+ * 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.handler.admin.api;
+
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import javax.inject.Inject;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.util.IOUtils;
+import org.apache.solr.client.api.endpoint.MergeIndexesApi;
+import org.apache.solr.client.api.model.MergeIndexesRequestBody;
+import org.apache.solr.client.api.model.SolrJerseyResponse;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.CachingDirectoryFactory;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.core.DirectoryFactory;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.handler.admin.CoreAdminHandler;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.request.LocalSolrQueryRequest;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.search.SolrIndexSearcher;
+import org.apache.solr.security.PermissionNameProvider;
+import org.apache.solr.update.MergeIndexesCommand;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorChain;
+import org.apache.solr.util.RefCounted;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Implementation of V2 API interface {@link MergeIndexesApi} for merging one or more indexes to
+ * another index.
+ *
+ * @see MergeIndexesApi
+ * @see MergeIndexesRequestBody
+ * @see CoreAdminAPIBase
+ */
+public class MergeIndexes extends CoreAdminAPIBase implements MergeIndexesApi {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  @Inject
+  public MergeIndexes(
+      CoreContainer coreContainer,
+      CoreAdminHandler.CoreAdminAsyncTracker coreAdminAsyncTracker,
+      SolrQueryRequest req,
+      SolrQueryResponse rsp) {
+    super(coreContainer, coreAdminAsyncTracker, req, rsp);
+  }
+
+  @Override
+  public boolean isExpensive() {
+    return true;
+  }
+
+  @Override
+  @PermissionName(PermissionNameProvider.Name.CORE_EDIT_PERM)
+  public SolrJerseyResponse mergeIndexes(String coreName, MergeIndexesRequestBody requestBody)
+      throws Exception {
+    ensureRequiredParameterProvided("coreName", coreName);
+    SolrJerseyResponse response = instantiateJerseyResponse(SolrJerseyResponse.class);
+
+    return handlePotentiallyAsynchronousTask(
+        response,
+        coreName,
+        requestBody.async,
+        "merge-indices",
+        () -> {
+          try {
+            SolrCore core = coreContainer.getCore(coreName);
+            SolrQueryRequest wrappedReq = null;
+            if (core == null) return response;
+
+            List<SolrCore> sourceCores = new ArrayList<>();
+            List<RefCounted<SolrIndexSearcher>> searchers = new ArrayList<>();
+            // stores readers created from indexDir param values
+            List<DirectoryReader> readersToBeClosed = new ArrayList<>();
+            Map<Directory, Boolean> dirsToBeReleased = new HashMap<>();
+
+            try {
+              var dirNames =
+                  Optional.ofNullable(requestBody.indexDirs).orElseGet(() -> new ArrayList<>());
+              if (dirNames.isEmpty()) {
+                var sources =
+                    Optional.ofNullable(requestBody.srcCores).orElseGet(() -> new ArrayList<>());
+                if (sources.isEmpty())
+                  throw new SolrException(
+                      SolrException.ErrorCode.BAD_REQUEST,
+                      "At least one indexDir or srcCore must be specified");
+                sources.stream()
+                    .forEach(
+                        src -> {
+                          String source = src;
+                          SolrCore srcCore = coreContainer.getCore(source);
+                          if (srcCore == null)
+                            throw new SolrException(
+                                SolrException.ErrorCode.BAD_REQUEST,
+                                "Core: " + source + " does not exist");
+                          sourceCores.add(srcCore);
+                        });
+              } else {
+                // Validate each 'indexDir' input as valid
+                dirNames.stream()
+                    .forEach(
+                        indexDir -> core.getCoreContainer().assertPathAllowed(Paths.get(indexDir)));
+                DirectoryFactory dirFactory = core.getDirectoryFactory();
+                dirNames.stream()
+                    .forEach(
+                        dir -> {
+                          boolean markAsDone = false;
+                          if (dirFactory instanceof CachingDirectoryFactory) {
+                            if (!((CachingDirectoryFactory) dirFactory)
+                                .getLivePaths()
+                                .contains(dir)) {
+                              markAsDone = true;
+                            }
+                          }
+                          try {
+                            Directory dirTemp =
+                                dirFactory.get(
+                                    dir,
+                                    DirectoryFactory.DirContext.DEFAULT,
+                                    core.getSolrConfig().indexConfig.lockType);
+                            dirsToBeReleased.put(dirTemp, markAsDone);
+                            // TODO: why doesn't this use the IR factory? what is going on here?
+                            readersToBeClosed.add(DirectoryReader.open(dirTemp));
+                          } catch (IOException e) {
+                            throw new RuntimeException(e);
+                          }
+                        });
+              }
+
+              List<DirectoryReader> readers = null;
+              if (readersToBeClosed.size() > 0) {
+                readers = readersToBeClosed;
+              } else {
+                readers = new ArrayList<>();
+                for (SolrCore solrCore : sourceCores) {
+                  // record the searchers so that we can decref
+                  RefCounted<SolrIndexSearcher> searcher = solrCore.getSearcher();
+                  searchers.add(searcher);
+                  readers.add(searcher.get().getRawReader());
+                }
+              }
+
+              UpdateRequestProcessorChain processorChain =
+                  core.getUpdateProcessingChain(requestBody.updateChain);
+              wrappedReq = new LocalSolrQueryRequest(core, req.getParams());
+              UpdateRequestProcessor processor = processorChain.createProcessor(wrappedReq, rsp);
+              processor.processMergeIndexes(new MergeIndexesCommand(readers, req));
+            } catch (Exception e) {
+              // log and rethrow so that if the finally fails we don't lose the original problem
+              log.error("ERROR executing merge:", e);
+              throw e;
+            } finally {
+              for (RefCounted<SolrIndexSearcher> searcher : searchers) {
+                if (searcher != null) searcher.decref();
+              }
+              for (SolrCore solrCore : sourceCores) {
+                if (solrCore != null) solrCore.close();
+              }
+              IOUtils.closeWhileHandlingException(readersToBeClosed);
+              Set<Map.Entry<Directory, Boolean>> entries = dirsToBeReleased.entrySet();
+              for (Map.Entry<Directory, Boolean> entry : entries) {
+                DirectoryFactory dirFactory = core.getDirectoryFactory();
+                Directory dir = entry.getKey();
+                boolean markAsDone = entry.getValue();
+                if (markAsDone) {
+                  dirFactory.doneWithDirectory(dir);
+                }
+                dirFactory.release(dir);
+              }
+              if (wrappedReq != null) wrappedReq.close();
+              core.close();
+            }
+            return response;
+          } catch (SolrException exp) {
+            throw exp;
+          } catch (Exception e) {
+            throw new SolrException(
+                SolrException.ErrorCode.SERVER_ERROR,
+                "Failed to Merge Indexes=" + coreName + " because " + e,
+                e);
+          }
+        });
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/MergeIndexesAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/MergeIndexesAPI.java
deleted file mode 100644
index b99141a9047..00000000000
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/MergeIndexesAPI.java
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- * 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.handler.admin.api;
-
-import static org.apache.solr.client.solrj.SolrRequest.METHOD.POST;
-import static org.apache.solr.handler.ClusterAPI.wrapParams;
-import static org.apache.solr.security.PermissionNameProvider.Name.CORE_EDIT_PERM;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import org.apache.solr.api.Command;
-import org.apache.solr.api.EndPoint;
-import org.apache.solr.api.PayloadObj;
-import org.apache.solr.common.annotation.JsonProperty;
-import org.apache.solr.common.params.CoreAdminParams;
-import org.apache.solr.common.params.UpdateParams;
-import org.apache.solr.common.util.ReflectMapWriter;
-import org.apache.solr.handler.admin.CoreAdminHandler;
-
-/**
- * V2 API for merging one or more Solr cores into the target core.
- *
- * <p>The new API (POST /v2/cores/coreName {'merge-indexes': {...}}) is equivalent to the v1
- * /admin/cores?action=mergeindexes command.
- */
-@EndPoint(
-    path = {"/cores/{core}"},
-    method = POST,
-    permission = CORE_EDIT_PERM)
-public class MergeIndexesAPI {
-  private static final String V2_MERGE_INDEXES_CORE_CMD = "merge-indexes";
-
-  private final CoreAdminHandler coreHandler;
-
-  public MergeIndexesAPI(CoreAdminHandler coreHandler) {
-    this.coreHandler = coreHandler;
-  }
-
-  @Command(name = V2_MERGE_INDEXES_CORE_CMD)
-  public void mergeIndexesIntoCore(PayloadObj<MergeIndexesPayload> obj) throws Exception {
-    final MergeIndexesPayload v2Body = obj.get();
-    final Map<String, Object> v1Params = v2Body.toMap(new HashMap<>());
-    v1Params.put(
-        CoreAdminParams.ACTION,
-        CoreAdminParams.CoreAdminAction.MERGEINDEXES.name().toLowerCase(Locale.ROOT));
-    v1Params.put(
-        CoreAdminParams.CORE, obj.getRequest().getPathTemplateValues().get(CoreAdminParams.CORE));
-    if (v2Body.indexDir != null && !v2Body.indexDir.isEmpty()) {
-      v1Params.put("indexDir", v2Body.indexDir.toArray(new String[0]));
-    }
-    if (v2Body.srcCore != null && !v2Body.srcCore.isEmpty()) {
-      v1Params.put("srcCore", v2Body.srcCore.toArray(new String[0]));
-    }
-    // V1 API uses 'update.chain' instead of 'updateChain'.
-    if (v2Body.updateChain != null) {
-      v1Params.put(UpdateParams.UPDATE_CHAIN, v1Params.remove("updateChain"));
-    }
-
-    coreHandler.handleRequestBody(wrapParams(obj.getRequest(), v1Params), obj.getResponse());
-  }
-
-  public static class MergeIndexesPayload implements ReflectMapWriter {
-    @JsonProperty public List<String> indexDir;
-
-    @JsonProperty public List<String> srcCore;
-
-    @JsonProperty public String updateChain;
-
-    @JsonProperty public String async;
-  }
-}
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/MergeIndexesTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/MergeIndexesTest.java
new file mode 100644
index 00000000000..2165694e7d6
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/MergeIndexesTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.handler.admin.api;
+
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.nio.file.Paths;
+import java.util.Arrays;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.client.api.endpoint.MergeIndexesApi;
+import org.apache.solr.client.api.model.MergeIndexesRequestBody;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.handler.admin.CoreAdminHandler;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class MergeIndexesTest extends SolrTestCaseJ4 {
+  private MergeIndexesApi mergeIndexesApi;
+  private CoreContainer coreContainer;
+
+  @BeforeClass
+  public static void initializeCoreAndRequestFactory() throws Exception {
+    initCore("solrconfig.xml", "schema.xml");
+    lrf = h.getRequestFactory("/api", 0, 10);
+  }
+
+  @Before
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    SolrQueryRequest solrQueryRequest = req();
+    SolrQueryResponse solrQueryResponse = new SolrQueryResponse();
+    coreContainer = h.getCoreContainer();
+
+    CoreAdminHandler.CoreAdminAsyncTracker coreAdminAsyncTracker =
+        new CoreAdminHandler.CoreAdminAsyncTracker();
+    mergeIndexesApi =
+        new MergeIndexes(coreContainer, coreAdminAsyncTracker, solrQueryRequest, solrQueryResponse);
+    assumeWorkingMockito();
+  }
+
+  @Test
+  public void testReportsErrorIfBothIndexDirAndSrcCoreAreEmpty() throws Exception {
+    var requestBody = new MergeIndexesRequestBody();
+    requestBody.indexDirs = null;
+    requestBody.srcCores = null;
+    var ex =
+        assertThrows(
+            SolrException.class, () -> mergeIndexesApi.mergeIndexes(coreName, requestBody));
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+    assertTrue(ex.getMessage().contains("At least one indexDir or srcCore must be specified"));
+  }
+
+  @Test
+  public void testReportsErrorIfSrcCoreMissing() throws Exception {
+    final var INVALID_CORE = "INVALID_CORE";
+    var requestBody = new MergeIndexesRequestBody();
+    requestBody.srcCores = Arrays.asList(INVALID_CORE);
+    var ex =
+        assertThrows(
+            SolrException.class, () -> mergeIndexesApi.mergeIndexes(coreName, requestBody));
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+    assertTrue(ex.getMessage().contains("Core: " + INVALID_CORE + " does not exist"));
+  }
+
+  @Test
+  public void testReportsErrorIfPathNotAllowed() throws Exception {
+    CoreContainer mockCoreContainer = mock(CoreContainer.class);
+    CoreAdminHandler.CoreAdminAsyncTracker coreAdminAsyncTracker =
+        mock(CoreAdminHandler.CoreAdminAsyncTracker.class);
+    SolrCore mockSolrCore = mock(SolrCore.class);
+
+    final var requestBody = new MergeIndexesRequestBody();
+    final var path_not_allowed = "INVALID_PATH";
+    requestBody.indexDirs = Arrays.asList(path_not_allowed);
+
+    var mergeIndexes =
+        new MergeIndexes(mockCoreContainer, coreAdminAsyncTracker, req(), new SolrQueryResponse());
+
+    when(mockCoreContainer.getCore(coreName)).thenReturn(mockSolrCore);
+    when(mockSolrCore.getCoreContainer()).thenReturn(mockCoreContainer);
+    doThrow(IndexOutOfBoundsException.class)
+        .when(mockCoreContainer)
+        .assertPathAllowed(Paths.get(path_not_allowed));
+    var exp =
+        assertThrows(SolrException.class, () -> mergeIndexes.mergeIndexes(coreName, requestBody));
+  }
+
+  @AfterClass // unique core per test
+  public static void coreDestroy() {
+    deleteCore();
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/V2CoreAPIMappingTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/V2CoreAPIMappingTest.java
index 83befc7d523..2ab55905e04 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/api/V2CoreAPIMappingTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/V2CoreAPIMappingTest.java
@@ -33,7 +33,6 @@ import java.util.Arrays;
 import java.util.List;
 import org.apache.solr.common.cloud.ZkStateReader;
 import org.apache.solr.common.params.SolrParams;
-import org.apache.solr.common.params.UpdateParams;
 import org.apache.solr.handler.admin.CoreAdminHandler;
 import org.apache.solr.handler.admin.V2ApiMappingTest;
 import org.junit.Test;
@@ -65,7 +64,6 @@ public class V2CoreAPIMappingTest extends V2ApiMappingTest<CoreAdminHandler> {
   public void populateApiBag() {
     final CoreAdminHandler handler = getRequestHandler();
     apiBag.registerObject(new RenameCoreAPI(handler));
-    apiBag.registerObject(new MergeIndexesAPI(handler));
     apiBag.registerObject(new SplitCoreAPI(handler));
     apiBag.registerObject(new RequestCoreRecoveryAPI(handler));
     apiBag.registerObject(new PrepareCoreRecoveryAPI(handler));
@@ -86,31 +84,6 @@ public class V2CoreAPIMappingTest extends V2ApiMappingTest<CoreAdminHandler> {
     assertEquals("otherCore", v1Params.get(OTHER));
   }
 
-  @Test
-  public void testMergeIndexesAllParams() throws Exception {
-    final SolrParams v1Params =
-        captureConvertedV1Params(
-            "/cores/coreName",
-            "POST",
-            "{"
-                + "\"merge-indexes\": {"
-                + "\"indexDir\": [\"dir1\", \"dir2\"], "
-                + "\"srcCore\": [\"core1\", \"core2\"], "
-                + "\"updateChain\": \"someUpdateChain\", "
-                + "\"async\": \"someRequestId\"}}");
-
-    assertEquals("mergeindexes", v1Params.get(ACTION));
-    assertEquals("coreName", v1Params.get(CORE));
-    assertEquals("someUpdateChain", v1Params.get(UpdateParams.UPDATE_CHAIN));
-    assertEquals("someRequestId", v1Params.get(ASYNC));
-    final List<String> indexDirs = Arrays.asList(v1Params.getParams("indexDir"));
-    assertEquals(2, indexDirs.size());
-    assertTrue(indexDirs.containsAll(List.of("dir1", "dir2")));
-    final List<String> srcCores = Arrays.asList(v1Params.getParams("srcCore"));
-    assertEquals(2, srcCores.size());
-    assertTrue(srcCores.containsAll(List.of("core1", "core2")));
-  }
-
   @Test
   public void testSplitCoreAllParams() throws Exception {
     final SolrParams v1Params =
diff --git a/solr/solr-ref-guide/modules/configuration-guide/pages/coreadmin-api.adoc b/solr/solr-ref-guide/modules/configuration-guide/pages/coreadmin-api.adoc
index b0af69e2d93..173d4e4ee4b 100644
--- a/solr/solr-ref-guide/modules/configuration-guide/pages/coreadmin-api.adoc
+++ b/solr/solr-ref-guide/modules/configuration-guide/pages/coreadmin-api.adoc
@@ -573,17 +573,56 @@ The `MERGEINDEXES` action merges one or more indexes to another index.
 The indexes must have completed commits, and should be locked against writes until the merge is complete or the resulting merged index may become corrupted.
 The target core index must already exist and have a compatible schema with the one or more indexes that will be merged to it.
 Another commit on the target core should also be performed after the merge is complete.
+--
+[example.tab-pane#v1coreadmin-mergeindexes]
+====
+[.tab-label]*V1 API*
+[source,bash]
+----
+curl -X GET "http://localhost:8983/solr/admin/cores?action=MERGEINDEXES&core=targetCoreName&indexDir=path/to/core1/data/index&indexDir=path/to/core2/data/index"
+----
+====
+[example.tab-pane#v2coreadmin-mergeindexes]
+====
+[.tab-label]*V2 API*
+[source,bash]
+----
+curl -X POST http://localhost:8983/api/cores/targetCoreName/merge-indices -H 'Content-Type: application/json' -d '
+{
+  "indexDirs": ["path/to/core1/data/index","path/to/core2/data/index"]
+}
+'
+----
+====
+--
 
-`admin/cores?action=MERGEINDEXES&core=_new-core-name_&indexDir=_path/to/core1/data/index_&indexDir=_path/to/core2/data/index_`
-
-In this example, we use the `indexDir` parameter to define the index locations of the source cores.
+In this example, we use the `indexDir` parameter (`indexDirs` in the v2 API) to define the index locations of the source cores.
 The `core` parameter defines the target index.
 A benefit of this approach is that we can merge any Lucene-based index that may not be associated with a Solr core.
 
-Alternatively, we can instead use a `srcCore` parameter, as in this example:
-
-`admin/cores?action=mergeindexes&core=_new-core-name_&srcCore=_core1-name_&srcCore=_core2-name_`
-
+Alternatively, we can instead use a `srcCore` parameter (`srcCores` in the v2 API), as in the example below:
+--
+[example.tab-pane#v1coreadmin-mergeindexes]
+====
+[.tab-label]*V1 API*
+[source,bash]
+----
+curl -X GET "http://localhost:8983/solr/admin/cores?action=mergeindexes&core=targetCoreName&srcCore=core1&srcCore=core2"
+----
+====
+[example.tab-pane#v2coreadmin-mergeindexes]
+====
+[.tab-label]*V2 API*
+[source,bash]
+----
+curl -X POST http://localhost:8983/api/cores/targetCoreName/merge-indices -H 'Content-Type: application/json' -d '
+{
+  "srcCores": ["core1","core2"]
+}
+'
+----
+====
+--
 This approach allows us to define cores that may not have an index path that is on the same physical server as the target core.
 However, we can only use Solr cores as the source indexes.
 Another benefit of this approach is that we don't have as high a risk for corruption if writes occur in parallel with the source index.