Skip to content

Commit e9767e0

Browse files
samhita-allaclaude
andcommitted
feat(ray,kfoperators,clustered): name the child pods of a distributed task
The Ray, Kubeflow and clustered plugins track a CRD, so they implement ChildPodDiscovery to tell the framework where the pods that actually ran the task are. All three build the same selector: the attempt's own labels, which the pod templates they hand the operator already carry, narrowed by one label the operator applies to the pods themselves. For Kubeflow that is training.kubeflow.org/job-name, which is the CR's own name and so is known from the moment the resource exists; one implementation in common covers PyTorchJob, MPIJob and TFJob, because all three get their replica pods from the same operator. For the clustered plugin it is jobset.sigs.k8s.io/jobset-name, exported by the vendored JobSet API the plugin already imports. For Ray it is ray.io/cluster, which KubeRay stamps on head and worker pods and overwrites on the template, and whose value is only knowable from the RayJob's status because KubeRay appends a random suffix to the cluster name. Until that status is reported Ray falls back to the attempt labels alone. That is not a wider search in any sense that matters, since run, action and attempt already pin the selector to one attempt of one action; the most it can add is the job submitter pod, which has no GPU and so no faults to contribute. The alternative, declining until the operator catches up, would lose the fault on exactly the runs where the cluster never came up healthy. Ray also has to stop a task from taking its own pods out of reach. The head and worker templates let a task supply k8s_pod metadata, and that metadata was unioned after the execution labels with only the managed label re-forced afterwards, so a user label named run, action or attempt would overwrite the identity the selector looks a pod up by. All three templates now re-apply flytek8s.PreservedPodLabels last, which is the same key set the selector is built from. Dask and Spark are left for a follow-up. The conformance tests are the point of the test additions. Each asserts that the labels the plugin puts on the pod templates satisfy the selector the same plugin hands the framework, for every replica type it builds. The two halves live far apart and drift on either one would leave a GPU fault on a worker silently unclassified, which is the failure mode this whole path exists to prevent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Samhita Alla <aallasamhita@gmail.com>
1 parent f1fe3ca commit e9767e0

17 files changed

Lines changed: 819 additions & 8 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package clustered
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
corev1 "k8s.io/api/core/v1"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"k8s.io/apimachinery/pkg/labels"
12+
jobsetv1alpha2 "sigs.k8s.io/jobset/api/jobset/v1alpha2"
13+
14+
pluginsCore "github.com/flyteorg/flyte/v2/flyteplugins/go/tasks/pluginmachinery/core"
15+
coreMocks "github.com/flyteorg/flyte/v2/flyteplugins/go/tasks/pluginmachinery/core/mocks"
16+
"github.com/flyteorg/flyte/v2/flyteplugins/go/tasks/pluginmachinery/flytek8s"
17+
clusteredpb "github.com/flyteorg/flyte/v2/gen/go/flyteidl2/plugins"
18+
)
19+
20+
// attemptExecutionLabels mirrors what NewTaskExecutionMetadata stamps on every task and
21+
// what build.go then merges onto every child pod template.
22+
func attemptExecutionLabels() map[string]string {
23+
return map[string]string{
24+
"execution-id": "my-exec",
25+
"node-id": "n1",
26+
flytek8s.ManagedLabelKey: flytek8s.ManagedLabelValue,
27+
flytek8s.RunLabel: "run-abc",
28+
flytek8s.ActionLabel: "a0",
29+
flytek8s.AttemptLabel: "1",
30+
}
31+
}
32+
33+
func attemptMetadata(executionLabels map[string]string) pluginsCore.TaskExecutionMetadata {
34+
meta := &coreMocks.TaskExecutionMetadata{}
35+
meta.EXPECT().GetLabels().Return(executionLabels)
36+
return meta
37+
}
38+
39+
func TestClusteredChildPods(t *testing.T) {
40+
jobSet := &jobsetv1alpha2.JobSet{
41+
ObjectMeta: metav1.ObjectMeta{Namespace: testNS, Name: testJobName},
42+
}
43+
44+
t.Run("selects on the attempt and the JobSet", func(t *testing.T) {
45+
selector, err := clusteredResourceHandler{}.ChildPods(
46+
context.Background(), attemptMetadata(attemptExecutionLabels()), jobSet)
47+
48+
require.NoError(t, err)
49+
require.NotNil(t, selector)
50+
51+
podLabels := attemptExecutionLabels()
52+
podLabels[jobsetv1alpha2.JobSetNameKey] = testJobName
53+
assert.True(t, selector.Matches(labels.Set(podLabels)))
54+
55+
// Another JobSet in the same namespace is not this one.
56+
podLabels[jobsetv1alpha2.JobSetNameKey] = "some-other-jobset"
57+
assert.False(t, selector.Matches(labels.Set(podLabels)))
58+
})
59+
60+
t.Run("does not select another attempt of the same action", func(t *testing.T) {
61+
selector, err := clusteredResourceHandler{}.ChildPods(
62+
context.Background(), attemptMetadata(attemptExecutionLabels()), jobSet)
63+
require.NoError(t, err)
64+
require.NotNil(t, selector)
65+
66+
podLabels := attemptExecutionLabels()
67+
podLabels[jobsetv1alpha2.JobSetNameKey] = testJobName
68+
podLabels[flytek8s.AttemptLabel] = "2"
69+
assert.False(t, selector.Matches(labels.Set(podLabels)))
70+
})
71+
72+
t.Run("declines when the attempt cannot be identified", func(t *testing.T) {
73+
executionLabels := attemptExecutionLabels()
74+
delete(executionLabels, flytek8s.AttemptLabel)
75+
76+
selector, err := clusteredResourceHandler{}.ChildPods(
77+
context.Background(), attemptMetadata(executionLabels), jobSet)
78+
79+
require.NoError(t, err)
80+
assert.Nil(t, selector)
81+
})
82+
83+
t.Run("rejects a resource that is not a JobSet", func(t *testing.T) {
84+
_, err := clusteredResourceHandler{}.ChildPods(
85+
context.Background(), attemptMetadata(attemptExecutionLabels()), &corev1.Pod{})
86+
87+
assert.Error(t, err)
88+
})
89+
}
90+
91+
// TestClusteredChildPodsMatchTheTemplatesTheyCameFrom is the conformance check between the
92+
// two halves of child pod discovery: the labels this plugin puts on the pod templates the
93+
// JobSet controller expands, and the selector it hands the framework to find the resulting
94+
// pods. Label drift on either side would leave a GPU fault on a worker silently
95+
// unclassified, which is the failure mode this whole path exists to prevent.
96+
func TestClusteredChildPodsMatchTheTemplatesTheyCameFrom(t *testing.T) {
97+
spec := &clusteredpb.ClusteredTaskSpec{
98+
Replicas: 4,
99+
NprocPerNode: 8,
100+
Runtime: &clusteredpb.Runtime{
101+
Kind: &clusteredpb.Runtime_Torchrun{
102+
Torchrun: &clusteredpb.TorchRuntime{
103+
RdzvBackend: clusteredpb.RdzvBackend_STATIC,
104+
},
105+
},
106+
},
107+
FailurePolicy: &clusteredpb.ClusterFailurePolicy{MaxRestarts: 3},
108+
}
109+
executionLabels := attemptExecutionLabels()
110+
taskCtx := dummyTaskCtxWithLabels(buildTaskTemplate(spec), testJobName, executionLabels)
111+
112+
obj, err := clusteredResourceHandler{}.BuildResource(context.Background(), taskCtx)
113+
require.NoError(t, err)
114+
jobSet, ok := obj.(*jobsetv1alpha2.JobSet)
115+
require.True(t, ok)
116+
117+
selector, err := clusteredResourceHandler{}.ChildPods(context.Background(), attemptMetadata(executionLabels), jobSet)
118+
require.NoError(t, err)
119+
require.NotNil(t, selector)
120+
121+
require.NotEmpty(t, jobSet.Spec.ReplicatedJobs)
122+
for _, replicatedJob := range jobSet.Spec.ReplicatedJobs {
123+
t.Run(replicatedJob.Name, func(t *testing.T) {
124+
templateLabels := replicatedJob.Template.Spec.Template.GetLabels()
125+
require.NotEmpty(t, templateLabels)
126+
127+
podLabels := make(map[string]string, len(templateLabels)+1)
128+
for k, v := range templateLabels {
129+
podLabels[k] = v
130+
}
131+
// The JobSet controller stamps its own name on the pods it creates, so the
132+
// template does not carry it and the fixture adds what the operator would.
133+
podLabels[jobsetv1alpha2.JobSetNameKey] = jobSet.Name
134+
135+
assert.True(t, selector.Matches(labels.Set(podLabels)),
136+
"the %s pod template's labels %v do not satisfy %s", replicatedJob.Name, podLabels, selector)
137+
})
138+
}
139+
}

flyteplugins/go/tasks/plugins/k8s/clustered/clustered_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ func dummyTaskCtx(taskTemplate *core.TaskTemplate) *coreMocks.TaskExecutionConte
6969
// dummyTaskCtxWithGeneratedName is dummyTaskCtx with a caller-supplied generated name, used to
7070
// exercise the long composed/nested-name truncation path.
7171
func dummyTaskCtxWithGeneratedName(taskTemplate *core.TaskTemplate, generatedName string) *coreMocks.TaskExecutionContext {
72+
return dummyTaskCtxWithLabels(taskTemplate, generatedName, map[string]string{"execution-id": "my-exec", "node-id": "n1"})
73+
}
74+
75+
// dummyTaskCtxWithLabels is dummyTaskCtxWithGeneratedName with caller-supplied execution
76+
// labels, used to exercise the attempt identity the framework finds child pods by.
77+
func dummyTaskCtxWithLabels(taskTemplate *core.TaskTemplate, generatedName string, executionLabels map[string]string) *coreMocks.TaskExecutionContext {
7278
taskCtx := &coreMocks.TaskExecutionContext{}
7379

7480
inputReader := &pluginIOMocks.InputReader{}
@@ -121,7 +127,7 @@ func dummyTaskCtxWithGeneratedName(taskTemplate *core.TaskTemplate, generatedNam
121127
meta.EXPECT().GetTaskExecutionID().Return(tID)
122128
meta.EXPECT().GetNamespace().Return(testNS)
123129
meta.EXPECT().GetAnnotations().Return(map[string]string{"flyte.org/test-annotation": "av"})
124-
meta.EXPECT().GetLabels().Return(map[string]string{"execution-id": "my-exec", "node-id": "n1"})
130+
meta.EXPECT().GetLabels().Return(executionLabels)
125131
meta.EXPECT().GetOwnerReference().Return(metav1.OwnerReference{Kind: "node", Name: "n1"})
126132
meta.EXPECT().IsInterruptible().Return(false)
127133
meta.EXPECT().GetOverrides().Return(overrides)

flyteplugins/go/tasks/plugins/k8s/clustered/plugin.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import (
66
"time"
77

88
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9+
"k8s.io/apimachinery/pkg/labels"
10+
"k8s.io/apimachinery/pkg/selection"
911
"k8s.io/client-go/kubernetes/scheme"
1012
"sigs.k8s.io/controller-runtime/pkg/client"
1113
jobsetv1alpha2 "sigs.k8s.io/jobset/api/jobset/v1alpha2"
1214

1315
"github.com/flyteorg/flyte/v2/flyteplugins/go/tasks/pluginmachinery"
1416
pluginsCore "github.com/flyteorg/flyte/v2/flyteplugins/go/tasks/pluginmachinery/core"
17+
"github.com/flyteorg/flyte/v2/flyteplugins/go/tasks/pluginmachinery/flytek8s"
1518
"github.com/flyteorg/flyte/v2/flyteplugins/go/tasks/pluginmachinery/k8s"
1619
)
1720

@@ -21,6 +24,10 @@ type clusteredResourceHandler struct{}
2124

2225
var _ k8s.Plugin = clusteredResourceHandler{}
2326

27+
// The JobSet's child pods are where a node daemon records what the hardware did, so the
28+
// framework has to be able to find them from the JobSet this plugin tracks.
29+
var _ k8s.ChildPodDiscovery = clusteredResourceHandler{}
30+
2431
func (clusteredResourceHandler) GetProperties() k8s.PluginProperties {
2532
// The plugin manager consumes this to stamp the JobSet name via
2633
// GetGeneratedNameWith(0, GeneratedNameMaxLength), bounding it so derived child
@@ -43,6 +50,36 @@ func (clusteredResourceHandler) IsTerminal(_ context.Context, resource client.Ob
4350
return false, nil
4451
}
4552

53+
// ChildPods implements k8s.ChildPodDiscovery. The pods that run the task are the ones the
54+
// JobSet controller expands from the templates build.go put in the JobSet, which the
55+
// framework tracks nothing of, since it tracks the JobSet.
56+
//
57+
// The selector is the attempt's own labels, which build.go merges onto every child pod
58+
// template, narrowed by the JobSet the pods belong to. The JobSet's name is its own, so
59+
// the selector is never partial.
60+
func (clusteredResourceHandler) ChildPods(
61+
_ context.Context,
62+
taskCtx pluginsCore.TaskExecutionMetadata,
63+
resource client.Object,
64+
) (labels.Selector, error) {
65+
jobSet, ok := resource.(*jobsetv1alpha2.JobSet)
66+
if !ok {
67+
return nil, fmt.Errorf("unexpected resource type %T", resource)
68+
}
69+
70+
selector := flytek8s.AttemptPodSelector(taskCtx)
71+
if selector == nil {
72+
return nil, nil
73+
}
74+
75+
requirement, err := labels.NewRequirement(jobsetv1alpha2.JobSetNameKey, selection.Equals, []string{jobSet.Name})
76+
if err != nil {
77+
return nil, err
78+
}
79+
80+
return selector.Add(*requirement), nil
81+
}
82+
4683
func (clusteredResourceHandler) GetCompletionTime(resource client.Object) (time.Time, error) {
4784
jobSet, ok := resource.(*jobsetv1alpha2.JobSet)
4885
if !ok {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package common
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
kubeflowv1 "github.com/kubeflow/training-operator/pkg/apis/kubeflow.org/v1"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"k8s.io/apimachinery/pkg/labels"
12+
13+
pluginsCore "github.com/flyteorg/flyte/v2/flyteplugins/go/tasks/pluginmachinery/core"
14+
"github.com/flyteorg/flyte/v2/flyteplugins/go/tasks/pluginmachinery/core/mocks"
15+
"github.com/flyteorg/flyte/v2/flyteplugins/go/tasks/pluginmachinery/flytek8s"
16+
)
17+
18+
// AttemptExecutionLabels mirrors what NewTaskExecutionMetadata stamps on every task and
19+
// what ToReplicaSpec then merges onto every replica pod template.
20+
func AttemptExecutionLabels() map[string]string {
21+
return map[string]string{
22+
"label-key": "label-value",
23+
flytek8s.ManagedLabelKey: flytek8s.ManagedLabelValue,
24+
flytek8s.RunLabel: "run-abc",
25+
flytek8s.ActionLabel: "a0",
26+
flytek8s.AttemptLabel: "1",
27+
}
28+
}
29+
30+
func attemptMetadata(executionLabels map[string]string) pluginsCore.TaskExecutionMetadata {
31+
meta := &mocks.TaskExecutionMetadata{}
32+
meta.EXPECT().GetLabels().Return(executionLabels)
33+
return meta
34+
}
35+
36+
func TestChildPods(t *testing.T) {
37+
job := &kubeflowv1.PyTorchJob{
38+
ObjectMeta: metav1.ObjectMeta{Namespace: "test-namespace", Name: "job3"},
39+
}
40+
41+
t.Run("selects on the attempt and the job", func(t *testing.T) {
42+
selector, err := ChildPods(context.TODO(), attemptMetadata(AttemptExecutionLabels()), job)
43+
44+
require.NoError(t, err)
45+
require.NotNil(t, selector)
46+
47+
podLabels := AttemptExecutionLabels()
48+
podLabels[kubeflowv1.JobNameLabel] = "job3"
49+
podLabels[kubeflowv1.ReplicaTypeLabel] = "worker"
50+
podLabels[kubeflowv1.ReplicaIndexLabel] = "0"
51+
assert.True(t, selector.Matches(labels.Set(podLabels)))
52+
53+
// Another job in the same namespace is not this one.
54+
podLabels[kubeflowv1.JobNameLabel] = "job4"
55+
assert.False(t, selector.Matches(labels.Set(podLabels)))
56+
})
57+
58+
t.Run("does not select another attempt of the same action", func(t *testing.T) {
59+
selector, err := ChildPods(context.TODO(), attemptMetadata(AttemptExecutionLabels()), job)
60+
require.NoError(t, err)
61+
require.NotNil(t, selector)
62+
63+
podLabels := AttemptExecutionLabels()
64+
podLabels[kubeflowv1.JobNameLabel] = "job3"
65+
podLabels[flytek8s.AttemptLabel] = "2"
66+
assert.False(t, selector.Matches(labels.Set(podLabels)))
67+
})
68+
69+
t.Run("declines when the attempt cannot be identified", func(t *testing.T) {
70+
executionLabels := AttemptExecutionLabels()
71+
delete(executionLabels, flytek8s.ActionLabel)
72+
73+
selector, err := ChildPods(context.TODO(), attemptMetadata(executionLabels), job)
74+
75+
require.NoError(t, err)
76+
assert.Nil(t, selector)
77+
})
78+
79+
t.Run("rejects a missing resource", func(t *testing.T) {
80+
_, err := ChildPods(context.TODO(), attemptMetadata(AttemptExecutionLabels()), nil)
81+
assert.Error(t, err)
82+
})
83+
}

flyteplugins/go/tasks/plugins/k8s/kfoperators/common/common_operator.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import (
99
kubeflowv1 "github.com/kubeflow/training-operator/pkg/apis/kubeflow.org/v1"
1010
v1 "k8s.io/api/core/v1"
1111
meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/labels"
13+
"k8s.io/apimachinery/pkg/selection"
14+
"sigs.k8s.io/controller-runtime/pkg/client"
1215

1316
flyteerr "github.com/flyteorg/flyte/v2/flyteplugins/go/tasks/errors"
1417
"github.com/flyteorg/flyte/v2/flyteplugins/go/tasks/logs"
@@ -29,6 +32,37 @@ const (
2932
PytorchTaskType = "pytorch"
3033
)
3134

35+
// ChildPods names the replica pods the training operator expands from the templates a
36+
// kubeflow plugin built. It is the shared implementation of k8s.ChildPodDiscovery for
37+
// PyTorchJob, MPIJob and TFJob, which all get their replica pods from the same operator
38+
// and so all carry the same job-name label.
39+
//
40+
// The selector is the attempt's own labels, which every replica template carries through
41+
// ToReplicaSpec, narrowed by the job the pods belong to. Unlike Ray's cluster name the job
42+
// name is the CR's own name, so it is known from the moment the resource exists and the
43+
// selector is never partial.
44+
func ChildPods(
45+
_ context.Context,
46+
taskCtx pluginsCore.TaskExecutionMetadata,
47+
resource client.Object,
48+
) (labels.Selector, error) {
49+
if resource == nil {
50+
return nil, fmt.Errorf("expected a kubeflow job, got nothing")
51+
}
52+
53+
selector := flytek8s.AttemptPodSelector(taskCtx)
54+
if selector == nil {
55+
return nil, nil
56+
}
57+
58+
requirement, err := labels.NewRequirement(kubeflowv1.JobNameLabel, selection.Equals, []string{resource.GetName()})
59+
if err != nil {
60+
return nil, err
61+
}
62+
63+
return selector.Add(*requirement), nil
64+
}
65+
3266
// ExtractCurrentCondition will return the first job condition for tensorflow/pytorch
3367
func ExtractCurrentCondition(jobConditions []kubeflowv1.JobCondition) (kubeflowv1.JobCondition, error) {
3468
if jobConditions != nil {

0 commit comments

Comments
 (0)