ahmedabu98 commented on code in PR #40143:
URL: https://github.com/apache/beam/pull/40143#discussion_r4030506947


##########
sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java:
##########
@@ -949,4 +963,609 @@ private DataWriter<Record> createWriter(String file, 
@Nullable StructLike partit
   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
+
+  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 testPinnedColumnWithNullsRoutesToErrors() throws Exception {
+    catalog.createTable(tableId, OPTIONAL_NAME);

Review Comment:
   These Pin tests should use required columns right? same with the tests below 
that use `WITH_ITEMS` and `WITH_ADDRESS`, they currently all pin optional 
columns



##########
sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java:
##########
@@ -464,19 +560,139 @@ private Callable<ProcessResult> createProcessTask(
                   .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);
         }
       };
     }
 
+    /**
+     * 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;

Review Comment:
   We should also trust when the Parquet file tells us the column is required 
right? AFAIK a required column doesn't have to provide null counts



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

Reply via email to