Skip to content

[core][spark] Maintain num files and total file size on snapshots - #9659

Closed
zhuxiangyi wants to merge 3 commits into
apache:masterfrom
zhuxiangyi:snapshot-file-stats-pr
Closed

[core][spark] Maintain num files and total file size on snapshots#9659
zhuxiangyi wants to merge 3 commits into
apache:masterfrom
zhuxiangyi:snapshot-file-stats-pr

Conversation

@zhuxiangyi

Copy link
Copy Markdown
Contributor

Purpose

Paimon cannot answer "how many data files does this table have, and how large are they" without folding every manifest entry.

Two places pay for it today:

  • ANALYZE TABLE calls listPartitionEntries() and sums fileSizeInBytes over the whole manifest set purely to obtain one number, then uses it to estimate the merged size as totalSize * mergedRecordCount / totalRecordCount (PaimonAnalyzeTableColumnCommand.scala:62-73).
  • Append compaction planning calls partitionEntries() on every discovery round — AppendTableCompact.java:115, CombinedAppendCompactSource.java:181, MultiTableAppendCompactReadOperator.java:110 — which in combined mode is one full manifest scan per table per round.

The commit path already computes this information and then throws it away. FileStoreCommitImpl builds PartitionEntry.merge(deltaFiles) on every commit, whose counts are already signed by file kind, hands the result to the catalog as partition statistics, and drops it for every catalog that does not implement partition reporting — AbstractCatalog.commitSnapshot throws UnsupportedOperationException, and RenamingSnapshotCommit.commit ignores the argument.

Summing those per-partition entries gives the table level delta, so a snapshot can carry the running totals:

  • numFiles — live data files
  • totalFileSizeInBytes — their total size

Both are derived as previous + delta. This adds no IO and no second pass over the delta entries; it reuses a fold that already runs.

Deriving the totals costs nothing when it works and is skipped when it does not, so the values are advisory. null means unknown, never zero, and readers fall back to scanning — PaimonAnalyzeTableColumnCommand shows the intended shape:

val totalSize = Option(currentSnapshot.totalFileSizeInBytes())
  .map(_.longValue())
  .getOrElse(table.newScan().listPartitionEntries().asScala.map(_.fileSizeInBytes()).sum)

Unknown arises for snapshots written before the fields existed, and for any commit whose predecessor was unknown. Two paths recompute instead of propagating it:

  • replaceManifestList replaces the manifest layout wholesale and its callers may drop files with it — RemoveUnexistingManifestsAction does exactly that — so inheriting the previous counters would publish a number that no longer matches the live file set. It folds the manifests it is about to commit instead.
  • Manifest compaction seeds unknown counters from the compacted manifest set. It rewrites the whole manifest set anyway and the compacted form is the cheapest one to fold, which makes compact_manifest the way to recover the counters on an upgraded table; the incremental chain continues on its own afterwards. To keep that reliable the procedure commits a snapshot when the counters are unknown even if no manifests need merging, and returns early exactly as before once they are known.

The two-field, fold-at-commit, advisory-and-degradable shape follows Delta Lake's version checksum file, which maintains the equivalent counters per commit and falls back rather than failing when it cannot.

Benefits

Delivered here:

  • ANALYZE TABLE no longer scans the manifest set for the table size, and the value it feeds into the merged-size calculation is exact rather than a scan result summed on the spot.
  • sys.snapshots gains num_files and total_file_size_in_bytes, so "which commit blew up the file count" is answerable by query, for any retained snapshot, without starting a job and without scanning manifests. Commit metrics cannot answer this: they are per-commit deltas held in a running writer's memory, carry no snapshot identity, and are absent entirely for tables written by Spark batch jobs or procedures.

Enabled next, not part of this PR:

  • Compaction planning and cost-based optimization can read the counters instead of scanning, with the fallback above keeping them correct on tables that have not been seeded.
  • The same fold extends naturally to a file size histogram and to deletion vector counts, which would let compaction decisions move from a file count threshold to a distribution.

Compatibility

Existing tables are unaffected. The fields are nullable and serialized only when present, so snapshots written before this change stay byte-identical, and the constructors that predate the fields are kept so no caller needs to change. Reads, writes, compaction, overwrite, tags, rollback, snapshot expiration and the sys.snapshots query all keep working on such tables, with the two columns reported as NULL.

Tests

SnapshotFileStatsTest, 20 cases:

  • Five assert the values against a full manifest scan across appends, primary key compaction, overwrite, tag and rollback, and manifest compaction.
  • Eleven cover tables rewritten into the pre-change snapshot format — read, write, primary key compaction, overwrite, tag, rollback, snapshot expiration, the sys.snapshots columns, mixed old/new history with time travel, and unknown JSON fields. The central one asserts that writing on top of an unknown baseline keeps reporting unknown rather than restarting from zero, which is the failure mode that would publish a plausible but wrong file count.
  • The rest cover the recompute paths, including one that drops a manifest and must follow the shrink instead of reporting the stale count, and seeding a legacy table whose manifests need no merging at all.

RemoveUnexistingManifestsActionITCase and CompactManifestProcedureITCase exercise the same paths through Flink.

Full run: 293 paimon-core cases and 9 paimon-flink cases, no failures. paimon-spark-common compiles.

Documentation

docs/docs/concepts/system-tables.mdx documents the two columns, what NULL means, how it propagates, and that compact_manifest recovers the values.

Answering "how many files does this table have, and how large are they"
currently requires folding every manifest entry: ANALYZE TABLE scans the
whole manifest set just to sum one number, and append compaction planning
repeats that scan on every discovery round.

The commit path already folds the same information. It builds per-partition
entries whose counts are signed by file kind, hands them to the catalog as
partition statistics, and then discards them for every catalog that does not
implement partition reporting. Summing those entries gives the table level
delta for free, so a snapshot can carry the running totals:

  numFiles              live data files
  totalFileSizeInBytes  their total size

Both are derived as previous + delta, at no extra IO and without walking the
delta entries a second time. Deriving them costs nothing when it works and
is skipped when it does not, so they are advisory: null means unknown, never
zero, and readers fall back to scanning. PaimonAnalyzeTableColumnCommand
shows the shape - use the snapshot value, otherwise scan.

Unknown propagates in three cases: snapshots written before these fields
existed, snapshots whose predecessor was unknown, and replaceManifestList,
which swaps the manifest layout wholesale and therefore cannot carry counters
over. RemoveUnexistingManifestsAction goes through that path and does drop
files, so inheriting the previous numbers there would publish a count that
does not match the live file set.

Old tables are unaffected. The fields are nullable and serialized only when
present, so snapshots written before this change are byte-identical, and the
constructors that predate the fields are kept. Reads, writes, compaction,
overwrite, tags, rollback, snapshot expiration and the snapshots system table
all keep working on such tables, with the two columns reported as NULL.
The previous commit left two gaps where numFiles and totalFileSizeInBytes
stayed unknown for good.

replaceManifestList reported unknown because it replaces the manifest layout
wholesale and may drop files with it, so the previous counters do not apply.
It now recomputes them from the manifests it is about to commit. That reads
every entry, which is why it stays out of the regular commit path, but the
callers are metadata repair operations - RemoveUnexistingManifestsAction and
the data evolution row id reassigner - where a second pass is affordable and
correctness by construction beats letting each caller supply its own numbers.

Tables whose snapshots predate the fields had no way back at all: every later
commit inherited the missing baseline. Manifest compaction now seeds them.
It already rewrites the whole manifest set, and the compacted form is the
cheapest one to fold, so compact_manifest becomes the way to recover the
counters, after which the incremental chain continues on its own. To make
that reliable the procedure now commits a snapshot when the counters are
unknown even if the manifests need no merging; once they are known it returns
early exactly as before.

Both recompute paths share one helper that folds ADD and DELETE entries with
opposite signs, so deletion entries cancel their adds rather than being
counted.

Tests cover: replacing a manifest layout with an equivalent one lands on the
same numbers; replacing one that drops a manifest follows the shrink instead
of reporting the stale count; seeding a legacy append table, a legacy primary
key table with cancelling deletion entries, and a legacy table small enough
that nothing needs merging; and that a seeded table keeps folding incrementally
afterwards. RemoveUnexistingManifestsActionITCase and
CompactManifestProcedureITCase exercise the same paths through Flink.
SnapshotsTableTest compares whole rows, so it has to list every column of
the snapshots system table. Add num_files and total_file_size_in_bytes to
the expected rows.

@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

This properties already existing in REST Server and REST API.

@zhuxiangyi

Copy link
Copy Markdown
Contributor Author

Thanks for the review. You are right — the REST catalog already receives these numbers on every commit via CommitTableRequest's partition statistics and exposes them through the partition API, so maintaining them again on the snapshot is redundant. I missed that when writing this up. Closing.

@zhuxiangyi zhuxiangyi closed this Sep 8, 2026
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