package discovery import ( "strings" "sync" "time" ) const ( DefaultHealthIntervalSec = 10 DefaultHealthTimeoutSec = 30 ) type RegistryEntry struct { ID string `json:"id"` Service string `json:"service"` Rail string `json:"rail,omitempty"` Network string `json:"network,omitempty"` Operations []string `json:"operations,omitempty"` Currencies []string `json:"currencies,omitempty"` Limits *Limits `json:"limits,omitempty"` InvokeURI string `json:"invokeURI,omitempty"` RoutingPriority int `json:"routingPriority,omitempty"` Version string `json:"version,omitempty"` Health HealthParams `json:"health,omitempty"` LastHeartbeat time.Time `json:"lastHeartbeat,omitempty"` Status string `json:"status,omitempty"` Healthy bool `json:"healthy,omitempty"` } type Registry struct { mu sync.RWMutex entries map[string]*RegistryEntry } type UpdateResult struct { Entry RegistryEntry IsNew bool WasHealthy bool BecameHealthy bool } func NewRegistry() *Registry { return &Registry{ entries: map[string]*RegistryEntry{}, } } func (r *Registry) UpsertFromAnnouncement(announce Announcement, now time.Time) UpdateResult { entry := registryEntryFromAnnouncement(normalizeAnnouncement(announce), now) r.mu.Lock() defer r.mu.Unlock() existing, ok := r.entries[entry.ID] wasHealthy := false if ok && existing != nil { wasHealthy = existing.isHealthyAt(now) } entry.Healthy = entry.isHealthyAt(now) r.entries[entry.ID] = &entry return UpdateResult{ Entry: entry, IsNew: !ok, WasHealthy: wasHealthy, BecameHealthy: !wasHealthy && entry.Healthy, } } func (r *Registry) UpdateHeartbeat(id string, status string, ts time.Time, now time.Time) (UpdateResult, bool) { id = strings.TrimSpace(id) if id == "" { return UpdateResult{}, false } if status == "" { status = "ok" } if ts.IsZero() { ts = now } r.mu.Lock() defer r.mu.Unlock() entry, ok := r.entries[id] if !ok || entry == nil { return UpdateResult{}, false } wasHealthy := entry.isHealthyAt(now) entry.Status = status entry.LastHeartbeat = ts entry.Healthy = entry.isHealthyAt(now) return UpdateResult{ Entry: *entry, IsNew: false, WasHealthy: wasHealthy, BecameHealthy: !wasHealthy && entry.Healthy, }, true } func (r *Registry) List(now time.Time, onlyHealthy bool) []RegistryEntry { r.mu.Lock() defer r.mu.Unlock() result := make([]RegistryEntry, 0, len(r.entries)) for _, entry := range r.entries { if entry == nil { continue } entry.Healthy = entry.isHealthyAt(now) if onlyHealthy && !entry.Healthy { continue } cp := *entry result = append(result, cp) } return result } func registryEntryFromAnnouncement(announce Announcement, now time.Time) RegistryEntry { status := "ok" return RegistryEntry{ ID: strings.TrimSpace(announce.ID), Service: strings.TrimSpace(announce.Service), Rail: strings.ToUpper(strings.TrimSpace(announce.Rail)), Network: strings.ToUpper(strings.TrimSpace(announce.Network)), Operations: cloneStrings(announce.Operations), Currencies: cloneStrings(announce.Currencies), Limits: cloneLimits(announce.Limits), InvokeURI: strings.TrimSpace(announce.InvokeURI), RoutingPriority: announce.RoutingPriority, Version: strings.TrimSpace(announce.Version), Health: normalizeHealth(announce.Health), LastHeartbeat: now, Status: status, } } func normalizeAnnouncement(announce Announcement) Announcement { announce.ID = strings.TrimSpace(announce.ID) announce.Service = strings.TrimSpace(announce.Service) announce.Rail = strings.ToUpper(strings.TrimSpace(announce.Rail)) announce.Network = strings.ToUpper(strings.TrimSpace(announce.Network)) announce.Operations = normalizeStrings(announce.Operations, false) announce.Currencies = normalizeStrings(announce.Currencies, true) announce.InvokeURI = strings.TrimSpace(announce.InvokeURI) announce.Version = strings.TrimSpace(announce.Version) announce.Health = normalizeHealth(announce.Health) if announce.Limits != nil { announce.Limits = normalizeLimits(*announce.Limits) } return announce } func normalizeHealth(h HealthParams) HealthParams { if h.IntervalSec <= 0 { h.IntervalSec = DefaultHealthIntervalSec } if h.TimeoutSec <= 0 { h.TimeoutSec = DefaultHealthTimeoutSec } if h.TimeoutSec < h.IntervalSec { h.TimeoutSec = h.IntervalSec * 2 } return h } func normalizeLimits(l Limits) *Limits { res := l if len(res.VolumeLimit) == 0 { res.VolumeLimit = nil } if len(res.VelocityLimit) == 0 { res.VelocityLimit = nil } return &res } func cloneLimits(src *Limits) *Limits { if src == nil { return nil } dst := *src if src.VolumeLimit != nil { dst.VolumeLimit = map[string]string{} for key, value := range src.VolumeLimit { if strings.TrimSpace(key) == "" { continue } dst.VolumeLimit[strings.TrimSpace(key)] = strings.TrimSpace(value) } } if src.VelocityLimit != nil { dst.VelocityLimit = map[string]int{} for key, value := range src.VelocityLimit { if strings.TrimSpace(key) == "" { continue } dst.VelocityLimit[strings.TrimSpace(key)] = value } } return &dst } func normalizeStrings(values []string, upper bool) []string { if len(values) == 0 { return nil } seen := map[string]bool{} result := make([]string, 0, len(values)) for _, value := range values { clean := strings.TrimSpace(value) if clean == "" { continue } if upper { clean = strings.ToUpper(clean) } if seen[clean] { continue } seen[clean] = true result = append(result, clean) } if len(result) == 0 { return nil } return result } func cloneStrings(values []string) []string { if len(values) == 0 { return nil } out := make([]string, len(values)) copy(out, values) return out } func (e *RegistryEntry) isHealthyAt(now time.Time) bool { if e == nil { return false } status := strings.ToLower(strings.TrimSpace(e.Status)) if status != "" && status != "ok" { return false } if e.LastHeartbeat.IsZero() { return false } timeout := time.Duration(e.Health.TimeoutSec) * time.Second if timeout <= 0 { timeout = time.Duration(DefaultHealthTimeoutSec) * time.Second } return now.Sub(e.LastHeartbeat) <= timeout }