Skip to content

feat(datafusion): support ANALYZE TABLE on catalog-managed format tables - #815

Merged
JingsongLi merged 4 commits into
apache:mainfrom
sundapeng:feat/format-table-analyze
Sep 14, 2026
Merged

feat(datafusion): support ANALYZE TABLE on catalog-managed format tables#815
JingsongLi merged 4 commits into
apache:mainfrom
sundapeng:feat/format-table-analyze

Conversation

@sundapeng

@sundapeng sundapeng commented Sep 11, 2026

Copy link
Copy Markdown
Member

Purpose

Last child PR split out of #591. A Format Table loaded from a REST catalog with metastore.partitioned-table=true has its partitions registered in the catalog, and the catalog can hold statistics for each of them: record count, file count, size and last file creation time. The Rust client had no way to measure a partition and report them. This adds ANALYZE TABLE for such tables, following the Java PaimonAnalyzeFormatTablePartitionsCommand and FormatTablePartitionStatsCollector.

#816 and #817 have merged, so this PR now applies to main on its own.

This PR was rebased onto the restructured stack. ANALYZE itself is unchanged: its tests now use the shared helpers of #816, and a value in PARTITION (...) is read by the shared parse_format_partition_value that #816 introduced for partition literals.

Brief change log

  • ANALYZE TABLE t [PARTITION (...)] COMPUTE STATISTICS [NOSCAN] on a Format Table with catalog-managed partitions measures the registered partitions from storage and reports them through Catalog::create_partitions_with_statistics with replaceStatistics=true, so each measured field replaces what the catalog holds. It never adds or removes a partition.
    • NOSCAN reports the file count, total size and latest file modification time, and leaves the row count unknown (-1).
    • A full ANALYZE also reads Parquet and ORC footers for row counts. A footer that cannot be read leaves the row count of its partition unknown rather than short, a partition without files holds exactly zero rows, and other formats report an unknown row count.
    • PARTITION (...) selects a leading run of partition values; a column named without a value means every value of it. A prefix with no registered partition is an error.
    • Refused: FOR COLUMNS, CACHE METADATA, a missing COMPUTE STATISTICS, tables whose partitions the catalog does not manage, an empty or whitespace-only string in PARTITION (...) for a string column (it would name the default partition, as ADD and DROP in feat(datafusion): add SHOW, ADD and DROP PARTITION for catalog-managed format tables #816 refuse), and any selected partition at a custom location.
    • format-table.statistics.parallelism (a session setting, SET 'paimon.<key>', as in Java; default 8, at least 1) bounds the listings and footer reads in flight.
  • FormatTablePartitionStatsCollector, public in paimon::table, does the measuring. A listing failure fails the whole collection, since a truncated listing cannot be told apart from a partition that lost files. Only a partition directory that the listing itself reports as not found counts as empty, as in Java; an existence check is not trusted, because on object stores a HEAD on the directory key returns 404 even when files lie below it.
  • The Format Table scan's file listing is factored into list_format_table_data_files, which the collector shares, so a measurement counts exactly the files a scan of that partition reads and leaves committer staging trees out (the rule from fix(table): skip staging files and list format table partitions concurrently #813). A scan reads the same files as before, except that a listing failure is no longer read as an empty directory when an existence check on the root returns false.
  • read_file_row_count reads the row count of a Parquet or ORC file from its footer without decoding rows.
  • The statement lives in crates/integrations/datafusion/src/format_partition_analyze.rs and reuses the partition-spec helpers of format_partition_ddl.rs from feat(datafusion): add SHOW, ADD and DROP PARTITION for catalog-managed format tables #816; sql_context.rs only dispatches it.

Follow-up: using the reported row counts in DataFusion scan statistics, as apache/paimon#9351 does for Spark.

Tests

  • crates/integrations/datafusion/tests/rest_format_partition_sql.rs:
    • NOSCAN and full measurement replacing what the catalog holds, and a later NOSCAN keeping the known row counts;
    • PARTITION selecting a leading run of values, and its errors;
    • a partition value read as its column type;
    • staging trees, markers, hidden files and non-data files left out by both ANALYZE and a scan;
    • an unreadable footer leaving the row count unknown;
    • refusals: FOR COLUMNS, a blank string partition value, a partition at a custom location, a non-positive parallelism read as one, and a table without catalog-managed partitions.
  • format_table_scan::tests::test_data_file_listing_returns_what_a_partition_scan_reads for the shared listing, and test_listing_failure_is_not_read_as_an_empty_directory for a listing that fails before or after returning files.
  • The compile-time check that the future of SQLContext::sql stays Send now covers the new statement too.

Commands run on the top commit:

cargo fmt --all -- --check
cargo clippy --locked --all-targets --workspace --features fulltext,vortex -- -D warnings
cargo test --locked -p paimon --all-targets --features fulltext,vortex
cargo test --locked --no-fail-fast -p paimon-datafusion --all-targets
cargo test --locked -p paimon-rest-server --all-targets

fmt and clippy pass. paimon: 3000 tests pass. paimon-rest-server: 10 tests pass. paimon-datafusion: 781 pass, including the 16 tests in rest_format_partition_sql.rs, and 39 fail. The failures are the 39 tests that also fail by name on main in this environment, because they read fixture tables provisioned by make docker-up (two also need the lumina native library).

Additions without a direct Java counterpart

  • read_file_row_count reads a Parquet or ORC file's row count from its footer. Java counts rows with the format's SimpleStatsExtractor, which the Rust client does not have for Format Tables.
  • A full ANALYZE measures in process with bounded concurrency, where Spark spreads the footer reads over executors (PaimonAnalyzeFormatTablePartitionsCommand.measureOnExecutors).

API and Format

  • New public paimon::table::FormatTablePartitionStatsCollector.
  • New option format-table.statistics.parallelism, read from the SQLContext session.

The storage format is unchanged.

Documentation

docs/src/sql.md gains an ANALYZE TABLE subsection under Format Table Partitions and lists ANALYZE TABLE in the SQL support scope.

Split out of apache#591. A Format Table whose partitions the REST catalog
manages had no way to report what those partitions hold.

ANALYZE TABLE t [PARTITION (...)] COMPUTE STATISTICS [NOSCAN] measures the
registered partitions from storage and reports the result through
create_partitions_with_statistics with replaceStatistics, as Java
PaimonAnalyzeFormatTablePartitionsCommand does:

- NOSCAN stops at the listing: file count, byte size and the latest file
  modification time. A full ANALYZE also reads Parquet and ORC footers
  for row counts; a footer that cannot be read leaves the partition's row
  count unknown rather than short, and an empty partition holds exactly
  zero rows.
- PARTITION (...) selects a leading run of partition values. A prefix
  with no registered partition, a partition at a custom location, FOR
  COLUMNS and CACHE METADATA are refused, as is a table whose partitions
  the catalog does not manage.
- format-table.statistics.parallelism (default 8) bounds the listings and
  footer reads in flight.

The collector lists each partition through the same helper the scan now
uses, so a measurement counts exactly the files a scan of that partition
reads and leaves committer staging trees out. Its streams own their items
so that the future of every SQLContext statement stays Send.
Describe what NOSCAN and a full ANALYZE measure, how PARTITION selects
partitions, what is refused, and format-table.statistics.parallelism.
Comment blocks stay within two lines; the reasoning they carried moves
here.

- ANALYZE measures the registered partitions from storage, and each
  measured field replaces what the catalog holds, so a table catches up
  with writers the catalog never saw. NOSCAN stops at what a listing
  gives (file count, byte size, last file creation time); a full ANALYZE
  also reads every file footer for its row count.
- Analyzing never adds or removes a partition. There is no lock between
  its listing and the write, so a partition dropped in between can come
  back with its last measurement, the same last-writer-wins window every
  lock-free partition operation on these tables has.
- `format-table.statistics.parallelism` is a session setting, not a
  table option, like the Java Spark connector option of the same name.
  Partition listings and footer reads share it, so it bounds one large
  partition as much as many small ones.
- In a PARTITION clause a column named without a value means every value
  of it, and the valued columns must be a leading run of the keys:
  `PARTITION (dt = 'x', hour)` selects every hour of that day, while
  `PARTITION (hour = '00')` is rejected rather than widened. Values are
  spelled the way ADD PARTITION writes them, so `p = '01'` selects the
  INT partition registered as `1`.
- The collector lists through the scan listing, so a measurement counts
  exactly the files a reader returns and leaves committer staging trees
  out. A listing failure aborts the whole collection, since a truncated
  listing looks like a partition that lost files. A partition holding
  nothing measures as an exact zero with no last file to date. The
  result is a whole-partition measurement that a catalog replaces rather
  than adds up, and it never decides that a partition should exist.
- One file with an unknown row count makes its partition's count
  unknown: a sum missing a file, reported as exact, is worse than none.
- The shared listing returns files whose own name is not hidden and ends
  with the format's extension, outside any entry that
  `is_hidden_below_partitions` skips.
@sundapeng
sundapeng force-pushed the feat/format-table-analyze branch from fa132af to c29f3a3 Compare September 13, 2026 12:10
@sundapeng
sundapeng marked this pull request as ready for review September 13, 2026 12:10
let listings: Vec<Vec<FileStatus>> = futures::stream::iter(directories)
.map(|directory| async move {
// Each directory is a complete partition, so no partition level lies below it.
list_format_table_data_files(file_io, &directory, 0, format_extension).await

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.

[P2] Propagate listing failures before replacing partition statistics

The reused helper returns an empty list after any listing error when file_io.exists(root) is false. In the locked OpenDAL 0.58.2, OSS/S3 exists performs HEAD on the exact key: table/dt=a can return 404 even when table/dt=a/part.parquet exists. If LIST or a subsequent page fails and HEAD returns 404, full ANALYZE treats the partition as empty and replaces its record count, file count, and byte size with zero; NOSCAN also replaces the latter two with zero.

I reproduced this with fault injection: listing returned one file and then an error, while stat on the partition prefix returned NotFound. collect() returned Ok with all three statistics set to zero.

Please treat only an actual NotFound from the listing as an empty partition and propagate other listing failures before writing statistics back to the catalog.

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.

Good catch, and thanks for the fault-injection repro. Only a not-found root now reads as an empty partition; any other listing failure is returned before statistics are written, which also covers the scan path (1f87213).

…listing

A listing failure was read as an empty directory whenever `exists` on
the root returned false. On OSS and S3 in the locked OpenDAL 0.58.2,
`exists` is a HEAD on the exact key, so `table/dt=a` can report 404 while
`table/dt=a/part.parquet` exists. A LIST or a later page that failed then
made ANALYZE replace the partition's record count, file count and size
with zero, and made a scan silently skip the partition's files.

The listing now reads only a not-found error reported before anything was
listed as an empty directory, as Java FormatTableScan.listDataFiles and
FormatTablePartitionStatsCollector do with FileNotFoundException. Every
other failure, including a not-found after entries were listed, is
returned before any statistics are written back.

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

+1

@JingsongLi
JingsongLi merged commit 30d20c3 into apache:main Sep 14, 2026
14 checks passed
jerry-024 added a commit to jerry-024/paimon-rust that referenced this pull request Sep 14, 2026
…em-table

* upstream/main:
  feat(datafusion): support ANALYZE TABLE on catalog-managed format tables (apache#815)
  refactor(table): unify vector search scan and read APIs (apache#822)
  fix(rest): sign the query string the client actually sends (apache#821)
  feat(datafusion): add MSCK REPAIR TABLE for catalog-managed format tables (apache#817)
  feat(table): execute a primary-key vector search over engine-planned splits (apache#771)
  feat(datafusion): add SHOW, ADD and DROP PARTITION for catalog-managed format tables (apache#816)
  feat(table): read the registered partitions of a catalog-managed format table (apache#814)
  fix: report unsupported time travel instead of returning current data (apache#753)
  fix(file_index): skip pruning for narrowing integer schema changes (apache#806)
  fix(c): align append vector filter with core behavior (apache#793)
  fix(table): skip staging files and list format table partitions concurrently (apache#813)
  feat(rest): add partition registration, lookup and statistics APIs (apache#812)
  fix(datafusion): keep format table partition columns out of decoder filters (apache#811)
  fix(read): null-fill nested fields a data file predates (apache#805)
  fix(scan): prune NOT IN predicates using file stats (apache#795)
  fix(spec): accept the legacy first_not_null_value aggregate name (apache#794)
  fix(table): check batch arity instead of asserting it in debug builds (apache#801)
  fix(vindex): bound a deletion-vector position by its own source file (apache#802)
  fix(lumina): decide on the index size before parsing the metric (apache#803)

# Conflicts:
#	crates/integrations/datafusion/src/physical_plan/scan.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants