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 0f0873630af Emit per-statement OpenLineage events for BigQuery script
jobs (#69234)
0f0873630af is described below
commit 0f0873630af036a698bb7aec31d54e5944bd9d76
Author: Aaron Chen <[email protected]>
AuthorDate: Thu Jul 23 01:24:00 2026 +0800
Emit per-statement OpenLineage events for BigQuery script jobs (#69234)
---
.../providers/google/cloud/openlineage/mixins.py | 116 +++++-
.../unit/google/cloud/openlineage/test_mixins.py | 390 ++++++++++++++++++++-
2 files changed, 497 insertions(+), 9 deletions(-)
diff --git
a/providers/google/src/airflow/providers/google/cloud/openlineage/mixins.py
b/providers/google/src/airflow/providers/google/cloud/openlineage/mixins.py
index 539c24f6533..3e5520721ed 100644
--- a/providers/google/src/airflow/providers/google/cloud/openlineage/mixins.py
+++ b/providers/google/src/airflow/providers/google/cloud/openlineage/mixins.py
@@ -21,6 +21,7 @@ import copy
import json
import traceback
from collections.abc import Iterable
+from datetime import datetime, timezone
from typing import TYPE_CHECKING, cast
from airflow.providers.common.compat.openlineage.facet import (
@@ -53,7 +54,7 @@ if TYPE_CHECKING:
class _BigQueryInsertJobOperatorOpenLineageMixin:
"""Mixin for BigQueryInsertJobOperator to extract OpenLineage metadata."""
- def get_openlineage_facets_on_complete(self, _):
+ def get_openlineage_facets_on_complete(self, task_instance):
"""
Retrieve OpenLineage data for a completed BigQuery job.
@@ -109,14 +110,45 @@ class _BigQueryInsertJobOperatorOpenLineageMixin:
run_facets["bigQueryJob"] =
self._get_bigquery_job_run_facet(job_properties)
if get_from_nullable_chain(job_properties, ["statistics",
"numChildJobs"]):
- self.log.debug("Found SCRIPT job. Extracting lineage from
child jobs instead.")
- # SCRIPT job type has no input / output information but spawns
child jobs that have one
+ self.log.debug("Found SCRIPT job. Extracting lineage from
child jobs.")
+ # SCRIPT job has no input/output of its own but spawns child
jobs that do. The parent
+ # task keeps the aggregated coarse-grained lineage (backward
compatible) while each
+ # child query is additionally emitted below as its own event
for per-statement detail.
#
https://cloud.google.com/bigquery/docs/information-schema-jobs#multi-statement_query_job
- for child_job_id in
self._client.list_jobs(parent_job=self.job_id):
- child_job_properties =
self._client.get_job(job_id=child_job_id)._properties
- child_inputs, child_outputs =
self._get_inputs_and_outputs(child_job_properties)
+ child_jobs_properties = []
+ for child_job in
self._client.list_jobs(parent_job=self.job_id):
+ try:
+ child_jobs_properties.append(
+
self._client.get_job(job_id=child_job.job_id)._properties
+ )
+ except Exception as child_exception:
+ self.log.warning(
+ "Cannot retrieve BigQuery child job `%s`. %s",
+ child_job.job_id,
+ child_exception,
+ exc_info=True,
+ )
+ child_jobs_properties.sort(key=self._get_child_job_sort_key)
+ for child_index, child_job_properties in
enumerate(child_jobs_properties, start=1):
+ try:
+ child_inputs, child_outputs =
self._get_inputs_and_outputs(child_job_properties)
+ except Exception as child_exception:
+ self.log.warning(
+ "Cannot extract lineage for BigQuery child job
`%s`. %s",
+ get_from_nullable_chain(child_job_properties,
["jobReference", "jobId"]),
+ child_exception,
+ exc_info=True,
+ )
+ continue
inputs.extend(child_inputs)
outputs.extend(child_outputs)
+ self._emit_child_query_lineage(
+ task_instance=task_instance,
+ child_index=child_index,
+ child_job_properties=child_job_properties,
+ inputs=child_inputs,
+ outputs=child_outputs,
+ )
else:
inputs, outputs = self._get_inputs_and_outputs(job_properties)
@@ -139,6 +171,78 @@ class _BigQueryInsertJobOperatorOpenLineageMixin:
job_facets={"sql":
SQLJobFacet(query=SQLParser.normalize_sql(self.sql))} if self.sql else {},
)
+ def _emit_child_query_lineage(
+ self,
+ *,
+ task_instance,
+ child_index: int,
+ child_job_properties: dict,
+ inputs: list[InputDataset | Dataset],
+ outputs: list[OutputDataset | Dataset],
+ ) -> None:
+ if task_instance is None:
+ self.log.debug("No task instance available. Skipping BigQuery
child job OpenLineage event.") # type: ignore[attr-defined]
+ return
+
+ try:
+ from airflow.providers.openlineage.api.sql import
emit_query_lineage
+ except ImportError:
+ self.log.debug( # type: ignore[attr-defined]
+ "The emit_query_lineage API requires
apache-airflow-providers-openlineage>=2.16.0. "
+ "Skipping BigQuery child job OpenLineage event."
+ )
+ return
+
+ from airflow.providers.openlineage.sqlparser import SQLParser
+
+ child_query = get_from_nullable_chain(child_job_properties,
["configuration", "query", "query"])
+ job_facets = {"sql":
SQLJobFacet(query=SQLParser.normalize_sql(child_query))} if child_query else
None
+ error_result = get_from_nullable_chain(child_job_properties,
["status", "errorResult"])
+ start_time = self._get_bigquery_job_datetime(child_job_properties,
"startTime")
+ end_time = self._get_bigquery_job_datetime(child_job_properties,
"endTime")
+ if start_time is None or end_time is None:
+ start_time = end_time = None
+ emit_query_lineage(
+ query_id=get_from_nullable_chain(child_job_properties,
["jobReference", "jobId"]),
+ query_source_namespace=BIGQUERY_NAMESPACE,
+ # Intentionally not passed as query_text: BigQuery job metadata
already provides
+ # authoritative lineage, and parser-derived datasets could
duplicate or conflict
+ # with it. The SQL is still attached via the sql facet in
additional_job_facets.
+ query_text=None,
+ inputs=inputs,
+ outputs=outputs,
+ start_time=start_time,
+ end_time=end_time,
+ is_successful=error_result is None,
+ error_message=error_result.get("message") if error_result else
None,
+ task_instance=task_instance,
+
job_name=f"{task_instance.dag_id}.{task_instance.task_id}.query.{child_index}",
+ additional_run_facets={"bigQueryJob":
self._get_bigquery_job_run_facet(child_job_properties)},
+ additional_job_facets=job_facets, # type: ignore[arg-type]
+ )
+
+ @staticmethod
+ def _get_bigquery_job_datetime(properties: dict, field_name: str) ->
datetime | None:
+ value = get_from_nullable_chain(properties, ["statistics", field_name])
+ if value is None:
+ return None
+ try:
+ return datetime.fromtimestamp(float(value) / 1000, tz=timezone.utc)
+ except (TypeError, ValueError, OverflowError):
+ return None
+
+ @classmethod
+ def _get_child_job_sort_key(cls, properties: dict) -> tuple[datetime, str]:
+ # Emit children ordered by execution time so the query.N suffix is
stable across runs,
+ # breaking ties deterministically by job id.
+ start_time = cls._get_bigquery_job_datetime(properties, "startTime")
+ return (
+ # None is not comparable with datetime, so a missing startTime
maps to
+ # datetime.max to sort those children last instead of crashing the
sort.
+ start_time or datetime.max.replace(tzinfo=timezone.utc),
+ get_from_nullable_chain(properties, ["jobReference", "jobId"]) or
"",
+ )
+
def _get_inputs_and_outputs(self, properties: dict) ->
tuple[list[InputDataset], list[OutputDataset]]:
job_type = get_from_nullable_chain(properties, ["configuration",
"jobType"])
diff --git
a/providers/google/tests/unit/google/cloud/openlineage/test_mixins.py
b/providers/google/tests/unit/google/cloud/openlineage/test_mixins.py
index e7204368fd5..0106eb81eb3 100644
--- a/providers/google/tests/unit/google/cloud/openlineage/test_mixins.py
+++ b/providers/google/tests/unit/google/cloud/openlineage/test_mixins.py
@@ -20,10 +20,12 @@ import copy
import json
import logging
import os
+from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from google.cloud.bigquery.table import Table
+from openlineage.client.event_v2 import RunState
from airflow.providers.common.compat.openlineage.facet import (
ColumnLineageDatasetFacet,
@@ -110,6 +112,31 @@ def read_common_json_file(rel: str):
return json.load(f)
+def make_task_instance():
+ logical_date = datetime(2024, 1, 1, tzinfo=timezone.utc)
+ dag_run = MagicMock(
+ logical_date=logical_date,
+ clear_number=0,
+ run_after=logical_date,
+ conf={},
+ )
+ ti = MagicMock(
+ dag_id="dag_id",
+ task_id="task_id",
+ try_number=1,
+ map_index=-1,
+ logical_date=logical_date,
+ )
+ ti.dag_run = dag_run
+ ti.get_template_context.return_value = {
+ "dag_run": dag_run,
+ "dag": MagicMock(),
+ "task": MagicMock(),
+ "task_instance": ti,
+ }
+ return ti
+
+
class TestBigQueryOpenLineageMixin:
def setup_method(self):
self.copy_job_details = read_common_json_file("copy_job_details.json")
@@ -395,7 +422,8 @@ class TestBigQueryOpenLineageMixin:
),
]
- def test_get_openlineage_facets_on_complete_script_job(self):
+ @patch("airflow.providers.openlineage.api.sql.emit_query_lineage")
+ def test_get_openlineage_facets_on_complete_script_job(self,
mock_emit_query_lineage):
self.client.get_job.side_effect = [
MagicMock(_properties=self.script_job_details),
MagicMock(_properties=self.query_job_details),
@@ -404,9 +432,10 @@ class TestBigQueryOpenLineageMixin:
Table.from_api_repr(read_common_json_file("table_details.json")),
Table.from_api_repr(read_common_json_file("out_table_details.json")),
]
- self.client.list_jobs.return_value = ["child_job_id"]
+ self.client.list_jobs.return_value = [MagicMock(job_id="child_job_id")]
+ mock_ti = make_task_instance()
- lineage = self.operator.get_openlineage_facets_on_complete(None)
+ lineage = self.operator.get_openlineage_facets_on_complete(mock_ti)
self.script_job_details["configuration"]["query"].pop("query")
assert lineage.run_facets == {
@@ -456,6 +485,361 @@ class TestBigQueryOpenLineageMixin:
},
),
]
+ mock_emit_query_lineage.assert_called_once()
+ assert (
+ mock_emit_query_lineage.call_args.kwargs["query_id"]
+ == self.query_job_details["jobReference"]["jobId"]
+ )
+ assert
mock_emit_query_lineage.call_args.kwargs["query_source_namespace"] == "bigquery"
+ assert mock_emit_query_lineage.call_args.kwargs["query_text"] is None
+ assert mock_emit_query_lineage.call_args.kwargs["is_successful"] is
True
+ assert mock_emit_query_lineage.call_args.kwargs["error_message"] is
None
+ assert mock_emit_query_lineage.call_args.kwargs["task_instance"] is
mock_ti
+ assert mock_emit_query_lineage.call_args.kwargs["job_name"] ==
"dag_id.task_id.query.1"
+ assert mock_emit_query_lineage.call_args.kwargs["start_time"] ==
datetime.fromtimestamp(
+ self.query_job_details["statistics"]["startTime"] / 1000,
tz=timezone.utc
+ )
+ assert mock_emit_query_lineage.call_args.kwargs["end_time"] ==
datetime.fromtimestamp(
+ self.query_job_details["statistics"]["endTime"] / 1000,
tz=timezone.utc
+ )
+
+ @patch.object(
+ _BigQueryInsertJobOperatorOpenLineageMixin,
+ "_get_inputs_and_outputs",
+ autospec=True,
+ )
+ @patch("airflow.providers.openlineage.api.sql.emit_query_lineage")
+ def
test_script_job_aggregates_parent_datasets_and_emits_child_query_lineage(
+ self, mock_emit_query_lineage, mock_get_inputs_and_outputs
+ ):
+ parent_job_details = copy.deepcopy(self.script_job_details)
+ parent_job_details["statistics"]["numChildJobs"] = "2"
+ child_job_1_details = {
+ "jobReference": {"jobId": "child_job_1"},
+ "configuration": {
+ "jobType": "QUERY",
+ "query": {"query": "CREATE TABLE output_table1 AS SELECT 1 AS
id"},
+ },
+ "statistics": {"query": {"cacheHit": False, "totalBytesBilled":
"10"}},
+ "status": {"state": "DONE"},
+ }
+ child_job_2_details = {
+ "jobReference": {"jobId": "child_job_2"},
+ "configuration": {
+ "jobType": "QUERY",
+ "query": {"query": "CREATE TABLE output_table2 AS SELECT 2 AS
id"},
+ },
+ "statistics": {"query": {"cacheHit": False, "totalBytesBilled":
"20"}},
+ "status": {"state": "DONE"},
+ }
+ input_table1 = InputDataset(namespace="bigquery",
name="project.dataset.input_table1")
+ output_table1 = OutputDataset(namespace="bigquery",
name="project.dataset.output_table1")
+ input_table2 = InputDataset(namespace="bigquery",
name="project.dataset.input_table2")
+ output_table2 = OutputDataset(namespace="bigquery",
name="project.dataset.output_table2")
+ self.client.get_job.side_effect = [
+ MagicMock(_properties=parent_job_details),
+ MagicMock(_properties=child_job_1_details),
+ MagicMock(_properties=child_job_2_details),
+ ]
+ self.client.list_jobs.return_value = [
+ MagicMock(job_id="child_job_1"),
+ MagicMock(job_id="child_job_2"),
+ ]
+ mock_ti = make_task_instance()
+
+ def get_inputs_and_outputs(_, properties):
+ query = properties["configuration"]["query"]["query"]
+ if "output_table1" in query:
+ return [input_table1], [output_table1]
+ return [input_table2], [output_table2]
+
+ mock_get_inputs_and_outputs.side_effect = get_inputs_and_outputs
+
+ lineage = self.operator.get_openlineage_facets_on_complete(mock_ti)
+
+ assert lineage.inputs == [input_table1, input_table2]
+ assert lineage.outputs == [output_table1, output_table2]
+ assert "bigQueryJob" in lineage.run_facets
+ assert "externalQuery" in lineage.run_facets
+ assert mock_emit_query_lineage.call_count == 2
+
+ first_call, second_call = mock_emit_query_lineage.call_args_list
+ assert first_call.kwargs["query_id"] == "child_job_1"
+ assert first_call.kwargs["query_source_namespace"] == "bigquery"
+ assert first_call.kwargs["inputs"] == [input_table1]
+ assert first_call.kwargs["outputs"] == [output_table1]
+ assert first_call.kwargs["task_instance"] is mock_ti
+ assert first_call.kwargs["job_name"] == "dag_id.task_id.query.1"
+
+ assert second_call.kwargs["query_id"] == "child_job_2"
+ assert second_call.kwargs["query_source_namespace"] == "bigquery"
+ assert second_call.kwargs["inputs"] == [input_table2]
+ assert second_call.kwargs["outputs"] == [output_table2]
+ assert second_call.kwargs["task_instance"] is mock_ti
+ assert second_call.kwargs["job_name"] == "dag_id.task_id.query.2"
+
+ @patch.object(
+ _BigQueryInsertJobOperatorOpenLineageMixin,
+ "_get_inputs_and_outputs",
+ autospec=True,
+ )
+
@patch("airflow.providers.openlineage.api.sql.resolve_task_emission_policy")
+ @patch("airflow.providers.openlineage.api.sql.is_openlineage_active",
return_value=True)
+ @patch("airflow.providers.openlineage.api.sql.emit")
+ def test_script_job_builds_child_query_events(
+ self,
+ mock_emit,
+ mock_is_openlineage_active,
+ mock_resolve_task_emission_policy,
+ mock_get_inputs_and_outputs,
+ ):
+ parent_job_details = copy.deepcopy(self.script_job_details)
+ parent_job_details["statistics"]["numChildJobs"] = "2"
+ child_job_1_details = {
+ "jobReference": {"jobId": "child_job_1"},
+ "configuration": {
+ "jobType": "QUERY",
+ "query": {"query": "CREATE TABLE output_table1 AS SELECT 1 AS
id"},
+ },
+ "statistics": {
+ "startTime": "1600000000000",
+ "endTime": "1600000005000",
+ "query": {"cacheHit": False, "totalBytesBilled": "10"},
+ },
+ "status": {"state": "DONE"},
+ }
+ child_job_2_details = {
+ "jobReference": {"jobId": "child_job_2"},
+ "configuration": {
+ "jobType": "QUERY",
+ "query": {"query": "CREATE TABLE output_table2 AS SELECT 2 AS
id"},
+ },
+ "statistics": {
+ "startTime": "1600000010000",
+ "endTime": "1600000015000",
+ "query": {"cacheHit": False, "totalBytesBilled": "20"},
+ },
+ "status": {"state": "DONE"},
+ }
+ input_table1 = InputDataset(namespace="bigquery",
name="project.dataset.input_table1")
+ output_table1 = OutputDataset(namespace="bigquery",
name="project.dataset.output_table1")
+ input_table2 = InputDataset(namespace="bigquery",
name="project.dataset.input_table2")
+ output_table2 = OutputDataset(namespace="bigquery",
name="project.dataset.output_table2")
+
+ class ChildJob:
+ def __init__(self, job_id):
+ self.job_id = job_id
+
+ job_details_by_id = {
+ "job_id": parent_job_details,
+ "child_job_1": child_job_1_details,
+ "child_job_2": child_job_2_details,
+ }
+ self.client.get_job.side_effect = lambda job_id:
MagicMock(_properties=job_details_by_id[job_id])
+ self.client.list_jobs.return_value = [ChildJob("child_job_2"),
ChildJob("child_job_1")]
+
+ def get_inputs_and_outputs(_, properties):
+ query = properties["configuration"]["query"]["query"]
+ if "output_table1" in query:
+ return [input_table1], [output_table1]
+ return [input_table2], [output_table2]
+
+ mock_get_inputs_and_outputs.side_effect = get_inputs_and_outputs
+ mock_resolve_task_emission_policy.return_value = MagicMock(emit=True)
+
+ lineage =
self.operator.get_openlineage_facets_on_complete(make_task_instance())
+
+ # Aggregation follows the sorted (execution-time) order, not the
list_jobs order.
+ assert lineage.inputs == [input_table1, input_table2]
+ assert lineage.outputs == [output_table1, output_table2]
+ assert mock_is_openlineage_active.call_count == 2
+ assert mock_emit.call_count == 4
+ child_1_start, child_1_complete, child_2_start, child_2_complete = [
+ call.args[0] for call in mock_emit.call_args_list
+ ]
+
+ assert child_1_start.eventType == RunState.START
+ assert child_1_complete.eventType == RunState.COMPLETE
+ assert child_1_complete.job.name == "dag_id.task_id.query.1"
+ assert child_1_complete.run.facets["externalQuery"].externalQueryId ==
"child_job_1"
+ assert child_1_complete.run.facets["externalQuery"].source ==
"bigquery"
+ assert child_1_complete.inputs == [input_table1]
+ assert child_1_complete.outputs == [output_table1]
+ assert child_1_start.eventTime == "2020-09-13T12:26:40+00:00"
+ assert child_1_complete.eventTime == "2020-09-13T12:26:45+00:00"
+ assert child_1_complete.run.facets["bigQueryJob"].billedBytes == 10
+ assert child_1_complete.job.facets["sql"].query == "CREATE TABLE
output_table1 AS SELECT 1 AS id"
+
+ assert child_2_start.eventType == RunState.START
+ assert child_2_complete.eventType == RunState.COMPLETE
+ assert child_2_complete.job.name == "dag_id.task_id.query.2"
+ assert child_2_complete.run.facets["externalQuery"].externalQueryId ==
"child_job_2"
+ assert child_2_complete.inputs == [input_table2]
+ assert child_2_complete.outputs == [output_table2]
+ assert child_2_start.eventTime == "2020-09-13T12:26:50+00:00"
+ assert child_2_complete.eventTime == "2020-09-13T12:26:55+00:00"
+
+ @patch.object(
+ _BigQueryInsertJobOperatorOpenLineageMixin,
+ "_get_inputs_and_outputs",
+ autospec=True,
+ )
+ @patch("airflow.providers.openlineage.api.sql.emit_query_lineage")
+ def test_script_job_continues_after_child_lineage_failure(
+ self, mock_emit_query_lineage, mock_get_inputs_and_outputs
+ ):
+ parent_job_details = copy.deepcopy(self.script_job_details)
+ parent_job_details["statistics"]["numChildJobs"] = "2"
+ child_job_1_details = {
+ "jobReference": {"jobId": "child_job_1"},
+ "configuration": {"jobType": "QUERY"},
+ "statistics": {"startTime": "1600000000000"},
+ "status": {"state": "DONE"},
+ }
+ child_job_2_details = {
+ "jobReference": {"jobId": "child_job_2"},
+ "configuration": {"jobType": "QUERY", "query": {"query": "SELECT
2"}},
+ "statistics": {"startTime": "1600000010000"},
+ "status": {"state": "DONE"},
+ }
+ input_table2 = InputDataset(namespace="bigquery",
name="project.dataset.input_table2")
+ output_table2 = OutputDataset(namespace="bigquery",
name="project.dataset.output_table2")
+ self.client.get_job.side_effect = [
+ MagicMock(_properties=parent_job_details),
+ MagicMock(_properties=child_job_1_details),
+ MagicMock(_properties=child_job_2_details),
+ ]
+ self.client.list_jobs.return_value = [
+ MagicMock(job_id="child_job_1"),
+ MagicMock(job_id="child_job_2"),
+ ]
+ mock_get_inputs_and_outputs.side_effect = [
+ RuntimeError("broken child"),
+ ([input_table2], [output_table2]),
+ ]
+
+ lineage =
self.operator.get_openlineage_facets_on_complete(make_task_instance())
+
+ assert lineage.inputs == [input_table2]
+ assert lineage.outputs == [output_table2]
+ assert "errorMessage" not in lineage.run_facets
+ mock_emit_query_lineage.assert_called_once()
+ assert mock_emit_query_lineage.call_args.kwargs["query_id"] ==
"child_job_2"
+ assert mock_emit_query_lineage.call_args.kwargs["inputs"] ==
[input_table2]
+ assert mock_emit_query_lineage.call_args.kwargs["outputs"] ==
[output_table2]
+ # The failing child keeps its positional slot so the query.N suffix
stays stable across runs.
+ assert mock_emit_query_lineage.call_args.kwargs["job_name"] ==
"dag_id.task_id.query.2"
+
+ @patch("airflow.providers.openlineage.api.sql.emit_query_lineage")
+ def
test_script_job_without_task_instance_does_not_emit_child_query_events(self,
mock_emit_query_lineage):
+ self.client.get_job.side_effect = [
+ MagicMock(_properties=self.script_job_details),
+ MagicMock(_properties=self.query_job_details),
+ ]
+ self.client.get_table.side_effect = [
+ Table.from_api_repr(read_common_json_file("table_details.json")),
+
Table.from_api_repr(read_common_json_file("out_table_details.json")),
+ ]
+ self.client.list_jobs.return_value = [MagicMock(job_id="child_job_id")]
+
+ lineage = self.operator.get_openlineage_facets_on_complete(None)
+
+ # Parent still aggregates child datasets; only the per-child emission
is skipped without a TI.
+ assert [i.name for i in lineage.inputs] ==
["airflow-openlineage.new_dataset.test_table"]
+ assert [o.name for o in lineage.outputs] ==
["airflow-openlineage.new_dataset.output_table"]
+ mock_emit_query_lineage.assert_not_called()
+ # Without the None-TI guard, building the child job_name dereferences
None and the failure
+ # surfaces as an errorMessage facet; its absence proves the guard
short-circuited cleanly.
+ assert "errorMessage" not in lineage.run_facets
+
+ @patch("airflow.providers.openlineage.api.sql.emit_query_lineage")
+ def test_child_query_lineage_without_query_omits_sql_job_facet(self,
mock_emit_query_lineage):
+ self.operator._emit_child_query_lineage(
+ task_instance=make_task_instance(),
+ child_index=1,
+ child_job_properties={
+ "configuration": {"jobType": "QUERY", "query": {}},
+ "statistics": {"query": {"cacheHit": False,
"totalBytesBilled": "10"}},
+ },
+ inputs=[],
+ outputs=[],
+ )
+
+ assert
mock_emit_query_lineage.call_args.kwargs["additional_job_facets"] is None
+
+ @pytest.mark.parametrize(
+ "statistics",
+ [
+ {"startTime": "invalid", "endTime": "1600000005000"},
+ {"startTime": "1600000000000", "endTime": "invalid"},
+ ],
+ )
+ @patch("airflow.providers.openlineage.api.sql.emit_query_lineage")
+ def test_child_query_lineage_omits_partial_timestamps(self,
mock_emit_query_lineage, statistics):
+ self.operator._emit_child_query_lineage(
+ task_instance=make_task_instance(),
+ child_index=1,
+ child_job_properties={
+ "configuration": {"jobType": "QUERY", "query": {}},
+ "statistics": statistics,
+ },
+ inputs=[],
+ outputs=[],
+ )
+
+ assert mock_emit_query_lineage.call_args.kwargs["start_time"] is None
+ assert mock_emit_query_lineage.call_args.kwargs["end_time"] is None
+
+ @patch("airflow.providers.openlineage.api.sql.emit_query_lineage")
+ def test_child_query_lineage_marks_failed_child_job(self,
mock_emit_query_lineage):
+ self.operator._emit_child_query_lineage(
+ task_instance=make_task_instance(),
+ child_index=1,
+ child_job_properties={
+ "jobReference": {"jobId": "child_job_id"},
+ "configuration": {"jobType": "QUERY", "query": {"query":
"SELECT 1"}},
+ "status": {"state": "DONE", "errorResult": {"reason":
"invalid", "message": "Syntax error"}},
+ },
+ inputs=[],
+ outputs=[],
+ )
+
+ assert mock_emit_query_lineage.call_args.kwargs["is_successful"] is
False
+ assert mock_emit_query_lineage.call_args.kwargs["error_message"] ==
"Syntax error"
+
+ @patch.dict("sys.modules", {"airflow.providers.openlineage.api.sql": None})
+ def test_child_query_lineage_skipped_with_old_openlineage_provider(self):
+ self.client.get_job.side_effect = [
+ MagicMock(_properties=self.script_job_details),
+ MagicMock(_properties=self.query_job_details),
+ ]
+ self.client.get_table.side_effect = [
+ Table.from_api_repr(read_common_json_file("table_details.json")),
+
Table.from_api_repr(read_common_json_file("out_table_details.json")),
+ ]
+ self.client.list_jobs.return_value = [MagicMock(job_id="child_job_id")]
+
+ lineage =
self.operator.get_openlineage_facets_on_complete(make_task_instance())
+
+ # Old OpenLineage providers lack emit_query_lineage: the parent event
must still
+ # aggregate child datasets and must not gain a misleading errorMessage
facet.
+ assert [i.name for i in lineage.inputs] ==
["airflow-openlineage.new_dataset.test_table"]
+ assert [o.name for o in lineage.outputs] ==
["airflow-openlineage.new_dataset.output_table"]
+ assert "errorMessage" not in lineage.run_facets
+
+ @pytest.mark.parametrize(
+ ("value", "expected"),
+ [
+ ("1600000000000", datetime(2020, 9, 13, 12, 26, 40,
tzinfo=timezone.utc)),
+ (None, None),
+ ("not-a-timestamp", None),
+ ],
+ )
+ def test_get_bigquery_job_datetime(self, value, expected):
+ assert (
+ self.operator._get_bigquery_job_datetime({"statistics":
{"startTime": value}}, "startTime")
+ == expected
+ )
def test_deduplicate_outputs(self):
outputs = [