Skip to content

Commit 13e4f9b

Browse files
committed
test(kubeflow): update stale tests for uuid names, idempotent 409, rank-0/last log forwarding
The base kubeflow rewrite changed behavior the tests still asserted the old way: TrainJob names are now <base>-<uuid6>; a 409 cancels the stale job and recreates (idempotent) rather than raising; and fetch_logs writes every rank to <job_dir>/log-allranks_0.out while forwarding only rank-0 + the last rank to stdout. Set job_dir, patch status/time.sleep to avoid the retry-loop hang, and assert the all-ranks file + uuid-suffixed names. Signed-off-by: oliver könig <okoenig@nvidia.com>
1 parent 2870f12 commit 13e4f9b

1 file changed

Lines changed: 46 additions & 30 deletions

File tree

test/core/execution/test_kubeflow.py

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,8 @@ def test_launch_success(self, executor, mock_k8s_clients):
243243
mock_custom.create_namespaced_custom_object.return_value = {}
244244

245245
job_name, state = executor.launch("test-job", ["/bin/bash", "-c", "echo hi"])
246-
assert job_name == "test-job"
246+
# TrainJob names are <base>-<uuid6> (RFC-1123 safe, unique per launch)
247+
assert job_name.startswith("test-job-") and len(job_name) == len("test-job-") + 6
247248
assert state == KubeflowJobState.CREATED
248249
mock_custom.create_namespaced_custom_object.assert_called_once()
249250

@@ -272,12 +273,18 @@ def test_launch_wait_timeout(self, executor, mock_k8s_clients):
272273
with pytest.raises(RuntimeError, match="did not reach RUNNING"):
273274
executor.launch("test-job", ["echo"], wait=True, timeout=-1)
274275

275-
def test_launch_conflict(self, executor, mock_k8s_clients):
276+
def test_launch_conflict_recreates(self, executor, mock_k8s_clients):
276277
mock_custom, _ = mock_k8s_clients
277-
mock_custom.create_namespaced_custom_object.side_effect = ApiException(status=409)
278+
# A 409 means a stale TrainJob from a prior attempt lingers; launch cancels
279+
# it and recreates so the caller's retry makes progress (idempotent launch).
280+
mock_custom.create_namespaced_custom_object.side_effect = [ApiException(status=409), {}]
278281

279-
with pytest.raises(RuntimeError, match="already exists"):
280-
executor.launch("test-job", ["/bin/bash", "-c", "echo hi"])
282+
with patch.object(executor, "cancel") as mock_cancel:
283+
_, state = executor.launch("test-job", ["/bin/bash", "-c", "echo hi"])
284+
285+
mock_cancel.assert_called_once()
286+
assert mock_custom.create_namespaced_custom_object.call_count == 2
287+
assert state == KubeflowJobState.CREATED
281288

282289
def test_status_running(self, executor, mock_k8s_clients):
283290
mock_custom, _ = mock_k8s_clients
@@ -346,34 +353,38 @@ def test_cancel_with_wait_timeout(self, executor, mock_k8s_clients):
346353

347354
# ── Logs ─────────────────────────────────────────────────────────────────────
348355

349-
def test_fetch_logs_no_follow(self, executor, mock_k8s_clients):
356+
def test_fetch_logs_no_follow(self, executor, mock_k8s_clients, tmp_path):
357+
executor.job_dir = str(tmp_path)
350358
with patch("subprocess.run") as mock_run:
351359
mock_run.return_value = MagicMock(stdout="line1\nline2\n")
352-
lines = list(executor.fetch_logs("my-job", stream=False, lines=50))
353-
354-
mock_run.assert_called_once()
355-
called_cmd = mock_run.call_args[0][0]
356-
assert "--tail" in called_cmd
357-
assert "50" in called_cmd
358-
label_arg = " ".join(called_cmd)
359-
assert "jobset.sigs.k8s.io/jobset-name=my-job" in label_arg
360-
assert "-f" not in called_cmd
361-
assert lines == ["line1", "line2"]
362-
363-
def test_fetch_logs_follow(self, executor, mock_k8s_clients):
360+
list(executor.fetch_logs("my-job", stream=False, lines=50))
361+
362+
# the kubectl logs call (distinct from the pod-index lookup) targets the
363+
# jobset and does not follow.
364+
log_cmd = next(c.args[0] for c in mock_run.call_args_list if "logs" in c.args[0])
365+
assert "jobset.sigs.k8s.io/jobset-name=my-job" in " ".join(log_cmd)
366+
assert "--tail" in log_cmd and "-f" not in log_cmd
367+
# every rank is persisted to the all-ranks log
368+
assert (tmp_path / "log-allranks_0.out").read_text() == "line1\nline2\n"
369+
370+
def test_fetch_logs_follow(self, executor, mock_k8s_clients, tmp_path):
364371
import io
365372

373+
executor.job_dir = str(tmp_path)
366374
mock_proc = MagicMock()
367375
mock_proc.stdout = io.StringIO("line1\nline2\n")
368376
mock_proc.poll.return_value = None # still running; loop exits when readline() hits EOF
369377

370-
with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
371-
lines = list(executor.fetch_logs("my-job", stream=True, lines=100))
378+
with (
379+
patch("subprocess.Popen", return_value=mock_proc) as mock_popen,
380+
patch("time.sleep"),
381+
patch.object(executor, "status", return_value=KubeflowJobState.SUCCEEDED),
382+
):
383+
list(executor.fetch_logs("my-job", stream=True, lines=100))
372384

373-
mock_popen.assert_called_once()
374-
called_cmd = mock_popen.call_args[0][0]
375-
assert "-f" in called_cmd
376-
assert lines == ["line1\n", "line2\n"]
385+
assert "-f" in mock_popen.call_args.args[0]
386+
# every rank is persisted to the all-ranks log
387+
assert (tmp_path / "log-allranks_0.out").read_text() == "line1\nline2\n"
377388

378389
def test_status_unknown_when_empty(self, mock_k8s_clients):
379390
mock_custom, _ = mock_k8s_clients
@@ -473,10 +484,12 @@ def test_pull_results_syncs_from_pvc(self, workdir_executor, mock_k8s_clients):
473484
mock_core.create_namespaced_pod.assert_called_once()
474485
assert mock_check_call.call_count == 1 # kubectl cp only (no mkdir for pull)
475486
cp_args = mock_check_call.call_args[0][0]
476-
# kubectl cp <ns>/<pod>:<remote> <local>
487+
# kubectl cp <ns>/<pod>:<remote> <local>; the data-mover pod is named off the
488+
# <base>-<uuid6> TrainJob name.
477489
assert "kubectl" in cp_args
478490
assert "cp" in cp_args
479-
assert f"test-job-data-mover:{workdir_executor.code_dir}" in cp_args
491+
dm = next(a for a in cp_args if "-data-mover:" in a)
492+
assert dm.startswith("test-job-") and dm.endswith(f"-data-mover:{workdir_executor.code_dir}")
480493

481494
def test_pull_results_noop_without_workdir_pvc(self, mock_k8s_clients):
482495
e = KubeflowExecutor(image="test:latest")
@@ -590,11 +603,12 @@ def test_launch_wait_exits_on_failed(self, executor, mock_k8s_clients):
590603

591604
# ── fetch_logs streaming: retry until terminal state ─────────────────────
592605

593-
def test_fetch_logs_stream_retries_until_terminal_state(self, executor, mock_k8s_clients):
606+
def test_fetch_logs_stream_retries_until_terminal_state(self, executor, mock_k8s_clients, tmp_path):
594607
"""First Popen yields nothing and job is RUNNING; second yields a line and job is
595608
SUCCEEDED — loop exits on terminal status."""
596609
import io
597610

611+
executor.job_dir = str(tmp_path)
598612
empty_proc = MagicMock()
599613
empty_proc.stdout = io.StringIO("")
600614
empty_proc.poll.return_value = None
@@ -614,13 +628,15 @@ def test_fetch_logs_stream_retries_until_terminal_state(self, executor, mock_k8s
614628
side_effect=[KubeflowJobState.RUNNING, KubeflowJobState.SUCCEEDED],
615629
),
616630
):
617-
lines = list(executor.fetch_logs("my-job", stream=True))
631+
list(executor.fetch_logs("my-job", stream=True))
618632

619-
assert "some output\n" in lines
633+
# forwarded stdout is rank-0/last only, but every rank lands in the all-ranks log
634+
assert "some output" in (tmp_path / "log-allranks_0.out").read_text()
620635

621-
def test_fetch_logs_stream_handles_exception(self, executor, mock_k8s_clients):
636+
def test_fetch_logs_stream_handles_exception(self, executor, mock_k8s_clients, tmp_path):
622637
"""Exception inside the readline loop is caught; loop exits when job is terminal."""
623638

639+
executor.job_dir = str(tmp_path)
624640
mock_proc = MagicMock()
625641
mock_proc.stdout.readline.side_effect = OSError("read error")
626642
mock_proc.poll.return_value = None

0 commit comments

Comments
 (0)