feat: Add BigQuery append-only history mode#875
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 494b203d33
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
8380eb5 to
9e43cd4
Compare
0dad4a4 to
a898626
Compare
a898626 to
59da97f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 22c5ad991a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Thanks for the PR! This is a welcome addition but given the complexity, we are not looking to add this at this point in time. |
|
Thanks for the review and the feedback! I understand the reasoning. I'll close this PR. Thanks again for taking the time to review it. |
What
Adds a BigQuery append-only history write mode for the BigQuery destination.
This PR depends on #874 for durable destination write stream state. It is currently a draft follow-up PR for #873.
User-facing behavior
This adds a BigQuery write mode that preserves source changes as append-only history rows instead of maintaining only the latest row state.
current_stateremains the default BigQuery write mode.append_onlycreates append-only BigQuery tables.write_modeto another is rejected, because the physical table schema and write behavior are different.BigQuery table shape
Append-only tables include the source columns plus flat Supabase ETL metadata columns:
_etl_uuid_etl_source_timestamp_etl_change_sequence_number_etl_change_type_etl_sort_keysThe metadata is intentionally flat rather than a nested
datastream_metadatastruct so it can be written through the Storage Write API path used by this implementation.Change event mapping
The append-only mode maps source events to
_etl_change_typevalues as follows:_etl_change_typeINSERTINSERTUPDATE-DELETEUPDATE-INSERTDELETEFor updates, the destination emits both before and after rows when the old image is available. Key-only old images are expanded into sparse before rows so primary-key changes can still be represented in history.
Write path and retry behavior
Append-only writes use BigQuery Storage Write API committed streams.
The implementation uses the durable stream state added in #874 to remember:
That lets retries and process restarts resume from the stored offset instead of generating a new stream position and duplicating rows.
The stored stream state is monotonic by committed-stream offset. Stale writers cannot move the offset backwards, and same-offset updates cannot move the sequence watermark backwards. Initial-copy sequence values are stable row identities rather than globally increasing CDC sequence values, so offset advancement is allowed even when the next copied row's sequence sorts lower than the previous copied row. Schema changes rotate to a fresh committed stream while preserving the sequence watermark. This keeps replay filtering intact if the process crashes around a schema change.
For BigQuery
ALREADY_EXISTSresponses, the committed stream path only treats the response as success for a retried append request. A first-attempt overlap is still returned as an error because batch boundaries may not match a previous append.Ordering and initial copy
Initial copy rows are emitted as
INSERTrows.For initial copy rows, the implementation builds a stable row identity from the source row and stores it in
_etl_uuid/_etl_change_sequence_numberusing a reserved copy sequence prefix plus the row hash. This makes the metadata deterministic for the same copied row, which is important if the copy has to be retried.Initial copy rows also get a dedicated low sort key prefix so they sort before CDC rows for the same source table, even when the initial copy timestamp is newer than an already captured CDC timestamp.
For example, a copied row and a later CDC row would sort like this:
_etl_change_type_etl_change_sequence_numberexample_etl_sort_keysexampleINSERT0000000000000000/0000000000000000/<row-hash>["0000000000000000", "0000000000000000/0000000000000000/<row-hash>", "copy/<sequence>/<row-hash>"]INSERT/UPDATE-*/DELETE0000000000000001/0000000000000002/0000000000000003["800000000000002a", "0000000000000001/0000000000000002", "0000000000000003"]The CDC sequence number is derived from the event sequence key and an internal ordinal. That gives consumers a deterministic replay order: first sort by
_etl_sort_keys, then interpret_etl_change_typeto reconstruct history.Schema changes
Append-only tables can evolve with source schema changes.
PR split
This PR is split with pattern 1: bottom-up.
Because this is a fork-to-upstream PR, GitHub cannot use the fork branch from #874 as the upstream base branch. Until #874 is merged, the normal GitHub diff for this PR still includes the #874 foundation commit.
For a focused review of only the append-only slice, use this compare view:
1neoneo3/etl@feature/bigquery-append-only-history...feature/bigquery-write-stream-state
After #874 is merged, this PR can be rebased onto the updated
main, and the normal GitHub diff will shrink to the append-only implementation only.Out of scope / follow-ups
current_statetoappend_onlyis intentionally not supported._etl_*metadata columns.Tests
cargo check -p etl --features test-utilscargo check -p etl-destinations --features test-utils,bigquerycargo check -p etl-replicatorcargo test -p etl --features test-utils memory_store_destination_write_stream_state_is_monotonic --test main -- --nocapturecargo test -p etl --features test-utils notifying_store_destination_write_stream_state_is_monotonic --test main -- --nocapturecargo test -p etl --features test-utils state_store_destination_write_stream_state_persists_and_clears --test main -- --nocapturecargo test -p etl-destinations --features test-utils,bigquery append_only_rows_after_sequence -- --nocapturecargo test -p etl-destinations --features test-utils,bigquery committed_stream_already_exists_only_succeeds_on_retry_attempts -- --nocapturecargo test -p etl-destinations --features test-utils,bigquery committed_stream_max_inflight_uses_connection_pool_size_as_proactive_limit -- --nocapturecargo test -p etl-destinations --features bigquery,test-utils append_only -- --nocapturecargo test -p etl-destinations --features bigquery,test-utils committed_stream -- --nocapturecargo test -p etl-destinations --features bigquery committed_stream_ --libcargo test -p etl write_stream_state_ --libcargo test -p etl-destinations --features bigquery,test-utils append_only_copy_rows_after_sequence --libgit diff --no-ext-diff --checkLocal BigQuery smoke validation was also run without adding the smoke test source to this PR, to keep the review diff focused. This used a real BigQuery project and local test Postgres:
env TESTS_BIGQUERY_PROJECT_ID=oripaone TESTS_BIGQUERY_SA_KEY_PATH=/home/mk/.config/gcp/automation-bigquery.json REQUIRE_BIGQUERY_CREDENTIALS=true cargo test -p etl-destinations --features bigquery,test-utils local_append_only_smoke --test main -- --ignored --nocaptureThe latest local smoke run passed 11 ignored tests. Under those conditions, the following behavior has been verified:
INSERTrows.ADD COLUMNcan be followed by a committed stream append.DROP COLUMNpreserves the historical BigQuery column and future appends still succeed.RENAME COLUMNpreserves the old historical BigQuery column, adds the new column, and future appends still succeed.NaNfloat values append successfully through the append-only Storage Write path._etl_source_timestamp.loststatus.loststatus.CANCELLEDandUNKNOWN, which this PR now treats as retryable transport uncertainty together withDEADLINE_EXCEEDED; after that fix, BigQuery row count reached 101,000,000 in 13,804.76s, max observed retained WAL was 6,508,233,144 bytes, max observed unconfirmed WAL was 6,508,233,088 bytes, and no replication slot reachedloststatus.The following existing BigQuery current-state regression tests were also run to check that this PR does not break the default mode:
env TESTS_BIGQUERY_PROJECT_ID=oripaone TESTS_BIGQUERY_SA_KEY_PATH=/home/mk/.config/gcp/automation-bigquery.json REQUIRE_BIGQUERY_CREDENTIALS=true cargo test -p etl-destinations --features bigquery,test-utils table_insert_update_delete --test main -- --nocaptureenv TESTS_BIGQUERY_PROJECT_ID=oripaone TESTS_BIGQUERY_SA_KEY_PATH=/home/mk/.config/gcp/automation-bigquery.json REQUIRE_BIGQUERY_CREDENTIALS=true cargo test -p etl-destinations --features bigquery,test-utils table_truncate_with_batching --test main -- --nocaptureenv TESTS_BIGQUERY_PROJECT_ID=oripaone TESTS_BIGQUERY_SA_KEY_PATH=/home/mk/.config/gcp/automation-bigquery.json REQUIRE_BIGQUERY_CREDENTIALS=true cargo test -p etl-destinations --features bigquery,test-utils schema_change_add_column_defaults --test main -- --nocaptureenv TESTS_BIGQUERY_PROJECT_ID=oripaone TESTS_BIGQUERY_SA_KEY_PATH=/home/mk/.config/gcp/automation-bigquery.json REQUIRE_BIGQUERY_CREDENTIALS=true cargo test -p etl-destinations --features bigquery,test-utils table_schema_change --test main -- --nocaptureRefs #873