perf: expand gateway capacity and prevent receipt replay

This commit is contained in:
hectorzhao
2026-08-25 16:08:30 +08:00
parent 761c123b65
commit 9292352be1
48 changed files with 2001 additions and 144 deletions
+14
View File
@@ -41,6 +41,10 @@ type ChannelConfig struct {
WindowSize int `json:"windowSize,omitempty"`
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds,omitempty"`
HeartbeatMissThreshold int `json:"heartbeatMissThreshold,omitempty"`
ConnectionWarmupSeconds int `json:"connectionWarmupSeconds,omitempty"`
ConnectionDrainSeconds int `json:"connectionDrainTimeoutSeconds,omitempty"`
SubmitTimeoutSeconds int `json:"submitResponseTimeoutSeconds,omitempty"`
FailureCooldownSeconds int `json:"connectionFailureCooldownSeconds,omitempty"`
}
type DisconnectChannelCommand struct {
@@ -351,6 +355,10 @@ func (s Server) connectChannel(ctx context.Context, command ConnectChannelComman
WindowSize: command.Channel.WindowSize,
HeartbeatIntervalSeconds: command.Channel.HeartbeatIntervalSeconds,
HeartbeatMissThreshold: command.Channel.HeartbeatMissThreshold,
ConnectionWarmupSeconds: command.Channel.ConnectionWarmupSeconds,
ConnectionDrainSeconds: command.Channel.ConnectionDrainSeconds,
SubmitTimeoutSeconds: command.Channel.SubmitTimeoutSeconds,
FailureCooldownSeconds: command.Channel.FailureCooldownSeconds,
},
})
if err != nil {
@@ -411,6 +419,12 @@ func validateConnectChannelCommand(command ConnectChannelCommand) error {
if command.Channel.Account == "" || command.Channel.PasswordCipher == "" {
return fmt.Errorf("account and passwordCipher are required")
}
if command.DesiredConnections < 1 || command.DesiredConnections > 8 {
return fmt.Errorf("desiredConnections must be between 1 and 8")
}
if command.Channel.WindowSize != 0 && (command.Channel.WindowSize < 1 || command.Channel.WindowSize > 64) {
return fmt.Errorf("windowSize must be between 1 and 64")
}
return nil
}
+16
View File
@@ -140,6 +140,22 @@ func TestConnectChannelRejectsInvalidCommand(t *testing.T) {
}
}
func TestConnectChannelRejectsCapacityOutsideSupportedMatrix(t *testing.T) {
handler := handlerWithConnect(func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error) {
return ConnectionStateCallback{}, nil
})
for _, body := range []string{
`{"schemaVersion":"v1","messageType":"ConnectChannel","channelId":"c","connectionId":"x","desiredConnections":9,"channel":{"gatewayHost":"127.0.0.1","gatewayPort":17890,"account":"a","passwordCipher":"p","windowSize":16}}`,
`{"schemaVersion":"v1","messageType":"ConnectChannel","channelId":"c","connectionId":"x","desiredConnections":1,"channel":{"gatewayHost":"127.0.0.1","gatewayPort":17890,"account":"a","passwordCipher":"p","windowSize":65}}`,
} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/connections/connect", strings.NewReader(body)))
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
}
func TestDisconnectChannelStopsSupplierPool(t *testing.T) {
var received DisconnectChannelCommand
handler := handlerWithServer(Server{
+6 -5
View File
@@ -18,6 +18,7 @@ const defaultDownstreamAckTimeout = 30 * time.Second
type downstreamAckTracker struct {
deliveryID string
claimID string
connectionID string
sequenceID uint32
messageID uint64
@@ -69,29 +70,29 @@ func downstreamAckKey(conn *cmpp.Conn, sequenceID uint32) string {
return fmt.Sprintf("%p:%d", conn, sequenceID)
}
func registerDownstreamAck(session *downstreamSession, deliveryID string, sequenceID uint32, messageID uint64, deadline time.Time) *downstreamAckTracker {
func registerDownstreamAck(session *downstreamSession, deliveryID string, claimID string, sequenceID uint32, messageID uint64, deadline time.Time) *downstreamAckTracker {
if session == nil || session.conn == nil || strings.TrimSpace(deliveryID) == "" {
return nil
}
tracker := &downstreamAckTracker{
deliveryID: deliveryID, connectionID: session.connectionID,
deliveryID: deliveryID, claimID: claimID, connectionID: session.connectionID,
sequenceID: sequenceID, messageID: messageID, session: session,
}
key := downstreamAckKey(session.conn, sequenceID)
downstreamAckRegistry.Lock()
downstreamAckRegistry.items[key] = tracker
downstreamAckRegistry.Unlock()
tracker.timer = time.AfterFunc(time.Until(deadline), func() {
timedOut := takeDownstreamAck(session.conn, sequenceID)
if timedOut == nil || timedOut.session == nil || timedOut.session.deliveryReport == nil {
return
}
timedOut.session.deliveryReport(downstreamDeliveryLifecycleEvent{
Kind: "failed", DeliveryID: timedOut.deliveryID, ConnectionID: timedOut.connectionID,
Kind: "failed", DeliveryID: timedOut.deliveryID, ClaimID: timedOut.claimID, ConnectionID: timedOut.connectionID,
SequenceID: timedOut.sequenceID, MessageID: timedOut.messageID, ObservedAt: time.Now().UTC(),
FailureType: "ack_timeout", ErrorMessage: "CMPP_DELIVER_RESP timeout",
})
})
downstreamAckRegistry.Unlock()
return tracker
}
@@ -149,7 +150,7 @@ func handleDownstreamAcknowledgement(conn *cmpp.Conn, sequenceID uint32, message
}
if tracker.session != nil && tracker.session.deliveryReport != nil {
go tracker.session.deliveryReport(downstreamDeliveryLifecycleEvent{
Kind: "acknowledged", DeliveryID: tracker.deliveryID, ConnectionID: tracker.connectionID,
Kind: "acknowledged", DeliveryID: tracker.deliveryID, ClaimID: tracker.claimID, ConnectionID: tracker.connectionID,
SequenceID: sequenceID, MessageID: messageID, Result: result, ObservedAt: time.Now().UTC(),
})
}
+9 -6
View File
@@ -16,6 +16,7 @@ import (
type DownstreamReceipt struct {
DeliveryID string `json:"deliveryId,omitempty"`
ClaimID string `json:"claimId,omitempty"`
Account string `json:"account,omitempty"`
ApplicationID string `json:"applicationId,omitempty"`
MessageID string `json:"messageId"`
@@ -31,6 +32,7 @@ type DownstreamReceipt struct {
type DownstreamUplink struct {
DeliveryID string `json:"deliveryId,omitempty"`
ClaimID string `json:"claimId,omitempty"`
Account string `json:"account,omitempty"`
ApplicationID string `json:"applicationId,omitempty"`
MessageID string `json:"messageId,omitempty"`
@@ -55,6 +57,7 @@ type DownstreamSendResult struct {
type downstreamDeliveryLifecycleEvent struct {
Kind string
DeliveryID string
ClaimID string
ConnectionID string
SequenceID uint32
MessageID uint64
@@ -70,7 +73,7 @@ func (s Server) reportDownstreamDelivery(event downstreamDeliveryLifecycleEvent)
return
}
payload := map[string]any{
"id": event.DeliveryID, "connectionId": event.ConnectionID,
"id": event.DeliveryID, "claimId": event.ClaimID, "connectionId": event.ConnectionID,
"sequenceId": strconv.FormatUint(uint64(event.SequenceID), 10),
"messageId": strconv.FormatUint(event.MessageID, 10),
}
@@ -160,7 +163,7 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
return DownstreamSendResult{}, err
}
deliver := downstreamDeliverPacket(session, receiptMessageID, session.srcID, defaultString(event.PhoneNumber, session.phoneNumber), 0, 1, string(receiptBytes))
return sendDownstream(session, deliver, event.DeliveryID)
return sendDownstream(session, deliver, event.DeliveryID, event.ClaimID)
}
func downstreamReceiptMessageID(event DownstreamReceipt, session *downstreamSession) uint64 {
@@ -228,7 +231,7 @@ func PushUplinkWithResult(event DownstreamUplink) (DownstreamSendResult, error)
0,
content,
)
return sendDownstream(session, deliver, event.DeliveryID)
return sendDownstream(session, deliver, event.DeliveryID, event.ClaimID)
}
func errorMessageWithCode(message string, code string) string {
@@ -272,7 +275,7 @@ func findSession(messageID string, account string) *downstreamSession {
return nil
}
func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID string) (DownstreamSendResult, error) {
func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID string, claimID string) (DownstreamSendResult, error) {
session.mu.Lock()
defer session.mu.Unlock()
messageID := downstreamDeliverMessageID(deliver)
@@ -289,7 +292,7 @@ func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID
SentAt: formatRFC3339Nano(sentAt),
AckDeadlineAt: formatRFC3339Nano(ackDeadlineAt),
}
tracker := registerDownstreamAck(session, deliveryID, sequenceID, messageID, ackDeadlineAt)
tracker := registerDownstreamAck(session, deliveryID, claimID, sequenceID, messageID, ackDeadlineAt)
if err := session.conn.SendPkt(deliver, sequenceID); err != nil {
removeDownstreamAck(tracker)
session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "failed", "SEND_FAILED", err)
@@ -306,7 +309,7 @@ func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID
result.Sent = true
if deliveryID != "" && session.deliveryReport != nil {
go session.deliveryReport(downstreamDeliveryLifecycleEvent{
Kind: "sent", DeliveryID: deliveryID, ConnectionID: session.connectionID,
Kind: "sent", DeliveryID: deliveryID, ClaimID: claimID, ConnectionID: session.connectionID,
SequenceID: sequenceID, MessageID: messageID, ObservedAt: sentAt, AckDeadlineAt: ackDeadlineAt,
})
}
+93 -3
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log"
"strings"
"sync"
"time"
)
@@ -15,6 +16,8 @@ import (
type pendingDeliveryRequest struct {
Account string `json:"account"`
Limit int `json:"limit,omitempty"`
ClaimID string `json:"claimId"`
LeaseMS int `json:"leaseMs"`
}
type pendingDelivery struct {
@@ -22,6 +25,7 @@ type pendingDelivery struct {
DeliveryType string `json:"deliveryType"`
Payload json.RawMessage `json:"payload"`
CreatedAt time.Time `json:"createdAt"`
ClaimID string `json:"claimId"`
}
type pendingFlushResult struct {
@@ -33,25 +37,96 @@ type pendingFlushResult struct {
LastError string
}
type pendingFlushCall struct {
done chan struct{}
result pendingFlushResult
err error
}
var pendingFlushSingleflight = struct {
sync.Mutex
byAccount map[string]*pendingFlushCall
}{byAccount: make(map[string]*pendingFlushCall)}
type pendingFlushSchedule struct {
first time.Time
timer *time.Timer
}
var pendingFlushDebouncer = struct {
sync.Mutex
byAccount map[string]*pendingFlushSchedule
}{byAccount: make(map[string]*pendingFlushSchedule)}
func (s Server) schedulePendingFlush(account string, logger *log.Logger) {
account = strings.TrimSpace(account)
if account == "" {
return
}
pendingFlushDebouncer.Lock()
if scheduled := pendingFlushDebouncer.byAccount[account]; scheduled != nil {
if time.Since(scheduled.first) < 250*time.Millisecond {
scheduled.timer.Reset(25 * time.Millisecond)
}
pendingFlushDebouncer.Unlock()
return
}
scheduled := &pendingFlushSchedule{first: time.Now()}
scheduled.timer = time.AfterFunc(25*time.Millisecond, func() {
pendingFlushDebouncer.Lock()
if pendingFlushDebouncer.byAccount[account] == scheduled {
delete(pendingFlushDebouncer.byAccount, account)
}
pendingFlushDebouncer.Unlock()
if _, err := s.flushPending(account, logger); err != nil {
logger.Printf("cmpp inbound event=scheduled_pending_flush_failed account=%s error=%q", account, err.Error())
}
})
pendingFlushDebouncer.byAccount[account] = scheduled
pendingFlushDebouncer.Unlock()
}
func (s Server) flushPending(account string, logger *log.Logger) (pendingFlushResult, error) {
account = strings.TrimSpace(account)
result := pendingFlushResult{Account: account}
if account == "" {
return result, nil
}
pendingFlushSingleflight.Lock()
if active := pendingFlushSingleflight.byAccount[account]; active != nil {
pendingFlushSingleflight.Unlock()
<-active.done
return active.result, active.err
}
call := &pendingFlushCall{done: make(chan struct{})}
pendingFlushSingleflight.byAccount[account] = call
pendingFlushSingleflight.Unlock()
call.result, call.err = s.flushPendingClaimed(account, logger)
pendingFlushSingleflight.Lock()
delete(pendingFlushSingleflight.byAccount, account)
close(call.done)
pendingFlushSingleflight.Unlock()
return call.result, call.err
}
func (s Server) flushPendingClaimed(account string, logger *log.Logger) (pendingFlushResult, error) {
result := pendingFlushResult{Account: account}
claimID := fmt.Sprintf("%s:%s:%d", s.gatewayInstanceID(), account, time.Now().UnixNano())
var deliveries []pendingDelivery
if err := s.post(context.Background(), "/gateway/events/downstream/pending", pendingDeliveryRequest{Account: account, Limit: 100}, &deliveries); err != nil {
if err := s.post(context.Background(), "/gateway/events/downstream/pending", pendingDeliveryRequest{Account: account, Limit: 100, ClaimID: claimID, LeaseMS: 30000}, &deliveries); err != nil {
logger.Printf("cmpp inbound pending delivery fetch failed account=%s err=%v", account, err)
result.LastError = err.Error()
return result, err
}
result.Deliveries = len(deliveries)
for _, delivery := range deliveries {
delivery.ClaimID = defaultString(delivery.ClaimID, claimID)
sendResult, err := s.pushPendingDelivery(account, delivery)
if err != nil {
result.FailedCount++
result.LastError = err.Error()
_ = s.post(context.Background(), "/gateway/events/downstream/failed", map[string]any{
"id": delivery.ID, "errorMessage": err.Error(), "failureType": "send_failed",
"id": delivery.ID, "claimId": delivery.ClaimID, "errorMessage": err.Error(), "failureType": "send_failed",
"connectionId": sendResult.ConnectionID, "sequenceId": sendResult.SequenceID,
"messageId": sendResult.MessageID, "sentAt": sendResult.SentAt,
}, nil)
@@ -63,6 +138,7 @@ func (s Server) flushPending(account string, logger *log.Logger) (pendingFlushRe
}
if sendResult.ReasonCode == "SUBMIT_RESPONSE_PENDING" {
result.WaitingCount++
_ = s.releasePendingClaim(delivery, sendResult)
continue
}
errorMessage := defaultString(sendResult.ErrorMessage, "gateway did not complete downstream delivery")
@@ -74,8 +150,12 @@ func (s Server) flushPending(account string, logger *log.Logger) (pendingFlushRe
result.FailedCount++
}
result.LastError = errorMessage
if sendResult.Retryable {
_ = s.releasePendingClaim(delivery, sendResult)
continue
}
_ = s.post(context.Background(), "/gateway/events/downstream/failed", map[string]any{
"id": delivery.ID, "errorMessage": errorMessageWithCode(errorMessage, sendResult.ReasonCode),
"id": delivery.ID, "claimId": delivery.ClaimID, "errorMessage": errorMessageWithCode(errorMessage, sendResult.ReasonCode),
"failureType": failureType, "connectionId": sendResult.ConnectionID,
"sequenceId": sendResult.SequenceID, "messageId": sendResult.MessageID, "sentAt": sendResult.SentAt,
}, nil)
@@ -83,6 +163,14 @@ func (s Server) flushPending(account string, logger *log.Logger) (pendingFlushRe
return result, nil
}
func (s Server) releasePendingClaim(delivery pendingDelivery, sendResult DownstreamSendResult) error {
return s.post(context.Background(), "/gateway/events/downstream/failed", map[string]any{
"id": delivery.ID, "claimId": delivery.ClaimID,
"errorMessage": errorMessageWithCode(defaultString(sendResult.ErrorMessage, "Gateway released downstream delivery claim"), sendResult.ReasonCode),
"failureType": "claim_released",
}, nil)
}
func (s Server) pushPendingDelivery(account string, delivery pendingDelivery) (DownstreamSendResult, error) {
switch delivery.DeliveryType {
case "receipt":
@@ -91,6 +179,7 @@ func (s Server) pushPendingDelivery(account string, delivery pendingDelivery) (D
return DownstreamSendResult{}, err
}
event.DeliveryID = delivery.ID
event.ClaimID = delivery.ClaimID
event.Account = defaultString(event.Account, account)
allowRecovery := !delivery.CreatedAt.IsZero() && time.Since(delivery.CreatedAt) >= 5*time.Second
return pushReceiptWithResult(event, allowRecovery)
@@ -100,6 +189,7 @@ func (s Server) pushPendingDelivery(account string, delivery pendingDelivery) (D
return DownstreamSendResult{}, err
}
event.DeliveryID = delivery.ID
event.ClaimID = delivery.ClaimID
event.Account = defaultString(event.Account, account)
return PushUplinkWithResult(event)
default:
+11 -14
View File
@@ -1,6 +1,7 @@
package inbound
import (
"cmpp-platform/gateway/internal/protocollog"
"context"
"fmt"
cmpp "github.com/bigwhite/gocmpp"
@@ -8,20 +9,7 @@ import (
"strconv"
)
type protocolLogEvent struct {
Protocol string `json:"protocol"`
Direction string `json:"direction"`
EventType string `json:"eventType"`
Status string `json:"status"`
TenantID string `json:"tenantId,omitempty"`
ApplicationID string `json:"applicationId,omitempty"`
Account string `json:"account,omitempty"`
MessageID string `json:"messageId,omitempty"`
GatewayMessageID string `json:"gatewayMessageId,omitempty"`
Phone string `json:"phone,omitempty"`
ResultCode string `json:"resultCode,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
}
type protocolLogEvent = protocollog.Event
func (s Server) submitResponseProtocolLogger(
account string,
@@ -71,6 +59,15 @@ func protocolSubmitResponseDetail(sequenceID uint32, sendErr error) map[string]a
}
func (s Server) emitProtocolLog(event protocolLogEvent) {
event.GatewayInstanceID = s.GatewayInstanceID
if s.ProtocolLogPublisher != nil {
go func() {
if err := s.ProtocolLogPublisher.Publish(context.Background(), event); err != nil {
log.Printf("cmpp inbound protocol log Redis publish failed account=%s message_id=%s error=%q", event.Account, event.MessageID, err)
}
}()
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout)
defer cancel()
+5
View File
@@ -1,6 +1,8 @@
package inbound
import (
"cmpp-platform/gateway/internal/protocollog"
"context"
cmpp "github.com/bigwhite/gocmpp"
"io"
"log"
@@ -22,6 +24,9 @@ type Server struct {
RecoveryStore RecoveryStore
GatewayInstanceID string
MaxSubmitConcurrency int
ProtocolLogPublisher interface {
Publish(context.Context, protocollog.Event) error
}
}
func (s Server) ListenAndServe() error {
+39 -2
View File
@@ -930,6 +930,42 @@ func TestFlushOnlineAccountsFetchesPendingDeliveries(t *testing.T) {
}
}
func TestFlushPendingCoalescesConcurrentRequestsPerAccount(t *testing.T) {
var calls atomic.Int32
release := make(chan struct{})
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/gateway/events/downstream/pending" {
t.Fatalf("unexpected api path: %s", r.URL.Path)
}
calls.Add(1)
<-release
_ = json.NewEncoder(w).Encode([]pendingDelivery{})
}))
defer api.Close()
server := Server{APIBaseURL: api.URL + "/api", GatewayInstanceID: "gateway-a"}
results := make(chan error, 8)
for range 8 {
go func() {
_, err := server.flushPending("100001", log.Default())
results <- err
}()
}
deadline := time.Now().Add(time.Second)
for calls.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
close(release)
for range 8 {
if err := <-results; err != nil {
t.Fatalf("flush failed: %v", err)
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("pending fetch calls = %d, want 1", got)
}
}
func TestRecoverPendingCandidatesFetchesPresenceAccounts(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
@@ -1085,7 +1121,7 @@ func TestDownstreamDeliveryRequiresAcknowledgement(t *testing.T) {
messageID: "MSG-LONG-1", phoneNumber: "18821203795",
protocolLog: func(event protocolLogEvent) { protocolEvents <- event },
}
registerDownstreamAck(session, "delivery-1", 37, 9016479179509871733, time.Now().Add(time.Second))
registerDownstreamAck(session, "delivery-1", "", 37, 9016479179509871733, time.Now().Add(time.Second))
handleDownstreamAcknowledgement(conn, 37, 9016479179509871733, 0, log.Default())
select {
@@ -1165,6 +1201,7 @@ func TestSendDownstreamRejectsZeroMessageID(t *testing.T) {
&downstreamSession{mu: &sync.Mutex{}},
&cmpp.Cmpp2DeliverReqPkt{MsgId: 0},
"delivery-zero",
"",
)
if err == nil || !strings.Contains(err.Error(), "Msg_Id=0") {
t.Fatalf("expected zero Msg_Id rejection, got %v", err)
@@ -1180,7 +1217,7 @@ func TestDownstreamDeliveryReportsAckTimeout(t *testing.T) {
conn: &cmpp.Conn{}, connectionID: "conn-1",
deliveryReport: func(event downstreamDeliveryLifecycleEvent) { events <- event },
}
registerDownstreamAck(session, "delivery-timeout", 38, 9017467844344255865, time.Now().Add(20*time.Millisecond))
registerDownstreamAck(session, "delivery-timeout", "", 38, 9017467844344255865, time.Now().Add(20*time.Millisecond))
select {
case event := <-events:
+1 -5
View File
@@ -212,11 +212,7 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge
if sendErr != nil {
return
}
go func() {
if _, err := s.flushPending(account, logger); err != nil {
logger.Printf("cmpp inbound event=post_submit_pending_flush_failed account=%s message_id=%s error=%q", account, result.MessageID, err.Error())
}
}()
s.schedulePendingFlush(account, logger)
})
logger.Printf(
"cmpp inbound event=submit_accepted protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s dest_count=%d accepted_count=%d result=0 message_id=%s gateway_message_id=%d duration_ms=%d content_chars=%d content_hash=%s",
+14
View File
@@ -39,6 +39,8 @@ var submitStageHistograms [5][2]durationHistogram
type Snapshot struct {
UpstreamDesired int
UpstreamConnected int
UpstreamWindowConfigured int
UpstreamWindowInFlight int
DownstreamConnected int
SubmitWorkerUp bool
SubmitWorkerConcurrency int
@@ -55,6 +57,14 @@ type Snapshot struct {
ResultQueueAvailable bool
ResultQueuePending int64
ResultQueueLag int64
CallbackBatchRequests int64
CallbackBatchEvents int64
CallbackBatchRetries int64
CallbackDeadLetters int64
ProtocolLogPublished int64
ProtocolLogSampled int64
ProtocolLogErrors int64
ProtocolLogQueueLength int64
}
type SnapshotFunc func(context.Context) Snapshot
@@ -121,6 +131,10 @@ func Handler(load SnapshotFunc) http.Handler {
writeDurationHistogram(response, "cmpp_gateway_submit_stage_duration_seconds", stage, &submitStageHistograms[index])
}
fmt.Fprintf(response, "# HELP cmpp_gateway_upstream_connections Desired and live supplier connections.\n# TYPE cmpp_gateway_upstream_connections gauge\ncmpp_gateway_upstream_connections{state=\"desired\"} %d\ncmpp_gateway_upstream_connections{state=\"connected\"} %d\n", snapshot.UpstreamDesired, snapshot.UpstreamConnected)
fmt.Fprintf(response, "# HELP cmpp_gateway_upstream_window_slots Configured and in-flight supplier window slots.\n# TYPE cmpp_gateway_upstream_window_slots gauge\ncmpp_gateway_upstream_window_slots{state=\"configured\"} %d\ncmpp_gateway_upstream_window_slots{state=\"in_flight\"} %d\n", snapshot.UpstreamWindowConfigured, snapshot.UpstreamWindowInFlight)
fmt.Fprintf(response, "# HELP cmpp_gateway_callback_batch_total Batch callback requests, events, retries and dead letters.\n# TYPE cmpp_gateway_callback_batch_total counter\ncmpp_gateway_callback_batch_total{result=\"requests\"} %d\ncmpp_gateway_callback_batch_total{result=\"events\"} %d\ncmpp_gateway_callback_batch_total{result=\"retries\"} %d\ncmpp_gateway_callback_batch_total{result=\"dead_letter\"} %d\n", snapshot.CallbackBatchRequests, snapshot.CallbackBatchEvents, snapshot.CallbackBatchRetries, snapshot.CallbackDeadLetters)
fmt.Fprintf(response, "# HELP cmpp_gateway_protocol_log_total Protocol log Stream outcomes.\n# TYPE cmpp_gateway_protocol_log_total counter\ncmpp_gateway_protocol_log_total{result=\"published\"} %d\ncmpp_gateway_protocol_log_total{result=\"sampled_out\"} %d\ncmpp_gateway_protocol_log_total{result=\"error\"} %d\n", snapshot.ProtocolLogPublished, snapshot.ProtocolLogSampled, snapshot.ProtocolLogErrors)
fmt.Fprintf(response, "# HELP cmpp_gateway_protocol_log_stream_length Current protocol log Stream length.\n# TYPE cmpp_gateway_protocol_log_stream_length gauge\ncmpp_gateway_protocol_log_stream_length %d\n", snapshot.ProtocolLogQueueLength)
fmt.Fprintf(response, "# HELP cmpp_gateway_downstream_connections Authenticated client connections.\n# TYPE cmpp_gateway_downstream_connections gauge\ncmpp_gateway_downstream_connections %d\n", snapshot.DownstreamConnected)
fmt.Fprintf(response, "# HELP cmpp_gateway_submit_worker_up Whether the submit worker was initialized.\n# TYPE cmpp_gateway_submit_worker_up gauge\ncmpp_gateway_submit_worker_up %d\n", boolNumber(snapshot.SubmitWorkerUp))
fmt.Fprintf(response, "# HELP cmpp_gateway_submit_worker_slots Configured and active bounded submit worker slots.\n# TYPE cmpp_gateway_submit_worker_slots gauge\ncmpp_gateway_submit_worker_slots{state=\"configured\"} %d\ncmpp_gateway_submit_worker_slots{state=\"in_flight\"} %d\n", snapshot.SubmitWorkerConcurrency, snapshot.SubmitWorkerInFlight)
+128
View File
@@ -0,0 +1,128 @@
package protocollog
import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
)
const defaultStream = "gateway.protocol.logs"
type Event struct {
EventID string `json:"eventId"`
GatewayInstanceID string `json:"gatewayInstanceId,omitempty"`
ConnectionID string `json:"connectionId,omitempty"`
Protocol string `json:"protocol"`
Direction string `json:"direction"`
EventType string `json:"eventType"`
Status string `json:"status"`
TenantID string `json:"tenantId,omitempty"`
ApplicationID string `json:"applicationId,omitempty"`
ChannelID string `json:"channelId,omitempty"`
Account string `json:"account,omitempty"`
MessageID string `json:"messageId,omitempty"`
SubmitID string `json:"submitId,omitempty"`
GatewayMessageID string `json:"gatewayMessageId,omitempty"`
Phone string `json:"phone,omitempty"`
ResultCode string `json:"resultCode,omitempty"`
DurationMs int `json:"durationMs,omitempty"`
PayloadBytes int `json:"payloadBytes,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type Publisher struct {
Redis *redis.Client
Stream string
GatewayInstanceID string
SuccessSampleRate int
MaxLen int64
published atomic.Int64
sampled atomic.Int64
errors atomic.Int64
}
func New(redisURL string) (*Publisher, error) {
if strings.TrimSpace(redisURL) == "" {
redisURL = "redis://127.0.0.1:6379"
}
options, err := redis.ParseURL(redisURL)
if err != nil {
return nil, err
}
return &Publisher{Redis: redis.NewClient(options)}, nil
}
func (p *Publisher) Publish(ctx context.Context, event Event) error {
if p == nil || p.Redis == nil {
return fmt.Errorf("protocol log Redis publisher is unavailable")
}
if event.CreatedAt.IsZero() {
event.CreatedAt = time.Now().UTC()
}
if event.GatewayInstanceID == "" {
event.GatewayInstanceID = p.GatewayInstanceID
}
if event.EventID == "" {
event.EventID = fmt.Sprintf("PL-%d-%d", event.CreatedAt.UnixNano(), p.published.Load()+p.sampled.Load()+1)
}
if !p.mustKeep(event) && !p.sample(event) {
p.sampled.Add(1)
return nil
}
data, err := json.Marshal(event)
if err != nil {
p.errors.Add(1)
return err
}
args := &redis.XAddArgs{Stream: p.stream(), Values: map[string]any{"data": string(data)}}
if p.MaxLen > 0 {
args.MaxLen = p.MaxLen
args.Approx = true
}
if err := p.Redis.XAdd(ctx, args).Err(); err != nil {
p.errors.Add(1)
return err
}
p.published.Add(1)
return nil
}
func (p *Publisher) mustKeep(event Event) bool {
if event.Status != "success" {
return true
}
code := strings.TrimSpace(event.ResultCode)
return code != "" && code != "0"
}
func (p *Publisher) sample(event Event) bool {
rate := p.SuccessSampleRate
if rate <= 0 {
rate = 10
}
if rate >= 100 {
return true
}
h := fnv.New32a()
_, _ = h.Write([]byte(event.ChannelID + "|" + event.MessageID + "|" + event.EventType + "|" + strconv.FormatInt(event.CreatedAt.UnixNano()/int64(time.Millisecond), 10)))
return int(h.Sum32()%100) < rate
}
func (p *Publisher) stream() string {
if strings.TrimSpace(p.Stream) != "" {
return p.Stream
}
return defaultStream
}
func (p *Publisher) StreamName() string { return p.stream() }
func (p *Publisher) Counts() (int64, int64, int64) {
return p.published.Load(), p.sampled.Load(), p.errors.Load()
}
@@ -0,0 +1,34 @@
package protocollog
import (
"context"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func TestNewUsesLocalRedisWhenURLIsEmpty(t *testing.T) {
publisher, err := New("")
if err != nil {
t.Fatalf("New returned error: %v", err)
}
if publisher == nil || publisher.Redis == nil {
t.Fatal("expected an initialized Redis client")
}
if got := publisher.Redis.Options().Addr; got != "127.0.0.1:6379" {
t.Fatalf("expected local Redis fallback, got %q", got)
}
}
func TestFailuresAreNeverSampledOut(t *testing.T) {
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
p := &Publisher{Redis: client, SuccessSampleRate: 1, MaxLen: 100}
if err := p.Publish(context.Background(), Event{Protocol: "cmpp", EventType: "submit", Status: "failed", ResultCode: "TIMEOUT"}); err != nil {
t.Fatal(err)
}
if client.XLen(context.Background(), p.StreamName()).Val() != 1 {
t.Fatal("failed protocol event must be retained")
}
}
+8
View File
@@ -70,6 +70,10 @@ type UpstreamConfig struct {
WindowSize int `json:"windowSize,omitempty"`
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds,omitempty"`
HeartbeatMissThreshold int `json:"heartbeatMissThreshold,omitempty"`
ConnectionWarmupSeconds int `json:"connectionWarmupSeconds,omitempty"`
ConnectionDrainSeconds int `json:"connectionDrainTimeoutSeconds,omitempty"`
SubmitTimeoutSeconds int `json:"submitResponseTimeoutSeconds,omitempty"`
FailureCooldownSeconds int `json:"connectionFailureCooldownSeconds,omitempty"`
}
type Retry struct {
@@ -146,6 +150,10 @@ type ConnectChannelConfig struct {
WindowSize int `json:"windowSize,omitempty"`
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds,omitempty"`
HeartbeatMissThreshold int `json:"heartbeatMissThreshold,omitempty"`
ConnectionWarmupSeconds int `json:"connectionWarmupSeconds,omitempty"`
ConnectionDrainSeconds int `json:"connectionDrainTimeoutSeconds,omitempty"`
SubmitTimeoutSeconds int `json:"submitResponseTimeoutSeconds,omitempty"`
FailureCooldownSeconds int `json:"connectionFailureCooldownSeconds,omitempty"`
}
type DisconnectChannelCommand struct {
+170
View File
@@ -0,0 +1,170 @@
package resultoutbox
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
type callbackBatch struct {
BatchID string `json:"batchId"`
GatewayInstanceID string `json:"gatewayInstanceId"`
CreatedAt time.Time `json:"createdAt"`
Events []callbackBatchEvent `json:"events"`
}
type callbackBatchEvent struct {
EventID string `json:"eventId"`
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
type callbackBatchResponse struct {
BatchID string `json:"batchId"`
Results []callbackEventResult `json:"results"`
}
type callbackEventResult struct {
EventID string `json:"eventId"`
Accepted bool `json:"accepted"`
Retryable bool `json:"retryable,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
}
func (o *Outbox) runBatches(ctx context.Context) error {
for ctx.Err() == nil {
messages, _, err := o.Redis.XAutoClaim(ctx, &redis.XAutoClaimArgs{Stream: o.stream(), Group: o.group(), Consumer: o.consumer(), MinIdle: o.minIdle(), Start: "0-0", Count: int64(o.batchSize())}).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return err
}
if len(messages) == 0 {
streams, readErr := o.Redis.XReadGroup(ctx, &redis.XReadGroupArgs{Group: o.group(), Consumer: o.consumer(), Streams: []string{o.stream(), ">"}, Count: int64(o.batchSize()), Block: o.batchWait()}).Result()
if errors.Is(readErr, redis.Nil) {
continue
}
if readErr != nil {
return readErr
}
for _, stream := range streams {
messages = append(messages, stream.Messages...)
}
}
if len(messages) == 0 {
continue
}
if err := o.processBatch(ctx, messages); err != nil {
o.batchRetries.Add(int64(len(messages)))
sleep(ctx, 100*time.Millisecond)
}
}
return ctx.Err()
}
func (o *Outbox) processBatch(ctx context.Context, messages []redis.XMessage) error {
batch := callbackBatch{BatchID: fmt.Sprintf("CB-%d", time.Now().UnixNano()), GatewayInstanceID: o.GatewayInstanceID, CreatedAt: time.Now().UTC()}
byEvent := make(map[string]redis.XMessage, len(messages))
for _, message := range messages {
event, err := EventFromStreamValues(message.Values)
if err != nil {
_ = o.deadLetter(ctx, message, "INVALID_ENVELOPE", err.Error())
continue
}
batch.Events = append(batch.Events, callbackBatchEvent{EventID: event.EventID, Type: event.EventType, Payload: event.Payload})
byEvent[event.EventID] = message
}
if len(batch.Events) == 0 {
return nil
}
body, err := json.Marshal(batch)
if err != nil {
return err
}
if len(body) > 1024*1024 {
return fmt.Errorf("callback batch exceeds 1MB")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(o.APIBaseURL, "/")+"/gateway/events/batch", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: o.httpTimeout()}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("batch callback returned %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
var result callbackBatchResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 1024*1024)).Decode(&result); err != nil {
return err
}
if result.BatchID != batch.BatchID {
return fmt.Errorf("callback batchId mismatch")
}
o.batchRequests.Add(1)
o.batchEvents.Add(int64(len(batch.Events)))
seen := make(map[string]struct{}, len(result.Results))
for _, item := range result.Results {
message, ok := byEvent[item.EventID]
if !ok {
continue
}
seen[item.EventID] = struct{}{}
if item.Accepted {
if err := o.ackDelete(ctx, message.ID); err != nil {
return err
}
continue
}
if !item.Retryable {
if err := o.deadLetter(ctx, message, item.ErrorCode, "non-retryable callback result"); err != nil {
return err
}
}
}
for eventID := range byEvent {
if _, ok := seen[eventID]; !ok {
return fmt.Errorf("batch response omitted event %s", eventID)
}
}
return nil
}
func (o *Outbox) ackDelete(ctx context.Context, id string) error {
return acknowledgeAndDeleteScript.Run(ctx, o.Redis, []string{o.stream()}, o.group(), id).Err()
}
func (o *Outbox) deadLetter(ctx context.Context, message redis.XMessage, code, detail string) error {
stream := o.DeadLetterStream
if stream == "" {
stream = o.stream() + ".dead"
}
pipe := o.Redis.TxPipeline()
pipe.XAdd(ctx, &redis.XAddArgs{Stream: stream, Values: map[string]any{"sourceId": message.ID, "errorCode": code, "detail": detail, "data": fmt.Sprint(message.Values["data"])}})
pipe.XAck(ctx, o.stream(), o.group(), message.ID)
pipe.XDel(ctx, o.stream(), message.ID)
if _, err := pipe.Exec(ctx); err != nil {
return err
}
o.deadLetters.Add(1)
return nil
}
func (o *Outbox) batchSize() int {
if o.BatchSize < 1 {
return 50
}
return min(o.BatchSize, 100)
}
func (o *Outbox) batchWait() time.Duration {
if o.BatchWait <= 0 {
return 10 * time.Millisecond
}
return o.BatchWait
}
+36 -12
View File
@@ -53,16 +53,25 @@ type Event struct {
}
type Outbox struct {
Redis *redis.Client
Stream string
Group string
Consumer string
DedupeTTL time.Duration
APIBaseURL string
HTTPTimeout time.Duration
Concurrency int
MinIdle time.Duration
inFlight atomic.Int64
Redis *redis.Client
Stream string
Group string
Consumer string
DedupeTTL time.Duration
APIBaseURL string
HTTPTimeout time.Duration
Concurrency int
MinIdle time.Duration
BatchEnabled bool
BatchSize int
BatchWait time.Duration
GatewayInstanceID string
DeadLetterStream string
inFlight atomic.Int64
batchRequests atomic.Int64
batchEvents atomic.Int64
batchRetries atomic.Int64
deadLetters atomic.Int64
}
func New(client *redis.Client) *Outbox {
@@ -145,6 +154,18 @@ func (o *Outbox) PublishSubmitResult(ctx context.Context, command queue.SubmitCo
return o.publish(ctx, event)
}
func (o *Outbox) PublishReceipt(ctx context.Context, event queue.ReceiptEvent) error {
return o.publishRaw(ctx, Event{SchemaVersion: queue.SchemaVersion, EventID: fmt.Sprintf("receipt:%s:%s:%d", event.GatewayMessageID, event.RawStatus, event.SequenceID), EventType: "receipt_intake", Path: "/gateway/events/receipt/intake", TraceID: event.TraceID, MessageID: event.MessageID, ChannelID: event.ChannelID, Payload: mustMarshal(event), CreatedAt: time.Now().UTC()})
}
func (o *Outbox) PublishUplink(ctx context.Context, event queue.UplinkEvent) error {
return o.publishRaw(ctx, Event{SchemaVersion: queue.SchemaVersion, EventID: fmt.Sprintf("uplink:%s:%d:%d", event.ChannelID, event.SequenceID, event.ReceivedAt.UnixNano()), EventType: "uplink", Path: "/gateway/events/uplink", TraceID: event.TraceID, MessageID: event.MessageID, ChannelID: event.ChannelID, Payload: mustMarshal(event), CreatedAt: time.Now().UTC()})
}
func mustMarshal(value any) json.RawMessage { data, _ := json.Marshal(value); return data }
func (o *Outbox) publishRaw(ctx context.Context, event Event) error { return o.publish(ctx, event) }
func (o *Outbox) publish(ctx context.Context, event Event) error {
if o.Redis == nil {
return fmt.Errorf("result Outbox Redis client is required")
@@ -211,10 +232,10 @@ func EventFromStreamValues(values map[string]interface{}) (Event, error) {
if err := json.Unmarshal([]byte(data), &event); err != nil {
return Event{}, err
}
if event.SchemaVersion != queue.SchemaVersion || event.EventID == "" || event.MessageID == "" || event.SubmitID == "" {
if event.SchemaVersion != queue.SchemaVersion || event.EventID == "" || event.MessageID == "" {
return Event{}, fmt.Errorf("invalid result Outbox envelope")
}
if event.Path != "/gateway/events/submit-result" && event.Path != "/gateway/events/submit-segment-result" {
if event.Path != "/gateway/events/submit-result" && event.Path != "/gateway/events/submit-segment-result" && event.Path != "/gateway/events/receipt/intake" && event.Path != "/gateway/events/uplink" && event.Path != "/gateway/events/dead-letter" {
return Event{}, fmt.Errorf("unsupported result Outbox path %q", event.Path)
}
if len(event.Payload) == 0 {
@@ -258,3 +279,6 @@ func (o *Outbox) dedupeKey(eventID string) string {
func (o *Outbox) StreamName() string { return o.stream() }
func (o *Outbox) GroupName() string { return o.group() }
func (o *Outbox) InFlight() int64 { return o.inFlight.Load() }
func (o *Outbox) BatchCounts() (int64, int64, int64, int64) {
return o.batchRequests.Load(), o.batchEvents.Load(), o.batchRetries.Load(), o.deadLetters.Load()
}
@@ -2,6 +2,7 @@ package resultoutbox
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
@@ -32,6 +33,99 @@ func TestPublishSubmitSegmentIsIdempotent(t *testing.T) {
}
}
func TestBatchCallbackSendsMultipleEventsInOneRequest(t *testing.T) {
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
var requests atomic.Int32
var eventCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
requests.Add(1)
var batch callbackBatch
if err := json.NewDecoder(request.Body).Decode(&batch); err != nil {
t.Errorf("decode batch: %v", err)
return
}
eventCount.Store(int32(len(batch.Events)))
result := callbackBatchResponse{BatchID: batch.BatchID}
for _, event := range batch.Events {
result.Results = append(result.Results, callbackEventResult{EventID: event.EventID, Accepted: true})
}
_ = json.NewEncoder(response).Encode(result)
}))
defer server.Close()
outbox := New(client)
outbox.APIBaseURL = server.URL
outbox.BatchEnabled = true
outbox.BatchSize = 50
outbox.BatchWait = 10 * time.Millisecond
outbox.GatewayInstanceID = "gateway-test"
command := testCommand()
for index := 1; index <= 2; index++ {
if err := outbox.PublishSubmitSegment(context.Background(), command, queue.SubmitSegmentResult{SegmentTotal: 2, SegmentIndex: index, SequenceID: uint32(index), GatewayMessageID: "88", SubmitStatus: "accepted"}); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- outbox.Run(ctx) }()
deadline := time.Now().Add(3 * time.Second)
for client.XLen(context.Background(), outbox.StreamName()).Val() != 0 {
if time.Now().After(deadline) {
t.Fatal("batch did not drain")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
<-done
if requests.Load() != 1 || eventCount.Load() != 2 {
t.Fatalf("requests/events=%d/%d, want 1/2", requests.Load(), eventCount.Load())
}
}
func TestBatchCallbackReplaysWholeRequestAfterHTTPFailure(t *testing.T) {
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
var batch callbackBatch
_ = json.NewDecoder(request.Body).Decode(&batch)
if calls.Add(1) == 1 {
http.Error(response, "busy", http.StatusServiceUnavailable)
return
}
result := callbackBatchResponse{BatchID: batch.BatchID}
for _, event := range batch.Events {
result.Results = append(result.Results, callbackEventResult{EventID: event.EventID, Accepted: true})
}
_ = json.NewEncoder(response).Encode(result)
}))
defer server.Close()
outbox := New(client)
outbox.APIBaseURL = server.URL
outbox.BatchEnabled = true
outbox.BatchWait = 5 * time.Millisecond
outbox.MinIdle = 5 * time.Millisecond
outbox.GatewayInstanceID = "g"
if err := outbox.PublishSubmitSegment(context.Background(), testCommand(), queue.SubmitSegmentResult{SegmentTotal: 1, SegmentIndex: 1, SequenceID: 1, GatewayMessageID: "1", SubmitStatus: "accepted"}); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- outbox.Run(ctx) }()
deadline := time.Now().Add(3 * time.Second)
for client.XLen(context.Background(), outbox.StreamName()).Val() != 0 {
if time.Now().After(deadline) {
t.Fatal("replayed batch did not drain")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
<-done
if calls.Load() < 2 {
t.Fatalf("calls=%d want replay", calls.Load())
}
}
func TestPublishAggregateAndCommandAckAreAtomicAndIdempotent(t *testing.T) {
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
+3
View File
@@ -46,6 +46,9 @@ func (o *Outbox) Run(ctx context.Context) error {
if err := o.ensureGroup(ctx); err != nil {
return err
}
if o.BatchEnabled {
return o.runBatches(ctx)
}
pool := newCallbackPool(ctx, o, o.concurrency())
defer pool.wait()
for {
+41 -18
View File
@@ -1,6 +1,7 @@
package upstream
import (
"cmpp-platform/gateway/internal/protocollog"
"cmpp-platform/gateway/internal/queue"
"context"
"fmt"
@@ -16,24 +17,38 @@ import (
// the connection must wake pending submitters before scheduling pool recovery.
type connection struct {
channelID string
config queue.UpstreamConfig
index int
pool *connectionPool
apiBaseURL string
httpClient *http.Client
channelID string
config queue.UpstreamConfig
index int
pool *connectionPool
apiBaseURL string
httpClient *http.Client
protocolLogPublisher interface {
Publish(context.Context, protocollog.Event) error
}
gatewayInstanceID string
eventPublisher interface {
PublishReceipt(context.Context, queue.ReceiptEvent) error
PublishUplink(context.Context, queue.UplinkEvent) error
}
mu sync.Mutex
sendMu sync.Mutex
client *cmpp.Client
window chan struct{}
pending map[uint32]chan submitPartResponse
tracker map[uint64]queue.SubmitCommand
longUplink map[string]*longUplinkAssembly
readOnce sync.Once
closed bool
heartbeatCancel context.CancelFunc
heartbeatPending map[uint32]time.Time
mu sync.Mutex
sendMu sync.Mutex
client *cmpp.Client
window chan struct{}
windowLimit int
draining bool
retired bool
lastSubmitRTT time.Duration
consecutiveFailures int
cooldownUntil time.Time
pending map[uint32]chan submitPartResponse
tracker map[uint64]queue.SubmitCommand
longUplink map[string]*longUplinkAssembly
readOnce sync.Once
closed bool
heartbeatCancel context.CancelFunc
heartbeatPending map[uint32]time.Time
}
type submitPartResponse struct {
@@ -161,6 +176,14 @@ func (c *connection) identity() string {
return fmt.Sprintf("%s-%d", c.channelID, c.index)
}
func (c *connection) retire() {
c.mu.Lock()
c.draining = true
c.retired = true
c.mu.Unlock()
c.handleConnectionLoss(fmt.Errorf("connection retired after drain"))
}
func (c *connection) close() {
c.handleConnectionLoss(fmt.Errorf("connection closed"))
}
@@ -191,7 +214,7 @@ func (c *connection) handleConnectionLoss(err error) {
default:
}
}
if c.pool != nil {
if c.pool != nil && !c.retired {
status := "disconnected"
if c.pool.countActiveConnections() > 0 {
status = "reconnecting"
+20 -2
View File
@@ -88,7 +88,16 @@ func (c *connection) handleDeliver(pkt deliverPacket) error {
DeliveredAt: time.Now().UTC(),
ConnectionID: c.identity(),
}
if err := postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/receipt/intake", event); err != nil {
if c.protocolLogPublisher != nil {
c.emitProtocolLog(protocolLogEvent{Protocol: "cmpp", Direction: "channel_to_platform", EventType: "deliver_receipt", Status: "success", ChannelID: channelID, Account: c.config.Account, MessageID: messageID, GatewayMessageID: fmt.Sprint(receipt.MsgId), Phone: strings.TrimSpace(receipt.DestTerminalId), ResultCode: strings.TrimSpace(receipt.Stat), Detail: map[string]any{"sequenceId": pkt.seqID}})
}
var publishErr error
if c.eventPublisher != nil {
publishErr = c.eventPublisher.PublishReceipt(context.Background(), event)
} else {
publishErr = postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/receipt/intake", event)
}
if err := publishErr; err != nil {
log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_receipt status=forward_failed channel_id=%s sequence_id=%d gateway_message_id=%d error=%q", c.channelID, pkt.seqID, receipt.MsgId, err)
return err
} else {
@@ -121,7 +130,16 @@ func (c *connection) handleDeliver(pkt deliverPacket) error {
Content: content,
ReceivedAt: time.Now().UTC(),
}
if err := postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/uplink", event); err != nil {
if c.protocolLogPublisher != nil {
c.emitProtocolLog(protocolLogEvent{Protocol: "cmpp", Direction: "channel_to_platform", EventType: "deliver_uplink", Status: "success", ChannelID: c.channelID, Account: c.config.Account, MessageID: cmd.MessageID, Phone: strings.TrimSpace(pkt.srcTerminalID), Detail: map[string]any{"sequenceId": pkt.seqID}})
}
var publishErr error
if c.eventPublisher != nil {
publishErr = c.eventPublisher.PublishUplink(context.Background(), event)
} else {
publishErr = postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/uplink", event)
}
if err := publishErr; err != nil {
log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_uplink status=forward_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err)
} else {
log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_uplink status=forwarded channel_id=%s sequence_id=%d packet_msg_id=%d", c.channelID, pkt.seqID, pkt.msgID)
+54 -5
View File
@@ -45,11 +45,20 @@ func (p *connectionPool) tryAcquireConnection() (*connection, func()) {
if len(p.conns) == 0 {
return nil, nil
}
for i := 0; i < len(p.conns); i++ {
index := (p.next + i) % len(p.conns)
conn := p.conns[index]
bestIndex := -1
bestInFlight := int(^uint(0) >> 1)
var bestRTT time.Duration
for index, conn := range p.conns {
inFlight, rtt, usable := conn.capacitySnapshot()
if !usable || inFlight > bestInFlight || (inFlight == bestInFlight && bestIndex >= 0 && bestRTT > 0 && rtt >= bestRTT) {
continue
}
bestIndex, bestInFlight, bestRTT = index, inFlight, rtt
}
if bestIndex >= 0 {
conn := p.conns[bestIndex]
if conn.tryAcquireWindow() {
p.next = (index + 1) % len(p.conns)
p.next = (bestIndex + 1) % len(p.conns)
return conn, conn.releaseWindow
}
}
@@ -57,8 +66,17 @@ func (p *connectionPool) tryAcquireConnection() (*connection, func()) {
}
func (c *connection) tryAcquireWindow() bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.window == nil {
c.window = make(chan struct{}, defaultWindowSize)
c.window = make(chan struct{}, maximumWindowSize)
}
limit := c.windowLimit
if limit < 1 {
limit = defaultWindowSize
}
if c.closed || c.draining || c.client == nil || len(c.window) >= limit {
return false
}
select {
case c.window <- struct{}{}:
@@ -68,6 +86,37 @@ func (c *connection) tryAcquireWindow() bool {
}
}
func (c *connection) capacitySnapshot() (int, time.Duration, bool) {
c.mu.Lock()
defer c.mu.Unlock()
limit := c.windowLimit
if limit < 1 {
limit = defaultWindowSize
}
inFlight := len(c.window)
return inFlight, c.lastSubmitRTT, !c.closed && !c.draining && c.client != nil && inFlight < limit && !time.Now().Before(c.cooldownUntil)
}
func (c *connection) markSubmitFailure() {
c.mu.Lock()
defer c.mu.Unlock()
c.consecutiveFailures++
if c.consecutiveFailures >= 3 {
seconds := c.config.FailureCooldownSeconds
if seconds <= 0 {
seconds = 30
}
c.cooldownUntil = time.Now().Add(time.Duration(seconds) * time.Second)
}
}
func (c *connection) markSubmitSuccess() {
c.mu.Lock()
defer c.mu.Unlock()
c.consecutiveFailures = 0
c.cooldownUntil = time.Time{}
}
func (c *connection) releaseWindow() {
if c.window == nil {
return
+65 -4
View File
@@ -1,6 +1,7 @@
package upstream
import (
"cmpp-platform/gateway/internal/protocollog"
"cmpp-platform/gateway/internal/queue"
"context"
"fmt"
@@ -19,6 +20,8 @@ const (
defaultReconnectInitialDelay = 5 * time.Second
defaultReconnectMaximumDelay = 5 * time.Minute
defaultAuthReconnectDelay = 5 * time.Minute
maximumConnections = 8
maximumWindowSize = 64
)
type Manager struct {
@@ -26,6 +29,14 @@ type Manager struct {
EventAPIBaseURL string
HTTPClient *http.Client
SubmitSegmentPublisher SubmitSegmentPublisher
EventPublisher interface {
PublishReceipt(context.Context, queue.ReceiptEvent) error
PublishUplink(context.Context, queue.UplinkEvent) error
}
ProtocolLogPublisher interface {
Publish(context.Context, protocollog.Event) error
}
GatewayInstanceID string
mu sync.Mutex
conns map[string]*connectionPool
@@ -72,8 +83,14 @@ func (m *Manager) ConnectChannel(ctx context.Context, command queue.ConnectChann
WindowSize: command.Channel.WindowSize,
HeartbeatIntervalSeconds: command.Channel.HeartbeatIntervalSeconds,
HeartbeatMissThreshold: command.Channel.HeartbeatMissThreshold,
ConnectionWarmupSeconds: command.Channel.ConnectionWarmupSeconds,
ConnectionDrainSeconds: command.Channel.ConnectionDrainSeconds,
SubmitTimeoutSeconds: command.Channel.SubmitTimeoutSeconds,
FailureCooldownSeconds: command.Channel.FailureCooldownSeconds,
})
if pool == nil || !pool.matches(config) {
if pool != nil && !pool.matches(config) && pool.sameEndpoint(config) {
pool.reconfigure(config)
} else if pool == nil || !pool.matches(config) {
if pool != nil {
pool.close()
}
@@ -130,7 +147,9 @@ func (m *Manager) connectionFor(cmd queue.SubmitCommand) (*connectionPool, error
m.ensureDefaultsLocked()
pool := m.conns[cmd.ChannelID]
if pool == nil || !pool.matches(cmd.Upstream) {
if pool != nil && !pool.matches(cmd.Upstream) && pool.sameEndpoint(cmd.Upstream) {
pool.reconfigure(cmd.Upstream)
} else if pool == nil || !pool.matches(cmd.Upstream) {
if pool != nil {
pool.close()
}
@@ -170,6 +189,27 @@ func (m *Manager) ConnectionCounts() (desired int, connected int) {
return desired, connected
}
func (m *Manager) WindowCounts() (configured int, inFlight 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 {
pool.mu.Lock()
conns := append([]*connection(nil), pool.conns...)
pool.mu.Unlock()
for _, conn := range conns {
conn.mu.Lock()
configured += max(1, conn.windowLimit)
inFlight += len(conn.window)
conn.mu.Unlock()
}
}
return configured, inFlight
}
func (m *Manager) newConnectionPool(channelID string, connectionID string, config queue.UpstreamConfig) *connectionPool {
eventAPIBaseURL := m.EventAPIBaseURL
if eventAPIBaseURL == "" {
@@ -184,8 +224,11 @@ func (m *Manager) newConnectionPool(channelID string, connectionID string, confi
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{}),
protocolLogPublisher: m.ProtocolLogPublisher,
gatewayInstanceID: m.GatewayInstanceID,
eventPublisher: m.EventPublisher,
reconnectSignal: make(chan struct{}, 1),
stopCh: make(chan struct{}),
}
}
@@ -202,6 +245,12 @@ func validateConnectChannelCommand(command queue.ConnectChannelCommand) error {
if command.Channel.Account == "" || command.Channel.PasswordCipher == "" {
return fmt.Errorf("account and passwordCipher are required")
}
if command.DesiredConnections < 1 || command.DesiredConnections > maximumConnections {
return fmt.Errorf("desiredConnections must be between 1 and %d", maximumConnections)
}
if command.Channel.WindowSize != 0 && (command.Channel.WindowSize < 1 || command.Channel.WindowSize > maximumWindowSize) {
return fmt.Errorf("windowSize must be between 1 and %d", maximumWindowSize)
}
return nil
}
@@ -222,5 +271,17 @@ func normalizeUpstreamConfig(config queue.UpstreamConfig) queue.UpstreamConfig {
if config.HeartbeatMissThreshold <= 0 {
config.HeartbeatMissThreshold = defaultHeartbeatMissThreshold
}
if config.ConnectionWarmupSeconds < 0 {
config.ConnectionWarmupSeconds = 30
}
if config.ConnectionDrainSeconds <= 0 {
config.ConnectionDrainSeconds = 60
}
if config.SubmitTimeoutSeconds <= 0 {
config.SubmitTimeoutSeconds = 60
}
if config.FailureCooldownSeconds <= 0 {
config.FailureCooldownSeconds = 30
}
return config
}
+118 -19
View File
@@ -1,6 +1,7 @@
package upstream
import (
"cmpp-platform/gateway/internal/protocollog"
"cmpp-platform/gateway/internal/queue"
"context"
"net/http"
@@ -9,12 +10,20 @@ import (
)
type connectionPool struct {
channelID string
connectionID string
config queue.UpstreamConfig
apiBaseURL string
httpClient *http.Client
reporter func(context.Context, ConnectionState) error
channelID string
connectionID string
config queue.UpstreamConfig
apiBaseURL string
httpClient *http.Client
reporter func(context.Context, ConnectionState) error
protocolLogPublisher interface {
Publish(context.Context, protocollog.Event) error
}
gatewayInstanceID string
eventPublisher interface {
PublishReceipt(context.Context, queue.ReceiptEvent) error
PublishUplink(context.Context, queue.UplinkEvent) error
}
mu sync.Mutex
connectMu sync.Mutex
@@ -34,6 +43,103 @@ func (p *connectionPool) matches(config queue.UpstreamConfig) bool {
return p.config == normalizeUpstreamConfig(config)
}
func (p *connectionPool) sameEndpoint(config queue.UpstreamConfig) bool {
next := normalizeUpstreamConfig(config)
current := p.config
return current.GatewayHost == next.GatewayHost && current.GatewayPort == next.GatewayPort &&
current.Account == next.Account && current.PasswordCipher == next.PasswordCipher && current.CMPPVersion == next.CMPPVersion
}
// reconfigure changes only runtime capacity on the existing pool. Existing
// sequence mappings remain attached to their physical connection. Scale down
// marks surplus connections draining before closing them; scale up is serialized.
func (p *connectionPool) reconfigure(config queue.UpstreamConfig) {
next := normalizeUpstreamConfig(config)
p.mu.Lock()
p.config = next
for _, conn := range p.conns {
conn.mu.Lock()
conn.config = next
conn.windowLimit = next.WindowSize
conn.mu.Unlock()
}
p.mu.Unlock()
go p.reconcileCapacity()
}
func (p *connectionPool) reconcileCapacity() {
p.connectMu.Lock()
defer p.connectMu.Unlock()
if p.stopped() {
return
}
p.mu.Lock()
desired := max(1, min(maximumConnections, p.config.DesiredConnections))
if len(p.conns) > desired {
retiring := append([]*connection(nil), p.conns[desired:]...)
p.conns = p.conns[:desired]
for _, conn := range retiring {
conn.mu.Lock()
conn.draining = true
conn.mu.Unlock()
}
p.mu.Unlock()
deadline := time.Now().Add(time.Duration(p.config.ConnectionDrainSeconds) * time.Second)
for _, conn := range retiring {
for len(conn.window) > 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
conn.retire()
}
_ = p.reportState(context.Background(), "connected", nil)
return
}
p.mu.Unlock()
for {
p.mu.Lock()
if len(p.conns) >= desired || p.stopped() {
p.mu.Unlock()
break
}
index := len(p.conns)
config := p.config
p.mu.Unlock()
conn := p.newConnection(index, config)
if _, err := conn.ensureConnected(); err != nil {
p.scheduleReconnect(err)
_ = p.reportState(context.Background(), "failed", err)
return
}
p.mu.Lock()
p.conns = append(p.conns, conn)
p.mu.Unlock()
_ = p.reportState(context.Background(), "connected", nil)
if len(p.conns) < desired && config.ConnectionWarmupSeconds > 0 {
timer := time.NewTimer(time.Duration(config.ConnectionWarmupSeconds) * time.Second)
select {
case <-p.stopCh:
timer.Stop()
return
case <-timer.C:
}
}
}
}
func (p *connectionPool) newConnection(index int, config queue.UpstreamConfig) *connection {
return &connection{
channelID: p.channelID, config: config, index: index, pool: p,
apiBaseURL: p.apiBaseURL, httpClient: p.httpClient,
protocolLogPublisher: p.protocolLogPublisher, gatewayInstanceID: p.gatewayInstanceID,
eventPublisher: p.eventPublisher,
window: make(chan struct{}, maximumWindowSize), windowLimit: config.WindowSize,
pending: make(map[uint32]chan submitPartResponse), tracker: make(map[uint64]queue.SubmitCommand),
longUplink: make(map[string]*longUplinkAssembly), heartbeatPending: make(map[uint32]time.Time),
}
}
func (p *connectionPool) ensureConnected() error {
p.connectMu.Lock()
defer p.connectMu.Unlock()
@@ -58,20 +164,12 @@ func (p *connectionPool) ensureConnected() error {
p.mu.Unlock()
break
}
index := len(p.conns)
conn := &connection{
channelID: p.channelID,
config: p.config,
index: index,
pool: p,
apiBaseURL: p.apiBaseURL,
httpClient: p.httpClient,
window: make(chan struct{}, p.config.WindowSize),
pending: make(map[uint32]chan submitPartResponse),
tracker: make(map[uint64]queue.SubmitCommand),
longUplink: make(map[string]*longUplinkAssembly),
heartbeatPending: make(map[uint32]time.Time),
if connectedAny {
p.mu.Unlock()
break
}
index := len(p.conns)
conn := p.newConnection(index, p.config)
p.mu.Unlock()
connected, err := conn.ensureConnected()
@@ -87,6 +185,7 @@ func (p *connectionPool) ensureConnected() error {
if connectedAny {
_ = p.reportState(context.Background(), "connected", nil)
}
go p.reconcileCapacity()
return nil
}
+49 -2
View File
@@ -2,7 +2,9 @@ package upstream
import (
"context"
cmpp "github.com/bigwhite/gocmpp"
"testing"
"time"
"cmpp-platform/gateway/internal/queue"
)
@@ -10,8 +12,8 @@ import (
func TestConnectionPoolAcquiresAcrossConnections(t *testing.T) {
pool := &connectionPool{
conns: []*connection{
{window: make(chan struct{}, 1)},
{window: make(chan struct{}, 1)},
{client: &cmpp.Client{}, window: make(chan struct{}, 64), windowLimit: 1},
{client: &cmpp.Client{}, window: make(chan struct{}, 64), windowLimit: 1},
},
}
@@ -40,6 +42,51 @@ func TestConnectionPoolAcquiresAcrossConnections(t *testing.T) {
releaseSecond()
}
func TestPoolContinuesOnHealthyConnectionWhenPeerIsDraining(t *testing.T) {
draining := &connection{client: &cmpp.Client{}, window: make(chan struct{}, 64), windowLimit: 16, draining: true}
healthy := &connection{client: &cmpp.Client{}, window: make(chan struct{}, 64), windowLimit: 16}
pool := &connectionPool{conns: []*connection{draining, healthy}}
selected, release := pool.tryAcquireConnection()
if selected != healthy {
t.Fatal("expected healthy peer connection")
}
release()
}
func TestRuntimeCapacityMatrixAndSmoothScaleDown(t *testing.T) {
for _, connections := range []int{1, 2, 4, 8} {
for _, window := range []int{1, 16, 32, 64} {
config := normalizeUpstreamConfig(queue.UpstreamConfig{GatewayHost: "127.0.0.1", GatewayPort: 17890, Account: "a", PasswordCipher: "p", CMPPVersion: "3.0", DesiredConnections: connections, WindowSize: window})
if config.DesiredConnections != connections || config.WindowSize != window {
t.Fatalf("matrix normalized incorrectly: %+v", config)
}
}
}
pool := &connectionPool{channelID: "channel-1", connectionID: "primary", config: normalizeUpstreamConfig(queue.UpstreamConfig{DesiredConnections: 2, WindowSize: 16, ConnectionDrainSeconds: 1}), stopCh: make(chan struct{}), reconnectSignal: make(chan struct{}, 1)}
pool.conns = []*connection{{pool: pool, window: make(chan struct{}, 64), windowLimit: 16}, {pool: pool, window: make(chan struct{}, 64), windowLimit: 16}}
pool.reconfigure(queue.UpstreamConfig{DesiredConnections: 1, WindowSize: 32, ConnectionDrainSeconds: 1})
deadline := time.Now().Add(time.Second)
for {
pool.mu.Lock()
count := len(pool.conns)
first := pool.conns[0]
pool.mu.Unlock()
if count == 1 {
first.mu.Lock()
limit := first.windowLimit
first.mu.Unlock()
if limit != 32 {
t.Fatalf("window limit=%d want32", limit)
}
break
}
if time.Now().After(deadline) {
t.Fatal("scale down did not complete")
}
time.Sleep(time.Millisecond)
}
}
func TestManagerDisconnectChannelRemovesPoolAndStopsReconnects(t *testing.T) {
pool := &connectionPool{
channelID: "channel-1",
+12 -16
View File
@@ -1,6 +1,7 @@
package upstream
import (
"cmpp-platform/gateway/internal/protocollog"
"context"
"fmt"
cmpp "github.com/bigwhite/gocmpp"
@@ -8,22 +9,7 @@ import (
"strings"
)
type protocolLogEvent struct {
Protocol string `json:"protocol"`
Direction string `json:"direction"`
EventType string `json:"eventType"`
Status string `json:"status"`
TenantID string `json:"tenantId,omitempty"`
ApplicationID string `json:"applicationId,omitempty"`
ChannelID string `json:"channelId,omitempty"`
Account string `json:"account,omitempty"`
MessageID string `json:"messageId,omitempty"`
GatewayMessageID string `json:"gatewayMessageId,omitempty"`
Phone string `json:"phone,omitempty"`
ResultCode string `json:"resultCode,omitempty"`
PayloadBytes int `json:"payloadBytes,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
}
type protocolLogEvent = protocollog.Event
func (c *connection) emitDeliverResponse(pkt deliverPacket, responseErr error) {
status := "success"
@@ -71,6 +57,16 @@ func (c *connection) emitDeliverResponse(pkt deliverPacket, responseErr error) {
}
func (c *connection) emitProtocolLog(event protocolLogEvent) {
event.ConnectionID = c.identity()
event.GatewayInstanceID = c.gatewayInstanceID
if c.protocolLogPublisher != nil {
go func() {
if err := c.protocolLogPublisher.Publish(context.Background(), event); err != nil {
log.Printf("protocol log Redis publish failed channel_id=%s message_id=%s error=%q", event.ChannelID, event.MessageID, err)
}
}()
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout)
defer cancel()
+20 -1
View File
@@ -97,6 +97,12 @@ func (p *connectionPool) submit(
}
func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, part submitPart) (uint32, string, queue.SubmitResult, error) {
startedAt := time.Now()
defer func() {
c.mu.Lock()
c.lastSubmitRTT = time.Since(startedAt)
c.mu.Unlock()
}()
rspCh := make(chan submitPartResponse, 1)
pkt := c.submitRequestPacket(cmd, part)
@@ -163,14 +169,20 @@ func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, pa
c.mu.Unlock()
}()
waitCtx, cancel := context.WithTimeout(ctx, defaultSubmitTimeout)
timeout := time.Duration(c.config.SubmitTimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = defaultSubmitTimeout
}
waitCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
select {
case <-waitCtx.Done():
c.markSubmitFailure()
result := submitResult(cmd, seq, "", "timeout", "SUBMIT_TIMEOUT", waitCtx.Err().Error())
return seq, "", result, waitCtx.Err()
case rsp := <-rspCh:
if rsp.err != nil {
c.markSubmitFailure()
result := submitResult(cmd, seq, "", "timeout", "CONNECTION_LOST", rsp.err.Error())
return seq, "", result, rsp.err
}
@@ -203,6 +215,7 @@ func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, pa
},
})
if rsp.result == 0 {
c.markSubmitSuccess()
c.mu.Lock()
c.tracker[rsp.msgID] = cmd
c.mu.Unlock()
@@ -345,6 +358,12 @@ func validateSubmitCommand(cmd queue.SubmitCommand) error {
if cmd.Upstream.Account == "" || cmd.Upstream.PasswordCipher == "" {
return fmt.Errorf("upstream account and passwordCipher are required")
}
if cmd.Upstream.DesiredConnections != 0 && (cmd.Upstream.DesiredConnections < 1 || cmd.Upstream.DesiredConnections > maximumConnections) {
return fmt.Errorf("upstream desiredConnections must be between 1 and %d", maximumConnections)
}
if cmd.Upstream.WindowSize != 0 && (cmd.Upstream.WindowSize < 1 || cmd.Upstream.WindowSize > maximumWindowSize) {
return fmt.Errorf("upstream windowSize must be between 1 and %d", maximumWindowSize)
}
if len(cmd.PhoneNumber) == 0 || len(cmd.Content) == 0 {
return fmt.Errorf("phoneNumber and content are required")
}