feat: expose result subscription HTTP and WebSocket APIs
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
179
internal/storage/mysql/event_queries.go
Normal file
179
internal/storage/mysql/event_queries.go
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
502
internal/storage/mysql/event_transport_integration_test.go
Normal file
502
internal/storage/mysql/event_transport_integration_test.go
Normal 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), ""), "")
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
61
internal/testcontract/events.go
Normal file
61
internal/testcontract/events.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Package testcontract 在回归中校验实际报文与公开 Schema,服务二进制不依赖此包。
|
||||
package testcontract
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/protocol/eventsv1"
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
var eventOnce sync.Once
|
||||
var eventSchemas map[string]*jsonschema.Schema
|
||||
var eventError error
|
||||
|
||||
// EventSchema 编译并缓存两个方向的 Schema;开启 date-time 等格式断言。
|
||||
func EventSchema(t testing.TB, direction string) *jsonschema.Schema {
|
||||
t.Helper()
|
||||
eventOnce.Do(func() {
|
||||
compiler := jsonschema.NewCompiler()
|
||||
compiler.AssertFormat()
|
||||
doc, err := jsonschema.UnmarshalJSON(strings.NewReader(eventsv1.Schema))
|
||||
if err != nil {
|
||||
eventError = err
|
||||
return
|
||||
}
|
||||
const resource = "https://git.yuntuo.hk/k2safe/orbit/schema/events.v1.json"
|
||||
if err := compiler.AddResource(resource, doc); err != nil {
|
||||
eventError = err
|
||||
return
|
||||
}
|
||||
eventSchemas = make(map[string]*jsonschema.Schema)
|
||||
for _, side := range []string{"client", "server"} {
|
||||
eventSchemas[side], eventError = compiler.Compile(resource + "#/$defs/" + side + "Frame")
|
||||
if eventError != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
if eventError != nil {
|
||||
t.Fatal(eventError)
|
||||
}
|
||||
schema := eventSchemas[direction]
|
||||
if schema == nil {
|
||||
t.Fatal("unknown Event protocol direction")
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
// EventFrame 验证真实传输的 JSON,不在错误中输出可能包含秘密的报文正文。
|
||||
func EventFrame(t testing.TB, direction string, raw []byte) {
|
||||
t.Helper()
|
||||
value, err := jsonschema.UnmarshalJSON(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
t.Fatal("Event frame is not valid JSON")
|
||||
}
|
||||
if EventSchema(t, direction).Validate(value) != nil {
|
||||
t.Fatalf("%s Event frame violates published Schema", direction)
|
||||
}
|
||||
}
|
||||
175
internal/transport/httpserver/event_codec.go
Normal file
175
internal/transport/httpserver/event_codec.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
)
|
||||
|
||||
const EventSubprotocol = "orbit.events.v1"
|
||||
const eventMaxFrame = 512 << 10
|
||||
|
||||
type eventFrame struct {
|
||||
Version string `json:"protocol_version"`
|
||||
Type string `json:"type"`
|
||||
MessageID core.ID `json:"message_id"`
|
||||
ReplyTo core.ID `json:"reply_to,omitempty"`
|
||||
SessionID core.ID `json:"session_id,omitempty"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
Error *fault.Error `json:"error,omitempty"`
|
||||
}
|
||||
type eventHello struct {
|
||||
SubscriptionID core.ID `json:"subscription_id"`
|
||||
Capacity uint32 `json:"capacity"`
|
||||
}
|
||||
type eventWelcome struct {
|
||||
SessionID core.ID `json:"session_id"`
|
||||
SubscriptionID core.ID `json:"subscription_id"`
|
||||
Capacity uint32 `json:"capacity"`
|
||||
LeaseDurationMS int64 `json:"lease_duration_ms"`
|
||||
HeartbeatIntervalMS int64 `json:"heartbeat_interval_ms"`
|
||||
ServerTime time.Time `json:"server_time"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
type eventPong struct {
|
||||
ServerTime time.Time `json:"server_time"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func decodeEventBody(r *http.Request, out any) error {
|
||||
content, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || content != "application/json" {
|
||||
return fault.New("UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json")
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, eventMaxFrame+1))
|
||||
if err != nil {
|
||||
return fault.Invalid("cannot read request body")
|
||||
}
|
||||
if len(raw) > eventMaxFrame {
|
||||
return fault.New("BODY_TOO_LARGE", "request body exceeds 512 KiB")
|
||||
}
|
||||
return decodeEventJSON(raw, out)
|
||||
}
|
||||
func decodeEventJSON(raw []byte, out any) error {
|
||||
if len(raw) == 0 || len(raw) > eventMaxFrame {
|
||||
return fault.Invalid("invalid event message size")
|
||||
}
|
||||
if _, err := jsonvalue.Canonical(raw); err != nil {
|
||||
return fault.Invalid("invalid event JSON")
|
||||
}
|
||||
typ := reflect.TypeOf(out).Elem()
|
||||
if exactFields(raw, typ) != nil || requiredEventFields(raw, typ) != nil {
|
||||
return fault.Invalid("missing, unknown or invalid event field")
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(out) != nil {
|
||||
return fault.Invalid("invalid event field value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requiredEventFields 同时验证显式控制意图及规范十进制字符串;可选历史游标允许0和null。
|
||||
func requiredEventFields(raw []byte, typ reflect.Type) error {
|
||||
if typ == reflect.TypeOf(json.RawMessage{}) || typ == reflect.TypeOf(time.Time{}) {
|
||||
return nil
|
||||
}
|
||||
if typ.Kind() == reflect.Pointer {
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
return requiredEventFields(raw, typ.Elem())
|
||||
}
|
||||
if typ.Kind() == reflect.Array || typ.Kind() == reflect.Slice {
|
||||
var values []json.RawMessage
|
||||
if json.Unmarshal(raw, &values) != nil {
|
||||
return fault.Invalid("invalid event array")
|
||||
}
|
||||
for _, v := range values {
|
||||
if err := requiredEventFields(v, typ.Elem()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if typ.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &fields) != nil {
|
||||
return fault.Invalid("invalid event object")
|
||||
}
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
tags := strings.Split(field.Tag.Get("json"), ",")
|
||||
if tags[0] == "" || tags[0] == "-" {
|
||||
continue
|
||||
}
|
||||
optional, numberString := false, false
|
||||
for _, v := range tags[1:] {
|
||||
optional = optional || v == "omitempty"
|
||||
numberString = numberString || v == "string"
|
||||
}
|
||||
value, found := fields[tags[0]]
|
||||
if !found {
|
||||
if optional {
|
||||
continue
|
||||
}
|
||||
return fault.Invalid("missing event field")
|
||||
}
|
||||
ft := field.Type
|
||||
nullable := ft.Kind() == reflect.Pointer
|
||||
if nullable {
|
||||
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
continue
|
||||
}
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft.Kind() == reflect.Uint64 && numberString {
|
||||
var text string
|
||||
if json.Unmarshal(value, &text) != nil {
|
||||
return fault.Invalid("event counter must be a decimal string")
|
||||
}
|
||||
n, err := strconv.ParseUint(text, 10, 64)
|
||||
if err != nil || strconv.FormatUint(n, 10) != text || (!nullable && n == 0) {
|
||||
return fault.Invalid("invalid event counter")
|
||||
}
|
||||
}
|
||||
if err := requiredEventFields(value, field.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func decodeEventRequest(raw []byte, hello bool) (eventFrame, error) {
|
||||
var frame eventFrame
|
||||
if err := decodeEventJSON(raw, &frame); err != nil {
|
||||
return frame, err
|
||||
}
|
||||
if frame.Version != "1" || frame.MessageID.Validate() != nil || len(frame.Body) == 0 {
|
||||
return frame, fault.Invalid("invalid event envelope")
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
_ = json.Unmarshal(raw, &fields)
|
||||
if fields["error"] != nil || fields["reply_to"] != nil {
|
||||
return frame, fault.Invalid("response fields are not allowed in event requests")
|
||||
}
|
||||
if hello {
|
||||
if frame.Type != "events.hello" || fields["session_id"] != nil {
|
||||
return frame, fault.Invalid("invalid event hello")
|
||||
}
|
||||
} else if frame.SessionID.Validate() != nil || (frame.Type != "event.ack" && frame.Type != "events.ping") {
|
||||
return frame, fault.Invalid("invalid event session or type")
|
||||
}
|
||||
return frame, nil
|
||||
}
|
||||
141
internal/transport/httpserver/event_codec_test.go
Normal file
141
internal/transport/httpserver/event_codec_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/testcontract"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
)
|
||||
|
||||
func TestEventFrameContract(t *testing.T) {
|
||||
id := strings.Repeat("1", 32)
|
||||
secret, _, err := events.NewSecret()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid := []string{
|
||||
`{"protocol_version":"1","type":"events.hello","message_id":"` + id + `","body":{"subscription_id":"` + id + `","capacity":1}}`,
|
||||
`{"protocol_version":"1","type":"events.ping","message_id":"` + id + `","session_id":"` + id + `","body":{}}`,
|
||||
`{"protocol_version":"1","type":"event.ack","message_id":"` + id + `","session_id":"` + id + `","body":{"delivery_id":"` + id + `","epoch":"1","lease_secret":"` + secret + `"}}`,
|
||||
}
|
||||
for i, raw := range valid {
|
||||
testcontract.EventFrame(t, "client", []byte(raw))
|
||||
f, err := decodeEventRequest([]byte(raw), i == 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch i {
|
||||
case 0:
|
||||
var v eventHello
|
||||
err = decodeEventJSON(f.Body, &v)
|
||||
case 1:
|
||||
var v struct{}
|
||||
err = decodeEventJSON(f.Body, &v)
|
||||
case 2:
|
||||
var v events.AckInput
|
||||
err = decodeEventJSON(f.Body, &v)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, number := range []string{"0", "01", "18446744073709551616", "18446744073709551615"} {
|
||||
raw := strings.Replace(valid[2], `"epoch":"1"`, `"epoch":"`+number+`"`, 1)
|
||||
var frame eventFrame
|
||||
_ = json.Unmarshal([]byte(raw), &frame)
|
||||
var input events.AckInput
|
||||
decodeErr := decodeEventJSON(frame.Body, &input)
|
||||
var value any
|
||||
_ = json.Unmarshal([]byte(raw), &value)
|
||||
schemaErr := testcontract.EventSchema(t, "client").Validate(value)
|
||||
wantValid := number == "18446744073709551615"
|
||||
if (decodeErr == nil) != wantValid || (schemaErr == nil) != wantValid {
|
||||
t.Fatal("event counter range disagrees with contract")
|
||||
}
|
||||
}
|
||||
bad := []string{
|
||||
strings.Replace(valid[0], `"body":`, `"session_id":null,"body":`, 1),
|
||||
strings.Replace(valid[1], `"body":`, `"reply_to":"","body":`, 1),
|
||||
strings.Replace(valid[1], `"body":`, `"error":null,"body":`, 1),
|
||||
strings.Replace(valid[1], `"type":"events.ping"`, `"Type":"events.ping"`, 1),
|
||||
strings.Replace(valid[1], `"protocol_version":"1"`, `"protocol_version":"1","protocol_version":"1"`, 1),
|
||||
strings.Replace(valid[1], `"events.ping"`, `"worker.capacity"`, 1),
|
||||
strings.Replace(valid[1], `"body":{}`, `"body":null`, 1),
|
||||
}
|
||||
for _, raw := range bad {
|
||||
if _, err := decodeEventRequest([]byte(raw), strings.Contains(raw, "events.hello")); err == nil {
|
||||
t.Fatal("accepted invalid event envelope")
|
||||
}
|
||||
}
|
||||
// 标准Schema无法识别重复JSON键;实际解码器负责拒绝,其他方向约束由两者共同验证。
|
||||
for _, raw := range bad[:4] {
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if testcontract.EventSchema(t, "client").Validate(value) == nil {
|
||||
t.Fatal("schema accepted direction or field violation")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestEventHTTPRequiredFieldsAndCounterCanonicalization(t *testing.T) {
|
||||
id := strings.Repeat("1", 32)
|
||||
for _, number := range []string{`"00"`, `"+1"`, `"01"`, `1`, `true`, `"18446744073709551616"`} {
|
||||
var in events.CreateInput
|
||||
raw := `{"name":"results","queue_id":"` + id + `","from_sequence":` + number + `}`
|
||||
if decodeEventJSON([]byte(raw), &in) == nil {
|
||||
t.Fatal("accepted invalid sequence")
|
||||
}
|
||||
}
|
||||
for _, number := range []string{`"0"`, `null`, `"18446744073709551615"`} {
|
||||
var in events.CreateInput
|
||||
if err := decodeEventJSON([]byte(`{"name":"results","queue_id":"`+id+`","from_sequence":`+number+`}`), &in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{`{"expected_revision":"1","reason":"pause"}`, `{"expected_revision":"01","reason":"pause","enabled":false}`, `{"expected_revision":"1","reason":"pause","enabled":null}`, `{"expected_revision":"1","reason":"pause","Enabled":false}`} {
|
||||
var in events.ControlInput
|
||||
if decodeEventJSON([]byte(raw), &in) == nil {
|
||||
t.Fatal("accepted ambiguous control intent")
|
||||
}
|
||||
}
|
||||
var good events.ControlInput
|
||||
if err := decodeEventJSON([]byte(`{"expected_revision":"1","reason":"pause","enabled":false}`), &good); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventDocumentationFramesMatchPublishedSchema(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../../docs/api/subscriptions.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frames := 0
|
||||
for _, block := range strings.Split(string(raw), "```json\n")[1:] {
|
||||
snippet := strings.SplitN(block, "\n```", 2)[0]
|
||||
var value map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(snippet), &value) != nil {
|
||||
t.Fatal("invalid documented JSON")
|
||||
}
|
||||
if value["protocol_version"] == nil {
|
||||
continue
|
||||
}
|
||||
var kind string
|
||||
if json.Unmarshal(value["type"], &kind) != nil {
|
||||
t.Fatal("documented frame lacks type")
|
||||
}
|
||||
side := "server"
|
||||
if kind == "events.hello" || kind == "events.ping" || kind == "event.ack" {
|
||||
side = "client"
|
||||
}
|
||||
testcontract.EventFrame(t, side, []byte(snippet))
|
||||
frames++
|
||||
}
|
||||
if frames != 6 {
|
||||
t.Fatalf("expected six complete documented frames, got %d", frames)
|
||||
}
|
||||
}
|
||||
356
internal/transport/httpserver/event_hub.go
Normal file
356
internal/transport/httpserver/event_hub.go
Normal file
@@ -0,0 +1,356 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/dispatch"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// EventHub 仅负责已持久化结果的传输;断线不删除租约,消费者按事件ID持久化去重。
|
||||
// 节点最多128条连接,领取池全局4个、每应用1个在途,队列加在途共128个。
|
||||
type EventHub struct {
|
||||
service *events.Service
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
closing bool
|
||||
slots chan struct{}
|
||||
wg sync.WaitGroup
|
||||
dispatch *dispatch.Pool
|
||||
poll time.Duration
|
||||
}
|
||||
|
||||
func NewEventHub(parent context.Context, service *events.Service) *EventHub {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
h := &EventHub{service: service, ctx: ctx, cancel: cancel, slots: make(chan struct{}, 128), poll: time.Second}
|
||||
h.dispatch = dispatch.New(ctx, 4, 128)
|
||||
h.wg.Add(1)
|
||||
go func() { defer h.wg.Done(); <-h.dispatch.Done() }()
|
||||
return h
|
||||
}
|
||||
func RegisterEventConsumers(server *http.Server, hub *EventHub) {
|
||||
server.Handler.(*http.ServeMux).Handle("GET /v1/events/connect", hub)
|
||||
}
|
||||
func (h *EventHub) Shutdown(ctx context.Context) error {
|
||||
h.mu.Lock()
|
||||
h.closing = true
|
||||
h.cancel()
|
||||
h.mu.Unlock()
|
||||
done := make(chan struct{})
|
||||
go func() { h.wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
func (h *EventHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
requestID, err := core.NewID()
|
||||
if err != nil {
|
||||
writeError(w, "", fault.Unavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Request-ID", string(requestID))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if err = noEventQuery(r); err != nil {
|
||||
writeError(w, string(requestID), err)
|
||||
return
|
||||
}
|
||||
values := r.Header.Values("Authorization")
|
||||
if len(values) != 1 {
|
||||
writeError(w, string(requestID), fault.Unauthorized)
|
||||
return
|
||||
}
|
||||
scheme, value, ok := strings.Cut(values[0], " ")
|
||||
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
||||
writeError(w, string(requestID), fault.Unauthorized)
|
||||
return
|
||||
}
|
||||
token, err := auth.Parse(value)
|
||||
if err != nil {
|
||||
writeError(w, string(requestID), err)
|
||||
return
|
||||
}
|
||||
supported := false
|
||||
for _, header := range r.Header.Values("Sec-WebSocket-Protocol") {
|
||||
for _, p := range strings.Split(header, ",") {
|
||||
supported = supported || strings.TrimSpace(p) == EventSubprotocol
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
writeJSON(w, http.StatusUpgradeRequired, map[string]any{"request_id": requestID, "error": fault.New("PROTOCOL_REQUIRED", "orbit.events.v1 subprotocol is required")})
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
if h.closing || h.ctx.Err() != nil {
|
||||
h.mu.Unlock()
|
||||
writeError(w, string(requestID), fault.Unavailable)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case h.slots <- struct{}{}:
|
||||
h.wg.Add(1)
|
||||
default:
|
||||
h.mu.Unlock()
|
||||
w.Header().Set("Retry-After", "1")
|
||||
writeJSON(w, 429, map[string]any{"request_id": requestID, "error": fault.New("CAPACITY_EXCEEDED", "node event connection capacity exceeded")})
|
||||
return
|
||||
}
|
||||
h.mu.Unlock()
|
||||
defer func() { <-h.slots; h.wg.Done() }()
|
||||
authorize, stop := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
err = h.service.Authorize(authorize, token)
|
||||
stop()
|
||||
if err != nil {
|
||||
writeError(w, string(requestID), err)
|
||||
return
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{EventSubprotocol}, CompressionMode: websocket.CompressionDisabled})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
conn.SetReadLimit(eventMaxFrame)
|
||||
ctx, cancel := context.WithCancel(h.ctx)
|
||||
defer cancel()
|
||||
helloCtx, helloStop := context.WithTimeout(ctx, 5*time.Second)
|
||||
kind, raw, err := conn.Read(helloCtx)
|
||||
helloStop()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
frame, decodeErr := decodeEventRequest(raw, true)
|
||||
var input eventHello
|
||||
if kind != websocket.MessageText || decodeErr != nil || decodeEventJSON(frame.Body, &input) != nil || input.SubscriptionID.Validate() != nil || input.Capacity < 1 || input.Capacity > 32 {
|
||||
_ = conn.Close(websocket.StatusPolicyViolation, "invalid event hello")
|
||||
return
|
||||
}
|
||||
call, callStop := context.WithTimeout(ctx, 8*time.Second)
|
||||
binding, err := h.service.Bind(call, token, input.SubscriptionID)
|
||||
callStop()
|
||||
if err != nil {
|
||||
_ = writeEventFrame(ctx, conn, eventFrame{Version: "1", Type: "error", MessageID: requestID, ReplyTo: frame.MessageID, Error: eventFault(err)})
|
||||
return
|
||||
}
|
||||
session, err := core.NewID()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
welcome := eventWelcome{SessionID: session, SubscriptionID: input.SubscriptionID, Capacity: input.Capacity, LeaseDurationMS: events.LeaseDuration.Milliseconds(), HeartbeatIntervalMS: 15000, ServerTime: binding.ServerTime, Enabled: binding.Enabled}
|
||||
body, err := jsonwire.Marshal(welcome)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err = writeEventFrame(ctx, conn, eventFrame{Version: "1", Type: "events.welcome", MessageID: requestID, ReplyTo: frame.MessageID, SessionID: session, Body: body}); err != nil {
|
||||
return
|
||||
}
|
||||
h.consume(ctx, cancel, conn, token, binding, session, input.Capacity)
|
||||
}
|
||||
|
||||
type eventClaimResult struct {
|
||||
grant *events.Grant
|
||||
err error
|
||||
}
|
||||
|
||||
func (h *EventHub) claim(ctx context.Context, t auth.Token, sub, connection core.ID, capacity uint32) (out eventClaimResult) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
out = eventClaimResult{err: fault.Unavailable}
|
||||
}
|
||||
}()
|
||||
call, stop := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer stop()
|
||||
out.grant, out.err = h.service.Claim(call, t, sub, events.ClaimInput{ConnectionID: connection, Capacity: capacity})
|
||||
return out
|
||||
}
|
||||
func (h *EventHub) consume(ctx context.Context, cancel context.CancelFunc, conn *websocket.Conn, t auth.Token, b events.ConsumerBinding, session core.ID, capacity uint32) {
|
||||
incoming := make(chan eventFrame, 4)
|
||||
outgoing := make(chan outboundFrame, 8)
|
||||
var loops sync.WaitGroup
|
||||
loops.Add(2)
|
||||
go func() {
|
||||
defer loops.Done()
|
||||
defer cancel()
|
||||
for {
|
||||
readCtx, stop := context.WithTimeout(ctx, 60*time.Second)
|
||||
kind, raw, err := conn.Read(readCtx)
|
||||
stop()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
frame, err := decodeEventRequest(raw, false)
|
||||
if kind != websocket.MessageText || err != nil || frame.SessionID != session {
|
||||
_ = conn.Close(websocket.StatusPolicyViolation, "invalid event frame")
|
||||
return
|
||||
}
|
||||
select {
|
||||
case incoming <- frame:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer loops.Done()
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case frame := <-outgoing:
|
||||
writeCtx, stop := context.WithTimeout(ctx, 5*time.Second)
|
||||
err := conn.Write(writeCtx, websocket.MessageText, frame.data)
|
||||
stop()
|
||||
if frame.done != nil {
|
||||
frame.done <- err
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer func() { cancel(); _ = conn.CloseNow(); loops.Wait() }()
|
||||
send := func(frame eventFrame, wait bool) bool {
|
||||
frame.Version = "1"
|
||||
frame.SessionID = session
|
||||
if frame.MessageID == "" {
|
||||
var err error
|
||||
frame.MessageID, err = core.NewID()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
raw, err := jsonwire.Marshal(frame)
|
||||
if err != nil || len(raw) > eventMaxFrame {
|
||||
return false
|
||||
}
|
||||
out := outboundFrame{data: raw}
|
||||
if wait {
|
||||
out.done = make(chan error, 1)
|
||||
}
|
||||
select {
|
||||
case outgoing <- out:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if !wait {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case err := <-out.done:
|
||||
return err == nil
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
results := make(chan eventClaimResult, 1)
|
||||
pending, burst := false, 0
|
||||
request := func() {
|
||||
if pending || burst == 0 || len(outgoing) >= 4 {
|
||||
return
|
||||
}
|
||||
pending = h.dispatch.Submit(dispatch.Job{Context: ctx, Application: string(b.ApplicationID), Queue: string(t.NamespaceID) + "/" + string(b.SubscriptionID), Run: func(job context.Context) { results <- h.claim(job, t, b.SubscriptionID, session, capacity) }})
|
||||
if pending {
|
||||
burst--
|
||||
}
|
||||
}
|
||||
ticker := time.NewTicker(h.poll)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case result := <-results:
|
||||
pending = false
|
||||
if result.err != nil {
|
||||
send(eventFrame{Type: "error", Error: eventFault(result.err)}, true)
|
||||
return
|
||||
}
|
||||
if result.grant == nil {
|
||||
burst = 0
|
||||
continue
|
||||
}
|
||||
raw, err := jsonwire.Marshal(result.grant)
|
||||
if err != nil || !send(eventFrame{Type: "event.deliver", Body: raw}, false) {
|
||||
return
|
||||
}
|
||||
request()
|
||||
case frame := <-incoming:
|
||||
call, stop := context.WithTimeout(ctx, 8*time.Second)
|
||||
var body any
|
||||
var err error
|
||||
switch frame.Type {
|
||||
case "event.ack":
|
||||
var in events.AckInput
|
||||
if err = decodeEventJSON(frame.Body, &in); err == nil {
|
||||
body, err = h.service.Ack(call, t, b.SubscriptionID, in)
|
||||
}
|
||||
case "events.ping":
|
||||
var in struct{}
|
||||
if err = decodeEventJSON(frame.Body, &in); err == nil {
|
||||
var current events.ConsumerBinding
|
||||
current, err = h.service.Bind(call, t, b.SubscriptionID)
|
||||
if err == nil {
|
||||
body = eventPong{ServerTime: current.ServerTime, Enabled: current.Enabled}
|
||||
}
|
||||
}
|
||||
default:
|
||||
err = fault.Invalid("unsupported event message type")
|
||||
}
|
||||
stop()
|
||||
reply := eventFrame{Type: "ack", ReplyTo: frame.MessageID}
|
||||
if err != nil {
|
||||
reply.Type = "error"
|
||||
reply.Error = eventFault(err)
|
||||
} else {
|
||||
var marshalErr error
|
||||
reply.Body, marshalErr = jsonwire.Marshal(body)
|
||||
if marshalErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
fatal := err != nil && (fault.Code(err) == "UNAUTHENTICATED" || fault.Code(err) == "PERMISSION_DENIED" || fault.Code(err) == "NOT_FOUND" || fault.Code(err) == "UNAVAILABLE")
|
||||
if !send(reply, fatal) || fatal {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
if !pending {
|
||||
burst = 4
|
||||
request()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func eventFault(err error) *fault.Error {
|
||||
var known *fault.Error
|
||||
if errors.As(err, &known) {
|
||||
return known
|
||||
}
|
||||
return fault.Unavailable
|
||||
}
|
||||
func writeEventFrame(ctx context.Context, conn *websocket.Conn, frame eventFrame) error {
|
||||
raw, err := jsonwire.Marshal(frame)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > eventMaxFrame {
|
||||
return fault.Invalid("event frame too large")
|
||||
}
|
||||
bounded, stop := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer stop()
|
||||
return conn.Write(bounded, websocket.MessageText, raw)
|
||||
}
|
||||
139
internal/transport/httpserver/event_hub_test.go
Normal file
139
internal/transport/httpserver/event_hub_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
type slowEventStore struct {
|
||||
events.Store
|
||||
once sync.Once
|
||||
started chan struct{}
|
||||
finished chan struct{}
|
||||
}
|
||||
|
||||
func (s *slowEventStore) AuthorizeEvents(context.Context, auth.Token) error { return nil }
|
||||
func (s *slowEventStore) BindConsumer(_ context.Context, _ auth.Token, id core.ID) (events.ConsumerBinding, error) {
|
||||
return events.ConsumerBinding{ApplicationID: core.ID(strings.Repeat("1", 32)), SubscriptionID: id, QueueID: core.ID(strings.Repeat("2", 32)), Enabled: true, ServerTime: time.Now().UTC()}, nil
|
||||
}
|
||||
func (s *slowEventStore) ClaimDelivery(ctx context.Context, _ auth.Token, _ core.ID, _ events.ClaimInput) (*events.Grant, error) {
|
||||
s.once.Do(func() { close(s.started) })
|
||||
<-ctx.Done()
|
||||
select {
|
||||
case s.finished <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
func TestEventHubSlowClaimKeepsControlLiveAndBoundsConnections(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
store := &slowEventStore{started: make(chan struct{}), finished: make(chan struct{}, 4)}
|
||||
hub := NewEventHub(ctx, events.New(store))
|
||||
if cap(hub.slots) != 128 {
|
||||
t.Fatal("unexpected production connection bound")
|
||||
}
|
||||
hub.slots = make(chan struct{}, 2)
|
||||
hub.poll = 10 * time.Millisecond
|
||||
server := New("", nil)
|
||||
RegisterEventConsumers(server, hub)
|
||||
tls := httptest.NewTLSServer(server.Handler)
|
||||
defer tls.Close()
|
||||
defer hub.Shutdown(context.Background())
|
||||
id := core.ID(strings.Repeat("3", 32))
|
||||
credential := core.ID(strings.Repeat("4", 32))
|
||||
token, _, err := auth.Issue(id, credential)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := http.Header{}
|
||||
headers.Set("Authorization", "Bearer "+token)
|
||||
dial := func() (*websocket.Conn, *http.Response, error) {
|
||||
return websocket.Dial(ctx, strings.Replace(tls.URL, "https://", "wss://", 1)+"/v1/events/connect", &websocket.DialOptions{HTTPClient: tls.Client(), HTTPHeader: headers, Subprotocols: []string{EventSubprotocol}})
|
||||
}
|
||||
connect := func() (*websocket.Conn, core.ID) {
|
||||
t.Helper()
|
||||
conn, _, err := dial()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { conn.CloseNow() })
|
||||
hello, _ := jsonwire.Marshal(eventHello{SubscriptionID: id, Capacity: 1})
|
||||
raw, _ := jsonwire.Marshal(eventFrame{Version: "1", Type: "events.hello", MessageID: id, Body: hello})
|
||||
if err = conn.Write(ctx, websocket.MessageText, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, reply, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var frame eventFrame
|
||||
if json.Unmarshal(reply, &frame) != nil || frame.Type != "events.welcome" {
|
||||
t.Fatal("missing welcome")
|
||||
}
|
||||
return conn, frame.SessionID
|
||||
}
|
||||
first, session := connect()
|
||||
second, _ := connect()
|
||||
select {
|
||||
case <-store.started:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("claim was not dispatched")
|
||||
}
|
||||
blocked, resp, err := dial()
|
||||
if blocked != nil {
|
||||
blocked.CloseNow()
|
||||
}
|
||||
if err == nil || resp == nil || resp.StatusCode != 429 || resp.Header.Get("Retry-After") != "1" {
|
||||
t.Fatal("event connection bound not enforced")
|
||||
}
|
||||
ping, _ := jsonwire.Marshal(eventFrame{Version: "1", Type: "events.ping", MessageID: credential, SessionID: session, Body: json.RawMessage(`{}`)})
|
||||
if err = first.Write(ctx, websocket.MessageText, ping); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bounded, stop := context.WithTimeout(ctx, 500*time.Millisecond)
|
||||
_, raw, err := first.Read(bounded)
|
||||
stop()
|
||||
if err != nil {
|
||||
t.Fatal("slow claim blocked control reply")
|
||||
}
|
||||
var reply eventFrame
|
||||
if json.Unmarshal(raw, &reply) != nil || reply.Type != "ack" || reply.ReplyTo != credential {
|
||||
t.Fatal("missing ping acknowledgement")
|
||||
}
|
||||
second.CloseNow()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for len(hub.slots) == 2 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if len(hub.slots) != 1 {
|
||||
t.Fatal("disconnected consumer leaked connection slot")
|
||||
}
|
||||
third, _ := connect()
|
||||
defer third.CloseNow()
|
||||
bounded, stop = context.WithTimeout(ctx, time.Second)
|
||||
defer stop()
|
||||
if err = hub.Shutdown(bounded); err != nil {
|
||||
t.Fatal("shutdown leaked claim or connection", err)
|
||||
}
|
||||
select {
|
||||
case <-store.finished:
|
||||
default:
|
||||
t.Fatal("shutdown did not cancel in-flight claim")
|
||||
}
|
||||
if len(hub.slots) != 0 {
|
||||
t.Fatal("shutdown leaked connection slots")
|
||||
}
|
||||
}
|
||||
128
internal/transport/httpserver/events.go
Normal file
128
internal/transport/httpserver/events.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
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"
|
||||
)
|
||||
|
||||
func RegisterEventSubscriptions(server *http.Server, s *events.Service) {
|
||||
mux := server.Handler.(*http.ServeMux)
|
||||
mux.HandleFunc("POST /v1/subscriptions", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var in events.CreateInput
|
||||
if err := decodeEventBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Create(r.Context(), t, key, in)
|
||||
return out, createdStatus(out.Deduplicated), err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/subscriptions", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
queue, in, err := eventQuery(r, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.List(r.Context(), t, queue, in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/subscriptions/{id}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Get(r.Context(), t, core.ID(r.PathValue("id")))
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("POST /v1/subscriptions/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
id, command, ok := strings.Cut(r.PathValue("command"), ":")
|
||||
if !ok || command != "configure" {
|
||||
return nil, 0, fault.NotFound
|
||||
}
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var in events.ControlInput
|
||||
if err := decodeEventBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Control(r.Context(), t, core.ID(id), key, in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/subscriptions/{id}/deliveries", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
_, in, err := eventQuery(r, false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Deliveries(r.Context(), t, core.ID(r.PathValue("id")), in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/subscriptions/{id}/deliveries/{delivery}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Detail(r.Context(), t, core.ID(r.PathValue("id")), core.ID(r.PathValue("delivery")))
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("POST /v1/subscriptions/{id}/deliveries/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
delivery, command, ok := strings.Cut(r.PathValue("command"), ":")
|
||||
if !ok || command != "retry" {
|
||||
return nil, 0, fault.NotFound
|
||||
}
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var in events.RetryInput
|
||||
if err := decodeEventBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Retry(r.Context(), t, core.ID(r.PathValue("id")), core.ID(delivery), key, in)
|
||||
return out, 200, err
|
||||
}))
|
||||
}
|
||||
func noEventQuery(r *http.Request) error {
|
||||
if r.URL.RawQuery != "" || r.URL.ForceQuery {
|
||||
return fault.Invalid("query parameters are not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func eventQuery(r *http.Request, subscriptions bool) (core.ID, events.PageInput, error) {
|
||||
var in events.PageInput
|
||||
q, err := url.ParseQuery(r.URL.RawQuery)
|
||||
if err != nil {
|
||||
return "", in, fault.Invalid("invalid query encoding")
|
||||
}
|
||||
for k, v := range q {
|
||||
if len(v) != 1 || (k != "limit" && k != "cursor" && !(subscriptions && k == "queue_id") && !(!subscriptions && k == "state")) {
|
||||
return "", in, fault.Invalid("invalid subscription query")
|
||||
}
|
||||
}
|
||||
if q.Has("limit") {
|
||||
in.Limit, err = strconv.Atoi(q.Get("limit"))
|
||||
if err != nil || in.Limit < 1 || strconv.Itoa(in.Limit) != q.Get("limit") {
|
||||
return "", in, fault.Invalid("invalid limit")
|
||||
}
|
||||
}
|
||||
in.Cursor = q.Get("cursor")
|
||||
in.State = platform.DeliveryState(q.Get("state"))
|
||||
return core.ID(q.Get("queue_id")), in, nil
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func writeError(w http.ResponseWriter, id string, err error) {
|
||||
if !errors.As(err, &known) {
|
||||
known = fault.Unavailable
|
||||
}
|
||||
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415}[known.Code]
|
||||
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409}[known.Code]
|
||||
if status == 0 {
|
||||
status = 503
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user