From c46bf05f9875b99ad0e9ac69845dd43b67aabd2c Mon Sep 17 00:00:00 2001 From: shudorcl <1985366171@qq.com> Date: Thu, 30 Jul 2026 01:33:38 +0800 Subject: [PATCH 1/8] feat(gateway): propagate template scheduling identity Add template_id to the internal scheduling hint and extract it from bounded sandbox creation bodies so scheduler strategies can group template and snapshot requests. Refs #15 --- services/api/proto/scheduler.pb.go | 18 ++++++++++++--- services/api/proto/scheduler.proto | 3 +++ services/gateway/internal/schedule_hint.go | 4 +++- .../gateway/internal/schedule_hint_test.go | 22 +++++++++++++++++++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/services/api/proto/scheduler.pb.go b/services/api/proto/scheduler.pb.go index 730476017..0274440b0 100644 --- a/services/api/proto/scheduler.pb.go +++ b/services/api/proto/scheduler.pb.go @@ -359,7 +359,10 @@ func (x *NewColdSandboxHint) GetMetadata() map[string]string { type NewSandboxHint struct { state protoimpl.MessageState `protogen:"open.v1"` // Sandbox metadata key/value pairs parsed from the request body. - Metadata map[string]string `protobuf:"bytes,1,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Metadata map[string]string `protobuf:"bytes,1,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Template or snapshot reference supplied by POST /sandboxes. The locality + // strategy treats the exact reference as an advisory workload identity. + TemplateId string `protobuf:"bytes,2,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -401,6 +404,13 @@ func (x *NewSandboxHint) GetMetadata() map[string]string { return nil } +func (x *NewSandboxHint) GetTemplateId() string { + if x != nil { + return x.TemplateId + } + return "" +} + type ScheduleRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Hint *ScheduleRequestHint `protobuf:"bytes,2,opt,name=hint,proto3" json:"hint,omitempty"` @@ -2327,9 +2337,11 @@ const file_api_proto_scheduler_proto_rawDesc = "" + "\bmetadata\x18\x04 \x03(\v2..scheduler.v1.NewColdSandboxHint.MetadataEntryR\bmetadata\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x95\x01\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb6\x01\n" + "\x0eNewSandboxHint\x12F\n" + - "\bmetadata\x18\x01 \x03(\v2*.scheduler.v1.NewSandboxHint.MetadataEntryR\bmetadata\x1a;\n" + + "\bmetadata\x18\x01 \x03(\v2*.scheduler.v1.NewSandboxHint.MetadataEntryR\bmetadata\x12\x1f\n" + + "\vtemplate_id\x18\x02 \x01(\tR\n" + + "templateId\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"N\n" + diff --git a/services/api/proto/scheduler.proto b/services/api/proto/scheduler.proto index 169459f4d..50be7dc4d 100644 --- a/services/api/proto/scheduler.proto +++ b/services/api/proto/scheduler.proto @@ -51,6 +51,9 @@ message NewColdSandboxHint { message NewSandboxHint { // Sandbox metadata key/value pairs parsed from the request body. map metadata = 1; + // Template or snapshot reference supplied by POST /sandboxes. The locality + // strategy treats the exact reference as an advisory workload identity. + string template_id = 2; } message ScheduleRequest { diff --git a/services/gateway/internal/schedule_hint.go b/services/gateway/internal/schedule_hint.go index 44284a274..7eb4b137f 100644 --- a/services/gateway/internal/schedule_hint.go +++ b/services/gateway/internal/schedule_hint.go @@ -134,7 +134,8 @@ func parseNewColdSandboxHint(body []byte) *schedulerv1.NewColdSandboxHint { // newSandboxBody mirrors the subset of NewSandbox (src/api/openapi.yml) that is // relevant for scheduling. type newSandboxBody struct { - Metadata map[string]string `json:"metadata"` + TemplateID string `json:"templateID"` + Metadata map[string]string `json:"metadata"` } // parseNewSandboxHint extracts the structured sandbox hint from the request @@ -149,6 +150,7 @@ func parseNewSandboxHint(body []byte) *schedulerv1.NewSandboxHint { if err := json.Unmarshal(body, &parsed); err != nil { return hint } + hint.TemplateId = parsed.TemplateID hint.Metadata = parsed.Metadata return hint } diff --git a/services/gateway/internal/schedule_hint_test.go b/services/gateway/internal/schedule_hint_test.go index c58392867..b0ead1fea 100644 --- a/services/gateway/internal/schedule_hint_test.go +++ b/services/gateway/internal/schedule_hint_test.go @@ -35,6 +35,9 @@ func TestBuildScheduleHintNewSandbox(t *testing.T) { if hint.GetNewColdSandbox() != nil { t.Fatalf("did not expect cold sandbox hint") } + if got := hint.GetNewSandbox().GetTemplateId(); got != "tmpl" { + t.Fatalf("template_id = %q, want tmpl", got) + } // Body must remain available for the upstream request. body, err := io.ReadAll(r.Body) @@ -158,6 +161,25 @@ func TestParseNewColdSandboxHint(t *testing.T) { }) } +func TestParseNewSandboxHint(t *testing.T) { + t.Run("template and metadata", func(t *testing.T) { + hint := parseNewSandboxHint([]byte(`{"templateID":"tmpl","metadata":{"team":"infra"}}`)) + if got := hint.GetTemplateId(); got != "tmpl" { + t.Fatalf("template_id = %q, want tmpl", got) + } + if got := hint.GetMetadata()["team"]; got != "infra" { + t.Fatalf("metadata team = %q, want infra", got) + } + }) + + t.Run("malformed json", func(t *testing.T) { + hint := parseNewSandboxHint([]byte("{not json")) + if hint.GetTemplateId() != "" || len(hint.GetMetadata()) != 0 { + t.Fatalf("expected empty best-effort hint, got %v", hint) + } + }) +} + func TestCaptureRequestBodyNil(t *testing.T) { r := newHintRequest(t, http.MethodPost, "/sandboxes-cold", "") body, err := captureRequestBody(r) From ecce76bccadc4c8c9678d6dffb51124437ee27a7 Mon Sep 17 00:00:00 2001 From: shudorcl <1985366171@qq.com> Date: Thu, 30 Jul 2026 01:35:18 +0800 Subject: [PATCH 2/8] refactor(scheduler): derive current scheduling status Replace raw heartbeat snapshot peeks with a scheduling snapshot whose node status reflects discovery state and heartbeat TTL. --- services/scheduler/internal/node_registry.go | 51 +++++++++++--------- services/scheduler/internal/service.go | 2 +- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/services/scheduler/internal/node_registry.go b/services/scheduler/internal/node_registry.go index 5596d2980..9542b9263 100644 --- a/services/scheduler/internal/node_registry.go +++ b/services/scheduler/internal/node_registry.go @@ -21,11 +21,9 @@ type NodeRegistry interface { ListP2pPeers(clusterID string, backend string, excludeNodeID string, now time.Time) []*schedulerv1.P2PPeer FilterP2pPeers(clusterID string, backend string, nodeIDs []string, excludeNodeID string, now time.Time) []*schedulerv1.P2PPeer GetObserved(nodeID string, clusterID string, now time.Time) (*schedulerv1.ObservedNode, bool) - // PeekObserved returns the latest heartbeat-reported NodeSnapshot for a node. - // Unlike GetObserved, it does not derive status from discovery state or TTL, - // and returns only the raw snapshot suitable for scheduling decisions. - // Returns nil if the node has never sent a heartbeat. - PeekObserved(nodeID string) *schedulerv1.NodeSnapshot + // SchedulingSnapshot returns the latest NodeSnapshot with status derived + // from current discovery state and heartbeat TTL. + SchedulingSnapshot(nodeID string, now time.Time) *schedulerv1.NodeSnapshot UnregisterObserved(nodeID string, serviceInstanceID string) error } @@ -331,7 +329,9 @@ func (r *AtomicNodeRegistry) GetObserved(nodeID string, clusterID string, now ti return r.deriveObservedNodeViewLocked(record, nowMs), true } -func (r *AtomicNodeRegistry) PeekObserved(nodeID string) *schedulerv1.NodeSnapshot { +func (r *AtomicNodeRegistry) SchedulingSnapshot(nodeID string, now time.Time) *schedulerv1.NodeSnapshot { + nowMs := now.UTC().UnixMilli() + r.mu.RLock() defer r.mu.RUnlock() record, ok := r.observed[nodeID] @@ -342,7 +342,9 @@ func (r *AtomicNodeRegistry) PeekObserved(nodeID string) *schedulerv1.NodeSnapsh if snapshot == nil { return nil } - return cloneSnapshot(snapshot) + out := cloneSnapshot(snapshot) + out.Status = r.derivedStatusLocked(record, nowMs) + return out } func (r *AtomicNodeRegistry) UnregisterObserved(nodeID string, serviceInstanceID string) error { @@ -374,33 +376,38 @@ func (r *AtomicNodeRegistry) deriveObservedNodeViewLocked(record observedNodeRec } nodeID := out.GetNodeId() - knownNode, inDiscovery := r.nodesByID[nodeID] - isLingering := r.lingeringIDs[nodeID] if inDiscovery && strings.TrimSpace(knownNode.Endpoint) != "" { out.Endpoint = knownNode.Endpoint } + out.Snapshot.Status = r.derivedStatusLocked(record, nowMs) + return out +} + +// derivedStatusLocked computes the effective scheduling status for a heartbeat +// record. r.mu must be held by the caller. +func (r *AtomicNodeRegistry) derivedStatusLocked(record observedNodeRecord, nowMs int64) schedulerv1.NodeStatus { ttl := record.reportTTL if ttl <= 0 { ttl = defaultObservedReportTTL } - if out.GetLastSeenUnixMs() > 0 && nowMs-out.GetLastSeenUnixMs() > ttl.Milliseconds() { - out.Snapshot.Status = schedulerv1.NodeStatus_NODE_STATUS_UNHEALTHY - } else if !inDiscovery { - out.Snapshot.Status = schedulerv1.NodeStatus_NODE_STATUS_CONNECTING - } else if isLingering { - out.Snapshot.Status = schedulerv1.NodeStatus_NODE_STATUS_LINGERING - } else { - // Active — keep the status reported by the node. - if out.Snapshot.GetStatus() == schedulerv1.NodeStatus_NODE_STATUS_UNSPECIFIED { - out.Snapshot.Status = schedulerv1.NodeStatus_NODE_STATUS_CONNECTING - } + if lastSeen := record.node.GetLastSeenUnixMs(); lastSeen > 0 && nowMs-lastSeen > ttl.Milliseconds() { + return schedulerv1.NodeStatus_NODE_STATUS_UNHEALTHY } - - return out + nodeID := record.node.GetNodeId() + if _, inDiscovery := r.nodesByID[nodeID]; !inDiscovery { + return schedulerv1.NodeStatus_NODE_STATUS_CONNECTING + } + if r.lingeringIDs[nodeID] { + return schedulerv1.NodeStatus_NODE_STATUS_LINGERING + } + if status := record.node.GetSnapshot().GetStatus(); status != schedulerv1.NodeStatus_NODE_STATUS_UNSPECIFIED { + return status + } + return schedulerv1.NodeStatus_NODE_STATUS_CONNECTING } func cloneObservedNode(node *schedulerv1.ObservedNode) *schedulerv1.ObservedNode { diff --git a/services/scheduler/internal/service.go b/services/scheduler/internal/service.go index a284dfa84..4403d4daf 100644 --- a/services/scheduler/internal/service.go +++ b/services/scheduler/internal/service.go @@ -89,7 +89,7 @@ func (s *Service) Schedule(_ context.Context, req *schedulerv1.ScheduleRequest) for _, n := range discovered { rich = append(rich, RichNode{ Node: n, - Snapshot: s.nodes.PeekObserved(n.ID), + Snapshot: s.nodes.SchedulingSnapshot(n.ID, start), }) } From 41c67ecffd9ee74a276704d100ff60a083a38c57 Mon Sep 17 00:00:00 2001 From: shudorcl <1985366171@qq.com> Date: Thu, 30 Jul 2026 01:35:49 +0800 Subject: [PATCH 3/8] feat(scheduler): add resource-bounded locality strategy Keep requests for the same image or template on an open group node until a sandbox, CPU, or memory budget closes the group. Assign new groups through stable global round-robin, require ready telemetry, and account concurrent placements atomically. Refs #15 --- .../internal/locality_strategy_test.go | 281 ++++++++++++++++++ services/scheduler/internal/service.go | 2 +- services/scheduler/internal/service_test.go | 46 +++ services/scheduler/internal/strategy.go | 237 ++++++++++++++- 4 files changed, 563 insertions(+), 3 deletions(-) create mode 100644 services/scheduler/internal/locality_strategy_test.go diff --git a/services/scheduler/internal/locality_strategy_test.go b/services/scheduler/internal/locality_strategy_test.go new file mode 100644 index 000000000..83a41c748 --- /dev/null +++ b/services/scheduler/internal/locality_strategy_test.go @@ -0,0 +1,281 @@ +package scheduler + +import ( + "fmt" + "sync" + "testing" + + schedulerv1 "agentenv/services/api/proto" +) + +func TestGroupedLocalityInterleavesImageGroups(t *testing.T) { + strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}) + nodes := readyNodes("a", "b", "c") + + got := []string{ + selectNodeID(t, strategy, nodes, coldHint("ubuntu", 0, 0)), + selectNodeID(t, strategy, nodes, coldHint("python", 0, 0)), + selectNodeID(t, strategy, nodes, coldHint("ubuntu", 0, 0)), + selectNodeID(t, strategy, nodes, coldHint("python", 0, 0)), + selectNodeID(t, strategy, nodes, coldHint("ubuntu", 0, 0)), + selectNodeID(t, strategy, nodes, coldHint("python", 0, 0)), + } + want := []string{"a", "b", "a", "b", "c", "a"} + if !equalNodeIDs(got, want) { + t.Fatalf("placements = %v, want %v", got, want) + } +} + +func TestGroupedLocalityClosesGroupAtCPULimit(t *testing.T) { + strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{ + MaxSandboxCount: 10, + MaxCPUCount: 4, + }) + nodes := readyNodes("a", "b") + + got := []string{ + selectNodeID(t, strategy, nodes, coldHint("ubuntu", 3, 0)), + selectNodeID(t, strategy, nodes, coldHint("ubuntu", 2, 0)), + selectNodeID(t, strategy, nodes, coldHint("ubuntu", 2, 0)), + selectNodeID(t, strategy, nodes, coldHint("ubuntu", 1, 0)), + } + want := []string{"a", "b", "b", "a"} + if !equalNodeIDs(got, want) { + t.Fatalf("placements = %v, want %v", got, want) + } +} + +func TestGroupedLocalityClosesGroupAtMemoryLimit(t *testing.T) { + strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{ + MaxSandboxCount: 10, + MaxMemoryMB: 1024, + }) + nodes := readyNodes("a", "b") + + got := []string{ + selectNodeID(t, strategy, nodes, coldHint("ubuntu", 0, 768)), + selectNodeID(t, strategy, nodes, coldHint("ubuntu", 0, 512)), + selectNodeID(t, strategy, nodes, coldHint("ubuntu", 0, 512)), + } + want := []string{"a", "b", "b"} + if !equalNodeIDs(got, want) { + t.Fatalf("placements = %v, want %v", got, want) + } +} + +func TestGroupedLocalityOversizedRequestDoesNotLeaveOpenGroup(t *testing.T) { + strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{ + MaxSandboxCount: 10, + MaxCPUCount: 2, + }) + nodes := readyNodes("a", "b") + + first := selectNodeID(t, strategy, nodes, coldHint("ubuntu", 4, 0)) + second := selectNodeID(t, strategy, nodes, coldHint("ubuntu", 4, 0)) + if first != "a" || second != "b" { + t.Fatalf("oversized placements = %s %s, want a b", first, second) + } + if len(strategy.groups) != 0 { + t.Fatalf("oversized requests left %d open groups, want 0", len(strategy.groups)) + } +} + +func TestGroupedLocalityClosesGroupWhenNodeBecomesIneligible(t *testing.T) { + strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 3}) + nodes := readyNodes("a", "b") + + if got := selectNodeID(t, strategy, nodes, coldHint("ubuntu", 0, 0)); got != "a" { + t.Fatalf("first placement = %s, want a", got) + } + + // The service removes resource-constrained nodes before calling Select. + if got := selectNodeID(t, strategy, readyNodes("b", "c"), coldHint("ubuntu", 0, 0)); got != "b" { + t.Fatalf("replacement placement = %s, want b", got) + } +} + +func TestGroupedLocalitySkipsNodesWithoutReadyHeartbeat(t *testing.T) { + strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}) + nodes := []RichNode{ + {Node: Node{ID: "missing"}}, + { + Node: Node{ID: "unhealthy"}, + Snapshot: &schedulerv1.NodeSnapshot{ + Status: schedulerv1.NodeStatus_NODE_STATUS_UNHEALTHY, + }, + }, + readyNodes("ready")[0], + } + + if got := selectNodeID(t, strategy, nodes, coldHint("ubuntu", 0, 0)); got != "ready" { + t.Fatalf("placement = %s, want ready", got) + } +} + +func TestGroupedLocalityGroupsTemplatesByExactReference(t *testing.T) { + strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}) + nodes := readyNodes("a", "b") + + got := []string{ + selectNodeID(t, strategy, nodes, templateHint("base")), + selectNodeID(t, strategy, nodes, templateHint("alias")), + selectNodeID(t, strategy, nodes, templateHint("base")), + selectNodeID(t, strategy, nodes, templateHint("alias")), + selectNodeID(t, strategy, nodes, templateHint("base")), + } + want := []string{"a", "b", "a", "b", "a"} + if !equalNodeIDs(got, want) { + t.Fatalf("placements = %v, want %v", got, want) + } +} + +func TestGroupedLocalityFallsBackToGlobalRoundRobin(t *testing.T) { + strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}) + nodes := readyNodes("a", "b", "c") + + tooLong := coldHint("x", 0, 0) + tooLong.GetNewColdSandbox().Images[0] = string(make([]byte, maxLocalityGroupKeyBytes+1)) + got := []string{ + selectNodeID(t, strategy, nodes, nil), + selectNodeID(t, strategy, nodes, coldHint("", 0, 0)), + selectNodeID(t, strategy, nodes, tooLong), + } + want := []string{"a", "b", "c"} + if !equalNodeIDs(got, want) { + t.Fatalf("fallback placements = %v, want %v", got, want) + } +} + +func TestGroupedLocalityFallbackDoesNotAdvanceGroupCursor(t *testing.T) { + strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}) + nodes := readyNodes("a", "b") + + if got := selectNodeID(t, strategy, nodes, nil); got != "a" { + t.Fatalf("fallback placement = %s, want a", got) + } + if got := selectNodeID(t, strategy, nodes, coldHint("ubuntu", 0, 0)); got != "a" { + t.Fatalf("first group placement = %s, want a", got) + } +} + +func TestGroupedLocalityBoundsOpenGroupState(t *testing.T) { + strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}) + nodes := readyNodes("a") + + for i := 0; i <= maxOpenLocalityGroups; i++ { + selectNodeID(t, strategy, nodes, coldHint(fmt.Sprintf("image-%d", i), 0, 0)) + } + if got := len(strategy.groups); got != maxOpenLocalityGroups { + t.Fatalf("open group count = %d, want %d", got, maxOpenLocalityGroups) + } + if _, ok := strategy.groups["image:image-0"]; ok { + t.Fatal("oldest open group was not evicted") + } +} + +func TestGroupedLocalityCountsConcurrentPlacementsAtomically(t *testing.T) { + strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 10}) + nodes := readyNodes("a", "b") + + const requests = 200 + type result struct { + nodeID string + err error + } + results := make(chan result, requests) + var wg sync.WaitGroup + wg.Add(requests) + for i := 0; i < requests; i++ { + go func() { + defer wg.Done() + node, err := strategy.Select(nodes, coldHint("ubuntu", 0, 0)) + results <- result{nodeID: node.ID, err: err} + }() + } + wg.Wait() + close(results) + + counts := map[string]int{} + for result := range results { + if result.err != nil { + t.Fatalf("Select returned error: %v", result.err) + } + counts[result.nodeID]++ + } + if counts["a"] != requests/2 || counts["b"] != requests/2 { + t.Fatalf("concurrent placement counts = %v, want equal distribution", counts) + } +} + +func TestNewStrategySelectsLocalityCaseInsensitively(t *testing.T) { + strategy := NewStrategy( + " LOCALITY ", + WithLocalityGroupLimits(LocalityGroupLimits{MaxSandboxCount: 2}), + ) + if strategy.Name() != "locality" { + t.Fatalf("strategy name = %q, want locality", strategy.Name()) + } +} + +func readyNodes(ids ...string) []RichNode { + nodes := make([]RichNode, 0, len(ids)) + for _, id := range ids { + nodes = append(nodes, RichNode{ + Node: Node{ID: id, Endpoint: "http://" + id}, + Snapshot: &schedulerv1.NodeSnapshot{ + Status: schedulerv1.NodeStatus_NODE_STATUS_READY, + }, + }) + } + return nodes +} + +func coldHint(image string, cpuCount uint32, memoryMB uint64) *schedulerv1.ScheduleRequestHint { + images := []string(nil) + if image != "" { + images = []string{image} + } + return &schedulerv1.ScheduleRequestHint{ + Kind: &schedulerv1.ScheduleRequestHint_NewColdSandbox{ + NewColdSandbox: &schedulerv1.NewColdSandboxHint{ + Images: images, + CpuCount: cpuCount, + MemoryMb: memoryMB, + }, + }, + } +} + +func templateHint(templateID string) *schedulerv1.ScheduleRequestHint { + return &schedulerv1.ScheduleRequestHint{ + Kind: &schedulerv1.ScheduleRequestHint_NewSandbox{ + NewSandbox: &schedulerv1.NewSandboxHint{TemplateId: templateID}, + }, + } +} + +func selectNodeID( + t *testing.T, + strategy Strategy, + nodes []RichNode, + hint *schedulerv1.ScheduleRequestHint, +) string { + t.Helper() + node, err := strategy.Select(nodes, hint) + if err != nil { + t.Fatalf("Select returned error: %v", err) + } + return node.ID +} + +func equalNodeIDs(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} diff --git a/services/scheduler/internal/service.go b/services/scheduler/internal/service.go index 4403d4daf..d9a89027d 100644 --- a/services/scheduler/internal/service.go +++ b/services/scheduler/internal/service.go @@ -130,7 +130,7 @@ func summarizeScheduleHint(hint *schedulerv1.ScheduleRequestHint) string { c := k.NewColdSandbox return fmt.Sprintf("new_cold_sandbox cpu=%d memory_mb=%d images=%v", c.GetCpuCount(), c.GetMemoryMb(), c.GetImages()) case *schedulerv1.ScheduleRequestHint_NewSandbox: - return "new_sandbox" + return fmt.Sprintf("new_sandbox template=%q", k.NewSandbox.GetTemplateId()) default: return "none" } diff --git a/services/scheduler/internal/service_test.go b/services/scheduler/internal/service_test.go index 4f326b89e..6a2134273 100644 --- a/services/scheduler/internal/service_test.go +++ b/services/scheduler/internal/service_test.go @@ -232,6 +232,52 @@ func TestScheduleReturnsUnavailableWhenRegistryIsEmpty(t *testing.T) { } } +func TestLocalityScheduleSkipsStaleHeartbeat(t *testing.T) { + registry := NewAtomicNodeRegistry( + []Node{ + {ID: "node-a", Endpoint: "http://node-a"}, + {ID: "node-b", Endpoint: "http://node-b"}, + }, + time.Second, + ) + now := time.Now() + for _, heartbeat := range []struct { + nodeID string + at time.Time + }{ + {nodeID: "node-a", at: now.Add(-time.Minute)}, + {nodeID: "node-b", at: now}, + } { + _, _, err := registry.Heartbeat(&schedulerv1.HeartbeatRequest{ + NodeId: heartbeat.nodeID, + ClusterId: "cluster-1", + ServiceInstanceId: "service-" + heartbeat.nodeID, + Snapshot: &schedulerv1.NodeSnapshot{ + Status: schedulerv1.NodeStatus_NODE_STATUS_READY, + }, + }, heartbeat.at) + if err != nil { + t.Fatalf("heartbeat %s failed: %v", heartbeat.nodeID, err) + } + } + + service := NewService( + zap.NewNop(), + registry, + NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}), + NewInMemoryBindingStore(defaultObservedReportTTL), + ) + response, err := service.Schedule(context.Background(), &schedulerv1.ScheduleRequest{ + Hint: coldHint("ubuntu", 0, 0), + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + if got := response.GetNode().GetNodeId(); got != "node-b" { + t.Fatalf("scheduled node = %q, want node-b", got) + } +} + func TestScheduleOnlyConsidersReadyNodes(t *testing.T) { registry := NewAtomicNodeRegistry(nil, defaultObservedReportTTL) // node-a: active, node-b: lingering diff --git a/services/scheduler/internal/strategy.go b/services/scheduler/internal/strategy.go index 165588bb6..e0b1674b1 100644 --- a/services/scheduler/internal/strategy.go +++ b/services/scheduler/internal/strategy.go @@ -1,8 +1,12 @@ package scheduler import ( + "container/list" "errors" "math/rand" + "sort" + "strings" + "sync" "sync/atomic" schedulerv1 "agentenv/services/api/proto" @@ -48,10 +52,239 @@ func (s *RandomStrategy) Name() string { return "random" } -func NewStrategy(name string) Strategy { - switch name { +// LocalityGroupLimits bounds a same-workload placement group. Zero CPU and +// memory limits disable those checks; MaxSandboxCount is always enforced. +type LocalityGroupLimits struct { + MaxSandboxCount uint32 + MaxCPUCount uint32 + MaxMemoryMB uint64 +} + +type localityRequest struct { + key string + cpuCount uint32 + memoryMB uint64 +} + +type localityGroup struct { + nodeID string + sandboxCount uint32 + cpuCount uint64 + memoryMB uint64 +} + +type localityGroupEntry struct { + key string + group localityGroup +} + +const ( + maxLocalityGroupKeyBytes = 1024 + maxOpenLocalityGroups = 10_000 +) + +// GroupedLocalityStrategy keeps same-workload requests on one node until the +// current group reaches a configured budget. New groups share a global +// round-robin cursor so popular workloads spread progressively across nodes. +type GroupedLocalityStrategy struct { + mu sync.Mutex + lastGroupNodeID string + lastFallbackNodeID string + limits LocalityGroupLimits + groups map[string]*list.Element + lru list.List +} + +func NewGroupedLocalityStrategy(limits LocalityGroupLimits) *GroupedLocalityStrategy { + if limits.MaxSandboxCount == 0 { + // Production config rejects this, but keep direct construction bounded. + limits.MaxSandboxCount = 1 + } + return &GroupedLocalityStrategy{ + limits: limits, + groups: make(map[string]*list.Element), + } +} + +func (s *GroupedLocalityStrategy) Select(nodes []RichNode, hint *schedulerv1.ScheduleRequestHint) (RichNode, error) { + ready := readyLocalityNodes(nodes) + if len(ready) == 0 { + return RichNode{}, ErrNoNodes + } + + request, grouped := localityRequestFromHint(hint) + + s.mu.Lock() + defer s.mu.Unlock() + + if !grouped { + return selectNext(ready, &s.lastFallbackNodeID), nil + } + + if element, ok := s.groups[request.key]; ok { + entry := element.Value.(*localityGroupEntry) + if node, eligible := findNode(ready, entry.group.nodeID); eligible && + groupCanFit(entry.group, request, s.limits) { + addToGroup(&entry.group, request) + if groupIsFull(entry.group, s.limits) { + s.removeGroup(element) + } else { + s.lru.MoveToFront(element) + } + return node, nil + } + s.removeGroup(element) + } + + node := selectNext(ready, &s.lastGroupNodeID) + group := localityGroup{nodeID: node.ID} + addToGroup(&group, request) + if groupCanRemainOpen(group, s.limits) { + s.putGroup(request.key, group) + } + return node, nil +} + +func (s *GroupedLocalityStrategy) Name() string { + return "locality" +} + +func selectNext(nodes []RichNode, lastNodeID *string) RichNode { + index := 0 + if *lastNodeID != "" { + index = sort.Search(len(nodes), func(i int) bool { + return nodes[i].ID > *lastNodeID + }) + if index == len(nodes) { + index = 0 + } + } + node := nodes[index] + *lastNodeID = node.ID + return node +} + +func (s *GroupedLocalityStrategy) putGroup(key string, group localityGroup) { + element := s.lru.PushFront(&localityGroupEntry{key: key, group: group}) + s.groups[key] = element + if len(s.groups) <= maxOpenLocalityGroups { + return + } + s.removeGroup(s.lru.Back()) +} + +func (s *GroupedLocalityStrategy) removeGroup(element *list.Element) { + if element == nil { + return + } + entry := element.Value.(*localityGroupEntry) + delete(s.groups, entry.key) + s.lru.Remove(element) +} + +func readyLocalityNodes(nodes []RichNode) []RichNode { + ready := make([]RichNode, 0, len(nodes)) + for _, node := range nodes { + if node.Snapshot == nil || + node.Snapshot.GetStatus() != schedulerv1.NodeStatus_NODE_STATUS_READY { + continue + } + ready = append(ready, node) + } + sort.Slice(ready, func(i, j int) bool { + return ready[i].ID < ready[j].ID + }) + return ready +} + +func localityRequestFromHint(hint *schedulerv1.ScheduleRequestHint) (localityRequest, bool) { + var request localityRequest + switch kind := hint.GetKind().(type) { + case *schedulerv1.ScheduleRequestHint_NewColdSandbox: + images := kind.NewColdSandbox.GetImages() + if len(images) == 0 { + return localityRequest{}, false + } + request = localityRequest{ + key: "image:" + strings.TrimSpace(images[0]), + cpuCount: kind.NewColdSandbox.GetCpuCount(), + memoryMB: kind.NewColdSandbox.GetMemoryMb(), + } + case *schedulerv1.ScheduleRequestHint_NewSandbox: + request.key = "template:" + strings.TrimSpace(kind.NewSandbox.GetTemplateId()) + default: + return localityRequest{}, false + } + if strings.HasSuffix(request.key, ":") || len(request.key) > maxLocalityGroupKeyBytes { + return localityRequest{}, false + } + return request, true +} + +func findNode(nodes []RichNode, nodeID string) (RichNode, bool) { + for _, node := range nodes { + if node.ID == nodeID { + return node, true + } + } + return RichNode{}, false +} + +func groupCanFit(group localityGroup, request localityRequest, limits LocalityGroupLimits) bool { + if group.sandboxCount >= limits.MaxSandboxCount { + return false + } + if exceedsLimit(group.cpuCount, uint64(request.cpuCount), uint64(limits.MaxCPUCount)) { + return false + } + return !exceedsLimit(group.memoryMB, request.memoryMB, limits.MaxMemoryMB) +} + +func exceedsLimit(current, added, limit uint64) bool { + return limit > 0 && (added > limit || current > limit-added) +} + +func addToGroup(group *localityGroup, request localityRequest) { + group.sandboxCount++ + group.cpuCount += uint64(request.cpuCount) + group.memoryMB += request.memoryMB +} + +func groupIsFull(group localityGroup, limits LocalityGroupLimits) bool { + return group.sandboxCount >= limits.MaxSandboxCount || + (limits.MaxCPUCount > 0 && group.cpuCount >= uint64(limits.MaxCPUCount)) || + (limits.MaxMemoryMB > 0 && group.memoryMB >= limits.MaxMemoryMB) +} + +func groupCanRemainOpen(group localityGroup, limits LocalityGroupLimits) bool { + return !groupIsFull(group, limits) +} + +type strategyOptions struct { + localityGroupLimits LocalityGroupLimits +} + +type StrategyOption func(*strategyOptions) + +func WithLocalityGroupLimits(limits LocalityGroupLimits) StrategyOption { + return func(options *strategyOptions) { + options.localityGroupLimits = limits + } +} + +func NewStrategy(name string, opts ...StrategyOption) Strategy { + options := strategyOptions{ + localityGroupLimits: LocalityGroupLimits{MaxSandboxCount: 1}, + } + for _, opt := range opts { + opt(&options) + } + + switch strings.ToLower(strings.TrimSpace(name)) { case "random": return NewRandomStrategy() + case "locality": + return NewGroupedLocalityStrategy(options.localityGroupLimits) case "round_robin": fallthrough default: From 742aa2df888ccb99cda4b437408a9c99ae31b8d0 Mon Sep 17 00:00:00 2001 From: shudorcl <1985366171@qq.com> Date: Thu, 30 Jul 2026 01:36:12 +0800 Subject: [PATCH 4/8] feat(scheduler): configure locality group limits Wire locality strategy limits through scheduler JSON and environment configuration, validate the mandatory sandbox bound, and document the placement semantics and operator controls. Refs #15 --- services/README.md | 25 +++++++- services/scheduler/cmd/main.go | 9 ++- services/shared/config/config.go | 41 ++++++++++++++ services/shared/config/config_test.go | 82 +++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/services/README.md b/services/README.md index ee2880ca5..59987cb4e 100644 --- a/services/README.md +++ b/services/README.md @@ -10,7 +10,7 @@ Go implementation of a distributed Gateway and pluggable Scheduler for AgentENV. - Gateway resolves `GET /nodes/{id}` via scheduler and proxies to the target node. - Gateway routes sandbox requests by existing sandbox-to-node binding. - Scheduler exposes gRPC API and supports pluggable strategy providers. -- Built-in strategies in v1: round_robin and random. +- Built-in strategies in v1: round_robin, random, and locality. - Scheduler supports both static node configuration and Kubernetes EndpointSlice discovery. - Scheduler sandbox binding store can be in-memory or Redis-backed. - Scheduler can run as a primary read/write service or as query-only replicas that serve only `LookupNode` from Redis. @@ -98,6 +98,9 @@ General config notes: - `SCHEDULER_REDIS_ADDR=` overrides `scheduler.redis_addr` from the environment. - `SCHEDULER_ARTIFACT_STORE_CAPACITY=` overrides `scheduler.artifact_store_capacity` from the environment. - `SCHEDULER_ARTIFACT_LOOKUP_NODE_LIMIT=` overrides `scheduler.artifact_lookup_node_limit` from the environment. +- `SCHEDULER_LOCALITY_GROUP_MAX_SANDBOX_COUNT=` overrides `scheduler.locality_group.max_sandbox_count`. +- `SCHEDULER_LOCALITY_GROUP_MAX_CPU_COUNT=` overrides `scheduler.locality_group.max_cpu_count`. +- `SCHEDULER_LOCALITY_GROUP_MAX_MEMORY_MB=` overrides `scheduler.locality_group.max_memory_mb`. ### Scheduling strategy @@ -107,8 +110,26 @@ General config notes: |---|---| | `round_robin` (default) | Cycles through eligible nodes in stable order | | `random` | Picks a uniformly random eligible node | +| `locality` | Keeps requests for the same image/template in a resource-bounded group, then assigns the next group by global round-robin | -The strategy interface receives `RichNode` values that carry the node identity (ID + endpoint) together with the latest heartbeat `NodeSnapshot` (sandbox counts, CPU, memory, disk metrics). Current built-in strategies ignore the snapshot, but custom strategy implementations can use it for load-aware decisions. +The strategy interface receives `RichNode` values that carry the node identity (ID + endpoint) together with the latest heartbeat `NodeSnapshot` (sandbox counts, CPU, memory, disk metrics). + +The `locality` strategy uses the rootfs image reference from `POST /sandboxes-cold` or the exact template reference from `POST /sandboxes` as its grouping key. Requests for the same key stay on the current group's node until adding another request would exceed any configured group limit. A full group is closed permanently, and the next group is assigned to the next eligible node through one global round-robin cursor shared by all keys. If the open group's node is no longer ready or was removed by the node resource filter, the group closes early. + +Group accounting occurs atomically during scheduling, before the runtime finishes creating the sandbox, so bursts cannot overfill an open group while heartbeat metrics lag. Groups are advisory and in-memory; scheduler restart forgets them. The strategy requires a fresh READY heartbeat and skips nodes with missing or stale telemetry. + +`scheduler.locality_group.max_sandbox_count` is required and must be greater than zero when `scheduler.strategy` is `locality`. CPU and memory limits are optional additional bounds for cold-start requests: + +```json +"strategy": "locality", +"locality_group": { + "max_sandbox_count": 4, + "max_cpu_count": 8, + "max_memory_mb": 16384 +} +``` + +Template-based create requests do not carry CPU or memory values, so their groups are bounded by `max_sandbox_count` only. Image tags and template aliases are used exactly as supplied and may be mutable; a stale grouping hint can reduce cache affinity, but cannot bypass group or node resource limits. ### Node resource limit diff --git a/services/scheduler/cmd/main.go b/services/scheduler/cmd/main.go index 3e8574a5e..d84a8084c 100644 --- a/services/scheduler/cmd/main.go +++ b/services/scheduler/cmd/main.go @@ -69,7 +69,14 @@ func main() { svc := scheduler.NewService( logger, registry, - scheduler.NewStrategy(cfg.Scheduler.Strategy), + scheduler.NewStrategy( + cfg.Scheduler.Strategy, + scheduler.WithLocalityGroupLimits(scheduler.LocalityGroupLimits{ + MaxSandboxCount: cfg.Scheduler.LocalityGroup.MaxSandboxCount, + MaxCPUCount: cfg.Scheduler.LocalityGroup.MaxCPUCount, + MaxMemoryMB: cfg.Scheduler.LocalityGroup.MaxMemoryMB, + }), + ), store, scheduler.WithArtifactStore(scheduler.NewInMemoryArtifactStore( cfg.Scheduler.ArtifactStoreCapacity, diff --git a/services/shared/config/config.go b/services/shared/config/config.go index 7977084e2..7027b5917 100644 --- a/services/shared/config/config.go +++ b/services/shared/config/config.go @@ -57,6 +57,14 @@ type NodeResourceLimit struct { MaxAllocatedMemoryBytesIncludingPaused *uint64 `json:"max_allocated_memory_bytes_including_paused"` } +// LocalityGroupConfig bounds each same-workload placement group. A group is +// closed before adding a sandbox that would exceed any enabled limit. +type LocalityGroupConfig struct { + MaxSandboxCount uint32 `json:"max_sandbox_count"` + MaxCPUCount uint32 `json:"max_cpu_count"` + MaxMemoryMB uint64 `json:"max_memory_mb"` +} + type SchedulerConfig struct { GRPCListenAddr string `json:"grpc_listen_addr"` MetricsListenAddr string `json:"metrics_listen_addr"` @@ -69,6 +77,7 @@ type SchedulerConfig struct { Nodes []Node `json:"nodes"` Discovery SchedulerDiscoveryConfig `json:"discovery"` NodeResourceLimit *NodeResourceLimit `json:"node_resource_limit"` + LocalityGroup LocalityGroupConfig `json:"locality_group"` } func (s *SchedulerConfig) UnmarshalJSON(data []byte) error { @@ -84,6 +93,7 @@ func (s *SchedulerConfig) UnmarshalJSON(data []byte) error { Nodes *[]Node `json:"nodes"` Discovery *SchedulerDiscoveryConfig `json:"discovery"` NodeResourceLimit *NodeResourceLimit `json:"node_resource_limit"` + LocalityGroup *LocalityGroupConfig `json:"locality_group"` } parsed := wire{} @@ -109,6 +119,9 @@ func (s *SchedulerConfig) UnmarshalJSON(data []byte) error { if parsed.NodeResourceLimit != nil { s.NodeResourceLimit = parsed.NodeResourceLimit } + if parsed.LocalityGroup != nil { + s.LocalityGroup = *parsed.LocalityGroup + } if parsed.RedisAddr != nil { s.RedisAddr = *parsed.RedisAddr } @@ -353,6 +366,30 @@ func overrideWithEnv(cfg *Config) error { cfg.Scheduler.ArtifactLookupNodeLimit = limit } + if v := strings.TrimSpace(os.Getenv("SCHEDULER_LOCALITY_GROUP_MAX_SANDBOX_COUNT")); v != "" { + limit, err := strconv.ParseUint(v, 10, 32) + if err != nil { + return fmt.Errorf("invalid SCHEDULER_LOCALITY_GROUP_MAX_SANDBOX_COUNT %q: %w", v, err) + } + cfg.Scheduler.LocalityGroup.MaxSandboxCount = uint32(limit) + } + + if v := strings.TrimSpace(os.Getenv("SCHEDULER_LOCALITY_GROUP_MAX_CPU_COUNT")); v != "" { + limit, err := strconv.ParseUint(v, 10, 32) + if err != nil { + return fmt.Errorf("invalid SCHEDULER_LOCALITY_GROUP_MAX_CPU_COUNT %q: %w", v, err) + } + cfg.Scheduler.LocalityGroup.MaxCPUCount = uint32(limit) + } + + if v := strings.TrimSpace(os.Getenv("SCHEDULER_LOCALITY_GROUP_MAX_MEMORY_MB")); v != "" { + limit, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return fmt.Errorf("invalid SCHEDULER_LOCALITY_GROUP_MAX_MEMORY_MB %q: %w", v, err) + } + cfg.Scheduler.LocalityGroup.MaxMemoryMB = limit + } + if v := strings.TrimSpace(os.Getenv("GATEWAY_REQUEST_TIMEOUT")); v != "" { d, err := time.ParseDuration(v) if err != nil { @@ -443,6 +480,10 @@ func (c Config) validate(schedulerQueryOnly bool) error { } return nil } + if strings.EqualFold(strings.TrimSpace(c.Scheduler.Strategy), "locality") && + c.Scheduler.LocalityGroup.MaxSandboxCount == 0 { + return errors.New("scheduler.locality_group.max_sandbox_count must be greater than zero for locality strategy") + } if c.Scheduler.ArtifactStoreCapacity <= 0 { return errors.New("scheduler.artifact_store_capacity must be greater than zero") } diff --git a/services/shared/config/config_test.go b/services/shared/config/config_test.go index dbe47f0f2..8a7ed8a80 100644 --- a/services/shared/config/config_test.go +++ b/services/shared/config/config_test.go @@ -38,6 +38,88 @@ func TestDefaultSchedulerDiscoveryModeIsStatic(t *testing.T) { if got := cfg.Scheduler.ArtifactLookupNodeLimit; got != 0 { t.Fatalf("expected scheduler artifact lookup node limit 0, got %d", got) } + if got := cfg.Scheduler.LocalityGroup; got != (LocalityGroupConfig{}) { + t.Fatalf("expected zero-value locality group config, got %+v", got) + } +} + +func TestLoadParsesSchedulerLocalityGroup(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "config.json") + content := `{ + "scheduler": { + "strategy": "locality", + "locality_group": { + "max_sandbox_count": 4, + "max_cpu_count": 8, + "max_memory_mb": 16384 + } + } + }` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write config file failed: %v", err) + } + + cfg, err := Load(path, "scheduler") + if err != nil { + t.Fatalf("load config failed: %v", err) + } + want := LocalityGroupConfig{ + MaxSandboxCount: 4, + MaxCPUCount: 8, + MaxMemoryMB: 16384, + } + if got := cfg.Scheduler.LocalityGroup; got != want { + t.Fatalf("locality group = %+v, want %+v", got, want) + } +} + +func TestLoadRejectsLocalityWithoutSandboxGroupLimit(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "config.json") + content := `{ + "scheduler": { + "strategy": "locality", + "locality_group": { + "max_cpu_count": 8, + "max_memory_mb": 16384 + } + } + }` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write config file failed: %v", err) + } + + if _, err := Load(path, "scheduler"); err == nil { + t.Fatal("expected locality strategy without max_sandbox_count to fail") + } +} + +func TestLoadAppliesSchedulerLocalityGroupEnv(t *testing.T) { + t.Setenv("SCHEDULER_STRATEGY", "locality") + t.Setenv("SCHEDULER_LOCALITY_GROUP_MAX_SANDBOX_COUNT", "3") + t.Setenv("SCHEDULER_LOCALITY_GROUP_MAX_CPU_COUNT", "6") + t.Setenv("SCHEDULER_LOCALITY_GROUP_MAX_MEMORY_MB", "12288") + + cfg, err := Load("", "scheduler") + if err != nil { + t.Fatalf("load config failed: %v", err) + } + if got := cfg.Scheduler.LocalityGroup; got != (LocalityGroupConfig{ + MaxSandboxCount: 3, + MaxCPUCount: 6, + MaxMemoryMB: 12288, + }) { + t.Fatalf("unexpected locality group from env: %+v", got) + } +} + +func TestLoadRejectsInvalidSchedulerLocalityGroupEnv(t *testing.T) { + t.Setenv("SCHEDULER_LOCALITY_GROUP_MAX_SANDBOX_COUNT", "-1") + + if _, err := Load("", "scheduler"); err == nil { + t.Fatal("expected invalid locality group env to fail") + } } func TestLoadSchedulerAllowsQueryOnlyWithRedisWithoutNodes(t *testing.T) { From 59e8787877c52bc7eedc63bae7baf18d838379fe Mon Sep 17 00:00:00 2001 From: shudorcl <1985366171@qq.com> Date: Thu, 30 Jul 2026 02:00:23 +0800 Subject: [PATCH 5/8] refactor(scheduler): rename locality strategy Rename the strategy and its configuration surface to grouped_round_robin so the public name describes its resource-bounded grouping and rotation semantics precisely. --- services/README.md | 18 ++-- services/api/proto/scheduler.pb.go | 4 +- services/api/proto/scheduler.proto | 4 +- services/scheduler/cmd/main.go | 8 +- ...o => grouped_round_robin_strategy_test.go} | 62 ++++++------- services/scheduler/internal/service_test.go | 4 +- services/scheduler/internal/strategy.go | 86 +++++++++---------- services/shared/config/config.go | 36 ++++---- services/shared/config/config_test.go | 44 +++++----- 9 files changed, 133 insertions(+), 133 deletions(-) rename services/scheduler/internal/{locality_strategy_test.go => grouped_round_robin_strategy_test.go} (74%) diff --git a/services/README.md b/services/README.md index 59987cb4e..d3798bdcd 100644 --- a/services/README.md +++ b/services/README.md @@ -10,7 +10,7 @@ Go implementation of a distributed Gateway and pluggable Scheduler for AgentENV. - Gateway resolves `GET /nodes/{id}` via scheduler and proxies to the target node. - Gateway routes sandbox requests by existing sandbox-to-node binding. - Scheduler exposes gRPC API and supports pluggable strategy providers. -- Built-in strategies in v1: round_robin, random, and locality. +- Built-in strategies in v1: round_robin, random, and grouped_round_robin. - Scheduler supports both static node configuration and Kubernetes EndpointSlice discovery. - Scheduler sandbox binding store can be in-memory or Redis-backed. - Scheduler can run as a primary read/write service or as query-only replicas that serve only `LookupNode` from Redis. @@ -98,9 +98,9 @@ General config notes: - `SCHEDULER_REDIS_ADDR=` overrides `scheduler.redis_addr` from the environment. - `SCHEDULER_ARTIFACT_STORE_CAPACITY=` overrides `scheduler.artifact_store_capacity` from the environment. - `SCHEDULER_ARTIFACT_LOOKUP_NODE_LIMIT=` overrides `scheduler.artifact_lookup_node_limit` from the environment. -- `SCHEDULER_LOCALITY_GROUP_MAX_SANDBOX_COUNT=` overrides `scheduler.locality_group.max_sandbox_count`. -- `SCHEDULER_LOCALITY_GROUP_MAX_CPU_COUNT=` overrides `scheduler.locality_group.max_cpu_count`. -- `SCHEDULER_LOCALITY_GROUP_MAX_MEMORY_MB=` overrides `scheduler.locality_group.max_memory_mb`. +- `SCHEDULER_GROUPED_ROUND_ROBIN_MAX_SANDBOX_COUNT=` overrides `scheduler.grouped_round_robin.max_sandbox_count`. +- `SCHEDULER_GROUPED_ROUND_ROBIN_MAX_CPU_COUNT=` overrides `scheduler.grouped_round_robin.max_cpu_count`. +- `SCHEDULER_GROUPED_ROUND_ROBIN_MAX_MEMORY_MB=` overrides `scheduler.grouped_round_robin.max_memory_mb`. ### Scheduling strategy @@ -110,19 +110,19 @@ General config notes: |---|---| | `round_robin` (default) | Cycles through eligible nodes in stable order | | `random` | Picks a uniformly random eligible node | -| `locality` | Keeps requests for the same image/template in a resource-bounded group, then assigns the next group by global round-robin | +| `grouped_round_robin` | Keeps requests for the same image/template in a resource-bounded group, then assigns the next group by global round-robin | The strategy interface receives `RichNode` values that carry the node identity (ID + endpoint) together with the latest heartbeat `NodeSnapshot` (sandbox counts, CPU, memory, disk metrics). -The `locality` strategy uses the rootfs image reference from `POST /sandboxes-cold` or the exact template reference from `POST /sandboxes` as its grouping key. Requests for the same key stay on the current group's node until adding another request would exceed any configured group limit. A full group is closed permanently, and the next group is assigned to the next eligible node through one global round-robin cursor shared by all keys. If the open group's node is no longer ready or was removed by the node resource filter, the group closes early. +The `grouped_round_robin` strategy uses the rootfs image reference from `POST /sandboxes-cold` or the exact template reference from `POST /sandboxes` as its grouping key. Requests for the same key stay on the current group's node until adding another request would exceed any configured group limit. A full group is closed permanently, and the next group is assigned to the next eligible node through one global round-robin cursor shared by all keys. If the open group's node is no longer ready or was removed by the node resource filter, the group closes early. Group accounting occurs atomically during scheduling, before the runtime finishes creating the sandbox, so bursts cannot overfill an open group while heartbeat metrics lag. Groups are advisory and in-memory; scheduler restart forgets them. The strategy requires a fresh READY heartbeat and skips nodes with missing or stale telemetry. -`scheduler.locality_group.max_sandbox_count` is required and must be greater than zero when `scheduler.strategy` is `locality`. CPU and memory limits are optional additional bounds for cold-start requests: +`scheduler.grouped_round_robin.max_sandbox_count` is required and must be greater than zero when `scheduler.strategy` is `grouped_round_robin`. CPU and memory limits are optional additional bounds for cold-start requests: ```json -"strategy": "locality", -"locality_group": { +"strategy": "grouped_round_robin", +"grouped_round_robin": { "max_sandbox_count": 4, "max_cpu_count": 8, "max_memory_mb": 16384 diff --git a/services/api/proto/scheduler.pb.go b/services/api/proto/scheduler.pb.go index 0274440b0..b01db82bd 100644 --- a/services/api/proto/scheduler.pb.go +++ b/services/api/proto/scheduler.pb.go @@ -360,8 +360,8 @@ type NewSandboxHint struct { state protoimpl.MessageState `protogen:"open.v1"` // Sandbox metadata key/value pairs parsed from the request body. Metadata map[string]string `protobuf:"bytes,1,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Template or snapshot reference supplied by POST /sandboxes. The locality - // strategy treats the exact reference as an advisory workload identity. + // Template or snapshot reference supplied by POST /sandboxes. The grouped + // round-robin strategy treats the exact reference as a workload identity. TemplateId string `protobuf:"bytes,2,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/services/api/proto/scheduler.proto b/services/api/proto/scheduler.proto index 50be7dc4d..8f330f75c 100644 --- a/services/api/proto/scheduler.proto +++ b/services/api/proto/scheduler.proto @@ -51,8 +51,8 @@ message NewColdSandboxHint { message NewSandboxHint { // Sandbox metadata key/value pairs parsed from the request body. map metadata = 1; - // Template or snapshot reference supplied by POST /sandboxes. The locality - // strategy treats the exact reference as an advisory workload identity. + // Template or snapshot reference supplied by POST /sandboxes. The grouped + // round-robin strategy treats the exact reference as a workload identity. string template_id = 2; } diff --git a/services/scheduler/cmd/main.go b/services/scheduler/cmd/main.go index d84a8084c..2a672170e 100644 --- a/services/scheduler/cmd/main.go +++ b/services/scheduler/cmd/main.go @@ -71,10 +71,10 @@ func main() { registry, scheduler.NewStrategy( cfg.Scheduler.Strategy, - scheduler.WithLocalityGroupLimits(scheduler.LocalityGroupLimits{ - MaxSandboxCount: cfg.Scheduler.LocalityGroup.MaxSandboxCount, - MaxCPUCount: cfg.Scheduler.LocalityGroup.MaxCPUCount, - MaxMemoryMB: cfg.Scheduler.LocalityGroup.MaxMemoryMB, + scheduler.WithGroupedRoundRobinLimits(scheduler.GroupedRoundRobinLimits{ + MaxSandboxCount: cfg.Scheduler.GroupedRoundRobin.MaxSandboxCount, + MaxCPUCount: cfg.Scheduler.GroupedRoundRobin.MaxCPUCount, + MaxMemoryMB: cfg.Scheduler.GroupedRoundRobin.MaxMemoryMB, }), ), store, diff --git a/services/scheduler/internal/locality_strategy_test.go b/services/scheduler/internal/grouped_round_robin_strategy_test.go similarity index 74% rename from services/scheduler/internal/locality_strategy_test.go rename to services/scheduler/internal/grouped_round_robin_strategy_test.go index 83a41c748..c1af198ef 100644 --- a/services/scheduler/internal/locality_strategy_test.go +++ b/services/scheduler/internal/grouped_round_robin_strategy_test.go @@ -8,8 +8,8 @@ import ( schedulerv1 "agentenv/services/api/proto" ) -func TestGroupedLocalityInterleavesImageGroups(t *testing.T) { - strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}) +func TestGroupedRoundRobinInterleavesImageGroups(t *testing.T) { + strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{MaxSandboxCount: 2}) nodes := readyNodes("a", "b", "c") got := []string{ @@ -26,8 +26,8 @@ func TestGroupedLocalityInterleavesImageGroups(t *testing.T) { } } -func TestGroupedLocalityClosesGroupAtCPULimit(t *testing.T) { - strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{ +func TestGroupedRoundRobinClosesGroupAtCPULimit(t *testing.T) { + strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{ MaxSandboxCount: 10, MaxCPUCount: 4, }) @@ -45,8 +45,8 @@ func TestGroupedLocalityClosesGroupAtCPULimit(t *testing.T) { } } -func TestGroupedLocalityClosesGroupAtMemoryLimit(t *testing.T) { - strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{ +func TestGroupedRoundRobinClosesGroupAtMemoryLimit(t *testing.T) { + strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{ MaxSandboxCount: 10, MaxMemoryMB: 1024, }) @@ -63,8 +63,8 @@ func TestGroupedLocalityClosesGroupAtMemoryLimit(t *testing.T) { } } -func TestGroupedLocalityOversizedRequestDoesNotLeaveOpenGroup(t *testing.T) { - strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{ +func TestGroupedRoundRobinOversizedRequestDoesNotLeaveOpenGroup(t *testing.T) { + strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{ MaxSandboxCount: 10, MaxCPUCount: 2, }) @@ -80,8 +80,8 @@ func TestGroupedLocalityOversizedRequestDoesNotLeaveOpenGroup(t *testing.T) { } } -func TestGroupedLocalityClosesGroupWhenNodeBecomesIneligible(t *testing.T) { - strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 3}) +func TestGroupedRoundRobinClosesGroupWhenNodeBecomesIneligible(t *testing.T) { + strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{MaxSandboxCount: 3}) nodes := readyNodes("a", "b") if got := selectNodeID(t, strategy, nodes, coldHint("ubuntu", 0, 0)); got != "a" { @@ -94,8 +94,8 @@ func TestGroupedLocalityClosesGroupWhenNodeBecomesIneligible(t *testing.T) { } } -func TestGroupedLocalitySkipsNodesWithoutReadyHeartbeat(t *testing.T) { - strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}) +func TestGroupedRoundRobinSkipsNodesWithoutReadyHeartbeat(t *testing.T) { + strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{MaxSandboxCount: 2}) nodes := []RichNode{ {Node: Node{ID: "missing"}}, { @@ -112,8 +112,8 @@ func TestGroupedLocalitySkipsNodesWithoutReadyHeartbeat(t *testing.T) { } } -func TestGroupedLocalityGroupsTemplatesByExactReference(t *testing.T) { - strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}) +func TestGroupedRoundRobinGroupsTemplatesByExactReference(t *testing.T) { + strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{MaxSandboxCount: 2}) nodes := readyNodes("a", "b") got := []string{ @@ -129,12 +129,12 @@ func TestGroupedLocalityGroupsTemplatesByExactReference(t *testing.T) { } } -func TestGroupedLocalityFallsBackToGlobalRoundRobin(t *testing.T) { - strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}) +func TestGroupedRoundRobinFallsBackToGlobalRoundRobin(t *testing.T) { + strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{MaxSandboxCount: 2}) nodes := readyNodes("a", "b", "c") tooLong := coldHint("x", 0, 0) - tooLong.GetNewColdSandbox().Images[0] = string(make([]byte, maxLocalityGroupKeyBytes+1)) + tooLong.GetNewColdSandbox().Images[0] = string(make([]byte, maxGroupedRoundRobinKeyBytes+1)) got := []string{ selectNodeID(t, strategy, nodes, nil), selectNodeID(t, strategy, nodes, coldHint("", 0, 0)), @@ -146,8 +146,8 @@ func TestGroupedLocalityFallsBackToGlobalRoundRobin(t *testing.T) { } } -func TestGroupedLocalityFallbackDoesNotAdvanceGroupCursor(t *testing.T) { - strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}) +func TestGroupedRoundRobinFallbackDoesNotAdvanceGroupCursor(t *testing.T) { + strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{MaxSandboxCount: 2}) nodes := readyNodes("a", "b") if got := selectNodeID(t, strategy, nodes, nil); got != "a" { @@ -158,23 +158,23 @@ func TestGroupedLocalityFallbackDoesNotAdvanceGroupCursor(t *testing.T) { } } -func TestGroupedLocalityBoundsOpenGroupState(t *testing.T) { - strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}) +func TestGroupedRoundRobinBoundsOpenGroupState(t *testing.T) { + strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{MaxSandboxCount: 2}) nodes := readyNodes("a") - for i := 0; i <= maxOpenLocalityGroups; i++ { + for i := 0; i <= maxOpenGroupedRoundRobinGroups; i++ { selectNodeID(t, strategy, nodes, coldHint(fmt.Sprintf("image-%d", i), 0, 0)) } - if got := len(strategy.groups); got != maxOpenLocalityGroups { - t.Fatalf("open group count = %d, want %d", got, maxOpenLocalityGroups) + if got := len(strategy.groups); got != maxOpenGroupedRoundRobinGroups { + t.Fatalf("open group count = %d, want %d", got, maxOpenGroupedRoundRobinGroups) } if _, ok := strategy.groups["image:image-0"]; ok { t.Fatal("oldest open group was not evicted") } } -func TestGroupedLocalityCountsConcurrentPlacementsAtomically(t *testing.T) { - strategy := NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 10}) +func TestGroupedRoundRobinCountsConcurrentPlacementsAtomically(t *testing.T) { + strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{MaxSandboxCount: 10}) nodes := readyNodes("a", "b") const requests = 200 @@ -207,13 +207,13 @@ func TestGroupedLocalityCountsConcurrentPlacementsAtomically(t *testing.T) { } } -func TestNewStrategySelectsLocalityCaseInsensitively(t *testing.T) { +func TestNewStrategySelectsGroupedRoundRobinCaseInsensitively(t *testing.T) { strategy := NewStrategy( - " LOCALITY ", - WithLocalityGroupLimits(LocalityGroupLimits{MaxSandboxCount: 2}), + " GROUPED_ROUND_ROBIN ", + WithGroupedRoundRobinLimits(GroupedRoundRobinLimits{MaxSandboxCount: 2}), ) - if strategy.Name() != "locality" { - t.Fatalf("strategy name = %q, want locality", strategy.Name()) + if strategy.Name() != "grouped_round_robin" { + t.Fatalf("strategy name = %q, want grouped_round_robin", strategy.Name()) } } diff --git a/services/scheduler/internal/service_test.go b/services/scheduler/internal/service_test.go index 6a2134273..f9979ba03 100644 --- a/services/scheduler/internal/service_test.go +++ b/services/scheduler/internal/service_test.go @@ -232,7 +232,7 @@ func TestScheduleReturnsUnavailableWhenRegistryIsEmpty(t *testing.T) { } } -func TestLocalityScheduleSkipsStaleHeartbeat(t *testing.T) { +func TestGroupedRoundRobinScheduleSkipsStaleHeartbeat(t *testing.T) { registry := NewAtomicNodeRegistry( []Node{ {ID: "node-a", Endpoint: "http://node-a"}, @@ -264,7 +264,7 @@ func TestLocalityScheduleSkipsStaleHeartbeat(t *testing.T) { service := NewService( zap.NewNop(), registry, - NewGroupedLocalityStrategy(LocalityGroupLimits{MaxSandboxCount: 2}), + NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{MaxSandboxCount: 2}), NewInMemoryBindingStore(defaultObservedReportTTL), ) response, err := service.Schedule(context.Background(), &schedulerv1.ScheduleRequest{ diff --git a/services/scheduler/internal/strategy.go b/services/scheduler/internal/strategy.go index e0b1674b1..f3a0727bb 100644 --- a/services/scheduler/internal/strategy.go +++ b/services/scheduler/internal/strategy.go @@ -52,67 +52,67 @@ func (s *RandomStrategy) Name() string { return "random" } -// LocalityGroupLimits bounds a same-workload placement group. Zero CPU and +// GroupedRoundRobinLimits bounds a same-workload placement group. Zero CPU and // memory limits disable those checks; MaxSandboxCount is always enforced. -type LocalityGroupLimits struct { +type GroupedRoundRobinLimits struct { MaxSandboxCount uint32 MaxCPUCount uint32 MaxMemoryMB uint64 } -type localityRequest struct { +type groupedRoundRobinRequest struct { key string cpuCount uint32 memoryMB uint64 } -type localityGroup struct { +type groupedRoundRobinGroup struct { nodeID string sandboxCount uint32 cpuCount uint64 memoryMB uint64 } -type localityGroupEntry struct { +type groupedRoundRobinGroupEntry struct { key string - group localityGroup + group groupedRoundRobinGroup } const ( - maxLocalityGroupKeyBytes = 1024 - maxOpenLocalityGroups = 10_000 + maxGroupedRoundRobinKeyBytes = 1024 + maxOpenGroupedRoundRobinGroups = 10_000 ) -// GroupedLocalityStrategy keeps same-workload requests on one node until the +// GroupedRoundRobinStrategy keeps same-workload requests on one node until the // current group reaches a configured budget. New groups share a global // round-robin cursor so popular workloads spread progressively across nodes. -type GroupedLocalityStrategy struct { +type GroupedRoundRobinStrategy struct { mu sync.Mutex lastGroupNodeID string lastFallbackNodeID string - limits LocalityGroupLimits + limits GroupedRoundRobinLimits groups map[string]*list.Element lru list.List } -func NewGroupedLocalityStrategy(limits LocalityGroupLimits) *GroupedLocalityStrategy { +func NewGroupedRoundRobinStrategy(limits GroupedRoundRobinLimits) *GroupedRoundRobinStrategy { if limits.MaxSandboxCount == 0 { // Production config rejects this, but keep direct construction bounded. limits.MaxSandboxCount = 1 } - return &GroupedLocalityStrategy{ + return &GroupedRoundRobinStrategy{ limits: limits, groups: make(map[string]*list.Element), } } -func (s *GroupedLocalityStrategy) Select(nodes []RichNode, hint *schedulerv1.ScheduleRequestHint) (RichNode, error) { - ready := readyLocalityNodes(nodes) +func (s *GroupedRoundRobinStrategy) Select(nodes []RichNode, hint *schedulerv1.ScheduleRequestHint) (RichNode, error) { + ready := readyGroupedRoundRobinNodes(nodes) if len(ready) == 0 { return RichNode{}, ErrNoNodes } - request, grouped := localityRequestFromHint(hint) + request, grouped := groupedRoundRobinRequestFromHint(hint) s.mu.Lock() defer s.mu.Unlock() @@ -122,7 +122,7 @@ func (s *GroupedLocalityStrategy) Select(nodes []RichNode, hint *schedulerv1.Sch } if element, ok := s.groups[request.key]; ok { - entry := element.Value.(*localityGroupEntry) + entry := element.Value.(*groupedRoundRobinGroupEntry) if node, eligible := findNode(ready, entry.group.nodeID); eligible && groupCanFit(entry.group, request, s.limits) { addToGroup(&entry.group, request) @@ -137,7 +137,7 @@ func (s *GroupedLocalityStrategy) Select(nodes []RichNode, hint *schedulerv1.Sch } node := selectNext(ready, &s.lastGroupNodeID) - group := localityGroup{nodeID: node.ID} + group := groupedRoundRobinGroup{nodeID: node.ID} addToGroup(&group, request) if groupCanRemainOpen(group, s.limits) { s.putGroup(request.key, group) @@ -145,8 +145,8 @@ func (s *GroupedLocalityStrategy) Select(nodes []RichNode, hint *schedulerv1.Sch return node, nil } -func (s *GroupedLocalityStrategy) Name() string { - return "locality" +func (s *GroupedRoundRobinStrategy) Name() string { + return "grouped_round_robin" } func selectNext(nodes []RichNode, lastNodeID *string) RichNode { @@ -164,25 +164,25 @@ func selectNext(nodes []RichNode, lastNodeID *string) RichNode { return node } -func (s *GroupedLocalityStrategy) putGroup(key string, group localityGroup) { - element := s.lru.PushFront(&localityGroupEntry{key: key, group: group}) +func (s *GroupedRoundRobinStrategy) putGroup(key string, group groupedRoundRobinGroup) { + element := s.lru.PushFront(&groupedRoundRobinGroupEntry{key: key, group: group}) s.groups[key] = element - if len(s.groups) <= maxOpenLocalityGroups { + if len(s.groups) <= maxOpenGroupedRoundRobinGroups { return } s.removeGroup(s.lru.Back()) } -func (s *GroupedLocalityStrategy) removeGroup(element *list.Element) { +func (s *GroupedRoundRobinStrategy) removeGroup(element *list.Element) { if element == nil { return } - entry := element.Value.(*localityGroupEntry) + entry := element.Value.(*groupedRoundRobinGroupEntry) delete(s.groups, entry.key) s.lru.Remove(element) } -func readyLocalityNodes(nodes []RichNode) []RichNode { +func readyGroupedRoundRobinNodes(nodes []RichNode) []RichNode { ready := make([]RichNode, 0, len(nodes)) for _, node := range nodes { if node.Snapshot == nil || @@ -197,15 +197,15 @@ func readyLocalityNodes(nodes []RichNode) []RichNode { return ready } -func localityRequestFromHint(hint *schedulerv1.ScheduleRequestHint) (localityRequest, bool) { - var request localityRequest +func groupedRoundRobinRequestFromHint(hint *schedulerv1.ScheduleRequestHint) (groupedRoundRobinRequest, bool) { + var request groupedRoundRobinRequest switch kind := hint.GetKind().(type) { case *schedulerv1.ScheduleRequestHint_NewColdSandbox: images := kind.NewColdSandbox.GetImages() if len(images) == 0 { - return localityRequest{}, false + return groupedRoundRobinRequest{}, false } - request = localityRequest{ + request = groupedRoundRobinRequest{ key: "image:" + strings.TrimSpace(images[0]), cpuCount: kind.NewColdSandbox.GetCpuCount(), memoryMB: kind.NewColdSandbox.GetMemoryMb(), @@ -213,10 +213,10 @@ func localityRequestFromHint(hint *schedulerv1.ScheduleRequestHint) (localityReq case *schedulerv1.ScheduleRequestHint_NewSandbox: request.key = "template:" + strings.TrimSpace(kind.NewSandbox.GetTemplateId()) default: - return localityRequest{}, false + return groupedRoundRobinRequest{}, false } - if strings.HasSuffix(request.key, ":") || len(request.key) > maxLocalityGroupKeyBytes { - return localityRequest{}, false + if strings.HasSuffix(request.key, ":") || len(request.key) > maxGroupedRoundRobinKeyBytes { + return groupedRoundRobinRequest{}, false } return request, true } @@ -230,7 +230,7 @@ func findNode(nodes []RichNode, nodeID string) (RichNode, bool) { return RichNode{}, false } -func groupCanFit(group localityGroup, request localityRequest, limits LocalityGroupLimits) bool { +func groupCanFit(group groupedRoundRobinGroup, request groupedRoundRobinRequest, limits GroupedRoundRobinLimits) bool { if group.sandboxCount >= limits.MaxSandboxCount { return false } @@ -244,37 +244,37 @@ func exceedsLimit(current, added, limit uint64) bool { return limit > 0 && (added > limit || current > limit-added) } -func addToGroup(group *localityGroup, request localityRequest) { +func addToGroup(group *groupedRoundRobinGroup, request groupedRoundRobinRequest) { group.sandboxCount++ group.cpuCount += uint64(request.cpuCount) group.memoryMB += request.memoryMB } -func groupIsFull(group localityGroup, limits LocalityGroupLimits) bool { +func groupIsFull(group groupedRoundRobinGroup, limits GroupedRoundRobinLimits) bool { return group.sandboxCount >= limits.MaxSandboxCount || (limits.MaxCPUCount > 0 && group.cpuCount >= uint64(limits.MaxCPUCount)) || (limits.MaxMemoryMB > 0 && group.memoryMB >= limits.MaxMemoryMB) } -func groupCanRemainOpen(group localityGroup, limits LocalityGroupLimits) bool { +func groupCanRemainOpen(group groupedRoundRobinGroup, limits GroupedRoundRobinLimits) bool { return !groupIsFull(group, limits) } type strategyOptions struct { - localityGroupLimits LocalityGroupLimits + groupedRoundRobinLimits GroupedRoundRobinLimits } type StrategyOption func(*strategyOptions) -func WithLocalityGroupLimits(limits LocalityGroupLimits) StrategyOption { +func WithGroupedRoundRobinLimits(limits GroupedRoundRobinLimits) StrategyOption { return func(options *strategyOptions) { - options.localityGroupLimits = limits + options.groupedRoundRobinLimits = limits } } func NewStrategy(name string, opts ...StrategyOption) Strategy { options := strategyOptions{ - localityGroupLimits: LocalityGroupLimits{MaxSandboxCount: 1}, + groupedRoundRobinLimits: GroupedRoundRobinLimits{MaxSandboxCount: 1}, } for _, opt := range opts { opt(&options) @@ -283,8 +283,8 @@ func NewStrategy(name string, opts ...StrategyOption) Strategy { switch strings.ToLower(strings.TrimSpace(name)) { case "random": return NewRandomStrategy() - case "locality": - return NewGroupedLocalityStrategy(options.localityGroupLimits) + case "grouped_round_robin": + return NewGroupedRoundRobinStrategy(options.groupedRoundRobinLimits) case "round_robin": fallthrough default: diff --git a/services/shared/config/config.go b/services/shared/config/config.go index 7027b5917..18610eb25 100644 --- a/services/shared/config/config.go +++ b/services/shared/config/config.go @@ -57,9 +57,9 @@ type NodeResourceLimit struct { MaxAllocatedMemoryBytesIncludingPaused *uint64 `json:"max_allocated_memory_bytes_including_paused"` } -// LocalityGroupConfig bounds each same-workload placement group. A group is +// GroupedRoundRobinConfig bounds each same-workload placement group. A group is // closed before adding a sandbox that would exceed any enabled limit. -type LocalityGroupConfig struct { +type GroupedRoundRobinConfig struct { MaxSandboxCount uint32 `json:"max_sandbox_count"` MaxCPUCount uint32 `json:"max_cpu_count"` MaxMemoryMB uint64 `json:"max_memory_mb"` @@ -77,7 +77,7 @@ type SchedulerConfig struct { Nodes []Node `json:"nodes"` Discovery SchedulerDiscoveryConfig `json:"discovery"` NodeResourceLimit *NodeResourceLimit `json:"node_resource_limit"` - LocalityGroup LocalityGroupConfig `json:"locality_group"` + GroupedRoundRobin GroupedRoundRobinConfig `json:"grouped_round_robin"` } func (s *SchedulerConfig) UnmarshalJSON(data []byte) error { @@ -93,7 +93,7 @@ func (s *SchedulerConfig) UnmarshalJSON(data []byte) error { Nodes *[]Node `json:"nodes"` Discovery *SchedulerDiscoveryConfig `json:"discovery"` NodeResourceLimit *NodeResourceLimit `json:"node_resource_limit"` - LocalityGroup *LocalityGroupConfig `json:"locality_group"` + GroupedRoundRobin *GroupedRoundRobinConfig `json:"grouped_round_robin"` } parsed := wire{} @@ -119,8 +119,8 @@ func (s *SchedulerConfig) UnmarshalJSON(data []byte) error { if parsed.NodeResourceLimit != nil { s.NodeResourceLimit = parsed.NodeResourceLimit } - if parsed.LocalityGroup != nil { - s.LocalityGroup = *parsed.LocalityGroup + if parsed.GroupedRoundRobin != nil { + s.GroupedRoundRobin = *parsed.GroupedRoundRobin } if parsed.RedisAddr != nil { s.RedisAddr = *parsed.RedisAddr @@ -366,28 +366,28 @@ func overrideWithEnv(cfg *Config) error { cfg.Scheduler.ArtifactLookupNodeLimit = limit } - if v := strings.TrimSpace(os.Getenv("SCHEDULER_LOCALITY_GROUP_MAX_SANDBOX_COUNT")); v != "" { + if v := strings.TrimSpace(os.Getenv("SCHEDULER_GROUPED_ROUND_ROBIN_MAX_SANDBOX_COUNT")); v != "" { limit, err := strconv.ParseUint(v, 10, 32) if err != nil { - return fmt.Errorf("invalid SCHEDULER_LOCALITY_GROUP_MAX_SANDBOX_COUNT %q: %w", v, err) + return fmt.Errorf("invalid SCHEDULER_GROUPED_ROUND_ROBIN_MAX_SANDBOX_COUNT %q: %w", v, err) } - cfg.Scheduler.LocalityGroup.MaxSandboxCount = uint32(limit) + cfg.Scheduler.GroupedRoundRobin.MaxSandboxCount = uint32(limit) } - if v := strings.TrimSpace(os.Getenv("SCHEDULER_LOCALITY_GROUP_MAX_CPU_COUNT")); v != "" { + if v := strings.TrimSpace(os.Getenv("SCHEDULER_GROUPED_ROUND_ROBIN_MAX_CPU_COUNT")); v != "" { limit, err := strconv.ParseUint(v, 10, 32) if err != nil { - return fmt.Errorf("invalid SCHEDULER_LOCALITY_GROUP_MAX_CPU_COUNT %q: %w", v, err) + return fmt.Errorf("invalid SCHEDULER_GROUPED_ROUND_ROBIN_MAX_CPU_COUNT %q: %w", v, err) } - cfg.Scheduler.LocalityGroup.MaxCPUCount = uint32(limit) + cfg.Scheduler.GroupedRoundRobin.MaxCPUCount = uint32(limit) } - if v := strings.TrimSpace(os.Getenv("SCHEDULER_LOCALITY_GROUP_MAX_MEMORY_MB")); v != "" { + if v := strings.TrimSpace(os.Getenv("SCHEDULER_GROUPED_ROUND_ROBIN_MAX_MEMORY_MB")); v != "" { limit, err := strconv.ParseUint(v, 10, 64) if err != nil { - return fmt.Errorf("invalid SCHEDULER_LOCALITY_GROUP_MAX_MEMORY_MB %q: %w", v, err) + return fmt.Errorf("invalid SCHEDULER_GROUPED_ROUND_ROBIN_MAX_MEMORY_MB %q: %w", v, err) } - cfg.Scheduler.LocalityGroup.MaxMemoryMB = limit + cfg.Scheduler.GroupedRoundRobin.MaxMemoryMB = limit } if v := strings.TrimSpace(os.Getenv("GATEWAY_REQUEST_TIMEOUT")); v != "" { @@ -480,9 +480,9 @@ func (c Config) validate(schedulerQueryOnly bool) error { } return nil } - if strings.EqualFold(strings.TrimSpace(c.Scheduler.Strategy), "locality") && - c.Scheduler.LocalityGroup.MaxSandboxCount == 0 { - return errors.New("scheduler.locality_group.max_sandbox_count must be greater than zero for locality strategy") + if strings.EqualFold(strings.TrimSpace(c.Scheduler.Strategy), "grouped_round_robin") && + c.Scheduler.GroupedRoundRobin.MaxSandboxCount == 0 { + return errors.New("scheduler.grouped_round_robin.max_sandbox_count must be greater than zero for grouped_round_robin strategy") } if c.Scheduler.ArtifactStoreCapacity <= 0 { return errors.New("scheduler.artifact_store_capacity must be greater than zero") diff --git a/services/shared/config/config_test.go b/services/shared/config/config_test.go index 8a7ed8a80..73a58dae4 100644 --- a/services/shared/config/config_test.go +++ b/services/shared/config/config_test.go @@ -38,18 +38,18 @@ func TestDefaultSchedulerDiscoveryModeIsStatic(t *testing.T) { if got := cfg.Scheduler.ArtifactLookupNodeLimit; got != 0 { t.Fatalf("expected scheduler artifact lookup node limit 0, got %d", got) } - if got := cfg.Scheduler.LocalityGroup; got != (LocalityGroupConfig{}) { - t.Fatalf("expected zero-value locality group config, got %+v", got) + if got := cfg.Scheduler.GroupedRoundRobin; got != (GroupedRoundRobinConfig{}) { + t.Fatalf("expected zero-value grouped round-robin config, got %+v", got) } } -func TestLoadParsesSchedulerLocalityGroup(t *testing.T) { +func TestLoadParsesSchedulerGroupedRoundRobin(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.json") content := `{ "scheduler": { - "strategy": "locality", - "locality_group": { + "strategy": "grouped_round_robin", + "grouped_round_robin": { "max_sandbox_count": 4, "max_cpu_count": 8, "max_memory_mb": 16384 @@ -64,23 +64,23 @@ func TestLoadParsesSchedulerLocalityGroup(t *testing.T) { if err != nil { t.Fatalf("load config failed: %v", err) } - want := LocalityGroupConfig{ + want := GroupedRoundRobinConfig{ MaxSandboxCount: 4, MaxCPUCount: 8, MaxMemoryMB: 16384, } - if got := cfg.Scheduler.LocalityGroup; got != want { - t.Fatalf("locality group = %+v, want %+v", got, want) + if got := cfg.Scheduler.GroupedRoundRobin; got != want { + t.Fatalf("grouped round-robin config = %+v, want %+v", got, want) } } -func TestLoadRejectsLocalityWithoutSandboxGroupLimit(t *testing.T) { +func TestLoadRejectsGroupedRoundRobinWithoutSandboxGroupLimit(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.json") content := `{ "scheduler": { - "strategy": "locality", - "locality_group": { + "strategy": "grouped_round_robin", + "grouped_round_robin": { "max_cpu_count": 8, "max_memory_mb": 16384 } @@ -91,34 +91,34 @@ func TestLoadRejectsLocalityWithoutSandboxGroupLimit(t *testing.T) { } if _, err := Load(path, "scheduler"); err == nil { - t.Fatal("expected locality strategy without max_sandbox_count to fail") + t.Fatal("expected grouped_round_robin strategy without max_sandbox_count to fail") } } -func TestLoadAppliesSchedulerLocalityGroupEnv(t *testing.T) { - t.Setenv("SCHEDULER_STRATEGY", "locality") - t.Setenv("SCHEDULER_LOCALITY_GROUP_MAX_SANDBOX_COUNT", "3") - t.Setenv("SCHEDULER_LOCALITY_GROUP_MAX_CPU_COUNT", "6") - t.Setenv("SCHEDULER_LOCALITY_GROUP_MAX_MEMORY_MB", "12288") +func TestLoadAppliesSchedulerGroupedRoundRobinEnv(t *testing.T) { + t.Setenv("SCHEDULER_STRATEGY", "grouped_round_robin") + t.Setenv("SCHEDULER_GROUPED_ROUND_ROBIN_MAX_SANDBOX_COUNT", "3") + t.Setenv("SCHEDULER_GROUPED_ROUND_ROBIN_MAX_CPU_COUNT", "6") + t.Setenv("SCHEDULER_GROUPED_ROUND_ROBIN_MAX_MEMORY_MB", "12288") cfg, err := Load("", "scheduler") if err != nil { t.Fatalf("load config failed: %v", err) } - if got := cfg.Scheduler.LocalityGroup; got != (LocalityGroupConfig{ + if got := cfg.Scheduler.GroupedRoundRobin; got != (GroupedRoundRobinConfig{ MaxSandboxCount: 3, MaxCPUCount: 6, MaxMemoryMB: 12288, }) { - t.Fatalf("unexpected locality group from env: %+v", got) + t.Fatalf("unexpected grouped round-robin config from env: %+v", got) } } -func TestLoadRejectsInvalidSchedulerLocalityGroupEnv(t *testing.T) { - t.Setenv("SCHEDULER_LOCALITY_GROUP_MAX_SANDBOX_COUNT", "-1") +func TestLoadRejectsInvalidSchedulerGroupedRoundRobinEnv(t *testing.T) { + t.Setenv("SCHEDULER_GROUPED_ROUND_ROBIN_MAX_SANDBOX_COUNT", "-1") if _, err := Load("", "scheduler"); err == nil { - t.Fatal("expected invalid locality group env to fail") + t.Fatal("expected invalid grouped round-robin env to fail") } } From f2b8e654319e6a11dbc175d1e1c45d91fc6fb424 Mon Sep 17 00:00:00 2001 From: shudorcl <1985366171@qq.com> Date: Thu, 30 Jul 2026 02:25:07 +0800 Subject: [PATCH 6/8] refactor(scheduler): inline group fullness check --- services/scheduler/internal/strategy.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/services/scheduler/internal/strategy.go b/services/scheduler/internal/strategy.go index f3a0727bb..5b5fee555 100644 --- a/services/scheduler/internal/strategy.go +++ b/services/scheduler/internal/strategy.go @@ -139,7 +139,7 @@ func (s *GroupedRoundRobinStrategy) Select(nodes []RichNode, hint *schedulerv1.S node := selectNext(ready, &s.lastGroupNodeID) group := groupedRoundRobinGroup{nodeID: node.ID} addToGroup(&group, request) - if groupCanRemainOpen(group, s.limits) { + if !groupIsFull(group, s.limits) { s.putGroup(request.key, group) } return node, nil @@ -256,10 +256,6 @@ func groupIsFull(group groupedRoundRobinGroup, limits GroupedRoundRobinLimits) b (limits.MaxMemoryMB > 0 && group.memoryMB >= limits.MaxMemoryMB) } -func groupCanRemainOpen(group groupedRoundRobinGroup, limits GroupedRoundRobinLimits) bool { - return !groupIsFull(group, limits) -} - type strategyOptions struct { groupedRoundRobinLimits GroupedRoundRobinLimits } From bcce052159db5fce13c01f4e0ad86d08b9e0e7b0 Mon Sep 17 00:00:00 2001 From: shudorcl <1985366171@qq.com> Date: Thu, 30 Jul 2026 02:36:16 +0800 Subject: [PATCH 7/8] test(scheduler): cover scheduling snapshot derivation --- .../scheduler/internal/node_registry_test.go | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/services/scheduler/internal/node_registry_test.go b/services/scheduler/internal/node_registry_test.go index 66d656a18..a774c2953 100644 --- a/services/scheduler/internal/node_registry_test.go +++ b/services/scheduler/internal/node_registry_test.go @@ -290,6 +290,115 @@ func TestLingeringNodeBecomesUnhealthyAfterTTL(t *testing.T) { } } +func TestSchedulingSnapshotDerivesCurrentStatus(t *testing.T) { + start := time.Unix(100, 0) + tests := []struct { + name string + lingering bool + queryOffset time.Duration + want schedulerv1.NodeStatus + }{ + { + name: "fresh ready heartbeat", + want: schedulerv1.NodeStatus_NODE_STATUS_READY, + }, + { + name: "expired heartbeat", + queryOffset: 2 * time.Second, + want: schedulerv1.NodeStatus_NODE_STATUS_UNHEALTHY, + }, + { + name: "lingering overrides reported ready", + lingering: true, + want: schedulerv1.NodeStatus_NODE_STATUS_LINGERING, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + registry := NewAtomicNodeRegistry(nil, time.Second) + node := Node{ID: "node-a", Endpoint: "http://node-a"} + if tt.lingering { + registry.Set(nil, []Node{node}) + } else { + registry.Set([]Node{node}, nil) + } + if _, _, err := registry.Heartbeat(&schedulerv1.HeartbeatRequest{ + NodeId: node.ID, + ClusterId: "cluster-a", + ServiceInstanceId: "svc-a", + Snapshot: &schedulerv1.NodeSnapshot{Status: schedulerv1.NodeStatus_NODE_STATUS_READY}, + }, start); err != nil { + t.Fatalf("heartbeat: %v", err) + } + + snapshot := registry.SchedulingSnapshot(node.ID, start.Add(tt.queryOffset)) + if snapshot == nil { + t.Fatal("expected scheduling snapshot") + } + if got := snapshot.GetStatus(); got != tt.want { + t.Fatalf("status = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSchedulingSnapshotReturnsNilWithoutHeartbeat(t *testing.T) { + registry := NewAtomicNodeRegistry( + []Node{{ID: "node-a", Endpoint: "http://node-a"}}, + time.Second, + ) + + if snapshot := registry.SchedulingSnapshot("node-a", time.Unix(100, 0)); snapshot != nil { + t.Fatalf("expected nil scheduling snapshot, got %+v", snapshot) + } +} + +func TestSchedulingSnapshotReturnsDeepClone(t *testing.T) { + registry := NewAtomicNodeRegistry( + []Node{{ID: "node-a", Endpoint: "http://node-a"}}, + time.Second, + ) + now := time.Unix(100, 0) + if _, _, err := registry.Heartbeat(&schedulerv1.HeartbeatRequest{ + NodeId: "node-a", + ClusterId: "cluster-a", + ServiceInstanceId: "svc-a", + Snapshot: &schedulerv1.NodeSnapshot{ + Status: schedulerv1.NodeStatus_NODE_STATUS_READY, + AllocatedCpu: 2, + Disks: []*schedulerv1.DiskMetric{{ + MountPoint: "/", + UsedBytes: 10, + }}, + }, + }, now); err != nil { + t.Fatalf("heartbeat: %v", err) + } + + first := registry.SchedulingSnapshot("node-a", now) + if first == nil || len(first.GetDisks()) != 1 { + t.Fatalf("unexpected first scheduling snapshot: %+v", first) + } + first.Status = schedulerv1.NodeStatus_NODE_STATUS_UNHEALTHY + first.AllocatedCpu = 99 + first.Disks[0].UsedBytes = 99 + + second := registry.SchedulingSnapshot("node-a", now) + if second == nil { + t.Fatal("expected second scheduling snapshot") + } + if got := second.GetStatus(); got != schedulerv1.NodeStatus_NODE_STATUS_READY { + t.Fatalf("stored status changed through returned snapshot: %v", got) + } + if got := second.GetAllocatedCpu(); got != 2 { + t.Fatalf("stored allocated CPU changed through returned snapshot: %d", got) + } + if got := second.GetDisks()[0].GetUsedBytes(); got != 10 { + t.Fatalf("stored disk metric changed through returned snapshot: %d", got) + } +} + func heartbeatWithConfig(t *testing.T, registry *AtomicNodeRegistry, nodeID, clusterID, svcID, cpuJSON string) string { t.Helper() var mi *schedulerv1.MachineInfo From 33ea51840bf989981fb391f1548e1691fbda0ee1 Mon Sep 17 00:00:00 2001 From: shudorcl <1985366171@qq.com> Date: Mon, 10 Aug 2026 15:11:38 +0800 Subject: [PATCH 8/8] fix(scheduler): bound omitted cold-start resources --- services/README.md | 2 ++ .../grouped_round_robin_strategy_test.go | 18 ++++++++++++++++++ services/scheduler/internal/strategy.go | 18 ++++++++++++++++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/services/README.md b/services/README.md index d3798bdcd..f05a8a31c 100644 --- a/services/README.md +++ b/services/README.md @@ -129,6 +129,8 @@ Group accounting occurs atomically during scheduling, before the runtime finishe } ``` +When a cold-start request omits `cpuCount` or `memoryMB` while the corresponding group limit is enabled, the scheduler charges that dimension at the full configured group limit. Runtime defaults are node-local and are not available in the gateway hint, so this conservative behavior closes that request's group instead of allowing an unknown default to exceed the budget. + Template-based create requests do not carry CPU or memory values, so their groups are bounded by `max_sandbox_count` only. Image tags and template aliases are used exactly as supplied and may be mutable; a stale grouping hint can reduce cache affinity, but cannot bypass group or node resource limits. ### Node resource limit diff --git a/services/scheduler/internal/grouped_round_robin_strategy_test.go b/services/scheduler/internal/grouped_round_robin_strategy_test.go index c1af198ef..b56d2bc74 100644 --- a/services/scheduler/internal/grouped_round_robin_strategy_test.go +++ b/services/scheduler/internal/grouped_round_robin_strategy_test.go @@ -63,6 +63,24 @@ func TestGroupedRoundRobinClosesGroupAtMemoryLimit(t *testing.T) { } } +func TestGroupedRoundRobinTreatsOmittedColdResourcesConservatively(t *testing.T) { + strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{ + MaxSandboxCount: 10, + MaxCPUCount: 4, + MaxMemoryMB: 1024, + }) + nodes := readyNodes("a", "b") + + got := []string{ + selectNodeID(t, strategy, nodes, coldHint("ubuntu", 0, 0)), + selectNodeID(t, strategy, nodes, coldHint("ubuntu", 0, 0)), + } + want := []string{"a", "b"} + if !equalNodeIDs(got, want) { + t.Fatalf("placements for omitted resources = %v, want %v", got, want) + } +} + func TestGroupedRoundRobinOversizedRequestDoesNotLeaveOpenGroup(t *testing.T) { strategy := NewGroupedRoundRobinStrategy(GroupedRoundRobinLimits{ MaxSandboxCount: 10, diff --git a/services/scheduler/internal/strategy.go b/services/scheduler/internal/strategy.go index 5b5fee555..21e362e76 100644 --- a/services/scheduler/internal/strategy.go +++ b/services/scheduler/internal/strategy.go @@ -112,7 +112,7 @@ func (s *GroupedRoundRobinStrategy) Select(nodes []RichNode, hint *schedulerv1.S return RichNode{}, ErrNoNodes } - request, grouped := groupedRoundRobinRequestFromHint(hint) + request, grouped := groupedRoundRobinRequestFromHint(hint, s.limits) s.mu.Lock() defer s.mu.Unlock() @@ -197,7 +197,10 @@ func readyGroupedRoundRobinNodes(nodes []RichNode) []RichNode { return ready } -func groupedRoundRobinRequestFromHint(hint *schedulerv1.ScheduleRequestHint) (groupedRoundRobinRequest, bool) { +func groupedRoundRobinRequestFromHint( + hint *schedulerv1.ScheduleRequestHint, + limits GroupedRoundRobinLimits, +) (groupedRoundRobinRequest, bool) { var request groupedRoundRobinRequest switch kind := hint.GetKind().(type) { case *schedulerv1.ScheduleRequestHint_NewColdSandbox: @@ -210,6 +213,17 @@ func groupedRoundRobinRequestFromHint(hint *schedulerv1.ScheduleRequestHint) (gr cpuCount: kind.NewColdSandbox.GetCpuCount(), memoryMB: kind.NewColdSandbox.GetMemoryMb(), } + // The runtime fills omitted cold-start resources from node-local + // machine defaults. The scheduler cannot know those defaults for every + // candidate, so charge an unknown dimension at the full configured + // group limit. This conservatively prevents an omitted value from + // allowing a group to exceed its resource budget. + if request.cpuCount == 0 && limits.MaxCPUCount > 0 { + request.cpuCount = limits.MaxCPUCount + } + if request.memoryMB == 0 && limits.MaxMemoryMB > 0 { + request.memoryMB = limits.MaxMemoryMB + } case *schedulerv1.ScheduleRequestHint_NewSandbox: request.key = "template:" + strings.TrimSpace(kind.NewSandbox.GetTemplateId()) default: