mxm commented on code in PR #18739:
URL: https://github.com/apache/iceberg/pull/18739#discussion_r4491389240


##########
flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java:
##########
@@ -98,6 +137,10 @@ public DynamicRecordInternal map(DynamicRecordInternal 
data) throws Exception {
     data.setSchema(tableSchema);
     data.setSpec(newData.f1);
 
+    // Validate before writing: a bad upsert config or a record that cannot be 
converted to the
+    // resolved schema throws here and is diverted/failed at this stage, never 
reaching the writer.
+    DynamicSinkUtil.validateUpsert(data.upsertMode(), newData.f1, 
data.equalityFields());

Review Comment:
   Is this a regression for upsert records without explicit equality fields? 
`DynamicWriter` falls back to the table's identifier fields when the record has 
none, but `data.equalityFields()` is only resolved against the record schema.
   
   Could we apply the same fallback to `tableSchema.identifierFieldIds()` 
before validating?



##########
flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DataConverter.java:
##########
@@ -134,6 +139,15 @@ static DataConverter nullable(DataConverter converter) {
     return value -> value == null ? null : converter.convert(value);
   }
 
+  static DataConverter nonNullable(DataConverter converter, String fieldName) {
+    return value -> {
+      if (value == null) {
+        throw new IllegalArgumentException("Null value for non-nullable field: 
" + fieldName);

Review Comment:
   Does this break upsert deletes? In upsert mode a `RowKind.DELETE` row only 
needs the equality fields, the delta writer reads just the key for it.
   
   Should we skip this check for DELETE rows?



##########
flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java:
##########
@@ -130,7 +149,31 @@ public void processElement(T element, Context ctx, 
Collector<DynamicRecordIntern
   @Override
   public void collect(DynamicRecord inputData) {
     DynamicRecordWithConfig data = dynamicRecordWithConfig.wrap(inputData);
+    try {
+      processRecord(data);
+    } catch (RuntimeException e) {
+      // Divert only failures the filter whitelists; otherwise propagate so 
Flink can retry.
+      if (errorFilter != null && errorFilter.shouldDivert(e)) {
+        context.output(
+            errorStream,
+            Tuple2.of(
+                new DynamicRecordInternal(
+                    data.tableIdentifier().toString(),
+                    data.branch(),
+                    data.schema(),
+                    data.rowData(),
+                    data.spec(),
+                    -1,
+                    data.upsertMode(),
+                    DynamicSinkUtil.getEqualityFieldIds(data.equalityFields(), 
data.schema())),

Review Comment:
   `getEqualityFieldIds` throws when an equality field is not in the schema, so 
building the error record can fail again.
   
   Could we build the error record without resolving the field IDs again?



##########
flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java:
##########
@@ -80,10 +94,35 @@ public void open(OpenContext openContext) throws Exception {
             catalog,
             caseSensitive,
             dropUnusedColumns);
+
+    if (errorFilter != null) {
+      errorStream =
+          new OutputTag<>(
+              DynamicRecordProcessor.ERROR_STREAM,
+              new TupleTypeInfo<>(
+                  new DynamicRecordInternalType(
+                      catalogLoader, true, cacheMaximumSize, 
inputSchemasPerTableCacheMaximumSize),
+                  TypeInformation.of(Exception.class)));
+    }
   }
 
   @Override
-  public DynamicRecordInternal map(DynamicRecordInternal data) throws 
Exception {
+  public void processElement(
+      DynamicRecordInternal record, Context ctx, 
Collector<DynamicRecordInternal> out)
+      throws Exception {
+    try {
+      out.collect(processRecord(record));
+    } catch (RuntimeException e) {
+      // Divert only failures the filter whitelists; otherwise propagate so 
Flink can retry.
+      if (errorFilter != null && errorFilter.shouldDivert(e)) {
+        ctx.output(errorStream, Tuple2.of(record, e));

Review Comment:
   `processRecord` mutates `record` before `validateUpsert` and the conversion 
run, so the diverted record has the table's schema and spec but still the 
unconverted row.
   
   Could we divert the unmodified record?



##########
flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java:
##########
@@ -2143,6 +2145,80 @@ void 
testWriteOldShapeRecordAfterColumnReAddedWithWiderType() throws Exception {
     assertThat(records.get(0).getField("c")).isEqualTo(5L);
   }
 
+  @Test
+  void testSideOutputForErroneousRecordsImmediateUpdatePath() {
+    runTestSideOutputForErroneousRecords(true);
+  }
+
+  @Test
+  void testSideOutputForErroneousRecordsNonImmediateUpdatePath() {
+    runTestSideOutputForErroneousRecords(false);
+  }
+
+  private void runTestSideOutputForErroneousRecords(boolean immediateUpdate) {
+    // Converting int -> boolean will fail
+    Schema incompatibleSchemaChange =
+        new Schema(
+            Types.NestedField.optional(1, "id", Types.BooleanType.get()),
+            Types.NestedField.optional(2, "data", Types.StringType.get()));
+
+    List<DynamicIcebergDataImpl> rows =
+        Lists.newArrayList(
+            new DynamicIcebergDataImpl(
+                SimpleDataUtil.SCHEMA,
+                "t1",
+                SnapshotRef.MAIN_BRANCH,
+                PartitionSpec.unpartitioned()),
+            new DynamicIcebergDataImpl(
+                // Converting int -> boolean will fail
+                incompatibleSchemaChange,
+                "t1",
+                SnapshotRef.MAIN_BRANCH,
+                PartitionSpec.unpartitioned(),
+                true,
+                Collections.singleton("id"),
+                false));
+
+    DataStream<DynamicIcebergDataImpl> dataStream =
+        env.fromData(rows, TypeInformation.of(new TypeHint<>() {}));
+    env.setParallelism(1);
+
+    AtomicInteger errorCount = new AtomicInteger();
+    AtomicReference<Tuple2<DynamicRecord, Exception>> rejectedRecord = new 
AtomicReference<>();
+
+    DynamicIcebergSink.forInput(dataStream)
+        .generator(new Generator())
+        .catalogLoader(CATALOG_EXTENSION.catalogLoader())
+        .errorStreamConsumer(
+            errorStream -> {
+              // This code runs at the end of the append() call to execute the 
Flink job and
+              // retrieve the error stream.
+              // The try/catch is only required due to the checked exception 
of executeAndCollect().
+              try {
+                errorStream
+                    .executeAndCollect()
+                    .forEachRemaining(
+                        record -> {
+                          errorCount.incrementAndGet();
+                          rejectedRecord.set(record);
+                        });
+              } catch (Exception e) {
+                throw new RuntimeException(e);
+              }
+            })
+        .errorStreamFilter(error -> true)
+        .immediateTableUpdate(immediateUpdate)
+        .append();
+
+    assertThat(errorCount.get()).isEqualTo(1);

Review Comment:
   Could we also assert that the first (valid) record ends up in `t1`? 
Otherwise the test doesn't show that the job keeps running after a diverted 
record.



##########
flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordProcessor.java:
##########
@@ -172,7 +172,8 @@ private static DynamicRecordProcessor<DynamicRecord> 
processorWithoutImmediateUp
                 String.valueOf(CACHE_REFRESH_MS)),
             new Configuration()),
         Collections.emptyMap(),
-        new Configuration());
+        new Configuration(),
+        null);

Review Comment:
   Could we add processor tests here, using the harness like the other tests in 
this class? Only the end-to-end test covers the new catch block today. I'd like 
to see a diverted failure and a propagated failure. And one with forward write 
path.



##########
flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java:
##########
@@ -298,6 +309,37 @@ public Builder<T> tableCreator(TableCreator 
tableCreationFunction) {
       return this;
     }
 
+    /**
+     * Set a consumer for the error stream of records that fail during 
conversion/table update. When
+     * set, a record that fails in the generator, schema evolution, or table 
update is routed to

Review Comment:
   NIT: Could we adjust "fails in the generator"? An exception thrown by 
`DynamicRecordGenerator.generate` itself is not diverted.



##########
flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java:
##########
@@ -130,7 +149,31 @@ public void processElement(T element, Context ctx, 
Collector<DynamicRecordIntern
   @Override
   public void collect(DynamicRecord inputData) {
     DynamicRecordWithConfig data = dynamicRecordWithConfig.wrap(inputData);
+    try {
+      processRecord(data);

Review Comment:
   The try also covers the emit. This will create issue with the chained 
operators for the forward write path. Ideally, we want to exclude the emission.



##########
flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DataConverter.java:
##########
@@ -161,8 +175,14 @@ class RowDataConverter implements DataConverter {
           RowType.RowField sourceField = 
sourceType.getFields().get(sourceFieldIndex);
           fieldGetter = RowData.createFieldGetter(sourceField.getType(), 
sourceFieldIndex);
           dataConverter =
-              DataConverter.getNullable(
-                  sourceField.getType(), targetField.getType(), caseSensitive);
+              targetField.getType().isNullable()
+                  ? DataConverter.getNullable(
+                      sourceField.getType(), targetField.getType(), 
caseSensitive)
+                  : DataConverter.getNonNullable(
+                      sourceField.getType(),
+                      targetField.getType(),
+                      caseSensitive,
+                      targetField.getName());

Review Comment:
   If the record schema matches the table schema, `TableMetadataCache` returns 
`DataConverter.identity()` and this check never runs. E.g. table `(id required, 
data required)` with a record `(1, null)` of the same schema is neither 
rejected nor diverted. Should we cover that case too, or narrow the "never 
reaching the writer" comments?



##########
flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordExceptionFilter.java:
##########
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.flink.sink.dynamic;
+
+import java.io.Serializable;
+import org.apache.flink.annotation.Experimental;
+
+/**
+ * Classifies a failure raised while processing a {@link DynamicRecord} as 
divertable or not. Keeps
+ * error handling fail-safe: only failures this filter whitelists are diverted 
to the error stream
+ * set via {@link DynamicIcebergSink.Builder#errorStreamConsumer}; every other 
failure propagates
+ * and fails the job so Flink's restart strategy can retry it.
+ *
+ * <p>Whitelist only deterministic, record-scoped failures (bad data, for 
example a value that
+ * cannot be encoded for its table, or an upsert record whose partition source 
is not an equality
+ * field). Return {@code false} for transient or systemic failures (an 
unreachable catalog,
+ * object-store errors, an interrupt) so a sustained outage does not silently 
drain the whole
+ * stream.
+ */
+@Experimental
+@FunctionalInterface
+public interface DynamicRecordExceptionFilter extends Serializable {
+
+  /**
+   * Classifies a failure as divertable or not.
+   *
+   * @param error the cause thrown while processing a record
+   * @return {@code true} to divert the record, {@code false} to propagate the 
failure and fail the
+   *     job
+   */
+  boolean shouldDivert(Exception error);

Review Comment:
   How would a user write this filter? Should we add a dedicated exception type 
for record-scoped failures?



##########
flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicSinkUtil.java:
##########
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.flink.sink.dynamic;
+
+import static org.apache.iceberg.types.Types.NestedField.optional;
+import static org.apache.iceberg.types.Types.NestedField.required;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.Collections;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.relocated.com.google.common.collect.Sets;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Test;
+
+class TestDynamicSinkUtil {
+
+  private static final Schema SCHEMA =
+      new Schema(
+          required(1, "id", Types.IntegerType.get()), optional(2, "data", 
Types.StringType.get()));
+
+  @Test
+  void testValidateUpsertSkippedWhenNotUpsert() {
+    assertThatCode(
+            () ->
+                DynamicSinkUtil.validateUpsert(
+                    false, PartitionSpec.unpartitioned(), 
Collections.emptySet()))
+        .doesNotThrowAnyException();
+  }
+
+  @Test
+  void testValidateUpsertFailsWithoutEqualityFields() {
+    assertThatThrownBy(
+            () ->
+                DynamicSinkUtil.validateUpsert(
+                    true, PartitionSpec.unpartitioned(), 
Collections.emptySet()))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage(
+            "Equality field columns shouldn't be empty when configuring to use 
UPSERT data.");

Review Comment:
   The old test covered more of the execution path, see 
https://github.com/apache/iceberg/pull/18739/changes#diff-749232de0cb8516114173b69933365184b8ae963be6527e09096028febfb8655L283



##########
flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java:
##########
@@ -589,6 +667,32 @@ private Configuration fromReadableConfig() {
           ? (Configuration) readableConfig
           : Configuration.fromMap(readableConfig.toMap());
     }
+
+    /**
+     * Rebuilds the public {@link DynamicRecord} handed to the error stream 
from the internal
+     * record, preserving {@code upsertMode} and {@code equalityFields} 
(mapping the internal
+     * field-ids back to column names via the record schema). Routing hints 
(distribution mode,
+     * write parallelism) are not meaningful for an errored record and are 
left at their defaults.
+     */
+    private static DynamicRecord toDynamicRecord(DynamicRecordInternal 
internal) {
+      Schema schema = internal.schema();
+      DynamicRecord record =
+          new DynamicRecord(
+              TableIdentifier.parse(internal.tableName()),
+              internal.branch(),
+              schema,
+              internal.rowData(),
+              internal.spec(),
+              null,
+              -1);

Review Comment:
   NIT: Could we use the five-argument `DynamicRecord` constructor? It sets 
`null` and `-1` already.



##########
flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java:
##########
@@ -271,6 +285,126 @@ void testDynamicTableUpdateOperatorDropUnusedColumns() 
throws Exception {
     assertThat(input).isEqualTo(output);
   }
 
+  @Test
+  void testDynamicTableUpdateOperatorErrorStream() throws Exception {
+    Catalog catalog = CATALOG_EXTENSION.catalog();
+    TableIdentifier table = TableIdentifier.of(TABLE);
+
+    AtomicInteger errorCount = new AtomicInteger();
+    AtomicReference<Tuple2<DynamicRecordInternal, Exception>> errorRecord = 
new AtomicReference<>();
+
+    DynamicTableUpdateOperator operator =
+        new DynamicTableUpdateOperator(
+            CATALOG_EXTENSION.catalogLoader(),
+            TableCreator.DEFAULT,
+            flinkDynamicSinkConfiguration(CASE_SENSITIVE, PRESERVE_COLUMNS),
+            error -> true);
+    operator.open(null);
+
+    catalog.createTable(table, SCHEMA1);
+
+    // Incompatible evolution (int -> boolean) fails the update; the filter 
whitelists it, so the
+    // record is routed to the side output instead of failing the job.
+    Schema incompatibleSchema =
+        new Schema(Types.NestedField.required(1, "id", 
Types.BooleanType.get()));
+    DynamicRecordInternal input =
+        new DynamicRecordInternal(
+            TABLE,
+            "branch",
+            incompatibleSchema,
+            GenericRowData.of(true),
+            PartitionSpec.unpartitioned(),
+            42,
+            false,
+            Collections.emptySet());
+
+    TestCollector collector = new TestCollector();
+    ProcessFunction<DynamicRecordInternal, DynamicRecordInternal>.Context 
mockContext =
+        Mockito.mock(ProcessFunction.Context.class);

Review Comment:
   Could we use `ProcessFunctionTestHarnesses.forProcessFunction` and 
`harness.getSideOutput(...)`, as in `TestDynamicRecordProcessor`? I'd prefer 
not to mock the `Context`, and this is the only test class in the package that 
uses Mockito. A harness test could also cover a failure after the update, which 
the tests here don't.



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