blob: b8cef223b0aa77b127256a364ebdb64b554b2319 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package server
import (
"fmt"
"reflect"
"sync"
"sync/atomic"
"testing"
)
import (
"github.com/stretchr/testify/assert"
)
import (
"github.com/apache/dubbo-go-pixiu/pkg/cluster"
_ "github.com/apache/dubbo-go-pixiu/pkg/cluster/loadbalancer/maglev" // Register Maglev for cluster-manager tests.
_ "github.com/apache/dubbo-go-pixiu/pkg/cluster/loadbalancer/rand" // Register Rand for cluster-manager tests.
_ "github.com/apache/dubbo-go-pixiu/pkg/cluster/loadbalancer/ringhash" // Register RingHash for cluster-manager tests.
_ "github.com/apache/dubbo-go-pixiu/pkg/cluster/loadbalancer/roundrobin" // Register RoundRobin for cluster-manager tests.
"github.com/apache/dubbo-go-pixiu/pkg/model"
)
func TestClusterManager(t *testing.T) {
cm := testClusterManager(
testCluster("test", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("1", "127.0.0.1", 18080),
}),
)
assert.Len(t, cm.store.Config, 1)
cm.AddCluster(testCluster("test2", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("1", "127.0.0.1", 18081),
}))
assert.Len(t, cm.store.Config, 2)
cm.SetEndpoint("test2", testEndpoint("2", "127.0.0.1", 18082))
assert.Equal(t, "1", cm.PickEndpoint("test", nil).ID)
cm.DeleteEndpoint("test2", "1")
}
func TestClusterManager_PickEndpointReturnsNilForMissingCluster(t *testing.T) {
cm := testClusterManager()
assert.Nil(t, cm.PickEndpoint("missing-cluster", nil))
}
func TestClusterManager_PickEndpointUsesRuntimeClusterMap(t *testing.T) {
runtimeConfig := testCluster("runtime-lookup", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("runtime-ep", "127.0.0.1", 18083),
})
cm := testClusterManager(runtimeConfig)
cm.store.Config = []*model.ClusterConfig{
testCluster(runtimeConfig.Name, model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("stale-ep", "127.0.0.1", 18084),
}),
}
endpoint := cm.PickEndpoint(runtimeConfig.Name, nil)
if assert.NotNil(t, endpoint) {
assert.Equal(t, "runtime-ep", endpoint.ID)
}
}
func TestClusterManager_PickNextEndpointUsesRuntimeClusterMap(t *testing.T) {
runtimeConfig := testCluster("runtime-next-lookup", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("runtime-1", "127.0.0.1", 18085),
testEndpoint("runtime-2", "127.0.0.1", 18086),
})
cm := testClusterManager(runtimeConfig)
cm.store.Config = []*model.ClusterConfig{
testCluster(runtimeConfig.Name, model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("stale-1", "127.0.0.1", 18087),
testEndpoint("stale-2", "127.0.0.1", 18088),
}),
}
endpoint := cm.PickNextEndpoint(runtimeConfig.Name, "runtime-1")
if assert.NotNil(t, endpoint) {
assert.Equal(t, "runtime-2", endpoint.ID)
}
}
func TestClusterManager_GetEndpointByIDUsesRuntimeClusterMap(t *testing.T) {
runtimeConfig := testCluster("runtime-id-lookup", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("runtime-ep", "127.0.0.1", 18089),
})
cm := testClusterManager(runtimeConfig)
cm.store.Config = []*model.ClusterConfig{
testCluster(runtimeConfig.Name, model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("stale-ep", "127.0.0.1", 18090),
}),
}
endpoint := cm.GetEndpointByID(runtimeConfig.Name, "runtime-ep")
if assert.NotNil(t, endpoint) {
assert.Equal(t, "runtime-ep", endpoint.ID)
}
}
func TestClusterManager_PickEndpointSingleUnhealthyReturnsNil(t *testing.T) {
cm := testClusterManager(
testCluster("single-unhealthy", model.LoadBalancerRoundRobin, []*model.Endpoint{
{
ID: "ep-1",
Name: "endpoint-ep-1",
UnHealthy: true,
Address: model.SocketAddress{
Address: "127.0.0.1",
Port: 18090,
},
},
}),
)
assert.Nil(t, cm.PickEndpoint("single-unhealthy", nil))
}
func TestClusterManager_PickEndpointAllUnhealthyReturnsNil(t *testing.T) {
cm := testClusterManager(
testCluster("all-unhealthy", model.LoadBalancerRoundRobin, []*model.Endpoint{
{
ID: "ep-1",
Name: "endpoint-ep-1",
UnHealthy: true,
Address: model.SocketAddress{
Address: "127.0.0.1",
Port: 18091,
},
},
{
ID: "ep-2",
Name: "endpoint-ep-2",
UnHealthy: true,
Address: model.SocketAddress{
Address: "127.0.0.1",
Port: 18092,
},
},
}),
)
assert.Nil(t, cm.PickEndpoint("all-unhealthy", nil))
}
func TestClusterManager_CompareAndSetStorePreservesRoundRobinCursorAcrossRefresh(t *testing.T) {
cluster := testCluster("refresh-round-robin", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-1", "127.0.0.1", 19200),
testEndpoint("ep-2", "127.0.0.1", 19201),
testEndpoint("ep-3", "127.0.0.1", 19202),
})
cm := testClusterManager(cluster)
const expectedCursor uint32 = 5
atomic.StoreUint32(&cm.store.Config[0].PrePickEndpointIndex, expectedCursor)
oldStore, err := cm.CloneStore()
if !assert.NoError(t, err) {
return
}
newStore := cm.NewStore(oldStore.Version)
for _, endpoint := range oldStore.Config[0].Endpoints {
copied := *endpoint
newStore.SetEndpoint(cluster.Name, &copied)
}
assert.True(t, cm.CompareAndSetStore(newStore))
if assert.Len(t, cm.store.Config, 1) {
assert.Equal(t, expectedCursor, atomic.LoadUint32(&cm.store.Config[0].PrePickEndpointIndex))
}
endpoint := cm.PickEndpoint(cluster.Name, nil)
if assert.NotNil(t, endpoint) {
assert.Equal(t, "ep-3", endpoint.ID)
}
}
func TestClusterManager_UpdateClusterRebuildsRuntimeCluster(t *testing.T) {
oldConfig := testCluster("runtime-update", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-1", "127.0.0.1", 19300),
}, testHealthCheck())
cm := testClusterManager(oldConfig)
defer stopStoreRuntimes(cm.store)
oldRuntime := cm.store.clustersMap[oldConfig.Name]
if !assert.NotNil(t, oldRuntime) {
return
}
assert.Same(t, oldConfig, oldRuntime.Config)
assert.Greater(t, healthCheckersLen(oldRuntime), 0)
const expectedCursor uint32 = 11
atomic.StoreUint32(&oldConfig.PrePickEndpointIndex, expectedCursor)
newConfig := testCluster(oldConfig.Name, model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-2", "127.0.0.1", 19301),
}, testHealthCheck())
cm.UpdateCluster(newConfig)
newRuntime := cm.store.clustersMap[oldConfig.Name]
if !assert.NotNil(t, newRuntime) {
return
}
assert.NotSame(t, oldRuntime, newRuntime)
assert.Same(t, newConfig, newRuntime.Config)
assert.Same(t, newConfig, cm.store.Config[0])
assert.Equal(t, expectedCursor, atomic.LoadUint32(&newConfig.PrePickEndpointIndex))
assert.Equal(t, 0, healthCheckersLen(oldRuntime))
assert.Greater(t, healthCheckersLen(newRuntime), 0)
}
func TestClusterManager_CompareAndSetStoreVersionMismatchHasNoSideEffects(t *testing.T) {
currentConfig := testCluster("cas-version-mismatch", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-1", "127.0.0.1", 19310),
}, testHealthCheck())
cm := testClusterManager(currentConfig)
defer stopStoreRuntimes(cm.store)
oldStore := cm.store
oldRuntime := oldStore.clustersMap[currentConfig.Name]
if !assert.NotNil(t, oldRuntime) {
return
}
assert.Greater(t, healthCheckersLen(oldRuntime), 0)
candidateConfig := testCluster(currentConfig.Name, model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-2", "127.0.0.1", 19311),
})
candidate := &ClusterStore{
Config: []*model.ClusterConfig{candidateConfig},
Version: oldStore.Version + 1,
}
assert.False(t, cm.CompareAndSetStore(candidate))
assert.Same(t, oldStore, cm.store)
assert.Nil(t, candidate.clustersMap)
assert.Greater(t, healthCheckersLen(oldRuntime), 0)
}
func TestClusterManager_CompareAndSetStoreEnsuresRuntimeAndStopsOld(t *testing.T) {
oldConfig := testCluster("cas-runtime-refresh", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-1", "127.0.0.1", 19320),
}, testHealthCheck())
cm := testClusterManager(oldConfig)
oldRuntime := cm.store.clustersMap[oldConfig.Name]
if !assert.NotNil(t, oldRuntime) {
return
}
assert.Greater(t, healthCheckersLen(oldRuntime), 0)
const expectedCursor uint32 = 17
atomic.StoreUint32(&oldConfig.PrePickEndpointIndex, expectedCursor)
newConfig := testCluster(oldConfig.Name, model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-2", "127.0.0.1", 19321),
}, testHealthCheck())
candidate := &ClusterStore{
Config: []*model.ClusterConfig{newConfig},
Version: cm.store.Version,
}
assert.True(t, cm.CompareAndSetStore(candidate))
defer stopStoreRuntimes(cm.store)
newRuntime := candidate.clustersMap[newConfig.Name]
if !assert.NotNil(t, newRuntime) {
return
}
assert.Same(t, candidate, cm.store)
assert.NotSame(t, oldRuntime, newRuntime)
assert.Same(t, newConfig, newRuntime.Config)
assert.Equal(t, expectedCursor, atomic.LoadUint32(&newConfig.PrePickEndpointIndex))
assert.Equal(t, 0, healthCheckersLen(oldRuntime))
assert.Greater(t, healthCheckersLen(newRuntime), 0)
}
func TestClusterManager_CompareAndSetStoreStopsRemovedRuntime(t *testing.T) {
oldConfig := testCluster("cas-runtime-removed", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-1", "127.0.0.1", 19325),
}, testHealthCheck())
cm := testClusterManager(oldConfig)
oldRuntime := cm.store.clustersMap[oldConfig.Name]
if !assert.NotNil(t, oldRuntime) {
return
}
assert.Greater(t, healthCheckersLen(oldRuntime), 0)
candidate := &ClusterStore{
Version: cm.store.Version,
}
assert.True(t, cm.CompareAndSetStore(candidate))
assert.Same(t, candidate, cm.store)
assert.Empty(t, cm.store.Config)
assert.NotContains(t, cm.store.clustersMap, oldConfig.Name)
assert.Equal(t, 0, healthCheckersLen(oldRuntime))
}
func TestClusterStore_EnsureRuntimeClustersRepairsRuntimeMap(t *testing.T) {
t.Run("nil map", func(t *testing.T) {
config := testCluster("ensure-nil-map", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-1", "127.0.0.1", 19330),
})
store := &ClusterStore{Config: []*model.ClusterConfig{config}}
replaced := store.ensureRuntimeClusters()
defer stopStoreRuntimes(store)
assert.Empty(t, replaced)
if assert.NotNil(t, store.clustersMap[config.Name]) {
assert.Same(t, config, store.clustersMap[config.Name].Config)
}
})
t.Run("missing mismatched stale and idempotent", func(t *testing.T) {
correctConfig := testCluster("ensure-correct", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-1", "127.0.0.1", 19331),
})
missingConfig := testCluster("ensure-missing", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-2", "127.0.0.1", 19332),
})
oldMismatchedConfig := testCluster("ensure-mismatch", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("old", "127.0.0.1", 19333),
})
newMismatchedConfig := testCluster("ensure-mismatch", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("new", "127.0.0.1", 19334),
})
staleConfig := testCluster("ensure-stale", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("stale", "127.0.0.1", 19335),
})
correctRuntime := cluster.NewCluster(correctConfig)
mismatchedRuntime := cluster.NewCluster(oldMismatchedConfig)
staleRuntime := cluster.NewCluster(staleConfig)
store := &ClusterStore{
Config: []*model.ClusterConfig{
correctConfig,
missingConfig,
newMismatchedConfig,
},
clustersMap: map[string]*cluster.Cluster{
correctConfig.Name: correctRuntime,
newMismatchedConfig.Name: mismatchedRuntime,
staleConfig.Name: staleRuntime,
},
}
defer stopStoreRuntimes(store)
replaced := store.ensureRuntimeClusters()
stopClusters(replaced)
assert.Same(t, correctRuntime, store.clustersMap[correctConfig.Name])
if assert.NotNil(t, store.clustersMap[missingConfig.Name]) {
assert.Same(t, missingConfig, store.clustersMap[missingConfig.Name].Config)
}
if assert.NotNil(t, store.clustersMap[newMismatchedConfig.Name]) {
assert.NotSame(t, mismatchedRuntime, store.clustersMap[newMismatchedConfig.Name])
assert.Same(t, newMismatchedConfig, store.clustersMap[newMismatchedConfig.Name].Config)
}
assert.NotContains(t, store.clustersMap, staleConfig.Name)
assert.Contains(t, replaced, mismatchedRuntime)
assert.Contains(t, replaced, staleRuntime)
runtimesAfterRepair := map[string]*cluster.Cluster{}
for name, runtime := range store.clustersMap {
runtimesAfterRepair[name] = runtime
}
assert.Empty(t, store.ensureRuntimeClusters())
assert.Equal(t, runtimesAfterRepair, store.clustersMap)
})
}
// TestClusterManager_SetEndpointExplicitSameIDDifferentAddressRebuildsConsistentHash
// locks the consistent-hash side of the registry-update contract. When
// SetEndpoint replaces an existing endpoint's address in place, the hash
// ring must drop the old host and admit the new one. The previous behavior
// (append a -2 sibling keeping both hosts in the ring) caused stale hosts
// to keep receiving traffic after an instance moved.
func TestClusterManager_SetEndpointExplicitSameIDDifferentAddressRebuildsConsistentHash(t *testing.T) {
tests := []model.LbPolicyType{
model.LoadBalancerRingHashing,
model.LoadBalancerMaglevHashing,
}
for _, lb := range tests {
t.Run(string(lb), func(t *testing.T) {
oldEndpoint := testEndpoint("ep-1", "127.0.0.1", 19340)
oldHost := oldEndpoint.GetHost()
config := testCluster(fmt.Sprintf("hash-update-%s", lb), lb, []*model.Endpoint{oldEndpoint})
cm := testClusterManager(config)
defer stopStoreRuntimes(cm.store)
newEndpoint := testEndpoint("ep-1", "127.0.0.2", 19341)
newHost := newEndpoint.GetHost()
cm.SetEndpoint(config.Name, newEndpoint)
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 1, "address-changing replace must keep exactly one endpoint") {
return
}
assert.Equal(t, "ep-1", endpoints[0].ID)
assert.Equal(t, "127.0.0.2", endpoints[0].Address.Address)
hash := cm.store.Config[0].ConsistentHash.Hash
if !assert.NotNil(t, hash) {
return
}
if hostList, ok := hash.(interface{ Hosts() []string }); ok {
hosts := hostList.Hosts()
assert.NotContains(t, hosts, oldHost, "old host must drop from the hash after the slot is overwritten")
assert.Contains(t, hosts, newHost, "new host must enter the hash after the slot is overwritten")
return
}
assert.False(t, hash.Remove(oldHost), "old host must not be in the hash after the slot is overwritten")
assert.True(t, hash.Remove(newHost), "new host must be in the hash after the slot is overwritten")
})
}
}
func TestClusterManager_DeleteEndpointRepairsRuntimeAndConsistentHash(t *testing.T) {
deletedEndpoint := testEndpoint("ep-1", "127.0.0.1", 19350)
remainingEndpoint := testEndpoint("ep-2", "127.0.0.1", 19351)
config := testCluster("delete-runtime-repair", model.LoadBalancerRingHashing, []*model.Endpoint{
deletedEndpoint,
remainingEndpoint,
})
cm := testClusterManager(config)
defer stopStoreRuntimes(cm.store)
staleConfig := testCluster(config.Name, model.LoadBalancerRingHashing, []*model.Endpoint{
testEndpoint("stale", "127.0.0.1", 19352),
})
staleRuntime := cluster.NewCluster(staleConfig)
cm.store.clustersMap[config.Name] = staleRuntime
deletedHost := deletedEndpoint.GetHost()
remainingHost := remainingEndpoint.GetHost()
cm.DeleteEndpoint(config.Name, deletedEndpoint.ID)
runtime := cm.store.clustersMap[config.Name]
if !assert.NotNil(t, runtime) {
return
}
assert.NotSame(t, staleRuntime, runtime)
assert.Same(t, config, runtime.Config)
if assert.Len(t, config.Endpoints, 1) {
assert.Equal(t, remainingEndpoint, config.Endpoints[0])
assert.NotSame(t, remainingEndpoint, config.Endpoints[0])
}
hash := config.ConsistentHash.Hash
if !assert.NotNil(t, hash) {
return
}
hostList, ok := hash.(interface{ Hosts() []string })
if !assert.True(t, ok) {
return
}
hosts := hostList.Hosts()
assert.NotContains(t, hosts, deletedHost)
assert.Contains(t, hosts, remainingHost)
}
func TestClusterManager_Race_RoundRobinPickEndpoint(t *testing.T) {
cluster := testCluster("race-round-robin", model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-1", "127.0.0.1", 19100),
testEndpoint("ep-2", "127.0.0.1", 19101),
testEndpoint("ep-3", "127.0.0.1", 19102),
testEndpoint("ep-4", "127.0.0.1", 19103),
})
cm := testClusterManager(cluster)
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for j := 0; j < 4000; j++ {
_ = cm.PickEndpoint(cluster.Name, nil)
}
}()
}
close(start)
wg.Wait()
}
func TestClusterManager_AssembleEndpointsAssignsDeterministicID(t *testing.T) {
build := func() *model.ClusterConfig {
return &model.ClusterConfig{
Name: "assemble-id",
LbStr: model.LoadBalancerRoundRobin,
Endpoints: []*model.Endpoint{
{Address: model.SocketAddress{Address: "127.0.0.1", Port: 21080}},
{Address: model.SocketAddress{Address: "127.0.0.1", Port: 21081}},
},
}
}
first := testClusterManager(build())
defer stopStoreRuntimes(first.store)
second := testClusterManager(build())
defer stopStoreRuntimes(second.store)
firstEPs := first.store.Config[0].Endpoints
secondEPs := second.store.Config[0].Endpoints
if !assert.Len(t, firstEPs, 2) || !assert.Len(t, secondEPs, 2) {
return
}
// Deterministic: identical static config produces identical IDs across
// independent constructions, so dashboards keyed on endpoint.ID survive
// process restart.
assert.Equal(t, firstEPs[0].ID, secondEPs[0].ID)
assert.Equal(t, firstEPs[1].ID, secondEPs[1].ID)
// Generated prefix indicates the deterministic helper, not the legacy
// random UUID fallback.
assert.Contains(t, firstEPs[0].ID, "pixiu-generated-endpoint-")
// Endpoints differing only by port must not collide within the same cluster.
assert.NotEqual(t, firstEPs[0].ID, firstEPs[1].ID)
}
func TestClusterManager_AddClusterDoesNotMutateUserSuppliedEndpoint(t *testing.T) {
endpoint := &model.Endpoint{
Address: model.SocketAddress{
Address: "127.0.0.1",
Port: 21079,
},
}
cluster := &model.ClusterConfig{
Name: "add-clone-boundary",
LbStr: model.LoadBalancerRoundRobin,
Endpoints: []*model.Endpoint{endpoint},
}
cm := testClusterManager(cluster)
defer stopStoreRuntimes(cm.store)
assert.Empty(t, endpoint.ID)
assert.Empty(t, endpoint.Name)
assert.NotSame(t, endpoint, cm.store.Config[0].Endpoints[0])
assert.NotEmpty(t, cm.store.Config[0].Endpoints[0].ID)
assert.NotEmpty(t, cm.store.Config[0].Endpoints[0].Name)
}
func TestClusterManager_SetEndpointDoesNotMutateInputEndpoint(t *testing.T) {
cm := testClusterManager(testCluster("set-clone-boundary", model.LoadBalancerRoundRobin, nil))
defer stopStoreRuntimes(cm.store)
endpoint := &model.Endpoint{
Address: model.SocketAddress{
Address: "127.0.0.1",
Port: 21078,
},
}
cm.SetEndpoint("set-clone-boundary", endpoint)
assert.Empty(t, endpoint.ID)
assert.Empty(t, endpoint.Name)
if assert.Len(t, cm.store.Config[0].Endpoints, 1) {
assert.NotSame(t, endpoint, cm.store.Config[0].Endpoints[0])
assert.NotEmpty(t, cm.store.Config[0].Endpoints[0].ID)
assert.NotEmpty(t, cm.store.Config[0].Endpoints[0].Name)
}
}
// TestClusterManager_SetEndpointExplicitSameIDDifferentAddressReplacesLLMMeta
// locks LLMMeta semantics across an in-place address replacement. A
// registry-update event ("this instance moved to a new IP, here's its
// current LLMMeta") must publish the incoming LLMMeta verbatim — not
// preserve the old slot's LLMMeta as if the new payload were a sibling.
// The previous "append -2" behavior preserved the old LLMMeta, which is
// wrong for the update path because the old slot's LLMMeta belonged to
// an instance that no longer exists.
func TestClusterManager_SetEndpointExplicitSameIDDifferentAddressReplacesLLMMeta(t *testing.T) {
oldEndpoint := testEndpoint("ep-1", "127.0.0.1", 21077)
oldEndpoint.LLMMeta = &model.LLMMeta{APIKey: "old-key"}
config := testCluster("set-llm-meta-replace", model.LoadBalancerRoundRobin, []*model.Endpoint{oldEndpoint})
cm := testClusterManager(config)
defer stopStoreRuntimes(cm.store)
incoming := testEndpoint("ep-1", "127.0.0.2", 21078)
incoming.LLMMeta = &model.LLMMeta{APIKey: "new-key"}
cm.SetEndpoint(config.Name, incoming)
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 1,
"address-changing replace must keep exactly one endpoint, not append a sibling") {
return
}
assert.Equal(t, "ep-1", endpoints[0].ID)
assert.Equal(t, "127.0.0.2", endpoints[0].Address.Address)
if assert.NotNil(t, endpoints[0].LLMMeta) {
assert.Equal(t, "new-key", endpoints[0].LLMMeta.APIKey,
"incoming LLMMeta replaces the prior slot's LLMMeta — the update represents the instance's current state")
}
assert.Equal(t, "new-key", incoming.LLMMeta.APIKey, "input endpoint is not mutated")
}
// TestClusterManager_SetEndpointExplicitSameIDDifferentAddressOverwritesInPlace
// locks the registry-update contract: when two SetEndpoint calls share an
// explicit ID but route to different addresses, the second call represents
// "the same instance moved to a new address" (Nacos/SpringCloud OnUpdate),
// not "two distinct endpoints that happen to collide". The cluster must end
// up with exactly one endpoint at the new address, not two suffixed siblings
// — otherwise registry update events accumulate stale endpoints over time.
func TestClusterManager_SetEndpointExplicitSameIDDifferentAddressOverwritesInPlace(t *testing.T) {
cm := testClusterManager(testCluster("c", model.LoadBalancerRoundRobin, nil))
defer stopStoreRuntimes(cm.store)
cm.SetEndpoint("c", testEndpoint("foo", "127.0.0.1", 21100))
cm.SetEndpoint("c", testEndpoint("foo", "127.0.0.2", 21101))
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 1,
"same explicit ID across two SetEndpoint calls represents the same instance; "+
"the second call must overwrite the slot, not accumulate a -2 sibling") {
return
}
assert.Equal(t, "foo", endpoints[0].ID)
assert.Equal(t, "127.0.0.2", endpoints[0].Address.Address)
assert.Equal(t, 21101, endpoints[0].Address.Port)
}
// TestClusterManager_SetEndpointExplicitSameIDSameContentIsIdempotent locks
// the other side of the dedup contract: when two calls share the same
// explicit ID AND the same routing-relevant content, the second call is a
// re-registration (idempotent), not a duplicate that should accumulate.
// This is the safety net so Nacos heartbeats don't grow the endpoint slice.
func TestClusterManager_SetEndpointExplicitSameIDSameContentIsIdempotent(t *testing.T) {
cm := testClusterManager(testCluster("c", model.LoadBalancerRoundRobin, nil))
defer stopStoreRuntimes(cm.store)
cm.SetEndpoint("c", testEndpoint("foo", "127.0.0.1", 21110))
cm.SetEndpoint("c", testEndpoint("foo", "127.0.0.1", 21110))
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 1,
"same-content re-registration must be idempotent, never accumulate -N entries") {
return
}
assert.Equal(t, "foo", endpoints[0].ID)
}
// TestClusterManager_SetEndpointEmptyIDHashCollisionDifferentContentSuffixes
// covers the empty-ID variant of the dedup contract. Two SetEndpoint calls
// with empty IDs and the same hash material (so they resolve to the same
// generated-* ID) but different non-hash content (Metadata) must produce
// two endpoints — the second suffixed -2 — to stay consistent with how
// assembleClusterEndpoints handles two static entries with identical hash
// material.
func TestClusterManager_SetEndpointEmptyIDHashCollisionDifferentContentSuffixes(t *testing.T) {
cm := testClusterManager(testCluster("c", model.LoadBalancerRoundRobin, nil))
defer stopStoreRuntimes(cm.store)
addr := model.SocketAddress{Address: "127.0.0.1", Port: 21120}
cm.SetEndpoint("c", &model.Endpoint{
Address: addr,
Metadata: map[string]string{"region": "us-east"},
})
cm.SetEndpoint("c", &model.Endpoint{
Address: addr,
Metadata: map[string]string{"region": "us-west"},
})
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 2,
"empty-ID calls with matching hash but differing Metadata must survive as siblings") {
return
}
baseID := model.GenerateEndpointID("c", &model.Endpoint{Address: addr})
assert.Equal(t, baseID, endpoints[0].ID)
assert.Equal(t, baseID+"-2", endpoints[1].ID)
}
// TestClusterManager_SetEndpointEmptyIDSameContentIsIdempotent guarantees
// the natural idempotency of dynamic re-registration when callers leave the
// ID empty: two byte-equal empty-ID SetEndpoint calls collapse to one
// runtime endpoint, just like an explicit-ID idempotent call would.
func TestClusterManager_SetEndpointEmptyIDSameContentIsIdempotent(t *testing.T) {
cm := testClusterManager(testCluster("c", model.LoadBalancerRoundRobin, nil))
defer stopStoreRuntimes(cm.store)
addr := model.SocketAddress{Address: "127.0.0.1", Port: 21130}
cm.SetEndpoint("c", &model.Endpoint{Address: addr})
cm.SetEndpoint("c", &model.Endpoint{Address: addr})
endpoints := cm.store.Config[0].Endpoints
assert.Len(t, endpoints, 1,
"empty-ID re-registration with identical content must collapse to one entry")
}
// TestClusterManager_SetEndpointEmptyIDReusesOperatorPinnedID locks truth-
// table row 3: when an empty-ID SetEndpoint call hash-matches an existing
// endpoint that carries an operator-pinned (non-generated) ID and is
// content-equal, the pinned ID is reused — the dynamic path does NOT
// override the operator's choice with a generated-* hash. Without this
// test the empty-ID branch of resolveSetEndpointSlot could be silently
// regressed to `return incomingHash, true` in a future refactor.
//
// Non-obvious dependency: assembleClusterEndpoints defaults the existing
// pinned slot's Name to "endpoint-1" on first load while the incoming
// arrives with Name="". The test passes because endpointContentEqualForSet
// excludes Name from the equality check — see that helper's godoc. A
// refactor that re-includes Name in equality would make this test fail
// for a confusing reason (apparent content mismatch even though Address
// and LLMMeta are identical); update endpointContentEqualForSet's
// contract first if you change that.
func TestClusterManager_SetEndpointEmptyIDReusesOperatorPinnedID(t *testing.T) {
addr := model.SocketAddress{Address: "127.0.0.1", Port: 21140}
pinned := &model.Endpoint{ID: "operator-pinned", Address: addr}
cm := testClusterManager(testCluster("c", model.LoadBalancerRoundRobin, []*model.Endpoint{pinned}))
defer stopStoreRuntimes(cm.store)
cm.SetEndpoint("c", &model.Endpoint{Address: addr})
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 1,
"empty-ID call with matching hash and content must reuse the pinned slot, not append") {
return
}
assert.Equal(t, "operator-pinned", endpoints[0].ID,
"the operator's pinned ID must survive empty-ID re-registration")
}
// TestClusterManager_SetEndpointNameOnlyChangeIsIdempotent locks the
// deliberate trade-off documented on ClusterManager.SetEndpoint: Name is
// excluded from the dedup content check, so a SetEndpoint call whose only
// difference from an existing slot is Name is treated as an idempotent
// re-registration. The original Name stays in place. Callers that need to
// rename must DeleteEndpoint + SetEndpoint, or update the cluster config.
//
// Without this test we cannot tell whether the Name-not-applied behavior
// is a deliberate contract or a latent bug, and the next refactor could
// flip it silently.
func TestClusterManager_SetEndpointNameOnlyChangeIsIdempotent(t *testing.T) {
addr := model.SocketAddress{Address: "127.0.0.1", Port: 21150}
original := &model.Endpoint{ID: "ep-1", Name: "original-name", Address: addr}
cm := testClusterManager(testCluster("c", model.LoadBalancerRoundRobin, []*model.Endpoint{original}))
defer stopStoreRuntimes(cm.store)
cm.SetEndpoint("c", &model.Endpoint{ID: "ep-1", Name: "renamed", Address: addr})
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 1,
"Name-only change must NOT append a -2 sibling — that would grow the slice on harmless renames") {
return
}
assert.Equal(t, "ep-1", endpoints[0].ID)
assert.Equal(t, "original-name", endpoints[0].Name,
"the SetEndpoint godoc commits to not applying Name-only updates; "+
"if this assertion ever flips, update the SetEndpoint contract documentation too")
}
// TestClusterManager_SetEndpointMetadataOnlyChangeInheritsHealth locks the
// runtime side of the address-equal replace case. Pure metadata updates
// (same ID, same address, Metadata changed) must not restart the
// address-keyed healthcheck — that would discard a known-unhealthy verdict
// and briefly route to a node we already know is bad. The snapshot rebuild
// in newEndpointSnapshot inherits health by ID-and-address; this test
// pins that the wiring through SetEndpoint actually triggers that path.
func TestClusterManager_SetEndpointMetadataOnlyChangeInheritsHealth(t *testing.T) {
addr := model.SocketAddress{Address: "127.0.0.1", Port: 21160}
original := &model.Endpoint{
ID: "ep-1",
Address: addr,
Metadata: map[string]string{"version": "v1"},
}
config := testCluster("metadata-inherits-health", model.LoadBalancerRoundRobin,
[]*model.Endpoint{original}, testHealthCheck())
cm := testClusterManager(config)
defer stopStoreRuntimes(cm.store)
runtime := cm.store.clustersMap[config.Name]
if !assert.NotNil(t, runtime) {
return
}
assert.True(t, runtime.UpdateEndpointHealth("ep-1", addr.GetAddress(), false),
"baseline: mark the endpoint unhealthy so we can observe whether the metadata "+
"update preserves the runtime verdict")
if !assert.Nil(t, runtime.EndpointSnapshot().HealthyEndpointByID("ep-1"),
"endpoint must be unhealthy in the snapshot before the metadata update") {
return
}
cm.SetEndpoint(config.Name, &model.Endpoint{
ID: "ep-1",
Address: addr,
Metadata: map[string]string{"version": "v2"},
})
runtime = cm.store.clustersMap[config.Name]
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 1, "address-equal metadata update must not grow the endpoint slice") {
return
}
assert.Equal(t, "ep-1", endpoints[0].ID)
assert.Equal(t, "v2", endpoints[0].Metadata["version"],
"the new metadata must be published after the replace")
assert.Nil(t, runtime.EndpointSnapshot().HealthyEndpointByID("ep-1"),
"address-equal metadata update must NOT reset the runtime health verdict; "+
"the endpoint was unhealthy before and must stay unhealthy after")
assert.Equal(t, 1, healthCheckersLen(runtime),
"address-equal replace must not restart the address-keyed healthcheck")
}
// TestClusterManager_SetEndpointCertsDirChangeReplacesWithoutHealthcheckRestart
// pins the deliberate divergence between socketAddressContentEqual (used
// for dedup) and SocketAddress.GetAddress() (used for healthcheck keying).
// A SetEndpoint call that changes only CertsDir while keeping the same
// host/port is content-different (so the slot is replaced and the new
// CertsDir is published) but address-equal (so the healthcheck is NOT
// restarted — the backend is still reachable at the same address). If a
// future refactor accidentally collapses these two equality oracles, this
// test fails on the "CertsDir didn't update" side or the "healthcheck got
// restarted on a TLS-only change" side; either is a real regression worth
// surfacing.
func TestClusterManager_SetEndpointCertsDirChangeReplacesWithoutHealthcheckRestart(t *testing.T) {
addr := model.SocketAddress{Address: "127.0.0.1", Port: 21190, CertsDir: "/etc/certs/v1"}
original := &model.Endpoint{ID: "ep-1", Address: addr}
config := testCluster("certs-dir-replace", model.LoadBalancerRoundRobin,
[]*model.Endpoint{original}, testHealthCheck())
cm := testClusterManager(config)
defer stopStoreRuntimes(cm.store)
runtime := cm.store.clustersMap[config.Name]
if !assert.NotNil(t, runtime) {
return
}
originalCheckers := healthCheckerAddresses(runtime)
newAddr := model.SocketAddress{Address: "127.0.0.1", Port: 21190, CertsDir: "/etc/certs/v2"}
cm.SetEndpoint(config.Name, &model.Endpoint{ID: "ep-1", Address: newAddr})
runtime = cm.store.clustersMap[config.Name]
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 1,
"CertsDir-only change is content-different (Replace), so the slot must NOT accumulate "+
"a -2 sibling — that would be the dedup oracle agreeing with the address oracle, "+
"which is exactly the conflation this test guards against") {
return
}
assert.Equal(t, "ep-1", endpoints[0].ID)
assert.Equal(t, "/etc/certs/v2", endpoints[0].Address.CertsDir,
"the new CertsDir must be published — Replace overwrites the slot's TLS config")
assert.ElementsMatch(t, originalCheckers, healthCheckerAddresses(runtime),
"CertsDir change does not move the backend address, so the healthcheck registration "+
"must not flip; restarting it would briefly route to an unprobed address for no "+
"reason (TLS cert dir is irrelevant to L4 reachability)")
}
// runtime side of the address-changing replace case. When SetEndpoint
// overwrites a slot with a new address, the old address's healthcheck
// must stop (no goroutine leak) and a fresh checker must start against
// the new address. Goroutine accounting via healthCheckersLen would catch
// the leak only if the new checker also started; "old leaked, new never
// started" gives the same count of 1. So we also assert the registered
// address key flipped to the new address — the count + key together pin
// "old stopped AND new started".
func TestClusterManager_SetEndpointAddressChangeRestartsHealthcheck(t *testing.T) {
oldEndpoint := testEndpoint("ep-1", "127.0.0.1", 21170)
oldAddr := oldEndpoint.Address.GetAddress()
config := testCluster("address-restart", model.LoadBalancerRoundRobin,
[]*model.Endpoint{oldEndpoint}, testHealthCheck())
cm := testClusterManager(config)
defer stopStoreRuntimes(cm.store)
runtime := cm.store.clustersMap[config.Name]
if !assert.NotNil(t, runtime) {
return
}
if !assert.ElementsMatch(t, []string{oldAddr}, healthCheckerAddresses(runtime),
"baseline: exactly one healthcheck, keyed by the original address") {
return
}
newEndpoint := testEndpoint("ep-1", "127.0.0.2", 21171)
newAddr := newEndpoint.Address.GetAddress()
cm.SetEndpoint(config.Name, newEndpoint)
runtime = cm.store.clustersMap[config.Name]
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 1, "address-changing replace must keep exactly one endpoint") {
return
}
assert.Equal(t, "127.0.0.2", endpoints[0].Address.Address)
assert.ElementsMatch(t, []string{newAddr}, healthCheckerAddresses(runtime),
"address-changing replace must stop the old address's healthcheck AND start the new one; "+
"keys must equal exactly {newAddr} — {oldAddr} means new-never-started, "+
"{oldAddr,newAddr} means old leaked")
}
// TestClusterManager_SetEndpointRepeatedUpdatesDoNotGrowEndpoints simulates
// the long-running regression the registry-update path would have hit:
// SpringCloud/Nacos firing OnUpdateServiceInstance repeatedly for the same
// instance as its address shifts. Under the old "append -2" semantics the
// endpoint slice grew unboundedly on every update; under the replace
// semantics it must stay at exactly one entry no matter how many updates
// arrive. healthcheck count likewise stays at one.
func TestClusterManager_SetEndpointRepeatedUpdatesDoNotGrowEndpoints(t *testing.T) {
config := testCluster("update-accumulation", model.LoadBalancerRoundRobin,
[]*model.Endpoint{testEndpoint("ep-1", "127.0.0.1", 21180)}, testHealthCheck())
cm := testClusterManager(config)
defer stopStoreRuntimes(cm.store)
const updates = 100
for i := 0; i < updates; i++ {
cm.SetEndpoint(config.Name, testEndpoint("ep-1", fmt.Sprintf("127.0.%d.%d", (i>>8)&0xff, i&0xff), 21181+i))
}
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 1,
"100 registry-update events for the same instance must collapse to one endpoint; "+
"any number > 1 means the dynamic SetEndpoint path is accumulating stale slots") {
return
}
assert.Equal(t, "ep-1", endpoints[0].ID)
lastAddr := fmt.Sprintf("127.0.%d.%d", ((updates-1)>>8)&0xff, (updates-1)&0xff)
assert.Equal(t, lastAddr, endpoints[0].Address.Address,
"the final endpoint must carry the last update's address")
assert.Equal(t, 21181+updates-1, endpoints[0].Address.Port)
runtime := cm.store.clustersMap[config.Name]
assert.Equal(t, 1, healthCheckersLen(runtime),
"healthcheck goroutines must not leak across repeated address-changing updates")
}
func TestClusterManager_AssembleEndpointsPreservesExplicitID(t *testing.T) {
cluster := &model.ClusterConfig{
Name: "explicit-id",
LbStr: model.LoadBalancerRoundRobin,
Endpoints: []*model.Endpoint{
{ID: "operator-pinned", Address: model.SocketAddress{Address: "127.0.0.1", Port: 21082}},
},
}
cm := testClusterManager(cluster)
defer stopStoreRuntimes(cm.store)
if assert.Len(t, cm.store.Config[0].Endpoints, 1) {
assert.Equal(t, "operator-pinned", cm.store.Config[0].Endpoints[0].ID)
}
}
// TestClusterManager_AssembleEndpointsDeduplicatesDuplicateGenerated locks
// the PR-3 dedup behavior: when two static endpoints share every input that
// feeds GenerateEndpointID (cluster name, address, LLM credential), the
// second one's ID gets a -2 suffix so runtime health-key lookups stay
// unambiguous. A warning is logged so the operator notices.
func TestClusterManager_AssembleEndpointsDeduplicatesDuplicateGenerated(t *testing.T) {
cluster := &model.ClusterConfig{
Name: "collapse-id",
LbStr: model.LoadBalancerRoundRobin,
Endpoints: []*model.Endpoint{
{Address: model.SocketAddress{Address: "127.0.0.1", Port: 21083}},
{Address: model.SocketAddress{Address: "127.0.0.1", Port: 21083}},
},
}
cm := testClusterManager(cluster)
defer stopStoreRuntimes(cm.store)
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 2) {
return
}
assert.NotEqual(t, endpoints[0].ID, endpoints[1].ID,
"PR-3 deduplicates colliding generated IDs instead of collapsing them")
assert.Contains(t, endpoints[0].ID, "pixiu-generated-endpoint-")
assert.Equal(t, endpoints[0].ID+"-2", endpoints[1].ID,
"second endpoint must be suffixed -2 relative to the first")
}
// TestAssembleEndpointsDeduplicatesExplicitID locks Blocker 6: when an
// operator writes the same `id:` on two static endpoints, the second one
// becomes `<id>-2` rather than `generated-<hash>-2`. Most-readable
// (least-surprising) choice for dashboards and log correlation.
func TestAssembleEndpointsDeduplicatesExplicitID(t *testing.T) {
cluster := &model.ClusterConfig{
Name: "explicit-id-collision",
LbStr: model.LoadBalancerRoundRobin,
Endpoints: []*model.Endpoint{
{ID: "foo", Address: model.SocketAddress{Address: "127.0.0.1", Port: 21090}},
{ID: "foo", Address: model.SocketAddress{Address: "127.0.0.1", Port: 21091}},
},
}
cm := testClusterManager(cluster)
defer stopStoreRuntimes(cm.store)
endpoints := cm.store.Config[0].Endpoints
if !assert.Len(t, endpoints, 2) {
return
}
assert.Equal(t, "foo", endpoints[0].ID, "first explicit ID is preserved")
assert.Equal(t, "foo-2", endpoints[1].ID,
"colliding second endpoint must be suffixed off the operator's ID, "+
"not the generated- hash, so the operator's choice stays readable")
}
func testClusterManager(clusters ...*model.ClusterConfig) *ClusterManager {
return CreateDefaultClusterManager(&model.Bootstrap{
StaticResources: model.StaticResources{
Clusters: clusters,
},
})
}
func testCluster(name string, lb model.LbPolicyType, endpoints []*model.Endpoint, healthChecks ...model.HealthCheckConfig) *model.ClusterConfig {
cluster := &model.ClusterConfig{
Name: name,
LbStr: lb,
Endpoints: endpoints,
}
if len(healthChecks) > 0 {
cluster.HealthChecks = append([]model.HealthCheckConfig(nil), healthChecks...)
}
return cluster
}
func testEndpoint(id string, host string, port int) *model.Endpoint {
return &model.Endpoint{
ID: id,
Name: fmt.Sprintf("endpoint-%s", id),
Address: model.SocketAddress{
Address: host,
Port: port,
},
}
}
func testHealthCheck() model.HealthCheckConfig {
return model.HealthCheckConfig{
Protocol: "tcp",
TimeoutConfig: "1h",
IntervalConfig: "1h",
}
}
func stopStoreRuntimes(store *ClusterStore) {
if store == nil {
return
}
for _, runtime := range store.clustersMap {
if runtime != nil {
runtime.Stop()
}
}
}
func healthCheckersLen(runtime *cluster.Cluster) int {
if runtime == nil || runtime.HealthCheck == nil {
return 0
}
return reflect.ValueOf(runtime.HealthCheck).Elem().FieldByName("checkers").Len()
}
// healthCheckerAddresses returns the address keys currently registered
// with the runtime's health checker. It exists so address-changing tests
// can distinguish "old stopped + new started" (keys = {new}) from "old
// leaked + new not started" (keys = {old}) — both states would otherwise
// produce healthCheckersLen == 1 and look identical.
func healthCheckerAddresses(runtime *cluster.Cluster) []string {
if runtime == nil || runtime.HealthCheck == nil {
return nil
}
checkers := reflect.ValueOf(runtime.HealthCheck).Elem().FieldByName("checkers")
addrs := make([]string, 0, checkers.Len())
iter := checkers.MapRange()
for iter.Next() {
addrs = append(addrs, iter.Key().String())
}
return addrs
}