Skip to content

Commit 9ae23a9

Browse files
authored
feat: resolve overlaps in compartments (#105)
* feat: resolve overlaps in compartments * change strategy type to string with order map * refactor node assignment to be a method on skyhookNodes and use floor for capacity calculations
1 parent 299a07d commit 9ae23a9

File tree

6 files changed

+659
-25
lines changed

6 files changed

+659
-25
lines changed

operator/api/v1alpha1/deployment_policy_types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ type DeploymentBudget struct {
109109
Count *int `json:"count,omitempty"`
110110
}
111111

112+
// StrategyType represents the type of deployment strategy
113+
type StrategyType string
114+
115+
const (
116+
StrategyTypeFixed StrategyType = "fixed"
117+
StrategyTypeLinear StrategyType = "linear"
118+
StrategyTypeExponential StrategyType = "exponential"
119+
StrategyTypeUnknown StrategyType = "unknown"
120+
)
121+
112122
const (
113123
DefaultCompartmentName = "__default__"
114124
)

operator/internal/controller/cluster_state_v2.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ type SkyhookNodes interface {
180180
GetCompartments() map[string]*wrapper.Compartment
181181
AddCompartment(name string, compartment *wrapper.Compartment)
182182
AddCompartmentNode(name string, node wrapper.SkyhookNode)
183+
AssignNodeToCompartment(node wrapper.SkyhookNode) (string, error)
183184
}
184185

185186
var _ SkyhookNodes = &skyhookNodes{}
@@ -751,6 +752,106 @@ func (skyhook *skyhookNodes) AddCompartmentNode(name string, node wrapper.Skyhoo
751752
skyhook.compartments[name].AddNode(node)
752753
}
753754

755+
// compartmentMatch represents a compartment that matches a node
756+
type compartmentMatch struct {
757+
name string
758+
strategyType v1alpha1.StrategyType
759+
capacity int
760+
}
761+
762+
// countMatchingNodes counts how many nodes from allNodes match the given selector
763+
func (skyhook *skyhookNodes) countMatchingNodes(selector metav1.LabelSelector) (int, error) {
764+
labelSelector, err := metav1.LabelSelectorAsSelector(&selector)
765+
if err != nil {
766+
return 0, err
767+
}
768+
769+
count := 0
770+
for _, node := range skyhook.nodes {
771+
if labelSelector.Matches(labels.Set(node.GetNode().Labels)) {
772+
count++
773+
}
774+
}
775+
return count, nil
776+
}
777+
778+
// AssignNodeToCompartment assigns a single node to the appropriate compartment using overlap resolution.
779+
// When a node matches multiple compartments, it resolves using:
780+
// 1. Strategy safety order: Fixed is safer than Linear, which is safer than Exponential
781+
// 2. Tie-break on same strategy: Choose compartment with smaller effective ceiling (window)
782+
// 3. Final tie-break: Lexicographically by compartment name for determinism
783+
// Assignments are recalculated fresh on every reconcile based on current cluster state.
784+
func (skyhook *skyhookNodes) AssignNodeToCompartment(node wrapper.SkyhookNode) (string, error) {
785+
nodeLabels := labels.Set(node.GetNode().Labels)
786+
787+
matches := []compartmentMatch{}
788+
789+
// Collect all matching compartments (excluding default)
790+
for _, compartment := range skyhook.compartments {
791+
// Skip the default compartment - it's a fallback
792+
if compartment.Name == v1alpha1.DefaultCompartmentName {
793+
continue
794+
}
795+
796+
selector, err := metav1.LabelSelectorAsSelector(&compartment.Selector)
797+
if err != nil {
798+
return "", fmt.Errorf("invalid selector for compartment %s: %w", compartment.Name, err)
799+
}
800+
801+
if selector.Matches(nodeLabels) {
802+
// Count how many nodes in total match this compartment's selector
803+
matchedCount, err := skyhook.countMatchingNodes(compartment.Selector)
804+
if err != nil {
805+
return "", fmt.Errorf("error counting matching nodes for compartment %s: %w", compartment.Name, err)
806+
}
807+
808+
// Ensure at least 1 node for capacity calculation
809+
if matchedCount == 0 {
810+
matchedCount = 1
811+
}
812+
813+
stratType := wrapper.GetStrategyType(compartment.Strategy)
814+
capacity := wrapper.ComputeEffectiveCapacity(compartment.Budget, matchedCount)
815+
816+
matches = append(matches, compartmentMatch{
817+
name: compartment.Name,
818+
strategyType: stratType,
819+
capacity: capacity,
820+
})
821+
}
822+
}
823+
824+
// No matches - assign to default
825+
if len(matches) == 0 {
826+
return v1alpha1.DefaultCompartmentName, nil
827+
}
828+
829+
// Single match - return it
830+
if len(matches) == 1 {
831+
return matches[0].name, nil
832+
}
833+
834+
// Multiple matches - apply overlap resolution
835+
// Sort matches using the safety heuristic
836+
sort.Slice(matches, func(i, j int) bool {
837+
// 1. Strategy safety order: Fixed > Linear > Exponential
838+
if matches[i].strategyType != matches[j].strategyType {
839+
return wrapper.StrategyIsSafer(matches[i].strategyType, matches[j].strategyType)
840+
}
841+
842+
// 2. Tie-break on same strategy: smaller window (capacity)
843+
if matches[i].capacity != matches[j].capacity {
844+
return matches[i].capacity < matches[j].capacity
845+
}
846+
847+
// 3. Final tie-break: lexicographically by name for determinism
848+
return matches[i].name < matches[j].name
849+
})
850+
851+
// Return the safest compartment
852+
return matches[0].name, nil
853+
}
854+
754855
// cleanupNodeMap removes nodes from the given map that no longer exist in currentNodes
755856
// Returns false if nodeMap is nil, otherwise returns true if any nodes were removed
756857
func cleanupNodeMap[T any](nodeMap map[string]T, currentNodes map[string]struct{}) bool {

0 commit comments

Comments
 (0)