Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion airflow/providers/google/cloud/hooks/bigquery.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@

log = logging.getLogger(__name__)

BigQueryJob = Union[CopyJob, QueryJob, LoadJob, ExtractJob]


# pylint: disable=too-many-public-methods
class BigQueryHook(GoogleBaseHook, DbApiHook):
Expand Down Expand Up @@ -1435,7 +1437,7 @@ def insert_job(
job_id: Optional[str] = None,
project_id: Optional[str] = None,
location: Optional[str] = None,
) -> Union[CopyJob, QueryJob, LoadJob, ExtractJob]:
) -> BigQueryJob:
"""
Executes a BigQuery job. Waits for the job to complete and returns job id.
See here:
Expand Down
107 changes: 83 additions & 24 deletions airflow/providers/google/cloud/operators/bigquery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
"""

Expand All @@ -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,
Expand All @@ -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()
Comment thread
turbaszek marked this conversation as resolved.
Outdated
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?
Would this not by default reattach to the originally succeeded job (rather than expected behavior or re-running the job)? Should this be configurable?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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:
Imagine a streaming job in charge of inserting records to an hourly partitioned fact table and a DAG responsible for running some hourly analytics query (joins to dimension tables / aggregation). Once the processing time watermark passes the end of the partition interval original scheduled dag run runs (BigQueryInsertJobOperator task runs a query on the data at this processing time). Then late data (event time << processing time) arrives that should still go to the old partition (because partitioning is on event time). When the streaming job detects this it could use some mechanism (e.g. the REST API or an alert to a human who manually re-runs the dag for an execution date) to tell the DAG (and this BigQueryInsertJobOperator) to re-run because the original run for this interval / partition was actually against incomplete data.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 job_id needs to be more unique than just airflow_{self.dag_id}_{self.task_id}_{exec_date}_ in order to account for this.

If I clear the status of a task in a DAG, I expect that task to be fully rerun.

@mik-laj mik-laj Jul 29, 2020

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the query if fully rerun.

That was the original behaviour as the job_id was always generated by discovery API.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. I'd say that should be the default going forward too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

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 force_rerun=True parameter that controls how the job id is generated.

# 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`."
Comment thread
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
Loading