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


##########
providers/openlineage/tests/system/openlineage/operator.py:
##########
@@ -351,33 +479,47 @@ 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)
                 except ValueError as e:
                     if self.fail_fast:
                         raise
                     errors[key] = str(e)
+            if self.check_run_ids and errors:
+                # A key that failed its template is missing from 
events_by_key, so its runIds would look incomplete
+                log.info("Skipping the runId integrity check until the event 
templates match")
+            elif self.check_run_ids:
+                if problems := check_run_id_integrity(
+                    events_by_key,
+                    repeated_start_keys={key for key in events_by_key if 
self._expects_repeated_events(key)},
+                ):
+                    message = "\n".join(f"    {problem}" for problem in 
problems)
+                    if self.fail_fast:
+                        raise ValueError(f"runId integrity check 
failed:\n{message}")
+                    errors["<runId integrity>"] = f"\n{message}"
         finally:
             if self.clear_variables:

Review Comment:
   Nothing tests that `execute()` actually runs this. The PR's headline is that 
system tests check runId integrity by default, but the only test of `execute()` 
(`test_operator_skips_the_run_id_check_when_a_template_fails`) covers the skip 
branch; the other 45 tests call `check_run_id_integrity` directly. On a copy, 
each of these leaves 46/46 passing: `elif self.check_run_ids:` -> `elif 
False:`, the default changed to `check_run_ids=False`, 
`repeated_start_keys=set()`, removing the `fail_fast` raise, and replacing 
`errors["<runId integrity>"] = ...` with `pass`. So the check can be silently 
disabled in every system test.
   
   One `execute()` test with `_check_key` returning events whose START and 
COMPLETE runIds differ would bind it: default -> `Event check failures`, 
`fail_fast` -> raises, `check_run_ids=False` -> passes. 
(`mock.patch("...time.sleep")` and `patch.object(operator, "_check_key")` in 
that test file also lack `autospec`.)



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1125,21 +1125,105 @@ def team_name(cls, dagrun: DagRun) -> str | None:
 class TaskInstanceInfo(InfoJsonEncodable):
     """Defines encoding TaskInstance object to JSON."""
 
-    includes = ["duration", "log_url", "pool", "queued_dttm", "try_number"]
+    includes = ["duration", "log_url", "max_tries", "pool", "queued_dttm", 
"state", "try_number"]
     casts = {
         "log_url": lambda ti: getattr(ti, "log_url", None),
         "note": lambda ti: safe_getattr(ti, "note", None),  # From manual 
state changes only
-        "dag_bundle_version": lambda ti: (
-            ti.bundle_instance.version if hasattr(ti, "bundle_instance") else 
None
+        "dag_version_id": lambda ti: (
+            str(dag_version_id) if (dag_version_id := getattr(ti, 
"dag_version_id", None)) else None
         ),
-        "dag_bundle_name": lambda ti: ti.bundle_instance.name if hasattr(ti, 
"bundle_instance") else None,
+        "dag_bundle_version": lambda ti: TaskInstanceInfo.get_bundle_info(ti, 
"version"),
+        "dag_bundle_name": lambda ti: TaskInstanceInfo.get_bundle_info(ti, 
"name"),
     }
 
+    @staticmethod
+    def get_rendered_map_index(ti: TaskInstance | RuntimeTaskInstance) -> str 
| None:
+        """Return the label a mapped task rendered with 
``map_index_template``, as the worker sends it."""
+        if AIRFLOW_V_3_0_PLUS and isinstance(ti, TaskInstance):
+            # Its rendered_map_index falls back to the map index when no label 
was rendered, the worker's doesn't
+            return safe_getattr(ti, "_rendered_map_index")
+        return getattr(ti, "rendered_map_index", None)
+
+    @staticmethod
+    def get_bundle_info(ti: TaskInstance | RuntimeTaskInstance, key: str) -> 
str | None:
+        """Return the name or version of the Dag bundle the attempt runs 
from."""
+        if (bundle := getattr(ti, "bundle_instance", None)) is not None:  # A 
runtime task instance
+            return getattr(bundle, key, None)
+        if not isinstance(ti, TaskInstance):
+            return None
+        # Only for a FAIL the scheduler sends itself, e.g. for a missed 
heartbeat: UI and API state changes build no
+        # airflow facet, and the worker's runtime task instance has its bundle 
above
+        return getattr(TaskInstanceInfo.get_stored_dag_version(ti), 
f"bundle_{key}", None)
+
+    @staticmethod
+    def get_stored_dag_version(ti: TaskInstance) -> Any:
+        """Return the Dag version of a stored task instance, or None when it 
can't be read, as on Airflow 2."""
+        from sqlalchemy import inspect
+
+        try:
+            state: InstanceState = inspect(ti)
+            if "dag_version" not in state.unloaded or state.session is None:
+                # Loaded already, or not in a session to load it from, so 
reading it doesn't query
+                return safe_getattr(ti, "dag_version")
+            # The event must still be sent without it, and the savepoint keeps 
the caller's transaction usable
+            with state.session.begin_nested():
+                return ti.dag_version
+        except Exception:
+            log.warning(
+                "OpenLineage failed to read the Dag version of task instance 
%s", getattr(ti, "id", None)
+            )
+            log.debug("Exception details:", exc_info=True)
+            return 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 or
+        # with get_task_instance_position; it is then used as is.
+        self._position = position
+        super().__init__(obj)
+
     def _extend_fields(self) -> None:
-        map_index = get_task_instance_map_index(self.obj)
+        position = self._position
+        if position is None:
+            # 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 = _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.rendered_map_index = self.get_rendered_map_index(self.obj) if 
map_index != -1 else None
         self._fields.extend(("map_index", "rendered_map_index"))
+        if position and position.expanded_ti_count is not None:
+            self.expanded_ti_count = position.expanded_ti_count
+            self._fields.append("expanded_ti_count")
+        if AIRFLOW_V_3_0_PLUS and (ti_id := getattr(self.obj, "id", None)) is 
not None:

Review Comment:
   `taskInstance.id` is not the id of the attempt that failed for a stored 
`TaskInstance` on Airflow 3.0-3.3, so "Airflow 3+" is wrong here. Those 
versions call `prepare_db_for_next_try` (which does `self.id = uuid7()`) 
*before* `on_task_instance_failed` in `fetch_handle_failure_context` (3.0.6 
`taskinstance.py:1945` then `:1952`; 3.3.2 `:1917` then `:1922`). 3.4 reverses 
the order (`notify_failure` at `:2727`, rotation after). So a scheduler-sent 
FAIL for an attempt that still has retries carries the *next* attempt's id 
(state `up_for_retry`), which is what the next START will carry. Probe against 
3.2.2 and 3.3.2 trees: START id `01a164b1-5f93-...`, id in the FAIL facet 
`01a164b1-5fb4-...`. The runId is unaffected on those versions, but this field 
exists to say which execution sent the event.
   
   Suggest emitting `id` for a stored `TaskInstance` only on 3.4+ 
(`AIRFLOW_V_3_4_PLUS or not isinstance(self.obj, TaskInstance)`), keeping it 
for `RuntimeTaskInstance` on every 3.x version, and saying so in the `id` 
description in `AirflowRunFacet.json`. Nothing tests this: the unit and system 
tests only run against the installed Airflow version.



##########
providers/openlineage/tests/system/openlineage/example_openlineage_task_states_dag.py:
##########
@@ -0,0 +1,76 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+DAG with tasks whose events only taskInstance's `state` tells apart (requires 
Airflow 3+).
+
+It checks:
+    - a task that skips itself emits START with `state` running, and COMPLETE 
with `state` skipped, which
+      a successful task's COMPLETE would carry as success
+      (Airflow 3.0 calls no listener when a task skips itself, so it emits no 
COMPLETE there)
+    - a task that raises AirflowFailException with retries left emits START 
with `state` running, and FAIL
+      with `state` failed at `try_number` 1 and `max_tries` 2: it isn't 
retried although
+      `try_number <= max_tries`
+    - taskInstance's `dag_version_id` of every event, from Airflow 3.1 on
+    - every task event's identity: taskInstance's `id`, `dag_version_id` 
(Airflow 3.1+) and bundle, the absence
+      of fields that don't apply, task's `task_id`, `trigger_rule`, `retries` 
and `retry_delay`, the Dag and Dag run,
+      and the parent run, plus each task in the Dag's job facet
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from airflow import DAG
+from airflow.providers.common.compat.sdk import AirflowFailException, 
AirflowSkipException
+from airflow.sdk import task
+
+from system.openlineage.constants import DEFAULT_DAGRUN_TIMEOUT
+from system.openlineage.expected_events import get_expected_event_file_path
+from system.openlineage.operator import OpenLineageTestOperator
+
+DAG_ID = "openlineage_task_states_dag"
+
+with DAG(
+    dagrun_timeout=DEFAULT_DAGRUN_TIMEOUT,
+    dag_id=DAG_ID,
+    start_date=datetime(2021, 1, 1),
+    schedule=None,
+    catchup=False,
+    default_args={"retries": 0},
+) as dag:
+
+    @task
+    def skip_itself():
+        raise AirflowSkipException("Intentional skip")
+
+    @task(retries=2)
+    def fail_without_retry():
+        raise AirflowFailException("Intentional failure that skips the retries 
left")
+
+    check_events = OpenLineageTestOperator(
+        task_id="check_events",
+        file_path=get_expected_event_file_path(DAG_ID),
+        trigger_rule="all_done",
+    )

Review Comment:
   This will fail on Airflow 3.0. `check_run_ids` defaults to `True`, and for a 
TASK runId `check_run_id_integrity` requires exactly one COMPLETE/FAIL 
(`operator.py:357`, `job_type != "DAG" and not terminals`). The `__af3_0` 
template just made `skip_itself`'s COMPLETE `$absent` (3.0 calls no listener 
when a task skips itself), so once the templates match its runId group holds 
only a START and `check_events` fails. Running the PR's own 
`check_run_id_integrity` over events built from that template gives:
   
   `runId skip_itself-uuid-try1 of TASK 
`openlineage_task_states_dag.skip_itself` should have one START (...) and one 
COMPLETE/FAIL, got: ['START']`
   
   `openlineage_task_states_dag.json` (3.1+) passes. 5ee168897b0 fixed the e2e 
failure for the missing COMPLETE; this is the next failure on the same job. 
Either pass `check_run_ids=False` on 3.0 for this Dag, or have the checker 
treat a terminal key declared `$absent` as an intentionally partial lifecycle 
(with a unit test for START + `$absent` terminals). A scan of the other 
expected-event files found no other START-without-terminal job.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1542,56 +1682,228 @@ def _require_task_instance_position(
     return position
 
 
+def _add_runtime_loop_iteration(
+    task_instance: TaskInstance | RuntimeTaskInstance, position: 
TaskInstancePosition
+) -> TaskInstancePosition:
+    if isinstance(task_instance, TaskInstance):
+        return position
+    # Only runtime task instances of Airflow 3.4+ expose these
+    position = attrs.evolve(position, expanded_ti_count=getattr(task_instance, 
"expanded_ti_count", None))
+    if not position.in_loop or position.loop_index is not None:
+        return position
+    if (loop := getattr(task_instance, "loop", None)) is None:
+        return position
+    return attrs.evolve(
+        position,
+        loop_index=loop.index,
+        loop_region_id=loop.region_id,
+        loop_trajectory=loop.trajectory,
+        loop_forked_from_region_id=loop.forked_from_region_id,
+        loop_resumes_from_index=loop.resumes_from_index,
+    )
+
+
+def get_task_instance_position(task_instance: TaskInstance | 
RuntimeTaskInstance) -> TaskInstancePosition:
+    """Place a task instance with its loop iteration, once per event, to pass 
to ``get_airflow_run_facet``."""
+    return _add_runtime_loop_iteration(task_instance, 
_require_task_instance_position(task_instance))
+
+
[email protected]
+class _TaskCoordinate:
+    """Where Airflow stores a task instance, as its ``TaskCoordinateResolver`` 
reads it."""
+
+    dag_id: str
+    run_id: str
+    task_id: str
+    region_id: UUID
+    region_index: int
+    dag_version_id: UUID | None
+
+
+def _get_core_task_instance_position(
+    task_instance: TaskInstance, *, session: Session | None
+) -> TaskInstancePosition | None:
+    """Place a stored task instance in a region as Airflow places it for the 
worker, from Airflow 3.4 on."""
+    if session is None or not AIRFLOW_V_3_4_PLUS:
+        return None
+    region_id = task_instance.region_id
+    if not isinstance(region_id, UUID) or region_id.int == 0:
+        return None
+    dag = getattr(getattr(task_instance, "task", None), "dag", None)
+    if (dag_version_id := getattr(dag, "dag_version_id", None)) is None:
+        return None
+    from airflow.models.task_coordinates import TaskCoordinateResolver
+
+    # Placed with the Dag version the task instance holds, which its task 
facet describes too, even when it pins
+    # another one: the resolver would load and deserialize that version for 
every task instance in the scheduler
+    # or API server process, while the region rows it reads don't depend on 
the version.
+    coordinate = _TaskCoordinate(
+        dag_id=task_instance.dag_id,
+        run_id=task_instance.run_id,
+        task_id=task_instance.task_id,
+        region_id=region_id,
+        region_index=task_instance.region_index,
+        dag_version_id=dag_version_id,
+    )
+    try:
+        # The savepoint keeps the caller's transaction usable after a failure
+        with session.begin_nested():

Review Comment:
   **Scheduler cost.** `get_stored_task_instance_position` now runs for every 
scheduler-sent FAIL (`process_executor_events` and the heartbeat purge), and 
this path costs a SAVEPOINT, a fresh `TaskCoordinateResolver` + `DBDagBag`, 
region-ancestry selects and, for mapped tasks, a `count(*)` over the whole 
expansion. That is 4 statements for a loop body and 5-6 for a mapped task in a 
loop (your own `assert_queries_count(4)` / `(5)`), where main needed 0 when the 
task is attached.
   
   It isn't only loops: any mapped task instance has a non-sentinel region, so 
every mapped TI the scheduler fails goes through here, and the `count(*)` makes 
N failures scan O(N^2) rows (0.35 -> 1.17 ms per call from N=200 to N=2000 on 
SQLite; PostgreSQL not measured). N is the number of task instances the 
scheduler fails in one pass, e.g. a worker pod lost with 500 running mapped TIs 
is ~3000 statements inside the scheduler loop's transaction, with nothing 
cached between calls (`for_dag` builds a new `DBDagBag` each time).
   
   Cheap options: skip the resolver when the task is not in a loop 
(`get_orm_task_instance_positions` already places an attached-task mapped TI 
with 0 queries), omit `expanded_ti_count` on scheduler-sent events or memoize 
it per `(dag_id, run_id, task_id, region_id)` for the pass, and share one 
resolver per pass.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1542,56 +1682,228 @@ def _require_task_instance_position(
     return position
 
 
+def _add_runtime_loop_iteration(
+    task_instance: TaskInstance | RuntimeTaskInstance, position: 
TaskInstancePosition
+) -> TaskInstancePosition:
+    if isinstance(task_instance, TaskInstance):
+        return position
+    # Only runtime task instances of Airflow 3.4+ expose these
+    position = attrs.evolve(position, expanded_ti_count=getattr(task_instance, 
"expanded_ti_count", None))
+    if not position.in_loop or position.loop_index is not None:
+        return position
+    if (loop := getattr(task_instance, "loop", None)) is None:
+        return position
+    return attrs.evolve(
+        position,
+        loop_index=loop.index,
+        loop_region_id=loop.region_id,
+        loop_trajectory=loop.trajectory,
+        loop_forked_from_region_id=loop.forked_from_region_id,
+        loop_resumes_from_index=loop.resumes_from_index,
+    )
+
+
+def get_task_instance_position(task_instance: TaskInstance | 
RuntimeTaskInstance) -> TaskInstancePosition:
+    """Place a task instance with its loop iteration, once per event, to pass 
to ``get_airflow_run_facet``."""
+    return _add_runtime_loop_iteration(task_instance, 
_require_task_instance_position(task_instance))
+
+
[email protected]
+class _TaskCoordinate:
+    """Where Airflow stores a task instance, as its ``TaskCoordinateResolver`` 
reads it."""
+
+    dag_id: str
+    run_id: str
+    task_id: str
+    region_id: UUID
+    region_index: int
+    dag_version_id: UUID | None
+
+
+def _get_core_task_instance_position(
+    task_instance: TaskInstance, *, session: Session | None
+) -> TaskInstancePosition | None:
+    """Place a stored task instance in a region as Airflow places it for the 
worker, from Airflow 3.4 on."""
+    if session is None or not AIRFLOW_V_3_4_PLUS:
+        return None
+    region_id = task_instance.region_id
+    if not isinstance(region_id, UUID) or region_id.int == 0:
+        return None
+    dag = getattr(getattr(task_instance, "task", None), "dag", None)
+    if (dag_version_id := getattr(dag, "dag_version_id", None)) is None:
+        return None
+    from airflow.models.task_coordinates import TaskCoordinateResolver
+
+    # Placed with the Dag version the task instance holds, which its task 
facet describes too, even when it pins
+    # another one: the resolver would load and deserialize that version for 
every task instance in the scheduler
+    # or API server process, while the region rows it reads don't depend on 
the version.

Review Comment:
   **Loop / runId.** The scheduler places this task instance with the Dag 
version it *holds* (`ti.task.dag`), not the one it *pinned* 
(`ti.dag_version_id`), and the reasoning in this comment is incomplete: the 
region rows don't depend on the version, but loop membership does. 
`enclosing_loop(task)` comes from that version's group definition, so if the 
held version no longer has the task in a loop, `get_loop_context` returns 
`None` without raising, you get `in_loop=False`, and `get_task_instance_run_id` 
builds a coordinate hash instead of `str(ti.id)`.
   
   When can the two differ: `get_dag_for_run` resolves to the *latest* version 
for any run without a `bundle_version` (`DBDagBag._version_from_dag_run`, i.e. 
the default local bundle), while the worker was placed with `ti.dag_version_id` 
(`ti_run`). A Dag edit that removes or renames the loop between the worker's 
START and a scheduler-detected failure (heartbeat timeout, executor-reported 
failure) is enough.
   
   Reproduced with two serialized versions of one Dag (`body.work` in a loop, 
then `body` as a plain `TaskGroup`):
   - pinned version: `in_loop=True, loop_index=1`, runId == `ti.id` 
(`01a164ed-d412-...`)
   - held (latest) version: `in_loop=False`, runId `018cc251-f400-757a-...`
   
   So the FAIL lands on a different run than the START, which stays open in the 
lineage backend. The loop facet fields (`max_iterations`, `terminal_task_id`) 
are also read from the held version even when the runId agrees.
   
   The commit message of 456a27fd158 says task instances pinned to another 
version "fall back to reading their region rows"; the code doesn't do that. And 
`test_stored_task_instance_is_placed_as_airflow_places_it_for_the_worker[holds_another_version=True]`
 only reassigns `dag_version_id` on a Dag with identical content, so it can't 
see this: passing `task_instance.dag_version_id` instead of 
`dag.dag_version_id` to `get_task` leaves the whole suite green 
(`DBDagBag.get_dag` is called 0 times unmutated, 1 time mutated).
   
   Suggestion: build the coordinate from `task_instance` (it already satisfies 
`TaskCoordinate`, and it is what `ti_run` passes) and only when 
`task_instance.dag_version_id != dag.dag_version_id` resolve against the pinned 
version. That is the one case where paying the Dag load is justified. The test 
then needs two versions that differ in loop membership, plus a spy on 
`DBDagBag.get_dag` (`assert_queries_count` is an upper bound and the identity 
map hides the repeated select).



##########
providers/openlineage/tests/unit/openlineage/utils/test_utils.py:
##########
@@ -3924,38 +4273,901 @@ def body():
             create_loop(body, max_iterations=2)
         else:
             body()
-    ti = SimpleNamespace(id=uuid4(), region_id=uuid4(), 
task=dag.get_task("body.work"))
+    ti = SimpleNamespace(
+        id=uuid4(),
+        dag_id="run_id_dag",
+        task_id="body.work",
+        try_number=1,
+        region_id=uuid4(),
+        map_index=-1,
+        task=dag.get_task("body.work"),
+    )
+    logical_date = timezone.datetime(2024, 1, 1)
+
+    run_id = get_task_instance_run_id(ti, logical_date=logical_date)
+
+    assert run_id == (
+        str(ti.id)
+        if in_loop
+        else build_task_instance_ol_run_id(
+            dag_id="run_id_dag", task_id="body.work", try_number=1, 
logical_date=logical_date, map_index=-1
+        )
+    )
+    assert get_task_instance_map_index(ti) == -1
+
+
+def test_task_instance_run_id_uses_the_map_index_of_the_given_position():
+    ti = SimpleNamespace(id=uuid4(), dag_id="dag", task_id="task", 
try_number=2)
+    logical_date = timezone.datetime(2024, 1, 1)
+
+    run_id = get_task_instance_run_id(
+        ti, logical_date=logical_date, 
position=TaskInstancePosition(in_loop=False, map_index=3)
+    )
+
+    assert run_id == build_task_instance_ol_run_id(
+        dag_id="dag", task_id="task", try_number=2, logical_date=logical_date, 
map_index=3
+    )
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
+def 
test_runtime_task_instance_position_without_its_loop_context_has_no_iteration():
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with DAG("position_dag") as dag:
+
+        @task_group
+        def body():
+            EmptyOperator(task_id="work")
+
+        create_loop(body, max_iterations=3)
+    ti = SimpleNamespace(
+        id=uuid4(), region_id=uuid4(), map_index=-1, 
task=dag.get_task("body.work"), loop=None
+    )
 
-    assert get_regional_task_instance_run_id(ti) == (str(ti.id) if in_loop 
else None)
+    assert get_task_instance_position(ti) == TaskInstancePosition(
+        in_loop=True, map_index=-1, loop_group_id="body"
+    )
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
+def 
test_runtime_task_instance_position_reads_its_loop_and_expansion_from_the_task_instance():
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with DAG("position_dag") as dag:
+
+        @task_group
+        def body():
+            PythonOperator.partial(task_id="work", 
python_callable=list).expand(op_kwargs=[{}, {}])
+
+        create_loop(body, max_iterations=3)
+    loop_region_id, forked_from_region_id = uuid4(), uuid4()
+    ti = SimpleNamespace(
+        id=uuid4(),
+        region_id=uuid4(),
+        map_index=1,
+        task=dag.get_task("body.work"),
+        expanded_ti_count=2,
+        loop=SimpleNamespace(
+            index=2,
+            max_iterations=3,
+            region_id=loop_region_id,
+            trajectory=2,
+            forked_from_region_id=forked_from_region_id,
+            resumes_from_index=1,
+        ),
+    )
+
+    assert get_task_instance_position(ti) == TaskInstancePosition(
+        in_loop=True,
+        map_index=1,
+        expanded_ti_count=2,
+        loop_index=2,
+        loop_group_id="body",
+        loop_region_id=loop_region_id,
+        loop_trajectory=2,
+        loop_forked_from_region_id=forked_from_region_id,
+        loop_resumes_from_index=1,
+    )
+
+
[email protected]_test
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
[email protected]("holds_another_version", [False, True])
+def test_stored_task_instance_is_placed_as_airflow_places_it_for_the_worker(
+    dag_maker, session, holds_another_version
+):
+    from airflow.models.dynamic_region import DynamicRegion
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with dag_maker(serialized=True):
+
+        @task_group
+        def body():
+            EmptyOperator(task_id="plain") >> PythonOperator.partial(
+                task_id="mapped", python_callable=list
+            ).expand(op_kwargs=[{}, {}])
+
+        create_loop(body, max_iterations=3)
+        PythonOperator.partial(task_id="outside", 
python_callable=list).expand(op_kwargs=[{}, {}, {}])
+    run = dag_maker.create_dagrun()
+    tis = {(ti.task_id, ti.region_index): ti for ti in run.task_instances}
+    # As the scheduler's Dag bag sets it on the Dag it loads for the run, 
which can be a later version than the
+    # one the task instances pin
+    tis["body.plain", 1].task.dag.dag_version_id = (
+        uuid7() if holds_another_version else tis["body.plain", 
1].dag_version_id
+    )
+    loop_region = session.get(DynamicRegion, tis["body.plain", 1].region_id)
+    fork = DynamicRegion.fork_family(loop_region.id, 2, {loop_region.id: 
loop_region}, session=session)
+    session.flush()
+    rerun = TaskInstance(
+        task=tis["body.plain", 1].task, run_id=run.run_id, 
dag_version_id=tis["body.plain", 1].dag_version_id
+    )
+    rerun.region_id, rerun.region_index = fork.id, 2
+    session.add(rerun)
+    session.flush()
+    in_loop = {"in_loop": True, "loop_trajectory": 1, "loop_group_id": "body"}
+
+    # Placing reuses the Dag the task instance holds: its region rows, then 
the count of a mapped expansion
+    with assert_queries_count(4):
+        plain = get_stored_task_instance_position(tis["body.plain", 1], 
session=session)
+    with assert_queries_count(5):
+        mapped = get_stored_task_instance_position(tis["body.mapped", 1], 
session=session)
+    outside = get_stored_task_instance_position(tis["outside", 2], 
session=session)
+    rerun_position = get_stored_task_instance_position(rerun, session=session)
+
+    assert plain == TaskInstancePosition(map_index=-1, loop_index=1, 
loop_region_id=loop_region.id, **in_loop)
+    assert mapped == TaskInstancePosition(
+        map_index=1, expanded_ti_count=2, loop_index=1, 
loop_region_id=loop_region.id, **in_loop
+    )
+    assert outside == TaskInstancePosition(in_loop=False, map_index=2, 
expanded_ti_count=3)
+    assert rerun_position == TaskInstancePosition(
+        in_loop=True,
+        map_index=-1,
+        loop_index=2,
+        loop_group_id="body",
+        loop_region_id=fork.id,
+        loop_trajectory=2,
+        loop_forked_from_region_id=loop_region.id,
+        loop_resumes_from_index=2,
+    )
+    assert dict(TaskInstanceInfo(rerun, position=rerun_position))["loop"] == {
+        "index": 2,
+        "region_id": str(fork.id),
+        "trajectory": 2,
+        "forked_from_region_id": str(loop_region.id),
+        "resumes_from_index": 2,
+    }
 
 
 @pytest.mark.db_test
[email protected](not AIRFLOW_V_3_0_PLUS, reason="Dag versions require 
Airflow 3")
[email protected]("load_fails", [False, True])
+@patch("airflow.providers.openlineage.utils.utils.log", autospec=True)
+def 
test_stored_task_instance_info_reads_its_dag_version_without_risking_the_event(
+    mock_log, dag_maker, session, load_fails
+):
+    with dag_maker(session=session):
+        PythonOperator(task_id="work", python_callable=list)
+    ti = dag_maker.create_dagrun().task_instances[0]
+    session.expire(ti, ["dag_version"])
+
+    if load_fails:
+        with patch.object(
+            TaskInstance,
+            "dag_version",
+            new_callable=PropertyMock,
+            side_effect=RuntimeError("Lost connection"),
+        ):
+            info = dict(TaskInstanceInfo(ti, 
position=TaskInstancePosition(in_loop=False, map_index=-1)))
+    else:
+        info = dict(TaskInstanceInfo(ti, 
position=TaskInstancePosition(in_loop=False, map_index=-1)))
+
+    expected_bundle_name = None if load_fails else "dag_maker"
+    assert (info["dag_bundle_name"], info["dag_version_id"]) == 
(expected_bundle_name, str(ti.dag_version_id))
+    assert mock_log.warning.call_count == (
+        2 if load_fails else 0
+    )  # Once for the bundle name, once for its version
+    # The failure was rolled back to its savepoint, so the caller's 
transaction is still usable
+    assert session.execute(select(literal(1))).scalar() == 1
+
+
[email protected]_test
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
[email protected]("task_id", ["body.plain", "body.inner.mapped"])
+def 
test_stored_and_runtime_task_instances_identify_an_execution_alike(dag_maker, 
session, task_id):
+    """
+    The scheduler's events about a stored task instance must tell everything 
the worker's tell about it.
+
+    A field missing on one side only would leave consumers without it for that 
kind of event, while the other
+    side's events made them rely on it.
+    """
+    from airflow.models.task_coordinates import TaskCoordinateResolver
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with dag_maker(serialized=True):
+
+        @task_group
+        def body():
+            plain = EmptyOperator(task_id="plain")
+            with TaskGroup("inner"):
+                plain >> PythonOperator.partial(task_id="mapped", 
python_callable=list).expand(
+                    op_kwargs=[{}, {}]
+                )
+
+        create_loop(body, max_iterations=3)
+    run = dag_maker.create_dagrun()
+    stored = next(ti for ti in run.task_instances if ti.task_id == task_id and 
ti.region_index in (0, 1))
+    stored.task.dag.dag_version_id = stored.dag_version_id
+    stored.state, stored.try_number, stored.max_tries = 
TaskInstanceState.UP_FOR_RETRY, 1, 2
+    resolver = TaskCoordinateResolver.for_dag(stored.task.dag, session)
+    mapped = stored.task.get_needs_expansion()
+    # What the server sends the worker for this execution, on the task of the 
Dag the worker parses
+    runtime = SimpleNamespace(
+        id=stored.id,
+        dag_id=stored.dag_id,
+        run_id=stored.run_id,
+        task_id=stored.task_id,
+        try_number=stored.try_number,
+        max_tries=stored.max_tries,
+        state=stored.state,
+        map_index=stored.region_index if mapped else -1,
+        rendered_map_index=None,
+        region_id=stored.region_id,
+        region_index=stored.region_index,
+        dag_version_id=stored.dag_version_id,
+        bundle_instance=SimpleNamespace(
+            name=stored.dag_version.bundle_name, 
version=stored.dag_version.bundle_version
+        ),
+        loop=resolver.get_loop_context(stored, task=stored.task),
+        expanded_ti_count=resolver.get_expanded_ti_count(stored, 
task=stored.task),
+        task=dag_maker.dag.get_task(task_id),
+    )
+
+    stored_info = dict(
+        TaskInstanceInfo(stored, 
position=get_stored_task_instance_position(stored, session=session))
+    )
+    runtime_info = dict(TaskInstanceInfo(runtime, 
position=get_task_instance_position(runtime)))
+
+    # Only stored task instances have these, which describe the attempt's 
resources rather than which it is
+    resources = {"duration", "pool", "queued_dttm", "note", "log_url"}
+    assert {k: v for k, v in stored_info.items() if k not in resources} == {
+        k: v for k, v in runtime_info.items() if k not in resources
+    }
+    assert ("expanded_ti_count" in runtime_info) is mapped
+    assert runtime_info["loop"]["trajectory"] == 1
+    identity = ("task_id", "task_group", "loop", "retries", "retry_delay")
+    assert {k: dict(TaskInfo(stored.task))[k] for k in identity} == {
+        k: dict(TaskInfo(runtime.task))[k] for k in identity
+    }
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
[email protected](
+    "dag_has_version", [pytest.param(True, id="airflow_fails"), 
pytest.param(False, id="no_version")]
+)
[email protected](
+    "airflow.models.task_coordinates.TaskCoordinateResolver.for_dag", 
side_effect=ValueError("Dag not found")
+)
+def 
test_stored_task_instance_is_placed_from_its_rows_without_airflow(mock_for_dag, 
dag_has_version):
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with DAG("fallback_dag") as dag:
+
+        @task_group
+        def body():
+            EmptyOperator(task_id="work")
+
+        create_loop(body, max_iterations=3)
+    ti = TaskInstance(task=dag.get_task("body.work"), dag_version_id=uuid7())
+    ti.region_id = uuid4()
+    ti.region_index = 1
+    dag.dag_version_id = ti.dag_version_id if dag_has_version else None
+    session = MagicMock(spec=Session)
+
+    assert get_stored_task_instance_position(ti, session=session) == 
TaskInstancePosition(
+        in_loop=True, map_index=-1, loop_index=1, loop_group_id="body"
+    )
+    if dag_has_version:
+        mock_for_dag.assert_called_once_with(dag, session)
+    else:
+        mock_for_dag.assert_not_called()
+    session.execute.assert_not_called()
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
+def 
test_stored_mapped_task_in_loop_is_placed_without_iteration_when_its_region_query_fails():
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with DAG("stored_mapped_loop_dag") as dag:
+
+        @task_group
+        def body():
+            PythonOperator.partial(task_id="work", 
python_callable=list).expand(op_kwargs=[{}, {}])
+
+        create_loop(body, max_iterations=3)
+    ti = TaskInstance(task=dag.get_task("body.work"), dag_version_id=uuid7())
+    ti.region_id = uuid4()
+    ti.region_index = 1
+    session = MagicMock(spec=Session)
+    session.execute.side_effect = OSError("connection lost")
+
+    positions = get_orm_task_instance_positions([ti], session=session)
+
+    assert positions == {ti.id: TaskInstancePosition(in_loop=True, 
map_index=1, loop_group_id="body")}
+    session.begin_nested.assert_called_once_with()
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Region identity requires 
Airflow 3.4")
+def 
test_stored_task_instance_without_task_still_raises_when_its_region_query_fails():
+    ti = TaskInstance(task=MagicMock(), run_id="run_id", 
dag_version_id=uuid7())
+    ti.region_id = uuid4()
+    ti.region_index = 1
+    ti.task = None
+    session = MagicMock(spec=Session)
+    session.execute.side_effect = OSError("connection lost")
+
+    with pytest.raises(OSError, match="connection lost"):
+        get_orm_task_instance_positions([ti], session=session)
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loops require Airflow 3.4")
+def test_task_in_a_task_group_of_unknown_type_is_assumed_in_a_loop():
+    task = SimpleNamespace(task_id="work", 
task_group=SimpleNamespace(group_id="unknown"))
+    ti = SimpleNamespace(id=uuid4(), region_id=uuid4(), map_index=-1, 
task=task)
+
+    assert get_task_instance_position(ti) == 
TaskInstancePosition(in_loop=True, map_index=-1)
+
+
 @pytest.mark.skipif(not AIRFLOW_V_3_4_PLUS, reason="Region identity requires 
Airflow 3.4")
+def test_stored_task_instance_without_task_or_region_has_no_loop_index():
+    ti = TaskInstance(task=MagicMock(), run_id="run_id", 
dag_version_id=uuid7())
+    ti.dag_id = "dag_id"
+    ti.task_id = "task_id"
+    ti.region_id = uuid4()
+    ti.region_index = 1
+    ti.task = None
+    session = MagicMock(spec=Session)
+    session.execute.return_value = []
+
+    assert get_orm_task_instance_positions([ti], session=session) == {
+        ti.id: TaskInstancePosition(in_loop=True, map_index=-1, 
loop_index=None)
+    }
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
 @pytest.mark.parametrize("mapped", [False, True])
-def 
test_stored_regional_instance_without_its_task_is_placed_from_its_region(dag_maker,
 session, mapped):
[email protected]("in_loop", [False, True])
+def test_runtime_task_instance_info_has_loop_only_inside_a_loop(in_loop, 
mapped):
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with DAG("loop_index_dag") as dag:
+
+        @task_group
+        def body():
+            if mapped:
+                PythonOperator.partial(task_id="work", 
python_callable=list).expand(op_kwargs=[{}, {}])
+            else:
+                EmptyOperator(task_id="work")
+
+        if in_loop:
+            create_loop(body, max_iterations=3)
+        else:
+            body()
+    ti = SimpleNamespace(
+        id=uuid4(),
+        region_id=uuid4(),
+        map_index=1 if mapped else -1,
+        task=dag.get_task("body.work"),
+    )
+    if in_loop:
+        ti.loop = SimpleNamespace(
+            index=2,
+            max_iterations=3,
+            region_id=None,
+            trajectory=None,
+            forked_from_region_id=None,
+            resumes_from_index=None,
+        )
+
+    info = dict(TaskInstanceInfo(ti))
+
+    assert info["map_index"] == (1 if mapped else None)
+    if in_loop:
+        assert info["loop"] == {
+            "index": 2,
+        }
+    else:
+        assert "loop" not in info
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loops require Airflow 3.4")
[email protected]("nested", [False, True])
+def test_task_info_has_the_loop_around_the_task(nested):
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    def ready():
+        return True
+
+    with DAG("nested_loop_dag") as dag:
+        with TaskGroup("outer"):
+
+            @task_group
+            def body():
+                if nested:
+
+                    @task_group
+                    def inner():
+                        EmptyOperator(task_id="work")
+
+                    inner()
+                else:
+                    EmptyOperator(task_id="work")
+
+            create_loop(body, max_iterations=3, until=ready)
+    task_id = "outer.body.inner.work" if nested else "outer.body.work"
+
+    info = dict(TaskInfo(dag.get_task(task_id)))
+
+    assert info["loop"] == {
+        "group_id": "outer.body",
+        "max_iterations": 3,
+        "has_until": True,
+        "gate_task_id": dag.task_group_dict["outer.body"].gate_task_id,
+        "terminal_task_id": task_id,
+    }
+    # task_group is the task's own group and never describes the loop; its 
unique id tells whether it is the loop
+    assert (info["task_group"]["group_id"], 
info["task_group"]["unique_group_id"]) == (
+        ("inner", "outer.body.inner") if nested else ("body", "outer.body")
+    )
+    assert (info["task_group"]["unique_group_id"] == info["loop"]["group_id"]) 
is not nested
+    assert "gate_task_id" not in info["task_group"]
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loops require Airflow 3.4")
[email protected](
+    ("until", "loop_index", "expected", "runs_out"),
+    [
+        pytest.param(None, 1, {"stop": False}, False, id="no_until_continues"),
+        pytest.param(None, 3, {"stop": True}, False, 
id="no_until_stops_at_last"),
+        pytest.param("done", 1, {"stop": True, "reason": "done"}, False, 
id="until_stops_with_reason"),
+        pytest.param(False, 3, {"stop": False}, True, 
id="until_false_at_last"),
+    ],
+)
+def test_task_instance_info_has_the_decision_of_a_loop_gate(until, loop_index, 
expected, runs_out):
+    from airflow.sdk import LoopDecision
+    from airflow.sdk.definitions._internal.loop import LoopGateOperator
+    from airflow.sdk.execution_time.loop import LoopMaxIterationsExceeded, 
execute_loop_gate
+    from airflow.sdk.execution_time.task_runner import RuntimeTaskInstance
+
+    class GateTaskInstance(SimpleNamespace):
+        loop_decision = RuntimeTaskInstance.loop_decision
+
+    if until is False:
+        until_callable = lambda: False
+    elif until is not None:
+        until_callable = lambda: LoopDecision(stop=True, reason=until)
+    else:
+        until_callable = None
+    gate = LoopGateOperator(task_id="__loop_gate", until=until_callable)
+    ti = GateTaskInstance(id=uuid7(), map_index=-1, _loop_decision=None)
+    context = {"loop": SimpleNamespace(index=loop_index, max_iterations=3), 
"ti": ti}
+
+    if runs_out:
+        with pytest.raises(LoopMaxIterationsExceeded):
+            execute_loop_gate(gate, context)
+    else:
+        execute_loop_gate(gate, context)
+    info = dict(TaskInstanceInfo(ti, 
position=TaskInstancePosition(in_loop=False, map_index=-1)))
+
+    assert info["loop"]["decision"] == expected
+
+
+def test_task_instance_info_has_no_loop_decision_for_other_tasks():
+    ti = SimpleNamespace(id=uuid7(), map_index=-1)
+
+    info = dict(TaskInstanceInfo(ti, 
position=TaskInstancePosition(in_loop=False, map_index=-1)))
+
+    assert "loop" not in info
+
+
[email protected](not AIRFLOW_V_3_0_PLUS, reason="Attempt UUIDs require 
Airflow 3")
[email protected]("stored", [False, True])
[email protected]("in_region", [False, True])
+def test_task_instance_info_has_attempt_id_and_region_only_in_a_region(stored, 
in_region):
+    region_id = uuid4() if in_region else UUID(int=0)
+    if stored:
+        ti = TaskInstance(task=MagicMock(), run_id="run_id", 
dag_version_id=uuid7())
+    else:
+        ti = SimpleNamespace(id=uuid7(), map_index=-1)
+    ti.region_id = region_id
+    ti.region_index = 2 if in_region else -1
+
+    info = dict(TaskInstanceInfo(ti, 
position=TaskInstancePosition(in_loop=False, map_index=-1)))
+
+    assert info["id"] == str(ti.id)
+    if in_region:
+        assert (info["region_id"], info["region_index"]) == (str(region_id), 2)
+    else:
+        assert "region_id" not in info
+        assert "region_index" not in info
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
+def test_stored_task_instance_in_loop_has_loop_index_and_max_iterations():
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with DAG("stored_loop_dag") as dag:
+
+        @task_group
+        def body():
+            EmptyOperator(task_id="work")
+
+        create_loop(body, max_iterations=3)
+    ti = TaskInstance(task=dag.get_task("body.work"), dag_version_id=uuid7())
+    ti.region_id = uuid4()
+    ti.region_index = 2
+
+    assert get_orm_task_instance_positions([ti], session=None) == {
+        ti.id: TaskInstancePosition(in_loop=True, map_index=-1, loop_index=2, 
loop_group_id="body")
+    }
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
[email protected]("with_session", [True, False])
+def 
test_stored_mapped_task_in_loop_gets_its_iteration_from_its_region(with_session):
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with DAG("stored_mapped_loop_dag") as dag:
+
+        @task_group
+        def body():
+            PythonOperator.partial(task_id="work", 
python_callable=list).expand(op_kwargs=[{}, {}])
+
+        create_loop(body, max_iterations=3)
+    ti = TaskInstance(task=dag.get_task("body.work"), dag_version_id=uuid7())
+    ti.region_id = uuid4()
+    ti.region_index = 1
+    session = MagicMock(spec=Session)
+    session.execute.return_value = [_region_row(ti.region_id, "body.work", 
parent=(uuid4(), 2, "body"))]
+
+    positions = get_orm_task_instance_positions([ti], session=session if 
with_session else None)
+
+    assert positions == {
+        ti.id: TaskInstancePosition(
+            in_loop=True,
+            map_index=1,
+            loop_index=2 if with_session else None,
+            loop_group_id="body",
+        )
+    }
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Region identity requires 
Airflow 3.4")
+def test_task_instance_info_of_stored_instance_without_task_does_not_raise():
+    ti = TaskInstance(task=MagicMock(), run_id="run_id", 
dag_version_id=uuid7())
+    ti.dag_id = "dag_id"
+    ti.task_id = "task_id"
+    ti.region_id = uuid4()
+    ti.region_index = 2
+    ti.task = None
+
+    info = dict(TaskInstanceInfo(ti))
+
+    assert info["map_index"] is None
+    assert "loop" not in info
+
+
+def test_task_instance_info_uses_the_position_it_is_given():
+    # The task instance's own loop and expansion differ, so the info can only 
match the given position
+    ti = SimpleNamespace(
+        id=uuid4(),
+        region_id=uuid4(),
+        map_index=-1,
+        expanded_ti_count=9,
+        loop=SimpleNamespace(index=9, max_iterations=9),
+    )
+    loop_region_id, forked_from_region_id = uuid4(), uuid4()
+    position = TaskInstancePosition(
+        in_loop=True,
+        map_index=1,
+        expanded_ti_count=4,
+        loop_index=2,
+        loop_group_id="body",
+        loop_region_id=loop_region_id,
+        loop_trajectory=2,
+        loop_forked_from_region_id=forked_from_region_id,
+        loop_resumes_from_index=2,
+    )
+
+    info = dict(TaskInstanceInfo(ti, position=position))
+
+    assert (info["map_index"], info["expanded_ti_count"]) == (1, 4)
+    assert info["loop"] == {
+        "index": 2,
+        "region_id": str(loop_region_id),
+        "trajectory": 2,
+        "forked_from_region_id": str(forked_from_region_id),
+        "resumes_from_index": 2,
+    }
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loops require Airflow 3.4")
+def test_task_group_info_has_no_loop_attributes():
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with DAG("loop_group_dag"):
+
+        @task_group
+        def body():
+            EmptyOperator(task_id="work")
+
+        loop = create_loop(body, max_iterations=3)
+
+        @task_group
+        def plain():
+            EmptyOperator(task_id="work")
+
+        plain_group = plain()
+
+    loop_attributes = {"max_iterations", "has_until", "terminal_task_id", 
"gate_task_id"}
+    loop_info = dict(TaskGroupInfo(loop))
+    assert loop_info["group_id"] == "body"
+    assert not loop_attributes & set(loop_info)
+    assert not loop_attributes & set(TaskGroupInfo(plain_group))
+
+
+def _region_row(region_id, node_id, *, parent=None, 
grandparent_region_id=None):
+    """Build a row of the region query: ``parent`` is ``(parent_region_id, 
parent_region_index, parent_node_id)``."""
+    parent_region_id, parent_region_index, parent_node_id = parent or (None, 
None, None)
+    return SimpleNamespace(
+        id=region_id,
+        node_id=node_id,
+        parent_region_id=parent_region_id,
+        parent_region_index=parent_region_index,
+        parent_node_id=parent_node_id,
+        grandparent_region_id=grandparent_region_id,
+    )
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Region identity requires 
Airflow 3.4")
[email protected](
+    ("own_region", "parent", "grandparent_region_id", "map_index"),
+    [
+        pytest.param(True, (uuid4(), 2, "outer"), uuid4(), 1, 
id="mapped_task_two_levels_deep"),
+        pytest.param(False, (uuid4(), 2, "outer"), None, -1, 
id="enclosing_region_not_top_level"),
+        pytest.param(False, None, None, -1, id="region_missing"),
+    ],
+)
+def 
test_stored_task_instance_in_an_unexpected_region_shape_is_placed_without_iteration(
+    own_region, parent, grandparent_region_id, map_index
+):
+    ti = TaskInstance(task=MagicMock(), run_id="run_id", 
dag_version_id=uuid7())
+    ti.task_id = "work"
+    ti.region_id = uuid4()
+    ti.region_index = 1
+    ti.task = None
+    session = MagicMock(spec=Session)
+    rows = []
+    if parent is not None or own_region:
+        rows = [
+            _region_row(
+                ti.region_id,
+                "work" if own_region else "group",
+                parent=parent,
+                grandparent_region_id=grandparent_region_id,
+            )
+        ]
+    session.execute.return_value = rows
+
+    assert get_orm_task_instance_positions([ti], session=session) == {
+        ti.id: TaskInstancePosition(in_loop=True, map_index=map_index)
+    }
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
[email protected](
+    ("parent", "grandparent_region_id"),
+    [
+        pytest.param((uuid4(), 2, "another_loop"), None, 
id="parent_is_not_its_loop"),
+        pytest.param((uuid4(), 2, "body"), uuid4(), 
id="loop_region_not_top_level"),
+    ],
+)
+def 
test_stored_mapped_task_in_loop_keeps_no_iteration_from_an_unexpected_region_shape(
+    parent, grandparent_region_id
+):
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with DAG("stored_mapped_loop_dag") as dag:
+
+        @task_group
+        def body():
+            PythonOperator.partial(task_id="work", 
python_callable=list).expand(op_kwargs=[{}, {}])
+
+        create_loop(body, max_iterations=3)
+    ti = TaskInstance(task=dag.get_task("body.work"), dag_version_id=uuid7())
+    ti.region_id = uuid4()
+    ti.region_index = 1
+    session = MagicMock(spec=Session)
+    session.execute.return_value = [
+        _region_row(ti.region_id, "body.work", parent=parent, 
grandparent_region_id=grandparent_region_id)
+    ]
+
+    assert get_orm_task_instance_positions([ti], session=session) == {
+        ti.id: TaskInstancePosition(in_loop=True, map_index=1, 
loop_group_id="body")
+    }
+
+
[email protected]_test
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loops require Airflow 3.4")
[email protected]("task_attached", [False, True])
+def test_region_shapes_of_airflow_are_the_ones_openlineage_places(dag_maker, 
session, task_attached):
+    """
+    Pin the regions Airflow creates, which ``get_orm_task_instance_positions`` 
relies on to place task instances.
+
+    If this fails, Airflow changed which regions it creates or how they nest: 
update the region parsing in
+    ``get_orm_task_instance_positions`` and its comment before changing these 
expectations.
+    """
+    from airflow.sdk import task, task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with dag_maker(serialized=True) as dag:
+
+        @task_group
+        def body():
+            plain = EmptyOperator(task_id="plain")
+            mapped = PythonOperator.partial(task_id="mapped", 
python_callable=list).expand(op_kwargs=[{}, {}])
+
+            @task_group
+            def mapped_group(x):
+                @task
+                def member(x):
+                    return x
+
+                member(x)
+
+            last = EmptyOperator(task_id="last")
+            plain >> mapped >> mapped_group.expand(x=[1, 2]) >> last
+
+        create_loop(body, max_iterations=2)
+
+        @task_group
+        def top_group(x):
+            @task
+            def top_member(x):
+                return x
+
+            top_member(x)
+
+        top_group.expand(x=[1, 2])
+    run = dag_maker.create_dagrun()
+    tis = [ti for ti in run.task_instances if ti.task_id != "body.__loop_gate"]
+    for ti in tis:
+        ti.task = dag.get_task(ti.task_id) if task_attached else None
+
+    positions = get_orm_task_instance_positions(tis, session=session)
+
+    assert {(ti.task_id, ti.region_index): positions[ti.id] for ti in tis} == {
+        ("body.plain", 1): TaskInstancePosition(
+            in_loop=True, map_index=-1, loop_index=1, loop_group_id="body"
+        ),
+        ("body.last", 1): TaskInstancePosition(
+            in_loop=True, map_index=-1, loop_index=1, loop_group_id="body"
+        ),
+        **{
+            (task_id, map_index): TaskInstancePosition(
+                in_loop=True, map_index=map_index, loop_index=1, 
loop_group_id="body"
+            )
+            for task_id in ("body.mapped", "body.mapped_group.member")
+            for map_index in (0, 1)
+        },
+        **{
+            ("top_group.top_member", map_index): 
TaskInstancePosition(in_loop=False, map_index=map_index)
+            for map_index in (0, 1)
+        },
+    }
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loops require Airflow 3.4")
[email protected]("shape", ["nested_loop", "loop_in_mapped_group", 
"expand_in_mapped_group"])
+def test_region_shapes_stay_limited_by_the_task_sdk(shape):
+    """
+    Pin the Dag shapes the task SDK rejects, which 
``get_orm_task_instance_positions`` relies on.
+
+    If one of them stops raising, a task can sit two region levels deep, which 
OpenLineage places as a loop
+    without its iteration: support the new shape in 
``get_orm_task_instance_positions``, then update this test.
+    """
+    from airflow.sdk import task, task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    def define_dag():
+        with DAG(f"{shape}_dag"):
+
+            @task_group
+            def inner():
+                EmptyOperator(task_id="work")
+
+            if shape == "nested_loop":
+
+                @task_group
+                def outer():
+                    create_loop(inner, max_iterations=2)
+
+                create_loop(outer, max_iterations=2)
+            elif shape == "loop_in_mapped_group":
+
+                @task_group
+                def mapped_outer(x):
+                    create_loop(inner, max_iterations=2)
+
+                mapped_outer.expand(x=[1, 2])
+            else:
+
+                @task_group
+                def mapped_outer(x):
+                    @task
+                    def member(y):
+                        return y
+
+                    member.expand(y=[x])
+
+                mapped_outer.expand(x=[1, 2])
+
+    with pytest.raises(NotImplementedError):

Review Comment:
   `pytest.raises(NotImplementedError)` has no `match`, so any unrelated 
`NotImplementedError` raised while building the Dag keeps this tripwire green. 
The three real messages are distinct ("Nested loops are not supported", "A loop 
cannot be inside a mapped task group", "operator expansion in an expanded task 
group is not yet supported"); parametrize the expected message and pass 
`match=`.



##########
providers/openlineage/tests/unit/openlineage/utils/test_utils.py:
##########
@@ -2011,6 +2126,201 @@ def test_get_task_groups_details():
     assert result == expected
 
 
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loops require Airflow 3.4")
+def test_get_task_groups_details_has_loop_attributes_only_for_a_loop():
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop

Review Comment:
   This PR adds ~43 function-body imports to this file (and 6 to 
test_listener.py), none with a reason. The same `task_group`/`create_loop` pair 
is repeated ~17 times; `DynamicRegion` is re-imported at line 4381 although it 
is already imported at line 121 under `if AIRFLOW_V_3_4_PLUS:`; `task` is 
re-imported at 2233, 5022 and 5091 although it is a top-level import; and 
`create_scheduler_dag` is imported inline at 2236 and 2312 while 
test_listener.py imports it at module level. Extend the existing `if 
AIRFLOW_V_3_4_PLUS:` block at the top instead.
   
   Also `create_loop(body, max_iterations=3)` with the same body is repeated 
~15 times (1 in the base file); a small helper returning the Dag, with optional 
mapped/nested variants, would remove ~150 lines, and the four mapped-in-loop 
placement tests overlap (only the real-database ones measure the code rather 
than the stub).



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1461,6 +1556,8 @@ def get_task_duration(ti):
         return {
             "airflowState": AirflowStateRunFacet(
                 dagRunState=dag_run_state,
+                # TODO: keyed by task_id only, so the mapped instances and 
loop iterations of one task collapse
+                #  into a single entry (the last one by region wins). Needs 
the map index and loop iteration here.

Review Comment:
   The title is "Fix task loop information", and `airflowState` 
(`tasksState`/`tasksDuration`) is still keyed by `task_id`, so the loop passes 
and mapped slots of one task collapse into one entry (the last by `(region_id, 
region_index)` wins). The TODO records it. Either fix it here, or link an issue 
in the TODO so it is tracked: as it stands the Dag-run-level facet is the one 
place that still reports a single state for every iteration of a loop.



##########
providers/openlineage/tests/unit/openlineage/utils/test_utils.py:
##########
@@ -3924,38 +4273,901 @@ def body():
             create_loop(body, max_iterations=2)
         else:
             body()
-    ti = SimpleNamespace(id=uuid4(), region_id=uuid4(), 
task=dag.get_task("body.work"))
+    ti = SimpleNamespace(
+        id=uuid4(),
+        dag_id="run_id_dag",
+        task_id="body.work",
+        try_number=1,
+        region_id=uuid4(),
+        map_index=-1,
+        task=dag.get_task("body.work"),
+    )
+    logical_date = timezone.datetime(2024, 1, 1)
+
+    run_id = get_task_instance_run_id(ti, logical_date=logical_date)
+
+    assert run_id == (
+        str(ti.id)
+        if in_loop
+        else build_task_instance_ol_run_id(
+            dag_id="run_id_dag", task_id="body.work", try_number=1, 
logical_date=logical_date, map_index=-1
+        )
+    )
+    assert get_task_instance_map_index(ti) == -1
+
+
+def test_task_instance_run_id_uses_the_map_index_of_the_given_position():
+    ti = SimpleNamespace(id=uuid4(), dag_id="dag", task_id="task", 
try_number=2)
+    logical_date = timezone.datetime(2024, 1, 1)
+
+    run_id = get_task_instance_run_id(
+        ti, logical_date=logical_date, 
position=TaskInstancePosition(in_loop=False, map_index=3)
+    )
+
+    assert run_id == build_task_instance_ol_run_id(
+        dag_id="dag", task_id="task", try_number=2, logical_date=logical_date, 
map_index=3
+    )
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
+def 
test_runtime_task_instance_position_without_its_loop_context_has_no_iteration():
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with DAG("position_dag") as dag:
+
+        @task_group
+        def body():
+            EmptyOperator(task_id="work")
+
+        create_loop(body, max_iterations=3)
+    ti = SimpleNamespace(
+        id=uuid4(), region_id=uuid4(), map_index=-1, 
task=dag.get_task("body.work"), loop=None
+    )
 
-    assert get_regional_task_instance_run_id(ti) == (str(ti.id) if in_loop 
else None)
+    assert get_task_instance_position(ti) == TaskInstancePosition(
+        in_loop=True, map_index=-1, loop_group_id="body"
+    )
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
+def 
test_runtime_task_instance_position_reads_its_loop_and_expansion_from_the_task_instance():
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with DAG("position_dag") as dag:
+
+        @task_group
+        def body():
+            PythonOperator.partial(task_id="work", 
python_callable=list).expand(op_kwargs=[{}, {}])
+
+        create_loop(body, max_iterations=3)
+    loop_region_id, forked_from_region_id = uuid4(), uuid4()
+    ti = SimpleNamespace(
+        id=uuid4(),
+        region_id=uuid4(),
+        map_index=1,
+        task=dag.get_task("body.work"),
+        expanded_ti_count=2,
+        loop=SimpleNamespace(
+            index=2,
+            max_iterations=3,
+            region_id=loop_region_id,
+            trajectory=2,
+            forked_from_region_id=forked_from_region_id,
+            resumes_from_index=1,
+        ),
+    )
+
+    assert get_task_instance_position(ti) == TaskInstancePosition(
+        in_loop=True,
+        map_index=1,
+        expanded_ti_count=2,
+        loop_index=2,
+        loop_group_id="body",
+        loop_region_id=loop_region_id,
+        loop_trajectory=2,
+        loop_forked_from_region_id=forked_from_region_id,
+        loop_resumes_from_index=1,
+    )
+
+
[email protected]_test
[email protected](not AIRFLOW_V_3_4_PLUS, reason="Loop iteration requires 
Airflow 3.4")
[email protected]("holds_another_version", [False, True])
+def test_stored_task_instance_is_placed_as_airflow_places_it_for_the_worker(
+    dag_maker, session, holds_another_version
+):
+    from airflow.models.dynamic_region import DynamicRegion
+    from airflow.sdk import task_group
+    from airflow.sdk.definitions._internal.loop import create_loop
+
+    with dag_maker(serialized=True):
+
+        @task_group
+        def body():
+            EmptyOperator(task_id="plain") >> PythonOperator.partial(
+                task_id="mapped", python_callable=list
+            ).expand(op_kwargs=[{}, {}])
+
+        create_loop(body, max_iterations=3)
+        PythonOperator.partial(task_id="outside", 
python_callable=list).expand(op_kwargs=[{}, {}, {}])
+    run = dag_maker.create_dagrun()
+    tis = {(ti.task_id, ti.region_index): ti for ti in run.task_instances}
+    # As the scheduler's Dag bag sets it on the Dag it loads for the run, 
which can be a later version than the
+    # one the task instances pin
+    tis["body.plain", 1].task.dag.dag_version_id = (
+        uuid7() if holds_another_version else tis["body.plain", 
1].dag_version_id
+    )
+    loop_region = session.get(DynamicRegion, tis["body.plain", 1].region_id)
+    fork = DynamicRegion.fork_family(loop_region.id, 2, {loop_region.id: 
loop_region}, session=session)
+    session.flush()
+    rerun = TaskInstance(
+        task=tis["body.plain", 1].task, run_id=run.run_id, 
dag_version_id=tis["body.plain", 1].dag_version_id
+    )
+    rerun.region_id, rerun.region_index = fork.id, 2
+    session.add(rerun)
+    session.flush()
+    in_loop = {"in_loop": True, "loop_trajectory": 1, "loop_group_id": "body"}
+
+    # Placing reuses the Dag the task instance holds: its region rows, then 
the count of a mapped expansion
+    with assert_queries_count(4):
+        plain = get_stored_task_instance_position(tis["body.plain", 1], 
session=session)
+    with assert_queries_count(5):
+        mapped = get_stored_task_instance_position(tis["body.mapped", 1], 
session=session)
+    outside = get_stored_task_instance_position(tis["outside", 2], 
session=session)
+    rerun_position = get_stored_task_instance_position(rerun, session=session)
+
+    assert plain == TaskInstancePosition(map_index=-1, loop_index=1, 
loop_region_id=loop_region.id, **in_loop)
+    assert mapped == TaskInstancePosition(
+        map_index=1, expanded_ti_count=2, loop_index=1, 
loop_region_id=loop_region.id, **in_loop
+    )
+    assert outside == TaskInstancePosition(in_loop=False, map_index=2, 
expanded_ti_count=3)
+    assert rerun_position == TaskInstancePosition(
+        in_loop=True,
+        map_index=-1,
+        loop_index=2,
+        loop_group_id="body",
+        loop_region_id=fork.id,
+        loop_trajectory=2,
+        loop_forked_from_region_id=loop_region.id,
+        loop_resumes_from_index=2,
+    )
+    assert dict(TaskInstanceInfo(rerun, position=rerun_position))["loop"] == {
+        "index": 2,
+        "region_id": str(fork.id),
+        "trajectory": 2,
+        "forked_from_region_id": str(loop_region.id),
+        "resumes_from_index": 2,
+    }
 
 
 @pytest.mark.db_test
[email protected](not AIRFLOW_V_3_0_PLUS, reason="Dag versions require 
Airflow 3")
[email protected]("load_fails", [False, True])
+@patch("airflow.providers.openlineage.utils.utils.log", autospec=True)
+def 
test_stored_task_instance_info_reads_its_dag_version_without_risking_the_event(

Review Comment:
   This test doesn't bind the savepoint. The injected failure is a Python 
`RuntimeError` from a `PropertyMock`, never a database error, so "the caller's 
transaction is still usable" holds with or without `begin_nested()`. Removing 
it from `get_stored_dag_version` leaves test_utils.py green, and so does 
removing it from `_get_core_task_instance_position` (the resolver-failure test 
uses `MagicMock(spec=Session)` and a mocked resolver). The savepoint matters 
when a statement fails on PostgreSQL and poisons the scheduler's 
`process_executor_events` transaction, so it needs a real failing statement (a 
`@pytest.mark.backend("postgres")` db test), or at least an assertion that 
`begin_nested` was entered for a real session.
   
   Related gap: nothing runs a scheduler-style FAIL through 
`OpenLineageListener` with a real session. 
`test_manual_event_passes_its_stored_position_to_the_airflow_run_facet` uses a 
session-less TI, so changing `session=object_session(ti)` to `session=None` at 
`listener.py:892` leaves 383 tests green, and "scheduler FAIL carries the same 
fields as the worker" is pinned only at the `utils` level. One db test through 
`on_task_instance_failed` with a real loop TI, asserting 
`taskInstance.loop.trajectory`, `expanded_ti_count` and `runId == 
taskInstance.id`, would cover it.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1125,21 +1125,105 @@ def team_name(cls, dagrun: DagRun) -> str | None:
 class TaskInstanceInfo(InfoJsonEncodable):
     """Defines encoding TaskInstance object to JSON."""
 
-    includes = ["duration", "log_url", "pool", "queued_dttm", "try_number"]
+    includes = ["duration", "log_url", "max_tries", "pool", "queued_dttm", 
"state", "try_number"]
     casts = {
         "log_url": lambda ti: getattr(ti, "log_url", None),
         "note": lambda ti: safe_getattr(ti, "note", None),  # From manual 
state changes only

Review Comment:
   **Loop (stored vs public index).** `log_url` is read from the stored task 
instance, and `TaskInstance.log_url` builds its `/mapped/<n>` segment from 
`self.region_index`, which for a non-mapped loop body is the *iteration*. On a 
live 3.4.0 stack, a scheduler-sent FAIL (heartbeat kill) of `body.plain` at 
iteration 1 had `.../tasks/body.plain/mapped/1?try_number=1`, where the 
worker's START has `.../tasks/body.plain?try_number=1`; the first link doesn't 
exist. This is the same synonym trap you already handle for `map_index`, and it 
contradicts "FAIL events sent by the scheduler carry the same fields as worker 
ones".
   
   Build it from the placed `position.map_index` for a stored TI (or drop the 
segment when it is -1). A unit test comparing the stored and runtime `log_url` 
of a loop body would pin it.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1125,21 +1125,105 @@ def team_name(cls, dagrun: DagRun) -> str | None:
 class TaskInstanceInfo(InfoJsonEncodable):
     """Defines encoding TaskInstance object to JSON."""
 
-    includes = ["duration", "log_url", "pool", "queued_dttm", "try_number"]
+    includes = ["duration", "log_url", "max_tries", "pool", "queued_dttm", 
"state", "try_number"]
     casts = {
         "log_url": lambda ti: getattr(ti, "log_url", None),
         "note": lambda ti: safe_getattr(ti, "note", None),  # From manual 
state changes only
-        "dag_bundle_version": lambda ti: (
-            ti.bundle_instance.version if hasattr(ti, "bundle_instance") else 
None
+        "dag_version_id": lambda ti: (
+            str(dag_version_id) if (dag_version_id := getattr(ti, 
"dag_version_id", None)) else None
         ),
-        "dag_bundle_name": lambda ti: ti.bundle_instance.name if hasattr(ti, 
"bundle_instance") else None,
+        "dag_bundle_version": lambda ti: TaskInstanceInfo.get_bundle_info(ti, 
"version"),
+        "dag_bundle_name": lambda ti: TaskInstanceInfo.get_bundle_info(ti, 
"name"),
     }
 
+    @staticmethod
+    def get_rendered_map_index(ti: TaskInstance | RuntimeTaskInstance) -> str 
| None:
+        """Return the label a mapped task rendered with 
``map_index_template``, as the worker sends it."""
+        if AIRFLOW_V_3_0_PLUS and isinstance(ti, TaskInstance):
+            # Its rendered_map_index falls back to the map index when no label 
was rendered, the worker's doesn't
+            return safe_getattr(ti, "_rendered_map_index")
+        return getattr(ti, "rendered_map_index", None)
+
+    @staticmethod
+    def get_bundle_info(ti: TaskInstance | RuntimeTaskInstance, key: str) -> 
str | None:
+        """Return the name or version of the Dag bundle the attempt runs 
from."""
+        if (bundle := getattr(ti, "bundle_instance", None)) is not None:  # A 
runtime task instance
+            return getattr(bundle, key, None)
+        if not isinstance(ti, TaskInstance):
+            return None
+        # Only for a FAIL the scheduler sends itself, e.g. for a missed 
heartbeat: UI and API state changes build no
+        # airflow facet, and the worker's runtime task instance has its bundle 
above
+        return getattr(TaskInstanceInfo.get_stored_dag_version(ti), 
f"bundle_{key}", None)
+
+    @staticmethod
+    def get_stored_dag_version(ti: TaskInstance) -> Any:
+        """Return the Dag version of a stored task instance, or None when it 
can't be read, as on Airflow 2."""
+        from sqlalchemy import inspect

Review Comment:
   Imports added inside function bodies with no stated reason: `from sqlalchemy 
import inspect` (here), `from sqlalchemy.orm import aliased` (line 1818) and 
`from airflow.sdk.definitions.taskgroup import MappedTaskGroup` with its 
`except ImportError` fallback (line 2420). sqlalchemy is already imported at 
module level (`from sqlalchemy.orm.exc import DetachedInstanceError`), and 
`SerializedMappedOperator` (lines 83-88) shows the module's top-level 
try/except pattern for a compat import. `TaskCoordinateResolver` (line 1735) is 
genuinely 3.4+-only, but needs a one-line comment saying so.



##########
providers/openlineage/tests/system/openlineage/example_openlineage_task_loop_dag.py:
##########
@@ -0,0 +1,164 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+DAG with a plain task and task loops, some of their tasks retrying and one 
stopped early by `until`
+(requires Airflow 3.4+).
+
+It checks, for a plain task, tasks, a mapped task and a mapped task group in a 
loop, and loop gates:
+    - taskInstance's `try_number`, `state`, `map_index` and `loop` of every 
event: `loop.index` is the
+      iteration, counted from 1, and `map_index` the mapped position, and a 
plain task has no `loop`
+    - task's `loop`, the loop around the task: its `group_id`, 
`max_iterations`, `has_until`, `gate_task_id`
+      and `terminal_task_id`, also for a task of a task group inside a loop, 
and absent for the plain task
+    - `loop.region_id`, the loop region of the iteration, shared by the tasks 
of one iteration, mapped ones
+      included, `loop.trajectory` 1, and no `loop.forked_from_region_id` nor 
`loop.resumes_from_index`, as
+      no loop is cleared

Review Comment:
   **Loop fields the e2e doesn't actually bind.** The docstring says 
`loop.region_id` is the loop region "of the iteration, shared by the tasks of 
one iteration, mapped ones included". Nothing asserts that, and it isn't 
accurate: `loop.region_id` is the loop's physical region, shared by *all* 
iterations until a clear forks it (same value for iterations 1-3 in a captured 
run). It is a mapped task's `taskInstance.region_id` (its expansion region) 
that differs from it.
   
   In both loop JSONs `region_id`, `loop.region_id` and `taskInstance.id` are 
only `{{ is_uuid(result) }}`, and `match()` can't compare two fields, so wrong 
values pass. On a live 3.4.0 scheduler, replacing 
`loop_region_id=loop.region_id` with the task's own `region_id` (in 
`_add_runtime_loop_iteration`) still ended `success`. So did a constant 
`loop.region_id`, a constant `taskInstance.id`, and forcing `trajectory` to 1 
or `forked_from_region_id`/`resumes_from_index` to `None` (those are only 
asserted absent).
   
   "no loop is cleared" also means `trajectory > 1`, the fork fields, and the 
`region_id` term this PR adds to `Attempt` (it only matters across a clear) are 
exercised by no system test, only by unit tests with hand-built objects. 
Clearing the gate through the API on the same stack produced trajectory 2 with 
the fork fields set and `check_run_id_integrity` reported no problems, so a Dag 
step that clears a gate after the first pass is feasible.
   
   Suggest loop invariants in `check_run_id_integrity`: for a TI with `loop` 
and no `map_index`, `loop.region_id == region_id`; mapped in a loop, 
`loop.region_id != region_id`; `loop.region_id` constant per (loop group, 
trajectory); `taskInstance.id == runId` when `loop` is set. Plus one e2e Dag 
that clears a gate. Otherwise trim the claims from the docstring. The job-facet 
bullet overclaims too: only 4 of the 10 tasks assert 
`task_group`/`loop_group`/`is_mapped`/`parse_time_mapped_count`.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1542,56 +1682,228 @@ def _require_task_instance_position(
     return position
 
 
+def _add_runtime_loop_iteration(
+    task_instance: TaskInstance | RuntimeTaskInstance, position: 
TaskInstancePosition
+) -> TaskInstancePosition:
+    if isinstance(task_instance, TaskInstance):
+        return position
+    # Only runtime task instances of Airflow 3.4+ expose these
+    position = attrs.evolve(position, expanded_ti_count=getattr(task_instance, 
"expanded_ti_count", None))
+    if not position.in_loop or position.loop_index is not None:
+        return position
+    if (loop := getattr(task_instance, "loop", None)) is None:
+        return position
+    return attrs.evolve(
+        position,
+        loop_index=loop.index,
+        loop_region_id=loop.region_id,
+        loop_trajectory=loop.trajectory,
+        loop_forked_from_region_id=loop.forked_from_region_id,
+        loop_resumes_from_index=loop.resumes_from_index,
+    )
+
+
+def get_task_instance_position(task_instance: TaskInstance | 
RuntimeTaskInstance) -> TaskInstancePosition:
+    """Place a task instance with its loop iteration, once per event, to pass 
to ``get_airflow_run_facet``."""
+    return _add_runtime_loop_iteration(task_instance, 
_require_task_instance_position(task_instance))
+
+
[email protected]
+class _TaskCoordinate:
+    """Where Airflow stores a task instance, as its ``TaskCoordinateResolver`` 
reads it."""
+
+    dag_id: str
+    run_id: str
+    task_id: str
+    region_id: UUID
+    region_index: int
+    dag_version_id: UUID | None
+
+
+def _get_core_task_instance_position(
+    task_instance: TaskInstance, *, session: Session | None
+) -> TaskInstancePosition | None:
+    """Place a stored task instance in a region as Airflow places it for the 
worker, from Airflow 3.4 on."""
+    if session is None or not AIRFLOW_V_3_4_PLUS:
+        return None
+    region_id = task_instance.region_id
+    if not isinstance(region_id, UUID) or region_id.int == 0:
+        return None
+    dag = getattr(getattr(task_instance, "task", None), "dag", None)
+    if (dag_version_id := getattr(dag, "dag_version_id", None)) is None:
+        return None
+    from airflow.models.task_coordinates import TaskCoordinateResolver
+
+    # Placed with the Dag version the task instance holds, which its task 
facet describes too, even when it pins
+    # another one: the resolver would load and deserialize that version for 
every task instance in the scheduler
+    # or API server process, while the region rows it reads don't depend on 
the version.
+    coordinate = _TaskCoordinate(
+        dag_id=task_instance.dag_id,
+        run_id=task_instance.run_id,
+        task_id=task_instance.task_id,
+        region_id=region_id,
+        region_index=task_instance.region_index,
+        dag_version_id=dag_version_id,
+    )
+    try:
+        # The savepoint keeps the caller's transaction usable after a failure
+        with session.begin_nested():
+            resolver = TaskCoordinateResolver.for_dag(dag, session)
+            task = resolver.get_task(
+                coordinate.dag_id, coordinate.run_id, coordinate.task_id, 
dag_version_id=dag_version_id
+            )
+            loop = resolver.get_loop_context(coordinate, task=task)
+            expanded_ti_count = resolver.get_expanded_ti_count(coordinate, 
task=task)
+    except Exception:
+        log.warning("OpenLineage failed to place task instance %s with 
Airflow", task_instance.id)
+        log.debug("Exception details:", exc_info=True)
+        return None
+    map_index = task_instance.region_index if task.get_needs_expansion() else 
-1
+    if loop is None:
+        return TaskInstancePosition(in_loop=False, map_index=map_index, 
expanded_ti_count=expanded_ti_count)
+    return TaskInstancePosition(
+        in_loop=True,
+        map_index=map_index,
+        expanded_ti_count=expanded_ti_count,
+        loop_index=loop.index,
+        loop_group_id=loop.node_id,
+        loop_region_id=loop.region_id,
+        loop_trajectory=loop.trajectory,
+        loop_forked_from_region_id=loop.forked_from_region_id,
+        loop_resumes_from_index=loop.resumes_from_index,
+    )
+
+
+def get_stored_task_instance_position(
+    task_instance: TaskInstance, *, session: Session | None
+) -> TaskInstancePosition:
+    """
+    Place a stored task instance for an event about it, as Airflow places it 
for the worker when it can.
+
+    That needs Airflow 3.4+ and a session. Otherwise, or when Airflow fails 
to, it is placed with
+    ``get_orm_task_instance_positions``, which only knows what the task and 
region rows tell.
+    """
+    if (position := _get_core_task_instance_position(task_instance, 
session=session)) is not None:
+        return position
+    return get_orm_task_instance_positions([task_instance], 
session=session)[task_instance.id]
+
+
 def get_orm_task_instance_positions(
     task_instances: Collection[TaskInstance], *, session: Session | None
 ) -> dict[UUID, TaskInstancePosition]:
     """
-    Place stored task instances, reading the regions of those without their 
task in one query.
+    Place stored task instances, reading in one query the regions of those 
without their task and of mapped tasks in a loop.
 
-    Server side only: workers never hold a session, and an instance that needs 
its region read without one
-    raises instead of opening a connection.
+    Server side only: workers never hold a session. An instance without its 
task raises when there is no session to
+    read its region, while a mapped task in a loop is then placed without its 
iteration.
     """
     positions: dict[UUID, TaskInstancePosition] = {}
     unplaced: list[TaskInstance] = []
+    without_iteration: list[TaskInstance] = []
     for task_instance in task_instances:
         if (position := _get_task_instance_position(task_instance)) is None:
             unplaced.append(task_instance)
         else:
             positions[task_instance.id] = position
-    if not unplaced:
+            # A stored mapped task in a loop keeps its iteration on its region 
only. Without a session it stays unknown.
+            if position.in_loop and position.loop_index is None and session is 
not None:
+                without_iteration.append(task_instance)
+    if not unplaced and not without_iteration:
         return positions
     if session is None:
+        # Only unplaced instances get here without a session, as no mapped 
task is added to without_iteration then
         raise ValueError("Placing a stored task instance without its task 
needs the session it was loaded in")
 
     from sqlalchemy import select
+    from sqlalchemy.orm import aliased
 
     from airflow.models.dynamic_region import DynamicRegion
 
-    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})
+    parent = aliased(DynamicRegion)
+    query = (
+        select(
+            DynamicRegion.id,
+            DynamicRegion.node_id,
+            DynamicRegion.parent_region_id,
+            DynamicRegion.parent_region_index,
+            parent.node_id.label("parent_node_id"),
+            parent.parent_region_id.label("grandparent_region_id"),

Review Comment:
   **Design question, non-blocking.** There are now three implementations of 
"where does this task instance sit": `_get_task_instance_position` (task 
definition + stored index), `_get_core_task_instance_position` (Airflow's 
`TaskCoordinateResolver`) and this region-row parser with the self-join and the 
`without_iteration` second pass. They duplicate parts of the resolver, 
`loop_position` and `DynamicRegion.find_physical_coordinate`. I compared them 
on linear, forked, mapped-in-loop, mapped-group-member and placeholder rows and 
they agree on `in_loop`, `map_index`, `loop_index` and `loop_group_id`. But the 
fallback never has `loop.region_id`, `trajectory`, `forked_from_region_id`, 
`resumes_from_index` or `expanded_ti_count`, so when the resolver fails, the 
scheduler FAIL of a forked-region task instance carries less than its START, 
and the schema doesn't say those fields can be absent.
   
   `loop_index` from region rows only reaches an event when the resolver 
raised: the API-server path has no `ti.task`, so it builds no `airflow` facet, 
and the asset-event path only needs `in_loop` and `map_index`. The `elif 
region.grandparent_region_id is None` branch and the "unexpected shape" warning 
are bound only by hand-built rows (flipping the branch to `elif True:` fails 
one synthetic-row test and not 
`test_region_shapes_of_airflow_are_the_ones_openlineage_places`), and 
`loop_group_id` is read once in `src` and asserted on ~15 test lines.
   
   Would it be enough for the fallback to be (`in_loop`, `map_index`) as before 
this PR, with the facet schema saying which `loop` fields can be missing on 
scheduler-sent events? That drops the parent join, the second pass and ~4 
tests. Related: should core expose loop/expansion accessors on a stored TI, as 
#75132 did for the runtime TI, so the provider isn't hand-building a 
`_TaskCoordinate` just to override `dag_version_id`?



##########
providers/openlineage/tests/system/openlineage/example_openlineage_retry_dag.py:
##########
@@ -0,0 +1,73 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+DAG with a task that fails on its first try and succeeds on the retry.
+
+It checks:
+    - the first try emits START and FAIL, the retry emits START and COMPLETE
+    - taskInstance's `try_number` is 1 for the first try and 2 for the retry, 
and `max_tries` is 1 for both
+    - task's `retries` and `retry_delay` on every event
+    - taskInstance's `state`: up_for_retry on the FAIL of the first try, 
success on the COMPLETE of the retry
+    - each try has its own `runId` (the `runId` check of 
OpenLineageTestOperator is enabled by default)
+    - every task event's identity: taskInstance's `id`, `dag_version_id` 
(Airflow 3.1+) and bundle, the absence
+      of fields that don't apply, task's `task_id`, `trigger_rule`, `retries` 
and `retry_delay`, the Dag and Dag run,
+      and the parent run, plus each task in the Dag's job facet
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+from airflow import DAG
+
+try:
+    from airflow.sdk import task
+except ImportError:
+    # Airflow 2 path
+    from airflow.decorators import task  # type: ignore[attr-defined,no-redef]
+
+from system.openlineage.constants import DEFAULT_DAGRUN_TIMEOUT
+from system.openlineage.expected_events import get_expected_event_file_path
+from system.openlineage.operator import OpenLineageTestOperator
+
+DAG_ID = "openlineage_retry_dag"
+
+with DAG(
+    dagrun_timeout=DEFAULT_DAGRUN_TIMEOUT,
+    dag_id=DAG_ID,
+    start_date=datetime(2021, 1, 1),
+    schedule=None,
+    catchup=False,
+    default_args={"retries": 1, "retry_delay": timedelta(seconds=1)},

Review Comment:
   `default_args` also gives `check_events` `retries=1`. On a template mismatch 
the operator's `finally` deletes the Variables, the retry then runs and fails 
with "variable does not exist (no events emitted)", and the e2e log shows only 
the latest attempt, so the real mismatch from attempt 1 is hidden (a lane 
observed `check_events` start twice in a run where nothing was emitted). Put 
`retries=1, retry_delay=...` on `flaky_task` and leave the Dag default at 0; 
the expected JSON only lists `flaky_task` events, so it doesn't change.



##########
providers/openlineage/src/airflow/providers/openlineage/facets/AirflowRunFacet.json:
##########
@@ -196,6 +225,73 @@
         "map_index": {
           "type": "integer"
         },
+        "id": {
+          "description": "Id of the task instance attempt (Airflow 3+).",
+          "type": "string"
+        },
+        "state": {
+          "type": "string"
+        },
+        "max_tries": {
+          "type": "integer"
+        },
+        "rendered_map_index": {
+          "type": "string"
+        },
+        "expanded_ti_count": {
+          "description": "Number of instances in the mapped task's expansion 
(Airflow 3.4+).",
+          "type": "integer"
+        },
+        "region_id": {
+          "description": "Region of the loop iteration or mapped expansion 
(Airflow 3.4+).",
+          "type": "string"
+        },
+        "region_index": {
+          "description": "Position in the region (Airflow 3.4+).",
+          "type": "integer"
+        },

Review Comment:
   **Docs / design question, non-blocking.** The facet schema is now the only 
description of these fields (the guide was dropped from this PR), and several 
mean different things depending on the task:
   - `region_index` is the 1-based loop iteration for a non-mapped loop body 
but the 0-based map slot for a mapped task (probe: `body.plain` 1,2; 
`body.mapped` 0,1). It is described only as "Position in the region".
   - `region_id` is the loop's region for a loop body but the mapped task's 
*expansion* region for a mapped task, so a mapped task in a loop carries two 
different ids (`region_id` and `loop.region_id`). After a clear, 
`loop.region_id` is the fork's region, not the family root.
   - `loop.index` (1-based), `loop.trajectory` (1 = original, n+1 = nth fork) 
and `loop.resumes_from_index` have no description. `expanded_ti_count` counts 
the live (non-REMOVED) instances of that expansion, so it is per loop pass, and 
is 1 for a not-yet-expanded placeholder.
   
   Is exposing the physical storage coordinate (`region_id`/`region_index`) 
intended? `region_index` always duplicates `loop.index` or `map_index`, and 
`loop.index` + `map_index` + `loop.region_id` already carry the same 
information. If you keep them, descriptions like the above would save consumers 
from reverse-engineering the storage model.



##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -1125,21 +1125,105 @@ def team_name(cls, dagrun: DagRun) -> str | None:
 class TaskInstanceInfo(InfoJsonEncodable):
     """Defines encoding TaskInstance object to JSON."""
 
-    includes = ["duration", "log_url", "pool", "queued_dttm", "try_number"]
+    includes = ["duration", "log_url", "max_tries", "pool", "queued_dttm", 
"state", "try_number"]
     casts = {
         "log_url": lambda ti: getattr(ti, "log_url", None),
         "note": lambda ti: safe_getattr(ti, "note", None),  # From manual 
state changes only
-        "dag_bundle_version": lambda ti: (
-            ti.bundle_instance.version if hasattr(ti, "bundle_instance") else 
None
+        "dag_version_id": lambda ti: (
+            str(dag_version_id) if (dag_version_id := getattr(ti, 
"dag_version_id", None)) else None
         ),
-        "dag_bundle_name": lambda ti: ti.bundle_instance.name if hasattr(ti, 
"bundle_instance") else None,
+        "dag_bundle_version": lambda ti: TaskInstanceInfo.get_bundle_info(ti, 
"version"),
+        "dag_bundle_name": lambda ti: TaskInstanceInfo.get_bundle_info(ti, 
"name"),

Review Comment:
   `dag_bundle_version` and `dag_bundle_name` each call `get_bundle_info`, 
which calls `get_stored_dag_version`. For a stored TI the Dag version is 
therefore read twice per event, and when the read fails each call opens its own 
savepoint and logs the same warning ("OpenLineage failed to read the Dag 
version of task instance ...") twice (probe with `begin_nested` forced to 
raise: 2 calls, 2 warnings). Read it once per `TaskInstanceInfo`, e.g. set both 
bundle fields in `_extend_fields`.



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