155 lines
5.3 KiB
Go
155 lines
5.3 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
edgenode "iop/apps/edge/internal/node"
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
// reservationFixture admits one slot on a single-capacity provider and returns
|
|
// the manager, the reservation for that admission, and the slot key it holds.
|
|
func reservationFixture(t *testing.T, long bool, longCapacity int) (*modelQueueManager, *queueReservation, string) {
|
|
t.Helper()
|
|
store := edgenode.NewNodeStore()
|
|
store.Add(&edgenode.NodeRecord{
|
|
ID: "node-res",
|
|
Runtime: config.RuntimeConf{Concurrency: 1},
|
|
})
|
|
|
|
entry := &edgenode.NodeEntry{NodeID: "node-res"}
|
|
selected := &candidateNode{
|
|
entry: entry,
|
|
capacity: 1,
|
|
providerID: "prov-1",
|
|
longContextCapacity: longCapacity,
|
|
}
|
|
cands := []candidateNode{*selected}
|
|
|
|
m := newModelQueueManager(store)
|
|
admitted, _, err := m.admitWithReason(context.Background(), "g-res", "", "", cands, groupPolicy{}, long)
|
|
if err != nil || admitted == nil {
|
|
t.Fatalf("admit: %v", err)
|
|
}
|
|
|
|
return m, newQueueReservation(m, "g-res", selected, long), "node-res:prov-1"
|
|
}
|
|
|
|
func inflightCount(t *testing.T, m *modelQueueManager, groupKey, slot string) (int, int) {
|
|
t.Helper()
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
group, ok := m.groups[groupKey]
|
|
if !ok {
|
|
t.Fatalf("group %q not found", groupKey)
|
|
}
|
|
return group.inflight[slot], group.longInflight[slot]
|
|
}
|
|
|
|
// TestQueueReservationReleasesExactlyOnce pins the release contract at each
|
|
// boundary a dispatch can fail on: the slot is freed once, and a repeated
|
|
// release cannot free a second slot.
|
|
func TestQueueReservationReleasesExactlyOnce(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
// track reports whether the dispatch got as far as registering the run
|
|
// inflight before failing.
|
|
track bool
|
|
reason string
|
|
}{
|
|
{name: "prepare tunnel error before track", reason: "prepare-tunnel-error"},
|
|
{name: "prepare run error before track", reason: "prepare-run-error"},
|
|
{name: "build error before track", reason: "build-error"},
|
|
{name: "unknown execution path before track", reason: "unknown-execution-path"},
|
|
{name: "send error after track", track: true, reason: "send-error"},
|
|
{name: "no event bus after track", track: true, reason: "no-event-bus"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m, reservation, slot := reservationFixture(t, false, 0)
|
|
|
|
if inflight, _ := inflightCount(t, m, "g-res", slot); inflight != 1 {
|
|
t.Fatalf("expected 1 reserved slot after admission, got %d", inflight)
|
|
}
|
|
|
|
if tc.track {
|
|
reservation.track("run-res")
|
|
}
|
|
|
|
reservation.release(tc.reason)
|
|
if inflight, _ := inflightCount(t, m, "g-res", slot); inflight != 0 {
|
|
t.Fatalf("expected the slot to be released, got %d in flight", inflight)
|
|
}
|
|
|
|
// A second release must not decrement a slot that now belongs to
|
|
// another request.
|
|
reservation.release(tc.reason)
|
|
if inflight, _ := inflightCount(t, m, "g-res", slot); inflight != 0 {
|
|
t.Fatalf("expected release to be idempotent, got %d in flight", inflight)
|
|
}
|
|
if tc.track {
|
|
if _, ok := m.inflightByRun["run-res"]; ok {
|
|
t.Fatal("expected the run to be removed from the inflight index")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestQueueReservationHandOffKeepsSlotHeld pins that a dispatched request keeps
|
|
// its slot: after hand-off the run/tunnel lifecycle owns the release, so a late
|
|
// release on the reservation must not free it.
|
|
func TestQueueReservationHandOffKeepsSlotHeld(t *testing.T) {
|
|
m, reservation, slot := reservationFixture(t, false, 0)
|
|
reservation.track("run-res")
|
|
reservation.handOff()
|
|
|
|
reservation.release("send-error")
|
|
|
|
if inflight, _ := inflightCount(t, m, "g-res", slot); inflight != 1 {
|
|
t.Fatalf("expected the dispatched run to keep its slot, got %d in flight", inflight)
|
|
}
|
|
|
|
// The lifecycle owner releases it exactly once, via the run registration.
|
|
m.releaseRun("run-res", "complete")
|
|
if inflight, _ := inflightCount(t, m, "g-res", slot); inflight != 0 {
|
|
t.Fatalf("expected the terminal event to release the slot, got %d in flight", inflight)
|
|
}
|
|
}
|
|
|
|
// TestQueueReservationLongSlotMatchesReservation pins that the long-context
|
|
// slot is released only when it was actually reserved: a long request on a
|
|
// provider that declares a long limit.
|
|
func TestQueueReservationLongSlotMatchesReservation(t *testing.T) {
|
|
t.Run("long request on a long-capacity provider releases the long slot", func(t *testing.T) {
|
|
m, reservation, slot := reservationFixture(t, true, 1)
|
|
if _, longInflight := inflightCount(t, m, "g-res", slot); longInflight != 1 {
|
|
t.Fatalf("expected 1 reserved long slot, got %d", longInflight)
|
|
}
|
|
|
|
reservation.release("send-error")
|
|
|
|
inflight, longInflight := inflightCount(t, m, "g-res", slot)
|
|
if inflight != 0 || longInflight != 0 {
|
|
t.Fatalf("expected both slots released, got inflight=%d long=%d", inflight, longInflight)
|
|
}
|
|
})
|
|
|
|
t.Run("long request without a declared long limit reserves no long slot", func(t *testing.T) {
|
|
m, reservation, slot := reservationFixture(t, true, 0)
|
|
if _, longInflight := inflightCount(t, m, "g-res", slot); longInflight != 0 {
|
|
t.Fatalf("expected no long slot to be reserved, got %d", longInflight)
|
|
}
|
|
if reservation.long {
|
|
t.Fatal("expected the reservation not to claim a long slot")
|
|
}
|
|
|
|
reservation.release("send-error")
|
|
|
|
if inflight, _ := inflightCount(t, m, "g-res", slot); inflight != 0 {
|
|
t.Fatalf("expected the slot to be released, got %d in flight", inflight)
|
|
}
|
|
})
|
|
}
|