iop/apps/edge/internal/bootstrap/reconnect_readiness_integration_test.go
toki 4dcf6f0cf9 feat(edge): provider 리소스 승인 소유권 정렬 구현
- 노드 연결 2단계 핸드셰이크 추가 (Register→NodeReady→DispatchReady)
- ConnectionGeneration 기반 연결 세대 관리로 stale 연결 차폐
- configured 노드 catalog 기반 snapshot rebuild (Connected 상태 분리)
- provider 리스 소유권 일원화: edge가 소유권 승인·반환 전까지 대기
- 모델 대기열 승인/해제/스냅샷 서비스 구현
- 재연결 준비도 통합 테스트, 아카이브된 하위태스크 8건 포함
2026-07-22 18:10:54 +09:00

570 lines
17 KiB
Go

package bootstrap
import (
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
"google.golang.org/protobuf/proto"
edgeservice "iop/apps/edge/internal/service"
iop "iop/proto/gen/iop"
)
// TestActualNodeReconnectReadyPumpsQueuedWaiterExactlyOnce covers the hole that
// fake queue and bootstrap tests cannot see independently. It starts the actual
// iop-node entrypoint once, closes it to make the configured resource offline,
// queues a waiter behind an alternate provider's occupied slot, then starts the
// same node entrypoint again. The queued run must reach the real Node handler and
// return exactly one terminal event after the second process completes the
// RegisterResponse → SetHandler → NodeReadyRequest/ack sequence.
func TestActualNodeReconnectReadyPumpsQueuedWaiterExactlyOnce(t *testing.T) {
if goruntime.GOOS == "windows" {
t.Skip("the local actual-node fixture uses the POSIX sh CLI profile")
}
if _, err := exec.LookPath("sh"); err != nil {
t.Skipf("sh is required by the local actual-node fixture: %v", err)
}
dir := t.TempDir()
edgeAddr := freeTCPAddr(t)
edgeConfigPath := writeReconnectReadinessEdgeConfig(t, dir, edgeAddr)
rt, err := NewRuntime(loadServeNormalizedConfig(t, edgeConfigPath))
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("start edge runtime: %v", err)
}
defer func() {
if err := rt.Stop(); err != nil {
t.Errorf("stop edge runtime: %v", err)
}
}()
alt := dialReconnectReadinessFakeNode(t, edgeAddr)
defer alt.Close()
altRuns := make(chan *iop.RunRequest, 4)
toki.AddListenerTyped[*iop.RunRequest](&alt.Communicator, func(req *iop.RunRequest) {
altRuns <- req
})
altRegistration, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&alt.Communicator, &iop.RegisterRequest{Token: "alt-token"}, fakeNodeHandshakeTimeout)
if err != nil {
t.Fatalf("register alternate fake node: %v", err)
}
if !altRegistration.GetAccepted() {
t.Fatalf("alternate registration rejected: %s", altRegistration.GetReason())
}
readyFakeNode(t, alt, altRegistration.GetNodeId())
waitForReconnectReadiness(t, 3*time.Second, "alternate node dispatch-ready", func() bool {
_, ok := rt.Registry.GetReady("node-alt")
return ok
})
nodeBinary := buildReconnectReadinessNodeBinary(t, dir)
nodeConfigPath := writeReconnectReadinessNodeConfig(t, dir, edgeAddr)
// First process establishes the resource, then exits cleanly. The waiter below
// is therefore testing an actual reconnect, rather than only the first Node
// registration path.
first := startReconnectReadinessNode(t, nodeBinary, nodeConfigPath, filepath.Join(dir, "node-first"))
waitForReconnectReadiness(t, 6*time.Second, "initial actual node ready", func() bool {
_, ok := rt.Registry.GetReady("node-recon")
return ok
})
if err := first.Stop(); err != nil {
t.Fatalf("stop initial actual node: %v", err)
}
waitForReconnectReadiness(t, 4*time.Second, "initial actual node removed", func() bool {
_, ok := rt.Registry.Get("node-recon")
return !ok
})
// The alternate provider receives one background run and deliberately keeps
// its lease until the test sends its terminal event. With the reconnect node
// offline, the next run must become a provider-pool waiter.
blocking, err := rt.Service.SubmitRun(context.Background(), edgeservice.SubmitRunRequest{
ModelGroupKey: "reconnect-ready-model",
ProviderPool: true,
Background: true,
Input: map[string]any{"prompt": "hold alternate capacity"},
})
if err != nil {
t.Fatalf("submit alternate blocking run: %v", err)
}
defer blocking.Close()
select {
case req := <-altRuns:
if req.GetRunId() != blocking.Dispatch().RunID {
t.Fatalf("alternate run id=%q, want %q", req.GetRunId(), blocking.Dispatch().RunID)
}
case <-time.After(3 * time.Second):
t.Fatal("alternate provider did not receive the blocking RunRequest")
}
type submitOutcome struct {
result edgeservice.RunResult
err error
}
waiterDone := make(chan submitOutcome, 1)
waiterCtx, cancelWaiter := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelWaiter()
go func() {
result, submitErr := rt.Service.SubmitRun(waiterCtx, edgeservice.SubmitRunRequest{
ModelGroupKey: "reconnect-ready-model",
ProviderPool: true,
Input: map[string]any{"prompt": "reconnect-ready waiter"},
})
waiterDone <- submitOutcome{result: result, err: submitErr}
}()
waitForProviderSnapshot(t, rt.Service, "node-alt", "provider-alt", 1, 1, 0, 0)
select {
case outcome := <-waiterDone:
if outcome.result != nil {
outcome.result.Close()
}
t.Fatalf("waiter resolved before reconnect: %v", outcome.err)
default:
}
second := startReconnectReadinessNode(t, nodeBinary, nodeConfigPath, filepath.Join(dir, "node-second"))
waitForReconnectReadiness(t, 6*time.Second, "reconnected actual node ready", func() bool {
_, ok := rt.Registry.GetReady("node-recon")
return ok
})
var waiter submitOutcome
select {
case waiter = <-waiterDone:
case <-time.After(6 * time.Second):
t.Fatal("queued waiter was not dispatched after actual node ready ack")
}
if waiter.err != nil {
t.Fatalf("queued waiter failed after reconnect: %v", waiter.err)
}
if waiter.result == nil {
t.Fatal("queued waiter returned nil result")
}
defer waiter.result.Close()
dispatch := waiter.result.Dispatch()
if dispatch.NodeID != "node-recon" || dispatch.ProviderID != "provider-recon" {
t.Fatalf("waiter dispatch=(node=%q provider=%q), want node-recon/provider-recon", dispatch.NodeID, dispatch.ProviderID)
}
if dispatch.Adapter != "cli" || dispatch.Target != "recon-target" {
t.Fatalf("waiter dispatch=(adapter=%q target=%q), want cli/recon-target", dispatch.Adapter, dispatch.Target)
}
if dispatch.QueueReason != "capacity_full" {
t.Fatalf("waiter queue reason=%q, want capacity_full", dispatch.QueueReason)
}
waitForProviderSnapshot(t, rt.Service, "node-recon", "provider-recon", 1, 0, 0, 0)
// A rejected duplicate connection cannot turn itself ready or pump another
// waiter while the actual reconnect owner executes its RunRequest.
duplicate := dialReconnectReadinessFakeNode(t, edgeAddr)
defer duplicate.Close()
dupRegistration, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&duplicate.Communicator, &iop.RegisterRequest{Token: "recon-token"}, fakeNodeHandshakeTimeout)
if err != nil {
t.Fatalf("duplicate register request: %v", err)
}
if dupRegistration.GetAccepted() {
t.Fatal("duplicate reconnect registration was accepted")
}
dupReady, err := toki.SendRequestTyped[*iop.NodeReadyRequest, *iop.NodeReadyResponse](
&duplicate.Communicator, &iop.NodeReadyRequest{NodeId: "node-recon"}, fakeNodeHandshakeTimeout)
if err != nil {
t.Fatalf("duplicate ready request: %v", err)
}
if dupReady.GetReady() {
t.Fatal("duplicate connection received a successful ready acknowledgement")
}
terminalCount := waitForRunTerminal(t, waiter.result.Stream().Events, dispatch.RunID)
if terminalCount != 1 {
t.Fatalf("actual node terminal count=%d, want exactly 1", terminalCount)
}
waitForReconnectReadiness(t, 3*time.Second, "actual node payload", func() bool {
return strings.Contains(second.Output(), "reconnect-ready-terminal")
})
// Late terminal delivery is idempotent and must neither re-release the lease
// nor dispatch another request. The primary terminal above came from the
// actual node process; this is only the competing callback regression probe.
rt.Service.HandleRunLifecycleEvent(&iop.RunEvent{RunId: dispatch.RunID, Type: "complete", NodeId: "node-recon"})
waitForProviderSnapshot(t, rt.Service, "node-recon", "provider-recon", 0, 0, 0, 0)
select {
case unexpected := <-altRuns:
t.Fatalf("unexpected extra alternate dispatch after duplicate ready/terminal: %q", unexpected.GetRunId())
default:
}
if err := alt.Send(&iop.RunEvent{RunId: blocking.Dispatch().RunID, Type: "complete", NodeId: "node-alt"}); err != nil {
t.Fatalf("send alternate terminal: %v", err)
}
waitForProviderSnapshot(t, rt.Service, "node-alt", "provider-alt", 0, 0, 0, 0)
assertReconnectReadinessCountersZero(t, rt.Service)
if err := second.Stop(); err != nil {
t.Fatalf("stop reconnected actual node: %v", err)
}
}
func reconnectReadinessParserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&iop.RegisterResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.RegisterResponse{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.NodeReadyResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeReadyResponse{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RunRequest{}
return m, proto.Unmarshal(b, m)
},
}
}
func dialReconnectReadinessFakeNode(t *testing.T, edgeAddr string) *toki.TcpClient {
t.Helper()
host, portText, err := net.SplitHostPort(edgeAddr)
if err != nil {
t.Fatalf("split edge address %q: %v", edgeAddr, err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("parse edge port %q: %v", portText, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
client, err := toki.DialTcp(ctx, host, port, 30, 10, reconnectReadinessParserMap())
if err != nil {
t.Fatalf("dial edge: %v", err)
}
return client
}
func writeReconnectReadinessEdgeConfig(t *testing.T, dir, edgeAddr string) string {
t.Helper()
path := filepath.Join(dir, "edge.yaml")
yaml := fmt.Sprintf(`
server:
listen: %q
bootstrap:
listen: "127.0.0.1:0"
artifact_dir: %q
logging:
level: "error"
refresh:
enabled: false
listen: "127.0.0.1:0"
openai:
enabled: false
listen: "127.0.0.1:0"
a2a:
enabled: false
listen: "127.0.0.1:0"
metrics:
port: 0
provider_pool:
max_queue: 4
queue_timeout_ms: 8000
models:
- id: "reconnect-ready-model"
providers:
provider-alt: "alt-target"
provider-recon: "recon-target"
nodes:
- id: "node-alt"
alias: "alternate"
token: "alt-token"
adapters:
cli:
enabled: true
profiles:
alt-target:
command: "sh"
args: ["-c", "sleep 30"]
providers:
- id: "provider-alt"
type: "cli"
category: "cli"
adapter: "cli"
models: ["alt-target"]
health: "available"
capacity: 1
- id: "node-recon"
alias: "reconnect"
token: "recon-token"
adapters:
cli:
enabled: true
profiles:
recon-target:
command: "sh"
args: ["-c", "sleep 0.4; printf 'reconnect-ready-terminal\\n'"]
providers:
- id: "provider-recon"
type: "cli"
category: "cli"
adapter: "cli"
models: ["recon-target"]
health: "available"
capacity: 1
`, edgeAddr, filepath.Join(dir, "artifacts"))
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
t.Fatalf("write edge config: %v", err)
}
return path
}
func writeReconnectReadinessNodeConfig(t *testing.T, dir, edgeAddr string) string {
t.Helper()
path := filepath.Join(dir, "node.yaml")
yaml := fmt.Sprintf(`
transport:
edge_addr: %q
token: "recon-token"
reconnect:
interval_sec: 1
max_attempts: 1
logging:
level: "error"
path: %q
metrics:
port: 0
`, edgeAddr, filepath.Join(dir, "node.log"))
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
t.Fatalf("write node config: %v", err)
}
return path
}
func buildReconnectReadinessNodeBinary(t *testing.T, dir string) string {
t.Helper()
root := reconnectReadinessRepoRoot(t)
binary := filepath.Join(dir, "iop-node")
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "build", "-o", binary, "./apps/node/cmd/node")
cmd.Dir = root
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("build actual iop-node: %v\n%s", err, output)
}
return binary
}
func reconnectReadinessRepoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("could not locate repository go.mod")
}
dir = parent
}
}
type reconnectReadinessNodeProcess struct {
cmd *exec.Cmd
stdoutPath string
stderrPath string
stdout *os.File
stderr *os.File
wait chan error
once sync.Once
err error
}
func startReconnectReadinessNode(t *testing.T, binary, configPath, dir string) *reconnectReadinessNodeProcess {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("create node work directory: %v", err)
}
stdoutPath := filepath.Join(dir, "stdout.log")
stderrPath := filepath.Join(dir, "stderr.log")
stdout, err := os.Create(stdoutPath)
if err != nil {
t.Fatalf("create node stdout log: %v", err)
}
stderr, err := os.Create(stderrPath)
if err != nil {
_ = stdout.Close()
t.Fatalf("create node stderr log: %v", err)
}
cmd := exec.Command(binary, "--config", configPath, "serve")
cmd.Dir = dir
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
_ = stdout.Close()
_ = stderr.Close()
t.Fatalf("start actual iop-node: %v", err)
}
p := &reconnectReadinessNodeProcess{
cmd: cmd,
stdoutPath: stdoutPath,
stderrPath: stderrPath,
stdout: stdout,
stderr: stderr,
wait: make(chan error, 1),
}
go func() { p.wait <- cmd.Wait() }()
t.Cleanup(func() {
if err := p.Stop(); err != nil && !t.Failed() {
t.Errorf("stop actual iop-node during cleanup: %v", err)
}
if t.Failed() {
t.Logf("actual iop-node stdout (%s):\n%s", p.stdoutPath, p.Output())
t.Logf("actual iop-node stderr (%s):\n%s", p.stderrPath, p.ErrorOutput())
}
})
return p
}
func (p *reconnectReadinessNodeProcess) Stop() error {
p.once.Do(func() {
if p.cmd.ProcessState == nil {
if err := p.cmd.Process.Signal(os.Interrupt); err != nil && !strings.Contains(err.Error(), "process already finished") {
p.err = err
}
}
select {
case waitErr := <-p.wait:
if p.err == nil {
p.err = waitErr
}
case <-time.After(5 * time.Second):
_ = p.cmd.Process.Kill()
waitErr := <-p.wait
if p.err == nil {
p.err = fmt.Errorf("iop-node did not stop after interrupt: %w", waitErr)
}
}
_ = p.stdout.Close()
_ = p.stderr.Close()
})
return p.err
}
func (p *reconnectReadinessNodeProcess) Output() string {
b, err := os.ReadFile(p.stdoutPath)
if err != nil {
return fmt.Sprintf("read stdout: %v", err)
}
return string(b)
}
func (p *reconnectReadinessNodeProcess) ErrorOutput() string {
b, err := os.ReadFile(p.stderrPath)
if err != nil {
return fmt.Sprintf("read stderr: %v", err)
}
return string(b)
}
func waitForReconnectReadiness(t *testing.T, timeout time.Duration, description string, condition func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if condition() {
return
}
time.Sleep(20 * time.Millisecond)
}
if condition() {
return
}
t.Fatalf("timed out waiting for %s", description)
}
func waitForProviderSnapshot(t *testing.T, service *edgeservice.Service, nodeID, providerID string, inFlight, queued, longInFlight, longQueued int32) {
t.Helper()
waitForReconnectReadiness(t, 4*time.Second, fmt.Sprintf("provider snapshot %s/%s", nodeID, providerID), func() bool {
for _, node := range service.ListNodeSnapshots() {
if node.NodeID != nodeID {
continue
}
for _, provider := range node.ProviderSnapshots {
if provider.GetId() == providerID {
return provider.GetInFlight() == inFlight && provider.GetQueued() == queued &&
provider.GetLongInFlight() == longInFlight && provider.GetLongQueued() == longQueued
}
}
}
return false
})
}
func waitForRunTerminal(t *testing.T, events <-chan *iop.RunEvent, runID string) int {
t.Helper()
terminalCount := 0
deadline := time.NewTimer(6 * time.Second)
defer deadline.Stop()
for terminalCount == 0 {
select {
case event := <-events:
if event != nil && event.GetRunId() == runID && isReconnectTerminal(event.GetType()) {
terminalCount++
}
case <-deadline.C:
t.Fatalf("timed out waiting for actual node terminal run_id=%q", runID)
}
}
quiet := time.NewTimer(300 * time.Millisecond)
defer quiet.Stop()
for {
select {
case event := <-events:
if event != nil && event.GetRunId() == runID && isReconnectTerminal(event.GetType()) {
terminalCount++
}
case <-quiet.C:
return terminalCount
}
}
}
func isReconnectTerminal(eventType string) bool {
switch eventType {
case "complete", "error", "cancelled":
return true
default:
return false
}
}
func assertReconnectReadinessCountersZero(t *testing.T, service *edgeservice.Service) {
t.Helper()
for _, node := range service.ListNodeSnapshots() {
for _, provider := range node.ProviderSnapshots {
if provider.GetInFlight() != 0 || provider.GetQueued() != 0 || provider.GetLongInFlight() != 0 || provider.GetLongQueued() != 0 {
t.Errorf("provider counters not settled node=%q provider=%q normal=%d queued=%d long=%d long_queued=%d",
node.NodeID, provider.GetId(), provider.GetInFlight(), provider.GetQueued(), provider.GetLongInFlight(), provider.GetLongQueued())
}
}
}
}