unified gateway interface
This commit is contained in:
157
api/pkg/discovery/announcer.go
Normal file
157
api/pkg/discovery/announcer.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
type Announcer struct {
|
||||
logger mlogger.Logger
|
||||
producer msg.Producer
|
||||
sender string
|
||||
announce Announcement
|
||||
|
||||
startOnce sync.Once
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
}
|
||||
|
||||
func NewAnnouncer(logger mlogger.Logger, producer msg.Producer, sender string, announce Announcement) *Announcer {
|
||||
if logger != nil {
|
||||
logger = logger.Named("discovery")
|
||||
}
|
||||
announce = normalizeAnnouncement(announce)
|
||||
if announce.Service == "" {
|
||||
announce.Service = strings.TrimSpace(sender)
|
||||
}
|
||||
if announce.ID == "" {
|
||||
announce.ID = DefaultInstanceID(announce.Service)
|
||||
}
|
||||
if announce.InvokeURI == "" && announce.Service != "" {
|
||||
announce.InvokeURI = DefaultInvokeURI(announce.Service)
|
||||
}
|
||||
return &Announcer{
|
||||
logger: logger,
|
||||
producer: producer,
|
||||
sender: strings.TrimSpace(sender),
|
||||
announce: announce,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Announcer) Start() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.startOnce.Do(func() {
|
||||
if a.producer == nil {
|
||||
a.logWarn("Discovery announce skipped: producer not configured")
|
||||
close(a.doneCh)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(a.announce.ID) == "" {
|
||||
a.logWarn("Discovery announce skipped: missing instance id")
|
||||
close(a.doneCh)
|
||||
return
|
||||
}
|
||||
a.sendAnnouncement()
|
||||
a.sendHeartbeat()
|
||||
go a.heartbeatLoop()
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Announcer) Stop() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.stopOnce.Do(func() {
|
||||
close(a.stopCh)
|
||||
<-a.doneCh
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Announcer) heartbeatLoop() {
|
||||
defer close(a.doneCh)
|
||||
interval := time.Duration(a.announce.Health.IntervalSec) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = time.Duration(DefaultHealthIntervalSec) * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-a.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.sendHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Announcer) sendAnnouncement() {
|
||||
env := NewServiceAnnounceEnvelope(a.sender, a.announce)
|
||||
if a.announce.Rail != "" {
|
||||
env = NewGatewayAnnounceEnvelope(a.sender, a.announce)
|
||||
}
|
||||
if err := a.producer.SendMessage(env); err != nil {
|
||||
a.logWarn("Failed to publish discovery announce: " + err.Error())
|
||||
return
|
||||
}
|
||||
a.logInfo("Discovery announce published")
|
||||
}
|
||||
|
||||
func (a *Announcer) sendHeartbeat() {
|
||||
hb := Heartbeat{
|
||||
ID: a.announce.ID,
|
||||
Status: "ok",
|
||||
TS: time.Now().Unix(),
|
||||
}
|
||||
if err := a.producer.SendMessage(NewHeartbeatEnvelope(a.sender, hb)); err != nil {
|
||||
a.logWarn("Failed to publish discovery heartbeat: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Announcer) logInfo(message string) {
|
||||
if a.logger == nil {
|
||||
return
|
||||
}
|
||||
a.logger.Info(message)
|
||||
}
|
||||
|
||||
func (a *Announcer) logWarn(message string) {
|
||||
if a.logger == nil {
|
||||
return
|
||||
}
|
||||
a.logger.Warn(message)
|
||||
}
|
||||
|
||||
func DefaultInstanceID(service string) string {
|
||||
clean := strings.ToLower(strings.TrimSpace(service))
|
||||
if clean == "" {
|
||||
clean = "service"
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
uid := uuid.NewString()
|
||||
if host == "" {
|
||||
return clean + "_" + uid
|
||||
}
|
||||
return clean + "_" + host + "_" + uid
|
||||
}
|
||||
|
||||
func DefaultInvokeURI(service string) string {
|
||||
clean := strings.ToLower(strings.TrimSpace(service))
|
||||
if clean == "" {
|
||||
return ""
|
||||
}
|
||||
return "grpc://" + clean
|
||||
}
|
||||
137
api/pkg/discovery/client.go
Normal file
137
api/pkg/discovery/client.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/messaging/broker"
|
||||
cons "github.com/tech/sendico/pkg/messaging/consumer"
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
logger mlogger.Logger
|
||||
producer msg.Producer
|
||||
consumer msg.Consumer
|
||||
sender string
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]chan LookupResponse
|
||||
}
|
||||
|
||||
func NewClient(logger mlogger.Logger, broker broker.Broker, producer msg.Producer, sender string) (*Client, error) {
|
||||
if broker == nil {
|
||||
return nil, errors.New("discovery client: broker is nil")
|
||||
}
|
||||
if logger != nil {
|
||||
logger = logger.Named("discovery_client")
|
||||
}
|
||||
if producer == nil {
|
||||
producer = msgproducer.NewProducer(logger, broker)
|
||||
}
|
||||
sender = strings.TrimSpace(sender)
|
||||
if sender == "" {
|
||||
sender = "discovery_client"
|
||||
}
|
||||
|
||||
consumer, err := cons.NewConsumer(logger, broker, LookupResponseEvent())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
logger: logger,
|
||||
producer: producer,
|
||||
consumer: consumer,
|
||||
sender: sender,
|
||||
pending: map[string]chan LookupResponse{},
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := consumer.ConsumeMessages(client.handleLookupResponse); err != nil && client.logger != nil {
|
||||
client.logger.Warn("Discovery lookup consumer stopped", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if c.consumer != nil {
|
||||
c.consumer.Close()
|
||||
}
|
||||
c.mu.Lock()
|
||||
for key, ch := range c.pending {
|
||||
close(ch)
|
||||
delete(c.pending, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) Lookup(ctx context.Context) (LookupResponse, error) {
|
||||
if c == nil || c.producer == nil {
|
||||
return LookupResponse{}, errors.New("discovery client: producer not configured")
|
||||
}
|
||||
requestID := uuid.NewString()
|
||||
ch := make(chan LookupResponse, 1)
|
||||
|
||||
c.mu.Lock()
|
||||
c.pending[requestID] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, requestID)
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
req := LookupRequest{RequestID: requestID}
|
||||
if err := c.producer.SendMessage(NewLookupRequestEnvelope(c.sender, req)); err != nil {
|
||||
return LookupResponse{}, err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return LookupResponse{}, ctx.Err()
|
||||
case resp := <-ch:
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleLookupResponse(_ context.Context, env me.Envelope) error {
|
||||
var payload LookupResponse
|
||||
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
|
||||
c.logWarn("Failed to decode discovery lookup response", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
requestID := strings.TrimSpace(payload.RequestID)
|
||||
if requestID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
ch := c.pending[requestID]
|
||||
c.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) logWarn(message string, fields ...zap.Field) {
|
||||
if c == nil || c.logger == nil {
|
||||
return
|
||||
}
|
||||
c.logger.Warn(message, fields...)
|
||||
}
|
||||
31
api/pkg/discovery/events.go
Normal file
31
api/pkg/discovery/events.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
nm "github.com/tech/sendico/pkg/model/notification"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
)
|
||||
|
||||
func ServiceAnnounceEvent() model.NotificationEvent {
|
||||
return model.NewNotification(mservice.Discovery, nm.NADiscoveryServiceAnnounce)
|
||||
}
|
||||
|
||||
func GatewayAnnounceEvent() model.NotificationEvent {
|
||||
return model.NewNotification(mservice.Discovery, nm.NADiscoveryGatewayAnnounce)
|
||||
}
|
||||
|
||||
func HeartbeatEvent() model.NotificationEvent {
|
||||
return model.NewNotification(mservice.Discovery, nm.NADiscoveryHeartbeat)
|
||||
}
|
||||
|
||||
func LookupRequestEvent() model.NotificationEvent {
|
||||
return model.NewNotification(mservice.Discovery, nm.NADiscoveryLookupRequest)
|
||||
}
|
||||
|
||||
func LookupResponseEvent() model.NotificationEvent {
|
||||
return model.NewNotification(mservice.Discovery, nm.NADiscoveryLookupResponse)
|
||||
}
|
||||
|
||||
func RefreshUIEvent() model.NotificationEvent {
|
||||
return model.NewNotification(mservice.Discovery, nm.NADiscoveryRefreshUI)
|
||||
}
|
||||
69
api/pkg/discovery/lookup.go
Normal file
69
api/pkg/discovery/lookup.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package discovery
|
||||
|
||||
type LookupRequest struct {
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
}
|
||||
|
||||
type LookupResponse struct {
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
Services []ServiceSummary `json:"services,omitempty"`
|
||||
Gateways []GatewaySummary `json:"gateways,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceSummary struct {
|
||||
ID string `json:"id"`
|
||||
Service string `json:"service"`
|
||||
Ops []string `json:"ops,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Healthy bool `json:"healthy,omitempty"`
|
||||
InvokeURI string `json:"invokeURI,omitempty"`
|
||||
}
|
||||
|
||||
type GatewaySummary struct {
|
||||
ID string `json:"id"`
|
||||
Rail string `json:"rail"`
|
||||
Network string `json:"network,omitempty"`
|
||||
Currencies []string `json:"currencies,omitempty"`
|
||||
Ops []string `json:"ops,omitempty"`
|
||||
Limits *Limits `json:"limits,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Healthy bool `json:"healthy,omitempty"`
|
||||
RoutingPriority int `json:"routingPriority,omitempty"`
|
||||
InvokeURI string `json:"invokeURI,omitempty"`
|
||||
}
|
||||
|
||||
func (r *Registry) Lookup(now time.Time) LookupResponse {
|
||||
entries := r.List(now, true)
|
||||
resp := LookupResponse{
|
||||
Services: make([]ServiceSummary, 0),
|
||||
Gateways: make([]GatewaySummary, 0),
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Rail != "" {
|
||||
resp.Gateways = append(resp.Gateways, GatewaySummary{
|
||||
ID: entry.ID,
|
||||
Rail: entry.Rail,
|
||||
Network: entry.Network,
|
||||
Currencies: cloneStrings(entry.Currencies),
|
||||
Ops: cloneStrings(entry.Operations),
|
||||
Limits: cloneLimits(entry.Limits),
|
||||
Version: entry.Version,
|
||||
Healthy: entry.Healthy,
|
||||
RoutingPriority: entry.RoutingPriority,
|
||||
InvokeURI: entry.InvokeURI,
|
||||
})
|
||||
continue
|
||||
}
|
||||
resp.Services = append(resp.Services, ServiceSummary{
|
||||
ID: entry.ID,
|
||||
Service: entry.Service,
|
||||
Ops: cloneStrings(entry.Operations),
|
||||
Version: entry.Version,
|
||||
Healthy: entry.Healthy,
|
||||
InvokeURI: entry.InvokeURI,
|
||||
})
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
56
api/pkg/discovery/messages.go
Normal file
56
api/pkg/discovery/messages.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
messaging "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type jsonEnvelope struct {
|
||||
messaging.Envelope
|
||||
payload any
|
||||
}
|
||||
|
||||
func (e *jsonEnvelope) Serialize() ([]byte, error) {
|
||||
if e.payload == nil {
|
||||
return nil, errors.New("discovery envelope payload is nil")
|
||||
}
|
||||
data, err := json.Marshal(e.payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.Envelope.Wrap(data)
|
||||
}
|
||||
|
||||
func newEnvelope(sender string, event model.NotificationEvent, payload any) messaging.Envelope {
|
||||
return &jsonEnvelope{
|
||||
Envelope: messaging.CreateEnvelope(sender, event),
|
||||
payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func NewServiceAnnounceEnvelope(sender string, payload Announcement) messaging.Envelope {
|
||||
return newEnvelope(sender, ServiceAnnounceEvent(), payload)
|
||||
}
|
||||
|
||||
func NewGatewayAnnounceEnvelope(sender string, payload Announcement) messaging.Envelope {
|
||||
return newEnvelope(sender, GatewayAnnounceEvent(), payload)
|
||||
}
|
||||
|
||||
func NewHeartbeatEnvelope(sender string, payload Heartbeat) messaging.Envelope {
|
||||
return newEnvelope(sender, HeartbeatEvent(), payload)
|
||||
}
|
||||
|
||||
func NewLookupRequestEnvelope(sender string, payload LookupRequest) messaging.Envelope {
|
||||
return newEnvelope(sender, LookupRequestEvent(), payload)
|
||||
}
|
||||
|
||||
func NewLookupResponseEnvelope(sender string, payload LookupResponse) messaging.Envelope {
|
||||
return newEnvelope(sender, LookupResponseEvent(), payload)
|
||||
}
|
||||
|
||||
func NewRefreshUIEnvelope(sender string, payload RefreshEvent) messaging.Envelope {
|
||||
return newEnvelope(sender, RefreshUIEvent(), payload)
|
||||
}
|
||||
258
api/pkg/discovery/registry.go
Normal file
258
api/pkg/discovery/registry.go
Normal file
@@ -0,0 +1,258 @@
|
||||
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
|
||||
}
|
||||
188
api/pkg/discovery/service.go
Normal file
188
api/pkg/discovery/service.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/messaging/broker"
|
||||
cons "github.com/tech/sendico/pkg/messaging/consumer"
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type RegistryService struct {
|
||||
logger mlogger.Logger
|
||||
registry *Registry
|
||||
producer msg.Producer
|
||||
sender string
|
||||
consumers []consumerHandler
|
||||
|
||||
startOnce sync.Once
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
type consumerHandler struct {
|
||||
consumer msg.Consumer
|
||||
handler msg.MessageHandlerT
|
||||
}
|
||||
|
||||
func NewRegistryService(logger mlogger.Logger, broker broker.Broker, producer msg.Producer, registry *Registry, sender string) (*RegistryService, error) {
|
||||
if broker == nil {
|
||||
return nil, errors.New("discovery registry: broker is nil")
|
||||
}
|
||||
if registry == nil {
|
||||
registry = NewRegistry()
|
||||
}
|
||||
if logger != nil {
|
||||
logger = logger.Named("discovery_registry")
|
||||
}
|
||||
sender = strings.TrimSpace(sender)
|
||||
if sender == "" {
|
||||
sender = "discovery"
|
||||
}
|
||||
|
||||
serviceConsumer, err := cons.NewConsumer(logger, broker, ServiceAnnounceEvent())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gatewayConsumer, err := cons.NewConsumer(logger, broker, GatewayAnnounceEvent())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
heartbeatConsumer, err := cons.NewConsumer(logger, broker, HeartbeatEvent())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookupConsumer, err := cons.NewConsumer(logger, broker, LookupRequestEvent())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
svc := &RegistryService{
|
||||
logger: logger,
|
||||
registry: registry,
|
||||
producer: producer,
|
||||
sender: sender,
|
||||
consumers: []consumerHandler{
|
||||
{consumer: serviceConsumer, handler: func(ctx context.Context, env me.Envelope) error {
|
||||
return svc.handleAnnounce(ctx, env)
|
||||
}},
|
||||
{consumer: gatewayConsumer, handler: func(ctx context.Context, env me.Envelope) error {
|
||||
return svc.handleAnnounce(ctx, env)
|
||||
}},
|
||||
{consumer: heartbeatConsumer, handler: svc.handleHeartbeat},
|
||||
{consumer: lookupConsumer, handler: svc.handleLookup},
|
||||
},
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func (s *RegistryService) Start() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.startOnce.Do(func() {
|
||||
for _, ch := range s.consumers {
|
||||
ch := ch
|
||||
go func() {
|
||||
if err := ch.consumer.ConsumeMessages(ch.handler); err != nil && s.logger != nil {
|
||||
s.logger.Warn("Discovery consumer stopped with error", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *RegistryService) Stop() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.stopOnce.Do(func() {
|
||||
for _, ch := range s.consumers {
|
||||
if ch.consumer != nil {
|
||||
ch.consumer.Close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *RegistryService) handleAnnounce(_ context.Context, env me.Envelope) error {
|
||||
var payload Announcement
|
||||
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
|
||||
s.logWarn("Failed to decode discovery announce payload", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
result := s.registry.UpsertFromAnnouncement(payload, now)
|
||||
if result.IsNew || result.BecameHealthy {
|
||||
s.publishRefresh(result.Entry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RegistryService) handleHeartbeat(_ context.Context, env me.Envelope) error {
|
||||
var payload Heartbeat
|
||||
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
|
||||
s.logWarn("Failed to decode discovery heartbeat payload", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if payload.ID == "" {
|
||||
return nil
|
||||
}
|
||||
ts := time.Unix(payload.TS, 0)
|
||||
if ts.Unix() <= 0 {
|
||||
ts = time.Now()
|
||||
}
|
||||
result, ok := s.registry.UpdateHeartbeat(payload.ID, strings.TrimSpace(payload.Status), ts, time.Now())
|
||||
if ok && result.BecameHealthy {
|
||||
s.publishRefresh(result.Entry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RegistryService) handleLookup(_ context.Context, env me.Envelope) error {
|
||||
if s.producer == nil {
|
||||
s.logWarn("Discovery lookup request ignored: producer not configured")
|
||||
return nil
|
||||
}
|
||||
var payload LookupRequest
|
||||
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
|
||||
s.logWarn("Failed to decode discovery lookup payload", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
resp := s.registry.Lookup(time.Now())
|
||||
resp.RequestID = strings.TrimSpace(payload.RequestID)
|
||||
if err := s.producer.SendMessage(NewLookupResponseEnvelope(s.sender, resp)); err != nil {
|
||||
s.logWarn("Failed to publish discovery lookup response", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RegistryService) publishRefresh(entry RegistryEntry) {
|
||||
if s == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
payload := RefreshEvent{
|
||||
Service: entry.Service,
|
||||
Rail: entry.Rail,
|
||||
Network: entry.Network,
|
||||
Message: "new module available",
|
||||
}
|
||||
if err := s.producer.SendMessage(NewRefreshUIEnvelope(s.sender, payload)); err != nil {
|
||||
s.logWarn("Failed to publish discovery refresh event", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RegistryService) logWarn(message string, fields ...zap.Field) {
|
||||
if s.logger == nil {
|
||||
return
|
||||
}
|
||||
s.logger.Warn(message, fields...)
|
||||
}
|
||||
10
api/pkg/discovery/subjects.go
Normal file
10
api/pkg/discovery/subjects.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package discovery
|
||||
|
||||
const (
|
||||
SubjectServiceAnnounce = "discovery.service.announce"
|
||||
SubjectGatewayAnnounce = "discovery.gateway.announce"
|
||||
SubjectHeartbeat = "discovery.service.heartbeat"
|
||||
SubjectLookupRequest = "discovery.request.lookup"
|
||||
SubjectLookupResponse = "discovery.response.lookup"
|
||||
SubjectRefreshUI = "discovery.event.refresh_ui"
|
||||
)
|
||||
40
api/pkg/discovery/types.go
Normal file
40
api/pkg/discovery/types.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package discovery
|
||||
|
||||
type HealthParams struct {
|
||||
IntervalSec int `json:"intervalSec"`
|
||||
TimeoutSec int `json:"timeoutSec"`
|
||||
}
|
||||
|
||||
type Limits struct {
|
||||
MinAmount string `json:"minAmount,omitempty"`
|
||||
MaxAmount string `json:"maxAmount,omitempty"`
|
||||
VolumeLimit map[string]string `json:"volumeLimit,omitempty"`
|
||||
VelocityLimit map[string]int `json:"velocityLimit,omitempty"`
|
||||
}
|
||||
|
||||
type Announcement 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"`
|
||||
}
|
||||
|
||||
type Heartbeat struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
type RefreshEvent struct {
|
||||
Service string `json:"service,omitempty"`
|
||||
Rail string `json:"rail,omitempty"`
|
||||
Network string `json:"network,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user