Skip to content

Commit 9fb2f3b

Browse files
feat: Add TaskGroupWithTimeout to support group-level shared timeouts (GoogleCloudPlatform#1266)
This PR introduces `TaskGroupWithTimeout`, a custom `TaskGroup` designed to enforce a shared timeout across all tasks within the group. The implementation establishes a collective deadline for all member tasks by overriding the `.add` method and injecting timeout enforcement logic into each task's `.execute` method. --------- Co-authored-by: Alfred Yu <alfred.yu@cienet.com>
1 parent f06f074 commit 9fb2f3b

2 files changed

Lines changed: 229 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""TaskGroupWithTimeout: a TaskGroup that enforces a shared deadline timeout.
16+
17+
Usage:
18+
from dags.common.task_group_with_timeout import TaskGroupWithTimeout
19+
"""
20+
21+
from dags.common.task_group_with_timeout.task_group_with_timeout import (
22+
TaskGroupWithTimeout,
23+
)
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""TaskGroupWithTimeout: timeout enforcement for Airflow TaskGroups."""
16+
17+
import logging
18+
from datetime import datetime, timedelta, timezone
19+
20+
from airflow.exceptions import AirflowFailException
21+
from airflow.models import BaseOperator
22+
from airflow.models.mappedoperator import MappedOperator
23+
from airflow.models.taskmixin import DAGNode
24+
from airflow.operators.python import PythonOperator
25+
from airflow.sensors.base import BaseSensorOperator
26+
from airflow.utils.context import Context
27+
from airflow.utils.task_group import TaskGroup
28+
from airflow.utils.timeout import timeout as AirflowTimeout
29+
from airflow.utils.trigger_rule import TriggerRule
30+
31+
32+
class TaskGroupWithTimeout(TaskGroup):
33+
"""A TaskGroup that enforces a per-task timeout.
34+
35+
Each task in the group shares a single deadline: the first task to run
36+
sets the deadline to `now + timeout`, and each subsequent task receives
37+
only the time remaining until that deadline.
38+
39+
This customized object is implemented by intercepting `TaskGroup`'s `.add()`
40+
at parsing phase, and wrapping `Task`'s `.execute()` to allow setting up a
41+
dynamic timeout value so that it can affect `.execute()` at runtime phase.
42+
43+
Limitations:
44+
1. Dynamic Task Mapping: Tasks generated via `.expand()` return a
45+
`MappedOperator` which will unmap as a `BaseOperator` only at runtime;
46+
therefore, there's no `.execute()` method to wrap at parsing phase.
47+
2. Nested TaskGroups: The `.add()` interception only applies to direct
48+
children. Tasks placed inside a nested `TaskGroup` will bypass this
49+
parent group's customized wrapper and evade the shared timeout budget.
50+
51+
Args:
52+
group_id: Unique identifier for this TaskGroup.
53+
timeout: Timeout as a timedelta (e.g. `timedelta(minutes=30)`).
54+
is_teardown: When `True`, the group runs even if an upstream group
55+
has failed — suitable for cleanup/teardown groups (e.g. a `post_test`
56+
group following a `testing` group). Defaults to `False`.
57+
**kwargs: Additional arguments passed to TaskGroup.
58+
"""
59+
60+
ROOT_TASK_ID = "provision_taskgroup_session"
61+
62+
def __init__(
63+
self,
64+
group_id,
65+
timeout: timedelta,
66+
is_teardown: bool = False,
67+
**kwargs,
68+
):
69+
super().__init__(group_id=group_id, **kwargs)
70+
self.group_name = f"{self.__class__.__name__}: '{group_id}'"
71+
self.timeout = timeout
72+
self.trigger_rule = (
73+
TriggerRule.ALL_DONE if is_teardown else TriggerRule.ALL_SUCCESS
74+
)
75+
self._root_node = None
76+
77+
def __enter__(self):
78+
"""Inject `_root_node` when entering the group context.
79+
80+
Overridden because this group's timeout mechanism needs a single anchor
81+
task to record the start time; see class docstring for the full flow.
82+
"""
83+
tg = super().__enter__()
84+
self._root_node = PythonOperator(
85+
task_id=self.ROOT_TASK_ID,
86+
python_callable=lambda: datetime.now(timezone.utc).isoformat(),
87+
trigger_rule=self.trigger_rule,
88+
)
89+
return tg
90+
91+
def __exit__(self, *args):
92+
"""Wire `_root_node` as upstream of in-group root children on context exit.
93+
94+
Overridden to guarantee `_root_node` runs first. Only children with no
95+
in-group sibling upstream get a direct edge; others inherit transitively.
96+
"""
97+
children_ids = set(self.children.keys())
98+
for child in self.children.values():
99+
if child is self._root_node:
100+
continue
101+
# If a sibling already chains into this child, the dependency on
102+
# _root_node is satisfied transitively — no need to add a direct edge.
103+
if child.upstream_task_ids & children_ids:
104+
continue
105+
child.set_upstream(self._root_node)
106+
return super().__exit__(*args)
107+
108+
def add(self, node: DAGNode):
109+
node = super().add(node)
110+
111+
match node:
112+
case TaskGroup():
113+
# Tasks inside a nested TaskGroup will skip this parent's logic.
114+
# This means they will escape the shared timeout limit.
115+
# To prevent this, we intentionally block nested TaskGroups here.
116+
#
117+
# TODO: support nested TaskGroupWithTimeout
118+
raise AirflowFailException(
119+
f"{self.__class__.__name__} does not support nested TaskGroups"
120+
)
121+
122+
case MappedOperator():
123+
# Mapped tasks don't have an `.execute()` method at this stage.
124+
# This means they will escape the shared timeout limit.
125+
# To prevent this, we intentionally block MappedOperators here.
126+
raise AirflowFailException(
127+
f"{self.__class__.__name__} does not support Dynamic Task Mapping"
128+
)
129+
130+
case BaseOperator() if node.task_id.endswith(f".{self.ROOT_TASK_ID}"):
131+
# Skip the root node, which only initiates the session of this task
132+
# group and requires no interception.
133+
return node
134+
135+
case BaseOperator():
136+
# Use the unbound method so `self` binds at execution time, after
137+
# Airflow resolves XComArg placeholders. Binding via `node.execute` at
138+
# the parsing phase leaks unresolved placeholders into XCom and breaks
139+
# DAG serialization.
140+
original_execute = type(node).execute
141+
142+
group_name = self.group_name
143+
timeout = self.timeout
144+
root_node_id = self._root_node.task_id
145+
146+
def wrapped_execute(context: Context):
147+
task_instance = context.get("task_instance")
148+
149+
start_time_str = task_instance.xcom_pull(task_ids=root_node_id)
150+
if not start_time_str:
151+
raise AirflowFailException(
152+
"Failed to overwrite timeout for task: "
153+
f"{group_name} session wasn't initiated."
154+
)
155+
156+
start_time = datetime.fromisoformat(start_time_str)
157+
deadline = start_time + timeout
158+
remaining = (deadline - datetime.now(timezone.utc)).total_seconds()
159+
if remaining <= 0:
160+
raise AirflowFailException(f"{group_name} timeout exceeded")
161+
162+
task = task_instance.task
163+
164+
# Take the minimum value as the effective timeout to ensure all tasks
165+
# are strictly bounded under this task group's shared deadline.
166+
effective_timeout_sec = min(remaining, _determine_task_timeout(task))
167+
logging.info(
168+
f"{group_name}; "
169+
f"task: '{task_instance.task_id}'; "
170+
f"effective timeout: {effective_timeout_sec}s"
171+
)
172+
173+
# Group-budget exhaustion is enforced by the `remaining <= 0` check
174+
# above on the next retry; let AirflowTaskTimeout propagate normally.
175+
with AirflowTimeout(seconds=int(effective_timeout_sec)):
176+
return original_execute(task, context)
177+
178+
node.execute = wrapped_execute
179+
return node
180+
181+
182+
def _determine_task_timeout(task: BaseOperator) -> float:
183+
"""
184+
Determines the effective timeout for a task by identifying which limit
185+
triggers first.
186+
187+
This method centralizes the logic for various operator types.
188+
- For sensors, it resolves the potential overlap between sensor-specific
189+
timeouts and general execution timeouts.
190+
- For standard operators, it takes "inf" as the value when no limit is
191+
set, which aligns with the API's behavior of allowing unlimited
192+
execution.
193+
"""
194+
# Since Airflow treats an unset `execution_timeout` as unlimited,
195+
# we take "inf" as its value to align with this behavior
196+
is_set = task.execution_timeout is not None
197+
inf = float("inf")
198+
timeout_1 = task.execution_timeout.total_seconds() if is_set else inf
199+
200+
if isinstance(task, BaseSensorOperator):
201+
# This attribute has a default value stored in the configuration file;
202+
# therefore, `timeout` will always be set.
203+
timeout_2 = task.timeout
204+
return min(timeout_1, timeout_2)
205+
206+
return timeout_1

0 commit comments

Comments
 (0)