优化结构
This commit is contained in:
parent
626f505b5d
commit
2b78b195c1
|
|
@ -233,6 +233,9 @@ func validateCommunicationIntegrationConfig(kind, provider string, values map[st
|
||||||
if timeout := integrationInt64(values, "connect_timeout", 0); timeout <= 0 {
|
if timeout := integrationInt64(values, "connect_timeout", 0); timeout <= 0 {
|
||||||
return errors.New("emqx connect_timeout 必须大于 0")
|
return errors.New("emqx connect_timeout 必须大于 0")
|
||||||
}
|
}
|
||||||
|
if interval := integrationInt64(values, "reconnect_interval", 0); interval <= 0 {
|
||||||
|
return errors.New("emqx reconnect_interval 必须大于 0")
|
||||||
|
}
|
||||||
case IntegrationKindMQ + "/rabbitmq":
|
case IntegrationKindMQ + "/rabbitmq":
|
||||||
port := integrationInt64(values, "port", 0)
|
port := integrationInt64(values, "port", 0)
|
||||||
if port < 1 || port > 65535 {
|
if port < 1 || port > 65535 {
|
||||||
|
|
@ -251,6 +254,9 @@ func validateCommunicationIntegrationConfig(kind, provider string, values map[st
|
||||||
if integrationInt64(values, "connect_timeout", 0) <= 0 {
|
if integrationInt64(values, "connect_timeout", 0) <= 0 {
|
||||||
return errors.New("rabbitmq connect_timeout 必须大于 0")
|
return errors.New("rabbitmq connect_timeout 必须大于 0")
|
||||||
}
|
}
|
||||||
|
if integrationInt64(values, "reconnect_interval", 0) <= 0 {
|
||||||
|
return errors.New("rabbitmq reconnect_interval 必须大于 0")
|
||||||
|
}
|
||||||
case IntegrationKindWebSocket + "/melody":
|
case IntegrationKindWebSocket + "/melody":
|
||||||
path := integrationText(values, "path")
|
path := integrationText(values, "path")
|
||||||
if !strings.HasPrefix(path, "/") {
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
||||||
IntegrationKindMQ: {
|
IntegrationKindMQ: {
|
||||||
{
|
{
|
||||||
Kind: IntegrationKindMQ, Provider: "emqx", Name: "EMQX", Description: "EMQX MQTT 消息服务",
|
Kind: IntegrationKindMQ, Provider: "emqx", Name: "EMQX", Description: "EMQX MQTT 消息服务",
|
||||||
Defaults: map[string]any{"broker": "tcp://127.0.0.1:1883", "client_id": "kra", "username": "", "password": "", "keep_alive": 30, "clean_session": true, "connect_timeout": 10},
|
Defaults: map[string]any{"broker": "tcp://127.0.0.1:1883", "client_id": "kra", "username": "", "password": "", "keep_alive": 30, "clean_session": true, "connect_timeout": 10, "reconnect_interval": 5},
|
||||||
Fields: []IntegrationConfigField{
|
Fields: []IntegrationConfigField{
|
||||||
{Key: "broker", Label: "Broker 地址", Type: "text", Required: true, Placeholder: "tcp://127.0.0.1:1883"},
|
{Key: "broker", Label: "Broker 地址", Type: "text", Required: true, Placeholder: "tcp://127.0.0.1:1883"},
|
||||||
{Key: "client_id", Label: "客户端 ID", Type: "text", Required: true, Placeholder: "kra"},
|
{Key: "client_id", Label: "客户端 ID", Type: "text", Required: true, Placeholder: "kra"},
|
||||||
|
|
@ -101,11 +101,12 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
||||||
{Key: "keep_alive", Label: "心跳间隔(秒)", Type: "number", Required: true},
|
{Key: "keep_alive", Label: "心跳间隔(秒)", Type: "number", Required: true},
|
||||||
{Key: "clean_session", Label: "清理会话", Type: "switch", Description: "连接时不恢复 Broker 端保存的旧会话。"},
|
{Key: "clean_session", Label: "清理会话", Type: "switch", Description: "连接时不恢复 Broker 端保存的旧会话。"},
|
||||||
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
|
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
|
||||||
|
{Key: "reconnect_interval", Label: "重连退避上限(秒)", Type: "number", Required: true, Description: "网络中断后自动重连的最大退避间隔。"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Kind: IntegrationKindMQ, Provider: "rabbitmq", Name: "RabbitMQ", Description: "RabbitMQ AMQP 消息队列",
|
Kind: IntegrationKindMQ, Provider: "rabbitmq", Name: "RabbitMQ", Description: "RabbitMQ AMQP 消息队列",
|
||||||
Defaults: map[string]any{"host": "127.0.0.1", "port": 5672, "username": "guest", "password": "guest", "vhost": "/", "exchange": "kra", "exchange_type": "topic", "queue": "kra", "routing_key": "#", "durable": true, "auto_delete": false, "prefetch_count": 10, "heartbeat": 10, "connect_timeout": 10, "tls": false},
|
Defaults: map[string]any{"host": "127.0.0.1", "port": 5672, "username": "guest", "password": "guest", "vhost": "/", "exchange": "kra", "exchange_type": "topic", "queue": "kra", "routing_key": "#", "durable": true, "auto_delete": false, "prefetch_count": 10, "heartbeat": 10, "connect_timeout": 10, "reconnect_interval": 5, "tls": false},
|
||||||
Fields: []IntegrationConfigField{
|
Fields: []IntegrationConfigField{
|
||||||
{Key: "host", Label: "主机", Type: "text", Required: true, Placeholder: "127.0.0.1"},
|
{Key: "host", Label: "主机", Type: "text", Required: true, Placeholder: "127.0.0.1"},
|
||||||
{Key: "port", Label: "端口", Type: "number", Required: true},
|
{Key: "port", Label: "端口", Type: "number", Required: true},
|
||||||
|
|
@ -121,6 +122,7 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
||||||
{Key: "prefetch_count", Label: "预取数量", Type: "number"},
|
{Key: "prefetch_count", Label: "预取数量", Type: "number"},
|
||||||
{Key: "heartbeat", Label: "心跳间隔(秒)", Type: "number"},
|
{Key: "heartbeat", Label: "心跳间隔(秒)", Type: "number"},
|
||||||
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
|
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
|
||||||
|
{Key: "reconnect_interval", Label: "重连退避上限(秒)", Type: "number", Required: true, Description: "网络中断后自动重连的最大退避间隔。"},
|
||||||
{Key: "tls", Label: "启用 TLS", Type: "switch"},
|
{Key: "tls", Label: "启用 TLS", Type: "switch"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ const (
|
||||||
retryTick = time.Second
|
retryTick = time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reloadable owns the process-wide message clients. Configuration comes only
|
// Reloadable owns process-wide message clients and the logical subscription
|
||||||
// from sys_integration_configs through runtimeconfig.Store.
|
// declarations used to restore them after reconnects or configuration reloads.
|
||||||
type Reloadable struct {
|
type Reloadable struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
opMu sync.Mutex
|
opMu sync.Mutex
|
||||||
|
|
@ -31,10 +31,10 @@ type Reloadable struct {
|
||||||
bindings map[string]map[string]byte
|
bindings map[string]map[string]byte
|
||||||
pending map[string]bool
|
pending map[string]bool
|
||||||
nextRetry map[string]time.Time
|
nextRetry map[string]time.Time
|
||||||
|
legacySeq uint64
|
||||||
stop []func()
|
stop []func()
|
||||||
retryStop chan struct{}
|
retryStop chan struct{}
|
||||||
retryDone chan struct{}
|
retryDone chan struct{}
|
||||||
closeOnce sync.Once
|
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
closed bool
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
@ -139,20 +139,23 @@ func (r *Reloadable) apply(provider string, config runtimeconfig.Config) {
|
||||||
if r.closed {
|
if r.closed {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
r.ensureStateLocked()
|
||||||
|
config.Kind = "mq"
|
||||||
config.Provider = provider
|
config.Provider = provider
|
||||||
config.Values = append(json.RawMessage(nil), config.Values...)
|
config.Values = append(json.RawMessage(nil), config.Values...)
|
||||||
r.configs[provider] = config
|
r.configs[provider] = config
|
||||||
|
r.replaceClientLocked(provider, nil)
|
||||||
if !config.Enabled {
|
if !config.Enabled {
|
||||||
delete(r.pending, provider)
|
delete(r.pending, provider)
|
||||||
delete(r.nextRetry, provider)
|
delete(r.nextRetry, provider)
|
||||||
r.replaceClientLocked(provider, nil)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.activateLocked(provider, config); err != nil {
|
if err := r.activateLocked(provider, config); err != nil {
|
||||||
r.pending[provider] = true
|
r.scheduleRetryLocked(provider, config.Values)
|
||||||
r.nextRetry[provider] = time.Now().Add(configRetryInterval(config.Values))
|
if r.logger != nil {
|
||||||
r.logger.Warn("message integration unavailable", "mod", "mq", "provider", provider, "error", err)
|
r.logger.Warn("message integration unavailable", "mod", "mq", "provider", provider, "error", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Reloadable) activateLocked(provider string, config runtimeconfig.Config) error {
|
func (r *Reloadable) activateLocked(provider string, config runtimeconfig.Config) error {
|
||||||
|
|
@ -167,6 +170,9 @@ func (r *Reloadable) activateLocked(provider string, config runtimeconfig.Config
|
||||||
}
|
}
|
||||||
r.replaceClientLocked(provider, client)
|
r.replaceClientLocked(provider, client)
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
if r.bindings == nil {
|
||||||
|
r.bindings = make(map[string]map[string]byte)
|
||||||
|
}
|
||||||
r.bindings[provider] = bindings
|
r.bindings[provider] = bindings
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
delete(r.pending, provider)
|
delete(r.pending, provider)
|
||||||
|
|
@ -253,8 +259,89 @@ func configBool(values map[string]any, key string) bool {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) retryLoop() {
|
||||||
|
defer close(r.retryDone)
|
||||||
|
ticker := time.NewTicker(retryTick)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.retryStop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
r.retryOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) retryOnce() {
|
||||||
|
r.opMu.Lock()
|
||||||
|
defer r.opMu.Unlock()
|
||||||
|
if r.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.ensureStateLocked()
|
||||||
|
now := time.Now()
|
||||||
|
for _, provider := range []string{ProviderEMQX, ProviderRabbitMQ} {
|
||||||
|
config, exists := r.configs[provider]
|
||||||
|
if !exists || !config.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
client := r.clientLocked(provider)
|
||||||
|
if client != nil && client.Connected() {
|
||||||
|
if r.pending[provider] {
|
||||||
|
if err := r.reconcileProviderLocked(provider); err != nil {
|
||||||
|
r.scheduleRetryLocked(provider, config.Values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if client != nil {
|
||||||
|
if recovering, ok := client.(interface{ Reconnecting() bool }); ok && recovering.Reconnecting() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if retryAt := r.nextRetry[provider]; !retryAt.IsZero() && now.Before(retryAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.replaceClientLocked(provider, nil)
|
||||||
|
if err := r.activateLocked(provider, config); err != nil {
|
||||||
|
r.scheduleRetryLocked(provider, config.Values)
|
||||||
|
if r.logger != nil {
|
||||||
|
r.logger.Warn("message integration reconnect failed", "mod", "mq", "provider", provider, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) scheduleRetryLocked(provider string, raw json.RawMessage) {
|
||||||
|
if r.pending == nil {
|
||||||
|
r.pending = make(map[string]bool)
|
||||||
|
}
|
||||||
|
if r.nextRetry == nil {
|
||||||
|
r.nextRetry = make(map[string]time.Time)
|
||||||
|
}
|
||||||
|
r.pending[provider] = true
|
||||||
|
r.nextRetry[provider] = time.Now().Add(configRetryInterval(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func configRetryInterval(raw json.RawMessage) time.Duration {
|
||||||
|
values := map[string]any{}
|
||||||
|
_ = json.Unmarshal(raw, &values)
|
||||||
|
interval := configSeconds(values, "reconnect_interval")
|
||||||
|
if interval <= 0 {
|
||||||
|
return 5 * time.Second
|
||||||
|
}
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Reloadable) replaceClientLocked(provider string, next platformmq.Client) {
|
func (r *Reloadable) replaceClientLocked(provider string, next platformmq.Client) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
if r.clients == nil {
|
||||||
|
r.clients = make(map[string]platformmq.Client)
|
||||||
|
}
|
||||||
|
if r.bindings == nil {
|
||||||
|
r.bindings = make(map[string]map[string]byte)
|
||||||
|
}
|
||||||
old := r.clients[provider]
|
old := r.clients[provider]
|
||||||
if next == nil {
|
if next == nil {
|
||||||
delete(r.clients, provider)
|
delete(r.clients, provider)
|
||||||
|
|
@ -263,7 +350,7 @@ func (r *Reloadable) replaceClientLocked(provider string, next platformmq.Client
|
||||||
r.clients[provider] = next
|
r.clients[provider] = next
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if old != nil {
|
if old != nil && old != next {
|
||||||
_ = old.Close()
|
_ = old.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -280,23 +367,241 @@ func (r *Reloadable) restoreSubscriptionsLocked(provider string, client platform
|
||||||
return bindings, nil
|
return bindings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func configRetryInterval(raw json.RawMessage) time.Duration {
|
func (r *Reloadable) desiredSubscriptions(provider string) map[string]byte {
|
||||||
values := map[string]any{}
|
result := make(map[string]byte)
|
||||||
_ = json.Unmarshal(raw, &values)
|
r.mu.RLock()
|
||||||
interval := configSeconds(values, "reconnect_interval")
|
defer r.mu.RUnlock()
|
||||||
if interval <= 0 {
|
for topic, owners := range r.subscriptions[provider] {
|
||||||
return 5 * time.Second
|
for _, item := range owners {
|
||||||
|
if qos, exists := result[topic]; !exists || item.qos > qos {
|
||||||
|
result[topic] = item.qos
|
||||||
}
|
}
|
||||||
return interval
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Reloadable) client(provider string) platformmq.Client {
|
func (r *Reloadable) dispatcher(provider, topic string) platformmq.Handler {
|
||||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
return func(ctx context.Context, message platformmq.Message) {
|
||||||
|
r.mu.RLock()
|
||||||
|
owners := r.subscriptions[provider][topic]
|
||||||
|
handlers := make([]platformmq.Handler, 0, len(owners))
|
||||||
|
for _, item := range owners {
|
||||||
|
handlers = append(handlers, item.handler)
|
||||||
|
}
|
||||||
|
r.mu.RUnlock()
|
||||||
|
for _, handler := range handlers {
|
||||||
|
handler(ctx, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) clientLocked(provider string) platformmq.Client {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
return r.clients[provider]
|
return r.clients[provider]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) reconcileProviderLocked(provider string) error {
|
||||||
|
client := r.clientLocked(provider)
|
||||||
|
desired := r.desiredSubscriptions(provider)
|
||||||
|
if client == nil || !client.Connected() {
|
||||||
|
if len(desired) == 0 {
|
||||||
|
delete(r.pending, provider)
|
||||||
|
delete(r.nextRetry, provider)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return platformmq.ErrUnavailable
|
||||||
|
}
|
||||||
|
r.mu.RLock()
|
||||||
|
current := make(map[string]byte, len(r.bindings[provider]))
|
||||||
|
for topic, qos := range r.bindings[provider] {
|
||||||
|
current[topic] = qos
|
||||||
|
}
|
||||||
|
r.mu.RUnlock()
|
||||||
|
for topic := range current {
|
||||||
|
if _, exists := desired[topic]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := client.Unsubscribe(context.Background(), topic); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(current, topic)
|
||||||
|
}
|
||||||
|
for topic, qos := range desired {
|
||||||
|
if oldQoS, exists := current[topic]; exists && oldQoS == qos {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := current[topic]; exists {
|
||||||
|
if err := client.Unsubscribe(context.Background(), topic); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := client.Subscribe(context.Background(), topic, qos, r.dispatcher(provider, topic)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
current[topic] = qos
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.bindings[provider] = current
|
||||||
|
r.mu.Unlock()
|
||||||
|
delete(r.pending, provider)
|
||||||
|
delete(r.nextRetry, provider)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) Register(set platformmq.SubscriptionSet) error {
|
||||||
|
normalized, err := platformmq.NormalizeSubscriptionSet(set)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.opMu.Lock()
|
||||||
|
defer r.opMu.Unlock()
|
||||||
|
if r.closed {
|
||||||
|
return platformmq.ErrUnavailable
|
||||||
|
}
|
||||||
|
r.ensureStateLocked()
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.subscriptions[normalized.Provider] == nil {
|
||||||
|
r.subscriptions[normalized.Provider] = make(map[string]map[string]subscription)
|
||||||
|
}
|
||||||
|
for topic, owners := range r.subscriptions[normalized.Provider] {
|
||||||
|
delete(owners, normalized.Owner)
|
||||||
|
if len(owners) == 0 {
|
||||||
|
delete(r.subscriptions[normalized.Provider], topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range normalized.Topics {
|
||||||
|
if r.subscriptions[normalized.Provider][item.Topic] == nil {
|
||||||
|
r.subscriptions[normalized.Provider][item.Topic] = make(map[string]subscription)
|
||||||
|
}
|
||||||
|
r.subscriptions[normalized.Provider][item.Topic][normalized.Owner] = subscription{qos: item.QoS, handler: item.Handler}
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
if err := r.reconcileProviderLocked(normalized.Provider); err != nil {
|
||||||
|
config := r.configs[normalized.Provider]
|
||||||
|
r.scheduleRetryLocked(normalized.Provider, config.Values)
|
||||||
|
if r.logger != nil {
|
||||||
|
r.logger.Warn("message subscription bind deferred", "mod", "mq", "provider", normalized.Provider, "owner", normalized.Owner, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) Unregister(owner string) error {
|
||||||
|
owner = strings.TrimSpace(owner)
|
||||||
|
if owner == "" {
|
||||||
|
return fmt.Errorf("mq subscription owner is empty")
|
||||||
|
}
|
||||||
|
r.opMu.Lock()
|
||||||
|
defer r.opMu.Unlock()
|
||||||
|
if r.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.ensureStateLocked()
|
||||||
|
r.mu.Lock()
|
||||||
|
providers := make([]string, 0, len(r.subscriptions))
|
||||||
|
for provider, topics := range r.subscriptions {
|
||||||
|
providers = append(providers, provider)
|
||||||
|
for topic, owners := range topics {
|
||||||
|
delete(owners, owner)
|
||||||
|
if len(owners) == 0 {
|
||||||
|
delete(topics, topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
for _, provider := range providers {
|
||||||
|
if err := r.reconcileProviderLocked(provider); err != nil {
|
||||||
|
if config, ok := r.configs[provider]; ok {
|
||||||
|
r.scheduleRetryLocked(provider, config.Values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
||||||
|
return r.PublishTo(ctx, ProviderEMQX, topic, payload, qos, retain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) PublishTo(ctx context.Context, provider, topic string, payload []byte, qos byte, retain bool) error {
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
client := r.clientLocked(provider)
|
||||||
|
if client == nil {
|
||||||
|
return platformmq.ErrUnavailable
|
||||||
|
}
|
||||||
|
return client.Publish(ctx, topic, payload, qos, retain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler platformmq.Handler) error {
|
||||||
|
return r.SubscribeTo(ctx, ProviderEMQX, topic, qos, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) SubscribeTo(ctx context.Context, provider, topic string, qos byte, handler platformmq.Handler) error {
|
||||||
|
if ctx != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
r.opMu.Lock()
|
||||||
|
r.legacySeq++
|
||||||
|
owner := fmt.Sprintf("legacy/%s/%d", provider, r.legacySeq)
|
||||||
|
r.opMu.Unlock()
|
||||||
|
if err := r.Register(platformmq.SubscriptionSet{Owner: owner, Provider: provider, Topics: []platformmq.TopicSubscription{{Topic: topic, QoS: qos, Handler: handler}}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !r.ConnectedTo(provider) {
|
||||||
|
return platformmq.ErrUnavailable
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) Unsubscribe(ctx context.Context, topics ...string) error {
|
||||||
|
return r.UnsubscribeFrom(ctx, ProviderEMQX, topics...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) UnsubscribeFrom(ctx context.Context, provider string, topics ...string) error {
|
||||||
|
if ctx != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
if len(topics) == 0 {
|
||||||
|
return fmt.Errorf("mq topics are empty")
|
||||||
|
}
|
||||||
|
r.opMu.Lock()
|
||||||
|
defer r.opMu.Unlock()
|
||||||
|
r.ensureStateLocked()
|
||||||
|
r.mu.Lock()
|
||||||
|
for _, rawTopic := range topics {
|
||||||
|
topic := strings.TrimSpace(rawTopic)
|
||||||
|
owners := r.subscriptions[provider][topic]
|
||||||
|
for owner := range owners {
|
||||||
|
if strings.HasPrefix(owner, "legacy/") {
|
||||||
|
delete(owners, owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(owners) == 0 {
|
||||||
|
delete(r.subscriptions[provider], topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
if err := r.reconcileProviderLocked(provider); err != nil {
|
||||||
|
if config, ok := r.configs[provider]; ok {
|
||||||
|
r.scheduleRetryLocked(provider, config.Values)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Reloadable) Client(provider string) platformmq.Client {
|
func (r *Reloadable) Client(provider string) platformmq.Client {
|
||||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
if provider != ProviderEMQX && provider != ProviderRabbitMQ {
|
if provider != ProviderEMQX && provider != ProviderRabbitMQ {
|
||||||
|
|
@ -317,81 +622,24 @@ func (c *namedClient) Unsubscribe(ctx context.Context, topics ...string) error {
|
||||||
func (c *namedClient) Connected() bool { return c.owner.ConnectedTo(c.provider) }
|
func (c *namedClient) Connected() bool { return c.owner.ConnectedTo(c.provider) }
|
||||||
func (*namedClient) Close() error { return nil }
|
func (*namedClient) Close() error { return nil }
|
||||||
|
|
||||||
func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
|
||||||
return r.PublishTo(ctx, ProviderEMQX, topic, payload, qos, retain)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Reloadable) PublishTo(ctx context.Context, provider, topic string, payload []byte, qos byte, retain bool) error {
|
|
||||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
client := r.clients[provider]
|
|
||||||
if client == nil {
|
|
||||||
return platformmq.ErrUnavailable
|
|
||||||
}
|
|
||||||
return client.Publish(ctx, topic, payload, qos, retain)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler platformmq.Handler) error {
|
|
||||||
return r.SubscribeTo(ctx, ProviderEMQX, topic, qos, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Reloadable) SubscribeTo(ctx context.Context, provider, topic string, qos byte, handler platformmq.Handler) error {
|
|
||||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
|
||||||
r.opMu.Lock()
|
|
||||||
defer r.opMu.Unlock()
|
|
||||||
client := r.client(provider)
|
|
||||||
if client == nil {
|
|
||||||
return platformmq.ErrUnavailable
|
|
||||||
}
|
|
||||||
if err := client.Subscribe(ctx, topic, qos, handler); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if r.subscriptions[provider] == nil {
|
|
||||||
r.subscriptions[provider] = make(map[string]subscription)
|
|
||||||
}
|
|
||||||
r.subscriptions[provider][topic] = subscription{qos: qos, handler: handler}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Reloadable) Unsubscribe(ctx context.Context, topics ...string) error {
|
|
||||||
return r.UnsubscribeFrom(ctx, ProviderEMQX, topics...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Reloadable) UnsubscribeFrom(ctx context.Context, provider string, topics ...string) error {
|
|
||||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
|
||||||
r.opMu.Lock()
|
|
||||||
defer r.opMu.Unlock()
|
|
||||||
client := r.client(provider)
|
|
||||||
if client == nil {
|
|
||||||
return platformmq.ErrUnavailable
|
|
||||||
}
|
|
||||||
if err := client.Unsubscribe(ctx, topics...); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, topic := range topics {
|
|
||||||
delete(r.subscriptions[provider], topic)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Reloadable) Connected() bool { return r.ConnectedTo(ProviderEMQX) }
|
func (r *Reloadable) Connected() bool { return r.ConnectedTo(ProviderEMQX) }
|
||||||
|
|
||||||
func (r *Reloadable) ConnectedTo(provider string) bool {
|
func (r *Reloadable) ConnectedTo(provider string) bool {
|
||||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
r.mu.RLock()
|
client := r.clientLocked(provider)
|
||||||
defer r.mu.RUnlock()
|
|
||||||
client := r.clients[provider]
|
|
||||||
return client != nil && client.Connected()
|
return client != nil && client.Connected()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Reloadable) Close() error {
|
func (r *Reloadable) Close() error {
|
||||||
r.opMu.Lock()
|
r.opMu.Lock()
|
||||||
defer r.opMu.Unlock()
|
|
||||||
if r.closed {
|
if r.closed {
|
||||||
|
r.opMu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
r.closed = true
|
r.closed = true
|
||||||
|
if r.retryStop != nil {
|
||||||
|
close(r.retryStop)
|
||||||
|
}
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
clients := make([]platformmq.Client, 0, len(r.clients))
|
clients := make([]platformmq.Client, 0, len(r.clients))
|
||||||
for provider, client := range r.clients {
|
for provider, client := range r.clients {
|
||||||
|
|
@ -399,6 +647,10 @@ func (r *Reloadable) Close() error {
|
||||||
delete(r.clients, provider)
|
delete(r.clients, provider)
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
r.opMu.Unlock()
|
||||||
|
if r.retryDone != nil {
|
||||||
|
<-r.retryDone
|
||||||
|
}
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client != nil {
|
if client != nil {
|
||||||
_ = client.Close()
|
_ = client.Close()
|
||||||
|
|
@ -406,3 +658,24 @@ func (r *Reloadable) Close() error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) ensureStateLocked() {
|
||||||
|
if r.clients == nil {
|
||||||
|
r.clients = make(map[string]platformmq.Client)
|
||||||
|
}
|
||||||
|
if r.configs == nil {
|
||||||
|
r.configs = make(map[string]runtimeconfig.Config)
|
||||||
|
}
|
||||||
|
if r.subscriptions == nil {
|
||||||
|
r.subscriptions = make(map[string]map[string]map[string]subscription)
|
||||||
|
}
|
||||||
|
if r.bindings == nil {
|
||||||
|
r.bindings = make(map[string]map[string]byte)
|
||||||
|
}
|
||||||
|
if r.pending == nil {
|
||||||
|
r.pending = make(map[string]bool)
|
||||||
|
}
|
||||||
|
if r.nextRetry == nil {
|
||||||
|
r.nextRetry = make(map[string]time.Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,27 @@ package mq
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"kra/internal/integration/runtimeconfig"
|
||||||
platformmq "kra/pkg/mq"
|
platformmq "kra/pkg/mq"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeClient struct {
|
type fakeClient struct {
|
||||||
subscribed []string
|
subscribed []string
|
||||||
unsubscribed []string
|
unsubscribed []string
|
||||||
|
handlers map[string]platformmq.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*fakeClient) Publish(context.Context, string, []byte, byte, bool) error { return nil }
|
func (*fakeClient) Publish(context.Context, string, []byte, byte, bool) error { return nil }
|
||||||
func (f *fakeClient) Subscribe(_ context.Context, topic string, _ byte, _ platformmq.Handler) error {
|
func (f *fakeClient) Subscribe(_ context.Context, topic string, _ byte, handler platformmq.Handler) error {
|
||||||
f.subscribed = append(f.subscribed, topic)
|
f.subscribed = append(f.subscribed, topic)
|
||||||
|
if f.handlers == nil {
|
||||||
|
f.handlers = make(map[string]platformmq.Handler)
|
||||||
|
}
|
||||||
|
f.handlers[topic] = handler
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeClient) Unsubscribe(_ context.Context, topics ...string) error {
|
func (f *fakeClient) Unsubscribe(_ context.Context, topics ...string) error {
|
||||||
|
|
@ -28,7 +36,11 @@ func TestReloadableTracksSubscriptions(t *testing.T) {
|
||||||
client := &fakeClient{}
|
client := &fakeClient{}
|
||||||
r := &Reloadable{
|
r := &Reloadable{
|
||||||
clients: map[string]platformmq.Client{ProviderEMQX: client},
|
clients: map[string]platformmq.Client{ProviderEMQX: client},
|
||||||
subscriptions: make(map[string]map[string]subscription),
|
configs: map[string]runtimeconfig.Config{ProviderEMQX: {Kind: "mq", Provider: ProviderEMQX, Enabled: true}},
|
||||||
|
subscriptions: make(map[string]map[string]map[string]subscription),
|
||||||
|
bindings: make(map[string]map[string]byte),
|
||||||
|
pending: make(map[string]bool),
|
||||||
|
nextRetry: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
handler := func(context.Context, platformmq.Message) {}
|
handler := func(context.Context, platformmq.Message) {}
|
||||||
if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil {
|
if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil {
|
||||||
|
|
@ -47,15 +59,92 @@ func TestReloadableTracksSubscriptions(t *testing.T) {
|
||||||
|
|
||||||
func TestReloadableRestoresSubscriptions(t *testing.T) {
|
func TestReloadableRestoresSubscriptions(t *testing.T) {
|
||||||
client := &fakeClient{}
|
client := &fakeClient{}
|
||||||
r := &Reloadable{subscriptions: map[string]map[string]subscription{
|
r := &Reloadable{subscriptions: map[string]map[string]map[string]subscription{
|
||||||
ProviderEMQX: {
|
ProviderEMQX: {
|
||||||
"orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
|
"orders/+/paid": {
|
||||||
|
"orders": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
if err := r.restoreSubscriptionsLocked(ProviderEMQX, client); err != nil {
|
bindings, err := r.restoreSubscriptionsLocked(ProviderEMQX, client)
|
||||||
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(client.subscribed) != 1 || client.subscribed[0] != "orders/+/paid" {
|
if len(client.subscribed) != 1 || client.subscribed[0] != "orders/+/paid" {
|
||||||
t.Fatalf("restored subscriptions = %v", client.subscribed)
|
t.Fatalf("restored subscriptions = %v", client.subscribed)
|
||||||
}
|
}
|
||||||
|
if bindings["orders/+/paid"] != platformmq.AtLeastOnce {
|
||||||
|
t.Fatalf("restored binding qos = %d", bindings["orders/+/paid"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadableRegistersWhileOfflineAndRestoresLater(t *testing.T) {
|
||||||
|
client := &fakeClient{}
|
||||||
|
r := &Reloadable{
|
||||||
|
clients: make(map[string]platformmq.Client),
|
||||||
|
configs: map[string]runtimeconfig.Config{ProviderEMQX: {Kind: "mq", Provider: ProviderEMQX, Enabled: true}},
|
||||||
|
subscriptions: make(map[string]map[string]map[string]subscription),
|
||||||
|
bindings: make(map[string]map[string]byte),
|
||||||
|
pending: make(map[string]bool),
|
||||||
|
nextRetry: make(map[string]time.Time),
|
||||||
|
logger: slog.Default(),
|
||||||
|
}
|
||||||
|
called := make(chan struct{}, 1)
|
||||||
|
err := r.Register(platformmq.SubscriptionSet{
|
||||||
|
Owner: "orders",
|
||||||
|
Provider: ProviderEMQX,
|
||||||
|
Topics: []platformmq.TopicSubscription{{Topic: "orders.created", QoS: platformmq.AtLeastOnce, Handler: func(context.Context, platformmq.Message) { called <- struct{}{} }}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !r.pending[ProviderEMQX] {
|
||||||
|
t.Fatal("offline subscription was not marked pending")
|
||||||
|
}
|
||||||
|
r.clients[ProviderEMQX] = client
|
||||||
|
if err = r.reconcileProviderLocked(ProviderEMQX); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(client.subscribed) != 1 || client.subscribed[0] != "orders.created" {
|
||||||
|
t.Fatalf("restored subscriptions = %v", client.subscribed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadableDispatchesSameTopicToMultipleOwners(t *testing.T) {
|
||||||
|
client := &fakeClient{}
|
||||||
|
r := &Reloadable{
|
||||||
|
clients: map[string]platformmq.Client{ProviderEMQX: client},
|
||||||
|
configs: map[string]runtimeconfig.Config{ProviderEMQX: {Kind: "mq", Provider: ProviderEMQX, Enabled: true}},
|
||||||
|
subscriptions: make(map[string]map[string]map[string]subscription),
|
||||||
|
bindings: make(map[string]map[string]byte),
|
||||||
|
pending: make(map[string]bool),
|
||||||
|
nextRetry: make(map[string]time.Time),
|
||||||
|
logger: slog.Default(),
|
||||||
|
}
|
||||||
|
first, second := make(chan struct{}, 1), make(chan struct{}, 1)
|
||||||
|
for _, set := range []platformmq.SubscriptionSet{
|
||||||
|
{Owner: "orders", Provider: ProviderEMQX, Topics: []platformmq.TopicSubscription{{Topic: "events.created", Handler: func(context.Context, platformmq.Message) { first <- struct{}{} }}}},
|
||||||
|
{Owner: "audit", Provider: ProviderEMQX, Topics: []platformmq.TopicSubscription{{Topic: "events.created", Handler: func(context.Context, platformmq.Message) { second <- struct{}{} }}}},
|
||||||
|
} {
|
||||||
|
if err := r.Register(set); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := r.reconcileProviderLocked(ProviderEMQX); err != nil {
|
||||||
|
t.Fatalf("explicit reconcile failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(client.subscribed) != 1 {
|
||||||
|
t.Fatalf("broker subscriptions = %v, want one shared topic; desired=%#v bindings=%#v clients=%#v", client.subscribed, r.subscriptions, r.bindings, r.clients)
|
||||||
|
}
|
||||||
|
client.handlers["events.created"](context.Background(), platformmq.Message{Topic: "events.created"})
|
||||||
|
select {
|
||||||
|
case <-first:
|
||||||
|
default:
|
||||||
|
t.Fatal("orders handler was not called")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-second:
|
||||||
|
default:
|
||||||
|
t.Fatal("audit handler was not called")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ var ProviderSet = wire.NewSet(
|
||||||
mqintegration.New,
|
mqintegration.New,
|
||||||
wire.Bind(new(mq.Client), new(*mqintegration.Reloadable)),
|
wire.Bind(new(mq.Client), new(*mqintegration.Reloadable)),
|
||||||
wire.Bind(new(mq.Registry), new(*mqintegration.Reloadable)),
|
wire.Bind(new(mq.Registry), new(*mqintegration.Reloadable)),
|
||||||
|
wire.Bind(new(mq.SubscriptionRegistrar), new(*mqintegration.Reloadable)),
|
||||||
websocketintegration.New,
|
websocketintegration.New,
|
||||||
wire.Bind(new(platformws.Hub), new(*websocketintegration.Server)),
|
wire.Bind(new(platformws.Hub), new(*websocketintegration.Server)),
|
||||||
wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)),
|
wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)),
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ type Config struct {
|
||||||
KeepAlive time.Duration
|
KeepAlive time.Duration
|
||||||
CleanSession bool
|
CleanSession bool
|
||||||
ConnectTimeout time.Duration
|
ConnectTimeout time.Duration
|
||||||
|
ReconnectInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func PublishJSON(ctx context.Context, client Client, topic string, value any, qos byte, retain bool) error {
|
func PublishJSON(ctx context.Context, client Client, topic string, value any, qos byte, retain bool) error {
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,12 @@ func NewMQTT(cfg Config) (*MQTT, error) {
|
||||||
if cfg.ConnectTimeout <= 0 {
|
if cfg.ConnectTimeout <= 0 {
|
||||||
cfg.ConnectTimeout = 10 * time.Second
|
cfg.ConnectTimeout = 10 * time.Second
|
||||||
}
|
}
|
||||||
|
if cfg.ReconnectInterval <= 0 {
|
||||||
|
cfg.ReconnectInterval = 5 * time.Second
|
||||||
|
}
|
||||||
opts := paho.NewClientOptions().AddBroker(cfg.Broker).SetClientID(cfg.ClientID).SetUsername(cfg.Username).SetPassword(cfg.Password)
|
opts := paho.NewClientOptions().AddBroker(cfg.Broker).SetClientID(cfg.ClientID).SetUsername(cfg.Username).SetPassword(cfg.Password)
|
||||||
opts.SetKeepAlive(cfg.KeepAlive).SetCleanSession(cfg.CleanSession).SetConnectTimeout(cfg.ConnectTimeout).SetAutoReconnect(true)
|
opts.SetKeepAlive(cfg.KeepAlive).SetCleanSession(cfg.CleanSession).SetConnectTimeout(cfg.ConnectTimeout).SetAutoReconnect(true)
|
||||||
opts.SetResumeSubs(true).SetOrderMatters(false)
|
opts.SetMaxReconnectInterval(cfg.ReconnectInterval).SetResumeSubs(true).SetOrderMatters(false)
|
||||||
c := &MQTT{}
|
c := &MQTT{}
|
||||||
token := paho.NewClient(opts)
|
token := paho.NewClient(opts)
|
||||||
connect := token.Connect()
|
connect := token.Connect()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
|
@ -27,6 +28,7 @@ type RabbitMQConfig struct {
|
||||||
PrefetchCount int
|
PrefetchCount int
|
||||||
Heartbeat time.Duration
|
Heartbeat time.Duration
|
||||||
ConnectTimeout time.Duration
|
ConnectTimeout time.Duration
|
||||||
|
ReconnectInterval time.Duration
|
||||||
TLS bool
|
TLS bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,6 +54,7 @@ type RabbitMQ struct {
|
||||||
consuming bool
|
consuming bool
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
closed bool
|
closed bool
|
||||||
|
reconnecting atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) {
|
func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) {
|
||||||
|
|
@ -75,7 +78,13 @@ func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) {
|
||||||
}.String()
|
}.String()
|
||||||
connection, err := amqp.DialConfig(address, amqp.Config{
|
connection, err := amqp.DialConfig(address, amqp.Config{
|
||||||
Heartbeat: config.Heartbeat,
|
Heartbeat: config.Heartbeat,
|
||||||
Recovery: &amqp.Recovery{},
|
Recovery: &amqp.Recovery{
|
||||||
|
ReconnectionConfig: &amqp.ReconnectionConfig{MaxRetryCount: 5, RetryInterval: config.ReconnectInterval},
|
||||||
|
OnTopologyEntityError: func(_ *amqp.Connection, entity amqp.TopologyRecoveryEntity) bool {
|
||||||
|
amqp.Logger.Printf("rabbitmq topology recovery failed: %s", entity.Error())
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("connect rabbitmq: %w", err)
|
return nil, fmt.Errorf("connect rabbitmq: %w", err)
|
||||||
|
|
@ -116,6 +125,9 @@ func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) {
|
||||||
client.consumeChannel = consumeChannel
|
client.consumeChannel = consumeChannel
|
||||||
client.config = config
|
client.config = config
|
||||||
client.consumerTag = fmt.Sprintf("kra-%d", time.Now().UnixNano())
|
client.consumerTag = fmt.Sprintf("kra-%d", time.Now().UnixNano())
|
||||||
|
stateChanges := make(chan *amqp.StateChanged, 16)
|
||||||
|
connection.NotifyStateChange(stateChanges)
|
||||||
|
go client.watchState(stateChanges)
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,6 +160,9 @@ func defaultRabbitMQConfig(config RabbitMQConfig) RabbitMQConfig {
|
||||||
if config.ConnectTimeout <= 0 {
|
if config.ConnectTimeout <= 0 {
|
||||||
config.ConnectTimeout = 10 * time.Second
|
config.ConnectTimeout = 10 * time.Second
|
||||||
}
|
}
|
||||||
|
if config.ReconnectInterval <= 0 {
|
||||||
|
config.ReconnectInterval = 5 * time.Second
|
||||||
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,7 +386,28 @@ func (c *RabbitMQ) Connected() bool {
|
||||||
}
|
}
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return !c.closed && c.connection != nil && !c.connection.IsClosed()
|
return !c.closed && !c.reconnecting.Load() && c.connection != nil && !c.connection.IsClosed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnecting reports whether the AMQP driver is actively recovering the
|
||||||
|
// current connection. The outer runtime waits for this state to settle before
|
||||||
|
// deciding whether a fresh client must be built.
|
||||||
|
func (c *RabbitMQ) Reconnecting() bool {
|
||||||
|
return c != nil && !c.closed && c.reconnecting.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RabbitMQ) watchState(states <-chan *amqp.StateChanged) {
|
||||||
|
for state := range states {
|
||||||
|
if state == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch state.To {
|
||||||
|
case amqp.StateReconnecting:
|
||||||
|
c.reconnecting.Store(true)
|
||||||
|
case amqp.StateOpen, amqp.StateClosed:
|
||||||
|
c.reconnecting.Store(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *RabbitMQ) Close() error {
|
func (c *RabbitMQ) Close() error {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
package mq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeSubscriptionSetValidDeclaration(t *testing.T) {
|
||||||
|
handler := func(context.Context, Message) {}
|
||||||
|
set := SubscriptionSet{
|
||||||
|
Owner: " orders ",
|
||||||
|
Provider: " RABBITMQ ",
|
||||||
|
Topics: []TopicSubscription{
|
||||||
|
{Topic: " orders.created ", QoS: AtMostOnce, Handler: handler},
|
||||||
|
{Topic: "orders.updated", QoS: AtLeastOnce, Handler: handler},
|
||||||
|
{Topic: "orders.deleted", QoS: ExactlyOnce, Handler: handler},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := NormalizeSubscriptionSet(set)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NormalizeSubscriptionSet() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := SubscriptionSet{
|
||||||
|
Owner: "orders",
|
||||||
|
Provider: ProviderRabbitMQ,
|
||||||
|
Topics: []TopicSubscription{
|
||||||
|
{Topic: "orders.created", QoS: AtMostOnce, Handler: handler},
|
||||||
|
{Topic: "orders.updated", QoS: AtLeastOnce, Handler: handler},
|
||||||
|
{Topic: "orders.deleted", QoS: ExactlyOnce, Handler: handler},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got.Owner != want.Owner || got.Provider != want.Provider {
|
||||||
|
t.Fatalf("normalized identity = %#v, want owner=%q provider=%q", got, want.Owner, want.Provider)
|
||||||
|
}
|
||||||
|
if len(got.Topics) != len(want.Topics) {
|
||||||
|
t.Fatalf("normalized topic count = %d, want %d", len(got.Topics), len(want.Topics))
|
||||||
|
}
|
||||||
|
for index := range want.Topics {
|
||||||
|
if got.Topics[index].Topic != want.Topics[index].Topic {
|
||||||
|
t.Errorf("topic[%d] = %q, want %q", index, got.Topics[index].Topic, want.Topics[index].Topic)
|
||||||
|
}
|
||||||
|
if got.Topics[index].QoS != want.Topics[index].QoS {
|
||||||
|
t.Errorf("qos[%d] = %d, want %d", index, got.Topics[index].QoS, want.Topics[index].QoS)
|
||||||
|
}
|
||||||
|
if reflect.ValueOf(got.Topics[index].Handler).Pointer() != reflect.ValueOf(want.Topics[index].Handler).Pointer() {
|
||||||
|
t.Errorf("handler[%d] was changed", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeSubscriptionSetRejectsInvalidDeclarations(t *testing.T) {
|
||||||
|
handler := func(context.Context, Message) {}
|
||||||
|
base := func() SubscriptionSet {
|
||||||
|
return SubscriptionSet{
|
||||||
|
Owner: "orders",
|
||||||
|
Provider: ProviderEMQX,
|
||||||
|
Topics: []TopicSubscription{
|
||||||
|
{Topic: "orders.created", QoS: AtLeastOnce, Handler: handler},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set SubscriptionSet
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty owner",
|
||||||
|
set: func() SubscriptionSet { set := base(); set.Owner = " "; return set }(),
|
||||||
|
wantErr: "owner is empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty provider",
|
||||||
|
set: func() SubscriptionSet { set := base(); set.Provider = " "; return set }(),
|
||||||
|
wantErr: "unsupported mq provider",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported provider",
|
||||||
|
set: func() SubscriptionSet { set := base(); set.Provider = "kafka"; return set }(),
|
||||||
|
wantErr: "unsupported mq provider",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty topics",
|
||||||
|
set: func() SubscriptionSet { set := base(); set.Topics = nil; return set }(),
|
||||||
|
wantErr: "topics are empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty topic",
|
||||||
|
set: func() SubscriptionSet {
|
||||||
|
set := base()
|
||||||
|
set.Topics[0].Topic = " "
|
||||||
|
return set
|
||||||
|
}(),
|
||||||
|
wantErr: "topic at index 0 is empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate topic",
|
||||||
|
set: func() SubscriptionSet {
|
||||||
|
set := base()
|
||||||
|
set.Topics = append(set.Topics, TopicSubscription{
|
||||||
|
Topic: " orders.created ", QoS: AtMostOnce, Handler: handler,
|
||||||
|
})
|
||||||
|
return set
|
||||||
|
}(),
|
||||||
|
wantErr: "duplicate mq subscription topic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil handler",
|
||||||
|
set: func() SubscriptionSet {
|
||||||
|
set := base()
|
||||||
|
set.Topics[0].Handler = nil
|
||||||
|
return set
|
||||||
|
}(),
|
||||||
|
wantErr: "handler is nil",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid qos",
|
||||||
|
set: func() SubscriptionSet {
|
||||||
|
set := base()
|
||||||
|
set.Topics[0].QoS = ExactlyOnce + 1
|
||||||
|
return set
|
||||||
|
}(),
|
||||||
|
wantErr: "invalid mq qos",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := NormalizeSubscriptionSet(test.set)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("NormalizeSubscriptionSet() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.wantErr) {
|
||||||
|
t.Fatalf("error = %q, want substring %q", err, test.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscriptionTestRegistrar struct {
|
||||||
|
sets []SubscriptionSet
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *subscriptionTestRegistrar) Register(set SubscriptionSet) error {
|
||||||
|
r.sets = append(r.sets, set)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*subscriptionTestRegistrar) Unregister(string) error { return nil }
|
||||||
|
|
||||||
|
type subscriptionTestContributor struct {
|
||||||
|
name string
|
||||||
|
set SubscriptionSet
|
||||||
|
order *[]string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *subscriptionTestContributor) RegisterSubscriptions(registrar SubscriptionRegistrar) error {
|
||||||
|
*c.order = append(*c.order, c.name)
|
||||||
|
if c.err != nil {
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
return registrar.Register(c.set)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplySubscriptionsAppliesMultipleContributorsInOrder(t *testing.T) {
|
||||||
|
order := make([]string, 0, 2)
|
||||||
|
registrar := &subscriptionTestRegistrar{}
|
||||||
|
handler := func(context.Context, Message) {}
|
||||||
|
first := &subscriptionTestContributor{
|
||||||
|
name: "orders",
|
||||||
|
order: &order,
|
||||||
|
set: SubscriptionSet{
|
||||||
|
Owner: "orders",
|
||||||
|
Provider: ProviderRabbitMQ,
|
||||||
|
Topics: []TopicSubscription{{Topic: "orders.created", Handler: handler}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
second := &subscriptionTestContributor{
|
||||||
|
name: "notifications",
|
||||||
|
order: &order,
|
||||||
|
set: SubscriptionSet{
|
||||||
|
Owner: "notifications",
|
||||||
|
Provider: ProviderEMQX,
|
||||||
|
Topics: []TopicSubscription{{Topic: "notifications.sent", Handler: handler}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ApplySubscriptions(registrar, first, nil, second); err != nil {
|
||||||
|
t.Fatalf("ApplySubscriptions() error = %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(order, []string{"orders", "notifications"}) {
|
||||||
|
t.Fatalf("contributor order = %#v, want %#v", order, []string{"orders", "notifications"})
|
||||||
|
}
|
||||||
|
if len(registrar.sets) != 2 {
|
||||||
|
t.Fatalf("registered sets = %d, want 2", len(registrar.sets))
|
||||||
|
}
|
||||||
|
if registrar.sets[0].Owner != "orders" || registrar.sets[1].Owner != "notifications" {
|
||||||
|
t.Fatalf("registered owners = %q, %q", registrar.sets[0].Owner, registrar.sets[1].Owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplySubscriptionsPropagatesContributorError(t *testing.T) {
|
||||||
|
order := make([]string, 0, 2)
|
||||||
|
registrar := &subscriptionTestRegistrar{}
|
||||||
|
wantErr := errors.New("registration failed")
|
||||||
|
failing := &subscriptionTestContributor{name: "failing", order: &order, err: wantErr}
|
||||||
|
following := &subscriptionTestContributor{name: "following", order: &order}
|
||||||
|
|
||||||
|
err := ApplySubscriptions(registrar, failing, following)
|
||||||
|
if !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("ApplySubscriptions() error = %v, want %v", err, wantErr)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(order, []string{"failing"}) {
|
||||||
|
t.Fatalf("contributors called = %#v, want %#v", order, []string{"failing"})
|
||||||
|
}
|
||||||
|
if len(registrar.sets) != 0 {
|
||||||
|
t.Fatalf("registered sets = %d, want 0", len(registrar.sets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplySubscriptionsRejectsNilRegistrar(t *testing.T) {
|
||||||
|
if err := ApplySubscriptions(nil); err == nil || !strings.Contains(err.Error(), "registrar is nil") {
|
||||||
|
t.Fatalf("ApplySubscriptions(nil) error = %v, want nil-registrar error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -116,6 +116,7 @@
|
||||||
class="field-control"
|
class="field-control"
|
||||||
:min="numberConstraint(field.key).min"
|
:min="numberConstraint(field.key).min"
|
||||||
:max="numberConstraint(field.key).max"
|
:max="numberConstraint(field.key).max"
|
||||||
|
:precision="numberConstraint(field.key).integer ? 0 : undefined"
|
||||||
:step="1"
|
:step="1"
|
||||||
controls-position="right"
|
controls-position="right"
|
||||||
@update:model-value="clearFieldError(selected, field.key)"
|
@update:model-value="clearFieldError(selected, field.key)"
|
||||||
|
|
@ -231,10 +232,21 @@ const TARGETS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const TARGET_ORDER = Object.keys(TARGETS)
|
const TARGET_ORDER = Object.keys(TARGETS)
|
||||||
|
const RECONNECT_INTERVAL_KEY = 'reconnect_interval'
|
||||||
|
const RECONNECT_DEFAULT_SECONDS = 5
|
||||||
|
const MQ_RECONNECT_TARGETS = new Set(['mq/emqx', 'mq/rabbitmq'])
|
||||||
|
const RECONNECT_INTERVAL_FIELD = {
|
||||||
|
key: RECONNECT_INTERVAL_KEY,
|
||||||
|
label: '重连间隔(秒)',
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
description: '连接中断后再次尝试连接的等待时间,最小 1 秒。'
|
||||||
|
}
|
||||||
const NUMBER_CONSTRAINTS = {
|
const NUMBER_CONSTRAINTS = {
|
||||||
port: { min: 1, max: 65535 },
|
port: { min: 1, max: 65535 },
|
||||||
keep_alive: { min: 1 },
|
keep_alive: { min: 1 },
|
||||||
connect_timeout: { min: 1 },
|
connect_timeout: { min: 1 },
|
||||||
|
reconnect_interval: { min: 1, integer: true },
|
||||||
prefetch_count: { min: 0 },
|
prefetch_count: { min: 0 },
|
||||||
heartbeat: { min: 0 },
|
heartbeat: { min: 0 },
|
||||||
max_message_size: { min: 0 },
|
max_message_size: { min: 0 },
|
||||||
|
|
@ -264,13 +276,56 @@ const errorKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
|
||||||
const listKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
|
const listKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
|
||||||
const cloneConfig = (value) => JSON.parse(JSON.stringify(value || {}))
|
const cloneConfig = (value) => JSON.parse(JSON.stringify(value || {}))
|
||||||
|
|
||||||
|
const communicationFields = (item) => {
|
||||||
|
const fields = Array.isArray(item.fields)
|
||||||
|
? item.fields.map((field) =>
|
||||||
|
field.key === RECONNECT_INTERVAL_KEY
|
||||||
|
? {
|
||||||
|
...field,
|
||||||
|
label: field.label || RECONNECT_INTERVAL_FIELD.label,
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
description: field.description || RECONNECT_INTERVAL_FIELD.description
|
||||||
|
}
|
||||||
|
: { ...field }
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
if (
|
||||||
|
!MQ_RECONNECT_TARGETS.has(integrationKey(item)) ||
|
||||||
|
fields.some((field) => field.key === RECONNECT_INTERVAL_KEY)
|
||||||
|
) {
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectTimeoutIndex = fields.findIndex(
|
||||||
|
(field) => field.key === 'connect_timeout'
|
||||||
|
)
|
||||||
|
fields.splice(
|
||||||
|
connectTimeoutIndex < 0 ? fields.length : connectTimeoutIndex + 1,
|
||||||
|
0,
|
||||||
|
{ ...RECONNECT_INTERVAL_FIELD }
|
||||||
|
)
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeIntegration = (item) => {
|
const normalizeIntegration = (item) => {
|
||||||
|
const config = cloneConfig(item.config)
|
||||||
|
const fields = communicationFields(item)
|
||||||
|
if (
|
||||||
|
MQ_RECONNECT_TARGETS.has(integrationKey(item)) &&
|
||||||
|
(config[RECONNECT_INTERVAL_KEY] === null ||
|
||||||
|
typeof config[RECONNECT_INTERVAL_KEY] === 'undefined' ||
|
||||||
|
config[RECONNECT_INTERVAL_KEY] === '')
|
||||||
|
) {
|
||||||
|
config[RECONNECT_INTERVAL_KEY] = RECONNECT_DEFAULT_SECONDS
|
||||||
|
}
|
||||||
|
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...item,
|
...item,
|
||||||
enabled: Boolean(item.enabled),
|
enabled: Boolean(item.enabled),
|
||||||
configured: Boolean(item.configured),
|
configured: Boolean(item.configured),
|
||||||
config: cloneConfig(item.config),
|
config,
|
||||||
fields: Array.isArray(item.fields) ? item.fields : []
|
fields
|
||||||
}
|
}
|
||||||
normalized._savedEnabled = normalized.enabled
|
normalized._savedEnabled = normalized.enabled
|
||||||
normalized._savedConfig = cloneConfig(normalized.config)
|
normalized._savedConfig = cloneConfig(normalized.config)
|
||||||
|
|
@ -359,6 +414,8 @@ const validate = (item, enabled = item.enabled) => {
|
||||||
const constraint = numberConstraint(field.key)
|
const constraint = numberConstraint(field.key)
|
||||||
if (!Number.isFinite(number)) {
|
if (!Number.isFinite(number)) {
|
||||||
message = `${field.label}必须是数字`
|
message = `${field.label}必须是数字`
|
||||||
|
} else if (constraint.integer && !Number.isInteger(number)) {
|
||||||
|
message = `${field.label}必须是整数`
|
||||||
} else if (constraint.min !== undefined && number < constraint.min) {
|
} else if (constraint.min !== undefined && number < constraint.min) {
|
||||||
message = `${field.label}不能小于 ${constraint.min}`
|
message = `${field.label}不能小于 ${constraint.min}`
|
||||||
} else if (constraint.max !== undefined && number > constraint.max) {
|
} else if (constraint.max !== undefined && number > constraint.max) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue