服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
641 lines
19 KiB
Go
641 lines
19 KiB
Go
package eventlog
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
// ErrStoreRequired 表示 eventlog 缺少底层 store。
|
|
ErrStoreRequired = errors.New("eventlog store is required")
|
|
|
|
// ErrPublisherRequired 表示发布流程缺少 Publisher。
|
|
ErrPublisherRequired = errors.New("eventlog publisher is required")
|
|
|
|
// ErrEventExists 表示事件 ID 已经存在。
|
|
ErrEventExists = errors.New("event already exists")
|
|
|
|
// ErrIdempotencyConflict 表示同一幂等键对应的事件内容不一致。
|
|
ErrIdempotencyConflict = errors.New("eventlog idempotency conflict")
|
|
|
|
// ErrEventNotFound 表示目标事件不存在。
|
|
ErrEventNotFound = errors.New("event not found")
|
|
|
|
// ErrInvalidEvent 表示事件字段或状态不满足 eventlog 约束。
|
|
ErrInvalidEvent = errors.New("event is invalid")
|
|
|
|
// ErrInvalidLimit 表示查询或发布批量限制非法。
|
|
ErrInvalidLimit = errors.New("eventlog limit is invalid")
|
|
|
|
// ErrClaimLost 表示当前 worker 已不再持有事件 claim。
|
|
ErrClaimLost = errors.New("eventlog claim is no longer owned")
|
|
|
|
// ErrListUnsupported 表示 store 不支持按事件流聚合查询。
|
|
ErrListUnsupported = errors.New("eventlog store does not support stream listing")
|
|
|
|
// ErrPublisherPanic 表示外部 Publisher 发布时发生 panic。
|
|
ErrPublisherPanic = errors.New("eventlog publisher panic")
|
|
)
|
|
|
|
const (
|
|
// StatusPending 表示事件等待发布。
|
|
StatusPending = "pending"
|
|
|
|
// StatusProcessing 表示事件已被 worker claim。
|
|
StatusProcessing = "processing"
|
|
|
|
// StatusFailed 表示事件发布失败并等待重试。
|
|
StatusFailed = "failed"
|
|
|
|
// StatusPublished 表示事件已经发布成功。
|
|
StatusPublished = "published"
|
|
|
|
// StatusDeadLetter 表示事件进入死信队列等待人工处理。
|
|
StatusDeadLetter = "dead_letter"
|
|
|
|
defaultPublishLimit = 100
|
|
defDeadLetterLimit = 100
|
|
defaultRetryDelay = time.Second
|
|
defaultClaimTimeout = 30 * time.Second
|
|
defPubStateTimeout = 5 * time.Second
|
|
)
|
|
|
|
// Event 是可靠事件日志中的一条可持久化 outbox 事件。
|
|
type Event struct {
|
|
ID string `json:"id"`
|
|
Stream string `json:"stream"`
|
|
Type string `json:"type"`
|
|
AggregateID string `json:"aggregate_id,omitempty"`
|
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
|
Payload json.RawMessage `json:"payload,omitempty"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
Status string `json:"status"`
|
|
Attempts int `json:"attempts,omitempty"`
|
|
AvailableAt time.Time `json:"available_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
PublishedAt time.Time `json:"published_at,omitempty"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
}
|
|
|
|
// Store 定义 eventlog 对持久化、claim、死信和快照能力的最小要求。
|
|
type Store interface {
|
|
Append(context.Context, Event) (Event, error)
|
|
Get(context.Context, string) (Event, bool, error)
|
|
GetByIdempotencyKey(context.Context, string) (Event, bool, error)
|
|
ClaimPending(context.Context, int, time.Time, time.Duration) ([]Event, error)
|
|
MarkPublished(context.Context, string, time.Time, time.Time) error
|
|
MarkFailed(context.Context, string, string, time.Time, time.Time, time.Time) error
|
|
MarkDeadLetter(context.Context, string, string, time.Time, time.Time) error
|
|
ListDeadLetters(context.Context, int) ([]Event, error)
|
|
RequeueDeadLetter(context.Context, string, time.Time, time.Time, bool) error
|
|
Snapshot(context.Context) (Snapshot, error)
|
|
}
|
|
|
|
// Publisher 是事件发布扩展点,负责把已 claim 的事件投递到下游。
|
|
type Publisher interface {
|
|
Publish(context.Context, Event) error
|
|
}
|
|
|
|
// PublisherFunc 允许用函数快速实现 Publisher。
|
|
type PublisherFunc func(context.Context, Event) error
|
|
|
|
// Publish 调用底层函数并保护 nil 函数场景。
|
|
func (fn PublisherFunc) Publish(ctx context.Context, event Event) error {
|
|
if fn == nil {
|
|
return ErrPublisherRequired
|
|
}
|
|
return fn(ctx, event)
|
|
}
|
|
|
|
// IDGenerator 为未显式指定 ID 的事件生成稳定 ID。
|
|
type IDGenerator func() string
|
|
|
|
// Options 描述 eventlog 实例的存储、时钟和 ID 生成策略。
|
|
type Options struct {
|
|
Name string
|
|
Store Store
|
|
Now func() time.Time
|
|
IDGenerator IDGenerator
|
|
}
|
|
|
|
// Log 提供事件追加、发布推进、死信处理和查询聚合能力。
|
|
type Log struct {
|
|
name string
|
|
store Store
|
|
now func() time.Time
|
|
gen IDGenerator
|
|
|
|
mu sync.Mutex
|
|
next uint64
|
|
}
|
|
|
|
// PublishOptions 控制一次待发布扫描的批量、重试和排除流策略。
|
|
type PublishOptions struct {
|
|
Limit int
|
|
RetryDelay time.Duration
|
|
ClaimTimeout time.Duration
|
|
MaxAttempts int
|
|
ExcludeStreams []string
|
|
}
|
|
|
|
// PublishStats 记录一次发布扫描的领取、成功、失败和死信数量。
|
|
type PublishStats struct {
|
|
Fetched int `json:"fetched"`
|
|
Published int `json:"published"`
|
|
Failed int `json:"failed"`
|
|
DeadLettered int `json:"dead_lettered,omitempty"`
|
|
}
|
|
|
|
type streamAggLister interface {
|
|
ListByStreamAggregate(context.Context, string, string, int) ([]Event, error)
|
|
}
|
|
|
|
// Snapshot 是 eventlog 当前堆积和各状态数量的观测结果。
|
|
type Snapshot struct {
|
|
Name string `json:"name,omitempty"`
|
|
Total int `json:"total"`
|
|
ByStatus map[string]int `json:"by_status,omitempty"`
|
|
OldestDue time.Time `json:"oldest_due,omitempty"`
|
|
}
|
|
|
|
// RequeueOptions 控制死信重新入队后的可用时间和尝试次数处理。
|
|
type RequeueOptions struct {
|
|
AvailableAt time.Time
|
|
PreserveAttempts bool
|
|
}
|
|
|
|
// New 创建 eventlog 实例;缺省名称、时钟和 ID 生成器会在内部补齐。
|
|
func New(options Options) *Log {
|
|
name := strings.TrimSpace(options.Name)
|
|
if name == "" {
|
|
name = "eventlog"
|
|
}
|
|
now := options.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
return &Log{
|
|
name: name,
|
|
store: options.Store,
|
|
now: now,
|
|
gen: options.IDGenerator,
|
|
}
|
|
}
|
|
|
|
// Append 校验并追加事件,幂等键相同且内容一致时返回既有事件。
|
|
func (l *Log) Append(ctx context.Context, event Event) (Event, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return Event{}, err
|
|
}
|
|
if l == nil || l.store == nil {
|
|
return Event{}, ErrStoreRequired
|
|
}
|
|
normalized, err := l.normalize(event)
|
|
if err != nil {
|
|
return Event{}, err
|
|
}
|
|
return l.store.Append(ctx, normalized)
|
|
}
|
|
|
|
// Close 关闭底层支持关闭的 store。
|
|
func (l *Log) Close() error {
|
|
if l == nil || l.store == nil {
|
|
return nil
|
|
}
|
|
if closer, ok := l.store.(interface{ Close() error }); ok {
|
|
return closer.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Get 按事件 ID 查询事件。
|
|
func (l *Log) Get(ctx context.Context, id string) (Event, bool, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return Event{}, false, err
|
|
}
|
|
if l == nil || l.store == nil {
|
|
return Event{}, false, ErrStoreRequired
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return Event{}, false, nil
|
|
}
|
|
return l.store.Get(ctx, id)
|
|
}
|
|
|
|
// GetByIdempotencyKey 按幂等键查询已存在的事件。
|
|
func (l *Log) GetByIdempotencyKey(ctx context.Context, key string) (Event, bool, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return Event{}, false, err
|
|
}
|
|
if l == nil || l.store == nil {
|
|
return Event{}, false, ErrStoreRequired
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
return Event{}, false, nil
|
|
}
|
|
return l.store.GetByIdempotencyKey(ctx, key)
|
|
}
|
|
|
|
// PublishPending 领取到期事件、调用 publisher,并按结果推进状态。
|
|
func (l *Log) PublishPending(ctx context.Context, publisher Publisher, options PublishOptions) (PublishStats, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return PublishStats{}, err
|
|
}
|
|
if l == nil || l.store == nil {
|
|
return PublishStats{}, ErrStoreRequired
|
|
}
|
|
if publisher == nil {
|
|
return PublishStats{}, ErrPublisherRequired
|
|
}
|
|
limit := options.Limit
|
|
if limit <= 0 {
|
|
limit = defaultPublishLimit
|
|
}
|
|
retryDelay := options.RetryDelay
|
|
if retryDelay <= 0 {
|
|
retryDelay = defaultRetryDelay
|
|
}
|
|
claimTimeout := options.ClaimTimeout
|
|
if claimTimeout <= 0 {
|
|
claimTimeout = defaultClaimTimeout
|
|
}
|
|
// PublishPending 先 claim 一批事件,再逐条发布并更新状态;claim lease 防止多个 worker 同时提交同一事件。
|
|
now := l.now().UTC()
|
|
events, err := l.claimPending(ctx, limit, now, claimTimeout, options.ExcludeStreams)
|
|
if err != nil {
|
|
return PublishStats{}, err
|
|
}
|
|
stats := PublishStats{Fetched: len(events)}
|
|
var errs []error
|
|
for _, event := range events {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return stats, err
|
|
}
|
|
if err := safePublish(ctx, publisher, event); err != nil {
|
|
stats.Failed++
|
|
if errors.Is(err, ErrPermanentPublishFailure) || (options.MaxAttempts > 0 && event.Attempts+1 >= options.MaxAttempts) {
|
|
stats.DeadLettered++
|
|
markCtx, cancel := publishStateContext(ctx)
|
|
markErr := l.store.MarkDeadLetter(markCtx, event.ID, err.Error(), now, event.AvailableAt)
|
|
cancel()
|
|
if markErr != nil {
|
|
errs = append(errs, fmt.Errorf("mark event %s dead letter: %w", event.ID, markErr))
|
|
continue
|
|
}
|
|
} else {
|
|
next := now.Add(retryDelay)
|
|
markCtx, cancel := publishStateContext(ctx)
|
|
markErr := l.store.MarkFailed(markCtx, event.ID, err.Error(), next, now, event.AvailableAt)
|
|
cancel()
|
|
if markErr != nil {
|
|
errs = append(errs, fmt.Errorf("mark event %s failed: %w", event.ID, markErr))
|
|
continue
|
|
}
|
|
}
|
|
errs = append(errs, fmt.Errorf("publish event %s: %w", event.ID, err))
|
|
continue
|
|
}
|
|
markCtx, cancel := publishStateContext(ctx)
|
|
err := l.store.MarkPublished(markCtx, event.ID, now, event.AvailableAt)
|
|
cancel()
|
|
if err != nil {
|
|
stats.Failed++
|
|
errs = append(errs, fmt.Errorf("mark event %s published: %w", event.ID, err))
|
|
continue
|
|
}
|
|
stats.Published++
|
|
}
|
|
return stats, errors.Join(errs...)
|
|
}
|
|
|
|
func safePublish(ctx context.Context, publisher Publisher, event Event) (err error) {
|
|
// publisher 是外部扩展点,panic 必须转换成发布失败,避免 worker 被第三方代码打穿。
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
err = fmt.Errorf("%w: %v", ErrPublisherPanic, recovered)
|
|
}
|
|
}()
|
|
return publisher.Publish(ctx, event)
|
|
}
|
|
|
|
func publishStateContext(parent context.Context) (context.Context, context.CancelFunc) {
|
|
// 发布后状态写入不能继承调用方取消;否则下游已收到事件时,本地 outbox 仍可能停在 processing 并重复投递。
|
|
base := context.Background()
|
|
if parent != nil {
|
|
base = context.WithoutCancel(parent)
|
|
}
|
|
return context.WithTimeout(base, defPubStateTimeout)
|
|
}
|
|
|
|
type streamExcludingStore interface {
|
|
ClaimPendingExcept(context.Context, int, time.Time, time.Duration, []string) ([]Event, error)
|
|
}
|
|
|
|
func (l *Log) claimPending(ctx context.Context, limit int, now time.Time, claimTimeout time.Duration, excludeStreams []string) ([]Event, error) {
|
|
excludeStreams = normalizeStreamList(excludeStreams)
|
|
if len(excludeStreams) == 0 {
|
|
return l.store.ClaimPending(ctx, limit, now, claimTimeout)
|
|
}
|
|
store, ok := l.store.(streamExcludingStore)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: store does not support excluded streams", ErrInvalidEvent)
|
|
}
|
|
return store.ClaimPendingExcept(ctx, limit, now, claimTimeout, excludeStreams)
|
|
}
|
|
|
|
func normalizeStreamList(values []string) []string {
|
|
seen := make(map[string]struct{}, len(values))
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Snapshot 返回底层 store 的状态快照并补齐日志名称。
|
|
func (l *Log) Snapshot(ctx context.Context) (Snapshot, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
if l == nil || l.store == nil {
|
|
return Snapshot{}, ErrStoreRequired
|
|
}
|
|
snapshot, err := l.store.Snapshot(ctx)
|
|
if err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
if snapshot.Name == "" {
|
|
snapshot.Name = l.name
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
// ListByStreamAggregate 查询指定事件流和聚合 ID 的最近事件。
|
|
func (l *Log) ListByStreamAggregate(ctx context.Context, stream, aggregateID string, limit int) ([]Event, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if l == nil || l.store == nil {
|
|
return nil, ErrStoreRequired
|
|
}
|
|
stream = strings.TrimSpace(stream)
|
|
aggregateID = strings.TrimSpace(aggregateID)
|
|
if stream == "" {
|
|
return nil, fmt.Errorf("%w: stream is required", ErrInvalidEvent)
|
|
}
|
|
if limit < 0 {
|
|
return nil, ErrInvalidLimit
|
|
}
|
|
if limit == 0 {
|
|
limit = defaultPublishLimit
|
|
}
|
|
lister, ok := l.store.(streamAggLister)
|
|
if !ok {
|
|
return nil, ErrListUnsupported
|
|
}
|
|
return lister.ListByStreamAggregate(ctx, stream, aggregateID, limit)
|
|
}
|
|
|
|
// DeadLetters 查询当前死信队列中的事件。
|
|
func (l *Log) DeadLetters(ctx context.Context, limit int) ([]Event, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if l == nil || l.store == nil {
|
|
return nil, ErrStoreRequired
|
|
}
|
|
if limit < 0 {
|
|
return nil, ErrInvalidLimit
|
|
}
|
|
if limit == 0 {
|
|
limit = defDeadLetterLimit
|
|
}
|
|
return l.store.ListDeadLetters(ctx, limit)
|
|
}
|
|
|
|
// RequeueDeadLetter 把死信事件重新置为待发布状态。
|
|
func (l *Log) RequeueDeadLetter(ctx context.Context, id string, options RequeueOptions) (Event, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return Event{}, err
|
|
}
|
|
if l == nil || l.store == nil {
|
|
return Event{}, ErrStoreRequired
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return Event{}, ErrEventNotFound
|
|
}
|
|
now := l.now().UTC()
|
|
availableAt := options.AvailableAt
|
|
if availableAt.IsZero() {
|
|
availableAt = now
|
|
} else {
|
|
availableAt = availableAt.UTC()
|
|
}
|
|
if err := l.store.RequeueDeadLetter(ctx, id, availableAt, now, options.PreserveAttempts); err != nil {
|
|
return Event{}, err
|
|
}
|
|
event, ok, err := l.store.Get(ctx, id)
|
|
if err != nil {
|
|
return Event{}, err
|
|
}
|
|
if !ok {
|
|
return Event{}, ErrEventNotFound
|
|
}
|
|
return event, nil
|
|
}
|
|
|
|
// MarkPublished 手动把事件标记为已发布,主要用于管理修复和测试夹具。
|
|
func (l *Log) MarkPublished(ctx context.Context, id string) (Event, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return Event{}, err
|
|
}
|
|
if l == nil || l.store == nil {
|
|
return Event{}, ErrStoreRequired
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return Event{}, ErrEventNotFound
|
|
}
|
|
now := l.now().UTC()
|
|
if err := l.store.MarkPublished(ctx, id, now, time.Time{}); err != nil {
|
|
return Event{}, err
|
|
}
|
|
event, ok, err := l.store.Get(ctx, id)
|
|
if err != nil {
|
|
return Event{}, err
|
|
}
|
|
if !ok {
|
|
return Event{}, ErrEventNotFound
|
|
}
|
|
return event, nil
|
|
}
|
|
|
|
// MarkFailed 手动把事件标记为失败并设置下一次可发布时间。
|
|
func (l *Log) MarkFailed(ctx context.Context, id string, message string, nextAttempt time.Time) (Event, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return Event{}, err
|
|
}
|
|
if l == nil || l.store == nil {
|
|
return Event{}, ErrStoreRequired
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return Event{}, ErrEventNotFound
|
|
}
|
|
now := l.now().UTC()
|
|
if nextAttempt.IsZero() {
|
|
nextAttempt = now
|
|
} else {
|
|
nextAttempt = nextAttempt.UTC()
|
|
}
|
|
if err := l.store.MarkFailed(ctx, id, message, nextAttempt, now, time.Time{}); err != nil {
|
|
return Event{}, err
|
|
}
|
|
event, ok, err := l.store.Get(ctx, id)
|
|
if err != nil {
|
|
return Event{}, err
|
|
}
|
|
if !ok {
|
|
return Event{}, ErrEventNotFound
|
|
}
|
|
return event, nil
|
|
}
|
|
|
|
func (l *Log) normalize(event Event) (Event, error) {
|
|
event.ID = strings.TrimSpace(event.ID)
|
|
event.Stream = strings.TrimSpace(event.Stream)
|
|
event.Type = strings.TrimSpace(event.Type)
|
|
event.AggregateID = strings.TrimSpace(event.AggregateID)
|
|
event.IdempotencyKey = strings.TrimSpace(event.IdempotencyKey)
|
|
event.Status = strings.TrimSpace(event.Status)
|
|
if event.Stream == "" {
|
|
return Event{}, fmt.Errorf("%w: stream is required", ErrInvalidEvent)
|
|
}
|
|
if event.Type == "" {
|
|
return Event{}, fmt.Errorf("%w: type is required", ErrInvalidEvent)
|
|
}
|
|
if event.ID == "" {
|
|
event.ID = l.nextID()
|
|
}
|
|
if event.Status == "" {
|
|
event.Status = StatusPending
|
|
}
|
|
if event.Status != StatusPending && event.Status != StatusProcessing && event.Status != StatusFailed && event.Status != StatusPublished && event.Status != StatusDeadLetter {
|
|
return Event{}, fmt.Errorf("%w: unsupported status %q", ErrInvalidEvent, event.Status)
|
|
}
|
|
if len(event.Payload) == 0 {
|
|
event.Payload = json.RawMessage("null")
|
|
}
|
|
if !json.Valid(event.Payload) {
|
|
return Event{}, fmt.Errorf("%w: payload must be valid json", ErrInvalidEvent)
|
|
}
|
|
now := l.now().UTC()
|
|
if event.CreatedAt.IsZero() {
|
|
event.CreatedAt = now
|
|
} else {
|
|
event.CreatedAt = event.CreatedAt.UTC()
|
|
}
|
|
if event.UpdatedAt.IsZero() {
|
|
event.UpdatedAt = event.CreatedAt
|
|
} else {
|
|
event.UpdatedAt = event.UpdatedAt.UTC()
|
|
}
|
|
if event.AvailableAt.IsZero() {
|
|
event.AvailableAt = event.CreatedAt
|
|
} else {
|
|
event.AvailableAt = event.AvailableAt.UTC()
|
|
}
|
|
if !event.PublishedAt.IsZero() {
|
|
event.PublishedAt = event.PublishedAt.UTC()
|
|
}
|
|
if event.Attempts < 0 {
|
|
event.Attempts = 0
|
|
}
|
|
event.Headers = cloneStringMap(event.Headers)
|
|
event.Payload = cloneRawMessage(event.Payload)
|
|
return event, nil
|
|
}
|
|
|
|
func (l *Log) nextID() string {
|
|
if l != nil && l.gen != nil {
|
|
if id := strings.TrimSpace(l.gen()); id != "" {
|
|
return id
|
|
}
|
|
}
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.next++
|
|
return fmt.Sprintf("event-%d", l.next)
|
|
}
|
|
|
|
func cloneEvent(event Event) Event {
|
|
event.Payload = cloneRawMessage(event.Payload)
|
|
event.Headers = cloneStringMap(event.Headers)
|
|
return event
|
|
}
|
|
|
|
func sameIdempotentEvent(left Event, right Event) bool {
|
|
return left.Stream == right.Stream &&
|
|
left.Type == right.Type &&
|
|
left.AggregateID == right.AggregateID &&
|
|
left.IdempotencyKey == right.IdempotencyKey &&
|
|
bytes.Equal(left.Payload, right.Payload) &&
|
|
reflect.DeepEqual(left.Headers, right.Headers)
|
|
}
|
|
|
|
func cloneRawMessage(data json.RawMessage) json.RawMessage {
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
return append(json.RawMessage(nil), data...)
|
|
}
|
|
|
|
func cloneStringMap(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for key, value := range in {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isDueStatus(status string) bool {
|
|
return status == StatusPending || status == StatusFailed || status == StatusProcessing
|
|
}
|
|
|
|
func ownsClaim(event Event, claimDeadline time.Time) bool {
|
|
// MarkPublished/MarkFailed 必须确认仍持有本次 claim,过期后可能已被其他 worker 重新领取。
|
|
if claimDeadline.IsZero() {
|
|
return true
|
|
}
|
|
return event.Status == StatusProcessing && event.AvailableAt.Equal(claimDeadline.UTC())
|
|
}
|
|
|
|
func ctxErr(ctx context.Context) error {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
return ctx.Err()
|
|
}
|