diff --git a/changelog/unreleased/SOLR-18248-cancel-tasks.yml b/changelog/unreleased/SOLR-18248-cancel-tasks.yml new file mode 100644 index 000000000000..d86b6429de1a --- /dev/null +++ b/changelog/unreleased/SOLR-18248-cancel-tasks.yml @@ -0,0 +1,9 @@ +title: + Migration of CancelTask V2 API to JAX-RS construct +type: changed +authors: + - name: Jalaz Kumar + - name: Eric Pugh +links: + - name: SOLR-18248 + url: https://issues.apache.org/jira/browse/SOLR-18248 diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/TasksApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/TasksApi.java index 46d80de5d9d3..d2b9c9216376 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/TasksApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/TasksApi.java @@ -20,9 +20,11 @@ import static org.apache.solr.client.api.util.Constants.INDEX_PATH_PREFIX; import io.swagger.v3.oas.annotations.Operation; +import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; +import org.apache.solr.client.api.model.CancelTaskResponse; import org.apache.solr.client.api.model.ListActiveTaskResponse; import org.apache.solr.client.api.model.TaskStatusResponse; import org.apache.solr.client.api.util.StoreApiParameters; @@ -48,4 +50,14 @@ interface Status { tags = {"tasks"}) TaskStatusResponse getTaskStatus(@PathParam("taskID") String taskID) throws Exception; } + + @Path(INDEX_PATH_PREFIX + "/tasks/{taskID}") + interface Cancel { + @DELETE + @StoreApiParameters + @Operation( + summary = "Cancel any specific task", + tags = {"tasks"}) + CancelTaskResponse cancelRunningTask(@PathParam("taskID") String taskID) throws Exception; + } } diff --git a/solr/api/src/java/org/apache/solr/client/api/model/CancelTaskResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/CancelTaskResponse.java new file mode 100644 index 000000000000..41158d6e521f --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/CancelTaskResponse.java @@ -0,0 +1,31 @@ +/* + * 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; + +/** Response body returned after a task cancellation request. */ +public class CancelTaskResponse extends SolrJerseyResponse { + + public enum CancellationStatus { + SUCCESS, + NOT_FOUND + } + + @JsonProperty public CancelTaskResponse.CancellationStatus status; +} diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/CancelTask.java b/solr/core/src/java/org/apache/solr/handler/admin/api/CancelTask.java new file mode 100644 index 000000000000..095a981a5566 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/CancelTask.java @@ -0,0 +1,68 @@ +/* + * 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.security.PermissionNameProvider.Name.READ_PERM; + +import jakarta.inject.Inject; +import org.apache.solr.api.JerseyResource; +import org.apache.solr.client.api.endpoint.TasksApi; +import org.apache.solr.client.api.model.CancelTaskResponse; +import org.apache.solr.common.SolrException; +import org.apache.solr.handler.component.ActiveTaskQuerySupport; +import org.apache.solr.jersey.PermissionName; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.search.CancellableCollector; + +/** V2 API implementation for cancelling an active task. */ +public class CancelTask extends JerseyResource implements TasksApi.Cancel { + + private final SolrQueryRequest solrQueryRequest; + + @Inject + public CancelTask(SolrQueryRequest solrQueryRequest) { + this.solrQueryRequest = solrQueryRequest; + } + + @Override + @PermissionName(READ_PERM) + public CancelTaskResponse cancelRunningTask(String taskID) throws Exception { + final CancelTaskResponse response = instantiateJerseyResponse(CancelTaskResponse.class); + + boolean isTaskCancelled = ActiveTaskQuerySupport.cancelTask(solrQueryRequest, taskID); + + if (isTaskCancelled) { + response.status = CancelTaskResponse.CancellationStatus.SUCCESS; + return response; + } + response.status = CancelTaskResponse.CancellationStatus.NOT_FOUND; + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, "Task with ID '" + taskID + "' not found"); + } + + public static boolean cancelTaskActiveOnThisShard( + SolrQueryRequest solrQueryRequest, String taskId) { + CancellableCollector cancellableTask = + solrQueryRequest.getCore().getCancellableQueryTracker().getCancellableTask(taskId); + if (cancellableTask != null) { + cancellableTask.cancel(); + return true; + } + return false; + } +} diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/CancelTaskAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/CancelTaskAPI.java deleted file mode 100644 index a63adc055f42..000000000000 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/CancelTaskAPI.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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.client.solrj.SolrRequest.METHOD.GET; - -import org.apache.solr.api.EndPoint; -import org.apache.solr.handler.component.QueryCancellationHandler; -import org.apache.solr.request.SolrQueryRequest; -import org.apache.solr.response.SolrQueryResponse; -import org.apache.solr.security.PermissionNameProvider; - -/** - * V2 API for cancelling a currently running "task". - * - *

This API (GET /v2/collections/collectionName/tasks/cancel) is analogous to the v1 - * /solr/collectionName/tasks/cancel API. - */ -public class CancelTaskAPI { - private final QueryCancellationHandler cancellationHandler; - - public CancelTaskAPI(QueryCancellationHandler cancellationHandler) { - this.cancellationHandler = cancellationHandler; - } - - @EndPoint( - path = {"/tasks/cancel"}, - method = GET, - permission = PermissionNameProvider.Name.READ_PERM) - public void cancelActiveTask(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception { - cancellationHandler.handleRequestBody(req, rsp); - } -} diff --git a/solr/core/src/java/org/apache/solr/handler/component/ActiveTaskQuerySupport.java b/solr/core/src/java/org/apache/solr/handler/component/ActiveTaskQuerySupport.java index 6276b419451f..b6a2fcc8b777 100644 --- a/solr/core/src/java/org/apache/solr/handler/component/ActiveTaskQuerySupport.java +++ b/solr/core/src/java/org/apache/solr/handler/component/ActiveTaskQuerySupport.java @@ -18,6 +18,7 @@ import static org.apache.solr.common.params.CommonParams.DISTRIB; import static org.apache.solr.common.params.CommonParams.QT; +import static org.apache.solr.common.params.CommonParams.QUERY_UUID; import static org.apache.solr.common.params.CommonParams.TASK_CHECK_UUID; import java.util.ArrayList; @@ -29,39 +30,51 @@ import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.common.util.NamedList; import org.apache.solr.core.CoreContainer; +import org.apache.solr.handler.admin.api.CancelTask; import org.apache.solr.handler.admin.api.ListActiveTasks; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.response.SolrQueryResponse; public class ActiveTaskQuerySupport { private static final String ACTIVE_TASK_LIST_HANDLER_PATH = "/tasks/list"; + private static final String CANCEL_TASK_HANDLER_PATH = "/tasks/cancel"; private ActiveTaskQuerySupport() {} public static List listActiveTasks(SolrQueryRequest req) throws Exception { - return execute(req, null).taskList; + return execute(req, null, false).taskList; } public static boolean isTaskActive(SolrQueryRequest req, String taskId) throws Exception { - return execute(req, taskId).taskActive; + return execute(req, taskId, false).taskActive; } - private static TaskQueryResult execute(SolrQueryRequest req, String taskId) throws Exception { + public static boolean cancelTask(SolrQueryRequest req, String taskId) throws Exception { + return execute(req, taskId, true).taskCancelled; + } + + private static TaskQueryResult execute( + SolrQueryRequest req, String taskId, boolean isCancellationRequest) throws Exception { if (!shouldDistributed(req)) { - return localResult(req, taskId); + return localResult(req, taskId, isCancellationRequest); } - return distributedResult(req, taskId); + return distributedResult(req, taskId, isCancellationRequest); } - private static TaskQueryResult localResult(SolrQueryRequest req, String taskId) { + private static TaskQueryResult localResult( + SolrQueryRequest req, String taskId, boolean isCancellationRequest) { if (taskId != null) { - return new TaskQueryResult(List.of(), ListActiveTasks.isTaskActiveOnThisShard(req, taskId)); + return (isCancellationRequest) + ? new TaskQueryResult( + List.of(), false, CancelTask.cancelTaskActiveOnThisShard(req, taskId)) + : new TaskQueryResult( + List.of(), ListActiveTasks.isTaskActiveOnThisShard(req, taskId), false); } - return new TaskQueryResult(ListActiveTasks.getActiveTasksOnThisShard(req), false); + return new TaskQueryResult(ListActiveTasks.getActiveTasksOnThisShard(req), false, false); } - private static TaskQueryResult distributedResult(SolrQueryRequest req, String taskId) - throws Exception { + private static TaskQueryResult distributedResult( + SolrQueryRequest req, String taskId, boolean isCancellationRequest) { final ShardHandler shardHandler = req.getCoreContainer().getShardHandlerFactory().getShardHandler(); final ResponseBuilder responseBuilder = @@ -71,7 +84,7 @@ private static TaskQueryResult distributedResult(SolrQueryRequest req, String ta if (!responseBuilder.isDistrib || responseBuilder.shards == null || responseBuilder.shards.length == 0) { - return localResult(req, taskId); + return localResult(req, taskId, isCancellationRequest); } final ShardRequest shardRequest = new ShardRequest(); @@ -81,9 +94,17 @@ private static TaskQueryResult distributedResult(SolrQueryRequest req, String ta for (String shard : shardRequest.actualShards) { ModifiableSolrParams params = new ModifiableSolrParams(); - params.set(QT, ACTIVE_TASK_LIST_HANDLER_PATH); + if (isCancellationRequest) { + params.set(QT, CANCEL_TASK_HANDLER_PATH); + } else { + params.set(QT, ACTIVE_TASK_LIST_HANDLER_PATH); + } if (taskId != null) { - params.set(TASK_CHECK_UUID, taskId); + if (isCancellationRequest) { + params.set(QUERY_UUID, taskId); + } else { + params.set(TASK_CHECK_UUID, taskId); + } } ShardHandler.setShardAttributesToParams(params, shardRequest.purpose); shardHandler.submit(shardRequest, shard, params); @@ -101,9 +122,11 @@ private static TaskQueryResult distributedResult(SolrQueryRequest req, String ta } if (taskId != null) { - return new TaskQueryResult(List.of(), mergeTaskStatus(shardRequest.responses)); + return (isCancellationRequest) + ? new TaskQueryResult(List.of(), false, mergeCancellationStatus(shardRequest.responses)) + : new TaskQueryResult(List.of(), mergeTaskStatus(shardRequest.responses), false); } - return new TaskQueryResult(mergeTaskList(shardRequest.responses), false); + return new TaskQueryResult(mergeTaskList(shardRequest.responses), false, false); } private static boolean shouldDistributed(SolrQueryRequest req) { @@ -128,6 +151,25 @@ private static boolean mergeTaskStatus(List responses) { return false; } + // FRAGILE: matches TaskCancellationHandler's human-readable "status" message by substring, for + // both V1 and V2 (CancelTask calls this same method). Kept as-is since changing the V1 wire + // format is out of scope; see the matching FRAGILE note in + // TaskCancellationHandler.handleRequestBody(). + private static boolean mergeCancellationStatus(List responses) { + for (ShardResponse shardResponse : responses) { + Object cancellationStatus = shardResponse.getSolrResponse().getResponse().get("status"); + if (cancellationStatus instanceof Boolean && (Boolean) cancellationStatus) { + return true; + } + + if (cancellationStatus instanceof String + && ((String) cancellationStatus).contains("cancelled successfully")) { + return true; + } + } + return false; + } + @SuppressWarnings("unchecked") private static List mergeTaskList(List responses) { Map mergedTasks = new LinkedHashMap<>(); @@ -156,10 +198,13 @@ private static List mergeTaskList(List respons private static final class TaskQueryResult { private final List taskList; private final boolean taskActive; + private final boolean taskCancelled; - private TaskQueryResult(List taskList, boolean taskActive) { + private TaskQueryResult( + List taskList, boolean taskActive, boolean taskCancelled) { this.taskList = taskList; this.taskActive = taskActive; + this.taskCancelled = taskCancelled; } } } diff --git a/solr/core/src/java/org/apache/solr/handler/component/QueryCancellationComponent.java b/solr/core/src/java/org/apache/solr/handler/component/QueryCancellationComponent.java deleted file mode 100644 index 9648bb312e44..000000000000 --- a/solr/core/src/java/org/apache/solr/handler/component/QueryCancellationComponent.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * 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.component; - -import java.io.IOException; -import org.apache.solr.search.CancellableCollector; - -/** Responsible for handling query cancellation requests */ -public class QueryCancellationComponent extends SearchComponent { - public static final String COMPONENT_NAME = "querycancellation"; - - private boolean shouldProcess; - - @Override - public void prepare(ResponseBuilder rb) throws IOException { - if (rb.isCancellation()) { - shouldProcess = true; - } - } - - @Override - public void process(ResponseBuilder rb) { - if (!shouldProcess) { - return; - } - - String cancellationUUID = rb.getCancellationUUID(); - - if (cancellationUUID == null) { - throw new RuntimeException("Null query UUID seen"); - } - - CancellableCollector cancellableTask = - rb.req.getCore().getCancellableQueryTracker().getCancellableTask(cancellationUUID); - - if (cancellableTask != null) { - cancellableTask.cancel(); - rb.rsp.add("cancellationResult", "success"); - } else { - rb.rsp.add("cancellationResult", "not found"); - } - } - - @Override - @SuppressWarnings("unchecked") - public void handleResponses(ResponseBuilder rb, ShardRequest sreq) { - if (!shouldProcess) { - return; - } - - boolean queryFound = false; - - for (ShardResponse r : sreq.responses) { - - String cancellationResult = - (String) r.getSolrResponse().getResponse().get("cancellationResult"); - - if (cancellationResult.equalsIgnoreCase("success")) { - queryFound = true; - - break; - } - } - - // If any shard sees the query as present, then we mark the query as successfully cancelled. If - // no shard found the query, then that can denote that the query was not found. This is - // important since the query cancellation request is broadcast to all shards, and the query - // might have completed on some shards but not on others - - if (queryFound) { - rb.rsp - .getValues() - .add( - "status", - "Query with queryID " + rb.getCancellationUUID() + " cancelled successfully"); - rb.rsp.getValues().add("responseCode", 200 /* HTTP OK */); - } else { - rb.rsp - .getValues() - .add("status", "Query with queryID " + rb.getCancellationUUID() + " not found"); - rb.rsp.getValues().add("responseCode", 404 /* HTTP NOT FOUND */); - } - } - - @Override - public String getDescription() { - return "Supports cancellation of queries which are cancellable"; - } - - @Override - public Category getCategory() { - return Category.OTHER; - } -} diff --git a/solr/core/src/java/org/apache/solr/handler/component/QueryCancellationHandler.java b/solr/core/src/java/org/apache/solr/handler/component/TaskCancellationHandler.java similarity index 67% rename from solr/core/src/java/org/apache/solr/handler/component/QueryCancellationHandler.java rename to solr/core/src/java/org/apache/solr/handler/component/TaskCancellationHandler.java index 18c99ba09c3b..a99ba2d1a41e 100644 --- a/solr/core/src/java/org/apache/solr/handler/component/QueryCancellationHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/component/TaskCancellationHandler.java @@ -19,12 +19,10 @@ import static org.apache.solr.common.params.CommonParams.QUERY_UUID; import java.util.Collection; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import org.apache.solr.api.AnnotatedApi; import org.apache.solr.api.Api; -import org.apache.solr.handler.admin.api.CancelTaskAPI; +import org.apache.solr.api.JerseyResource; +import org.apache.solr.handler.admin.api.CancelTask; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.request.SolrRequestHandler; import org.apache.solr.response.SolrQueryResponse; @@ -32,40 +30,36 @@ import org.apache.solr.security.PermissionNameProvider; /** Handles requests for query cancellation for cancellable queries */ -public class QueryCancellationHandler extends TaskManagementHandler { +public class TaskCancellationHandler extends TaskManagementHandler { // This can be a parent level member but we keep it here to allow future handlers to have // a custom list of components - private List components; @Override public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception { - ResponseBuilder rb = buildResponseBuilder(req, rsp, getComponentsList()); - Map extraParams = null; + String taskCancellationID = req.getParams().get(QUERY_UUID, null); - rb.setCancellation(true); - - String cancellationUUID = req.getParams().get(QUERY_UUID, null); - - if (cancellationUUID == null) { + if (taskCancellationID == null) { throw new IllegalArgumentException( "Query cancellation was requested but no query UUID for cancellation was given"); } - if (rb.isDistrib) { - extraParams = new HashMap<>(); - - extraParams.put(QUERY_UUID, cancellationUUID); + boolean isTaskCancelled = ActiveTaskQuerySupport.cancelTask(req, taskCancellationID); + + // FRAGILE: "cancelled successfully" is matched by + // ActiveTaskQuerySupport.mergeCancellationStatus() for cross-shard aggregation. Don't reword + // without checking there and TestTaskManagement.testCrossShardTaskCancellationVisibility*. + if (isTaskCancelled) { + rsp.add("status", "Query with queryID " + taskCancellationID + " cancelled successfully"); + rsp.add("responseCode", 200); + } else { + rsp.add("status", "Query with queryID " + taskCancellationID + " not found"); + rsp.add("responseCode", 404); } - - // Let this be visible to handleResponses in the handling component - rb.setCancellationUUID(cancellationUUID); - - processRequest(req, rb, extraParams); } @Override public String getDescription() { - return "Cancel queries"; + return "Cancel active tasks"; } @Override @@ -83,7 +77,6 @@ public SolrRequestHandler getSubHandler(String path) { if (path.startsWith("/tasks/cancel")) { return this; } - return null; } @@ -94,14 +87,11 @@ public Boolean registerV2() { @Override public Collection getApis() { - return AnnotatedApi.getApis(new CancelTaskAPI(this)); + return List.of(); } - private List getComponentsList() { - if (components == null) { - components = buildComponentsList(); - } - - return components; + @Override + public Collection> getJerseyResources() { + return List.of(CancelTask.class); } } diff --git a/solr/core/src/java/org/apache/solr/handler/component/TaskManagementHandler.java b/solr/core/src/java/org/apache/solr/handler/component/TaskManagementHandler.java index 3e9c5ec21160..ad89247223f1 100644 --- a/solr/core/src/java/org/apache/solr/handler/component/TaskManagementHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/component/TaskManagementHandler.java @@ -17,15 +17,8 @@ package org.apache.solr.handler.component; import static org.apache.solr.common.params.CommonParams.DISTRIB; -import static org.apache.solr.common.params.CommonParams.PATH; -import java.io.IOException; -import java.util.ArrayList; import java.util.List; -import java.util.Map; -import org.apache.solr.common.SolrException; -import org.apache.solr.common.params.CommonParams; -import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrCore; import org.apache.solr.handler.RequestHandlerBase; @@ -44,80 +37,6 @@ public void inform(SolrCore core) { this.shardHandlerFactory = core.getCoreContainer().getShardHandlerFactory(); } - /** - * Process the actual request. extraParams is required for allowing sub handlers to pass in custom - * parameters to be put in the outgoing shard request - */ - protected void processRequest( - SolrQueryRequest req, ResponseBuilder rb, Map extraParams) - throws IOException { - ShardHandler shardHandler = shardHandlerFactory.getShardHandler(); - List components = rb.components; - - shardHandler.prepDistributed(rb); - - for (SearchComponent c : components) { - c.prepare(rb); - } - - if (!rb.isDistrib) { - for (SearchComponent component : components) { - component.process(rb); - } - } else { - ShardRequest sreq = new ShardRequest(); - - // Distribute to all shards - sreq.shards = rb.shards; - sreq.actualShards = sreq.shards; - - sreq.responses = new ArrayList<>(sreq.actualShards.length); - rb.finished = new ArrayList<>(); - - for (String shard : sreq.actualShards) { - ModifiableSolrParams params = new ModifiableSolrParams(sreq.params); - String reqPath = (String) req.getContext().get(PATH); - - params.set(CommonParams.QT, reqPath); - ShardHandler.setShardAttributesToParams(params, sreq.purpose); - - if (extraParams != null) { - for (Map.Entry entry : extraParams.entrySet()) { - params.set(entry.getKey(), entry.getValue()); - } - } - - shardHandler.submit(sreq, shard, params); - } - - ShardResponse srsp = shardHandler.takeCompletedOrError(); - - if (srsp.getException() != null) { - shardHandler.cancelAll(); - if (srsp.getException() instanceof SolrException) { - throw (SolrException) srsp.getException(); - } else { - throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, srsp.getException()); - } - } - - rb.finished.add(srsp.getShardRequest()); - - for (SearchComponent c : components) { - c.handleResponses(rb, srsp.getShardRequest()); - } - } - } - - public static List buildComponentsList() { - List components = new ArrayList<>(2); - - QueryCancellationComponent component = new QueryCancellationComponent(); - components.add(component); - - return components; - } - public static ResponseBuilder buildResponseBuilder( SolrQueryRequest req, SolrQueryResponse rsp, List components) { CoreContainer cc = req.getCoreContainer(); diff --git a/solr/core/src/resources/ImplicitPlugins.json b/solr/core/src/resources/ImplicitPlugins.json index a9e8dd45ef4c..8bb2019695a7 100644 --- a/solr/core/src/resources/ImplicitPlugins.json +++ b/solr/core/src/resources/ImplicitPlugins.json @@ -144,21 +144,15 @@ } }, "/tasks/cancel": { - "class": "solr.QueryCancellationHandler", - "useParams":"_TASK_CANCELLATION", - "components": [ - "querycancellation" - ] + "class": "solr.TaskCancellationHandler", + "useParams":"_TASK_CANCELLATION" }, "/tasks/list": { "class": "solr.ActiveTasksListHandler", - "useParams":"_LIST_TASKS", - "components": [ - "activetaskslist" - ] + "useParams":"_LIST_TASKS" } }, - "queryResponseWriter": { + "queryResponseWriter": { "geojson": { "class": "solr.GeoJSONResponseWriter" }, diff --git a/solr/core/src/test/org/apache/solr/core/SolrCoreTest.java b/solr/core/src/test/org/apache/solr/core/SolrCoreTest.java index 9fa63f89801f..19f4f5312e02 100644 --- a/solr/core/src/test/org/apache/solr/core/SolrCoreTest.java +++ b/solr/core/src/test/org/apache/solr/core/SolrCoreTest.java @@ -139,7 +139,7 @@ public void testImplicitPlugins() { ++ihCount; assertEquals(pathToClassMap.get("update"), "solr.V2UpdateRequestHandler"); ++ihCount; - assertEquals(pathToClassMap.get("/tasks/cancel"), "solr.QueryCancellationHandler"); + assertEquals(pathToClassMap.get("/tasks/cancel"), "solr.TaskCancellationHandler"); ++ihCount; assertEquals(pathToClassMap.get("/tasks/list"), "solr.ActiveTasksListHandler"); } diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/CancelTaskAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/CancelTaskAPITest.java new file mode 100644 index 000000000000..ef85bf2a2121 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/CancelTaskAPITest.java @@ -0,0 +1,80 @@ +/* + * 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 org.apache.lucene.search.TotalHitCountCollector; +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.api.model.CancelTaskResponse; +import org.apache.solr.client.api.model.IndexType; +import org.apache.solr.client.solrj.RemoteSolrException; +import org.apache.solr.client.solrj.request.TasksApi; +import org.apache.solr.core.SolrCore; +import org.apache.solr.search.CancellableCollector; +import org.apache.solr.util.ExternalPaths; +import org.apache.solr.util.SolrJettyTestRule; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +/** + * HTTP-level test for the {@link CancelTask} JAX-RS endpoint -- exercises real Jersey route + * registration, DELETE dispatch, response serialization, and HTTP 404 mapping, none of which {@link + * CancelTaskTest} (a direct in-process method call) can catch. + */ +public class CancelTaskAPITest extends SolrTestCase { + + private static final String CORE_NAME = "cancelTaskApiTestCore"; + + @ClassRule public static SolrJettyTestRule solrTestRule = new SolrJettyTestRule(); + + @BeforeClass + public static void beforeClass() throws Exception { + System.setProperty(ALLOW_PATHS_SYSPROP, ExternalPaths.SERVER_HOME.toAbsolutePath().toString()); + solrTestRule.startSolr(createTempDir()); + solrTestRule.newCollection(CORE_NAME).withConfigSet(ExternalPaths.DEFAULT_CONFIGSET).create(); + } + + @Test + public void testCancelRunningTaskHttp() throws Exception { + final String taskId = "cancel-task-api-test"; + try (SolrCore core = solrTestRule.getJetty().getCoreContainer().getCore(CORE_NAME)) { + core.getCancellableQueryTracker() + .addShardLevelActiveQuery(taskId, new CancellableCollector(new TotalHitCountCollector())); + + var req = new TasksApi.CancelRunningTask(IndexType.CORE, CORE_NAME, taskId); + CancelTaskResponse response = req.process(solrTestRule.getSolrClient(null)); + + assertEquals(0, response.responseHeader.status); + assertEquals(CancelTaskResponse.CancellationStatus.SUCCESS, response.status); + } + } + + @Test + public void testCancelNonExistentTaskHttpReturns404() { + var req = new TasksApi.CancelRunningTask(IndexType.CORE, CORE_NAME, "does-not-exist"); + + RemoteSolrException ex = + expectThrows( + RemoteSolrException.class, () -> req.process(solrTestRule.getSolrClient(null))); + assertEquals("Expected 404 for non-existent task", 404, ex.code()); + assertTrue( + "Expected error message to identify the missing task: " + ex.getMessage(), + ex.getMessage().contains("does-not-exist")); + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/CancelTaskTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/CancelTaskTest.java new file mode 100644 index 000000000000..1fcc1a52083d --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/CancelTaskTest.java @@ -0,0 +1,82 @@ +/* + * 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.SolrTestCaseJ4.assumeWorkingMockito; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.api.model.CancelTaskResponse; +import org.apache.solr.common.SolrException; +import org.apache.solr.core.CancellableQueryTracker; +import org.apache.solr.core.SolrCore; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.search.CancellableCollector; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +public class CancelTaskTest extends SolrTestCase { + + private CancellableQueryTracker cancellableQueryTracker; + private CancelTask cancelTask; + + @BeforeClass + public static void ensureWorkingMockito() { + assumeWorkingMockito(); + } + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + + SolrQueryRequest solrQueryRequest = mock(SolrQueryRequest.class); + SolrCore solrCore = mock(SolrCore.class); + cancellableQueryTracker = mock(CancellableQueryTracker.class); + + when(solrQueryRequest.getCore()).thenReturn(solrCore); + when(solrCore.getCancellableQueryTracker()).thenReturn(cancellableQueryTracker); + + cancelTask = new CancelTask(solrQueryRequest); + } + + @Test + public void testCancelRunningTask() throws Exception { + CancellableCollector cancellableCollector = mock(CancellableCollector.class); + when(cancellableQueryTracker.getCancellableTask("taskID_running")) + .thenReturn(cancellableCollector); + + CancelTaskResponse response = cancelTask.cancelRunningTask("taskID_running"); + + assertEquals(CancelTaskResponse.CancellationStatus.SUCCESS, response.status); + verify(cancellableCollector).cancel(); + } + + @Test + public void testCancelNonExistentTaskReturns404() { + when(cancellableQueryTracker.getCancellableTask("taskID_missing")).thenReturn(null); + + SolrException exception = + expectThrows(SolrException.class, () -> cancelTask.cancelRunningTask("taskID_missing")); + assertEquals(SolrException.ErrorCode.NOT_FOUND.code, exception.code()); + assertTrue(exception.getMessage().contains("taskID_missing")); + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/GetTaskStatusTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/GetTaskStatusTest.java index acc4fa1d1c7b..0bf04ca29aa5 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/GetTaskStatusTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/GetTaskStatusTest.java @@ -17,10 +17,11 @@ package org.apache.solr.handler.admin.api; +import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.SolrTestCase; import org.apache.solr.client.api.model.TaskStatusResponse; import org.apache.solr.core.CancellableQueryTracker; import org.apache.solr.core.SolrCore; @@ -29,7 +30,7 @@ import org.junit.BeforeClass; import org.junit.Test; -public class GetTaskStatusTest extends SolrTestCaseJ4 { +public class GetTaskStatusTest extends SolrTestCase { private SolrQueryRequest mockQueryRequest; private SolrCore solrCore; diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/ListActiveTasksTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/ListActiveTasksTest.java index 113f9dcb82a2..28a24977b8cb 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/ListActiveTasksTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/ListActiveTasksTest.java @@ -17,13 +17,14 @@ package org.apache.solr.handler.admin.api; +import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; -import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.SolrTestCase; import org.apache.solr.client.api.model.ListActiveTaskResponse; import org.apache.solr.core.CancellableQueryTracker; import org.apache.solr.core.SolrCore; @@ -32,7 +33,7 @@ import org.junit.BeforeClass; import org.junit.Test; -public class ListActiveTasksTest extends SolrTestCaseJ4 { +public class ListActiveTasksTest extends SolrTestCase { private SolrQueryRequest mockQueryRequest; private SolrCore solrCore; diff --git a/solr/core/src/test/org/apache/solr/search/TestTaskManagement.java b/solr/core/src/test/org/apache/solr/search/TestTaskManagement.java index f1039705765e..182ceaa637c0 100644 --- a/solr/core/src/test/org/apache/solr/search/TestTaskManagement.java +++ b/solr/core/src/test/org/apache/solr/search/TestTaskManagement.java @@ -29,7 +29,10 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import org.apache.lucene.search.TotalHitCountCollector; import org.apache.lucene.util.BytesRef; +import org.apache.solr.client.api.model.CancelTaskResponse; +import org.apache.solr.client.api.model.IndexType; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrRequest.SolrRequestType; import org.apache.solr.client.solrj.SolrServerException; @@ -37,6 +40,7 @@ import org.apache.solr.client.solrj.request.CollectionAdminRequest; import org.apache.solr.client.solrj.request.GenericSolrRequest; import org.apache.solr.client.solrj.request.QueryRequest; +import org.apache.solr.client.solrj.request.TasksApi; import org.apache.solr.cloud.SolrCloudTestCase; import org.apache.solr.common.SolrInputDocument; import org.apache.solr.common.cloud.DocCollection; @@ -262,17 +266,8 @@ public void testCheckSpecificQueryStatus_Inactive() throws Exception { } /** - * Regression test for cross-shard task visibility. - * - *

On {@code main}, {@code ActiveTasksListHandler.handleRequestBody()} delegates to {@code - * processRequest()}, which fans the request out to every shard via the distributed query - * pipeline. {@code ActiveTasksListComponent.handleResponses()} then aggregates the per-shard - * results, so a task running on shard 2 is visible when the status-check request lands on shard - * 1. - * - *

If this handler is migrated to JAX-RS and the fan-out is replaced with a direct call to the - * handler node's own {@code CancellableQueryTracker}, a task registered only on shard 2 becomes - * invisible to a request handled by shard 1, causing a false "inactive" response. + * Regression test for cross-shard task visibility -- guards against a task on shard 2 becoming + * invisible to a status-check request handled by shard 1. */ @Test public void testCrossShardTaskStatusVisibility() throws Exception { @@ -329,6 +324,108 @@ public void testCrossShardTaskStatusVisibility() throws Exception { } } + /** + * Regression test for cross-shard task cancellation. The current V1 approach is dependent on + * string matching and that makes it brittle, so this helps us make sure no issues creep in. + */ + @Test + public void testCrossShardTaskCancellationVisibility() throws Exception { + DocCollection docCollection = + cluster.getSolrClient().getClusterState().getCollection(COLLECTION_NAME); + List slices = new ArrayList<>(docCollection.getSlices()); + assertEquals("test requires exactly 2 shards", 2, slices.size()); + Replica shard1Leader = slices.get(0).getLeader(); + Replica shard2Leader = slices.get(1).getLeader(); + assumeFalse( + "Both shard leaders landed on the same node — cross-shard scenario cannot be tested", + shard1Leader.getNodeName().equals(shard2Leader.getNodeName())); + + final String taskId = "cross-shard-cancellation-test"; + + JettySolrRunner shard2Jetty = + cluster.getJettySolrRunners().stream() + .filter(j -> j.getNodeName().equals(shard2Leader.getNodeName())) + .findFirst() + .orElseThrow(() -> new AssertionError("No Jetty found for shard 2 leader")); + try (SolrCore shard2Core = shard2Jetty.getCoreContainer().getCore(shard2Leader.getCoreName())) { + assertNotNull("Could not open shard 2 core", shard2Core); + shard2Core + .getCancellableQueryTracker() + .addShardLevelActiveQuery(taskId, new CancellableCollector(new TotalHitCountCollector())); + + try { + try (var shard1Client = + new HttpJettySolrClient.Builder(shard1Leader.getBaseUrl()).build()) { + ModifiableSolrParams params = new ModifiableSolrParams(); + params.set(CommonParams.QUERY_UUID, taskId); + var cancelReq = + new GenericSolrRequest( + SolrRequest.METHOD.POST, "/tasks/cancel", SolrRequestType.ADMIN, params) + .setRequiresCollection(true); + NamedList response = shard1Client.request(cancelReq, COLLECTION_NAME); + + assertEquals( + "Task registered only on shard 2 must be cancellable via cross-shard fan-out from " + + "shard 1. Got: " + + response.get("status"), + 200, + response.get("responseCode")); + } + } finally { + shard2Core.getCancellableQueryTracker().removeCancellableQuery(taskId); + } + } + } + + /** + * Same as {@link #testCrossShardTaskCancellationVisibility}, but via the V2 API -- confirms V2 + * shares the same cross-shard string-matching fragility as V1. + */ + @Test + public void testCrossShardTaskCancellationVisibilityV2() throws Exception { + DocCollection docCollection = + cluster.getSolrClient().getClusterState().getCollection(COLLECTION_NAME); + List slices = new ArrayList<>(docCollection.getSlices()); + assertEquals("test requires exactly 2 shards", 2, slices.size()); + Replica shard1Leader = slices.get(0).getLeader(); + Replica shard2Leader = slices.get(1).getLeader(); + assumeFalse( + "Both shard leaders landed on the same node — cross-shard scenario cannot be tested", + shard1Leader.getNodeName().equals(shard2Leader.getNodeName())); + + final String taskId = "cross-shard-cancellation-test-v2"; + + JettySolrRunner shard2Jetty = + cluster.getJettySolrRunners().stream() + .filter(j -> j.getNodeName().equals(shard2Leader.getNodeName())) + .findFirst() + .orElseThrow(() -> new AssertionError("No Jetty found for shard 2 leader")); + try (SolrCore shard2Core = shard2Jetty.getCoreContainer().getCore(shard2Leader.getCoreName())) { + assertNotNull("Could not open shard 2 core", shard2Core); + shard2Core + .getCancellableQueryTracker() + .addShardLevelActiveQuery(taskId, new CancellableCollector(new TotalHitCountCollector())); + + try { + try (var shard1Client = + new HttpJettySolrClient.Builder(shard1Leader.getBaseUrl()).build()) { + var cancelReq = + new TasksApi.CancelRunningTask(IndexType.COLLECTION, COLLECTION_NAME, taskId); + CancelTaskResponse response = cancelReq.process(shard1Client, COLLECTION_NAME); + + assertEquals( + "Task registered only on shard 2 must be cancellable via the V2 API's cross-shard " + + "fan-out from shard 1. Got: " + + response.status, + CancelTaskResponse.CancellationStatus.SUCCESS, + response.status); + } + } finally { + shard2Core.getCancellableQueryTracker().removeCancellableQuery(taskId); + } + } + } + private CompletableFuture cancelQuery( final String queryID, Set cancelledQueryIdsSet, Set notFoundQueryIdSet) { return CompletableFuture.runAsync( diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/task-management.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/task-management.adoc index 5cce6eb8bc9c..96033d67c6d8 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/task-management.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/task-management.adoc @@ -17,22 +17,23 @@ // specific language governing permissions and limitations // under the License. -Solr offers a task management framework, that allows users to monitor (and even cancel) certain types of long-running tasks. +Solr offers a task management framework that allows users to monitor (and even cancel) certain types of long-running tasks. Queries are the only type of "task" currently supported, but additional types may be added in the future. == Registering Tasks for Task Management -Task-tracking and management is an opt-in feature: tracking must be explicitly enabled on each individual task. -For queries (the only type of "task" currently supported), this is done by specifying the `canCancel` boolean flag as a query-parameter. -A value of `true` enables task-management; `false` (the default) leaves it disabled. +Task tracking and management is an opt-in feature: tracking must be explicitly enabled on each individual task. +For queries (the only type of "task" currently supported), this is done by specifying the xref:query-guide:common-query-parameters.adoc#cancancel-parameter[`canCancel`] boolean flag as a query parameter. +A value of `true` enables task management; `false` (the default) leaves it disabled. -Solr will assign each task a UUID for tracking purposes. -Users may override this if desired with an arbitrary string of their choice using the `queryUUID` query-parameter. +Solr will assign each task an ID for tracking purposes. +Users may override this, if desired, with an arbitrary string of their choice, using the `queryUUID` query parameter when submitting the original query. (Users are responsible for ensuring that any `queryUUID` values they provide are unique and don't conflict with other running tasks.) -This UUID, whether generated or provided by the user, can then be used to track or cancel the task. +This ID, whether generated or provided by the user, can then be used to track or cancel the task: as a query parameter with the V1 API, or as part of the URL path with the V2 API. +See the sections below for the specifics of each operation. == Task Management Operations -Task management interface supports the following types of operations: +The task management interface supports the following types of operations: . List all currently running cancellable tasks. . Cancel a specific task. @@ -77,7 +78,7 @@ V1 API:: "status":0, "QTime":16}, "taskList":[ - "0,"q=weight_i:[0+TO+200]&canCancel=true&queryUUID=0", + "0","q=weight_i:[0+TO+200]&canCancel=true&queryUUID=0", "5","q=weight_i:[0+TO+200]&canCancel=true&queryUUID=5", "4bcd27bb-0792-4512-a699-532fa7878bd3","q=weight_i:[0+TO+200]&canCancel=true"]} ---- @@ -106,7 +107,7 @@ V2 API:: ==== ====== -== Cancelling An Active Cancellable Task +== Cancelling an Active Cancellable Task To cancel an active task, please use the following syntax: [tabs#cancelalltasks] @@ -124,14 +125,22 @@ V2 API:: ==== [source,bash] ---- -curl -X GET "http://localhost:8983/v2/collections/collectionName/tasks/cancel?queryUUID=5" +curl -X DELETE "http://localhost:8983/v2/collections/collectionName/tasks/5" ---- ==== ====== === Sample Response -==== If the task UUID was found and successfully cancelled: +The V1 and V2 APIs currently return the cancellation result in different shapes. + +==== If the task was found and successfully cancelled: + +[tabs#cancelalltasks-response-found] +====== +V1 API:: ++ +==== ---- { "responseHeader":{ @@ -140,9 +149,32 @@ curl -X GET "http://localhost:8983/v2/collections/collectionName/tasks/cancel?qu "status":"Query with queryID 5 cancelled successfully", "responseCode":200} ---- +==== + +V2 API:: ++ +==== +[source,json] +---- +{ + "responseHeader":{ + "status":0, + "QTime":26}, + "status":"SUCCESS"} +---- +==== +====== + +==== If the task was not found: -==== If the task UUID was not found +With the V1 API, a task that isn't found is still a `200` response, with `responseCode` in the body indicating the failure. +With the V2 API, a task that isn't found returns an actual HTTP `404`, with the standard v2 error envelope. +[tabs#cancelalltasks-response-notfound] +====== +V1 API:: ++ +==== ---- { "responseHeader":{ @@ -151,6 +183,26 @@ curl -X GET "http://localhost:8983/v2/collections/collectionName/tasks/cancel?qu "status":"Query with queryID 5 not found", "responseCode":404} ---- +==== + +V2 API:: ++ +==== +[source,json] +---- +{ + "responseHeader":{ + "status":404, + "QTime":24}, + "error":{ + "metadata":{ + "error-class":"org.apache.solr.common.SolrException", + "root-error-class":"org.apache.solr.common.SolrException"}, + "code":404, + "msg":"NOT_FOUND"}} +---- +==== +====== == Check Status of a Specific Task To check the status of a specific task, please use the following syntax: @@ -177,8 +229,8 @@ curl -X GET "http://localhost:8983/v2/collections/collectionName/tasks/5" ====== === taskUUID Parameter -With the V1 API, the `taskUUID` request parameter is used to specify a task UUID whose status can be checked. -With the V2 API, the task ID is instead supplied as part of the path, e.g. `/v2/collections/collectionName/tasks/{taskID}`. +With the V1 API, the `taskUUID` request parameter specifies which task's status to check. +With the V2 API, the task ID is instead supplied as part of the path, e.g. `/v2/collections/collectionName/tasks/\{taskID}`. === Sample Response