This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 3a37fb1c33e Fix BigQuery cancellation and links with inferred job 
locations (#75717)
3a37fb1c33e is described below

commit 3a37fb1c33ed2aabd49e24471dac4d98b7ed8e6b
Author: Django.Jiang <[email protected]>
AuthorDate: Tue Oct 27 00:00:41 2026 -0700

    Fix BigQuery cancellation and links with inferred job locations (#75717)
---
 .../providers/google/cloud/operators/bigquery.py   |  14 +-
 .../unit/google/cloud/operators/test_bigquery.py   | 183 ++++++++++++++++++++-
 2 files changed, 191 insertions(+), 6 deletions(-)

diff --git 
a/providers/google/src/airflow/providers/google/cloud/operators/bigquery.py 
b/providers/google/src/airflow/providers/google/cloud/operators/bigquery.py
index 800d6fa711e..85ebd84fd42 100644
--- a/providers/google/src/airflow/providers/google/cloud/operators/bigquery.py
+++ b/providers/google/src/airflow/providers/google/cloud/operators/bigquery.py
@@ -2447,6 +2447,7 @@ class BigQueryInsertJobOperator(
             raise AirflowException(f"Job failed with state: {job.state}")
 
     def _persist_job_links(self, job: BigQueryJob | UnknownJob, context: Any) 
-> None:
+        location = job.location or self.location
         job_types = {
             LoadJob._JOB_TYPE: ["sourceTable", "destinationTable"],
             CopyJob._JOB_TYPE: ["sourceTable", "destinationTable"],
@@ -2477,7 +2478,7 @@ class BigQueryInsertJobOperator(
             job_id_path = convert_job_id(
                 job_id=self.job_id,
                 project_id=self.project_id,
-                location=self.location,
+                location=location,
             )
             warnings.warn(
                 "BigQueryInsertJobOperator's `job_id_path` XCom is deprecated 
and will be removed in a "
@@ -2490,7 +2491,7 @@ class BigQueryInsertJobOperator(
         persist_kwargs = {
             "context": context,
             "project_id": self.project_id,
-            "location": self.location,
+            "location": location,
             "job_id": self.job_id,
         }
         BigQueryJobDetailLink.persist(**persist_kwargs)
@@ -2530,7 +2531,7 @@ class BigQueryInsertJobOperator(
                     conn_id=self.gcp_conn_id,
                     job_id=self.job_id,
                     project_id=self.project_id,
-                    location=self.location or self.hook.location,  # type: 
ignore[union-attr]
+                    location=job.location or self.location or 
self.hook.location,  # type: ignore[union-attr]
                     poll_interval=self.poll_interval,
                     impersonation_chain=self.impersonation_chain,
                     cancel_on_kill=self.cancel_on_kill,
@@ -2698,7 +2699,12 @@ class BigQueryInsertJobOperator(
 
     def on_kill(self) -> None:
         if self.hook and self.job_id and self.cancel_on_kill:
-            self.hook.cancel_job(job_id=self.job_id, 
project_id=self.project_id, location=self.location)
+            location = self.location
+            job: BigQueryJob | UnknownJob | None = getattr(self, "_job", None)
+            # A replacement may be submitting while _job still refers to the 
previous job.
+            if job is not None and job.job_id == self.job_id:
+                location = job.location or location
+            self.hook.cancel_job(job_id=self.job_id, 
project_id=self.project_id, location=location)
         else:
             self.log.info("Skipping to cancel job: %s:%s.%s", self.project_id, 
self.location, self.job_id)
 
diff --git 
a/providers/google/tests/unit/google/cloud/operators/test_bigquery.py 
b/providers/google/tests/unit/google/cloud/operators/test_bigquery.py
index ecc35874454..e7993051a2b 100644
--- a/providers/google/tests/unit/google/cloud/operators/test_bigquery.py
+++ b/providers/google/tests/unit/google/cloud/operators/test_bigquery.py
@@ -21,6 +21,7 @@ import json
 import logging
 import os
 from contextlib import suppress
+from typing import Any
 from unittest import mock
 from unittest.mock import ANY, MagicMock
 
@@ -1035,6 +1036,121 @@ class TestBigQueryUpsertTableOperator:
 
 
 class TestBigQueryInsertJobOperator:
+    @pytest.mark.parametrize("configured_location", [None, "EU"])
+    @pytest.mark.parametrize("durable", [False, True])
+    
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook", 
autospec=True)
+    def test_submitted_job_uses_actual_location_for_links_and_cancellation(
+        self, mock_hook: MagicMock, durable: bool, configured_location: str | 
None
+    ) -> None:
+        hook = mock_hook.return_value
+        hook.labels = {}
+        hook.generate_job_id.return_value = "submitted-job"
+        job = MagicMock(
+            spec=QueryJob,
+            job_id="submitted-job",
+            location="asia-northeast3",
+            state="DONE",
+            error_result=None,
+        )
+        job.to_api_repr.return_value = {"configuration": {"query": {}}}
+        hook.insert_job.return_value = job
+        op = BigQueryInsertJobOperator(
+            task_id="insert_query_job",
+            configuration={"query": {"query": "SELECT 1", "useLegacySql": 
False}},
+            project_id=TEST_GCP_PROJECT_ID,
+            location=configured_location,
+            deferrable=False,
+            durable=durable,
+        )
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = None
+        ti = MagicMock(spec_set=["try_number", "stats_tags", "xcom_push"], 
try_number=1, stats_tags={})
+        context = {"ti": ti, "task": op, "task_state_store": task_store}
+
+        assert op.execute(context) == "submitted-job"
+        op.on_kill()
+
+        ti.xcom_push.assert_any_call(
+            key="bigquery_job_detail",
+            value={
+                "project_id": TEST_GCP_PROJECT_ID,
+                "job_id": "submitted-job",
+                "location": "asia-northeast3",
+            },
+        )
+        ti.xcom_push.assert_any_call(
+            key="job_id_path", 
value=f"{TEST_GCP_PROJECT_ID}:asia-northeast3:submitted-job"
+        )
+        hook.cancel_job.assert_called_once_with(
+            job_id="submitted-job", project_id=TEST_GCP_PROJECT_ID, 
location="asia-northeast3"
+        )
+        assert hook.insert_job.call_args.kwargs["location"] == 
configured_location
+        assert op.location == configured_location
+
+        job.location = None
+        hook.cancel_job.reset_mock()
+        ti.xcom_push.reset_mock()
+        op._persist_job_links(job, context)
+        op.on_kill()
+
+        ti.xcom_push.assert_any_call(
+            key="bigquery_job_detail",
+            value={
+                "project_id": TEST_GCP_PROJECT_ID,
+                "job_id": "submitted-job",
+                "location": configured_location,
+            },
+        )
+        ti.xcom_push.assert_any_call(
+            key="job_id_path", 
value=f"{TEST_GCP_PROJECT_ID}:{configured_location or 'US'}:submitted-job"
+        )
+        hook.cancel_job.assert_called_once_with(
+            job_id="submitted-job", project_id=TEST_GCP_PROJECT_ID, 
location=configured_location
+        )
+
+    @pytest.mark.parametrize("configured_location", [None, "EU"])
+    
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook", 
autospec=True)
+    def test_defers_with_actual_job_location(
+        self, mock_hook: MagicMock, configured_location: str | None
+    ) -> None:
+        hook = mock_hook.return_value
+        hook.labels = {}
+        hook.location = "US"
+        hook.generate_job_id.return_value = "submitted-job"
+        for actual_location in ("asia-northeast3", None):
+            job = MagicMock(spec=QueryJob, job_id="submitted-job", 
location=actual_location)
+            job.to_api_repr.return_value = {"configuration": {"query": {}}}
+            job.running.return_value = True
+            hook.insert_job.return_value = job
+            op = BigQueryInsertJobOperator(
+                task_id="insert_query_job",
+                configuration={"query": {"query": "SELECT 1", "useLegacySql": 
False}},
+                project_id=TEST_GCP_PROJECT_ID,
+                location=configured_location,
+                deferrable=True,
+                durable=False,
+            )
+            ti = MagicMock(spec_set=["try_number", "xcom_push"], try_number=1)
+            context = {"ti": ti, "task": op}
+
+            with pytest.raises(TaskDeferred) as deferred:
+                op.execute(context)
+
+            assert isinstance(deferred.value.trigger, BigQueryInsertJobTrigger)
+            assert deferred.value.trigger.location == (
+                actual_location or configured_location or hook.location
+            )
+            assert deferred.value.trigger.job_id == "submitted-job"
+            ti.xcom_push.assert_any_call(
+                key="bigquery_job_detail",
+                value={
+                    "project_id": TEST_GCP_PROJECT_ID,
+                    "job_id": "submitted-job",
+                    "location": actual_location or configured_location,
+                },
+            )
+            assert op.location == configured_location
+
     def test_init_does_not_snapshot_templated_job_id(self):
         op = BigQueryInsertJobOperator(
             task_id="insert_query_job",
@@ -1171,7 +1287,7 @@ class TestBigQueryInsertJobOperator:
             }
         }
         mock_hook.return_value.insert_job.return_value = MagicMock(
-            state="DONE", job_id=real_job_id, error_result=False
+            state="DONE", job_id=real_job_id, error_result=False, location=None
         )
         mock_hook.return_value.generate_job_id.return_value = real_job_id
 
@@ -1224,6 +1340,7 @@ class TestBigQueryInsertJobOperator:
         }
 
         mock_job.job_id = real_job_id
+        mock_job.location = None
         mock_job.error_result = False
         mock_job.state = "DONE"
         mock_job.result.side_effect = AirflowTaskTimeout()
@@ -2306,6 +2423,48 @@ class TestBigQueryInsertJobOperator:
     not AIRFLOW_V_3_3_PLUS, reason="task_state_store (durable execution) 
requires Airflow 3.3+"
 )
 class TestBigQueryInsertJobOperatorDurable:
+    @pytest.mark.parametrize("keep_previous_job", [False, True])
+    
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook", 
autospec=True)
+    def test_cancel_during_replacement_does_not_use_previous_job_location(
+        self, mock_hook: MagicMock, keep_previous_job: bool
+    ) -> None:
+        hook = mock_hook.return_value
+        hook.labels = {}
+        hook.generate_job_id.return_value = "replacement-job"
+        hook.get_job.return_value = MagicMock(
+            spec=QueryJob,
+            job_id="previous-job",
+            location="asia-northeast3",
+            state="DONE",
+            error_result="failed",
+        )
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = {"job_id": "previous-job", "location": 
"asia-northeast3"}
+        context = self._context(task_store)
+        op = self._make_operator(deferrable=False)
+
+        assert op.get_job_status("previous-job", context) == "error"
+        op.on_kill()
+        hook.cancel_job.assert_called_once_with(
+            job_id="previous-job", project_id=TEST_GCP_PROJECT_ID, 
location="asia-northeast3"
+        )
+        hook.cancel_job.reset_mock()
+        if not keep_previous_job:
+            del op._job
+
+        def interrupt_submission(**kwargs: Any) -> None:
+            op.on_kill()
+            raise AirflowTaskTimeout()
+
+        hook.insert_job.side_effect = interrupt_submission
+        with pytest.raises(AirflowTaskTimeout):
+            op.submit_job(context)
+
+        hook.cancel_job.assert_called_once_with(
+            job_id="replacement-job", project_id=TEST_GCP_PROJECT_ID, 
location=TEST_DATASET_LOCATION
+        )
+        assert op.location == TEST_DATASET_LOCATION
+
     @pytest.mark.parametrize("status", ["RUNNING", "success", "error", 
"not_found"])
     
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
     def test_recovers_returned_location_after_worker_failure(self, mock_hook: 
MagicMock, status: str) -> None:
@@ -2334,6 +2493,11 @@ class TestBigQueryInsertJobOperatorDurable:
             "bigquery_job_id": "original-job",
             "bigquery_job_location": {"job_id": "original-job", "location": 
"asia-northeast3"},
         }
+        op.on_kill()
+        hook.cancel_job.assert_called_once_with(
+            job_id="original-job", project_id=TEST_GCP_PROJECT_ID, 
location="asia-northeast3"
+        )
+        hook.cancel_job.reset_mock()
         hook.insert_job.reset_mock()
         job.result.side_effect = lambda **_: setattr(job, "state", "DONE")
         job.state = "RUNNING" if status == "RUNNING" else "DONE"
@@ -2348,7 +2512,8 @@ class TestBigQueryInsertJobOperatorDurable:
         op = self._make_operator()
         op.location = "EU" if status in ("error", "not_found") else None
 
-        result = op.execute(self._context(task_store))
+        context = self._context(task_store)
+        result = op.execute(context)
 
         hook.get_job.assert_called_once_with(
             project_id=TEST_GCP_PROJECT_ID, location="asia-northeast3", 
job_id="original-job"
@@ -2361,6 +2526,20 @@ class TestBigQueryInsertJobOperatorDurable:
             assert result == "original-job"
             hook.insert_job.assert_not_called()
 
+        expected_location = "EU" if status in ("error", "not_found") else 
"asia-northeast3"
+        context["ti"].xcom_push.assert_any_call(
+            key="bigquery_job_detail",
+            value={"project_id": TEST_GCP_PROJECT_ID, "job_id": result, 
"location": expected_location},
+        )
+        context["ti"].xcom_push.assert_any_call(
+            key="job_id_path", 
value=f"{TEST_GCP_PROJECT_ID}:{expected_location}:{result}"
+        )
+        op.on_kill()
+        hook.cancel_job.assert_called_once_with(
+            job_id=result, project_id=TEST_GCP_PROJECT_ID, 
location=expected_location
+        )
+        assert op.location == ("EU" if status in ("error", "not_found") else 
None)
+
     @pytest.mark.parametrize(
         "saved_location",
         [

Reply via email to