You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ds...@apache.org on 2026/09/22 00:35:13 UTC

(solr) branch branch_10x updated (dddbd79b697 -> 42ed629da61)

This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a change to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git


    from dddbd79b697 SOLR-18397: Clean up stale '*Copy' configuration entries in gradle.lockfiles (#4806)
     new b3e4f811f36 SOLR-18248: Migration of CancelTask V2 API to JAX-RS construct (#4828)
     new 42ed629da61 SOLR-18175: Use HTTP status 400 (not 500) for unsupported field existence queries (#4915)

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 ...or-list-api.yml => SOLR-18248-cancel-tasks.yml} |   4 +-
 .../unreleased/solr-18175-field-exists-400.yml     |   8 ++
 .../apache/solr/client/api/endpoint/TasksApi.java  |  12 +++
 ...iveTaskDetails.java => CancelTaskResponse.java} |  14 ++-
 .../api/{ListActiveTasks.java => CancelTask.java}  |  49 +++++----
 .../solr/handler/admin/api/CancelTaskAPI.java      |  48 ---------
 .../handler/component/ActiveTaskQuerySupport.java  |  77 ++++++++++---
 .../component/QueryCancellationComponent.java      | 108 -------------------
 ...onHandler.java => TaskCancellationHandler.java} |  52 ++++-----
 .../handler/component/TaskManagementHandler.java   |  81 --------------
 .../src/java/org/apache/solr/schema/FieldType.java |  20 +++-
 solr/core/src/resources/ImplicitPlugins.json       |  14 +--
 .../solr/collection1/conf/schema-customfield.xml   |   2 +
 .../test/org/apache/solr/core/SolrCoreTest.java    |   2 +-
 .../solr/handler/admin/api/CancelTaskAPITest.java  |  80 ++++++++++++++
 ...istActiveTasksTest.java => CancelTaskTest.java} |  58 +++++-----
 .../solr/handler/admin/api/GetTaskStatusTest.java  |   5 +-
 .../handler/admin/api/ListActiveTasksTest.java     |   5 +-
 ...ntField.java => MissingDocValuesFieldType.java} |  20 ++--
 .../search/FieldExistsQueryBadRequestTest.java     |  55 ++++++++++
 .../org/apache/solr/search/TestTaskManagement.java | 119 +++++++++++++++++++--
 .../deployment-guide/pages/task-management.adoc    |  82 +++++++++++---
 22 files changed, 514 insertions(+), 401 deletions(-)
 copy changelog/unreleased/{SOLR-18248-refactor-list-api.yml => SOLR-18248-cancel-tasks.yml} (62%)
 create mode 100644 changelog/unreleased/solr-18175-field-exists-400.yml
 copy solr/api/src/java/org/apache/solr/client/api/model/{ActiveTaskDetails.java => CancelTaskResponse.java} (78%)
 copy solr/core/src/java/org/apache/solr/handler/admin/api/{ListActiveTasks.java => CancelTask.java} (52%)
 delete mode 100644 solr/core/src/java/org/apache/solr/handler/admin/api/CancelTaskAPI.java
 delete mode 100644 solr/core/src/java/org/apache/solr/handler/component/QueryCancellationComponent.java
 rename solr/core/src/java/org/apache/solr/handler/component/{QueryCancellationHandler.java => TaskCancellationHandler.java} (67%)
 create mode 100644 solr/core/src/test/org/apache/solr/handler/admin/api/CancelTaskAPITest.java
 copy solr/core/src/test/org/apache/solr/handler/admin/api/{ListActiveTasksTest.java => CancelTaskTest.java} (51%)
 copy solr/core/src/test/org/apache/solr/schema/{WrappedTrieIntField.java => MissingDocValuesFieldType.java} (64%)
 create mode 100644 solr/core/src/test/org/apache/solr/search/FieldExistsQueryBadRequestTest.java


(solr) 02/02: SOLR-18175: Use HTTP status 400 (not 500) for unsupported field existence queries (#4915)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit 42ed629da6116e4a5634ae1962490a6d520c73e8
Author: Xinyao Zhang <43...@users.noreply.github.com>
AuthorDate: Sun Sep 20 16:12:54 2026 -0400

    SOLR-18175: Use HTTP status 400 (not 500) for unsupported field existence queries (#4915)
    
    (cherry picked from commit b50f0a7f578d6e6c87fda95972d8d7742ca3569e)
---
 .../unreleased/solr-18175-field-exists-400.yml     |  8 ++++
 .../src/java/org/apache/solr/schema/FieldType.java | 20 +++++++-
 .../solr/collection1/conf/schema-customfield.xml   |  2 +
 .../solr/schema/MissingDocValuesFieldType.java     | 31 ++++++++++++
 .../search/FieldExistsQueryBadRequestTest.java     | 55 ++++++++++++++++++++++
 5 files changed, 114 insertions(+), 2 deletions(-)

diff --git a/changelog/unreleased/solr-18175-field-exists-400.yml b/changelog/unreleased/solr-18175-field-exists-400.yml
new file mode 100644
index 00000000000..ce338d537b0
--- /dev/null
+++ b/changelog/unreleased/solr-18175-field-exists-400.yml
@@ -0,0 +1,8 @@
+title: >
+  Return a client error for unsupported field existence queries instead of an internal server error.
+type: fixed
+authors:
+  - name: Xinyao Zhang
+links:
+  - name: SOLR-18175
+    url: https://issues.apache.org/jira/browse/SOLR-18175
diff --git a/solr/core/src/java/org/apache/solr/schema/FieldType.java b/solr/core/src/java/org/apache/solr/schema/FieldType.java
index 971235e07b5..f9d276935a6 100644
--- a/solr/core/src/java/org/apache/solr/schema/FieldType.java
+++ b/solr/core/src/java/org/apache/solr/schema/FieldType.java
@@ -1070,16 +1070,32 @@ public abstract class FieldType extends FieldProperties {
    */
   public Query getExistenceQuery(QParser parser, SchemaField field) {
     if (field.hasDocValues()) {
-      return new FieldExistsQuery(field.getName());
+      return validateFieldExistsQuery(parser, new FieldExistsQuery(field.getName()));
     } else if (!field.omitNorms()
         && !isPointField()) { // TODO: Remove !isPointField() for SOLR-14199
-      return new FieldExistsQuery(field.getName());
+      return validateFieldExistsQuery(parser, new FieldExistsQuery(field.getName()));
     } else {
       // Default to an unbounded range query
       return getSpecializedExistenceQuery(parser, field);
     }
   }
 
+  private static Query validateFieldExistsQuery(QParser parser, FieldExistsQuery query) {
+    if (parser == null || parser.getReq() == null || parser.getReq().getCore() == null) {
+      return query;
+    }
+
+    try {
+      parser.getReq().getCore().withSearcher(searcher -> searcher.rewrite(query));
+    } catch (IllegalStateException e) {
+      // FieldExistsQuery.rewrite throws this if the field lacks doc values, norms, and vectors.
+      throw new SolrException(ErrorCode.BAD_REQUEST, e);
+    } catch (IOException e) {
+      throw new SolrException(ErrorCode.SERVER_ERROR, e);
+    }
+    return query;
+  }
+
   /**
    * Returns a Query instance for doing existence searches for a field without certain options, such
    * as docValues or norms.
diff --git a/solr/core/src/test-files/solr/collection1/conf/schema-customfield.xml b/solr/core/src/test-files/solr/collection1/conf/schema-customfield.xml
index 2b1f719c6dc..2d3b1e9e4e6 100644
--- a/solr/core/src/test-files/solr/collection1/conf/schema-customfield.xml
+++ b/solr/core/src/test-files/solr/collection1/conf/schema-customfield.xml
@@ -40,11 +40,13 @@
   </fieldType>
 
   <fieldType name="customintfield" class="${solr.tests.CustomIntFieldType}" docValues="${solr.tests.numeric.dv}" />
+  <fieldType name="missingdocvaluesfield" class="org.apache.solr.schema.MissingDocValuesFieldType" docValues="true"/>
 
   <field name="id" type="string" indexed="true" stored="true" multiValued="false" required="true"/>
   <field name="intfield" type="int" indexed="true" stored="true"/>
   <field name="swap_foo_bar_in_prefix_query" type="customfield" indexed="true" stored="true" multiValued="true"/>
   <field name="int_prefix_as_range" type="customintfield" indexed="true" stored="true"/>
+  <field name="bad_exists" type="missingdocvaluesfield" indexed="true" stored="false"/>
 
   <field name="_version_" type="long" indexed="true" stored="true" multiValued="false"/>
 
diff --git a/solr/core/src/test/org/apache/solr/schema/MissingDocValuesFieldType.java b/solr/core/src/test/org/apache/solr/schema/MissingDocValuesFieldType.java
new file mode 100644
index 00000000000..c09884868dc
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/schema/MissingDocValuesFieldType.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.schema;
+
+import java.util.List;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.StringField;
+import org.apache.lucene.index.IndexableField;
+
+/** Test-only field type that omits the doc values it advertises in the schema. */
+public class MissingDocValuesFieldType extends StrField {
+
+  @Override
+  public List<IndexableField> createFields(SchemaField field, Object value) {
+    return List.of(new StringField(field.getName(), value.toString(), Field.Store.NO));
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/search/FieldExistsQueryBadRequestTest.java b/solr/core/src/test/org/apache/solr/search/FieldExistsQueryBadRequestTest.java
new file mode 100644
index 00000000000..8878dfd80e1
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/search/FieldExistsQueryBadRequestTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.search;
+
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.request.SolrQueryRequest;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class FieldExistsQueryBadRequestTest extends SolrTestCaseJ4 {
+
+  @BeforeClass
+  public static void beforeClass() throws Exception {
+    System.setProperty(
+        "solr.tests.CustomIntFieldType",
+        Boolean.getBoolean(NUMERIC_POINTS_SYSPROP)
+            ? "org.apache.solr.schema.IntPointPrefixActsAsRangeQueryFieldType"
+            : "org.apache.solr.schema.TrieIntPrefixActsAsRangeQueryFieldType");
+    initCore("solrconfig-basic.xml", "schema-customfield.xml");
+
+    assertU(adoc("id", "1", "bad_exists", "alpha"));
+    assertU(commit());
+  }
+
+  @Test
+  public void testQueryReturnsBadRequest() {
+    assertBadRequest(req("q", "bad_exists:*"));
+  }
+
+  @Test
+  public void testFacetQueryReturnsBadRequest() {
+    assertBadRequest(req("q", "*:*", "rows", "0", "facet", "true", "facet.query", "bad_exists:*"));
+  }
+
+  private void assertBadRequest(SolrQueryRequest req) {
+    SolrException exception = expectThrows(SolrException.class, () -> h.query(req));
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code());
+    assertTrue(exception.getCause() instanceof IllegalStateException);
+  }
+}


(solr) 01/02: SOLR-18248: Migration of CancelTask V2 API to JAX-RS construct (#4828)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit b3e4f811f36bff0f70de8f461084470574bdef4f
Author: Jalaz Kumar <ja...@gmail.com>
AuthorDate: Sun Sep 20 07:51:47 2026 +0530

    SOLR-18248: Migration of CancelTask V2 API to JAX-RS construct (#4828)
    
    Co-authored-by: Eric Pugh <ep...@opensourceconnections.com>
    (cherry picked from commit 6efa7ebc46bcb6938521165420d4c10f490581d2)
---
 changelog/unreleased/SOLR-18248-cancel-tasks.yml   |   9 ++
 .../apache/solr/client/api/endpoint/TasksApi.java  |  12 +++
 .../solr/client/api/model/CancelTaskResponse.java  |  31 ++++++
 .../apache/solr/handler/admin/api/CancelTask.java  |  68 ++++++++++++
 .../solr/handler/admin/api/CancelTaskAPI.java      |  48 ---------
 .../handler/component/ActiveTaskQuerySupport.java  |  77 ++++++++++---
 .../component/QueryCancellationComponent.java      | 108 -------------------
 ...onHandler.java => TaskCancellationHandler.java} |  52 ++++-----
 .../handler/component/TaskManagementHandler.java   |  81 --------------
 solr/core/src/resources/ImplicitPlugins.json       |  14 +--
 .../test/org/apache/solr/core/SolrCoreTest.java    |   2 +-
 .../solr/handler/admin/api/CancelTaskAPITest.java  |  80 ++++++++++++++
 ...istActiveTasksTest.java => CancelTaskTest.java} |  58 +++++-----
 .../solr/handler/admin/api/GetTaskStatusTest.java  |   5 +-
 .../handler/admin/api/ListActiveTasksTest.java     |   5 +-
 .../org/apache/solr/search/TestTaskManagement.java | 119 +++++++++++++++++++--
 .../deployment-guide/pages/task-management.adoc    |  82 +++++++++++---
 17 files changed, 497 insertions(+), 354 deletions(-)

diff --git a/changelog/unreleased/SOLR-18248-cancel-tasks.yml b/changelog/unreleased/SOLR-18248-cancel-tasks.yml
new file mode 100644
index 00000000000..d86b6429de1
--- /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 46d80de5d9d..d2b9c921637 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 @@ package org.apache.solr.client.api.endpoint;
 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 @@ public interface TasksApi {
         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 00000000000..41158d6e521
--- /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 00000000000..095a981a556
--- /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 a63adc055f4..00000000000
--- 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".
- *
- * <p>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 6276b419451..b6a2fcc8b77 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 @@ package org.apache.solr.handler.component;
 
 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.SolrException;
 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<ActiveTaskDetails> 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 @@ public class ActiveTaskQuerySupport {
     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 @@ public class ActiveTaskQuerySupport {
 
     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 @@ public class ActiveTaskQuerySupport {
     }
 
     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 @@ public class ActiveTaskQuerySupport {
     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<ShardResponse> 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<ActiveTaskDetails> mergeTaskList(List<ShardResponse> responses) {
     Map<String, String> mergedTasks = new LinkedHashMap<>();
@@ -156,10 +198,13 @@ public class ActiveTaskQuerySupport {
   private static final class TaskQueryResult {
     private final List<ActiveTaskDetails> taskList;
     private final boolean taskActive;
+    private final boolean taskCancelled;
 
-    private TaskQueryResult(List<ActiveTaskDetails> taskList, boolean taskActive) {
+    private TaskQueryResult(
+        List<ActiveTaskDetails> 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 9648bb312e4..00000000000
--- 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 18c99ba09c3..a99ba2d1a41 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 @@ package org.apache.solr.handler.component;
 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.AuthorizationContext;
 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<SearchComponent> components;
 
   @Override
   public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception {
-    ResponseBuilder rb = buildResponseBuilder(req, rsp, getComponentsList());
-    Map<String, String> 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 class QueryCancellationHandler extends TaskManagementHandler {
     if (path.startsWith("/tasks/cancel")) {
       return this;
     }
-
     return null;
   }
 
@@ -94,14 +87,11 @@ public class QueryCancellationHandler extends TaskManagementHandler {
 
   @Override
   public Collection<Api> getApis() {
-    return AnnotatedApi.getApis(new CancelTaskAPI(this));
+    return List.of();
   }
 
-  private List<SearchComponent> getComponentsList() {
-    if (components == null) {
-      components = buildComponentsList();
-    }
-
-    return components;
+  @Override
+  public Collection<Class<? extends JerseyResource>> 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 3e9c5ec2116..ad89247223f 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 abstract class TaskManagementHandler extends RequestHandlerBase
     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<String, String> extraParams)
-      throws IOException {
-    ShardHandler shardHandler = shardHandlerFactory.getShardHandler();
-    List<SearchComponent> 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<String, String> 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<SearchComponent> buildComponentsList() {
-    List<SearchComponent> components = new ArrayList<>(2);
-
-    QueryCancellationComponent component = new QueryCancellationComponent();
-    components.add(component);
-
-    return components;
-  }
-
   public static ResponseBuilder buildResponseBuilder(
       SolrQueryRequest req, SolrQueryResponse rsp, List<SearchComponent> components) {
     CoreContainer cc = req.getCoreContainer();
diff --git a/solr/core/src/resources/ImplicitPlugins.json b/solr/core/src/resources/ImplicitPlugins.json
index a9e8dd45ef4..8bb2019695a 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 9fa63f89801..19f4f5312e0 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 class SolrCoreTest extends SolrTestCaseJ4 {
       ++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 00000000000..ef85bf2a212
--- /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/ListActiveTasksTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/CancelTaskTest.java
similarity index 51%
copy from solr/core/src/test/org/apache/solr/handler/admin/api/ListActiveTasksTest.java
copy to solr/core/src/test/org/apache/solr/handler/admin/api/CancelTaskTest.java
index 113f9dcb82a..1fcc1a52083 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/CancelTaskTest.java
@@ -17,28 +17,26 @@
 
 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 java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import org.apache.solr.SolrTestCaseJ4;
-import org.apache.solr.client.api.model.ListActiveTaskResponse;
+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 ListActiveTasksTest extends SolrTestCaseJ4 {
+public class CancelTaskTest extends SolrTestCase {
 
-  private SolrQueryRequest mockQueryRequest;
-  private SolrCore solrCore;
   private CancellableQueryTracker cancellableQueryTracker;
-
-  private ListActiveTasks listActiveTasks;
+  private CancelTask cancelTask;
 
   @BeforeClass
   public static void ensureWorkingMockito() {
@@ -50,33 +48,35 @@ public class ListActiveTasksTest extends SolrTestCaseJ4 {
   public void setUp() throws Exception {
     super.setUp();
 
-    mockQueryRequest = mock(SolrQueryRequest.class);
-    solrCore = mock(SolrCore.class);
+    SolrQueryRequest solrQueryRequest = mock(SolrQueryRequest.class);
+    SolrCore solrCore = mock(SolrCore.class);
     cancellableQueryTracker = mock(CancellableQueryTracker.class);
 
-    listActiveTasks = new ListActiveTasks(mockQueryRequest);
+    when(solrQueryRequest.getCore()).thenReturn(solrCore);
+    when(solrCore.getCancellableQueryTracker()).thenReturn(cancellableQueryTracker);
+
+    cancelTask = new CancelTask(solrQueryRequest);
   }
 
   @Test
-  public void testGetActiveTasks() throws Exception {
-
-    Map<String, String> myMap = new LinkedHashMap<>();
-    myMap.put("taskID1", "/search?q=h&gf=text-1");
-    myMap.put("taskID2", "/search?q=h&gf=text-2");
-    Iterator<Map.Entry<String, String>> mockIterator = myMap.entrySet().iterator();
+  public void testCancelRunningTask() throws Exception {
+    CancellableCollector cancellableCollector = mock(CancellableCollector.class);
+    when(cancellableQueryTracker.getCancellableTask("taskID_running"))
+        .thenReturn(cancellableCollector);
 
-    when(mockQueryRequest.getCore()).thenReturn(solrCore);
-    when(solrCore.getCancellableQueryTracker()).thenReturn(cancellableQueryTracker);
-    when(cancellableQueryTracker.getActiveQueriesGenerated()).thenReturn(mockIterator);
-
-    ListActiveTaskResponse response = listActiveTasks.listAllActiveTasks();
-    assertNotNull(response.tasks);
+    CancelTaskResponse response = cancelTask.cancelRunningTask("taskID_running");
 
-    assertEquals(2, response.tasks.size());
+    assertEquals(CancelTaskResponse.CancellationStatus.SUCCESS, response.status);
+    verify(cancellableCollector).cancel();
+  }
 
-    assertEquals("taskID1", response.tasks.get(0).id);
-    assertEquals("/search?q=h&gf=text-1", response.tasks.get(0).query);
+  @Test
+  public void testCancelNonExistentTaskReturns404() {
+    when(cancellableQueryTracker.getCancellableTask("taskID_missing")).thenReturn(null);
 
-    assertNull(response.error);
+    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 acc4fa1d1c7..0bf04ca29aa 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.Before;
 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 113f9dcb82a..28a24977b8c 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.Before;
 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 f1039705765..182ceaa637c 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.CompletionException;
 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.jetty.HttpJettySolrClient;
 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 class TestTaskManagement extends SolrCloudTestCase {
   }
 
   /**
-   * Regression test for cross-shard task visibility.
-   *
-   * <p>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.
-   *
-   * <p>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 class TestTaskManagement extends SolrCloudTestCase {
     }
   }
 
+  /**
+   * 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<Slice> 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<Object> 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<Slice> 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<Void> cancelQuery(
       final String queryID, Set<Integer> cancelledQueryIdsSet, Set<Integer> 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 5cce6eb8bc9..96033d67c6d 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