From d81adb4b75495fcb1a41e3dfad78ce8d7aa48e54 Mon Sep 17 00:00:00 2001
From: Tianle Zhang
Date: Thu, 21 May 2026 10:16:11 -0700
Subject: [PATCH] Inject runtime filters at the leaf-stage filter boundary
Plumb a Map through QueryContext and have
FilterPlanNode AND-combine a synthetic RUNTIME_FILTER predicate per
column into the segment-scan filter tree. The producer (MSE runtime
filter feature) is added in a separate change; until that lands the
map stays null and FilterPlanNode is a no-op for existing queries.
QueryContext:
* Add a nullable Map _runtimeFilters with
getter/setter, alongside the other post-build mutable fields.
* Default is null; queries without runtime filters allocate nothing.
FilterPlanNode:
* combineWithRuntimeFilters(baseFilter, queryContext) is the new
package-private helper invoked from the constructor. Tree shape:
- no runtime filters -> baseFilter unchanged
- runtime filters, no base -> single predicate, or AND of them
- runtime filters + base -> AND(baseFilter, runtime-bundle)
* Runtime filters are bundled into a single AND child so the tree
retains an obvious "base AND injected" two-child shape that is easy
to read in EXPLAIN output and easy for downstream rewriters to skip.
* The leaf evaluator (BloomRuntimeFilterEvaluatorFactory, reached via
RuntimeFilterPredicateEvaluatorFactory) is responsible for validating
that each runtime filter's data type matches the column's data type;
no validation duplicated here.
Tests (FilterPlanNodeRuntimeFilterTest, 13 cases):
* Null and empty runtime maps return the base filter unchanged.
* No base + no runtime returns null.
* Single runtime filter with null base produces a bare PREDICATE node
(not a redundant AND of one child).
* Multiple runtime filters with null base produce an AND of two.
* Single runtime filter with base produces AND(base, predicate); base
is preserved by identity so downstream caching keys stay stable.
* Multiple runtime filters with base produce AND(base, AND(rf...))
so the tree shape stays predictable.
* The injected predicate carries the supplied RuntimeFilter by
reference identity (downstream evaluator wiring relies on this).
* The caller's runtime filter map is not mutated.
* A pre-existing RUNTIME_FILTER predicate in the base filter is not
deduplicated; that's the planner's responsibility, not the leaf's.
* QueryContext round-trips the map and defaults it to null.
* An ordinary EQ predicate in the base filter is preserved by identity.
Co-Authored-By: Claude Opus 4.7
---
.../pinot/core/plan/FilterPlanNode.java | 41 ++-
.../query/request/context/QueryContext.java | 23 ++
.../plan/FilterPlanNodeRuntimeFilterTest.java | 283 ++++++++++++++++++
3 files changed, 346 insertions(+), 1 deletion(-)
create mode 100644 pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeRuntimeFilterTest.java
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java b/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
index 805c70a1c2..994f6b7675 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
@@ -22,6 +22,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import javax.annotation.Nullable;
import org.apache.commons.lang3.tuple.Pair;
@@ -31,6 +32,8 @@
import org.apache.pinot.common.request.context.predicate.JsonMatchPredicate;
import org.apache.pinot.common.request.context.predicate.Predicate;
import org.apache.pinot.common.request.context.predicate.RegexpLikePredicate;
+import org.apache.pinot.common.request.context.predicate.RuntimeFilter;
+import org.apache.pinot.common.request.context.predicate.RuntimeFilterPredicate;
import org.apache.pinot.common.request.context.predicate.TextContainsPredicate;
import org.apache.pinot.common.request.context.predicate.TextMatchPredicate;
import org.apache.pinot.common.request.context.predicate.VectorSimilarityPredicate;
@@ -86,7 +89,43 @@ public FilterPlanNode(SegmentContext segmentContext, QueryContext queryContext,
_indexSegment = segmentContext.getIndexSegment();
_segmentContext = segmentContext;
_queryContext = queryContext;
- _filter = filter != null ? filter : _queryContext.getFilter();
+ FilterContext baseFilter = filter != null ? filter : _queryContext.getFilter();
+ _filter = combineWithRuntimeFilters(baseFilter, _queryContext);
+ }
+
+ /**
+ * If the {@link QueryContext} carries runtime filters (supplied by the MSE runtime filter
+ * feature), AND-combines a synthetic {@code RUNTIME_FILTER} predicate per column into the
+ * existing filter tree. Returns {@code baseFilter} unchanged when the map is null or empty so
+ * non-runtime-filter queries pay no extra cost.
+ *
+ * The combined tree shape is:
+ *
+ * - {@code baseFilter} alone, when no runtime filters are supplied.
+ * - A single {@code RUNTIME_FILTER} predicate (or AND of several), when the original
+ * {@code baseFilter} is null.
+ * - {@code AND(baseFilter, RUNTIME_FILTER...)} when both are present.
+ *
+ * The leaf evaluator validates that each runtime filter's data type matches the column's data
+ * type and throws at construction time on mismatch.
+ */
+ @Nullable
+ static FilterContext combineWithRuntimeFilters(@Nullable FilterContext baseFilter, QueryContext queryContext) {
+ Map runtimeFilters = queryContext.getRuntimeFilters();
+ if (runtimeFilters == null || runtimeFilters.isEmpty()) {
+ return baseFilter;
+ }
+ List injected = new ArrayList<>(runtimeFilters.size());
+ for (Map.Entry entry : runtimeFilters.entrySet()) {
+ ExpressionContext lhs = ExpressionContext.forIdentifier(entry.getKey());
+ RuntimeFilterPredicate predicate = new RuntimeFilterPredicate(lhs, entry.getValue());
+ injected.add(FilterContext.forPredicate(predicate));
+ }
+ FilterContext combinedRuntime = injected.size() == 1 ? injected.get(0) : FilterContext.forAnd(injected);
+ if (baseFilter == null) {
+ return combinedRuntime;
+ }
+ return FilterContext.forAnd(Arrays.asList(baseFilter, combinedRuntime));
}
@Override
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
index e85b221a89..5a3c190cfb 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
@@ -35,6 +35,7 @@
import org.apache.pinot.common.request.context.FunctionContext;
import org.apache.pinot.common.request.context.OrderByExpressionContext;
import org.apache.pinot.common.request.context.RequestContextUtils;
+import org.apache.pinot.common.request.context.predicate.RuntimeFilter;
import org.apache.pinot.common.utils.config.QueryOptionsUtils;
import org.apache.pinot.core.plan.maker.InstancePlanMakerImplV2;
import org.apache.pinot.core.query.aggregation.function.AggregationFunction;
@@ -143,6 +144,10 @@ public class QueryContext {
private boolean _accurateGroupByWithoutOrderBy;
// Collection of index types to skip per column
private Map> _skipIndexes;
+ // Runtime filters supplied by the MSE runtime filter feature, keyed by column name. When
+ // non-null and non-empty, FilterPlanNode AND-combines a synthetic RUNTIME_FILTER predicate per
+ // column into the segment-scan filter tree to prune non-matching rows during the scan.
+ private Map _runtimeFilters;
private KeyBasedCrypterCache _crypterCache;
private QueryContext(@Nullable String tableName, @Nullable QueryContext subquery,
@@ -529,6 +534,24 @@ public void setSkipIndexes(Map> skipIndexes)
_skipIndexes = skipIndexes;
}
+ /**
+ * Returns the runtime filters supplied by the MSE runtime filter feature, keyed by column name,
+ * or {@code null} if no runtime filters were supplied for this query.
+ */
+ @Nullable
+ public Map getRuntimeFilters() {
+ return _runtimeFilters;
+ }
+
+ /**
+ * Installs runtime filters for this query. Pass {@code null} or an empty map to leave the scan
+ * filter tree untouched. The map should be keyed by column name; each value's data type must
+ * match the column's data type or the leaf evaluator will reject it at construction time.
+ */
+ public void setRuntimeFilters(@Nullable Map runtimeFilters) {
+ _runtimeFilters = runtimeFilters;
+ }
+
public boolean isIndexUseAllowed(String columnName, FieldConfig.IndexType indexType) {
if (_skipIndexes == null) {
return true;
diff --git a/pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeRuntimeFilterTest.java b/pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeRuntimeFilterTest.java
new file mode 100644
index 0000000000..bd32de524a
--- /dev/null
+++ b/pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeRuntimeFilterTest.java
@@ -0,0 +1,283 @@
+/**
+ * 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.core.plan;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.pinot.common.request.context.ExpressionContext;
+import org.apache.pinot.common.request.context.FilterContext;
+import org.apache.pinot.common.request.context.predicate.BloomRuntimeFilter;
+import org.apache.pinot.common.request.context.predicate.EqPredicate;
+import org.apache.pinot.common.request.context.predicate.Predicate;
+import org.apache.pinot.common.request.context.predicate.RuntimeFilter;
+import org.apache.pinot.common.request.context.predicate.RuntimeFilterPredicate;
+import org.apache.pinot.core.query.request.context.QueryContext;
+import org.apache.pinot.core.query.request.context.utils.QueryContextConverterUtils;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+
+/**
+ * Tests {@link FilterPlanNode#combineWithRuntimeFilters} - the leaf-stage injection that
+ * AND-combines synthetic {@code RUNTIME_FILTER} predicates into the segment-scan filter tree.
+ */
+public class FilterPlanNodeRuntimeFilterTest {
+
+ private static final int EXPECTED_INSERTIONS = 1024;
+ private static final double FPP = 0.01;
+
+ /**
+ * Returns a QueryContext shaped exactly like one produced by the broker so the test exercises
+ * realistic field state (schema, expressions, etc.) instead of a hand-built skeleton.
+ */
+ private static QueryContext newQueryContext(String sql) {
+ return QueryContextConverterUtils.getQueryContext(sql);
+ }
+
+ // ---------- No-op cases (no runtime filters supplied) ----------
+
+ @Test
+ public void returnsBaseFilterWhenRuntimeMapIsNull() {
+ QueryContext qc = newQueryContext("SELECT col FROM t WHERE col = 1");
+ qc.setRuntimeFilters(null);
+ FilterContext baseFilter = qc.getFilter();
+ Assert.assertNotNull(baseFilter, "sanity: WHERE clause should produce a non-null filter");
+ Assert.assertSame(FilterPlanNode.combineWithRuntimeFilters(baseFilter, qc), baseFilter);
+ }
+
+ @Test
+ public void returnsBaseFilterWhenRuntimeMapIsEmpty() {
+ QueryContext qc = newQueryContext("SELECT col FROM t WHERE col = 1");
+ qc.setRuntimeFilters(Collections.emptyMap());
+ FilterContext baseFilter = qc.getFilter();
+ Assert.assertSame(FilterPlanNode.combineWithRuntimeFilters(baseFilter, qc), baseFilter);
+ }
+
+ @Test
+ public void returnsNullWhenBaseFilterAndRuntimeMapAreBothAbsent() {
+ QueryContext qc = newQueryContext("SELECT col FROM t");
+ qc.setRuntimeFilters(null);
+ Assert.assertNull(qc.getFilter(), "sanity: no WHERE clause means no base filter");
+ Assert.assertNull(FilterPlanNode.combineWithRuntimeFilters(null, qc));
+ }
+
+ // ---------- Injection with no original filter ----------
+
+ @Test
+ public void singleRuntimeFilterWithNullBaseProducesBarePredicateNode() {
+ QueryContext qc = newQueryContext("SELECT col FROM t");
+ qc.setRuntimeFilters(singletonRuntimeFilters("col", DataType.INT, 42));
+
+ FilterContext result = FilterPlanNode.combineWithRuntimeFilters(null, qc);
+
+ Assert.assertNotNull(result);
+ Assert.assertEquals(result.getType(), FilterContext.Type.PREDICATE,
+ "single runtime filter must not be wrapped in a redundant AND");
+ Assert.assertTrue(result.getPredicate() instanceof RuntimeFilterPredicate);
+ RuntimeFilterPredicate predicate = (RuntimeFilterPredicate) result.getPredicate();
+ Assert.assertEquals(predicate.getType(), Predicate.Type.RUNTIME_FILTER);
+ Assert.assertEquals(predicate.getLhs(), ExpressionContext.forIdentifier("col"));
+ Assert.assertEquals(predicate.getFilter().getKind(), RuntimeFilter.Kind.BLOOM);
+ Assert.assertTrue(((BloomRuntimeFilter) predicate.getFilter()).mightContain(42));
+ }
+
+ @Test
+ public void multipleRuntimeFiltersWithNullBaseAreAndCombined() {
+ Map rf = new LinkedHashMap<>();
+ rf.put("a", newBloom(DataType.INT, 1));
+ rf.put("b", newBloom(DataType.STRING, "x"));
+ QueryContext qc = newQueryContext("SELECT a, b FROM t");
+ qc.setRuntimeFilters(rf);
+
+ FilterContext result = FilterPlanNode.combineWithRuntimeFilters(null, qc);
+
+ Assert.assertNotNull(result);
+ Assert.assertEquals(result.getType(), FilterContext.Type.AND);
+ List children = result.getChildren();
+ Assert.assertEquals(children.size(), 2);
+ assertRuntimeFilterPredicateOnColumn(children.get(0), "a");
+ assertRuntimeFilterPredicateOnColumn(children.get(1), "b");
+ }
+
+ // ---------- Injection with existing base filter ----------
+
+ @Test
+ public void singleRuntimeFilterIsAndedWithBaseFilter() {
+ QueryContext qc = newQueryContext("SELECT col FROM t WHERE col = 1");
+ qc.setRuntimeFilters(singletonRuntimeFilters("col", DataType.INT, 42));
+ FilterContext baseFilter = qc.getFilter();
+
+ FilterContext result = FilterPlanNode.combineWithRuntimeFilters(baseFilter, qc);
+
+ Assert.assertNotNull(result);
+ Assert.assertEquals(result.getType(), FilterContext.Type.AND);
+ List children = result.getChildren();
+ Assert.assertEquals(children.size(), 2);
+ Assert.assertSame(children.get(0), baseFilter, "base filter must come first to preserve plan readability");
+ assertRuntimeFilterPredicateOnColumn(children.get(1), "col");
+ }
+
+ @Test
+ public void multipleRuntimeFiltersAreAndedWithBaseFilter() {
+ Map rf = new LinkedHashMap<>();
+ rf.put("a", newBloom(DataType.INT, 1));
+ rf.put("b", newBloom(DataType.LONG, 2L));
+ QueryContext qc = newQueryContext("SELECT a, b FROM t WHERE a > 0");
+ qc.setRuntimeFilters(rf);
+ FilterContext baseFilter = qc.getFilter();
+ Assert.assertNotNull(baseFilter);
+
+ FilterContext result = FilterPlanNode.combineWithRuntimeFilters(baseFilter, qc);
+
+ Assert.assertNotNull(result);
+ Assert.assertEquals(result.getType(), FilterContext.Type.AND);
+ Assert.assertEquals(result.getChildren().size(), 2,
+ "expected AND(baseFilter, AND(rfA, rfB)) - the runtime filters are bundled into one child");
+ Assert.assertSame(result.getChildren().get(0), baseFilter);
+ FilterContext runtimeBundle = result.getChildren().get(1);
+ Assert.assertEquals(runtimeBundle.getType(), FilterContext.Type.AND);
+ Assert.assertEquals(runtimeBundle.getChildren().size(), 2);
+ }
+
+ // ---------- Predicate properties ----------
+
+ @Test
+ public void injectedPredicateCarriesTheSuppliedFilterByReference() {
+ BloomRuntimeFilter bloom = newBloom(DataType.STRING, "user-42");
+ QueryContext qc = newQueryContext("SELECT col FROM t");
+ qc.setRuntimeFilters(Collections.singletonMap("col", bloom));
+
+ FilterContext result = FilterPlanNode.combineWithRuntimeFilters(null, qc);
+
+ Assert.assertNotNull(result);
+ RuntimeFilterPredicate predicate = (RuntimeFilterPredicate) result.getPredicate();
+ Assert.assertSame(predicate.getFilter(), bloom,
+ "the predicate must carry the same RuntimeFilter instance - reference identity matters for downstream "
+ + "evaluator wiring");
+ }
+
+ @Test
+ public void doesNotMutateTheCallersRuntimeFilterMap() {
+ Map rf = new LinkedHashMap<>();
+ rf.put("a", newBloom(DataType.INT, 1));
+ QueryContext qc = newQueryContext("SELECT a FROM t");
+ qc.setRuntimeFilters(rf);
+
+ FilterPlanNode.combineWithRuntimeFilters(null, qc);
+
+ Assert.assertEquals(rf.size(), 1);
+ Assert.assertTrue(rf.containsKey("a"));
+ }
+
+ // ---------- Coexistence with non-runtime-filter predicates in the tree ----------
+
+ @Test
+ public void existingRuntimeFilterPredicateInBaseFilterIsNotCollapsedOrDeduped() {
+ // If somebody hand-crafts a base filter that already contains a RUNTIME_FILTER predicate, the
+ // injection should still append the runtime-supplied filter rather than try to deduplicate.
+ // Deduping is the planner's job, not the leaf's.
+ QueryContext qc = newQueryContext("SELECT col FROM t");
+ BloomRuntimeFilter handCrafted = newBloom(DataType.INT, 100);
+ FilterContext handCraftedFilter = FilterContext.forPredicate(
+ new RuntimeFilterPredicate(ExpressionContext.forIdentifier("col"), handCrafted));
+
+ BloomRuntimeFilter runtimeSupplied = newBloom(DataType.INT, 200);
+ qc.setRuntimeFilters(Collections.singletonMap("col", runtimeSupplied));
+
+ FilterContext result = FilterPlanNode.combineWithRuntimeFilters(handCraftedFilter, qc);
+
+ Assert.assertNotNull(result);
+ Assert.assertEquals(result.getType(), FilterContext.Type.AND);
+ Assert.assertEquals(result.getChildren().size(), 2);
+ RuntimeFilterPredicate first = (RuntimeFilterPredicate) result.getChildren().get(0).getPredicate();
+ RuntimeFilterPredicate second = (RuntimeFilterPredicate) result.getChildren().get(1).getPredicate();
+ Assert.assertSame(first.getFilter(), handCrafted);
+ Assert.assertSame(second.getFilter(), runtimeSupplied);
+ }
+
+ // ---------- QueryContext map round-trip ----------
+
+ @Test
+ public void queryContextRoundTripsTheRuntimeFilterMap() {
+ QueryContext qc = newQueryContext("SELECT col FROM t");
+ Map rf = Collections.singletonMap("col", newBloom(DataType.INT, 7));
+ qc.setRuntimeFilters(rf);
+ Assert.assertSame(qc.getRuntimeFilters(), rf);
+ }
+
+ @Test
+ public void queryContextDefaultsRuntimeFilterMapToNull() {
+ QueryContext qc = newQueryContext("SELECT col FROM t");
+ Assert.assertNull(qc.getRuntimeFilters(),
+ "QueryContext must default to null so non-runtime-filter queries pay zero allocation");
+ }
+
+ // ---------- Sanity: ordinary predicates still work alongside ----------
+
+ @Test
+ public void baseFilterPredicateIsPreservedVerbatim() {
+ // The injection must not touch the existing predicate object - downstream code may rely on
+ // identity (e.g., for predicate-evaluator caching keyed on the Predicate instance).
+ EqPredicate eq = new EqPredicate(ExpressionContext.forIdentifier("col"), "1");
+ FilterContext baseFilter = FilterContext.forPredicate(eq);
+ QueryContext qc = newQueryContext("SELECT col FROM t");
+ qc.setRuntimeFilters(singletonRuntimeFilters("col", DataType.INT, 99));
+
+ FilterContext result = FilterPlanNode.combineWithRuntimeFilters(baseFilter, qc);
+
+ Assert.assertNotNull(result);
+ Assert.assertEquals(result.getType(), FilterContext.Type.AND);
+ Assert.assertSame(result.getChildren().get(0), baseFilter);
+ Assert.assertSame(result.getChildren().get(0).getPredicate(), eq);
+ }
+
+ // ---------- helpers ----------
+
+ private static BloomRuntimeFilter newBloom(DataType dataType, Object addedValue) {
+ BloomRuntimeFilter bf = new BloomRuntimeFilter(dataType, EXPECTED_INSERTIONS, FPP);
+ switch (dataType) {
+ case INT:
+ bf.add((int) (Integer) addedValue);
+ break;
+ case LONG:
+ bf.add((long) (Long) addedValue);
+ break;
+ case STRING:
+ bf.add((String) addedValue);
+ break;
+ default:
+ throw new IllegalArgumentException("test helper does not handle " + dataType);
+ }
+ return bf;
+ }
+
+ private static Map singletonRuntimeFilters(String column, DataType dataType,
+ Object addedValue) {
+ return Collections.singletonMap(column, newBloom(dataType, addedValue));
+ }
+
+ private static void assertRuntimeFilterPredicateOnColumn(FilterContext ctx, String expectedColumn) {
+ Assert.assertEquals(ctx.getType(), FilterContext.Type.PREDICATE);
+ Assert.assertTrue(ctx.getPredicate() instanceof RuntimeFilterPredicate);
+ Assert.assertEquals(ctx.getPredicate().getLhs(), ExpressionContext.forIdentifier(expectedColumn));
+ }
+}