Copilot commented on code in PR #1106: URL: https://github.com/apache/incubator-seata-go/pull/1106#discussion_r3132211182
########## pkg/discovery/naming_server.go: ########## @@ -0,0 +1,673 @@ +/* + * 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 discovery + +import ( + "encoding/json" + "errors" + "fmt" + gostnet "github.com/dubbogo/gost/net" + "go.uber.org/zap" + "net/http" + "net/url" + "seata.apache.org/seata-go/v2/pkg/util/rand" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + httpPrefix = "http://" + healthCheckThreshold = 3 + longPollTimeoutPeriod = 28 * time.Second + authorizationHeader = "Authorization" + contentTypeJSON = "application/json" + namingAddrCacheTTL = 30 * time.Second + failureRecoveryThreshold = 3 + maxRetryAttempts = 3 + retryDelayMs = 1000 +) + +type MetaResponse struct { + Term int64 `json:"term"` + ClusterList []Cluster `json:"clusterList"` +} + +type Cluster struct { + ClusterName string `json:"clusterName"` + ClusterType string `json:"clusterType"` + UnitData []Unit `json:"unitData"` +} + +type Unit struct { + UnitName string `json:"unitName"` + NamingInstanceList []NamingServerNode `json:"namingInstanceList"` +} + +type NamingServerNode struct { + Role ClusterRole `json:"role"` + Term int64 `json:"term"` + Transaction Endpoint `json:"transaction"` + Control Endpoint `json:"control"` + Internal Endpoint `json:"internal"` + Group string `json:"group"` + Version string `json:"version"` + Metadata map[string]interface{} `json:"metadata"` + TimeStamp int64 `json:"timeStamp"` + Weight float64 `json:"weight"` + Healthy bool `json:"healthy"` + Unit string `json:"unit"` +} + +type Endpoint struct { + Host string `json:"host"` + Port int `json:"port"` + Protocol string `json:"protocol"` +} + +type ExternalEndpoint struct { + Host string `json:"host"` + ControlPort int `json:"controlPort"` + TransactionPort int `json:"transactionPort"` +} + +type ClusterRole string + +const ( + ClusterRoleLeader ClusterRole = "LEADER" + ClusterRoleMember ClusterRole = "MEMBER" +) + +type NamingServerClient struct { + config *NamingServerConfig + logger *zap.Logger + mu sync.Mutex + instance *NamingServerClient + term int64 + jwtToken string + tokenTimeStamp int64 + isSubscribed bool + namingAddrCache string + namingAddrCacheTimestamp int64 + + availableNamingMap sync.Map + vgroupAddressMap sync.Map + listenerServiceMap sync.Map + + healthCheckTicker *time.Ticker + closeChan chan struct{} + wg sync.WaitGroup + + httpClient *http.Client + longPollClient *http.Client +} + +type NamingListener interface { + OnEvent(vGroup string) error +} + +type NamingServerRegistryService struct { + client *NamingServerClient +} + +var _ NamingserverRegistry = (*NamingServerRegistryService)(nil) + +func (n *NamingServerRegistryService) Lookup(key string) ([]*ServiceInstance, error) { + return n.client.Lookup(key) +} + +func (n *NamingServerRegistryService) Close() { + n.client.Close() +} + +func newNamingServerRegistryService(_ *ServiceConfig, cfg *NamingServerConfig) RegistryService { + client := GetInstance(cfg) + return &NamingServerRegistryService{ + client: client, + } +} + +var ( + namingServerInstance *NamingServerClient + namingServerOnce sync.Once +) + +func GetInstance(config *NamingServerConfig) *NamingServerClient { + namingServerOnce.Do(func() { + namingServerInstance = &NamingServerClient{ + config: config, + logger: zap.L().Named("naming-server-client"), + closeChan: make(chan struct{}), + healthCheckTicker: time.NewTicker(time.Duration(config.HeartbeatPeriod) * time.Millisecond), + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // Initialize available naming server addresses from config + namingServerInstance.initNamingAddrs() + namingServerInstance.initHealthCheck() + }) + return namingServerInstance +} + +func resetInstance() { + if namingServerInstance != nil { + namingServerInstance.Close() + namingServerInstance.mu.Lock() + namingServerInstance.clearNamingAddrCache() + namingServerInstance.mu.Unlock() + } + namingServerInstance = nil + namingServerOnce = sync.Once{} +} + +func (c *NamingServerClient) initNamingAddrs() { + addrs := c.getNamingAddrs() + for _, addr := range addrs { + c.availableNamingMap.Store(addr, int32(0)) + } +} + +func (c *NamingServerClient) initHealthCheck() { + c.wg.Add(1) + go func() { + defer c.wg.Done() + for { + select { + case <-c.healthCheckTicker.C: + urlList := c.getNamingAddrs() + c.checkAvailableNamingAddr(urlList) + case <-c.closeChan: + return + } + } + }() +} + +func (c *NamingServerClient) checkAvailableNamingAddr(urlList []string) { + for _, addr := range urlList { + isHealthy := c.doHealthCheck(addr) + + val, _ := c.availableNamingMap.LoadOrStore(addr, int32(0)) + failCount := val.(int32) + + if !isHealthy { + newFailCount := atomic.AddInt32(&failCount, 1) + c.availableNamingMap.Store(addr, newFailCount) + if newFailCount == 1 { + c.logger.Warn("naming server check failed", zap.String("addr", addr), zap.Int32("failCount", newFailCount)) + } else if newFailCount >= healthCheckThreshold { + c.logger.Error("naming server offline", zap.String("addr", addr), zap.Int32("failCount", newFailCount)) + c.mu.Lock() + if c.namingAddrCache == addr { + c.clearNamingAddrCache() + } + c.mu.Unlock() + } + } else { + if failCount > 0 { + c.availableNamingMap.Store(addr, int32(0)) + c.logger.Info("naming server recovered", zap.String("addr", addr), zap.Int32("previousFailCount", failCount)) + } + } + } +} + +func (c *NamingServerClient) doHealthCheck(addr string) bool { + checkURL := fmt.Sprintf("%s%s/naming/v1/health", httpPrefix, addr) Review Comment: URL construction always prepends the hard-coded "http://" prefix to addr. If users configure server-addr values that already include a scheme (e.g., "http://127.0.0.1:8081"), requests will become malformed ("http://http://..."). Consider normalizing addresses (strip scheme if present) or storing a base URL per server (and using url.JoinPath). ```suggestion func buildNamingServerURL(addr string, elem ...string) (string, error) { baseURL := addr if !strings.Contains(addr, "://") { baseURL = httpPrefix + addr } return url.JoinPath(baseURL, elem...) } func (c *NamingServerClient) doHealthCheck(addr string) bool { checkURL, err := buildNamingServerURL(addr, "naming", "v1", "health") if err != nil { c.logger.Error("build health check url failed", zap.String("addr", addr), zap.Error(err)) return false } ``` ########## pkg/discovery/naming_server.go: ########## @@ -0,0 +1,673 @@ +/* + * 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 discovery + +import ( + "encoding/json" + "errors" + "fmt" + gostnet "github.com/dubbogo/gost/net" + "go.uber.org/zap" + "net/http" + "net/url" + "seata.apache.org/seata-go/v2/pkg/util/rand" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + httpPrefix = "http://" + healthCheckThreshold = 3 + longPollTimeoutPeriod = 28 * time.Second + authorizationHeader = "Authorization" + contentTypeJSON = "application/json" + namingAddrCacheTTL = 30 * time.Second + failureRecoveryThreshold = 3 + maxRetryAttempts = 3 + retryDelayMs = 1000 +) + +type MetaResponse struct { + Term int64 `json:"term"` + ClusterList []Cluster `json:"clusterList"` +} + +type Cluster struct { + ClusterName string `json:"clusterName"` + ClusterType string `json:"clusterType"` + UnitData []Unit `json:"unitData"` +} + +type Unit struct { + UnitName string `json:"unitName"` + NamingInstanceList []NamingServerNode `json:"namingInstanceList"` +} + +type NamingServerNode struct { + Role ClusterRole `json:"role"` + Term int64 `json:"term"` + Transaction Endpoint `json:"transaction"` + Control Endpoint `json:"control"` + Internal Endpoint `json:"internal"` + Group string `json:"group"` + Version string `json:"version"` + Metadata map[string]interface{} `json:"metadata"` + TimeStamp int64 `json:"timeStamp"` + Weight float64 `json:"weight"` + Healthy bool `json:"healthy"` + Unit string `json:"unit"` +} + +type Endpoint struct { + Host string `json:"host"` + Port int `json:"port"` + Protocol string `json:"protocol"` +} + +type ExternalEndpoint struct { + Host string `json:"host"` + ControlPort int `json:"controlPort"` + TransactionPort int `json:"transactionPort"` +} + +type ClusterRole string + +const ( + ClusterRoleLeader ClusterRole = "LEADER" + ClusterRoleMember ClusterRole = "MEMBER" +) + +type NamingServerClient struct { + config *NamingServerConfig + logger *zap.Logger + mu sync.Mutex + instance *NamingServerClient + term int64 + jwtToken string + tokenTimeStamp int64 + isSubscribed bool + namingAddrCache string + namingAddrCacheTimestamp int64 + + availableNamingMap sync.Map + vgroupAddressMap sync.Map + listenerServiceMap sync.Map + + healthCheckTicker *time.Ticker + closeChan chan struct{} + wg sync.WaitGroup + + httpClient *http.Client + longPollClient *http.Client +} + +type NamingListener interface { + OnEvent(vGroup string) error +} + +type NamingServerRegistryService struct { + client *NamingServerClient +} + +var _ NamingserverRegistry = (*NamingServerRegistryService)(nil) + +func (n *NamingServerRegistryService) Lookup(key string) ([]*ServiceInstance, error) { + return n.client.Lookup(key) +} + +func (n *NamingServerRegistryService) Close() { + n.client.Close() +} + +func newNamingServerRegistryService(_ *ServiceConfig, cfg *NamingServerConfig) RegistryService { + client := GetInstance(cfg) + return &NamingServerRegistryService{ + client: client, + } +} + +var ( + namingServerInstance *NamingServerClient + namingServerOnce sync.Once +) + +func GetInstance(config *NamingServerConfig) *NamingServerClient { + namingServerOnce.Do(func() { + namingServerInstance = &NamingServerClient{ + config: config, + logger: zap.L().Named("naming-server-client"), + closeChan: make(chan struct{}), + healthCheckTicker: time.NewTicker(time.Duration(config.HeartbeatPeriod) * time.Millisecond), + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // Initialize available naming server addresses from config + namingServerInstance.initNamingAddrs() + namingServerInstance.initHealthCheck() + }) + return namingServerInstance +} + +func resetInstance() { + if namingServerInstance != nil { + namingServerInstance.Close() + namingServerInstance.mu.Lock() + namingServerInstance.clearNamingAddrCache() + namingServerInstance.mu.Unlock() + } + namingServerInstance = nil + namingServerOnce = sync.Once{} +} + +func (c *NamingServerClient) initNamingAddrs() { + addrs := c.getNamingAddrs() + for _, addr := range addrs { + c.availableNamingMap.Store(addr, int32(0)) + } +} + +func (c *NamingServerClient) initHealthCheck() { + c.wg.Add(1) + go func() { + defer c.wg.Done() + for { + select { + case <-c.healthCheckTicker.C: + urlList := c.getNamingAddrs() + c.checkAvailableNamingAddr(urlList) + case <-c.closeChan: + return + } + } + }() +} + +func (c *NamingServerClient) checkAvailableNamingAddr(urlList []string) { + for _, addr := range urlList { + isHealthy := c.doHealthCheck(addr) + + val, _ := c.availableNamingMap.LoadOrStore(addr, int32(0)) + failCount := val.(int32) + + if !isHealthy { + newFailCount := atomic.AddInt32(&failCount, 1) + c.availableNamingMap.Store(addr, newFailCount) + if newFailCount == 1 { + c.logger.Warn("naming server check failed", zap.String("addr", addr), zap.Int32("failCount", newFailCount)) + } else if newFailCount >= healthCheckThreshold { + c.logger.Error("naming server offline", zap.String("addr", addr), zap.Int32("failCount", newFailCount)) + c.mu.Lock() + if c.namingAddrCache == addr { + c.clearNamingAddrCache() + } + c.mu.Unlock() + } + } else { + if failCount > 0 { + c.availableNamingMap.Store(addr, int32(0)) + c.logger.Info("naming server recovered", zap.String("addr", addr), zap.Int32("previousFailCount", failCount)) + } + } + } +} + +func (c *NamingServerClient) doHealthCheck(addr string) bool { + checkURL := fmt.Sprintf("%s%s/naming/v1/health", httpPrefix, addr) + + req, err := http.NewRequest(http.MethodGet, checkURL, nil) + if err != nil { + c.logger.Error("create health check request failed", zap.Error(err)) + return false + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(req) + if err != nil { + c.logger.Error("health check failed", zap.String("addr", addr), zap.Error(err)) + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +func (c *NamingServerClient) getNamingAddrs() []string { + return strings.Split(c.config.ServerAddr, ",") +} + +func (c *NamingServerClient) Lookup(vGroup string) ([]*ServiceInstance, error) { + if !c.isSubscribed { + if err := c.RefreshGroup(vGroup); err != nil { + return nil, fmt.Errorf("refresh group failed: %w", err) + } + listener := &RefreshListener{client: c} + if err := c.Subscribe(vGroup, listener); err != nil { + return nil, fmt.Errorf("subscribe failed: %w", err) + } + } + + val, ok := c.vgroupAddressMap.Load(vGroup) + if !ok { + if err := c.RefreshGroup(vGroup); err != nil { + return nil, fmt.Errorf("refresh group failed: %w", err) + } + val, ok = c.vgroupAddressMap.Load(vGroup) + if !ok { + return nil, errors.New("no nodes found for vgroup") + } + } + + nodes := val.([]NamingServerNode) + + var instances []*ServiceInstance + for _, node := range nodes { + if !node.Healthy { + continue + } + + if node.Transaction.Host == "" || node.Transaction.Port <= 0 || node.Transaction.Port > 65535 { + c.logger.Warn("invalid node address", zap.String("host", node.Transaction.Host), zap.Int("port", node.Transaction.Port)) + continue + } + + instances = append(instances, &ServiceInstance{ + Addr: node.Transaction.Host, + Port: node.Transaction.Port, + }) + } + return instances, nil +} + +func (c *NamingServerClient) RefreshGroup(vGroup string) error { + namingAddr, err := c.getNamingAddr() + if err != nil { + return err + } + + if c.isTokenExpired() { + if err := c.RefreshToken(namingAddr); err != nil { + return err + } + } + + params := url.Values{} + params.Add("vGroup", vGroup) + params.Add("namespace", c.config.Namespace) + + discoveryURL := fmt.Sprintf("%s%s/naming/v1/discovery?%s", httpPrefix, namingAddr, params.Encode()) + req, err := http.NewRequest(http.MethodGet, discoveryURL, nil) + if err != nil { + return err + } + if c.jwtToken != "" { + req.Header.Set(authorizationHeader, c.jwtToken) + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("discovery request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("discovery failed: unauthorized (401), authentication required. please configure username and password in the naming server config") + } + return fmt.Errorf("discovery failed, status: %d", resp.StatusCode) + } + + var metaResp MetaResponse + if err := json.NewDecoder(resp.Body).Decode(&metaResp); err != nil { + return fmt.Errorf("decode meta response failed: %w", err) + } + + return c.handleMetadata(&metaResp, vGroup) +} + +func (c *NamingServerClient) getNamingAddr() (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now().UnixMilli() + + if c.namingAddrCache != "" && + now-c.namingAddrCacheTimestamp < namingAddrCacheTTL.Milliseconds() { + + if val, ok := c.availableNamingMap.Load(c.namingAddrCache); ok { + failCount := val.(int32) + if failCount < healthCheckThreshold { + return c.namingAddrCache, nil + } + } + + c.clearNamingAddrCache() + } + + var availableAddrs []string + c.availableNamingMap.Range(func(key, value interface{}) bool { + addr := key.(string) + failCount := value.(int32) + if failCount < healthCheckThreshold { + availableAddrs = append(availableAddrs, addr) + } + return true + }) + + if len(availableAddrs) == 0 { + return "", errors.New("no available naming server") + } + + addr := availableAddrs[rand.RandIntn(len(availableAddrs))] + c.namingAddrCache = addr + c.namingAddrCacheTimestamp = now + return addr, nil +} + +func (c *NamingServerClient) clearNamingAddrCache() { + c.namingAddrCache = "" + c.namingAddrCacheTimestamp = 0 +} + +func (c *NamingServerClient) isTokenExpired() bool { + if c.config.Username == "" || c.config.Password == "" { + return false + } + + ts := atomic.LoadInt64(&c.tokenTimeStamp) + if ts == 0 { + return true + } + + return time.Now().UnixMilli() >= ts+c.config.TokenValidityInMilliseconds +} + +func (c *NamingServerClient) RefreshToken(addr string) error { + if c.config.Username == "" || c.config.Password == "" { + return nil + } + + var lastErr error + for attempt := 1; attempt <= maxRetryAttempts; attempt++ { + err := c.doRefreshToken(addr) + if err == nil { + return nil + } + + lastErr = err + if attempt < maxRetryAttempts { + c.logger.Warn("token refresh failed, retrying", + zap.String("addr", addr), + zap.Int("attempt", attempt), + zap.Error(err)) + time.Sleep(time.Duration(retryDelayMs*attempt) * time.Millisecond) + } + } + + c.logger.Error("token refresh failed after all retries", + zap.String("addr", addr), + zap.Int("maxAttempts", maxRetryAttempts), + zap.Error(lastErr)) + return fmt.Errorf("token refresh failed after %d attempts: %w", maxRetryAttempts, lastErr) +} + +func (c *NamingServerClient) doRefreshToken(addr string) error { + loginURL := fmt.Sprintf("%s%s/api/v1/auth/login", httpPrefix, addr) + + loginReq := struct { + Username string `json:"username"` + Password string `json:"password"` + }{ + Username: c.config.Username, + Password: c.config.Password, + } + + body, err := json.Marshal(loginReq) + if err != nil { + return fmt.Errorf("marshal login request failed: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, loginURL, strings.NewReader(string(body))) + if err != nil { Review Comment: doRefreshToken() converts the marshaled JSON bytes to a string and then wraps it with strings.NewReader. This adds an extra allocation/copy. Prefer using bytes.NewReader(body) (or bytes.NewBuffer) to write the []byte payload directly. ########## pkg/discovery/naming_server_test.go: ########## @@ -0,0 +1,692 @@ +/* + * 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 discovery + +import ( + "encoding/json" + "flag" + "go.uber.org/zap" + "gopkg.in/yaml.v2" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" +) + +// fake config for tests +var testConfig = &NamingServerConfig{ + ServerAddr: "127.0.0.1:8080,localhost:9090", + HeartbeatPeriod: 1000, + Namespace: "testns", + Username: "user", + Password: "pass", + TokenValidityInMilliseconds: 100, + MetadataMaxAgeMs: 1000, +} + +// Test getNamingAddrs splits config.ServerAddr +func TestGetNamingAddrs(t *testing.T) { + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + addrs := client.getNamingAddrs() + if len(addrs) != 2 { + t.Errorf("expected 2 addresses, got %d", len(addrs)) + } +} + +// Test isTokenExpired behavior +func TestIsTokenExpired_NoCredentials(t *testing.T) { + cfg := *testConfig + cfg.Username = "" + cfg.Password = "" + client := &NamingServerClient{ + config: &cfg, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // without credentials, token should never be considered expired + atomic.StoreInt64(&client.tokenTimeStamp, 0) + if client.isTokenExpired() { + t.Error("expected token not expired when no credentials") + } +} + +func TestIsTokenExpired_WithCredentials(t *testing.T) { + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // timestamp zero => expired + atomic.StoreInt64(&client.tokenTimeStamp, 0) + if !client.isTokenExpired() { + t.Error("expected token expired when timestamp zero and credentials present") + } + // future timestamp => not expired + atomic.StoreInt64(&client.tokenTimeStamp, time.Now().UnixMilli()) + if client.isTokenExpired() { + t.Error("expected token not expired when timestamp fresh") + } +} + +// Test doHealthCheck with a local httptest server +func TestDoHealthCheck(t *testing.T) { + // healthy server + healthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer healthy.Close() + addr := healthy.Listener.Addr().String() + client := &NamingServerClient{ + logger: zap.NewNop(), + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + if !client.doHealthCheck(addr) { + t.Error("expected healthy server to return true") + } + + // unhealthy server + unhealthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer unhealthy.Close() + addr2 := unhealthy.Listener.Addr().String() + if client.doHealthCheck(addr2) { + t.Error("expected unhealthy server to return false") + } +} + +func TestHealthCheckThreshold(t *testing.T) { + resetInstance() + client := &NamingServerClient{ + config: testConfig, + logger: zap.NewNop(), + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + + addr := "test-addr" + + client.availableNamingMap.Store(addr, int32(0)) + val, _ := client.availableNamingMap.Load(addr) + if val.(int32) != 0 { + t.Error("initial fail count should be 0") + } + + client.checkAvailableNamingAddr([]string{addr}) + val, _ = client.availableNamingMap.Load(addr) + if val.(int32) != 1 { + t.Error("fail count should be 1 after first failure") + } + + client.checkAvailableNamingAddr([]string{addr}) + val, _ = client.availableNamingMap.Load(addr) + if val.(int32) != 2 { + t.Error("fail count should be 2 after second failure") + } + + client.checkAvailableNamingAddr([]string{addr}) + val, _ = client.availableNamingMap.Load(addr) + if val.(int32) != 3 { + t.Error("fail count should be 3 after third failure") + } +} + +// Test handleMetadata filters nodes correctly +func TestHandleMetadata(t *testing.T) { + client := &NamingServerClient{ + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + meta := &MetaResponse{ + Term: 2, + ClusterList: []Cluster{ + { + ClusterName: "c1", + ClusterType: "t1", + UnitData: []Unit{ + { + UnitName: "u1", + NamingInstanceList: []NamingServerNode{ + {Role: ClusterRoleLeader, Term: 2, Healthy: true}, + {Role: ClusterRoleLeader, Term: 1, Healthy: true}, + {Role: ClusterRoleMember, Term: 1, Healthy: false}, + }, + }, + }, + }, + }, + } + err := client.handleMetadata(meta, "vg1") + if err != nil { + t.Fatalf("handleMetadata returned error: %v", err) + } + val, ok := client.vgroupAddressMap.Load("vg1") + if !ok { + t.Fatal("vgroupAddressMap missing key vg1") + } + nodes := val.([]NamingServerNode) + // expect only leader with term>=2 and all members + if len(nodes) != 2 { + t.Errorf("expected 2 nodes, got %d", len(nodes)) + } +} + +// Additional tests for getNamingAddr when availableNamingMap populated +func TestGetNamingAddr_NoAvailable(t *testing.T) { + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // empty map + _, err := client.getNamingAddr() + if err == nil { + t.Error("expected error when no available naming server") + } +} + +func TestGetNamingAddr_OneAvailable(t *testing.T) { + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + client.availableNamingMap.Store("host1", int32(0)) + addr, err := client.getNamingAddr() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if addr != "host1" { + t.Errorf("expected host1, got %s", addr) + } +} + +func TestGetNamingAddr_CacheExpiry(t *testing.T) { + resetInstance() + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + client.availableNamingMap.Store("host1", int32(0)) + client.availableNamingMap.Store("host2", int32(0)) + + _, err := client.getNamingAddr() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + client.namingAddrCacheTimestamp = time.Now().UnixMilli() - 31000 + + _, err = client.getNamingAddr() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if client.namingAddrCacheTimestamp <= time.Now().UnixMilli()-30000 { + t.Error("cache timestamp should be updated after expiry") + } +} + +func TestLookup_FirstCall(t *testing.T) { + // Reset naming server singleton instance + resetInstance() + // Create mock naming server with necessary endpoints (login, discovery, health) + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Logf("Received request: %s %s", r.Method, r.URL.Path) + + switch r.URL.Path { + case "/api/v1/auth/login": + // Handle login request, return valid JWT token + w.Header().Set("Content-Type", contentTypeJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "200", + "data": "mock-jwt-token", + "success": true, + }) + + case "/naming/v1/discovery": + // Verify auth token + if r.Header.Get(authorizationHeader) != "mock-jwt-token" { + t.Errorf("Missing valid token: %s", r.Header.Get(authorizationHeader)) + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Construct mock response with 4 nodes (2 valid, 2 invalid) + resp := MetaResponse{ + Term: 1, + ClusterList: []Cluster{{ + ClusterName: "testCluster", + ClusterType: "SEATA", + UnitData: []Unit{{ + UnitName: "testUnit", + NamingInstanceList: []NamingServerNode{ + {Role: ClusterRoleLeader, Term: 1, Healthy: true, Transaction: Endpoint{Host: "10.0.0.1", Port: 8091, Protocol: "tcp"}, Version: "1.0.0"}, + {Role: ClusterRoleMember, Term: 1, Healthy: true, Transaction: Endpoint{Host: "10.0.0.2", Port: 8092, Protocol: "tcp"}, Version: "1.0.0"}, + {Healthy: false, Transaction: Endpoint{Host: "10.0.0.3", Port: 8093}}, + {Healthy: true, Transaction: Endpoint{Host: "10.0.0.4", Port: 70000}}, + }, + }}, + }}, + } + + w.Header().Set("Content-Type", contentTypeJSON) + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + + case "/naming/v1/health": + w.WriteHeader(http.StatusOK) + + default: + t.Errorf("Unknown path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + // Configure client to use mock server + mockAddr := mockServer.Listener.Addr().String() + testConfig.ServerAddr = mockAddr + client := GetInstance(testConfig) + client.logger = zap.NewNop() + client.availableNamingMap.Store(mockAddr, int32(0)) + client.namingAddrCache = mockAddr + + // Call Lookup and check for errors + instances, err := client.Lookup("testGroup") + if err != nil { + t.Fatalf("Lookup failed: %v", err) + } + + // Verify cached data exists + val, ok := client.vgroupAddressMap.Load("testGroup") + if !ok { + t.Fatal("No testGroup data in cache") + } + nodes := val.([]NamingServerNode) + t.Logf("Fetched %d nodes from server", len(nodes)) + for i, node := range nodes { + t.Logf("Node %d: Healthy=%v, Host=%s, Port=%d", i, node.Healthy, node.Transaction.Host, node.Transaction.Port) + } + + // Verify correct number of valid instances (2 expected) + if len(instances) != 2 { + t.Fatalf("Expected 2 instances, got %d", len(instances)) + } + expected := map[string]int{"10.0.0.1": 8091, "10.0.0.2": 8092} + for _, inst := range instances { + if port, ok := expected[inst.Addr]; !ok || inst.Port != port { + t.Errorf("Mismatched instance: %s:%d", inst.Addr, inst.Port) + } + } +} + +func TestRefreshToken_Success(t *testing.T) { + // Reset naming server singleton instance + resetInstance() + // Mock login endpoint returning valid token + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/auth/login" || r.Method != http.MethodPost { + t.Errorf("Invalid request: %s %s", r.Method, r.URL.Path) + } + // Verify credentials from JSON body + var loginReq struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil { + t.Errorf("Failed to decode login request: %v", err) + } + if loginReq.Username != testConfig.Username || loginReq.Password != testConfig.Password { + t.Error("Invalid username/password in request") + } + // Return success response with token + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "200", + "data": "mock-jwt-token", + "success": true, + }) + })) + defer mockServer.Close() + + client := GetInstance(testConfig) + client.logger = zap.NewNop() + err := client.RefreshToken(mockServer.Listener.Addr().String()) + if err != nil { + t.Fatalf("RefreshToken failed: %v", err) + } + if client.jwtToken != "mock-jwt-token" { + t.Error("jwtToken not updated after login") + } +} + +func TestRefreshToken_RetryMechanism(t *testing.T) { + resetInstance() + attemptCount := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attemptCount++ + if attemptCount < 3 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "200", + "data": "retry-success-token", + "success": true, + }) + })) + defer mockServer.Close() + + client := GetInstance(testConfig) + client.logger = zap.NewNop() + err := client.RefreshToken(mockServer.Listener.Addr().String()) + if err != nil { + t.Fatalf("RefreshToken failed: %v", err) + } + if attemptCount != 3 { + t.Errorf("Expected 3 attempts, got %d", attemptCount) + } + if client.jwtToken != "retry-success-token" { + t.Error("jwtToken not updated after retry success") + } +} + +func TestWatch(t *testing.T) { + // Reset naming server singleton instance + resetInstance() + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/auth/login": + // Handle login request + if r.Method != http.MethodPost { + t.Errorf("login: Expected POST, got %s", r.Method) + } + w.Header().Set("Content-Type", contentTypeJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "200", + "data": "mock-jwt-token", + "success": true, + }) + + case "/naming/v1/watch": + // Verify auth token + if r.Header.Get(authorizationHeader) != "mock-jwt-token" { + t.Errorf("watch: Missing valid token, got %s", r.Header.Get(authorizationHeader)) + w.WriteHeader(http.StatusUnauthorized) + return + } + // Verify request method and parameters + if r.Method != http.MethodPost { + t.Errorf("watch: Expected POST, got %s", r.Method) + } + params := r.URL.Query() + if params.Get("vGroup") != "testGroup" { + t.Errorf("watch: Invalid vGroup, got %s", params.Get("vGroup")) + } + if params.Get("clientTerm") != "0" { + t.Errorf("watch: Invalid clientTerm, got %s", params.Get("clientTerm")) + } + w.WriteHeader(http.StatusOK) + + default: + t.Errorf("Unknown path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + // Configure client + mockAddr := mockServer.Listener.Addr().String() + testConfig.ServerAddr = mockAddr + client := GetInstance(testConfig) Review Comment: This test mutates the shared package-level testConfig (ServerAddr), which can leak state into other tests and make execution order matter. Prefer using a per-test copy of the config instead of modifying the global variable. ```suggestion config := *testConfig config.ServerAddr = mockAddr client := GetInstance(&config) ``` ########## pkg/discovery/naming_server_registry.go: ########## @@ -0,0 +1,42 @@ +/* + * 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 discovery + +type NamingserverRegistry interface { + RegistryService + + Register(instance *ServiceInstance) error + + Deregister(instance *ServiceInstance) error + + // doHealthCheck + // perform a health check and call the/amine/v1/health interface Review Comment: Comment typo: "/amine/v1/health" looks incorrect; the implementation calls "/naming/v1/health". Please fix the comment to avoid confusion for future maintainers. ```suggestion // perform a health check and call the /naming/v1/health interface. ``` ########## pkg/discovery/naming_server_registry.go: ########## @@ -0,0 +1,42 @@ +/* + * 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 discovery + +type NamingserverRegistry interface { Review Comment: Exported type name "NamingserverRegistry" is inconsistent with the rest of the package's exported identifiers (e.g., NamingServerConfig, NamingServerRegistryService) and Go initialism/camel-case conventions. Consider renaming to NamingServerRegistry (and similarly GetNamingServerRegistry) before this API is consumed externally. ```suggestion type NamingServerRegistry interface { ``` ########## pkg/discovery/init.go: ########## @@ -60,3 +63,14 @@ func InitRegistry(serviceConfig *ServiceConfig, registryConfig *RegistryConfig) func GetRegistry() RegistryService { return registryServiceInstance } + +func GetNamingserverRegistry() (NamingserverRegistry, error) { + if registryServiceInstance == nil { + return nil, fmt.Errorf("registry service not initialized") + } + namingReg, ok := registryServiceInstance.(NamingserverRegistry) + if !ok { + return nil, fmt.Errorf("current registry is not namingserver") + } + return namingReg, nil Review Comment: GetNamingserverRegistry() name uses "Namingserver" instead of "NamingServer". For consistency with other exported symbols and Go naming conventions, consider renaming to GetNamingServerRegistry (and update the return interface name accordingly). ########## pkg/discovery/config.go: ########## @@ -38,17 +38,19 @@ func (cfg *ServiceConfig) RegisterFlagsWithPrefix(prefix string, f *flag.FlagSet } type RegistryConfig struct { - Type string `yaml:"type" json:"type" koanf:"type"` - File FileConfig `yaml:"file" json:"file" koanf:"file"` - Nacos NacosConfig `yaml:"nacos" json:"nacos" koanf:"nacos"` - Etcd3 Etcd3Config `yaml:"etcd3" json:"etcd3" koanf:"etcd3"` + Type string `yaml:"type" json:"type" koanf:"type"` + File FileConfig `yaml:"file" json:"file" koanf:"file"` + Nacos NacosConfig `yaml:"nacos" json:"nacos" koanf:"nacos"` + Etcd3 Etcd3Config `yaml:"etcd3" json:"etcd3" koanf:"etcd3"` + NamingServer NamingServerConfig `yaml:"naming-server" json:"namingServer" koanf:"naming-server"` Review Comment: RegistryConfig.NamingServer uses json tag `json:"namingServer"` while other registry sub-configs use lowercase names matching YAML keys (e.g., `json:"nacos"`, `json:"etcd3"`). If RegistryConfig is ever unmarshaled from JSON via encoding/json, this inconsistency will make the naming-server block hard to configure consistently. Consider using `json:"naming-server"` (or aligning all registry JSON keys to one convention). ```suggestion NamingServer NamingServerConfig `yaml:"naming-server" json:"naming-server" koanf:"naming-server"` ``` ########## pkg/discovery/naming_server_test.go: ########## @@ -0,0 +1,692 @@ +/* + * 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 discovery + +import ( + "encoding/json" + "flag" + "go.uber.org/zap" + "gopkg.in/yaml.v2" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" +) + +// fake config for tests +var testConfig = &NamingServerConfig{ + ServerAddr: "127.0.0.1:8080,localhost:9090", + HeartbeatPeriod: 1000, + Namespace: "testns", + Username: "user", + Password: "pass", + TokenValidityInMilliseconds: 100, + MetadataMaxAgeMs: 1000, +} + +// Test getNamingAddrs splits config.ServerAddr +func TestGetNamingAddrs(t *testing.T) { + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + addrs := client.getNamingAddrs() + if len(addrs) != 2 { + t.Errorf("expected 2 addresses, got %d", len(addrs)) + } +} + +// Test isTokenExpired behavior +func TestIsTokenExpired_NoCredentials(t *testing.T) { + cfg := *testConfig + cfg.Username = "" + cfg.Password = "" + client := &NamingServerClient{ + config: &cfg, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // without credentials, token should never be considered expired + atomic.StoreInt64(&client.tokenTimeStamp, 0) + if client.isTokenExpired() { + t.Error("expected token not expired when no credentials") + } +} + +func TestIsTokenExpired_WithCredentials(t *testing.T) { + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // timestamp zero => expired + atomic.StoreInt64(&client.tokenTimeStamp, 0) + if !client.isTokenExpired() { + t.Error("expected token expired when timestamp zero and credentials present") + } + // future timestamp => not expired + atomic.StoreInt64(&client.tokenTimeStamp, time.Now().UnixMilli()) + if client.isTokenExpired() { + t.Error("expected token not expired when timestamp fresh") + } +} + +// Test doHealthCheck with a local httptest server +func TestDoHealthCheck(t *testing.T) { + // healthy server + healthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer healthy.Close() + addr := healthy.Listener.Addr().String() + client := &NamingServerClient{ + logger: zap.NewNop(), + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + if !client.doHealthCheck(addr) { + t.Error("expected healthy server to return true") + } + + // unhealthy server + unhealthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer unhealthy.Close() + addr2 := unhealthy.Listener.Addr().String() + if client.doHealthCheck(addr2) { + t.Error("expected unhealthy server to return false") + } +} + +func TestHealthCheckThreshold(t *testing.T) { + resetInstance() + client := &NamingServerClient{ + config: testConfig, + logger: zap.NewNop(), + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + + addr := "test-addr" + + client.availableNamingMap.Store(addr, int32(0)) + val, _ := client.availableNamingMap.Load(addr) + if val.(int32) != 0 { + t.Error("initial fail count should be 0") + } + + client.checkAvailableNamingAddr([]string{addr}) + val, _ = client.availableNamingMap.Load(addr) + if val.(int32) != 1 { + t.Error("fail count should be 1 after first failure") + } + + client.checkAvailableNamingAddr([]string{addr}) + val, _ = client.availableNamingMap.Load(addr) + if val.(int32) != 2 { + t.Error("fail count should be 2 after second failure") + } + + client.checkAvailableNamingAddr([]string{addr}) + val, _ = client.availableNamingMap.Load(addr) + if val.(int32) != 3 { + t.Error("fail count should be 3 after third failure") + } +} + +// Test handleMetadata filters nodes correctly +func TestHandleMetadata(t *testing.T) { + client := &NamingServerClient{ + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + meta := &MetaResponse{ + Term: 2, + ClusterList: []Cluster{ + { + ClusterName: "c1", + ClusterType: "t1", + UnitData: []Unit{ + { + UnitName: "u1", + NamingInstanceList: []NamingServerNode{ + {Role: ClusterRoleLeader, Term: 2, Healthy: true}, + {Role: ClusterRoleLeader, Term: 1, Healthy: true}, + {Role: ClusterRoleMember, Term: 1, Healthy: false}, + }, + }, + }, + }, + }, + } + err := client.handleMetadata(meta, "vg1") + if err != nil { + t.Fatalf("handleMetadata returned error: %v", err) + } + val, ok := client.vgroupAddressMap.Load("vg1") + if !ok { + t.Fatal("vgroupAddressMap missing key vg1") + } + nodes := val.([]NamingServerNode) + // expect only leader with term>=2 and all members + if len(nodes) != 2 { + t.Errorf("expected 2 nodes, got %d", len(nodes)) + } +} + +// Additional tests for getNamingAddr when availableNamingMap populated +func TestGetNamingAddr_NoAvailable(t *testing.T) { + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // empty map + _, err := client.getNamingAddr() + if err == nil { + t.Error("expected error when no available naming server") + } +} + +func TestGetNamingAddr_OneAvailable(t *testing.T) { + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + client.availableNamingMap.Store("host1", int32(0)) + addr, err := client.getNamingAddr() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if addr != "host1" { + t.Errorf("expected host1, got %s", addr) + } +} + +func TestGetNamingAddr_CacheExpiry(t *testing.T) { + resetInstance() + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + client.availableNamingMap.Store("host1", int32(0)) + client.availableNamingMap.Store("host2", int32(0)) + + _, err := client.getNamingAddr() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + client.namingAddrCacheTimestamp = time.Now().UnixMilli() - 31000 + + _, err = client.getNamingAddr() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if client.namingAddrCacheTimestamp <= time.Now().UnixMilli()-30000 { + t.Error("cache timestamp should be updated after expiry") + } +} + +func TestLookup_FirstCall(t *testing.T) { + // Reset naming server singleton instance + resetInstance() + // Create mock naming server with necessary endpoints (login, discovery, health) + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Logf("Received request: %s %s", r.Method, r.URL.Path) + + switch r.URL.Path { + case "/api/v1/auth/login": + // Handle login request, return valid JWT token + w.Header().Set("Content-Type", contentTypeJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "200", + "data": "mock-jwt-token", + "success": true, + }) + + case "/naming/v1/discovery": + // Verify auth token + if r.Header.Get(authorizationHeader) != "mock-jwt-token" { + t.Errorf("Missing valid token: %s", r.Header.Get(authorizationHeader)) + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Construct mock response with 4 nodes (2 valid, 2 invalid) + resp := MetaResponse{ + Term: 1, + ClusterList: []Cluster{{ + ClusterName: "testCluster", + ClusterType: "SEATA", + UnitData: []Unit{{ + UnitName: "testUnit", + NamingInstanceList: []NamingServerNode{ + {Role: ClusterRoleLeader, Term: 1, Healthy: true, Transaction: Endpoint{Host: "10.0.0.1", Port: 8091, Protocol: "tcp"}, Version: "1.0.0"}, + {Role: ClusterRoleMember, Term: 1, Healthy: true, Transaction: Endpoint{Host: "10.0.0.2", Port: 8092, Protocol: "tcp"}, Version: "1.0.0"}, + {Healthy: false, Transaction: Endpoint{Host: "10.0.0.3", Port: 8093}}, + {Healthy: true, Transaction: Endpoint{Host: "10.0.0.4", Port: 70000}}, + }, + }}, + }}, + } + + w.Header().Set("Content-Type", contentTypeJSON) + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + + case "/naming/v1/health": + w.WriteHeader(http.StatusOK) + + default: + t.Errorf("Unknown path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + // Configure client to use mock server + mockAddr := mockServer.Listener.Addr().String() + testConfig.ServerAddr = mockAddr + client := GetInstance(testConfig) Review Comment: Several tests mutate the package-level testConfig (e.g., overwriting ServerAddr). Because testConfig is shared across the whole file, this can create order-dependent/flaky tests if additional tests are added or if tests are run in parallel. Prefer creating a per-test copy (cfg := *testConfig) and passing &cfg into GetInstance/client constructors instead of mutating the shared global. ```suggestion cfg := *testConfig cfg.ServerAddr = mockAddr client := GetInstance(&cfg) ``` ########## pkg/discovery/naming_server.go: ########## @@ -0,0 +1,673 @@ +/* + * 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 discovery + +import ( + "encoding/json" + "errors" + "fmt" + gostnet "github.com/dubbogo/gost/net" + "go.uber.org/zap" + "net/http" + "net/url" + "seata.apache.org/seata-go/v2/pkg/util/rand" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + httpPrefix = "http://" + healthCheckThreshold = 3 + longPollTimeoutPeriod = 28 * time.Second + authorizationHeader = "Authorization" + contentTypeJSON = "application/json" + namingAddrCacheTTL = 30 * time.Second + failureRecoveryThreshold = 3 + maxRetryAttempts = 3 + retryDelayMs = 1000 +) + +type MetaResponse struct { + Term int64 `json:"term"` + ClusterList []Cluster `json:"clusterList"` +} + +type Cluster struct { + ClusterName string `json:"clusterName"` + ClusterType string `json:"clusterType"` + UnitData []Unit `json:"unitData"` +} + +type Unit struct { + UnitName string `json:"unitName"` + NamingInstanceList []NamingServerNode `json:"namingInstanceList"` +} + +type NamingServerNode struct { + Role ClusterRole `json:"role"` + Term int64 `json:"term"` + Transaction Endpoint `json:"transaction"` + Control Endpoint `json:"control"` + Internal Endpoint `json:"internal"` + Group string `json:"group"` + Version string `json:"version"` + Metadata map[string]interface{} `json:"metadata"` + TimeStamp int64 `json:"timeStamp"` + Weight float64 `json:"weight"` + Healthy bool `json:"healthy"` + Unit string `json:"unit"` +} + +type Endpoint struct { + Host string `json:"host"` + Port int `json:"port"` + Protocol string `json:"protocol"` +} + +type ExternalEndpoint struct { + Host string `json:"host"` + ControlPort int `json:"controlPort"` + TransactionPort int `json:"transactionPort"` +} + +type ClusterRole string + +const ( + ClusterRoleLeader ClusterRole = "LEADER" + ClusterRoleMember ClusterRole = "MEMBER" +) + +type NamingServerClient struct { + config *NamingServerConfig + logger *zap.Logger + mu sync.Mutex + instance *NamingServerClient + term int64 + jwtToken string + tokenTimeStamp int64 + isSubscribed bool + namingAddrCache string + namingAddrCacheTimestamp int64 + + availableNamingMap sync.Map + vgroupAddressMap sync.Map + listenerServiceMap sync.Map + + healthCheckTicker *time.Ticker + closeChan chan struct{} + wg sync.WaitGroup + + httpClient *http.Client + longPollClient *http.Client +} + +type NamingListener interface { + OnEvent(vGroup string) error +} + +type NamingServerRegistryService struct { + client *NamingServerClient +} + +var _ NamingserverRegistry = (*NamingServerRegistryService)(nil) + +func (n *NamingServerRegistryService) Lookup(key string) ([]*ServiceInstance, error) { + return n.client.Lookup(key) +} + +func (n *NamingServerRegistryService) Close() { + n.client.Close() +} + +func newNamingServerRegistryService(_ *ServiceConfig, cfg *NamingServerConfig) RegistryService { + client := GetInstance(cfg) + return &NamingServerRegistryService{ + client: client, + } +} + +var ( + namingServerInstance *NamingServerClient + namingServerOnce sync.Once +) + +func GetInstance(config *NamingServerConfig) *NamingServerClient { + namingServerOnce.Do(func() { + namingServerInstance = &NamingServerClient{ + config: config, + logger: zap.L().Named("naming-server-client"), + closeChan: make(chan struct{}), + healthCheckTicker: time.NewTicker(time.Duration(config.HeartbeatPeriod) * time.Millisecond), + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // Initialize available naming server addresses from config + namingServerInstance.initNamingAddrs() + namingServerInstance.initHealthCheck() + }) + return namingServerInstance +} + +func resetInstance() { + if namingServerInstance != nil { + namingServerInstance.Close() + namingServerInstance.mu.Lock() + namingServerInstance.clearNamingAddrCache() + namingServerInstance.mu.Unlock() + } + namingServerInstance = nil + namingServerOnce = sync.Once{} +} + +func (c *NamingServerClient) initNamingAddrs() { + addrs := c.getNamingAddrs() + for _, addr := range addrs { + c.availableNamingMap.Store(addr, int32(0)) + } +} + +func (c *NamingServerClient) initHealthCheck() { + c.wg.Add(1) + go func() { + defer c.wg.Done() + for { + select { + case <-c.healthCheckTicker.C: + urlList := c.getNamingAddrs() + c.checkAvailableNamingAddr(urlList) + case <-c.closeChan: + return + } + } + }() +} + +func (c *NamingServerClient) checkAvailableNamingAddr(urlList []string) { + for _, addr := range urlList { + isHealthy := c.doHealthCheck(addr) + + val, _ := c.availableNamingMap.LoadOrStore(addr, int32(0)) + failCount := val.(int32) + + if !isHealthy { + newFailCount := atomic.AddInt32(&failCount, 1) + c.availableNamingMap.Store(addr, newFailCount) + if newFailCount == 1 { + c.logger.Warn("naming server check failed", zap.String("addr", addr), zap.Int32("failCount", newFailCount)) + } else if newFailCount >= healthCheckThreshold { + c.logger.Error("naming server offline", zap.String("addr", addr), zap.Int32("failCount", newFailCount)) + c.mu.Lock() + if c.namingAddrCache == addr { + c.clearNamingAddrCache() + } + c.mu.Unlock() + } + } else { + if failCount > 0 { + c.availableNamingMap.Store(addr, int32(0)) + c.logger.Info("naming server recovered", zap.String("addr", addr), zap.Int32("previousFailCount", failCount)) + } + } + } +} + +func (c *NamingServerClient) doHealthCheck(addr string) bool { + checkURL := fmt.Sprintf("%s%s/naming/v1/health", httpPrefix, addr) + + req, err := http.NewRequest(http.MethodGet, checkURL, nil) + if err != nil { + c.logger.Error("create health check request failed", zap.Error(err)) + return false + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(req) + if err != nil { + c.logger.Error("health check failed", zap.String("addr", addr), zap.Error(err)) + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +func (c *NamingServerClient) getNamingAddrs() []string { + return strings.Split(c.config.ServerAddr, ",") Review Comment: getNamingAddrs() uses strings.Split(c.config.ServerAddr, ",") without trimming whitespace or filtering empty entries. A config like "host1:8081, host2:8081" (or trailing comma) will produce invalid addresses and cause request failures. Consider trimming each entry and skipping empty strings before returning the list. ```suggestion parts := strings.Split(c.config.ServerAddr, ",") addrs := make([]string, 0, len(parts)) for _, part := range parts { addr := strings.TrimSpace(part) if addr == "" { continue } addrs = append(addrs, addr) } return addrs ``` ########## pkg/discovery/naming_server_test.go: ########## @@ -0,0 +1,692 @@ +/* + * 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 discovery + +import ( + "encoding/json" + "flag" + "go.uber.org/zap" + "gopkg.in/yaml.v2" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" +) + +// fake config for tests +var testConfig = &NamingServerConfig{ + ServerAddr: "127.0.0.1:8080,localhost:9090", + HeartbeatPeriod: 1000, + Namespace: "testns", + Username: "user", + Password: "pass", + TokenValidityInMilliseconds: 100, + MetadataMaxAgeMs: 1000, +} + +// Test getNamingAddrs splits config.ServerAddr +func TestGetNamingAddrs(t *testing.T) { + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + addrs := client.getNamingAddrs() + if len(addrs) != 2 { + t.Errorf("expected 2 addresses, got %d", len(addrs)) + } +} + +// Test isTokenExpired behavior +func TestIsTokenExpired_NoCredentials(t *testing.T) { + cfg := *testConfig + cfg.Username = "" + cfg.Password = "" + client := &NamingServerClient{ + config: &cfg, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // without credentials, token should never be considered expired + atomic.StoreInt64(&client.tokenTimeStamp, 0) + if client.isTokenExpired() { + t.Error("expected token not expired when no credentials") + } +} + +func TestIsTokenExpired_WithCredentials(t *testing.T) { + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // timestamp zero => expired + atomic.StoreInt64(&client.tokenTimeStamp, 0) + if !client.isTokenExpired() { + t.Error("expected token expired when timestamp zero and credentials present") + } + // future timestamp => not expired + atomic.StoreInt64(&client.tokenTimeStamp, time.Now().UnixMilli()) + if client.isTokenExpired() { + t.Error("expected token not expired when timestamp fresh") + } +} + +// Test doHealthCheck with a local httptest server +func TestDoHealthCheck(t *testing.T) { + // healthy server + healthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer healthy.Close() + addr := healthy.Listener.Addr().String() + client := &NamingServerClient{ + logger: zap.NewNop(), + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + if !client.doHealthCheck(addr) { + t.Error("expected healthy server to return true") + } + + // unhealthy server + unhealthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer unhealthy.Close() + addr2 := unhealthy.Listener.Addr().String() + if client.doHealthCheck(addr2) { + t.Error("expected unhealthy server to return false") + } +} + +func TestHealthCheckThreshold(t *testing.T) { + resetInstance() + client := &NamingServerClient{ + config: testConfig, + logger: zap.NewNop(), + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + + addr := "test-addr" + + client.availableNamingMap.Store(addr, int32(0)) + val, _ := client.availableNamingMap.Load(addr) + if val.(int32) != 0 { + t.Error("initial fail count should be 0") + } + + client.checkAvailableNamingAddr([]string{addr}) + val, _ = client.availableNamingMap.Load(addr) + if val.(int32) != 1 { + t.Error("fail count should be 1 after first failure") + } + + client.checkAvailableNamingAddr([]string{addr}) + val, _ = client.availableNamingMap.Load(addr) + if val.(int32) != 2 { + t.Error("fail count should be 2 after second failure") + } + + client.checkAvailableNamingAddr([]string{addr}) + val, _ = client.availableNamingMap.Load(addr) + if val.(int32) != 3 { + t.Error("fail count should be 3 after third failure") + } +} + +// Test handleMetadata filters nodes correctly +func TestHandleMetadata(t *testing.T) { + client := &NamingServerClient{ + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + meta := &MetaResponse{ + Term: 2, + ClusterList: []Cluster{ + { + ClusterName: "c1", + ClusterType: "t1", + UnitData: []Unit{ + { + UnitName: "u1", + NamingInstanceList: []NamingServerNode{ + {Role: ClusterRoleLeader, Term: 2, Healthy: true}, + {Role: ClusterRoleLeader, Term: 1, Healthy: true}, + {Role: ClusterRoleMember, Term: 1, Healthy: false}, + }, + }, + }, + }, + }, + } + err := client.handleMetadata(meta, "vg1") + if err != nil { + t.Fatalf("handleMetadata returned error: %v", err) + } + val, ok := client.vgroupAddressMap.Load("vg1") + if !ok { + t.Fatal("vgroupAddressMap missing key vg1") + } + nodes := val.([]NamingServerNode) + // expect only leader with term>=2 and all members + if len(nodes) != 2 { + t.Errorf("expected 2 nodes, got %d", len(nodes)) + } +} + +// Additional tests for getNamingAddr when availableNamingMap populated +func TestGetNamingAddr_NoAvailable(t *testing.T) { + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // empty map + _, err := client.getNamingAddr() + if err == nil { + t.Error("expected error when no available naming server") + } +} + +func TestGetNamingAddr_OneAvailable(t *testing.T) { + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + client.availableNamingMap.Store("host1", int32(0)) + addr, err := client.getNamingAddr() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if addr != "host1" { + t.Errorf("expected host1, got %s", addr) + } +} + +func TestGetNamingAddr_CacheExpiry(t *testing.T) { + resetInstance() + client := &NamingServerClient{ + config: testConfig, + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + client.availableNamingMap.Store("host1", int32(0)) + client.availableNamingMap.Store("host2", int32(0)) + + _, err := client.getNamingAddr() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + client.namingAddrCacheTimestamp = time.Now().UnixMilli() - 31000 + + _, err = client.getNamingAddr() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if client.namingAddrCacheTimestamp <= time.Now().UnixMilli()-30000 { + t.Error("cache timestamp should be updated after expiry") + } +} + +func TestLookup_FirstCall(t *testing.T) { + // Reset naming server singleton instance + resetInstance() + // Create mock naming server with necessary endpoints (login, discovery, health) + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Logf("Received request: %s %s", r.Method, r.URL.Path) + + switch r.URL.Path { + case "/api/v1/auth/login": + // Handle login request, return valid JWT token + w.Header().Set("Content-Type", contentTypeJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "200", + "data": "mock-jwt-token", + "success": true, + }) + + case "/naming/v1/discovery": + // Verify auth token + if r.Header.Get(authorizationHeader) != "mock-jwt-token" { + t.Errorf("Missing valid token: %s", r.Header.Get(authorizationHeader)) + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Construct mock response with 4 nodes (2 valid, 2 invalid) + resp := MetaResponse{ + Term: 1, + ClusterList: []Cluster{{ + ClusterName: "testCluster", + ClusterType: "SEATA", + UnitData: []Unit{{ + UnitName: "testUnit", + NamingInstanceList: []NamingServerNode{ + {Role: ClusterRoleLeader, Term: 1, Healthy: true, Transaction: Endpoint{Host: "10.0.0.1", Port: 8091, Protocol: "tcp"}, Version: "1.0.0"}, + {Role: ClusterRoleMember, Term: 1, Healthy: true, Transaction: Endpoint{Host: "10.0.0.2", Port: 8092, Protocol: "tcp"}, Version: "1.0.0"}, + {Healthy: false, Transaction: Endpoint{Host: "10.0.0.3", Port: 8093}}, + {Healthy: true, Transaction: Endpoint{Host: "10.0.0.4", Port: 70000}}, + }, + }}, + }}, + } + + w.Header().Set("Content-Type", contentTypeJSON) + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + + case "/naming/v1/health": + w.WriteHeader(http.StatusOK) + + default: + t.Errorf("Unknown path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + // Configure client to use mock server + mockAddr := mockServer.Listener.Addr().String() + testConfig.ServerAddr = mockAddr + client := GetInstance(testConfig) + client.logger = zap.NewNop() + client.availableNamingMap.Store(mockAddr, int32(0)) + client.namingAddrCache = mockAddr + + // Call Lookup and check for errors + instances, err := client.Lookup("testGroup") + if err != nil { + t.Fatalf("Lookup failed: %v", err) + } + + // Verify cached data exists + val, ok := client.vgroupAddressMap.Load("testGroup") + if !ok { + t.Fatal("No testGroup data in cache") + } + nodes := val.([]NamingServerNode) + t.Logf("Fetched %d nodes from server", len(nodes)) + for i, node := range nodes { + t.Logf("Node %d: Healthy=%v, Host=%s, Port=%d", i, node.Healthy, node.Transaction.Host, node.Transaction.Port) + } + + // Verify correct number of valid instances (2 expected) + if len(instances) != 2 { + t.Fatalf("Expected 2 instances, got %d", len(instances)) + } + expected := map[string]int{"10.0.0.1": 8091, "10.0.0.2": 8092} + for _, inst := range instances { + if port, ok := expected[inst.Addr]; !ok || inst.Port != port { + t.Errorf("Mismatched instance: %s:%d", inst.Addr, inst.Port) + } + } +} + +func TestRefreshToken_Success(t *testing.T) { + // Reset naming server singleton instance + resetInstance() + // Mock login endpoint returning valid token + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/auth/login" || r.Method != http.MethodPost { + t.Errorf("Invalid request: %s %s", r.Method, r.URL.Path) + } + // Verify credentials from JSON body + var loginReq struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil { + t.Errorf("Failed to decode login request: %v", err) + } + if loginReq.Username != testConfig.Username || loginReq.Password != testConfig.Password { + t.Error("Invalid username/password in request") + } + // Return success response with token + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "200", + "data": "mock-jwt-token", + "success": true, + }) + })) + defer mockServer.Close() + + client := GetInstance(testConfig) + client.logger = zap.NewNop() + err := client.RefreshToken(mockServer.Listener.Addr().String()) + if err != nil { + t.Fatalf("RefreshToken failed: %v", err) + } + if client.jwtToken != "mock-jwt-token" { + t.Error("jwtToken not updated after login") + } +} + +func TestRefreshToken_RetryMechanism(t *testing.T) { + resetInstance() + attemptCount := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attemptCount++ + if attemptCount < 3 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "200", + "data": "retry-success-token", + "success": true, + }) + })) + defer mockServer.Close() + + client := GetInstance(testConfig) + client.logger = zap.NewNop() + err := client.RefreshToken(mockServer.Listener.Addr().String()) + if err != nil { + t.Fatalf("RefreshToken failed: %v", err) + } + if attemptCount != 3 { + t.Errorf("Expected 3 attempts, got %d", attemptCount) + } + if client.jwtToken != "retry-success-token" { + t.Error("jwtToken not updated after retry success") + } +} + +func TestWatch(t *testing.T) { + // Reset naming server singleton instance + resetInstance() + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/auth/login": + // Handle login request + if r.Method != http.MethodPost { + t.Errorf("login: Expected POST, got %s", r.Method) + } + w.Header().Set("Content-Type", contentTypeJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "200", + "data": "mock-jwt-token", + "success": true, + }) + + case "/naming/v1/watch": + // Verify auth token + if r.Header.Get(authorizationHeader) != "mock-jwt-token" { + t.Errorf("watch: Missing valid token, got %s", r.Header.Get(authorizationHeader)) + w.WriteHeader(http.StatusUnauthorized) + return + } + // Verify request method and parameters + if r.Method != http.MethodPost { + t.Errorf("watch: Expected POST, got %s", r.Method) + } + params := r.URL.Query() + if params.Get("vGroup") != "testGroup" { + t.Errorf("watch: Invalid vGroup, got %s", params.Get("vGroup")) + } + if params.Get("clientTerm") != "0" { + t.Errorf("watch: Invalid clientTerm, got %s", params.Get("clientTerm")) + } + w.WriteHeader(http.StatusOK) + + default: + t.Errorf("Unknown path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + // Configure client + mockAddr := mockServer.Listener.Addr().String() + testConfig.ServerAddr = mockAddr + client := GetInstance(testConfig) + client.logger = zap.NewNop() + client.availableNamingMap.Store(mockAddr, int32(0)) + client.namingAddrCache = mockAddr + + // Call Watch and verify result + changed, err := client.Watch("testGroup") + if err != nil { + t.Fatalf("Watch failed: %v", err) + } + if !changed { + t.Error("Expected change detected (changed=true), got false") + } +} + +// Test Watch handling non-200 responses +func TestWatch_ErrorResponse(t *testing.T) { + // Reset naming server singleton instance + resetInstance() + // Mock server returning error status + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer mockServer.Close() + + client := GetInstance(testConfig) + client.logger = zap.NewNop() + mockAddr := mockServer.Listener.Addr().String() + client.availableNamingMap.Store(mockAddr, int32(0)) + client.namingAddrCache = mockAddr + + // Verify Watch returns error for non-200 status + _, err := client.Watch("testGroup") + if err == nil { + t.Error("Expected error from Watch, got nil") + } +} + +func TestNamingServerConfig_LoadFromYAML(t *testing.T) { + // Prepare test YAML content with custom configurations + yamlContent := ` +naming-server: + cluster: "test-cluster" + server-addr: "192.168.1.100:8888" + namespace: "test-ns" + heartbeat-period: 3000 + metadata-max-age-ms: 60000 + username: "test-user" + password: "test-pass" + token-validity-in-milliseconds: 3600000 +` + + // Create temporary YAML file + tmpFile, err := os.CreateTemp("", "naming-server-test-*.yaml") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(yamlContent); err != nil { + t.Fatalf("Failed to write YAML: %v", err) + } + tmpFile.Close() + + // Parse YAML into config structure + var registryConfig struct { + NamingServer NamingServerConfig `yaml:"naming-server"` + } + data, err := os.ReadFile(tmpFile.Name()) + if err != nil { + t.Fatalf("Failed to read temp file: %v", err) + } + if err := yaml.Unmarshal(data, ®istryConfig); err != nil { + t.Fatalf("Failed to unmarshal YAML: %v", err) + } + + // Verify parsed values match YAML content + nsConfig := registryConfig.NamingServer + if nsConfig.Cluster != "test-cluster" { + t.Errorf("Cluster: expected 'test-cluster', got %s", nsConfig.Cluster) + } + if nsConfig.ServerAddr != "192.168.1.100:8888" { + t.Errorf("ServerAddr: expected '192.168.1.100:8888', got %s", nsConfig.ServerAddr) + } + if nsConfig.Namespace != "test-ns" { + t.Errorf("Namespace: expected 'test-ns', got %s", nsConfig.Namespace) + } + if nsConfig.HeartbeatPeriod != 3000 { + t.Errorf("HeartbeatPeriod: expected 3000, got %d", nsConfig.HeartbeatPeriod) + } + if nsConfig.Username != "test-user" { + t.Errorf("Username: expected 'test-user', got %s", nsConfig.Username) + } + if nsConfig.Password != "test-pass" { + t.Errorf("Password: expected 'test-pass', got %s", nsConfig.Password) + } + if nsConfig.TokenValidityInMilliseconds != 3600000 { + t.Errorf("TokenValidity: expected 3600000, got %d", nsConfig.TokenValidityInMilliseconds) + } +} + +func TestNamingServerConfig_DefaultValues(t *testing.T) { + // Initialize default values using flag registration + var nsConfig NamingServerConfig + fs := flag.NewFlagSet("test", flag.ContinueOnError) + nsConfig.RegisterFlagsWithPrefix("naming-server", fs) + if err := fs.Parse([]string{}); err != nil { + t.Fatalf("Failed to parse flags: %v", err) + } + + // Parse empty YAML configuration + yamlContent := `naming-server: {}` + tmpFile, err := os.CreateTemp("", "naming-server-default-*.yaml") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + if _, err := tmpFile.WriteString(yamlContent); err != nil { + t.Fatalf("Failed to write YAML: %v", err) + } + tmpFile.Close() + + // Load empty config from YAML + var tmpConfig struct { + NamingServer NamingServerConfig `yaml:"naming-server"` + } + data, _ := os.ReadFile(tmpFile.Name()) Review Comment: os.ReadFile error is ignored here (data, _ := os.ReadFile(...)). If the read fails, the test may pass/fail for the wrong reason. Please handle the error and fail the test when it occurs. ```suggestion data, err := os.ReadFile(tmpFile.Name()) if err != nil { t.Fatalf("Failed to read YAML: %v", err) } ``` ########## pkg/discovery/naming_server.go: ########## @@ -0,0 +1,673 @@ +/* + * 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 discovery + +import ( + "encoding/json" + "errors" + "fmt" + gostnet "github.com/dubbogo/gost/net" + "go.uber.org/zap" + "net/http" + "net/url" + "seata.apache.org/seata-go/v2/pkg/util/rand" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + httpPrefix = "http://" + healthCheckThreshold = 3 + longPollTimeoutPeriod = 28 * time.Second + authorizationHeader = "Authorization" + contentTypeJSON = "application/json" + namingAddrCacheTTL = 30 * time.Second + failureRecoveryThreshold = 3 + maxRetryAttempts = 3 + retryDelayMs = 1000 +) + +type MetaResponse struct { + Term int64 `json:"term"` + ClusterList []Cluster `json:"clusterList"` +} + +type Cluster struct { + ClusterName string `json:"clusterName"` + ClusterType string `json:"clusterType"` + UnitData []Unit `json:"unitData"` +} + +type Unit struct { + UnitName string `json:"unitName"` + NamingInstanceList []NamingServerNode `json:"namingInstanceList"` +} + +type NamingServerNode struct { + Role ClusterRole `json:"role"` + Term int64 `json:"term"` + Transaction Endpoint `json:"transaction"` + Control Endpoint `json:"control"` + Internal Endpoint `json:"internal"` + Group string `json:"group"` + Version string `json:"version"` + Metadata map[string]interface{} `json:"metadata"` + TimeStamp int64 `json:"timeStamp"` + Weight float64 `json:"weight"` + Healthy bool `json:"healthy"` + Unit string `json:"unit"` +} + +type Endpoint struct { + Host string `json:"host"` + Port int `json:"port"` + Protocol string `json:"protocol"` +} + +type ExternalEndpoint struct { + Host string `json:"host"` + ControlPort int `json:"controlPort"` + TransactionPort int `json:"transactionPort"` +} + +type ClusterRole string + +const ( + ClusterRoleLeader ClusterRole = "LEADER" + ClusterRoleMember ClusterRole = "MEMBER" +) + +type NamingServerClient struct { + config *NamingServerConfig + logger *zap.Logger + mu sync.Mutex + instance *NamingServerClient + term int64 + jwtToken string + tokenTimeStamp int64 + isSubscribed bool + namingAddrCache string + namingAddrCacheTimestamp int64 + + availableNamingMap sync.Map + vgroupAddressMap sync.Map + listenerServiceMap sync.Map + + healthCheckTicker *time.Ticker + closeChan chan struct{} + wg sync.WaitGroup + + httpClient *http.Client + longPollClient *http.Client +} + +type NamingListener interface { + OnEvent(vGroup string) error +} + +type NamingServerRegistryService struct { + client *NamingServerClient +} + +var _ NamingserverRegistry = (*NamingServerRegistryService)(nil) + +func (n *NamingServerRegistryService) Lookup(key string) ([]*ServiceInstance, error) { + return n.client.Lookup(key) +} + +func (n *NamingServerRegistryService) Close() { + n.client.Close() +} + +func newNamingServerRegistryService(_ *ServiceConfig, cfg *NamingServerConfig) RegistryService { + client := GetInstance(cfg) + return &NamingServerRegistryService{ + client: client, + } +} + +var ( + namingServerInstance *NamingServerClient + namingServerOnce sync.Once +) + +func GetInstance(config *NamingServerConfig) *NamingServerClient { + namingServerOnce.Do(func() { + namingServerInstance = &NamingServerClient{ + config: config, + logger: zap.L().Named("naming-server-client"), + closeChan: make(chan struct{}), + healthCheckTicker: time.NewTicker(time.Duration(config.HeartbeatPeriod) * time.Millisecond), + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // Initialize available naming server addresses from config + namingServerInstance.initNamingAddrs() + namingServerInstance.initHealthCheck() + }) + return namingServerInstance +} + +func resetInstance() { + if namingServerInstance != nil { + namingServerInstance.Close() + namingServerInstance.mu.Lock() + namingServerInstance.clearNamingAddrCache() + namingServerInstance.mu.Unlock() + } + namingServerInstance = nil + namingServerOnce = sync.Once{} +} + +func (c *NamingServerClient) initNamingAddrs() { + addrs := c.getNamingAddrs() + for _, addr := range addrs { + c.availableNamingMap.Store(addr, int32(0)) + } +} + +func (c *NamingServerClient) initHealthCheck() { + c.wg.Add(1) + go func() { + defer c.wg.Done() + for { + select { + case <-c.healthCheckTicker.C: + urlList := c.getNamingAddrs() + c.checkAvailableNamingAddr(urlList) + case <-c.closeChan: + return + } + } + }() +} + +func (c *NamingServerClient) checkAvailableNamingAddr(urlList []string) { + for _, addr := range urlList { + isHealthy := c.doHealthCheck(addr) + + val, _ := c.availableNamingMap.LoadOrStore(addr, int32(0)) + failCount := val.(int32) + + if !isHealthy { + newFailCount := atomic.AddInt32(&failCount, 1) + c.availableNamingMap.Store(addr, newFailCount) + if newFailCount == 1 { + c.logger.Warn("naming server check failed", zap.String("addr", addr), zap.Int32("failCount", newFailCount)) + } else if newFailCount >= healthCheckThreshold { + c.logger.Error("naming server offline", zap.String("addr", addr), zap.Int32("failCount", newFailCount)) + c.mu.Lock() + if c.namingAddrCache == addr { + c.clearNamingAddrCache() + } + c.mu.Unlock() + } + } else { + if failCount > 0 { + c.availableNamingMap.Store(addr, int32(0)) + c.logger.Info("naming server recovered", zap.String("addr", addr), zap.Int32("previousFailCount", failCount)) + } + } + } +} + +func (c *NamingServerClient) doHealthCheck(addr string) bool { + checkURL := fmt.Sprintf("%s%s/naming/v1/health", httpPrefix, addr) + + req, err := http.NewRequest(http.MethodGet, checkURL, nil) + if err != nil { + c.logger.Error("create health check request failed", zap.Error(err)) + return false + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(req) + if err != nil { + c.logger.Error("health check failed", zap.String("addr", addr), zap.Error(err)) + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +func (c *NamingServerClient) getNamingAddrs() []string { + return strings.Split(c.config.ServerAddr, ",") +} + +func (c *NamingServerClient) Lookup(vGroup string) ([]*ServiceInstance, error) { + if !c.isSubscribed { + if err := c.RefreshGroup(vGroup); err != nil { + return nil, fmt.Errorf("refresh group failed: %w", err) + } + listener := &RefreshListener{client: c} + if err := c.Subscribe(vGroup, listener); err != nil { + return nil, fmt.Errorf("subscribe failed: %w", err) + } + } + + val, ok := c.vgroupAddressMap.Load(vGroup) + if !ok { + if err := c.RefreshGroup(vGroup); err != nil { + return nil, fmt.Errorf("refresh group failed: %w", err) + } + val, ok = c.vgroupAddressMap.Load(vGroup) + if !ok { + return nil, errors.New("no nodes found for vgroup") + } + } + + nodes := val.([]NamingServerNode) + + var instances []*ServiceInstance + for _, node := range nodes { + if !node.Healthy { + continue + } + + if node.Transaction.Host == "" || node.Transaction.Port <= 0 || node.Transaction.Port > 65535 { + c.logger.Warn("invalid node address", zap.String("host", node.Transaction.Host), zap.Int("port", node.Transaction.Port)) + continue + } + + instances = append(instances, &ServiceInstance{ + Addr: node.Transaction.Host, + Port: node.Transaction.Port, + }) + } + return instances, nil +} + +func (c *NamingServerClient) RefreshGroup(vGroup string) error { + namingAddr, err := c.getNamingAddr() + if err != nil { + return err + } + + if c.isTokenExpired() { + if err := c.RefreshToken(namingAddr); err != nil { + return err + } + } + + params := url.Values{} + params.Add("vGroup", vGroup) + params.Add("namespace", c.config.Namespace) + + discoveryURL := fmt.Sprintf("%s%s/naming/v1/discovery?%s", httpPrefix, namingAddr, params.Encode()) + req, err := http.NewRequest(http.MethodGet, discoveryURL, nil) + if err != nil { + return err + } + if c.jwtToken != "" { + req.Header.Set(authorizationHeader, c.jwtToken) + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("discovery request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("discovery failed: unauthorized (401), authentication required. please configure username and password in the naming server config") + } + return fmt.Errorf("discovery failed, status: %d", resp.StatusCode) + } + + var metaResp MetaResponse + if err := json.NewDecoder(resp.Body).Decode(&metaResp); err != nil { + return fmt.Errorf("decode meta response failed: %w", err) + } + + return c.handleMetadata(&metaResp, vGroup) +} + +func (c *NamingServerClient) getNamingAddr() (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now().UnixMilli() + + if c.namingAddrCache != "" && + now-c.namingAddrCacheTimestamp < namingAddrCacheTTL.Milliseconds() { + + if val, ok := c.availableNamingMap.Load(c.namingAddrCache); ok { + failCount := val.(int32) + if failCount < healthCheckThreshold { + return c.namingAddrCache, nil + } + } + + c.clearNamingAddrCache() + } + + var availableAddrs []string + c.availableNamingMap.Range(func(key, value interface{}) bool { + addr := key.(string) + failCount := value.(int32) + if failCount < healthCheckThreshold { + availableAddrs = append(availableAddrs, addr) + } + return true + }) + + if len(availableAddrs) == 0 { + return "", errors.New("no available naming server") + } + + addr := availableAddrs[rand.RandIntn(len(availableAddrs))] + c.namingAddrCache = addr + c.namingAddrCacheTimestamp = now + return addr, nil +} + +func (c *NamingServerClient) clearNamingAddrCache() { + c.namingAddrCache = "" + c.namingAddrCacheTimestamp = 0 +} + +func (c *NamingServerClient) isTokenExpired() bool { + if c.config.Username == "" || c.config.Password == "" { + return false + } + + ts := atomic.LoadInt64(&c.tokenTimeStamp) + if ts == 0 { + return true + } + + return time.Now().UnixMilli() >= ts+c.config.TokenValidityInMilliseconds +} + +func (c *NamingServerClient) RefreshToken(addr string) error { + if c.config.Username == "" || c.config.Password == "" { + return nil + } + + var lastErr error + for attempt := 1; attempt <= maxRetryAttempts; attempt++ { + err := c.doRefreshToken(addr) + if err == nil { + return nil + } + + lastErr = err + if attempt < maxRetryAttempts { + c.logger.Warn("token refresh failed, retrying", + zap.String("addr", addr), + zap.Int("attempt", attempt), + zap.Error(err)) + time.Sleep(time.Duration(retryDelayMs*attempt) * time.Millisecond) + } + } + + c.logger.Error("token refresh failed after all retries", + zap.String("addr", addr), + zap.Int("maxAttempts", maxRetryAttempts), + zap.Error(lastErr)) + return fmt.Errorf("token refresh failed after %d attempts: %w", maxRetryAttempts, lastErr) +} + +func (c *NamingServerClient) doRefreshToken(addr string) error { + loginURL := fmt.Sprintf("%s%s/api/v1/auth/login", httpPrefix, addr) + + loginReq := struct { + Username string `json:"username"` + Password string `json:"password"` + }{ + Username: c.config.Username, + Password: c.config.Password, + } + + body, err := json.Marshal(loginReq) + if err != nil { + return fmt.Errorf("marshal login request failed: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, loginURL, strings.NewReader(string(body))) + if err != nil { + return err + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("login request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + c.logger.Error("login request failed", + zap.String("addr", addr), + zap.Int("statusCode", resp.StatusCode), + zap.String("username", c.config.Username)) + return fmt.Errorf("authentication failed: status %d", resp.StatusCode) + } + + var loginResp struct { + Success bool `json:"success"` + Data string `json:"data"` + Code interface{} `json:"code"` + Msg string `json:"msg"` + } + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return fmt.Errorf("decode login response failed: %w", err) + } + + c.logger.Debug("login response received", + zap.Bool("success", loginResp.Success), + zap.String("code", fmt.Sprint(loginResp.Code)), + zap.String("msg", loginResp.Msg), + zap.String("data", loginResp.Data)) + + if !loginResp.Success { + errMsg := fmt.Sprintf("authentication failed: success=false, msg=%s", loginResp.Msg) + c.logger.Error(errMsg) + return fmt.Errorf(errMsg) + } + if loginResp.Data == "" { + return fmt.Errorf("authentication failed: token is empty") + } + + c.jwtToken = loginResp.Data + atomic.StoreInt64(&c.tokenTimeStamp, time.Now().UnixMilli()) + c.logger.Info("token refreshed successfully", zap.String("addr", addr)) + return nil +} + +func (c *NamingServerClient) handleMetadata(metaResp *MetaResponse, vGroup string) error { + if metaResp.Term > 0 { + atomic.StoreInt64(&c.term, metaResp.Term) + } + + var newNodes []NamingServerNode + for _, cluster := range metaResp.ClusterList { + for _, unit := range cluster.UnitData { + for _, node := range unit.NamingInstanceList { + if (node.Role == ClusterRoleLeader && node.Term >= atomic.LoadInt64(&c.term)) || + node.Role == ClusterRoleMember { + newNodes = append(newNodes, node) + } + } + } + } + + c.vgroupAddressMap.Store(vGroup, newNodes) + return nil +} + +type RefreshListener struct { + client *NamingServerClient +} + +func (l *RefreshListener) OnEvent(vGroup string) error { + return l.client.RefreshGroup(vGroup) +} + +func (c *NamingServerClient) Subscribe(vGroup string, listener NamingListener) error { + c.mu.Lock() + defer c.mu.Unlock() + + val, _ := c.listenerServiceMap.LoadOrStore(vGroup, []NamingListener{}) + listeners := append(val.([]NamingListener), listener) + c.listenerServiceMap.Store(vGroup, listeners) + + if !c.isSubscribed { + c.isSubscribed = true + c.wg.Add(1) + go c.watchLoop(vGroup) + } Review Comment: Subscribe() uses a single global flag (c.isSubscribed) and starts watchLoop(vGroup) only once for the first vGroup. If Lookup/Subscribe is called with additional vGroups later, no watch loop will be started for them, so their listeners will never be notified and their cache will not be kept fresh. Consider tracking subscriptions per vGroup (e.g., a map/set of active vGroups and one watch goroutine per vGroup), or change watchLoop to multiplex over all subscribed vGroups. ```suggestion if val, ok := c.listenerServiceMap.Load(vGroup); ok { listeners := append(val.([]NamingListener), listener) c.listenerServiceMap.Store(vGroup, listeners) return nil } c.listenerServiceMap.Store(vGroup, []NamingListener{listener}) c.wg.Add(1) go c.watchLoop(vGroup) ``` ########## pkg/discovery/naming_server.go: ########## @@ -0,0 +1,673 @@ +/* + * 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 discovery + +import ( + "encoding/json" + "errors" + "fmt" + gostnet "github.com/dubbogo/gost/net" + "go.uber.org/zap" + "net/http" + "net/url" + "seata.apache.org/seata-go/v2/pkg/util/rand" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + httpPrefix = "http://" + healthCheckThreshold = 3 + longPollTimeoutPeriod = 28 * time.Second + authorizationHeader = "Authorization" + contentTypeJSON = "application/json" + namingAddrCacheTTL = 30 * time.Second + failureRecoveryThreshold = 3 + maxRetryAttempts = 3 + retryDelayMs = 1000 +) + +type MetaResponse struct { + Term int64 `json:"term"` + ClusterList []Cluster `json:"clusterList"` +} + +type Cluster struct { + ClusterName string `json:"clusterName"` + ClusterType string `json:"clusterType"` + UnitData []Unit `json:"unitData"` +} + +type Unit struct { + UnitName string `json:"unitName"` + NamingInstanceList []NamingServerNode `json:"namingInstanceList"` +} + +type NamingServerNode struct { + Role ClusterRole `json:"role"` + Term int64 `json:"term"` + Transaction Endpoint `json:"transaction"` + Control Endpoint `json:"control"` + Internal Endpoint `json:"internal"` + Group string `json:"group"` + Version string `json:"version"` + Metadata map[string]interface{} `json:"metadata"` + TimeStamp int64 `json:"timeStamp"` + Weight float64 `json:"weight"` + Healthy bool `json:"healthy"` + Unit string `json:"unit"` +} + +type Endpoint struct { + Host string `json:"host"` + Port int `json:"port"` + Protocol string `json:"protocol"` +} + +type ExternalEndpoint struct { + Host string `json:"host"` + ControlPort int `json:"controlPort"` + TransactionPort int `json:"transactionPort"` +} + +type ClusterRole string + +const ( + ClusterRoleLeader ClusterRole = "LEADER" + ClusterRoleMember ClusterRole = "MEMBER" +) + +type NamingServerClient struct { + config *NamingServerConfig + logger *zap.Logger + mu sync.Mutex + instance *NamingServerClient + term int64 + jwtToken string + tokenTimeStamp int64 + isSubscribed bool + namingAddrCache string + namingAddrCacheTimestamp int64 + + availableNamingMap sync.Map + vgroupAddressMap sync.Map + listenerServiceMap sync.Map + + healthCheckTicker *time.Ticker + closeChan chan struct{} + wg sync.WaitGroup + + httpClient *http.Client + longPollClient *http.Client +} + +type NamingListener interface { + OnEvent(vGroup string) error +} + +type NamingServerRegistryService struct { + client *NamingServerClient +} + +var _ NamingserverRegistry = (*NamingServerRegistryService)(nil) + +func (n *NamingServerRegistryService) Lookup(key string) ([]*ServiceInstance, error) { + return n.client.Lookup(key) +} + +func (n *NamingServerRegistryService) Close() { + n.client.Close() +} + +func newNamingServerRegistryService(_ *ServiceConfig, cfg *NamingServerConfig) RegistryService { + client := GetInstance(cfg) + return &NamingServerRegistryService{ + client: client, + } +} + +var ( + namingServerInstance *NamingServerClient + namingServerOnce sync.Once +) + +func GetInstance(config *NamingServerConfig) *NamingServerClient { + namingServerOnce.Do(func() { + namingServerInstance = &NamingServerClient{ + config: config, + logger: zap.L().Named("naming-server-client"), + closeChan: make(chan struct{}), + healthCheckTicker: time.NewTicker(time.Duration(config.HeartbeatPeriod) * time.Millisecond), + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // Initialize available naming server addresses from config + namingServerInstance.initNamingAddrs() + namingServerInstance.initHealthCheck() + }) + return namingServerInstance +} + +func resetInstance() { + if namingServerInstance != nil { + namingServerInstance.Close() + namingServerInstance.mu.Lock() + namingServerInstance.clearNamingAddrCache() + namingServerInstance.mu.Unlock() + } + namingServerInstance = nil + namingServerOnce = sync.Once{} +} + +func (c *NamingServerClient) initNamingAddrs() { + addrs := c.getNamingAddrs() + for _, addr := range addrs { + c.availableNamingMap.Store(addr, int32(0)) + } +} + +func (c *NamingServerClient) initHealthCheck() { + c.wg.Add(1) + go func() { + defer c.wg.Done() + for { + select { + case <-c.healthCheckTicker.C: + urlList := c.getNamingAddrs() + c.checkAvailableNamingAddr(urlList) + case <-c.closeChan: + return + } + } + }() +} + +func (c *NamingServerClient) checkAvailableNamingAddr(urlList []string) { + for _, addr := range urlList { + isHealthy := c.doHealthCheck(addr) + + val, _ := c.availableNamingMap.LoadOrStore(addr, int32(0)) + failCount := val.(int32) + + if !isHealthy { + newFailCount := atomic.AddInt32(&failCount, 1) + c.availableNamingMap.Store(addr, newFailCount) + if newFailCount == 1 { + c.logger.Warn("naming server check failed", zap.String("addr", addr), zap.Int32("failCount", newFailCount)) + } else if newFailCount >= healthCheckThreshold { + c.logger.Error("naming server offline", zap.String("addr", addr), zap.Int32("failCount", newFailCount)) + c.mu.Lock() + if c.namingAddrCache == addr { + c.clearNamingAddrCache() + } + c.mu.Unlock() + } + } else { + if failCount > 0 { + c.availableNamingMap.Store(addr, int32(0)) + c.logger.Info("naming server recovered", zap.String("addr", addr), zap.Int32("previousFailCount", failCount)) + } + } + } +} + +func (c *NamingServerClient) doHealthCheck(addr string) bool { + checkURL := fmt.Sprintf("%s%s/naming/v1/health", httpPrefix, addr) + + req, err := http.NewRequest(http.MethodGet, checkURL, nil) + if err != nil { + c.logger.Error("create health check request failed", zap.Error(err)) + return false + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(req) + if err != nil { + c.logger.Error("health check failed", zap.String("addr", addr), zap.Error(err)) + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +func (c *NamingServerClient) getNamingAddrs() []string { + return strings.Split(c.config.ServerAddr, ",") +} + +func (c *NamingServerClient) Lookup(vGroup string) ([]*ServiceInstance, error) { + if !c.isSubscribed { + if err := c.RefreshGroup(vGroup); err != nil { + return nil, fmt.Errorf("refresh group failed: %w", err) + } + listener := &RefreshListener{client: c} + if err := c.Subscribe(vGroup, listener); err != nil { + return nil, fmt.Errorf("subscribe failed: %w", err) + } + } + + val, ok := c.vgroupAddressMap.Load(vGroup) + if !ok { + if err := c.RefreshGroup(vGroup); err != nil { + return nil, fmt.Errorf("refresh group failed: %w", err) + } + val, ok = c.vgroupAddressMap.Load(vGroup) + if !ok { + return nil, errors.New("no nodes found for vgroup") + } + } + + nodes := val.([]NamingServerNode) + + var instances []*ServiceInstance + for _, node := range nodes { + if !node.Healthy { + continue + } + + if node.Transaction.Host == "" || node.Transaction.Port <= 0 || node.Transaction.Port > 65535 { + c.logger.Warn("invalid node address", zap.String("host", node.Transaction.Host), zap.Int("port", node.Transaction.Port)) + continue + } + + instances = append(instances, &ServiceInstance{ + Addr: node.Transaction.Host, + Port: node.Transaction.Port, + }) + } + return instances, nil +} + +func (c *NamingServerClient) RefreshGroup(vGroup string) error { + namingAddr, err := c.getNamingAddr() + if err != nil { + return err + } + + if c.isTokenExpired() { + if err := c.RefreshToken(namingAddr); err != nil { + return err + } + } + + params := url.Values{} + params.Add("vGroup", vGroup) + params.Add("namespace", c.config.Namespace) + + discoveryURL := fmt.Sprintf("%s%s/naming/v1/discovery?%s", httpPrefix, namingAddr, params.Encode()) + req, err := http.NewRequest(http.MethodGet, discoveryURL, nil) + if err != nil { + return err + } + if c.jwtToken != "" { + req.Header.Set(authorizationHeader, c.jwtToken) + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("discovery request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("discovery failed: unauthorized (401), authentication required. please configure username and password in the naming server config") + } + return fmt.Errorf("discovery failed, status: %d", resp.StatusCode) + } + + var metaResp MetaResponse + if err := json.NewDecoder(resp.Body).Decode(&metaResp); err != nil { + return fmt.Errorf("decode meta response failed: %w", err) + } + + return c.handleMetadata(&metaResp, vGroup) +} + +func (c *NamingServerClient) getNamingAddr() (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now().UnixMilli() + + if c.namingAddrCache != "" && + now-c.namingAddrCacheTimestamp < namingAddrCacheTTL.Milliseconds() { + + if val, ok := c.availableNamingMap.Load(c.namingAddrCache); ok { + failCount := val.(int32) + if failCount < healthCheckThreshold { + return c.namingAddrCache, nil + } + } + + c.clearNamingAddrCache() + } + + var availableAddrs []string + c.availableNamingMap.Range(func(key, value interface{}) bool { + addr := key.(string) + failCount := value.(int32) + if failCount < healthCheckThreshold { + availableAddrs = append(availableAddrs, addr) + } + return true + }) + + if len(availableAddrs) == 0 { + return "", errors.New("no available naming server") + } + + addr := availableAddrs[rand.RandIntn(len(availableAddrs))] + c.namingAddrCache = addr + c.namingAddrCacheTimestamp = now + return addr, nil +} + +func (c *NamingServerClient) clearNamingAddrCache() { + c.namingAddrCache = "" + c.namingAddrCacheTimestamp = 0 +} + +func (c *NamingServerClient) isTokenExpired() bool { + if c.config.Username == "" || c.config.Password == "" { + return false + } + + ts := atomic.LoadInt64(&c.tokenTimeStamp) + if ts == 0 { + return true + } + + return time.Now().UnixMilli() >= ts+c.config.TokenValidityInMilliseconds +} + +func (c *NamingServerClient) RefreshToken(addr string) error { + if c.config.Username == "" || c.config.Password == "" { + return nil + } + + var lastErr error + for attempt := 1; attempt <= maxRetryAttempts; attempt++ { + err := c.doRefreshToken(addr) + if err == nil { + return nil + } + + lastErr = err + if attempt < maxRetryAttempts { + c.logger.Warn("token refresh failed, retrying", + zap.String("addr", addr), + zap.Int("attempt", attempt), + zap.Error(err)) + time.Sleep(time.Duration(retryDelayMs*attempt) * time.Millisecond) + } + } + + c.logger.Error("token refresh failed after all retries", + zap.String("addr", addr), + zap.Int("maxAttempts", maxRetryAttempts), + zap.Error(lastErr)) + return fmt.Errorf("token refresh failed after %d attempts: %w", maxRetryAttempts, lastErr) +} + +func (c *NamingServerClient) doRefreshToken(addr string) error { + loginURL := fmt.Sprintf("%s%s/api/v1/auth/login", httpPrefix, addr) + + loginReq := struct { + Username string `json:"username"` + Password string `json:"password"` + }{ + Username: c.config.Username, + Password: c.config.Password, + } + + body, err := json.Marshal(loginReq) + if err != nil { + return fmt.Errorf("marshal login request failed: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, loginURL, strings.NewReader(string(body))) + if err != nil { + return err + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("login request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + c.logger.Error("login request failed", + zap.String("addr", addr), + zap.Int("statusCode", resp.StatusCode), + zap.String("username", c.config.Username)) + return fmt.Errorf("authentication failed: status %d", resp.StatusCode) + } + + var loginResp struct { + Success bool `json:"success"` + Data string `json:"data"` + Code interface{} `json:"code"` + Msg string `json:"msg"` + } + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return fmt.Errorf("decode login response failed: %w", err) + } + + c.logger.Debug("login response received", + zap.Bool("success", loginResp.Success), + zap.String("code", fmt.Sprint(loginResp.Code)), + zap.String("msg", loginResp.Msg), + zap.String("data", loginResp.Data)) Review Comment: doRefreshToken() logs loginResp.Data (the JWT token) in debug logs. Tokens are credentials and should not be written to logs (even at debug) to avoid leakage via log aggregation. Please remove/redact the token value from logs (e.g., log only whether it is present, token length, or a short hash). ```suggestion zap.Bool("tokenPresent", loginResp.Data != ""), zap.Int("tokenLength", len(loginResp.Data))) ``` ########## pkg/discovery/naming_server.go: ########## @@ -0,0 +1,673 @@ +/* + * 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 discovery + +import ( + "encoding/json" + "errors" + "fmt" + gostnet "github.com/dubbogo/gost/net" + "go.uber.org/zap" + "net/http" + "net/url" + "seata.apache.org/seata-go/v2/pkg/util/rand" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + httpPrefix = "http://" + healthCheckThreshold = 3 + longPollTimeoutPeriod = 28 * time.Second + authorizationHeader = "Authorization" + contentTypeJSON = "application/json" + namingAddrCacheTTL = 30 * time.Second + failureRecoveryThreshold = 3 + maxRetryAttempts = 3 + retryDelayMs = 1000 +) + +type MetaResponse struct { + Term int64 `json:"term"` + ClusterList []Cluster `json:"clusterList"` +} + +type Cluster struct { + ClusterName string `json:"clusterName"` + ClusterType string `json:"clusterType"` + UnitData []Unit `json:"unitData"` +} + +type Unit struct { + UnitName string `json:"unitName"` + NamingInstanceList []NamingServerNode `json:"namingInstanceList"` +} + +type NamingServerNode struct { + Role ClusterRole `json:"role"` + Term int64 `json:"term"` + Transaction Endpoint `json:"transaction"` + Control Endpoint `json:"control"` + Internal Endpoint `json:"internal"` + Group string `json:"group"` + Version string `json:"version"` + Metadata map[string]interface{} `json:"metadata"` + TimeStamp int64 `json:"timeStamp"` + Weight float64 `json:"weight"` + Healthy bool `json:"healthy"` + Unit string `json:"unit"` +} + +type Endpoint struct { + Host string `json:"host"` + Port int `json:"port"` + Protocol string `json:"protocol"` +} + +type ExternalEndpoint struct { + Host string `json:"host"` + ControlPort int `json:"controlPort"` + TransactionPort int `json:"transactionPort"` +} + +type ClusterRole string + +const ( + ClusterRoleLeader ClusterRole = "LEADER" + ClusterRoleMember ClusterRole = "MEMBER" +) + +type NamingServerClient struct { + config *NamingServerConfig + logger *zap.Logger + mu sync.Mutex + instance *NamingServerClient + term int64 + jwtToken string + tokenTimeStamp int64 + isSubscribed bool + namingAddrCache string + namingAddrCacheTimestamp int64 + + availableNamingMap sync.Map + vgroupAddressMap sync.Map + listenerServiceMap sync.Map + + healthCheckTicker *time.Ticker + closeChan chan struct{} + wg sync.WaitGroup + + httpClient *http.Client + longPollClient *http.Client +} + +type NamingListener interface { + OnEvent(vGroup string) error +} + +type NamingServerRegistryService struct { + client *NamingServerClient +} + +var _ NamingserverRegistry = (*NamingServerRegistryService)(nil) + +func (n *NamingServerRegistryService) Lookup(key string) ([]*ServiceInstance, error) { + return n.client.Lookup(key) +} + +func (n *NamingServerRegistryService) Close() { + n.client.Close() +} + +func newNamingServerRegistryService(_ *ServiceConfig, cfg *NamingServerConfig) RegistryService { + client := GetInstance(cfg) + return &NamingServerRegistryService{ + client: client, + } +} + +var ( + namingServerInstance *NamingServerClient + namingServerOnce sync.Once +) + +func GetInstance(config *NamingServerConfig) *NamingServerClient { + namingServerOnce.Do(func() { + namingServerInstance = &NamingServerClient{ + config: config, + logger: zap.L().Named("naming-server-client"), + closeChan: make(chan struct{}), + healthCheckTicker: time.NewTicker(time.Duration(config.HeartbeatPeriod) * time.Millisecond), + httpClient: &http.Client{Timeout: 3 * time.Second}, + longPollClient: &http.Client{Timeout: 30 * time.Second}, + } + // Initialize available naming server addresses from config + namingServerInstance.initNamingAddrs() + namingServerInstance.initHealthCheck() + }) + return namingServerInstance +} + +func resetInstance() { + if namingServerInstance != nil { + namingServerInstance.Close() + namingServerInstance.mu.Lock() + namingServerInstance.clearNamingAddrCache() + namingServerInstance.mu.Unlock() + } + namingServerInstance = nil + namingServerOnce = sync.Once{} +} + +func (c *NamingServerClient) initNamingAddrs() { + addrs := c.getNamingAddrs() + for _, addr := range addrs { + c.availableNamingMap.Store(addr, int32(0)) + } +} + +func (c *NamingServerClient) initHealthCheck() { + c.wg.Add(1) + go func() { + defer c.wg.Done() + for { + select { + case <-c.healthCheckTicker.C: + urlList := c.getNamingAddrs() + c.checkAvailableNamingAddr(urlList) + case <-c.closeChan: + return + } + } + }() +} + +func (c *NamingServerClient) checkAvailableNamingAddr(urlList []string) { + for _, addr := range urlList { + isHealthy := c.doHealthCheck(addr) + + val, _ := c.availableNamingMap.LoadOrStore(addr, int32(0)) + failCount := val.(int32) + + if !isHealthy { + newFailCount := atomic.AddInt32(&failCount, 1) + c.availableNamingMap.Store(addr, newFailCount) + if newFailCount == 1 { + c.logger.Warn("naming server check failed", zap.String("addr", addr), zap.Int32("failCount", newFailCount)) + } else if newFailCount >= healthCheckThreshold { + c.logger.Error("naming server offline", zap.String("addr", addr), zap.Int32("failCount", newFailCount)) + c.mu.Lock() + if c.namingAddrCache == addr { + c.clearNamingAddrCache() + } + c.mu.Unlock() + } + } else { + if failCount > 0 { + c.availableNamingMap.Store(addr, int32(0)) + c.logger.Info("naming server recovered", zap.String("addr", addr), zap.Int32("previousFailCount", failCount)) + } + } + } +} + +func (c *NamingServerClient) doHealthCheck(addr string) bool { + checkURL := fmt.Sprintf("%s%s/naming/v1/health", httpPrefix, addr) + + req, err := http.NewRequest(http.MethodGet, checkURL, nil) + if err != nil { + c.logger.Error("create health check request failed", zap.Error(err)) + return false + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(req) + if err != nil { + c.logger.Error("health check failed", zap.String("addr", addr), zap.Error(err)) + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +func (c *NamingServerClient) getNamingAddrs() []string { + return strings.Split(c.config.ServerAddr, ",") +} + +func (c *NamingServerClient) Lookup(vGroup string) ([]*ServiceInstance, error) { + if !c.isSubscribed { + if err := c.RefreshGroup(vGroup); err != nil { + return nil, fmt.Errorf("refresh group failed: %w", err) + } + listener := &RefreshListener{client: c} + if err := c.Subscribe(vGroup, listener); err != nil { + return nil, fmt.Errorf("subscribe failed: %w", err) + } + } + + val, ok := c.vgroupAddressMap.Load(vGroup) + if !ok { + if err := c.RefreshGroup(vGroup); err != nil { + return nil, fmt.Errorf("refresh group failed: %w", err) + } + val, ok = c.vgroupAddressMap.Load(vGroup) + if !ok { + return nil, errors.New("no nodes found for vgroup") + } + } + + nodes := val.([]NamingServerNode) + + var instances []*ServiceInstance + for _, node := range nodes { + if !node.Healthy { + continue + } + + if node.Transaction.Host == "" || node.Transaction.Port <= 0 || node.Transaction.Port > 65535 { + c.logger.Warn("invalid node address", zap.String("host", node.Transaction.Host), zap.Int("port", node.Transaction.Port)) + continue + } + + instances = append(instances, &ServiceInstance{ + Addr: node.Transaction.Host, + Port: node.Transaction.Port, + }) + } + return instances, nil +} + +func (c *NamingServerClient) RefreshGroup(vGroup string) error { + namingAddr, err := c.getNamingAddr() + if err != nil { + return err + } + + if c.isTokenExpired() { + if err := c.RefreshToken(namingAddr); err != nil { + return err + } + } + + params := url.Values{} + params.Add("vGroup", vGroup) + params.Add("namespace", c.config.Namespace) + + discoveryURL := fmt.Sprintf("%s%s/naming/v1/discovery?%s", httpPrefix, namingAddr, params.Encode()) + req, err := http.NewRequest(http.MethodGet, discoveryURL, nil) + if err != nil { + return err + } + if c.jwtToken != "" { + req.Header.Set(authorizationHeader, c.jwtToken) + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("discovery request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("discovery failed: unauthorized (401), authentication required. please configure username and password in the naming server config") + } + return fmt.Errorf("discovery failed, status: %d", resp.StatusCode) + } + + var metaResp MetaResponse + if err := json.NewDecoder(resp.Body).Decode(&metaResp); err != nil { + return fmt.Errorf("decode meta response failed: %w", err) + } + + return c.handleMetadata(&metaResp, vGroup) +} + +func (c *NamingServerClient) getNamingAddr() (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now().UnixMilli() + + if c.namingAddrCache != "" && + now-c.namingAddrCacheTimestamp < namingAddrCacheTTL.Milliseconds() { + + if val, ok := c.availableNamingMap.Load(c.namingAddrCache); ok { + failCount := val.(int32) + if failCount < healthCheckThreshold { + return c.namingAddrCache, nil + } + } + + c.clearNamingAddrCache() + } + + var availableAddrs []string + c.availableNamingMap.Range(func(key, value interface{}) bool { + addr := key.(string) + failCount := value.(int32) + if failCount < healthCheckThreshold { + availableAddrs = append(availableAddrs, addr) + } + return true + }) + + if len(availableAddrs) == 0 { + return "", errors.New("no available naming server") + } + + addr := availableAddrs[rand.RandIntn(len(availableAddrs))] + c.namingAddrCache = addr + c.namingAddrCacheTimestamp = now + return addr, nil +} + +func (c *NamingServerClient) clearNamingAddrCache() { + c.namingAddrCache = "" + c.namingAddrCacheTimestamp = 0 +} + +func (c *NamingServerClient) isTokenExpired() bool { + if c.config.Username == "" || c.config.Password == "" { + return false + } + + ts := atomic.LoadInt64(&c.tokenTimeStamp) + if ts == 0 { + return true + } + + return time.Now().UnixMilli() >= ts+c.config.TokenValidityInMilliseconds +} + +func (c *NamingServerClient) RefreshToken(addr string) error { + if c.config.Username == "" || c.config.Password == "" { + return nil + } + + var lastErr error + for attempt := 1; attempt <= maxRetryAttempts; attempt++ { + err := c.doRefreshToken(addr) + if err == nil { + return nil + } + + lastErr = err + if attempt < maxRetryAttempts { + c.logger.Warn("token refresh failed, retrying", + zap.String("addr", addr), + zap.Int("attempt", attempt), + zap.Error(err)) + time.Sleep(time.Duration(retryDelayMs*attempt) * time.Millisecond) + } + } + + c.logger.Error("token refresh failed after all retries", + zap.String("addr", addr), + zap.Int("maxAttempts", maxRetryAttempts), + zap.Error(lastErr)) + return fmt.Errorf("token refresh failed after %d attempts: %w", maxRetryAttempts, lastErr) +} + +func (c *NamingServerClient) doRefreshToken(addr string) error { + loginURL := fmt.Sprintf("%s%s/api/v1/auth/login", httpPrefix, addr) + + loginReq := struct { + Username string `json:"username"` + Password string `json:"password"` + }{ + Username: c.config.Username, + Password: c.config.Password, + } + + body, err := json.Marshal(loginReq) + if err != nil { + return fmt.Errorf("marshal login request failed: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, loginURL, strings.NewReader(string(body))) + if err != nil { + return err + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("login request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + c.logger.Error("login request failed", + zap.String("addr", addr), + zap.Int("statusCode", resp.StatusCode), + zap.String("username", c.config.Username)) + return fmt.Errorf("authentication failed: status %d", resp.StatusCode) + } + + var loginResp struct { + Success bool `json:"success"` + Data string `json:"data"` + Code interface{} `json:"code"` + Msg string `json:"msg"` + } + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return fmt.Errorf("decode login response failed: %w", err) + } + + c.logger.Debug("login response received", + zap.Bool("success", loginResp.Success), + zap.String("code", fmt.Sprint(loginResp.Code)), + zap.String("msg", loginResp.Msg), + zap.String("data", loginResp.Data)) + + if !loginResp.Success { + errMsg := fmt.Sprintf("authentication failed: success=false, msg=%s", loginResp.Msg) + c.logger.Error(errMsg) + return fmt.Errorf(errMsg) + } + if loginResp.Data == "" { + return fmt.Errorf("authentication failed: token is empty") + } + + c.jwtToken = loginResp.Data + atomic.StoreInt64(&c.tokenTimeStamp, time.Now().UnixMilli()) + c.logger.Info("token refreshed successfully", zap.String("addr", addr)) + return nil +} + +func (c *NamingServerClient) handleMetadata(metaResp *MetaResponse, vGroup string) error { + if metaResp.Term > 0 { + atomic.StoreInt64(&c.term, metaResp.Term) + } + + var newNodes []NamingServerNode + for _, cluster := range metaResp.ClusterList { + for _, unit := range cluster.UnitData { + for _, node := range unit.NamingInstanceList { + if (node.Role == ClusterRoleLeader && node.Term >= atomic.LoadInt64(&c.term)) || + node.Role == ClusterRoleMember { + newNodes = append(newNodes, node) + } + } + } + } + + c.vgroupAddressMap.Store(vGroup, newNodes) + return nil +} + +type RefreshListener struct { + client *NamingServerClient +} + +func (l *RefreshListener) OnEvent(vGroup string) error { + return l.client.RefreshGroup(vGroup) +} + +func (c *NamingServerClient) Subscribe(vGroup string, listener NamingListener) error { + c.mu.Lock() + defer c.mu.Unlock() + + val, _ := c.listenerServiceMap.LoadOrStore(vGroup, []NamingListener{}) + listeners := append(val.([]NamingListener), listener) + c.listenerServiceMap.Store(vGroup, listeners) + + if !c.isSubscribed { + c.isSubscribed = true + c.wg.Add(1) + go c.watchLoop(vGroup) + } + return nil +} + +func (c *NamingServerClient) watchLoop(vGroup string) { + defer c.wg.Done() + var retryCount int + + for { + select { + case <-c.closeChan: + return + default: + } + + changed, err := c.Watch(vGroup) + if err != nil { + retryCount++ + if retryCount > failureRecoveryThreshold { + c.logger.Error("watch failed continuously, will retry", + zap.Error(err), zap.Int("retryCount", retryCount)) + select { + case <-time.After(time.Duration(retryDelayMs) * time.Millisecond): + case <-c.closeChan: + return + } + } else { + c.logger.Warn("watch error, will retry immediately", zap.Error(err)) + } + continue + } + + retryCount = 0 + + if changed { + if err := c.RefreshGroup(vGroup); err != nil { + c.logger.Error("refresh group failed in watch", zap.Error(err)) + continue + } + val, ok := c.listenerServiceMap.Load(vGroup) + if !ok { + continue + } + for _, listener := range val.([]NamingListener) { + if err := listener.OnEvent(vGroup); err != nil { + c.logger.Warn("listener callback failed", zap.Error(err)) + } + } + } + } +} + +func (c *NamingServerClient) Watch(vGroup string) (bool, error) { + namingAddr, err := c.getNamingAddr() + if err != nil { + return false, err + } + + if c.isTokenExpired() { + if err := c.RefreshToken(namingAddr); err != nil { + return false, err + } + } + + clientIP, err := gostnet.GetLocalIP() + if err != nil { + return false, fmt.Errorf("failed to get local IP: %w", err) + } + + params := url.Values{} + params.Add("vGroup", vGroup) + params.Add("clientTerm", strconv.FormatInt(atomic.LoadInt64(&c.term), 10)) + params.Add("timeout", strconv.FormatInt(longPollTimeoutPeriod.Milliseconds(), 10)) + params.Add("clientAddr", clientIP) + + watchURL := fmt.Sprintf("%s%s/naming/v1/watch?%s", httpPrefix, namingAddr, params.Encode()) + req, err := http.NewRequest(http.MethodPost, watchURL, nil) + if err != nil { + return false, err + } + if c.jwtToken != "" { + req.Header.Set(authorizationHeader, c.jwtToken) + } + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.longPollClient.Do(req) + if err != nil { + return false, fmt.Errorf("watch request failed: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusNotModified: + return false, nil + case http.StatusUnauthorized: + return false, fmt.Errorf("watch failed: unauthorized (401), token may have expired or authentication is required") + default: + return false, fmt.Errorf("watch request failed with status code: %d", resp.StatusCode) + } +} + +func (c *NamingServerClient) Close() { + close(c.closeChan) + c.healthCheckTicker.Stop() + c.wg.Wait() + c.logger.Info("naming server client closed") +} + +func (n *NamingServerRegistryService) Register(instance *ServiceInstance) error { + //TODO implement me + panic("implement me") +} + +func (n *NamingServerRegistryService) Deregister(instance *ServiceInstance) error { + //TODO implement me + panic("implement me") Review Comment: Register/Deregister currently panic("implement me"). Since NamingServerRegistryService is returned from InitRegistry when type==NAMINGSERVER, any call path that uses these methods will crash the process. Please implement these methods or return a well-defined error (e.g., "not supported") instead of panicking. ```suggestion return errors.New("register is not supported by NamingServerRegistryService") } func (n *NamingServerRegistryService) Deregister(instance *ServiceInstance) error { return errors.New("deregister is not supported by NamingServerRegistryService") ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
