This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun 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 1ce3f17906c Add the option to select bundle version parameter on dag
run trigger endpoint (#61550)
1ce3f17906c is described below
commit 1ce3f17906c1d2356abeb21d319ad144f016c8bd
Author: Itay Adler <[email protected]>
AuthorDate: Fri Jul 10 19:54:34 2026 +0300
Add the option to select bundle version parameter on dag run trigger
endpoint (#61550)
* feat: adding the option to add bundle version parameter on dag run
trigger endpoint
* removing code comments and adding the new parameter to the schema
* feat: adding the new parameter to schema and types files
* fix: fixing format and missing field in class
* fix: fixing the usage of bundle version in orm dagrun creation
* feat: added migration file
* feat: adding unit test for bundle version parameter dagrun endpoint
* fix: fixing the unit test by removing unecessary asserts
* fix: removing duplicate dag version check and raising exception
accordingly
* fix: adding a raise exception when passing incorrect bundle version
* fix: verify exception message to raise accordingly
* fix: wiring to bundle version and moved to the correct version target
* fix: missed adding bundle_version field in 2 unit tests response assertion
* fix: adding missing bundle version field in tests
* fix: handling dag and dag's tasks correctly when passing bundle version
* feat: added new airflow exception to handle no dag version has found
* fix: assigning run dag to dag version when only using bundle version
* fix: fixing assertion equal failing
* fix: removed unecessary init files after prek hook added them
* fix: adding support for all the dag-level callbacks
* feat: adding versioning test for bundle version parameter
* fix: rebasing branch
* feat: adding the option to add bundle version parameter on dag run
trigger endpoint
* removing code comments and adding the new parameter to the schema
* feat: adding the new parameter to schema and types files
* fix: fixing format and missing field in class
* fix: fixing the usage of bundle version in orm dagrun creation
* feat: added migration file
* feat: adding unit test for bundle version parameter dagrun endpoint
* fix: fixing the unit test by removing unecessary asserts
* fix: removing duplicate dag version check and raising exception
accordingly
* fix: adding a raise exception when passing incorrect bundle version
* fix: verify exception message to raise accordingly
* fix: wiring to bundle version and moved to the correct version target
* fix: missed adding bundle_version field in 2 unit tests response assertion
* fix: adding missing bundle version field in tests
* fix: handling dag and dag's tasks correctly when passing bundle version
* feat: added new airflow exception to handle no dag version has found
* fix: assigning run dag to dag version when only using bundle version
* fix: fixing assertion equal failing
* fix: removed unecessary init files after prek hook added them
* fix: adding support for all the dag-level callbacks
* fix: prek didn't updated datamodels for airflowctl
* fix: adding bundle version support also in asset route, fixing execution
api versioning, and also getting latest version of dag with bundle version
* fix: duplicate import and duplicate methods
* fix: remove duplicate DagVersion lookup and unintended dag swap for
non-explicit triggers
* fix: reverting uv lock due to unnecessary changes
* fix: fixing test to resolve ci errors
* fix: restore uv.lock to upstream/main
* fix: remove double code, moving to new CalVer file, raising correct
exception and add more callbacks support in test
* fix: instead of always using latest dag using the correct dag if bundle
version is passed
* fix: missing fields in schema.json
* Regenerate supervisor schema snapshot after rebase onto main
The schema.json was stale: it contained legacy top-level $defs
(DagRun, DagRunState, DagRunType, JsonValue, and nested asset
reference types) that the generator no longer emits after upstream
main restructured them. bundle_version also needed to land at the
correct offset in the generated output.
* fix: consolidate bundle version resolution, fix double deserialization,
drop bundle_version from Execution API
* restored provider_dependencies.json.sha256sum
* Fix bundle_version overwritten by sync_dag_to_db and stale supervisor
schema snapshot
* revert accidently changes to uv.lock
* Move AddPartitionDateField to 2026-06-30 version to match partition_date
introduction timeline
* Remove dead AirflowBadRequest handler and unintended execution API
version split
* Derive run context from requested bundle version on trigger
* Validate partition_key and allowed_run_types against the requested bundle
version
* Revert accidental uv.lock
* Regenerate private UI OpenAPI spec
* Removing unnecessary callback attributes
* Remove unnecessary test due to remove bundle version from execution api
---------
Co-authored-by: Itay Adler <[email protected]>
Co-authored-by: Itay Adler <[email protected]>
---
.../api_fastapi/core_api/datamodels/dag_run.py | 1 +
.../core_api/openapi/v2-rest-api-generated.yaml | 10 +
.../api_fastapi/core_api/routes/public/assets.py | 36 +++-
.../api_fastapi/core_api/routes/public/dag_run.py | 58 ++++--
airflow-core/src/airflow/exceptions.py | 4 +
airflow-core/src/airflow/models/dag_version.py | 9 +-
.../src/airflow/serialization/definitions/dag.py | 57 ++++--
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 22 +++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 2 +
.../core_api/routes/public/test_assets.py | 88 +++++++++
.../core_api/routes/public/test_dag_run.py | 217 ++++++++++++++++++++-
airflow-core/tests/unit/models/test_dag.py | 118 +++++++++++
.../src/airflowctl/api/datamodels/generated.py | 2 +
devel-common/src/tests_common/test_utils/dag.py | 10 +-
14 files changed, 593 insertions(+), 41 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
index 91a60cb776f..cae9d3a8339 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
@@ -228,6 +228,7 @@ class TriggerDAGRunPostBody(StrictBaseModel):
conf: dict | None = Field(default_factory=dict)
note: str | None = None
partition_key: str | None = None
+ bundle_version: str | None = None
@model_validator(mode="after")
def check_data_intervals(self):
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index d3a1d124943..005da0c6f4b 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -15274,6 +15274,11 @@ components:
- type: string
- type: 'null'
title: Partition Key
+ bundle_version:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Bundle Version
additionalProperties: false
type: object
title: MaterializeAssetBody
@@ -16807,6 +16812,11 @@ components:
- type: string
- type: 'null'
title: Partition Key
+ bundle_version:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Bundle Version
additionalProperties: false
type: object
required:
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py
index d83ed8ec8d8..424938af86d 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py
@@ -75,7 +75,7 @@ from airflow.api_fastapi.core_api.security import (
from airflow.api_fastapi.logging.decorators import action_logging
from airflow.assets.manager import asset_manager
from airflow.configuration import conf
-from airflow.exceptions import ParamValidationError
+from airflow.exceptions import DagVersionNotFound, ParamValidationError
from airflow.models.asset import (
AssetAliasModel,
AssetDagRunQueue,
@@ -84,6 +84,7 @@ from airflow.models.asset import (
AssetWatcherModel,
TaskOutletAssetReference,
)
+from airflow.models.dag_version import DagVersion
from airflow.typing_compat import Unpack
from airflow.utils.state import DagRunState
from airflow.utils.types import DagRunTriggeredByType, DagRunType
@@ -458,14 +459,31 @@ def materialize_asset(
dag = get_latest_version_of_dag(dag_bag, dag_id, session)
- if dag.allowed_run_types is not None and DagRunType.ASSET_MATERIALIZATION
not in dag.allowed_run_types:
- raise HTTPException(
- status.HTTP_400_BAD_REQUEST,
- f"Dag with dag_id: '{dag_id}' does not allow asset materialization
runs",
- )
+ resolved_body = body or MaterializeAssetBody()
try:
- params = (body or MaterializeAssetBody()).validate_context(dag)
+ preloaded_dag_version = None
+ context_dag = dag
+ if resolved_body.bundle_version is not None and not
dag.disable_bundle_versioning:
+ preloaded_dag_version = DagVersion.get_latest_version(
+ dag_id, bundle_version=resolved_body.bundle_version,
load_serialized_dag=True, session=session
+ )
+ if not preloaded_dag_version:
+ raise DagVersionNotFound(
+ f"DAG with dag_id: '{dag_id}' does not have a version for
bundle_version '{resolved_body.bundle_version}'"
+ )
+ context_dag = preloaded_dag_version.serialized_dag.dag
+
+ if (
+ context_dag.allowed_run_types is not None
+ and DagRunType.ASSET_MATERIALIZATION not in
context_dag.allowed_run_types
+ ):
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ f"Dag with dag_id: '{dag_id}' does not allow asset
materialization runs",
+ )
+
+ params = resolved_body.validate_context(context_dag)
return dag.create_dagrun(
run_id=params["run_id"],
logical_date=params["logical_date"],
@@ -480,9 +498,13 @@ def materialize_asset(
partition_date=params["partition_date"],
note=params["note"],
session=session,
+ bundle_version=resolved_body.bundle_version,
+ dag_version=preloaded_dag_version,
)
except (ParamValidationError, ValueError) as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
+ except DagVersionNotFound as e:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
@assets_router.get(
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
index 278f6e99119..59b3593b9ed 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
@@ -108,7 +108,7 @@ from airflow.api_fastapi.core_api.services.public.dag_run
import (
perform_clear_dag_run,
)
from airflow.api_fastapi.logging.decorators import action_logging
-from airflow.exceptions import ParamValidationError
+from airflow.exceptions import DagVersionNotFound, ParamValidationError
from airflow.models import DagModel, DagRun
from airflow.models.asset import AssetEvent
from airflow.models.dag_version import DagVersion
@@ -693,21 +693,44 @@ def trigger_dag_run(
f"Dag with dag_id: '{dag_id}' has import errors and cannot be
triggered",
)
- if dm.allowed_run_types is not None and DagRunType.MANUAL not in
dm.allowed_run_types:
- raise HTTPException(
- status.HTTP_400_BAD_REQUEST,
- f"Dag with dag_id: '{dag_id}' does not allow manual runs",
- )
-
referer = request.headers.get("referer")
if referer:
triggered_by = DagRunTriggeredByType.UI
else:
triggered_by = DagRunTriggeredByType.REST_API
- dag = get_latest_version_of_dag(dag_bag, dag_id, session)
try:
- params = body.validate_context(dag)
+ dag = get_latest_version_of_dag(dag_bag, dag_id, session)
+ preloaded_dag_version = None
+ context_dag = dag
+ if body.bundle_version is not None and not
dag.disable_bundle_versioning:
+ preloaded_dag_version = DagVersion.get_latest_version(
+ dag_id, bundle_version=body.bundle_version,
load_serialized_dag=True, session=session
+ )
+ if not preloaded_dag_version:
+ raise DagVersionNotFound(
+ f"DAG with dag_id: '{dag_id}' does not have a version for
bundle_version '{body.bundle_version}'"
+ )
+ context_dag = preloaded_dag_version.serialized_dag.dag
+
+ if (
+ context_dag.allowed_run_types is not None
+ and DagRunType.MANUAL not in context_dag.allowed_run_types
+ ):
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ f"Dag with dag_id: '{dag_id}' does not allow manual runs",
+ )
+
+ params = body.validate_context(context_dag)
+
+ if body.bundle_version is not None:
+ if dag.disable_bundle_versioning:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ f"DAG with dag_id: '{dag_id}' does not support bundle
versioning",
+ )
+
dag_run = dag.create_dagrun(
run_id=params["run_id"],
logical_date=params["logical_date"],
@@ -719,17 +742,22 @@ def trigger_dag_run(
triggering_user_name=user.get_name(),
state=DagRunState.QUEUED,
partition_key=params["partition_key"],
+ bundle_version=body.bundle_version,
+ dag_version=preloaded_dag_version,
partition_date=params["partition_date"],
session=session,
)
+
+ dag_run_note = body.note
+ if dag_run_note:
+ current_user_id = user.get_id()
+ dag_run.note = (dag_run_note, current_user_id)
+ return dag_run
+
except (ParamValidationError, ValueError) as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
-
- dag_run_note = body.note
- if dag_run_note:
- current_user_id = user.get_id()
- dag_run.note = (dag_run_note, current_user_id)
- return dag_run
+ except DagVersionNotFound as e:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
@dag_run_router.get(
diff --git a/airflow-core/src/airflow/exceptions.py
b/airflow-core/src/airflow/exceptions.py
index addd4f8f2a5..cb45b050fce 100644
--- a/airflow-core/src/airflow/exceptions.py
+++ b/airflow-core/src/airflow/exceptions.py
@@ -141,6 +141,10 @@ class DagRunNotFound(AirflowNotFoundException):
"""Raise when a DAG Run is not available in the system."""
+class DagVersionNotFound(AirflowNotFoundException):
+ """Raised when a DagVersion for the given dag_id / bundle_version is not
found."""
+
+
class DagNotPartitionedError(ValueError):
"""Raise when a partition_key is supplied for a Dag that is not
partitioned."""
diff --git a/airflow-core/src/airflow/models/dag_version.py
b/airflow-core/src/airflow/models/dag_version.py
index eb0a5204956..8aa7efcab01 100644
--- a/airflow-core/src/airflow/models/dag_version.py
+++ b/airflow-core/src/airflow/models/dag_version.py
@@ -154,6 +154,7 @@ class DagVersion(Base):
bundle_version: str | None = None,
load_dag_model: bool = False,
load_bundle_model: bool = False,
+ load_serialized_dag: bool = False,
) -> Select:
"""
Get the select object to get the latest version of the DAG.
@@ -162,7 +163,7 @@ class DagVersion(Base):
:return: The select object.
"""
query = select(cls).where(cls.dag_id == dag_id)
- if bundle_version:
+ if bundle_version is not None:
query = query.where(cls.bundle_version == bundle_version)
if load_dag_model:
@@ -171,6 +172,9 @@ class DagVersion(Base):
if load_bundle_model:
query = query.options(joinedload(cls.bundle))
+ if load_serialized_dag:
+ query = query.options(joinedload(cls.serialized_dag))
+
# Order by version_number, not created_at: version_number is monotonic
and unique per
# dag_id, so it is deterministic even when two versions share a
created_at timestamp.
# write_dag relies on this select to compute the next version_number;
ordering by
@@ -188,6 +192,7 @@ class DagVersion(Base):
bundle_version: str | None = None,
load_dag_model: bool = False,
load_bundle_model: bool = False,
+ load_serialized_dag: bool = False,
session: Session = NEW_SESSION,
) -> DagVersion | None:
"""
@@ -197,6 +202,7 @@ class DagVersion(Base):
:param session: The database session.
:param load_dag_model: Whether to load the DAG model.
:param load_bundle_model: Whether to load the DagBundle model.
+ :param load_serialized_dag: Whether to eagerly load the serialized DAG.
:return: The latest version of the DAG or None if not found.
"""
return session.scalar(
@@ -205,6 +211,7 @@ class DagVersion(Base):
bundle_version=bundle_version,
load_dag_model=load_dag_model,
load_bundle_model=load_bundle_model,
+ load_serialized_dag=load_serialized_dag,
)
)
diff --git a/airflow-core/src/airflow/serialization/definitions/dag.py
b/airflow-core/src/airflow/serialization/definitions/dag.py
index abae0f97785..8ed0fee2cca 100644
--- a/airflow-core/src/airflow/serialization/definitions/dag.py
+++ b/airflow-core/src/airflow/serialization/definitions/dag.py
@@ -36,6 +36,7 @@ from airflow.configuration import conf as airflow_conf
from airflow.exceptions import (
AirflowException,
DagNotPartitionedError,
+ DagVersionNotFound,
InvalidPartitionKeyError,
NodeNotFound,
TaskNotFound,
@@ -583,8 +584,10 @@ class SerializedDAG:
creating_job_id: int | None = None,
backfill_id: NonNegativeInt | None = None,
partition_key: str | None = None,
+ bundle_version: str | None = None,
partition_date: datetime.datetime | None = None,
note: str | None = None,
+ dag_version: DagVersion | None = None,
session: Session = NEW_SESSION,
) -> DagRun:
"""
@@ -652,10 +655,28 @@ class SerializedDAG:
f"is reserved for {inferred_run_type.value} runs"
)
- self.validate_partition_key(partition_key)
-
# todo: AIP-78 add verification that if run type is backfill then we
have a backfill id
- copied_params = self.params.deep_merge(conf)
+
+ # When triggering against a specific bundle version, resolve that
version first so
+ # partition_key and conf are validated against it (not the live/latest
dag).
+ if bundle_version is not None:
+ if self.disable_bundle_versioning:
+ raise ValueError(f"DAG with dag_id: '{self.dag_id}' does not
support bundle versioning")
+ if dag_version is None:
+ dag_version = DagVersion.get_latest_version(
+ self.dag_id, bundle_version=bundle_version,
load_serialized_dag=True, session=session
+ )
+ if not dag_version:
+ raise DagVersionNotFound(
+ f"DAG with dag_id: '{self.dag_id}' does not have a
version for bundle_version '{bundle_version}'"
+ )
+ params_dag = dag_version.serialized_dag.dag
+ else:
+ params_dag = self
+
+ params_dag.validate_partition_key(partition_key)
+
+ copied_params = params_dag.params.deep_merge(conf)
copied_params.validate()
orm_dagrun = _create_orm_dagrun(
dag=self,
@@ -672,12 +693,15 @@ class SerializedDAG:
triggered_by=triggered_by,
triggering_user_name=triggering_user_name,
partition_key=partition_key,
+ bundle_version=bundle_version,
partition_date=partition_date,
note=note,
+ dag_version=dag_version,
+ resolved_dag=params_dag if bundle_version is not None else None,
session=session,
)
- if self.deadline:
+ if params_dag.deadline:
self._process_dagrun_deadline_alerts(orm_dagrun, session)
return orm_dagrun
@@ -1393,16 +1417,25 @@ def _create_orm_dagrun(
triggered_by: DagRunTriggeredByType,
triggering_user_name: str | None = None,
partition_key: str | None = None,
+ bundle_version: str | None = None,
partition_date: datetime.datetime | None = None,
note: str | None = None,
+ dag_version: DagVersion | None = None,
+ resolved_dag: SerializedDAG | None = None,
session: Session = NEW_SESSION,
) -> DagRun:
- bundle_version = None
- if not dag.disable_bundle_versioning:
- bundle_version = session.scalar(
- select(DagModel.bundle_version).where(DagModel.dag_id ==
dag.dag_id),
- )
- dag_version = DagVersion.get_latest_version(dag.dag_id, session=session)
+ resolved_bundle_version: str | None = None
+ use_resolved_dag = False
+ if dag_version is not None:
+ resolved_bundle_version = bundle_version
+ use_resolved_dag = True
+ else:
+ if not dag.disable_bundle_versioning:
+ resolved_bundle_version = session.scalar(
+ select(DagModel.bundle_version).where(DagModel.dag_id ==
dag.dag_id)
+ )
+ dag_version = DagVersion.get_latest_version(dag.dag_id,
session=session)
+
if not dag_version:
raise AirflowException(f"Cannot create DagRun for DAG {dag.dag_id}
because the dag is not serialized")
@@ -1420,7 +1453,7 @@ def _create_orm_dagrun(
triggered_by=triggered_by,
triggering_user_name=triggering_user_name,
backfill_id=backfill_id,
- bundle_version=bundle_version,
+ bundle_version=resolved_bundle_version,
partition_key=partition_key,
partition_date=partition_date,
note=note,
@@ -1433,6 +1466,8 @@ def _create_orm_dagrun(
session.add(run)
session.flush()
run.dag = dag
+ if use_resolved_dag:
+ run.dag = resolved_dag if resolved_dag is not None else
dag_version.serialized_dag.dag
# create the associated task instances
# state is None at the moment of creation
run.verify_integrity(session=session, dag_version_id=dag_version.id)
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index df738113acc..e1880fdd4ea 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -5652,6 +5652,17 @@ export const $MaterializeAssetBody = {
}
],
title: 'Partition Key'
+ },
+ bundle_version: {
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Bundle Version'
}
},
additionalProperties: false,
@@ -7959,6 +7970,17 @@ export const $TriggerDAGRunPostBody = {
}
],
title: 'Partition Key'
+ },
+ bundle_version: {
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Bundle Version'
}
},
additionalProperties: false,
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 111ba9204ef..750202cd7fb 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -1482,6 +1482,7 @@ export type MaterializeAssetBody = {
} | null;
note?: string | null;
partition_key?: string | null;
+ bundle_version?: string | null;
};
/**
@@ -1989,6 +1990,7 @@ export type TriggerDAGRunPostBody = {
} | null;
note?: string | null;
partition_key?: string | null;
+ bundle_version?: string | null;
};
/**
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
index 1ffa8552b84..d5c56d133ad 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
@@ -1763,6 +1763,43 @@ class TestPostAssetMaterialize(TestAssets):
== f"Dag with dag_id: '{self.DAG_ASSET1_ID}' does not allow asset
materialization runs"
)
+ def test_materialize_allowed_run_types_from_requested_version(self,
test_client, session, dag_maker):
+ """Asset materialization allowed_run_types is enforced from the
requested bundle version, not latest."""
+ bundle_name = "allowed_run_types_bundle"
+ asset = session.get(AssetModel, 1).to_serialized()
+
+ with dag_maker(
+ self.DAG_ASSET1_ID,
+ bundle_name=bundle_name,
+ bundle_version="v1",
+ schedule=None,
+ session=session,
+ ):
+ EmptyOperator(task_id="task_v1", outlets=asset)
+
+ with dag_maker(
+ self.DAG_ASSET1_ID,
+ bundle_name=bundle_name,
+ bundle_version="v2",
+ schedule="@daily",
+ allowed_run_types=[DagRunType.SCHEDULED],
+ session=session,
+ ):
+ EmptyOperator(task_id="task_v2", outlets=asset)
+
+ # v1 allows materialization; latest v2 does not. Requesting v1 must
succeed.
+ response = test_client.post("/assets/1/materialize",
json={"bundle_version": "v1"})
+ assert response.status_code == 200
+ assert response.json()["bundle_version"] == "v1"
+
+ # Without bundle_version the latest (v2) governs and rejects the run.
+ response = test_client.post("/assets/1/materialize")
+ assert response.status_code == 400
+ assert (
+ response.json()["detail"]
+ == f"Dag with dag_id: '{self.DAG_ASSET1_ID}' does not allow asset
materialization runs"
+ )
+
@pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
def test_should_respond_403_when_user_cannot_trigger_dag(self,
test_client):
with mock.patch(
@@ -1784,6 +1821,57 @@ class TestPostAssetMaterialize(TestAssets):
user=mock.ANY,
)
+ def test_should_respond_with_bundle_version(self, test_client, session,
dag_maker):
+ """Test that asset materialization respects bundle_version
parameter."""
+ bundle_name = "testing_bundle"
+ asset = session.get(AssetModel, 1).to_serialized()
+
+ with dag_maker(
+ self.DAG_ASSET1_ID,
+ bundle_name=bundle_name,
+ bundle_version="v1",
+ schedule=None,
+ session=session,
+ ):
+ EmptyOperator(task_id="task_v1", outlets=asset)
+
+ with dag_maker(
+ self.DAG_ASSET1_ID,
+ bundle_name=bundle_name,
+ bundle_version="v2",
+ schedule=None,
+ session=session,
+ ):
+ EmptyOperator(task_id="task_v2", outlets=asset)
+
+ response = test_client.post("/assets/1/materialize",
json={"bundle_version": "v1"})
+ assert response.status_code == 200
+ assert response.json()["bundle_version"] == "v1"
+
+ response = test_client.post("/assets/1/materialize",
json={"bundle_version": "invalid_version"})
+ assert response.status_code == 404
+ assert (
+ f"DAG with dag_id: '{self.DAG_ASSET1_ID}' does not have a version
for bundle_version 'invalid_version'"
+ in response.json()["detail"]
+ )
+
+ with dag_maker(
+ self.DAG_ASSET1_ID,
+ bundle_name=bundle_name,
+ bundle_version="v3",
+ schedule=None,
+ session=session,
+ ):
+ EmptyOperator(task_id="task_v3", outlets=asset)
+ dag_maker.dag.disable_bundle_versioning = True
+
+ response = test_client.post("/assets/1/materialize",
json={"bundle_version": "v1"})
+ assert response.status_code == 400
+ assert (
+ f"DAG with dag_id: '{self.DAG_ASSET1_ID}' does not support bundle
versioning"
+ in response.json()["detail"]
+ )
+
@pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
def test_should_respond_400_on_invalid_dag_run_id(self, test_client):
"""A dag_run_id containing '..' triggers ValueError in
DagRun.validate_run_id.
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
index d21219156aa..3b1422c79c6 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
@@ -3293,12 +3293,22 @@ class TestTriggerDagRun:
== "Dag with dag_id: 'import_errors' has import errors and cannot
be triggered"
)
- def test_should_respond_400_if_manual_runs_denied(self, test_client,
session, testing_dag_bundle):
+ def test_should_respond_400_if_manual_runs_denied(self, test_client,
session, dag_maker):
now = timezone.utcnow().isoformat()
- self._dags_for_trigger_tests(session)
- response = test_client.post("/dags/allowed_scheduled/dagRuns",
json={"logical_date": now})
+ dag_id = "allowed_scheduled"
+ with dag_maker(
+ dag_id=dag_id,
+ schedule="@daily",
+ allowed_run_types=[DagRunType.SCHEDULED],
+ session=session,
+ serialized=True,
+ ):
+ EmptyOperator(task_id="task")
+ session.commit()
+
+ response = test_client.post(f"/dags/{dag_id}/dagRuns",
json={"logical_date": now})
assert response.status_code == 400
- assert response.json()["detail"] == "Dag with dag_id:
'allowed_scheduled' does not allow manual runs"
+ assert response.json()["detail"] == f"Dag with dag_id: '{dag_id}' does
not allow manual runs"
@time_machine.travel(timezone.utcnow(), tick=False)
@pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
@@ -3505,6 +3515,205 @@ class TestTriggerDagRun:
run = session.scalars(select(DagRun).where(DagRun.run_id ==
run_id_without_logical_date)).one()
assert run.dag_id == custom_dag_id
+ @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+ def test_trigger_dag_run_with_bundle_version(self, test_client, session,
dag_maker):
+ """Test triggering a DAG run with a specific bundle version."""
+ from tests_common.test_utils.dag import sync_dag_to_db
+
+ dag_id = "test_bundle_version_dag"
+ bundle_name = "testing_bundle"
+
+ with dag_maker(
+ dag_id=dag_id,
+ bundle_name=bundle_name,
+ bundle_version="v1",
+ session=session,
+ ) as dag1:
+ EmptyOperator(task_id="task_1")
+ sync_dag_to_db(dag1, bundle_name=bundle_name, bundle_version="v1")
+
+ with dag_maker(
+ dag_id=dag_id,
+ bundle_name=bundle_name,
+ bundle_version="v2",
+ session=session,
+ ) as dag2:
+ EmptyOperator(task_id="task_1")
+ EmptyOperator(task_id="task_2")
+ sync_dag_to_db(dag2, bundle_name=bundle_name, bundle_version="v2")
+
+ response = test_client.post(
+ f"/dags/{dag_id}/dagRuns", json={"logical_date":
"2024-01-01T00:00:00Z", "bundle_version": "v1"}
+ )
+ assert response.status_code == 200
+ assert response.json()["dag_versions"][0]["bundle_version"] == "v1"
+ run_id_v1 = response.json()["dag_run_id"]
+ dr_v1 = session.scalars(select(DagRun).where(DagRun.run_id ==
run_id_v1)).one()
+ assert {ti.task_id for ti in dr_v1.task_instances} == {"task_1"}
+
+ response = test_client.post(
+ f"/dags/{dag_id}/dagRuns",
+ json={
+ "logical_date": "2024-01-02T00:00:00Z",
+ },
+ )
+ assert response.status_code == 200
+ assert response.json()["dag_versions"][0]["bundle_version"] == "v2"
+ run_id_v2 = response.json()["dag_run_id"]
+ dr_v2 = session.scalars(select(DagRun).where(DagRun.run_id ==
run_id_v2)).one()
+ assert {ti.task_id for ti in dr_v2.task_instances} == {"task_1",
"task_2"}
+
+ response = test_client.post(
+ f"/dags/{dag_id}/dagRuns",
+ json={"logical_date": "2024-01-03T00:00:00Z", "bundle_version":
"invalid_version"},
+ )
+ assert response.status_code == 404
+ assert (
+ f"DAG with dag_id: '{dag_id}' does not have a version for
bundle_version 'invalid_version'"
+ in response.json()["detail"]
+ )
+
+ dag2.disable_bundle_versioning = True
+ sync_dag_to_db(dag2, bundle_name=bundle_name)
+
+ response = test_client.post(
+ f"/dags/{dag_id}/dagRuns", json={"logical_date":
"2024-01-04T00:00:00Z", "bundle_version": "v1"}
+ )
+ assert response.status_code == 400
+ assert f"DAG with dag_id: '{dag_id}' does not support bundle
versioning" in response.json()["detail"]
+
+ def test_trigger_dag_run_bundle_version_validates_against_old_param_schema(
+ self, test_client, session, dag_maker
+ ):
+ """Conf is validated against the requested bundle version's param
schema, not the live dag's."""
+ from tests_common.test_utils.dag import sync_dag_to_db
+
+ dag_id = "test_bundle_param_schema_dag"
+ bundle_name = "param_schema_bundle"
+
+ with dag_maker(
+ dag_id=dag_id,
+ bundle_name=bundle_name,
+ bundle_version="v1",
+ session=session,
+ params={"env": Param("staging", type="string", enum=["staging",
"prod"])},
+ ) as dag1:
+ EmptyOperator(task_id="task_1")
+ sync_dag_to_db(dag1, bundle_name=bundle_name, bundle_version="v1")
+
+ with dag_maker(
+ dag_id=dag_id,
+ bundle_name=bundle_name,
+ bundle_version="v2",
+ session=session,
+ params={"env": Param("dev", type="string", enum=["dev", "staging",
"prod"])},
+ ) as dag2:
+ EmptyOperator(task_id="task_1")
+ sync_dag_to_db(dag2, bundle_name=bundle_name, bundle_version="v2")
+
+ # "dev" is valid for v2 but not for v1's enum — triggering v1 should
reject it.
+ response = test_client.post(
+ f"/dags/{dag_id}/dagRuns",
+ json={"logical_date": "2024-02-01T00:00:00Z", "bundle_version":
"v1", "conf": {"env": "dev"}},
+ )
+ assert response.status_code == 400
+
+ # "staging" is valid for both v1 and v2 — triggering v1 should accept
it.
+ response = test_client.post(
+ f"/dags/{dag_id}/dagRuns",
+ json={
+ "logical_date": "2024-02-02T00:00:00Z",
+ "bundle_version": "v1",
+ "conf": {"env": "staging"},
+ },
+ )
+ assert response.status_code == 200
+
+ @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+ def test_trigger_dag_run_bundle_version_uses_v1_timetable(self,
test_client, session, dag_maker):
+ """Triggering with bundle_version='v1' must derive data_interval from
v1's timetable, not v2's."""
+ from tests_common.test_utils.dag import sync_dag_to_db
+
+ dag_id = "test_bundle_timetable_dag"
+ bundle_name = "timetable_bundle"
+
+ with dag_maker(
+ dag_id=dag_id,
+ bundle_name=bundle_name,
+ bundle_version="v1",
+ schedule=CronDataIntervalTimetable("0 0 * * *", timezone="UTC"),
+ session=session,
+ ) as dag1:
+ EmptyOperator(task_id="task_1")
+ sync_dag_to_db(dag1, bundle_name=bundle_name, bundle_version="v1")
+
+ with dag_maker(
+ dag_id=dag_id,
+ bundle_name=bundle_name,
+ bundle_version="v2",
+ schedule=None,
+ session=session,
+ ) as dag2:
+ EmptyOperator(task_id="task_1")
+ sync_dag_to_db(dag2, bundle_name=bundle_name, bundle_version="v2")
+
+ response = test_client.post(
+ f"/dags/{dag_id}/dagRuns",
+ json={"logical_date": "2024-01-01T00:00:00Z", "bundle_version":
"v1"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["dag_versions"][0]["bundle_version"] == "v1"
+ # data_interval must come from v1's daily cron timetable, not v2's
null timetable.
+ # For a "0 0 * * *" cron, logical_date is the interval END, so
interval is [prev_day, logical_date].
+ assert data["data_interval_start"] == "2023-12-31T00:00:00Z"
+ assert data["data_interval_end"] == "2024-01-01T00:00:00Z"
+
+ @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+ def test_trigger_dag_run_allowed_run_types_from_requested_version(self,
test_client, session, dag_maker):
+ """allowed_run_types is enforced from the requested bundle version,
not the latest."""
+ from tests_common.test_utils.dag import sync_dag_to_db
+
+ dag_id = "test_bundle_allowed_run_types_dag"
+ bundle_name = "allowed_run_types_bundle"
+
+ with dag_maker(
+ dag_id=dag_id,
+ bundle_name=bundle_name,
+ bundle_version="v1",
+ schedule="@daily",
+ allowed_run_types=[DagRunType.MANUAL, DagRunType.SCHEDULED],
+ session=session,
+ ) as dag1:
+ EmptyOperator(task_id="task_1")
+ sync_dag_to_db(dag1, bundle_name=bundle_name, bundle_version="v1")
+
+ with dag_maker(
+ dag_id=dag_id,
+ bundle_name=bundle_name,
+ bundle_version="v2",
+ schedule="@daily",
+ allowed_run_types=[DagRunType.SCHEDULED],
+ session=session,
+ ) as dag2:
+ EmptyOperator(task_id="task_1")
+ sync_dag_to_db(dag2, bundle_name=bundle_name, bundle_version="v2")
+
+ # Latest (v2) disallows manual runs; v1 allows them. Triggering v1
must succeed.
+ response = test_client.post(
+ f"/dags/{dag_id}/dagRuns",
+ json={"logical_date": "2024-02-01T00:00:00Z", "bundle_version":
"v1"},
+ )
+ assert response.status_code == 200
+
+ # Without bundle_version the latest (v2) governs and rejects the
manual run.
+ response = test_client.post(
+ f"/dags/{dag_id}/dagRuns",
+ json={"logical_date": "2024-02-02T00:00:00Z"},
+ )
+ assert response.status_code == 400
+ assert response.json()["detail"] == f"Dag with dag_id: '{dag_id}' does
not allow manual runs"
+
def
test_should_respond_400_when_partition_key_given_for_non_partitioned_dag(self,
test_client):
"""Passing partition_key to a non-partitioned Dag via REST trigger
must return 400, not 500.
diff --git a/airflow-core/tests/unit/models/test_dag.py
b/airflow-core/tests/unit/models/test_dag.py
index 81198d8e418..6a1e9e5782c 100644
--- a/airflow-core/tests/unit/models/test_dag.py
+++ b/airflow-core/tests/unit/models/test_dag.py
@@ -1581,6 +1581,38 @@ class TestDag:
partition_key=123,
)
+ def
test_create_dagrun_partition_key_validated_against_requested_version(self,
dag_maker, session):
+ """create_dagrun validates partition_key against the requested bundle
version, not the latest."""
+ dag_id = "test_create_dagrun_partition_key_bundle_version"
+
+ with dag_maker(
+ dag_id,
+ schedule=CronPartitionTimetable("@daily", timezone="UTC"),
+ bundle_version="v1",
+ session=session,
+ ):
+ EmptyOperator(task_id="task")
+
+ with dag_maker(dag_id, schedule=None, bundle_version="v2",
session=session):
+ EmptyOperator(task_id="task")
+ session.commit()
+
+ scheduler_dag_v2 = dag_maker.serialized_dag
+
+ # Latest (v2) is not partitioned, but the requested v1 is: the key
must be
+ # accepted against v1 rather than rejected against the latest dag.
+ dr = scheduler_dag_v2.create_dagrun(
+ run_id="manual__partition_key_from_v1",
+ run_after=DEFAULT_DATE,
+ run_type=DagRunType.MANUAL,
+ state=State.NONE,
+ triggered_by=DagRunTriggeredByType.TEST,
+ partition_key="my-key",
+ bundle_version="v1",
+ session=session,
+ )
+ assert dr.partition_key == "my-key"
+
@pytest.mark.need_serialized_dag
@pytest.mark.parametrize(
("partition_key", "schedule", "should_raise"),
@@ -4383,6 +4415,92 @@ def test_disable_bundle_versioning(disable,
bundle_version, expected, dag_maker,
assert dr.bundle_version == expected
+def test_create_dagrun_uses_resolved_bundle_version_for_integrity(dag_maker,
session, clear_dags):
+ """
+ When no explicit bundle_version is passed, the live dag drives TI creation
and
+ created_dag_version points to the latest serialized version.
DagRun.bundle_version
+ still records the DagModel.bundle_version for auditing purposes.
+ """
+ with dag_maker(
+ dag_id="test_dag_bundle_version_integrity",
+ session=session,
+ serialized=True,
+ bundle_version="v1",
+ ) as _dag_v1:
+ EmptyOperator(task_id="t1")
+
+ with dag_maker(
+ dag_id="test_dag_bundle_version_integrity",
+ session=session,
+ serialized=True,
+ bundle_version="v2",
+ ) as dag_v2:
+ EmptyOperator(task_id="t1")
+ EmptyOperator(task_id="t2")
+
+ dag_model = session.scalar(select(DagModel).where(DagModel.dag_id ==
dag_v2.dag_id))
+ dag_model.bundle_version = "v1"
+ session.commit()
+
+ dr = dag_v2.create_dagrun(
+ run_id="bundle_version_integrity",
+ run_after=pendulum.now(),
+ run_type="manual",
+ triggered_by=DagRunTriggeredByType.TEST,
+ state=None,
+ )
+
+ # DagRun.bundle_version records the DagModel value at trigger time (audit
field).
+ assert dr.bundle_version == "v1"
+ # created_dag_version reflects the latest serialized version (v2), not the
DagModel audit value.
+ assert dr.created_dag_version.bundle_version == "v2"
+ # TIs come from the live dag (dag_v2 with t1+t2), not from the old
serialized version.
+ assert {ti.task_id for ti in dr.get_task_instances(session=session)} ==
{"t1", "t2"}
+
+
+def test_create_dagrun_without_bundle_version_uses_live_dag(dag_maker,
session, clear_dags):
+ """
+ When no explicit bundle_version is passed, TIs are created from the live
dag even if
+ DagModel.bundle_version points to an older version. This confirms
backfills and other
+ callers that don't pass bundle_version are unaffected by the
bundle_version feature.
+ """
+ with dag_maker(
+ dag_id="test_dag_backfill_bundle_version",
+ session=session,
+ serialized=True,
+ bundle_version="v1",
+ ) as _dag_v1:
+ EmptyOperator(task_id="t1")
+
+ with dag_maker(
+ dag_id="test_dag_backfill_bundle_version",
+ session=session,
+ serialized=True,
+ bundle_version="v2",
+ ) as dag_v2:
+ EmptyOperator(task_id="t1")
+ EmptyOperator(task_id="t2")
+
+ dag_model = session.scalar(select(DagModel).where(DagModel.dag_id ==
dag_v2.dag_id))
+ dag_model.bundle_version = "v1"
+ session.commit()
+
+ dr = dag_v2.create_dagrun(
+ run_id="no_bundle_version_uses_live_dag",
+ run_after=pendulum.now(),
+ run_type="manual",
+ triggered_by=DagRunTriggeredByType.TEST,
+ state=None,
+ )
+
+ # TIs come from the live dag (dag_v2), not from the v1 serialized version.
+ assert {ti.task_id for ti in dr.get_task_instances(session=session)} ==
{"t1", "t2"}
+ # created_dag_version reflects the latest serialization (v2).
+ assert dr.created_dag_version.bundle_version == "v2"
+ # DagRun.bundle_version still records the DagModel value at trigger time.
+ assert dr.bundle_version == "v1"
+
+
def test_get_run_data_interval():
with DAG("dag", schedule=None, start_date=DEFAULT_DATE) as dag:
EmptyOperator(task_id="empty_task")
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index d4e39a8ad19..9bb75d8dd87 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -830,6 +830,7 @@ class MaterializeAssetBody(BaseModel):
conf: Annotated[dict[str, Any] | None, Field(title="Conf")] = None
note: Annotated[str | None, Field(title="Note")] = None
partition_key: Annotated[str | None, Field(title="Partition Key")] = None
+ bundle_version: Annotated[str | None, Field(title="Bundle Version")] = None
class NewTaskResponse(BaseModel):
@@ -1160,6 +1161,7 @@ class TriggerDAGRunPostBody(BaseModel):
conf: Annotated[dict[str, Any] | None, Field(title="Conf")] = None
note: Annotated[str | None, Field(title="Note")] = None
partition_key: Annotated[str | None, Field(title="Partition Key")] = None
+ bundle_version: Annotated[str | None, Field(title="Bundle Version")] = None
class TriggerResponse(BaseModel):
diff --git a/devel-common/src/tests_common/test_utils/dag.py
b/devel-common/src/tests_common/test_utils/dag.py
index 6e02ddf61ab..891176eb499 100644
--- a/devel-common/src/tests_common/test_utils/dag.py
+++ b/devel-common/src/tests_common/test_utils/dag.py
@@ -41,15 +41,17 @@ def create_scheduler_dag(dag: DAG | SerializedDAG) ->
SerializedDAG:
def sync_dag_to_db(
dag: DAG,
bundle_name: str = "testing",
+ bundle_version: str | None = None,
session: Session = NEW_SESSION,
) -> SerializedDAG:
- return sync_dags_to_db([dag], bundle_name=bundle_name, session=session)[0]
+ return sync_dags_to_db([dag], bundle_name=bundle_name,
bundle_version=bundle_version, session=session)[0]
@provide_session
def sync_dags_to_db(
dags: Collection[DAG],
bundle_name: str = "testing",
+ bundle_version: str | None = None,
session: Session = NEW_SESSION,
) -> Sequence[SerializedDAG]:
"""
@@ -68,10 +70,12 @@ def sync_dags_to_db(
def _write_dag(dag: DAG) -> SerializedDAG:
data = DagSerialization.to_dict(dag)
- SerializedDagModel.write_dag(LazyDeserializedDAG(data=data),
bundle_name, session=session)
+ SerializedDagModel.write_dag(
+ LazyDeserializedDAG(data=data), bundle_name, bundle_version,
session=session
+ )
return DagSerialization.from_dict(data)
- SerializedDAG.bulk_write_to_db(bundle_name, None, dags, session=session)
+ SerializedDAG.bulk_write_to_db(bundle_name, bundle_version, dags,
session=session)
scheduler_dags = [_write_dag(dag) for dag in dags]
session.flush()
return scheduler_dags