-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add IGNORE NULLS option to FIRST_VALUE and LAST_VALUE window functions #14264
Add IGNORE NULLS option to FIRST_VALUE and LAST_VALUE window functions #14264
Conversation
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #14264 +/- ##
============================================
+ Coverage 61.75% 63.82% +2.07%
- Complexity 207 1556 +1349
============================================
Files 2436 2660 +224
Lines 133233 145674 +12441
Branches 20636 22287 +1651
============================================
+ Hits 82274 92981 +10707
- Misses 44911 45822 +911
- Partials 6048 6871 +823
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. |
6a01d4b
to
f766ec0
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggest adding some end-to-end tests into ResourceBasedQueriesTest
. See WindowFunctions.json
for references
// Window functions are only supported in the multi-stage query engine | ||
setUseMultiStageQueryEngine(true); | ||
String sqlQuery = | ||
"SELECT salary, LAST_VALUE(salary) IGNORE NULLS OVER (ORDER BY DaysSinceEpoch) AS gapfilledSalary from " |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does it work with bounded preceding/following? I remember running into some exception when trying it out
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it works with bounded preceding / following as well.
I remember running into some exception when trying it out
Was it something like this error during query planning:
Caused by: java.lang.RuntimeException: Failed to convert query to relational expression:
...
Caused by: java.lang.AssertionError: Conversion to relational algebra failed to preserve datatypes:
Interestingly, it looks like Calcite throws this error if the window function's input column is not nullable in the table schema (i.e., if enableColumnBasedNullHandling
is false
or the column has "notNull": true
) and IGNORE NULLS
or RESPECT NULLS
option is used. While the error message is not the most clear, I don't think this is an actual bug a major issue because it doesn't really make sense to use those null handling related options if the window function's input column is not nullable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess we want to document it. Users might enable table level nullability (v1 engine nullability) and expect v2 engine to pick it up
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, good point, I'll make sure to add a note about this to the documentation. I plan to raise one consolidated documentation PR with changes from #14273 and here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should delegate this into something we document. We should also report the issue in Calcite Jira/email list and/or create a PR to fix it ourselfs. Same happened with the reserved keyword PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, actually this does look like a legitimate bug in Calcite on taking a second look. The issue is that the inferred return type for the window function in the parsed SqlNode
is INTEGER NOT NULL
when IGNORE NULLS
option is used (assuming the column input to FIRST_VALUE
/ LAST_VALUE
is INTEGER
) and the converted return type is INTEGER
(nullable) because offset based window frame bounds means that the result can be null
when the window frame is out of bounds - which can't be the case when using RANGE
window frames or ROWS
window frames with UNBOUNDED PRECEDING
/ UNBOUNDED FOLLOWING
/ CURRENT ROW
. When IGNORE NULLS
option is not provided, the inferred return type for the window function in the parsed SqlNode
is also INTEGER
(nullable) which is why the issue doesn't occur there. Same when IGNORE NULLS
option is used but input column is nullable.
Interestingly, the same error and issue also occurs when the RESPECT NULLS
option is explicitly provided.
I'll create a bug tracking Jira in the Calcite project and link it here.
// WINDOW Functions (non-aggregate) | ||
SqlStdOperatorTable.LAST_VALUE, | ||
SqlStdOperatorTable.FIRST_VALUE, | ||
// TODO: Replace these with SqlStdOperatorTable.LEAD and SqlStdOperatorTable.LAG when the function implementations |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this mean IGNORE NULLS
are simply ignored?
I'd suggest using the standard operator, and throw exception when IGNORE NULLS
is specified but cannot be supported to make the behavior more explicit
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this mean IGNORE NULLS are simply ignored?
Nope, using IGNORE NULLS
with LAG
/ LEAD
will lead to a clear error like this during query planning - From line 1, column 43 to line 1, column 60: Cannot specify IGNORE NULLS or RESPECT NULLS following 'LAG'
. This is because the custom operators we defined return false
for allowsNullTreatment
which means this Calcite validation will fail - https://github.com/apache/calcite/blob/ef1a83f659e8771c65c2541b92d2ef9cc2a05bea/core/src/main/java/org/apache/calcite/sql/SqlNullTreatmentOperator.java#L69-L74.
I'd initially gone with simply throwing a runtime exception in Pinot's LagValueWindowFunction
/ LeadValueWindowFunction
runtime operators, but the alternative chosen here suggested by @gortiz (defining our own custom SqlAggFunction
s) is much better because we fail fast during query planning instead of query execution and the error is also clear.
4057a4d
to
95ecd6f
Compare
...main/java/org/apache/pinot/query/runtime/operator/window/value/FirstValueWindowFunction.java
Outdated
Show resolved
Hide resolved
} | ||
lowerBound++; | ||
|
||
if (upperBound < numRows - 1) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some javadoc would help explain the logic here
} | ||
|
||
// Slide the window forward by one row | ||
if (indexOfFirstNonNullValue == lowerBound) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we do this check only if indexOfFirstNonNullValue != -1
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch, we'd be doing a pointless iteration from 0 to upper bound when we're at lowerBound = -1
👍
return result; | ||
} | ||
|
||
private List<Object> processRowsWindowIgnoreNulls(List<Object[]> rows) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we short circuit the unbounded case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think there's much benefit to that here? In the unbounded case for FIRST_VALUE
/ LAST_VALUE
with IGNORE NULLS
, we'll find the first / last non-null value in the first window (which will encompass all rows) at the beginning and then in each iteration there's only some simple boolean checks which will all be false
in every iteration of the loop and we'll simply keep adding the same value to the result list.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can replace the for loop to nCopy, and also short-circuit all if checks. The total cost of the query is just finding the first/last non-null value, so the save could potentially be relatively significant.
From high level, for UNBOUNDED PROCEEDING, CURRENT ROW, UNBOUNDED FOLLOWING
, the behavior of ROWS
and RANGES
should be the same. So we could potentially split the handling into 3 cases:
- Without bounded proceeding/following
- ROWS with bounded proceeding/following
- RANGE with bounded proceeding/following - not supported
We can discuss and address this in a separate PR
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From high level, for UNBOUNDED PROCEEDING, CURRENT ROW, UNBOUNDED FOLLOWING, the behavior of ROWS and RANGES should be the same
The behavior will only be the same for UNBOUNDED PRECEDING TO UNBOUNDED FOLLOWING
right? With CURRENT ROW
as either lower or upper bound or both, the behavior and logic are both significantly different due to the need to consider peer groups (rows before and after with the same order key) along with current row with RANGE
type window frames.
We can replace the for loop to nCopy, and also short-circuit all if checks. The total cost of the query is just finding the first/last non-null value, so the save could potentially be relatively significant.
This makes sense for true unbounded case though (UNBOUNDED PRECEDING TO UNBOUNDED FOLLOWING
, CURRENT ROW
is not really unbounded), I've raised a small follow-up PR - #14324.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. Good point on the peer group difference
05462ad
to
69dc900
Compare
@@ -326,6 +326,31 @@ public void testAggregateServerReturnFinalResult(boolean useMultiStageQueryEngin | |||
assertTrue(response.get("resultTable").get("rows").get(0).get(0).isNull()); | |||
} | |||
|
|||
@Test | |||
public void testWindowFunctionIgnoreNulls() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we divide this test method into two different ones?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I follow your suggestion - there's a single query here.
...anner/src/main/java/org/apache/pinot/query/planner/serde/RexExpressionToProtoExpression.java
Outdated
Show resolved
Hide resolved
} | ||
} | ||
} | ||
lowerBound++; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is assuming lowerBound is Integer.MIN_VALUE if unbounded, right? Although it can never turn into 0 due to the fact that maxRows is bound to Integer.MAX_VALUE... don't you think it is a bit difficult to understand?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, this convention was added in #14273, and was chosen since it was the least invasive given the existing framework for window functions. I've documented this in a couple of places -
pinot/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/plannode/WindowNode.java
Lines 33 to 37 in 0f984e8
// Both these bounds are relative to current row; 0 means current row, -1 means previous row, 1 means next row, etc. | |
// Integer.MIN_VALUE represents UNBOUNDED PRECEDING which is only allowed for the lower bound (ensured by Calcite). | |
// Integer.MAX_VALUE represents UNBOUNDED FOLLOWING which is only allowed for the upper bound (ensured by Calcite). | |
private final int _lowerBound; | |
private final int _upperBound; |
Lines 31 to 35 in 0f984e8
// Both these bounds are relative to current row; 0 means current row, -1 means previous row, 1 means next row, etc. | |
// Integer.MIN_VALUE represents UNBOUNDED PRECEDING which is only allowed for the lower bound (ensured by Calcite). | |
// Integer.MAX_VALUE represents UNBOUNDED FOLLOWING which is only allowed for the upper bound (ensured by Calcite). | |
private final int _lowerBound; | |
private final int _upperBound; |
Although it can never turn into 0 due to the fact that maxRows is bound to Integer.MAX_VALUE... don't you think it is a bit difficult to understand?
The logic here doesn't take that assumption into account though, we're handling all cases of lowerBound
/ upperBound
(-ve / +ve). This way is also convenient because we don't need to worry about handling overflows everywhere.
for (int i = Math.max(lowerBound, 0); i <= upperBound; i++) { | ||
Object value = extractValueFromRow(rows.get(i)); | ||
if (value != null) { | ||
indexOfFirstNonNullValue = i; | ||
break; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code is also repeated when the lower bound is moved. I think it is worth it to move the code to its own function. Something like findFirstNotNullInWindow(rows, lowerBound, upperBound)
. Not only makes the code easier to read but also makes the job easier for the jit trying to optimize the loop.
List<Object> result = new ArrayList<>(numRows); | ||
// Find the start of the peer group of the row with the first non-null value | ||
int i; | ||
for (i = 0; i < numRows; i++) { | ||
Object[] row = rows.get(i); | ||
Key orderKey = AggregationUtils.extractRowKey(row, _orderKeys); | ||
if (orderKey.equals(firstNonNullValueKey)) { | ||
break; | ||
} else { | ||
result.add(null); | ||
} | ||
} | ||
|
||
Object firstNonNullValue = extractValueFromRow(rows.get(firstNonNullValueIndex)); | ||
for (; i < numRows; i++) { | ||
result.add(firstNonNullValue); | ||
} | ||
|
||
return result; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Here we could reduce allocation cost if using a custom list that could combine two lists. I'm shocked about the lack of a list like that in Guava, but shouldn't be difficult to create one. The idea would be to create two lists like Collections.nCopies. The first would contain just nulls and the second just the first non null value. Finally we wrap these two instances in a view whose get delegates on either the first or the second list depending on whether the index is greater or smaller than firstNonNullValueIndex.
If this pattern is seen in more window functions, to create this combine list view would worth the effort
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice suggestion! I couldn't find any such off the shelf implementation either so I've created a small new class called DualValueList
and added it to pinot-common
in case we find use cases elsewhere in the codebase.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remember you can remove several //@Formatter:off/on
… more verbose comments explaining logic in FIRST_VALUE / LAST_VALUE with IGNORE NULLS and ROWS window frame; initialize ArrayList with known final size
…n has only 1 or 2 repeated values
69dc900
to
103051b
Compare
RESPECT NULLS
orIGNORE NULLS
option for the window functionsLEAD
,LAG
,FIRST_VALUE
,LAST_VALUE
, andNTH_VALUE
(although Pinot currently doesn't support this function). The default behavior isRESPECT NULLS
.FIRST_VALUE
andLAST_VALUE
window functions (LEAD
/LAG
can be added in a subsequent patch). As the name suggests, theIGNORE NULLS
option makes it so that theFIRST_VALUE
andLAST_VALUE
window functions compute the first and last non-null values respectively for each window frame.IGNORE NULLS
is specified likeLAST_VALUE(col1) IGNORE NULLS OVER (ORDER BY ts)
, it can effectively be used to gapfill data (see this article for example - https://learn.microsoft.com/en-us/azure/azure-sql-edge/imputing-missing-values).IGNORE NULLS
/RESPECT NULLS
operators are only used with window functions that they are applicable to as per standard SQL. This patch also updates the operators being registered in Pinot's operator table forLEAD
/LAG
since we don't currently support the null related options for those functions (this way, we fail during query planning rather than at runtime).IGNORE NULLS
option for a window function call.