286 lines
8.6 KiB
Go
286 lines
8.6 KiB
Go
package orbit
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/coder/websocket"
|
||
)
|
||
|
||
const eventSubprotocol = "orbit.events.v1"
|
||
const eventMaxPending = 32
|
||
|
||
// EventConnection 管理单次连接、请求关联和有界收发。可并发调用Ack/Ping。
|
||
// 本类型不自动心跳/重连/调用处理器;高层消费请使用EventConsumer。
|
||
type EventConnection struct {
|
||
conn *websocket.Conn
|
||
ctx context.Context
|
||
cancel context.CancelFunc
|
||
welcome EventWelcome
|
||
deliveries chan EventGrant
|
||
done chan struct{}
|
||
mu sync.Mutex
|
||
err error
|
||
pending map[string]chan eventFrame
|
||
}
|
||
|
||
// DialEvents 的ctx仅控制握手;成功后必须Close,取消握手ctx不会关闭已建立的连接。
|
||
func (c *Client) DialEvents(ctx context.Context, in EventRegistration) (*EventConnection, error) {
|
||
if c.token == "" || !validID(in.SubscriptionID) || in.Capacity < 1 || in.Capacity > 32 {
|
||
return nil, errors.New("authenticated client, valid subscription and capacity 1..32 are required")
|
||
}
|
||
bounded, stop := context.WithTimeout(ctx, 8*time.Second)
|
||
defer stop()
|
||
headers := http.Header{}
|
||
headers.Set("Authorization", "Bearer "+c.token)
|
||
conn, response, err := websocket.Dial(bounded, c.baseURL+"/v1/events/connect", &websocket.DialOptions{HTTPClient: c.http, HTTPHeader: headers, Subprotocols: []string{eventSubprotocol}, CompressionMode: websocket.CompressionDisabled})
|
||
if err != nil {
|
||
if response != nil && response.StatusCode != http.StatusSwitchingProtocols {
|
||
return nil, &HTTPError{StatusCode: response.StatusCode}
|
||
}
|
||
return nil, eventIOError(err)
|
||
}
|
||
ok := false
|
||
defer func() {
|
||
if !ok {
|
||
_ = conn.CloseNow()
|
||
}
|
||
}()
|
||
if conn.Subprotocol() != eventSubprotocol {
|
||
return nil, ErrEventProtocol
|
||
}
|
||
conn.SetReadLimit(512 << 10)
|
||
id, err := NewEventMessageID()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
body, _ := marshalWire(in)
|
||
raw, _ := marshalWire(eventFrame{Version: "1", Type: "events.hello", MessageID: id, Body: body})
|
||
if err = conn.Write(bounded, websocket.MessageText, raw); err != nil {
|
||
return nil, eventIOError(err)
|
||
}
|
||
kind, raw, err := conn.Read(bounded)
|
||
if err != nil {
|
||
return nil, eventIOError(err)
|
||
}
|
||
f, err := parseEventFrame(kind, raw)
|
||
if err != nil || f.ReplyTo != id {
|
||
return nil, ErrEventProtocol
|
||
}
|
||
if f.Type == "error" {
|
||
return nil, f.Error
|
||
}
|
||
var welcome EventWelcome
|
||
if f.Type != "events.welcome" || decodeEventValue(f.Body, &welcome) != nil || welcome.SessionID != f.SessionID || welcome.SubscriptionID != in.SubscriptionID || welcome.Capacity != in.Capacity || welcome.ServerTime.IsZero() || welcome.LeaseDurationMS != 30000 || welcome.HeartbeatIntervalMS != 15000 {
|
||
return nil, ErrEventProtocol
|
||
}
|
||
lifetime, cancel := context.WithCancel(context.Background())
|
||
e := &EventConnection{conn: conn, ctx: lifetime, cancel: cancel, welcome: welcome, deliveries: make(chan EventGrant, in.Capacity), done: make(chan struct{}), pending: make(map[string]chan eventFrame)}
|
||
ok = true
|
||
go e.readLoop()
|
||
return e, nil
|
||
}
|
||
func (e *EventConnection) Welcome() EventWelcome { return e.welcome }
|
||
func (e *EventConnection) Done() <-chan struct{} { return e.done }
|
||
func (e *EventConnection) Err() error { e.mu.Lock(); defer e.mu.Unlock(); return e.err }
|
||
func (e *EventConnection) Close() error { e.fail(ErrEventClosed); <-e.done; return nil }
|
||
func (e *EventConnection) fail(err error) {
|
||
e.mu.Lock()
|
||
if e.err != nil {
|
||
e.mu.Unlock()
|
||
return
|
||
}
|
||
e.err = err
|
||
e.cancel()
|
||
e.mu.Unlock()
|
||
_ = e.conn.CloseNow()
|
||
}
|
||
func (e *EventConnection) readLoop() {
|
||
defer close(e.done)
|
||
for {
|
||
ctx, stop := context.WithTimeout(e.ctx, 60*time.Second)
|
||
kind, raw, err := e.conn.Read(ctx)
|
||
stop()
|
||
if err != nil {
|
||
e.fail(eventIOError(err))
|
||
return
|
||
}
|
||
f, err := parseEventFrame(kind, raw)
|
||
if err != nil || f.SessionID != e.welcome.SessionID {
|
||
e.fail(ErrEventProtocol)
|
||
return
|
||
}
|
||
if f.Type == "event.deliver" {
|
||
var grant EventGrant
|
||
if decodeEventValue(f.Body, &grant) != nil || !validEventGrant(grant, e.welcome.SubscriptionID) {
|
||
e.fail(ErrEventProtocol)
|
||
return
|
||
}
|
||
select {
|
||
case e.deliveries <- grant:
|
||
default:
|
||
e.fail(ErrEventBackpressure)
|
||
return
|
||
}
|
||
continue
|
||
}
|
||
if f.Type != "ack" && f.Type != "error" {
|
||
e.fail(ErrEventProtocol)
|
||
return
|
||
}
|
||
if f.Type == "error" && f.ReplyTo == "" {
|
||
e.fail(f.Error)
|
||
return
|
||
}
|
||
e.mu.Lock()
|
||
waiter := e.pending[f.ReplyTo]
|
||
e.mu.Unlock()
|
||
if waiter == nil {
|
||
e.fail(ErrEventProtocol)
|
||
return
|
||
}
|
||
select {
|
||
case waiter <- f:
|
||
default:
|
||
e.fail(ErrEventProtocol)
|
||
return
|
||
}
|
||
if f.Error != nil && (f.Error.Code == "UNAUTHENTICATED" || f.Error.Code == "PERMISSION_DENIED" || f.Error.Code == "NOT_FOUND" || f.Error.Code == "UNAVAILABLE") {
|
||
e.fail(f.Error)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// Next 等待下一条交付。ctx到期只中断本次等待;未确认交付按服务端预算补发。
|
||
func (e *EventConnection) Next(ctx context.Context) (EventGrant, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return EventGrant{}, err
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return EventGrant{}, ctx.Err()
|
||
case <-e.ctx.Done():
|
||
return EventGrant{}, e.Err()
|
||
case g := <-e.deliveries:
|
||
if err := e.Err(); err != nil {
|
||
return EventGrant{}, err
|
||
}
|
||
return g, nil
|
||
}
|
||
}
|
||
func (e *EventConnection) call(ctx context.Context, kind, id string, in, out any) error {
|
||
if !validID(id) {
|
||
return errors.New("invalid event message id")
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
bounded, stop := context.WithTimeout(ctx, 10*time.Second)
|
||
defer stop()
|
||
waiter := make(chan eventFrame, 1)
|
||
e.mu.Lock()
|
||
if e.err != nil {
|
||
err := e.err
|
||
e.mu.Unlock()
|
||
return err
|
||
}
|
||
if len(e.pending) >= eventMaxPending {
|
||
e.mu.Unlock()
|
||
return ErrEventBusy
|
||
}
|
||
if e.pending[id] != nil {
|
||
e.mu.Unlock()
|
||
return errors.New("event message id is already in flight")
|
||
}
|
||
e.pending[id] = waiter
|
||
e.mu.Unlock()
|
||
defer func() { e.mu.Lock(); delete(e.pending, id); e.mu.Unlock() }()
|
||
body, err := marshalWire(in)
|
||
if err != nil {
|
||
return errors.New("invalid event request")
|
||
}
|
||
raw, err := marshalWire(eventFrame{Version: "1", Type: kind, MessageID: id, SessionID: e.welcome.SessionID, Body: body})
|
||
if err != nil || len(raw) > 512<<10 {
|
||
return errors.New("event request exceeds frame limit")
|
||
}
|
||
if err = e.conn.Write(bounded, websocket.MessageText, raw); err != nil {
|
||
err = eventIOError(err)
|
||
e.fail(err)
|
||
return err
|
||
}
|
||
var f eventFrame
|
||
select {
|
||
case f = <-waiter:
|
||
case <-e.ctx.Done():
|
||
select {
|
||
case f = <-waiter:
|
||
default:
|
||
return e.Err()
|
||
}
|
||
case <-bounded.Done():
|
||
e.fail(bounded.Err())
|
||
return bounded.Err()
|
||
}
|
||
if f.Error != nil {
|
||
return f.Error
|
||
}
|
||
if decodeEventValue(f.Body, out) != nil {
|
||
e.fail(ErrEventProtocol)
|
||
return ErrEventProtocol
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Ack 仅在业务事务已提交或持久化去重命中后调用。重连可用原Proof重报,MessageID可新建。
|
||
// 超时/断线不证明ACK未提交;DELIVERY_LEASE_LOST表示证明已失效,不应撤销已经提交的业务。
|
||
func (e *EventConnection) Ack(ctx context.Context, id string, p EventProof) (EventAckResult, error) {
|
||
var out EventAckResult
|
||
if !validEventProof(p) || p.SubscriptionID != e.welcome.SubscriptionID {
|
||
return out, errors.New("invalid event proof or subscription")
|
||
}
|
||
in := struct {
|
||
DeliveryID string `json:"delivery_id"`
|
||
Epoch uint64 `json:"epoch,string"`
|
||
LeaseSecret string `json:"lease_secret"`
|
||
}{p.DeliveryID, p.Epoch, p.LeaseSecret}
|
||
err := e.call(ctx, "event.ack", id, in, &out)
|
||
if err == nil && (out.DeliveryID != p.DeliveryID || out.EventID != p.EventID || out.Epoch != p.Epoch || out.AcknowledgedAt.IsZero()) {
|
||
err = ErrEventProtocol
|
||
e.fail(err)
|
||
}
|
||
return out, err
|
||
}
|
||
|
||
// Ping 重新核验凭据及订阅,并维持连接;不会续期交付租约。
|
||
func (e *EventConnection) Ping(ctx context.Context, id string) (EventPong, error) {
|
||
var out EventPong
|
||
err := e.call(ctx, "events.ping", id, struct{}{}, &out)
|
||
if err == nil && out.ServerTime.IsZero() {
|
||
err = ErrEventProtocol
|
||
e.fail(err)
|
||
}
|
||
return out, err
|
||
}
|
||
func eventIOError(err error) error {
|
||
if errors.Is(err, context.Canceled) {
|
||
return context.Canceled
|
||
}
|
||
if errors.Is(err, context.DeadlineExceeded) {
|
||
return context.DeadlineExceeded
|
||
}
|
||
switch websocket.CloseStatus(err) {
|
||
case websocket.StatusProtocolError, websocket.StatusUnsupportedData, websocket.StatusInvalidFramePayloadData, websocket.StatusPolicyViolation, websocket.StatusMessageTooBig:
|
||
return ErrEventProtocol
|
||
}
|
||
return ErrEventDisconnected
|
||
}
|
||
func (e *EventConnection) String() string {
|
||
return fmt.Sprintf("EventConnection{SubscriptionID:%s SessionID:%s secrets:redacted}", e.welcome.SubscriptionID, e.welcome.SessionID)
|
||
}
|
||
func (e *EventConnection) GoString() string { return e.String() }
|