Skip to content

Commit 3fd17db

Browse files
Add a new DAG that verifies Jobset Uptime metrics (GoogleCloudPlatform#1175)
This changes introduces a new DAG that automates the process of creating a TPU v6e-16 node pool, launching a jobset, and monitoring the jobset uptime metric to ensure it behaves correctly. It also includes a negative test case to verify metric behavior over invalid time ranges. Co-authored-by: chengpinglin <chengping.lin@cienet.com>
1 parent 7769c5a commit 3fd17db

2 files changed

Lines changed: 250 additions & 0 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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 test jobset uptime metric."""
16+
17+
import datetime
18+
19+
from airflow import models
20+
from airflow.decorators import task
21+
from airflow.models.baseoperator import chain
22+
from airflow.utils.task_group import TaskGroup
23+
from airflow.utils.trigger_rule import TriggerRule
24+
25+
from dags import composer_env
26+
from dags.tpu_observability.configs.common import (
27+
MachineConfigMap,
28+
GCS_CONFIG_PATH,
29+
GCS_JOBSET_CONFIG_PATH,
30+
)
31+
from dags.tpu_observability.utils import jobset_util as jobset
32+
from dags.tpu_observability.utils import node_pool_util as node_pool
33+
from dags.tpu_observability.utils.jobset_util import Workload
34+
from dags.tpu_observability.utils.time_util import TimeUtil
35+
36+
37+
@task
38+
def get_current_time() -> TimeUtil:
39+
"""Get the current time in UTC."""
40+
current_time_utc = datetime.datetime.now(datetime.timezone.utc)
41+
return TimeUtil.from_datetime(current_time_utc)
42+
43+
44+
# Keyword arguments are generated dynamically at runtime (pylint does not
45+
# know this signature).
46+
with models.DAG( # pylint: disable=unexpected-keyword-arg
47+
dag_id="jobset_uptime_validation",
48+
start_date=datetime.datetime(2025, 8, 15),
49+
default_args={"retries": 0},
50+
schedule="30 16 * * *" if composer_env.is_prod_env() else None,
51+
catchup=False,
52+
tags=[
53+
"cloud-ml-auto-solutions",
54+
"jobset",
55+
"uptime",
56+
"tpu-observability",
57+
"TPU",
58+
"v6e-16",
59+
],
60+
description=(
61+
"This DAG tests the jobset uptime metric by deploying a workload on a "
62+
"TPU v6e-16 node pool and verifying that "
63+
"the metric behaves as expected."
64+
),
65+
doc_md="""
66+
# JobSet Uptime Metric Test Using TPU v6e-16 Node Pool
67+
68+
### Description
69+
This DAG automates the process of creating a TPU v6e-16 node pool, launching
70+
a jobset, and monitoring the jobset uptime metric to ensure it behaves
71+
correctly. It also includes a negative test case to verify metric behavior
72+
over invalid time ranges. Finally, the DAG cleans up all created resources.
73+
74+
### Prerequisites
75+
This test requires an existing GKE cluster with TPU v6e-16 quota.
76+
77+
### Procedures
78+
1. **Provisioning**: Creates a TPU v6e-16 node pool with a specified reservation.
79+
2. **Deployment**: Applies a JobSet workload and waits for Pods to become active.
80+
3. **Metric Validation**: Polls the jobset uptime metric to confirm
81+
it behaves as expected.
82+
4. **Negative Testing**: Attempts to verify uptime against a current (future)
83+
timestamp to ensure the sensor correctly handles out-of-bounds queries.
84+
5. **Cleanup**: Deletes both the JobSet workload and the node pool to prevent
85+
resource leakage.
86+
""",
87+
) as dag:
88+
for machine in MachineConfigMap:
89+
config = machine.value
90+
91+
# Keyword arguments are generated dynamically at runtime (pylint does not
92+
# know this signature).
93+
with TaskGroup( # pylint: disable=unexpected-keyword-arg
94+
group_id=f"v{config.tpu_version.value}"
95+
):
96+
jobset_config = jobset.build_jobset_from_gcs_yaml(
97+
gcs_path=GCS_JOBSET_CONFIG_PATH,
98+
dag_name="jobset_uptime_validation",
99+
)
100+
101+
cluster_info = node_pool.build_node_pool_info_from_gcs_yaml.override(
102+
task_id="build_node_pool_info_from_gcs_yaml"
103+
)(
104+
gcs_path=GCS_CONFIG_PATH,
105+
dag_name="jobset_uptime_validation",
106+
is_prod=composer_env.is_prod_env(),
107+
machine_type=config.machine_version.value,
108+
tpu_topology=config.tpu_topology,
109+
)
110+
111+
create_node_pool = node_pool.create.override(task_id="create_node_pool")(
112+
node_pool=cluster_info,
113+
)
114+
115+
apply_time = jobset.run_workload.override(task_id="run_workload")(
116+
node_pool=cluster_info,
117+
jobset_config=jobset_config,
118+
workload_type=Workload.JAX_TPU_BENCHMARK,
119+
)
120+
121+
pod_names = jobset.list_pod_names.override(task_id="list_pod_names")(
122+
node_pool=cluster_info,
123+
jobset_config=jobset_config,
124+
)
125+
126+
wait_for_job_start = jobset.wait_for_jobset_started.override(
127+
task_id="wait_for_job_start"
128+
)(cluster_info, pod_name_list=pod_names, job_apply_time=apply_time)
129+
130+
wait_for_jobset_uptime_data = jobset.wait_for_jobset_uptime_data.override(
131+
task_id="wait_for_jobset_uptime_data"
132+
)(
133+
node_pool=cluster_info,
134+
jobset_config=jobset_config,
135+
jobset_apply_time=apply_time,
136+
)
137+
138+
clean_up_workload = jobset.end_workload.override(
139+
task_id="clean_up_workload", trigger_rule=TriggerRule.ALL_DONE
140+
)(
141+
node_pool=cluster_info,
142+
jobset_config=jobset_config,
143+
).as_teardown(
144+
setups=apply_time
145+
)
146+
147+
jobset_clear_time = get_current_time.override(
148+
task_id="get_current_time"
149+
)()
150+
151+
ensure_no_jobset_uptime_data = jobset.ensure_no_jobset_uptime_data.override(
152+
task_id="ensure_no_jobset_uptime_data"
153+
)(
154+
node_pool=cluster_info,
155+
jobset_config=jobset_config,
156+
jobset_clear_time=jobset_clear_time,
157+
# Wait 5 minutes to confirm no data has been detected.
158+
wait_time_seconds=300,
159+
)
160+
161+
cleanup_node_pool = node_pool.delete.override(
162+
task_id="cleanup_node_pool", trigger_rule=TriggerRule.ALL_DONE
163+
)(node_pool=cluster_info).as_teardown(
164+
setups=create_node_pool,
165+
)
166+
167+
chain(
168+
cluster_info,
169+
create_node_pool,
170+
apply_time,
171+
pod_names,
172+
wait_for_job_start,
173+
wait_for_jobset_uptime_data,
174+
clean_up_workload,
175+
jobset_clear_time,
176+
ensure_no_jobset_uptime_data,
177+
cleanup_node_pool,
178+
)

dags/tpu_observability/utils/jobset_util.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828

2929
from airflow.decorators import task
3030
from airflow.exceptions import AirflowFailException
31+
from google.cloud.monitoring_v3 import types
32+
import kubernetes
3133

3234
from dags.tpu_observability.utils import subprocess_util as subprocess
3335
from dags.tpu_observability.utils.gcp_util import query_time_series
@@ -746,3 +748,73 @@ def wait_for_all_pods_running(node_pool: node_pool_info, jobset_config: JobSet):
746748
)
747749
num_pods = jobset_config.replicas * jobset_config.parallelism
748750
return num_running == num_pods
751+
752+
753+
def query_uptime_metrics(
754+
node_pool: node_pool_info,
755+
jobset_name: str,
756+
start_time: datetime.datetime,
757+
end_time: datetime.datetime,
758+
):
759+
"""Queries the JobSet's uptime metric from Cloud Monitoring."""
760+
start_time = TimeUtil.from_datetime(start_time)
761+
end_time = TimeUtil.from_datetime(end_time)
762+
763+
filter_string = [
764+
'metric.type="kubernetes.io/jobset/uptime"',
765+
f'resource.labels.project_id = "{node_pool.project_id}"',
766+
f'resource.labels.cluster_name = "{node_pool.cluster_name}"',
767+
f'resource.labels.entity_name = "{jobset_name}"',
768+
]
769+
770+
return query_time_series(
771+
project_id=node_pool.project_id,
772+
filter_str=" AND ".join(filter_string),
773+
start_time=start_time,
774+
end_time=end_time,
775+
view=types.ListTimeSeriesRequest.TimeSeriesView.FULL,
776+
log_enable=True,
777+
)
778+
779+
780+
@task.sensor(poke_interval=30, timeout=3600, mode="reschedule")
781+
def wait_for_jobset_uptime_data(
782+
node_pool: node_pool_info,
783+
jobset_config: JobSet,
784+
jobset_apply_time: TimeUtil,
785+
):
786+
"""Verify uptime data exists after jobset application."""
787+
start_time = jobset_apply_time.to_datetime()
788+
end_time = datetime.datetime.now(datetime.timezone.utc)
789+
data = query_uptime_metrics(
790+
node_pool, jobset_config.jobset_name, start_time, end_time
791+
)
792+
793+
logging.info(f"Uptime data query result: {data}")
794+
if len(data) > 0:
795+
return True
796+
return False
797+
798+
799+
@task.sensor(poke_interval=30, timeout=360, mode="reschedule")
800+
def ensure_no_jobset_uptime_data(
801+
node_pool: node_pool_info,
802+
jobset_config: JobSet,
803+
jobset_clear_time: TimeUtil,
804+
wait_time_seconds: int,
805+
):
806+
"""Ensure no uptime data is recorded after jobset deletion."""
807+
start_time = jobset_clear_time.to_datetime()
808+
now = datetime.datetime.now(datetime.timezone.utc)
809+
data = query_uptime_metrics(
810+
node_pool, jobset_config.jobset_name, start_time, now
811+
)
812+
813+
logging.info(f"Uptime data query result: {data}")
814+
if len(data) > 0:
815+
raise AirflowFailException(f"Data detected: {data}")
816+
817+
if now - start_time >= datetime.timedelta(seconds=wait_time_seconds):
818+
logging.info("Stability period passed with no data detected.")
819+
return True
820+
return False

0 commit comments

Comments
 (0)