fix(bigquery): make partitioning configurable, detected and repairable (v0.5.3) - #90
Conversation
Three related defects left BigQuery destination tables unpartitioned, all
silently.
1. A copy job into an existing table keeps that table's partitioning spec, so
a table created by the pre-0.4.0 CTAS stayed unpartitioned forever however
`time_partitioning` was configured. finalize() now compares the staging
table's spec against the destination's and warns when they differ.
2. The partition column was hardcoded to `_bizon_loaded_at` on the batch
destination. With `unnest: true` that column does not exist, so the load job
failed mid-run. `time_partitioning` now takes the `{type, field}` shape the
streaming destinations already had (a bare `DAY` still validates), the field
is plumbed through, and it is validated against the resolved schema.
3. bigquery_streaming_v2 published full refreshes with a plain CTAS, which
produces an unpartitioned, unclustered table that BigQuery then refuses to
ever repartition; its first incremental run 404'd on INSERT INTO. The DDL now
carries PARTITION BY / CLUSTER BY, and incremental creates the main table
first.
Adds `enforce_partitioning` (default false) to drop and rebuild a mismatched
table on a full refresh -- the only repartitioning mechanism BigQuery offers.
Incremental never rebuilds; it points at `bizon stream reset`.
Verified against the BigQuery API on a throwaway dataset: WRITE_TRUNCATE copy
onto an unpartitioned table succeeds silently; copy onto a missing table
inherits partitioning and clustering; CREATE OR REPLACE TABLE is rejected in
either direction when the spec differs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…have BigQuery rejects CREATE OR REPLACE TABLE whenever the declared partitioning differs from the existing table's -- in either direction, not just when adding partitioning to an unpartitioned table. Falling back to a bare CTAS against a table partitioned on a different column therefore failed just as hard as declaring the configured spec would have. The publish DDL now declares whatever spec the table will end up with: the configured one when the table is absent, already matches, or enforce_partitioning permits a drop; otherwise the table's current spec, with a warning. This matters immediately because `time_partitioning.field` only became configurable in this release, so changing it on a live pipeline is now a reachable path. Verified live: a table partitioned on `_bizon_extracted_at` with the config asking for `_bizon_loaded_at` publishes successfully and warns, instead of failing the run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up commit:
|
| Destination table | Declared spec |
|---|---|
| absent | configured |
| matches config | configured |
differs, enforce_partitioning: false |
current (run keeps working, warns) |
differs, enforce_partitioning: true |
configured (table dropped first) |
Verified live: main table on _bizon_extracted_at, config asking for _bizon_loaded_at → publishes successfully with
Partitioning mismatch on
…v2_drift: the table is partitioned by_bizon_extracted_at(DAY), but the config asks for partitioned by_bizon_loaded_at(DAY). BigQuery cannot change an existing table's partitioning.
Publishing…v2_driftas partitioned by_bizon_extracted_at(DAY) to keep the run working. Setenforce_partitioning: true…
Three tests added for the new direction; 131 BigQuery mock tests green.
Unrelated pre-existing bug noticed while testing
bigquery_streaming_v2 intermittently fails a full-refresh run that starts shortly after a previous one on the same table:
404 Table …:dataset.table_temp is re-created.
Entity: projects/…/tables/table_temp/_default
finalize() deletes the staging table and the next run recreates it, and the Storage Write API's _default stream metadata cache lags behind. It is timing-dependent — back-to-back runs succeed as often as not. This PR does not change the staging-table lifecycle, so it is not introduced here; flagging it as a separate issue worth filing.
_check_destination_partitioning() caught only NotFound, so any other error from get_table -- a transient 5xx, a rate limit, a missing tables.get permission -- propagated out of finalize() and failed a run that would previously have published fine. The lookup exists to emit a warning; it must not be able to cause an outage. The two get_table calls are now wrapped: on an unexpected error the check logs and skips. enforce_partitioning's drop stays outside the guard, so an explicitly requested rebuild still surfaces its failures, and an unreadable spec is never mistaken for a mismatch worth dropping the table over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three related BigQuery partitioning defects, all silent — no error, just tables that scan in full forever.
What was wrong
1. No self-heal for legacy tables. The batch destination publishes with a copy job (
_copy_temp_to_main). A copy job into a table that already exists keeps that table's own partitioning spec, so a table first created by the pre-0.4.0CREATE TABLE AS SELECTis unpartitioned and stays that way forever, howevertime_partitioningis configured, with nothing in the logs.2. The partition column was hardcoded.
_build_load_job_config()always passedfield="_bizon_loaded_at", andtime_partitioningwas a bareDAY|HOUR|MONTH|YEARenum. Withunnest: truethe table holds onlyrecord_schemascolumns, so that column does not exist and the load job failed mid-run.3.
bigquery_streaming_v2threw away the layout on publish. The staging table was created withtime_partitioningandclustering_fields, thenfinalize()published withCREATE OR REPLACE TABLE main AS SELECT * FROM temp— CTAS inherits neither. Its incremental path was worse:INSERT INTO404s when the main table does not yet exist.Verified BigQuery behavior
The design hinged on facts I could not take on trust, so I ran them against the API on a throwaway dataset in
gorgias-growth-development:cp -f(WRITE_TRUNCATE) partitioned → existing unpartitionedcp -fpartitioned+clustered → nonexistent destinationcp -a(WRITE_APPEND) partitioned → existing unpartitionedCREATE OR REPLACE TABLE ... PARTITION BYover existing unpartitioned tableCannot replace a table with a different partitioning spec. Instead, DROP the table, and then recreate it.Two consequences that shaped the implementation:
PARTITION BYwould make every existing streaming_v2 pipeline fail on its next run. It therefore only emits the clauses when the table is absent or already matches, and otherwise falls back to the historical plain CTAS with a warning.What changed
time_partitioningtakes the{type, field}shape the streaming destinations already had, shared by all three (they had three byte-identical copies that had drifted). A baretime_partitioning: DAYstill validates.fieldis validated at config load and against the resolved schema at runtime — the stream runner injectsrecord_schemasafter validation, so neither layer alone is sufficient.field: nullselects ingestion-time partitioning.finalize()compares the staging and destination specs and warns, naming both.enforce_partitioning(defaultfalse, onbigqueryandbigquery_streaming_v2): a full refresh drops and rebuilds a mismatched table. Restricted to full refreshes, where the staging table already holds every row, so the rebuild is lossless and free. Incremental never rebuilds — it warns and points atbizon stream reset.PARTITION BY/CLUSTER BY; incremental creates the main table first.bigquery_streaming(v1) turns out not to have defect 3 — nofinalize(), no staging table, and it already sets partitioning and clustering on the table it appends to. Config model only.Testing
65 new mock tests (
test_bigquery_partitioning.py,test_bigquery_streaming_v2_partitioning.py) plus a sharedconftest.pyharness; two existing tests that were passing by accident made explicit. Full live end-to-end runs against real BigQuery:_bizon_loaded_at/DAY ✅{type: HOUR, field: _bizon_extracted_at}→ applied ✅time_partitioning: MONTH→ applied (back-compat) ✅Also confirmed
pip install bizonwith no extras still imports (config.pystays free ofgoogle.cloud.bigquery— the 0.5.1 regression class).Pre-existing test failures on this machine (
tests/cli,tests/alerting,tests/engine/backend) are a local Postgres credential conflict; confirmed identical on pristinev0.5.2.Not included
Per review scope: batch
clustering_keysremains a silent no-op fordestination.name: bigquery;pytest.ymlstill--ignores the whole BigQuery test tree, so these new mock tests will not run in CI. Both worth follow-ups.🤖 Generated with Claude Code