Build the lookup join key from the dimension table primary key - #19210
Build the lookup join key from the dimension table primary key#19210yashmayya wants to merge 3 commits into
Conversation
The lookup key was built from the equi-join keys in join-condition order, so it silently returned no rows when a primary key column was bound by a literal, when the conditions were written in a different order from the primary key, or when a join key was on a non-primary-key column. Fixes apache#19188
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #19210 +/- ##
=============================================
+ Coverage 38.95% 66.94% +27.99%
- Complexity 1422 1423 +1
=============================================
Files 3452 3452
Lines 218708 218631 -77
Branches 34789 34750 -39
=============================================
+ Hits 85196 146372 +61176
+ Misses 125746 60558 -65188
- Partials 7766 11701 +3935
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
timothy-e
left a comment
There was a problem hiding this comment.
Thanks for getting on this so fast! I have a few questions, but the approach looks awesome.
A constant never replaces an equi-join key. The equi-join key is held nowhere else, so a replacement drops a join
condition and adds wrong rows. A constant on a position that pass 1 filled stays a filter that runs after the lookup,
which is what SQL requires.
Could we improve perf by following up with a change that allows us to apploy both filters during the hashmap lookup, to avoid materializing rows just to filter them out later?
The operator converts each constant to the stored type of its column. A literal already holds the internal value of
Pinot, but its numeric width follows the type that the planner gave the literal. PrimaryKey compares values with
equals, where an Integer never equals a Long.
From my understanding, this kind of scenario could have occured before if we joined a Int column with a Long column, but it was avoided because the planner added casts. Could we do the same thing here, and put the casting in the planner instead of in the operator? (It seems a little messy to me to add a toStoredValue because I feel like that logic must have been implemented somewhere else already)
The key plan rejects a join condition that cannot give exactly one value per primary key column:
- a join key on a dimension column outside the primary key
- more than one join key on the same primary key column
Both of these seem like they could be added as filters after the join, which would increase our SQL compatibility?
| /// stored `1.50`. BYTES is rejected because the literal is a [org.apache.pinot.spi.utils.ByteArray] while the | ||
| /// dimension table stores `byte[]`, whose `equals` is identity. |
There was a problem hiding this comment.
the SSE lookup join wraps byte[] in a ByteArray to compare, can we do the same thing here? https://github.com/apache/pinot/blob/master/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/LookupTransformFunction.java#L218
There was a problem hiding this comment.
Good point, but I think wrapping won't help and BYTES dimension primary keys are probably broken today, everywhere.
The dimension table's map key holds a raw byte[]: PinotSegmentRecordReader.getRecordValues → PinotSegmentColumnReader.getValue → either BytesDictionary.get() (returns byte[], while the ByteArray-returning variant is getInternal, which is not called here) or ForwardIndexReader.getBytes(). And DimensionTableDataManager.HASH_STRATEGY is Arrays.hashCode/Arrays.equals over Object[], which delegate to each element's own hashCode/equals - identity for byte[].
So nothing can ever probe a BYTES primary key: not a ByteArray (different class, content-based hash), not even a fresh byte[] with the same bytes. That includes LookupTransformFunction itself - so I don't think the code you've linked is a working reference.
Let's track this separately since it doesn't really relate to the fix here.
There was a problem hiding this comment.
makes sense, thanks for the explanation!
| /// match the stored representation misses every row, and this operator reports no rows the same way whether the key | ||
| /// is genuinely absent or malformed. The single-stage `lookup` transform function rejects the same way. | ||
| /// | ||
| /// BIG_DECIMAL is rejected because `BigDecimal#equals` compares the scale, so a literal of `1.5` never matches a |
There was a problem hiding this comment.
How does BIG_DECIMAL work on other join types?
There was a problem hiding this comment.
I think the same hazard exists elsewhere too TBH, so this isn't lookup join specific. I've updated this.
The planner coerces the operands of a comparison, so a constant compared against a dimension column already carries that column's type. Replace the conversion with a check that states the assumption. Allow BIG_DECIMAL, whose scale sensitivity a hash join shares, and keep rejecting BYTES, which a dimension table keys on a raw byte[] that nothing can match. Name the column ids in the operator test, as suggested in review.
yashmayya
left a comment
There was a problem hiding this comment.
Thanks for the thorough review @timothy-e!
Could we improve perf by following up with a change that allows us to apploy both filters during the hashmap lookup, to avoid materializing rows just to filter them out later?
No row is materialized and discarded: JoinedRowView is a lazy view over the two rows and toArray() runs only after the filters pass (there is a // defer copying of the content until row matches comment); see #17542. The waste is the evaluator call plus the probe, not a row copy.
it was avoided because the planner added casts
Yes, fair point, I wasn't really able to find any cases where the planner wasn't already adding the right casts at the logical plan level. I've removed the redundant toStoredValue conversion.
Both of these seem like they could be added as filters after the join, which would increase our SQL compatibility
Agreed, but I'd rather add support for these as follow ups rather than bloating this bug fix PR further.
| /// stored `1.50`. BYTES is rejected because the literal is a [org.apache.pinot.spi.utils.ByteArray] while the | ||
| /// dimension table stores `byte[]`, whose `equals` is identity. |
There was a problem hiding this comment.
Good point, but I think wrapping won't help and BYTES dimension primary keys are probably broken today, everywhere.
The dimension table's map key holds a raw byte[]: PinotSegmentRecordReader.getRecordValues → PinotSegmentColumnReader.getValue → either BytesDictionary.get() (returns byte[], while the ByteArray-returning variant is getInternal, which is not called here) or ForwardIndexReader.getBytes(). And DimensionTableDataManager.HASH_STRATEGY is Arrays.hashCode/Arrays.equals over Object[], which delegate to each element's own hashCode/equals - identity for byte[].
So nothing can ever probe a BYTES primary key: not a ByteArray (different class, content-based hash), not even a fresh byte[] with the same bytes. That includes LookupTransformFunction itself - so I don't think the code you've linked is a working reference.
Let's track this separately since it doesn't really relate to the fix here.
| /// match the stored representation misses every row, and this operator reports no rows the same way whether the key | ||
| /// is genuinely absent or malformed. The single-stage `lookup` transform function rejects the same way. | ||
| /// | ||
| /// BIG_DECIMAL is rejected because `BigDecimal#equals` compares the scale, so a literal of `1.5` never matches a |
There was a problem hiding this comment.
I think the same hazard exists elsewhere too TBH, so this isn't lookup join specific. I've updated this.
There was a problem hiding this comment.
Nice, thanks Yash!
Both of these seem like they could be added as filters after the join, which would increase our SQL compatibility
Agreed, but I'd rather add support for these as follow ups rather than bloating this bug fix PR further.
+1, just thinking out loud
There was a problem hiding this comment.
Pull request overview
Fixes lookup joins by constructing dimension-table lookup keys in primary-key order, including literal-bound key components.
Changes:
- Compiles and validates lookup-key plans.
- Improves dimension-table test mocks and type handling.
- Adds unit and end-to-end regression coverage.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
LookupJoinOperator.java |
Builds and validates primary-key lookup plans. |
LookupJoinOperatorTest.java |
Tests key-plan ordering, constants, and validation. |
ResourceBasedQueriesTest.java |
Enhances dimension-table mocks. |
LookupJoin.json |
Adds composite-key regression scenarios. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| switch (fieldSpec.getDataType().getStoredType()) { | ||
| case INT: | ||
| return ((Number) value).intValue(); | ||
| case LONG: | ||
| return ((Number) value).longValue(); | ||
| case FLOAT: | ||
| return ((Number) value).floatValue(); | ||
| case DOUBLE: | ||
| return ((Number) value).doubleValue(); | ||
| case STRING: | ||
| return value.toString(); | ||
| default: | ||
| return value; | ||
| } |
| "ignoreLiteMode": true | ||
| }, | ||
| { | ||
| "description": "A literal binds the LONG primary-key column. The planner types the literal, and a literal of the wrong numeric width misses every row, so the operator converts it to the stored type of the column.", |
| // SEMI and ANTI joins project the left columns only, so an evaluator built over the join result schema cannot | ||
| // reference a dimension table column. Reject the combination here, otherwise the loop below fails with an index | ||
| // error that says nothing about the cause. | ||
| Preconditions.checkState(nonEquiConditions.isEmpty() || _joinType.projectsRight(), | ||
| "Lookup join type: %s does not support non-equi join conditions, got: %s", _joinType, nonEquiConditions); |
There was a problem hiding this comment.
Seems redundant to add a test for a clearly unsupported path.
…escription Reading the segment with PinotSegmentRecordReader is what a real DimensionTableDataManager does, so the mock no longer keeps its own partial copy of the stored-type conversion rules.
Problem
A lookup join reads the dimension table by primary key. The dimension table is a hash map, and the map key is an array
of the primary key values.
LookupJoinOperatorbuilt that array from the equi-join keys, in the order that the joincondition lists them. The array matched the map key only by accident.
Six query shapes gave no rows or wrong rows because of this. None of them gave an error.
Fixes #19188.
The faults
Every case below is broken on the legacy planner and with
usePhysicalOptimizer=true. The dimension table primary keyis
[currency, rate_start_date].dim.currency = 'gbp' AND dim.rate_start_date = fact.rate_start_dateWHERELEFT JOINdim.rate_start_date = fact.rate_start_date AND dim.currency = fact.currency... AND dim.rate = fact.amount, whererateis not a primary key columndim.rate_start_date = fact.rate_start_datealoneRoot cause
The map key has three requirements. The old code met none of them.
Length. Calcite reads
dim.currency = 'gbp'as a non-equi condition, not as an equi-join key. The operator builtthe key from the equi-join keys alone, so a primary key column that a literal supplies was absent. The key was shorter
than the map key, and every lookup missed. The literal ran as a filter after the lookup, so it never had an effect.
Order. The key positions followed the order of the join condition.
rightKeysnames the dimension column of eachequi-join key, but the operator never read it. Two conditions in the other order gave two key values in the wrong
places.
Completeness. A join key on a column outside the primary key has no position in the key. The old code counted it
in the key length, so the key was too long and missed every row. A fix that only skips such a key is worse. The
condition is absent from the non-equi conditions, so no filter applies it. The join then returns rows that do not
match the condition, which is why this pull request raises an error instead.
The fix
The constructor now compiles a key plan. The plan holds one entry per primary key column, in the order that the
dimension table schema declares them. Two passes fill the plan.
rightKeys[i]names a dimension column. The position of that column in the primary key decideswhere
leftKeys[i]lands. The key no longer depends on the order of the join condition.dim_column = literalfills a position that pass 1 left open.A constant never replaces an equi-join key. The equi-join key is held nowhere else, so a replacement drops a join
condition and adds wrong rows. A constant on a position that pass 1 filled stays a filter that runs after the lookup,
which is what SQL requires.
The operator does not convert a constant. The planner coerces the operands of a comparison, so a constant compared
against a dimension column already carries the type of that column. The operator checks that this holds, and names the
table and column if it ever stops.
PrimaryKeycompares values withequals, where anIntegernever equals aLong, so a constant of another type misses every row.A constant that is null makes every lookup miss, because a null never matches a primary key value.
A constant on a
BYTESprimary key column is rejected. A dimension table reads its key values as a rawbyte[]. TheequalsandhashCodeof abyte[]are identity, so no constant of any representation can match one. Thesingle-stage
lookuptransform function has the same limit. The fix for both belongs inDimensionTableDataManager.A constant on a
BIG_DECIMALprimary key column is allowed.BigDecimalcompares its scale, so1.5does not match astored
1.50. A hash join carries the same hazard, and this operator does not single out one arm of it.SEMI and ANTI lookup joins project the left columns only, so a filter over the join result cannot read a dimension
column. The operator now rejects a non-equi condition for those two join types. The old code failed with an index
error from inside the filter instead.
Errors in place of empty results
The key plan rejects a join condition that cannot give exactly one value per primary key column:
Each of these gave no rows or wrong rows before this change, so an error is the better outcome. The error names the
columns and tells the user to add the missing conditions or to remove the lookup join hint. This is the contract that
the single-stage
lookuptransform function already enforces.This is a behavior change. A query that returns 0 rows in silence today can now return an error. A query that returns
correct rows today is not affected.
Test harness
Two faults in
ResourceBasedQueriesTestkept this area out of reach of tests.DimensionTableDataManagerdid not stubgetPrimaryKeyColumns(). Mockito returned an empty list, so anyfix that reads the primary key gets a key of length 0 and breaks every lookup join test.
map held an
Integerwhere the query supplied aLong, so a lookup missed for a reason outside the code undertest. A test with a
LONGprimary key column cannot pass.This pull request corrects both.
Tests
LookupJoin.jsonruns end to end on the legacy planner and withusePhysicalOptimizer=true:ON, and the same condition inWHERELEFT JOIN, including a fact row with no matchLONGprimary key columnIN, and a join key outside the primary keyLookupJoinOperatorTesttests the key plan directly, for the cases that a query cannot reach:BIG_DECIMALcolumn, which is allowed, and on aBYTEScolumn, which is rejectedI ran each new test against the fault that it targets, and each one fails without the fix.
Related pull requests
This supersedes #19200.