Skip to content

feat(bigtable): add protobuf decoding to Bigtable Change Streams to BigQuery - #3572

Open
MattiasMTS wants to merge 8 commits into
GoogleCloudPlatform:mainfrom
MattiasMTS:ms/bt-bq-proto
Open

MattiasMTS wants to merge 8 commits into
GoogleCloudPlatform:mainfrom
MattiasMTS:ms/bt-bq-proto

Conversation

@MattiasMTS

@MattiasMTS MattiasMTS commented Mar 27, 2026 •

Copy link
Copy Markdown

Summary

Adds optional protobuf decoding to the Bigtable Change Streams to BigQuery template. Configured cells are decoded from protobuf wire format to JSON and written as a STRING to the BigQuery value column.

The Pub/Sub Proto to BigQuery template already supports this. Bigtable is a common store for protobuf data (see Query protobuf data), and a table can hold several message types, so this template needs per-column configuration.

New parameters (all optional, backwards compatible)

Parameter Description
columnTransforms Comma-separated column_family:column_qualifier:TRANSFORM_TYPE entries. Supported: PROTO_DECODE(package.MessageName). Each column can use a different message type. Qualifiers may contain :, but not ,.
protoSchemaPath GCS path to a self-contained FileDescriptorSet (protoc --descriptor_set_out --include_imports). Required when any PROTO_DECODE entry is used.
preserveProtoFieldNames Use proto field names (true) or lowerCamelCase (false, default) in JSON.
maxDecodedValueBytes Upper bound on decoded JSON size. Defaults to just below the Streaming Engine per-element commit limit and can't be set higher.

Example: columnTransforms=cf:profile:PROTO_DECODE(pkg.Profile),cf:event:PROTO_DECODE(pkg.Event)

Behavior

  • Config is validated at launch: malformed entries, unknown transform types, malformed PROTO_DECODE(...) and a missing protoSchemaPath fail fast.
  • Cells without a configured transform keep the existing raw-value behavior.
  • Decode errors fall back to the raw value and increment a counter + log a warning.
  • Oversized decoded values (above maxDecodedValueBytes) go to the severe DLQ with row key, column, sizes and source info, instead of stalling the pipeline on a commit that is too large. Values whose raw protobuf size already exceeds the bound are rejected before decoding (unknown fields count toward raw size but are dropped from JSON).
  • JSON is written into a UTF-8 byte-bounded Appendable that aborts mid-serialization once the budget is exceeded, so huge messages are never fully materialized.
  • Qualifier and row key are decoded with bigtableChangeStreamCharset, consistent with the rest of the template.
  • Transformers are stateless and shared; identical PROTO_DECODE(...) types reuse one instance. Descriptor/JsonFormat.Printer are lazily initialized on workers.

Reuses SchemaUtils.getProtoDomain() from v2/common and the DynamicMessage + JsonFormat.Printer pattern from PubsubProtoToBigQuery.

Test plan

  • Unit tests: ValueTransformerRegistryTest, ProtoDecodeTransformerTest, Utf8BoundedAppendableTest, OversizedValueDlqSanitizerTest, BigQueryUtilTest (48 tests, including negative cases for parser and decoder misconfiguration)
  • BigtableChangeStreamsToBigQueryIT: new cases for multi-column PROTO_DECODE and oversized values routed to the DLQ (needs GCP resources, not run locally)
  • mvn spotless:check and checkstyle:check
  • Running in production on a Bigtable table with protobuf payloads (fork build) since April

@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 enhances the Bigtable Change Streams to BigQuery template by enabling the decoding of protobuf-encoded data directly within the pipeline. By providing a schema descriptor file, users can now automatically transform binary Bigtable cell values into readable JSON format in BigQuery. The implementation is designed to be backwards compatible and includes robust error handling to ensure data integrity when decoding fails or when specific columns do not require transformation.

Highlights

  • New Proto Decoding Feature: Added optional protobuf decoding support to the Bigtable Change Streams to BigQuery template, allowing binary cell values to be converted to JSON.
  • Configuration Parameters: Introduced five new optional pipeline parameters: protoSchemaPath, fullProtoMessageName, protoColumnFamily, protoColumn, and preserveProtoFieldNames.
  • New Utility Class: Created ProtoDecoder.java to handle the dynamic parsing of protobuf messages using FileDescriptorSet files.
  • Robust Fallback Mechanism: Implemented logic to ensure that non-matching cells or decoding failures gracefully fall back to the existing raw value behavior.
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 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 counter productive. 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. ↩

@google-cla

google-cla Bot commented Mar 27, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@stankiewicz

Copy link
Copy Markdown
Contributor

/gemini review

@stankiewicz stankiewicz added the addition New feature or request label Apr 7, 2026

@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 support for decoding protobuf-encoded cell values and applying custom transformations to Bigtable change stream data before it is written to BigQuery. Key additions include a ProtoDecoder for schema-based JSON conversion and a ValueTransformerRegistry for handling specific data types like big-endian uint64 timestamps. The feedback identifies a potential issue in ValueTransformerRegistry.java where parsing configuration strings with split(":") may fail if column qualifiers contain colons, and suggests a more robust parsing approach using first and last index lookups.

Comment on lines +64 to +73
String[] parts = trimmed.split(":");
if (parts.length != 3) {
throw new IllegalArgumentException(
"Invalid columnTransforms entry '"
+ trimmed
+ "'. Expected format: column_family:column:TRANSFORM_TYPE");
}
String family = parts[0];
String column = parts[1];
String type = parts[2];

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

Using split(":") to parse the configuration string is fragile because Bigtable column qualifiers can contain colons. Since the column_family name is restricted and cannot contain colons, and the TRANSFORM_TYPE is the last segment, it is safer to find the first and last colons to extract the components. This ensures that column qualifiers containing colons are handled correctly.

Suggested change
String[] parts = trimmed.split(":");
if (parts.length != 3) {
throw new IllegalArgumentException(
"Invalid columnTransforms entry '"
+ trimmed
+ "'. Expected format: column_family:column:TRANSFORM_TYPE");
}
String family = parts[0];
String column = parts[1];
String type = parts[2];
int firstColon = trimmed.indexOf(':');
int lastColon = trimmed.lastIndexOf(':');
if (firstColon == -1 || firstColon == lastColon) {
throw new IllegalArgumentException(
"Invalid columnTransforms entry '"
+ trimmed
+ "'. Expected format: column_family:column:TRANSFORM_TYPE");
}
String family = trimmed.substring(0, firstColon);
String column = trimmed.substring(firstColon + 1, lastColon);
String type = trimmed.substring(lastColon + 1);

@stankiewicz

Copy link
Copy Markdown
Contributor

Please also add new test to BigtableChangeStreamsToBigQueryIT

@codecov

codecov Bot commented Apr 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 55.04886% with 138 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.16%. Comparing base (2f91d55) to head (c2626e7).
⚠️ Report is 137 commits behind head on main.

Files with missing lines Patch % Lines
...amstobigquery/BigtableChangeStreamsToBigQuery.java 0.00% 63 Missing ⚠️
...tes/bigtablechangestreamstobigquery/model/Mod.java 0.00% 30 Missing ⚠️
...tobigquery/schemautils/ProtoDecodeTransformer.java 63.46% 17 Missing and 2 partials ⚠️
...bigquery/schemautils/ValueTransformerRegistry.java 69.38% 8 Missing and 7 partials ⚠️
...ery/schemautils/BigEndianTimestampTransformer.java 61.53% 3 Missing and 2 partials ⚠️
...stobigquery/schemautils/Utf8BoundedAppendable.java 86.95% 0 Missing and 3 partials ⚠️
...gestreamstobigquery/schemautils/BigQueryUtils.java 0.00% 2 Missing ⚠️
...treamstobigquery/schemautils/ValueTransformer.java 87.50% 0 Missing and 1 partial ⚠️

❌ Your patch check has failed because the patch coverage (55.04%) 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    #3572      +/-   ##
============================================
+ Coverage     52.12%   53.16%   +1.03%     
- Complexity     5644     6089     +445     
============================================
  Files          1040     1078      +38     
  Lines         63118    65222    +2104     
  Branches       6922     7223     +301     
============================================
+ Hits          32903    34674    +1771     
- Misses        27981    28220     +239     
- Partials       2234     2328      +94     
Components Coverage Δ
spanner-templates 72.91% <ø> (+0.82%) ⬆️
spanner-import-export 68.64% <ø> (-0.11%) ⬇️
spanner-live-forward-migration 81.00% <ø> (+0.61%) ⬆️
spanner-live-reverse-replication 77.40% <ø> (-0.39%) ⬇️
spanner-bulk-migration 91.14% <ø> (+1.95%) ⬆️
gcs-spanner-dv 85.83% <ø> (+0.48%) ⬆️
Files with missing lines Coverage Δ
...echangestreamstobigquery/model/OversizedValue.java 100.00% <100.00%> (ø)
...gquery/schemautils/OversizedValueDlqSanitizer.java 100.00% <100.00%> (ø)
...streamstobigquery/schemautils/TransformResult.java 100.00% <100.00%> (ø)
...treamstobigquery/schemautils/ValueTransformer.java 87.50% <87.50%> (ø)
...gestreamstobigquery/schemautils/BigQueryUtils.java 0.00% <0.00%> (ø)
...stobigquery/schemautils/Utf8BoundedAppendable.java 86.95% <86.95%> (ø)
...ery/schemautils/BigEndianTimestampTransformer.java 61.53% <61.53%> (ø)
...bigquery/schemautils/ValueTransformerRegistry.java 69.38% <69.38%> (ø)
...tobigquery/schemautils/ProtoDecodeTransformer.java 63.46% <63.46%> (ø)
...tes/bigtablechangestreamstobigquery/model/Mod.java 0.00% <0.00%> (ø)
... and 1 more

... and 96 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.

@stankiewicz stankiewicz 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.

great contribution! Few comments added

@Default.String("")
String getFullProtoMessageName();

void setFullProtoMessageName(String value);

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 for being consistent with src/main/java/com/google/cloud/teleport/v2/templates/PubsubProtoToBigQuery.java

+ "(8-byte big-endian unsigned 64-bit integer as Unix epoch milliseconds, "
+ "converted to a timestamp string).")
@Default.String("")
String getColumnTransforms();

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.

There is writeNumericTimestamps that converts int64 to timestamp for limited amount of columns. In your change, you are not only converting to some format (String in this case), but you also change endianness. Why not to timestamp type? why not to little endian uint? This looks like UDF to me with very limited scope that would require releasing a template whenever you want to add new transform.
Would JS UDF be good alternative?

return null;
}
try {
long millis = ByteBuffer.wrap(bytes).getLong();

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.

add .order(ByteOrder.BIG_ENDIAN) to make it explicit and make code self documenting.

/**
* Parses a comma-separated transform configuration string.
*
* @param config format: "family:column:TYPE,family2:column2:TYPE2"

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.

TYPE is confusing, TRANSFORM_TYPE is used in other parts of javadoc.

}

Map<String, Map<String, ValueTransformer>> transformersByFamily = new HashMap<>();
for (String entry : config.split(",")) {

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.

column name can be any value, can contain comma. Maybe state somewhere about such limitation.

Comment on lines +104 to +115
private static ValueTransformer createTransformer(String type) {
switch (type) {
case "BIG_ENDIAN_UINT64_TIMESTAMP_MS":
return new BigEndianTimestampTransformer();
default:
throw new IllegalArgumentException(
"Unknown transform type '"
+ type
+ "'. Supported types: BIG_ENDIAN_UINT64_TIMESTAMP_MS");
}
}
}

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.

while few transformers look small, it's cleaner and more efficient to reuse them. tranformers are stateless and thread safe (maybe worth writing this somewhere), you can implement memoization strategy:

private static final Map<String, ValueTransformer> TRANSFORMER_CACHE = new HashMap<>();

private static ValueTransformer createTransformer(String type) {
    return TRANSFORMER_CACHE.computeIfAbsent(type, t -> {
        switch (t) {
            case "BIG_ENDIAN_UINT64_TIMESTAMP_MS":
                return new BigEndianTimestampTransformer();
            default:
                throw new IllegalArgumentException("Unknown transform type '" + t + "'");
        }
    });
}

boolean hasProtoColumn = !StringUtils.isBlank(options.getProtoColumn());

if (hasProtoSchema || hasProtoMessage || hasProtoColumnFamily || hasProtoColumn) {
if (!hasProtoSchema || !hasProtoMessage || !hasProtoColumnFamily || !hasProtoColumn) {

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.

difficult to read.. maybe:

boolean anyProtoSet = hasProtoSchema || hasProtoMessage || hasProtoColumnFamily || hasProtoColumn;
boolean allProtoSet = hasProtoSchema && hasProtoMessage && hasProtoColumnFamily && hasProtoColumn;

if (anyProtoSet && !allProtoSet) { throw new IllegalArgumentException( [..]

"When using protobuf decoding, all of protoSchemaPath, fullProtoMessageName, "
+ "protoColumnFamily, and protoColumn must be specified.");
}
protoDecoder =

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.

ProtoDecoder protoDecoder = null;
    if (allProtoSet) { protoDecoder = new ProtoDecoder( [..]

@TemplateParameter.Text(
order = 14,
optional = true,
description = "Column qualifier containing proto values",

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.

Grouping together multiple data into single proto column is a best practice, but it's not always the case, what if you have second proto in other column family or another column.

@stankiewicz
stankiewicz requested a review from a team April 7, 2026 13:09
@stankiewicz

stankiewicz commented Apr 7, 2026 •

Copy link
Copy Markdown
Contributor

PubSub Proto to BQ has single proto so it's ok that config is only for single proto message. In BT there is a limit of 10 schema bundles per table, which suggest that there may be multiple proto types used. Eventually you may end up creating another transform_type: foo:bar:PROTO_CAST(package.name.OuterMessage.InnerMessage) and slightly more complex cache.

@stankiewicz

Copy link
Copy Markdown
Contributor

Let me know when it's ready.

@MattiasMTS

Copy link
Copy Markdown
Author

Let me know when it's ready.

yes absolutely I'll let you know, boss! It's not ready yet. Sorry, been swamped in other stuff 😿 I'll try to focus getting this in shortly with high quality.

@MattiasMTS
MattiasMTS force-pushed the ms/bt-bq-proto branch 2 times, most recently from e22bd73 to c2626e7 Compare April 27, 2026 10:41
@MattiasMTS
MattiasMTS requested a review from stankiewicz April 27, 2026 10:48
@MattiasMTS

Copy link
Copy Markdown
Author

@stankiewicz should be ready for review now. It grew a bit - let me know if you want me to split this into stacked PRs instead

static final String REASON = "reason";
static final String REASON_VALUE = "decoded_value_exceeds_max_bytes";

private static final ThreadLocal<Gson> GSON = ThreadLocal.withInitial(Gson::new);

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.

Gson instances are natively thread-safe and intended to be shared concurrently across threads. Using ThreadLocal consumes unnecessary memory and initialization time per thread without any benefit.

why not static constant?


if (maxBytes > 0 && rawBytes > maxBytes) {
return TransformResult.oversized(rawBytes, rawBytes);
}

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.

just to note down on this cheap pre-check. if a protobuf payload contains a large number of unknown fields, those fields take up space in the rawBytes wire format but are subsequently dropped during JsonFormat.printer() rendering. Thus, a 10MB raw protobuf message might result in a 1MB JSON string. This pre-check would falsely reject the message if maxBytes was set to 8MB.
It is acceptable to leave it as-is, but the tradeoff should be explicitly understood.

@stankiewicz

Copy link
Copy Markdown
Contributor

/gemini review

@stankiewicz

Copy link
Copy Markdown
Contributor

fix spotless :)

@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 column value transformations, including BigEndian timestamps and Protobuf decoding, to the Bigtable Change Streams to BigQuery template. It also adds a mechanism to route oversized decoded values to a severe Dead Letter Queue to prevent pipeline stalls. The review feedback points out a serialization issue in ProtoDecodeTransformer that could cause a NullPointerException and suggests an optimization in Mod.java to avoid unnecessary memory allocations by checking raw value sizes before processing.

private final String fullMessageName;
private final boolean preserveFieldNames;

private volatile boolean initialized = false;

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

The initialized flag should be marked as transient. Currently, because it is not transient, if the transformer instance is serialized and then deserialized (which can happen in a distributed Beam pipeline), initialized will remain true while the transient fields descriptor and printer will be reset to null. This would cause ensureInitialized() to skip re-initialization on the worker, leading to a NullPointerException when the transformer is used.

Suggested change
private volatile boolean initialized = false;
private transient volatile boolean initialized = false;

Comment on lines +136 to +137
byte[] valueBytes = setCell.getValue().toByteArray();
result = transformer.transformBounded(valueBytes, maxDecodedValueBytes);

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

To improve efficiency and avoid unnecessary memory allocation, consider checking if the raw cell value size already exceeds maxDecodedValueBytes before calling toByteArray(). Since maxDecodedValueBytes is the limit for the decoded JSON string, any raw value larger than this limit will certainly exceed it after transformation (as JSON is typically more verbose than the protobuf wire format).

        if (maxDecodedValueBytes > 0 && setCell.getValue().size() > maxDecodedValueBytes) {
          result = TransformResult.oversized(setCell.getValue().size(), setCell.getValue().size());
        } else {
          byte[] valueBytes = setCell.getValue().toByteArray();
          result = transformer.transformBounded(valueBytes, maxDecodedValueBytes);
        }


/** Tests for {@link ValueTransformerRegistry#transformBounded}. */
@RunWith(JUnit4.class)
public class ValueTransformerRegistryTest {

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.

missing some negative tests

Comment on lines +174 to +176
if (type.startsWith("PROTO_DECODE(") && type.endsWith(")")) {
String messageName = type.substring("PROTO_DECODE(".length(), type.length() - 1);
if (messageName.isEmpty()) {

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.

maybe regex?

    Pattern.compile("PROTO_DECODE\\s*\\(\\s*(?<messageName>[^\\s\\)]+)\\s*\\)");

// Inside createTransformer method
Matcher matcher = PROTO_PATTERN.matcher(type.trim());
if (matcher.matches()) {
  String messageName = matcher.group("messageName");```

+ "BIG_ENDIAN_TIMESTAMP (interprets 8-byte big-endian values as Unix epoch millis), "
+ "PROTO_DECODE(package.MessageName) (decodes protobuf-encoded values to JSON; "
+ "requires protoSchemaPath). For complex transformations, consider using a "
+ "JavaScript UDF. Note that column qualifiers containing commas are not supported "

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.

there is no Javascript UDF in this template.

Comment on lines +127 to +143
if (transformerRegistry == null) {
result = TransformResult.noTransformer();
} else {
String qualifierStr = setCell.getQualifier().toStringUtf8();
ValueTransformer transformer =
transformerRegistry.getTransformer(setCell.getFamilyName(), qualifierStr);
if (transformer == null) {
result = TransformResult.noTransformer();
} else {
byte[] valueBytes = setCell.getValue().toByteArray();
result = transformer.transformBounded(valueBytes, maxDecodedValueBytes);
}
}

if (result.status() == TransformResult.Status.OVERSIZED) {
return new SetCellBuildResult(null, result);
}

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.

Determine TransformResult early with simplified logic

  TransformResult result = TransformResult.noTransformer();
  if (transformerRegistry != null) {
    String qualifierStr = setCell.getQualifier().toStringUtf8();
    ValueTransformer transformer = 
        transformerRegistry.getTransformer(setCell.getFamilyName(), qualifierStr);
    
    if (transformer != null) {
      byte[] valueBytes = setCell.getValue().toByteArray();
      result = transformer.transformBounded(valueBytes, maxDecodedValueBytes);
    }
  }```

// Exact for SUCCESS/OVERSIZED, 0 otherwise.
private final long decodedBytes;

private TransformResult(Status status, @Nullable String value, long rawBytes, long decodedBytes) {

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.

naming - rawBytes -> rawBytesCount ? Length?

TransformResult result = buildResult.transformResult();
if (result.status() == TransformResult.Status.SUCCESS) {
decodedValueBytesDistribution.update(result.decodedBytes());
} else if (result.status() == TransformResult.Status.OVERSIZED) {

@stankiewicz stankiewicz Apr 29, 2026 •

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.

what about. DECODE_ERROR

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.

The parser currently relies on string splitting. You need tests to ensure it doesn't crash or behave unpredictably when given:

  • Missing Parts: e.g., family:qualifier (missing the transform type).
  • Extra Colons: e.g., family:qualifier:type:extra.
  • Empty Entries: e.g., family:qualifier:TYPE,,next:col:TYPE (double commas).
  • Invalid Types: e.g., family:qualifier:NOT_A_REAL_TRANSFORM

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.

PROTO_DECODE missing tests - this is a complex transformation, it is the most likely to be misconfigured - make sure that if fails correctly for :

  • Missing Message Name: PROTO_DECODE() or PROTO_DECODE.
  • Invalid Message Name: PROTO_DECODE(nonexistent.Message).
  • Schema Issues: Using PROTO_DECODE but providing a null or empty protoSchemaPath.
  • Corrupt Descriptor: Testing what happens if the .pb file at the protoSchemaPath is malformed or not a valid FileDescriptorSet.

@@ -33,6 +33,10 @@ on [Metadata Annotations](https://github.com/GoogleCloudPlatform/DataflowTemplat
* **bigQueryChangelogTablePartitionExpirationMs**: Sets the changelog table partition expiration time, in milliseconds. When set to `true`, partitions older than the specified number of milliseconds are deleted. By default, no expiration is set.
* **bigQueryChangelogTableFieldsToIgnore**: A comma-separated list of the changelog columns that, when specified, aren't created and populated. Use one of the following supported values: `is_gc`, `source_instance`, `source_cluster`, `source_table`, `tiebreaker`, or `big_query_commit_timestamp`. By default, all columns are populated.
* **dlqDirectory**: The directory to use for the dead-letter queue. Records that fail to be processed are stored in this directory. The default is a directory under the Dataflow job's temp location. In most cases, you can use the default path.
* **columnTransforms**: A comma-separated list of column value transforms. Each entry has the format column_family:column_qualifier:TRANSFORM_TYPE. Supported TRANSFORM_TYPE values: BIG_ENDIAN_TIMESTAMP (interprets 8-byte big-endian values as Unix epoch millis), PROTO_DECODE(package.MessageName) (decodes protobuf-encoded values to JSON; requires protoSchemaPath). For complex transformations, consider using a JavaScript UDF. Note that column qualifiers containing commas are not supported since comma is used as the entry delimiter. Defaults to empty.

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.

JavaScript UDF is not available for this template

@derrickaw

Copy link
Copy Markdown
Contributor

Hi @MattiasMTS, friendly ping. Thanks

Adds protobuf decoding support to the Bigtable Change Streams to BigQuery
template. Cell values matching a registered transform are decoded into
JSON before being written to BigQuery.

- New columnTransforms option: comma-separated list of
  family:qualifier:TRANSFORM_TYPE entries. Supported types:
    - PROTO_DECODE(fully.qualified.MessageName): decodes proto bytes to
      JSON via JsonFormat.printer, with optional preserveProtoFieldNames.
    - BIG_ENDIAN_TIMESTAMP: decodes an 8-byte big-endian uint64 cell
      value to an ISO-8601 timestamp string.
- protoSchemaPath: GCS path to a serialized FileDescriptorSet loaded by
  ProtoDecodeTransformer on worker startup.
- Decoded output is capped at WINDMILL_ELEMENT_LIMIT_BYTES - 1 MiB
  (~82.8 MiB) via Utf8BoundedAppendable, which counts exact UTF-8 bytes
  and aborts mid-decode when the bound is crossed. Cells whose decoded
  form would exceed the bound are routed to the existing severe DLQ as
  OversizedValue records (serialized by OversizedValueDlqSanitizer) via
  a new TupleTag side output, preventing Dataflow Streaming Engine
  per-element commit failures on Bigtable cells approaching the 100 MB
  cell limit.
- BigQuery-level size rejections (Storage Write API: 10 MB per row;
  legacy batch load: 100 MB per row) continue to flow through the
  existing writeResult.getFailedStorageApiInserts() path, sanitized by
  BigQueryDeadLetterQueueSanitizer as today.
Unit tests for the new transformers, bounded-decode plumbing, and DLQ
sanitizer; integration test covering the full pipeline with a proto
schema and the oversized-value DLQ route.
- ProtoDecodeTransformer: mark `initialized` as transient so the
  initialized flag is reset after Beam serializes the DoFn for workers,
  preventing NPE when transient `descriptor`/`printer` would otherwise
  short-circuit through a stale `initialized=true`.
- OversizedValueDlqSanitizer: replace the per-thread Gson with a single
  static instance; Gson is documented thread-safe.
- Mod.buildSetCell: pre-check `setCell.getValue().size()` against the
  decoded-value budget before calling `toByteArray()`, avoiding a copy
  of multi-MB values that would be rejected anyway. The defensive
  pre-check inside ProtoDecodeTransformer.transformBounded is preserved
  as defense-in-depth and remains unit-tested.
- BigtableChangeStreamsToBigQuery: emit a counter and warning log when
  a SET_CELL transform reports DECODE_ERROR so silent decode failures
  are observable; existing fallback to raw bytes is unchanged.
- Delete BigEndianTimestampTransformer and its dispatch case in
  ValueTransformerRegistry; the only supported transform is now
  PROTO_DECODE.
- Switch PROTO_DECODE parsing to a compiled regex with a named
  capture group, and reject malformed PROTO_DECODE input (missing
  closing paren, blank message name, etc.) with a clear message.
- Rename TransformResult.rawBytes -> rawBytesLength to make it
  obvious it is a count, not the original byte[]; update all callers
  including BigtableChangeStreamsToBigQuery and tests.
- Flatten Mod.buildSetCell branching by initializing result to
  TransformResult.noTransformer() and only descending when a
  transformer is registered.
- Drop the JS UDF reference and BIG_ENDIAN_TIMESTAMP entry from the
  columnTransforms helpText and the generated README parameter list.
- Document on the ValueTransformer interface that implementations
  must be stateless and thread-safe.
- Drop ValueTransformerRegistryTest BigEndian-specific tests and
  rewrite the non-proto OVERSIZED case to use a small inline
  ValueTransformer; drop the BigEndian column-transform IT.
- ValueTransformerRegistryTest: add parse-time negative tests for
  missing transform type, double-comma empty entries (skipped
  gracefully), unknown transform types, malformed PROTO_DECODE
  syntax (no parens, empty/blank message name) and missing
  protoSchemaPath.
- ProtoDecodeTransformerTest: add negatives for nonexistent message
  name, malformed FileDescriptorSet bytes, and null/empty schema
  path. Validate non-null/non-empty constructor inputs in
  ProtoDecodeTransformer so the schema-path negative test fails
  fast at construction with a clear message instead of NPE during
  lazy init.
Transformer lookup, decode-error logs and oversized DLQ records now
decode the qualifier and row key with bigtableChangeStreamCharset
instead of always UTF-8, matching how the rest of the template renders
them.
…heck

- Add a registry test asserting that a trailing segment after the
  transform type is rejected as an unknown TRANSFORM_TYPE.
- Rename the double-comma test to reflect that empty entries are
  skipped, not rejected.
- Document that the raw-size pre-check counts unknown fields that the
  JSON printer drops, so such payloads can be rejected even when their
  JSON would fit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

addition New feature or request size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants