Skip to content

feat: Add Bigtable CDC -> Kafka template - #4357

Open
prawilny wants to merge 25 commits into
GoogleCloudPlatform:mainfrom
Unoperate:bigtable_change_streams_to_kafka
Open

prawilny wants to merge 25 commits into
GoogleCloudPlatform:mainfrom
Unoperate:bigtable_change_streams_to_kafka

Conversation

@prawilny

@prawilny prawilny commented Oct 2, 2026

Copy link
Copy Markdown

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:

  1. Read input change stream data.
  2. [optionally] Merge input change stream data and previously failed records from DLQ.
  3. Attempt to serialize and write data to Kafka.
  4. Send records failing step 3. to DLQ.

Reading is performed using BigtableIO.ReadChangeStream, DLQ is managed with DeadLetterQueueManager.

Writing to Kafka is implemented using a custom DoFn since KafkaIO lacks 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.

@brandtnewton brandtnewton left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. See previous discussion in PR#2388 for more details.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

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

  • New Dataflow Template: Introduced a new Bigtable Change Streams to Kafka Dataflow template to enable real-time data replication.
  • Custom Kafka Writing: Implemented a custom DoFn for Kafka writing to support error handling and dead-letter queue (DLQ) integration.
  • Schema Support: Added support for multiple message formats including Avro (Confluent Wire Format and Binary) and JSON.
  • Integration Testing: Included comprehensive integration tests and a Schema Registry resource manager to ensure pipeline reliability.
Using Gemini Code Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +144 to +147
public static Mod fromJson(String json) throws IOException {
RowJsonUtils.increaseDefaultStreamReadConstraints(100 * 1024 * 1024);
return new ObjectMapper().readValue(json, Mod.class);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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);
  }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is worth implementing

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +202 to +208
private String convertPropertiesToJson(Map<String, Object> propertiesMap) {
try {
return new ObjectMapper().writeValueAsString(propertiesMap);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Instantiating new ObjectMapper() inside convertPropertiesToJson for every single cell mutation is highly inefficient. Please reuse a single static final ObjectMapper instance.

Suggested change
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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is worth implementing

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +30 to +39
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)));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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)));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +413 to +431
@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)));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe? If this is easy to implement, we can add it, otherwise we can hold off as is.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.
The integration test will follow.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just pushed the integration test.

Comment on lines +292 to +303
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());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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());
  }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +43 to +56
@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());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
@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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +47 to +61
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());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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());
    }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@chamikaramj chamikaramj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

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.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably work for Dataflow runner v1 (renamed to Streaming Java Runner now) as well. Can you please try ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The integration tests passed with experiment disable_runner_v2 instead of use_runner_v2.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update the PR/comment ?

// Register the coders.
pipeline
.getCoderRegistry()
.registerCoderForType(STRING_CODER.getEncodedTypeDescriptor(), STRING_CODER);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed ? I would expect Beam to already register the String coder.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

if (options.getDisableDlqRetries()) {
retryableDlqFailsafeModJson = pipeline.apply(Create.empty(FAILSAFE_ELEMENT_CODER));
} else {
retryableDlqFailsafeModJson = dlqModJson.get(DeadLetterQueueManager.RETRYABLE_ERRORS);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are elements written via the same pipeline or other pipelines or both ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

[1] https://github.com/apache/beam/blob/69bdf5f29174adebe19006e1397acd6681ffce9d/sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/KafkaIO.java#L1604

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@damccorm or @Abacn do you have additional context here ?

KafkaFields.COLUMN_BYTES,
(k, chg) -> {
if (!chg.has(KafkaFields.COLUMN_BYTES.name())) {
return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could null values returned here and elsewhere result in serialization errors and send record to DLQ ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like we are double counting milliseconds here ?

Change to: getEpochSecond() * 1_000_000L + getNano() / 1_000

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these defined somewhere external that we can link to ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 prawilny left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +47 to +61
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());
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +202 to +208
private String convertPropertiesToJson(Map<String, Object> propertiesMap) {
try {
return new ObjectMapper().writeValueAsString(propertiesMap);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +30 to +39
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)));
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +292 to +303
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());
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +43 to +56
@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());
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +144 to +147
public static Mod fromJson(String json) throws IOException {
RowJsonUtils.increaseDefaultStreamReadConstraints(100 * 1024 * 1024);
return new ObjectMapper().readValue(json, Mod.class);
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@chamikaramj

Copy link
Copy Markdown
Contributor

Please let me know when existing comments have been addressed and this is ready for another look.

@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 41.12319% with 325 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.87%. Comparing base (302a936) to head (4d0f6dc).
⚠️ Report is 468 commits behind head on main.

Files with missing lines Patch % Lines
...ort/v2/templates/BigtableChangeStreamsToKafka.java 0.00% 199 Missing ⚠️
...ions/BigtableChangeStreamsToKafkaOptionsUtils.java 0.00% 73 Missing ⚠️
...ud/teleport/v2/templates/model/BigtableSource.java 25.64% 26 Missing and 3 partials ⚠️
.../google/cloud/teleport/v2/templates/model/Mod.java 80.00% 12 Missing and 4 partials ⚠️
.../teleport/v2/templates/schemautils/KafkaUtils.java 96.69% 4 Missing ⚠️
...rt/v2/kafka/transforms/AbstractAvroSerializer.java 78.57% 3 Missing ⚠️
...port/v2/kafka/transforms/BinaryAvroSerializer.java 66.66% 1 Missing ⚠️

❌ 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     
Components Coverage Δ
spanner-templates 76.21% <ø> (+3.29%) ⬆️
spanner-import-export 72.00% <ø> (+3.36%) ⬆️
spanner-live-forward-migration 88.97% <ø> (+7.97%) ⬆️
spanner-live-reverse-replication 80.93% <ø> (+3.52%) ⬆️
spanner-bulk-migration 88.97% <ø> (-2.18%) ⬇️
gcs-spanner-dv 88.07% <ø> (+2.24%) ⬆️
Files with missing lines Coverage Δ
...cloud/teleport/v2/templates/model/KafkaFields.java 100.00% <100.00%> (ø)
...gle/cloud/teleport/v2/templates/model/ModType.java 100.00% <100.00%> (ø)
...leport/v2/kafka/transforms/JsonAvroSerializer.java 100.00% <100.00%> (ø)
...port/v2/kafka/transforms/BinaryAvroSerializer.java 60.00% <66.66%> (+60.00%) ⬆️
...rt/v2/kafka/transforms/AbstractAvroSerializer.java 78.57% <78.57%> (ø)
.../teleport/v2/templates/schemautils/KafkaUtils.java 96.69% <96.69%> (ø)
.../google/cloud/teleport/v2/templates/model/Mod.java 80.00% <80.00%> (ø)
...ud/teleport/v2/templates/model/BigtableSource.java 25.64% <25.64%> (ø)
...ions/BigtableChangeStreamsToKafkaOptionsUtils.java 0.00% <0.00%> (ø)
...ort/v2/templates/BigtableChangeStreamsToKafka.java 0.00% <0.00%> (ø)

... and 549 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@prawilny

prawilny commented Oct 9, 2026

Copy link
Copy Markdown
Author

@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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 chamikaramj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added few more comments. Please let me know when they are addressed.

@damccorm damccorm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants