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 0390a7e61e1 SOLR-15823: Broadcast V2 logging level changes to named
nodes and migrate the Admin UI to V2 (#5030)
0390a7e61e1 is described below
commit 0390a7e61e159d17fee3655cb488ebe5ef73cf34
Author: Nick <[email protected]>
AuthorDate: Thu Oct 22 05:38:47 2026 -0600
SOLR-15823: Broadcast V2 logging level changes to named nodes and migrate
the Admin UI to V2 (#5030)
Co-authored-by: Eric Pugh <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
---
changelog/unreleased/SOLR-15823.yml | 7 +
.../solr/client/api/endpoint/NodeLoggingApis.java | 7 +-
.../solr/client/api/model/LoggingResponse.java | 2 +-
.../api/model/NodeProxyingJerseyResponse.java | 56 +++++++
.../solr/client/api/model/NodeSystemResponse.java | 18 +--
.../apache/solr/handler/admin/LoggingHandler.java | 2 +-
.../solr/handler/admin/api/GetNodeSystemInfo.java | 6 +-
.../apache/solr/handler/admin/api/NodeLogging.java | 52 ++++++-
.../handler/admin/proxy/RemoteRequestProxy.java | 32 +++-
.../admin/api/GetNodeSystemInfoSolrCloudTest.java | 2 +
.../solr/handler/admin/api/NodeLoggingAPITest.java | 107 +++++++++++++-
.../admin/api/NodeLoggingNodesSolrCloudTest.java | 162 +++++++++++++++++++++
.../pages/configuring-logging.adoc | 14 ++
solr/webapp/web/js/angular/controllers/logging.js | 19 +--
solr/webapp/web/js/angular/services.js | 11 --
15 files changed, 449 insertions(+), 48 deletions(-)
diff --git a/changelog/unreleased/SOLR-15823.yml
b/changelog/unreleased/SOLR-15823.yml
new file mode 100644
index 00000000000..97701cd06bf
--- /dev/null
+++ b/changelog/unreleased/SOLR-15823.yml
@@ -0,0 +1,7 @@
+title: V2 logging API can broadcast log level changes to multiple nodes, and
the Admin UI logging screen now uses it.
+type: added
+authors:
+ - name: Nick Shanin
+links:
+ - name: SOLR-15823
+ url: https://issues.apache.org/jira/browse/SOLR-15823
diff --git
a/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeLoggingApis.java
b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeLoggingApis.java
index c5b5cd95807..06d28017a5f 100644
--- a/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeLoggingApis.java
+++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeLoggingApis.java
@@ -42,8 +42,13 @@ public interface NodeLoggingApis {
@Path("/levels")
@Operation(
summary = "Set one or more logger levels on the target node.",
+ description =
+ "If the 'nodes' parameter is provided, the level changes are instead
applied to "
+ + "each of the named nodes (or to every live node, if 'nodes' is
'all'), and the "
+ + "response reports the per-node results.",
tags = {"logging"})
- LoggingResponse modifyLocalLogLevel(List<LogLevelChange> requestBody);
+ LoggingResponse modifyLocalLogLevel(
+ @QueryParam("nodes") String nodes, List<LogLevelChange> requestBody);
@GET
@Path("/messages")
diff --git
a/solr/api/src/java/org/apache/solr/client/api/model/LoggingResponse.java
b/solr/api/src/java/org/apache/solr/client/api/model/LoggingResponse.java
index 1ba4be2c0d3..1b41ac02002 100644
--- a/solr/api/src/java/org/apache/solr/client/api/model/LoggingResponse.java
+++ b/solr/api/src/java/org/apache/solr/client/api/model/LoggingResponse.java
@@ -19,7 +19,7 @@ package org.apache.solr.client.api.model;
import com.fasterxml.jackson.annotation.JsonProperty;
/** Generic logging response that includes the name of the log watcher (e.g.
"Log4j2") */
-public class LoggingResponse extends SolrJerseyResponse {
+public class LoggingResponse extends NodeProxyingJerseyResponse {
@JsonProperty("watcher")
public String watcherName;
}
diff --git
a/solr/api/src/java/org/apache/solr/client/api/model/NodeProxyingJerseyResponse.java
b/solr/api/src/java/org/apache/solr/client/api/model/NodeProxyingJerseyResponse.java
new file mode 100644
index 00000000000..1ff954abc79
--- /dev/null
+++
b/solr/api/src/java/org/apache/solr/client/api/model/NodeProxyingJerseyResponse.java
@@ -0,0 +1,56 @@
+/*
+ * 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.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A {@link SolrJerseyResponse} for a node-local API that can also be proxied
to other live nodes
+ * via the {@code nodes} request parameter (see {@code
+ * org.apache.solr.handler.admin.proxy.V2SolrRequestBasedProxy}).
+ *
+ * <p>Each proxied node's response is merged in under a top-level key equal to
that node's name.
+ */
+public class NodeProxyingJerseyResponse extends SolrJerseyResponse {
+
+ // Object, not a response subtype, since @JsonAnySetter below also feeds
this map raw values.
+ public Map<String, Object> remoteNodeData = new LinkedHashMap<>();
+
+ @JsonAnyGetter
+ public Map<String, Object> remoteNodeData() {
+ return remoteNodeData;
+ }
+
+ @JsonAnySetter
+ public void setRemoteNodeResponse(String field, Object value) {
+ remoteNodeData.put(field, value);
+ }
+
+ /**
+ * Nodes that were asked to apply a broadcast request (via the 'nodes'
parameter) but did not
+ * return a response, e.g. because they timed out or errored (see {@code
+ * RemoteRequestProxy#getFailedNodes()}). Null for requests that were not
broadcast, and empty
+ * when every requested node responded.
+ */
+ @JsonProperty("failedNodes")
+ public List<String> failedNodes;
+}
diff --git
a/solr/api/src/java/org/apache/solr/client/api/model/NodeSystemResponse.java
b/solr/api/src/java/org/apache/solr/client/api/model/NodeSystemResponse.java
index f513ca46b0d..48fb145795a 100644
--- a/solr/api/src/java/org/apache/solr/client/api/model/NodeSystemResponse.java
+++ b/solr/api/src/java/org/apache/solr/client/api/model/NodeSystemResponse.java
@@ -16,30 +16,14 @@
*/
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 java.util.Date;
-import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Response from /node/system */
-public class NodeSystemResponse extends SolrJerseyResponse {
-
- // Object, not NodeSystemResponse, since @JsonAnySetter below also feeds
this map raw values.
- public Map<String, Object> remoteNodeData = new LinkedHashMap<>();
-
- @JsonAnyGetter
- public Map<String, Object> remoteNodeData() {
- return remoteNodeData;
- }
-
- @JsonAnySetter
- public void setRemoteNodeResponse(String field, Object value) {
- remoteNodeData.put(field, value);
- }
+public class NodeSystemResponse extends NodeProxyingJerseyResponse {
@JsonProperty public String host;
@JsonProperty public String node;
diff --git
a/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java
b/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java
index 77e65ede715..deaa364def9 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java
@@ -75,7 +75,7 @@ public class LoggingHandler extends RequestHandlerBase {
String[] set = params.getParams("set");
if (set != null) {
final List<LogLevelChange> changes =
NodeLogging.parseLogLevelChanges(set);
- squashV2Response(rsp, loggingApi.modifyLocalLogLevel(changes));
+ squashV2Response(rsp, loggingApi.modifyLocalLogLevel(null, changes));
}
String since = req.getParams().get("since");
diff --git
a/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeSystemInfo.java
b/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeSystemInfo.java
index b865fb09d2e..11b0263f419 100644
---
a/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeSystemInfo.java
+++
b/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeSystemInfo.java
@@ -80,7 +80,11 @@ public class GetNodeSystemInfo extends JerseyResource
implements NodeSystemInfoA
response.remoteNodeData.put(nodeName, proxiedResponse);
}
};
- return reqProxy.proxyRequest();
+ boolean proxied = reqProxy.proxyRequest();
+ if (proxied) {
+ response.failedNodes = reqProxy.getFailedNodes();
+ }
+ return proxied;
}
} catch (Exception e) {
throw new SolrException(
diff --git
a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java
b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java
index 5594d5f084e..2c7a582b53b 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java
@@ -36,17 +36,20 @@ import org.apache.solr.client.api.model.LogMessageInfo;
import org.apache.solr.client.api.model.LogMessagesResponse;
import org.apache.solr.client.api.model.LoggingResponse;
import org.apache.solr.client.api.model.SetThresholdRequestBody;
+import org.apache.solr.client.solrj.request.LoggingApi;
import org.apache.solr.common.SolrDocumentList;
import org.apache.solr.common.SolrException;
import org.apache.solr.core.CoreContainer;
+import org.apache.solr.handler.admin.proxy.RemoteRequestProxy;
+import org.apache.solr.handler.admin.proxy.V2SolrRequestBasedProxy;
import org.apache.solr.jersey.PermissionName;
import org.apache.solr.logging.LogWatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-// TODO Add support for 'nodes' param once SOLR-16738 is completed.
/**
- * V2 APIs for getting or setting log levels on an individual node.
+ * V2 APIs for getting or setting log levels on an individual node, or for
broadcasting log level
+ * changes across nodes.
*
* <p>These APIs ('/api/node/logging' and descendants) are analogous to the v1
/admin/info/logging.
*/
@@ -84,7 +87,7 @@ public class NodeLogging extends JerseyResource implements
NodeLoggingApis {
@Override
@PermissionName(CONFIG_EDIT_PERM)
- public LoggingResponse modifyLocalLogLevel(List<LogLevelChange> requestBody)
{
+ public LoggingResponse modifyLocalLogLevel(String nodes,
List<LogLevelChange> requestBody) {
ensureLogWatcherEnabled();
final LoggingResponse response =
instantiateLoggingResponse(LoggingResponse.class);
@@ -92,12 +95,55 @@ public class NodeLogging extends JerseyResource implements
NodeLoggingApis {
throw new SolrException(BAD_REQUEST, "Missing request body");
}
+ if (nodes != null && !nodes.isEmpty()) {
+ proxyLevelChangesToNodes(response, nodes, requestBody);
+ return response;
+ }
+
for (LogLevelChange change : requestBody) {
watcher.setLogLevel(change.logger, change.level);
}
return response;
}
+ /**
+ * Applies the given log level changes on other nodes, mirroring how {@link
GetNodeSystemInfo}
+ * fans its request out. The receiving node does not apply the changes
locally; it is covered only
+ * if the resolved node set includes it, in which case it calls itself over
HTTP.
+ *
+ * <p>{@link RemoteRequestProxy#validateNodeNames} rejects an unknown node
name before any request
+ * goes out, so a broadcast either reaches every named node or none of them;
a node that stops
+ * responding after that point (timeout, error, or leaving the live set
mid-request) is instead
+ * reported via {@link RemoteRequestProxy#getFailedNodes()}.
+ */
+ private void proxyLevelChangesToNodes(
+ LoggingResponse response, String nodes, List<LogLevelChange>
requestBody) {
+ if (coreContainer == null || coreContainer.getZkController() == null) {
+ throw new SolrException(
+ BAD_REQUEST, "The 'nodes' parameter is only supported in SolrCloud
mode");
+ }
+ try {
+ final var req = new LoggingApi.ModifyLocalLogLevel();
+ requestBody.forEach(req::addLogLevelChange);
+ req.setNodes(nodes);
+ final var reqProxy =
+ new V2SolrRequestBasedProxy<LoggingResponse>(coreContainer, req) {
+ @Override
+ public void processTypedProxiedResponse(
+ String nodeName, LoggingResponse proxiedResponse) {
+ response.remoteNodeData.put(nodeName, proxiedResponse);
+ }
+ };
+ reqProxy.proxyRequest();
+ response.failedNodes = reqProxy.getFailedNodes();
+ } catch (SolrException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new SolrException(
+ SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying
to other nodes", e);
+ }
+ }
+
@Override
@PermissionName(CONFIG_READ_PERM)
public LogMessagesResponse fetchLocalLogMessages(Long boundingTimeMillis) {
diff --git
a/solr/core/src/java/org/apache/solr/handler/admin/proxy/RemoteRequestProxy.java
b/solr/core/src/java/org/apache/solr/handler/admin/proxy/RemoteRequestProxy.java
index fe948d58f0b..d8ef39c8ec7 100644
---
a/solr/core/src/java/org/apache/solr/handler/admin/proxy/RemoteRequestProxy.java
+++
b/solr/core/src/java/org/apache/solr/handler/admin/proxy/RemoteRequestProxy.java
@@ -20,12 +20,15 @@ package org.apache.solr.handler.admin.proxy;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.net.URI;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.TreeSet;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
@@ -51,6 +54,9 @@ public abstract class RemoteRequestProxy {
protected final CoreContainer coreContainer;
+ // Per-request state: each request builds a fresh proxy instance, so this is
never shared.
+ private final Set<String> failedNodes = new TreeSet<>();
+
public RemoteRequestProxy(CoreContainer container) {
this.coreContainer = container;
}
@@ -90,6 +96,15 @@ public abstract class RemoteRequestProxy {
return true;
}
+ /**
+ * Destination nodes (per {@link #getDestinationNodes()}) that did not
produce a response, e.g.
+ * because the request errored or timed out. Only meaningful after {@link
#proxyRequest()} returns
+ * true; empty if every destination node responded.
+ */
+ public List<String> getFailedNodes() {
+ return new ArrayList<>(failedNodes);
+ }
+
private void bulkProcessResponses(Map<String, Future<NamedList<Object>>>
responseFutures) {
for (Map.Entry<String, Future<NamedList<Object>>> entry :
responseFutures.entrySet()) {
try {
@@ -98,8 +113,10 @@ public abstract class RemoteRequestProxy {
} catch (ExecutionException | InterruptedException ee) {
log.warn(
"Exception when fetching result from node {}", entry.getKey(),
ee.getCause()); // nowarn
+ failedNodes.add(entry.getKey());
} catch (TimeoutException e) {
log.warn("Timeout exceeded waiting for response from proxied node {}",
entry.getKey());
+ failedNodes.add(entry.getKey());
}
}
if (log.isDebugEnabled()) {
@@ -145,9 +162,15 @@ public abstract class RemoteRequestProxy {
/**
* Resolve node names from the "nodes" parameter into a set of live node
names.
*
+ * <p>For an explicit (non-"all") list, every named node is checked against
the live-node set
+ * before returning, so a caller that validates up front (e.g. via {@link
#getDestinationNodes()}
+ * before dispatching) fails atomically instead of partway through a
broadcast: {@link
+ * #callRemoteNode} re-checks liveness per node regardless, but only once
dispatch is already
+ * underway.
+ *
* @param nodeNames the value of the "nodes" parameter ("all" or
comma-separated node names)
* @return set of resolved node names
- * @throws SolrException if node format is invalid
+ * @throws SolrException if node format is invalid, or a named node is not
live
*/
protected Set<String> validateNodeNames(String nodeNames) {
Set<String> liveNodes =
@@ -165,6 +188,13 @@ public abstract class RemoteRequestProxy {
SolrException.ErrorCode.BAD_REQUEST, "Parameter " + PARAM_NODES +
" has wrong format");
}
}
+ final List<String> unknownNodes =
+ nodes.stream().filter(n -> !liveNodes.contains(n)).sorted().toList();
+ if (!unknownNodes.isEmpty()) {
+ throw new SolrException(
+ SolrException.ErrorCode.BAD_REQUEST,
+ "Requested nodes are not part of the cluster: " + unknownNodes);
+ }
log.debug("Nodes requested: {}", nodes);
return nodes;
}
diff --git
a/solr/core/src/test/org/apache/solr/handler/admin/api/GetNodeSystemInfoSolrCloudTest.java
b/solr/core/src/test/org/apache/solr/handler/admin/api/GetNodeSystemInfoSolrCloudTest.java
index 12a6b7e9c06..c9e083d9ab0 100644
---
a/solr/core/src/test/org/apache/solr/handler/admin/api/GetNodeSystemInfoSolrCloudTest.java
+++
b/solr/core/src/test/org/apache/solr/handler/admin/api/GetNodeSystemInfoSolrCloudTest.java
@@ -44,5 +44,7 @@ public class GetNodeSystemInfoSolrCloudTest extends
SolrCloudTestCase {
assertEquals(0, infoRsp.responseHeader.status);
assertNotNull(infoRsp.remoteNodeData);
assertTrue(infoRsp.remoteNodeData.containsKey(nodeName));
+ assertNotNull(infoRsp.failedNodes);
+ assertTrue(infoRsp.failedNodes.isEmpty());
}
}
diff --git
a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java
b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java
index 8838ac9e483..4ec5b7ee38d 100644
---
a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java
+++
b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java
@@ -21,17 +21,31 @@ import static
org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.instanceOf;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import java.lang.reflect.Field;
import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
import org.apache.solr.SolrTestCase;
import org.apache.solr.client.api.model.LogLevelChange;
+import org.apache.solr.client.api.model.LoggingResponse;
import org.apache.solr.client.api.model.SetThresholdRequestBody;
+import org.apache.solr.client.solrj.jetty.HttpJettySolrClient;
+import org.apache.solr.cloud.ZkController;
import org.apache.solr.common.SolrDocument;
import org.apache.solr.common.SolrDocumentList;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.cloud.ClusterState;
+import org.apache.solr.common.cloud.ZkStateReader;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.SuppressForbidden;
import org.apache.solr.core.CoreContainer;
import org.apache.solr.logging.LogWatcher;
import org.apache.solr.logging.LoggerInfo;
@@ -84,13 +98,104 @@ public class NodeLoggingAPITest extends SolrTestCase {
public void testReliesOnLogWatcherToModifyLogLevels() {
final var responseBody =
new NodeLogging(mockCoreContainer)
- .modifyLocalLogLevel(List.of(new LogLevelChange("o.a.s.Foo",
"WARN")));
+ .modifyLocalLogLevel(null, List.of(new LogLevelChange("o.a.s.Foo",
"WARN")));
assertNotNull(responseBody);
assertNull("Expected error to be null but was " + responseBody.error,
responseBody.error);
verify(mockLogWatcher).setLogLevel("o.a.s.Foo", "WARN");
}
+ @Test
+ public void testModifyLogLevelsWithoutNodesReportsNoBroadcastResults() {
+ final var responseBody =
+ new NodeLogging(mockCoreContainer)
+ .modifyLocalLogLevel(null, List.of(new LogLevelChange("o.a.s.Foo",
"WARN")));
+
+ assertTrue(
+ "Expected no per-node results for a local request, but was " +
responseBody.remoteNodeData,
+ responseBody.remoteNodeData.isEmpty());
+ assertNull(
+ "Expected failedNodes to be unset for a local request, but was " +
responseBody.failedNodes,
+ responseBody.failedNodes);
+ }
+
+ @Test
+ public void testModifyLogLevelsWithNodesFailsOutsideSolrCloud() {
+ // The mock CoreContainer has no ZkController stubbed, as on a standalone
(user-managed)
+ // node, so NodeLogging sees a null ZkController here.
+ try {
+ new NodeLogging(mockCoreContainer)
+ .modifyLocalLogLevel("all", List.of(new LogLevelChange("o.a.s.Foo",
"WARN")));
+ fail("Expected a SolrException when 'nodes' is used outside SolrCloud
mode");
+ } catch (SolrException e) {
+ assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, e.code());
+ assertTrue(
+ "Expected the error to name the 'nodes' parameter, but was: " +
e.getMessage(),
+ e.getMessage().contains("'nodes'"));
+ }
+ verify(mockLogWatcher, never()).setLogLevel(any(), any());
+ }
+
+ @Test
+ @SuppressForbidden(
+ reason =
+ "places a stubbed ZkStateReader on a ZkController mock; production
reads the public"
+ + " final field directly, so there is no setter or getter to
stub")
+ public void testModifyLogLevelsWithNodesReportsNodesThatDidNotRespond()
throws Exception {
+ final String respondingNode = "host1:8983_solr";
+ final String failingNode = "host2:8983_solr";
+
+ final ZkController mockZkController = mock(ZkController.class);
+ final ZkStateReader mockZkStateReader = mock(ZkStateReader.class);
+ final ClusterState mockClusterState = mock(ClusterState.class);
+ // NodeLogging and the request proxy read ZkController.zkStateReader
directly (a public
+ // final field) rather than calling a getter, so the stubbed reader has to
be placed on
+ // the mock reflectively.
+ final Field zkStateReaderField =
ZkController.class.getField("zkStateReader");
+ zkStateReaderField.setAccessible(true);
+ zkStateReaderField.set(mockZkController, mockZkStateReader);
+
+ when(mockCoreContainer.getZkController()).thenReturn(mockZkController);
+ when(mockZkController.getCoreContainer()).thenReturn(mockCoreContainer);
+ when(mockZkStateReader.getClusterState()).thenReturn(mockClusterState);
+ when(mockClusterState.getLiveNodes()).thenReturn(Set.of(respondingNode,
failingNode));
+ when(mockZkStateReader.getBaseUrlForNodeName(respondingNode))
+ .thenReturn("http://host1:8983/solr");
+
when(mockZkStateReader.getBaseUrlForNodeName(failingNode)).thenReturn("http://host2:8983/solr");
+
+ // One node answers with a logging response; the other's request fails,
the way the proxy
+ // sees a node that errors or times out.
+ final HttpJettySolrClient mockClient = mock(HttpJettySolrClient.class);
+ when(mockCoreContainer.getDefaultHttpSolrClient()).thenReturn(mockClient);
+ final NamedList<Object> proxiedResponse = new NamedList<>();
+ proxiedResponse.add("response", new LoggingResponse());
+ doAnswer(
+ invocation -> {
+ if (invocation.getArgument(0).equals("http://host1:8983/solr")) {
+ return CompletableFuture.completedFuture(proxiedResponse);
+ }
+ return CompletableFuture.failedFuture(new
RuntimeException("simulated node failure"));
+ })
+ .when(mockClient)
+ .requestWithBaseUrl(anyString(), any());
+
+ final var responseBody =
+ new NodeLogging(mockCoreContainer)
+ .modifyLocalLogLevel(
+ respondingNode + "," + failingNode,
+ List.of(new LogLevelChange("o.a.s.Foo", "WARN")));
+
+ assertEquals(
+ "Expected only the responding node to report a result",
+ Set.of(respondingNode),
+ responseBody.remoteNodeData.keySet());
+ assertEquals(
+ "Expected the node whose request failed to be reported",
+ List.of(failingNode),
+ responseBody.failedNodes);
+ verify(mockLogWatcher, never()).setLogLevel(any(), any());
+ }
+
private SolrDocumentList logMessageDocList(String... logMessages) {
final var docList = new SolrDocumentList();
for (String logMessage : logMessages) {
diff --git
a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java
b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java
new file mode 100644
index 00000000000..37ddcca4fb2
--- /dev/null
+++
b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.util.Map;
+import org.apache.solr.client.api.model.ListLevelsResponse;
+import org.apache.solr.client.api.model.LogLevelChange;
+import org.apache.solr.client.api.model.LogLevelInfo;
+import org.apache.solr.client.api.model.LoggingResponse;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.jetty.HttpJettySolrClient;
+import org.apache.solr.client.solrj.request.LoggingApi;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.embedded.JettySolrRunner;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * Tests the {@code nodes} broadcast on the V2 {@code PUT
/node/logging/levels} endpoint against a
+ * two-node cluster.
+ *
+ * <p>Requests go through the generated SolrJ client, so this also exercises
its codegen. Log levels
+ * themselves cannot be observed per node here: every node of the test cluster
shares one JVM, and
+ * log levels are JVM-wide. What is observable per node is the response: a
broadcast request is
+ * expected to carry one result entry per requested node (in {@link
LoggingResponse#remoteNodeData},
+ * keyed by node name), plus the names of any requested nodes that did not
respond (in {@link
+ * LoggingResponse#failedNodes}). The JVM-wide level after a broadcast is
observable, and one test
+ * uses it to show the level change was applied.
+ */
+public class NodeLoggingNodesSolrCloudTest extends SolrCloudTestCase {
+
+ private static final String TEST_LOGGER =
+ "org.apache.solr.handler.admin.api.NodeLoggingNodesSolrCloudTest";
+
+ @BeforeClass
+ public static void setupCluster() throws Exception {
+ configureCluster(2).addConfig("cloud-minimal",
configset("cloud-minimal")).configure();
+ }
+
+ @Test
+ public void testBroadcastToAllNodesReportsEveryNode() throws Exception {
+ final String receivingNode = jettyName(0);
+ final String otherNode = jettyName(1);
+
+ try {
+ final LoggingResponse rsp = putLogLevel(0, "all", TEST_LOGGER, "WARN");
+
+ assertNotNull("Broadcast response should report failed nodes",
rsp.failedNodes);
+ assertTrue(
+ "No node should be reported failed: " + rsp.failedNodes,
rsp.failedNodes.isEmpty());
+ for (String nodeName : new String[] {receivingNode, otherNode}) {
+ final Object perNode = rsp.remoteNodeData.get(nodeName);
+ assertNotNull(
+ "Expected a per-node result for " + nodeName + " in " +
rsp.remoteNodeData, perNode);
+ assertTrue(perNode instanceof Map);
+ // The per-node entry is that node's own logging response. Its
presence proves the
+ // request reached the node and a response came back.
+ assertNotNull(((Map<?, ?>) perNode).get("watcher"));
+ }
+ // Log levels are JVM-wide in this cluster, so the receiving node's own
listing shows
+ // whether the level change in the broadcast payload was actually
applied, not just
+ // delivered.
+ assertEquals(
+ "The broadcast level change should be in effect", "WARN",
loggerLevel(getLogLevels(0)));
+ } finally {
+ // Leave the logger as the test found it (no level set) for the rest of
the JVM.
+ putLogLevel(0, null, TEST_LOGGER, "unset");
+ }
+ }
+
+ @Test
+ public void testBroadcastToSingleNamedNode() throws Exception {
+ final String receivingNode = jettyName(0);
+ final String otherNode = jettyName(1);
+
+ final LoggingResponse rsp = putLogLevel(0, otherNode, TEST_LOGGER, "WARN");
+
+ assertNotNull(rsp.failedNodes);
+ assertTrue(rsp.failedNodes.isEmpty());
+ assertNotNull("Expected a per-node result for " + otherNode,
rsp.remoteNodeData.get(otherNode));
+ assertNull(
+ "The receiving node was not a target and must not appear",
+ rsp.remoteNodeData.get(receivingNode));
+ }
+
+ @Test
+ public void testNoNodesKeepsLocalResponseShape() throws Exception {
+ final LoggingResponse rsp = putLogLevel(0, null, TEST_LOGGER, "WARN");
+
+ assertNotNull(rsp.watcherName);
+ assertNull(rsp.failedNodes);
+ assertTrue("No per-node entries expected: " + rsp.remoteNodeData,
rsp.remoteNodeData.isEmpty());
+ }
+
+ @Test
+ public void testUnknownNodeNameFailsFast() throws Exception {
+ final var req = new LoggingApi.ModifyLocalLogLevel();
+ req.addLogLevelChange(new LogLevelChange(TEST_LOGGER, "WARN"));
+ req.setNodes("no-such-host.invalid:9999_solr");
+
+ try (HttpJettySolrClient client = client(0)) {
+ final RemoteSolrException e =
+ expectThrows(RemoteSolrException.class, () -> req.process(client));
+ assertEquals(400, e.code());
+ assertTrue(
+ "Error should name the problem: " + e.getMessage(),
+ e.getMessage().contains("not part of the cluster"));
+ }
+ }
+
+ private static String jettyName(int idx) {
+ return cluster.getJettySolrRunners().get(idx).getNodeName();
+ }
+
+ private static JettySolrRunner jetty(int idx) {
+ return cluster.getJettySolrRunners().get(idx);
+ }
+
+ private static HttpJettySolrClient client(int idx) {
+ return new
HttpJettySolrClient.Builder(jetty(idx).getBaseUrl().toString()).build();
+ }
+
+ private static LoggingResponse putLogLevel(
+ int targetIdx, String nodes, String logger, String level) throws
Exception {
+ final var req = new LoggingApi.ModifyLocalLogLevel();
+ req.addLogLevelChange(new LogLevelChange(logger, level));
+ req.setNodes(nodes);
+ try (HttpJettySolrClient client = client(targetIdx)) {
+ return req.process(client);
+ }
+ }
+
+ private static ListLevelsResponse getLogLevels(int targetIdx) throws
Exception {
+ final var req = new LoggingApi.ListAllLoggersAndLevels();
+ try (HttpJettySolrClient client = client(targetIdx)) {
+ return req.process(client);
+ }
+ }
+
+ private static String loggerLevel(ListLevelsResponse listing) {
+ for (LogLevelInfo logger : listing.loggers) {
+ if (TEST_LOGGER.equals(logger.name)) {
+ return logger.level;
+ }
+ }
+ return null;
+ }
+}
diff --git
a/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc
b/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc
index 5adfc0bc9f9..dfdf83c97b2 100644
---
a/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc
+++
b/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc
@@ -102,6 +102,20 @@ curl -X PUT http://localhost:8983/api/node/logging/levels
-H 'Content-Type: appl
====
======
+In SolrCloud mode, the V2 request above changes only the node that receives it.
+Add the `nodes` query parameter to apply the same changes on other nodes:
`nodes=all` targets every live node, or provide a comma-separated list of node
names (in `host:port_solr` form).
+The response then reports the result from each node, and names any requested
nodes that did not respond under `failedNodes`.
+A node named in `failedNodes` is not guaranteed to be unchanged: if the node
accepted the request but its response was lost or timed out, it may already
have applied the change.
+
+[source,bash]
+----
+curl -X PUT "http://localhost:8983/api/node/logging/levels?nodes=all" -H
'Content-Type: application/json' -d '
+ [
+ {"logger": "root", "level": "WARN"}
+ ]
+'
+----
+
=== Choosing Log Level at Startup
You can temporarily choose a different logging level as you start Solr.
diff --git a/solr/webapp/web/js/angular/controllers/logging.js
b/solr/webapp/web/js/angular/controllers/logging.js
index a3529c3d420..c2f6d46eb6d 100644
--- a/solr/webapp/web/js/angular/controllers/logging.js
+++ b/solr/webapp/web/js/angular/controllers/logging.js
@@ -101,7 +101,7 @@ solrAdminApp.controller('LoggingController',
)
.controller('LoggingLevelController',
- function($scope, $timeout, Logging, LoggingV2, ApiErrorHandler) {
+ function($scope, $timeout, LoggingV2, ApiErrorHandler) {
$scope.resetMenu("logging-levels");
var packageOf = function(logger) {
@@ -159,20 +159,17 @@ solrAdminApp.controller('LoggingController',
if (!$scope.isPermitted(permissions.CONFIG_EDIT_PERM)) {
return;
}
- var setString = logger.name + ":" + newLevel;
logger.showOptions = false;
- // Intentionally still v1 (Logging, not LoggingV2): this relies on the
"nodes=all" param to
- // broadcast the level change to every live node. The v2 NodeLoggingApis
endpoint is
- // single-node only until SOLR-16738 wires it up to the new
V2SolrRequestBasedProxy (see the
- // TODO in NodeLogging.java). Move this to LoggingV2.modifyLocalLogLevel
once that lands.
var doSetLevel = function() {
- var params = {set: setString};
- // "nodes=all" only makes sense in SolrCloud mode; in standalone
(user-managed) mode the
- // request is handled locally, so the param is omitted entirely.
+ var opts = {logLevelChange: [{logger: logger.name, level: newLevel}]};
+ // "nodes=all" only makes sense in SolrCloud mode, where it broadcasts
the level
+ // change to every live node; in standalone (user-managed) mode the
request is
+ // handled locally, so the param is omitted entirely.
if ($scope.isCloudEnabled) {
- params.nodes = "all";
+ opts.nodes = "all";
}
- Logging.setLevel(params, function(data) {
+ LoggingV2.modifyLocalLogLevel(opts, function(error, data, response) {
+ if (error) { ApiErrorHandler.handle(response); return; }
$scope.refresh();
});
};
diff --git a/solr/webapp/web/js/angular/services.js
b/solr/webapp/web/js/angular/services.js
index a088e1c1c6f..42105030d76 100644
--- a/solr/webapp/web/js/angular/services.js
+++ b/solr/webapp/web/js/angular/services.js
@@ -183,17 +183,6 @@ solrAdminServices.factory('Metrics',
}
};
}])
-.factory('Logging',
- ['$resource', function($resource) {
- // This v1 factory only covers "setLevel", which in SolrCloud mode needs
the "nodes=all"
- // broadcast-to-every-node behavior that the v2 NodeLoggingApis endpoint
doesn't support yet
- // (see SOLR-16738). The caller (LoggingLevelController) passes
nodes:'all' only when
- // SolrCloud is enabled; in standalone mode the param is omitted entirely.
Retire this
- // factory once setLevel moves to LoggingV2.
- return $resource('admin/info/logging', {'wt':'json', '_':Date.now()}, {
- "setLevel": {}
- });
- }])
.factory('Zookeeper',
['$resource', function($resource) {
// Tree browsing (formerly "simple"/"detail") moved to ZookeeperReadV2;
this factory now only