diff --git a/.github/boring-cyborg.yml b/.github/boring-cyborg.yml
index 779060fcd70ae..40470aa6ae598 100644
--- a/.github/boring-cyborg.yml
+++ b/.github/boring-cyborg.yml
@@ -401,7 +401,6 @@ labelPRBasedOnFilePath:
- airflow/operators/**/*
- airflow/hooks/**/*
- airflow/sensors/**/*
- - tests/operators/**/*
- tests/hooks/**/*
- tests/sensors/**/*
- docs/apache-airflow/operators-and-hooks-ref.rst
diff --git a/tests/operators/__init__.py b/airflow/api_fastapi/execution_api/datamodels/dagrun.py
similarity index 58%
rename from tests/operators/__init__.py
rename to airflow/api_fastapi/execution_api/datamodels/dagrun.py
index 217e5db960782..1f73156cebf5a 100644
--- a/tests/operators/__init__.py
+++ b/airflow/api_fastapi/execution_api/datamodels/dagrun.py
@@ -1,4 +1,3 @@
-#
# 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
@@ -15,3 +14,25 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+
+from __future__ import annotations
+
+from pydantic import Field
+
+from airflow.api_fastapi.common.types import UtcDateTime
+from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel
+from airflow.utils.state import DagRunState
+
+
+class TriggerDAGRunPayload(StrictBaseModel):
+ """Schema for Trigger DAG Run API request."""
+
+ logical_date: UtcDateTime | None = None
+ conf: dict = Field(default_factory=dict)
+ reset_dag_run: bool = False
+
+
+class DagRunStateResponse(BaseModel):
+ """Schema for DAG Run State response."""
+
+ state: DagRunState
diff --git a/airflow/api_fastapi/execution_api/routes/__init__.py b/airflow/api_fastapi/execution_api/routes/__init__.py
index 2f4dac922351f..0c21b9c053599 100644
--- a/airflow/api_fastapi/execution_api/routes/__init__.py
+++ b/airflow/api_fastapi/execution_api/routes/__init__.py
@@ -23,6 +23,7 @@
asset_events,
assets,
connections,
+ dag_runs,
health,
task_instances,
variables,
@@ -38,6 +39,7 @@
authenticated_router.include_router(assets.router, prefix="/assets", tags=["Assets"])
authenticated_router.include_router(asset_events.router, prefix="/asset-events", tags=["Asset Events"])
authenticated_router.include_router(connections.router, prefix="/connections", tags=["Connections"])
+authenticated_router.include_router(dag_runs.router, prefix="/dag-runs", tags=["Dag Runs"])
authenticated_router.include_router(task_instances.router, prefix="/task-instances", tags=["Task Instances"])
authenticated_router.include_router(variables.router, prefix="/variables", tags=["Variables"])
authenticated_router.include_router(xcoms.router, prefix="/xcoms", tags=["XComs"])
diff --git a/airflow/api_fastapi/execution_api/routes/dag_runs.py b/airflow/api_fastapi/execution_api/routes/dag_runs.py
new file mode 100644
index 0000000000000..3a680c1ef8c69
--- /dev/null
+++ b/airflow/api_fastapi/execution_api/routes/dag_runs.py
@@ -0,0 +1,152 @@
+# 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 logging
+
+from fastapi import HTTPException, status
+from sqlalchemy import select
+
+from airflow.api.common.trigger_dag import trigger_dag
+from airflow.api_fastapi.common.db.common import SessionDep
+from airflow.api_fastapi.common.router import AirflowRouter
+from airflow.api_fastapi.execution_api.datamodels.dagrun import DagRunStateResponse, TriggerDAGRunPayload
+from airflow.exceptions import DagRunAlreadyExists
+from airflow.models.dag import DagModel
+from airflow.models.dagbag import DagBag
+from airflow.models.dagrun import DagRun
+from airflow.utils.types import DagRunTriggeredByType
+
+router = AirflowRouter()
+
+
+log = logging.getLogger(__name__)
+
+
+@router.post(
+ "/{dag_id}/{run_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+ responses={
+ status.HTTP_400_BAD_REQUEST: {"description": "DAG has import errors and cannot be triggered"},
+ status.HTTP_404_NOT_FOUND: {"description": "DAG not found for the given dag_id"},
+ status.HTTP_409_CONFLICT: {"description": "DAG Run already exists for the given dag_id"},
+ status.HTTP_422_UNPROCESSABLE_ENTITY: {"description": "Invalid payload"},
+ },
+)
+def trigger_dag_run(
+ dag_id: str,
+ run_id: str,
+ payload: TriggerDAGRunPayload,
+ session: SessionDep,
+):
+ """Trigger a DAG Run."""
+ dm = session.scalar(select(DagModel).where(DagModel.is_active, DagModel.dag_id == dag_id).limit(1))
+ if not dm:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ detail={"reason": "not_found", "message": f"DAG with dag_id: '{dag_id}' not found"},
+ )
+
+ if dm.has_import_errors:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail={
+ "reason": "import_errors",
+ "message": f"DAG with dag_id: '{dag_id}' has import errors and cannot be triggered",
+ },
+ )
+
+ try:
+ trigger_dag(
+ dag_id=dag_id,
+ run_id=run_id,
+ conf=payload.conf,
+ logical_date=payload.logical_date,
+ triggered_by=DagRunTriggeredByType.OPERATOR,
+ replace_microseconds=False,
+ session=session,
+ )
+ except DagRunAlreadyExists:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ detail={
+ "reason": "already_exists",
+ "message": f"A DAG Run already exists for DAG {dag_id} with run id {run_id}",
+ },
+ )
+
+
+@router.post(
+ "/{dag_id}/{run_id}/clear",
+ status_code=status.HTTP_204_NO_CONTENT,
+ responses={
+ status.HTTP_400_BAD_REQUEST: {"description": "DAG has import errors and cannot be triggered"},
+ status.HTTP_404_NOT_FOUND: {"description": "DAG not found for the given dag_id"},
+ status.HTTP_422_UNPROCESSABLE_ENTITY: {"description": "Invalid payload"},
+ },
+)
+def clear_dag_run(
+ dag_id: str,
+ run_id: str,
+ session: SessionDep,
+):
+ """Clear a DAG Run."""
+ dm = session.scalar(select(DagModel).where(DagModel.is_active, DagModel.dag_id == dag_id).limit(1))
+ if not dm:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ detail={"reason": "not_found", "message": f"DAG with dag_id: '{dag_id}' not found"},
+ )
+
+ if dm.has_import_errors:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail={
+ "reason": "import_errors",
+ "message": f"DAG with dag_id: '{dag_id}' has import errors and cannot be triggered",
+ },
+ )
+
+ dag_bag = DagBag(dag_folder=dm.fileloc, read_dags_from_db=True)
+ dag = dag_bag.get_dag(dag_id)
+ dag.clear(run_id=run_id)
+
+
+@router.get(
+ "/{dag_id}/{run_id}/state",
+ responses={
+ status.HTTP_404_NOT_FOUND: {"description": "DAG not found for the given dag_id"},
+ },
+)
+def get_dagrun_state(
+ dag_id: str,
+ run_id: str,
+ session: SessionDep,
+) -> DagRunStateResponse:
+ """Get a DAG Run State."""
+ dag_run = session.scalar(select(DagRun).where(DagRun.dag_id == dag_id, DagRun.run_id == run_id))
+ if dag_run is None:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ detail={
+ "reason": "not_found",
+ "message": f"The DagRun with dag_id: `{dag_id}` and run_id: `{run_id}` was not found",
+ },
+ )
+
+ return DagRunStateResponse(state=dag_run.state)
diff --git a/airflow/exceptions.py b/airflow/exceptions.py
index 4d3fba6101ea2..ecbd871d76da3 100644
--- a/airflow/exceptions.py
+++ b/airflow/exceptions.py
@@ -23,7 +23,7 @@
import warnings
from collections.abc import Collection, Sequence
-from datetime import timedelta
+from datetime import datetime, timedelta
from http import HTTPStatus
from typing import TYPE_CHECKING, Any, NamedTuple
@@ -34,6 +34,7 @@
from airflow.models import DagRun
from airflow.sdk.definitions.asset import AssetNameRef, AssetUniqueKey, AssetUriRef
+ from airflow.utils.state import DagRunState
class AirflowException(Exception):
@@ -418,6 +419,41 @@ def __init__(self, *, tasks: Sequence[str | tuple[str, int]]):
self.tasks = tasks
+class DagRunTriggerException(AirflowException):
+ """
+ Signal by an operator to trigger a specific Dag Run of a dag.
+
+ Special exception raised to signal that the operator it was raised from wishes to trigger
+ a specific Dag Run of a dag. This is used in the ``TriggerDagRunOperator``.
+ """
+
+ def __init__(
+ self,
+ *,
+ trigger_dag_id: str,
+ dag_run_id: str,
+ conf: dict | None,
+ logical_date: datetime | None,
+ reset_dag_run: bool,
+ skip_when_already_exists: bool,
+ wait_for_completion: bool,
+ allowed_states: list[str | DagRunState],
+ failed_states: list[str | DagRunState],
+ poke_interval: int,
+ ):
+ super().__init__()
+ self.trigger_dag_id = trigger_dag_id
+ self.dag_run_id = dag_run_id
+ self.conf = conf
+ self.logical_date = logical_date
+ self.reset_dag_run = reset_dag_run
+ self.skip_when_already_exists = skip_when_already_exists
+ self.wait_for_completion = wait_for_completion
+ self.allowed_states = allowed_states
+ self.failed_states = failed_states
+ self.poke_interval = poke_interval
+
+
class TaskDeferred(BaseException):
"""
Signal an operator moving to deferred state.
diff --git a/dev/breeze/doc/ci/04_selective_checks.md b/dev/breeze/doc/ci/04_selective_checks.md
index 4fb073aa040d2..40331b0be5995 100644
--- a/dev/breeze/doc/ci/04_selective_checks.md
+++ b/dev/breeze/doc/ci/04_selective_checks.md
@@ -107,7 +107,7 @@ together using `pytest-xdist` (pytest-xdist distributes the tests among parallel
* if there are any changes to "common" provider code not belonging to any provider (usually system tests
or tests), then tests for all Providers are run
* The specific unit test type is enabled only if changed files match the expected patterns for each type
- (`API`, `CLI`, `WWW`, `Providers`, `Operators` etc.). The `Always` test type is added always if any unit
+ (`API`, `CLI`, `WWW`, `Providers` etc.). The `Always` test type is added always if any unit
tests are run. `Providers` tests are removed if current branch is different than `main`
* If there are no files left in sources after matching the test types and Kubernetes files,
then apparently some Core/Other files have been changed. This automatically adds all test
diff --git a/dev/breeze/doc/images/output-commands.svg b/dev/breeze/doc/images/output-commands.svg
index dcb17ac367dd0..2d9668f5a21af 100644
--- a/dev/breeze/doc/images/output-commands.svg
+++ b/dev/breeze/doc/images/output-commands.svg
@@ -326,8 +326,8 @@
│kafka | kerberos | keycloak | mongo | mssql | │
│openlineage | otel | pinot | qdrant | redis | statsd | │
│trino | ydb) │
-│--standalone-dag-processor/--no-standalone-dag-process…Run standalone dag processor for start-airflow │
-│(required for Airflow 3). │
+│--standalone-dag-processor/--no-standalone-dag-processoRun standalone dag processor for start-airflow │
+│r(required for Airflow 3). │
│[default: standalone-dag-processor] │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Docker Compose selection and cleanup ───────────────────────────────────────────────────────────────────────────────╮
diff --git a/dev/breeze/doc/images/output_testing_core-tests.svg b/dev/breeze/doc/images/output_testing_core-tests.svg
index 6bf94a4627c33..50b7ae2e654e3 100644
--- a/dev/breeze/doc/images/output_testing_core-tests.svg
+++ b/dev/breeze/doc/images/output_testing_core-tests.svg
@@ -380,14 +380,14 @@
╭─ Select test types to run (tests can also be selected by command args individually) ─────────────────────────────────╮
│--test-typeType of tests to run for core test group │
│(All | All-Long | All-Quarantined | All-Postgres | All-MySQL | API | Always | CLI │
-│| Core | Operators | Other | Serialization) │
+│| Core | Other | Serialization) │
│[default: All] │
│--parallel-test-typesSpace separated list of core test types used for testing in parallel.│
-│(API | Always | CLI | Core | Operators | Other | Serialization) │
-│[default: API Always CLI Core Operators Other Serialization] │
+│(API | Always | CLI | Core | Other | Serialization) │
+│[default: API Always CLI Core Other Serialization] │
│--excluded-parallel-test-typesSpace separated list of core test types that will be excluded from parallel tes │
│runs. │
-│(API | Always | CLI | Core | Operators | Other | Serialization) │
+│(API | Always | CLI | Core | Other | Serialization) │
│[default: ""] │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Test options ───────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -458,8 +458,8 @@
│--force-lowest-dependenciesRun tests for the lowest direct dependencies of Airflow │
│or selected provider if `Provider[PROVIDER_ID]` is used │
│as test type. │
-│--install-airflow-with-constraints/--no-install-airflow…Install airflow in a separate step, with constraints │
-│determined from package or airflow version. │
+│--install-airflow-with-constraints/--no-install-airflow-Install airflow in a separate step, with constraints │
+│with-constraintsdetermined from package or airflow version. │
│[default: no-install-airflow-with-constraints] │
│--package-formatFormat of packages.(wheel | sdist | both)│
│[default: wheel] │
diff --git a/dev/breeze/doc/images/output_testing_core-tests.txt b/dev/breeze/doc/images/output_testing_core-tests.txt
index fbdb26945452a..7595fe2535ca0 100644
--- a/dev/breeze/doc/images/output_testing_core-tests.txt
+++ b/dev/breeze/doc/images/output_testing_core-tests.txt
@@ -1 +1 @@
-912ce9d271cc63cc41f0256bc6f7516e
+f3560899cf0f64593214c507936359cb
diff --git a/dev/breeze/src/airflow_breeze/global_constants.py b/dev/breeze/src/airflow_breeze/global_constants.py
index 4fed68c9d5e03..e1a7be2385abb 100644
--- a/dev/breeze/src/airflow_breeze/global_constants.py
+++ b/dev/breeze/src/airflow_breeze/global_constants.py
@@ -228,7 +228,6 @@ class SelectiveCoreTestType(SelectiveTestType):
CORE = "Core"
SERIALIZATION = "Serialization"
OTHER = "Other"
- OPERATORS = "Operators"
class SelectiveProvidersTestType(SelectiveTestType):
diff --git a/dev/breeze/src/airflow_breeze/utils/run_tests.py b/dev/breeze/src/airflow_breeze/utils/run_tests.py
index 814e1c91eacde..3927ed414c2ad 100644
--- a/dev/breeze/src/airflow_breeze/utils/run_tests.py
+++ b/dev/breeze/src/airflow_breeze/utils/run_tests.py
@@ -164,7 +164,6 @@ def get_excluded_provider_args(python_version: str) -> list[str]:
"tests/utils",
],
"Integration": ["tests/integration"],
- "Operators": ["tests/operators"],
"Serialization": [
"tests/serialization",
],
diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
index 6a596a90f3dea..dba5e49553b6f 100644
--- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py
+++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
@@ -86,7 +86,7 @@
USE_PUBLIC_RUNNERS_LABEL = "use public runners"
USE_SELF_HOSTED_RUNNERS_LABEL = "use self-hosted runners"
-ALL_CI_SELECTIVE_TEST_TYPES = "API Always CLI Core Operators Other Serialization"
+ALL_CI_SELECTIVE_TEST_TYPES = "API Always CLI Core Other Serialization"
ALL_PROVIDERS_SELECTIVE_TEST_TYPES = (
"Providers[-amazon,google,standard] Providers[amazon] Providers[google] Providers[standard]"
@@ -290,10 +290,6 @@ def __hash__(self):
r"^airflow/cli/",
r"^tests/cli/",
],
- SelectiveCoreTestType.OPERATORS: [
- r"^airflow/operators/",
- r"^tests/operators/",
- ],
SelectiveProvidersTestType.PROVIDERS: [
r"^providers/.*/src/airflow/providers/",
r"^providers/.*/tests/",
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 741e4ab0fb789..7e77718a330d6 100644
--- a/dev/breeze/tests/test_pytest_args_for_test_types.py
+++ b/dev/breeze/tests/test_pytest_args_for_test_types.py
@@ -88,11 +88,6 @@ def _find_all_integration_folders() -> list[str]:
"Serialization",
["tests/serialization"],
),
- (
- GroupOfTests.CORE,
- "Operators",
- ["tests/operators"],
- ),
(
GroupOfTests.PROVIDERS,
"Providers",
diff --git a/dev/breeze/tests/test_selective_checks.py b/dev/breeze/tests/test_selective_checks.py
index 419e3394982d1..17e6614232584 100644
--- a/dev/breeze/tests/test_selective_checks.py
+++ b/dev/breeze/tests/test_selective_checks.py
@@ -220,33 +220,6 @@ def assert_outputs_are_printed(expected_outputs: dict[str, str], stderr: str):
id="All tests should run when API test files change",
)
),
- (
- pytest.param(
- ("airflow/operators/file.py",),
- {
- "selected-providers-list-as-string": None,
- "all-python-versions": "['3.9']",
- "all-python-versions-list-as-string": "3.9",
- "python-versions": "['3.9']",
- "python-versions-list-as-string": "3.9",
- "ci-image-build": "true",
- "prod-image-build": "false",
- "needs-helm-tests": "false",
- "run-tests": "true",
- "run-amazon-tests": "false",
- "docs-build": "true",
- "skip-pre-commits": "check-provider-yaml-valid,identity,lint-helm-chart,mypy-airflow,mypy-dev,"
- "mypy-docs,mypy-providers,mypy-task-sdk,ts-compile-format-lint-ui",
- "upgrade-to-newer-dependencies": "false",
- "core-test-types-list-as-string": "Always Operators",
- "providers-test-types-list-as-string": "",
- "individual-providers-test-types-list-as-string": "",
- "needs-mypy": "true",
- "mypy-checks": "['mypy-airflow']",
- },
- id="Only Operator tests and DOCS should run",
- )
- ),
(
pytest.param(
("providers/standard/src/airflow/providers/standard/operators/python.py",),
@@ -510,7 +483,7 @@ def assert_outputs_are_printed(expected_outputs: dict[str, str], stderr: str):
),
"skip-providers-tests": "false",
"upgrade-to-newer-dependencies": "false",
- "core-test-types-list-as-string": ("API Always CLI Core Operators Other Serialization"),
+ "core-test-types-list-as-string": ("API Always CLI Core Other Serialization"),
"providers-test-types-list-as-string": "Providers[-amazon,google,standard] Providers[amazon] Providers[google] Providers[standard]",
"needs-mypy": "true",
"mypy-checks": "['mypy-providers', 'mypy-task-sdk']",
@@ -1320,7 +1293,7 @@ def test_full_test_needed_when_scripts_changes(files: tuple[str, ...], expected_
"full-tests-needed": "true",
"skip-pre-commits": "check-airflow-provider-compatibility,check-extra-packages-references,check-provider-yaml-valid,identity,lint-helm-chart,mypy-airflow,mypy-dev,mypy-docs,mypy-providers,mypy-task-sdk,validate-operators-init",
"upgrade-to-newer-dependencies": "false",
- "core-test-types-list-as-string": "API Always CLI Core Operators Other Serialization",
+ "core-test-types-list-as-string": "API Always CLI Core Other Serialization",
"needs-mypy": "true",
"mypy-checks": "['mypy-airflow', 'mypy-docs', 'mypy-dev', 'mypy-task-sdk']",
},
@@ -1441,7 +1414,7 @@ def test_expected_output_full_tests_needed(
"full-tests-needed": "false",
"run-kubernetes-tests": "false",
"upgrade-to-newer-dependencies": "false",
- "core-test-types-list-as-string": "API Always CLI Core Operators Other Serialization",
+ "core-test-types-list-as-string": "API Always CLI Core Other Serialization",
"needs-mypy": "true",
"mypy-checks": "['mypy-airflow']",
},
@@ -1504,7 +1477,7 @@ def test_expected_output_pull_request_v2_7(
"skip-pre-commits": "check-airflow-provider-compatibility,check-extra-packages-references,check-provider-yaml-valid,identity,lint-helm-chart,mypy-airflow,mypy-dev,mypy-docs,mypy-providers,mypy-task-sdk,validate-operators-init",
"docs-list-as-string": "apache-airflow docker-stack",
"upgrade-to-newer-dependencies": "true",
- "core-test-types-list-as-string": "API Always CLI Core Operators Other Serialization",
+ "core-test-types-list-as-string": "API Always CLI Core Other Serialization",
"needs-mypy": "true",
"mypy-checks": "['mypy-airflow', 'mypy-docs', 'mypy-dev', 'mypy-task-sdk']",
},
@@ -1674,7 +1647,7 @@ def test_expected_output_push(
"docs-list-as-string": "",
"upgrade-to-newer-dependencies": "false",
"skip-pre-commits": "identity,mypy-airflow,mypy-dev,mypy-docs,mypy-providers,mypy-task-sdk",
- "core-test-types-list-as-string": "API Always CLI Core Operators Other Serialization",
+ "core-test-types-list-as-string": "API Always CLI Core Other Serialization",
"needs-mypy": "true",
"mypy-checks": "['mypy-airflow', 'mypy-providers', 'mypy-docs', 'mypy-dev', 'mypy-task-sdk']",
},
@@ -1703,7 +1676,7 @@ def test_expected_output_push(
"ts-compile-format-lint-ui",
"run-kubernetes-tests": "false",
"upgrade-to-newer-dependencies": "false",
- "core-test-types-list-as-string": "API Always CLI Core Operators Other Serialization",
+ "core-test-types-list-as-string": "API Always CLI Core Other Serialization",
"providers-test-types-list-as-string": "Providers[amazon] Providers[common.compat,common.io,common.sql,dbt.cloud,ftp,microsoft.mssql,mysql,openlineage,postgres,sftp,snowflake,trino] Providers[google]",
"needs-mypy": "false",
"mypy-checks": "[]",
diff --git a/providers/standard/src/airflow/providers/standard/operators/trigger_dagrun.py b/providers/standard/src/airflow/providers/standard/operators/trigger_dagrun.py
index e00468d1bc42b..11977a081d3ff 100644
--- a/providers/standard/src/airflow/providers/standard/operators/trigger_dagrun.py
+++ b/providers/standard/src/airflow/providers/standard/operators/trigger_dagrun.py
@@ -43,7 +43,7 @@
from airflow.utils import timezone
from airflow.utils.session import provide_session
from airflow.utils.state import DagRunState
-from airflow.utils.types import DagRunTriggeredByType, DagRunType
+from airflow.utils.types import DagRunType
XCOM_LOGICAL_DATE_ISO = "trigger_logical_date_iso"
XCOM_RUN_ID = "trigger_run_id"
@@ -78,15 +78,15 @@ class TriggerDagRunLink(BaseOperatorLink):
name = "Triggered DAG"
def get_link(self, operator: BaseOperator, *, ti_key: TaskInstanceKey) -> str:
- from airflow.models.renderedtifields import RenderedTaskInstanceFields
-
if TYPE_CHECKING:
assert isinstance(operator, TriggerDagRunOperator)
- if template_fields := RenderedTaskInstanceFields.get_templated_fields(ti_key):
- trigger_dag_id: str = template_fields.get("trigger_dag_id", operator.trigger_dag_id)
- else:
- trigger_dag_id = operator.trigger_dag_id
+ trigger_dag_id = operator.trigger_dag_id
+ if not AIRFLOW_V_3_0_PLUS:
+ from airflow.models.renderedtifields import RenderedTaskInstanceFields
+
+ if template_fields := RenderedTaskInstanceFields.get_templated_fields(ti_key):
+ trigger_dag_id: str = template_fields.get("trigger_dag_id", operator.trigger_dag_id) # type: ignore[no-redef]
# Fetch the correct dag_run_id for the triggerED dag which is
# stored in xcom during execution of the triggerING task.
@@ -174,7 +174,7 @@ def __init__(
self.allowed_states = [DagRunState(s) for s in allowed_states]
else:
self.allowed_states = [DagRunState.SUCCESS]
- if failed_states or failed_states == []:
+ if failed_states is not None:
self.failed_states = [DagRunState(s) for s in failed_states]
else:
self.failed_states = [DagRunState.FAILED]
@@ -198,25 +198,51 @@ def execute(self, context: Context):
try:
json.dumps(self.conf)
except TypeError:
- raise AirflowException("conf parameter should be JSON Serializable")
+ raise ValueError("conf parameter should be JSON Serializable")
if self.trigger_run_id:
run_id = str(self.trigger_run_id)
else:
- run_id = DagRun.generate_run_id(
- run_type=DagRunType.MANUAL,
- logical_date=parsed_logical_date,
- run_after=parsed_logical_date or timezone.utcnow(),
- )
+ if AIRFLOW_V_3_0_PLUS:
+ run_id = DagRun.generate_run_id(
+ run_type=DagRunType.MANUAL,
+ logical_date=parsed_logical_date,
+ run_after=parsed_logical_date or timezone.utcnow(),
+ )
+ else:
+ run_id = DagRun.generate_run_id(DagRunType.MANUAL, parsed_logical_date or timezone.utcnow()) # type: ignore[misc,call-arg]
+ if AIRFLOW_V_3_0_PLUS:
+ self._trigger_dag_af_3(context=context, run_id=run_id, parsed_logical_date=parsed_logical_date)
+ else:
+ self._trigger_dag_af_2(context=context, run_id=run_id, parsed_logical_date=parsed_logical_date)
+
+ def _trigger_dag_af_3(self, context, run_id, parsed_logical_date):
+ from airflow.exceptions import DagRunTriggerException
+
+ raise DagRunTriggerException(
+ trigger_dag_id=self.trigger_dag_id,
+ dag_run_id=run_id,
+ conf=self.conf,
+ logical_date=parsed_logical_date,
+ reset_dag_run=self.reset_dag_run,
+ skip_when_already_exists=self.skip_when_already_exists,
+ wait_for_completion=self.wait_for_completion,
+ allowed_states=self.allowed_states,
+ failed_states=self.failed_states,
+ poke_interval=self.poke_interval,
+ )
+
+ # TODO: Support deferral
+
+ def _trigger_dag_af_2(self, context, run_id, parsed_logical_date):
try:
dag_run = trigger_dag(
dag_id=self.trigger_dag_id,
run_id=run_id,
conf=self.conf,
- logical_date=parsed_logical_date,
+ execution_date=parsed_logical_date,
replace_microseconds=False,
- triggered_by=DagRunTriggeredByType.OPERATOR,
)
except DagRunAlreadyExists as e:
@@ -232,7 +258,7 @@ def execute(self, context: Context):
# Note: here execution fails on database isolation mode. Needs structural changes for AIP-72
dag_bag = DagBag(dag_folder=dag_model.fileloc, read_dags_from_db=True)
dag = dag_bag.get_dag(self.trigger_dag_id)
- dag.clear(run_id=dag_run.run_id)
+ dag.clear(start_date=dag_run.logical_date, end_date=dag_run.logical_date)
else:
if self.skip_when_already_exists:
raise AirflowSkipException(
@@ -253,6 +279,7 @@ def execute(self, context: Context):
trigger=DagStateTrigger(
dag_id=self.trigger_dag_id,
states=self.allowed_states + self.failed_states,
+ execution_dates=[dag_run.logical_date],
run_ids=[run_id],
poll_interval=self.poke_interval,
),
@@ -279,16 +306,18 @@ def execute(self, context: Context):
@provide_session
def execute_complete(self, context: Context, session: Session, event: tuple[str, dict[str, Any]]):
- # This run_ids is parsed from the return trigger event
- provided_run_id = event[1]["run_ids"][0]
+ # This logical_date is parsed from the return trigger event
+ provided_logical_date = event[1]["execution_dates"][0]
try:
# Note: here execution fails on database isolation mode. Needs structural changes for AIP-72
dag_run = session.execute(
- select(DagRun).where(DagRun.dag_id == self.trigger_dag_id, DagRun.run_id == provided_run_id)
+ select(DagRun).where(
+ DagRun.dag_id == self.trigger_dag_id, DagRun.execution_date == provided_logical_date
+ )
).scalar_one()
except NoResultFound:
raise AirflowException(
- f"No DAG run found for DAG {self.trigger_dag_id} and run ID {provided_run_id}"
+ f"No DAG run found for DAG {self.trigger_dag_id} and logical date {self.logical_date}"
)
state = dag_run.state
diff --git a/tests/operators/test_trigger_dagrun.py b/providers/standard/tests/unit/standard/operators/test_trigger_dagrun.py
similarity index 66%
rename from tests/operators/test_trigger_dagrun.py
rename to providers/standard/tests/unit/standard/operators/test_trigger_dagrun.py
index e9a8a8a4f0ef9..ff1574cf1b8a9 100644
--- a/tests/operators/test_trigger_dagrun.py
+++ b/providers/standard/tests/unit/standard/operators/test_trigger_dagrun.py
@@ -32,15 +32,17 @@
from airflow.models.taskinstance import TaskInstance
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
from airflow.providers.standard.triggers.external_task import DagStateTrigger
-from airflow.sdk.execution_time.comms import XComResult
from airflow.utils import timezone
from airflow.utils.session import create_session
-from airflow.utils.state import DagRunState, State, TaskInstanceState
+from airflow.utils.state import DagRunState, TaskInstanceState
from airflow.utils.types import DagRunType
from tests_common.test_utils.db import parse_and_sync_to_db
from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.exceptions import DagRunTriggerException
+
pytestmark = pytest.mark.db_test
DEFAULT_DATE = datetime(2019, 1, 1, tzinfo=timezone.utc)
@@ -85,50 +87,149 @@ def teardown_method(self):
synchronize_session=False
)
- # pathlib.Path(self._tmpfile).unlink()
+ @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="Implementation is different for Airflow 2 & 3")
+ def test_trigger_dagrun(self):
+ """
+ Test TriggerDagRunOperator.
- def assert_extra_link(self, triggered_dag_run, triggering_task, session):
- """Asserts whether the correct extra links url will be created."""
- triggering_ti = (
- session.query(TaskInstance)
- .filter_by(
- task_id=triggering_task.task_id,
- dag_id=triggering_task.dag_id,
+ We only verify that the operator runs and raises correct exception. The actual execution logic
+ after the exception is in Task SDK code.
+ """
+ with time_machine.travel("2025-02-18T08:04:46Z", tick=False):
+ task = TriggerDagRunOperator(
+ task_id="test_task",
+ trigger_dag_id=TRIGGERED_DAG_ID,
+ conf={"foo": "bar"},
)
- .one()
+
+ # Ensure correct exception is raised
+ with pytest.raises(DagRunTriggerException) as exc_info:
+ task.execute(context={})
+
+ assert exc_info.value.trigger_dag_id == TRIGGERED_DAG_ID
+ assert exc_info.value.conf == {"foo": "bar"}
+ assert exc_info.value.logical_date is None
+ assert exc_info.value.reset_dag_run is False
+ assert exc_info.value.skip_when_already_exists is False
+ assert exc_info.value.wait_for_completion is False
+ assert exc_info.value.allowed_states == [DagRunState.SUCCESS]
+ assert exc_info.value.failed_states == [DagRunState.FAILED]
+
+ expected_run_id = DagRun.generate_run_id(
+ run_type=DagRunType.MANUAL, run_after=timezone.utcnow()
+ ).rsplit("_", 1)[0]
+ # rsplit because last few characters are random.
+ assert exc_info.value.dag_run_id.rsplit("_", 1)[0] == expected_run_id
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="Implementation is different for Airflow 2 & 3")
+ @mock.patch("airflow.providers.standard.operators.trigger_dagrun.XCom.get_one")
+ def test_extra_operator_link(self, mock_xcom_get_one, dag_maker):
+ with dag_maker(TEST_DAG_ID, default_args={"start_date": DEFAULT_DATE}, serialized=True):
+ task = TriggerDagRunOperator(
+ task_id="test_task",
+ trigger_dag_id=TRIGGERED_DAG_ID,
+ trigger_run_id="test_run_id",
+ conf={"foo": "bar"},
+ )
+
+ dr = dag_maker.create_dagrun(run_id="test_run_id")
+ ti = dr.get_task_instance(task_id=task.task_id)
+
+ mock_xcom_get_one.return_value = ti.run_id
+
+ link = task.operator_extra_links[0].get_link(operator=task, ti_key=ti.key)
+
+ base_url = conf.get_mandatory_value("api", "base_url").lower()
+ expected_url = f"{base_url}dags/{TRIGGERED_DAG_ID}/runs/test_run_id"
+ assert link == expected_url, f"Expected {expected_url}, but got {link}"
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="Implementation is different for Airflow 2 & 3")
+ def test_trigger_dagrun_custom_run_id(self):
+ task = TriggerDagRunOperator(
+ task_id="test_task",
+ trigger_dag_id=TRIGGERED_DAG_ID,
+ trigger_run_id="custom_run_id",
)
- if AIRFLOW_V_3_0_PLUS:
- base_url = conf.get_mandatory_value("api", "base_url").lower()
- expected_url = f"{base_url}dags/{triggered_dag_run.dag_id}/runs/{triggered_dag_run.run_id}"
+ with pytest.raises(DagRunTriggerException) as exc_info:
+ task.execute(context={})
- link = triggering_task.operator_extra_links[0].get_link(
- operator=triggering_task, ti_key=triggering_ti.key
+ assert exc_info.value.dag_run_id == "custom_run_id"
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="Implementation is different for Airflow 2 & 3")
+ def test_trigger_dagrun_with_logical_date(self):
+ """Test TriggerDagRunOperator with custom logical_date."""
+ task = TriggerDagRunOperator(
+ task_id="test_trigger_dagrun_with_logical_date",
+ trigger_dag_id=TRIGGERED_DAG_ID,
+ logical_date=timezone.datetime(2021, 1, 2, 3, 4, 5),
+ )
+
+ with pytest.raises(DagRunTriggerException) as exc_info:
+ task.execute(context={})
+
+ assert exc_info.value.logical_date == timezone.datetime(2021, 1, 2, 3, 4, 5)
+
+ def test_trigger_dagrun_operator_templated_invalid_conf(self, dag_maker):
+ """Test passing a conf that is not JSON Serializable raise error."""
+ with dag_maker(
+ TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
+ ):
+ task = TriggerDagRunOperator(
+ task_id="test_trigger_dagrun_with_invalid_conf",
+ trigger_dag_id=TRIGGERED_DAG_ID,
+ conf={"foo": "{{ dag.dag_id }}", "datetime": timezone.utcnow()},
)
+ dag_maker.sync_dagbag_to_db()
+ parse_and_sync_to_db(self.f_name)
+ dag_maker.create_dagrun()
+ with pytest.raises(ValueError, match="^conf parameter should be JSON Serializable$"):
+ task.run(start_date=DEFAULT_DATE, end_date=DEFAULT_DATE)
+
+ def test_trigger_dagrun_with_no_failed_state(self, dag_maker):
+ task = TriggerDagRunOperator(
+ task_id="test_task",
+ trigger_dag_id=TRIGGERED_DAG_ID,
+ logical_date=DEFAULT_DATE,
+ wait_for_completion=True,
+ poke_interval=10,
+ failed_states=[],
+ )
+
+ assert task.failed_states == []
+
+
+# TODO: To be removed once the provider drops support for Airflow 2
+@pytest.mark.skipif(AIRFLOW_V_3_0_PLUS, reason="Test only for Airflow 2")
+class TestDagRunOperatorAF2:
+ """Test TriggerDagRunOperator for Airflow 2."""
+
+ def setup_method(self):
+ # Airflow relies on reading the DAG from disk when triggering it.
+ # Therefore write a temp file holding the DAG to trigger.
+ with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
+ self._tmpfile = f.name
+ f.write(DAG_SCRIPT)
+ f.flush()
+ self.f_name = f.name
+
+ with create_session() as session:
+ session.add(DagModel(dag_id=TRIGGERED_DAG_ID, fileloc=self._tmpfile))
+ session.commit()
- assert link == expected_url, f"Expected {expected_url}, but got {link}"
- else:
- with mock.patch(
- "airflow.providers.standard.operators.trigger_dagrun.build_airflow_url_with_query"
- ) as mock_build_url:
- # This is equivalent of a task run calling this and pushing to xcom
- triggering_task.operator_extra_links[0].get_link(
- operator=triggering_task, ti_key=triggering_ti.key
+ def teardown_method(self):
+ """Cleanup state after testing in DB."""
+ with create_session() as session:
+ session.query(Log).filter(Log.dag_id == TEST_DAG_ID).delete(synchronize_session=False)
+ for dbmodel in [DagModel, DagRun, TaskInstance]:
+ session.query(dbmodel).filter(dbmodel.dag_id.in_([TRIGGERED_DAG_ID, TEST_DAG_ID])).delete(
+ synchronize_session=False
)
- assert mock_build_url.called
- args, _ = mock_build_url.call_args
- expected_args = {
- "dag_id": triggered_dag_run.dag_id,
- "dag_run_id": triggered_dag_run.run_id,
- }
- assert expected_args in args
def test_trigger_dagrun(self, dag_maker, mock_supervisor_comms):
"""Test TriggerDagRunOperator."""
with time_machine.travel("2025-02-18T08:04:46Z", tick=False):
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
+ with dag_maker(TEST_DAG_ID, default_args={"start_date": DEFAULT_DATE}, serialized=True):
task = TriggerDagRunOperator(task_id="test_task", trigger_dag_id=TRIGGERED_DAG_ID)
dag_maker.sync_dagbag_to_db()
parse_and_sync_to_db(self.f_name)
@@ -137,50 +238,39 @@ def test_trigger_dagrun(self, dag_maker, mock_supervisor_comms):
dagrun = dag_maker.session.query(DagRun).filter(DagRun.dag_id == TRIGGERED_DAG_ID).one()
assert dagrun.run_type == DagRunType.MANUAL
- actual_run_id = dagrun.run_id.rsplit("_", 1)[0]
-
- expected_run_id = DagRun.generate_run_id(
- run_type=DagRunType.MANUAL, run_after=timezone.utcnow()
- ).rsplit("_", 1)[0]
-
- assert actual_run_id == expected_run_id
-
- mock_supervisor_comms.get_message.return_value = XComResult(key="xcom_key", value=dagrun.run_id)
-
- self.assert_extra_link(dagrun, task, dag_maker.session)
+ assert dagrun.run_id == DagRun.generate_run_id(DagRunType.MANUAL, dagrun.logical_date)
- def test_trigger_dagrun_custom_run_id(self, dag_maker):
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
+ def test_extra_operator_link(self, dag_maker, session):
+ """Asserts whether the correct extra links url will be created."""
+ with dag_maker(TEST_DAG_ID, default_args={"start_date": DEFAULT_DATE}, serialized=True):
task = TriggerDagRunOperator(
- task_id="test_task",
- trigger_dag_id=TRIGGERED_DAG_ID,
- trigger_run_id="custom_run_id",
+ task_id="test_task", trigger_dag_id=TRIGGERED_DAG_ID, trigger_run_id="test_run_id"
)
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
dag_maker.create_dagrun()
- task.run(start_date=DEFAULT_DATE, end_date=DEFAULT_DATE)
+ task.run(start_date=DEFAULT_DATE, end_date=DEFAULT_DATE, ignore_ti_state=True)
- with create_session() as session:
- dagruns = session.query(DagRun).filter(DagRun.dag_id == TRIGGERED_DAG_ID).all()
- assert len(dagruns) == 1
- assert dagruns[0].run_id == "custom_run_id"
+ triggering_ti = session.query(TaskInstance).filter_by(task_id=task.task_id, dag_id=task.dag_id).one()
+
+ with mock.patch("airflow.utils.helpers.build_airflow_url_with_query") as mock_build_url:
+ # This is equivalent of a task run calling this and pushing to xcom
+ task.operator_extra_links[0].get_link(operator=task, ti_key=triggering_ti.key)
+ assert mock_build_url.called
+ args, _ = mock_build_url.call_args
+ expected_args = {
+ "dag_id": TRIGGERED_DAG_ID,
+ "dag_run_id": "test_run_id",
+ }
+ assert expected_args in args
- def test_trigger_dagrun_with_logical_date(self, dag_maker, mock_supervisor_comms):
+ def test_trigger_dagrun_with_logical_date(self, dag_maker):
"""Test TriggerDagRunOperator with custom logical_date."""
custom_logical_date = timezone.datetime(2021, 1, 2, 3, 4, 5)
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
+ with dag_maker(TEST_DAG_ID, default_args={"start_date": DEFAULT_DATE}, serialized=True):
task = TriggerDagRunOperator(
task_id="test_trigger_dagrun_with_logical_date",
trigger_dag_id=TRIGGERED_DAG_ID,
logical_date=custom_logical_date,
)
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
dag_maker.create_dagrun()
task.run(start_date=DEFAULT_DATE, end_date=DEFAULT_DATE, ignore_ti_state=True)
@@ -188,13 +278,9 @@ def test_trigger_dagrun_with_logical_date(self, dag_maker, mock_supervisor_comms
dagrun = session.query(DagRun).filter(DagRun.dag_id == TRIGGERED_DAG_ID).one()
assert dagrun.run_type == DagRunType.MANUAL
assert dagrun.logical_date == custom_logical_date
- assert dagrun.run_id == DagRun.generate_run_id(
- run_type=DagRunType.MANUAL, logical_date=custom_logical_date, run_after=custom_logical_date
- )
- mock_supervisor_comms.get_message.return_value = XComResult(key="xcom_key", value=dagrun.run_id)
- self.assert_extra_link(dagrun, task, session)
+ assert dagrun.run_id == DagRun.generate_run_id(DagRunType.MANUAL, custom_logical_date)
- def test_trigger_dagrun_twice(self, dag_maker, mock_supervisor_comms):
+ def test_trigger_dagrun_twice(self, dag_maker):
"""Test TriggerDagRunOperator with custom logical_date."""
utc_now = timezone.utcnow()
run_id = f"manual__{utc_now.isoformat()}"
@@ -215,10 +301,8 @@ def test_trigger_dagrun_twice(self, dag_maker, mock_supervisor_comms):
dag_maker.create_dagrun()
dag_run = DagRun(
dag_id=TRIGGERED_DAG_ID,
- logical_date=utc_now,
- data_interval=(utc_now, utc_now),
- run_after=utc_now,
- state=State.SUCCESS,
+ execution_date=utc_now,
+ state=DagRunState.SUCCESS,
run_type="manual",
run_id=run_id,
)
@@ -231,10 +315,6 @@ def test_trigger_dagrun_twice(self, dag_maker, mock_supervisor_comms):
triggered_dag_run = dagruns[0]
assert triggered_dag_run.run_type == DagRunType.MANUAL
assert triggered_dag_run.logical_date == utc_now
- mock_supervisor_comms.get_message.return_value = XComResult(
- key="xcom_key", value=triggered_dag_run.run_id
- )
- self.assert_extra_link(triggered_dag_run, task, dag_maker.session)
def test_trigger_dagrun_with_scheduled_dag_run(self, dag_maker, mock_supervisor_comms):
"""Test TriggerDagRunOperator with custom logical_date and scheduled dag_run."""
@@ -252,16 +332,12 @@ def test_trigger_dagrun_with_scheduled_dag_run(self, dag_maker, mock_supervisor_
reset_dag_run=True,
wait_for_completion=True,
)
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
dag_maker.create_dagrun()
run_id = f"scheduled__{utc_now.isoformat()}"
dag_run = DagRun(
dag_id=TRIGGERED_DAG_ID,
- logical_date=utc_now,
- data_interval=(utc_now, utc_now),
- run_after=utc_now,
- state=State.SUCCESS,
+ execution_date=utc_now,
+ state=DagRunState.SUCCESS,
run_type="scheduled",
run_id=run_id,
)
@@ -273,117 +349,6 @@ def test_trigger_dagrun_with_scheduled_dag_run(self, dag_maker, mock_supervisor_
assert len(dagruns) == 1
triggered_dag_run = dagruns[0]
assert triggered_dag_run.logical_date == utc_now
- mock_supervisor_comms.get_message.return_value = XComResult(
- key="xcom_key", value=triggered_dag_run.run_id
- )
- self.assert_extra_link(triggered_dag_run, task, dag_maker.session)
-
- def test_trigger_dagrun_with_templated_logical_date(self, dag_maker, mock_supervisor_comms):
- """Test TriggerDagRunOperator with templated logical_date."""
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
- task = TriggerDagRunOperator(
- task_id="test_trigger_dagrun_with_str_logical_date",
- trigger_dag_id=TRIGGERED_DAG_ID,
- logical_date="{{ logical_date }}",
- )
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
- dag_maker.create_dagrun()
- task.run(start_date=DEFAULT_DATE, end_date=DEFAULT_DATE, ignore_ti_state=True)
-
- with create_session() as session:
- dagruns = session.query(DagRun).filter(DagRun.dag_id == TRIGGERED_DAG_ID).all()
- assert len(dagruns) == 1
- triggered_dag_run = dagruns[0]
- assert triggered_dag_run.run_type == DagRunType.MANUAL
- assert triggered_dag_run.logical_date == DEFAULT_DATE
- mock_supervisor_comms.get_message.return_value = XComResult(
- key="xcom_key", value=triggered_dag_run.run_id
- )
- self.assert_extra_link(triggered_dag_run, task, session)
-
- def test_trigger_dagrun_with_templated_trigger_dag_id(self, dag_maker, mock_supervisor_comms):
- """Test TriggerDagRunOperator with templated trigger dag id."""
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
- task = TriggerDagRunOperator(
- task_id="__".join(["test_trigger_dagrun_with_templated_trigger_dag_id", TRIGGERED_DAG_ID]),
- trigger_dag_id="{{ ti.task_id.rsplit('.', 1)[-1].split('__')[-1] }}",
- )
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
- dag_maker.create_dagrun()
- task.run(start_date=DEFAULT_DATE, end_date=DEFAULT_DATE, ignore_ti_state=True)
-
- with create_session() as session:
- dagruns = session.query(DagRun).filter(DagRun.dag_id == TRIGGERED_DAG_ID).all()
- assert len(dagruns) == 1
- triggered_dag_run = dagruns[0]
- assert triggered_dag_run.run_type == DagRunType.MANUAL
- assert triggered_dag_run.dag_id == TRIGGERED_DAG_ID
- mock_supervisor_comms.get_message.return_value = XComResult(
- key="xcom_key", value=triggered_dag_run.run_id
- )
- self.assert_extra_link(triggered_dag_run, task, session)
-
- def test_trigger_dagrun_operator_conf(self, dag_maker):
- """Test passing conf to the triggered DagRun."""
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
- task = TriggerDagRunOperator(
- task_id="test_trigger_dagrun_with_str_logical_date",
- trigger_dag_id=TRIGGERED_DAG_ID,
- conf={"foo": "bar"},
- )
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
- dag_maker.create_dagrun()
- task.run(start_date=DEFAULT_DATE, end_date=DEFAULT_DATE, ignore_ti_state=True)
-
- with create_session() as session:
- dagruns = session.query(DagRun).filter(DagRun.dag_id == TRIGGERED_DAG_ID).all()
- assert len(dagruns) == 1
- assert dagruns[0].conf == {"foo": "bar"}
-
- def test_trigger_dagrun_operator_templated_invalid_conf(self, dag_maker):
- """Test passing a conf that is not JSON Serializable raise error."""
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
- task = TriggerDagRunOperator(
- task_id="test_trigger_dagrun_with_invalid_conf",
- trigger_dag_id=TRIGGERED_DAG_ID,
- conf={"foo": "{{ dag.dag_id }}", "datetime": timezone.utcnow()},
- )
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
- dag_maker.create_dagrun()
- with pytest.raises(AirflowException, match="^conf parameter should be JSON Serializable$"):
- task.run(start_date=DEFAULT_DATE, end_date=DEFAULT_DATE)
-
- def test_trigger_dagrun_operator_templated_conf(self, dag_maker):
- """Test passing a templated conf to the triggered DagRun."""
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
- task = TriggerDagRunOperator(
- task_id="test_trigger_dagrun_with_str_logical_date",
- trigger_dag_id=TRIGGERED_DAG_ID,
- conf={"foo": "{{ dag.dag_id }}"},
- )
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
- dag_maker.create_dagrun()
- task.run(start_date=DEFAULT_DATE, end_date=DEFAULT_DATE, ignore_ti_state=True)
-
- with create_session() as session:
- dagruns = session.query(DagRun).filter(DagRun.dag_id == TRIGGERED_DAG_ID).all()
- assert len(dagruns) == 1
- assert dagruns[0].conf == {"foo": TEST_DAG_ID}
def test_trigger_dagrun_with_reset_dag_run_false(self, dag_maker):
"""Test TriggerDagRunOperator without reset_dag_run."""
@@ -452,8 +417,6 @@ def test_trigger_dagrun_with_skip_when_already_exists(self, dag_maker):
reset_dag_run=False,
skip_when_already_exists=True,
)
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
dr: DagRun = dag_maker.create_dagrun()
task.run(start_date=logical_date, end_date=logical_date, ignore_ti_state=True)
assert dr.get_task_instance("test_task").state == TaskInstanceState.SUCCESS
@@ -484,8 +447,6 @@ def test_trigger_dagrun_with_reset_dag_run_true(
logical_date=trigger_logical_date,
reset_dag_run=True,
)
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
dag_maker.create_dagrun()
task.run(start_date=logical_date, end_date=logical_date, ignore_ti_state=True)
task.run(start_date=logical_date, end_date=logical_date, ignore_ti_state=True)
@@ -493,21 +454,19 @@ def test_trigger_dagrun_with_reset_dag_run_true(
with create_session() as session:
dag_runs = session.query(DagRun).filter(DagRun.dag_id == TRIGGERED_DAG_ID).all()
assert len(dag_runs) == expected_dagruns_count
- assert dag_runs[0].run_type == DagRunType.MANUAL
+ assert dag_runs[0].external_trigger
def test_trigger_dagrun_with_wait_for_completion_true(self, dag_maker):
"""Test TriggerDagRunOperator with wait_for_completion."""
logical_date = DEFAULT_DATE
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
+ with dag_maker(TEST_DAG_ID, default_args={"start_date": DEFAULT_DATE}, serialized=True):
task = TriggerDagRunOperator(
task_id="test_task",
trigger_dag_id=TRIGGERED_DAG_ID,
logical_date=logical_date,
wait_for_completion=True,
poke_interval=10,
- allowed_states=[State.QUEUED],
+ allowed_states=[DagRunState.QUEUED],
)
dag_maker.sync_dagbag_to_db()
parse_and_sync_to_db(self.f_name)
@@ -521,66 +480,32 @@ def test_trigger_dagrun_with_wait_for_completion_true(self, dag_maker):
def test_trigger_dagrun_with_wait_for_completion_true_fail(self, dag_maker):
"""Test TriggerDagRunOperator with wait_for_completion but triggered dag fails."""
logical_date = DEFAULT_DATE
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
+ with dag_maker(TEST_DAG_ID, default_args={"start_date": DEFAULT_DATE}, serialized=True):
task = TriggerDagRunOperator(
task_id="test_task",
trigger_dag_id=TRIGGERED_DAG_ID,
logical_date=logical_date,
wait_for_completion=True,
poke_interval=10,
- failed_states=[State.QUEUED],
+ failed_states=[DagRunState.QUEUED],
)
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
dag_maker.create_dagrun()
with pytest.raises(AirflowException):
task.run(start_date=logical_date, end_date=logical_date)
- def test_trigger_dagrun_triggering_itself(self, dag_maker):
- """Test TriggerDagRunOperator that triggers itself"""
- logical_date = DEFAULT_DATE
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
- task = TriggerDagRunOperator(
- task_id="test_task",
- trigger_dag_id=TEST_DAG_ID,
- logical_date=timezone.utcnow(),
- )
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
- dag_maker.create_dagrun()
- task.run(start_date=logical_date, end_date=logical_date)
-
- dagruns = (
- dag_maker.session.query(DagRun)
- .filter(DagRun.dag_id == TEST_DAG_ID)
- .order_by(DagRun.logical_date)
- .all()
- )
- assert len(dagruns) == 2
- triggered_dag_run = dagruns[1]
- assert triggered_dag_run.state == State.QUEUED
-
def test_trigger_dagrun_with_wait_for_completion_true_defer_false(self, dag_maker):
"""Test TriggerDagRunOperator with wait_for_completion."""
logical_date = DEFAULT_DATE
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
+ with dag_maker(TEST_DAG_ID, default_args={"start_date": DEFAULT_DATE}, serialized=True):
task = TriggerDagRunOperator(
task_id="test_task",
trigger_dag_id=TRIGGERED_DAG_ID,
logical_date=logical_date,
wait_for_completion=True,
poke_interval=10,
- allowed_states=[State.QUEUED],
+ allowed_states=[DagRunState.QUEUED],
deferrable=False,
)
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
dag_maker.create_dagrun()
task.run(start_date=logical_date, end_date=logical_date)
@@ -591,21 +516,17 @@ def test_trigger_dagrun_with_wait_for_completion_true_defer_false(self, dag_make
def test_trigger_dagrun_with_wait_for_completion_true_defer_true(self, dag_maker):
"""Test TriggerDagRunOperator with wait_for_completion."""
logical_date = DEFAULT_DATE
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
+ with dag_maker(TEST_DAG_ID, default_args={"start_date": DEFAULT_DATE}, serialized=True):
task = TriggerDagRunOperator(
task_id="test_task",
trigger_dag_id=TRIGGERED_DAG_ID,
logical_date=logical_date,
wait_for_completion=True,
poke_interval=10,
- allowed_states=[State.QUEUED],
+ allowed_states=[DagRunState.QUEUED],
deferrable=True,
trigger_run_id=DEFAULT_RUN_ID,
)
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
dag_maker.create_dagrun()
task.run(start_date=logical_date, end_date=logical_date)
@@ -615,6 +536,7 @@ def test_trigger_dagrun_with_wait_for_completion_true_defer_true(self, dag_maker
assert len(dagruns) == 1
trigger = DagStateTrigger(
dag_id="down_stream",
+ execution_dates=[DEFAULT_DATE],
run_ids=[DEFAULT_RUN_ID],
poll_interval=20,
states=["success", "failed"],
@@ -634,12 +556,10 @@ def test_trigger_dagrun_with_wait_for_completion_true_defer_true_failure(self, d
logical_date=logical_date,
wait_for_completion=True,
poke_interval=10,
- allowed_states=[State.SUCCESS],
+ allowed_states=[DagRunState.SUCCESS],
deferrable=True,
trigger_run_id=DEFAULT_RUN_ID,
)
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
dag_maker.create_dagrun()
task.run(start_date=logical_date, end_date=logical_date)
@@ -650,6 +570,7 @@ def test_trigger_dagrun_with_wait_for_completion_true_defer_true_failure(self, d
trigger = DagStateTrigger(
dag_id="down_stream",
+ execution_dates=[DEFAULT_DATE],
run_ids=[DEFAULT_RUN_ID],
poll_interval=20,
states=["success", "failed"],
@@ -672,13 +593,11 @@ def test_trigger_dagrun_with_wait_for_completion_true_defer_true_failure_2(self,
logical_date=logical_date,
wait_for_completion=True,
poke_interval=10,
- allowed_states=[State.SUCCESS],
- failed_states=[State.QUEUED],
+ allowed_states=[DagRunState.SUCCESS],
+ failed_states=[DagRunState.QUEUED],
deferrable=True,
trigger_run_id=DEFAULT_RUN_ID,
)
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
dag_maker.create_dagrun()
task.run(start_date=logical_date, end_date=logical_date)
@@ -689,6 +608,7 @@ def test_trigger_dagrun_with_wait_for_completion_true_defer_true_failure_2(self,
trigger = DagStateTrigger(
dag_id="down_stream",
+ execution_dates=[DEFAULT_DATE],
run_ids=[DEFAULT_RUN_ID],
poll_interval=20,
states=["success", "failed"],
@@ -718,8 +638,6 @@ def test_dagstatetrigger_run_id(self, trigger_logical_date, dag_maker):
allowed_states=[DagRunState.QUEUED],
deferrable=True,
)
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
dag_maker.create_dagrun()
mock_task_defer = mock.MagicMock(side_effect=task.defer)
@@ -747,8 +665,6 @@ def test_dagstatetrigger_run_id_with_clear_and_reset(self, dag_maker):
deferrable=True,
reset_dag_run=True,
)
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
dag_maker.create_dagrun()
mock_task_defer = mock.MagicMock(side_effect=task.defer)
@@ -778,22 +694,3 @@ def test_dagstatetrigger_run_id_with_clear_and_reset(self, dag_maker):
# The second DagStateTrigger call should still use the original `logical_date` value.
assert mock_task_defer.call_args_list[1].kwargs["trigger"].run_ids == [run_id]
-
- def test_trigger_dagrun_with_no_failed_state(self, dag_maker):
- logical_date = DEFAULT_DATE
- with dag_maker(
- TEST_DAG_ID, default_args={"owner": "airflow", "start_date": DEFAULT_DATE}, serialized=True
- ):
- task = TriggerDagRunOperator(
- task_id="test_task",
- trigger_dag_id=TRIGGERED_DAG_ID,
- logical_date=logical_date,
- wait_for_completion=True,
- poke_interval=10,
- failed_states=[],
- )
- dag_maker.sync_dagbag_to_db()
- parse_and_sync_to_db(self.f_name)
- dag_maker.create_dagrun()
-
- assert task.failed_states == []
diff --git a/task-sdk/pyproject.toml b/task-sdk/pyproject.toml
index 46543faffe129..d8423b9fad1ce 100644
--- a/task-sdk/pyproject.toml
+++ b/task-sdk/pyproject.toml
@@ -102,6 +102,7 @@ codegen = [
"apache-airflow",
"rich",
"ruff",
+ "svcs>=25.1.0",
]
dev = [
"apache-airflow",
diff --git a/task-sdk/src/airflow/sdk/api/client.py b/task-sdk/src/airflow/sdk/api/client.py
index 5bcf353dcf00f..334d283b688e1 100644
--- a/task-sdk/src/airflow/sdk/api/client.py
+++ b/task-sdk/src/airflow/sdk/api/client.py
@@ -38,6 +38,7 @@
AssetEventsResponse,
AssetResponse,
ConnectionResponse,
+ DagRunStateResponse,
DagRunType,
PrevSuccessfulDagRunResponse,
TerminalStateNonSuccess,
@@ -50,6 +51,7 @@
TISkippedDownstreamTasksStatePayload,
TISuccessStatePayload,
TITerminalStatePayload,
+ TriggerDAGRunPayload,
ValidationError as RemoteValidationError,
VariablePostBody,
VariableResponse,
@@ -405,6 +407,52 @@ def get(
return AssetEventsResponse.model_validate_json(resp.read())
+class DagRunOperations:
+ __slots__ = ("client",)
+
+ def __init__(self, client: Client):
+ self.client = client
+
+ def trigger(
+ self,
+ dag_id: str,
+ run_id: str,
+ conf: dict | None = None,
+ logical_date: datetime | None = None,
+ reset_dag_run: bool = False,
+ ):
+ """Trigger a DAG run via the API server."""
+ body = TriggerDAGRunPayload(logical_date=logical_date, conf=conf or {}, reset_dag_run=reset_dag_run)
+
+ try:
+ self.client.post(
+ f"dag-runs/{dag_id}/{run_id}", content=body.model_dump_json(exclude_defaults=True)
+ )
+ except ServerResponseError as e:
+ if e.response.status_code == HTTPStatus.CONFLICT:
+ if reset_dag_run:
+ log.info("DAG Run already exists; Resetting DAG Run.", dag_id=dag_id, run_id=run_id)
+ return self.clear(run_id=run_id, dag_id=dag_id)
+
+ log.info("DAG Run already exists!", detail=e.detail, dag_id=dag_id, run_id=run_id)
+ return ErrorResponse(error=ErrorType.DAGRUN_ALREADY_EXISTS)
+ else:
+ raise
+
+ return OKResponse(ok=True)
+
+ def clear(self, dag_id: str, run_id: str):
+ """Clear a DAG run via the API server."""
+ self.client.post(f"dag-runs/{dag_id}/{run_id}/clear")
+ # TODO: Error handling
+ return OKResponse(ok=True)
+
+ def get_state(self, dag_id: str, run_id: str) -> DagRunStateResponse:
+ """Get the state of a DAG run via the API server."""
+ resp = self.client.get(f"dag-runs/{dag_id}/{run_id}/state")
+ return DagRunStateResponse.model_validate_json(resp.read())
+
+
class BearerAuth(httpx.Auth):
def __init__(self, token: str):
self.token: str = token
@@ -495,6 +543,12 @@ def task_instances(self) -> TaskInstanceOperations:
"""Operations related to TaskInstances."""
return TaskInstanceOperations(self)
+ @lru_cache() # type: ignore[misc]
+ @property
+ def dag_runs(self) -> DagRunOperations:
+ """Operations related to DagRuns."""
+ return DagRunOperations(self)
+
@lru_cache() # type: ignore[misc]
@property
def connections(self) -> ConnectionOperations:
diff --git a/task-sdk/src/airflow/sdk/api/datamodels/_generated.py b/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
index 622f3d0a74e72..8725cdc04cfa0 100644
--- a/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
+++ b/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
@@ -94,6 +94,29 @@ class DagRunAssetReference(BaseModel):
data_interval_end: Annotated[datetime | None, Field(title="Data Interval End")] = None
+class DagRunState(str, Enum):
+ """
+ All possible states that a DagRun can be in.
+
+ These are "shared" with TaskInstanceState in some parts of the code,
+ so please ensure that their values always match the ones with the
+ same name in TaskInstanceState.
+ """
+
+ QUEUED = "queued"
+ RUNNING = "running"
+ SUCCESS = "success"
+ FAILED = "failed"
+
+
+class DagRunStateResponse(BaseModel):
+ """
+ Schema for DAG Run State response.
+ """
+
+ state: DagRunState
+
+
class DagRunType(str, Enum):
"""
Class with DagRun types.
@@ -245,6 +268,19 @@ class TerminalStateNonSuccess(str, Enum):
FAIL_WITHOUT_RETRY = "fail_without_retry"
+class TriggerDAGRunPayload(BaseModel):
+ """
+ Schema for Trigger DAG Run API request.
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ logical_date: Annotated[datetime | None, Field(title="Logical Date")] = None
+ conf: Annotated[dict[str, Any] | None, Field(title="Conf")] = None
+ reset_dag_run: Annotated[bool | None, Field(title="Reset Dag Run")] = False
+
+
class ValidationError(BaseModel):
loc: Annotated[list[str | int], Field(title="Location")]
msg: Annotated[str, Field(title="Message")]
diff --git a/task-sdk/src/airflow/sdk/exceptions.py b/task-sdk/src/airflow/sdk/exceptions.py
index 4a007be8a874a..60c620cf0ce84 100644
--- a/task-sdk/src/airflow/sdk/exceptions.py
+++ b/task-sdk/src/airflow/sdk/exceptions.py
@@ -39,6 +39,7 @@ class ErrorType(enum.Enum):
VARIABLE_NOT_FOUND = "VARIABLE_NOT_FOUND"
XCOM_NOT_FOUND = "XCOM_NOT_FOUND"
ASSET_NOT_FOUND = "ASSET_NOT_FOUND"
+ DAGRUN_ALREADY_EXISTS = "DAGRUN_ALREADY_EXISTS"
GENERIC_ERROR = "GENERIC_ERROR"
diff --git a/task-sdk/src/airflow/sdk/execution_time/comms.py b/task-sdk/src/airflow/sdk/execution_time/comms.py
index bd081b534abfd..ae05956235fb4 100644
--- a/task-sdk/src/airflow/sdk/execution_time/comms.py
+++ b/task-sdk/src/airflow/sdk/execution_time/comms.py
@@ -55,6 +55,7 @@
AssetResponse,
BundleInfo,
ConnectionResponse,
+ DagRunStateResponse,
PrevSuccessfulDagRunResponse,
TaskInstance,
TerminalTIState,
@@ -64,6 +65,7 @@
TIRuntimeCheckPayload,
TISkippedDownstreamTasksStatePayload,
TISuccessStatePayload,
+ TriggerDAGRunPayload,
VariableResponse,
XComResponse,
)
@@ -182,6 +184,23 @@ def from_variable_response(cls, variable_response: VariableResponse) -> Variable
return cls(**variable_response.model_dump(exclude_defaults=True), type="VariableResult")
+class DagRunStateResult(DagRunStateResponse):
+ type: Literal["DagRunStateResult"] = "DagRunStateResult"
+
+ # TODO: Create a convert api_response to result classes so we don't need to do this
+ # for all the classes above
+ @classmethod
+ def from_api_response(cls, dr_state_response: DagRunStateResponse) -> DagRunStateResult:
+ """
+ Create result class from API Response.
+
+ API Response is autogenerated from the API schema, so we need to convert it to Result
+ for communication between the Supervisor and the task process since it needs a
+ discriminator field.
+ """
+ return cls(**dr_state_response.model_dump(exclude_defaults=True), type="DagRunStateResult")
+
+
class PrevSuccessfulDagRunResult(PrevSuccessfulDagRunResponse):
type: Literal["PrevSuccessfulDagRunResult"] = "PrevSuccessfulDagRunResult"
@@ -212,6 +231,7 @@ class OKResponse(BaseModel):
AssetResult,
AssetEventsResult,
ConnectionResult,
+ DagRunStateResult,
ErrorResponse,
PrevSuccessfulDagRunResult,
StartupDetails,
@@ -371,6 +391,18 @@ class SetRenderedFields(BaseModel):
type: Literal["SetRenderedFields"] = "SetRenderedFields"
+class TriggerDagRun(TriggerDAGRunPayload):
+ dag_id: str
+ run_id: Annotated[str, Field(title="Dag Run Id")]
+ type: Literal["TriggerDagRun"] = "TriggerDagRun"
+
+
+class GetDagRunState(BaseModel):
+ dag_id: str
+ run_id: str
+ type: Literal["GetDagRunState"] = "GetDagRunState"
+
+
class GetAssetByName(BaseModel):
name: str
type: Literal["GetAssetByName"] = "GetAssetByName"
@@ -406,6 +438,7 @@ class GetPrevSuccessfulDagRun(BaseModel):
GetAssetEventByAsset,
GetAssetEventByAssetAlias,
GetConnection,
+ GetDagRunState,
GetPrevSuccessfulDagRun,
GetVariable,
GetXCom,
@@ -416,6 +449,7 @@ class GetPrevSuccessfulDagRun(BaseModel):
SetRenderedFields,
SetXCom,
TaskState,
+ TriggerDagRun,
RuntimeCheckOnTask,
DeleteXCom,
],
diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index e27e631419001..b73db7d568f04 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -65,6 +65,7 @@
AssetEventsResult,
AssetResult,
ConnectionResult,
+ DagRunStateResult,
DeferTask,
DeleteXCom,
GetAssetByName,
@@ -72,6 +73,7 @@
GetAssetEventByAsset,
GetAssetEventByAssetAlias,
GetConnection,
+ GetDagRunState,
GetPrevSuccessfulDagRun,
GetVariable,
GetXCom,
@@ -87,6 +89,7 @@
SucceedTask,
TaskState,
ToSupervisor,
+ TriggerDagRun,
VariableResult,
XComCountResponse,
XComResult,
@@ -934,6 +937,18 @@ def _handle_request(self, msg: ToSupervisor, log: FilteringBoundLogger):
dagrun_resp = self.client.task_instances.get_previous_successful_dagrun(self.id)
dagrun_result = PrevSuccessfulDagRunResult.from_dagrun_response(dagrun_resp)
resp = dagrun_result.model_dump_json(exclude_unset=True).encode()
+ elif isinstance(msg, TriggerDagRun):
+ dr_resp = self.client.dag_runs.trigger(
+ msg.dag_id,
+ msg.run_id,
+ msg.conf,
+ msg.logical_date,
+ msg.reset_dag_run,
+ )
+ resp = dr_resp.model_dump_json().encode()
+ elif isinstance(msg, GetDagRunState):
+ dr_resp = self.client.dag_runs.get_state(msg.dag_id, msg.run_id)
+ resp = DagRunStateResult.from_api_response(dr_resp).model_dump_json().encode()
else:
log.error("Unhandled request", msg=msg)
return
diff --git a/task-sdk/src/airflow/sdk/execution_time/task_runner.py b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
index 15cc8de3a2d47..3242a6eb9c66b 100644
--- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
+++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
@@ -23,6 +23,7 @@
import functools
import os
import sys
+import time
from collections.abc import Iterable, Iterator, Mapping
from datetime import datetime, timezone
from io import FileIO
@@ -50,8 +51,12 @@
from airflow.sdk.definitions.baseoperator import BaseOperator, ExecutorSafeguard
from airflow.sdk.definitions.mappedoperator import MappedOperator
from airflow.sdk.definitions.param import process_params
+from airflow.sdk.exceptions import ErrorType
from airflow.sdk.execution_time.comms import (
+ DagRunStateResult,
DeferTask,
+ ErrorResponse,
+ GetDagRunState,
OKResponse,
RescheduleTask,
RuntimeCheckOnTask,
@@ -62,6 +67,7 @@
TaskState,
ToSupervisor,
ToTask,
+ TriggerDagRun,
)
from airflow.sdk.execution_time.context import (
ConnectionAccessor,
@@ -80,6 +86,7 @@
import jinja2
from structlog.typing import FilteringBoundLogger as Logger
+ from airflow.exceptions import DagRunTriggerException
from airflow.sdk.definitions._internal.abstractoperator import AbstractOperator
from airflow.sdk.definitions.context import Context
from airflow.sdk.types import OutletEventAccessorsProtocol
@@ -547,6 +554,7 @@ def run(
AirflowSkipException,
AirflowTaskTerminated,
AirflowTaskTimeout,
+ DagRunTriggerException,
DownstreamTasksSkipped,
TaskDeferred,
)
@@ -558,6 +566,8 @@ def run(
msg: ToSupervisor | None = None
state: IntermediateTIState | TerminalTIState
error: BaseException | None = None
+
+ context = ti.get_template_context()
try:
# First, clear the xcom data sent from server
if ti._ti_context_from_server and (keys_to_delete := ti._ti_context_from_server.xcom_keys_to_clear):
@@ -570,7 +580,6 @@ def run(
run_id=ti.run_id,
)
- context = ti.get_template_context()
with set_current_context(context):
# This is the earliest that we can render templates -- as if it excepts for any reason we need to
# catch it and handle it like a normal task failure
@@ -585,11 +594,12 @@ def run(
msg, state = _handle_current_task_success(context, ti)
except DownstreamTasksSkipped as skip:
- context = ti.get_template_context()
log.info("Skipping downstream tasks.")
tasks_to_skip = skip.tasks if isinstance(skip.tasks, list) else [skip.tasks]
SUPERVISOR_COMMS.send_request(log=log, msg=SkipDownstreamTasks(tasks=tasks_to_skip))
msg, state = _handle_current_task_success(context, ti)
+ except DagRunTriggerException as drte:
+ msg, state = _handle_trigger_dag_run(drte, context, ti, log)
except TaskDeferred as defer:
# TODO: Should we use structlog.bind_contextvars here for dag_id, task_id & run_id?
log.info("Pausing task as DEFERRED. ", dag_id=ti.dag_id, task_id=ti.task_id, run_id=ti.run_id)
@@ -670,7 +680,7 @@ def run(
return state, msg, error
-def _handle_current_task_success(context, ti) -> tuple[SucceedTask, TerminalTIState]:
+def _handle_current_task_success(context: Context, ti: RuntimeTaskInstance):
task_outlets = list(_build_asset_profiles(ti.task.outlets))
outlet_events = list(_serialize_outlet_events(context["outlet_events"]))
msg = SucceedTask(
@@ -681,6 +691,83 @@ def _handle_current_task_success(context, ti) -> tuple[SucceedTask, TerminalTISt
return msg, TerminalTIState.SUCCESS
+def _handle_trigger_dag_run(
+ drte: DagRunTriggerException, context: Context, ti: RuntimeTaskInstance, log: Logger
+):
+ """Handle exception from TriggerDagRunOperator."""
+ log.info("Triggering Dag Run.", trigger_dag_id=drte.trigger_dag_id)
+ SUPERVISOR_COMMS.send_request(
+ log=log,
+ msg=TriggerDagRun(
+ dag_id=drte.trigger_dag_id,
+ run_id=drte.dag_run_id,
+ logical_date=drte.logical_date,
+ conf=drte.conf,
+ reset_dag_run=drte.reset_dag_run,
+ ),
+ )
+
+ comms_msg = SUPERVISOR_COMMS.get_message()
+ if isinstance(comms_msg, ErrorResponse) and comms_msg.error == ErrorType.DAGRUN_ALREADY_EXISTS:
+ if drte.skip_when_already_exists:
+ log.info(
+ "Dag Run already exists, skipping task as skip_when_already_exists is set to True.",
+ dag_id=drte.trigger_dag_id,
+ )
+ msg = TaskState(state=TerminalTIState.SKIPPED, end_date=datetime.now(tz=timezone.utc))
+ state = TerminalTIState.SKIPPED
+ else:
+ log.error("Dag Run already exists, marking task as failed.", dag_id=drte.trigger_dag_id)
+ msg = TaskState(state=TerminalTIState.FAILED, end_date=datetime.now(tz=timezone.utc))
+ state = TerminalTIState.FAILED
+
+ return msg, state
+
+ log.info("Dag Run triggered successfully.", trigger_dag_id=drte.trigger_dag_id)
+
+ # Store the run id from the dag run (either created or found above) to
+ # be used when creating the extra link on the webserver.
+ ti.xcom_push(key="trigger_run_id", value=drte.dag_run_id)
+
+ if drte.wait_for_completion:
+ while True:
+ log.info(
+ "Waiting for dag run to complete execution in allowed state.",
+ dag_id=drte.trigger_dag_id,
+ run_id=drte.dag_run_id,
+ allowed_state=drte.allowed_states,
+ )
+ time.sleep(drte.poke_interval)
+
+ SUPERVISOR_COMMS.send_request(
+ log=log, msg=GetDagRunState(dag_id=drte.trigger_dag_id, run_id=drte.dag_run_id)
+ )
+ comms_msg = SUPERVISOR_COMMS.get_message()
+ if TYPE_CHECKING:
+ assert isinstance(comms_msg, DagRunStateResult)
+ if comms_msg.state in drte.failed_states:
+ log.error(
+ "DagRun finished with failed state.", dag_id=drte.trigger_dag_id, state=comms_msg.state
+ )
+ msg = TaskState(state=TerminalTIState.FAILED, end_date=datetime.now(tz=timezone.utc))
+ state = TerminalTIState.FAILED
+ return msg, state
+ if comms_msg.state in drte.allowed_states:
+ log.info(
+ "DagRun finished with allowed state.", dag_id=drte.trigger_dag_id, state=comms_msg.state
+ )
+ break
+ log.debug(
+ "DagRun not yet in allowed or failed state.",
+ dag_id=drte.trigger_dag_id,
+ state=comms_msg.state,
+ )
+
+ msg, state = _handle_current_task_success(context, ti)
+
+ return msg, state
+
+
def _execute_task(context: Context, ti: RuntimeTaskInstance):
"""Execute Task (optionally with a Timeout) and push Xcom results."""
from airflow.exceptions import AirflowTaskTimeout
diff --git a/task-sdk/tests/task_sdk/api/test_client.py b/task-sdk/tests/task_sdk/api/test_client.py
index 1731baf86defc..ff86b9c6184f9 100644
--- a/task-sdk/tests/task_sdk/api/test_client.py
+++ b/task-sdk/tests/task_sdk/api/test_client.py
@@ -29,11 +29,13 @@
from airflow.sdk.api.datamodels._generated import (
AssetResponse,
ConnectionResponse,
+ DagRunState,
+ DagRunStateResponse,
VariableResponse,
XComResponse,
)
from airflow.sdk.exceptions import ErrorType
-from airflow.sdk.execution_time.comms import DeferTask, ErrorResponse, RescheduleTask
+from airflow.sdk.execution_time.comms import DeferTask, ErrorResponse, OKResponse, RescheduleTask
from airflow.utils import timezone
from airflow.utils.state import TerminalTIState
@@ -711,3 +713,102 @@ def handle_request(request: httpx.Request) -> httpx.Response:
assert isinstance(result, ErrorResponse)
assert result.error == ErrorType.ASSET_NOT_FOUND
+
+
+class TestDagRunOperations:
+ def test_trigger(self):
+ # Simulate a successful response from the server when triggering a dag run
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ if request.url.path == "/dag-runs/test_trigger/test_run_id":
+ actual_body = json.loads(request.read())
+ assert actual_body["logical_date"] == "2025-01-01T00:00:00Z"
+ assert actual_body["conf"] == {}
+
+ # Since the value for `reset_dag_run` is default, it should not be present in the request body
+ assert "reset_dag_run" not in actual_body
+ return httpx.Response(status_code=204)
+ return httpx.Response(status_code=400, json={"detail": "Bad Request"})
+
+ client = make_client(transport=httpx.MockTransport(handle_request))
+ result = client.dag_runs.trigger(
+ dag_id="test_trigger", run_id="test_run_id", logical_date=timezone.datetime(2025, 1, 1)
+ )
+
+ assert result == OKResponse(ok=True)
+
+ def test_trigger_conflict(self):
+ """Test that if the dag run already exists, the client returns an error when default reset_dag_run=False"""
+
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ if request.url.path == "/dag-runs/test_trigger_conflict/test_run_id":
+ return httpx.Response(
+ status_code=409,
+ json={
+ "detail": {
+ "reason": "already_exists",
+ "message": "A DAG Run already exists for DAG test_trigger_conflict with run id test_run_id",
+ }
+ },
+ )
+ return httpx.Response(status_code=422)
+
+ client = make_client(transport=httpx.MockTransport(handle_request))
+ result = client.dag_runs.trigger(dag_id="test_trigger_conflict", run_id="test_run_id")
+
+ assert result == ErrorResponse(error=ErrorType.DAGRUN_ALREADY_EXISTS)
+
+ def test_trigger_conflict_reset_dag_run(self):
+ """Test that if dag run already exists and reset_dag_run=True, the client clears the dag run"""
+
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ if request.url.path == "/dag-runs/test_trigger_conflict_reset/test_run_id":
+ return httpx.Response(
+ status_code=409,
+ json={
+ "detail": {
+ "reason": "already_exists",
+ "message": "A DAG Run already exists for DAG test_trigger_conflict with run id test_run_id",
+ }
+ },
+ )
+ elif request.url.path == "/dag-runs/test_trigger_conflict_reset/test_run_id/clear":
+ return httpx.Response(status_code=204)
+ return httpx.Response(status_code=422)
+
+ client = make_client(transport=httpx.MockTransport(handle_request))
+ result = client.dag_runs.trigger(
+ dag_id="test_trigger_conflict_reset",
+ run_id="test_run_id",
+ reset_dag_run=True,
+ )
+
+ assert result == OKResponse(ok=True)
+
+ def test_clear(self):
+ """Test that the client can clear a dag run"""
+
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ if request.url.path == "/dag-runs/test_clear/test_run_id/clear":
+ return httpx.Response(status_code=204)
+ return httpx.Response(status_code=422)
+
+ client = make_client(transport=httpx.MockTransport(handle_request))
+ result = client.dag_runs.clear(dag_id="test_clear", run_id="test_run_id")
+
+ assert result == OKResponse(ok=True)
+
+ def test_get_state(self):
+ """Test that the client can get the state of a dag run"""
+
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ if request.url.path == "/dag-runs/test_state/test_run_id/state":
+ return httpx.Response(
+ status_code=200,
+ json={"state": "running"},
+ )
+ return httpx.Response(status_code=422)
+
+ client = make_client(transport=httpx.MockTransport(handle_request))
+ result = client.dag_runs.get_state(dag_id="test_state", run_id="test_run_id")
+
+ assert result == DagRunStateResponse(state=DagRunState.RUNNING)
diff --git a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
index 993d4520753a3..848b7be44a663 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
@@ -49,20 +49,25 @@
AssetEventResponse,
AssetProfile,
AssetResponse,
+ DagRunState,
TaskInstance,
TerminalTIState,
)
+from airflow.sdk.exceptions import ErrorType
from airflow.sdk.execution_time.comms import (
AssetEventsResult,
AssetResult,
ConnectionResult,
+ DagRunStateResult,
DeferTask,
DeleteXCom,
+ ErrorResponse,
GetAssetByName,
GetAssetByUri,
GetAssetEventByAsset,
GetAssetEventByAssetAlias,
GetConnection,
+ GetDagRunState,
GetPrevSuccessfulDagRun,
GetVariable,
GetXCom,
@@ -75,6 +80,7 @@
SetXCom,
SucceedTask,
TaskState,
+ TriggerDagRun,
VariableResult,
XComResult,
)
@@ -1312,6 +1318,39 @@ def watched_subprocess(self, mocker):
OKResponse(ok=True),
id="runtime_check_on_task",
),
+ pytest.param(
+ TriggerDagRun(
+ dag_id="test_dag",
+ run_id="test_run",
+ conf={"key": "value"},
+ logical_date=timezone.datetime(2025, 1, 1),
+ reset_dag_run=True,
+ ),
+ b'{"ok":true,"type":"OKResponse"}\n',
+ "dag_runs.trigger",
+ ("test_dag", "test_run", {"key": "value"}, timezone.datetime(2025, 1, 1), True),
+ {},
+ OKResponse(ok=True),
+ id="dag_run_trigger",
+ ),
+ pytest.param(
+ TriggerDagRun(dag_id="test_dag", run_id="test_run"),
+ b'{"error":"DAGRUN_ALREADY_EXISTS","detail":null,"type":"ErrorResponse"}\n',
+ "dag_runs.trigger",
+ ("test_dag", "test_run", None, None, False),
+ {},
+ ErrorResponse(error=ErrorType.DAGRUN_ALREADY_EXISTS),
+ id="dag_run_trigger_already_exists",
+ ),
+ pytest.param(
+ GetDagRunState(dag_id="test_dag", run_id="test_run"),
+ b'{"state":"running","type":"DagRunStateResult"}\n',
+ "dag_runs.get_state",
+ ("test_dag", "test_run"),
+ {},
+ DagRunStateResult(state=DagRunState.RUNNING),
+ id="get_dag_run_state",
+ ),
],
)
def test_handle_requests(
diff --git a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
index 851a9c29b7ff1..414d6b6b2329f 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
@@ -50,18 +50,23 @@
AssetEventResponse,
AssetProfile,
AssetResponse,
+ DagRunState,
TaskInstance,
TerminalTIState,
)
from airflow.sdk.definitions.asset import Asset, AssetAlias, Dataset, Model
from airflow.sdk.definitions.param import DagParam
from airflow.sdk.definitions.variable import Variable
+from airflow.sdk.exceptions import ErrorType
from airflow.sdk.execution_time.comms import (
AssetEventsResult,
BundleInfo,
ConnectionResult,
+ DagRunStateResult,
DeferTask,
+ ErrorResponse,
GetConnection,
+ GetDagRunState,
GetVariable,
GetXCom,
OKResponse,
@@ -73,6 +78,7 @@
StartupDetails,
SucceedTask,
TaskState,
+ TriggerDagRun,
VariableResult,
XComResult,
)
@@ -1862,3 +1868,182 @@ def execute(self, context):
assert listener.state == [TaskInstanceState.RUNNING, TaskInstanceState.FAILED]
assert listener.error == error
+
+
+class TestTriggerDagRunOperator:
+ """Tests to verify various aspects of TriggerDagRunOperator"""
+
+ def test_handle_trigger_dag_run(self, create_runtime_ti, mock_supervisor_comms):
+ """Test that TriggerDagRunOperator (with default args) sends the correct message to the Supervisor"""
+ from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
+
+ task = TriggerDagRunOperator(
+ task_id="test_task",
+ trigger_dag_id="test_dag",
+ trigger_run_id="test_run_id",
+ )
+ ti = create_runtime_ti(dag_id="test_handle_trigger_dag_run", run_id="test_run", task=task)
+
+ log = mock.MagicMock()
+ mock_supervisor_comms.get_message.return_value = OKResponse(ok=True)
+ state, msg, _ = run(ti, log=log)
+
+ assert state == TaskInstanceState.SUCCESS
+ assert msg.state == TaskInstanceState.SUCCESS
+
+ expected_calls = [
+ mock.call.send_request(
+ msg=TriggerDagRun(
+ dag_id="test_dag",
+ run_id="test_run_id",
+ reset_dag_run=False,
+ ),
+ log=mock.ANY,
+ ),
+ mock.call.get_message(),
+ mock.call.send_request(
+ msg=SetXCom(
+ key="trigger_run_id",
+ value="test_run_id",
+ dag_id="test_handle_trigger_dag_run",
+ task_id="test_task",
+ run_id="test_run",
+ map_index=-1,
+ ),
+ log=mock.ANY,
+ ),
+ ]
+ mock_supervisor_comms.assert_has_calls(expected_calls)
+
+ @pytest.mark.parametrize(
+ ["skip_when_already_exists", "expected_state"],
+ [
+ (True, TaskInstanceState.SKIPPED),
+ (False, TaskInstanceState.FAILED),
+ ],
+ )
+ def test_handle_trigger_dag_run_conflict(
+ self, skip_when_already_exists, expected_state, create_runtime_ti, mock_supervisor_comms
+ ):
+ """Test that TriggerDagRunOperator (when dagrun already exists) sends the correct message to the Supervisor"""
+ from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
+
+ task = TriggerDagRunOperator(
+ task_id="test_task",
+ trigger_dag_id="test_dag",
+ trigger_run_id="test_run_id",
+ skip_when_already_exists=skip_when_already_exists,
+ )
+ ti = create_runtime_ti(dag_id="test_handle_trigger_dag_run_conflict", run_id="test_run", task=task)
+
+ log = mock.MagicMock()
+ mock_supervisor_comms.get_message.return_value = ErrorResponse(error=ErrorType.DAGRUN_ALREADY_EXISTS)
+ state, msg, _ = run(ti, log=log)
+
+ assert state == expected_state
+ assert msg.state == expected_state
+
+ expected_calls = [
+ mock.call.send_request(
+ msg=TriggerDagRun(
+ dag_id="test_dag",
+ run_id="test_run_id",
+ reset_dag_run=False,
+ ),
+ log=mock.ANY,
+ ),
+ mock.call.get_message(),
+ ]
+ mock_supervisor_comms.assert_has_calls(expected_calls)
+
+ @pytest.mark.parametrize(
+ ["allowed_states", "failed_states", "target_dr_state", "expected_task_state"],
+ [
+ (None, None, DagRunState.FAILED, TaskInstanceState.FAILED),
+ (None, None, DagRunState.SUCCESS, TaskInstanceState.SUCCESS),
+ ([DagRunState.FAILED], [], DagRunState.FAILED, DagRunState.SUCCESS),
+ ([DagRunState.FAILED], None, DagRunState.FAILED, DagRunState.FAILED),
+ ([DagRunState.SUCCESS], None, DagRunState.FAILED, DagRunState.FAILED),
+ ],
+ )
+ def test_handle_trigger_dag_run_wait_for_completion(
+ self,
+ allowed_states,
+ failed_states,
+ target_dr_state,
+ expected_task_state,
+ create_runtime_ti,
+ mock_supervisor_comms,
+ ):
+ """
+ Test that TriggerDagRunOperator (with wait_for_completion) sends the correct message to the Supervisor
+
+ It also polls the Supervisor for the DagRun state until it completes execution.
+ """
+ from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
+
+ task = TriggerDagRunOperator(
+ task_id="test_task",
+ trigger_dag_id="test_dag",
+ trigger_run_id="test_run_id",
+ poke_interval=5,
+ wait_for_completion=True,
+ allowed_states=allowed_states,
+ failed_states=failed_states,
+ )
+ ti = create_runtime_ti(
+ dag_id="test_handle_trigger_dag_run_wait_for_completion", run_id="test_run", task=task
+ )
+
+ log = mock.MagicMock()
+ mock_supervisor_comms.get_message.side_effect = [
+ # Successful Dag Run trigger
+ OKResponse(ok=True),
+ # Dag Run is still running
+ DagRunStateResult(state=DagRunState.RUNNING),
+ # Dag Run completes execution on the next poll
+ DagRunStateResult(state=target_dr_state),
+ ]
+ with mock.patch("time.sleep", return_value=None):
+ state, msg, _ = run(ti, log=log)
+
+ assert state == expected_task_state
+ assert msg.state == expected_task_state
+
+ expected_calls = [
+ mock.call.send_request(
+ msg=TriggerDagRun(
+ dag_id="test_dag",
+ run_id="test_run_id",
+ ),
+ log=mock.ANY,
+ ),
+ mock.call.get_message(),
+ mock.call.send_request(
+ msg=SetXCom(
+ key="trigger_run_id",
+ value="test_run_id",
+ dag_id="test_handle_trigger_dag_run_wait_for_completion",
+ task_id="test_task",
+ run_id="test_run",
+ map_index=-1,
+ ),
+ log=mock.ANY,
+ ),
+ mock.call.send_request(
+ msg=GetDagRunState(
+ dag_id="test_dag",
+ run_id="test_run_id",
+ ),
+ log=mock.ANY,
+ ),
+ mock.call.get_message(),
+ mock.call.send_request(
+ msg=GetDagRunState(
+ dag_id="test_dag",
+ run_id="test_run_id",
+ ),
+ log=mock.ANY,
+ ),
+ ]
+ mock_supervisor_comms.assert_has_calls(expected_calls)
diff --git a/tests/api_fastapi/execution_api/routes/test_dag_runs.py b/tests/api_fastapi/execution_api/routes/test_dag_runs.py
new file mode 100644
index 0000000000000..c5624f188b51a
--- /dev/null
+++ b/tests/api_fastapi/execution_api/routes/test_dag_runs.py
@@ -0,0 +1,220 @@
+# 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.models import DagModel
+from airflow.models.dagrun import DagRun
+from airflow.providers.standard.operators.empty import EmptyOperator
+from airflow.utils import timezone
+from airflow.utils.state import DagRunState
+
+from tests_common.test_utils.db import clear_db_runs
+
+pytestmark = pytest.mark.db_test
+
+
+class TestDagRunTrigger:
+ def setup_method(self):
+ clear_db_runs()
+
+ def teardown_method(self):
+ clear_db_runs()
+
+ def test_trigger_dag_run(self, client, session, dag_maker):
+ dag_id = "test_trigger_dag_run"
+ run_id = "test_run_id"
+ logical_date = timezone.datetime(2025, 2, 20)
+
+ with dag_maker(dag_id=dag_id, session=session, serialized=True):
+ EmptyOperator(task_id="test_task")
+
+ session.commit()
+
+ response = client.post(
+ f"/execution/dag-runs/{dag_id}/{run_id}",
+ json={"logical_date": logical_date.isoformat(), "conf": {"key1": "value1"}},
+ )
+
+ assert response.status_code == 204
+
+ dag_run = session.query(DagRun).filter(DagRun.run_id == run_id).one()
+ assert dag_run.conf == {"key1": "value1"}
+ assert dag_run.logical_date == logical_date
+
+ def test_trigger_dag_run_dag_not_found(self, client):
+ """Test that a DAG that does not exist cannot be triggered."""
+ dag_id = "dag_not_found"
+ logical_date = timezone.datetime(2025, 2, 20)
+
+ response = client.post(
+ f"/execution/dag-runs/{dag_id}/test_run_id", json={"logical_date": logical_date.isoformat()}
+ )
+
+ assert response.status_code == 404
+
+ def test_trigger_dag_run_import_error(self, client, session, dag_maker):
+ """Test that a DAG with import errors cannot be triggered."""
+
+ dag_id = "test_trigger_dag_run_import_error"
+ run_id = "test_run_id"
+ logical_date = timezone.datetime(2025, 2, 20)
+
+ with dag_maker(dag_id=dag_id, session=session, serialized=True):
+ EmptyOperator(task_id="test_task")
+
+ session.query(DagModel).filter(DagModel.dag_id == dag_id).update({"has_import_errors": True})
+
+ session.commit()
+
+ response = client.post(
+ f"/execution/dag-runs/{dag_id}/{run_id}",
+ json={"logical_date": logical_date.isoformat()},
+ )
+
+ assert response.status_code == 400
+ assert response.json() == {
+ "detail": {
+ "message": "DAG with dag_id: 'test_trigger_dag_run_import_error' has import errors and cannot be triggered",
+ "reason": "import_errors",
+ }
+ }
+
+ def test_trigger_dag_run_already_exists(self, client, session, dag_maker):
+ """Test that error is raised when a DAG Run already exists."""
+
+ dag_id = "test_trigger_dag_run_already_exists"
+ run_id = "test_run_id"
+ logical_date = timezone.datetime(2025, 2, 20)
+
+ with dag_maker(dag_id=dag_id, session=session, serialized=True):
+ EmptyOperator(task_id="test_task")
+
+ session.commit()
+
+ response = client.post(
+ f"/execution/dag-runs/{dag_id}/{run_id}",
+ json={"logical_date": logical_date.isoformat()},
+ )
+
+ assert response.status_code == 204
+
+ response = client.post(
+ f"/execution/dag-runs/{dag_id}/{run_id}",
+ json={"logical_date": logical_date.isoformat()},
+ )
+
+ assert response.status_code == 409
+ assert response.json() == {
+ "detail": {
+ "message": "A DAG Run already exists for DAG test_trigger_dag_run_already_exists with run id test_run_id",
+ "reason": "already_exists",
+ }
+ }
+
+
+class TestDagRunClear:
+ def setup_method(self):
+ clear_db_runs()
+
+ def teardown_method(self):
+ clear_db_runs()
+
+ def test_dag_run_clear(self, client, session, dag_maker):
+ dag_id = "test_trigger_dag_run_clear"
+ run_id = "test_run_id"
+
+ with dag_maker(dag_id=dag_id, session=session, serialized=True):
+ EmptyOperator(task_id="test_task")
+
+ dag_maker.create_dagrun(run_id=run_id, state=DagRunState.SUCCESS)
+
+ session.commit()
+
+ response = client.post(f"/execution/dag-runs/{dag_id}/{run_id}/clear")
+
+ assert response.status_code == 204
+
+ session.expire_all()
+ dag_run = session.query(DagRun).filter(DagRun.run_id == run_id).one()
+ assert dag_run.state == DagRunState.QUEUED
+
+ def test_dag_run_import_error(self, client, session, dag_maker):
+ """Test that a DAG with import errors cannot be cleared."""
+
+ dag_id = "test_trigger_dag_run_import_error"
+ run_id = "test_run_id"
+
+ with dag_maker(dag_id=dag_id, session=session, serialized=True):
+ EmptyOperator(task_id="test_task")
+
+ session.query(DagModel).filter(DagModel.dag_id == dag_id).update({"has_import_errors": True})
+
+ session.commit()
+
+ response = client.post(f"/execution/dag-runs/{dag_id}/{run_id}/clear")
+
+ assert response.status_code == 400
+ assert response.json() == {
+ "detail": {
+ "message": "DAG with dag_id: 'test_trigger_dag_run_import_error' has import errors and cannot be triggered",
+ "reason": "import_errors",
+ }
+ }
+
+ def test_dag_run_not_found(self, client):
+ """Test that a DAG that does not exist cannot be cleared."""
+ dag_id = "dag_not_found"
+ run_id = "test_run_id"
+
+ response = client.post(f"/execution/dag-runs/{dag_id}/{run_id}/clear")
+
+ assert response.status_code == 404
+
+
+class TestDagRunState:
+ def setup_method(self):
+ clear_db_runs()
+
+ def teardown_method(self):
+ clear_db_runs()
+
+ def test_get_state(self, client, session, dag_maker):
+ dag_id = "test_get_state"
+ run_id = "test_run_id"
+
+ with dag_maker(dag_id=dag_id, session=session, serialized=True):
+ EmptyOperator(task_id="test_task")
+
+ dag_maker.create_dagrun(run_id=run_id, state=DagRunState.SUCCESS)
+
+ session.commit()
+
+ response = client.get(f"/execution/dag-runs/{dag_id}/{run_id}/state")
+
+ assert response.status_code == 200
+ assert response.json() == {"state": "success"}
+
+ def test_dag_run_not_found(self, client):
+ dag_id = "dag_not_found"
+ run_id = "test_run_id"
+
+ response = client.post(f"/execution/dag-runs/{dag_id}/{run_id}/clear")
+
+ assert response.status_code == 404