feat: add reliable event consumer SDK and process recovery tests
This commit is contained in:
285
sdk/go/event_connection.go
Normal file
285
sdk/go/event_connection.go
Normal file
@@ -0,0 +1,285 @@
|
||||
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() }
|
||||
Reference in New Issue
Block a user