fix(trainer): filter duplicate Pods in get_job() API - #160
Conversation
978b209 to
faf96a5
Compare
| pod_groups[key] = [] | ||
| pod_groups[key].append(pod) | ||
|
|
||
| # Select the most recently created Pod from each group. |
There was a problem hiding this comment.
I think to make it more robust we could select the pod based on the status as well as the timestamp something like this Fiona-Waters@b48277f
wdyt?
It will return a pod that actually reflects the true state of each TrainJob component, rather than the newest pod.
There was a problem hiding this comment.
Absolutely agree! I've actually implemented exactly that approach from your commit b48277f. The current implementation now:
Prioritizes by status first: Running (4) > Succeeded (3) > Failed (2) > Pending (1) > Unknown (0)
Uses timestamp as tiebreaker: Among pods with the same status, selects the most recent one
This ensures we return a pod that reflects the true state of the TrainJob component (preferring Running/Succeeded pods over Failed ones), rather than blindly picking the newest pod regardless of its state.
For example, if we have:
Pod A: Failed (created at 11:00)
Pod B: Running (created at 10:00)
The old logic would return Pod A (newest), but the new logic correctly returns Pod B (Running status is higher priority).
|
|
||
| # Sort by creation timestamp (most recent first) | ||
| candidate_pods.sort( | ||
| key=lambda p: p.metadata.creation_timestamp or datetime.datetime.min, reverse=True |
There was a problem hiding this comment.
This could cause an issue in newer python versions - do we want it to be timezone naive or set to utc?
| key=lambda p: p.metadata.creation_timestamp or datetime.datetime.min, reverse=True | |
| key=lambda p: (p.metadata.creation_timestamp or datetime.datetime.min.replace(tzinfo=timezone.utc)) | |
There was a problem hiding this comment.
Good catch! I've applied your suggestion to use datetime.datetime.min.replace(tzinfo=timezone.utc) instead of the timezone-naive datetime.datetime.min.
Thanks for the review!
There was a problem hiding this comment.
Great. Don't forget to import timezone too
from datetime import timezone
There was a problem hiding this comment.
Done! Added the timezone import. Thanks for catching that! 👍
|
@HKanoje left one more comment but otherwise it looks good to me. |
|
/lgtm Thanks @HKanoje @Fiona-Waters! /assign @kubeflow/kubeflow-sdk-team |
|
/ok-to-test |
Pull Request Test Coverage Report for Build 19878220233Details
💛 - Coveralls |
kramaranya
left a comment
There was a problem hiding this comment.
Thank you @HKanoje!
I've left a few comments
| Priority order: | ||
| 1. Running or Succeeded Pods (prefer most recent) | ||
| 2. Failed Pods (prefer most recent) | ||
| 3. Pending Pods (prefer most recent) | ||
| 4. Unknown Pods (prefer most recent) | ||
| """ | ||
| if not pods: | ||
| return None | ||
|
|
||
| # Pod status priority (higher number = higher priority) | ||
| status_priority = { | ||
| constants.POD_RUNNING: 4, # Highest priority | ||
| constants.POD_SUCCEEDED: 3, # Second highest | ||
| constants.POD_FAILED: 2, # Third priority | ||
| constants.POD_PENDING: 1, # Low priority | ||
| constants.POD_UNKNOWN: 0, # Lowest priority |
There was a problem hiding this comment.
Do running and succeeded statuses have the same priority? The docstring doesn't match the actual priorities
| constants.POD_RUNNING: 4, # Highest priority | ||
| constants.POD_SUCCEEDED: 3, # Second highest |
There was a problem hiding this comment.
Shall we consider those two to be equal priority? Since both running and succeeded are healthy pods, I think we should care about the most recent one. wdyt @HKanoje @andreyvelich @astefanutti
| trainjob.runtime, | ||
| pod.metadata.labels[constants.JOBSET_RJOB_NAME_LABEL], | ||
| int(pod.metadata.labels[constants.JOB_INDEX_LABEL]), | ||
| int(pod.metadata.labels.get(constants.JOB_INDEX_LABEL, "0")), |
There was a problem hiding this comment.
Don't those pods always have this label?
|
|
||
| self.namespace = cfg.namespace | ||
|
|
||
| def _select_best_pod_for_role( |
There was a problem hiding this comment.
Can you move this after public methods?
There was a problem hiding this comment.
@kramaranya Thank you for the thorough review! I've addressed all your comments:
Changes Made:
1. Docstring & Priority Design
- Updated docstring to explicitly state: "Running or Succeeded Pods (equal priority, prefer most recent)"
- Changed
POD_SUCCEEDEDpriority from3to4(now equal toPOD_RUNNING) - Added clarification: "Both Running and Succeeded are considered healthy states with equal priority"
2. JOB_INDEX_LABEL
- Removed
.get(constants.JOB_INDEX_LABEL, "0")in both locations - Now using direct access:
pod.metadata.labels[constants.JOB_INDEX_LABEL]
3. Method Placement
- Moved
_select_best_pod_for_roleafter all public methods (afterdelete_job) - Now positioned before
_read_pod_logs, following project convention
Testing:
-
make verifypasses - All 36 Kubernetes backend tests pass
- All 163 Python tests pass
- Give Running and Succeeded pods equal priority (both are healthy states) - Update docstring to clearly explain equal priority and timestamp tiebreaker - Remove JOB_INDEX_LABEL .get() default, use direct access - Move _select_best_pod_for_role method after public methods per convention Addresses review comments from @kramaranya on PR kubeflow#160
|
New changes are detected. LGTM label has been removed. |
- Give Running and Succeeded pods equal priority (both are healthy states) - Update docstring to clearly explain equal priority and timestamp tiebreaker - Remove JOB_INDEX_LABEL .get() default, use direct access - Move _select_best_pod_for_role method after public methods per convention Addresses review comments from @kramaranya on PR kubeflow#160 Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
a22ed47 to
3ec8093
Compare
|
@astefanutti I have made new changes please review and then it can be tested. |
|
@szaher Please Review whenever you get chance! Thanks! |
| print("test execution complete") | ||
|
|
||
|
|
||
| def test_get_job_with_pod_restarts(kubernetes_backend): |
There was a problem hiding this comment.
Please add another test case to test_get_job() API as described here:
https://github.com/kubeflow/sdk/blob/main/AGENTS.md#3-testing-requirements
| candidate_pods.sort( | ||
| key=lambda p: ( | ||
| p.metadata.creation_timestamp or datetime.datetime.min.replace(tzinfo=timezone.utc) | ||
| ), | ||
| reverse=True, | ||
| ) |
There was a problem hiding this comment.
Can you just simple sort all pods by creation_timestamp after calling self.core_api.list_namespaced_pod() ?
After that, just add items to trainjob.steps.append() list, and if item already exists (e.g. node-1, or model-initializer, just ignore them).
There was a problem hiding this comment.
@andreyvelich Thanks for the feedback! I've simplified the implementation as you suggested:
Simplified pod filtering: Removed the _select_best_pod_for_role() helper method with status-priority logic. Now pods are sorted by creation_timestamp after list_namespaced_pod(), and duplicates are skipped using a seen_roles set — first occurrence (newest) wins.
Added test case to test_get_job(): Added a new parametrized test case "filters duplicate pods and returns only newest per role" and removed the standalone test_get_job_with_pod_restarts() function.
|
Thanks @HKanoje for the detailed implementation and the comprehensive test coverage, the I noticed the current implementation introduces However, in a recent comment, @andreyvelich suggested a simpler approach: sorting Pods by That approach might simplify the implementation and avoid maintaining the additional status-priority mapping and helper method. One question I had while reading the code: if a Pod is recreated quickly and the newest Pod is still in Curious whether the timestamp-based approach is preferred mainly for simplicity, or if there are cases where Pod status might still be useful in the selection logic. |
- Give Running and Succeeded pods equal priority (both are healthy states) - Update docstring to clearly explain equal priority and timestamp tiebreaker - Remove JOB_INDEX_LABEL .get() default, use direct access - Move _select_best_pod_for_role method after public methods per convention Addresses review comments from @kramaranya on PR kubeflow#160 Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
3ec8093 to
ab7d286
Compare
There was a problem hiding this comment.
Pull request overview
This PR updates the Kubernetes Trainer backend’s get_job() path to de-duplicate restarted Kubernetes Pods so users see a single, authoritative component per TrainJob step.
Changes:
- Add Pod-phase constants (e.g.,
POD_RUNNING,POD_FAILED) for consistent phase handling. - Update Kubernetes backend TrainJob parsing to group Pods by component role/index and select a single Pod per component.
- Add a unit test that simulates Pod restarts and asserts that only the newest Pod per component is returned.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
kubeflow/trainer/constants/constants.py |
Adds explicit Pod phase constants used by backend pod-selection logic. |
kubeflow/trainer/backends/kubernetes/backend.py |
Introduces pod grouping/selection logic to filter duplicate Pods returned by get_job(). |
kubeflow/trainer/backends/kubernetes/backend_test.py |
Adds coverage for duplicate-Pod/restart scenarios in get_job(). |
|
|
||
| def _select_best_pod_for_role( | ||
| self, pods: list[models.IoK8sApiCoreV1Pod] | ||
| ) -> Optional[models.IoK8sApiCoreV1Pod]: |
There was a problem hiding this comment.
Optional is used in the return type but isn’t imported (and this file doesn’t use from __future__ import annotations), which will raise a NameError when importing the module; import Optional from typing or switch the annotation to models.IoK8sApiCoreV1Pod | None to match the rest of the file’s union style.
| ) -> Optional[models.IoK8sApiCoreV1Pod]: | |
| ) -> models.IoK8sApiCoreV1Pod | None: |
| def _select_best_pod_for_role( | ||
| self, pods: list[models.IoK8sApiCoreV1Pod] | ||
| ) -> Optional[models.IoK8sApiCoreV1Pod]: | ||
| """ | ||
| Select the best Pod for a role based on status priority and creation timestamp. | ||
|
|
||
| Priority order (higher priority = preferred): | ||
| 1. Running or Succeeded Pods (equal priority, prefer most recent) | ||
| 2. Failed Pods (prefer most recent) | ||
| 3. Pending Pods (prefer most recent) | ||
| 4. Unknown Pods (prefer most recent) | ||
|
|
||
| Both Running and Succeeded are considered healthy states with equal priority. | ||
| When multiple pods share the same priority, the most recently created pod is selected. | ||
| """ | ||
| if not pods: | ||
| return None | ||
|
|
||
| # Pod status priority (higher number = higher priority) | ||
| # Running and Succeeded have equal priority as both are healthy states | ||
| status_priority = { | ||
| constants.POD_RUNNING: 4, # Highest priority (healthy) | ||
| constants.POD_SUCCEEDED: 4, # Highest priority (healthy) | ||
| constants.POD_FAILED: 2, # Lower priority | ||
| constants.POD_PENDING: 1, # Low priority | ||
| constants.POD_UNKNOWN: 0, # Lowest priority | ||
| } | ||
|
|
||
| # Group Pods by status priority | ||
| pods_by_status = {} | ||
| for pod in pods: | ||
| status = pod.status.phase if pod.status else constants.POD_UNKNOWN | ||
| priority = status_priority.get(status, 0) | ||
|
|
||
| if priority not in pods_by_status: | ||
| pods_by_status[priority] = [] | ||
| pods_by_status[priority].append(pod) | ||
|
|
||
| # Find the highest priority status that has Pods | ||
| highest_priority = max(pods_by_status.keys()) if pods_by_status else 0 | ||
| candidate_pods = pods_by_status[highest_priority] | ||
|
|
||
| # Among Pods with the same priority, select the most recent one | ||
| if len(candidate_pods) == 1: | ||
| return candidate_pods[0] | ||
|
|
||
| # Sort by creation timestamp (most recent first) | ||
| candidate_pods.sort( | ||
| key=lambda p: ( | ||
| p.metadata.creation_timestamp or datetime.datetime.min.replace(tzinfo=timezone.utc) | ||
| ), | ||
| reverse=True, | ||
| ) | ||
|
|
||
| return candidate_pods[0] | ||
|
|
There was a problem hiding this comment.
_select_best_pod_for_role is defined twice in this class, so the later definition silently overrides the earlier one; please remove one copy and keep a single implementation to avoid dead code and inconsistent behavior/documentation.
| def _select_best_pod_for_role( | |
| self, pods: list[models.IoK8sApiCoreV1Pod] | |
| ) -> Optional[models.IoK8sApiCoreV1Pod]: | |
| """ | |
| Select the best Pod for a role based on status priority and creation timestamp. | |
| Priority order (higher priority = preferred): | |
| 1. Running or Succeeded Pods (equal priority, prefer most recent) | |
| 2. Failed Pods (prefer most recent) | |
| 3. Pending Pods (prefer most recent) | |
| 4. Unknown Pods (prefer most recent) | |
| Both Running and Succeeded are considered healthy states with equal priority. | |
| When multiple pods share the same priority, the most recently created pod is selected. | |
| """ | |
| if not pods: | |
| return None | |
| # Pod status priority (higher number = higher priority) | |
| # Running and Succeeded have equal priority as both are healthy states | |
| status_priority = { | |
| constants.POD_RUNNING: 4, # Highest priority (healthy) | |
| constants.POD_SUCCEEDED: 4, # Highest priority (healthy) | |
| constants.POD_FAILED: 2, # Lower priority | |
| constants.POD_PENDING: 1, # Low priority | |
| constants.POD_UNKNOWN: 0, # Lowest priority | |
| } | |
| # Group Pods by status priority | |
| pods_by_status = {} | |
| for pod in pods: | |
| status = pod.status.phase if pod.status else constants.POD_UNKNOWN | |
| priority = status_priority.get(status, 0) | |
| if priority not in pods_by_status: | |
| pods_by_status[priority] = [] | |
| pods_by_status[priority].append(pod) | |
| # Find the highest priority status that has Pods | |
| highest_priority = max(pods_by_status.keys()) if pods_by_status else 0 | |
| candidate_pods = pods_by_status[highest_priority] | |
| # Among Pods with the same priority, select the most recent one | |
| if len(candidate_pods) == 1: | |
| return candidate_pods[0] | |
| # Sort by creation timestamp (most recent first) | |
| candidate_pods.sort( | |
| key=lambda p: ( | |
| p.metadata.creation_timestamp or datetime.datetime.min.replace(tzinfo=timezone.utc) | |
| ), | |
| reverse=True, | |
| ) | |
| return candidate_pods[0] |
| Priority order (higher priority = preferred): | ||
| 1. Running or Succeeded Pods (equal priority, prefer most recent) | ||
| 2. Failed Pods (prefer most recent) | ||
| 3. Pending Pods (prefer most recent) | ||
| 4. Unknown Pods (prefer most recent) |
There was a problem hiding this comment.
The “status priority” selection can return an older Pod instead of the most recently created one (e.g., old Failed pod vs newly recreated Pending pod), which contradicts the PR’s goal of showing the latest pod per component; prefer selecting by latest creation_timestamp first (and only use phase as a tie-breaker if needed).
| # The pending phase of the Pod. | ||
| POD_PENDING = "Pending" | ||
|
|
There was a problem hiding this comment.
POD_PENDING is now defined here but it’s also defined again later in the same module, which creates an unnecessary duplicate and can confuse readers; keep a single POD_PENDING definition and remove the other copy.
| # The pending phase of the Pod. | |
| POD_PENDING = "Pending" |
00a24f9 to
72f47b5
Compare
…ubeflow#485) Cover all public methods of the optimizer's KubernetesBackend with parametrized unit tests mirroring the trainer backend test structure. Tests added for: optimize, get_job, list_jobs, get_job_logs, get_best_results, wait_for_job_status, delete_job, get_job_events. Each method tested with success paths and error scenarios (TimeoutError, RuntimeError, ValueError) using mock K8s APIs. Address review feedback: - Add payload verification for create_namespaced_custom_object - Add test_get_job_status_conditions for all status-mapping branches - Add get_job_logs branch coverage (trial_name, follow, empty, pending pod) - Restore polling_interval validation cases (zero, equal-to-timeout, negative) - Rework wait_for_job_status with CR-based mock and callback test - Fix docstrings, move TypeVar near imports, add positional-arg comments Signed-off-by: Slowlybomb <hslyusar@redhat.com> Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
Implement support for NPU resource labels in resource limit validation, resolving the existing TODO to support additional accelerator types. Signed-off-by: Sujal Shah <sujalshah28092004@gmail.com> Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
…eflow#495) Replaces the Trivy-based workflows removed in kubeflow#427 with two new tools: - validate-lockfile.yaml: PR check using uv audit to diff-compare vulnerabilities between PR and base branch. Non-blocking (informational comments only). Note: uv audit is experimental; if its text output format changes, the fallback is switching to osv-scanner JSON output (both query the same OSV.dev database). - osv-scanner.yaml: Nightly scan using OSV-Scanner CLI (v2.3.8) with SARIF upload to the GitHub Security tab and auto-fix PRs. Integrates with existing .github/scripts/ utilities (update_overrides.py, compare_versions.py, extract_version.py) and the cleanup-overrides workflow for the full fix lifecycle. - osv-scanner.toml: Minimal config for suppressing false positives. Closes kubeflow#478 Signed-off-by: Fiona-Waters <fiwaters6@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
When Kubernetes recreates Pods due to restart policies, multiple Pods with the same role can exist simultaneously. This causes get_job() to return duplicate TrainJob components with different statuses, creating confusion for users. This change groups Pods by their component role and selects only the most recently created Pod for each component based on creation_timestamp. This ensures users see the current state of their TrainJob after any Pod restarts. Changes: - Group Pods by role identifier (initializer name or node+index) - Select most recent Pod from each group using creation_timestamp - Add comprehensive test for Pod restart scenarios Fixes kubeflow#25 Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
…e safety Apply code quality improvements based on review feedback: - Use status-based priority for pod selection (Running > Succeeded > Failed > Pending > Unknown) - Add datetime.min fallback for safer timestamp sorting (prevents TypeError) - Add precise type hints to internal dicts for better type checking - Use consistent .get() access for JOB_INDEX_LABEL with default fallback - Add pod phase constants (POD_RUNNING, POD_FAILED, POD_PENDING, POD_UNKNOWN) These changes improve robustness, type safety, and maintainability while maintaining the same behavior of selecting the best pod for each role. Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
Use datetime.datetime.min.replace(tzinfo=timezone.utc) instead of datetime.datetime.min to prevent TypeError when comparing timezone-aware and timezone-naive datetimes in Python 3.9+. The Kubernetes API returns creation_timestamp as timezone-aware datetime objects in UTC, so the fallback should also be timezone-aware for safe comparison. Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
Import timezone from datetime module to use timezone.utc directly instead of datetime.timezone.utc for better readability. Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
- Give Running and Succeeded pods equal priority (both are healthy states) - Update docstring to clearly explain equal priority and timestamp tiebreaker - Remove JOB_INDEX_LABEL .get() default, use direct access - Move _select_best_pod_for_role method after public methods per convention Addresses review comments from @kramaranya on PR kubeflow#160 Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
- Remove _select_best_pod_for_role() helper with status-priority logic - Sort pods by creation_timestamp and use seen set to skip duplicates - Add test case to parametrized test_get_job() per AGENTS.md pattern - Remove standalone test_get_job_with_pod_restarts() function Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
…ubernetes client v35+ - Replace watch.Watch().stream() with response.stream() for pod log streaming - Pass _preload_content=False to read_namespaced_pod_log when following logs - Remove unused watch import that caused ApiTypeError in newer kubernetes versions - Update mock_read_namespaced_pod_log to support streaming responses - Add parameterized test case for get_job_logs with follow=True Fixes the RuntimeError in E2E tests: 'Got an unexpected keyword argument watch to method read_namespaced_pod_log' which occurs with kubernetes client >= 35.0.0 Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
Address reviewer feedback from @andreyvelich: - Sort pods by creation_timestamp descending (newest first) - Use set-based deduplication to track seen (role, index) combinations - First occurrence (newest pod) wins, duplicates skipped - Creates unique keys: 'role' for initializers, 'role-index' for trainers - Improve error handling with .get() for label lookups and explicit RuntimeError messages - Remove unused Pod phase constants (POD_RUNNING, POD_FAILED, POD_UNKNOWN) - Remove unused timezone import This simplifies the pod filtering logic while maintaining correctness for duplicate pod detection during Kubernetes restarts. Closes: kubeflow#160 Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
Signed-off-by: HKanoje <hrithik.kanoje@gmail.com>
2732c83 to
5ab1caa
Compare
|
@Shekharrajak @andreyvelich Just wanted to let you know that after the reset branch to commit 57ce1c8 Spark operator test are running properly now |
|
Sorry for the late reply @HKanoje, could you rebase it once again, so we can merge it? |
Currently the pr needs rebase and fixing of the tests failure, otherwise lgtm |
|
@andreyvelich The pr has yet not been completed so can I complete it and raise a pr? |
|
Sure, @reckless-sherixx feel free to finalize it! |
|
Fixed by: #632 |
|
@andreyvelich: Closed this PR. DetailsIn response to this:
Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes/test-infra repository. |
Description
Problem:
The
get_job()API currently returns multiple Pods for the same TrainJob component(e.g.,
dataset-initializer,trainer-node-0) when Kubernetes recreates Pods based onBatch/Job restart policies.
This causes users to see duplicate components with conflicting statuses
—for example, one Pod may show "Failed" while another shows "Running"—leading to
confusion about the actual state of the training job.
Solution:
This PR improves the
get_job()API to filter duplicate Pods and display only the most recently created Pod for each TrainJob component.Key Improvements
1. Groups Pods by role
JOBSET_RJOB_NAME_LABELfor initializer PodsJOBSET_RJOB_NAME_LABEL+JOB_INDEX_LABELfor training-node Pods(ensures correct grouping across multi-node trainer replicas)
2. Selects the most recent Pod
creation_timestamp(e.g., old Pods in
Failedstate)3. Maintains backward compatibility
This ensures users see clean, de-duplicated component statuses that accurately represent the current state of their training job.
Example Impact:
Before this fix:
After this fix:
Changes Made
Modified Files
backend.py__get_trainjob_from_cr()method to implement Pod de-duplication and filtering logicJOBSET_RJOB_NAME_LABELJOBSET_RJOB_NAME_LABEL+JOB_INDEX_LABELcreation_timestampbackend_test.pytest_get_job_with_pod_restarts()Testing
All tests passing:
make verify— PASSED (lint + format checks)test_get_job_with_pod_restarts— PASSED (new test for Pod restart filtering)test_get_job— PASSED (existing behavior remains compatible)Test Coverage
creation_timestampvaluesChecklist
make verifypasses)make test-python)Related Issues
Fixes #25