iop/apps/edge/internal/service/model_queue_admission_test.go
toki 0ffcb88db0 feat: provider-resource-admission-ownership alignment
- Archive provider-resource-admission-ownership milestone/SDD
- Align contract: CP-edge wire, runtime refresh, node runtime, OpenAI surface
- Update roadmap: phase state, priority queue
- Update specs: control-plane ops, OpenAI surface, edge execution, provider pool refresh
- Add node runtime supervisor bootstrapping and unit tests
- Fix control-plane edge registry handler and http_views
- Fix edge model queue admission and long context queue tests
2026-07-22 20:45:04 +09:00

1472 lines
48 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
edgeevents "iop/apps/edge/internal/events"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
// TestModelQueueFIFOOrdering verifies that queued items are dispatched in FIFO
// order when a slot becomes available.
func TestModelQueueFIFOOrdering(t *testing.T) {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-q1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
entry := &edgenode.NodeEntry{NodeID: "node-q1"}
cands := []candidateNode{{entry: entry, capacity: 1}}
defPolicy := groupPolicy{}
m := newModelQueueManager(store)
// Fill the only capacity slot.
first, err := m.admit(context.Background(), "g-fifo", "", "", cands, defPolicy, nil, false, false)
if err != nil || first == nil {
t.Fatalf("initial admit: %v", err)
}
// Manually insert two items in known order so we can verify FIFO.
item1 := &queueItem{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(2 * time.Second),
}
item2 := &queueItem{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(2 * time.Second),
}
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-fifo", groupPolicy{})
g.queue = append(g.queue, item1, item2)
m.mu.Unlock()
// Release one slot — item1 (head) must be dispatched first.
m.mu.Lock()
m.releaseSlotLocked("g-fifo", "node-q1", "", false)
m.mu.Unlock()
select {
case res := <-item1.waitCh:
if res.err != nil {
t.Fatalf("item1 expected dispatch, got error: %v", res.err)
}
if res.candidate == nil || res.candidate.entry.NodeID != "node-q1" {
t.Fatalf("item1: unexpected candidate %v", res.candidate)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timeout: item1 was not dispatched after slot release")
}
// item2 must still be waiting (no slot available yet).
select {
case <-item2.waitCh:
t.Fatal("item2 should not have been dispatched yet")
default:
}
// Release the slot item1 holds so item2 gets dispatched.
m.mu.Lock()
m.releaseSlotLocked("g-fifo", "node-q1", "", false)
m.mu.Unlock()
select {
case res := <-item2.waitCh:
if res.err != nil {
t.Fatalf("item2 expected dispatch, got error: %v", res.err)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timeout: item2 was not dispatched after second slot release")
}
}
// TestModelQueueOverflow verifies that admit rejects requests when the queue
// is at max_queue capacity.
func TestModelQueueOverflow(t *testing.T) {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-ov1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
entry := &edgenode.NodeEntry{NodeID: "node-ov1"}
cands := []candidateNode{{entry: entry, capacity: 1}}
m := newModelQueueManager(store)
// Fill the node's capacity and set max_queue=1.
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-overflow", groupPolicy{})
g.policy.maxQueue = 1
g.inflight["node-ov1"] = 1
m.mu.Unlock()
// Queue one item — should succeed.
item := &queueItem{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(2 * time.Second),
}
m.mu.Lock()
m.groups["g-overflow"].queue = append(m.groups["g-overflow"].queue, item)
m.mu.Unlock()
// Second admit should fail immediately with errQueueFull.
_, err := m.admit(context.Background(), "g-overflow", "", "", cands, groupPolicy{}, nil, false, false)
if !errors.Is(err, errQueueFull) {
t.Fatalf("expected errQueueFull, got: %v", err)
}
}
// TestModelQueueTimeout verifies that queued items receive a timeout error
// when no slot becomes available before the deadline.
func TestModelQueueTimeout(t *testing.T) {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-to1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
entry := &edgenode.NodeEntry{NodeID: "node-to1"}
cands := []candidateNode{{entry: entry, capacity: 1}}
m := newModelQueueManager(store)
// Fill capacity and set a very short queue timeout.
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-timeout", groupPolicy{})
g.policy.queueTimeout = 20 * time.Millisecond
g.inflight["node-to1"] = 1
m.mu.Unlock()
start := time.Now()
_, err := m.admit(context.Background(), "g-timeout", "", "", cands, groupPolicy{}, nil, false, false)
elapsed := time.Since(start)
if !errors.Is(err, errQueueTimeout) {
t.Fatalf("expected errQueueTimeout, got: %v", err)
}
if elapsed < 15*time.Millisecond {
t.Fatalf("timed out too fast: %v", elapsed)
}
}
// TestModelQueueTerminalReleaseDispatchesNext verifies that a terminal run event
// releases the in-flight slot and dispatches the next queued item.
func TestModelQueueTerminalReleaseDispatchesNext(t *testing.T) {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-tr1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
entry := &edgenode.NodeEntry{NodeID: "node-tr1"}
cands := []candidateNode{{entry: entry, capacity: 1}}
defPolicy := groupPolicy{}
m := newModelQueueManager(store)
// Fill capacity and record inflight.
selected, err := m.admit(context.Background(), "g-tr", "", "", cands, defPolicy, nil, false, false)
if err != nil {
t.Fatalf("admit: %v", err)
}
m.trackLease(selected.leaseID, "run-tr-001")
// Queue a second item in a goroutine.
resultCh := make(chan admitResult, 1)
go func() {
n, e := m.admit(context.Background(), "g-tr", "", "", cands, defPolicy, nil, false, false)
resultCh <- admitResult{candidate: n, err: e}
}()
waitForQueueLen(t, m, "g-tr", 1)
// Terminal event for the first run — should unblock the queued admit.
for _, termType := range []string{"complete", "error", "cancelled"} {
t.Run(termType, func(t *testing.T) {
// Reset state for each sub-test.
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-tr-"+termType, groupPolicy{})
g.inflight["node-tr1"] = 1
item := &queueItem{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(2 * time.Second),
}
g.queue = []*queueItem{item}
runID := "run-tr-" + termType
m.mu.Unlock()
trackTestLease(m, "g-tr-"+termType, runID, "node-tr1", "", false)
// The transport lifecycle hook calls this directly; the event bus is
// not in the release path.
m.releaseRun(runID, termType)
select {
case res := <-item.waitCh:
if res.err != nil {
t.Fatalf("expected dispatch, got error: %v", res.err)
}
if res.candidate == nil {
t.Fatal("expected non-nil candidate")
}
case <-time.After(500 * time.Millisecond):
t.Fatalf("timeout: queued item not dispatched after %q terminal event", termType)
}
})
}
// Cleanup the goroutine from the outer admit.
m.mu.Lock()
if g, ok := m.groups["g-tr"]; ok && len(g.queue) > 0 {
g.inflight["node-tr1"] = 1
m.pumpAllLocked()
}
m.mu.Unlock()
select {
case <-resultCh:
case <-time.After(100 * time.Millisecond):
}
}
// TestModelQueueNodeDisconnectReleasesInflight verifies that a node disconnect
// event removes the node from queued item candidate lists, and that subsequent
// dispatch goes to a remaining live candidate (not the disconnected node).
func TestModelQueueNodeDisconnectReleasesInflight(t *testing.T) {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-nd1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
store.Add(&edgenode.NodeRecord{
ID: "node-nd2",
Runtime: config.RuntimeConf{Concurrency: 1},
})
entry1 := &edgenode.NodeEntry{NodeID: "node-nd1"}
entry2 := &edgenode.NodeEntry{NodeID: "node-nd2"}
// Queued item that can use either node (both at capacity 1).
bothCands := []candidateNode{
{entry: entry1, capacity: 1},
{entry: entry2, capacity: 1},
}
m := newModelQueueManager(store)
// Fill node-nd1 and node-nd2 capacities and record inflight.
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-nd", groupPolicy{})
g.inflight["node-nd1"] = 1
g.inflight["node-nd2"] = 1
// Queue an item that can use either node.
item := &queueItem{
candidates: bothCands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(2 * time.Second),
}
g.queue = []*queueItem{item}
m.mu.Unlock()
trackTestLease(m, "g-nd", "run-nd-x", "node-nd1", "", false)
trackTestLease(m, "g-nd", "run-nd-y", "node-nd2", "", false)
// node-nd1 disconnects: its inflight is freed and it is removed from candidates.
// node-nd2 is still full, so no dispatch yet. Generation 0 fences the node
// unconditionally (untracked/whole-node release).
m.releaseNode("node-nd1", 0, "disconnected")
// node-nd2's run terminates: now nd2 has capacity, dispatch goes to nd2.
m.releaseRun("run-nd-y", "complete")
select {
case res := <-item.waitCh:
if res.err != nil {
t.Fatalf("expected dispatch after disconnect+terminal, got error: %v", res.err)
}
if res.candidate == nil {
t.Fatal("expected non-nil candidate after disconnect release")
}
// Must be nd2 — nd1 was removed from candidates on disconnect.
if res.candidate.entry.NodeID != "node-nd2" {
t.Errorf("expected dispatch to node-nd2, got %q (nd1 was disconnected)", res.candidate.entry.NodeID)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("timeout: queued item not dispatched after node disconnect and terminal event")
}
// The lease for run-nd-x should be gone (released at disconnect).
m.mu.Lock()
_, stillInFlight := m.leaseByRun["run-nd-x"]
m.mu.Unlock()
if stillInFlight {
t.Error("run-nd-x lease should be released after node disconnect")
}
}
// TestRunTerminalReleasesLeaseWhenEventSubscriberFull pins that lease accounting
// survives a lossy observability fanout. The bus drops into full subscriber
// channels by design, so the transport calls the service lifecycle hook directly;
// this test saturates the bus, confirms it is actually dropping, and then checks
// the terminal event still returns the slot.
func TestRunTerminalReleasesLeaseWhenEventSubscriberFull(t *testing.T) {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-drop",
Providers: []config.NodeProviderConf{
{ID: "prov-drop", Capacity: 1, Adapter: "vllm"},
},
})
bus := edgeevents.NewBus()
svc := New(edgenode.NewRegistry(), bus)
svc.SetNodeStore(store)
// A slow observer with a one-deep buffer: the fanout starts dropping as soon
// as it stops reading, which is exactly the delivery correctness must not
// depend on.
_, unsub := bus.SubscribeAllRuns(1)
defer unsub()
entry := &edgenode.NodeEntry{NodeID: "node-drop"}
cands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-drop"}}
admitted, _, err := svc.queue.admitWithReason(context.Background(), "g-drop", "", "", cands, groupPolicy{}, nil, false, false)
if err != nil || admitted == nil {
t.Fatalf("admit: %v", err)
}
svc.queue.trackLease(admitted.leaseID, "run-drop-1")
for i := 0; i < 8; i++ {
bus.PublishRun(&iop.RunEvent{RunId: "run-drop-1", Type: "chunk"})
}
if dropped := bus.Stats().DroppedRunEvents; dropped == 0 {
t.Fatal("expected the saturated subscriber to drop run events; the test is not exercising the drop path")
}
// The terminal event is delivered the way the transport delivers it.
svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: "run-drop-1", Type: "complete"})
svc.queue.mu.Lock()
inflight, _ := svc.queue.getStatsForProviderLocked("node-drop", "prov-drop")
svc.queue.mu.Unlock()
if inflight != 0 {
t.Errorf("in-flight after terminal event with a dropping bus = %d, want 0", inflight)
}
if got := leaseCount(svc.queue); got != 0 {
t.Errorf("live leases after terminal event = %d, want 0", got)
}
}
// TestModelQueueCancelledTerminalReleasesProviderInflight verifies that a
// cancelled terminal RunEvent releases provider in-flight accounting back to
// zero, the same as complete/error. This guards the BUG-2 recovery contract:
// once Edge OpenAI cancel propagation reaches Node and Node replies with a
// cancelled terminal event, the provider snapshot's in_flight must not stay
// stuck positive.
func TestModelQueueCancelledTerminalReleasesProviderInflight(t *testing.T) {
entry := &edgenode.NodeEntry{NodeID: "node-x"}
cands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-1"}}
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-x",
Providers: []config.NodeProviderConf{
{ID: "prov-1", Capacity: 1, Adapter: "vllm"},
},
})
m := newModelQueueManager(store)
m.setStore(store)
selected, err := m.admit(context.Background(), "g-cancel", "", "", cands, groupPolicy{}, nil, false, false)
if err != nil || selected == nil {
t.Fatalf("admit failed: %v", err)
}
m.trackLease(selected.leaseID, "run-cancel-1")
m.mu.Lock()
inf, _ := m.getStatsForProviderLocked("node-x", "prov-1")
m.mu.Unlock()
if inf != 1 {
t.Fatalf("inflight before cancel: got %d, want 1", inf)
}
m.releaseRun("run-cancel-1", "cancelled")
deadline := time.Now().Add(200 * time.Millisecond)
for {
m.mu.Lock()
inf, _ = m.getStatsForProviderLocked("node-x", "prov-1")
m.mu.Unlock()
if inf == 0 {
return
}
if time.Now().After(deadline) {
t.Fatalf("inflight did not recover to 0 after cancelled terminal event, got %d", inf)
}
time.Sleep(1 * time.Millisecond)
}
}
// TestModelQueueContextCancelRemovesQueuedItem verifies that cancelling the
// context of a queued admit removes the item and returns context.Canceled.
func TestModelQueueContextCancelRemovesQueuedItem(t *testing.T) {
entry := &edgenode.NodeEntry{NodeID: "node-cc1"}
cands := []candidateNode{{entry: entry, capacity: 1}}
defPolicy := groupPolicy{}
m := newModelQueueManager(nil)
// Fill the only slot.
n, err := m.admit(context.Background(), "g-cc", "", "", cands, defPolicy, nil, false, false)
if err != nil || n == nil {
t.Fatalf("initial admit: %v", err)
}
// Queue a second admit with a cancellable context.
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan error, 1)
go func() {
_, err := m.admit(ctx, "g-cc", "", "", cands, defPolicy, nil, false, false)
resultCh <- err
}()
waitForQueueLen(t, m, "g-cc", 1)
cancel()
select {
case err := <-resultCh:
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got: %v", err)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("timeout: context cancellation not handled")
}
// Queue must be empty after cancellation.
m.mu.Lock()
qLen := 0
if g, ok := m.groups["g-cc"]; ok {
qLen = len(g.queue)
}
m.mu.Unlock()
if qLen != 0 {
t.Errorf("queue should be empty after cancel, got %d items", qLen)
}
}
func TestProviderResourceCapacitySharedAcrossOrnithModelAliases(t *testing.T) {
const (
nodeID = "rtx5090-lemonade-node"
providerID = "rtx5090-lemonade"
)
modelAliases := []string{"ornith:35b", "ornith-fast"}
entry := &edgenode.NodeEntry{NodeID: nodeID}
cands := []candidateNode{
{entry: entry, capacity: 1, providerID: providerID},
}
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: nodeID,
Providers: []config.NodeProviderConf{
{ID: providerID, Capacity: 1, Adapter: "vllm"},
},
})
m := newModelQueueManager(store)
m.setStore(store)
start := make(chan struct{})
resCh := make(chan concurrentAdmitResult, 2)
for i := 0; i < 2; i++ {
go func(id int) {
<-start
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
groupKey := modelAliases[id]
sel, err := m.admit(ctx, groupKey, "", "", cands, groupPolicy{maxQueue: 1, queueTimeout: 30 * time.Millisecond}, nil, false, false)
resCh <- concurrentAdmitResult{candidate: sel, err: err}
}(i)
}
close(start)
var results []concurrentAdmitResult
for i := 0; i < 2; i++ {
results = append(results, <-resCh)
}
successCount := 0
failCount := 0
var successSel *candidateNode
for _, res := range results {
if res.err == nil {
successCount++
successSel = res.candidate
} else {
failCount++
}
}
if successCount != 1 || failCount != 1 {
t.Fatalf("expected exactly 1 success and 1 failure under concurrent admission, got success=%d fail=%d", successCount, failCount)
}
// Verify in-flight count peak is 1
m.mu.Lock()
res := m.resources[providerResourceKey{nodeID: nodeID, providerID: providerID}]
if res == nil {
m.mu.Unlock()
t.Fatal("expected provider resource state to exist")
}
if res.inFlight != 1 {
t.Errorf("expected inFlight peak to be 1, got %d", res.inFlight)
}
m.mu.Unlock()
// Release the lease held by whichever alias won the shared provider slot.
m.releaseLease(successSel.leaseID, "test-complete")
// Verify count is 0
m.mu.Lock()
if res.inFlight != 0 {
t.Errorf("expected inFlight to recover to 0, got %d", res.inFlight)
}
m.mu.Unlock()
}
func TestProviderSnapshotReadsAdmissionResourceState(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-snap-read", Alias: "node-snap-read"})
store := edgenode.NewNodeStore()
rec := &edgenode.NodeRecord{
ID: "node-snap-read",
Runtime: config.RuntimeConf{Concurrency: 5},
Providers: []config.NodeProviderConf{
{
ID: "snap-prov-read",
Adapter: "vllm",
Capacity: 3,
LongContextCapacity: 2,
Enabled: nil,
},
},
}
store.Add(rec)
bus := edgeevents.NewBus()
svc := New(reg, bus)
svc.SetNodeStore(store)
cands := []candidateNode{
{entry: reg.All()[0], capacity: 3, providerID: "snap-prov-read", longContextCapacity: 2},
}
sel, err := svc.queue.admit(context.Background(), "g-snap-read", "", "", cands, groupPolicy{}, nil, true, false)
if err != nil || sel == nil {
t.Fatalf("admit failed: %v", err)
}
snaps := svc.ListNodeSnapshots()
if len(snaps) != 1 {
t.Fatalf("expected 1 node snapshot")
}
p := snaps[0].ProviderSnapshots[0]
if p.InFlight != 1 {
t.Errorf("expected InFlight 1, got %d", p.InFlight)
}
if p.LongInFlight != 1 {
t.Errorf("expected LongInFlight 1, got %d", p.LongInFlight)
}
svc.queue.releaseSlotWithLong("g-snap-read", "node-snap-read", "snap-prov-read", true)
snaps2 := svc.ListNodeSnapshots()
p2 := snaps2[0].ProviderSnapshots[0]
if p2.InFlight != 0 {
t.Errorf("expected InFlight 0 after release, got %d", p2.InFlight)
}
if p2.LongInFlight != 0 {
t.Errorf("expected LongInFlight 0 after release, got %d", p2.LongInFlight)
}
}
func TestProviderResourceCapacityRefreshPreservesInflight(t *testing.T) {
store := edgenode.NewNodeStore()
rec := &edgenode.NodeRecord{
ID: "node-refresh",
Providers: []config.NodeProviderConf{
{
ID: "prov-refresh",
Capacity: 2,
Enabled: nil, // defaults to true
},
},
}
store.Add(rec)
m := newModelQueueManager(store)
m.setStore(store)
entry := &edgenode.NodeEntry{NodeID: "node-refresh"}
cands := []candidateNode{
{entry: entry, capacity: 2, providerID: "prov-refresh"},
}
// Admit 2 items (matching capacity)
sel1, err := m.admit(context.Background(), "group-1", "", "", cands, groupPolicy{}, nil, false, false)
if err != nil || sel1 == nil {
t.Fatalf("admit 1 failed: %v", err)
}
sel2, err := m.admit(context.Background(), "group-2", "", "", cands, groupPolicy{}, nil, false, false)
if err != nil || sel2 == nil {
t.Fatalf("admit 2 failed: %v", err)
}
// Verify inFlight = 2
m.mu.Lock()
res := m.resources[providerResourceKey{nodeID: "node-refresh", providerID: "prov-refresh"}]
if res.inFlight != 2 {
t.Fatalf("expected inFlight = 2, got %d", res.inFlight)
}
m.mu.Unlock()
// Update store with decreased capacity (2 -> 1)
newStore := edgenode.NewNodeStore()
newRec := &edgenode.NodeRecord{
ID: "node-refresh",
Providers: []config.NodeProviderConf{
{
ID: "prov-refresh",
Capacity: 1,
Enabled: nil,
},
},
}
newStore.Add(newRec)
// Refresh store
m.setStore(newStore)
// Verify capacity is updated but inFlight is preserved
m.mu.Lock()
if res.capacity != 1 {
t.Errorf("expected updated capacity to be 1, got %d", res.capacity)
}
if res.inFlight != 2 {
t.Errorf("expected inFlight to remain 2, got %d", res.inFlight)
}
m.mu.Unlock()
// Third admit should fail because capacity is full (capacity 1, inflight 2)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
_, err = m.admit(ctx, "group-3", "", "", cands, groupPolicy{queueTimeout: 10 * time.Millisecond}, nil, false, false)
if !errors.Is(err, errQueueTimeout) {
t.Fatalf("expected queue timeout for third admit, got: %v", err)
}
}
// TestProviderResourceRemovedProviderDoesNotReadmitQueuedCandidate verifies
// that a stale queued candidate cannot re-admit a removed provider. After the
// last release deletes the orphan resource, a queue pump must not recreate an
// active resource from the stale candidate's old capacity.
//
// Arrange order is critical: the second admit must be in-flight in the queue
// BEFORE the release that triggers pumpAllLocked. This exercises the real
// release-triggered queue-pump path where releaseSlotLocked → pumpAllLocked
// would otherwise hand a stale candidate to a deleted provider.
func TestProviderResourceRemovedProviderDoesNotReadmitQueuedCandidate(t *testing.T) {
store := edgenode.NewNodeStore()
rec := &edgenode.NodeRecord{
ID: "node-stale",
Providers: []config.NodeProviderConf{
{ID: "prov-stale", Capacity: 1, Adapter: "vllm"},
},
}
store.Add(rec)
entry := &edgenode.NodeEntry{NodeID: "node-stale"}
cands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-stale"}}
m := newModelQueueManager(store)
m.setStore(store)
// 1. Admit the first request — fills the only capacity slot.
first, err := m.admit(context.Background(), "g-stale", "", "", cands, groupPolicy{}, nil, false, false)
if err != nil || first == nil {
t.Fatalf("initial admit: %v", err)
}
key := providerResourceKey{nodeID: "node-stale", providerID: "prov-stale"}
m.mu.Lock()
res := m.resources[key]
m.mu.Unlock()
if res == nil || res.orphan {
t.Fatal("expected active non-orphan resource after first admit")
}
// 2. Start the second admit in a goroutine so it blocks in the queue
// while the first request is still running. Use a short timeout so the
// test can observe the terminal result quickly.
resultCh := make(chan admitResult, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
c, _, e := m.admitWithReason(ctx, "g-stale", "", "", cands, groupPolicy{maxQueue: 16, queueTimeout: 500 * time.Millisecond}, nil, false, false)
resultCh <- admitResult{candidate: c, err: e}
}()
// 3. Confirm the second request actually entered the queue.
waitForQueueLen(t, m, "g-stale", 1)
// 4. Remove the provider from the store so the resource becomes orphan.
rec.Providers = nil
m.setStore(store)
m.mu.Lock()
res, ok := m.resources[key]
m.mu.Unlock()
if !ok || res == nil || !res.orphan {
t.Fatalf("expected resource to be orphan after store update, ok=%t, res=%v", ok, res)
}
// 5. Release the first slot to trigger the queue pump. The orphan
// resource must be deleted (inFlight=0) and the queue pump must
// NOT recreate it via pumpAllLocked → findAvailableNodeLocked.
m.releaseSlotWithLong("g-stale", "node-stale", "prov-stale", false)
// 6. The second request must NOT succeed — the stale candidate must
// not be dispatched and the resource must not be recreated.
select {
case res := <-resultCh:
if res.err == nil {
t.Fatal("expected error/timeout for queued candidate of removed provider, got nil error")
}
if res.candidate != nil {
t.Fatalf("expected no candidate dispatch, got %+v", res.candidate)
}
case <-time.After(1 * time.Second):
t.Fatal("timeout: second admit did not return within expected window")
}
// 7. Verify the resource was not recreated as an active entry.
m.mu.Lock()
_, exists := m.resources[key]
m.mu.Unlock()
if exists {
t.Fatal("expected orphan resource to remain deleted after queue pump")
}
}
// queueItemForTest builds a pending item with a generous deadline so the test
// controls when it resolves.
func queueItemForTest(cands []candidateNode, long bool) *queueItem {
return &queueItem{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(5 * time.Second),
long: long,
}
}
// enqueueForTest registers an item in the given group through the same helper
// admission uses, so it receives a real global enqueue sequence. The optional
// resolveCandidates closure is forwarded to the manager; nil keeps the legacy
// snapshot path for existing fixtures.
func enqueueForTest(m *modelQueueManager, groupKey string, item *queueItem, resolveCandidates func() ([]candidateNode, error)) {
m.mu.Lock()
defer m.mu.Unlock()
m.enqueueItemLocked(m.getOrCreateGroupLocked(groupKey, groupPolicy{}), item, resolveCandidates)
}
// TestGlobalPumpWakesDifferentModelGroup verifies the cross-group wake-up
// contract (S03 / cross-group-wakeup): provider resources are shared across
// model groups, so a lease released by one group must wake a waiter queued in
// another. Before the global pump, the waiter slept until its own group saw an
// event or its queue timeout expired.
func TestGlobalPumpWakesDifferentModelGroup(t *testing.T) {
entry := &edgenode.NodeEntry{NodeID: "node-wake"}
cands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-wake"}}
m := newModelQueueManager(nil)
// Group A takes the provider's only slot.
first, err := m.admit(context.Background(), "g-wake-a", "", "", cands, groupPolicy{}, nil, false, false)
if err != nil || first == nil {
t.Fatalf("group A admit: %v", err)
}
// Group B queues a request for the same provider.
waiter := queueItemForTest(cands, false)
enqueueForTest(m, "g-wake-b", waiter, nil)
// Releasing group A's lease must dispatch group B's waiter.
m.releaseLease(first.leaseID, "complete")
select {
case res := <-waiter.waitCh:
if res.err != nil {
t.Fatalf("group B waiter expected dispatch, got error: %v", res.err)
}
if res.candidate == nil || res.candidate.providerID != "prov-wake" {
t.Fatalf("group B waiter: unexpected candidate %v", res.candidate)
}
default:
t.Fatal("group B waiter was not woken by the group A release (cross-group wake-up missing)")
}
}
// TestGlobalPumpPreservesEarliestDispatchableSequence verifies that the pump
// orders waiters by global arrival, not by group: an item blocked on a full
// provider is skipped, and among the items that can dispatch the earliest
// enqueued one wins even when it sits in a different group than the release.
func TestGlobalPumpPreservesEarliestDispatchableSequence(t *testing.T) {
entry := &edgenode.NodeEntry{NodeID: "node-seq"}
blockedCands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-seq-blocked"}}
openCands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-seq-open"}}
m := newModelQueueManager(nil)
// Fill both providers so every queued item starts out blocked.
if _, err := m.admit(context.Background(), "g-seq-a", "", "", blockedCands, groupPolicy{}, nil, false, false); err != nil {
t.Fatalf("fill blocked provider: %v", err)
}
openHolder, err := m.admit(context.Background(), "g-seq-a", "", "", openCands, groupPolicy{}, nil, false, false)
if err != nil {
t.Fatalf("fill open provider: %v", err)
}
// Arrival order: the earliest item can never dispatch (its provider is never
// released); the next two compete for the one slot that does free.
blocked := queueItemForTest(blockedCands, false)
enqueueForTest(m, "g-seq-a", blocked, nil)
earlier := queueItemForTest(openCands, false)
enqueueForTest(m, "g-seq-b", earlier, nil)
later := queueItemForTest(openCands, false)
enqueueForTest(m, "g-seq-a", later, nil)
m.releaseLease(openHolder.leaseID, "complete")
select {
case res := <-earlier.waitCh:
if res.err != nil {
t.Fatalf("earlier item dispatch error: %v", res.err)
}
default:
t.Fatal("earliest dispatchable item was not dispatched first")
}
select {
case <-later.waitCh:
t.Fatal("later item dispatched out of global enqueue order")
default:
}
select {
case <-blocked.waitCh:
t.Fatal("item on a still-full provider must stay queued")
default:
}
// Freeing the slot again promotes the later item; the blocked one stays put,
// proving skipping does not consume its turn.
m.mu.Lock()
pending := len(m.pendingItemsLocked())
m.mu.Unlock()
if pending != 2 {
t.Fatalf("expected 2 pending items after the first dispatch, got %d", pending)
}
}
// TestGlobalPumpConcurrentReleaseCancelRefreshNoLeak drives releases, cancels,
// and config refreshes against the global pump concurrently. The invariants it
// pins are the ones a shared scheduler can silently break: no request is handed
// two leases, provider in-flight never exceeds configured capacity, and every
// counter returns to zero once the storm ends.
func TestGlobalPumpConcurrentReleaseCancelRefreshNoLeak(t *testing.T) {
const (
capacity = 3
groups = 3
perGroup = 12
nodeID = "node-race"
providerID = "prov-race"
)
newStore := func(cap int) *edgenode.NodeStore {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: nodeID,
Providers: []config.NodeProviderConf{
{ID: providerID, Adapter: "vllm", Capacity: cap, LongContextCapacity: 1},
},
})
return store
}
m := newModelQueueManager(nil)
m.setStore(newStore(capacity))
entry := &edgenode.NodeEntry{NodeID: nodeID}
cands := []candidateNode{{entry: entry, capacity: capacity, providerID: providerID, longContextCapacity: 1}}
var (
mu sync.Mutex
seenLeases = map[uint64]int{}
peak int
peakLong int
)
observe := func() {
inFlight, longInFlight := providerResourceCounts(m, nodeID, providerID)
mu.Lock()
if inFlight > peak {
peak = inFlight
}
if longInFlight > peakLong {
peakLong = longInFlight
}
mu.Unlock()
}
stop := make(chan struct{})
var refreshWG sync.WaitGroup
refreshWG.Add(1)
go func() {
defer refreshWG.Done()
for {
select {
case <-stop:
return
default:
}
// Config refresh alternates capacity; both values are pumped, so the
// scheduler re-evaluates every waiter on each swap.
m.setStore(newStore(capacity))
m.setStore(newStore(capacity + 1))
observe()
}
}()
var wg sync.WaitGroup
for g := 0; g < groups; g++ {
groupKey := fmt.Sprintf("g-race-%d", g)
for i := 0; i < perGroup; i++ {
wg.Add(1)
long := i%3 == 0
// A share of the callers abandon their request mid-flight, so cancel
// races the dispatch hand-off.
cancelEarly := i%4 == 0
go func() {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
if cancelEarly {
go func() {
time.Sleep(time.Millisecond)
cancel()
}()
}
admitted, err := m.admit(ctx, groupKey, "", "", cands, groupPolicy{maxQueue: 64, queueTimeout: 150 * time.Millisecond}, nil, long, false)
if err != nil || admitted == nil {
return
}
mu.Lock()
seenLeases[admitted.leaseID]++
mu.Unlock()
observe()
time.Sleep(time.Millisecond)
m.releaseLease(admitted.leaseID, "complete")
}()
}
}
wg.Wait()
close(stop)
refreshWG.Wait()
// Every admitted request holds exactly one lease identity.
mu.Lock()
for id, count := range seenLeases {
if count != 1 {
mu.Unlock()
t.Fatalf("lease %d handed out %d times; a request must own exactly one lease", id, count)
}
}
observedPeak, observedPeakLong := peak, peakLong
mu.Unlock()
if observedPeak > capacity+1 {
t.Errorf("provider in-flight peaked at %d, above the highest configured capacity %d", observedPeak, capacity+1)
}
if observedPeakLong > 1 {
t.Errorf("long in-flight peaked at %d, above the configured long capacity 1", observedPeakLong)
}
// Final state must be fully drained: no held resource, lease, or pending item.
inFlight, longInFlight := providerResourceCounts(m, nodeID, providerID)
if inFlight != 0 || longInFlight != 0 {
t.Errorf("expected zero in-flight after drain, got inflight=%d long=%d", inFlight, longInFlight)
}
if got := leaseCount(m); got != 0 {
t.Errorf("expected zero live leases after drain, got %d", got)
}
if got := pendingItemCount(m); got != 0 {
t.Errorf("expected zero pending items after drain, got %d", got)
}
}
// TestModelQueueTerminalUnavailableOnNoCandidates verifies that when a queued
// item's live resolver returns zero candidates, the item is removed from the
// queue and the waiter receives a typed errProviderUnavailable error exactly
// once. This is the terminal-unavailable outcome contract.
func TestModelQueueTerminalUnavailableOnNoCandidates(t *testing.T) {
store := edgenode.NewNodeStore()
m := newModelQueueManager(store)
candidates := []candidateNode{
{entry: &edgenode.NodeEntry{NodeID: "node-x"}, capacity: 1, providerID: "prov-x"},
}
item := &queueItem{
candidates: candidates,
resolveCandidates: func() ([]candidateNode, error) {
return nil, nil // no live candidates
},
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(5 * time.Second),
providerPool: true,
}
enqueueForTest(m, "g-no-cand", item, item.resolveCandidates)
// Run the pump — should deliver terminal unavailable and remove the item.
m.mu.Lock()
m.pumpAllLocked()
m.mu.Unlock()
// The waiter should have received the terminal unavailable error.
select {
case res := <-item.waitCh:
if res.candidate != nil {
t.Fatalf("expected no candidate, got %+v", res.candidate)
}
if !errors.Is(res.err, errProviderUnavailable) {
t.Fatalf("expected errProviderUnavailable, got: %v", res.err)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timeout: waiter was not notified of terminal unavailable")
}
// Queue must be empty after terminal delivery.
m.mu.Lock()
qLen := len(m.groups["g-no-cand"].queue)
m.mu.Unlock()
if qLen != 0 {
t.Errorf("expected queue to be empty after terminal delivery, got %d", qLen)
}
}
type providerPoolQueueFixture struct {
svc *Service
registry *edgenode.Registry
store *edgenode.NodeStore
catalog []config.ModelCatalogEntry
policy groupPolicy
req SubmitRunRequest
candidates []candidateNode
resolver func() ([]candidateNode, error)
entry *edgenode.NodeEntry
}
func newProviderPoolQueueFixture(t *testing.T, suffix string) *providerPoolQueueFixture {
t.Helper()
nodeID := "node-" + suffix
providerID := "provider-" + suffix
servedModel := "served-" + suffix
groupKey := "group-" + suffix
registry := edgenode.NewRegistry()
entry := &edgenode.NodeEntry{NodeID: nodeID, Alias: nodeID}
registry.Register(entry)
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: nodeID,
Providers: []config.NodeProviderConf{
{
ID: providerID,
Type: "vllm",
Category: config.CategoryAPI,
Models: []string{servedModel},
Health: "available",
Capacity: 1,
},
},
})
catalog := []config.ModelCatalogEntry{
{ID: groupKey, Providers: map[string]string{providerID: servedModel}},
}
policy := NewGroupPolicy(16, 5*time.Second)
svc := New(registry, edgeevents.NewBus())
svc.SetRuntimeConfig(store, catalog, policy)
req := SubmitRunRequest{ModelGroupKey: groupKey, ProviderPool: true}
candidates, _, err := svc.resolveQueueCandidates(req)
if err != nil {
t.Fatalf("resolve initial provider-pool candidates: %v", err)
}
return &providerPoolQueueFixture{
svc: svc,
registry: registry,
store: store,
catalog: catalog,
policy: policy,
req: req,
candidates: candidates,
resolver: svc.resolveQueueCandidatesClosure(req),
entry: entry,
}
}
func (f *providerPoolQueueFixture) admitHolder(t *testing.T) *candidateNode {
t.Helper()
holder, err := f.svc.queue.admit(
t.Context(), f.req.ModelGroupKey, "", "", f.candidates, f.policy,
f.resolver, false, true,
)
if err != nil {
t.Fatalf("admit capacity holder: %v", err)
}
f.svc.queue.trackLease(holder.leaseID, "run-holder-"+f.req.ModelGroupKey)
return holder
}
func (f *providerPoolQueueFixture) enqueueWaiter() *queueItem {
item := queueItemForTest(f.candidates, false)
item.providerPool = true
enqueueForTest(f.svc.queue, f.req.ModelGroupKey, item, f.resolver)
return item
}
func providerQueuePressureForTest(m *modelQueueManager, nodeID, providerID string) (queued, longQueued int) {
m.mu.Lock()
defer m.mu.Unlock()
pressure := m.providerQueuePressureLocked()
if byProvider := pressure[nodeID]; byProvider != nil {
if value := byProvider[providerID]; value != nil {
return value.queued, value.longQueued
}
}
return 0, 0
}
// TestProviderPoolQueuedDisconnectReturnsUnavailableImmediately verifies that
// the production registry/store/catalog resolver classifies the last live
// provider's disconnect as terminal unavailable. The queue and candidate
// pressure are cleared in the same disconnect pump, and a later pump cannot
// deliver a duplicate terminal result.
func TestProviderPoolQueuedDisconnectReturnsUnavailableImmediately(t *testing.T) {
fixture := newProviderPoolQueueFixture(t, "disconnect-unavailable")
holder := fixture.admitHolder(t)
item := fixture.enqueueWaiter()
if pending := pendingItemCount(fixture.svc.queue); pending != 1 {
t.Fatalf("pending before disconnect = %d, want 1", pending)
}
queued, longQueued := providerQueuePressureForTest(
fixture.svc.queue, holder.entry.NodeID, holder.providerID,
)
if queued != 1 || longQueued != 0 {
t.Fatalf("candidate pressure before disconnect = (%d, %d), want (1, 0)", queued, longQueued)
}
// Keep an unrelated Node connected so production resolution reaches the
// filtered zero-candidate branch instead of the simpler empty-registry branch.
fixture.registry.Register(&edgenode.NodeEntry{NodeID: "node-unrelated-disconnect-unavailable"})
fixture.registry.Unregister(fixture.entry.NodeID)
fixture.svc.HandleNodeDisconnect(
fixture.entry.NodeID, fixture.entry.ConnectionGeneration, "test disconnect",
)
select {
case result := <-item.waitCh:
if result.candidate != nil {
t.Fatalf("disconnect waiter candidate = %+v, want nil", result.candidate)
}
if !errors.Is(result.err, errProviderUnavailable) {
t.Fatalf("disconnect waiter error = %v, want errProviderUnavailable", result.err)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("disconnect waiter did not receive terminal unavailable immediately")
}
if pending := pendingItemCount(fixture.svc.queue); pending != 0 {
t.Fatalf("pending after disconnect = %d, want 0", pending)
}
queued, longQueued = providerQueuePressureForTest(
fixture.svc.queue, holder.entry.NodeID, holder.providerID,
)
if queued != 0 || longQueued != 0 {
t.Fatalf("candidate pressure after disconnect = (%d, %d), want (0, 0)", queued, longQueued)
}
if inFlight, _ := providerResourceCounts(fixture.svc.queue, holder.entry.NodeID, holder.providerID); inFlight != 0 {
t.Fatalf("in-flight after disconnect = %d, want 0", inFlight)
}
if leases := leaseCount(fixture.svc.queue); leases != 0 {
t.Fatalf("live leases after disconnect = %d, want 0", leases)
}
fixture.svc.queue.mu.Lock()
fixture.svc.queue.pumpAllLocked()
fixture.svc.queue.mu.Unlock()
select {
case duplicate := <-item.waitCh:
t.Fatalf("duplicate terminal delivery after re-pump: %+v", duplicate)
default:
}
}
// TestModelQueueStaysQueuedWhenAllCandidatesFull verifies through the
// production provider-pool resolver that a connected, available provider at
// capacity is a temporary block, not terminal unavailability.
func TestModelQueueStaysQueuedWhenAllCandidatesFull(t *testing.T) {
fixture := newProviderPoolQueueFixture(t, "all-candidates-full")
holder := fixture.admitHolder(t)
item := fixture.enqueueWaiter()
// Pump — the item should be skipped (temporarily blocked), not delivered.
fixture.svc.queue.mu.Lock()
fixture.svc.queue.pumpAllLocked()
fixture.svc.queue.mu.Unlock()
if pending := pendingItemCount(fixture.svc.queue); pending != 1 {
t.Fatalf("pending when all candidates full = %d, want 1", pending)
}
queued, longQueued := providerQueuePressureForTest(
fixture.svc.queue, holder.entry.NodeID, holder.providerID,
)
if queued != 1 || longQueued != 0 {
t.Fatalf("candidate pressure while full = (%d, %d), want (1, 0)", queued, longQueued)
}
select {
case <-item.waitCh:
t.Fatal("waiter should not have been notified when all candidates full")
default:
}
fixture.svc.HandleRunLifecycleEvent(&iop.RunEvent{
RunId: "run-holder-" + fixture.req.ModelGroupKey,
Type: "complete",
})
select {
case result := <-item.waitCh:
if result.err != nil || result.candidate == nil {
t.Fatalf("waiter after holder release = %+v, want dispatch", result)
}
fixture.svc.queue.releaseLease(result.candidate.leaseID, "test cleanup")
case <-time.After(100 * time.Millisecond):
t.Fatal("full candidate waiter did not dispatch after holder release")
}
}
// TestProviderPoolQueuedResolverErrorStaysQueuedAndRecovers verifies that a
// production catalog/config resolver fault is not confused with the typed
// no-live-provider outcome. The waiter remains queued across the fault and
// dispatches after a valid snapshot is restored and capacity is released.
func TestProviderPoolQueuedResolverErrorStaysQueuedAndRecovers(t *testing.T) {
fixture := newProviderPoolQueueFixture(t, "resolver-recovery")
fixture.admitHolder(t)
item := fixture.enqueueWaiter()
// Remove the model catalog entry while the request is queued. This is a
// resolver/config fault, not a live-candidate absence, so it must not carry
// errProviderUnavailable or terminate the waiter.
fixture.svc.SetRuntimeConfig(fixture.store, nil, fixture.policy)
if _, err := fixture.resolver(); err == nil {
t.Fatal("resolver with missing catalog returned nil error")
} else if errors.Is(err, errProviderUnavailable) {
t.Fatalf("missing catalog error incorrectly classified unavailable: %v", err)
}
if pending := pendingItemCount(fixture.svc.queue); pending != 1 {
t.Fatalf("pending during resolver fault = %d, want 1", pending)
}
select {
case result := <-item.waitCh:
t.Fatalf("resolver fault unexpectedly notified waiter: %+v", result)
default:
}
fixture.svc.SetRuntimeConfig(fixture.store, fixture.catalog, fixture.policy)
if pending := pendingItemCount(fixture.svc.queue); pending != 1 {
t.Fatalf("pending after resolver recovery while full = %d, want 1", pending)
}
queued, longQueued := providerQueuePressureForTest(
fixture.svc.queue, fixture.entry.NodeID, fixture.candidates[0].providerID,
)
if queued != 1 || longQueued != 0 {
t.Fatalf("candidate pressure after resolver recovery = (%d, %d), want (1, 0)", queued, longQueued)
}
fixture.svc.HandleRunLifecycleEvent(&iop.RunEvent{
RunId: "run-holder-" + fixture.req.ModelGroupKey,
Type: "complete",
})
select {
case result := <-item.waitCh:
if result.err != nil || result.candidate == nil {
t.Fatalf("waiter after resolver recovery = %+v, want dispatch", result)
}
fixture.svc.queue.releaseLease(result.candidate.leaseID, "test cleanup")
case <-time.After(100 * time.Millisecond):
t.Fatal("resolver-recovered waiter did not dispatch after capacity release")
}
}
// TestProviderPoolDisconnectAuthoritativeSettlementNoLeak verifies that an
// authoritative disconnect with the live resolver settles the last live
// candidate as terminal unavailable in a single deterministic pass: the waiter
// receives errProviderUnavailable, the queue is empty, in-flight and candidate
// pressure are zero, and every lease is released. It also runs with a
// saturated event subscriber to prove that event bus backpressure does not
// affect the settlement contract — the transport drives releaseNode directly.
func TestProviderPoolDisconnectAuthoritativeSettlementNoLeak(t *testing.T) {
bus := edgeevents.NewBus()
fixture := newProviderPoolQueueFixture(t, "auth-settlement")
// Override the bus so we can verify it is actually saturated (dropping).
// We achieve this by subscribing with a one-deep buffer and flooding it
// before the disconnect.
fixture.svc = New(fixture.registry, bus)
fixture.svc.SetRuntimeConfig(fixture.store, fixture.catalog, fixture.policy)
// Re-resolve candidates against the new service so the resolver closure
// points at the correct service instance.
cands, _, err := fixture.svc.resolveQueueCandidates(fixture.req)
if err != nil {
t.Fatalf("re-resolve candidates after bus swap: %v", err)
}
fixture.candidates = cands
fixture.resolver = fixture.svc.resolveQueueCandidatesClosure(fixture.req)
// Saturated subscriber: one-deep buffer, then flood so drops are real.
_, unsub := bus.SubscribeAllRuns(1)
defer unsub()
for i := 0; i < 8; i++ {
bus.PublishRun(&iop.RunEvent{RunId: "run-flood", Type: "chunk"})
}
if dropped := bus.Stats().DroppedRunEvents; dropped == 0 {
t.Fatal("expected the saturated subscriber to drop events; the test must exercise the drop path")
}
holder := fixture.admitHolder(t)
item := fixture.enqueueWaiter()
if pending := pendingItemCount(fixture.svc.queue); pending != 1 {
t.Fatalf("pending before disconnect = %d, want 1", pending)
}
// Keep an unrelated node connected so production resolution reaches the
// filtered zero-candidate branch (not the empty-registry shortcut).
fixture.registry.Register(&edgenode.NodeEntry{NodeID: "node-unrelated-auth"})
// Authoritative disconnect: the registry confirms the disconnecting
// client still owned the entry.
fixture.svc.HandleNodeDisconnect(
fixture.entry.NodeID, fixture.entry.ConnectionGeneration, "test disconnect",
)
// Waiter must receive typed unavailable exactly once.
select {
case result := <-item.waitCh:
if result.candidate != nil {
t.Fatalf("disconnect waiter candidate = %+v, want nil", result.candidate)
}
if !errors.Is(result.err, errProviderUnavailable) {
t.Fatalf("disconnect waiter error = %v, want errProviderUnavailable", result.err)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("disconnect waiter did not receive terminal unavailable immediately")
}
// Post-settlement invariants.
if pending := pendingItemCount(fixture.svc.queue); pending != 0 {
t.Fatalf("pending after authoritative disconnect = %d, want 0", pending)
}
queued, longQueued := providerQueuePressureForTest(
fixture.svc.queue, holder.entry.NodeID, holder.providerID,
)
if queued != 0 || longQueued != 0 {
t.Fatalf("candidate pressure after disconnect = (%d, %d), want (0, 0)", queued, longQueued)
}
if inFlight, _ := providerResourceCounts(fixture.svc.queue, holder.entry.NodeID, holder.providerID); inFlight != 0 {
t.Fatalf("in-flight after disconnect = %d, want 0", inFlight)
}
if leases := leaseCount(fixture.svc.queue); leases != 0 {
t.Fatalf("live leases after disconnect = %d, want 0", leases)
}
// Re-pump must not deliver a duplicate terminal result.
fixture.svc.queue.mu.Lock()
fixture.svc.queue.pumpAllLocked()
fixture.svc.queue.mu.Unlock()
select {
case duplicate := <-item.waitCh:
t.Fatalf("duplicate terminal delivery after re-pump: %+v", duplicate)
default:
}
}
// TestProviderPoolLastProviderDisconnectTerminalSettlement verifies that when
// the very last live candidate for a queued provider-pool item disappears
// (because the node disconnects), the waiter receives errProviderUnavailable
// immediately — not via queue timeout — and all accounting (queue, lease,
// in-flight, candidate pressure) returns to zero in the same pass.
func TestProviderPoolLastProviderDisconnectTerminalSettlement(t *testing.T) {
fixture := newProviderPoolQueueFixture(t, "last-provider")
holder := fixture.admitHolder(t)
item := fixture.enqueueWaiter()
// Verify pre-disconnect state.
if pending := pendingItemCount(fixture.svc.queue); pending != 1 {
t.Fatalf("pending before last-provider disconnect = %d, want 1", pending)
}
// The last live candidate disappears via disconnect.
fixture.svc.HandleNodeDisconnect(
fixture.entry.NodeID, fixture.entry.ConnectionGeneration, "last provider disconnect",
)
select {
case result := <-item.waitCh:
if result.candidate != nil {
t.Fatalf("last-provider waiter candidate = %+v, want nil", result.candidate)
}
if !errors.Is(result.err, errProviderUnavailable) {
t.Fatalf("last-provider waiter error = %v, want errProviderUnavailable", result.err)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("last-provider waiter did not receive terminal unavailable immediately")
}
// Queue, lease, and pressure must all be zero.
if pending := pendingItemCount(fixture.svc.queue); pending != 0 {
t.Fatalf("pending after last-provider disconnect = %d, want 0", pending)
}
if leases := leaseCount(fixture.svc.queue); leases != 0 {
t.Fatalf("live leases after last-provider disconnect = %d, want 0", leases)
}
// No duplicate terminal delivery after a subsequent pump.
fixture.svc.queue.mu.Lock()
fixture.svc.queue.pumpAllLocked()
fixture.svc.queue.mu.Unlock()
select {
case duplicate := <-item.waitCh:
t.Fatalf("duplicate terminal delivery after re-pump: %+v", duplicate)
default:
}
// Also verify that the holder's resource is cleaned up.
if inFlight, _ := providerResourceCounts(fixture.svc.queue, holder.entry.NodeID, holder.providerID); inFlight != 0 {
t.Fatalf("holder in-flight after last-provider disconnect = %d, want 0", inFlight)
}
}