perf(cmpp): process inbound submits within client window

This commit is contained in:
hectorzhao
2026-08-20 11:45:16 +08:00
parent b9a71fe0b9
commit 0757a699ff
20 changed files with 389 additions and 47 deletions
+1
View File
@@ -12,6 +12,7 @@
- 支持断线重连和后续消息继续消费。
- 暴露健康检查和最小指标。
- Redis Stream Submit Worker 使用持续补位的有界工作池并逐条 ACK;默认并发64,可用`GATEWAY_SUBMIT_WORKER_CONCURRENCY`调整,最大1024。供应商通道的真实上限仍由TPS限速、连接数和CMPP窗口共同决定。
- 客户CMPP入站Submit按认证接口返回的应用`cmppWindowSize`在单连接内并发,Gateway再以`GATEWAY_CMPP_INBOUND_MAX_CONCURRENCY`实施默认64、最大1024的全局单连接保护。登录保持串行,心跳和Deliver ACK不等待慢SubmitSubmitResp依靠Sequence_Id关联,允许按完成顺序返回。
## 建议骨架
+8 -6
View File
@@ -48,12 +48,13 @@ func main() {
go func() {
log.Printf("cmpp gateway inbound server listening on %s", cmppAddr)
if err := (inbound.Server{
Addr: cmppAddr,
APIBaseURL: apiBaseURL,
PresenceStore: presenceStore,
RecoveryStore: recoveryStore,
GatewayInstanceID: getenv("GATEWAY_INSTANCE_ID", hostname()),
SecurityEventToken: os.Getenv("SECURITY_EVENT_TOKEN"),
Addr: cmppAddr,
APIBaseURL: apiBaseURL,
PresenceStore: presenceStore,
RecoveryStore: recoveryStore,
GatewayInstanceID: getenv("GATEWAY_INSTANCE_ID", hostname()),
MaxSubmitConcurrency: positiveEnvInt("GATEWAY_CMPP_INBOUND_MAX_CONCURRENCY", 64),
SecurityEventToken: os.Getenv("SECURITY_EVENT_TOKEN"),
}).ListenAndServe(); err != nil {
log.Fatalf("gateway inbound server stopped: %v", err)
}
@@ -90,6 +91,7 @@ func main() {
snapshot.SubmitWorkerConcurrency = worker.ConfiguredConcurrency()
snapshot.SubmitWorkerInFlight = worker.InFlight()
}
snapshot.InboundSubmitConcurrency, snapshot.InboundSubmitInFlight = inbound.SubmitSlotSnapshot()
if worker == nil || worker.Redis == nil {
return snapshot
}
@@ -9,6 +9,7 @@ import (
"net"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -28,6 +29,7 @@ type authResponse struct {
Account string `json:"account"`
EnterpriseCode string `json:"enterpriseCode"`
MaxConnections int `json:"maxConnections"`
WindowSize int `json:"windowSize"`
}
func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) {
@@ -59,6 +61,8 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
applicationID: strings.TrimSpace(auth.ApplicationID),
enterpriseCode: strings.TrimSpace(auth.EnterpriseCode),
protocol: cmppVersionName(req.Version),
windowSize: boundedSubmitWindow(auth.WindowSize, s.MaxSubmitConcurrency),
submitInFlight: &atomic.Int64{},
srcID: strings.TrimSpace(auth.Account),
remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()),
connectedAt: now,
+12 -1
View File
@@ -20,6 +20,7 @@ type Server struct {
PresenceStore PresenceStore
RecoveryStore RecoveryStore
GatewayInstanceID string
MaxSubmitConcurrency int
}
func (s Server) ListenAndServe() error {
@@ -30,9 +31,19 @@ func (s Server) ListenAndServe() error {
s.logRecoveryCandidates(log.Default())
go s.recoverPendingCandidates(log.Default())
go s.runPendingFlusher(log.Default())
return cmpp.ListenAndServeWithClose(addr, cmpp.V30, 30*time.Second, 3, s.LogWriter, s.handleConnectionClosed,
return cmpp.ListenAndServeWithCloseAndSubmitWindow(addr, cmpp.V30, 30*time.Second, 3, s.LogWriter, s.handleConnectionClosed, submitWindowByConn,
cmpp.HandlerFunc(s.handleLogin),
cmpp.HandlerFunc(s.handleSubmit),
cmpp.HandlerFunc(s.handleActivity),
)
}
func boundedSubmitWindow(applicationWindow int, gatewayMaximum int) int {
if applicationWindow < 1 {
applicationWindow = 1
}
if gatewayMaximum < 1 {
gatewayMaximum = 64
}
return min(applicationWindow, min(gatewayMaximum, 1024))
}
+111
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
@@ -237,6 +238,116 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
}
}
func TestInboundServerProcessesSubmitWithinAuthenticatedConnectionWindow(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
account := "100020"
password := "window-secret"
firstStarted := make(chan struct{})
secondStarted := make(chan struct{})
releaseFirst := make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(releaseFirst) })
var calls atomic.Int32
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/gateway/events/inbound/authenticate":
_ = json.NewEncoder(w).Encode(authResponse{
PasswordCipher: password, Account: account, EnterpriseCode: account, WindowSize: 2,
})
case "/api/gateway/events/inbound/submit":
call := calls.Add(1)
if call == 1 {
close(firstStarted)
<-releaseFirst
} else {
close(secondStarted)
}
_ = json.NewEncoder(w).Encode(submitResponse{Accepted: true, MessageID: fmt.Sprintf("MSG-WINDOW-%d", call)})
case "/api/gateway/events/inbound/connection", "/api/gateway/events/protocol-log":
w.WriteHeader(http.StatusOK)
case "/api/gateway/events/downstream/pending":
_ = json.NewEncoder(w).Encode([]pendingDelivery{})
default:
w.WriteHeader(http.StatusOK)
}
}))
defer api.Close()
addr := reserveTCPAddr(t)
go func() {
_ = (Server{Addr: addr, APIBaseURL: api.URL + "/api", MaxSubmitConcurrency: 2}).ListenAndServe()
}()
time.Sleep(300 * time.Millisecond)
client := cmpp.NewClient(cmpp.V30)
defer client.Disconnect()
if err := client.Connect(addr, account, password, 2*time.Second); err != nil {
t.Fatalf("connect inbound cmpp: %v", err)
}
packet := func(phone string) *cmpp.Cmpp3SubmitReqPkt {
return &cmpp.Cmpp3SubmitReqPkt{
PkTotal: 1, PkNumber: 1, RegisteredDelivery: 1, MsgLevel: 1,
ServiceId: "cmpp", FeeUserType: 2, FeeTerminalId: phone, MsgFmt: 0,
MsgSrc: account, FeeType: "02", FeeCode: "0", SrcId: "10690000",
DestUsrTl: 1, DestTerminalId: []string{phone}, MsgLength: 6, MsgContent: "window",
}
}
firstSequence, err := client.SendReqPkt(packet("13800000001"))
if err != nil {
t.Fatalf("send first submit: %v", err)
}
<-firstStarted
secondSequence, err := client.SendReqPkt(packet("13800000002"))
if err != nil {
t.Fatalf("send second submit: %v", err)
}
select {
case <-secondStarted:
case <-time.After(time.Second):
t.Fatal("second Submit did not enter the authenticated connection window")
}
response := recvSubmitRsp(t, client)
if response.SeqId != secondSequence {
t.Fatalf("first completed response sequence=%d, want second sequence=%d (first=%d)", response.SeqId, secondSequence, firstSequence)
}
releaseOnce.Do(func() { close(releaseFirst) })
response = recvSubmitRsp(t, client)
if response.SeqId != firstSequence {
t.Fatalf("released response sequence=%d, want first sequence=%d", response.SeqId, firstSequence)
}
}
func TestBoundedSubmitWindowAndAggregateSlotSnapshot(t *testing.T) {
for _, test := range []struct {
application int
maximum int
want int
}{
{application: 0, maximum: 0, want: 1},
{application: 32, maximum: 64, want: 32},
{application: 32, maximum: 16, want: 16},
{application: 2048, maximum: 2048, want: 1024},
} {
if got := boundedSubmitWindow(test.application, test.maximum); got != test.want {
t.Fatalf("boundedSubmitWindow(%d, %d)=%d, want %d", test.application, test.maximum, got, test.want)
}
}
resetDownstreamRegistry()
defer resetDownstreamRegistry()
firstCounter := &atomic.Int64{}
secondCounter := &atomic.Int64{}
firstCounter.Store(3)
secondCounter.Store(1)
downstreamRegistry.byConn[&cmpp.Conn{}] = &downstreamSession{windowSize: 32, submitInFlight: firstCounter}
downstreamRegistry.byConn[&cmpp.Conn{}] = &downstreamSession{windowSize: 16, submitInFlight: secondCounter}
configured, inFlight := SubmitSlotSnapshot()
if configured != 48 || inFlight != 4 {
t.Fatalf("slot snapshot configured=%d in_flight=%d, want 48/4", configured, inFlight)
}
}
func TestDecodeInboundLongMessageStripsConcatUDHBeforeUCS2Decode(t *testing.T) {
payload, err := cmpputils.Utf8ToUcs2("【深圳市合正物业服务有限公司】第一片正文")
if err != nil {
+36
View File
@@ -6,6 +6,7 @@ import (
"log"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -30,6 +31,8 @@ type downstreamSession struct {
applicationID string
enterpriseCode string
protocol string
windowSize int
submitInFlight *atomic.Int64
srcID string
phoneNumber string
gatewayMsgID uint64
@@ -123,6 +126,39 @@ func findSessionByConn(conn *cmpp.Conn) *downstreamSession {
return downstreamRegistry.byConn[conn]
}
func submitWindowByConn(conn *cmpp.Conn) int {
session := findSessionByConn(conn)
if session == nil || session.windowSize < 1 {
return 1
}
return session.windowSize
}
func beginInboundSubmit(session *downstreamSession) func() {
if session == nil || session.submitInFlight == nil {
return func() {}
}
session.submitInFlight.Add(1)
return func() { session.submitInFlight.Add(-1) }
}
func SubmitSlotSnapshot() (int, int64) {
downstreamRegistry.RLock()
defer downstreamRegistry.RUnlock()
configured := 0
var inFlight int64
for _, session := range downstreamRegistry.byConn {
if session == nil {
continue
}
configured += max(1, session.windowSize)
if session.submitInFlight != nil {
inFlight += session.submitInFlight.Load()
}
}
return configured, inFlight
}
func rememberDownstream(session downstreamSession) {
if session.messageID == "" || session.conn == nil {
return
+2
View File
@@ -69,6 +69,8 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge
response.AfterSend = observeInboundSubmitResponse(handlerStartedAt, responseReadyAt, false, s.submitResponseProtocolLogger("", req.protocol, req.sequenceID, "", "", 0, 9))
return false, nil
}
releaseInboundSlot := beginInboundSubmit(session)
defer releaseInboundSlot()
account := session.account
enterpriseCode := strings.TrimRight(req.msgSrc, "\x00")
if session.enterpriseCode != "" && enterpriseCode != session.enterpriseCode {
+13 -10
View File
@@ -37,16 +37,18 @@ var inboundStageHistograms [4][2]durationHistogram
var submitStageHistograms [5][2]durationHistogram
type Snapshot struct {
UpstreamDesired int
UpstreamConnected int
DownstreamConnected int
SubmitWorkerUp bool
SubmitWorkerConcurrency int
SubmitWorkerInFlight int64
QueueAvailable bool
QueuePending int64
QueueLag int64
QueueOldestAgeSeconds float64
UpstreamDesired int
UpstreamConnected int
DownstreamConnected int
SubmitWorkerUp bool
SubmitWorkerConcurrency int
SubmitWorkerInFlight int64
InboundSubmitConcurrency int
InboundSubmitInFlight int64
QueueAvailable bool
QueuePending int64
QueueLag int64
QueueOldestAgeSeconds float64
}
type SnapshotFunc func(context.Context) Snapshot
@@ -116,6 +118,7 @@ func Handler(load SnapshotFunc) http.Handler {
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)
fmt.Fprintf(response, "# HELP cmpp_gateway_inbound_submit_slots Configured and active authenticated client Submit slots.\n# TYPE cmpp_gateway_inbound_submit_slots gauge\ncmpp_gateway_inbound_submit_slots{state=\"configured\"} %d\ncmpp_gateway_inbound_submit_slots{state=\"in_flight\"} %d\n", snapshot.InboundSubmitConcurrency, snapshot.InboundSubmitInFlight)
if snapshot.QueueAvailable {
fmt.Fprintf(response, "# HELP cmpp_gateway_submit_queue_pending Pending entries owned by the consumer group.\n# TYPE cmpp_gateway_submit_queue_pending gauge\ncmpp_gateway_submit_queue_pending %d\n", snapshot.QueuePending)
fmt.Fprintf(response, "# HELP cmpp_gateway_submit_queue_lag Undelivered entries for the consumer group.\n# TYPE cmpp_gateway_submit_queue_lag gauge\ncmpp_gateway_submit_queue_lag %d\n", snapshot.QueueLag)
+3 -1
View File
@@ -16,7 +16,7 @@ func TestMetricsHandlerExportsOnlyAggregateGatewayState(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/metrics", nil)
response := httptest.NewRecorder()
Handler(func(context.Context) Snapshot {
return Snapshot{UpstreamDesired: 2, UpstreamConnected: 1, DownstreamConnected: 3, SubmitWorkerUp: true, SubmitWorkerConcurrency: 64, SubmitWorkerInFlight: 7, QueueAvailable: true, QueuePending: 4, QueueLag: 5, QueueOldestAgeSeconds: 12}
return Snapshot{UpstreamDesired: 2, UpstreamConnected: 1, DownstreamConnected: 3, SubmitWorkerUp: true, SubmitWorkerConcurrency: 64, SubmitWorkerInFlight: 7, InboundSubmitConcurrency: 96, InboundSubmitInFlight: 9, QueueAvailable: true, QueuePending: 4, QueueLag: 5, QueueOldestAgeSeconds: 12}
}).ServeHTTP(response, request)
body := response.Body.String()
@@ -28,6 +28,8 @@ func TestMetricsHandlerExportsOnlyAggregateGatewayState(t *testing.T) {
`cmpp_gateway_submit_stage_duration_seconds_count{stage="rate_limit_wait",result="success"} 1`,
`cmpp_gateway_submit_worker_slots{state="configured"} 64`,
`cmpp_gateway_submit_worker_slots{state="in_flight"} 7`,
`cmpp_gateway_inbound_submit_slots{state="configured"} 96`,
`cmpp_gateway_inbound_submit_slots{state="in_flight"} 9`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("metrics response is missing %q: %s", expected, body)
+89 -18
View File
@@ -20,6 +20,7 @@ import (
"log"
"net"
"os"
"sync"
"sync/atomic"
"time"
)
@@ -80,6 +81,9 @@ type Server struct {
// standard logger.
ErrorLog *log.Logger
OnClose func(*Conn)
// SubmitWindow resolves the authenticated client's allowed in-flight Submit
// count. A nil resolver, or a value below two, preserves serial handling.
SubmitWindow func(*Conn) int
}
// A conn represents the server side of a Cmpp connection.
@@ -394,7 +398,14 @@ func (c *conn) serve() {
}
}()
var submitGroup sync.WaitGroup
var submitSlots chan struct{}
fatal := make(chan error, 1)
defer func() {
// Why wait: a handler may persist the message and register receipt routing
// after the peer disconnects. Session cleanup must run after every accepted
// in-flight request finishes, otherwise a late handler can recreate stale state.
submitGroup.Wait()
c.close()
if c.server.OnClose != nil {
c.server.OnClose(c.Conn)
@@ -408,6 +419,8 @@ func (c *conn) serve() {
select {
case <-c.exceed:
return // close the connection.
case <-fatal:
return
default:
}
@@ -426,29 +439,81 @@ func (c *conn) serve() {
break
}
_, err = c.server.Handler.ServeCmpp(r, r.Packet, c.server.ErrorLog)
err1 := c.finishPacket(r)
if r.AfterSend != nil {
r.AfterSend(err1)
if isSubmitPacket(r.Packet.Packer) && c.submitWindow() > 1 {
if submitSlots == nil {
submitSlots = make(chan struct{}, c.submitWindow())
}
select {
case submitSlots <- struct{}{}:
case <-c.exceed:
return
case <-fatal:
return
}
submitGroup.Add(1)
go func(response *Response) {
defer submitGroup.Done()
defer func() { <-submitSlots }()
if handleErr := c.handlePacket(response); handleErr != nil {
select {
case fatal <- handleErr:
default:
}
}
}(r)
continue
}
if err1 != nil {
c.server.ErrorLog.Printf(
"send response packet failed remote=%v protocol=%s packet_type=%T seq=%d err_type=%T err=%v",
c.Conn.RemoteAddr(), c.Conn.Typ, r.Packer, r.SeqId, err1, err1,
)
break
}
if err != nil {
c.server.ErrorLog.Printf(
"handler failed remote=%v protocol=%s packet_type=%T seq=%d err_type=%T err=%v",
c.Conn.RemoteAddr(), c.Conn.Typ, r.Packet.Packer, r.SeqId, err, err,
)
if err = c.handlePacket(r); err != nil {
break
}
}
}
func (c *conn) submitWindow() int {
if c.server.SubmitWindow == nil {
return 1
}
window := c.server.SubmitWindow(c.Conn)
if window < 1 {
return 1
}
if window > 1024 {
return 1024
}
return window
}
func isSubmitPacket(packet Packer) bool {
switch packet.(type) {
case *Cmpp2SubmitReqPkt, *Cmpp3SubmitReqPkt:
return true
default:
return false
}
}
func (c *conn) handlePacket(r *Response) error {
_, handlerErr := c.server.Handler.ServeCmpp(r, r.Packet, c.server.ErrorLog)
sendErr := c.finishPacket(r)
if r.AfterSend != nil {
r.AfterSend(sendErr)
}
if sendErr != nil {
c.server.ErrorLog.Printf(
"send response packet failed remote=%v protocol=%s packet_type=%T seq=%d err_type=%T err=%v",
c.Conn.RemoteAddr(), c.Conn.Typ, r.Packer, r.SeqId, sendErr, sendErr,
)
return sendErr
}
if handlerErr != nil {
c.server.ErrorLog.Printf(
"handler failed remote=%v protocol=%s packet_type=%T seq=%d err_type=%T err=%v",
c.Conn.RemoteAddr(), c.Conn.Typ, r.Packet.Packer, r.SeqId, handlerErr, handlerErr,
)
}
return handlerErr
}
// Create new connection from rwc.
func (srv *Server) newConn(rwc net.Conn) (c *conn, err error) {
c = new(conn)
@@ -480,6 +545,12 @@ func ListenAndServe(addr string, typ Type, t time.Duration, n int32, logWriter i
// ListenAndServeWithClose behaves like ListenAndServe and invokes onClose once
// after an accepted client connection ends, including abrupt TCP disconnects.
func ListenAndServeWithClose(addr string, typ Type, t time.Duration, n int32, logWriter io.Writer, onClose func(*Conn), handlers ...Handler) error {
return ListenAndServeWithCloseAndSubmitWindow(addr, typ, t, n, logWriter, onClose, nil, handlers...)
}
// ListenAndServeWithCloseAndSubmitWindow adds bounded per-connection Submit
// concurrency while keeping login, heartbeat and acknowledgement handling serial.
func ListenAndServeWithCloseAndSubmitWindow(addr string, typ Type, t time.Duration, n int32, logWriter io.Writer, onClose func(*Conn), submitWindow func(*Conn) int, handlers ...Handler) error {
if addr == "" {
return ErrEmptyServerAddr
}
@@ -504,7 +575,7 @@ func ListenAndServeWithClose(addr string, typ Type, t time.Duration, n int32, lo
}
server := &Server{Addr: addr, Handler: handler, Typ: typ,
T: t, N: n,
ErrorLog: log.New(logWriter, "cmppserver: ", log.LstdFlags), OnClose: onClose}
ErrorLog: log.New(logWriter, "cmppserver: ", log.LstdFlags), OnClose: onClose, SubmitWindow: submitWindow}
return server.listenAndServe()
}