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 5b2ac4dea21 SOLR-16398: Add V2 JAX-RS API for replication details
(#5179)
5b2ac4dea21 is described below
commit 5b2ac4dea21a7c86e31e2ba0cea58c262dc558c8
Author: Jalaz Kumar <[email protected]>
AuthorDate: Fri Oct 23 06:52:34 2026 +0530
SOLR-16398: Add V2 JAX-RS API for replication details (#5179)
Co-authored-by: Eric Pugh <[email protected]>
---
.../unreleased/SOLR-16398-replication-details.yml | 11 ++
.../solr/client/api/endpoint/ReplicationApis.java | 14 ++
.../api/model/ReplicationDetailsResponse.java | 207 ++++++++++++++++++++
.../apache/solr/handler/ReplicationHandler.java | 209 ++++++++++++++-------
.../solr/handler/admin/api/ClusterCommands.java | 28 ++-
.../solr/handler/admin/api/CoreReplication.java | 9 +
.../solr/handler/admin/api/ReplicationAPIBase.java | 7 +
.../org/apache/solr/handler/api/V2ApiUtils.java | 38 +++-
.../handler/admin/api/CoreReplicationAPITest.java | 30 +++
.../admin/api/FetchReplicationDetailsAPITest.java | 69 +++++++
.../pages/user-managed-index-replication.adoc | 20 +-
11 files changed, 567 insertions(+), 75 deletions(-)
diff --git a/changelog/unreleased/SOLR-16398-replication-details.yml
b/changelog/unreleased/SOLR-16398-replication-details.yml
new file mode 100644
index 00000000000..19808888bf0
--- /dev/null
+++ b/changelog/unreleased/SOLR-16398-replication-details.yml
@@ -0,0 +1,11 @@
+title:
+ Add V2 JAX-RS API for replication details (GET
/api/cores/{coreName}/replication)
+type: added
+authors:
+ - name: Jalaz Kumar
+ nick: jaykay12
+ - name: Eric Pugh
+ nick: epugh
+links:
+ - name: SOLR-16398
+ url: https://issues.apache.org/jira/browse/SOLR-16398
diff --git
a/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java
b/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java
index 1f0476f8661..f95335dc407 100644
--- a/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java
+++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java
@@ -31,11 +31,25 @@ import jakarta.ws.rs.core.StreamingOutput;
import java.io.IOException;
import org.apache.solr.client.api.model.FileListResponse;
import org.apache.solr.client.api.model.IndexVersionResponse;
+import org.apache.solr.client.api.model.ReplicationDetailsResponse;
import org.apache.solr.client.api.util.CoreApiParameters;
@Path("/cores/{coreName}/replication")
public interface ReplicationApis {
+ @GET
+ @CoreApiParameters
+ @Operation(
+ summary = "Return replication configuration and current status.",
+ tags = {"replication"})
+ ReplicationDetailsResponse fetchReplicationDetails(
+ @Parameter(
+ description = "When true on a follower, include nested details
fetched from leader",
+ required = false)
+ @QueryParam("follower")
+ @DefaultValue("true")
+ Boolean follower);
+
@GET
@CoreApiParameters
@Path("/indexversion")
diff --git
a/solr/api/src/java/org/apache/solr/client/api/model/ReplicationDetailsResponse.java
b/solr/api/src/java/org/apache/solr/client/api/model/ReplicationDetailsResponse.java
new file mode 100644
index 00000000000..9a1054c5240
--- /dev/null
+++
b/solr/api/src/java/org/apache/solr/client/api/model/ReplicationDetailsResponse.java
@@ -0,0 +1,207 @@
+/*
+ * 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;
+import org.apache.solr.client.api.util.ReflectWritable;
+
+public class ReplicationDetailsResponse extends SolrJerseyResponse {
+
+ @JsonProperty("status")
+ @Schema(description = "Request status, typically OK")
+ public String status;
+
+ @JsonProperty("details")
+ @Schema(description = "Replication configuration and status details")
+ public ReplicationDetails details;
+
+ public ReplicationDetailsResponse() {}
+
+ public ReplicationDetailsResponse(ReplicationDetails details, String status)
{
+ this.details = details;
+ this.status = status;
+ }
+
+ /** Top-level replication details payload (nested under {@code details} in
the response). */
+ public static class ReplicationDetails implements ReflectWritable {
+ @JsonProperty("indexSize")
+ public String indexSize;
+
+ @JsonProperty("indexPath")
+ public String indexPath;
+
+ @JsonProperty("commits")
+ public List<CommitDetails> commits;
+
+ @JsonProperty("isLeader")
+ public Boolean isLeader;
+
+ @JsonProperty("isFollower")
+ public Boolean isFollower;
+
+ @JsonProperty("indexVersion")
+ public Long indexVersion;
+
+ @JsonProperty("generation")
+ public Long generation;
+
+ @JsonProperty("leader")
+ public LeaderDetails leader;
+
+ @JsonProperty("follower")
+ public FollowerDetails follower;
+
+ /** Optional snapshot/backup stats (heterogeneous NamedList from the
handler). */
+ @JsonProperty("backup")
+ public Object backup;
+ }
+
+ public static class CommitDetails implements ReflectWritable {
+ @JsonProperty("indexVersion")
+ public Long indexVersion;
+
+ @JsonProperty("generation")
+ public Long generation;
+
+ @JsonProperty("filelist")
+ public List<String> filelist;
+ }
+
+ public static class LeaderDetails implements ReflectWritable {
+ @JsonProperty("confFiles")
+ public String confFiles;
+
+ @JsonProperty("replicateAfter")
+ public List<String> replicateAfter;
+
+ @JsonProperty("replicationEnabled")
+ public Boolean replicationEnabled;
+
+ @JsonProperty("replicableVersion")
+ public Long replicableVersion;
+
+ @JsonProperty("replicableGeneration")
+ public Long replicableGeneration;
+ }
+
+ public static class FollowerDetails implements ReflectWritable {
+ /** Nested leader details (remote NamedList or map), present when
follower=true. */
+ @JsonProperty("leaderDetails")
+ public Object leaderDetails;
+
+ @JsonProperty("ERROR")
+ public String error;
+
+ @JsonProperty("leaderUrl")
+ public String leaderUrl;
+
+ @JsonProperty("pollInterval")
+ public String pollInterval;
+
+ @JsonProperty("nextExecutionAt")
+ public String nextExecutionAt;
+
+ @JsonProperty("indexReplicatedAt")
+ public String indexReplicatedAt;
+
+ @JsonProperty("indexReplicatedAtList")
+ public List<String> indexReplicatedAtList;
+
+ @JsonProperty("replicationFailedAtList")
+ public List<String> replicationFailedAtList;
+
+ @JsonProperty("timesIndexReplicated")
+ public Integer timesIndexReplicated;
+
+ @JsonProperty("confFilesReplicated")
+ public String confFilesReplicated;
+
+ @JsonProperty("timesConfigReplicated")
+ public Integer timesConfigReplicated;
+
+ @JsonProperty("confFilesReplicatedAt")
+ public String confFilesReplicatedAt;
+
+ @JsonProperty("lastCycleBytesDownloaded")
+ public Long lastCycleBytesDownloaded;
+
+ @JsonProperty("timesFailed")
+ public Integer timesFailed;
+
+ @JsonProperty("replicationFailedAt")
+ public String replicationFailedAt;
+
+ @JsonProperty("previousCycleTimeInSeconds")
+ public Long previousCycleTimeInSeconds;
+
+ @JsonProperty("currentDate")
+ public String currentDate;
+
+ @JsonProperty("isPollingDisabled")
+ public Boolean isPollingDisabled;
+
+ @JsonProperty("isReplicating")
+ public Boolean isReplicating;
+
+ @JsonProperty("filesToDownload")
+ public List<String> filesToDownload;
+
+ @JsonProperty("numFilesToDownload")
+ public String numFilesToDownload;
+
+ @JsonProperty("bytesToDownload")
+ public String bytesToDownload;
+
+ @JsonProperty("filesDownloaded")
+ public List<String> filesDownloaded;
+
+ @JsonProperty("numFilesDownloaded")
+ public String numFilesDownloaded;
+
+ @JsonProperty("replicationStartTime")
+ public String replicationStartTime;
+
+ @JsonProperty("timeElapsed")
+ public String timeElapsed;
+
+ @JsonProperty("currentFile")
+ public String currentFile;
+
+ @JsonProperty("currentFileSize")
+ public String currentFileSize;
+
+ @JsonProperty("currentFileSizeDownloaded")
+ public String currentFileSizeDownloaded;
+
+ @JsonProperty("currentFileSizePercent")
+ public String currentFileSizePercent;
+
+ @JsonProperty("bytesDownloaded")
+ public String bytesDownloaded;
+
+ @JsonProperty("totalPercent")
+ public String totalPercent;
+
+ @JsonProperty("timeRemaining")
+ public String timeRemaining;
+
+ @JsonProperty("downloadSpeed")
+ public String downloadSpeed;
+ }
+}
diff --git a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
index 01058b59c9b..8287459f28f 100644
--- a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
@@ -62,7 +62,6 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
-import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.zip.Adler32;
@@ -74,9 +73,15 @@ import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
+import org.apache.solr.api.Api;
import org.apache.solr.api.JerseyResource;
import org.apache.solr.client.api.model.FileMetaData;
import org.apache.solr.client.api.model.IndexVersionResponse;
+import org.apache.solr.client.api.model.ReplicationDetailsResponse;
+import
org.apache.solr.client.api.model.ReplicationDetailsResponse.CommitDetails;
+import
org.apache.solr.client.api.model.ReplicationDetailsResponse.FollowerDetails;
+import
org.apache.solr.client.api.model.ReplicationDetailsResponse.LeaderDetails;
+import
org.apache.solr.client.api.model.ReplicationDetailsResponse.ReplicationDetails;
import org.apache.solr.client.api.model.SolrJerseyResponse;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.SolrException.ErrorCode;
@@ -324,7 +329,14 @@ public class ReplicationHandler extends RequestHandlerBase
} else if (command.equals(CMD_SHOW_COMMITS)) {
populateCommitInfo(rsp);
} else if (command.equals(CMD_DETAILS)) {
- getReplicationDetails(rsp, solrParams.getBool("follower", true));
+ final CoreReplication coreReplicationAPI = new CoreReplication(core,
req, rsp);
+ // stringifyBooleans=true: V1's command=details has always rendered
isLeader/isFollower/etc.
+ // as Strings, while the v2 API exposes them as proper JSON booleans.
+ V2ApiUtils.squashObjectIntoNamedList(
+ rsp.getValues(),
+
coreReplicationAPI.fetchReplicationDetails(solrParams.getBool("follower",
true)),
+ true,
+ true);
} else if (CMD_ENABLE_REPL.equalsIgnoreCase(command)) {
replicationEnabled.set(true);
rsp.add(STATUS, OK_STATUS);
@@ -1018,63 +1030,65 @@ public class ReplicationHandler extends
RequestHandlerBase
// TODO Should a failure retrieving any piece of info mark the overall
request as a failure? Is
// there a core set of values that are required to make a response here
useful?
/** Used for showing statistics and progress information. */
- private NamedList<Object> getReplicationDetails(
- SolrQueryResponse rsp, boolean showFollowerDetails) {
- NamedList<Object> details = new SimpleOrderedMap<>();
- NamedList<Object> leader = new SimpleOrderedMap<>();
- NamedList<Object> follower = new SimpleOrderedMap<>();
-
- details.add("indexSize", NumberUtils.readableSize(core.getIndexSize()));
- details.add("indexPath", core.getIndexDir());
- details.add(CMD_SHOW_COMMITS, getCommits());
- details.add("isLeader", String.valueOf(isLeader));
- details.add("isFollower", String.valueOf(isFollower));
+ public ReplicationDetailsResponse getReplicationDetails(boolean
showFollowerDetails) {
+ ReplicationDetails details = new ReplicationDetails();
+ LeaderDetails leader = new LeaderDetails();
+ FollowerDetails follower = new FollowerDetails();
+
+ boolean shouldFollowerBePopulated = false;
+
+ details.indexSize = NumberUtils.readableSize(core.getIndexSize());
+ details.indexPath = core.getIndexDir();
+ details.commits = getCommitDetails();
+ details.isLeader = isLeader;
+ details.isFollower = isFollower;
CommitVersionInfo vInfo = getIndexVersion();
- details.add("indexVersion", null == vInfo ? 0 : vInfo.version);
- details.add(GENERATION, null == vInfo ? 0 : vInfo.generation);
+ details.indexVersion = null == vInfo ? 0 : vInfo.version;
+ details.generation = null == vInfo ? 0 : vInfo.generation;
IndexCommit commit = indexCommitPoint; // make a copy so it won't change
if (isLeader) {
- if (includeConfFiles != null) leader.add(CONF_FILES, includeConfFiles);
- leader.add(REPLICATE_AFTER, getReplicateAfterStrings());
- leader.add("replicationEnabled",
String.valueOf(replicationEnabled.get()));
+ if (includeConfFiles != null) leader.confFiles = includeConfFiles;
+ leader.replicateAfter = getReplicateAfterStrings();
+ leader.replicationEnabled = replicationEnabled.get();
}
if (isLeader && commit != null) {
CommitVersionInfo repCommitInfo = CommitVersionInfo.build(commit);
- leader.add("replicableVersion", repCommitInfo.version);
- leader.add("replicableGeneration", repCommitInfo.generation);
+ leader.replicableVersion = repCommitInfo.version;
+ leader.replicableGeneration = repCommitInfo.generation;
}
IndexFetcher fetcher = currentIndexFetcher;
if (fetcher != null) {
+ shouldFollowerBePopulated = true;
Properties props = loadReplicationProperties();
if (showFollowerDetails) {
try {
NamedList<Object> nl = fetcher.getDetails();
- follower.add("leaderDetails", nl.get(CMD_DETAILS));
+ follower.leaderDetails = nl.get(CMD_DETAILS);
} catch (Exception e) {
log.warn("Exception while invoking 'details' method for replication
on leader ", e);
- follower.add(ERR_STATUS, "invalid_leader");
+ follower.error = "invalid_leader";
}
}
- follower.add(LEADER_URL, fetcher.getLeaderCoreUrl());
+ follower.leaderUrl = fetcher.getLeaderCoreUrl();
if (getPollInterval() != null) {
- follower.add(ReplicationAPIBase.POLL_INTERVAL, getPollInterval());
+ follower.pollInterval = getPollInterval();
}
Date nextScheduled = getNextScheduledExecTime();
if (nextScheduled != null && !isPollingDisabled()) {
- follower.add(NEXT_EXECUTION_AT, nextScheduled.toString());
+ follower.nextExecutionAt = nextScheduled.toString();
} else if (isPollingDisabled()) {
- follower.add(NEXT_EXECUTION_AT, "Polling disabled");
+ follower.nextExecutionAt = "Polling disabled";
}
- addReplicationProperties(follower::add, props);
+ addReplicationProperties(follower, props);
- follower.add("currentDate", new Date().toString());
- follower.add("isPollingDisabled", String.valueOf(isPollingDisabled()));
+ follower.currentDate = new Date().toString();
+ follower.isPollingDisabled = isPollingDisabled();
boolean isReplicating = isReplicating();
- follower.add("isReplicating", String.valueOf(isReplicating));
+ follower.isReplicating = isReplicating;
if (isReplicating) {
try {
long bytesToDownload = 0;
@@ -1090,9 +1104,9 @@ public class ReplicationHandler extends RequestHandlerBase
bytesToDownload += (Long) file.get(SIZE);
}
- follower.add("filesToDownload", filesToDownload);
- follower.add("numFilesToDownload",
String.valueOf(filesToDownload.size()));
- follower.add("bytesToDownload",
NumberUtils.readableSize(bytesToDownload));
+ follower.filesToDownload = filesToDownload;
+ follower.numFilesToDownload = String.valueOf(filesToDownload.size());
+ follower.bytesToDownload = NumberUtils.readableSize(bytesToDownload);
long bytesDownloaded = 0;
List<String> filesDownloaded = new ArrayList<>();
@@ -1121,17 +1135,17 @@ public class ReplicationHandler extends
RequestHandlerBase
percentDownloaded = (float) (currFileSizeDownloaded * 100) /
currFileSize;
}
}
- follower.add("filesDownloaded", filesDownloaded);
- follower.add("numFilesDownloaded",
String.valueOf(filesDownloaded.size()));
+ follower.filesDownloaded = filesDownloaded;
+ follower.numFilesDownloaded = String.valueOf(filesDownloaded.size());
long estimatedTimeRemaining = 0;
Date replicationStartTimeStamp =
fetcher.getReplicationStartTimeStamp();
if (replicationStartTimeStamp != null) {
- follower.add("replicationStartTime",
replicationStartTimeStamp.toString());
+ follower.replicationStartTime =
replicationStartTimeStamp.toString();
}
long elapsed = fetcher.getReplicationTimeElapsed();
- follower.add("timeElapsed", elapsed + "s");
+ follower.timeElapsed = elapsed + "s";
if (bytesDownloaded > 0)
estimatedTimeRemaining =
@@ -1140,53 +1154,105 @@ public class ReplicationHandler extends
RequestHandlerBase
long downloadSpeed = 0;
if (bytesToDownload > 0) totalPercent = (float) (bytesDownloaded *
100) / bytesToDownload;
if (elapsed > 0) downloadSpeed = (bytesDownloaded / elapsed);
- if (currFile != null) follower.add("currentFile", currFile);
- follower.add("currentFileSize",
NumberUtils.readableSize(currFileSize));
- follower.add(
- "currentFileSizeDownloaded",
NumberUtils.readableSize(currFileSizeDownloaded));
- follower.add("currentFileSizePercent",
String.valueOf(percentDownloaded));
- follower.add("bytesDownloaded",
NumberUtils.readableSize(bytesDownloaded));
- follower.add("totalPercent", String.valueOf(totalPercent));
- follower.add("timeRemaining", String.valueOf(estimatedTimeRemaining)
+ "s");
- follower.add("downloadSpeed",
NumberUtils.readableSize(downloadSpeed));
+ if (currFile != null) follower.currentFile = currFile;
+ follower.currentFileSize = NumberUtils.readableSize(currFileSize);
+ follower.currentFileSizeDownloaded =
NumberUtils.readableSize(currFileSizeDownloaded);
+ follower.currentFileSizePercent = String.valueOf(percentDownloaded);
+ follower.bytesDownloaded = NumberUtils.readableSize(bytesDownloaded);
+ follower.totalPercent = String.valueOf(totalPercent);
+ follower.timeRemaining = estimatedTimeRemaining + "s";
+ follower.downloadSpeed = NumberUtils.readableSize(downloadSpeed);
} catch (Exception e) {
log.error("Exception while writing replication details: ", e);
}
}
}
- if (isLeader) details.add("leader", leader);
- if (follower.size() > 0) details.add("follower", follower);
+ if (isLeader) details.leader = leader;
+ if (shouldFollowerBePopulated) details.follower = follower;
NamedList<?> snapshotStats = snapShootDetails;
- if (snapshotStats != null) details.add(CMD_BACKUP, snapshotStats);
+ if (snapshotStats != null) details.backup = snapshotStats;
- if (rsp.getValues().get(STATUS) == null) {
- rsp.add(STATUS, OK_STATUS);
- }
- rsp.add(CMD_DETAILS, details);
- return details;
+ return new ReplicationDetailsResponse(details, OK_STATUS);
}
- private void addReplicationProperties(BiConsumer<String, Object> consumer,
Properties props) {
- addVal(consumer, IndexFetcher.INDEX_REPLICATED_AT, props, Date.class);
- addVal(consumer, IndexFetcher.INDEX_REPLICATED_AT_LIST, props, List.class);
- addVal(consumer, IndexFetcher.REPLICATION_FAILED_AT_LIST, props,
List.class);
- addVal(consumer, IndexFetcher.TIMES_INDEX_REPLICATED, props,
Integer.class);
- addVal(consumer, IndexFetcher.CONF_FILES_REPLICATED, props, String.class);
- addVal(consumer, IndexFetcher.TIMES_CONFIG_REPLICATED, props,
Integer.class);
- addVal(consumer, IndexFetcher.CONF_FILES_REPLICATED_AT, props, Date.class);
- addVal(consumer, IndexFetcher.LAST_CYCLE_BYTES_DOWNLOADED, props,
Long.class);
- addVal(consumer, IndexFetcher.TIMES_FAILED, props, Integer.class);
- addVal(consumer, IndexFetcher.REPLICATION_FAILED_AT, props, Date.class);
- addVal(consumer, IndexFetcher.PREVIOUS_CYCLE_TIME_TAKEN, props,
Long.class);
+ @SuppressWarnings("unchecked")
+ private void addReplicationProperties(FollowerDetails followerDetails,
Properties props) {
+ addVal(
+ v -> followerDetails.indexReplicatedAt = (String) v,
+ IndexFetcher.INDEX_REPLICATED_AT,
+ props,
+ Date.class);
+ addVal(
+ v -> followerDetails.indexReplicatedAtList = (List<String>) v,
+ IndexFetcher.INDEX_REPLICATED_AT_LIST,
+ props,
+ List.class);
+ addVal(
+ v -> followerDetails.replicationFailedAtList = (List<String>) v,
+ IndexFetcher.REPLICATION_FAILED_AT_LIST,
+ props,
+ List.class);
+ addVal(
+ v -> followerDetails.timesIndexReplicated = (Integer) v,
+ IndexFetcher.TIMES_INDEX_REPLICATED,
+ props,
+ Integer.class);
+ addVal(
+ v -> followerDetails.confFilesReplicated = (String) v,
+ IndexFetcher.CONF_FILES_REPLICATED,
+ props,
+ String.class);
+ addVal(
+ v -> followerDetails.timesConfigReplicated = (Integer) v,
+ IndexFetcher.TIMES_CONFIG_REPLICATED,
+ props,
+ Integer.class);
+ addVal(
+ v -> followerDetails.confFilesReplicatedAt = (String) v,
+ IndexFetcher.CONF_FILES_REPLICATED_AT,
+ props,
+ Date.class);
+ addVal(
+ v -> followerDetails.lastCycleBytesDownloaded = (Long) v,
+ IndexFetcher.LAST_CYCLE_BYTES_DOWNLOADED,
+ props,
+ Long.class);
+ addVal(
+ v -> followerDetails.timesFailed = (Integer) v,
+ IndexFetcher.TIMES_FAILED,
+ props,
+ Integer.class);
+ addVal(
+ v -> followerDetails.replicationFailedAt = (String) v,
+ IndexFetcher.REPLICATION_FAILED_AT,
+ props,
+ Date.class);
+ addVal(
+ v -> followerDetails.previousCycleTimeInSeconds = (Long) v,
+ IndexFetcher.PREVIOUS_CYCLE_TIME_TAKEN,
+ props,
+ Long.class);
}
- private void addVal(
- BiConsumer<String, Object> consumer, String key, Properties props,
Class<?> clzz) {
+ @SuppressWarnings("unchecked")
+ private List<CommitDetails> getCommitDetails() {
+ List<CommitDetails> commits = new ArrayList<>();
+ for (NamedList<Object> namedList : getCommits()) {
+ CommitDetails commitDetail = new CommitDetails();
+ commitDetail.indexVersion = (Long) namedList.get("indexVersion");
+ commitDetail.generation = (Long) namedList.get(GENERATION);
+ commitDetail.filelist = (List<String>) namedList.get(CMD_GET_FILE_LIST);
+ commits.add(commitDetail);
+ }
+ return commits;
+ }
+
+ private void addVal(Consumer<Object> consumer, String key, Properties props,
Class<?> clzz) {
Object val = formatVal(key, props, clzz);
if (val != null) {
- consumer.accept(key, val);
+ consumer.accept(val);
}
}
@@ -1467,6 +1533,11 @@ public class ReplicationHandler extends
RequestHandlerBase
return Boolean.TRUE;
}
+ @Override
+ public Collection<Api> getApis() {
+ return List.of();
+ }
+
// check leader or follower is enabled
private boolean isEnabled(NamedList<?> params) {
if (params == null) return false;
diff --git
a/solr/core/src/java/org/apache/solr/handler/admin/api/ClusterCommands.java
b/solr/core/src/java/org/apache/solr/handler/admin/api/ClusterCommands.java
index 4094f373205..c70b56ed3e6 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/ClusterCommands.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/ClusterCommands.java
@@ -255,11 +255,31 @@ public class ClusterCommands extends AdminAPIBase
implements ClusterCommandsApi
return;
}
final Object statusVal = values.getVal(idx);
+ final String stateKey;
+ final String msgKey;
if (statusVal instanceof CommandStatus commandStatus) {
- final SimpleOrderedMap<String> status = new SimpleOrderedMap<>();
- status.add("state", commandStatus.state.getKey());
- status.add("msg", commandStatus.msg);
- values.setVal(idx, status);
+ stateKey = commandStatus.state == null ? null :
commandStatus.state.getKey();
+ msgKey = commandStatus.msg;
+ } else if (statusVal instanceof NamedList<?> namedList) {
+ stateKey = requestStateWireKey(namedList.get("state"));
+ msgKey = namedList.get("msg") == null ? null :
namedList.get("msg").toString();
+ } else {
+ return;
+ }
+
+ final SimpleOrderedMap<String> status = new SimpleOrderedMap<>();
+ status.add("state", stateKey);
+ status.add("msg", msgKey);
+ values.setVal(idx, status);
+ }
+
+ private static String requestStateWireKey(Object state) {
+ if (state == null) {
+ return null;
+ }
+ if (state instanceof CommandStatus.RequestState requestState) {
+ return requestState.getKey();
}
+ return CommandStatus.RequestState.fromKey(state.toString()).getKey();
}
}
diff --git
a/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java
b/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java
index 1389c6e780f..9985eb05a67 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java
@@ -24,6 +24,7 @@ import java.io.IOException;
import org.apache.solr.client.api.endpoint.ReplicationApis;
import org.apache.solr.client.api.model.FileListResponse;
import org.apache.solr.client.api.model.IndexVersionResponse;
+import org.apache.solr.client.api.model.ReplicationDetailsResponse;
import org.apache.solr.common.SolrException;
import org.apache.solr.core.SolrCore;
import org.apache.solr.jersey.PermissionName;
@@ -42,6 +43,14 @@ public class CoreReplication extends ReplicationAPIBase
implements ReplicationAp
super(solrCore, req, rsp);
}
+ @Override
+ @PermissionName(CORE_READ_PERM)
+ public ReplicationDetailsResponse fetchReplicationDetails(Boolean follower) {
+ // Boxed (rather than the primitive boolean used elsewhere for similar
flags) so this method
+ // can also be called directly, bypassing the JAX-RS @DefaultValue, with
no value supplied.
+ return doFetchReplicationDetails(follower == null || follower);
+ }
+
@Override
@PermissionName(CORE_READ_PERM)
public IndexVersionResponse fetchIndexVersion() throws IOException {
diff --git
a/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java
b/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java
index 743aad55e88..378c5699d31 100644
---
a/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java
+++
b/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java
@@ -48,6 +48,7 @@ import org.apache.solr.api.JerseyResource;
import org.apache.solr.client.api.model.FileListResponse;
import org.apache.solr.client.api.model.FileMetaData;
import org.apache.solr.client.api.model.IndexVersionResponse;
+import org.apache.solr.client.api.model.ReplicationDetailsResponse;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.util.FastOutputStream;
import org.apache.solr.common.util.IOUtils;
@@ -93,6 +94,12 @@ public abstract class ReplicationAPIBase extends
JerseyResource {
this.solrQueryResponse = solrQueryResponse;
}
+ protected ReplicationDetailsResponse doFetchReplicationDetails(boolean
showFollowerDetails) {
+ ReplicationHandler replicationHandler =
+ (ReplicationHandler)
solrCore.getRequestHandler(ReplicationHandler.PATH);
+ return replicationHandler.getReplicationDetails(showFollowerDetails);
+ }
+
protected IndexVersionResponse doFetchIndexVersion() throws IOException {
ReplicationHandler replicationHandler =
(ReplicationHandler)
solrCore.getRequestHandler(ReplicationHandler.PATH);
diff --git a/solr/core/src/java/org/apache/solr/handler/api/V2ApiUtils.java
b/solr/core/src/java/org/apache/solr/handler/api/V2ApiUtils.java
index 25758ccee03..478c7c09be6 100644
--- a/solr/core/src/java/org/apache/solr/handler/api/V2ApiUtils.java
+++ b/solr/core/src/java/org/apache/solr/handler/api/V2ApiUtils.java
@@ -23,12 +23,15 @@ import static
org.apache.solr.handler.admin.api.ReplicationAPIBase.FILE_STREAM;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.solr.client.api.model.SolrJerseyResponse;
+import org.apache.solr.client.api.util.ReflectWritable;
import org.apache.solr.common.MapWriter.EntryWriter;
import org.apache.solr.common.params.SolrParams;
import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.SimpleOrderedMap;
import org.apache.solr.common.util.StrUtils;
import org.apache.solr.common.util.Utils;
import org.apache.solr.response.PrometheusResponseWriter;
@@ -112,6 +115,20 @@ public class V2ApiUtils {
public static void squashObjectIntoNamedList(
NamedList<Object> destination, Object o, boolean trimHeader) {
+ squashObjectIntoNamedList(destination, o, trimHeader, false);
+ }
+
+ /**
+ * Like {@link #squashObjectIntoNamedList(NamedList, Object, boolean)}, but
renders {@code
+ * Boolean} leaf values as their String form ("true"/"false") instead of a
JSON boolean.
+ *
+ * <p>Use only when a V1 API must preserve a legacy String-typed boolean
field that the same data
+ * now exposes as a properly-typed {@code Boolean} on its v2 response POJO -
see {@code
+ * ReplicationHandler#getReplicationDetails}, whose {@code isLeader}/{@code
isFollower}/etc.
+ * fields have always been Strings in the V1 {@code command=details}
response.
+ */
+ public static void squashObjectIntoNamedList(
+ NamedList<Object> destination, Object o, boolean trimHeader, boolean
stringifyBooleans) {
final var ew =
new EntryWriter() {
@Override
@@ -120,7 +137,7 @@ public class V2ApiUtils {
if (trimHeader && kStr.equals("responseHeader")) {
return null;
}
- destination.add(kStr, value);
+ destination.add(kStr, convertReflectWriteableValue(value,
stringifyBooleans));
return this; // returning "this" means we can't use a lambda :-(
}
};
@@ -135,4 +152,23 @@ public class V2ApiUtils {
return prop.value().isEmpty() ? field.getName() : prop.value();
});
}
+
+ private static Object convertReflectWriteableValue(Object value, boolean
stringifyBooleans) {
+ if (stringifyBooleans && value instanceof Boolean bool) {
+ return bool.toString();
+ }
+ if (value instanceof ReflectWritable) {
+ NamedList<Object> result = new SimpleOrderedMap<>();
+ squashObjectIntoNamedList(result, value, true, stringifyBooleans);
+ return result;
+ }
+ if (value instanceof List<?> list) {
+ List<Object> objects = new ArrayList<>(list.size());
+ for (Object item : list) {
+ objects.add(convertReflectWriteableValue(item, stringifyBooleans));
+ }
+ return objects;
+ }
+ return value;
+ }
}
diff --git
a/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java
b/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java
index cd7de60c095..8406f8db35b 100644
---
a/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java
+++
b/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java
@@ -21,6 +21,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.opentelemetry.api.trace.Span;
+import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -32,6 +33,8 @@ import org.apache.solr.SolrTestCaseJ4;
import org.apache.solr.client.api.model.FileListResponse;
import org.apache.solr.client.api.model.FileMetaData;
import org.apache.solr.client.api.model.IndexVersionResponse;
+import org.apache.solr.client.api.model.ReplicationDetailsResponse;
+import
org.apache.solr.client.api.model.ReplicationDetailsResponse.ReplicationDetails;
import org.apache.solr.common.SolrException;
import org.apache.solr.core.SolrCore;
import org.apache.solr.core.SolrResourceLoader;
@@ -70,6 +73,33 @@ public class CoreReplicationAPITest extends SolrTestCaseJ4 {
coreReplicationAPI = new CoreReplicationAPIMock(mockCore,
mockQueryRequest, queryResponse);
}
+ @Test
+ public void testFetchReplicationDetails() {
+ ReplicationDetails details = new ReplicationDetails();
+ details.isLeader = true;
+ details.isFollower = false;
+ details.indexVersion = 42L;
+ details.generation = 7L;
+ ReplicationDetailsResponse expectedResponse =
+ new ReplicationDetailsResponse(details,
Response.Status.OK.getReasonPhrase());
+
+
when(mockReplicationHandler.getReplicationDetails(true)).thenReturn(expectedResponse);
+
when(mockReplicationHandler.getReplicationDetails(false)).thenReturn(expectedResponse);
+
+ ReplicationDetailsResponse withDefaultFollower =
+ coreReplicationAPI.fetchReplicationDetails(null);
+ assertSame(expectedResponse, withDefaultFollower);
+ assertEquals(Boolean.TRUE, withDefaultFollower.details.isLeader);
+ assertEquals(Long.valueOf(42L), withDefaultFollower.details.indexVersion);
+
+ ReplicationDetailsResponse withFollowerTrue =
coreReplicationAPI.fetchReplicationDetails(true);
+ assertSame(expectedResponse, withFollowerTrue);
+
+ ReplicationDetailsResponse withFollowerFalse =
+ coreReplicationAPI.fetchReplicationDetails(false);
+ assertSame(expectedResponse, withFollowerFalse);
+ }
+
@Test
public void testGetIndexVersion() throws Exception {
IndexVersionResponse expected = new IndexVersionResponse(123L, 123L,
"testGeneration");
diff --git
a/solr/core/src/test/org/apache/solr/handler/admin/api/FetchReplicationDetailsAPITest.java
b/solr/core/src/test/org/apache/solr/handler/admin/api/FetchReplicationDetailsAPITest.java
new file mode 100644
index 00000000000..885e83c2fd1
--- /dev/null
+++
b/solr/core/src/test/org/apache/solr/handler/admin/api/FetchReplicationDetailsAPITest.java
@@ -0,0 +1,69 @@
+/*
+ * 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.core.CoreContainer.ALLOW_PATHS_SYSPROP;
+
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.client.solrj.request.ReplicationApi;
+import org.apache.solr.util.ExternalPaths;
+import org.apache.solr.util.SolrJettyTestRule;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+/** HTTP tests for {@code GET /api/cores/coreName/replication} via the
generated SolrJ request. */
+public class FetchReplicationDetailsAPITest extends SolrTestCase {
+
+ private static final String CORE_NAME = "replicationDetailsApiTestCore";
+
+ @ClassRule public static final SolrJettyTestRule solrTestRule = new
SolrJettyTestRule();
+
+ @BeforeClass
+ public static void setupSolr() throws Exception {
+ System.setProperty(ALLOW_PATHS_SYSPROP,
ExternalPaths.SERVER_HOME.toAbsolutePath().toString());
+ solrTestRule.startSolr(createTempDir());
+
solrTestRule.newCollection(CORE_NAME).withConfigSet(ExternalPaths.DEFAULT_CONFIGSET).create();
+ }
+
+ @Test
+ public void testFetchReplicationDetails() throws Exception {
+ var request = new ReplicationApi.FetchReplicationDetails(CORE_NAME);
+ var response = request.process(solrTestRule.getSolrClient(CORE_NAME));
+
+ assertNotNull(response);
+ assertNull(response.error);
+ assertEquals("OK", response.status);
+ assertNotNull(response.details);
+ assertEquals(Boolean.TRUE, response.details.isLeader);
+ assertEquals(Boolean.FALSE, response.details.isFollower);
+ assertNotNull(response.details.indexSize);
+ assertNotNull(response.details.indexPath);
+ assertNotNull(response.details.commits);
+ }
+
+ @Test
+ public void testFetchReplicationDetailsWithFollowerFalse() throws Exception {
+ var request = new ReplicationApi.FetchReplicationDetails(CORE_NAME);
+ request.setFollower(false);
+ var response = request.process(solrTestRule.getSolrClient(CORE_NAME));
+
+ assertNotNull(response);
+ assertNull(response.error);
+ assertNotNull(response.details);
+ }
+}
diff --git
a/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc
b/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc
index 60513c1470a..b3e8484098e 100644
---
a/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc
+++
b/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc
@@ -430,8 +430,26 @@
http://{followerHost}:{port}/solr/{coreName}/replication?command=disablepoll
`details`::
Retrieve configuration details and current status.
+
+[tabs#details-request]
+======
+V1 API::
++
+====
[source,bash]
+----
http://{followerHost}:{port}/solr/{coreName}/replication?command=details
+----
+====
+
+V2 API::
++
+====
+[source,bash]
+----
+http://{followerHost}:{port}/api/cores/{coreName}/replication
+----
+====
+======
`filelist`::
Retrieve a list of Lucene files present in the specified host's index.
@@ -575,7 +593,7 @@ A snapshot with the name `snapshot._name_` must exist or an
error will be return
`repository`::: The name of the backup repository to use.
When not specified, it defaults to local file system.
-
+
`location`::: The location where the snapshot is created.