iop/apps/node/internal/node/concurrency_gate_test.go
toki 01dc2ef78b refactor: readability baseline 및 테스트 구조 개선
- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동
- 새 테스트 파일 추가 (edge, node, client, config, readability)
- readability_audit 스크립트 및 baseline 추가
- roadmap/SDD 문서 갱신
- agent-client/pi/extensions/openai-sampling-parameters 추가
2026-07-17 16:02:12 +09:00

151 lines
5 KiB
Go

package node_test
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"iop/apps/node/internal/node"
"iop/apps/node/internal/runtime"
"iop/apps/node/internal/transport"
iop "iop/proto/gen/iop"
)
// --- concurrency gate tests ---
// TestOnRunRequest_OverDispatchSafetyRejectsWithoutQueue verifies that a run
// is rejected immediately with reason concurrency_unavailable when the concurrency limit is reached (no queue).
func TestOnRunRequest_OverDispatchSafetyRejectsWithoutQueue(t *testing.T) {
// capacity=1: one running is the ceiling.
sa := newQueuedSlowAdapter("slow", 1, 0, 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")
// Second run overflows the capacity and must be rejected immediately.
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, "concurrency unavailable") {
t.Fatalf("expected concurrency unavailable reason in error, got %q", rec.Error)
}
sa.releaseRun("of-hold")
if err := <-hold; err != nil {
t.Fatalf("of-hold: %v", err)
}
}
// TestOnRunRequest_PermitReleasedAfterCompletion verifies that after the first
// run completes, a subsequent run can acquire the slot immediately.
func TestOnRunRequest_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)
}
}
}
// TestConcurrencyLimit_RejectStoreAndEvent verifies that a rejected run
// is stored with terminal status "rejected" and an error message.
func TestConcurrencyLimit_RejectStoreAndEvent(t *testing.T) {
// capacity=1: 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.
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)
}
}