Skip to content

Core: Read content stats from v4 Manifest - #17433

Open
nastra wants to merge 10 commits into
apache:mainfrom
nastra:read-content-stats-from-manifest
Open

Core: Read content stats from v4 Manifest#17433
nastra wants to merge 10 commits into
apache:mainfrom
nastra:read-content-stats-from-manifest

Conversation

@nastra

@nastra nastra commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

V4ManifestReader now reads the content_stats column, so each TrackedFile comes
back with per-column bounds and counts.
The stats schema is derived from the table schema, so builder() takes it as a second
argument. Stats for every column are read by default, which is what copying entries into
a new manifest needs. Callers that want less can narrow:

  • projectStats(fieldIds) reads stats only for the given columns, projectStats() reads none
  • forScanPlanning() reads only what the filter needs
  • stats referenced by the filter are always read, even when select/project omits them

Reading stats surfaced two bugs in the copy path, fixed here:

  • ContentStatsStruct.copy() threw an NPE when a projected column had no stats in the
    manifest
  • FieldStatsStruct copied StructLike bounds by reference. Geometry and geography bound
    a bounding-box struct, so under reuseContainers() every entry reported the last row's
    bounds. Bounds are now deep-copied through StructLikeUtil.copy, and StructCopy is
    Serializable so a copied bound still survives serialization.

Used Claude for the initial prototyping but reviewed and adjusted the code manually

@github-actions github-actions Bot added the core label Jul 30, 2026
@nastra
nastra marked this pull request as draft July 30, 2026 15:45
@nastra
nastra force-pushed the read-content-stats-from-manifest branch 3 times, most recently from c62305e to 6cf0fd0 Compare July 30, 2026 18:25
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
readBuilder.setCustomType(TrackedFile.CONTENT_STATS_ID, ContentStatsStruct.class);
// content_stats holds one stats struct per projected column
for (Types.NestedField fieldStats : statsField.type().asStructType().fields()) {
readBuilder.setCustomType(fieldStats.fieldId(), FieldStatsStruct.class);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does geo and variant need any special handling here? Can we add a test to make sure those types work?

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.

good point, I've added tests for geo + variant types with single/multiple files and this uncovered a bug around Geo types copying, which I've fixed in FieldStatsStruct

@anoopj anoopj left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is the file pruning coming in a followup?

@nastra
nastra force-pushed the read-content-stats-from-manifest branch 3 times, most recently from 4975ce5 to 33d52b2 Compare July 31, 2026 10:38
@nastra nastra moved this to In review in V4: metadata tree Jul 31, 2026
@nastra
nastra marked this pull request as ready for review July 31, 2026 10:54
* fields referenced by the {@link #filter(Expression) filter} are always read.
*/
Builder projectStats(Iterable<Integer> fieldIds) {
Preconditions.checkArgument(fieldIds != null, "Invalid stats projection for field IDs: null");

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.

I think we have 4 modes relevant to stats so far, the default/CDC, scan planing, select column by name and project schema and we conditionally add required column depends on the filter. I am wondering if we want to add coverage for

  • empty projectStats and filter (ok to use precondition to check if this combination does not make sense)
  • valid projectStats and filter with default mode ( I think scan planning is already covered in projectStatsAndFilterStatsAreCombined)

Comment on lines +290 to +291
Set<Integer> requiredFieldIds = requiredStatsProjectionForFieldIds();
Schema fullSchema = fullSchema(requiredFieldIds);

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.

nit: curious if we shall build requiredStatsType for reading stats regardless of caller's column projection ahead of time instead of passing them to calculate multiple times later? Can potentially pass requiredStatsType to fullSchema() and addRequiredColumns().

@nastra
nastra force-pushed the read-content-stats-from-manifest branch from 33d52b2 to b8612ba Compare August 6, 2026 17:59
/**
* Reads content stats for the given table field IDs instead of for every field. Stats for
* fields referenced by the {@link #filter(Expression) filter} are always read.
*/

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.

nit: worth spelling out the null-vs-empty distinction contract. Something like: Call with no arguments or an empty iterable to opt out of stats reads entirely; a null argument is rejected. This applies symmetrically to the Iterable<Integer> overload below.

Comment thread core/src/main/java/org/apache/iceberg/util/StructLikeUtil.java

/** Returns the stats type to read, which is empty when no stats are needed. */
private Types.StructType contentStatsType(Set<Integer> requiredStatsProjectionForFieldIds) {
if (scanPlanning || statsProjectionForFieldIds != null) {

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.

Is the asymmetry intended? A caller doing join/aggregate pushdown has to explicitly narrow via projectStats(...), but stats for row-filter-referenced columns come along for free — without any opt-in from the caller — because the default (non-scan-planning, no projectStats) reads full stats and the filter only forces its refs to be included on top of a narrower projection.

The check itself (statsProjectionForFieldIds != null) is right — requiredStatsProjectionForFieldIds may be non-empty due to the row filter, but we only want to narrow when the caller explicitly asks via projectStats(fieldIds).

}

return StatsUtil.statsReadSchema(
tableSchema, TypeUtil.indexById(tableSchema.asStruct()).keySet());

@stevenzwu stevenzwu Aug 7, 2026

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 walks the full table schema for every build()TypeUtil.indexById once, then statsReadSchema walks again (plus indexParents and per-field isScalar climbs to the root). Fine per manifest, but this is on the default path (no projectStats, no forScanPlanning) taken for every manifest read that copies entries forward, so the cost multiplies across a scan's fan-out on wide tables.

Compounding this: manifests only store stats for a capped prefix of columns (default ~100 via MetricsConfig), so on a table with e.g. 5,000 columns the default "read all stats" builds a stats schema with ~5,000 slots and registers 5,000 FieldStatsStruct custom types — but ~4,900 of them resolve to null at decode time because the manifest never stored them. We're paying construction cost for stats we know aren't there.

But I don't have a good solution. Neither option below is clean:

  • Using current MetricsConfig.metricsFieldIds() at read time is per-table, not per-manifest. If the cap narrowed since the manifest was written, we silently drop stats the manifest actually holds — no correctness impact (InclusiveMetricsEvaluator treats absent stats as "may match"), but pruning gets coarser on copy-forward and scans open more files at query time. If it widened, we still over-ask for the extra columns and get the same null-resolution waste. Not a sound signal either way.
  • The only truthful source is the manifest's own content_stats schema. But peeking at that before configuring the projection means either an extra file open per manifest (drop below InternalData to Avro.read/Parquet.read for a header/footer peek, then reopen via InternalData with the intersection), or extending InternalData.ReadBuilder with a fileSchema() accessor so the projection can be picked after the header is read. Both cost something.

Flagging this to see if we can explore good alternatives — not blocking this PR.

And orthogonally, I am also wondering if we should caching the full stats read schema keyed off the Schema (like Schema.lazyIdToField)?

@stevenzwu stevenzwu Aug 7, 2026

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.

Idea worth exploring: version MetricsConfig in TableMetadata alongside schemas and partition specs, and stamp each manifest file (root or leaf) with the metrics-config-id in effect when the manifest was written. Readers resolve the id and recover the exact write-time stats field IDs, and build the read schema off the intersection with tableSchema. Correct across config drift; no extra file peek at read time.

Trade-offs: spec change (metrics-configs list + current-metrics-config-id on TableMetadata, MetricsConfigParser, metrics-config-id on manifest file entries); MetricsConfig shifts from a runtime property derivation to a first-class immutable artifact; retention/dedup matters since property tweaks churn more than schema/spec changes. With metrics-config-id, schema-id, sort-id, reader should be able to faithfully reconstruct the writer MetricsConfig and content stats write schema. This also requires move the metrics config from table properties to properly versioned struct in table metadata.

Alternatives — field-IDs list in each manifest's header, or on the manifest-list entry — either force a pre-read I/O per manifest or bloat the manifest list on wide tables, so the versioned direction seems the cleanest long-term shape.

void statsAreNullForColumnsWithoutStoredStats(FileFormat format) throws IOException {
// the manifest stores stats for id alone, while the reader reads stats for every column
Types.StructType storedStatsType =
StatsUtil.statsReadSchema(TABLE_SCHEMA, List.of(ID_FIELD_ID));

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.

nit: we are constructing write schema using StatsUtil.statsReadSchema. just looks a bit weird.


@ParameterizedTest
@FieldSource("MANIFEST_FORMATS")
void statsAreCorrectWithContainerReuse(FileFormat format) throws IOException {

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.

I don't quite get this test. V4ManifestReader always copy first before return, although it configures InternalData.reuseContainers().

   return CloseableIterable.transform(entries, TrackedFile::copy).iterator();

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.

yes it does copy but there were bugs which were uncovered by this and the other containerReuse tests

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.

details can be seen in 5e3733c and 5651b63

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.

should the copy testing be covered in TestFieldsStatsStruct, TestContentStatsStruct, or TestTrackedFileStruct?


@ParameterizedTest
@FieldSource("MANIFEST_FORMATS")
void requestedStatsAreProjectedWhenOmittedByCaller(FileFormat format) throws IOException {

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.

nit: maybe the method name should be requestedStatsProjectedButOmittedBySchemaProjection?

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

Labels

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

4 participants