feat: add reliable event consumer SDK and process recovery tests

This commit is contained in:
2026-09-17 20:22:41 +08:00
parent 8485a662b2
commit b17d9b1977
25 changed files with 2170 additions and 28 deletions

285
sdk/go/event_connection.go Normal file
View 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() }