diff --git a/changelog/unreleased/SOLR-16398-disable-enable-poll.yml b/changelog/unreleased/SOLR-16398-disable-enable-poll.yml new file mode 100644 index 00000000000..a9c95729cb5 --- /dev/null +++ b/changelog/unreleased/SOLR-16398-disable-enable-poll.yml @@ -0,0 +1,8 @@ +title: Added V2 APIs to enable and disable polling of a leader from its follower +type: added +authors: + - name: Jalaz Kumar + nick: jaykay12 +links: + - name: SOLR-16398 + url: https://issues.apache.org/jira/browse/SOLR-16398 diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java index 3457528fa2f..27f7c42621a 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java @@ -36,6 +36,7 @@ import org.apache.solr.client.api.model.IndexVersionResponse; import org.apache.solr.client.api.model.ReplicationDetailsResponse; import org.apache.solr.client.api.model.ReplicationEnabledRequestBody; +import org.apache.solr.client.api.model.ReplicationPollingRequestBody; import org.apache.solr.client.api.model.RestoreStatusResponse; import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.client.api.util.CoreApiParameters; @@ -136,4 +137,13 @@ StreamingOutput fetchFile( tags = {"replication"}) SolrJerseyResponse setReplicationEnabled( @RequestBody(required = true) ReplicationEnabledRequestBody requestBody); + + @PUT + @CoreApiParameters + @Path("/polling") + @Operation( + summary = "Enable or disable polling of the leader by a follower.", + tags = {"replication"}) + SolrJerseyResponse setReplicationPolling( + @RequestBody(required = true) ReplicationPollingRequestBody requestBody); } diff --git a/solr/api/src/java/org/apache/solr/client/api/model/ReplicationPollingRequestBody.java b/solr/api/src/java/org/apache/solr/client/api/model/ReplicationPollingRequestBody.java new file mode 100644 index 00000000000..8d550206a3a --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/ReplicationPollingRequestBody.java @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Request body for enabling and disabling polling of the leader by a follower. */ +public class ReplicationPollingRequestBody { + + @JsonProperty(required = true) + public Boolean enabled; +} diff --git a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java index 40943072223..d9e6658fc9f 100644 --- a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java @@ -83,6 +83,7 @@ import org.apache.solr.client.api.model.ReplicationDetailsResponse.FollowerDetails; import org.apache.solr.client.api.model.ReplicationDetailsResponse.LeaderDetails; import org.apache.solr.client.api.model.ReplicationDetailsResponse.ReplicationDetails; +import org.apache.solr.client.api.model.ReplicationPollingRequestBody; import org.apache.solr.client.api.model.RestoreStatusResponse; import org.apache.solr.client.api.model.RestoreStatusResponse.RestoreState; import org.apache.solr.client.api.model.RestoreStatusResponse.RestoreStatus; @@ -331,9 +332,9 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw } else if (command.equalsIgnoreCase(CMD_FETCH_INDEX)) { fetchIndex(solrParams, rsp); } else if (command.equalsIgnoreCase(CMD_DISABLE_POLL)) { - disablePoll(rsp); + respondWithPollingEnabled(false, req, rsp); } else if (command.equalsIgnoreCase(CMD_ENABLE_POLL)) { - enablePoll(rsp); + respondWithPollingEnabled(true, req, rsp); } else if (command.equalsIgnoreCase(CMD_ABORT_FETCH)) { if (abortFetch()) { rsp.add(STATUS, OK_STATUS); @@ -856,23 +857,28 @@ public FileInfo(long lasmodified, String name, long size, long checksum) { } } - private void disablePoll(SolrQueryResponse rsp) { - if (pollingIndexFetcher != null) { - pollDisabled.set(true); - log.info("inside disable poll, value of pollDisabled = {}", pollDisabled); - rsp.add(STATUS, OK_STATUS); - } else { - reportErrorOnResponse(rsp, "No follower configured", null); - } + /** + * Enables or disables polling of the leader. + * + * @return false if this core is not configured as a follower + */ + public boolean setPollingEnabled(boolean enabled) { + if (pollingIndexFetcher == null) return false; + pollDisabled.set(!enabled); + log.info( + "polling {}, value of pollDisabled = {}", enabled ? "enabled" : "disabled", pollDisabled); + return true; } - private void enablePoll(SolrQueryResponse rsp) { - if (pollingIndexFetcher != null) { - pollDisabled.set(false); - log.info("inside enable poll, value of pollDisabled = {}", pollDisabled); + private void respondWithPollingEnabled( + boolean enabled, SolrQueryRequest req, SolrQueryResponse rsp) { + final ReplicationPollingRequestBody requestBody = new ReplicationPollingRequestBody(); + requestBody.enabled = enabled; + try { + new CoreReplication(core, req, rsp).setReplicationPolling(requestBody); rsp.add(STATUS, OK_STATUS); - } else { - reportErrorOnResponse(rsp, "No follower configured", null); + } catch (SolrException e) { + reportErrorOnResponse(rsp, e.getMessage(), null); } } diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java b/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java index ea5de6768ed..4e55f5ebc44 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java @@ -28,6 +28,7 @@ import org.apache.solr.client.api.model.IndexVersionResponse; import org.apache.solr.client.api.model.ReplicationDetailsResponse; import org.apache.solr.client.api.model.ReplicationEnabledRequestBody; +import org.apache.solr.client.api.model.ReplicationPollingRequestBody; import org.apache.solr.client.api.model.RestoreStatusResponse; import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.common.SolrException; @@ -103,4 +104,10 @@ public StreamingOutput fetchFile( public SolrJerseyResponse setReplicationEnabled(ReplicationEnabledRequestBody requestBody) { return doSetReplicationEnabled(requestBody); } + + @Override + @PermissionName(CORE_EDIT_PERM) + public SolrJerseyResponse setReplicationPolling(ReplicationPollingRequestBody requestBody) { + return doSetReplicationPolling(requestBody); + } } diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java b/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java index 107d8818ecb..7468d108a4f 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java @@ -51,6 +51,7 @@ import org.apache.solr.client.api.model.IndexVersionResponse; import org.apache.solr.client.api.model.ReplicationDetailsResponse; import org.apache.solr.client.api.model.ReplicationEnabledRequestBody; +import org.apache.solr.client.api.model.ReplicationPollingRequestBody; import org.apache.solr.client.api.model.RestoreStatusResponse; import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.common.SolrException; @@ -113,6 +114,17 @@ protected SolrJerseyResponse doSetReplicationEnabled(ReplicationEnabledRequestBo return instantiateJerseyResponse(SolrJerseyResponse.class); } + protected SolrJerseyResponse doSetReplicationPolling(ReplicationPollingRequestBody requestBody) { + ensureRequiredRequestBodyProvided(requestBody); + ensureRequiredParameterProvided("enabled", requestBody.enabled); + ReplicationHandler replicationHandler = + (ReplicationHandler) solrCore.getRequestHandler(ReplicationHandler.PATH); + if (!replicationHandler.setPollingEnabled(requestBody.enabled)) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No follower configured"); + } + return instantiateJerseyResponse(SolrJerseyResponse.class); + } + protected RestoreStatusResponse doFetchRestoreStatus() { ReplicationHandler replicationHandler = (ReplicationHandler) solrCore.getRequestHandler(ReplicationHandler.PATH); diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java index 0cd932f8c4e..b1fab305f3e 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java @@ -18,6 +18,7 @@ package org.apache.solr.handler.admin.api; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import io.opentelemetry.api.trace.Span; @@ -38,6 +39,7 @@ import org.apache.solr.client.api.model.ReplicationDetailsResponse; import org.apache.solr.client.api.model.ReplicationDetailsResponse.ReplicationDetails; import org.apache.solr.client.api.model.ReplicationEnabledRequestBody; +import org.apache.solr.client.api.model.ReplicationPollingRequestBody; import org.apache.solr.client.api.model.RestoreStatusResponse; import org.apache.solr.common.SolrException; import org.apache.solr.core.SolrCore; @@ -129,6 +131,42 @@ public void testSetReplicationEnabledRejectsBadBody() { assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); } + @Test + public void testSetReplicationPolling() { + for (boolean enabled : new boolean[] {true, false}) { + when(mockReplicationHandler.setPollingEnabled(enabled)).thenReturn(true); + final var body = new ReplicationPollingRequestBody(); + body.enabled = enabled; + assertNotNull(coreReplicationAPI.setReplicationPolling(body)); + verify(mockReplicationHandler).setPollingEnabled(enabled); + } + } + + @Test + public void testSetReplicationPollingRejectsNoFollower() { + final var body = new ReplicationPollingRequestBody(); + body.enabled = true; + SolrException ex = + expectThrows(SolrException.class, () -> coreReplicationAPI.setReplicationPolling(body)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + } + + @Test + public void testSetReplicationPollingRejectsNullBody() { + SolrException ex = + expectThrows(SolrException.class, () -> coreReplicationAPI.setReplicationPolling(null)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + } + + @Test + public void testSetReplicationPollingRejectsBadBody() { + final var emptyBody = new ReplicationPollingRequestBody(); + SolrException ex = + expectThrows( + SolrException.class, () -> coreReplicationAPI.setReplicationPolling(emptyBody)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + } + @Test public void testGetRestoreStatus() { RestoreStatusResponse expected = new RestoreStatusResponse(); diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/SetReplicationPollingAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/SetReplicationPollingAPITest.java new file mode 100644 index 00000000000..7adf3aacd77 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/SetReplicationPollingAPITest.java @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.handler.admin.api; + +import static org.apache.solr.core.CoreContainer.ALLOW_PATHS_SYSPROP; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.commons.io.file.PathUtils; +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.solrj.RemoteSolrException; +import org.apache.solr.client.solrj.request.ReplicationApi; +import org.apache.solr.common.SolrException; +import org.apache.solr.util.ExternalPaths; +import org.apache.solr.util.SolrJettyTestRule; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +/** + * HTTP tests for {@code PUT /api/cores/coreName/replication/polling} via the generated SolrJ + * request. + */ +public class SetReplicationPollingAPITest extends SolrTestCase { + + private static final String CORE_NAME = "setReplicationPollingApiTestCore"; + private static final String FOLLOWER_CORE_NAME = "setReplicationPollingApiTestFollowerCore"; + + @ClassRule public static final SolrJettyTestRule solrTestRule = new SolrJettyTestRule(); + + @BeforeClass + public static void setupSolr() throws Exception { + System.setProperty(ALLOW_PATHS_SYSPROP, ExternalPaths.SERVER_HOME.toAbsolutePath().toString()); + System.setProperty("solr.security.allow.urls.enabled", "false"); + final Path solrHome = createTempDir(); + solrTestRule.startSolr(solrHome); + solrTestRule.newCollection(CORE_NAME).withConfigSet(ExternalPaths.DEFAULT_CONFIGSET).create(); + + final Path followerConfigSet = solrHome.resolve("followerConfigSet"); + PathUtils.copyDirectory(ExternalPaths.DEFAULT_CONFIGSET, followerConfigSet); + final Path solrConfig = followerConfigSet.resolve("solrconfig.xml"); + final String followerHandler = + "" + + "" + + solrTestRule.getBaseUrl() + + "/" + + CORE_NAME + + "01:00:00"; + Files.writeString( + solrConfig, Files.readString(solrConfig).replace("", followerHandler)); + solrTestRule.newCollection(FOLLOWER_CORE_NAME).withConfigSet(followerConfigSet).create(); + } + + @Before + public void beforeEach() throws Exception { + setPolling(true); + } + + @Test + public void testDisableThenEnablePolling() throws Exception { + assertEquals(Boolean.FALSE, fetchPollingDisabled()); + + setPolling(false); + assertEquals(Boolean.TRUE, fetchPollingDisabled()); + + setPolling(true); + assertEquals(Boolean.FALSE, fetchPollingDisabled()); + } + + @Test + public void testMissingEnabledIsRejectedAndStateUnchanged() throws Exception { + var request = new ReplicationApi.SetReplicationPolling(FOLLOWER_CORE_NAME); + RemoteSolrException ex = + expectThrows( + RemoteSolrException.class, + () -> request.process(solrTestRule.getSolrClient(FOLLOWER_CORE_NAME))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + + assertEquals(Boolean.FALSE, fetchPollingDisabled()); + } + + @Test + public void testNoFollowerIsRejected() { + var request = new ReplicationApi.SetReplicationPolling(CORE_NAME); + request.setEnabled(true); + RemoteSolrException ex = + expectThrows( + RemoteSolrException.class, + () -> request.process(solrTestRule.getSolrClient(CORE_NAME))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + } + + @Test + public void testNullEnabledIsRejected() { + var request = new ReplicationApi.SetReplicationPolling(FOLLOWER_CORE_NAME); + request.setEnabled(null); + RemoteSolrException ex = + expectThrows( + RemoteSolrException.class, + () -> request.process(solrTestRule.getSolrClient(FOLLOWER_CORE_NAME))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + } + + private static void setPolling(boolean enabled) throws Exception { + var request = new ReplicationApi.SetReplicationPolling(FOLLOWER_CORE_NAME); + request.setEnabled(enabled); + var response = request.process(solrTestRule.getSolrClient(FOLLOWER_CORE_NAME)); + assertNull(response.error); + } + + private static Boolean fetchPollingDisabled() throws Exception { + var request = new ReplicationApi.FetchReplicationDetails(FOLLOWER_CORE_NAME); + return request.process(solrTestRule.getSolrClient(FOLLOWER_CORE_NAME)) + .details + .follower + .isPollingDisabled; + } +} diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc index 277038c3686..785e0dccdb3 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc @@ -456,14 +456,52 @@ http://{followerHost}:{port}/solr/{coreName}/replication?command=abortfetch `enablepoll`:: Enable the specified follower to poll for changes on the leader. + +[tabs#enablepoll-request] +====== +V1 API:: ++ +==== [source,bash] +---- http://{followerHost}:{port}/solr/{coreName}/replication?command=enablepoll +---- +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl -X PUT -H 'Content-Type: application/json' -d '{"enabled": true}' \ +http://{followerHost}:{port}/api/cores/{coreName}/replication/polling +---- +==== +====== `disablepoll`:: Disable the specified follower from polling for changes on the leader. + +[tabs#disablepoll-request] +====== +V1 API:: ++ +==== [source,bash] +---- http://{followerHost}:{port}/solr/{coreName}/replication?command=disablepoll +---- +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl -X PUT -H 'Content-Type: application/json' -d '{"enabled": false}' \ +http://{followerHost}:{port}/api/cores/{coreName}/replication/polling +---- +==== +====== `commits`:: Retrieve the index commit points currently retained by the core, with each commit's index version, generation, and the file list.