Skip to content

Improve task loop information and tests for OpenLineage provider - #74752

Open
kacpermuda wants to merge 6 commits into
apache:mainfrom
kacpermuda:ol-loop-iteration-facet-and-run-id-check
Open

kacpermuda wants to merge 6 commits into
apache:mainfrom
kacpermuda:ol-loop-iteration-facet-and-run-id-check

Conversation

@kacpermuda

Copy link
Copy Markdown
Collaborator

Task loops are new, and OpenLineage events need to describe them from day one. Inside a loop the iteration of a
task was invisible: taskInstance.map_index is only set for mapped tasks and the runId is the attempt UUID, so
iterations of one task looked identical, and nothing verified that runIds stay consistent across START and
COMPLETE, retries, map indexes and iterations.

  • taskInstance facet gets loop_index and loop_max_iterations. Loop groups expose max_iterations,
    has_until, gate_task_id and terminal_task_id in task_group and in AirflowJobFacet.taskGroups.
  • The manual state change path passes its computed position to the facet, and TaskInstanceInfo no longer
    raises for a stored task instance it can't place.
  • OpenLineageTestOperator checks runId integrity by default: one job and attempt per runId, a complete
    START and COMPLETE/FAIL lifecycle, and one runId per attempt.
  • New example Dags for retries, a rescheduled sensor, and loops with a plain task, a retried loop task and a
    retried mapped loop task (Airflow 3.4+).

Was generative AI tooling used to co-author this PR?
  • Yes (Claude Code)

Generated-by: Claude Code (Sonnet 5.5) following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

Comment on lines +199 to +206
"loop_index": {
"description": "Zero-based iteration of the loop the task runs in. Absent outside a loop.",
"type": "integer"
},
"loop_max_iterations": {
"description": "Maximum number of iterations of the loop the task runs in. Absent outside a loop.",
"type": "integer"
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just having loop_index will mean this needs to change if/when nested loops are allowed in Airflow.

It also doesn't support mapped tasks inside a loop, which is supported today. This is why Airflow has a "dynamic_region" concept.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, extended this with region_id. Also looking into exposing a bit more data from core/sdk so that OL can show it.

@kacpermuda
kacpermuda force-pushed the ol-loop-iteration-facet-and-run-id-check branch from fb42765 to 69b1b1e Compare October 16, 2026 14:33
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

uv.lock on main just moved via #74290 ("Add AzureAISearchHook to the Microsoft Azure provider"), commit 935bac0 and this PR currently conflicts.

Quickest fix:

git fetch upstream main && git rebase upstream/main
rm uv.lock && uv lock
git add uv.lock && git rebase --continue
git push --force-with-lease

Automated nudge — ignore if you're not ready to rebase. This comment is updated in place on future uv.lock bumps.

@kacpermuda
kacpermuda force-pushed the ol-loop-iteration-facet-and-run-id-check branch from 6e892fd to 54821f3 Compare October 19, 2026 11:40
return group
group = group.parent_group
return group is not None
return None

@mobuchowski mobuchowski Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrecognized group types now count as "not in a loop" instead of "in a loop"

This function walks up the task's parent groups looking for a loop group. The walk stops as soon as it meets something that isn't a TaskGroup / SerializedTaskGroup. The old _is_task_in_loop then returned group is not None: if the walk stopped on some unexpected non-None object, it assumed the task was in a loop. The new code returns None in that case, so it now assumes the task is not in a loop.

Why the direction of this fallback matters:

  • When a task is in a loop, the listener uses the task instance's UUID (ti.id) as the OpenLineage runId. Each iteration gets a new UUID, so iterations stay separate.
  • When it's not in a loop, the runId is built from (dag_id, task_id, try_number, logical_date, map_index). Call that the "computed runId": the same inputs always give the same runId.

Every iteration of a looped task has the same values for all five inputs. If loop detection wrongly says "not in a loop", iteration 0 and iteration 1 emit START/COMPLETE under one runId, and consumers merge their lineage into a single run.

I'm not sure which group types can reach this point in practice (maybe a group from a different SDK version, a proxy, or a subclass that doesn't inherit from these two classes). But if one does, the old fallback gave a harmless extra UUID while the new one silently merges runs. Keeping return group is not None, or at least logging when the walk stops on an unexpected type, seems safer.

@kacpermuda kacpermuda Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 7441d2d: _find_enclosing_loop assumes a loop again when the walk stops on a group of an unknown type, and logs a warning, so the worst case is an extra runId rather than merged iterations.

DynamicRegion.parent_region_index,
).where(
DynamicRegion.id.in_(
{task_instance.region_id for task_instance in (*unplaced, *without_iteration)}

@mobuchowski mobuchowski Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new database query in the manual state change path can make the whole event disappear

Before this PR, a stored task instance whose task object was attached got its position computed purely in Python, with no query. This line adds a second group, without_iteration: mapped tasks inside a loop. Their iteration number only lives on the DynamicRegion row (the record Airflow keeps for each loop iteration or mapped expansion), so this now queries the database for them.

That code runs from _on_task_instance_manual_state_change, the handler for when the scheduler fails a task (e.g. handle_failure after a heartbeat timeout) or a user marks a task failed or successful in the UI. So this query runs inside the scheduler/API server process.

The scenario I'm worried about:

  1. A mapped task inside a loop gets marked failed by the scheduler.
  2. get_orm_task_instance_positions now runs the DynamicRegion query for it.
  3. The query raises (connection hiccup, the TI's session already detached or closed, etc.).
  4. The broad try/except in _on_task_instance_manual_state_change catches the error and returns, so no FAIL event is emitted for that task at all.

All of this is only to fill loop_index, which is descriptive: the runId doesn't depend on it. _add_runtime_loop_iteration states this exact rule in its comment ("failing to read it must not cost the event its run id"). Could the without_iteration lookup get its own try/except, so a failure only leaves loop_index unset?

@kacpermuda kacpermuda Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7441d2d: when the query only fills in mapped tasks' iterations, it runs in a savepoint, and a failure only leaves loop_index unset and logs a warning. The event still goes out and the caller's transaction stays usable. Task instances without their task still raise as before this PR.

in_loop=True,
map_index=-1,
loop_index=task_instance.region_index if region is not None else None,
loop_group_id=region.node_id if region is not None else None,

@mobuchowski mobuchowski Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tasks in a mapped task group (with no loop at all) get reported as loop iterations

This else branch handles a stored task instance without its task object attached. That happens for the asset-event source and for the manual state change path. Here the code reads the task's DynamicRegion row and assumes that if the row's node_id isn't the task itself, the row must belong to an enclosing loop.

But DynamicRegion rows are also created for mapped task groups (@task_group + .expand()), not just for loops. Example:

@task_group
def g(x):
    t(x)

g.expand(x=[1, 2, 3])  # no loop anywhere

For task g.t, map index 1:

  1. Its region row has node_id = "g", which isn't "g.t", so we land in this else.
  2. We set in_loop=True, map_index=-1, loop_index=1, loop_group_id="g".

So the event says "iteration 1 of loop g" for a task that isn't in a loop, drops its real map_index (1), and uses the UUID runId. The same task's START/COMPLETE from the worker uses the computed runId (built from dag/task/try/date/map_index), so the manual-state FAIL and the worker's START end up with different runIds.

Checking the region's kind (loop vs. mapped group) before treating it as a loop would avoid this, if DynamicRegion records that.

@kacpermuda kacpermuda Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked this against core: it never creates a region for a mapped task group. Every task that needs expansion, a task in a mapped task group included, gets its own region with node_id == task_id (core's log addressing classifies regions with the same rule), so g.t takes the first branch with its map index. 7441d2d makes the parsing check the shapes it expects (a loop's region is top-level, a mapped task's region sits directly in its loop's) and places anything else as a loop without its iteration, with a warning. test_region_shapes_of_airflow_are_the_ones_openlineage_places builds a real Dag run with these shapes, so a change in core fails the provider's tests.

region = regions.get(task_instance.region_id)
if region is not None and region.parent_region_index is not None:
positions[task_instance.id] = attrs.evolve(
positions[task_instance.id], loop_index=region.parent_region_index

@mobuchowski mobuchowski Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parent_region_index is the loop iteration only if the loop is the direct parent

For a mapped task in a loop, this takes region.parent_region_index: the index of the mapped task's region inside its parent region. That equals the loop iteration only when the parent region is the loop itself.

If there's another mapped level in between, for example:

loop L
 └── mapped task group G (.expand)
      └── mapped task t (.expand)

then t's region's parent is G's region, so parent_region_index is G's map index, not L's iteration. In iteration 0 of the loop, t could report loop_index=1 just because it sits in G's second expansion.

I haven't checked whether Airflow allows this nesting. If it doesn't, a short comment stating that assumption would help. If it does, this probably needs to walk up the parent regions until it reaches the loop's region.

@kacpermuda kacpermuda Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Airflow doesn't allow that nesting today: the SDK rejects expanding a task inside a mapped task group, and inside a loop each task of a mapped task group gets its region directly under the loop's. 7441d2d states this, uses parent_region_index only when the parent region is the task's own loop and top-level, and pins the SDK restrictions in test_region_shapes_stay_limited_by_the_task_sdk.

Comment thread providers/openlineage/src/airflow/providers/openlineage/utils/utils.py Outdated
Comment thread providers/openlineage/tests/system/openlineage/operator.py
Comment thread providers/openlineage/src/airflow/providers/openlineage/utils/utils.py Outdated
kacpermuda and others added 6 commits October 20, 2026 15:19
Provider mypy and the docs spellcheck run in CI through breeze rather than
the default prek hooks, so they were not caught locally before the previous push.

Co-Authored-By: Claude <noreply@anthropic.com>
Alerting on why a loop stopped (for example, its until condition still being false at
max_iterations) needs the gate's decision in the event itself, without reading the Airflow
metadata database.

Co-Authored-By: Claude <noreply@anthropic.com>
The run id must not depend on the optional loop iteration, and the lineage macro should
place a task instance the same way the listener does.

Co-Authored-By: Claude <noreply@anthropic.com>
A region of an unexpected shape, or a failed lookup of a mapped task's iteration, must not
merge the runs of loop iterations or cost an event. Tests now pin the region shapes Airflow
creates, so a change to them fails the provider's tests instead of silently changing events.

Co-Authored-By: Claude <noreply@anthropic.com>
The dbt Cloud and Dataproc tests patched how OpenLineage builds a task's run id, so they broke
when that changed. Stubbing the macros they actually use keeps them independent of it.

Co-Authored-By: Claude <noreply@anthropic.com>
@kacpermuda
kacpermuda force-pushed the ol-loop-iteration-facet-and-run-id-check branch from c2d487f to 18e5856 Compare October 20, 2026 13:19
@task(retries=2, retry_delay=timedelta(seconds=1))
def increment(*, loop, ti):
# The second iteration fails on its first try
if loop.index == 1 and ti.try_number < 2:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, Brent is changing this to be 1-based, not 0-based!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In #74773

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's a good call, was confused in the UI. Will adjust the test (here or in the followup PR if there are no other nits in this PR)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants