This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 98c4b8306f3 Store `allow_producer_teams` in
`dag_schedule_asset_reference` instead of `asset` (#66487)
98c4b8306f3 is described below
commit 98c4b8306f3af0829d6fbfba9571cb9f37975df4
Author: Vincent <[email protected]>
AuthorDate: Fri May 8 08:19:03 2026 -0400
Store `allow_producer_teams` in `dag_schedule_asset_reference` instead of
`asset` (#66487)
---
airflow-core/docs/migrations-ref.rst | 5 +-
airflow-core/src/airflow/assets/manager.py | 9 +++-
.../src/airflow/dag_processing/collection.py | 16 ++++--
...oducer_teams_to_dag_schedule_asset_reference.py | 52 ++++++++++++++++++
airflow-core/src/airflow/models/asset.py | 15 +++---
airflow-core/src/airflow/serialization/decoders.py | 1 +
airflow-core/src/airflow/serialization/encoders.py | 2 +
airflow-core/src/airflow/utils/db.py | 2 +-
airflow-core/tests/unit/assets/test_manager.py | 22 +++++---
.../tests/unit/dag_processing/test_collection.py | 39 ++++++++++++++
airflow-core/tests/unit/models/test_asset.py | 63 ----------------------
11 files changed, 140 insertions(+), 86 deletions(-)
diff --git a/airflow-core/docs/migrations-ref.rst
b/airflow-core/docs/migrations-ref.rst
index 71687ef384b..a9502d28fe9 100644
--- a/airflow-core/docs/migrations-ref.rst
+++ b/airflow-core/docs/migrations-ref.rst
@@ -39,7 +39,10 @@ Here's the list of all the Database Migrations that are
executed via when you ru
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
| Revision ID | Revises ID | Airflow Version | Description
|
+=========================+==================+===================+==============================================================+
-| ``b8f3e4a1d2c9`` (head) | ``fde9ed84d07b`` | ``3.3.0`` | Add
retry_delay_override and retry_reason to task_instance. |
+| ``a7f3b2c1d4e5`` (head) | ``b8f3e4a1d2c9`` | ``3.3.0`` | Add
allow_producer_teams column to |
+| | | |
dag_schedule_asset_reference table. |
++-------------------------+------------------+-------------------+--------------------------------------------------------------+
+| ``b8f3e4a1d2c9`` | ``fde9ed84d07b`` | ``3.3.0`` | Add
retry_delay_override and retry_reason to task_instance. |
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
| ``fde9ed84d07b`` | ``9fabad868fdb`` | ``3.3.0`` | Add
task_state and asset_state tables. |
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
diff --git a/airflow-core/src/airflow/assets/manager.py
b/airflow-core/src/airflow/assets/manager.py
index bcfd7cfd061..9f333efe851 100644
--- a/airflow-core/src/airflow/assets/manager.py
+++ b/airflow-core/src/airflow/assets/manager.py
@@ -187,7 +187,7 @@ class AssetManager(LoggingMixin):
:param dags_to_queue: set of DagModel instances to potentially queue.
:param source_teams: set of team names the source belongs to. Empty
set means teamless.
- :param asset_model: the AssetModel whose extra may contain
allow_producer_teams.
+ :param asset_model: the AssetModel whose scheduled_dags carry
allow_producer_teams.
:param source_is_api: True if the event was triggered via the REST API
(not a DAG task).
:param session: SQLAlchemy session.
"""
@@ -199,12 +199,16 @@ class AssetManager(LoggingMixin):
from airflow.models.dag import DagModel
- allow_producer_teams: list[str] =
asset_model.extra.get("allow_producer_teams", [])
is_teamless_source = len(source_teams) == 0
dag_ids = [dag.dag_id for dag in dags_to_queue]
dag_id_to_team = DagModel.get_dag_id_to_team_name_mapping(dag_ids,
session=session)
+ # Build per-consumer allow_producer_teams from the schedule reference
rows.
+ dag_id_to_allow_teams: dict[str, list[str]] = {
+ ref.dag_id: ref.allow_producer_teams or [] for ref in
asset_model.scheduled_dags
+ }
+
filtered = set()
for dag in dags_to_queue:
consumer_team = dag_id_to_team.get(dag.dag_id)
@@ -227,6 +231,7 @@ class AssetManager(LoggingMixin):
filtered.add(dag)
continue
+ allow_producer_teams = dag_id_to_allow_teams.get(dag.dag_id, [])
if source_teams & set(allow_producer_teams):
# Cross-team via allow_producer_teams
filtered.add(dag)
diff --git a/airflow-core/src/airflow/dag_processing/collection.py
b/airflow-core/src/airflow/dag_processing/collection.py
index 4df0b589c1f..6f8bc752bbc 100644
--- a/airflow-core/src/airflow/dag_processing/collection.py
+++ b/airflow-core/src/airflow/dag_processing/collection.py
@@ -891,15 +891,23 @@ class AssetModelOperation(NamedTuple):
if not references:
dags[dag_id].schedule_asset_references = []
continue
- referenced_asset_ids = {asset.id for asset in (assets[r.name,
r.uri] for r in references)}
+ referenced_assets = {assets[r.name, r.uri]: r.allow_producer_teams
for r in references}
+ referenced_asset_ids = {a.id for a in referenced_assets}
orm_refs = {r.asset_id: r for r in
dags[dag_id].schedule_asset_references}
for asset_id, ref in orm_refs.items():
if asset_id not in referenced_asset_ids:
session.delete(ref)
+ for asset_model, teams in referenced_assets.items():
+ if asset_model.id in orm_refs:
+ orm_refs[asset_model.id].allow_producer_teams = teams
session.bulk_save_objects(
- DagScheduleAssetReference(asset_id=asset_id, dag_id=dag_id)
- for asset_id in referenced_asset_ids
- if asset_id not in orm_refs
+ DagScheduleAssetReference(
+ asset_id=asset_model.id,
+ dag_id=dag_id,
+ allow_producer_teams=teams,
+ )
+ for asset_model, teams in referenced_assets.items()
+ if asset_model.id not in orm_refs
)
def add_dag_asset_alias_references(
diff --git
a/airflow-core/src/airflow/migrations/versions/0114_3_3_0_add_allow_producer_teams_to_dag_schedule_asset_reference.py
b/airflow-core/src/airflow/migrations/versions/0114_3_3_0_add_allow_producer_teams_to_dag_schedule_asset_reference.py
new file mode 100644
index 00000000000..d354607a84f
--- /dev/null
+++
b/airflow-core/src/airflow/migrations/versions/0114_3_3_0_add_allow_producer_teams_to_dag_schedule_asset_reference.py
@@ -0,0 +1,52 @@
+#
+# 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 allow_producer_teams column to dag_schedule_asset_reference table.
+
+Revision ID: a7f3b2c1d4e5
+Revises: b8f3e4a1d2c9
+Create Date: 2026-05-06 12:00:00.000000
+
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "a7f3b2c1d4e5"
+down_revision = "b8f3e4a1d2c9"
+branch_labels = None
+depends_on = None
+airflow_version = "3.3.0"
+
+
+def upgrade():
+ """Add allow_producer_teams column to dag_schedule_asset_reference."""
+ with op.batch_alter_table("dag_schedule_asset_reference", schema=None) as
batch_op:
+ batch_op.add_column(sa.Column("allow_producer_teams", sa.JSON(),
nullable=True))
+
+
+def downgrade():
+ """Remove allow_producer_teams column from dag_schedule_asset_reference."""
+ from airflow.migrations.utils import disable_sqlite_fkeys
+
+ with disable_sqlite_fkeys(op):
+ with op.batch_alter_table("dag_schedule_asset_reference", schema=None)
as batch_op:
+ batch_op.drop_column("allow_producer_teams")
diff --git a/airflow-core/src/airflow/models/asset.py
b/airflow-core/src/airflow/models/asset.py
index 7a43e056d0d..3e8390fdfaf 100644
--- a/airflow-core/src/airflow/models/asset.py
+++ b/airflow-core/src/airflow/models/asset.py
@@ -337,10 +337,12 @@ class AssetModel(Base):
@classmethod
def from_serialized(cls, obj: SerializedAsset) -> AssetModel:
- extra = dict(obj.extra)
- if obj.allow_producer_teams:
- extra["allow_producer_teams"] = obj.allow_producer_teams
- return cls(name=obj.name, uri=obj.uri, group=obj.group, extra=extra)
+ return cls(
+ name=obj.name,
+ uri=obj.uri,
+ group=obj.group,
+ extra=dict(obj.extra),
+ )
def __init__(self, name: str = "", uri: str = "", **kwargs):
if not name and not uri:
@@ -379,14 +381,12 @@ class AssetModel(Base):
def to_serialized(self) -> SerializedAsset:
from airflow.serialization.definitions.assets import SerializedAsset
- extra = {k: v for k, v in self.extra.items() if k !=
"allow_producer_teams"}
return SerializedAsset(
name=self.name,
uri=self.uri,
group=self.group,
- extra=extra,
+ extra=self.extra,
watchers=[],
- allow_producer_teams=self.extra.get("allow_producer_teams", []),
)
def add_trigger(self, trigger: Trigger, watcher_name: str):
@@ -591,6 +591,7 @@ class DagScheduleAssetReference(Base):
asset_id: Mapped[int] = mapped_column(Integer, primary_key=True,
nullable=False)
dag_id: Mapped[str] = mapped_column(StringID(), primary_key=True,
nullable=False)
+ allow_producer_teams: Mapped[list | None] = mapped_column(sa.JSON(),
nullable=True)
created_at: Mapped[datetime] = mapped_column(UtcDateTime,
default=timezone.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
UtcDateTime, default=timezone.utcnow, onupdate=timezone.utcnow,
nullable=False
diff --git a/airflow-core/src/airflow/serialization/decoders.py
b/airflow-core/src/airflow/serialization/decoders.py
index 27db6134b13..683efdc87e6 100644
--- a/airflow-core/src/airflow/serialization/decoders.py
+++ b/airflow-core/src/airflow/serialization/decoders.py
@@ -106,6 +106,7 @@ def _decode_asset(var: dict[str, Any]):
)
for watcher in watchers
],
+ allow_producer_teams=var.get("allow_producer_teams", []),
)
diff --git a/airflow-core/src/airflow/serialization/encoders.py
b/airflow-core/src/airflow/serialization/encoders.py
index 87d7ef1f137..9e341cbe783 100644
--- a/airflow-core/src/airflow/serialization/encoders.py
+++ b/airflow-core/src/airflow/serialization/encoders.py
@@ -190,6 +190,8 @@ def encode_asset_like(a: BaseAsset | SerializedAssetBase)
-> dict[str, Any]:
d = {"__type": DAT.ASSET, "name": a.name, "uri": a.uri, "group":
a.group, "extra": a.extra}
if a.watchers:
d["watchers"] = [{"name": w.name, "trigger":
encode_trigger(w.trigger)} for w in a.watchers]
+ if a.allow_producer_teams:
+ d["allow_producer_teams"] = a.allow_producer_teams
return d
case AssetAlias() | SerializedAssetAlias():
return {"__type": DAT.ASSET_ALIAS, "name": a.name, "group":
a.group}
diff --git a/airflow-core/src/airflow/utils/db.py
b/airflow-core/src/airflow/utils/db.py
index 59b16228e37..4caa9901bfb 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": "b8f3e4a1d2c9",
+ "3.3.0": "a7f3b2c1d4e5",
}
# Prefix used to identify tables holding data moved during migration.
diff --git a/airflow-core/tests/unit/assets/test_manager.py
b/airflow-core/tests/unit/assets/test_manager.py
index 03ec82139cb..1f3eead3dc2 100644
--- a/airflow-core/tests/unit/assets/test_manager.py
+++ b/airflow-core/tests/unit/assets/test_manager.py
@@ -326,12 +326,18 @@ def _make_dag(dag_id: str) -> DagModel:
return dag
-def _make_asset_model(allow_producer_teams: list[str] | None = None) ->
AssetModel:
+def _make_asset_model(
+ scheduled_dags: dict[str, list[str]] | None = None,
+) -> AssetModel:
+ """Create a mock AssetModel.
+
+ :param scheduled_dags: mapping of dag_id -> allow_producer_teams for each
consumer reference.
+ """
model = mock.Mock(spec=AssetModel)
- extra = {}
- if allow_producer_teams:
- extra["allow_producer_teams"] = allow_producer_teams
- model.extra = extra
+ model.scheduled_dags = [
+ mock.Mock(dag_id=dag_id, allow_producer_teams=teams)
+ for dag_id, teams in (scheduled_dags or {}).items()
+ ]
return model
@@ -390,7 +396,7 @@ class TestFilterDagsByTeam:
result = AssetManager._filter_dags_by_team(
dags_to_queue={dag},
source_teams={"team_a"},
- asset_model=_make_asset_model(allow_producer_teams=[]),
+ asset_model=_make_asset_model(scheduled_dags={"dag1": []}),
source_is_api=False,
session=mock.Mock(),
)
@@ -406,7 +412,7 @@ class TestFilterDagsByTeam:
result = AssetManager._filter_dags_by_team(
dags_to_queue={dag},
source_teams={"team_a"},
- asset_model=_make_asset_model(allow_producer_teams=["team_a"]),
+ asset_model=_make_asset_model(scheduled_dags={"dag1":
["team_a"]}),
source_is_api=False,
session=mock.Mock(),
)
@@ -490,7 +496,7 @@ class TestFilterDagsByTeam:
result = AssetManager._filter_dags_by_team(
dags_to_queue={dag},
source_teams={"team_a"},
- asset_model=_make_asset_model(allow_producer_teams=["team_a"]),
+ asset_model=_make_asset_model(scheduled_dags={"dag1":
["team_a"]}),
source_is_api=True,
session=mock.Mock(),
)
diff --git a/airflow-core/tests/unit/dag_processing/test_collection.py
b/airflow-core/tests/unit/dag_processing/test_collection.py
index 8dd02fd5a88..42be7792381 100644
--- a/airflow-core/tests/unit/dag_processing/test_collection.py
+++ b/airflow-core/tests/unit/dag_processing/test_collection.py
@@ -143,6 +143,45 @@ class TestAssetModelOperation:
yield
self.clean_db()
+ @pytest.mark.usefixtures("testing_dag_bundle")
+ def
test_sync_assets_preserves_allow_producer_teams_from_other_bundle(self,
dag_maker, session):
+ """When a producer bundle (without allow_producer_teams) is synced
after a consumer bundle
+ (with allow_producer_teams), the stored allow_producer_teams must not
be wiped out."""
+ from airflow.models.asset import DagScheduleAssetReference
+
+ # First sync: consumer bundle sets allow_producer_teams on the asset.
+ consumer_asset = Asset("shared_asset", allow_producer_teams=["team1",
"team2"])
+ with dag_maker(dag_id="consumer_dag", schedule=[consumer_asset]) as
consumer_dag:
+ EmptyOperator(task_id="mytask")
+
+ consumer_dags = {consumer_dag.dag_id:
LazyDeserializedDAG.from_dag(consumer_dag)}
+ orm_dags = DagModelOperation(consumer_dags, "testing",
None).add_dags(session=session)
+ asset_op = AssetModelOperation.collect(consumer_dags)
+ orm_assets = asset_op.sync_assets(session=session)
+ session.flush()
+ asset_op.add_dag_asset_references(orm_dags, orm_assets,
session=session)
+ session.flush()
+
+ ref = session.scalar(
+
select(DagScheduleAssetReference).where(DagScheduleAssetReference.dag_id ==
"consumer_dag")
+ )
+ assert ref.allow_producer_teams == ["team1", "team2"]
+
+ # Second sync: producer bundle references the same asset WITHOUT
allow_producer_teams.
+ producer_asset = Asset("shared_asset")
+ with dag_maker(dag_id="producer_dag", schedule="@once") as
producer_dag:
+ EmptyOperator(task_id="produce", outlets=[producer_asset])
+
+ producer_dags = {producer_dag.dag_id:
LazyDeserializedDAG.from_dag(producer_dag)}
+ DagModelOperation(producer_dags, "testing",
None).add_dags(session=session)
+ asset_op = AssetModelOperation.collect(producer_dags)
+ asset_op.sync_assets(session=session)
+ session.flush()
+
+ # Consumer's allow_producer_teams must still be preserved.
+ session.expire(ref)
+ assert ref.allow_producer_teams == ["team1", "team2"]
+
@pytest.mark.parametrize(
("is_active", "is_paused", "expected_num_triggers"),
[
diff --git a/airflow-core/tests/unit/models/test_asset.py
b/airflow-core/tests/unit/models/test_asset.py
index 1f4ffa579f5..f4c9e5cc866 100644
--- a/airflow-core/tests/unit/models/test_asset.py
+++ b/airflow-core/tests/unit/models/test_asset.py
@@ -163,66 +163,3 @@ def test_remove_reference_for_inactive_dag(
_simulate_soft_dag_deletion("test2")
remove_references_to_deleted_dags(session=session)
assert set(session.execute(select_stmt)) == set()
-
-
-class TestAssetModelAllowProducerTeams:
- @pytest.mark.parametrize(
- ("extra", "expected_teams"),
- [
- pytest.param({"allow_producer_teams": ["team_a"]}, ["team_a"],
id="with_teams"),
- pytest.param({}, [], id="without_teams"),
- ],
- )
- def test_to_serialized_allow_producer_teams(self, extra, expected_teams):
- model = AssetModel(name="test_asset", uri="test://asset", extra=extra)
- serialized = model.to_serialized()
- assert serialized.allow_producer_teams == expected_teams
- assert "allow_producer_teams" not in serialized.extra
-
- @pytest.mark.parametrize(
- ("teams", "expected_in_extra"),
- [
- pytest.param(["team_a", "team_b"], True, id="with_teams"),
- pytest.param([], False, id="empty_teams"),
- ],
- )
- def test_from_serialized_allow_producer_teams(self, teams,
expected_in_extra):
- from airflow.serialization.definitions.assets import SerializedAsset
-
- serialized = SerializedAsset(
- name="test_asset",
- uri="test://asset",
- group="asset",
- extra={},
- watchers=[],
- allow_producer_teams=teams,
- )
- model = AssetModel.from_serialized(serialized)
- if expected_in_extra:
- assert model.extra["allow_producer_teams"] == teams
- else:
- assert "allow_producer_teams" not in model.extra
-
- @pytest.mark.parametrize(
- ("teams", "extra"),
- [
- pytest.param(["team_analytics", "team_ml"], {"some_key":
"some_value"}, id="with_teams"),
- pytest.param([], {}, id="empty_teams"),
- ],
- )
- def test_round_trip_preserves_allow_producer_teams(self, teams, extra):
- from airflow.serialization.definitions.assets import SerializedAsset
-
- serialized = SerializedAsset(
- name="test_asset",
- uri="test://asset",
- group="asset",
- extra=extra,
- watchers=[],
- allow_producer_teams=teams,
- )
- model = AssetModel.from_serialized(serialized)
- result = model.to_serialized()
- assert result.allow_producer_teams == teams
- assert "allow_producer_teams" not in result.extra
- assert result.extra == extra