Skip to content

Commit 4dc1438

Browse files
authored
Hack to shuffel host in evacuation request (#606)
1 parent 65929ee commit 4dc1438

5 files changed

Lines changed: 153 additions & 6 deletions

File tree

cmd/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,9 @@ func main() {
329329
os.Exit(1)
330330
}
331331
novaAPIConfig := conf.GetConfigOrDie[nova.HTTPAPIConfig]()
332+
setupLog.Info("loaded nova API config",
333+
"evacuationShuffleK", novaAPIConfig.EvacuationShuffleK,
334+
"novaLimitHostsToRequest", novaAPIConfig.NovaLimitHostsToRequest)
332335
nova.NewAPI(novaAPIConfig, filterWeigherController).Init(mux)
333336

334337
// Detector pipeline controller setup.

helm/bundles/cortex-nova/values.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ cortex-scheduling-controllers:
141141
# If true, the external scheduler API will limit the list of hosts in its
142142
# response to those included in the scheduling request.
143143
novaLimitHostsToRequest: true
144+
# Number of top hosts to shuffle for evacuation requests.
145+
# Set to 0 or negative to disable shuffling.
146+
evacuationShuffleK: 3
144147
# CommittedResourceFlavorGroupPipelines maps flavor group IDs to pipeline names for CR reservations
145148
# This allows different scheduling strategies per flavor group (e.g., HANA vs GP)
146149
committedResourceFlavorGroupPipelines:

internal/scheduling/nova/external_scheduler_api.go

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"fmt"
1212
"io"
1313
"log/slog"
14+
"math/rand"
1415
"net/http"
1516

1617
api "github.com/cobaltcore-dev/cortex/api/external/nova"
@@ -24,6 +25,16 @@ import (
2425
"sigs.k8s.io/controller-runtime/pkg/metrics"
2526
)
2627

28+
// Custom configuration for the Nova external scheduler api.
29+
type HTTPAPIConfig struct {
30+
// Number of top hosts to shuffle for evacuation requests.
31+
// Set to 0 or negative to disable shuffling.
32+
EvacuationShuffleK int `json:"evacuationShuffleK,omitempty"`
33+
// NovaLimitHostsToRequest, if true, will filter the Nova scheduler response
34+
// to only include hosts that were in the original request.
35+
NovaLimitHostsToRequest bool `json:"novaLimitHostsToRequest,omitempty"`
36+
}
37+
2738
type HTTPAPIDelegate interface {
2839
// Process the decision from the API. Should create and return the updated decision.
2940
ProcessNewDecisionFromAPI(ctx context.Context, decision *v1alpha1.Decision) error
@@ -34,12 +45,6 @@ type HTTPAPI interface {
3445
Init(*http.ServeMux)
3546
}
3647

37-
type HTTPAPIConfig struct {
38-
// NovaLimitHostsToRequest, if true, will filter the Nova scheduler response
39-
// to only include hosts that were in the original request.
40-
NovaLimitHostsToRequest bool `json:"novaLimitHostsToRequest,omitempty"`
41-
}
42-
4348
type httpAPI struct {
4449
monitor scheduling.APIMonitor
4550
delegate HTTPAPIDelegate
@@ -116,6 +121,26 @@ func (httpAPI *httpAPI) inferPipelineName(requestData api.ExternalSchedulerReque
116121
}
117122
}
118123

124+
// shuffleTopHosts randomly reorders the first k hosts if the request
125+
// is an evacuation. This helps distribute evacuated VMs across multiple hosts
126+
// rather than concentrating them on the single "best" host.
127+
func shuffleTopHosts(hosts []string, k int) []string {
128+
if k <= 0 {
129+
return hosts
130+
}
131+
n := min(k, len(hosts))
132+
if n <= 1 {
133+
return hosts
134+
}
135+
result := make([]string, len(hosts))
136+
copy(result, hosts)
137+
rand.Shuffle(n, func(i, j int) {
138+
result[i], result[j] = result[j], result[i]
139+
})
140+
slog.Info("shuffled top hosts for evacuation", "k", n, "hosts", result[:n])
141+
return result
142+
}
143+
119144
// Limit the external scheduler response to the hosts provided in the external
120145
// scheduler request. i.e. don't provide new hosts that weren't in the request,
121146
// since the Nova scheduler won't know how to handle them.
@@ -235,6 +260,12 @@ func (httpAPI *httpAPI) NovaExternalScheduler(w http.ResponseWriter, r *http.Req
235260
slog.Info("limited hosts to request",
236261
"hosts", hosts, "originalHosts", decision.Status.Result.OrderedHosts)
237262
}
263+
// This is a hack to address the problem that Nova only uses the first host in hosts for evacuation requests.
264+
// Only for evacuation we shuffle the first k hosts to ensure that we do not get stuck on a single host
265+
intent, err := requestData.GetIntent()
266+
if err == nil && intent == api.EvacuateIntent {
267+
hosts = shuffleTopHosts(hosts, httpAPI.config.EvacuationShuffleK)
268+
}
238269
response := api.ExternalSchedulerResponse{Hosts: hosts}
239270
w.Header().Set("Content-Type", "application/json")
240271
if err = json.NewEncoder(w).Encode(response); err != nil {

internal/scheduling/nova/external_scheduler_api_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,114 @@ func TestLimitHostsToRequest(t *testing.T) {
506506
}
507507
}
508508

509+
func TestShuffleTopHosts(t *testing.T) {
510+
tests := []struct {
511+
name string
512+
hosts []string
513+
k int
514+
unchangedTailFrom int // index from which hosts should be unchanged (-1 if all can change)
515+
expectUnchanged bool
516+
}{
517+
{
518+
name: "empty hosts returns empty",
519+
hosts: []string{},
520+
k: 3,
521+
},
522+
{
523+
name: "single host returns unchanged",
524+
hosts: []string{"host1"},
525+
k: 3,
526+
unchangedTailFrom: 0,
527+
},
528+
{
529+
name: "two hosts with k=3 shuffles all",
530+
hosts: []string{"host1", "host2"},
531+
k: 3,
532+
unchangedTailFrom: -1,
533+
},
534+
{
535+
name: "three hosts with k=3 shuffles all",
536+
hosts: []string{"host1", "host2", "host3"},
537+
k: 3,
538+
unchangedTailFrom: -1,
539+
},
540+
{
541+
name: "four hosts with k=3 shuffles first 3",
542+
hosts: []string{"host1", "host2", "host3", "host4"},
543+
k: 3,
544+
unchangedTailFrom: 3,
545+
},
546+
{
547+
name: "shuffles only first k hosts",
548+
hosts: []string{"host1", "host2", "host3", "host4", "host5"},
549+
k: 3,
550+
unchangedTailFrom: 3,
551+
},
552+
{
553+
name: "k=0 disables shuffling",
554+
hosts: []string{"host1", "host2", "host3", "host4", "host5"},
555+
k: 0,
556+
expectUnchanged: true,
557+
},
558+
{
559+
name: "negative k disables shuffling",
560+
hosts: []string{"host1", "host2", "host3", "host4", "host5"},
561+
k: -1,
562+
expectUnchanged: true,
563+
},
564+
{
565+
name: "k larger than hosts shuffles all",
566+
hosts: []string{"host1", "host2"},
567+
k: 10,
568+
unchangedTailFrom: -1,
569+
},
570+
}
571+
572+
for _, tt := range tests {
573+
t.Run(tt.name, func(t *testing.T) {
574+
original := make([]string, len(tt.hosts))
575+
copy(original, tt.hosts)
576+
577+
result := shuffleTopHosts(tt.hosts, tt.k)
578+
579+
if len(result) != len(tt.hosts) {
580+
t.Fatalf("expected %d hosts, got %d", len(tt.hosts), len(result))
581+
}
582+
// Verify original slice not modified
583+
for i, h := range original {
584+
if tt.hosts[i] != h {
585+
t.Errorf("original slice modified at %d: expected %s, got %s", i, h, tt.hosts[i])
586+
}
587+
}
588+
// When k <= 0, expect the same slice returned (no copy)
589+
if tt.expectUnchanged {
590+
if len(result) > 0 && &result[0] != &tt.hosts[0] {
591+
t.Error("expected same slice returned when k <= 0")
592+
}
593+
return
594+
}
595+
// Verify tail unchanged
596+
if tt.unchangedTailFrom >= 0 {
597+
for i := tt.unchangedTailFrom; i < len(original); i++ {
598+
if result[i] != original[i] {
599+
t.Errorf("expected host[%d] = %s unchanged, got %s", i, original[i], result[i])
600+
}
601+
}
602+
}
603+
// Verify all hosts present
604+
hostSet := make(map[string]bool)
605+
for _, h := range result {
606+
hostSet[h] = true
607+
}
608+
for _, h := range original {
609+
if !hostSet[h] {
610+
t.Errorf("host %s missing from result", h)
611+
}
612+
}
613+
})
614+
}
615+
}
616+
509617
func TestHTTPAPI_inferPipelineName(t *testing.T) {
510618
delegate := &mockHTTPAPIDelegate{}
511619
api := NewAPI(HTTPAPIConfig{}, delegate).(*httpAPI)

internal/scheduling/nova/integration_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,9 @@ func NewIntegrationTestServer(t *testing.T, pipelineConfig PipelineConfig, objec
283283
controller.PipelineConfigs[testPipeline.Name] = testPipeline
284284

285285
// Create the HTTP API with the controller as delegate - skip metrics registration
286+
// Set EvacuationShuffleK=0 to disable shuffle for deterministic test results
286287
api := &httpAPI{
288+
config: HTTPAPIConfig{EvacuationShuffleK: 0},
287289
monitor: lib.NewSchedulerMonitor(), // Create new monitor but don't register
288290
delegate: controller,
289291
}

0 commit comments

Comments
 (0)