Repository navigation
Conversation
…cope of variables, make fields final, use new Java features (after the project-wide version bump), ...
…f BigtableChangeStreamsToKafka
…sToKafkaOptionsUtils
brandtnewton
left a comment
There was a problem hiding this comment.
Approved. See previous discussion in PR#2388 for more details.
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces a new Flex Template for streaming data changes from Google Cloud Bigtable to Apache Kafka. The template is designed to read Bigtable change streams, serialize the mutations, and write them to a specified Kafka topic. It includes robust error handling through a dead-letter queue (DLQ) mechanism and supports various serialization formats to accommodate different downstream Kafka consumers. This addition expands the Dataflow Templates library, providing users with a native solution for Bigtable-to-Kafka data integration. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a new Dataflow template to stream Cloud Bigtable change streams to Apache Kafka, along with corresponding Terraform configurations, Avro schemas, and integration tests. Key feedback on the implementation includes performance optimizations, such as reusing a single static ObjectMapper instance in Mod.java and utilizing asynchronous writes in WriteToKafkaFn to avoid blocking the pipeline thread. Additionally, robustness improvements are recommended, including adding null checks to prevent potential NullPointerExceptions when retrieving pipeline options, preserving full exception context in AbstractAvroSerializer, and filtering out blank values when parsing ignored columns and families in BigtableSource.
| public static Mod fromJson(String json) throws IOException { | ||
| RowJsonUtils.increaseDefaultStreamReadConstraints(100 * 1024 * 1024); | ||
| return new ObjectMapper().readValue(json, Mod.class); | ||
| } |
There was a problem hiding this comment.
Instantiating new ObjectMapper() for every single record in fromJson is extremely expensive and will cause severe performance degradation and high garbage collection overhead. Consider reusing a single, thread-safe static final ObjectMapper instance at the class level.
public static Mod fromJson(String json) throws IOException {
return OBJECT_MAPPER.readValue(json, Mod.class);
}There was a problem hiding this comment.
I think this is worth implementing
| private String convertPropertiesToJson(Map<String, Object> propertiesMap) { | ||
| try { | ||
| return new ObjectMapper().writeValueAsString(propertiesMap); | ||
| } catch (IOException e) { | ||
| throw new RuntimeException(e); | ||
| } | ||
| } |
There was a problem hiding this comment.
Instantiating new ObjectMapper() inside convertPropertiesToJson for every single cell mutation is highly inefficient. Please reuse a single static final ObjectMapper instance.
| private String convertPropertiesToJson(Map<String, Object> propertiesMap) { | |
| try { | |
| return new ObjectMapper().writeValueAsString(propertiesMap); | |
| } catch (IOException e) { | |
| throw new RuntimeException(e); | |
| } | |
| } | |
| private String convertPropertiesToJson(Map<String, Object> propertiesMap) { | |
| try { | |
| return OBJECT_MAPPER.writeValueAsString(propertiesMap); | |
| } catch (IOException e) { | |
| throw new RuntimeException(e); | |
| } | |
| } |
There was a problem hiding this comment.
I think this is worth implementing
| public static Instant getStartTimestamp(BigtableChangeStreamsToKafkaOptions options) { | ||
| if (options.getBigtableChangeStreamStartTimestamp().isEmpty()) { | ||
| return Instant.now(); | ||
| } else { | ||
| Timestamp ts = Timestamp.parseTimestamp(options.getBigtableChangeStreamStartTimestamp()); | ||
| return Instant.ofEpochSecond(ts.getSeconds()) | ||
| .plus( | ||
| Duration.millis(TimeUnit.MILLISECONDS.convert(ts.getNanos(), TimeUnit.NANOSECONDS))); | ||
| } | ||
| } |
There was a problem hiding this comment.
options.getBigtableChangeStreamStartTimestamp() can return null if the parameter is not specified, which will cause a NullPointerException when calling .isEmpty(). Use StringUtils.isEmpty() to safely check for null or empty values.
| public static Instant getStartTimestamp(BigtableChangeStreamsToKafkaOptions options) { | |
| if (options.getBigtableChangeStreamStartTimestamp().isEmpty()) { | |
| return Instant.now(); | |
| } else { | |
| Timestamp ts = Timestamp.parseTimestamp(options.getBigtableChangeStreamStartTimestamp()); | |
| return Instant.ofEpochSecond(ts.getSeconds()) | |
| .plus( | |
| Duration.millis(TimeUnit.MILLISECONDS.convert(ts.getNanos(), TimeUnit.NANOSECONDS))); | |
| } | |
| } | |
| public static Instant getStartTimestamp(BigtableChangeStreamsToKafkaOptions options) { | |
| if (StringUtils.isEmpty(options.getBigtableChangeStreamStartTimestamp())) { | |
| return Instant.now(); | |
| } else { | |
| Timestamp ts = Timestamp.parseTimestamp(options.getBigtableChangeStreamStartTimestamp()); | |
| return Instant.ofEpochSecond(ts.getSeconds()) | |
| .plus( | |
| Duration.millis(TimeUnit.MILLISECONDS.convert(ts.getNanos(), TimeUnit.NANOSECONDS))); | |
| } | |
| } |
| @ProcessElement | ||
| public void process( | ||
| @Element FailsafeElement<String, String> input, | ||
| OutputReceiver<FailsafeElement<String, String>> receiver) { | ||
| try { | ||
| String modChangeJson = Mod.fromJson(input.getPayload()).getChangeJson(); | ||
| ProducerRecord<byte[], GenericRecord> record = | ||
| base64EncodeByteFields | ||
| ? kafkaUtils.getProducerRecordWithBase64EncodedFields( | ||
| modChangeJson, destinationTopic) | ||
| : kafkaUtils.getProducerRecord(modChangeJson, destinationTopic); | ||
| producer.send(record).get(); | ||
| } catch (Exception e) { | ||
| receiver.output( | ||
| FailsafeElement.of(input) | ||
| .setErrorMessage(e.getMessage()) | ||
| .setStacktrace(Throwables.getStackTraceAsString(e))); | ||
| } | ||
| } |
There was a problem hiding this comment.
Calling producer.send(record).get() synchronously blocks the pipeline thread for every single record, which severely limits throughput (e.g., to 1 / latency records per second per thread). To achieve high throughput while maintaining error handling, consider using asynchronous writes and buffering the futures, then blocking on them at the end of the bundle (or when a batch size is reached).
There was a problem hiding this comment.
Maybe? If this is easy to implement, we can add it, otherwise we can hold off as is.
There was a problem hiding this comment.
Done.
The integration test will follow.
There was a problem hiding this comment.
I just pushed the integration test.
| private static DeadLetterQueueManager buildDlqManager( | ||
| BigtableChangeStreamsToKafkaOptions options) { | ||
| String tempLocation = | ||
| options.as(DataflowPipelineOptions.class).getTempLocation().endsWith("/") | ||
| ? options.as(DataflowPipelineOptions.class).getTempLocation() | ||
| : options.as(DataflowPipelineOptions.class).getTempLocation() + "/"; | ||
| String dlqDirectory = | ||
| options.getDlqDirectory().isEmpty() ? tempLocation + "dlq/" : options.getDlqDirectory(); | ||
|
|
||
| LOG.info("DLQ directory: {}", dlqDirectory); | ||
| return DeadLetterQueueManager.create(dlqDirectory, options.getDlqMaxRetries()); | ||
| } |
There was a problem hiding this comment.
options.as(DataflowPipelineOptions.class).getTempLocation() can return null if not set, which will cause a NullPointerException when calling .endsWith("/"). Add a null check to handle this safely.
private static DeadLetterQueueManager buildDlqManager(
BigtableChangeStreamsToKafkaOptions options) {
String tempLocation = options.as(DataflowPipelineOptions.class).getTempLocation();
if (tempLocation != null) {
tempLocation = tempLocation.endsWith("/") ? tempLocation : tempLocation + "/";
} else {
tempLocation = "";
}
String dlqDirectory =
options.getDlqDirectory().isEmpty() ? tempLocation + "dlq/" : options.getDlqDirectory();
LOG.info("DLQ directory: {}", dlqDirectory);
return DeadLetterQueueManager.create(dlqDirectory, options.getDlqMaxRetries());
}| @Override | ||
| public byte[] serialize(String topic, GenericRecord record) { | ||
| try { | ||
| Schema schema = getSchema(record); | ||
| ByteArrayOutputStream out = new ByteArrayOutputStream(); | ||
| Encoder encoder = getEncoder(schema, out); | ||
| DatumWriter<GenericRecord> writer = new GenericDatumWriter<>(schema); | ||
| writer.write(record, encoder); | ||
| encoder.flush(); | ||
| return out.toByteArray(); | ||
| } catch (IOException e) { | ||
| throw new SerializationException("Error serializing avro message", e.getCause()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Passing e.getCause() to SerializationException can discard the actual IOException context and stack trace if e.getCause() is null. Pass the exception e directly as the cause.
| @Override | |
| public byte[] serialize(String topic, GenericRecord record) { | |
| try { | |
| Schema schema = getSchema(record); | |
| ByteArrayOutputStream out = new ByteArrayOutputStream(); | |
| Encoder encoder = getEncoder(schema, out); | |
| DatumWriter<GenericRecord> writer = new GenericDatumWriter<>(schema); | |
| writer.write(record, encoder); | |
| encoder.flush(); | |
| return out.toByteArray(); | |
| } catch (IOException e) { | |
| throw new SerializationException("Error serializing avro message", e.getCause()); | |
| } | |
| } | |
| @Override | |
| public byte[] serialize(String topic, GenericRecord record) { | |
| try { | |
| Schema schema = getSchema(record); | |
| ByteArrayOutputStream out = new ByteArrayOutputStream(); | |
| Encoder encoder = getEncoder(schema, out); | |
| DatumWriter<GenericRecord> writer = new GenericDatumWriter<>(schema); | |
| writer.write(record, encoder); | |
| encoder.flush(); | |
| return out.toByteArray(); | |
| } catch (IOException e) { | |
| throw new SerializationException("Error serializing avro message", e); | |
| } | |
| } |
| if (StringUtils.isBlank(ignoreColumnFamilies)) { | ||
| this.columnFamiliesToIgnore = Collections.emptySet(); | ||
| } else { | ||
| this.columnFamiliesToIgnore = | ||
| Arrays.stream(ignoreColumnFamilies.trim().split("[\\s]*,[\\s]*")) | ||
| .collect(Collectors.toSet()); | ||
| } | ||
|
|
||
| Set<String> columnsToIgnore; | ||
| if (StringUtils.isBlank(ignoreColumns)) { | ||
| columnsToIgnore = Collections.emptySet(); | ||
| } else { | ||
| columnsToIgnore = | ||
| Arrays.stream(ignoreColumns.trim().split("[\\s]*,[\\s]*")).collect(Collectors.toSet()); | ||
| } |
There was a problem hiding this comment.
Splitting ignoreColumnFamilies and ignoreColumns using split("[\\s]*,[\\s]*") on strings with trailing commas or spaces can result in empty strings in the sets, which can cause bugs (e.g., ignoring empty column names). Filter out blank/empty strings after splitting.
if (StringUtils.isBlank(ignoreColumnFamilies)) {
this.columnFamiliesToIgnore = Collections.emptySet();
} else {
this.columnFamiliesToIgnore =
Arrays.stream(ignoreColumnFamilies.split(","))
.map(String::trim)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());
}
Set<String> columnsToIgnore;
if (StringUtils.isBlank(ignoreColumns)) {
columnsToIgnore = Collections.emptySet();
} else {
columnsToIgnore =
Arrays.stream(ignoreColumns.split(","))
.map(String::trim)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());
}| category = TemplateCategory.STREAMING, | ||
| displayName = "Cloud Bigtable Change Streams to Apache Kafka", | ||
| description = | ||
| "Streaming pipeline. Streams Bigtable data change records and writes them into Kafka using Dataflow runner V2.", |
There was a problem hiding this comment.
This should probably work for Dataflow runner v1 (renamed to Streaming Java Runner now) as well. Can you please try ?
There was a problem hiding this comment.
The integration tests passed with experiment disable_runner_v2 instead of use_runner_v2.
There was a problem hiding this comment.
Update the PR/comment ?
| // Register the coders. | ||
| pipeline | ||
| .getCoderRegistry() | ||
| .registerCoderForType(STRING_CODER.getEncodedTypeDescriptor(), STRING_CODER); |
There was a problem hiding this comment.
Is this needed ? I would expect Beam to already register the String coder.
| if (options.getDisableDlqRetries()) { | ||
| retryableDlqFailsafeModJson = pipeline.apply(Create.empty(FAILSAFE_ELEMENT_CODER)); | ||
| } else { | ||
| retryableDlqFailsafeModJson = dlqModJson.get(DeadLetterQueueManager.RETRYABLE_ERRORS); |
There was a problem hiding this comment.
These are elements written via the same pipeline or other pipelines or both ?
There was a problem hiding this comment.
Potentially, the user, by not providing dlqDirectory and tempLocation parameters can configure the DLQ to be shared across different runs or pipelines. We can add options.getJobName() to the DLQ path to avoid this risk. Would you like us to do it?
There was a problem hiding this comment.
How about adding a random string to the path here ? Different jobs that are not run at the same time may share the job name.
There was a problem hiding this comment.
Or did you intend the different runs of the same pipeline to share the DLQ ? In the case, job name makes sense.
| if (options.getDisableDlqRetries()) { | ||
| retryableDlqFailsafeModJson = pipeline.apply(Create.empty(FAILSAFE_ELEMENT_CODER)); | ||
| } else { | ||
| retryableDlqFailsafeModJson = dlqModJson.get(DeadLetterQueueManager.RETRYABLE_ERRORS); |
There was a problem hiding this comment.
Can this result in failed record being retried forever ? Usually we retry within the pipeline a given number of times potentially with backoff and then send the error records to DLQ for downstream processing (do not re-read from the same pipeline). Mostly I'm curious to know why you made this decision.
There was a problem hiding this comment.
AFAIK this decision was part of the initial Google’s design. The number of attempted retries is stored, incremented and compared to the max limit. Please take a look at:
There's a special case when the user configures the number of retries to be 0 (which means: unlimited).
In our case the default value is 5, which should prevent from the failed records being retried forever.
| } | ||
|
|
||
| @ProcessElement | ||
| public void process( |
There was a problem hiding this comment.
I highly recommend using the official Beam Kafka sink here since there are lot more nuances when it comes to implementing a Beam sink which properly handles worker failures/retries, optimized for performance etc. Can the Beam Kafka sink be extended to add the missing functionality you need here ?
Another, more simpler, option might be to check for any potential errors that would not be captured by Beam KafkaIO DLQ [1] before the sink step in a DoFn and send them to DLQ manually.
There was a problem hiding this comment.
This was a conscious decision reached with Google, to use the hand-written simpler sink instead of KafkaIO. I think it was due to a mismatch between KafkaIO error handling and the requirements from the initial Google design.
| KafkaFields.COLUMN_BYTES, | ||
| (k, chg) -> { | ||
| if (!chg.has(KafkaFields.COLUMN_BYTES.name())) { | ||
| return null; |
There was a problem hiding this comment.
Could null values returned here and elsewhere result in serialization errors and send record to DLQ ?
There was a problem hiding this comment.
Yes, there was a bug - fixed in 1e92e01 (note that it's still present in PubSubUtils on main branch!).
Apart from that, the nulls shouldn't be a problem - the Avro schema marks these fields as nullable.
|
|
||
| private Long cbtTimestampToLongMicros(Instant commitTimestamp) { | ||
| long epochMicros = commitTimestamp.toEpochMilli() * 1000; | ||
| long nanosAsMicros = commitTimestamp.getNano() / 1000; |
There was a problem hiding this comment.
Seems like we are double counting milliseconds here ?
Change to: getEpochSecond() * 1_000_000L + getNano() / 1_000
There was a problem hiding this comment.
Note that this bug is still present in the Bigtable change streams to PubSub template on main.
| /** Database column/data type name for bit. */ | ||
| public static final String BIT_TYPE = "bit"; | ||
| /** Static metadata class for all changelog columns. */ | ||
| public enum KafkaFields { |
There was a problem hiding this comment.
Are these defined somewhere external that we can link to ?
There was a problem hiding this comment.
It’s directly inspired by Avro schema, which is the definition of the external interface.
This remark led me to commit 5110126, which slightly cleaned the enum names up.
prawilny
left a comment
There was a problem hiding this comment.
I implemented the easy fixes and successfully reran the integration test.
Most of the fixes (single ObjectMapper object, null tempLocation, null bigtableChangeStreamStartTimestamp, cbtTimestampToLongMicros double counting milliseconds, ignoring empty columns when there's a trailing comma) are in the code copypasted from BigtableChangeStreamsToPubSub, but I didn't touch actual BigtableChangeStreamsToPubSub to avoid modifying its behavior, so the inaccuracies are still there.
The only exception to this rule is exception handling in AbstractAvroSerializer - it actually modifies behavior for all the users of the class, including BigtableChangeStreamsToPubSub.
Responses to the more complicated comments will follow in the coming days.
| if (StringUtils.isBlank(ignoreColumnFamilies)) { | ||
| this.columnFamiliesToIgnore = Collections.emptySet(); | ||
| } else { | ||
| this.columnFamiliesToIgnore = | ||
| Arrays.stream(ignoreColumnFamilies.trim().split("[\\s]*,[\\s]*")) | ||
| .collect(Collectors.toSet()); | ||
| } | ||
|
|
||
| Set<String> columnsToIgnore; | ||
| if (StringUtils.isBlank(ignoreColumns)) { | ||
| columnsToIgnore = Collections.emptySet(); | ||
| } else { | ||
| columnsToIgnore = | ||
| Arrays.stream(ignoreColumns.trim().split("[\\s]*,[\\s]*")).collect(Collectors.toSet()); | ||
| } |
| private String convertPropertiesToJson(Map<String, Object> propertiesMap) { | ||
| try { | ||
| return new ObjectMapper().writeValueAsString(propertiesMap); | ||
| } catch (IOException e) { | ||
| throw new RuntimeException(e); | ||
| } | ||
| } |
| public static Instant getStartTimestamp(BigtableChangeStreamsToKafkaOptions options) { | ||
| if (options.getBigtableChangeStreamStartTimestamp().isEmpty()) { | ||
| return Instant.now(); | ||
| } else { | ||
| Timestamp ts = Timestamp.parseTimestamp(options.getBigtableChangeStreamStartTimestamp()); | ||
| return Instant.ofEpochSecond(ts.getSeconds()) | ||
| .plus( | ||
| Duration.millis(TimeUnit.MILLISECONDS.convert(ts.getNanos(), TimeUnit.NANOSECONDS))); | ||
| } | ||
| } |
| private static DeadLetterQueueManager buildDlqManager( | ||
| BigtableChangeStreamsToKafkaOptions options) { | ||
| String tempLocation = | ||
| options.as(DataflowPipelineOptions.class).getTempLocation().endsWith("/") | ||
| ? options.as(DataflowPipelineOptions.class).getTempLocation() | ||
| : options.as(DataflowPipelineOptions.class).getTempLocation() + "/"; | ||
| String dlqDirectory = | ||
| options.getDlqDirectory().isEmpty() ? tempLocation + "dlq/" : options.getDlqDirectory(); | ||
|
|
||
| LOG.info("DLQ directory: {}", dlqDirectory); | ||
| return DeadLetterQueueManager.create(dlqDirectory, options.getDlqMaxRetries()); | ||
| } |
| @Override | ||
| public byte[] serialize(String topic, GenericRecord record) { | ||
| try { | ||
| Schema schema = getSchema(record); | ||
| ByteArrayOutputStream out = new ByteArrayOutputStream(); | ||
| Encoder encoder = getEncoder(schema, out); | ||
| DatumWriter<GenericRecord> writer = new GenericDatumWriter<>(schema); | ||
| writer.write(record, encoder); | ||
| encoder.flush(); | ||
| return out.toByteArray(); | ||
| } catch (IOException e) { | ||
| throw new SerializationException("Error serializing avro message", e.getCause()); | ||
| } | ||
| } |
| public static Mod fromJson(String json) throws IOException { | ||
| RowJsonUtils.increaseDefaultStreamReadConstraints(100 * 1024 * 1024); | ||
| return new ObjectMapper().readValue(json, Mod.class); | ||
| } |
|
Please let me know when existing comments have been addressed and this is ready for another look. |
Codecov Report❌ Patch coverage is ❌ Your patch check has failed because the patch coverage (41.12%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #4357 +/- ##
============================================
+ Coverage 53.06% 56.87% +3.81%
- Complexity 6429 8072 +1643
============================================
Files 1070 1165 +95
Lines 64918 74234 +9316
Branches 7190 8754 +1564
============================================
+ Hits 34446 42218 +7772
- Misses 28159 29207 +1048
- Partials 2313 2809 +496
🚀 New features to boost your workflow:
|
…efore timestamp so that the success window is wider
|
@chamikaramj, @brandtnewton, the existing comments have been addressed, please take a look. |
| : kafkaUtils.getProducerRecord(modChangeJson, destinationTopic); | ||
| inFlightRequests.add(new InFlightRequest(input, producer.send(record), timestamp, window)); | ||
| } catch (Exception e) { | ||
| receiver.output(toFailedElement(input, e)); |
There was a problem hiding this comment.
How about only sending more permanent errors to the DLQ and failing the bundle for retriable errors (even better if we retry them locally) ? I think using DLQ as a way to retry transient errors could result in quite a bit of unnecessary latency.
| } | ||
|
|
||
| /** DoFn that serializes and writes {@link FailsafeElement FailsafeElements} to Kafka. */ | ||
| static class WriteToKafkaFn |
There was a problem hiding this comment.
Please document the duplicate behavior of the end-to-end pipeline at the template documentation offered by this Kafka sink. Seems like this offers at-least-once delivery and may result in duplicates at the Kafka side if bundles are retried by Dataflow etc.
chamikaramj
left a comment
There was a problem hiding this comment.
Added few more comments. Please let me know when they are addressed.
damccorm
left a comment
There was a problem hiding this comment.
Hey folks, thanks for looking into this. With that said, have you followed https://github.com/GoogleCloudPlatform/DataflowTemplates/blob/main/contributor-docs/code-contributions.md#adding-new-templates and gotten approval for adding this?
For context, rolling out a new template is not as straightforward as just adding the code, and requires additional work to maintain/support the template; in general (though not in all cases), we have not been adding new templates and instead have been guiding people towards the Dataflow Job Builder (https://docs.cloud.google.com/dataflow/docs/guides/job-builder).
Additionally, we have tried quite hard to avoid forking/duplicating IO functionality. IOs tend to be the most complicated parts of pipelines and the most sensitive to small issues which can impact performance. So I think we should stay away from that approach and instead try to land any changes in Beam where everyone can benefit.
Reopens #2388
Bigtable Change Streams to Kafka Dataflow Template
This template is heavily influenced by Bigtable change streams to Pub/Sub template and mirrors its steps:
Reading is performed using
BigtableIO.ReadChangeStream, DLQ is managed withDeadLetterQueueManager.Writing to Kafka is implemented using a custom
DoFnsinceKafkaIOlacks an interface allowing error handling of writes (different than serialization errors) needed to send failed records to DLQ. It provides at-least-once delivery semantics.