gortiz commented on code in PR #19539:
URL: https://github.com/apache/pinot/pull/19539#discussion_r4063350625
##########
pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/partition/PartitionFunction.java:
##########
@@ -63,6 +63,26 @@ default Map<String, String> getFunctionConfig() {
return null;
}
+ /// Returns whether the exposed function configuration is null or empty.
This does not imply that other settings,
+ /// such as the partition id normalizer, have their default values.
+ @JsonIgnore
+ default boolean hasEmptyConfig() {
+ Map<String, String> config = getFunctionConfig();
+ return config == null || config.isEmpty();
+ }
+
+ /// Returns whether partition ids computed by this function can be reused
for the non-null `other` function.
+ /// A true result must guarantee identical results for every input value.
False is conservative, not proof that the
+ /// functions differ. Implementations with additional output-affecting state
must account for it in this method.
+ ///
+ /// The default only permits matching functions with empty exposed
configurations, without comparing config contents.
+ /// Configured implementations may override this method to compare their
effective settings.
+ default boolean canReusePartitionIds(PartitionFunction other) {
+ return hasEmptyConfig() && other.hasEmptyConfig() && getClass() ==
other.getClass()
Review Comment:
The default is opt-OUT for existing third-party PartitionFunction plugins:
any impl whose behaviour is not fully captured by (class, name, numPartitions,
normalizer) and that returns null from getFunctionConfig() now silently shares
partition ids.
All seven OSS impls (Murmur, Murmur3, Modulo, Fnv, HashCode, ByteArray,
BoundedColumnValue) derive every output-affecting field from the function
config, so hasEmptyConfig() plus class/count/normalizer really is sufficient
for them - I checked each one. But a plugin that loads a lookup table from
disk, or takes state from a table-config field it does not echo back in
getFunctionConfig(), will be recompiled against this default and start reusing
ids across segments that disagree. The javadoc warns implementers, but they
have to know to go read it. Safer shape is opt-IN (default false, built-ins
override) or config-content equality: getClass() == other.getClass() &&
Objects.equals(getFunctionConfig(), other.getFunctionConfig()) &&
getNumPartitions() == ... && getPartitionIdNormalizer() == ... . The second
form is strictly more capable too - it would let the BoundedColumnValue 100 KB
case share ids, which today is the one workload that gets no benefit (-3.1%,
i.e. noise), and a confi
g == other.getFunctionConfig() identity fast path makes the common interned
case free.
##########
pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/partition/PartitionFunction.java:
##########
@@ -63,6 +63,26 @@ default Map<String, String> getFunctionConfig() {
return null;
}
+ /// Returns whether the exposed function configuration is null or empty.
This does not imply that other settings,
+ /// such as the partition id normalizer, have their default values.
+ @JsonIgnore
+ default boolean hasEmptyConfig() {
+ Map<String, String> config = getFunctionConfig();
+ return config == null || config.isEmpty();
+ }
+
+ /// Returns whether partition ids computed by this function can be reused
for the non-null `other` function.
+ /// A true result must guarantee identical results for every input value.
False is conservative, not proof that the
+ /// functions differ. Implementations with additional output-affecting state
must account for it in this method.
+ ///
+ /// The default only permits matching functions with empty exposed
configurations, without comparing config contents.
+ /// Configured implementations may override this method to compare their
effective settings.
+ default boolean canReusePartitionIds(PartitionFunction other) {
Review Comment:
This is the load-bearing contract of the whole PR: a wrong 'true' silently
drops segments and loses rows with no error anywhere.
The pruner picks the first valid segment's function as a pivot and then
fills one shared partition-id cache from whichever segment answers true. For
that to be safe, the relation must be symmetric AND transitive over the pivot's
compatibility class. The javadoc's phrasing 'ids computed by THIS function can
be reused FOR other' reads directional, while the guarantee sentence
('identical results for every input value') is bidirectional. An implementer
reading only the first sentence could write a legitimately asymmetric override
(e.g. 'other is coarser / a superset'), and the pruner would then cache ids
produced by a non-pivot function and hand them to a third segment. Worth
stating the required algebra explicitly: canReusePartitionIds must be
reflexive, symmetric and transitive, and true implies getPartition(v) is equal
for both functions for every v.
##########
pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/SinglePartitionColumnSegmentPruner.java:
##########
@@ -94,6 +98,13 @@ public Set<String> prune(BrokerRequest brokerRequest,
Set<String> segments) {
if (filterExpression == null) {
return segments;
}
+ int numSegments = segments.size();
+ if (numSegments == 0) {
+ return segments;
+ }
+ if (numSegments >= MIN_SEGMENTS_FOR_PREPARATION) {
Review Comment:
Hardcoded 256-segment cutoff with no escape hatch, and it creates a second
evaluator that must stay semantically identical to isPartitionMatch() forever.
Two concerns. (1) No broker config or query option can force the prepared
path on or off. The PR's own numbers show the interleaved case is bimodal in
both versions and landed 14.7% slower on the pooled mean; if that shows up in a
real cluster there is no knob to turn. A `private static final int` read from a
CommonConstants broker config, defaulting to 256, would cost nothing. (2) The
old loop and isPartitionMatch() stay, so there are now two implementations of
the same predicate semantics - including the subtle bits (identifier must equal
_partitionColumn, unsupported kinds return true, OR short-circuits before
FilterKind.valueOf can throw). The tests do cover both sides via the
candidateCounts data provider, but only for some of the cases; a single
data-provider-driven equivalence test that runs every fixture at 255 and 256
candidates and asserts the two paths return the same set would pin this down
permanently.
##########
pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/SinglePartitionColumnSegmentPruner.java:
##########
@@ -105,6 +116,30 @@ public Set<String> prune(BrokerRequest brokerRequest,
Set<String> segments) {
return selectedSegments;
}
+ private Set<String> pruneWithPreparedPredicate(Expression filterExpression,
Set<String> segments) {
+ Set<String> selectedSegments = new HashSet<>();
+ PreparedPredicate predicate = null;
+ PartitionFunction cachedFunction = null;
+ for (String segment : segments) {
+ SegmentPartitionInfo partitionInfo = _partitionInfoMap.get(segment);
+ if (partitionInfo == null || partitionInfo ==
SegmentPartitionUtils.INVALID_PARTITION_INFO) {
+ selectedSegments.add(segment);
+ continue;
+ }
+ PartitionFunction function = partitionInfo.getPartitionFunction();
+ if (predicate == null) {
+ predicate = new PreparedPredicate(filterExpression);
+ cachedFunction = function;
+ }
+ // Reuse the filter structure even when the functions cannot share
partition ids.
+ boolean reusePartitionIds =
cachedFunction.canReusePartitionIds(function);
+ if (predicate.matches(partitionInfo.getPartitions(), function,
reusePartitionIds)) {
Review Comment:
Suggestion: once the prepared tree is built and no leaf touched the
partition column, the answer is 'all segments' for every remaining segment -
you can return `segments` and skip the loop entirely.
For the unrelated-predicate workload the current code still pays a
getPartitionFunction() read plus a virtual canReusePartitionIds() call per
segment, and the description measures only -7.7% there. If every leaf ended up
with _partitionIds == null the predicate is a constant true, so the whole prune
degenerates to O(1). Careful with two things: segments with null/INVALID
partition info must still be kept (they are, since the answer is 'keep
everything'), and the exception behaviour must not change - FilterKind.valueOf
on a bad operator has to still throw on the first evaluated segment, which it
does because the tree is built before you can know the answer.
##########
pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/SinglePartitionColumnSegmentPruner.java:
##########
@@ -152,4 +187,82 @@ private boolean isPartitionMatch(Expression
filterExpression, SegmentPartitionIn
return true;
}
}
+
+ /// Lazily prepares only visited expressions and values. Instances belong to
one prune call, never shared by queries.
+ private final class PreparedPredicate {
+ private final Expression _expression;
+ private FilterKind _kind;
+ private List<Expression> _operands;
+ private PreparedPredicate[] _children;
+ private List<Integer> _partitionIds;
+
+ private PreparedPredicate(Expression expression) {
+ _expression = expression;
+ }
+
+ private boolean matches(Set<Integer> partitions, PartitionFunction
partitionFunction, boolean reusePartitionIds) {
+ if (_kind == null) {
+ Function function = _expression.getFunctionCall();
+ _kind = FilterKind.valueOf(function.getOperator());
+ _operands = function.getOperands();
+ if (_kind == FilterKind.AND || _kind == FilterKind.OR) {
+ _children = new PreparedPredicate[_operands.size()];
+ for (int i = 0; i < _children.length; i++) {
+ _children[i] = new PreparedPredicate(_operands.get(i));
+ }
+ } else if (_kind == FilterKind.EQUALS || _kind == FilterKind.IN) {
+ Identifier identifier = _operands.get(0).getIdentifier();
+ if (identifier != null &&
identifier.getName().equals(_partitionColumn)) {
+ _partitionIds = new ArrayList<>(1);
Review Comment:
new ArrayList<Integer>(1) for an IN list of up to N values: repeated array
growth plus a boxed Integer per value, on the path this PR exists to speed up.
_operands.size() - 1 is already known here, so size it (or use an Integer[]
with a fill count - the index is always dense and appended at position size(),
so an array is a drop-in). Keeping boxed Integer objects is right, since
`partitions` is a Set<Integer> and re-boxing per segment would undo part of the
win, but growing 1 -> 2 -> 3 -> ... -> 128 does ~8 copies for the 128-value IN
benchmark.
##########
pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/SinglePartitionColumnSegmentPruner.java:
##########
@@ -152,4 +187,82 @@ private boolean isPartitionMatch(Expression
filterExpression, SegmentPartitionIn
return true;
}
}
+
+ /// Lazily prepares only visited expressions and values. Instances belong to
one prune call, never shared by queries.
+ private final class PreparedPredicate {
+ private final Expression _expression;
+ private FilterKind _kind;
+ private List<Expression> _operands;
+ private PreparedPredicate[] _children;
+ private List<Integer> _partitionIds;
+
+ private PreparedPredicate(Expression expression) {
+ _expression = expression;
+ }
+
+ private boolean matches(Set<Integer> partitions, PartitionFunction
partitionFunction, boolean reusePartitionIds) {
+ if (_kind == null) {
+ Function function = _expression.getFunctionCall();
+ _kind = FilterKind.valueOf(function.getOperator());
+ _operands = function.getOperands();
+ if (_kind == FilterKind.AND || _kind == FilterKind.OR) {
+ _children = new PreparedPredicate[_operands.size()];
+ for (int i = 0; i < _children.length; i++) {
+ _children[i] = new PreparedPredicate(_operands.get(i));
+ }
+ } else if (_kind == FilterKind.EQUALS || _kind == FilterKind.IN) {
+ Identifier identifier = _operands.get(0).getIdentifier();
+ if (identifier != null &&
identifier.getName().equals(_partitionColumn)) {
+ _partitionIds = new ArrayList<>(1);
+ }
+ }
+ }
+ switch (_kind) {
+ case AND:
+ for (PreparedPredicate child : _children) {
+ if (!child.matches(partitions, partitionFunction,
reusePartitionIds)) {
+ return false;
+ }
+ }
+ return true;
+ case OR:
+ for (PreparedPredicate child : _children) {
+ if (child.matches(partitions, partitionFunction,
reusePartitionIds)) {
+ return true;
+ }
+ }
+ return false;
+ case EQUALS:
+ case IN:
+ if (_partitionIds != null) {
+ int numValues = _kind == FilterKind.EQUALS ? 1 : _operands.size()
- 1;
+ if (!reusePartitionIds) {
Review Comment:
Not a finding - recording that I verified this, because it is the part of
the diff most likely to be misread as a bug.
numValues maps i in [0, numValues) to _operands.get(i + 1), which matches
the original loop over [1, numOperands). Early `return true` on a hit leaves
_partitionIds partially filled, but the next segment resumes at exactly i ==
_partitionIds.size() and appends there, so index i always holds the id for
operand i + 1. The `if (!reusePartitionIds)` branch never reads or writes the
cache, so an incompatible segment cannot poison or consume the prefix - the
testDuplicateInPartitionsAndIncrementalEvaluation fixture is exactly the right
test for this. Noting it here because it is the part of the diff most likely to
be misread as a bug.
##########
pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/SinglePartitionColumnSegmentPruner.java:
##########
@@ -105,6 +116,30 @@ public Set<String> prune(BrokerRequest brokerRequest,
Set<String> segments) {
return selectedSegments;
}
+ private Set<String> pruneWithPreparedPredicate(Expression filterExpression,
Set<String> segments) {
Review Comment:
Scope: MultiPartitionColumnsSegmentPruner has the same per-segment-per-value
hashing loop and gets none of this.
It hashes at lines 142 and 157 in exactly the same shape, so a
multi-column-partitioned table with 4096 segments and a 128-value IN still pays
the full 100k hashes this PR is about. Fine to leave for a follow-up, but worth
saying so in the description so it does not look like the problem is solved
table-wide. If a follow-up does extend it, the PreparedPredicate tree would
need to be per partition column, which is a good reason to check now that the
current shape can be generalised rather than refactored again.
##########
pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpruner/SinglePartitionColumnSegmentPrunerTest.java:
##########
@@ -0,0 +1,393 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.broker.routing.segmentpruner;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.Nullable;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.pinot.common.metadata.segment.SegmentPartitionMetadata;
+import org.apache.pinot.common.request.BrokerRequest;
+import org.apache.pinot.common.request.Expression;
+import org.apache.pinot.common.request.PinotQuery;
+import org.apache.pinot.common.utils.request.RequestUtils;
+import org.apache.pinot.segment.spi.partition.PartitionFunction;
+import org.apache.pinot.segment.spi.partition.PartitionFunctionFactory;
+import org.apache.pinot.segment.spi.partition.PartitionIdNormalizer;
+import org.apache.pinot.segment.spi.partition.metadata.ColumnPartitionMetadata;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotEquals;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.expectThrows;
+
+
+/// Exercises query-local partition ID caching with real metadata
initialization and refresh, without ZooKeeper.
+public class SinglePartitionColumnSegmentPrunerTest {
+ private static final String COLUMN = "memberId";
+ private static final String TABLE = "testTable_OFFLINE";
+
+ @Test
+ public void testHashesOnceAcrossDistinctMetadataInstancesPerQuery() throws
Exception {
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ Set<String> expected = new HashSet<>();
+ for (int i = 0; i < 256; i++) {
+ String segment = "segment_" + i;
+ records.put(segment, metadata(segment, "PrunerCounting", 8, Set.of(i %
8), i % 2 == 0 ? null : Map.of()));
+ if (i % 8 == 3) {
+ expected.add(segment);
+ }
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ CountingPartitionFunction.CALLS.set(0);
+ BrokerRequest request = request(predicate("EQUALS", "3"));
+ assertEquals(pruner.prune(request, records.keySet()), expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 1);
+ assertEquals(pruner.prune(request, records.keySet()), expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 2, "Computed hashes
must not survive a prune call");
+ }
+
+ @Test
+ public void testInterleavedFunctionConfigurationsAndPartitionCounts() throws
Exception {
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ records.put("a", metadata("a", "PrunerCounting", 8, Set.of(3), null));
+ records.put("sameFunction", metadata("sameFunction", "PrunerCounting", 8,
Set.of(2), null));
+ records.put("b", metadata("b", "PrunerCounting", 8, Set.of(4),
Map.of("offset", "1")));
+ records.put("c", metadata("c", "PrunerCounting", 8, Set.of(2), null));
+ records.put("d", metadata("d", "PrunerCounting", 8, Set.of(3),
Map.of("offset", "1")));
+ records.put("e", metadata("e", "PrunerCounting", 16, Set.of(11), null));
+ Set<String> expected = new HashSet<>(Set.of("a", "b", "e"));
+ for (int i = 0; i < 250; i++) {
+ String segment = "tail_" + i;
+ records.put(segment, metadata(segment, "PrunerCounting", 8, Set.of(3),
null));
+ expected.add(segment);
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ CountingPartitionFunction.CALLS.set(0);
+ assertEquals(pruner.prune(request(predicate("EQUALS", "11")),
records.keySet()), expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 4,
+ "Only compatible default functions reuse IDs; configured functions
never compare configuration contents");
+ records.put("b", metadata("b", "PrunerCounting", 16, Set.of(11), null));
+ records.put("d", metadata("d", "PrunerCounting", 8, Set.of(3), null));
+ expected.add("d");
+ CountingPartitionFunction.CALLS.set(0);
+ assertEquals(pruner(records).prune(request(predicate("EQUALS", "11")),
records.keySet()), expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 3,
+ "Different partition counts must not reuse partition IDs");
+ }
+
+ @Test
+ public void testDuplicateInPartitionsAndIncrementalEvaluation() throws
Exception {
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ records.put("first", metadata("first", "PrunerCounting", 8, Set.of(1),
null));
+ records.put("configured", metadata("configured", "PrunerCounting", 8,
Set.of(3), Map.of("offset", "1")));
+ records.put("second", metadata("second", "PrunerCounting", 8, Set.of(2),
null));
+ records.put("miss", metadata("miss", "PrunerCounting", 8, Set.of(3, 4),
null));
+ records.put("repeat", metadata("repeat", "PrunerCounting", 8, Set.of(1,
2), null));
+ Set<String> expected = new HashSet<>(Set.of("first", "configured",
"second", "repeat"));
+ for (int i = 0; i < 252; i++) {
+ String segment = "repeat_" + i;
+ records.put(segment, metadata(segment, "PrunerCounting", 8, Set.of(1,
2), null));
+ expected.add(segment);
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ CountingPartitionFunction.CALLS.set(0);
+ // The configured segment must not consume or extend the prefix cached by
the first segment.
+ assertEquals(pruner.prune(request(predicate("IN", "1", "9", "17", "2")),
records.keySet()),
+ expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 8);
+ // A configured first segment must not seed IDs for later default-config
segments.
+ records.put("first", metadata("first", "PrunerCounting", 8, Set.of(2),
Map.of("offset", "1")));
+ assertEquals(pruner(records).prune(request(predicate("IN", "1", "9", "17",
"2")), records.keySet()), expected);
+ }
+
+ @Test
+ public void testLargeConfigurationsAndUnrelatedPredicates() throws Exception
{
+ String values = "first|" + "x".repeat(100_000);
+ Map<String, String> config = Map.of("columnValues", values,
"columnValuesDelimiter", "|");
+ ZNRecord first = metadata("first", "BoundedColumnValue", 3, Set.of(1),
config);
+ ZNRecord second = metadata("second", "BoundedColumnValue", 3, Set.of(2),
config);
+ SinglePartitionColumnSegmentPruner pruner = pruner(Map.of("first", first,
"second", second));
+ assertEquals(pruner.prune(request(predicate("EQUALS", "first")),
Set.of("first", "second")), Set.of("first"));
+ assertEquals(pruner.prune(request(function("EQUALS",
RequestUtils.getIdentifierExpression("other"),
+ RequestUtils.getLiteralExpression("value"))), Set.of("first",
"second")), Set.of("first", "second"));
+ pruner.refreshSegment("first", metadata("first", "BoundedColumnValue", 3,
Set.of(2), config));
+ assertEquals(pruner.prune(request(predicate("EQUALS", "first")),
Set.of("first", "second")), Set.of());
+ }
+
+ @Test
+ public void testConfigurationHashCollisionsDoNotReusePartitionIds() throws
Exception {
Review Comment:
This test is vestigial - it guards against a hash collision in a
config-hashing scheme the current implementation no longer has.
With the interface-based canReusePartitionIds there is no
PartitionFunctionKey and configs are never hashed or compared, so 'Fixture must
exercise a configuration hash collision' asserts a property of an earlier
design. It also only has 2 candidate segments, which is below
MIN_SEGMENTS_FOR_PREPARATION, so it exercises the legacy loop rather than the
new code. Harmless, but the name and the assertion message will mislead the
next reader. Either drop it or re-point it at whatever the current design
actually risks (two configured BoundedColumnValue functions with equal
numPartitions and normalizer).
##########
pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpruner/SinglePartitionColumnSegmentPrunerTest.java:
##########
@@ -0,0 +1,393 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.broker.routing.segmentpruner;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.Nullable;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.pinot.common.metadata.segment.SegmentPartitionMetadata;
+import org.apache.pinot.common.request.BrokerRequest;
+import org.apache.pinot.common.request.Expression;
+import org.apache.pinot.common.request.PinotQuery;
+import org.apache.pinot.common.utils.request.RequestUtils;
+import org.apache.pinot.segment.spi.partition.PartitionFunction;
+import org.apache.pinot.segment.spi.partition.PartitionFunctionFactory;
+import org.apache.pinot.segment.spi.partition.PartitionIdNormalizer;
+import org.apache.pinot.segment.spi.partition.metadata.ColumnPartitionMetadata;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotEquals;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.expectThrows;
+
+
+/// Exercises query-local partition ID caching with real metadata
initialization and refresh, without ZooKeeper.
+public class SinglePartitionColumnSegmentPrunerTest {
+ private static final String COLUMN = "memberId";
+ private static final String TABLE = "testTable_OFFLINE";
+
+ @Test
+ public void testHashesOnceAcrossDistinctMetadataInstancesPerQuery() throws
Exception {
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ Set<String> expected = new HashSet<>();
+ for (int i = 0; i < 256; i++) {
+ String segment = "segment_" + i;
+ records.put(segment, metadata(segment, "PrunerCounting", 8, Set.of(i %
8), i % 2 == 0 ? null : Map.of()));
+ if (i % 8 == 3) {
+ expected.add(segment);
+ }
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ CountingPartitionFunction.CALLS.set(0);
+ BrokerRequest request = request(predicate("EQUALS", "3"));
+ assertEquals(pruner.prune(request, records.keySet()), expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 1);
+ assertEquals(pruner.prune(request, records.keySet()), expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 2, "Computed hashes
must not survive a prune call");
+ }
+
+ @Test
+ public void testInterleavedFunctionConfigurationsAndPartitionCounts() throws
Exception {
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ records.put("a", metadata("a", "PrunerCounting", 8, Set.of(3), null));
+ records.put("sameFunction", metadata("sameFunction", "PrunerCounting", 8,
Set.of(2), null));
+ records.put("b", metadata("b", "PrunerCounting", 8, Set.of(4),
Map.of("offset", "1")));
+ records.put("c", metadata("c", "PrunerCounting", 8, Set.of(2), null));
+ records.put("d", metadata("d", "PrunerCounting", 8, Set.of(3),
Map.of("offset", "1")));
+ records.put("e", metadata("e", "PrunerCounting", 16, Set.of(11), null));
+ Set<String> expected = new HashSet<>(Set.of("a", "b", "e"));
+ for (int i = 0; i < 250; i++) {
+ String segment = "tail_" + i;
+ records.put(segment, metadata(segment, "PrunerCounting", 8, Set.of(3),
null));
+ expected.add(segment);
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ CountingPartitionFunction.CALLS.set(0);
+ assertEquals(pruner.prune(request(predicate("EQUALS", "11")),
records.keySet()), expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 4,
+ "Only compatible default functions reuse IDs; configured functions
never compare configuration contents");
+ records.put("b", metadata("b", "PrunerCounting", 16, Set.of(11), null));
+ records.put("d", metadata("d", "PrunerCounting", 8, Set.of(3), null));
+ expected.add("d");
+ CountingPartitionFunction.CALLS.set(0);
+ assertEquals(pruner(records).prune(request(predicate("EQUALS", "11")),
records.keySet()), expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 3,
+ "Different partition counts must not reuse partition IDs");
+ }
+
+ @Test
+ public void testDuplicateInPartitionsAndIncrementalEvaluation() throws
Exception {
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ records.put("first", metadata("first", "PrunerCounting", 8, Set.of(1),
null));
+ records.put("configured", metadata("configured", "PrunerCounting", 8,
Set.of(3), Map.of("offset", "1")));
+ records.put("second", metadata("second", "PrunerCounting", 8, Set.of(2),
null));
+ records.put("miss", metadata("miss", "PrunerCounting", 8, Set.of(3, 4),
null));
+ records.put("repeat", metadata("repeat", "PrunerCounting", 8, Set.of(1,
2), null));
+ Set<String> expected = new HashSet<>(Set.of("first", "configured",
"second", "repeat"));
+ for (int i = 0; i < 252; i++) {
+ String segment = "repeat_" + i;
+ records.put(segment, metadata(segment, "PrunerCounting", 8, Set.of(1,
2), null));
+ expected.add(segment);
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ CountingPartitionFunction.CALLS.set(0);
+ // The configured segment must not consume or extend the prefix cached by
the first segment.
+ assertEquals(pruner.prune(request(predicate("IN", "1", "9", "17", "2")),
records.keySet()),
+ expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 8);
+ // A configured first segment must not seed IDs for later default-config
segments.
+ records.put("first", metadata("first", "PrunerCounting", 8, Set.of(2),
Map.of("offset", "1")));
+ assertEquals(pruner(records).prune(request(predicate("IN", "1", "9", "17",
"2")), records.keySet()), expected);
+ }
+
+ @Test
+ public void testLargeConfigurationsAndUnrelatedPredicates() throws Exception
{
+ String values = "first|" + "x".repeat(100_000);
+ Map<String, String> config = Map.of("columnValues", values,
"columnValuesDelimiter", "|");
+ ZNRecord first = metadata("first", "BoundedColumnValue", 3, Set.of(1),
config);
+ ZNRecord second = metadata("second", "BoundedColumnValue", 3, Set.of(2),
config);
+ SinglePartitionColumnSegmentPruner pruner = pruner(Map.of("first", first,
"second", second));
+ assertEquals(pruner.prune(request(predicate("EQUALS", "first")),
Set.of("first", "second")), Set.of("first"));
+ assertEquals(pruner.prune(request(function("EQUALS",
RequestUtils.getIdentifierExpression("other"),
+ RequestUtils.getLiteralExpression("value"))), Set.of("first",
"second")), Set.of("first", "second"));
+ pruner.refreshSegment("first", metadata("first", "BoundedColumnValue", 3,
Set.of(2), config));
+ assertEquals(pruner.prune(request(predicate("EQUALS", "first")),
Set.of("first", "second")), Set.of());
+ }
+
+ @Test
+ public void testConfigurationHashCollisionsDoNotReusePartitionIds() throws
Exception {
+ Map<String, String> firstConfig = Map.of("columnValues", "Aa|BB",
"columnValuesDelimiter", "|");
+ Map<String, String> secondConfig = Map.of("columnValues", "BB|Aa",
"columnValuesDelimiter", "|");
+ assertEquals(firstConfig.hashCode(), secondConfig.hashCode(),
+ "Fixture must exercise a configuration hash collision");
+ ZNRecord first = metadata("first", "BoundedColumnValue", 3, Set.of(1),
firstConfig);
+ ZNRecord second = metadata("second", "BoundedColumnValue", 3, Set.of(2),
secondConfig);
+ Map<String, ZNRecord> records = Map.of("first", first, "second", second);
+ assertEquals(pruner(records).prune(request(predicate("EQUALS", "Aa")),
records.keySet()), records.keySet());
+ }
+
+ @Test
+ public void testMixedFunctionsNormalizersAndFunctionConfig() throws
Exception {
+ Map<String, ZNRecord> moduloRecords = new LinkedHashMap<>();
+ moduloRecords.put("positive", metadata("positive", "Modulo", 8, Set.of(7),
null));
+ moduloRecords.put("abs", metadata("abs", "Modulo", 8, Set.of(1),
Map.of("partitionIdNormalizer", "ABS")));
+ moduloRecords.put("wrongAbs", metadata("wrongAbs", "Modulo", 8, Set.of(7),
Map.of("partitionIdNormalizer", "ABS")));
+ Set<String> expected = new HashSet<>(Set.of("positive", "abs"));
+ for (int i = 0; i < 253; i++) {
+ String segment = "positive_" + i;
+ moduloRecords.put(segment, metadata(segment, "Modulo", 8, Set.of(7),
null));
+ expected.add(segment);
+ }
+ assertEquals(pruner(moduloRecords).prune(request(predicate("EQUALS",
"-1")), moduloRecords.keySet()),
+ expected);
+
+ String value = "80ff0102";
+ Map<String, String> rawConfig = Map.of("useRawBytes", "true");
+ int textPartition =
PartitionFunctionFactory.getPartitionFunction("Murmur", 97,
null).getPartition(value);
+ int rawPartition = PartitionFunctionFactory.getPartitionFunction("Murmur",
97, rawConfig).getPartition(value);
+ assertNotEquals(textPartition, rawPartition, "Fixture must distinguish
raw-byte and string hashing");
+ Map<String, ZNRecord> murmurRecords = new LinkedHashMap<>();
+ murmurRecords.put("text", metadata("text", "Murmur", 97,
Set.of(textPartition), null));
+ murmurRecords.put("raw", metadata("raw", "Murmur", 97,
Set.of(rawPartition), rawConfig));
+ murmurRecords.put("wrongRaw", metadata("wrongRaw", "Murmur", 97,
Set.of(textPartition), rawConfig));
+ expected = new HashSet<>(Set.of("text", "raw"));
+ for (int i = 0; i < 253; i++) {
+ String segment = "text_" + i;
+ murmurRecords.put(segment, metadata(segment, "Murmur", 97,
Set.of(textPartition), null));
+ expected.add(segment);
+ }
+ assertEquals(pruner(murmurRecords).prune(request(predicate("EQUALS",
value)), murmurRecords.keySet()),
+ expected);
+
+ Map<String, ZNRecord> lookupRecords = new LinkedHashMap<>();
+ lookupRecords.put("first", metadata("first", "BoundedColumnValue", 3,
Set.of(1),
+ Map.of("columnValues", "11|12", "columnValuesDelimiter", "|")));
+ lookupRecords.put("second", metadata("second", "BoundedColumnValue", 3,
Set.of(2),
+ Map.of("columnValues", "12|11", "columnValuesDelimiter", "|")));
+ lookupRecords.put("modulo", metadata("modulo", "Modulo", 3, Set.of(2),
null));
+ assertEquals(pruner(lookupRecords).prune(request(predicate("EQUALS",
"11")), lookupRecords.keySet()),
+ lookupRecords.keySet());
+ }
+
+ @DataProvider
+ public Object[][] candidateCounts() {
+ return new Object[][]{{1}, {2}, {256}};
+ }
+
+ @Test(dataProvider = "candidateCounts")
+ public void testAndOrUnsupportedPredicatesAndLazyInValues(int numSegments)
throws Exception {
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ for (int i = 0; i < numSegments; i++) {
+ String segment = "segment_" + i;
+ records.put(segment, metadata(segment, "Modulo", 8, Set.of(1), null));
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ Set<String> segments = records.keySet();
+ Expression invalidValue = predicate("EQUALS", "invalid-number");
+ assertEquals(pruner.prune(request(predicate("IN", "1", "invalid-number")),
segments), segments);
+ expectThrows(NumberFormatException.class,
+ () -> pruner.prune(request(predicate("IN", "2", "invalid-number")),
segments));
+ assertEquals(pruner.prune(request(function("AND", predicate("EQUALS",
"2"), invalidValue)), segments), Set.of());
+ assertEquals(pruner.prune(request(function("OR", predicate("EQUALS", "1"),
invalidValue)), segments), segments);
+ Expression invalidOperator = function("INVALID_OPERATOR");
+ assertEquals(pruner.prune(request(function("OR", predicate("EQUALS", "1"),
invalidOperator)), segments), segments);
+ assertEquals(pruner.prune(request(function("AND", predicate("EQUALS",
"2"), invalidOperator)), segments), Set.of());
+ expectThrows(IllegalArgumentException.class,
+ () -> pruner.prune(request(function("OR", predicate("EQUALS", "2"),
invalidOperator)), segments));
+ expectThrows(IllegalArgumentException.class, () ->
pruner.prune(request(invalidOperator), segments));
+
+ Expression unsupported = predicate("GREATER_THAN", "100");
+ assertEquals(pruner.prune(request(function("AND", predicate("IN", "0",
"1"), unsupported)), segments), segments);
+ assertEquals(pruner.prune(request(function("OR", predicate("EQUALS", "2"),
unsupported)), segments), segments);
+ assertEquals(pruner.prune(request(function("NOT", predicate("EQUALS",
"1"))), segments), segments);
+ assertEquals(pruner.prune(request(function("EQUALS",
RequestUtils.getIdentifierExpression("other"),
+ RequestUtils.getLiteralExpression("invalid-number"))), segments),
segments);
+ Expression transformedColumn = function("LOWER",
RequestUtils.getIdentifierExpression(COLUMN));
+ assertEquals(pruner.prune(request(function("EQUALS", transformedColumn,
+ RequestUtils.getLiteralExpression("invalid-number"))), segments),
segments);
+ BrokerRequest unfilteredRequest = new BrokerRequest();
+ unfilteredRequest.setPinotQuery(new PinotQuery());
+ assertSame(pruner.prune(unfilteredRequest, segments), segments);
+ }
+
+ @Test
+ public void testEmptyCandidatesDoNotEvaluateFilter() throws Exception {
+ SinglePartitionColumnSegmentPruner pruner = pruner(Map.of("one",
metadata("one", "Modulo", 8, Set.of(1), null)));
+ assertEquals(pruner.prune(request(function("INVALID_OPERATOR")),
Set.of()), Set.of());
+ assertEquals(pruner.prune(request(predicate("EQUALS", "invalid-number")),
Set.of()), Set.of());
+ }
+
+ @Test
+ public void testUnknownMetadataIsConservativeAndDoesNotEvaluateFilter()
throws Exception {
+ SinglePartitionColumnSegmentPruner pruner = new
SinglePartitionColumnSegmentPruner(TABLE, COLUMN);
+ ZNRecord invalid = new ZNRecord("invalid");
+ invalid.setSimpleField(CommonConstants.Segment.PARTITION_METADATA,
"invalid-json");
+ pruner.init(null, null, List.of("missing", "empty", "invalid"),
+ Arrays.asList(null, new ZNRecord("empty"), invalid));
+ Set<String> segments = Set.of("missing", "empty", "invalid",
"not-initialized");
+ assertEquals(pruner.prune(request(predicate("EQUALS", "invalid-number")),
segments), segments);
+ assertEquals(pruner.prune(request(function("INVALID_OPERATOR")),
segments), segments);
+ }
+
+ @Test(dataProvider = "candidateCounts")
+ public void testRefreshUsesCurrentPartitionsAndConfiguration(int
numSegments) throws Exception {
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ for (int i = 0; i < numSegments; i++) {
+ String segment = "segment_" + i;
+ records.put(segment, metadata(segment, "PrunerCounting", 8, Set.of(3),
null));
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ Set<String> segments = records.keySet();
+ BrokerRequest request = request(predicate("EQUALS", "3"));
+ assertEquals(pruner.prune(request, segments), segments);
+ for (String segment : segments) {
+ pruner.refreshSegment(segment, metadata(segment, "PrunerCounting", 8,
Set.of(4), null));
+ }
+ assertEquals(pruner.prune(request, segments), Set.of());
+ for (String segment : segments) {
+ pruner.refreshSegment(segment, metadata(segment, "PrunerCounting", 8,
Set.of(4), Map.of("offset", "1")));
+ }
+ assertEquals(pruner.prune(request, segments), segments);
+ for (String segment : segments) {
+ pruner.refreshSegment(segment, null);
+ }
+ assertEquals(pruner.prune(request(predicate("EQUALS", "invalid-number")),
segments), segments);
+ }
+
+ @Test
+ public void testConcurrentQueriesKeepSeparateCachedValues() throws Exception
{
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ for (int i = 0; i < 256; i++) {
+ String segment = "segment_" + i;
+ records.put(segment, metadata(segment, "Modulo", 8, Set.of(i % 8),
null));
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ ExecutorService executor = Executors.newFixedThreadPool(4);
+ try {
+ List<Callable<Void>> queries = new ArrayList<>();
+ for (int i = 0; i < 32; i++) {
+ int partition = i % 8;
+ Set<String> expected = new HashSet<>();
+ for (int segment = partition; segment < 256; segment += 8) {
+ expected.add("segment_" + segment);
+ }
+ queries.add(() -> {
+ assertEquals(pruner.prune(request(predicate("EQUALS",
Integer.toString(partition))), records.keySet()),
+ expected);
+ return null;
+ });
+ }
+ for (Future<Void> future : executor.invokeAll(queries)) {
+ future.get();
+ }
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ private static SinglePartitionColumnSegmentPruner pruner(Map<String,
ZNRecord> records) {
+ SinglePartitionColumnSegmentPruner pruner = new
SinglePartitionColumnSegmentPruner(TABLE, COLUMN);
+ pruner.init(null, null, new ArrayList<>(records.keySet()), new
ArrayList<>(records.values()));
+ return pruner;
+ }
+
+ private static ZNRecord metadata(String segment, String function, int count,
Set<Integer> partitions,
+ @Nullable Map<String, String> config) throws Exception {
+ ZNRecord record = new ZNRecord(segment);
+ record.setSimpleField(CommonConstants.Segment.PARTITION_METADATA, new
SegmentPartitionMetadata(Map.of(COLUMN,
+ new ColumnPartitionMetadata(function, count, partitions,
config))).toJsonString());
+ return record;
+ }
+
+ private static Expression predicate(String operator, String... values) {
+ List<Expression> operands = new ArrayList<>();
+ operands.add(RequestUtils.getIdentifierExpression(COLUMN));
+ for (String value : values) {
+ operands.add(RequestUtils.getLiteralExpression(value));
+ }
+ return RequestUtils.getFunctionExpression(operator, operands);
+ }
+
+ private static Expression function(String operator, Expression... operands) {
+ return RequestUtils.getFunctionExpression(operator, List.of(operands));
+ }
+
+ private static BrokerRequest request(Expression filter) {
+ PinotQuery pinotQuery = new PinotQuery();
+ pinotQuery.setFilterExpression(filter);
+ BrokerRequest brokerRequest = new BrokerRequest();
+ brokerRequest.setPinotQuery(pinotQuery);
+ return brokerRequest;
+ }
+
+ /// Stateless partition function with observable hash calls.
+ public static class CountingPartitionFunction implements PartitionFunction {
+ private static final long serialVersionUID = 1L;
+ private static final AtomicInteger CALLS = new AtomicInteger();
Review Comment:
The exact-call-count assertions on this static counter are safe today, but
only because of the repo's surefire settings - worth a comment in the test so
it is not lost.
Three tests reset CALLS and assert exact values. The root pom sets
<parallel>false</parallel> and reuseForks=false, so methods run sequentially
and this class gets its own JVM. If the module ever enabled TestNG method-level
parallelism these would interleave and flake. Also note
CountingPartitionFunction is public and in org.apache.pinot, so
PartitionFunctionFactory's classpath scan registers 'PrunerCounting' globally
for every test in this fork - intentional and needed here, just a global side
effect to be aware of.
##########
pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/partition/PartitionFunction.java:
##########
@@ -63,6 +63,26 @@ default Map<String, String> getFunctionConfig() {
return null;
}
+ /// Returns whether the exposed function configuration is null or empty.
This does not imply that other settings,
+ /// such as the partition id normalizer, have their default values.
+ @JsonIgnore
+ default boolean hasEmptyConfig() {
Review Comment:
hasEmptyConfig() becomes permanent public SPI surface, but it exists only to
support the default canReusePartitionIds. A private interface method would do
the same job without the commitment.
Java 9+ private interface methods are callable on any receiver from inside
the interface body, so `private boolean hasEmptyConfig()` still lets the
default impl call other.hasEmptyConfig(). SPI method names are forever in
Pinot; I would rather not add one whose only caller is two lines below unless
overriders are expected to reuse it. If it is meant as a public helper for
overriders, say so in the javadoc. The @JsonIgnore is correct and necessary, by
the way - Jackson would otherwise expose it as an 'emptyConfig' property on
serialised partition metadata.
##########
pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/SinglePartitionColumnSegmentPruner.java:
##########
@@ -105,6 +116,30 @@ public Set<String> prune(BrokerRequest brokerRequest,
Set<String> segments) {
return selectedSegments;
}
+ private Set<String> pruneWithPreparedPredicate(Expression filterExpression,
Set<String> segments) {
+ Set<String> selectedSegments = new HashSet<>();
+ PreparedPredicate predicate = null;
+ PartitionFunction cachedFunction = null;
+ for (String segment : segments) {
+ SegmentPartitionInfo partitionInfo = _partitionInfoMap.get(segment);
+ if (partitionInfo == null || partitionInfo ==
SegmentPartitionUtils.INVALID_PARTITION_INFO) {
+ selectedSegments.add(segment);
+ continue;
+ }
+ PartitionFunction function = partitionInfo.getPartitionFunction();
+ if (predicate == null) {
+ predicate = new PreparedPredicate(filterExpression);
+ cachedFunction = function;
+ }
+ // Reuse the filter structure even when the functions cannot share
partition ids.
+ boolean reusePartitionIds =
cachedFunction.canReusePartitionIds(function);
Review Comment:
The pivot is whichever segment HashSet iteration happens to visit first, and
for any configured partition function the first call is a self-comparison that
returns false - so the optimization silently disables itself for the whole
query.
cachedFunction is pinned to the first valid segment, then compared against
itself on that same iteration. With the default canReusePartitionIds,
f.canReusePartitionIds(f) is FALSE whenever f has a non-empty functionConfig,
because hasEmptyConfig() fails on both sides. So the default impl is not
reflexive, which is surprising for a relation named 'canReuse...', and it means
a configured table gets zero id reuse - matching the BOUNDED_100KB rows in the
description. Two follow-ons: (a) worth a `function == cachedFunction` identity
short-circuit before the virtual call, both for speed and so self-comparison is
trivially true; (b) one outlier segment arriving first (a stale numPartitions
after a table-config change, say) disables reuse for the other N-1 segments. If
that matters, re-pivot when the pivot yields no reuse for the first few
segments, or key a small map by function instead of one pivot.
##########
pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/SinglePartitionColumnSegmentPruner.java:
##########
@@ -94,6 +98,13 @@ public Set<String> prune(BrokerRequest brokerRequest,
Set<String> segments) {
if (filterExpression == null) {
return segments;
}
+ int numSegments = segments.size();
+ if (numSegments == 0) {
Review Comment:
This branch is unreachable in production and it changes what the method
returns: the caller's set instead of a fresh one.
BaseBrokerRoutingManager.selectThenPrune() guards the whole pruner chain
with `if (!selectedSegments.isEmpty())`, so prune() is never called with an
empty set on the query path. The old code already never evaluated the filter
for an empty input (the loop body just did not run), so the branch buys nothing
behaviourally. What it does change is aliasing: getPrunedSegments() documents
that 'the pruners return a new set rather than editing the one they are
handed'. The filterExpression == null branch above already breaks that, so this
is not new, but I would drop the branch rather than add a second exception to a
convention a neighbouring comment relies on.
##########
pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpruner/SinglePartitionColumnSegmentPrunerTest.java:
##########
@@ -0,0 +1,393 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.broker.routing.segmentpruner;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.Nullable;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.pinot.common.metadata.segment.SegmentPartitionMetadata;
+import org.apache.pinot.common.request.BrokerRequest;
+import org.apache.pinot.common.request.Expression;
+import org.apache.pinot.common.request.PinotQuery;
+import org.apache.pinot.common.utils.request.RequestUtils;
+import org.apache.pinot.segment.spi.partition.PartitionFunction;
+import org.apache.pinot.segment.spi.partition.PartitionFunctionFactory;
+import org.apache.pinot.segment.spi.partition.PartitionIdNormalizer;
+import org.apache.pinot.segment.spi.partition.metadata.ColumnPartitionMetadata;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotEquals;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.expectThrows;
+
+
+/// Exercises query-local partition ID caching with real metadata
initialization and refresh, without ZooKeeper.
+public class SinglePartitionColumnSegmentPrunerTest {
+ private static final String COLUMN = "memberId";
+ private static final String TABLE = "testTable_OFFLINE";
+
+ @Test
+ public void testHashesOnceAcrossDistinctMetadataInstancesPerQuery() throws
Exception {
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ Set<String> expected = new HashSet<>();
+ for (int i = 0; i < 256; i++) {
+ String segment = "segment_" + i;
+ records.put(segment, metadata(segment, "PrunerCounting", 8, Set.of(i %
8), i % 2 == 0 ? null : Map.of()));
+ if (i % 8 == 3) {
+ expected.add(segment);
+ }
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ CountingPartitionFunction.CALLS.set(0);
+ BrokerRequest request = request(predicate("EQUALS", "3"));
+ assertEquals(pruner.prune(request, records.keySet()), expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 1);
+ assertEquals(pruner.prune(request, records.keySet()), expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 2, "Computed hashes
must not survive a prune call");
+ }
+
+ @Test
+ public void testInterleavedFunctionConfigurationsAndPartitionCounts() throws
Exception {
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ records.put("a", metadata("a", "PrunerCounting", 8, Set.of(3), null));
+ records.put("sameFunction", metadata("sameFunction", "PrunerCounting", 8,
Set.of(2), null));
+ records.put("b", metadata("b", "PrunerCounting", 8, Set.of(4),
Map.of("offset", "1")));
+ records.put("c", metadata("c", "PrunerCounting", 8, Set.of(2), null));
+ records.put("d", metadata("d", "PrunerCounting", 8, Set.of(3),
Map.of("offset", "1")));
+ records.put("e", metadata("e", "PrunerCounting", 16, Set.of(11), null));
+ Set<String> expected = new HashSet<>(Set.of("a", "b", "e"));
+ for (int i = 0; i < 250; i++) {
+ String segment = "tail_" + i;
+ records.put(segment, metadata(segment, "PrunerCounting", 8, Set.of(3),
null));
+ expected.add(segment);
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ CountingPartitionFunction.CALLS.set(0);
+ assertEquals(pruner.prune(request(predicate("EQUALS", "11")),
records.keySet()), expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 4,
+ "Only compatible default functions reuse IDs; configured functions
never compare configuration contents");
+ records.put("b", metadata("b", "PrunerCounting", 16, Set.of(11), null));
+ records.put("d", metadata("d", "PrunerCounting", 8, Set.of(3), null));
+ expected.add("d");
+ CountingPartitionFunction.CALLS.set(0);
+ assertEquals(pruner(records).prune(request(predicate("EQUALS", "11")),
records.keySet()), expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 3,
+ "Different partition counts must not reuse partition IDs");
+ }
+
+ @Test
+ public void testDuplicateInPartitionsAndIncrementalEvaluation() throws
Exception {
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ records.put("first", metadata("first", "PrunerCounting", 8, Set.of(1),
null));
+ records.put("configured", metadata("configured", "PrunerCounting", 8,
Set.of(3), Map.of("offset", "1")));
+ records.put("second", metadata("second", "PrunerCounting", 8, Set.of(2),
null));
+ records.put("miss", metadata("miss", "PrunerCounting", 8, Set.of(3, 4),
null));
+ records.put("repeat", metadata("repeat", "PrunerCounting", 8, Set.of(1,
2), null));
+ Set<String> expected = new HashSet<>(Set.of("first", "configured",
"second", "repeat"));
+ for (int i = 0; i < 252; i++) {
+ String segment = "repeat_" + i;
+ records.put(segment, metadata(segment, "PrunerCounting", 8, Set.of(1,
2), null));
+ expected.add(segment);
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ CountingPartitionFunction.CALLS.set(0);
+ // The configured segment must not consume or extend the prefix cached by
the first segment.
+ assertEquals(pruner.prune(request(predicate("IN", "1", "9", "17", "2")),
records.keySet()),
+ expected);
+ assertEquals(CountingPartitionFunction.CALLS.get(), 8);
+ // A configured first segment must not seed IDs for later default-config
segments.
+ records.put("first", metadata("first", "PrunerCounting", 8, Set.of(2),
Map.of("offset", "1")));
+ assertEquals(pruner(records).prune(request(predicate("IN", "1", "9", "17",
"2")), records.keySet()), expected);
+ }
+
+ @Test
+ public void testLargeConfigurationsAndUnrelatedPredicates() throws Exception
{
+ String values = "first|" + "x".repeat(100_000);
+ Map<String, String> config = Map.of("columnValues", values,
"columnValuesDelimiter", "|");
+ ZNRecord first = metadata("first", "BoundedColumnValue", 3, Set.of(1),
config);
+ ZNRecord second = metadata("second", "BoundedColumnValue", 3, Set.of(2),
config);
+ SinglePartitionColumnSegmentPruner pruner = pruner(Map.of("first", first,
"second", second));
+ assertEquals(pruner.prune(request(predicate("EQUALS", "first")),
Set.of("first", "second")), Set.of("first"));
+ assertEquals(pruner.prune(request(function("EQUALS",
RequestUtils.getIdentifierExpression("other"),
+ RequestUtils.getLiteralExpression("value"))), Set.of("first",
"second")), Set.of("first", "second"));
+ pruner.refreshSegment("first", metadata("first", "BoundedColumnValue", 3,
Set.of(2), config));
+ assertEquals(pruner.prune(request(predicate("EQUALS", "first")),
Set.of("first", "second")), Set.of());
+ }
+
+ @Test
+ public void testConfigurationHashCollisionsDoNotReusePartitionIds() throws
Exception {
+ Map<String, String> firstConfig = Map.of("columnValues", "Aa|BB",
"columnValuesDelimiter", "|");
+ Map<String, String> secondConfig = Map.of("columnValues", "BB|Aa",
"columnValuesDelimiter", "|");
+ assertEquals(firstConfig.hashCode(), secondConfig.hashCode(),
+ "Fixture must exercise a configuration hash collision");
+ ZNRecord first = metadata("first", "BoundedColumnValue", 3, Set.of(1),
firstConfig);
+ ZNRecord second = metadata("second", "BoundedColumnValue", 3, Set.of(2),
secondConfig);
+ Map<String, ZNRecord> records = Map.of("first", first, "second", second);
+ assertEquals(pruner(records).prune(request(predicate("EQUALS", "Aa")),
records.keySet()), records.keySet());
+ }
+
+ @Test
+ public void testMixedFunctionsNormalizersAndFunctionConfig() throws
Exception {
+ Map<String, ZNRecord> moduloRecords = new LinkedHashMap<>();
+ moduloRecords.put("positive", metadata("positive", "Modulo", 8, Set.of(7),
null));
+ moduloRecords.put("abs", metadata("abs", "Modulo", 8, Set.of(1),
Map.of("partitionIdNormalizer", "ABS")));
+ moduloRecords.put("wrongAbs", metadata("wrongAbs", "Modulo", 8, Set.of(7),
Map.of("partitionIdNormalizer", "ABS")));
+ Set<String> expected = new HashSet<>(Set.of("positive", "abs"));
+ for (int i = 0; i < 253; i++) {
+ String segment = "positive_" + i;
+ moduloRecords.put(segment, metadata(segment, "Modulo", 8, Set.of(7),
null));
+ expected.add(segment);
+ }
+ assertEquals(pruner(moduloRecords).prune(request(predicate("EQUALS",
"-1")), moduloRecords.keySet()),
+ expected);
+
+ String value = "80ff0102";
+ Map<String, String> rawConfig = Map.of("useRawBytes", "true");
+ int textPartition =
PartitionFunctionFactory.getPartitionFunction("Murmur", 97,
null).getPartition(value);
+ int rawPartition = PartitionFunctionFactory.getPartitionFunction("Murmur",
97, rawConfig).getPartition(value);
+ assertNotEquals(textPartition, rawPartition, "Fixture must distinguish
raw-byte and string hashing");
+ Map<String, ZNRecord> murmurRecords = new LinkedHashMap<>();
+ murmurRecords.put("text", metadata("text", "Murmur", 97,
Set.of(textPartition), null));
+ murmurRecords.put("raw", metadata("raw", "Murmur", 97,
Set.of(rawPartition), rawConfig));
+ murmurRecords.put("wrongRaw", metadata("wrongRaw", "Murmur", 97,
Set.of(textPartition), rawConfig));
+ expected = new HashSet<>(Set.of("text", "raw"));
+ for (int i = 0; i < 253; i++) {
+ String segment = "text_" + i;
+ murmurRecords.put(segment, metadata(segment, "Murmur", 97,
Set.of(textPartition), null));
+ expected.add(segment);
+ }
+ assertEquals(pruner(murmurRecords).prune(request(predicate("EQUALS",
value)), murmurRecords.keySet()),
+ expected);
+
+ Map<String, ZNRecord> lookupRecords = new LinkedHashMap<>();
+ lookupRecords.put("first", metadata("first", "BoundedColumnValue", 3,
Set.of(1),
+ Map.of("columnValues", "11|12", "columnValuesDelimiter", "|")));
+ lookupRecords.put("second", metadata("second", "BoundedColumnValue", 3,
Set.of(2),
+ Map.of("columnValues", "12|11", "columnValuesDelimiter", "|")));
+ lookupRecords.put("modulo", metadata("modulo", "Modulo", 3, Set.of(2),
null));
+ assertEquals(pruner(lookupRecords).prune(request(predicate("EQUALS",
"11")), lookupRecords.keySet()),
+ lookupRecords.keySet());
+ }
+
+ @DataProvider
+ public Object[][] candidateCounts() {
+ return new Object[][]{{1}, {2}, {256}};
+ }
+
+ @Test(dataProvider = "candidateCounts")
+ public void testAndOrUnsupportedPredicatesAndLazyInValues(int numSegments)
throws Exception {
+ Map<String, ZNRecord> records = new LinkedHashMap<>();
+ for (int i = 0; i < numSegments; i++) {
+ String segment = "segment_" + i;
+ records.put(segment, metadata(segment, "Modulo", 8, Set.of(1), null));
+ }
+ SinglePartitionColumnSegmentPruner pruner = pruner(records);
+ Set<String> segments = records.keySet();
+ Expression invalidValue = predicate("EQUALS", "invalid-number");
+ assertEquals(pruner.prune(request(predicate("IN", "1", "invalid-number")),
segments), segments);
+ expectThrows(NumberFormatException.class,
+ () -> pruner.prune(request(predicate("IN", "2", "invalid-number")),
segments));
+ assertEquals(pruner.prune(request(function("AND", predicate("EQUALS",
"2"), invalidValue)), segments), Set.of());
+ assertEquals(pruner.prune(request(function("OR", predicate("EQUALS", "1"),
invalidValue)), segments), segments);
+ Expression invalidOperator = function("INVALID_OPERATOR");
+ assertEquals(pruner.prune(request(function("OR", predicate("EQUALS", "1"),
invalidOperator)), segments), segments);
+ assertEquals(pruner.prune(request(function("AND", predicate("EQUALS",
"2"), invalidOperator)), segments), Set.of());
+ expectThrows(IllegalArgumentException.class,
+ () -> pruner.prune(request(function("OR", predicate("EQUALS", "2"),
invalidOperator)), segments));
+ expectThrows(IllegalArgumentException.class, () ->
pruner.prune(request(invalidOperator), segments));
+
+ Expression unsupported = predicate("GREATER_THAN", "100");
+ assertEquals(pruner.prune(request(function("AND", predicate("IN", "0",
"1"), unsupported)), segments), segments);
+ assertEquals(pruner.prune(request(function("OR", predicate("EQUALS", "2"),
unsupported)), segments), segments);
+ assertEquals(pruner.prune(request(function("NOT", predicate("EQUALS",
"1"))), segments), segments);
+ assertEquals(pruner.prune(request(function("EQUALS",
RequestUtils.getIdentifierExpression("other"),
+ RequestUtils.getLiteralExpression("invalid-number"))), segments),
segments);
+ Expression transformedColumn = function("LOWER",
RequestUtils.getIdentifierExpression(COLUMN));
+ assertEquals(pruner.prune(request(function("EQUALS", transformedColumn,
+ RequestUtils.getLiteralExpression("invalid-number"))), segments),
segments);
+ BrokerRequest unfilteredRequest = new BrokerRequest();
+ unfilteredRequest.setPinotQuery(new PinotQuery());
+ assertSame(pruner.prune(unfilteredRequest, segments), segments);
+ }
+
+ @Test
+ public void testEmptyCandidatesDoNotEvaluateFilter() throws Exception {
+ SinglePartitionColumnSegmentPruner pruner = pruner(Map.of("one",
metadata("one", "Modulo", 8, Set.of(1), null)));
+ assertEquals(pruner.prune(request(function("INVALID_OPERATOR")),
Set.of()), Set.of());
+ assertEquals(pruner.prune(request(predicate("EQUALS", "invalid-number")),
Set.of()), Set.of());
+ }
+
+ @Test
+ public void testUnknownMetadataIsConservativeAndDoesNotEvaluateFilter()
throws Exception {
Review Comment:
Test gap: the all-invalid-metadata case only runs on the legacy path (4
candidates), yet it is the one case where the prepared path never builds the
predicate at all.
In pruneWithPreparedPredicate the PreparedPredicate is constructed only when
the first VALID partition info is found, so when every segment is null/INVALID
the filter is never decoded and FilterKind.valueOf never throws. That is the
same conservative behaviour as the original short-circuit, but it is a distinct
code path and this test does not reach it. Padding the fixture past 256
candidates (or adding it to the candidateCounts data provider) would cover it.
Same thought for testLargeConfigurationsAndUnrelatedPredicates, which has 2
segments and therefore never touches the prepared evaluator it is named after.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]