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

@@ -67,7 +67,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
f.exec(`DROP TABLE orbit_application_resources`)
f.exec(`DROP TABLE orbit_result_events,orbit_event_streams`)
f.exec(`ALTER TABLE orbit_subscriptions DROP CHECK ck_subscription_window, DROP COLUMN queue_id, DROP COLUMN cursor_sequence, DROP COLUMN pending_count, DROP COLUMN max_pending, DROP COLUMN max_attempts`)
f.exec(`ALTER TABLE orbit_deliveries DROP INDEX ix_delivery_subscription_due, DROP INDEX ix_delivery_subscription_lease, DROP CHECK ck_delivery_budget, DROP COLUMN attempt_budget, DROP COLUMN token_digest`)
f.exec(`ALTER TABLE orbit_deliveries DROP INDEX ix_delivery_history, DROP INDEX ix_delivery_state_history, DROP INDEX ix_delivery_subscription_due, DROP INDEX ix_delivery_subscription_lease, DROP CHECK ck_delivery_budget, DROP COLUMN attempt_budget, DROP COLUMN token_digest`)
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_priority_dispatch, DROP CHECK ck_task_priority, DROP COLUMN dispatch_at`)
f.exec(`ALTER TABLE pulse_tasks DROP CHECK ck_task_concurrency_key, DROP COLUMN concurrency_key, DROP COLUMN concurrency_key_digest`)
f.exec(`ALTER TABLE pulse_attempts DROP COLUMN concurrency_token`)

View File

@@ -98,12 +98,12 @@ type storedDelivery struct {
func readDelivery(ctx context.Context, tx *sql.Tx, ns, sub, id core.ID, lock bool) (storedDelivery, error) {
var out storedDelivery
q := `SELECT id,subscription_id,event_id,revision,state,available_at,lease_epoch,lease_until,attempts,attempt_budget,acknowledged_at,token_digest FROM orbit_deliveries WHERE namespace_id=? AND subscription_id=? AND id=?`
q := `SELECT id,subscription_id,event_id,revision,state,available_at,lease_epoch,lease_until,attempts,attempt_budget,acknowledged_at,token_digest,created_at FROM orbit_deliveries WHERE namespace_id=? AND subscription_id=? AND id=?`
if lock {
q += ` FOR UPDATE`
}
v := &out.view
err := tx.QueryRowContext(ctx, q, ns, sub, id).Scan(&v.ID, &v.SubscriptionID, &v.EventID, &v.Revision, &v.State, &v.AvailableAt, &v.LeaseEpoch, &v.LeaseUntil, &v.Attempts, &v.AttemptBudget, &v.AcknowledgedAt, &out.digest)
err := tx.QueryRowContext(ctx, q, ns, sub, id).Scan(&v.ID, &v.SubscriptionID, &v.EventID, &v.Revision, &v.State, &v.AvailableAt, &v.LeaseEpoch, &v.LeaseUntil, &v.Attempts, &v.AttemptBudget, &v.AcknowledgedAt, &out.digest, &v.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return out, fault.NotFound
}

View File

@@ -0,0 +1,179 @@
package mysql
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/platform/events"
platform "git.yuntuo.hk/k2safe/orbit/platform/model"
)
type eventCursor struct {
Version int `json:"v"`
Kind string `json:"k"`
Namespace core.ID `json:"n"`
Credential core.ID `json:"c"`
Parent core.ID `json:"p"`
State platform.DeliveryState `json:"s"`
At time.Time `json:"t"`
ID core.ID `json:"i"`
}
func parseEventCursor(raw string, t auth.Token, parent core.ID, kind string, state platform.DeliveryState) (eventCursor, error) {
var c eventCursor
b, err := base64.RawURLEncoding.Strict().DecodeString(raw)
if err != nil || len(b) > 1024 {
return c, fault.Invalid("invalid event cursor")
}
if _, err = jsonvalue.Canonical(b); err != nil {
return c, fault.Invalid("invalid event cursor")
}
var fields map[string]json.RawMessage
if json.Unmarshal(b, &fields) != nil || len(fields) != 8 {
return c, fault.Invalid("invalid event cursor")
}
for _, k := range []string{"v", "k", "n", "c", "p", "s", "t", "i"} {
if _, ok := fields[k]; !ok {
return c, fault.Invalid("invalid event cursor")
}
}
d := json.NewDecoder(bytes.NewReader(b))
d.DisallowUnknownFields()
if d.Decode(&c) != nil || c.Version != 1 || c.Kind != kind || c.Namespace != t.NamespaceID || c.Credential != t.CredentialID || c.Parent != parent || c.State != state || c.ID.Validate() != nil || c.At.Year() < 2000 || c.At.Year() > 9998 || c.At.Nanosecond()%1000 != 0 {
return c, fault.Invalid("cursor does not match event query")
}
return c, nil
}
func makeEventCursor(t auth.Token, parent core.ID, kind string, state platform.DeliveryState, at time.Time, id core.ID) (string, error) {
b, err := json.Marshal(eventCursor{1, kind, t.NamespaceID, t.CredentialID, parent, state, at, id})
return base64.RawURLEncoding.EncodeToString(b), err
}
func (s *EventStore) AuthorizeEvents(ctx context.Context, t auth.Token) error {
return s.transaction(ctx, t, "events:consume", func(*sql.Tx, auth.Permissions) error { return nil })
}
func (s *EventStore) BindConsumer(ctx context.Context, t auth.Token, id core.ID) (events.ConsumerBinding, error) {
var out events.ConsumerBinding
err := s.transaction(ctx, t, "events:consume", func(tx *sql.Tx, p auth.Permissions) error {
sub, err := readSubscription(ctx, tx, t, p, id, false)
if err != nil {
return err
}
out.SubscriptionID = sub.ID
out.QueueID = sub.QueueID
out.Enabled = sub.Enabled
return tx.QueryRowContext(ctx, `SELECT application_id,UTC_TIMESTAMP(6) FROM orbit_namespaces WHERE id=?`, t.NamespaceID).Scan(&out.ApplicationID, &out.ServerTime)
})
return out, err
}
func (s *EventStore) ListSubscriptions(ctx context.Context, t auth.Token, queue core.ID, in events.PageInput) (events.SubscriptionPage, error) {
out := events.SubscriptionPage{Items: []events.Subscription{}}
err := s.transaction(ctx, t, "events:read", func(tx *sql.Tx, p auth.Permissions) error {
if !p.AllowsQueue(queue) {
return fault.Forbidden
}
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND credential_id=? AND queue_id=?`
args := []any{t.NamespaceID, t.CredentialID, queue}
if in.Cursor != "" {
c, err := parseEventCursor(in.Cursor, t, queue, "subscriptions", "")
if err != nil {
return err
}
q += ` AND (created_at<? OR (created_at=? AND id<?))`
args = append(args, c.At, c.At, c.ID)
}
q += ` ORDER BY created_at DESC,id DESC LIMIT ?`
args = append(args, in.Limit+1)
rows, err := tx.QueryContext(ctx, q, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
v, err := scanSubscription(rows)
if err != nil {
return err
}
out.Items = append(out.Items, v)
}
if err = rows.Err(); err != nil {
return err
}
if len(out.Items) > in.Limit {
out.Items = out.Items[:in.Limit]
last := out.Items[len(out.Items)-1]
out.NextCursor, err = makeEventCursor(t, queue, "subscriptions", "", last.CreatedAt, last.ID)
}
return err
})
return out, err
}
func (s *EventStore) ListDeliveries(ctx context.Context, t auth.Token, id core.ID, in events.PageInput) (events.DeliveryPage, error) {
out := events.DeliveryPage{Items: []events.Delivery{}}
err := s.transaction(ctx, t, "events:read", func(tx *sql.Tx, p auth.Permissions) error {
if _, err := readSubscription(ctx, tx, t, p, id, false); err != nil {
return err
}
q := `SELECT d.id,d.subscription_id,d.event_id,r.sequence,d.revision,d.state,d.available_at,d.lease_epoch,d.lease_until,d.attempts,d.attempt_budget,d.acknowledged_at,d.created_at FROM orbit_deliveries d LEFT JOIN orbit_result_events r ON r.namespace_id=d.namespace_id AND r.id=d.event_id WHERE d.namespace_id=? AND d.subscription_id=?`
args := []any{t.NamespaceID, id}
if in.State != "" {
q += ` AND d.state=?`
args = append(args, in.State)
}
if in.Cursor != "" {
c, err := parseEventCursor(in.Cursor, t, id, "deliveries", in.State)
if err != nil {
return err
}
q += ` AND (d.created_at<? OR (d.created_at=? AND d.id<?))`
args = append(args, c.At, c.At, c.ID)
}
q += ` ORDER BY d.created_at DESC,d.id DESC LIMIT ?`
args = append(args, in.Limit+1)
rows, err := tx.QueryContext(ctx, q, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var v events.Delivery
if err = rows.Scan(&v.ID, &v.SubscriptionID, &v.EventID, &v.Sequence, &v.Revision, &v.State, &v.AvailableAt, &v.LeaseEpoch, &v.LeaseUntil, &v.Attempts, &v.AttemptBudget, &v.AcknowledgedAt, &v.CreatedAt); err != nil {
return err
}
out.Items = append(out.Items, v)
}
if err = rows.Err(); err != nil {
return err
}
if len(out.Items) > in.Limit {
out.Items = out.Items[:in.Limit]
last := out.Items[len(out.Items)-1]
out.NextCursor, err = makeEventCursor(t, id, "deliveries", in.State, last.CreatedAt, last.ID)
}
return err
})
return out, err
}
func (s *EventStore) GetDeliveryDetail(ctx context.Context, t auth.Token, sub, id core.ID) (events.DeliveryDetail, error) {
var out events.DeliveryDetail
err := s.transaction(ctx, t, "events:read", func(tx *sql.Tx, p auth.Permissions) error {
if _, err := readSubscription(ctx, tx, t, p, sub, false); err != nil {
return err
}
d, err := readDelivery(ctx, tx, t.NamespaceID, sub, id, false)
if err != nil {
return err
}
out.Delivery = d.view
out.Event, err = readResultEvent(ctx, tx, t.NamespaceID, d.view.EventID)
return err
})
return out, err
}

View File

@@ -27,13 +27,11 @@ var _ events.Store = (*EventStore)(nil)
const subscriptionColumns = `id,queue_id,credential_id,name,revision,enabled,filter,cursor_sequence,pending_count,max_pending,max_attempts,created_at,updated_at`
func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Permissions, id core.ID, lock bool) (events.Subscription, error) {
var out events.Subscription
var raw []byte
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND credential_id=? AND queue_id IS NOT NULL`
if lock {
q += ` FOR UPDATE`
}
err := tx.QueryRowContext(ctx, q, t.NamespaceID, id, t.CredentialID).Scan(&out.ID, &out.QueueID, &out.CredentialID, &out.Name, &out.Revision, &out.Enabled, &raw, &out.CursorSequence, &out.PendingCount, &out.MaxPending, &out.MaxAttempts, &out.CreatedAt, &out.UpdatedAt)
out, err := scanSubscription(tx.QueryRowContext(ctx, q, t.NamespaceID, id, t.CredentialID))
if errors.Is(err, sql.ErrNoRows) {
return out, fault.NotFound
}
@@ -43,10 +41,17 @@ func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Perm
if !p.AllowsQueue(out.QueueID) {
return events.Subscription{}, fault.NotFound
}
if err = json.Unmarshal(raw, &out.Types); err != nil {
return out, nil
}
func scanSubscription(row scanner) (events.Subscription, error) {
var out events.Subscription
var raw []byte
err := row.Scan(&out.ID, &out.QueueID, &out.CredentialID, &out.Name, &out.Revision, &out.Enabled, &raw, &out.CursorSequence, &out.PendingCount, &out.MaxPending, &out.MaxAttempts, &out.CreatedAt, &out.UpdatedAt)
if err != nil {
return out, err
}
return out, nil
err = json.Unmarshal(raw, &out.Types)
return out, err
}
func (s *EventStore) CreateSubscription(ctx context.Context, t auth.Token, key string, in events.CreateInput) (events.SubscriptionResult, error) {

View File

@@ -0,0 +1,502 @@
package mysql
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
"git.yuntuo.hk/k2safe/orbit/internal/testcontract"
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/events"
platform "git.yuntuo.hk/k2safe/orbit/platform/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
"github.com/coder/websocket"
)
func startEventServer(t *testing.T, ctx context.Context, db *sql.DB) (*httptest.Server, *httpserver.EventHub) {
t.Helper()
svc := events.New(NewEventStore(db))
hub := httpserver.NewEventHub(ctx, svc)
server := httpserver.New("", func(context.Context) error { return nil }, service.New(NewTaskStore(db)))
httpserver.RegisterEventSubscriptions(server, svc)
httpserver.RegisterEventConsumers(server, hub)
tls := httptest.NewTLSServer(server.Handler)
t.Cleanup(func() {
bounded, stop := context.WithTimeout(context.Background(), 3*time.Second)
defer stop()
if err := hub.Shutdown(bounded); err != nil {
t.Error(err)
}
tls.Close()
})
return tls, hub
}
func eventHTTP(t *testing.T, ctx context.Context, server *httptest.Server, method, path, token, key string, body any, status int) json.RawMessage {
t.Helper()
var raw []byte
var err error
switch b := body.(type) {
case nil:
case string:
raw = []byte(b)
default:
raw, err = jsonwire.Marshal(body)
if err != nil {
t.Fatal(err)
}
}
req, err := http.NewRequestWithContext(ctx, method, server.URL+path, bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := server.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
t.Fatal(err)
}
var envelope struct {
RequestID core.ID `json:"request_id"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
} `json:"error"`
}
if err = json.Unmarshal(data, &envelope); err != nil {
t.Fatalf("invalid event HTTP response: status %d", resp.StatusCode)
}
if resp.StatusCode != status {
code := ""
if envelope.Error != nil {
code = envelope.Error.Code
}
t.Fatalf("expected HTTP %d, got %d/%s", status, resp.StatusCode, code)
}
if envelope.RequestID.Validate() != nil || resp.Header.Get("Cache-Control") != "no-store" {
t.Fatal("missing event API envelope metadata")
}
if status >= 400 {
if envelope.Error == nil || len(envelope.Data) != 0 {
t.Fatal("invalid event error response")
}
return data
}
if envelope.Error != nil {
return nil
}
return envelope.Data
}
type testEventSocket struct {
t *testing.T
ctx context.Context
conn *websocket.Conn
session core.ID
pending []testWorkerFrame
}
func connectTestEvents(t *testing.T, ctx context.Context, server *httptest.Server, f *eventFixture, sub core.ID, capacity uint32) *testEventSocket {
t.Helper()
headers := http.Header{}
headers.Set("Authorization", "Bearer "+f.resources.Token)
conn, resp, err := websocket.Dial(ctx, strings.Replace(server.URL, "https://", "wss://", 1)+"/v1/events/connect", &websocket.DialOptions{HTTPClient: server.Client(), HTTPHeader: headers, Subprotocols: []string{httpserver.EventSubprotocol}})
if err != nil {
status := 0
if resp != nil {
status = resp.StatusCode
}
t.Fatalf("event dial failed HTTP %d", status)
}
t.Cleanup(func() { conn.CloseNow() })
conn.SetReadLimit(512 << 10)
c := &testEventSocket{t: t, ctx: ctx, conn: conn}
hello := c.send("events.hello", map[string]any{"subscription_id": sub, "capacity": capacity}, "")
frame := c.read()
if frame.Type != "events.welcome" || frame.ReplyTo != hello || frame.SessionID.Validate() != nil {
t.Fatal("missing event welcome")
}
var welcome struct {
SessionID core.ID `json:"session_id"`
SubscriptionID core.ID `json:"subscription_id"`
Capacity uint32 `json:"capacity"`
LeaseDurationMS int `json:"lease_duration_ms"`
ServerTime time.Time `json:"server_time"`
}
if json.Unmarshal(frame.Body, &welcome) != nil || welcome.SessionID != frame.SessionID || welcome.SubscriptionID != sub || welcome.Capacity != capacity || welcome.LeaseDurationMS != 30000 || welcome.ServerTime.IsZero() {
t.Fatal("invalid welcome binding")
}
c.session = frame.SessionID
return c
}
func (c *testEventSocket) send(kind string, body any, id core.ID) core.ID {
c.t.Helper()
if id == "" {
id = executionID(c.t)
}
payload, err := jsonwire.Marshal(body)
if err != nil {
c.t.Fatal(err)
}
raw, err := jsonwire.Marshal(testWorkerFrame{Version: "1", Type: kind, MessageID: id, SessionID: c.session, Body: payload})
if err != nil {
c.t.Fatal(err)
}
testcontract.EventFrame(c.t, "client", raw)
ctx, stop := context.WithTimeout(c.ctx, 5*time.Second)
defer stop()
if err = c.conn.Write(ctx, websocket.MessageText, raw); err != nil {
c.t.Fatal(err)
}
return id
}
func (c *testEventSocket) read() testWorkerFrame {
c.t.Helper()
ctx, stop := context.WithTimeout(c.ctx, 5*time.Second)
defer stop()
kind, raw, err := c.conn.Read(ctx)
if err != nil {
c.t.Fatal(err)
}
testcontract.EventFrame(c.t, "server", raw)
var frame testWorkerFrame
if kind != websocket.MessageText || json.Unmarshal(raw, &frame) != nil {
c.t.Fatal("invalid event frame")
}
if c.session != "" && frame.SessionID != c.session {
c.t.Fatal("event response changed session")
}
return frame
}
func (c *testEventSocket) reply(id core.ID, code string) json.RawMessage {
c.t.Helper()
for {
frame := c.read()
if frame.Type == "event.deliver" {
c.pending = append(c.pending, frame)
continue
}
if frame.ReplyTo != id {
c.t.Fatal("uncorrelated event reply")
}
if code != "" {
if frame.Type != "error" || frame.Error == nil || frame.Error.Code != code {
c.t.Fatal("unexpected event error")
}
} else if frame.Type != "ack" || frame.Error != nil {
c.t.Fatal("expected event acknowledgement")
}
return frame.Body
}
}
func (c *testEventSocket) grant() *events.Grant {
c.t.Helper()
var frame testWorkerFrame
if len(c.pending) > 0 {
frame = c.pending[0]
c.pending = c.pending[1:]
} else {
frame = c.read()
}
if frame.Type != "event.deliver" {
c.t.Fatalf("expected event delivery, got %s", frame.Type)
}
var g events.Grant
if json.Unmarshal(frame.Body, &g) != nil || g.Delivery.EventID != g.Event.ID || g.Delivery.Sequence != g.Event.Sequence || g.Delivery.State != platform.DeliveryLeased {
c.t.Fatal("invalid delivered event")
}
return &g
}
func eventProof(g *events.Grant) events.AckInput {
return events.AckInput{DeliveryID: g.Delivery.ID, Epoch: g.Delivery.LeaseEpoch, LeaseSecret: g.LeaseSecret}
}
func TestIntegrationEventHTTPAndWebSocket(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, stop := context.WithTimeout(context.Background(), 90*time.Second)
defer stop()
db, err := Open(ctx, dsn, 20, 5)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
serverA, hubA := startEventServer(t, ctx, db)
serverB, _ := startEventServer(t, ctx, db)
t.Run("HTTP_management_required_fields_and_scoped_pagination", func(t *testing.T) {
f := newEventFixture(t, ctx, db)
other := newEventFixture(t, ctx, db)
in := events.CreateInput{Name: "managed-results", QueueID: f.resources.QueueID, MaxAttempts: 1}
raw := eventHTTP(t, ctx, serverA, "POST", "/v1/subscriptions", f.resources.Token, "create", in, 201)
var created events.SubscriptionResult
if json.Unmarshal(raw, &created) != nil {
t.Fatal("invalid subscription receipt")
}
id := created.Subscription.ID
raw = eventHTTP(t, ctx, serverA, "POST", "/v1/subscriptions", f.resources.Token, "create", in, 200)
var duplicate events.SubscriptionResult
if json.Unmarshal(raw, &duplicate) != nil || !duplicate.Deduplicated || duplicate.Subscription.ID != id {
t.Fatal("idempotency changed through HTTP")
}
eventHTTP(t, ctx, serverA, "GET", "/v1/subscriptions/"+string(id), other.resources.Token, "", nil, 404)
eventHTTP(t, ctx, serverA, "POST", "/v1/subscriptions/"+string(id)+":configure", f.resources.Token, "invalid-control", `{"expected_revision":"1","reason":"ambiguous"}`, 400)
if !f.subscription(id).Enabled {
t.Fatal("missing enabled paused subscription")
}
eventHTTP(t, ctx, serverA, "POST", "/v1/subscriptions/"+string(id)+":configure", f.resources.Token, "pause", events.ControlInput{ExpectedRevision: 1, Enabled: false, Reason: "maintenance"}, 200)
eventHTTP(t, ctx, serverA, "POST", "/v1/subscriptions/"+string(id)+":configure", f.resources.Token, "resume", events.ControlInput{ExpectedRevision: 2, Enabled: true, Reason: "ready"}, 200)
for _, bad := range []string{`"00"`, `"+1"`, `1`, `false`, `"18446744073709551616"`} {
eventHTTP(t, ctx, serverA, "POST", "/v1/subscriptions", f.resources.Token, "invalid-sequence", `{"name":"invalid","queue_id":"`+string(f.resources.QueueID)+`","from_sequence":`+bad+`}`, 400)
}
for _, path := range []string{"/v1/subscriptions?queue_id=" + string(f.resources.QueueID) + "&limit=01", "/v1/subscriptions?queue_id=" + string(f.resources.QueueID) + "&limit=101", "/v1/subscriptions?queue_id=" + string(f.resources.QueueID) + "&cursor=a&cursor=b", "/v1/subscriptions/" + string(id) + "?unknown=1"} {
eventHTTP(t, ctx, serverA, "GET", path, f.resources.Token, "", nil, 400)
}
f.create(events.CreateInput{})
f.create(events.CreateInput{})
path := "/v1/subscriptions?queue_id=" + string(f.resources.QueueID) + "&limit=1"
var page events.SubscriptionPage
raw = eventHTTP(t, ctx, serverA, "GET", path, f.resources.Token, "", nil, 200)
if json.Unmarshal(raw, &page) != nil || len(page.Items) != 1 || page.NextCursor == "" {
t.Fatal("missing subscription page")
}
first := page.Items[0].ID
raw = eventHTTP(t, ctx, serverA, "GET", path+"&cursor="+url.QueryEscape(page.NextCursor), f.resources.Token, "", nil, 200)
if json.Unmarshal(raw, &page) != nil || page.Items[0].ID == first {
t.Fatal("subscription pagination repeated row")
}
eventHTTP(t, ctx, serverA, "GET", "/v1/subscriptions?queue_id="+string(other.resources.QueueID)+"&cursor="+url.QueryEscape(page.NextCursor), f.resources.Token, "", nil, 400)
for range 3 {
f.cancelResult()
}
firstGrant := f.grant(id)
f.ack(id, firstGrant)
second := f.grant(id)
f.expireDelivery(second)
third := f.grant(id)
path = "/v1/subscriptions/" + string(id) + "/deliveries"
var deliveries events.DeliveryPage
raw = eventHTTP(t, ctx, serverA, "GET", path+"?limit=1", f.resources.Token, "", nil, 200)
if json.Unmarshal(raw, &deliveries) != nil || len(deliveries.Items) != 1 || deliveries.NextCursor == "" {
t.Fatal("missing delivery page")
}
eventHTTP(t, ctx, serverA, "GET", path+"?state=EXPIRED&cursor="+url.QueryEscape(deliveries.NextCursor), f.resources.Token, "", nil, 400)
nextRaw := eventHTTP(t, ctx, serverA, "GET", path+"?limit=1&cursor="+url.QueryEscape(deliveries.NextCursor), f.resources.Token, "", nil, 200)
var nextPage events.DeliveryPage
if json.Unmarshal(nextRaw, &nextPage) != nil || len(nextPage.Items) != 1 || nextPage.Items[0].ID == deliveries.Items[0].ID {
t.Fatal("delivery page repeated a row")
}
eventHTTP(t, ctx, serverA, "GET", path+"/"+string(second.Delivery.ID), other.resources.Token, "", nil, 404)
wrongSub := f.create(events.CreateInput{})
eventHTTP(t, ctx, serverA, "GET", "/v1/subscriptions/"+string(wrongSub.ID)+"/deliveries/"+string(second.Delivery.ID), f.resources.Token, "", nil, 404)
raw = eventHTTP(t, ctx, serverA, "GET", path+"?state=EXPIRED", f.resources.Token, "", nil, 200)
if json.Unmarshal(raw, &deliveries) != nil || len(deliveries.Items) != 1 || deliveries.Items[0].ID != second.Delivery.ID {
t.Fatal("expired delivery not queryable")
}
expired := deliveries.Items[0]
raw = eventHTTP(t, ctx, serverA, "GET", path+"/"+string(expired.ID), f.resources.Token, "", nil, 200)
var detail events.DeliveryDetail
if json.Unmarshal(raw, &detail) != nil || detail.Event.ID != second.Event.ID || strings.Contains(string(raw), second.LeaseSecret) || strings.Contains(string(raw), "token_digest") {
t.Fatal("unsafe delivery detail")
}
retry := events.RetryInput{ExpectedRevision: expired.Revision, Reason: "consumer fixed", AdditionalAttempts: 1}
eventHTTP(t, ctx, serverA, "POST", path+"/"+string(expired.ID)+":retry", f.resources.Token, "retry", retry, 200)
eventHTTP(t, ctx, serverA, "POST", path+"/"+string(expired.ID)+":retry", f.resources.Token, "retry", retry, 200)
f.ack(id, third)
})
t.Run("handshake_authority_protocol_origin_and_URL", func(t *testing.T) {
f := newEventFixture(t, ctx, db)
plain := newExecutionFixture(t, ctx, db)
for _, c := range []struct {
token, protocol, path, origin string
status int
}{
{"", httpserver.EventSubprotocol, "/v1/events/connect", "", 401},
{plain.resources.Token, httpserver.EventSubprotocol, "/v1/events/connect", "", 403},
{f.resources.Token, "pulse.worker.v1", "/v1/events/connect", "", 426},
{f.resources.Token, httpserver.EventSubprotocol, "/v1/events/connect?token=forbidden", "", 400},
{f.resources.Token, httpserver.EventSubprotocol, "/v1/events/connect", "https://foreign.example", 403},
} {
headers := http.Header{}
if c.token != "" {
headers.Set("Authorization", "Bearer "+c.token)
}
if c.origin != "" {
headers.Set("Origin", c.origin)
}
conn, resp, err := websocket.Dial(ctx, strings.Replace(serverA.URL, "https://", "wss://", 1)+c.path, &websocket.DialOptions{HTTPClient: serverA.Client(), HTTPHeader: headers, Subprotocols: []string{c.protocol}})
if conn != nil {
conn.CloseNow()
}
if err == nil || resp == nil || resp.StatusCode != c.status {
t.Fatalf("event handshake expected HTTP %d", c.status)
}
}
})
t.Run("WSS_capacity_ack_receipt_and_result_snapshot", func(t *testing.T) {
f := newEventFixture(t, ctx, db)
sub := f.create(events.CreateInput{})
task := f.submit(1)
ref := retryStart(t, f.executionFixture)
result := json.RawMessage(`{"n":9007199254740993,"text":"` + strings.Repeat("<&>", 80000) + `"}`)
if _, err := f.a.Finish(ctx, f.token, f.binding, executionID(t), worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: result}); err != nil {
t.Fatal(err)
}
f.cancelResult()
c := connectTestEvents(t, ctx, serverA, f, sub.ID, 1)
first := c.grant()
if first.Event.Snapshot.TaskID != task.ID || string(first.Event.Snapshot.Result) != string(result) {
t.Fatal("WSS snapshot changed large result")
}
c.reply(c.send("events.ping", struct{}{}, ""), "")
var leased int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_deliveries WHERE namespace_id=? AND subscription_id=? AND state='LEASED'`, f.token.NamespaceID, sub.ID).Scan(&leased); err != nil || leased != 1 {
t.Fatal("WSS capacity exceeded", err)
}
var ack events.AckResult
message := c.send("event.ack", eventProof(first), "")
if json.Unmarshal(c.reply(message, ""), &ack) != nil || ack.Deduplicated {
t.Fatal("first ack not applied")
}
second := c.grant()
if second.Event.ID == first.Event.ID {
t.Fatal("confirmed result redelivered")
}
var repeated events.AckResult
if json.Unmarshal(c.reply(c.send("event.ack", eventProof(first), message), ""), &repeated) != nil || !repeated.Deduplicated || !repeated.AcknowledgedAt.Equal(ack.AcknowledgedAt) {
t.Fatal("duplicate ACK changed receipt")
}
c.reply(c.send("event.ack", eventProof(second), ""), "")
})
t.Run("cross_node_redelivery_fences_old_proof", func(t *testing.T) {
f := newEventFixture(t, ctx, db)
sub := f.create(events.CreateInput{MaxAttempts: 2})
f.cancelResult()
a := connectTestEvents(t, ctx, serverA, f, sub.ID, 1)
first := a.grant()
a.conn.CloseNow()
f.expireDelivery(first)
b := connectTestEvents(t, ctx, serverB, f, sub.ID, 1)
second := b.grant()
if second.Event.ID != first.Event.ID || second.Delivery.ID != first.Delivery.ID || second.Delivery.LeaseEpoch != first.Delivery.LeaseEpoch+1 {
t.Fatal("cross-node redelivery lost durable identity")
}
b.reply(b.send("event.ack", eventProof(first), ""), "DELIVERY_LEASE_LOST")
b.reply(b.send("event.ack", eventProof(second), ""), "")
if f.subscription(sub.ID).PendingCount != 0 {
t.Fatal("cross-node ack did not release window")
}
})
t.Run("discarded_ack_response_reconciles_on_new_connection", func(t *testing.T) {
f := newEventFixture(t, ctx, db)
sub := f.create(events.CreateInput{})
f.cancelResult()
a := connectTestEvents(t, ctx, serverA, f, sub.ID, 1)
g := a.grant()
message := a.send("event.ack", eventProof(g), "")
deadline := time.Now().Add(3 * time.Second)
var original events.Delivery
for time.Now().Before(deadline) {
original, err = f.events.Delivery(ctx, f.token, sub.ID, g.Delivery.ID)
if err != nil {
t.Fatal(err)
}
if original.State == platform.DeliveryAcked {
break
}
time.Sleep(5 * time.Millisecond)
}
if original.State != platform.DeliveryAcked {
t.Fatal("ACK did not commit")
}
a.conn.CloseNow()
b := connectTestEvents(t, ctx, serverB, f, sub.ID, 1)
var receipt events.AckResult
if json.Unmarshal(b.reply(b.send("event.ack", eventProof(g), message), ""), &receipt) != nil || !receipt.Deduplicated || original.AcknowledgedAt == nil || !receipt.AcknowledgedAt.Equal(*original.AcknowledgedAt) {
t.Fatal("lost ACK response could not be reconciled")
}
if f.count("orbit_audit_events", "resource_id=? AND action='delivery.ack'", g.Delivery.ID) != 1 {
t.Fatal("replayed ACK duplicated audit")
}
})
t.Run("revocation_and_session_spoofing_close_live_connection", func(t *testing.T) {
f := newEventFixture(t, ctx, db)
sub := f.create(events.CreateInput{})
c := connectTestEvents(t, ctx, serverA, f, sub.ID, 1)
f.exec(`UPDATE orbit_credentials SET revoked_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.token.NamespaceID, f.token.CredentialID)
// 主动心跳或下一次领取均重验权限;这里主动心跳固定相关响应。
msg := c.send("events.ping", struct{}{}, "")
frame := c.read()
if frame.Type != "error" || frame.Error == nil || frame.Error.Code != "UNAUTHENTICATED" || (frame.ReplyTo != "" && frame.ReplyTo != msg) {
t.Fatal("revoked connection retained authority")
}
g := newEventFixture(t, ctx, db)
owned := g.create(events.CreateInput{})
d := connectTestEvents(t, ctx, serverA, g, owned.ID, 1)
raw := `{"protocol_version":"1","type":"events.ping","message_id":"` + string(executionID(t)) + `","session_id":"` + string(executionID(t)) + `","body":{}}`
if err := d.conn.Write(ctx, websocket.MessageText, []byte(raw)); err != nil {
t.Fatal(err)
}
readCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_, _, err := d.conn.Read(readCtx)
if websocket.CloseStatus(err) != websocket.StatusPolicyViolation {
t.Fatal("foreign connection session accepted")
}
})
t.Run("shutdown_closes_socket_preserves_delivery_and_rejects_new_connection", func(t *testing.T) {
f := newEventFixture(t, ctx, db)
sub := f.create(events.CreateInput{})
f.cancelResult()
a := connectTestEvents(t, ctx, serverA, f, sub.ID, 1)
g := a.grant()
bounded, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := hubA.Shutdown(bounded); err != nil {
t.Fatal(err)
}
v, err := f.events.Delivery(ctx, f.token, sub.ID, g.Delivery.ID)
if err != nil || v.State != platform.DeliveryLeased || v.LeaseEpoch != g.Delivery.LeaseEpoch {
t.Fatal("shutdown erased authoritative lease", err)
}
eventHTTP(t, ctx, serverA, "GET", "/v1/events/connect", f.resources.Token, "", nil, 426)
headers := http.Header{}
headers.Set("Authorization", "Bearer "+f.resources.Token)
conn, response, err := websocket.Dial(ctx, strings.Replace(serverA.URL, "https://", "wss://", 1)+"/v1/events/connect", &websocket.DialOptions{HTTPClient: serverA.Client(), HTTPHeader: headers, Subprotocols: []string{httpserver.EventSubprotocol}})
if conn != nil {
conn.CloseNow()
}
if err == nil || response == nil || response.StatusCode != 503 {
t.Fatal("closing hub accepted new connection")
}
b := connectTestEvents(t, ctx, serverB, f, sub.ID, 1)
b.reply(b.send("event.ack", eventProof(g), ""), "")
})
}

View File

@@ -0,0 +1,5 @@
-- 面向已物化交付历史的稳定创建时间分页;确认和重新发送不会改变排序键。
-- 不使用外键或级联删除;序号和事件归属继续由服务端按命名空间校验。
ALTER TABLE orbit_deliveries
ADD KEY ix_delivery_history(namespace_id,subscription_id,created_at,id),
ADD KEY ix_delivery_state_history(namespace_id,subscription_id,state,created_at,id);

View File

@@ -88,7 +88,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
}
indexes := func() []string {
t.Helper()
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
if err != nil {
t.Fatal(err)
}