2984 lines
92 KiB
Go
2984 lines
92 KiB
Go
package cliproxy
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/homeplugins"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
|
|
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
|
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
|
)
|
|
|
|
type blockingServiceCooldownStore struct {
|
|
started chan struct{}
|
|
}
|
|
|
|
func (s *blockingServiceCooldownStore) Load(context.Context) ([]coreauth.CooldownStateRecord, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *blockingServiceCooldownStore) Save(ctx context.Context, _ []coreauth.CooldownStateRecord) error {
|
|
close(s.started)
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
}
|
|
|
|
func TestConfigCommitDoesNotHoldCommitMutexDuringCooldownPersistence(t *testing.T) {
|
|
manager := coreauth.NewManager(nil, nil, nil)
|
|
auth := &coreauth.Auth{ID: "auth-1", Provider: "xai", Status: coreauth.StatusActive}
|
|
if _, errRegister := manager.Register(coreauth.WithSkipPersist(context.Background()), auth); errRegister != nil {
|
|
t.Fatalf("Register() error = %v", errRegister)
|
|
}
|
|
manager.MarkResult(context.Background(), coreauth.Result{
|
|
AuthID: auth.ID, Provider: auth.Provider, Model: "grok-4", Success: false,
|
|
Error: &coreauth.Error{Message: "rate limited", HTTPStatus: http.StatusTooManyRequests},
|
|
})
|
|
store := &blockingServiceCooldownStore{started: make(chan struct{})}
|
|
manager.SetCooldownStateStore(store)
|
|
service := &Service{cfg: &config.Config{}, coreManager: manager}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
applyDone := make(chan bool, 1)
|
|
go func() {
|
|
applyDone <- service.applyConfigUpdateWithAuthSynthesis(ctx, &config.Config{DisableCooling: true}, false)
|
|
}()
|
|
select {
|
|
case <-store.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("old cooldown store persistence did not start")
|
|
}
|
|
|
|
commitDone := make(chan struct{})
|
|
go func() {
|
|
service.commitConfigUpdate(&config.Config{})
|
|
close(commitDone)
|
|
}()
|
|
select {
|
|
case <-commitDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("config commit mutex remained locked during cooldown persistence")
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case applied := <-applyDone:
|
|
if applied {
|
|
t.Fatal("config runtime apply succeeded after cooldown persistence cancellation")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("config runtime apply did not honor cooldown persistence cancellation")
|
|
}
|
|
}
|
|
|
|
func TestServiceShutdownPreservesReplacementHomeClient(t *testing.T) {
|
|
staleClient := home.New(internalconfig.HomeConfig{Enabled: true})
|
|
replacementClient := home.New(internalconfig.HomeConfig{Enabled: true})
|
|
home.SetCurrent(replacementClient)
|
|
t.Cleanup(home.ClearCurrent)
|
|
|
|
service := &Service{homeClient: staleClient}
|
|
if errShutdown := service.Shutdown(context.Background()); errShutdown != nil {
|
|
t.Fatalf("Shutdown() error = %v", errShutdown)
|
|
}
|
|
if current := home.Current(); current != replacementClient {
|
|
t.Fatal("Shutdown() cleared the replacement Home client")
|
|
}
|
|
}
|
|
|
|
func TestServiceConcurrentReplacementWaitsForInFlightDrain(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
registry := executionregistry.New()
|
|
pending, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
_, oldCancel := context.WithCancel(context.Background())
|
|
t.Cleanup(oldCancel)
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
service := &Service{
|
|
cfg: cfg,
|
|
homeCancel: oldCancel,
|
|
homeClient: home.New(internalconfig.HomeConfig{Enabled: true}),
|
|
homeRegistry: registry,
|
|
homeDrainBound: time.Second,
|
|
}
|
|
|
|
firstReturned := make(chan struct{})
|
|
go func() {
|
|
service.startHomeSubscriber(ctx)
|
|
close(firstReturned)
|
|
}()
|
|
deadline := time.Now().Add(time.Second)
|
|
for {
|
|
if _, errLate := registry.BeginDispatch(); errLate != nil {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("first replacement did not begin draining")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
secondReturned := make(chan struct{})
|
|
go func() {
|
|
service.startHomeSubscriber(ctx)
|
|
close(secondReturned)
|
|
}()
|
|
select {
|
|
case <-secondReturned:
|
|
t.Fatal("concurrent replacement returned before the first drain completed")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
pending.End()
|
|
select {
|
|
case <-firstReturned:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first replacement did not complete after its drain")
|
|
}
|
|
select {
|
|
case <-secondReturned:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("second replacement did not complete after the first drain")
|
|
}
|
|
}
|
|
|
|
func TestServiceReplacementWaitsForPreACKSupervisorExit(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
firstSubscribed := make(chan struct{})
|
|
secondStarted := make(chan struct{})
|
|
secondStartedBeforeFirstDone := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
firstDoneForServer := make(chan (<-chan struct{}), 1)
|
|
var configRequests atomic.Int32
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go servePreACKReplacementConnection(conn, &configRequests, firstSubscribed, secondStarted, secondStartedBeforeFirstDone, firstDoneForServer, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
service := newRegistryTestService(t, listener)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
service.startHomeSubscriber(ctx)
|
|
select {
|
|
case <-firstSubscribed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first subscriber did not reach pre-ACK state")
|
|
}
|
|
|
|
service.homeLifecycleMu.Lock()
|
|
firstDone := service.homeSupervisor.done
|
|
service.homeLifecycleMu.Unlock()
|
|
if firstDone == nil {
|
|
t.Fatal("first subscriber has no supervisor completion signal")
|
|
}
|
|
firstDoneForServer <- firstDone
|
|
|
|
replaced := make(chan struct{})
|
|
go func() {
|
|
service.startHomeSubscriber(ctx)
|
|
close(replaced)
|
|
}()
|
|
|
|
select {
|
|
case <-secondStartedBeforeFirstDone:
|
|
t.Fatal("replacement subscriber started before the pre-ACK supervisor exited")
|
|
case <-secondStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement subscriber did not start")
|
|
}
|
|
select {
|
|
case <-firstDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("pre-ACK supervisor did not exit")
|
|
}
|
|
select {
|
|
case <-replaced:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement start did not return")
|
|
}
|
|
}
|
|
|
|
func TestServiceReplacementWaitsForPublisherExitAndPinsACKedLifetimeDependencies(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
frames := make(chan home.InFlightSnapshotFrame, 64)
|
|
var configRequests atomic.Int32
|
|
firstPublisherDoneForServer := make(chan (<-chan struct{}), 1)
|
|
secondConfigResult := make(chan error, 1)
|
|
allowSecondConfig := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go servePublisherReplacementConnection(conn, &configRequests, frames, firstPublisherDoneForServer, secondConfigResult, allowSecondConfig, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
service := newRegistryTestService(t, listener)
|
|
service.coreManager = coreauth.NewManager(nil, nil, nil)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
service.startHomeSubscriber(ctx)
|
|
|
|
firstFrame := waitForPublisherReplacementFrame(t, frames, 11)
|
|
firstClient := waitForServiceHomeClient(t, service, time.Second)
|
|
firstRegistry := waitForServiceRegistry(t, service, time.Second)
|
|
service.homeLifecycleMu.Lock()
|
|
firstPublisherDone := service.homeSupervisor.publisherCompletion()
|
|
service.homeLifecycleMu.Unlock()
|
|
if firstPublisherDone == nil {
|
|
t.Fatal("first subscriber did not record publisher completion")
|
|
}
|
|
firstPublisherDoneForServer <- firstPublisherDone
|
|
if firstFrame.BarrierRevision != 11 {
|
|
t.Fatalf("first publisher frame = %#v", firstFrame)
|
|
}
|
|
|
|
replaced := make(chan struct{})
|
|
go func() {
|
|
service.startHomeSubscriber(ctx)
|
|
close(replaced)
|
|
}()
|
|
|
|
deadline := time.NewTimer(time.Second)
|
|
defer deadline.Stop()
|
|
select {
|
|
case errSecondConfig := <-secondConfigResult:
|
|
if errSecondConfig != nil {
|
|
t.Fatal(errSecondConfig)
|
|
}
|
|
case <-deadline.C:
|
|
t.Fatal("replacement did not begin its config lifetime")
|
|
}
|
|
close(allowSecondConfig)
|
|
|
|
secondFrame := waitForPublisherReplacementFrame(t, frames, 22)
|
|
secondClient := waitForServiceHomeClient(t, service, time.Second)
|
|
secondRegistry := waitForServiceRegistry(t, service, time.Second)
|
|
if secondFrame.BarrierRevision != 22 {
|
|
t.Fatalf("replacement publisher frame = %#v", secondFrame)
|
|
}
|
|
if secondClient == firstClient || secondRegistry == firstRegistry {
|
|
t.Fatal("replacement publisher reused the previous lifetime dependencies")
|
|
}
|
|
select {
|
|
case <-replaced:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement subscriber did not finish setup")
|
|
}
|
|
}
|
|
|
|
func TestHomeConfigWorkerDoesNotApplyCanceledQueuedConfig(t *testing.T) {
|
|
baseCfg := &config.Config{}
|
|
baseCfg.Home.Enabled = true
|
|
baseCfg.Routing.Strategy = "round-robin"
|
|
service := &Service{cfg: baseCfg}
|
|
queue := newHomeConfigWorkQueue()
|
|
queue.enqueue([]byte("routing:\n strategy: fill-first\n"))
|
|
ready := make(chan struct{})
|
|
close(ready)
|
|
lifetimeCtx, cancelLifetime := context.WithCancel(context.Background())
|
|
cancelLifetime()
|
|
cancelBound := atomic.Int64{}
|
|
cancelBound.Store(int64(time.Second))
|
|
|
|
service.runHomeConfigWorker(lifetimeCtx, context.Background(), 1, nil, executionregistry.New(), queue, ready, &atomic.Bool{}, &cancelBound)
|
|
|
|
service.cfgMu.RLock()
|
|
strategy := service.cfg.Routing.Strategy
|
|
service.cfgMu.RUnlock()
|
|
if strategy != "round-robin" {
|
|
t.Fatalf("canceled queued config changed routing strategy to %q", strategy)
|
|
}
|
|
}
|
|
|
|
func TestHomeConfigWorkerSkipsStagedConfigWhenReplacementCancels(t *testing.T) {
|
|
client, _ := newHomePluginTaskTestClient(t, nil, 0)
|
|
baseCfg := &config.Config{}
|
|
baseCfg.Home.Enabled = true
|
|
baseCfg.Routing.Strategy = "round-robin"
|
|
parentCtx, cancelParent := context.WithCancel(context.Background())
|
|
t.Cleanup(cancelParent)
|
|
homeCtx, cancelHome := context.WithCancel(parentCtx)
|
|
t.Cleanup(cancelHome)
|
|
lifetimeCtx, cancelLifetime := context.WithCancel(homeCtx)
|
|
t.Cleanup(cancelLifetime)
|
|
stagePaused := make(chan struct{})
|
|
releaseStage := make(chan struct{})
|
|
var releaseStageOnce sync.Once
|
|
t.Cleanup(func() { releaseStageOnce.Do(func() { close(releaseStage) }) })
|
|
cancelled := make(chan struct{})
|
|
workerDone := make(chan struct{})
|
|
service := &Service{
|
|
cfg: baseCfg,
|
|
homeGeneration: 1,
|
|
homeConfigStageHook: func() {
|
|
close(stagePaused)
|
|
<-releaseStage
|
|
},
|
|
homeSupervisor: &homeSubscriberSupervisor{cancel: func() {
|
|
cancelLifetime()
|
|
close(cancelled)
|
|
}, done: workerDone},
|
|
}
|
|
queue := newHomeConfigWorkQueue()
|
|
queue.enqueue([]byte("routing:\n strategy: fill-first\n"))
|
|
ready := make(chan struct{})
|
|
close(ready)
|
|
cancelBound := atomic.Int64{}
|
|
cancelBound.Store(int64(time.Second))
|
|
go func() {
|
|
defer close(workerDone)
|
|
service.runHomeConfigWorker(lifetimeCtx, homeCtx, 1, client, executionregistry.New(), queue, ready, &atomic.Bool{}, &cancelBound)
|
|
}()
|
|
select {
|
|
case <-stagePaused:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("config worker did not pause after staging")
|
|
}
|
|
|
|
replacementDone := make(chan struct{})
|
|
go func() {
|
|
service.startHomeSubscriber(parentCtx)
|
|
close(replacementDone)
|
|
}()
|
|
select {
|
|
case <-cancelled:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement did not cancel the staged Home config")
|
|
}
|
|
releaseStageOnce.Do(func() { close(releaseStage) })
|
|
select {
|
|
case <-workerDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("canceled config worker did not exit")
|
|
}
|
|
|
|
service.cfgMu.RLock()
|
|
strategy := service.cfg.Routing.Strategy
|
|
service.cfgMu.RUnlock()
|
|
if strategy != "round-robin" {
|
|
t.Fatalf("canceled staged config changed routing strategy to %q", strategy)
|
|
}
|
|
select {
|
|
case <-replacementDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement deadlocked after canceling staged config")
|
|
}
|
|
}
|
|
|
|
func TestHomeConfigWorkerCommitCompletesBeforeReplacementCancellation(t *testing.T) {
|
|
client, _ := newHomePluginTaskTestClient(t, nil, 0)
|
|
baseCfg := &config.Config{}
|
|
baseCfg.Home.Enabled = true
|
|
baseCfg.Routing.Strategy = "round-robin"
|
|
parentCtx, cancelParent := context.WithCancel(context.Background())
|
|
t.Cleanup(cancelParent)
|
|
homeCtx, cancelHome := context.WithCancel(parentCtx)
|
|
t.Cleanup(cancelHome)
|
|
lifetimeCtx, cancelLifetime := context.WithCancel(homeCtx)
|
|
t.Cleanup(cancelLifetime)
|
|
commitPaused := make(chan struct{})
|
|
releaseCommit := make(chan struct{})
|
|
var releaseCommitOnce sync.Once
|
|
t.Cleanup(func() { releaseCommitOnce.Do(func() { close(releaseCommit) }) })
|
|
cancelled := make(chan struct{})
|
|
workerDone := make(chan struct{})
|
|
service := &Service{
|
|
cfg: baseCfg,
|
|
homeGeneration: 1,
|
|
homeConfigCommitHook: func() {
|
|
close(commitPaused)
|
|
<-releaseCommit
|
|
},
|
|
homeSupervisor: &homeSubscriberSupervisor{cancel: func() {
|
|
cancelLifetime()
|
|
close(cancelled)
|
|
}, done: workerDone},
|
|
}
|
|
queue := newHomeConfigWorkQueue()
|
|
queue.enqueue([]byte("routing:\n strategy: fill-first\n"))
|
|
ready := make(chan struct{})
|
|
close(ready)
|
|
cancelBound := atomic.Int64{}
|
|
cancelBound.Store(int64(time.Second))
|
|
go func() {
|
|
defer close(workerDone)
|
|
service.runHomeConfigWorker(lifetimeCtx, homeCtx, 1, client, executionregistry.New(), queue, ready, &atomic.Bool{}, &cancelBound)
|
|
}()
|
|
select {
|
|
case <-commitPaused:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("config worker did not pause inside commit")
|
|
}
|
|
|
|
replacementDone := make(chan struct{})
|
|
go func() {
|
|
service.startHomeSubscriber(parentCtx)
|
|
close(replacementDone)
|
|
}()
|
|
select {
|
|
case <-cancelled:
|
|
t.Fatal("replacement canceled while config commit owned the commit mutex")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
releaseCommitOnce.Do(func() { close(releaseCommit) })
|
|
select {
|
|
case <-cancelled:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement did not cancel after config commit completed")
|
|
}
|
|
select {
|
|
case <-workerDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("config worker deadlocked after committed config was canceled")
|
|
}
|
|
|
|
service.cfgMu.RLock()
|
|
strategy := service.cfg.Routing.Strategy
|
|
service.cfgMu.RUnlock()
|
|
if strategy != "fill-first" {
|
|
t.Fatalf("committed config routing strategy = %q, want fill-first", strategy)
|
|
}
|
|
select {
|
|
case <-replacementDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement deadlocked after committed config")
|
|
}
|
|
}
|
|
|
|
func TestHomeConfigWorkerCancellationAtPostCommitBoundarySkipsRuntimePublish(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
cancel func(context.CancelFunc, context.CancelFunc)
|
|
}{
|
|
{name: "parent", cancel: func(cancelParent, _ context.CancelFunc) { cancelParent() }},
|
|
{name: "transport", cancel: func(_, cancelLifetime context.CancelFunc) { cancelLifetime() }},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
client, _ := newHomePluginTaskTestClient(t, nil, 0)
|
|
baseCfg := &config.Config{}
|
|
baseCfg.Home.Enabled = true
|
|
baseCfg.Routing.Strategy = "round-robin"
|
|
parentCtx, cancelParent := context.WithCancel(context.Background())
|
|
t.Cleanup(cancelParent)
|
|
homeCtx, cancelHome := context.WithCancel(parentCtx)
|
|
t.Cleanup(cancelHome)
|
|
lifetimeCtx, cancelLifetime := context.WithCancel(homeCtx)
|
|
t.Cleanup(cancelLifetime)
|
|
runtimePaused := make(chan struct{})
|
|
releaseRuntime := make(chan struct{})
|
|
var releaseRuntimeOnce sync.Once
|
|
t.Cleanup(func() { releaseRuntimeOnce.Do(func() { close(releaseRuntime) }) })
|
|
service := &Service{
|
|
cfg: baseCfg,
|
|
homeGeneration: 1,
|
|
homeConfigRuntimeHook: func() {
|
|
close(runtimePaused)
|
|
<-releaseRuntime
|
|
},
|
|
}
|
|
queue := newHomeConfigWorkQueue()
|
|
queue.enqueue([]byte("routing:\n strategy: fill-first\n"))
|
|
ready := make(chan struct{})
|
|
close(ready)
|
|
published := atomic.Bool{}
|
|
cancelBound := atomic.Int64{}
|
|
cancelBound.Store(int64(time.Second))
|
|
workerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(workerDone)
|
|
service.runHomeConfigWorker(lifetimeCtx, homeCtx, 1, client, executionregistry.New(), queue, ready, &published, &cancelBound)
|
|
}()
|
|
select {
|
|
case <-runtimePaused:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Home config worker did not reach post-commit boundary")
|
|
}
|
|
|
|
testCase.cancel(cancelParent, cancelLifetime)
|
|
releaseRuntimeOnce.Do(func() { close(releaseRuntime) })
|
|
select {
|
|
case <-workerDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("canceled Home config worker did not exit")
|
|
}
|
|
service.cfgMu.RLock()
|
|
strategy := service.cfg.Routing.Strategy
|
|
service.cfgMu.RUnlock()
|
|
if strategy != "fill-first" {
|
|
t.Fatalf("post-commit cancellation changed committed routing strategy to %q", strategy)
|
|
}
|
|
if published.Load() {
|
|
t.Fatal("canceled post-commit work published Home runtime")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHomeConfigWorkerShutdownCancelsBlockedRuntimeUpdatesBeforePublish(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
apply func(*Service, func(context.Context, *config.Config) bool)
|
|
}{
|
|
{
|
|
name: "pprof",
|
|
apply: func(service *Service, blocked func(context.Context, *config.Config) bool) {
|
|
service.applyPprofConfigContextFn = blocked
|
|
},
|
|
},
|
|
{
|
|
name: "server",
|
|
apply: func(service *Service, blocked func(context.Context, *config.Config) bool) {
|
|
service.updateServerClientsContextFn = blocked
|
|
},
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
client, _ := newHomePluginTaskTestClient(t, nil, 0)
|
|
baseCfg := &config.Config{}
|
|
baseCfg.Home.Enabled = true
|
|
baseCfg.Home.NodeID = "node-1"
|
|
parentCtx, cancelParent := context.WithCancel(context.Background())
|
|
t.Cleanup(cancelParent)
|
|
homeCtx, cancelHome := context.WithCancel(parentCtx)
|
|
t.Cleanup(cancelHome)
|
|
lifetimeCtx, cancelLifetime := context.WithCancel(homeCtx)
|
|
t.Cleanup(cancelLifetime)
|
|
started := make(chan struct{})
|
|
workerDone := make(chan struct{})
|
|
service := &Service{
|
|
cfg: baseCfg,
|
|
homeGeneration: 1,
|
|
homeSupervisor: &homeSubscriberSupervisor{cancel: cancelLifetime, done: workerDone},
|
|
}
|
|
testCase.apply(service, func(ctx context.Context, _ *config.Config) bool {
|
|
close(started)
|
|
<-ctx.Done()
|
|
return false
|
|
})
|
|
queue := newHomeConfigWorkQueue()
|
|
queue.enqueue([]byte("routing:\n strategy: fill-first\n"))
|
|
ready := make(chan struct{})
|
|
close(ready)
|
|
published := atomic.Bool{}
|
|
cancelBound := atomic.Int64{}
|
|
cancelBound.Store(int64(time.Second))
|
|
go func() {
|
|
defer close(workerDone)
|
|
service.runHomeConfigWorker(lifetimeCtx, homeCtx, 1, client, executionregistry.New(), queue, ready, &published, &cancelBound)
|
|
}()
|
|
select {
|
|
case <-started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Home config worker did not start blocked runtime update")
|
|
}
|
|
|
|
shutdownDone := make(chan error, 1)
|
|
go func() { shutdownDone <- service.Shutdown(context.Background()) }()
|
|
select {
|
|
case <-workerDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("shutdown did not cancel blocked runtime update")
|
|
}
|
|
select {
|
|
case errShutdown := <-shutdownDone:
|
|
if errShutdown != nil {
|
|
t.Fatalf("Shutdown() error = %v", errShutdown)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("shutdown waited for blocked runtime update")
|
|
}
|
|
if published.Load() {
|
|
t.Fatal("canceled runtime update published Home state")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHomeConfigWorkerCancelsBlockedAntigravityModelRefreshBeforePublish(t *testing.T) {
|
|
modelRefreshStarted := make(chan struct{})
|
|
releaseModelRefresh := make(chan struct{})
|
|
var releaseModelRefreshOnce sync.Once
|
|
modelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
close(modelRefreshStarted)
|
|
select {
|
|
case <-r.Context().Done():
|
|
case <-releaseModelRefresh:
|
|
}
|
|
}))
|
|
t.Cleanup(modelServer.Close)
|
|
t.Cleanup(func() { releaseModelRefreshOnce.Do(func() { close(releaseModelRefresh) }) })
|
|
|
|
client, _ := newHomePluginTaskTestClient(t, nil, 0)
|
|
baseCfg := &config.Config{}
|
|
baseCfg.Home.Enabled = true
|
|
manager := coreauth.NewManager(nil, nil, nil)
|
|
auth := &coreauth.Auth{
|
|
ID: "blocked-antigravity-refresh",
|
|
Provider: "antigravity",
|
|
Metadata: map[string]any{"access_token": "test-token"},
|
|
Attributes: map[string]string{
|
|
"base_url": modelServer.URL,
|
|
},
|
|
}
|
|
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
|
|
t.Fatal(errRegister)
|
|
}
|
|
t.Cleanup(func() { GlobalModelRegistry().UnregisterClient(auth.ID) })
|
|
|
|
parentCtx, cancelParent := context.WithCancel(context.Background())
|
|
t.Cleanup(cancelParent)
|
|
homeCtx, cancelHome := context.WithCancel(parentCtx)
|
|
t.Cleanup(cancelHome)
|
|
lifetimeCtx, cancelLifetime := context.WithCancel(homeCtx)
|
|
t.Cleanup(cancelLifetime)
|
|
service := &Service{
|
|
cfg: baseCfg,
|
|
coreManager: manager,
|
|
pluginHost: pluginhost.New(),
|
|
homeGeneration: 1,
|
|
}
|
|
queue := newHomeConfigWorkQueue()
|
|
queue.enqueue([]byte("routing:\n strategy: fill-first\n"))
|
|
ready := make(chan struct{})
|
|
close(ready)
|
|
published := atomic.Bool{}
|
|
cancelBound := atomic.Int64{}
|
|
cancelBound.Store(int64(time.Second))
|
|
workerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(workerDone)
|
|
service.runHomeConfigWorker(lifetimeCtx, homeCtx, 1, client, executionregistry.New(), queue, ready, &published, &cancelBound)
|
|
}()
|
|
|
|
select {
|
|
case <-modelRefreshStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Home config worker did not start Antigravity model refresh")
|
|
}
|
|
cancelLifetime()
|
|
select {
|
|
case <-workerDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Home config worker did not stop after model refresh cancellation")
|
|
}
|
|
if published.Load() {
|
|
t.Fatal("canceled model refresh published Home runtime")
|
|
}
|
|
service.homeMu.Lock()
|
|
publishedClient := service.homeClient
|
|
publishedRegistry := service.homeRegistry
|
|
service.homeMu.Unlock()
|
|
if publishedClient != nil || publishedRegistry != nil {
|
|
t.Fatal("canceled model refresh exposed Home runtime state")
|
|
}
|
|
}
|
|
|
|
func TestHomeConfigWorkerRetriesStageFailureForSameQueuedConfig(t *testing.T) {
|
|
client, _ := newHomePluginTaskTestClient(t, nil, 0)
|
|
baseCfg := &config.Config{}
|
|
baseCfg.Home.Enabled = true
|
|
baseCfg.Routing.Strategy = "round-robin"
|
|
var attempts atomic.Int32
|
|
service := &Service{
|
|
cfg: baseCfg,
|
|
homeGeneration: 1,
|
|
homePluginSyncFetch: func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
|
if attempts.Add(1) == 1 {
|
|
return sdkpluginstore.PluginSyncResponse{}, fmt.Errorf("plugin sync unavailable")
|
|
}
|
|
return sdkpluginstore.PluginSyncResponse{
|
|
SchemaVersion: sdkpluginstore.PluginSyncSchemaVersion,
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
}, nil
|
|
},
|
|
}
|
|
queue := newHomeConfigWorkQueue()
|
|
queue.enqueue([]byte("plugins:\n enabled: true\nrouting:\n strategy: fill-first\n"))
|
|
ready := make(chan struct{})
|
|
close(ready)
|
|
lifetimeCtx, cancelLifetime := context.WithCancel(context.Background())
|
|
t.Cleanup(cancelLifetime)
|
|
cancelBound := atomic.Int64{}
|
|
cancelBound.Store(int64(time.Second))
|
|
published := atomic.Bool{}
|
|
published.Store(true)
|
|
workerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(workerDone)
|
|
service.runHomeConfigWorker(lifetimeCtx, context.Background(), 1, client, executionregistry.New(), queue, ready, &published, &cancelBound)
|
|
}()
|
|
|
|
deadline := time.Now().Add(time.Second)
|
|
for time.Now().Before(deadline) {
|
|
service.cfgMu.RLock()
|
|
strategy := service.cfg.Routing.Strategy
|
|
service.cfgMu.RUnlock()
|
|
if attempts.Load() >= 2 && strategy == "fill-first" {
|
|
cancelLifetime()
|
|
select {
|
|
case <-workerDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("config worker did not stop after cancellation")
|
|
}
|
|
return
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
cancelLifetime()
|
|
<-workerDone
|
|
t.Fatalf("stage attempts = %d and config was not applied after retry", attempts.Load())
|
|
}
|
|
|
|
func TestServiceInitialOverlayStagesPluginWritesUntilReady(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
pluginSync := make(chan struct{})
|
|
pluginStatus := make(chan struct{}, 2)
|
|
pluginTasks := make(chan struct{})
|
|
freshCommandProbe := make(chan struct{})
|
|
allowAck := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveInitialOverlayPluginConnection(conn, pluginSync, pluginStatus, pluginTasks, freshCommandProbe, allowAck, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
host, portText, errSplit := net.SplitHostPort(listener.Addr().String())
|
|
if errSplit != nil {
|
|
t.Fatalf("split listener address: %v", errSplit)
|
|
}
|
|
port, errPort := strconv.Atoi(portText)
|
|
if errPort != nil {
|
|
t.Fatalf("parse port: %v", errPort)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.Host = host
|
|
cfg.Home.Port = port
|
|
cfg.Home.NodeID = "node-1"
|
|
cfg.Home.DisableClusterDiscovery = true
|
|
cfg.Plugins.Enabled = true
|
|
cfg.Plugins.Dir = t.TempDir()
|
|
var deletes atomic.Int32
|
|
service := &Service{cfg: cfg, homePluginDeleteTask: func(_ context.Context, _ *config.Config, task home.PluginTask) homeplugins.SyncReport {
|
|
deletes.Add(1)
|
|
return homeplugins.DeleteWithReport(context.Background(), nil, nil, task.ID, task.PluginID)
|
|
}}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
service.startHomeSubscriber(ctx)
|
|
|
|
for name, observed := range map[string]<-chan struct{}{
|
|
"plugin sync": pluginSync,
|
|
"plugin tasks": pluginTasks,
|
|
"plugin status": pluginStatus,
|
|
} {
|
|
select {
|
|
case <-observed:
|
|
t.Fatalf("initial overlay staged %s before subscription ACK and fresh command probe", name)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
if gotDeletes := deletes.Load(); gotDeletes != 0 {
|
|
t.Fatalf("initial overlay executed %d plugin deletes before subscription ACK and fresh command probe", gotDeletes)
|
|
}
|
|
service.homeMu.Lock()
|
|
client := service.homeClient
|
|
registry := service.homeRegistry
|
|
service.homeMu.Unlock()
|
|
if client != nil || registry != nil || home.Current() != nil {
|
|
t.Fatal("initial overlay exposed its Home client or registry before subscription ACK")
|
|
}
|
|
|
|
close(allowAck)
|
|
select {
|
|
case <-freshCommandProbe:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("subscription ACK did not rebuild and probe a fresh command connection")
|
|
}
|
|
for name, observed := range map[string]<-chan struct{}{
|
|
"plugin sync": pluginSync,
|
|
"plugin tasks": pluginTasks,
|
|
} {
|
|
select {
|
|
case <-observed:
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("ready Home lifetime did not stage %s after subscription ACK and fresh command probe", name)
|
|
}
|
|
}
|
|
for range 2 {
|
|
select {
|
|
case <-pluginStatus:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("ready Home lifetime did not flush staged plugin reports")
|
|
}
|
|
}
|
|
if gotDeletes := deletes.Load(); gotDeletes != 1 {
|
|
t.Fatalf("ready Home lifetime executed %d plugin deletes, want 1", gotDeletes)
|
|
}
|
|
if waitForServiceRegistry(t, service, time.Second) == nil || home.Current() == nil {
|
|
t.Fatal("subscription ACK did not expose the Home client and registry")
|
|
}
|
|
}
|
|
|
|
func TestServiceDiscardsStalePreACKPluginWork(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
firstSubscribed := make(chan struct{})
|
|
secondSubscribed := make(chan struct{})
|
|
allowSecondAck := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
serverDone := make(chan struct{})
|
|
var subscriptions atomic.Int32
|
|
var pluginWrites atomic.Int32
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveStalePreACKPluginConnection(conn, &subscriptions, &pluginWrites, firstSubscribed, secondSubscribed, allowSecondAck, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
host, portText, errSplit := net.SplitHostPort(listener.Addr().String())
|
|
if errSplit != nil {
|
|
t.Fatalf("split listener address: %v", errSplit)
|
|
}
|
|
port, errPort := strconv.Atoi(portText)
|
|
if errPort != nil {
|
|
t.Fatalf("parse port: %v", errPort)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.Host = host
|
|
cfg.Home.Port = port
|
|
cfg.Home.NodeID = "node-1"
|
|
cfg.Home.DisableClusterDiscovery = true
|
|
cfg.Plugins.Enabled = true
|
|
cfg.Plugins.Dir = t.TempDir()
|
|
service := &Service{cfg: cfg}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
service.startHomeSubscriber(ctx)
|
|
select {
|
|
case <-firstSubscribed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first subscriber did not stage plugin work before ACK")
|
|
}
|
|
|
|
replaced := make(chan struct{})
|
|
go func() {
|
|
service.startHomeSubscriber(ctx)
|
|
close(replaced)
|
|
}()
|
|
select {
|
|
case <-secondSubscribed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement subscriber did not reach subscription ACK")
|
|
}
|
|
if got := pluginWrites.Load(); got != 0 {
|
|
t.Fatalf("stale pre-ACK lifetime flushed %d plugin reports", got)
|
|
}
|
|
close(allowSecondAck)
|
|
deadline := time.Now().Add(time.Second)
|
|
for pluginWrites.Load() != 1 && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if got := pluginWrites.Load(); got != 1 {
|
|
t.Fatalf("replacement lifetime plugin reports = %d, want 1", got)
|
|
}
|
|
if waitForServiceRegistry(t, service, time.Second) == nil {
|
|
t.Fatal("replacement subscription did not expose a ready registry")
|
|
}
|
|
select {
|
|
case <-replaced:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement subscriber did not finish setup")
|
|
}
|
|
}
|
|
|
|
func TestServiceExplicitReplacementDrainsPendingAndScopeBeforeStartingNewLifetime(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
firstAck := make(chan struct{})
|
|
loseFirst := make(chan struct{})
|
|
secondSubscribe := make(chan struct{})
|
|
var secondSubscribeOnce sync.Once
|
|
allowSecondAck := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
var subscriptionMu sync.Mutex
|
|
subscriptions := 0
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveRegistryTestHomeConnection(conn, &subscriptionMu, &subscriptions, firstAck, loseFirst, secondSubscribe, &secondSubscribeOnce, allowSecondAck, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
service := newRegistryTestService(t, listener)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
service.startHomeSubscriber(ctx)
|
|
select {
|
|
case <-firstAck:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first subscription was not acknowledged")
|
|
}
|
|
registry := waitForServiceRegistry(t, service, time.Second)
|
|
pending, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
scopePending, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
scope, errInstall := registry.Install(scopePending, executionregistry.ScopeSpec{})
|
|
if errInstall != nil {
|
|
t.Fatal(errInstall)
|
|
}
|
|
resourceClosed := make(chan struct{})
|
|
if errBind := scope.Bind(func() error {
|
|
close(resourceClosed)
|
|
go scope.End("canceled")
|
|
return nil
|
|
}); errBind != nil {
|
|
t.Fatal(errBind)
|
|
}
|
|
|
|
replaced := make(chan struct{})
|
|
go func() {
|
|
service.startHomeSubscriber(ctx)
|
|
close(replaced)
|
|
}()
|
|
select {
|
|
case <-resourceClosed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("explicit replacement did not start draining the active scope")
|
|
}
|
|
select {
|
|
case <-secondSubscribe:
|
|
t.Fatal("new subscriber started before the old pending dispatch drained")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
pending.End()
|
|
select {
|
|
case <-replaced:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("explicit replacement did not finish after pending dispatch ended")
|
|
}
|
|
select {
|
|
case <-secondSubscribe:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("new subscriber did not start after successful drain")
|
|
}
|
|
}
|
|
|
|
func TestServiceReplacementWaitsForBlockedDrainSupervisorExit(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
firstAck := make(chan struct{})
|
|
loseFirst := make(chan struct{})
|
|
secondSubscribe := make(chan struct{})
|
|
var secondSubscribeOnce sync.Once
|
|
allowSecondAck := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
var subscriptionMu sync.Mutex
|
|
subscriptions := 0
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveRegistryTestHomeConnection(conn, &subscriptionMu, &subscriptions, firstAck, loseFirst, secondSubscribe, &secondSubscribeOnce, allowSecondAck, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
service := newRegistryTestService(t, listener)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
service.startHomeSubscriber(ctx)
|
|
select {
|
|
case <-firstAck:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first subscription was not acknowledged")
|
|
}
|
|
service.homeLifecycleMu.Lock()
|
|
firstDone := service.homeSupervisor.done
|
|
service.homeLifecycleMu.Unlock()
|
|
registry := waitForServiceRegistry(t, service, time.Second)
|
|
pending, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
scopePending, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
scope, errInstall := registry.Install(scopePending, executionregistry.ScopeSpec{})
|
|
if errInstall != nil {
|
|
t.Fatal(errInstall)
|
|
}
|
|
resourceClosed := make(chan struct{})
|
|
if errBind := scope.Bind(func() error {
|
|
close(resourceClosed)
|
|
go scope.End("canceled")
|
|
return nil
|
|
}); errBind != nil {
|
|
t.Fatal(errBind)
|
|
}
|
|
|
|
replaced := make(chan struct{})
|
|
go func() {
|
|
service.startHomeSubscriber(ctx)
|
|
close(replaced)
|
|
}()
|
|
select {
|
|
case <-resourceClosed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement did not begin draining the active scope")
|
|
}
|
|
select {
|
|
case <-firstDone:
|
|
t.Fatal("supervisor exited before the pending dispatch drained")
|
|
case <-secondSubscribe:
|
|
t.Fatal("replacement subscriber started before the old supervisor exited")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
pending.End()
|
|
select {
|
|
case <-firstDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("old supervisor did not exit after drain completed")
|
|
}
|
|
select {
|
|
case <-secondSubscribe:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement subscriber did not start after old supervisor exit")
|
|
}
|
|
select {
|
|
case <-replaced:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement start did not return")
|
|
}
|
|
}
|
|
|
|
func TestServiceExplicitReplacementCancelsRunWhenDrainTimesOut(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
firstAck := make(chan struct{})
|
|
loseFirst := make(chan struct{})
|
|
secondSubscribe := make(chan struct{})
|
|
var secondSubscribeOnce sync.Once
|
|
allowSecondAck := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
var subscriptionMu sync.Mutex
|
|
subscriptions := 0
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveRegistryTestHomeConnection(conn, &subscriptionMu, &subscriptions, firstAck, loseFirst, secondSubscribe, &secondSubscribeOnce, allowSecondAck, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
service := newRegistryTestService(t, listener)
|
|
serviceCtx, cancelService := context.WithCancel(context.Background())
|
|
t.Cleanup(cancelService)
|
|
service.homeMu.Lock()
|
|
service.runCancel = cancelService
|
|
service.homeMu.Unlock()
|
|
service.startHomeSubscriber(serviceCtx)
|
|
select {
|
|
case <-firstAck:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first subscription was not acknowledged")
|
|
}
|
|
registry := waitForServiceRegistry(t, service, time.Second)
|
|
pending, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
scope, errInstall := registry.Install(pending, executionregistry.ScopeSpec{})
|
|
if errInstall != nil {
|
|
t.Fatal(errInstall)
|
|
}
|
|
resourceClosed := make(chan struct{})
|
|
release := make(chan struct{})
|
|
if errBind := scope.Bind(func() error {
|
|
close(resourceClosed)
|
|
<-release
|
|
return nil
|
|
}); errBind != nil {
|
|
t.Fatal(errBind)
|
|
}
|
|
|
|
go service.startHomeSubscriber(serviceCtx)
|
|
select {
|
|
case <-resourceClosed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("explicit replacement did not start draining the blocking scope")
|
|
}
|
|
select {
|
|
case <-serviceCtx.Done():
|
|
case <-time.After(time.Second):
|
|
t.Fatal("explicit replacement did not cancel the Service run after drain timeout")
|
|
}
|
|
select {
|
|
case <-secondSubscribe:
|
|
t.Fatal("new subscriber started after explicit replacement drain timeout")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
close(release)
|
|
scope.End("test cleanup")
|
|
}
|
|
|
|
func TestServiceKeepsRegistryAcrossHeartbeatFailoverAndExposesOnlyAfterNewACK(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
firstAck := make(chan struct{})
|
|
loseFirst := make(chan struct{})
|
|
secondSubscribe := make(chan struct{})
|
|
var secondSubscribeOnce sync.Once
|
|
allowSecondAck := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
var subscriptionMu sync.Mutex
|
|
subscriptions := 0
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveRegistryTestHomeConnection(conn, &subscriptionMu, &subscriptions, firstAck, loseFirst, secondSubscribe, &secondSubscribeOnce, allowSecondAck, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
host, portText, errSplit := net.SplitHostPort(listener.Addr().String())
|
|
if errSplit != nil {
|
|
t.Fatalf("split listener address: %v", errSplit)
|
|
}
|
|
port, errPort := strconv.Atoi(portText)
|
|
if errPort != nil {
|
|
t.Fatalf("parse port: %v", errPort)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.Host = host
|
|
cfg.Home.Port = port
|
|
cfg.Home.DisableClusterDiscovery = true
|
|
service := &Service{cfg: cfg}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
service.startHomeSubscriber(ctx)
|
|
|
|
select {
|
|
case <-firstAck:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first subscription was not acknowledged")
|
|
}
|
|
firstRegistry := waitForServiceRegistry(t, service, time.Second)
|
|
if home.Current() == nil {
|
|
t.Fatal("first client was not exposed after subscription ACK")
|
|
}
|
|
|
|
close(loseFirst)
|
|
select {
|
|
case <-secondSubscribe:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("second subscription did not start after heartbeat loss")
|
|
}
|
|
service.homeMu.Lock()
|
|
exposedRegistry := service.homeRegistry
|
|
exposedClient := service.homeClient
|
|
service.homeMu.Unlock()
|
|
if exposedRegistry != nil || exposedClient != nil || home.Current() != nil {
|
|
t.Fatal("old subscriber lifetime remained exposed before the replacement ACK")
|
|
}
|
|
|
|
close(allowSecondAck)
|
|
secondRegistry := waitForServiceRegistry(t, service, time.Second)
|
|
if secondRegistry != firstRegistry {
|
|
t.Fatal("heartbeat failover replaced the execution registry")
|
|
}
|
|
if home.Current() == nil {
|
|
t.Fatal("replacement client was not exposed after the replacement ACK")
|
|
}
|
|
}
|
|
|
|
func TestServicePreservesActiveScopeDuringPreACKFailoverRetries(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
firstAck := make(chan struct{})
|
|
loseFirst := make(chan struct{})
|
|
resourceClosed := make(chan struct{})
|
|
preAckAttempts := make(chan time.Time, 2)
|
|
finalSubscribe := make(chan struct{})
|
|
allowFinalAck := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
var configMu sync.Mutex
|
|
configRequests := 0
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveSuccessChainHomeConnection(conn, &configMu, &configRequests, firstAck, loseFirst, preAckAttempts, finalSubscribe, allowFinalAck, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
service := newRegistryTestService(t, listener)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
service.startHomeSubscriber(ctx)
|
|
select {
|
|
case <-firstAck:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first subscription was not acknowledged")
|
|
}
|
|
firstRegistry := waitForServiceRegistry(t, service, time.Second)
|
|
pending, errBegin := firstRegistry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
scope, errInstall := firstRegistry.Install(pending, executionregistry.ScopeSpec{})
|
|
if errInstall != nil {
|
|
t.Fatal(errInstall)
|
|
}
|
|
if errBind := scope.Bind(func() error {
|
|
close(resourceClosed)
|
|
return nil
|
|
}); errBind != nil {
|
|
t.Fatal(errBind)
|
|
}
|
|
|
|
close(loseFirst)
|
|
select {
|
|
case <-resourceClosed:
|
|
t.Fatal("heartbeat failover drained the active scope")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
firstPreAck := <-preAckAttempts
|
|
secondPreAck := <-preAckAttempts
|
|
if retryDelay := secondPreAck.Sub(firstPreAck); retryDelay < 75*time.Millisecond {
|
|
t.Fatalf("pre-ACK retry delay = %v, want at least 75ms", retryDelay)
|
|
}
|
|
select {
|
|
case <-finalSubscribe:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("subscriber did not retry after pre-ACK rejections")
|
|
}
|
|
service.homeMu.Lock()
|
|
exposedRegistry := service.homeRegistry
|
|
exposedClient := service.homeClient
|
|
service.homeMu.Unlock()
|
|
if exposedRegistry != nil || exposedClient != nil || home.Current() != nil {
|
|
t.Fatal("new Home lifetime was exposed before its subscription ACK")
|
|
}
|
|
|
|
close(allowFinalAck)
|
|
secondRegistry := waitForServiceRegistry(t, service, time.Second)
|
|
if secondRegistry != firstRegistry || home.Current() == nil {
|
|
t.Fatal("new Home lifetime was not exposed only after its subscription ACK")
|
|
}
|
|
scope.End("completed")
|
|
}
|
|
|
|
func TestServiceHeartbeatFailoverDoesNotDrainBlockingScope(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
firstAck := make(chan struct{})
|
|
loseFirst := make(chan struct{})
|
|
secondSubscribe := make(chan struct{})
|
|
var secondSubscribeOnce sync.Once
|
|
allowSecondAck := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
var subscriptionMu sync.Mutex
|
|
subscriptions := 0
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveRegistryTestHomeConnection(conn, &subscriptionMu, &subscriptions, firstAck, loseFirst, secondSubscribe, &secondSubscribeOnce, allowSecondAck, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
host, portText, errSplit := net.SplitHostPort(listener.Addr().String())
|
|
if errSplit != nil {
|
|
t.Fatalf("split listener address: %v", errSplit)
|
|
}
|
|
port, errPort := strconv.Atoi(portText)
|
|
if errPort != nil {
|
|
t.Fatalf("parse port: %v", errPort)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.Host = host
|
|
cfg.Home.Port = port
|
|
cfg.Home.DisableClusterDiscovery = true
|
|
service := &Service{cfg: cfg}
|
|
serviceCtx, cancelService := context.WithCancel(context.Background())
|
|
t.Cleanup(cancelService)
|
|
service.homeMu.Lock()
|
|
service.runCancel = cancelService
|
|
service.homeMu.Unlock()
|
|
service.startHomeSubscriber(serviceCtx)
|
|
|
|
select {
|
|
case <-firstAck:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first subscription was not acknowledged")
|
|
}
|
|
registry := waitForServiceRegistry(t, service, time.Second)
|
|
pending, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
scope, errInstall := registry.Install(pending, executionregistry.ScopeSpec{})
|
|
if errInstall != nil {
|
|
t.Fatal(errInstall)
|
|
}
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
if errBind := scope.Bind(func() error {
|
|
close(started)
|
|
<-release
|
|
return nil
|
|
}); errBind != nil {
|
|
t.Fatal(errBind)
|
|
}
|
|
|
|
close(loseFirst)
|
|
select {
|
|
case <-started:
|
|
t.Fatal("heartbeat failover started draining the blocking scope")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
select {
|
|
case <-secondSubscribe:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("new subscription did not start while the old scope remained active")
|
|
}
|
|
service.homeMu.Lock()
|
|
exposedRegistry := service.homeRegistry
|
|
service.homeMu.Unlock()
|
|
if exposedRegistry != nil {
|
|
t.Fatal("registry was exposed before the replacement ACK")
|
|
}
|
|
close(allowSecondAck)
|
|
if nextRegistry := waitForServiceRegistry(t, service, time.Second); nextRegistry != registry {
|
|
t.Fatal("heartbeat failover replaced the registry containing the active scope")
|
|
}
|
|
select {
|
|
case <-serviceCtx.Done():
|
|
t.Fatal("heartbeat failover canceled the service run")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
close(release)
|
|
scope.End("test cleanup")
|
|
}
|
|
|
|
func TestServiceShutdownDrainsDetachedRegistryDuringRetry(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
firstAck := make(chan struct{})
|
|
loseFirst := make(chan struct{})
|
|
secondSubscribe := make(chan struct{})
|
|
var secondSubscribeOnce sync.Once
|
|
allowSecondAck := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
var subscriptionMu sync.Mutex
|
|
subscriptions := 0
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveRegistryTestHomeConnection(conn, &subscriptionMu, &subscriptions, firstAck, loseFirst, secondSubscribe, &secondSubscribeOnce, allowSecondAck, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
service := newRegistryTestService(t, listener)
|
|
serviceCtx, cancelService := context.WithCancel(context.Background())
|
|
t.Cleanup(cancelService)
|
|
service.homeMu.Lock()
|
|
service.runCancel = cancelService
|
|
service.homeMu.Unlock()
|
|
service.startHomeSubscriber(serviceCtx)
|
|
|
|
select {
|
|
case <-firstAck:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first subscription was not acknowledged")
|
|
}
|
|
registry := waitForServiceRegistry(t, service, time.Second)
|
|
pendingRetry, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
pendingScope, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
scope, errInstall := registry.Install(pendingScope, executionregistry.ScopeSpec{})
|
|
if errInstall != nil {
|
|
t.Fatal(errInstall)
|
|
}
|
|
resourceClosed := make(chan struct{})
|
|
if errBind := scope.Bind(func() error {
|
|
close(resourceClosed)
|
|
go scope.End("shutdown")
|
|
return nil
|
|
}); errBind != nil {
|
|
t.Fatal(errBind)
|
|
}
|
|
t.Cleanup(func() {
|
|
pendingRetry.End()
|
|
scope.End("test cleanup")
|
|
})
|
|
|
|
service.homeMu.Lock()
|
|
client := service.homeClient
|
|
service.homeMu.Unlock()
|
|
if client == nil {
|
|
t.Fatal("ready Home client is unavailable")
|
|
}
|
|
close(loseFirst)
|
|
deadline := time.After(time.Second)
|
|
for {
|
|
errRelease := client.PushConcurrencyRelease(context.Background(), home.ConcurrencyReleaseFrame{CredentialID: "cred-a", Model: "model-a", ReleaseSeq: 1})
|
|
if errors.Is(errRelease, home.ErrDispatchFenced) {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("subscriber retry did not close the previous Home client")
|
|
case <-time.After(time.Millisecond):
|
|
}
|
|
}
|
|
|
|
shutdownDone := make(chan error, 1)
|
|
go func() {
|
|
shutdownDone <- service.Shutdown(context.Background())
|
|
}()
|
|
pendingRetry.End()
|
|
|
|
select {
|
|
case <-resourceClosed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("shutdown did not drain the detached execution registry")
|
|
}
|
|
select {
|
|
case errShutdown := <-shutdownDone:
|
|
if errShutdown != nil {
|
|
t.Fatalf("Shutdown() error = %v", errShutdown)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Shutdown() did not complete after draining the detached registry")
|
|
}
|
|
}
|
|
|
|
func TestServiceAmbiguousDispatchDrainsRegistryBeforeRetry(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
firstAck := make(chan struct{})
|
|
loseFirst := make(chan struct{})
|
|
secondSubscribe := make(chan struct{})
|
|
var secondSubscribeOnce sync.Once
|
|
allowSecondAck := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
var subscriptionMu sync.Mutex
|
|
subscriptions := 0
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveRegistryTestHomeConnection(conn, &subscriptionMu, &subscriptions, firstAck, loseFirst, secondSubscribe, &secondSubscribeOnce, allowSecondAck, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
service := newRegistryTestService(t, listener)
|
|
serviceCtx, cancelService := context.WithCancel(context.Background())
|
|
t.Cleanup(cancelService)
|
|
service.homeMu.Lock()
|
|
service.runCancel = cancelService
|
|
service.homeMu.Unlock()
|
|
service.startHomeSubscriber(serviceCtx)
|
|
|
|
select {
|
|
case <-firstAck:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first subscription was not acknowledged")
|
|
}
|
|
registry := waitForServiceRegistry(t, service, time.Second)
|
|
pending, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
scope, errInstall := registry.Install(pending, executionregistry.ScopeSpec{})
|
|
if errInstall != nil {
|
|
t.Fatal(errInstall)
|
|
}
|
|
resourceClosed := make(chan struct{})
|
|
if errBind := scope.Bind(func() error {
|
|
close(resourceClosed)
|
|
go scope.End("ambiguous dispatch")
|
|
return nil
|
|
}); errBind != nil {
|
|
t.Fatal(errBind)
|
|
}
|
|
|
|
service.homeMu.Lock()
|
|
client := service.homeClient
|
|
service.homeMu.Unlock()
|
|
if client == nil {
|
|
t.Fatal("ready Home client is unavailable")
|
|
}
|
|
client.AbortAmbiguousDispatch()
|
|
select {
|
|
case <-resourceClosed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("ambiguous dispatch did not drain the active registry")
|
|
}
|
|
select {
|
|
case <-secondSubscribe:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("subscriber did not retry after ambiguous dispatch drain")
|
|
}
|
|
service.homeMu.Lock()
|
|
exposedRegistry := service.homeRegistry
|
|
service.homeMu.Unlock()
|
|
if exposedRegistry != nil {
|
|
t.Fatal("replacement registry was exposed before its subscription ACK")
|
|
}
|
|
|
|
close(allowSecondAck)
|
|
nextRegistry := waitForServiceRegistry(t, service, time.Second)
|
|
if nextRegistry == registry {
|
|
t.Fatal("ambiguous dispatch reused the drained execution registry")
|
|
}
|
|
select {
|
|
case <-serviceCtx.Done():
|
|
t.Fatal("successful ambiguous dispatch recovery canceled the service run")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func TestServiceBacksOffAfterRepeatedPreAckFailures(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
attempts := make(chan time.Time, 8)
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go servePreAckFailureConnection(conn, attempts)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
host, portText, errSplit := net.SplitHostPort(listener.Addr().String())
|
|
if errSplit != nil {
|
|
t.Fatalf("split listener address: %v", errSplit)
|
|
}
|
|
port, errPort := strconv.Atoi(portText)
|
|
if errPort != nil {
|
|
t.Fatalf("parse port: %v", errPort)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.Host = host
|
|
cfg.Home.Port = port
|
|
cfg.Home.DisableClusterDiscovery = true
|
|
service := &Service{cfg: cfg}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
service.startHomeSubscriber(ctx)
|
|
|
|
firstAttempt := <-attempts
|
|
secondAttempt := <-attempts
|
|
if retryDelay := secondAttempt.Sub(firstAttempt); retryDelay < 75*time.Millisecond {
|
|
t.Fatalf("pre-ACK retry delay = %v, want at least 75ms", retryDelay)
|
|
}
|
|
cancel()
|
|
select {
|
|
case thirdAttempt := <-attempts:
|
|
t.Fatalf("pre-ACK retry continued after cancellation at %v", thirdAttempt)
|
|
case <-time.After(150 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func TestServiceHeartbeatLossCancelsBlockedConfigFinalizationWithoutDrainingRegistry(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
update := make(chan struct{})
|
|
statusStarted := make(chan struct{})
|
|
statusRelease := make(chan struct{})
|
|
secondConfig := make(chan struct{})
|
|
var configRequests atomic.Int32
|
|
var statusWrites atomic.Int32
|
|
stop := make(chan struct{})
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveBlockedFinalizationConnection(conn, &configRequests, &statusWrites, update, statusStarted, statusRelease, secondConfig, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
close(statusRelease)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
service := newRegistryTestService(t, listener)
|
|
service.cfg.Home.NodeID = "node-1"
|
|
service.homePluginSyncKey = homePluginSyncKey(service.cfg)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
service.startHomeSubscriber(ctx)
|
|
registry := waitForServiceRegistry(t, service, time.Second)
|
|
pending, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
scope, errInstall := registry.Install(pending, executionregistry.ScopeSpec{})
|
|
if errInstall != nil {
|
|
t.Fatal(errInstall)
|
|
}
|
|
resourceClosed := make(chan struct{})
|
|
if errBind := scope.Bind(func() error {
|
|
close(resourceClosed)
|
|
go scope.End("canceled")
|
|
return nil
|
|
}); errBind != nil {
|
|
t.Fatal(errBind)
|
|
}
|
|
|
|
close(update)
|
|
select {
|
|
case <-statusStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("updated config did not enter blocked finalization")
|
|
}
|
|
select {
|
|
case <-resourceClosed:
|
|
t.Fatal("heartbeat loss drained the active execution")
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
select {
|
|
case <-secondConfig:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("subscriber did not retry after heartbeat loss")
|
|
}
|
|
service.homeMu.Lock()
|
|
currentRegistry := service.homeRegistry
|
|
currentClient := service.homeClient
|
|
service.homeMu.Unlock()
|
|
if currentRegistry != nil || currentClient != nil || home.Current() != nil {
|
|
t.Fatal("heartbeat-lost lifetime left a published Home client or registry")
|
|
}
|
|
scope.End("completed")
|
|
}
|
|
|
|
func TestServiceConfigWorkerFinalizesRapidUpdatesInOrder(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
updates := make(chan struct{})
|
|
statuses := make(chan homeplugins.SyncReport, 4)
|
|
var taskRequests atomic.Int32
|
|
stop := make(chan struct{})
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveOrderedConfigUpdatesConnection(conn, &taskRequests, updates, statuses, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
service := newRegistryTestService(t, listener)
|
|
service.cfg.Home.NodeID = "node-1"
|
|
service.homePluginSyncKey = homePluginSyncKey(service.cfg)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
service.startHomeSubscriber(ctx)
|
|
waitForServiceRegistry(t, service, time.Second)
|
|
close(updates)
|
|
|
|
gotTaskIDs := make([]uint, 0, 2)
|
|
for len(gotTaskIDs) < 2 {
|
|
select {
|
|
case report := <-statuses:
|
|
if report.TaskID != 0 {
|
|
gotTaskIDs = append(gotTaskIDs, report.TaskID)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("rapid config updates did not finalize all ordered task work")
|
|
}
|
|
}
|
|
wantTaskIDs := []uint{1, 2}
|
|
for index := range wantTaskIDs {
|
|
if gotTaskIDs[index] != wantTaskIDs[index] {
|
|
t.Fatalf("plugin task status IDs = %v, want %v", gotTaskIDs, wantTaskIDs)
|
|
}
|
|
}
|
|
}
|
|
|
|
func serveBlockedFinalizationConnection(conn net.Conn, configRequests, statusWrites *atomic.Int32, update <-chan struct{}, statusStarted chan<- struct{}, statusRelease <-chan struct{}, secondConfig chan<- struct{}, stop <-chan struct{}) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
if _, errWrite := io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "config":
|
|
if configRequests.Add(1) > 1 {
|
|
select {
|
|
case secondConfig <- struct{}{}:
|
|
case <-stop:
|
|
}
|
|
_, _ = io.WriteString(conn, "-ERR unavailable\r\n")
|
|
return
|
|
}
|
|
writeRegistryTestConfig(conn, "credential-concurrency:\n lifecycle-config-revision: 1\n cpa-heartbeat-timeout: 100ms\n cpa-cancel-bound: 100ms\n")
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-tasks":
|
|
_, _ = io.WriteString(conn, "$-1\r\n")
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-sync":
|
|
payload := fmt.Sprintf(`{"schema_version":1,"expires_at":%q,"items":[]}`, time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano))
|
|
writeRegistryTestConfig(conn, payload)
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "RPUSH") && args[1] == "plugin-status":
|
|
if statusWrites.Add(1) == 1 {
|
|
if _, errWrite := io.WriteString(conn, ":1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
select {
|
|
case statusStarted <- struct{}{}:
|
|
case <-stop:
|
|
return
|
|
}
|
|
select {
|
|
case <-statusRelease:
|
|
return
|
|
case <-stop:
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "SUBSCRIBE") && args[1] == "config":
|
|
if _, errWrite := io.WriteString(conn, "*3\r\n$9\r\nsubscribe\r\n$6\r\nconfig\r\n:1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
select {
|
|
case <-update:
|
|
writeRegistryTestMessage(conn, "credential-concurrency:\n lifecycle-config-revision: 2\n cpa-heartbeat-timeout: 100ms\n cpa-cancel-bound: 100ms\nplugins:\n enabled: true\n")
|
|
case <-stop:
|
|
return
|
|
}
|
|
<-stop
|
|
return
|
|
default:
|
|
if _, errWrite := io.WriteString(conn, "+OK\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func serveOrderedConfigUpdatesConnection(conn net.Conn, taskRequests *atomic.Int32, updates <-chan struct{}, statuses chan<- homeplugins.SyncReport, stop <-chan struct{}) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
_, _ = io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n")
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "config":
|
|
writeRegistryTestConfig(conn, "credential-concurrency:\n lifecycle-config-revision: 1\n cpa-heartbeat-timeout: 1s\n cpa-cancel-bound: 100ms\n")
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-sync":
|
|
payload := fmt.Sprintf(`{"schema_version":1,"expires_at":%q,"items":[]}`, time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano))
|
|
writeRegistryTestConfig(conn, payload)
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-tasks":
|
|
request := taskRequests.Add(1)
|
|
if request == 1 {
|
|
_, _ = io.WriteString(conn, "$-1\r\n")
|
|
continue
|
|
}
|
|
payload := fmt.Sprintf(`[{"id":%d,"operation":"delete","plugin_id":"plugin-%d"}]`, request-1, request-1)
|
|
writeRegistryTestConfig(conn, payload)
|
|
case len(args) >= 3 && strings.EqualFold(args[0], "RPUSH") && args[1] == "plugin-status":
|
|
var report homeplugins.SyncReport
|
|
if errUnmarshal := json.Unmarshal([]byte(args[2]), &report); errUnmarshal != nil {
|
|
return
|
|
}
|
|
select {
|
|
case statuses <- report:
|
|
case <-stop:
|
|
return
|
|
}
|
|
_, _ = io.WriteString(conn, ":1\r\n")
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "SUBSCRIBE") && args[1] == "config":
|
|
if _, errWrite := io.WriteString(conn, "*3\r\n$9\r\nsubscribe\r\n$6\r\nconfig\r\n:1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
select {
|
|
case <-updates:
|
|
writeRegistryTestMessage(conn, "credential-concurrency:\n lifecycle-config-revision: 2\n cpa-heartbeat-timeout: 1s\n cpa-cancel-bound: 100ms\nplugins:\n enabled: true\n")
|
|
writeRegistryTestMessage(conn, "credential-concurrency:\n lifecycle-config-revision: 3\n cpa-heartbeat-timeout: 1s\n cpa-cancel-bound: 100ms\n")
|
|
case <-stop:
|
|
return
|
|
}
|
|
<-stop
|
|
return
|
|
default:
|
|
_, _ = io.WriteString(conn, "+OK\r\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeRegistryTestConfig(conn net.Conn, payload string) {
|
|
_, _ = io.WriteString(conn, fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload))
|
|
}
|
|
|
|
func writeRegistryTestMessage(conn net.Conn, payload string) {
|
|
_, _ = io.WriteString(conn, fmt.Sprintf("*3\r\n$7\r\nmessage\r\n$6\r\nconfig\r\n$%d\r\n%s\r\n", len(payload), payload))
|
|
}
|
|
|
|
func newRegistryTestService(t *testing.T, listener net.Listener) *Service {
|
|
t.Helper()
|
|
host, portText, errSplit := net.SplitHostPort(listener.Addr().String())
|
|
if errSplit != nil {
|
|
t.Fatalf("split listener address: %v", errSplit)
|
|
}
|
|
port, errPort := strconv.Atoi(portText)
|
|
if errPort != nil {
|
|
t.Fatalf("parse port: %v", errPort)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.Host = host
|
|
cfg.Home.Port = port
|
|
cfg.Home.DisableClusterDiscovery = true
|
|
return &Service{cfg: cfg}
|
|
}
|
|
|
|
func waitForServiceRegistry(t *testing.T, service *Service, timeout time.Duration) *executionregistry.Registry {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
service.homeMu.Lock()
|
|
registry := service.homeRegistry
|
|
service.homeMu.Unlock()
|
|
if registry != nil {
|
|
return registry
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
t.Fatal("service did not expose a ready execution registry")
|
|
return nil
|
|
}
|
|
|
|
type testHomeLogForwarder struct {
|
|
mu sync.Mutex
|
|
owner *home.Client
|
|
binds int
|
|
deactivations int
|
|
stops atomic.Int32
|
|
}
|
|
|
|
func (f *testHomeLogForwarder) Bind(client *home.Client) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.owner = client
|
|
f.binds++
|
|
}
|
|
|
|
func (f *testHomeLogForwarder) Deactivate(client *home.Client) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.owner == client {
|
|
f.owner = nil
|
|
}
|
|
f.deactivations++
|
|
}
|
|
|
|
func (f *testHomeLogForwarder) currentOwner() *home.Client {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.owner
|
|
}
|
|
|
|
func (f *testHomeLogForwarder) bindCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.binds
|
|
}
|
|
|
|
func (f *testHomeLogForwarder) Stop() {
|
|
f.stops.Add(1)
|
|
}
|
|
|
|
func TestServiceReusesHomeLogForwarderAcrossReconnects(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
acks := make(chan struct{}, 3)
|
|
stop := make(chan struct{})
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go serveHomeLogForwarderReconnectConnection(conn, acks, stop)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
home.ClearCurrent()
|
|
})
|
|
|
|
forwarder := &testHomeLogForwarder{}
|
|
originalStart := startHomeLogForwarder
|
|
var starts atomic.Int32
|
|
startHomeLogForwarder = func(int) homeLogForwarder {
|
|
starts.Add(1)
|
|
return forwarder
|
|
}
|
|
t.Cleanup(func() { startHomeLogForwarder = originalStart })
|
|
|
|
service := newRegistryTestService(t, listener)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
service.startHomeSubscriber(ctx)
|
|
waitForHomeLogForwarderACK(t, acks)
|
|
first := waitForServiceHomeClient(t, service, time.Second)
|
|
|
|
service.startHomeSubscriber(ctx)
|
|
waitForHomeLogForwarderACK(t, acks)
|
|
second := waitForServiceHomeClient(t, service, time.Second)
|
|
if second == first {
|
|
t.Fatal("first reconnect reused the previous Home client")
|
|
}
|
|
|
|
service.startHomeSubscriber(ctx)
|
|
waitForHomeLogForwarderACK(t, acks)
|
|
third := waitForServiceHomeClient(t, service, time.Second)
|
|
if third == second {
|
|
t.Fatal("second reconnect reused the previous Home client")
|
|
}
|
|
if got := starts.Load(); got != 1 {
|
|
t.Fatalf("Home log forwarder starts = %d, want 1", got)
|
|
}
|
|
if got := forwarder.bindCount(); got != 3 {
|
|
t.Fatalf("Home log forwarder binds = %d, want 3", got)
|
|
}
|
|
if owner := forwarder.currentOwner(); owner != third {
|
|
t.Fatal("Home log forwarder does not target the current Home client")
|
|
}
|
|
if current := home.Current(); current != third {
|
|
t.Fatal("current Home client does not match log forwarder owner")
|
|
}
|
|
|
|
if errShutdown := service.Shutdown(context.Background()); errShutdown != nil {
|
|
t.Fatalf("Shutdown() error = %v", errShutdown)
|
|
}
|
|
if got := forwarder.stops.Load(); got != 1 {
|
|
t.Fatalf("Home log forwarder stops = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func waitForHomeLogForwarderACK(t *testing.T, acks <-chan struct{}) {
|
|
t.Helper()
|
|
select {
|
|
case <-acks:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Home subscription was not acknowledged")
|
|
}
|
|
}
|
|
|
|
func waitForServiceHomeClient(t *testing.T, service *Service, timeout time.Duration) *home.Client {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
service.homeMu.Lock()
|
|
client := service.homeClient
|
|
service.homeMu.Unlock()
|
|
if client != nil {
|
|
return client
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
t.Fatal("service did not expose a Home client")
|
|
return nil
|
|
}
|
|
|
|
func TestDetachHomeSubscriberLifetimeKeepsNewForwarderForStaleClient(t *testing.T) {
|
|
staleClient := home.New(internalconfig.HomeConfig{Enabled: true})
|
|
currentClient := home.New(internalconfig.HomeConfig{Enabled: true})
|
|
staleRegistry := executionregistry.New()
|
|
currentRegistry := executionregistry.New()
|
|
staleForwarder := &testHomeLogForwarder{}
|
|
currentForwarder := &testHomeLogForwarder{}
|
|
service := &Service{
|
|
homeClient: currentClient,
|
|
homeRegistry: currentRegistry,
|
|
homeLogForwarder: currentForwarder,
|
|
homeLogForwarderClient: currentClient,
|
|
}
|
|
|
|
staleForwarder.Stop()
|
|
service.detachHomeSubscriberLifetime(staleClient, staleRegistry)
|
|
|
|
service.homeMu.Lock()
|
|
forwarder := service.homeLogForwarder
|
|
forwarderClient := service.homeLogForwarderClient
|
|
client := service.homeClient
|
|
registry := service.homeRegistry
|
|
service.homeMu.Unlock()
|
|
if forwarder != currentForwarder || forwarderClient != currentClient || client != currentClient || registry != currentRegistry {
|
|
t.Fatal("stale detach cleared the replacement Home lifetime")
|
|
}
|
|
if currentForwarder.stops.Load() != 0 {
|
|
t.Fatal("stale detach stopped the replacement log forwarder")
|
|
}
|
|
if staleForwarder.stops.Load() != 1 {
|
|
t.Fatal("stale forwarder ownership changed during stale detach")
|
|
}
|
|
}
|
|
|
|
func serveHomeLogForwarderReconnectConnection(conn net.Conn, acks chan<- struct{}, stop <-chan struct{}) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
if _, errWrite := io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "config":
|
|
payload := "credential-concurrency:\n lifecycle-config-revision: 1\n cpa-heartbeat-timeout: 100ms\n cpa-cancel-bound: 100ms\n"
|
|
if _, errWrite := io.WriteString(conn, fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-tasks":
|
|
if _, errWrite := io.WriteString(conn, "$2\r\n[]\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "SUBSCRIBE") && args[1] == "config":
|
|
if _, errWrite := io.WriteString(conn, "*3\r\n$9\r\nsubscribe\r\n$6\r\nconfig\r\n:1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
acks <- struct{}{}
|
|
<-stop
|
|
return
|
|
default:
|
|
if _, errWrite := io.WriteString(conn, "+OK\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func waitForPublisherReplacementFrame(t *testing.T, frames <-chan home.InFlightSnapshotFrame, barrierRevision int64) home.InFlightSnapshotFrame {
|
|
t.Helper()
|
|
timer := time.NewTimer(time.Second)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case frame := <-frames:
|
|
if frame.BarrierRevision == barrierRevision {
|
|
return frame
|
|
}
|
|
case <-timer.C:
|
|
t.Fatalf("publisher did not send barrier revision %d", barrierRevision)
|
|
return home.InFlightSnapshotFrame{}
|
|
}
|
|
}
|
|
}
|
|
|
|
func servePublisherReplacementConnection(conn net.Conn, configRequests *atomic.Int32, frames chan<- home.InFlightSnapshotFrame, firstPublisherDoneForServer <-chan (<-chan struct{}), secondConfigResult chan<- error, allowSecondConfig <-chan struct{}, stop <-chan struct{}) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
if _, errWrite := io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "config":
|
|
request := int(configRequests.Add(1))
|
|
if request == 2 {
|
|
var firstPublisherDone <-chan struct{}
|
|
select {
|
|
case firstPublisherDone = <-firstPublisherDoneForServer:
|
|
case <-stop:
|
|
return
|
|
}
|
|
select {
|
|
case <-firstPublisherDone:
|
|
secondConfigResult <- nil
|
|
default:
|
|
secondConfigResult <- errors.New("replacement began its config lifetime before the previous publisher exited")
|
|
}
|
|
select {
|
|
case <-allowSecondConfig:
|
|
case <-stop:
|
|
return
|
|
}
|
|
}
|
|
barrierRevision := 11
|
|
if request == 2 {
|
|
barrierRevision = 22
|
|
}
|
|
payload := fmt.Sprintf("credential-concurrency:\n lifecycle-config-revision: %d\n observation-barrier-revision: %d\n cpa-heartbeat-timeout: 100ms\n cpa-cancel-bound: 100ms\ncredential-in-flight:\n snapshot-interval: 10ms\n", request, barrierRevision)
|
|
writeRegistryTestConfig(conn, payload)
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-tasks":
|
|
if _, errWrite := io.WriteString(conn, "$2\r\n[]\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) > 0 && strings.EqualFold(args[0], "PING"):
|
|
if _, errWrite := io.WriteString(conn, "+PONG\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "SUBSCRIBE") && args[1] == "config":
|
|
if _, errWrite := io.WriteString(conn, "*3\r\n$9\r\nsubscribe\r\n$6\r\nconfig\r\n:1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-time.After(time.Second):
|
|
return
|
|
}
|
|
case len(args) >= 3 && strings.EqualFold(args[0], "LPUSH") && args[1] == "in-flight-snapshot":
|
|
var frame home.InFlightSnapshotFrame
|
|
if errUnmarshal := json.Unmarshal([]byte(args[2]), &frame); errUnmarshal != nil {
|
|
return
|
|
}
|
|
select {
|
|
case frames <- frame:
|
|
case <-stop:
|
|
return
|
|
}
|
|
if _, errWrite := io.WriteString(conn, ":1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
default:
|
|
if _, errWrite := io.WriteString(conn, "+OK\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func servePreACKReplacementConnection(conn net.Conn, configRequests *atomic.Int32, firstSubscribed chan struct{}, secondStarted chan struct{}, secondStartedBeforeFirstDone chan struct{}, firstDone <-chan (<-chan struct{}), stop chan struct{}) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
if _, errWrite := io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "config":
|
|
if configRequests.Add(1) > 1 {
|
|
supervisorDone := <-firstDone
|
|
select {
|
|
case <-supervisorDone:
|
|
default:
|
|
close(secondStartedBeforeFirstDone)
|
|
}
|
|
close(secondStarted)
|
|
}
|
|
payload := "credential-concurrency:\n lifecycle-config-revision: 1\n cpa-heartbeat-timeout: 100ms\n cpa-cancel-bound: 100ms\n"
|
|
if _, errWrite := io.WriteString(conn, fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "SUBSCRIBE") && args[1] == "config":
|
|
if configRequests.Load() == 1 {
|
|
close(firstSubscribed)
|
|
}
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-time.After(time.Second):
|
|
return
|
|
}
|
|
default:
|
|
if _, errWrite := io.WriteString(conn, "+OK\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func serveSuccessChainHomeConnection(conn net.Conn, configMu *sync.Mutex, configRequests *int, firstAck chan struct{}, loseFirst chan struct{}, preAckAttempts chan time.Time, finalSubscribe chan struct{}, allowFinalAck chan struct{}, stop chan struct{}) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
if _, errWrite := io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "config":
|
|
configMu.Lock()
|
|
*configRequests++
|
|
request := *configRequests
|
|
configMu.Unlock()
|
|
if request == 2 || request == 3 {
|
|
preAckAttempts <- time.Now()
|
|
_, _ = io.WriteString(conn, "-ERR unavailable\r\n")
|
|
return
|
|
}
|
|
payload := "credential-concurrency:\n lifecycle-config-revision: 1\n cpa-heartbeat-timeout: 100ms\n cpa-cancel-bound: 100ms\n"
|
|
if _, errWrite := io.WriteString(conn, fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-tasks":
|
|
if _, errWrite := io.WriteString(conn, "$2\r\n[]\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "SUBSCRIBE") && args[1] == "config":
|
|
configMu.Lock()
|
|
request := *configRequests
|
|
configMu.Unlock()
|
|
if request == 1 {
|
|
if _, errWrite := io.WriteString(conn, "*3\r\n$9\r\nsubscribe\r\n$6\r\nconfig\r\n:1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
close(firstAck)
|
|
select {
|
|
case <-loseFirst:
|
|
<-stop
|
|
case <-stop:
|
|
}
|
|
return
|
|
}
|
|
close(finalSubscribe)
|
|
select {
|
|
case <-allowFinalAck:
|
|
case <-stop:
|
|
return
|
|
}
|
|
if _, errWrite := io.WriteString(conn, "*3\r\n$9\r\nsubscribe\r\n$6\r\nconfig\r\n:1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
<-stop
|
|
return
|
|
default:
|
|
if _, errWrite := io.WriteString(conn, "+OK\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func serveStalePreACKPluginConnection(conn net.Conn, subscriptions *atomic.Int32, pluginWrites *atomic.Int32, firstSubscribed chan struct{}, secondSubscribed chan struct{}, allowSecondAck chan struct{}, stop chan struct{}) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
if _, errWrite := io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "config":
|
|
payload := "credential-concurrency:\n lifecycle-config-revision: 1\n cpa-heartbeat-timeout: 100ms\n cpa-cancel-bound: 100ms\nplugins:\n enabled: true\n"
|
|
if _, errWrite := io.WriteString(conn, fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-sync":
|
|
payload := fmt.Sprintf(`{"schema_version":1,"expires_at":%q,"items":[]}`, time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano))
|
|
if _, errWrite := io.WriteString(conn, fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-tasks":
|
|
if _, errWrite := io.WriteString(conn, "$-1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) > 0 && strings.EqualFold(args[0], "PING"):
|
|
if _, errWrite := io.WriteString(conn, "+PONG\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "RPUSH") && args[1] == "plugin-status":
|
|
pluginWrites.Add(1)
|
|
if _, errWrite := io.WriteString(conn, ":1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "SUBSCRIBE") && args[1] == "config":
|
|
subscription := subscriptions.Add(1)
|
|
switch subscription {
|
|
case 1:
|
|
close(firstSubscribed)
|
|
<-stop
|
|
return
|
|
case 2:
|
|
close(secondSubscribed)
|
|
select {
|
|
case <-allowSecondAck:
|
|
case <-stop:
|
|
return
|
|
}
|
|
if _, errWrite := io.WriteString(conn, "*3\r\n$9\r\nsubscribe\r\n$6\r\nconfig\r\n:1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
<-stop
|
|
return
|
|
}
|
|
default:
|
|
if _, errWrite := io.WriteString(conn, "+OK\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func serveInitialOverlayPluginConnection(conn net.Conn, pluginSync chan struct{}, pluginStatus chan struct{}, pluginTasks chan struct{}, freshCommandProbe chan struct{}, allowAck chan struct{}, stop chan struct{}) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
if _, errWrite := io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "config":
|
|
payload := "credential-concurrency:\n lifecycle-config-revision: 1\n cpa-heartbeat-timeout: 100ms\n cpa-cancel-bound: 100ms\nplugins:\n enabled: true\n"
|
|
if _, errWrite := io.WriteString(conn, fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-sync":
|
|
if home.Current() != nil {
|
|
return
|
|
}
|
|
close(pluginSync)
|
|
payload := fmt.Sprintf(`{"schema_version":1,"expires_at":%q,"items":[]}`, time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano))
|
|
if _, errWrite := io.WriteString(conn, fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "RPUSH") && args[1] == "plugin-status":
|
|
select {
|
|
case <-freshCommandProbe:
|
|
default:
|
|
return
|
|
}
|
|
pluginStatus <- struct{}{}
|
|
if _, errWrite := io.WriteString(conn, ":1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-tasks":
|
|
close(pluginTasks)
|
|
payload := `[{"id":1,"operation":"delete","plugin_id":"plugin-a"}]`
|
|
if _, errWrite := io.WriteString(conn, fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) > 0 && strings.EqualFold(args[0], "PING"):
|
|
close(freshCommandProbe)
|
|
if _, errWrite := io.WriteString(conn, "+PONG\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "SUBSCRIBE") && args[1] == "config":
|
|
select {
|
|
case <-allowAck:
|
|
case <-stop:
|
|
return
|
|
}
|
|
if _, errWrite := io.WriteString(conn, "*3\r\n$9\r\nsubscribe\r\n$6\r\nconfig\r\n:1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
<-stop
|
|
return
|
|
default:
|
|
if _, errWrite := io.WriteString(conn, "+OK\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func serveRegistryTestHomeConnection(conn net.Conn, subscriptionMu *sync.Mutex, subscriptions *int, firstAck chan struct{}, loseFirst chan struct{}, secondSubscribe chan struct{}, secondSubscribeOnce *sync.Once, allowSecondAck chan struct{}, stop chan struct{}) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
if _, errWrite := io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "config":
|
|
payload := "credential-concurrency:\n lifecycle-config-revision: 1\n cpa-heartbeat-timeout: 100ms\n cpa-cancel-bound: 100ms\n"
|
|
if _, errWrite := io.WriteString(conn, fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-tasks":
|
|
if _, errWrite := io.WriteString(conn, "$2\r\n[]\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "SUBSCRIBE") && args[1] == "config":
|
|
subscriptionMu.Lock()
|
|
*subscriptions++
|
|
subscription := *subscriptions
|
|
subscriptionMu.Unlock()
|
|
if subscription == 1 {
|
|
if _, errWrite := io.WriteString(conn, "*3\r\n$9\r\nsubscribe\r\n$6\r\nconfig\r\n:1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
close(firstAck)
|
|
select {
|
|
case <-loseFirst:
|
|
<-stop
|
|
return
|
|
case <-stop:
|
|
return
|
|
}
|
|
}
|
|
secondSubscribeOnce.Do(func() { close(secondSubscribe) })
|
|
select {
|
|
case <-allowSecondAck:
|
|
case <-stop:
|
|
return
|
|
}
|
|
if _, errWrite := io.WriteString(conn, "*3\r\n$9\r\nsubscribe\r\n$6\r\nconfig\r\n:1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
<-stop
|
|
return
|
|
default:
|
|
if _, errWrite := io.WriteString(conn, "+OK\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func servePreAckFailureConnection(conn net.Conn, attempts chan<- time.Time) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
if _, errWrite := io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "config":
|
|
attempts <- time.Now()
|
|
_, _ = io.WriteString(conn, "-ERR unavailable\r\n")
|
|
return
|
|
default:
|
|
if _, errWrite := io.WriteString(conn, "+OK\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func readRegistryTestRedisCommand(reader *bufio.Reader) ([]string, error) {
|
|
line, errRead := reader.ReadString('\n')
|
|
if errRead != nil {
|
|
return nil, errRead
|
|
}
|
|
if !strings.HasPrefix(line, "*") {
|
|
return nil, fmt.Errorf("unexpected RESP command header %q", line)
|
|
}
|
|
count, errCount := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "*")))
|
|
if errCount != nil {
|
|
return nil, errCount
|
|
}
|
|
args := make([]string, 0, count)
|
|
for range count {
|
|
lengthLine, errLength := reader.ReadString('\n')
|
|
if errLength != nil {
|
|
return nil, errLength
|
|
}
|
|
length, errParseLength := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(lengthLine, "$")))
|
|
if errParseLength != nil {
|
|
return nil, errParseLength
|
|
}
|
|
raw := make([]byte, length+2)
|
|
if _, errReadRaw := io.ReadFull(reader, raw); errReadRaw != nil {
|
|
return nil, errReadRaw
|
|
}
|
|
args = append(args, string(raw[:length]))
|
|
}
|
|
return args, nil
|
|
}
|
|
|
|
func TestServiceSkipsStaleLocalConfigRuntimeApply(t *testing.T) {
|
|
service := &Service{cfg: &config.Config{}}
|
|
var applied []string
|
|
service.applyPprofConfigContextFn = func(_ context.Context, cfg *config.Config) bool {
|
|
applied = append(applied, cfg.Routing.Strategy)
|
|
return true
|
|
}
|
|
first := service.commitConfigUpdate(&config.Config{Routing: internalconfig.RoutingConfig{Strategy: "fill-first"}})
|
|
second := service.commitConfigUpdate(&config.Config{Routing: internalconfig.RoutingConfig{Strategy: "round-robin"}})
|
|
if !service.applyConfigRuntime(context.Background(), second, false) {
|
|
t.Fatal("newest config runtime apply failed")
|
|
}
|
|
if service.applyConfigRuntime(context.Background(), first, false) {
|
|
t.Fatal("stale config runtime apply succeeded")
|
|
}
|
|
if got, want := strings.Join(applied, ","), "round-robin"; got != want {
|
|
t.Fatalf("runtime apply order = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestServiceAppliesSameValueNewestSelectorCommit(t *testing.T) {
|
|
manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
|
|
manager.RegisterExecutor(serviceTestPluginExecutor{})
|
|
for _, id := range []string{"auth-b", "auth-a"} {
|
|
if _, errRegister := manager.Register(context.Background(), &coreauth.Auth{ID: id, Provider: "plugin-provider", Status: coreauth.StatusActive}); errRegister != nil {
|
|
t.Fatalf("Register(%s) error = %v", id, errRegister)
|
|
}
|
|
}
|
|
|
|
service := &Service{cfg: &config.Config{}, coreManager: manager}
|
|
older := service.commitConfigUpdate(&config.Config{Routing: internalconfig.RoutingConfig{Strategy: "fill-first"}})
|
|
newer := service.commitConfigUpdate(&config.Config{Routing: internalconfig.RoutingConfig{Strategy: "fill-first"}})
|
|
if !service.applyConfigRuntime(context.Background(), newer, false) {
|
|
t.Fatal("newest same-value config runtime apply failed")
|
|
}
|
|
if service.applyConfigRuntime(context.Background(), older, false) {
|
|
t.Fatal("stale same-value config runtime apply succeeded")
|
|
}
|
|
|
|
for range 2 {
|
|
selected, errSelect := manager.SelectAuth(context.Background(), "plugin-provider", "", cliproxyexecutor.Options{})
|
|
if errSelect != nil {
|
|
t.Fatalf("SelectAuth() error = %v", errSelect)
|
|
}
|
|
if selected == nil || selected.ID != "auth-a" {
|
|
t.Fatalf("selector picked = %+v, want auth-a from fill-first", selected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuilderPreservesInitialSelectorForSameRouting(t *testing.T) {
|
|
cfg := &config.Config{
|
|
AuthDir: t.TempDir(),
|
|
Routing: internalconfig.RoutingConfig{
|
|
Strategy: "fill-first",
|
|
SessionAffinity: true,
|
|
SessionAffinityTTL: "1h",
|
|
},
|
|
}
|
|
service, errBuild := NewBuilder().
|
|
WithConfig(cfg).
|
|
WithConfigPath(t.TempDir() + "/config.yaml").
|
|
Build()
|
|
if errBuild != nil {
|
|
t.Fatalf("Build() error = %v", errBuild)
|
|
}
|
|
|
|
initialSelector := service.coreManager.Selector()
|
|
initialAffinity, ok := initialSelector.(*coreauth.SessionAffinitySelector)
|
|
if !ok {
|
|
t.Fatalf("initial selector = %T, want *SessionAffinitySelector", initialSelector)
|
|
}
|
|
defer initialAffinity.Stop()
|
|
commit := service.commitConfigUpdate(cfg)
|
|
if !service.applyConfigRuntime(context.Background(), commit, false) {
|
|
t.Fatal("same-routing config runtime apply failed")
|
|
}
|
|
if got := service.coreManager.Selector(); got != initialSelector {
|
|
t.Fatalf("same-routing selector = %p, want initial selector %p", got, initialSelector)
|
|
}
|
|
}
|
|
|
|
func TestServiceApplyConfigRuntimePreservesSelectorForUnchangedRouting(t *testing.T) {
|
|
manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
|
|
service := &Service{cfg: &config.Config{}, coreManager: manager}
|
|
|
|
initial := service.commitConfigUpdate(&config.Config{Routing: internalconfig.RoutingConfig{
|
|
Strategy: "fill-first",
|
|
SessionAffinity: true,
|
|
SessionAffinityTTL: "1h",
|
|
}})
|
|
if !service.applyConfigRuntime(context.Background(), initial, false) {
|
|
t.Fatal("initial config runtime apply failed")
|
|
}
|
|
initialSelector := manager.Selector()
|
|
initialAffinity, ok := initialSelector.(*coreauth.SessionAffinitySelector)
|
|
if !ok {
|
|
t.Fatalf("initial selector = %T, want *SessionAffinitySelector", initialSelector)
|
|
}
|
|
defer initialAffinity.Stop()
|
|
|
|
older := service.commitConfigUpdate(&config.Config{Routing: internalconfig.RoutingConfig{
|
|
Strategy: " FILLFIRST ",
|
|
SessionAffinity: true,
|
|
SessionAffinityTTL: "60m",
|
|
}})
|
|
newer := service.commitConfigUpdate(&config.Config{
|
|
Routing: internalconfig.RoutingConfig{
|
|
Strategy: "fill-first",
|
|
SessionAffinity: true,
|
|
SessionAffinityTTL: "1h",
|
|
},
|
|
UsageStatisticsEnabled: true,
|
|
})
|
|
if !service.applyConfigRuntime(context.Background(), newer, false) {
|
|
t.Fatal("newest same-routing config runtime apply failed")
|
|
}
|
|
if got := manager.Selector(); got != initialSelector {
|
|
t.Fatalf("same-routing selector = %p, want original %p", got, initialSelector)
|
|
}
|
|
if service.applyConfigRuntime(context.Background(), older, false) {
|
|
t.Fatal("stale same-routing config runtime apply succeeded")
|
|
}
|
|
if got := manager.Selector(); got != initialSelector {
|
|
t.Fatalf("stale same-routing selector = %p, want original %p", got, initialSelector)
|
|
}
|
|
|
|
changed := service.commitConfigUpdate(&config.Config{Routing: internalconfig.RoutingConfig{
|
|
Strategy: "round-robin",
|
|
SessionAffinity: true,
|
|
SessionAffinityTTL: "1h",
|
|
}})
|
|
if !service.applyConfigRuntime(context.Background(), changed, false) {
|
|
t.Fatal("changed-routing config runtime apply failed")
|
|
}
|
|
changedSelector := manager.Selector()
|
|
if changedSelector == initialSelector {
|
|
t.Fatal("changed-routing selector retained original identity")
|
|
}
|
|
changedAffinity, ok := changedSelector.(*coreauth.SessionAffinitySelector)
|
|
if !ok {
|
|
t.Fatalf("changed selector = %T, want *SessionAffinitySelector", changedSelector)
|
|
}
|
|
defer changedAffinity.Stop()
|
|
|
|
unrelated := service.commitConfigUpdate(&config.Config{
|
|
Routing: internalconfig.RoutingConfig{
|
|
Strategy: "round-robin",
|
|
SessionAffinity: true,
|
|
SessionAffinityTTL: "1h",
|
|
},
|
|
UsageStatisticsEnabled: false,
|
|
})
|
|
if !service.applyConfigRuntime(context.Background(), unrelated, false) {
|
|
t.Fatal("unrelated config runtime apply failed")
|
|
}
|
|
if got := manager.Selector(); got != changedSelector {
|
|
t.Fatalf("unrelated-update selector = %p, want changed selector %p", got, changedSelector)
|
|
}
|
|
}
|
|
|
|
func TestServiceSerializesHomeAndWatcherConfigRuntimeApply(t *testing.T) {
|
|
baseCfg := &config.Config{}
|
|
baseCfg.Home.Enabled = true
|
|
service := &Service{cfg: baseCfg, homeGeneration: 1}
|
|
firstStarted := make(chan struct{})
|
|
releaseFirst := make(chan struct{})
|
|
var appliedMu sync.Mutex
|
|
var applied []string
|
|
service.applyPprofConfigContextFn = func(_ context.Context, cfg *config.Config) bool {
|
|
if cfg.Routing.Strategy == "fill-first" {
|
|
close(firstStarted)
|
|
<-releaseFirst
|
|
}
|
|
appliedMu.Lock()
|
|
applied = append(applied, cfg.Routing.Strategy)
|
|
appliedMu.Unlock()
|
|
return true
|
|
}
|
|
client, _ := newHomePluginTaskTestClient(t, nil, 0)
|
|
queue := newHomeConfigWorkQueue()
|
|
queue.enqueue([]byte("routing:\n strategy: fill-first\n"))
|
|
ready := make(chan struct{})
|
|
close(ready)
|
|
lifetimeCtx, cancelLifetime := context.WithCancel(context.Background())
|
|
defer cancelLifetime()
|
|
cancelBound := atomic.Int64{}
|
|
cancelBound.Store(int64(time.Second))
|
|
workerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(workerDone)
|
|
service.runHomeConfigWorker(lifetimeCtx, context.Background(), 1, client, executionregistry.New(), queue, ready, &atomic.Bool{}, &cancelBound)
|
|
}()
|
|
select {
|
|
case <-firstStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Home config runtime apply did not start")
|
|
}
|
|
|
|
watcherDone := make(chan struct{})
|
|
go func() {
|
|
service.applyWatcherConfigUpdate(&config.Config{Routing: internalconfig.RoutingConfig{Strategy: "round-robin"}})
|
|
close(watcherDone)
|
|
}()
|
|
select {
|
|
case <-watcherDone:
|
|
t.Fatal("watcher runtime apply completed before the older Home apply")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
close(releaseFirst)
|
|
select {
|
|
case <-watcherDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("watcher runtime apply did not finish")
|
|
}
|
|
appliedMu.Lock()
|
|
got := strings.Join(applied, ",")
|
|
appliedMu.Unlock()
|
|
if want := "fill-first,round-robin"; got != want {
|
|
t.Fatalf("runtime completion order = %q, want %q", got, want)
|
|
}
|
|
cancelLifetime()
|
|
select {
|
|
case <-workerDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Home config worker did not stop")
|
|
}
|
|
}
|