Skip to content

Commit 4e61747

Browse files
committed
feat: add jobset_ttr_pod_oom DAG for v6e recovery validation
1 parent a29b9ea commit 4e61747

3 files changed

Lines changed: 244 additions & 1 deletion

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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+
"""A DAG to validate the `tpumonitoring` SDK, ensuring help() and
16+
list_supported_metrics() are functional inside TPU worker pods."""
17+
18+
import datetime
19+
import tempfile
20+
import os
21+
import random
22+
from typing import List
23+
24+
from airflow import models
25+
from airflow.utils.trigger_rule import TriggerRule
26+
from airflow.utils.task_group import TaskGroup
27+
from airflow.decorators import task
28+
29+
from dags import composer_env
30+
from dags.tpu_observability.utils import jobset_util as jobset
31+
from dags.tpu_observability.utils import node_pool_util as node_pool
32+
from dags.tpu_observability.utils import subprocess_util as subprocess
33+
from dags.tpu_observability.utils.jobset_util import JobSet, Workload
34+
from dags.tpu_observability.configs.common import MachineConfigMap, GCS_CONFIG_PATH
35+
36+
37+
@task
38+
def trigger_oom_failure(info, pod_name: str):
39+
"""
40+
Triggers an OOM (Out-of-Memory) event on a specified TPU pod.
41+
42+
This task connects to the specified pod via kubectl exec and runs a
43+
memory-intensive Python script (oomkill.py). The script is designed
44+
to exhaust the container's memory limit, forcing a SIGKILL (Exit Code 137).
45+
A connection error is expected and caught when the pod is killed.
46+
47+
Args:
48+
info: Node pool and cluster information.
49+
pod_name (str): The name of the target pod to be OOMKilled.
50+
"""
51+
with tempfile.NamedTemporaryFile() as temp_config_file:
52+
env = os.environ.copy()
53+
env["KUBECONFIG"] = temp_config_file.name
54+
55+
cmd = " && ".join([
56+
jobset.Command.get_credentials_command(info),
57+
f"kubectl exec {pod_name} -n default -- python3 oomkill.py",
58+
])
59+
60+
try:
61+
print(f"Executing OOM script in {pod_name}...")
62+
subprocess.run_exec(cmd, env=env)
63+
except Exception as e:
64+
print(f"Expectation: Connection closed due to OOMKilled. Info: {e}")
65+
66+
@task
67+
def pick_random_pod(pod_names: List[str]) -> str:
68+
"""
69+
Randomly selects one pod from a list of available JobSet pods.
70+
71+
This ensures that the fault injection is performed on a single
72+
unit of the TPU slice, allowing the test to validate how the
73+
JobSet controller handles partial failures within a replica.
74+
75+
Args:
76+
pod_names (List[str]): List of active pod names in the JobSet.
77+
78+
Returns:
79+
str: The name of the randomly selected pod.
80+
"""
81+
if not pod_names:
82+
raise ValueError("No pods found to attack!")
83+
chosen_pod = random.choice(pod_names)
84+
print(f"Randomly selected pod for OOM test: {chosen_pod}")
85+
return chosen_pod
86+
87+
with models.DAG(
88+
dag_id="jobset_ttr_pod_oom",
89+
start_date=datetime.datetime(2026, 1, 15),
90+
schedule="0 18 * * *" if composer_env.is_prod_env() else None,
91+
catchup=False,
92+
tags=[
93+
"cloud-ml-auto-solutions",
94+
"jobset",
95+
"tpu-observability",
96+
"pod_oom",
97+
"TPU",
98+
"v6e-16",
99+
"SDK",
100+
"Validation",
101+
],
102+
description=(
103+
"This DAG tests the JobSet time-to-recover metric by injecting an OOM event "
104+
"into a random pod to trigger a recovery, then polls Cloud Monitoring to "
105+
"verify the metric is updated."
106+
),
107+
doc_md="""
108+
### JobSet Time-To-Recover (TTR) Test Using Random Pod OOM Injection
109+
### Description
110+
This DAG verifies that JobSet can recover from a single pod failure caused by
111+
an Out-Of-Memory (OOM) event. It launches a JobSet with memory limits,
112+
injects a memory-intensive workload into a running pod, and uses a sensor
113+
to confirm that the JobSet controller triggers a recovery and reports the
114+
recovery duration.
115+
### Prerequisites
116+
This test requires an existing cluster and a container image containing
117+
the `oomkill.py` script to run.
118+
### Procedures
119+
First, the node pool is created. A JobSet YAML with specific memory constraints
120+
is then launched on the cluster and given time for all pods to reach a
121+
`Running` state. After this, a random pod is selected and an OOM event is
122+
triggered via `kubectl exec` to interrupt the JobSet (expecting Exit Code 137).
123+
A sensor is finally run which will poll Cloud Monitoring to detect that the
124+
JobSet Time-To-Recover (TTR) metric has been updated, resulting in a success,
125+
or timeout, and fail.
126+
""",
127+
) as dag:
128+
for machine in MachineConfigMap:
129+
config = machine.value
130+
131+
jobset_config = JobSet(
132+
jobset_name="jobset-ttr-pod-oom-v6e-workload",
133+
namespace="default",
134+
max_restarts=5,
135+
replicated_job_name="tpu-job-slice",
136+
replicas=1,
137+
backoff_limit=0,
138+
completions=4,
139+
parallelism=4,
140+
tpu_accelerator_type="tpu-v6e-slice",
141+
tpu_topology="4x4",
142+
container_name="jax-tpu-worker",
143+
image="us-docker.pkg.dev/cienet-cmcs/emma-tpu-test-repo/oom-test:v1",
144+
tpu_cores_per_pod=4,
145+
)
146+
raw_yaml = jobset_config.generate_yaml(workload_script="sleep infinity")
147+
custom_yaml = raw_yaml.replace(
148+
"google.com/tpu: 4",
149+
"google.com/tpu: 4\n memory: \"4Gi\""
150+
)
151+
# Keyword arguments are generated dynamically at runtime (pylint does not
152+
# know this signature).
153+
with TaskGroup( # pylint: disable=unexpected-keyword-arg
154+
group_id=f"v{config.tpu_version.value}"
155+
):
156+
cluster_info = node_pool.build_node_pool_info_from_gcs_yaml.override(
157+
task_id="build_node_pool_info_from_gcs_yaml"
158+
)(
159+
gcs_path=GCS_CONFIG_PATH,
160+
dag_name="jobset_ttr_pod_oom",
161+
is_prod=composer_env.is_prod_env(),
162+
machine_type=config.machine_version.value,
163+
tpu_topology=config.tpu_topology,
164+
)
165+
166+
create_node_pool = node_pool.create.override(task_id="create_node_pool")(
167+
node_pool=cluster_info,
168+
)
169+
170+
start_workload = jobset.run_workload.override(task_id="start_workload")(
171+
node_pool=cluster_info,
172+
yaml_config=custom_yaml,
173+
namespace=jobset_config.namespace,
174+
)
175+
176+
ensure_all_pods_running = jobset.wait_for_all_pods_running.override(
177+
task_id="ensure_all_pods_running"
178+
)(
179+
num_pods=(jobset_config.replicas * jobset_config.parallelism),
180+
node_pool=cluster_info,
181+
)
182+
183+
pod_names = jobset.list_pod_names.override(task_id="list_pod_names")(
184+
node_pool=cluster_info,
185+
namespace=jobset_config.namespace,
186+
)
187+
188+
select_random_pod = pick_random_pod.override(task_id='select_random_pod')(
189+
pod_names=pod_names
190+
)
191+
192+
trigger_oom_killed = trigger_oom_failure.override(task_id='trigger_oom_killed')(
193+
info=cluster_info,
194+
pod_name=select_random_pod
195+
)
196+
197+
wait_for_metric_upload = jobset.wait_for_jobset_ttr_to_be_found.override(
198+
task_id="wait_for_jobset_ttr_to_be_found"
199+
)(
200+
node_pool=cluster_info,
201+
jobset_name=jobset_config.jobset_name,
202+
)
203+
204+
cleanup_workload = jobset.end_workload.override(
205+
task_id="cleanup_workload", trigger_rule=TriggerRule.ALL_DONE
206+
)(
207+
node_pool=cluster_info,
208+
jobset_name=jobset_config.jobset_name,
209+
namespace=jobset_config.namespace,
210+
).as_teardown(
211+
setups=start_workload
212+
)
213+
214+
cleanup_node_pool = node_pool.delete.override(
215+
task_id="cleanup_node_pool", trigger_rule=TriggerRule.ALL_DONE
216+
)(node_pool=cluster_info).as_teardown(
217+
setups=create_node_pool,
218+
)
219+
220+
# Airflow uses >> for task chaining, which is pointless for pylint.
221+
# pylint: disable=pointless-statement
222+
(
223+
cluster_info
224+
>> create_node_pool
225+
>> start_workload
226+
>> ensure_all_pods_running
227+
>> pod_names
228+
>> select_random_pod
229+
>> trigger_oom_killed
230+
>> wait_for_metric_upload
231+
>> cleanup_workload
232+
>> cleanup_node_pool
233+
)
234+
# pylint: enable=pointless-statement

dags/tpu_observability/jobset_ttr_rollback.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@
122122
rollback_node_pool = node_pool.rollback(node_pool=cluster_info)
123123

124124
wait_for_metric_upload = jobset.wait_for_jobset_ttr_to_be_found(
125-
node_pool=cluster_info
125+
node_pool=cluster_info,
126+
jobset_name=jobset_config.jobset_name,
126127
)
127128

128129
cleanup_workload = jobset.end_workload.override(

dags/tpu_observability/utils/jobset_util.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,8 +525,15 @@ def wait_for_jobset_started(
525525
return all(p > threshold_value for p in last_n_data_points)
526526

527527

528+
<<<<<<< HEAD
528529
@task.sensor(poke_interval=60, timeout=3600, mode="reschedule")
529530
def wait_for_jobset_ttr_to_be_found(node_pool: node_pool_info) -> bool:
531+
=======
532+
@task.sensor(poke_interval=60, timeout=3600, mode="poke")
533+
def wait_for_jobset_ttr_to_be_found(
534+
node_pool: node_pool_info, jobset_name: str
535+
) -> bool:
536+
>>>>>>> 03535a3 (feat: add jobset_ttr_pod_oom DAG for v6e recovery validation)
530537
"""
531538
Polls the jobset time_between_interruptions metric.
532539
@@ -548,6 +555,7 @@ def wait_for_jobset_ttr_to_be_found(node_pool: node_pool_info) -> bool:
548555
filter_str=(
549556
'metric.type="kubernetes.io/jobset/times_to_recover" '
550557
f'resource.labels.cluster_name="{node_pool.cluster_name}" '
558+
f'resource.labels.entity_name="{jobset_name}"'
551559
),
552560
start_time=TimeUtil.from_datetime(now - datetime.timedelta(minutes=60)),
553561
end_time=TimeUtil.from_datetime(now),

0 commit comments

Comments
 (0)