Core: Refactor ContentStats and FieldStats#17159
Conversation
| Types.StructType type(); | ||
|
|
||
| /** Returns a copy of these {@link ContentStats}, deep-copying the contained field stats. */ | ||
| ContentStats copy(); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
These changes are conservative. We may want to widen later, but I want to make a conscious choice.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
|
|
||
| private Object getOffset(int offset) { | ||
| return switch (offset) { | ||
| case StatsUtil.LOWER_BOUND_OFFSET -> lowerBound(); |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
Updated to align with what stats would actually look like.
b35b148 to
f996392
Compare
297cd9b to
33128e1
Compare
|
|
||
| /** The total NaN value count */ | ||
| Long nanValueCount(); | ||
| long nanValueCount(); |
There was a problem hiding this comment.
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
| return tightBounds; | ||
| } | ||
|
|
||
| @Override | ||
| public long valueCount() { | ||
| return valueCount; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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. */ |
There was a problem hiding this comment.
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])); |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
This updates the content stats API and implementations used for columnar stats in v4.
API changes:
StructLikeused for serialization from the interfacescopymethods to support copyingTrackedFilewith statsIterable, expose full struct type astype, etc.)StatsUtilchanges:FieldStatisticenum; not all offsets were represented asFieldStatisticand it also embedded util methods. All utils are now inStatsUtil.switchstatement to resolve position to enum and enum to field inFieldStatsStructgetandsetMetricsConfigContentStatsimplementation changes:ContentStatsStructto align with other v4 typesStructLikemethods without copying data to allow object reuse, do not rebuild field statssetCustomTypeinstead of copying via builder insetFieldStatsimplementation changes:FieldStatsStructto align with other v4 typesSupportsIndexProjectionthat is bypassedgetandsetprojection directly based on offset, do not rely on mapping passed to constructorjavaClass.castequalsandhashCodeTest changes:
StatsUtiltest values from production constants