Skip to content

Core: Refactor ContentStats and FieldStats - #17159

Merged
rdblue merged 2 commits into
apache:mainfrom
rdblue:fix-content-stats
Jul 17, 2026
Merged

Core: Refactor ContentStats and FieldStats#17159
rdblue merged 2 commits into
apache:mainfrom
rdblue:fix-content-stats

Conversation

@rdblue

@rdblue rdblue commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

This updates the content stats API and implementations used for columnar stats in v4.

API changes:

  • Removes StructLike used for serialization from the interfaces
  • Adds copy methods to support copying TrackedFile with stats
  • Aligns interface conventions (use Iterable, expose full struct type as type, etc.)

StatsUtil changes:

  • Reduce complexity of ID methods, reduce number of constants
  • Remove FieldStatistic enum; not all offsets were represented as FieldStatistic and it also embedded util methods. All utils are now in StatsUtil.
  • Avoid double switch statement to resolve position to enum and enum to field in FieldStatsStruct get and set
  • Simplified field stats struct generation
  • Replaced content stats schema visitor with a loop
  • Added stats schema generation for read path based on field IDs
  • Added stats schema generation for write path based on MetricsConfig

ContentStats implementation changes:

  • Rename to ContentStatsStruct to align with other v4 types
  • Implement StructLike methods without copying data to allow object reuse, do not rebuild field stats
  • Use setCustomType instead of copying via builder in set
  • Keep field stats in a map rather than syncing between list and map
  • Remove unnecessary builder
  • Fix raw types suppressions

FieldStats implementation changes:

  • Rename to FieldStatsStruct to align with other v4 types
  • Remove SupportsIndexProjection that is bypassed
  • Implement get and set projection directly based on offset, do not rely on mapping passed to constructor
  • Use internal get/set to avoid duplicated javaClass.cast
  • Remove equals and hashCode
  • Remove unnecessary builder

Test changes:

  • Separated StatsUtil test values from production constants
  • Aligned implementation tests with other v4 object tests (test accessors, get, set, projection, serialization, and copy)
  • Updated tests that depend on stats classes

@github-actions github-actions Bot added the core label Jul 10, 2026
Types.StructType type();

/** Returns a copy of these {@link ContentStats}, deep-copying the contained field stats. */
ContentStats copy();

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.

Needed to implement copy in TrackedFile correctly. We also need to follow up with methods to project by field ID.

* <p>Note: This type may be a projection of the stats stored in manifest files.
*/
Type type();
Types.StructType type();

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.

This changed to the actual schema. The lower and upper bounds types do not always match (for geo types) so this is the best way to expose types. It is also more consistent with existing conventions.


/** The total NaN value count */
Long nanValueCount();
long nanValueCount();

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.

These changes are conservative. We may want to widen later, but I want to make a conscious choice.

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.

Curious what's driving this, from a spec perspective is there an open question about if these stats should be required? My understanding of the parquet spec is that value counts are required but null/NaN counts are optional

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.

I think these accessors should reflect the API that we want for building on top of stats. So the decision should be made when we introduce classes that consume these.

I think that primitives are more locked down. They will throw a NullPointerException if called when there is no value, which is good for catching calls that should not be made. For example, IS NaN and IS NOT NaN predicates cannot be bound to fields that are not floating points (throws ValidationException) so accessing the NaN count should not happen if the field is missing from the stats struct.

On the other hand, these values may just be missing for a specific data file because they weren't collected. In this case, we may want to signal that the value is missing by returning the underlying null. This is why I left the avg_value_size_in_bytes column as an Integer.

If there is a case where we know we always have a value, then it makes sense to use the primitive. But it may also be better to have a method to check whether the value is known and still use a primitive when we may not have a value. I'm undecided at the moment and I'm waiting for actual uses. I'm also currently writing the evaluator and, while it isn't done, I think we may want to add more methods and keep the types more restrictive.

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.

consolidate the discussion on how to handle no stats scenario. there are two options

  1. nullable boxed type of Long
  2. primitive long + ValidationException + hasXxx

The existing APIs consistently use option 1, where null means "unknown/not collected," and callers null-check directly. If we decided to go with option 2, we should include the guardrails of ValidationException and hasXxx.


private Object getOffset(int offset) {
return switch (offset) {
case StatsUtil.LOWER_BOUND_OFFSET -> lowerBound();

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.

Uses offsets to avoid a switch by position immediately followed by a switch by enum symbol.


private static final Types.StructType CONTENT_STATS_TYPE =
StatsUtil.contentStatsFor(TABLE_SCHEMA).type().asStructType();
StatsUtil.statsReadSchema(TABLE_SCHEMA, ImmutableList.of(1, 2));

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.

The read schema method is easiest to call for testing since it creates full stats structs for each field ID. The write schema method will omit lower and upper bounds for MetricsMode.Counts.

10L,
3L,
null);
private static final ContentStatsStruct CONTENT_STATS =

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.

I left this in to continue testing the MetricsUtil conversion, but I suspect that those conversions will be done lazily.

Those are the only place where fieldId is needed so I think we can avoid needing to recover field ID from base ID if we rewrite them.

assertThat(dataFile.equalityFieldIds()).isNull();
assertThat(dataFile.columnSizes()).isNull();
assertThat(dataFile.valueCounts()).containsOnly(Map.entry(1, 100L), Map.entry(2, 200L));
assertThat(dataFile.valueCounts()).containsOnly(Map.entry(1, 100L), Map.entry(2, 100L));

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.

Updated to align with what stats would actually look like.

@rdblue
rdblue force-pushed the fix-content-stats branch from b35b148 to f996392 Compare July 10, 2026 21:56
Comment thread core/src/main/java/org/apache/iceberg/TrackedFileStruct.java Outdated

/** The total NaN value count */
Long nanValueCount();
long nanValueCount();

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.

Curious what's driving this, from a spec perspective is there an open question about if these stats should be required? My understanding of the parquet spec is that value counts are required but null/NaN counts are optional

Comment on lines +142 to +147
return tightBounds;
}

@Override
public long valueCount() {
return valueCount;

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.

Based on the comment below the change to make this API a long was a conscious one but given the additional change to MetricsUtil that's made wouldn't this NPE in cases where value / null / nan counts are null?

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.

I added a comment about MetricsUtil conversion, but it was in tests so it wasn't obvious. I don't think that we actually want to convert between the two representations and that it was premature to add that logic.

Conversion is expensive. It allocates lots of buffers and copies data into them, but we don't really know what is going to be accessed. I think we should have a simple Map implementation that translates when values are accessed, or just return null for the old APIs.

I think we're seeing another place where you get in trouble when production code is introduced just for testing.

/** Container struct type containing tracked field-level stats structs. */
Types.StructType type();

/** Returns a copy, deep-copying all field stats. */

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: Returns a deep copy of all field stats?


@Override
public <T> T get(int pos, Class<T> javaClass) {
return javaClass.cast(idToFieldStats.get(posToId[pos]));

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.

BaseContentStats get used to check pos against the struct size, and it looks like the old code returned null in case the pos was "out of bounds" of the struct. It looks like that was done for Avro because on the write path Avro calls get() for every field in the writer schema. I don't think we're fully plumbed through which is why we don't see any issues but are we handling that case differently in this PR, or we're just deferring that? If I recall correctly we wanted to use this in memory representation regardless of format version (because we theoretically could adapt the existing stats structure to this)

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, that behavior deviated from what StructLike instances are intended to do. If the indexes don't match then the reader is using a different schema than the class and we want that to fail. Part of the problem with the old implementation was not implementing the StructLike interface or using the SupportsIndexProjection base class correctly.

This implementation should bring the struct classes in line with what SupportsIndexProjection does.

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

some initial comments

Comment thread core/src/main/java/org/apache/iceberg/ContentStats.java
Comment thread core/src/main/java/org/apache/iceberg/ContentStats.java
Comment thread core/src/test/java/org/apache/iceberg/TestContentStatsStruct.java
Comment thread core/src/main/java/org/apache/iceberg/ContentStatsStruct.java
Comment thread core/src/main/java/org/apache/iceberg/FieldStatsStruct.java
Comment thread core/src/main/java/org/apache/iceberg/FieldStatsStruct.java
Comment thread core/src/main/java/org/apache/iceberg/FieldStatsStruct.java
Comment thread core/src/main/java/org/apache/iceberg/StatsUtil.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/StatsUtil.java
Comment thread core/src/main/java/org/apache/iceberg/StatsUtil.java
result.put(fs.fieldId(), fs.nanValueCount());
if (fs != null) {
Type boundType = fs.type().fieldType("lower_bound");
if (boundType.typeId() == Type.TypeID.FLOAT || boundType.typeId() == Type.TypeID.DOUBLE) {

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.

Problem:

boundType.typeId() NPEs when the stats struct has no lower_bound field. This happens for float/double columns in Counts mode — StatsUtil.fieldStatsStruct only adds lower_bound when mode.hasBounds() is true (L262-273), but nan_value_count is added for any floating-point field regardless of mode.

because bound fields are conditionally omitted from the stats struct, the field's type isn't available to consumers when bounds aren't collected. MetricsUtil has no way to know whether a column is float/double if lower_bound is missing.

Options:

  1. Tactical fix — use a different presence signal. fs.type().field("nan_value_count") != null maps directly to "this field can have NaN counts" and doesn't depend on lower_bound being present. Local, targeted.

  2. Keep bound fields in the schema even when values aren't collected. Change fieldStatsStruct so Counts mode still emits lower_bound / upper_bound NestedFields (with null values). Preserves the type/values distinction, keeps schema shape stable across modes, and consumers can always derive the bound type. Null column probably has negligible cost for Parquet manifest.

Option 2 is the architecturally cleanest. Option 1 is the smallest diff.

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, this is throw-away code that will likely not be kept.

if (fs != null) {
Type boundType = fs.type().fieldType("lower_bound");
if (fs.lowerBound() != null && boundType != null) {
result.put(fs.fieldId(), Conversions.toByteBuffer(boundType, fs.lowerBound()));

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.

Summary: FieldStats.type() changed from "field's data type" to "stats struct type," which affects only geo — for a geo column, lower_bound's schema type is a struct (bounding-box) rather than a scalar matching the field type, and Conversions.toByteBuffer has no case STRUCT, so it throws instead of dispatching to case GEOMETRY.

Layout. For a geo column, lower_bound / upper_bound are bounding-box structs, not scalar Geometry values (geoLowerBound / geoUpperBound at StatsUtil.java:207-237):

FieldStatsStruct for a GEOMETRY column:
  ├── lower_bound: STRUCT       ← name is "lower_bound", type is a struct
  │     ├── x: double (required)
  │     ├── y: double (required)
  │     ├── z: double (optional)
  │     └── m: double (optional)
  ├── upper_bound: STRUCT       ← same shape
  ├── tight_bounds: boolean
  ├── value_count: long
  └── null_value_count: long

FieldStats.type() before/after. Before this PR: returned the field's data type (GeometryType for geo). After (FieldStats.java:32): returns the stats struct type; the bound's type is now retrieved via fs.type().fieldType("lower_bound").

Runtime failure. For geo, fs.type().fieldType("lower_bound") returns the geo_lower StructType. Conversions.toByteBuffer (Conversions.java:95-146) switches on typeId()STRUCT has no matching case, so default throws UnsupportedOperationException: Cannot serialize type: STRUCT. The case GEOMETRY / case GEOGRAPHY branch handling bounds via ((GeospatialBound) value).toByteBuffer() is unreachable.

Same at line 558 for upperBounds.

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.

Coverage gap. TestFieldStatsStruct.testGeoSerialization exercises Java / Kryo / InternalData round-trip but not this MetricsUtil path. TestTrackedFileAdapters calls MetricsUtil.lowerBounds via TrackedFileAdapters.asDataFile, but its schema is (id: int, score: float) — no geo. Geo × MetricsUtil isn't tested anywhere.

Should we add TestMetricsUtil? Every other v4 stats class in this PR has its own test file (TestFieldStatsStruct, TestContentStatsStruct, TestStatsUtil) — MetricsUtil is the exception and currently has no direct test. A new core/src/test/java/org/apache/iceberg/TestMetricsUtil.java would be the natural home for:

  • testLowerBoundsGeo — geometry column, assert ByteBuffer output matches GeospatialBound.toByteBuffer()
  • testUpperBoundsGeography — same for geography
  • testNanValueCountsWithCountsMode — float column in Counts mode, assert no NPE (see the sibling thread on L521)
  • testValueCountsProjectionFieldStatsStruct constructed via the projection constructor, verify unset counts are handled without NPE (see the sibling thread on L490)
  • testLowerBoundsScalarPrimitives — smoke test across int / long / double / string / binary / decimal / uuid

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.

MetricsUtil conversions are placeholders. This was already a problem. It was just hidden before.

Comment thread core/src/test/java/org/apache/iceberg/TestStatsUtil.java
Comment thread core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java
Comment thread core/src/test/java/org/apache/iceberg/TestContentStatsStruct.java
Comment thread core/src/main/java/org/apache/iceberg/ContentStatsStruct.java
Comment thread core/src/main/java/org/apache/iceberg/ContentStatsStruct.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestContentStatsStruct.java
Comment thread core/src/main/java/org/apache/iceberg/ContentStatsStruct.java Outdated
Map<Integer, String> idToStatsName = TypeUtil.indexStatsNames(tableSchema.asStruct());
List<Types.NestedField> fieldStructs = Lists.newArrayList();

for (int id : metricsConfig.metricsFieldIds()) {

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.

The field order is non-determinisitc because we are iterating over a hash map. Should we sort by IDs? There is no correctness issue here.

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.

I thought about this and wanted to preserve the schema order. At the time, it seemed like a good idea to use the order from the input (in this case metricsFieldIds) rather than sorting by field ID because sorting would erase the schema order (this is why the read path accepts Iterable). However, you're right that metricsFieldIds is unordered.

I think what we want is to preserve the schema order, but I'd like to do it in a follow up since it would probably make more changes to MetricsConfig and I tried to separate those changes out (into #17022).

@RussellSpitzer RussellSpitzer added the Iceberg V4 Iceberg Table Format Version 4 label Jul 16, 2026
@rdblue
rdblue force-pushed the fix-content-stats branch 2 times, most recently from 4da0316 to d16aa52 Compare July 16, 2026 22:11
@rdblue
rdblue force-pushed the fix-content-stats branch from d16aa52 to 0fa25ad Compare July 16, 2026 22:59

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

I am approving this.

but I wanted to point out that this discussion seems still open: #17159 (comment)

long nanValueCount,
Integer avgValueSize) {
this(struct);
setLowerBound(lowerBound);

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.

we used to check that upper/lower bound are of the right type and would throw Invalid lower bound type, expected a subtype of xx. Are we not doing that anymore?

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.

That's correct. We don't want additional type checks. The readers and writers are responsible for ensuring types match. Those are generated from the schema and won't operate on invalid values.

return javaClass.cast(getOffset(posToOffset[pos]));
}

private void setOffset(int offset, Object value) {

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 this be called setFromOffset because setOffset is misleading and indicates that we're setting the offset while we actually set the value

switch (offset) {
case StatsUtil.LOWER_BOUND_OFFSET -> setLowerBound(value);
case StatsUtil.UPPER_BOUND_OFFSET -> setUpperBound(value);
case StatsUtil.TIGHT_BOUNDS_OFFSET -> this.tightBounds = (Boolean) value;

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.

wouldn't this fail with a NPE when a null is passed?

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 guess the same applies for the other fields

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.

I think the only time that tight bounds will be null is when the column is not present. Otherwise, we should always have a value for tight bounds because we can always assume that bounds are not tight if it is unknown.

That said, the column is currently optional. I think we should decide whether to make that required or to accept a null here and translate it to false since the bounds are assumed to not be tight when this is unknown.

Types.NestedField.optional(30_002, "upper_bound", upperBound),
Types.NestedField.optional(30_004, "value_count", Types.LongType.get()),
Types.NestedField.optional(30_005, "null_value_count", Types.LongType.get()),
Types.NestedField.optional(30_007, "avg_value_size_in_bytes", Types.IntegerType.get()));

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.

for Geo the spec says that avg_value_size_in_bytes should be omitted

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.

Geo types are variable width so I think we should update the spec.

String fieldName = idToStatsName.get(id);
Types.NestedField field = tableSchema.findField(id);
Preconditions.checkArgument(
field != null, "Cannot build content stats schema: missing field ID %s", 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.

maybe mention that the field ID is missing from the table schema

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.

I think that's what this says.

return (FieldStats<T>) idToFieldStats.get(id);
}

public <T> void setStats(int id, FieldStats<T> fieldStats) {

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.

Suggested change
public <T> void setStats(int id, FieldStats<T> fieldStats) {
public <T> void setStats(int fieldId, FieldStats<T> fieldStats) {


@Override
@SuppressWarnings("unchecked")
public <T> FieldStats<T> statsFor(int 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.

Suggested change
public <T> FieldStats<T> statsFor(int id) {
public <T> FieldStats<T> statsFor(int fieldId) {

@rdblue

rdblue commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews! I'm going to merge this to unblock other changes and integration with the readers. We can still follow up on a few things, like whether tightBounds is required or optional.

@rdblue
rdblue merged commit 100d062 into apache:main Jul 17, 2026
36 of 38 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in V4: metadata tree Jul 17, 2026
@nssalian nssalian added this to the Iceberg 1.12.0 milestone Jul 20, 2026
stevenzwu added a commit to stevenzwu/iceberg that referenced this pull request Jul 20, 2026
…e_count

MetricsUtil.nullValueCounts(ContentStats) was added in apache#16100, when the
FieldStats count accessors returned boxed Long, so a column without a
null_value_count simply produced a null map entry. apache#17159 changed those
accessors to primitive long.

In v4 content stats, null_value_count is only stored for optional
(nullable) columns: StatsUtil.fieldStatsStruct omits the field for
required columns, so a deserialized FieldStatsStruct for a required
column leaves it null. nullValueCounts now throws a NullPointerException
unboxing that null.

Add StatsUtil.tracksStat(fieldStatsType, fieldId, statOffset) and guard
nullValueCounts with it, skipping columns whose stats struct does not
track null_value_count (reported as unknown) instead of throwing, as
nanValueCounts already filters by whether the metric applies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Iceberg V4 Iceberg Table Format Version 4

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

7 participants