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 a080910ca48 Build TimeSegmentPruner's interval tree from flat arrays
and skip no-op rebuilds (#19643)
a080910ca48 is described below
commit a080910ca48f11cbf8b72a67f17b7fa10c070ed6
Author: Yash Mayya <[email protected]>
AuthorDate: Wed Sep 30 13:30:51 2026 -0700
Build TimeSegmentPruner's interval tree from flat arrays and skip no-op
rebuilds (#19643)
---
.../routing/segmentpruner/TimeSegmentPruner.java | 38 +++-
.../segmentpruner/interval/IntervalTree.java | 240 +++++++++------------
.../routing/intervalst/IntervalTreeTest.java | 60 ++++++
.../routing/segmentpruner/SegmentPrunerTest.java | 120 +++++++++++
.../pinot/perf/BenchmarkTimeSegmentPruner.java | 163 ++++++++++++++
5 files changed, 481 insertions(+), 140 deletions(-)
diff --git
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/TimeSegmentPruner.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/TimeSegmentPruner.java
index 19ef2ee0cc4..7c2833b12d6 100644
---
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/TimeSegmentPruner.java
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/TimeSegmentPruner.java
@@ -18,6 +18,7 @@
*/
package org.apache.pinot.broker.routing.segmentpruner;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import java.util.ArrayList;
import java.util.HashMap;
@@ -61,8 +62,16 @@ public class TimeSegmentPruner implements SegmentPruner {
private final String _timeColumn;
private final DateTimeFormatSpec _timeFormatSpec;
+ /// Rebuilt under the lock on this pruner and read by [#prune(BrokerRequest,
Set)] without locking. Each tree is
+ /// immutable, so a query always sees a complete tree.
private volatile IntervalTree<String> _intervalTree;
+ /// Guarded by the lock on this pruner, except in [#init], which runs before
the pruner is used.
private final Map<String, Interval> _intervalMap = new HashMap<>();
+ /// True while [#_intervalTree] may lag [#_intervalMap]: set before the map
is updated and cleared once the tree
+ /// matches it again. If an update throws part way (e.g. with an
OutOfMemoryError), the next update rebuilds the tree
+ /// even when it changes nothing, instead of skipping the rebuild and
leaving segments missing from the tree. Guarded
+ /// by the lock on this pruner.
+ private boolean _intervalTreeStale;
public TimeSegmentPruner(TableConfig tableConfig, DateTimeFieldSpec
timeFieldSpec) {
_tableNameWithType = tableConfig.getTableName();
@@ -107,20 +116,41 @@ public class TimeSegmentPruner implements SegmentPruner {
Set<String> onlineSegments, List<String> pulledSegments, List<ZNRecord>
znRecords) {
// NOTE: We don't update all the segment ZK metadata for every external
view change, but only the new added/removed
// ones. The refreshed segment ZK metadata change won't be picked up.
+ boolean treeWasStale = _intervalTreeStale;
+ _intervalTreeStale = true;
+ int numSegmentsBefore = _intervalMap.size();
for (int idx = 0; idx < pulledSegments.size(); idx++) {
String segment = pulledSegments.get(idx);
ZNRecord zNrecord = znRecords.get(idx);
_intervalMap.computeIfAbsent(segment, k ->
extractIntervalFromSegmentZKMetaZNRecord(k, zNrecord));
}
- _intervalMap.keySet().retainAll(onlineSegments);
- _intervalTree = new IntervalTree<>(_intervalMap);
+ // Only insertions can change the size because computeIfAbsent never
replaces an existing interval. An external
+ // view change that adds and removes no segment (e.g. a replica changing
state) leaves the tree correct as is.
+ boolean segmentsChanged = _intervalMap.size() != numSegmentsBefore;
+ segmentsChanged |= _intervalMap.keySet().retainAll(onlineSegments);
+ if (segmentsChanged || treeWasStale) {
+ _intervalTree = new IntervalTree<>(_intervalMap);
+ }
+ _intervalTreeStale = false;
}
@Override
public synchronized void refreshSegment(String segment, @Nullable ZNRecord
znRecord) {
Interval interval = extractIntervalFromSegmentZKMetaZNRecord(segment,
znRecord);
- _intervalMap.put(segment, interval);
- _intervalTree = new IntervalTree<>(_intervalMap);
+ boolean treeWasStale = _intervalTreeStale;
+ _intervalTreeStale = true;
+ Interval previousInterval = _intervalMap.put(segment, interval);
+ // A segment is commonly refreshed onto the time interval it already has
(e.g. an OFFLINE segment replaced with a
+ // new build of the same time range), which leaves the tree correct as is
+ if (treeWasStale || !interval.equals(previousInterval)) {
+ _intervalTree = new IntervalTree<>(_intervalMap);
+ }
+ _intervalTreeStale = false;
+ }
+
+ @VisibleForTesting
+ IntervalTree<String> getIntervalTree() {
+ return _intervalTree;
}
/// NOTE: Pruning is done by searching \_intervalTree based on request time
interval and check if the results
diff --git
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/interval/IntervalTree.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/interval/IntervalTree.java
index 59d6dcc95dd..f8d90684b47 100644
---
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/interval/IntervalTree.java
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpruner/interval/IntervalTree.java
@@ -18,179 +18,147 @@
*/
package org.apache.pinot.broker.routing.segmentpruner.interval;
-import com.google.common.base.Preconditions;
+import it.unimi.dsi.fastutil.ints.IntArrays;
import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.LinkedList;
import java.util.List;
import java.util.Map;
-import org.apache.pinot.spi.utils.Pairs;
-
-
-/// The `IntervalTree` class represents read-only balanced binary interval
tree map (from intervals to values)
+import javax.annotation.Nullable;
+
+
+/// The `IntervalTree` class represents a read-only balanced binary interval
tree map (from intervals to values).
+///
+/// The distinct intervals are held sorted in [#_intervals], and the balanced
tree over them is implicit: the root of
+/// the index range `[start, end)` is `start + (end - start) / 2`, its left
child is the root of `[start, mid)` and its
+/// right child the root of `[mid + 1, end)`. A typical balanced tree:
+/// ```
+/// [10, 20]
+/// / \
+/// [8, 15] [12, 20]
+/// / /
+/// [5, 10] [10, 30]
+/// ```
+/// is held as the sorted array `{ [5, 10], [8, 15], [10, 20], [10, 30], [12,
20] }`.
+///
+/// The tree is held implicitly and its payload in flat arrays because a tree
is rebuilt whenever the segments of a
+/// table change, including on every REALTIME segment commit, and on a table
with hundreds of thousands of segments an
+/// object per interval dominates broker allocation.
+///
+/// Instances are immutable and safe to publish to readers through a volatile
field.
public class IntervalTree<VALUE> {
-
- // List representation of BST with root at index 0. For node with index x,
it's left child index is (2x+1), right
- // child index is (2x+2)
- private final List<Node> _nodes;
+ /// The distinct intervals, sorted ascending by `(min, max)`.
+ private final Interval[] _intervals;
+ /// Max interval end of the subtree rooted at each node, used to skip
subtrees that cannot match.
+ private final long[] _subtreeMaxs;
+ /// The values mapped to `_intervals[i]` are `_values[_valueOffsets[i]]`
(inclusive) through
+ /// `_values[_valueOffsets[i + 1]]` (exclusive). Holds one extra trailing
entry so the last node has an end offset.
+ private final int[] _valueOffsets;
+ /// Every key of the map the tree was built from, grouped by interval. Typed
as `Object[]` because a `VALUE[]`
+ /// cannot be created from a type parameter; only values put here by the
constructor are ever read back out.
+ private final Object[] _values;
public IntervalTree(Map<VALUE, Interval> valueToIntervalMap) {
- Map<Interval, List<VALUE>> intervalToValuesMap = new HashMap<>();
+ int numValues = valueToIntervalMap.size();
+ Object[] values = new Object[numValues];
+ Interval[] intervals = new Interval[numValues];
+ int index = 0;
for (Map.Entry<VALUE, Interval> entry : valueToIntervalMap.entrySet()) {
- intervalToValuesMap.putIfAbsent(entry.getValue(), new ArrayList<>());
- intervalToValuesMap.get(entry.getValue()).add(entry.getKey());
+ values[index] = entry.getKey();
+ intervals[index] = entry.getValue();
+ index++;
}
- List<Node<VALUE>> sortedNodes = new ArrayList<>();
- for (Map.Entry<Interval, List<VALUE>> entry :
intervalToValuesMap.entrySet()) {
- sortedNodes.add(new Node(entry.getKey(), entry.getValue()));
+ // Sort a permutation of the indexes rather than the entries themselves,
so that no object is allocated per entry.
+ // This sorts every value instead of grouping by interval first and
sorting only the distinct ones. Grouping first
+ // needs a hash structure over the values, which costs more than it saves
unless most values share an interval:
+ // measured over 250k values, grouping first is 35 ms / 12 MB against 20
ms / 3 MB when every interval is distinct,
+ // and 3 ms / 1 MB against 12 ms / 3 MB when 64 values share each interval.
+ int[] sortedIndexes = new int[numValues];
+ for (int i = 0; i < numValues; i++) {
+ sortedIndexes[i] = i;
}
- Collections.sort(sortedNodes);
- _nodes = buildIntervalTree(sortedNodes);
- buildAuxiliaryInfo();
- }
-
- /// Build interval bst by bfs, the root for each subtree will be the one
with median interval.
- /// A typical balanced tree:
- /// \[10, 20\]
- /// / \
- /// \[8, 15\] \[12, 20\]
- /// / /
- /// \[5, 10\] \[10, 30\]
- /// is represented as { \[10, 20\], \[8, 15\], \[12, 20\], \[5, 10\], null,
\[10, 30\] }
- private List<Node> buildIntervalTree(List<Node<VALUE>> sortedNodes) {
- List<Node> resNodes = new ArrayList<>();
- LinkedList<Pairs.IntPair> indexQueue = new LinkedList<>();
- indexQueue.add(new Pairs.IntPair(0, sortedNodes.size()));
- int count = 0;
- while (count < sortedNodes.size()) {
- Pairs.IntPair indexPair = indexQueue.pollFirst();
- int start = indexPair.getLeft();
- int end = indexPair.getRight();
-
- if (start < end) {
- int mid = start + (end - start) / 2;
- resNodes.add(sortedNodes.get(mid));
- count++;
- indexQueue.add(new Pairs.IntPair(start, mid));
- indexQueue.add(new Pairs.IntPair(mid + 1, end));
- } else {
- resNodes.add(null);
+ IntArrays.quickSort(sortedIndexes, (i, j) ->
intervals[i].compareTo(intervals[j]));
+
+ // Equal intervals are adjacent after the sort, so a single scan gives the
number of distinct intervals
+ int numIntervals = 0;
+ Interval previousInterval = null;
+ for (int i = 0; i < numValues; i++) {
+ Interval interval = intervals[sortedIndexes[i]];
+ if (!interval.equals(previousInterval)) {
+ numIntervals++;
+ previousInterval = interval;
}
}
- return resNodes;
- }
-
- private void buildAuxiliaryInfo() {
- // Build max info for the interval tree by dfs
- buildAuxiliaryInfo(0);
- }
- private void buildAuxiliaryInfo(int nodeIndex) {
- if (!hasNode(nodeIndex)) {
- return;
+ _intervals = new Interval[numIntervals];
+ _subtreeMaxs = new long[numIntervals];
+ _valueOffsets = new int[numIntervals + 1];
+ _values = new Object[numValues];
+ int numIntervalsAdded = 0;
+ previousInterval = null;
+ for (int i = 0; i < numValues; i++) {
+ int sortedIndex = sortedIndexes[i];
+ Interval interval = intervals[sortedIndex];
+ if (!interval.equals(previousInterval)) {
+ _intervals[numIntervalsAdded] = interval;
+ _valueOffsets[numIntervalsAdded] = i;
+ numIntervalsAdded++;
+ previousInterval = interval;
+ }
+ _values[i] = values[sortedIndex];
}
+ _valueOffsets[numIntervals] = numValues;
- int leftChildIndex = getLeftChildIndex(nodeIndex);
- int rightChildIndex = getRightChildIndex(nodeIndex);
-
- buildAuxiliaryInfo(leftChildIndex);
- buildAuxiliaryInfo(rightChildIndex);
-
- long max = _nodes.get(nodeIndex)._interval._max;
- max = Math.max(getMax(rightChildIndex), Math.max(max,
getMax(leftChildIndex)));
- _nodes.get(nodeIndex)._max = max;
+ buildSubtreeMaxs(0, numIntervals);
}
- private int getLeftChildIndex(int nodeIndex) {
- return nodeIndex * 2 + 1;
- }
-
- private int getRightChildIndex(int nodeIndex) {
- return nodeIndex * 2 + 2;
- }
-
- private long getMax(int index) {
- if (!hasNode(index)) {
+ /// Fills [#_subtreeMaxs] for the subtree covering `[start, end)` and
returns its max interval end.
+ private long buildSubtreeMaxs(int start, int end) {
+ if (start >= end) {
return Long.MIN_VALUE;
}
- return _nodes.get(index)._max;
+ int mid = start + (end - start) / 2;
+ long max = Math.max(_intervals[mid]._max, Math.max(buildSubtreeMaxs(start,
mid), buildSubtreeMaxs(mid + 1, end)));
+ _subtreeMaxs[mid] = max;
+ return max;
}
/// Find all values whose intervals intersect with the input interval.
///
/// @param searchInterval search interval
/// @return list of all qualified values.
- public List<VALUE> searchAll(Interval searchInterval) {
- List<VALUE> list = new ArrayList<>();
- if (searchInterval == null) {
- return list;
+ public List<VALUE> searchAll(@Nullable Interval searchInterval) {
+ List<VALUE> values = new ArrayList<>();
+ if (searchInterval != null) {
+ searchAll(0, _intervals.length, searchInterval, values);
}
- searchAll(0, searchInterval, list);
- return list;
+ return values;
}
- private void searchAll(int nodeIndex, Interval searchInterval, List<VALUE>
list) {
- if (!hasNode(nodeIndex)) {
+ private void searchAll(int start, int end, Interval searchInterval,
List<VALUE> values) {
+ if (start >= end) {
return;
}
+ int mid = start + (end - start) / 2;
- int leftChildIndex = getLeftChildIndex(nodeIndex);
- int rightChildIndex = getRightChildIndex(nodeIndex);
-
- if (hasNode(leftChildIndex) && getMax(leftChildIndex) >=
searchInterval._min) {
- searchAll(leftChildIndex, searchInterval, list);
+ // Search the left subtree unless every interval in it ends before the
search interval starts
+ if (start < mid && _subtreeMaxs[start + (mid - start) / 2] >=
searchInterval._min) {
+ searchAll(start, mid, searchInterval, values);
}
- Node<VALUE> node = _nodes.get(nodeIndex);
- Interval interval = node._interval;
+ Interval interval = _intervals[mid];
if (searchInterval.intersects(interval)) {
- list.addAll(node._values);
- }
-
- if (interval._min <= searchInterval._max) {
- searchAll(rightChildIndex, searchInterval, list);
- }
- }
-
- private boolean hasNode(int nodeIndex) {
- return nodeIndex < _nodes.size() && _nodes.get(nodeIndex) != null;
- }
-
- private class Node<VALUE> implements Comparable<Node> {
- private final Interval _interval;
- private final List<VALUE> _values;
- private long _max; // max interval right end of subtree rooted at this node
-
- Node(Interval interval, List<VALUE> values) {
- _interval = interval;
- _values = values;
- }
-
- @Override
- public int compareTo(Node o) {
- Preconditions.checkNotNull(o, "Compare to invalid node: null");
- return _interval.compareTo(o._interval);
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) {
- return true;
+ int valueEndOffset = _valueOffsets[mid + 1];
+ for (int i = _valueOffsets[mid]; i < valueEndOffset; i++) {
+ @SuppressWarnings("unchecked")
+ VALUE value = (VALUE) _values[i];
+ values.add(value);
}
- if (o == null || getClass() != o.getClass()) {
- return false;
- }
-
- Node<?> node = (Node<?>) o;
-
- return _interval.equals(node._interval);
}
- @Override
- public int hashCode() {
- return _interval.hashCode();
+ // Intervals are sorted by start, so nothing in the right subtree can
match once this one starts after the search
+ // interval ends
+ if (interval._min <= searchInterval._max) {
+ searchAll(mid + 1, end, searchInterval, values);
}
}
}
diff --git
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/intervalst/IntervalTreeTest.java
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/intervalst/IntervalTreeTest.java
index 30c082d8965..10e7cdbda6c 100644
---
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/intervalst/IntervalTreeTest.java
+++
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/intervalst/IntervalTreeTest.java
@@ -23,6 +23,8 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Random;
+import java.util.Set;
import org.apache.pinot.broker.routing.segmentpruner.interval.Interval;
import org.apache.pinot.broker.routing.segmentpruner.interval.IntervalTree;
import org.testng.Assert;
@@ -86,4 +88,62 @@ public class IntervalTreeTest {
Assert.assertEquals(new HashSet<>(intervalTree.searchAll(new Interval(20,
30))),
new HashSet<>(Arrays.asList(name8, name9, name10, name11, name12)));
}
+
+ @Test
+ public void testEmptyTree() {
+ IntervalTree<String> intervalTree = new IntervalTree<>(Map.of());
+ Assert.assertEquals(intervalTree.searchAll(new Interval(0,
Long.MAX_VALUE)), List.of());
+ Assert.assertEquals(intervalTree.searchAll(null), List.of());
+ }
+
+ @Test
+ public void testNullSearchInterval() {
+ IntervalTree<String> intervalTree = new IntervalTree<>(Map.of("value", new
Interval(5, 10)));
+ Assert.assertEquals(intervalTree.searchAll(null), List.of());
+ }
+
+ @Test
+ public void testAllValuesOnOneInterval() {
+ Map<String, Interval> valueToIntervalMap = new HashMap<>();
+ for (int i = 0; i < 100; i++) {
+ valueToIntervalMap.put("value" + i, new Interval(5, 10));
+ }
+ IntervalTree<String> intervalTree = new IntervalTree<>(valueToIntervalMap);
+ Assert.assertEquals(new HashSet<>(intervalTree.searchAll(new Interval(10,
20))), valueToIntervalMap.keySet());
+ Assert.assertEquals(intervalTree.searchAll(new Interval(11, 20)),
List.of());
+ }
+
+ /// Cross-checks the tree against a brute force scan over randomly generated
intervals. The tight value ranges make
+ /// many values share an interval, and the `[0, Long.MAX_VALUE]` entry
mirrors the interval
+ /// [org.apache.pinot.broker.routing.segmentpruner.TimeSegmentPruner] gives
a segment with no time range.
+ @Test
+ public void testAgainstBruteForceSearch() {
+ Random random = new Random(0);
+ for (int numValues : new int[]{1, 2, 3, 5, 13, 64, 500}) {
+ for (int valueRange : new int[]{1, 2, 5, 40, 1000}) {
+ Map<String, Interval> valueToIntervalMap = new HashMap<>();
+ for (int i = 0; i < numValues; i++) {
+ long min = random.nextInt(valueRange);
+ valueToIntervalMap.put("value" + i, new Interval(min, min +
random.nextInt(valueRange)));
+ }
+ valueToIntervalMap.put("noTimeRange", new Interval(0, Long.MAX_VALUE));
+
+ IntervalTree<String> intervalTree = new
IntervalTree<>(valueToIntervalMap);
+ for (int i = 0; i < 100; i++) {
+ long min = random.nextInt(valueRange + 2);
+ Interval searchInterval = new Interval(min, min +
random.nextInt(valueRange + 2));
+ Set<String> expected = new HashSet<>();
+ for (Map.Entry<String, Interval> entry :
valueToIntervalMap.entrySet()) {
+ if (entry.getValue().intersects(searchInterval)) {
+ expected.add(entry.getKey());
+ }
+ }
+ List<String> actual = intervalTree.searchAll(searchInterval);
+ Assert.assertEquals(new HashSet<>(actual), expected, "Mismatch for
search interval: " + searchInterval);
+ Assert.assertEquals(actual.size(), expected.size(), "Duplicate
values for search interval: "
+ + searchInterval);
+ }
+ }
+ }
+ }
}
diff --git
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpruner/SegmentPrunerTest.java
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpruner/SegmentPrunerTest.java
index 5e48a981ccc..283f148ba4e 100644
---
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpruner/SegmentPrunerTest.java
+++
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpruner/SegmentPrunerTest.java
@@ -35,6 +35,7 @@ import org.apache.helix.zookeeper.datamodel.ZNRecord;
import org.apache.helix.zookeeper.datamodel.serializer.ZNRecordSerializer;
import org.apache.helix.zookeeper.impl.client.ZkClient;
import
org.apache.pinot.broker.routing.segmentmetadata.SegmentZkMetadataFetcher;
+import org.apache.pinot.broker.routing.segmentpruner.interval.IntervalTree;
import org.apache.pinot.common.metadata.ZKMetadataProvider;
import org.apache.pinot.common.metadata.segment.SegmentPartitionMetadata;
import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
@@ -61,9 +62,14 @@ import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertThrows;
import static org.testng.Assert.assertTrue;
@@ -501,6 +507,112 @@ public class SegmentPrunerTest extends ControllerTest {
assertEquals(segmentPruner.prune(brokerRequest9, input), Set.of()); //
Query with invalid range
}
+ /// The interval tree is rebuilt over every segment of the table, so it must
be rebuilt only when the set of segments
+ /// or a segment's time range actually changes. A refresh onto the same time
range (an OFFLINE segment replaced by a
+ /// new build of the same range) and an assignment change that adds and
removes no segment must keep the tree as is.
+ @Test
+ public void testTimeSegmentPrunerRebuildsOnlyOnChange() {
+ BrokerRequest selectAll =
CalciteSqlCompiler.compileToBrokerRequest(QUERY_1);
+ BrokerRequest between20And30 =
CalciteSqlCompiler.compileToBrokerRequest(TIME_QUERY_2);
+
+ TableConfig tableConfig =
+ new
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).setTimeColumnName(TIME_COLUMN).build();
+ DateTimeFieldSpec timeFieldSpec = new DateTimeFieldSpec(TIME_COLUMN,
DataType.INT, "EPOCH|DAYS", "1:DAYS");
+ TimeSegmentPruner segmentPruner = new TimeSegmentPruner(tableConfig,
timeFieldSpec);
+
+ String segment0 = "segment0";
+ String segment1 = "segment1";
+ Set<String> input = Set.of(segment0, segment1);
+ segmentPruner.init(null, null, List.of(segment0, segment1),
+ List.of(createTimeRangeZNRecord(segment0, 10, 15),
createTimeRangeZNRecord(segment1, 25, 35)));
+ assertEquals(segmentPruner.prune(selectAll, input), input);
+ assertEquals(segmentPruner.prune(between20And30, input), Set.of(segment1));
+
+ // Refreshing onto the same time range keeps the tree
+ IntervalTree<String> intervalTree = segmentPruner.getIntervalTree();
+ segmentPruner.refreshSegment(segment0, createTimeRangeZNRecord(segment0,
10, 15));
+ segmentPruner.refreshSegment(segment1, createTimeRangeZNRecord(segment1,
25, 35));
+ assertSame(segmentPruner.getIntervalTree(), intervalTree);
+ assertEquals(segmentPruner.prune(between20And30, input), Set.of(segment1));
+
+ // An assignment change that adds and removes no segment keeps the tree
+ segmentPruner.onAssignmentChange(null, null, input, List.of(), List.of());
+ assertSame(segmentPruner.getIntervalTree(), intervalTree);
+
+ // Refreshing onto a new time range rebuilds the tree
+ segmentPruner.refreshSegment(segment0, createTimeRangeZNRecord(segment0,
20, 22));
+ segmentPruner.refreshSegment(segment1, createTimeRangeZNRecord(segment1,
40, 50));
+ assertNotSame(segmentPruner.getIntervalTree(), intervalTree);
+ assertEquals(segmentPruner.prune(between20And30, input), Set.of(segment0));
+
+ // Adding a segment rebuilds the tree
+ intervalTree = segmentPruner.getIntervalTree();
+ String segment2 = "segment2";
+ Set<String> inputWithSegment2 = Set.of(segment0, segment1, segment2);
+ segmentPruner.onAssignmentChange(null, null, inputWithSegment2,
List.of(segment2),
+ List.of(createTimeRangeZNRecord(segment2, 28, 29)));
+ assertNotSame(segmentPruner.getIntervalTree(), intervalTree);
+ assertEquals(segmentPruner.prune(between20And30, inputWithSegment2),
Set.of(segment0, segment2));
+
+ // Removing a segment rebuilds the tree, so the removed segment is no
longer selected
+ intervalTree = segmentPruner.getIntervalTree();
+ segmentPruner.onAssignmentChange(null, null, input, List.of(), List.of());
+ assertNotSame(segmentPruner.getIntervalTree(), intervalTree);
+ assertEquals(segmentPruner.prune(between20And30, inputWithSegment2),
Set.of(segment0));
+
+ // A segment whose ZK metadata went missing falls back to the full time
range and is not pruned
+ segmentPruner.refreshSegment(segment1, null);
+ assertEquals(segmentPruner.prune(between20And30, input), Set.of(segment0,
segment1));
+
+ // Replacing a segment adds one and removes one, so the segment count
stays the same, but the tree must be rebuilt
+ intervalTree = segmentPruner.getIntervalTree();
+ String segment3 = "segment3";
+ segmentPruner.onAssignmentChange(null, null, Set.of(segment0, segment3),
List.of(segment3),
+ List.of(createTimeRangeZNRecord(segment3, 24, 26)));
+ assertNotSame(segmentPruner.getIntervalTree(), intervalTree);
+ assertEquals(segmentPruner.prune(between20And30, Set.of(segment0,
segment1, segment3)), Set.of(segment0, segment3));
+ }
+
+ /// An update that throws part way (e.g. with an OutOfMemoryError while the
broker is under heap pressure) can leave
+ /// the interval map ahead of the tree. The next update must rebuild the
tree even when it changes nothing itself,
+ /// or the segments added before the failure stay missing from the tree and
are dropped from the routing.
+ @Test
+ public void testTimeSegmentPrunerRecoversFromFailedUpdate() {
+ BrokerRequest between20And30 =
CalciteSqlCompiler.compileToBrokerRequest(TIME_QUERY_2);
+
+ TableConfig tableConfig =
+ new
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).setTimeColumnName(TIME_COLUMN).build();
+ DateTimeFieldSpec timeFieldSpec = new DateTimeFieldSpec(TIME_COLUMN,
DataType.INT, "EPOCH|DAYS", "1:DAYS");
+ TimeSegmentPruner segmentPruner = new TimeSegmentPruner(tableConfig,
timeFieldSpec);
+ String segment0 = "segment0";
+ segmentPruner.init(null, null, List.of(segment0),
List.of(createTimeRangeZNRecord(segment0, 20, 22)));
+ ZNRecord brokenZNRecord = mock(ZNRecord.class);
+ when(brokenZNRecord.getLongField(anyString(), anyLong())).thenThrow(new
IllegalStateException("broken"));
+ String brokenSegment = "brokenSegment";
+
+ // The update adds segment1, then fails on the broken segment before the
tree is rebuilt
+ String segment1 = "segment1";
+ assertThrows(IllegalStateException.class,
+ () -> segmentPruner.onAssignmentChange(null, null, Set.of(segment0,
segment1, brokenSegment),
+ List.of(segment1, brokenSegment),
List.of(createTimeRangeZNRecord(segment1, 25, 26), brokenZNRecord)));
+ Set<String> input = Set.of(segment0, segment1);
+ assertEquals(segmentPruner.prune(between20And30, input), Set.of(segment0));
+
+ // A refresh onto the same time range changes nothing, but must still
rebuild the stale tree
+ segmentPruner.refreshSegment(segment0, createTimeRangeZNRecord(segment0,
20, 22));
+ assertEquals(segmentPruner.prune(between20And30, input), Set.of(segment0,
segment1));
+
+ // The same failure again, this time repaired by an assignment change that
adds and removes no segment
+ String segment2 = "segment2";
+ assertThrows(IllegalStateException.class,
+ () -> segmentPruner.onAssignmentChange(null, null, Set.of(segment0,
segment1, segment2, brokenSegment),
+ List.of(segment2, brokenSegment),
List.of(createTimeRangeZNRecord(segment2, 27, 28), brokenZNRecord)));
+ Set<String> inputWithSegment2 = Set.of(segment0, segment1, segment2);
+ assertEquals(segmentPruner.prune(between20And30, inputWithSegment2),
Set.of(segment0, segment1));
+ segmentPruner.onAssignmentChange(null, null, inputWithSegment2, List.of(),
List.of());
+ assertEquals(segmentPruner.prune(between20And30, inputWithSegment2),
Set.of(segment0, segment1, segment2));
+ }
+
@Test
public void testTimeSegmentPrunerSimpleDateFormat() {
BrokerRequest brokerRequest1 =
CalciteSqlCompiler.compileToBrokerRequest(SDF_QUERY_1);
@@ -696,6 +808,14 @@ public class SegmentPrunerTest extends ControllerTest {
ZKMetadataProvider.setSegmentZKMetadata(_propertyStore, tableNameWithType,
segmentZKMetadata);
}
+ private static ZNRecord createTimeRangeZNRecord(String segment, long
startTime, long endTime) {
+ SegmentZKMetadata segmentZKMetadata = new SegmentZKMetadata(segment);
+ segmentZKMetadata.setStartTime(startTime);
+ segmentZKMetadata.setEndTime(endTime);
+ segmentZKMetadata.setTimeUnit(TimeUnit.DAYS);
+ return segmentZKMetadata.toZNRecord();
+ }
+
private void setSegmentZKTotalDocsMetadata(String tableNameWithType, String
segment, long totalDocs) {
SegmentZKMetadata segmentZKMetadata = new SegmentZKMetadata(segment);
segmentZKMetadata.setTotalDocs(totalDocs);
diff --git
a/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkTimeSegmentPruner.java
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkTimeSegmentPruner.java
new file mode 100644
index 00000000000..03097cd7bc4
--- /dev/null
+++
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkTimeSegmentPruner.java
@@ -0,0 +1,163 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.perf;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.pinot.broker.routing.segmentpruner.TimeSegmentPruner;
+import org.apache.pinot.common.request.BrokerRequest;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.DateTimeFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.apache.pinot.sql.parsers.CalciteSqlCompiler;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.RunnerException;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+
+/// Benchmarks the broker-side segment metadata maintenance path of
[TimeSegmentPruner].
+///
+/// Every LLC REALTIME segment commit makes the controller broadcast a segment
refresh message to every broker, and each
+/// refresh that changes a segment's time range rebuilds the table's interval
tree. `refreshChangedInterval` is that
+/// cost: a committing REALTIME segment getting its time range.
`refreshUnchangedInterval` is an OFFLINE segment
+/// re-pushed with the same time range, which must skip the rebuild. `prune`
is the query path, which reads the tree
+/// but never builds it.
+///
+/// Run with `-prof gc` — allocation rate matters more than latency here,
because the garbage this path produces is
+/// promoted to old gen rather than dying in eden.
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@Fork(1)
+@Warmup(iterations = 3, time = 3)
+@Measurement(iterations = 5, time = 3)
+@State(Scope.Benchmark)
+public class BenchmarkTimeSegmentPruner {
+ private static final String RAW_TABLE_NAME = "benchTable";
+ private static final String TIME_COLUMN = "tsMs";
+ private static final long BASE_TIME_MS = 1704067200000L; //
2024-01-01T00:00:00Z
+ private static final long SEGMENT_SPAN_MS = TimeUnit.HOURS.toMillis(1);
+
+ @Param({"10000", "100000", "250000"})
+ private int _numSegments;
+
+ /// Number of segments sharing one `[start, end]` interval. 1 models
REALTIME segments whose time range comes from
+ /// the data (effectively all distinct); 64 models one segment per stream
partition landing on the same coarse
+ /// time bucket.
+ @Param({"1", "64"})
+ private int _numSegmentsPerInterval;
+
+ private TimeSegmentPruner _segmentPruner;
+ private List<String> _segments;
+ private Set<String> _onlineSegments;
+ private BrokerRequest _brokerRequest;
+ private int _numIntervals;
+ private int _cursor;
+
+ @Setup(Level.Iteration)
+ public void setUp() {
+ TableConfig tableConfig = new
TableConfigBuilder(TableType.REALTIME).setTableName(RAW_TABLE_NAME)
+ .setTimeColumnName(TIME_COLUMN).build();
+ DateTimeFieldSpec timeFieldSpec =
+ new DateTimeFieldSpec(TIME_COLUMN, DataType.LONG,
"EPOCH|MILLISECONDS", "1:MILLISECONDS");
+ _segmentPruner = new TimeSegmentPruner(tableConfig, timeFieldSpec);
+
+ _segments = new ArrayList<>(_numSegments);
+ List<ZNRecord> znRecords = new ArrayList<>(_numSegments);
+ for (int i = 0; i < _numSegments; i++) {
+ String segment = RAW_TABLE_NAME + "__" + (i % 64) + "__" + (i / 64) +
"__20240101T0000Z";
+ long startTimeMs = BASE_TIME_MS + (long) (i / _numSegmentsPerInterval) *
SEGMENT_SPAN_MS;
+ _segments.add(segment);
+ znRecords.add(createZNRecord(segment, startTimeMs, startTimeMs +
SEGMENT_SPAN_MS - 1));
+ }
+ // NOTE: Ideal state and external view are not used by TimeSegmentPruner
+ _segmentPruner.init(null, null, _segments, znRecords);
+ _onlineSegments = new HashSet<>(_segments);
+ _numIntervals = (_numSegments + _numSegmentsPerInterval - 1) /
_numSegmentsPerInterval;
+
+ long queryStartMs = BASE_TIME_MS + (_numIntervals / 4L) * SEGMENT_SPAN_MS;
+ _brokerRequest = CalciteSqlCompiler.compileToBrokerRequest(
+ "SELECT * FROM " + RAW_TABLE_NAME + " WHERE " + TIME_COLUMN + "
BETWEEN " + queryStartMs + " AND " + (
+ queryStartMs + 24 * SEGMENT_SPAN_MS));
+ _cursor = 0;
+ }
+
+ private static ZNRecord createZNRecord(String segment, long startTimeMs,
long endTimeMs) {
+ ZNRecord znRecord = new ZNRecord(segment);
+ znRecord.setLongField(CommonConstants.Segment.START_TIME, startTimeMs);
+ znRecord.setLongField(CommonConstants.Segment.END_TIME, endTimeMs);
+ znRecord.setEnumField(CommonConstants.Segment.TIME_UNIT,
TimeUnit.MILLISECONDS);
+ return znRecord;
+ }
+
+ /// Refreshes a segment onto a time range it did not have before - the
REALTIME commit shape.
+ ///
+ /// The segment is moved one time bucket forward rather than past the end of
the table, so that the number of
+ /// distinct intervals and the selectivity of the benchmark query both stay
at the values `_numSegments` and
+ /// `_numSegmentsPerInterval` model, however long the benchmark runs.
+ @Benchmark
+ public void refreshChangedInterval() {
+ int index = _cursor++;
+ int segmentIndex = index % _numSegments;
+ String segment = _segments.get(segmentIndex);
+ int intervalIndex = (segmentIndex / _numSegmentsPerInterval + 1 + index /
_numSegments) % _numIntervals;
+ long startTimeMs = BASE_TIME_MS + (long) intervalIndex * SEGMENT_SPAN_MS;
+ _segmentPruner.refreshSegment(segment, createZNRecord(segment,
startTimeMs, startTimeMs + SEGMENT_SPAN_MS - 1));
+ }
+
+ /// Refreshes a segment onto the time range it already has - the OFFLINE
re-push shape.
+ @Benchmark
+ public void refreshUnchangedInterval() {
+ int index = _cursor++ % _numSegments;
+ String segment = _segments.get(index);
+ long startTimeMs = BASE_TIME_MS + (long) (index / _numSegmentsPerInterval)
* SEGMENT_SPAN_MS;
+ _segmentPruner.refreshSegment(segment, createZNRecord(segment,
startTimeMs, startTimeMs + SEGMENT_SPAN_MS - 1));
+ }
+
+ /// Query path only, to confirm pruning itself does not regress.
+ @Benchmark
+ public void prune(Blackhole blackhole) {
+ blackhole.consume(_segmentPruner.prune(_brokerRequest, _onlineSegments));
+ }
+
+ public static void main(String[] args)
+ throws RunnerException {
+ new Runner(new
OptionsBuilder().include(BenchmarkTimeSegmentPruner.class.getSimpleName()).addProfiler("gc").build())
+ .run();
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]