This is an automated email from the ASF dual-hosted git repository.

claudevdm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 706c76dc613 AddFiles: per-file coverage check and pinned-column 
enforcement in ConvertToDataFile (#40143)
706c76dc613 is described below

commit 706c76dc6132b156e47bd6ab9bcc3169c5ea928f
Author: claudevdm <[email protected]>
AuthorDate: Thu Sep 17 07:23:31 2026 -0400

    AddFiles: per-file coverage check and pinned-column enforcement in 
ConvertToDataFile (#40143)
    
    * AddFiles: per-file coverage check and pinned-column enforcement in 
ConvertToDataFile
    
    ConvertToDataFile gains a SchemaEvolutionConfig.
    With options set, every Parquet file is checked against the table
    before its DataFile is built, and every failure is one row on the
    error output:
    
    1. FileSchemas.effective(footer) (convert plus tighten, shared with the
       read side so both sides see the same schema) is computed. A footer
       whose schema cannot be converted (unannotated repeated leaf, legacy
       MAP_KEY_VALUE, uint64, ...) yields "Could not read the file's
       schema: <cause>".
    2. Coverage: SchemaDelta.classify against the cached table. The
       pre-pass commits the schema before paths reach this DoFn, so the
       delta is expected to be empty. If it is not, the table is refreshed once
        and classified again. A remaining delta is reported as "Table schema 
does
       not cover the file after refresh: <reason>", where the reason is the
       delta's disallowed reason when the options forbid the change (the
       ROUTE_TO_ERRORS path: this is where an incompatible file lands) or
       "changes not applied: ..." when the change was allowed but somehow not 
committed
       (defensive catch all).
    3. Pins: for each pinned column the table has, the file must contain
       it and be provably null-free: the footer's own null count for the
       column (FileSchemas.nullCount, summed over row groups) must be
       present and zero. A missing count is a violation too: "cannot prove"
       is not "proven".
       Messages: "Pinned required column X is absent from the file" / "has no 
null
       count statistics in the file" / "has N null(s) in the file".
    
    The pin evidence deliberately does NOT come from the Metrics object
    built for the DataFile: the table's write.metadata.metrics
    configuration shapes those (mode none, or the inferred-column cap on
    wide schemas, drops null counts entirely), and pin enforcement must
    not be configurable away. Two tests pin this down under
    write.metadata.metrics.default=none: a null-free pinned file still
    registers and a file with nulls is still caught.
    
    With evolution enabled, a non-Parquet file (ORC, Avro) is routed to
    the error output instead of registering unchecked: none of the checks
    above can read those formats, and silently registering a file the
    options promised to verify would be a hole in the guarantee. With
    evolution disabled they register exactly as before.
    
    SchemaEvolutionConfig.UnverifiableFileHandling relaxes exactly the
    "cannot verify" cases, never a failed check: REJECT (default) routes
    them to the error output as above; ACCEPT registers a non-Parquet file
    unchecked, and a Parquet file whose footer has no null count for a
    pinned column on trust (a writer with statistics disabled, or a pin
    under a list or map, whose physical chunk path is never mapped). An
    absent pinned column and a counted null stay violations under ACCEPT,
    and the pin walk continues past an unproven pin so a counted one still
    fails the file.
    
    * comments
    
    * spotbugs
---
 .../IO_Iceberg_Integration_Tests.json              |   2 +-
 .../org/apache/beam/sdk/io/iceberg/AddFiles.java   | 218 ++++++-
 .../apache/beam/sdk/io/iceberg/FileSchemas.java    | 115 +++-
 .../beam/sdk/io/iceberg/SchemaEvolutionConfig.java |  46 +-
 .../apache/beam/sdk/io/iceberg/AddFilesTest.java   | 681 +++++++++++++++++++++
 .../beam/sdk/io/iceberg/FileSchemasTest.java       |  95 +++
 .../sdk/io/iceberg/SchemaEvolutionConfigTest.java  |  22 +
 7 files changed, 1152 insertions(+), 27 deletions(-)

diff --git a/.github/trigger_files/IO_Iceberg_Integration_Tests.json 
b/.github/trigger_files/IO_Iceberg_Integration_Tests.json
index 5d04b2c0a8c..89e73b29da0 100644
--- a/.github/trigger_files/IO_Iceberg_Integration_Tests.json
+++ b/.github/trigger_files/IO_Iceberg_Integration_Tests.json
@@ -1,4 +1,4 @@
 {
     "comment": "Modify this file in a trivial way to cause this test suite to 
run.",
-    "modification": 5
+    "modification": 6
 }
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java
index 8254b2fc07d..daf61bf883f 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java
@@ -34,13 +34,16 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.apache.beam.sdk.coders.KvCoder;
 import org.apache.beam.sdk.coders.VarIntCoder;
 import org.apache.beam.sdk.coders.VarLongCoder;
+import 
org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.UnverifiableFileHandling;
 import org.apache.beam.sdk.metrics.Counter;
 import org.apache.beam.sdk.schemas.Schema;
 import org.apache.beam.sdk.schemas.SchemaCoder;
@@ -120,6 +123,11 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
       counter(AddFiles.class, "numManifestFilesAdded");
   private static final Counter numDataFilesAdded = counter(AddFiles.class, 
"numDataFilesAdded");
   private static final Counter numErrorFiles = counter(AddFiles.class, 
"numErrorFiles");
+  static final String UNCHECKED_FORMAT_COUNTER = "numUncheckedFormatFiles";
+  static final String UNPROVEN_PINS_COUNTER = "numUnprovenPinFiles";
+  private static final Counter numUncheckedFormatFiles =
+      counter(AddFiles.class, UNCHECKED_FORMAT_COUNTER);
+  private static final Counter numUnprovenPinFiles = counter(AddFiles.class, 
UNPROVEN_PINS_COUNTER);
   private static final Logger LOG = LoggerFactory.getLogger(AddFiles.class);
   private static final int DEFAULT_DATAFILES_PER_MANIFEST = 10_000;
   private static final int DEFAULT_MAX_MANIFESTS_PER_SNAPSHOT = 100;
@@ -260,8 +268,10 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
     private final @Nullable List<String> partitionFields;
     private final @Nullable List<String> sortFields;
     private final @Nullable Map<String, String> tableProps;
+    private final SchemaEvolutionConfig evolution;
     private transient @MonotonicNonNull BoundedAsyncTasks<ProcessResult> tasks;
     private transient volatile @MonotonicNonNull Table table;
+    private transient @MonotonicNonNull Set<String> warned;
 
     // Number of parallel threads processing incoming files
     private static final int THREAD_POOL_SIZE = 10;
@@ -274,21 +284,80 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
         @Nullable List<String> partitionFields,
         @Nullable List<String> sortFields,
         @Nullable Map<String, String> tableProps) {
+      this(
+          catalogConfig,
+          identifier,
+          prefix,
+          partitionFields,
+          sortFields,
+          tableProps,
+          SchemaEvolutionConfig.disabled());
+    }
+
+    public ConvertToDataFile(
+        IcebergCatalogConfig catalogConfig,
+        String identifier,
+        @Nullable String prefix,
+        @Nullable List<String> partitionFields,
+        @Nullable List<String> sortFields,
+        @Nullable Map<String, String> tableProps,
+        SchemaEvolutionConfig evolution) {
       this.catalogConfig = catalogConfig;
       this.identifier = identifier;
       this.prefix = prefix;
       this.partitionFields = partitionFields;
       this.sortFields = sortFields;
       this.tableProps = tableProps;
+      this.evolution = evolution;
     }
 
     static final String PREFIX_ERROR = "File path did not start with the 
specified prefix";
     private static final String UNKNOWN_FORMAT_ERROR = "Could not determine 
the file's format";
     static final String UNKNOWN_PARTITION_ERROR = "Could not determine the 
file's partition: ";
+    static final String UNREADABLE_SCHEMA_ERROR = "Could not read the file's 
schema: ";
+    static final String UNCOVERED_ERROR = "Table schema does not cover the 
file after refresh: ";
+    static final String PINNED_COLUMN_ERROR = "Pinned required column ";
+    static final String UNCHECKED_FORMAT_ERROR =
+        "Schema evolution is enabled but coverage and pin checks support only 
Parquet;"
+            + " refusing to register an unchecked file of format ";
+
+    /**
+     * What a file registered under {@link UnverifiableFileHandling#ACCEPT} 
could not be checked
+     * for.
+     */
+    enum Unverified {
+      FORMAT,
+      PIN_STATISTICS
+    }
+
+    /** Verdict of the per-file checks: an error, or none plus what was left 
unverified. */
+    private static final class Verdict {
+      static final Verdict OK = new Verdict(null, null);
+
+      final @Nullable String error;
+      final @Nullable Unverified unverified;
+
+      private Verdict(@Nullable String error, @Nullable Unverified unverified) 
{
+        this.error = error;
+        this.unverified = unverified;
+      }
+
+      static Verdict error(String message) {
+        return new Verdict(message, null);
+      }
+
+      static Verdict unverified(Unverified what) {
+        return new Verdict(null, what);
+      }
+    }
 
     private static class ProcessResult {
       final @Nullable SerializableDataFile dataFile;
       final @Nullable Row errorRow;
+
+      /** Counted on the processing thread: metrics touched from the executor 
are lost. */
+      final @Nullable Unverified unverified;
+
       final Instant timestamp;
       final BoundedWindow window;
       final PaneInfo paneInfo;
@@ -296,6 +365,7 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
       ProcessResult(
           @Nullable SerializableDataFile dataFile,
           @Nullable Row errorRow,
+          @Nullable Unverified unverified,
           Instant timestamp,
           BoundedWindow window,
           PaneInfo paneInfo) {
@@ -306,6 +376,7 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
             errorRow);
         this.dataFile = dataFile;
         this.errorRow = errorRow;
+        this.unverified = unverified;
         this.timestamp = timestamp;
         this.window = window;
         this.paneInfo = paneInfo;
@@ -315,6 +386,7 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
     @Setup
     public void setup() {
       tasks = new BoundedAsyncTasks<>(THREAD_POOL_SIZE, MAX_IN_FLIGHT_TASKS);
+      warned = ConcurrentHashMap.newKeySet();
     }
 
     /** Clears anything left behind if the runner reuses this instance after a 
failed bundle. */
@@ -361,6 +433,7 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
                 result.timestamp,
                 Collections.singleton(result.window),
                 result.paneInfo);
+        countUnverified(result);
       }
     }
 
@@ -375,6 +448,15 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
         numErrorFiles.inc();
       } else if (result.dataFile != null) {
         context.output(DATA_FILES, result.dataFile, result.timestamp, 
result.window);
+        countUnverified(result);
+      }
+    }
+
+    private static void countUnverified(ProcessResult result) {
+      if (result.unverified == Unverified.FORMAT) {
+        numUncheckedFormatFiles.inc();
+      } else if (result.unverified == Unverified.PIN_STATISTICS) {
+        numUnprovenPinFiles.inc();
       }
     }
 
@@ -411,6 +493,13 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
           return errorResult(filePath, PREFIX_ERROR, timestamp, window, 
paneInfo);
         }
 
+        if (table.schema().columns().isEmpty() && firstTime("empty schema")) {
+          LOG.warn(
+              "Table {} has no columns: files register with no readable 
columns and no stats."
+                  + " Enable schema evolution to infer the schema from the 
files.",
+              identifier);
+        }
+
         // ---- Per-file phase: every failure below is one error row, never a 
failed bundle.
         @Nullable ParquetMetadata parquetFooter = null;
         if (format.equals(FileFormat.PARQUET)) {
@@ -420,6 +509,13 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
             return errorResult(filePath, errorMessage(e), timestamp, window, 
paneInfo);
           }
         }
+        Verdict verdict = Verdict.OK;
+        if (evolution.isEnabled()) {
+          verdict = verify(filePath, format, parquetFooter);
+        }
+        if (verdict.error != null) {
+          return errorResult(filePath, verdict.error, timestamp, window, 
paneInfo);
+        }
 
         InputFile inputFile = table.io().newInputFile(filePath);
 
@@ -464,7 +560,12 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
                   .withPartitionPath(partitionPath)
                   .build();
           return new ProcessResult(
-              SerializableDataFile.from(df, table.spec()), null, timestamp, 
window, paneInfo);
+              SerializableDataFile.from(df, table.spec()),
+              null,
+              verdict.unverified,
+              timestamp,
+              window,
+              paneInfo);
         } catch (Exception e) {
           // getLength is a per-file read (e.g. the file was deleted 
mid-flight).
           return errorResult(filePath, errorMessage(e), timestamp, window, 
paneInfo);
@@ -472,11 +573,126 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
       };
     }
 
+    /**
+     * The checks the options promise, in order: a format the checks can read, 
a convertible schema,
+     * coverage by the table, pins. The first failure is the verdict.
+     */
+    private Verdict verify(String filePath, FileFormat format, @Nullable 
ParquetMetadata footer) {
+      if (!format.equals(FileFormat.PARQUET)) {
+        if (evolution.getUnverifiableFileHandling() == 
UnverifiableFileHandling.REJECT) {
+          return Verdict.error(UNCHECKED_FORMAT_ERROR + format.name());
+        }
+        if (firstTime("unchecked format")) {
+          LOG.warn(
+              "Registering {} files in table {} unchecked 
(UnverifiableFileHandling.ACCEPT):"
+                  + " coverage and pin checks read only Parquet, so a required 
column such a file"
+                  + " lacks or holds nulls in fails reads of the table, not 
registration. First"
+                  + " file: {}",
+              format,
+              identifier,
+              filePath);
+        }
+        return Verdict.unverified(Unverified.FORMAT);
+      }
+      ParquetMetadata parquetFooter = checkStateNotNull(footer, "Parquet 
checks need the footer");
+      org.apache.iceberg.Schema fileSchema;
+      try {
+        fileSchema = FileSchemas.effective(parquetFooter);
+      } catch (Exception e) {
+        return Verdict.error(UNREADABLE_SCHEMA_ERROR + errorMessage(e));
+      }
+      @Nullable String uncovered = uncoveredReason(fileSchema);
+      if (uncovered != null) {
+        return Verdict.error(uncovered);
+      }
+      return checkPins(filePath, fileSchema, parquetFooter);
+    }
+
+    /**
+     * The pre-pass commits the schema before paths reach this stage, so the 
cached table normally
+     * covers every file. If not, refresh once (a commit may have landed since 
the table was cached)
+     * and report the remaining delta. Never changes the schema.
+     */
+    private @Nullable String uncoveredReason(org.apache.iceberg.Schema 
fileSchema) {
+      Table table = checkStateNotNull(this.table);
+      SchemaDelta delta = SchemaDelta.classify(table, fileSchema);
+      if (delta.isEmpty()) {
+        return null;
+      }
+      synchronized (this) {
+        table.refresh();
+      }
+      delta = SchemaDelta.classify(table, fileSchema);
+      if (delta.isEmpty()) {
+        return null;
+      }
+      String reason = delta.disallowedReason(evolution);
+      if (reason.isEmpty()) {
+        reason = "changes not applied: " + String.join("; ", 
delta.descriptions());
+      }
+      return UNCOVERED_ERROR + reason;
+    }
+
+    /**
+     * A pinned column must be present and provably null-free; a zero-row file 
is vacuously fine.
+     * The evidence is the footer's own null counts read by the tighten rules 
({@link
+     * FileSchemas#nullCount}), never the Metrics built for the DataFile: the 
table's
+     * write.metadata.metrics configuration shapes those (mode none, or the 
inferred-column cap on
+     * wide schemas, drops the counts) and must not be able to turn pin 
enforcement off. A pin with
+     * no count is a violation under REJECT; under ACCEPT it is recorded as 
unproven and the walk
+     * goes on, so a pin the footer does count nulls for still fails the file.
+     */
+    private Verdict checkPins(
+        String filePath, org.apache.iceberg.Schema fileSchema, ParquetMetadata 
footer) {
+      Table table = checkStateNotNull(this.table);
+      List<String> unproven = new ArrayList<>();
+      for (String pinned : evolution.getRequiredColumns()) {
+        if (table.schema().findField(pinned) == null) {
+          continue;
+        }
+        if (fileSchema.findField(pinned) == null) {
+          return Verdict.error(PINNED_COLUMN_ERROR + pinned + " is absent from 
the file");
+        }
+        @Nullable Long nulls = FileSchemas.nullCount(footer, fileSchema, 
pinned);
+        if (nulls == null) {
+          if (evolution.getUnverifiableFileHandling() == 
UnverifiableFileHandling.REJECT) {
+            return Verdict.error(
+                PINNED_COLUMN_ERROR + pinned + " has no null count statistics 
in the file");
+          }
+          unproven.add(pinned);
+          continue;
+        }
+        if (nulls > 0) {
+          return Verdict.error(
+              PINNED_COLUMN_ERROR + pinned + " has " + nulls + " null(s) in 
the file");
+        }
+      }
+      if (unproven.isEmpty()) {
+        return Verdict.OK;
+      }
+      if (firstTime("unproven pins")) {
+        LOG.warn(
+            "Registering files in table {} whose footer has no null count 
statistics for pinned"
+                + " column(s) {} on trust (UnverifiableFileHandling.ACCEPT): 
nulls there fail"
+                + " reads of the table, not registration. First file: {}",
+            identifier,
+            unproven,
+            filePath);
+      }
+      return Verdict.unverified(Unverified.PIN_STATISTICS);
+    }
+
+    /** Once per instance per key: at volume a line per file would drown the 
log. */
+    private boolean firstTime(String key) {
+      return checkStateNotNull(warned).add(key);
+    }
+
     private static ProcessResult errorResult(
         String filePath, String message, Instant timestamp, BoundedWindow 
window, PaneInfo pane) {
       return new ProcessResult(
           null,
           Row.withSchema(ERROR_SCHEMA).addValues(filePath, message).build(),
+          null,
           timestamp,
           window,
           pane);
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/FileSchemas.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/FileSchemas.java
index d95321153d5..79f87aa2fba 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/FileSchemas.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/FileSchemas.java
@@ -21,8 +21,10 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.SchemaParser;
@@ -34,6 +36,7 @@ import org.apache.parquet.column.statistics.Statistics;
 import org.apache.parquet.hadoop.metadata.BlockMetaData;
 import org.apache.parquet.hadoop.metadata.ColumnChunkMetaData;
 import org.apache.parquet.hadoop.metadata.ParquetMetadata;
+import org.checkerframework.checker.nullness.qual.Nullable;
 
 /**
  * What a file contributes to schema inference: the canonical form of the 
schema it declares, and
@@ -66,6 +69,51 @@ final class FileSchemas {
         SchemaParser.toJson(canonical(converted)), 1, 
changedToRequired(converted, tightened));
   }
 
+  /**
+   * This one file's schema in table terms: convert, then tighten using its 
own footer. Uses the
+   * file's own null evidence, not its group's: a file that proves a column 
null-free registers even
+   * when other files sharing its schema could not prove it.
+   */
+  static Schema effective(ParquetMetadata footer) {
+    Schema converted = 
ParquetSchemaUtil.convert(footer.getFileMetaData().getSchema());
+    return tighten(converted, footer);
+  }
+
+  /**
+   * The file's own null count for one dotted column of its schema, or null 
when the file cannot
+   * prove it. Pin evidence must come from the file itself, not from the 
Metrics built for the
+   * DataFile: the table's write.metadata.metrics configuration shapes those 
and must not be able to
+   * turn pin enforcement off. A column the schema declares required, with 
every ancestor required,
+   * counts 0 without any statistic: Parquet's repetition is structural, so 
such a column cannot
+   * encode a null. Otherwise the footer's counts are read by the {@link 
#tighten} rules: a leaf's
+   * count is summed over row groups and unknown when any non-empty row group 
lacks it; a struct
+   * counts 0 when a leaf beneath it (struct nesting only) has none, since a 
null struct nulls every
+   * leaf, and is unknown otherwise, since a leaf's nulls include its 
ancestors' and cannot be
+   * attributed; a column under a list or map is unknown; a file with no rows 
proves every column.
+   */
+  static @Nullable Long nullCount(ParquetMetadata footer, Schema fileSchema, 
String column) {
+    if (rowCount(footer) == 0) {
+      return 0L;
+    }
+    Types.NestedField field = fileSchema.findField(column);
+    if (field == null) {
+      return null;
+    }
+    if (requiredAlongPath(fileSchema, column)) {
+      return 0L;
+    }
+    List<String> path = new ArrayList<>(Arrays.asList(column.split("\\.", 
-1)));
+    Map<List<String>, @Nullable Long> counts = nullCountsByLeaf(footer);
+    if (field.type().isPrimitiveType()) {
+      return counts.get(path);
+    }
+    if (field.type().isStructType()) {
+      Tightened struct = tightenStruct(field.type().asStructType(), path, 
zeroNullLeaves(counts));
+      return struct.hasNullFreeLeaf ? Long.valueOf(0) : null;
+    }
+    return null;
+  }
+
   /**
    * Marks a declared-optional column required when every row group has a null 
count of zero for it,
    * so the file does not request a relaxation it does not need; an absent 
count is not proof. A
@@ -164,33 +212,68 @@ final class FileSchemas {
     return rows;
   }
 
+  /** Whether the column and each of its ancestors are required: no null can 
be encoded there. */
+  private static boolean requiredAlongPath(Schema schema, String column) {
+    Types.NestedField field = schema.findField(column);
+    if (field == null) {
+      return false;
+    }
+    Map<Integer, Integer> parents = TypeUtil.indexParents(schema.asStruct());
+    for (@Nullable Integer id = field.fieldId(); id != null; id = 
parents.get(id)) {
+      if (schema.findField(id).isOptional()) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /** Leaf paths proven null-free: the one source of null evidence for tighten 
and for pins. */
+  private static Set<List<String>> leafPathsWithZeroNullCounts(ParquetMetadata 
footer) {
+    return zeroNullLeaves(nullCountsByLeaf(footer));
+  }
+
+  private static Set<List<String>> zeroNullLeaves(Map<List<String>, @Nullable 
Long> counts) {
+    Set<List<String>> proven = new HashSet<>();
+    for (Map.Entry<List<String>, @Nullable Long> entry : counts.entrySet()) {
+      Long count = entry.getValue();
+      if (count != null && count == 0) {
+        proven.add(entry.getKey());
+      }
+    }
+    return proven;
+  }
+
   /**
-   * Leaf paths proven null-free in every row group that has rows 
(intersection over blocks). An
-   * empty row group holds no nulls whatever its statistics say, so it 
constrains nothing.
+   * Null count per leaf chunk path, summed over the row groups that have 
rows; null where any such
+   * row group lacks the statistic (an absent count proves nothing). An empty 
row group holds no
+   * nulls whatever its statistics say, so it constrains nothing.
    */
-  private static Set<List<String>> leafPathsWithZeroNullCounts(ParquetMetadata 
footer) {
-    Set<List<String>> proven = null;
+  private static Map<List<String>, @Nullable Long> 
nullCountsByLeaf(ParquetMetadata footer) {
+    Map<List<String>, @Nullable Long> counts = new HashMap<>();
     for (BlockMetaData block : footer.getBlocks()) {
       if (block.getRowCount() == 0) {
         continue;
       }
-      Set<List<String>> provenHere = new HashSet<>();
       for (ColumnChunkMetaData chunk : block.getColumns()) {
+        List<String> path = Arrays.asList(chunk.getPath().toArray());
         Statistics<?> stats = chunk.getStatistics();
-        if (stats != null && stats.isNumNullsSet() && stats.getNumNulls() == 
0) {
-          provenHere.add(Arrays.asList(chunk.getPath().toArray()));
+        Long inChunk = null;
+        if (stats != null && stats.isNumNullsSet()) {
+          inChunk = stats.getNumNulls();
+        }
+        if (!counts.containsKey(path)) {
+          counts.put(path, inChunk);
+          continue;
+        }
+        Long soFar = counts.get(path);
+        if (soFar == null || inChunk == null) {
+          counts.put(path, null);
+        } else {
+          counts.put(path, soFar + inChunk);
         }
-      }
-      if (proven == null) {
-        proven = provenHere;
-      } else {
-        proven.retainAll(provenHere);
       }
     }
-    if (proven == null) {
-      return new HashSet<>();
-    }
-    return proven;
+    return counts;
   }
 
   private static final class Tightened {
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfig.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfig.java
index 492b1cdad49..6b67b390ae5 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfig.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfig.java
@@ -38,13 +38,18 @@ import org.checkerframework.checker.nullness.qual.Nullable;
  * }</pre>
  *
  * <p><b>Pins.</b> Required columns are pinned: never made optional whatever 
the options say, and
- * created required when this transform creates the table. A Parquet file that 
lacks a pinned
- * column, has nulls in it, or carries no null-count statistics for it is 
routed to the error
- * output; ORC and Avro files are not checked. Pins name canonical (table) 
paths, dotted for nested
- * fields, with the container segment spelled out under lists and maps ({@code
+ * created required when this transform creates the table. A Parquet file that 
lacks a pinned column
+ * or has nulls in it is routed to the error output. Pins name canonical 
(table) paths, dotted for
+ * nested fields, with the container segment spelled out under lists and maps 
({@code
  * addresses.element.city}, {@code attributes.value.total}). A top-level 
column whose own name
  * contains a dot cannot be pinned.
  *
+ * <p><b>Unverifiable files.</b> The per-file checks read Parquet footers. An 
ORC or Avro file
+ * cannot be checked at all, and a Parquet file whose footer carries no 
null-count statistics for a
+ * pinned column (a writer with statistics disabled, or a pin under a list or 
map, whose physical
+ * chunk path the check does not map) cannot prove the pin. {@link 
UnverifiableFileHandling} decides
+ * whether such a file is routed to the error output (the default) or 
registered on trust.
+ *
  * <p><b>Incompatible schemas.</b> A schema that needs a change the options do 
not allow, or that
  * conflicts with the table or with another file's schema. {@link 
IncompatibleSchemaHandling}
  * decides whether that fails the pipeline before any schema commit (the batch 
default) or skips the
@@ -69,6 +74,23 @@ public abstract class SchemaEvolutionConfig implements 
Serializable {
     ROUTE_TO_ERRORS
   }
 
+  /**
+   * What to do with a file the per-file checks cannot verify: a non-Parquet 
file, or a Parquet file
+   * with no null-count statistics for a pinned column. A file that fails a 
check is always routed
+   * to the error output.
+   */
+  public enum UnverifiableFileHandling {
+    /** Route the file to the error output. The default: "cannot prove" is not 
"proven". */
+    REJECT,
+    /**
+     * Register the file unchecked, counted ({@code numUncheckedFormatFiles}, 
{@code
+     * numUnprovenPinFiles}) and logged. A trusted file that lacks a required 
column or holds nulls
+     * in one breaks reads of the table at query time, not at registration. A 
non-Parquet file never
+     * contributes to schema inference, so it cannot seed a missing table.
+     */
+    ACCEPT
+  }
+
   public abstract Set<SchemaEvolutionOption> getOptions();
 
   /**
@@ -86,6 +108,8 @@ public abstract class SchemaEvolutionConfig implements 
Serializable {
    */
   public abstract @Nullable IncompatibleSchemaHandling 
getIncompatibleSchemaHandling();
 
+  public abstract UnverifiableFileHandling getUnverifiableFileHandling();
+
   public IncompatibleSchemaHandling incompatibleSchemaHandling(boolean 
bounded) {
     IncompatibleSchemaHandling handling = getIncompatibleSchemaHandling();
     if (handling != null) {
@@ -117,7 +141,8 @@ public abstract class SchemaEvolutionConfig implements 
Serializable {
   public static Builder builder() {
     return new AutoValue_SchemaEvolutionConfig.Builder()
         .setOptions(Collections.emptySet())
-        .setRequiredColumns(Collections.emptySet());
+        .setRequiredColumns(Collections.emptySet())
+        .setUnverifiableFileHandling(UnverifiableFileHandling.REJECT);
   }
 
   @AutoValue.Builder
@@ -129,9 +154,11 @@ public abstract class SchemaEvolutionConfig implements 
Serializable {
     public abstract Builder setIncompatibleSchemaHandling(
         @Nullable IncompatibleSchemaHandling handling);
 
+    public abstract Builder 
setUnverifiableFileHandling(UnverifiableFileHandling handling);
+
     abstract SchemaEvolutionConfig autoBuild();
 
-    /** Pins and handling without an option would silently do nothing, so they 
are rejected. */
+    /** Any setting without an option would silently do nothing, so they are 
rejected. */
     public SchemaEvolutionConfig build() {
       SchemaEvolutionConfig config = autoBuild();
       for (String column : config.getRequiredColumns()) {
@@ -143,9 +170,10 @@ public abstract class SchemaEvolutionConfig implements 
Serializable {
       Preconditions.checkArgument(
           config.isEnabled()
               || (config.getRequiredColumns().isEmpty()
-                  && config.getIncompatibleSchemaHandling() == null),
-          "required columns and incompatible schema handling need at least one 
schema evolution"
-              + " option");
+                  && config.getIncompatibleSchemaHandling() == null
+                  && config.getUnverifiableFileHandling() == 
UnverifiableFileHandling.REJECT),
+          "required columns, incompatible schema handling and unverifiable 
file handling need at"
+              + " least one schema evolution option");
       return config;
     }
   }
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
index 96da59f4505..85d82b84551 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
@@ -36,18 +36,31 @@ import java.nio.ByteBuffer;
 import java.nio.CharBuffer;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.Comparator;
+import java.util.EnumSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
+import org.apache.beam.sdk.PipelineResult;
 import org.apache.beam.sdk.coders.StringUtf8Coder;
+import 
org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.UnverifiableFileHandling;
+import org.apache.beam.sdk.metrics.MetricNameFilter;
+import org.apache.beam.sdk.metrics.MetricResult;
+import org.apache.beam.sdk.metrics.MetricsFilter;
+import org.apache.beam.sdk.testing.ExpectedLogs;
 import org.apache.beam.sdk.testing.PAssert;
 import org.apache.beam.sdk.testing.TestPipeline;
 import org.apache.beam.sdk.testing.TestStream;
+import org.apache.beam.sdk.transforms.Count;
 import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.ParDo;
 import org.apache.beam.sdk.values.PCollection;
 import org.apache.beam.sdk.values.PCollectionRowTuple;
+import org.apache.beam.sdk.values.PCollectionTuple;
 import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.TupleTagList;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Lists;
@@ -116,6 +129,7 @@ public class AddFilesTest {
   private IcebergCatalogConfig catalogConfig;
   @ClassRule public static final TemporaryFolder TEMPORARY_FOLDER = new 
TemporaryFolder();
   @Rule public TestName testName = new TestName();
+  @Rule public ExpectedLogs logs = ExpectedLogs.none(AddFiles.class);
   @Rule public ExpectedException thrown = ExpectedException.none();
 
   @Rule
@@ -949,4 +963,671 @@ public class AddFilesTest {
   private Record record(int id, String name, int age) {
     return GenericRecord.create(icebergSchema).copy("id", id, "name", name, 
"age", age);
   }
+
+  // ---- ConvertToDataFile coverage check and pinned columns
+
+  private static final SchemaEvolutionConfig ADDITIONS =
+      SchemaEvolutionConfig.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION);
+
+  private PCollectionTuple convert(SchemaEvolutionConfig config, String... 
files) {
+    PCollectionTuple out =
+        pipeline
+            .apply("Create Input", Create.of(Arrays.asList(files)))
+            .apply(
+                ParDo.of(
+                        new AddFiles.ConvertToDataFile(
+                            catalogConfig, tableId.toString(), null, null, 
null, null, config))
+                    .withOutputTags(
+                        AddFiles.ConvertToDataFile.DATA_FILES,
+                        TupleTagList.of(AddFiles.ConvertToDataFile.ERRORS)));
+    
out.get(AddFiles.ConvertToDataFile.ERRORS).setRowSchema(AddFiles.ERROR_SCHEMA);
+    return out;
+  }
+
+  private String writeWithSchema(String name, Schema schema, Record... 
records) throws IOException {
+    String file = root + name;
+    DataWriter<Record> writer =
+        Parquet.writeData(Files.localOutput(file))
+            .schema(schema)
+            .withSpec(PartitionSpec.unpartitioned())
+            .createWriterFunc(GenericParquetWriter::create)
+            .build();
+    try {
+      for (Record record : records) {
+        writer.write(record);
+      }
+    } finally {
+      writer.close();
+    }
+    return file;
+  }
+
+  private String writeOneRecord(String name) throws IOException {
+    String file = root + name;
+    DataWriter<Record> writer = createWriter(file);
+    writer.write(record(1, "a", 1));
+    writer.close();
+    return file;
+  }
+
+  private static final Schema WIDER =
+      new Schema(
+          Types.NestedField.required(1, "id", Types.IntegerType.get()),
+          Types.NestedField.required(2, "name", Types.StringType.get()),
+          Types.NestedField.required(3, "age", Types.IntegerType.get()),
+          Types.NestedField.optional(4, "email", Types.StringType.get()));
+
+  private String writeWider(String name) throws IOException {
+    Record record = GenericRecord.create(WIDER);
+    record.setField("id", 1);
+    record.setField("name", "a");
+    record.setField("age", 1);
+    record.setField("email", "e");
+    return writeWithSchema(name, WIDER, record);
+  }
+
+  private void assertSingleError(PCollectionTuple out, String file, String 
contains) {
+    PAssert.that(out.get(AddFiles.ConvertToDataFile.DATA_FILES)).empty();
+    PAssert.that(out.get(AddFiles.ConvertToDataFile.ERRORS))
+        .satisfies(
+            rows -> {
+              Row row = Iterables.getOnlyElement(rows);
+              assertEquals(file, row.getString("file"));
+              assertThat(row.getString("error"), containsString(contains));
+              return null;
+            });
+  }
+
+  private void assertRegisters(PCollectionTuple out, long files) {
+    PAssert.that(out.get(AddFiles.ConvertToDataFile.ERRORS)).empty();
+    
PAssert.thatSingleton(out.get(AddFiles.ConvertToDataFile.DATA_FILES).apply(Count.globally()))
+        .isEqualTo(files);
+  }
+
+  @Test
+  public void testEmptySchemaTableWarnsAndStillRegisters() throws Exception {
+    catalog.createTable(tableId, new Schema());
+    String file = writeOneRecord("data.parquet");
+
+    PCollectionTuple out = convert(SchemaEvolutionConfig.disabled(), file);
+
+    assertRegisters(out, 1);
+    pipeline.run().waitUntilFinish();
+    logs.verifyWarn("has no columns");
+  }
+
+  /** Files without embedded field ids against a zero-column table still 
register. */
+  @Test
+  public void testEmptySchemaTableWithIdLessParquetStillRegisters() throws 
Exception {
+    catalog.createTable(tableId, new Schema());
+    File file = new File(temp.getRoot(), "idless.parquet");
+    org.apache.avro.Schema avro =
+        org.apache.avro.SchemaBuilder.record("r")
+            .fields()
+            .requiredInt("id")
+            .optionalString("name")
+            .name("address")
+            .type()
+            .record("address")
+            .fields()
+            .optionalString("city")
+            .endRecord()
+            .noDefault()
+            .endRecord();
+    try (org.apache.parquet.hadoop.ParquetWriter<Object> writer =
+        org.apache.parquet.avro.AvroParquetWriter.builder(
+                new org.apache.hadoop.fs.Path(file.getAbsolutePath()))
+            .withSchema(avro)
+            .build()) {
+      org.apache.avro.generic.GenericData.Record record =
+          new org.apache.avro.generic.GenericData.Record(avro);
+      record.put("id", 1);
+      record.put("name", "a");
+      org.apache.avro.generic.GenericData.Record address =
+          new 
org.apache.avro.generic.GenericData.Record(avro.getField("address").schema());
+      address.put("city", "c");
+      record.put("address", address);
+      writer.write(record);
+    }
+
+    PCollectionTuple out = convert(SchemaEvolutionConfig.disabled(), 
file.getAbsolutePath());
+
+    assertRegisters(out, 1);
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testCoveredFileRegistersWithEvolutionEnabled() throws Exception {
+    catalog.createTable(tableId, icebergSchema);
+    String file = writeOneRecord("data.parquet");
+
+    PCollectionTuple out = convert(ADDITIONS, file);
+
+    assertRegisters(out, 1);
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testUncoveredFileRoutesToErrorsWhenEvolutionEnabled() throws 
Exception {
+    catalog.createTable(tableId, icebergSchema);
+    String file = writeWider("wider.parquet");
+
+    PCollectionTuple out = convert(ADDITIONS, file);
+
+    assertSingleError(out, file, "does not cover the file");
+    PAssert.that(out.get(AddFiles.ConvertToDataFile.ERRORS))
+        .satisfies(
+            rows -> {
+              assertThat(
+                  Iterables.getOnlyElement(rows).getString("error"),
+                  containsString("add optional email string"));
+              return null;
+            });
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testExtraColumnsRegisterWhenEvolutionDisabled() throws Exception 
{
+    catalog.createTable(tableId, icebergSchema);
+    String file = writeWider("wider.parquet");
+
+    PCollectionTuple out = convert(SchemaEvolutionConfig.disabled(), file);
+
+    assertRegisters(out, 1);
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testUnreadableSchemaRoutesToErrorsWithConverterMessage() throws 
Exception {
+    catalog.createTable(tableId, icebergSchema);
+    // a legacy unannotated repeated field: readable Parquet, rejected by 
Iceberg's converter
+    org.apache.parquet.schema.MessageType legacy =
+        org.apache.parquet.schema.Types.buildMessage()
+            
.required(org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32)
+            .named("id")
+            
.repeated(org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32)
+            .named("vals")
+            .named("root");
+    File legacyFile = new File(temp.getRoot(), "legacy.parquet");
+    try 
(org.apache.parquet.hadoop.ParquetWriter<org.apache.parquet.example.data.Group> 
writer =
+        org.apache.parquet.hadoop.example.ExampleParquetWriter.builder(
+                new org.apache.hadoop.fs.Path(legacyFile.getAbsolutePath()))
+            .withType(legacy)
+            .build()) {
+      org.apache.parquet.example.data.Group group =
+          new 
org.apache.parquet.example.data.simple.SimpleGroupFactory(legacy).newGroup();
+      group.add("id", 1);
+      group.add("vals", 2);
+      writer.write(group);
+    }
+    String file = legacyFile.getAbsolutePath();
+
+    PCollectionTuple out = convert(ADDITIONS, file);
+
+    assertSingleError(out, file, 
AddFiles.ConvertToDataFile.UNREADABLE_SCHEMA_ERROR);
+    PAssert.that(out.get(AddFiles.ConvertToDataFile.ERRORS))
+        .satisfies(
+            rows -> {
+              assertThat(
+                  Iterables.getOnlyElement(rows).getString("error"),
+                  containsString("repetition REPEATED"));
+              return null;
+            });
+    pipeline.run().waitUntilFinish();
+  }
+
+  // ---- pinned columns
+  //
+  // A pin is enforced in two layers. A pinned column the table holds as 
REQUIRED is protected by
+  // the coverage check: a file that declares it optional with nulls, or lacks 
it, needs a
+  // relaxation of a pinned column, which SchemaDelta refuses, so the file is 
routed there
+  // (testPinnedRequiredColumnIsProtectedByCoverage). The per-file pin walk is 
reached only for
+  // pinned columns the table holds as OPTIONAL: columns the pre-pass added 
(it never creates them
+  // required) or pre-existing optional ones. The tests below therefore pin 
optional columns.
+
+  private static SchemaEvolutionConfig pinned(String column) {
+    return SchemaEvolutionConfig.builder()
+        .setOptions(EnumSet.allOf(SchemaEvolutionOption.class))
+        .setRequiredColumns(Collections.singleton(column))
+        .build();
+  }
+
+  private static final Schema OPTIONAL_NAME =
+      new Schema(
+          Types.NestedField.required(1, "id", Types.IntegerType.get()),
+          Types.NestedField.optional(2, "name", Types.StringType.get()),
+          Types.NestedField.required(3, "age", Types.IntegerType.get()));
+
+  private static final Schema WITHOUT_NAME =
+      new Schema(
+          Types.NestedField.required(1, "id", Types.IntegerType.get()),
+          Types.NestedField.required(3, "age", Types.IntegerType.get()));
+
+  private String writeCleanName(String name) throws IOException {
+    return writeWithSchema(
+        name,
+        OPTIONAL_NAME,
+        GenericRecord.create(OPTIONAL_NAME).copy("id", 1, "name", "a", "age", 
1));
+  }
+
+  private String writeOneNullName(String name) throws IOException {
+    return writeWithSchema(
+        name,
+        OPTIONAL_NAME,
+        GenericRecord.create(OPTIONAL_NAME).copy("id", 1, "name", "a", "age", 
1),
+        GenericRecord.create(OPTIONAL_NAME).copy("id", 2, "age", 2));
+  }
+
+  private String writeWithoutName(String name) throws IOException {
+    return writeWithSchema(
+        name, WITHOUT_NAME, GenericRecord.create(WITHOUT_NAME).copy("id", 1, 
"age", 1));
+  }
+
+  @Test
+  public void testPinnedRequiredColumnIsProtectedByCoverage() throws Exception 
{
+    catalog.createTable(tableId, icebergSchema);
+    String withNull = writeOneNullName("nulls.parquet");
+    String absent = writeWithoutName("noname.parquet");
+
+    PCollectionTuple out = convert(pinned("name"), withNull, absent);
+
+    PAssert.that(out.get(AddFiles.ConvertToDataFile.DATA_FILES)).empty();
+    PAssert.that(out.get(AddFiles.ConvertToDataFile.ERRORS))
+        .satisfies(
+            rows -> {
+              assertEquals(2, Iterables.size(rows));
+              for (Row row : rows) {
+                assertThat(row.getString("error"), containsString("does not 
cover the file"));
+                assertThat(row.getString("error"), containsString("pinned as 
required"));
+              }
+              return null;
+            });
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testPinnedColumnWithNullsRoutesToErrors() throws Exception {
+    catalog.createTable(tableId, OPTIONAL_NAME);
+    String file = writeOneNullName("nulls.parquet");
+
+    PCollectionTuple out = convert(pinned("name"), file);
+
+    assertSingleError(out, file, "Pinned required column name has 1 null(s)");
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testPinnedColumnAbsentRoutesToErrors() throws Exception {
+    catalog.createTable(tableId, OPTIONAL_NAME);
+    String file = writeWithoutName("noname.parquet");
+
+    PCollectionTuple out = convert(pinned("name"), file);
+
+    assertSingleError(out, file, "Pinned required column name is absent from 
the file");
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testPinnedColumnProvenNullFreeRegisters() throws Exception {
+    catalog.createTable(tableId, OPTIONAL_NAME);
+    String file = writeCleanName("clean.parquet");
+
+    PCollectionTuple out = convert(pinned("name"), file);
+
+    assertRegisters(out, 1);
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testPinnedColumnZeroRowFileRegisters() throws Exception {
+    catalog.createTable(tableId, OPTIONAL_NAME);
+    // Iceberg's writer creates no file for zero rows; parquet-avro does.
+    File empty = new File(temp.getRoot(), "empty.parquet");
+    org.apache.parquet.avro.AvroParquetWriter.builder(
+            new org.apache.hadoop.fs.Path(empty.getAbsolutePath()))
+        .withSchema(AVRO_OPTIONAL_NAME)
+        .build()
+        .close();
+    String file = empty.getAbsolutePath();
+
+    PCollectionTuple out = convert(pinned("name"), file);
+
+    assertRegisters(out, 1);
+    pipeline.run().waitUntilFinish();
+  }
+
+  /** Pin evidence comes from the footer: the table's metrics configuration 
cannot disable it. */
+  @Test
+  public void testPinnedColumnProvenNullFreeRegistersUnderMetricsModeNone() 
throws Exception {
+    catalog.createTable(
+        tableId,
+        OPTIONAL_NAME,
+        PartitionSpec.unpartitioned(),
+        ImmutableMap.of("write.metadata.metrics.default", "none"));
+    String file = writeCleanName("clean.parquet");
+
+    PCollectionTuple out = convert(pinned("name"), file);
+
+    assertRegisters(out, 1);
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testPinnedColumnNullsDetectedUnderMetricsModeNone() throws 
Exception {
+    catalog.createTable(
+        tableId,
+        OPTIONAL_NAME,
+        PartitionSpec.unpartitioned(),
+        ImmutableMap.of("write.metadata.metrics.default", "none"));
+    String file = writeOneNullName("nulls.parquet");
+
+    PCollectionTuple out = convert(pinned("name"), file);
+
+    assertSingleError(out, file, "Pinned required column name has 1 null(s)");
+    pipeline.run().waitUntilFinish();
+  }
+
+  /** With evolution on, a format the checks cannot read must not register 
unchecked. */
+  @Test
+  public void testNonParquetFileRoutesToErrorsWhenEvolutionEnabled() throws 
Exception {
+    catalog.createTable(tableId, icebergSchema);
+    String file = writeAvroFile("data.avro");
+
+    PCollectionTuple out = convert(ADDITIONS, file);
+
+    assertSingleError(out, file, 
AddFiles.ConvertToDataFile.UNCHECKED_FORMAT_ERROR + "AVRO");
+    pipeline.run().waitUntilFinish();
+  }
+
+  // ---- UnverifiableFileHandling.ACCEPT
+
+  private static SchemaEvolutionConfig accepting(SchemaEvolutionConfig base) {
+    return SchemaEvolutionConfig.builder()
+        .setOptions(base.getOptions())
+        .setRequiredColumns(base.getRequiredColumns())
+        .setUnverifiableFileHandling(UnverifiableFileHandling.ACCEPT)
+        .build();
+  }
+
+  private static long counted(PipelineResult result, String counter) {
+    long total = 0;
+    for (MetricResult<Long> metric :
+        result
+            .metrics()
+            .queryMetrics(
+                MetricsFilter.builder()
+                    .addNameFilter(MetricNameFilter.named(AddFiles.class, 
counter))
+                    .build())
+            .getCounters()) {
+      total += metric.getAttempted();
+    }
+    return total;
+  }
+
+  private String writeAvroFile(String name) throws IOException {
+    File avroFile = new File(temp.getRoot(), name);
+    org.apache.avro.Schema avro =
+        
org.apache.avro.SchemaBuilder.record("r").fields().requiredInt("id").endRecord();
+    try 
(org.apache.avro.file.DataFileWriter<org.apache.avro.generic.GenericRecord> 
writer =
+        new org.apache.avro.file.DataFileWriter<>(
+            new org.apache.avro.generic.GenericDatumWriter<>(avro))) {
+      writer.create(avro, avroFile);
+      org.apache.avro.generic.GenericData.Record avroRecord =
+          new org.apache.avro.generic.GenericData.Record(avro);
+      avroRecord.put("id", 1);
+      writer.append(avroRecord);
+    }
+    return avroFile.getAbsolutePath();
+  }
+
+  private static final org.apache.avro.Schema AVRO_OPTIONAL_NAME =
+      org.apache.avro.SchemaBuilder.record("r")
+          .fields()
+          .requiredInt("id")
+          .optionalString("name")
+          .requiredInt("age")
+          .endRecord();
+
+  /**
+   * One OPTIONAL_NAME-shaped row written by parquet-avro with column 
statistics switched off, for
+   * the named columns or for every column when none is named.
+   */
+  private String writeWithoutStatistics(String name, List<String> 
statlessColumns, boolean nullName)
+      throws IOException {
+    File file = new File(temp.getRoot(), name);
+    org.apache.parquet.avro.AvroParquetWriter.Builder<Object> builder =
+        org.apache.parquet.avro.AvroParquetWriter.builder(
+                new org.apache.hadoop.fs.Path(file.getAbsolutePath()))
+            .withSchema(AVRO_OPTIONAL_NAME);
+    if (statlessColumns.isEmpty()) {
+      builder = builder.withStatisticsEnabled(false);
+    }
+    for (String column : statlessColumns) {
+      builder = builder.withStatisticsEnabled(column, false);
+    }
+    try (org.apache.parquet.hadoop.ParquetWriter<Object> writer = 
builder.build()) {
+      org.apache.avro.generic.GenericData.Record record =
+          new org.apache.avro.generic.GenericData.Record(AVRO_OPTIONAL_NAME);
+      record.put("id", 1);
+      record.put("name", nullName ? null : "a");
+      record.put("age", 1);
+      writer.write(record);
+    }
+    return file.getAbsolutePath();
+  }
+
+  private static final org.apache.avro.Schema AVRO_REQUIRED_NAME =
+      org.apache.avro.SchemaBuilder.record("r")
+          .fields()
+          .requiredInt("id")
+          .requiredString("name")
+          .requiredInt("age")
+          .endRecord();
+
+  /** Parquet cannot encode a null in a required column, so no statistics are 
needed to prove it. */
+  @Test
+  public void testPinnedColumnDeclaredRequiredRegistersWithoutStatistics() 
throws Exception {
+    catalog.createTable(tableId, OPTIONAL_NAME);
+    File file = new File(temp.getRoot(), "required_nostats.parquet");
+    try (org.apache.parquet.hadoop.ParquetWriter<Object> writer =
+        org.apache.parquet.avro.AvroParquetWriter.builder(
+                new org.apache.hadoop.fs.Path(file.getAbsolutePath()))
+            .withSchema(AVRO_REQUIRED_NAME)
+            .withStatisticsEnabled(false)
+            .build()) {
+      org.apache.avro.generic.GenericData.Record record =
+          new org.apache.avro.generic.GenericData.Record(AVRO_REQUIRED_NAME);
+      record.put("id", 1);
+      record.put("name", "a");
+      record.put("age", 1);
+      writer.write(record);
+    }
+
+    PCollectionTuple out = convert(pinned("name"), file.getAbsolutePath());
+
+    assertRegisters(out, 1);
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testPinnedColumnWithoutStatisticsRoutesToErrors() throws 
Exception {
+    catalog.createTable(tableId, OPTIONAL_NAME);
+    String file = writeWithoutStatistics("nostats.parquet", 
Collections.emptyList(), false);
+
+    PCollectionTuple out = convert(pinned("name"), file);
+
+    assertSingleError(
+        out, file, "Pinned required column name has no null count statistics 
in the file");
+    pipeline.run().waitUntilFinish();
+  }
+
+  /** The trusted file does hold a null the footer cannot report; ACCEPT 
registers it anyway. */
+  @Test
+  public void testPinnedColumnWithoutStatisticsRegistersWhenAccepted() throws 
Exception {
+    catalog.createTable(tableId, OPTIONAL_NAME);
+    String file = writeWithoutStatistics("nostats.parquet", 
Collections.emptyList(), true);
+
+    PCollectionTuple out = convert(accepting(pinned("name")), file);
+
+    assertRegisters(out, 1);
+    PipelineResult result = pipeline.run();
+    result.waitUntilFinish();
+    assertEquals(1, counted(result, AddFiles.UNPROVEN_PINS_COUNTER));
+    assertEquals(0, counted(result, AddFiles.UNCHECKED_FORMAT_COUNTER));
+    logs.verifyWarn("no null count statistics for pinned column(s) [name]");
+  }
+
+  /**
+   * ACCEPT trusts only what the footer cannot say; a pin the footer does 
count is still enforced.
+   */
+  @Test
+  public void testPinWithNullsStillCaughtWhenAnotherPinIsUnproven() throws 
Exception {
+    catalog.createTable(tableId, OPTIONAL_NAME);
+    String file = writeWithoutStatistics("mixed.parquet", 
Collections.singletonList("age"), true);
+    SchemaEvolutionConfig config =
+        SchemaEvolutionConfig.builder()
+            .setOptions(EnumSet.allOf(SchemaEvolutionOption.class))
+            .setRequiredColumns(new LinkedHashSet<>(Arrays.asList("age", 
"name")))
+            .setUnverifiableFileHandling(UnverifiableFileHandling.ACCEPT)
+            .build();
+
+    PCollectionTuple out = convert(config, file);
+
+    assertSingleError(out, file, "Pinned required column name has 1 null(s)");
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testPinnedColumnAbsentStillCaughtWhenAccepted() throws Exception 
{
+    catalog.createTable(tableId, OPTIONAL_NAME);
+    String file = writeWithoutName("noname.parquet");
+
+    PCollectionTuple out = convert(accepting(pinned("name")), file);
+
+    assertSingleError(out, file, "Pinned required column name is absent from 
the file");
+    pipeline.run().waitUntilFinish();
+  }
+
+  private static final Schema WITH_ITEMS =
+      new Schema(
+          Types.NestedField.required(1, "id", Types.IntegerType.get()),
+          Types.NestedField.optional(
+              2,
+              "items",
+              Types.ListType.ofOptional(
+                  3,
+                  Types.StructType.of(
+                      Types.NestedField.optional(4, "sku", 
Types.StringType.get())))));
+
+  private String writeWithItems(String name) throws IOException {
+    Types.StructType item =
+        
WITH_ITEMS.findField("items").type().asListType().elementType().asStructType();
+    Record row = GenericRecord.create(WITH_ITEMS);
+    row.setField("id", 1);
+    row.setField("items", 
Collections.singletonList(GenericRecord.create(item).copy("sku", "x")));
+    return writeWithSchema(name, WITH_ITEMS, row);
+  }
+
+  /**
+   * The footer's chunk paths under a list are never mapped onto the pin, so 
it cannot be proven.
+   */
+  @Test
+  public void testPinUnderListCannotBeProvenAndIsRejected() throws Exception {
+    catalog.createTable(tableId, WITH_ITEMS);
+    String file = writeWithItems("items.parquet");
+
+    PCollectionTuple out = convert(pinned("items.element.sku"), file);
+
+    assertSingleError(
+        out,
+        file,
+        "Pinned required column items.element.sku has no null count statistics 
in the file");
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testPinUnderListRegistersWhenAccepted() throws Exception {
+    catalog.createTable(tableId, WITH_ITEMS);
+    String file = writeWithItems("items.parquet");
+
+    PCollectionTuple out = convert(accepting(pinned("items.element.sku")), 
file);
+
+    assertRegisters(out, 1);
+    PipelineResult result = pipeline.run();
+    result.waitUntilFinish();
+    assertEquals(1, counted(result, AddFiles.UNPROVEN_PINS_COUNTER));
+  }
+
+  private static final Schema WITH_ADDRESS =
+      new Schema(
+          Types.NestedField.required(1, "id", Types.IntegerType.get()),
+          Types.NestedField.optional(
+              2,
+              "address",
+              Types.StructType.of(
+                  Types.NestedField.optional(3, "city", 
Types.StringType.get()),
+                  Types.NestedField.optional(4, "zip", 
Types.IntegerType.get()))));
+
+  private String writeWithAddress(String name, @Nullable String city, boolean 
nullAddress)
+      throws IOException {
+    Types.StructType addressType = 
WITH_ADDRESS.findField("address").type().asStructType();
+    Record row = GenericRecord.create(WITH_ADDRESS);
+    row.setField("id", 1);
+    if (!nullAddress) {
+      row.setField("address", GenericRecord.create(addressType).copy("city", 
city, "zip", 1));
+    }
+    return writeWithSchema(name, WITH_ADDRESS, row);
+  }
+
+  /** A struct pin is proven by any null-free leaf beneath it, exactly as 
tighten proves it. */
+  @Test
+  public void testPinnedStructProvenByOneLeafRegisters() throws Exception {
+    catalog.createTable(tableId, WITH_ADDRESS);
+    String file = writeWithAddress("address.parquet", null, false);
+
+    PCollectionTuple out = convert(pinned("address"), file);
+
+    assertRegisters(out, 1);
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testPinnedStructNullInARowRoutesToErrors() throws Exception {
+    catalog.createTable(tableId, WITH_ADDRESS);
+    String file = writeWithAddress("noaddress.parquet", "c", true);
+
+    PCollectionTuple out = convert(pinned("address"), file);
+
+    assertSingleError(
+        out, file, "Pinned required column address has no null count 
statistics in the file");
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testNonParquetFileRegistersUncheckedWhenAccepted() throws 
Exception {
+    catalog.createTable(tableId, icebergSchema);
+    String file = writeAvroFile("data.avro");
+
+    PCollectionTuple out = convert(accepting(ADDITIONS), file);
+
+    assertRegisters(out, 1);
+    PipelineResult result = pipeline.run();
+    result.waitUntilFinish();
+    assertEquals(1, counted(result, AddFiles.UNCHECKED_FORMAT_COUNTER));
+    assertEquals(0, counted(result, AddFiles.UNPROVEN_PINS_COUNTER));
+    logs.verifyWarn("unchecked (UnverifiableFileHandling.ACCEPT)");
+  }
+
+  @Test
+  public void testNonParquetFileRegistersWhenEvolutionDisabled() throws 
Exception {
+    catalog.createTable(tableId, icebergSchema);
+    String file = writeAvroFile("data.avro");
+
+    PCollectionTuple out = convert(SchemaEvolutionConfig.disabled(), file);
+
+    assertRegisters(out, 1);
+    PipelineResult result = pipeline.run();
+    result.waitUntilFinish();
+    assertEquals(0, counted(result, AddFiles.UNCHECKED_FORMAT_COUNTER));
+  }
 }
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/FileSchemasTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/FileSchemasTest.java
index 6a6bef483b7..d72ccbf5974 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/FileSchemasTest.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/FileSchemasTest.java
@@ -21,6 +21,7 @@ import static 
org.apache.iceberg.types.Types.NestedField.optional;
 import static org.apache.iceberg.types.Types.NestedField.required;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
 import java.io.File;
@@ -50,6 +51,7 @@ import org.apache.parquet.schema.LogicalTypeAnnotation;
 import org.apache.parquet.schema.MessageType;
 import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName;
 import org.apache.parquet.schema.Type.Repetition;
+import org.checkerframework.checker.nullness.qual.Nullable;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
@@ -346,6 +348,99 @@ public class FileSchemasTest {
     assertTrue(isRequired(schema, "address.zip"));
   }
 
+  // ---- null counts (pin evidence), by the tighten rules
+
+  private static @Nullable Long nullCount(ParquetMetadata footer, String 
column) {
+    return FileSchemas.nullCount(
+        footer, 
ParquetSchemaUtil.convert(footer.getFileMetaData().getSchema()), column);
+  }
+
+  @Test
+  public void testNullCountOfLeafSumsRowGroups() throws IOException {
+    ParquetMetadata footer =
+        write(10, 2, true, new Nulls(r -> r == 1 || r == 7, r -> false, r -> 
false, r -> false));
+    assertEquals(Long.valueOf(2), nullCount(footer, "name"));
+    assertEquals(Long.valueOf(0), nullCount(footer, "id"));
+    assertEquals(Long.valueOf(0), nullCount(footer, "address.city"));
+  }
+
+  @Test
+  public void testNullCountUnknownWithoutStatisticsOrUnderAList() throws 
IOException {
+    assertNull(nullCount(write(10, 1, false, Nulls.NONE), "name"));
+    assertNull(nullCount(write(10, 1, true, Nulls.NONE), "tags.element"));
+    assertNull(nullCount(write(10, 1, true, Nulls.NONE), "missing"));
+  }
+
+  // root: required id, optional group home {required city}, required group 
office {required city}
+  private static final MessageType REQUIRED_LEAVES =
+      org.apache.parquet.schema.Types.buildMessage()
+          .required(PrimitiveTypeName.INT64)
+          .named("id")
+          .addField(
+              org.apache.parquet.schema.Types.buildGroup(Repetition.OPTIONAL)
+                  .required(PrimitiveTypeName.BINARY)
+                  .as(LogicalTypeAnnotation.stringType())
+                  .named("city")
+                  .named("home"))
+          .addField(
+              org.apache.parquet.schema.Types.buildGroup(Repetition.REQUIRED)
+                  .required(PrimitiveTypeName.BINARY)
+                  .as(LogicalTypeAnnotation.stringType())
+                  .named("city")
+                  .named("office"))
+          .named("root");
+
+  /** A declared-required path cannot encode a null, so it needs no statistics 
to be proven. */
+  @Test
+  public void testNullCountTrustsDeclaredRequiredPathsWithoutStatistics() 
throws IOException {
+    File file = new File(tmp.getRoot(), "required.parquet");
+    try (ParquetWriter<Group> writer =
+        ExampleParquetWriter.builder(new Path(file.getAbsolutePath()))
+            .withType(REQUIRED_LEAVES)
+            .withStatisticsEnabled(false)
+            .build()) {
+      Group group = new SimpleGroupFactory(REQUIRED_LEAVES).newGroup();
+      group.add("id", 1L);
+      group.addGroup("office").add("city", "c");
+      writer.write(group);
+    }
+    ParquetMetadata footer = ParquetFooters.read(file.getAbsolutePath());
+
+    assertEquals(Long.valueOf(0), nullCount(footer, "id"));
+    assertEquals(Long.valueOf(0), nullCount(footer, "office"));
+    assertEquals(Long.valueOf(0), nullCount(footer, "office.city"));
+    // required only relative to an optional parent: a null home nulls city, 
and no count says
+    assertNull(nullCount(footer, "home"));
+    assertNull(nullCount(footer, "home.city"));
+  }
+
+  /** A struct is proven exactly when tighten would mark it required. */
+  @Test
+  public void testNullCountOfStructFollowsTighten() throws IOException {
+    ParquetMetadata oneLeafProven =
+        write(10, 1, true, new Nulls(r -> false, r -> false, r -> r == 2, r -> 
false));
+    assertEquals(Long.valueOf(0), nullCount(oneLeafProven, "address"));
+    assertEquals(Long.valueOf(1), nullCount(oneLeafProven, "address.city"));
+
+    ParquetMetadata nullStruct =
+        write(10, 1, true, new Nulls(r -> false, r -> r == 5, r -> false, r -> 
false));
+    assertNull(nullCount(nullStruct, "address"));
+    assertEquals(Long.valueOf(1), nullCount(nullStruct, "address.city"));
+
+    // every leaf has a null of its own, in different rows: the struct itself 
was never null,
+    // but leaf counts cannot say so
+    ParquetMetadata leavesNulled =
+        write(10, 1, true, new Nulls(r -> false, r -> false, r -> r == 2, r -> 
r == 4));
+    assertNull(nullCount(leavesNulled, "address"));
+  }
+
+  @Test
+  public void testNullCountZeroRowsProvesEverything() throws IOException {
+    ParquetMetadata footer = write(0, 1, false, Nulls.NONE);
+    assertEquals(Long.valueOf(0), nullCount(footer, "name"));
+    assertEquals(Long.valueOf(0), nullCount(footer, "address"));
+  }
+
   @Test
   public void testTightenPreservesIdsNamesAndTypes() throws IOException {
     ParquetMetadata footer = write(10, 1, true, Nulls.NONE);
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfigTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfigTest.java
index edc8b338bfc..4ccf844538f 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfigTest.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfigTest.java
@@ -26,6 +26,7 @@ import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashSet;
 import 
org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.IncompatibleSchemaHandling;
+import 
org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.UnverifiableFileHandling;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -66,13 +67,34 @@ public class SchemaEvolutionConfigTest {
     SchemaEvolutionConfig.Builder handlingOnly =
         SchemaEvolutionConfig.builder()
             
.setIncompatibleSchemaHandling(IncompatibleSchemaHandling.ROUTE_TO_ERRORS);
+    SchemaEvolutionConfig.Builder acceptOnly =
+        SchemaEvolutionConfig.builder()
+            .setUnverifiableFileHandling(UnverifiableFileHandling.ACCEPT);
 
     IllegalArgumentException e = assertThrows(IllegalArgumentException.class, 
pinsOnly::build);
     assertThrows(IllegalArgumentException.class, handlingOnly::build);
+    assertThrows(IllegalArgumentException.class, acceptOnly::build);
 
     assertTrue(e.getMessage(), e.getMessage().contains("at least one schema 
evolution option"));
   }
 
+  @Test
+  public void testUnverifiableFileHandlingDefaultsToReject() {
+    SchemaEvolutionConfig unset =
+        SchemaEvolutionConfig.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION);
+    SchemaEvolutionConfig accepting =
+        SchemaEvolutionConfig.builder()
+            .setOptions(EnumSet.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION))
+            .setUnverifiableFileHandling(UnverifiableFileHandling.ACCEPT)
+            .build();
+
+    assertEquals(UnverifiableFileHandling.REJECT, 
unset.getUnverifiableFileHandling());
+    assertEquals(
+        UnverifiableFileHandling.REJECT,
+        SchemaEvolutionConfig.disabled().getUnverifiableFileHandling());
+    assertEquals(UnverifiableFileHandling.ACCEPT, 
accepting.getUnverifiableFileHandling());
+  }
+
   @Test
   public void testIncompatibleSchemaHandlingDefaultsByMode() {
     SchemaEvolutionConfig unset =

Reply via email to