-
Notifications
You must be signed in to change notification settings - Fork 17.4k
Improve idempotency of BigQueryInsertJobOperator #9590
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
69d581d
256d093
f704528
ce440b9
147aaad
7dd582d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,21 +21,22 @@ | |
| This module contains Google BigQuery operators. | ||
| """ | ||
| import enum | ||
| import hashlib | ||
| import json | ||
| import re | ||
| import uuid | ||
| import warnings | ||
| from time import sleep, time | ||
| from typing import Any, Dict, Iterable, List, Optional, SupportsAbs, Union | ||
| from typing import Any, Dict, Iterable, List, Optional, Set, SupportsAbs, Union | ||
|
|
||
| import attr | ||
| from google.api_core.exceptions import Conflict | ||
| from google.api_core.retry import exponential_sleep_generator | ||
| from google.cloud.bigquery import TableReference | ||
|
|
||
| from airflow.exceptions import AirflowException | ||
| from airflow.models import BaseOperator, BaseOperatorLink | ||
| from airflow.models.taskinstance import TaskInstance | ||
| from airflow.operators.check_operator import CheckOperator, IntervalCheckOperator, ValueCheckOperator | ||
| from airflow.providers.google.cloud.hooks.bigquery import BigQueryHook | ||
| from airflow.providers.google.cloud.hooks.bigquery import BigQueryHook, BigQueryJob | ||
| from airflow.providers.google.cloud.hooks.gcs import GCSHook, _parse_gcs_url | ||
| from airflow.utils.decorators import apply_defaults | ||
|
|
||
|
|
@@ -1631,7 +1632,18 @@ def execute(self, context): | |
| class BigQueryInsertJobOperator(BaseOperator): | ||
| """ | ||
| Executes a BigQuery job. Waits for the job to complete and returns job id. | ||
| See here: | ||
| This operator work in the following way: | ||
|
|
||
| - it calculates a unique hash of the job using job's configuration or uuid if ``force_rerun`` is True | ||
| - creates ``job_id`` in form of | ||
| ``[provided_job_id | airflow_{dag_id}_{task_id}_{exec_date}]_{uniqueness_suffix}`` | ||
| - submits a BigQuery job using the ``job_id`` | ||
| - if job with given id already exists then it tries to reattach to the job if its not done and its | ||
| state is in ``reattach_states``. If the job is done the operator will raise ``AirflowException``. | ||
|
|
||
| Using ``force_rerun`` will submit a new job everytime without attaching to already existing ones. | ||
|
|
||
| For job definition see here: | ||
|
|
||
| https://cloud.google.com/bigquery/docs/reference/v2/jobs | ||
|
|
||
|
|
@@ -1644,15 +1656,21 @@ class BigQueryInsertJobOperator(BaseOperator): | |
| configuration field in the job object. For more details see | ||
| https://cloud.google.com/bigquery/docs/reference/v2/jobs | ||
| :type configuration: Dict[str, Any] | ||
| :param job_id: The ID of the job. The ID must contain only letters (a-z, A-Z), | ||
| numbers (0-9), underscores (_), or dashes (-). The maximum length is 1,024 | ||
| characters. If not provided then uuid will be generated. | ||
| :param job_id: The ID of the job. It will be suffixed with hash of job configuration | ||
| unless ``force_rerun`` is True. | ||
| The ID must contain only letters (a-z, A-Z), numbers (0-9), underscores (_), or | ||
| dashes (-). The maximum length is 1,024 characters. If not provided then uuid will | ||
| be generated. | ||
| :type job_id: str | ||
| :param force_rerun: If True then operator will use hash of uuid as job id suffix | ||
| :type force_rerun: bool | ||
| :param reattach_states: Set of BigQuery job's states in case of which we should reattach | ||
| to the job. Should be other than final states. | ||
| :param project_id: Google Cloud Project where the job is running | ||
| :type project_id: str | ||
| :param location: location the job is running | ||
| :type location: str | ||
| :param gcp_conn_id: (Optional) The connection ID used to connect to Google Cloud Platform. | ||
| :param gcp_conn_id: The connection ID used to connect to Google Cloud Platform. | ||
| :type gcp_conn_id: str | ||
| """ | ||
|
|
||
|
|
@@ -1666,6 +1684,8 @@ def __init__( | |
| project_id: Optional[str] = None, | ||
| location: Optional[str] = None, | ||
| job_id: Optional[str] = None, | ||
| force_rerun: bool = True, | ||
| reattach_states: Optional[Set[str]] = None, | ||
| gcp_conn_id: str = 'google_cloud_default', | ||
| delegate_to: Optional[str] = None, | ||
| **kwargs, | ||
|
|
@@ -1677,39 +1697,78 @@ def __init__( | |
| self.project_id = project_id | ||
| self.gcp_conn_id = gcp_conn_id | ||
| self.delegate_to = delegate_to | ||
| self.force_rerun = force_rerun | ||
| self.reattach_states: Set[str] = reattach_states or set() | ||
|
|
||
| def prepare_template(self) -> None: | ||
| # If .json is passed then we have to read the file | ||
| if isinstance(self.configuration, str) and self.configuration.endswith('.json'): | ||
| with open(self.configuration, 'r') as file: | ||
| self.configuration = json.loads(file.read()) | ||
|
|
||
| def _submit_job( | ||
| self, | ||
| hook: BigQueryHook, | ||
| job_id: str, | ||
| ) -> BigQueryJob: | ||
| # Submit a new job | ||
| job = hook.insert_job( | ||
| configuration=self.configuration, | ||
| project_id=self.project_id, | ||
| location=self.location, | ||
| job_id=job_id, | ||
| ) | ||
| # Start the job and wait for it to complete and get the result. | ||
| job.result() | ||
| return job | ||
|
|
||
| @staticmethod | ||
| def _handle_job_error(job: BigQueryJob) -> None: | ||
| if job.error_result: | ||
| raise AirflowException(f"BigQuery job {job.job_id} failed: {job.error_result}") | ||
|
|
||
| def _job_id(self, context): | ||
| if self.force_rerun: | ||
| hash_base = str(uuid.uuid4()) | ||
| else: | ||
| hash_base = json.dumps(self.configuration, sort_keys=True) | ||
|
|
||
| uniqueness_suffix = hashlib.md5(hash_base.encode()).hexdigest() | ||
|
|
||
| if self.job_id: | ||
| return f"{self.job_id}_{uniqueness_suffix}" | ||
|
|
||
| exec_date = re.sub(r"\:|-|\+", "_", context['execution_date'].isoformat()) | ||
| return f"airflow_{self.dag_id}_{self.task_id}_{exec_date}_{uniqueness_suffix}" | ||
|
|
||
| def execute(self, context: Any): | ||
| hook = BigQueryHook( | ||
| gcp_conn_id=self.gcp_conn_id, | ||
| delegate_to=self.delegate_to, | ||
| ) | ||
|
|
||
| job_id = self.job_id or f"airflow_{self.task_id}_{int(time())}" | ||
| job_id = self._job_id(context) | ||
|
|
||
| try: | ||
| job = hook.insert_job( | ||
| configuration=self.configuration, | ||
| project_id=self.project_id, | ||
| location=self.location, | ||
| job_id=job_id, | ||
| ) | ||
| # Start the job and wait for it to complete and get the result. | ||
| job.result() | ||
| job = self._submit_job(hook, job_id) | ||
| self._handle_job_error(job) | ||
| except Conflict: | ||
| # If the job already exists retrieve it | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what does this do to the the behavior for re-running a DAG for an execution date?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My question is: if job succeded why should we rerun it instead of using the existing result?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In short, the underlying table / partition could have changed between the original run and the new run and in many cases the user will want to ensure that the result of this job is based on a fresh run of the query. Airflow cannot know if the underlying table(s) have changed since the original run. To give an example use case:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @turbaszek I tend to agree with @jaketf. We frequently might want to rerun a DAG for a prior, successful execution date at some point in the future that would overwrite data in a partitioned BigQuery table. Typically this might occur because of a change in the business logic. So, in my opinion, the If I clear the status of a task in a DAG, I expect that task to be fully rerun.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we will not be able to create universal behavior that will keep idempotency. Some users want to always execute SQL query, others want to use predetermined results, and others want to re-query only when it is backfill. All the cases sound correct and they cannot always be combined into a single operator without additional parameters.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Then there should be additional parameters. IMHO, I don't think it is controversial to suggest that, when the status of a BQ task is cleared, the default behaviour should be that the query if fully rerun. Other cases sound like less common scenarios (in my experience at least).
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That was the original behaviour as the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Indeed. I'd say that should be the default going forward too.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This is an interesting point. Perhaps we consider bringing back the deprecated BigQueryOperator (contract is always runs a new query) and than have this BigQueryInsertJobOperator (contract is create job if not succeeded exists). IMHO we can have both behaviors in a single operator with a # choice of uuid
uniqueness_hash = hash(uuid.uuid4()) if self.force_rerun else hash(job_config)
job_id = f"{dag_id}{task_id}{exec_date}{uniqueness_hash}" |
||
| job = hook.get_job( | ||
| project_id=self.project_id, | ||
| location=self.location, | ||
| job_id=job_id, | ||
| ) | ||
| # Get existing job and wait for it to be ready | ||
| for time_to_wait in exponential_sleep_generator(initial=10, maximum=120): | ||
| sleep(time_to_wait) | ||
| job.reload() | ||
| if job.done(): | ||
| break | ||
| if job.state in self.reattach_states: | ||
| # We are reattaching to a job | ||
| job.result() | ||
| self._handle_job_error(job) | ||
| else: | ||
| # Same job configuration so we need force_rerun | ||
| raise AirflowException( | ||
| f"Job with id: {job_id} already exists and is in {job.state} state. If you " | ||
| f"want to force rerun it consider setting `force_rerun=True`." | ||
|
turbaszek marked this conversation as resolved.
|
||
| f"Or, if you want to reattach in this scenario add {job.state} to `reattach_states`" | ||
| ) | ||
|
|
||
| return job.job_id | ||
Uh oh!
There was an error while loading. Please reload this page.