kacpermuda commented on code in PR #69234:
URL: https://github.com/apache/airflow/pull/69234#discussion_r3549668610


##########
providers/google/src/airflow/providers/google/cloud/openlineage/mixins.py:
##########
@@ -109,14 +110,50 @@ def get_openlineage_facets_on_complete(self, _):
             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_to_emit = []
+                for child_job in 
self._client.list_jobs(parent_job=self.job_id):
+                    # BigQuery returns Job objects; keep raw job IDs supported 
for lightweight clients.
+                    child_job_id = getattr(child_job, "job_id", child_job)

Review Comment:
   Did this loop not work before? We iterated over list_jobs result as it was 
job_ids, not job_instances. I think I've tested this when implementing, was I 
wrong or the client changed over time?



##########
providers/google/src/airflow/providers/google/cloud/openlineage/mixins.py:
##########
@@ -139,6 +176,61 @@ def get_openlineage_facets_on_complete(self, _):
             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_id: str,
+        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
+
+        from airflow.providers.openlineage.api.sql import emit_query_lineage

Review Comment:
   As mentioned above, this will not work with older OL provider (api was added 
recently), so we need to try/except and early return.



##########
providers/google/src/airflow/providers/google/cloud/openlineage/mixins.py:
##########
@@ -139,6 +176,61 @@ def get_openlineage_facets_on_complete(self, _):
             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_id: str,
+        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
+
+        from airflow.providers.openlineage.api.sql import emit_query_lineage
+        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
+        start_time = self._get_bigquery_job_datetime(child_job_properties, 
"startTime")
+        end_time = self._get_bigquery_job_datetime(child_job_properties, 
"endTime")
+        emit_query_lineage(
+            query_id=child_job_id,
+            query_source_namespace=BIGQUERY_NAMESPACE,
+            inputs=inputs,
+            outputs=outputs,
+            start_time=start_time,
+            end_time=end_time,
+            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, child_job) -> tuple[datetime, str]:
+        # Emit children ordered by execution time so the query.N suffix is 
stable across runs;
+        # children missing a startTime sort last, then break ties 
deterministically by job id.
+        child_job_id, child_job_properties, _, _ = child_job
+        start_time = cls._get_bigquery_job_datetime(child_job_properties, 
"startTime")
+        return (
+            start_time or datetime.max.replace(tzinfo=timezone.utc),

Review Comment:
   Let's add an inline comment here why we're using max, and not f.e. None



##########
providers/google/src/airflow/providers/google/cloud/openlineage/mixins.py:
##########
@@ -139,6 +176,61 @@ def get_openlineage_facets_on_complete(self, _):
             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_id: str,
+        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
+
+        from airflow.providers.openlineage.api.sql import emit_query_lineage
+        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

Review Comment:
   Can we also add a comment on why we're not passing this sql as query_text to 
the `emit_query_lineage` and that this is intentional? Also let's pass 
query_text=None explicitly to the function, since we don't want to use sql 
parsing capability.



##########
providers/google/src/airflow/providers/google/cloud/openlineage/mixins.py:
##########
@@ -109,14 +110,50 @@ def get_openlineage_facets_on_complete(self, _):
             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_to_emit = []
+                for child_job in 
self._client.list_jobs(parent_job=self.job_id):
+                    # BigQuery returns Job objects; keep raw job IDs supported 
for lightweight clients.
+                    child_job_id = getattr(child_job, "job_id", child_job)
+                    try:
+                        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)
+                    except Exception as child_exception:
+                        self.log.warning(
+                            "Cannot retrieve lineage for BigQuery child job 
`%s`. %s",
+                            child_job_id,
+                            child_exception,
+                            exc_info=True,
+                        )
+                        continue
                     inputs.extend(child_inputs)
                     outputs.extend(child_outputs)
+                    child_jobs_to_emit.append(
+                        (
+                            str(child_job_id),

Review Comment:
   Can't we just use a dict here with job_id as key?



##########
providers/google/src/airflow/providers/google/cloud/openlineage/mixins.py:
##########
@@ -109,14 +110,50 @@ def get_openlineage_facets_on_complete(self, _):
             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_to_emit = []
+                for child_job in 
self._client.list_jobs(parent_job=self.job_id):
+                    # BigQuery returns Job objects; keep raw job IDs supported 
for lightweight clients.
+                    child_job_id = getattr(child_job, "job_id", child_job)
+                    try:
+                        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)
+                    except Exception as child_exception:
+                        self.log.warning(
+                            "Cannot retrieve lineage for BigQuery child job 
`%s`. %s",
+                            child_job_id,
+                            child_exception,
+                            exc_info=True,
+                        )
+                        continue
                     inputs.extend(child_inputs)
                     outputs.extend(child_outputs)
+                    child_jobs_to_emit.append(

Review Comment:
   Let's move this whole piece with emitting child events to a separate method 
maybe, but for sure let's guard it with the try/except on "from 
airflow.providers.openlineage.api.sql import emit_query_lineage" as this code 
is not available in older OL clients. This way, we keep the older behavior 
intact for all, and for those with newer OL clients, we offer this new 
functionality.



##########
providers/google/src/airflow/providers/google/cloud/openlineage/mixins.py:
##########
@@ -139,6 +176,61 @@ def get_openlineage_facets_on_complete(self, _):
             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_id: str,
+        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
+
+        from airflow.providers.openlineage.api.sql import emit_query_lineage
+        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
+        start_time = self._get_bigquery_job_datetime(child_job_properties, 
"startTime")
+        end_time = self._get_bigquery_job_datetime(child_job_properties, 
"endTime")
+        emit_query_lineage(

Review Comment:
   Just curious, do we have error from bigquery if the job failed? We could 
also pass it here for each sql. Also, if the script stopped, f.e. only first of 
all 10 sqls executed, do we know about it ? 



##########
providers/google/src/airflow/providers/google/cloud/openlineage/mixins.py:
##########
@@ -139,6 +176,61 @@ def get_openlineage_facets_on_complete(self, _):
             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_id: str,
+        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
+
+        from airflow.providers.openlineage.api.sql import emit_query_lineage
+        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
+        start_time = self._get_bigquery_job_datetime(child_job_properties, 
"startTime")
+        end_time = self._get_bigquery_job_datetime(child_job_properties, 
"endTime")
+        emit_query_lineage(
+            query_id=child_job_id,
+            query_source_namespace=BIGQUERY_NAMESPACE,
+            inputs=inputs,
+            outputs=outputs,
+            start_time=start_time,
+            end_time=end_time,
+            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, child_job) -> tuple[datetime, str]:
+        # Emit children ordered by execution time so the query.N suffix is 
stable across runs;
+        # children missing a startTime sort last, then break ties 
deterministically by job id.
+        child_job_id, child_job_properties, _, _ = child_job

Review Comment:
   This is very hard to debug and read (this mysterious list of tuples), let's 
not pass what we do not need to this function or figure out a more readable way 
to store this child-job info.



##########
providers/google/pyproject.toml:
##########
@@ -189,7 +189,7 @@ dependencies = [
     "apache-airflow-providers-mysql"
 ]
 "openlineage" = [
-    "apache-airflow-providers-openlineage"
+    "apache-airflow-providers-openlineage>=2.16.0"

Review Comment:
   We should probably not add this version constraint, since the new feature is 
an addition to current functionality, not breaking the entire Ol integration. 



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to