- edge connector 및 service 테스트 개선 - node FIFO admission queue 구현 - 로드맵 및 마일스톤 문서 업데이트 - queue observe snapshot 아카이브 이동
2109 lines
70 KiB
Go
2109 lines
70 KiB
Go
package node_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"iop/apps/node/internal/node"
|
|
"iop/apps/node/internal/runtime"
|
|
"iop/apps/node/internal/store"
|
|
"iop/apps/node/internal/transport"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
// --- test doubles ---
|
|
|
|
type countingAdapter struct {
|
|
executeCalls int32
|
|
lastSpec runtime.ExecutionSpec
|
|
}
|
|
|
|
func (a *countingAdapter) Name() string { return "test" }
|
|
func (a *countingAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|
return runtime.Capabilities{AdapterName: "test", Targets: []string{"v1"}, MaxConcurrency: 1}, nil
|
|
}
|
|
func (a *countingAdapter) Execute(_ context.Context, spec runtime.ExecutionSpec, _ runtime.EventSink) error {
|
|
atomic.AddInt32(&a.executeCalls, 1)
|
|
a.lastSpec = spec
|
|
return nil
|
|
}
|
|
|
|
// failingAdapter returns a fixed error from Execute.
|
|
type failingAdapter struct{ err error }
|
|
|
|
func (a *failingAdapter) Name() string { return "failing" }
|
|
func (a *failingAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|
return runtime.Capabilities{AdapterName: "failing"}, nil
|
|
}
|
|
func (a *failingAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
|
|
return a.err
|
|
}
|
|
|
|
// blockingAdapter blocks until its context is cancelled, then returns ErrRunCancelled.
|
|
type blockingAdapter struct {
|
|
started chan struct{}
|
|
done chan struct{}
|
|
}
|
|
|
|
func newBlockingAdapter() *blockingAdapter {
|
|
return &blockingAdapter{started: make(chan struct{}), done: make(chan struct{})}
|
|
}
|
|
|
|
func (a *blockingAdapter) Name() string { return "blocking" }
|
|
func (a *blockingAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|
return runtime.Capabilities{AdapterName: "blocking"}, nil
|
|
}
|
|
func (a *blockingAdapter) Execute(ctx context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
|
|
close(a.started)
|
|
<-ctx.Done()
|
|
close(a.done)
|
|
return runtime.ErrRunCancelled
|
|
}
|
|
|
|
// terminatingAdapter implements SessionTerminator.
|
|
type terminatingAdapter struct {
|
|
terminateCalls int32
|
|
lastTarget string
|
|
lastSessionID string
|
|
}
|
|
|
|
func (a *terminatingAdapter) Name() string { return "terminating" }
|
|
func (a *terminatingAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|
return runtime.Capabilities{AdapterName: "terminating"}, nil
|
|
}
|
|
func (a *terminatingAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
|
|
return nil
|
|
}
|
|
func (a *terminatingAdapter) TerminateSession(_ context.Context, target, sessionID string) error {
|
|
atomic.AddInt32(&a.terminateCalls, 1)
|
|
a.lastTarget = target
|
|
a.lastSessionID = sessionID
|
|
return nil
|
|
}
|
|
|
|
type instanceKeyAdapter struct {
|
|
instanceKey string
|
|
}
|
|
|
|
func (a *instanceKeyAdapter) Name() string { return "ollama" }
|
|
func (a *instanceKeyAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|
return runtime.Capabilities{
|
|
AdapterName: "ollama",
|
|
InstanceKey: a.instanceKey,
|
|
Targets: []string{"llama3"},
|
|
MaxConcurrency: 4,
|
|
}, nil
|
|
}
|
|
func (a *instanceKeyAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
|
|
return nil
|
|
}
|
|
|
|
type commandAdapter struct {
|
|
lastReq runtime.CommandRequest
|
|
providerStatus runtime.ProviderStatus
|
|
}
|
|
|
|
func (a *commandAdapter) Name() string { return "command" }
|
|
func (a *commandAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|
return runtime.Capabilities{
|
|
AdapterName: "command",
|
|
Targets: []string{"v2", "v1"},
|
|
MaxConcurrency: 3,
|
|
ProviderStatus: a.providerStatus,
|
|
}, nil
|
|
}
|
|
func (a *commandAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
|
|
return nil
|
|
}
|
|
func (a *commandAdapter) HandleCommand(ctx context.Context, req runtime.CommandRequest) (runtime.CommandResponse, error) {
|
|
a.lastReq = req
|
|
switch req.Type {
|
|
case runtime.CommandTypeUsageStatus:
|
|
return runtime.CommandResponse{
|
|
RequestID: req.RequestID,
|
|
Type: req.Type,
|
|
Adapter: req.Adapter,
|
|
Target: req.Target,
|
|
SessionID: req.SessionID,
|
|
UsageStatus: &runtime.AgentUsageStatus{
|
|
RawOutput: "success",
|
|
},
|
|
}, nil
|
|
default:
|
|
return runtime.CommandResponse{}, errors.New("command not supported by adapter")
|
|
}
|
|
}
|
|
|
|
type proberTestAdapter struct {
|
|
providerStatus runtime.ProviderStatus
|
|
probeTargets []string
|
|
probeErr error
|
|
}
|
|
|
|
func (a *proberTestAdapter) Name() string { return "prober" }
|
|
func (a *proberTestAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|
return runtime.Capabilities{
|
|
AdapterName: "prober",
|
|
Targets: []string{"model-a"},
|
|
MaxConcurrency: 3,
|
|
ProviderStatus: a.providerStatus,
|
|
}, nil
|
|
}
|
|
func (a *proberTestAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
|
|
return nil
|
|
}
|
|
func (a *proberTestAdapter) ProbeProvider(ctx context.Context, target string) (runtime.ProviderProbeResult, error) {
|
|
if a.probeErr != nil {
|
|
return runtime.ProviderProbeResult{}, a.probeErr
|
|
}
|
|
status := runtime.ProviderStatusAvailable
|
|
found := false
|
|
for _, t := range a.probeTargets {
|
|
if t == target {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if target != "" && !found {
|
|
status = runtime.ProviderStatusUnavailable
|
|
}
|
|
return runtime.ProviderProbeResult{
|
|
AdapterName: "prober",
|
|
Target: target,
|
|
Targets: a.probeTargets,
|
|
Status: status,
|
|
}, nil
|
|
}
|
|
|
|
type fixedRouter struct {
|
|
adapterName string
|
|
adapter runtime.Adapter
|
|
adapters map[string]runtime.Adapter
|
|
lookupErrors map[string]error // optional per-adapter errors for LookupAdapter
|
|
}
|
|
|
|
func (r *fixedRouter) Resolve(_ context.Context, req runtime.RunRequest) (runtime.ExecutionSpec, error) {
|
|
return runtime.ExecutionSpec{
|
|
RunID: req.RunID,
|
|
Adapter: r.adapterName,
|
|
Target: req.Target,
|
|
SessionID: req.SessionID,
|
|
SessionMode: req.SessionMode,
|
|
Background: req.Background,
|
|
Workspace: req.Workspace,
|
|
Policy: req.Policy,
|
|
Input: req.Input,
|
|
TimeoutSec: req.TimeoutSec,
|
|
Metadata: req.Metadata,
|
|
}, nil
|
|
}
|
|
|
|
func (r *fixedRouter) ResolveAdapter(ctx context.Context, req runtime.RunRequest) (runtime.ExecutionSpec, runtime.Adapter, error) {
|
|
spec, err := r.Resolve(ctx, req)
|
|
if err != nil {
|
|
return runtime.ExecutionSpec{}, nil, err
|
|
}
|
|
a, ok := r.adapters[spec.Adapter]
|
|
if !ok {
|
|
return runtime.ExecutionSpec{}, nil, fmt.Errorf("router: adapter %q not found", spec.Adapter)
|
|
}
|
|
return spec, a, nil
|
|
}
|
|
|
|
func (r *fixedRouter) LookupAdapter(adapterName string) (runtime.Adapter, error) {
|
|
if r.lookupErrors != nil {
|
|
if err, ok := r.lookupErrors[adapterName]; ok {
|
|
return nil, err
|
|
}
|
|
}
|
|
a, ok := r.adapters[adapterName]
|
|
if !ok {
|
|
return nil, fmt.Errorf("adapter %q not found", adapterName)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func (r *fixedRouter) GetAdapter(adapterName string) (runtime.Adapter, bool) {
|
|
a, ok := r.adapters[adapterName]
|
|
return a, ok
|
|
}
|
|
|
|
type errorRouter struct{ err error }
|
|
|
|
func (r *errorRouter) Resolve(_ context.Context, _ runtime.RunRequest) (runtime.ExecutionSpec, error) {
|
|
return runtime.ExecutionSpec{}, r.err
|
|
}
|
|
|
|
func (r *errorRouter) ResolveAdapter(_ context.Context, _ runtime.RunRequest) (runtime.ExecutionSpec, runtime.Adapter, error) {
|
|
return runtime.ExecutionSpec{}, nil, r.err
|
|
}
|
|
|
|
func (r *errorRouter) LookupAdapter(_ string) (runtime.Adapter, error) {
|
|
return nil, r.err
|
|
}
|
|
|
|
func (r *errorRouter) GetAdapter(_ string) (runtime.Adapter, bool) {
|
|
return nil, false
|
|
}
|
|
|
|
func makeNode(t *testing.T, rtr runtime.Router) (*node.Node, *store.Store) {
|
|
t.Helper()
|
|
return makeNodeWithConcurrency(t, rtr, 0)
|
|
}
|
|
|
|
func makeNodeWithConcurrency(t *testing.T, rtr runtime.Router, concurrency int) (*node.Node, *store.Store) {
|
|
t.Helper()
|
|
st, err := store.New(":memory:", zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
return node.New("test-node", rtr, st, concurrency, io.Discard, zap.NewNop()), st
|
|
}
|
|
|
|
// --- existing tests (updated) ---
|
|
|
|
func TestOnRunRequest_RouterError(t *testing.T) {
|
|
n, _ := makeNode(t, &errorRouter{err: errors.New("boom")})
|
|
|
|
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{RunId: "run-1"})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), "node: resolve:") {
|
|
t.Fatalf("expected resolve prefix, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOnRunRequest_AdapterNotFound(t *testing.T) {
|
|
router := &fixedRouter{adapterName: "missing", adapters: make(map[string]runtime.Adapter)}
|
|
n, _ := makeNode(t, router)
|
|
|
|
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{RunId: "run-1"})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), "not found") {
|
|
t.Fatalf("expected adapter lookup error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOnRunRequest_Success(t *testing.T) {
|
|
adapter := &countingAdapter{}
|
|
router := &fixedRouter{adapterName: "test", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["test"] = adapter
|
|
n, st := makeNode(t, router)
|
|
|
|
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "run-1",
|
|
Adapter: "test",
|
|
Target: "v1",
|
|
Workspace: "/config/workspace/iop",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run request: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&adapter.executeCalls); got != 1 {
|
|
t.Fatalf("expected 1 execute call, got %d", got)
|
|
}
|
|
if adapter.lastSpec.Workspace != "/config/workspace/iop" {
|
|
t.Fatalf("adapter workspace: got %q", adapter.lastSpec.Workspace)
|
|
}
|
|
|
|
run, err := st.GetRun(context.Background(), "run-1")
|
|
if err != nil {
|
|
t.Fatalf("get run: %v", err)
|
|
}
|
|
if run == nil {
|
|
t.Fatal("expected persisted run record")
|
|
}
|
|
if run.Status != "completed" {
|
|
t.Fatalf("expected completed status, got %q", run.Status)
|
|
}
|
|
}
|
|
|
|
// --- new tests ---
|
|
|
|
func TestOnRunRequest_ForegroundAdapterErrorReturned(t *testing.T) {
|
|
adapter := &failingAdapter{err: errors.New("adapter boom")}
|
|
router := &fixedRouter{adapterName: "failing", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["failing"] = adapter
|
|
n, st := makeNode(t, router)
|
|
|
|
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "run-fail",
|
|
Adapter: "failing",
|
|
Target: "v1",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error from OnRunRequest")
|
|
}
|
|
if !strings.Contains(err.Error(), "adapter boom") {
|
|
t.Fatalf("expected adapter boom in error, got %v", err)
|
|
}
|
|
|
|
run, err := st.GetRun(context.Background(), "run-fail")
|
|
if err != nil {
|
|
t.Fatalf("get run: %v", err)
|
|
}
|
|
if run == nil {
|
|
t.Fatal("expected persisted run record")
|
|
}
|
|
if run.Status != "failed" {
|
|
t.Fatalf("expected failed status, got %q", run.Status)
|
|
}
|
|
if !strings.Contains(run.Error, "adapter boom") {
|
|
t.Fatalf("expected adapter boom in stored error, got %q", run.Error)
|
|
}
|
|
}
|
|
|
|
func TestOnRunRequest_ForegroundCancelReturnedAndStored(t *testing.T) {
|
|
adapter := &failingAdapter{err: runtime.ErrRunCancelled}
|
|
router := &fixedRouter{adapterName: "failing", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["failing"] = adapter
|
|
n, st := makeNode(t, router)
|
|
|
|
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "run-cancel-fg",
|
|
Adapter: "failing",
|
|
Target: "v1",
|
|
})
|
|
if !errors.Is(err, runtime.ErrRunCancelled) {
|
|
t.Fatalf("expected ErrRunCancelled, got %v", err)
|
|
}
|
|
run, err := st.GetRun(context.Background(), "run-cancel-fg")
|
|
if err != nil {
|
|
t.Fatalf("get run: %v", err)
|
|
}
|
|
if run == nil || run.Status != "cancelled" {
|
|
t.Fatalf("expected cancelled status, got %+v", run)
|
|
}
|
|
}
|
|
|
|
func TestOnRunRequest_BackgroundReturnsBeforeAdapterCompletes(t *testing.T) {
|
|
ba := newBlockingAdapter()
|
|
router := &fixedRouter{adapterName: "blocking", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["blocking"] = ba
|
|
n, st := makeNode(t, router)
|
|
|
|
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "run-bg",
|
|
Adapter: "blocking",
|
|
Target: "v1",
|
|
Background: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnRunRequest: %v", err)
|
|
}
|
|
|
|
// OnRunRequest should return before adapter finishes.
|
|
select {
|
|
case <-ba.started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("adapter never started")
|
|
}
|
|
|
|
// Cancel via OnCancel so the blocking adapter finishes.
|
|
if err := n.OnCancel(context.Background(), &transport.Session{}, &iop.CancelRequest{RunId: "run-bg"}); err != nil {
|
|
t.Fatalf("OnCancel: %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-ba.done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("adapter did not finish after cancel")
|
|
}
|
|
|
|
// Wait briefly for completeRun goroutine to update the store.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
run, err := st.GetRun(context.Background(), "run-bg")
|
|
if err != nil {
|
|
t.Fatalf("get run: %v", err)
|
|
}
|
|
if run != nil && run.Status == "cancelled" {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
run, _ := st.GetRun(context.Background(), "run-bg")
|
|
t.Fatalf("expected cancelled status, got %q", run.Status)
|
|
}
|
|
|
|
func TestOnCancel_CancelsRunViaRunManager(t *testing.T) {
|
|
ba := newBlockingAdapter()
|
|
router := &fixedRouter{adapterName: "blocking", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["blocking"] = ba
|
|
n, _ := makeNode(t, router)
|
|
|
|
go func() {
|
|
_ = n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "run-cancel",
|
|
Adapter: "blocking",
|
|
Target: "v1",
|
|
})
|
|
}()
|
|
|
|
<-ba.started
|
|
|
|
if err := n.OnCancel(context.Background(), &transport.Session{}, &iop.CancelRequest{RunId: "run-cancel"}); err != nil {
|
|
t.Fatalf("OnCancel: %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-ba.done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("adapter did not finish after cancel")
|
|
}
|
|
}
|
|
|
|
func TestOnCancel_TerminatesAdapterSession(t *testing.T) {
|
|
ta := &terminatingAdapter{}
|
|
router := &fixedRouter{adapterName: "terminating", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["terminating"] = ta
|
|
n, _ := makeNode(t, router)
|
|
|
|
err := n.OnCancel(context.Background(), &transport.Session{}, &iop.CancelRequest{
|
|
RunId: "",
|
|
Adapter: "terminating",
|
|
Target: "codex",
|
|
SessionId: "session-a",
|
|
Action: iop.CancelAction_CANCEL_ACTION_TERMINATE_SESSION,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCancel terminate: %v", err)
|
|
}
|
|
if atomic.LoadInt32(&ta.terminateCalls) != 1 {
|
|
t.Fatal("expected TerminateSession to be called once")
|
|
}
|
|
if ta.lastTarget != "codex" || ta.lastSessionID != "session-a" {
|
|
t.Fatalf("unexpected terminate args: target=%q session=%q", ta.lastTarget, ta.lastSessionID)
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_Success(t *testing.T) {
|
|
ca := &commandAdapter{}
|
|
router := &fixedRouter{adapterName: "command", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["command"] = ca
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-1",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS,
|
|
Adapter: "command",
|
|
Target: "codex",
|
|
SessionId: "default",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("expected no error, got %q", resp.GetError())
|
|
}
|
|
if resp.GetUsageStatus().GetRawOutput() != "success" {
|
|
t.Fatalf("expected success raw output, got %q", resp.GetUsageStatus().GetRawOutput())
|
|
}
|
|
if ca.lastReq.Type != runtime.CommandTypeUsageStatus || ca.lastReq.Target != "codex" {
|
|
t.Fatalf("unexpected last req: %+v", ca.lastReq)
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_MissingAdapter(t *testing.T) {
|
|
router := &fixedRouter{adapterName: "missing", adapters: make(map[string]runtime.Adapter)}
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-1",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS,
|
|
Adapter: "missing",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if !strings.Contains(resp.GetError(), "not found") {
|
|
t.Fatalf("expected not found error, got %q", resp.GetError())
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_NotSupported(t *testing.T) {
|
|
ta := &terminatingAdapter{}
|
|
router := &fixedRouter{adapterName: "terminating", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["terminating"] = ta
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-1",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS,
|
|
Adapter: "terminating",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if !strings.Contains(resp.GetError(), "does not support commands") {
|
|
t.Fatalf("expected not supported error, got %q", resp.GetError())
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_UnspecifiedRejected(t *testing.T) {
|
|
ca := &commandAdapter{}
|
|
router := &fixedRouter{adapterName: "command", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["command"] = ca
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-1",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_UNSPECIFIED,
|
|
Adapter: "command",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if !strings.Contains(resp.GetError(), "unsupported command type") {
|
|
t.Fatalf("expected unsupported command type error, got %q", resp.GetError())
|
|
}
|
|
// Adapter must not be invoked for unsupported command types.
|
|
if ca.lastReq.RequestID != "" {
|
|
t.Fatalf("expected adapter to be skipped, got lastReq %+v", ca.lastReq)
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_Capabilities(t *testing.T) {
|
|
ca := &commandAdapter{}
|
|
router := &fixedRouter{adapterName: "command", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["command"] = ca
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-cap",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
|
Adapter: "command",
|
|
Target: "codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("expected no error, got %q", resp.GetError())
|
|
}
|
|
if resp.GetType() != iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES {
|
|
t.Fatalf("type: got %v want CAPABILITIES", resp.GetType())
|
|
}
|
|
if got := resp.GetResult()["targets"]; got != "v1,v2" {
|
|
t.Fatalf("result[targets]: got %q want %q", got, "v1,v2")
|
|
}
|
|
if got := resp.GetResult()["adapter"]; got != "command" {
|
|
t.Fatalf("result[adapter]: got %q want %q", got, "command")
|
|
}
|
|
if got := resp.GetResult()["max_concurrency"]; got != "3" {
|
|
t.Fatalf("result[max_concurrency]: got %q want %q", got, "3")
|
|
}
|
|
if got := resp.GetResult()["provider_status"]; got != "unknown" {
|
|
t.Fatalf("result[provider_status]: got %q want %q", got, "unknown")
|
|
}
|
|
if got := resp.GetResult()["capacity"]; got != "3" {
|
|
t.Fatalf("result[capacity]: got %q want %q", got, "3")
|
|
}
|
|
if got := resp.GetResult()["in_flight"]; got != "0" {
|
|
t.Fatalf("result[in_flight]: got %q want %q", got, "0")
|
|
}
|
|
if got := resp.GetResult()["queued"]; got != "0" {
|
|
t.Fatalf("result[queued]: got %q want %q", got, "0")
|
|
}
|
|
if len(resp.GetProviderSnapshots()) != 1 {
|
|
t.Fatalf("expected 1 provider snapshot, got %d", len(resp.GetProviderSnapshots()))
|
|
}
|
|
snap := resp.GetProviderSnapshots()[0]
|
|
if snap.GetAdapter() != "command" {
|
|
t.Fatalf("snap.Adapter: got %q want %q", snap.GetAdapter(), "command")
|
|
}
|
|
if snap.GetStatus() != "unknown" {
|
|
t.Fatalf("snap.Status: got %q want %q", snap.GetStatus(), "unknown")
|
|
}
|
|
if snap.GetCapacity() != 3 {
|
|
t.Fatalf("snap.Capacity: got %d want %d", snap.GetCapacity(), 3)
|
|
}
|
|
if snap.GetInFlight() != 0 {
|
|
t.Fatalf("snap.InFlight: got %d want %d", snap.GetInFlight(), 0)
|
|
}
|
|
if snap.GetQueued() != 0 {
|
|
t.Fatalf("snap.Queued: got %d want %d", snap.GetQueued(), 0)
|
|
}
|
|
// Capabilities must not go through the adapter CommandHandler.
|
|
if ca.lastReq.RequestID != "" {
|
|
t.Fatalf("expected CommandHandler to be skipped, got %+v", ca.lastReq)
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_Capabilities_InFlight(t *testing.T) {
|
|
ba := newBlockingAdapter()
|
|
router := &fixedRouter{adapterName: "blocking", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["blocking"] = ba
|
|
n, _ := makeNode(t, router)
|
|
|
|
// Start a background run which will block.
|
|
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "run-in-flight",
|
|
Adapter: "blocking",
|
|
Target: "v1",
|
|
Background: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnRunRequest: %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-ba.started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("adapter never started")
|
|
}
|
|
|
|
// Now query Capabilities while the run is active.
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-cap",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
|
Adapter: "blocking",
|
|
Target: "v1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("expected no error, got %q", resp.GetError())
|
|
}
|
|
|
|
// Verify capacity, in_flight, queued values.
|
|
if got := resp.GetResult()["capacity"]; got != "0" {
|
|
t.Fatalf("result[capacity]: got %q want %q", got, "0")
|
|
}
|
|
if got := resp.GetResult()["in_flight"]; got != "1" {
|
|
t.Fatalf("result[in_flight]: got %q want %q", got, "1")
|
|
}
|
|
if got := resp.GetResult()["queued"]; got != "0" {
|
|
t.Fatalf("result[queued]: got %q want %q", got, "0")
|
|
}
|
|
|
|
if len(resp.GetProviderSnapshots()) != 1 {
|
|
t.Fatalf("expected 1 provider snapshot, got %d", len(resp.GetProviderSnapshots()))
|
|
}
|
|
snap := resp.GetProviderSnapshots()[0]
|
|
if snap.GetAdapter() != "blocking" {
|
|
t.Fatalf("snap.Adapter: got %q want %q", snap.GetAdapter(), "blocking")
|
|
}
|
|
if snap.GetStatus() != "unknown" {
|
|
t.Fatalf("snap.Status: got %q want %q", snap.GetStatus(), "unknown")
|
|
}
|
|
if snap.GetCapacity() != 0 {
|
|
t.Fatalf("snap.Capacity: got %d want %d", snap.GetCapacity(), 0)
|
|
}
|
|
if snap.GetInFlight() != 1 {
|
|
t.Fatalf("snap.InFlight: got %d want %d", snap.GetInFlight(), 1)
|
|
}
|
|
if snap.GetQueued() != 0 {
|
|
t.Fatalf("snap.Queued: got %d want %d", snap.GetQueued(), 0)
|
|
}
|
|
|
|
// Clean up.
|
|
if err := n.OnCancel(context.Background(), &transport.Session{}, &iop.CancelRequest{RunId: "run-in-flight"}); err != nil {
|
|
t.Fatalf("OnCancel: %v", err)
|
|
}
|
|
<-ba.done
|
|
}
|
|
|
|
func TestOnCommandRequest_Capabilities_Queued(t *testing.T) {
|
|
sa := newQueuedSlowAdapter("slow", 1, 4, 0)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
n, st := makeNodeWithConcurrency(t, router, 0)
|
|
|
|
if err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "cap-hold", Adapter: "slow", Target: "v1", Background: true,
|
|
}); err != nil {
|
|
t.Fatalf("holding run: %v", err)
|
|
}
|
|
waitStarted(t, sa, "cap-hold")
|
|
|
|
if err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "cap-queued", Adapter: "slow", Target: "v1", Background: true,
|
|
}); err != nil {
|
|
t.Fatalf("queued run: %v", err)
|
|
}
|
|
requireQueued(t, st, "cap-queued")
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-cap-queued",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
|
Adapter: "slow",
|
|
Target: "v1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("expected no error, got %q", resp.GetError())
|
|
}
|
|
if got := resp.GetResult()["capacity"]; got != "1" {
|
|
t.Fatalf("result[capacity]: got %q want %q", got, "1")
|
|
}
|
|
if got := resp.GetResult()["in_flight"]; got != "1" {
|
|
t.Fatalf("result[in_flight]: got %q want %q", got, "1")
|
|
}
|
|
if got := resp.GetResult()["queued"]; got != "1" {
|
|
t.Fatalf("result[queued]: got %q want %q", got, "1")
|
|
}
|
|
if len(resp.GetProviderSnapshots()) != 1 {
|
|
t.Fatalf("expected 1 provider snapshot, got %d", len(resp.GetProviderSnapshots()))
|
|
}
|
|
snap := resp.GetProviderSnapshots()[0]
|
|
if snap.GetCapacity() != 1 {
|
|
t.Fatalf("snap.Capacity: got %d want %d", snap.GetCapacity(), 1)
|
|
}
|
|
if snap.GetInFlight() != 1 {
|
|
t.Fatalf("snap.InFlight: got %d want %d", snap.GetInFlight(), 1)
|
|
}
|
|
if snap.GetQueued() != 1 {
|
|
t.Fatalf("snap.Queued: got %d want %d", snap.GetQueued(), 1)
|
|
}
|
|
|
|
sa.releaseRun("cap-hold")
|
|
waitStarted(t, sa, "cap-queued")
|
|
sa.releaseRun("cap-queued")
|
|
requireStatusEventually(t, st, "cap-queued", "completed")
|
|
}
|
|
|
|
func TestOnCommandRequest_CapabilitiesProviderStatusModel(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
status runtime.ProviderStatus
|
|
want string
|
|
}{
|
|
{name: "available", status: runtime.ProviderStatusAvailable, want: "available"},
|
|
{name: "unavailable", status: runtime.ProviderStatusUnavailable, want: "unavailable"},
|
|
{name: "invalid status folds to unknown", status: runtime.ProviderStatus("degraded"), want: "unknown"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ca := &commandAdapter{providerStatus: tc.status}
|
|
router := &fixedRouter{adapterName: "command", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["command"] = ca
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-cap-status",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
|
Adapter: "command",
|
|
Target: "codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("expected no error, got %q", resp.GetError())
|
|
}
|
|
if got := resp.GetResult()["provider_status"]; got != tc.want {
|
|
t.Fatalf("result[provider_status]: got %q want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_CapabilitiesWithProber(t *testing.T) {
|
|
t.Run("probe_target_hit", func(t *testing.T) {
|
|
pa := &proberTestAdapter{
|
|
providerStatus: runtime.ProviderStatusAvailable,
|
|
probeTargets: []string{"model-a", "model-b"},
|
|
}
|
|
router := &fixedRouter{adapterName: "prober", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["prober"] = pa
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-cap-prober",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
|
Adapter: "prober",
|
|
Target: "model-a",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("expected no error, got %q", resp.GetError())
|
|
}
|
|
if got := resp.GetResult()["provider_status"]; got != "available" {
|
|
t.Fatalf("result[provider_status]: got %q want %q", got, "available")
|
|
}
|
|
if got := resp.GetResult()["targets"]; got != "model-a,model-b" {
|
|
t.Fatalf("result[targets]: got %q want %q", got, "model-a,model-b")
|
|
}
|
|
})
|
|
|
|
t.Run("probe_target_miss", func(t *testing.T) {
|
|
pa := &proberTestAdapter{
|
|
providerStatus: runtime.ProviderStatusAvailable,
|
|
probeTargets: []string{"model-a"},
|
|
}
|
|
router := &fixedRouter{adapterName: "prober", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["prober"] = pa
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-cap-prober",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
|
Adapter: "prober",
|
|
Target: "model-c",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("expected no error, got %q", resp.GetError())
|
|
}
|
|
if got := resp.GetResult()["provider_status"]; got != "unavailable" {
|
|
t.Fatalf("result[provider_status]: got %q want %q", got, "unavailable")
|
|
}
|
|
})
|
|
|
|
t.Run("probe_error_folds_to_unavailable", func(t *testing.T) {
|
|
pa := &proberTestAdapter{
|
|
providerStatus: runtime.ProviderStatusAvailable,
|
|
probeErr: fmt.Errorf("network error"),
|
|
}
|
|
router := &fixedRouter{adapterName: "prober", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["prober"] = pa
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-cap-prober",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
|
Adapter: "prober",
|
|
Target: "model-a",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
// Probe error should NOT cause a command error, but set provider_status to unavailable
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("expected no error, got %q", resp.GetError())
|
|
}
|
|
if got := resp.GetResult()["provider_status"]; got != "unavailable" {
|
|
t.Fatalf("result[provider_status]: got %q want %q", got, "unavailable")
|
|
}
|
|
if got := resp.GetResult()["provider_detail"]; got != "network error" {
|
|
t.Fatalf("result[provider_detail]: got %q want %q", got, "network error")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestOnCommandRequest_CapabilitiesWithoutCommandHandler verifies CAPABILITIES
|
|
// succeeds for an adapter that does not implement runtime.CommandHandler.
|
|
func TestOnCommandRequest_CapabilitiesWithoutCommandHandler(t *testing.T) {
|
|
ta := &terminatingAdapter{} // no CommandHandler
|
|
router := &fixedRouter{adapterName: "terminating", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["terminating"] = ta
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-cap",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
|
Adapter: "terminating",
|
|
Target: "codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("expected no error, got %q", resp.GetError())
|
|
}
|
|
if got := resp.GetResult()["adapter"]; got != "terminating" {
|
|
t.Fatalf("result[adapter]: got %q want %q", got, "terminating")
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_TransportStatus(t *testing.T) {
|
|
ta := &terminatingAdapter{} // adapter not required for transport_status
|
|
router := &fixedRouter{adapterName: "terminating", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["terminating"] = ta
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-tx",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS,
|
|
Adapter: "terminating",
|
|
Target: "codex",
|
|
SessionId: "sess-a",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("expected no error, got %q", resp.GetError())
|
|
}
|
|
if got := resp.GetResult()["node_id"]; got != "test-node" {
|
|
t.Fatalf("result[node_id]: got %q want %q", got, "test-node")
|
|
}
|
|
if got := resp.GetResult()["adapter"]; got != "terminating" {
|
|
t.Fatalf("result[adapter]: got %q want %q", got, "terminating")
|
|
}
|
|
if got := resp.GetResult()["session_id"]; got != "sess-a" {
|
|
t.Fatalf("result[session_id]: got %q want %q", got, "sess-a")
|
|
}
|
|
if _, ok := resp.GetResult()["connected"]; !ok {
|
|
t.Fatalf("result missing connected key: %+v", resp.GetResult())
|
|
}
|
|
if got, ok := resp.GetResult()["state"]; !ok || (got != "connected" && got != "disconnected") {
|
|
t.Fatalf("result[state]: got %q, want \"connected\" or \"disconnected\"", got)
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_AdapterError(t *testing.T) {
|
|
ca := &commandAdapter{}
|
|
router := &fixedRouter{adapterName: "command", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["command"] = ca
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-1",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_SESSION_LIST,
|
|
Adapter: "command",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if !strings.Contains(resp.GetError(), "command not supported by adapter") {
|
|
t.Fatalf("expected adapter unsupported error, got %q", resp.GetError())
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_TransportStatusDefaultsSessionID(t *testing.T) {
|
|
ta := &terminatingAdapter{}
|
|
router := &fixedRouter{adapterName: "terminating", adapters: make(map[string]runtime.Adapter)}
|
|
router.adapters["terminating"] = ta
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-ts-default",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS,
|
|
Adapter: "terminating",
|
|
Target: "codex",
|
|
SessionId: "",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("expected no error, got %q", resp.GetError())
|
|
}
|
|
if got := resp.GetSessionId(); got != "default" {
|
|
t.Fatalf("response SessionId: got %q want %q", got, "default")
|
|
}
|
|
if got := resp.GetResult()["session_id"]; got != "default" {
|
|
t.Fatalf("result[session_id]: got %q want %q", got, "default")
|
|
}
|
|
if got, ok := resp.GetResult()["state"]; !ok || (got != "connected" && got != "disconnected") {
|
|
t.Fatalf("result[state]: got %q, want \"connected\" or \"disconnected\"", got)
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_ErrorResponsesPreserveEnvelope(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
req *iop.NodeCommandRequest
|
|
adapter runtime.Adapter
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing adapter",
|
|
req: &iop.NodeCommandRequest{
|
|
RequestId: "req-missing",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS,
|
|
Adapter: "missing",
|
|
Target: "tgt",
|
|
SessionId: "sess-x",
|
|
},
|
|
adapter: nil,
|
|
wantErr: "not found",
|
|
},
|
|
{
|
|
name: "unsupported command type",
|
|
req: &iop.NodeCommandRequest{
|
|
RequestId: "req-unspec",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_UNSPECIFIED,
|
|
Adapter: "terminating",
|
|
Target: "tgt",
|
|
SessionId: "sess-y",
|
|
},
|
|
adapter: &terminatingAdapter{},
|
|
wantErr: "unsupported command type",
|
|
},
|
|
{
|
|
name: "adapter does not support commands",
|
|
req: &iop.NodeCommandRequest{
|
|
RequestId: "req-nosupport",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS,
|
|
Adapter: "terminating",
|
|
Target: "tgt",
|
|
SessionId: "sess-z",
|
|
},
|
|
adapter: &terminatingAdapter{},
|
|
wantErr: "does not support commands",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
adapters := make(map[string]runtime.Adapter)
|
|
if tc.adapter != nil {
|
|
adapters[tc.req.Adapter] = tc.adapter
|
|
}
|
|
router := &fixedRouter{adapterName: tc.req.Adapter, adapters: adapters}
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, tc.req)
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if !strings.Contains(resp.GetError(), tc.wantErr) {
|
|
t.Fatalf("error: got %q want contains %q", resp.GetError(), tc.wantErr)
|
|
}
|
|
if got := resp.GetRequestId(); got != tc.req.RequestId {
|
|
t.Fatalf("RequestId: got %q want %q", got, tc.req.RequestId)
|
|
}
|
|
if got := resp.GetType(); got != tc.req.Type {
|
|
t.Fatalf("Type: got %v want %v", got, tc.req.Type)
|
|
}
|
|
if got := resp.GetAdapter(); got != tc.req.Adapter {
|
|
t.Fatalf("Adapter: got %q want %q", got, tc.req.Adapter)
|
|
}
|
|
if got := resp.GetTarget(); got != tc.req.Target {
|
|
t.Fatalf("Target: got %q want %q", got, tc.req.Target)
|
|
}
|
|
if got := resp.GetSessionId(); got != tc.req.SessionId {
|
|
t.Fatalf("SessionId: got %q want %q", got, tc.req.SessionId)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- LookupAdapter / ambiguous error propagation tests ---
|
|
|
|
func TestOnCancel_TerminateSession_AmbiguousAdapterError(t *testing.T) {
|
|
ambigErr := fmt.Errorf("adapter \"ollama\" is ambiguous: matches instance keys [ollama@local ollama@dgx]; use an instance key")
|
|
router := &fixedRouter{
|
|
adapterName: "ollama",
|
|
adapters: make(map[string]runtime.Adapter),
|
|
lookupErrors: map[string]error{"ollama": ambigErr},
|
|
}
|
|
n, _ := makeNode(t, router)
|
|
|
|
err := n.OnCancel(context.Background(), &transport.Session{}, &iop.CancelRequest{
|
|
Adapter: "ollama",
|
|
Target: "llama3",
|
|
SessionId: "sess-1",
|
|
Action: iop.CancelAction_CANCEL_ACTION_TERMINATE_SESSION,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for ambiguous adapter lookup")
|
|
}
|
|
if !strings.Contains(err.Error(), "ambiguous") {
|
|
t.Errorf("expected 'ambiguous' in error, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "instance key") {
|
|
t.Errorf("expected 'instance key' guidance in error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOnCancel_TerminateSession_ExactInstanceKey(t *testing.T) {
|
|
ta := &terminatingAdapter{}
|
|
router := &fixedRouter{
|
|
adapterName: "ollama@local",
|
|
adapters: map[string]runtime.Adapter{"ollama@local": ta},
|
|
}
|
|
n, _ := makeNode(t, router)
|
|
|
|
err := n.OnCancel(context.Background(), &transport.Session{}, &iop.CancelRequest{
|
|
Adapter: "ollama@local",
|
|
Target: "llama3",
|
|
SessionId: "sess-a",
|
|
Action: iop.CancelAction_CANCEL_ACTION_TERMINATE_SESSION,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCancel with exact instance key: %v", err)
|
|
}
|
|
if atomic.LoadInt32(&ta.terminateCalls) != 1 {
|
|
t.Fatal("expected TerminateSession to be called once")
|
|
}
|
|
if ta.lastTarget != "llama3" || ta.lastSessionID != "sess-a" {
|
|
t.Errorf("terminate args: target=%q session=%q", ta.lastTarget, ta.lastSessionID)
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_Capabilities_AmbiguousAdapter(t *testing.T) {
|
|
ambigErr := fmt.Errorf("adapter \"cli\" is ambiguous: matches instance keys [cli@claude cli@codex]; use an instance key")
|
|
router := &fixedRouter{
|
|
adapterName: "cli",
|
|
adapters: make(map[string]runtime.Adapter),
|
|
lookupErrors: map[string]error{"cli": ambigErr},
|
|
}
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-ambig-cap",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
|
Adapter: "cli",
|
|
Target: "codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if !strings.Contains(resp.GetError(), "ambiguous") {
|
|
t.Errorf("expected 'ambiguous' in response error, got %q", resp.GetError())
|
|
}
|
|
if !strings.Contains(resp.GetError(), "instance key") {
|
|
t.Errorf("expected 'instance key' guidance in response error, got %q", resp.GetError())
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_AdapterDispatch_AmbiguousAdapter(t *testing.T) {
|
|
ambigErr := fmt.Errorf("adapter \"cli\" is ambiguous: matches instance keys [cli@claude cli@codex]; use an instance key")
|
|
router := &fixedRouter{
|
|
adapterName: "cli",
|
|
adapters: make(map[string]runtime.Adapter),
|
|
lookupErrors: map[string]error{"cli": ambigErr},
|
|
}
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-ambig-cmd",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS,
|
|
Adapter: "cli",
|
|
Target: "codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if !strings.Contains(resp.GetError(), "ambiguous") {
|
|
t.Errorf("expected 'ambiguous' in response error, got %q", resp.GetError())
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_MultiAdapterCapabilities(t *testing.T) {
|
|
ika1 := &instanceKeyAdapter{instanceKey: "ollama@local"}
|
|
ika2 := &instanceKeyAdapter{instanceKey: "ollama@dgx"}
|
|
router := &fixedRouter{
|
|
adapters: map[string]runtime.Adapter{
|
|
"ollama@local": ika1,
|
|
"ollama@dgx": ika2,
|
|
},
|
|
}
|
|
n, _ := makeNode(t, router)
|
|
|
|
cases := []struct {
|
|
adapter string
|
|
wantInstKey string
|
|
}{
|
|
{"ollama@local", "ollama@local"},
|
|
{"ollama@dgx", "ollama@dgx"},
|
|
}
|
|
instKeys := make([]string, 0, len(cases))
|
|
for _, tc := range cases {
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-multi-" + tc.adapter,
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
|
Adapter: tc.adapter,
|
|
Target: "llama3",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CAPABILITIES %s: %v", tc.adapter, err)
|
|
}
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("CAPABILITIES %s: unexpected error %q", tc.adapter, resp.GetError())
|
|
}
|
|
if got := resp.GetResult()["instance_key"]; got != tc.wantInstKey {
|
|
t.Errorf("CAPABILITIES %s: instance_key got %q want %q", tc.adapter, got, tc.wantInstKey)
|
|
}
|
|
if resp.GetAdapter() != tc.adapter {
|
|
t.Errorf("CAPABILITIES %s: response adapter got %q want %q", tc.adapter, resp.GetAdapter(), tc.adapter)
|
|
}
|
|
instKeys = append(instKeys, resp.GetResult()["instance_key"])
|
|
}
|
|
if instKeys[0] == instKeys[1] {
|
|
t.Errorf("expected distinct instance_key for distinct adapters, both got %q", instKeys[0])
|
|
}
|
|
}
|
|
|
|
func TestOnCommandRequest_Capabilities_ExactInstanceKey(t *testing.T) {
|
|
ika := &instanceKeyAdapter{instanceKey: "ollama@local"}
|
|
router := &fixedRouter{
|
|
adapterName: "ollama@local",
|
|
adapters: map[string]runtime.Adapter{"ollama@local": ika},
|
|
}
|
|
n, _ := makeNode(t, router)
|
|
|
|
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
|
RequestId: "req-exact-cap",
|
|
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
|
Adapter: "ollama@local",
|
|
Target: "llama3",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OnCommandRequest: %v", err)
|
|
}
|
|
if resp.GetError() != "" {
|
|
t.Fatalf("expected no error for exact instance key, got %q", resp.GetError())
|
|
}
|
|
if got := resp.GetResult()["adapter"]; got != "ollama" {
|
|
t.Errorf("result[adapter]: got %q want ollama", got)
|
|
}
|
|
if got := resp.GetResult()["instance_key"]; got != "ollama@local" {
|
|
t.Errorf("result[instance_key]: got %q want ollama@local", got)
|
|
}
|
|
}
|
|
|
|
// --- concurrency gate tests ---
|
|
|
|
// slowAdapterUnlimited reports MaxConcurrency=0 (unlimited) and blocks until
|
|
// released, exposing a start channel so tests can synchronize.
|
|
type slowAdapterUnlimited struct {
|
|
started chan struct{}
|
|
release chan struct{}
|
|
}
|
|
|
|
func newSlowAdapterUnlimited() *slowAdapterUnlimited {
|
|
return &slowAdapterUnlimited{started: make(chan struct{}, 1), release: make(chan struct{})}
|
|
}
|
|
|
|
func (a *slowAdapterUnlimited) Name() string { return "slow" }
|
|
func (a *slowAdapterUnlimited) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|
return runtime.Capabilities{AdapterName: "slow", MaxConcurrency: 0}, nil
|
|
}
|
|
func (a *slowAdapterUnlimited) Execute(ctx context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
|
|
select {
|
|
case a.started <- struct{}{}:
|
|
default:
|
|
}
|
|
select {
|
|
case <-a.release:
|
|
case <-ctx.Done():
|
|
return runtime.ErrRunCancelled
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// queuedSlowAdapter is a configurable adapter used by the FIFO admission tests.
|
|
// It advertises MaxConcurrency / MaxQueue / QueueTimeout via Capabilities, and
|
|
// gives per-run control: each Execute blocks until the test releases (or fails)
|
|
// that specific run id, so tests can observe queue ordering deterministically.
|
|
type queuedSlowAdapter struct {
|
|
name string
|
|
maxConcurrency int
|
|
maxQueue int
|
|
queueTimeout time.Duration
|
|
|
|
mu sync.Mutex
|
|
gates map[string]chan error // run_id → release signal carrying exec result
|
|
startSeq chan string // run_id pushed when Execute begins
|
|
}
|
|
|
|
func newQueuedSlowAdapter(name string, maxConcurrency, maxQueue int, queueTimeout time.Duration) *queuedSlowAdapter {
|
|
return &queuedSlowAdapter{
|
|
name: name,
|
|
maxConcurrency: maxConcurrency,
|
|
maxQueue: maxQueue,
|
|
queueTimeout: queueTimeout,
|
|
gates: make(map[string]chan error),
|
|
startSeq: make(chan string, 64),
|
|
}
|
|
}
|
|
|
|
func (a *queuedSlowAdapter) Name() string { return a.name }
|
|
|
|
func (a *queuedSlowAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|
return runtime.Capabilities{
|
|
AdapterName: a.name,
|
|
MaxConcurrency: a.maxConcurrency,
|
|
MaxQueue: a.maxQueue,
|
|
QueueTimeoutMS: int(a.queueTimeout / time.Millisecond),
|
|
}, nil
|
|
}
|
|
|
|
func (a *queuedSlowAdapter) gateFor(runID string) chan error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
ch, ok := a.gates[runID]
|
|
if !ok {
|
|
ch = make(chan error, 1)
|
|
a.gates[runID] = ch
|
|
}
|
|
return ch
|
|
}
|
|
|
|
func (a *queuedSlowAdapter) Execute(ctx context.Context, spec runtime.ExecutionSpec, _ runtime.EventSink) error {
|
|
gate := a.gateFor(spec.RunID)
|
|
a.startSeq <- spec.RunID
|
|
select {
|
|
case res := <-gate:
|
|
return res
|
|
case <-ctx.Done():
|
|
return runtime.ErrRunCancelled
|
|
}
|
|
}
|
|
|
|
// releaseRun lets the given run complete successfully.
|
|
func (a *queuedSlowAdapter) releaseRun(runID string) { a.gateFor(runID) <- nil }
|
|
|
|
// failRun lets the given run terminate with a non-cancel error.
|
|
func (a *queuedSlowAdapter) failRun(runID string) { a.gateFor(runID) <- errBoom }
|
|
|
|
// preRelease pre-arms a run so it completes the instant it starts executing.
|
|
func (a *queuedSlowAdapter) preRelease(runID string) { a.gateFor(runID) <- nil }
|
|
|
|
var errBoom = errors.New("boom")
|
|
|
|
// waitStarted blocks until the adapter reports the given run id started, or
|
|
// fails the test after a timeout.
|
|
func waitStarted(t *testing.T, a *queuedSlowAdapter, runID string) {
|
|
t.Helper()
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
select {
|
|
case got := <-a.startSeq:
|
|
if got == runID {
|
|
return
|
|
}
|
|
// Not the one we want; requeue for other waiters.
|
|
a.startSeq <- got
|
|
time.Sleep(2 * time.Millisecond)
|
|
case <-deadline:
|
|
t.Fatalf("run %s never started", runID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// waitAnyStarted returns the next run id that began executing.
|
|
func waitAnyStarted(t *testing.T, a *queuedSlowAdapter) string {
|
|
t.Helper()
|
|
select {
|
|
case got := <-a.startSeq:
|
|
return got
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("no run started within timeout")
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// requireQueued asserts the run is stored with status "queued" (polling briefly
|
|
// for the asynchronous store write).
|
|
func requireQueued(t *testing.T, st *store.Store, runID string) {
|
|
t.Helper()
|
|
requireStatusEventually(t, st, runID, "queued")
|
|
}
|
|
|
|
// requireStatus asserts the run is stored with exactly the given status now.
|
|
func requireStatus(t *testing.T, st *store.Store, runID, status string) *store.RunRecord {
|
|
t.Helper()
|
|
return requireStatusEventually(t, st, runID, status)
|
|
}
|
|
|
|
// requireStatusEventually polls until the run reaches the expected status.
|
|
func requireStatusEventually(t *testing.T, st *store.Store, runID, status string) *store.RunRecord {
|
|
t.Helper()
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
run, err := st.GetRun(context.Background(), runID)
|
|
if err != nil {
|
|
t.Fatalf("GetRun(%s): %v", runID, err)
|
|
}
|
|
if run != nil && run.Status == status {
|
|
return run
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
got := "<nil>"
|
|
if run != nil {
|
|
got = run.Status
|
|
}
|
|
t.Fatalf("run %s: expected status %q, got %q", runID, status, got)
|
|
case <-time.After(5 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAdmissionQueue_ForegroundSecondWaitsThenRuns verifies that a second
|
|
// foreground run is not rejected: it waits in the FIFO queue and executes once
|
|
// the first run releases its slot.
|
|
func TestAdmissionQueue_ForegroundSecondWaitsThenRuns(t *testing.T) {
|
|
sa := newQueuedSlowAdapter("slow", 1, 4, 0)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
n, st := makeNodeWithConcurrency(t, router, 1)
|
|
|
|
first := make(chan error, 1)
|
|
go func() {
|
|
first <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "run-fg-1", Adapter: "slow", Target: "v1",
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "run-fg-1")
|
|
|
|
second := make(chan error, 1)
|
|
go func() {
|
|
second <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "run-fg-2", Adapter: "slow", Target: "v1",
|
|
})
|
|
}()
|
|
|
|
// The second run must remain queued (not started, not rejected) while the
|
|
// first holds the only slot.
|
|
requireQueued(t, st, "run-fg-2")
|
|
select {
|
|
case err := <-second:
|
|
t.Fatalf("second run should be queued, but returned early: %v", err)
|
|
case <-time.After(150 * time.Millisecond):
|
|
}
|
|
|
|
// Release the first; the second must then start and complete.
|
|
sa.releaseRun("run-fg-1")
|
|
if err := <-first; err != nil {
|
|
t.Fatalf("first run: %v", err)
|
|
}
|
|
waitStarted(t, sa, "run-fg-2")
|
|
sa.releaseRun("run-fg-2")
|
|
if err := <-second; err != nil {
|
|
t.Fatalf("second run should succeed after promotion, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestAdmissionQueue_BackgroundQueuedReturnsThenRuns verifies that a background
|
|
// run that cannot run immediately returns after enqueue and is later promoted.
|
|
func TestAdmissionQueue_BackgroundQueuedReturnsThenRuns(t *testing.T) {
|
|
sa := newQueuedSlowAdapter("slow", 1, 4, 0)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
n, st := makeNodeWithConcurrency(t, router, 1)
|
|
|
|
first := make(chan error, 1)
|
|
go func() {
|
|
first <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "run-bg-hold", Adapter: "slow", Target: "v1",
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "run-bg-hold")
|
|
|
|
// Background queued run returns promptly after enqueue.
|
|
if err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "run-bg-2", Adapter: "slow", Target: "v1", Background: true,
|
|
}); err != nil {
|
|
t.Fatalf("background queued run should enqueue without error, got %v", err)
|
|
}
|
|
requireQueued(t, st, "run-bg-2")
|
|
|
|
// Release the holder; the queued background run must be promoted and run.
|
|
sa.releaseRun("run-bg-hold")
|
|
if err := <-first; err != nil {
|
|
t.Fatalf("hold run: %v", err)
|
|
}
|
|
waitStarted(t, sa, "run-bg-2")
|
|
sa.releaseRun("run-bg-2")
|
|
requireStatusEventually(t, st, "run-bg-2", "completed")
|
|
}
|
|
|
|
// TestAdmissionQueue_FIFOOrdering verifies that queued runs are promoted in
|
|
// first-in-first-out order.
|
|
func TestAdmissionQueue_FIFOOrdering(t *testing.T) {
|
|
sa := newQueuedSlowAdapter("slow", 1, 8, 0)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
n, _ := makeNodeWithConcurrency(t, router, 1)
|
|
|
|
// First run holds the single slot.
|
|
hold := make(chan error, 1)
|
|
go func() {
|
|
hold <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "fifo-0", Adapter: "slow", Target: "v1", Background: true,
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "fifo-0")
|
|
|
|
// Enqueue three background runs in a deterministic order. Each enqueue
|
|
// returns before the next is submitted, so queue order is well-defined.
|
|
order := []string{"fifo-1", "fifo-2", "fifo-3"}
|
|
for _, id := range order {
|
|
if err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: id, Adapter: "slow", Target: "v1", Background: true,
|
|
}); err != nil {
|
|
t.Fatalf("enqueue %s: %v", id, err)
|
|
}
|
|
}
|
|
|
|
// Release runs one at a time and assert they start in FIFO order.
|
|
sa.releaseRun("fifo-0")
|
|
if err := <-hold; err != nil {
|
|
t.Fatalf("fifo-0: %v", err)
|
|
}
|
|
for _, id := range order {
|
|
got := waitAnyStarted(t, sa)
|
|
if got != id {
|
|
t.Fatalf("FIFO order violated: expected %s to start next, got %s", id, got)
|
|
}
|
|
sa.releaseRun(id)
|
|
}
|
|
}
|
|
|
|
// TestAdmissionQueue_MaxQueueOverflow verifies that a run is rejected with
|
|
// reason queue_full when the FIFO queue is already at max_queue.
|
|
func TestAdmissionQueue_MaxQueueOverflow(t *testing.T) {
|
|
// capacity=1, max_queue=1: one running + one queued is the ceiling.
|
|
sa := newQueuedSlowAdapter("slow", 1, 1, 0)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
n, st := makeNodeWithConcurrency(t, router, 0) // global unlimited; adapter cap governs
|
|
|
|
hold := make(chan error, 1)
|
|
go func() {
|
|
hold <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "of-hold", Adapter: "slow", Target: "v1", Background: true,
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "of-hold")
|
|
|
|
// Fill the single queue slot.
|
|
if err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "of-queued", Adapter: "slow", Target: "v1", Background: true,
|
|
}); err != nil {
|
|
t.Fatalf("first queued run should enqueue, got %v", err)
|
|
}
|
|
requireQueued(t, st, "of-queued")
|
|
|
|
// Third run overflows the queue and must be rejected with queue_full.
|
|
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "of-reject", Adapter: "slow", Target: "v1",
|
|
})
|
|
if !errors.Is(err, node.ErrConcurrencyLimitExceeded) {
|
|
t.Fatalf("expected ErrConcurrencyLimitExceeded, got %v", err)
|
|
}
|
|
rec := requireStatus(t, st, "of-reject", "rejected")
|
|
if !strings.Contains(rec.Error, "queue full") {
|
|
t.Fatalf("expected queue_full reason in error, got %q", rec.Error)
|
|
}
|
|
|
|
sa.releaseRun("of-hold")
|
|
if err := <-hold; err != nil {
|
|
t.Fatalf("of-hold: %v", err)
|
|
}
|
|
waitStarted(t, sa, "of-queued")
|
|
sa.releaseRun("of-queued")
|
|
}
|
|
|
|
// TestAdmissionQueue_QueueTimeout verifies that a queued run is rejected with
|
|
// reason queue_timeout when it waits past queue_timeout_ms.
|
|
func TestAdmissionQueue_QueueTimeout(t *testing.T) {
|
|
// capacity=1, max_queue=4, queue_timeout=120ms.
|
|
sa := newQueuedSlowAdapter("slow", 1, 4, 120*time.Millisecond)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
n, st := makeNodeWithConcurrency(t, router, 0)
|
|
|
|
hold := make(chan error, 1)
|
|
go func() {
|
|
hold <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "to-hold", Adapter: "slow", Target: "v1", Background: true,
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "to-hold")
|
|
|
|
// This foreground run will wait in the queue and time out (holder is never
|
|
// released until after the timeout).
|
|
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "to-timeout", Adapter: "slow", Target: "v1",
|
|
})
|
|
if !errors.Is(err, node.ErrConcurrencyLimitExceeded) {
|
|
t.Fatalf("expected ErrConcurrencyLimitExceeded on queue timeout, got %v", err)
|
|
}
|
|
rec := requireStatus(t, st, "to-timeout", "rejected")
|
|
if !strings.Contains(rec.Error, "queue timeout") {
|
|
t.Fatalf("expected queue_timeout reason in error, got %q", rec.Error)
|
|
}
|
|
|
|
sa.releaseRun("to-hold")
|
|
if err := <-hold; err != nil {
|
|
t.Fatalf("to-hold: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestAdmissionQueue_ReleaseAfterFailurePromotesNext verifies that a queued run
|
|
// is promoted when the running run fails.
|
|
func TestAdmissionQueue_ReleaseAfterFailurePromotesNext(t *testing.T) {
|
|
sa := newQueuedSlowAdapter("slow", 1, 4, 0)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
n, st := makeNodeWithConcurrency(t, router, 1)
|
|
|
|
first := make(chan error, 1)
|
|
go func() {
|
|
first <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "fail-1", Adapter: "slow", Target: "v1",
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "fail-1")
|
|
|
|
if err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "fail-next", Adapter: "slow", Target: "v1", Background: true,
|
|
}); err != nil {
|
|
t.Fatalf("enqueue fail-next: %v", err)
|
|
}
|
|
requireQueued(t, st, "fail-next")
|
|
|
|
// First run fails; its slot must promote the queued run.
|
|
sa.failRun("fail-1")
|
|
<-first
|
|
waitStarted(t, sa, "fail-next")
|
|
sa.releaseRun("fail-next")
|
|
requireStatusEventually(t, st, "fail-next", "completed")
|
|
}
|
|
|
|
// TestAdmissionQueue_ReleaseAfterCancelPromotesNext verifies that a queued run
|
|
// is promoted when the running run is cancelled.
|
|
func TestAdmissionQueue_ReleaseAfterCancelPromotesNext(t *testing.T) {
|
|
sa := newQueuedSlowAdapter("slow", 1, 4, 0)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
n, st := makeNodeWithConcurrency(t, router, 1)
|
|
|
|
first := make(chan error, 1)
|
|
go func() {
|
|
first <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "cancel-1", Adapter: "slow", Target: "v1", Background: true,
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "cancel-1")
|
|
|
|
if err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "cancel-next", Adapter: "slow", Target: "v1", Background: true,
|
|
}); err != nil {
|
|
t.Fatalf("enqueue cancel-next: %v", err)
|
|
}
|
|
requireQueued(t, st, "cancel-next")
|
|
<-first
|
|
|
|
// Cancel the running run; its slot must promote the queued run.
|
|
if err := n.OnCancel(context.Background(), &transport.Session{}, &iop.CancelRequest{RunId: "cancel-1"}); err != nil {
|
|
t.Fatalf("cancel: %v", err)
|
|
}
|
|
waitStarted(t, sa, "cancel-next")
|
|
sa.releaseRun("cancel-next")
|
|
requireStatusEventually(t, st, "cancel-next", "completed")
|
|
}
|
|
|
|
// TestAdmissionQueue_PermitReleasedAfterCompletion verifies that after the first
|
|
// run completes, a subsequent run can acquire the slot without queuing.
|
|
func TestAdmissionQueue_PermitReleasedAfterCompletion(t *testing.T) {
|
|
sa := newQueuedSlowAdapter("slow", 1, 4, 0)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
n, _ := makeNodeWithConcurrency(t, router, 1)
|
|
|
|
first := make(chan error, 1)
|
|
go func() {
|
|
first <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "rel-1", Adapter: "slow", Target: "v1",
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "rel-1")
|
|
sa.releaseRun("rel-1")
|
|
if err := <-first; err != nil {
|
|
t.Fatalf("rel-1: %v", err)
|
|
}
|
|
|
|
// Second run must run immediately now that the slot is free.
|
|
sa.preRelease("rel-2")
|
|
if err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "rel-2", Adapter: "slow", Target: "v1",
|
|
}); err != nil {
|
|
t.Fatalf("rel-2 should succeed after slot released, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestConcurrencyLimit_Unlimited verifies that global=0 + adapter cap=0 imposes no cap.
|
|
func TestConcurrencyLimit_Unlimited(t *testing.T) {
|
|
const count = 5
|
|
shared := newSlowAdapterUnlimited() // MaxConcurrency=0 (unlimited)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": shared}}
|
|
nd, _ := makeNodeWithConcurrency(t, router, 0) // global unlimited
|
|
|
|
errs := make(chan error, count)
|
|
for i := 0; i < count; i++ {
|
|
runID := fmt.Sprintf("run-unlimited-%d", i)
|
|
go func(id string) {
|
|
errs <- nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: id, Adapter: "slow", Target: "v1",
|
|
})
|
|
}(runID)
|
|
}
|
|
|
|
// Release all.
|
|
close(shared.release)
|
|
|
|
// All must complete without ErrConcurrencyLimitExceeded.
|
|
for i := 0; i < count; i++ {
|
|
if err := <-errs; errors.Is(err, node.ErrConcurrencyLimitExceeded) {
|
|
t.Fatalf("unexpected concurrency rejection with limit=0: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAdmissionQueue_AdapterCapQueues verifies that when global=0 and adapter
|
|
// cap=1, a second run queues (rather than being rejected) and runs after release.
|
|
func TestAdmissionQueue_AdapterCapQueues(t *testing.T) {
|
|
sa := newQueuedSlowAdapter("slow", 1, 4, 0) // adapter cap=1
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
nd, st := makeNodeWithConcurrency(t, router, 0) // global=0 → adapter cap governs
|
|
|
|
first := make(chan error, 1)
|
|
go func() {
|
|
first <- nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "cap-1", Adapter: "slow", Target: "v1",
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "cap-1")
|
|
|
|
if err := nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "cap-2", Adapter: "slow", Target: "v1", Background: true,
|
|
}); err != nil {
|
|
t.Fatalf("cap-2 should enqueue, got %v", err)
|
|
}
|
|
requireQueued(t, st, "cap-2")
|
|
|
|
sa.releaseRun("cap-1")
|
|
if err := <-first; err != nil {
|
|
t.Fatalf("cap-1: %v", err)
|
|
}
|
|
waitStarted(t, sa, "cap-2")
|
|
sa.releaseRun("cap-2")
|
|
requireStatusEventually(t, st, "cap-2", "completed")
|
|
}
|
|
|
|
// TestAdmissionQueue_GlobalBeatsAdapterCap verifies that the global gate (=1)
|
|
// limits effective in-flight to 1 even when the adapter cap is looser (=4): the
|
|
// second run queues behind the global slot.
|
|
func TestAdmissionQueue_GlobalBeatsAdapterCap(t *testing.T) {
|
|
sa := newQueuedSlowAdapter("slow-hicap", 4, 4, 0) // adapter cap=4
|
|
router := &fixedRouter{adapterName: "slow-hicap", adapters: map[string]runtime.Adapter{"slow-hicap": sa}}
|
|
nd, st := makeNodeWithConcurrency(t, router, 1) // global=1 → effective=1
|
|
|
|
first := make(chan error, 1)
|
|
go func() {
|
|
first <- nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "g1-1", Adapter: "slow-hicap", Target: "v1",
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "g1-1")
|
|
|
|
// Second run queues behind the global gate even though adapter cap=4.
|
|
if err := nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "g1-2", Adapter: "slow-hicap", Target: "v1", Background: true,
|
|
}); err != nil {
|
|
t.Fatalf("g1-2 should enqueue, got %v", err)
|
|
}
|
|
requireQueued(t, st, "g1-2")
|
|
|
|
sa.releaseRun("g1-1")
|
|
if err := <-first; err != nil {
|
|
t.Fatalf("g1-1: %v", err)
|
|
}
|
|
waitStarted(t, sa, "g1-2")
|
|
sa.releaseRun("g1-2")
|
|
requireStatusEventually(t, st, "g1-2", "completed")
|
|
}
|
|
|
|
// TestAdmissionQueue_GlobalBeatsAdapterCap_DifferentAdapters verifies that
|
|
// global=1 makes a run on adapter B queue while adapter A holds the global slot,
|
|
// even though both adapter caps are > 1.
|
|
func TestAdmissionQueue_GlobalBeatsAdapterCap_DifferentAdapters(t *testing.T) {
|
|
saA := newQueuedSlowAdapter("slow-hicap-a", 4, 4, 0)
|
|
saB := newQueuedSlowAdapter("slow-hicap-b", 4, 4, 0)
|
|
|
|
router := &fixedRouter{
|
|
adapterName: "slow-hicap-a",
|
|
adapters: map[string]runtime.Adapter{
|
|
"slow-hicap-a": saA,
|
|
"slow-hicap-b": saB,
|
|
},
|
|
}
|
|
nd, st := makeNodeWithConcurrency(t, router, 1) // global=1
|
|
|
|
first := make(chan error, 1)
|
|
go func() {
|
|
first <- nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "xa-1", Adapter: "slow-hicap-a", Target: "v1",
|
|
})
|
|
}()
|
|
waitStarted(t, saA, "xa-1")
|
|
|
|
// Run on adapter B queues because the global slot is taken by adapter A.
|
|
router.adapterName = "slow-hicap-b"
|
|
if err := nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "xb-1", Adapter: "slow-hicap-b", Target: "v1", Background: true,
|
|
}); err != nil {
|
|
t.Fatalf("xb-1 should enqueue, got %v", err)
|
|
}
|
|
requireQueued(t, st, "xb-1")
|
|
|
|
sa := saA
|
|
sa.releaseRun("xa-1")
|
|
if err := <-first; err != nil {
|
|
t.Fatalf("xa-1: %v", err)
|
|
}
|
|
waitStarted(t, saB, "xb-1")
|
|
saB.releaseRun("xb-1")
|
|
requireStatusEventually(t, st, "xb-1", "completed")
|
|
}
|
|
|
|
// TestAdmissionQueue_GlobalNoCrossAdapterHeadOfLine is the regression test for
|
|
// the gate-composition fix: a queued run on one adapter must not occupy a global
|
|
// slot it cannot yet use and thereby block a runnable queue head on a different
|
|
// adapter.
|
|
//
|
|
// Setup: global=2, adapter A capacity=1, adapter B capacity=1.
|
|
// - A1 and B1 run, consuming both global slots and both adapter slots.
|
|
// - A2 is enqueued first (waiting on adapter A slot, held by A1).
|
|
// - B2 is enqueued next (waiting on adapter B slot, held by B1).
|
|
// - Releasing only B1 must let B2 start immediately, because A2 (still without
|
|
// an adapter A slot) must NOT have grabbed the freed global slot ahead of it.
|
|
func TestAdmissionQueue_GlobalNoCrossAdapterHeadOfLine(t *testing.T) {
|
|
saA := newQueuedSlowAdapter("adapter-a", 1, 4, 0)
|
|
saB := newQueuedSlowAdapter("adapter-b", 1, 4, 0)
|
|
router := &fixedRouter{
|
|
adapterName: "adapter-a",
|
|
adapters: map[string]runtime.Adapter{
|
|
"adapter-a": saA,
|
|
"adapter-b": saB,
|
|
},
|
|
}
|
|
nd, st := makeNodeWithConcurrency(t, router, 2) // global=2
|
|
|
|
// A1 and B1 occupy both global slots and both adapter slots.
|
|
a1 := make(chan error, 1)
|
|
go func() {
|
|
a1 <- nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "a1", Adapter: "adapter-a", Target: "v1", Background: true,
|
|
})
|
|
}()
|
|
waitStarted(t, saA, "a1")
|
|
|
|
router.adapterName = "adapter-b"
|
|
b1 := make(chan error, 1)
|
|
go func() {
|
|
b1 <- nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "b1", Adapter: "adapter-b", Target: "v1", Background: true,
|
|
})
|
|
}()
|
|
waitStarted(t, saB, "b1")
|
|
|
|
// Enqueue A2 first (waits on adapter A slot), then B2 (waits on adapter B slot).
|
|
router.adapterName = "adapter-a"
|
|
if err := nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "a2", Adapter: "adapter-a", Target: "v1", Background: true,
|
|
}); err != nil {
|
|
t.Fatalf("enqueue a2: %v", err)
|
|
}
|
|
requireQueued(t, st, "a2")
|
|
|
|
router.adapterName = "adapter-b"
|
|
if err := nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "b2", Adapter: "adapter-b", Target: "v1", Background: true,
|
|
}); err != nil {
|
|
t.Fatalf("enqueue b2: %v", err)
|
|
}
|
|
requireQueued(t, st, "b2")
|
|
|
|
// Release only B1: its adapter B slot and one global slot free up. B2 must
|
|
// start. A2 stays queued because adapter A slot (a1) is still held.
|
|
<-b1
|
|
saB.releaseRun("b1")
|
|
waitStarted(t, saB, "b2") // fails here if A2 head-of-line-blocked the global slot
|
|
|
|
// A2 must still be queued (it has no adapter A slot yet).
|
|
requireStatus(t, st, "a2", "queued")
|
|
select {
|
|
case <-saA.startSeq:
|
|
t.Fatal("a2 must not start before a1 releases its adapter slot")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
// Now release a1 → A2 can take adapter A slot and the remaining global slot.
|
|
<-a1
|
|
saA.releaseRun("a1")
|
|
waitStarted(t, saA, "a2")
|
|
|
|
saB.releaseRun("b2")
|
|
saA.releaseRun("a2")
|
|
requireStatusEventually(t, st, "a2", "completed")
|
|
requireStatusEventually(t, st, "b2", "completed")
|
|
}
|
|
|
|
// TestAdmissionQueue_QueueTimeoutAppliesAcrossGates verifies that the queue
|
|
// timeout bounds the total admission wait even when the run is blocked waiting
|
|
// for the global slot after acquiring its adapter slot.
|
|
func TestAdmissionQueue_QueueTimeoutAppliesAcrossGates(t *testing.T) {
|
|
// adapter A and B each capacity=1 with a queue timeout; global=1 is the
|
|
// bottleneck so the second adapter's run holds its adapter slot but blocks on
|
|
// the global gate, then must time out.
|
|
saA := newQueuedSlowAdapter("adapter-a", 1, 4, 120*time.Millisecond)
|
|
saB := newQueuedSlowAdapter("adapter-b", 1, 4, 120*time.Millisecond)
|
|
router := &fixedRouter{
|
|
adapterName: "adapter-a",
|
|
adapters: map[string]runtime.Adapter{
|
|
"adapter-a": saA,
|
|
"adapter-b": saB,
|
|
},
|
|
}
|
|
nd, st := makeNodeWithConcurrency(t, router, 1) // global=1
|
|
|
|
a1 := make(chan error, 1)
|
|
go func() {
|
|
a1 <- nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "ta1", Adapter: "adapter-a", Target: "v1", Background: true,
|
|
})
|
|
}()
|
|
waitStarted(t, saA, "ta1")
|
|
|
|
// Run on adapter B acquires adapter B slot immediately but blocks on global=1.
|
|
// It must time out via the queue timeout rather than waiting indefinitely.
|
|
router.adapterName = "adapter-b"
|
|
err := nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "tb1", Adapter: "adapter-b", Target: "v1",
|
|
})
|
|
if !errors.Is(err, node.ErrConcurrencyLimitExceeded) {
|
|
t.Fatalf("expected ErrConcurrencyLimitExceeded on global-wait timeout, got %v", err)
|
|
}
|
|
rec := requireStatus(t, st, "tb1", "rejected")
|
|
if !strings.Contains(rec.Error, "queue timeout") {
|
|
t.Fatalf("expected queue_timeout reason for global-wait timeout, got %q", rec.Error)
|
|
}
|
|
|
|
<-a1
|
|
saA.releaseRun("ta1")
|
|
}
|
|
|
|
// TestAdmissionQueue_ParentTimeoutNotQueueTimeout verifies that when a queued
|
|
// run is terminated by its parent RunRequest.timeout_sec (not the provider
|
|
// queue timeout), the rejection reason is NOT misreported as queue_timeout. With
|
|
// queue_timeout_ms=0 the queue timeout mechanism is disabled, so the bogus
|
|
// "queue timeout exceeded (queue_timeout=0s)" message must never appear.
|
|
func TestAdmissionQueue_ParentTimeoutNotQueueTimeout(t *testing.T) {
|
|
// queue_timeout_ms=0 (disabled), max_queue=4, capacity=1.
|
|
sa := newQueuedSlowAdapter("slow", 1, 4, 0)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
nd, st := makeNodeWithConcurrency(t, router, 0)
|
|
|
|
hold := make(chan error, 1)
|
|
go func() {
|
|
hold <- nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "pt-hold", Adapter: "slow", Target: "v1", Background: true,
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "pt-hold")
|
|
|
|
// Second foreground run queues (queue enabled, slot busy) and is terminated
|
|
// by its own 1s request timeout, not by a queue timeout.
|
|
err := nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "pt-timeout", Adapter: "slow", Target: "v1", TimeoutSec: 1,
|
|
})
|
|
if !errors.Is(err, node.ErrConcurrencyLimitExceeded) {
|
|
t.Fatalf("expected ErrConcurrencyLimitExceeded on parent request timeout, got %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "queue timeout") {
|
|
t.Fatalf("parent request timeout must not be reported as queue timeout, got %q", err.Error())
|
|
}
|
|
rec := requireStatus(t, st, "pt-timeout", "rejected")
|
|
if strings.Contains(rec.Error, "queue timeout") || strings.Contains(rec.Error, "queue_timeout=0s") {
|
|
t.Fatalf("store error must not contain a queue_timeout reason, got %q", rec.Error)
|
|
}
|
|
|
|
sa.releaseRun("pt-hold")
|
|
if err := <-hold; err != nil {
|
|
t.Fatalf("pt-hold: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestAdmissionQueue_ParentTimeoutBeatsQueueTimeout verifies that even when a
|
|
// provider queue timeout IS configured, a parent request timeout that fires
|
|
// first is still classified as concurrency_unavailable, not queue_timeout.
|
|
func TestAdmissionQueue_ParentTimeoutBeatsQueueTimeout(t *testing.T) {
|
|
// queue_timeout_ms=10s (long), but the run's request timeout is 1s, so the
|
|
// parent deadline fires first.
|
|
sa := newQueuedSlowAdapter("slow", 1, 4, 10*time.Second)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
nd, st := makeNodeWithConcurrency(t, router, 0)
|
|
|
|
hold := make(chan error, 1)
|
|
go func() {
|
|
hold <- nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "pb-hold", Adapter: "slow", Target: "v1", Background: true,
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "pb-hold")
|
|
|
|
err := nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "pb-timeout", Adapter: "slow", Target: "v1", TimeoutSec: 1,
|
|
})
|
|
if !errors.Is(err, node.ErrConcurrencyLimitExceeded) {
|
|
t.Fatalf("expected ErrConcurrencyLimitExceeded, got %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "queue timeout") {
|
|
t.Fatalf("parent deadline (earlier than queue timeout) must not be queue timeout, got %q", err.Error())
|
|
}
|
|
rec := requireStatus(t, st, "pb-timeout", "rejected")
|
|
if strings.Contains(rec.Error, "queue timeout") {
|
|
t.Fatalf("store error must not contain queue timeout, got %q", rec.Error)
|
|
}
|
|
|
|
sa.releaseRun("pb-hold")
|
|
if err := <-hold; err != nil {
|
|
t.Fatalf("pb-hold: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestConcurrencyLimit_RejectStoreAndEvent verifies that a rejected run (queue
|
|
// overflow) is stored with terminal status "rejected" and an error message.
|
|
func TestConcurrencyLimit_RejectStoreAndEvent(t *testing.T) {
|
|
// capacity=1, max_queue=0: any second concurrent run overflows immediately.
|
|
sa := newQueuedSlowAdapter("slow", 1, 0, 0)
|
|
router := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
|
nd, st := makeNodeWithConcurrency(t, router, 0)
|
|
|
|
errc := make(chan error, 1)
|
|
go func() {
|
|
errc <- nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "run-hold", Adapter: "slow", Target: "v1", Background: true,
|
|
})
|
|
}()
|
|
waitStarted(t, sa, "run-hold")
|
|
|
|
// Second run must be rejected (queue capacity is 0).
|
|
err := nd.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
|
RunId: "run-rejected", Adapter: "slow", Target: "v1",
|
|
})
|
|
if !errors.Is(err, node.ErrConcurrencyLimitExceeded) {
|
|
t.Fatalf("expected ErrConcurrencyLimitExceeded, got %v", err)
|
|
}
|
|
|
|
// Rejected run must be stored with status "rejected".
|
|
run, err := st.GetRun(context.Background(), "run-rejected")
|
|
if err != nil {
|
|
t.Fatalf("GetRun: %v", err)
|
|
}
|
|
if run == nil {
|
|
t.Fatal("expected rejected run to be stored")
|
|
}
|
|
if run.Status != "rejected" {
|
|
t.Fatalf("expected status %q, got %q", "rejected", run.Status)
|
|
}
|
|
if run.Error == "" {
|
|
t.Fatal("expected non-empty error message for rejected run")
|
|
}
|
|
|
|
sa.releaseRun("run-hold")
|
|
if err := <-errc; err != nil {
|
|
t.Fatalf("run-hold: %v", err)
|
|
}
|
|
}
|