This is an automated email from the ASF dual-hosted git repository. dsmiley pushed a commit to branch branch_10x in repository https://gitbox.apache.org/repos/asf/solr.git
commit c6866f59fc1ecde267e13a0b6d553d2a6a978986 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]> (cherry picked from commit 5b2ac4dea21a7c86e31e2ba0cea58c262dc558c8) --- .../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.
