Files
2fbaa43f1e enhance: make etcd client dial timeout configurable (#50901)
## What this PR does

Makes the etcd client dial timeout configurable via `etcd.dialTimeout`
(milliseconds, default `5000` — unchanged behavior).

## Why

The dial timeout was hardcoded to 5s in `GetRemoteEtcdClient` /
`GetRemoteEtcdClientWithAuth` / `GetRemoteEtcdSSLClientWithCfg`. On
newly scaled-out nodes, transient network conditions during pod startup
(sidecar proxy not fully ready, DNS propagation delay, or etcd
connection pressure from many nodes starting at once) can push the
blocking dial past 5s, causing the second etcd client created during
`streaming.Init()` to panic with `context deadline exceeded`. Operators
had no way to tune it.

## Changes

- `pkg/util/etcd/etcd_util.go`: add `WithDialTimeout` client option (a
non-positive value keeps the default).
- `pkg/util/paramtable/service_param.go`: add `etcd.dialTimeout` param
and wire it through `EtcdConfig.ClientOptions()`.
- `configs/milvus.yaml`: document the new option.
- `pkg/config/etcd_source.go` + `pkg/config/source.go` +
`pkg/util/paramtable/base_table.go`: thread `dialTimeout` into the
bootstrap config-source client as well (see below).

Every paramtable-based etcd client honors it: meta kv, coordinator/node
services, and the woodpecker WAL builder (the `streaming.Init()` path
from the issue, which already passes `ClientOptions()...`).

## Bootstrap config-source client

The bootstrap config-source client (`pkg/config/etcd_source.go`) is
created before paramtable is fully ready, so it cannot read
`etcd.dialTimeout` from etcd itself. But it does not need to:
`initConfigsFromLocal()` loads the file/env config source *before*
`initConfigsFromRemote()` builds the `EtcdInfo`, and the client's
endpoints/auth/TLS are already read from there. `dialTimeout` is
threaded through the same `EtcdInfo` path, so a value set in
`milvus.yaml`/env applies to this client too. A zero/non-positive value
is a no-op, so unconfigured behavior stays at the 5s default.

The only remaining paths still on the fixed default are CLI/offline
tools and the etcd `HealthCheck` (which uses a short fixed 3s context) —
intended.

issue: #50867

---------

Signed-off-by: xiaofanluan <xf@hjjaq.com>
Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
Co-authored-by: xiaofanluan <xf@hjjaq.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:28:31 +08:00

362 lines
10 KiB
Go

// Licensed to the LF AI & Data foundation 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 paramtable
import (
"context"
"os"
"path"
"runtime"
"strings"
"sync"
"time"
"github.com/milvus-io/milvus/pkg/v3/config"
"github.com/milvus-io/milvus/pkg/v3/mlog"
"github.com/milvus-io/milvus/pkg/v3/util/etcd"
"github.com/milvus-io/milvus/pkg/v3/util/typeutil"
)
// UniqueID is type alias of typeutil.UniqueID
type UniqueID = typeutil.UniqueID
const (
DefaultGlogConf = "glog.conf"
DefaultMinioHost = "localhost"
DefaultMinioPort = "9000"
DefaultMinioAccessKey = "minioadmin"
DefaultMinioSecretAccessKey = "minioadmin"
DefaultMinioUseSSL = "false"
DefaultMinioBucketName = "a-bucket"
DefaultMinioUseIAM = "false"
DefaultMinioCloudProvider = "aws"
DefaultMinioIAMEndpoint = ""
DefaultEtcdEndpoints = "localhost:2379"
DefaultLogFormat = "text"
DefaultLogLevelForBase = "debug"
DefaultRootPath = ""
DefaultMinioLogLevel = "fatal"
DefaultKnowhereThreadPoolNumRatioInBuild = 1
DefaultKnowhereThreadPoolNumRatioInBuildOfStandalone = 0.75
DefaultMinioRegion = ""
DefaultMinioUseVirtualHost = "false"
DefaultMinioRequestTimeout = "10000"
DefaultMinioMaxConnections = "100"
)
// Const of Global Config List
func globalConfigPrefixs() []string {
return []string{"metastore", "localStorage", "etcd", "tikv", "minio", "pulsar", "kafka", "rocksmq", "log", "grpc", "common", "quotaAndLimits", "trace"}
}
// support read "milvus.yaml", "_test.yaml", "default.yaml", "user.yaml" as this order.
// order: milvus.yaml < _test.yaml < default.yaml < user.yaml, do not change the order below.
// Use _test.yaml only for test related purpose.
var defaultYaml = []string{"milvus.yaml", "_test.yaml", "default.yaml", "user.yaml"}
// BaseTable the basics of paramtable
type BaseTable struct {
once sync.Once
mgr *config.Manager
config *baseTableConfig
}
type baseTableConfig struct {
configDir string
refreshInterval time.Duration
skipRemote bool
skipEnv bool
yamlFiles []string
}
type Option func(*baseTableConfig)
func Files(files []string) Option {
return func(bt *baseTableConfig) {
bt.yamlFiles = files
}
}
func Interval(interval time.Duration) Option {
return func(bt *baseTableConfig) {
bt.refreshInterval = interval
}
}
func SkipRemote(skip bool) Option {
return func(bt *baseTableConfig) {
bt.skipRemote = skip
}
}
func SkipEnv(skip bool) Option {
return func(bt *baseTableConfig) {
bt.skipEnv = skip
}
}
// NewBaseTableFromYamlOnly only used in migration tool.
// Maybe we shouldn't limit the configDir internally.
func NewBaseTableFromYamlOnly(yaml string) *BaseTable {
return NewBaseTable(Files([]string{yaml}), SkipRemote(true), SkipEnv(true))
}
func NewBaseTable(opts ...Option) *BaseTable {
defaultConfig := &baseTableConfig{
configDir: initConfPath(),
yamlFiles: defaultYaml,
refreshInterval: 5 * time.Second,
skipRemote: false,
skipEnv: false,
}
for _, opt := range opts {
opt(defaultConfig)
}
bt := &BaseTable{config: defaultConfig}
bt.init()
return bt
}
// init initializes the param table.
// if refreshInterval greater than 0 will auto refresh config from source
func (bt *BaseTable) init() {
formatter := func(key string) string {
ret := strings.ToLower(key)
ret = strings.TrimPrefix(ret, "milvus.")
ret = strings.ReplaceAll(ret, "/", "")
ret = strings.ReplaceAll(ret, "_", "")
ret = strings.ReplaceAll(ret, ".", "")
return ret
}
var err error
bt.mgr, err = config.Init()
if err != nil {
mlog.Error(context.TODO(), "failed to initialize config manager", mlog.Err(err))
panic(err)
}
if !bt.config.skipEnv {
err := bt.mgr.AddSource(config.NewEnvSource(formatter))
if err != nil {
mlog.Warn(context.TODO(), "init baseTable with env failed", mlog.Err(err))
return
}
}
bt.initConfigsFromLocal()
if !bt.config.skipRemote {
bt.initConfigsFromRemote()
}
}
func (bt *BaseTable) initConfigsFromLocal() {
refreshInterval := bt.config.refreshInterval
var files []string
for _, file := range bt.config.yamlFiles {
_, err := os.Stat(path.Join(bt.config.configDir, file))
// not found
if os.IsNotExist(err) {
continue
}
if err != nil {
mlog.Warn(context.TODO(), "failed to check file", mlog.String("file", file), mlog.Err(err))
panic(err)
}
files = append(files, path.Join(bt.config.configDir, file))
}
err := bt.mgr.AddSource(config.NewFileSource(&config.FileInfo{
Files: files,
RefreshInterval: refreshInterval,
}))
if err != nil {
mlog.Warn(context.TODO(), "init baseTable with file failed", mlog.Strings("configFile", bt.config.yamlFiles), mlog.Err(err))
return
}
}
func (bt *BaseTable) initConfigsFromRemote() {
refreshInterval := bt.config.refreshInterval
etcdConfig := EtcdConfig{}
etcdConfig.Init(bt)
etcdConfig.Endpoints.PanicIfEmpty = false
etcdConfig.RootPath.PanicIfEmpty = false
if etcdConfig.Endpoints.GetValue() == "" {
return
}
if etcdConfig.UseEmbedEtcd.GetAsBool() && !etcd.HasServer() {
return
}
info := &config.EtcdInfo{
UseEmbed: etcdConfig.UseEmbedEtcd.GetAsBool(),
EnableAuth: etcdConfig.EtcdEnableAuth.GetAsBool(),
UserName: etcdConfig.EtcdAuthUserName.GetValue(),
PassWord: etcdConfig.EtcdAuthPassword.GetValue(),
UseSSL: etcdConfig.EtcdUseSSL.GetAsBool(),
Endpoints: etcdConfig.Endpoints.GetAsStrings(),
CertFile: etcdConfig.EtcdTLSCert.GetValue(),
KeyFile: etcdConfig.EtcdTLSKey.GetValue(),
CaCertFile: etcdConfig.EtcdTLSCACert.GetValue(),
MinVersion: etcdConfig.EtcdTLSMinVersion.GetValue(),
KeyPrefix: etcdConfig.RootPath.GetValue(),
DialTimeout: etcdConfig.DialTimeout.GetAsDuration(time.Millisecond),
RefreshInterval: refreshInterval,
}
s, err := config.NewEtcdSource(info)
if err != nil {
mlog.Info(context.TODO(), "init with etcd failed", mlog.Err(err))
return
}
bt.mgr.AddSource(s)
s.SetEventHandler(bt.mgr)
}
// GetConfigDir returns the config directory
func (bt *BaseTable) GetConfigDir() string {
return bt.config.configDir
}
func initConfPath() string {
// check if user set conf dir through env
configDir := os.Getenv("MILVUSCONF")
if len(configDir) != 0 {
return configDir
}
runPath, err := os.Getwd()
if err != nil {
panic(err)
}
configDir = runPath + "/configs"
if _, err := os.Stat(configDir); err != nil {
_, fpath, _, _ := runtime.Caller(0)
configDir = path.Dir(fpath) + "/../../../configs"
}
return configDir
}
func (bt *BaseTable) FileConfigs() map[string]string {
return bt.mgr.FileConfigs()
}
func (bt *BaseTable) UpdateSourceOptions(opts ...config.Option) {
bt.mgr.UpdateSourceOptions(opts...)
}
// Load loads an object with @key.
func (bt *BaseTable) Load(key string) (string, error) {
_, v, err := bt.mgr.GetConfig(key)
return v, err
}
func (bt *BaseTable) Get(key string) string {
return bt.GetWithDefault(key, "")
}
// GetWithDefault loads an object with @key. If the object does not exist, @defaultValue will be returned.
func (bt *BaseTable) GetWithDefault(key, defaultValue string) string {
if bt.mgr == nil {
return defaultValue
}
_, str, err := bt.mgr.GetConfig(key)
if err != nil {
return defaultValue
}
return str
}
// Remove Config by key
func (bt *BaseTable) Remove(key string) error {
normalizedKey := strings.ToLower(strings.TrimPrefix(key, "milvus."))
bt.mgr.DeleteConfig(key)
bt.mgr.EvictCachedValue(key)
// Fire runtime config update event so watchers (e.g. tracing) can react immediately.
bt.mgr.OnEvent(&config.Event{
EventSource: config.RuntimeSource,
EventType: config.DeleteType,
Key: normalizedKey,
Value: "",
HasUpdated: true,
})
return nil
}
// Update Config
func (bt *BaseTable) Save(key, value string) error {
normalizedKey := strings.ToLower(strings.TrimPrefix(key, "milvus."))
bt.mgr.SetConfig(key, value)
bt.mgr.EvictCachedValue(key)
// Fire runtime config update event so watchers (e.g. tracing) can react immediately.
bt.mgr.OnEvent(&config.Event{
EventSource: config.RuntimeSource,
EventType: config.UpdateType,
Key: normalizedKey,
Value: value,
HasUpdated: true,
})
return nil
}
func (bt *BaseTable) SaveGroup(group map[string]string) error {
for key, value := range group {
normalizedKey := strings.ToLower(strings.TrimPrefix(key, "milvus."))
bt.mgr.SetMapConfig(key, value)
// Fire runtime config update event so watchers (e.g. tracing) can react immediately.
bt.mgr.OnEvent(&config.Event{
EventSource: config.RuntimeSource,
EventType: config.UpdateType,
Key: normalizedKey,
Value: value,
HasUpdated: true,
})
}
return nil
}
// Reset Config to default value
func (bt *BaseTable) Reset(key string) error {
normalizedKey := strings.ToLower(strings.TrimPrefix(key, "milvus."))
bt.mgr.ResetConfig(key)
bt.mgr.EvictCachedValue(key)
// Fire runtime config update event so watchers can refresh their derived state.
// If the key is not found in any source after reset, emit a DELETE event.
if _, v, err := bt.mgr.GetConfig(key); err == nil {
bt.mgr.OnEvent(&config.Event{
EventSource: config.RuntimeSource,
EventType: config.UpdateType,
Key: normalizedKey,
Value: v,
HasUpdated: true,
})
} else {
bt.mgr.OnEvent(&config.Event{
EventSource: config.RuntimeSource,
EventType: config.DeleteType,
Key: normalizedKey,
Value: "",
HasUpdated: true,
})
}
return nil
}
func (bt *BaseTable) Manager() *config.Manager {
return bt.mgr
}