Skip to content

Make IDSET and IN_ID_SET work in the multi-stage engine - #19757

Merged
yashmayya merged 2 commits into
apache:masterfrom
yashmayya:mse-idset-phase1
Oct 7, 2026
Merged

yashmayya merged 2 commits into
apache:masterfrom
yashmayya:mse-idset-phase1

Conversation

@yashmayya

@yashmayya yashmayya commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

PR flow

Flow for IN_ID_SET in MSE non-leaf stages: scalar function deserializes, validates, checks containment.

flowchart TD
  N0["Planner#58; scalar function for IN#95;ID#95;SET #40;non#45;leaf#41; #40;F1#44; F2#41;"]:::stModified
  N1["Call inIdSet#40;value#44; serializedIdSet#41; #40;F2#41;"]:::stAdded
  N2["Deserialize IdSet #40;if changed#41; #40;F2#44; F5#44; F4#44; F6#41;"]:::stModified
  N3["Validate value type #40;F2#44; F5#41;"]:::stModified
  N4["Check containment #40;type#45;specific#41; #40;F2#41;"]:::stAdded
  N0 -->|"routes to"| N1
  N1 -->|"deserializes if needed"| N2
  N2 -->|"then validates"| N3
  N3 -->|"then checks containment"| 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
Loading

AI-generated · Green: added · Yellow: modified · Red: removed · Gray: existing

Diff evidence
  • F1: pinot-common/src/main/java/org/apache/pinot/common/function/TransformFunctionType.java — before · after
  • F2: pinot-core/src/main/java/org/apache/pinot/core/function/scalar/IdSetFunctions.java — after
  • F4: pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/BloomFilterIdSet.java — before · after
  • F5: pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/IdSets.java — before · after
  • F6: pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/Roaring64NavigableMapIdSet.java — before · after
  • Regenerate PR flow

Problem

IDSET and IN_ID_SET only partly work in the multi-stage engine (MSE):

  • IDSET works on INT columns only. Every other type, and every Bloom filter set, fails with Cannot deserialize BloomFilter from ByteBuffer not backed by an accessible byte array (or the same error for Roaring64NavigableMap). MSE hands intermediate results over in read-only buffers, which have no accessible array.
  • IN_ID_SET works only when the planner pushes it into a leaf stage. In a filter over a join, in HAVING or in a join condition it fails with Unsupported function: INIDSET, because it has no scalar implementation.
  • When the set and the column have different types (for example a LONG set on an INT column), both engines fail with an exception that has no message, or with a ClassCastException.

Changes

  • BloomFilterIdSet and Roaring64NavigableMapIdSet read from any ByteBuffer, through Pinot's DataBufferPinotInputStream over PinotByteBuffer.slice. Heap, direct and read-only buffers take the same path, and Roaring64NavigableMap reads the stream as a DataInput directly. Bloom filter reads go through a BufferedInputStream, because Guava reads 8 bytes at a time.
  • New inIdSet scalar function, for MSE stages that are not leaf stages. Leaf stages keep InIdSetTransformFunction, because transform functions take precedence. Each call site gets its own instance, which caches the deserialized set.
  • IdSets.validateValueType checks the value type against the set once, in both the transform function and the scalar function. A mismatch now fails with, for example, Cannot look up LONG values in an IdSet built from INT values.
  • IN_ID_SET is now typed as nullable in the planner, because it returns NULL for a NULL value. With the old NOT NULL type, Calcite removed IS NOT TRUE and COALESCE around it, which gave wrong results over outer joins.
  • An empty or NULL set gives FALSE, even for a NULL value, as IN over an empty subquery does. IDSET returns NULL over zero rows when null handling is on. This changes existing results in both engines: with null handling on, IN_ID_SET(nullableCol, <empty set>) = 0 (or NOT IN_ID_SET(...)) now keeps the NULL rows, as x NOT IN (empty subquery) does. This includes the single-stage IN_SUBQUERY when its subquery returns an empty set.
  • The scalar function is not evaluated at compile time (isDeterministic = false). The planner passes literals in types the lookup cannot use, such as BigDecimal for an INT literal, so an all-literal call is left to the runtime, as before. The planner still pushes IN_ID_SET below joins, because it uses the deterministic operator of the IN_ID_SET transform function. A plan test pins this.
  • The single-stage engine now also accepts IN_ID_SET after aggregation, for example in HAVING. Aggregations such as MAX return DOUBLE there, so they must be cast to the type the set was built from.

No change to the serialized format or the wire protocol.

Release notes

Behavior change, with null handling on: IN_ID_SET(col, <empty IdSet>) is now FALSE for a NULL col, not NULL, as col IN (<empty subquery>) is. So IN_ID_SET(col, <empty IdSet>) = 0 and NOT IN_ID_SET(...) now keep the rows where col is NULL. This applies to both engines, and to single-stage IN_SUBQUERY when its subquery returns an empty IdSet. With a non-empty IdSet, a NULL value still gives NULL.

Known limits

  • A Bloom filter set records only its hash funnel. INT and FLOAT values share one funnel, and LONG and DOUBLE values share another, so a mismatch inside those pairs is not detected.
  • A call site whose set changes from row to row (for example a correlated IDSET subquery, which has one set per group) deserializes the set on every change.
  • Single-stage IN_SUBQUERY fails with a NullPointerException when its subquery runs with null handling on and matches no rows. IDSET returns NULL there, and the broker cannot turn NULL into the set literal. This is not new.

Next phases

This PR is phase 1 of 3, plus an optional phase 4. Each phase is a separate PR.

  • Phase 2, large sets (Support IdSet literals of millions of ids #19759):
    • Parse long string literals in linear time. The generated JavaCC char stream grows its buffer by 2,048 chars at a time, so a 6 MB literal costs 2.5 s and 84 GiB of allocation to parse.
    • Decode a literal set once per query, not once per segment.
    • Make the gRPC message size limits configurable. QueryServer and DispatchClient hardcode 64 MB, and the broker gRPC endpoint uses the gRPC default of 4 MB.
  • Phase 3, single-query forms in MSE: IN_SUBQUERY(expr, 'sql') and uncorrelated IN_ID_SET(expr, (SELECT IDSET(...) ...)). The broker runs the subquery first, so the set never goes through the parser or the client.
  • Phase 4 (optional, measure first): one shared Bloom filter per query for IDSET, and shorter literals in EXPLAIN (Explain plan should elide function parameters #8009).

Tests

  • IdSetTest: every set type from heap, read-only, direct and offset buffers, and the value type matrix.
  • IdSetFunctionsTest: every value type (including BOOLEAN, TIMESTAMP and UUID, through their stored types), NULL and empty sets, malformed sets, cache reuse and registration.
  • IdSetQueriesTest: IN_ID_SET on every single-value type in a leaf, in HAVING, the mismatch error, and NULL values with empty and non-empty sets under null handling.
  • JoinPlans.json and PhysicalOptimizerPlans.json: an IN_ID_SET filter on one side of a join is pushed into the leaf stage, in both planners.
  • IdSet.json (MSE, both planners):
    • IDSET on every type, as a scalar subquery, a CTE and a correlated subquery.
    • IN_ID_SET in leaf filters, post-join filters, HAVING and join conditions.
    • NULL values from outer joins and nullable columns, and empty subqueries with null handling on and off.
    • A 9 MB set that crosses the mailbox in more than one chunk.
    • IDSET results compared byte for byte with sets built directly from the values.
  • IdSetIntegrationTest (2 servers): both engines return the same IDSET bytes, and a set built by either engine filters the same rows in both.

Without the main changes, 56 of the first 66 IdSet.json cases fail.

@yashmayya yashmayya added multi-stage Related to the multi-stage query engine feature New functionality labels Oct 6, 2026
@codecov-commenter

codecov-commenter commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.13483% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.39%. Comparing base (6a2ab12) to head (021374b).
⚠️ Report is 11 commits behind head on master.

Files with missing lines Patch % Lines
...che/pinot/core/function/scalar/IdSetFunctions.java 96.36% 1 Missing and 1 partial ⚠️
...r/transform/function/InIdSetTransformFunction.java 50.00% 2 Missing ⚠️
...rg/apache/pinot/core/query/utils/idset/IdSets.java 90.47% 1 Missing and 1 partial ⚠️
...pinot/core/query/utils/idset/BloomFilterIdSet.java 85.71% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19757      +/-   ##
============================================
+ Coverage     68.33%   68.39%   +0.06%     
  Complexity     1470     1470              
============================================
  Files          3520     3522       +2     
  Lines        228960   229102     +142     
  Branches      36308    36361      +53     
============================================
+ Hits         156459   156698     +239     
+ Misses        60232    60136      -96     
+ Partials      12269    12268       -1     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 68.39% <92.13%> (+0.06%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 68.39% <92.13%> (+0.06%) ⬆️
unittests 68.39% <92.13%> (+0.06%) ⬆️
unittests1 58.18% <92.13%> (+0.04%) ⬆️
unittests2 40.05% <1.12%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gortiz gortiz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for the thorough tests.

One suggestion for IdSets.toInputStream: instead of Jackson's ByteBufferBackedInputStream, we can use our own stream classes from pinot-segment-spi (org.apache.pinot.segment.spi.memory). pinot-core already has them on the classpath. Something like this would do:

static InputStream toInputStream(ByteBuffer byteBuffer) {
  return new DataBufferPinotInputStream(PinotByteBuffer.slice(byteBuffer));
}

PinotByteBuffer.slice wraps any ByteBuffer (heap, direct or read-only) without moving the caller's position, so the hasArray() branch goes away and every buffer type takes the same path. DataBufferPinotInputStream supports bulk reads and also implements DataInput, so Roaring64NavigableMapIdSet could pass it straight to deserialize(DataInput) without the extra DataInputStream. It is also the same buffer abstraction the multi-stage engine already uses for mailbox payloads (DataBlockUtils and BaseDataBlock wrap them with PinotByteBuffer), and it keeps a JSON library out of plain IO code. The new IdSetTest cases for heap, read-only, direct and offset buffers would cover the change.

Not a blocker, so approving as is.

/// instead, as before this function existed. The flag also marks the function VOLATILE, so ingestion transforms
/// cannot use it.
@Nullable
@ScalarFunction(nullableParameters = true, isDeterministic = false)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isDeterministic = false works around literal typing (BigDecimal for an INT literal), but it also marks the function volatile. That is harmless for planning today only because PinotOperatorTable registers TransformFunctionType.IN_ID_SET before the scalar functions, so Calcite sees the transform's operator, which is deterministic.

If that operator ever goes away, the planner picks this scalar's PinotSqlFunction instead. Then PinotRuleUtils.isRelocatable() returns false and PinotFilterJoinRule stops pushing IN_ID_SET below joins. Results stay correct, but the leaf transform path is lost silently.

Could we add a comment here that names this coupling, and a plan test (EXPLAIN) that pins a filter over a join down to the leaf? Another option is to map BigDecimal literals to the set's type in getStoredType and keep the function deterministic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 021374b. The comment is in IdSetFunctions and at TransformFunctionType.IN_ID_SET, and new cases in JoinPlans.json and PhysicalOptimizerPlans.json pin the push-down. When I made the IN_ID_SET operator volatile, both cases failed, with the filter above the join.

I kept isDeterministic = false instead of mapping BigDecimal to the set's type. A Bloom set does not record whether it was built from INT or FLOAT values (or LONG or DOUBLE), so the mapping could look up the wrong bits. The root cause is that PinotEvaluateLiteralRule passes raw Calcite values (BigDecimal for every exact number) to Object parameters, where the runtime passes Integer or Long. From the code, this also changes fold-time results of other Object-parameter functions, such as toThetaSketch. So I think the fix belongs in the rule, in a separate PR.

@Nullable
@Override
public RoaringBitmap getNullBitmap(ValueBlock valueBlock) {
return _idSet.getType() == IdSet.Type.EMPTY ? null : super.getNullBitmap(valueBlock);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes existing results. With null handling on, IN_ID_SET(nullableCol, <empty set>) used to be NULL for NULL rows and is now FALSE, so = 0 or NOT IN_ID_SET(...) now keeps those rows. The single-stage IN_SUBQUERY changes too when the subquery matches no rows.

The PR description explains it, but I think it needs the backward-incompat label and a release note, plus a line in the IN_ID_SET / IN_SUBQUERY docs. Also, only the multi-stage leaf case in IdSet.json covers this today. A single-stage case in IdSetQueriesTest (empty set, nullable column, null handling on) would lock it in for that engine as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 021374b. I added the backward-incompat label, a release note in the description, and IdSetQueriesTest.testInIdSetNullHandling (single-stage, nullable column, null handling on, empty and non-empty sets). The docs line needs a separate PR in the docs repo.

One note on IN_SUBQUERY: its subquery takes null handling from the broker config or the request options, not from the outer query's SET. If the subquery runs with null handling on and matches no rows, IDSET returns NULL, and the rewrite already fails with a NullPointerException today. I listed that under Known limits.

@yashmayya yashmayya added the backward-incompat Introduces a backward-incompatible API or behavior change label Oct 6, 2026
@yashmayya

Copy link
Copy Markdown
Contributor Author

@gortiz Thanks! Done in 021374b. IdSets now read through DataBufferPinotInputStream over PinotByteBuffer.slice, and Roaring64NavigableMapIdSet passes the stream to deserialize(DataInput) directly, which decodes about 2x faster.

One addition: Bloom filter reads go through a BufferedInputStream. Guava reads 8 bytes at a time, and PinotByteBuffer.copyTo copies reads of up to 10 bytes one byte at a time, so Bloom decoding was about 2x slower without it. With the buffer, a 4.5 MB Bloom set decodes within about 1 ms of the old path.

@yashmayya
yashmayya merged commit 08f4ab1 into apache:master Oct 7, 2026
14 checks passed
xiangfu0 added a commit to pinot-contrib/pinot-docs that referenced this pull request Oct 7, 2026
Docs follow-up for apache/pinot#19757. Corrects the single-stage-only
guidance, describes multi-stage IDSET/IN_ID_SET support, empty-set null
behavior, and the current subquery-form limit.\n\nValidation: full docs
validator passed (existing non-fatal anchor warnings only); git diff
--check passed.

Co-authored-by: Xiang Fu <xiangfu@Xiang-mac-mtv-2.local>
@xiangfu0

xiangfu0 commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Docs follow-up: pinot-contrib/pinot-docs#1088 (merged). The IdSet guide now covers multi-stage IDSET/IN_ID_SET support, empty-set null behavior, and current subquery-form limits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backward-incompat Introduces a backward-incompatible API or behavior change feature New functionality multi-stage Related to the multi-stage query engine

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants