This is an automated email from the ASF dual-hosted git repository.
yashmayya pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new da15d13df5e Support broker segment pruning for colocated joins (#19174)
da15d13df5e is described below
commit da15d13df5e7640dc183e9f12c30c099f3ea7f3c
Author: Yash Mayya <[email protected]>
AuthorDate: Tue Aug 25 14:54:15 2026 -0400
Support broker segment pruning for colocated joins (#19174)
---
.../routing/manager/BaseBrokerRoutingManager.java | 86 +++-
.../manager/MultiClusterRoutingManager.java | 56 +++
.../routing/manager/BrokerRoutingManagerTest.java | 168 +++++++-
.../manager/MultiClusterRoutingManagerTest.java | 86 ++++
.../apache/pinot/core/routing/RoutingManager.java | 32 ++
.../custom/ColocatedJoinEmptyPartitionTest.java | 176 ++++++++-
.../planner/physical/DispatchablePlanContext.java | 9 +
.../query/routing/ColocationGroupAnalyzer.java | 19 +-
.../query/routing/PlanNodeRoutingQueryBuilder.java | 21 +
.../apache/pinot/query/routing/WorkerManager.java | 356 +++++++++++++----
.../query/routing/ColocationGroupAnalyzerTest.java | 15 +-
.../routing/PlanNodeRoutingQueryBuilderTest.java | 56 +++
.../pinot/query/routing/WorkerManagerTest.java | 431 +++++++++++++++++++--
.../resources/queries/ExplainPhysicalPlans.json | 24 +-
.../apache/pinot/spi/utils/CommonConstants.java | 5 +
15 files changed, 1349 insertions(+), 191 deletions(-)
diff --git
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
index 631b67f63cc..269243cb5b6 100644
---
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
@@ -20,6 +20,7 @@ package org.apache.pinot.broker.routing.manager;
import com.google.common.base.Preconditions;
import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import java.util.ArrayList;
import java.util.HashMap;
@@ -1235,6 +1236,16 @@ public abstract class BaseBrokerRoutingManager
implements RoutingManager, Cluste
return routingEntry.getSegments(brokerRequest, samplerName);
}
+ @Nullable
+ @Override
+ public Set<String> getPrunedSegments(BrokerRequest brokerRequest) {
+ RoutingEntry routingEntry =
_routingEntryMap.get(brokerRequest.getQuerySource().getTableName());
+ if (routingEntry == null) {
+ return null;
+ }
+ return routingEntry.getPrunedSegments(brokerRequest,
extractSamplerName(brokerRequest));
+ }
+
private static String normalizeSamplerName(String samplerName) {
return samplerName.trim().toLowerCase(Locale.ROOT);
}
@@ -1510,22 +1521,33 @@ public abstract class BaseBrokerRoutingManager
implements RoutingManager, Cluste
}
}
- InstanceSelector.SelectionResult calculateRouting(BrokerRequest
brokerRequest, long requestId,
- @Nullable String samplerName) {
- SamplerInfo samplerInfo = getSamplerInfo(samplerName);
+ /// Runs selection and then the pruner chain, which is the one place that
decides what a query sees. Every caller
+ /// goes through here on purpose: the routing table, the plain segment
list and the planner's emptiness proof must
+ /// all be judged by the same selector and the same pruners. A second copy
of this sequence that drifted would let
+ /// the planner prove a partition empty that a real query would still have
scanned, and that loses rows with no
+ /// error anywhere.
+ private SelectedSegments selectThenPrune(BrokerRequest brokerRequest,
@Nullable SamplerInfo samplerInfo) {
SegmentSelector segmentSelector = samplerInfo != null ?
samplerInfo._segmentSelector : _segmentSelector;
- InstanceSelector instanceSelector = samplerInfo != null ?
samplerInfo._instanceSelector : _instanceSelector;
Set<String> selectedSegments = segmentSelector.select(brokerRequest);
- int numTotalSelectedSegments = selectedSegments.size();
+ Set<String> survivingSegments = selectedSegments;
if (!selectedSegments.isEmpty()) {
for (SegmentPruner segmentPruner : _segmentPruners) {
- selectedSegments = segmentPruner.prune(brokerRequest,
selectedSegments);
+ survivingSegments = segmentPruner.prune(brokerRequest,
survivingSegments);
}
}
- int numPrunedSegments = numTotalSelectedSegments -
selectedSegments.size();
- if (!selectedSegments.isEmpty()) {
+ return new SelectedSegments(selectedSegments, survivingSegments);
+ }
+
+ InstanceSelector.SelectionResult calculateRouting(BrokerRequest
brokerRequest, long requestId,
+ @Nullable String samplerName) {
+ SamplerInfo samplerInfo = getSamplerInfo(samplerName);
+ InstanceSelector instanceSelector = samplerInfo != null ?
samplerInfo._instanceSelector : _instanceSelector;
+ SelectedSegments selectedSegments = selectThenPrune(brokerRequest,
samplerInfo);
+ Set<String> survivingSegments = selectedSegments._surviving;
+ int numPrunedSegments = selectedSegments.getNumPruned();
+ if (!survivingSegments.isEmpty()) {
InstanceSelector.SelectionResult selectionResult =
- instanceSelector.select(brokerRequest, new
ArrayList<>(selectedSegments), requestId);
+ instanceSelector.select(brokerRequest, new
ArrayList<>(survivingSegments), requestId);
selectionResult.setNumPrunedSegments(numPrunedSegments);
return selectionResult;
} else {
@@ -1535,15 +1557,47 @@ public abstract class BaseBrokerRoutingManager
implements RoutingManager, Cluste
}
List<String> getSegments(BrokerRequest brokerRequest, @Nullable String
samplerName) {
- SamplerInfo samplerInfo = getSamplerInfo(samplerName);
- SegmentSelector segmentSelector = samplerInfo != null ?
samplerInfo._segmentSelector : _segmentSelector;
- Set<String> selectedSegments = segmentSelector.select(brokerRequest);
- if (!selectedSegments.isEmpty()) {
- for (SegmentPruner segmentPruner : _segmentPruners) {
- selectedSegments = segmentPruner.prune(brokerRequest,
selectedSegments);
+ return new ArrayList<>(selectThenPrune(brokerRequest,
getSamplerInfo(samplerName))._surviving);
+ }
+
+ /// See [RoutingManager#getPrunedSegments]. The sampler is honoured for
the same reason the query path honours it:
+ /// a narrower selection only ever shrinks what this can prove, never
widens it.
+ ///
+ /// The pruners return a new set rather than editing the one they are
handed, so taking the difference costs
+ /// nothing unless something was actually pruned. If one ever did edit in
place the two sets would be the same
+ /// object, the difference would come out empty, and this would fall back
to proving nothing -- the safe direction.
+ Set<String> getPrunedSegments(BrokerRequest brokerRequest, @Nullable
String samplerName) {
+ SelectedSegments selectedSegments = selectThenPrune(brokerRequest,
getSamplerInfo(samplerName));
+ int numPruned = selectedSegments.getNumPruned();
+ if (numPruned == 0) {
+ return Set.of();
+ }
+ // Built up rather than copied down: the count is already known and is
usually a small fraction of the table's
+ // segments, so copying every selected segment only to remove most of
them again would size the allocation to
+ // the table instead of to the answer.
+ Set<String> prunedSegments = Sets.newHashSetWithExpectedSize(numPruned);
+ for (String segment : selectedSegments._selected) {
+ if (!selectedSegments._surviving.contains(segment)) {
+ prunedSegments.add(segment);
}
}
- return new ArrayList<>(selectedSegments);
+ return prunedSegments;
+ }
+ }
+
+ /// What one run of [RoutingEntry#selectThenPrune] decided: the segments
selection offered, and the ones the pruners
+ /// left. Both are needed because the difference between them is the only
sound proof that a segment cannot match.
+ private static class SelectedSegments {
+ final Set<String> _selected;
+ final Set<String> _surviving;
+
+ SelectedSegments(Set<String> selected, Set<String> surviving) {
+ _selected = selected;
+ _surviving = surviving;
+ }
+
+ int getNumPruned() {
+ return _selected.size() - _surviving.size();
}
}
}
diff --git
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManager.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManager.java
index 312d7b0b7d1..9f04a4ca3d4 100644
---
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManager.java
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManager.java
@@ -217,6 +217,62 @@ public class MultiClusterRoutingManager implements
RoutingManager {
return combined.isEmpty() ? null : combined;
}
+ /// Combines by *intersection* over the clusters that have routing for the
table, which is the opposite of how
+ /// [#getSegments] combines and deliberately so: that returns segments that
survive, and a segment survives if any
+ /// cluster keeps it, while this returns segments that are provably
eliminated, and a proof only holds if every
+ /// cluster that could route the segment eliminated it. Unioning instead
would let one cluster's pruners speak for a
+ /// segment another cluster would still have queried -- silently dropping
matching data.
+ ///
+ /// Restricting the intersection to the clusters that have the table is what
keeps it useful: the usual case is a
+ /// table in exactly one cluster, where the intersection is that cluster's
own verdict. A cluster without the table
+ /// would otherwise contribute an empty set and reduce every answer to
"nothing proven".
+ @Nullable
+ @Override
+ public Set<String> getPrunedSegments(BrokerRequest brokerRequest) {
+ String tableNameWithType = brokerRequest.getQuerySource().getTableName();
+ Set<String> combined = intersectPrunedSegments(null,
_localClusterRoutingManager, brokerRequest,
+ tableNameWithType);
+ for (BaseBrokerRoutingManager remoteCluster :
_remoteClusterRoutingManagers) {
+ combined = intersectPrunedSegments(combined, remoteCluster,
brokerRequest, tableNameWithType);
+ }
+ // Still null when no cluster has the table at all, which is what the
interface reports for a table that does not
+ // exist -- as opposed to an empty set, which is a cluster that ran the
pruners and proved nothing.
+ return combined;
+ }
+
+ /// Folds one cluster's verdict into the running intersection, or returns an
empty set to end it: once nothing is
+ /// proven, nothing downstream can make it provable again, and asking the
remaining clusters would run a full
+ /// selection and pruner chain each for an answer that is already fixed.
`null` means no cluster has answered yet.
+ @Nullable
+ private Set<String> intersectPrunedSegments(@Nullable Set<String> combined,
BaseBrokerRoutingManager cluster,
+ BrokerRequest brokerRequest, String tableNameWithType) {
+ if (combined != null && combined.isEmpty()) {
+ return combined;
+ }
+ try {
+ // One lookup rather than routingExists-then-get: a table appearing
between the two would let this skip a
+ // cluster that can route it, and the intersection would then claim
segments eliminated that nobody asked about.
+ Set<String> prunedSegments = cluster.getPrunedSegments(brokerRequest);
+ if (prunedSegments == null) {
+ // This cluster has no routing for the table, so it eliminates nothing
and constrains nothing.
+ return combined;
+ }
+ if (prunedSegments.isEmpty()) {
+ // This cluster proves nothing, so neither does the intersection.
+ return Set.of();
+ }
+ if (combined == null) {
+ return new HashSet<>(prunedSegments);
+ }
+ combined.retainAll(prunedSegments);
+ return combined;
+ } catch (Exception e) {
+ LOGGER.error("Error getting pruned segments from cluster routing manager
for table {}", tableNameWithType, e);
+ // A cluster we could not ask may still have routed any of these
segments, so prove nothing.
+ return Set.of();
+ }
+ }
+
/// Returns the partition info only when a single cluster has any, and
`null` when more than one does.
///
/// Unlike [#getRoutingTable], [#getSegments] and [#getServingInstances],
this cannot union the clusters: the info is
diff --git
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/BrokerRoutingManagerTest.java
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/BrokerRoutingManagerTest.java
index 32be5f024cf..75c2f3570ba 100644
---
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/BrokerRoutingManagerTest.java
+++
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/BrokerRoutingManagerTest.java
@@ -19,9 +19,10 @@
package org.apache.pinot.broker.routing.manager;
import java.lang.reflect.Constructor;
-import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.function.Consumer;
import org.apache.helix.AccessOption;
import org.apache.helix.BaseDataAccessor;
@@ -45,6 +46,8 @@ import
org.apache.pinot.broker.routing.tablesampler.TableSampler;
import org.apache.pinot.broker.routing.timeboundary.TimeBoundaryManager;
import org.apache.pinot.common.metrics.BrokerGauge;
import org.apache.pinot.common.metrics.BrokerMetrics;
+import org.apache.pinot.common.request.BrokerRequest;
+import org.apache.pinot.common.request.QuerySource;
import org.apache.pinot.common.utils.config.TableConfigSerDeUtils;
import org.apache.pinot.core.routing.TablePartitionInfo;
import org.apache.pinot.core.routing.TablePartitionReplicatedServersInfo;
@@ -73,9 +76,11 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
import static org.testng.Assert.assertSame;
import static org.testng.Assert.assertTrue;
@@ -258,17 +263,170 @@ public class BrokerRoutingManagerTest {
assertSame(_routingManager.getTablePartitionReplicatedServersInfo(TEST_TABLE),
expectedReplicatedServersInfo);
}
+ @Test
+ public void testGetPrunedSegmentsIsExactlySelectedMinusSurvivors()
+ throws Exception {
+ SegmentSelector segmentSelector = selectorOf("seg1", "seg2", "seg3",
"seg4");
+ putRoutingEntry(TEST_TABLE,
+ createRoutingEntry(TEST_TABLE, segmentSelector,
List.of(prunerDropping("seg1", "seg3")),
+ mock(InstanceSelector.class)));
+
+ assertEquals(_routingManager.getPrunedSegments(brokerRequest(TEST_TABLE)),
Set.of("seg1", "seg3"));
+ }
+
+ @Test
+ public void testGetPrunedSegmentsChainsEveryPruner()
+ throws Exception {
+ SegmentPruner firstPruner = prunerDropping("seg1");
+ SegmentPruner secondPruner = prunerDropping("seg3");
+ putRoutingEntry(TEST_TABLE,
+ createRoutingEntry(TEST_TABLE, selectorOf("seg1", "seg2", "seg3"),
List.of(firstPruner, secondPruner),
+ mock(InstanceSelector.class)));
+
+ assertEquals(_routingManager.getPrunedSegments(brokerRequest(TEST_TABLE)),
Set.of("seg1", "seg3"));
+
+ // The second pruner judges what the first left, so consulting only the
last one would lose "seg1".
+ ArgumentCaptor<Set<String>> captor = ArgumentCaptor.captor();
+ verify(secondPruner).prune(any(), captor.capture());
+ assertEquals(captor.getValue(), Set.of("seg2", "seg3"));
+ }
+
+ /// Nothing pruned must read as "proved nothing", not as "proved every
selected segment empty" -- the latter would
+ /// let a caller treat a table that fully matches the filter as a table with
no matching rows.
+ @Test
+ public void testGetPrunedSegmentsIsEmptyWhenNothingWasPruned()
+ throws Exception {
+ putRoutingEntry(TEST_TABLE,
+ createRoutingEntry(TEST_TABLE, selectorOf("seg1", "seg2"),
List.of(prunerDropping()),
+ mock(InstanceSelector.class)));
+
+ assertEquals(_routingManager.getPrunedSegments(brokerRequest(TEST_TABLE)),
Set.of());
+ }
+
+ @Test
+ public void testGetPrunedSegmentsIsEmptyWhenSelectionIsEmpty()
+ throws Exception {
+ SegmentPruner pruner = prunerDropping("seg1");
+ putRoutingEntry(TEST_TABLE,
+ createRoutingEntry(TEST_TABLE, selectorOf(), List.of(pruner),
mock(InstanceSelector.class)));
+
+ assertEquals(_routingManager.getPrunedSegments(brokerRequest(TEST_TABLE)),
Set.of());
+ // An empty selection is answered without asking anyone, so the empty
result cannot have come from a pruner.
+ verify(pruner, never()).prune(any(), any());
+ }
+
+ /// A table this broker has no routing for is `null`, not an empty set: it
eliminated nothing because it would have
+ /// routed nothing, which is a different claim from "the pruners ran and
proved nothing".
+ @Test
+ public void testGetPrunedSegmentsIsNullForUnknownTable() {
+
assertNull(_routingManager.getPrunedSegments(brokerRequest("noSuchTable_OFFLINE")));
+ }
+
+ /// The whole point of the API: only presence in the result is a proof. A
segment the selector never offered is
+ /// absent from the survivors for a reason that has nothing to do with the
filter -- here the selector withheld
+ /// "seg3" -- and reporting it would let a caller skip a segment that may
well hold matching rows.
+ @Test
+ public void
testGetPrunedSegmentsDoesNotReportASegmentTheSelectorNeverOffered()
+ throws Exception {
+ putRoutingEntry(TEST_TABLE,
+ createRoutingEntry(TEST_TABLE, selectorOf("seg1", "seg2"),
List.of(prunerDropping("seg1", "seg3")),
+ mock(InstanceSelector.class)));
+
+ Set<String> prunedSegments =
_routingManager.getPrunedSegments(brokerRequest(TEST_TABLE));
+
+ assertEquals(prunedSegments, Set.of("seg1"));
+ assertFalse(prunedSegments.contains("seg3"));
+ }
+
+ /// Instance selection is what makes routing depend on the request id and on
which replicas are up; keeping it out
+ /// is what makes this deterministic enough to plan on.
+ @Test
+ public void testGetPrunedSegmentsNeverConsultsInstanceSelection()
+ throws Exception {
+ InstanceSelector instanceSelector = mock(InstanceSelector.class);
+ putRoutingEntry(TEST_TABLE,
+ createRoutingEntry(TEST_TABLE, selectorOf("seg1", "seg2"),
List.of(prunerDropping("seg1")), instanceSelector));
+
+ assertEquals(_routingManager.getPrunedSegments(brokerRequest(TEST_TABLE)),
Set.of("seg1"));
+ verifyNoInteractions(instanceSelector);
+ }
+
+ /// A pruner that edits the set it was handed leaves nothing to take a
difference against. That has to degrade to
+ /// "proved nothing" rather than to a wrong proof.
+ @Test
+ public void testGetPrunedSegmentsIsEmptyWhenAPrunerEditsInPlace()
+ throws Exception {
+ SegmentPruner pruner = mock(SegmentPruner.class);
+ when(pruner.prune(any(), any())).thenAnswer(invocation -> {
+ Set<String> segments = invocation.getArgument(1);
+ segments.remove("seg1");
+ return segments;
+ });
+ SegmentSelector segmentSelector = mock(SegmentSelector.class);
+ when(segmentSelector.select(any())).thenReturn(new
HashSet<>(Set.of("seg1", "seg2")));
+ putRoutingEntry(TEST_TABLE,
+ createRoutingEntry(TEST_TABLE, segmentSelector, List.of(pruner),
mock(InstanceSelector.class)));
+
+ assertEquals(_routingManager.getPrunedSegments(brokerRequest(TEST_TABLE)),
Set.of());
+ }
+
+ private static BrokerRequest brokerRequest(String tableNameWithType) {
+ QuerySource querySource = new QuerySource();
+ querySource.setTableName(tableNameWithType);
+ BrokerRequest brokerRequest = new BrokerRequest();
+ brokerRequest.setQuerySource(querySource);
+ return brokerRequest;
+ }
+
+ private static SegmentSelector selectorOf(String... segments) {
+ SegmentSelector segmentSelector = mock(SegmentSelector.class);
+ when(segmentSelector.select(any())).thenReturn(Set.of(segments));
+ return segmentSelector;
+ }
+
+ /// Mirrors
[org.apache.pinot.broker.routing.segmentpruner.EmptySegmentPruner]: a fresh set
when it prunes
+ /// something, the very set it was handed when it does not.
+ private static SegmentPruner prunerDropping(String... segments) {
+ Set<String> droppedSegments = Set.of(segments);
+ SegmentPruner segmentPruner = mock(SegmentPruner.class);
+ when(segmentPruner.prune(any(), any())).thenAnswer(invocation -> {
+ Set<String> candidateSegments = invocation.getArgument(1);
+ if (droppedSegments.stream().noneMatch(candidateSegments::contains)) {
+ return candidateSegments;
+ }
+ Set<String> survivingSegments = new HashSet<>(candidateSegments);
+ survivingSegments.removeAll(droppedSegments);
+ return survivingSegments;
+ });
+ return segmentPruner;
+ }
+
private static Object createRoutingEntry(String tableNameWithType,
TimeBoundaryManager timeBoundaryManager,
SegmentPartitionMetadataManager partitionMetadataManager, Map<String, ?>
samplerInfos)
throws Exception {
- return createRoutingEntry(tableNameWithType, timeBoundaryManager,
partitionMetadataManager, samplerInfos,
- mock(InstanceSelector.class), false);
+ return createRoutingEntry(tableNameWithType, mock(SegmentSelector.class),
List.of(),
+ mock(InstanceSelector.class), timeBoundaryManager,
partitionMetadataManager, samplerInfos, false);
}
private static Object createRoutingEntry(String tableNameWithType,
TimeBoundaryManager timeBoundaryManager,
SegmentPartitionMetadataManager partitionMetadataManager, Map<String, ?>
samplerInfos,
InstanceSelector instanceSelector, boolean disabled)
throws Exception {
+ return createRoutingEntry(tableNameWithType, mock(SegmentSelector.class),
List.of(), instanceSelector,
+ timeBoundaryManager, partitionMetadataManager, samplerInfos, disabled);
+ }
+
+ private static Object createRoutingEntry(String tableNameWithType,
SegmentSelector segmentSelector,
+ List<SegmentPruner> segmentPruners, InstanceSelector instanceSelector)
+ throws Exception {
+ return createRoutingEntry(tableNameWithType, segmentSelector,
segmentPruners, instanceSelector,
+ mock(TimeBoundaryManager.class),
mock(SegmentPartitionMetadataManager.class), Map.of(), false);
+ }
+
+ private static Object createRoutingEntry(String tableNameWithType,
SegmentSelector segmentSelector,
+ List<SegmentPruner> segmentPruners, InstanceSelector instanceSelector,
TimeBoundaryManager timeBoundaryManager,
+ SegmentPartitionMetadataManager partitionMetadataManager, Map<String, ?>
samplerInfos, boolean disabled)
+ throws Exception {
Class<?> routingEntryClass =
Class.forName(BaseBrokerRoutingManager.class.getName() + "$RoutingEntry");
Constructor<?> constructor =
routingEntryClass.getDeclaredConstructor(String.class, String.class,
String.class,
SegmentPreSelector.class, SegmentSelector.class, List.class,
InstanceSelector.class, int.class, int.class,
@@ -276,8 +434,8 @@ public class BrokerRoutingManagerTest {
Map.class, boolean.class);
constructor.setAccessible(true);
return constructor.newInstance(tableNameWithType, "/IDEALSTATES/" +
tableNameWithType,
- "/EXTERNALVIEW/" + tableNameWithType, mock(SegmentPreSelector.class),
mock(SegmentSelector.class),
- Collections.<SegmentPruner>emptyList(), instanceSelector, 1, 1,
+ "/EXTERNALVIEW/" + tableNameWithType, mock(SegmentPreSelector.class),
segmentSelector, segmentPruners,
+ instanceSelector, 1, 1,
mock(SegmentZkMetadataFetcher.class), timeBoundaryManager,
partitionMetadataManager, null, samplerInfos,
disabled);
}
diff --git
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManagerTest.java
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManagerTest.java
index 8d8cdb34c26..0ea105a56e1 100644
---
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManagerTest.java
+++
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManagerTest.java
@@ -44,6 +44,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertNull;
import static org.testng.Assert.assertTrue;
@@ -327,6 +328,91 @@ public class MultiClusterRoutingManagerTest {
assertEquals(_multiClusterRoutingManager.getTablePartitionReplicatedServersInfo(TEST_TABLE),
partitionInfo);
}
+ /// The usual case: the table lives in exactly one cluster, so the
intersection is that cluster's own verdict.
+ @Test
+ public void testGetPrunedSegmentsReturnsTheSoleClusterVerdictVerbatim() {
+ BrokerRequest brokerRequest = createMockBrokerRequest(TEST_TABLE);
+ when(_localClusterRoutingManager.getPrunedSegments(brokerRequest))
+ .thenReturn(Set.of("seg1", "seg2"));
+ withoutTheTable(brokerRequest, _remoteClusterRoutingManager1,
_remoteClusterRoutingManager2);
+
+ assertEquals(_multiClusterRoutingManager.getPrunedSegments(brokerRequest),
Set.of("seg1", "seg2"));
+ }
+
+ /// Intersection, not union: unioning would let one cluster's pruners speak
for a segment another cluster would
+ /// still have queried, so the planner would skip data that matches -- a
silent wrong answer rather than a slow one.
+ /// Here only "seg2" is eliminated everywhere; "seg1" and "seg3" each
survive in one cluster.
+ @Test
+ public void testGetPrunedSegmentsIntersectsRatherThanUnions() {
+ BrokerRequest brokerRequest = createMockBrokerRequest(TEST_TABLE);
+ when(_localClusterRoutingManager.getPrunedSegments(brokerRequest))
+ .thenReturn(Set.of("seg1", "seg2"));
+ when(_remoteClusterRoutingManager1.getPrunedSegments(brokerRequest))
+ .thenReturn(Set.of("seg2", "seg3"));
+ withoutTheTable(brokerRequest, _remoteClusterRoutingManager2);
+
+ Set<String> prunedSegments =
_multiClusterRoutingManager.getPrunedSegments(brokerRequest);
+
+ assertEquals(prunedSegments, Set.of("seg2"));
+ assertFalse(prunedSegments.contains("seg1"));
+ assertFalse(prunedSegments.contains("seg3"));
+ }
+
+ @Test
+ public void testGetPrunedSegmentsIsEmptyWhenAClusterProvesNothing() {
+ BrokerRequest brokerRequest = createMockBrokerRequest(TEST_TABLE);
+
when(_localClusterRoutingManager.getPrunedSegments(brokerRequest)).thenReturn(Set.of("seg1"));
+
when(_remoteClusterRoutingManager1.getPrunedSegments(brokerRequest)).thenReturn(Set.of());
+ withoutTheTable(brokerRequest, _remoteClusterRoutingManager2);
+
+ assertEquals(_multiClusterRoutingManager.getPrunedSegments(brokerRequest),
Set.of());
+ }
+
+ /// A cluster that does not have the table constrains nothing. Were its
absence folded into the same empty set the
+ /// pruners use for "proved nothing", every answer would collapse to
"nothing proven" in the common deployment.
+ @Test
+ public void testGetPrunedSegmentsSkipsAClusterWithoutTheTable() {
+ BrokerRequest brokerRequest = createMockBrokerRequest(TEST_TABLE);
+ when(_localClusterRoutingManager.getPrunedSegments(brokerRequest))
+ .thenReturn(Set.of("seg1", "seg2"));
+ // A cluster without the table reports null rather than an empty verdict,
which is what keeps it from collapsing
+ // the intersection to "nothing proven".
+ withoutTheTable(brokerRequest, _remoteClusterRoutingManager1,
_remoteClusterRoutingManager2);
+
+ assertEquals(_multiClusterRoutingManager.getPrunedSegments(brokerRequest),
Set.of("seg1", "seg2"));
+ }
+
+ @Test
+ public void testGetPrunedSegmentsIsNullWhenNoClusterHasTheTable() {
+ BrokerRequest brokerRequest = createMockBrokerRequest(TEST_TABLE);
+ withoutTheTable(brokerRequest, _localClusterRoutingManager,
_remoteClusterRoutingManager1,
+ _remoteClusterRoutingManager2);
+
+ assertNull(_multiClusterRoutingManager.getPrunedSegments(brokerRequest));
+ }
+
+ /// The deliberate opposite of
[#testGetTablePartitionInfoIgnoresAFailingRemoteCluster]: a cluster we could
not ask
+ /// may still have routed any of these segments, so its silence cannot be
read as agreement.
+ @Test
+ public void testGetPrunedSegmentsIsEmptyWhenAClusterThrows() {
+ BrokerRequest brokerRequest = createMockBrokerRequest(TEST_TABLE);
+
when(_localClusterRoutingManager.getPrunedSegments(brokerRequest)).thenReturn(Set.of("seg1"));
+ when(_remoteClusterRoutingManager1.getPrunedSegments(brokerRequest))
+ .thenThrow(new RuntimeException("remote cluster is down"));
+ withoutTheTable(brokerRequest, _remoteClusterRoutingManager2);
+
+ assertEquals(_multiClusterRoutingManager.getPrunedSegments(brokerRequest),
Set.of());
+ }
+
+ /// Makes the given clusters report that they have no routing for the table.
Worth spelling out in every pruning
+ /// test: an unstubbed mock hands back an empty set, which means "ran the
pruners and proved nothing" and would
+ /// collapse the intersection for a reason the test did not intend.
+ private static void withoutTheTable(BrokerRequest brokerRequest,
BaseBrokerRoutingManager... clusters) {
+ for (BaseBrokerRoutingManager cluster : clusters) {
+ when(cluster.getPrunedSegments(brokerRequest)).thenReturn(null);
+ }
+ }
+
private RoutingTable createRoutingTable(String serverName, List<String>
segments) {
Map<ServerInstance, SegmentsToQuery> serverMap = new HashMap<>();
ServerInstance server = createMockServerInstance(serverName);
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/routing/RoutingManager.java
b/pinot-core/src/main/java/org/apache/pinot/core/routing/RoutingManager.java
index 9f33b868b51..53198ede031 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/routing/RoutingManager.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/routing/RoutingManager.java
@@ -89,6 +89,38 @@ public interface RoutingManager {
return getSegments(brokerRequest);
}
+ /// Returns the segments that the segment pruners *provably eliminated* for
the given broker request, i.e. the
+ /// selected segments that cannot hold a row matching the request's filter.
Absence from the returned set means
+ /// nothing: a segment may be missing because it matches, because it was
never selected, or because the table does
+ /// not exist. Only presence is a proof.
+ ///
+ /// This is the complement of [#getSegments(BrokerRequest)], and the
distinction is what makes it usable as a
+ /// planning-time emptiness proof. Deciding "this segment does not match"
from *absence* of a survivor conflates
+ /// pruning with the several innocent reasons a segment can be missing from
a routing result -- it was classified as
+ /// optional by instance selection, its server left the enabled server map,
or it entered the partition metadata
+ /// before it became selectable -- and each of those would silently drop
matching data. Deciding it from presence in
+ /// this set cannot.
+ ///
+ /// Instance selection deliberately takes no part, so the result depends
only on the request and the pruners, never
+ /// on a request id or on which replica a query happens to pick.
+ ///
+ /// Returns `null` if the table does not exist, as
[#getSegments(BrokerRequest)] does, which is not the same as an
+ /// empty set: a broker that does not have the table eliminated nothing
because it would have routed nothing, while
+ /// an empty set is a broker that ran the pruners and proved nothing. A
caller combining several brokers' verdicts
+ /// has to tell those apart, and gets both from this one lookup rather than
from a separate existence check that
+ /// could disagree with it.
+ ///
+ /// The returned set is for reading only: an implementation may hand back an
immutable or a shared set, so a caller
+ /// that needs to modify it must copy it first.
+ ///
+ /// The default implementation returns an empty set, i.e. proves nothing
about any segment. Note that this is a
+ /// statement about segments only: a caller may still act on emptiness it
can see for itself, such as a partition
+ /// that lists no segment at all.
+ @Nullable
+ default Set<String> getPrunedSegments(BrokerRequest brokerRequest) {
+ return Set.of();
+ }
+
/// Validate routing exist for a table
///
/// @param tableNameWithType the name of the table.
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/ColocatedJoinEmptyPartitionTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/ColocatedJoinEmptyPartitionTest.java
index 797ff14d35f..cfc96bbc721 100644
---
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/ColocatedJoinEmptyPartitionTest.java
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/ColocatedJoinEmptyPartitionTest.java
@@ -27,6 +27,8 @@ import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
import org.apache.avro.SchemaBuilder;
import org.apache.avro.file.DataFileWriter;
import org.apache.avro.generic.GenericData;
@@ -34,6 +36,7 @@ import org.apache.avro.generic.GenericDatumWriter;
import org.apache.commons.io.FileUtils;
import org.apache.pinot.integration.tests.ClusterIntegrationTestUtils;
import org.apache.pinot.spi.config.table.ColumnPartitionConfig;
+import org.apache.pinot.spi.config.table.RoutingConfig;
import org.apache.pinot.spi.config.table.SegmentPartitionConfig;
import org.apache.pinot.spi.config.table.TableConfig;
import org.apache.pinot.spi.config.table.TableType;
@@ -66,6 +69,11 @@ import static org.testng.Assert.assertTrue;
/// end-to-end run can show is that a real server accepts and answers a
leaf-stage request whose segment list is empty
/// for a genuinely partitioned table scan.
///
+/// The same fixture covers the class reduction a filter buys. The tables
configure the `partition` segment pruner, so
+/// a restriction on the join key lets the broker eliminate segments before
planning, and a class every member
+/// eliminates leaves the group: `partitionKey IN (1, 2)` keeps only classes 1
and 2, halving the leaf worker count.
+/// A restriction matching nothing anywhere falls back to the populated
classes and returns an empty result.
+///
/// The partition layout is supplied with explicit `tableOptions` hints rather
than inferred, because hint inference is
/// off by default (`pinot.broker.multistage.infer.partition.hint`); the hints
carry exactly what it would have
/// produced. The `is_colocated_by_join_keys` hint is spelled out for
readability -- the exchange would be
@@ -91,6 +99,12 @@ public class ColocatedJoinEmptyPartitionTest extends
CustomDataQueryClusterInteg
private static final int NUM_KEPT_CLASSES_FOR_JOIN = 4;
private static final int NUM_ROWS_PER_PARTITION = 2;
+ /// The partition keys the pruning tests filter on. They land in partitions
1 and 2, the only ones both tables
+ /// populate, so classes 0 (left only) and 3 (right only) are eliminated on
every member of the colocated group.
+ private static final List<Integer> FILTERED_KEYS = List.of(1, 2);
+ /// A key in a partition neither table populates, so the filter matches
nothing anywhere.
+ private static final List<Integer> UNMATCHED_KEYS = List.of(5);
+
private static final int LEFT_METRIC_MULTIPLIER = 10;
private static final int RIGHT_METRIC_MULTIPLIER = 100;
@@ -235,31 +249,143 @@ public class ColocatedJoinEmptyPartitionTest extends
CustomDataQueryClusterInteg
assertEquals(colocatedResponse.get("resultTable").get("rows"),
shuffledResponse.get("resultTable").get("rows"),
"Colocated and shuffled plans must return the same rows");
+ assertShuffledLeafStages(shuffledResponse, 2);
+ }
- JsonNode shuffledStageStats = shuffledResponse.get("stageStats");
- assertNotNull(shuffledStageStats, "Missing stage stats in shuffled
response: " + shuffledResponse);
- List<JsonNode> shuffledLeafStageSends = new ArrayList<>();
- collectLeafStageSends(shuffledStageStats, shuffledLeafStageSends);
- assertEquals(shuffledLeafStageSends.size(), 2,
- "Unexpected number of leaf stages in stage stats: " +
shuffledStageStats.toPrettyString());
- for (JsonNode leafStageSend : shuffledLeafStageSends) {
- assertTrue(leafStageSend.path("fanOut").asInt(-1) > 1,
- "A shuffled leaf send must write more than one receive mailbox,
otherwise the fanOut of 1 asserted for the "
- + "colocated plan proves nothing. Stage stats: " +
shuffledStageStats.toPrettyString());
- }
+ /// The class reduction broker pruning buys: a filter that every member of
the colocated group can prune with drops
+ /// the classes all of them eliminate, so the leaves run fewer workers and
scan fewer segments than the union of the
+ /// populated classes. What the reduced width must not cost is the 1-to-1
wiring, which is what the two exchange
+ /// assertions are for.
+ @Test
+ public void testColocatedJoinPrunesClassesEveryMemberFilters()
+ throws Exception {
+ setUseMultiStageQueryEngine(true);
+ String query = colocatedJoinQuery(LEFT_TABLE_NAME, RIGHT_TABLE_NAME,
bothSidesFilter(FILTERED_KEYS));
+
+ JsonNode response = queryBrokerHttpEndpoint(query);
+ assertNoExceptions(response);
+ assertRows(response, expectedFilteredRows(FILTERED_KEYS));
+
+ // Classes 0 and 3 go: the left table's only class-0 segment is pruned by
its own filter and the right table never
+ // populated that class, and symmetrically for class 3. Both tables
populate every surviving class with exactly one
+ // segment, so both leaves scan one segment per kept class.
+ List<Integer> keptClasses = keptClassesFor(FILTERED_KEYS);
+ assertLeafStages(response, 2, keptClasses.size(), keptClasses.size());
+ assertDirectExchanges(query, 2, keptClasses.size());
+
+ // One segment per class that a table populated but the filter did not
keep: the left table's class-0 segment and
+ // the right table's class-3 one.
+ long expectedNumPrunedSegments =
+ Stream.concat(LEFT_POPULATED_PARTITIONS.stream(),
RIGHT_POPULATED_PARTITIONS.stream())
+ .filter(partition -> !keptClasses.contains(partition))
+ .count();
+ assertEquals(response.path("numSegmentsPrunedByBroker").asLong(-1),
expectedNumPrunedSegments,
+ "Unexpected number of broker-pruned segments in response: " +
response);
+ }
+
+ /// The same differential check as [#testColocatedJoinMatchesShuffledJoin],
at the reduced width. A plan that quietly
+ /// fell back to a shuffle would return the right rows too, so this pairs
with the `fanOut` and `[PARTITIONED]`
+ /// assertions rather than replacing them; what it rules out is a reduction
that pairs the wrong classes and drops or
+ /// duplicates rows with no error. The shuffled plan reaches its answer by
an independent route: with no table hints
+ /// its leaves are assigned per server and pruned per segment, not per
partition class.
+ @Test
+ public void testFilteredColocatedJoinMatchesFilteredShuffledJoin()
+ throws Exception {
+ setUseMultiStageQueryEngine(true);
+ String whereClause = bothSidesFilter(FILTERED_KEYS);
+ JsonNode colocatedResponse = queryBrokerHttpEndpoint(
+ colocatedJoinQuery(LEFT_TABLE_NAME, RIGHT_TABLE_NAME, whereClause));
+ assertNoExceptions(colocatedResponse);
+ // Pin the rows rather than only comparing the two plans: were the filter
to stop matching anything, both sides
+ // would return nothing and the comparison below would still pass while
proving nothing at all.
+ assertRows(colocatedResponse, expectedFilteredRows(FILTERED_KEYS));
+ JsonNode shuffledResponse = queryBrokerHttpEndpoint(
+ shuffledJoinQuery(LEFT_TABLE_NAME, RIGHT_TABLE_NAME, whereClause));
+ assertNoExceptions(shuffledResponse);
+
+ assertEquals(colocatedResponse.get("resultTable").get("rows"),
shuffledResponse.get("resultTable").get("rows"),
+ "Class-reduced colocated and shuffled plans must return the same
rows");
+ assertShuffledLeafStages(shuffledResponse, 2);
+ }
+
+ /// A filter every member prunes every segment with: the group is left with
no surviving class at all. It must fall
+ /// back to its populated classes and let the servers return the empty
result, because a zero-worker leaf has no
+ /// handling on a 1-to-1 exchange -- an empty answer, not an error.
+ @Test
+ public void testColocatedJoinWithFilterMatchingNothing()
+ throws Exception {
+ setUseMultiStageQueryEngine(true);
+ assertTrue(keptClassesFor(UNMATCHED_KEYS).isEmpty(),
+ "The filter must match no partition either table populates, otherwise
this is not the all-pruned fallback");
+ String query = colocatedJoinQuery(LEFT_TABLE_NAME, RIGHT_TABLE_NAME,
bothSidesFilter(UNMATCHED_KEYS));
+
+ JsonNode response = queryBrokerHttpEndpoint(query);
+ assertNoExceptions(response);
+ assertRows(response, List.of());
+
+ // Planned exactly as if there were no filter, and nothing is reported as
pruned: the fallback dropped no class the
+ // group would otherwise have kept.
+ assertLeafStages(response, 2, NUM_KEPT_CLASSES_FOR_JOIN,
LEFT_POPULATED_PARTITIONS.size());
+ assertDirectExchanges(query, 2, NUM_KEPT_CLASSES_FOR_JOIN);
+ assertEquals(response.path("numSegmentsPrunedByBroker").asLong(-1), 0L,
+ "Unexpected number of broker-pruned segments in response: " +
response);
}
private static String colocatedJoinQuery(String leftTableName, String
rightTableName) {
+ return colocatedJoinQuery(leftTableName, rightTableName, "");
+ }
+
+ private static String colocatedJoinQuery(String leftTableName, String
rightTableName, String whereClause) {
return String.format(
- "SELECT %s l.%s, l.%s, r.%s FROM %s %s AS l JOIN %s %s AS r ON l.%s =
r.%s ORDER BY l.%s",
+ "SELECT %s l.%s, l.%s, r.%s FROM %s %s AS l JOIN %s %s AS r ON l.%s =
r.%s %s ORDER BY l.%s",
COLOCATED_JOIN_HINT, PARTITION_KEY_COLUMN, METRIC_COLUMN,
METRIC_COLUMN, leftTableName, TABLE_HINT,
- rightTableName, TABLE_HINT, PARTITION_KEY_COLUMN,
PARTITION_KEY_COLUMN, PARTITION_KEY_COLUMN);
+ rightTableName, TABLE_HINT, PARTITION_KEY_COLUMN,
PARTITION_KEY_COLUMN, whereClause, PARTITION_KEY_COLUMN);
}
private static String shuffledJoinQuery(String leftTableName, String
rightTableName) {
- return String.format("SELECT l.%s, l.%s, r.%s FROM %s AS l JOIN %s AS r ON
l.%s = r.%s ORDER BY l.%s",
+ return shuffledJoinQuery(leftTableName, rightTableName, "");
+ }
+
+ private static String shuffledJoinQuery(String leftTableName, String
rightTableName, String whereClause) {
+ return String.format("SELECT l.%s, l.%s, r.%s FROM %s AS l JOIN %s AS r ON
l.%s = r.%s %s ORDER BY l.%s",
PARTITION_KEY_COLUMN, METRIC_COLUMN, METRIC_COLUMN, leftTableName,
rightTableName, PARTITION_KEY_COLUMN,
- PARTITION_KEY_COLUMN, PARTITION_KEY_COLUMN);
+ PARTITION_KEY_COLUMN, whereClause, PARTITION_KEY_COLUMN);
+ }
+
+ /// A partition-key restriction spelled out on both sides of the join rather
than on one and left to Calcite's
+ /// transitive inference, so that the leaf of each member carries it
whatever the planner decides to push down.
+ private static String bothSidesFilter(List<Integer> keys) {
+ String keyList =
keys.stream().map(String::valueOf).collect(Collectors.joining(", "));
+ return String.format("WHERE l.%s IN (%s) AND r.%s IN (%s)",
PARTITION_KEY_COLUMN, keyList, PARTITION_KEY_COLUMN,
+ keyList);
+ }
+
+ /// The partition classes a colocated join of the two tables keeps under the
given partition-key restriction. A class
+ /// survives when at least one member still holds a segment its own filter
leaves, i.e. when some restricted key
+ /// hashes into a partition that member populates. One partition per class
here, since the declared partition count
+ /// is the hinted partition size.
+ private static List<Integer> keptClassesFor(List<Integer> keys) {
+ return keys.stream()
+ .map(key -> key % NUM_DECLARED_PARTITIONS)
+ .filter(partition -> LEFT_POPULATED_PARTITIONS.contains(partition)
+ || RIGHT_POPULATED_PARTITIONS.contains(partition))
+ .distinct()
+ .sorted()
+ .collect(Collectors.toList());
+ }
+
+ /// The rows the given partition-key restriction leaves in an inner join of
the two tables: a key survives when both
+ /// tables populate the partition it hashes into, and every populated
partition holds every one of its keys.
+ private static List<List<Long>> expectedFilteredRows(List<Integer> keys) {
+ List<List<Long>> expectedRows = new ArrayList<>();
+ for (int key : keys) {
+ int partition = key % NUM_DECLARED_PARTITIONS;
+ if (LEFT_POPULATED_PARTITIONS.contains(partition) &&
RIGHT_POPULATED_PARTITIONS.contains(partition)) {
+ expectedRows.add(
+ List.of((long) key, (long) key * LEFT_METRIC_MULTIPLIER, (long)
key * RIGHT_METRIC_MULTIPLIER));
+ }
+ }
+ return expectedRows;
}
private static void assertNoExceptions(JsonNode response) {
@@ -315,6 +441,22 @@ public class ColocatedJoinEmptyPartitionTest extends
CustomDataQueryClusterInteg
}
}
+ /// Asserts that every leaf stage of a shuffled plan writes more than one
receive mailbox. This is the control that
+ /// gives the `fanOut` of 1 asserted for a colocated plan its meaning.
+ private static void assertShuffledLeafStages(JsonNode response, int
expectedNumLeafStages) {
+ JsonNode stageStats = response.get("stageStats");
+ assertNotNull(stageStats, "Missing stage stats in shuffled response: " +
response);
+ List<JsonNode> leafStageSends = new ArrayList<>();
+ collectLeafStageSends(stageStats, leafStageSends);
+ assertEquals(leafStageSends.size(), expectedNumLeafStages,
+ "Unexpected number of leaf stages in stage stats: " +
stageStats.toPrettyString());
+ for (JsonNode leafStageSend : leafStageSends) {
+ assertTrue(leafStageSend.path("fanOut").asInt(-1) > 1,
+ "A shuffled leaf send must write more than one receive mailbox,
otherwise the fanOut of 1 asserted for the "
+ + "colocated plan proves nothing. Stage stats: " +
stageStats.toPrettyString());
+ }
+ }
+
private static void collectLeafStageSends(JsonNode node, List<JsonNode>
leafStageSends) {
JsonNode children = node.get("children");
if ("MAILBOX_SEND".equals(node.path("type").asText()) && children != null
&& children.size() == 1 && "LEAF".equals(
@@ -406,6 +548,10 @@ public class ColocatedJoinEmptyPartitionTest extends
CustomDataQueryClusterInteg
.setNumReplicas(2)
.setSegmentPartitionConfig(new SegmentPartitionConfig(
Map.of(PARTITION_KEY_COLUMN, new
ColumnPartitionConfig(PARTITION_FUNCTION, NUM_DECLARED_PARTITIONS))))
+ // Without this the broker builds no partition pruner at all
(SegmentPrunerFactory only reads the routing
+ // config), a filter on the partition key would prune nothing, and the
filtered tests below would assert the
+ // unfiltered worker count. The unfiltered tests are unaffected: with
no filter there is nothing to prune.
+ .setRoutingConfig(new RoutingConfig(null,
List.of(RoutingConfig.PARTITION_SEGMENT_PRUNER_TYPE), null, null))
.build();
}
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/DispatchablePlanContext.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/DispatchablePlanContext.java
index f50a3db9e1c..585576dfa99 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/DispatchablePlanContext.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/DispatchablePlanContext.java
@@ -61,6 +61,7 @@ public class DispatchablePlanContext {
private final Map<Integer, DispatchablePlanMetadata>
_dispatchablePlanMetadataMap = new HashMap<>();
private final Map<Integer, PlanNode> _dispatchablePlanStageRootMap = new
HashMap<>();
private final Map<String, WorkerManager.PartitionTableInfo>
_partitionTableInfoCache = new HashMap<>();
+ private final Map<Integer, Set<String>> _prunedSegmentsCache = new
HashMap<>();
private long _numSegmentsPrunedByBroker;
private int _leafStagesAssigned;
private int _leafStagesEmpty;
@@ -141,6 +142,14 @@ public class DispatchablePlanContext {
return _partitionTableInfoCache;
}
+ /// The segments the broker's pruners provably eliminated for each leaf
fragment, keyed by fragment id. Keyed by
+ /// fragment rather than by table because the verdict depends on the leaf's
own filter, and the two sides of a
+ /// self-join scan one table under two different ones. Cached because the
colocation pre-pass and the leaf
+ /// assignment both need it, and each entry costs a routing call.
+ public Map<Integer, Set<String>> getPrunedSegmentsCache() {
+ return _prunedSegmentsCache;
+ }
+
public long getNumSegmentsPrunedByBroker() {
return _numSegmentsPrunedByBroker;
}
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/ColocationGroupAnalyzer.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/ColocationGroupAnalyzer.java
index 88b43fb61e7..0d5508f6fc9 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/ColocationGroupAnalyzer.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/ColocationGroupAnalyzer.java
@@ -150,7 +150,7 @@ class ColocationGroupAnalyzer {
if (members.size() < 2) {
return null;
}
- List<Integer> partitionedLeafFragmentIds = new ArrayList<>();
+ List<PlanFragment> partitionedLeafFragments = new ArrayList<>();
int partitionSize = -1;
int partitionParallelism = -1;
String partitionFunction = null;
@@ -186,7 +186,7 @@ class ColocationGroupAnalyzer {
return null;
}
String leafPartitionFunction = hints.getHintedPartitionFunction();
- if (partitionedLeafFragmentIds.isEmpty()) {
+ if (partitionedLeafFragments.isEmpty()) {
partitionSize = hints.getPartitionSize();
partitionParallelism = hints.getPartitionParallelism();
partitionFunction = leafPartitionFunction;
@@ -195,12 +195,12 @@ class ColocationGroupAnalyzer {
|| !isSamePartitionFunction(partitionFunction,
leafPartitionFunction)) {
return null;
}
- partitionedLeafFragmentIds.add(fragmentId);
+ partitionedLeafFragments.add(fragment);
}
- if (partitionedLeafFragmentIds.isEmpty()) {
+ if (partitionedLeafFragments.isEmpty()) {
return null;
}
- return new ColocationGroup(partitionSize, partitionedLeafFragmentIds);
+ return new ColocationGroup(partitionSize, partitionedLeafFragments);
}
/// Compares two `partition_function` hints the way the rest of the engine
compares partition function names:
@@ -242,12 +242,13 @@ class ColocationGroupAnalyzer {
static class ColocationGroup {
/// The number of partition classes, and of workers before reduction, i.e.
the hinted `partition_size`.
final int _partitionSize;
- /// The members whose data decides which classes survive.
- final List<Integer> _partitionedLeafFragmentIds;
+ /// The members whose data decides which classes survive. The whole
fragment rather than its id because deciding
+ /// survival reads each member's own filter off its leaf stage tree, see
`WorkerManager#assignPartitionClasses`.
+ final List<PlanFragment> _partitionedLeafFragments;
- ColocationGroup(int partitionSize, List<Integer>
partitionedLeafFragmentIds) {
+ ColocationGroup(int partitionSize, List<PlanFragment>
partitionedLeafFragments) {
_partitionSize = partitionSize;
- _partitionedLeafFragmentIds = partitionedLeafFragmentIds;
+ _partitionedLeafFragments = partitionedLeafFragments;
}
}
}
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilder.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilder.java
index d7b32f3e4b3..e946e4b68b0 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilder.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilder.java
@@ -85,6 +85,27 @@ public class PlanNodeRoutingQueryBuilder {
return pinotQuery;
}
+ /// Whether [#createPinotQueryForRouting] can fold the given leaf stage
tree, i.e. whether it holds no multi-input
+ /// node. Lets a caller skip the attempt rather than pay a thrown-and-caught
exception per query, which a colocated
+ /// semi-join's probe leaf -- the one holding the join -- would otherwise do
on every query. It lives here so that it
+ /// walks the tree the same way [#accumulateBottomToTop] does; the two
disagreeing would either bring the exception
+ /// back or, worse, silently refuse shapes that fold perfectly well.
+ ///
+ /// A `true` result is not a promise that the fold succeeds: the tree may
still be missing a table scan, which only
+ /// the fold itself detects.
+ static boolean canBuildRoutingQuery(PlanNode leafStageRoot) {
+ List<PlanNode> inputs = leafStageRoot.getInputs();
+ if (inputs.size() > 1) {
+ return false;
+ }
+ for (PlanNode input : inputs) {
+ if (!canBuildRoutingQuery(input)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
private static void accumulateBottomToTop(PlanNode root, List<PlanNode>
parentNodes) {
Preconditions.checkState(root.getInputs().size() <= 1,
"Leaf stage nodes should have at most one input, found: %s",
root.getInputs().size());
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/WorkerManager.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/WorkerManager.java
index a6f9e3e3303..917cbab89fe 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/WorkerManager.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/WorkerManager.java
@@ -136,12 +136,24 @@ public class WorkerManager {
/// publishes the decision on each partitioned leaf of the group (see
/// [DispatchablePlanMetadata#getPartitionClassIds()] and
[DispatchablePlanMetadata#getPaddedClassCandidates()]).
///
- /// A class survives when *any* member holds a segment in it: the union, not
the intersection, because a class that
- /// holds data for one member must keep its worker on every member or the
members stop agreeing on what a worker id
- /// stands for. A member holding no data in a surviving class gets a worker
with no segments (see
- /// [#assignPaddedWorker]). Emptiness is computed in class space
(`0..partitionSize-1`) rather than over raw partition
- /// ids because members may declare different partition counts; a member
carrying no per-class visibility (replicated,
- /// non-partitioned, or deriving its worker map from a peer) contributes
nothing to the union.
+ /// A class survives when *any* member holds a segment in it that the
query's filter does not provably exclude: the
+ /// union, not the intersection, because a class that holds matching data
for one member must keep its worker on
+ /// every member or the members stop agreeing on what a worker id stands
for. Dropping a class the group as a whole
+ /// has no matching row in is what turns broker pruning into fewer workers,
and therefore fewer dispatched servers,
+ /// for a colocated join. The union direction is also what keeps this layer
free of relational semantics: no input
+ /// row anywhere in a class means no output row attributable to it, whatever
operator sits above, whereas dropping a
+ /// class only one side filtered away would be wrong for a RIGHT or FULL
join, a union, or an anti-join.
+ ///
+ /// A member holding no data in a surviving class gets a worker with no
segments (see [#assignPaddedWorker]). A
+ /// member that holds data the filter excludes does *not*: it keeps every
segment of the class. Survival is decided
+ /// per class rather than per partition on purpose -- a class's worker is
placed on the servers shared by all of its
+ /// populated partitions, so dropping some of them on one member and not on
another would move that member's worker
+ /// off its peer's server and turn an in-process exchange into a network
one. Filtering inside a surviving class is a
+ /// possible follow-up.
+ ///
+ /// Emptiness is computed in class space (`0..partitionSize-1`) rather than
over raw partition ids because members
+ /// may declare different partition counts; a member carrying no per-class
visibility (replicated, non-partitioned,
+ /// or deriving its worker map from a peer) contributes nothing to the union.
///
/// Marking no group keeps the assignment as it is without one: every class
gets a worker, so a class holding no
/// segment fails the assignment instead of being dropped or padded.
@@ -151,67 +163,108 @@ public class WorkerManager {
for (ColocationGroupAnalyzer.ColocationGroup group :
ColocationGroupAnalyzer.findReducibleGroups(rootFragment,
metadataMap)) {
int numWorkers = group._partitionSize;
- List<Integer> memberFragmentIds = group._partitionedLeafFragmentIds;
- // The servers each member can scan each class on, in the same order as
the member fragment ids.
- List<List<Set<String>>> memberClassServers = new
ArrayList<>(memberFragmentIds.size());
+ List<PlanFragment> memberFragments = group._partitionedLeafFragments;
+ List<Integer> memberFragmentIds = new
ArrayList<>(memberFragments.size());
+ // The servers each member can scan each class on, in the same order as
the member fragments.
+ List<List<Set<String>>> memberClassServers = new
ArrayList<>(memberFragments.size());
+ // The partition layouts, same order again, kept only to count what a
filter-dropped class cost each member.
+ List<PartitionInfo[]> memberPartitionInfoMaps = new
ArrayList<>(memberFragments.size());
// Allocated lazily, once the first member has checked the hint against
its table: numWorkers is the raw hinted
// partition size, so sizing anything from it before that check would
let a bogus hint allocate unboundedly. The
// check also bounds it by the table's partition count.
- boolean[] survivingClasses = null;
+ // populatedClasses ignores the filter and is what decides padding and
the all-pruned fallback; matchingClasses
+ // is the same union taken over the segments the filter leaves, and is a
subset of it.
+ boolean[] populatedClasses = null;
+ boolean[] matchingClasses = null;
boolean reducible = true;
- for (Integer fragmentId : memberFragmentIds) {
- DispatchablePlanMetadata metadata = metadataMap.get(fragmentId);
+ for (PlanFragment fragment : memberFragments) {
+ DispatchablePlanMetadata metadata =
metadataMap.get(fragment.getFragmentId());
String tableName = metadata.getScannedTables().get(0);
// NOTE: A failure here is the same one the leaf assignment would hit
for this table, only raised earlier.
PartitionTableInfo partitionTableInfo =
partitionTableInfoCache.computeIfAbsent(tableName,
this::calculatePartitionTableInfo);
- int numPartitions = partitionTableInfo._partitionInfoMap.length;
+ PartitionInfo[] partitionInfoMap =
partitionTableInfo._partitionInfoMap;
+ int numPartitions = partitionInfoMap.length;
if (numPartitions == 0 || numPartitions % numWorkers != 0) {
// The table does not match the hinted partition size. Leave the
group alone so that checkPartitionInfoMap
// reports it during the leaf assignment.
reducible = false;
break;
}
- if (survivingClasses == null) {
- survivingClasses = new boolean[numWorkers];
+ if (populatedClasses == null) {
+ populatedClasses = new boolean[numWorkers];
+ matchingClasses = new boolean[numWorkers];
}
- List<Set<String>> classServers =
collectClassServers(partitionTableInfo._partitionInfoMap, numWorkers);
+ List<Set<String>> classServers = collectClassServers(partitionInfoMap,
numWorkers);
+ Set<String> prunedSegments = getPrunedSegments(fragment, tableName,
context);
boolean anyPopulated = false;
for (int classId = 0; classId < numWorkers; classId++) {
if (classServers.get(classId) != null) {
- survivingClasses[classId] = true;
+ populatedClasses[classId] = true;
anyPopulated = true;
+ if (prunedSegments == null) {
+ // No filter to prune this member with, so it contributes every
class it holds data in.
+ matchingClasses[classId] = true;
+ }
}
}
// A member holding no data at all leaves nothing to assign: no class
to place its single empty worker in, and
// no server known to host the table to place it on. Check the
deferred cause first though -- a table whose
// every partition is deferred also has no populated class, and
reports far more actionably. That is the
- // pre-pass' only deferred check: a group it marks gets no broker
pruning, so the leaf assignment covers the
- // rest.
+ // pre-pass' only deferred check: a group it marks gets no broker
pruning at the leaf, so the leaf assignment
+ // covers the rest.
+ //
+ // NOTE: This reads the unfiltered population on purpose. A member
whose filter matches nothing holds data
+ // all the same, and failing it here would turn a correct empty
result into a query error.
if (!anyPopulated) {
checkNoPartitionsWithOnlyDeferredSegments(partitionTableInfo,
tableName);
}
Preconditions.checkState(anyPopulated,
"Failed to find any segment in any partition for table: %s, which
is required for a partitioned worker "
+ "assignment", tableName);
+ if (prunedSegments != null) {
+ markClassesWithMatchingData(partitionInfoMap, numWorkers,
prunedSegments, matchingClasses);
+ }
memberClassServers.add(classServers);
+ memberPartitionInfoMaps.add(partitionInfoMap);
+ memberFragmentIds.add(fragment.getFragmentId());
}
if (!reducible) {
continue;
}
- // The member list is never empty (see
ColocationGroupAnalyzer#toReducibleGroup), so the loop allocated this, and
- // the class list is never empty either: every member holds data in at
least one class, and the union keeps it.
- assert survivingClasses != null;
+ // The member list is never empty (see
ColocationGroupAnalyzer#toReducibleGroup), so the loop allocated these,
+ // and the class list is never empty either: every member holds data in
at least one class, and the union keeps
+ // it.
+ assert populatedClasses != null && matchingClasses != null;
+ // A group the filter empties keeps all of its populated classes,
mirroring the leaf-level fallback in
+ // computePartitionsToKeep: a zero-worker leaf has no handling on a
1-to-1 exchange, and the server-side filter
+ // still returns the correct empty result from an unreduced plan.
+ boolean[] survivingClasses = anyTrue(matchingClasses) ? matchingClasses
: populatedClasses;
int[] partitionClassIds = toClassIds(survivingClasses);
Map<Integer, Map<Integer, Set<String>>> padding =
computePadding(memberFragmentIds, memberClassServers,
partitionClassIds);
if (padding.isEmpty() && partitionClassIds.length == numWorkers) {
// Worker k already stands for class k on every member: nothing to
reduce, nothing to pad. Leaving the group
- // unmarked also keeps broker pruning on for its leaves (see
computePartitionsToKeep). A group that needs
+ // unmarked also keeps leaf-level broker pruning on for the members
that are eligible for it (see
+ // computePartitionsToKeep), which prunes at partition rather than
class granularity. A group that needs
// padding is marked even when it keeps every class, because a padded
worker's id is its index in the class
// list.
continue;
}
+ // Report what the filter cost, not what the class reduction did: a
class no member holds data in is empty
+ // rather than pruned, and it is already dropped above without being
counted. Derived from the decision actually
+ // taken, so the all-pruned fallback above reports nothing.
+ long numPrunedSegments = 0;
+ for (int classId = 0; classId < numWorkers; classId++) {
+ if (populatedClasses[classId] && !survivingClasses[classId]) {
+ for (PartitionInfo[] partitionInfoMap : memberPartitionInfoMaps) {
+ numPrunedSegments += countSegmentsInClass(partitionInfoMap,
numWorkers, classId);
+ }
+ }
+ }
+ if (numPrunedSegments > 0) {
+ context.addNumSegmentsPrunedByBroker(numPrunedSegments);
+ }
// One shared array instance, so that the agreement check in
MailboxAssignmentVisitor compares one list rather
// than copies of it. The padding goes on the same metadata: a padded
worker's id only means something within the
// list.
@@ -223,6 +276,55 @@ public class WorkerManager {
}
}
+ private static long countSegmentsInClass(PartitionInfo[] partitionInfoMap,
int numWorkers, int classId) {
+ long numSegments = 0;
+ for (int partitionId = classId; partitionId < partitionInfoMap.length;
partitionId += numWorkers) {
+ PartitionInfo partitionInfo = partitionInfoMap[partitionId];
+ if (partitionInfo != null) {
+ numSegments += CollectionUtils.size(partitionInfo._offlineSegments)
+ + CollectionUtils.size(partitionInfo._realtimeSegments);
+ }
+ }
+ return numSegments;
+ }
+
+ /// Sets, for every class holding at least one segment the given pruned set
does not cover, the corresponding entry
+ /// of `matchingClasses`. Only presence in the pruned set is a proof (see
[RoutingManager#getPrunedSegments]), so a
+ /// segment missing from it counts as matching. A hybrid partition that
lists segments for one table type and none
+ /// for the other is therefore decided by the type that has them.
+ ///
+ /// This reads "holds data" more strictly than [#collectClassServers], which
counts every partition that has an entry
+ /// at all. A partition whose entry lists no segment is empty here and its
class goes unmarked, while
+ /// `collectClassServers` still reports it as populated. The two can only
disagree on a shape the broker does not
+ /// publish -- a partition's entry is created together with its first
segment -- so the difference shows up in test
+ /// fixtures rather than on a live table, and it is emptiness the planner
sees for itself rather than a pruning
+ /// verdict, so nothing is counted as pruned for it.
+ private static void markClassesWithMatchingData(PartitionInfo[]
partitionInfoMap, int numWorkers,
+ Set<String> prunedSegments, boolean[] matchingClasses) {
+ int numPartitions = partitionInfoMap.length;
+ for (int classId = 0; classId < numWorkers; classId++) {
+ if (matchingClasses[classId]) {
+ continue;
+ }
+ for (int partitionId = classId; partitionId < numPartitions; partitionId
+= numWorkers) {
+ PartitionInfo partitionInfo = partitionInfoMap[partitionId];
+ if (partitionInfo != null && !allSegmentsPruned(partitionInfo,
prunedSegments)) {
+ matchingClasses[classId] = true;
+ break;
+ }
+ }
+ }
+ }
+
+ private static boolean anyTrue(boolean[] flags) {
+ for (boolean flag : flags) {
+ if (flag) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/// Returns the servers that can scan each partition class of the given
layout as a whole, in class-id order, or
/// `null` for a class that holds no segment at all. This is the
intersection of the fully replicated servers of the
/// class's populated partitions, i.e. the candidate set its worker is
picked from (see
@@ -687,13 +789,14 @@ public class WorkerManager {
metadata.setPartitionParallelism(partitionHints.getPartitionParallelism());
if (partitionHints.getPartitionKey() != null) {
- // Broker pruning: build a filter-bearing routing query (null when
disabled/unsupported) so the partitioned
- // assignment can drop partitions with no matching segments. Reuses
the same gate as the non-partitioned path.
- // Skip pre-partitioned leaves and leaves of a reduced colocated group
up front: pruning is disabled for them
- // (see computePartitionsToKeep), so don't spend planning time
building the routing query.
- PinotQuery routingPinotQuery = metadata.isPrePartitioned() ||
metadata.getPartitionClassIds() != null ? null
- : extractRoutingQuery(fragment.getFragmentRoot(),
metadata.getScannedTables().get(0), context);
- assignWorkersToPartitionedLeafFragment(metadata, context,
partitionHints, routingPinotQuery);
+ // Broker pruning: the segments the pruners provably eliminated (empty
when disabled/unsupported) so the
+ // partitioned assignment can drop partitions holding none of them.
Skip the lookup for a pre-partitioned leaf
+ // and for a leaf of a marked colocated group: leaf-level pruning is
disabled for both (see
+ // computePartitionsToKeep) because the group's shared class list
already carries their verdict, so asking
+ // would only cost planning time.
+ Set<String> prunedSegments = metadata.isPrePartitioned() ||
metadata.getPartitionClassIds() != null ? null
+ : getPrunedSegments(fragment, metadata.getScannedTables().get(0),
context);
+ assignWorkersToPartitionedLeafFragment(metadata, context,
partitionHints, prunedSegments);
updateContextForLeafStage(metadata, context);
return;
}
@@ -867,6 +970,86 @@ public class WorkerManager {
}
}
+ /// Returns the segments the broker's pruners provably eliminated for the
given leaf fragment, or `null` when there
+ /// was no filter to prune with at all: broker pruning off, an unsupported
leaf shape, a filterless leaf, or a
+ /// routing failure (pruning is best-effort and never fails a query that
would otherwise route).
+ ///
+ /// The empty set and `null` mean different things and callers depend on it.
An empty set is "a filter ran and
+ /// proved nothing", which still lets the partitioned assignment skip a
partition holding no segment at all --
+ /// behaviour that predates this and that a query with an empty partition
relies on to plan.
+ ///
+ /// Memoised per fragment for the query, because the colocation pre-pass and
the leaf assignment both ask for it and
+ /// each answer costs a routing call. Keyed by fragment rather than by
table: the two sides of a self-join scan one
+ /// table under two different filters.
+ @Nullable
+ private Set<String> getPrunedSegments(PlanFragment fragment, String
tableName, DispatchablePlanContext context) {
+ // Not computeIfAbsent: null is a meaningful answer here and would be
recomputed on every call.
+ Map<Integer, Set<String>> prunedSegmentsCache =
context.getPrunedSegmentsCache();
+ Integer fragmentId = fragment.getFragmentId();
+ if (prunedSegmentsCache.containsKey(fragmentId)) {
+ return prunedSegmentsCache.get(fragmentId);
+ }
+ Set<String> prunedSegments = computePrunedSegments(fragment, tableName,
context);
+ prunedSegmentsCache.put(fragmentId, prunedSegments);
+ return prunedSegments;
+ }
+
+ @Nullable
+ private Set<String> computePrunedSegments(PlanFragment fragment, String
tableName,
+ DispatchablePlanContext context) {
+ PinotQuery routingPinotQuery =
extractRoutingQuery(fragment.getFragmentRoot(), tableName, context);
+ if (routingPinotQuery == null || routingPinotQuery.getFilterExpression()
== null) {
+ return null;
+ }
+ try {
+ TableType tableType =
TableNameBuilder.getTableTypeFromTableName(routingPinotQuery.getDataSource()
+ .getTableName());
+ if (tableType != null) {
+ return getPrunedSegmentsHelper(routingPinotQuery);
+ }
+ // A raw table name may resolve to either or both physical tables.
Segment names are unique across them, and a
+ // segment is pruned by the table that holds it, so the two verdicts
simply add up. Only merge when both prove
+ // something: a table of one type alone is the common case, and copying
its verdict to union it with an empty
+ // set would allocate a second set over every segment name for nothing.
+ Set<String> offlinePrunedSegments =
getPrunedSegmentsHelper(routingPinotQuery, TableType.OFFLINE);
+ Set<String> realtimePrunedSegments =
getPrunedSegmentsHelper(routingPinotQuery, TableType.REALTIME);
+ if (offlinePrunedSegments.isEmpty()) {
+ return realtimePrunedSegments;
+ }
+ if (realtimePrunedSegments.isEmpty()) {
+ return offlinePrunedSegments;
+ }
+ Set<String> prunedSegments = new HashSet<>(offlinePrunedSegments);
+ prunedSegments.addAll(realtimePrunedSegments);
+ return prunedSegments;
+ } catch (RuntimeException e) {
+ // Pruning is best-effort: never fail a query that would otherwise route
successfully unpruned.
+ LOGGER.warn("Broker pruning skipped for table {} due to routing
failure", tableName, e);
+ return null;
+ }
+ }
+
+ /// A table the routing manager does not have is reported as `null` there;
here it is simply a table that proves
+ /// nothing, which is the same thing this path does with a table whose
pruners eliminated no segment.
+ private Set<String> getPrunedSegmentsHelper(PinotQuery pinotQuery) {
+ Set<String> prunedSegments =
+
_routingManager.getPrunedSegments(CalciteSqlCompiler.convertToBrokerRequest(pinotQuery));
+ return prunedSegments != null ? prunedSegments : Set.of();
+ }
+
+ private Set<String> getPrunedSegmentsHelper(PinotQuery pinotQuery, TableType
tableType) {
+ return getPrunedSegmentsHelper(withTableType(pinotQuery, tableType));
+ }
+
+ /// Returns a copy of the given routing query aimed at one physical table,
so that a query written against a raw
+ /// table name can be routed against each type in turn.
+ private static PinotQuery withTableType(PinotQuery pinotQuery, TableType
tableType) {
+ PinotQuery copy = pinotQuery.deepCopy();
+
copy.getDataSource().setTableName(TableNameBuilder.forType(tableType).tableNameWithType(
+
TableNameBuilder.extractRawTableName(pinotQuery.getDataSource().getTableName())));
+ return copy;
+ }
+
/// Builds a [PinotQuery] from the leaf stage tree for broker-side segment
pruning on the logical planner path.
/// Returns `null` if broker pruning is disabled or the leaf stage shape is
unsupported.
@Nullable
@@ -878,6 +1061,9 @@ public class WorkerManager {
if (!useBrokerPruning) {
return null;
}
+ if (!PlanNodeRoutingQueryBuilder.canBuildRoutingQuery(leafStageRoot)) {
+ return null;
+ }
try {
PinotQuery pinotQuery =
PlanNodeRoutingQueryBuilder.createPinotQueryForRouting(tableName,
leafStageRoot, false);
Map<String, String> queryOptions =
context.getPlannerContext().getOptions();
@@ -911,10 +1097,7 @@ public class WorkerManager {
@Nullable
private RoutingTable getRoutingTableHelper(PinotQuery pinotQuery, long
requestId, TableType tableType) {
- PinotQuery copy = pinotQuery.deepCopy();
-
copy.getDataSource().setTableName(TableNameBuilder.forType(tableType).tableNameWithType(
-
TableNameBuilder.extractRawTableName(pinotQuery.getDataSource().getTableName())));
- return getRoutingTableHelper(copy, requestId);
+ return getRoutingTableHelper(withTableType(pinotQuery, tableType),
requestId);
}
// --------------------------------------------------------------------------
@@ -1137,8 +1320,7 @@ public class WorkerManager {
/// Assigns one worker per partition class of a leaf that scans a
partitioned table.
private void assignWorkersToPartitionedLeafFragment(DispatchablePlanMetadata
metadata,
- DispatchablePlanContext context, LeafPartitionHints partitionHints,
- @Nullable PinotQuery routingPinotQuery) {
+ DispatchablePlanContext context, LeafPartitionHints partitionHints,
@Nullable Set<String> prunedSegments) {
// when partition key exist, we assign workers for leaf-stage in
partitioned fashion.
String partitionKey = partitionHints.getPartitionKey();
assert partitionKey != null;
@@ -1172,8 +1354,7 @@ public class WorkerManager {
collectHostingServers(partitionInfoMap)) : null;
// Broker pruning: the partitions to keep (null means keep all).
Partitions absent from the set are skipped below.
- Set<Integer> partitionsToKeep =
- computePartitionsToKeep(routingPinotQuery, metadata,
context.getRequestId(), partitionInfoMap);
+ Set<Integer> partitionsToKeep = computePartitionsToKeep(prunedSegments,
metadata, partitionInfoMap);
if (partitionsToKeep != null) {
long numSegmentsPrunedByBroker = countPrunedSegments(partitionInfoMap,
partitionsToKeep);
if (numSegmentsPrunedByBroker > 0) {
@@ -1202,35 +1383,42 @@ public class WorkerManager {
partitionClassIds, partitionsToKeep, paddingInfo,
_routingManager.getEnabledServerInstanceMap(),
workerIdToServerInstanceMap, workerIdToSegmentsMap);
}
- checkLeafWorkerAssignment(tableName, workerIdToServerInstanceMap,
workerIdToSegmentsMap);
+ checkLeafWorkerAssignment(tableName, partitionClassIds,
workerIdToServerInstanceMap, workerIdToSegmentsMap);
metadata.setWorkerIdToServerInstanceMap(workerIdToServerInstanceMap);
metadata.setWorkerIdToSegmentsMap(workerIdToSegmentsMap);
metadata.setTimeBoundaryInfo(partitionTableInfo._timeBoundaryInfo);
metadata.setPartitionFunction(partitionFunction);
}
- /// Broker pruning for the partitioned leaf path. Returns the set of
partition ids that still have at least one
- /// segment matching the query filter, or `null` to keep all partitions.
+ /// Broker pruning for the partitioned leaf path. Returns the set of
partition ids that are not provably empty for
+ /// this query, or `null` to keep all partitions.
+ ///
+ /// Note that an *empty* pruned set is not the same as an absent one and
does not return `null` here: a filter that
+ /// proved nothing still leaves this the job of skipping a partition holding
no segment at all, which is what lets a
+ /// table with an empty partition plan rather than fail on a worker it
cannot place.
///
/// Returns `null` (no pruning) when any of the following hold:
///
- /// - broker pruning is disabled or the leaf shape is unsupported (the
routing query is `null`), or there is
- /// no filter to prune with;
+ /// - there was no filter to prune with at all (see [#getPrunedSegments]) --
broker pruning is disabled, the leaf
+ /// shape is unsupported, the leaf carries no filter, or routing failed
(pruning is best-effort);
/// - the leaf feeds a pre-partitioned (1-to-1 direct) exchange, or it
belongs to a colocated group that agreed on a
- /// partition class list -- dropping/compacting workers would misalign
sender/receiver worker ids in
- /// `MailboxAssignmentVisitor`. A non-pre-partitioned leaf is shuffled via
`connectWorkers`, which re-hashes across
- /// any worker count, so pruning is safe there;
- /// - routing fails (pruning is best-effort);
+ /// partition class list. Both get their verdict from the group instead,
in `assignPartitionClasses`, because it is
+ /// the only place that sees every member before any of them is assigned:
a leaf deciding on its own would drop a
+ /// class its peer keeps, and the two would stop agreeing on what a worker
id stands for. A non-pre-partitioned,
+ /// unmarked leaf is shuffled via `connectWorkers`, which re-hashes across
any worker count, so it can decide alone
+ /// -- and at partition rather than class granularity;
/// - every partition would be pruned -- an empty worker map would break
exchanges in a multi-leaf plan (the
/// all-leaves-empty short-circuit does not fire for a partially-empty
plan), and the server-side filter still
/// yields the correct empty result unpruned.
///
- /// Partition survival is decided by routing the filter-bearing query
through the [RoutingManager] (the same
- /// mechanism the non-partitioned path uses), so the segment-level pruners
judge survival using each segment's own
- /// partition metadata. This is correct for every partition function and
configuration, unlike recomputing the
- /// partition id from the table-level function name (which lacks the
per-segment function config). A partition is
- /// dropped only when every one of its segments was pruned; a segment that
merely became unavailable keeps its
- /// partition alive so matching data is never silently dropped.
+ /// A partition is dropped only when every one of its segments is in the
*provably pruned* set (see
+ /// [RoutingManager#getPrunedSegments]), never because a segment failed to
appear somewhere. That direction is the
+ /// whole point: absence from a routing result has innocent causes -- a
segment classified as optional by instance
+ /// selection, one whose server left the enabled server map, one that
entered the partition metadata before it became
+ /// selectable -- and each would otherwise be read as "this partition is
empty" and silently drop matching rows. It
+ /// also makes the verdict independent of the request id, so two leaves
scanning one table under one filter cannot
+ /// disagree. Judging by pruner verdict rather than by recomputing the
partition id from the table-level function
+ /// name is also what keeps it correct for every partition function and
per-segment function config.
///
/// Note that pruning here is partition-level, not segment-level: a
surviving partition dispatches all of its
/// segments, including ones the pruners eliminated (the server-side pruners
drop those again cheaply). This keeps
@@ -1238,37 +1426,15 @@ public class WorkerManager {
/// workers -- at the cost of a lower pruning ceiling than the
non-partitioned path for partitions with mixed-match
/// segments. Segment-level pruning within surviving partitions is a
possible follow-up.
@Nullable
- private Set<Integer> computePartitionsToKeep(@Nullable PinotQuery
routingPinotQuery,
- DispatchablePlanMetadata metadata, long requestId, PartitionInfo[]
partitionInfoMap) {
- if (routingPinotQuery == null || routingPinotQuery.getFilterExpression()
== null || metadata.isPrePartitioned()
- || metadata.getPartitionClassIds() != null) {
- return null;
- }
- Map<String, RoutingTable> routingTableMap;
- try {
- routingTableMap = getRoutingTable(routingPinotQuery, requestId);
- } catch (RuntimeException e) {
- // Pruning is best-effort: never fail a query that would otherwise route
successfully unpruned.
- LOGGER.warn("Broker pruning skipped for partitioned table {} due to
routing failure",
- routingPinotQuery.getDataSource().getTableName(), e);
+ private static Set<Integer> computePartitionsToKeep(@Nullable Set<String>
prunedSegments,
+ DispatchablePlanMetadata metadata, PartitionInfo[] partitionInfoMap) {
+ if (prunedSegments == null || metadata.isPrePartitioned() ||
metadata.getPartitionClassIds() != null) {
return null;
}
- if (routingTableMap.isEmpty()) {
- return null;
- }
- Set<String> matchedSegments = new HashSet<>();
- for (RoutingTable routingTable : routingTableMap.values()) {
- for (SegmentsToQuery segmentsToQuery :
routingTable.getServerInstanceToSegmentsMap().values()) {
- matchedSegments.addAll(segmentsToQuery.getSegments());
- }
- // Keep a partition alive if any of its segments is merely unavailable
(rather than pruned) so we never drop data.
- matchedSegments.addAll(routingTable.getUnavailableSegments());
- }
Set<Integer> partitionsToKeep = new HashSet<>();
for (int i = 0; i < partitionInfoMap.length; i++) {
PartitionInfo partitionInfo = partitionInfoMap[i];
- if (partitionInfo != null &&
(containsAny(partitionInfo._offlineSegments, matchedSegments) || containsAny(
- partitionInfo._realtimeSegments, matchedSegments))) {
+ if (partitionInfo != null && !allSegmentsPruned(partitionInfo,
prunedSegments)) {
partitionsToKeep.add(i);
}
}
@@ -1276,18 +1442,28 @@ public class WorkerManager {
return partitionsToKeep.isEmpty() ? null : partitionsToKeep;
}
- private static boolean containsAny(@Nullable List<String> segments,
Set<String> matchedSegments) {
+ /// Returns whether every segment of the given partition is provably pruned.
Vacuously true for a partition listing
+ /// no segment at all, which has no rows to contribute either way -- note
that a partition holding data the broker
+ /// cannot route yet has no entry in the map rather than an empty one, so it
is not this case (see
+ /// [#checkNoPartitionsWithOnlyDeferredSegments]).
+ private static boolean allSegmentsPruned(PartitionInfo partitionInfo,
Set<String> prunedSegments) {
+ return allPruned(partitionInfo._offlineSegments, prunedSegments)
+ && allPruned(partitionInfo._realtimeSegments, prunedSegments);
+ }
+
+ private static boolean allPruned(@Nullable List<String> segments,
Set<String> prunedSegments) {
if (segments != null) {
for (String segment : segments) {
- if (matchedSegments.contains(segment)) {
- return true;
+ if (!prunedSegments.contains(segment)) {
+ return false;
}
}
}
- return false;
+ return true;
}
- /// Counts the segments in partitions dropped by broker pruning (those
absent from `partitionsToKeep`).
+ /// Counts the segments in partitions dropped by broker pruning (those
absent from `partitionsToKeep`). A partition
+ /// dropped for holding no segment rather than for being pruned contributes
nothing, so it is not miscounted.
private static long countPrunedSegments(PartitionInfo[] partitionInfoMap,
Set<Integer> partitionsToKeep) {
long numPrunedSegments = 0;
for (int i = 0; i < partitionInfoMap.length; i++) {
@@ -1508,12 +1684,20 @@ public class WorkerManager {
/// server map by worker id, where a gap leaves a null entry;
/// - every worker must have a segments map keyed by 1 or 2 [TableType]
names, with non-null lists, because the server
/// splits the request on the number of entries and resolves one table
data manager per key: an unexpected key
- /// becomes an opaque server-side failure.
+ /// becomes an opaque server-side failure;
+ /// - a leaf of a colocated group must produce exactly one worker per class
of the group's shared list, because a
+ /// worker id *is* an index into that list. Nothing downstream can catch a
leaf that skipped one:
+ /// `MailboxAssignmentVisitor#checkPartitionClassAgreement` compares the
shared array against itself, so a member
+ /// that quietly assigned fewer workers than the array claims still agrees
with its peers on the array while
+ /// disagreeing with them on what every worker id after the gap means.
@VisibleForTesting
- static void checkLeafWorkerAssignment(String tableName,
+ static void checkLeafWorkerAssignment(String tableName, @Nullable int[]
partitionClassIds,
Map<Integer, QueryServerInstance> workerIdToServerInstanceMap,
Map<Integer, Map<String, List<String>>> workerIdToSegmentsMap) {
int numWorkers = workerIdToServerInstanceMap.size();
+ Preconditions.checkState(partitionClassIds == null ||
partitionClassIds.length == numWorkers,
+ "Got %s workers for partition classes: %s of table: %s", numWorkers,
+ partitionClassIds != null ? Arrays.toString(partitionClassIds) : null,
tableName);
Preconditions.checkState(workerIdToSegmentsMap.size() == numWorkers,
"Got %s workers but %s worker segment entries for table: %s",
numWorkers, workerIdToSegmentsMap.size(),
tableName);
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/ColocationGroupAnalyzerTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/ColocationGroupAnalyzerTest.java
index 3a779db8731..5908900e33c 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/ColocationGroupAnalyzerTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/ColocationGroupAnalyzerTest.java
@@ -22,6 +22,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.apache.calcite.rel.RelDistribution;
import org.apache.pinot.calcite.rel.hint.PinotHintOptions;
@@ -54,7 +55,7 @@ public class ColocationGroupAnalyzerTest {
assertEquals(groups.size(), 1);
assertEquals(groups.get(0)._partitionSize, 4);
- assertEquals(Set.copyOf(groups.get(0)._partitionedLeafFragmentIds),
Set.of(2, 3));
+ assertEquals(Set.copyOf(fragmentIds(groups.get(0))), Set.of(2, 3));
}
/// A member that also receives a shuffled send must keep today's worker
count, or that sender's rows land on
@@ -78,7 +79,7 @@ public class ColocationGroupAnalyzerTest {
ColocationGroupAnalyzer.findReducibleGroups(twoLeafPlan(RelDistribution.Type.SINGLETON),
metadataMap);
assertEquals(groups.size(), 1);
- assertEquals(Set.copyOf(groups.get(0)._partitionedLeafFragmentIds),
Set.of(2, 3));
+ assertEquals(Set.copyOf(fragmentIds(groups.get(0))), Set.of(2, 3));
}
/// Reducing the worker count must not turn mismatched counts into a match
for a pre-partitioned BROADCAST send, which
@@ -227,7 +228,7 @@ public class ColocationGroupAnalyzerTest {
assertEquals(groups.size(), 1);
// Only the partitioned leaf decides which classes survive.
- assertEquals(groups.get(0)._partitionedLeafFragmentIds, List.of(2));
+ assertEquals(fragmentIds(groups.get(0)), List.of(2));
}
/// A lookup join's workers come from its single local exchange child, so
its own hints (a different partition size
@@ -249,7 +250,7 @@ public class ColocationGroupAnalyzerTest {
assertEquals(groups.size(), 1);
assertEquals(groups.get(0)._partitionSize, 4);
- assertEquals(groups.get(0)._partitionedLeafFragmentIds, List.of(2));
+ assertEquals(fragmentIds(groups.get(0)), List.of(2));
}
/// A group of intermediate stages only has nothing to reduce: only a
partitioned leaf's data decides the classes.
@@ -287,7 +288,11 @@ public class ColocationGroupAnalyzerTest {
// One group, and the spooled leaf is listed once rather than once per
receiver.
assertEquals(groups.size(), 1);
- assertEquals(groups.get(0)._partitionedLeafFragmentIds, List.of(3));
+ assertEquals(fragmentIds(groups.get(0)), List.of(3));
+ }
+
+ private static List<Integer>
fragmentIds(ColocationGroupAnalyzer.ColocationGroup group) {
+ return
group._partitionedLeafFragments.stream().map(PlanFragment::getFragmentId).collect(Collectors.toList());
}
/// Builds a 4 stage plan: 2 partitioned leaves (stages 2 and 3) sending to
a join stage (stage 1), which sends
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilderTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilderTest.java
index 125249b64ea..0b0e0fc1491 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilderTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilderTest.java
@@ -27,11 +27,15 @@ import
org.apache.pinot.query.planner.plannode.AggregateNode;
import org.apache.pinot.query.planner.plannode.FilterNode;
import org.apache.pinot.query.planner.plannode.PlanNode;
import org.apache.pinot.query.planner.plannode.ProjectNode;
+import org.apache.pinot.query.planner.plannode.SetOpNode;
import org.apache.pinot.query.planner.plannode.TableScanNode;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
public class PlanNodeRoutingQueryBuilderTest {
@@ -118,4 +122,56 @@ public class PlanNodeRoutingQueryBuilderTest {
assertEquals(filter.getFunctionCall().getOperator(), "EQUALS");
assertEquals(filter.getFunctionCall().getOperands().get(0).getIdentifier().getName(),
"col1");
}
+
+ @Test
+ public void testCanBuildRoutingQueryAcceptsASingleInputChain() {
+ TableScanNode tableScanNode =
+ new TableScanNode(1, TEST_SCHEMA, PlanNode.NodeHint.EMPTY, List.of(),
"testTable", List.of("col1", "col2"));
+ FilterNode filterNode = new FilterNode(1, TEST_SCHEMA,
PlanNode.NodeHint.EMPTY, List.of(tableScanNode),
+ new RexExpression.FunctionCall(DataSchema.ColumnDataType.BOOLEAN,
"EQUALS",
+ List.of(new RexExpression.InputRef(0),
+ new RexExpression.Literal(DataSchema.ColumnDataType.STRING,
"foo"))));
+
+ assertTrue(PlanNodeRoutingQueryBuilder.canBuildRoutingQuery(filterNode));
+ }
+
+ /// The pre-check has to recurse exactly as the fold does. Checking only the
root would call this tree foldable and
+ /// hand the caller back the thrown-and-caught exception the pre-check
exists to avoid.
+ @Test
+ public void testCanBuildRoutingQueryRejectsAMultiInputNodeBelowTheRoot() {
+ PlanNode multiInputNode = setOpOverTwoScans();
+ FilterNode filterAbove = new FilterNode(1, TEST_SCHEMA,
PlanNode.NodeHint.EMPTY, List.of(multiInputNode),
+ new RexExpression.FunctionCall(DataSchema.ColumnDataType.BOOLEAN,
"EQUALS",
+ List.of(new RexExpression.InputRef(0),
+ new RexExpression.Literal(DataSchema.ColumnDataType.STRING,
"foo"))));
+
+
assertFalse(PlanNodeRoutingQueryBuilder.canBuildRoutingQuery(multiInputNode));
+ assertFalse(PlanNodeRoutingQueryBuilder.canBuildRoutingQuery(filterAbove));
+ }
+
+ /// The two must agree: whatever the pre-check refuses is exactly what the
fold would have thrown on, and whatever it
+ /// accepts the fold attempts. Disagreement either restores the per-query
exception or silently disables pruning for
+ /// shapes that fold perfectly well.
+ @Test
+ public void testCanBuildRoutingQueryAgreesWithTheFold() {
+ PlanNode foldable =
+ new TableScanNode(1, TEST_SCHEMA, PlanNode.NodeHint.EMPTY, List.of(),
"testTable", List.of("col1", "col2"));
+ PlanNode notFoldable = setOpOverTwoScans();
+
+ assertTrue(PlanNodeRoutingQueryBuilder.canBuildRoutingQuery(foldable));
+
assertNotNull(PlanNodeRoutingQueryBuilder.createPinotQueryForRouting("testTable",
foldable, false));
+
+ assertFalse(PlanNodeRoutingQueryBuilder.canBuildRoutingQuery(notFoldable));
+ assertThrows(IllegalStateException.class,
+ () ->
PlanNodeRoutingQueryBuilder.createPinotQueryForRouting("testTable",
notFoldable, false));
+ }
+
+ private static PlanNode setOpOverTwoScans() {
+ TableScanNode first =
+ new TableScanNode(1, TEST_SCHEMA, PlanNode.NodeHint.EMPTY, List.of(),
"testTable", List.of("col1", "col2"));
+ TableScanNode second =
+ new TableScanNode(1, TEST_SCHEMA, PlanNode.NodeHint.EMPTY, List.of(),
"testTable", List.of("col1", "col2"));
+ return new SetOpNode(1, TEST_SCHEMA, PlanNode.NodeHint.EMPTY,
List.of(first, second), SetOpNode.SetOpType.UNION,
+ true);
+ }
}
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/WorkerManagerTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/WorkerManagerTest.java
index 84ce1a4e108..49802eba16f 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/WorkerManagerTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/WorkerManagerTest.java
@@ -720,10 +720,14 @@ public class WorkerManagerTest {
}
@Test
- public void testBrokerPruningPartitionedLeafSkippedForColocatedJoin() {
- // A pre-partitioned leaf (here both sides of a colocated self-join) feeds
a 1-to-1 direct exchange wired by worker
- // id. Compacting a side's workers for pruned partitions could pair
mismatched partitions across the exchange, so
- // pruning is skipped for any pre-partitioned leaf and all partitions stay
assigned on every scan.
+ public void testBrokerPruningColocatedJoinDropsClassesBothSidesPrune() {
+ // A pre-partitioned leaf feeds a 1-to-1 direct exchange wired by worker
id, so no leaf may decide on its own what
+ // to drop. The colocation pre-pass decides for the whole group instead: a
class survives when ANY member still
+ // holds a segment its own filter leaves, so dropping one is safe without
knowing which operator sits above.
+ //
+ // This is the shape the reduction is worth having for. The filter is on
the partition key, so it transfers across
+ // the join equality to both sides, both prune every class but 2, and the
group's class list shrinks to [2]: one
+ // worker per leaf, and the query is dispatched to the single server
holding partition 2 instead of all four.
QueryEnvironment queryEnvironment =
newPartitionedQueryEnvironment(new int[]{0, 1, 2, 3}, 4,
List.of("seg2"), 3);
try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
@@ -733,13 +737,76 @@ public class WorkerManagerTest {
+ "/*+ tableOptions(partition_function='hashcode',
partition_key='col1', partition_size='4') */ t2 "
+ "ON t1.col1 = t2.col1 WHERE t1.col1 = 'foo'")) {
DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
- assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 0);
List<DispatchablePlanFragment> leafFragments =
leafFragments(dispatchableSubPlan);
- assertFalse(leafFragments.isEmpty());
+ assertEquals(leafFragments.size(), 2);
+ for (DispatchablePlanFragment leaf : leafFragments) {
+ assertEquals(leaf.getWorkerIdToSegmentsMap().size(), 1);
+ // Worker 0 stands for class 2 on both members, and carries that
class's whole segment list.
+ assertEquals(assignedSegments(leaf, 0), List.of("seg2"));
+ }
+ // Three classes dropped, one segment each, counted once per member of
the group rather than from the routing
+ // table's own self-reported count (the fixture reports 3).
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 6);
+ // The point of the exercise: partition 2 lives only on server 3, so
that is the whole dispatched set.
+ assertEquals(dispatchedServers(dispatchableSubPlan),
Set.of(getServerInstance("localhost", 3).getInstanceId()));
+ }
+ }
+
+ @Test
+ public void testBrokerPruningSelfJoinKeepsTheTwoSidesVerdictsApart() {
+ // Two leaves scanning ONE table under two different filters. The pruning
verdict is memoised per leaf fragment,
+ // and this is what says so: keyed by table instead, one side would
inherit the other's verdict and the union would
+ // be computed from one filter applied twice.
+ //
+ // Left keeps only class 1, right only class 2, so the union is {1, 2} --
a result neither side's verdict produces
+ // on its own, and neither does either verdict applied to both sides ({1}
or {2}).
+ QueryEnvironment queryEnvironment =
+ newPartitionedQueryEnvironment(new int[]{0, 1, 2, 3}, 4, 1, List.of(),
List.of(), 0, false, Set.of(), Set.of(),
+ Map.of("left", List.of("seg1"), "right", List.of("seg2")));
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SET useBrokerPruning=true; SELECT t1.col2 FROM testTable "
+ + "/*+ tableOptions(partition_function='hashcode',
partition_key='col1', partition_size='4') */ t1 "
+ + "JOIN testTable "
+ + "/*+ tableOptions(partition_function='hashcode',
partition_key='col1', partition_size='4') */ t2 "
+ + "ON t1.col1 = t2.col1 WHERE t1.col2 = 'left' AND t2.col2 =
'right'")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ List<DispatchablePlanFragment> leafFragments =
leafFragments(dispatchableSubPlan);
+ assertEquals(leafFragments.size(), 2);
+ Map<Integer, Integer> workerIdToClass = new HashMap<>();
for (DispatchablePlanFragment leaf : leafFragments) {
- assertEquals(leaf.getWorkerIdToSegmentsMap().size(), 4, "Expected all
partitions assigned for a pruned-gated "
- + "colocated join leaf");
+ // Both classes survive on both sides, and a class the group keeps
dispatches all of its segments even on the
+ // member whose own filter excluded them.
+ assertEquals(workerIdToPartitions(leaf, "seg"), Map.of(0, Set.of(1),
1, Set.of(2)));
+ mergeWorkerIdToClass(workerIdToClass, leaf, "seg", 4);
}
+ assertEquals(workerIdToClass, Map.of(0, 1, 1, 2));
+ // Classes 0 and 3 dropped, one segment each, on each of the two leaves.
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 4);
+ }
+ }
+
+ @Test
+ public void testBrokerPruningColocatedJoinKeepsClassOnlyOneSidePrunes() {
+ // The union rule, and the reason it is not an intersection: only t1 is
filtered, t2 still holds every class, so
+ // every class keeps its worker and the fan-out is unchanged. Dropping the
classes t1's filter empties would be
+ // wrong the moment the operator above is a RIGHT or FULL join, a union,
or an anti-join -- and the worker
+ // assignment deliberately knows nothing about which one it is. A
one-sided filter buying nothing is the price.
+ QueryEnvironment queryEnvironment = newColocatedJoinQueryEnvironment(
+ new ColocatedTableSpec(4, false).survivingSegments(List.of("a_seg1")),
+ new ColocatedTableSpec(4, false).survivingSegments(List.of("b_seg0",
"b_seg1", "b_seg2", "b_seg3")));
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SET useBrokerPruning=true; " + colocatedJoinQuery(4) + " WHERE " +
COLOCATED_TABLE_A + ".col2 = 'foo'")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ DispatchablePlanFragment leafA =
leafFragmentForTable(dispatchableSubPlan, COLOCATED_TABLE_A);
+ DispatchablePlanFragment leafB =
leafFragmentForTable(dispatchableSubPlan, COLOCATED_TABLE_B);
+ assertEquals(leafA.getWorkerIdToSegmentsMap().size(), 4);
+ assertEquals(leafB.getWorkerIdToSegmentsMap().size(), 4);
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 0);
+ // A kept class dispatches all of its segments on every member,
including the ones the member's own filter
+ // excluded, so worker 0 of the filtered side still scans a_seg0.
+ assertEquals(workerIdToPartitions(leafA, "a_seg"),
+ Map.of(0, Set.of(0), 1, Set.of(1), 2, Set.of(2), 3, Set.of(3)));
+ assertEquals(workerIdToServer(leafA), workerIdToServer(leafB));
}
}
@@ -914,6 +981,29 @@ public class WorkerManagerTest {
}
}
+ @Test
+ public void
testPlainPartitionedLeafWithEmptyPartitionPlansWhenNothingIsPruned() {
+ // Partition 3 holds no segment at all and the filter prunes nothing, so
the pruning verdict is an EMPTY set rather
+ // than an absent one. It still has to be acted on: without a verdict
every partition gets a worker, and the one
+ // with no segment has no server to place it on, which fails a query that
plans perfectly well with 3 workers.
+ // Hence the verdict distinguishes "a filter ran and proved nothing" from
"there was no filter".
+ QueryEnvironment queryEnvironment =
+ newPartitionedQueryEnvironment(new int[]{0, 1, 2, 3}, 4, 1,
List.of("seg0", "seg1", "seg2"), List.of(), 0,
+ false, Set.of(3), Set.of());
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SET useBrokerPruning=true; SELECT col2 FROM testTable "
+ + "/*+ tableOptions(partition_function='hashcode',
partition_key='col1', partition_size='4') */ "
+ + "WHERE col1 = 'foo'")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ DispatchablePlanFragment leaf = leafFragment(dispatchableSubPlan);
+ assertNotNull(leaf);
+ assertEquals(leaf.getWorkerIdToSegmentsMap().size(), 3);
+ assertEquals(assignedSegments(leaf), List.of("seg0", "seg1", "seg2"));
+ // Nothing was pruned, only skipped for holding nothing, so nothing is
reported as pruned either.
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 0);
+ }
+ }
+
@Test
public void testPartitionedLeafRejectsTableWithoutAnyPartition() {
// An empty partition info map passes the "partitions must be a multiple
of the hinted partition size" check
@@ -1320,36 +1410,176 @@ public class WorkerManagerTest {
}
@Test
- public void testColocatedJoinReducedGroupIgnoresBrokerPruning() {
- // A reduced group's worker id is a position in the group's surviving
class list, not a running counter over what a
- // filter leaves behind, so broker pruning has to be off for its leaves.
This shape is the only one that reaches
- // that gate: a join written with is_colocated_by_join_keys marks its
leaves pre-partitioned and is gated one step
- // earlier (see testBrokerPruningPartitionedLeafSkippedForColocatedJoin),
while a fact table joined with a
- // replicated dimension table over an explicit local exchange is not
marked pre-partitioned.
+ public void
testBrokerPruningColocatedJoinDropsClassWhosePartitionsListNoSegment() {
+ // A partition whose entry lists no segment holds no rows, so its class is
dropped even though the pruners proved
+ // nothing about any segment -- emptiness the planner can see for itself,
not a pruning verdict. It is reported as
+ // such: numSegmentsPrunedByBroker stays 0 because no segment was pruned.
+ //
+ // The broker does not publish this shape today (a partition's entry is
created together with its first segment),
+ // so this pins the behaviour rather than describing something reachable.
It is what the golden physical plans
+ // record for a leaf whose fixture builds partitions this way.
+ QueryEnvironment queryEnvironment = newColocatedJoinQueryEnvironment(
+ new ColocatedTableSpec(4, false).partitionsWithoutSegments(Set.of(2,
3))
+ .survivingSegments(List.of("a_seg0", "a_seg1")),
+ new ColocatedTableSpec(4, false).partitionsWithoutSegments(Set.of(2,
3))
+ .survivingSegments(List.of("b_seg0", "b_seg1")));
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SET useBrokerPruning=true; " + colocatedJoinQuery(4) + " WHERE " +
COLOCATED_TABLE_A + ".col2 = 'foo' AND "
+ + COLOCATED_TABLE_B + ".col2 = 'bar'")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ DispatchablePlanFragment leafA =
leafFragmentForTable(dispatchableSubPlan, COLOCATED_TABLE_A);
+ assertEquals(workerIdToPartitions(leafA, "a_seg"), Map.of(0, Set.of(0),
1, Set.of(1)));
+ assertEquals(leafA.getWorkerIdToSegmentsMap().keySet(), Set.of(0, 1));
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 0);
+ }
+ }
+
+ @Test
+ public void
testBrokerPruningColocatedJoinAllPrunedKeepsEveryPopulatedClass() {
+ // Both sides prune everything, so the filtered union is empty and the
group falls back to its populated classes.
+ // A group reduced to zero workers would leave a 1-to-1 exchange with no
worker to wire on either side, and the
+ // server-side filter returns the same empty result from the unreduced
plan anyway. So the most selective query
+ // gets the least reduction, which is the same trade the leaf-level
fallback makes.
+ QueryEnvironment queryEnvironment = newColocatedJoinQueryEnvironment(
+ new ColocatedTableSpec(4, false).survivingSegments(List.of()),
+ new ColocatedTableSpec(4, false).survivingSegments(List.of()));
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SET useBrokerPruning=true; " + colocatedJoinQuery(4) + " WHERE " +
COLOCATED_TABLE_A + ".col2 = 'foo'")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ assertEquals(leafFragmentForTable(dispatchableSubPlan,
COLOCATED_TABLE_A).getWorkerIdToSegmentsMap().size(), 4);
+ assertEquals(leafFragmentForTable(dispatchableSubPlan,
COLOCATED_TABLE_B).getWorkerIdToSegmentsMap().size(), 4);
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 0);
+ }
+ }
+
+ @Test
+ public void testBrokerPruningColocatedJoinDisabledByQueryOption() {
+ // The existing useBrokerPruning switch is the kill switch for this too:
with it off no routing query is built, so
+ // nothing is provably pruned and every populated class keeps its worker.
+ QueryEnvironment queryEnvironment = newColocatedJoinQueryEnvironment(
+ new ColocatedTableSpec(4, false).survivingSegments(List.of("a_seg1")),
+ new ColocatedTableSpec(4, false).survivingSegments(List.of("b_seg1")));
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SET useBrokerPruning=false; " + colocatedJoinQuery(4) + " WHERE " +
COLOCATED_TABLE_A + ".col2 = 'foo'")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ assertEquals(leafFragmentForTable(dispatchableSubPlan,
COLOCATED_TABLE_A).getWorkerIdToSegmentsMap().size(), 4);
+ assertEquals(leafFragmentForTable(dispatchableSubPlan,
COLOCATED_TABLE_B).getWorkerIdToSegmentsMap().size(), 4);
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 0);
+ }
+ }
+
+ @Test
+ public void testBrokerPruningColocatedJoinFallsBackOnRoutingFailure() {
+ // Pruning is best-effort on this path too: a routing call that throws
must leave the group with every populated
+ // class rather than fail a query that would otherwise plan.
+ QueryEnvironment queryEnvironment = newColocatedJoinQueryEnvironment(
+ new ColocatedTableSpec(4, false).survivingSegments(List.of("a_seg1")),
+ new ColocatedTableSpec(4, false).survivingSegments(List.of("b_seg1")),
TableType.OFFLINE, true);
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SET useBrokerPruning=true; " + colocatedJoinQuery(4) + " WHERE " +
COLOCATED_TABLE_A + ".col2 = 'foo'")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ assertEquals(leafFragmentForTable(dispatchableSubPlan,
COLOCATED_TABLE_A).getWorkerIdToSegmentsMap().size(), 4);
+ assertEquals(leafFragmentForTable(dispatchableSubPlan,
COLOCATED_TABLE_B).getWorkerIdToSegmentsMap().size(), 4);
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 0);
+ }
+ }
+
+ @Test
+ public void testBrokerPruningColocatedJoinKeepsClassWithUnavailableSegment()
{
+ // An unavailable segment was selected and not pruned, so the broker
cannot prove its class empty. Class 2 keeps
+ // its worker on the strength of a_seg2 being unavailable rather than
eliminated; only class 3, which both sides
+ // prune, is dropped. Reading "absent from the routing table" as "pruned"
instead would silently drop the rows a
+ // transient outage hid.
+ QueryEnvironment queryEnvironment = newColocatedJoinQueryEnvironment(
+ new ColocatedTableSpec(4, false).survivingSegments(List.of("a_seg0",
"a_seg1"))
+ .unavailableSegments(List.of("a_seg2")),
+ new ColocatedTableSpec(4, false).survivingSegments(List.of("b_seg0",
"b_seg1")));
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SET useBrokerPruning=true; " + colocatedJoinQuery(4) + " WHERE " +
COLOCATED_TABLE_A + ".col2 = 'foo' AND "
+ + COLOCATED_TABLE_B + ".col2 = 'bar'")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ DispatchablePlanFragment leafA =
leafFragmentForTable(dispatchableSubPlan, COLOCATED_TABLE_A);
+ DispatchablePlanFragment leafB =
leafFragmentForTable(dispatchableSubPlan, COLOCATED_TABLE_B);
+ assertEquals(workerIdToPartitions(leafA, "a_seg"), Map.of(0, Set.of(0),
1, Set.of(1), 2, Set.of(2)));
+ assertEquals(workerIdToPartitions(leafB, "b_seg"), Map.of(0, Set.of(0),
1, Set.of(1), 2, Set.of(2)));
+ // Only class 3 dropped: one segment on each side.
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 2);
+ }
+ }
+
+ @Test
+ public void
testBrokerPruningColocatedJoinStillPadsAMemberWithNoDataInASurvivingClass() {
+ // Emptiness padding and filter reduction have to compose. Table A holds
nothing in class 3 while B does, and B's
+ // filter keeps class 3, so the class survives and A gets an empty worker
for it -- placed on the server B picks
+ // for that class, so the exchange stays in process. Classes 1 and 2 are
dropped because BOTH sides prune them.
+ //
+ // Padding is decided from unfiltered presence on purpose: a member that
holds data the filter excludes dispatches
+ // it rather than being padded, so a surviving class always has the same
segment list it would have unpruned.
+ QueryEnvironment queryEnvironment = newColocatedJoinQueryEnvironment(
+ new ColocatedTableSpec(4,
false).emptyPartitions(Set.of(3)).survivingSegments(List.of("a_seg0")),
+ new ColocatedTableSpec(4, false).survivingSegments(List.of("b_seg0",
"b_seg3")));
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SET useBrokerPruning=true; " + colocatedJoinQuery(4) + " WHERE " +
COLOCATED_TABLE_A + ".col2 = 'foo' AND "
+ + COLOCATED_TABLE_B + ".col2 = 'bar'")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ DispatchablePlanFragment leafA =
leafFragmentForTable(dispatchableSubPlan, COLOCATED_TABLE_A);
+ DispatchablePlanFragment leafB =
leafFragmentForTable(dispatchableSubPlan, COLOCATED_TABLE_B);
+ // Class list [0, 3]: worker 1 of A is the padded one, so it has an
entry with no segment in it.
+ assertEquals(leafA.getWorkerIdToSegmentsMap().keySet(), Set.of(0, 1));
+ assertEquals(workerIdToPartitions(leafA, "a_seg"), Map.of(0, Set.of(0)));
+ assertEquals(workerIdToPartitions(leafB, "b_seg"), Map.of(0, Set.of(0),
1, Set.of(3)));
+ Map<Integer, Integer> workerIdToClass = new HashMap<>();
+ mergeWorkerIdToClass(workerIdToClass, leafA, "a_seg", 4);
+ mergeWorkerIdToClass(workerIdToClass, leafB, "b_seg", 4);
+ assertEquals(workerIdToClass, Map.of(0, 0, 1, 3));
+ // Worker 0 stands on the same server on both sides, which is what the
1-to-1 exchange is for.
+ assertEquals(workerIdToServer(leafA).get(0),
workerIdToServer(leafB).get(0));
+ // Worker 1 is A's padded one. It would rather land on B's server for
class 3 to keep the exchange in process,
+ // but here A holds no data on that server at all -- partition 3 lives
only on server 4 and A is empty there --
+ // and a server with no data manager for the table fails the query
outright, so it falls back to a server that
+ // provably hosts A and accepts one cross-server send.
+ assertEquals(workerIdToServer(leafB).get(1),
getServerInstance("localhost", 4).getInstanceId());
+ assertTrue(Set.of(getServerInstance("localhost", 1).getInstanceId(),
+ getServerInstance("localhost", 2).getInstanceId(),
getServerInstance("localhost", 3).getInstanceId())
+ .contains(workerIdToServer(leafA).get(1)),
workerIdToServer(leafA).toString());
+ // Classes 1 and 2 dropped, one segment each on each side.
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 4);
+ }
+ }
+
+ @Test
+ public void testColocatedJoinReducedGroupPrunesWholeClasses() {
+ // Emptiness reduction and filter reduction compose, on the one shape
whose partitioned leaf is not marked
+ // pre-partitioned: a fact table joined with a replicated dimension table
over an explicit local exchange.
+ //
+ // 8 partitions over 4 classes, so class c holds partitions c and c+4. The
fact table's class 3 is empty, and the
+ // filter leaves only a_seg0 and a_seg4, which are both class 0. Emptiness
drops class 3, the filter drops classes
+ // 1 and 2, and the class list ends up [0] -- a single worker holding the
whole of class 0.
//
- // The fact table's class 3 is empty, so the group is reduced to [0, 1,
2], and the filter leaves only class 0. Were
- // pruning left on, assignMultiplePartitionsPerWorker would find no
segment for classes 1 and 2 and skip them
- // WITHOUT consuming a worker id, leaving the leaf one worker while its
class list still claimed three.
+ // This is the shape that used to be gated: reducing to [0] by dropping
classes from the SHARED list keeps the
+ // worker id equal to its index in that list. Compacting at the leaf
instead -- which is what
+ // assignMultiplePartitionsPerWorker would do if a per-leaf verdict ever
reached it -- would leave the leaf one
+ // worker while its class list still claimed three.
QueryEnvironment queryEnvironment = newColocatedJoinQueryEnvironment(
new ColocatedTableSpec(8, false).emptyPartitions(Set.of(3,
7)).survivingSegments(List.of("a_seg0", "a_seg4")),
new ColocatedTableSpec(8, false));
try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile("SET useBrokerPruning=true; "
+ replicatedDimensionJoinQuery(4) + " WHERE " + COLOCATED_TABLE_A +
".col2 = 'foo'")) {
DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
- // Nothing was pruned, even though the filtered routing query would have
dropped 2 of the 3 surviving classes.
- assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 0);
+ // Classes 1 and 2, two segments each. Class 3 is empty, not pruned, so
it is not counted.
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 4);
DispatchablePlanFragment leafA =
leafFragmentForTable(dispatchableSubPlan, COLOCATED_TABLE_A);
DispatchablePlanFragment leafB =
leafFragmentForTable(dispatchableSubPlan, COLOCATED_TABLE_B);
- // One worker per surviving class, holding both partitions of that
class, and no worker dropped or padded.
- assertEquals(workerIdToPartitions(leafA, "a_seg"), Map.of(0, Set.of(0,
4), 1, Set.of(1, 5), 2, Set.of(2, 6)));
- assertEquals(leafA.getWorkerIdToSegmentsMap().keySet(), Set.of(0, 1, 2));
+ // One worker for the one surviving class, holding both of its
partitions, and no worker dropped or padded.
+ assertEquals(workerIdToPartitions(leafA, "a_seg"), Map.of(0, Set.of(0,
4)));
+ assertEquals(leafA.getWorkerIdToSegmentsMap().keySet(), Set.of(0));
Map<Integer, Integer> workerIdToClass = new HashMap<>();
mergeWorkerIdToClass(workerIdToClass, leafA, "a_seg", 4);
- assertEquals(workerIdToClass, Map.of(0, 0, 1, 1, 2, 2));
+ assertEquals(workerIdToClass, Map.of(0, 0));
// The replicated leaf and the join derive their workers from the fact
leaf, so they follow it class for class.
assertEquals(leafB.getWorkerIdToSegmentsMap().keySet(),
leafA.getWorkerIdToSegmentsMap().keySet());
assertEquals(workerIdToServer(leafB), workerIdToServer(leafA));
-
assertEquals(joinFragment(dispatchableSubPlan).getWorkerMetadataList().size(),
3);
+
assertEquals(joinFragment(dispatchableSubPlan).getWorkerMetadataList().size(),
1);
}
}
@@ -1516,7 +1746,7 @@ public class WorkerManagerTest {
Map<Integer, Map<String, List<String>>> segmentsMap =
Map.of(0, offlineSegments("seg0"), 2, offlineSegments("seg2"));
IllegalStateException e = expectThrows(IllegalStateException.class,
- () -> WorkerManager.checkLeafWorkerAssignment("testTable", serverMap,
segmentsMap));
+ () -> WorkerManager.checkLeafWorkerAssignment("testTable", null,
serverMap, segmentsMap));
assertTrue(e.getMessage().contains("Missing server instance for worker:
1"), e.getMessage());
}
@@ -1526,7 +1756,7 @@ public class WorkerManagerTest {
Map<Integer, Map<String, List<String>>> segmentsMap =
Map.of(0, offlineSegments("seg0"), 5, offlineSegments("seg5"));
IllegalStateException e = expectThrows(IllegalStateException.class,
- () -> WorkerManager.checkLeafWorkerAssignment("testTable", serverMap,
segmentsMap));
+ () -> WorkerManager.checkLeafWorkerAssignment("testTable", null,
serverMap, segmentsMap));
assertTrue(e.getMessage().contains("Missing segments for worker: 1"),
e.getMessage());
}
@@ -1535,7 +1765,7 @@ public class WorkerManagerTest {
Map<String, List<String>> nullList = new HashMap<>();
nullList.put(TableType.OFFLINE.name(), null);
IllegalStateException e = expectThrows(IllegalStateException.class,
- () -> WorkerManager.checkLeafWorkerAssignment("testTable", Map.of(0,
queryServerInstance(1)),
+ () -> WorkerManager.checkLeafWorkerAssignment("testTable", null,
Map.of(0, queryServerInstance(1)),
Map.of(0, nullList)));
assertTrue(e.getMessage().contains("Null segment list for table type:
OFFLINE"), e.getMessage());
}
@@ -1545,7 +1775,7 @@ public class WorkerManagerTest {
// The server splits the request on the number of entries in this map, so
a worker with no table type at all would
// produce no server request.
IllegalStateException e = expectThrows(IllegalStateException.class,
- () -> WorkerManager.checkLeafWorkerAssignment("testTable", Map.of(0,
queryServerInstance(1)),
+ () -> WorkerManager.checkLeafWorkerAssignment("testTable", null,
Map.of(0, queryServerInstance(1)),
Map.of(0, Map.of())));
assertTrue(e.getMessage().contains("Expected 1 or 2 table types for
worker: 0, got: 0"), e.getMessage());
}
@@ -1557,7 +1787,7 @@ public class WorkerManagerTest {
threeTypes.put(TableType.REALTIME.name(), List.of());
threeTypes.put("HYBRID", List.of());
IllegalStateException e = expectThrows(IllegalStateException.class,
- () -> WorkerManager.checkLeafWorkerAssignment("testTable", Map.of(0,
queryServerInstance(1)),
+ () -> WorkerManager.checkLeafWorkerAssignment("testTable", null,
Map.of(0, queryServerInstance(1)),
Map.of(0, threeTypes)));
assertTrue(e.getMessage().contains("Expected 1 or 2 table types for
worker: 0, got: 3"), e.getMessage());
}
@@ -1566,11 +1796,30 @@ public class WorkerManagerTest {
public void testCheckLeafWorkerAssignmentRejectsUnknownTableType() {
// The server resolves one table data manager per key in this map, and
reports a missing table for an unknown one.
IllegalStateException e = expectThrows(IllegalStateException.class,
- () -> WorkerManager.checkLeafWorkerAssignment("testTable", Map.of(0,
queryServerInstance(1)),
+ () -> WorkerManager.checkLeafWorkerAssignment("testTable", null,
Map.of(0, queryServerInstance(1)),
Map.of(0, Map.of("HYBRID", List.of()))));
assertTrue(e.getMessage().contains("Unexpected table type: HYBRID for
worker: 0"), e.getMessage());
}
+ @Test
+ public void
testCheckLeafWorkerAssignmentRejectsFewerWorkersThanPartitionClasses() {
+ // A worker id of a colocated leaf IS an index into the group's shared
class list, so a member that assigned fewer
+ // workers than the list has renumbered every class after the gap.
checkPartitionClassAgreement cannot see it: it
+ // compares the shared array against itself, and both sides still hold the
same instance.
+ IllegalStateException e = expectThrows(IllegalStateException.class,
+ () -> WorkerManager.checkLeafWorkerAssignment("testTable", new
int[]{0, 2, 5},
+ Map.of(0, queryServerInstance(1), 1, queryServerInstance(2)),
+ Map.of(0, offlineSegments("seg0"), 1, offlineSegments("seg2"))));
+ assertTrue(e.getMessage().contains("Got 2 workers for partition classes:
[0, 2, 5]"), e.getMessage());
+ }
+
+ @Test
+ public void testCheckLeafWorkerAssignmentAcceptsOneWorkerPerPartitionClass()
{
+ WorkerManager.checkLeafWorkerAssignment("testTable", new int[]{0, 2},
+ Map.of(0, queryServerInstance(1), 1, queryServerInstance(2)),
+ Map.of(0, offlineSegments("seg0"), 1, offlineSegments("seg2")));
+ }
+
@Test
public void
testCheckLeafWorkerAssignmentAcceptsHybridAndEmptySegmentWorkers() {
// The two shapes the partitioned assignment produces: a hybrid worker
with both table types, and one with a single
@@ -1578,7 +1827,7 @@ public class WorkerManagerTest {
Map<String, List<String>> hybridSegments = new HashMap<>();
hybridSegments.put(TableType.OFFLINE.name(), List.of("segO0"));
hybridSegments.put(TableType.REALTIME.name(), List.of("segR0"));
- WorkerManager.checkLeafWorkerAssignment("testTable",
+ WorkerManager.checkLeafWorkerAssignment("testTable", null,
Map.of(0, queryServerInstance(1), 1, queryServerInstance(2)),
Map.of(0, hybridSegments, 1, Map.of(TableType.OFFLINE.name(), new
ArrayList<>())));
}
@@ -1694,6 +1943,13 @@ public class WorkerManagerTest {
/// tables are registered under, so that the realtime-only shape can be
covered too.
private static QueryEnvironment
newColocatedJoinQueryEnvironment(ColocatedTableSpec specA, ColocatedTableSpec
specB,
TableType tableType) {
+ return newColocatedJoinQueryEnvironment(specA, specB, tableType, false);
+ }
+
+ /// Same as [#newColocatedJoinQueryEnvironment(ColocatedTableSpec,
ColocatedTableSpec, TableType)], with a routing
+ /// manager that throws on every routing call, to exercise the best-effort
fallback.
+ private static QueryEnvironment
newColocatedJoinQueryEnvironment(ColocatedTableSpec specA, ColocatedTableSpec
specB,
+ TableType tableType, boolean throwOnRouting) {
int numServers = 4;
ServerInstance[] servers = new ServerInstance[numServers];
Map<String, ServerInstance> enabledServers = new HashMap<>();
@@ -1710,13 +1966,15 @@ public class WorkerManagerTest {
colocatedTablePartitionInfo(tableBWithType, "b_seg", servers, specB));
Map<String, RoutingTable> routingTableByTable = new HashMap<>();
if (specA._survivingSegments != null) {
- routingTableByTable.put(tableAWithType, colocatedRoutingTable(servers,
"a_seg", specA._survivingSegments));
+ routingTableByTable.put(tableAWithType,
+ colocatedRoutingTable(servers, "a_seg", specA._survivingSegments,
specA._unavailableSegments));
}
if (specB._survivingSegments != null) {
- routingTableByTable.put(tableBWithType, colocatedRoutingTable(servers,
"b_seg", specB._survivingSegments));
+ routingTableByTable.put(tableBWithType,
+ colocatedRoutingTable(servers, "b_seg", specB._survivingSegments,
specB._unavailableSegments));
}
PartitionedRoutingManager routingManager =
- new PartitionedRoutingManager(enabledServers, partitionInfoByTable,
routingTableByTable, false);
+ new PartitionedRoutingManager(enabledServers, partitionInfoByTable,
routingTableByTable, throwOnRouting);
Map<String, String> tableNameMap = new HashMap<>();
tableNameMap.put(tableAWithType, tableAWithType);
@@ -1764,8 +2022,9 @@ public class WorkerManagerTest {
: Set.of(servers[p % servers.length].getInstanceId());
}
// Mutable, like the lists the broker publishes: the assignment must
hand out a copy rather than this instance.
- partitionInfoMap[p] = new
TablePartitionReplicatedServersInfo.PartitionInfo(partitionServers,
- new ArrayList<>(List.of(segmentPrefix + p)));
+ List<String> segments = spec._partitionsWithoutSegments.contains(p) ?
new ArrayList<>()
+ : new ArrayList<>(List.of(segmentPrefix + p));
+ partitionInfoMap[p] = new
TablePartitionReplicatedServersInfo.PartitionInfo(partitionServers, segments);
}
}
return new TablePartitionReplicatedServersInfo(tableNameWithType, "col1",
"Hashcode", numPartitions,
@@ -1775,7 +2034,7 @@ public class WorkerManagerTest {
/// Buckets the given surviving segments onto the server hosting their
partition (partition `p` lives on server
/// `p % 4`), i.e. builds what the routing manager returns for one colocated
table's filtered routing query.
private static RoutingTable colocatedRoutingTable(ServerInstance[] servers,
String segmentPrefix,
- List<String> survivingSegments) {
+ List<String> survivingSegments, List<String> unavailableSegments) {
Map<ServerInstance, List<String>> serverToSegmentList = new HashMap<>();
for (String segment : survivingSegments) {
int partition =
Integer.parseInt(segment.substring(segmentPrefix.length()));
@@ -1784,7 +2043,7 @@ public class WorkerManagerTest {
Map<ServerInstance, SegmentsToQuery> serverToSegments = new HashMap<>();
serverToSegmentList.forEach((server, segments) ->
serverToSegments.put(server,
new SegmentsToQuery(segments, List.of())));
- return new RoutingTable(serverToSegments, List.of(), 0);
+ return new RoutingTable(serverToSegments, new
ArrayList<>(unavailableSegments), 0);
}
/// How one side of a colocated join is laid out, for
[#newColocatedJoinQueryEnvironment(ColocatedTableSpec,
@@ -1809,6 +2068,8 @@ public class WorkerManagerTest {
/// null rather than a silently empty answer.
@Nullable
List<String> _survivingSegments;
+ List<String> _unavailableSegments = List.of();
+ Set<Integer> _partitionsWithoutSegments = Set.of();
ColocatedTableSpec(int numPartitions, boolean
everyServerHostsEveryPartition) {
_numPartitions = numPartitions;
@@ -1844,6 +2105,21 @@ public class WorkerManagerTest {
_survivingSegments = survivingSegments;
return this;
}
+
+ /// Partitions that get an entry listing no segment, as opposed to no
entry at all. The broker never publishes
+ /// this shape today -- a partition's entry is created together with its
first segment -- so it exists only to pin
+ /// what the assignment does if one ever appears.
+ ColocatedTableSpec partitionsWithoutSegments(Set<Integer>
partitionsWithoutSegments) {
+ _partitionsWithoutSegments = partitionsWithoutSegments;
+ return this;
+ }
+
+ /// Segments the routing table reports as unavailable. They were selected
and not pruned, so the broker cannot
+ /// prove them empty and their partition class has to survive.
+ ColocatedTableSpec unavailableSegments(List<String> unavailableSegments) {
+ _unavailableSegments = unavailableSegments;
+ return this;
+ }
}
/// Returns the only fragment below the reduce stage with neither segments
nor children, i.e. the join stage.
@@ -2054,6 +2330,18 @@ public class WorkerManagerTest {
int replicasPerPartition, List<String> survivingSegments, List<String>
unavailableSegments,
int reportedPrunedByRouting, boolean throwOnRouting, Set<Integer>
emptyPartitions,
Set<Integer> partitionsWithOnlyDeferredSegments) {
+ return newPartitionedQueryEnvironment(serverIdxPerPartition, numServers,
replicasPerPartition, survivingSegments,
+ unavailableSegments, reportedPrunedByRouting, throwOnRouting,
emptyPartitions,
+ partitionsWithOnlyDeferredSegments, Map.of());
+ }
+
+ /// Same again, with the surviving segments a leaf sees when its own filter
carries a given string literal. Lets two
+ /// leaves scanning the SAME table be given different verdicts, which is the
only way to tell a per-leaf pruning
+ /// verdict from a per-table one.
+ private static QueryEnvironment newPartitionedQueryEnvironment(int[]
serverIdxPerPartition, int numServers,
+ int replicasPerPartition, List<String> survivingSegments, List<String>
unavailableSegments,
+ int reportedPrunedByRouting, boolean throwOnRouting, Set<Integer>
emptyPartitions,
+ Set<Integer> partitionsWithOnlyDeferredSegments, Map<String,
List<String>> survivingSegmentsByFilterLiteral) {
int numPartitions = serverIdxPerPartition.length;
ServerInstance[] servers = new ServerInstance[numServers];
Map<String, ServerInstance> enabledServers = new HashMap<>();
@@ -2095,6 +2383,7 @@ public class WorkerManagerTest {
PartitionedRoutingManager routingManager = new
PartitionedRoutingManager(enabledServers,
Map.of(PARTITIONED_TABLE_OFFLINE, tablePartitionInfo),
Map.of(PARTITIONED_TABLE_OFFLINE, prunedRoutingTable), throwOnRouting);
+
survivingSegmentsByFilterLiteral.forEach(routingManager::survivingSegmentsForFilterLiteral);
Map<String, String> tableNameMap = new HashMap<>();
tableNameMap.put(PARTITIONED_TABLE_OFFLINE, PARTITIONED_TABLE_OFFLINE);
@@ -2494,6 +2783,9 @@ public class WorkerManagerTest {
private final Map<String, ServerInstance> _enabledServers;
private final Map<String, TablePartitionReplicatedServersInfo>
_partitionInfoByTable;
private final Map<String, RoutingTable> _routingTableByTable;
+ /// Surviving segments for a leaf whose filter carries the given string
literal, which is how a test gives two
+ /// leaves scanning ONE table two different verdicts. Falls back to the
per-table routing table when absent.
+ private final Map<String, List<String>> _survivingSegmentsByFilterLiteral
= new HashMap<>();
private final boolean _throwOnRouting;
@Nullable
private final TimeBoundaryInfo _timeBoundaryInfo;
@@ -2541,8 +2833,67 @@ public class WorkerManagerTest {
@Nullable
@Override
public List<String> getSegments(BrokerRequest brokerRequest) {
- TablePartitionReplicatedServersInfo partitionInfo =
-
_partitionInfoByTable.get(brokerRequest.getQuerySource().getTableName());
+ return allSegments(brokerRequest.getQuerySource().getTableName());
+ }
+
+ /// Derives the pruned set the way `BaseBrokerRoutingManager` does --
everything selection offered minus what the
+ /// pruners kept -- from the same configured routing table. Unavailable
segments were selected and not pruned, so
+ /// they stay out of it and keep their partition alive. A table with no
routing table registered proves nothing,
+ /// which is what an unexpected routing call should look like.
+ @Override
+ public Set<String> getPrunedSegments(BrokerRequest brokerRequest) {
+ if (_throwOnRouting) {
+ throw new RuntimeException("Simulated routing failure");
+ }
+
validatePrunableFilter(brokerRequest.getPinotQuery().getFilterExpression());
+ String tableNameWithType = brokerRequest.getQuerySource().getTableName();
+ Set<String> prunedSegments = new
HashSet<>(allSegments(tableNameWithType));
+ String filterLiteral =
firstStringLiteral(brokerRequest.getPinotQuery().getFilterExpression());
+ List<String> survivingSegments =
+ filterLiteral != null ?
_survivingSegmentsByFilterLiteral.get(filterLiteral) : null;
+ if (survivingSegments != null) {
+ prunedSegments.removeAll(survivingSegments);
+ return prunedSegments;
+ }
+ RoutingTable routingTable = _routingTableByTable.get(tableNameWithType);
+ if (routingTable == null) {
+ return Set.of();
+ }
+ for (SegmentsToQuery segmentsToQuery :
routingTable.getServerInstanceToSegmentsMap().values()) {
+ prunedSegments.removeAll(segmentsToQuery.getSegments());
+ }
+ prunedSegments.removeAll(routingTable.getUnavailableSegments());
+ return prunedSegments;
+ }
+
+ PartitionedRoutingManager survivingSegmentsForFilterLiteral(String
filterLiteral, List<String> segments) {
+ _survivingSegmentsByFilterLiteral.put(filterLiteral, segments);
+ return this;
+ }
+
+ /// The first string literal in the filter, which the tests use as a
stand-in for "which filter is this".
+ @Nullable
+ private static String firstStringLiteral(@Nullable Expression expression) {
+ if (expression == null) {
+ return null;
+ }
+ if (expression.getLiteral() != null &&
expression.getLiteral().isSetStringValue()) {
+ return expression.getLiteral().getStringValue();
+ }
+ Function function = expression.getFunctionCall();
+ if (function != null) {
+ for (Expression operand : function.getOperands()) {
+ String literal = firstStringLiteral(operand);
+ if (literal != null) {
+ return literal;
+ }
+ }
+ }
+ return null;
+ }
+
+ private List<String> allSegments(String tableNameWithType) {
+ TablePartitionReplicatedServersInfo partitionInfo =
_partitionInfoByTable.get(tableNameWithType);
if (partitionInfo == null) {
return List.of();
}
diff --git
a/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
b/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
index 20ef9aed09e..5f6c4b84e9d 100644
--- a/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
+++ b/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
@@ -421,21 +421,15 @@
"\n ├── [2]@localhost:1|[1] PROJECT",
"\n │ └── [2]@localhost:1|[1] TABLE SCAN
(a) null",
"\n └── [2]@localhost:1|[1]
MAIL_RECEIVE(BROADCAST_DISTRIBUTED)",
- "\n ├── [3]@localhost:2|[2]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[2]@localhost:1|[0, 1],[2]@localhost:2|[2,
3]} (Subtree Omitted)",
- "\n ├── [3]@localhost:2|[3]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[2]@localhost:1|[0, 1],[2]@localhost:2|[2,
3]} (Subtree Omitted)",
- "\n ├── [3]@localhost:1|[0]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[2]@localhost:1|[0, 1],[2]@localhost:2|[2,
3]} (Subtree Omitted)",
- "\n └── [3]@localhost:1|[1]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[2]@localhost:1|[0, 1],[2]@localhost:2|[2,
3]}",
- "\n └── [3]@localhost:1|[1] PROJECT",
- "\n └── [3]@localhost:1|[1]
FILTER",
- "\n └── [3]@localhost:1|[1]
AGGREGATE_FINAL",
- "\n └──
[3]@localhost:1|[1] MAIL_RECEIVE(HASH_DISTRIBUTED)",
- "\n ├──
[4]@localhost:2|[2]
MAIL_SEND(HASH_DISTRIBUTED)[PARTITIONED]->{[3]@localhost:2|[2]} (Subtree
Omitted)",
- "\n ├──
[4]@localhost:2|[3]
MAIL_SEND(HASH_DISTRIBUTED)[PARTITIONED]->{[3]@localhost:2|[3]} (Subtree
Omitted)",
- "\n ├──
[4]@localhost:1|[0]
MAIL_SEND(HASH_DISTRIBUTED)[PARTITIONED]->{[3]@localhost:1|[0]} (Subtree
Omitted)",
- "\n └──
[4]@localhost:1|[1]
MAIL_SEND(HASH_DISTRIBUTED)[PARTITIONED]->{[3]@localhost:1|[1]}",
- "\n └──
[4]@localhost:1|[1] AGGREGATE_LEAF",
- "\n └──
[4]@localhost:1|[1] FILTER",
- "\n └──
[4]@localhost:1|[1] TABLE SCAN (b) null",
+ "\n └── [3]@localhost:1|[0]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[2]@localhost:1|[0, 1],[2]@localhost:2|[2,
3]}",
+ "\n └── [3]@localhost:1|[0] PROJECT",
+ "\n └── [3]@localhost:1|[0]
FILTER",
+ "\n └── [3]@localhost:1|[0]
AGGREGATE_FINAL",
+ "\n └──
[3]@localhost:1|[0] MAIL_RECEIVE(HASH_DISTRIBUTED)",
+ "\n └──
[4]@localhost:1|[0]
MAIL_SEND(HASH_DISTRIBUTED)[PARTITIONED]->{[3]@localhost:1|[0]}",
+ "\n └──
[4]@localhost:1|[0] AGGREGATE_LEAF",
+ "\n └──
[4]@localhost:1|[0] FILTER",
+ "\n └──
[4]@localhost:1|[0] TABLE SCAN (b) null",
"\n"
]
},
diff --git
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
index a60b43ddd58..7cdf8a56398 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
@@ -636,6 +636,11 @@ public class CommonConstants {
/// Separated from [#CONFIG_OF_USE_BROKER_PRUNING] so the two paths can be
rolled out independently; both
/// default to enabled now that all logical-planner leaf paths
(non-partitioned, partitioned, logical tables)
/// support broker pruning. Actual pruning still requires segment pruners
to be configured on the table.
+ ///
+ /// On a colocated join this governs more than which segments are
dispatched: a partition class that every member
+ /// of the colocated group prunes away is dropped from the group's shared
class list, so the leaves and the stages
+ /// derived from them run fewer workers and the query is dispatched to
fewer servers. Turning it off restores one
+ /// worker per populated class.
public static final String CONFIG_OF_LOGICAL_PLANNER_USE_BROKER_PRUNING =
"pinot.broker.multistage.logical.planner.use.broker.pruning";
public static final boolean DEFAULT_LOGICAL_PLANNER_USE_BROKER_PRUNING =
true;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]