Skip to content

Commit 30c3b24

Browse files
committed
fixup! Introduce the (internal/db level) concept of "dynamic regions" for TIs
Normalise MySQL's reflected expression defaults in compare_server_default. MySQL 8 reports the task_instance.region_id and task_state_store.region_id defaults through information_schema as unhex(_utf8mb4\'00000000000000000000000000000000\'), with escaped quotes and a charset introducer, while the model default renders as UNHEX('00000000000000000000000000000000'). The two never compared equal, so Alembic autogenerate reported a modify_default for both columns and test_has_pending_upgrade_ops failed on MySQL. Strip the escaping and the introducer in _normalize_mysql_server_default so both spellings compare equal. Migration 0143 and the model default are unchanged. MariaDB is expected to reflect the plain unhex('...') form, which already normalised equal.
1 parent 6fb8d60 commit 30c3b24

8 files changed

Lines changed: 123 additions & 89 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Task State Store
3232

3333
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

35+
``region_id`` is the all-zero UUID for task instances outside any dynamic region. Task instances created by dynamically expanded work, such as a loop, belong to a region, and two of them can share the same ``dag_id``, ``run_id``, ``task_id`` and ``map_index``. A custom backend must therefore include ``region_id`` in its uniqueness key.
36+
3537
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
3638
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.
3739

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@ def disable_sqlite_fkeys(op):
3131
yield op
3232

3333

34+
# Unlike disable_sqlite_fkeys, a failed SQLite upgrade rolls back atomically and foreign_keys is always restored.
35+
@contextmanager
36+
def sqlite_rebuilds(op):
37+
if op.get_bind().dialect.name != "sqlite":
38+
yield
39+
return
40+
if op.get_context().as_sql:
41+
raise RuntimeError("SQLite offline SQL cannot render this migration's table rebuilds")
42+
enabled = op.get_bind().exec_driver_sql("PRAGMA foreign_keys").scalar()
43+
with op.get_context().autocommit_block():
44+
op.execute("PRAGMA foreign_keys=OFF")
45+
try:
46+
with op.get_bind().begin_nested():
47+
yield
48+
finally:
49+
with op.get_context().autocommit_block():
50+
op.execute(f"PRAGMA foreign_keys={int(enabled)}")
51+
52+
3453
def mysql_drop_foreignkey_if_exists(constraint_name, table_name, op):
3554
"""Older Mysql versions do not support DROP FOREIGN KEY IF EXISTS."""
3655
op.execute(f"""

‎airflow-core/src/airflow/migrations/versions/0142_3_4_0_unify_task_attempt_ownership.py‎

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,12 @@
2626

2727
from __future__ import annotations
2828

29-
from contextlib import contextmanager
30-
3129
import sqlalchemy as sa
3230
from alembic import op
3331
from sqlalchemy.dialects import postgresql, sqlite
3432

3533
from airflow.migrations.db_types import TIMESTAMP, StringID
34+
from airflow.migrations.utils import sqlite_rebuilds
3635
from airflow.utils.sqlalchemy import ExecutorConfigType, ExtendedJSON, UtcDateTime
3736

3837
revision = "e7c2a91bd540"
@@ -99,25 +98,6 @@
9998
)
10099

101100

102-
# Unlike disable_sqlite_fkeys, a failed SQLite upgrade rolls back atomically and foreign_keys is always restored.
103-
@contextmanager
104-
def _sqlite_rebuilds():
105-
if op.get_bind().dialect.name != "sqlite":
106-
yield
107-
return
108-
if op.get_context().as_sql:
109-
raise RuntimeError("SQLite offline SQL cannot render this migration's table rebuilds")
110-
enabled = op.get_bind().exec_driver_sql("PRAGMA foreign_keys").scalar()
111-
with op.get_context().autocommit_block():
112-
op.execute("PRAGMA foreign_keys=OFF")
113-
try:
114-
with op.get_bind().begin_nested():
115-
yield
116-
finally:
117-
with op.get_context().autocommit_block():
118-
op.execute(f"PRAGMA foreign_keys={int(enabled)}")
119-
120-
121101
def _check_source():
122102
bind = op.get_bind()
123103
if op.get_context().as_sql:
@@ -190,7 +170,7 @@ def _redirect_legacy(table, constraint, target, *, onupdate=None, not_valid=Fals
190170
def upgrade():
191171
"""Retain attempts and give legacy and new task data immutable UUID owners."""
192172
_check_source()
193-
with _sqlite_rebuilds():
173+
with sqlite_rebuilds(op):
194174
owner = op.create_table(
195175
"legacy_task_data_owner",
196176
sa.Column("dag_id", StringID(), nullable=False),
@@ -387,7 +367,7 @@ def downgrade():
387367
)
388368
):
389369
raise RuntimeError("Cannot downgrade attempt ownership after legacy owners changed coordinates")
390-
with _sqlite_rebuilds():
370+
with sqlite_rebuilds(op):
391371
op.drop_table("rtif_v2")
392372
op.drop_table("xcom_v2")
393373
op.drop_index("ti_current_state", table_name="task_instance")

‎airflow-core/src/airflow/migrations/versions/0143_3_4_0_add_dynamic_region_storage.py‎

Lines changed: 59 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
import sqlalchemy as sa
3232
from alembic import op
3333

34-
from airflow.migrations.utils import raise_if_rows_exist
34+
from airflow.migrations.utils import raise_if_rows_exist, sqlite_rebuilds
3535
from airflow.models.base import StringID
3636
from airflow.utils.sqlalchemy import CompactUUID, UtcDateTime, compact_uuid_default
3737

@@ -79,15 +79,9 @@ def _replace_unique(table_name, constraint_name, columns):
7979
"""
8080
op.execute(dedent(sql))
8181
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}")
82+
with op.batch_alter_table(table_name) as batch_op:
83+
batch_op.drop_constraint(constraint_name, type_="unique")
84+
batch_op.create_unique_constraint(constraint_name, columns)
9185

9286

9387
def _build_collision_query(table_name, columns, where):
@@ -153,63 +147,65 @@ def _configure_index_builds():
153147

154148
def upgrade():
155149
_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)
150+
with sqlite_rebuilds(op):
151+
op.create_table(
152+
"dynamic_region",
153+
sa.Column("id", CompactUUID(), nullable=False),
154+
sa.Column("dag_id", StringID(), nullable=False),
155+
sa.Column("run_id", StringID(), nullable=False),
156+
sa.Column("node_id", StringID(), nullable=False),
157+
sa.Column("parent_region_id", CompactUUID(), nullable=True),
158+
sa.Column("parent_region_index", sa.Integer(), nullable=True),
159+
sa.Column("forked_from_region_id", CompactUUID(), nullable=True),
160+
sa.Column("resumes_from_index", sa.Integer(), nullable=False, server_default="0"),
161+
sa.Column("created_at", UtcDateTime(), nullable=False),
162+
sa.PrimaryKeyConstraint("id", name="dynamic_region_pkey"),
163+
sa.ForeignKeyConstraint(
164+
["dag_id", "run_id"],
165+
["dag_run.dag_id", "dag_run.run_id"],
166+
name="dynamic_region_dag_run_fkey",
167+
ondelete="CASCADE",
200168
),
169+
sa.ForeignKeyConstraint(
170+
["parent_region_id"],
171+
["dynamic_region.id"],
172+
name="dynamic_region_parent_region_id_fkey",
173+
ondelete="CASCADE",
174+
),
175+
# Fork lineage is unbounded; a cascading self-FK would exceed MySQL's cascade depth limit.
176+
sa.UniqueConstraint("forked_from_region_id", name="dynamic_region_forked_from_region_id_uq"),
177+
sa.CheckConstraint(
178+
"(parent_region_id IS NULL AND parent_region_index IS NULL) OR "
179+
"(parent_region_id IS NOT NULL AND parent_region_index IS NOT NULL)",
180+
name="parent_coordinates_paired",
181+
),
182+
sa.CheckConstraint("resumes_from_index >= 0", name="resumes_from_index_nonnegative"),
201183
)
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)
184+
op.create_index(
185+
"idx_dynamic_region_slot",
186+
"dynamic_region",
187+
["dag_id", "run_id", "node_id", "parent_region_id", "parent_region_index"],
188+
)
189+
op.create_index("idx_dynamic_region_parent_region_id", "dynamic_region", ["parent_region_id"])
190+
for table_name in ("task_instance", "task_state_store"):
191+
op.add_column(
192+
table_name,
193+
sa.Column(
194+
"region_id", CompactUUID(), nullable=False, server_default=compact_uuid_default(_SENTINEL)
195+
),
196+
)
197+
for table_name, constraint_name, columns, _ in _KEYS:
198+
new_columns = list(columns)
199+
new_columns.insert(new_columns.index("map_index"), "region_id")
200+
_replace_unique(table_name, constraint_name, new_columns)
206201

207202

208203
def downgrade():
209204
_configure_index_builds()
210205
_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")
206+
with sqlite_rebuilds(op):
207+
for table_name, constraint_name, columns, _ in _KEYS:
208+
_replace_unique(table_name, constraint_name, list(columns))
209+
for table_name in ("task_instance", "task_state_store"):
210+
op.drop_column(table_name, "region_id")
211+
op.drop_table("dynamic_region")

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1760,6 +1760,9 @@ def _normalize_mysql_server_default(default: Any) -> str | None:
17601760
return None
17611761

17621762
normalized = str(default).strip().strip("'\"").lower()
1763+
# MySQL reflects expression defaults with escaped quotes and a charset introducer,
1764+
# e.g. unhex(_utf8mb4\'00ff\') for a model default of UNHEX('00ff').
1765+
normalized = re.sub(r"\b_[a-z0-9]+(?=')", "", normalized.replace("\\'", "'"))
17631766
while normalized.startswith("(") and normalized.endswith(")"):
17641767
normalized = normalized[1:-1].strip()
17651768
normalized = normalized.replace(" ", "")

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,9 @@ def _compact_uuid_default(element, compiler, **kw):
341341
@compiles(compact_uuid_default, "mysql")
342342
@compiles(compact_uuid_default, "mariadb")
343343
def _compact_uuid_default_mysql(element, compiler, **kw):
344-
return f"UNHEX({compiler.render_literal_value(element.value.hex, String())})"
344+
# SQLAlchemy only parenthesizes expression defaults when it knows the server version, which offline SQL
345+
# generation (--show-sql-only) does not; MySQL rejects an unparenthesized expression default.
346+
return f"(UNHEX({compiler.render_literal_value(element.value.hex, String())}))"
345347

346348

347349
class ExtendedJSON(TypeDecorator):

‎airflow-core/tests/unit/utils/test_db.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
LazySelectSequence,
4141
_get_alembic_config,
4242
_get_current_revision,
43+
_normalize_mysql_server_default,
4344
check_migrations,
4445
check_team_names_can_be_lower_cased,
4546
compare_server_default,
@@ -227,6 +228,25 @@ def test_has_pending_upgrade_ops(self, initialized_db):
227228
config = _get_alembic_config()
228229
check(config)
229230

231+
@pytest.mark.parametrize(
232+
("reflected", "rendered"),
233+
[
234+
pytest.param(
235+
"unhex(_utf8mb4\\'00000000000000000000000000000000\\')",
236+
"UNHEX('00000000000000000000000000000000')",
237+
id="mysql8-charset-introducer-and-escaped-quotes",
238+
),
239+
pytest.param(
240+
"unhex('00000000000000000000000000000000')",
241+
"UNHEX('00000000000000000000000000000000')",
242+
id="plain-expression",
243+
),
244+
pytest.param("(0)", "false", id="boolean"),
245+
],
246+
)
247+
def test_normalize_mysql_server_default_matches_rendered_default(self, reflected, rendered):
248+
assert _normalize_mysql_server_default(reflected) == _normalize_mysql_server_default(rendered)
249+
230250
def test_default_connections_sort(self):
231251
conn_ids = [c.conn_id for c in get_default_connections()]
232252
assert conn_ids

‎airflow-core/tests/unit/utils/test_sqlalchemy.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@
2525

2626
import pytest
2727
from kubernetes.client import Configuration, models as k8s
28-
from sqlalchemy import text
28+
from sqlalchemy import Column, MetaData, Table, text
2929
from sqlalchemy.dialects import mysql, postgresql, sqlite
3030
from sqlalchemy.exc import StatementError
31+
from sqlalchemy.schema import CreateTable
3132

3233
from airflow import settings
3334
from airflow.sdk import DAG
@@ -484,10 +485,21 @@ def test_none_passes_through(self):
484485
@pytest.mark.parametrize(
485486
("dialect", "expected"),
486487
[
487-
pytest.param(mysql.dialect(), "UNHEX('0190a1b2c3d47e5f8a9b0c1d2e3f4a5b')", id="mysql"),
488+
pytest.param(mysql.dialect(), "(UNHEX('0190a1b2c3d47e5f8a9b0c1d2e3f4a5b'))", id="mysql"),
488489
pytest.param(sqlite.dialect(), "'0190a1b2c3d47e5f8a9b0c1d2e3f4a5b'", id="sqlite"),
489490
pytest.param(postgresql.dialect(), "'0190a1b2c3d47e5f8a9b0c1d2e3f4a5b'", id="postgresql"),
490491
],
491492
)
492493
def test_server_default_per_dialect(self, dialect, expected):
493494
assert str(compact_uuid_default(self.value).compile(dialect=dialect)) == expected
495+
496+
def test_mysql_column_default_is_parenthesized_without_a_server_version(self):
497+
table = Table(
498+
"t",
499+
MetaData(),
500+
Column(
501+
"region_id", CompactUUID(), nullable=False, server_default=compact_uuid_default(self.value)
502+
),
503+
)
504+
ddl = str(CreateTable(table).compile(dialect=mysql.dialect()))
505+
assert "DEFAULT (UNHEX('0190a1b2c3d47e5f8a9b0c1d2e3f4a5b'))" in ddl

0 commit comments

Comments
 (0)