// Licensed to the LF AI & Data foundation under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package telemetry import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "sort" "strings" "sync" "sync/atomic" "time" clientv3 "go.etcd.io/etcd/client/v3" "google.golang.org/protobuf/proto" "github.com/milvus-io/milvus-proto/go-api/v3/commonpb" "github.com/milvus-io/milvus-proto/go-api/v3/milvuspb" "github.com/milvus-io/milvus/pkg/v3/mlog" "github.com/milvus-io/milvus/pkg/v3/util/merr" ) // TelemetryConfig holds configurable time values for the telemetry manager type TelemetryConfig struct { // CleanupInterval is how often the cleanup loop runs (default: 5 minutes) CleanupInterval time.Duration // InactiveClientThreshold is how long since last heartbeat before a client is removed (default: 10 minutes) InactiveClientThreshold time.Duration // ClientStatusThreshold is how long since last heartbeat before a client is marked inactive (default: 1 minute) ClientStatusThreshold time.Duration // CommandCleanupTimeout is the context timeout for command cleanup operations (default: 10 seconds) CommandCleanupTimeout time.Duration // MaxMetricsPerClient is the maximum size of metrics payload per client (default: 1MB) MaxMetricsPerClient int // MaxOperationTypesPerClient is the maximum number of operation types per client (default: 100) MaxOperationTypesPerClient int // MaxClientsInMemory is the maximum number of clients to track in memory (default: 100,000) // This prevents unbounded memory growth from malicious or misconfigured clients MaxClientsInMemory int } // DefaultTelemetryConfig returns the default configuration func DefaultTelemetryConfig() *TelemetryConfig { return &TelemetryConfig{ CleanupInterval: 1 * time.Minute, InactiveClientThreshold: 10 * time.Minute, ClientStatusThreshold: 1 * time.Minute, CommandCleanupTimeout: 10 * time.Second, MaxMetricsPerClient: 1 * 1024 * 1024, // 1MB max per client MaxOperationTypesPerClient: 100, // Maximum 100 operation types MaxClientsInMemory: 100000, // Maximum 100k clients in memory } } // StoredCommandReply stores a command reply with metadata // NOTE: Payload and CommandPayload are stored as strings (not []byte) to ensure // proper JSON serialization without base64 encoding, making the API response // directly parseable by JavaScript clients. type StoredCommandReply struct { CommandID string `json:"command_id"` CommandType string `json:"command_type,omitempty"` CommandPayload string `json:"command_payload,omitempty"` Success bool `json:"success"` ErrorMsg string `json:"error_msg,omitempty"` Payload string `json:"payload,omitempty"` ReceivedAt int64 `json:"received_at"` } // ClientMetricsCache stores the latest metrics from a client type ClientMetricsCache struct { ClientInfo *commonpb.ClientInfo ClientID string LastHeartbeat atomic.Int64 // Unix nanoseconds for atomic access LatestMetrics []*commonpb.OperationMetrics AccessedDatabases sync.Map // map[string]struct{} for concurrent access ConfigHash string LastCommandTS int64 CommandReplies []*StoredCommandReply // Last N command replies from this client } // TelemetryManager manages client telemetry data type TelemetryManager struct { clientMetrics sync.Map // key: client_id -> *ClientMetricsCache commandStore CommandStoreInterface commandRouter *CommandRouter config *TelemetryConfig // Background cleanup stopCh chan struct{} wg sync.WaitGroup } // NewTelemetryManager creates a new TelemetryManager with default config func NewTelemetryManager(etcdClient *clientv3.Client) *TelemetryManager { return NewTelemetryManagerWithConfig(etcdClient, DefaultTelemetryConfig()) } // NewTelemetryManagerWithConfig creates a new TelemetryManager with custom config func NewTelemetryManagerWithConfig(etcdClient *clientv3.Client, config *TelemetryConfig) *TelemetryManager { var store CommandStoreInterface if etcdClient != nil { store = NewCommandStore(etcdClient, "/client-telemetry/") } if config == nil { config = DefaultTelemetryConfig() } tm := &TelemetryManager{ commandStore: store, config: config, stopCh: make(chan struct{}), } // Initialize command router with default handlers tm.commandRouter = NewCommandRouter() tm.initializeCommandHandlers() return tm } // SetCommandStore sets the command store (for testing) func (m *TelemetryManager) SetCommandStore(store CommandStoreInterface) { m.commandStore = store } // SetConfig updates the telemetry configuration func (m *TelemetryManager) SetConfig(config *TelemetryConfig) { m.config = config } // Start launches the background cleanup goroutine func (m *TelemetryManager) Start() { m.wg.Add(1) go m.cleanupLoop() } // Stop stops the background cleanup goroutine func (m *TelemetryManager) Stop() { close(m.stopCh) m.wg.Wait() } // cleanupLoop periodically cleans up inactive clients func (m *TelemetryManager) cleanupLoop() { defer m.wg.Done() ticker := time.NewTicker(m.config.CleanupInterval) defer ticker.Stop() for { select { case <-m.stopCh: return case <-ticker.C: // Create context for background cleanup operations ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) m.cleanupInactiveClients(ctx) m.cleanupExpiredCommands(ctx) cancel() } } } // cleanupInactiveClients removes clients that haven't sent a heartbeat within threshold // Also enforces memory limits using LRU eviction when limit is reached func (m *TelemetryManager) cleanupInactiveClients(ctx context.Context) { now := time.Now() // First pass: remove clients that exceed the inactive threshold cleaned := 0 m.clientMetrics.Range(func(key, value any) bool { clientID := key.(string) cache := value.(*ClientMetricsCache) lastHeartbeat := time.Unix(0, cache.LastHeartbeat.Load()) inactiveDuration := now.Sub(lastHeartbeat) if inactiveDuration > m.config.InactiveClientThreshold { m.clientMetrics.Delete(clientID) cleaned++ mlog.Debug(ctx, "cleanupInactiveClients: removed inactive client", mlog.String("client_id", clientID), mlog.Duration("inactive_duration", inactiveDuration), mlog.Duration("threshold", m.config.InactiveClientThreshold)) } return true }) if cleaned > 0 { mlog.Debug(ctx, "cleanupInactiveClients: normal cleanup completed", mlog.Int("cleaned_count", cleaned)) } // Second pass: enforce LRU eviction if still over limit m.evictLRUIfNeeded(ctx) } // evictLRUIfNeeded removes the least recently used clients if the count exceeds MaxClientsInMemory func (m *TelemetryManager) evictLRUIfNeeded(ctx context.Context) { // Count current clients clientCount := 0 m.clientMetrics.Range(func(key, value any) bool { clientCount++ return true }) if clientCount <= m.config.MaxClientsInMemory { return } // Collect all clients with their last heartbeat time for LRU sorting type clientEntry struct { clientID string lastHeartbeat time.Time } entries := make([]clientEntry, 0, clientCount) m.clientMetrics.Range(func(key, value any) bool { clientID := key.(string) cache := value.(*ClientMetricsCache) entries = append(entries, clientEntry{ clientID: clientID, lastHeartbeat: time.Unix(0, cache.LastHeartbeat.Load()), }) return true }) // Sort by last heartbeat (oldest first) sort.Slice(entries, func(i, j int) bool { return entries[i].lastHeartbeat.Before(entries[j].lastHeartbeat) }) // Evict oldest clients until we're under the limit toEvict := len(entries) - m.config.MaxClientsInMemory if toEvict > 0 { mlog.Warn(ctx, "telemetry client count exceeds limit, LRU eviction required", mlog.Int("current_count", len(entries)), mlog.Int("max_allowed", m.config.MaxClientsInMemory), mlog.Int("to_evict", toEvict)) for i := 0; i < toEvict && i < len(entries); i++ { m.clientMetrics.Delete(entries[i].clientID) mlog.Debug(ctx, "cleanupInactiveClients: LRU evicted client", mlog.String("client_id", entries[i].clientID), mlog.Time("last_heartbeat", entries[i].lastHeartbeat)) } mlog.Info(ctx, "cleanupInactiveClients: LRU eviction completed", mlog.Int("evicted_count", toEvict), mlog.Int("max_allowed", m.config.MaxClientsInMemory)) } } // cleanupExpiredCommands removes expired commands from etcd func (m *TelemetryManager) cleanupExpiredCommands(ctx context.Context) { if m.commandStore == nil { mlog.Debug(ctx, "cleanupExpiredCommands: command store not initialized") return } // Create a timeout context for command cleanup cleanupCtx, cancel := context.WithTimeout(ctx, m.config.CommandCleanupTimeout) defer cancel() m.commandStore.CleanupExpiredCommands(cleanupCtx) } // validateAndTruncateMetrics validates metrics size and truncates if needed to prevent DoS // Only keeps collections with requests to reduce data size func (m *TelemetryManager) validateAndTruncateMetrics(metrics []*commonpb.OperationMetrics) []*commonpb.OperationMetrics { if len(metrics) == 0 { return metrics } // Limit number of operation types if len(metrics) > m.config.MaxOperationTypesPerClient { metrics = metrics[:m.config.MaxOperationTypesPerClient] } // Filter out collections with zero requests to compress data for _, opMetrics := range metrics { if len(opMetrics.CollectionMetrics) > 0 { // Keep only collections that have requests newMap := make(map[string]*commonpb.Metrics) for name, m := range opMetrics.CollectionMetrics { if m != nil && m.RequestCount > 0 { newMap[name] = m } } opMetrics.CollectionMetrics = newMap } } // Enforce max payload size (best-effort based on proto size). // First drop collection-level metrics, then truncate operations if still too large. if m.config.MaxMetricsPerClient > 0 { size := m.estimateMetricsSize(metrics) if size > m.config.MaxMetricsPerClient { for _, opMetrics := range metrics { if opMetrics != nil { opMetrics.CollectionMetrics = nil } } size = m.estimateMetricsSize(metrics) if size > m.config.MaxMetricsPerClient { var truncated []*commonpb.OperationMetrics total := 0 for _, opMetrics := range metrics { if opMetrics == nil { continue } s := proto.Size(opMetrics) if total+s > m.config.MaxMetricsPerClient { break } truncated = append(truncated, opMetrics) total += s } metrics = truncated } } } return metrics } func (m *TelemetryManager) estimateMetricsSize(metrics []*commonpb.OperationMetrics) int { total := 0 for _, opMetrics := range metrics { if opMetrics == nil { continue } total += proto.Size(opMetrics) } return total } // HandleHeartbeat processes a client heartbeat and returns commands // This method uses a two-phase approach for scalability: // 1. Fast path: Update client metrics cache in memory using sync.Map // 2. Slow path: Fetch commands from in-memory cache (not etcd) // This design prevents lock contention and etcd query amplification for 10,000+ clients func (m *TelemetryManager) HandleHeartbeat(req *milvuspb.ClientHeartbeatRequest) (*milvuspb.ClientHeartbeatResponse, error) { // Phase 1: Fast cache update using sync.Map (lock-free for reads) // IMPORTANT: Generate clientID once and reuse to avoid inconsistency clientID := m.getOrCreateClientID(req.ClientInfo) // Load or create client cache var cache *ClientMetricsCache if existing, loaded := m.clientMetrics.Load(clientID); loaded { cache = existing.(*ClientMetricsCache) } else { cache = &ClientMetricsCache{ ClientID: clientID, } // Use LoadOrStore to handle race condition if actual, loaded := m.clientMetrics.LoadOrStore(clientID, cache); loaded { cache = actual.(*ClientMetricsCache) } } // Update cache fields (these updates are safe as each client has its own cache) cache.ClientInfo = req.ClientInfo cache.LastHeartbeat.Store(time.Now().UnixNano()) cache.LatestMetrics = m.validateAndTruncateMetrics(req.Metrics) // Validate and truncate metrics cache.ConfigHash = req.ConfigHash cache.LastCommandTS = req.LastCommandTimestamp if dbName := m.getDatabaseFromClientInfo(req.ClientInfo); dbName != "" { cache.AccessedDatabases.Store(dbName, struct{}{}) } // Process command replies from client (client's acknowledgment of commands it received) // This is used to track which commands have been successfully processed by the client var repliedIDs []string if len(req.CommandReplies) > 0 { repliedIDs = m.processCommandReplies(cache, req.CommandReplies) } if len(repliedIDs) > 0 { m.cleanupRepliedCommands(repliedIDs) } // Phase 2: Fetch commands WITHOUT holding the lock (uses in-memory cache, not etcd) // This prevents serialization of heartbeats and allows parallel processing // IMPORTANT: Pass clientID to avoid regenerating it commands := m.getCommandsForClientWithID(clientID, req) return &milvuspb.ClientHeartbeatResponse{ Status: &commonpb.Status{}, ServerTimestamp: time.Now().UnixMilli(), Commands: commands, }, nil } // processCommandReplies processes acknowledgments from client about executed commands // This tracks which commands were successfully executed by clients for monitoring and retry func (m *TelemetryManager) processCommandReplies(cache *ClientMetricsCache, replies []*commonpb.CommandReply) []string { if len(replies) == 0 || cache == nil { return nil } now := time.Now().UnixMilli() const maxStoredReplies = 50 // Keep last 50 replies per client deletedIDs := make([]string, 0, len(replies)) for _, reply := range replies { if reply == nil { continue } cmdType, cmdPayload := m.lookupCommandInfo(reply.CommandId) stored := &StoredCommandReply{ CommandID: reply.CommandId, CommandType: cmdType, CommandPayload: string(cmdPayload), // Convert []byte to string for JSON serialization Success: reply.Success, ErrorMsg: reply.ErrorMessage, Payload: string(reply.Payload), // Convert []byte to string for JSON serialization ReceivedAt: now, } cache.CommandReplies = append(cache.CommandReplies, stored) if !reply.Success { mlog.Warn(context.TODO(), "processCommandReplies: command execution failed", mlog.String("client_id", cache.ClientID), mlog.String("command_id", reply.CommandId), mlog.String("command_type", cmdType), mlog.String("error", reply.ErrorMessage)) } if reply.CommandId != "" { deletedIDs = append(deletedIDs, reply.CommandId) } } // Keep only the most recent replies if len(cache.CommandReplies) > maxStoredReplies { cache.CommandReplies = cache.CommandReplies[len(cache.CommandReplies)-maxStoredReplies:] } return deletedIDs } func (m *TelemetryManager) lookupCommandInfo(commandID string) (string, []byte) { if m.commandStore == nil || commandID == "" { return "", nil } cmdType, payload, _, ok := m.commandStore.GetCommandInfo(commandID) if !ok { return "", nil } return cmdType, payload } func (m *TelemetryManager) cleanupRepliedCommands(commandIDs []string) { if m.commandStore == nil { return } for _, id := range commandIDs { if id == "" { continue } m.commandStore.DeleteNonPersistentCommand(id) } } func (m *TelemetryManager) getOrCreateClientID(info *commonpb.ClientInfo) string { // Use reserved["client_id"] if exists - this must be a stable UUID from the client if info != nil && info.Reserved != nil { if id, ok := info.Reserved["client_id"]; ok && id != "" { return id } } // Fallback: generate a stable legacy ID from client attributes. // This avoids unbounded growth when old clients don't supply client_id. host := "unknown" sdkType := "" sdkVersion := "" user := "" if info != nil { if info.Host != "" { host = info.Host } sdkType = info.SdkType sdkVersion = info.SdkVersion user = info.User } seed := fmt.Sprintf("%s|%s|%s|%s", sdkType, sdkVersion, host, user) sum := sha256.Sum256([]byte(seed)) return fmt.Sprintf("legacy:%s:%s", host, hex.EncodeToString(sum[:8])) } func (m *TelemetryManager) getDatabaseFromClientInfo(info *commonpb.ClientInfo) string { if info == nil || info.Reserved == nil { return "" } if db := strings.TrimSpace(info.Reserved["db_name"]); db != "" { return db } if db := strings.TrimSpace(info.Reserved["database"]); db != "" { return db } return "" } // getCommandsForClientWithID fetches commands for a specific client using the provided clientID // This avoids regenerating clientID which could cause inconsistency // CommandStore handles all caching internally with TTL, so we just call it directly func (m *TelemetryManager) getCommandsForClientWithID(clientID string, req *milvuspb.ClientHeartbeatRequest) []*commonpb.ClientCommand { if m.commandStore == nil { return nil } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Fetch commands from CommandStore (handles caching internally) commands, err := m.commandStore.ListCommands(ctx) if err != nil { mlog.Warn(ctx, "getCommandsForClientWithID: failed to fetch commands from CommandStore", mlog.Err(err)) return nil } // Fetch configs from CommandStore (handles caching internally) configs, _, err := m.commandStore.ListConfigs(ctx) if err != nil { mlog.Warn(ctx, "getCommandsForClientWithID: failed to fetch configs from CommandStore", mlog.Err(err)) return nil } // Filter and build result from fetched data var result []*commonpb.ClientCommand // Filter one-time commands by scope and timestamp for _, cmd := range commands { if !m.matchesScope(cmd.TargetScope, clientID, req.ClientInfo) { continue } // One-time commands: return if newer than last command timestamp if cmd.CreateTime > req.LastCommandTimestamp { result = append(result, cmd) } } // Filter persistent configs by scope first, then compute hash over filtered configs // This ensures the hash matches what the client will compute over the configs it receives var filteredConfigs []*ClientConfig for _, cfg := range configs { if m.matchesScope(cfg.TargetScope, clientID, req.ClientInfo) { filteredConfigs = append(filteredConfigs, cfg) } } // Compute hash only over configs this client will receive filteredConfigHash := computeClientConfigHash(filteredConfigs) // Only send configs if client's hash differs from filtered hash if req.ConfigHash != filteredConfigHash { for _, cfg := range filteredConfigs { // Convert config to command format for response result = append(result, &commonpb.ClientCommand{ CommandId: cfg.ConfigId, CommandType: cfg.ConfigType, Payload: cfg.Payload, CreateTime: cfg.CreateTime, TargetScope: cfg.TargetScope, Persistent: true, // Mark as persistent so client knows to track it }) } } return result } func (m *TelemetryManager) matchesScope(scope, clientID string, info *commonpb.ClientInfo) bool { if scope == "global" { return true } if strings.HasPrefix(scope, "client:") { return scope == fmt.Sprintf("client:%s", clientID) } if strings.HasPrefix(scope, "database:") { targetDB := strings.TrimPrefix(scope, "database:") // Check if client has accessed this database if existing, loaded := m.clientMetrics.Load(clientID); loaded { cache := existing.(*ClientMetricsCache) _, hasAccess := cache.AccessedDatabases.Load(targetDB) return hasAccess } return false } return false } // computeClientConfigHash computes hash over configs that a specific client will receive // This ensures hash comparison works correctly when configs have different scopes func computeClientConfigHash(configs []*ClientConfig) string { if len(configs) == 0 { return "" } // Sort by config ID for consistent hash sorted := make([]*ClientConfig, len(configs)) copy(sorted, configs) sort.Slice(sorted, func(i, j int) bool { return sorted[i].ConfigId < sorted[j].ConfigId }) h := sha256.New() for _, cfg := range sorted { h.Write([]byte(cfg.ConfigId)) h.Write([]byte(cfg.ConfigType)) h.Write(cfg.Payload) } return hex.EncodeToString(h.Sum(nil))[:16] } // ListClients returns all clients, optionally filtered by database func (m *TelemetryManager) ListClients(database string) []*ClientMetricsCache { var result []*ClientMetricsCache m.clientMetrics.Range(func(key, value any) bool { cache := value.(*ClientMetricsCache) if database == "" { result = append(result, cache) } else if _, ok := cache.AccessedDatabases.Load(database); ok { result = append(result, cache) } return true }) return result } // GetClientTelemetry returns telemetry data for clients func (m *TelemetryManager) GetClientTelemetry(req *milvuspb.GetClientTelemetryRequest) (*milvuspb.GetClientTelemetryResponse, error) { var clients []*milvuspb.ClientTelemetry aggregated := &commonpb.Metrics{} m.clientMetrics.Range(func(key, value any) bool { clientID := key.(string) cache := value.(*ClientMetricsCache) // Filter by client_id if specified if req.ClientId != "" && clientID != req.ClientId { return true } // Filter by database if specified if req.Database != "" { if _, ok := cache.AccessedDatabases.Load(req.Database); !ok { return true } } ct := &milvuspb.ClientTelemetry{ ClientInfo: cloneClientInfo(cache.ClientInfo), LastHeartbeatTime: cache.LastHeartbeat.Load() / int64(time.Millisecond), Status: m.getClientStatus(cache), Databases: m.getDatabaseList(cache), } // Ensure client_id is always set in Reserved (for legacy clients that don't have it) if ct.ClientInfo == nil { ct.ClientInfo = &commonpb.ClientInfo{} } if ct.ClientInfo.Reserved == nil { ct.ClientInfo.Reserved = make(map[string]string) } if ct.ClientInfo.Reserved["client_id"] == "" { ct.ClientInfo.Reserved["client_id"] = clientID } if req.IncludeMetrics { ct.Metrics = cloneOperationMetrics(cache.LatestMetrics) } // Add command replies to ClientInfo.Reserved if there are any if len(cache.CommandReplies) > 0 { if ct.ClientInfo == nil { ct.ClientInfo = &commonpb.ClientInfo{} } if ct.ClientInfo.Reserved == nil { ct.ClientInfo.Reserved = make(map[string]string) } // JSON encode command replies and store in Reserved if repliesJSON, err := json.Marshal(cache.CommandReplies); err == nil { ct.ClientInfo.Reserved["command_replies"] = string(repliesJSON) } } clients = append(clients, ct) // Aggregate metrics for _, opMetrics := range cache.LatestMetrics { if opMetrics.Global != nil { aggregated.RequestCount += opMetrics.Global.RequestCount aggregated.SuccessCount += opMetrics.Global.SuccessCount aggregated.ErrorCount += opMetrics.Global.ErrorCount } } return true }) return &milvuspb.GetClientTelemetryResponse{ Status: &commonpb.Status{}, Clients: clients, Aggregated: aggregated, }, nil } func cloneClientInfo(info *commonpb.ClientInfo) *commonpb.ClientInfo { if info == nil { return nil } clone := proto.Clone(info) if ci, ok := clone.(*commonpb.ClientInfo); ok { return ci } return info } func cloneOperationMetrics(metrics []*commonpb.OperationMetrics) []*commonpb.OperationMetrics { if len(metrics) == 0 { return nil } result := make([]*commonpb.OperationMetrics, 0, len(metrics)) for _, m := range metrics { if m == nil { result = append(result, nil) continue } clone := proto.Clone(m) if om, ok := clone.(*commonpb.OperationMetrics); ok { result = append(result, om) } else { result = append(result, m) } } return result } func (m *TelemetryManager) getClientStatus(cache *ClientMetricsCache) string { lastHeartbeat := time.Unix(0, cache.LastHeartbeat.Load()) if time.Since(lastHeartbeat) > m.config.ClientStatusThreshold { return "inactive" } return "active" } func (m *TelemetryManager) getDatabaseList(cache *ClientMetricsCache) []string { var dbs []string cache.AccessedDatabases.Range(func(key, value any) bool { dbs = append(dbs, key.(string)) return true }) return dbs } // PushCommand stores a command to be sent to clients func (m *TelemetryManager) PushCommand(ctx context.Context, req *milvuspb.PushClientCommandRequest) (*milvuspb.PushClientCommandResponse, error) { if m.commandStore == nil { // Non-retriable: service not ready err := merr.WrapErrServiceNotReady("telemetry", 0, "command_store_not_initialized", "command store not initialized") mlog.Warn(ctx, "PushCommand: command store not initialized", mlog.Err(err)) return nil, err } cmdID, err := m.commandStore.PushCommand(ctx, req) if err != nil { // Errors from commandStore are already wrapped with merr mlog.Warn(ctx, "PushCommand: failed to push command", mlog.Err(err), mlog.String("command_type", req.CommandType), mlog.Bool("persistent", req.Persistent)) return nil, err } mlog.Debug(ctx, "PushCommand: command pushed successfully", mlog.String("command_id", cmdID), mlog.String("command_type", req.CommandType), mlog.Bool("persistent", req.Persistent)) return &milvuspb.PushClientCommandResponse{ Status: &commonpb.Status{}, CommandId: cmdID, }, nil } // DeleteCommand removes a command func (m *TelemetryManager) DeleteCommand(ctx context.Context, req *milvuspb.DeleteClientCommandRequest) (*milvuspb.DeleteClientCommandResponse, error) { if m.commandStore == nil { // Non-retriable: service not ready err := merr.WrapErrServiceNotReady("telemetry", 0, "command_store_not_initialized", "command store not initialized") mlog.Warn(ctx, "DeleteCommand: command store not initialized", mlog.Err(err)) return nil, err } err := m.commandStore.DeleteCommand(ctx, req.CommandId) if err != nil { // Errors from commandStore are already wrapped with merr mlog.Warn(ctx, "DeleteCommand: failed to delete command", mlog.Err(err), mlog.String("command_id", req.CommandId)) return nil, err } mlog.Debug(ctx, "DeleteCommand: command deleted successfully", mlog.String("command_id", req.CommandId)) return &milvuspb.DeleteClientCommandResponse{ Status: &commonpb.Status{}, }, nil } // initializeCommandHandlers sets up default command handlers for all command types func (m *TelemetryManager) initializeCommandHandlers() { // Show errors handler - display last 100 error messages m.commandRouter.RegisterHandler(CommandTypeShowErrors, NewShowErrorsHandler(nil)) // Collection metrics handler - enable fine-grained collection-level metrics m.commandRouter.RegisterHandler(CommandTypeCollectionMetrics, NewCollectionMetricsHandler()) // Push config handler - push persistent configuration to clients m.commandRouter.RegisterHandler(CommandTypePushConfig, NewPushConfigHandler()) } // SetErrorCollector sets the error collector for the show_errors command handler func (m *TelemetryManager) SetErrorCollector(collector ErrorCollector) { m.commandRouter.RegisterHandler(CommandTypeShowErrors, NewShowErrorsHandler(collector)) } // CommandInfo represents command information for API responses type CommandInfo struct { CommandID string `json:"command_id"` CommandType string `json:"command_type"` TargetScope string `json:"target_scope"` Persistent bool `json:"persistent"` CreateTime int64 `json:"create_time"` TTLSeconds int64 `json:"ttl_seconds,omitempty"` } // GetClientCommandReplies returns the stored command replies for a specific client func (m *TelemetryManager) GetClientCommandReplies(clientID string) []*StoredCommandReply { existing, loaded := m.clientMetrics.Load(clientID) if !loaded { return nil } cache := existing.(*ClientMetricsCache) if len(cache.CommandReplies) == 0 { return nil } // Return a copy to avoid external modification result := make([]*StoredCommandReply, len(cache.CommandReplies)) for i, reply := range cache.CommandReplies { copied := *reply result[i] = &copied } return result } // ListAllCommands returns all active commands (both one-time commands and persistent configs) func (m *TelemetryManager) ListAllCommands(ctx context.Context) ([]*CommandInfo, error) { if m.commandStore == nil { return nil, nil } // Use ListCommandsWithInfo to get all commands including TTLSeconds cmdInfos, err := m.commandStore.ListCommandsWithInfo(ctx) if err != nil { mlog.Warn(ctx, "ListAllCommands: failed to list commands", mlog.Err(err)) return nil, err } var result []*CommandInfo for _, info := range cmdInfos { result = append(result, &CommandInfo{ CommandID: info.CommandID, CommandType: info.CommandType, TargetScope: info.TargetScope, Persistent: info.Persistent, CreateTime: info.CreateTime, TTLSeconds: info.TTLSeconds, }) } return result, nil }