Skip to content

fix: clear error instead of panic on mismatched column statistics - #25694

Open
jackylee-ch wants to merge 16 commits into
apache:mainfrom
jackylee-ch:fix-count-stats-guard
Open

jackylee-ch wants to merge 16 commits into
apache:mainfrom
jackylee-ch:fix-count-stats-guard

Conversation

@jackylee-ch

@jackylee-ch jackylee-ch commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Extensions can report their own Statistics: a custom ExecutionPlan, such as a table provider's scan, or a StatisticsProvider registered on the session. column_statistics must have one entry per field of the plan's schema, but nothing validates this when statistics are reported, and it is easy to get wrong: starting from Statistics::default() gives no entries, and returning a child's statistics from a plan with fewer columns gives too many.

Three downstream projects have hit this:

Each failure surfaced far from its cause, as an index out of bounds, with nothing naming the plan that reported the statistics. Rules and operators read a column's statistics by its index, so a mismatch stays hidden until some code reads that column, and a DataFusion upgrade can add such code. Too many entries can also give wrong answers: in a test where a scan of b got the statistics of both a and b, SELECT min(b), max(b) returned the min and max of a.

What changes are included in this PR?

The planner and optimizer rules get plan statistics through StatisticsContext. It now checks that the statistics of every plan and statistics provider have one entry per field, so a rule or operator that would read wrong statistics gets an error instead, naming the plan or provider that reported them, e.g. MyExec returned 1 column statistics, expected 2 (one per field of its schema). The default EnsureRequirements rule, which adds the repartitions and sorts a plan needs to run, computes the statistics of each operator's input, so even a plain SELECT * over such a plan fails at planning with this error. Every other query we tried, from filters to joins and aggregates, failed the same way, so an extension's own tests should catch the mistake. Padding with unknown statistics instead would hide it.

value_from_stats, which computes an aggregate from statistics alone, is public, so callers can pass it statistics that never went through this check, and it returns an Option, so it cannot report an error. The count, min and max implementations therefore return None on too few entries instead of panicking, as sum's already does.

Not covered: the table-level and per-file statistics that a table provider passes to a file scan. Files can legitimately lack statistics for some columns (virtual columns have none; delta-rs skips them when there is no predicate to prune with), so a missing entry there is not a bug, and the built-in file scans fill it in as unknown. That layer needs a different rule, allowing missing entries but not extra ones, so this PR keeps to one rule in one place and drops the extra-entries check an earlier version added there. The statistics the scan itself reports are still checked.

What is the testing strategy for this PR?

End-to-end tests in core/tests/physical_optimizer/aggregate_statistics.rs reproduce too few and too many entries from SQL (on main they panic or return the wrong min/max) and check the new error. Unit tests cover plans and providers reporting too few and too many entries. The check is one length comparison, about 5 ns, each time a plan's statistics are computed. The compute_statistics microbenchmarks, which only compute statistics, are up to 7% slower. Total planning time, this PR vs main (median of 3 runs): TPC-H (21 queries, all but q15) 15.24 vs 15.17 ms; TPC-DS (99 queries) 240.3 vs 240.1 ms.

Are there any user-facing changes?

Yes, breaking for extensions: a plan or statistics provider with the wrong number of column statistics now fails planning, even for queries such as SELECT * that worked before. The upgrade guide describes the fix: start from Statistics::new_unknown(&schema), or project a child's statistics to the plan's columns with Statistics::project. Please add the api change label.

count's statistics fast path indexed column_statistics[col.index()]
directly, so a TableProvider reporting fewer column statistics than its
schema made SELECT count(col) panic. Use .get(idx)? in both branches,
matching the guard already in sum.rs.
@github-actions github-actions Bot added the functions Changes to functions implementation label Sep 24, 2026
@codecov-commenter

codecov-commenter commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.94%. Comparing base (3ee3e39) to head (9313de8).
⚠️ Report is 131 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #25694   +/-   ##
=======================================
  Coverage   82.93%   82.94%           
=======================================
  Files        1154     1154           
  Lines      458002   458075   +73     
  Branches   458002   458075   +73     
=======================================
+ Hits       379856   379930   +74     
+ Misses      55123    55122    -1     
  Partials    23023    23023           

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

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

@jackylee-ch,

Thanks for fixing this. Both COUNT and COUNT(DISTINCT) now handle missing column statistics safely, and the new regression tests cover both paths. The change looks good to me. I have one optional suggestion for additional end-to-end coverage.

Ok(())
}

#[test]

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.

It would be useful to add an end-to-end regression test with incomplete column statistics, NULLs, and duplicates to verify that aggregation is preserved and returns the correct counts. Please test COUNT(b) and COUNT(DISTINCT b) separately, since the optimizer stops at the first unresolved expression, and make sure the column index remains out of bounds after any projection or remapping. This is optional since the existing unit tests already cover both guarded accesses.

@jackylee-ch jackylee-ch Oct 14, 2026 •

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.

Edit: superseded by d032a22. Short column statistics now fail planning with an error naming the provider, which also covers COUNT(DISTINCT b) (rewritten into a GROUP BY), and the end-to-end test short_column_statistics_are_reported checks that error for count(b) and a plain scan. An earlier version of this reply said test_count_distinct_optimization gained a short-statistics case; that was wrong: the case passed without the guard and was never pushed. The count unit test covers both branches.

A statistics provider that drops the trailing column entry keeps the
aggregated column's index out of bounds whatever the projection, so
`count`'s statistics fast path is reached from SQL. The query panics
on main and returns the correct count with the guard in place.

@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 for this PR @jackylee-ch and for the review @kosiew

I feel like this is just papering over a bug and by sliently ignoring the problem we will make debugging harder

Changing the code so it doesn't panic seems fine, but if the table provider returns the wrong number of statistics I think that should return a clear error

@jackylee-ch

Copy link
Copy Markdown
Contributor Author

Agreed — it should be an error. Two candidates: the provider/source boundary, or StatisticsContext::compute_base, where provider and built-in statistics meet; that one also covers min_max and AggregateExec, which index it the same way.

One thing first: planning-time rules compute statistics (join_selection, limit_pushdown), so the broad gate also fails SELECT * ... LIMIT at planning time, not only count. With it the sqllogictests and core_integration pass; in physical-plan, two StatisticsProvider fixtures reporting no column statistics fail. Which do you prefer?

@alamb

alamb commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Which do you prefer?

I don't understand what you are asking me

What is the "broad gate"?

`Statistics::column_statistics` must hold one entry per schema field,
and consumers index it by column. Check that where `StatisticsContext`
receives statistics from an operator or a statistics provider, and fail
with an error naming the culprit instead of panicking in a consumer.
`value_from_stats` is public, so a direct caller can still pass short
statistics; fold the two `count` cases into one loop. Link
`ColumnStatistics` in the `StatisticsProvider` docs.
`min` and `max` index `column_statistics` like `count` did, and
`value_from_stats` is public, so use `.get(idx)?` there as well. The
end-to-end test now also checks that a plain scan fails planning.
Extra entries never indexed out of bounds; only missing ones did.
`FilterExec` indexes its input's column statistics when it is built, so
a table provider returning too few entries also panicked on `WHERE`.
A statistics provider returning the statistics of the whole table for a
scan of only `b` used to make `min(b)` and `max(b)` return the minimum
and maximum of `a`. Check end to end that planning fails instead, and
that both statistics sources reject one entry too many as well as too
few.

Also pin what file scans accept: with a projection, as all built-in
sources have, table and file group statistics of any length, missing
trailing entries being unknown and extra ones ignored; without one,
exactly one entry per table column. Say so in the upgrade guide.
`FileScanConfig` pads table and file group statistics it is given and
ignores extra entries when its source has a projection, so a missing or
extra entry before the last column shifts the statistics of the columns
after it. Describe that as unchecked rather than as accepted.
`FileScanConfig` looks up the table and file group statistics it is
given by column position, so an extra entry anywhere but at the end
shifted the statistics of every later column by one, and `min`/`max`
could be answered from another column's values. Return an internal
error naming the statistics instead. Missing trailing entries are still
unknown, which virtual columns and some table providers rely on.
@github-actions github-actions Bot added documentation Improvements or additions to documentation core Core DataFusion crate datasource Changes to the datasource crate physical-plan Changes to the physical-plan crate labels Oct 16, 2026
@jackylee-ch jackylee-ch changed the title fix: guard against out-of-bounds column statistics in count's fast path fix: reject statistics whose column count does not match the schema Oct 16, 2026
@jackylee-ch

Copy link
Copy Markdown
Contributor Author

Sorry, that was unclear. "Broad gate" meant one check in StatisticsContext, which sees what every operator and provider returns. This PR adds it: unless column_statistics has one entry per field, planning fails with an error naming the culprit.

Too few entries can make filters, joins, GROUP BY, count, min/max panic, too many can give min/max another column's values; such plans now fail, plain scans too. File scans also reject extras but still pad short statistics (virtual columns, delta-rs). count/min/max use .get(idx)?: value_from_stats cannot return an error.

@alamb

alamb commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

I am sorry -- I still don't get it.

How about we start with a clear and concise description of what problem you are solving (from the end user API perspective, not a code description)

Describe:

  1. What scenario are you hitting
  2. What happens today
  3. What your proposed behavior ?

It seems like you are trying to add better error messages when a table provider provides incorrect statistics ? But that is not clear

The error now reads, for example, "MyExec returned 1 column statistics,
expected 2 (one per field of its schema)", instead of starting with
"Assertion failed: column_statistics == fields (left: 1, right: 2)".

Also drop the FileScanConfig check for extra table and file group column
statistics. When its FileSource has a projection, FileScanConfig fills in
missing statistics as unknown on purpose, for virtual columns and some
table providers, so it needs a different check; only the statistics of
plans and statistics providers are checked now.

Note on Statistics::default() that it does not fit a schema with fields.
In the upgrade guide, use self.schema() in the example and mention
Statistics::project for plans that drop or reorder their input's columns.
@github-actions github-actions Bot removed the datasource Changes to the datasource crate label Oct 20, 2026
@github-actions github-actions Bot added the common Related to common crate label Oct 20, 2026
@jackylee-ch jackylee-ch changed the title fix: reject statistics whose column count does not match the schema fix: clear error instead of panic on mismatched column statistics Oct 20, 2026
@jackylee-ch

Copy link
Copy Markdown
Contributor Author

I've updated the description; does it answer your questions? Your guess is right: a clear planning error instead of a panic or a wrong answer. In short:

  1. Scenario: a table provider's scan reports statistics for the wrong number of columns. In fix(datafusion): schema-size ProximaScanExec stats (DF54 aggregate panic) anvai-labs/proximaDB#76, the scan fell back to Statistics::default(), which has no column entries; the description has two more cases.
  2. Today: rules and operators read column statistics by position, and nothing checks the count when a plan reports them, so a mismatch surfaces wherever they are first read. DataFusion 54's AggregateExec indexes its input's column statistics, so after upgrading, proximaDB's aggregate queries panicked there (index out of bounds: the len is 0 but the index is 0), with nothing pointing at the scan. With too many entries, min/max can return another column's values.
  3. Proposed: check the count whenever statistics are reported for a plan node, and fail planning with an error naming that node, e.g. MyExec returned 1 column statistics, expected 2 (one per field of its schema). Queries over such a scan then fail at planning, even SELECT *, which works today; the upgrade guide shows the fix.

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

Labels

common Related to common crate core Core DataFusion crate documentation Improvements or additions to documentation functions Changes to functions implementation physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants