xiangfu0 commented on code in PR #19408:
URL: https://github.com/apache/pinot/pull/19408#discussion_r4105094375
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/OrDocIdSet.java:
##########
@@ -133,6 +138,82 @@ public long getNumEntriesScannedInFilter() {
return _numEntriesScannedInFilter + numEntriesScannedForScanBasedDocIdSets;
}
+ @Override
+ public boolean isScanBased() {
+ List<BlockDocIdSet> docIdSets = _docIdSets;
+ if (docIdSets == null) {
+ return false;
+ }
+ for (BlockDocIdSet docIdSet : docIdSets) {
+ if (docIdSet.isScanBased()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean isApplyAndDeferrable() {
+ return isScanBased();
+ }
+
+ /// Unions the branches, each restricted to the candidate document ids.
+ ///
+ /// A branch only has to look at the candidates no earlier branch has
matched yet, because
+ /// `(A OR B) AND S == (A AND S) OR (B AND (S MINUS (A AND S)))`. That
matters because the cost of a scan-based
+ /// branch is linear in the size of the candidate set it is given, so
handing every branch the full candidate set
+ /// would scan it once per branch.
+ @Override
+ public ImmutableRoaringBitmap applyAnd(ImmutableRoaringBitmap docIds) {
Review Comment:
Consider bounding the candidate set at entry, mirroring `NotDocIdSet`:
`docIds = NotDocIdSet.bound(docIds, _numDocs)` (or promote `bound` to a shared
helper).
NOT and MatchAll are bounded, but a bitmap **leaf** under this OR is reached
through the default `BlockDocIdSet#applyAnd`, which intersects the raw
`BitmapBasedDocIdIterator.getDocIds()` with no `[0, numDocs)` clamp. On the
lazy path this replaces, bitmap leaves are always clamped
(`BitmapDocIdIterator#next()`, and `iterator()` above wraps its merged bitmap
in `new BitmapDocIdIterator(docIds, _numDocs)`). So on a realtime segment whose
bitmaps grow past the query snapshot, a candidate id >= `numDocs` that also
appears in a branch's raw bitmap survives the push-down where master would drop
it. "An intersection cannot introduce such ids" is true, but it can *preserve*
ids the old iterator path would have excluded.
Racy realtime-only edge case behind a default-off flag, so not blocking -
but it is a one-line fix in exactly the contract area #15756 showed to be
fragile, and bounding here covers every leaf reached through an OR. A test with
a tail-carrying `BitmapDocIdSet` under a deferred OR would pin it.
(Minor, same method: the `docIds.isEmpty()` early return skips releasing the
branches. Unreachable from current callers - `AndDocIdSet` guards, and the loop
below never hands a branch an empty set - but cheap to make safe.)
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/NotDocIdSet.java:
##########
@@ -41,4 +43,46 @@ public BlockDocIdIterator iterator() {
public long getNumEntriesScannedInFilter() {
return _childDocIdSet.getNumEntriesScannedInFilter();
}
+
+ @Override
+ public boolean isScanBased() {
+ return _childDocIdSet.isScanBased();
+ }
+
+ @Override
+ public boolean isApplyAndDeferrable() {
+ return isScanBased();
+ }
+
+ @Override
+ public void release() {
+ _childDocIdSet.release();
+ }
+
+ @Override
+ public ImmutableRoaringBitmap applyAnd(ImmutableRoaringBitmap docIds) {
+ if (docIds.isEmpty()) {
+ return new MutableRoaringBitmap();
+ }
+ // NOTE: The candidate set can carry document ids beyond numDocs, because
BitmapDocIdIterator#getDocIds() hands out
+ // the raw bitmap while its next() stops at numDocs. An intersection
cannot introduce such ids, but a
+ // complement can, so the bound NotDocIdIterator applies has to be
applied here too.
+ ImmutableRoaringBitmap boundedDocIds = bound(docIds, _numDocs);
+ if (boundedDocIds.isEmpty()) {
Review Comment:
Both empty early returns in this method skip `_childDocIdSet.release()`. The
`docIds.isEmpty()` one is guarded by every current caller, but this
`boundedDocIds.isEmpty()` path is reachable (all candidates >= `numDocs`), and
the child - e.g. a scan whose iterator was built in its constructor - then
never gets its `ForwardIndexReaderContext` closed. Since `release()` exists
precisely for subtrees that are never evaluated, suggest calling it on both
paths.
##########
pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java:
##########
@@ -341,6 +342,25 @@ public static boolean
isAndScanReorderingEnabled(Map<String, String> queryOption
return
Boolean.parseBoolean(queryOptions.get(QueryOptionKey.AND_SCAN_REORDERING));
}
+ /// Per-query override of [AndRestrictionPushdownMode], or `null` when the
query does not set one, in which case
+ /// the server default applies.
+ @Nullable
+ public static AndRestrictionPushdownMode
getAndRestrictionPushdownMode(Map<String, String> queryOptions) {
+ String mode =
queryOptions.get(QueryOptionKey.AND_RESTRICTION_PUSHDOWN_MODE);
+ return mode != null ?
parseAndRestrictionPushdownMode(QueryOptionKey.AND_RESTRICTION_PUSHDOWN_MODE,
mode) : null;
+ }
+
+ /// Parses an [AndRestrictionPushdownMode], naming the offending key and the
legal values on failure. The option
+ /// reads like a toggle, so `true`/`false` is a likely mistake and the
message has to say so.
Review Comment:
Nit: the comment says the message has to call out that `true`/`false` is a
likely mistake, but the message below only lists the legal values. Either
extend the message (e.g. append "this option is a mode, not a boolean toggle")
or trim the comment. While here, deriving the list from
`AndRestrictionPushdownMode.values()` would keep it from drifting if a mode is
ever added.
##########
pinot-core/src/main/java/org/apache/pinot/core/common/BlockDocIdSet.java:
##########
@@ -52,6 +56,87 @@ default BlockDocIdSet getOptimizedDocIdSet() {
return this;
}
+ /// Returns whether evaluating this DocIdSet costs time proportional to the
number of documents it is asked about,
+ /// i.e. whether the subtree still has a scan- or expression-based predicate
left to evaluate.
+ ///
+ /// Only such a subtree gains anything from being handed a candidate set. An
index-only subtree produces the same
+ /// bitmap either way, so restricting it just adds intersection work. Note
this is a property of the *DocIdSet*, not
+ /// of the operator that built it: an operator that scans internally and
hands back a bitmap -- a non-exact range
+ /// index, an H3 index -- has already paid for the scan by the time its
DocIdSet exists, and correctly reports
+ /// `false`.
+ default boolean isScanBased() {
+ return false;
+ }
+
+ /// Returns whether a parent AND may defer calling [#iterator] on this
DocIdSet and reach it through [#applyAnd]
+ /// instead.
+ ///
+ /// True only for a composite DocIdSet (AND, OR, NOT) whose subtree is still
scan-based. Forwarding a candidate set
+ /// to its children is how a restriction from an enclosing AND reaches a
scan-based predicate nested inside an OR,
+ /// which would otherwise be evaluated against every document that branch
matches. A leaf is never deferrable: its
+ /// index lookup has already happened, and a scan-based leaf is already
reached through
+ /// [ScanBasedDocIdIterator#applyAnd] by the enclosing AND.
+ ///
+ /// This says nothing about whether [#applyAnd] may be called -- every
DocIdSet supports it.
+ default boolean isApplyAndDeferrable() {
+ return false;
+ }
+
+ /// Returns the document ids matching this DocIdSet that are also in
`docIds`, i.e. the intersection of this
+ /// DocIdSet with the given candidate set.
+ ///
+ /// `docIds` is never modified, so the same candidate set may be handed to
several DocIdSets. The returned bitmap
+ /// must not be modified by the caller either: it may be a view of a bitmap
the DocIdSet still owns.
+ ///
+ /// Like [ScanBasedDocIdIterator#applyAnd], this method consumes the
DocIdSet: call it at most once, and never
+ /// together with [#iterator]. [#getNumEntriesScannedInFilter] stays valid
afterwards.
+ default ImmutableRoaringBitmap applyAnd(ImmutableRoaringBitmap docIds) {
+ if (docIds.isEmpty()) {
Review Comment:
Same `release()` consideration as in the composites: for a scan-based
DocIdSet the iterator (and its `ForwardIndexReaderContext`) already exists by
the time this early return fires, and nothing closes it. All current callers
guard against empty candidates so this is latent, but calling `release()` here
instead of the bare return makes the contract self-enforcing rather than
convention-enforced.
##########
pinot-core/src/test/java/org/apache/pinot/core/plan/maker/AndRestrictionPushdownModeTest.java:
##########
@@ -0,0 +1,129 @@
+/**
+ * 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.maker;
+
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.pinot.core.query.request.context.QueryContext;
+import
org.apache.pinot.core.query.request.context.utils.QueryContextConverterUtils;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import
org.apache.pinot.spi.utils.CommonConstants.Broker.Request.QueryOptionKey;
+import org.apache.pinot.spi.utils.CommonConstants.Server;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+/// Tests how [InstancePlanMakerImplV2] resolves
[Server.AndRestrictionPushdownMode] into the per-query flag read by
+/// the AND filter operators.
+///
+/// Under `AUTO` the push-down is skipped for a selection query without ORDER
BY: it stops once it has LIMIT rows,
+/// while the push-down materializes the whole filter result. See
+/// [issue 19339](https://github.com/apache/pinot/issues/19339).
+public class AndRestrictionPushdownModeTest {
+ private static final String SELECTION_ONLY = "SELECT col1 FROM testTable
WHERE col2 = 1";
+ private static final String SELECTION_ORDER_BY = "SELECT col1 FROM testTable
WHERE col2 = 1 ORDER BY col1";
+ private static final String AGGREGATION = "SELECT SUM(col1) FROM testTable
WHERE col2 = 1";
+ private static final String GROUP_BY = "SELECT col1, SUM(col3) FROM
testTable WHERE col2 = 1 GROUP BY col1";
+ private static final String DISTINCT = "SELECT DISTINCT col1 FROM testTable
WHERE col2 = 1";
+
+ @Test
+ public void testDefaultIsNever() {
+ assertFalse(resolve(null, AGGREGATION), "The push-down ships disabled");
+ assertFalse(resolve(null, SELECTION_ONLY));
+ }
+
+ @Test
+ public void testAutoSkipsQueriesThatCanStopEarly() {
+ assertFalse(resolve("auto", SELECTION_ONLY), "A selection-only query stops
at its LIMIT");
+ assertFalse(resolve("auto", DISTINCT), "DistinctOperator stops once it has
LIMIT rows");
+ // Whether an ORDER BY selection can stop early is decided per segment, so
AUTO excludes it conservatively
+ assertFalse(resolve("auto", SELECTION_ORDER_BY));
+ assertFalse(resolve("auto", AGGREGATION + " LIMIT 0"), "LIMIT 0 reads
nothing");
+ }
+
+ @Test
+ public void testAutoAppliesToQueriesThatReadEveryMatchingDocument() {
+ assertTrue(resolve("auto", AGGREGATION));
+ assertTrue(resolve("auto", GROUP_BY));
+ }
+
+ @Test
+ public void testAlwaysAppliesEvenToQueriesThatCanStopEarly() {
+ assertTrue(resolve("always", SELECTION_ONLY));
+ assertTrue(resolve("always", DISTINCT));
+ }
+
+ @Test
+ public void testNeverSkipsEvenAggregationQuery() {
+ assertFalse(resolve("never", AGGREGATION));
+ }
+
+ @Test
+ public void testModeIsCaseInsensitive() {
+ assertTrue(resolve("AlWaYs", SELECTION_ONLY));
+ assertTrue(resolve("AuTo", AGGREGATION));
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class)
+ public void testInvalidQueryOptionValueIsRejected() {
+ // A boolean is the most likely mistake, since the option name reads like
a toggle
+ resolve("true", AGGREGATION);
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class)
+ public void testInvalidServerConfigValueIsRejected() {
+ planMakerWithServerDefault("sometimes");
+ }
+
+ @Test
+ public void testServerDefaultAppliesWithoutQueryOption() {
+ InstancePlanMakerImplV2 planMaker = planMakerWithServerDefault("always");
+ QueryContext queryContext =
QueryContextConverterUtils.getQueryContext(SELECTION_ONLY);
+ planMaker.applyQueryOptions(queryContext);
+ assertTrue(queryContext.isAndRestrictionPushdownEnabled(), "The server
default must apply");
+ }
+
+ @Test
+ public void testQueryOptionOverridesServerDefault() {
+ InstancePlanMakerImplV2 planMaker = planMakerWithServerDefault("always");
+ QueryContext queryContext =
QueryContextConverterUtils.getQueryContext(AGGREGATION);
+
queryContext.getQueryOptions().put(QueryOptionKey.AND_RESTRICTION_PUSHDOWN_MODE,
"never");
+ planMaker.applyQueryOptions(queryContext);
+ assertFalse(queryContext.isAndRestrictionPushdownEnabled(), "The query
option must win over the server default");
+ }
+
+ private static InstancePlanMakerImplV2 planMakerWithServerDefault(String
mode) {
+ InstancePlanMakerImplV2 planMaker = new InstancePlanMakerImplV2();
+ planMaker.init(new
PinotConfiguration(Map.of(Server.AND_RESTRICTION_PUSHDOWN_MODE, mode)));
+ return planMaker;
+ }
+
+ /// Resolves the flag for the given query, optionally setting the
`andRestrictionPushdown` query option. With no
+ /// option the server default (AUTO) applies.
Review Comment:
Nit: the server default is `NEVER` (as `testDefaultIsNever` asserts), not
`AUTO`.
--
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]