mobuchowski commented on code in PR #74752:
URL: https://github.com/apache/airflow/pull/74752#discussion_r4350955107
##########
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():
Review Comment:
When the scheduler fails a task instance itself (e.g. its worker died),
`TaskInstance.handle_failure` calls `notify_failure` →
`on_task_instance_failed` **in the middle of its unit of work**: it has just
`session.add`-ed a `Log` row and set `ti.state` / `ti.end_date`, and nothing is
flushed yet. This listener then reads the Dag version, loop context and regions
on that same session (`object_session(ti)`), each inside
`session.begin_nested()`.
`begin_nested()` always flushes the session before it emits `SAVEPOINT`,
whether or not autoflush is on (in SQLAlchemy 2.0 this is
`SessionTransaction._take_snapshot` → `session.flush()`). Airflow's sessions
use `autoflush=False`, so before this PR the provider's reads never wrote
anything. Now building the facet forces the scheduler's half-finished changes
to the DB, before `prepare_db_for_next_try` runs.
If that flush fails (a lock timeout, an integrity error on the pending
`Log`, …), the error is raised by `begin_nested()` itself, before the savepoint
exists. The `except Exception` here logs it as "OpenLineage failed to read the
Dag version" (the real error is only at debug level), and the session stays in
the "rolled back due to a previous exception during flush" state. The
scheduler's next statement then fails with `PendingRollbackError`, and the
cause is hidden in an OpenLineage debug log. So "the savepoint keeps the
caller's transaction usable" holds only for errors in the read itself, not for
the flush it triggers.
The same pattern is in `_get_core_task_instance_position` and
`get_orm_task_instance_positions`.
Could these reads use their own short session (`with create_session() as
s:`, as `_place_asset_event_sources` already does)? Everything they read
(`dag_version`, `dynamic_region`, the TI count) is already committed, so they
don't need to see the scheduler's uncommitted changes. That also removes the
need for the savepoints.
##########
providers/openlineage/src/airflow/providers/openlineage/utils/utils.py:
##########
@@ -2042,17 +2391,49 @@ def _get_task_groups_details(dag: DAG | SerializedDAG,
edge_map: dict[str, tuple
result = {}
for tg_id, tg in dag.task_group_dict.items():
task_edges, group_edges = edge_map.get(tg_id, ({}, {}))
+ is_mapped = _is_mapped_task_group(tg)
result[tg_id] = {
"parent_group": tg.parent_group.group_id if tg.parent_group else
None,
+ # Independent, so a group could be both
+ "is_mapped": is_mapped,
+ "is_loop": hasattr(tg, "gate_task_id"),
Review Comment:
(optional) Nothing triggers this today, because the job facet is built from
the serialized Dag, where every `SerializedLoopTaskGroup` has `gate_task_id`.
But everywhere else in the PR a loop is recognised by `isinstance(…,
(LoopTaskGroup, SerializedLoopTaskGroup))` (`_find_enclosing_loop`). Using the
same test here keeps `taskGroups[...].is_loop` consistent with
`tasks[...].loop_group`, and doesn't treat any group that happens to have a
`gate_task_id` attribute as a loop. For example:
```python
def _is_loop_task_group(task_group: Any) -> bool:
from airflow.sdk.definitions._internal.loop import LoopTaskGroup
from airflow.serialization.definitions.taskgroup import
SerializedLoopTaskGroup
return isinstance(task_group, (LoopTaskGroup, SerializedLoopTaskGroup))
```
(guarded by `AIRFLOW_V_3_4_PLUS`, like `get_task_loop_info`).
##########
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)
Review Comment:
This function now joins each region to its parent, and assumes a loop region
is top-level and a mapped region sits directly under it, in order to fill
`loop_index` / `loop_group_id`. That is a second, hand-written version of what
core's `DynamicRegion.find_physical_coordinate` /
`TaskCoordinateResolver.get_loop_context` do.
As far as I can trace, those new fields only matter on one path:
- `_place_asset_event_sources` passes only TIs with no task attached
(`unplaced`). It then reads just `position.in_loop` and `position.map_index` to
build a `runId`, and those come out the same as before this PR for every region
shape.
- `get_stored_task_instance_position` gets here when
`_get_core_task_instance_position` returned `None`. With a session on 3.4+, for
a task in a loop, that only happens when the resolver **raised**. (No region
means the task isn't in a loop, and `dag_version_id` is set on a serialized
Dag.)
So the `without_iteration` list, the parent/grandparent join and the
"unexpected shape" branches only fill in loop facet fields after core's own
placement has already failed, using a copy of core's rules that can fall out of
sync if region layout changes (forks, nesting). Would you consider dropping
that part and letting the fallback report `in_loop` / `map_index` only, as it
did before? If you want to keep the fallback complete, it could call
`load_region_ancestry` + `DynamicRegion.find_physical_coordinate` instead of
reimplementing them.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]