This is an automated email from the ASF dual-hosted git repository.
amoghrajesh 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 3217e297f99 Adding durable execution to `BigQueryInsertJobOperator`
(#69542)
3217e297f99 is described below
commit 3217e297f994fbfa49079e11d945b5af9369795f
Author: Amogh Desai <[email protected]>
AuthorDate: Fri Jul 10 10:38:10 2026 +0530
Adding durable execution to `BigQueryInsertJobOperator` (#69542)
---
providers/google/docs/operators/cloud/bigquery.rst | 57 ++++
.../providers/google/cloud/operators/bigquery.py | 218 ++++++++++-----
.../unit/google/cloud/operators/test_bigquery.py | 292 ++++++++++++++++++++-
3 files changed, 496 insertions(+), 71 deletions(-)
diff --git a/providers/google/docs/operators/cloud/bigquery.rst
b/providers/google/docs/operators/cloud/bigquery.rst
index d42cda58656..1513a958670 100644
--- a/providers/google/docs/operators/cloud/bigquery.rst
+++ b/providers/google/docs/operators/cloud/bigquery.rst
@@ -369,6 +369,63 @@ Also for all this action you can use operator in the
deferrable mode:
:start-after: [START howto_operator_bigquery_insert_job_async]
:end-before: [END howto_operator_bigquery_insert_job_async]
+Durable execution
+^^^^^^^^^^^^^^^^^
+
+``BigQueryInsertJobOperator`` submits a job and then polls it to completion on
the worker. By
+default the operator runs in a *durable* mode that makes this crash-safe: the
submitted BigQuery
+job id is persisted to task state before polling begins, so if the worker
crashes or is preempted
+and the task is retried, the operator reconnects to the job that is already
running in BigQuery
+instead of submitting a duplicate.
+
+On retry the operator checks the prior job's state:
+
+* if it is still running, the operator reconnects and continues polling
+* if it already succeeded, the operator returns immediately without
resubmitting
+* if it failed, or its id is no longer found, the operator submits the job
fresh
+
+This makes reattachment on retry work regardless of ``force_rerun``, which
defaults to ``True``.
+Without durable execution, ``force_rerun=True`` computes a new random job id
on every attempt, so
+the existing ``reattach_states``/``Conflict`` mechanism (recomputing an
identical id and relying
+on BigQuery rejecting the duplicate) almost never has a matching id to find on
retry -- setting
+``force_rerun=False`` was the only way to make that mechanism reliable.
Durable execution replaces
+that specific use of ``force_rerun=False``: the operator now reconnects using
the id it actually
+persisted, so ``force_rerun`` no longer needs to change purely to make
*retries of the same task
+instance* reattach.
+
+``force_rerun`` still matters for a different, narrower purpose it always had:
idempotency across
+*separate* invocations, such as two different DAG runs or a manual re-trigger
that reuses the same
+explicit ``job_id``. Durable execution's persisted state is scoped to a single
task instance
+(``dag_id``/``task_id``/``run_id``/``map_index``); a new DAG run has no
durable state to reconnect
+to at all. If you rely on ``force_rerun=False`` with an explicit ``job_id`` so
that re-triggering
+the same logical run doesn't submit a second job, that guarantee is unrelated
to durable execution
+and keeping ``force_rerun=False`` is still the right choice for it.
+
+Durable execution requires Airflow 3.3 or newer, since it relies on the task
state store. On
+earlier Airflow versions the flag is a no-op and the operator always submits a
fresh job on retry,
+exactly as before -- including the pre-existing
``reattach_states``/``Conflict`` behavior, which is
+unchanged. If the task state store is unavailable at runtime, the operator
logs that crash
+recovery is disabled and behaves the same way.
+
+To opt out and always submit a fresh job on retry, set ``durable=False``:
+
+.. code-block:: python
+
+ insert_query_job = BigQueryInsertJobOperator(
+ task_id="insert_query_job",
+ configuration={
+ "query": {
+ "query": "SELECT 1",
+ "useLegacySql": False,
+ }
+ },
+ durable=False,
+ )
+
+Durable execution applies to the synchronous path. When ``deferrable=True`` is
set, the Triggerer
+already tracks the job across the wait, so deferrable mode takes precedence
and ``durable`` has no
+effect.
+
Validate data
^^^^^^^^^^^^^
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 06b9e905555..7800a5ad44d 100644
--- a/providers/google/src/airflow/providers/google/cloud/operators/bigquery.py
+++ b/providers/google/src/airflow/providers/google/cloud/operators/bigquery.py
@@ -27,7 +27,7 @@ from collections.abc import Sequence
from functools import cached_property
from typing import TYPE_CHECKING, Any, SupportsAbs
-from google.api_core.exceptions import Conflict
+from google.api_core.exceptions import Conflict, NotFound
from google.api_core.gapic_v1.method import DEFAULT, _MethodDefault
from google.cloud.bigquery import DEFAULT_RETRY, CopyJob, ExtractJob, LoadJob,
QueryJob, Row
from google.cloud.bigquery.routine import Routine
@@ -73,9 +73,29 @@ except ImportError:
return value is not NOTSET
+try:
+ from airflow.sdk import ResumableJobMixin
+except ImportError:
+
+ class ResumableJobMixin: # type: ignore[no-redef]
+ """Airflow <3.3 stub, task_state_store unavailable, always submits
fresh."""
+
+ external_id_key: str = "bigquery_job_id"
+
+ def __init__(self, *, durable: bool = True, **kwargs: Any) -> None:
+ super().__init__(**kwargs)
+ self.durable = durable
+
+ def execute_resumable(self, context):
+ external_id = self.submit_job(context)
+ self.poll_until_complete(external_id, context)
+ return self.get_job_result(external_id, context)
+
+
if TYPE_CHECKING:
from google.api_core.retry import Retry
from google.cloud.bigquery import UnknownJob
+ from pydantic import JsonValue
from airflow.providers.common.compat.sdk import Context
@@ -2260,7 +2280,9 @@ class
BigQueryUpdateTableSchemaOperator(GoogleCloudBaseOperator):
return OperatorLineage(outputs=[output_dataset])
-class BigQueryInsertJobOperator(GoogleCloudBaseOperator,
_BigQueryInsertJobOperatorOpenLineageMixin):
+class BigQueryInsertJobOperator(
+ ResumableJobMixin, GoogleCloudBaseOperator,
_BigQueryInsertJobOperatorOpenLineageMixin
+):
"""
Execute a BigQuery job.
@@ -2313,6 +2335,11 @@ class BigQueryInsertJobOperator(GoogleCloudBaseOperator,
_BigQueryInsertJobOpera
:param deferrable: Run operator in the deferrable mode
:param poll_interval: (Deferrable mode only) polling period in seconds to
check for the status of job.
Defaults to 4 seconds.
+ :param durable: When ``True`` (the default), the submitted BigQuery job id
is persisted to task
+ state before polling begins. A worker crash on retry reconnects to the
existing job instead of
+ submitting a duplicate, this works regardless of ``force_rerun``,
since the persisted id is
+ read back directly rather than recomputed. Set to ``False`` to always
submit fresh on retry.
+ Requires Airflow 3.3+; no-op on earlier versions.
"""
template_fields: Sequence[str] = (
@@ -2329,6 +2356,7 @@ class BigQueryInsertJobOperator(GoogleCloudBaseOperator,
_BigQueryInsertJobOpera
template_fields_renderers = {"configuration": "json",
"configuration.query.query": "sql"}
ui_color = BigQueryUIColors.QUERY.value
operator_extra_links = (BigQueryTableLink(), BigQueryJobDetailLink())
+ external_id_key = "bigquery_job_id"
def __init__(
self,
@@ -2351,6 +2379,7 @@ class BigQueryInsertJobOperator(GoogleCloudBaseOperator,
_BigQueryInsertJobOpera
self.configuration = configuration
self.location = location
self.job_id = job_id
+ self._configured_job_id = job_id
self.project_id = project_id
self.gcp_conn_id = gcp_conn_id
self.force_rerun = force_rerun
@@ -2422,6 +2451,55 @@ class BigQueryInsertJobOperator(GoogleCloudBaseOperator,
_BigQueryInsertJobOpera
if job.state != "DONE":
raise AirflowException(f"Job failed with state: {job.state}")
+ def _persist_job_links(self, job: BigQueryJob | UnknownJob, context: Any)
-> None:
+ job_types = {
+ LoadJob._JOB_TYPE: ["sourceTable", "destinationTable"],
+ CopyJob._JOB_TYPE: ["sourceTable", "destinationTable"],
+ ExtractJob._JOB_TYPE: ["sourceTable"],
+ QueryJob._JOB_TYPE: ["destinationTable"],
+ }
+
+ if self.project_id:
+ for job_type, tables_prop in job_types.items():
+ job_configuration = job.to_api_repr()["configuration"]
+ if job_type in job_configuration:
+ for table_prop in tables_prop:
+ if table_prop in job_configuration[job_type]:
+ table = job_configuration[job_type][table_prop]
+ persist_kwargs = {
+ "context": context,
+ "project_id": self.project_id,
+ "table_id": table,
+ }
+ if not isinstance(table, str):
+ persist_kwargs["table_id"] = table["tableId"]
+ persist_kwargs["dataset_id"] =
table["datasetId"]
+ persist_kwargs["project_id"] =
table["projectId"]
+ BigQueryTableLink.persist(**persist_kwargs)
+
+ self.job_id = job.job_id
+ if self.project_id:
+ job_id_path = convert_job_id(
+ job_id=self.job_id,
+ project_id=self.project_id,
+ location=self.location,
+ )
+ warnings.warn(
+ "BigQueryInsertJobOperator's `job_id_path` XCom is deprecated
and will be removed in a "
+ "future provider release. Use the operator return value or
BigQuery job extra link instead.",
+ AirflowProviderDeprecationWarning,
+ stacklevel=2,
+ )
+ context["ti"].xcom_push(key="job_id_path", value=job_id_path)
+
+ persist_kwargs = {
+ "context": context,
+ "project_id": self.project_id,
+ "location": self.location,
+ "job_id": self.job_id,
+ }
+ BigQueryJobDetailLink.persist(**persist_kwargs)
+
def _submit_new_job_on_retry(
self,
context: Any,
@@ -2442,6 +2520,34 @@ class BigQueryInsertJobOperator(GoogleCloudBaseOperator,
_BigQueryInsertJobOpera
return job
def execute(self, context: Any):
+ self._configured_job_id = self.job_id
+ if not self.deferrable:
+ self.execute_resumable(context)
+ return self.job_id
+
+ self.job_id = self.submit_job(context)
+ job = self._job
+
+ if job.running():
+ self.defer(
+ timeout=self.execution_timeout,
+ trigger=BigQueryInsertJobTrigger(
+ 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]
+ poll_interval=self.poll_interval,
+ impersonation_chain=self.impersonation_chain,
+ cancel_on_kill=self.cancel_on_kill,
+ ),
+ method_name="execute_complete",
+ )
+ self.log.info("Current state of job %s is %s", job.job_id, job.state)
+ self._handle_job_error(job)
+ return self.job_id
+
+ def submit_job(self, context: Any) -> str:
+ """Submit the job (or reattach per today's
Conflict/reattach_states/429 rules) and return its id."""
hook = BigQueryHook(
gcp_conn_id=self.gcp_conn_id,
impersonation_chain=self.impersonation_chain,
@@ -2459,11 +2565,11 @@ class
BigQueryInsertJobOperator(GoogleCloudBaseOperator, _BigQueryInsertJobOpera
# To maintain backward compatibility, the try_number is appended to
the job name
# only starting from the 2nd attempt.
ti_try_number = None
- if self.job_id is None and context["ti"].try_number > 2:
+ if self._configured_job_id is None and context["ti"].try_number > 2:
ti_try_number = context["ti"].try_number - 1
self.job_id = hook.generate_job_id(
- job_id=self.job_id,
+ job_id=self._configured_job_id,
dag_id=self.dag_id,
task_id=self.task_id,
logical_date=None,
@@ -2516,77 +2622,49 @@ class
BigQueryInsertJobOperator(GoogleCloudBaseOperator, _BigQueryInsertJobOpera
# We are reattaching to a job
self.log.info("Reattaching to existing Job in state %s",
job.state)
- job_types = {
- LoadJob._JOB_TYPE: ["sourceTable", "destinationTable"],
- CopyJob._JOB_TYPE: ["sourceTable", "destinationTable"],
- ExtractJob._JOB_TYPE: ["sourceTable"],
- QueryJob._JOB_TYPE: ["destinationTable"],
- }
-
- if self.project_id:
- for job_type, tables_prop in job_types.items():
- job_configuration = job.to_api_repr()["configuration"]
- if job_type in job_configuration:
- for table_prop in tables_prop:
- if table_prop in job_configuration[job_type]:
- table = job_configuration[job_type][table_prop]
- persist_kwargs = {
- "context": context,
- "project_id": self.project_id,
- "table_id": table,
- }
- if not isinstance(table, str):
- persist_kwargs["table_id"] = table["tableId"]
- persist_kwargs["dataset_id"] =
table["datasetId"]
- persist_kwargs["project_id"] =
table["projectId"]
- BigQueryTableLink.persist(**persist_kwargs)
-
- self.job_id = job.job_id
- if self.project_id:
- job_id_path = convert_job_id(
- job_id=self.job_id,
- project_id=self.project_id,
- location=self.location,
+ self._job = job
+ self._persist_job_links(job, context)
+ return job.job_id
+
+ def get_job_status(self, external_id: JsonValue, context: Any) -> str:
+ """Query the raw job status; a missing job degrades to a not_found
sentinel."""
+ job_id = str(external_id)
+ # On reconnect/already-succeeded, submit_job never runs, so
hook/project_id normally
+ # resolved there must be resolved here too.
+ if self.hook is None:
+ self.hook = BigQueryHook(
+ gcp_conn_id=self.gcp_conn_id,
impersonation_chain=self.impersonation_chain
)
- warnings.warn(
- "BigQueryInsertJobOperator's `job_id_path` XCom is deprecated
and will be removed in a "
- "future provider release. Use the operator return value or
BigQuery job extra link instead.",
- AirflowProviderDeprecationWarning,
- stacklevel=2,
- )
- context["ti"].xcom_push(key="job_id_path", value=job_id_path)
+ if self.project_id is None:
+ self.project_id = self.hook.project_id
- persist_kwargs = {
- "context": context,
- "project_id": self.project_id,
- "location": self.location,
- "job_id": self.job_id,
- }
- BigQueryJobDetailLink.persist(**persist_kwargs)
+ try:
+ job = self.hook.get_job(project_id=self.project_id,
location=self.location, job_id=job_id)
+ except NotFound:
+ return "not_found"
+ # Reuse the same link bookkeeping as submit_job: this is the only
place a job object is
+ # obtained when reconnecting to (or finding already-succeeded) a
previously stored id.
+ self._job = job
+ self._persist_job_links(job, context)
+ if job.state != "DONE":
+ return job.state
+ return "error" if job.error_result else "success"
- # Wait for the job to complete
- if not self.deferrable:
- job.result(timeout=self.result_timeout, retry=self.result_retry)
- self._handle_job_error(job)
- return self.job_id
+ def is_job_active(self, status: str) -> bool:
+ return status not in ("success", "error", "not_found")
- if job.running():
- self.defer(
- timeout=self.execution_timeout,
- trigger=BigQueryInsertJobTrigger(
- conn_id=self.gcp_conn_id,
- job_id=self.job_id,
- project_id=self.project_id,
- location=self.location or hook.location,
- poll_interval=self.poll_interval,
- impersonation_chain=self.impersonation_chain,
- cancel_on_kill=self.cancel_on_kill,
- ),
- method_name="execute_complete",
- )
- self.log.info("Current state of job %s is %s", job.job_id, job.state)
+ def is_job_succeeded(self, status: str) -> bool:
+ return status == "success"
+
+ def poll_until_complete(self, external_id: JsonValue, context: Any) ->
None:
+ # self._job is set by whichever of submit_job / get_job_status last
obtained it,
+ # never fetched again here, since neither of those calls skip setting
it.
+ job = self._job
+ job.result(timeout=self.result_timeout, retry=self.result_retry)
self._handle_job_error(job)
- return self.job_id
+
+ def get_job_result(self, external_id: JsonValue, context: Any) -> None:
+ return None
def execute_complete(self, context: Context, event: dict[str, Any]) -> str
| None:
"""
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 284342b7f70..28e46235dce 100644
--- a/providers/google/tests/unit/google/cloud/operators/test_bigquery.py
+++ b/providers/google/tests/unit/google/cloud/operators/test_bigquery.py
@@ -28,7 +28,7 @@ import pandas as pd
import pytest
from google.cloud.bigquery import DEFAULT_RETRY, ScalarQueryParameter, Table
from google.cloud.bigquery.routine import Routine
-from google.cloud.exceptions import Conflict
+from google.cloud.exceptions import Conflict, NotFound
from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.providers.common.compat.openlineage.facet import (
@@ -84,6 +84,8 @@ from airflow.providers.google.cloud.triggers.bigquery import (
from airflow.utils.task_group import TaskGroup
from airflow.utils.timezone import datetime
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS
+
TASK_ID = "test-bq-generic-operator"
TEST_DATASET = "test-dataset"
TEST_DATASET_LOCATION = "EU"
@@ -1056,6 +1058,7 @@ class TestBigQueryInsertJobOperator:
location=TEST_DATASET_LOCATION,
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
+ durable=False,
)
result = op.execute(context=MagicMock())
assert configuration["labels"] == {"airflow-dag": "adhoc_airflow",
"airflow-task": "insert_query_job"}
@@ -1094,6 +1097,7 @@ class TestBigQueryInsertJobOperator:
location=TEST_DATASET_LOCATION,
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
+ durable=False,
)
with pytest.warns(AirflowProviderDeprecationWarning,
match="`job_id_path` XCom is deprecated"):
@@ -1127,6 +1131,7 @@ class TestBigQueryInsertJobOperator:
location=TEST_DATASET_LOCATION,
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
+ durable=False,
)
result = op.execute(context=MagicMock())
assert configuration["labels"] == {"airflow-dag": "adhoc_airflow",
"airflow-task": "copy_query_job"}
@@ -1167,6 +1172,7 @@ class TestBigQueryInsertJobOperator:
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
cancel_on_kill=False,
+ durable=False,
)
op.execute(context=MagicMock())
@@ -1210,6 +1216,7 @@ class TestBigQueryInsertJobOperator:
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
cancel_on_kill=True,
+ durable=False,
)
with pytest.raises(AirflowTaskTimeout):
op.execute(context=MagicMock())
@@ -1244,6 +1251,7 @@ class TestBigQueryInsertJobOperator:
location=TEST_DATASET_LOCATION,
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
+ durable=False,
)
with pytest.raises(AirflowException):
op.execute(context=MagicMock())
@@ -1280,6 +1288,7 @@ class TestBigQueryInsertJobOperator:
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
reattach_states={"PENDING", "RUNNING"},
+ durable=False,
)
result = op.execute(context=MagicMock())
@@ -1326,6 +1335,7 @@ class TestBigQueryInsertJobOperator:
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
reattach_states={"PENDING"},
+ durable=False,
)
with pytest.raises(AirflowException):
# Not possible to reattach to any state if job is already DONE
@@ -1359,6 +1369,7 @@ class TestBigQueryInsertJobOperator:
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
force_rerun=True,
+ durable=False,
)
result = op.execute(context=MagicMock())
@@ -1404,6 +1415,7 @@ class TestBigQueryInsertJobOperator:
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
reattach_states={"PENDING"},
+ durable=False,
)
# No force rerun
with pytest.raises(AirflowException):
@@ -1696,6 +1708,7 @@ class TestBigQueryInsertJobOperator:
job_id=None, # job_id must be None to trigger the try_number logic
project_id=TEST_GCP_PROJECT_ID,
force_rerun=False,
+ durable=False,
)
op.execute(context=context)
@@ -1754,6 +1767,7 @@ class TestBigQueryInsertJobOperator:
job_id=None,
project_id=TEST_GCP_PROJECT_ID,
force_rerun=False,
+ durable=False,
)
result = op.execute(context=context)
@@ -1811,6 +1825,7 @@ class TestBigQueryInsertJobOperator:
location=TEST_DATASET_LOCATION,
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
+ durable=False,
)
result = op.execute(context=MagicMock())
@@ -1858,6 +1873,7 @@ class TestBigQueryInsertJobOperator:
location=TEST_DATASET_LOCATION,
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
+ durable=False,
)
mock_hook.return_value.generate_job_id.return_value = "1234"
mock_hook.return_value.get_client.return_value.get_job.side_effect =
RuntimeError()
@@ -1944,6 +1960,7 @@ class TestBigQueryInsertJobOperator:
location=TEST_DATASET_LOCATION,
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
+ durable=False,
)
op.execute(context=MagicMock())
assert configuration["labels"] == {
@@ -1977,6 +1994,7 @@ class TestBigQueryInsertJobOperator:
location=TEST_DATASET_LOCATION,
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
+ durable=False,
)
op.execute(context=MagicMock())
assert configuration["labels"] is None
@@ -2005,6 +2023,7 @@ class TestBigQueryInsertJobOperator:
location=TEST_DATASET_LOCATION,
job_id=job_id,
project_id=TEST_GCP_PROJECT_ID,
+ durable=False,
)
op.execute(context=MagicMock())
@@ -2261,6 +2280,277 @@ class TestBigQueryInsertJobOperator:
op._handle_job_error(job_empty_error)
[email protected](
+ not AIRFLOW_V_3_3_PLUS, reason="task_state_store (durable execution)
requires Airflow 3.3+"
+)
+class TestBigQueryInsertJobOperatorDurable:
+ @staticmethod
+ def _context(task_store=None):
+ ctx: dict = {"ti": MagicMock(stats_tags={}, try_number=1)}
+ if task_store is not None:
+ ctx["task_state_store"] = task_store
+ return ctx
+
+ @staticmethod
+ def _make_operator(**kwargs):
+ configuration = {"query": {"query": "SELECT 1", "useLegacySql": False}}
+ kwargs.setdefault("project_id", TEST_GCP_PROJECT_ID)
+ return BigQueryInsertJobOperator(
+ task_id="insert_query_job",
+ configuration=configuration,
+ location=TEST_DATASET_LOCATION,
+ **kwargs,
+ )
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_persists_job_id_to_task_state_store_on_fresh_submit(self,
mock_hook):
+ op = self._make_operator()
+ mock_hook.return_value.generate_job_id.return_value =
"generated-job-id"
+ mock_hook.return_value.insert_job.return_value = MagicMock(
+ state="DONE", job_id="generated-job-id", error_result=False
+ )
+ task_store = MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = None
+
+ result = op.execute(self._context(task_store))
+
+ mock_hook.return_value.insert_job.assert_called_once()
+ task_store.set.assert_called_once_with("bigquery_job_id",
"generated-job-id")
+ assert result == "generated-job-id"
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_reconnects_to_running_job_without_resubmitting(self, mock_hook):
+ op = self._make_operator()
+ job = MagicMock(state="RUNNING", job_id="stored-job-id",
error_result=False)
+ job.result.side_effect = lambda **_: setattr(job, "state", "DONE")
+ mock_hook.return_value.get_job.return_value = job
+ task_store = MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = "stored-job-id"
+
+ result = op.execute(self._context(task_store))
+
+ mock_hook.return_value.insert_job.assert_not_called()
+ mock_hook.return_value.generate_job_id.assert_not_called()
+ task_store.set.assert_not_called()
+ job.result.assert_called_once()
+ assert result == "stored-job-id"
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_reconnect_wins_over_force_rerun_default(self, mock_hook):
+ """Headline behavior: force_rerun=True (the operator default) no
longer defeats
+ reattach, since a retry reconnects via the persisted id instead of
recomputing a
+ fresh uuid-based id that would never match the original."""
+ op = self._make_operator() # force_rerun defaults to True
+ job = MagicMock(state="RUNNING", job_id="stored-job-id",
error_result=False)
+ job.result.side_effect = lambda **_: setattr(job, "state", "DONE")
+ mock_hook.return_value.get_job.return_value = job
+ task_store = MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = "stored-job-id"
+
+ result = op.execute(self._context(task_store))
+
+ mock_hook.return_value.generate_job_id.assert_not_called()
+ mock_hook.return_value.insert_job.assert_not_called()
+ assert result == "stored-job-id"
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_already_succeeded_returns_result_without_polling(self, mock_hook):
+ op = self._make_operator()
+ job = MagicMock(state="DONE", job_id="stored-job-id",
error_result=False)
+ mock_hook.return_value.get_job.return_value = job
+ task_store = MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = "stored-job-id"
+
+ result = op.execute(self._context(task_store))
+
+ mock_hook.return_value.insert_job.assert_not_called()
+ job.result.assert_not_called()
+ assert result == "stored-job-id"
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_resubmits_when_stored_job_in_terminal_failure(self, mock_hook):
+ op = self._make_operator()
+ failed_job = MagicMock(state="DONE", job_id="stored-job-id",
error_result="boom")
+ mock_hook.return_value.get_job.return_value = failed_job
+ mock_hook.return_value.generate_job_id.return_value = "new-job-id"
+ mock_hook.return_value.insert_job.return_value = MagicMock(
+ state="DONE", job_id="new-job-id", error_result=False
+ )
+ task_store = MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = "stored-job-id"
+
+ result = op.execute(self._context(task_store))
+
+ mock_hook.return_value.insert_job.assert_called_once()
+ task_store.set.assert_called_once_with("bigquery_job_id", "new-job-id")
+ assert result == "new-job-id"
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def
test_resubmit_computes_id_from_configured_job_id_not_stale_stored_id(self,
mock_hook):
+ """Regression test: get_job_status persists the stored (failed) job's
id onto
+ self.job_id before terminal_resubmit falls through to submit_job.
submit_job must
+ still compute the new id from the user's originally configured job_id,
not from
+ that stale value -- otherwise ids grow an extra suffix on every failed
retry."""
+ op = self._make_operator(job_id="my_job", force_rerun=False)
+ failed_job = MagicMock(state="DONE", job_id="my_job_oldsuffix",
error_result="boom")
+ mock_hook.return_value.get_job.return_value = failed_job
+ mock_hook.return_value.generate_job_id.return_value =
"my_job_newsuffix"
+ mock_hook.return_value.insert_job.return_value = MagicMock(
+ state="DONE", job_id="my_job_newsuffix", error_result=False
+ )
+ task_store = MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = "my_job_oldsuffix"
+
+ op.execute(self._context(task_store))
+
+ mock_hook.return_value.generate_job_id.assert_called_once_with(
+ job_id="my_job",
+ dag_id=op.dag_id,
+ task_id=op.task_id,
+ logical_date=None,
+ configuration=op.configuration,
+
run_after=mock_hook.return_value.get_run_after_or_logical_date.return_value,
+ force_rerun=False,
+ try_number=None,
+ )
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_configured_job_id_uses_rendered_template_not_raw_jinja(self,
mock_hook):
+ """
+ Test to validate that _configured_job_id must be snapshotted after
template
+ rendering. job_id is a template field, rendering happens between
__init__ and
+ execute(), so capturing it in __init__ would feed the raw, unrendered
Jinja
+ string into generate_job_id instead of the rendered value.
+ """
+ op = self._make_operator(job_id="report_{{ ds_nodash }}",
force_rerun=False)
+ op.job_id = "report_20260101" # simulate template rendering having
already run
+ mock_hook.return_value.generate_job_id.return_value = "report_20260101"
+ mock_hook.return_value.insert_job.return_value = MagicMock(
+ state="DONE", job_id="report_20260101", error_result=False
+ )
+ task_store = MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = None
+
+ op.execute(self._context(task_store))
+
+ mock_hook.return_value.generate_job_id.assert_called_once_with(
+ job_id="report_20260101",
+ dag_id=op.dag_id,
+ task_id=op.task_id,
+ logical_date=None,
+ configuration=op.configuration,
+
run_after=mock_hook.return_value.get_run_after_or_logical_date.return_value,
+ force_rerun=False,
+ try_number=None,
+ )
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_resubmits_when_stored_job_not_found(self, mock_hook):
+ op = self._make_operator()
+ mock_hook.return_value.get_job.side_effect = NotFound("gone")
+ mock_hook.return_value.generate_job_id.return_value = "new-job-id"
+ mock_hook.return_value.insert_job.return_value = MagicMock(
+ state="DONE", job_id="new-job-id", error_result=False
+ )
+ task_store = MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = "stored-job-id"
+
+ result = op.execute(self._context(task_store))
+
+ mock_hook.return_value.insert_job.assert_called_once()
+ task_store.set.assert_called_once_with("bigquery_job_id", "new-job-id")
+ assert result == "new-job-id"
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_durable_false_never_touches_task_state_store(self, mock_hook):
+ op = self._make_operator(durable=False)
+ mock_hook.return_value.generate_job_id.return_value =
"generated-job-id"
+ mock_hook.return_value.insert_job.return_value = MagicMock(
+ state="DONE", job_id="generated-job-id", error_result=False
+ )
+ task_store = MagicMock(spec_set=["get", "set"])
+
+ op.execute(self._context(task_store))
+
+ mock_hook.return_value.insert_job.assert_called_once()
+ task_store.get.assert_not_called()
+ task_store.set.assert_not_called()
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_deferrable_unaffected_by_durable(self, mock_hook):
+ op = self._make_operator(deferrable=True)
+ mock_hook.return_value.generate_job_id.return_value =
"generated-job-id"
+ running_job = MagicMock(state="RUNNING", job_id="generated-job-id",
error_result=False)
+ running_job.running.return_value = True
+ mock_hook.return_value.insert_job.return_value = running_job
+ task_store = MagicMock(spec_set=["get", "set"])
+
+ with pytest.raises(TaskDeferred) as exc:
+ op.execute(self._context(task_store))
+
+ assert isinstance(exc.value.trigger, BigQueryInsertJobTrigger)
+ task_store.get.assert_not_called()
+ task_store.set.assert_not_called()
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_get_job_status_success_when_done_without_error(self, mock_hook):
+ op = self._make_operator()
+ op.hook = mock_hook.return_value
+ mock_hook.return_value.get_job.return_value = MagicMock(state="DONE",
error_result=False)
+
+ assert op.get_job_status("some-id", context=self._context()) ==
"success"
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_get_job_status_error_when_done_with_error(self, mock_hook):
+ op = self._make_operator()
+ op.hook = mock_hook.return_value
+ mock_hook.return_value.get_job.return_value = MagicMock(state="DONE",
error_result="boom")
+
+ assert op.get_job_status("some-id", context=self._context()) == "error"
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_get_job_status_returns_raw_state_when_not_done(self, mock_hook):
+ op = self._make_operator()
+ op.hook = mock_hook.return_value
+ mock_hook.return_value.get_job.return_value =
MagicMock(state="RUNNING", error_result=False)
+
+ assert op.get_job_status("some-id", context=self._context()) ==
"RUNNING"
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_get_job_status_not_found(self, mock_hook):
+ op = self._make_operator()
+ op.hook = mock_hook.return_value
+ mock_hook.return_value.get_job.side_effect = NotFound("gone")
+
+ assert op.get_job_status("some-id", context=self._context()) ==
"not_found"
+
+
@mock.patch("airflow.providers.google.cloud.operators.bigquery.BigQueryHook")
+ def test_get_job_status_resolves_hook_and_project_id_when_unset(self,
mock_hook):
+ """Regression test: on reconnect, submit_job never runs, so
get_job_status must
+ resolve self.hook / self.project_id itself rather than assuming
they're set."""
+ op = self._make_operator(project_id=None)
+ assert op.hook is None
+ mock_hook.return_value.project_id = "resolved-project"
+ mock_hook.return_value.get_job.return_value = MagicMock(state="DONE",
error_result=False)
+
+ status = op.get_job_status("some-id", context=self._context())
+
+ assert status == "success"
+ assert op.hook is not None
+ assert op.project_id == "resolved-project"
+
+ def test_is_job_active_and_is_job_succeeded_predicates(self):
+ op = self._make_operator()
+
+ for status in ("RUNNING", "PENDING"):
+ assert op.is_job_active(status) is True
+ for status in ("success", "error", "not_found"):
+ assert op.is_job_active(status) is False
+
+ assert op.is_job_succeeded("success") is True
+ assert op.is_job_succeeded("RUNNING") is False
+
+
class TestBigQueryIntervalCheckOperator:
def test_bigquery_interval_check_operator_execute_complete(self):
"""Asserts that logging occurs as expected"""