kaxil commented on code in PR #65314:
URL: https://github.com/apache/airflow/pull/65314#discussion_r3482222478


##########
airflow-core/src/airflow/serialization/definitions/dag.py:
##########
@@ -1137,6 +1155,124 @@ def apply_state_filter(query):
         else:
             tis_full = apply_state_filter(tis_full)
 
+        if include_dependent_dags:
+            # Recursively find external tasks indicated by ExternalTaskMarker
+            import pendulum
+
+            from airflow.providers.standard.sensors.external_task import 
ExternalTaskMarker
+
+            # Build a full-object query for identifying ExternalTaskMarker TIs 
in the current set
+            if as_pk_tuple:
+                all_ti_rows = session.execute(tis_pk).all()
+                condition = TaskInstance.filter_for_tis(
+                    TaskInstanceKey(**cols._mapping) for cols in all_ti_rows
+                )
+                marker_query = select(TaskInstance).where(condition) if 
condition is not None else None
+            else:
+                marker_query = tis_full
+
+            if marker_query is not None:
+                if visited_external_tis is None:
+                    visited_external_tis = set()
+
+                external_marker_tis = session.scalars(
+                    marker_query.where(TaskInstance.operator == 
ExternalTaskMarker.__name__)
+                )
+
+                for ti in external_marker_tis:
+                    ti_key = ti.key.primary
+
+                    if ti_key in visited_external_tis:
+                        continue
+
+                    visited_external_tis.add(ti_key)
+                    task: ExternalTaskMarker = cast(
+                        "ExternalTaskMarker", 
copy.copy(self.get_task(ti.task_id))
+                    )
+
+                    if max_recursion_depth is None:
+                        # Maximum recursion depth is set from the first 
ExternalTaskMarker encountered
+                        max_recursion_depth = task.recursion_depth
+
+                    if recursion_depth + 1 > max_recursion_depth:
+                        raise MaxRecursionDepthError(

Review Comment:
   `MaxRecursionDepthError` here, and `DagNotFound` at line 1249, both escape 
through the dry-run `dag.clear()` in the route, which is outside the handler's 
only try/except (that one catches `AirflowClearRunningTaskException`). Nothing 
at the app level handles `AirflowException`/`DagNotFound` either, so a cyclic 
or too-deep marker chain -- or a missing child DAG -- comes back as a 500 
rather than a 4xx. Probably want to catch these in the route and return 
400/404, plus a test for the cyclic case.



##########
airflow-core/src/airflow/serialization/definitions/dag.py:
##########
@@ -1137,6 +1155,124 @@ def apply_state_filter(query):
         else:
             tis_full = apply_state_filter(tis_full)
 
+        if include_dependent_dags:
+            # Recursively find external tasks indicated by ExternalTaskMarker
+            import pendulum
+
+            from airflow.providers.standard.sensors.external_task import 
ExternalTaskMarker
+
+            # Build a full-object query for identifying ExternalTaskMarker TIs 
in the current set
+            if as_pk_tuple:
+                all_ti_rows = session.execute(tis_pk).all()
+                condition = TaskInstance.filter_for_tis(
+                    TaskInstanceKey(**cols._mapping) for cols in all_ti_rows
+                )
+                marker_query = select(TaskInstance).where(condition) if 
condition is not None else None
+            else:
+                marker_query = tis_full
+
+            if marker_query is not None:
+                if visited_external_tis is None:
+                    visited_external_tis = set()
+
+                external_marker_tis = session.scalars(
+                    marker_query.where(TaskInstance.operator == 
ExternalTaskMarker.__name__)
+                )
+
+                for ti in external_marker_tis:
+                    ti_key = ti.key.primary
+
+                    if ti_key in visited_external_tis:
+                        continue
+
+                    visited_external_tis.add(ti_key)
+                    task: ExternalTaskMarker = cast(
+                        "ExternalTaskMarker", 
copy.copy(self.get_task(ti.task_id))
+                    )
+
+                    if max_recursion_depth is None:
+                        # Maximum recursion depth is set from the first 
ExternalTaskMarker encountered
+                        max_recursion_depth = task.recursion_depth
+
+                    if recursion_depth + 1 > max_recursion_depth:
+                        raise MaxRecursionDepthError(
+                            f"Maximum recursion depth {max_recursion_depth} 
reached for "
+                            f"{ExternalTaskMarker.__name__} {ti.task_id}. "
+                            f"Attempted to clear too many tasks or there may 
be a cyclic dependency."
+                        )
+
+                    # Resolve the logical_date that the ExternalTaskMarker 
points to
+                    dr_logical_date = session.scalar(
+                        select(DagRun.logical_date).where(
+                            DagRun.dag_id == ti.dag_id, DagRun.run_id == 
ti.run_id
+                        )
+                    )
+
+                    if dr_logical_date is None:
+                        continue
+
+                    logical_date_str: str = dr_logical_date.isoformat()
+
+                    # Check whether a non-default template was used by 
comparing the serialized template
+                    # field on the task against the default "{{ 
logical_date.isoformat() }}" pattern
+                    default_template = "{{ logical_date.isoformat() }}"
+
+                    # If it differs, look up the rendered value from 
RenderedTaskInstanceFields
+                    if task.logical_date != default_template:
+                        from airflow.models.renderedtifields import 
RenderedTaskInstanceFields
+
+                        rendered = 
RenderedTaskInstanceFields.get_templated_fields(ti, session=session)

Review Comment:
   The custom-template path has a case that isn't handled: if the marker uses a 
non-default `logical_date` template and the RTIF row has already been pruned 
(`num_dag_runs_to_retain_rendered_fields`), `rendered` comes back empty and 
`logical_date_str` quietly stays as the marker run's own date from line 1214 -- 
so the child lookup targets the wrong DagRun. The test seeds the RTIF row, so 
this case isn't exercised. Either fail loudly or add a test with the row 
missing.



##########
airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py:
##########
@@ -922,6 +922,10 @@ def _collect_relatives(run_id: str, direction: 
Literal["upstream", "downstream"]
             *((t, m) for t, m in mapped_tasks_tuples if t not in 
normal_task_ids),
         ]
 
+    # Follow ExternalTaskMarker connections when explicitly requested via 
include_downstream_dags, or
+    # automatically whenever downstream clearing is selected (restoring 
Airflow 2 behavior)
+    include_dependent_dags = body.include_downstream_dags or downstream

Review Comment:
   This endpoint only authorizes the path `dag_id` -- 
`requires_access_dag(method="PUT", access_entity=TASK_INSTANCE)` at line 834. 
With `include_dependent_dags` set, the recursion in `_get_task_instances` pulls 
TIs out of the linked child DAGs (`task.external_dag_id`) and 
`clear_task_instances` resets them, but nothing re-checks access on those 
children. So someone who can only edit the parent ends up able to clear/re-run 
tasks in child DAGs they can't otherwise touch.
   
   Could we run the cleared child TIs through the caller's editable set 
(`get_authorized_dag_ids(method="PUT", user=...)`), or re-check per distinct 
child `dag_id` before the reset?



##########
airflow-core/src/airflow/serialization/definitions/dag.py:
##########
@@ -1137,6 +1155,124 @@ def apply_state_filter(query):
         else:
             tis_full = apply_state_filter(tis_full)
 
+        if include_dependent_dags:
+            # Recursively find external tasks indicated by ExternalTaskMarker
+            import pendulum

Review Comment:
   Minor stuff while you're in here. `import pendulum` can live at the module 
top rather than inline. The `copy.copy(self.get_task(...))` on line 1190 
doesn't look necessary, since the task is only read and never mutated. Lines 
1205-1209 re-query `DagRun.logical_date` even though `ti.dag_run.logical_date` 
already has it. And the `DBDagBag` is being built once per external TI inside 
the loop (1241-1244) when it could be constructed once above.



##########
airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py:
##########
@@ -226,6 +226,12 @@ class ClearTaskInstancesBody(StrictBaseModel):
     )
     prevent_running_task: bool = False
     note: Annotated[str, StringConstraints(max_length=1000)] | None = None
+    include_downstream_dags: bool = Field(

Review Comment:
   Since this brings back user-visible clear behavior and adds a public API 
field, it's a bit beyond a routine bugfix -- might be worth a newsfragment 
(`airflow-core/newsfragments/65314.improvement.rst`).
   
   Also, the route already cascades on `downstream=True` 
(`include_dependent_dags = body.include_downstream_dags or downstream`), so 
this new field overlaps with the existing `include_downstream`. A note in the 
description on why both exist, and whether the public name should line up with 
the internal `include_dependent_dags`, would help.



##########
airflow-core/src/airflow/serialization/definitions/dag.py:
##########
@@ -1137,6 +1155,124 @@ def apply_state_filter(query):
         else:
             tis_full = apply_state_filter(tis_full)
 
+        if include_dependent_dags:
+            # Recursively find external tasks indicated by ExternalTaskMarker
+            import pendulum
+
+            from airflow.providers.standard.sensors.external_task import 
ExternalTaskMarker
+
+            # Build a full-object query for identifying ExternalTaskMarker TIs 
in the current set
+            if as_pk_tuple:
+                all_ti_rows = session.execute(tis_pk).all()
+                condition = TaskInstance.filter_for_tis(
+                    TaskInstanceKey(**cols._mapping) for cols in all_ti_rows
+                )
+                marker_query = select(TaskInstance).where(condition) if 
condition is not None else None
+            else:
+                marker_query = tis_full
+
+            if marker_query is not None:
+                if visited_external_tis is None:
+                    visited_external_tis = set()
+
+                external_marker_tis = session.scalars(
+                    marker_query.where(TaskInstance.operator == 
ExternalTaskMarker.__name__)
+                )
+
+                for ti in external_marker_tis:
+                    ti_key = ti.key.primary
+
+                    if ti_key in visited_external_tis:
+                        continue
+
+                    visited_external_tis.add(ti_key)
+                    task: ExternalTaskMarker = cast(
+                        "ExternalTaskMarker", 
copy.copy(self.get_task(ti.task_id))
+                    )
+
+                    if max_recursion_depth is None:
+                        # Maximum recursion depth is set from the first 
ExternalTaskMarker encountered
+                        max_recursion_depth = task.recursion_depth
+
+                    if recursion_depth + 1 > max_recursion_depth:
+                        raise MaxRecursionDepthError(
+                            f"Maximum recursion depth {max_recursion_depth} 
reached for "
+                            f"{ExternalTaskMarker.__name__} {ti.task_id}. "
+                            f"Attempted to clear too many tasks or there may 
be a cyclic dependency."
+                        )
+
+                    # Resolve the logical_date that the ExternalTaskMarker 
points to
+                    dr_logical_date = session.scalar(
+                        select(DagRun.logical_date).where(
+                            DagRun.dag_id == ti.dag_id, DagRun.run_id == 
ti.run_id
+                        )
+                    )
+
+                    if dr_logical_date is None:
+                        continue
+
+                    logical_date_str: str = dr_logical_date.isoformat()
+
+                    # Check whether a non-default template was used by 
comparing the serialized template
+                    # field on the task against the default "{{ 
logical_date.isoformat() }}" pattern
+                    default_template = "{{ logical_date.isoformat() }}"
+
+                    # If it differs, look up the rendered value from 
RenderedTaskInstanceFields
+                    if task.logical_date != default_template:
+                        from airflow.models.renderedtifields import 
RenderedTaskInstanceFields
+
+                        rendered = 
RenderedTaskInstanceFields.get_templated_fields(ti, session=session)
+
+                        if rendered and "logical_date" in rendered:
+                            logical_date_str = rendered["logical_date"]
+
+                    external_logical_date = pendulum.parse(logical_date_str)
+                    external_tis = session.scalars(
+                        select(TaskInstance)
+                        .join(TaskInstance.dag_run)
+                        .where(
+                            TaskInstance.dag_id == task.external_dag_id,

Review Comment:
   `ExternalTaskMarker.template_fields` covers all three of `external_dag_id`, 
`external_task_id` and `logical_date`, but only `logical_date` gets resolved 
from the rendered fields above -- the other two are read straight off the 
deserialized task here. If someone templates `external_dag_id` (say `"{{ 
var.value.child_dag }}"`), this filter compares against the literal Jinja 
string and the child just silently doesn't get cleared.
   
   The rendered values are already in the dict you fetch for `logical_date` 
(`RenderedTaskInstanceFields.get_templated_fields(ti)`, the same call used at 
`external_task.py:95-96`), so pulling all three from there would cover it.



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