From ca9d1c115e9b22b4accf0899cb8ca90935f614fe Mon Sep 17 00:00:00 2001 From: gshaibi Date: Mon, 17 Aug 2026 15:21:01 +0300 Subject: [PATCH] fix(api): allow PodGroup minMember of 0 (#1989) Backport of 62c594bf328ee24f4156cec979b6103690a438c4 to v0.9, adapted for non-pointer MinMember and the SubGroups-based scheduler API. Signed-off-by: lin121291 <4jp33f9e@gmail.com> Signed-off-by: gshaibi --- .../unreleased/fixed-20260728-212512.yaml | 3 ++ .../crds/scheduling.run.ai_podgroups.yaml | 1 + .../scheduling/v2alpha2/podgroup_types.go | 1 + .../plugins/knative/knative_test.go | 16 ++++++++-- pkg/scheduler/api/podgroup_info/job_info.go | 4 +-- .../api/podgroup_info/job_info_test.go | 30 +++++++++++++++++++ .../third_party/knative/knative_test.go | 26 ++++++++++++++++ 7 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 .changes/unreleased/fixed-20260728-212512.yaml diff --git a/.changes/unreleased/fixed-20260728-212512.yaml b/.changes/unreleased/fixed-20260728-212512.yaml new file mode 100644 index 000000000..2e4a57e25 --- /dev/null +++ b/.changes/unreleased/fixed-20260728-212512.yaml @@ -0,0 +1,3 @@ +kind: Fixed +body: |- + Allow PodGroup minMember of 0 for workloads with no gang requirement diff --git a/deployments/kai-scheduler/crds/scheduling.run.ai_podgroups.yaml b/deployments/kai-scheduler/crds/scheduling.run.ai_podgroups.yaml index 63c3255c5..f9e85e5d3 100644 --- a/deployments/kai-scheduler/crds/scheduling.run.ai_podgroups.yaml +++ b/deployments/kai-scheduler/crds/scheduling.run.ai_podgroups.yaml @@ -61,6 +61,7 @@ spec: MinMember defines the minimal number of members/tasks to run the pod group; if there's not enough resources to start all tasks, the scheduler will not start anyone. + A value of 0 means no gang requirement: all pods are scheduled elastically (e.g. scale-to-zero workloads). format: int32 type: integer parallelism: diff --git a/pkg/apis/scheduling/v2alpha2/podgroup_types.go b/pkg/apis/scheduling/v2alpha2/podgroup_types.go index 946c5cc09..69ed4e22b 100644 --- a/pkg/apis/scheduling/v2alpha2/podgroup_types.go +++ b/pkg/apis/scheduling/v2alpha2/podgroup_types.go @@ -34,6 +34,7 @@ type PodGroupSpec struct { // MinMember defines the minimal number of members/tasks to run the pod group; // if there's not enough resources to start all tasks, the scheduler // will not start anyone. + // A value of 0 means no gang requirement: all pods are scheduled elastically (e.g. scale-to-zero workloads). MinMember int32 `json:"minMember,omitempty" protobuf:"bytes,1,opt,name=minMember"` // Queue defines the queue to allocate resource for PodGroup; if queue does not exist, diff --git a/pkg/podgrouper/podgrouper/plugins/knative/knative_test.go b/pkg/podgrouper/podgrouper/plugins/knative/knative_test.go index 0a7a2fd09..050501784 100644 --- a/pkg/podgrouper/podgrouper/plugins/knative/knative_test.go +++ b/pkg/podgrouper/podgrouper/plugins/knative/knative_test.go @@ -4,7 +4,6 @@ package knative import ( - "strconv" "testing" "github.com/stretchr/testify/assert" @@ -113,6 +112,17 @@ func TestGetPodGroupMetadata(t *testing.T) { } func TestGetPodGroupMetadata_MinScale(t *testing.T) { + for minScale, expectedMinAvailable := range map[string]int32{ + "3": 3, + "0": 0, // scale-to-zero: no gang requirement + } { + t.Run("min-scale="+minScale, func(t *testing.T) { + testGetPodGroupMetadataMinScale(t, minScale, expectedMinAvailable) + }) + } +} + +func testGetPodGroupMetadataMinScale(t *testing.T, minScale string, expectedMinAvailable int32) { service := &unstructured.Unstructured{ Object: map[string]interface{}{ "kind": "Service", @@ -156,7 +166,7 @@ func TestGetPodGroupMetadata_MinScale(t *testing.T) { Namespace: "test_namespace", UID: "2", Annotations: map[string]string{ - "autoscaling.knative.dev/min-scale": "3", + "autoscaling.knative.dev/min-scale": minScale, }, }, Spec: knative.RevisionSpec{}, @@ -197,7 +207,7 @@ func TestGetPodGroupMetadata_MinScale(t *testing.T) { assert.Equal(t, "pg-revision-2", metadata.Name) assert.Equal(t, constants.InferencePriorityClass, metadata.PriorityClassName) assert.Equal(t, "test_queue", metadata.Queue) - assert.Equal(t, "3", strconv.Itoa(int(metadata.MinAvailable))) + assert.Equal(t, expectedMinAvailable, metadata.MinAvailable) } func TestGetPodGroupMetadataBackwardsCompatibility(t *testing.T) { diff --git a/pkg/scheduler/api/podgroup_info/job_info.go b/pkg/scheduler/api/podgroup_info/job_info.go index d206ad2b7..a57c16783 100644 --- a/pkg/scheduler/api/podgroup_info/job_info.go +++ b/pkg/scheduler/api/podgroup_info/job_info.go @@ -204,9 +204,9 @@ func (pgi *PodGroupInfo) setSubGroups(podGroup *enginev2alpha2.PodGroup) { } defaultSubGroup, found := pgi.SubGroups[DefaultSubGroup] if !found { - pgi.SubGroups[DefaultSubGroup] = NewSubGroupInfo(DefaultSubGroup, max(podGroup.Spec.MinMember, 1)) + pgi.SubGroups[DefaultSubGroup] = NewSubGroupInfo(DefaultSubGroup, max(podGroup.Spec.MinMember, 0)) } else { - defaultSubGroup.SetMinAvailable(max(podGroup.Spec.MinMember, 1)) + defaultSubGroup.SetMinAvailable(max(podGroup.Spec.MinMember, 0)) } } diff --git a/pkg/scheduler/api/podgroup_info/job_info_test.go b/pkg/scheduler/api/podgroup_info/job_info_test.go index 708bbd818..8d5f4c9e6 100644 --- a/pkg/scheduler/api/podgroup_info/job_info_test.go +++ b/pkg/scheduler/api/podgroup_info/job_info_test.go @@ -115,6 +115,36 @@ func TestAddTaskInfo(t *testing.T) { } } +func TestSetPodGroupMinMember(t *testing.T) { + tests := []struct { + name string + minMember int32 + expectedMinAvail int32 + }{ + {"zero minMember is honored", 0, 0}, + {"positive minMember is honored", 3, 3}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + info := NewPodGroupInfo("group-1") + info.SetPodGroup(&v2alpha2.PodGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "group-1", + Namespace: "ns-1", + }, + Spec: v2alpha2.PodGroupSpec{ + Queue: "queue-1", + MinMember: test.minMember, + }, + }) + + if got := info.SubGroups[DefaultSubGroup].GetMinAvailable(); got != test.expectedMinAvail { + t.Errorf("expected minAvailable %d, got %d", test.expectedMinAvail, got) + } + }) + } +} + func TestDeleteTaskInfo(t *testing.T) { // case1 case01_uid := common_info.PodGroupID("owner1") diff --git a/test/e2e/suites/integrations/third_party/knative/knative_test.go b/test/e2e/suites/integrations/third_party/knative/knative_test.go index 560743cf4..0faff5550 100644 --- a/test/e2e/suites/integrations/third_party/knative/knative_test.go +++ b/test/e2e/suites/integrations/third_party/knative/knative_test.go @@ -118,6 +118,32 @@ var _ = Describe("Knative integration", Ordered, func() { "Expected minmember of podgroup to be 1") }) + It("knative service with scale-to-zero - minscale 0", func(ctx context.Context) { + namespace := queue.GetConnectedNamespaceToQueue(testCtx.Queues[0]) + serviceName := "knative-service-" + utils.GenerateRandomK8sName(10) + + knativeService := getKnativeServiceObject(serviceName, namespace, testCtx.Queues[0].Name) + + knativeService.Spec.ConfigurationSpec.Template.Annotations[minScaleAnnotation] = "0" + + Expect(testCtx.ControllerClient.Create(ctx, knativeService)).To(Succeed()) + defer func() { + Expect(testCtx.ControllerClient.Delete(ctx, knativeService)).To(Succeed()) + }() + + // Knative still creates the initial pod on deployment even with min-scale 0 + pods := getServicePods(ctx, testCtx, serviceName, namespace, 1) + + wait.ForPodsScheduled(ctx, testCtx.ControllerClient, namespace, pods) + + var podGroups v2alpha2.PodGroupList + Expect(testCtx.ControllerClient.List(ctx, &podGroups, client.InNamespace(namespace))).To(Succeed()) + Expect(len(podGroups.Items)).To(Equal(1), + "Expected one podgroup for the revision") + Expect(podGroups.Items[0].Spec.MinMember).To(Equal(int32(0)), + "Expected minmember of podgroup to be 0") + }) + Context("Non-Gang scheduling", func() { BeforeAll(func(ctx context.Context) { setKnativeGangAndWait(ctx, testCtx, pointer.Bool(false))