stevenzwu commented on code in PR #17433:
URL: https://github.com/apache/iceberg/pull/17433#discussion_r3731312422


##########
core/src/main/java/org/apache/iceberg/V4ManifestReader.java:
##########
@@ -252,6 +272,25 @@ Builder project(Schema newProjection) {
       return this;
     }
 
+    /**
+     * 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.
+     */

Review Comment:
   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.



##########
core/src/main/java/org/apache/iceberg/V4ManifestReader.java:
##########
@@ -292,17 +333,65 @@ private Schema readSchema(boolean hasPartitionFilter) {
       if (columns != null) {
         Schema selected =
             caseSensitive ? fullSchema.select(columns) : 
fullSchema.caseInsensitiveSelect(columns);
-        return addRequiredColumns(selected, hasPartitionFilter);
+        return addRequiredColumns(fullSchema, selected, requiredFieldIds, 
hasPartitionFilter);
       }
 
       if (requestedProjection != null) {
-        return addRequiredColumns(requestedProjection, hasPartitionFilter);
+        return addRequiredColumns(
+            fullSchema, requestedProjection, requiredFieldIds, 
hasPartitionFilter);
       }
 
       return fullSchema;
     }
 
-    private Schema addRequiredColumns(Schema projection, boolean 
hasPartitionFilter) {
+    /** Returns the schema of everything this reader may read, including 
content stats. */
+    private Schema fullSchema(Set<Integer> requiredStatsProjectionFieldIds) {
+      Types.StructType contentStatsType = 
contentStatsType(requiredStatsProjectionFieldIds);
+      Schema base = TrackedFile.schema(unionPartitionType, contentStatsType);
+      if (contentStatsType.fields().isEmpty()) {
+        // schema uses the unknown type for empty stats, which cannot be 
paired with the stats
+        // struct in the manifest, so drop the field instead of reading it as 
unknown
+        base = TypeUtil.selectNot(base, 
ImmutableSet.of(TrackedFile.CONTENT_STATS_ID));
+      }
+
+      // the read schema carries row_position (via BASE_TYPE) so the reader 
can fill manifestPos
+      return TypeUtil.replaceFieldTypes(
+          base, ImmutableMap.of(TrackedFile.TRACKING.fieldId(), 
TrackingStruct.BASE_TYPE));
+    }
+
+    /** 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) {
+        // scan planning and projectStats(fieldIds) both narrow the set of 
stats that are read
+        return StatsUtil.statsReadSchema(tableSchema, 
requiredStatsProjectionForFieldIds);
+      }
+
+      return StatsUtil.statsReadSchema(
+          tableSchema, TypeUtil.indexById(tableSchema.asStruct()).keySet());

Review Comment:
   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 so to explore if we can have 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`)? 



##########
core/src/main/java/org/apache/iceberg/util/StructLikeUtil.java:
##########
@@ -28,7 +29,7 @@ public static StructLike copy(StructLike struct) {
     return StructCopy.copy(struct);
   }
 
-  private static class StructCopy implements StructLike {
+  private static class StructCopy implements StructLike, Serializable {

Review Comment:
   is this needed for the geo bounding box stats (struct) round trip 
serialization?



##########
core/src/main/java/org/apache/iceberg/V4ManifestReader.java:
##########
@@ -292,17 +333,65 @@ private Schema readSchema(boolean hasPartitionFilter) {
       if (columns != null) {
         Schema selected =
             caseSensitive ? fullSchema.select(columns) : 
fullSchema.caseInsensitiveSelect(columns);
-        return addRequiredColumns(selected, hasPartitionFilter);
+        return addRequiredColumns(fullSchema, selected, requiredFieldIds, 
hasPartitionFilter);
       }
 
       if (requestedProjection != null) {
-        return addRequiredColumns(requestedProjection, hasPartitionFilter);
+        return addRequiredColumns(
+            fullSchema, requestedProjection, requiredFieldIds, 
hasPartitionFilter);
       }
 
       return fullSchema;
     }
 
-    private Schema addRequiredColumns(Schema projection, boolean 
hasPartitionFilter) {
+    /** Returns the schema of everything this reader may read, including 
content stats. */
+    private Schema fullSchema(Set<Integer> requiredStatsProjectionFieldIds) {
+      Types.StructType contentStatsType = 
contentStatsType(requiredStatsProjectionFieldIds);
+      Schema base = TrackedFile.schema(unionPartitionType, contentStatsType);
+      if (contentStatsType.fields().isEmpty()) {
+        // schema uses the unknown type for empty stats, which cannot be 
paired with the stats
+        // struct in the manifest, so drop the field instead of reading it as 
unknown
+        base = TypeUtil.selectNot(base, 
ImmutableSet.of(TrackedFile.CONTENT_STATS_ID));
+      }
+
+      // the read schema carries row_position (via BASE_TYPE) so the reader 
can fill manifestPos
+      return TypeUtil.replaceFieldTypes(
+          base, ImmutableMap.of(TrackedFile.TRACKING.fieldId(), 
TrackingStruct.BASE_TYPE));
+    }
+
+    /** 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) {

Review Comment:
   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)`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to