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..ad00dc695bcc 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 @@ -35,15 +35,24 @@ public interface NodeLoggingApis { @Path("/levels") @Operation( summary = "List all log-levels for the target node.", + description = + "If the 'nodes' parameter is provided, the listing is instead collected from " + + "each of the named nodes (or from every live node, if 'nodes' is 'all'), and " + + "the response reports the per-node results.", tags = {"logging"}) - ListLevelsResponse listAllLoggersAndLevels(); + ListLevelsResponse listAllLoggersAndLevels(@QueryParam("nodes") String nodes); @PUT @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..42541b489408 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 @@ -16,10 +16,42 @@ */ 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; /** Generic logging response that includes the name of the log watcher (e.g. "Log4j2") */ public class LoggingResponse extends SolrJerseyResponse { @JsonProperty("watcher") public String watcherName; + + /** + * Per-node results of a request broadcast with the 'nodes' parameter, keyed by node name in "live + * node" format (e.g. "someHost:8983_solr"). Serialized inline, as top-level fields named by node, + * mirroring {@link NodeSystemResponse#remoteNodeData}. Empty for requests that were not + * broadcast. + */ + // Object, not LoggingResponse, 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. Null for requests that were not + * broadcast, and empty when every requested node responded. + */ + @JsonProperty("failedNodes") + public List failedNodes; } 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..87a1c88c16fe 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"); @@ -88,7 +88,7 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw } squashV2Response(rsp, loggingApi.fetchLocalLogMessages(time)); } else { - squashV2Response(rsp, loggingApi.listAllLoggersAndLevels()); + squashV2Response(rsp, loggingApi.listAllLoggersAndLevels(null)); } rsp.setHttpCaching(false); 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..7b6b2a6cdc82 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 @@ -24,7 +24,10 @@ import jakarta.inject.Inject; import java.lang.invoke.MethodHandles; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import org.apache.solr.api.JerseyResource; @@ -36,17 +39,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.SolrRequest; +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.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. */ @@ -65,10 +71,17 @@ public NodeLogging(CoreContainer coreContainer) { @Override @PermissionName(CONFIG_READ_PERM) - public ListLevelsResponse listAllLoggersAndLevels() { + public ListLevelsResponse listAllLoggersAndLevels(String nodes) { ensureLogWatcherEnabled(); final ListLevelsResponse response = instantiateLoggingResponse(ListLevelsResponse.class); + if (nodes != null && !nodes.isEmpty()) { + final var req = new LoggingApi.ListAllLoggersAndLevels(); + req.setNodes(nodes); + proxyToNodes(response, req); + return response; + } + response.levels = watcher.getAllLevels(); final List loggerInfo = @@ -84,7 +97,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 +105,66 @@ public LoggingResponse modifyLocalLogLevel(List requestBody) { throw new SolrException(BAD_REQUEST, "Missing request body"); } + if (nodes != null && !nodes.isEmpty()) { + final var req = new LoggingApi.ModifyLocalLogLevel(); + requestBody.forEach(req::addLogLevelChange); + req.setNodes(nodes); + proxyToNodes(response, req); + return response; + } + for (LogLevelChange change : requestBody) { watcher.setLogLevel(change.logger, change.level); } return response; } + /** + * Fans the given request out to other nodes, mirroring how {@link GetNodeSystemInfo} fans its + * request out. The receiving node does not also serve the request locally; it is covered only if + * the resolved node set includes it, in which case it calls itself over HTTP. Per-node results + * are collected into the response, keyed by node name, and requested nodes that did not respond + * are named in {@code failedNodes}. + */ + private void proxyToNodes(T response, SolrRequest request) { + if (coreContainer == null || coreContainer.getZkController() == null) { + throw new SolrException( + BAD_REQUEST, "The 'nodes' parameter is only supported in SolrCloud mode"); + } + try { + final var reqProxy = + new V2SolrRequestBasedProxy(coreContainer, request) { + @Override + public void processTypedProxiedResponse(String nodeName, T proxiedResponse) { + response.remoteNodeData.put(nodeName, proxiedResponse); + } + }; + final Collection destinationNodes = reqProxy.getDestinationNodes(); + // Fail before sending anything if a named node is not part of the cluster; otherwise the + // broadcast would fail partway through, after earlier nodes were already contacted. + final Set liveNodes = + coreContainer.getZkController().zkStateReader.getClusterState().getLiveNodes(); + final List unknownNodes = + destinationNodes.stream().filter(node -> !liveNodes.contains(node)).sorted().toList(); + if (!unknownNodes.isEmpty()) { + throw new SolrException( + BAD_REQUEST, "Requested nodes are not part of the cluster: " + unknownNodes); + } + reqProxy.proxyRequest(); + // The proxy logs and skips nodes that error or time out; surface those nodes here so a + // partial broadcast is visible in the response instead of silent. + final var failedNodes = new ArrayList<>(destinationNodes); + failedNodes.removeAll(response.remoteNodeData.keySet()); + Collections.sort(failedNodes); + response.failedNodes = failedNodes; + } 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/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java index 8838ac9e4835..ef765c0aa8ad 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 @@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,6 +33,7 @@ import org.apache.solr.client.api.model.SetThresholdRequestBody; import org.apache.solr.common.SolrDocument; import org.apache.solr.common.SolrDocumentList; +import org.apache.solr.common.SolrException; import org.apache.solr.core.CoreContainer; import org.apache.solr.logging.LogWatcher; import org.apache.solr.logging.LoggerInfo; @@ -64,7 +66,7 @@ public void testReliesOnLogWatcherToListLogLevels() { .thenReturn(List.of("ERROR", "WARN", "INFO", "DEBUG", "TRACE")); when(mockLogWatcher.getAllLoggers()) .thenReturn(List.of(logInfo("org.a.s.Foo", "WARN", true), logInfo("org", null, false))); - final var responseBody = new NodeLogging(mockCoreContainer).listAllLoggersAndLevels(); + final var responseBody = new NodeLogging(mockCoreContainer).listAllLoggersAndLevels(null); assertEquals(5, responseBody.levels.size()); assertThat(responseBody.levels, containsInAnyOrder("ERROR", "WARN", "INFO", "DEBUG", "TRACE")); @@ -80,17 +82,80 @@ public void testReliesOnLogWatcherToListLogLevels() { assertTrue("Expected logger info to report 'set'", secondLogger.set); } + @Test + public void testListLogLevelsWithoutNodesReportsNoBroadcastResults() { + when(mockLogWatcher.getAllLevels()) + .thenReturn(List.of("ERROR", "WARN", "INFO", "DEBUG", "TRACE")); + when(mockLogWatcher.getAllLoggers()).thenReturn(List.of()); + + final var responseBody = new NodeLogging(mockCoreContainer).listAllLoggersAndLevels(null); + + 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 testListLogLevelsWithNodesFailsOutsideSolrCloud() { + // 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).listAllLoggersAndLevels("all"); + 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()).getAllLoggers(); + } + @Test 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()); + } + 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..76ae5272640b --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java @@ -0,0 +1,247 @@ +/* + * 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 com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import org.apache.solr.client.solrj.RemoteSolrException; +import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.client.solrj.jetty.HttpJettySolrClient; +import org.apache.solr.client.solrj.request.GenericV2SolrRequest; +import org.apache.solr.client.solrj.response.InputStreamResponseParser; +import org.apache.solr.client.solrj.response.SimpleSolrResponse; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.common.params.ModifiableSolrParams; +import org.apache.solr.embedded.JettySolrRunner; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Tests the {@code nodes} broadcast on the V2 logging endpoints ({@code PUT /node/logging/levels} + * and {@code GET /node/logging/levels}) against a two-node cluster. + * + *

The requests are sent with a generic V2 request, and the assertions run against the raw JSON + * response body. 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, keyed by node name, + * plus the names of any requested nodes that did not respond. + */ +public class NodeLoggingNodesSolrCloudTest extends SolrCloudTestCase { + + private static final String LEVEL_CHANGES_JSON = + "[{\"logger\": \"org.apache.solr.handler.admin.api.NodeLoggingNodesSolrCloudTest\"," + + " \"level\": \"WARN\"}]"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @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); + + final JsonNode rsp = putLogLevels(cluster.getJettySolrRunners().get(0), "all"); + + final JsonNode failedNodes = rsp.get("failedNodes"); + assertNotNull("Broadcast response should report failed nodes", failedNodes); + assertTrue("No node should be reported failed: " + failedNodes, failedNodes.isEmpty()); + for (String nodeName : new String[] {receivingNode, otherNode}) { + final JsonNode perNode = rsp.get(nodeName); + assertNotNull("Expected a per-node result for " + nodeName + " in " + rsp, perNode); + // The per-node entry is that node's own logging response. Its presence proves the request + // body (the level changes) reached the node and a response came back. + assertNotNull(perNode.get("watcher")); + } + } + + @Test + public void testBroadcastToSingleNamedNode() throws Exception { + final String receivingNode = jettyName(0); + final String otherNode = jettyName(1); + + final JsonNode rsp = putLogLevels(cluster.getJettySolrRunners().get(0), otherNode); + + assertNotNull(rsp.get("failedNodes")); + assertTrue(rsp.get("failedNodes").isEmpty()); + assertNotNull("Expected a per-node result for " + otherNode, rsp.get(otherNode)); + assertNull("The receiving node was not a target and must not appear", rsp.get(receivingNode)); + } + + @Test + public void testNoNodesKeepsLocalResponseShape() throws Exception { + final JsonNode rsp = putLogLevels(cluster.getJettySolrRunners().get(0), null); + + assertNotNull(rsp.get("watcher")); + assertNull(rsp.get("failedNodes")); + rsp.fieldNames() + .forEachRemaining( + field -> + assertFalse("No per-node entries expected: " + field, field.endsWith("_solr"))); + } + + @Test + public void testUnknownNodeNameFailsFast() { + final GenericV2SolrRequest req = + new GenericV2SolrRequest( + SolrRequest.METHOD.PUT, + "/node/logging/levels", + SolrRequest.SolrRequestType.ADMIN, + new ModifiableSolrParams().set("nodes", "no-such-host.invalid:9999_solr")); + req.withContent(LEVEL_CHANGES_JSON.getBytes(StandardCharsets.UTF_8), "application/json"); + final RemoteSolrException e = + expectThrows(RemoteSolrException.class, () -> sendWithDefaultParser(jetty(0), req)); + assertEquals(400, e.code()); + assertTrue( + "Error should name the problem: " + e.getMessage(), + e.getMessage().contains("not part of the cluster")); + } + + @Test + public void testBroadcastLevelsToAllNodesReportsEveryNode() throws Exception { + final String receivingNode = jettyName(0); + final String otherNode = jettyName(1); + + final JsonNode rsp = getLogLevels(cluster.getJettySolrRunners().get(0), "all"); + + final JsonNode failedNodes = rsp.get("failedNodes"); + assertNotNull("Broadcast response should report failed nodes", failedNodes); + assertTrue("No node should be reported failed: " + failedNodes, failedNodes.isEmpty()); + // The receiving node does not also serve the listing locally, so the top level carries no + // levels or loggers of its own; the listings live under the per-node entries. + assertNull("Expected no top-level 'levels' in a broadcast response: " + rsp, rsp.get("levels")); + assertNull( + "Expected no top-level 'loggers' in a broadcast response: " + rsp, rsp.get("loggers")); + for (String nodeName : new String[] {receivingNode, otherNode}) { + final JsonNode perNode = rsp.get(nodeName); + assertNotNull("Expected a per-node result for " + nodeName + " in " + rsp, perNode); + // The per-node entry is that node's own levels listing. + assertNotNull(perNode.get("watcher")); + assertNotNull(perNode.get("levels")); + final JsonNode loggers = perNode.get("loggers"); + assertNotNull(loggers); + assertTrue("Expected a non-empty logger list for " + nodeName, loggers.size() > 0); + } + } + + @Test + public void testBroadcastLevelsToSingleNamedNode() throws Exception { + final String receivingNode = jettyName(0); + final String otherNode = jettyName(1); + + final JsonNode rsp = getLogLevels(cluster.getJettySolrRunners().get(0), otherNode); + + assertNotNull(rsp.get("failedNodes")); + assertTrue(rsp.get("failedNodes").isEmpty()); + final JsonNode perNode = rsp.get(otherNode); + assertNotNull("Expected a per-node result for " + otherNode, perNode); + assertNotNull(perNode.get("loggers")); + assertNull("The receiving node was not a target and must not appear", rsp.get(receivingNode)); + } + + @Test + public void testNoNodesLevelsKeepsLocalResponseShape() throws Exception { + final JsonNode rsp = getLogLevels(cluster.getJettySolrRunners().get(0), null); + + assertNotNull(rsp.get("watcher")); + assertNotNull(rsp.get("levels")); + final JsonNode loggers = rsp.get("loggers"); + assertNotNull(loggers); + assertTrue("Expected a non-empty logger list", loggers.size() > 0); + assertNull(rsp.get("failedNodes")); + rsp.fieldNames() + .forEachRemaining( + field -> + assertFalse("No per-node entries expected: " + field, field.endsWith("_solr"))); + } + + @Test + public void testUnknownNodeNameFailsFastForLevelsGet() { + final GenericV2SolrRequest req = + new GenericV2SolrRequest( + SolrRequest.METHOD.GET, + "/node/logging/levels", + SolrRequest.SolrRequestType.ADMIN, + new ModifiableSolrParams().set("nodes", "no-such-host.invalid:9999_solr")); + final RemoteSolrException e = + expectThrows(RemoteSolrException.class, () -> sendWithDefaultParser(jetty(0), req)); + 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 JsonNode putLogLevels(JettySolrRunner target, String nodes) throws Exception { + final ModifiableSolrParams params = new ModifiableSolrParams(); + if (nodes != null) { + params.set("nodes", nodes); + } + final GenericV2SolrRequest req = + new GenericV2SolrRequest( + SolrRequest.METHOD.PUT, + "/node/logging/levels", + SolrRequest.SolrRequestType.ADMIN, + params); + req.withContent(LEVEL_CHANGES_JSON.getBytes(StandardCharsets.UTF_8), "application/json"); + req.setResponseParser(new InputStreamResponseParser("json")); + try (HttpJettySolrClient client = + new HttpJettySolrClient.Builder(target.getBaseUrl().toString()).build()) { + final String body = + InputStreamResponseParser.consumeResponseToString(req.process(client).getResponse()); + return MAPPER.readTree(body); + } + } + + private static JsonNode getLogLevels(JettySolrRunner target, String nodes) throws Exception { + final ModifiableSolrParams params = new ModifiableSolrParams(); + if (nodes != null) { + params.set("nodes", nodes); + } + final GenericV2SolrRequest req = + new GenericV2SolrRequest( + SolrRequest.METHOD.GET, + "/node/logging/levels", + SolrRequest.SolrRequestType.ADMIN, + params); + req.setResponseParser(new InputStreamResponseParser("json")); + try (HttpJettySolrClient client = + new HttpJettySolrClient.Builder(target.getBaseUrl().toString()).build()) { + final String body = + InputStreamResponseParser.consumeResponseToString(req.process(client).getResponse()); + return MAPPER.readTree(body); + } + } + + private static SimpleSolrResponse sendWithDefaultParser( + JettySolrRunner target, GenericV2SolrRequest req) throws Exception { + try (HttpJettySolrClient client = + new HttpJettySolrClient.Builder(target.getBaseUrl().toString()).build()) { + return req.process(client); + } + } +} 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..2629ee1754df 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,27 @@ 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`. + +[source,bash] +---- +curl -X PUT "http://localhost:8983/api/node/logging/levels?nodes=all" -H 'Content-Type: application/json' -d ' + [ + {"logger": "root", "level": "WARN"} + ] +' +---- + +The `nodes` parameter works the same way on the V2 log level listing, `GET /api/node/logging/levels`. +The response then carries each node's listing under its node name instead of a single top-level listing, and names any requested nodes that did not respond under `failedNodes`. + +[source,bash] +---- +curl "http://localhost:8983/api/node/logging/levels?nodes=all" +---- + === 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..1129dae1603c 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) { @@ -126,7 +126,7 @@ solrAdminApp.controller('LoggingController', }; $scope.refresh = function() { - LoggingV2.listAllLoggersAndLevels(function(error, data, response) { + LoggingV2.listAllLoggersAndLevels({}, function(error, data, response) { $timeout(function() { if (error) { ApiErrorHandler.handle(response); return; } $scope.logging = makeTree(data.loggers, ""); @@ -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