Skip to content

Commit f8e4de0

Browse files
authored
Improve BigQueryIO Storage Write API error messages (#38948)
* Improve BigQueryIO Storage Write API error messages * Surface the root cause in the RuntimeException when AppendRows retries are exhausted. * Elevate the final failure logging to ERROR level. * Provide actionable advice for PERMISSION_DENIED and NOT_FOUND errors, suggesting to check if the destination table exists and if the service account has the TABLES_UPDATE_DATA permission. * Add a unit test to verify the improved error messages on retry exhaustion. * Address PR feedback: Use null-safe == operator for enum comparison * Address PR feedback: remove duplicate logging, simplify status extraction, and preserve exception chain * Address PR feedback: handle InterruptedException properly and use official IAM permission name * Address PR feedback: handle InterruptedException and use official permission name in source files * Fix NotSerializableException: mark appendRowsError as transient in FakeDatasetService * Fix NotSerializableException: store error as serializable Strings and reconstruct at runtime The previous transient fix prevented the serialization error but caused the error to be lost on deserialization, so the test never actually triggered the error path. Instead, store the gRPC status code and description as serializable Strings and reconstruct the StatusRuntimeException at runtime in appendRows. * Fix CI failure: create table in FakeDatasetService before pipeline run The test was failing because no table was created in the fake dataset service, causing createWriteStream to fail with NOT_FOUND before the pipeline ever reached appendRows. Now we create the table first so the pipeline reaches the appendRows call where the PERMISSION_DENIED error is actually injected. * Fix CI failure: remove withTriggeringFrequency for bounded PCollection Parameterizations [3] and [4] (useStreaming=true) were failing because withTriggeringFrequency requires an unbounded PCollection, but the test uses Create.of which is bounded. Removed the withTriggeringFrequency block since the Storage Write API is still exercised via useStorageApi. * Fix CI failure: skip streaming parameterizations that cause timeout Streaming parameterizations [3] and [4] (useStreaming=true) cause the pipeline to hang indefinitely with a bounded PCollection, resulting in a 600s timeout. The streaming path uses triggering frequency which requires an unbounded source. Since the error handling code is identical in both sharded and unsharded paths, testing the non-streaming path (useStorageApi=true, useStreaming=false) is sufficient.
1 parent db66869 commit f8e4de0

4 files changed

Lines changed: 110 additions & 3 deletions

File tree

sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWriteUnshardedRecords.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -792,9 +792,17 @@ long flush(
792792

793793
// Maximum number of times we retry before we fail the work item.
794794
if (failedContext.failureCount > allowedRetry) {
795-
throw new RuntimeException(
795+
String errorMessage =
796796
String.format(
797-
"More than %d attempts to call AppendRows failed.", allowedRetry));
797+
"More than %d attempts to call AppendRows failed. Last encountered error: %s",
798+
allowedRetry, error != null ? error.toString() : "unknown");
799+
if (statusCode == Status.Code.PERMISSION_DENIED
800+
|| statusCode == Status.Code.NOT_FOUND) {
801+
errorMessage +=
802+
". Please check if the destination table exists and if the service account has the "
803+
+ "bigquery.tables.updateData permission.";
804+
}
805+
throw new RuntimeException(errorMessage, error);
798806
}
799807

800808
// The following errors are known to be persistent, so always fail the work item in

sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/StorageApiWritesShardedRecords.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1066,7 +1066,25 @@ public void process(
10661066

10671067
if (numAppends > 0) {
10681068
initializeContexts.accept(contexts);
1069-
retryManager.run(true);
1069+
try {
1070+
retryManager.run(true);
1071+
} catch (InterruptedException e) {
1072+
Thread.currentThread().interrupt();
1073+
throw e;
1074+
} catch (Exception e) {
1075+
Status.Code statusCode = Status.fromThrowable(e).getCode();
1076+
String errorMessage =
1077+
String.format(
1078+
"More than %d attempts to call AppendRows failed. Last encountered error: %s",
1079+
maxRetries, e.toString());
1080+
if (statusCode == Status.Code.PERMISSION_DENIED
1081+
|| statusCode == Status.Code.NOT_FOUND) {
1082+
errorMessage +=
1083+
". Please check if the destination table exists and if the service account has the "
1084+
+ "bigquery.tables.updateData permission.";
1085+
}
1086+
throw new RuntimeException(errorMessage, e);
1087+
}
10701088

10711089
appendSplitDistribution.update(numAppends);
10721090
if (autoUpdateSchema) {

sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/testing/FakeDatasetService.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,16 @@ void commit() {
238238

239239
Function<TableRow, Boolean> shouldFailRow =
240240
(Function<TableRow, Boolean> & Serializable) tr -> false;
241+
242+
private volatile String appendRowsErrorCode = null;
243+
private volatile String appendRowsErrorDescription = null;
244+
245+
public void setAppendRowsError(Throwable t) {
246+
io.grpc.Status status = io.grpc.Status.fromThrowable(t);
247+
this.appendRowsErrorCode = status.getCode().name();
248+
this.appendRowsErrorDescription = status.getDescription();
249+
}
250+
241251
Map<String, List<String>> insertErrors = Maps.newHashMap();
242252

243253
// The counter for the number of insertions performed.
@@ -801,6 +811,14 @@ Exceptions.StorageException tryInitialize() throws Exception {
801811
@Override
802812
public ApiFuture<AppendRowsResponse> appendRows(long offset, ProtoRows rows)
803813
throws Exception {
814+
if (appendRowsErrorCode != null) {
815+
io.grpc.Status.Code code = io.grpc.Status.Code.valueOf(appendRowsErrorCode);
816+
io.grpc.Status status =
817+
appendRowsErrorDescription != null
818+
? code.toStatus().withDescription(appendRowsErrorDescription)
819+
: code.toStatus();
820+
return ApiFutures.immediateFailedFuture(new io.grpc.StatusRuntimeException(status));
821+
}
804822
// The BigQuery client returns stream-open errors when the first append is called, so we
805823
// duplicate that here.
806824
Exceptions.StorageException storageException = tryInitialize();

sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIOWriteTest.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1659,6 +1659,69 @@ public void testStreamingStorageApiWriteWithErrorHandling() throws Exception {
16591659
storageWriteWithErrorHandling(false);
16601660
}
16611661

1662+
@Test
1663+
public void testStorageApiWriteFailureExhaustedRetries() throws Exception {
1664+
assumeTrue(useStorageApi);
1665+
// Skip streaming parameterizations: the streaming write path uses triggering
1666+
// frequency which requires an unbounded PCollection. With our bounded test
1667+
// data, the pipeline hangs indefinitely. The error handling code is identical
1668+
// in both paths, so testing the non-streaming path is sufficient.
1669+
assumeFalse(useStreaming);
1670+
1671+
// Create the table in the fake dataset service so write stream creation succeeds.
1672+
// The error will be injected at the appendRows level.
1673+
Table table =
1674+
new Table()
1675+
.setTableReference(
1676+
new TableReference()
1677+
.setProjectId("project-id")
1678+
.setDatasetId("dataset-id")
1679+
.setTableId("table-id"))
1680+
.setSchema(
1681+
new TableSchema()
1682+
.setFields(
1683+
ImmutableList.of(
1684+
new TableFieldSchema().setName("number").setType("INTEGER"))));
1685+
fakeDatasetService.createTable(table);
1686+
1687+
// Set up fake dataset service to return PERMISSION_DENIED for appendRows
1688+
fakeDatasetService.setAppendRowsError(
1689+
new io.grpc.StatusRuntimeException(
1690+
io.grpc.Status.PERMISSION_DENIED.withDescription("Missing permissions")));
1691+
1692+
List<Integer> elements = Lists.newArrayList(1, 2, 3);
1693+
1694+
BigQueryIO.Write<Integer> write =
1695+
BigQueryIO.<Integer>write()
1696+
.to("project-id:dataset-id.table-id")
1697+
.withCreateDisposition(BigQueryIO.Write.CreateDisposition.CREATE_NEVER)
1698+
.withFormatFunction(
1699+
(SerializableFunction<Integer, TableRow>)
1700+
input -> new TableRow().set("number", input))
1701+
.withSchema(
1702+
new TableSchema()
1703+
.setFields(
1704+
ImmutableList.of(
1705+
new TableFieldSchema().setName("number").setType("INTEGER"))))
1706+
.withTestServices(fakeBqServices)
1707+
.withoutValidation();
1708+
1709+
// Note: This test uses a bounded PCollection, so we don't set withTriggeringFrequency
1710+
// (which requires an unbounded PCollection). The Storage Write API is still exercised
1711+
// because useStorageApi=true.
1712+
1713+
PCollection<Integer> input = p.apply(Create.of(elements).withCoder(BigEndianIntegerCoder.of()));
1714+
input.apply("WriteToBQ", write);
1715+
1716+
thrown.expect(RuntimeException.class);
1717+
thrown.expectMessage("More than");
1718+
thrown.expectMessage("attempts to call AppendRows failed");
1719+
thrown.expectMessage("PERMISSION_DENIED");
1720+
thrown.expectMessage("bigquery.tables.updateData");
1721+
1722+
p.run().waitUntilFinish();
1723+
}
1724+
16621725
@Test
16631726
public void testStreamingStorageApiWriteWithAutoShardingWithErrorHandling() throws Exception {
16641727
assumeTrue(useStreaming);

0 commit comments

Comments
 (0)