Skip to content

Commit eef2c1c

Browse files
committed
feat: add OOM fault injection for JobSet TTR validation
1 parent 6a9dc2c commit eef2c1c

1 file changed

Lines changed: 138 additions & 120 deletions

File tree

dags/tpu_observability/jobset_ttr_pod_oom.py

Lines changed: 138 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import tempfile
2020
import os
2121
import random
22+
import time
2223
from typing import List
2324

2425
from airflow import models
@@ -31,38 +32,75 @@
3132
from dags.tpu_observability.utils import jobset_util as jobset
3233
from dags.tpu_observability.utils import node_pool_util as node_pool
3334
from dags.tpu_observability.utils import subprocess_util as subprocess
34-
from dags.tpu_observability.utils.jobset_util import JobSet
35-
from dags.tpu_observability.configs.common import MachineConfigMap, GCS_CONFIG_PATH
35+
from dags.tpu_observability.utils.jobset_util import Workload
36+
from dags.tpu_observability.configs.common import (
37+
MachineConfigMap,
38+
GCS_CONFIG_PATH,
39+
GCS_JOBSET_CONFIG_PATH,
40+
)
41+
from dags.common.scheduling_helper.scheduling_helper import SchedulingHelper, get_dag_timeout
42+
43+
DAG_ID = "jobset_ttr_pod_oom"
44+
DAGRUN_TIMEOUT = get_dag_timeout(DAG_ID)
45+
SCHEDULE = SchedulingHelper.arrange_schedule_time(DAG_ID)
3646

3747

3848
@task
39-
def trigger_oom_failure(info, pod_name: str):
49+
def trigger_oom_failure(info, pod_name: str, namespace: str):
4050
"""
41-
Triggers an OOM (Out-of-Memory) event on a specified TPU pod.
51+
Injects an OOM fault by running a memory-intensive loop inside a pod.
4252
43-
This task connects to the specified pod via kubectl exec and runs a
44-
memory-intensive Python script (oomkill.py). The script is designed
45-
to exhaust the container's memory limit, forcing a SIGKILL (Exit Code 137).
46-
A connection error is expected and caught when the pod is killed.
53+
This task waits for the target pod to stabilize, then executes a Python
54+
one-liner via 'kubectl exec' that rapidly allocates memory. The script
55+
uses bytearray allocation combined with an immediate write operation
56+
(a[-1][0] = 1) to ensure physical RAM is committed by the OS, effectively
57+
triggering a SIGKILL from the OOM Killer (Exit Code 137).
4758
4859
Args:
49-
info: Node pool and cluster information.
50-
pod_name (str): The name of the target pod to be OOMKilled.
60+
info: An object containing cluster and node pool credentials/metadata.
61+
pod_name: The name of the target TPU worker pod to inject the fault.
62+
namespace: The Kubernetes namespace where the pod is running.
63+
64+
Raises:
65+
Exception: If the subprocess execution fails for reasons other than
66+
the expected connection loss during an OOM event.
5167
"""
68+
69+
wait_time = 60
70+
print(
71+
f"Waiting {wait_time}s for Pod {pod_name} to stabilize before OOM Test..."
72+
)
73+
time.sleep(wait_time)
74+
75+
python_logic = (
76+
"import time\n"
77+
"a = []\n"
78+
"print('Starting memory stress test...')\n"
79+
"while True:\n"
80+
" a.append(bytearray(1024**3))\n"
81+
" a[-1][0] = 1\n"
82+
" time.sleep(0.01)"
83+
)
84+
5285
with tempfile.NamedTemporaryFile() as temp_config_file:
5386
env = os.environ.copy()
5487
env["KUBECONFIG"] = temp_config_file.name
5588

56-
cmd = " && ".join([
57-
jobset.Command.get_credentials_command(info),
58-
f"kubectl exec {pod_name} -n default -- python3 oomkill.py",
59-
])
89+
credentials_cmd = jobset.Command.get_credentials_command(info)
90+
exec_cmd = (
91+
f'kubectl exec {pod_name} -n {namespace} -- python3 -c "{python_logic}"'
92+
)
93+
94+
full_command = f"{credentials_cmd} && {exec_cmd}"
6095

6196
try:
62-
print(f"Executing OOM script in {pod_name}...")
63-
subprocess.run_exec(cmd, env=env)
64-
except RuntimeError as e:
65-
print(f"Expectation: Connection closed due to OOMKilled. Info: {e}")
97+
print(f"Blasting {pod_name} memory now...")
98+
subprocess.run_exec(full_command, env=env)
99+
except Exception as e:
100+
if "137" in str(e):
101+
print(f"Success: Pod {pod_name} was OOMKilled (Exit Code 137).")
102+
else:
103+
print(f"Connection lost (Possible OOM): {e}")
66104

67105

68106
@task
@@ -88,9 +126,10 @@ def pick_random_pod(active_pods: List[str]) -> str:
88126

89127

90128
with models.DAG(
91-
dag_id="jobset_ttr_pod_oom",
129+
dag_id=DAG_ID,
92130
start_date=datetime.datetime(2026, 1, 15),
93-
schedule="0 18 * * *" if composer_env.is_prod_env() else None,
131+
schedule=SCHEDULE if composer_env.is_prod_env() else None,
132+
dagrun_timeout=DAGRUN_TIMEOUT,
94133
catchup=False,
95134
tags=[
96135
"cloud-ml-auto-solutions",
@@ -130,103 +169,82 @@ def pick_random_pod(active_pods: List[str]) -> str:
130169
for machine in MachineConfigMap:
131170
config = machine.value
132171

133-
jobset_config = JobSet(
134-
jobset_name="jobset-ttr-pod-oom-v6e-workload",
135-
namespace="default",
136-
max_restarts=5,
137-
replicated_job_name="tpu-job-slice",
138-
replicas=1,
139-
backoff_limit=0,
140-
completions=4,
141-
parallelism=4,
142-
tpu_accelerator_type="tpu-v6e-slice",
143-
tpu_topology="4x4",
144-
container_name="jax-tpu-worker",
145-
image="us-docker.pkg.dev/cienet-cmcs/emma-tpu-test-repo/oom-test:v1",
146-
tpu_cores_per_pod=4,
147-
)
148-
raw_yaml = jobset_config.generate_yaml(workload_script="sleep infinity")
149-
custom_yaml = raw_yaml.replace(
150-
"google.com/tpu: 4",
151-
'google.com/tpu: 4\n memory: "4Gi"',
152-
)
153-
# Keyword arguments are generated dynamically at runtime (pylint does not
154-
# know this signature).
155-
with TaskGroup( # pylint: disable=unexpected-keyword-arg
156-
group_id=f"v{config.tpu_version.value}"
157-
):
158-
cluster_info = node_pool.build_node_pool_info_from_gcs_yaml.override(
159-
task_id="build_node_pool_info_from_gcs_yaml"
160-
)(
161-
gcs_path=GCS_CONFIG_PATH,
162-
dag_name="jobset_ttr_pod_oom",
163-
is_prod=composer_env.is_prod_env(),
164-
machine_type=config.machine_version.value,
165-
tpu_topology=config.tpu_topology,
166-
)
167-
168-
create_node_pool = node_pool.create.override(task_id="create_node_pool")(
169-
node_pool=cluster_info,
170-
)
171-
172-
start_workload = jobset.run_workload.override(task_id="start_workload")(
173-
node_pool=cluster_info,
174-
yaml_config=custom_yaml,
175-
namespace=jobset_config.namespace,
176-
)
177-
178-
ensure_all_pods_running = jobset.wait_for_all_pods_running.override(
179-
task_id="ensure_all_pods_running"
180-
)(
181-
num_pods=(jobset_config.replicas * jobset_config.parallelism),
182-
node_pool=cluster_info,
183-
)
184-
185-
found_pods = jobset.list_pod_names.override(task_id="list_pod_names")(
186-
node_pool=cluster_info,
187-
namespace=jobset_config.namespace,
188-
)
189-
190-
select_random_pod = pick_random_pod.override(task_id="select_random_pod")(
191-
active_pods=found_pods
192-
)
193-
194-
trigger_oom_killed = trigger_oom_failure.override(
195-
task_id="trigger_oom_killed"
196-
)(info=cluster_info, pod_name=select_random_pod)
197-
198-
wait_for_metric_upload = jobset.wait_for_jobset_ttr_to_be_found.override(
199-
task_id="wait_for_jobset_ttr_to_be_found"
200-
)(
201-
node_pool=cluster_info,
202-
jobset_name=jobset_config.jobset_name,
203-
)
204-
205-
cleanup_workload = jobset.end_workload.override(
206-
task_id="cleanup_workload", trigger_rule=TriggerRule.ALL_DONE
207-
)(
208-
node_pool=cluster_info,
209-
jobset_name=jobset_config.jobset_name,
210-
namespace=jobset_config.namespace,
211-
).as_teardown(
212-
setups=start_workload
213-
)
214-
215-
cleanup_node_pool = node_pool.delete.override(
216-
task_id="cleanup_node_pool", trigger_rule=TriggerRule.ALL_DONE
217-
)(node_pool=cluster_info).as_teardown(
218-
setups=create_node_pool,
219-
)
220-
221-
chain(
222-
cluster_info,
223-
create_node_pool,
224-
start_workload,
225-
ensure_all_pods_running,
226-
found_pods,
227-
select_random_pod,
228-
trigger_oom_killed,
229-
wait_for_metric_upload,
230-
cleanup_workload,
231-
cleanup_node_pool,
232-
)
172+
# Keyword arguments are generated dynamically at runtime (pylint does not
173+
# know this signature).
174+
with TaskGroup( # pylint: disable=unexpected-keyword-arg
175+
group_id=f"v{config.tpu_version.value}"
176+
):
177+
selector = jobset.generate_node_pool_selector("jobset-ttr-pod-oom")
178+
179+
jobset_config = jobset.build_jobset_from_gcs_yaml(
180+
gcs_path=GCS_JOBSET_CONFIG_PATH,
181+
dag_name=DAG_ID,
182+
node_pool_selector=selector,
183+
)
184+
185+
cluster_info = node_pool.build_node_pool_info_from_gcs_yaml.override(
186+
task_id="build_node_pool_info_from_gcs_yaml"
187+
)(
188+
gcs_path=GCS_CONFIG_PATH,
189+
dag_name=DAG_ID,
190+
is_prod=composer_env.is_prod_env(),
191+
machine_type=config.machine_version.value,
192+
tpu_topology=config.tpu_topology,
193+
node_pool_selector=selector,
194+
)
195+
196+
create_node_pool = node_pool.create.override(task_id="create_node_pool")(
197+
node_pool=cluster_info,
198+
)
199+
200+
start_workload = jobset.run_workload.override(task_id="start_workload")(
201+
node_pool=cluster_info,
202+
jobset_config=jobset_config,
203+
workload_type=Workload.JAX_TPU_BENCHMARK,
204+
)
205+
206+
ensure_all_pods_running = jobset.wait_for_all_pods_running.override(
207+
task_id="ensure_all_pods_running"
208+
)(
209+
node_pool=cluster_info,
210+
jobset_config=jobset_config,
211+
)
212+
213+
select_random_pod = pick_random_pod.override(task_id="select_random_pod")(
214+
active_pods=ensure_all_pods_running
215+
)
216+
217+
trigger_oom_killed = trigger_oom_failure.override(
218+
task_id="trigger_oom_killed"
219+
)(info=cluster_info, pod_name=select_random_pod, namespace="default")
220+
221+
wait_for_metric_upload = jobset.wait_for_jobset_ttr_to_be_found.override(
222+
task_id="wait_for_jobset_ttr_to_be_found"
223+
)(
224+
node_pool=cluster_info,
225+
jobset_config=jobset_config,
226+
)
227+
228+
cleanup_workload = jobset.end_workload.override(
229+
task_id="cleanup_workload", trigger_rule=TriggerRule.ALL_DONE
230+
)(node_pool=cluster_info, jobset_config=jobset_config).as_teardown(
231+
setups=start_workload
232+
)
233+
234+
cleanup_node_pool = node_pool.delete.override(
235+
task_id="cleanup_node_pool", trigger_rule=TriggerRule.ALL_DONE
236+
)(node_pool=cluster_info).as_teardown(
237+
setups=create_node_pool,
238+
)
239+
240+
chain(
241+
cluster_info,
242+
create_node_pool,
243+
start_workload,
244+
ensure_all_pods_running,
245+
select_random_pod,
246+
trigger_oom_killed,
247+
wait_for_metric_upload,
248+
cleanup_workload,
249+
cleanup_node_pool,
250+
)

0 commit comments

Comments
 (0)