From d72e59d4b2508c08524ba6ef732c8202ad4b70e3 Mon Sep 17 00:00:00 2001 From: gshaibi Date: Mon, 17 Aug 2026 16:03:17 +0300 Subject: [PATCH] fix(api): allow PodGroup minMember of 0 (#1989) Adapted backport: MinMember is a non-pointer int32 on this branch, so unset and explicit 0 are indistinguishable. The CRD now accepts 0, but the scheduler keeps defaulting minAvailable to 1 to preserve existing gang semantics for PodGroups created without minMember. Signed-off-by: lin121291 <4jp33f9e@gmail.com> (cherry picked from commit 62c594bf328ee24f4156cec979b6103690a438c4) Signed-off-by: gshaibi --- .../unreleased/fixed-20260728-212512.yaml | 3 ++ .../crds/scheduling.run.ai_podgroups.yaml | 3 +- .../scheduling/v2alpha2/podgroup_types.go | 3 +- .../plugins/knative/knative_test.go | 16 ++++++++-- .../api/podgroup_info/job_info_test.go | 32 +++++++++++++++++++ .../third_party/knative/knative_specs.go | 26 +++++++++++++++ 6 files changed, 78 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..bfda2d554 --- /dev/null +++ b/.changes/unreleased/fixed-20260728-212512.yaml @@ -0,0 +1,3 @@ +kind: Fixed +body: |- + Allow creating PodGroups with an explicit minMember of 0 diff --git a/deployments/kai-scheduler/crds/scheduling.run.ai_podgroups.yaml b/deployments/kai-scheduler/crds/scheduling.run.ai_podgroups.yaml index 70fd2bf9c..194b13d78 100644 --- a/deployments/kai-scheduler/crds/scheduling.run.ai_podgroups.yaml +++ b/deployments/kai-scheduler/crds/scheduling.run.ai_podgroups.yaml @@ -64,8 +64,9 @@ spec: description: |- MinMember defines the minimal number of members to run the PodGroup; if there are not enough resources to start all required members, 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 - minimum: 1 + minimum: 0 type: integer parallelism: description: The number of pods which will try to run at any instant. diff --git a/pkg/apis/scheduling/v2alpha2/podgroup_types.go b/pkg/apis/scheduling/v2alpha2/podgroup_types.go index 072b43982..4531fece3 100644 --- a/pkg/apis/scheduling/v2alpha2/podgroup_types.go +++ b/pkg/apis/scheduling/v2alpha2/podgroup_types.go @@ -34,7 +34,8 @@ import ( type PodGroupSpec struct { // MinMember defines the minimal number of members to run the PodGroup; // if there are not enough resources to start all required members, the scheduler will not start anyone. - // +kubebuilder:validation:Minimum=1 + // A value of 0 means no gang requirement: all pods are scheduled elastically (e.g. scale-to-zero workloads). + // +kubebuilder:validation:Minimum=0 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 e554cd65e..f9b86a372 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_test.go b/pkg/scheduler/api/podgroup_info/job_info_test.go index 13a713414..0244208c3 100644 --- a/pkg/scheduler/api/podgroup_info/job_info_test.go +++ b/pkg/scheduler/api/podgroup_info/job_info_test.go @@ -28,6 +28,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" + enginev2alpha2 "github.com/kai-scheduler/KAI-scheduler/pkg/apis/scheduling/v2alpha2" commonconstants "github.com/kai-scheduler/KAI-scheduler/pkg/common/constants" "github.com/kai-scheduler/KAI-scheduler/pkg/scheduler/api/common_info" "github.com/kai-scheduler/KAI-scheduler/pkg/scheduler/api/pod_info" @@ -132,6 +133,37 @@ func TestAddTaskInfo(t *testing.T) { } } +func TestSetPodGroupMinMember(t *testing.T) { + tests := []struct { + name string + minMember int32 + expectedMinAvail int32 + }{ + // int32 cannot distinguish unset from explicit 0; both default to a gang of 1 + {"zero or unset minMember defaults to 1", 0, 1}, + {"positive minMember is honored", 3, 3}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + info := NewPodGroupInfo("group-1") + info.SetPodGroup(&enginev2alpha2.PodGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "group-1", + Namespace: "ns-1", + }, + Spec: enginev2alpha2.PodGroupSpec{ + Queue: "queue-1", + MinMember: test.minMember, + }, + }) + + if got := info.PodSets[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_specs.go b/test/e2e/suites/integrations/third_party/knative/knative_specs.go index 3ace9e74c..eca4a30a0 100644 --- a/test/e2e/suites/integrations/third_party/knative/knative_specs.go +++ b/test/e2e/suites/integrations/third_party/knative/knative_specs.go @@ -110,6 +110,32 @@ func DescribeKnativeSpecs() bool { Expect(podGroups.Items[0].Spec.MinMember).To(Equal(int32(1)), "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") + }) }) }) }