This is an automated email from the ASF dual-hosted git repository.

gortiz 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 4b7b358b9dd Push AND restrictions into composite filter children 
(#19408)
4b7b358b9dd is described below

commit 4b7b358b9dd9051ba238193ebe05d29c0da3cbf0
Author: Gonzalo Ortiz Jaureguizar <[email protected]>
AuthorDate: Fri Oct 9 19:15:51 2026 +0200

    Push AND restrictions into composite filter children (#19408)
    
    <!-- pinot-pr-flow:start -->
    
    ### PR flow
    
    Pushes AND restriction down to composite children and scans when enabled\.
    
    ```mermaid
    flowchart TD
      N0["InstancePlanMakerImplV2#46;applyQueryOptions #40;F18#41;"]:::stAdded
      N1["QueryContext#46;isAndRestrictionPushdownEnabled 
#40;F20#41;"]:::stAdded
      N2["AndFilterOperator constructor #40;F13#41;"]:::stModified
      N3["AndDocIdSet constructor #40;F4#41;"]:::stModified
      N4["AndDocIdSet#46;iterator#40;#41; #40;F4#41;"]:::stModified
      N5["AndDocIdSet#46;applyAnd#40;#41; #40;F4#41;"]:::stAdded
      N6["OrDocIdSet#46;applyAnd#40;#41; #40;F10#41;"]:::stModified
      N7["NotDocIdSet#46;applyAnd#40;#41; #40;F9#41;"]:::stModified
      N8["ScanBasedDocIdIterator#46;applyAnd#40;#41; #40;F3#41;"]:::stModified
      N0 -->|"sets flag from config#47;query option to QueryContext field"| N1
      N1 -->|"AndFilterOperator reads flag via getter"| N2
      N2 -->|"AndFilterOperator passes flag to AndDocIdSet constructor"| N3
      N3 -->|"AndDocIdSet#46;iterator checks #95;restrictionPushdownEnabled to 
#40;"| N4
      classDef stAdded 
fill:#dafbe1,stroke:#1a7f37,color:#1f2328,stroke-width:2px
      classDef stModified 
fill:#fff8c5,stroke:#9a6700,color:#1f2328,stroke-width:2px
      classDef stRemoved 
fill:#ffebe9,stroke:#cf222e,color:#1f2328,stroke-width:2px
      classDef stUnchanged 
fill:#f6f8fa,stroke:#656d76,color:#1f2328,stroke-width:1px
    ```
    
    AI-generated · Green: added · Yellow: modified · Red: removed · Gray: 
existing
    
    <details>
    <summary>Diff evidence</summary>
    
    - F3: 
pinot\-core/src/main/java/org/apache/pinot/core/operator/dociditerators/MVScanDocIdIterator\.java
 — 
[before](https://github.com/apache/pinot/blob/15b838a5df8d1cc34ab6302289defed31c7f44f9/pinot-core/src/main/java/org/apache/pinot/core/operator/dociditerators/MVScanDocIdIterator.java)
 · 
[after](https://github.com/apache/pinot/blob/0c87bb5ae77c1c08707ed05eede55567acf5447e/pinot-core/src/main/java/org/apache/pinot/core/operator/dociditerators/MVScanDocIdIterator.java)
    - F4: 
pinot\-core/src/main/java/org/apache/pinot/core/operator/docidsets/AndDocIdSet\.java
 — 
[before](https://github.com/apache/pinot/blob/15b838a5df8d1cc34ab6302289defed31c7f44f9/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/AndDocIdSet.java)
 · 
[after](https://github.com/apache/pinot/blob/0c87bb5ae77c1c08707ed05eede55567acf5447e/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/AndDocIdSet.java)
    - F9: 
pinot\-core/src/main/java/org/apache/pinot/core/operator/docidsets/NotDocIdSet\.java
 — 
[before](https://github.com/apache/pinot/blob/15b838a5df8d1cc34ab6302289defed31c7f44f9/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/NotDocIdSet.java)
 · 
[after](https://github.com/apache/pinot/blob/0c87bb5ae77c1c08707ed05eede55567acf5447e/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/NotDocIdSet.java)
    - F10: 
pinot\-core/src/main/java/org/apache/pinot/core/operator/docidsets/OrDocIdSet\.java
 — 
[before](https://github.com/apache/pinot/blob/15b838a5df8d1cc34ab6302289defed31c7f44f9/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/OrDocIdSet.java)
 · 
[after](https://github.com/apache/pinot/blob/0c87bb5ae77c1c08707ed05eede55567acf5447e/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/OrDocIdSet.java)
    - F13: 
pinot\-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator\.java
 — 
[before](https://github.com/apache/pinot/blob/15b838a5df8d1cc34ab6302289defed31c7f44f9/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator.java)
 · 
[after](https://github.com/apache/pinot/blob/0c87bb5ae77c1c08707ed05eede55567acf5447e/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator.java)
    - F18: 
pinot\-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2\.java
 — 
[before](https://github.com/apache/pinot/blob/15b838a5df8d1cc34ab6302289defed31c7f44f9/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java)
 · 
[after](https://github.com/apache/pinot/blob/0c87bb5ae77c1c08707ed05eede55567acf5447e/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java)
    - F20: 
pinot\-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext\.java
 — 
[before](https://github.com/apache/pinot/blob/15b838a5df8d1cc34ab6302289defed31c7f44f9/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java)
 · 
[after](https://github.com/apache/pinot/blob/0c87bb5ae77c1c08707ed05eede55567acf5447e/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java)
    
    </details>
    
    - [ ] Regenerate PR flow
    
    <!-- pinot-pr-flow:meta 
eyJiYXNlX3NoYSI6IjE1YjgzOGE1ZGY4ZDFjYzM0YWI2MzAyMjg5ZGVmZWQzMWM3ZjQ0ZjkiLCJjb250ZXh0X2hhc2giOiJkYjdhODRhM2NkMTU3MmYxYmY5ZGRjNjE2ZmExYjJiYmU5Y2NhMzAxOGI4MjgyMzMxZDU5ZDU4YzAyMmEzODg5IiwiZ2VuZXJhdGVkX2F0IjoxNzkwNjczMTQ3LCJoZWFkX3NoYSI6IjBjODdiYjVhZTc3YzFjMDg3MDdlZDA1ZWVkZTU1NTY3YWNmNTQ0N2UiLCJtb2RlbCI6Im52aWRpYS9uZW1vdHJvbi0zLXN1cGVyLTEyMGItYTEyYjpmcmVlIiwib2JzZXJ2ZWRfYmFzZV9zaGEiOiJlZjc1ZWQ5NzE0OGY1ZWIyOTRlNTQ0YjkxODVhMzQ4MzdjZDViMzU4IiwicG9saWN5IjoicGlub3QtcHItZ
 [...]
    <!-- pinot-pr-flow:signature 
3194e019799374a8f9b31d0b2e0cc39757184be42c3cbdcbe0c57351222b504c -->
    <!-- pinot-pr-flow:end -->
    
    Fixes #19339
    
    ## Problem
    
    When a filter has the shape `P AND (A OR B)`, the `(A OR B)` subtree is 
evaluated independently of `P`.
    
    `AndDocIdSet#iterator()` is the only place a restriction is ever pushed 
anywhere: it merges the index-based children into a bitmap and hands it to each 
`ScanBasedDocIdIterator` via `applyAnd`. `OrDocIdSet` has no equivalent, so a 
predicate nested inside an OR branch never learns about `P`.
    
    That has two consequences:
    
    - A predicate with no usable index inside an OR branch — `IN_SUBQUERY` 
evaluated through `ExpressionScanDocIdIterator`, or a plain scan on a segment 
missing its range index — is evaluated on every document its branch matches, 
however selective `P` is.
    - **Adding an index to a column inside an OR branch can make a query much 
slower.** With no index-based sibling the branch stays lazy and is driven by 
the outer merged bitmap; add one and the branch takes the eager path in 
`AndDocIdSet#iterator()`, materializing over the whole segment before the outer 
AND intersects. In one deployment the same query returning the same two rows 
went from ~500 to ~17,000,000 `numEntriesScannedInFilter`, allocating 4.6 GB, 
after two columns in the branch [...]
    
    The same gap has been reported twice before and both reports are still 
open: #10396 and #9402.
    
    ## Fix
    
    Generalize `applyAnd` from `ScanBasedDocIdIterator` up to `BlockDocIdSet`, 
so an AND can hand the document ids it has matched to its composite children:
    
    - `AndDocIdSet#applyAnd` adds the candidate set to its children as one more 
index-based child and runs `iterator()`. Everything `iterator()` already does — 
the sorted-range intersection, sorting bitmaps by cardinality, 
`AndScanReordering`, running every scan against the merged document ids — 
therefore applies to the restricted evaluation too, with a single 
implementation of AND.
    - `OrDocIdSet#applyAnd` unions the branches, each restricted to the 
candidates no earlier branch has matched, using `(A OR B) AND S == (A AND S) OR 
(B AND (S MINUS (A AND S)))`. A scan-based branch costs time linear in the 
candidate set it is handed, so narrowing avoids one full pass per branch.
    - `NotDocIdSet#applyAnd` returns the candidates the child does not match, 
so the child is only evaluated on the candidates.
    
    `AndDocIdSet#iterator()` defers `iterator()` on children that report 
`isApplyAndDeferrable()` and pushes its merged document ids into them instead. 
With no index-based child to seed the push-down it falls back to the lazy 
`AndDocIdIterator`, exactly as before.
    
    Deferral is limited to subtrees that still have a scan to restrict, via 
`BlockDocIdSet#isScanBased()`, which the composites compute over their 
children. An index-only subtree produces the same bitmap either way, so handing 
it a candidate set would only add an intersection per branch. Reading this off 
the DocIdSet rather than the operator is also more accurate: an operator that 
scans internally and returns a bitmap — a non-exact range index, an H3 index — 
has already paid for the scan  [...]
    
    ## This ships disabled
    
    The push-down **materializes** the filter result instead of streaming it, 
so a query that stops before reading every matching document ends up doing the 
full filter work. It is gated by `AndRestrictionPushdownMode`, defaulting to 
`NEVER`:
    
    | Mode | Behavior |
    |---|---|
    | `NEVER` (default) | Never push down. Provably identical to today. |
    | `ALWAYS` | Always push down. |
    | `AUTO` | Push down only for queries that read every matching document — 
aggregation and group-by. |
    
    `AUTO` excludes a selection query without ORDER BY (stops at its LIMIT), a 
DISTINCT query (`DistinctOperator` stops the same way, and DISTINCT is modelled 
as an aggregation function so it needs excluding explicitly), `LIMIT 0`, and — 
conservatively — a selection query *with* ORDER BY, because whether it can stop 
early is decided per segment in `SelectionPlanNode`, which picks 
`SelectionPartiallyOrderedBy*` from the segment's sorted column.
    
    - Server config: `pinot.server.query.executor.and.restriction.pushdown.mode`
    - Query option: `SET andRestrictionPushdownMode = 'always'`
    
    The mode is resolved once per query in 
`InstancePlanMakerImplV2#applyQueryOptions` and carried on `QueryContext`, so 
the filter operators only ever see the resolved flag.
    
    ## Benchmark
    
    `BenchmarkAndRestrictionPushdown` (new, in `pinot-perf`) measures 
wall-clock time, and allocation under `-prof gc`, because 
`numEntriesScannedInFilter` can fall while both of those rise. 2M documents, 
real `SVScanDocIdIterator` over a fixed-bit forward index. Ratios of push-down 
on to push-down off — lower is better:
    
    | shape | consume | sel | time | alloc |
    |---|---|---|---|---|
    | SCAN_IN_OR | DRAIN | 0.01 | **0.03x** | 0.10x |
    | SCAN_IN_OR | DRAIN | 0.5 | 0.67x | 1.14x |
    | SCAN_IN_OR | LIMIT | 0.01 | **0.02x** | 0.10x |
    | SCAN_IN_OR | LIMIT | 0.5 | 0.64x | 1.14x |
    | SCAN_ONLY_OR | DRAIN | 0.01 | 0.28x | 21.95x |
    | SCAN_ONLY_OR | DRAIN | 0.5 | 0.34x | 231.14x |
    | SCAN_ONLY_OR | LIMIT | 0.01 | **144.77x** | 25.04x |
    | SCAN_ONLY_OR | LIMIT | 0.5 | **9431.50x** | 383.65x |
    | INDEXED_OR | all four | | 0.99–1.02x | 1.00x |
    
    `SCAN_IN_OR` is a scan next to an index-based predicate inside an OR 
branch, the reported shape. `SCAN_ONLY_OR` is scans directly under the OR with 
no index-based sibling. `INDEXED_OR` has no scan anywhere.
    
    Three things to take from it:
    
    - **`INDEXED_OR` is neutral**, which is what restricting deferral to 
scan-bearing subtrees is for.
    - **The reported shape gains most where the enclosing AND is selective** — 
30 to 50 times — which is the production case.
    - **`SCAN_ONLY_OR` with a LIMIT is the shape `AUTO` exists to exclude, and 
it is far worse than an entries-scanned proxy suggests.** That OR is already 
lazy on master, so materializing it replaces a scan of a few dozen documents 
with a scan of the whole segment. This is the reason the default is `NEVER`, 
and it means `ALWAYS` is genuinely dangerous on a selection workload — it is 
not a "slightly more work" setting.
    
    ## Follow-up
    
    Delivering the restriction as a stream rather than a bitmap removes that 
cliff: worst case 3.27x instead of 9431x, allocation 1.33x instead of 384x. 
Spike with numbers in #19589. It does not remove the need for a mode — eager is 
still the better strategy for a full drain — but it changes what the mode 
chooses, and makes the excluded branch fast rather than merely safe.
    
    Pushing the restriction further down, into the range index itself via 
`RangeBitmap`'s context parameter (#7597, and @richardstartin's suggestion on 
the issue), is a separate layer again and is not addressed here.
    
    ## Test plan
    
    - `AndDocIdSetPushdownTest` — the reported shape (`AND(P, OR(AND(idx, 
scan), other))`): the scan visits 100 documents instead of 5000, with identical 
results. Plus the NOT case, the no-index-child fallback, sorted-range 
boundaries, the `numDocs` bound on NOT and MATCH-ALL, release of 
short-circuited OR branches, deferral limited to scan-bearing subtrees, and 200 
randomized filter trees over bitmap/scan/sorted/match-all/empty leaves 
asserting identical document ids with the push-down o [...]
    - `AndRestrictionPushdownModeTest` — mode resolution: every `AUTO` 
inclusion and exclusion, `ALWAYS`/`NEVER`, case-insensitivity, invalid values 
rejected, server default, and the query option overriding it.
    - `AndRestrictionPushdownQueriesTest` — end to end against a real segment 
in every mode: `ALWAYS` scans strictly fewer entries than `NEVER` with 
identical rows and `numDocsScanned`; `AUTO` matches `NEVER` for selections and 
`ALWAYS` for aggregations; the default is pinned to off. Covers filtered 
aggregations (`CombinedFilterOperator`) and null handling, where `getFalses()` 
wraps the push-down in a NOT over `OR(trues, nulls)`.
    - Full `org.apache.pinot.core.**` and `org.apache.pinot.queries.**` suites 
pass. No existing expectation changed, because the default leaves every 
existing query on its current plan.
    
    ## Release notes
    
    Two new user-facing configuration surfaces, both defaulting to the current 
behavior: the server config 
`pinot.server.query.executor.and.restriction.pushdown.mode` and the query 
option `andRestrictionPushdownMode`. Enabling either changes 
`numEntriesScannedInFilter` for affected queries. Old servers in a 
mixed-version cluster ignore the unknown query option, which matches the 
`NEVER` default.
---
 .../common/utils/config/QueryOptionsUtils.java     |  21 +
 .../apache/pinot/core/common/BlockDocIdSet.java    |  95 ++++-
 .../dociditerators/MVScanDocIdIterator.java        |  55 +--
 .../pinot/core/operator/docidsets/AndDocIdSet.java | 121 +++++-
 .../core/operator/docidsets/EmptyDocIdSet.java     |   7 +
 .../operator/docidsets/ExpressionDocIdSet.java     |   5 +
 .../core/operator/docidsets/MVScanDocIdSet.java    |   5 +
 .../core/operator/docidsets/MatchAllDocIdSet.java  |   7 +
 .../pinot/core/operator/docidsets/NotDocIdSet.java |  47 +++
 .../pinot/core/operator/docidsets/OrDocIdSet.java  |  90 +++-
 .../core/operator/docidsets/SVScanDocIdSet.java    |   5 +
 .../core/operator/filter/AndFilterOperator.java    |  12 +-
 .../operator/filter/BaseColumnFilterOperator.java  |   5 +-
 .../operator/filter/CombinedFilterOperator.java    |   7 +-
 .../core/operator/filter/FilterOperatorUtils.java  |   2 +-
 .../core/operator/filter/MapFilterOperator.java    |   7 +-
 .../core/plan/maker/InstancePlanMakerImplV2.java   |  41 ++
 .../function/AggregationFunctionUtils.java         |   3 +-
 .../core/query/request/context/QueryContext.java   |  14 +
 .../docidsets/AndDocIdSetPushdownTest.java         | 465 +++++++++++++++++++++
 .../plan/maker/AndRestrictionPushdownModeTest.java | 129 ++++++
 .../AndRestrictionPushdownNullQueriesTest.java     | 201 +++++++++
 .../queries/AndRestrictionPushdownQueriesTest.java | 109 +++++
 .../perf/BenchmarkAndRestrictionPushdown.java      | 208 +++++++++
 .../apache/pinot/spi/utils/CommonConstants.java    |  25 ++
 25 files changed, 1636 insertions(+), 50 deletions(-)

diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
index f5f8168ca5a..68b32d3b122 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
@@ -39,6 +39,7 @@ import org.apache.pinot.spi.utils.CommonConstants;
 import 
org.apache.pinot.spi.utils.CommonConstants.Broker.Request.QueryOptionKey;
 import 
org.apache.pinot.spi.utils.CommonConstants.MultiStageQueryRunner.JoinOverFlowMode;
 import 
org.apache.pinot.spi.utils.CommonConstants.MultiStageQueryRunner.WindowOverFlowMode;
+import 
org.apache.pinot.spi.utils.CommonConstants.Server.AndRestrictionPushdownMode;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -342,6 +343,26 @@ public class QueryOptionsUtils {
     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.
+  public static AndRestrictionPushdownMode 
parseAndRestrictionPushdownMode(String key, String value) {
+    try {
+      return 
AndRestrictionPushdownMode.valueOf(value.toUpperCase(Locale.ROOT));
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException(
+          String.format("Invalid value for %s: '%s'. Expected one of: %s (this 
option is a mode, not a boolean toggle)",
+              key, value, 
Arrays.toString(AndRestrictionPushdownMode.values())));
+    }
+  }
+
   public static boolean isSkipUpsert(Map<String, String> queryOptions) {
     return Boolean.parseBoolean(queryOptions.get(QueryOptionKey.SKIP_UPSERT));
   }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/common/BlockDocIdSet.java 
b/pinot-core/src/main/java/org/apache/pinot/core/common/BlockDocIdSet.java
index c960727dd46..f89cb43a56b 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/common/BlockDocIdSet.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/common/BlockDocIdSet.java
@@ -19,16 +19,20 @@
 package org.apache.pinot.core.common;
 
 import org.apache.pinot.core.operator.dociditerators.AndDocIdIterator;
+import org.apache.pinot.core.operator.dociditerators.BitmapBasedDocIdIterator;
 import org.apache.pinot.core.operator.dociditerators.BitmapDocIdIterator;
 import org.apache.pinot.core.operator.dociditerators.EmptyDocIdIterator;
 import org.apache.pinot.core.operator.dociditerators.OrDocIdIterator;
 import 
org.apache.pinot.core.operator.dociditerators.RangelessBitmapDocIdIterator;
 import org.apache.pinot.core.operator.dociditerators.ScanBasedDocIdIterator;
+import org.apache.pinot.core.operator.dociditerators.SortedDocIdIterator;
 import org.apache.pinot.core.operator.docidsets.BitmapDocIdSet;
 import org.apache.pinot.core.operator.docidsets.EmptyDocIdSet;
 import org.apache.pinot.core.operator.docidsets.RangelessBitmapDocIdSet;
 import org.apache.pinot.segment.spi.Constants;
+import org.apache.pinot.spi.utils.Pairs.IntPair;
 import org.roaringbitmap.RoaringBitmapWriter;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 import org.roaringbitmap.buffer.MutableRoaringBitmap;
 
 
@@ -48,6 +52,89 @@ public interface BlockDocIdSet {
   /// filtering phase. This method should be called after the filtering is 
done.
   long getNumEntriesScannedInFilter();
 
+  /// 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()) {
+      // Nothing is evaluated, so the iterator will not close itself
+      release();
+      return new MutableRoaringBitmap();
+    }
+    BlockDocIdIterator docIdIterator = iterator();
+    if (docIdIterator instanceof ScanBasedDocIdIterator) {
+      // The scan only visits the candidate documents, which is the whole 
point of the push-down
+      return ((ScanBasedDocIdIterator) docIdIterator).applyAnd(docIds);
+    }
+    if (docIdIterator instanceof BitmapBasedDocIdIterator) {
+      return ImmutableRoaringBitmap.and(((BitmapBasedDocIdIterator) 
docIdIterator).getDocIds(), docIds);
+    }
+    if (docIdIterator instanceof SortedDocIdIterator) {
+      MutableRoaringBitmap docIdsFromRanges = new MutableRoaringBitmap();
+      for (IntPair docIdRange : ((SortedDocIdIterator) 
docIdIterator).getDocIdRanges()) {
+        // NOTE: docIdRange has inclusive start and end.
+        docIdsFromRanges.add(docIdRange.getLeft(), docIdRange.getRight() + 1L);
+      }
+      docIdsFromRanges.and(docIds);
+      return docIdsFromRanges;
+    }
+    // Generic fallback: drive the iterator from the candidate set so that it 
is only asked about candidate documents
+    return collect(new AndDocIdIterator(
+        new BlockDocIdIterator[]{new RangelessBitmapDocIdIterator(docIds), 
docIdIterator}));
+  }
+
+  /// Releases a DocIdSet that will not be evaluated, e.g. an OR branch a 
short-circuit skipped past.
+  ///
+  /// Scan-based DocIdSets build their iterator in the constructor, and that 
iterator holds a
+  /// `ForwardIndexReaderContext` -- a direct ByteBuffer for a 
chunk-compressed column. Evaluation normally releases it
+  /// because the iterator closes itself on reaching EOF, so a DocIdSet that 
is never evaluated has to be released
+  /// explicitly. Calling this instead of evaluating never reads any data.
+  default void release() {
+    iterator().close();
+  }
+
+  /// Materializes the document ids remaining in the given iterator.
+  static MutableRoaringBitmap collect(BlockDocIdIterator docIdIterator) {
+    RoaringBitmapWriter<MutableRoaringBitmap> bitmapWriter =
+        RoaringBitmapWriter.bufferWriter().runCompress(false).get();
+    int docId;
+    while ((docId = docIdIterator.next()) != Constants.EOF) {
+      bitmapWriter.add(docId);
+    }
+    return bitmapWriter.get();
+  }
+
   /// For scan-based FilterBlockDocIdSet, pre-scans the documents and returns 
a non-scan-based FilterBlockDocIdSet.
   default BlockDocIdSet toNonScanDocIdSet() {
     BlockDocIdIterator docIdIterator = iterator();
@@ -55,13 +142,7 @@ public interface BlockDocIdSet {
     // TODO: This scan is not counted in the execution stats
     if (docIdIterator instanceof ScanBasedDocIdIterator || docIdIterator 
instanceof AndDocIdIterator
         || docIdIterator instanceof OrDocIdIterator) {
-      RoaringBitmapWriter<MutableRoaringBitmap> bitmapWriter =
-          RoaringBitmapWriter.bufferWriter().runCompress(false).get();
-      int docId;
-      while ((docId = docIdIterator.next()) != Constants.EOF) {
-        bitmapWriter.add(docId);
-      }
-      return RangelessBitmapDocIdSet.create(bitmapWriter.get());
+      return RangelessBitmapDocIdSet.create(collect(docIdIterator));
     }
 
     // NOTE: AND and OR DocIdSet might return BitmapBasedDocIdIterator after 
processing the iterators. Create a new
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/dociditerators/MVScanDocIdIterator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/dociditerators/MVScanDocIdIterator.java
index f9664b68adf..0dd7f99394e 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/dociditerators/MVScanDocIdIterator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/dociditerators/MVScanDocIdIterator.java
@@ -81,34 +81,39 @@ public final class MVScanDocIdIterator implements 
ScanBasedDocIdIterator {
 
   @Override
   public MutableRoaringBitmap applyAnd(BatchIterator docIdIterator, 
OptionalInt firstDoc, OptionalInt lastDoc) {
-    if (!docIdIterator.hasNext()) {
-      return new MutableRoaringBitmap();
-    }
-    RoaringBitmapWriter<MutableRoaringBitmap> result;
-    if (firstDoc.isPresent() && lastDoc.isPresent()) {
-      result = RoaringBitmapWriter.bufferWriter()
-          .expectedRange(firstDoc.getAsInt(), lastDoc.getAsInt())
-          .runCompress(false)
-          .get();
-    } else {
-      result = RoaringBitmapWriter.bufferWriter()
-          .runCompress(false)
-          .get();
-    }
-    int[] buffer = new int[OPTIMAL_ITERATOR_BATCH_SIZE];
-    while (docIdIterator.hasNext()) {
-      int limit = docIdIterator.nextBatch(buffer);
-      for (int i = 0; i < limit; i++) {
-        int nextDocId = buffer[i];
-        // TODO: The performance can be improved by batching the docID lookups 
similar to how it's done in
-        //       SVScanDocIdIterator
-        boolean doesValueMatch = _valueMatcher.doesValueMatch(nextDocId);
-        if (doesValueMatch) {
-          result.add(nextDocId);
+    // NOTE: Close the reader context once the candidates are consumed: unlike 
next(), this path never reaches EOF
+    try {
+      if (!docIdIterator.hasNext()) {
+        return new MutableRoaringBitmap();
+      }
+      RoaringBitmapWriter<MutableRoaringBitmap> result;
+      if (firstDoc.isPresent() && lastDoc.isPresent()) {
+        result = RoaringBitmapWriter.bufferWriter()
+            .expectedRange(firstDoc.getAsInt(), lastDoc.getAsInt())
+            .runCompress(false)
+            .get();
+      } else {
+        result = RoaringBitmapWriter.bufferWriter()
+            .runCompress(false)
+            .get();
+      }
+      int[] buffer = new int[OPTIMAL_ITERATOR_BATCH_SIZE];
+      while (docIdIterator.hasNext()) {
+        int limit = docIdIterator.nextBatch(buffer);
+        for (int i = 0; i < limit; i++) {
+          int nextDocId = buffer[i];
+          // TODO: The performance can be improved by batching the docID 
lookups similar to how it's done in
+          //       SVScanDocIdIterator
+          boolean doesValueMatch = _valueMatcher.doesValueMatch(nextDocId);
+          if (doesValueMatch) {
+            result.add(nextDocId);
+          }
         }
       }
+      return result.get();
+    } finally {
+      close();
     }
-    return result.get();
   }
 
   @Override
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/AndDocIdSet.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/AndDocIdSet.java
index 27db4f64709..0e60e537b11 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/AndDocIdSet.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/AndDocIdSet.java
@@ -18,6 +18,7 @@
  */
 package org.apache.pinot.core.operator.docidsets;
 
+import com.google.common.base.Preconditions;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
@@ -53,18 +54,37 @@ public final class AndDocIdSet implements BlockDocIdSet {
   // Keep the scan based BlockDocIdSets to be accessed when collecting query 
execution stats
   private final AtomicReference<List<BlockDocIdSet>> _scanBasedDocIdSets = new 
AtomicReference<>();
   private final boolean _cardinalityBasedRankingForScan;
+  private final boolean _restrictionPushdownEnabled;
   private List<BlockDocIdSet> _docIdSets;
   private volatile long _numEntriesScannedInFilter;
 
+  /// Creates an AndDocIdSet that does not push its matching document ids into 
its composite children.
   public AndDocIdSet(List<BlockDocIdSet> docIdSets, @Nullable Map<String, 
String> queryOptions) {
+    this(docIdSets, queryOptions, false);
+  }
+
+  /// @param restrictionPushdownEnabled whether the matching document ids may 
be handed to the composite (AND/OR/NOT)
+  ///                                   children. Resolved once per query from 
the `andRestrictionPushdown` query
+  ///                                   option and the server default, see 
`QueryContext`.
+  public AndDocIdSet(List<BlockDocIdSet> docIdSets, @Nullable Map<String, 
String> queryOptions,
+      boolean restrictionPushdownEnabled) {
     _docIdSets = docIdSets;
     _cardinalityBasedRankingForScan =
         queryOptions != null && 
QueryOptionsUtils.isAndScanReorderingEnabled(queryOptions);
+    _restrictionPushdownEnabled = restrictionPushdownEnabled;
   }
 
   @Override
   public BlockDocIdIterator iterator() {
-    int numDocIdSets = _docIdSets.size();
+    List<BlockDocIdSet> docIdSets = _docIdSets;
+    Preconditions.checkState(docIdSets != null, "iterator() called on an 
already consumed AndDocIdSet");
+    return buildIterator(docIdSets);
+  }
+
+  /// Builds the iterator over the given children and consumes this DocIdSet. 
[#applyAnd] passes the children with
+  /// the candidate set prepended, which is why they are not read from 
`_docIdSets` here.
+  private BlockDocIdIterator buildIterator(List<BlockDocIdSet> docIdSets) {
+    int numDocIdSets = docIdSets.size();
     // NOTE: Keep the order of BlockDocIdSets to preserve the order decided 
within FilterOperatorUtils.
     // TODO: Consider deciding the order based on the stats of 
BlockDocIdIterators
     BlockDocIdIterator[] allDocIdIterators = new 
BlockDocIdIterator[numDocIdSets];
@@ -72,11 +92,21 @@ public final class AndDocIdSet implements BlockDocIdSet {
     List<BitmapBasedDocIdIterator> bitmapBasedDocIdIterators = new 
ArrayList<>();
     List<ScanBasedDocIdIterator> scanBasedDocIdIterators = new ArrayList<>();
     List<BlockDocIdIterator> remainingDocIdIterators = new ArrayList<>();
+    // Composite (AND/OR/NOT) children whose evaluation is deferred so that 
the merged index document ids can be
+    // pushed into them. Their slot in allDocIdIterators stays null until the 
lazy fallback below fills it.
+    List<BlockDocIdSet> restrictableDocIdSets = new ArrayList<>();
     long numEntriesScannedForNonScanBasedDocIdSets = 0L;
     List<BlockDocIdSet> scanBasedDocIdSets = new ArrayList<>();
 
     for (int i = 0; i < numDocIdSets; i++) {
-      BlockDocIdSet docIdSet = _docIdSets.get(i);
+      BlockDocIdSet docIdSet = docIdSets.get(i);
+      if (_restrictionPushdownEnabled && docIdSet.isApplyAndDeferrable()) {
+        // NOTE: Do not call iterator() here. That evaluates the whole subtree 
over the segment, which is exactly what
+        //       the push-down below avoids.
+        restrictableDocIdSets.add(docIdSet);
+        scanBasedDocIdSets.add(docIdSet);
+        continue;
+      }
       BlockDocIdIterator docIdIterator = docIdSet.iterator();
       allDocIdIterators[i] = docIdIterator;
       if (docIdIterator instanceof SortedDocIdIterator) {
@@ -94,10 +124,12 @@ public final class AndDocIdSet implements BlockDocIdSet {
       }
     }
 
-    // Set _docIdSets to null so that underlying BlockDocIdSets can be garbage 
collected
-    _docIdSets = null;
+    // Publish the stats state before dropping the children, so that a 
concurrent reader of
+    // getNumEntriesScannedInFilter() never sees it disappear, then set 
_docIdSets to null so that the underlying
+    // BlockDocIdSets can be garbage collected
     _numEntriesScannedInFilter = numEntriesScannedForNonScanBasedDocIdSets;
     _scanBasedDocIdSets.set(scanBasedDocIdSets);
+    _docIdSets = null;
 
     // evaluate the bitmaps in the order of the lowest matching num docIds 
comes first, so that we minimize the number
     // of containers (range) for comparison from the beginning, as will 
minimize the effort of bitmap AND application
@@ -117,8 +149,10 @@ public final class AndDocIdSet implements BlockDocIdSet {
     int numBitmapBasedDocIdIterators = bitmapBasedDocIdIterators.size();
     int numScanBasedDocIdIterators = scanBasedDocIdIterators.size();
     int numRemainingDocIdIterators = remainingDocIdIterators.size();
+    int numRestrictableDocIdSets = restrictableDocIdSets.size();
     int numIndexBasedDocIdIterators = numSortedDocIdIterators + 
numBitmapBasedDocIdIterators;
-    if ((numIndexBasedDocIdIterators > 0 && numScanBasedDocIdIterators > 0) || 
numIndexBasedDocIdIterators > 1) {
+    if ((numIndexBasedDocIdIterators > 0 && (numScanBasedDocIdIterators > 0 || 
numRestrictableDocIdSets > 0))
+        || numIndexBasedDocIdIterators > 1) {
       // When there are at least one index-base BlockDocIdIterator 
(SortedDocIdIterator or BitmapBasedDocIdIterator)
       // and at least one ScanBasedDocIdIterator, or more than one index-based 
BlockDocIdIterator, merge them and
       // construct a RangelessBitmapDocIdIterator from the merged document 
ids. If there is no remaining
@@ -166,6 +200,18 @@ public final class AndDocIdSet implements BlockDocIdSet {
       for (ScanBasedDocIdIterator scanBasedDocIdIterator : 
scanBasedDocIdIterators) {
         docIds = scanBasedDocIdIterator.applyAnd(docIds);
       }
+      // Hand the matching document ids to the composite children, so that a 
scan-based predicate nested inside one of
+      // them (typically under an OR) is only evaluated on the documents that 
can still match this AND.
+      for (int i = 0; i < numRestrictableDocIdSets; i++) {
+        if (docIds.isEmpty()) {
+          // Nothing can match any more, but the remaining children still hold 
reader contexts
+          for (int j = i; j < numRestrictableDocIdSets; j++) {
+            restrictableDocIdSets.get(j).release();
+          }
+          break;
+        }
+        docIds = restrictableDocIdSets.get(i).applyAnd(docIds);
+      }
       RangelessBitmapDocIdIterator rangelessBitmapDocIdIterator = new 
RangelessBitmapDocIdIterator(docIds);
       if (numRemainingDocIdIterators == 0) {
         return rangelessBitmapDocIdIterator;
@@ -179,11 +225,64 @@ public final class AndDocIdSet implements BlockDocIdSet {
       }
     } else {
       // Otherwise, construct and return an AndDocIdIterator with all 
BlockDocIdIterators.
-
+      // There is no index-based document id set to seed the push-down with, 
so evaluate the deferred composite
+      // children now and let AndDocIdIterator drive them lazily, as it did 
before the push-down existed.
+      for (int i = 0; i < numDocIdSets; i++) {
+        if (allDocIdIterators[i] == null) {
+          allDocIdIterators[i] = docIdSets.get(i).iterator();
+        }
+      }
       return new AndDocIdIterator(allDocIdIterators);
     }
   }
 
+  @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();
+  }
+
+  /// Intersects this AND with the candidate set by handing the candidates to 
[#buildIterator] as one more
+  /// index-based child. Everything [#iterator] does -- merging the index 
children first, sorting bitmaps by
+  /// cardinality, 
[org.apache.pinot.common.utils.config.QueryOptionsUtils#isAndScanReorderingEnabled],
 running every
+  /// scan against the merged document ids -- then applies to the restricted 
evaluation too, with one implementation of
+  /// AND.
+  @Override
+  public ImmutableRoaringBitmap applyAnd(ImmutableRoaringBitmap docIds) {
+    List<BlockDocIdSet> docIdSets = _docIdSets;
+    Preconditions.checkState(docIdSets != null, "applyAnd() called on an 
already consumed AndDocIdSet");
+    if (docIds.isEmpty()) {
+      // No child is evaluated, so none of them will close its own iterator
+      for (BlockDocIdSet docIdSet : docIdSets) {
+        docIdSet.release();
+      }
+      _scanBasedDocIdSets.set(docIdSets);
+      _docIdSets = null;
+      return new MutableRoaringBitmap();
+    }
+    List<BlockDocIdSet> docIdSetsWithCandidates = new 
ArrayList<>(docIdSets.size() + 1);
+    docIdSetsWithCandidates.add(RangelessBitmapDocIdSet.create(docIds));
+    docIdSetsWithCandidates.addAll(docIdSets);
+    BlockDocIdIterator docIdIterator = buildIterator(docIdSetsWithCandidates);
+    // buildIterator() returns the merged document ids directly whenever it 
has no lazy child left, which is the usual
+    // case once the candidate set has forced the merge branch
+    return docIdIterator instanceof BitmapBasedDocIdIterator ? 
((BitmapBasedDocIdIterator) docIdIterator).getDocIds()
+        : BlockDocIdSet.collect(docIdIterator);
+  }
+
   @Override
   public long getNumEntriesScannedInFilter() {
     List<BlockDocIdSet> scanBasedDocIdSets = _scanBasedDocIdSets.get();
@@ -195,4 +294,14 @@ public final class AndDocIdSet implements BlockDocIdSet {
     }
     return _numEntriesScannedInFilter + numEntriesScannedForScanBasedDocIdSets;
   }
+
+  @Override
+  public void release() {
+    List<BlockDocIdSet> docIdSets = _docIdSets;
+    if (docIdSets != null) {
+      for (BlockDocIdSet docIdSet : docIdSets) {
+        docIdSet.release();
+      }
+    }
+  }
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/EmptyDocIdSet.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/EmptyDocIdSet.java
index ea08723cd8d..ae46e957081 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/EmptyDocIdSet.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/EmptyDocIdSet.java
@@ -20,6 +20,8 @@ package org.apache.pinot.core.operator.docidsets;
 
 import org.apache.pinot.core.common.BlockDocIdSet;
 import org.apache.pinot.core.operator.dociditerators.EmptyDocIdIterator;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
 
 
 /// A [BlockDocIdSet] that does not contain any document.
@@ -49,4 +51,9 @@ public final class EmptyDocIdSet implements BlockDocIdSet {
   public long getNumEntriesScannedInFilter() {
     return _numEntriesScannedInFilter;
   }
+
+  @Override
+  public ImmutableRoaringBitmap applyAnd(ImmutableRoaringBitmap docIds) {
+    return new MutableRoaringBitmap();
+  }
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/ExpressionDocIdSet.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/ExpressionDocIdSet.java
index 333a7ab14d3..db4610b1135 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/ExpressionDocIdSet.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/ExpressionDocIdSet.java
@@ -43,6 +43,11 @@ public final class ExpressionDocIdSet implements 
BlockDocIdSet {
     return _docIdIterator;
   }
 
+  @Override
+  public boolean isScanBased() {
+    return true;
+  }
+
   @Override
   public long getNumEntriesScannedInFilter() {
     return _docIdIterator.getNumEntriesScanned();
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/MVScanDocIdSet.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/MVScanDocIdSet.java
index f6807b79074..4ed5c18ced9 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/MVScanDocIdSet.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/MVScanDocIdSet.java
@@ -38,6 +38,11 @@ public final class MVScanDocIdSet implements BlockDocIdSet {
     return _docIdIterator;
   }
 
+  @Override
+  public boolean isScanBased() {
+    return true;
+  }
+
   @Override
   public long getNumEntriesScannedInFilter() {
     return _docIdIterator.getNumEntriesScanned();
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/MatchAllDocIdSet.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/MatchAllDocIdSet.java
index 497be02bd4f..d971982aebc 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/MatchAllDocIdSet.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/MatchAllDocIdSet.java
@@ -20,6 +20,7 @@ package org.apache.pinot.core.operator.docidsets;
 
 import org.apache.pinot.core.common.BlockDocIdSet;
 import org.apache.pinot.core.operator.dociditerators.MatchAllDocIdIterator;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 
 
 public final class MatchAllDocIdSet implements BlockDocIdSet {
@@ -38,4 +39,10 @@ public final class MatchAllDocIdSet implements BlockDocIdSet 
{
   public long getNumEntriesScannedInFilter() {
     return 0L;
   }
+
+  @Override
+  public ImmutableRoaringBitmap applyAnd(ImmutableRoaringBitmap docIds) {
+    // MatchAllDocIdIterator stops at numDocs, so the candidate set has to be 
bounded the same way
+    return NotDocIdSet.bound(docIds, _numDocs);
+  }
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/NotDocIdSet.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/NotDocIdSet.java
index d44b263c74a..65fdbe4ee07 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/NotDocIdSet.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/NotDocIdSet.java
@@ -21,6 +21,8 @@ package org.apache.pinot.core.operator.docidsets;
 import org.apache.pinot.core.common.BlockDocIdIterator;
 import org.apache.pinot.core.common.BlockDocIdSet;
 import org.apache.pinot.core.operator.dociditerators.NotDocIdIterator;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
 
 
 public final class NotDocIdSet implements BlockDocIdSet {
@@ -41,4 +43,49 @@ public final class NotDocIdSet implements BlockDocIdSet {
   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()) {
+      // The child is not evaluated, so it will not close its own iterator
+      _childDocIdSet.release();
+      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()) {
+      _childDocIdSet.release();
+      return new MutableRoaringBitmap();
+    }
+    // Within the candidate set, NOT(child) is the candidates the child does 
not match, so the child only has to be
+    // evaluated on the candidates: (NOT child) AND docIds == docIds MINUS 
(child AND docIds).
+    return ImmutableRoaringBitmap.andNot(boundedDocIds, 
_childDocIdSet.applyAnd(boundedDocIds));
+  }
+
+  /// Drops the document ids that are not below `numDocs`, without copying 
when there are none.
+  static ImmutableRoaringBitmap bound(ImmutableRoaringBitmap docIds, int 
numDocs) {
+    if (docIds.isEmpty() || docIds.last() < numDocs) {
+      return docIds;
+    }
+    MutableRoaringBitmap boundedDocIds = docIds.toMutableRoaringBitmap();
+    boundedDocIds.remove(numDocs, 0x1_0000_0000L);
+    return boundedDocIds;
+  }
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/OrDocIdSet.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/OrDocIdSet.java
index 2705a6d4e28..0ce14c22519 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/OrDocIdSet.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/OrDocIdSet.java
@@ -18,6 +18,7 @@
  */
 package org.apache.pinot.core.operator.docidsets;
 
+import com.google.common.base.Preconditions;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
@@ -28,6 +29,7 @@ import 
org.apache.pinot.core.operator.dociditerators.BitmapDocIdIterator;
 import org.apache.pinot.core.operator.dociditerators.OrDocIdIterator;
 import org.apache.pinot.core.operator.dociditerators.SortedDocIdIterator;
 import org.apache.pinot.spi.utils.Pairs;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 import org.roaringbitmap.buffer.MutableRoaringBitmap;
 
 
@@ -55,6 +57,7 @@ public final class OrDocIdSet implements BlockDocIdSet {
 
   @Override
   public BlockDocIdIterator iterator() {
+    Preconditions.checkState(_docIdSets != null, "iterator() called on an 
already consumed OrDocIdSet");
     int numDocIdSets = _docIdSets.size();
     BlockDocIdIterator[] allDocIdIterators = new 
BlockDocIdIterator[numDocIdSets];
     List<SortedDocIdIterator> sortedDocIdIterators = new ArrayList<>();
@@ -79,10 +82,12 @@ public final class OrDocIdSet implements BlockDocIdSet {
       }
     }
 
-    // Set _docIdSets to null so that underlying BlockDocIdSets can be garbage 
collected
-    _docIdSets = null;
+    // Publish the stats state before dropping the branches, so that a 
concurrent reader of
+    // getNumEntriesScannedInFilter() never sees it disappear, then set 
_docIdSets to null so that the underlying
+    // BlockDocIdSets can be garbage collected
     _numEntriesScannedInFilter = numEntriesScannedForNonScanBasedDocIdSets;
     _scanBasedDocIdSets.set(scanBasedDocIdSets);
+    _docIdSets = null;
 
     int numSortedDocIdIterators = sortedDocIdIterators.size();
     int numBitmapBasedDocIdIterators = bitmapBasedDocIdIterators.size();
@@ -132,4 +137,85 @@ public final class OrDocIdSet implements BlockDocIdSet {
     }
     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) {
+    List<BlockDocIdSet> docIdSets = _docIdSets;
+    Preconditions.checkState(docIdSets != null, "applyAnd() called on an 
already consumed OrDocIdSet");
+    // Publish the stats state before dropping the branches, so that a 
concurrent reader of
+    // getNumEntriesScannedInFilter() never sees it disappear. Branches left 
unevaluated by the short-circuit below
+    // report zero.
+    _scanBasedDocIdSets.set(docIdSets);
+    _docIdSets = null;
+    MutableRoaringBitmap docIdsToReturn = new MutableRoaringBitmap();
+    // NOTE: Bound the candidates like NotDocIdSet does. A bitmap branch is 
intersected through its raw bitmap, which
+    //       on a realtime segment can carry document ids beyond numDocs that 
the iterator() path would have dropped.
+    ImmutableRoaringBitmap boundedDocIds = NotDocIdSet.bound(docIds, _numDocs);
+    if (boundedDocIds.isEmpty()) {
+      // No branch is evaluated, so none of them will close its own iterator
+      releaseFrom(docIdSets, 0);
+      return docIdsToReturn;
+    }
+    int numCandidates = boundedDocIds.getCardinality();
+    int numDocIdSets = docIdSets.size();
+    ImmutableRoaringBitmap remainingDocIds = boundedDocIds;
+    for (int i = 0; i < numDocIdSets; i++) {
+      ImmutableRoaringBitmap matchingDocIds = 
docIdSets.get(i).applyAnd(remainingDocIds);
+      if (matchingDocIds.isEmpty()) {
+        continue;
+      }
+      docIdsToReturn.or(matchingDocIds);
+      if (docIdsToReturn.getCardinality() == numCandidates) {
+        // Every candidate is already matched, so the remaining branches 
cannot add anything. They still have to be
+        // released: their iterators were built eagerly and hold forward-index 
reader contexts.
+        releaseFrom(docIdSets, i + 1);
+        break;
+      }
+      if (i < numDocIdSets - 1) {
+        remainingDocIds = ImmutableRoaringBitmap.andNot(remainingDocIds, 
matchingDocIds);
+      }
+    }
+    return docIdsToReturn;
+  }
+
+  private static void releaseFrom(List<BlockDocIdSet> docIdSets, int 
fromIndex) {
+    for (int i = fromIndex; i < docIdSets.size(); i++) {
+      docIdSets.get(i).release();
+    }
+  }
+
+  @Override
+  public void release() {
+    List<BlockDocIdSet> docIdSets = _docIdSets;
+    if (docIdSets != null) {
+      for (BlockDocIdSet docIdSet : docIdSets) {
+        docIdSet.release();
+      }
+    }
+  }
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/SVScanDocIdSet.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/SVScanDocIdSet.java
index 2633f305d5e..4742c252c53 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/SVScanDocIdSet.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/docidsets/SVScanDocIdSet.java
@@ -38,6 +38,11 @@ public final class SVScanDocIdSet implements BlockDocIdSet {
     return _docIdIterator;
   }
 
+  @Override
+  public boolean isScanBased() {
+    return true;
+  }
+
   @Override
   public long getNumEntriesScannedInFilter() {
     return _docIdIterator.getNumEntriesScanned();
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator.java
index 3ea299d8a48..a82d59d9ada 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator.java
@@ -39,12 +39,19 @@ public class AndFilterOperator extends BaseFilterOperator {
 
   private final List<BaseFilterOperator> _filterOperators;
   private final Map<String, String> _queryOptions;
+  private final boolean _restrictionPushdownEnabled;
 
   public AndFilterOperator(List<BaseFilterOperator> filterOperators, @Nullable 
Map<String, String> queryOptions,
       int numDocs, boolean nullHandlingEnabled) {
+    this(filterOperators, queryOptions, numDocs, nullHandlingEnabled, false);
+  }
+
+  public AndFilterOperator(List<BaseFilterOperator> filterOperators, @Nullable 
Map<String, String> queryOptions,
+      int numDocs, boolean nullHandlingEnabled, boolean 
restrictionPushdownEnabled) {
     super(numDocs, nullHandlingEnabled);
     _filterOperators = filterOperators;
     _queryOptions = queryOptions;
+    _restrictionPushdownEnabled = restrictionPushdownEnabled;
   }
 
   @Override
@@ -66,7 +73,7 @@ public class AndFilterOperator extends BaseFilterOperator {
     if (blockDocIdSets.isEmpty()) {
       return new MatchAllDocIdSet(_numDocs);
     }
-    return new AndDocIdSet(blockDocIdSets, _queryOptions);
+    return new AndDocIdSet(blockDocIdSets, _queryOptions, 
_restrictionPushdownEnabled);
   }
 
   /// A conjunction is not false where no child is false: the intersection of 
the children's not-false documents.
@@ -86,7 +93,8 @@ public class AndFilterOperator extends BaseFilterOperator {
     if (notFalses.isEmpty()) {
       return new MatchAllDocIdSet(_numDocs);
     }
-    return notFalses.size() == 1 ? notFalses.get(0) : new 
AndDocIdSet(notFalses, _queryOptions);
+    return notFalses.size() == 1 ? notFalses.get(0)
+        : new AndDocIdSet(notFalses, _queryOptions, 
_restrictionPushdownEnabled);
   }
 
   @Override
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseColumnFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseColumnFilterOperator.java
index 16d30ed512a..dc2bc68f879 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseColumnFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseColumnFilterOperator.java
@@ -105,8 +105,11 @@ public abstract class BaseColumnFilterOperator extends 
BaseFilterOperator {
     BlockDocIdSet nonNulls = 
BitmapDocIdSet.create(ImmutableRoaringBitmap.flip(nullBitmap, 0, (long) 
_numDocs),
         _numDocs);
     if (nonNulls instanceof EmptyDocIdSet) {
+      // The match is never evaluated, so a scan-based one will not close its 
own iterator
+      blockDocIdSet.release();
       return new EmptyDocIdSet(blockDocIdSet.getNumEntriesScannedInFilter());
     }
-    return new AndDocIdSet(List.of(blockDocIdSet, nonNulls), 
_queryContext.getQueryOptions());
+    return new AndDocIdSet(List.of(blockDocIdSet, nonNulls), 
_queryContext.getQueryOptions(),
+        _queryContext.isAndRestrictionPushdownEnabled());
   }
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/CombinedFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/CombinedFilterOperator.java
index 52800c141aa..3d1b7f7f588 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/CombinedFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/CombinedFilterOperator.java
@@ -35,9 +35,10 @@ public class CombinedFilterOperator extends 
BaseFilterOperator {
   private final BaseFilterOperator _mainFilterOperator;
   private final BaseFilterOperator _subFilterOperator;
   private final Map<String, String> _queryOptions;
+  private final boolean _restrictionPushdownEnabled;
 
   public CombinedFilterOperator(BaseFilterOperator mainFilterOperator, 
BaseFilterOperator subFilterOperator,
-      Map<String, String> queryOptions) {
+      Map<String, String> queryOptions, boolean restrictionPushdownEnabled) {
     // This filter operator does not support AND/OR/NOT operations.
     super(0, false);
     assert !mainFilterOperator.isResultEmpty() && 
!mainFilterOperator.isResultMatchingAll()
@@ -45,6 +46,7 @@ public class CombinedFilterOperator extends 
BaseFilterOperator {
     _mainFilterOperator = mainFilterOperator;
     _subFilterOperator = subFilterOperator;
     _queryOptions = queryOptions;
+    _restrictionPushdownEnabled = restrictionPushdownEnabled;
   }
 
   @Override
@@ -65,6 +67,7 @@ public class CombinedFilterOperator extends 
BaseFilterOperator {
       return EmptyDocIdSet.unscanned();
     }
     BlockDocIdSet subFilterDocIdSet = 
_subFilterOperator.nextBlock().getBlockDocIdSet();
-    return new AndDocIdSet(Arrays.asList(mainFilterDocIdSet, 
subFilterDocIdSet), _queryOptions);
+    return new AndDocIdSet(Arrays.asList(mainFilterDocIdSet, 
subFilterDocIdSet), _queryOptions,
+        _restrictionPushdownEnabled);
   }
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/FilterOperatorUtils.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/FilterOperatorUtils.java
index 29b98784aef..d0757e5b1a9 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/FilterOperatorUtils.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/FilterOperatorUtils.java
@@ -175,7 +175,7 @@ public class FilterOperatorUtils {
         // Return the AND filter operator with re-ordered child filter 
operators
         reorderAndFilterChildOperators(queryContext, childFilterOperators);
         return new AndFilterOperator(childFilterOperators, 
queryContext.getQueryOptions(), numDocs,
-            queryContext.isNullHandlingEnabled());
+            queryContext.isNullHandlingEnabled(), 
queryContext.isAndRestrictionPushdownEnabled());
       }
     }
 
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/MapFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/MapFilterOperator.java
index b248684377b..3900a3be3c1 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/MapFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/MapFilterOperator.java
@@ -284,9 +284,10 @@ public class MapFilterOperator extends BaseFilterOperator {
   }
 
   /// Forwarded so three-valued logic survives the delegation. 
`NotFilterOperator.getTrues()` reads
-  /// `getFalses()`, and `And`/`OrFilterOperator.getFalses()` read 
`getNulls()`; leaving these on the
-  /// base implementation would report an absent key's UNKNOWN docs as FALSE, 
so
-  /// `NOT (col['key'] = v)` would match every doc missing the key.
+  /// `getFalses()`, and `And`/`OrFilterOperator.getNotFalses()` read their 
children's `getNotFalses()`;
+  /// leaving these on the base implementation would report an absent key's 
UNKNOWN docs as FALSE, so
+  /// `NOT (col['key'] = v)` would match every doc missing the key. 
`getNulls()` is forwarded too, so
+  /// that it stays consistent with the other three.
   @Override
   protected BlockDocIdSet getNulls() {
     return _delegate.getNulls();
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java
index 3a24e0ed51e..03199b367cb 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java
@@ -56,6 +56,7 @@ import org.apache.pinot.segment.spi.IndexSegment;
 import org.apache.pinot.segment.spi.SegmentContext;
 import org.apache.pinot.spi.env.PinotConfiguration;
 import org.apache.pinot.spi.utils.CommonConstants.Server;
+import 
org.apache.pinot.spi.utils.CommonConstants.Server.AndRestrictionPushdownMode;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -114,6 +115,8 @@ public class InstancePlanMakerImplV2 implements PlanMaker {
   private int _minSegmentGroupTrimSize = 
Server.DEFAULT_QUERY_EXECUTOR_MIN_SEGMENT_GROUP_TRIM_SIZE;
   private int _minServerGroupTrimSize = 
Server.DEFAULT_QUERY_EXECUTOR_MIN_SERVER_GROUP_TRIM_SIZE;
   private int _groupByTrimThreshold = 
Server.DEFAULT_QUERY_EXECUTOR_GROUPBY_TRIM_THRESHOLD;
+  private AndRestrictionPushdownMode _andRestrictionPushdownMode =
+      Server.DEFAULT_QUERY_EXECUTOR_AND_RESTRICTION_PUSHDOWN_MODE;
 
   @Override
   public void init(PinotConfiguration queryExecutorConfig) {
@@ -142,6 +145,9 @@ public class InstancePlanMakerImplV2 implements PlanMaker {
         Server.DEFAULT_QUERY_EXECUTOR_MIN_SERVER_GROUP_TRIM_SIZE);
     _groupByTrimThreshold = 
queryExecutorConfig.getProperty(Server.GROUPBY_TRIM_THRESHOLD,
         Server.DEFAULT_QUERY_EXECUTOR_GROUPBY_TRIM_THRESHOLD);
+    _andRestrictionPushdownMode = 
QueryOptionsUtils.parseAndRestrictionPushdownMode(
+        Server.AND_RESTRICTION_PUSHDOWN_MODE, 
queryExecutorConfig.getProperty(Server.AND_RESTRICTION_PUSHDOWN_MODE,
+            
Server.DEFAULT_QUERY_EXECUTOR_AND_RESTRICTION_PUSHDOWN_MODE.name()));
     Preconditions.checkState(_groupByTrimThreshold > 0,
         "Invalid configurable: groupByTrimThreshold: %d must be positive", 
_groupByTrimThreshold);
     LOGGER.info("Initialized plan maker with maxExecutionThreads: {}, 
defaultExecutionThreads: {}, "
@@ -231,6 +237,35 @@ public class InstancePlanMakerImplV2 implements PlanMaker {
         new InstanceResponsePlanNode(combinePlanNode, segmentContexts, 
fetchContexts, queryContext));
   }
 
+  /// Resolves [AndRestrictionPushdownMode] for a query.
+  ///
+  /// The push-down materializes the filter result instead of streaming it, so 
under AUTO it is only applied to a
+  /// query that is going to read every matching document anyway. That is 
exactly the aggregation and group-by
+  /// queries:
+  ///
+  /// - a selection query without ORDER BY stops as soon as it has LIMIT rows;
+  /// - a DISTINCT query without ORDER BY stops the same way, in 
`DistinctOperator#getNextBlock`;
+  /// - whether a selection query *with* ORDER BY can stop early is decided 
per segment, because
+  ///   [org.apache.pinot.core.plan.SelectionPlanNode] picks 
`SelectionPartiallyOrderedBy*` from the segment's sorted
+  ///   column, so it cannot be ruled out here and is excluded conservatively;
+  /// - LIMIT 0 reads nothing at all.
+  ///
+  /// Use ALWAYS to push down on a workload known not to terminate early.
+  private static boolean 
isAndRestrictionPushdownEnabled(AndRestrictionPushdownMode mode, QueryContext 
queryContext) {
+    switch (mode) {
+      case ALWAYS:
+        return true;
+      case NEVER:
+        return false;
+      case AUTO:
+        // NOTE: DISTINCT is modelled as an aggregation function, so it has to 
be excluded explicitly
+        return queryContext.getLimit() > 0 && 
QueryContextUtils.isAggregationQuery(queryContext)
+            && !QueryContextUtils.isDistinctQuery(queryContext);
+      default:
+        throw new IllegalStateException("Unsupported AND restriction push-down 
mode: " + mode);
+    }
+  }
+
   @VisibleForTesting
   void applyQueryOptions(QueryContext queryContext) {
     Map<String, String> queryOptions = queryContext.getQueryOptions();
@@ -248,6 +283,12 @@ public class InstancePlanMakerImplV2 implements PlanMaker {
     // Set skipScanFilterReorder
     
queryContext.setSkipScanFilterReorder(QueryOptionsUtils.isSkipScanFilterReorder(queryOptions));
 
+    // Set andRestrictionPushdown, letting the query option override the 
server default
+    AndRestrictionPushdownMode andRestrictionPushdownMode =
+        QueryOptionsUtils.getAndRestrictionPushdownMode(queryOptions);
+    
queryContext.setAndRestrictionPushdownEnabled(isAndRestrictionPushdownEnabled(
+        andRestrictionPushdownMode != null ? andRestrictionPushdownMode : 
_andRestrictionPushdownMode, queryContext));
+
     
queryContext.setSkipIndexes(QueryOptionsUtils.getSkipIndexes(queryOptions));
 
     // Set maxExecutionThreads
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionUtils.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionUtils.java
index 4f96018d661..fb59e4dd00e 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionUtils.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionUtils.java
@@ -508,7 +508,8 @@ public class AggregationFunctionUtils {
             combinedFilterOperator = mainFilterOperator;
           } else {
             combinedFilterOperator =
-                new CombinedFilterOperator(mainFilterOperator, 
subFilterOperator, queryContext.getQueryOptions());
+                new CombinedFilterOperator(mainFilterOperator, 
subFilterOperator, queryContext.getQueryOptions(),
+                    queryContext.isAndRestrictionPushdownEnabled());
           }
 
           List<Pair<Predicate, PredicateEvaluator>> subPredicateEvaluators = 
subFilterPlan.getPredicateEvaluators();
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 41c12e6adce..83fd2ef62eb 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
@@ -103,6 +103,9 @@ public class QueryContext {
   private boolean _skipStarTree;
   // Whether to skip reordering scan filters for the query
   private boolean _skipScanFilterReorder;
+  // Whether an AND may push the document ids it has matched into its 
composite (AND/OR/NOT) children.
+  // Resolved from the query option and the server default by 
InstancePlanMakerImplV2#applyQueryOptions.
+  private boolean _andRestrictionPushdownEnabled;
   // Maximum number of threads used to execute the query
   private int _maxExecutionThreads = 
Server.DEFAULT_QUERY_EXECUTOR_MAX_EXECUTION_THREADS;
   // The following properties apply to group-by queries
@@ -400,6 +403,17 @@ public class QueryContext {
     _skipScanFilterReorder = skipScanFilterReorder;
   }
 
+  /// Whether an AND may hand the document ids matched by its index-based 
children to its composite (AND/OR/NOT)
+  /// children, so that a scan-based predicate nested inside an OR is only 
evaluated on the documents that can still
+  /// match. See 
[org.apache.pinot.spi.utils.CommonConstants.Server.AndRestrictionPushdownMode].
+  public boolean isAndRestrictionPushdownEnabled() {
+    return _andRestrictionPushdownEnabled;
+  }
+
+  public void setAndRestrictionPushdownEnabled(boolean 
andRestrictionPushdownEnabled) {
+    _andRestrictionPushdownEnabled = andRestrictionPushdownEnabled;
+  }
+
   public int getMaxExecutionThreads() {
     return _maxExecutionThreads;
   }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/docidsets/AndDocIdSetPushdownTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/docidsets/AndDocIdSetPushdownTest.java
new file mode 100644
index 00000000000..21f39678db7
--- /dev/null
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/docidsets/AndDocIdSetPushdownTest.java
@@ -0,0 +1,465 @@
+/**
+ * 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.operator.docidsets;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.OptionalInt;
+import java.util.Random;
+import org.apache.pinot.core.common.BlockDocIdIterator;
+import org.apache.pinot.core.common.BlockDocIdSet;
+import 
org.apache.pinot.core.operator.dociditerators.RangelessBitmapDocIdIterator;
+import org.apache.pinot.core.operator.dociditerators.ScanBasedDocIdIterator;
+import org.apache.pinot.segment.spi.Constants;
+import org.apache.pinot.spi.utils.Pairs.IntPair;
+import org.roaringbitmap.BatchIterator;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+/// Unit test for the restriction push-down: an AND hands the document ids 
matched by its index-based children to its
+/// composite (AND/OR/NOT) children, so that a scan-based predicate nested 
inside one of them is only evaluated on the
+/// documents that can still match.
+///
+/// See [issue 19339](https://github.com/apache/pinot/issues/19339).
+public class AndDocIdSetPushdownTest {
+  private static final int NUM_DOCS = 10000;
+  private static final long RANDOM_SEED = System.currentTimeMillis();
+  private static final String ERROR_MESSAGE = "Random seed: " + RANDOM_SEED;
+
+  /// The shape reported in the issue: a scan-based predicate sits in an OR 
branch next to an index-based predicate.
+  /// That makes the branch take the eager path in [AndDocIdSet#iterator], 
materializing it over the whole segment and
+  /// evaluating the scan on every document its sibling matches, regardless of 
how selective the enclosing AND is.
+  @Test
+  public void testScanNestedInsideOrBranchIsRestrictedByEnclosingAnd() {
+    ImmutableRoaringBitmap selective = range(0, 100);
+    ImmutableRoaringBitmap indexedInBranch = range(0, 5000);
+    ImmutableRoaringBitmap otherBranch = range(9000, 9010);
+    ImmutableRoaringBitmap scanMatches = multiplesOf(3);
+
+    // Expected: selective AND ((indexedInBranch AND scanMatches) OR 
otherBranch)
+    MutableRoaringBitmap expected = MutableRoaringBitmap.and(indexedInBranch, 
scanMatches);
+    expected.or(otherBranch);
+    expected.and(selective);
+
+    CountingScanDocIdSet pushedDownScan = new 
CountingScanDocIdSet(scanMatches);
+    BlockDocIdSet withPushdown = orBranchTree(true, pushedDownScan, selective, 
indexedInBranch, otherBranch);
+    assertEquals(collectDocIds(withPushdown), expected.toArray());
+    long scannedWithPushdown = pushedDownScan.getNumEntriesScannedInFilter();
+    assertEquals(scannedWithPushdown, selective.getCardinality(),
+        "The scan must only visit the documents matching the enclosing AND");
+    assertEquals(withPushdown.getNumEntriesScannedInFilter(), 
scannedWithPushdown,
+        "Entries scanned inside the OR must be reported by the enclosing AND");
+
+    CountingScanDocIdSet unrestrictedScan = new 
CountingScanDocIdSet(scanMatches);
+    BlockDocIdSet withoutPushdown =
+        orBranchTree(false, unrestrictedScan, selective, indexedInBranch, 
otherBranch);
+    assertEquals(collectDocIds(withoutPushdown), expected.toArray(),
+        "Disabling the push-down must not change the result");
+    long scannedWithoutPushdown = 
unrestrictedScan.getNumEntriesScannedInFilter();
+    assertEquals(scannedWithoutPushdown, indexedInBranch.getCardinality(),
+        "Without the push-down the scan visits every document matching its 
sibling index predicate");
+    assertTrue(scannedWithPushdown < scannedWithoutPushdown,
+        "The push-down must reduce the number of scanned entries");
+  }
+
+  /// AND(selective, NOT(scan)): the scan only has to be evaluated on the 
candidates, because
+  /// `NOT(child) AND candidates == candidates MINUS (child AND candidates)`.
+  @Test
+  public void testScanUnderNotIsRestrictedByEnclosingAnd() {
+    ImmutableRoaringBitmap selective = range(0, 100);
+    ImmutableRoaringBitmap scanMatches = multiplesOf(3);
+
+    MutableRoaringBitmap expected = selective.toMutableRoaringBitmap();
+    expected.andNot(scanMatches);
+
+    CountingScanDocIdSet scan = new CountingScanDocIdSet(scanMatches);
+    BlockDocIdSet docIdSet = new AndDocIdSet(
+        List.of(BitmapDocIdSet.create(selective, NUM_DOCS), new 
NotDocIdSet(scan, NUM_DOCS)), null, true);
+
+    assertEquals(collectDocIds(docIdSet), expected.toArray());
+    assertEquals(scan.getNumEntriesScannedInFilter(), 
selective.getCardinality(),
+        "The scan under the NOT must only visit the documents matching the 
enclosing AND");
+  }
+
+  /// With no index-based child there is nothing to seed the push-down with, 
so the AND must fall back to the lazy
+  /// iterator over all of its children, including the deferred composite ones.
+  @Test
+  public void testFallsBackToLazyPathWithoutIndexBasedChild() {
+    ImmutableRoaringBitmap firstBranch = range(0, 50);
+    ImmutableRoaringBitmap secondBranch = range(40, 90);
+    ImmutableRoaringBitmap scanMatches = multiplesOf(3);
+
+    MutableRoaringBitmap expected = firstBranch.toMutableRoaringBitmap();
+    expected.or(secondBranch);
+    expected.and(scanMatches);
+
+    // One scan-based branch keeps the OR deferrable, and without an 
index-based child the outer AND has no seed
+    BlockDocIdSet or = new OrDocIdSet(
+        List.of(BitmapDocIdSet.create(firstBranch, NUM_DOCS), new 
CountingScanDocIdSet(secondBranch)), NUM_DOCS);
+    assertTrue(or.isApplyAndDeferrable());
+    BlockDocIdSet docIdSet = new AndDocIdSet(List.of(new 
CountingScanDocIdSet(scanMatches), or), null, true);
+
+    assertEquals(collectDocIds(docIdSet), expected.toArray());
+  }
+
+  /// The push-down must never change the matching documents, whatever the 
shape of the filter tree.
+  @Test
+  public void testRandomTreesMatchTheSameDocumentsWithAndWithoutPushdown() {
+    for (int i = 0; i < 200; i++) {
+      long seed = RANDOM_SEED + i;
+      BlockDocIdSet withPushdown = randomTree(new Random(seed), 3, true);
+      BlockDocIdSet withoutPushdown = randomTree(new Random(seed), 3, false);
+      assertEquals(collectDocIds(withPushdown), 
collectDocIds(withoutPushdown), ERROR_MESSAGE + ", iteration: " + i);
+    }
+  }
+
+  /// The candidate set can carry document ids beyond numDocs, because 
`BitmapDocIdIterator#getDocIds()` hands out the
+  /// raw bitmap while its `next()` stops at numDocs. An AND cannot introduce 
such ids, but a NOT can, so the bound has
+  /// to survive the push-down.
+  @Test
+  public void testNotDoesNotEmitDocumentIdsBeyondNumDocs() {
+    int numDocs = 100;
+    // The candidate bitmap deliberately reaches past numDocs, as a live 
mutable-segment bitmap can
+    MutableRoaringBitmap candidates = new MutableRoaringBitmap();
+    candidates.add(0L, 150L);
+    BlockDocIdSet child = 
BitmapDocIdSet.create(MutableRoaringBitmap.bitmapOf(1, 2, 3), numDocs);
+
+    ImmutableRoaringBitmap docIds = new NotDocIdSet(child, 
numDocs).applyAnd(candidates);
+
+    assertTrue(docIds.isEmpty() || docIds.last() < numDocs,
+        "NOT must not emit document ids at or beyond numDocs, but emitted " + 
docIds.last());
+    assertEquals(docIds.getCardinality(), numDocs - 3);
+    // The iterator path is the reference implementation: both must agree
+    assertEquals(docIds.toArray(), collectDocIds(new AndDocIdSet(
+        List.of(BitmapDocIdSet.create(candidates, numDocs), new NotDocIdSet(
+            BitmapDocIdSet.create(MutableRoaringBitmap.bitmapOf(1, 2, 3), 
numDocs), numDocs)), null, false)));
+  }
+
+  @Test
+  public void testMatchAllDoesNotEmitDocumentIdsBeyondNumDocs() {
+    int numDocs = 100;
+    MutableRoaringBitmap candidates = new MutableRoaringBitmap();
+    candidates.add(0L, 150L);
+
+    ImmutableRoaringBitmap docIds = new 
MatchAllDocIdSet(numDocs).applyAnd(candidates);
+
+    assertEquals(docIds.getCardinality(), numDocs);
+    assertTrue(docIds.last() < numDocs);
+  }
+
+  /// The OR short-circuits once every candidate is matched. The branches it 
skips were never evaluated, so nothing
+  /// closes the iterators they built in their constructors.
+  @Test
+  public void testShortCircuitedOrBranchesAreReleased() {
+    ImmutableRoaringBitmap candidates = range(0, 100);
+    // The first branch matches every candidate, so the second is never 
evaluated
+    ReleaseTrackingDocIdSet skipped = new ReleaseTrackingDocIdSet(range(0, 
100));
+    BlockDocIdSet or =
+        new OrDocIdSet(List.of(BitmapDocIdSet.create(range(0, 100), NUM_DOCS), 
skipped), NUM_DOCS);
+
+    or.applyAnd(candidates);
+
+    assertTrue(skipped.isReleased(), "A branch skipped by the short-circuit 
must still be released");
+  }
+
+  /// A bitmap branch under an OR is intersected through its raw bitmap, which 
can reach past numDocs just like the
+  /// candidate set can. The iterator path drops those ids, so the push-down 
has to drop them too.
+  @Test
+  public void testOrDoesNotEmitDocumentIdsBeyondNumDocs() {
+    int numDocs = 100;
+    MutableRoaringBitmap candidates = new MutableRoaringBitmap();
+    candidates.add(0L, 150L);
+
+    ImmutableRoaringBitmap docIds = new OrDocIdSet(
+        List.of(BitmapDocIdSet.create(range(90, 150), numDocs), new 
CountingScanDocIdSet(range(0, 10))), numDocs)
+        .applyAnd(candidates);
+
+    assertTrue(docIds.last() < numDocs, "OR must not emit document ids at or 
beyond numDocs, but emitted "
+        + docIds.last());
+    // The iterator path is the reference implementation: both must agree
+    assertEquals(docIds.toArray(), collectDocIds(new 
AndDocIdSet(List.of(BitmapDocIdSet.create(candidates, numDocs),
+        new OrDocIdSet(List.of(BitmapDocIdSet.create(range(90, 150), numDocs), 
new CountingScanDocIdSet(range(0, 10))),
+            numDocs)), null, false)));
+  }
+
+  /// A DocIdSet handed no candidate is never evaluated, so it has to be 
released instead.
+  @Test
+  public void testDocIdSetsWithoutCandidatesAreReleased() {
+    MutableRoaringBitmap noCandidates = new MutableRoaringBitmap();
+
+    ReleaseTrackingDocIdSet leaf = new ReleaseTrackingDocIdSet(range(0, 10));
+    leaf.applyAnd(noCandidates);
+    assertTrue(leaf.isReleased(), "A leaf handed no candidate must be 
released");
+
+    ReleaseTrackingDocIdSet orBranch = new ReleaseTrackingDocIdSet(range(0, 
10));
+    new OrDocIdSet(List.of(orBranch), NUM_DOCS).applyAnd(noCandidates);
+    assertTrue(orBranch.isReleased(), "An OR branch must be released when the 
OR is handed no candidate");
+
+    // Every candidate is at or beyond numDocs, so the NOT drops them all 
before reaching its child
+    int numDocs = 100;
+    MutableRoaringBitmap candidatesBeyondNumDocs = new MutableRoaringBitmap();
+    candidatesBeyondNumDocs.add(100L, 150L);
+    ReleaseTrackingDocIdSet notChild = new ReleaseTrackingDocIdSet(range(0, 
10));
+    new NotDocIdSet(notChild, numDocs).applyAnd(candidatesBeyondNumDocs);
+    assertTrue(notChild.isReleased(), "A NOT child must be released when no 
candidate is below numDocs");
+  }
+
+  private static final class ReleaseTrackingDocIdSet implements BlockDocIdSet {
+    private final ImmutableRoaringBitmap _docIds;
+    private boolean _released;
+
+    private ReleaseTrackingDocIdSet(ImmutableRoaringBitmap docIds) {
+      _docIds = docIds;
+    }
+
+    private boolean isReleased() {
+      return _released;
+    }
+
+    @Override
+    public BlockDocIdIterator iterator() {
+      return new RangelessBitmapDocIdIterator(_docIds);
+    }
+
+    @Override
+    public long getNumEntriesScannedInFilter() {
+      return 0L;
+    }
+
+    @Override
+    public void release() {
+      _released = true;
+    }
+  }
+
+  /// Deferring a composite child is only worth it when the subtree still has 
a scan to restrict. An index-only OR
+  /// produces the same bitmap either way, so deferring it would only add an 
intersection per branch.
+  @Test
+  public void testOnlyScanBearingSubtreesAreDeferred() {
+    BlockDocIdSet indexOnlyOr = new OrDocIdSet(
+        List.of(BitmapDocIdSet.create(range(0, 10), NUM_DOCS), 
BitmapDocIdSet.create(range(20, 30), NUM_DOCS)),
+        NUM_DOCS);
+    assertFalse(indexOnlyOr.isApplyAndDeferrable(), "An index-only OR gains 
nothing from a candidate set");
+
+    BlockDocIdSet scanBearingOr = new OrDocIdSet(
+        List.of(BitmapDocIdSet.create(range(0, 10), NUM_DOCS), new 
CountingScanDocIdSet(range(20, 30))), NUM_DOCS);
+    assertTrue(scanBearingOr.isApplyAndDeferrable());
+
+    // The scan two levels down still counts: that is the shape from the issue
+    BlockDocIdSet nested = new 
OrDocIdSet(List.of(BitmapDocIdSet.create(range(0, 10), NUM_DOCS),
+        new AndDocIdSet(List.of(BitmapDocIdSet.create(range(20, 30), NUM_DOCS),
+            new CountingScanDocIdSet(range(20, 30))), null, true)), NUM_DOCS);
+    assertTrue(nested.isApplyAndDeferrable());
+
+    assertFalse(new NotDocIdSet(BitmapDocIdSet.create(range(0, 10), NUM_DOCS), 
NUM_DOCS).isApplyAndDeferrable());
+    assertTrue(new NotDocIdSet(new CountingScanDocIdSet(range(0, 10)), 
NUM_DOCS).isApplyAndDeferrable());
+  }
+
+  private static BlockDocIdSet orBranchTree(boolean pushdownEnabled, 
BlockDocIdSet scan,
+      ImmutableRoaringBitmap selective, ImmutableRoaringBitmap 
indexedInBranch, ImmutableRoaringBitmap otherBranch) {
+    BlockDocIdSet branch =
+        new AndDocIdSet(List.of(BitmapDocIdSet.create(indexedInBranch, 
NUM_DOCS), scan), null, pushdownEnabled);
+    BlockDocIdSet or = new OrDocIdSet(List.of(branch, 
BitmapDocIdSet.create(otherBranch, NUM_DOCS)), NUM_DOCS);
+    return new AndDocIdSet(List.of(BitmapDocIdSet.create(selective, NUM_DOCS), 
or), null, pushdownEnabled);
+  }
+
+  /// Builds a random filter tree. Two calls with equally seeded [Random] 
instances build the same tree, which is what
+  /// lets the same tree be evaluated with and without the push-down.
+  private static BlockDocIdSet randomTree(Random random, int depth, boolean 
pushdownEnabled) {
+    if (depth == 0) {
+      return randomLeaf(random);
+    }
+    switch (random.nextInt(5)) {
+      case 0: {
+        List<BlockDocIdSet> children = randomChildren(random, depth, 
pushdownEnabled);
+        return new AndDocIdSet(children, null, pushdownEnabled);
+      }
+      case 1: {
+        List<BlockDocIdSet> children = randomChildren(random, depth, 
pushdownEnabled);
+        return new OrDocIdSet(children, NUM_DOCS);
+      }
+      case 2:
+        return new NotDocIdSet(randomTree(random, depth - 1, pushdownEnabled), 
NUM_DOCS);
+      default:
+        return randomLeaf(random);
+    }
+  }
+
+  private static List<BlockDocIdSet> randomChildren(Random random, int depth, 
boolean pushdownEnabled) {
+    int numChildren = 2 + random.nextInt(2);
+    List<BlockDocIdSet> children = new ArrayList<>(numChildren);
+    for (int i = 0; i < numChildren; i++) {
+      children.add(randomTree(random, depth - 1, pushdownEnabled));
+    }
+    return children;
+  }
+
+  /// Builds a leaf of every kind that [BlockDocIdSet#applyAnd] dispatches on, 
so that the bitmap, scan, sorted-range
+  /// and match-all/empty arms are all exercised by the randomized equivalence 
test.
+  private static BlockDocIdSet randomLeaf(Random random) {
+    switch (random.nextInt(8)) {
+      case 0:
+        return new MatchAllDocIdSet(NUM_DOCS);
+      case 1:
+        return EmptyDocIdSet.unscanned();
+      case 2:
+      case 3: {
+        // Sorted leaf: inclusive ranges, which applyAnd has to convert to an 
exclusive upper bound
+        List<IntPair> docIdRanges = new ArrayList<>();
+        int start = random.nextInt(NUM_DOCS / 2);
+        int numRanges = 1 + random.nextInt(3);
+        for (int i = 0; i < numRanges && start < NUM_DOCS; i++) {
+          int end = Math.min(start + random.nextInt(500), NUM_DOCS - 1);
+          docIdRanges.add(new IntPair(start, end));
+          start = end + 2 + random.nextInt(500);
+        }
+        return SortedDocIdSet.create(docIdRanges);
+      }
+      case 4:
+      case 5:
+        return new CountingScanDocIdSet(randomBitmap(random));
+      default:
+        return BitmapDocIdSet.create(randomBitmap(random), NUM_DOCS);
+    }
+  }
+
+  private static MutableRoaringBitmap randomBitmap(Random random) {
+    MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
+    int numRuns = 1 + random.nextInt(8);
+    for (int i = 0; i < numRuns; i++) {
+      int start = random.nextInt(NUM_DOCS);
+      bitmap.add(start, Math.min((long) start + 1 + random.nextInt(2000), 
NUM_DOCS));
+    }
+    return bitmap;
+  }
+
+  /// A sorted leaf whose ranges end exactly at the candidate boundaries, 
pinning the inclusive-to-exclusive
+  /// conversion in [BlockDocIdSet#applyAnd] against an off-by-one.
+  @Test
+  public void testSortedLeafRangeBoundariesAreInclusive() {
+    BlockDocIdSet sorted = SortedDocIdSet.create(List.of(new IntPair(10, 20), 
new IntPair(30, 30)));
+    MutableRoaringBitmap candidates = MutableRoaringBitmap.bitmapOf(9, 10, 20, 
21, 29, 30, 31);
+
+    assertEquals(sorted.applyAnd(candidates).toArray(), new int[]{10, 20, 30});
+  }
+
+  private static int[] collectDocIds(BlockDocIdSet docIdSet) {
+    BlockDocIdIterator iterator = docIdSet.iterator();
+    List<Integer> docIds = new ArrayList<>();
+    int docId;
+    while ((docId = iterator.next()) != Constants.EOF) {
+      docIds.add(docId);
+    }
+    return docIds.stream().mapToInt(Integer::intValue).toArray();
+  }
+
+  private static MutableRoaringBitmap range(int start, int endExclusive) {
+    MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
+    bitmap.add(start, endExclusive);
+    return bitmap;
+  }
+
+  private static MutableRoaringBitmap multiplesOf(int divisor) {
+    MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
+    for (int docId = 0; docId < NUM_DOCS; docId += divisor) {
+      bitmap.add(docId);
+    }
+    return bitmap;
+  }
+
+  /// A scan-based DocIdSet that records how many documents it was asked 
about, standing in for a predicate with no
+  /// usable index (a full scan, or `IN_SUBQUERY` evaluated through 
`ExpressionScanDocIdIterator`).
+  private static final class CountingScanDocIdSet implements BlockDocIdSet {
+    private final CountingScanDocIdIterator _iterator;
+
+    private CountingScanDocIdSet(ImmutableRoaringBitmap matchingDocIds) {
+      _iterator = new CountingScanDocIdIterator(matchingDocIds);
+    }
+
+    @Override
+    public ScanBasedDocIdIterator iterator() {
+      return _iterator;
+    }
+
+    @Override
+    public boolean isScanBased() {
+      return true;
+    }
+
+    @Override
+    public long getNumEntriesScannedInFilter() {
+      return _iterator.getNumEntriesScanned();
+    }
+  }
+
+  private static final class CountingScanDocIdIterator implements 
ScanBasedDocIdIterator {
+    private final ImmutableRoaringBitmap _matchingDocIds;
+    private long _numEntriesScanned;
+    private int _nextDocId;
+
+    private CountingScanDocIdIterator(ImmutableRoaringBitmap matchingDocIds) {
+      _matchingDocIds = matchingDocIds;
+    }
+
+    @Override
+    public MutableRoaringBitmap applyAnd(BatchIterator batchIterator, 
OptionalInt firstDoc, OptionalInt lastDoc) {
+      MutableRoaringBitmap docIds = new MutableRoaringBitmap();
+      int[] buffer = new int[OPTIMAL_ITERATOR_BATCH_SIZE];
+      while (batchIterator.hasNext()) {
+        int numDocIds = batchIterator.nextBatch(buffer);
+        for (int i = 0; i < numDocIds; i++) {
+          _numEntriesScanned++;
+          if (_matchingDocIds.contains(buffer[i])) {
+            docIds.add(buffer[i]);
+          }
+        }
+      }
+      return docIds;
+    }
+
+    @Override
+    public int next() {
+      while (_nextDocId < NUM_DOCS) {
+        int docId = _nextDocId++;
+        _numEntriesScanned++;
+        if (_matchingDocIds.contains(docId)) {
+          return docId;
+        }
+      }
+      return Constants.EOF;
+    }
+
+    @Override
+    public int advance(int targetDocId) {
+      _nextDocId = targetDocId;
+      return next();
+    }
+
+    @Override
+    public long getNumEntriesScanned() {
+      return _numEntriesScanned;
+    }
+  }
+}
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/plan/maker/AndRestrictionPushdownModeTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/plan/maker/AndRestrictionPushdownModeTest.java
new file mode 100644
index 00000000000..79b1e46ad7e
--- /dev/null
+++ 
b/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 (NEVER) applies.
+  private static boolean resolve(@Nullable String mode, String query) {
+    QueryContext queryContext = 
QueryContextConverterUtils.getQueryContext(query);
+    if (mode != null) {
+      
queryContext.getQueryOptions().put(QueryOptionKey.AND_RESTRICTION_PUSHDOWN_MODE,
 mode);
+    }
+    new InstancePlanMakerImplV2().applyQueryOptions(queryContext);
+    return queryContext.isAndRestrictionPushdownEnabled();
+  }
+}
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/queries/AndRestrictionPushdownNullQueriesTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/queries/AndRestrictionPushdownNullQueriesTest.java
new file mode 100644
index 00000000000..f544ef1381f
--- /dev/null
+++ 
b/pinot-core/src/test/java/org/apache/pinot/queries/AndRestrictionPushdownNullQueriesTest.java
@@ -0,0 +1,201 @@
+/**
+ * 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.queries;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.IntPredicate;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.common.response.broker.BrokerResponseNative;
+import 
org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
+import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
+import org.apache.pinot.segment.local.segment.index.loader.IndexLoadingConfig;
+import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
+import org.apache.pinot.segment.spi.ImmutableSegment;
+import org.apache.pinot.segment.spi.IndexSegment;
+import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import 
org.apache.pinot.spi.utils.CommonConstants.Broker.Request.QueryOptionKey;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+
+/// End-to-end test of the AND restriction push-down with null handling, 
against a segment whose scanned columns hold
+/// both null and non-null values.
+///
+/// `idx` has an inverted index and seeds the push-down; `a` and `b` have 
none, so predicates on them are scans. Under
+/// null handling a predicate on a null row is UNKNOWN, and `NOT` keeps 
UNKNOWN rows out, so the push-down has to carry
+/// the null documents correctly through `excludeNulls()` and 
`getNotFalses()`. Each query is checked against a
+/// row-by-row evaluation of three-valued logic, not only against the 
push-down turned off.
+public class AndRestrictionPushdownNullQueriesTest extends BaseQueriesTest {
+  private static final File INDEX_DIR = new File(FileUtils.getTempDirectory(), 
"AndRestrictionPushdownNullQueriesTest");
+  private static final String RAW_TABLE_NAME = "testTable";
+  private static final String SEGMENT_NAME = "testSegment";
+  private static final int NUM_ROWS = 5000;
+  // BaseQueriesTest queries two instances of two copies of the segment
+  private static final int NUM_SEGMENT_COPIES = 4;
+  private static final String[] B_VALUES = {"x", "y", "z"};
+
+  private IndexSegment _indexSegment;
+  private List<IndexSegment> _indexSegments;
+
+  @Override
+  protected String getFilter() {
+    return "";
+  }
+
+  @Override
+  protected IndexSegment getIndexSegment() {
+    return _indexSegment;
+  }
+
+  @Override
+  protected List<IndexSegment> getIndexSegments() {
+    return _indexSegments;
+  }
+
+  @BeforeClass
+  public void setUp()
+      throws Exception {
+    FileUtils.deleteDirectory(INDEX_DIR);
+    Schema schema = new Schema.SchemaBuilder().setSchemaName(RAW_TABLE_NAME)
+        .addSingleValueDimension("id", DataType.INT)
+        .addSingleValueDimension("idx", DataType.INT)
+        .addSingleValueDimension("a", DataType.INT)
+        .addSingleValueDimension("b", DataType.STRING)
+        .build();
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+        .setInvertedIndexColumns(List.of("idx"))
+        .setNullHandlingEnabled(true)
+        .build();
+
+    List<GenericRow> rows = new ArrayList<>(NUM_ROWS);
+    for (int i = 0; i < NUM_ROWS; i++) {
+      GenericRow row = new GenericRow();
+      row.putValue("id", i);
+      row.putValue("idx", i % 10);
+      row.putValue("a", a(i));
+      row.putValue("b", b(i));
+      rows.add(row);
+    }
+
+    SegmentGeneratorConfig config = new SegmentGeneratorConfig(tableConfig, 
schema);
+    config.setTableName(RAW_TABLE_NAME);
+    config.setSegmentName(SEGMENT_NAME);
+    config.setOutDir(INDEX_DIR.getPath());
+    config.setDefaultNullHandlingEnabled(true);
+    SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+    driver.init(config, new GenericRowRecordReader(rows));
+    driver.build();
+
+    ImmutableSegment segment =
+        ImmutableSegmentLoader.load(new File(INDEX_DIR, SEGMENT_NAME), new 
IndexLoadingConfig(tableConfig, schema));
+    _indexSegment = segment;
+    _indexSegments = List.of(segment, segment);
+  }
+
+  @AfterClass
+  public void tearDown() {
+    _indexSegment.destroy();
+    FileUtils.deleteQuietly(INDEX_DIR);
+  }
+
+  private static Integer a(int i) {
+    return i % 7 == 0 ? null : i % 100;
+  }
+
+  private static String b(int i) {
+    return i % 11 == 0 ? null : B_VALUES[i % 3];
+  }
+
+  /// Each filter has `idx = 1` to seed the push-down and an OR/AND over 
nullable scans, paired with the rows it must
+  /// match under three-valued logic.
+  @DataProvider
+  public static Object[][] filters() {
+    IntPredicate aTrue = i -> a(i) != null && a(i) > 50;
+    IntPredicate aFalse = i -> a(i) != null && a(i) <= 50;
+    IntPredicate bTrue = i -> b(i) != null && b(i).equals("x");
+    IntPredicate bFalse = i -> b(i) != null && !b(i).equals("x");
+    return new Object[][]{
+        // excludeNulls() on each leaf under the pushed-into OR
+        {"(a > 50 OR b = 'x')", aTrue.or(bTrue)},
+        // NOT(OR) is true where the OR is false: every branch false, none 
UNKNOWN
+        {"NOT (a > 50 OR b = 'x')", aFalse.and(bFalse)},
+        // NOT(AND) is true where the AND is false: some branch false; 
getNotFalses() builds the AND with the flag
+        {"NOT (a > 50 AND b = 'x')", aFalse.or(bFalse)}
+    };
+  }
+
+  @Test(dataProvider = "filters")
+  public void testPushdownMatchesThreeValuedLogic(String predicate, 
IntPredicate expected) {
+    String query = "SELECT COUNT(*), SUM(id) FROM testTable WHERE idx = 1 AND 
" + predicate;
+    long expectedCount = 0;
+    long expectedSum = 0;
+    for (int i = 0; i < NUM_ROWS; i++) {
+      if (i % 10 == 1 && expected.test(i)) {
+        expectedCount++;
+        expectedSum += i;
+      }
+    }
+    assertTrue(expectedCount > 0, "The filter must match some rows to be 
meaningful");
+
+    BrokerResponseNative disabled = 
getBrokerResponse(withNullHandling("never", query));
+    BrokerResponseNative enabled = 
getBrokerResponse(withNullHandling("always", query));
+
+    for (BrokerResponseNative response : List.of(disabled, enabled)) {
+      Object[] row = response.getResultTable().getRows().get(0);
+      assertEquals(((Number) row[0]).longValue(), expectedCount * 
NUM_SEGMENT_COPIES, predicate);
+      assertEquals(((Number) row[1]).longValue(), expectedSum * 
NUM_SEGMENT_COPIES, predicate);
+    }
+    assertTrue(enabled.getNumEntriesScannedInFilter() < 
disabled.getNumEntriesScannedInFilter(),
+        "The push-down must reduce the entries scanned in the filter for " + 
predicate + ", but scanned "
+            + enabled.getNumEntriesScannedInFilter() + " with it and " + 
disabled.getNumEntriesScannedInFilter()
+            + " without");
+  }
+
+  @Test(dataProvider = "filters")
+  public void testPushdownKeepsTheSameRowsForASelectionQuery(String predicate, 
IntPredicate expected) {
+    String query = "SELECT id, a, b FROM testTable WHERE idx = 1 AND " + 
predicate + " ORDER BY id LIMIT 100000";
+
+    List<Object[]> enabledRows = getBrokerResponse(withNullHandling("always", 
query)).getResultTable().getRows();
+    List<Object[]> disabledRows = getBrokerResponse(withNullHandling("never", 
query)).getResultTable().getRows();
+
+    assertEquals(enabledRows.size(), disabledRows.size(), predicate);
+    for (int i = 0; i < enabledRows.size(); i++) {
+      assertEquals(enabledRows.get(i), disabledRows.get(i), predicate + ", row 
" + i);
+      assertTrue(expected.test((Integer) enabledRows.get(i)[0]), predicate + 
", row " + i);
+    }
+  }
+
+  private static String withNullHandling(String mode, String query) {
+    return "SET enableNullHandling = true; SET " + 
QueryOptionKey.AND_RESTRICTION_PUSHDOWN_MODE + " = '" + mode
+        + "'; " + query;
+  }
+}
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/queries/AndRestrictionPushdownQueriesTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/queries/AndRestrictionPushdownQueriesTest.java
new file mode 100644
index 00000000000..c77cdd9e224
--- /dev/null
+++ 
b/pinot-core/src/test/java/org/apache/pinot/queries/AndRestrictionPushdownQueriesTest.java
@@ -0,0 +1,109 @@
+/**
+ * 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.queries;
+
+import java.util.List;
+import org.apache.pinot.common.response.broker.BrokerResponseNative;
+import 
org.apache.pinot.spi.utils.CommonConstants.Broker.Request.QueryOptionKey;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+
+/// End-to-end test of the AND restriction push-down against a real segment.
+///
+/// The shared `FILTER` is `col1 > x AND col3 BETWEEN y AND z AND col5 = 
'gFuH' AND (col6 < w OR col11 NOT IN (..))
+/// AND daysSinceEpoch = d`, i.e. an OR containing scan-based predicates under 
an AND with index-based ones -- the
+/// shape from [issue 19339](https://github.com/apache/pinot/issues/19339). 
The push-down ships disabled, so this is
+/// the only coverage that runs a real query with it on.
+public class AndRestrictionPushdownQueriesTest extends 
BaseSingleValueQueriesTest {
+  private static final String AGGREGATION = "SELECT SUM(column1) FROM 
testTable";
+  private static final String SELECTION = "SELECT column1, column5, column11 
FROM testTable";
+
+  @Test
+  public void testPushdownScansFewerEntriesAndKeepsTheSameResult() {
+    BrokerResponseNative disabled = getBrokerResponse(withMode("never", 
AGGREGATION + FILTER));
+    BrokerResponseNative enabled = getBrokerResponse(withMode("always", 
AGGREGATION + FILTER));
+
+    assertRowsEqual(enabled, disabled);
+    assertEquals(enabled.getNumDocsScanned(), disabled.getNumDocsScanned(), 
"The same documents must match");
+    assertEquals(enabled.getTotalDocs(), disabled.getTotalDocs());
+    assertTrue(enabled.getNumEntriesScannedInFilter() < 
disabled.getNumEntriesScannedInFilter(),
+        "The push-down must reduce the entries scanned in the filter, but 
scanned "
+            + enabled.getNumEntriesScannedInFilter() + " with it and " + 
disabled.getNumEntriesScannedInFilter()
+            + " without");
+  }
+
+  @Test
+  public void testPushdownKeepsTheSameRowsForASelectionQuery() {
+    // ALWAYS is the only mode that reaches a selection query: AUTO excludes 
it because it can stop at its LIMIT
+    assertRowsEqual(getBrokerResponse(withMode("always", SELECTION + FILTER)),
+        getBrokerResponse(withMode("never", SELECTION + FILTER)));
+  }
+
+  @Test
+  public void testAutoLeavesSelectionQueriesAlone() {
+    // A selection query stops at its LIMIT, so with several threads the 
number of segments it scans depends on timing.
+    // One thread makes the entries scanned in the filter deterministic.
+    String query = "SET " + QueryOptionKey.MAX_EXECUTION_THREADS + " = 1; " + 
SELECTION + FILTER;
+    BrokerResponseNative auto = getBrokerResponse(withMode("auto", query));
+    BrokerResponseNative disabled = getBrokerResponse(withMode("never", 
query));
+
+    assertRowsEqual(auto, disabled);
+    assertEquals(auto.getNumEntriesScannedInFilter(), 
disabled.getNumEntriesScannedInFilter(),
+        "AUTO must not push down for a selection query");
+  }
+
+  @Test
+  public void testAutoPushesDownForAnAggregationQuery() {
+    assertEquals(getBrokerResponse(withMode("auto", AGGREGATION + 
FILTER)).getNumEntriesScannedInFilter(),
+        getBrokerResponse(withMode("always", AGGREGATION + 
FILTER)).getNumEntriesScannedInFilter());
+  }
+
+  @Test
+  public void testDefaultLeavesEveryQueryAlone() {
+    assertEquals(getBrokerResponse(AGGREGATION + 
FILTER).getNumEntriesScannedInFilter(),
+        getBrokerResponse(withMode("never", AGGREGATION + 
FILTER)).getNumEntriesScannedInFilter(),
+        "The push-down must be off by default");
+  }
+
+  /// Filtered aggregations build the outer AND through 
CombinedFilterOperator, where both the main and the sub
+  /// filter can be deferrable, so it can reach the lazy fallback arm of 
AndDocIdSet#iterator().
+  @Test
+  public void testPushdownForFilteredAggregation() {
+    String query = "SELECT SUM(column1) FILTER (WHERE column3 > 0), COUNT(*) 
FROM testTable" + FILTER;
+
+    assertRowsEqual(getBrokerResponse(withMode("always", query)), 
getBrokerResponse(withMode("never", query)));
+  }
+
+  private static String withMode(String mode, String query) {
+    return "SET " + QueryOptionKey.AND_RESTRICTION_PUSHDOWN_MODE + " = '" + 
mode + "'; " + query;
+  }
+
+  private static void assertRowsEqual(BrokerResponseNative actual, 
BrokerResponseNative expected) {
+    List<Object[]> actualRows = actual.getResultTable().getRows();
+    List<Object[]> expectedRows = expected.getResultTable().getRows();
+    assertEquals(actual.getResultTable().getDataSchema(), 
expected.getResultTable().getDataSchema());
+    assertEquals(actualRows.size(), expectedRows.size());
+    for (int i = 0; i < actualRows.size(); i++) {
+      assertEquals(actualRows.get(i), expectedRows.get(i), "Row " + i + " 
differs");
+    }
+  }
+}
diff --git 
a/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkAndRestrictionPushdown.java
 
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkAndRestrictionPushdown.java
new file mode 100644
index 00000000000..a960426833c
--- /dev/null
+++ 
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkAndRestrictionPushdown.java
@@ -0,0 +1,208 @@
+/**
+ * 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.io.File;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.core.common.BlockDocIdIterator;
+import org.apache.pinot.core.common.BlockDocIdSet;
+import org.apache.pinot.core.operator.dociditerators.SVScanDocIdIterator;
+import org.apache.pinot.core.operator.docidsets.AndDocIdSet;
+import org.apache.pinot.core.operator.docidsets.BitmapDocIdSet;
+import org.apache.pinot.core.operator.docidsets.OrDocIdSet;
+import 
org.apache.pinot.segment.local.io.writer.impl.FixedBitSVForwardIndexWriter;
+import 
org.apache.pinot.segment.local.segment.index.readers.forward.FixedBitSVForwardIndexReaderV2;
+import org.apache.pinot.segment.spi.Constants;
+import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
+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.TearDown;
+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;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
+
+
+/// Measures the AND restriction push-down (`andRestrictionPushdownMode`) in 
wall-clock time and, with `-prof gc`,
+/// allocation -- not only in `numEntriesScannedInFilter`, which can fall 
while both of those rise.
+///
+/// The matrix covers the four dimensions that decide whether the push-down 
pays for itself:
+///
+/// - `_shape`: whether the OR subtree still has a scan to restrict 
(`SCAN_IN_OR`, the shape from
+///   [issue 19339](https://github.com/apache/pinot/issues/19339)) or is 
entirely index-based (`INDEXED_OR`, where
+///   there is nothing to gain and the question is only how much the machinery 
costs). `SCAN_ONLY_OR` is the third
+///   case: scans directly under the OR with no index-based sibling, so the OR 
is already lazy today and a LIMIT
+///   really does stop early -- the shape the AUTO mode exists to protect.
+/// - `_consume`: whether the consumer drains the filter, as an aggregation 
does, or stops at a LIMIT, as a
+///   selection does. The push-down materializes, so this is where it can lose.
+/// - `_candidateSelectivity`: how much the enclosing AND narrows things 
before the OR is reached.
+/// - `_pushdown`: the feature itself, off versus on.
+///
+/// Run with `-prof gc` to get `gc.alloc.rate.norm` (bytes per operation) 
alongside the timings.
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@Fork(1)
+@Warmup(iterations = 3, time = 3)
+@Measurement(iterations = 5, time = 3)
+public class BenchmarkAndRestrictionPushdown {
+  private static final File INDEX_DIR = new File(FileUtils.getTempDirectory(), 
"BenchmarkAndRestrictionPushdown");
+  private static final int NUM_DOCS = 2_000_000;
+  private static final int LIMIT = 10;
+  private static final int NUM_DICT_IDS = 1024;
+
+  @Param({"false", "true"})
+  private boolean _pushdown;
+
+  @Param({"SCAN_IN_OR", "SCAN_ONLY_OR", "INDEXED_OR"})
+  private String _shape;
+
+  @Param({"DRAIN", "LIMIT"})
+  private String _consume;
+
+  /// Fraction of documents the enclosing AND's index-based child matches: 
sparse candidates versus dense.
+  @Param({"0.01", "0.5"})
+  private double _candidateSelectivity;
+
+  private ImmutableRoaringBitmap _candidateDocIds;
+  private ImmutableRoaringBitmap _firstBranchDocIds;
+  private ImmutableRoaringBitmap _secondBranchDocIds;
+  private PinotDataBuffer _dataBuffer;
+  private FixedBitSVForwardIndexReaderV2 _forwardIndexReader;
+  private BenchmarkScanDocIdIterators.DummyPredicateEvaluator 
_predicateEvaluator;
+
+  public static void main(String[] args)
+      throws RunnerException {
+    new Runner(new 
OptionsBuilder().include(BenchmarkAndRestrictionPushdown.class.getSimpleName())
+        .addProfiler("gc")
+        .build()).run();
+  }
+
+  @Setup(Level.Trial)
+  public void setUp()
+      throws Exception {
+    FileUtils.deleteDirectory(INDEX_DIR);
+    FileUtils.forceMkdir(INDEX_DIR);
+    File indexFile = new File(INDEX_DIR, "fwd-index");
+    Random random = new Random(42);
+
+    int numBits = 32 - Integer.numberOfLeadingZeros(NUM_DICT_IDS - 1);
+    try (FixedBitSVForwardIndexWriter indexWriter = new 
FixedBitSVForwardIndexWriter(indexFile, NUM_DOCS, numBits)) {
+      for (int i = 0; i < NUM_DOCS; i++) {
+        indexWriter.putDictId(random.nextInt(NUM_DICT_IDS));
+      }
+    }
+    _dataBuffer = PinotDataBuffer.mapReadOnlyBigEndianFile(indexFile);
+    _forwardIndexReader = new FixedBitSVForwardIndexReaderV2(_dataBuffer, 
NUM_DOCS, numBits);
+    // Matches roughly a third of the documents, so the scan is neither 
trivially empty nor a match-all
+    _predicateEvaluator = new 
BenchmarkScanDocIdIterators.DummyPredicateEvaluator(NUM_DICT_IDS / 3);
+
+    _candidateDocIds = randomBitmap(random, _candidateSelectivity);
+    _firstBranchDocIds = randomBitmap(random, 0.3);
+    _secondBranchDocIds = randomBitmap(random, 0.2);
+  }
+
+  @TearDown(Level.Trial)
+  public void tearDown()
+      throws Exception {
+    _dataBuffer.close();
+    FileUtils.deleteDirectory(INDEX_DIR);
+  }
+
+  @Benchmark
+  public int evaluateFilter(Blackhole bh) {
+    BlockDocIdIterator docIdIterator = buildFilter().iterator();
+    int limit = "LIMIT".equals(_consume) ? LIMIT : Integer.MAX_VALUE;
+    int numDocs = 0;
+    int docId;
+    while ((docId = docIdIterator.next()) != Constants.EOF) {
+      bh.consume(docId);
+      if (++numDocs >= limit) {
+        break;
+      }
+    }
+    return numDocs;
+  }
+
+  /// `AND(candidates, OR(...))`, rebuilt on every invocation because a 
DocIdSet is single-use.
+  private BlockDocIdSet buildFilter() {
+    BlockDocIdSet orDocIdSet;
+    if ("INDEXED_OR".equals(_shape)) {
+      orDocIdSet = new 
OrDocIdSet(List.of(BitmapDocIdSet.create(_firstBranchDocIds, NUM_DOCS),
+          BitmapDocIdSet.create(_secondBranchDocIds, NUM_DOCS)), NUM_DOCS);
+    } else if ("SCAN_ONLY_OR".equals(_shape)) {
+      // No index-based sibling inside the branches, so without the push-down 
the OR stays lazy and a LIMIT really
+      // does stop early. This is the shape AUTO exists to protect.
+      orDocIdSet = new OrDocIdSet(List.of(newScanDocIdSet(), 
newScanDocIdSet()), NUM_DOCS);
+    } else {
+      // The reported shape: a scan sits next to an index-based predicate 
inside an OR branch
+      BlockDocIdSet branch = new AndDocIdSet(
+          List.of(BitmapDocIdSet.create(_firstBranchDocIds, NUM_DOCS), 
newScanDocIdSet()), null, _pushdown);
+      orDocIdSet =
+          new OrDocIdSet(List.of(branch, 
BitmapDocIdSet.create(_secondBranchDocIds, NUM_DOCS)), NUM_DOCS);
+    }
+    return new AndDocIdSet(List.of(BitmapDocIdSet.create(_candidateDocIds, 
NUM_DOCS), orDocIdSet), null, _pushdown);
+  }
+
+  private BlockDocIdSet newScanDocIdSet() {
+    SVScanDocIdIterator docIdIterator =
+        new SVScanDocIdIterator(_predicateEvaluator, _forwardIndexReader, 
NUM_DOCS);
+    return new BlockDocIdSet() {
+      @Override
+      public BlockDocIdIterator iterator() {
+        return docIdIterator;
+      }
+
+      @Override
+      public boolean isScanBased() {
+        return true;
+      }
+
+      @Override
+      public long getNumEntriesScannedInFilter() {
+        return docIdIterator.getNumEntriesScanned();
+      }
+    };
+  }
+
+  private static ImmutableRoaringBitmap randomBitmap(Random random, double 
selectivity) {
+    MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
+    for (int docId = 0; docId < NUM_DOCS; docId++) {
+      if (random.nextDouble() < selectivity) {
+        bitmap.add(docId);
+      }
+    }
+    return bitmap;
+  }
+}
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
index ee6fd925d58..4b31ac6e346 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
@@ -999,6 +999,9 @@ public class CommonConstants {
 
         // Reorder scan based predicates based on cardinality and number of 
selected values
         public static final String AND_SCAN_REORDERING = "AndScanReordering";
+
+        // Per-query override of Server.AndRestrictionPushdownMode: ALWAYS, 
NEVER or AUTO
+        public static final String AND_RESTRICTION_PUSHDOWN_MODE = 
"andRestrictionPushdownMode";
         public static final String SKIP_INDEXES = "skipIndexes";
 
         // Query option key used to trace rule productions
@@ -1560,6 +1563,28 @@ public class CommonConstants {
         QUERY_EXECUTOR_CONFIG_PREFIX + 
".heap.usage.throttle.monitorIntervalMs";
     public static final long DEFAULT_HEAP_USAGE_THROTTLE_MONITOR_INTERVAL_MS = 
1_000L;
 
+    /// Controls whether an AND pushes the document ids matched by its 
index-based children into its composite
+    /// (AND/OR/NOT) children, so that a scan-based predicate nested inside an 
OR is only evaluated on the documents
+    /// that can still match the AND. See [issue 
19339](https://github.com/apache/pinot/issues/19339).
+    public static final String AND_RESTRICTION_PUSHDOWN_MODE = 
"and.restriction.pushdown.mode";
+    public static final String 
CONFIG_OF_QUERY_EXECUTOR_AND_RESTRICTION_PUSHDOWN_MODE =
+        QUERY_EXECUTOR_CONFIG_PREFIX + "." + AND_RESTRICTION_PUSHDOWN_MODE;
+    /// Defaults to NEVER: the push-down is a new filter execution path and is 
opt-in until it has soaked.
+    public static final AndRestrictionPushdownMode 
DEFAULT_QUERY_EXECUTOR_AND_RESTRICTION_PUSHDOWN_MODE =
+        AndRestrictionPushdownMode.NEVER;
+
+    /// How aggressively an AND pushes the document ids it has matched so far 
into its composite children.
+    public enum AndRestrictionPushdownMode {
+      /// Always push the restriction down.
+      ALWAYS,
+      /// Never push the restriction down, keeping the behavior Pinot had 
before the push-down existed.
+      NEVER,
+      /// Push the restriction down unless the query can stop before reading 
every matching document. The push-down
+      /// materializes the filter result, so applying it to a query that stops 
at its LIMIT makes it do the full
+      /// filter work instead of only enough to fill the result.
+      AUTO
+    }
+
     // Group-by query related configs
     public static final String NUM_GROUPS_LIMIT = "num.groups.limit";
     public static final String CONFIG_OF_QUERY_EXECUTOR_NUM_GROUPS_LIMIT =


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to