장시간 무응답 attempt를 안전하게 fence하고 provider health와 분리 관측해야 중복 출력 없이 기존 recovery budget으로 재실행할 수 있다.
649 lines
25 KiB
Go
649 lines
25 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"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"
|
|
)
|
|
|
|
// The recovery-preference tests exercise the production admission path
|
|
// (admitWithRecovery → applyRecoveryPreferenceLocked → findAvailableNodeLocked →
|
|
// reserveCandidateLocked) rather than the pure helper. The avoided provider
|
|
// "prov-a-primary" sorts before the alternate "prov-b-backup", so the plain
|
|
// rotation would pick the avoided provider; a case that instead selects the
|
|
// alternate proves the preference actually changed the dispatched candidate.
|
|
const (
|
|
recoveryNodeID = "node-recovery"
|
|
recoveryAvoidID = "prov-a-primary"
|
|
recoveryAltID = "prov-b-backup"
|
|
recoveryServed = "served-x"
|
|
recoveryGroupKey = "recovery-model"
|
|
recoveryAvoidAdap = "vllm-a"
|
|
recoveryAltAdap = "vllm-b"
|
|
)
|
|
|
|
// newRecoveryQueueFixture builds a store-backed queue manager with two
|
|
// capacity-1 providers on one node and the provider-pool policy seeded from the
|
|
// store, matching how production reconciles resources before admission.
|
|
func newRecoveryQueueFixture(t *testing.T) (*modelQueueManager, *edgenode.NodeEntry) {
|
|
t.Helper()
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: recoveryNodeID,
|
|
Runtime: config.RuntimeConf{Concurrency: 1},
|
|
Providers: []config.NodeProviderConf{
|
|
{ID: recoveryAvoidID, Type: "vllm", Models: []string{recoveryServed}, Health: "available", Capacity: 1},
|
|
{ID: recoveryAltID, Type: "vllm", Models: []string{recoveryServed}, Health: "available", Capacity: 1},
|
|
},
|
|
})
|
|
m := newModelQueueManager(store)
|
|
m.setProviderPoolPolicyLocked(store, NewGroupPolicy(4, 5*time.Second))
|
|
entry := &edgenode.NodeEntry{NodeID: recoveryNodeID}
|
|
return m, entry
|
|
}
|
|
|
|
func recoveryCandidate(entry *edgenode.NodeEntry, providerID string) candidateNode {
|
|
return candidateNode{
|
|
entry: entry,
|
|
providerID: providerID,
|
|
servedTarget: recoveryServed,
|
|
capacity: 1,
|
|
generation: entry.ConnectionGeneration,
|
|
}
|
|
}
|
|
|
|
// markRecoveryUnavailable installs a runtime-health overlay that lowers one
|
|
// provider on the candidate's generation, without mutating any config.
|
|
func markRecoveryUnavailable(m *modelQueueManager, entry *edgenode.NodeEntry, providerID string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.runtimeHealth[providerRuntimeHealthKey{
|
|
nodeID: entry.NodeID,
|
|
generation: entry.ConnectionGeneration,
|
|
providerID: providerID,
|
|
}] = &providerRuntimeHealthOverlay{
|
|
adapter: recoveryAvoidAdap,
|
|
target: recoveryServed,
|
|
observationSeq: 1,
|
|
unavailable: true,
|
|
}
|
|
}
|
|
|
|
// TestProviderRecoverySelectionImmediateAdmission drives the immediate
|
|
// provider-pool admission path for every recovery branch and asserts the
|
|
// selected provider (or typed rejection), exactly one lease per dispatch, and
|
|
// that every counter settles back to zero after release with no forbidden
|
|
// reservation on a rejected policy.
|
|
func TestProviderRecoverySelectionImmediateAdmission(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
candidates []string // provider ids present in the request
|
|
unavailable string // provider id lowered by runtime overlay, or ""
|
|
recovery recoveryCandidatePolicy
|
|
wantProviderID string // expected dispatched provider, or "" when rejected
|
|
wantErr error // expected terminal error, or nil on dispatch
|
|
}{
|
|
{
|
|
name: "eligible_alternate_preferred_over_avoided",
|
|
candidates: []string{recoveryAvoidID, recoveryAltID},
|
|
recovery: recoveryCandidatePolicy{avoidProviderID: recoveryAvoidID},
|
|
wantProviderID: recoveryAltID,
|
|
},
|
|
{
|
|
name: "same_only_fallback_true_selects_avoided",
|
|
candidates: []string{recoveryAvoidID},
|
|
recovery: recoveryCandidatePolicy{avoidProviderID: recoveryAvoidID, allowAvoidedProviderFallback: true},
|
|
wantProviderID: recoveryAvoidID,
|
|
},
|
|
{
|
|
name: "same_only_fallback_false_rejects",
|
|
candidates: []string{recoveryAvoidID},
|
|
recovery: recoveryCandidatePolicy{avoidProviderID: recoveryAvoidID},
|
|
wantErr: ErrProviderPoolCandidateRejected,
|
|
},
|
|
{
|
|
name: "unavailable_alternate_fallback_true_selects_avoided",
|
|
candidates: []string{recoveryAvoidID, recoveryAltID},
|
|
unavailable: recoveryAltID,
|
|
recovery: recoveryCandidatePolicy{avoidProviderID: recoveryAvoidID, allowAvoidedProviderFallback: true},
|
|
wantProviderID: recoveryAvoidID,
|
|
},
|
|
{
|
|
name: "unavailable_alternate_fallback_false_rejects",
|
|
candidates: []string{recoveryAvoidID, recoveryAltID},
|
|
unavailable: recoveryAltID,
|
|
recovery: recoveryCandidatePolicy{avoidProviderID: recoveryAvoidID},
|
|
wantErr: ErrProviderPoolCandidateRejected,
|
|
},
|
|
{
|
|
name: "unavailable_avoided_selects_alternate",
|
|
candidates: []string{recoveryAvoidID, recoveryAltID},
|
|
unavailable: recoveryAvoidID,
|
|
recovery: recoveryCandidatePolicy{avoidProviderID: recoveryAvoidID},
|
|
wantProviderID: recoveryAltID,
|
|
},
|
|
{
|
|
name: "same_only_runtime_unavailable_is_terminal",
|
|
candidates: []string{recoveryAvoidID},
|
|
unavailable: recoveryAvoidID,
|
|
recovery: recoveryCandidatePolicy{avoidProviderID: recoveryAvoidID, allowAvoidedProviderFallback: true},
|
|
wantErr: errProviderUnavailable,
|
|
},
|
|
{
|
|
name: "empty_hints_dispatches_by_rotation",
|
|
candidates: []string{recoveryAvoidID, recoveryAltID},
|
|
recovery: recoveryCandidatePolicy{},
|
|
wantProviderID: recoveryAvoidID, // lowest providerID wins the rotation
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m, entry := newRecoveryQueueFixture(t)
|
|
if tc.unavailable != "" {
|
|
markRecoveryUnavailable(m, entry, tc.unavailable)
|
|
}
|
|
candidates := make([]candidateNode, 0, len(tc.candidates))
|
|
for _, id := range tc.candidates {
|
|
candidates = append(candidates, recoveryCandidate(entry, id))
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
|
|
defer cancel()
|
|
selected, _, err := m.admitWithRecovery(ctx, recoveryGroupKey, "", recoveryServed, candidates, groupPolicy{}, nil, false, true, tc.recovery)
|
|
|
|
if tc.wantErr != nil {
|
|
if !errors.Is(err, tc.wantErr) {
|
|
t.Fatalf("err=%v, want %v", err, tc.wantErr)
|
|
}
|
|
if selected != nil {
|
|
t.Fatalf("rejected policy reserved candidate %+v", selected)
|
|
}
|
|
if lc := leaseCount(m); lc != 0 {
|
|
t.Fatalf("leaseCount=%d after rejection, want 0", lc)
|
|
}
|
|
for _, id := range []string{recoveryAvoidID, recoveryAltID} {
|
|
if inflight, _ := providerResourceCounts(m, recoveryNodeID, id); inflight != 0 {
|
|
t.Fatalf("provider %s in-flight=%d after rejection, want 0", id, inflight)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("admit err=%v, want dispatch of %s", err, tc.wantProviderID)
|
|
}
|
|
if selected == nil || selected.providerID != tc.wantProviderID {
|
|
t.Fatalf("selected=%+v, want providerID=%s", selected, tc.wantProviderID)
|
|
}
|
|
if lc := leaseCount(m); lc != 1 {
|
|
t.Fatalf("leaseCount=%d after dispatch, want exactly 1", lc)
|
|
}
|
|
if inflight, _ := providerResourceCounts(m, recoveryNodeID, tc.wantProviderID); inflight != 1 {
|
|
t.Fatalf("provider %s in-flight=%d after dispatch, want 1", tc.wantProviderID, inflight)
|
|
}
|
|
|
|
// Release the lease and confirm every counter settles.
|
|
m.releaseLease(selected.leaseID, "test-settle")
|
|
if lc := leaseCount(m); lc != 0 {
|
|
t.Fatalf("leaseCount=%d after release, want 0", lc)
|
|
}
|
|
if inflight, _ := providerResourceCounts(m, recoveryNodeID, tc.wantProviderID); inflight != 0 {
|
|
t.Fatalf("provider %s in-flight=%d after release, want 0", tc.wantProviderID, inflight)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// recoveryAdmitResult carries a queued admission outcome back to the test body.
|
|
type recoveryAdmitResult struct {
|
|
candidate *candidateNode
|
|
err error
|
|
}
|
|
|
|
// TestProviderRecoverySelectionQueuedReresolution proves the queued path
|
|
// reapplies the identical request-local recovery policy after a runtime-health
|
|
// overlay change lands between enqueue and pump: an eligible alternate that
|
|
// disappears either promotes the avoided provider under explicit fallback or
|
|
// yields a typed terminal rejection when fallback is not permitted.
|
|
func TestProviderRecoverySelectionQueuedReresolution(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
fallback bool
|
|
wantProviderID string
|
|
wantErr error
|
|
}{
|
|
{name: "fallback_true_promotes_avoided", fallback: true, wantProviderID: recoveryAvoidID},
|
|
{name: "fallback_false_rejects", fallback: false, wantErr: ErrProviderPoolCandidateRejected},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m, entry := newRecoveryQueueFixture(t)
|
|
|
|
// Occupy the alternate's only slot so a recovery request that prefers
|
|
// it must queue instead of dispatching immediately.
|
|
filler, _, err := m.admitWithRecovery(t.Context(), "filler-group", "", recoveryServed,
|
|
[]candidateNode{recoveryCandidate(entry, recoveryAltID)}, groupPolicy{}, nil, false, true, recoveryCandidatePolicy{})
|
|
if err != nil || filler == nil || filler.providerID != recoveryAltID {
|
|
t.Fatalf("filler admit: candidate=%+v err=%v", filler, err)
|
|
}
|
|
|
|
resolver := func() ([]candidateNode, error) {
|
|
return []candidateNode{
|
|
recoveryCandidate(entry, recoveryAvoidID),
|
|
recoveryCandidate(entry, recoveryAltID),
|
|
}, nil
|
|
}
|
|
|
|
resultCh := make(chan recoveryAdmitResult, 1)
|
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
|
defer cancel()
|
|
go func() {
|
|
candidate, _, admitErr := m.admitWithRecovery(ctx, recoveryGroupKey, "", recoveryServed,
|
|
[]candidateNode{recoveryCandidate(entry, recoveryAvoidID), recoveryCandidate(entry, recoveryAltID)},
|
|
groupPolicy{}, resolver, false, true,
|
|
recoveryCandidatePolicy{avoidProviderID: recoveryAvoidID, allowAvoidedProviderFallback: tc.fallback})
|
|
resultCh <- recoveryAdmitResult{candidate: candidate, err: admitErr}
|
|
}()
|
|
|
|
requireProviderPoolPending(t, m, 1)
|
|
|
|
// Overlay change before pump: the alternate becomes runtime-unavailable.
|
|
markRecoveryUnavailable(m, entry, recoveryAltID)
|
|
m.mu.Lock()
|
|
m.pumpAllLocked()
|
|
m.mu.Unlock()
|
|
|
|
result := <-resultCh
|
|
if tc.wantErr != nil {
|
|
if !errors.Is(result.err, tc.wantErr) {
|
|
t.Fatalf("queued err=%v, want %v", result.err, tc.wantErr)
|
|
}
|
|
if result.candidate != nil {
|
|
t.Fatalf("queued rejection reserved candidate %+v", result.candidate)
|
|
}
|
|
// Only the filler lease remains.
|
|
if lc := leaseCount(m); lc != 1 {
|
|
t.Fatalf("leaseCount=%d after queued rejection, want 1 (filler only)", lc)
|
|
}
|
|
} else {
|
|
if result.err != nil {
|
|
t.Fatalf("queued admit err=%v, want dispatch of %s", result.err, tc.wantProviderID)
|
|
}
|
|
if result.candidate == nil || result.candidate.providerID != tc.wantProviderID {
|
|
t.Fatalf("queued selected=%+v, want providerID=%s", result.candidate, tc.wantProviderID)
|
|
}
|
|
if inflight, _ := providerResourceCounts(m, recoveryNodeID, tc.wantProviderID); inflight != 1 {
|
|
t.Fatalf("provider %s in-flight=%d after queued dispatch, want 1", tc.wantProviderID, inflight)
|
|
}
|
|
if lc := leaseCount(m); lc != 2 {
|
|
t.Fatalf("leaseCount=%d after queued dispatch, want 2 (filler + recovery)", lc)
|
|
}
|
|
m.releaseLease(result.candidate.leaseID, "test-settle")
|
|
}
|
|
|
|
// The pending queue must have drained in both branches.
|
|
m.mu.Lock()
|
|
pending := m.pendingProviderPoolCountLocked()
|
|
m.mu.Unlock()
|
|
if pending != 0 {
|
|
t.Fatalf("pending=%d after pump, want 0", pending)
|
|
}
|
|
|
|
m.releaseLease(filler.leaseID, "test-cleanup")
|
|
if lc := leaseCount(m); lc != 0 {
|
|
t.Fatalf("leaseCount=%d after full cleanup, want 0", lc)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestProviderRecoverySelectionServiceDispatchPrefersAlternate exercises the
|
|
// full SubmitProviderPool surface over net.Pipe: with the avoided provider
|
|
// suppressed, the returned DispatchInfo names the alternate, and exactly one
|
|
// provider tunnel request reaches the node (the avoided provider is never
|
|
// dispatched).
|
|
func TestProviderRecoverySelectionServiceDispatchPrefersAlternate(t *testing.T) {
|
|
edgeConn, nodeConn := net.Pipe()
|
|
t.Cleanup(func() {
|
|
_ = edgeConn.Close()
|
|
_ = nodeConn.Close()
|
|
})
|
|
|
|
parserMap := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.ProviderTunnelRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.ProviderTunnelRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
edgeClient := toki.NewTcpClient(edgeConn, 0, 0, parserMap)
|
|
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, parserMap)
|
|
|
|
var capturedMu sync.Mutex
|
|
var capturedCount int
|
|
var capturedAdapter string
|
|
toki.AddListenerTyped[*iop.ProviderTunnelRequest](&nodeClient.Communicator, func(req *iop.ProviderTunnelRequest) {
|
|
capturedMu.Lock()
|
|
capturedCount++
|
|
// The alternate and avoided providers use distinct adapter instances
|
|
// (vllm-b vs vllm-a) but the same served target, so the wire adapter is
|
|
// the identity that proves which provider was dispatched.
|
|
capturedAdapter = req.GetAdapter()
|
|
capturedMu.Unlock()
|
|
})
|
|
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: recoveryNodeID,
|
|
Runtime: config.RuntimeConf{Concurrency: 4},
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: recoveryAvoidAdap, Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
{Name: recoveryAltAdap, Enabled: true, Endpoint: "http://127.0.0.1:8001/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{
|
|
{ID: recoveryAvoidID, Adapter: recoveryAvoidAdap, Type: "vllm", Models: []string{recoveryServed}, Health: "available", Capacity: 1},
|
|
{ID: recoveryAltID, Adapter: recoveryAltAdap, Type: "vllm", Models: []string{recoveryServed}, Health: "available", Capacity: 1},
|
|
},
|
|
})
|
|
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{
|
|
NodeID: recoveryNodeID,
|
|
LifecycleState: edgenode.LifecycleConnected,
|
|
Client: edgeClient,
|
|
CredentialRecipientKeyID: "recipient-recovery",
|
|
CredentialRecipientPublicKey: make([]byte, 32),
|
|
})
|
|
|
|
svc := New(reg, edgeevents.NewBus())
|
|
svc.SetNodeStore(store)
|
|
svc.SetModelCatalog([]config.ModelCatalogEntry{
|
|
{ID: recoveryGroupKey, Providers: map[string]string{recoveryAvoidID: recoveryServed, recoveryAltID: recoveryServed}},
|
|
})
|
|
|
|
result, err := svc.SubmitProviderPool(context.Background(), ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{
|
|
ModelGroupKey: recoveryGroupKey,
|
|
ProviderPool: true,
|
|
Background: true,
|
|
},
|
|
AvoidProviderID: recoveryAvoidID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitProviderPool: %v", err)
|
|
}
|
|
if result == nil || result.Path != ProviderPoolPathTunnel {
|
|
t.Fatalf("result=%+v, want tunnel path", result)
|
|
}
|
|
if result.Tunnel != nil {
|
|
defer result.Tunnel.Close()
|
|
}
|
|
if result.DispatchInfo.ProviderID != recoveryAltID {
|
|
t.Fatalf("DispatchInfo.ProviderID=%q, want %q (avoided provider must be suppressed)", result.DispatchInfo.ProviderID, recoveryAltID)
|
|
}
|
|
|
|
waitForCondition(t, func() bool {
|
|
capturedMu.Lock()
|
|
defer capturedMu.Unlock()
|
|
return capturedCount == 1
|
|
}, "expected exactly one provider tunnel request to reach the node")
|
|
|
|
capturedMu.Lock()
|
|
defer capturedMu.Unlock()
|
|
if capturedCount != 1 {
|
|
t.Fatalf("captured %d provider tunnel requests, want exactly 1", capturedCount)
|
|
}
|
|
if capturedAdapter != recoveryAltAdap {
|
|
t.Fatalf("wire adapter=%q, want %q (avoided provider was dispatched)", capturedAdapter, recoveryAltAdap)
|
|
}
|
|
if got := inflightRunCount(svc.queue); got != 1 {
|
|
t.Fatalf("inflight run count=%d after single dispatch, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestProviderRecoverySelectionServiceQueuedReresolution crosses the public
|
|
// SubmitProviderPool surface with its default resolver. The alternate first
|
|
// fills its capacity; a queued recovery request has no operation or custom
|
|
// predicate, then re-resolves against a changed live catalog when the filler
|
|
// lease releases. This proves the request-local recovery policy survives the
|
|
// public queued path rather than only a queue-core fixture.
|
|
func TestProviderRecoverySelectionServiceQueuedReresolution(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
fallback bool
|
|
wantProviderID string
|
|
wantRecoveryWire int
|
|
wantErr error
|
|
}{
|
|
{name: "fallback_true_dispatches_the_now_only_avoided_provider", fallback: true, wantProviderID: recoveryAvoidID, wantRecoveryWire: 2},
|
|
{name: "fallback_false_terminates_without_avoided_dispatch", fallback: false, wantRecoveryWire: 1, wantErr: ErrProviderPoolCandidateRejected},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
edgeConn, nodeConn := net.Pipe()
|
|
t.Cleanup(func() {
|
|
_ = edgeConn.Close()
|
|
_ = nodeConn.Close()
|
|
})
|
|
|
|
parserMap := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.ProviderTunnelRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.ProviderTunnelRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
edgeClient := toki.NewTcpClient(edgeConn, 0, 0, parserMap)
|
|
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, parserMap)
|
|
|
|
var capturedMu sync.Mutex
|
|
var capturedAdapters []string
|
|
toki.AddListenerTyped[*iop.ProviderTunnelRequest](&nodeClient.Communicator, func(req *iop.ProviderTunnelRequest) {
|
|
capturedMu.Lock()
|
|
capturedAdapters = append(capturedAdapters, req.GetAdapter())
|
|
capturedMu.Unlock()
|
|
})
|
|
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: recoveryNodeID,
|
|
Runtime: config.RuntimeConf{Concurrency: 4},
|
|
Adapters: config.AdaptersConf{VllmInstances: []config.VllmInstanceConf{
|
|
{Name: recoveryAvoidAdap, Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
{Name: recoveryAltAdap, Enabled: true, Endpoint: "http://127.0.0.1:8001/v1"},
|
|
}},
|
|
Providers: []config.NodeProviderConf{
|
|
{ID: recoveryAvoidID, Adapter: recoveryAvoidAdap, Type: "vllm", Models: []string{recoveryServed}, Health: "available", Capacity: 1},
|
|
{ID: recoveryAltID, Adapter: recoveryAltAdap, Type: "vllm", Models: []string{recoveryServed}, Health: "available", Capacity: 1},
|
|
},
|
|
})
|
|
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{
|
|
NodeID: recoveryNodeID,
|
|
LifecycleState: edgenode.LifecycleConnected,
|
|
Client: edgeClient,
|
|
CredentialRecipientKeyID: "recipient-recovery",
|
|
CredentialRecipientPublicKey: make([]byte, 32),
|
|
})
|
|
svc := New(reg, edgeevents.NewBus())
|
|
svc.SetNodeStore(store)
|
|
|
|
// Fill the alternate before the recovery request sees both candidates.
|
|
svc.SetModelCatalog([]config.ModelCatalogEntry{{
|
|
ID: recoveryGroupKey, Providers: map[string]string{recoveryAltID: recoveryServed},
|
|
}})
|
|
filler, err := svc.SubmitProviderPool(t.Context(), ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{ModelGroupKey: recoveryGroupKey, ProviderPool: true, Background: true},
|
|
})
|
|
if err != nil || filler == nil || filler.DispatchInfo.ProviderID != recoveryAltID {
|
|
t.Fatalf("alternate filler: result=%+v err=%v", filler, err)
|
|
}
|
|
waitForCondition(t, func() bool {
|
|
capturedMu.Lock()
|
|
defer capturedMu.Unlock()
|
|
return len(capturedAdapters) == 1
|
|
}, "expected one alternate filler tunnel dispatch")
|
|
|
|
// The recovery request has the default empty operation and no custom
|
|
// predicate. While the alternate is capacity-full it must remain queued.
|
|
svc.SetModelCatalog([]config.ModelCatalogEntry{{
|
|
ID: recoveryGroupKey, Providers: map[string]string{recoveryAvoidID: recoveryServed, recoveryAltID: recoveryServed},
|
|
}})
|
|
resultCh := make(chan *ProviderPoolDispatchResult, 1)
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
result, submitErr := svc.SubmitProviderPool(t.Context(), ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{ModelGroupKey: recoveryGroupKey, ProviderPool: true, Background: true},
|
|
AvoidProviderID: recoveryAvoidID,
|
|
AllowAvoidedProviderFallback: tc.fallback,
|
|
})
|
|
resultCh <- result
|
|
errCh <- submitErr
|
|
}()
|
|
requireProviderPoolPending(t, svc.queue, 1)
|
|
|
|
// Re-resolution must observe the changed catalog, not the enqueue-time
|
|
// slice. Releasing the filler is the production queue pump trigger.
|
|
svc.SetModelCatalog([]config.ModelCatalogEntry{{
|
|
ID: recoveryGroupKey, Providers: map[string]string{recoveryAvoidID: recoveryServed},
|
|
}})
|
|
svc.queue.releaseRun(filler.DispatchInfo.RunID, "test-release-filler")
|
|
|
|
result := <-resultCh
|
|
err = <-errCh
|
|
if tc.wantErr != nil {
|
|
if !errors.Is(err, tc.wantErr) {
|
|
t.Fatalf("queued SubmitProviderPool err=%v, want %v", err, tc.wantErr)
|
|
}
|
|
if result != nil {
|
|
t.Fatalf("terminal recovery returned result=%+v", result)
|
|
}
|
|
} else {
|
|
if err != nil || result == nil || result.DispatchInfo.ProviderID != tc.wantProviderID {
|
|
t.Fatalf("queued recovery result=%+v err=%v, want provider %q", result, err, tc.wantProviderID)
|
|
}
|
|
if lc := leaseCount(svc.queue); lc != 1 {
|
|
t.Fatalf("leaseCount=%d after recovery dispatch, want exactly 1", lc)
|
|
}
|
|
svc.queue.releaseRun(result.DispatchInfo.RunID, "test-release-recovery")
|
|
}
|
|
|
|
waitForCondition(t, func() bool {
|
|
capturedMu.Lock()
|
|
defer capturedMu.Unlock()
|
|
return len(capturedAdapters) == tc.wantRecoveryWire
|
|
}, "unexpected provider tunnel dispatch count")
|
|
capturedMu.Lock()
|
|
gotAdapters := append([]string(nil), capturedAdapters...)
|
|
capturedMu.Unlock()
|
|
if gotAdapters[0] != recoveryAltAdap {
|
|
t.Fatalf("filler adapter=%q, want %q", gotAdapters[0], recoveryAltAdap)
|
|
}
|
|
if tc.fallback && gotAdapters[1] != recoveryAvoidAdap {
|
|
t.Fatalf("fallback adapter=%q, want permitted avoided adapter %q", gotAdapters[1], recoveryAvoidAdap)
|
|
}
|
|
if lc := leaseCount(svc.queue); lc != 0 {
|
|
t.Fatalf("leaseCount=%d after cleanup, want 0", lc)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestProviderRecoverySelectionServiceRejectsUnavailableOrUnknownAvoidedProvider
|
|
// covers the same-only terminal branches through SubmitProviderPool. Neither a
|
|
// runtime-unavailable avoided provider nor a configured-unknown one may reserve
|
|
// a lease or emit a provider tunnel request, even when same-provider fallback is
|
|
// explicitly permitted.
|
|
func TestProviderRecoverySelectionServiceRejectsUnavailableOrUnknownAvoidedProvider(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
configuredHealth string
|
|
markRuntimeOffline bool
|
|
wantErr error
|
|
}{
|
|
{name: "runtime_unavailable", configuredHealth: "available", markRuntimeOffline: true, wantErr: errProviderUnavailable},
|
|
{name: "configured_unknown", configuredHealth: "unknown"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
edgeConn, nodeConn := net.Pipe()
|
|
t.Cleanup(func() {
|
|
_ = edgeConn.Close()
|
|
_ = nodeConn.Close()
|
|
})
|
|
parserMap := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.ProviderTunnelRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.ProviderTunnelRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
edgeClient := toki.NewTcpClient(edgeConn, 0, 0, parserMap)
|
|
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, parserMap)
|
|
var capturedMu sync.Mutex
|
|
captured := 0
|
|
toki.AddListenerTyped[*iop.ProviderTunnelRequest](&nodeClient.Communicator, func(*iop.ProviderTunnelRequest) {
|
|
capturedMu.Lock()
|
|
captured++
|
|
capturedMu.Unlock()
|
|
})
|
|
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: recoveryNodeID,
|
|
Runtime: config.RuntimeConf{Concurrency: 1},
|
|
Adapters: config.AdaptersConf{VllmInstances: []config.VllmInstanceConf{{
|
|
Name: recoveryAvoidAdap, Enabled: true, Endpoint: "http://127.0.0.1:8000/v1",
|
|
}}},
|
|
Providers: []config.NodeProviderConf{{
|
|
ID: recoveryAvoidID, Adapter: recoveryAvoidAdap, Type: "vllm", Models: []string{recoveryServed}, Health: tc.configuredHealth, Capacity: 1,
|
|
}},
|
|
})
|
|
reg := edgenode.NewRegistry()
|
|
entry := &edgenode.NodeEntry{NodeID: recoveryNodeID, LifecycleState: edgenode.LifecycleConnected, Client: edgeClient}
|
|
reg.Register(entry)
|
|
svc := New(reg, edgeevents.NewBus())
|
|
svc.SetNodeStore(store)
|
|
svc.SetModelCatalog([]config.ModelCatalogEntry{{
|
|
ID: recoveryGroupKey, Providers: map[string]string{recoveryAvoidID: recoveryServed},
|
|
}})
|
|
if tc.markRuntimeOffline {
|
|
markRecoveryUnavailable(svc.queue, entry, recoveryAvoidID)
|
|
}
|
|
|
|
result, err := svc.SubmitProviderPool(t.Context(), ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{ModelGroupKey: recoveryGroupKey, ProviderPool: true, Background: true},
|
|
AvoidProviderID: recoveryAvoidID,
|
|
AllowAvoidedProviderFallback: true,
|
|
})
|
|
if tc.wantErr != nil {
|
|
if !errors.Is(err, tc.wantErr) {
|
|
t.Fatalf("SubmitProviderPool err=%v, want %v", err, tc.wantErr)
|
|
}
|
|
} else if err == nil {
|
|
t.Fatal("configured-unknown provider unexpectedly dispatched")
|
|
}
|
|
if result != nil {
|
|
t.Fatalf("terminal branch returned result=%+v", result)
|
|
}
|
|
if lc := leaseCount(svc.queue); lc != 0 {
|
|
t.Fatalf("leaseCount=%d after terminal branch, want 0", lc)
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
capturedMu.Lock()
|
|
defer capturedMu.Unlock()
|
|
if captured != 0 {
|
|
t.Fatalf("captured %d provider tunnel requests after terminal branch, want 0", captured)
|
|
}
|
|
})
|
|
}
|
|
}
|