Skip to content

feat(bigquery): implement watermark column query based replication for bigquery sources - #4739

Open
dtunikov wants to merge 18 commits into
mainfrom
bq/watermark-column-replication
Open

feat(bigquery): implement watermark column query based replication for bigquery sources#4739
dtunikov wants to merge 18 commits into
mainfrom
bq/watermark-column-replication

Conversation

@dtunikov

@dtunikov dtunikov commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

This PR implement the third BigQuery replication mode - query based replication based on user-specified watermark/cursor column (for example created_at).

  • Added a new field to TableMapping called watermark_column. It's a required field for BigQuery sources with query-based replication mode.
  • Implemented new pullTableQuery function in biguqery/cdc.go. It fetches data from the table using a simple SQL query like: SELECT ... FROM ... WHERE col > lower AND col <= upper.
  • Adjusted initial snapshot checkpoint calculation logic. For query-based CDC we use SELECT max(watermark_column) FROM table as a snapshot boundary and store it as an initial CDC checkpoint. So, in this mode we don't use FOR SYSTEM_TIME AS OF TIMESTAMP, instead we query: SELECT ... FROM ... WHERE col < max(watermark_col).

Resolves DBI-1056.

@dtunikov
dtunikov requested review from a team as code owners August 27, 2026 08:29
Comment thread protos/flow.proto Outdated
BigqueryCdcEventsFunction bigquery_cdc_events_function = 10;
// the column to use as a cursor for query-based CDC replication
// required if replication_mode is BIGQUERY_REPLICATION_MODE_QUERY
string watermark_column = 11;

@dtunikov dtunikov Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The plan is to have an option on the UI to configure watermark_column per table + to be able to type in a global watermark_column name (since in most cases the same column name is used for all tables, like created_at).
I think it can be handled completely on the UI and here we just get the column for every configured table. (e.g. we won't need to store global_watermark_column on the flow config lvl)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

another thing is that I decided not to add bigquery_ prefix to its name, because it might be used for other query-based CDC connectors in the future.

@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown

Code review

Two issues found. Checked for bugs and CLAUDE.md compliance.


1. tm can be nil and is dereferenced unconditionally — flow/connectors/bigquery/cdc.go L144-L171

tm stays nil when no entry in cfg.TableMappings matches req.SourceTableIdentifier, and every branch below dereferences it with raw field access — tm.WatermarkColumn (L164), tm.BigqueryCdcEventsFunction (L165, L167), and even the "unreachable" error string at L171. Raw field access on a nil protobuf pointer panics; the generated GetX() getters would not.

This is a regression: the pre-PR code read tableMapping.GetBigqueryCdcEventsFunction() into a plain enum value and fell through to the default: (APPENDS) arm when nothing matched.

It is reachable because cfg is re-fetched here from the catalog (internal.FetchConfigFromDB, L139) while req.SourceTableIdentifier comes from the workflow's SyncFlowOptions.TableMappings (flow/activities/flowable_isolated_cdc.go:240). Those two diverge when tables are added mid-mirror — cdc_flow.go appends AdditionalTables to SyncFlowOptions and only refreshes the catalog afterwards, via a best-effort activity that merely logs a warning on failure.

Suggested fix — add an explicit error after the lookup loop:

	if tm == nil {
		return model.PullTableRecordsResult{}, fmt.Errorf("no table mapping found for source table %s", req.SourceTableIdentifier)
	}

2. TIMESTAMP(<TIMESTAMP column>) has no matching GoogleSQL signature — flow/connectors/bigquery/cdc.go L553-L557

BigQuery's TIMESTAMP() is only defined for TIMESTAMP(string_expression[, tz]), TIMESTAMP(date_expression[, tz]), and TIMESTAMP(datetime_expression[, tz]). There is no identity overload, and GoogleSQL has no implicit coercion from TIMESTAMP to DATETIME/DATE/STRING, so TIMESTAMP(ts_col) fails at analysis time with No matching signature for function TIMESTAMP for argument types: TIMESTAMP.

Meanwhile source.go L86-L91 rejects any watermark column whose type is not bigquery.TimestampFieldType — so the only column type QUERY mode admits is exactly the one these queries cannot wrap. The two halves of the feature disagree; one of them has to change.

The same wrap appears in two more places:

Corroborating signals that the wrap is unintentional: the ORDER BY on cdc.go L556 uses the raw col while the WHERE wraps it; the pre-existing export code at qrep_object_pull.go L501 uses CAST(x AS TIMESTAMP) rather than TIMESTAMP(x); and the case civil.Date: branch in pullTableQuery is dead code under a TIMESTAMP-only validator.

Suggested fix: drop the TIMESTAMP() wrapper in all three places, since the column is already validated as TIMESTAMP — or, if DATE/DATETIME watermark columns are meant to be supported, relax source.go:88 and apply the conversion conditionally on the field type. Note that qrep_object_pull_test.go:88 currently pins the invalid shape in its expected string and would need updating alongside.

Separately, even with a valid overload, wrapping the watermark column in a function makes the predicate non-sargable and defeats partition pruning on what is typically the partitioning column — a real cost on every CDC poll and on the snapshot export.

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Deprecated destination connector

This issue or pull request relates to a deprecated destination connector (Snowflake, BigQuery, ElasticSearch, Kafka, Redpanda, Confluent, Azure Event Hubs, Google Pub/Sub, or S3).

These destinations are no longer actively maintained, but remain functional. We are unlikely to prioritize new work here.

Note: BigQuery is deprecated only as a destination — it remains a supported source.

If you depend on one of these connectors, we recommend:

  • Pin to a known-good PeerDB version so behavior stays stable.
  • Fork the repository if you need to carry your own changes.

See the deprecated connectors documentation for details and migration guidance.

@dtunikov
dtunikov force-pushed the bq/watermark-column-replication branch from d7a13e0 to 4aa256b Compare August 30, 2026 19:39
) (map[string]time.Time, error) {
checkpointByTable := make(map[string]time.Time, len(cfg.TableMappings))
for _, tableMapping := range cfg.TableMappings {
watermark, err := c.maxWatermarkValue(ctx, tableMapping.SourceTableIdentifier, tableMapping.GetWatermarkColumn())

@dtunikov dtunikov Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We could also use BQ current timestamp as an initial watermark value.
I don't have a strong preference here, both ways should work fine.

Comment thread protos/flow.proto
enum BigqueryCdcEventsFunction {
BIGQUERY_CDC_EVENTS_FUNCTION_APPENDS = 0;
BIGQUERY_CDC_EVENTS_FUNCTION_CHANGES = 1;
BIGQUERY_CDC_EVENTS_FUNCTION_UNSPECIFIED = 0;

@dtunikov dtunikov Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

since it's not used in production yet - it's safe to update these enums
would make validation more predictable in peerdb and clickpipes

Comment on lines +554 to +558
buildQueryModePullQuery := func(dsTable string, watermarkColumn string, exclude map[string]struct{}) string {
col := quotedIdentifier(watermarkColumn)
return fmt.Sprintf("SELECT *%s FROM %s WHERE TIMESTAMP(%s) > @start AND TIMESTAMP(%s) <= @end ORDER BY %s",
exceptClause(exclude), dsTable, col, col, col)
}

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.

nit: Could we maybe extra this to a package level function, then above for runPullQuery we can use a named type instead of the closure definition. Would also ensure that if we ever change the signature, we don't need to update it in multiple places.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

will do that in the follow-up PR where I replaced EXCEPT with explicit column selection set (since this code will change a bit there)

Comment thread flow/connectors/bigquery/cdc.go Outdated
@dtunikov
dtunikov force-pushed the bq/watermark-column-replication branch from 4955cc0 to c0f6c21 Compare September 2, 2026 10:27
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

❌ Test Failure

Analysis: Not flaky — the PR's newly added replication-mode validation rejects BIGQUERY_REPLICATION_MODE_UNSPECIFIED, deterministically breaking three Test_BigQuery_Source_CDC_Validation subtests (which pass a nil SourceConnectorConfig) identically across all three matrix jobs.
Confidence: 0.96

⚠️ This appears to be a real bug - manual intervention needed

View workflow run

@dtunikov
dtunikov force-pushed the bq/watermark-column-replication branch 2 times, most recently from 47774c4 to 08a8139 Compare September 3, 2026 11:13
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🔄 Flaky Test Detected

Analysis: TestPeerFlowE2ETestSuitePG_CH/Test_CTID_Inherited_Table hit the 60s "UNEXPECTED STATUS TIMEOUT STATUS_SNAPSHOT" poll cap in only the pg16 matrix job while passing on pg17 and pg18 with the same code, indicating a slow initial snapshot under CI load rather than a functional failure.
Confidence: 0.8

✅ Automatically retrying the workflow

View workflow run

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🔄 Flaky Test Detected

Analysis: Three TestApiPg subtests on a single matrix leg all hit the same 60s "UNEXPECTED STATUS TIMEOUT STATUS_SNAPSHOT" wait within a 5-second window while the identical wait helper passed in many other tests in the same run and on the other two matrix legs, indicating a transient snapshot/worker stall rather than a code defect.
Confidence: 0.85

✅ Automatically retrying the workflow

View workflow run

Base automatically changed from bq/isolate-tables-flow to main September 4, 2026 09:36
dtunikov and others added 6 commits September 4, 2026 11:36
Adding BIGQUERY_REPLICATION_MODE_UNSPECIFIED = 0 (and
BIGQUERY_CDC_EVENTS_FUNCTION_UNSPECIFIED = 0) moved EVENTS and APPENDS
off the proto zero value, so Test_BigQuery_Source_CDC_Validation's base
config, which relied on those defaults, started tripping the new
"invalid replication mode" guard in ValidateMirrorSource.

Set the replication mode and CDC events function explicitly on the base
config, restore it (rather than nil) after the QUERY subtest, and cover
the unspecified-mode rejection directly.

Also reject an unset replication mode in ValidateSourceCDC. The flow
connector already guards it, but the pkg is meant to be callable from
outside the flow module, and there it silently skipped every CDC check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The regenerated TableMapping interface has a required watermarkColumn
field, and TableMapRow derives from it, so both table-mapping object
literals in the mirror create handlers stopped type checking. Pass the
row's value through in reformattedTableMapping and default it to empty
when building rows from the source schema; there is no UI control for it
yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dtunikov
dtunikov force-pushed the bq/watermark-column-replication branch from 9955a78 to 183fbdc Compare September 4, 2026 09:36
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🔄 Flaky Test Detected

Analysis: Two MariaDB→ClickHouse e2e tests hit the 60s "UNEXPECTED STATUS TIMEOUT" wait in SetupCDCFlowStatusQuery (mirrors still in STATUS_SETUP/STATUS_SNAPSHOT) within ~22s of each other on one matrix leg, while the other two matrix legs passed on the same commit and the PR only touches BigQuery code — a transient CI resource stall rather than a real bug.
Confidence: 0.85

✅ Automatically retrying the workflow

View workflow run

Comment thread protos/flow.proto
BigqueryCdcEventsFunction bigquery_cdc_events_function = 10;
// the column to use as a cursor for query-based CDC replication
// required if replication_mode is BIGQUERY_REPLICATION_MODE_QUERY
string query_cdc_watermark_column = 11;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

no bigquery prefix since it will be used by other query-cdc connectors in the future

}

// GetTables returns information about the specified tables
func GetTables(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

if we encounter issues with datasets that have a lot of tables - we can consider using plain SELECT over bigquery system tables.

}

// ColumnInfo describes a single column of a BigQuery table.
type ColumnInfo struct {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

tried to move everything related to validation here so that it'd be re-usable from the ClickPipes

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.

for my own understanding: we'll also call this directly from discovery and that's why we moved this here?

"github.com/PeerDB-io/peerdb/flow/shared/exceptions"
)

func (c *BigQueryConnector) ValidateMirrorSource(ctx context.Context, cfg *protos.FlowConnectionConfigsCore) error {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

logic was moved to pkg so that it'd be easy to share with the ClickPipes
ValidateMirrorSource just builds bqvalidate.SourceConfig and passed it to pkg functions

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

Mostly looks good to me, just some clarifying questions. I'll look at the tests next!

from := fmt.Sprintf("%s FOR SYSTEM_TIME AS OF TIMESTAMP('%s UTC')", dsTable.stringQuoted(), boundLiteral)
if watermarkColumn != "" {
from = fmt.Sprintf("%s WHERE TIMESTAMP(%s) <= TIMESTAMP('%s UTC')",
dsTable.stringQuoted(), quotedIdentifier(watermarkColumn), boundLiteral)

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.

watermarkColumn should never be pre-quoted right?

}

// ColumnInfo describes a single column of a BigQuery table.
type ColumnInfo struct {

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.

for my own understanding: we'll also call this directly from discovery and that's why we moved this here?

_, err = it.Next()
if err != nil && !errors.Is(err, iterator.Done) {
return fmt.Errorf("failed to access staging bucket: %w", exceptions.NewBigQueryError(err))
tablesByKey, err := bqvalidate.ValidateSourceTables(ctx, sourceConfig)

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.

nit: maybe we can have a function in bqvalidate that calls both this and ValidateSourceCDC in the CDC case? Feels a little awkward to thread state from one call into another (tablesByKey).

// SELECT ... WHERE watermark_column > lower AND watermark_column <= upper scan,
// rather than APPENDS()/CHANGES(). The initial snapshot is bounded by the
// watermark column's max value at setup time instead of FOR SYSTEM_TIME AS OF.
func (s BigQueryClickhouseSuite) Test_BigQuery_CDC_Query_Mode() {

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.

nit: I see this is a convention in this file but usually we don't use underscore in test function names right? It'd just be TestBigQueryCDCQueryMode

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants