feat: expose result subscription HTTP and WebSocket APIs
This commit is contained in:
109
sdk/go/examples/subscriptions/main.go
Normal file
109
sdk/go/examples/subscriptions/main.go
Normal 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
|
||||
}
|
||||
126
sdk/go/subscription_types.go
Normal file
126
sdk/go/subscription_types.go
Normal 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
96
sdk/go/subscriptions.go
Normal 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
|
||||
}
|
||||
181
sdk/go/subscriptions_integration_test.go
Normal file
181
sdk/go/subscriptions_integration_test.go
Normal 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")
|
||||
}
|
||||
69
sdk/go/subscriptions_test.go
Normal file
69
sdk/go/subscriptions_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user