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


##########
core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java:
##########
@@ -732,6 +739,274 @@ public void statsFilterMissingColumnFailure() {
         .hasMessageContaining("Cannot find field 'missing' in struct: %s", 
TABLE_SCHEMA.asStruct());
   }
 
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void statsFilterRecordCountFiltering(FileFormat format) throws 
IOException {
+    TrackedFile emptyTrackedFile =
+        new TrackedFileStruct(
+            ADDED_TRACKING,
+            FileContent.DATA,
+            FORMAT_VERSION_V4,
+            "s3://bucket/table/empty-file.parquet",
+            FileFormat.PARQUET,
+            0, // file contains no records
+            100L,
+            null,
+            null,
+            null,
+            SortOrder.unsorted().orderId(),
+            null,
+            null,
+            null,
+            List.of(4L),
+            null);
+
+    ManifestFile manifest =
+        writeManifest(format, UNPARTITIONED_TYPE, 
ImmutableList.of(emptyTrackedFile, FILE_D));
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, IO, TABLE_SCHEMA, 
UNPARTITIONED_SPECS)
+            .metricsConfig(METRICS_CONFIG);
+
+    List<TrackedFile> actualFiles = read(builder);
+
+    assertThat(actualFiles)
+        .usingComparatorForType(FILE_COMPARATOR, TrackedFile.class)
+        .containsExactly(FILE_D);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void statsFilterInvalidRecordCountNotFiltered(FileFormat format) 
throws IOException {
+    // A bug in old writers produced Avro files with record_count=-1 (unknown)
+    TrackedFile invalidRecordCountFile =
+        new TrackedFileStruct(
+            ADDED_TRACKING,
+            FileContent.DATA,
+            FORMAT_VERSION_V4,
+            "s3://bucket/table/very-old.avro",
+            FileFormat.AVRO,
+            -1, // mimic invalid record count in old Avro metadata
+            100L,
+            null,
+            null,
+            null,
+            SortOrder.unsorted().orderId(),
+            null,
+            null,
+            null,
+            List.of(4L),
+            null);
+
+    ManifestFile manifest =
+        writeManifest(format, UNPARTITIONED_TYPE, 
ImmutableList.of(invalidRecordCountFile, FILE_D));
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, IO, TABLE_SCHEMA, 
UNPARTITIONED_SPECS)
+            .metricsConfig(METRICS_CONFIG);
+
+    List<TrackedFile> actualFiles = read(builder);
+
+    assertThat(actualFiles)
+        .usingComparatorForType(FILE_COMPARATOR, TrackedFile.class)
+        .containsExactly(invalidRecordCountFile, FILE_D);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void statsFilterDataFileBoundsFiltering(FileFormat format) throws 
IOException {
+    // FILE_C has stats {id in [0, 99], data in [a, z]}, FILE_D has no stats
+    ManifestFile manifest =
+        writeManifest(format, UNPARTITIONED_TYPE, ImmutableList.of(FILE_C, 
FILE_D));
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, IO, TABLE_SCHEMA, 
UNPARTITIONED_SPECS)
+            .filter(Expressions.equal("id", 105)) // eliminates FILE_C
+            .metricsConfig(METRICS_CONFIG);
+
+    List<TrackedFile> actualFiles = read(builder);
+
+    assertThat(actualFiles)
+        .usingComparatorForType(FILE_COMPARATOR, TrackedFile.class)
+        .containsExactly(FILE_D);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void statsFilterManifestBoundsFiltering(FileFormat format) throws 
IOException {
+    // DATA_MANIFEST_WITH_STATS_REF has stats {id in [0, 99], data in [a, z]}
+    ManifestFile manifest =
+        writeManifest(
+            format,
+            UNPARTITIONED_TYPE,
+            ImmutableList.of(DATA_MANIFEST_REF, DATA_MANIFEST_WITH_STATS_REF));
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, IO, TABLE_SCHEMA, 
UNPARTITIONED_SPECS)
+            .filter(Expressions.equal("id", 105)) // eliminates the manifest 
with stats
+            .metricsConfig(METRICS_CONFIG);
+
+    List<TrackedFile> actualFiles = read(builder);
+
+    assertThat(actualFiles)
+        .usingComparatorForType(FILE_COMPARATOR, TrackedFile.class)
+        .containsExactly(DATA_MANIFEST_REF);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void statsFilterBoundsFilteringWithForScanPlanning(FileFormat format) 
throws IOException {
+    // FILE_C has stats {id in [0, 99], data in [a, z]}, FILE_D has no stats
+    ManifestFile manifest =
+        writeManifest(format, UNPARTITIONED_TYPE, ImmutableList.of(FILE_C, 
FILE_D));
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, IO, TABLE_SCHEMA, 
UNPARTITIONED_SPECS)
+            .forScanPlanning() // does not project unused stats
+            .filter(Expressions.equal("id", 105)) // eliminates FILE_C
+            .metricsConfig(METRICS_CONFIG);
+
+    List<TrackedFile> actualFiles = read(builder);
+
+    assertThat(actualFiles)
+        .usingComparatorForType(FILE_COMPARATOR, TrackedFile.class)
+        .containsExactly(FILE_D);
+  }
+
+  @ParameterizedTest
+  @FieldSource("PROJECTION_CASES")
+  public void 
statsFilterBoundsFilteringWithProjection(Consumer<V4ManifestReader.Builder> 
config)
+      throws IOException {
+    // FILE_C has stats {id in [0, 99], data in [a, z]}, FILE_D has no stats
+    // config projects just the file location, but stats are automatically 
projected for the filter
+    ManifestFile manifest =
+        writeManifest(FileFormat.PARQUET, UNPARTITIONED_TYPE, 
ImmutableList.of(FILE_C, FILE_D));
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, IO, TABLE_SCHEMA, 
UNPARTITIONED_SPECS)
+            .filter(Expressions.equal("id", 105)) // eliminates FILE_C
+            .metricsConfig(METRICS_CONFIG);
+
+    config.accept(builder);
+
+    List<TrackedFile> actualFiles = read(builder);
+
+    
assertThat(actualFiles).extracting(TrackedFile::location).containsExactly(FILE_D.location());
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void statsFilterCaseSensitivity(FileFormat format) throws IOException 
{

Review Comment:
   nit: should we call this `statsFilterCaseInsensitive`? 
   



##########
core/src/main/java/org/apache/iceberg/V4ManifestReader.java:
##########
@@ -97,18 +101,30 @@ Schema readSchema() {
   /** Returns copies of the tracked files that match this reader's configured 
filters. */
   @Override
   public CloseableIterator<TrackedFile> iterator() {
-    CloseableIterable<TrackedFile> entries = 
CloseableIterable.transform(open(), this::prepare);
-    if (!partitionFilters.isEmpty()) {
-      // manifests have no partition, so the partition filter cannot apply to 
them
-      entries =
-          CloseableIterable.filter(entries, entry -> isManifest(entry) || 
matchesPartition(entry));
-    }
+    CloseableIterable<TrackedFile> files = CloseableIterable.transform(open(), 
this::prepare);
 
     if (!includeAll) {
-      entries = CloseableIterable.filter(entries, entry -> 
entry.tracking().isLive());
+      files = CloseableIterable.filter(files, file -> 
file.tracking().isLive());

Review Comment:
   Status-first makes sense. Stats before partition is the opposite of 
`ManifestReader`, which short-circuits `evaluator.eval(partition) && 
metricsEvaluator.eval(file)`. Partition eval is typically cheaper than bounds, 
so files that fail the partition residual still pay for 
`InclusiveStatsEvaluator` here. When partition filtering is rewritten, worth 
combining into one predicate with partition first (or keeping partition ahead 
of stats).



##########
api/src/main/java/org/apache/iceberg/io/CloseableIterable.java:
##########
@@ -132,6 +133,38 @@ protected boolean shouldKeep(E item) {
         iterable);
   }
 
+  /**
+   * Filters the given {@link CloseableIterable} and passes each skipped item 
to a {@link Consumer}.
+   *
+   * @param skipCallback A consumer used to handle skipped items
+   * @param iterable The underlying {@link CloseableIterable} to filter
+   * @param <E> The underlying type to be iterated
+   * @return A filtered {@link CloseableIterable} that skips items the 
predicate does not match
+   */
+  static <E> CloseableIterable<E> filter(
+      Consumer<E> skipCallback, CloseableIterable<E> iterable, Predicate<E> 
pred) {
+    Preconditions.checkArgument(null != iterable, "Invalid iterable: null");
+    Preconditions.checkArgument(null != pred, "Invalid predicate: null");
+
+    if (skipCallback != null) {

Review Comment:
   I'd require a non-null `skipCallback` like the `Counter` overload, and drop 
the null branch. The 2-arg `filter` already covers "no callback", and 
`filter(null, iterable, pred)` is ambiguous between `Counter` and `Consumer`.



##########
core/src/main/java/org/apache/iceberg/V4ManifestReader.java:
##########
@@ -97,18 +101,30 @@ Schema readSchema() {
   /** Returns copies of the tracked files that match this reader's configured 
filters. */
   @Override
   public CloseableIterator<TrackedFile> iterator() {
-    CloseableIterable<TrackedFile> entries = 
CloseableIterable.transform(open(), this::prepare);
-    if (!partitionFilters.isEmpty()) {
-      // manifests have no partition, so the partition filter cannot apply to 
them
-      entries =
-          CloseableIterable.filter(entries, entry -> isManifest(entry) || 
matchesPartition(entry));
-    }
+    CloseableIterable<TrackedFile> files = CloseableIterable.transform(open(), 
this::prepare);
 
     if (!includeAll) {
-      entries = CloseableIterable.filter(entries, entry -> 
entry.tracking().isLive());
+      files = CloseableIterable.filter(files, file -> 
file.tracking().isLive());
+    }
+
+    if (statsFilter != null) {
+      files =
+          CloseableIterable.filter(
+              this::incrementSkipCount,
+              files,
+              file -> statsFilter.eval(file.contentStats(), 
file.recordCount()));
+    } else {
+      files =
+          CloseableIterable.filter(
+              this::incrementSkipCount, files, file -> file.recordCount() != 
0L);

Review Comment:
   This always-on `recordCount != 0` path diverges from `ManifestReader`, which 
only runs `InclusiveMetricsEvaluator` when there is a row/partition filter. 
Empty files are still table entries: once this reader is used for rewrite, 
expire, or `includeAll()` metadata listing, dropping them would lose files. 
`InclusiveStatsEvaluator` already rejects `recordCount == 0` when `statsFilter 
!= null`. I would gate this skip on scan planning (or drop the else branch) so 
unfiltered reads still return empty files.



-- 
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