Skip to content

Core: present v4 ContentStats as legacy stat maps via ContentStatsBackedMap - #17322

Merged
stevenzwu merged 8 commits into
apache:mainfrom
stevenzwu:content-stats-backed-map
Jul 24, 2026
Merged

Core: present v4 ContentStats as legacy stat maps via ContentStatsBackedMap#17322
stevenzwu merged 8 commits into
apache:mainfrom
stevenzwu:content-stats-backed-map

Conversation

@stevenzwu

@stevenzwu stevenzwu commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What

Add ContentStatsBackedMap, a lazy read-only Map<Integer, V> view over a ContentStats that projects a single metric (value/null/NaN counts, or lower/upper bounds) from a column's FieldStats on demand. TrackedFileAdapters now presents the five legacy ContentFile stat maps through ContentStatsBackedMap.forKind(...), replacing the eager MetricsUtil converters that materialized a fresh map per metric for every manifest entry.

  • forKind restores the eager converters' null-when-empty contract — it returns null when no column tracks the metric — via an allocation-free, short-circuiting scan. Point reads (get/containsKey) allocate no map, boxed value, or bound buffer; the entry set is materialized (and cached) only when the whole map is iterated.
  • FieldStats gains hasNullCount() / hasNaNCount() so the presence of a primitive-long count is explicit. FieldStatsStruct takes the two counts as boxed Long, so null uniformly signals an untracked count across every construction path, and the count getters checkState rather than unbox a null.
  • Removes the now-unused MetricsUtil.valueCounts/nullValueCounts/nanValueCounts/lowerBounds/upperBounds(ContentStats) converters.

Why

The eager converters allocated a LinkedHashMap (plus boxed counts and decoded bound buffers) per metric for every manifest entry read. The lazy view avoids that on the hot point-read path and preserves the legacy null-when-empty semantics that scan planning relies on.

Benchmarks

The design and the null-when-empty tradeoff are backed by JMH microbenchmarks (lazy wrap vs eager convert, and the cost of restoring null-when-empty): V4 Read-Direction Bridge Benchmark.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the core label Jul 21, 2026
@stevenzwu
stevenzwu force-pushed the content-stats-backed-map branch 2 times, most recently from 00fa85c to 02c40c9 Compare July 21, 2026 18:59
26L,
0L,
0L,
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.

Intentional: data is optional (tracks a null count, kept 0L above) but not floating-point, so it has no NaN count. With hasNullCount()/hasNaNCount() now derived from a non-null boxed Long, an untracked count must be null — matching what StatsUtil.fieldStatsStruct declares. ID_STATS (a required long) is null for both.

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 for changing the tests in this file.

These fields are set correctly for the types so these are all non-functional changes that are not needed. If the type is a string, then a 0 NaN count is valid.


FieldStats<Integer> fieldStats =
new FieldStatsStruct<>(UNKNOWN_FIELD_STATS_STRUCT, 0, 10, false, 8, 3, 0, null);
new FieldStatsStruct<>(UNKNOWN_FIELD_STATS_STRUCT, 0, 10, false, 8, null, null, 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.

Intentional: UNKNOWN_FIELD_STATS_STRUCT is a required Integer, so it declares neither count; both args are null. Previously 3, 0 — harmless under the old type-based tracksStat, but with the value-based check a non-null value would make hasNullCount()/hasNaNCount() report a metric the type doesn't track. This test never reads the counts, so null just keeps the fixture consistent.

long valueCount,
long nullValueCount,
long nanValueCount,
Long nullValueCount,

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.

Boxed Long (not primitive long) for the two counts so a caller can pass null for a metric the field's type does not track; hasNullCount()/hasNaNCount() then report presence as != null. valueCount stays primitive since every column has one.

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 disagree with this change. The cases where null count can be null do not go through this path. This constructor should require non-null counts for both nan value count and null value count (though not avgValueSize) because both should be known when generating stats. I don't know of a case where we write a file and have stats, but don't know the NaN count.

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.

There are cases where the null and NaN counts can be null.

  • nan_value_count is only applicable for float and double fields. other types won't have Nan count.
  • null_value_count is omitted in the type for required fields.

But maybe you are suggesting we should construct the FieldStatsStruct this way?

    FieldStatsStruct<String> stats = new FieldStatsStruct<>(STRING_STATS);
    stats.fromFieldMetrics(new FieldMetrics<>(100, 28, 2, "a", "z"));

@stevenzwu stevenzwu Jul 23, 2026

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 existing code already has trouble construct the FieldStatsStruct properly. Because the constructor can't pass in null, 0L is passed in for the nanValueCount for an integer field. E.g., this code from TestTrackedFileAdapters.

  private static final FieldStats<Integer> ID_STATS =
      new FieldStatsStruct<>(
          CONTENT_STATS_TYPE.fieldType("id").asStructType(), 1, 1000, true, 100L, 5L, 0L, null);
                                                                                                                                                 ^^^

}

/** Returns a lazy view of the metric across columns, or {@code null} if no column tracks it. */
static <V> Map<Integer, V> forKind(Kind kind, ContentStats 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: the argument order here is (kind, stats), but the constructor above is (stats, kind) and the static isEmpty below is also (stats, kind). Could we make forKind match so the order is consistent across all three?

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.

will do

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 could also remove the forKind method to keep Kind private and instead use factory methods: lowerBounds, upperBounds, valueCounts, etc.

@Override
public V get(Object key) {
if (!(key instanceof Integer)) {
throw new ClassCastException("Key must be an Integer field id: " + key);

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 seems to be a behavior change from the HashMaps this replaces, which returned null from get and false from containsKey for a missing or wrong-typed key. Is this change intentional? If so, a short comment would help.

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.

Seems easier to return null and be consistent with more widely used Map implementations.


/** The total null value count */
/** Whether a null value count is tracked for this field. */
boolean hasNullCount();

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.

Small naming nit: could we align these with the existing getters? They are nullValueCount() and nanValueCount(), so hasNullValueCount() and hasNanValueCount() would match,

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 agree with this.


/** Returns a lazy view of the metric across columns, or {@code null} if no column tracks it. */
static <V> Map<Integer, V> forKind(Kind kind, ContentStats stats) {
return isEmpty(stats, kind) ? null : new ContentStatsBackedMap<>(stats, kind);

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.

isEmpty check adds some overhead as it needs to iterates through the FieldStats collection in the ContentStats. But I think it is worth to have this for two reason.

  1. The ContentFile interface states null map if not collected.
  /** Returns if collected, map from column ID to its null value count, null otherwise. */
  Map<Integer, Long> nullValueCounts();
  1. isEmpty short-circuit and return early if it detects a FieldStats has stats for the kind. In most production scenarios, I imagine short-circuit should kick in.

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 this is a good idea.

private final Kind kind;
private Map<Integer, V> materialized;

ContentStatsBackedMap(ContentStats stats, Kind kind) {

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.

Can this be private?

}

@SuppressWarnings("unchecked")
private static <V> V getStatValue(FieldStats<?> fieldStats, Kind kind) {

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.

Style: method names (even private ones) should generally avoid "get".

private static ByteBuffer bound(FieldStats<?> fieldStats, Object bound, int boundOffset) {
Types.NestedField boundField =
fieldStats.type().field(StatsUtil.toBaseId(fieldStats.fieldId()) + boundOffset);
if (bound == null || boundField == 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.

bound == null is checked in toByteBuffer so there's no need for it here. Also, it is easier to look up the bound field by name rather than ID:

// you can introduce a constant for "lower_bound" if you want
Type boundType = fieldStats.type().fieldType("lower_bound");

I think it is correct to check for the field since it may be missing for MetricsMode.Counts.

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.

Also, it is easier to look up the bound field by name rather than ID:

I also thought about it. it is also probably slightly better to query by name lower_bound, as the FieldStatsStruct constructor internally already triggered lazyFieldsByName() mapping.

    this.boundType = struct.fieldType("lower_bound");

Earlier, I used id because it is the canonical identifier defined in StatsUtil, while field names are not exposed constants in StatsUtil. I don't want to use hardcoded literals here. But since field name is also part of the spec, we can add the stat field name constants to StatsUtil just like the stat field offset.

Either way, we should be consistent in the wrapper and the FieldStatsStruct constructor.

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.

Yeah, we can introduce a constant for it if you like. I just wanted to reduce the number of constants if they weren't needed.

@Override
public Set<Entry<Integer, V>> entrySet() {
if (materialized == null) {
Map<Integer, V> entries = Maps.newLinkedHashMap();

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.

Why use a map rather than a set of entries?

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.

Oh, I get it. This caches the result for faster lookups.

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.

actually, it is not used for lookups get call. it is only cached for repeated entrySet() call. your question on why not set is valid.


// Whether the field's stats struct declares the metric at the given offset.
private static boolean tracksStat(FieldStats<?> fieldStats, int statOffset) {
return fieldStats.type().field(StatsUtil.toBaseId(fieldStats.fieldId()) + statOffset) != 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 don't think that this class should be performing ID calculations. Instead, this should check based on field name in the struct. This is safe because the field name is generated by this library. We generate the read schema with predictable field names.


// Whether getStatValue would return a non-null value, without allocating a boxed count or
// decoding a bound. Must mirror getStatValue's null-ness.
private static boolean contributes(FieldStats<?> fieldStats, Kind kind) {

@rdblue rdblue Jul 22, 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.

The results are inconsistent between map Kinds. This handles bounds by checking whether the field is present in the schema, but it handles counts by assuming the field is present and just checking for null. The same check would be valid for bounds: if the schema doesn't have a bound then it will always be null because there is no way to set it during a read. And it would be more accurate to test isEmpty using the simpler null check because lower_bound could be present but null.

I would rename this to be a bit more clear, like isKnown.

/** The total value count, including null and NaN */
long valueCount();

/** The total null value 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.

I think we also need a few more methods:

  • hasValueCount: the value count may not be projected or may not have been written
  • missingNullValueCount, missingNaNValueCount: I think it is good to have negations to make code easier to read, like Map.nonEmpty
  • hasAvgValueSizeInBytes: this field may be null for the same reason as the others, so we should treat it the same way (this was an oversight on my part)

@stevenzwu stevenzwu Jul 23, 2026

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 will add hasValueCount. But let's add others when they are actually used/needed. Introducing them now is a bit weird since there are no callers.

  • missingNullValueCount
  • missingNaNValueCount
  • hasAvgValueSizeInBytes

stevenzwu and others added 2 commits July 23, 2026 11:24
…kedMap

Add ContentStatsBackedMap, a lazy read-only Map<Integer, V> view over a ContentStats that projects one metric per column on demand, replacing the eager MetricsUtil converters that materialized a map per metric. TrackedFileAdapters exposes the five legacy ContentFile stat maps through ContentStatsBackedMap.forKind, which restores the converters' null-when-empty contract with an allocation-free short-circuit scan.

Add FieldStats.hasNullCount()/hasNaNCount() so the presence of a primitive-long count is explicit, and take the two counts as boxed Long in FieldStatsStruct's constructor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- expose per-column factory methods (valueCounts/nullValueCounts/
  nanValueCounts/lowerBounds/upperBounds) and make Kind and the
  constructor private; drop forKind
- get() returns null for a non-Integer key instead of throwing,
  matching common Map implementations
- rename contributes -> isKnown and test bounds with a simple null check
  (a bound absent from the schema is always null); drop the field-id math
- look up the bound field type by name via StatsUtil.LOWER_BOUND_NAME/
  UPPER_BOUND_NAME
- FieldStats: rename hasNullCount/hasNaNCount -> hasNullValueCount/
  hasNanValueCount; add hasValueCount and guard valueCount()
- cache entrySet() as a Set; rename getStatValue -> statValue

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@stevenzwu
stevenzwu force-pushed the content-stats-backed-map branch from 3fec893 to 132377a Compare July 23, 2026 18:32
Revert the null and NaN value counts on the FieldStatsStruct constructor
from boxed Long back to primitive long, matching the existing signature. The
boxed parameters existed only so tests could construct columns with absent
counts, which does not warrant widening production code.

Build those absent-count states in tests instead: ContentStatsBackedMap and
TrackedFileAdapters read stats only through the FieldStats interface, so they
mock FieldStats; the FieldStatsStruct and ContentStatsStruct tests exercise
the real struct and serialize it, so they populate a FieldStatsStruct through
the setter path, leaving untracked counts unset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@Override
public long valueCount() {
Preconditions.checkState(hasValueCount(), "Field %s does not track a value count", fieldId);

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 this and similar checks should be removed. We don't need extra checks in tight loops that are only going to slow down scan planning. If this is called when the value count is missing then it is a bug in the caller.


// Presents the wrapped content stats as a legacy per-column stats map without eagerly
// materializing it.
private <V> Map<Integer, V> statsMap(Function<ContentStats, Map<Integer, V>> statsView) {

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 understand the value of this function. All it does is check that contentStats is non-null. Rather than a complicated way to save an inline null check, the stats functions should just return null for null content stats. You can do that in a single place by updating isEmpty to check for a null content stats.

}

@Test
public void testGetDoesNotUnboxUntrackedNullCount() {

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 duplicates the null value count test above, just without the third entry for dbl. I think you can remove it.

}

@Test
public void testLowerAndUpperBounds() {

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 this should be a separate test like there are for the other map types. I'd also like to see each value in the map tested.

}

@Test
public void testFactoryReturnsPopulatedViewWhenColumnsTrackMetric() {

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.

These maps are already tested. We know that field 1 is not in either map and that the counts are correct, so I think this test can be removed as a duplicate.

}

@Test
public void testFactoryReturnsNullWhenNoColumnTracksMetric() {

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 would also like to see a similar test to this one that returns a non-null map for counts. The case is for null stats structs. The schema may include them but they are missing.

@Test
public void testPopulatedViewIsNotEmpty() {
// isEmpty() answers from a scan without materializing entrySet
assertThat(ContentStatsBackedMap.valueCounts(ONLY_REQUIRED_STATS)).isNotEmpty();

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 this is okay, but this relies on the same isEmpty implementation as the cases that the map is null and all cases where the map is non-null. As a result, the only thing actually being tested here is that isEmpty() is backed by isEmpty(stats, kind). I'm not sure I'd include this but it's fine if you think it's important.

stevenzwu and others added 2 commits July 23, 2026 15:28
Move the FieldStatsStruct setter-path factory into a shared StatsTestUtil so
the FieldStats, ContentStats, ContentStatsBackedMap, and TrackedFileAdapters
tests build stats through one helper instead of per-file copies and inline
FieldStats mocks.

Drop two comments that only restated the code: the StatsUtil bound-name
constants and the ContentStatsBackedMap isKnown helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Struct

Remove the value/null/NaN count getter guards in FieldStatsStruct: reading a
missing count is a caller bug and the checkState calls only slow scan-planning
loops. Callers that must tolerate absence check has*ValueCount() first.

Handle null content stats in one place: ContentStatsBackedMap.isEmpty (and
therefore the stat-map factories) returns null for null stats, so
TrackedFileAdapters calls the factories directly and its statsMap null-check
wrapper is removed.

Restructure TestContentStatsBackedMap: remove three tests that duplicated
existing coverage, split the bounds test into separate lower/upper tests that
assert every entry, and add a test that a count map stays non-null when some
columns' stats structs are missing. Drop the obsolete "reading absent count
throws" assertions in TestFieldStatsStruct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

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

Went through the PR for my own education. Left some comments in the meantime

UPPER_BOUND
}

/** Per-column value counts, or {@code null} if no column tracks the value 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.

nit: null instead of {@code null}

}

@Override
public boolean isEmpty() {

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.

If I'm not mistaken, in the constructing methods we return null if the map were empty. Following this, in case we have constructed a map object it's not empty. Can we simply return false here?

return viewOrNull(stats, Kind.UPPER_BOUND);
}

private static <V> Map<Integer, V> viewOrNull(ContentStats stats, Kind kind) {

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: this is private, shouldn't this be located next to the other private static methods?


@SuppressWarnings("unchecked")
private static <V> V statValue(FieldStats<?> fieldStats, Kind kind) {
switch (kind) {

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: new switch style here and above?

return true;
}

for (FieldStats<?> fieldStats : stats.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.

nit: maybe with stream?

return stats.fieldStats().stream()
    .filter(Objects::nonNull)
    .noneMatch(fieldStats -> isKnown(fieldStats, kind));

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.

isEmpty() is on the scan-planning's hot code path. A stream adds a Stream + lambda allocation per call, and ContentStats.fieldStats() returns an Iterable, so it would also need a Streams.stream(...) wrapper. The imperative loop short-circuits on the first contributing field and allocates nothing, so I'd prefer to keep it here.

}

@Override
public Set<Entry<Integer, V>> entrySet() {

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.

Just for my understanding: I'm trying to understand the reason for introducing the cached materialized result. Do we expect entrySet to be called multiple times on the same ContentStatsBackedMap object? Apart from that e.g. ContentStatsBackedMap.valueCounts(stats) seems identical to MetricsUtil.valueCounts(stats) to me, unless I miss something.
Asking the question from a different angle, can this entrySet function be "pass-through" without caching similarly to get?

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.

entrySet() is the only method that has to materialize (it's a full projection), so its result is cached: AbstractMap routes size(), forEach(), toString(), and equals() through entrySet(), and without the cache any caller that does more than a single pass would rebuild the LinkedHashSet every time. get()/containsKey() stay pass-through and allocate nothing.

It isn't equivalent to the old MetricsUtil.valueCounts(stats), which eagerly built and returned a full map on every call. Here the map is a lazy view: a caller that only does get() or a null check never materializes a set, and an empty metric returns null from an allocation-free scan rather than an empty map. That laziness, plus returning null instead of an empty map, is the reason for the class.

return materialized;
}

/** Returns whether no column contributes an entry for the metric, including for null 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 whether no column contributes an entry for the metric
This sentence seems weird. Maybe this:
Returns whether no fields have the given metric.

, including for null stats: I think this part of the comment is not needed.

- isEmpty() returns false directly, since a factory returns null for an empty
  view so a constructed instance always has an entry; add a test that isEmpty()
  agrees with entrySet()
- convert isKnown and statValue to expression switches
- group viewOrNull with the other private static helpers
- javadoc: use plain null over {@code null} and reword the isEmpty helper doc

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* {@code hasNullValueCount()}/{@code hasNanValueCount()} report false. This keeps "absent count"
* states out of the production constructor.
*/
static <T> FieldStatsStruct<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.

I don't think that we should be abusing FieldStatsStruct in this way to test a class that is based on the FieldStats interface and not on the object. This should just return a mock or a test implementation, not a FieldStatsStruct.

Per review feedback, apache#17322 should not modify TestFieldStatsStruct or
TestContentStatsStruct -- fixing structs in those tests is unrelated to the
content-stats maps and belongs in a separate change. Revert both to their
apache/main form.

The ContentStatsBackedMap and TrackedFileAdapters tests now mock FieldStats to
construct the columns they need, instead of a set-by-position helper; a single
shared StatsTestUtil.mockFieldStats(...) backs both. The FieldStats and
FieldStatsStruct presence methods that ContentStatsBackedMap relies on stay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3L,
null);
private static final FieldStats<?> ID_STATS =
StatsTestUtil.mockFieldStats(

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 this necessary? Isn't this equivalent to the existing code?

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.

Not equivalent

  • old: 100L, 5L, 0L
  • new: 100L, 5L, null

it's needed. id is an integer, and the previous fixture set nan=0 on it. MetricsUtil.nanValueCounts hid that by filtering on column type (FLOAT/DOUBLE only); ContentStatsBackedMap decides tracking by presence (hasNanValueCount()), so a forced 0 makes id show up in nanValueCounts() -- which a real integer column never does, since its stats struct has no nan_value_count field. The value constructor can't express "no nan count" (the nan arg is a primitive long), so the fixture mocks FieldStats instead.

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.

Sounds reasonable. Thanks for the context!

}

@Test
void testMetricTrackedByNoColumnReturnsNull() {

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 think additional tests are needed here. Behavior of the new maps is thoroughly checked by TestContentStatsBackedMap, which is a better place for the tests. The existing tests demonstrate that content stats are adapted to the maps, but the behavior does not need to be thoroughly validated.

TestTrackedFileAdapters should only check that the stat maps are produced;
whether a map is null when no column tracks a metric is the map's contract,
covered by TestContentStatsBackedMap. Remove testMetricTrackedByNoColumnReturnsNull
and the dataFileWithStats helper it used.

The ID_STATS/SCORE_STATS fixtures stay mocked so the integer id column carries
no nan_value_count, which the FieldStatsStruct value constructor cannot express.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@stevenzwu
stevenzwu merged commit ea5c1cd into apache:main Jul 24, 2026
36 checks passed
@stevenzwu
stevenzwu deleted the content-stats-backed-map branch July 24, 2026 21:42
stevenzwu added a commit to stevenzwu/iceberg that referenced this pull request Jul 24, 2026
…eldStats

apache/main (via apache#17322) added presence methods to FieldStats. Implement
hasValueCount / hasNullValueCount / hasNanValueCount in MapBackedFieldStats,
mirroring FieldStatsStruct.

Also drop the redundant final class modifiers and make MapBackedFieldStats a
non-static inner class, so it reads the enclosing stats view's maps directly
instead of carrying an explicit parent reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
stevenzwu added a commit to stevenzwu/iceberg that referenced this pull request Jul 25, 2026
Production:
- Implement the new FieldStats presence methods (hasValueCount /
  hasNullValueCount / hasNanValueCount) in MapBackedFieldStats.
- Drop the -1 sentinel from the count getters; valueCount / nullValueCount /
  nanValueCount unbox directly and callers must check has*Count() first
  (matches FieldStatsStruct after apache#17322).
- Drop redundant final modifiers and make MapBackedFieldStats a non-static
  inner class, reading the enclosing view's maps directly.

Tests:
- Assert upperBound type in testBoundDecodingPerType.
- Add testSetNotSupported for the outer set(); split
  testFieldStatsCopyAndSetNotSupported into two focused tests.
- Sharpen testContentStructLikeGetReturnsChildrenOrNull to verify the null
  slot maps to source field 5, not just that some position is null.
- Split the StructLike surface out of testCountsOnlyColumnOmitsBounds into
  two symmetric tests (testDefaultStructLike / testCountsOnlyStructLike)
  using Comparators.forType with TestHelpers.Row expectations.
- Extend testCountsAndDefaults to also cover the absent-count throw path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nssalian nssalian added this to the Iceberg 1.12.0 milestone Jul 26, 2026
Comment on lines 254 to -252
Type boundType = isGeoType(type) ? geoLowerBound(baseId) : type;
return optional(baseId + LOWER_BOUND_OFFSET, "lower_bound", boundType);

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.

Not direct relate to your change, but I think geoLowerBound(baseId) actually returns StructType, but the Conversions.toByteBuffer called within ContentStatsBackedMap.bound() today might not handle the struct type for geometry properly.

switch (typeId) {
case BOOLEAN:
return ByteBuffer.allocate(1).put(0, (Boolean) value ? (byte) 0x01 : (byte) 0x00);
case INTEGER:
case DATE:
return ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(0, (int) value);
case LONG:
case TIME:
case TIMESTAMP:
case TIMESTAMP_NANO:
return ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(0, (long) value);
case FLOAT:
return ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putFloat(0, (float) value);
case DOUBLE:
return ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putDouble(0, (double) value);
case STRING:
CharBuffer buffer = CharBuffer.wrap((CharSequence) value);
try {
return ENCODER.get().encode(buffer);
} catch (CharacterCodingException e) {
throw new RuntimeIOException(e, "Failed to encode value as UTF-8: %s", value);
}
case UUID:
return UUIDUtil.convertToByteBuffer((UUID) value);
case FIXED:
case BINARY:
return (ByteBuffer) value;
case DECIMAL:
return ByteBuffer.wrap(((BigDecimal) value).unscaledValue().toByteArray());
case VARIANT:
// Produce a concatenated buffer of metadata and value
Variant variant = (Variant) value;
VariantMetadata variantMetadata = variant.metadata();
VariantValue variantValue = variant.value();
ByteBuffer variantBuffer =
ByteBuffer.allocate(variantMetadata.sizeInBytes() + variantValue.sizeInBytes())
.order(ByteOrder.LITTLE_ENDIAN);
variantMetadata.writeTo(variantBuffer, 0);
variantValue.writeTo(variantBuffer, variantMetadata.sizeInBytes());
return variantBuffer;
case GEOMETRY:
case GEOGRAPHY:
// Geometry and geography lower/upper bounds are single points encoded as an
// x:y:z:m concatenation of 8-byte little-endian IEEE 754 doubles. See the
// Bound Serialization section of the Iceberg spec.
return ((GeospatialBound) value).toByteBuffer();
case UNKNOWN:
// underlying type not known
return null;
default:
throw new UnsupportedOperationException("Cannot serialize type: " + typeId);
.

I am not sure the status of upper/lower bound of geometry/geography type in main, but it seems we can run into UnsupportedOperationException.

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.

Confirmed ,this does throw UnsupportedOperationException for geometry and geography bounds.
Repro and proposed fix in #17493.

stevenzwu added a commit to stevenzwu/iceberg that referenced this pull request Jul 31, 2026
…eldStats

apache/main (via apache#17322) added presence methods to FieldStats. Implement
hasValueCount / hasNullValueCount / hasNanValueCount in MapBackedFieldStats,
mirroring FieldStatsStruct.

Also drop the redundant final class modifiers and make MapBackedFieldStats a
non-static inner class, so it reads the enclosing stats view's maps directly
instead of carrying an explicit parent reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
stevenzwu added a commit to stevenzwu/iceberg that referenced this pull request Jul 31, 2026
Production:
- Implement the new FieldStats presence methods (hasValueCount /
  hasNullValueCount / hasNanValueCount) in MapBackedFieldStats.
- Drop the -1 sentinel from the count getters; valueCount / nullValueCount /
  nanValueCount unbox directly and callers must check has*Count() first
  (matches FieldStatsStruct after apache#17322).
- Drop redundant final modifiers and make MapBackedFieldStats a non-static
  inner class, reading the enclosing view's maps directly.

Tests:
- Assert upperBound type in testBoundDecodingPerType.
- Add testSetNotSupported for the outer set(); split
  testFieldStatsCopyAndSetNotSupported into two focused tests.
- Sharpen testContentStructLikeGetReturnsChildrenOrNull to verify the null
  slot maps to source field 5, not just that some position is null.
- Split the StructLike surface out of testCountsOnlyColumnOmitsBounds into
  two symmetric tests (testDefaultStructLike / testCountsOnlyStructLike)
  using Comparators.forType with TestHelpers.Row expectations.
- Extend testCountsAndDefaults to also cover the absent-count throw path.

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

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

7 participants