package discovery import ( "fmt" "strings" "sync" "time" ) const ( DefaultHealthIntervalSec = 10 DefaultHealthTimeoutSec = 30 ) type RegistryEntry struct { ID string `json:"id"` InstanceID string `bson:"instanceId" json:"instanceId"` Service string `json:"service"` Rail string `json:"rail,omitempty"` Network string `json:"network,omitempty"` Operations []string `json:"operations,omitempty"` Currencies []string `json:"currencies,omitempty"` CurrencyMeta []CurrencyAnnouncement `json:"currencyMeta,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 byID map[string]map[string]struct{} byInstance map[string]map[string]struct{} } type UpdateResult struct { Entry RegistryEntry IsNew bool WasHealthy bool BecameHealthy bool } func NewRegistry() *Registry { return &Registry{ entries: map[string]*RegistryEntry{}, byID: map[string]map[string]struct{}{}, byInstance: map[string]map[string]struct{}{}, } } func (r *Registry) UpsertFromAnnouncement(announce Announcement, now time.Time) UpdateResult { entry := registryEntryFromAnnouncement(normalizeAnnouncement(announce), now) key := registryEntryKey(entry) if key == "" { return UpdateResult{Entry: entry} } r.mu.Lock() defer r.mu.Unlock() existing, ok := r.entries[key] wasHealthy := false if ok && existing != nil { wasHealthy = existing.isHealthyAt(now) r.unindexEntry(key, existing) } entry.Healthy = entry.isHealthyAt(now) r.entries[key] = &entry r.indexEntry(key, &entry) return UpdateResult{ Entry: entry, IsNew: !ok, WasHealthy: wasHealthy, BecameHealthy: !wasHealthy && entry.Healthy, } } func (r *Registry) UpsertEntry(entry RegistryEntry, now time.Time) UpdateResult { entry = normalizeEntry(entry) key := registryEntryKey(entry) if key == "" { return UpdateResult{Entry: entry} } if entry.LastHeartbeat.IsZero() { entry.LastHeartbeat = now } if strings.TrimSpace(entry.Status) == "" { entry.Status = "ok" } r.mu.Lock() defer r.mu.Unlock() existing, ok := r.entries[key] wasHealthy := false if ok && existing != nil { wasHealthy = existing.isHealthyAt(now) r.unindexEntry(key, existing) } entry.Healthy = entry.isHealthyAt(now) r.entries[key] = &entry r.indexEntry(key, &entry) return UpdateResult{ Entry: entry, IsNew: !ok, WasHealthy: wasHealthy, BecameHealthy: !wasHealthy && entry.Healthy, } } func (r *Registry) UpdateHeartbeat(id string, instanceID string, status string, ts time.Time, now time.Time) []UpdateResult { id = strings.TrimSpace(id) instanceID = strings.TrimSpace(instanceID) if id == "" && instanceID == "" { return nil } if status == "" { status = "ok" } if ts.IsZero() { ts = now } r.mu.Lock() defer r.mu.Unlock() keys := keysFromIndex(r.byInstance[instanceID]) if len(keys) == 0 && id != "" { keys = keysFromIndex(r.byID[id]) } if len(keys) == 0 { return nil } results := make([]UpdateResult, 0, len(keys)) for _, key := range keys { entry := r.entries[key] if entry == nil { continue } if id != "" && entry.ID != id { continue } if instanceID != "" && entry.InstanceID != instanceID { continue } wasHealthy := entry.isHealthyAt(now) entry.Status = status entry.LastHeartbeat = ts entry.Healthy = entry.isHealthyAt(now) results = append(results, UpdateResult{ Entry: *entry, IsNew: false, WasHealthy: wasHealthy, BecameHealthy: !wasHealthy && entry.Healthy, }) } return results } func (r *Registry) Delete(key string) bool { key = strings.TrimSpace(key) if key == "" { return false } r.mu.Lock() defer r.mu.Unlock() entry, ok := r.entries[key] if !ok { return false } delete(r.entries, key) r.unindexEntry(key, entry) return 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" currencies := cloneCurrencyAnnouncements(announce.Currencies) return RegistryEntry{ ID: strings.TrimSpace(announce.ID), InstanceID: strings.TrimSpace(announce.InstanceID), Service: strings.TrimSpace(announce.Service), Rail: strings.ToUpper(strings.TrimSpace(announce.Rail)), Network: legacyNetworkFromCurrencies(currencies), Operations: cloneStrings(announce.Operations), Currencies: legacyCurrencyCodes(currencies), CurrencyMeta: currencies, Limits: legacyLimitsFromCurrencies(currencies), InvokeURI: strings.TrimSpace(announce.InvokeURI), RoutingPriority: announce.RoutingPriority, Version: strings.TrimSpace(announce.Version), Health: normalizeHealth(announce.Health), LastHeartbeat: now, Status: status, } } func normalizeEntry(entry RegistryEntry) RegistryEntry { entry.ID = strings.TrimSpace(entry.ID) entry.InstanceID = strings.TrimSpace(entry.InstanceID) if entry.InstanceID == "" { entry.InstanceID = entry.ID } entry.Service = strings.TrimSpace(entry.Service) entry.Rail = strings.ToUpper(strings.TrimSpace(entry.Rail)) entry.Network = strings.ToUpper(strings.TrimSpace(entry.Network)) entry.Operations = normalizeStrings(entry.Operations, false) entry.CurrencyMeta = normalizeCurrencyAnnouncements(entry.CurrencyMeta) if len(entry.CurrencyMeta) > 0 { entry.Currencies = legacyCurrencyCodes(entry.CurrencyMeta) if derivedNetwork := legacyNetworkFromCurrencies(entry.CurrencyMeta); derivedNetwork != "" { entry.Network = derivedNetwork } entry.Limits = legacyLimitsFromCurrencies(entry.CurrencyMeta) } else { entry.Currencies = normalizeStrings(entry.Currencies, true) } entry.InvokeURI = strings.TrimSpace(entry.InvokeURI) entry.Version = strings.TrimSpace(entry.Version) entry.Status = strings.TrimSpace(entry.Status) entry.Health = normalizeHealth(entry.Health) if len(entry.CurrencyMeta) == 0 && entry.Limits != nil { entry.Limits = normalizeLimits(*entry.Limits) } return entry } func normalizeAnnouncement(announce Announcement) Announcement { announce.ID = strings.TrimSpace(announce.ID) announce.InstanceID = strings.TrimSpace(announce.InstanceID) if announce.InstanceID == "" { announce.InstanceID = announce.ID } announce.Service = strings.TrimSpace(announce.Service) announce.Rail = strings.ToUpper(strings.TrimSpace(announce.Rail)) announce.Operations = normalizeStrings(announce.Operations, false) announce.Currencies = normalizeCurrencyAnnouncements(announce.Currencies) announce.InvokeURI = strings.TrimSpace(announce.InvokeURI) announce.Version = strings.TrimSpace(announce.Version) announce.Health = normalizeHealth(announce.Health) 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 normalizeCurrencyAnnouncements(values []CurrencyAnnouncement) []CurrencyAnnouncement { if len(values) == 0 { return nil } seen := map[string]bool{} result := make([]CurrencyAnnouncement, 0, len(values)) for _, value := range values { clean, ok := normalizeCurrencyAnnouncement(value) if !ok { continue } key := strings.Join([]string{ clean.Currency, clean.Network, clean.ProviderID, clean.ContractAddress, }, "|") if seen[key] { continue } seen[key] = true result = append(result, clean) } if len(result) == 0 { return nil } return result } func normalizeCurrencyAnnouncement(src CurrencyAnnouncement) (CurrencyAnnouncement, bool) { src.Currency = strings.ToUpper(strings.TrimSpace(src.Currency)) if src.Currency == "" { return CurrencyAnnouncement{}, false } src.Network = strings.ToUpper(strings.TrimSpace(src.Network)) src.ProviderID = strings.TrimSpace(src.ProviderID) src.ContractAddress = strings.ToLower(strings.TrimSpace(src.ContractAddress)) if src.Decimals != nil && *src.Decimals < 0 { src.Decimals = nil } src.Limits = normalizeCurrencyLimits(src.Limits) return src, true } func normalizeCurrencyLimits(src *CurrencyLimits) *CurrencyLimits { if src == nil { return nil } dst := &CurrencyLimits{} if src.Amount != nil { amount := &CurrencyAmount{ Min: strings.TrimSpace(src.Amount.Min), Max: strings.TrimSpace(src.Amount.Max), } if amount.Min != "" || amount.Max != "" { dst.Amount = amount } } if src.Running != nil { running := &CurrencyRunningLimits{} for _, limit := range src.Running.Volume { max := strings.TrimSpace(limit.Max) if max == "" { continue } window := normalizeWindow(limit.Window) if legacyWindowKey(window) == "" { continue } running.Volume = append(running.Volume, VolumeLimit{ Window: window, Max: max, }) } for _, limit := range src.Running.Velocity { if limit.Max <= 0 { continue } window := normalizeWindow(limit.Window) if legacyWindowKey(window) == "" { continue } running.Velocity = append(running.Velocity, VelocityLimit{ Window: window, Max: limit.Max, }) } if len(running.Volume) > 0 || len(running.Velocity) > 0 { dst.Running = running } } if dst.Amount == nil && dst.Running == nil { return nil } return dst } func normalizeWindow(src Window) Window { src.Raw = strings.TrimSpace(src.Raw) src.Duration = strings.TrimSpace(src.Duration) src.Named = strings.TrimSpace(src.Named) if src.Calendar != nil { cal := &CalendarWindow{ Unit: CalendarUnit(strings.ToLower(strings.TrimSpace(string(src.Calendar.Unit)))), Count: src.Calendar.Count, } if cal.Count <= 0 { cal.Count = 1 } if cal.Unit == CalendarUnitUnspecified { cal = nil } src.Calendar = cal } return src } func cloneCurrencyAnnouncements(values []CurrencyAnnouncement) []CurrencyAnnouncement { if len(values) == 0 { return nil } out := make([]CurrencyAnnouncement, 0, len(values)) for _, value := range values { cp := CurrencyAnnouncement{ Currency: value.Currency, Network: value.Network, ProviderID: value.ProviderID, ContractAddress: value.ContractAddress, } if value.Decimals != nil { decimals := *value.Decimals cp.Decimals = &decimals } cp.Limits = cloneCurrencyLimits(value.Limits) out = append(out, cp) } return out } func cloneCurrencyLimits(src *CurrencyLimits) *CurrencyLimits { if src == nil { return nil } dst := &CurrencyLimits{} if src.Amount != nil { dst.Amount = &CurrencyAmount{ Min: src.Amount.Min, Max: src.Amount.Max, } } if src.Running != nil { running := &CurrencyRunningLimits{} if len(src.Running.Volume) > 0 { running.Volume = make([]VolumeLimit, 0, len(src.Running.Volume)) for _, item := range src.Running.Volume { running.Volume = append(running.Volume, VolumeLimit{ Window: cloneWindow(item.Window), Max: item.Max, }) } } if len(src.Running.Velocity) > 0 { running.Velocity = make([]VelocityLimit, 0, len(src.Running.Velocity)) for _, item := range src.Running.Velocity { running.Velocity = append(running.Velocity, VelocityLimit{ Window: cloneWindow(item.Window), Max: item.Max, }) } } if len(running.Volume) > 0 || len(running.Velocity) > 0 { dst.Running = running } } if dst.Amount == nil && dst.Running == nil { return nil } return dst } func cloneWindow(src Window) Window { dst := Window{ Raw: src.Raw, Duration: src.Duration, Named: src.Named, } if src.Calendar != nil { dst.Calendar = &CalendarWindow{ Unit: src.Calendar.Unit, Count: src.Calendar.Count, } } return dst } func legacyCurrencyCodes(values []CurrencyAnnouncement) []string { if len(values) == 0 { return nil } seen := map[string]bool{} out := make([]string, 0, len(values)) for _, value := range values { currency := strings.ToUpper(strings.TrimSpace(value.Currency)) if currency == "" || seen[currency] { continue } seen[currency] = true out = append(out, currency) } if len(out) == 0 { return nil } return out } func legacyNetworkFromCurrencies(values []CurrencyAnnouncement) string { if len(values) == 0 { return "" } network := "" for _, value := range values { current := strings.ToUpper(strings.TrimSpace(value.Network)) if current == "" { continue } if network == "" { network = current continue } if network != current { return "" } } return network } func legacyLimitsFromCurrencies(values []CurrencyAnnouncement) *Limits { if len(values) == 0 { return nil } var merged *Limits for _, value := range values { current := legacyLimitsFromCurrency(value.Limits) if current == nil { continue } if merged == nil { merged = current continue } if !strings.EqualFold(strings.TrimSpace(merged.MinAmount), strings.TrimSpace(current.MinAmount)) { merged.MinAmount = "" } if !strings.EqualFold(strings.TrimSpace(merged.MaxAmount), strings.TrimSpace(current.MaxAmount)) { merged.MaxAmount = "" } merged.VolumeLimit = intersectStringMaps(merged.VolumeLimit, current.VolumeLimit) merged.VelocityLimit = intersectIntMaps(merged.VelocityLimit, current.VelocityLimit) } if merged == nil { return nil } if merged.MinAmount == "" && merged.MaxAmount == "" && len(merged.VolumeLimit) == 0 && len(merged.VelocityLimit) == 0 { return nil } return merged } func legacyLimitsFromCurrency(src *CurrencyLimits) *Limits { if src == nil { return nil } out := &Limits{} if src.Amount != nil { out.MinAmount = strings.TrimSpace(src.Amount.Min) out.MaxAmount = strings.TrimSpace(src.Amount.Max) } if src.Running != nil { if len(src.Running.Volume) > 0 { out.VolumeLimit = map[string]string{} for _, item := range src.Running.Volume { key := legacyWindowKey(item.Window) max := strings.TrimSpace(item.Max) if key == "" || max == "" { continue } out.VolumeLimit[key] = max } } if len(src.Running.Velocity) > 0 { out.VelocityLimit = map[string]int{} for _, item := range src.Running.Velocity { key := legacyWindowKey(item.Window) if key == "" || item.Max <= 0 { continue } out.VelocityLimit[key] = item.Max } } } if out.MinAmount == "" && out.MaxAmount == "" && len(out.VolumeLimit) == 0 && len(out.VelocityLimit) == 0 { return nil } return out } func intersectStringMaps(left, right map[string]string) map[string]string { if len(left) == 0 || len(right) == 0 { return nil } out := map[string]string{} for key, value := range left { if rightValue, ok := right[key]; ok && strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(rightValue)) { out[key] = value } } if len(out) == 0 { return nil } return out } func intersectIntMaps(left, right map[string]int) map[string]int { if len(left) == 0 || len(right) == 0 { return nil } out := map[string]int{} for key, value := range left { if rightValue, ok := right[key]; ok && value == rightValue { out[key] = value } } if len(out) == 0 { return nil } return out } func legacyWindowKey(window Window) string { if raw := strings.TrimSpace(window.Raw); raw != "" { return raw } if named := strings.TrimSpace(window.Named); named != "" { return named } if duration := strings.TrimSpace(window.Duration); duration != "" { return duration } if window.Calendar != nil && window.Calendar.Unit != CalendarUnitUnspecified { count := window.Calendar.Count if count <= 0 { count = 1 } return fmt.Sprintf("%d%s", count, strings.ToLower(strings.TrimSpace(string(window.Calendar.Unit)))) } return "" } 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 (r *Registry) indexEntry(key string, entry *RegistryEntry) { if r == nil || entry == nil || key == "" { return } if entry.ID != "" { addIndex(r.byID, entry.ID, key) } if entry.InstanceID != "" { addIndex(r.byInstance, entry.InstanceID, key) } } func (r *Registry) unindexEntry(key string, entry *RegistryEntry) { if r == nil || entry == nil || key == "" { return } if entry.ID != "" { removeIndex(r.byID, entry.ID, key) } if entry.InstanceID != "" { removeIndex(r.byInstance, entry.InstanceID, key) } } func addIndex(index map[string]map[string]struct{}, id string, key string) { if id == "" || key == "" { return } set := index[id] if set == nil { set = map[string]struct{}{} index[id] = set } set[key] = struct{}{} } func removeIndex(index map[string]map[string]struct{}, id string, key string) { if id == "" || key == "" { return } set := index[id] if set == nil { return } delete(set, key) if len(set) == 0 { delete(index, id) } } func keysFromIndex(index map[string]struct{}) []string { if len(index) == 0 { return nil } keys := make([]string, 0, len(index)) for key := range index { keys = append(keys, key) } return keys } 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 }