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


##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1489,28 +1587,38 @@ def is_dag_run_asset_triggered(
 @attrs.frozen
 class TaskInstancePosition:
     """
-    Where a task instance runs: inside a loop body or not, and the map index 
users see.
+    Where a task instance runs: inside a loop body or not, the map index users 
see and the loop iteration.
 
     A plain mapped task keeps the region of its own expansion, so its dag, 
task, try, logical date and
     map index stay unique; inside a loop they repeat on every pass, so only 
the execution UUID identifies
-    the run.
+    the run. ``loop_index`` is the zero-based iteration and 
``loop_max_iterations`` the limit of the loop, the
+    counterparts of ``try_number`` and the maximum number of tries, and 
``loop_group_id`` the loop's group id.
+    All three describe the innermost loop and are ``None`` outside a loop. A 
stored task whose task isn't
+    attached has no ``loop_max_iterations``, nor ``loop_group_id`` when it is 
mapped, and a stored mapped task
+    in a loop has no ``loop_index`` unless ``get_orm_task_instance_positions`` 
could read its region. A runtime
+    task instance has no ``loop_index`` nor ``loop_max_iterations`` unless 
placed with
+    ``get_task_instance_position``, as only its template context holds them.
+    ``TaskInstanceInfo`` explains how these read together with the try and the 
region in events.
     """
 
     in_loop: bool
     map_index: int
+    loop_index: int | None = None
+    loop_max_iterations: int | None = None
+    loop_group_id: str | None = None
 
 
-def _is_task_in_loop(task: Any) -> bool:
+def _get_enclosing_loop(task: Any) -> Any | None:
     from airflow.sdk.definitions._internal.loop import LoopTaskGroup
     from airflow.sdk.definitions.taskgroup import TaskGroup
     from airflow.serialization.definitions.taskgroup import 
SerializedLoopTaskGroup, SerializedTaskGroup
 
     group = getattr(task, "task_group", None)
     while isinstance(group, (TaskGroup, SerializedTaskGroup)):
         if isinstance(group, (LoopTaskGroup, SerializedLoopTaskGroup)):
-            return True
+            return group
         group = group.parent_group
-    return group is not None
+    return None

Review Comment:
   **Unrecognized group types now count as "not in a loop" instead of "in a 
loop"**
   
   This function walks up the task's parent groups looking for a loop group. 
The walk stops as soon as it meets something that isn't a `TaskGroup` / 
`SerializedTaskGroup`. The old `_is_task_in_loop` then returned `group is not 
None`: if the walk stopped on some unexpected non-`None` object, it assumed the 
task *was* in a loop. The new code returns `None` in that case, so it now 
assumes the task is *not* in a loop.
   
   Why the direction of this fallback matters:
   - When a task is in a loop, the listener uses the task instance's UUID 
(`ti.id`) as the OpenLineage runId. Each iteration gets a new UUID, so 
iterations stay separate.
   - When it's not in a loop, the runId is built from `(dag_id, task_id, 
try_number, logical_date, map_index)`. Call that the "computed runId": the same 
inputs always give the same runId.
   
   Every iteration of a looped task has the same values for all five inputs. If 
loop detection wrongly says "not in a loop", iteration 0 and iteration 1 emit 
START/COMPLETE under one runId, and consumers merge their lineage into a single 
run.
   
   I'm not sure which group types can reach this point in practice (maybe a 
group from a different SDK version, a proxy, or a subclass that doesn't inherit 
from these two classes). But if one does, the old fallback gave a harmless 
extra UUID while the new one silently merges runs. Keeping `return group is not 
None`, or at least logging when the walk stops on an unexpected type, seems 
safer.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1570,26 +1724,47 @@ def get_orm_task_instance_positions(
     regions = {
         row.id: row
         for row in session.execute(
-            select(DynamicRegion.id, DynamicRegion.node_id, 
DynamicRegion.parent_region_id).where(
-                DynamicRegion.id.in_({task_instance.region_id for 
task_instance in unplaced})
+            select(
+                DynamicRegion.id,
+                DynamicRegion.node_id,
+                DynamicRegion.parent_region_id,
+                DynamicRegion.parent_region_index,
+            ).where(
+                DynamicRegion.id.in_(
+                    {task_instance.region_id for task_instance in (*unplaced, 
*without_iteration)}

Review Comment:
   **A new database query in the manual state change path can make the whole 
event disappear**
   
   Before this PR, a stored task instance whose task object was attached got 
its position computed purely in Python, with no query. This line adds a second 
group, `without_iteration`: mapped tasks inside a loop. Their iteration number 
only lives on the `DynamicRegion` row (the record Airflow keeps for each loop 
iteration or mapped expansion), so this now queries the database for them.
   
   That code runs from `_on_task_instance_manual_state_change`, the handler for 
when the scheduler fails a task (e.g. `handle_failure` after a heartbeat 
timeout) or a user marks a task failed or successful in the UI. So this query 
runs inside the scheduler/API server process.
   
   The scenario I'm worried about:
   1. A mapped task inside a loop gets marked failed by the scheduler.
   2. `get_orm_task_instance_positions` now runs the `DynamicRegion` query for 
it.
   3. The query raises (connection hiccup, the TI's session already detached or 
closed, etc.).
   4. The broad `try/except` in `_on_task_instance_manual_state_change` catches 
the error and returns, so **no FAIL event is emitted for that task at all**.
   
   All of this is only to fill `loop_index`, which is descriptive: the runId 
doesn't depend on it. `_add_runtime_loop_iteration` states this exact rule in 
its comment ("failing to read it must not cost the event its run id"). Could 
the `without_iteration` lookup get its own `try/except`, so a failure only 
leaves `loop_index` unset?



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1570,26 +1724,47 @@ def get_orm_task_instance_positions(
     regions = {
         row.id: row
         for row in session.execute(
-            select(DynamicRegion.id, DynamicRegion.node_id, 
DynamicRegion.parent_region_id).where(
-                DynamicRegion.id.in_({task_instance.region_id for 
task_instance in unplaced})
+            select(
+                DynamicRegion.id,
+                DynamicRegion.node_id,
+                DynamicRegion.parent_region_id,
+                DynamicRegion.parent_region_index,
+            ).where(
+                DynamicRegion.id.in_(
+                    {task_instance.region_id for task_instance in (*unplaced, 
*without_iteration)}
+                )
             )
         )
     }
     for task_instance in unplaced:
         region = regions.get(task_instance.region_id)
         if region is not None and region.node_id == task_instance.task_id:
             positions[task_instance.id] = TaskInstancePosition(
-                in_loop=region.parent_region_id is not None, 
map_index=task_instance.region_index
+                in_loop=region.parent_region_id is not None,
+                map_index=task_instance.region_index,
+                loop_index=region.parent_region_index,
             )
         else:
-            positions[task_instance.id] = TaskInstancePosition(in_loop=True, 
map_index=-1)
+            # Only a found region tells that the task isn't mapped, so that 
its region is the loop's and its
+            # region_index the iteration
+            positions[task_instance.id] = TaskInstancePosition(
+                in_loop=True,
+                map_index=-1,
+                loop_index=task_instance.region_index if region is not None 
else None,
+                loop_group_id=region.node_id if region is not None else None,

Review Comment:
   **Tasks in a mapped task group (with no loop at all) get reported as loop 
iterations**
   
   This `else` branch handles a stored task instance without its task object 
attached. That happens for the asset-event source and for the manual state 
change path. Here the code reads the task's `DynamicRegion` row and assumes 
that if the row's `node_id` isn't the task itself, the row must belong to an 
enclosing loop.
   
   But `DynamicRegion` rows are also created for mapped task groups 
(`@task_group` + `.expand()`), not just for loops. Example:
   
   ```python
   @task_group
   def g(x):
       t(x)
   
   g.expand(x=[1, 2, 3])  # no loop anywhere
   ```
   
   For task `g.t`, map index 1:
   1. Its region row has `node_id = "g"`, which isn't `"g.t"`, so we land in 
this `else`.
   2. We set `in_loop=True`, `map_index=-1`, `loop_index=1`, 
`loop_group_id="g"`.
   
   So the event says "iteration 1 of loop `g`" for a task that isn't in a loop, 
drops its real `map_index` (1), and uses the UUID runId. The same task's 
START/COMPLETE from the worker uses the computed runId (built from 
dag/task/try/date/map_index), so the manual-state FAIL and the worker's START 
end up with different runIds.
   
   Checking the region's kind (loop vs. mapped group) before treating it as a 
loop would avoid this, if `DynamicRegion` records that.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1570,26 +1724,47 @@ def get_orm_task_instance_positions(
     regions = {
         row.id: row
         for row in session.execute(
-            select(DynamicRegion.id, DynamicRegion.node_id, 
DynamicRegion.parent_region_id).where(
-                DynamicRegion.id.in_({task_instance.region_id for 
task_instance in unplaced})
+            select(
+                DynamicRegion.id,
+                DynamicRegion.node_id,
+                DynamicRegion.parent_region_id,
+                DynamicRegion.parent_region_index,
+            ).where(
+                DynamicRegion.id.in_(
+                    {task_instance.region_id for task_instance in (*unplaced, 
*without_iteration)}
+                )
             )
         )
     }
     for task_instance in unplaced:
         region = regions.get(task_instance.region_id)
         if region is not None and region.node_id == task_instance.task_id:
             positions[task_instance.id] = TaskInstancePosition(
-                in_loop=region.parent_region_id is not None, 
map_index=task_instance.region_index
+                in_loop=region.parent_region_id is not None,
+                map_index=task_instance.region_index,
+                loop_index=region.parent_region_index,
             )
         else:
-            positions[task_instance.id] = TaskInstancePosition(in_loop=True, 
map_index=-1)
+            # Only a found region tells that the task isn't mapped, so that 
its region is the loop's and its
+            # region_index the iteration
+            positions[task_instance.id] = TaskInstancePosition(
+                in_loop=True,
+                map_index=-1,
+                loop_index=task_instance.region_index if region is not None 
else None,
+                loop_group_id=region.node_id if region is not None else None,
+            )
+    for task_instance in without_iteration:
+        region = regions.get(task_instance.region_id)
+        if region is not None and region.parent_region_index is not None:
+            positions[task_instance.id] = attrs.evolve(
+                positions[task_instance.id], 
loop_index=region.parent_region_index

Review Comment:
   **`parent_region_index` is the loop iteration only if the loop is the direct 
parent**
   
   For a mapped task in a loop, this takes `region.parent_region_index`: the 
index of the mapped task's region inside its *parent* region. That equals the 
loop iteration only when the parent region is the loop itself.
   
   If there's another mapped level in between, for example:
   
   ```
   loop L
    └── mapped task group G (.expand)
         └── mapped task t (.expand)
   ```
   
   then `t`'s region's parent is G's region, so `parent_region_index` is G's 
map index, not L's iteration. In iteration 0 of the loop, `t` could report 
`loop_index=1` just because it sits in G's second expansion.
   
   I haven't checked whether Airflow allows this nesting. If it doesn't, a 
short comment stating that assumption would help. If it does, this probably 
needs to walk up the parent regions until it reaches the loop's region.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1135,11 +1193,44 @@ class TaskInstanceInfo(InfoJsonEncodable):
         "dag_bundle_name": lambda ti: ti.bundle_instance.name if hasattr(ti, 
"bundle_instance") else None,
     }
 
+    def __init__(self, obj, position: TaskInstancePosition | None = None):
+        # Pass the position when the caller has already placed the task 
instance, e.g. from its stored region
+        self._position = position
+        super().__init__(obj)
+
     def _extend_fields(self) -> None:
-        map_index = get_task_instance_map_index(self.obj)
+        # A stored task instance whose task isn't attached can't be placed 
without its region, and a facet must
+        # not fail the whole event, so then it describes the task instance 
without its position.
+        position = self._position or _get_task_instance_position(self.obj)
+        if position is not None:
+            position = _add_runtime_loop_iteration(self.obj, position)
+        map_index = position.map_index if position else -1
         self.map_index = map_index if map_index != -1 else None
         self.rendered_map_index = getattr(self.obj, "rendered_map_index", 
None) if map_index != -1 else None
         self._fields.extend(("map_index", "rendered_map_index"))
+        if position and position.loop_index is not None:
+            self.loop_index = position.loop_index
+            self._fields.append("loop_index")
+        if position and position.loop_max_iterations is not None:
+            self.loop_max_iterations = position.loop_max_iterations
+            self._fields.append("loop_max_iterations")
+        if position and position.loop_group_id is not None:
+            self.loop_group_id = position.loop_group_id
+            self._fields.append("loop_group_id")
+        if AIRFLOW_V_3_0_PLUS and (ti_id := getattr(self.obj, "id", None)) is 
not None:
+            self.id = str(ti_id)
+            self._fields.append("id")
+        # Only a loop gate's runtime task instance has one, once it has 
evaluated ``until``. Read from a private
+        # attribute until the task SDK exposes it.
+        if (decision := getattr(self.obj, "_loop_decision", None)) is not None:
+            self.loop_decision = {"stop": decision.stop, "reason": 
decision.reason}

Review Comment:
   **`loop_decision.reason` is emitted as `null`, but the schema says it's a 
string**
   
   `reason` comes from what the loop's `until` callable returns. If `until` 
returns a plain `True`/`False`, or the loop has no `until` and simply stops at 
`max_iterations`, `decision.reason` is `None`, and the gate task's COMPLETE 
event contains:
   
   ```json
   "loop_decision": {"stop": true, "reason": null}
   ```
   
   `AirflowRunFacet.json` declares `reason` as `{"type": "string"}`, so a 
consumer that validates events against the schema will flag or reject this 
event. Only adding `reason` when it isn't `None` would match the schema, and 
the example Dag docs, which describe this case as having "no reason".



##########
providers/openlineage/tests/system/openlineage/operator.py:
##########
@@ -351,33 +479,44 @@ def execute(self, context: Context) -> None:
                     else:
                         self.event_templates[key] = [existing, event]
         errors: dict[str, str] = {}
+        events_by_key: dict[str, list[dict]] = {}
         try:
             for key, template in self.event_templates.items():  # type: 
ignore[union-attr]
                 log.info("Checking key: `%s`", key)
                 try:
-                    self._check_key(key, template)
+                    events_by_key[key] = self._check_key(key, template)

Review Comment:
   **One failed template match produces a second, misleading runId error**
   
   In this loop, an event key goes into `events_by_key` only if `_check_key` 
passes. If it raises, the key is recorded in `errors` and left out of 
`events_by_key`. Then `check_run_id_integrity` runs over the partial 
`events_by_key` anyway.
   
   Example: the test expects `dag.task.event.start` and 
`dag.task.event.complete`.
   1. The COMPLETE event exists but doesn't match its template (say a facet 
value differs), so it goes to `errors`.
   2. `events_by_key` now holds only the START for that runId.
   3. The integrity check sees a runId with a START and no COMPLETE, and adds: 
`runId X of TASK dag.task should have one START and one COMPLETE/FAIL`.
   
   The person debugging the test now sees two failures. The second one points 
to a broken runId lifecycle that doesn't exist, which sends them in the wrong 
direction. Skipping the integrity check when template errors already exist (or 
skipping only the runIds those keys touched) would keep the output focused on 
the real problem.



##########
providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py:
##########
@@ -263,15 +263,17 @@ def on_running():
                 clear_number=clear_number,
             )
 
-            task_uuid = get_regional_task_instance_run_id(
-                task_instance
-            ) or self.adapter.build_task_instance_run_id(
-                dag_id=task_instance.dag_id,
-                task_id=task_instance.task_id,
-                try_number=task_instance.try_number,
-                logical_date=date,
-                map_index=task_instance.map_index,
-            )
+            position = get_task_instance_position(task_instance)

Review Comment:
   **The rule for choosing a task's runId is now copied in six places**
   
   Before, `get_regional_task_instance_run_id` decided between "use the task 
instance UUID (in a loop)" and "use the computed runId from 
dag/task/try/date/map_index (not in a loop)". With it removed, the same `if 
position.in_loop: str(ti.id) else build_task_instance_run_id(...)` block is 
written out separately in `on_task_instance_running`, 
`on_task_instance_success`, `on_task_instance_failed`, and 
`on_task_instance_skipped`. The manual state change path and `macros.py` (the 
`lineage_run_id` macro) have it too, so that's six copies.
   
   These copies must stay identical. If someone later changes how the runId is 
derived (e.g. includes `region_id`) and misses one, START and COMPLETE for the 
same attempt get different runIds. That's exactly what the new integrity check 
in the test operator is designed to catch, but only for Dags that the system 
tests cover. Keeping one helper, e.g. `get_task_instance_run_id(ti, position, 
logical_date)`, that all six call sites use would make this impossible to get 
out of sync.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1135,11 +1193,44 @@ class TaskInstanceInfo(InfoJsonEncodable):
         "dag_bundle_name": lambda ti: ti.bundle_instance.name if hasattr(ti, 
"bundle_instance") else None,
     }
 
+    def __init__(self, obj, position: TaskInstancePosition | None = None):
+        # Pass the position when the caller has already placed the task 
instance, e.g. from its stored region
+        self._position = position
+        super().__init__(obj)
+
     def _extend_fields(self) -> None:
-        map_index = get_task_instance_map_index(self.obj)
+        # A stored task instance whose task isn't attached can't be placed 
without its region, and a facet must
+        # not fail the whole event, so then it describes the task instance 
without its position.
+        position = self._position or _get_task_instance_position(self.obj)

Review Comment:
   **The loop iteration lookup, and its warning, run twice per event**
   
   The listener already computes the task instance's position (including the 
loop iteration) with `get_task_instance_position`, and passes it in here as 
`self._position`. This line then calls `_add_runtime_loop_iteration` on it a 
second time.
   
   Usually that's a no-op, because `loop_index` is already set. But when the 
first attempt couldn't find the iteration (the `loop` key is missing from the 
template context, or reading the context raised), `loop_index` is still `None`, 
so the second call:
   - calls `task_instance.get_template_context()` again. Per the comment in 
`_add_runtime_loop_iteration`, that re-validates the Dag params each time, and 
`get_dag_run_dag_and_task_from_ti` had already fetched the context once, so 
that's a third time;
   - if it fails again, logs "OpenLineage failed to read the loop iteration..." 
a second time for the same event.
   
   Using a position passed in by the caller as-is, i.e. only calling 
`_add_runtime_loop_iteration` when `self._position` is `None`, would avoid the 
repeat work and the duplicate warning.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1123,9 +1123,67 @@ def team_name(cls, dagrun: DagRun) -> str | None:
 
 
 class TaskInstanceInfo(InfoJsonEncodable):

Review Comment:
   **The same loop/region explanation is written out in several places**
   
   This docstring grew to about 60 lines, and much of it (what a region is, how 
clearing a loop forks it, how try numbers relate to iterations) is also 
explained in:
   - the `TaskInstancePosition` docstring,
   - the `region_id` / `region_index` / `try_number` descriptions in 
`AirflowRunFacet.json`,
   - the example Dag docstring.
   
   Airflow's `AGENTS.md` asks to keep explanations "short, precise and at the 
source of truth" and not repeat them at each call site. With four copies, a 
future behavior change will likely update one or two and leave the rest 
outdated. I'd suggest keeping the full explanation in one place (the facet 
schema seems the natural one, since consumers read it) and having the others 
refer to it.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -2049,6 +2224,11 @@ def _get_task_groups_details(dag: DAG | SerializedDAG, 
edge_map: dict[str, tuple
             "ui_label": tg.label,
             "downstream_task_edges": task_edges,
             "downstream_group_edges": group_edges,
+            **{
+                field: getattr(tg, field)  # Only a loop group has these
+                for field in ("gate_task_id", "has_until", "max_iterations", 
"terminal_task_id")

Review Comment:
   **These new loop-group fields aren't documented in any schema**
   
   For loop groups, this adds `gate_task_id`, `has_until`, `max_iterations` and 
`terminal_task_id` to the task group output. That output ends up in the run 
facet's `task_group` and in `AirflowJobFacet.taskGroups`.
   
   The `taskInstance` additions in this PR (`loop_index`, `loop_group_id`, 
etc.) were all added to `AirflowRunFacet.json` with descriptions. These four 
weren't: neither the `task_group` definition in `AirflowRunFacet.json` nor 
`AirflowJobFacet.json` mentions them. Validation still passes because those 
objects allow `additionalProperties`. But anyone reading the schema, or 
generating types or docs from it, won't know these fields exist or what they 
mean (e.g. what the "gate" and "terminal" tasks are). Could they get schema 
entries alongside the `taskInstance` ones?



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