diff --git a/pkg/scheduler/framework/score.go b/pkg/scheduler/framework/score.go new file mode 100644 index 000000000..b8b4b5996 --- /dev/null +++ b/pkg/scheduler/framework/score.go @@ -0,0 +1,57 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package framework + +import ( + fleetv1 "go.goms.io/fleet/apis/v1" +) + +// ClusterScore is the scores the scheduler assigns to a cluster. +type ClusterScore struct { + // TopologySpreadScore determines how much a binding would satisfy the topology spread + // constraints specified by the user. + TopologySpreadScore int + // AffinityScore determines how much a binding would satisfy the affinity terms + // specified by the user. + AffinityScore int +} + +// Add adds a ClusterScore to another ClusterScore. +func (s1 *ClusterScore) Add(s2 ClusterScore) { + s1.TopologySpreadScore += s2.TopologySpreadScore + s1.AffinityScore += s2.AffinityScore +} + +// Less returns true if a ClusterScore is less than another. +func (s1 *ClusterScore) Less(s2 *ClusterScore) bool { + if s1.TopologySpreadScore != s2.TopologySpreadScore { + return s1.TopologySpreadScore < s2.TopologySpreadScore + } + + return s1.AffinityScore < s2.AffinityScore +} + +// ScoredCluster is a cluster with a score. +type ScoredCluster struct { + Cluster *fleetv1.MemberCluster + Score *ClusterScore +} + +// ScoredClusters is a list of ScoredClusters; this type implements the sort.Interface. +type ScoredClusters []*ScoredCluster + +// Len returns the length of a ScoredClusters; it implemented sort.Interface.Len(). +func (sc ScoredClusters) Len() int { return len(sc) } + +// Less returns true if a ScoredCluster is of a lower score than another; it implemented sort.Interface.Less(). +func (sc ScoredClusters) Less(i, j int) bool { + return sc[i].Score.Less(sc[j].Score) +} + +// Swap swaps two ScoredClusters in the list; it implemented sort.Interface.Swap(). +func (sc ScoredClusters) Swap(i, j int) { + sc[i], sc[j] = sc[j], sc[i] +} diff --git a/pkg/scheduler/framework/score_test.go b/pkg/scheduler/framework/score_test.go new file mode 100644 index 000000000..ead61139c --- /dev/null +++ b/pkg/scheduler/framework/score_test.go @@ -0,0 +1,310 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package framework + +import ( + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + + fleetv1 "go.goms.io/fleet/apis/v1" +) + +func TestClusterScoreAdd(t *testing.T) { + s1 := &ClusterScore{ + TopologySpreadScore: 0, + AffinityScore: 0, + } + + s2 := &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 5, + } + + s1.Add(*s2) + want := &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 5, + } + if !cmp.Equal(s1, want) { + t.Fatalf("Add() = %v, want %v", s1, want) + } +} + +func TestClusterScoreLess(t *testing.T) { + testCases := []struct { + name string + s1 *ClusterScore + s2 *ClusterScore + want bool + }{ + { + name: "s1 is less than s2 in topology spread score", + s1: &ClusterScore{ + TopologySpreadScore: 0, + AffinityScore: 10, + }, + s2: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 20, + }, + want: true, + }, + { + name: "s1 is less than s2 in affinity score", + s1: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 10, + }, + s2: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 20, + }, + want: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.s1.Less(tc.s2) != tc.want { + t.Fatalf("Less(%v, %v) = %t, want %t", tc.s1, tc.s2, !tc.want, tc.want) + } + + if tc.s2.Less(tc.s1) != !tc.want { + t.Fatalf("Less(%v, %v) = %t, want %t", tc.s2, tc.s1, tc.want, !tc.want) + } + }) + } +} + +func TestClusterScoreEqual(t *testing.T) { + s1 := &ClusterScore{ + TopologySpreadScore: 0, + AffinityScore: 0, + } + + s2 := &ClusterScore{ + TopologySpreadScore: 0, + AffinityScore: 0, + } + + if s1.Less(s2) || s2.Less(s1) { + t.Fatalf("Less(%v, %v) = %v, Less(%v, %v) = %v, want both to be false", s1, s2, s1.Less(s2), s2, s1, s2.Less(s1)) + } +} + +func TestScoredClustersSort(t *testing.T) { + clusterA := &fleetv1.MemberCluster{} + clusterB := &fleetv1.MemberCluster{} + clusterC := &fleetv1.MemberCluster{} + clusterD := &fleetv1.MemberCluster{} + + testCases := []struct { + name string + scs ScoredClusters + want ScoredClusters + }{ + { + name: "sort asc values", + scs: ScoredClusters{ + { + Cluster: clusterA, + Score: &ClusterScore{ + TopologySpreadScore: 0, + AffinityScore: 10, + }, + }, + { + Cluster: clusterB, + Score: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 10, + }, + }, + { + Cluster: clusterC, + Score: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 20, + }, + }, + { + Cluster: clusterD, + Score: &ClusterScore{ + TopologySpreadScore: 2, + AffinityScore: 30, + }, + }, + }, + want: ScoredClusters{ + { + Cluster: clusterD, + Score: &ClusterScore{ + TopologySpreadScore: 2, + AffinityScore: 30, + }, + }, + { + Cluster: clusterC, + Score: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 20, + }, + }, + { + Cluster: clusterB, + Score: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 10, + }, + }, + { + Cluster: clusterA, + Score: &ClusterScore{ + TopologySpreadScore: 0, + AffinityScore: 10, + }, + }, + }, + }, + { + name: "sort desc values", + scs: ScoredClusters{ + { + Cluster: clusterD, + Score: &ClusterScore{ + TopologySpreadScore: 2, + AffinityScore: 30, + }, + }, + { + Cluster: clusterC, + Score: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 20, + }, + }, + { + Cluster: clusterB, + Score: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 10, + }, + }, + { + Cluster: clusterA, + Score: &ClusterScore{ + TopologySpreadScore: 0, + AffinityScore: 10, + }, + }, + }, + want: ScoredClusters{ + { + Cluster: clusterD, + Score: &ClusterScore{ + TopologySpreadScore: 2, + AffinityScore: 30, + }, + }, + { + Cluster: clusterC, + Score: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 20, + }, + }, + { + Cluster: clusterB, + Score: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 10, + }, + }, + { + Cluster: clusterA, + Score: &ClusterScore{ + TopologySpreadScore: 0, + AffinityScore: 10, + }, + }, + }, + }, + { + name: "sort values in random", + scs: ScoredClusters{ + { + Cluster: clusterC, + Score: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 20, + }, + }, + { + Cluster: clusterD, + Score: &ClusterScore{ + TopologySpreadScore: 2, + AffinityScore: 30, + }, + }, + { + Cluster: clusterA, + Score: &ClusterScore{ + TopologySpreadScore: 0, + AffinityScore: 10, + }, + }, + { + Cluster: clusterB, + Score: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 10, + }, + }, + }, + want: ScoredClusters{ + { + Cluster: clusterD, + Score: &ClusterScore{ + TopologySpreadScore: 2, + AffinityScore: 30, + }, + }, + { + Cluster: clusterC, + Score: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 20, + }, + }, + { + Cluster: clusterB, + Score: &ClusterScore{ + TopologySpreadScore: 1, + AffinityScore: 10, + }, + }, + { + Cluster: clusterA, + Score: &ClusterScore{ + TopologySpreadScore: 0, + AffinityScore: 10, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sort.Sort(sort.Reverse(tc.scs)) + if !cmp.Equal(tc.scs, tc.want) { + t.Fatalf("Sort() = %v, want %v", tc.scs, tc.want) + } + }) + } +}