This is an automated email from the ASF dual-hosted git repository.
Fly-Style pushed a commit to branch 39.0.0
in repository https://gitbox.apache.org/repos/asf/druid.git
The following commit(s) were added to refs/heads/39.0.0 by this push:
new be11dde6842 fix: Restore the shutdownAllTasks Overlord API (#20824)
(#20834)
be11dde6842 is described below
commit be11dde68420757cb12b7d1bad893e99ef937554
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Nov 4 20:37:26 2026 +0200
fix: Restore the shutdownAllTasks Overlord API (#20824) (#20834)
* fix: Restore the shutdownAllTasks Overlord API
POST /druid/indexer/v1/datasources/{datasource}/shutdownAllTasks has
returned 404 since 32.0.0. OverlordDataSourcesResource, added in #17545,
owns the /druid/indexer/v1/datasources path, and Jersey doesn't fall back
to OverlordResource for requests under it, so the endpoint could no
longer be reached.
Move the endpoint to OverlordDataSourcesResource, keeping the same URL
and behavior, and add an embedded test that calls it over HTTP.
Fixes #18419
* test: Check that no Jersey endpoint is shadowed by another resource
Jersey routes each request to a single root resource class and never falls
back to another one, so an endpoint is unreachable when its path falls under
a more specific @Path of another resource class. That is how
shutdownAllTasks
broke in 32.0.0.
MainTest now checks every server type for such endpoints, using Jersey's own
resource model and path matching. Without the fix, it reports exactly the
shutdownAllTasks endpoint of OverlordResource.
---------
(cherry picked from commit a88e765203ce81b2e3caa332ce33e43ccb9ba70d)
Co-authored-by: Nagarjunanm <[email protected]>
Co-authored-by: n0n02ip <[email protected]>
---
.../embedded/server/OverlordClientTest.java | 28 ++++
.../overlord/http/OverlordDataSourcesResource.java | 38 ++++-
.../indexing/overlord/http/OverlordResource.java | 23 ---
.../http/OverlordDataSourcesResourceTest.java | 61 ++++++-
.../overlord/http/OverlordResourceTest.java | 53 ------
services/pom.xml | 4 +
.../apache/druid/cli/JerseyShadowedEndpoints.java | 186 +++++++++++++++++++++
.../druid/cli/JerseyShadowedEndpointsTest.java | 163 ++++++++++++++++++
.../test/java/org/apache/druid/cli/MainTest.java | 36 +++-
9 files changed, 510 insertions(+), 82 deletions(-)
diff --git
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/OverlordClientTest.java
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/OverlordClientTest.java
index eae3a492f6a..1bba0185600 100644
---
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/OverlordClientTest.java
+++
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/OverlordClientTest.java
@@ -19,9 +19,11 @@
package org.apache.druid.testing.embedded.server;
+import com.fasterxml.jackson.core.type.TypeReference;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.ListenableFuture;
+import io.netty.handler.codec.http.HttpMethod;
import org.apache.druid.client.indexing.IndexingTotalWorkerCapacityInfo;
import org.apache.druid.client.indexing.IndexingWorkerInfo;
import org.apache.druid.common.utils.IdUtils;
@@ -36,6 +38,7 @@ import org.apache.druid.java.util.common.Intervals;
import org.apache.druid.java.util.common.StringUtils;
import org.apache.druid.java.util.common.parsers.CloseableIterator;
import org.apache.druid.rpc.HttpResponseException;
+import org.apache.druid.rpc.RequestBuilder;
import org.apache.druid.rpc.indexing.OverlordClient;
import org.apache.druid.rpc.indexing.SegmentUpdateResponse;
import org.apache.druid.segment.TestDataSource;
@@ -134,6 +137,31 @@ public class OverlordClientTest extends
EmbeddedClusterTestBase
);
}
+ @Test
+ public void test_shutdownAllTasks_forDatasourceWithActiveTask()
+ {
+ // Submit a task that would run for much longer than this test
+ final String taskId = IdUtils.newTaskId("sim_test_noop", dataSource, null);
+ cluster.callApi().submitTask(new NoopTask(taskId, null, dataSource,
300_000L, 0L, null));
+
+ // Send a real HTTP request, since a routing conflict made this API return
404 (#18419)
+ final Map<String, String> response =
cluster.callApi().serviceClient().onLeaderOverlord(
+ mapper -> new RequestBuilder(
+ HttpMethod.POST,
+ StringUtils.format(
+ "/druid/indexer/v1/datasources/%s/shutdownAllTasks",
+ StringUtils.urlEncode(dataSource)
+ )
+ ),
+ new TypeReference<>() {}
+ );
+ Assertions.assertEquals(Map.of("dataSource", dataSource), response);
+
+ final TaskStatus taskStatus =
cluster.callApi().waitForTaskToFinish(taskId, overlord.latchableEmitter());
+ Assertions.assertEquals(TaskState.FAILED, taskStatus.getStatusCode());
+ Assertions.assertEquals("Shutdown request from user",
taskStatus.getErrorMsg());
+ }
+
@Test
public void test_taskStatuses_returnsEmpty_forRunningTasks()
{
diff --git
a/indexing-service/src/main/java/org/apache/druid/indexing/overlord/http/OverlordDataSourcesResource.java
b/indexing-service/src/main/java/org/apache/druid/indexing/overlord/http/OverlordDataSourcesResource.java
index 3e9254a9c41..69f874bf8d4 100644
---
a/indexing-service/src/main/java/org/apache/druid/indexing/overlord/http/OverlordDataSourcesResource.java
+++
b/indexing-service/src/main/java/org/apache/druid/indexing/overlord/http/OverlordDataSourcesResource.java
@@ -19,6 +19,7 @@
package org.apache.druid.indexing.overlord.http;
+import com.google.common.base.Optional;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableMap;
import com.google.inject.Inject;
@@ -27,8 +28,11 @@ import org.apache.druid.audit.AuditEntry;
import org.apache.druid.audit.AuditManager;
import org.apache.druid.common.utils.IdUtils;
import org.apache.druid.error.DruidException;
+import org.apache.druid.indexer.TaskInfo;
import org.apache.druid.indexing.overlord.IndexerMetadataStorageCoordinator;
import org.apache.druid.indexing.overlord.TaskMaster;
+import org.apache.druid.indexing.overlord.TaskQueryTool;
+import org.apache.druid.indexing.overlord.TaskQueue;
import org.apache.druid.java.util.common.StringUtils;
import org.apache.druid.java.util.common.logger.Logger;
import org.apache.druid.rpc.indexing.SegmentUpdateResponse;
@@ -55,9 +59,13 @@ import java.util.Set;
import java.util.stream.Collectors;
/**
- * Datasource APIs exposed by the Overlord to update segments.
- * Some of these APIs are also exposed by the Coordinator, but they have been
+ * Datasource APIs exposed by the Overlord to update segments and shut down
tasks.
+ * Some of the segment APIs are also exposed by the Coordinator, but they have
been
* deprecated and the Overlord APIs must be used for all update operations.
+ * <p>
+ * All Overlord APIs under {@code /druid/indexer/v1/datasources} must be
defined in this class.
+ * Jersey routes every request under that path here and never falls back to
{@link OverlordResource},
+ * so a method defined there for such a path can't be reached.
*/
@Path("/druid/indexer/v1/datasources")
public class OverlordDataSourcesResource
@@ -66,16 +74,19 @@ public class OverlordDataSourcesResource
private final IndexerMetadataStorageCoordinator metadataStorageCoordinator;
private final TaskMaster taskMaster;
+ private final TaskQueryTool taskQueryTool;
private final AuditManager auditManager;
@Inject
public OverlordDataSourcesResource(
TaskMaster taskMaster,
+ TaskQueryTool taskQueryTool,
IndexerMetadataStorageCoordinator metadataStorageCoordinator,
AuditManager auditManager
)
{
this.taskMaster = taskMaster;
+ this.taskQueryTool = taskQueryTool;
this.auditManager = auditManager;
this.metadataStorageCoordinator = metadataStorageCoordinator;
}
@@ -256,6 +267,29 @@ public class OverlordDataSourcesResource
return performSegmentUpdate(dataSourceName, operation);
}
+ @POST
+ @Path("/{dataSourceName}/shutdownAllTasks")
+ @Produces(MediaType.APPLICATION_JSON)
+ @ResourceFilters(DatasourceResourceFilter.class)
+ public Response shutdownTasksForDataSource(@PathParam("dataSourceName")
final String dataSourceName)
+ {
+ final Optional<TaskQueue> taskQueue = taskMaster.getTaskQueue();
+ if (!taskQueue.isPresent()) {
+ // Encourage client to try again soon, when we'll likely have a redirect
set up
+ return Response.status(Response.Status.SERVICE_UNAVAILABLE).build();
+ }
+
+ final List<TaskInfo> tasks =
taskQueryTool.getActiveTaskInfo(dataSourceName);
+ if (tasks.isEmpty()) {
+ return Response.status(Response.Status.NOT_FOUND).build();
+ }
+
+ for (final TaskInfo task : tasks) {
+ taskQueue.get().shutdown(task.getId(), "Shutdown request from user");
+ }
+ return Response.ok(ImmutableMap.of("dataSource", dataSourceName)).build();
+ }
+
private Response performSegmentUpdate(String dataSourceName,
SegmentUpdateOperation operation)
{
if (!taskMaster.isHalfOrFullLeader()) {
diff --git
a/indexing-service/src/main/java/org/apache/druid/indexing/overlord/http/OverlordResource.java
b/indexing-service/src/main/java/org/apache/druid/indexing/overlord/http/OverlordResource.java
index 6c2efddd746..a78849d0845 100644
---
a/indexing-service/src/main/java/org/apache/druid/indexing/overlord/http/OverlordResource.java
+++
b/indexing-service/src/main/java/org/apache/druid/indexing/overlord/http/OverlordResource.java
@@ -69,7 +69,6 @@ import org.apache.druid.metadata.LockFilterPolicy;
import org.apache.druid.server.http.HttpMediaType;
import org.apache.druid.server.http.ServletResourceUtils;
import org.apache.druid.server.http.security.ConfigResourceFilter;
-import org.apache.druid.server.http.security.DatasourceResourceFilter;
import org.apache.druid.server.http.security.StateResourceFilter;
import org.apache.druid.server.security.Action;
import org.apache.druid.server.security.AuthConfig;
@@ -397,28 +396,6 @@ public class OverlordResource
);
}
- @POST
- @Path("/datasources/{dataSource}/shutdownAllTasks")
- @Produces(MediaType.APPLICATION_JSON)
- @ResourceFilters(DatasourceResourceFilter.class)
- public Response shutdownTasksForDataSource(@PathParam("dataSource") final
String dataSource)
- {
- return asLeaderWith(
- taskMaster.getTaskQueue(),
- taskQueue -> {
- final List<TaskInfo> tasks =
taskQueryTool.getActiveTaskInfo(dataSource);
- if (tasks.isEmpty()) {
- return Response.status(Status.NOT_FOUND).build();
- } else {
- for (final TaskInfo task : tasks) {
- taskQueue.shutdown(task.getId(), "Shutdown request from user");
- }
- return Response.ok(ImmutableMap.of("dataSource",
dataSource)).build();
- }
- }
- );
- }
-
@POST
@Path("/taskStatus")
@Produces(MediaType.APPLICATION_JSON)
diff --git
a/indexing-service/src/test/java/org/apache/druid/indexing/overlord/http/OverlordDataSourcesResourceTest.java
b/indexing-service/src/test/java/org/apache/druid/indexing/overlord/http/OverlordDataSourcesResourceTest.java
index d83ae340a4d..6b0b96844a8 100644
---
a/indexing-service/src/test/java/org/apache/druid/indexing/overlord/http/OverlordDataSourcesResourceTest.java
+++
b/indexing-service/src/test/java/org/apache/druid/indexing/overlord/http/OverlordDataSourcesResourceTest.java
@@ -20,9 +20,16 @@
package org.apache.druid.indexing.overlord.http;
import org.apache.druid.audit.AuditManager;
+import org.apache.druid.indexer.TaskInfo;
+import org.apache.druid.indexer.TaskStatus;
+import org.apache.druid.indexing.common.task.NoopTask;
+import org.apache.druid.indexing.common.task.Task;
import org.apache.druid.indexing.overlord.Segments;
import org.apache.druid.indexing.overlord.TaskMaster;
+import org.apache.druid.indexing.overlord.TaskQueryTool;
+import org.apache.druid.indexing.overlord.TaskQueue;
import org.apache.druid.indexing.test.TestIndexerMetadataStorageCoordinator;
+import org.apache.druid.java.util.common.DateTimes;
import org.apache.druid.java.util.common.Intervals;
import org.apache.druid.java.util.common.granularity.Granularities;
import org.apache.druid.rpc.indexing.SegmentUpdateResponse;
@@ -42,6 +49,7 @@ import javax.ws.rs.core.Response;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
import java.util.Set;
public class OverlordDataSourcesResourceTest
@@ -54,6 +62,9 @@ public class OverlordDataSourcesResourceTest
.eachOfSizeInMb(500);
private TestIndexerMetadataStorageCoordinator storageCoordinator;
+ private TaskMaster taskMaster;
+ private TaskQueue taskQueue;
+ private TaskQueryTool taskQueryTool;
private OverlordDataSourcesResource dataSourcesResource;
@@ -62,13 +73,17 @@ public class OverlordDataSourcesResourceTest
{
AuditManager auditManager = EasyMock.createStrictMock(AuditManager.class);
storageCoordinator = new TestIndexerMetadataStorageCoordinator();
+ taskQueue = EasyMock.createStrictMock(TaskQueue.class);
+ taskQueryTool = EasyMock.createMock(TaskQueryTool.class);
- TaskMaster taskMaster = new TaskMaster(null, null);
+ taskMaster = new TaskMaster(null, null);
dataSourcesResource = new OverlordDataSourcesResource(
taskMaster,
+ taskQueryTool,
storageCoordinator,
auditManager
);
+ taskMaster.becomeHalfLeader(null, taskQueue);
taskMaster.becomeFullLeader();
storageCoordinator.commitSegments(Set.copyOf(WIKI_SEGMENTS_10X1D), null);
@@ -353,6 +368,50 @@ public class OverlordDataSourcesResourceTest
Assertions.assertEquals(expectedErrorMessage, response.getEntity());
}
+ @Test
+ public void testShutdownAllTasks_shutsDownEveryActiveTask()
+ {
+ final Task task1 = NoopTask.forDatasource(TestDataSource.WIKI);
+ final Task task2 = NoopTask.forDatasource(TestDataSource.WIKI);
+ EasyMock.expect(taskQueryTool.getActiveTaskInfo(TestDataSource.WIKI))
+ .andReturn(List.of(createActiveTaskInfo(task1),
createActiveTaskInfo(task2)));
+ taskQueue.shutdown(task1.getId(), "Shutdown request from user");
+ taskQueue.shutdown(task2.getId(), "Shutdown request from user");
+ EasyMock.replay(taskQueryTool, taskQueue);
+
+ final Response response =
dataSourcesResource.shutdownTasksForDataSource(TestDataSource.WIKI);
+ Assertions.assertEquals(200, response.getStatus());
+ Assertions.assertEquals(Map.of("dataSource", TestDataSource.WIKI),
response.getEntity());
+ EasyMock.verify(taskQueryTool, taskQueue);
+ }
+
+ @Test
+ public void testShutdownAllTasks_withNoActiveTasks_returnsNotFound()
+ {
+
EasyMock.expect(taskQueryTool.getActiveTaskInfo(TestDataSource.WIKI)).andReturn(List.of());
+ EasyMock.replay(taskQueryTool, taskQueue);
+
+ final Response response =
dataSourcesResource.shutdownTasksForDataSource(TestDataSource.WIKI);
+ Assertions.assertEquals(404, response.getStatus());
+ EasyMock.verify(taskQueryTool, taskQueue);
+ }
+
+ @Test
+ public void testShutdownAllTasks_whenNotLeader_returnsServiceUnavailable()
+ {
+ taskMaster.stopBeingLeader();
+ EasyMock.replay(taskQueryTool, taskQueue);
+
+ final Response response =
dataSourcesResource.shutdownTasksForDataSource(TestDataSource.WIKI);
+ Assertions.assertEquals(503, response.getStatus());
+ EasyMock.verify(taskQueryTool, taskQueue);
+ }
+
+ private static TaskInfo createActiveTaskInfo(Task task)
+ {
+ return new TaskInfo(DateTimes.nowUtc(), TaskStatus.running(task.getId()),
task);
+ }
+
private void verifyNumSegmentsUpdated(int expectedUpdatedCount, Response
response)
{
Assertions.assertEquals(200, response.getStatus());
diff --git
a/indexing-service/src/test/java/org/apache/druid/indexing/overlord/http/OverlordResourceTest.java
b/indexing-service/src/test/java/org/apache/druid/indexing/overlord/http/OverlordResourceTest.java
index ae1f50cc044..22d7cdf8acb 100644
---
a/indexing-service/src/test/java/org/apache/druid/indexing/overlord/http/OverlordResourceTest.java
+++
b/indexing-service/src/test/java/org/apache/druid/indexing/overlord/http/OverlordResourceTest.java
@@ -65,7 +65,6 @@ import org.apache.druid.java.util.common.Intervals;
import org.apache.druid.java.util.common.RE;
import org.apache.druid.java.util.common.UOE;
import org.apache.druid.metadata.LockFilterPolicy;
-import org.apache.druid.metadata.TaskLookup;
import org.apache.druid.metadata.TaskLookup.ActiveTaskLookup;
import org.apache.druid.metadata.TaskLookup.CompleteTaskLookup;
import org.apache.druid.metadata.TaskLookup.TaskLookupType;
@@ -1195,58 +1194,6 @@ public class OverlordResourceTest
Assertions.assertEquals("id_1", response.get("task"));
}
- @Test
- public void testShutdownAllTasks()
- {
- // This is disabled since OverlordResource.shutdownTasksForDataSource is
annotated with DatasourceResourceFilter
- // This should be fixed in https://github.com/apache/druid/issues/6685.
- // expectAuthorizationTokenCheck();
- TaskQueue mockQueue = EasyMock.createMock(TaskQueue.class);
- EasyMock.expect(taskMaster.getTaskQueue()).andReturn(
- Optional.of(mockQueue)
- ).anyTimes();
- EasyMock.expect(
- taskStorage.getTaskInfos(TaskLookup.activeTasksOnly(), "datasource")
- ).andStubReturn(ImmutableList.of(
- new TaskInfo(
- DateTime.now(ISOChronology.getInstanceUTC()),
- TaskStatus.success("id_1"),
- new NoopTask("id_1", null, "datasource", 1L, 0L, null)
- ),
- new TaskInfo(
- DateTime.now(ISOChronology.getInstanceUTC()),
- TaskStatus.success("id_2"),
- new NoopTask("id_2", null, "datasource", 1L, 0L, null)
- )
- ));
- mockQueue.shutdown("id_1", "Shutdown request from user");
- EasyMock.expectLastCall();
- mockQueue.shutdown("id_2", "Shutdown request from user");
- EasyMock.expectLastCall();
-
- replayAll();
- EasyMock.replay(mockQueue);
-
- final Map<String, String> response = (Map<String, String>) overlordResource
- .shutdownTasksForDataSource("datasource")
- .getEntity();
- Assertions.assertEquals("datasource", response.get("dataSource"));
- }
-
- @Test
- public void testShutdownAllTasksForNonExistingDataSource()
- {
- final TaskQueue taskQueue = EasyMock.createMock(TaskQueue.class);
- EasyMock.expect(overlord.isLeader()).andReturn(true).anyTimes();
-
EasyMock.expect(taskMaster.getTaskQueue()).andReturn(Optional.of(taskQueue)).anyTimes();
-
EasyMock.expect(taskStorage.getTaskInfos(EasyMock.anyObject(TaskLookup.class),
EasyMock.anyString()))
- .andReturn(Collections.emptyList());
- replayAll();
-
- final Response response =
overlordResource.shutdownTasksForDataSource("notExisting");
- Assertions.assertEquals(Status.NOT_FOUND.getStatusCode(),
response.getStatus());
- }
-
@Test
public void testEnableWorker()
{
diff --git a/services/pom.xml b/services/pom.xml
index 38eb1d03f17..4e38de2d764 100644
--- a/services/pom.xml
+++ b/services/pom.xml
@@ -170,6 +170,10 @@
<groupId>com.sun.jersey</groupId>
<artifactId>jersey-server</artifactId>
</dependency>
+ <dependency>
+ <groupId>com.sun.jersey</groupId>
+ <artifactId>jersey-core</artifactId>
+ </dependency>
<dependency>
<groupId>org.roaringbitmap</groupId>
diff --git
a/services/src/test/java/org/apache/druid/cli/JerseyShadowedEndpoints.java
b/services/src/test/java/org/apache/druid/cli/JerseyShadowedEndpoints.java
new file mode 100644
index 00000000000..e74f46312a3
--- /dev/null
+++ b/services/src/test/java/org/apache/druid/cli/JerseyShadowedEndpoints.java
@@ -0,0 +1,186 @@
+/*
+ * 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.druid.cli;
+
+import com.sun.jersey.api.model.AbstractMethod;
+import com.sun.jersey.api.model.AbstractResource;
+import com.sun.jersey.api.model.AbstractResourceMethod;
+import com.sun.jersey.api.model.AbstractSubResourceLocator;
+import com.sun.jersey.api.model.AbstractSubResourceMethod;
+import com.sun.jersey.server.impl.modelapi.annotation.IntrospectionModeller;
+import com.sun.jersey.server.impl.uri.PathPattern;
+import com.sun.jersey.server.impl.uri.PathTemplate;
+import org.apache.druid.java.util.common.ISE;
+import org.apache.druid.java.util.common.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Finds Jersey endpoints that no request can reach, because the root resource
class of another endpoint shadows them.
+ * <p>
+ * Jersey 1.x routes a request in two steps. Out of the root resource classes
whose {@code @Path} matches the request
+ * path, it first picks the one that sorts first under {@link
PathPattern#COMPARATOR}, which prefers paths with more
+ * literal characters. It then looks for a matching method in that class only.
It never falls back to another class,
+ * so if the chosen class has no matching method, the request fails with a 404
even when another class declares that
+ * exact endpoint. For example, an endpoint at {@code
/druid/indexer/v1/datasources/{dataSource}/shutdownAllTasks} in a
+ * class with {@code @Path("/druid/indexer/v1")} stops working once another
class has
+ * {@code @Path("/druid/indexer/v1/datasources")}.
+ * <p>
+ * To apply exactly the same rules, this class uses Jersey's own resource
model and path matching.
+ */
+final class JerseyShadowedEndpoints
+{
+ /**
+ * Substituted for every path parameter to build a request path that reaches
an endpoint.
+ */
+ private static final String PATH_PARAMETER_VALUE = "x";
+
+ private JerseyShadowedEndpoints()
+ {
+ }
+
+ /**
+ * Returns a description of every shadowed endpoint of the given resource
classes, or an empty list if every endpoint
+ * is reachable. Classes without {@code @Path}, such as providers, are
ignored.
+ */
+ static List<String> find(final Collection<Class<?>> resourceClasses)
+ {
+ // In the order in which Jersey tries root resources.
+ final List<RootResource> rootResources =
+ resourceClasses.stream()
+ .map(IntrospectionModeller::createResource)
+ .filter(AbstractResource::isRootResource)
+ .map(RootResource::new)
+ .sorted((r1, r2) ->
PathPattern.COMPARATOR.compare(r1.pattern, r2.pattern))
+ .collect(Collectors.toList());
+
+ final List<String> shadowedEndpoints = new ArrayList<>();
+ for (final RootResource rootResource : rootResources) {
+ for (final Endpoint endpoint : rootResource.getEndpoints()) {
+ if (!rootResource.matches(endpoint.requestPath)) {
+ throw new ISE("Request path[%s] built for %s does not match its own
resource class", endpoint.requestPath, endpoint);
+ }
+ final RootResource routedTo = findRoutedResource(rootResources,
endpoint.requestPath);
+ if (routedTo != rootResource) {
+ shadowedEndpoints.add(StringUtils.format("%s is shadowed by %s",
endpoint, routedTo));
+ }
+ }
+ }
+ return shadowedEndpoints;
+ }
+
+ private static RootResource findRoutedResource(final List<RootResource>
sortedRootResources, final String requestPath)
+ {
+ for (final RootResource rootResource : sortedRootResources) {
+ if (rootResource.matches(requestPath)) {
+ return rootResource;
+ }
+ }
+ throw new ISE("No resource class matches request path[%s]", requestPath);
+ }
+
+ private static class RootResource
+ {
+ private final AbstractResource resource;
+ private final PathPattern pattern;
+
+ private RootResource(final AbstractResource resource)
+ {
+ this.resource = resource;
+ // Built the same way as in Jersey's RootResourceUriRules.
+ this.pattern = new PathPattern(new
PathTemplate(resource.getPath().getValue()));
+ }
+
+ private boolean matches(final String requestPath)
+ {
+ return pattern.match(requestPath) != null;
+ }
+
+ private List<Endpoint> getEndpoints()
+ {
+ final String path = resource.getPath().getValue();
+ final List<Endpoint> endpoints = new ArrayList<>();
+ for (final AbstractResourceMethod method :
resource.getResourceMethods()) {
+ endpoints.add(new Endpoint(method.getHttpMethod(), path, method));
+ }
+ for (final AbstractSubResourceMethod method :
resource.getSubResourceMethods()) {
+ endpoints.add(new Endpoint(method.getHttpMethod(), concatPaths(path,
method.getPath().getValue()), method));
+ }
+ for (final AbstractSubResourceLocator locator :
resource.getSubResourceLocators()) {
+ endpoints.add(new Endpoint("ANY", concatPaths(path,
locator.getPath().getValue()), locator));
+ }
+ return endpoints;
+ }
+
+ private static String concatPaths(final String path, final String subPath)
+ {
+ return StringUtils.maybeRemoveTrailingSlash(path) + "/" +
StringUtils.maybeRemoveLeadingSlash(subPath);
+ }
+
+ @Override
+ public String toString()
+ {
+ return StringUtils.format(
+ "[%s] with @Path[%s]",
+ resource.getResourceClass().getName(),
+ resource.getPath().getValue()
+ );
+ }
+ }
+
+ private static class Endpoint
+ {
+ private final String httpMethod;
+ private final String path;
+ private final AbstractMethod method;
+ private final String requestPath;
+
+ private Endpoint(final String httpMethod, final String path, final
AbstractMethod method)
+ {
+ this.httpMethod = httpMethod;
+ this.path = path;
+ this.method = method;
+
+ final PathTemplate template = new PathTemplate(path);
+ final Map<String, String> pathParameters = new HashMap<>();
+ for (final String name : template.getTemplateVariables()) {
+ pathParameters.put(name, PATH_PARAMETER_VALUE);
+ }
+ this.requestPath = template.createURI(pathParameters);
+ }
+
+ @Override
+ public String toString()
+ {
+ return StringUtils.format(
+ "[%s %s] of [%s.%s()]",
+ httpMethod,
+ path,
+ method.getMethod().getDeclaringClass().getName(),
+ method.getMethod().getName()
+ );
+ }
+ }
+}
diff --git
a/services/src/test/java/org/apache/druid/cli/JerseyShadowedEndpointsTest.java
b/services/src/test/java/org/apache/druid/cli/JerseyShadowedEndpointsTest.java
new file mode 100644
index 00000000000..80bbec0ac3c
--- /dev/null
+++
b/services/src/test/java/org/apache/druid/cli/JerseyShadowedEndpointsTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.druid.cli;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import java.util.List;
+
+public class JerseyShadowedEndpointsTest
+{
+ @Test
+ public void testEndpointUnderMoreSpecificPathOfAnotherResourceIsShadowed()
+ {
+ Assertions.assertEquals(
+ List.of(
+ "[POST
/druid/indexer/v1/datasources/{dataSource}/shutdownAllTasks]"
+ + " of [" + IndexerResource.class.getName() +
".shutdownAllTasks()]"
+ + " is shadowed by [" + DataSourcesResource.class.getName() + "]"
+ + " with @Path[/druid/indexer/v1/datasources]"
+ ),
+ JerseyShadowedEndpoints.find(List.of(IndexerResource.class,
DataSourcesResource.class))
+ );
+ }
+
+ @Test
+ public void testEndpointInMostSpecificResourceIsNotShadowed()
+ {
+ Assertions.assertEquals(
+ List.of(),
+ JerseyShadowedEndpoints.find(
+ List.of(IndexerResourceWithoutShutdown.class,
DataSourcesResourceWithShutdown.class, StatusResource.class)
+ )
+ );
+ }
+
+ @Test
+ public void
testSubResourceLocatorUnderMoreSpecificPathOfAnotherResourceIsShadowed()
+ {
+ Assertions.assertEquals(
+ List.of(
+ "[ANY /druid/indexer/v1/datasources/{dataSource}/handler]"
+ + " of [" + IndexerResourceWithLocator.class.getName() +
".getHandler()]"
+ + " is shadowed by [" + DataSourcesResource.class.getName() + "]"
+ + " with @Path[/druid/indexer/v1/datasources]"
+ ),
+ JerseyShadowedEndpoints.find(List.of(IndexerResourceWithLocator.class,
DataSourcesResource.class))
+ );
+ }
+
+ @Test
+ public void testClassWithoutPathIsIgnored()
+ {
+ Assertions.assertEquals(
+ List.of(),
+ JerseyShadowedEndpoints.find(List.of(NotAResource.class,
StatusResource.class))
+ );
+ }
+
+ /**
+ * Same layout as {@code OverlordResource} and {@code
OverlordDataSourcesResource} had before the
+ * {@code shutdownAllTasks} endpoint moved.
+ */
+ @Path("/druid/indexer/v1")
+ public static class IndexerResource
+ {
+ @GET
+ @Path("/task/{taskId}")
+ public void getTask()
+ {
+ }
+
+ @POST
+ @Path("/datasources/{dataSource}/shutdownAllTasks")
+ public void shutdownAllTasks()
+ {
+ }
+ }
+
+ @Path("/druid/indexer/v1/datasources")
+ public static class DataSourcesResource
+ {
+ @DELETE
+ @Path("/{dataSource}")
+ public void markAllSegmentsAsUnused()
+ {
+ }
+ }
+
+ @Path("/druid/indexer/v1")
+ public static class IndexerResourceWithoutShutdown
+ {
+ @GET
+ @Path("/task/{taskId}")
+ public void getTask()
+ {
+ }
+ }
+
+ @Path("/druid/indexer/v1/datasources")
+ public static class DataSourcesResourceWithShutdown
+ {
+ @DELETE
+ @Path("/{dataSource}")
+ public void markAllSegmentsAsUnused()
+ {
+ }
+
+ @POST
+ @Path("/{dataSource}/shutdownAllTasks")
+ public void shutdownAllTasks()
+ {
+ }
+ }
+
+ @Path("/druid/indexer/v1")
+ public static class IndexerResourceWithLocator
+ {
+ @Path("/datasources/{dataSource}/handler")
+ public Object getHandler()
+ {
+ return new Object();
+ }
+ }
+
+ @Path("/status")
+ public static class StatusResource
+ {
+ @GET
+ public void getStatus()
+ {
+ }
+ }
+
+ public static class NotAResource
+ {
+ @GET
+ public void get()
+ {
+ }
+ }
+}
diff --git a/services/src/test/java/org/apache/druid/cli/MainTest.java
b/services/src/test/java/org/apache/druid/cli/MainTest.java
index 33fb605d1f6..229855dcc67 100644
--- a/services/src/test/java/org/apache/druid/cli/MainTest.java
+++ b/services/src/test/java/org/apache/druid/cli/MainTest.java
@@ -20,12 +20,17 @@
package org.apache.druid.cli;
import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.TypeLiteral;
import org.apache.druid.guice.GuiceInjectors;
+import org.apache.druid.guice.annotations.JSR311Resource;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
+import java.util.List;
import java.util.Properties;
+import java.util.Set;
import java.util.stream.Stream;
public class MainTest
@@ -47,8 +52,33 @@ public class MainTest
@MethodSource("runnables")
public void testSimpleInjection(ServerRunnable runnable)
{
- final Injector injector = GuiceInjectors.makeStartupInjector();
- injector.injectMembers(runnable);
- Assertions.assertNotNull(runnable.makeInjector(runnable.getNodeRoles(new
Properties())));
+ Assertions.assertNotNull(makeInjector(runnable));
+ }
+
+ /**
+ * Jersey routes each request to a single root resource class, so an
endpoint is unreachable if its path falls under
+ * a more specific {@code @Path} of another resource class served by the
same process.
+ */
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("runnables")
+ public void testNoShadowedEndpoints(final ServerRunnable runnable)
+ {
+ final Set<Class<?>> resources = makeInjector(runnable).getInstance(
+ Key.get(new TypeLiteral<Set<Class<?>>>() {}, JSR311Resource.class)
+ );
+ Assertions.assertFalse(resources.isEmpty());
+
+ final List<String> shadowedEndpoints =
JerseyShadowedEndpoints.find(resources);
+ Assertions.assertTrue(
+ shadowedEndpoints.isEmpty(),
+ () -> "Unreachable endpoints:\n" + String.join("\n", shadowedEndpoints)
+ );
+ }
+
+ private static Injector makeInjector(final ServerRunnable runnable)
+ {
+ final Injector startupInjector = GuiceInjectors.makeStartupInjector();
+ startupInjector.injectMembers(runnable);
+ return runnable.makeInjector(runnable.getNodeRoles(new Properties()));
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]