feat: add durable result journal and subscription delivery core

This commit is contained in:
2026-09-17 19:19:57 +08:00
parent 309ed75666
commit 1c3300cc5b
26 changed files with 2036 additions and 11 deletions

277
platform/events/service.go Normal file
View File

@@ -0,0 +1,277 @@
// 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 {
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 {
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
}

View File

@@ -0,0 +1,84 @@
package events
import (
"context"
"strings"
"testing"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
type recordingStore struct {
Store
input CreateInput
calls int
}
func (s *recordingStore) CreateSubscription(_ context.Context, _ auth.Token, _ string, in CreateInput) (SubscriptionResult, error) {
s.input = in
s.calls++
return SubscriptionResult{}, nil
}
func TestSubscriptionNormalizationAndValidation(t *testing.T) {
store := &recordingStore{}
svc := New(store)
good := CreateInput{Name: "object-results", QueueID: core.ID(strings.Repeat("1", 32))}
if _, err := svc.Create(context.Background(), auth.Token{}, "create", good); err != nil {
t.Fatal(err)
}
if store.input.MaxPending != 100 || store.input.MaxAttempts != 20 || len(store.input.Types) != 4 || store.input.FromSequence != nil {
t.Fatal("invalid defaults")
}
// 排序仅作用于内部副本,不修改调用方重报保存的原输入。
types := []string{"task.succeeded", "task.cancelled"}
good.Types = types
if _, err := svc.Create(context.Background(), auth.Token{}, "create", good); err != nil {
t.Fatal(err)
}
if types[0] != "task.succeeded" || store.input.Types[0] != "task.cancelled" {
t.Fatal("normalization modified caller")
}
cases := []func(*CreateInput){
func(in *CreateInput) { in.Name = " spaced " },
func(in *CreateInput) { in.QueueID = "missing" },
func(in *CreateInput) { in.MaxPending = 1001 },
func(in *CreateInput) { in.MaxAttempts = 101 },
func(in *CreateInput) { in.Types = []string{"task.accepted"} },
func(in *CreateInput) { in.Types = []string{"task.failed", "task.failed"} },
}
for i, mutate := range cases {
in := good
mutate(&in)
if _, err := svc.Create(context.Background(), auth.Token{}, "create", in); err == nil {
t.Fatalf("accepted invalid case %d", i)
}
}
for _, key := range []string{"", "with space", strings.Repeat("a", 129)} {
if _, err := svc.Create(context.Background(), auth.Token{}, key, good); err == nil {
t.Fatal("accepted invalid identity")
}
}
if store.calls != 2 {
t.Fatal("invalid input reached storage")
}
}
func TestDeliveryProofStrictness(t *testing.T) {
secret, digest, err := NewSecret()
if err != nil {
t.Fatal(err)
}
actual, err := SecretDigest(secret)
if err != nil || actual != digest {
t.Fatal("proof roundtrip", err)
}
for _, s := range []string{"", secret + "=", secret + "\n", secret[:42], strings.Repeat("!", 43)} {
if _, err := SecretDigest(s); err == nil {
t.Fatal("accepted noncanonical proof")
}
}
if ResultType("task.requeued") || ResultType("task.accepted") || !ResultType("task.needs_review") {
t.Fatal("invalid result event scope")
}
}