1919
2020from collections .abc import AsyncGenerator
2121from contextlib import asynccontextmanager
22+ from datetime import datetime , timedelta
2223from typing import TYPE_CHECKING
2324
25+ import structlog
2426from sqlalchemy import delete , select
2527
2628from airflow ._shared .state import AssetScope , BaseStateBackend , StateScope , TaskScope
2729from airflow ._shared .timezones import timezone
30+ from airflow .configuration import conf
2831from airflow .models .asset_state import AssetStateModel
2932from airflow .models .dagrun import DagRun
3033from airflow .models .task_state import TaskStateModel
3134from 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
3336from airflow .utils .sqlalchemy import get_dialect_name
3437
3538if TYPE_CHECKING :
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
4462async 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