Skip to content

[MAINTENANCE] Create BigQuery test tables in the configured dataset and sweep tables, not datasets - #12024

Draft
joshua-stauffer wants to merge 3 commits into
developfrom
m/bigquery-tables-in-stable-dataset
Draft

[MAINTENANCE] Create BigQuery test tables in the configured dataset and sweep tables, not datasets#12024
joshua-stauffer wants to merge 3 commits into
developfrom
m/bigquery-tables-in-stable-dataset

Conversation

@joshua-stauffer

Copy link
Copy Markdown
Collaborator

Summary

Two commits. The BigQuery test harness stops creating a dataset per test config, and the cleanup script stops looking for stale datasets and looks for stale tables instead. The second follows from the first: once no test creates a dataset, there are no stale datasets to find.

Why

BigQuery calls its schemas "datasets", so the harness's use_schema = True meant a project-level object created and dropped per test config — a CREATE SCHEMA on setup and a DROP SCHEMA on teardown, plus an orphan whenever a run was killed before teardown.

That bought no isolation the suite didn't already have. Generated table names carry a uuid4-derived suffix (expectation_test_table[_label]_<10 hex>), so concurrent runs cannot collide however many share a dataset — across xdist workers, Python versions, or branches.

It was also the largest remaining cost. After #12020 the BigQuery suite runs in 3:36, and the four slowest entries in that run are all teardown, at 20–29s each — which is DROP TABLE plus DROP SCHEMA. This removes the second half.

Nothing exercises schema-qualified access on BigQuery today: test_schemas.py covers databricks, snowflake and sql_server, and test_sql_datasources.py covers sqlite, postgresql, trino and sql_server. No BigQuery test sets schema_name, and none sets an explicit table_name. Should that need arise, passing schema_name while use_schema is False raises, so it surfaces as a clear error rather than silently doing nothing.

The sweeper

Discovery moves to tables.list against the configured dataset. Compared to the previous project-wide INFORMATION_SCHEMA.SCHEMATA query:

  • Dataset-scoped. Runs with a credential that can see only the CI dataset, and never pays to enumerate a project that may hold many thousands of unrelated datasets.
  • No region blind spot. INFORMATION_SCHEMA is region-scoped, so anything created in another location was invisible to it and never cleaned up.
  • No project-wide metadata access, which a CI credential should not need.
  • No follow-up request per candidatetables.list reports creationTime, so age filtering is free.

The match is deliberately narrow — fixed prefix, optional label, anchored 10-hex suffix — because the configured dataset is shared with whatever else lives there and a deliberately-created table must never be a candidate. Deletion tolerates a table vanishing between listing and deleting, which happens whenever a run reaches its own teardown mid-sweep.

Also drops the py3XX_i* pattern, which nothing in the codebase produces any more.

Testing

21 new tests in tests/scripts/test_cleanup_big_query.py, covering the name match (13 cases including near-misses like a 9- and 11-char suffix, a non-hex suffix, and an unanchored prefix), the age threshold and its boundary, unknown creation time, dataset-scoping, and the delete path including NotFound.

I mutation-tested them rather than trusting green mocks — each of these was caught by exactly the intended test:

mutation caught by
>>= on the age comparison test_table_exactly_at_the_age_threshold_is_left_alone
remove the name-pattern guard 10 tests, incl. test_cleanup_deletes_each_stale_table
treat unknown created as stale test_table_with_unknown_creation_time_is_left_alone

Local runs: -m unit 4380 passed (baseline 4359, +21 = exactly the new tests) · -m sqlite 539 passed · ruff clean · mypy clean on the changed files under CI's flags on Python 3.10.

The use_schema change has no unit test — there's no existing test module for the harness configs, and asserting schema is None would test little. I verified it directly instead: use_schema False, schema None, connection string resolving to the configured dataset, the table's SQLAlchemy schema None, and schema_name raising. The real verification is the BigQuery marker job.

Not yet verified

The BigQuery marker job has not run against this branch — it's gated on draft == false. That job is the only meaningful test of this change, and it now takes ~4 minutes, so marking this ready is worth doing before merge. I'd expect green with a visible drop in teardown time.

One transition note

After this merges, nothing sweeps gx_ci_test_* datasets any more, because nothing creates them. If a run is in flight across the merge it could leave one final orphan with no sweeper to collect it. Worth one check afterwards:

bq ls --project_id=$GE_TEST_GCP_PROJECT --max_results=10000 | grep gx_ci_test_

Anything listed can be removed with bq rm -r -f -d <dataset>.

…w one per test

BigQuery calls its schemas "datasets", so the harness's per-test schema became a
per-test dataset. Datasets are project-level objects, which makes them an
expensive unit of test isolation: every test config paid a CREATE SCHEMA on setup
and a DROP SCHEMA on teardown, and any run killed before teardown left an orphan
that only an out-of-band sweep could find.

That bought no isolation the suite did not already have. Generated table names
carry a uuid4-derived suffix, so concurrent runs -- across xdist workers, Python
versions, and branches -- cannot collide however many of them share a dataset.

Tests now create their tables directly in the dataset the connection already
points at. Teardown drops the tables and nothing else, and there is no longer a
class of leaked resource that lives outside the one dataset CI is configured
with. Dataset teardown was the largest remaining cost in the suite, holding the
four slowest entries at 20-29s each.

Schema-qualified coverage on BigQuery is not currently exercised by any test.
Should one need it, passing `schema_name` while use_schema is False raises, so
the requirement surfaces as a clear error rather than silently doing nothing.
With tests no longer creating a dataset apiece, there are no stale datasets left
to find -- only stale tables, and only ever inside the one dataset CI is
configured with. Narrowing the sweep to match removes every reason the old
project-wide search was awkward.

Discovery is now `tables.list` against the configured dataset. That is scoped to
a single dataset, so it runs with a credential that can see only that dataset and
never pays to enumerate a project which may hold many thousands of unrelated
datasets. It also sidesteps the two problems with the previous
INFORMATION_SCHEMA.SCHEMATA query: that view is region-scoped, so anything
created in another location was invisible to it, and reading it required
project-wide metadata access that a CI credential should not need.

`tables.list` reports creationTime per table, so age filtering needs no
follow-up request per candidate. The age threshold is an injectable parameter so
it can be exercised without waiting an hour.

The match is deliberately narrow: the fixed prefix, an optional label, and an
anchored uuid4-derived suffix. The configured dataset is shared with whatever
else lives there, so a table someone created on purpose must never be a
candidate. Deletion tolerates a table disappearing between listing and deleting,
which happens whenever a run reaches its own teardown mid-sweep.

Also drops the `py3XX_i*` pattern, which nothing in the codebase produces.
@netlify

netlify Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploy Preview for niobium-lead-7998 canceled.

Name Link
🔨 Latest commit 2e4abb0
🔍 Latest deploy log https://app.netlify.com/projects/niobium-lead-7998/deploys/6a748cdc96296700085db672

The module annotated a parameter as `python_bigquery.Client`. Annotations are
evaluated at import time, and outside the BigQuery test lane that name is a
NotImported sentinel which raises on attribute access, so merely importing the
module failed wherever the optional dependency was absent -- including the unit
test lane, which collects the whole tests tree.

`from __future__ import annotations` defers the annotation, matching how the rest
of the codebase refers to optional dependencies in signatures.

The tests now stand in for the module rather than reaching through it. Patching
`python_bigquery.Client` cannot work against the sentinel, which raises from both
__getattr__ and __setattr__; and patch's own default path inspects what it is
replacing via `hasattr(original, "__func__")`, where NotImported raises
ModuleNotFoundError rather than the AttributeError hasattr would absorb. Passing
`new=` explicitly skips that inspection.

`NotFound` is likewise substituted with a local exception, since it is a sentinel
in the same environments. That keeps the behaviour under test -- a table vanishing
mid-sweep is tolerated -- but would no longer notice the guard being widened to
catch everything, so there is now a test that an unexpected delete failure
propagates instead of being logged as a routine skip.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant