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:
   `_is_task_in_loop` used to fall back to `return group is not None`; this now 
falls back to `return None`. A task with a real region whose group chain isn't 
a recognized `TaskGroup`/`SerializedTaskGroup` (some deserialized/proxied group 
type) is therefore treated as *not* in a loop.
   
   The consequence is worse than before: it gets the deterministic runId from 
(dag, task, try, date, map_index), which is identical for every iteration, so 
all iterations share one runId and their lineage merges. Erring on the "in 
loop" side (UUID runId) seems safer here.



##########
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:
   This adds a synchronous `DynamicRegion` query for mapped tasks in a loop 
(`without_iteration`), only to fill the descriptive `loop_index`. On the manual 
state change path this runs on the scheduler (`handle_failure`, mark-failed 
from UI), where such a TI was previously placed with zero queries.
   
   If the query raises, the outer `try` in 
`_on_task_instance_manual_state_change` swallows it and no FAIL/COMPLETE is 
emitted at all — which contradicts the rule stated in 
`_add_runtime_loop_iteration` that reading the iteration must not cost the 
event its run id. Could the `loop_index` lookup be isolated so its failure only 
drops `loop_index`?



##########
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:
   This branch assumes a found region with `node_id != task_id` is a loop's 
region. But `DynamicRegion` also stores mapped task group expansions.
   
   E.g. task `g.t` in a mapped task group `g` that is not in any loop, placed 
without its task (asset-event source / manual-state path): region `node_id='g' 
!= 'g.t'`, so it becomes `in_loop=True, map_index=-1, loop_index=<its map 
index>, loop_group_id='g'`. The event then reports a loop iteration that 
doesn't exist, drops the real `map_index`, and uses a UUID runId that doesn't 
match the deterministic one its worker events use.



##########
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:
   `region.parent_region_index` of a mapped task's own region is the loop 
iteration only if the region's direct parent is the loop region. If the mapped 
task sits inside another mapped region within the loop (e.g. loop → mapped task 
group → mapped task), this is the enclosing group's map index, not the 
iteration. I haven't checked whether that nesting is allowed — if it isn't, 
maybe worth a comment/assertion stating the assumption.



##########
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:
   `events_by_key` only collects keys that passed `_check_key`, but 
`check_run_id_integrity` still runs on that partial set. So if e.g. 
`dag.task.event.complete` fails template matching, integrity additionally 
reports "runId X ... should have one START and one COMPLETE/FAIL" — every real 
failure produces a second, misleading one. Maybe skip the integrity check (or 
the affected runIds) when key checks already failed?



##########
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:
   With `get_regional_task_instance_run_id` removed, the `if position.in_loop: 
str(ti.id) else build_task_instance_run_id(...)` rule is now repeated in 
on_running/on_success/on_failure/on_skipped, plus the manual state change path 
and `macros.py` — six copies. Any divergence makes START and COMPLETE carry 
different runIds (exactly what the new integrity check hunts for). A single 
helper taking `(ti, position, logical_date)` would keep the rule in one place.



##########
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:
   When the listener already passed a position, this still runs 
`_add_runtime_loop_iteration` on it; whenever `loop_index` is still `None` that 
calls `get_template_context()` again (re-running `process_params`) and logs the 
"failed to read the loop iteration" warning a second time. Could a 
caller-supplied position be used as-is, or the already-fetched context be 
passed in?



##########
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:
   `reason` is always included and is `None` when `until` returns a plain bool 
(or there is no `until`), so the gate emits `"loop_decision": {"stop": true, 
"reason": null}`. The schema types `reason` as `string`, so strict validators 
will reject this. Omitting the key when there's no reason would match the 
schema (and the example docs saying "no reason").



##########
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:
   This docstring is ~60 lines, and the trajectory/clear/region semantics are 
also explained in `TaskInstancePosition`, the `AirflowRunFacet.json` 
descriptions and the example Dag docstring. AGENTS.md asks to keep explanations 
"short, precise and at the source of truth" — could this be trimmed to one 
canonical place so the copies don't drift?



##########
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 loop-group fields (`gate_task_id`, `has_until`, `max_iterations`, 
`terminal_task_id`) are now emitted in `task_group` and 
`AirflowJobFacet.taskGroups`, but neither the `task_group` definition in 
`AirflowRunFacet.json` nor `AirflowJobFacet.json` describes them — only the 
`taskInstance` additions were documented. They pass via `additionalProperties`, 
but consumers generating types/docs won't see them.



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