Skip to content

Commit a50dc86

Browse files
caohy1988claude
andcommitted
fix: auto-detect dataset location for BigQuery analytics view creation
The plugin previously passed a configured location (default "US") to bigquery.Client(location=...). This only affects query job routing, not table CRUD or the Storage Write API. When the dataset lives in a non-US region, view creation DDL queries silently fail with a location mismatch — data writes succeed but analytics views are missing. The fix auto-detects the dataset's actual location via get_dataset() during initialization and passes it explicitly to client.query() for view creation. Falls back to the configured location when dataset metadata cannot be resolved. Fixes #5476 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a69f861 commit a50dc86

2 files changed

Lines changed: 151 additions & 2 deletions

File tree

src/google/adk/plugins/bigquery_agent_analytics_plugin.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1990,6 +1990,7 @@ def __init__(
19901990
self._setup_lock = None
19911991
self._user_credentials = credentials
19921992
self._credentials = credentials
1993+
self._resolved_location: Optional[str] = None
19931994
self.client = None
19941995
self._loop_state_by_loop: dict[asyncio.AbstractEventLoop, _LoopState] = {}
19951996
self._write_stream_name = None # Resolved stream name
@@ -2184,11 +2185,28 @@ async def _lazy_setup(self, **kwargs) -> None:
21842185
self._executor,
21852186
lambda: bigquery.Client(
21862187
project=self.project_id,
2187-
location=self.location,
21882188
credentials=self._credentials,
21892189
),
21902190
)
21912191

2192+
# Auto-detect the dataset's location so view-creation DDL
2193+
# queries are routed to the correct region. Table CRUD and the
2194+
# Storage Write API derive location from the dataset server-side,
2195+
# but client.query() uses the client's default location for job
2196+
# routing — a mismatch causes silent view-creation failure.
2197+
if self._resolved_location is None:
2198+
dataset_id = f"{self.project_id}.{self.dataset_id}"
2199+
try:
2200+
dataset = await loop.run_in_executor(
2201+
self._executor,
2202+
lambda: self.client.get_dataset(dataset_id),
2203+
)
2204+
self._resolved_location = dataset.location
2205+
except Exception:
2206+
# Fallback when dataset metadata cannot be resolved,
2207+
# preserving current behavior.
2208+
self._resolved_location = self.location
2209+
21922210
self.full_table_id = f"{self.project_id}.{self.dataset_id}.{self.table_id}"
21932211
if not self._schema:
21942212
self._schema = _get_events_schema()
@@ -2447,7 +2465,7 @@ def _create_analytics_views(self) -> None:
24472465
event_type=event_type,
24482466
)
24492467
try:
2450-
self.client.query(sql).result()
2468+
self.client.query(sql, location=self._resolved_location).result()
24512469
except cloud_exceptions.Conflict:
24522470
logger.debug(
24532471
"View %s was updated concurrently by another process.",
@@ -2546,6 +2564,8 @@ def __getstate__(self):
25462564
state["_startup_error"] = None
25472565
state["_is_shutting_down"] = False
25482566
state["_init_pid"] = 0
2567+
# Re-detect dataset location after unpickle.
2568+
state["_resolved_location"] = None
25492569
# _credentials is always runtime-resolved; clear unconditionally.
25502570
state["_credentials"] = None
25512571
# Preserve _user_credentials if they are picklable (e.g.,
@@ -2567,6 +2587,7 @@ def __setstate__(self, state):
25672587
state.setdefault("_init_pid", 0)
25682588
state.setdefault("_user_credentials", None)
25692589
state.setdefault("_credentials", None)
2590+
state.setdefault("_resolved_location", None)
25702591
# Restore _credentials from _user_credentials if available so
25712592
# _create_loop_state uses the user's identity. When both are
25722593
# None (non-picklable credentials were dropped), ADC is used.
@@ -2616,6 +2637,7 @@ def _reset_runtime_state(self) -> None:
26162637

26172638
# Clear all runtime state.
26182639
self._setup_lock = None
2640+
self._resolved_location = None
26192641
self.client = None
26202642
self._loop_state_by_loop = {}
26212643
self._write_stream_name = None

tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7246,3 +7246,130 @@ async def test_no_a2a_interaction_for_no_metadata(
72467246

72477247
await asyncio.sleep(0.05)
72487248
assert mock_write_client.append_rows.call_count == 0
7249+
7250+
7251+
# ================================================================
7252+
# TEST CLASS: Dataset location auto-detection (Issue #5476)
7253+
# ================================================================
7254+
class TestDatasetLocationDetection:
7255+
"""Tests for auto-detecting the dataset location."""
7256+
7257+
@pytest.mark.asyncio
7258+
async def test_location_detected_from_dataset(
7259+
self,
7260+
mock_auth_default,
7261+
mock_to_arrow_schema,
7262+
mock_asyncio_to_thread,
7263+
):
7264+
"""Resolved location comes from get_dataset, not constructor."""
7265+
mock_dataset = mock.MagicMock()
7266+
mock_dataset.location = "europe-west1"
7267+
7268+
with mock.patch.object(bigquery, "Client", autospec=True) as mock_bq_cls:
7269+
mock_bq_cls.return_value.get_dataset.return_value = mock_dataset
7270+
mock_bq_cls.return_value.get_table.side_effect = (
7271+
cloud_exceptions.NotFound("table")
7272+
)
7273+
mock_bq_cls.return_value.create_table.return_value = None
7274+
7275+
async with managed_plugin(
7276+
project_id=PROJECT_ID,
7277+
dataset_id=DATASET_ID,
7278+
table_id=TABLE_ID,
7279+
config=bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
7280+
create_views=False,
7281+
),
7282+
) as plugin:
7283+
await plugin._ensure_started()
7284+
assert plugin._resolved_location == "europe-west1"
7285+
7286+
@pytest.mark.asyncio
7287+
async def test_location_falls_back_on_get_dataset_failure(
7288+
self,
7289+
mock_auth_default,
7290+
mock_to_arrow_schema,
7291+
mock_asyncio_to_thread,
7292+
):
7293+
"""Falls back to configured location when get_dataset fails."""
7294+
with mock.patch.object(bigquery, "Client", autospec=True) as mock_bq_cls:
7295+
mock_bq_cls.return_value.get_dataset.side_effect = Exception("not found")
7296+
mock_bq_cls.return_value.get_table.side_effect = (
7297+
cloud_exceptions.NotFound("table")
7298+
)
7299+
mock_bq_cls.return_value.create_table.return_value = None
7300+
7301+
async with managed_plugin(
7302+
project_id=PROJECT_ID,
7303+
dataset_id=DATASET_ID,
7304+
table_id=TABLE_ID,
7305+
location="asia-northeast1",
7306+
config=bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
7307+
create_views=False,
7308+
),
7309+
) as plugin:
7310+
await plugin._ensure_started()
7311+
assert plugin._resolved_location == "asia-northeast1"
7312+
7313+
@pytest.mark.asyncio
7314+
async def test_views_created_with_resolved_location(
7315+
self,
7316+
mock_auth_default,
7317+
mock_to_arrow_schema,
7318+
mock_asyncio_to_thread,
7319+
):
7320+
"""View creation DDL passes resolved location to client.query()."""
7321+
mock_dataset = mock.MagicMock()
7322+
mock_dataset.location = "europe-west1"
7323+
7324+
with mock.patch.object(bigquery, "Client", autospec=True) as mock_bq_cls:
7325+
mock_client = mock_bq_cls.return_value
7326+
mock_client.get_dataset.return_value = mock_dataset
7327+
mock_client.get_table.return_value = mock.MagicMock()
7328+
mock_client.query.return_value.result.return_value = None
7329+
7330+
async with managed_plugin(
7331+
project_id=PROJECT_ID,
7332+
dataset_id=DATASET_ID,
7333+
table_id=TABLE_ID,
7334+
config=bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
7335+
create_views=True,
7336+
),
7337+
) as plugin:
7338+
await plugin._ensure_started()
7339+
7340+
# Verify query() was called with location kwarg
7341+
assert mock_client.query.call_count > 0
7342+
for call in mock_client.query.call_args_list:
7343+
_, kwargs = call
7344+
assert kwargs.get("location") == "europe-west1"
7345+
7346+
@pytest.mark.asyncio
7347+
async def test_view_error_still_logged(
7348+
self,
7349+
mock_auth_default,
7350+
mock_to_arrow_schema,
7351+
mock_asyncio_to_thread,
7352+
):
7353+
"""View creation errors are logged but not raised."""
7354+
mock_dataset = mock.MagicMock()
7355+
mock_dataset.location = "US"
7356+
7357+
with mock.patch.object(bigquery, "Client", autospec=True) as mock_bq_cls:
7358+
mock_client = mock_bq_cls.return_value
7359+
mock_client.get_dataset.return_value = mock_dataset
7360+
mock_client.get_table.return_value = mock.MagicMock()
7361+
mock_client.query.return_value.result.side_effect = Exception(
7362+
"view error"
7363+
)
7364+
7365+
# Should not raise
7366+
async with managed_plugin(
7367+
project_id=PROJECT_ID,
7368+
dataset_id=DATASET_ID,
7369+
table_id=TABLE_ID,
7370+
config=bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
7371+
create_views=True,
7372+
),
7373+
) as plugin:
7374+
await plugin._ensure_started()
7375+
assert plugin._started

0 commit comments

Comments
 (0)