Skip to content

Commit 0cf6462

Browse files
authored
AIP-103: Adding periodic task state garbage collection and retention support (#66463)
1 parent 50fa403 commit 0cf6462

10 files changed

Lines changed: 421 additions & 12 deletions

File tree

airflow-core/src/airflow/cli/cli_config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1531,6 +1531,20 @@ class GroupCommand(NamedTuple):
15311531
args=(ARG_VERBOSE,),
15321532
),
15331533
)
1534+
STATE_STORE_COMMANDS = (
1535+
ActionCommand(
1536+
name="cleanup-task-states",
1537+
help="Remove expired task state rows (MetastoreStateBackend only)",
1538+
description=(
1539+
"Reads [state_store] default_retention_days from config and deletes task_state rows "
1540+
"older than the configured threshold. Only applies when MetastoreStateBackend is configured; "
1541+
"custom backends are skipped. Use --dry-run to preview without deleting."
1542+
),
1543+
func=lazy_load_command("airflow.cli.commands.state_store_command.cleanup_task_states"),
1544+
args=(ARG_DB_DRY_RUN, ARG_VERBOSE),
1545+
),
1546+
)
1547+
15341548
DB_COMMANDS = (
15351549
ActionCommand(
15361550
name="check-migrations",
@@ -2115,6 +2129,11 @@ class GroupCommand(NamedTuple):
21152129
help="Display providers",
21162130
subcommands=PROVIDERS_COMMANDS,
21172131
),
2132+
GroupCommand(
2133+
name="state-store",
2134+
help="Manage task and asset state storage",
2135+
subcommands=STATE_STORE_COMMANDS,
2136+
),
21182137
ActionCommand(
21192138
name="rotate-fernet-key",
21202139
func=lazy_load_command("airflow.cli.commands.rotate_fernet_key_command.rotate_fernet_key"),
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
from __future__ import annotations
18+
19+
import logging
20+
21+
from airflow.state import get_state_backend
22+
from airflow.state.metastore import MetastoreStateBackend
23+
24+
log = logging.getLogger(__name__)
25+
26+
# Other state operations (list, get, delete per key) will be added here in the future.
27+
28+
29+
def cleanup_task_states(args) -> None:
30+
"""Remove expired task state rows (MetastoreStateBackend only)."""
31+
backend = get_state_backend()
32+
33+
if not isinstance(backend, MetastoreStateBackend):
34+
print("Custom backend configured — skipping cleanup (not supported).")
35+
return
36+
37+
if args.dry_run:
38+
summary = backend._summary_dry_run()
39+
expired = summary["expired"]
40+
if not expired:
41+
print("Nothing to delete.")
42+
return
43+
print(f"Would delete {len(expired)} task state row(s):\n")
44+
for dag_id, run_id, task_id, map_index, key in expired:
45+
print(f" Dag {dag_id!r}, run {run_id!r}, task {task_id!r}, map_index {map_index!r}, key {key!r}")
46+
return
47+
48+
log.info("Running task state cleanup")
49+
backend.cleanup()

airflow-core/src/airflow/config_templates/config.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3025,6 +3025,24 @@ state_store:
30253025
type: string
30263026
example: "mypackage.state.CustomStateBackend"
30273027
default: "airflow.state.metastore.MetastoreStateBackend"
3028+
default_retention_days:
3029+
description: |
3030+
Number of days to retain task state after their last update.
3031+
Rows older than this are removed when cleanup is triggered.
3032+
This config does not affect asset_state rows.
3033+
Set to 0 to disable time-based cleanup entirely.
3034+
version_added: 3.3.0
3035+
type: integer
3036+
example: "7"
3037+
default: "30"
3038+
state_cleanup_batch_size:
3039+
description: |
3040+
Number of rows deleted per batch during cleanup. Defaults to 0 (no batching).
3041+
Tune this on deployments with large task_state tables to improve performance per transaction.
3042+
version_added: 3.3.0
3043+
type: integer
3044+
example: "10000"
3045+
default: "0"
30283046

30293047
profiling:
30303048
description: |

airflow-core/src/airflow/jobs/scheduler_job_runner.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,20 @@
3333
from itertools import groupby
3434
from typing import TYPE_CHECKING, Any, cast
3535

36-
from sqlalchemy import CTE, and_, case, delete, exists, func, inspect, or_, select, text, tuple_, update
36+
from sqlalchemy import (
37+
CTE,
38+
and_,
39+
case,
40+
delete,
41+
exists,
42+
func,
43+
inspect,
44+
or_,
45+
select,
46+
text,
47+
tuple_,
48+
update,
49+
)
3750
from sqlalchemy.exc import DBAPIError, OperationalError
3851
from sqlalchemy.orm import joinedload, lazyload, load_only, make_transient, selectinload
3952
from sqlalchemy.sql import expression
@@ -70,6 +83,7 @@
7083
TaskInletAssetReference,
7184
TaskOutletAssetReference,
7285
)
86+
from airflow.models.asset_state import AssetStateModel
7387
from airflow.models.backfill import Backfill, BackfillDagRun
7488
from airflow.models.callback import Callback, CallbackType, ExecutorCallback
7589
from airflow.models.dag import DagModel
@@ -3096,6 +3110,7 @@ def _update_asset_orphanage(self, session: Session = NEW_SESSION) -> None:
30963110

30973111
self._orphan_unreferenced_assets(orphan_query, session=session)
30983112
self._activate_referenced_assets(activate_query, session=session)
3113+
self._cleanup_orphaned_asset_state(session=session)
30993114

31003115
@staticmethod
31013116
def _orphan_unreferenced_assets(assets_query: CTE, *, session: Session) -> None:
@@ -3204,6 +3219,21 @@ def _activate_assets_generate_warnings() -> Iterator[tuple[str, str]]:
32043219
session.add(warning)
32053220
existing_warned_dag_ids.add(warning.dag_id)
32063221

3222+
@staticmethod
3223+
def _cleanup_orphaned_asset_state(*, session: Session) -> None:
3224+
"""
3225+
Delete asset_state rows for assets no longer active in any Dag.
3226+
3227+
When _orphan_unreferenced_assets removes an asset from asset_active, its
3228+
asset_state rows become unreachable — no task can write to them anymore.
3229+
This runs in the same pass as asset orphanage to keep the table clean.
3230+
"""
3231+
active_asset_ids = select(AssetModel.id).join(
3232+
AssetActive,
3233+
(AssetActive.name == AssetModel.name) & (AssetActive.uri == AssetModel.uri),
3234+
)
3235+
session.execute(delete(AssetStateModel).where(AssetStateModel.asset_id.not_in(active_asset_ids)))
3236+
32073237
def _executor_to_workloads(
32083238
self,
32093239
workloads: Iterable[SchedulerWorkload],

airflow-core/src/airflow/migrations/versions/0112_3_3_0_add_task_state_and_asset_state_tables.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def upgrade():
5757
)
5858
op.create_table(
5959
"task_state",
60+
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
6061
sa.Column("dag_run_id", sa.Integer(), nullable=False),
6162
sa.Column("task_id", StringID(), nullable=False),
6263
sa.Column("map_index", sa.Integer(), server_default="-1", nullable=False),
@@ -65,20 +66,24 @@ def upgrade():
6566
sa.Column("run_id", StringID(), nullable=False),
6667
sa.Column("value", sa.Text().with_variant(mysql.MEDIUMTEXT(), "mysql"), nullable=False),
6768
sa.Column("updated_at", UtcDateTime(), nullable=False),
69+
sa.Column("expires_at", UtcDateTime(), nullable=True),
6870
sa.ForeignKeyConstraint(
6971
["dag_run_id"], ["dag_run.id"], name="task_state_dag_run_fkey", ondelete="CASCADE"
7072
),
71-
sa.PrimaryKeyConstraint("dag_run_id", "task_id", "map_index", "key", name="task_state_pkey"),
73+
sa.PrimaryKeyConstraint("id", name="task_state_pkey"),
74+
sa.UniqueConstraint("dag_run_id", "task_id", "map_index", "key", name="task_state_uq"),
7275
)
7376
with op.batch_alter_table("task_state", schema=None) as batch_op:
7477
batch_op.create_index(
7578
"idx_task_state_lookup", ["dag_id", "run_id", "task_id", "map_index"], unique=False
7679
)
80+
batch_op.create_index("idx_task_state_expires_at", ["expires_at"], unique=False)
7781

7882

7983
def downgrade():
8084
"""Unapply add task_state and asset_state tables."""
8185
with op.batch_alter_table("task_state", schema=None) as batch_op:
86+
batch_op.drop_index("idx_task_state_expires_at")
8287
batch_op.drop_index("idx_task_state_lookup")
8388

8489
op.drop_table("task_state")

airflow-core/src/airflow/models/task_state.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from datetime import datetime
2121

22-
from sqlalchemy import ForeignKeyConstraint, Index, Integer, PrimaryKeyConstraint, String, Text
22+
from sqlalchemy import ForeignKeyConstraint, Index, Integer, String, Text, UniqueConstraint
2323
from sqlalchemy.dialects.mysql import MEDIUMTEXT
2424
from sqlalchemy.orm import Mapped, mapped_column
2525

@@ -39,24 +39,33 @@ class TaskStateModel(Base):
3939

4040
__tablename__ = "task_state"
4141

42-
dag_run_id: Mapped[int] = mapped_column(Integer, nullable=False, primary_key=True)
43-
task_id: Mapped[str] = mapped_column(StringID(), nullable=False, primary_key=True)
44-
map_index: Mapped[int] = mapped_column(Integer, primary_key=True, nullable=False, server_default="-1")
45-
key: Mapped[str] = mapped_column(String(512, **COLLATION_ARGS), nullable=False, primary_key=True)
42+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
43+
44+
dag_run_id: Mapped[int] = mapped_column(Integer, nullable=False)
45+
task_id: Mapped[str] = mapped_column(StringID(), nullable=False)
46+
map_index: Mapped[int] = mapped_column(Integer, nullable=False, server_default="-1")
47+
key: Mapped[str] = mapped_column(String(512, **COLLATION_ARGS), nullable=False)
4648

4749
dag_id: Mapped[str] = mapped_column(StringID(), nullable=False)
4850
run_id: Mapped[str] = mapped_column(StringID(), nullable=False)
4951

5052
value: Mapped[str] = mapped_column(Text().with_variant(MEDIUMTEXT, "mysql"), nullable=False)
5153
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=timezone.utcnow, nullable=False)
54+
# Optional override for early expiry. When set, garbage collection deletes this row when
55+
# expires_at < now(), even if updated_at is recent. NULL means no early expiry —
56+
# the row is still cleaned up by the global `updated_at + default_retention_days` check.
57+
# Populated via task_state.set(retention_days=N) for keys that should expire differently
58+
# than the deployment wide default.
59+
expires_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
5260

5361
__table_args__ = (
54-
PrimaryKeyConstraint("dag_run_id", "task_id", "map_index", "key", name="task_state_pkey"),
62+
UniqueConstraint("dag_run_id", "task_id", "map_index", "key", name="task_state_uq"),
5563
ForeignKeyConstraint(
5664
["dag_run_id"],
5765
["dag_run.id"],
5866
name="task_state_dag_run_fkey",
5967
ondelete="CASCADE",
6068
),
6169
Index("idx_task_state_lookup", "dag_id", "run_id", "task_id", "map_index"),
70+
Index("idx_task_state_expires_at", "expires_at"),
6271
)

airflow-core/src/airflow/state/metastore.py

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,20 @@
1919

2020
from collections.abc import AsyncGenerator
2121
from contextlib import asynccontextmanager
22+
from datetime import datetime, timedelta
2223
from typing import TYPE_CHECKING
2324

25+
import structlog
2426
from sqlalchemy import delete, select
2527

2628
from airflow._shared.state import AssetScope, BaseStateBackend, StateScope, TaskScope
2729
from airflow._shared.timezones import timezone
30+
from airflow.configuration import conf
2831
from airflow.models.asset_state import AssetStateModel
2932
from airflow.models.dagrun import DagRun
3033
from airflow.models.task_state import TaskStateModel
3134
from airflow.typing_compat import assert_never
32-
from airflow.utils.session import NEW_SESSION, create_session_async, provide_session
35+
from airflow.utils.session import NEW_SESSION, create_session, create_session_async, provide_session
3336
from airflow.utils.sqlalchemy import get_dialect_name
3437

3538
if TYPE_CHECKING:
@@ -40,6 +43,21 @@
4043
from sqlalchemy.orm import Session
4144

4245

46+
log = structlog.get_logger(__name__)
47+
48+
49+
def _compute_expires_at(now: datetime) -> datetime | None:
50+
"""
51+
Return the expiry timestamp for a new task state row based on config.
52+
53+
Returns None if default_retention_days is 0 (never expires).
54+
"""
55+
retention_days = conf.getint("state_store", "default_retention_days")
56+
if retention_days <= 0:
57+
return None
58+
return now + timedelta(days=retention_days)
59+
60+
4361
@asynccontextmanager
4462
async def _async_session(session: AsyncSession | None) -> AsyncGenerator[AsyncSession, None]:
4563
"""Use provided async session or create a new one."""
@@ -200,6 +218,7 @@ def _set_task_state(self, scope: TaskScope, key: str, value: str, *, session: Se
200218
if dag_run_id is None:
201219
raise ValueError(f"No DagRun found for dag_id={scope.dag_id!r} run_id={scope.run_id!r}")
202220
now = timezone.utcnow()
221+
expires_at = _compute_expires_at(now)
203222
values = dict(
204223
dag_run_id=dag_run_id,
205224
dag_id=scope.dag_id,
@@ -209,13 +228,14 @@ def _set_task_state(self, scope: TaskScope, key: str, value: str, *, session: Se
209228
key=key,
210229
value=value,
211230
updated_at=now,
231+
expires_at=expires_at,
212232
)
213233
stmt = _build_upsert_stmt(
214234
get_dialect_name(session),
215235
TaskStateModel,
216236
["dag_run_id", "task_id", "map_index", "key"],
217237
values,
218-
dict(value=value, updated_at=now),
238+
dict(value=value, updated_at=now, expires_at=expires_at),
219239
)
220240
session.execute(stmt)
221241

@@ -276,6 +296,51 @@ def _clear_asset_state(self, scope: AssetScope, *, session: Session) -> None:
276296
)
277297
)
278298

299+
def cleanup(self) -> None:
300+
"""
301+
Remove expired task state rows.
302+
303+
``expires_at`` is set at write time on every ``set()`` call, so cleanup is a single
304+
``WHERE expires_at < now()`` pass. Rows with ``expires_at=NULL`` (default_retention_days=0)
305+
are never deleted. Batching is configurable via ``[state_store] state_cleanup_batch_size``.
306+
"""
307+
batch_size = conf.getint("state_store", "state_cleanup_batch_size")
308+
now = timezone.utcnow()
309+
310+
def _delete_batched(where_clause) -> int:
311+
total = 0
312+
with create_session() as session:
313+
while True:
314+
id_query = select(TaskStateModel.id).where(where_clause)
315+
if batch_size > 0:
316+
id_query = id_query.limit(batch_size)
317+
ids = session.scalars(id_query).all()
318+
if not ids:
319+
break
320+
session.execute(delete(TaskStateModel).where(TaskStateModel.id.in_(ids)))
321+
session.commit()
322+
total += len(ids)
323+
if batch_size <= 0 or len(ids) < batch_size:
324+
break
325+
return total
326+
327+
deleted = _delete_batched(TaskStateModel.expires_at < now)
328+
log.info("Deleted expired task_state rows", rows_deleted=deleted)
329+
330+
def _summary_dry_run(self) -> dict[str, list]:
331+
"""Return rows that would be deleted by cleanup() without deleting anything."""
332+
now = timezone.utcnow()
333+
cols = (
334+
TaskStateModel.dag_id,
335+
TaskStateModel.run_id,
336+
TaskStateModel.task_id,
337+
TaskStateModel.map_index,
338+
TaskStateModel.key,
339+
)
340+
with create_session() as session:
341+
expired = session.execute(select(*cols).where(TaskStateModel.expires_at < now)).all()
342+
return {"expired": list(expired)}
343+
279344
async def _aget_task_state(self, scope: TaskScope, key: str, *, session: AsyncSession) -> str | None:
280345
row = await session.scalar(
281346
select(TaskStateModel).where(
@@ -300,6 +365,7 @@ async def _aset_task_state(
300365
if dag_run_id is None:
301366
raise ValueError(f"No DagRun found for dag_id={scope.dag_id!r} run_id={scope.run_id!r}")
302367
now = timezone.utcnow()
368+
expires_at = _compute_expires_at(now)
303369
values = dict(
304370
dag_run_id=dag_run_id,
305371
dag_id=scope.dag_id,
@@ -309,14 +375,15 @@ async def _aset_task_state(
309375
key=key,
310376
value=value,
311377
updated_at=now,
378+
expires_at=expires_at,
312379
)
313380
# get_dialect_name expects a sync Session; sync_session is the underlying Session the async wrapper delegates to
314381
stmt = _build_upsert_stmt(
315382
get_dialect_name(session.sync_session),
316383
TaskStateModel,
317384
["dag_run_id", "task_id", "map_index", "key"],
318385
values,
319-
dict(value=value, updated_at=now),
386+
dict(value=value, updated_at=now, expires_at=expires_at),
320387
)
321388
await session.execute(stmt)
322389

0 commit comments

Comments
 (0)