Skip to content

Commit 4d82e8e

Browse files
committed
Strongly-consistent schemna detection in BigQueryIO.
1 parent 0856c22 commit 4d82e8e

21 files changed

Lines changed: 2416 additions & 424 deletions
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.apache.beam.sdk.io.gcp.bigquery;
19+
20+
import com.google.api.services.bigquery.model.TableRow;
21+
import com.google.auto.value.AutoValue;
22+
import com.google.cloud.bigquery.storage.v1.ProtoRows;
23+
import com.google.protobuf.ByteString;
24+
import com.google.protobuf.Descriptors;
25+
import java.io.IOException;
26+
import java.util.BitSet;
27+
import java.util.List;
28+
import java.util.Map;
29+
import java.util.function.Consumer;
30+
import java.util.function.Supplier;
31+
import java.util.stream.IntStream;
32+
import java.util.stream.Stream;
33+
import org.apache.beam.sdk.util.Preconditions;
34+
import org.apache.beam.sdk.util.ThrowingSupplier;
35+
import org.apache.beam.sdk.values.TimestampedValue;
36+
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Lists;
37+
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Maps;
38+
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.PeekingIterator;
39+
import org.checkerframework.checker.nullness.qual.Nullable;
40+
import org.joda.time.Instant;
41+
42+
@AutoValue
43+
abstract class AppendRowsPacket {
44+
abstract ProtoRows getProtoRows();
45+
46+
abstract List<Instant> getTimestamps();
47+
48+
abstract List<@Nullable TableRow> getFailsafeTableRows();
49+
50+
abstract BitSet getSchemaMismatchedRows();
51+
52+
abstract Map<Integer, byte[]> getSchemaHashes();
53+
54+
abstract Map<Integer, TableRow> getUnknownFields();
55+
56+
abstract Map<Integer, byte[]> getOriginalPayloads();
57+
58+
private static final BitSet EMPTY_BIT_SET = new BitSet(0);
59+
60+
static AppendRowsPacket fromStorageApiWritePayload(
61+
PeekingIterator<StorageApiWritePayload> underlyingIterator,
62+
long maxByteSize,
63+
SchemaChangeDetectorHelper schemaChangeDetectorHelper,
64+
Instant elementTimestamp,
65+
Supplier<AppendClientInfo> appendClientInfoSupplier,
66+
Consumer<TimestampedValue<BigQueryStorageApiInsertError>> failedRowsConsumer,
67+
ThrowingSupplier<byte[]> getCurrentTableSchemaHash,
68+
ThrowingSupplier<Descriptors.Descriptor> getCurrentTableSchemaDescriptor) {
69+
List<Instant> timestamps = Lists.newArrayList();
70+
List<@Nullable TableRow> failsafeRows = Lists.newArrayList();
71+
Map<Integer, byte[]> schemaHashes = Maps.newHashMap();
72+
Map<Integer, TableRow> unknownFields = Maps.newHashMap();
73+
Map<Integer, byte[]> originalPayloads = Maps.newHashMap();
74+
ProtoRows.Builder inserts = ProtoRows.newBuilder();
75+
long bytesSize = 0;
76+
BitSet mismatchedRows = new BitSet();
77+
try {
78+
while (underlyingIterator.hasNext()) {
79+
// Make sure that we don't exceed the maxByteSize over multiple elements. A single
80+
// element can exceed
81+
// the split threshold, but in that case it should be the only element returned.
82+
if ((bytesSize + underlyingIterator.peek().getPayload().length > maxByteSize)
83+
&& inserts.getSerializedRowsCount() > 0) {
84+
break;
85+
}
86+
StorageApiWritePayload payload = underlyingIterator.next();
87+
88+
@Nullable TableRow failsafeTableRow = null;
89+
try {
90+
failsafeTableRow = payload.getFailsafeTableRow();
91+
} catch (IOException e) {
92+
// Do nothing, table row will be generated later from row bytes
93+
}
94+
95+
// If autoUpdateSchema is set, we try to automatically merge in unknown fields.
96+
SchemaChangeDetectorHelper.MergePayloadResult mergeResult =
97+
schemaChangeDetectorHelper.getMergedPayload(
98+
payload, elementTimestamp, failsafeTableRow, appendClientInfoSupplier.get());
99+
if (mergeResult.getKind() == SchemaChangeDetectorHelper.MergePayloadResult.Kind.FAILED) {
100+
failedRowsConsumer.accept(mergeResult.getFailed());
101+
continue;
102+
}
103+
ByteString byteString = mergeResult.getMerged();
104+
105+
if (schemaChangeDetectorHelper.isPayloadSchemaOutOfDate(
106+
payload,
107+
byteString.toByteArray(),
108+
getCurrentTableSchemaHash,
109+
getCurrentTableSchemaDescriptor)) {
110+
mismatchedRows.set(inserts.getSerializedRowsCount());
111+
}
112+
113+
int currentIndex = inserts.getSerializedRowsCount();
114+
inserts.addSerializedRows(byteString);
115+
Instant timestamp = payload.getTimestamp();
116+
if (timestamp == null) {
117+
timestamp = elementTimestamp;
118+
}
119+
timestamps.add(timestamp);
120+
failsafeRows.add(failsafeTableRow);
121+
if (payload.getSchemaHash() != null) {
122+
schemaHashes.put(currentIndex, Preconditions.checkStateNotNull(payload.getSchemaHash()));
123+
}
124+
if (payload.getUnknownFields() != null) {
125+
unknownFields.put(
126+
currentIndex, Preconditions.checkStateNotNull(payload.getUnknownFields()));
127+
originalPayloads.put(currentIndex, payload.getPayload());
128+
}
129+
bytesSize += byteString.size();
130+
}
131+
} catch (Exception e) {
132+
throw new RuntimeException(e);
133+
}
134+
135+
return new AutoValue_AppendRowsPacket(
136+
inserts.build(),
137+
timestamps,
138+
failsafeRows,
139+
mismatchedRows,
140+
schemaHashes,
141+
unknownFields,
142+
originalPayloads);
143+
}
144+
145+
AppendRowsPacket getSchemaMismatchedRowsOnly() {
146+
ProtoRows.Builder inserts = ProtoRows.newBuilder();
147+
List<Instant> timestamps = Lists.newArrayList();
148+
List<@Nullable TableRow> failsafeTableRows = Lists.newArrayList();
149+
Map<Integer, byte[]> schemaHashes = Maps.newHashMap();
150+
Map<Integer, TableRow> unknownFields = Maps.newHashMap();
151+
Map<Integer, byte[]> originalPayloads = Maps.newHashMap();
152+
if (!getSchemaMismatchedRows().isEmpty()) {
153+
int newIndex = 0;
154+
for (int i = 0; i < getProtoRows().getSerializedRowsCount(); i++) {
155+
if (getSchemaMismatchedRows().get(i)) {
156+
inserts.addSerializedRows(getProtoRows().getSerializedRows(i));
157+
timestamps.add(getTimestamps().get(i));
158+
failsafeTableRows.add(getFailsafeTableRows().get(i));
159+
if (getUnknownFields().containsKey(i)) {
160+
unknownFields.put(newIndex, Preconditions.checkStateNotNull(getUnknownFields().get(i)));
161+
}
162+
if (getOriginalPayloads().containsKey(i)) {
163+
originalPayloads.put(
164+
newIndex, Preconditions.checkStateNotNull(getOriginalPayloads().get(i)));
165+
}
166+
if (getSchemaHashes().containsKey(i)) {
167+
schemaHashes.put(newIndex, Preconditions.checkStateNotNull(getSchemaHashes().get(i)));
168+
}
169+
newIndex++;
170+
}
171+
}
172+
}
173+
BitSet allBits = new BitSet(inserts.getSerializedRowsCount());
174+
allBits.set(0, inserts.getSerializedRowsCount());
175+
return new AutoValue_AppendRowsPacket(
176+
inserts.build(),
177+
timestamps,
178+
failsafeTableRows,
179+
allBits,
180+
schemaHashes,
181+
unknownFields,
182+
originalPayloads);
183+
}
184+
185+
AppendRowsPacket getSchemaMatchedRowsOnly() {
186+
if (getSchemaMismatchedRows().isEmpty()) {
187+
return this;
188+
}
189+
190+
ProtoRows.Builder inserts = ProtoRows.newBuilder();
191+
List<Instant> timestamps = Lists.newArrayList();
192+
Map<Integer, byte[]> schemaHashes = Maps.newHashMap();
193+
Map<Integer, TableRow> unknownFields = Maps.newHashMap();
194+
Map<Integer, byte[]> originalPayloads = Maps.newHashMap();
195+
List<@Nullable TableRow> failsafeTableRows = Lists.newArrayList();
196+
int newIndex = 0;
197+
for (int i = 0; i < getProtoRows().getSerializedRowsCount(); i++) {
198+
if (!getSchemaMismatchedRows().get(i)) {
199+
inserts.addSerializedRows(getProtoRows().getSerializedRows(i));
200+
timestamps.add(getTimestamps().get(i));
201+
failsafeTableRows.add(getFailsafeTableRows().get(i));
202+
if (getSchemaHashes().containsKey(i)) {
203+
schemaHashes.put(newIndex, Preconditions.checkStateNotNull(getSchemaHashes().get(i)));
204+
}
205+
if (getUnknownFields().containsKey(i)) {
206+
unknownFields.put(newIndex, Preconditions.checkStateNotNull(getUnknownFields().get(i)));
207+
}
208+
if (getOriginalPayloads().containsKey(i)) {
209+
originalPayloads.put(
210+
newIndex, Preconditions.checkStateNotNull(getOriginalPayloads().get(i)));
211+
}
212+
newIndex++;
213+
}
214+
}
215+
return new AutoValue_AppendRowsPacket(
216+
inserts.build(),
217+
timestamps,
218+
failsafeTableRows,
219+
EMPTY_BIT_SET,
220+
schemaHashes,
221+
unknownFields,
222+
originalPayloads);
223+
}
224+
225+
Stream<StorageApiWritePayload> toPayloadStream() {
226+
return IntStream.range(0, getProtoRows().getSerializedRowsCount())
227+
.mapToObj(
228+
i -> {
229+
try {
230+
byte @Nullable [] originalBytes = getOriginalPayloads().get(i);
231+
StorageApiWritePayload payload =
232+
StorageApiWritePayload.of(
233+
originalBytes != null
234+
? originalBytes
235+
: getProtoRows().getSerializedRows(i).toByteArray(),
236+
getUnknownFields().get(i),
237+
getFailsafeTableRows().get(i))
238+
.withTimestamp(getTimestamps().get(i));
239+
byte @Nullable [] hash = getSchemaHashes().get(i);
240+
if (hash != null) {
241+
payload = payload.withSchemaHash(hash);
242+
}
243+
return payload;
244+
} catch (IOException e) {
245+
throw new RuntimeException(e);
246+
}
247+
});
248+
}
249+
}

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

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
import org.apache.beam.sdk.io.gcp.bigquery.PassThroughThenCleanup.ContextContainer;
109109
import org.apache.beam.sdk.io.gcp.bigquery.RowWriterFactory.OutputType;
110110
import org.apache.beam.sdk.options.PipelineOptions;
111+
import org.apache.beam.sdk.options.StreamingOptions;
111112
import org.apache.beam.sdk.options.ValueProvider;
112113
import org.apache.beam.sdk.options.ValueProvider.NestedValueProvider;
113114
import org.apache.beam.sdk.options.ValueProvider.StaticValueProvider;
@@ -2496,6 +2497,7 @@ public static <T> Write<T> write() {
24962497
.setAutoSharding(false)
24972498
.setPropagateSuccessful(true)
24982499
.setAutoSchemaUpdate(false)
2500+
.setAutoSchemaUpdateStrictTimeout(null)
24992501
.setDeterministicRecordIdFn(null)
25002502
.setMaxRetryJobs(1000)
25012503
.setPropagateSuccessfulStorageApiWrites(false)
@@ -2777,6 +2779,8 @@ public enum Method {
27772779

27782780
abstract boolean getAutoSchemaUpdate();
27792781

2782+
abstract @Nullable Duration getAutoSchemaUpdateStrictTimeout();
2783+
27802784
abstract @Nullable Class<T> getWriteProtosClass();
27812785

27822786
abstract boolean getDirectWriteProtos();
@@ -2897,6 +2901,8 @@ abstract Builder<T> setDefaultMissingValueInterpretation(
28972901

28982902
abstract Builder<T> setAutoSchemaUpdate(boolean autoSchemaUpdate);
28992903

2904+
abstract Builder<T> setAutoSchemaUpdateStrictTimeout(@Nullable Duration timeout);
2905+
29002906
abstract Builder<T> setWriteProtosClass(@Nullable Class<T> clazz);
29012907

29022908
abstract Builder<T> setDirectWriteProtos(boolean direct);
@@ -3562,11 +3568,46 @@ public Write<T> withSuccessfulInsertsPropagation(boolean propagateSuccessful) {
35623568
* If true, enables automatically detecting BigQuery table schema updates. Table schema updates
35633569
* are usually noticed within several minutes. Only supported when using one of the STORAGE_API
35643570
* insert methods.
3571+
*
3572+
* <p>Rows that contain new columns will only have the new columns sent to BigQuery after the
3573+
* new table schema has been observed. Until then, only the known columns will be sent to
3574+
* BigQuery.
3575+
*
3576+
* <p>Note that this option detects table schema updates performed on the table, usually by an
3577+
* external process. If you want Beam to update the table schema for you, please see {@link
3578+
* #withSchemaUpdateOptions}.
35653579
*/
35663580
public Write<T> withAutoSchemaUpdate(boolean autoSchemaUpdate) {
35673581
return toBuilder().setAutoSchemaUpdate(autoSchemaUpdate).build();
35683582
}
35693583

3584+
/**
3585+
* If true, enables automatically detecting BigQuery table schema updates. Table schema updates
3586+
* are usually noticed within several minutes. Only supported when using one of the STORAGE_API
3587+
* insert methods.
3588+
*
3589+
* <p>This mode ensures consistent writes - rows with new schemas will not be sent to BigQuery
3590+
* until we have observed the new table schema. This comes with a few caveats: - Detecting
3591+
* unknown columns requires extra parsing. Some increase in CPU usage may be noticed. - Rows
3592+
* with unknown columns will be retried until a new schema is observed. This may temporarily
3593+
* block inserts of rows into BigQuery whenever a schema update happens.
3594+
*
3595+
* <p>The timeout parameter specifies how long to wait until we see the new table schema. If
3596+
* more than the specified time goes by before a matching table schema is seen, the row will be
3597+
* sent to the failedRows output collection.
3598+
*
3599+
* <p>Note that this option detects table schema updates performed on the table, usually by an
3600+
* external process. If you want Beam to update the table schema for you, please see {@link
3601+
* #withSchemaUpdateOptions}.
3602+
*/
3603+
public Write<T> withAutoSchemaUpdateConsistent(
3604+
boolean autoSchemaUpdate, Duration waitForSchemaTimeout) {
3605+
return toBuilder()
3606+
.setAutoSchemaUpdate(autoSchemaUpdate)
3607+
.setAutoSchemaUpdateStrictTimeout(waitForSchemaTimeout)
3608+
.build();
3609+
}
3610+
35703611
/*
35713612
* Provides a function which can serve as a source of deterministic unique ids for each record
35723613
* to be written, replacing the unique ids generated with the default scheme. When used with
@@ -4090,6 +4131,14 @@ private <DestinationT> WriteResult continueExpandTyped(
40904131
DynamicDestinations<T, DestinationT> dynamicDestinations,
40914132
RowWriterFactory<T, DestinationT> rowWriterFactory,
40924133
Write.Method method) {
4134+
if (getAutoSchemaUpdateStrictTimeout() != null) {
4135+
checkArgument(
4136+
method == Method.STORAGE_API_AT_LEAST_ONCE || method == Method.STORAGE_WRITE_API,
4137+
"Auto update schema only supported when using storage write API");
4138+
checkArgument(
4139+
input.getPipeline().getOptions().as(StreamingOptions.class).isStreaming(),
4140+
"auto update schema only supported on streaming pipelines");
4141+
}
40934142
if (method == Write.Method.STREAMING_INSERTS) {
40944143
checkArgument(
40954144
getWriteDisposition() != WriteDisposition.WRITE_TRUNCATE,
@@ -4294,6 +4343,10 @@ private <DestinationT> WriteResult continueExpandTyped(
42944343
RowWriterFactory.TableRowWriterFactory<T, DestinationT> tableRowWriterFactory =
42954344
(RowWriterFactory.TableRowWriterFactory<T, DestinationT>) rowWriterFactory;
42964345
// Fallback behavior: convert to JSON TableRows and convert those into Beam TableRows.
4346+
@Nullable Set<SchemaUpdateOption> schemaUpdateOptions = getSchemaUpdateOptions();
4347+
boolean useSchemaUpdatingTableRow =
4348+
(schemaUpdateOptions != null && !schemaUpdateOptions.isEmpty())
4349+
|| (getAutoSchemaUpdate() && getAutoSchemaUpdateStrictTimeout() != null);
42974350
storageApiDynamicDestinations =
42984351
new StorageApiDynamicDestinationsTableRow<>(
42994352
dynamicDestinations,
@@ -4303,9 +4356,7 @@ private <DestinationT> WriteResult continueExpandTyped(
43034356
getCreateDisposition(),
43044357
getIgnoreUnknownValues(),
43054358
getAutoSchemaUpdate(),
4306-
getSchemaUpdateOptions() == null
4307-
? Collections.emptySet()
4308-
: getSchemaUpdateOptions());
4359+
useSchemaUpdatingTableRow);
43094360
}
43104361

43114362
int numShards = getStorageApiNumStreams(bqOptions);
@@ -4328,6 +4379,7 @@ private <DestinationT> WriteResult continueExpandTyped(
43284379
method == Method.STORAGE_API_AT_LEAST_ONCE,
43294380
enableAutoSharding,
43304381
getAutoSchemaUpdate(),
4382+
getAutoSchemaUpdateStrictTimeout(),
43314383
getIgnoreUnknownValues(),
43324384
getPropagateSuccessfulStorageApiWrites(),
43334385
getPropagateSuccessfulStorageApiWritesPredicate(),

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,4 +260,11 @@ public interface BigQueryOptions
260260
Integer getSchemaUpgradeBufferingShards();
261261

262262
void setSchemaUpgradeBufferingShards(Integer value);
263+
264+
@Hidden
265+
@Description("The initial retry time in milliseconds when a schema mismatch is detected.")
266+
@Default.Integer(5000)
267+
Integer getStorageApiInitialMismatchRetryTimeMilliSec();
268+
269+
void setStorageApiInitialMismatchRetryTimeMilliSec(Integer value);
263270
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public class BigQuerySinkMetrics {
5151
public static final String OK = Status.Code.OK.toString();
5252
static final String INTERNAL = "INTERNAL";
5353
public static final String PAYLOAD_TOO_LARGE = "PayloadTooLarge";
54+
public static final String SCHEMA_MISMATCHED = "SchemaMismatched";
5455

5556
// Base Metric names
5657
private static final String RPC_REQUESTS = "RpcRequestsCount";

0 commit comments

Comments
 (0)