Repository navigation
fix(substrait): Preserve pushed-down table scan offsets and limits - #26035
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #26035 +/- ##
==========================================
+ Coverage 82.64% 82.82% +0.18%
==========================================
Files 1147 1149 +2
Lines 445883 452364 +6481
Branches 445883 452364 +6481
==========================================
+ Hits 368513 374687 +6174
+ Misses 54982 54871 -111
- Partials 22388 22806 +418 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
bvolpato
left a comment
There was a problem hiding this comment.
LGTM, thanks for fixing this. Left one nonblocking comment on the signed-range edge case.
| .map(fetch_rel::OffsetMode::OffsetExpr); | ||
| let count_mode = scan | ||
| .fetch | ||
| .map(|fetch| producer.handle_expr(&lit(fetch as i64), &empty_schema)) |
There was a problem hiding this comment.
nonblocking: could we handle scan counts above i64::MAX before this cast? Optimizing SELECT * FROM t LIMIT 9223372036854775807 OFFSET 1 against a MemTable sets scan.fetch to 9223372036854775808. I reproduced the roundtrip failing with LIMIT must be >= 0, '-9223372036854775808' was provided, while the original plan and base producer succeed.
There was a problem hiding this comment.
Thanks. Fixed in latest update.
bvolpato
left a comment
There was a problem hiding this comment.
LGTM, thanks for fixing this. Rechecked the signed-range handling and offset preservation. The focused roundtrip checks pass locally, and the overflow regression fails with the previous implementation.
| limit: &Limit, | ||
| ) -> datafusion::common::Result<Box<Rel>> { | ||
| let input = producer.handle_plan(limit.input.as_ref())?; | ||
| // Pushing down LIMIT + OFFSET can produce a scan fetch above i64::MAX. |
There was a problem hiding this comment.
i64::MAX is pretty large -- 9,223,372,036,854,775,807
What is the usecase for a query that skips / offsets that many?
In other words I question if we need special case handling for fetch/offset
| Ok(()) | ||
| } | ||
|
|
||
| // Limit expressions use Int64, but limit pushdown can produce a larger usize fetch. |
There was a problem hiding this comment.
this feels to me like it is unnecessary (again for a i64 max ...) I think it would be better just to error if the limit/offset overflowed i64::MAX and we can fix it after if we find a real usecase
There was a problem hiding this comment.
It is to address the previous comment #26035 (comment). Let me change it to do an error instead.
|
Thanks @alamb. I removed the special handling of pretty big fetch. For over i64::MAX, an error is reported instead now. |
| scan: &TableScan, | ||
| ) -> datafusion::common::Result<Box<Rel>> { | ||
| let skip = scan.skip.map(i64::try_from).transpose().map_err(|_| { | ||
| not_impl_datafusion_err!("TableScan offset exceeds the supported Int64 range") |
| } | ||
|
|
||
| #[tokio::test] | ||
| #[cfg(target_pointer_width = "64")] |
There was a problem hiding this comment.
This feels like an unecessary test now -- it isn't wrong just maybe not all tha timportant
| let offset_mode = skip | ||
| .map(|skip| producer.handle_expr(&lit(skip), &empty_schema)) | ||
| .transpose()? | ||
| .map(Box::new) | ||
| .map(fetch_rel::OffsetMode::OffsetExpr); | ||
| let count_mode = fetch | ||
| .map(|fetch| producer.handle_expr(&lit(fetch), &empty_schema)) | ||
| .transpose()? | ||
| .map(Box::new) | ||
| .map(fetch_rel::CountMode::CountExpr); |
There was a problem hiding this comment.
| let offset_mode = skip | |
| .map(|skip| producer.handle_expr(&lit(skip), &empty_schema)) | |
| .transpose()? | |
| .map(Box::new) | |
| .map(fetch_rel::OffsetMode::OffsetExpr); | |
| let count_mode = fetch | |
| .map(|fetch| producer.handle_expr(&lit(fetch), &empty_schema)) | |
| .transpose()? | |
| .map(Box::new) | |
| .map(fetch_rel::CountMode::CountExpr); | |
| let offset_mode = skip | |
| .map(|skip| producer.handle_expr(&lit(skip), &empty_schema)) | |
| .transpose()? | |
| .map(|expr| fetch_rel::OffsetMode::OffsetExpr(Box::new(expr))); | |
| let count_mode = fetch | |
| .map(|fetch| producer.handle_expr(&lit(fetch), &empty_schema)) | |
| .transpose()? | |
| .map(|expr| fetch_rel::CountMode::CountExpr(Box::new(expr))); |
Which issue does this PR close?
Rationale for this change
Substrait roundtripping loses pushed-down TableScan offsets and limits when no parent Limit exists. A Projection between the scan and a parent Limit also bypasses the existing offset compensation, changing query results.
What changes are included in this PR?
i64::MAXreturn an explicit unsupported-range error instead of being encoded as negative values.What is the testing strategy for this PR?
Verified the offset-only regression against upstream 6047791: the original scan returns [2, 3, 4, 5], while the roundtripped scan incorrectly returns all six rows. The new regression test passes with the fix. An additional regression test verifies that the pushed-down fetch from
SELECT * FROM t LIMIT 9223372036854775807 OFFSET 1is rejected with an explicit unsupported-range error.Passed:
No relevant local Substrait producer benchmark is defined.
Are there any user-facing changes?
Substrait roundtrips preserve pushed-down table-scan offsets and limits, including across projections and nested limits. No public API changes.