222 lines
7.2 KiB
Go
222 lines
7.2 KiB
Go
package upstream
|
|
|
|
import (
|
|
"cmpp-platform/gateway/internal/queue"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultConnectTimeout = 5 * time.Second
|
|
defaultSubmitTimeout = 10 * time.Second
|
|
defaultHTTPTimeout = 10 * time.Second
|
|
defaultWindowSize = 16
|
|
defaultHeartbeatInterval = 30 * time.Second
|
|
defaultHeartbeatMissThreshold = 3
|
|
defaultReconnectInitialDelay = 5 * time.Second
|
|
defaultReconnectMaximumDelay = 5 * time.Minute
|
|
defaultAuthReconnectDelay = 5 * time.Minute
|
|
)
|
|
|
|
type Manager struct {
|
|
APIBaseURL string
|
|
HTTPClient *http.Client
|
|
SubmitSegmentPublisher SubmitSegmentPublisher
|
|
|
|
mu sync.Mutex
|
|
conns map[string]*connectionPool
|
|
}
|
|
|
|
// SubmitSegmentPublisher persists each supplier response before the next long-message
|
|
// segment is sent. The boundary is intentionally storage-only: HTTP callbacks belong to
|
|
// the result Outbox worker and must not consume a supplier Submit window slot.
|
|
type SubmitSegmentPublisher interface {
|
|
PublishSubmitSegment(context.Context, queue.SubmitCommand, queue.SubmitSegmentResult) error
|
|
}
|
|
|
|
type ConnectionState struct {
|
|
ChannelID string `json:"channelId"`
|
|
ConnectionID string `json:"connectionId"`
|
|
Status string `json:"status"`
|
|
DesiredConnections int `json:"desiredConnections"`
|
|
CurrentConnections int `json:"currentConnections"`
|
|
LastConnectedAt string `json:"lastConnectedAt,omitempty"`
|
|
LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"`
|
|
LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"`
|
|
ReconnectCount int `json:"reconnectCount,omitempty"`
|
|
LastReconnectAttemptAt string `json:"lastReconnectAttemptAt,omitempty"`
|
|
NextReconnectAt string `json:"nextReconnectAt,omitempty"`
|
|
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
|
|
LastError string `json:"lastError,omitempty"`
|
|
}
|
|
|
|
func (m *Manager) ConnectChannel(ctx context.Context, command queue.ConnectChannelCommand) (ConnectionState, error) {
|
|
if err := validateConnectChannelCommand(command); err != nil {
|
|
return ConnectionState{}, err
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.ensureDefaultsLocked()
|
|
pool := m.conns[command.ChannelID]
|
|
config := normalizeUpstreamConfig(queue.UpstreamConfig{
|
|
GatewayHost: command.Channel.GatewayHost,
|
|
GatewayPort: command.Channel.GatewayPort,
|
|
Account: command.Channel.Account,
|
|
PasswordCipher: command.Channel.PasswordCipher,
|
|
CMPPVersion: command.Channel.CMPPVersion,
|
|
DesiredConnections: command.DesiredConnections,
|
|
WindowSize: command.Channel.WindowSize,
|
|
HeartbeatIntervalSeconds: command.Channel.HeartbeatIntervalSeconds,
|
|
HeartbeatMissThreshold: command.Channel.HeartbeatMissThreshold,
|
|
})
|
|
if pool == nil || !pool.matches(config) {
|
|
if pool != nil {
|
|
pool.close()
|
|
}
|
|
pool = m.newConnectionPool(command.ChannelID, command.ConnectionID, config)
|
|
m.conns[command.ChannelID] = pool
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
pool.startSupervisor()
|
|
if err := pool.ensureConnected(); err != nil {
|
|
if pool.stopped() {
|
|
return pool.snapshotState("disconnected", nil), nil
|
|
}
|
|
pool.scheduleReconnect(err)
|
|
_ = pool.reportState(ctx, "failed", err)
|
|
return pool.snapshotState("failed", err), nil
|
|
}
|
|
if pool.stopped() {
|
|
return pool.snapshotState("disconnected", nil), nil
|
|
}
|
|
pool.resetReconnectState()
|
|
return pool.snapshotState("connected", nil), nil
|
|
}
|
|
|
|
func (m *Manager) DisconnectChannel(ctx context.Context, command queue.DisconnectChannelCommand) (ConnectionState, error) {
|
|
if command.ChannelID == "" || command.ConnectionID == "" {
|
|
return ConnectionState{}, fmt.Errorf("channelId and connectionId are required")
|
|
}
|
|
m.mu.Lock()
|
|
m.ensureDefaultsLocked()
|
|
pool := m.conns[command.ChannelID]
|
|
delete(m.conns, command.ChannelID)
|
|
m.mu.Unlock()
|
|
if pool != nil {
|
|
pool.close()
|
|
state := pool.snapshotState("disconnected", nil)
|
|
_ = pool.reportState(ctx, "disconnected", nil)
|
|
return state, nil
|
|
}
|
|
return ConnectionState{
|
|
ChannelID: command.ChannelID,
|
|
ConnectionID: command.ConnectionID,
|
|
Status: "disconnected",
|
|
DesiredConnections: 0,
|
|
CurrentConnections: 0,
|
|
LastDisconnectedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
}, nil
|
|
}
|
|
|
|
func (m *Manager) connectionFor(cmd queue.SubmitCommand) (*connectionPool, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.ensureDefaultsLocked()
|
|
|
|
pool := m.conns[cmd.ChannelID]
|
|
if pool == nil || !pool.matches(cmd.Upstream) {
|
|
if pool != nil {
|
|
pool.close()
|
|
}
|
|
pool = m.newConnectionPool(cmd.ChannelID, defaultChannelConnectionID(cmd.ChannelID), normalizeUpstreamConfig(cmd.Upstream))
|
|
m.conns[cmd.ChannelID] = pool
|
|
}
|
|
pool.startSupervisor()
|
|
if err := pool.ensureConnected(); err != nil {
|
|
pool.scheduleReconnect(err)
|
|
return nil, err
|
|
}
|
|
pool.resetReconnectState()
|
|
return pool, nil
|
|
}
|
|
|
|
func (m *Manager) ensureDefaultsLocked() {
|
|
if m.HTTPClient == nil {
|
|
m.HTTPClient = &http.Client{Timeout: defaultHTTPTimeout}
|
|
}
|
|
if m.conns == nil {
|
|
m.conns = make(map[string]*connectionPool)
|
|
}
|
|
}
|
|
|
|
// ConnectionCounts returns bounded platform totals without channel identifiers to prevent time-series cardinality growth.
|
|
func (m *Manager) ConnectionCounts() (desired int, connected int) {
|
|
m.mu.Lock()
|
|
pools := make([]*connectionPool, 0, len(m.conns))
|
|
for _, pool := range m.conns {
|
|
pools = append(pools, pool)
|
|
}
|
|
m.mu.Unlock()
|
|
for _, pool := range pools {
|
|
desired += max(pool.config.DesiredConnections, 1)
|
|
connected += pool.countActiveConnections()
|
|
}
|
|
return desired, connected
|
|
}
|
|
|
|
func (m *Manager) newConnectionPool(channelID string, connectionID string, config queue.UpstreamConfig) *connectionPool {
|
|
return &connectionPool{
|
|
channelID: channelID,
|
|
connectionID: connectionID,
|
|
config: config,
|
|
apiBaseURL: m.APIBaseURL,
|
|
httpClient: m.HTTPClient,
|
|
reporter: func(ctx context.Context, state ConnectionState) error {
|
|
return m.post(ctx, "/admin/gateway/connections", state)
|
|
},
|
|
reconnectSignal: make(chan struct{}, 1),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func validateConnectChannelCommand(command queue.ConnectChannelCommand) error {
|
|
if command.MessageType != queue.MessageTypeConnectChannel {
|
|
return fmt.Errorf("unsupported messageType %q", command.MessageType)
|
|
}
|
|
if command.ChannelID == "" || command.ConnectionID == "" {
|
|
return fmt.Errorf("channelId and connectionId are required")
|
|
}
|
|
if command.Channel.GatewayHost == "" || command.Channel.GatewayPort <= 0 {
|
|
return fmt.Errorf("gatewayHost and gatewayPort are required")
|
|
}
|
|
if command.Channel.Account == "" || command.Channel.PasswordCipher == "" {
|
|
return fmt.Errorf("account and passwordCipher are required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func defaultChannelConnectionID(channelID string) string {
|
|
return fmt.Sprintf("%s:primary", channelID)
|
|
}
|
|
|
|
func normalizeUpstreamConfig(config queue.UpstreamConfig) queue.UpstreamConfig {
|
|
if config.DesiredConnections <= 0 {
|
|
config.DesiredConnections = 1
|
|
}
|
|
if config.WindowSize <= 0 {
|
|
config.WindowSize = defaultWindowSize
|
|
}
|
|
if config.HeartbeatIntervalSeconds <= 0 {
|
|
config.HeartbeatIntervalSeconds = int(defaultHeartbeatInterval / time.Second)
|
|
}
|
|
if config.HeartbeatMissThreshold <= 0 {
|
|
config.HeartbeatMissThreshold = defaultHeartbeatMissThreshold
|
|
}
|
|
return config
|
|
}
|