Skip to content

fix(substrait): Preserve pushed-down table scan offsets and limits - #26035

Merged
viirya merged 5 commits into
apache:mainfrom
viirya:codex/substrait-scan-offset
Oct 12, 2026
Merged

viirya merged 5 commits into
apache:mainfrom
viirya:codex/substrait-scan-offset

Conversation

@viirya

@viirya viirya commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

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?

  • Encode a scan's skip/fetch in a FetchRel wrapping its ReadRel.
  • Serialize each parent Limit's own offset without adding the scan offset again.
  • Use checked conversions for scan offsets and fetch counts; values above i64::MAX return an explicit unsupported-range error instead of being encoded as negative values.
  • Add execution-based roundtrip coverage for 96 combinations of scan offsets, fetch counts, optional projections, and parent limits, including zero limits and offsets beyond the input.

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 1 is rejected with an explicit unsupported-range error.

Passed:

  • cargo fmt --all
  • cargo clippy --all-targets --all-features -- -D warnings
  • All Substrait integration tests: 227 passed, 6 ignored.
  • Required extended workspace tests: 12,388 passed, 0 failed, 8 ignored; all 526 SQLLogicTests files completed.
  • Contributor lint suite through formatting, its feature-specific Clippy check, TOML formatting, license headers, typos, documentation formatting and generation, workflow checks, large-file checks, Markdown links, and security audit.
  • Rust documentation check.

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.

@github-actions github-actions Bot added the substrait Changes to the substrait crate label Oct 4, 2026
@codecov-commenter

codecov-commenter commented Oct 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.37500% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.82%. Comparing base (6047791) to head (ba2c6d2).
⚠️ Report is 100 commits behind head on main.

Files with missing lines Patch % Lines
...ubstrait/src/logical_plan/producer/rel/read_rel.rs 82.14% 2 Missing and 3 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@viirya
viirya requested review from alamb, comphead and sunchao October 10, 2026 22:32

@bvolpato bvolpato 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 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))

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.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks. Fixed in latest update.

@bvolpato bvolpato 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 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.

@alamb alamb 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.

Thank you @viirya and @bvolpato

My only concern about this PR is the handling for offset+limit that overflows i64::MAX (it seems unecessary / unlikely to me)

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.

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.

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.

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 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It is to address the previous comment #26035 (comment). Let me change it to do an error instead.

@viirya

viirya commented Oct 11, 2026

Copy link
Copy Markdown
Member Author

Thanks @alamb. I removed the special handling of pretty big fetch. For over i64::MAX, an error is reported instead now.

@alamb alamb 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.

Thanks @viirya -- looks good to me

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")

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.

👍

}

#[tokio::test]
#[cfg(target_pointer_width = "64")]

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 feels like an unecessary test now -- it isn't wrong just maybe not all tha timportant

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed it. Thanks.

@comphead comphead 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.

Thanks @viirya

Comment on lines +127 to +136
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);

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.

Suggested change
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)));

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.

less maps

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks. Refactored it.

@viirya
viirya added this pull request to the merge queue Oct 12, 2026
Merged via the queue into apache:main with commit 39be5f2 Oct 12, 2026
43 checks passed
@viirya
viirya deleted the codex/substrait-scan-offset branch October 12, 2026 19:11
@viirya

viirya commented Oct 12, 2026

Copy link
Copy Markdown
Member Author

Thanks @bvolpato @alamb @comphead

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

Labels

substrait Changes to the substrait crate v56.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Substrait roundtrip loses pushed-down TableScan offsets and limits

5 participants