Files
creator-hub/internal/controlplane/api/event_channel.go
T
rogee e4c0186f96
douyin-release-gate / verify (push) Failing after 21m22s
fix(events): isolate subscription overflow instead of killing the shared channel
One slow account's full subscription buffer no longer tears down the
WebSocket shared by all accounts on the gateway. The overflowing
subscription is retired with an explicit error and replays unacked
deliveries on resubscribe; the buffer grows from 2 to 64 messages.
2026-10-08 21:57:12 +08:00

256 lines
7.1 KiB
Go

package api
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"git.ipao.vip/rogee/creator-hub/internal/creator"
"git.ipao.vip/rogee/creator-hub/internal/environment"
"github.com/gorilla/websocket"
"github.com/sirupsen/logrus"
)
type channelMessage struct {
Type string `json:"type"`
Subscription string `json:"subscription"`
Error string `json:"error"`
Deliveries []creator.ListenerDelivery `json:"deliveries"`
}
type eventSubscription struct {
hub *gatewayEventChannel
id, alias string
messages chan channelMessage
done chan struct{}
failErr error
}
type gatewayEventChannel struct {
target environment.Gateway
once sync.Once
connectErr error
conn *websocket.Conn
write sync.Mutex
mu sync.Mutex
subs map[string]*eventSubscription
done chan struct{}
finish sync.Once
}
var eventChannels = struct {
sync.Mutex
items map[string]*gatewayEventChannel
}{items: map[string]*gatewayEventChannel{}}
var subscriptionSequence atomic.Uint64
func acquireEventSubscription(ctx context.Context, target environment.Gateway, params map[string]any) (*eventSubscription, error) {
key := target.Endpoint + "\n" + target.Token
eventChannels.Lock()
hub := eventChannels.items[key]
if hub != nil {
select {
case <-hub.done:
hub = nil
default:
}
}
if hub == nil {
hub = &gatewayEventChannel{target: target, subs: map[string]*eventSubscription{}, done: make(chan struct{})}
eventChannels.items[key] = hub
}
alias, _ := params["alias"].(string)
sub := &eventSubscription{hub: hub, id: fmt.Sprint(subscriptionSequence.Add(1)), alias: alias, messages: make(chan channelMessage, 64), done: make(chan struct{})}
// Reserve the subscription before releasing the pool lock, so closing the
// previous last account cannot retire a channel another account is joining.
hub.mu.Lock()
hub.subs[sub.id] = sub
hub.mu.Unlock()
eventChannels.Unlock()
hub.once.Do(func() {
u, err := url.Parse(strings.TrimRight(target.Endpoint, "/") + "/v1/channel")
if err != nil {
hub.connectErr = err
hub.fail(err)
return
}
switch u.Scheme {
case "http":
u.Scheme = "ws"
case "https":
u.Scheme = "wss"
default:
hub.connectErr = errors.New("invalid gateway scheme")
hub.fail(hub.connectErr)
return
}
headers := http.Header{"Authorization": []string{"Bearer " + target.Token}}
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
hub.conn, _, hub.connectErr = dialer.DialContext(ctx, u.String(), headers)
if hub.connectErr != nil {
hub.fail(hub.connectErr)
return
}
hub.conn.SetReadLimit(16 << 20)
hub.conn.SetPingHandler(func(data string) error {
_ = hub.conn.SetReadDeadline(time.Now().Add(45 * time.Second))
return hub.conn.WriteControl(websocket.PongMessage, []byte(data), time.Now().Add(5*time.Second))
})
go hub.receive()
})
if hub.connectErr != nil {
sub.close()
return nil, fmt.Errorf("gateway WS connection: %w", hub.connectErr)
}
value := make(map[string]any, len(params)+2)
for k, v := range params {
value[k] = v
}
value["type"] = "subscribe"
value["subscription"] = sub.id
if err := hub.send(value); err != nil {
sub.close()
return nil, err
}
select {
case message := <-sub.messages:
if message.Type != "subscribed" {
sub.close()
return nil, fmt.Errorf("gateway WS subscription: %s", message.Error)
}
return sub, nil
case <-hub.done:
sub.close()
return nil, errors.New("gateway WS disconnected")
case <-ctx.Done():
sub.close()
return nil, ctx.Err()
}
}
func (h *gatewayEventChannel) send(value any) error {
h.write.Lock()
defer h.write.Unlock()
select {
case <-h.done:
return errors.New("gateway WS disconnected")
default:
}
if err := h.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
return err
}
if err := h.conn.WriteJSON(value); err != nil {
h.fail(err)
return err
}
return nil
}
func (h *gatewayEventChannel) receive() {
for {
_ = h.conn.SetReadDeadline(time.Now().Add(45 * time.Second))
var message channelMessage
if err := h.conn.ReadJSON(&message); err != nil {
h.fail(err)
return
}
if message.Type != "subscribed" && message.Type != "deliveries" && message.Type != "error" {
h.fail(errors.New("invalid gateway WS message"))
return
}
if message.Type == "error" && message.Subscription == "" {
h.fail(errors.New(message.Error))
return
}
h.mu.Lock()
sub := h.subs[message.Subscription]
h.mu.Unlock()
if sub == nil {
continue
} // A closed generation cannot receive delayed replies.
select {
case sub.messages <- message:
default:
// One slow account must not tear down the shared channel: retire only
// this subscription. The gateway keeps unacknowledged deliveries and
// replays them when the account session resubscribes.
spec := fmt.Errorf("event subscription queue overflow; the account session will resubscribe and replay unacknowledged deliveries")
logrus.WithFields(logrus.Fields{"alias": sub.alias, "subscription": sub.id}).Error("event subscription queue overflow; isolating subscription from shared channel")
sub.fail(spec)
}
}
}
func (h *gatewayEventChannel) fail(err error) {
h.finish.Do(func() {
close(h.done)
if h.conn != nil {
_ = h.conn.Close()
}
h.mu.Lock()
defer h.mu.Unlock()
for _, sub := range h.subs {
select {
case sub.messages <- channelMessage{Type: "error", Error: err.Error()}:
default:
}
}
})
}
// fail retires one subscription without touching the shared channel: the
// account session restarts and the gateway replays unacknowledged deliveries.
// Only the receive goroutine calls this, so close(done) happens exactly once.
func (s *eventSubscription) fail(err error) {
if err == nil {
err = errors.New("event subscription failed")
}
s.hub.mu.Lock()
delete(s.hub.subs, s.id)
s.hub.mu.Unlock()
s.failErr = err
close(s.done)
}
func (s *eventSubscription) poll(ctx context.Context, acks []string) ([]creator.ListenerDelivery, error) {
if len(acks) > 0 {
if err := s.hub.send(map[string]any{"type": "ack", "alias": s.alias, "subscription": s.id, "delivery_ids": acks}); err != nil {
return nil, err
}
}
select {
case message := <-s.messages:
if message.Type != "deliveries" || len(message.Deliveries) == 0 {
return nil, fmt.Errorf("gateway event delivery: %s", message.Error)
}
for _, d := range message.Deliveries {
if err := d.Validate(); err != nil {
return nil, err
}
}
return message.Deliveries, nil
case <-s.done:
return nil, s.failErr
case <-s.hub.done:
return nil, errors.New("gateway WS disconnected")
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (s *eventSubscription) close() {
_ = s.hub.send(map[string]any{"type": "unsubscribe", "alias": s.alias, "subscription": s.id})
key := s.hub.target.Endpoint + "\n" + s.hub.target.Token
eventChannels.Lock()
s.hub.mu.Lock()
delete(s.hub.subs, s.id)
empty := len(s.hub.subs) == 0
if empty && eventChannels.items[key] == s.hub {
delete(eventChannels.items, key)
}
s.hub.mu.Unlock()
eventChannels.Unlock()
if empty {
s.hub.fail(errors.New("gateway event channel stopped"))
}
}