This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 6a925a8fbe6 Resolve loop iterations through family and path columns on 
dynamic regions (#74892)
6a925a8fbe6 is described below

commit 6a925a8fbe690444a8e685fdd824e79a5864833d
Author: Ash Berlin-Taylor <[email protected]>
AuthorDate: Sat Oct 17 21:44:00 2026 +0100

    Resolve loop iterations through family and path columns on dynamic regions 
(#74892)
    
    First off: why is this needed, wasn't loops landed and working? "Yes, but."
    Part of the goal with the data-modelling changes for task loops (AIP-111)
    is to support dynamic graphs (AIP-113) and other dynamic things, such as
    choosing to allow loops inside loops (currently a parse error), with no 
future
    migrations or data modelling changes needed. This change lets us enable 
these
    features without expensive recursive queries in TIDep evaulation etc. And 
why
    now? By getting this change in before 3.4.0 enters the RC stage, while we 
can
    still change the migration "at zero-cost". And adding it as a migration in 
3.5
    would involve a (possibly costly) migration.
    
    So the meat of the change:
    
    Finding the task instances that sit inside one iteration of a loop currently
    means finding every region of the loop's fork chain (a fork is when a loop 
is
    cleared part way through and some iterations run a-fresh),  plus everything
    nested under that iteration, however deep. The previous lookup resolved 
those
    region ids with two recursive CTEs and then passed them to the task_instance
    query as literal lists. The nested list holds every region under the
    iteration, whichever task is being read, so once something under an 
iteration
    can fan out again (nested loops, or dynamic graphs inside a loop) its size
    grows with the product of those widths. Every lookup hits this path: the
    scheduler's trigger-rule check of a loop task repeats it on each pass until
    the task is ready, and every XCom read from inside a loop pays it on the API
    server. Measured with 5,000 regions under one iteration, a lookup that 
returns
    one row took about 30 ms on PostgreSQL and MySQL, almost all of it building,
    sending and parsing the list; with 50,000 it took about 550 ms.
    
    Two columns on dynamic_region lets us get the same result but without
    recursion. `family_root_id` is the id of the first region of a fork chain, 
so
    a chain is one equality column comparisson. `path` contains, for each 
ancestor
    from the top, its family root and the index the region sits at inside it, so
    everything under one iteration of a loop shares a byte prefix and is one 
range
    of an index. The lookup becomes a UNION ALL of two joins from task_instance
    to dynamic_region, each driven by its own index; a single OR across the two
    legs gives the planner no such choice. Its cost no longer depends on how 
many
    regions sit under the iteration. The hook that fills the columns rejects a
    fork whose parent differs from its source's, because both the family 
equality
    and the prefix range rely on every member of a chain sharing one position.
    
    Region inserts are rare next to task instance reads and writes, so the hook
    and the two extra indexes cost comparitavely little.
    
    The columns are NOT NULL so that raw inserts which skip the ORM fail loudly
    instead of writing rows the lookup would silently miss. They are added to 
the
    migration that creates dynamic_region, which has only shipped in a beta, so 
no
    backfill is needed. A 3.4.0b1 database already ran that migration without 
them
    so users in that situation will need to downgrade to 3.3 and upgrade again,
    or reset the database. Nesting is capped at ten ancestors, which sets the
    200-byte path width on MySQL. The cap is a chosen bound rather than an index
    limit, and authoring already rejects nested loops, so current real depth is 
far lower.
    
    In order to allow more easily resizing this column on MySQL in the future if
    we want or need to, the length needs to be over 255 bytes (as InnoDB stores
    VARBINARY with shorter values with a 1 byte length prefix.) So we have 
chosen
    300, allowing 15 levels, such that we can change it in future with a 
meta-data
    only storage change. Regardless of max length of column, all DBs only use as
    much storage as needed (+ size counter).
---
 .../0143_3_4_0_add_dynamic_region_storage.py       |   6 +
 airflow-core/src/airflow/models/dynamic_region.py  | 212 +++++++++++++------
 .../core_api/routes/public/test_extra_links.py     |   7 +-
 .../test_0143_add_dynamic_region_storage.py        |  10 +-
 .../test_0144_rename_stored_map_index.py           |  12 +-
 .../tests/unit/models/test_dynamic_region.py       | 233 ++++++++++++++++++++-
 airflow-core/tests/unit/utils/test_db_cleanup.py   |   2 +
 7 files changed, 418 insertions(+), 64 deletions(-)

diff --git 
a/airflow-core/src/airflow/migrations/versions/0143_3_4_0_add_dynamic_region_storage.py
 
b/airflow-core/src/airflow/migrations/versions/0143_3_4_0_add_dynamic_region_storage.py
index 80d6c5e1aad..8c99d502386 100644
--- 
a/airflow-core/src/airflow/migrations/versions/0143_3_4_0_add_dynamic_region_storage.py
+++ 
b/airflow-core/src/airflow/migrations/versions/0143_3_4_0_add_dynamic_region_storage.py
@@ -157,6 +157,10 @@ def upgrade():
             sa.Column("parent_region_id", CompactUUID(), nullable=True),
             sa.Column("parent_region_index", sa.Integer(), nullable=True),
             sa.Column("forked_from_region_id", CompactUUID(), nullable=True),
+            sa.Column("family_root_id", CompactUUID(), nullable=False),
+            sa.Column(
+                "path", sa.LargeBinary().with_variant(sa.VARBINARY(300), 
"mysql", "mariadb"), nullable=False
+            ),
             sa.Column("resumes_from_index", sa.Integer(), nullable=False, 
server_default="0"),
             sa.Column("created_at", UtcDateTime(), nullable=False),
             sa.PrimaryKeyConstraint("id", name="dynamic_region_pkey"),
@@ -187,6 +191,8 @@ def upgrade():
             ["dag_id", "run_id", "node_id", "parent_region_id", 
"parent_region_index"],
         )
         op.create_index("idx_dynamic_region_parent_region_id", 
"dynamic_region", ["parent_region_id"])
+        op.create_index("idx_dynamic_region_family_root_id", "dynamic_region", 
["family_root_id"])
+        op.create_index("idx_dynamic_region_path", "dynamic_region", ["path"])
         for table_name in ("task_instance", "task_state_store"):
             op.add_column(
                 table_name,
diff --git a/airflow-core/src/airflow/models/dynamic_region.py 
b/airflow-core/src/airflow/models/dynamic_region.py
index 65470ea6bc5..494cd09a926 100644
--- a/airflow-core/src/airflow/models/dynamic_region.py
+++ b/airflow-core/src/airflow/models/dynamic_region.py
@@ -28,7 +28,9 @@ import attrs
 import uuid6
 from sqlalchemy import (
     BINARY,
+    VARBINARY,
     CheckConstraint,
+    CompoundSelect,
     ForeignKeyConstraint,
     Index,
     Integer,
@@ -44,7 +46,7 @@ from sqlalchemy import (
     union_all,
 )
 from sqlalchemy.exc import IntegrityError
-from sqlalchemy.orm import Mapped, aliased, mapped_column
+from sqlalchemy.orm import Mapped, Session, mapped_column, object_session
 
 from airflow._shared.timezones import timezone
 from airflow.models.base import Base, StringID
@@ -54,11 +56,19 @@ SENTINEL_REGION_ID = UUID(int=0)
 LOOP_XCOM_PREFIX = "_airflow_loop_"
 LOOP_DECISION_REASON_KEY = f"{LOOP_XCOM_PREFIX}decision_reason"
 LOOP_DECISION_REASON_MAX_BYTES = 512
+MAX_REGION_DEPTH = 10  # Max depth we allow at runtime
+_PATH_SEGMENT_BYTES = 20
+_MAX_REGION_INDEX = 2**31 - 1
+_COORDINATES_CACHE_KEY = "dynamic_region_coordinates"
+# Over 255 bytes so MySQL can widen the column later as a metadata-only 
change; 15 levels fit already.
+_PATH_TYPE = LargeBinary().with_variant(VARBINARY(300), "mysql", "mariadb")
+_pack_index = struct.Struct(">I").pack
 
 if TYPE_CHECKING:
     from sqlalchemy import Select
     from sqlalchemy.engine import Connection
-    from sqlalchemy.orm import Mapper, Session
+    from sqlalchemy.orm import Mapper
+    from sqlalchemy.orm.session import SessionTransaction
     from sqlalchemy.sql.elements import ColumnElement
 
     from airflow.models.taskinstance import TaskInstance
@@ -142,6 +152,34 @@ class DynamicRegion(Base):
     rows from before the column existed or a raw insert that bypasses the ORM, 
have a NULL key and are
     not guarded: :meth:`DynamicRegion.get_or_create` finds those by their slot 
columns, and a raw insert
     must supply the key itself.
+
+    A *family* is an original region and the chain of forks of it, and 
``family_root_id`` is the id of
+    its first region. Everything that sits inside one iteration of a loop, 
however deep, has the loop
+    family and that iteration in its ``path``: for each ancestor from the top, 
16 bytes of its
+    ``family_root_id`` and the 4-byte big-endian index this region sits at 
within it. That makes "every
+    region of a loop family, and everything nested under it" an equality on 
``family_root_id`` plus a
+    range scan of ``path``, see :func:`build_loop_family_legs`, instead of a 
walk of the ancestry. Both
+    columns are set by the ``before_insert`` hook from 
``forked_from_region_id`` and the parent
+    columns, so a raw insert that bypasses the ORM must supply them. A fork 
must sit under the same
+    parent as its source, which the hook enforces, and nesting is limited to 
:data:`MAX_REGION_DEPTH`
+    ancestors.
+
+    The same encoding in SQL, for a region ``r`` with parent ``p``, a loop 
family member ``m`` and an
+    iteration ``k``. ``family_root_id`` is ``BINARY(16)`` on MySQL, so it 
concatenates as is, and 32 hex
+    characters on SQLite, where ``||`` always yields text, so the path is 
built in hex and decoded once::
+
+        -- r.path on PostgreSQL / MySQL / SQLite 3.41+
+        p.path || uuid_send(p.family_root_id) || 
int4send(r.parent_region_index)
+        CONCAT(p.path, p.family_root_id, 
UNHEX(LPAD(HEX(r.parent_region_index), 8, '0')))
+        unhex(hex(p.path) || p.family_root_id || printf('%08X', 
r.parent_region_index))
+
+        -- everything nested under iteration k of m's family, on PostgreSQL
+        path >= m.path || uuid_send(m.family_root_id) || int4send(k)
+        AND path < m.path || uuid_send(m.family_root_id) || int4send(k + 1)
+
+        -- the iteration of m's family that a nested region sits in, on 
PostgreSQL / MySQL
+        ('x' || encode(substring(path from length(m.path) + 17 for 4), 
'hex'))::bit(32)::integer
+        CONV(HEX(SUBSTRING(path, LENGTH(m.path) + 17, 4)), 16, 10)
     """
 
     __tablename__ = "dynamic_region"
@@ -154,6 +192,8 @@ class DynamicRegion(Base):
     parent_region_id: Mapped[UUID | None] = mapped_column(CompactUUID(), 
nullable=True)
     parent_region_index: Mapped[int | None] = mapped_column(Integer, 
nullable=True)
     forked_from_region_id: Mapped[UUID | None] = mapped_column(CompactUUID(), 
nullable=True)
+    family_root_id: Mapped[UUID] = mapped_column(CompactUUID(), nullable=False)
+    path: Mapped[bytes] = mapped_column(_PATH_TYPE, nullable=False)
     resumes_from_index: Mapped[int] = mapped_column(Integer, nullable=False, 
default=0, server_default="0")
     # Hash of the slot, set on original regions and NULL on forks; see the 
class docstring.
     slot_key: Mapped[bytes | None] = mapped_column(
@@ -184,6 +224,8 @@ class DynamicRegion(Base):
         CheckConstraint("resumes_from_index >= 0", 
name="resumes_from_index_nonnegative"),
         Index("idx_dynamic_region_slot", dag_id, run_id, node_id, 
parent_region_id, parent_region_index),
         Index("idx_dynamic_region_parent_region_id", parent_region_id),
+        Index("idx_dynamic_region_family_root_id", family_root_id),
+        Index("idx_dynamic_region_path", path),
     )
 
     @classmethod
@@ -404,6 +446,72 @@ def _set_slot_key(mapper: Mapper, connection: Connection, 
region: DynamicRegion)
         )
 
 
+def _get_coordinates_cache(region: DynamicRegion) -> dict[UUID, tuple[UUID, 
bytes]]:
+    """
+    Remember region coordinates up until the unit-of-work is flushed to the DB.
+
+    That is enough for siblings flushed together to read their parent once, 
and for a region to find a
+    parent added in the same flush: every ``before_insert`` hook runs before 
the first ``INSERT``, so the
+    parent is not in the database yet.
+    """
+    session = object_session(region)
+    if TYPE_CHECKING:
+        assert session is not None
+    return session.info.setdefault(_COORDINATES_CACHE_KEY, {})
+
+
+def _get_region_coordinates(
+    connection: Connection, region: DynamicRegion, region_id: UUID, role: str
+) -> tuple[UUID, bytes]:
+    cache = _get_coordinates_cache(region)
+    if region_id not in cache:
+        row = connection.execute(
+            select(DynamicRegion.family_root_id, 
DynamicRegion.path).where(DynamicRegion.id == region_id)
+        ).one_or_none()
+        if row is None:
+            raise ValueError(f"The {role} region {region_id} does not exist")
+        cache[region_id] = (row.family_root_id, row.path)
+    return cache[region_id]
+
+
+def _build_region_path(connection: Connection, region: DynamicRegion) -> bytes:
+    if region.parent_region_id is None and region.parent_region_index is None:
+        return b""
+    if region.parent_region_id is None or region.parent_region_index is None:
+        raise ValueError("parent_region_id and parent_region_index must be set 
together")
+    if region.parent_region_index < 0:
+        raise ValueError("parent_region_index must not be negative")
+    parent_root, parent_path = _get_region_coordinates(connection, region, 
region.parent_region_id, "parent")
+    path = parent_path + parent_root.bytes + 
_pack_index(region.parent_region_index)
+    if len(path) > MAX_REGION_DEPTH * _PATH_SEGMENT_BYTES:
+        raise ValueError(f"Dynamic regions can nest at most {MAX_REGION_DEPTH} 
levels deep")
+    return path
+
+
[email protected]_for(DynamicRegion, "before_insert")
+def _set_region_coordinates(mapper: Mapper, connection: Connection, region: 
DynamicRegion) -> None:
+    # The id default runs after this hook, and a region that starts a family 
is named by its own id.
+    if region.id is None:
+        region.id = uuid6.uuid7()
+    path = _build_region_path(connection, region)
+    if region.forked_from_region_id is None:
+        region.family_root_id = region.id
+    else:
+        region.family_root_id, source_path = _get_region_coordinates(
+            connection, region, region.forked_from_region_id, "fork source"
+        )
+        if source_path != path:
+            raise ValueError("A fork must sit under the same parent as the 
region it forks")
+    region.path = path
+    _get_coordinates_cache(region)[region.id] = (region.family_root_id, 
region.path)
+
+
+# Every flush runs in a subtransaction of its own, so this fires after each 
flush, not only at commit.
[email protected]_for(Session, "after_transaction_end")
+def _forget_region_coordinates(session: Session, transaction: 
SessionTransaction) -> None:
+    session.info.pop(_COORDINATES_CACHE_KEY, None)
+
+
 def load_region_ancestry(
     region_ids: Collection[UUID], *, dag_id: str, run_id: str, session: Session
 ) -> dict[UUID, DynamicRegion]:
@@ -457,59 +565,39 @@ def loop_position(
     return None
 
 
[email protected](frozen=True)
-class _LoopPassRegions:
-    """The regions that hold one iteration of a loop: its fork family and 
everything nested under it."""
-
-    family_ids: tuple[UUID, ...]
-    nested_ids: tuple[UUID, ...]
-    iteration: int
-
-
-def _load_loop_pass_regions(position: tuple[UUID, int], *, session: Session) 
-> _LoopPassRegions:
+def build_loop_family_legs(
+    query: Select[Any], family_member: DynamicRegion, *, iteration: int | None 
= None
+) -> tuple[Select[Any], Select[Any]]:
     """
-    Resolve, in one statement, the regions that hold the loop iteration 
``position`` names.
-
-    Both recursive CTEs run against ``dynamic_region`` only. The 
``task_instance`` query then takes the
-    ids as literals, which every backend turns into an index lookup on 
``task_instance_current_key``;
-    a subquery in that position leaves MySQL and Postgres scanning every row 
of the task.
+    Split ``query``, a selection from ``task_instance``, into the rows that 
belong to one loop family.
+
+    ``family_member`` is any region of the loop's family; the family's regions 
share ``family_root_id`` and
+    ``path``. The first leg holds the rows that sit directly in a region of 
the family, where
+    ``region_index`` is the iteration. The second holds the rows nested under 
the family, however deep,
+    whose ``path`` starts with the family and the iteration. Pass 
``iteration`` to keep one iteration, or
+    leave it out for all of them.
+
+    Each leg is joined to :class:`DynamicRegion`, so a caller can add columns 
to both before combining
+    them with ``UNION ALL``. The iteration of a row in a region directly under 
the family is
+    ``DynamicRegion.parent_region_index``; deeper down, it is the 4-byte index 
that follows the family's
+    16-byte root in ``path``, as decoded in the :class:`DynamicRegion` 
docstring. The
+    legs never overlap. They are separate statements because the planner can 
then drive each from its
+    own index, which an ``OR`` across them does not allow.
     """
-    family_root, iteration = position
-    family = (
-        select(DynamicRegion.id).where(DynamicRegion.id == 
family_root).cte("loop_family", recursive=True)
-    )
-    forked = aliased(DynamicRegion)
-    family = family.union(select(forked.id).where(forked.forked_from_region_id 
== family.c.id))
-    nested = (
-        select(DynamicRegion.id)
-        .where(
-            DynamicRegion.parent_region_id.in_(select(family.c.id)),
-            DynamicRegion.parent_region_index == iteration,
-        )
-        .cte("loop_iteration_regions", recursive=True)
-    )
-    child = aliased(DynamicRegion)
-    nested = nested.union(select(child.id).where(child.parent_region_id == 
nested.c.id))
-    rows = session.execute(
-        union_all(
-            select(family.c.id, literal(0).label("is_nested")),
-            select(nested.c.id, literal(1).label("is_nested")),
-        )
-    ).all()
-    return _LoopPassRegions(
-        family_ids=tuple(region_id for region_id, is_nested in rows if not 
is_nested),
-        nested_ids=tuple(region_id for region_id, is_nested in rows if 
is_nested),
-        iteration=iteration,
-    )
-
-
-def _build_loop_pass_filter(regions: _LoopPassRegions) -> ColumnElement[bool]:
     from airflow.models.taskinstance import TaskInstance
 
-    return or_(
-        and_(TaskInstance.region_id.in_(regions.family_ids), 
TaskInstance.region_index == regions.iteration),
-        TaskInstance.region_id.in_(regions.nested_ids),
+    first, end = (0, _MAX_REGION_INDEX + 1) if iteration is None else 
(iteration, iteration + 1)
+    prefix = family_member.path + family_member.family_root_id.bytes
+    direct = query.join(DynamicRegion, TaskInstance.region_id == 
DynamicRegion.id).where(
+        DynamicRegion.family_root_id == family_member.family_root_id
     )
+    if iteration is not None:
+        direct = direct.where(TaskInstance.region_index == iteration)
+    nested = query.join(DynamicRegion, TaskInstance.region_id == 
DynamicRegion.id).where(
+        DynamicRegion.path >= prefix + _pack_index(first),
+        DynamicRegion.path < prefix + _pack_index(end),
+    )
+    return direct, nested
 
 
 def public_region_filter(
@@ -570,9 +658,8 @@ def _filter_producers(
     region_id: UUID | None,
     region_index: int | None,
     top_level_only: bool,
-    loop_pass: _LoopPassRegions | None = None,
 ) -> Select:
-    """Push every coordinate predicate into SQL."""
+    """Push every coordinate predicate that does not need loop ancestry into 
SQL."""
     from airflow.models.taskinstance import TaskInstance
 
     query = query.where(
@@ -585,8 +672,6 @@ def _filter_producers(
         query = query.where(TaskInstance.region_id == region_id)
     if region_index is not None:
         query = query.where(TaskInstance.region_index == region_index)
-    if loop_pass is not None:
-        query = query.where(_build_loop_pass_filter(loop_pass))
     if top_level_only:
         if is_mapped:
             top_level_region = (
@@ -633,8 +718,13 @@ def _build_candidate_query(
     region_id: UUID | None,
     region_index: int | None,
     session: Session,
-) -> Select[Any] | None:
-    """Narrow ``query`` to the candidate producers of the caller's scope, or 
``None`` if there are none."""
+) -> Select[Any] | CompoundSelect | None:
+    """
+    Narrow ``query`` to the candidate producers of the caller's scope, or 
``None`` if there are none.
+
+    Inside a loop the result is the ``UNION ALL`` of the two legs of 
:func:`build_loop_family_legs`, so
+    load ORM entities from it with ``from_statement``.
+    """
     regions: dict[UUID, DynamicRegion] = {}
     position = None
     if context and region_id is None:
@@ -650,7 +740,7 @@ def _build_candidate_query(
                 if position[1] < 0:
                     return None
 
-    return _filter_producers(
+    query = _filter_producers(
         query,
         dag_id=dag_id,
         run_id=run_id,
@@ -660,8 +750,11 @@ def _build_candidate_query(
         region_id=region_id,
         region_index=region_index,
         top_level_only=region_id is None and position is None,
-        loop_pass=_load_loop_pass_regions(position, session=session) if 
position is not None else None,
     )
+    if position is None:
+        return query
+    family_root, iteration = position
+    return union_all(*build_loop_family_legs(query, regions[family_root], 
iteration=iteration))
 
 
 def _check_producers_unambiguous(
@@ -707,7 +800,8 @@ def resolve_current_producers(
     )
     if query is None:
         return ()
-    candidates = session.scalars(query).all()
+    statement = select(TaskInstance).from_statement(query) if 
isinstance(query, CompoundSelect) else query
+    candidates = session.scalars(statement).all()
     _check_producers_unambiguous(
         (ti.region_index for ti in candidates),
         dag_id=dag_id,
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_extra_links.py
 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_extra_links.py
index 1bc12c93260..0bb45fb62a8 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_extra_links.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_extra_links.py
@@ -140,13 +140,16 @@ def 
test_serialized_links_read_xcom_from_selected_region(test_client, dag_maker,
             CustomOperator(task_id="work", bash_command="echo test")
 
     with dag_maker(serialized=True) as dag:
-        loop = create_loop(body, max_iterations=4)
+        create_loop(body, max_iterations=4)
     run = dag_maker.create_dagrun()
     sibling = next(ti for ti in run.task_instances if ti.task_id == 
"body.work" and ti.region_index == 0)
+    source_region = session.get(DynamicRegion, sibling.region_id)
     selected_region = DynamicRegion(
         dag_id=run.dag_id,
         run_id=run.run_id,
-        node_id=loop.group_id,
+        node_id=source_region.node_id,
+        parent_region_id=source_region.parent_region_id,
+        parent_region_index=source_region.parent_region_index,
         forked_from_region_id=sibling.region_id,
         resumes_from_index=sibling.region_index,
     )
diff --git 
a/airflow-core/tests/unit/migrations/test_0143_add_dynamic_region_storage.py 
b/airflow-core/tests/unit/migrations/test_0143_add_dynamic_region_storage.py
index 16fc5206149..27e489cc007 100644
--- a/airflow-core/tests/unit/migrations/test_0143_add_dynamic_region_storage.py
+++ b/airflow-core/tests/unit/migrations/test_0143_add_dynamic_region_storage.py
@@ -90,6 +90,8 @@ dynamic_region = sa.Table(
     sa.Column("dag_id", sa.String(250)),
     sa.Column("run_id", sa.String(250)),
     sa.Column("node_id", sa.String(250)),
+    sa.Column("family_root_id", CompactUUID),
+    sa.Column("path", sa.LargeBinary),
     sa.Column("created_at", UtcDateTime),
 )
 TABLES = (task_instance, task_state_store)
@@ -166,7 +168,13 @@ def _add_region_row(connection, table, **overrides):
     region_id = uuid4()
     connection.execute(
         dynamic_region.insert().values(
-            id=region_id, dag_id=DAG_ID, run_id=RUN_ID, node_id="task", 
created_at=timezone.utcnow()
+            id=region_id,
+            dag_id=DAG_ID,
+            run_id=RUN_ID,
+            node_id="task",
+            family_root_id=region_id,
+            path=b"",
+            created_at=timezone.utcnow(),
         )
     )
     values = {name: value for name, value in row.items() if name != "id" or 
table is task_instance}
diff --git 
a/airflow-core/tests/unit/migrations/test_0144_rename_stored_map_index.py 
b/airflow-core/tests/unit/migrations/test_0144_rename_stored_map_index.py
index 16ded6715be..e28fbbc5af6 100644
--- a/airflow-core/tests/unit/migrations/test_0144_rename_stored_map_index.py
+++ b/airflow-core/tests/unit/migrations/test_0144_rename_stored_map_index.py
@@ -93,6 +93,8 @@ dynamic_region = sa.Table(
     sa.Column("node_id", sa.String(250)),
     sa.Column("parent_region_id", CompactUUID),
     sa.Column("parent_region_index", sa.Integer),
+    sa.Column("family_root_id", CompactUUID),
+    sa.Column("path", sa.LargeBinary),
     sa.Column("created_at", UtcDateTime),
 )
 task_reschedule = sa.Table(
@@ -183,7 +185,13 @@ def stored_rows(database_at_revision):
         ).inserted_primary_key[0]
         connection.execute(
             dynamic_region.insert().values(
-                id=REGION_ID, dag_id=DAG_ID, run_id=RUN_ID, node_id="task", 
created_at=now
+                id=REGION_ID,
+                dag_id=DAG_ID,
+                run_id=RUN_ID,
+                node_id="task",
+                family_root_id=REGION_ID,
+                path=b"",
+                created_at=now,
             )
         )
         connection.execute(
@@ -194,6 +202,8 @@ def stored_rows(database_at_revision):
                 node_id="task",
                 parent_region_id=REGION_ID,
                 parent_region_index=0,
+                family_root_id=CHILD_REGION_ID,
+                path=REGION_ID.bytes + b"\x00\x00\x00\x00",
                 created_at=now,
             )
         )
diff --git a/airflow-core/tests/unit/models/test_dynamic_region.py 
b/airflow-core/tests/unit/models/test_dynamic_region.py
index 82f107abf13..54c17bf9b2e 100644
--- a/airflow-core/tests/unit/models/test_dynamic_region.py
+++ b/airflow-core/tests/unit/models/test_dynamic_region.py
@@ -17,20 +17,24 @@
 
 from __future__ import annotations
 
+import struct
 from typing import TYPE_CHECKING
 from uuid import uuid4
 
 import pytest
-from sqlalchemy import event, func, insert, select, text
+from sqlalchemy import event, func, insert, select, text, union_all
 from sqlalchemy.exc import IntegrityError
 
 from airflow._shared.timezones import timezone
 from airflow.models.dagrun import DagRun
 from airflow.models.dynamic_region import (
+    _COORDINATES_CACHE_KEY,
+    MAX_REGION_DEPTH,
     SENTINEL_REGION_ID,
     AmbiguousProducerError,
     DynamicRegion,
     ProducerContext,
+    build_loop_family_legs,
     build_loop_sequence_order,
     resolve_current_producers,
     select_loop_producer_ids,
@@ -626,6 +630,231 @@ def 
test_fork_is_exempt_but_a_source_can_only_be_forked_once(regional_tis, sessi
     add_region(session, run, forked_from_region_id=fork.id, 
resumes_from_index=2)
 
 
+def pack_index(value: int) -> bytes:
+    return struct.pack(">i", value)
+
+
+def test_top_level_region_is_its_own_family_with_an_empty_path(regional_tis, 
session):
+    run, _ = regional_tis
+    region = add_region(session, run)
+
+    assert region.family_root_id == region.id
+    assert region.path == b""
+
+
+def 
test_nested_regions_extend_the_path_by_the_parents_family_and_the_index(regional_tis,
 session):
+    run, _ = regional_tis
+    outer = add_region(session, run)
+    inner = add_region(session, run, parent_region_id=outer.id, 
parent_region_index=300)
+    innermost = add_region(session, run, parent_region_id=inner.id, 
parent_region_index=1)
+
+    assert inner.path == outer.id.bytes + pack_index(300)
+    assert innermost.path == inner.path + inner.id.bytes + pack_index(1)
+
+
+def 
test_forks_share_the_family_and_regions_nested_under_a_fork_use_its_family_root(regional_tis,
 session):
+    run, _ = regional_tis
+    original = add_region(session, run)
+    fork = add_region(session, run, forked_from_region_id=original.id)
+    forked_again = add_region(session, run, forked_from_region_id=fork.id)
+    child = add_region(session, run, parent_region_id=forked_again.id, 
parent_region_index=2)
+    grandchild = add_region(session, run, parent_region_id=child.id, 
parent_region_index=0)
+
+    assert fork.family_root_id == forked_again.family_root_id == original.id
+    assert child.family_root_id == child.id
+    assert child.path == original.id.bytes + pack_index(2)
+    assert grandchild.path == child.path + child.id.bytes + pack_index(0)
+
+
[email protected]("moved", ["parent", "index"])
+def test_fork_under_another_parent_than_its_source_is_rejected(regional_tis, 
session, moved):
+    run, _ = regional_tis
+    outer = add_region(session, run)
+    other = add_region(session, run, node_id="other")
+    original = add_region(session, run, node_id="body", 
parent_region_id=outer.id, parent_region_index=0)
+    fork_parent = {
+        "parent": {"parent_region_id": other.id, "parent_region_index": 0},
+        "index": {"parent_region_id": outer.id, "parent_region_index": 1},
+    }[moved]
+
+    with pytest.raises(ValueError, match="same parent"), 
session.begin_nested():
+        add_region(session, run, node_id="body", 
forked_from_region_id=original.id, **fork_parent)
+
+
+def test_regions_flushed_together_resolve_a_pending_parent(regional_tis, 
session):
+    run, _ = regional_tis
+    parent = make_region(run, id=uuid4())
+    child = make_region(run, node_id="child", parent_region_id=parent.id, 
parent_region_index=4)
+    session.add_all([parent, child])
+    session.flush()
+
+    assert child.path == parent.id.bytes + pack_index(4)
+
+
+def test_siblings_under_one_parent_read_the_parent_once(regional_tis, session):
+    run, _ = regional_tis
+    parent_id = uuid4()
+    session.execute(
+        insert(DynamicRegion).values(
+            id=parent_id,
+            dag_id=run.dag_id,
+            run_id=run.run_id,
+            node_id="parent",
+            family_root_id=parent_id,
+            path=b"",
+            resumes_from_index=0,
+            created_at=timezone.utcnow(),
+        )
+    )
+    statements = []
+
+    def record(conn, cursor, statement, parameters, context, executemany):
+        if statement.lstrip().upper().startswith("SELECT") and 
"dynamic_region" in statement:
+            statements.append(statement)
+
+    event.listen(session.get_bind(), "before_cursor_execute", record)
+    try:
+        session.add_all(
+            make_region(run, node_id=f"child{index}", 
parent_region_id=parent_id, parent_region_index=index)
+            for index in range(5)
+        )
+        session.flush()
+    finally:
+        event.remove(session.get_bind(), "before_cursor_execute", record)
+
+    assert len(statements) == 1
+
+
[email protected](
+    ("kwargs", "role"),
+    [
+        pytest.param({"parent_region_id": uuid4(), "parent_region_index": 0}, 
"parent", id="parent"),
+        pytest.param({"forked_from_region_id": uuid4()}, "fork source", 
id="fork source"),
+    ],
+)
+def test_unknown_parent_or_fork_source_is_rejected(regional_tis, session, 
kwargs, role):
+    run, _ = regional_tis
+
+    with pytest.raises(ValueError, match=f"The {role} region .* does not 
exist"), session.begin_nested():
+        add_region(session, run, **kwargs)
+
+
[email protected](
+    ("with_parent", "index", "message"),
+    [
+        pytest.param(True, None, "set together", id="parent without index"),
+        pytest.param(False, 3, "set together", id="index without parent"),
+        pytest.param(True, -1, "must not be negative", id="negative index"),
+    ],
+)
+def test_invalid_parent_coordinates_are_rejected(regional_tis, session, 
with_parent, index, message):
+    run, _ = regional_tis
+    outer = add_region(session, run)
+    parent = {"parent_region_id": outer.id} if with_parent else {}
+
+    with pytest.raises(ValueError, match=message), session.begin_nested():
+        add_region(session, run, node_id="body", parent_region_index=index, 
**parent)
+
+
+def test_regions_nest_at_most_the_maximum_depth(regional_tis, session):
+    run, _ = regional_tis
+    region = add_region(session, run)
+    for _ in range(MAX_REGION_DEPTH):
+        region = add_region(session, run, parent_region_id=region.id, 
parent_region_index=0)
+
+    with pytest.raises(ValueError, match="nest at most"), 
session.begin_nested():
+        add_region(session, run, parent_region_id=region.id, 
parent_region_index=0)
+
+
+def test_coordinates_of_a_rolled_back_region_are_forgotten(regional_tis, 
session):
+    run, _ = regional_tis
+    with session.begin_nested() as savepoint:
+        region_id = add_region(session, run).id
+        savepoint.rollback()
+
+    with pytest.raises(ValueError, match="fork source"), 
session.begin_nested():
+        add_region(session, run, forked_from_region_id=region_id)
+
+
+def test_coordinates_are_forgotten_after_the_flush(regional_tis, session):
+    run, _ = regional_tis
+    add_region(session, run)
+
+    assert _COORDINATES_CACHE_KEY not in session.info
+
+
[email protected]
+def family_rows(producer_tis, session):
+    """
+    Task instances of ``mapped`` in the loop family of ``producer_tis``, in a 
loop of another node and outside.
+
+    The family is three regions. Each has a row at the iteration it is named 
for, and the last has a second
+    one. Nested regions hold rows at iterations 0 to 3, one of them two levels 
deep.
+    """
+    tis, regions, _ = producer_tis
+    mapped = tis["mapped"]
+    other_loop = add_region(session, mapped.dag_run, node_id="other_loop")
+
+    def add_row(region: DynamicRegion, region_index: int = 0) -> TaskInstance:
+        ti = TaskInstance(
+            mapped.task,
+            mapped.dag_version_id,
+            run_id=mapped.run_id,
+            map_index=region_index,
+            region_id=region.id,
+        )
+        session.add(ti)
+        return ti
+
+    def add_nested(parent: DynamicRegion, iteration: int, node_id: str = 
"mapped") -> DynamicRegion:
+        return add_region(
+            session,
+            mapped.dag_run,
+            node_id=node_id,
+            parent_region_id=parent.id,
+            parent_region_index=iteration,
+        )
+
+    nested_in_first_pass = add_nested(regions[1], 0)
+    rows = {
+        "direct 0": add_row(regions[0], 0),
+        "direct 1": add_row(regions[1], 1),
+        "direct 2": add_row(regions[2], 2),
+        "direct 3": add_row(regions[2], 3),
+        "nested 0": add_row(nested_in_first_pass),
+        "nested 2": add_row(add_nested(regions[0], 2)),
+        "nested 3": add_row(add_nested(regions[2], 3)),
+        "deep 0": add_row(add_nested(nested_in_first_pass, 0, "deep")),
+        "other loop": add_row(add_nested(other_loop, 2)),
+        "outside": add_row(add_region(session, mapped.dag_run, 
node_id="outside")),
+    }
+    session.flush()
+    return regions, {name: ti.id for name, ti in rows.items()}
+
+
[email protected](
+    ("iteration", "expected"),
+    [
+        pytest.param(
+            None,
+            ["direct 0", "direct 1", "direct 2", "direct 3", "nested 0", 
"nested 2", "nested 3", "deep 0"],
+        ),
+        pytest.param(0, ["direct 0", "nested 0", "deep 0"]),
+        pytest.param(2, ["direct 2", "nested 2"]),
+        pytest.param(3, ["direct 3", "nested 3"]),
+    ],
+)
+def test_loop_family_legs_select_every_member_and_everything_nested_once(
+    family_rows, session, iteration, expected
+):
+    regions, rows = family_rows
+
+    query = select(TaskInstance.id).where(TaskInstance.task_id == "mapped")
+    legs = build_loop_family_legs(query, regions[1], iteration=iteration)
+
+    assert sorted(session.scalars(union_all(*legs))) == sorted(rows[name] for 
name in expected)
+
+
 def test_get_or_create_returns_the_region_that_filled_the_slot(regional_tis, 
session):
     run, _ = regional_tis
     slot = {"dag_id": run.dag_id, "run_id": run.run_id, "node_id": "mapped", 
"session": session}
@@ -653,6 +882,8 @@ def 
test_get_or_create_finds_a_region_created_before_slot_keys_existed(regional_
             dag_id=run.dag_id,
             run_id=run.run_id,
             node_id="legacy",
+            family_root_id=region_id,
+            path=b"",
             resumes_from_index=0,
             created_at=timezone.utcnow(),
         )
diff --git a/airflow-core/tests/unit/utils/test_db_cleanup.py 
b/airflow-core/tests/unit/utils/test_db_cleanup.py
index 57eeb9c76fd..38daeda56bf 100644
--- a/airflow-core/tests/unit/utils/test_db_cleanup.py
+++ b/airflow-core/tests/unit/utils/test_db_cleanup.py
@@ -1683,6 +1683,8 @@ class TestDBCleanup:
                 dag_id=COORDINATES["dag_id"],
                 run_id=COORDINATES["run_id"],
                 node_id="loop",
+                family_root_id=region_id,
+                path=b"",
                 created_at=NOW - timedelta(days=100),
             )
         )

Reply via email to