mobuchowski commented on code in PR #75303:
URL: https://github.com/apache/airflow/pull/75303#discussion_r4350417328


##########
providers/standard/src/airflow/providers/standard/utils/openlineage.py:
##########
@@ -80,9 +80,19 @@ def _get_openlineage_parent_info(ti: TaskInstance | 
RuntimeTI) -> dict[str, str]
         "rootParentJobNamespace": lineage_root_job_namespace(ti),
     }
 
+    try:
+        from airflow.providers.openlineage.plugins.macros import 
lineage_job_type, lineage_root_job_type
+    except ImportError:  # Job type macros are missing in older versions of 
the OpenLineage provider
+        return parent_info
+
+    parent_info["parentJobType"] = lineage_job_type()
+    if (root_job_type := lineage_root_job_type(ti)) is not None:
+        parent_info["rootParentJobType"] = root_job_type
+    return parent_info
+
 
 def _inject_openlineage_parent_info_to_dagrun_conf(
-    dr_conf: dict | None, ol_parent_info: dict[str, str]
+    dr_conf: dict | None, ol_parent_info: dict[str, Any]

Review Comment:
   `_inject_openlineage_parent_info_to_dagrun_conf` decides whether to inject 
by checking the user's existing `openlineage` conf for any key in 
`forbidden_keys`. If it finds none, it merges with `{**ol_parent_info, 
**current_ol_dr_conf}`, so the user's keys win key by key and the injected 
values fill in the rest.
   
   The new `parentJobType` / `rootParentJobType` keys aren't in 
`forbidden_keys`. Also, the existing entries `rootJobName` / `rootJobNamespace` 
are misspelled: the real keys are `rootParentJobName` / 
`rootParentJobNamespace`. That typo predates this PR, but it now affects job 
types too. Two examples:
   
   - A user triggers with `conf={"openlineage": {"rootParentJobName": "x", 
"rootParentJobNamespace": "y"}}`. No forbidden key matches, so injection goes 
ahead. The child Dag gets the user's root name and namespace together with the 
injected `rootParentRunId` and, new in this PR, the injected 
`rootParentJobType`. That's the TriggerDagRunOperator Dag's type attached to 
job `y/x`.
   - A user sets only `rootParentJobType` (or `parentJobType`). Their type is 
attached to the injected run ids of a different job.
   
   Both are uncommon inputs. Still, the description says "a type is never 
paired with a different job", and this path gets around it. Suggested fix: add 
both job type keys to `forbidden_keys`, and fix the two root key names while 
you're here:
   
   ```python
   forbidden_keys = (
       "parentRunId",
       "parentJobName",
       "parentJobNamespace",
       "parentJobType",
       "rootParentRunId",
       "rootParentJobName",
       "rootParentJobNamespace",
       "rootParentJobType",
   )
   ```



##########
providers/openlineage/src/airflow/providers/openlineage/plugins/macros.py:
##########
@@ -55,6 +55,20 @@ def lineage_job_name(task_instance: TaskInstance):
     return get_job_name(task_instance)
 
 
+def lineage_job_type() -> dict[str, str]:

Review Comment:
   The docs list these next to the other `lineage_*` macros and say they "can 
be forwarded to a child job together with the parent information". Every 
example in that section uses the macros inside a string template such as `"{{ 
macros.OpenLineageProviderPlugin.lineage_job_namespace() }}"`. The other macros 
return strings, so that works for them.
   
   These two return a Python dict, or `None`. Unless 
`render_template_as_native_obj=True` is set, Airflow renders templates to text, 
so you get the Python repr instead:
   
   - `"{{ macros.OpenLineageProviderPlugin.lineage_job_type() }}"` renders to 
`"{'processingType': 'BATCH', 'integration': 'AIRFLOW', 'jobType': 'TASK'}"`. 
That has single quotes, so it isn't valid JSON for a Spark conf or an HTTP 
payload.
   - When the root type is unknown, `lineage_root_job_type(task_instance)` 
renders to the literal string `"None"`.
   - Someone who passes these by hand in a `TriggerDagRunOperator` conf gets a 
string where a dict is expected. `_get_job_type_from_ol_data` then logs 
"Invalid OpenLineage parentJobType" and drops it.
   
   The automatic injection path calls the macros directly from Python, so it 
isn't affected. Only the template use the docs suggest is. Could you either 
note in `macros.rst` that these return dicts and need native rendering (or 
should be called from Python), or add string forms, for example a 
`|tojson`-friendly example or `lineage_job_type_json()`?



##########
providers/standard/tests/unit/standard/utils/test_openlineage.py:
##########
@@ -137,18 +156,39 @@ def _mock_version(package):
         mock_root_run_id.assert_called_once_with(ti)
         mock_root_job_name.assert_called_once_with(ti)
         mock_root_job_namespace.assert_called_once_with(ti)
+        mock_job_type.assert_called_once_with()
+        mock_root_job_type.assert_called_once_with(ti)
 
-        # Verify result structure
-        assert isinstance(result, dict)
         assert result == expected_values
-        assert set(result.keys()) == {
-            "parentRunId",
-            "parentJobName",
-            "parentJobNamespace",
-            "rootParentRunId",
-            "rootParentJobName",
-            "rootParentJobNamespace",
-        }
+
+
+def test_get_openlineage_parent_info_without_job_type_macros(monkeypatch):

Review Comment:
   Nit: the repo's testing guidelines ask for `spec`/`autospec` on mocks and 
prefer `@mock.patch` decorators over `with patch(...)`. The six 
`patch(f"{OL_MACROS_PATH}.lineage_*", return_value=...)` calls here use 
neither. The updated test above already added `autospec=True` for the new 
patches, so matching that here would be consistent.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -430,6 +458,7 @@ def get_task_parent_run_facet(
         root_parent_run_id=root_parent_run_id,
         root_parent_job_namespace=root_parent_job_namespace,
         root_parent_job_name=root_parent_job_name,
+        root_parent_job_type=root_parent_job_type,

Review Comment:
   Nothing is broken today. For task events, `get_task_parent_run_facet` never 
sets `parent_job_type`. The parent of a task is always its Dag, which we know 
is `BATCH/AIRFLOW/DAG`. When the Dag run has no OpenLineage conf, the root (the 
Dag itself) gets no type either.
   
   Meanwhile `lineage_root_job_type(ti)` returns `{BATCH, AIRFLOW, DAG}` for 
that same root, and TriggerDagRunOperator injects it. So in a child Dag's 
events the root job has `jobType: DAG`, but the same root job has no `jobType` 
facet in its own Dag's task events. Is leaving that out deliberate for this PR, 
or worth passing the Dag type as `parent_job_type` here?



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -441,7 +470,30 @@ def _get_openlineage_data_from_dagrun_conf(dr_conf: dict | 
None) -> dict:
     return ol_data if isinstance(ol_data, dict) else {}
 
 
-def get_root_information_from_dagrun_conf(dr_conf: dict | None) -> dict[str, 
str]:
+def _get_job_type_from_ol_data(ol_data: dict, key: str) -> dict[str, str] | 
None:
+    """Return a copy of the job type facet fields stored under ``key`` if they 
are valid, otherwise None."""
+    job_type = ol_data.get(key)
+    if job_type is None:
+        return None
+    expected_fields = {"processingType", "integration", "jobType"}
+    # The spec allows partial job type, but only complete information is 
supported for now.
+    if (
+        not isinstance(job_type, dict)
+        or job_type.keys() != expected_fields

Review Comment:
   This check drops the whole job type if the dict has any key beyond 
`processingType` / `integration` / `jobType`, and the `unknown-field` test case 
shows that's on purpose. Nothing in this PR sends extra keys, but a producer 
outside Airflow might forward a serialized `JobTypeJobFacet`, which includes 
`_producer` / `_schemaURL`. That type would then be thrown away with a warning, 
even though the three fields we need are all there. Would it be better to 
require the three fields and ignore the rest?
   
   ```python
   if not isinstance(job_type, dict) or not all(
       isinstance(job_type.get(f), str) and job_type[f] for f in expected_fields
   ):
       ...
   return {f: job_type[f] for f in expected_fields}
   ```
   
   If strict matching is deliberate (for example, to reject anything that isn't 
the macro's own output), feel free to ignore this.



##########
providers/openlineage/src/airflow/providers/openlineage/plugins/macros.py:
##########
@@ -154,6 +189,10 @@ def _get_ol_root_id(id_key: str, task_instance: 
TaskInstance) -> str | None:
     return None
 
 
+def _build_airflow_job_type(job_type: Literal["DAG", "TASK"]) -> dict[str, 
str]:

Review Comment:
   Optional: `_build_airflow_job_type` repeats the `BATCH` / `AIRFLOW` values 
that `OpenLineageAdapter` uses when it builds the real `jobType` facet 
(`adapter.py:651`). `_JOB_TYPE_DAG` / `_JOB_TYPE_TASK` already exist in 
`adapter.py:75`. This module already imports from `adapter`, so pulling in the 
constants keeps the macros in sync with the emitted events:
   
   ```python
   from airflow.providers.openlineage.plugins.adapter import _JOB_TYPE_DAG, 
_JOB_TYPE_TASK, OpenLineageAdapter
   ...
   return _build_airflow_job_type(_JOB_TYPE_TASK)
   ```



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