- 노드 연결 2단계 핸드셰이크 추가 (Register→NodeReady→DispatchReady) - ConnectionGeneration 기반 연결 세대 관리로 stale 연결 차폐 - configured 노드 catalog 기반 snapshot rebuild (Connected 상태 분리) - provider 리스 소유권 일원화: edge가 소유권 승인·반환 전까지 대기 - 모델 대기열 승인/해제/스냅샷 서비스 구현 - 재연결 준비도 통합 테스트, 아카이브된 하위태스크 8건 포함
1691 lines
56 KiB
Go
1691 lines
56 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"runtime"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
toki "git.toki-labs.com/toki/proto-socket/go"
|
|
"google.golang.org/protobuf/proto"
|
|
edgeevents "iop/apps/edge/internal/events"
|
|
edgenode "iop/apps/edge/internal/node"
|
|
"iop/packages/go/config"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
// TestRefreshProviderCapacityAffectsNextDispatch verifies that a provider
|
|
// capacity change applied via SetRuntimeConfig is reflected in the candidate
|
|
// capacity used for the next dispatch admission, since candidates are rebuilt
|
|
// from the live runtime snapshot on each request.
|
|
func TestRefreshProviderCapacityAffectsNextDispatch(t *testing.T) {
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-cap"})
|
|
svc := New(reg, nil)
|
|
|
|
newStore := func(capacity int) *edgenode.NodeStore {
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-cap",
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{
|
|
{
|
|
ID: "prov-a",
|
|
Type: "vllm",
|
|
Category: config.CategoryAPI,
|
|
Adapter: "vllm-gpu",
|
|
Models: []string{"served-a"},
|
|
Health: "available",
|
|
Capacity: capacity,
|
|
},
|
|
},
|
|
})
|
|
return store
|
|
}
|
|
catalog := []config.ModelCatalogEntry{{
|
|
ID: "qwen3.6:35b",
|
|
Providers: map[string]string{"prov-a": "served-a"},
|
|
}}
|
|
|
|
req := SubmitRunRequest{ModelGroupKey: "qwen3.6:35b", ProviderPool: true}
|
|
|
|
svc.SetRuntimeConfig(newStore(2), catalog, groupPolicy{})
|
|
store, cat, _ := svc.runtimeConfigSnapshot()
|
|
cands, _, err := svc.resolveProviderPoolCandidates(req, store, cat)
|
|
if err != nil {
|
|
t.Fatalf("resolve before refresh: %v", err)
|
|
}
|
|
if len(cands) != 1 || cands[0].capacity != 2 {
|
|
t.Fatalf("before refresh: got candidates=%+v, want one with capacity=2", cands)
|
|
}
|
|
|
|
// Refresh raises capacity to 8; the next candidate build must observe it.
|
|
svc.SetRuntimeConfig(newStore(8), catalog, groupPolicy{})
|
|
store, cat, _ = svc.runtimeConfigSnapshot()
|
|
cands, _, err = svc.resolveProviderPoolCandidates(req, store, cat)
|
|
if err != nil {
|
|
t.Fatalf("resolve after refresh: %v", err)
|
|
}
|
|
if len(cands) != 1 || cands[0].capacity != 8 {
|
|
t.Fatalf("after refresh: got candidates=%+v, want one with capacity=8", cands)
|
|
}
|
|
}
|
|
|
|
// TestRefreshProviderLongCapacityAffectsCandidate verifies that a provider
|
|
// long_context_capacity change applied via SetRuntimeConfig is reflected in the
|
|
// candidate longContextCapacity used for the next long-context admission.
|
|
func TestRefreshProviderLongCapacityAffectsCandidate(t *testing.T) {
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-cap"})
|
|
svc := New(reg, nil)
|
|
|
|
newStore := func(longCapacity int) *edgenode.NodeStore {
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-cap",
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{
|
|
{
|
|
ID: "prov-a",
|
|
Type: "vllm",
|
|
Category: config.CategoryAPI,
|
|
Adapter: "vllm-gpu",
|
|
Models: []string{"served-a"},
|
|
Health: "available",
|
|
Capacity: 4,
|
|
TotalContextTokens: 524288,
|
|
LongContextCapacity: longCapacity,
|
|
},
|
|
},
|
|
})
|
|
return store
|
|
}
|
|
catalog := []config.ModelCatalogEntry{{
|
|
ID: "qwen3.6:35b",
|
|
ContextWindowTokens: 262144,
|
|
Providers: map[string]string{"prov-a": "served-a"},
|
|
}}
|
|
|
|
req := SubmitRunRequest{ModelGroupKey: "qwen3.6:35b", ProviderPool: true, ContextClass: "long"}
|
|
|
|
svc.SetRuntimeConfig(newStore(1), catalog, groupPolicy{})
|
|
store, cat, _ := svc.runtimeConfigSnapshot()
|
|
cands, _, err := svc.resolveProviderPoolCandidates(req, store, cat)
|
|
if err != nil {
|
|
t.Fatalf("resolve before refresh: %v", err)
|
|
}
|
|
if len(cands) != 1 || cands[0].longContextCapacity != 1 {
|
|
t.Fatalf("before refresh: got candidates=%+v, want one with longContextCapacity=1", cands)
|
|
}
|
|
|
|
// Refresh raises long capacity to 2; the next candidate build must observe it.
|
|
svc.SetRuntimeConfig(newStore(2), catalog, groupPolicy{})
|
|
store, cat, _ = svc.runtimeConfigSnapshot()
|
|
cands, _, err = svc.resolveProviderPoolCandidates(req, store, cat)
|
|
if err != nil {
|
|
t.Fatalf("resolve after refresh: %v", err)
|
|
}
|
|
if len(cands) != 1 || cands[0].longContextCapacity != 2 {
|
|
t.Fatalf("after refresh: got candidates=%+v, want one with longContextCapacity=2", cands)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeConfigSnapshotConcurrentReplace(t *testing.T) {
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-refresh"})
|
|
svc := New(reg, nil)
|
|
|
|
newStore := func(providerID, servedModel string, capacity int) *edgenode.NodeStore {
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-refresh",
|
|
Runtime: config.RuntimeConf{Concurrency: capacity},
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{
|
|
{
|
|
ID: providerID,
|
|
Type: "vllm",
|
|
Category: config.CategoryAPI,
|
|
Adapter: "vllm-gpu",
|
|
Models: []string{servedModel},
|
|
Health: "available",
|
|
Capacity: capacity,
|
|
},
|
|
},
|
|
})
|
|
return store
|
|
}
|
|
newCatalog := func(providerID, servedModel string) []config.ModelCatalogEntry {
|
|
return []config.ModelCatalogEntry{
|
|
{
|
|
ID: "qwen3.6:35b",
|
|
Providers: map[string]string{
|
|
providerID: servedModel,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
storeA := newStore("prov-a", "served-a", 2)
|
|
catalogA := newCatalog("prov-a", "served-a")
|
|
policyA := NewGroupPolicy(3, 500*time.Millisecond)
|
|
storeB := newStore("prov-b", "served-b", 4)
|
|
catalogB := newCatalog("prov-b", "served-b")
|
|
policyB := NewGroupPolicy(7, 2*time.Second)
|
|
svc.SetRuntimeConfig(storeA, catalogA, policyA)
|
|
|
|
const readers = 8
|
|
const iterations = 200
|
|
start := make(chan struct{})
|
|
errCh := make(chan error, readers)
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < readers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
for j := 0; j < iterations; j++ {
|
|
store, catalog, policy := svc.runtimeConfigSnapshot()
|
|
// Snapshot must return a self-consistent (store, catalog, policy) tuple.
|
|
// A reader must never observe storeA with policyB, or storeB with policyA.
|
|
snapProviders := store.All()
|
|
if len(snapProviders) != 1 {
|
|
errCh <- fmt.Errorf("snapshot store has %d providers, want 1", len(snapProviders))
|
|
return
|
|
}
|
|
providerID := snapProviders[0].Providers[0].ID
|
|
// Each snapshot must pair the correct policy with the correct provider.
|
|
if providerID == "prov-a" && (policy.maxQueue != policyA.maxQueue || policy.queueTimeout != policyA.queueTimeout) {
|
|
errCh <- fmt.Errorf("A snapshot has B policy: store=%s policy=(max=%d,timeout=%v) want (max=%d,timeout=%v)",
|
|
providerID, policy.maxQueue, policy.queueTimeout, policyA.maxQueue, policyA.queueTimeout)
|
|
return
|
|
}
|
|
if providerID == "prov-b" && (policy.maxQueue != policyB.maxQueue || policy.queueTimeout != policyB.queueTimeout) {
|
|
errCh <- fmt.Errorf("B snapshot has A policy: store=%s policy=(max=%d,timeout=%v) want (max=%d,timeout=%v)",
|
|
providerID, policy.maxQueue, policy.queueTimeout, policyB.maxQueue, policyB.queueTimeout)
|
|
return
|
|
}
|
|
// Policy must always be one of the two known policies (never a stale or mixed value).
|
|
if policy.maxQueue != policyA.maxQueue && policy.maxQueue != policyB.maxQueue {
|
|
errCh <- fmt.Errorf("snapshot has unknown policy maxQueue=%d (want %d or %d)",
|
|
policy.maxQueue, policyA.maxQueue, policyB.maxQueue)
|
|
return
|
|
}
|
|
candidates, _, err := svc.resolveProviderPoolCandidates(SubmitRunRequest{
|
|
ModelGroupKey: "qwen3.6:35b",
|
|
ProviderPool: true,
|
|
}, store, catalog)
|
|
if err != nil {
|
|
errCh <- err
|
|
return
|
|
}
|
|
if len(candidates) != 1 {
|
|
errCh <- errors.New("expected exactly one provider candidate")
|
|
return
|
|
}
|
|
_ = svc.ListNodeSnapshots()
|
|
}
|
|
}()
|
|
}
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
for i := 0; i < iterations; i++ {
|
|
svc.SetRuntimeConfig(storeA, catalogA, policyA)
|
|
svc.SetRuntimeConfig(storeB, catalogB, policyB)
|
|
}
|
|
}()
|
|
|
|
close(start)
|
|
wg.Wait()
|
|
close(errCh)
|
|
for err := range errCh {
|
|
if err != nil {
|
|
t.Fatalf("runtime config snapshot reader observed inconsistent state: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServiceRuntimeConfigRefreshPreservesInflight(t *testing.T) {
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-ref-pres"})
|
|
bus := edgeevents.NewBus()
|
|
svc := New(reg, bus)
|
|
|
|
newStore := func(capacity, longCapacity int) *edgenode.NodeStore {
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-ref-pres",
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{
|
|
{
|
|
ID: "prov-a",
|
|
Type: "vllm",
|
|
Category: config.CategoryAPI,
|
|
Adapter: "vllm-gpu",
|
|
Models: []string{"served-a"},
|
|
Health: "available",
|
|
Capacity: capacity,
|
|
LongContextCapacity: longCapacity,
|
|
},
|
|
},
|
|
})
|
|
return store
|
|
}
|
|
|
|
catalog := []config.ModelCatalogEntry{{
|
|
ID: "qwen3.6:35b",
|
|
Providers: map[string]string{"prov-a": "served-a"},
|
|
}}
|
|
|
|
svc.SetRuntimeConfig(newStore(4, 2), catalog, groupPolicy{})
|
|
|
|
// Simulate admission by reserving slot
|
|
svc.queue.mu.Lock()
|
|
key := providerResourceKey{nodeID: "node-ref-pres", providerID: "prov-a"}
|
|
res := svc.queue.resources[key]
|
|
if res == nil {
|
|
svc.queue.mu.Unlock()
|
|
t.Fatal("expected provider resource state to exist")
|
|
}
|
|
res.inFlight = 3
|
|
res.longInFlight = 2
|
|
svc.queue.mu.Unlock()
|
|
|
|
// Update store (reduce capacity and long capacity)
|
|
svc.SetRuntimeConfig(newStore(2, 1), catalog, groupPolicy{})
|
|
|
|
// Verify capacity changed but inflight counts preserved
|
|
svc.queue.mu.Lock()
|
|
if res.capacity != 2 {
|
|
t.Errorf("expected capacity updated to 2, got %d", res.capacity)
|
|
}
|
|
if res.longCapacity != 1 {
|
|
t.Errorf("expected longCapacity updated to 1, got %d", res.longCapacity)
|
|
}
|
|
if res.inFlight != 3 {
|
|
t.Errorf("expected inFlight preserved at 3, got %d", res.inFlight)
|
|
}
|
|
if res.longInFlight != 2 {
|
|
t.Errorf("expected longInFlight preserved at 2, got %d", res.longInFlight)
|
|
}
|
|
svc.queue.mu.Unlock()
|
|
}
|
|
|
|
// refreshWakeupFixture builds a service whose single provider can be reconfigured
|
|
// between calls, plus the candidate list a queued request would carry for it.
|
|
func refreshWakeupFixture(t *testing.T, nodeID, providerID, servedModel string) (*Service, func(capacity int, enabled bool), []candidateNode) {
|
|
t.Helper()
|
|
|
|
reg := edgenode.NewRegistry()
|
|
entry := &edgenode.NodeEntry{NodeID: nodeID}
|
|
reg.Register(entry)
|
|
svc := New(reg, edgeevents.NewBus())
|
|
|
|
catalog := []config.ModelCatalogEntry{{
|
|
ID: "group-refresh",
|
|
Providers: map[string]string{providerID: servedModel},
|
|
}}
|
|
|
|
applyConfig := func(capacity int, enabled bool) {
|
|
enabledFlag := enabled
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: nodeID,
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{{
|
|
ID: providerID,
|
|
Type: "vllm",
|
|
Adapter: "vllm-gpu",
|
|
Models: []string{servedModel},
|
|
Health: "available",
|
|
Capacity: capacity,
|
|
Enabled: &enabledFlag,
|
|
}},
|
|
})
|
|
svc.SetRuntimeConfig(store, catalog, groupPolicy{})
|
|
}
|
|
|
|
applyConfig(1, true)
|
|
cands := []candidateNode{{entry: entry, capacity: 1, providerID: providerID, servedTarget: servedModel}}
|
|
return svc, applyConfig, cands
|
|
}
|
|
|
|
// TestGlobalPumpCapacityRefreshWakesWaiter verifies that raising provider
|
|
// capacity through a runtime config refresh wakes an already-queued request.
|
|
// A capacity increase frees room that no lease release will ever announce, so
|
|
// without pumping on refresh the waiter would sleep until its queue timeout.
|
|
func TestGlobalPumpCapacityRefreshWakesWaiter(t *testing.T) {
|
|
svc, applyConfig, cands := refreshWakeupFixture(t, "node-cap-wake", "prov-cap-wake", "served-cap-wake")
|
|
|
|
// Fill the provider's only slot.
|
|
if _, err := svc.queue.admit(t.Context(), "g-cap-wake-a", "", "", cands, groupPolicy{}, nil, false, false); err != nil {
|
|
t.Fatalf("initial admit: %v", err)
|
|
}
|
|
|
|
// A request in a different group queues behind it.
|
|
waiter := queueItemForTest(cands, false)
|
|
enqueueForTest(svc.queue, "g-cap-wake-b", waiter, nil)
|
|
|
|
// Raising capacity must dispatch the waiter immediately.
|
|
applyConfig(2, true)
|
|
|
|
select {
|
|
case res := <-waiter.waitCh:
|
|
if res.err != nil {
|
|
t.Fatalf("waiter expected dispatch after capacity refresh, got error: %v", res.err)
|
|
}
|
|
if res.candidate == nil || res.candidate.capacity != 2 {
|
|
t.Fatalf("waiter must be dispatched with the refreshed capacity, got %+v", res.candidate)
|
|
}
|
|
default:
|
|
t.Fatal("capacity refresh did not wake the queued waiter")
|
|
}
|
|
|
|
// Over-capacity must still be refused: both slots are now taken.
|
|
inFlight, _ := providerResourceCounts(svc.queue, "node-cap-wake", "prov-cap-wake")
|
|
if inFlight != 2 {
|
|
t.Fatalf("expected 2 in-flight after the refresh dispatch, got %d", inFlight)
|
|
}
|
|
extra := queueItemForTest(cands, false)
|
|
enqueueForTest(svc.queue, "g-cap-wake-b", extra, nil)
|
|
svc.queue.mu.Lock()
|
|
svc.queue.pumpAllLocked()
|
|
svc.queue.mu.Unlock()
|
|
select {
|
|
case <-extra.waitCh:
|
|
t.Fatal("pump dispatched past the refreshed capacity")
|
|
default:
|
|
}
|
|
}
|
|
|
|
// TestGlobalPumpDisabledProviderIsNotDispatched verifies that dispatch re-reads
|
|
// the enabled switch at hand-off time. A request queued while the provider was
|
|
// enabled must not be handed the provider after a refresh disabled it, even
|
|
// though its candidate snapshot still says the provider is usable — and it must
|
|
// resume once the provider comes back.
|
|
func TestGlobalPumpDisabledProviderIsNotDispatched(t *testing.T) {
|
|
svc, applyConfig, cands := refreshWakeupFixture(t, "node-dis", "prov-dis", "served-dis")
|
|
|
|
admitted, err := svc.queue.admit(t.Context(), "g-dis-a", "", "", cands, groupPolicy{}, nil, false, false)
|
|
if err != nil || admitted == nil {
|
|
t.Fatalf("initial admit: %v", err)
|
|
}
|
|
|
|
waiter := queueItemForTest(cands, false)
|
|
enqueueForTest(svc.queue, "g-dis-b", waiter, nil)
|
|
|
|
// Disable the provider, then free the in-flight slot. The freed capacity must
|
|
// not be handed to the waiter: the provider is no longer dispatchable.
|
|
applyConfig(1, false)
|
|
svc.queue.releaseLease(admitted.leaseID, "complete")
|
|
|
|
select {
|
|
case res := <-waiter.waitCh:
|
|
t.Fatalf("waiter was dispatched to a disabled provider: %+v", res)
|
|
default:
|
|
}
|
|
if got := pendingItemCount(svc.queue); got != 1 {
|
|
t.Fatalf("expected the waiter to stay queued, got %d pending items", got)
|
|
}
|
|
|
|
// Re-enabling must wake it through the same refresh pump.
|
|
applyConfig(1, true)
|
|
select {
|
|
case res := <-waiter.waitCh:
|
|
if res.err != nil {
|
|
t.Fatalf("waiter expected dispatch after re-enable, got error: %v", res.err)
|
|
}
|
|
default:
|
|
t.Fatal("re-enabling the provider did not wake the queued waiter")
|
|
}
|
|
}
|
|
|
|
// TestGlobalPumpReenabledProviderAbsentAtEnqueueWakesWaiter verifies the
|
|
// required regression: a provider that was disabled at enqueue time is absent
|
|
// from the candidate snapshot, so the original snapshot-only pump could never
|
|
// discover it after re-enable. With the live resolver the waiter must wake
|
|
// and dispatch to the re-enabled provider without any lease release.
|
|
func TestGlobalPumpReenabledProviderAbsentAtEnqueueWakesWaiter(t *testing.T) {
|
|
parserMap := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RunRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
|
|
edgeConn1, nodeConn1 := net.Pipe()
|
|
edgeConn2, nodeConn2 := net.Pipe()
|
|
defer edgeConn1.Close()
|
|
defer nodeConn1.Close()
|
|
defer edgeConn2.Close()
|
|
defer nodeConn2.Close()
|
|
|
|
edgeClient1 := toki.NewTcpClient(edgeConn1, 0, 0, parserMap)
|
|
edgeClient2 := toki.NewTcpClient(edgeConn2, 0, 0, parserMap)
|
|
_ = toki.NewTcpClient(nodeConn1, 0, 0, parserMap)
|
|
_ = toki.NewTcpClient(nodeConn2, 0, 0, parserMap)
|
|
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-re-a", Client: edgeClient1})
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-re-b", Client: edgeClient2})
|
|
svc := New(reg, edgeevents.NewBus())
|
|
|
|
catalog := []config.ModelCatalogEntry{{
|
|
ID: "group-re",
|
|
Providers: map[string]string{"prov-a": "served-a", "prov-b": "served-b"},
|
|
}}
|
|
|
|
applyConfig := func(capA, capB int, enabledB bool) {
|
|
enabledFlag := true
|
|
if !enabledB {
|
|
enabledFlag = false
|
|
}
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-re-a",
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{{
|
|
ID: "prov-a", Type: "vllm", Adapter: "vllm-gpu",
|
|
Models: []string{"served-a"}, Health: "available",
|
|
Capacity: capA,
|
|
}},
|
|
})
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-re-b",
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8001/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{{
|
|
ID: "prov-b", Type: "vllm", Adapter: "vllm-gpu",
|
|
Models: []string{"served-b"}, Health: "available",
|
|
Capacity: capB, Enabled: &enabledFlag,
|
|
}},
|
|
})
|
|
svc.SetRuntimeConfig(store, catalog, groupPolicy{})
|
|
}
|
|
|
|
// Fill prov-a's only slot so prov-b is the only candidate for the waiter.
|
|
applyConfig(1, 1, true)
|
|
admitted, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-re", ProviderPool: true, Background: true,
|
|
})
|
|
if err != nil || admitted == nil {
|
|
t.Fatalf("initial admit: %v", err)
|
|
}
|
|
defer admitted.Close()
|
|
|
|
// Disable prov-b and enqueue a waiter — prov-b must be absent from the
|
|
// resolver's candidate list at enqueue time.
|
|
applyConfig(1, 1, false)
|
|
|
|
type submitResult struct {
|
|
run RunResult
|
|
err error
|
|
}
|
|
resultCh := make(chan submitResult, 1)
|
|
go func() {
|
|
run, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-re", ProviderPool: true, Background: true,
|
|
})
|
|
resultCh <- submitResult{run: run, err: err}
|
|
}()
|
|
|
|
// Verify the waiter is queued and prov-b is NOT in its stored enqueue snapshot.
|
|
requireQueued := func() {
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
svc.queue.mu.Lock()
|
|
group := svc.queue.groups["group-re"]
|
|
count := 0
|
|
if group != nil {
|
|
count = len(group.queue)
|
|
}
|
|
svc.queue.mu.Unlock()
|
|
if count == 1 {
|
|
return
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("waiter was not enqueued within timeout")
|
|
default:
|
|
runtime.Gosched()
|
|
}
|
|
}
|
|
}
|
|
requireQueued()
|
|
svc.queue.mu.Lock()
|
|
group := svc.queue.groups["group-re"]
|
|
if group == nil || len(group.queue) != 1 {
|
|
svc.queue.mu.Unlock()
|
|
t.Fatalf("expected exactly one queued item")
|
|
}
|
|
item := group.queue[0]
|
|
if item.resolveCandidates == nil {
|
|
svc.queue.mu.Unlock()
|
|
t.Fatal("expected live resolver on queued item")
|
|
}
|
|
for _, candidate := range item.candidates {
|
|
if candidate.providerID == "prov-b" {
|
|
svc.queue.mu.Unlock()
|
|
t.Fatalf("prov-b must be absent from stored enqueue snapshot")
|
|
}
|
|
}
|
|
svc.queue.mu.Unlock()
|
|
|
|
// Re-enable prov-b — the live resolver must now include it and the pump
|
|
// must dispatch the waiter to prov-b without any lease release.
|
|
applyConfig(1, 1, true)
|
|
|
|
var res3 RunResult
|
|
var err3 error
|
|
select {
|
|
case got := <-resultCh:
|
|
err3 = got.err
|
|
res3 = got.run
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("re-enabling prov-b did not wake the queued waiter (provider absent-at-enqueue regression)")
|
|
}
|
|
|
|
if err3 != nil {
|
|
t.Fatalf("waiter expected dispatch after re-enable, got error: %v", err3)
|
|
}
|
|
if res3 == nil {
|
|
t.Fatal("waiter expected non-nil result")
|
|
}
|
|
defer res3.Close()
|
|
if got := res3.Dispatch().ProviderID; got != "prov-b" {
|
|
t.Fatalf("waiter dispatched to provider %q, want prov-b", got)
|
|
}
|
|
|
|
// Verify both providers have in-flight=1 after dispatch.
|
|
preAInFlight, _ := providerResourceCounts(svc.queue, "node-re-a", "prov-a")
|
|
preBInFlight, _ := providerResourceCounts(svc.queue, "node-re-b", "prov-b")
|
|
if preAInFlight != 1 {
|
|
t.Fatalf("expected prov-a in-flight=1 after dispatch, got %d", preAInFlight)
|
|
}
|
|
if preBInFlight != 1 {
|
|
t.Fatalf("expected prov-b in-flight=1 after dispatch, got %d", preBInFlight)
|
|
}
|
|
|
|
// Release both runs through terminal lifecycle events and verify full drain.
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: admitted.Dispatch().RunID, Type: "complete"})
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: res3.Dispatch().RunID, Type: "complete"})
|
|
aInFlight, aLong := providerResourceCounts(svc.queue, "node-re-a", "prov-a")
|
|
bInFlight, bLong := providerResourceCounts(svc.queue, "node-re-b", "prov-b")
|
|
if aInFlight != 0 || aLong != 0 {
|
|
t.Fatalf("expected prov-a drained to 0, got inFlight=%d longInFlight=%d", aInFlight, aLong)
|
|
}
|
|
if bInFlight != 0 || bLong != 0 {
|
|
t.Fatalf("expected prov-b drained to 0, got inFlight=%d longInFlight=%d", bInFlight, bLong)
|
|
}
|
|
if lc := leaseCount(svc.queue); lc != 0 {
|
|
t.Fatalf("expected leaseCount=0 after terminal events, got %d", lc)
|
|
}
|
|
}
|
|
|
|
// TestGlobalPumpCapacityZeroProviderBecomesEligible verifies the required
|
|
// regression: a provider with capacity 0 at enqueue time is absent from the
|
|
// candidate snapshot, so the original snapshot-only pump could never discover
|
|
// it after capacity becomes positive. With the live resolver the waiter must
|
|
// wake and dispatch to the provider once capacity is raised.
|
|
func TestGlobalPumpCapacityZeroProviderBecomesEligible(t *testing.T) {
|
|
parserMap := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RunRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
|
|
edgeConn1, nodeConn1 := net.Pipe()
|
|
edgeConn2, nodeConn2 := net.Pipe()
|
|
defer edgeConn1.Close()
|
|
defer nodeConn1.Close()
|
|
defer edgeConn2.Close()
|
|
defer nodeConn2.Close()
|
|
|
|
edgeClient1 := toki.NewTcpClient(edgeConn1, 0, 0, parserMap)
|
|
edgeClient2 := toki.NewTcpClient(edgeConn2, 0, 0, parserMap)
|
|
_ = toki.NewTcpClient(nodeConn1, 0, 0, parserMap)
|
|
_ = toki.NewTcpClient(nodeConn2, 0, 0, parserMap)
|
|
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-cz-a", Client: edgeClient1})
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-cz-b", Client: edgeClient2})
|
|
svc := New(reg, edgeevents.NewBus())
|
|
|
|
catalog := []config.ModelCatalogEntry{{
|
|
ID: "group-cz",
|
|
Providers: map[string]string{"prov-a": "served-a", "prov-b": "served-b"},
|
|
}}
|
|
|
|
applyConfig := func(capA, capB int) {
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-cz-a",
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{{
|
|
ID: "prov-a", Type: "vllm", Adapter: "vllm-gpu",
|
|
Models: []string{"served-a"}, Health: "available",
|
|
Capacity: capA,
|
|
}},
|
|
})
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-cz-b",
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8001/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{{
|
|
ID: "prov-b", Type: "vllm", Adapter: "vllm-gpu",
|
|
Models: []string{"served-b"}, Health: "available",
|
|
Capacity: capB,
|
|
}},
|
|
})
|
|
svc.SetRuntimeConfig(store, catalog, groupPolicy{})
|
|
}
|
|
|
|
// Fill prov-a's only slot so prov-b is the only candidate for the waiter.
|
|
applyConfig(1, 1)
|
|
admitted, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-cz", ProviderPool: true, Background: true,
|
|
})
|
|
if err != nil || admitted == nil {
|
|
t.Fatalf("initial admit: %v", err)
|
|
}
|
|
defer admitted.Close()
|
|
|
|
// Set prov-b to capacity 0 and enqueue a waiter — prov-b must be absent
|
|
// from the resolver's candidate list at enqueue time.
|
|
applyConfig(1, 0)
|
|
|
|
type submitResult struct {
|
|
run RunResult
|
|
err error
|
|
}
|
|
resultCh := make(chan submitResult, 1)
|
|
go func() {
|
|
run, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-cz", ProviderPool: true, Background: true,
|
|
})
|
|
resultCh <- submitResult{run: run, err: err}
|
|
}()
|
|
|
|
// Verify the waiter is queued and prov-b is NOT in its stored enqueue snapshot.
|
|
requireQueued := func() {
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
svc.queue.mu.Lock()
|
|
group := svc.queue.groups["group-cz"]
|
|
count := 0
|
|
if group != nil {
|
|
count = len(group.queue)
|
|
}
|
|
svc.queue.mu.Unlock()
|
|
if count == 1 {
|
|
return
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("waiter was not enqueued within timeout")
|
|
default:
|
|
runtime.Gosched()
|
|
}
|
|
}
|
|
}
|
|
requireQueued()
|
|
svc.queue.mu.Lock()
|
|
group := svc.queue.groups["group-cz"]
|
|
if group == nil || len(group.queue) != 1 {
|
|
svc.queue.mu.Unlock()
|
|
t.Fatalf("expected exactly one queued item")
|
|
}
|
|
item := group.queue[0]
|
|
if item.resolveCandidates == nil {
|
|
svc.queue.mu.Unlock()
|
|
t.Fatal("expected live resolver on queued item")
|
|
}
|
|
for _, candidate := range item.candidates {
|
|
if candidate.providerID == "prov-b" {
|
|
svc.queue.mu.Unlock()
|
|
t.Fatalf("prov-b must be absent from stored enqueue snapshot")
|
|
}
|
|
}
|
|
svc.queue.mu.Unlock()
|
|
|
|
// Raise prov-b's capacity — the live resolver must now include it and the
|
|
// pump must dispatch the waiter to prov-b without any lease release.
|
|
applyConfig(1, 1)
|
|
|
|
var res3 RunResult
|
|
var err3 error
|
|
select {
|
|
case got := <-resultCh:
|
|
err3 = got.err
|
|
res3 = got.run
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("raising prov-b capacity did not wake the queued waiter (capacity-zero-at-enqueue regression)")
|
|
}
|
|
|
|
if err3 != nil {
|
|
t.Fatalf("waiter expected dispatch after capacity raise, got error: %v", err3)
|
|
}
|
|
if res3 == nil {
|
|
t.Fatal("waiter expected non-nil result")
|
|
}
|
|
defer res3.Close()
|
|
if got := res3.Dispatch().ProviderID; got != "prov-b" {
|
|
t.Fatalf("waiter dispatched to provider %q, want prov-b", got)
|
|
}
|
|
|
|
// Verify both providers have in-flight=1 after dispatch.
|
|
preAInFlight, _ := providerResourceCounts(svc.queue, "node-cz-a", "prov-a")
|
|
preBInFlight, _ := providerResourceCounts(svc.queue, "node-cz-b", "prov-b")
|
|
if preAInFlight != 1 {
|
|
t.Fatalf("expected prov-a in-flight=1 after dispatch, got %d", preAInFlight)
|
|
}
|
|
if preBInFlight != 1 {
|
|
t.Fatalf("expected prov-b in-flight=1 after dispatch, got %d", preBInFlight)
|
|
}
|
|
|
|
// Release both runs through terminal lifecycle events and verify full drain.
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: admitted.Dispatch().RunID, Type: "complete"})
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: res3.Dispatch().RunID, Type: "complete"})
|
|
aInFlight, aLong := providerResourceCounts(svc.queue, "node-cz-a", "prov-a")
|
|
bInFlight, bLong := providerResourceCounts(svc.queue, "node-cz-b", "prov-b")
|
|
if aInFlight != 0 || aLong != 0 {
|
|
t.Fatalf("expected prov-a drained to 0, got inFlight=%d longInFlight=%d", aInFlight, aLong)
|
|
}
|
|
if bInFlight != 0 || bLong != 0 {
|
|
t.Fatalf("expected prov-b drained to 0, got inFlight=%d longInFlight=%d", bInFlight, bLong)
|
|
}
|
|
if lc := leaseCount(svc.queue); lc != 0 {
|
|
t.Fatalf("expected leaseCount=0 after terminal events, got %d", lc)
|
|
}
|
|
}
|
|
|
|
// --- PROVIDER_POLICY_REFRESH-2: resource attribute refresh tests ---
|
|
|
|
// helper for capacity/priority/disable rebase tests.
|
|
// Returns a service with a mock client pair so SubmitRun can dispatch.
|
|
func buildCapacityRebaseService(t *testing.T, nodeID, providerID, servedModel string, initialCap, initialLongCap int, enabled *bool) (*Service, func(capacity, longCapacity int, enabled bool)) {
|
|
t.Helper()
|
|
edgeConn, nodeConn := net.Pipe()
|
|
t.Cleanup(func() {
|
|
edgeConn.Close()
|
|
nodeConn.Close()
|
|
})
|
|
parserMap := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RunRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
edgeClient := toki.NewTcpClient(edgeConn, 0, 0, parserMap)
|
|
_ = toki.NewTcpClient(nodeConn, 0, 0, parserMap)
|
|
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{NodeID: nodeID, Client: edgeClient})
|
|
svc := New(reg, edgeevents.NewBus())
|
|
catalog := []config.ModelCatalogEntry{{
|
|
ID: "group-cap-rebase",
|
|
Providers: map[string]string{providerID: servedModel},
|
|
}}
|
|
applyFn := func(capacity, longCapacity int, enabled bool) {
|
|
enabledFlag := enabled
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: nodeID,
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{{
|
|
ID: providerID,
|
|
Type: "vllm",
|
|
Adapter: "vllm-gpu",
|
|
Models: []string{servedModel},
|
|
Health: "available",
|
|
Capacity: capacity,
|
|
LongContextCapacity: longCapacity,
|
|
Enabled: &enabledFlag,
|
|
}},
|
|
})
|
|
svc.SetRuntimeConfig(store, catalog, groupPolicy{maxQueue: 8, queueTimeout: 5 * time.Second})
|
|
}
|
|
applyFn(initialCap, initialLongCap, *enabled)
|
|
return svc, applyFn
|
|
}
|
|
|
|
// buildPriorityService sets up two providers with separate mock clients.
|
|
func buildPriorityService(t *testing.T) (*Service, func(capA, capB, prioA, prioB int, enabled bool)) {
|
|
t.Helper()
|
|
edgeConnA, nodeConnA := net.Pipe()
|
|
edgeConnB, nodeConnB := net.Pipe()
|
|
t.Cleanup(func() {
|
|
edgeConnA.Close()
|
|
nodeConnA.Close()
|
|
edgeConnB.Close()
|
|
nodeConnB.Close()
|
|
})
|
|
parserMap := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RunRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
edgeClientA := toki.NewTcpClient(edgeConnA, 0, 0, parserMap)
|
|
_ = toki.NewTcpClient(nodeConnA, 0, 0, parserMap)
|
|
edgeClientB := toki.NewTcpClient(edgeConnB, 0, 0, parserMap)
|
|
_ = toki.NewTcpClient(nodeConnB, 0, 0, parserMap)
|
|
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-prio-a", Client: edgeClientA})
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-prio-b", Client: edgeClientB})
|
|
svc := New(reg, edgeevents.NewBus())
|
|
catalog := []config.ModelCatalogEntry{{
|
|
ID: "group-prio",
|
|
Providers: map[string]string{"prov-a": "served-a", "prov-b": "served-b"},
|
|
}}
|
|
applyFn := func(capA, capB, prioA, prioB int, enabled bool) {
|
|
enabledFlag := enabled
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-prio-a",
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{{
|
|
ID: "prov-a", Type: "vllm", Adapter: "vllm-gpu",
|
|
Models: []string{"served-a"}, Health: "available",
|
|
Capacity: capA, Priority: prioA, Enabled: &enabledFlag,
|
|
}},
|
|
})
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-prio-b",
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8001/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{{
|
|
ID: "prov-b", Type: "vllm", Adapter: "vllm-gpu",
|
|
Models: []string{"served-b"}, Health: "available",
|
|
Capacity: capB, Priority: prioB, Enabled: &enabledFlag,
|
|
}},
|
|
})
|
|
svc.SetRuntimeConfig(store, catalog, groupPolicy{maxQueue: 8, queueTimeout: 5 * time.Second})
|
|
}
|
|
applyFn(1, 0, 0, 0, true)
|
|
return svc, applyFn
|
|
}
|
|
|
|
// TestRefreshCapacityShrinkPreservesLeaseAndBlocksUntilRecovery verifies that
|
|
// shrinking capacity while in-flight leases exist preserves those leases and
|
|
// blocks new admission, and that raising capacity after a release immediately
|
|
// pumps the queued waiter.
|
|
func TestRefreshCapacityShrinkPreservesLeaseAndBlocksUntilRecovery(t *testing.T) {
|
|
enabled := true
|
|
svc, applyFn := buildCapacityRebaseService(t, "node-cap-shrink", "prov-shrink", "served-shrink", 2, 0, &enabled)
|
|
|
|
admitted1, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-cap-rebase", ProviderPool: true, Background: true,
|
|
})
|
|
if err != nil || admitted1 == nil {
|
|
t.Fatalf("admit 1 failed: %v", err)
|
|
}
|
|
admitted2, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-cap-rebase", ProviderPool: true, Background: true,
|
|
})
|
|
if err != nil || admitted2 == nil {
|
|
admitted1.Close()
|
|
t.Fatalf("admit 2 failed: %v", err)
|
|
}
|
|
|
|
svc.queue.mu.Lock()
|
|
res := svc.queue.resources[providerResourceKey{nodeID: "node-cap-shrink", providerID: "prov-shrink"}]
|
|
if res == nil || res.inFlight != 2 {
|
|
svc.queue.mu.Unlock()
|
|
admitted1.Close()
|
|
admitted2.Close()
|
|
t.Fatalf("expected inFlight=2 after admits, got %d", res.inFlight)
|
|
}
|
|
svc.queue.mu.Unlock()
|
|
|
|
applyFn(1, 0, true)
|
|
|
|
svc.queue.mu.Lock()
|
|
if res.capacity != 1 {
|
|
svc.queue.mu.Unlock()
|
|
admitted1.Close()
|
|
admitted2.Close()
|
|
t.Fatalf("expected capacity=1 after shrink, got %d", res.capacity)
|
|
}
|
|
if res.inFlight != 2 {
|
|
svc.queue.mu.Unlock()
|
|
admitted1.Close()
|
|
admitted2.Close()
|
|
t.Fatalf("expected inFlight preserved at 2 after shrink, got %d", res.inFlight)
|
|
}
|
|
svc.queue.mu.Unlock()
|
|
|
|
blockedCh := make(chan struct {
|
|
run RunResult
|
|
err error
|
|
}, 1)
|
|
go func() {
|
|
run, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-cap-rebase", ProviderPool: true, Background: true,
|
|
})
|
|
blockedCh <- struct {
|
|
run RunResult
|
|
err error
|
|
}{run: run, err: err}
|
|
}()
|
|
|
|
waitForQueueLen(t, svc.queue, "group-cap-rebase", 1)
|
|
if inFlight, _ := providerResourceCounts(svc.queue, "node-cap-shrink", "prov-shrink"); inFlight != 2 {
|
|
admitted1.Close()
|
|
admitted2.Close()
|
|
t.Fatalf("blocked admission increased over-capacity in-flight to %d", inFlight)
|
|
}
|
|
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: admitted1.Dispatch().RunID, Type: "complete"})
|
|
applyFn(2, 0, true)
|
|
|
|
select {
|
|
case got := <-blockedCh:
|
|
if got.err != nil {
|
|
admitted2.Close()
|
|
t.Fatalf("blocked waiter expected dispatch after raise, got error: %v", got.err)
|
|
}
|
|
if got.run == nil {
|
|
admitted2.Close()
|
|
t.Fatal("blocked waiter expected non-nil result")
|
|
}
|
|
got.run.Close()
|
|
// Release the blocked waiter's lease.
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: got.run.Dispatch().RunID, Type: "complete"})
|
|
got.run.Close()
|
|
// Release admitted2's lease.
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: admitted2.Dispatch().RunID, Type: "complete"})
|
|
admitted2.Close()
|
|
case <-time.After(2 * time.Second):
|
|
admitted2.Close()
|
|
t.Fatal("raised capacity did not pump the blocked waiter")
|
|
}
|
|
|
|
aInFlight, aLong := providerResourceCounts(svc.queue, "node-cap-shrink", "prov-shrink")
|
|
if aInFlight != 0 || aLong != 0 {
|
|
t.Fatalf("expected full drain, got inFlight=%d longInFlight=%d", aInFlight, aLong)
|
|
}
|
|
if lc := leaseCount(svc.queue); lc != 0 {
|
|
t.Fatalf("expected leaseCount=0, got %d", lc)
|
|
}
|
|
}
|
|
|
|
// TestRefreshLongCapacityShrinkPreservesLeaseAndBlocksLongAdmission verifies
|
|
// that shrinking long context capacity preserves existing long leases and
|
|
// blocks new long admission until recovery.
|
|
func TestRefreshLongCapacityShrinkPreservesLeaseAndBlocksLongAdmission(t *testing.T) {
|
|
enabled := true
|
|
svc, applyFn := buildCapacityRebaseService(t, "node-lc-shrink", "prov-lc-shrink", "served-lc-shrink", 3, 2, &enabled)
|
|
|
|
admitted1, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-cap-rebase", ProviderPool: true, ContextClass: "long",
|
|
})
|
|
if err != nil || admitted1 == nil {
|
|
t.Fatalf("admit long 1 failed: %v", err)
|
|
}
|
|
admitted2, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-cap-rebase", ProviderPool: true, ContextClass: "long",
|
|
})
|
|
if err != nil || admitted2 == nil {
|
|
admitted1.Close()
|
|
t.Fatalf("admit long 2 failed: %v", err)
|
|
}
|
|
|
|
svc.queue.mu.Lock()
|
|
res := svc.queue.resources[providerResourceKey{nodeID: "node-lc-shrink", providerID: "prov-lc-shrink"}]
|
|
if res == nil || res.longInFlight != 2 {
|
|
svc.queue.mu.Unlock()
|
|
admitted1.Close()
|
|
admitted2.Close()
|
|
t.Fatalf("expected longInFlight=2, got %d", res.longInFlight)
|
|
}
|
|
svc.queue.mu.Unlock()
|
|
|
|
applyFn(3, 1, true)
|
|
|
|
svc.queue.mu.Lock()
|
|
if res.longCapacity != 1 {
|
|
svc.queue.mu.Unlock()
|
|
admitted1.Close()
|
|
admitted2.Close()
|
|
t.Fatalf("expected longCapacity=1, got %d", res.longCapacity)
|
|
}
|
|
if res.longInFlight != 2 {
|
|
svc.queue.mu.Unlock()
|
|
admitted1.Close()
|
|
admitted2.Close()
|
|
t.Fatalf("expected longInFlight preserved at 2, got %d", res.longInFlight)
|
|
}
|
|
svc.queue.mu.Unlock()
|
|
|
|
blockedCh := make(chan struct {
|
|
run RunResult
|
|
err error
|
|
}, 1)
|
|
go func() {
|
|
run, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-cap-rebase", ProviderPool: true, ContextClass: "long",
|
|
})
|
|
blockedCh <- struct {
|
|
run RunResult
|
|
err error
|
|
}{run: run, err: err}
|
|
}()
|
|
|
|
waitForQueueLen(t, svc.queue, "group-cap-rebase", 1)
|
|
inFlight, longInFlight := providerResourceCounts(svc.queue, "node-lc-shrink", "prov-lc-shrink")
|
|
if inFlight != 2 || longInFlight != 2 {
|
|
admitted1.Close()
|
|
admitted2.Close()
|
|
t.Fatalf("blocked long admission changed counts: inFlight=%d longInFlight=%d", inFlight, longInFlight)
|
|
}
|
|
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: admitted1.Dispatch().RunID, Type: "complete"})
|
|
applyFn(3, 2, true)
|
|
|
|
select {
|
|
case got := <-blockedCh:
|
|
if got.err != nil {
|
|
admitted2.Close()
|
|
t.Fatalf("blocked long waiter expected dispatch, got: %v", got.err)
|
|
}
|
|
got.run.Close()
|
|
// Release the blocked waiter's lease.
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: got.run.Dispatch().RunID, Type: "complete"})
|
|
got.run.Close()
|
|
// Release admitted2's lease.
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: admitted2.Dispatch().RunID, Type: "complete"})
|
|
admitted2.Close()
|
|
case <-time.After(2 * time.Second):
|
|
admitted2.Close()
|
|
t.Fatal("raised long capacity did not pump the blocked waiter")
|
|
}
|
|
|
|
aInFlight, aLong := providerResourceCounts(svc.queue, "node-lc-shrink", "prov-lc-shrink")
|
|
if aInFlight != 0 || aLong != 0 {
|
|
t.Fatalf("expected full drain, got inFlight=%d longInFlight=%d", aInFlight, aLong)
|
|
}
|
|
if lc := leaseCount(svc.queue); lc != 0 {
|
|
t.Fatalf("expected leaseCount=0, got %d", lc)
|
|
}
|
|
}
|
|
|
|
// TestRefreshPriorityChangesQueuedProviderSelection verifies that a priority
|
|
// refresh via SetRuntimeConfig is picked up by the live candidate resolver
|
|
// so that a queued waiter selects the higher-priority provider. The priority
|
|
// update itself leaves capacity and enabled state unchanged.
|
|
func TestRefreshPriorityChangesQueuedProviderSelection(t *testing.T) {
|
|
svc, applyConfig := buildPriorityService(t)
|
|
applyConfig(1, 1, 0, 10, true)
|
|
|
|
first, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-prio", ProviderPool: true, Background: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("first SubmitRun: %v", err)
|
|
}
|
|
second, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-prio", ProviderPool: true, Background: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("second SubmitRun: %v", err)
|
|
}
|
|
if first.Dispatch().ProviderID != "prov-a" || second.Dispatch().ProviderID != "prov-b" {
|
|
t.Fatalf("unexpected initial provider order: first=%q second=%q", first.Dispatch().ProviderID, second.Dispatch().ProviderID)
|
|
}
|
|
|
|
type submitResult struct {
|
|
run RunResult
|
|
err error
|
|
}
|
|
blockedCh := make(chan submitResult, 1)
|
|
go func() {
|
|
run, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-prio", ProviderPool: true, Background: true,
|
|
})
|
|
blockedCh <- submitResult{run: run, err: err}
|
|
}()
|
|
waitForQueueLen(t, svc.queue, "group-prio", 1)
|
|
|
|
// Change priority while both held slots are still occupied so the pump
|
|
// cannot dispatch the waiter. Then release both held slots under the
|
|
// manager lock so the waiter dispatches with the new priority ordering.
|
|
applyConfig(1, 1, 10, 0, true)
|
|
if pending := pendingItemCount(svc.queue); pending != 1 {
|
|
t.Fatalf("priority-only refresh changed pending count to %d, want 1", pending)
|
|
}
|
|
|
|
// Release both runs while holding the queue lock so the pump does not
|
|
// dispatch the waiter to the first-available provider on each release.
|
|
svc.queue.mu.Lock()
|
|
m := svc.queue
|
|
m.releaseLeaseLocked(m.leaseByRun[first.Dispatch().RunID])
|
|
m.releaseLeaseLocked(m.leaseByRun[second.Dispatch().RunID])
|
|
m.pumpAllLocked()
|
|
svc.queue.mu.Unlock()
|
|
|
|
var third RunResult
|
|
select {
|
|
case got := <-blockedCh:
|
|
if got.err != nil {
|
|
t.Fatalf("queued SubmitRun: %v", got.err)
|
|
}
|
|
third = got.run
|
|
if third.Dispatch().ProviderID != "prov-b" {
|
|
t.Fatalf("priority refresh selected %q, want prov-b", third.Dispatch().ProviderID)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("priority refresh did not pump the queued waiter")
|
|
}
|
|
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: third.Dispatch().RunID, Type: "complete"})
|
|
third.Close()
|
|
if pending := pendingItemCount(svc.queue); pending != 0 {
|
|
t.Fatalf("expected no pending items, got %d", pending)
|
|
}
|
|
if leases := leaseCount(svc.queue); leases != 0 {
|
|
t.Fatalf("expected no live leases, got %d", leases)
|
|
}
|
|
}
|
|
|
|
// TestRefreshDisableReenableReevaluatesAllWaiters verifies that a provider
|
|
// disable followed by re-enable re-evaluates ALL queued waiters through the
|
|
// live candidate resolver, not just the first.
|
|
func TestRefreshDisableReenableReevaluatesAllWaiters(t *testing.T) {
|
|
enabled := true
|
|
svc, applyConfig := buildCapacityRebaseService(t, "node-dre", "prov-dre", "served-dre", 2, 0, &enabled)
|
|
|
|
held := make([]RunResult, 0, 2)
|
|
for i := 0; i < 2; i++ {
|
|
run, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-cap-rebase", ProviderPool: true, Background: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("fill capacity %d: %v", i, err)
|
|
}
|
|
held = append(held, run)
|
|
}
|
|
|
|
type submitResult struct {
|
|
run RunResult
|
|
err error
|
|
}
|
|
waiters := make(chan submitResult, 2)
|
|
for i := 0; i < 2; i++ {
|
|
go func() {
|
|
run, err := svc.SubmitRun(t.Context(), SubmitRunRequest{
|
|
ModelGroupKey: "group-cap-rebase", ProviderPool: true, Background: true,
|
|
})
|
|
waiters <- submitResult{run: run, err: err}
|
|
}()
|
|
}
|
|
waitForQueueLen(t, svc.queue, "group-cap-rebase", 2)
|
|
|
|
// Disable while the waiters are queued, then free both held slots. A stale
|
|
// candidate would dispatch here; the live provider snapshot must keep both queued.
|
|
applyConfig(2, 0, false)
|
|
for _, run := range held {
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: run.Dispatch().RunID, Type: "complete"})
|
|
run.Close()
|
|
}
|
|
if pending := pendingItemCount(svc.queue); pending != 2 {
|
|
t.Fatalf("expected 2 pending while provider is disabled, got %d", pending)
|
|
}
|
|
if inFlight, _ := providerResourceCounts(svc.queue, "node-dre", "prov-dre"); inFlight != 0 {
|
|
t.Fatalf("expected held slots to drain while disabled, got inFlight=%d", inFlight)
|
|
}
|
|
|
|
// Re-enable through SetRuntimeConfig. Both real SubmitRun waiters must be
|
|
// re-evaluated and dispatched, not merely a synthetic queue item.
|
|
applyConfig(2, 0, true)
|
|
dispatched := make([]RunResult, 0, 2)
|
|
for i := 0; i < 2; i++ {
|
|
select {
|
|
case got := <-waiters:
|
|
if got.err != nil {
|
|
t.Fatalf("waiter %d after re-enable: %v", i, got.err)
|
|
}
|
|
dispatched = append(dispatched, got.run)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("waiter %d not dispatched after re-enable", i)
|
|
}
|
|
}
|
|
for _, run := range dispatched {
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: run.Dispatch().RunID, Type: "complete"})
|
|
run.Close()
|
|
}
|
|
|
|
aInFlight, aLong := providerResourceCounts(svc.queue, "node-dre", "prov-dre")
|
|
if aInFlight != 0 || aLong != 0 {
|
|
t.Fatalf("expected full drain, got inFlight=%d longInFlight=%d", aInFlight, aLong)
|
|
}
|
|
if lc := leaseCount(svc.queue); lc != 0 {
|
|
t.Fatalf("expected leaseCount=0, got %d", lc)
|
|
}
|
|
}
|
|
|
|
// --- PROVIDER_POLICY_REFRESH-3: concurrent refresh/admission/release race ---
|
|
|
|
// TestRefreshConcurrentAdmissionReleasePreservesProviderPolicyInvariants verifies
|
|
// refresh/admit/release/cancel races across normal and long-context requests.
|
|
func TestRefreshConcurrentAdmissionReleasePreservesProviderPolicyInvariants(t *testing.T) {
|
|
const (
|
|
nodeID = "node-concurrent"
|
|
provider = "prov-concurrent"
|
|
)
|
|
|
|
reg := edgenode.NewRegistry()
|
|
entry := &edgenode.NodeEntry{NodeID: nodeID}
|
|
reg.Register(entry)
|
|
svc := New(reg, edgeevents.NewBus())
|
|
|
|
catalog := []config.ModelCatalogEntry{{
|
|
ID: "group-concurrent",
|
|
Providers: map[string]string{provider: "served-concurrent"},
|
|
}}
|
|
|
|
cands := []candidateNode{{entry: entry, capacity: 4, providerID: provider}}
|
|
|
|
newStore := func(capacity, longCapacity int) *edgenode.NodeStore {
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: nodeID,
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{{
|
|
ID: provider,
|
|
Type: "vllm",
|
|
Adapter: "vllm-gpu",
|
|
Models: []string{"served-concurrent"},
|
|
Health: "available",
|
|
Capacity: capacity,
|
|
LongContextCapacity: longCapacity,
|
|
}},
|
|
})
|
|
return store
|
|
}
|
|
highPolicy := groupPolicy{maxQueue: 64, queueTimeout: 15 * time.Millisecond}
|
|
svc.SetRuntimeConfig(newStore(4, 2), catalog, highPolicy)
|
|
|
|
var (
|
|
seenMu sync.Mutex
|
|
seenLeases = map[uint64]struct{}{}
|
|
peak int
|
|
peakLong int
|
|
peakMu sync.Mutex
|
|
)
|
|
observe := func() {
|
|
inFlight, longInFlight := providerResourceCounts(svc.queue, nodeID, provider)
|
|
peakMu.Lock()
|
|
defer peakMu.Unlock()
|
|
if inFlight > peak {
|
|
peak = inFlight
|
|
}
|
|
if longInFlight > peakLong {
|
|
peakLong = longInFlight
|
|
}
|
|
}
|
|
|
|
errs := make(chan error, 2048)
|
|
report := func(err error) {
|
|
if err != nil {
|
|
errs <- err
|
|
}
|
|
}
|
|
start := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
var cancelWG sync.WaitGroup
|
|
|
|
// Repeatedly shrink and expand while workers are admitting and releasing.
|
|
// During each shrink interval occupancy may remain above the new limit due
|
|
// to preserved leases, but it must never grow beyond the post-shrink value.
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
for i := 0; i < 80; i++ {
|
|
svc.SetRuntimeConfig(newStore(2, 1), catalog, groupPolicy{
|
|
maxQueue: 64, queueTimeout: time.Duration(i%2) * 15 * time.Millisecond,
|
|
})
|
|
shrinkInFlight, shrinkLong := providerResourceCounts(svc.queue, nodeID, provider)
|
|
normalLimit := max(shrinkInFlight, 2)
|
|
longLimit := max(shrinkLong, 1)
|
|
for sample := 0; sample < 4; sample++ {
|
|
runtime.Gosched()
|
|
inFlight, longInFlight := providerResourceCounts(svc.queue, nodeID, provider)
|
|
if inFlight > normalLimit {
|
|
report(fmt.Errorf("post-shrink inFlight grew from %d to %d (limit %d)", shrinkInFlight, inFlight, normalLimit))
|
|
}
|
|
if longInFlight > longLimit {
|
|
report(fmt.Errorf("post-shrink longInFlight grew from %d to %d (limit %d)", shrinkLong, longInFlight, longLimit))
|
|
}
|
|
if inFlight > 4 || longInFlight > 2 {
|
|
report(fmt.Errorf("global capacity bound exceeded: inFlight=%d longInFlight=%d", inFlight, longInFlight))
|
|
}
|
|
}
|
|
svc.SetRuntimeConfig(newStore(4, 2), catalog, highPolicy)
|
|
}
|
|
}()
|
|
|
|
for worker := 0; worker < 8; worker++ {
|
|
worker := worker
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
for attempt := 0; attempt < 24; attempt++ {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
|
|
if attempt%4 == 0 {
|
|
cancelWG.Add(1)
|
|
go func() {
|
|
defer cancelWG.Done()
|
|
runtime.Gosched()
|
|
cancel()
|
|
}()
|
|
}
|
|
candidate, err := svc.queue.admit(ctx, "group-concurrent", "", "", cands,
|
|
highPolicy, nil, worker%2 == 0, true)
|
|
cancel()
|
|
if err != nil {
|
|
if !errors.Is(err, context.DeadlineExceeded) &&
|
|
!errors.Is(err, context.Canceled) &&
|
|
!errors.Is(err, errQueueTimeout) {
|
|
report(fmt.Errorf("worker %d admit %d: %w", worker, attempt, err))
|
|
}
|
|
continue
|
|
}
|
|
seenMu.Lock()
|
|
if _, duplicate := seenLeases[candidate.leaseID]; duplicate {
|
|
report(fmt.Errorf("lease %d handed out more than once", candidate.leaseID))
|
|
}
|
|
seenLeases[candidate.leaseID] = struct{}{}
|
|
seenMu.Unlock()
|
|
observe()
|
|
runtime.Gosched()
|
|
svc.queue.releaseLease(candidate.leaseID, "complete")
|
|
svc.queue.releaseLease(candidate.leaseID, "duplicate_complete")
|
|
}
|
|
}()
|
|
}
|
|
|
|
close(start)
|
|
wg.Wait()
|
|
cancelWG.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
svc.SetRuntimeConfig(newStore(4, 2), catalog, highPolicy)
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
inFlight, longInFlight := providerResourceCounts(svc.queue, nodeID, provider)
|
|
if inFlight == 0 && longInFlight == 0 && pendingItemCount(svc.queue) == 0 && leaseCount(svc.queue) == 0 {
|
|
break
|
|
}
|
|
runtime.Gosched()
|
|
}
|
|
|
|
peakMu.Lock()
|
|
defer peakMu.Unlock()
|
|
if len(seenLeases) == 0 {
|
|
t.Error("expected at least one successful concurrent admission")
|
|
}
|
|
if peak > 4 {
|
|
t.Errorf("provider in-flight peaked at %d, above configured capacity 4", peak)
|
|
}
|
|
if peakLong > 2 {
|
|
t.Errorf("long in-flight peaked at %d, above configured long capacity 2", peakLong)
|
|
}
|
|
|
|
inFlight, longInFlight := providerResourceCounts(svc.queue, nodeID, provider)
|
|
pending := pendingItemCount(svc.queue)
|
|
lc := leaseCount(svc.queue)
|
|
if inFlight != 0 {
|
|
t.Errorf("expected inFlight=0 at end, got %d", inFlight)
|
|
}
|
|
if longInFlight != 0 {
|
|
t.Errorf("expected longInFlight=0 at end, got %d", longInFlight)
|
|
}
|
|
if pending != 0 {
|
|
t.Errorf("expected pending=0 at end, got %d", pending)
|
|
}
|
|
if lc != 0 {
|
|
t.Errorf("expected leaseCount=0 at end, got %d", lc)
|
|
}
|
|
}
|
|
|
|
// resourceStateForTest returns the (present, orphan, generation, inFlight) view
|
|
// of a provider resource under the manager lock. Reconnect-activation tests use
|
|
// it to assert the orphan marker and generation transitions directly.
|
|
func resourceStateForTest(m *modelQueueManager, nodeID, providerID string) (present, orphan bool, generation uint64, inFlight int) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
res, ok := m.resources[providerResourceKey{nodeID: nodeID, providerID: providerID}]
|
|
if !ok {
|
|
return false, false, 0, 0
|
|
}
|
|
return true, res.orphan, res.generation, res.inFlight
|
|
}
|
|
|
|
// TestAcceptedReconnectActivatesCandidateAndPumpsWaiter verifies the S15
|
|
// reconnect-candidate-recovery contract at the service/queue boundary: with the
|
|
// only alternate provider full and the target node disconnected (its resource
|
|
// orphaned), a waiter is stranded; the accepted reconnect alone — via
|
|
// HandleNodeConnect — re-activates the node's resource for the new generation and
|
|
// pumps the waiter to it, with no new request, config refresh, or lease release.
|
|
// It also pins that a stale/absent-generation connect callback is a no-op.
|
|
func TestAcceptedReconnectActivatesCandidateAndPumpsWaiter(t *testing.T) {
|
|
const (
|
|
nodeA = "node-recon-a"
|
|
nodeB = "node-recon-b"
|
|
provA = "prov-recon-a"
|
|
provB = "prov-recon-b"
|
|
modelGroup = "group-recon"
|
|
servedA = "served-recon-a"
|
|
servedB = "served-recon-b"
|
|
)
|
|
|
|
reg := edgenode.NewRegistry()
|
|
entryA := &edgenode.NodeEntry{NodeID: nodeA}
|
|
entryB := &edgenode.NodeEntry{NodeID: nodeB}
|
|
reg.Register(entryA)
|
|
reg.Register(entryB)
|
|
genA := entryA.ConnectionGeneration
|
|
if genA == 0 {
|
|
t.Fatal("node A must carry a non-zero connection generation")
|
|
}
|
|
|
|
svc := New(reg, edgeevents.NewBus())
|
|
store := edgenode.NewNodeStore()
|
|
newRecord := func(nodeID, providerID, served string) *edgenode.NodeRecord {
|
|
return &edgenode.NodeRecord{
|
|
ID: nodeID,
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{{
|
|
ID: providerID, Type: "vllm", Adapter: "vllm-gpu",
|
|
Models: []string{served}, Health: "available", Capacity: 1,
|
|
}},
|
|
}
|
|
}
|
|
store.Add(newRecord(nodeA, provA, servedA))
|
|
store.Add(newRecord(nodeB, provB, servedB))
|
|
catalog := []config.ModelCatalogEntry{{
|
|
ID: modelGroup,
|
|
Providers: map[string]string{provA: servedA, provB: servedB},
|
|
}}
|
|
svc.SetRuntimeConfig(store, catalog, NewGroupPolicy(16, 3*time.Second))
|
|
|
|
// Fill the alternate provider B so it cannot absorb the waiter. Hand-build the
|
|
// candidate so the fill is forced onto B regardless of scheduler ordering.
|
|
fillCand := []candidateNode{{
|
|
entry: entryB, capacity: 1, providerID: provB,
|
|
servedTarget: servedB, generation: entryB.ConnectionGeneration,
|
|
}}
|
|
filled, err := svc.queue.admit(t.Context(), modelGroup, "", "", fillCand, groupPolicy{}, nil, false, true)
|
|
if err != nil || filled == nil {
|
|
t.Fatalf("fill alternate provider B: %v", err)
|
|
}
|
|
if inFlight, _ := providerResourceCounts(svc.queue, nodeB, provB); inFlight != 1 {
|
|
t.Fatalf("expected provB in-flight=1 after fill, got %d", inFlight)
|
|
}
|
|
|
|
// Disconnect node A (idle, holding no lease). The authoritative disconnect
|
|
// marks provA's resource orphan/offline.
|
|
reg.Unregister(nodeA)
|
|
svc.HandleNodeDisconnect(nodeA, genA, "disconnected")
|
|
present, orphan, _, _ := resourceStateForTest(svc.queue, nodeA, provA)
|
|
if !present || !orphan {
|
|
t.Fatalf("after disconnect provA resource present=%t orphan=%t, want true/true", present, orphan)
|
|
}
|
|
|
|
// A stale/absent-generation connect callback must not re-activate: node A is
|
|
// no longer the registry owner (it is absent), so IsCurrentOwnerGeneration
|
|
// fails and the callback is a no-op.
|
|
svc.HandleNodeConnect(nodeA, genA)
|
|
if _, stillOrphan, _, _ := resourceStateForTest(svc.queue, nodeA, provA); !stillOrphan {
|
|
t.Fatal("stale connect callback re-activated the orphaned resource")
|
|
}
|
|
|
|
// Enqueue the waiter. At enqueue time node A is disconnected, so the only live
|
|
// candidate is B, which is full — the request queues.
|
|
req := SubmitRunRequest{ModelGroupKey: modelGroup, ProviderPool: true}
|
|
type admitOutcome struct {
|
|
candidate *candidateNode
|
|
err error
|
|
}
|
|
waiterCh := make(chan admitOutcome, 1)
|
|
waiterCtx, cancelWaiter := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancelWaiter()
|
|
go func() {
|
|
enqCands, _, resolveErr := svc.resolveQueueCandidates(req)
|
|
if resolveErr != nil {
|
|
waiterCh <- admitOutcome{err: resolveErr}
|
|
return
|
|
}
|
|
candidate, admitErr := svc.queue.admit(
|
|
waiterCtx, modelGroup, "", "", enqCands, groupPolicy{},
|
|
svc.resolveQueueCandidatesClosure(req), false, true,
|
|
)
|
|
waiterCh <- admitOutcome{candidate: candidate, err: admitErr}
|
|
}()
|
|
|
|
waitForQueueLen(t, svc.queue, modelGroup, 1)
|
|
select {
|
|
case outcome := <-waiterCh:
|
|
t.Fatalf("waiter resolved before reconnect: candidate=%+v err=%v", outcome.candidate, outcome.err)
|
|
default:
|
|
}
|
|
|
|
// Reconnect the same identity: a strictly higher generation is minted. The
|
|
// accepted-connect hook alone must re-activate provA and dispatch the waiter —
|
|
// no config refresh, no new request, no lease release is issued here.
|
|
reconnectA := &edgenode.NodeEntry{NodeID: nodeA}
|
|
reg.Register(reconnectA)
|
|
newGen := reconnectA.ConnectionGeneration
|
|
if newGen <= genA {
|
|
t.Fatalf("reconnect generation %d must exceed the first %d", newGen, genA)
|
|
}
|
|
svc.HandleNodeConnect(nodeA, newGen)
|
|
|
|
select {
|
|
case outcome := <-waiterCh:
|
|
if outcome.err != nil {
|
|
t.Fatalf("waiter expected dispatch after reconnect, got error: %v", outcome.err)
|
|
}
|
|
if outcome.candidate == nil {
|
|
t.Fatal("waiter expected a candidate after reconnect")
|
|
}
|
|
if outcome.candidate.providerID != provA || outcome.candidate.entry.NodeID != nodeA {
|
|
t.Fatalf("waiter dispatched to (%q,%q), want (%q,%q)",
|
|
outcome.candidate.entry.NodeID, outcome.candidate.providerID, nodeA, provA)
|
|
}
|
|
if outcome.candidate.generation != newGen {
|
|
t.Fatalf("waiter candidate generation=%d, want the reconnect generation %d", outcome.candidate.generation, newGen)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("accepted reconnect did not pump the stranded waiter")
|
|
}
|
|
|
|
// The activation re-enabled provA for the reconnect generation and the pump
|
|
// reserved it. The alternate B's lease was never released, proving the pump was
|
|
// triggered by the reconnect alone.
|
|
present, orphan, gen, inFlightA := resourceStateForTest(svc.queue, nodeA, provA)
|
|
if !present || orphan {
|
|
t.Fatalf("after reconnect provA present=%t orphan=%t, want true/false", present, orphan)
|
|
}
|
|
if gen != newGen {
|
|
t.Fatalf("provA resource generation=%d, want %d", gen, newGen)
|
|
}
|
|
if inFlightA != 1 {
|
|
t.Fatalf("provA in-flight=%d after waiter dispatch, want 1", inFlightA)
|
|
}
|
|
if inFlightB, _ := providerResourceCounts(svc.queue, nodeB, provB); inFlightB != 1 {
|
|
t.Fatalf("provB in-flight=%d, want 1 (its lease must not have been released)", inFlightB)
|
|
}
|
|
_ = filled
|
|
}
|