- 노드 연결 2단계 핸드셰이크 추가 (Register→NodeReady→DispatchReady) - ConnectionGeneration 기반 연결 세대 관리로 stale 연결 차폐 - configured 노드 catalog 기반 snapshot rebuild (Connected 상태 분리) - provider 리스 소유권 일원화: edge가 소유권 승인·반환 전까지 대기 - 모델 대기열 승인/해제/스냅샷 서비스 구현 - 재연결 준비도 통합 테스트, 아카이브된 하위태스크 8건 포함
570 lines
17 KiB
Go
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())
|
|
}
|
|
}
|
|
}
|
|
}
|