Skip to content

Commit ff9e826

Browse files
fix(trainer): deduplicate restarted TrainJob pods
1 parent b63a291 commit ff9e826

2 files changed

Lines changed: 84 additions & 6 deletions

File tree

kubeflow/trainer/backends/kubernetes/backend.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -698,14 +698,32 @@ def __get_trainjob_from_cr(
698698
if not pod_list:
699699
return trainjob
700700

701-
for pod in pod_list.items:
701+
sorted_pods = sorted(
702+
pod_list.items,
703+
key=lambda pod: (
704+
pod.metadata is not None and pod.metadata.creation_timestamp is not None,
705+
pod.metadata.creation_timestamp if pod.metadata else None,
706+
),
707+
reverse=True,
708+
)
709+
seen_step_keys: set[str] = set()
710+
for pod in sorted_pods:
702711
# Pod must have labels to detect the TrainJob step.
703712
# Every Pod always has a single TrainJob step.
704713
if not (pod.metadata and pod.metadata.name and pod.metadata.labels and pod.spec):
705714
raise Exception(f"TrainJob Pod is invalid: {pod}")
706715

716+
role = pod.metadata.labels[constants.JOBSET_RJOB_NAME_LABEL]
717+
step_key = role
718+
if role in {constants.LAUNCHER, constants.NODE}:
719+
step_key = f"{role}-{pod.metadata.labels[constants.JOB_INDEX_LABEL]}"
720+
721+
if step_key in seen_step_keys:
722+
continue
723+
seen_step_keys.add(step_key)
724+
707725
# Get the Initializer step.
708-
if pod.metadata.labels[constants.JOBSET_RJOB_NAME_LABEL] in {
726+
if role in {
709727
constants.DATASET_INITIALIZER,
710728
constants.MODEL_INITIALIZER,
711729
}:
@@ -717,7 +735,7 @@ def __get_trainjob_from_cr(
717735
)
718736
)
719737
# Get the Node step.
720-
elif pod.metadata.labels[constants.JOBSET_RJOB_NAME_LABEL] in {
738+
elif role in {
721739
constants.LAUNCHER,
722740
constants.NODE,
723741
}:
@@ -727,7 +745,7 @@ def __get_trainjob_from_cr(
727745
pod.spec,
728746
pod.status,
729747
trainjob.runtime,
730-
pod.metadata.labels[constants.JOBSET_RJOB_NAME_LABEL],
748+
role,
731749
int(pod.metadata.labels[constants.JOB_INDEX_LABEL]),
732750
)
733751
)

kubeflow/trainer/backends/kubernetes/backend_test.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
It tests KubernetesBackend's behavior across job listing, resource creation etc.
1919
"""
2020

21+
import copy
2122
from dataclasses import asdict
2223
import datetime
2324
import logging
@@ -71,6 +72,7 @@
7172
FAIL_LOGS = "fail_logs"
7273
LIST_RUNTIMES = "list_runtimes"
7374
BASIC_TRAIN_JOB_NAME = "basic-job"
75+
JOB_WITH_POD_RESTARTS = "job-with-pod-restarts"
7476
TRAIN_JOBS = "trainjobs"
7577
TRAIN_JOB_WITH_BUILT_IN_TRAINER = "train-job-with-built-in-trainer"
7678
TRAIN_JOB_WITH_CUSTOM_TRAINER = "train-job-with-custom-trainer"
@@ -128,8 +130,13 @@ def conditional_error_handler(*args, **kwargs):
128130

129131

130132
def list_namespaced_pod_response(*args, **kwargs):
131-
"""Return mock pod list response."""
132-
pod_list = get_mock_pod_list()
133+
"""Return a mock pod list response for the requested TrainJob."""
134+
label_selector = kwargs.get("label_selector", "")
135+
pod_list = (
136+
get_mock_pod_list_with_restarts()
137+
if JOB_WITH_POD_RESTARTS in label_selector
138+
else get_mock_pod_list()
139+
)
133140
mock_thread = Mock()
134141
mock_thread.get.return_value = pod_list
135142
return mock_thread
@@ -210,6 +217,30 @@ def get_mock_pod_list():
210217
)
211218

212219

220+
def get_mock_pod_list_with_restarts() -> models.IoK8sApiCoreV1PodList:
221+
"""Create Pods where newer replacements share the same TrainJob component roles."""
222+
old_timestamp = datetime.datetime(2025, 6, 1, 10, 0, 0)
223+
new_timestamp = datetime.datetime(2025, 6, 1, 11, 0, 0)
224+
old_pods = get_mock_pod_list().items
225+
node_1_pod = copy.deepcopy(old_pods[-1])
226+
node_1_pod.metadata.name = "node-1-pod"
227+
node_1_pod.metadata.labels[constants.JOB_INDEX_LABEL] = "1"
228+
old_pods.append(node_1_pod)
229+
restarted_pods = []
230+
231+
for old_pod in old_pods:
232+
old_pod.metadata.creation_timestamp = old_timestamp
233+
old_pod.metadata.labels[constants.JOBSET_NAME_LABEL] = JOB_WITH_POD_RESTARTS
234+
235+
restarted_pod = copy.deepcopy(old_pod)
236+
restarted_pod.metadata.name = f"{old_pod.metadata.name}-restarted"
237+
restarted_pod.metadata.creation_timestamp = new_timestamp
238+
restarted_pod.status.phase = constants.POD_PENDING
239+
restarted_pods.append(restarted_pod)
240+
241+
return models.IoK8sApiCoreV1PodList(items=[*old_pods, *restarted_pods])
242+
243+
213244
def get_resource_requirements() -> models.IoK8sApiCoreV1ResourceRequirements:
214245
"""Create a mock ResourceRequirements object for testing."""
215246
return models.IoK8sApiCoreV1ResourceRequirements(
@@ -726,6 +757,7 @@ def get_train_job_data_type(
726757
num_nodes=2,
727758
image="example.com/test-runtime",
728759
)
760+
729761
trainer.set_command(constants.TORCH_COMMAND)
730762
return types.TrainJob(
731763
name=train_job_name,
@@ -763,6 +795,25 @@ def get_train_job_data_type(
763795
)
764796

765797

798+
def get_train_job_with_restarted_pods_data_type(
799+
runtime_name: str,
800+
train_job_name: str,
801+
) -> types.TrainJob:
802+
"""Create the expected TrainJob after newer replacement Pods are selected."""
803+
train_job = get_train_job_data_type(runtime_name, train_job_name)
804+
805+
for step in train_job.steps:
806+
step.pod_name = f"{step.pod_name}-restarted"
807+
step.status = constants.POD_PENDING
808+
809+
node_1_step = copy.deepcopy(train_job.steps[-1])
810+
node_1_step.name = "node-1"
811+
node_1_step.pod_name = "node-1-pod-restarted"
812+
train_job.steps.append(node_1_step)
813+
814+
return train_job
815+
816+
766817
def _run_verify_backend_with_core_api(core_api: Mock) -> tuple[list[str], int]:
767818
"""Helper to run verify_backend and capture warning logs."""
768819

@@ -1447,6 +1498,15 @@ def test_train(kubernetes_backend, test_case):
14471498
train_job_name=BASIC_TRAIN_JOB_NAME,
14481499
),
14491500
),
1501+
TestCase(
1502+
name="returns only the newest Pod for each TrainJob component",
1503+
expected_status=SUCCESS,
1504+
config={"name": JOB_WITH_POD_RESTARTS},
1505+
expected_output=get_train_job_with_restarted_pods_data_type(
1506+
runtime_name=TORCH_RUNTIME,
1507+
train_job_name=JOB_WITH_POD_RESTARTS,
1508+
),
1509+
),
14501510
TestCase(
14511511
name="timeout error when getting job",
14521512
expected_status=FAILED,

0 commit comments

Comments
 (0)