This is an automated email from the ASF dual-hosted git repository.
amoghrajesh 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 ebb01703096 AIP-103: Setting up foundation with ORM and Backend
Abstraction (#65759)
ebb01703096 is described below
commit ebb0170309695c4873249fa04d44bcc9fb5a5852
Author: Amogh Desai <[email protected]>
AuthorDate: Thu Apr 30 13:01:25 2026 +0530
AIP-103: Setting up foundation with ORM and Backend Abstraction (#65759)
* Adding ORM models for task_state and asset_state
* Adding base state backend and metastore implementation
* Introducing config: [state_store]
---
airflow-core/docs/migrations-ref.rst | 4 +-
airflow-core/pyproject.toml | 2 +
airflow-core/src/airflow/_shared/state | 1 +
.../src/airflow/config_templates/config.yml | 14 +
..._3_3_0_add_task_state_and_asset_state_tables.py | 85 +++++
airflow-core/src/airflow/models/__init__.py | 2 +
airflow-core/src/airflow/models/asset_state.py | 55 +++
airflow-core/src/airflow/models/task_state.py | 62 ++++
airflow-core/src/airflow/state/__init__.py | 38 ++
airflow-core/src/airflow/state/metastore.py | 359 ++++++++++++++++++
airflow-core/src/airflow/utils/db.py | 2 +-
airflow-core/tests/unit/models/test_asset_state.py | 150 ++++++++
airflow-core/tests/unit/models/test_task_state.py | 230 ++++++++++++
airflow-core/tests/unit/state/__init__.py | 18 +
airflow-core/tests/unit/state/test_metastore.py | 399 +++++++++++++++++++++
.../tests/test_pytest_args_for_test_types.py | 1 +
dev/breeze/tests/test_selective_checks.py | 40 +--
pyproject.toml | 5 +
scripts/ci/prek/check_tests_in_right_folders.py | 1 +
shared/state/.gitignore | 1 +
shared/state/.pre-commit-config.yaml | 31 ++
shared/state/pyproject.toml | 55 +++
shared/state/src/airflow_shared/state/__init__.py | 124 +++++++
shared/state/tests/conftest.py | 21 ++
shared/state/tests/state/__init__.py | 17 +
shared/state/tests/state/test_state.py | 72 ++++
uv.lock | 23 ++
27 files changed, 1790 insertions(+), 22 deletions(-)
diff --git a/airflow-core/docs/migrations-ref.rst
b/airflow-core/docs/migrations-ref.rst
index e54009b856e..55cbb7e4d1a 100644
--- a/airflow-core/docs/migrations-ref.rst
+++ b/airflow-core/docs/migrations-ref.rst
@@ -39,7 +39,9 @@ Here's the list of all the Database Migrations that are
executed via when you ru
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
| Revision ID | Revises ID | Airflow Version | Description
|
+=========================+==================+===================+==============================================================+
-| ``9fabad868fdb`` (head) | ``a4c2d171ae18`` | ``3.3.0`` | Add
timetable_periodic to DagModel. |
+| ``fde9ed84d07b`` (head) | ``9fabad868fdb`` | ``3.3.0`` | Add
task_state and asset_state tables. |
++-------------------------+------------------+-------------------+--------------------------------------------------------------+
+| ``9fabad868fdb`` | ``a4c2d171ae18`` | ``3.3.0`` | Add
timetable_periodic to DagModel. |
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
| ``a4c2d171ae18`` | ``1d6611b6ab7c`` | ``3.3.0`` | Add
dag_result to XComModel. |
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
diff --git a/airflow-core/pyproject.toml b/airflow-core/pyproject.toml
index b7f7f3c3b57..42d6dd737b6 100644
--- a/airflow-core/pyproject.toml
+++ b/airflow-core/pyproject.toml
@@ -247,6 +247,7 @@ exclude = [
"../shared/secrets_backend/src/airflow_shared/secrets_backend" =
"src/airflow/_shared/secrets_backend"
"../shared/secrets_masker/src/airflow_shared/secrets_masker" =
"src/airflow/_shared/secrets_masker"
"../shared/serialization/src/airflow_shared/serialization" =
"src/airflow/_shared/serialization"
+"../shared/state/src/airflow_shared/state" = "src/airflow/_shared/state"
"../shared/timezones/src/airflow_shared/timezones" =
"src/airflow/_shared/timezones"
"../shared/listeners/src/airflow_shared/listeners" =
"src/airflow/_shared/listeners"
"../shared/plugins_manager/src/airflow_shared/plugins_manager" =
"src/airflow/_shared/plugins_manager"
@@ -334,6 +335,7 @@ shared_distributions = [
"apache-airflow-shared-secrets-backend",
"apache-airflow-shared-secrets-masker",
"apache-airflow-shared-serialization",
+ "apache-airflow-shared-state",
"apache-airflow-shared-timezones",
"apache-airflow-shared-plugins-manager",
"apache-airflow-shared-providers-discovery",
diff --git a/airflow-core/src/airflow/_shared/state
b/airflow-core/src/airflow/_shared/state
new file mode 120000
index 00000000000..d251a2e1b0e
--- /dev/null
+++ b/airflow-core/src/airflow/_shared/state
@@ -0,0 +1 @@
+../../../../shared/state/src/airflow_shared/state
\ No newline at end of file
diff --git a/airflow-core/src/airflow/config_templates/config.yml
b/airflow-core/src/airflow/config_templates/config.yml
index c6dc3e97f29..3ac011a0999 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -2997,6 +2997,20 @@ dag_processor:
example: ~
default: "warning"
+state_store:
+ description: |
+ Configuration for task and asset state storage backend.
+ options:
+ backend:
+ description: |
+ Full dotted path to the class that implements state storage for tasks
and assets.
+ The class must be a subclass of ``BaseStateBackend``.
+ The default implementation persists state in the Airflow metadata
database.
+ version_added: 3.3.0
+ type: string
+ example: "mypackage.state.CustomStateBackend"
+ default: "airflow.state.metastore.MetastoreStateBackend"
+
profiling:
description: |
Configuration for memory profiling in Airflow component.
diff --git
a/airflow-core/src/airflow/migrations/versions/0112_3_3_0_add_task_state_and_asset_state_tables.py
b/airflow-core/src/airflow/migrations/versions/0112_3_3_0_add_task_state_and_asset_state_tables.py
new file mode 100644
index 00000000000..7f852d05c6c
--- /dev/null
+++
b/airflow-core/src/airflow/migrations/versions/0112_3_3_0_add_task_state_and_asset_state_tables.py
@@ -0,0 +1,85 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Add task_state and asset_state tables.
+
+Revision ID: fde9ed84d07b
+Revises: 9fabad868fdb
+Create Date: 2026-04-27 09:29:20.165461
+
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import mysql
+
+from airflow.migrations.db_types import StringID
+from airflow.utils.sqlalchemy import UtcDateTime
+
+# revision identifiers, used by Alembic.
+revision = "fde9ed84d07b"
+down_revision = "9fabad868fdb"
+branch_labels = None
+depends_on = None
+airflow_version = "3.3.0"
+
+
+def upgrade():
+ """Apply add task_state and asset_state tables."""
+ op.create_table(
+ "asset_state",
+ sa.Column("asset_id", sa.Integer(), nullable=False),
+ sa.Column("key", sa.String(length=512), nullable=False),
+ sa.Column("value", sa.Text().with_variant(mysql.MEDIUMTEXT(),
"mysql"), nullable=False),
+ sa.Column("updated_at", UtcDateTime(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["asset_id"], ["asset.id"], name="asset_state_asset_fkey",
ondelete="CASCADE"
+ ),
+ sa.PrimaryKeyConstraint("asset_id", "key", name="asset_state_pkey"),
+ )
+ op.create_table(
+ "task_state",
+ sa.Column("dag_run_id", sa.Integer(), nullable=False),
+ sa.Column("task_id", StringID(), nullable=False),
+ sa.Column("map_index", sa.Integer(), server_default="-1",
nullable=False),
+ sa.Column("key", sa.String(length=512), nullable=False),
+ sa.Column("dag_id", StringID(), nullable=False),
+ sa.Column("run_id", StringID(), nullable=False),
+ sa.Column("value", sa.Text().with_variant(mysql.MEDIUMTEXT(),
"mysql"), nullable=False),
+ sa.Column("updated_at", UtcDateTime(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["dag_run_id"], ["dag_run.id"], name="task_state_dag_run_fkey",
ondelete="CASCADE"
+ ),
+ sa.PrimaryKeyConstraint("dag_run_id", "task_id", "map_index", "key",
name="task_state_pkey"),
+ )
+ with op.batch_alter_table("task_state", schema=None) as batch_op:
+ batch_op.create_index(
+ "idx_task_state_lookup", ["dag_id", "run_id", "task_id",
"map_index"], unique=False
+ )
+
+
+def downgrade():
+ """Unapply add task_state and asset_state tables."""
+ with op.batch_alter_table("task_state", schema=None) as batch_op:
+ batch_op.drop_index("idx_task_state_lookup")
+
+ op.drop_table("task_state")
+ op.drop_table("asset_state")
diff --git a/airflow-core/src/airflow/models/__init__.py
b/airflow-core/src/airflow/models/__init__.py
index 8e12325f568..9b134f4ff36 100644
--- a/airflow-core/src/airflow/models/__init__.py
+++ b/airflow-core/src/airflow/models/__init__.py
@@ -61,6 +61,7 @@ def import_all_models():
__getattr__(name)
import airflow.models.asset
+ import airflow.models.asset_state
import airflow.models.backfill
import airflow.models.dag_favorite
import airflow.models.dag_version
@@ -71,6 +72,7 @@ def import_all_models():
import airflow.models.errors
import airflow.models.revoked_token
import airflow.models.serialized_dag
+ import airflow.models.task_state
import airflow.models.taskinstancehistory
import airflow.models.tasklog
import airflow.models.team
diff --git a/airflow-core/src/airflow/models/asset_state.py
b/airflow-core/src/airflow/models/asset_state.py
new file mode 100644
index 00000000000..ecae12acaa8
--- /dev/null
+++ b/airflow-core/src/airflow/models/asset_state.py
@@ -0,0 +1,55 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import ForeignKeyConstraint, Integer, PrimaryKeyConstraint,
String, Text
+from sqlalchemy.dialects.mysql import MEDIUMTEXT
+from sqlalchemy.orm import Mapped, mapped_column
+
+from airflow._shared.timezones import timezone
+from airflow.models.base import COLLATION_ARGS, Base
+from airflow.utils.sqlalchemy import UtcDateTime
+
+
+class AssetStateModel(Base):
+ """
+ Persists key/value state scoped to an asset identity.
+
+ Not scoped to any DAG run — a watermark written in run 1 is readable by
run 2.
+ Rows survive until explicitly deleted or the asset itself is deleted.
+ """
+
+ __tablename__ = "asset_state"
+
+ asset_id: Mapped[int] = mapped_column(Integer, nullable=False,
primary_key=True)
+ key: Mapped[str] = mapped_column(String(512, **COLLATION_ARGS),
nullable=False, primary_key=True)
+
+ value: Mapped[str] = mapped_column(Text().with_variant(MEDIUMTEXT,
"mysql"), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(UtcDateTime,
default=timezone.utcnow, nullable=False)
+
+ __table_args__ = (
+ PrimaryKeyConstraint("asset_id", "key", name="asset_state_pkey"),
+ ForeignKeyConstraint(
+ ["asset_id"],
+ ["asset.id"],
+ name="asset_state_asset_fkey",
+ ondelete="CASCADE",
+ ),
+ )
diff --git a/airflow-core/src/airflow/models/task_state.py
b/airflow-core/src/airflow/models/task_state.py
new file mode 100644
index 00000000000..dbc17e3b069
--- /dev/null
+++ b/airflow-core/src/airflow/models/task_state.py
@@ -0,0 +1,62 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import ForeignKeyConstraint, Index, Integer,
PrimaryKeyConstraint, String, Text
+from sqlalchemy.dialects.mysql import MEDIUMTEXT
+from sqlalchemy.orm import Mapped, mapped_column
+
+from airflow._shared.timezones import timezone
+from airflow.models.base import COLLATION_ARGS, Base, StringID
+from airflow.utils.sqlalchemy import UtcDateTime
+
+
+class TaskStateModel(Base):
+ """
+ Persists key/value state for a task within a single DAG run.
+
+ Scoped to (dag_run_id, task_id, map_index). Retries of the same task share
+ the same rows — that is the point. Different DAG runs have different
dag_run_id
+ values so they get independent namespaces automatically.
+ """
+
+ __tablename__ = "task_state"
+
+ dag_run_id: Mapped[int] = mapped_column(Integer, nullable=False,
primary_key=True)
+ task_id: Mapped[str] = mapped_column(StringID(), nullable=False,
primary_key=True)
+ map_index: Mapped[int] = mapped_column(Integer, primary_key=True,
nullable=False, server_default="-1")
+ key: Mapped[str] = mapped_column(String(512, **COLLATION_ARGS),
nullable=False, primary_key=True)
+
+ dag_id: Mapped[str] = mapped_column(StringID(), nullable=False)
+ run_id: Mapped[str] = mapped_column(StringID(), nullable=False)
+
+ value: Mapped[str] = mapped_column(Text().with_variant(MEDIUMTEXT,
"mysql"), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(UtcDateTime,
default=timezone.utcnow, nullable=False)
+
+ __table_args__ = (
+ PrimaryKeyConstraint("dag_run_id", "task_id", "map_index", "key",
name="task_state_pkey"),
+ ForeignKeyConstraint(
+ ["dag_run_id"],
+ ["dag_run.id"],
+ name="task_state_dag_run_fkey",
+ ondelete="CASCADE",
+ ),
+ Index("idx_task_state_lookup", "dag_id", "run_id", "task_id",
"map_index"),
+ )
diff --git a/airflow-core/src/airflow/state/__init__.py
b/airflow-core/src/airflow/state/__init__.py
new file mode 100644
index 00000000000..89fa0801a06
--- /dev/null
+++ b/airflow-core/src/airflow/state/__init__.py
@@ -0,0 +1,38 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from airflow._shared.state import (
+ AssetScope as AssetScope,
+ BaseStateBackend as BaseStateBackend,
+ StateScope as StateScope,
+ TaskScope as TaskScope,
+)
+
+
+def resolve_state_backend() -> type[BaseStateBackend]:
+ from airflow.configuration import conf
+
+ clazz = conf.getimport("state_store", "backend")
+ if clazz is None:
+ raise ValueError("state_store.backend is not configured or resolved to
None.")
+ if not issubclass(clazz, BaseStateBackend):
+ raise TypeError(
+ f"Your custom state backend `{clazz.__name__}` is not a subclass
of `BaseStateBackend`."
+ )
+ return clazz
diff --git a/airflow-core/src/airflow/state/metastore.py
b/airflow-core/src/airflow/state/metastore.py
new file mode 100644
index 00000000000..3382dad81fc
--- /dev/null
+++ b/airflow-core/src/airflow/state/metastore.py
@@ -0,0 +1,359 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from sqlalchemy import delete, select
+
+from airflow._shared.state import AssetScope, BaseStateBackend, StateScope,
TaskScope
+from airflow._shared.timezones import timezone
+from airflow.models.asset_state import AssetStateModel
+from airflow.models.dagrun import DagRun
+from airflow.models.task_state import TaskStateModel
+from airflow.typing_compat import assert_never
+from airflow.utils.session import NEW_SESSION, create_session_async,
provide_session
+from airflow.utils.sqlalchemy import get_dialect_name
+
+if TYPE_CHECKING:
+ from sqlalchemy.dialects.mysql.dml import Insert as MySQLInsert
+ from sqlalchemy.dialects.postgresql.dml import Insert as PostgreSQLInsert
+ from sqlalchemy.dialects.sqlite.dml import Insert as SQLiteInsert
+ from sqlalchemy.ext.asyncio import AsyncSession
+ from sqlalchemy.orm import Session
+
+
+def _build_upsert_stmt(
+ dialect: str | None,
+ model: type,
+ conflict_cols: list[str],
+ values: dict,
+ update_fields: dict,
+):
+ """Return a dialect-specific INSERT ... ON CONFLICT UPDATE statement."""
+ stmt: MySQLInsert | PostgreSQLInsert | SQLiteInsert
+ if dialect == "postgresql":
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
+
+ stmt = pg_insert(model).values(**values)
+ stmt = stmt.on_conflict_do_update(index_elements=conflict_cols,
set_=update_fields)
+ elif dialect == "mysql":
+ from sqlalchemy.dialects.mysql import insert as mysql_insert
+
+ stmt = mysql_insert(model).values(**values)
+ stmt = stmt.on_duplicate_key_update(**update_fields)
+ else:
+ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+
+ stmt = sqlite_insert(model).values(**values)
+ stmt = stmt.on_conflict_do_update(index_elements=conflict_cols,
set_=update_fields)
+ return stmt
+
+
+class MetastoreStateBackend(BaseStateBackend):
+ """Default state backend for tasks and assets. Stores task and asset state
in the Airflow metadata database."""
+
+ @provide_session
+ def get(self, scope: StateScope, key: str, *, session: Session =
NEW_SESSION) -> str | None:
+ match scope:
+ case TaskScope():
+ return self._get_task_state(scope, key, session=session)
+ case AssetScope():
+ return self._get_asset_state(scope, key, session=session)
+ case _:
+ assert_never(scope)
+
+ @provide_session
+ def set(self, scope: StateScope, key: str, value: str, *, session: Session
= NEW_SESSION) -> None:
+ match scope:
+ case TaskScope():
+ self._set_task_state(scope, key, value, session=session)
+ case AssetScope():
+ self._set_asset_state(scope, key, value, session=session)
+ case _:
+ assert_never(scope)
+
+ @provide_session
+ def delete(self, scope: StateScope, key: str, *, session: Session =
NEW_SESSION) -> None:
+ match scope:
+ case TaskScope():
+ self._delete_task_state(scope, key, session=session)
+ case AssetScope():
+ self._delete_asset_state(scope, key, session=session)
+ case _:
+ assert_never(scope)
+
+ @provide_session
+ def clear(
+ self,
+ scope: StateScope,
+ *,
+ all_map_indices: bool = False,
+ session: Session = NEW_SESSION,
+ ) -> None:
+ match scope:
+ case TaskScope():
+ self._clear_task_state(scope, all_map_indices=all_map_indices,
session=session)
+ case AssetScope():
+ self._clear_asset_state(scope, session=session)
+ case _:
+ assert_never(scope)
+
+ async def aget(self, scope: StateScope, key: str) -> str | None:
+ async with create_session_async() as session:
+ match scope:
+ case TaskScope():
+ return await self._aget_task_state(scope, key,
session=session)
+ case AssetScope():
+ return await self._aget_asset_state(scope, key,
session=session)
+ case _:
+ assert_never(scope)
+
+ async def aset(self, scope: StateScope, key: str, value: str) -> None:
+ async with create_session_async() as session:
+ match scope:
+ case TaskScope():
+ await self._aset_task_state(scope, key, value,
session=session)
+ case AssetScope():
+ await self._aset_asset_state(scope, key, value,
session=session)
+ case _:
+ assert_never(scope)
+
+ async def adelete(self, scope: StateScope, key: str) -> None:
+ async with create_session_async() as session:
+ match scope:
+ case TaskScope():
+ await self._adelete_task_state(scope, key, session=session)
+ case AssetScope():
+ await self._adelete_asset_state(scope, key,
session=session)
+ case _:
+ assert_never(scope)
+
+ async def aclear(self, scope: StateScope, *, all_map_indices: bool =
False) -> None:
+ async with create_session_async() as session:
+ match scope:
+ case TaskScope():
+ await self._aclear_task_state(scope,
all_map_indices=all_map_indices, session=session)
+ case AssetScope():
+ await self._aclear_asset_state(scope, session=session)
+ case _:
+ assert_never(scope)
+
+ def _get_task_state(self, scope: TaskScope, key: str, *, session: Session)
-> str | None:
+ row = session.scalar(
+ select(TaskStateModel).where(
+ TaskStateModel.dag_id == scope.dag_id,
+ TaskStateModel.run_id == scope.run_id,
+ TaskStateModel.task_id == scope.task_id,
+ TaskStateModel.map_index == scope.map_index,
+ TaskStateModel.key == key,
+ )
+ )
+ return row.value if row is not None else None
+
+ def _set_task_state(self, scope: TaskScope, key: str, value: str, *,
session: Session) -> None:
+ dag_run_id = session.scalar(
+ select(DagRun.id).where(
+ DagRun.dag_id == scope.dag_id,
+ DagRun.run_id == scope.run_id,
+ )
+ )
+ if dag_run_id is None:
+ raise ValueError(f"No DagRun found for dag_id={scope.dag_id!r}
run_id={scope.run_id!r}")
+ now = timezone.utcnow()
+ values = dict(
+ dag_run_id=dag_run_id,
+ dag_id=scope.dag_id,
+ run_id=scope.run_id,
+ task_id=scope.task_id,
+ map_index=scope.map_index,
+ key=key,
+ value=value,
+ updated_at=now,
+ )
+ stmt = _build_upsert_stmt(
+ get_dialect_name(session),
+ TaskStateModel,
+ ["dag_run_id", "task_id", "map_index", "key"],
+ values,
+ dict(value=value, updated_at=now),
+ )
+ session.execute(stmt)
+
+ def _delete_task_state(self, scope: TaskScope, key: str, *, session:
Session) -> None:
+ session.execute(
+ delete(TaskStateModel).where(
+ TaskStateModel.dag_id == scope.dag_id,
+ TaskStateModel.run_id == scope.run_id,
+ TaskStateModel.task_id == scope.task_id,
+ TaskStateModel.map_index == scope.map_index,
+ TaskStateModel.key == key,
+ )
+ )
+
+ def _clear_task_state(self, scope: TaskScope, *, all_map_indices: bool =
False, session: Session) -> None:
+ conditions = [
+ TaskStateModel.dag_id == scope.dag_id,
+ TaskStateModel.run_id == scope.run_id,
+ TaskStateModel.task_id == scope.task_id,
+ ]
+ if not all_map_indices:
+ conditions.append(TaskStateModel.map_index == scope.map_index)
+ session.execute(delete(TaskStateModel).where(*conditions))
+
+ def _get_asset_state(self, scope: AssetScope, key: str, *, session:
Session) -> str | None:
+ row = session.scalar(
+ select(AssetStateModel).where(
+ AssetStateModel.asset_id == scope.asset_id,
+ AssetStateModel.key == key,
+ )
+ )
+ return row.value if row is not None else None
+
+ def _set_asset_state(self, scope: AssetScope, key: str, value: str, *,
session: Session) -> None:
+ now = timezone.utcnow()
+ values = dict(asset_id=scope.asset_id, key=key, value=value,
updated_at=now)
+ stmt = _build_upsert_stmt(
+ get_dialect_name(session),
+ AssetStateModel,
+ ["asset_id", "key"],
+ values,
+ dict(value=value, updated_at=now),
+ )
+ session.execute(stmt)
+
+ def _delete_asset_state(self, scope: AssetScope, key: str, *, session:
Session) -> None:
+ session.execute(
+ delete(AssetStateModel).where(
+ AssetStateModel.asset_id == scope.asset_id,
+ AssetStateModel.key == key,
+ )
+ )
+
+ def _clear_asset_state(self, scope: AssetScope, *, session: Session) ->
None:
+ session.execute(
+ delete(AssetStateModel).where(
+ AssetStateModel.asset_id == scope.asset_id,
+ )
+ )
+
+ async def _aget_task_state(self, scope: TaskScope, key: str, *, session:
AsyncSession) -> str | None:
+ row = await session.scalar(
+ select(TaskStateModel).where(
+ TaskStateModel.dag_id == scope.dag_id,
+ TaskStateModel.run_id == scope.run_id,
+ TaskStateModel.task_id == scope.task_id,
+ TaskStateModel.map_index == scope.map_index,
+ TaskStateModel.key == key,
+ )
+ )
+ return row.value if row is not None else None
+
+ async def _aset_task_state(
+ self, scope: TaskScope, key: str, value: str, *, session: AsyncSession
+ ) -> None:
+ dag_run_id = await session.scalar(
+ select(DagRun.id).where(
+ DagRun.dag_id == scope.dag_id,
+ DagRun.run_id == scope.run_id,
+ )
+ )
+ if dag_run_id is None:
+ raise ValueError(f"No DagRun found for dag_id={scope.dag_id!r}
run_id={scope.run_id!r}")
+ now = timezone.utcnow()
+ values = dict(
+ dag_run_id=dag_run_id,
+ dag_id=scope.dag_id,
+ run_id=scope.run_id,
+ task_id=scope.task_id,
+ map_index=scope.map_index,
+ key=key,
+ value=value,
+ updated_at=now,
+ )
+ # get_dialect_name expects a sync Session; sync_session is the
underlying Session the async wrapper delegates to
+ stmt = _build_upsert_stmt(
+ get_dialect_name(session.sync_session),
+ TaskStateModel,
+ ["dag_run_id", "task_id", "map_index", "key"],
+ values,
+ dict(value=value, updated_at=now),
+ )
+ await session.execute(stmt)
+
+ async def _adelete_task_state(self, scope: TaskScope, key: str, *,
session: AsyncSession) -> None:
+ await session.execute(
+ delete(TaskStateModel).where(
+ TaskStateModel.dag_id == scope.dag_id,
+ TaskStateModel.run_id == scope.run_id,
+ TaskStateModel.task_id == scope.task_id,
+ TaskStateModel.map_index == scope.map_index,
+ TaskStateModel.key == key,
+ )
+ )
+
+ async def _aclear_task_state(
+ self, scope: TaskScope, *, all_map_indices: bool = False, session:
AsyncSession
+ ) -> None:
+ conditions = [
+ TaskStateModel.dag_id == scope.dag_id,
+ TaskStateModel.run_id == scope.run_id,
+ TaskStateModel.task_id == scope.task_id,
+ ]
+ if not all_map_indices:
+ conditions.append(TaskStateModel.map_index == scope.map_index)
+ await session.execute(delete(TaskStateModel).where(*conditions))
+
+ async def _aget_asset_state(self, scope: AssetScope, key: str, *, session:
AsyncSession) -> str | None:
+ row = await session.scalar(
+ select(AssetStateModel).where(
+ AssetStateModel.asset_id == scope.asset_id,
+ AssetStateModel.key == key,
+ )
+ )
+ return row.value if row is not None else None
+
+ async def _aset_asset_state(
+ self, scope: AssetScope, key: str, value: str, *, session: AsyncSession
+ ) -> None:
+ now = timezone.utcnow()
+ values = dict(asset_id=scope.asset_id, key=key, value=value,
updated_at=now)
+ # get_dialect_name expects a sync Session; sync_session is the
underlying Session the async wrapper delegates to
+ stmt = _build_upsert_stmt(
+ get_dialect_name(session.sync_session),
+ AssetStateModel,
+ ["asset_id", "key"],
+ values,
+ dict(value=value, updated_at=now),
+ )
+ await session.execute(stmt)
+
+ async def _adelete_asset_state(self, scope: AssetScope, key: str, *,
session: AsyncSession) -> None:
+ await session.execute(
+ delete(AssetStateModel).where(
+ AssetStateModel.asset_id == scope.asset_id,
+ AssetStateModel.key == key,
+ )
+ )
+
+ async def _aclear_asset_state(self, scope: AssetScope, *, session:
AsyncSession) -> None:
+ await session.execute(
+ delete(AssetStateModel).where(
+ AssetStateModel.asset_id == scope.asset_id,
+ )
+ )
diff --git a/airflow-core/src/airflow/utils/db.py
b/airflow-core/src/airflow/utils/db.py
index 9293f1c49ac..0e1173c2dce 100644
--- a/airflow-core/src/airflow/utils/db.py
+++ b/airflow-core/src/airflow/utils/db.py
@@ -116,7 +116,7 @@ _REVISION_HEADS_MAP: dict[str, str] = {
"3.1.0": "cc92b33c6709",
"3.1.8": "509b94a1042d",
"3.2.0": "1d6611b6ab7c",
- "3.3.0": "9fabad868fdb",
+ "3.3.0": "fde9ed84d07b",
}
# Prefix used to identify tables holding data moved during migration.
diff --git a/airflow-core/tests/unit/models/test_asset_state.py
b/airflow-core/tests/unit/models/test_asset_state.py
new file mode 100644
index 00000000000..cb2976fe411
--- /dev/null
+++ b/airflow-core/tests/unit/models/test_asset_state.py
@@ -0,0 +1,150 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from sqlalchemy import delete, select
+from sqlalchemy.exc import IntegrityError
+
+from airflow.models.asset import AssetModel
+from airflow.models.asset_state import AssetStateModel
+
+from tests_common.test_utils.db import clear_db_assets
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import Session
+
+pytestmark = pytest.mark.db_test
+
+
[email protected](autouse=True)
+def clean_tables():
+ clear_db_assets()
+ yield
+ clear_db_assets()
+
+
[email protected]
+def asset(session: Session) -> AssetModel:
+ a = AssetModel(uri="s3://bucket/prefix", name="test_asset", group="test")
+ session.add(a)
+ session.flush()
+ return a
+
+
+class TestAssetStateModel:
+ def test_insert_and_read(self, session: Session, asset: AssetModel):
+ """A row written for an asset key is readable back with the correct
value."""
+ row = AssetStateModel(
+ asset_id=asset.id,
+ key="last_processed_at",
+ value="2026-04-24T00:00:00Z",
+ )
+ session.add(row)
+ session.flush()
+
+ result = session.scalar(
+ select(AssetStateModel).where(
+ AssetStateModel.asset_id == asset.id,
+ AssetStateModel.key == "last_processed_at",
+ )
+ )
+ assert result is not None
+ assert result.value == "2026-04-24T00:00:00Z"
+
+ def test_duplicate_pk_raises(self, session: Session, asset: AssetModel):
+ """Inserting a second row with the same (asset_id, key) raises
IntegrityError."""
+ session.add(AssetStateModel(asset_id=asset.id, key="watermark",
value="first"))
+ session.flush()
+
+ session.add(AssetStateModel(asset_id=asset.id, key="watermark",
value="duplicate"))
+ with pytest.raises(IntegrityError):
+ session.flush()
+
+ def test_multiple_keys_per_asset(self, session: Session, asset:
AssetModel):
+ """An asset can hold multiple independent keys in the same
namespace."""
+ for key in ("watermark", "last_file_count", "last_error"):
+ session.add(AssetStateModel(asset_id=asset.id, key=key,
value=f"val_{key}"))
+ session.flush()
+
+ results =
session.scalars(select(AssetStateModel).where(AssetStateModel.asset_id ==
asset.id)).all()
+ assert len(results) == 3
+
+ def test_cascade_delete_on_asset(self, session: Session, asset:
AssetModel):
+ """Deleting an asset cascades to its asset_state rows — no orphans
remain."""
+ session.add(
+ AssetStateModel(
+ asset_id=asset.id,
+ key="last_processed_at",
+ value="2026-04-24T00:00:00Z",
+ )
+ )
+ session.flush()
+
+ session.execute(delete(AssetModel).where(AssetModel.id == asset.id))
+ session.flush()
+
+ remaining =
session.scalars(select(AssetStateModel).where(AssetStateModel.asset_id ==
asset.id)).all()
+ assert remaining == []
+
+ def test_different_assets_are_isolated(self, session: Session, asset:
AssetModel):
+ """State written for one asset is not visible when querying a
different asset."""
+ asset2 = AssetModel(uri="s3://bucket/other", name="other_asset",
group="test")
+ session.add(asset2)
+ session.flush()
+
+ session.add(
+ AssetStateModel(
+ asset_id=asset.id,
+ key="watermark",
+ value="asset1_value",
+ )
+ )
+ session.flush()
+
+ result = session.scalar(
+ select(AssetStateModel).where(
+ AssetStateModel.asset_id == asset2.id,
+ AssetStateModel.key == "watermark",
+ )
+ )
+ assert result is None
+
+ def test_state_survives_across_runs(self, session: Session, asset:
AssetModel):
+ """Asset state is not scoped to a DAG run — updating a value persists
it for future reads."""
+ row = AssetStateModel(
+ asset_id=asset.id,
+ key="watermark",
+ value="2026-04-01T00:00:00Z",
+ )
+ session.add(row)
+ session.flush()
+
+ row.value = "2026-04-24T00:00:00Z"
+ session.flush()
+
+ result = session.scalar(
+ select(AssetStateModel).where(
+ AssetStateModel.asset_id == asset.id,
+ AssetStateModel.key == "watermark",
+ )
+ )
+ assert result is not None
+ assert result.value == "2026-04-24T00:00:00Z"
diff --git a/airflow-core/tests/unit/models/test_task_state.py
b/airflow-core/tests/unit/models/test_task_state.py
new file mode 100644
index 00000000000..5e527ec8ebd
--- /dev/null
+++ b/airflow-core/tests/unit/models/test_task_state.py
@@ -0,0 +1,230 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from sqlalchemy import delete, select
+from sqlalchemy.exc import IntegrityError
+
+from airflow._shared.timezones import timezone
+from airflow.models.dagrun import DagRun, DagRunType
+from airflow.models.task_state import TaskStateModel
+
+from tests_common.test_utils.db import clear_db_dags, clear_db_runs
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import Session
+
+pytestmark = pytest.mark.db_test
+
+DAG_ID = "test_dag"
+TASK_ID = "test_task"
+RUN_ID = "scheduled__2026-04-24"
+
+
[email protected](autouse=True)
+def clean_tables():
+ clear_db_dags()
+ clear_db_runs()
+ yield
+ clear_db_dags()
+ clear_db_runs()
+
+
[email protected]
+def dag_run(session: Session) -> DagRun:
+ run = DagRun(
+ dag_id=DAG_ID,
+ run_id=RUN_ID,
+ run_type=DagRunType.SCHEDULED,
+ logical_date=timezone.datetime(2026, 4, 24),
+ run_after=timezone.datetime(2026, 4, 24),
+ )
+ session.add(run)
+ session.flush()
+ return run
+
+
+class TestTaskStateModel:
+ def test_insert_and_read(self, session: Session, dag_run: DagRun):
+ row = TaskStateModel(
+ dag_run_id=dag_run.id,
+ task_id=TASK_ID,
+ map_index=-1,
+ key="remote_job_id",
+ dag_id=DAG_ID,
+ run_id=RUN_ID,
+ value="application_1234",
+ )
+ session.add(row)
+ session.flush()
+
+ result = session.scalar(
+ select(TaskStateModel).where(
+ TaskStateModel.dag_run_id == dag_run.id,
+ TaskStateModel.task_id == TASK_ID,
+ TaskStateModel.map_index == -1,
+ TaskStateModel.key == "remote_job_id",
+ )
+ )
+ assert result is not None
+ assert result.value == "application_1234"
+ assert result.dag_id == DAG_ID
+ assert result.run_id == RUN_ID
+
+ def test_duplicate_pk_raises(self, session: Session, dag_run: DagRun):
+ """Inserting a second row with the same (dag_run_id, task_id,
map_index, key) raises IntegrityError."""
+ session.add(
+ TaskStateModel(
+ dag_run_id=dag_run.id,
+ task_id=TASK_ID,
+ map_index=-1,
+ key="remote_job_id",
+ dag_id=DAG_ID,
+ run_id=RUN_ID,
+ value="first",
+ )
+ )
+ session.flush()
+
+ session.add(
+ TaskStateModel(
+ dag_run_id=dag_run.id,
+ task_id=TASK_ID,
+ map_index=-1,
+ key="remote_job_id",
+ dag_id=DAG_ID,
+ run_id=RUN_ID,
+ value="duplicate",
+ )
+ )
+ with pytest.raises(IntegrityError):
+ session.flush()
+
+ def test_retries_share_state(self, session: Session, dag_run: DagRun):
+ """Retries of the same task share the same row — updates are visible
across retries."""
+ row = TaskStateModel(
+ dag_run_id=dag_run.id,
+ task_id=TASK_ID,
+ map_index=-1,
+ key="remote_job_id",
+ dag_id=DAG_ID,
+ run_id=RUN_ID,
+ value="application_try1",
+ )
+ session.add(row)
+ session.flush()
+
+ row.value = "application_try2"
+ session.flush()
+
+ result = session.scalar(
+ select(TaskStateModel).where(
+ TaskStateModel.dag_run_id == dag_run.id,
+ TaskStateModel.task_id == TASK_ID,
+ TaskStateModel.key == "remote_job_id",
+ )
+ )
+ assert result is not None
+ assert result.value == "application_try2"
+
+ def test_map_index_isolation(self, session: Session, dag_run: DagRun):
+ """Each mapped task instance gets its own independent state
namespace."""
+ for idx in (0, 1, 2):
+ session.add(
+ TaskStateModel(
+ dag_run_id=dag_run.id,
+ task_id=TASK_ID,
+ map_index=idx,
+ key="checkpoint",
+ dag_id=DAG_ID,
+ run_id=RUN_ID,
+ value=f"value_{idx}",
+ )
+ )
+ session.flush()
+
+ for idx in (0, 1, 2):
+ result = session.scalar(
+ select(TaskStateModel).where(
+ TaskStateModel.dag_run_id == dag_run.id,
+ TaskStateModel.task_id == TASK_ID,
+ TaskStateModel.map_index == idx,
+ TaskStateModel.key == "checkpoint",
+ )
+ )
+ assert result is not None
+ assert result.value == f"value_{idx}"
+
+ def test_cascade_delete_on_dag_run(self, session: Session, dag_run:
DagRun):
+ """Deleting a DagRun cascades to its task_state rows — no orphans
remain."""
+ session.add(
+ TaskStateModel(
+ dag_run_id=dag_run.id,
+ task_id=TASK_ID,
+ map_index=-1,
+ key="remote_job_id",
+ dag_id=DAG_ID,
+ run_id=RUN_ID,
+ value="application_1234",
+ )
+ )
+ session.flush()
+
+ session.execute(delete(DagRun).where(DagRun.id == dag_run.id))
+ session.flush()
+
+ remaining = session.scalars(
+ select(TaskStateModel).where(TaskStateModel.dag_run_id ==
dag_run.id)
+ ).all()
+ assert remaining == []
+
+ def test_different_dag_runs_are_isolated(self, session: Session, dag_run:
DagRun):
+ """State written for one DagRun is not visible when querying a
different DagRun."""
+ run2 = DagRun(
+ dag_id=DAG_ID,
+ run_id="scheduled__2026-04-25",
+ run_type=DagRunType.SCHEDULED,
+ logical_date=timezone.datetime(2026, 4, 25),
+ run_after=timezone.datetime(2026, 4, 25),
+ )
+ session.add(run2)
+ session.flush()
+
+ session.add(
+ TaskStateModel(
+ dag_run_id=dag_run.id,
+ task_id=TASK_ID,
+ map_index=-1,
+ key="watermark",
+ dag_id=DAG_ID,
+ run_id=RUN_ID,
+ value="run1_value",
+ )
+ )
+ session.flush()
+
+ result = session.scalar(
+ select(TaskStateModel).where(
+ TaskStateModel.dag_run_id == run2.id,
+ TaskStateModel.key == "watermark",
+ )
+ )
+ assert result is None
diff --git a/airflow-core/tests/unit/state/__init__.py
b/airflow-core/tests/unit/state/__init__.py
new file mode 100644
index 00000000000..03cb33c14c4
--- /dev/null
+++ b/airflow-core/tests/unit/state/__init__.py
@@ -0,0 +1,18 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
diff --git a/airflow-core/tests/unit/state/test_metastore.py
b/airflow-core/tests/unit/state/test_metastore.py
new file mode 100644
index 00000000000..98993d7133c
--- /dev/null
+++ b/airflow-core/tests/unit/state/test_metastore.py
@@ -0,0 +1,399 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from sqlalchemy import select
+
+from airflow._shared.timezones import timezone
+from airflow.models.asset import AssetModel
+from airflow.models.dagrun import DagRun, DagRunType
+from airflow.models.task_state import TaskStateModel
+from airflow.state import AssetScope, TaskScope, resolve_state_backend
+from airflow.state.metastore import MetastoreStateBackend
+from airflow.utils.session import create_session
+
+from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.db import clear_db_assets, clear_db_dags,
clear_db_runs
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import Session
+
+pytestmark = pytest.mark.db_test
+
+DAG_ID = "test_dag"
+TASK_ID = "test_task"
+RUN_ID = "scheduled__2026-04-24"
+
+
[email protected](autouse=True)
+def clean_tables():
+ clear_db_dags()
+ clear_db_runs()
+ clear_db_assets()
+ yield
+ clear_db_dags()
+ clear_db_runs()
+ clear_db_assets()
+
+
[email protected]
+def backend() -> MetastoreStateBackend:
+ return MetastoreStateBackend()
+
+
[email protected]
+def dag_run(session: Session) -> DagRun:
+ run = DagRun(
+ dag_id=DAG_ID,
+ run_id=RUN_ID,
+ run_type=DagRunType.SCHEDULED,
+ logical_date=timezone.datetime(2026, 4, 24),
+ run_after=timezone.datetime(2026, 4, 24),
+ )
+ session.add(run)
+ session.flush()
+ return run
+
+
[email protected]
+def dag_run_committed() -> DagRun:
+ """DagRun committed to DB so async sessions (which open their own
connections) can see it."""
+ with create_session() as session:
+ run = DagRun(
+ dag_id=DAG_ID,
+ run_id=RUN_ID,
+ run_type=DagRunType.SCHEDULED,
+ logical_date=timezone.datetime(2026, 4, 24),
+ run_after=timezone.datetime(2026, 4, 24),
+ )
+ session.add(run)
+ session.flush()
+ session.expunge(run)
+ return run
+
+
[email protected]
+def asset(session: Session) -> AssetModel:
+ a = AssetModel(uri="s3://bucket/prefix", name="test_asset", group="test")
+ session.add(a)
+ session.flush()
+ return a
+
+
[email protected]
+def asset_committed() -> AssetModel:
+ """AssetModel committed to DB so async sessions (which open their own
connections) can see it."""
+ with create_session() as session:
+ a = AssetModel(uri="s3://bucket/prefix", name="test_asset",
group="test")
+ session.add(a)
+ session.flush()
+ session.expunge(a)
+ return a
+
+
+class TestMetastoreStateBackendTaskScope:
+ def test_get_returns_none_for_missing_key(
+ self, session: Session, backend: MetastoreStateBackend, dag_run: DagRun
+ ):
+ scope = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID)
+ assert backend.get(scope, "missing", session=session) is None
+
+ def test_set_and_get_roundtrip(self, session: Session, backend:
MetastoreStateBackend, dag_run: DagRun):
+ scope = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID)
+ backend.set(scope, "job_id", "app_1234", session=session)
+ session.flush()
+ assert backend.get(scope, "job_id", session=session) == "app_1234"
+
+ @pytest.mark.backend("postgres", "mysql", "sqlite")
+ def test_set_twice_overrides_existing_value(
+ self, session: Session, backend: MetastoreStateBackend, dag_run: DagRun
+ ):
+ """Calling set twice on the same key updates the value in place."""
+ scope = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID)
+ backend.set(scope, "job_id", "app_try1", session=session)
+ session.flush()
+ backend.set(scope, "job_id", "app_try2", session=session)
+ session.flush()
+ assert backend.get(scope, "job_id", session=session) == "app_try2"
+
+ def test_set_stores_dag_run_id_fk(
+ self, session: Session, backend: MetastoreStateBackend, dag_run: DagRun
+ ):
+ """set resolves dag_run_id from (dag_id, run_id) and persists it as
the FK."""
+ scope = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID)
+ backend.set(scope, "job_id", "app_1234", session=session)
+ session.flush()
+
+ row = session.scalar(
+ select(TaskStateModel).where(
+ TaskStateModel.dag_id == DAG_ID,
+ TaskStateModel.task_id == TASK_ID,
+ TaskStateModel.key == "job_id",
+ )
+ )
+ assert row is not None
+ assert row.dag_run_id == dag_run.id
+
+ def test_delete_removes_key(self, session: Session, backend:
MetastoreStateBackend, dag_run: DagRun):
+ scope = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID)
+ backend.set(scope, "job_id", "app_1234", session=session)
+ backend.set(scope, "checkpoint", "step_3", session=session)
+ session.flush()
+
+ backend.delete(scope, "job_id", session=session)
+ session.flush()
+
+ assert backend.get(scope, "job_id", session=session) is None
+ assert backend.get(scope, "checkpoint", session=session) == "step_3"
+
+ def test_delete_is_noop_for_missing_key(
+ self, session: Session, backend: MetastoreStateBackend, dag_run: DagRun
+ ):
+ scope = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID)
+ backend.delete(scope, "nonexistent", session=session)
+
+ def test_clear_removes_all_keys(self, session: Session, backend:
MetastoreStateBackend, dag_run: DagRun):
+ """clear removes every key under the given task scope."""
+ scope = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID)
+ for key in ("job_id", "checkpoint", "watermark"):
+ backend.set(scope, key, f"val_{key}", session=session)
+ session.flush()
+
+ backend.clear(scope, session=session)
+ session.flush()
+
+ for key in ("job_id", "checkpoint", "watermark"):
+ assert backend.get(scope, key, session=session) is None
+
+ def test_map_index_isolation(self, session: Session, backend:
MetastoreStateBackend, dag_run: DagRun):
+ """Mapped task instances with different map_index values have isolated
namespaces."""
+ scope0 = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID,
map_index=0)
+ scope1 = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID,
map_index=1)
+
+ backend.set(scope0, "job_id", "app_0", session=session)
+ backend.set(scope1, "job_id", "app_1", session=session)
+ session.flush()
+
+ assert backend.get(scope0, "job_id", session=session) == "app_0"
+ assert backend.get(scope1, "job_id", session=session) == "app_1"
+
+ def test_set_raises_for_missing_dag_run(self, session: Session, backend:
MetastoreStateBackend):
+ scope = TaskScope(dag_id="nonexistent_dag", run_id="nonexistent_run",
task_id=TASK_ID)
+ with pytest.raises(ValueError, match="No DagRun found"):
+ backend.set(scope, "job_id", "app_1234", session=session)
+
+ def test_clear_scoped_to_map_index(
+ self, session: Session, backend: MetastoreStateBackend, dag_run: DagRun
+ ):
+ """clear for map_index=0 does not remove state belonging to
map_index=1."""
+ scope0 = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID,
map_index=0)
+ scope1 = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID,
map_index=1)
+
+ backend.set(scope0, "job_id", "app_0", session=session)
+ backend.set(scope1, "job_id", "app_1", session=session)
+ session.flush()
+
+ backend.clear(scope0, session=session)
+ session.flush()
+
+ assert backend.get(scope0, "job_id", session=session) is None
+ assert backend.get(scope1, "job_id", session=session) == "app_1"
+
+ def test_clear_with_all_map_indices_flag_wipes_wide(
+ self, session: Session, backend: MetastoreStateBackend, dag_run: DagRun
+ ):
+ """clear(scope, all_map_indices=True) removes state for every map
index of the task."""
+ scope0 = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID,
map_index=0)
+ scope1 = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID,
map_index=1)
+
+ backend.set(scope0, "job_id", "app_0", session=session)
+ backend.set(scope1, "job_id", "app_1", session=session)
+ session.flush()
+
+ backend.clear(scope0, all_map_indices=True, session=session)
+ session.flush()
+
+ assert backend.get(scope0, "job_id", session=session) is None
+ assert backend.get(scope1, "job_id", session=session) is None
+
+
+class TestMetastoreStateBackendAssetScope:
+ def test_get_returns_none_for_missing_key(
+ self, session: Session, backend: MetastoreStateBackend, asset:
AssetModel
+ ):
+ scope = AssetScope(asset_id=asset.id)
+ assert backend.get(scope, "missing", session=session) is None
+
+ def test_set_and_get_roundtrip(self, session: Session, backend:
MetastoreStateBackend, asset: AssetModel):
+ scope = AssetScope(asset_id=asset.id)
+ backend.set(scope, "watermark", "2026-04-24T00:00:00Z",
session=session)
+ session.flush()
+ assert backend.get(scope, "watermark", session=session) ==
"2026-04-24T00:00:00Z"
+
+ @pytest.mark.backend("postgres", "mysql", "sqlite")
+ def test_set_twice_overwrites_existing_value(
+ self, session: Session, backend: MetastoreStateBackend, asset:
AssetModel
+ ):
+ scope = AssetScope(asset_id=asset.id)
+ backend.set(scope, "watermark", "2026-04-01T00:00:00Z",
session=session)
+ session.flush()
+ backend.set(scope, "watermark", "2026-04-24T00:00:00Z",
session=session)
+ session.flush()
+ assert backend.get(scope, "watermark", session=session) ==
"2026-04-24T00:00:00Z"
+
+ def test_delete_removes_key(self, session: Session, backend:
MetastoreStateBackend, asset: AssetModel):
+ scope = AssetScope(asset_id=asset.id)
+ backend.set(scope, "watermark", "2026-04-24T00:00:00Z",
session=session)
+ backend.set(scope, "file_count", "42", session=session)
+ session.flush()
+
+ backend.delete(scope, "watermark", session=session)
+ session.flush()
+
+ assert backend.get(scope, "watermark", session=session) is None
+ assert backend.get(scope, "file_count", session=session) == "42"
+
+ def test_delete_is_noop_for_missing_key(
+ self, session: Session, backend: MetastoreStateBackend, asset:
AssetModel
+ ):
+ scope = AssetScope(asset_id=asset.id)
+ backend.delete(scope, "nonexistent", session=session)
+
+ def test_clear_removes_all_keys(
+ self, session: Session, backend: MetastoreStateBackend, asset:
AssetModel
+ ):
+ scope = AssetScope(asset_id=asset.id)
+ for key in ("watermark", "file_count", "last_error"):
+ backend.set(scope, key, f"val_{key}", session=session)
+ session.flush()
+
+ backend.clear(scope, session=session)
+ session.flush()
+
+ for key in ("watermark", "file_count", "last_error"):
+ assert backend.get(scope, key, session=session) is None
+
+ def test_different_assets_are_isolated(
+ self, session: Session, backend: MetastoreStateBackend, asset:
AssetModel
+ ):
+ asset2 = AssetModel(uri="s3://bucket/other", name="other_asset",
group="test")
+ session.add(asset2)
+ session.flush()
+
+ scope1 = AssetScope(asset_id=asset.id)
+ scope2 = AssetScope(asset_id=asset2.id)
+
+ backend.set(scope1, "watermark", "asset1_value", session=session)
+ session.flush()
+
+ assert backend.get(scope2, "watermark", session=session) is None
+
+
[email protected](loop_scope="class")
+class TestMetastoreStateBackendAsync:
+ async def test_aset_and_aget_task_roundtrip(
+ self, backend: MetastoreStateBackend, dag_run_committed: DagRun
+ ):
+ scope = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID)
+ await backend.aset(scope, "job_id", "app_async")
+ result = await backend.aget(scope, "job_id")
+ assert result == "app_async"
+
+ async def test_adelete_task_removes_key(self, backend:
MetastoreStateBackend, dag_run_committed: DagRun):
+ scope = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID)
+ await backend.aset(scope, "job_id", "app_async")
+ await backend.adelete(scope, "job_id")
+ assert await backend.aget(scope, "job_id") is None
+
+ async def test_aclear_task_removes_all_keys(
+ self, backend: MetastoreStateBackend, dag_run_committed: DagRun
+ ):
+ scope = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID)
+ await backend.aset(scope, "job_id", "app_async")
+ await backend.aset(scope, "checkpoint", "step_1")
+ await backend.aclear(scope)
+ assert await backend.aget(scope, "job_id") is None
+ assert await backend.aget(scope, "checkpoint") is None
+
+ async def test_aclear_with_all_map_indices_flag_wipes_fleet(
+ self, backend: MetastoreStateBackend, dag_run_committed: DagRun
+ ):
+ """aclear(scope, all_map_indices=True) removes state for every map
index of the task."""
+ scope0 = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID,
map_index=0)
+ scope1 = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID,
map_index=1)
+
+ await backend.aset(scope0, "job_id", "app_0")
+ await backend.aset(scope1, "job_id", "app_1")
+
+ await backend.aclear(scope0, all_map_indices=True)
+
+ assert await backend.aget(scope0, "job_id") is None
+ assert await backend.aget(scope1, "job_id") is None
+
+ async def test_aset_and_aget_asset_roundtrip(
+ self, backend: MetastoreStateBackend, asset_committed: AssetModel
+ ):
+ scope = AssetScope(asset_id=asset_committed.id)
+ await backend.aset(scope, "watermark", "2026-04-27T00:00:00Z")
+ result = await backend.aget(scope, "watermark")
+ assert result == "2026-04-27T00:00:00Z"
+
+ async def test_adelete_asset_removes_key(
+ self, backend: MetastoreStateBackend, asset_committed: AssetModel
+ ):
+ scope = AssetScope(asset_id=asset_committed.id)
+ await backend.aset(scope, "watermark", "2026-04-27T00:00:00Z")
+ await backend.adelete(scope, "watermark")
+ assert await backend.aget(scope, "watermark") is None
+
+ async def test_aclear_asset_removes_all_keys(
+ self, backend: MetastoreStateBackend, asset_committed: AssetModel
+ ):
+ scope = AssetScope(asset_id=asset_committed.id)
+ await backend.aset(scope, "watermark", "2026-04-27T00:00:00Z")
+ await backend.aset(scope, "file_count", "42")
+ await backend.aclear(scope)
+ assert await backend.aget(scope, "watermark") is None
+ assert await backend.aget(scope, "file_count") is None
+
+ async def test_aset_task_raises_for_missing_dag_run(self, backend:
MetastoreStateBackend):
+ scope = TaskScope(dag_id="nonexistent_dag", run_id="nonexistent_run",
task_id=TASK_ID)
+ with pytest.raises(ValueError, match="No DagRun found"):
+ await backend.aset(scope, "job_id", "app_async")
+
+
+class TestResolveStateBackend:
+ @conf_vars({("state_store", "backend"):
"airflow.state.metastore.MetastoreStateBackend"})
+ def test_resolve_returns_configured_backend(self):
+ """resolve_state_backend() imports and returns the explicitly
configured backend class."""
+ assert resolve_state_backend() is MetastoreStateBackend
+
+ @conf_vars({("state_store", "backend"): ""})
+ def test_empty_backend_raises_value_error(self):
+ """resolve_state_backend() raises ValueError when backend is
explicitly set to empty string."""
+ with pytest.raises(ValueError, match="state_store.backend is not
configured"):
+ resolve_state_backend()
+
+ @conf_vars({("state_store", "backend"): "airflow.models.dagrun.DagRun"})
+ def test_invalid_backend_raises_type_error(self):
+ """resolve_state_backend() raises TypeError when the configured class
is not a BaseStateBackend subclass."""
+ with pytest.raises(TypeError, match="not a subclass of
`BaseStateBackend`"):
+ resolve_state_backend()
diff --git a/dev/breeze/tests/test_pytest_args_for_test_types.py
b/dev/breeze/tests/test_pytest_args_for_test_types.py
index 423c96eb10f..e184a70a357 100644
--- a/dev/breeze/tests/test_pytest_args_for_test_types.py
+++ b/dev/breeze/tests/test_pytest_args_for_test_types.py
@@ -173,6 +173,7 @@ def _find_all_integration_folders() -> list[str]:
"airflow-core/tests/unit/plugins",
"airflow-core/tests/unit/security",
"airflow-core/tests/unit/sensors",
+ "airflow-core/tests/unit/state",
"airflow-core/tests/unit/task",
"airflow-core/tests/unit/testconfig",
"airflow-core/tests/unit/timetables",
diff --git a/dev/breeze/tests/test_selective_checks.py
b/dev/breeze/tests/test_selective_checks.py
index 2922465ca66..7ffd1370475 100644
--- a/dev/breeze/tests/test_selective_checks.py
+++ b/dev/breeze/tests/test_selective_checks.py
@@ -107,7 +107,7 @@ ALL_SKIPPED_COMMITS_ON_NO_CI_IMAGE = (
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
)
@@ -120,7 +120,7 @@ ALL_SKIPPED_COMMITS_IF_NO_UI = (
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
)
ALL_SKIPPED_COMMITS_IF_NO_HELM_TESTS = (
@@ -131,7 +131,7 @@ ALL_SKIPPED_COMMITS_IF_NO_HELM_TESTS = (
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,update-uv-lock"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,update-uv-lock"
)
ALL_SKIPPED_COMMITS_IF_NO_UI_AND_HELM_TESTS = (
@@ -142,7 +142,7 @@ ALL_SKIPPED_COMMITS_IF_NO_UI_AND_HELM_TESTS = (
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
)
@@ -154,7 +154,7 @@ ALL_SKIPPED_COMMITS_IF_NO_PROVIDERS_AND_UI = (
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
)
@@ -166,7 +166,7 @@ ALL_SKIPPED_COMMITS_IF_NO_PROVIDERS = (
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
)
@@ -179,7 +179,7 @@ ALL_SKIPPED_COMMITS_IF_NO_PROVIDERS_UI_AND_HELM_TESTS = (
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
)
@@ -191,7 +191,7 @@ ALL_SKIPPED_COMMITS_IF_NO_CODE_PROVIDERS_AND_HELM_TESTS = (
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,update-uv-lock"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,update-uv-lock"
)
ALL_SKIPPED_COMMITS_IF_NOT_IMPORTANT_FILES_CHANGED = (
@@ -202,7 +202,7 @@ ALL_SKIPPED_COMMITS_IF_NOT_IMPORTANT_FILES_CHANGED = (
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
)
@@ -405,7 +405,7 @@ def assert_outputs_are_printed(expected_outputs: dict[str,
str], stderr: str):
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
),
"upgrade-to-newer-dependencies": "false",
@@ -678,7 +678,7 @@ def assert_outputs_are_printed(expected_outputs: dict[str,
str], stderr: str):
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
),
"skip-providers-tests": "false",
@@ -716,7 +716,7 @@ def assert_outputs_are_printed(expected_outputs: dict[str,
str], stderr: str):
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
),
"skip-providers-tests": "true",
@@ -753,7 +753,7 @@ def assert_outputs_are_printed(expected_outputs: dict[str,
str], stderr: str):
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
),
"skip-providers-tests": "true",
@@ -789,7 +789,7 @@ def assert_outputs_are_printed(expected_outputs: dict[str,
str], stderr: str):
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
),
"skip-providers-tests": "true",
@@ -1130,7 +1130,7 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
),
"upgrade-to-newer-dependencies": "false",
@@ -1294,7 +1294,7 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
),
"upgrade-to-newer-dependencies": "false",
@@ -1454,7 +1454,7 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
"mypy-shared-module_loading,mypy-shared-observability,"
"mypy-shared-plugins_manager,mypy-shared-providers_discovery,"
"mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,"
"mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
),
@@ -2191,7 +2191,7 @@ def test_expected_output_push(
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
),
"upgrade-to-newer-dependencies": "false",
@@ -2232,7 +2232,7 @@ def test_expected_output_push(
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
),
"run-kubernetes-tests": "true",
@@ -2278,7 +2278,7 @@ def test_expected_output_push(
"mypy-shared-configuration,mypy-shared-dagnode,mypy-shared-listeners,mypy-shared-logging,"
"mypy-shared-module_loading,mypy-shared-observability,mypy-shared-plugins_manager,"
"mypy-shared-providers_discovery,mypy-shared-secrets_backend,mypy-shared-secrets_masker,"
-
"mypy-shared-serialization,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
+
"mypy-shared-serialization,mypy-shared-state,mypy-shared-template_rendering,mypy-shared-timezones,mypy-task-sdk,mypy-task-sdk-integration-tests,"
"ts-compile-lint-simple-auth-manager-ui,ts-compile-lint-ui,update-uv-lock"
),
"run-kubernetes-tests": "false",
diff --git a/pyproject.toml b/pyproject.toml
index bf8bd0f272e..e085c79b998 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1326,6 +1326,7 @@ dev = [
"apache-airflow-shared-secrets-backend",
"apache-airflow-shared-secrets-masker",
"apache-airflow-shared-serialization",
+ "apache-airflow-shared-state",
"apache-airflow-shared-template-rendering",
"apache-airflow-shared-timezones",
]
@@ -1492,6 +1493,7 @@ apache-airflow-shared-providers-discovery = false
apache-airflow-shared-secrets-backend = false
apache-airflow-shared-secrets-masker = false
apache-airflow-shared-serialization = false
+apache-airflow-shared-state = false
apache-airflow-shared-template-rendering = false
apache-airflow-shared-timezones = false
apache-airflow-task-sdk = false
@@ -1642,6 +1644,7 @@ apache-airflow-shared-providers-discovery = false
apache-airflow-shared-secrets-backend = false
apache-airflow-shared-secrets-masker = false
apache-airflow-shared-serialization = false
+apache-airflow-shared-state = false
apache-airflow-shared-template-rendering = false
apache-airflow-shared-timezones = false
apache-airflow-task-sdk = false
@@ -1686,6 +1689,7 @@ apache-airflow-shared-providers-discovery = { workspace =
true }
apache-airflow-shared-secrets-backend = { workspace = true }
apache-airflow-shared-secrets-masker = { workspace = true }
apache-airflow-shared-serialization = { workspace = true }
+apache-airflow-shared-state = { workspace = true }
apache-airflow-shared-template-rendering = { workspace = true }
apache-airflow-shared-timezones = { workspace = true }
# Automatically generated provider workspace items
(update_airflow_pyproject_toml.py)
@@ -1822,6 +1826,7 @@ members = [
"shared/secrets_backend",
"shared/secrets_masker",
"shared/serialization",
+ "shared/state",
"shared/template_rendering",
"shared/timezones",
# Automatically generated provider workspace members
(update_airflow_pyproject_toml.py)
diff --git a/scripts/ci/prek/check_tests_in_right_folders.py
b/scripts/ci/prek/check_tests_in_right_folders.py
index 1a2bf5b53bc..7c164c0bb14 100755
--- a/scripts/ci/prek/check_tests_in_right_folders.py
+++ b/scripts/ci/prek/check_tests_in_right_folders.py
@@ -72,6 +72,7 @@ POSSIBLE_TEST_FOLDERS = [
"security",
"sensors",
"serialization",
+ "state",
"task",
"template",
"test_utils",
diff --git a/shared/state/.gitignore b/shared/state/.gitignore
new file mode 100644
index 00000000000..bff2d762960
--- /dev/null
+++ b/shared/state/.gitignore
@@ -0,0 +1 @@
+*.iml
diff --git a/shared/state/.pre-commit-config.yaml
b/shared/state/.pre-commit-config.yaml
new file mode 100644
index 00000000000..9c22fb9a0ec
--- /dev/null
+++ b/shared/state/.pre-commit-config.yaml
@@ -0,0 +1,31 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+---
+default_stages: [pre-commit, pre-push]
+minimum_prek_version: '0.3.4'
+default_language_version:
+ python: python3
+repos:
+ - repo: local
+ hooks:
+ - id: mypy-shared-state
+ name: Run mypy for shared-state
+ language: python
+ entry:
../../scripts/ci/prek/run_mypy_full_dist_local_venv_or_breeze_in_ci.py
shared/state
+ pass_filenames: false
+ files: ^.*\.py$
+ require_serial: true
diff --git a/shared/state/pyproject.toml b/shared/state/pyproject.toml
new file mode 100644
index 00000000000..f16ea66797a
--- /dev/null
+++ b/shared/state/pyproject.toml
@@ -0,0 +1,55 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[project]
+name = "apache-airflow-shared-state"
+description = "Shared task and asset state abstractions for Airflow
distributions"
+version = "0.0"
+classifiers = [
+ "Private :: Do Not Upload",
+]
+
+dependencies = []
+
+[dependency-groups]
+dev = [
+ "apache-airflow-devel-common",
+]
+mypy = [
+ "apache-airflow-devel-common[mypy]",
+]
+
+[build-system]
+requires = [
+ "hatchling==1.29.0",
+ "packaging==26.1",
+ "pathspec==1.0.4",
+ "pluggy==1.6.0",
+ "tomli==2.4.1; python_version < '3.11'",
+ "trove-classifiers==2026.1.14.14",
+]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/airflow_shared"]
+
+[tool.ruff]
+extend = "../../pyproject.toml"
+src = ["src"]
+
+[tool.ruff.lint.per-file-ignores]
+"!src/*" = ["D", "S101", "TRY002"]
diff --git a/shared/state/src/airflow_shared/state/__init__.py
b/shared/state/src/airflow_shared/state/__init__.py
new file mode 100644
index 00000000000..463d9f378f3
--- /dev/null
+++ b/shared/state/src/airflow_shared/state/__init__.py
@@ -0,0 +1,124 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class TaskScope:
+ """
+ Identifies the state namespace for a single task instance (or retry
thereof).
+
+ ``map_index`` defaults to ``-1`` for non-mapped tasks. For mapped tasks,
+ set it to the actual mapped index. ``get``/``set``/``delete`` always match
+ on ``(dag_id, run_id, task_id, map_index)`` exactly. To wipe state across
+ every map index of the task, call ``clear``/``aclear`` with
+ ``all_map_indices=True``.
+ """
+
+ dag_id: str
+ run_id: str
+ task_id: str
+ map_index: int = -1
+
+
+@dataclass(frozen=True)
+class AssetScope:
+ """Identifies the state namespace for an asset."""
+
+ asset_id: int
+
+
+StateScope = TaskScope | AssetScope
+
+
+class BaseStateBackend(ABC):
+ """
+ Abstract backend for reading and writing task and asset state.
+
+ Each method receives a ``StateScope`` which is either a ``TaskScope`` or
an ``AssetScope``.
+ Implementations must handle both types. The standard dispatch pattern is::
+
+ match scope:
+ case TaskScope():
+ ... # task-specific storage
+ case AssetScope():
+ ... # asset-specific storage
+
+ Custom backends are configured via ``[state_store] backend`` in
``airflow.cfg``.
+ """
+
+ @abstractmethod
+ def get(self, scope: StateScope, key: str) -> str | None:
+ """
+ Return the stored value, or None if the key does not exist.
+
+ Must handle both ``TaskScope`` and ``AssetScope``.
+ """
+
+ @abstractmethod
+ def set(self, scope: StateScope, key: str, value: str) -> None:
+ """
+ Write or overwrite the value for the given key.
+
+ Must handle both ``TaskScope`` and ``AssetScope``.
+ """
+
+ @abstractmethod
+ def delete(self, scope: StateScope, key: str) -> None:
+ """
+ Delete a single key. No-op if the key does not exist.
+
+ Must handle both ``TaskScope`` and ``AssetScope``.
+ """
+
+ @abstractmethod
+ def clear(self, scope: StateScope, *, all_map_indices: bool = False) ->
None:
+ """
+ Delete all keys under the given scope.
+
+ Must handle both ``TaskScope`` and ``AssetScope``.
+
+ For ``TaskScope``: by default, only keys for the exact ``map_index``
on the
+ scope are cleared. Pass ``all_map_indices=True`` to drop the
``map_index``
+ filter entirely and wipe state across every mapped instance of the
task.
+ For ``AssetScope`` the flag has no effect.
+ """
+
+ @abstractmethod
+ async def aget(self, scope: StateScope, key: str) -> str | None:
+ """Async variant of get. Must handle both ``TaskScope`` and
``AssetScope``."""
+
+ @abstractmethod
+ async def aset(self, scope: StateScope, key: str, value: str) -> None:
+ """Async variant of set. Must handle both ``TaskScope`` and
``AssetScope``."""
+
+ @abstractmethod
+ async def adelete(self, scope: StateScope, key: str) -> None:
+ """Async variant of delete. Must handle both ``TaskScope`` and
``AssetScope``."""
+
+ @abstractmethod
+ async def aclear(self, scope: StateScope, *, all_map_indices: bool =
False) -> None:
+ """
+ Async variant of clear. Must handle both ``TaskScope`` and
``AssetScope``.
+
+ For ``TaskScope``: by default, only keys for the exact ``map_index``
on the
+ scope are cleared. Pass ``all_map_indices=True`` to wipe state across
every
+ mapped instance of the task. For ``AssetScope`` the flag has no effect.
+ """
diff --git a/shared/state/tests/conftest.py b/shared/state/tests/conftest.py
new file mode 100644
index 00000000000..93aecf26184
--- /dev/null
+++ b/shared/state/tests/conftest.py
@@ -0,0 +1,21 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import os
+
+os.environ["_AIRFLOW__AS_LIBRARY"] = "true"
diff --git a/shared/state/tests/state/__init__.py
b/shared/state/tests/state/__init__.py
new file mode 100644
index 00000000000..21d298ede6e
--- /dev/null
+++ b/shared/state/tests/state/__init__.py
@@ -0,0 +1,17 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
diff --git a/shared/state/tests/state/test_state.py
b/shared/state/tests/state/test_state.py
new file mode 100644
index 00000000000..47bce18a69e
--- /dev/null
+++ b/shared/state/tests/state/test_state.py
@@ -0,0 +1,72 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import pytest
+
+from airflow_shared.state import BaseStateBackend, StateScope
+
+
+class TestBaseStateBackend:
+ @pytest.fixture
+ def backend(self) -> BaseStateBackend:
+ class ConcreteBackend(BaseStateBackend):
+ def __init__(self):
+ self.calls: list[str] = []
+
+ def get(self, scope: StateScope, key: str) -> str | None:
+ self.calls.append("get")
+ return "value"
+
+ def set(self, scope: StateScope, key: str, value: str) -> None:
+ self.calls.append("set")
+
+ def delete(self, scope: StateScope, key: str) -> None:
+ self.calls.append("delete")
+
+ def clear(self, scope: StateScope, *, all_map_indices: bool =
False) -> None:
+ self.calls.append("clear")
+
+ async def aget(self, scope: StateScope, key: str) -> str | None:
+ self.calls.append("aget")
+ return "value"
+
+ async def aset(self, scope: StateScope, key: str, value: str) ->
None:
+ self.calls.append("aset")
+
+ async def adelete(self, scope: StateScope, key: str) -> None:
+ self.calls.append("adelete")
+
+ async def aclear(self, scope: StateScope, *, all_map_indices: bool
= False) -> None:
+ self.calls.append("aclear")
+
+ return ConcreteBackend()
+
+ def test_incomplete_subclass_raises_type_error(self):
+ """A subclass that omits any abstract method cannot be instantiated."""
+
+ class IncompleteBackend(BaseStateBackend):
+ def get(self, scope: StateScope, key: str) -> str | None:
+ return None
+
+ with pytest.raises(TypeError, match="Can't instantiate abstract
class"):
+ IncompleteBackend()
+
+ def test_abstract_methods_cover_full_interface(self):
+ """BaseStateBackend enforces all 8 sync+async methods as abstract."""
+ expected = {"get", "set", "delete", "clear", "aget", "aset",
"adelete", "aclear"}
+ assert BaseStateBackend.__abstractmethods__ == expected
diff --git a/uv.lock b/uv.lock
index 0c5a314f249..73aa0f11a7d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -89,6 +89,7 @@ apache-airflow-providers-http = false
apache-airflow-providers-slack = false
apache-airflow-providers-vespa = false
apache-airflow-providers-databricks = false
+apache-airflow-shared-state = false
apache-airflow-providers-sqlite = false
apache-airflow-shared-module-loading = false
apache-airflow-providers-yandex = false
@@ -281,6 +282,7 @@ members = [
"apache-airflow-shared-secrets-backend",
"apache-airflow-shared-secrets-masker",
"apache-airflow-shared-serialization",
+ "apache-airflow-shared-state",
"apache-airflow-shared-template-rendering",
"apache-airflow-shared-timezones",
"apache-airflow-task-sdk",
@@ -1435,6 +1437,7 @@ dev = [
{ name = "apache-airflow-shared-secrets-backend" },
{ name = "apache-airflow-shared-secrets-masker" },
{ name = "apache-airflow-shared-serialization" },
+ { name = "apache-airflow-shared-state" },
{ name = "apache-airflow-shared-template-rendering" },
{ name = "apache-airflow-shared-timezones" },
{ name = "apache-airflow-task-sdk", extra = ["all"] },
@@ -1707,6 +1710,7 @@ dev = [
{ name = "apache-airflow-shared-secrets-backend", editable =
"shared/secrets_backend" },
{ name = "apache-airflow-shared-secrets-masker", editable =
"shared/secrets_masker" },
{ name = "apache-airflow-shared-serialization", editable =
"shared/serialization" },
+ { name = "apache-airflow-shared-state", editable = "shared/state" },
{ name = "apache-airflow-shared-template-rendering", editable =
"shared/template_rendering" },
{ name = "apache-airflow-shared-timezones", editable = "shared/timezones"
},
{ name = "apache-airflow-task-sdk", extras = ["all"], editable =
"task-sdk" },
@@ -8360,6 +8364,25 @@ mypy = [
dev = [{ name = "apache-airflow-devel-common", editable = "devel-common" }]
mypy = [{ name = "apache-airflow-devel-common", extras = ["mypy"], editable =
"devel-common" }]
+[[package]]
+name = "apache-airflow-shared-state"
+version = "0.0"
+source = { editable = "shared/state" }
+
+[package.dev-dependencies]
+dev = [
+ { name = "apache-airflow-devel-common" },
+]
+mypy = [
+ { name = "apache-airflow-devel-common", extra = ["mypy"] },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [{ name = "apache-airflow-devel-common", editable = "devel-common" }]
+mypy = [{ name = "apache-airflow-devel-common", extras = ["mypy"], editable =
"devel-common" }]
+
[[package]]
name = "apache-airflow-shared-template-rendering"
version = "0.0"