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

reuvenlax 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 c45c018cc75 Merge pull request #39999 from 
bvolpato/bvolpato/java-bigquery-failed-row-retries
c45c018cc75 is described below

commit c45c018cc754839c1d9cafb4e29f020277e3b818
Author: Bruno Volpato <[email protected]>
AuthorDate: Tue Oct 27 15:02:05 2026 -0400

    Merge pull request #39999 from 
bvolpato/bvolpato/java-bigquery-failed-row-retries
    
    [Java] Preserve BigQuery failed rows across retries
---
 .../beam_PreCommit_Java_GCP_IO_Direct.json         |   4 +
 CHANGES.md                                         |   1 +
 .../bigquery/StorageApiWriteUnshardedRecords.java  |   3 +
 .../bigquery/StorageApiWritesShardedRecords.java   |   3 +
 .../io/gcp/bigquery/StorageApiWriteRetryTest.java  | 236 +++++++++++++++++++++
 5 files changed, 247 insertions(+)

diff --git a/.github/trigger_files/beam_PreCommit_Java_GCP_IO_Direct.json 
b/.github/trigger_files/beam_PreCommit_Java_GCP_IO_Direct.json
new file mode 100644
index 00000000000..3a009261f4f
--- /dev/null
+++ b/.github/trigger_files/beam_PreCommit_Java_GCP_IO_Direct.json
@@ -0,0 +1,4 @@
+{
+  "comment": "Modify this file in a trivial way to cause this test suite to 
run.",
+  "modification": 2
+}
diff --git a/CHANGES.md b/CHANGES.md
index aa9f5aadb04..d616a633531 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -125,6 +125,7 @@
 * (Go) Fixed pubsubio importing a `google.golang.org/genproto` package removed 
in recent releases, which broke builds of Go modules depending on a current 
`genproto` version ([#40018](https://github.com/apache/beam/issues/40018)).
 * (Python) `VertexAIFeatureStoreEnrichmentHandler` now logs a warning instead 
of failing when the caller lacks permission to read the feature store metadata 
([#40442](https://github.com/apache/beam/issues/40442)).
 * (Java) BigQueryIO now treats a 404 when deleting a temporary table or 
dataset as success, so a replayed work item whose earlier attempt already 
deleted it no longer retries forever 
([#24997](https://github.com/apache/beam/issues/24997)).
+* (Java) Fixed BigQuery Storage Write API failure outputs returning the wrong 
original row after consecutive serialization errors 
([#39999](https://github.com/apache/beam/pull/39999)).
 * (Java) IcebergIO now writes rows containing `EnumerationType` (proto enum) 
fields as strings, instead of throwing `Unsupported Beam logical type Enum` 
([#40299](https://github.com/apache/beam/issues/40299)).
 * (Python) Fixed stateful DoFns with side inputs sometimes taking the timer 
key coder from a side input instead of the main input, which could make the 
worker fail to decode timer keys with `Unknown type tag` 
([#40374](https://github.com/apache/beam/issues/40374)).
 * (Python) Fixed `AsyncWrapper` finalizing bundles before generator 
processing, which could drop buffered writes, and nesting iterable outputs 
([#40000](https://github.com/apache/beam/pull/40000)).
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWriteUnshardedRecords.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWriteUnshardedRecords.java
index 8ea8d75ed17..da51604c527 100644
--- 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWriteUnshardedRecords.java
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWriteUnshardedRecords.java
@@ -885,15 +885,18 @@ public class 
StorageApiWriteUnshardedRecords<DestinationT, ElementT>
                 // rows.
                 ProtoRows.Builder retryRows = ProtoRows.newBuilder();
                 List<org.joda.time.Instant> retryTimestamps = 
Lists.newArrayList();
+                List<@Nullable TableRow> retryFailsafeTableRows = 
Lists.newArrayList();
                 for (int i = 0; i < 
failedContext.protoRows.getSerializedRowsCount(); ++i) {
                   if (!failedRowIndices.contains(i)) {
                     ByteString rowBytes = 
failedContext.protoRows.getSerializedRows(i);
                     retryRows.addSerializedRows(rowBytes);
                     retryTimestamps.add(failedContext.timestamps.get(i));
+                    
retryFailsafeTableRows.add(failedContext.failsafeTableRows.get(i));
                   }
                 }
                 failedContext.protoRows = retryRows.build();
                 failedContext.timestamps = retryTimestamps;
+                failedContext.failsafeTableRows = retryFailsafeTableRows;
                 int numRowsRetried = 
failedContext.protoRows.getSerializedRowsCount();
                 BigQuerySinkMetrics.appendRowsRowStatusCounter(
                         BigQuerySinkMetrics.RowStatus.RETRIED, errorCode, 
shortTableUrn)
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWritesShardedRecords.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWritesShardedRecords.java
index 336311e3421..9361ae5d569 100644
--- 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWritesShardedRecords.java
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWritesShardedRecords.java
@@ -668,15 +668,18 @@ public class StorageApiWritesShardedRecords<DestinationT 
extends @NonNull Object
         // rows.
         ProtoRows.Builder retryRows = ProtoRows.newBuilder();
         @Nullable List<org.joda.time.Instant> timestamps = 
Lists.newArrayList();
+        List<@Nullable TableRow> failsafeTableRows = Lists.newArrayList();
         for (int i = 0; i < failedContext.protoRows.getSerializedRowsCount(); 
++i) {
           if (!failedRowIndices.contains(i)) {
             ByteString rowBytes = failedContext.protoRows.getSerializedRows(i);
             retryRows.addSerializedRows(rowBytes);
             timestamps.add(failedContext.timestamps.get(i));
+            failsafeTableRows.add(failedContext.failsafeTableRows.get(i));
           }
         }
         failedContext.protoRows = retryRows.build();
         failedContext.timestamps = timestamps;
+        failedContext.failsafeTableRows = failsafeTableRows;
         int retriedRows = failedContext.protoRows.getSerializedRowsCount();
         BigQuerySinkMetrics.appendRowsRowStatusCounter(
                 BigQuerySinkMetrics.RowStatus.RETRIED, errorCode, shortTableId)
diff --git 
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWriteRetryTest.java
 
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWriteRetryTest.java
new file mode 100644
index 00000000000..c37cf2eb274
--- /dev/null
+++ 
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWriteRetryTest.java
@@ -0,0 +1,236 @@
+/*
+ * 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.beam.sdk.io.gcp.bigquery;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.hasItems;
+
+import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutures;
+import com.google.api.services.bigquery.model.Table;
+import com.google.api.services.bigquery.model.TableFieldSchema;
+import com.google.api.services.bigquery.model.TableReference;
+import com.google.api.services.bigquery.model.TableRow;
+import com.google.api.services.bigquery.model.TableSchema;
+import com.google.cloud.bigquery.storage.v1.AppendRowsRequest;
+import com.google.cloud.bigquery.storage.v1.AppendRowsResponse;
+import com.google.cloud.bigquery.storage.v1.Exceptions;
+import com.google.cloud.bigquery.storage.v1.ProtoRows;
+import com.google.protobuf.DescriptorProtos;
+import com.google.protobuf.Descriptors.Descriptor;
+import com.google.protobuf.DynamicMessage;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.apache.beam.runners.direct.DirectOptions;
+import org.apache.beam.sdk.io.gcp.bigquery.BigQueryIO.Write.CreateDisposition;
+import org.apache.beam.sdk.io.gcp.bigquery.BigQueryIO.Write.Method;
+import org.apache.beam.sdk.io.gcp.testing.FakeBigQueryServices;
+import org.apache.beam.sdk.io.gcp.testing.FakeDatasetService;
+import org.apache.beam.sdk.io.gcp.testing.FakeJobService;
+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.Create;
+import org.apache.beam.sdk.transforms.FlatMapElements;
+import org.apache.beam.sdk.transforms.MapElements;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.TypeDescriptor;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.joda.time.Duration;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+/** Regression tests for row bookkeeping across Storage Write API retries. */
+@RunWith(Parameterized.class)
+public class StorageApiWriteRetryTest {
+  @Parameters(name = "method={0}, streaming={1}")
+  public static Iterable<Object[]> parameters() {
+    return ImmutableList.of(
+        new Object[] {Method.STORAGE_WRITE_API, false},
+        new Object[] {Method.STORAGE_WRITE_API, true},
+        new Object[] {Method.STORAGE_API_AT_LEAST_ONCE, false});
+  }
+
+  @Parameter public Method method;
+
+  @Parameter(1)
+  public boolean streaming;
+
+  @Rule public final transient TestPipeline pipeline = TestPipeline.create();
+
+  private static final List<Integer> APPEND_SIZES = 
Collections.synchronizedList(new ArrayList<>());
+
+  @Before
+  public void setUp() throws Exception {
+    FakeDatasetService.setUp();
+    BigQueryIO.clearStaticCaches();
+    APPEND_SIZES.clear();
+    pipeline.getOptions().as(BigQueryOptions.class).setProject("project-id");
+    pipeline.getOptions().as(DirectOptions.class).setTargetParallelism(1);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    BigQueryIO.clearStaticCaches();
+  }
+
+  @Test
+  public void testConsecutiveSerializationErrorsPreserveOriginalRows() throws 
Exception {
+    TableReference table =
+        new TableReference()
+            .setProjectId("project-id")
+            .setDatasetId("dataset-id")
+            .setTableId("table-id");
+    TableSchema schema =
+        new TableSchema()
+            .setFields(ImmutableList.of(new 
TableFieldSchema().setName("id").setType("STRING")));
+    FakeDatasetService dataset = new RejectOneRowPerAppend();
+    dataset.createDataset("project-id", "dataset-id", "", "", null);
+    dataset.createTable(new 
Table().setTableReference(table).setSchema(schema));
+
+    PCollection<TableRow> rows;
+    if (streaming) {
+      rows =
+          pipeline.apply(
+              TestStream.create(TableRowJsonCoder.of())
+                  .addElements(row("A"), row("B"), row("C"))
+                  .advanceProcessingTime(Duration.standardSeconds(2))
+                  .advanceWatermarkToInfinity());
+    } else {
+      // Emit all rows from one element so the unsharded writer receives one 
bundle.
+      rows =
+          pipeline
+              .apply(Create.of(0))
+              .apply(
+                  FlatMapElements.into(TypeDescriptor.of(TableRow.class))
+                      .via(ignored -> ImmutableList.of(row("A"), row("B"), 
row("C"))))
+              .setCoder(TableRowJsonCoder.of());
+    }
+
+    BigQueryIO.Write<TableRow> write =
+        BigQueryIO.writeTableRows()
+            .to(table)
+            .withMethod(method)
+            .withCreateDisposition(CreateDisposition.CREATE_NEVER)
+            .withFormatRecordOnFailureFunction(
+                input -> new TableRow().set("original_id", input.get("id")))
+            .withPropagateSuccessfulStorageApiWrites(true)
+            .withTestServices(
+                new FakeBigQueryServices()
+                    .withDatasetService(dataset)
+                    .withJobService(new FakeJobService()))
+            .withoutValidation();
+    if (streaming) {
+      write =
+          write
+              .withNumStorageWriteApiStreams(1)
+              .withTriggeringFrequency(Duration.standardSeconds(1));
+    } else {
+      write = write.withNumStorageWriteApiStreams(0);
+    }
+    WriteResult result = rows.apply(write);
+    PAssert.that(
+            result
+                .getFailedStorageApiInserts()
+                .apply(
+                    MapElements.into(TypeDescriptor.of(TableRow.class))
+                        .via(BigQueryStorageApiInsertError::getRow)))
+        .containsInAnyOrder(
+            new TableRow().set("original_id", "A"), new 
TableRow().set("original_id", "B"));
+    
PAssert.that(result.getSuccessfulStorageApiInserts()).containsInAnyOrder(row("C"));
+
+    pipeline.run().waitUntilFinish();
+
+    assertThat(
+        dataset.getAllRows("project-id", "dataset-id", "table-id"), 
containsInAnyOrder(row("C")));
+    // Ensure this exercised successive retries of the same multirow batch, 
not singleton writes.
+    assertThat(APPEND_SIZES, hasItems(3, 2, 1));
+  }
+
+  private static TableRow row(String id) {
+    return new TableRow().set("id", id);
+  }
+
+  private static class RejectOneRowPerAppend extends FakeDatasetService {
+    @Override
+    public BigQueryServices.StreamAppendClient getStreamAppendClient(
+        String streamName,
+        DescriptorProtos.DescriptorProto descriptor,
+        boolean useConnectionPool,
+        AppendRowsRequest.MissingValueInterpretation 
missingValueInterpretation)
+        throws Exception {
+      BigQueryServices.StreamAppendClient delegate =
+          super.getStreamAppendClient(
+              streamName, descriptor, useConnectionPool, 
missingValueInterpretation);
+      Descriptor protoDescriptor = 
TableRowToStorageApiProto.wrapDescriptorProto(descriptor);
+      return new BigQueryServices.StreamAppendClient() {
+        @Override
+        public ApiFuture<AppendRowsResponse> appendRows(long offset, ProtoRows 
rows)
+            throws Exception {
+          APPEND_SIZES.add(rows.getSerializedRowsCount());
+          try {
+            for (int i = 0; i < rows.getSerializedRowsCount(); i++) {
+              String id =
+                  (String)
+                      DynamicMessage.parseFrom(protoDescriptor, 
rows.getSerializedRows(i))
+                          .getField(protoDescriptor.findFieldByName("id"));
+              if (!id.equals("C")) {
+                return ApiFutures.immediateFailedFuture(
+                    new Exceptions.AppendSerializationError(
+                        400, "Invalid row", streamName, ImmutableMap.of(i, 
"Invalid id: " + id)));
+              }
+            }
+          } catch (Exception e) {
+            return ApiFutures.immediateFailedFuture(e);
+          }
+          return delegate.appendRows(offset, rows);
+        }
+
+        @Override
+        public com.google.cloud.bigquery.storage.v1.@Nullable TableSchema 
getUpdatedSchema() {
+          return delegate.getUpdatedSchema();
+        }
+
+        @Override
+        public void pin() {
+          delegate.pin();
+        }
+
+        @Override
+        public void unpin() {
+          delegate.unpin();
+        }
+
+        @Override
+        public void close() throws Exception {
+          delegate.close();
+        }
+      };
+    }
+  }
+}

Reply via email to