Skip to content

perf(datafusion): push down partition-grouped COUNT(*) to manifests - #858

Open
jerry-024 wants to merge 5 commits into
apache:mainfrom
jerry-024:feat/partition-count-pushdown
Open

jerry-024 wants to merge 5 commits into
apache:mainfrom
jerry-024:feat/partition-count-pushdown

Conversation

@jerry-024

@jerry-024 jerry-024 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Purpose

Linked issue: closes #859

Queries such as:

SELECT dt, COUNT(*)
FROM t
WHERE region = 'eu'
GROUP BY dt;

currently plan every live file and read data files even when grouping and filtering use only partition columns. On tables with millions of files, materializing full manifest entries and column statistics can also exhaust planner memory.

This PR adds an exact manifest-only path for partition-grouped COUNT(*). It preserves correctness for data-evolution files and deletion vectors, and falls back to the ordinary scan whenever metadata cannot provide an exact count.

Brief change log

  • Add slim streaming decoders for data and index manifests. They skip column statistics and avoid materializing deletion-vector maps.
  • Fetch and decode data manifests through one pipeline capped at 32 in-flight items. A semaphore limits blocking-pool Avro decoding to available CPU parallelism, clamped to [2, 32], without introducing a second buffered queue.
  • Add Table::partition_row_counts{,_with_filter} and PartitionRowCount.
    • Apply manifest- and entry-level partition pruning to data manifests, and the same entry filter while aggregating deletion-vector counts.
    • Net ADD/DELETE entries using the complete Paimon file identity, including exact embedded-index bytes.
    • Union row-ID ranges only when data evolution is enabled, so overlapping column-group and blob files count once.
    • Subtract deletion-vector cardinalities and report unknown counts instead of guessing.
    • Preserve branch, time-travel, and query-authorization behavior.
    • While building the complete delete set, retain at most 1,000,000 matching ADD identities from DELETE-bearing manifests. Only manifests that exceed the shared budget are fetched again.
  • Add a DataFusion optimizer rule for COUNT(*) / COUNT(non-null literal) grouped by partition columns.
    • Rewrite only non-primary-key Paimon tables with exact partition-only filters.
    • Pin the selected snapshot during physical planning, then return a lazy PartitionRowCountExec; EXPLAIN performs no manifest I/O.
    • Omit partitions with zero surviving rows so grouped results match an ordinary scan.
    • Decode only partition columns requested by the physical projection.
    • Preserve output names and types.
    • Lazily plan and execute the original pinned scan if any partition count is unknown.
  • Keep peak aggregation state bounded by partitions, live DELETE entries, at most 1,000,000 retained ADD identities, disjoint row-ID ranges, and in-flight manifest buffers rather than all live data files and their statistics. Highly fragmented row-ID space and large embedded-index payloads may still increase retained memory.

Known trade-offs and follow-ups

  • The retained-ADD budget is entry-based, not byte-based. Exact embedded-index payloads can make retained identities larger; manifests beyond the 1,000,000-entry budget are read twice instead.
  • Highly fragmented data-evolution row-ID ranges increase both retained BTreeMap state and serial merge work. A sorted-vector merge or partition-sharded merge is a possible follow-up.
  • Manifest files are fully buffered before decoding. Filters that cannot prune manifest partition min/max statistics still need to read every manifest.
  • Exact DELETE and retained-ADD identity matching keeps embedded-index payloads in memory. A spill-backed set or on-demand exact verifier is a possible follow-up if this becomes a measured bottleneck.

Tests

A controlled release-mode pipeline benchmark on an 8-core host repeatedly read and slim-decoded the same cached 6.67 MB manifest 256 times. Across five fresh processes, replacing the two buffered stages with one 32-item pipeline reduced median peak RSS from 370,588 KiB to 352,196 KiB (-5.0%) and median measured execution time from 1,111 ms to 879 ms (-20.9%). This isolates pipeline buffering; it is not an object-store or full-table benchmark.

A second release-mode benchmark repeated the same cached 3.05 MB DELETE-bearing manifest as 900 inputs, with 1,000 ADDs per input. Across three fresh processes, retaining the 900,000 ADD identities reduced median execution time from 1,851 ms to 1,145 ms (-38.1%) versus a forced zero budget, while median peak RSS increased from 234,352 KiB to 321,444 KiB (+37.2%). This is a controlled cache/decode trade-off measurement, not a full-table benchmark.

cargo +1.94.0 test -p paimon --lib partition_row_count
cargo +1.94.0 test -p paimon-datafusion \
  --test partition_count_pushdown \
  --test count_pushdown \
  --test system_tables
cargo +1.94.0 clippy -p paimon -p paimon-datafusion --all-targets \
  -- -D warnings -A clippy::manual_is_multiple_of

Coverage includes:

  • partition filters, partition-column subsets, IN, HAVING, ordering, empty results, and ungrouped counts;
  • data-evolution overlap and complete ADD/DELETE identity matching;
  • deletion vectors with known and unknown cardinality, including fully deleted partitions;
  • time travel, unpartitioned tables, empty tables, and primary-key/data-filter fallback paths;
  • snapshot stability across planning and execution, deferred manifest I/O, EXPLAIN without manifest reads, and execution-time fallback for unknown metadata.

API and Format

Adds these Rust APIs:

Table::partition_row_counts
Table::partition_row_counts_with_filter
PartitionRowCount

No storage-format or SQL-syntax changes.

Documentation

No documentation update is required. The optimization is transparent to existing SQL queries.

@jerry-024
jerry-024 marked this pull request as draft September 17, 2026 07:39
@jerry-024 jerry-024 changed the title feat(datafusion): push down partition row counts perf(datafusion): answer partitioned COUNT(*) from manifests Sep 17, 2026
@jerry-024
jerry-024 force-pushed the feat/partition-count-pushdown branch from dcb4e01 to d1c8eab Compare September 17, 2026 08:32
@jerry-024
jerry-024 force-pushed the feat/partition-count-pushdown branch 11 times, most recently from b90caee to 764df07 Compare September 18, 2026 06:03
@jerry-024 jerry-024 changed the title perf(datafusion): answer partitioned COUNT(*) from manifests perf(datafusion): push down partition-grouped COUNT(*) to manifests Sep 20, 2026
@jerry-024
jerry-024 marked this pull request as ready for review September 20, 2026 05:45
@shyjsarah

Copy link
Copy Markdown
Contributor

Code Review Summary

Mode: full
Scope: #858 at 764df07534677e9944764001125f3510fab42ae6
Files Changed: 11 files (+2395/-4 lines)
Score: 41/100
GAN Stats: Generators found 9 issues -> Discriminator accepted 6 / challenged 2 / rejected 1 -> Arbiter included 6 / adjusted 2 / excluded 1
CI: All reported GitHub checks pass, including three-platform build/unit, check, and DataFusion integration.

Critical Issues (must fix before merge)

None found.

Major Issues (should fix)

  1. [logic-1] Known-zero partitions create groups that do not exist in the table
    Location: crates/integrations/datafusion/src/partition_count_pushdown.rs:374-407
    counts_to_batch materializes every PartitionRowCount, including Some(0). For a partition whose rows are all removed by deletion vectors, a normal scan supplies no input row to GROUP BY, but the rewritten plan supplies a synthetic (partition, 0) row and returns an extra group.
    Reproduction: insert one row into a partition, delete it with a deletion vector, then run SELECT partition_col, COUNT(*) ... GROUP BY partition_col; the optimized query can emit the deleted partition with count zero.
    Fix: filter out known-zero counts before building the batch, while retaining None so unknown counts still trigger fallback. Preserve the ungrouped empty-input behavior via the existing coalesce-to-zero path.

  2. [logic-2] The optimized physical plan does not pin its snapshot
    Location: crates/integrations/datafusion/src/partition_count_pushdown.rs:335-345
    The normal provider plans splits during TableProvider::scan, thereby fixing the snapshot represented by the physical plan. PartitionRowCountExec stores only a cloned Table and resolves the latest snapshot when execution polls the stream. A commit between physical planning and collection can therefore change this rewritten query's result, and repeated execution of the same physical plan can change again.
    Fix: resolve and store the selected snapshot during PartitionRowCountProvider::scan; defer manifest I/O, but not snapshot selection, until execution.

  3. [perf-1] One unknown count discards all exact manifest counts and forces a whole-table fallback
    Location: crates/integrations/datafusion/src/partition_count_pushdown.rs:347-365
    After reading and aggregating all selected manifests, any None count causes scan_by_reading to scan the complete selected table. A single legacy deletion vector without cardinality makes the query pay both the full metadata pass and the full data scan, while exact counts for all other partitions are discarded.
    Fix: retain known partition counts and restrict fallback to unknown full partition keys. At minimum, detect unavoidable unknown-cardinality fallback before doing redundant manifest aggregation.

  4. [perf-3] Projection is applied only after all partition columns are decoded and allocated
    Location: crates/integrations/datafusion/src/partition_count_pushdown.rs:374-413
    counts_to_batch constructs arrays for every partition field and only then calls batch.project(projection). Ungrouped COUNT(*) needs only the row-count column; subset grouping needs only selected partition fields. On high-partition-count tables this adds substantial avoidable CPU and peak memory.
    Fix: construct only projected source columns and build the output-schema RecordBatch directly.

  5. [perf-4] The retained-entry budget is not a real memory bound
    Location: crates/paimon/src/table/partition_row_count.rs:210-232
    The budget counts ADD entries, but each retained identity clones variable-sized extra_files, embedded_index, and external_path data; DELETE identities have no count or byte budget. Delete-heavy manifests or large embedded indexes can still consume hundreds of MB or more and defeat the feature's OOM-avoidance goal.
    Fix: account retained state by estimated heap bytes and spill exact identities after a configurable byte limit; apply the same policy to DELETE identities.

Minor Issues

  1. [perf-2] Fallback output partitions are consumed sequentially
    Location: crates/integrations/datafusion/src/partition_count_pushdown.rs:358-364
    Manual stream::iter(streams).flatten() serializes final output consumption and can increase buffering or spill on the unknown-count fallback. Upstream scan work is still driven concurrently by RepartitionExec, so this is minor rather than major. Use datafusion::physical_plan::execute_stream(plan, context) to coalesce outputs through DataFusion's standard helper.

  2. [arch-1] Slim Avro decoders duplicate storage-format logic
    Location: crates/paimon/src/spec/avro/manifest_entry_decode.rs:170-337
    The slim data- and index-manifest paths duplicate field dispatch, union/null handling, defaults, and deletion-vector traversal. Tests cover several schema variants and no current divergence was shown, but future schema evolution must now keep parallel handwritten decoders aligned. Share traversal/field-selection machinery with the canonical decoder where practical.

  3. [arch-2] DeleteSet duplicates the canonical manifest file-identity contract
    Location: crates/paimon/src/table/partition_row_count.rs:169-293
    The new representation manually enumerates the fields already centralized by Identifier. A future identity-field change can make normal manifest merging and partition counting disagree. Introduce a shared borrowed identity view/accessor so both paths inherit the same equality contract without restoring the allocations this path is trying to avoid.

Suggestions

None beyond the fixes above.

Positive Observations

  • The optimization has dedicated integration coverage, including fallback for deletion vectors without cardinality and a check that EXPLAIN does not perform table I/O.
  • The new index-manifest decoder tests exercise nullable and non-null array items, missing cardinality, reordered fields, and unknown fields.
  • Existing CI is green across Linux, macOS, Windows, unit tests, and DataFusion integration.

}
columns.push(Arc::new(Int64Array::from_iter_values(
counts.iter().filter_map(|count| count.record_count),
)));

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.

[major][logic-1] Known-zero partitions create groups that do not exist in the table

counts_to_batch materializes every PartitionRowCount, including Some(0). If deletion vectors remove every row from a partition, a normal scan supplies no input row to GROUP BY, but this rewritten plan supplies a synthetic row and returns (partition, 0).

Please filter known-zero counts before constructing the batch while retaining None so unknown counts still trigger fallback. The ungrouped empty-input case can continue to use the existing coalesce-to-zero path.

table.partition_row_counts_with_filter(predicate).await
})
.await
.map_err(to_datafusion_error)?;

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.

[major][logic-2] Pin the snapshot when the physical plan is created

The ordinary Paimon provider plans splits during TableProvider::scan, which fixes the snapshot represented by the physical plan. This path stores only a cloned Table and resolves latest inside execution, so a commit between physical planning and collection can change the result; re-executing the same physical plan can change it again.

Please resolve and store the selected snapshot in PartitionRowCountProvider::scan. Manifest I/O can remain lazy, but snapshot selection should not.

Arc::clone(&self.output_schema),
Box::pin(stream::iter(streams).flatten()),
)));
}

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.

[major][perf-1] One unknown count discards every exact manifest count

After all selected manifests have been read and aggregated, any None sends the complete selected table through scan_by_reading. A single legacy deletion vector without cardinality therefore adds a full metadata pass before a full data scan, while exact counts already computed for other partitions are discarded.

Please retain known partition counts and restrict fallback to unknown full partition keys. At minimum, detect an unavoidable unknown-cardinality fallback before doing redundant manifest aggregation.

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.

Thanks. This is an intentional compatibility fallback for deletion vectors without cardinality. It preserves correctness and falls back to the same pinned ordinary scan used before this optimization, so it is not a performance regression from the existing path. A partial fallback would require a mixed manifest/data plan and merge semantics; we would only add that complexity if measurements show this legacy case is common.

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.

Rechecked on current HEAD 576441f. The minimum requested part is now addressed: read_partition_row_counts checks unknown DV cardinality and returns before loading the base/delta data manifests, so there is no redundant manifest aggregation before fallback. The remaining ordinary scan is the intentional exactness fallback; a mixed partial-fallback plan is deferred unless measurements show this legacy case is common.

match &self.projection {
Some(projection) => Ok(batch.project(projection)?),
None => Ok(batch),
}

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.

[major][perf-3] Apply projection before decoding partition columns

counts_to_batch decodes every partition field and builds every Arrow array before applying batch.project(projection). Ungrouped COUNT(*) needs only the row-count column, and subset grouping needs only selected partition fields. At the high partition cardinalities this optimization targets, the discarded arrays add substantial avoidable CPU and peak memory.

Please construct only the projected source columns and build the output-schema RecordBatch directly.

.map(|value| Box::from(*value))
.collect(),
embedded_index: entry.embedded_index.map(Box::from),
external_path: entry.external_path.map(Box::from),

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.

[major][perf-4] The retained-entry budget is not a memory bound

The budget counts ADD entries, but each retained identity clones variable-sized extra_files, embedded_index, and external_path; DELETE identities have no count or byte budget. Delete-heavy manifests or large embedded indexes can therefore still consume hundreds of MB or more and defeat the OOM-avoidance goal.

Please account retained state by estimated heap bytes and spill exact identities after a configurable byte limit, applying the same policy to DELETE identities.

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.

Thanks. The limit is intentionally entry-based, not a strict byte cap. The PR description documents this trade-off, including the measured RSS increase for retained ADDs and the fact that exact DELETE/embedded-index payloads remain variable-sized. Exact matching is required; byte accounting plus spill would be a separate, substantially larger mechanism. We will keep the current design unless production measurements show this state is the actual memory bottleneck.

@jerry-024
jerry-024 force-pushed the feat/partition-count-pushdown branch from 764df07 to b2cbc62 Compare September 20, 2026 08:23
@shyjsarah

Copy link
Copy Markdown
Contributor

Incremental Code Review Summary

Mode: full incremental review
Scope: #858, 764df07534677e9944764001125f3510fab42ae6 -> b2cbc6290c21723ef081b45cf81f9de6ae77e9ad
Incremental Changes: 3 files (+168/-44 lines; 375 diff lines)
Current Score: 61/100 (previously 41/100)
GAN Stats: Generators found 6 current issues -> Discriminator accepted 6 / challenged 0 / rejected 0 -> no arbitration required
CI: All current GitHub checks pass, including three-platform build/unit, check, and DataFusion integration.

Resolution of Previous Findings

  • Fixed — logic-1: Known-zero partition rows are removed before batch construction. A new deletion-vector regression test confirms grouped COUNT(*) omits a fully deleted partition.
  • Fixed — logic-2: Snapshot selection now occurs during physical planning, and both the manifest-count path and unknown-cardinality fallback use the pinned table. New tests cover commits made after physical-plan creation.
  • Fixed — perf-2: The manual sequential stream flatten was replaced with DataFusion's standard execute_stream helper.
  • Fixed — perf-3: counts_to_batch now constructs only projected columns instead of decoding every partition field first.
  • Remaining — perf-1: One unknown count still discards all exact counts and scans the complete selected table.
  • Remaining — perf-4: Retained identity memory is still not byte-bounded.
  • Remaining — arch-1: Slim Avro decoders still duplicate the canonical storage-format traversal.
  • Remaining — arch-2: DeleteSet still duplicates the canonical file-identity contract.

Critical Issues (must fix before merge)

None found.

Major Issues (should fix)

  1. [arch-4, new] with_table can break the provider's schema invariant
    Location: crates/integrations/datafusion/src/table/mod.rs:107-180
    PaimonTableProvider derives and caches its Arrow schema when it is constructed, but the new with_table method replaces only self.table. If the snapshot resolved during physical planning has a different schema, the provider can expose the old cached schema while filters, projections, and fallback reads operate on the replacement table's schema. A concurrent add/drop/reorder before a partition column can therefore select the wrong field, index out of bounds, or fail with an output-schema/type mismatch when the unknown-cardinality fallback is used.
    Fix: replace the generic mutator with a fallible snapshot-pinning operation that re-derives the replacement table's Arrow schema and verifies it matches the logical provider schema before swapping. If it differs, decline the count rewrite or rebuild all related provider state consistently.

  2. [perf-1, remaining] One unknown count still discards all exact manifest counts
    Location: crates/integrations/datafusion/src/partition_count_pushdown.rs:384-395
    After aggregating all matching manifests, any None count still replaces the entire result with scan_by_reading() over the full selected table. The update improves how fallback output is consumed, but it neither reuses known counts nor restricts the scan to unknown partition keys.
    Fix: concatenate known manifest-count rows with an ordinary scan filtered to the unknown full partition keys. At minimum, detect an unavoidable full fallback before performing redundant manifest aggregation.

  3. [perf-4, remaining] The retained-entry budget is still not a heap-memory bound
    Location: crates/paimon/src/table/partition_row_count.rs:210-232
    ADD accounting still charges one unit while cloning variable-sized extra_files, embedded_index, and external_path values; DELETE identities remain unbudgeted. Large embedded indexes or delete-heavy snapshots can still retain hundreds of MB or more and defeat the optimization's OOM-avoidance goal.
    Fix: account by estimated heap bytes and spill exact identities after a configurable byte limit, applying the same policy to DELETE identities.

Minor Issues

  1. [perf-5, new] Latest-snapshot pinning fetches the same snapshot twice
    Location: crates/integrations/datafusion/src/partition_count_pushdown.rs:295-304
    get_latest_snapshot() loads the snapshot, but only its id is retained. copy_with_time_travel_strict(snapshot-id) then resolves and reads the same snapshot again. This adds an avoidable object-store metadata round trip and JSON read to every eligible latest-snapshot plan. Reuse the already resolved Snapshot through a public equivalent of copy_with_resolved_snapshot.

  2. [arch-1, remaining] Slim Avro decoders duplicate storage-format logic
    Location: crates/paimon/src/spec/avro/manifest_entry_decode.rs:170-337
    The parallel decoder paths remain unchanged and can drift when schema/default/nullability behavior evolves. Share traversal and field-selection machinery with the canonical decoder where practical.

  3. [arch-2, remaining] DeleteSet duplicates the canonical file-identity contract
    Location: crates/paimon/src/table/partition_row_count.rs:169-293
    The representation still manually mirrors Identifier fields. A shared borrowed identity view/accessor would keep equality semantics aligned without restoring unnecessary allocations.

Positive Observations

  • The two prior correctness blockers are fixed and now have targeted regression coverage.
  • Fallback output uses DataFusion's standard concurrent execution helper.
  • Projection is applied during column construction, eliminating the prior unnecessary decoding and allocation.
  • The current CI matrix is fully green.

@jerry-024
jerry-024 force-pushed the feat/partition-count-pushdown branch from b2cbc62 to 9932ceb Compare September 20, 2026 10:32
Match deletion vectors to live partition/bucket/file identities, skip rewrites on internal column-name collisions, and fall back before data-manifest aggregation when cardinalities are unknown. Add correctness and I/O regression coverage.
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.

[Feature] Push down partitioned COUNT(*) to manifest metadata

2 participants