Skip to content

feat(bigquery): implement a separate SyncFlow branch for query-based CDC - #4727

Merged
dtunikov merged 123 commits into
mainfrom
bq/isolate-tables-flow
Sep 4, 2026
Merged

feat(bigquery): implement a separate SyncFlow branch for query-based CDC#4727
dtunikov merged 123 commits into
mainfrom
bq/isolate-tables-flow

Conversation

@dtunikov

@dtunikov dtunikov commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Problem

We need to support CDC for DWH sources such as BigQuery/Snowflake. These sources don't have global wal/binlog/change-stream concept. There is no channel we can listen to for events (inserts, updates, deletes).
For such sources we are going to implement query-based replication. Which, essentially, just queries the tables every SyncInterval seconds, e.g. runs similar query every N secs:
SELECT ... FROM table WHERE col > lower AND col <= upper

We could keep using existing SyncFlow PullRecords function to query the source. But it means that all mirror's tables would be coupled:

  • one CDC records stream for all tables
  • one table's failure blocks other tables replication
  • one table can slow down replication of other tables (if the source is slow or a downstream MV is slow)

Solution

This PR introduces a new SyncFlow branch for query-based CDC sources (for now just BigQuery).

It implements the following requirements:

  • Back-pressure must be applied per table
  • Tables must be queried in parallel (a single slow table can't affect others)
  • Errors must be reported via LogFlowError, but shouldn't block other tables
  • No raw table hop. Batches are ingested directly from s3/gcs into the final destination table

Currently, only BigQuery source connector supports new interfaces. In the future this workflow can be used for other query-based CDC connectors as well (e.g. other data warehouses or even OTLP databases in cases when a customer doesn't want to rely on wal/binlog replication).

Tests

Added e2e tests that cover the following scenarios:

  • single table's failure doesn't block other tables
  • back-pressure kicks in only for the affected table, so it doesn't block sibling tables
  • run cdc, pause pipe, remove table from the mirror, unpause -> replication still works

Workflow diagram

flowchart TD
    SF["SyncFlow activity<br/>(flowable.go)"]
    SF -->|isIsolatedTableCDCPath?<br/>BQ source + ClickHouse dest| DEC{branch}
    DEC -->|false| OLD["pullAndSync / pullAndSyncPg<br/>+ shared CDCStream<br/>+ normalizeLoop (unchanged path)"]
    DEC -->|true| SFI["syncFlowIsolatedTables<br/>(flowable_isolated_cdc.go)"]

    SFI --> SETUP["setup: idleTimeout, channelBufferSize,<br/>pullSem(parallelism), normBufferSize,<br/>PruneTableReplicationState, batchIDCounter"]
    SETUP --> SPAWN["for each TableMapping:<br/>spawn 1 sync loop + 1 normalize loop<br/>paired via per-table LastChan(normRequests, normResponses)"]

    subgraph PERTABLE["per source table (independent goroutines)"]
        direction TB

        subgraph SYNCLOOP["isolatedTablePullSyncLoop"]
            direction TB
            S1["GetTableReplicationState"] --> S2{"synced - normalized<br/>>= normBufferSize?"}
            S2 -->|yes: backpressured| S2W["wait on normResponses<br/>(this table only)"] --> S1
            S2 -->|no| S3{"poll due?<br/>(idleTimeout)"}
            S3 -->|not yet| S3W["waitOrDone"] --> S1
            S3 -->|due| S4["acquire pullSem"]
            S4 --> S5["RecordTableReplicationAttempt"]
            S5 --> S6["errgroup:<br/>PullTableRecords -> TableCDCStream<br/>SyncTableCDC drains stream"]
            S6 --> S7["release pullSem"]
            S7 --> S8{"rows > 0?"}
            S8 -->|no| S9["RecordTableReplicationSync<br/>(cursor only)"] --> S1
            S8 -->|yes| S10["RecordTableReplicationSync<br/>(cursor + synced_batch_id++)"]
            S10 --> S11["recordIsolatedTableBatch<br/>(cdc_batches/cdc_flows monitoring)"]
            S11 --> S12["normRequests.Update(batchID)"]
            S12 --> S1
        end

        subgraph NORMLOOP["isolatedTableNormalizeLoop"]
            direction TB
            N1["resume: normResponses.Update(normalizedBatchID);<br/>if synced>normalized, normRequests.Update(synced)"] --> N2{"normRequests > normResponses?"}
            N2 -->|no| N2W["wait on normRequests"] --> N2
            N2 -->|yes| N3["NormalizeTableCDC:<br/>for batch in (lastNorm, reqBatch]:<br/>GetTableAvroStage -> INSERT...SELECT<br/>into final table -> DeleteTableAvroStage"]
            N3 --> N4{"error?"}
            N4 -->|yes| N4B["alert once + backoff"] --> N2
            N4 -->|no| N5["RecordTableReplicationNormalize"]
            N5 --> N6["normResponses.Update(batchID)<br/>(releases sync loop's backpressure wait)"]
            N6 --> N2
        end

        S6 -. "PullTableRecords" .-> BQ[("BigQuery<br/>connector")]
        S6 -. "SyncTableCDC:<br/>typed Avro -> S3/GCS<br/>+ SetTableAvroStage" .-> CH1[("ClickHouse<br/>connector")]
        N3 -. "NormalizeTableCDC" .-> CH2[("ClickHouse<br/>final table")]

        S12 -. signal .-> N2
        N6 -. signal .-> S2
    end

    SPAWN --> SYNCLOOP
    SPAWN --> NORMLOOP

    CAT1[("catalog:<br/>cdc_table_replication_state<br/>(cursor_text, last_attempt_at,<br/>synced_batch_id, normalized_batch_id)")]
    CAT2[("catalog:<br/>cdc_table_avro_stage<br/>(flow, table, batch_id -> avro_file)")]
    S1 -.-> CAT1
    S10 -.-> CAT1
    N1 -.-> CAT1
    N5 -.-> CAT1
    S6 -.-> CAT2
    N3 -.-> CAT2
Loading

Resolves: DBI-1045

dtunikov and others added 30 commits August 17, 2026 11:21
Capture T (BigQuery's own CURRENT_TIMESTAMP(), not local wall-clock) once per
ExportTxSnapshot call and append FOR SYSTEM_TIME AS OF TIMESTAMP('<T> UTC') to
every table's EXPORT DATA statement, so all tables in a snapshot read a
consistent point in time.

Persist T as the initial CDC checkpoint via SetLastOffset, but from
ExportTxSnapshot itself rather than SetupReplication as the plan originally
sketched: SnapshotFlowWorkflow only calls ExportTxSnapshot when
InitialSnapshotOnly is true, and only calls SetupReplication when it's false
(cloneTablesWithSlot, or the no-snapshot CDC-only branch) - the two are
mutually exclusive per run, so T is never in scope inside SetupReplication.
Gating the write on !InitialSnapshotOnly here is therefore a forward-looking
no-op until a later chunk changes how BigQuery mirrors that continue into CDC
get their initial load wired up; documented in code at the capture site.

Refactored the SQL-building half of bigQueryExportQueryStatement into a pure
buildBigQueryExportSQL taking an already-resolved schema, so it's unit
testable without a live BigQuery client (mirrors the existing
bigQuerySchemaToQRecordSchema/datasetTable test patterns in this package).
…t code

ExportTxSnapshot only ever runs on SnapshotFlowWorkflow's pure
snapshot-only branch (InitialSnapshotOnly && DoInitialSnapshot) - the
continue-to-CDC and CDC-only branches call SetupReplication instead
and never touch ExportTxSnapshot. So the checkpoint-persist code added
there, gated on !InitialSnapshotOnly, could never actually run; removed.

SetupReplication now does what MySQL's does: capture a starting
position (BigQuery's current timestamp T, no replication slot to
open) and persist it as the initial CDC checkpoint. Unlike MySQL,
BigQuery's initial load reads pre-exported Parquet from GCS rather
than querying the live table, so when the mirror wants an initial load
(req.DoInitialSnapshot), this also runs that export as of T - the
per-table export-job loop is factored out of ExportTxSnapshot into a
shared exportTablesAsOf helper so both callers use it. Returns a
zero-value SetupReplicationResult (no slot/snapshot name), same as
MySQL: cloneTablesWithSlot falls back to the mirror's configured
SnapshotStagingPath when no override is given, which is exactly where
this export writes.
Newline/tab padding made the query harder to read; collapse to single-line format string.
Introduces a connector-specific mirror-config extension point on
FlowConnectionConfigs/FlowConnectionConfigsCore, mirroring how Peer
already does this for peer-level config. BigqueryCdcConfig (with a
cdc_mode of APPENDS or CHANGES) is the first variant.

No converter work is needed: flow/proto_conversions copies between the
two messages by field number, so a oneof - whose wrapper types are
scoped per parent message - is carried over without special-casing.

No behavior change yet - BigqueryCdcConfig is not read anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
start_peer_flow_job builds FlowConnectionConfigs as a fully exhaustive
struct literal (no ..Default::default()), so it needs every field set
explicitly. The new source_connector_config oneof broke this build;
nexus doesn't set any connector-specific mirror config today, so None
is correct here.
Copying by field number handles the oneof without special-casing, but the
wrapper types are scoped per parent message, so assert the variant is
rebuilt against the destination's own type in both directions - and that an
unset oneof stays unset rather than spuriously populating a variant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ecting

Snapshot-only mirrors skip these checks (mirrors MySQL's ValidateMirrorSource
pattern). CHANGES mode needs a real PK constraint plus enable_change_history
on the source table; APPENDS mode needs an explicit MergeTree destination
engine when there's no PK, since ORDER BY tuple() on a keyless
ReplacingMergeTree collapses the table on writes.
…GE_TREE

CH_ENGINE_REPLICATED_REPLACING_MERGE_TREE is the same collapsing dedup
engine as the plain variant, just wrapped for replication (see how
normalize.go's engine switch groups the two under one case) - a
keyless table hits the same ORDER BY tuple() collapse either way, so
the APPENDS-mode keyless-engine check needs to catch both.
Gives BigQueryConnector real CDCPullConnectorCore bodies (SetupReplConn,
UpdateReplStateLastOffset, PullFlowCleanup, EnsurePullability) and a real
PullRecords: self-paced polling of APPENDS() per mapped table over
(checkpoint, upper], converting rows to InsertRecords via a new BigQuery
value -> QValue converter, advancing the checkpoint text once the window
closes. No delete/insert pairing yet -- that's CHANGES mode, next chunk.
CHANGES() reports an UPDATE as a delete+insert pair sharing the same PK
and _CHANGE_TIMESTAMP, so PullRecords needs to pair those back into one
UpdateRecord instead of emitting two records -- otherwise every update
on a CHANGES-mode mirror would be replicated as a delete followed by an
unrelated insert. Reads cdc_mode once per PullRecords call (it's a
per-mirror setting) to pick APPENDS or CHANGES per the "Decisions
locked in" plan.
Rewrites the now-stale Test_BigQuery_Source_CDC_Not_Supported, which
asserted CDC gets rejected - chunk 3 replaced that with mode-specific
validation. Adds e2e coverage for snapshot->CDC handoff, APPENDS
insert-only polling, CHANGES insert/update/delete pairing, and
pause/resume from the persisted checkpoint; none of this has been run
against a live BigQuery instance yet.
bigquery.go holds the shared BigQueryConnector struct used by both
source and destination code, so source-only changes there were
wrongly picked up by the deprecated-connector labeler.
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Flaky Test Detected

Analysis: The flow/e2e package was killed by the global 20m -timeout 1200s cap (no test hung — all in-flight tests were only 9–47s old, and there were zero assertion failures), while the other two matrix legs passed on the same commit at 933s/998s, showing this slowest leg simply lacks headroom under the timeout.
Confidence: 0.85

✅ Automatically retrying the workflow

View workflow run

@dtunikov
dtunikov requested a review from ilidemi September 1, 2026 05:54
Comment thread flow/connectors/external_metadata/table_replication_state.go Outdated
Comment thread flow/connectors/external_metadata/query_cdc_replication_state.go
Comment thread flow/connectors/utils/stream.go Outdated
Comment thread flow/connectors/external_metadata/store.go Outdated
Comment thread flow/activities/flowable_isolated_cdc.go Outdated
Comment thread flow/activities/flowable.go Outdated
Comment thread flow/activities/flowable.go Outdated
Comment thread flow/connectors/bigquery/cdc.go

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

general style nit: there's a mixed terminology usage for "isolated cdc" and "query cdc" (as well as both "cdc table" and "table cdc") across variable name, method name, table definitions, etc. would be good to introduce a single terminology/concept for the query-based cdc

@dtunikov
dtunikov requested a review from jgao54 September 2, 2026 14:31

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

Looks great overall.

Comment thread flow/connectors/utils/monitoring/monitoring.go Outdated
Comment thread flow/connectors/utils/stream.go Outdated
Comment thread flow/activities/flowable.go Outdated
Comment thread flow/model/model.go
Comment thread nexus/catalog/migrations/V56__query_cdc_replication_state.sql
Comment thread flow/connectors/clickhouse/table_function.go Outdated
@jgao54

jgao54 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

the catalog migration error is expected, i forgot to mention that they just needed to be added to goose https://github.com/PeerDB-io/peerdb/blob/e9d5da54c91c0a5dca73205b120a101129908124/flow/db/README.md

i plan to remove the refinery migration scripts after the next release (or at least freeze them in some way) so going forward we'd only need to modify goose-side; but this is dependent on a release

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

❌ Test Failure

Analysis: Not flaky — PR #4727 adds migration 56 to flow/db/migrations without the matching nexus/catalog/migrations file, deterministically failing TestMigrationVersions and TestGooseBootstrapFromRefinery on all three matrix legs, plus its own new BigQuery backpressure e2e test times out on the assertion for the behavior it introduces.
Confidence: 0.95

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

View workflow run

Comment thread flow/activities/flowable_query_cdc.go Outdated
dtunikov and others added 4 commits September 4, 2026 10:31
…ream (#4768)

Follow-up refactor on top of `bq/isolate-tables-flow`. The shared-stream
path behaves the same as before. The isolated path gets two small
behavior fixes.

### What changed

`SyncFlow` now does only the setup both CDC paths need: cancellable
context, flow name and operation context, worker-stop handling,
destination peer type, source connector plus `SetupReplConn`. It then
hands off to `syncFlowIsolatedTables` or the new `syncFlowSharedStream`.

Resolves: DBI-1045

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dtunikov
dtunikov merged commit ec710b4 into main Sep 4, 2026
30 checks passed
@dtunikov
dtunikov deleted the bq/isolate-tables-flow branch September 4, 2026 09:36
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.

6 participants