- 노드 연결 2단계 핸드셰이크 추가 (Register→NodeReady→DispatchReady) - ConnectionGeneration 기반 연결 세대 관리로 stale 연결 차폐 - configured 노드 catalog 기반 snapshot rebuild (Connected 상태 분리) - provider 리스 소유권 일원화: edge가 소유권 승인·반환 전까지 대기 - 모델 대기열 승인/해제/스냅샷 서비스 구현 - 재연결 준비도 통합 테스트, 아카이브된 하위태스크 8건 포함
2031 lines
73 KiB
Go
2031 lines
73 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"strings"
|
|
"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"
|
|
)
|
|
|
|
func TestRunHandleCloseIsIdempotent(t *testing.T) {
|
|
calls := 0
|
|
handle := &RunHandle{
|
|
close: func() {
|
|
calls++
|
|
},
|
|
}
|
|
|
|
handle.Close()
|
|
handle.Close()
|
|
|
|
if calls != 1 {
|
|
t.Fatalf("close called %d times, want 1", calls)
|
|
}
|
|
}
|
|
|
|
func waitForCondition(t *testing.T, cond func() bool, msg string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if cond() {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatal(msg)
|
|
}
|
|
|
|
func inflightRunCount(q *modelQueueManager) int {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
return len(q.leaseByRun)
|
|
}
|
|
|
|
// TestRouteProviderTunnelFrameDoesNotPublishToBus verifies that tunnel frames
|
|
// reach only the request-bound tunnel subscriber and never the run event bus
|
|
// fanout (negative test for the passthrough/no-sideband boundary).
|
|
func TestRouteProviderTunnelFrameDoesNotPublishToBus(t *testing.T) {
|
|
bus := edgeevents.NewBus()
|
|
svc := New(edgenode.NewRegistry(), bus)
|
|
|
|
runCh, unsubRun := bus.SubscribeAllRuns(8)
|
|
defer unsubRun()
|
|
|
|
tunnelCh, unsubTunnel := svc.tunnels.subscribe("tunnel-neg-1", 8)
|
|
defer unsubTunnel()
|
|
|
|
frame := &iop.ProviderTunnelFrame{
|
|
RunId: "run-neg-1",
|
|
TunnelId: "tunnel-neg-1",
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
|
|
Body: []byte("data: raw provider bytes\n\n"),
|
|
}
|
|
svc.RouteProviderTunnelFrame(frame)
|
|
|
|
select {
|
|
case got := <-tunnelCh:
|
|
if string(got.GetBody()) != string(frame.GetBody()) {
|
|
t.Fatalf("tunnel frame body mismatch: %q", got.GetBody())
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("tunnel subscriber did not receive the frame")
|
|
}
|
|
|
|
select {
|
|
case e := <-runCh:
|
|
t.Fatalf("tunnel frame leaked into run event bus: %+v", e)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
// TestRouteProviderTunnelFrameWithoutSubscriberIsDropped verifies routing a
|
|
// frame with no subscriber neither blocks nor panics.
|
|
func TestRouteProviderTunnelFrameWithoutSubscriberIsDropped(t *testing.T) {
|
|
svc := New(edgenode.NewRegistry(), nil)
|
|
svc.RouteProviderTunnelFrame(&iop.ProviderTunnelFrame{TunnelId: "no-sub"})
|
|
}
|
|
|
|
type providerTunnelTestEnv struct {
|
|
svc *Service
|
|
nodeClient *toki.TcpClient
|
|
capturedMu sync.Mutex
|
|
captured *iop.ProviderTunnelRequest
|
|
}
|
|
|
|
func (e *providerTunnelTestEnv) capturedRequest() *iop.ProviderTunnelRequest {
|
|
e.capturedMu.Lock()
|
|
defer e.capturedMu.Unlock()
|
|
return e.captured
|
|
}
|
|
|
|
// newProviderTunnelTestEnv wires a provider-pool service to a fake node over
|
|
// net.Pipe and captures the ProviderTunnelRequest the node receives.
|
|
func newProviderTunnelTestEnv(t *testing.T) *providerTunnelTestEnv {
|
|
t.Helper()
|
|
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)
|
|
|
|
env := &providerTunnelTestEnv{nodeClient: nodeClient}
|
|
toki.AddListenerTyped[*iop.ProviderTunnelRequest](&nodeClient.Communicator, func(req *iop.ProviderTunnelRequest) {
|
|
env.capturedMu.Lock()
|
|
env.captured = req
|
|
env.capturedMu.Unlock()
|
|
})
|
|
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-pool",
|
|
Runtime: config.RuntimeConf{Concurrency: 4},
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{
|
|
{
|
|
ID: "prov-vllm-01",
|
|
Adapter: "vllm-gpu",
|
|
Type: "vllm",
|
|
Models: []string{"served-qwen"},
|
|
Health: "available",
|
|
Capacity: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{
|
|
NodeID: "node-pool",
|
|
LifecycleState: edgenode.LifecycleConnected,
|
|
Client: edgeClient,
|
|
})
|
|
|
|
svc := New(reg, edgeevents.NewBus())
|
|
svc.SetNodeStore(store)
|
|
svc.SetModelCatalog([]config.ModelCatalogEntry{
|
|
{ID: "qwen3.6:35b", Providers: map[string]string{"prov-vllm-01": "served-qwen"}},
|
|
})
|
|
env.svc = svc
|
|
return env
|
|
}
|
|
|
|
// TestSubmitProviderTunnelProviderPoolSendsRequestAndReleasesSlotOnEnd covers
|
|
// the provider-pool tunnel path end to end: admission rewrites adapter/target,
|
|
// BuildBody receives the served target, ordered frames reach the handle, and
|
|
// the admission slot is released on the END frame.
|
|
func TestSubmitProviderTunnelProviderPoolSendsRequestAndReleasesSlotOnEnd(t *testing.T) {
|
|
env := newProviderTunnelTestEnv(t)
|
|
svc := env.svc
|
|
|
|
var builtTarget string
|
|
handle, err := svc.SubmitProviderTunnel(context.Background(), SubmitProviderTunnelRequest{
|
|
RunID: "run-tunnel-001",
|
|
ModelGroupKey: "qwen3.6:35b",
|
|
Method: "POST",
|
|
Path: "/v1/chat/completions",
|
|
BuildBody: func(target string) ([]byte, error) {
|
|
builtTarget = target
|
|
return []byte(`{"model":"` + target + `","stream":true}`), nil
|
|
},
|
|
Stream: true,
|
|
ProviderPool: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitProviderTunnel: %v", err)
|
|
}
|
|
defer handle.Close()
|
|
|
|
if builtTarget != "served-qwen" {
|
|
t.Errorf("BuildBody target: got %q, want served-qwen", builtTarget)
|
|
}
|
|
dispatch := handle.Dispatch()
|
|
if dispatch.Adapter != "vllm-gpu" || dispatch.Target != "served-qwen" {
|
|
t.Errorf("dispatch adapter/target: got %q/%q", dispatch.Adapter, dispatch.Target)
|
|
}
|
|
|
|
waitForCondition(t, func() bool { return env.capturedRequest() != nil },
|
|
"fake node did not receive ProviderTunnelRequest")
|
|
captured := env.capturedRequest()
|
|
if captured.GetAdapter() != "vllm-gpu" || captured.GetTarget() != "served-qwen" {
|
|
t.Errorf("wire adapter/target: got %q/%q", captured.GetAdapter(), captured.GetTarget())
|
|
}
|
|
if !strings.Contains(string(captured.GetBody()), `"model":"served-qwen"`) {
|
|
t.Errorf("wire body missing served model: %s", captured.GetBody())
|
|
}
|
|
if inflightRunCount(svc.queue) != 1 {
|
|
t.Fatalf("expected 1 inflight run after dispatch, got %d", inflightRunCount(svc.queue))
|
|
}
|
|
|
|
tunnelID := captured.GetTunnelId()
|
|
frames := []*iop.ProviderTunnelFrame{
|
|
{RunId: captured.GetRunId(), TunnelId: tunnelID, Sequence: 0, Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200},
|
|
{RunId: captured.GetRunId(), TunnelId: tunnelID, Sequence: 1, Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte("data: chunk-1\n\n")},
|
|
{RunId: captured.GetRunId(), TunnelId: tunnelID, Sequence: 2, Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true},
|
|
}
|
|
for _, f := range frames {
|
|
svc.RouteProviderTunnelFrame(f)
|
|
}
|
|
|
|
stream := handle.Stream()
|
|
for i, want := range frames {
|
|
select {
|
|
case got := <-stream.Frames:
|
|
if got.GetSequence() != want.GetSequence() || got.GetKind() != want.GetKind() {
|
|
t.Fatalf("frame %d: got seq=%d kind=%v", i, got.GetSequence(), got.GetKind())
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("frame %d not delivered", i)
|
|
}
|
|
}
|
|
select {
|
|
case _, open := <-stream.Frames:
|
|
if open {
|
|
t.Fatal("expected frame channel to close after END")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("frame channel not closed after END")
|
|
}
|
|
|
|
waitForCondition(t, func() bool { return inflightRunCount(svc.queue) == 0 },
|
|
"admission slot not released after END frame")
|
|
}
|
|
|
|
// TestSubmitProviderTunnelCloseReleasesSlot verifies the cancel path: closing
|
|
// the handle before a terminal frame releases the provider-pool slot.
|
|
func TestSubmitProviderTunnelCloseReleasesSlot(t *testing.T) {
|
|
env := newProviderTunnelTestEnv(t)
|
|
svc := env.svc
|
|
|
|
handle, err := svc.SubmitProviderTunnel(context.Background(), SubmitProviderTunnelRequest{
|
|
RunID: "run-tunnel-cancel",
|
|
ModelGroupKey: "qwen3.6:35b",
|
|
Method: "POST",
|
|
Path: "/v1/chat/completions",
|
|
Body: []byte(`{"model":"qwen3.6:35b"}`),
|
|
ProviderPool: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitProviderTunnel: %v", err)
|
|
}
|
|
if inflightRunCount(svc.queue) != 1 {
|
|
t.Fatalf("expected 1 inflight run after dispatch, got %d", inflightRunCount(svc.queue))
|
|
}
|
|
|
|
handle.Close()
|
|
|
|
waitForCondition(t, func() bool { return inflightRunCount(svc.queue) == 0 },
|
|
"admission slot not released after Close")
|
|
}
|
|
|
|
// TestProviderTunnelRouterBackpressureBlocksWithoutDrop verifies the router
|
|
// blocks the producer while the subscriber buffer is full instead of dropping
|
|
// frames, and delivers every frame in order once the subscriber drains.
|
|
func TestProviderTunnelRouterBackpressureBlocksWithoutDrop(t *testing.T) {
|
|
svc := New(edgenode.NewRegistry(), nil)
|
|
ch, unsub := svc.tunnels.subscribe("tunnel-bp", 2)
|
|
defer unsub()
|
|
|
|
const total = 6
|
|
routed := make(chan struct{})
|
|
go func() {
|
|
defer close(routed)
|
|
for i := 0; i < total; i++ {
|
|
svc.tunnels.route(&iop.ProviderTunnelFrame{
|
|
TunnelId: "tunnel-bp",
|
|
Sequence: int64(i),
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
|
|
})
|
|
}
|
|
}()
|
|
|
|
// With buffer 2 the producer cannot finish all routes before the
|
|
// subscriber drains: full buffer must block, not drop.
|
|
select {
|
|
case <-routed:
|
|
t.Fatal("route calls finished while the subscriber buffer was full; expected blocking backpressure")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
for i := 0; i < total; i++ {
|
|
select {
|
|
case f := <-ch:
|
|
if f.GetSequence() != int64(i) {
|
|
t.Fatalf("frame %d: got sequence %d; frames must stay ordered and lossless under backpressure", i, f.GetSequence())
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("frame %d not delivered after draining", i)
|
|
}
|
|
}
|
|
|
|
select {
|
|
case <-routed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("producer still blocked after all frames were drained")
|
|
}
|
|
}
|
|
|
|
// TestProviderTunnelRouterCloseUnblocksBlockedRoute verifies unsubscribe (the
|
|
// handle Close/cancel path) releases a route call blocked on a full buffer, so
|
|
// an abandoned tunnel can never wedge the transport frame reader.
|
|
func TestProviderTunnelRouterCloseUnblocksBlockedRoute(t *testing.T) {
|
|
svc := New(edgenode.NewRegistry(), nil)
|
|
_, unsub := svc.tunnels.subscribe("tunnel-close", 1)
|
|
|
|
frame := func(seq int64) *iop.ProviderTunnelFrame {
|
|
return &iop.ProviderTunnelFrame{
|
|
TunnelId: "tunnel-close",
|
|
Sequence: seq,
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
|
|
}
|
|
}
|
|
|
|
blocked := make(chan struct{})
|
|
go func() {
|
|
defer close(blocked)
|
|
svc.tunnels.route(frame(0)) // fills the buffer
|
|
svc.tunnels.route(frame(1)) // blocks until close releases it
|
|
}()
|
|
|
|
select {
|
|
case <-blocked:
|
|
t.Fatal("second route must block on the full subscriber buffer")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
unsub()
|
|
|
|
select {
|
|
case <-blocked:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("unsubscribe did not release the blocked route call")
|
|
}
|
|
|
|
// A late frame for the closed tunnel is dropped without blocking.
|
|
svc.tunnels.route(frame(2))
|
|
}
|
|
|
|
// TestProviderTunnelReleaseExactlyOnceAcrossEndAndClose verifies the tracked
|
|
// provider-pool slot (capacity 1) is released exactly once when both the
|
|
// terminal END frame and handle Close fire, and that the freed slot admits the
|
|
// next tunnel immediately instead of queueing.
|
|
func TestProviderTunnelReleaseExactlyOnceAcrossEndAndClose(t *testing.T) {
|
|
env := newProviderTunnelTestEnv(t)
|
|
svc := env.svc
|
|
|
|
submit := func(runID string) ProviderTunnelResult {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
handle, err := svc.SubmitProviderTunnel(ctx, SubmitProviderTunnelRequest{
|
|
RunID: runID,
|
|
ModelGroupKey: "qwen3.6:35b",
|
|
Method: "POST",
|
|
Path: "/v1/chat/completions",
|
|
Body: []byte(`{"model":"qwen3.6:35b"}`),
|
|
ProviderPool: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitProviderTunnel(%s): %v", runID, err)
|
|
}
|
|
return handle
|
|
}
|
|
|
|
handle1 := submit("run-release-1")
|
|
waitForCondition(t, func() bool {
|
|
captured := env.capturedRequest()
|
|
return captured != nil && captured.GetRunId() == "run-release-1"
|
|
}, "fake node did not receive the first ProviderTunnelRequest")
|
|
captured := env.capturedRequest()
|
|
|
|
svc.RouteProviderTunnelFrame(&iop.ProviderTunnelFrame{
|
|
RunId: captured.GetRunId(),
|
|
TunnelId: captured.GetTunnelId(),
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END,
|
|
End: true,
|
|
})
|
|
stream := handle1.Stream()
|
|
for {
|
|
if _, open := <-stream.Frames; !open {
|
|
break
|
|
}
|
|
}
|
|
waitForCondition(t, func() bool { return inflightRunCount(svc.queue) == 0 },
|
|
"admission slot not released after END frame")
|
|
if got := leaseCount(svc.queue); got != 0 {
|
|
t.Fatalf("live leases after END frame: got %d, want 0", got)
|
|
}
|
|
|
|
// Close after the terminal frame must stay a no-op release: the lease it
|
|
// would target is already gone, so there is nothing left to decrement.
|
|
handle1.Close()
|
|
if got := inflightRunCount(svc.queue); got != 0 {
|
|
t.Fatalf("inflight count after END+Close: got %d, want 0", got)
|
|
}
|
|
if got := leaseCount(svc.queue); got != 0 {
|
|
t.Fatalf("live leases after END+Close: got %d, want 0", got)
|
|
}
|
|
|
|
// The capacity-1 slot is free again: the next tunnel is admitted without
|
|
// waiting on the queue (a leaked slot would block this submit).
|
|
handle2 := submit("run-release-2")
|
|
defer handle2.Close()
|
|
waitForCondition(t, func() bool {
|
|
captured := env.capturedRequest()
|
|
return captured != nil && captured.GetRunId() == "run-release-2"
|
|
}, "fake node did not receive the second ProviderTunnelRequest after slot release")
|
|
if got := inflightRunCount(svc.queue); got != 1 {
|
|
t.Fatalf("inflight count after second dispatch: got %d, want 1", got)
|
|
}
|
|
// The second tunnel holds its own lease identity, so a late release carrying
|
|
// the first tunnel's id can never free it.
|
|
if got := leaseCount(svc.queue); got != 1 {
|
|
t.Fatalf("live leases after second dispatch: got %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestSubmitProviderTunnelErrorFrameReleasesSlot verifies the ERROR frame is
|
|
// terminal and releases the provider-pool slot like END.
|
|
func TestSubmitProviderTunnelErrorFrameReleasesSlot(t *testing.T) {
|
|
env := newProviderTunnelTestEnv(t)
|
|
svc := env.svc
|
|
|
|
handle, err := svc.SubmitProviderTunnel(context.Background(), SubmitProviderTunnelRequest{
|
|
RunID: "run-tunnel-error",
|
|
ModelGroupKey: "qwen3.6:35b",
|
|
Method: "POST",
|
|
Path: "/v1/chat/completions",
|
|
Body: []byte(`{"model":"qwen3.6:35b"}`),
|
|
ProviderPool: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitProviderTunnel: %v", err)
|
|
}
|
|
defer handle.Close()
|
|
|
|
waitForCondition(t, func() bool { return env.capturedRequest() != nil },
|
|
"fake node did not receive ProviderTunnelRequest")
|
|
captured := env.capturedRequest()
|
|
|
|
svc.RouteProviderTunnelFrame(&iop.ProviderTunnelFrame{
|
|
RunId: captured.GetRunId(),
|
|
TunnelId: captured.GetTunnelId(),
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR,
|
|
Error: "provider unavailable",
|
|
})
|
|
|
|
stream := handle.Stream()
|
|
select {
|
|
case got := <-stream.Frames:
|
|
if got.GetKind() != iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR {
|
|
t.Fatalf("expected ERROR frame, got %v", got.GetKind())
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("ERROR frame not delivered")
|
|
}
|
|
|
|
waitForCondition(t, func() bool { return inflightRunCount(svc.queue) == 0 },
|
|
"admission slot not released after ERROR frame")
|
|
}
|
|
|
|
// TestSubmitProviderPoolSelectsTunnelProviderAndReleasesSlot verifies that
|
|
// SubmitProviderPool with an OpenAI-compatible provider (tunnel path) performs
|
|
// a single admission, sends a ProviderTunnelRequest, and releases the slot on
|
|
// tunnel END frame.
|
|
func TestSubmitProviderPoolSelectsTunnelProviderAndReleasesSlot(t *testing.T) {
|
|
env := newProviderTunnelTestEnv(t)
|
|
svc := env.svc
|
|
|
|
// Catalog entry with a vLLM provider (tunnel path).
|
|
catalog := []config.ModelCatalogEntry{
|
|
{ID: "qwen3.6:35b", Providers: map[string]string{"prov-vllm-01": "served-qwen"}},
|
|
}
|
|
svc.SetModelCatalog(catalog)
|
|
|
|
result, err := svc.SubmitProviderPool(context.Background(), ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{
|
|
ModelGroupKey: "qwen3.6:35b",
|
|
ProviderPool: true,
|
|
Background: true,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitProviderPool: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if result.Path != ProviderPoolPathTunnel {
|
|
t.Fatalf("expected path=tunnel, got %q", result.Path)
|
|
}
|
|
if result.Tunnel == nil {
|
|
t.Fatal("expected tunnel handle for tunnel path")
|
|
}
|
|
if result.Run != nil {
|
|
t.Fatal("expected nil run for tunnel path")
|
|
}
|
|
|
|
// Verify inflight after dispatch.
|
|
if got := inflightRunCount(svc.queue); got != 1 {
|
|
t.Fatalf("expected 1 inflight after dispatch, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestSubmitProviderPoolSelectsNormalizedProviderAndDoesNotOpenTunnel verifies
|
|
// that SubmitProviderPool with an Ollama provider (normalized path) performs
|
|
// a single admission, sends a RunRequest (not ProviderTunnelRequest), and
|
|
// the tunnel handle is nil.
|
|
func TestSubmitProviderPoolSelectsNormalizedProviderAndDoesNotOpenTunnel(t *testing.T) {
|
|
// Build a provider-pool service with an Ollama provider.
|
|
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)
|
|
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, parserMap)
|
|
|
|
// Capture the RunRequest received by the fake node.
|
|
var capturedRunReq *iop.RunRequest
|
|
var capturedMu sync.Mutex
|
|
toki.AddListenerTyped[*iop.RunRequest](&nodeClient.Communicator, func(req *iop.RunRequest) {
|
|
capturedMu.Lock()
|
|
capturedRunReq = req
|
|
capturedMu.Unlock()
|
|
})
|
|
|
|
// Build NodeStore with an Ollama provider (normalized path).
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-ollama",
|
|
Runtime: config.RuntimeConf{Concurrency: 2},
|
|
Adapters: config.AdaptersConf{
|
|
OllamaInstances: []config.OllamaInstanceConf{
|
|
{Name: "ollama", Enabled: true, BaseURL: "http://127.0.0.1:11434"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{
|
|
{
|
|
ID: "prov-ollama-01",
|
|
Adapter: "ollama",
|
|
Type: "ollama",
|
|
Models: []string{"served-qwen"},
|
|
Health: "available",
|
|
Capacity: 2,
|
|
},
|
|
},
|
|
})
|
|
|
|
// Build catalog with Ollama provider.
|
|
catalog := []config.ModelCatalogEntry{
|
|
{ID: "qwen3.6:35b", Providers: map[string]string{"prov-ollama-01": "served-qwen"}},
|
|
}
|
|
|
|
// Build registry with the fake node.
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{
|
|
NodeID: "node-ollama",
|
|
LifecycleState: edgenode.LifecycleConnected,
|
|
Client: edgeClient,
|
|
})
|
|
|
|
// Create Service with queue and catalog.
|
|
bus := edgeevents.NewBus()
|
|
svc := New(reg, bus)
|
|
svc.SetNodeStore(store)
|
|
svc.SetModelCatalog(catalog)
|
|
|
|
// SubmitProviderPool with Ollama provider → normalized path.
|
|
result, err := svc.SubmitProviderPool(context.Background(), ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{
|
|
ModelGroupKey: "qwen3.6:35b",
|
|
ProviderPool: true,
|
|
Background: true,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitProviderPool: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if result.Path != ProviderPoolPathNormalized {
|
|
t.Fatalf("expected path=normalized, got %q", result.Path)
|
|
}
|
|
if result.Run == nil {
|
|
t.Fatal("expected run handle for normalized path")
|
|
}
|
|
if result.Tunnel != nil {
|
|
t.Fatal("expected nil tunnel for normalized path")
|
|
}
|
|
|
|
// Wait for the fake node to receive the RunRequest.
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
capturedMu.Lock()
|
|
defer capturedMu.Unlock()
|
|
|
|
if capturedRunReq == nil {
|
|
t.Fatal("no RunRequest captured from fake node; SubmitProviderPool did not send")
|
|
}
|
|
|
|
// Verify that the adapter and target were rewritten from the provider-pool candidate.
|
|
if capturedRunReq.GetAdapter() != "ollama" {
|
|
t.Errorf("adapter: got %q, want %q", capturedRunReq.GetAdapter(), "ollama")
|
|
}
|
|
if capturedRunReq.GetTarget() != "served-qwen" {
|
|
t.Errorf("target: got %q, want %q", capturedRunReq.GetTarget(), "served-qwen")
|
|
}
|
|
|
|
// Verify inflight after dispatch.
|
|
if got := inflightRunCount(svc.queue); got != 1 {
|
|
t.Fatalf("expected 1 inflight after dispatch, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestSubmitProviderPoolUsesSingleAdmissionForMixedCandidates verifies that
|
|
// SubmitProviderPool performs exactly one queue admission when presented with
|
|
// mixed tunnel+normalized candidates (e.g., vLLM + Ollama). The selected
|
|
// candidate (by in-flight/priority rotation) determines the path.
|
|
func TestSubmitProviderPoolUsesSingleAdmissionForMixedCandidates(t *testing.T) {
|
|
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)
|
|
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, parserMap)
|
|
|
|
var capturedRunReq *iop.RunRequest
|
|
var capturedMu sync.Mutex
|
|
toki.AddListenerTyped[*iop.RunRequest](&nodeClient.Communicator, func(req *iop.RunRequest) {
|
|
capturedMu.Lock()
|
|
capturedRunReq = req
|
|
capturedMu.Unlock()
|
|
})
|
|
|
|
// Build NodeStore with two providers: vLLM (tunnel) and Ollama (normalized).
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-mixed",
|
|
Runtime: config.RuntimeConf{Concurrency: 4},
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{
|
|
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
|
|
},
|
|
OllamaInstances: []config.OllamaInstanceConf{
|
|
{Name: "ollama", Enabled: true, BaseURL: "http://127.0.0.1:11434"},
|
|
},
|
|
},
|
|
Providers: []config.NodeProviderConf{
|
|
{
|
|
ID: "prov-vllm",
|
|
Adapter: "vllm-gpu",
|
|
Type: "vllm",
|
|
Models: []string{"served-model"},
|
|
Health: "available",
|
|
Capacity: 2,
|
|
},
|
|
{
|
|
ID: "prov-ollama",
|
|
Adapter: "ollama",
|
|
Type: "ollama",
|
|
Models: []string{"served-model"},
|
|
Health: "available",
|
|
Capacity: 2,
|
|
},
|
|
},
|
|
})
|
|
|
|
// Catalog with both providers.
|
|
catalog := []config.ModelCatalogEntry{
|
|
{ID: "mixed-model", Providers: map[string]string{"prov-vllm": "served-model", "prov-ollama": "served-model"}},
|
|
}
|
|
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{
|
|
NodeID: "node-mixed",
|
|
LifecycleState: edgenode.LifecycleConnected,
|
|
Client: edgeClient,
|
|
})
|
|
|
|
bus := edgeevents.NewBus()
|
|
svc := New(reg, bus)
|
|
svc.SetNodeStore(store)
|
|
svc.SetModelCatalog(catalog)
|
|
|
|
// SubmitProviderPool → should do exactly one admission.
|
|
result, err := svc.SubmitProviderPool(context.Background(), ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{
|
|
ModelGroupKey: "mixed-model",
|
|
ProviderPool: true,
|
|
Background: true,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitProviderPool: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
|
|
// The selected provider depends on admission order (vLLM or Ollama by
|
|
// providerID alphabetical tie-break). Verify only one request was sent.
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
capturedMu.Lock()
|
|
defer capturedMu.Unlock()
|
|
|
|
if capturedRunReq == nil {
|
|
t.Fatal("expected exactly one RunRequest for normalized path; admission must be single")
|
|
}
|
|
|
|
// Verify the request target matches the catalog served model.
|
|
if capturedRunReq.GetTarget() != "served-model" {
|
|
t.Errorf("target: got %q, want %q", capturedRunReq.GetTarget(), "served-model")
|
|
}
|
|
|
|
// Verify inflight is exactly 1 (single admission).
|
|
if got := inflightRunCount(svc.queue); got != 1 {
|
|
t.Fatalf("expected 1 inflight (single admission), got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestDispatchRejectsStaleGenerationBeforeSend pins the pre-send generation
|
|
// fence on both execution paths: when the connection a lease was admitted under
|
|
// is superseded by a reconnect before dispatch, the normalized path releases the
|
|
// lease without ever sending a RunRequest, and the tunnel path releases it
|
|
// without ever sending a ProviderTunnelRequest, to the stale (dead) client. Both
|
|
// request parsers are wired to the stale connection so a leaked send of either
|
|
// type is caught directly.
|
|
func TestDispatchRejectsStaleGenerationBeforeSend(t *testing.T) {
|
|
t.Run("normalized", func(t *testing.T) {
|
|
staleGenerationFenceCase(t, providerExecutionPathNormalized)
|
|
})
|
|
t.Run("tunnel", func(t *testing.T) {
|
|
staleGenerationFenceCase(t, providerExecutionPathTunnel)
|
|
})
|
|
}
|
|
|
|
// staleGenerationFenceCase drives one execution path through its provider-pool
|
|
// dispatch entry point with a lease admitted under a connection a reconnect has
|
|
// already superseded, and asserts the fence releases the lease and fails with no
|
|
// request of any type reaching the stale connection.
|
|
func staleGenerationFenceCase(t *testing.T, path providerExecutionPath) {
|
|
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)
|
|
},
|
|
toki.TypeNameOf(&iop.ProviderTunnelRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.ProviderTunnelRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
staleClient := toki.NewTcpClient(edgeConn, 0, 0, parserMap)
|
|
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, parserMap)
|
|
|
|
var capturedMu sync.Mutex
|
|
captured := 0
|
|
toki.AddListenerTyped[*iop.RunRequest](&nodeClient.Communicator, func(*iop.RunRequest) {
|
|
capturedMu.Lock()
|
|
captured++
|
|
capturedMu.Unlock()
|
|
})
|
|
toki.AddListenerTyped[*iop.ProviderTunnelRequest](&nodeClient.Communicator, func(*iop.ProviderTunnelRequest) {
|
|
capturedMu.Lock()
|
|
captured++
|
|
capturedMu.Unlock()
|
|
})
|
|
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-stale",
|
|
Runtime: config.RuntimeConf{Concurrency: 1},
|
|
Adapters: config.AdaptersConf{
|
|
OllamaInstances: []config.OllamaInstanceConf{{Name: "ollama", Enabled: true}},
|
|
},
|
|
Providers: []config.NodeProviderConf{
|
|
{ID: "prov-stale", Adapter: "ollama", Type: "ollama", Models: []string{"served"}, Health: "available", Capacity: 1},
|
|
},
|
|
})
|
|
|
|
reg := edgenode.NewRegistry()
|
|
// entry1 is the connection admission selected; it holds the stale client.
|
|
entry1 := &edgenode.NodeEntry{NodeID: "node-stale", LifecycleState: edgenode.LifecycleConnected, Client: staleClient}
|
|
reg.Register(entry1)
|
|
staleGeneration := entry1.ConnectionGeneration
|
|
// A reconnect supersedes entry1: the registry's current owner is a strictly
|
|
// higher generation, so entry1's lease is now stale.
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-stale", LifecycleState: edgenode.LifecycleConnected, Client: &toki.TcpClient{}})
|
|
if reg.IsCurrentOwnerGeneration("node-stale", staleGeneration) {
|
|
t.Fatal("reconnect should have superseded the stale generation")
|
|
}
|
|
|
|
svc := New(reg, edgeevents.NewBus())
|
|
svc.SetNodeStore(store)
|
|
svc.SetModelCatalog([]config.ModelCatalogEntry{
|
|
{ID: "stale-model", Providers: map[string]string{"prov-stale": "served"}},
|
|
})
|
|
|
|
// Admit a lease bound to entry1's (now stale) generation on the path under test.
|
|
selectedCand := candidateNode{
|
|
entry: entry1,
|
|
providerID: "prov-stale",
|
|
servedTarget: "served",
|
|
generation: staleGeneration,
|
|
capacity: 1,
|
|
adapter: "ollama",
|
|
providerType: "ollama",
|
|
executionPath: path,
|
|
}
|
|
admitted, _, err := svc.queue.admitWithReason(context.Background(), "stale-model", "ollama", "served", []candidateNode{selectedCand}, groupPolicy{}, nil, false, false)
|
|
if err != nil || admitted == nil {
|
|
t.Fatalf("admit: %v", err)
|
|
}
|
|
if leaseCount(svc.queue) != 1 {
|
|
t.Fatalf("expected 1 live lease after admit, got %d", leaseCount(svc.queue))
|
|
}
|
|
reservation := newQueueReservation(svc.queue, admitted)
|
|
|
|
switch path {
|
|
case providerExecutionPathNormalized:
|
|
req := SubmitRunRequest{ModelGroupKey: "stale-model", ProviderPool: true, Background: true}
|
|
result, err := svc.dispatchProviderPoolRun(context.Background(), req, "ollama", "served", admitted, "dispatched", reservation)
|
|
if err == nil {
|
|
t.Fatal("expected stale-generation dispatch to be rejected before send")
|
|
}
|
|
if result != nil {
|
|
t.Fatal("expected nil result on a fenced dispatch")
|
|
}
|
|
if !strings.Contains(err.Error(), "connection changed before dispatch") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
case providerExecutionPathTunnel:
|
|
req := ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{ModelGroupKey: "stale-model", ProviderPool: true},
|
|
Tunnel: SubmitProviderTunnelRequest{
|
|
ModelGroupKey: "stale-model",
|
|
ProviderPool: true,
|
|
Method: "POST",
|
|
Path: "/v1/chat/completions",
|
|
},
|
|
}
|
|
result, err := svc.dispatchProviderPoolTunnel(req, "ollama", "served", admitted, "dispatched", reservation)
|
|
if err == nil {
|
|
t.Fatal("expected stale-generation tunnel dispatch to be rejected before send")
|
|
}
|
|
if result != nil {
|
|
t.Fatal("expected nil result on a fenced tunnel dispatch")
|
|
}
|
|
if !strings.Contains(err.Error(), "connection changed before dispatch") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
default:
|
|
t.Fatalf("unhandled execution path %q", path)
|
|
}
|
|
|
|
// The fenced dispatch must have released its lease and never sent a request.
|
|
if got := leaseCount(svc.queue); got != 0 {
|
|
t.Fatalf("expected the fenced dispatch to release its lease, got %d live leases", got)
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
capturedMu.Lock()
|
|
sent := captured
|
|
capturedMu.Unlock()
|
|
if sent != 0 {
|
|
t.Fatalf("a request reached the stale connection %d time(s); want none", sent)
|
|
}
|
|
}
|
|
|
|
// TestSubmitProviderPoolDispatchInfoObservation verifies that the provider-pool
|
|
// one-shot dispatch carries selected provider id, provider type, and execution
|
|
// path in RunDispatch on both tunnel and normalized paths (SURFACE_OBS-1).
|
|
func TestSubmitProviderPoolDispatchInfoObservation(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
providerType string
|
|
wantPath providerPoolPath
|
|
wantExecPath string
|
|
}{
|
|
{
|
|
name: "vllm_tunnel",
|
|
providerType: "vllm",
|
|
wantPath: ProviderPoolPathTunnel,
|
|
wantExecPath: "provider_tunnel",
|
|
},
|
|
{
|
|
name: "ollama_normalized",
|
|
providerType: "ollama",
|
|
wantPath: ProviderPoolPathNormalized,
|
|
wantExecPath: "normalized",
|
|
},
|
|
{
|
|
name: "cli_normalized",
|
|
providerType: "cli",
|
|
wantPath: ProviderPoolPathNormalized,
|
|
wantExecPath: "normalized",
|
|
},
|
|
} {
|
|
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.RunRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RunRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
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)
|
|
_ = toki.NewTcpClient(nodeConn, 0, 0, parserMap)
|
|
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-obs",
|
|
Runtime: config.RuntimeConf{Concurrency: 2},
|
|
Adapters: config.AdaptersConf{
|
|
VllmInstances: []config.VllmInstanceConf{{Name: "vllm-gpu", Enabled: true}},
|
|
OllamaInstances: []config.OllamaInstanceConf{{Name: "ollama", Enabled: true}},
|
|
OpenAICompatInstances: []config.OpenAICompatInstanceConf{{Name: "openai", Enabled: true}},
|
|
},
|
|
Providers: []config.NodeProviderConf{
|
|
{
|
|
ID: "prov-obs",
|
|
Adapter: "vllm-gpu",
|
|
Type: tc.providerType,
|
|
Models: []string{"served-model"},
|
|
Health: "available",
|
|
Capacity: 2,
|
|
},
|
|
},
|
|
})
|
|
|
|
catalog := []config.ModelCatalogEntry{
|
|
{ID: "obs-model", Providers: map[string]string{"prov-obs": "served-model"}},
|
|
}
|
|
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{
|
|
NodeID: "node-obs",
|
|
LifecycleState: edgenode.LifecycleConnected,
|
|
Client: edgeClient,
|
|
})
|
|
|
|
bus := edgeevents.NewBus()
|
|
svc := New(reg, bus)
|
|
svc.SetNodeStore(store)
|
|
svc.SetModelCatalog(catalog)
|
|
|
|
result, err := svc.SubmitProviderPool(context.Background(), ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{
|
|
ModelGroupKey: "obs-model",
|
|
ProviderPool: true,
|
|
Background: true,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitProviderPool: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if result.Path != tc.wantPath {
|
|
t.Fatalf("path: got %q, want %q", result.Path, tc.wantPath)
|
|
}
|
|
|
|
disp := result.DispatchInfo
|
|
if disp.ProviderID != "prov-obs" {
|
|
t.Errorf("provider_id: got %q, want %q", disp.ProviderID, "prov-obs")
|
|
}
|
|
if disp.ProviderType != tc.providerType {
|
|
t.Errorf("provider_type: got %q, want %q", disp.ProviderType, tc.providerType)
|
|
}
|
|
if disp.ExecutionPath != tc.wantExecPath {
|
|
t.Errorf("execution_path: got %q, want %q", disp.ExecutionPath, tc.wantExecPath)
|
|
}
|
|
if disp.QueueReason != "dispatched" {
|
|
t.Errorf("queue_reason: got %q, want %q", disp.QueueReason, "dispatched")
|
|
}
|
|
if disp.Adapter != "vllm-gpu" {
|
|
t.Errorf("adapter: got %q, want %q", disp.Adapter, "vllm-gpu")
|
|
}
|
|
if disp.Target != "served-model" {
|
|
t.Errorf("target: got %q, want %q", disp.Target, "served-model")
|
|
}
|
|
|
|
// Also verify tunnel/normalized handle DispatchInfo matches.
|
|
switch result.Path {
|
|
case ProviderPoolPathTunnel:
|
|
if result.Tunnel == nil {
|
|
t.Fatal("expected tunnel handle for tunnel path")
|
|
}
|
|
hDisp := result.Tunnel.Dispatch()
|
|
if hDisp.ProviderID != "prov-obs" {
|
|
t.Errorf("tunnel handle provider_id: got %q, want %q", hDisp.ProviderID, "prov-obs")
|
|
}
|
|
if hDisp.ProviderType != tc.providerType {
|
|
t.Errorf("tunnel handle provider_type: got %q, want %q", hDisp.ProviderType, tc.providerType)
|
|
}
|
|
if hDisp.ExecutionPath != tc.wantExecPath {
|
|
t.Errorf("tunnel handle execution_path: got %q, want %q", hDisp.ExecutionPath, tc.wantExecPath)
|
|
}
|
|
// Close releases the inflight slot.
|
|
result.Tunnel.Close()
|
|
if got := inflightRunCount(svc.queue); got != 0 {
|
|
t.Fatalf("expected 0 inflight after tunnel close, got %d", got)
|
|
}
|
|
case ProviderPoolPathNormalized:
|
|
if result.Run == nil {
|
|
t.Fatal("expected run handle for normalized path")
|
|
}
|
|
hDisp := result.Run.Dispatch()
|
|
if hDisp.ProviderID != "prov-obs" {
|
|
t.Errorf("run handle provider_id: got %q, want %q", hDisp.ProviderID, "prov-obs")
|
|
}
|
|
if hDisp.ProviderType != tc.providerType {
|
|
t.Errorf("run handle provider_type: got %q, want %q", hDisp.ProviderType, tc.providerType)
|
|
}
|
|
if hDisp.ExecutionPath != tc.wantExecPath {
|
|
t.Errorf("run handle execution_path: got %q, want %q", hDisp.ExecutionPath, tc.wantExecPath)
|
|
}
|
|
// Inflight should be 1 for normalized (run hasn't completed yet).
|
|
if got := inflightRunCount(svc.queue); got != 1 {
|
|
t.Fatalf("expected 1 inflight for normalized, got %d", got)
|
|
}
|
|
result.Run.Close()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPendingClientDirectSendFence asserts the pending→ready dispatch fence at
|
|
// the granularity of the exact protobuf each direct wire carries. While the
|
|
// connection is pending every direct send path fails and nothing reaches the
|
|
// node. After the ready transition each wire is delivered exactly once, and its
|
|
// identity fields — not merely its type — are asserted against a cloned copy of
|
|
// the received message, so a request that dispatched with an empty or wrong field
|
|
// can no longer pass by type count alone.
|
|
func TestPendingClientDirectSendFence(t *testing.T) {
|
|
edgeConn, nodeConn := net.Pipe()
|
|
defer edgeConn.Close()
|
|
defer nodeConn.Close()
|
|
|
|
parserMap := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.NodeCommandRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.NodeCommandRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.NodeCommandResponse{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.NodeCommandResponse{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RunRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.CancelRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.CancelRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
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)
|
|
|
|
// Each listener clones the received protobuf so its exact identity fields can
|
|
// be asserted; fire-and-forget wires arrive on buffered channels, and the
|
|
// synchronous NodeCommandRequests accumulate under a lock keyed by type.
|
|
runReqs := make(chan *iop.RunRequest, 4)
|
|
tunnelReqs := make(chan *iop.ProviderTunnelRequest, 4)
|
|
cancelReqs := make(chan *iop.CancelRequest, 4)
|
|
var cmdMu sync.Mutex
|
|
cmdReqs := make(map[iop.NodeCommandType][]*iop.NodeCommandRequest)
|
|
|
|
toki.AddListenerTyped[*iop.RunRequest](&nodeClient.Communicator, func(req *iop.RunRequest) {
|
|
runReqs <- proto.Clone(req).(*iop.RunRequest)
|
|
})
|
|
toki.AddListenerTyped[*iop.ProviderTunnelRequest](&nodeClient.Communicator, func(req *iop.ProviderTunnelRequest) {
|
|
tunnelReqs <- proto.Clone(req).(*iop.ProviderTunnelRequest)
|
|
})
|
|
toki.AddListenerTyped[*iop.CancelRequest](&nodeClient.Communicator, func(req *iop.CancelRequest) {
|
|
cancelReqs <- proto.Clone(req).(*iop.CancelRequest)
|
|
})
|
|
toki.AddRequestListenerTyped(&nodeClient.Communicator, func(req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error) {
|
|
cmdMu.Lock()
|
|
cmdReqs[req.GetType()] = append(cmdReqs[req.GetType()], proto.Clone(req).(*iop.NodeCommandRequest))
|
|
cmdMu.Unlock()
|
|
return &iop.NodeCommandResponse{
|
|
RequestId: req.GetRequestId(),
|
|
Type: req.GetType(),
|
|
Result: map[string]string{"result": "ok", "status_code": "200"},
|
|
}, nil
|
|
})
|
|
|
|
reg := edgenode.NewRegistry()
|
|
entry := &edgenode.NodeEntry{
|
|
NodeID: "node-fence-1",
|
|
Alias: "fence-alias",
|
|
AgentKind: config.AgentKindGenericNode,
|
|
Client: edgeClient,
|
|
DispatchReady: false, // start pending
|
|
}
|
|
reg.RegisterIfAbsent(entry)
|
|
svc := New(reg, nil)
|
|
ctx := context.Background()
|
|
|
|
// 1. Observability lookups still see the pending connection.
|
|
if _, err := svc.ResolveNode("node-fence-1"); err != nil {
|
|
t.Errorf("ResolveNode failed for pending: %v", err)
|
|
}
|
|
snap, err := svc.ResolveNodeSnapshot("node-fence-1")
|
|
if err != nil {
|
|
t.Errorf("ResolveNodeSnapshot failed for pending: %v", err)
|
|
}
|
|
if !snap.Connected {
|
|
t.Errorf("expected Connected=true for pending snapshot, got %v", snap.Connected)
|
|
}
|
|
|
|
// 2. Every direct send path fails while pending and puts nothing on the wire.
|
|
if _, err := svc.SubmitRun(ctx, SubmitRunRequest{NodeRef: "node-fence-1", Adapter: "cli", Target: "codex"}); err == nil {
|
|
t.Error("expected SubmitRun on pending node to fail")
|
|
}
|
|
if _, err := svc.submitProviderTunnelDirect(SubmitProviderTunnelRequest{NodeRef: "node-fence-1", Adapter: "cli", Target: "codex"}); err == nil {
|
|
t.Error("expected submitProviderTunnelDirect on pending node to fail")
|
|
}
|
|
if _, err := svc.CancelRun(ctx, CancelRunRequest{NodeRef: "node-fence-1", RunID: "run-1"}); err == nil {
|
|
t.Error("expected CancelRun on pending node to fail")
|
|
}
|
|
if _, err := svc.TerminateSession(ctx, TerminateSessionRequest{NodeRef: "node-fence-1"}); err == nil {
|
|
t.Error("expected TerminateSession on pending node to fail")
|
|
}
|
|
if _, err := svc.UsageStatus(ctx, UsageStatusRequest{NodeRef: "node-fence-1"}); err == nil {
|
|
t.Error("expected UsageStatus on pending node to fail")
|
|
}
|
|
pendingResp, _ := svc.ExecuteCommand(ctx, &iop.EdgeCommandRequest{
|
|
CommandId: "cmd-pending",
|
|
Operation: "agent.command",
|
|
TargetSelector: "node-fence-1",
|
|
Parameters: map[string]string{"command": "capabilities"},
|
|
}, func(*iop.EdgeCommandEvent) {})
|
|
if pendingResp == nil || pendingResp.Status != "error" {
|
|
t.Errorf("expected ExecuteCommand on pending node to error, got %v", pendingResp.GetStatus())
|
|
}
|
|
|
|
if n := len(runReqs) + len(tunnelReqs) + len(cancelReqs); n != 0 {
|
|
t.Fatalf("pending node received %d fire-and-forget wire messages, want 0", n)
|
|
}
|
|
cmdMu.Lock()
|
|
pendingCmds := len(cmdReqs)
|
|
cmdMu.Unlock()
|
|
if pendingCmds != 0 {
|
|
t.Fatalf("pending node received %d command wire types, want 0", pendingCmds)
|
|
}
|
|
|
|
// 3. Ready opens dispatch.
|
|
if _, transitioned, ok := reg.MarkDispatchReadyIfClient("node-fence-1", edgeClient); !ok || !transitioned {
|
|
t.Fatalf("failed to transition node to ready: ok=%v transitioned=%v", ok, transitioned)
|
|
}
|
|
snap2, err := svc.ResolveNodeSnapshot("node-fence-1")
|
|
if err != nil {
|
|
t.Errorf("ResolveNodeSnapshot failed after ready: %v", err)
|
|
}
|
|
if !snap2.Connected {
|
|
t.Errorf("expected Connected=true after ready, got %v", snap2.Connected)
|
|
}
|
|
|
|
// 4. Each direct wire now dispatches exactly once with its exact payload.
|
|
if _, err := svc.SubmitRun(ctx, SubmitRunRequest{
|
|
NodeRef: "node-fence-1", RunID: "run-fence-run", Adapter: "cli", Target: "codex",
|
|
SessionID: "sess-run", Background: true, Prompt: "hello-fence",
|
|
Metadata: map[string]string{"source": "fence-test"},
|
|
}); err != nil {
|
|
t.Errorf("SubmitRun after ready: %v", err)
|
|
}
|
|
if _, err := svc.submitProviderTunnelDirect(SubmitProviderTunnelRequest{
|
|
NodeRef: "node-fence-1", RunID: "run-fence-tunnel", Adapter: "cli", Target: "codex",
|
|
SessionID: "sess-tunnel", Method: "POST", Path: "/v1/chat", Body: []byte("tunnel-body"),
|
|
}); err != nil {
|
|
t.Errorf("submitProviderTunnelDirect after ready: %v", err)
|
|
}
|
|
if _, err := svc.CancelRun(ctx, CancelRunRequest{
|
|
NodeRef: "node-fence-1", RunID: "run-fence-cancel", Adapter: "cli", Target: "codex", SessionID: "sess-cancel",
|
|
}); err != nil {
|
|
t.Errorf("CancelRun after ready: %v", err)
|
|
}
|
|
if _, err := svc.TerminateSession(ctx, TerminateSessionRequest{
|
|
NodeRef: "node-fence-1", Adapter: "cli", Target: "codex", SessionID: "sess-term",
|
|
}); err != nil {
|
|
t.Errorf("TerminateSession after ready: %v", err)
|
|
}
|
|
if _, err := svc.UsageStatus(ctx, UsageStatusRequest{
|
|
NodeRef: "node-fence-1", Adapter: "cli", Target: "codex", SessionID: "sess-usage",
|
|
}); err != nil {
|
|
t.Errorf("UsageStatus after ready: %v", err)
|
|
}
|
|
if _, err := svc.SessionList(ctx, NodeCommandRequestSpec{
|
|
NodeRef: "node-fence-1", Adapter: "cli", Target: "codex", SessionID: "sess-list",
|
|
}); err != nil {
|
|
t.Errorf("SessionList after ready: %v", err)
|
|
}
|
|
if _, err := svc.TransportStatus(ctx, NodeCommandRequestSpec{
|
|
NodeRef: "node-fence-1", Adapter: "cli", Target: "codex", SessionID: "sess-transport",
|
|
}); err != nil {
|
|
t.Errorf("TransportStatus after ready: %v", err)
|
|
}
|
|
if _, err := svc.OllamaAPI(ctx, OllamaAPIRequest{
|
|
NodeRef: "node-fence-1", Adapter: "ollama", Target: "llama", Method: "POST", Path: "/api/chat", Body: "ollama-body",
|
|
}); err != nil {
|
|
t.Errorf("OllamaAPI after ready: %v", err)
|
|
}
|
|
readyResp, _ := svc.ExecuteCommand(ctx, &iop.EdgeCommandRequest{
|
|
CommandId: "cmd-ready",
|
|
Operation: "agent.command",
|
|
TargetSelector: "node-fence-1",
|
|
Parameters: map[string]string{"command": "capabilities"},
|
|
}, func(*iop.EdgeCommandEvent) {})
|
|
if readyResp == nil || readyResp.Status == "error" {
|
|
t.Errorf("ExecuteCommand after ready failed: %v", readyResp.GetError())
|
|
}
|
|
|
|
// 4.1 RunRequest identity.
|
|
run := recvWire(t, runReqs, "RunRequest")
|
|
if got := run.GetRunId(); got != "run-fence-run" {
|
|
t.Errorf("RunRequest.run_id = %q, want run-fence-run", got)
|
|
}
|
|
if got := run.GetAdapter(); got != "cli" {
|
|
t.Errorf("RunRequest.adapter = %q, want cli", got)
|
|
}
|
|
if got := run.GetTarget(); got != "codex" {
|
|
t.Errorf("RunRequest.target = %q, want codex", got)
|
|
}
|
|
if !run.GetBackground() {
|
|
t.Errorf("RunRequest.background = false, want true")
|
|
}
|
|
if got := run.GetSessionId(); got != "sess-run" {
|
|
t.Errorf("RunRequest.session_id = %q, want sess-run", got)
|
|
}
|
|
if got := run.GetInput().GetFields()["prompt"].GetStringValue(); got != "hello-fence" {
|
|
t.Errorf("RunRequest.input.prompt = %q, want hello-fence", got)
|
|
}
|
|
if got := run.GetMetadata()["source"]; got != "fence-test" {
|
|
t.Errorf("RunRequest.metadata.source = %q, want fence-test", got)
|
|
}
|
|
if run.GetSessionMode() != iop.RunSessionMode_RUN_SESSION_MODE_CREATE_IF_MISSING {
|
|
t.Errorf("RunRequest.session_mode = %v, want CREATE_IF_MISSING", run.GetSessionMode())
|
|
}
|
|
assertNoExtra(t, runReqs, "RunRequest")
|
|
|
|
// 4.2 ProviderTunnelRequest identity.
|
|
tunnel := recvWire(t, tunnelReqs, "ProviderTunnelRequest")
|
|
if got := tunnel.GetRunId(); got != "run-fence-tunnel" {
|
|
t.Errorf("ProviderTunnelRequest.run_id = %q, want run-fence-tunnel", got)
|
|
}
|
|
if got := tunnel.GetTunnelId(); got != "run-fence-tunnel-tunnel" {
|
|
t.Errorf("ProviderTunnelRequest.tunnel_id = %q, want run-fence-tunnel-tunnel", got)
|
|
}
|
|
if got := tunnel.GetAdapter(); got != "cli" {
|
|
t.Errorf("ProviderTunnelRequest.adapter = %q, want cli", got)
|
|
}
|
|
if got := tunnel.GetTarget(); got != "codex" {
|
|
t.Errorf("ProviderTunnelRequest.target = %q, want codex", got)
|
|
}
|
|
if got := tunnel.GetMethod(); got != "POST" {
|
|
t.Errorf("ProviderTunnelRequest.method = %q, want POST", got)
|
|
}
|
|
if got := tunnel.GetPath(); got != "/v1/chat" {
|
|
t.Errorf("ProviderTunnelRequest.path = %q, want /v1/chat", got)
|
|
}
|
|
if got := string(tunnel.GetBody()); got != "tunnel-body" {
|
|
t.Errorf("ProviderTunnelRequest.body = %q, want tunnel-body", got)
|
|
}
|
|
if got := tunnel.GetSessionId(); got != "sess-tunnel" {
|
|
t.Errorf("ProviderTunnelRequest.session_id = %q, want sess-tunnel", got)
|
|
}
|
|
assertNoExtra(t, tunnelReqs, "ProviderTunnelRequest")
|
|
|
|
// 4.3 CancelRequest: one CANCEL_RUN and one TERMINATE_SESSION.
|
|
firstCancel := recvWire(t, cancelReqs, "CancelRequest")
|
|
secondCancel := recvWire(t, cancelReqs, "CancelRequest")
|
|
cancels := map[iop.CancelAction]*iop.CancelRequest{
|
|
firstCancel.GetAction(): firstCancel,
|
|
secondCancel.GetAction(): secondCancel,
|
|
}
|
|
if len(cancels) != 2 {
|
|
t.Fatalf("expected distinct CANCEL_RUN and TERMINATE_SESSION, got actions %v and %v", firstCancel.GetAction(), secondCancel.GetAction())
|
|
}
|
|
if cancelRun := cancels[iop.CancelAction_CANCEL_ACTION_CANCEL_RUN]; cancelRun == nil {
|
|
t.Error("missing CANCEL_RUN cancel request")
|
|
} else {
|
|
if got := cancelRun.GetRunId(); got != "run-fence-cancel" {
|
|
t.Errorf("CANCEL_RUN.run_id = %q, want run-fence-cancel", got)
|
|
}
|
|
if got := cancelRun.GetSessionId(); got != "sess-cancel" {
|
|
t.Errorf("CANCEL_RUN.session_id = %q, want sess-cancel", got)
|
|
}
|
|
if got := cancelRun.GetAdapter(); got != "cli" {
|
|
t.Errorf("CANCEL_RUN.adapter = %q, want cli", got)
|
|
}
|
|
if got := cancelRun.GetTarget(); got != "codex" {
|
|
t.Errorf("CANCEL_RUN.target = %q, want codex", got)
|
|
}
|
|
}
|
|
if term := cancels[iop.CancelAction_CANCEL_ACTION_TERMINATE_SESSION]; term == nil {
|
|
t.Error("missing TERMINATE_SESSION cancel request")
|
|
} else {
|
|
if got := term.GetSessionId(); got != "sess-term" {
|
|
t.Errorf("TERMINATE_SESSION.session_id = %q, want sess-term", got)
|
|
}
|
|
if got := term.GetRunId(); got != "" {
|
|
t.Errorf("TERMINATE_SESSION.run_id = %q, want empty", got)
|
|
}
|
|
}
|
|
assertNoExtra(t, cancelReqs, "CancelRequest")
|
|
|
|
// 4.4 NodeCommandRequests: each type exactly once with its exact fields.
|
|
cmdMu.Lock()
|
|
defer cmdMu.Unlock()
|
|
expectCommand := func(typ iop.NodeCommandType, wantAdapter, wantTarget, wantSession, wantIDPrefix string, extra func(*iop.NodeCommandRequest)) {
|
|
reqs := cmdReqs[typ]
|
|
if len(reqs) != 1 {
|
|
t.Errorf("command %v received %d times, want 1", typ, len(reqs))
|
|
return
|
|
}
|
|
req := reqs[0]
|
|
if got := req.GetAdapter(); got != wantAdapter {
|
|
t.Errorf("%v adapter = %q, want %q", typ, got, wantAdapter)
|
|
}
|
|
if got := req.GetTarget(); got != wantTarget {
|
|
t.Errorf("%v target = %q, want %q", typ, got, wantTarget)
|
|
}
|
|
if got := req.GetSessionId(); got != wantSession {
|
|
t.Errorf("%v session_id = %q, want %q", typ, got, wantSession)
|
|
}
|
|
if got := req.GetRequestId(); !strings.HasPrefix(got, wantIDPrefix) {
|
|
t.Errorf("%v request_id = %q, want prefix %q", typ, got, wantIDPrefix)
|
|
}
|
|
if extra != nil {
|
|
extra(req)
|
|
}
|
|
}
|
|
expectCommand(iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS, "cli", "codex", "sess-usage", "status-", nil)
|
|
expectCommand(iop.NodeCommandType_NODE_COMMAND_TYPE_SESSION_LIST, "cli", "codex", "sess-list", "sessions-", nil)
|
|
expectCommand(iop.NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS, "cli", "codex", "sess-transport", "transport-", nil)
|
|
expectCommand(iop.NodeCommandType_NODE_COMMAND_TYPE_OLLAMA_API, "ollama", "llama", "default", "ollama-", func(req *iop.NodeCommandRequest) {
|
|
if got := req.GetMetadata()["ollama_method"]; got != "POST" {
|
|
t.Errorf("OLLAMA_API metadata.ollama_method = %q, want POST", got)
|
|
}
|
|
if got := req.GetMetadata()["ollama_path"]; got != "/api/chat" {
|
|
t.Errorf("OLLAMA_API metadata.ollama_path = %q, want /api/chat", got)
|
|
}
|
|
if got := req.GetMetadata()["ollama_body"]; got != "ollama-body" {
|
|
t.Errorf("OLLAMA_API metadata.ollama_body = %q, want ollama-body", got)
|
|
}
|
|
})
|
|
expectCommand(iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES, "", "", "default", "caps-", nil)
|
|
if len(cmdReqs) != 5 {
|
|
t.Errorf("received %d distinct command types, want 5", len(cmdReqs))
|
|
}
|
|
}
|
|
|
|
// recvWire returns the next captured wire of type T or fails after a timeout.
|
|
func recvWire[T proto.Message](t *testing.T, ch chan T, name string) T {
|
|
t.Helper()
|
|
select {
|
|
case v := <-ch:
|
|
return v
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("timed out waiting for %s wire", name)
|
|
var zero T
|
|
return zero
|
|
}
|
|
}
|
|
|
|
// assertNoExtra fails if any additional wire of type T is buffered. No further
|
|
// send was issued after the expected ones, so a non-blocking read is a
|
|
// deterministic exactly-once guard.
|
|
func assertNoExtra[T proto.Message](t *testing.T, ch chan T, name string) {
|
|
t.Helper()
|
|
select {
|
|
case v := <-ch:
|
|
t.Errorf("unexpected extra %s wire: %v", name, v)
|
|
default:
|
|
}
|
|
}
|
|
|
|
// gateConn blocks the first (and every subsequent) Write until release is
|
|
// closed, signalling entered once the write is in progress. The provider-pool
|
|
// dispatch performs its wire send inside WithCurrentDispatchOwner while holding
|
|
// the registry lock, so blocking the write here holds that lock and lets a test
|
|
// schedule a competing disconnect deterministically against it.
|
|
type gateConn struct {
|
|
net.Conn
|
|
entered chan struct{}
|
|
release chan struct{}
|
|
enterOnce sync.Once
|
|
}
|
|
|
|
func (c *gateConn) Write(b []byte) (int, error) {
|
|
c.enterOnce.Do(func() { close(c.entered) })
|
|
<-c.release
|
|
return c.Conn.Write(b)
|
|
}
|
|
|
|
// newProviderPoolRaceService builds a Service whose registry holds one ready
|
|
// node with a single capacity-1 provider serving model-1. provType selects the
|
|
// dispatch path: an OpenAI-compatible type (e.g. "vllm") routes to the tunnel
|
|
// path, everything else (e.g. "openai") to the normalized run path.
|
|
func newProviderPoolRaceService(t *testing.T, nodeID, provType string, edgeClient *toki.TcpClient) (*Service, *edgenode.Registry, *edgenode.NodeEntry) {
|
|
t.Helper()
|
|
reg := edgenode.NewRegistry()
|
|
entry := &edgenode.NodeEntry{
|
|
NodeID: nodeID,
|
|
Alias: nodeID + "-alias",
|
|
AgentKind: config.AgentKindGenericNode,
|
|
Client: edgeClient,
|
|
DispatchReady: true,
|
|
}
|
|
reg.Register(entry)
|
|
svc := New(reg, edgeevents.NewBus())
|
|
store, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{ID: nodeID, Alias: nodeID + "-alias", Token: "token", AgentKind: config.AgentKindGenericNode, Providers: []config.NodeProviderConf{
|
|
{ID: "prov-1", Type: provType, Models: []string{"model-1"}, Health: "available", Capacity: 1},
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
svc.SetNodeStore(store)
|
|
svc.SetModelCatalog([]config.ModelCatalogEntry{{ID: "model-1", Providers: map[string]string{"prov-1": "model-1"}}})
|
|
return svc, reg, entry
|
|
}
|
|
|
|
// assertQueueSettled asserts every lease is released and every provider/group
|
|
// in-flight counter is back to zero, so a race can never leak a slot.
|
|
func assertQueueSettled(t *testing.T, q *modelQueueManager) {
|
|
t.Helper()
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
if len(q.leases) != 0 {
|
|
t.Errorf("leases not settled: %d remain", len(q.leases))
|
|
}
|
|
if len(q.leaseByRun) != 0 {
|
|
t.Errorf("leaseByRun not settled: %d remain", len(q.leaseByRun))
|
|
}
|
|
for key, res := range q.resources {
|
|
if res.inFlight != 0 || res.longInFlight != 0 {
|
|
t.Errorf("provider resource %v not settled: inFlight=%d longInFlight=%d", key, res.inFlight, res.longInFlight)
|
|
}
|
|
}
|
|
for gk, g := range q.groups {
|
|
for slot, n := range g.inflight {
|
|
if n != 0 {
|
|
t.Errorf("group %q slot %q inflight=%d, want 0", gk, slot, n)
|
|
}
|
|
}
|
|
for slot, n := range g.longInflight {
|
|
if n != 0 {
|
|
t.Errorf("group %q slot %q longInflight=%d, want 0", gk, slot, n)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// assertTrackedLease proves the send-winning barrier was reached only after the
|
|
// exact run identity had been bound to one live lease for the expected owner
|
|
// generation. The later disconnect/terminal assertions then prove that same
|
|
// identity is removed without leaving a duplicate decrement behind.
|
|
func assertTrackedLease(t *testing.T, q *modelQueueManager, runID, nodeID string, generation uint64) {
|
|
t.Helper()
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
leaseID, ok := q.leaseByRun[runID]
|
|
if !ok || leaseID == 0 {
|
|
t.Fatalf("run %q has no tracked lease", runID)
|
|
}
|
|
lease := q.leases[leaseID]
|
|
if lease == nil {
|
|
t.Fatalf("run %q maps to missing lease %d", runID, leaseID)
|
|
}
|
|
if lease.nodeID != nodeID || lease.generation != generation {
|
|
t.Fatalf("run %q lease owner=(node=%q generation=%d), want (%q,%d)",
|
|
runID, lease.nodeID, lease.generation, nodeID, generation)
|
|
}
|
|
}
|
|
|
|
func disconnectCurrentOwner(svc *Service, reg *edgenode.Registry, entry *edgenode.NodeEntry, reason string) (uint64, bool) {
|
|
generation, ok := reg.UnregisterIfClient(entry.NodeID, entry.Client)
|
|
if ok {
|
|
svc.HandleNodeDisconnect(entry.NodeID, generation, reason)
|
|
}
|
|
return generation, ok
|
|
}
|
|
|
|
// TestProviderPoolDispatchRunDisconnectRace pins the normalized send/disconnect
|
|
// race in two deterministic directions instead of inferring order from sleeps.
|
|
//
|
|
// - send-winning: the dispatch enters its wire send under the owner gate
|
|
// (registry lock held); a disconnect racing in blocks on that lock, the wire
|
|
// is written exactly once, and the authoritative disconnect then releases the
|
|
// handed-off lease exactly once, leaving every counter at zero.
|
|
// - disconnect-winning: the disconnect settles (unregister + fence) while the
|
|
// dispatch is parked at its post-admission PrepareRun barrier, before the
|
|
// owner gate. The gate then fences the stale generation, so no wire and no
|
|
// run handle escape and the reserved lease is released exactly once.
|
|
func TestProviderPoolDispatchRunDisconnectRace(t *testing.T) {
|
|
runParser := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RunRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
|
|
t.Run("send-winning", func(t *testing.T) {
|
|
edgeConn, nodeConn := net.Pipe()
|
|
defer edgeConn.Close()
|
|
defer nodeConn.Close()
|
|
gate := &gateConn{Conn: edgeConn, entered: make(chan struct{}), release: make(chan struct{})}
|
|
edgeClient := toki.NewTcpClient(gate, 0, 0, runParser)
|
|
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, runParser)
|
|
runReqs := make(chan *iop.RunRequest, 4)
|
|
toki.AddListenerTyped[*iop.RunRequest](&nodeClient.Communicator, func(req *iop.RunRequest) {
|
|
runReqs <- proto.Clone(req).(*iop.RunRequest)
|
|
})
|
|
|
|
svc, reg, entry := newProviderPoolRaceService(t, "node-race-run", "openai", edgeClient)
|
|
|
|
dispatchDone := make(chan struct{})
|
|
var result *ProviderPoolDispatchResult
|
|
var dispatchErr error
|
|
go func() {
|
|
result, dispatchErr = svc.SubmitProviderPool(context.Background(), ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{NodeRef: "node-race-run", RunID: "run-send-win", ModelGroupKey: "model-1", Adapter: "openai", Target: "model-1", ProviderPool: true, Background: true},
|
|
})
|
|
close(dispatchDone)
|
|
}()
|
|
|
|
select {
|
|
case <-gate.entered:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("dispatch did not reach the wire send")
|
|
}
|
|
assertTrackedLease(t, svc.queue, "run-send-win", "node-race-run", entry.ConnectionGeneration)
|
|
|
|
type disconnectOutcome struct {
|
|
generation uint64
|
|
ok bool
|
|
}
|
|
disconnectDone := make(chan disconnectOutcome, 1)
|
|
go func() {
|
|
generation, ok := disconnectCurrentOwner(svc, reg, entry, "test-disconnect")
|
|
disconnectDone <- disconnectOutcome{generation: generation, ok: ok}
|
|
}()
|
|
|
|
close(gate.release)
|
|
<-dispatchDone
|
|
disconnected := <-disconnectDone
|
|
if !disconnected.ok || disconnected.generation != entry.ConnectionGeneration {
|
|
t.Fatalf("send-winning disconnect=(generation=%d ok=%v), want (%d,true)",
|
|
disconnected.generation, disconnected.ok, entry.ConnectionGeneration)
|
|
}
|
|
|
|
if dispatchErr != nil {
|
|
t.Fatalf("send-winning dispatch must succeed, got %v", dispatchErr)
|
|
}
|
|
if result == nil || result.Run == nil {
|
|
t.Fatal("send-winning must return a run handle")
|
|
}
|
|
wire := recvWire(t, runReqs, "RunRequest")
|
|
if got := wire.GetRunId(); got != "run-send-win" {
|
|
t.Errorf("wire run_id = %q, want run-send-win", got)
|
|
}
|
|
assertNoExtra(t, runReqs, "RunRequest")
|
|
assertQueueSettled(t, svc.queue)
|
|
|
|
// A terminal callback losing to the authoritative disconnect is an
|
|
// idempotent no-op for the same tracked lease; closing the handle must not
|
|
// reopen or double-release it either.
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: wire.GetRunId(), Type: "complete", NodeId: "node-race-run"})
|
|
result.Run.Close()
|
|
assertQueueSettled(t, svc.queue)
|
|
})
|
|
|
|
t.Run("disconnect-winning", func(t *testing.T) {
|
|
edgeConn, nodeConn := net.Pipe()
|
|
defer edgeConn.Close()
|
|
defer nodeConn.Close()
|
|
edgeClient := toki.NewTcpClient(edgeConn, 0, 0, runParser)
|
|
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, runParser)
|
|
runReqs := make(chan *iop.RunRequest, 4)
|
|
toki.AddListenerTyped[*iop.RunRequest](&nodeClient.Communicator, func(req *iop.RunRequest) {
|
|
runReqs <- proto.Clone(req).(*iop.RunRequest)
|
|
})
|
|
|
|
svc, reg, entry := newProviderPoolRaceService(t, "node-race-run", "openai", edgeClient)
|
|
|
|
reachedPrepare := make(chan struct{})
|
|
releasePrepare := make(chan struct{})
|
|
dispatchDone := make(chan struct{})
|
|
var result *ProviderPoolDispatchResult
|
|
var dispatchErr error
|
|
go func() {
|
|
result, dispatchErr = svc.SubmitProviderPool(context.Background(), ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{NodeRef: "node-race-run", RunID: "run-disc-win", ModelGroupKey: "model-1", Adapter: "openai", Target: "model-1", ProviderPool: true, Background: true},
|
|
PrepareRun: func(req SubmitRunRequest) (SubmitRunRequest, error) {
|
|
close(reachedPrepare)
|
|
<-releasePrepare
|
|
return req, nil
|
|
},
|
|
})
|
|
close(dispatchDone)
|
|
}()
|
|
|
|
<-reachedPrepare
|
|
if got := leaseCount(svc.queue); got != 1 {
|
|
t.Fatalf("disconnect-winning reserved leases=%d before fence, want 1", got)
|
|
}
|
|
disconnectedGen, ok := disconnectCurrentOwner(svc, reg, entry, "test-disconnect")
|
|
if !ok || disconnectedGen != entry.ConnectionGeneration {
|
|
t.Fatalf("disconnect-winning disconnect=(generation=%d ok=%v), want (%d,true)",
|
|
disconnectedGen, ok, entry.ConnectionGeneration)
|
|
}
|
|
close(releasePrepare)
|
|
<-dispatchDone
|
|
|
|
if dispatchErr == nil {
|
|
t.Fatal("disconnect-winning dispatch must fail")
|
|
}
|
|
if result != nil {
|
|
t.Fatalf("disconnect-winning must not return a result, got %+v", result)
|
|
}
|
|
assertNoExtra(t, runReqs, "RunRequest")
|
|
assertQueueSettled(t, svc.queue)
|
|
})
|
|
}
|
|
|
|
// TestProviderPoolDispatchTunnelDisconnectRace mirrors the normalized race for
|
|
// the tunnel/passthrough path: a send-winning subtest that writes exactly one
|
|
// ProviderTunnelRequest then releases the lease on the authoritative disconnect,
|
|
// and a disconnect-winning subtest whose PrepareTunnel barrier lets the fence
|
|
// win so no tunnel frame and no handle escape.
|
|
func TestProviderPoolDispatchTunnelDisconnectRace(t *testing.T) {
|
|
tunnelParser := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.ProviderTunnelRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.ProviderTunnelRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
|
|
t.Run("send-winning", func(t *testing.T) {
|
|
edgeConn, nodeConn := net.Pipe()
|
|
defer edgeConn.Close()
|
|
defer nodeConn.Close()
|
|
gate := &gateConn{Conn: edgeConn, entered: make(chan struct{}), release: make(chan struct{})}
|
|
edgeClient := toki.NewTcpClient(gate, 0, 0, tunnelParser)
|
|
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, tunnelParser)
|
|
tunnelReqs := make(chan *iop.ProviderTunnelRequest, 4)
|
|
toki.AddListenerTyped[*iop.ProviderTunnelRequest](&nodeClient.Communicator, func(req *iop.ProviderTunnelRequest) {
|
|
tunnelReqs <- proto.Clone(req).(*iop.ProviderTunnelRequest)
|
|
})
|
|
|
|
svc, reg, entry := newProviderPoolRaceService(t, "node-race-tunnel", "vllm", edgeClient)
|
|
|
|
dispatchDone := make(chan struct{})
|
|
var result *ProviderPoolDispatchResult
|
|
var dispatchErr error
|
|
go func() {
|
|
result, dispatchErr = svc.SubmitProviderPool(context.Background(), ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{NodeRef: "node-race-tunnel", ModelGroupKey: "model-1", Adapter: "openai", Target: "model-1", ProviderPool: true},
|
|
Tunnel: SubmitProviderTunnelRequest{NodeRef: "node-race-tunnel", RunID: "run-tunnel-send-win", ModelGroupKey: "model-1", Adapter: "openai", Target: "model-1", ProviderPool: true},
|
|
})
|
|
close(dispatchDone)
|
|
}()
|
|
|
|
select {
|
|
case <-gate.entered:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("tunnel dispatch did not reach the wire send")
|
|
}
|
|
assertTrackedLease(t, svc.queue, "run-tunnel-send-win", "node-race-tunnel", entry.ConnectionGeneration)
|
|
|
|
type disconnectOutcome struct {
|
|
generation uint64
|
|
ok bool
|
|
}
|
|
disconnectDone := make(chan disconnectOutcome, 1)
|
|
go func() {
|
|
generation, ok := disconnectCurrentOwner(svc, reg, entry, "test-disconnect")
|
|
disconnectDone <- disconnectOutcome{generation: generation, ok: ok}
|
|
}()
|
|
|
|
close(gate.release)
|
|
<-dispatchDone
|
|
disconnected := <-disconnectDone
|
|
if !disconnected.ok || disconnected.generation != entry.ConnectionGeneration {
|
|
t.Fatalf("send-winning tunnel disconnect=(generation=%d ok=%v), want (%d,true)",
|
|
disconnected.generation, disconnected.ok, entry.ConnectionGeneration)
|
|
}
|
|
|
|
if dispatchErr != nil {
|
|
t.Fatalf("send-winning tunnel dispatch must succeed, got %v", dispatchErr)
|
|
}
|
|
if result == nil || result.Tunnel == nil {
|
|
t.Fatal("send-winning must return a tunnel handle")
|
|
}
|
|
wire := recvWire(t, tunnelReqs, "ProviderTunnelRequest")
|
|
if got := wire.GetRunId(); got != "run-tunnel-send-win" {
|
|
t.Errorf("wire run_id = %q, want run-tunnel-send-win", got)
|
|
}
|
|
assertNoExtra(t, tunnelReqs, "ProviderTunnelRequest")
|
|
assertQueueSettled(t, svc.queue)
|
|
|
|
// Deliver the competing tunnel terminal after disconnect. The handle sees
|
|
// exactly one END and closes, while the already-settled lease/counters stay
|
|
// at zero when Close races afterward.
|
|
stream := result.Tunnel.Stream()
|
|
svc.RouteProviderTunnelFrame(&iop.ProviderTunnelFrame{
|
|
RunId: wire.GetRunId(), TunnelId: wire.GetTunnelId(),
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true,
|
|
})
|
|
select {
|
|
case terminal, ok := <-stream.Frames:
|
|
if !ok || terminal.GetKind() != iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END || !terminal.GetEnd() {
|
|
t.Fatalf("tunnel terminal=%v open=%v, want one END frame", terminal, ok)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for tunnel END frame")
|
|
}
|
|
select {
|
|
case extra, ok := <-stream.Frames:
|
|
if ok {
|
|
t.Fatalf("unexpected extra tunnel terminal/frame: %v", extra)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("tunnel stream did not close after END")
|
|
}
|
|
result.Tunnel.Close()
|
|
assertQueueSettled(t, svc.queue)
|
|
})
|
|
|
|
t.Run("disconnect-winning", func(t *testing.T) {
|
|
edgeConn, nodeConn := net.Pipe()
|
|
defer edgeConn.Close()
|
|
defer nodeConn.Close()
|
|
edgeClient := toki.NewTcpClient(edgeConn, 0, 0, tunnelParser)
|
|
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, tunnelParser)
|
|
tunnelReqs := make(chan *iop.ProviderTunnelRequest, 4)
|
|
toki.AddListenerTyped[*iop.ProviderTunnelRequest](&nodeClient.Communicator, func(req *iop.ProviderTunnelRequest) {
|
|
tunnelReqs <- proto.Clone(req).(*iop.ProviderTunnelRequest)
|
|
})
|
|
|
|
svc, reg, entry := newProviderPoolRaceService(t, "node-race-tunnel", "vllm", edgeClient)
|
|
|
|
reachedPrepare := make(chan struct{})
|
|
releasePrepare := make(chan struct{})
|
|
dispatchDone := make(chan struct{})
|
|
var result *ProviderPoolDispatchResult
|
|
var dispatchErr error
|
|
go func() {
|
|
result, dispatchErr = svc.SubmitProviderPool(context.Background(), ProviderPoolDispatchRequest{
|
|
Run: SubmitRunRequest{NodeRef: "node-race-tunnel", ModelGroupKey: "model-1", Adapter: "openai", Target: "model-1", ProviderPool: true},
|
|
Tunnel: SubmitProviderTunnelRequest{NodeRef: "node-race-tunnel", RunID: "run-tunnel-disc-win", ModelGroupKey: "model-1", Adapter: "openai", Target: "model-1", ProviderPool: true},
|
|
PrepareTunnel: func(req SubmitProviderTunnelRequest) (SubmitProviderTunnelRequest, error) {
|
|
close(reachedPrepare)
|
|
<-releasePrepare
|
|
return req, nil
|
|
},
|
|
})
|
|
close(dispatchDone)
|
|
}()
|
|
|
|
<-reachedPrepare
|
|
if got := leaseCount(svc.queue); got != 1 {
|
|
t.Fatalf("disconnect-winning tunnel reserved leases=%d before fence, want 1", got)
|
|
}
|
|
disconnectedGen, ok := disconnectCurrentOwner(svc, reg, entry, "test-disconnect")
|
|
if !ok || disconnectedGen != entry.ConnectionGeneration {
|
|
t.Fatalf("disconnect-winning tunnel disconnect=(generation=%d ok=%v), want (%d,true)",
|
|
disconnectedGen, ok, entry.ConnectionGeneration)
|
|
}
|
|
close(releasePrepare)
|
|
<-dispatchDone
|
|
|
|
if dispatchErr == nil {
|
|
t.Fatal("disconnect-winning tunnel dispatch must fail")
|
|
}
|
|
if result != nil {
|
|
t.Fatalf("disconnect-winning must not return a result, got %+v", result)
|
|
}
|
|
assertNoExtra(t, tunnelReqs, "ProviderTunnelRequest")
|
|
assertQueueSettled(t, svc.queue)
|
|
})
|
|
}
|
|
|
|
// providerResourceState reads a provider resource's in-flight count and orphan
|
|
// flag under the manager lock for white-box assertions.
|
|
func providerResourceInflight(q *modelQueueManager, nodeID, providerID string) (inFlight int, orphan, ok bool) {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
res, exists := q.resources[providerResourceKey{nodeID: nodeID, providerID: providerID}]
|
|
if !exists {
|
|
return 0, false, false
|
|
}
|
|
return res.inFlight, res.orphan, true
|
|
}
|
|
|
|
func queuedItemCount(q *modelQueueManager) int {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
n := 0
|
|
for _, g := range q.groups {
|
|
n += len(g.queue)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// TestReconnectActivationLinearizesAgainstDisconnect drives the real queue
|
|
// through an actual reconnect and proves the ownership linearization the fix
|
|
// establishes: a stranded provider-pool waiter is pumped exactly once, and only
|
|
// by the current reconnect generation. A stale (superseded) generation's
|
|
// HandleNodeConnect is a no-op — it neither re-activates the orphaned resource
|
|
// nor pumps the waiter — while the current generation activates and dispatches it
|
|
// exactly once, and all counters settle to zero afterward.
|
|
func TestReconnectActivationLinearizesAgainstDisconnect(t *testing.T) {
|
|
parser := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RunRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
|
|
altEdge, altNode := net.Pipe()
|
|
defer altEdge.Close()
|
|
defer altNode.Close()
|
|
reconEdge, reconNode := net.Pipe()
|
|
defer reconEdge.Close()
|
|
defer reconNode.Close()
|
|
|
|
altEdgeClient := toki.NewTcpClient(altEdge, 0, 0, parser)
|
|
altNodeClient := toki.NewTcpClient(altNode, 0, 0, parser)
|
|
reconEdgeClient := toki.NewTcpClient(reconEdge, 0, 0, parser)
|
|
reconNodeClient := toki.NewTcpClient(reconNode, 0, 0, parser)
|
|
|
|
altRuns := make(chan *iop.RunRequest, 4)
|
|
reconRuns := make(chan *iop.RunRequest, 4)
|
|
toki.AddListenerTyped[*iop.RunRequest](&altNodeClient.Communicator, func(req *iop.RunRequest) {
|
|
altRuns <- proto.Clone(req).(*iop.RunRequest)
|
|
})
|
|
toki.AddListenerTyped[*iop.RunRequest](&reconNodeClient.Communicator, func(req *iop.RunRequest) {
|
|
reconRuns <- proto.Clone(req).(*iop.RunRequest)
|
|
})
|
|
|
|
reg := edgenode.NewRegistry()
|
|
altEntry := &edgenode.NodeEntry{NodeID: "node-alt", Alias: "alt", AgentKind: config.AgentKindGenericNode, Client: altEdgeClient, DispatchReady: true}
|
|
reg.Register(altEntry)
|
|
reconEntry := &edgenode.NodeEntry{NodeID: "node-recon", Alias: "recon", AgentKind: config.AgentKindGenericNode, Client: reconEdgeClient, DispatchReady: true}
|
|
reg.Register(reconEntry)
|
|
gen1 := reconEntry.ConnectionGeneration
|
|
|
|
svc := New(reg, edgeevents.NewBus())
|
|
store, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{ID: "node-alt", Alias: "alt", Token: "alt-token", AgentKind: config.AgentKindGenericNode, Providers: []config.NodeProviderConf{
|
|
{ID: "prov-alt", Type: "openai", Models: []string{"model-1"}, Health: "available", Capacity: 1},
|
|
}},
|
|
{ID: "node-recon", Alias: "recon", Token: "recon-token", AgentKind: config.AgentKindGenericNode, Providers: []config.NodeProviderConf{
|
|
{ID: "prov-recon", Type: "openai", Models: []string{"model-1"}, Health: "available", Capacity: 1},
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
svc.SetNodeStore(store)
|
|
svc.SetModelCatalog([]config.ModelCatalogEntry{{ID: "model-1", Providers: map[string]string{"prov-alt": "model-1", "prov-recon": "model-1"}}})
|
|
|
|
svc.HandleNodeConnect("node-alt", altEntry.ConnectionGeneration)
|
|
svc.HandleNodeConnect("node-recon", gen1)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Occupy prov-alt with a background run. With both providers idle the tie-break
|
|
// (prov-alt < prov-recon) sends it to prov-alt, leaving prov-recon the only
|
|
// candidate a later waiter can reach.
|
|
if _, err := svc.SubmitRun(ctx, SubmitRunRequest{ModelGroupKey: "model-1", Adapter: "openai", Target: "model-1", ProviderPool: true, Background: true}); err != nil {
|
|
t.Fatalf("submit blocking run: %v", err)
|
|
}
|
|
occupy := recvWire(t, altRuns, "RunRequest(alt)")
|
|
if got, _, _ := providerResourceInflight(svc.queue, "node-alt", "prov-alt"); got != 1 {
|
|
t.Fatalf("prov-alt in-flight = %d after blocking run, want 1", got)
|
|
}
|
|
|
|
// Disconnect node-recon: its provider resource is orphaned.
|
|
reg.UnregisterIfClient("node-recon", reconEntry.Client)
|
|
svc.HandleNodeDisconnect("node-recon", gen1, "test-disconnect")
|
|
if _, orphan, ok := providerResourceInflight(svc.queue, "node-recon", "prov-recon"); !ok || !orphan {
|
|
t.Fatalf("prov-recon must be orphaned after disconnect (ok=%v orphan=%v)", ok, orphan)
|
|
}
|
|
|
|
// Submit the waiter: alt is full and recon is offline, so it strands in queue.
|
|
type outcome struct {
|
|
res RunResult
|
|
err error
|
|
}
|
|
waiterDone := make(chan outcome, 1)
|
|
go func() {
|
|
res, err := svc.SubmitRun(ctx, SubmitRunRequest{ModelGroupKey: "model-1", Adapter: "openai", Target: "model-1", ProviderPool: true, Background: true})
|
|
waiterDone <- outcome{res: res, err: err}
|
|
}()
|
|
waitForCondition(t, func() bool { return queuedItemCount(svc.queue) == 1 }, "waiter did not enqueue")
|
|
|
|
// Reconnect node-recon under a strictly higher generation (pending→ready in the
|
|
// real flow; Register here mints the new generation and makes it the owner).
|
|
reconEntry2 := &edgenode.NodeEntry{NodeID: "node-recon", Alias: "recon", AgentKind: config.AgentKindGenericNode, Client: reconEdgeClient, DispatchReady: true}
|
|
reg.Register(reconEntry2)
|
|
gen2 := reconEntry2.ConnectionGeneration
|
|
if gen2 <= gen1 {
|
|
t.Fatalf("reconnect generation %d must exceed %d", gen2, gen1)
|
|
}
|
|
|
|
// Stale activation: the superseded generation must be a no-op — no resource
|
|
// re-activation, no pump, waiter stays queued.
|
|
svc.HandleNodeConnect("node-recon", gen1)
|
|
if _, orphan, ok := providerResourceInflight(svc.queue, "node-recon", "prov-recon"); !ok || !orphan {
|
|
t.Fatalf("stale activation must leave prov-recon orphaned (ok=%v orphan=%v)", ok, orphan)
|
|
}
|
|
if n := queuedItemCount(svc.queue); n != 1 {
|
|
t.Fatalf("stale activation pumped the waiter: queued=%d, want 1", n)
|
|
}
|
|
select {
|
|
case o := <-waiterDone:
|
|
t.Fatalf("stale activation dispatched the waiter: err=%v", o.err)
|
|
default:
|
|
}
|
|
assertNoExtra(t, reconRuns, "RunRequest(recon)")
|
|
|
|
// Current activation: the reconnect generation activates the resource and
|
|
// pumps the stranded waiter to it exactly once.
|
|
svc.HandleNodeConnect("node-recon", gen2)
|
|
var waiter outcome
|
|
select {
|
|
case waiter = <-waiterDone:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("current activation did not dispatch the waiter")
|
|
}
|
|
if waiter.err != nil {
|
|
t.Fatalf("waiter dispatch failed: %v", waiter.err)
|
|
}
|
|
dispatch := waiter.res.Dispatch()
|
|
if dispatch.NodeID != "node-recon" || dispatch.ProviderID != "prov-recon" {
|
|
t.Fatalf("waiter dispatch=(node=%q provider=%q), want node-recon/prov-recon", dispatch.NodeID, dispatch.ProviderID)
|
|
}
|
|
reconWire := recvWire(t, reconRuns, "RunRequest(recon)")
|
|
if reconWire.GetRunId() != dispatch.RunID {
|
|
t.Fatalf("recon wire run_id=%q, want %q", reconWire.GetRunId(), dispatch.RunID)
|
|
}
|
|
assertNoExtra(t, reconRuns, "RunRequest(recon)")
|
|
if got, _, _ := providerResourceInflight(svc.queue, "node-recon", "prov-recon"); got != 1 {
|
|
t.Fatalf("prov-recon in-flight = %d after activation, want 1", got)
|
|
}
|
|
|
|
// Settle both runs and confirm no counter leaked.
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: occupy.GetRunId(), Type: "complete"})
|
|
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: dispatch.RunID, Type: "complete"})
|
|
waitForCondition(t, func() bool { return inflightRunCount(svc.queue) == 0 }, "runs did not settle")
|
|
assertQueueSettled(t, svc.queue)
|
|
}
|