feat: expose result subscription HTTP and WebSocket APIs

This commit is contained in:
2026-09-17 19:57:55 +08:00
parent 1c3300cc5b
commit 8485a662b2
38 changed files with 3462 additions and 26 deletions

View File

@@ -0,0 +1,109 @@
// subscriptions 演示订阅管理和幂等回执;事件消费及Inbox完整示例随后独立提供。
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
token, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_QUEUE_ID")
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
var credential struct {
Token string `json:"token"`
QueueID string `json:"queue_id"`
}
if err = json.Unmarshal(raw, &credential); err != nil {
return err
}
if token == "" {
token = credential.Token
}
if queue == "" {
queue = credential.QueueID
}
}
if token == "" || queue == "" {
return errors.New("provide an event-enabled credential and queue via ORBIT_CREDENTIALS_FILE or environment")
}
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var random [16]byte
if _, err = rand.Read(random[:]); err != nil {
return err
}
operation := hex.EncodeToString(random[:])
input := orbit.CreateSubscriptionRequest{Name: "results-" + operation, QueueID: queue, Types: []string{"task.succeeded", "task.failed", "task.cancelled", "task.needs_review"}, MaxPending: 100, MaxAttempts: 20}
created, err := client.CreateSubscription(ctx, "create-"+operation, input)
if err != nil {
return err
}
duplicate, err := client.CreateSubscription(ctx, "create-"+operation, input)
if err != nil {
return err
}
if !duplicate.Deduplicated || duplicate.Subscription.ID != created.Subscription.ID {
return errors.New("subscription identity was not preserved")
}
sub := created.Subscription
configure := func(enabled bool, key string) error {
out, err := client.ConfigureSubscription(ctx, sub.ID, key, orbit.ConfigureSubscriptionRequest{ExpectedRevision: sub.Revision, Enabled: enabled, Reason: "subscription management example"})
if err == nil {
sub = out.Subscription
}
return err
}
if err = configure(false, "pause-"+operation); err != nil {
return err
}
if err = configure(true, "resume-"+operation); err != nil {
return err
}
current, err := client.GetSubscription(ctx, sub.ID)
if err != nil {
return err
}
if !current.Enabled || current.Revision != 3 {
return errors.New("subscription control state mismatch")
}
page, err := client.ListSubscriptions(ctx, queue, orbit.SubscriptionPageRequest{Limit: 100})
if err != nil {
return err
}
found := false
for _, item := range page.Items {
found = found || item.ID == sub.ID
}
if !found {
return errors.New("subscription absent from scoped listing")
}
// 示例不启动消费者。结束时暂停本次订阅,保留配置和审计以便核查。
if err = configure(false, "finish-"+operation); err != nil {
return err
}
fmt.Printf("subscription=%s duplicate=true resumed_revision=3\n", sub.ID)
fmt.Printf("final_enabled=%t revision=%d pending=%d\n", sub.Enabled, sub.Revision, sub.PendingCount)
return nil
}

View File

@@ -0,0 +1,126 @@
package orbit
import (
"encoding/json"
"time"
)
// CreateSubscriptionRequest 冻结一个队列的结果类型;已有订阅不能改写过滤或起点。
type CreateSubscriptionRequest struct {
Name string `json:"name"` // Name 命名空间内唯一名称,1至128个ASCII字符。
QueueID string `json:"queue_id"` // QueueID 固定队列,创建和消费都需要相应队列权限。
Types []string `json:"types,omitempty"` // Types 四种任务结果的子集;省略使用全部。
FromSequence *uint64 `json:"from_sequence,string,omitempty"` // FromSequence 之后的结果纳入订阅;nil从创建时流头开始,指向0从迁移13后的现存日志开始。
MaxPending uint32 `json:"max_pending,omitempty"` // MaxPending 未确认窗口,0使用100;自定义1至1000。
MaxAttempts uint32 `json:"max_attempts,omitempty"` // MaxAttempts 初始交付次数预算,0使用20;自定义1至100。
}
// Subscription 仅包含订阅配置和窗口进度,不包含任何凭据原文。
type Subscription struct {
ID string `json:"id"`
QueueID string `json:"queue_id"`
CredentialID string `json:"credential_id"`
Name string `json:"name"`
Revision uint64 `json:"revision,string"` // Revision 配置版本,不随领取和ACK计数变化。
Enabled bool `json:"enabled"` // Enabled 为false时停止新领取,已有证明仍可确认。
Types []string `json:"types"`
CursorSequence uint64 `json:"cursor_sequence,string"` // CursorSequence 已扫描并建立交付的位置,不等于确认位置。
PendingCount uint32 `json:"pending_count"` // PendingCount 包括PENDING、LEASED和EXPIRED。
MaxPending uint32 `json:"max_pending"`
MaxAttempts uint32 `json:"max_attempts"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type SubscriptionResult struct {
Subscription Subscription `json:"subscription"`
Deduplicated bool `json:"deduplicated"`
}
// ConfigureSubscriptionRequest 显式启停,不改动冻结的队列、过滤和预算配置。
type ConfigureSubscriptionRequest struct {
ExpectedRevision uint64 `json:"expected_revision,string"`
Enabled bool `json:"enabled"`
Reason string `json:"reason"`
}
type SubscriptionPageRequest struct {
Limit int
Cursor string
}
type SubscriptionPage struct {
Items []Subscription `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
}
type DeliveryState string
const (
DeliveryPending DeliveryState = "PENDING"
DeliveryLeased DeliveryState = "LEASED"
DeliveryAcked DeliveryState = "ACKED"
DeliveryExpired DeliveryState = "EXPIRED"
)
// EventDelivery 是一条结果的可靠交付状态;详情查询不返回交付秘密。
type EventDelivery struct {
CreatedAt time.Time `json:"created_at,omitzero"`
ID string `json:"id"`
SubscriptionID string `json:"subscription_id"`
EventID string `json:"event_id"`
Sequence uint64 `json:"sequence,string"`
Revision uint64 `json:"revision,string"`
State DeliveryState `json:"state"`
AvailableAt time.Time `json:"available_at"`
LeaseEpoch uint64 `json:"lease_epoch,string"`
LeaseUntil *time.Time `json:"lease_until,omitempty"`
Attempts uint32 `json:"attempts"`
AttemptBudget uint32 `json:"attempt_budget"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
}
// TaskResultSnapshot 冻结事件发生时的任务结果;NEEDS_REVIEW不能当作成功或最终失败。
type TaskResultSnapshot struct {
SchemaVersion uint32 `json:"schema_version"`
TaskID string `json:"task_id"`
QueueID string `json:"queue_id"`
State TaskState `json:"state"`
Revision uint64 `json:"revision,string"`
Result json.RawMessage `json:"result,omitempty"`
EffectUncertain bool `json:"effect_uncertain"`
CurrentAttemptID *string `json:"current_attempt_id,omitempty"`
}
// ResultEvent 的ID用于跨连接及重复投递去重,序号只在当前命名空间内有序。
type ResultEvent struct {
ID string `json:"id"`
Sequence uint64 `json:"sequence,string"`
Type string `json:"type"`
AggregateKind string `json:"aggregate_kind"`
AggregateID string `json:"aggregate_id"`
AggregateRevision uint64 `json:"aggregate_revision,string"`
OccurredAt time.Time `json:"occurred_at"`
Details json.RawMessage `json:"details"`
Snapshot TaskResultSnapshot `json:"snapshot"`
}
type DeliveryDetail struct {
Delivery EventDelivery `json:"delivery"`
Event ResultEvent `json:"event"`
}
type DeliveryPageRequest struct {
Limit int
Cursor string
State DeliveryState
}
type DeliveryPage struct {
Items []EventDelivery `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
}
// RetryDeliveryRequest 只给EXPIRED追加预算;次数、代次、事件及交付身份不重置。
type RetryDeliveryRequest struct {
ExpectedRevision uint64 `json:"expected_revision,string"`
Reason string `json:"reason"`
AdditionalAttempts uint32 `json:"additional_attempts"`
}
type RetryDeliveryResult struct {
Delivery EventDelivery `json:"delivery"`
Deduplicated bool `json:"deduplicated"`
}

96
sdk/go/subscriptions.go Normal file
View File

@@ -0,0 +1,96 @@
package orbit
import (
"context"
"errors"
"net/url"
"strconv"
)
// CreateSubscription 保留原幂等键和请求内容重报;SDK不会自动生成新键或重新指定起点。
func (c *Client) CreateSubscription(ctx context.Context, key string, in CreateSubscriptionRequest) (SubscriptionResult, error) {
var out SubscriptionResult
if !validID(in.QueueID) {
return out, errors.New("invalid queue id")
}
err := c.call(ctx, "POST", "/v1/subscriptions", key, in, &out)
return out, err
}
func (c *Client) GetSubscription(ctx context.Context, id string) (Subscription, error) {
var out Subscription
if !validID(id) {
return out, errors.New("invalid subscription id")
}
err := c.call(ctx, "GET", "/v1/subscriptions/"+id, "", nil, &out)
return out, err
}
func (c *Client) ConfigureSubscription(ctx context.Context, id, key string, in ConfigureSubscriptionRequest) (SubscriptionResult, error) {
var out SubscriptionResult
if !validID(id) {
return out, errors.New("invalid subscription id")
}
err := c.call(ctx, "POST", "/v1/subscriptions/"+id+":configure", key, in, &out)
return out, err
}
func (c *Client) ListSubscriptions(ctx context.Context, queue string, in SubscriptionPageRequest) (SubscriptionPage, error) {
var out SubscriptionPage
if !validID(queue) {
return out, errors.New("invalid queue id")
}
q, err := subscriptionQuery(in.Limit, in.Cursor)
if err != nil {
return out, err
}
q.Set("queue_id", queue)
err = c.call(ctx, "GET", "/v1/subscriptions?"+q.Encode(), "", nil, &out)
return out, err
}
func (c *Client) ListEventDeliveries(ctx context.Context, id string, in DeliveryPageRequest) (DeliveryPage, error) {
var out DeliveryPage
if !validID(id) {
return out, errors.New("invalid subscription id")
}
q, err := subscriptionQuery(in.Limit, in.Cursor)
if err != nil {
return out, err
}
switch in.State {
case "", DeliveryPending, DeliveryLeased, DeliveryAcked, DeliveryExpired:
default:
return out, errors.New("invalid delivery state")
}
if in.State != "" {
q.Set("state", string(in.State))
}
err = c.call(ctx, "GET", "/v1/subscriptions/"+id+"/deliveries?"+q.Encode(), "", nil, &out)
return out, err
}
func (c *Client) GetEventDelivery(ctx context.Context, sub, id string) (DeliveryDetail, error) {
var out DeliveryDetail
if !validID(sub) || !validID(id) {
return out, errors.New("invalid subscription or delivery id")
}
err := c.call(ctx, "GET", "/v1/subscriptions/"+sub+"/deliveries/"+id, "", nil, &out)
return out, err
}
func (c *Client) RetryEventDelivery(ctx context.Context, sub, id, key string, in RetryDeliveryRequest) (RetryDeliveryResult, error) {
var out RetryDeliveryResult
if !validID(sub) || !validID(id) {
return out, errors.New("invalid subscription or delivery id")
}
err := c.call(ctx, "POST", "/v1/subscriptions/"+sub+"/deliveries/"+id+":retry", key, in, &out)
return out, err
}
func subscriptionQuery(limit int, cursor string) (url.Values, error) {
q := url.Values{}
if limit < 0 || limit > 100 || len(cursor) > 2048 {
return nil, errors.New("invalid subscription page")
}
if limit != 0 {
q.Set("limit", strconv.Itoa(limit))
}
if cursor != "" {
q.Set("cursor", cursor)
}
return q, nil
}

View File

@@ -0,0 +1,181 @@
package orbit
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/coder/websocket"
)
// 该测试用原始WSS帧驱动交付,用于验证管理SDK;不是待交付的订阅连接SDK替代品。
type managementEventFrame struct {
Version string `json:"protocol_version"`
Type string `json:"type"`
MessageID string `json:"message_id"`
SessionID string `json:"session_id,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
Body json.RawMessage `json:"body,omitempty"`
Error *struct {
Code string `json:"code"`
} `json:"error,omitempty"`
}
func TestIntegrationSubscriptionManagementSDK(t *testing.T) {
api, _, _ := integrationWorkerClient(t)
ctx, stop := context.WithTimeout(context.Background(), 45*time.Second)
defer stop()
queue, err := api.CreateQueue(ctx, workerTestID(t), CreateQueueRequest{Name: "subscription-" + workerTestID(t), MaxConcurrency: 1, RatePerSecond: 10})
if err != nil {
t.Fatal(err)
}
key := workerTestID(t)
in := CreateSubscriptionRequest{Name: "results-" + workerTestID(t), QueueID: queue.Queue.ID, MaxPending: 1, MaxAttempts: 1, Types: []string{"task.cancelled"}}
created, err := api.CreateSubscription(ctx, key, in)
if err != nil {
t.Fatal(err)
}
duplicate, err := api.CreateSubscription(ctx, key, in)
if err != nil || !duplicate.Deduplicated || duplicate.Subscription.ID != created.Subscription.ID {
t.Fatal("SDK subscription create replay", err)
}
sub := created.Subscription
current, err := api.GetSubscription(ctx, sub.ID)
if err != nil || current.ID != sub.ID || !current.Enabled {
t.Fatal("SDK subscription get", err)
}
page, err := api.ListSubscriptions(ctx, queue.Queue.ID, SubscriptionPageRequest{Limit: 1})
if err != nil || len(page.Items) != 1 || page.Items[0].ID != sub.ID {
t.Fatal("SDK subscription list", err)
}
paused, err := api.ConfigureSubscription(ctx, sub.ID, workerTestID(t), ConfigureSubscriptionRequest{ExpectedRevision: sub.Revision, Reason: "maintenance", Enabled: false})
if err != nil || paused.Subscription.Enabled {
t.Fatal("SDK pause", err)
}
resumed, err := api.ConfigureSubscription(ctx, sub.ID, workerTestID(t), ConfigureSubscriptionRequest{ExpectedRevision: paused.Subscription.Revision, Reason: "ready", Enabled: true})
if err != nil || !resumed.Subscription.Enabled {
t.Fatal("SDK resume", err)
}
task, err := api.SubmitTask(ctx, workerTestID(t), SubmitTaskRequest{QueueID: queue.Queue.ID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
if err != nil {
t.Fatal(err)
}
if _, err = api.CancelTask(ctx, task.Task.ID, workerTestID(t), CancelTaskRequest{ExpectedRevision: task.Task.Revision, Reason: "create result"}); err != nil {
t.Fatal(err)
}
send := func(conn *websocket.Conn, session, kind string, body any) string {
t.Helper()
id := workerTestID(t)
raw, err := marshalWire(body)
if err != nil {
t.Fatal(err)
}
frame, err := marshalWire(managementEventFrame{Version: "1", Type: kind, MessageID: id, SessionID: session, Body: raw})
if err != nil {
t.Fatal(err)
}
if err = conn.Write(ctx, websocket.MessageText, frame); err != nil {
t.Fatal(err)
}
return id
}
read := func(conn *websocket.Conn) managementEventFrame {
t.Helper()
bounded, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_, raw, err := conn.Read(bounded)
if err != nil {
t.Fatal(err)
}
var frame managementEventFrame
if json.Unmarshal(raw, &frame) != nil || frame.Version != "1" || !validID(frame.MessageID) {
t.Fatal("invalid management test frame")
}
return frame
}
connect := func() (*websocket.Conn, string) {
t.Helper()
headers := http.Header{}
headers.Set("Authorization", "Bearer "+api.token)
endpoint := "ws" + strings.TrimPrefix(api.baseURL, "http") + "/v1/events/connect"
conn, _, err := websocket.Dial(ctx, endpoint, &websocket.DialOptions{HTTPClient: api.http, HTTPHeader: headers, Subprotocols: []string{"orbit.events.v1"}})
if err != nil {
t.Fatal("management test event dial failed")
}
t.Cleanup(func() { conn.CloseNow() })
conn.SetReadLimit(512 << 10)
msg := send(conn, "", "events.hello", map[string]any{"subscription_id": sub.ID, "capacity": 1})
welcome := read(conn)
if welcome.Type != "events.welcome" || welcome.ReplyTo != msg || !validID(welcome.SessionID) {
t.Fatal("management test welcome missing")
}
return conn, welcome.SessionID
}
type grant struct {
Delivery EventDelivery `json:"delivery"`
Event ResultEvent `json:"event"`
Secret string `json:"lease_secret"`
}
a, _ := connect()
frame := read(a)
var first grant
if frame.Type != "event.deliver" || json.Unmarshal(frame.Body, &first) != nil || first.Event.Snapshot.TaskID != task.Task.ID {
t.Fatal("management test delivery missing")
}
a.CloseNow()
b, session := connect()
// 使用实际30秒租约,不改数据库时钟或手工更新租约时间。
var expired EventDelivery
for {
deliveries, err := api.ListEventDeliveries(ctx, sub.ID, DeliveryPageRequest{State: DeliveryExpired, Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(deliveries.Items) == 1 {
expired = deliveries.Items[0]
break
}
select {
case <-ctx.Done():
t.Fatal("delivery did not expire through real WSS recovery")
case <-time.After(150 * time.Millisecond):
}
}
detail, err := api.GetEventDelivery(ctx, sub.ID, expired.ID)
if err != nil || detail.Event.ID != first.Event.ID || detail.Delivery.Attempts != 1 || detail.Event.Snapshot.State != TaskCancelled {
t.Fatal("SDK delivery detail", err)
}
retryKey := workerTestID(t)
retry := RetryDeliveryRequest{ExpectedRevision: expired.Revision, Reason: "receiver fixed", AdditionalAttempts: 1}
restored, err := api.RetryEventDelivery(ctx, sub.ID, expired.ID, retryKey, retry)
if err != nil || restored.Delivery.Attempts != 1 || restored.Delivery.AttemptBudget != 2 {
t.Fatal("SDK retry reset history", err)
}
replay, err := api.RetryEventDelivery(ctx, sub.ID, expired.ID, retryKey, retry)
if err != nil || !replay.Deduplicated || replay.Delivery.Revision != restored.Delivery.Revision {
t.Fatal("SDK retry replay", err)
}
frame = read(b)
var second grant
if frame.Type != "event.deliver" || json.Unmarshal(frame.Body, &second) != nil || second.Event.ID != first.Event.ID || second.Delivery.LeaseEpoch != 2 {
t.Fatal("manual retry failed to deliver")
}
ackBody := map[string]any{"delivery_id": second.Delivery.ID, "epoch": "2", "lease_secret": second.Secret}
msg := send(b, session, "event.ack", ackBody)
ack := read(b)
if ack.Type != "ack" || ack.ReplyTo != msg {
t.Fatal("management test ACK failed")
}
delivered, err := api.ListEventDeliveries(ctx, sub.ID, DeliveryPageRequest{State: DeliveryAcked})
if err != nil || len(delivered.Items) != 1 || delivered.Items[0].ID != expired.ID {
t.Fatal("SDK acknowledged history", err)
}
current, err = api.GetSubscription(ctx, sub.ID)
if err != nil || current.PendingCount != 0 || current.Revision != resumed.Subscription.Revision {
t.Fatal("SDK pending count or config version", err)
}
t.Log("management SDK exercised create/get/list/control/detail/expired retry with a real 30-second delivery lease")
}

View File

@@ -0,0 +1,69 @@
package orbit
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSubscriptionHTTPWireAndPathValidation(t *testing.T) {
id := strings.Repeat("1", 32)
zero := uint64(0)
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Header.Get("Authorization") != "Bearer test-token" || r.Header.Get("Idempotency-Key") != "stable" {
t.Error("missing protected request headers")
}
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
t.Error(err)
}
switch r.URL.Path {
case "/v1/subscriptions":
if string(raw["from_sequence"]) != `"0"` {
t.Error("explicit zero cursor omitted or encoded as number")
}
case "/v1/subscriptions/" + id + ":configure":
if string(raw["enabled"]) != "false" || string(raw["expected_revision"]) != `"9007199254740993"` {
t.Error("control intent or integer precision lost")
}
default:
t.Error("wrong subscription route")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"request_id":"` + id + `","data":{"subscription":{"id":"` + id + `","revision":"9007199254740993"},"deduplicated":false}}`))
}))
defer server.Close()
c, err := NewClient(server.URL, Options{Token: "test-token"})
if err != nil {
t.Fatal(err)
}
out, err := c.CreateSubscription(context.Background(), "stable", CreateSubscriptionRequest{Name: "results", QueueID: id, FromSequence: &zero})
if err != nil || out.Subscription.Revision != 9007199254740993 {
t.Fatal("subscription counter precision", err)
}
if _, err = c.ConfigureSubscription(context.Background(), id, "stable", ConfigureSubscriptionRequest{ExpectedRevision: 9007199254740993, Enabled: false, Reason: "pause"}); err != nil {
t.Fatal(err)
}
for _, bad := range []string{"", id + "/deliveries", "../events"} {
if _, err := c.GetSubscription(context.Background(), bad); err == nil {
t.Fatal("accepted invalid subscription path")
}
if _, err := c.GetEventDelivery(context.Background(), id, bad); err == nil {
t.Fatal("accepted invalid delivery path")
}
}
if _, err = c.ListSubscriptions(context.Background(), id, SubscriptionPageRequest{Limit: 101}); err == nil {
t.Fatal("accepted unbounded page")
}
if _, err = c.ListEventDeliveries(context.Background(), id, DeliveryPageRequest{State: "INVALID"}); err == nil {
t.Fatal("accepted invalid delivery state")
}
if calls != 2 {
t.Fatal("invalid request reached network")
}
}