Skip to content

Commit 8dd908b

Browse files
committed
Introduce the (internal/db level) concept of "dynamic regions" for TIs
As mentioned in the first docs PR of this stack, and the precursor PR #74222, we need a way of distinguishing between "interation 2" and "iteration 2 after we already cleared and re-ran loop 2 but-these-are-totally-different-TIs, not retries.", or more simply in the digram as `2'` (said as "two prime"). Task loops need the same task_id to exist once per pass, and a clear needs to put replacement task beside the work it has "retired" or superceded. A task instance is identified by dag_id, task_id, run_id and map_index today, so neither is possible: the second row would collide with the first as they both have the same task_id and try_number. A "dynamic region is one execution of a construct that creates task instances at run time: a loop, or the expansion of a mapped task or task group. Each region is a row recording which Dag run it belongs to, which node it executes (node_id, the id of the task group or mapped task), which enclosing region and position contains it and (so that we can have mapped tasks inside loops!), when a clear replaced an earlier execution, which region it was forked from and where it resumes (i.e. "clear iteration 2 onwards"). A region is a row rather than a position (i.e. it's own table, not just a colum on TI) because a position can be reused/"overwritten" after a clear, and would then stop saying which precise TI created the data. Regions are immutable for the same reason; everything that changes stays on the task instances. This change only adds the storage to hopefully make PR creation easier. Nothing creates a region yet, and existing rows take the all-zero sentinel, meaning outside any region, because rewriting existing rows on upgrade was measured at production scale and rejected (4+ hrs! But I never let it run to completion. Even 30mins is too long). XCom and rendered fields are keyed by attempt UUID since the precursor ownership PR and so don't need a region column. `region_index` is an ORM synonym of `map_index` for now. Later changes will build upon this to make mapped expansions and loops create regions, resolve and write task data by (region_id, region_index), add the task loop gate that decides whether a loop runs another pass/iteration, and let a clear replace only the selected work by forking a region instead of rewriting history. Downgrade refuses when rows differ only by region, because dropping the column would otherwise merge distinct executions. The check scans task_instance, so expect it to be slow on very large installations.
1 parent 2e45493 commit 8dd908b

28 files changed

Lines changed: 1002 additions & 57 deletions

File tree

‎airflow-core/docs/administration-and-deployment/task-and-asset-state-store.rst‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,9 @@ Example skeleton:
203203
class S3StateBackend(BaseStoreBackend):
204204
205205
def _task_ref(self, scope: TaskScope, key: str) -> str:
206-
return f"airflow/task-store/{scope.dag_id}/{scope.run_id}/{scope.task_id}/{scope.map_index}/{key}"
206+
# Two regions can hold the same task_id and map_index, so region_id must be part of the ref.
207+
region = f"region={scope.region_id}/" if scope.region_id.int else ""
208+
return f"airflow/task-store/{scope.dag_id}/{scope.run_id}/{region}{scope.task_id}/{scope.map_index}/{key}"
207209
208210
def _asset_ref(self, scope: AssetScope, key: str) -> str:
209211
import hashlib

‎airflow-core/docs/core-concepts/task-and-asset-state-store.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Task and Asset state store provide two key/value stores to persist data like a j
3838
- Default lifetime
3939
- Primary use case
4040
* - **Task state store**
41-
- A single task Instance (``dag_id`` + ``run_id`` + ``task_id`` + ``map_index``)
41+
- A single task Instance (``dag_id`` + ``run_id`` + ``task_id`` + ``region_id`` + ``map_index``)
4242
- Configurable retention; cleared on task success when ``clear_on_success = True``
4343
- Survive retries, track in-flight jobs, checkpoint progress within a run, resume progress from checkpoint set by a past run
4444
* - **Asset state store**

‎airflow-core/docs/core-concepts/task-state-store.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Task State Store
3030

3131
.. versionadded:: 3.3
3232

33-
Task store is a persistent key/value store scoped to a single task instance (``dag_id`` + ``run_id`` + ``task_id`` + ``map_index``). It survives worker crashes and task retries within the same Dag run, making it suitable for storing external job IDs, intra-task checkpoints, and progress metadata.
33+
Task store is a persistent key/value store scoped to a single task instance (``dag_id`` + ``run_id`` + ``task_id`` + ``region_id`` + ``map_index``). It survives worker crashes and task retries within the same Dag run, making it suitable for storing external job IDs, intra-task checkpoints, and progress metadata.
3434

3535
Because it outlives a worker crash and stays readable by the next attempt, the task state store is the mechanism behind :ref:`durable execution <concepts-durable-execution>`, where a task continues
3636
from where it stopped instead of repeating work or submitting a duplicate external job. Provider operators that advertise a ``durable`` parameter are built on the API described here.

‎airflow-core/docs/migrations-ref.rst‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ Here's the list of all the Database Migrations that are executed via when you ru
3939
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
4040
| Revision ID | Revises ID | Airflow Version | Description |
4141
+=========================+==================+===================+==============================================================+
42-
| ``e7c2a91bd540`` (head) | ``90e4d18ccadf`` | ``3.4.0`` | Unify task attempt ownership without rewriting legacy XCom |
42+
| ``54a27b6f9d01`` (head) | ``e7c2a91bd540`` | ``3.4.0`` | Add dynamic region storage. |
43+
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
44+
| ``e7c2a91bd540`` | ``90e4d18ccadf`` | ``3.4.0`` | Unify task attempt ownership without rewriting legacy XCom |
4345
| | | | data. |
4446
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
4547
| ``90e4d18ccadf`` | ``e5a91c7f42b3`` | ``3.4.0`` | Add timetable_asset_gated to DagModel. |

‎airflow-core/src/airflow/migrations/utils.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import contextlib
2020
from contextlib import contextmanager
21+
from textwrap import dedent
2122

2223

2324
@contextmanager
@@ -55,6 +56,58 @@ def mysql_drop_foreignkey_if_exists(constraint_name, table_name, op):
5556
""")
5657

5758

59+
def raise_if_rows_exist(select_sql: str, message: str, op) -> None:
60+
"""
61+
Abort the migration when ``select_sql`` returns a row, using SQL so it also runs offline.
62+
63+
Call it before any DDL: MySQL DDL is not transactional, so a late failure leaves a half-applied
64+
migration. ``message`` must not contain single quotes or percent signs and, for MySQL, must be at
65+
most 128 characters. PostgreSQL and MySQL only.
66+
"""
67+
dialect = op.get_context().dialect.name
68+
if dialect == "postgresql":
69+
op.execute(
70+
dedent("""
71+
DO $$
72+
BEGIN
73+
IF EXISTS ({select_sql}) THEN
74+
RAISE EXCEPTION '{message}';
75+
END IF;
76+
END $$
77+
""").format(select_sql=select_sql, message=message)
78+
)
79+
elif dialect == "mysql":
80+
create = (
81+
dedent("""
82+
CREATE PROCEDURE AirflowMigrationGuard()
83+
BEGIN
84+
IF EXISTS ({select_sql}) THEN
85+
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '{message}';
86+
END IF;
87+
END
88+
""")
89+
.strip()
90+
.format(select_sql=select_sql, message=message)
91+
)
92+
context = op.get_context()
93+
if context.as_sql:
94+
# The mysql client splits on ';' unless the procedure body is fenced by DELIMITER.
95+
context.output_buffer.write(
96+
"DROP PROCEDURE IF EXISTS AirflowMigrationGuard;\n"
97+
f"DELIMITER //\n{create}//\nDELIMITER ;\n"
98+
"CALL AirflowMigrationGuard();\nDROP PROCEDURE AirflowMigrationGuard;\n\n"
99+
)
100+
else:
101+
op.execute("DROP PROCEDURE IF EXISTS AirflowMigrationGuard")
102+
op.execute(create)
103+
try:
104+
op.execute("CALL AirflowMigrationGuard()")
105+
finally:
106+
op.execute("DROP PROCEDURE AirflowMigrationGuard")
107+
else:
108+
raise NotImplementedError(f"No SQL guard for dialect {dialect}")
109+
110+
58111
def ignore_sqlite_value_error():
59112
from alembic import op
60113

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
"""
19+
Add dynamic region storage.
20+
21+
Revision ID: 54a27b6f9d01
22+
Revises: e7c2a91bd540
23+
Create Date: 2026-09-27 20:00:00.000000
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from textwrap import dedent
29+
from uuid import UUID
30+
31+
import sqlalchemy as sa
32+
from alembic import op
33+
34+
from airflow.migrations.utils import raise_if_rows_exist
35+
from airflow.models.base import StringID
36+
from airflow.utils.sqlalchemy import CompactUUID, UtcDateTime, compact_uuid_default
37+
38+
revision = "54a27b6f9d01"
39+
down_revision = "e7c2a91bd540"
40+
branch_labels = None
41+
depends_on = None
42+
airflow_version = "3.4.0"
43+
44+
_SENTINEL = UUID(int=0)
45+
_KEYS = (
46+
(
47+
"task_instance",
48+
"task_instance_current_key",
49+
("dag_id", "task_id", "run_id", "map_index", "working_set"),
50+
"working_set IS NOT NULL",
51+
),
52+
(
53+
"task_instance",
54+
"task_instance_try_key",
55+
("dag_id", "task_id", "run_id", "map_index", "try_number"),
56+
None,
57+
),
58+
("task_state_store", "task_state_store_uq", ("dag_run_id", "task_id", "map_index", "key"), None),
59+
)
60+
61+
62+
def _replace_unique(table_name, constraint_name, columns):
63+
context = op.get_context()
64+
dialect = context.dialect.name
65+
if dialect in ("postgresql", "mysql"):
66+
quote = context.dialect.identifier_preparer.quote
67+
column_list = ", ".join(quote(name) for name in columns)
68+
if dialect == "postgresql":
69+
sql = f"""
70+
ALTER TABLE {table_name}
71+
DROP CONSTRAINT {constraint_name},
72+
ADD CONSTRAINT {constraint_name} UNIQUE ({column_list})
73+
"""
74+
else:
75+
sql = f"""
76+
ALTER TABLE {table_name}
77+
DROP INDEX {constraint_name},
78+
ADD UNIQUE KEY {constraint_name} ({column_list})
79+
"""
80+
op.execute(dedent(sql))
81+
else:
82+
with op.get_context().autocommit_block():
83+
foreign_keys = op.get_bind().exec_driver_sql("PRAGMA foreign_keys").scalar_one()
84+
op.execute("PRAGMA foreign_keys=OFF")
85+
try:
86+
with op.batch_alter_table(table_name) as batch_op:
87+
batch_op.drop_constraint(constraint_name, type_="unique")
88+
batch_op.create_unique_constraint(constraint_name, columns)
89+
finally:
90+
op.execute(f"PRAGMA foreign_keys={foreign_keys}")
91+
92+
93+
def _build_collision_query(table_name, columns, where):
94+
"""Select old-key groups holding more than one row, scanning only DagRuns that have regions."""
95+
table = sa.table(table_name, *(sa.column(name) for name in columns))
96+
region = sa.table("dynamic_region", sa.column("dag_id"), sa.column("run_id"))
97+
if table_name == "task_state_store":
98+
dag_run = sa.table("dag_run", sa.column("id"), sa.column("dag_id"), sa.column("run_id"))
99+
has_region = (
100+
sa.select(1)
101+
.select_from(
102+
dag_run.join(
103+
region, sa.and_(region.c.dag_id == dag_run.c.dag_id, region.c.run_id == dag_run.c.run_id)
104+
)
105+
)
106+
.where(dag_run.c.id == table.c.dag_run_id)
107+
.exists()
108+
)
109+
else:
110+
has_region = (
111+
sa.select(1)
112+
.select_from(region)
113+
.where(region.c.dag_id == table.c.dag_id, region.c.run_id == table.c.run_id)
114+
.exists()
115+
)
116+
query = sa.select(1).select_from(table).where(has_region)
117+
if where:
118+
query = query.where(sa.text(where))
119+
return query.group_by(*(table.c[name] for name in columns)).having(sa.func.count() > 1).limit(1)
120+
121+
122+
def _assert_downgrade_is_lossless():
123+
"""
124+
Abort the downgrade if dropping ``region_id`` would merge rows, before any DDL runs.
125+
126+
MySQL DDL cannot be rolled back, so every check has to happen first. The SQL guard also works
127+
in offline mode but does not support SQLite, which is checked by running the query directly.
128+
The query scans the whole ``task_instance`` table, so expect it to be slow on very large installations.
129+
"""
130+
dialect = op.get_context().dialect
131+
for table_name, _, columns, where in _KEYS:
132+
query = _build_collision_query(table_name, columns, where)
133+
message = (
134+
f"Cannot downgrade: {table_name} has region rows colliding on the old key. "
135+
"Delete the affected DagRuns first."
136+
)
137+
if dialect.name == "sqlite":
138+
if op.get_bind().execute(query).scalar_one_or_none() is not None:
139+
raise RuntimeError(message)
140+
else:
141+
sql = str(query.compile(dialect=dialect, compile_kwargs={"literal_binds": True}))
142+
raise_if_rows_exist(sql, message, op)
143+
144+
145+
def _configure_index_builds():
146+
if op.get_bind().dialect.name == "postgresql":
147+
op.execute("SET LOCAL statement_timeout = 0")
148+
op.execute(
149+
"SELECT set_config('maintenance_work_mem', '256MB', true) "
150+
"WHERE (SELECT setting::bigint FROM pg_settings WHERE name = 'maintenance_work_mem') < 262144"
151+
)
152+
153+
154+
def upgrade():
155+
_configure_index_builds()
156+
op.create_table(
157+
"dynamic_region",
158+
sa.Column("id", CompactUUID(), nullable=False),
159+
sa.Column("dag_id", StringID(), nullable=False),
160+
sa.Column("run_id", StringID(), nullable=False),
161+
sa.Column("node_id", StringID(), nullable=False),
162+
sa.Column("parent_region_id", CompactUUID(), nullable=True),
163+
sa.Column("parent_region_index", sa.Integer(), nullable=True),
164+
sa.Column("forked_from_region_id", CompactUUID(), nullable=True),
165+
sa.Column("resumes_from_index", sa.Integer(), nullable=False, server_default="0"),
166+
sa.Column("created_at", UtcDateTime(), nullable=False),
167+
sa.PrimaryKeyConstraint("id", name="dynamic_region_pkey"),
168+
sa.ForeignKeyConstraint(
169+
["dag_id", "run_id"],
170+
["dag_run.dag_id", "dag_run.run_id"],
171+
name="dynamic_region_dag_run_fkey",
172+
ondelete="CASCADE",
173+
),
174+
sa.ForeignKeyConstraint(
175+
["parent_region_id"],
176+
["dynamic_region.id"],
177+
name="dynamic_region_parent_region_id_fkey",
178+
ondelete="CASCADE",
179+
),
180+
# Fork lineage is unbounded; a cascading self-FK would exceed MySQL's cascade depth limit.
181+
sa.UniqueConstraint("forked_from_region_id", name="dynamic_region_forked_from_region_id_uq"),
182+
sa.CheckConstraint(
183+
"(parent_region_id IS NULL AND parent_region_index IS NULL) OR "
184+
"(parent_region_id IS NOT NULL AND parent_region_index IS NOT NULL)",
185+
name="parent_coordinates_paired",
186+
),
187+
sa.CheckConstraint("resumes_from_index >= 0", name="resumes_from_index_nonnegative"),
188+
)
189+
op.create_index(
190+
"idx_dynamic_region_slot",
191+
"dynamic_region",
192+
["dag_id", "run_id", "node_id", "parent_region_id", "parent_region_index"],
193+
)
194+
op.create_index("idx_dynamic_region_parent_region_id", "dynamic_region", ["parent_region_id"])
195+
for table_name in ("task_instance", "task_state_store"):
196+
op.add_column(
197+
table_name,
198+
sa.Column(
199+
"region_id", CompactUUID(), nullable=False, server_default=compact_uuid_default(_SENTINEL)
200+
),
201+
)
202+
for table_name, constraint_name, columns, _ in _KEYS:
203+
new_columns = list(columns)
204+
new_columns.insert(new_columns.index("map_index"), "region_id")
205+
_replace_unique(table_name, constraint_name, new_columns)
206+
207+
208+
def downgrade():
209+
_configure_index_builds()
210+
_assert_downgrade_is_lossless()
211+
for table_name, constraint_name, columns, _ in _KEYS:
212+
_replace_unique(table_name, constraint_name, list(columns))
213+
for table_name in ("task_instance", "task_state_store"):
214+
op.drop_column(table_name, "region_id")
215+
op.drop_table("dynamic_region")

‎airflow-core/src/airflow/models/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def import_all_models():
6969
import airflow.models.dagbundle
7070
import airflow.models.dagwarning
7171
import airflow.models.deadline_alert
72+
import airflow.models.dynamic_region
7273
import airflow.models.errors
7374
import airflow.models.revoked_token
7475
import airflow.models.serialized_dag

‎airflow-core/src/airflow/models/dagrun.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
from airflow.models.backfill import Backfill
8989
from airflow.models.base import Base, StringID
9090
from airflow.models.deadline_alert import DeadlineAlert as DeadlineAlertModel
91+
from airflow.models.dynamic_region import SENTINEL_REGION_ID
9192
from airflow.models.taskinstance import TaskInstance as TI, _add_and_prime_mapped_ti, clear_task_instances
9293
from airflow.models.tasklog import LogTemplate
9394
from airflow.serialization.definitions.deadline import SerializedReferenceModels
@@ -2180,6 +2181,7 @@ def _revise_map_indexes_if_mapped(
21802181
TI.dag_id == self.dag_id,
21812182
TI.task_id == task.task_id,
21822183
TI.run_id == self.run_id,
2184+
TI.region_id == SENTINEL_REGION_ID,
21832185
)
21842186
)
21852187
existing_indexes = set(query)
@@ -2192,6 +2194,7 @@ def _revise_map_indexes_if_mapped(
21922194
TI.dag_id == self.dag_id,
21932195
TI.task_id == task.task_id,
21942196
TI.run_id == self.run_id,
2197+
TI.region_id == SENTINEL_REGION_ID,
21952198
TI.map_index.in_(removed_indexes),
21962199
)
21972200
.values(state=TaskInstanceState.REMOVED)
@@ -2206,6 +2209,7 @@ def _revise_map_indexes_if_mapped(
22062209
task_id=task.task_id,
22072210
run_id=self.run_id,
22082211
map_indexes=missing_indexes,
2212+
region_id=SENTINEL_REGION_ID,
22092213
session=session,
22102214
)
22112215

0 commit comments

Comments
 (0)