Files
tasks/common/membership/ringpop/factory.go
Anton RomanovichandGitHub 71a623ea8c Allow dynamic configuration of ringpop replica points (#9702)
## What changed?

Added a configurable `ReplicaPoints` variable to the ringpop service
resolver. Instead of a hardcoded constant (`replicaPoints = 100`), the
number of virtual nodes per host is now
  controlled by a `var ReplicaPoints func()` int that can be overridden.

When the value returned by `ReplicaPoints()` changes, the hash ring is
rebuilt and a `ChangedEvent` is emitted -- even if membership itself
hasn't changed.

## Why?

The default of 100 replica points is not optimal for all combinations of
shard and history node counts and may lead to uneven shard distribution
across history nodes.

For example, having 4096 shards with 9 history nodes with 100 replica
points gives us std dev around 50 shards. Increasing replica points to
500 lowers std dev to around 30 shards. The optimal replica points is
roughly `shards / nodes`, which equates the number of "shard points" and
"node points" on the ring.

For 4096 shards and 9 history nodes, distribution got better as follows
* with 100 replica points: 370, 380, 408, 442, 452, 488, 510, 520, 526
(shards per node);
* with 500 replica points: 426, 444, 446, 446, 450, 452, 458, 474, 500.

This patch allows setting custom `ringpop.ReplicaPoints` (e.g. to
dynamic config value getter) before starting the server, and gradually
(to avoid reshuffling too many shards at once) increasing replica points
to the desired value.

## How did you test it?
- [x] built
- [x] run locally and tested manually
- [x] covered by existing tests
- [ ] added new unit test(s)
- [ ] added new functional test(s)

## Potential risks
None if `var ReplicaPoints func()` not modified.
If the server is built with overridden `ReplicaPoints`, it may cause
ring inconsistency across cluster nodes during the deployment process.
2026-04-06 17:50:12 -07:00

271 lines
8.4 KiB
Go

package ringpop
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"sync"
"time"
"github.com/temporalio/ringpop-go"
"github.com/temporalio/tchannel-go"
"go.temporal.io/server/common/config"
"go.temporal.io/server/common/convert"
"go.temporal.io/server/common/dynamicconfig"
"go.temporal.io/server/common/headers"
"go.temporal.io/server/common/log"
"go.temporal.io/server/common/log/tag"
"go.temporal.io/server/common/membership"
"go.temporal.io/server/common/persistence"
"go.temporal.io/server/common/primitives"
"go.temporal.io/server/common/rpc/encryption"
"go.temporal.io/server/common/util"
"go.temporal.io/server/temporal/environment"
"go.uber.org/fx"
)
const (
defaultMaxJoinDuration = 10 * time.Second
persistenceOperationTimeout = 10 * time.Second
)
type factoryParams struct {
fx.In
Config *config.Membership
ServiceName primitives.ServiceName
ServicePortMap config.ServicePortMap
Logger log.Logger
MetadataManager persistence.ClusterMetadataManager
RPCConfig *config.RPC
TLSFactory encryption.TLSConfigProvider
DC *dynamicconfig.Collection
}
// factory provides ringpop based membership objects
type factory struct {
Config *config.Membership
ServiceName primitives.ServiceName
ServicePortMap config.ServicePortMap
Logger log.Logger
MetadataManager persistence.ClusterMetadataManager
RPCConfig *config.RPC
TLSFactory encryption.TLSConfigProvider
DC *dynamicconfig.Collection
channel *tchannel.Channel
monitor *monitor
chOnce sync.Once
monOnce sync.Once
}
var errMalformedBroadcastAddress = errors.New("ringpop config malformed `broadcastAddress` param")
// newFactory builds a ringpop factory
func newFactory(params factoryParams) (*factory, error) {
cfg := params.Config
if cfg.BroadcastAddress != "" && net.ParseIP(cfg.BroadcastAddress) == nil {
return nil, fmt.Errorf("%w: %s", errMalformedBroadcastAddress, cfg.BroadcastAddress)
}
if cfg.MaxJoinDuration == 0 {
cfg.MaxJoinDuration = defaultMaxJoinDuration
}
return &factory{
Config: params.Config,
ServiceName: params.ServiceName,
ServicePortMap: params.ServicePortMap,
Logger: params.Logger,
MetadataManager: params.MetadataManager,
RPCConfig: params.RPCConfig,
TLSFactory: params.TLSFactory,
DC: params.DC,
}, nil
}
// getMonitor returns a membership monitor
func (factory *factory) getMonitor() *monitor {
factory.monOnce.Do(func() {
ctx, cancel := context.WithTimeout(context.Background(), persistenceOperationTimeout)
defer cancel()
ctx = headers.SetCallerInfo(ctx, headers.SystemBackgroundHighCallerInfo)
currentClusterMetadata, err := factory.MetadataManager.GetCurrentClusterMetadata(ctx)
if err != nil {
factory.Logger.Fatal("Failed to get current cluster ID", tag.Error(err))
}
appName := "temporal"
if currentClusterMetadata.UseClusterIdMembership {
appName = fmt.Sprintf("temporal-%s", currentClusterMetadata.GetClusterId())
}
rp, err := ringpop.New(appName, ringpop.Channel(factory.getTChannel()), ringpop.AddressResolverFunc(factory.broadcastAddressResolver))
if err != nil {
factory.Logger.Fatal("Failed to get new ringpop", tag.Error(err))
}
// Empirically, ringpop updates usually propagate in under a second even in relatively large clusters.
// 3 seconds is an over-estimate to be safer.
maxPropagationTime := dynamicconfig.RingpopApproximateMaxPropagationTime.Get(factory.DC)()
replicaPoints := dynamicconfig.RingpopReplicaPoints.Get(factory.DC)()
factory.monitor = newMonitor(
factory.ServiceName,
factory.ServicePortMap,
rp,
factory.Logger,
factory.MetadataManager,
factory.broadcastAddressResolver,
factory.Config.MaxJoinDuration,
maxPropagationTime,
factory.getJoinTime(maxPropagationTime),
replicaPoints,
)
})
return factory.monitor
}
func (factory *factory) getJoinTime(maxPropagationTime time.Duration) time.Time {
var alignTime time.Duration
switch factory.ServiceName {
case primitives.MatchingService:
alignTime = dynamicconfig.MatchingAlignMembershipChange.Get(factory.DC)()
case primitives.HistoryService:
alignTime = dynamicconfig.HistoryAlignMembershipChange.Get(factory.DC)()
}
if alignTime == 0 {
return time.Time{}
}
return util.NextAlignedTime(time.Now().Add(maxPropagationTime), alignTime)
}
func (factory *factory) broadcastAddressResolver() (string, error) {
return buildBroadcastHostPort(factory.getTChannel().PeerInfo(), factory.Config.BroadcastAddress)
}
func (factory *factory) getTChannel() *tchannel.Channel {
factory.chOnce.Do(func() {
ringpopServiceName := fmt.Sprintf("%v-ringpop", factory.ServiceName)
ringpopHostAddress := net.JoinHostPort(factory.getListenIP().String(), convert.IntToString(factory.RPCConfig.MembershipPort))
enableTLS := dynamicconfig.EnableRingpopTLS.Get(factory.DC)()
var tChannel *tchannel.Channel
if enableTLS {
tChannel = factory.getTLSChannel(ringpopHostAddress, ringpopServiceName)
} else {
tChannel = factory.getTCPChannel(ringpopHostAddress, ringpopServiceName)
}
factory.channel = tChannel
})
return factory.channel
}
func (factory *factory) getTCPChannel(ringpopHostAddress string, ringpopServiceName string) *tchannel.Channel {
listener, err := net.Listen("tcp", ringpopHostAddress)
if err != nil {
factory.Logger.Fatal("Failed to start ringpop listener", tag.Error(err), tag.Address(ringpopHostAddress))
}
tChannel, err := tchannel.NewChannel(ringpopServiceName, &tchannel.ChannelOptions{})
if err != nil {
factory.Logger.Fatal("Failed to create ringpop TChannel", tag.Error(err))
}
if err := tChannel.Serve(listener); err != nil {
factory.Logger.Fatal("Failed to serve ringpop listener", tag.Error(err), tag.Address(ringpopHostAddress))
}
return tChannel
}
func (factory *factory) getTLSChannel(ringpopHostAddress string, ringpopServiceName string) *tchannel.Channel {
clientTLSConfig, err := factory.TLSFactory.GetInternodeClientConfig()
if err != nil {
factory.Logger.Fatal("Failed to get internode TLS client config", tag.Error(err))
}
serverTLSConfig, err := factory.TLSFactory.GetInternodeServerConfig()
if err != nil {
factory.Logger.Fatal("Failed to get internode TLS server config", tag.Error(err))
}
listener, err := tls.Listen("tcp", ringpopHostAddress, serverTLSConfig)
if err != nil {
factory.Logger.Fatal("Failed to start ringpop TLS listener", tag.Error(err), tag.Address(ringpopHostAddress))
}
dialer := tls.Dialer{Config: clientTLSConfig}
tChannel, err := tchannel.NewChannel(ringpopServiceName, &tchannel.ChannelOptions{Dialer: dialer.DialContext})
if err != nil {
factory.Logger.Fatal("Failed to create ringpop TChannel", tag.Error(err))
}
if err := tChannel.Serve(listener); err != nil {
factory.Logger.Fatal("Failed to serve ringpop listener", tag.Error(err), tag.Address(ringpopHostAddress))
}
return tChannel
}
func (factory *factory) getListenIP() net.IP {
if factory.RPCConfig.BindOnLocalHost && len(factory.RPCConfig.BindOnIP) > 0 {
factory.Logger.Fatal("ListenIP failed, bindOnLocalHost and bindOnIP are mutually exclusive")
return nil
}
if factory.RPCConfig.BindOnLocalHost {
return net.ParseIP(environment.GetLocalhostIP())
}
if len(factory.RPCConfig.BindOnIP) > 0 {
ip := net.ParseIP(factory.RPCConfig.BindOnIP)
if ip != nil {
return ip
}
factory.Logger.Fatal("ListenIP failed, unable to parse bindOnIP value", tag.Address(factory.RPCConfig.BindOnIP))
return nil
}
ip, err := config.ListenIP()
if err != nil {
factory.Logger.Fatal("ListenIP failed", tag.Error(err))
return nil
}
return ip
}
// closeTChannel allows fx Stop hook to close channel
func (factory *factory) closeTChannel() {
if factory.channel != nil {
factory.getTChannel().Close()
factory.channel = nil
}
}
func (factory *factory) getHostInfoProvider() (membership.HostInfoProvider, error) {
address, err := factory.broadcastAddressResolver()
if err != nil {
return nil, err
}
servicePort, ok := factory.ServicePortMap[factory.ServiceName]
if !ok {
return nil, membership.ErrUnknownService
}
// The broadcastAddressResolver returns the host:port used to listen for
// ringpop messages. We use a different port for the service, so we
// replace that portion.
serviceAddress, err := replaceServicePort(address, servicePort)
if err != nil {
return nil, err
}
hostInfo := membership.NewHostInfoFromAddress(serviceAddress)
return membership.NewHostInfoProvider(hostInfo), nil
}