iop/apps/node/internal/node/node_test.go
toki 953617b12d feat(node): FIFO admission queue 구현 및 관련 파일 업데이트
- edge connector 및 service 테스트 개선
- node FIFO admission queue 구현
- 로드맵 및 마일스톤 문서 업데이트
- queue observe snapshot 아카이브 이동
2026-06-15 07:38:37 +09:00

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)
}
}