284 lines
11 KiB
Go
284 lines
11 KiB
Go
// Package events 定义可靠结果订阅契约;交付采用至少一次语义,接收方按事件ID去重。
|
||
package events
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"regexp"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||
platform "git.yuntuo.hk/k2safe/orbit/platform/model"
|
||
)
|
||
|
||
const LeaseDuration = 30 * time.Second
|
||
const MaxSubscriptions = 64
|
||
|
||
var (
|
||
LeaseLost = fault.New("DELIVERY_LEASE_LOST", "delivery proof was superseded or is invalid")
|
||
HistoryExpired = fault.New("EVENT_HISTORY_EXPIRED", "requested event history is no longer retained")
|
||
SubscriptionLimit = fault.New("SUBSCRIPTION_LIMIT", "namespace subscription limit reached")
|
||
)
|
||
|
||
func ResultType(s string) bool {
|
||
switch s {
|
||
case "task.succeeded", "task.failed", "task.cancelled", "task.needs_review":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// TaskResult 是事件发生时冻结的快照,不从后续可变任务状态重新生成。
|
||
type TaskResult struct {
|
||
SchemaVersion uint32 `json:"schema_version"`
|
||
TaskID core.ID `json:"task_id"`
|
||
QueueID core.ID `json:"queue_id"`
|
||
State string `json:"state"`
|
||
Revision uint64 `json:"revision,string"`
|
||
Result json.RawMessage `json:"result,omitempty"`
|
||
EffectUncertain bool `json:"effect_uncertain"`
|
||
CurrentAttemptID *core.ID `json:"current_attempt_id,omitempty"`
|
||
}
|
||
|
||
type Event struct {
|
||
ID core.ID `json:"id"`
|
||
Sequence uint64 `json:"sequence,string"`
|
||
Type string `json:"type"`
|
||
AggregateKind string `json:"aggregate_kind"`
|
||
AggregateID core.ID `json:"aggregate_id"`
|
||
AggregateRevision uint64 `json:"aggregate_revision,string"`
|
||
OccurredAt time.Time `json:"occurred_at"`
|
||
// Details 是来源事件元信息,不包含租约秘密和任务输入。
|
||
Details json.RawMessage `json:"details"`
|
||
Snapshot TaskResult `json:"snapshot"`
|
||
}
|
||
|
||
type CreateInput struct {
|
||
Name string `json:"name"`
|
||
QueueID core.ID `json:"queue_id"`
|
||
Types []string `json:"types,omitempty"`
|
||
// FromSequence 之后的结果纳入订阅;省略为创建时已提交的最新序号,0为从现存日志开始。
|
||
FromSequence *uint64 `json:"from_sequence,string,omitempty"`
|
||
MaxPending uint32 `json:"max_pending,omitempty"`
|
||
MaxAttempts uint32 `json:"max_attempts,omitempty"`
|
||
}
|
||
|
||
type Subscription struct {
|
||
ID core.ID `json:"id"`
|
||
QueueID core.ID `json:"queue_id"`
|
||
CredentialID core.ID `json:"credential_id"`
|
||
Name string `json:"name"`
|
||
Revision uint64 `json:"revision,string"`
|
||
Enabled bool `json:"enabled"`
|
||
Types []string `json:"types"`
|
||
CursorSequence uint64 `json:"cursor_sequence,string"`
|
||
PendingCount uint32 `json:"pending_count"`
|
||
MaxPending uint32 `json:"max_pending"`
|
||
MaxAttempts uint32 `json:"max_attempts"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
type SubscriptionResult struct {
|
||
Subscription Subscription `json:"subscription"`
|
||
Deduplicated bool `json:"deduplicated"`
|
||
}
|
||
|
||
type ControlInput struct {
|
||
ExpectedRevision uint64 `json:"expected_revision,string"`
|
||
Enabled bool `json:"enabled"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
|
||
type Delivery struct {
|
||
CreatedAt time.Time `json:"created_at,omitzero"`
|
||
ID core.ID `json:"id"`
|
||
SubscriptionID core.ID `json:"subscription_id"`
|
||
EventID core.ID `json:"event_id"`
|
||
Sequence uint64 `json:"sequence,string"`
|
||
Revision uint64 `json:"revision,string"`
|
||
State platform.DeliveryState `json:"state"`
|
||
AvailableAt time.Time `json:"available_at"`
|
||
LeaseEpoch uint64 `json:"lease_epoch,string"`
|
||
LeaseUntil *time.Time `json:"lease_until,omitempty"`
|
||
Attempts uint32 `json:"attempts"`
|
||
AttemptBudget uint32 `json:"attempt_budget"`
|
||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||
}
|
||
|
||
// ClaimInput 由服务端已认证的连接生成,ConnectionID不得直接使用客户端自报值。
|
||
type ClaimInput struct {
|
||
ConnectionID core.ID
|
||
Capacity uint32
|
||
}
|
||
|
||
type Grant struct {
|
||
Delivery Delivery `json:"delivery"`
|
||
Event Event `json:"event"`
|
||
// LeaseSecret 仅交付给当前连接;持久层、日志和审计仅保存摘要。
|
||
LeaseSecret string `json:"lease_secret"`
|
||
}
|
||
|
||
type AckInput struct {
|
||
DeliveryID core.ID `json:"delivery_id"`
|
||
Epoch uint64 `json:"epoch,string"`
|
||
LeaseSecret string `json:"lease_secret"`
|
||
}
|
||
|
||
type AckResult struct {
|
||
DeliveryID core.ID `json:"delivery_id"`
|
||
EventID core.ID `json:"event_id"`
|
||
Epoch uint64 `json:"epoch,string"`
|
||
AcknowledgedAt time.Time `json:"acknowledged_at"`
|
||
Deduplicated bool `json:"deduplicated"`
|
||
}
|
||
|
||
type RetryInput struct {
|
||
ExpectedRevision uint64 `json:"expected_revision,string"`
|
||
Reason string `json:"reason"`
|
||
AdditionalAttempts uint32 `json:"additional_attempts"`
|
||
}
|
||
|
||
type RetryResult struct {
|
||
Delivery Delivery `json:"delivery"`
|
||
Deduplicated bool `json:"deduplicated"`
|
||
}
|
||
|
||
type Store interface {
|
||
AuthorizeEvents(context.Context, auth.Token) error
|
||
BindConsumer(context.Context, auth.Token, core.ID) (ConsumerBinding, error)
|
||
ListSubscriptions(context.Context, auth.Token, core.ID, PageInput) (SubscriptionPage, error)
|
||
ListDeliveries(context.Context, auth.Token, core.ID, PageInput) (DeliveryPage, error)
|
||
GetDeliveryDetail(context.Context, auth.Token, core.ID, core.ID) (DeliveryDetail, error)
|
||
CreateSubscription(context.Context, auth.Token, string, CreateInput) (SubscriptionResult, error)
|
||
GetSubscription(context.Context, auth.Token, core.ID) (Subscription, error)
|
||
ControlSubscription(context.Context, auth.Token, core.ID, string, ControlInput) (SubscriptionResult, error)
|
||
GetDelivery(context.Context, auth.Token, core.ID, core.ID) (Delivery, error)
|
||
ClaimDelivery(context.Context, auth.Token, core.ID, ClaimInput) (*Grant, error)
|
||
AckDelivery(context.Context, auth.Token, core.ID, AckInput) (AckResult, error)
|
||
RetryDelivery(context.Context, auth.Token, core.ID, core.ID, string, RetryInput) (RetryResult, error)
|
||
}
|
||
|
||
type Service struct{ store Store }
|
||
|
||
func New(s Store) *Service { return &Service{store: s} }
|
||
|
||
var validName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||
|
||
func keyValid(key string) error {
|
||
if len(key) < 1 || len(key) > 128 {
|
||
return fault.Invalid("invalid Idempotency-Key")
|
||
}
|
||
for _, c := range key {
|
||
if c < 33 || c > 126 {
|
||
return fault.Invalid("invalid Idempotency-Key")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
func reasonValid(revision uint64, reason string) bool {
|
||
return revision > 0 && utf8.ValidString(reason) && strings.TrimSpace(reason) != "" && utf8.RuneCountInString(reason) <= 1024
|
||
}
|
||
func (s *Service) Create(ctx context.Context, t auth.Token, key string, in CreateInput) (SubscriptionResult, error) {
|
||
if err := keyValid(key); err != nil {
|
||
return SubscriptionResult{}, err
|
||
}
|
||
if len(in.Name) > 128 || !validName.MatchString(in.Name) || in.QueueID.Validate() != nil {
|
||
return SubscriptionResult{}, fault.Invalid("invalid subscription name or queue_id")
|
||
}
|
||
if in.MaxPending == 0 {
|
||
in.MaxPending = 100
|
||
}
|
||
if in.MaxAttempts == 0 {
|
||
in.MaxAttempts = 20
|
||
}
|
||
if in.MaxPending > 1000 || in.MaxAttempts > 100 {
|
||
return SubscriptionResult{}, fault.Invalid("subscription limits are invalid")
|
||
}
|
||
if len(in.Types) == 0 {
|
||
in.Types = []string{"task.succeeded", "task.failed", "task.cancelled", "task.needs_review"}
|
||
}
|
||
if len(in.Types) > 4 {
|
||
return SubscriptionResult{}, fault.Invalid("too many event types")
|
||
}
|
||
in.Types = slices.Clone(in.Types)
|
||
slices.Sort(in.Types)
|
||
for i, v := range in.Types {
|
||
if !ResultType(v) || i > 0 && v == in.Types[i-1] {
|
||
return SubscriptionResult{}, fault.Invalid("unsupported or repeated event type")
|
||
}
|
||
}
|
||
return s.store.CreateSubscription(ctx, t, key, in)
|
||
}
|
||
func (s *Service) Get(ctx context.Context, t auth.Token, id core.ID) (Subscription, error) {
|
||
if id.Validate() != nil {
|
||
return Subscription{}, fault.Invalid("invalid subscription_id")
|
||
}
|
||
return s.store.GetSubscription(ctx, t, id)
|
||
}
|
||
func (s *Service) Control(ctx context.Context, t auth.Token, id core.ID, key string, in ControlInput) (SubscriptionResult, error) {
|
||
if err := keyValid(key); err != nil {
|
||
return SubscriptionResult{}, err
|
||
}
|
||
if id.Validate() != nil || !reasonValid(in.ExpectedRevision, in.Reason) {
|
||
return SubscriptionResult{}, fault.Invalid("subscription_id, revision and reason are required")
|
||
}
|
||
return s.store.ControlSubscription(ctx, t, id, key, in)
|
||
}
|
||
func (s *Service) Delivery(ctx context.Context, t auth.Token, id, delivery core.ID) (Delivery, error) {
|
||
if id.Validate() != nil || delivery.Validate() != nil {
|
||
return Delivery{}, fault.Invalid("invalid subscription_id or delivery_id")
|
||
}
|
||
return s.store.GetDelivery(ctx, t, id, delivery)
|
||
}
|
||
func (s *Service) Claim(ctx context.Context, t auth.Token, id core.ID, in ClaimInput) (*Grant, error) {
|
||
if id.Validate() != nil || in.ConnectionID.Validate() != nil || in.Capacity < 1 || in.Capacity > 32 {
|
||
return nil, fault.Invalid("invalid delivery claim")
|
||
}
|
||
return s.store.ClaimDelivery(ctx, t, id, in)
|
||
}
|
||
func (s *Service) Ack(ctx context.Context, t auth.Token, id core.ID, in AckInput) (AckResult, error) {
|
||
if id.Validate() != nil || in.DeliveryID.Validate() != nil || in.Epoch == 0 {
|
||
return AckResult{}, fault.Invalid("invalid delivery confirmation")
|
||
}
|
||
if _, err := SecretDigest(in.LeaseSecret); err != nil {
|
||
return AckResult{}, err
|
||
}
|
||
return s.store.AckDelivery(ctx, t, id, in)
|
||
}
|
||
func (s *Service) Retry(ctx context.Context, t auth.Token, id, delivery core.ID, key string, in RetryInput) (RetryResult, error) {
|
||
if err := keyValid(key); err != nil {
|
||
return RetryResult{}, err
|
||
}
|
||
if id.Validate() != nil || delivery.Validate() != nil || !reasonValid(in.ExpectedRevision, in.Reason) || in.AdditionalAttempts < 1 || in.AdditionalAttempts > 100 {
|
||
return RetryResult{}, fault.Invalid("invalid delivery retry")
|
||
}
|
||
return s.store.RetryDelivery(ctx, t, id, delivery, key, in)
|
||
}
|
||
|
||
func SecretDigest(secret string) (core.Digest, error) {
|
||
if len(secret) != 43 {
|
||
return core.Digest{}, fault.Invalid("invalid delivery secret")
|
||
}
|
||
raw, err := base64.RawURLEncoding.Strict().DecodeString(secret)
|
||
if err != nil || len(raw) != 32 {
|
||
return core.Digest{}, fault.Invalid("invalid delivery secret")
|
||
}
|
||
return core.Digest(sha256.Sum256(raw)), nil
|
||
}
|
||
func NewSecret() (string, core.Digest, error) {
|
||
var raw [32]byte
|
||
if _, err := rand.Read(raw[:]); err != nil {
|
||
return "", core.Digest{}, err
|
||
}
|
||
return base64.RawURLEncoding.EncodeToString(raw[:]), core.Digest(sha256.Sum256(raw[:])), nil
|
||
}
|