Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions changelog/unreleased/SOLR-18450-v2-collections-detailed.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
title:
V2 GET /api/collections gains a `detailed=true` param returning the collections, shards, and
replicas tree (in `collectionsDetail`), without live nodes, aliases, or cluster properties. This
tree previously lived at GET /api/cluster in an earlier draft of this change; it was relocated to
/api/collections since it's collection data, not cluster-wide data -- `/api/cluster/nodes`,
`/api/aliases`, and `/api/cluster/properties` already cover the cluster-wide pieces.
type: changed
authors:
- name: Prithvi S
nick: iprithv
- name: Eric Pugh
links:
- name: SOLR-18450
url: https://issues.apache.org/jira/browse/SOLR-18450
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
package org.apache.solr.client.api.endpoint;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import org.apache.solr.client.api.model.ListCollectionsResponse;

@Path("/collections")
Expand All @@ -27,5 +29,37 @@ public interface ListCollectionsApi {
@Operation(
summary = "List all collections in this Solr cluster",
tags = {"collections"})
ListCollectionsResponse listCollections();
ListCollectionsResponse listCollections(
@Parameter(
description =
"When true, return the collections, shards, and replicas tree (in"
+ " 'collectionsDetail') instead of the plain collection name list.")
@QueryParam("detailed")
Boolean detailed,
@Parameter(
description =
"Only used when 'detailed' is true. Collection or alias to return. Omit to"
+ " return every collection. An alias returns the collections it points"
+ " at.")
@QueryParam("collection")
String collection,
@Parameter(
description =
"Only used when 'detailed' is true. Shard or comma-separated shards to return."
+ " Applied to each selected collection.")
@QueryParam("shard")
String shard,
@Parameter(
description =
"Only used when 'detailed' is true. Route key of a document. Limits the tree to"
+ " the shard that would hold that document.")
@QueryParam("_route_")
String routeKey,
@Parameter(
description =
"Only used when 'detailed' is true. Include per-replica state when the"
+ " collection uses it.")
@QueryParam("prs")
Boolean prs)
throws Exception;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,110 @@
*/
package org.apache.solr.client.api.model;

import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class ListCollectionsResponse extends SolrJerseyResponse {
@JsonProperty("collections")
public List<String> collections;

@Schema(description = "Collections, shards, and replicas, present only when detailed=true.")
@JsonProperty("collectionsDetail")
public Map<String, CollectionState> collectionsDetail;

/** State of one collection, including its shards and replicas. */
public static class CollectionState {
@JsonProperty public Map<String, ShardState> shards;

@Schema(description = "Worst shard health in this collection: GREEN, YELLOW, ORANGE, or RED.")
@JsonProperty
public String health;

@JsonProperty public String configName;
@JsonProperty public Integer znodeVersion;
@JsonProperty public Long creationTimeMillis;

@Schema(description = "Aliases that point at this collection.")
@JsonProperty
public List<String> aliases;

@Schema(description = "Document router for this collection, e.g. name=compositeId.")
@JsonProperty
public Map<String, String> router;

@JsonProperty public Integer replicationFactor;

private final Map<String, Object> additionalProperties = new LinkedHashMap<>();

@JsonAnyGetter
public Map<String, Object> unknownProperties() {
return additionalProperties;
}

@JsonAnySetter
public void setUnknownProperty(String field, Object value) {
additionalProperties.put(field, value);
}
}

/** State of one shard. */
public static class ShardState {
@JsonProperty public String state;
@JsonProperty public String range;

@Schema(description = "Shard health: GREEN, YELLOW, ORANGE, or RED.")
@JsonProperty
public String health;

@JsonProperty public Map<String, ReplicaState> replicas;

private final Map<String, Object> additionalProperties = new LinkedHashMap<>();

@JsonAnyGetter
public Map<String, Object> unknownProperties() {
return additionalProperties;
}

@JsonAnySetter
public void setUnknownProperty(String field, Object value) {
additionalProperties.put(field, value);
}
}

/** State of one replica. */
public static class ReplicaState {
@JsonProperty public String state;
@JsonProperty public String core;

@JsonProperty("node_name")
public String nodeName;

@JsonProperty("base_url")
public String baseUrl;

/**
* {@code "true"} when this replica is the leader. Absent otherwise. state.json stores this as a
* string.
*/
@JsonProperty public String leader;

@JsonProperty public String type;

private final Map<String, Object> additionalProperties = new LinkedHashMap<>();

@JsonAnyGetter
public Map<String, Object> unknownProperties() {
return additionalProperties;
}

@JsonAnySetter
public void setUnknownProperty(String field, Object value) {
additionalProperties.put(field, value);
}
}
}
9 changes: 0 additions & 9 deletions solr/core/src/java/org/apache/solr/handler/ClusterAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@
import org.apache.solr.common.SolrException;
import org.apache.solr.common.cloud.ClusterProperties;
import org.apache.solr.common.cloud.ZkStateReader;
import org.apache.solr.common.params.CollectionParams.CollectionAction;
import org.apache.solr.common.params.CommonParams;
import org.apache.solr.common.params.DefaultSolrParams;
import org.apache.solr.common.params.ModifiableSolrParams;
import org.apache.solr.common.util.Utils;
Expand Down Expand Up @@ -210,13 +208,6 @@ public static SolrQueryRequest wrapParams(SolrQueryRequest req, Map<String, Obje
return req;
}

@EndPoint(method = GET, path = "/cluster", permission = COLL_READ_PERM)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there also a registerV2 Api method on this guy which can be removed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no registerV2 on this class :) only removed old GET /cluster @ EndPoint that forwarded to v1 CLUSTERSTATUS. kept POST /cluster (set-ratelimiter).

public void getClusterStatus(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception {
final Map<String, Object> v1Params =
Map.of(CommonParams.ACTION, CollectionAction.CLUSTERSTATUS.toLower());
collectionsHandler.handleRequestBody(wrapParams(req, v1Params), rsp);
}

private CoreContainer getCoreContainer() {
return collectionsHandler.getCoreContainer();
}
Expand Down
127 changes: 90 additions & 37 deletions solr/core/src/java/org/apache/solr/handler/admin/ClusterStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.stream.Stream;
import org.apache.solr.client.api.util.SolrVersion;
import org.apache.solr.common.MapWriter;
Expand Down Expand Up @@ -112,9 +114,10 @@ public void getClusterStatus(NamedList<Object> results, SolrVersion solrVersion)
boolean withAliases = solrParams.getBool(ALIASES_PROP, includeAll);

List<String> liveNodes = null;
if (withLiveNodes || collection != null) {
// Replica state is cross-checked against live nodes whenever the collection tree is built,
// including when the caller asked for that tree without the live-node list itself.
if (withLiveNodes || withCollection) {
liveNodes = zkStateReader.getZkClient().getChildren(ZkStateReader.LIVE_NODES_ZKNODE, null);
// add live_nodes
if (withLiveNodes) clusterStatus.add("live_nodes", liveNodes);
}

Expand All @@ -125,7 +128,7 @@ public void getClusterStatus(NamedList<Object> results, SolrVersion solrVersion)

if (withCollection) {
assert liveNodes != null;
fetchClusterStatusForCollOrAlias(clusterStatus, liveNodes, aliases, solrVersion);
addCollectionStatuses(clusterStatus, liveNodes, aliases, solrVersion);
}

if (withAliases) {
Expand All @@ -143,13 +146,49 @@ public void getClusterStatus(NamedList<Object> results, SolrVersion solrVersion)
results.add("cluster", clusterStatus);
}

private void fetchClusterStatusForCollOrAlias(
/**
* Collections, shards, and replicas selected by this request's {@code collection}, {@code shard},
* and {@code _route_} parameters.
*
* <p>Live nodes are read so replica state can be cross-checked, and aliases are read so a
* collection alias in {@code collection} can be resolved. Each collection still lists the aliases
* that point at it. The live-node list, the alias map, and cluster properties are not returned.
*/
public Map<String, Object> getCollectionStatuses() throws KeeperException, InterruptedException {
List<String> liveNodes =
zkStateReader.getZkClient().getChildren(ZkStateReader.LIVE_NODES_ZKNODE, null);
Aliases aliases = zkStateReader.getAliases();
Map<String, Object> collections = new LinkedHashMap<>();
forEachCollectionStatus(
liveNodes, aliases, (name, status) -> collections.put(name, copyForJackson(status)));
return collections;
}

private void addCollectionStatuses(
NamedList<Object> clusterStatus,
List<String> liveNodes,
Aliases aliases,
SolrVersion solrVersion) {
// Resolve the collection list now so a missing name fails the request, rather than during
// response writing. The per-collection JSON is still built while the response is written.
PreparedCollections prepared = prepareCollections(aliases);
if (solrVersion == null || solrVersion.greaterThanOrEqualTo(SolrVersion.valueOf("9.9.0"))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh.... is this part of a rolling upgrade feature?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thsi is pre-existing, from SOLR-17582.. it streams collections as a MapWriter for SolrJ 9.9+, and falls back to a NamedList for older clients. not a rolling-upgrade feature of this change... just left that path alone.

MapWriter collectionPropsWriter =
ew -> prepared.forEach(liveNodes, (name, status) -> ew.putNoEx(name, status));
clusterStatus.add("collections", collectionPropsWriter);
} else {
NamedList<Object> collectionProps = new SimpleOrderedMap<>();
prepared.forEach(liveNodes, collectionProps::add);
clusterStatus.add("collections", collectionProps);
}
}

private void forEachCollectionStatus(
List<String> liveNodes, Aliases aliases, BiConsumer<String, Map<String, Object>> consumer) {
prepareCollections(aliases).forEach(liveNodes, consumer);
}

// read aliases
private PreparedCollections prepareCollections(Aliases aliases) {
Map<String, List<String>> collectionVsAliases = new HashMap<>();
Map<String, List<String>> aliasVsCollections = aliases.getCollectionAliasListMap();
for (Map.Entry<String, List<String>> entry : aliasVsCollections.entrySet()) {
Expand All @@ -164,12 +203,6 @@ private void fetchClusterStatusForCollOrAlias(
}

ClusterState clusterState = zkStateReader.getClusterState();

String routeKey = solrParams.get(ShardParams._ROUTE_);
String shard = solrParams.get(ZkStateReader.SHARD_ID_PROP);

Set<String> requestedShards = (shard != null) ? Set.of(shard.split(",")) : null;

Stream<DocCollection> collectionStream;
if (collection == null) {
collectionStream = clusterState.collectionStream();
Expand All @@ -195,33 +228,53 @@ private void fetchClusterStatusForCollOrAlias(
}
}

if (solrVersion == null || solrVersion.greaterThanOrEqualTo(SolrVersion.valueOf("9.9.0"))) {
MapWriter collectionPropsWriter =
ew -> {
collectionStream.forEach(
(collectionState) -> {
ew.putNoEx(
collectionState.getName(),
buildResponseForCollection(
collectionState,
collectionVsAliases,
routeKey,
liveNodes,
requestedShards));
});
};
clusterStatus.add("collections", collectionPropsWriter);
} else {
NamedList<Object> collectionProps = new SimpleOrderedMap<>();
collectionStream.forEach(
collectionState -> {
collectionProps.add(
collectionState.getName(),
buildResponseForCollection(
collectionState, collectionVsAliases, routeKey, liveNodes, requestedShards));
});
clusterStatus.add("collections", collectionProps);
String routeKey = solrParams.get(ShardParams._ROUTE_);
String shard = solrParams.get(ZkStateReader.SHARD_ID_PROP);
Set<String> requestedShards = (shard != null) ? Set.of(shard.split(",")) : null;
return new PreparedCollections(
collectionStream.toList(), collectionVsAliases, routeKey, requestedShards);
}

private final class PreparedCollections {
private final List<DocCollection> collections;
private final Map<String, List<String>> collectionVsAliases;
private final String routeKey;
private final Set<String> requestedShards;

private PreparedCollections(
List<DocCollection> collections,
Map<String, List<String>> collectionVsAliases,
String routeKey,
Set<String> requestedShards) {
this.collections = collections;
this.collectionVsAliases = collectionVsAliases;
this.routeKey = routeKey;
this.requestedShards = requestedShards;
}

private void forEach(List<String> liveNodes, BiConsumer<String, Map<String, Object>> consumer) {
for (DocCollection collectionState : collections) {
consumer.accept(
collectionState.getName(),
buildResponseForCollection(
collectionState, collectionVsAliases, routeKey, liveNodes, requestedShards));
}
}
}

/**
* Jersey serializes the v2 response with Jackson, which does not write Solr {@link MapWriter}s.
* Per-replica state is the one value still attached as a writer; turn it into plain JSON objects.
* Other fields are already maps and scalars.
*/
private static Map<String, Object> copyForJackson(Map<String, Object> status) {
Object prs = status.get("PRS");
if (!(prs instanceof MapWriter)) {
return status;
}
Map<String, Object> copy = new LinkedHashMap<>(status);
copy.put("PRS", Utils.fromJSON(Utils.toJSON(prs)));
return copy;
}

private void addAliasMap(Aliases aliases, NamedList<Object> clusterStatus) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,8 @@ collection, propName, new UpdateCollectionPropertyRequestBody(val))
LIST,
(req, rsp, h) -> {
final ListCollections listCollectionsAPI = new ListCollections(h.coreContainer, req, rsp);
final SolrJerseyResponse listCollectionsResponse = listCollectionsAPI.listCollections();
final SolrJerseyResponse listCollectionsResponse =
listCollectionsAPI.listCollections(null, null, null, null, null);
V2ApiUtils.squashIntoSolrResponseWithoutHeader(rsp, listCollectionsResponse);
return null;
}),
Expand Down
Loading
Loading