diff --git a/airflow-core/src/airflow/models/dagrun.py b/airflow-core/src/airflow/models/dagrun.py
index 7d41528df5ca7..5bdefb99f775e 100644
--- a/airflow-core/src/airflow/models/dagrun.py
+++ b/airflow-core/src/airflow/models/dagrun.py
@@ -1195,27 +1195,31 @@ def get_previous_scheduled_dagrun(
def _tis_for_dagrun_state(self, *, dag, tis):
"""
- Return the collection of tasks that should be considered for evaluation of terminal dag run state.
-
- Teardown tasks by default are not considered for the purpose of dag run state. But
- users may enable such consideration with on_failure_fail_dagrun.
+ Return the collection of tasks that should be considered for evaluation
+ of terminal dag run state.
"""
def is_effective_leaf(task):
for down_task_id in task.downstream_task_ids:
down_task = dag.get_task(down_task_id)
- if not down_task.is_teardown or down_task.on_failure_fail_dagrun:
- # we found a down task that is not ignorable; not a leaf
+
+ if (
+ not down_task.is_teardown
+ or (
+ down_task.on_failure_fail_dagrun
+ and down_task.on_success_succeed_dagrun
+ )
+ ):
return False
- # we found no ignorable downstreams
- # evaluate whether task is itself ignorable
return not task.is_teardown or task.on_failure_fail_dagrun
-
leaf_task_ids = {x.task_id for x in dag.tasks if is_effective_leaf(x)}
if not leaf_task_ids:
- # can happen if dag is exclusively teardown tasks
leaf_task_ids = {x.task_id for x in dag.tasks if not x.downstream_list}
- leaf_tis = {ti for ti in tis if ti.task_id in leaf_task_ids if ti.state != TaskInstanceState.REMOVED}
+ leaf_tis = {
+ ti
+ for ti in tis
+ if ti.task_id in leaf_task_ids and ti.state != TaskInstanceState.REMOVED
+ }
return leaf_tis
def _emit_dagrun_span(self, state: DagRunState):
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
index e8735ea2713c0..2656a817a9d15 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
@@ -15,6 +15,7 @@
},
"connections": {
"add": "Add $t(connections.connection_one)",
+ "duplicate": "Duplicate",
"columns": {
"connectionId": "Connection ID",
"connectionType": "Connection Type",
diff --git a/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx b/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx
index 6279938f5747d..da4bb91ade634 100644
--- a/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx
@@ -52,6 +52,7 @@ import { formatNumber, useDocumentTitle } from "src/utils";
import AddConnectionButton from "./AddConnectionButton";
import DeleteConnectionButton from "./DeleteConnectionButton";
import DeleteConnectionsButton from "./DeleteConnectionsButton";
+import DuplicateConnectionButton from "./DuplicateConnectionButton";
import EditConnectionButton from "./EditConnectionButton";
import { NothingFoundInfo } from "./NothingFoundInfo";
import TestConnectionButton from "./TestConnectionButton";
@@ -125,6 +126,7 @@ const getColumns = ({
+
),
diff --git a/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.tsx b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.tsx
new file mode 100644
index 0000000000000..0af1480883a9d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.tsx
@@ -0,0 +1,87 @@
+
+/*!
+ * 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.
+ */
+import { useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiCopy } from "react-icons/fi";
+
+import type { ConnectionResponse } from "openapi/requests/types.gen";
+
+import { IconButton, Modal } from "src/system-components";
+
+import { useAddConnection } from "src/queries/useAddConnection";
+
+import ConnectionForm from "./ConnectionForm";
+import type { ConnectionBody } from "./Connections";
+
+type Props = {
+ readonly connection: ConnectionResponse;
+ readonly disabled: boolean;
+};
+
+const DuplicateConnectionButton = ({ connection, disabled }: Props) => {
+ const { t: translate } = useTranslation("admin");
+ const { onClose, onOpen, open } = useDisclosure();
+
+ const initialConnection: ConnectionBody = {
+ conn_type: connection.conn_type,
+ connection_id: "",
+ description: connection.description ?? "",
+ extra: connection.extra === "" || connection.extra === null ? "{}" : connection.extra,
+ host: connection.host ?? "",
+ login: connection.login ?? "",
+ password: "",
+ port: connection.port?.toString() ?? "",
+ schema: connection.schema ?? "",
+ team_name: connection.team_name ?? "",
+ };
+
+ const { addConnection, error, isPending } = useAddConnection({
+ onSuccessConfirm: onClose,
+ });
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default DuplicateConnectionButton;
diff --git a/airflow-core/tests/unit/models/test_dagrun.py b/airflow-core/tests/unit/models/test_dagrun.py
index 4e00a1392ea3d..699e386fa77b6 100644
--- a/airflow-core/tests/unit/models/test_dagrun.py
+++ b/airflow-core/tests/unit/models/test_dagrun.py
@@ -4784,7 +4784,48 @@ def mytask():
dr = session.scalar(select(DagRun))
assert dr.state == DagRunState.FAILED
+def test_work_task_failure_when_teardown_succeeds_and_on_success_is_false(
+ dag_maker, session
+):
+ with dag_maker():
+
+ @setup
+ def setuptask():
+ pass
+ @teardown(
+ on_failure_fail_dagrun=True,
+ on_success_succeed_dagrun=False,
+ )
+ def teardown_task():
+ pass
+
+ @task
+ def mytask():
+ pass
+
+ with setuptask() >> teardown_task():
+ mytask()
+
+ dr = dag_maker.create_dagrun()
+ s1 = dr.get_task_instance(task_id="setuptask")
+ td1 = dr.get_task_instance(task_id="teardown_task")
+ t1 = dr.get_task_instance(task_id="mytask")
+
+ s1.state = TaskInstanceState.SUCCESS
+ td1.state = TaskInstanceState.SUCCESS
+ t1.state = TaskInstanceState.FAILED
+
+ session.merge(s1)
+ session.merge(td1)
+ session.merge(t1)
+ session.flush()
+
+ dr.update_state()
+ session.flush()
+
+ dr = session.scalar(select(DagRun))
+ assert dr.state == DagRunState.FAILED
def test_failure_of_leaf_task_not_connected_to_teardown_task(dag_maker, session):
with dag_maker():
diff --git a/task-sdk/src/airflow/sdk/bases/decorator.py b/task-sdk/src/airflow/sdk/bases/decorator.py
index 896bedb3abc37..ac3e788460c91 100644
--- a/task-sdk/src/airflow/sdk/bases/decorator.py
+++ b/task-sdk/src/airflow/sdk/bases/decorator.py
@@ -521,7 +521,7 @@ class _TaskDecorator(ExpandableFactory, Generic[FParams, FReturn, OperatorSubcla
is_teardown: bool = False
returns_dag_result: bool = False
on_failure_fail_dagrun: bool = False
-
+ on_success_succeed_dagrun: bool = True
# This is set in __attrs_post_init__ by update_wrapper. Provided here for type hints.
__wrapped__: Callable[FParams, FReturn] = attr.ib(init=False)
@@ -566,6 +566,7 @@ def __call__(self, *args: FParams.args, **kwargs: FParams.kwargs) -> XComArg:
raise ValueError("Trigger rule not configurable for teardown tasks.")
self.kwargs.update(trigger_rule=TriggerRule.ALL_DONE_SETUP_SUCCESS)
on_failure_fail_dagrun = self.kwargs.pop("on_failure_fail_dagrun", self.on_failure_fail_dagrun)
+ on_success_succeed_dagrun = self.kwargs.pop("on_success_succeed_dagrun", self.on_success_succeed_dagrun)
op = self.operator_class(
python_callable=self.function,
op_args=args,
@@ -576,6 +577,7 @@ def __call__(self, *args: FParams.args, **kwargs: FParams.kwargs) -> XComArg:
op.is_setup = self.is_setup
op.is_teardown = self.is_teardown
op.on_failure_fail_dagrun = on_failure_fail_dagrun
+ op.on_success_succeed_dagrun = on_success_succeed_dagrun
if self.returns_dag_result:
check_dag_result_outside_loop(op)
op.returns_dag_result = self.returns_dag_result
@@ -660,6 +662,7 @@ def _expand(self, expand_input: ExpandInput, *, strict: bool) -> XComArg:
"is_setup": self.is_setup,
"is_teardown": self.is_teardown,
"on_failure_fail_dagrun": self.on_failure_fail_dagrun,
+ "on_success_succeed_dagrun": self.on_success_succeed_dagrun,
}
base_signature = inspect.signature(BaseOperator)
ignore = {
@@ -762,6 +765,7 @@ def override(self, **kwargs: Any) -> _TaskDecorator[FParams, FReturn, OperatorSu
setattr(result, "is_setup", self.is_setup)
setattr(result, "is_teardown", self.is_teardown)
setattr(result, "on_failure_fail_dagrun", self.on_failure_fail_dagrun)
+ setattr(result, "on_success_succeed_dagrun", self.on_success_succeed_dagrun)
return result
diff --git a/task-sdk/src/airflow/sdk/definitions/decorators/setup_teardown.py b/task-sdk/src/airflow/sdk/definitions/decorators/setup_teardown.py
index 0cdd8b52a5f37..f6900ec5d03f3 100644
--- a/task-sdk/src/airflow/sdk/definitions/decorators/setup_teardown.py
+++ b/task-sdk/src/airflow/sdk/definitions/decorators/setup_teardown.py
@@ -58,13 +58,14 @@ def initialize_context(...):
return func
-def teardown_task(_func=None, *, on_failure_fail_dagrun: bool = False) -> Callable:
+def teardown_task(_func=None, *, on_failure_fail_dagrun: bool = False, on_success_succeed_dagrun: bool = True,) -> Callable:
"""
Decorate a function to mark it as a teardown task.
A teardown task runs after all main tasks in its Dag or TaskGroup context.
If ``on_failure_fail_dagrun=True``, a failure in teardown will mark the Dag run as failed.
-
+ If ``on_success_succeed_dagrun=True``, a successful teardown can mark the Dag run as successful
+ even if another task failed.
Example::
@teardown(on_failure_fail_dagrun=True)
@@ -82,6 +83,7 @@ def teardown(func: Callable) -> Callable:
func.is_teardown = True
func.on_failure_fail_dagrun = on_failure_fail_dagrun
+ func.on_success_succeed_dagrun = on_success_succeed_dagrun
return func
if _func is None: