diff --git a/changelog/unreleased/SOLR-15823.yml b/changelog/unreleased/SOLR-15823.yml new file mode 100644 index 000000000000..97701cd06bf4 --- /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 c5b5cd958070..06d28017a5f0 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 requestBody); + LoggingResponse modifyLocalLogLevel( + @QueryParam("nodes") String nodes, List 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 1ba4be2c0d34..1b41ac020020 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 @@ 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 000000000000..1ff954abc79e --- /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}). + * + *

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 remoteNodeData = new LinkedHashMap<>(); + + @JsonAnyGetter + public Map 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 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 f513ca46b0d2..48fb145795ad 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 remoteNodeData = new LinkedHashMap<>(); - - @JsonAnyGetter - public Map 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 77e65ede7150..deaa364def91 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 void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw String[] set = params.getParams("set"); if (set != null) { final List 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 b865fb09d2e9..11b0263f419e 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 void processTypedProxiedResponse( 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 5594d5f084ef..2c7a582b53b3 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.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. * *

These APIs ('/api/node/logging' and descendants) are analogous to the v1 /admin/info/logging. */ @@ -84,7 +87,7 @@ public ListLevelsResponse listAllLoggersAndLevels() { @Override @PermissionName(CONFIG_EDIT_PERM) - public LoggingResponse modifyLocalLogLevel(List requestBody) { + public LoggingResponse modifyLocalLogLevel(String nodes, List requestBody) { ensureLogWatcherEnabled(); final LoggingResponse response = instantiateLoggingResponse(LoggingResponse.class); @@ -92,12 +95,55 @@ public LoggingResponse modifyLocalLogLevel(List requestBody) { 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. + * + *

{@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 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(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 fe948d58f0b9..d8ef39c8ec7b 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 @@ 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 failedNodes = new TreeSet<>(); + public RemoteRequestProxy(CoreContainer container) { this.coreContainer = container; } @@ -90,6 +96,15 @@ public boolean proxyRequest() { 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 getFailedNodes() { + return new ArrayList<>(failedNodes); + } + private void bulkProcessResponses(Map>> responseFutures) { for (Map.Entry>> entry : responseFutures.entrySet()) { try { @@ -98,8 +113,10 @@ private void bulkProcessResponses(Map>> respons } 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 @@ private CompletableFuture> callRemoteNode( /** * Resolve node names from the "nodes" parameter into a set of live node names. * + *

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 validateNodeNames(String nodeNames) { Set liveNodes = @@ -165,6 +188,13 @@ protected Set validateNodeNames(String nodeNames) { SolrException.ErrorCode.BAD_REQUEST, "Parameter " + PARAM_NODES + " has wrong format"); } } + final List 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 12a6b7e9c064..c9e083d9ab04 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 void testGetNodeInfoWithNodesParam() throws Exception { 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 8838ac9e4835..4ec5b7ee38d0 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.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 void testReliesOnLogWatcherToListLogLevels() { 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 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 000000000000..37ddcca4fb20 --- /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. + * + *

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 5adfc0bc9f9e..dfdf83c97b2b 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 a3529c3d420f..c2f6d46eb6db 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 c0b525b1698c..3b18921e2aed 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -193,17 +193,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