DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,161 @@
|
||||
package eventlog
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/platform/httpx"
|
||||
)
|
||||
|
||||
// ErrAdminMuxRequired 表示管理路由注册缺少 HTTP mux。
|
||||
var ErrAdminMuxRequired = errors.New("eventlog admin mux is required")
|
||||
|
||||
// AdminOptions 描述 eventlog 管理接口的挂载前缀和鉴权包装器。
|
||||
type AdminOptions struct {
|
||||
Prefix string
|
||||
Wrap func(http.HandlerFunc) http.HandlerFunc
|
||||
}
|
||||
|
||||
// RegisterAdminRoutes 把快照、死信查询和死信重投接口注册到管理 mux。
|
||||
func RegisterAdminRoutes(mux *http.ServeMux, log *Log, options AdminOptions) error {
|
||||
if mux == nil {
|
||||
return ErrAdminMuxRequired
|
||||
}
|
||||
prefix := normalizeAdminPrefix(options.Prefix)
|
||||
wrap := options.Wrap
|
||||
if wrap == nil {
|
||||
wrap = func(next http.HandlerFunc) http.HandlerFunc { return next }
|
||||
}
|
||||
handler := wrap(SnapshotHandler(log))
|
||||
mux.HandleFunc(prefix, handler)
|
||||
mux.HandleFunc(prefix+"/snapshot", handler)
|
||||
mux.HandleFunc(prefix+"/deadletters", wrap(DeadLettersHandler(log)))
|
||||
mux.HandleFunc(prefix+"/deadletters/requeue", wrap(RequeueDeadLetterHandler(log)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SnapshotHandler 返回 eventlog 当前状态快照的只读管理接口。
|
||||
func SnapshotHandler(log *Log) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
if log == nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": ErrStoreRequired.Error()})
|
||||
return
|
||||
}
|
||||
snapshot, err := log.Snapshot(r.Context())
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
// DeadLettersHandler 返回死信事件列表的只读管理接口。
|
||||
func DeadLettersHandler(log *Log) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
if log == nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": ErrStoreRequired.Error()})
|
||||
return
|
||||
}
|
||||
limit, err := parsePositiveLimit(r.URL.Query().Get("limit"))
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
events, err := log.DeadLetters(r.Context(), limit)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"dead_letters": events})
|
||||
}
|
||||
}
|
||||
|
||||
// RequeueDeadLetterHandler 返回死信事件重新入队的管理接口。
|
||||
func RequeueDeadLetterHandler(log *Log) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
if log == nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": ErrStoreRequired.Error()})
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(r.URL.Query().Get("event_id"))
|
||||
}
|
||||
if id == "" {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
preserveAttempts, err := parseOptionalBool(r.URL.Query().Get("preserve_attempts"))
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
event, err := log.RequeueDeadLetter(r.Context(), id, RequeueOptions{PreserveAttempts: preserveAttempts})
|
||||
if err != nil {
|
||||
status := http.StatusServiceUnavailable
|
||||
if errors.Is(err, ErrEventNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"status": "requeued", "event": event})
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAdminPrefix(prefix string) string {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return "/eventlog"
|
||||
}
|
||||
if !strings.HasPrefix(prefix, "/") {
|
||||
prefix = "/" + prefix
|
||||
}
|
||||
prefix = strings.TrimRight(prefix, "/")
|
||||
if prefix == "" {
|
||||
return "/eventlog"
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
func parsePositiveLimit(raw string) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
limit, err := strconv.Atoi(raw)
|
||||
if err != nil || limit < 0 {
|
||||
return 0, ErrInvalidLimit
|
||||
}
|
||||
return limit, nil
|
||||
}
|
||||
|
||||
func parseOptionalBool(raw string) (bool, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return false, nil
|
||||
}
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package eventlog 记录可持久化的业务事件,并将它们重放给下游投影。
|
||||
//
|
||||
// EventLog 是幂等命令、读模型重建、审计和 runtime 集成的恢复主线。调用方应把
|
||||
// append 成功视为命令对系统其他部分可见的边界。
|
||||
package eventlog
|
||||
@@ -0,0 +1,640 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package eventlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MemoryStoreMaxEvents 是内存事件日志默认保留的最大事件数。
|
||||
const MemoryStoreMaxEvents = 10000
|
||||
|
||||
// MemoryStore 是用于本地开发、测试和轻量部署的内存 eventlog store。
|
||||
type MemoryStore struct {
|
||||
name string
|
||||
|
||||
mu sync.Mutex
|
||||
events map[string]Event
|
||||
order []string
|
||||
idempotency map[string]string
|
||||
maxEvents int
|
||||
}
|
||||
|
||||
// NewMemoryStore 使用默认容量创建内存事件日志 store。
|
||||
func NewMemoryStore(name string) *MemoryStore {
|
||||
return NewMemoryStoreWithLimit(name, MemoryStoreMaxEvents)
|
||||
}
|
||||
|
||||
// NewMemoryStoreWithLimit 使用指定容量创建内存事件日志 store。
|
||||
func NewMemoryStoreWithLimit(name string, maxEvents int) *MemoryStore {
|
||||
if maxEvents <= 0 {
|
||||
maxEvents = MemoryStoreMaxEvents
|
||||
}
|
||||
return &MemoryStore{
|
||||
name: name,
|
||||
events: make(map[string]Event),
|
||||
idempotency: make(map[string]string),
|
||||
maxEvents: maxEvents,
|
||||
}
|
||||
}
|
||||
|
||||
// Append 写入事件,并对相同幂等键的重复写入返回既有事件。
|
||||
func (s *MemoryStore) Append(ctx context.Context, event Event) (Event, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
if s == nil {
|
||||
return Event{}, ErrStoreRequired
|
||||
}
|
||||
event = cloneEvent(event)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ensureReadyLocked()
|
||||
if event.IdempotencyKey != "" {
|
||||
if id, ok := s.idempotency[event.IdempotencyKey]; ok {
|
||||
existing, exists := s.events[id]
|
||||
if !exists {
|
||||
delete(s.idempotency, event.IdempotencyKey)
|
||||
} else {
|
||||
if !sameIdempotentEvent(existing, event) {
|
||||
return Event{}, ErrIdempotencyConflict
|
||||
}
|
||||
return cloneEvent(existing), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, exists := s.events[event.ID]; exists {
|
||||
return Event{}, ErrEventExists
|
||||
}
|
||||
s.events[event.ID] = event
|
||||
s.order = append(s.order, event.ID)
|
||||
if event.IdempotencyKey != "" {
|
||||
s.idempotency[event.IdempotencyKey] = event.ID
|
||||
}
|
||||
s.evictOverflowLocked()
|
||||
return cloneEvent(event), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ensureReadyLocked() {
|
||||
if s.events == nil {
|
||||
s.events = make(map[string]Event)
|
||||
}
|
||||
if s.idempotency == nil {
|
||||
s.idempotency = make(map[string]string)
|
||||
}
|
||||
if s.maxEvents <= 0 {
|
||||
s.maxEvents = MemoryStoreMaxEvents
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) evictOverflowLocked() {
|
||||
for s.maxEvents > 0 && len(s.order) > s.maxEvents {
|
||||
id := s.order[0]
|
||||
s.order = s.order[1:]
|
||||
event, ok := s.events[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(s.events, id)
|
||||
if event.IdempotencyKey != "" {
|
||||
delete(s.idempotency, event.IdempotencyKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get 按事件 ID 读取事件副本。
|
||||
func (s *MemoryStore) Get(ctx context.Context, id string) (Event, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Event{}, false, err
|
||||
}
|
||||
if s == nil {
|
||||
return Event{}, false, ErrStoreRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
event, ok := s.events[id]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return Event{}, false, nil
|
||||
}
|
||||
return cloneEvent(event), true, nil
|
||||
}
|
||||
|
||||
// GetByIdempotencyKey 按幂等键读取事件副本。
|
||||
func (s *MemoryStore) GetByIdempotencyKey(ctx context.Context, key string) (Event, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Event{}, false, err
|
||||
}
|
||||
if s == nil {
|
||||
return Event{}, false, ErrStoreRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
id, ok := s.idempotency[key]
|
||||
if ok {
|
||||
event := cloneEvent(s.events[id])
|
||||
s.mu.Unlock()
|
||||
return event, true, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return Event{}, false, nil
|
||||
}
|
||||
|
||||
// ListByStreamAggregate 按事件流和聚合 ID 倒序读取事件。
|
||||
func (s *MemoryStore) ListByStreamAggregate(ctx context.Context, stream, aggregateID string, limit int) ([]Event, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil {
|
||||
return nil, ErrStoreRequired
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultPublishLimit
|
||||
}
|
||||
s.mu.Lock()
|
||||
out := make([]Event, 0, limit)
|
||||
for i := len(s.order) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
event := s.events[s.order[i]]
|
||||
if event.Stream != stream || event.AggregateID != aggregateID {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneEvent(event))
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ClaimPending 领取一批到期事件用于发布。
|
||||
func (s *MemoryStore) ClaimPending(ctx context.Context, limit int, now time.Time, claimTimeout time.Duration) ([]Event, error) {
|
||||
return s.claimPending(ctx, limit, now, claimTimeout, nil)
|
||||
}
|
||||
|
||||
// ClaimPendingExcept 领取到期事件时排除指定事件流。
|
||||
func (s *MemoryStore) ClaimPendingExcept(ctx context.Context, limit int, now time.Time, claimTimeout time.Duration, excludeStreams []string) ([]Event, error) {
|
||||
excluded := make(map[string]struct{}, len(excludeStreams))
|
||||
for _, stream := range normalizeStreamList(excludeStreams) {
|
||||
excluded[stream] = struct{}{}
|
||||
}
|
||||
return s.claimPending(ctx, limit, now, claimTimeout, excluded)
|
||||
}
|
||||
|
||||
func (s *MemoryStore) claimPending(ctx context.Context, limit int, now time.Time, claimTimeout time.Duration, excluded map[string]struct{}) ([]Event, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil {
|
||||
return nil, ErrStoreRequired
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultPublishLimit
|
||||
}
|
||||
now = now.UTC()
|
||||
if claimTimeout <= 0 {
|
||||
claimTimeout = defaultClaimTimeout
|
||||
}
|
||||
claimUntil := now.Add(claimTimeout)
|
||||
// 内存 store 用 AvailableAt 临时保存 claim 截止时间;worker 更新状态时必须带回同一个 deadline。
|
||||
s.mu.Lock()
|
||||
out := make([]Event, 0, limit)
|
||||
for _, id := range s.order {
|
||||
event := s.events[id]
|
||||
if event.Status != StatusPending && event.Status != StatusFailed && event.Status != StatusProcessing {
|
||||
continue
|
||||
}
|
||||
if _, skip := excluded[event.Stream]; skip {
|
||||
continue
|
||||
}
|
||||
if event.AvailableAt.After(now) {
|
||||
continue
|
||||
}
|
||||
event.Status = StatusProcessing
|
||||
event.AvailableAt = claimUntil
|
||||
event.UpdatedAt = now
|
||||
s.events[id] = event
|
||||
out = append(out, cloneEvent(event))
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkPublished 将当前 claim 持有的事件推进为已发布。
|
||||
func (s *MemoryStore) MarkPublished(ctx context.Context, id string, publishedAt time.Time, claimDeadline time.Time) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil {
|
||||
return ErrStoreRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
event, ok := s.events[id]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return ErrEventNotFound
|
||||
}
|
||||
if !ownsClaim(event, claimDeadline) {
|
||||
s.mu.Unlock()
|
||||
return ErrClaimLost
|
||||
}
|
||||
publishedAt = publishedAt.UTC()
|
||||
// Published 是终态,只有当前 claim 持有者能写入,避免过期 worker 覆盖新 worker 的结果。
|
||||
event.Status = StatusPublished
|
||||
event.PublishedAt = publishedAt
|
||||
event.UpdatedAt = publishedAt
|
||||
event.LastError = ""
|
||||
s.events[id] = event
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkFailed 将当前 claim 持有的事件推进为失败并设置下次重试时间。
|
||||
func (s *MemoryStore) MarkFailed(ctx context.Context, id string, message string, nextAttempt time.Time, updatedAt time.Time, claimDeadline time.Time) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil {
|
||||
return ErrStoreRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
event, ok := s.events[id]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return ErrEventNotFound
|
||||
}
|
||||
if !ownsClaim(event, claimDeadline) {
|
||||
s.mu.Unlock()
|
||||
return ErrClaimLost
|
||||
}
|
||||
// MarkFailed 会把 AvailableAt 改成下一次可重试时间;超过最大次数后由上层转 dead_letter。
|
||||
event.Status = StatusFailed
|
||||
event.Attempts++
|
||||
event.LastError = message
|
||||
event.AvailableAt = nextAttempt.UTC()
|
||||
event.UpdatedAt = updatedAt.UTC()
|
||||
s.events[id] = event
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkDeadLetter 将当前 claim 持有的事件推进为死信。
|
||||
func (s *MemoryStore) MarkDeadLetter(ctx context.Context, id string, message string, updatedAt time.Time, claimDeadline time.Time) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil {
|
||||
return ErrStoreRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
event, ok := s.events[id]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return ErrEventNotFound
|
||||
}
|
||||
if !ownsClaim(event, claimDeadline) {
|
||||
s.mu.Unlock()
|
||||
return ErrClaimLost
|
||||
}
|
||||
updatedAt = updatedAt.UTC()
|
||||
// DeadLetter 是人工介入边界,事件保留在 store 中,等待 requeue 或审计排查。
|
||||
event.Status = StatusDeadLetter
|
||||
event.Attempts++
|
||||
event.LastError = message
|
||||
event.AvailableAt = updatedAt
|
||||
event.UpdatedAt = updatedAt
|
||||
s.events[id] = event
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDeadLetters 返回当前死信事件列表。
|
||||
func (s *MemoryStore) ListDeadLetters(ctx context.Context, limit int) ([]Event, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil {
|
||||
return nil, ErrStoreRequired
|
||||
}
|
||||
if limit < 0 {
|
||||
return nil, ErrInvalidLimit
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = defDeadLetterLimit
|
||||
}
|
||||
s.mu.Lock()
|
||||
out := make([]Event, 0, limit)
|
||||
for _, id := range s.order {
|
||||
event := s.events[id]
|
||||
if event.Status != StatusDeadLetter {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneEvent(event))
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RequeueDeadLetter 把死信事件重新放回待发布队列。
|
||||
func (s *MemoryStore) RequeueDeadLetter(ctx context.Context, id string, availableAt time.Time, updatedAt time.Time, preserveAttempts bool) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil {
|
||||
return ErrStoreRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
event, ok := s.events[id]
|
||||
if !ok || event.Status != StatusDeadLetter {
|
||||
s.mu.Unlock()
|
||||
return ErrEventNotFound
|
||||
}
|
||||
event.Status = StatusPending
|
||||
event.AvailableAt = availableAt.UTC()
|
||||
event.UpdatedAt = updatedAt.UTC()
|
||||
event.PublishedAt = time.Time{}
|
||||
event.LastError = ""
|
||||
if !preserveAttempts {
|
||||
event.Attempts = 0
|
||||
}
|
||||
s.events[id] = event
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot 汇总内存 store 当前各状态数量和最早到期时间。
|
||||
func (s *MemoryStore) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if s == nil {
|
||||
return Snapshot{}, ErrStoreRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
snapshot := Snapshot{
|
||||
Name: s.name,
|
||||
Total: len(s.events),
|
||||
ByStatus: make(map[string]int),
|
||||
}
|
||||
for _, event := range s.events {
|
||||
snapshot.ByStatus[event.Status]++
|
||||
if isDueStatus(event.Status) && (snapshot.OldestDue.IsZero() || event.AvailableAt.Before(snapshot.OldestDue)) {
|
||||
snapshot.OldestDue = event.AvailableAt
|
||||
}
|
||||
}
|
||||
if len(snapshot.ByStatus) == 0 {
|
||||
snapshot.ByStatus = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return snapshot, nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package eventlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/metrics"
|
||||
)
|
||||
|
||||
const (
|
||||
// MetricEvents 记录 eventlog 各状态事件数量。
|
||||
MetricEvents = "eventlog_events"
|
||||
|
||||
// MetricOldestDueUnixSeconds 记录最早到期待发布事件的 Unix 时间。
|
||||
MetricOldestDueUnixSeconds = "eventlog_oldest_due_unix_seconds"
|
||||
|
||||
// MetricPublishLagSeconds 记录待发布事件相对当前时间的滞后秒数。
|
||||
MetricPublishLagSeconds = "eventlog_publish_lag_seconds"
|
||||
|
||||
// MetricPublishEventsTotal 记录发布结果分类累计数。
|
||||
MetricPublishEventsTotal = "eventlog_publish_events_total"
|
||||
|
||||
// MetricPublishBatchesTotal 记录发布扫描批次累计数。
|
||||
MetricPublishBatchesTotal = "eventlog_publish_batches_total"
|
||||
|
||||
// MetricPublishBatchSize 记录最近一次发布扫描领取的事件数。
|
||||
MetricPublishBatchSize = "eventlog_publish_batch_size"
|
||||
|
||||
// MetricPublishLastRunUnixTime 记录最近一次发布扫描运行时间。
|
||||
MetricPublishLastRunUnixTime = "eventlog_publish_last_run_unix_seconds"
|
||||
)
|
||||
|
||||
// ObserveSnapshot 读取快照并写入指标注册表。
|
||||
func (l *Log) ObserveSnapshot(ctx context.Context, registry *metrics.Registry) error {
|
||||
if l == nil {
|
||||
return ErrStoreRequired
|
||||
}
|
||||
snapshot, err := l.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
RecordSnapshotMetrics(registry, snapshot)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordSnapshotMetrics 把快照中的状态数量和最早到期时间写入指标。
|
||||
func RecordSnapshotMetrics(registry *metrics.Registry, snapshot Snapshot) {
|
||||
recordSnapMetrics(registry, snapshot, time.Now().UTC())
|
||||
}
|
||||
|
||||
func recordSnapMetrics(registry *metrics.Registry, snapshot Snapshot, now time.Time) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
logName := snapshot.Name
|
||||
if logName == "" {
|
||||
logName = "eventlog"
|
||||
}
|
||||
statuses := map[string]int{
|
||||
StatusPending: 0,
|
||||
StatusProcessing: 0,
|
||||
StatusFailed: 0,
|
||||
StatusPublished: 0,
|
||||
StatusDeadLetter: 0,
|
||||
}
|
||||
for status, count := range snapshot.ByStatus {
|
||||
statuses[status] = count
|
||||
}
|
||||
ordered := make([]string, 0, len(statuses))
|
||||
for status := range statuses {
|
||||
ordered = append(ordered, status)
|
||||
}
|
||||
sort.Strings(ordered)
|
||||
for _, status := range ordered {
|
||||
registry.SetGauge(MetricEvents, map[string]string{"log": logName, "status": status}, int64(statuses[status]))
|
||||
}
|
||||
registry.SetGauge(MetricOldestDueUnixSeconds, map[string]string{"log": logName}, unixOrZero(snapshot.OldestDue))
|
||||
registry.SetGauge(MetricPublishLagSeconds, map[string]string{"log": logName}, publishLagSeconds(now, snapshot.OldestDue))
|
||||
}
|
||||
|
||||
// RecordPublishMetrics 记录一次发布扫描的批量、结果和运行时间指标。
|
||||
func RecordPublishMetrics(registry *metrics.Registry, logName string, stats PublishStats, ranAt time.Time) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
if logName == "" {
|
||||
logName = "eventlog"
|
||||
}
|
||||
labels := map[string]string{"log": logName}
|
||||
registry.Inc(MetricPublishBatchesTotal, labels)
|
||||
registry.SetGauge(MetricPublishBatchSize, labels, int64(stats.Fetched))
|
||||
registry.SetGauge(MetricPublishLastRunUnixTime, labels, unixOrZero(ranAt))
|
||||
if stats.Published > 0 {
|
||||
registry.Add(MetricPublishEventsTotal, map[string]string{"log": logName, "result": "published"}, int64(stats.Published))
|
||||
}
|
||||
failed := stats.Failed - stats.DeadLettered
|
||||
if failed < 0 {
|
||||
failed = 0
|
||||
}
|
||||
if failed > 0 {
|
||||
registry.Add(MetricPublishEventsTotal, map[string]string{"log": logName, "result": "failed"}, int64(failed))
|
||||
}
|
||||
if stats.DeadLettered > 0 {
|
||||
registry.Add(MetricPublishEventsTotal, map[string]string{"log": logName, "result": "dead_lettered"}, int64(stats.DeadLettered))
|
||||
}
|
||||
skipped := stats.Fetched - stats.Published - stats.Failed
|
||||
if skipped > 0 {
|
||||
registry.Add(MetricPublishEventsTotal, map[string]string{"log": logName, "result": "skipped"}, int64(skipped))
|
||||
}
|
||||
}
|
||||
|
||||
func unixOrZero(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.UTC().Unix()
|
||||
}
|
||||
|
||||
func publishLagSeconds(now, oldestDue time.Time) int64 {
|
||||
if oldestDue.IsZero() {
|
||||
return 0
|
||||
}
|
||||
lag := now.UTC().Sub(oldestDue.UTC())
|
||||
if lag <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(lag / time.Second)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package eventlog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/bus"
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidPublisherConfig 表示发布器配置缺少必要字段。
|
||||
ErrInvalidPublisherConfig = errors.New("eventlog publisher config is invalid")
|
||||
|
||||
// ErrPermanentPublishFailure 表示发布失败不可重试,应直接进入死信。
|
||||
ErrPermanentPublishFailure = errors.New("eventlog publisher failure is permanent")
|
||||
)
|
||||
|
||||
const (
|
||||
// HeaderOriginalTopic 表示 outbox 事件需要恢复投递到的原始业务 topic。
|
||||
HeaderOriginalTopic = "original_topic"
|
||||
)
|
||||
|
||||
// TopicFunc 根据事件计算下游 topic。
|
||||
type TopicFunc func(Event) string
|
||||
|
||||
// PayloadFunc 根据事件构造 Bus 发布负载。
|
||||
type PayloadFunc func(Event) (any, error)
|
||||
|
||||
// FanoutPublisher 将同一事件投递给多个 Publisher。
|
||||
type FanoutPublisher struct {
|
||||
publishers []Publisher
|
||||
}
|
||||
|
||||
// NewFanoutPublisher 创建扇出发布器并过滤 nil publisher。
|
||||
func NewFanoutPublisher(publishers ...Publisher) (*FanoutPublisher, error) {
|
||||
filtered := make([]Publisher, 0, len(publishers))
|
||||
for _, publisher := range publishers {
|
||||
if publisher != nil {
|
||||
filtered = append(filtered, publisher)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, ErrPublisherRequired
|
||||
}
|
||||
return &FanoutPublisher{publishers: filtered}, nil
|
||||
}
|
||||
|
||||
// Publish 依次调用所有子 publisher,并聚合发布错误。
|
||||
func (p *FanoutPublisher) Publish(ctx context.Context, event Event) error {
|
||||
if p == nil || len(p.publishers) == 0 {
|
||||
return ErrPublisherRequired
|
||||
}
|
||||
var errs []error
|
||||
// Fanout 会尝试所有 publisher 并聚合错误;任一失败都会让 EventLog 保持可重试状态。
|
||||
for _, publisher := range p.publishers {
|
||||
if err := publisher.Publish(ctx, event); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Message 是发送给通用消息下游的规范化事件消息。
|
||||
type Message struct {
|
||||
Topic string `json:"topic"`
|
||||
Key string `json:"key,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Payload []byte `json:"payload,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// MessageSink 是通用消息发布下游的最小接口。
|
||||
type MessageSink interface {
|
||||
PublishMessage(context.Context, Message) error
|
||||
}
|
||||
|
||||
// MessageSinkFunc 允许用函数快速实现 MessageSink。
|
||||
type MessageSinkFunc func(context.Context, Message) error
|
||||
|
||||
// PublishMessage 调用底层函数并保护 nil 函数场景。
|
||||
func (fn MessageSinkFunc) PublishMessage(ctx context.Context, message Message) error {
|
||||
if fn == nil {
|
||||
return ErrPublisherRequired
|
||||
}
|
||||
return fn(ctx, message)
|
||||
}
|
||||
|
||||
// KeyFunc 根据事件计算消息 key。
|
||||
type KeyFunc func(Event) string
|
||||
|
||||
// MessagePublisherOptions 描述通用消息发布器的 topic、key、头和正文策略。
|
||||
type MessagePublisherOptions struct {
|
||||
Sink MessageSink
|
||||
Topic TopicFunc
|
||||
Key KeyFunc
|
||||
Headers map[string]string
|
||||
Body HTTPBodyFunc
|
||||
}
|
||||
|
||||
// MessagePublisher 把 eventlog 事件转换成 MessageSink 可消费的消息。
|
||||
type MessagePublisher struct {
|
||||
sink MessageSink
|
||||
topic TopicFunc
|
||||
key KeyFunc
|
||||
headers map[string]string
|
||||
body HTTPBodyFunc
|
||||
}
|
||||
|
||||
// NewMessagePublisher 创建通用消息发布器并补齐默认 topic、key 和正文策略。
|
||||
func NewMessagePublisher(options MessagePublisherOptions) (*MessagePublisher, error) {
|
||||
if options.Sink == nil {
|
||||
return nil, fmt.Errorf("%w: message sink is required", ErrInvalidPublisherConfig)
|
||||
}
|
||||
topic := options.Topic
|
||||
if topic == nil {
|
||||
topic = DefaultTopic
|
||||
}
|
||||
key := options.Key
|
||||
if key == nil {
|
||||
key = DefaultMessageKey
|
||||
}
|
||||
body := options.Body
|
||||
if body == nil {
|
||||
body = EventJSONBody
|
||||
}
|
||||
return &MessagePublisher{
|
||||
sink: options.Sink,
|
||||
topic: topic,
|
||||
key: key,
|
||||
headers: cloneStringMap(options.Headers),
|
||||
body: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Publish 构造消息并投递到 MessageSink。
|
||||
func (p *MessagePublisher) Publish(ctx context.Context, event Event) error {
|
||||
if p == nil || p.sink == nil || p.topic == nil || p.key == nil || p.body == nil {
|
||||
return ErrPublisherRequired
|
||||
}
|
||||
topic := strings.TrimSpace(p.topic(event))
|
||||
if topic == "" {
|
||||
return fmt.Errorf("%w: topic is required", ErrInvalidPublisherConfig)
|
||||
}
|
||||
body, contentType, err := p.body(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
headers := cloneStringMap(p.headers)
|
||||
if headers == nil {
|
||||
headers = map[string]string{}
|
||||
}
|
||||
setMessageHeader(headers, "event_id", event.ID)
|
||||
setMessageHeader(headers, "event_stream", event.Stream)
|
||||
setMessageHeader(headers, "event_type", event.Type)
|
||||
setMessageHeader(headers, "event_aggregate_id", event.AggregateID)
|
||||
setMessageHeader(headers, "event_idempotency_key", event.IdempotencyKey)
|
||||
return p.sink.PublishMessage(ctx, Message{
|
||||
Topic: topic,
|
||||
Key: strings.TrimSpace(p.key(event)),
|
||||
ContentType: contentType,
|
||||
Payload: append([]byte(nil), body...),
|
||||
Headers: headers,
|
||||
})
|
||||
}
|
||||
|
||||
// BusPublisherOptions 描述事件发布到平台 Bus 时的 topic 和 payload 策略。
|
||||
type BusPublisherOptions struct {
|
||||
Topic TopicFunc
|
||||
Payload PayloadFunc
|
||||
}
|
||||
|
||||
// BusPublisher 把 eventlog 事件发布到平台 Bus。
|
||||
type BusPublisher struct {
|
||||
bus bus.Bus
|
||||
topic TopicFunc
|
||||
payload PayloadFunc
|
||||
}
|
||||
|
||||
// NewBusPublisher 创建 Bus 发布器并补齐默认 topic 和 payload 策略。
|
||||
func NewBusPublisher(eventBus bus.Bus, options BusPublisherOptions) (*BusPublisher, error) {
|
||||
if eventBus == nil {
|
||||
return nil, fmt.Errorf("%w: bus is required", ErrInvalidPublisherConfig)
|
||||
}
|
||||
topic := options.Topic
|
||||
if topic == nil {
|
||||
topic = DefaultTopic
|
||||
}
|
||||
payload := options.Payload
|
||||
if payload == nil {
|
||||
payload = RawJSONPayload
|
||||
}
|
||||
return &BusPublisher{bus: eventBus, topic: topic, payload: payload}, nil
|
||||
}
|
||||
|
||||
// Publish 将事件转换后发布到平台 Bus。
|
||||
func (p *BusPublisher) Publish(ctx context.Context, event Event) error {
|
||||
if p == nil || p.bus == nil || p.topic == nil || p.payload == nil {
|
||||
return ErrPublisherRequired
|
||||
}
|
||||
topic := strings.TrimSpace(p.topic(event))
|
||||
if topic == "" {
|
||||
return fmt.Errorf("%w: topic is required", ErrInvalidPublisherConfig)
|
||||
}
|
||||
payload, err := p.payload(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// BusPublisher 的可靠性只等于底层 Bus;Redis/NATS Pub/Sub 成功不代表消费者持久确认。
|
||||
return p.bus.Publish(ctx, topic, payload)
|
||||
}
|
||||
|
||||
// HTTPBodyFunc 根据事件生成 HTTP 请求体和 content-type。
|
||||
type HTTPBodyFunc func(Event) ([]byte, string, error)
|
||||
|
||||
// HTTPPublisherOptions 描述 webhook 发布器的地址、方法、客户端和正文策略。
|
||||
type HTTPPublisherOptions struct {
|
||||
URL string
|
||||
Method string
|
||||
Client *http.Client
|
||||
Headers map[string]string
|
||||
Body HTTPBodyFunc
|
||||
}
|
||||
|
||||
// HTTPPublisher 把 eventlog 事件投递到 HTTP webhook。
|
||||
type HTTPPublisher struct {
|
||||
url string
|
||||
method string
|
||||
client *http.Client
|
||||
headers map[string]string
|
||||
body HTTPBodyFunc
|
||||
}
|
||||
|
||||
// NewHTTPPublisher 创建 webhook 发布器并补齐默认 HTTP 客户端和正文策略。
|
||||
func NewHTTPPublisher(options HTTPPublisherOptions) (*HTTPPublisher, error) {
|
||||
url := strings.TrimSpace(options.URL)
|
||||
if url == "" {
|
||||
return nil, fmt.Errorf("%w: url is required", ErrInvalidPublisherConfig)
|
||||
}
|
||||
method := strings.TrimSpace(options.Method)
|
||||
if method == "" {
|
||||
method = http.MethodPost
|
||||
}
|
||||
client := options.Client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
body := options.Body
|
||||
if body == nil {
|
||||
body = EventJSONBody
|
||||
}
|
||||
return &HTTPPublisher{
|
||||
url: url,
|
||||
method: method,
|
||||
client: client,
|
||||
headers: cloneStringMap(options.Headers),
|
||||
body: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Publish 发送 webhook 请求,并把 4xx 响应归类为永久失败。
|
||||
func (p *HTTPPublisher) Publish(ctx context.Context, event Event) error {
|
||||
if p == nil || p.client == nil || p.body == nil {
|
||||
return ErrPublisherRequired
|
||||
}
|
||||
body, contentType, err := p.body(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, p.method, p.url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
for key, value := range p.headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
setEventHeader(req.Header, "X-Event-ID", event.ID)
|
||||
setEventHeader(req.Header, "X-Event-Stream", event.Stream)
|
||||
setEventHeader(req.Header, "X-Event-Type", event.Type)
|
||||
setEventHeader(req.Header, "X-Event-Aggregate-ID", event.AggregateID)
|
||||
setEventHeader(req.Header, "X-Event-Idempotency-Key", event.IdempotencyKey)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
// HTTP 4xx 通常是不可重试的契约错误,直接进入死信,避免把坏事件反复打到下游。
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
message := strings.TrimSpace(string(data))
|
||||
if message == "" {
|
||||
message = resp.Status
|
||||
}
|
||||
publishErr := fmt.Errorf("eventlog webhook returned %s: %s", resp.Status, message)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||
return fmt.Errorf("%w: %w", ErrPermanentPublishFailure, publishErr)
|
||||
}
|
||||
return publishErr
|
||||
}
|
||||
|
||||
// DefaultTopic 使用 stream 和 type 组合默认 topic。
|
||||
func DefaultTopic(event Event) string {
|
||||
stream := strings.TrimSpace(event.Stream)
|
||||
eventType := strings.TrimSpace(event.Type)
|
||||
switch {
|
||||
case stream == "":
|
||||
return eventType
|
||||
case eventType == "":
|
||||
return stream
|
||||
default:
|
||||
return stream + "." + eventType
|
||||
}
|
||||
}
|
||||
|
||||
// OriginalTopic 优先使用事件头中记录的原始 topic,缺失时回退到 stream.type。
|
||||
func OriginalTopic(event Event) string {
|
||||
if event.Headers != nil {
|
||||
if topic := strings.TrimSpace(event.Headers[HeaderOriginalTopic]); topic != "" {
|
||||
return topic
|
||||
}
|
||||
}
|
||||
return DefaultTopic(event)
|
||||
}
|
||||
|
||||
// DefaultMessageKey 优先使用幂等键,缺失时退回事件 ID。
|
||||
func DefaultMessageKey(event Event) string {
|
||||
if key := strings.TrimSpace(event.IdempotencyKey); key != "" {
|
||||
return key
|
||||
}
|
||||
return strings.TrimSpace(event.ID)
|
||||
}
|
||||
|
||||
// RawJSONPayload 返回事件原始 payload 副本。
|
||||
func RawJSONPayload(event Event) (any, error) {
|
||||
return cloneRawMessage(event.Payload), nil
|
||||
}
|
||||
|
||||
// OriginalTopicPayload 只在事件声明 original_topic 时按契约解码 payload,避免恢复事件在 Memory bus 中被 typed 订阅者跳过。
|
||||
func OriginalTopicPayload(event Event) (any, error) {
|
||||
if event.Headers == nil || strings.TrimSpace(event.Headers[HeaderOriginalTopic]) == "" {
|
||||
return RawJSONPayload(event)
|
||||
}
|
||||
return contracts.UnmarshalTopicPayload(OriginalTopic(event), cloneRawMessage(event.Payload))
|
||||
}
|
||||
|
||||
// EventJSONBody 把完整事件编码成 JSON 请求体。
|
||||
func EventJSONBody(event Event) ([]byte, string, error) {
|
||||
data, err := json.Marshal(cloneEvent(event))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return data, "application/json", nil
|
||||
}
|
||||
|
||||
// PayloadJSONBody 把事件 payload 作为 JSON 请求体。
|
||||
func PayloadJSONBody(event Event) ([]byte, string, error) {
|
||||
return cloneRawMessage(event.Payload), "application/json", nil
|
||||
}
|
||||
|
||||
func setEventHeader(headers http.Header, key string, value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
headers.Set(key, value)
|
||||
}
|
||||
|
||||
func setMessageHeader(headers map[string]string, key string, value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
headers[key] = value
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
package eventlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNilSQLDB 表示 SQL store 缺少可用数据库连接或事务。
|
||||
ErrNilSQLDB = errors.New("eventlog sql db is required")
|
||||
|
||||
// ErrInvalidTable 表示配置的 SQL 表名不符合安全标识符规则。
|
||||
ErrInvalidTable = errors.New("eventlog table name is invalid")
|
||||
|
||||
// ErrInvalidSQLRows 表示查询返回的 rows 为空或不满足消费契约。
|
||||
ErrInvalidSQLRows = errors.New("eventlog sql rows are invalid")
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSQLTable = "outbox_events"
|
||||
defPartMonths = 36
|
||||
defPartStartYMD = "2026-01-01"
|
||||
)
|
||||
|
||||
// SQLRows 抽象 database/sql.Rows,便于测试替换和 rows.Err 门禁覆盖。
|
||||
type SQLRows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Close() error
|
||||
Err() error
|
||||
}
|
||||
|
||||
// SQLDB 是 eventlog SQL store 需要的最小数据库执行接口。
|
||||
type SQLDB interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
QueryContext(context.Context, string, ...any) (SQLRows, error)
|
||||
}
|
||||
|
||||
// SQLTransaction 是带提交和回滚能力的 SQLDB。
|
||||
type SQLTransaction interface {
|
||||
SQLDB
|
||||
Commit() error
|
||||
Rollback() error
|
||||
}
|
||||
|
||||
// SQLTransactor 能为 eventlog 追加事件开启事务。
|
||||
type SQLTransactor interface {
|
||||
BeginEventLogTx(context.Context) (SQLTransaction, error)
|
||||
}
|
||||
|
||||
// SQLDBAdapter 把 *sql.DB 适配成 eventlog SQLDB。
|
||||
type SQLDBAdapter struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// SQLTxAdapter 把 *sql.Tx 适配成 eventlog SQLTransaction。
|
||||
type SQLTxAdapter struct {
|
||||
Tx *sql.Tx
|
||||
}
|
||||
|
||||
var (
|
||||
_ SQLDB = SQLDBAdapter{}
|
||||
_ SQLDB = SQLTxAdapter{}
|
||||
)
|
||||
|
||||
// ExecContext 在底层 *sql.DB 上执行语句。
|
||||
func (a SQLDBAdapter) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||
if a.DB == nil {
|
||||
return nil, ErrNilSQLDB
|
||||
}
|
||||
return a.DB.ExecContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// QueryContext 在底层 *sql.DB 上查询 rows,rows 生命周期由调用方负责。
|
||||
func (a SQLDBAdapter) QueryContext(ctx context.Context, query string, args ...any) (SQLRows, error) { //nolint:rowserrcheck // 适配层只转交 rows,遍历和 Err 检查由上层消费函数负责。
|
||||
if a.DB == nil {
|
||||
return nil, ErrNilSQLDB
|
||||
}
|
||||
return a.DB.QueryContext(ctx, query, args...) //nolint:rowserrcheck // 调用方拿到 rows 后统一遍历并检查 Err。
|
||||
}
|
||||
|
||||
// BeginEventLogTx 开启 eventlog 使用的 SQL 事务。
|
||||
func (a SQLDBAdapter) BeginEventLogTx(ctx context.Context) (SQLTransaction, error) {
|
||||
if a.DB == nil {
|
||||
return nil, ErrNilSQLDB
|
||||
}
|
||||
tx, err := a.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SQLTxAdapter{Tx: tx}, nil
|
||||
}
|
||||
|
||||
// ExecContext 在底层 *sql.Tx 上执行语句。
|
||||
func (a SQLTxAdapter) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||
if a.Tx == nil {
|
||||
return nil, ErrNilSQLDB
|
||||
}
|
||||
return a.Tx.ExecContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// QueryContext 在底层 *sql.Tx 上查询 rows,rows 生命周期由调用方负责。
|
||||
func (a SQLTxAdapter) QueryContext(ctx context.Context, query string, args ...any) (SQLRows, error) { //nolint:rowserrcheck // 适配层只转交 rows,遍历和 Err 检查由上层消费函数负责。
|
||||
if a.Tx == nil {
|
||||
return nil, ErrNilSQLDB
|
||||
}
|
||||
rows, err := a.Tx.QueryContext(ctx, query, args...) //nolint:rowserrcheck // 调用方拿到 rows 后统一遍历并检查 Err。
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Commit 提交底层事务。
|
||||
func (a SQLTxAdapter) Commit() error {
|
||||
if a.Tx == nil {
|
||||
return ErrNilSQLDB
|
||||
}
|
||||
return a.Tx.Commit()
|
||||
}
|
||||
|
||||
// Rollback 回滚底层事务。
|
||||
func (a SQLTxAdapter) Rollback() error {
|
||||
if a.Tx == nil {
|
||||
return ErrNilSQLDB
|
||||
}
|
||||
return a.Tx.Rollback()
|
||||
}
|
||||
|
||||
// SQLStoreOptions 描述 SQL eventlog 的实例名称和主表名。
|
||||
type SQLStoreOptions struct {
|
||||
Name string
|
||||
Table string
|
||||
}
|
||||
|
||||
// SQLStore 使用 SQL 表保存 eventlog/outbox 事件。
|
||||
type SQLStore struct {
|
||||
name string
|
||||
db SQLDB
|
||||
table string
|
||||
closer interface {
|
||||
Close() error
|
||||
}
|
||||
}
|
||||
|
||||
// NewSQLStore 使用抽象 SQLDB 创建 eventlog store。
|
||||
func NewSQLStore(db SQLDB, options SQLStoreOptions) (*SQLStore, error) {
|
||||
if db == nil {
|
||||
return nil, ErrNilSQLDB
|
||||
}
|
||||
table, err := normalizeTable(options.Table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := strings.TrimSpace(options.Name)
|
||||
if name == "" {
|
||||
name = "sql-eventlog"
|
||||
}
|
||||
return &SQLStore{name: name, db: db, table: table}, nil
|
||||
}
|
||||
|
||||
// NewSQLStoreFromDB 使用 *sql.DB 创建 eventlog store,并在 Close 时关闭 DB。
|
||||
func NewSQLStoreFromDB(db *sql.DB, options SQLStoreOptions) (*SQLStore, error) {
|
||||
if db == nil {
|
||||
return nil, ErrNilSQLDB
|
||||
}
|
||||
store, err := NewSQLStore(SQLDBAdapter{DB: db}, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store.closer = db
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// NewSQLStoreFromTx 使用现有事务创建 eventlog store,不接管事务提交。
|
||||
func NewSQLStoreFromTx(tx *sql.Tx, options SQLStoreOptions) (*SQLStore, error) {
|
||||
if tx == nil {
|
||||
return nil, ErrNilSQLDB
|
||||
}
|
||||
return NewSQLStore(SQLTxAdapter{Tx: tx}, options)
|
||||
}
|
||||
|
||||
// EnsureSchema 创建 eventlog 主表、事件 ID 表和幂等键表。
|
||||
func (s *SQLStore) EnsureSchema(ctx context.Context) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return ErrNilSQLDB
|
||||
}
|
||||
for _, statement := range s.SchemaStatements() {
|
||||
if _, err := s.db.ExecContext(ctx, statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭由 store 接管的底层数据库连接。
|
||||
func (s *SQLStore) Close() error {
|
||||
if s == nil || s.closer == nil {
|
||||
return nil
|
||||
}
|
||||
return s.closer.Close()
|
||||
}
|
||||
|
||||
// Schema 返回完整建表 SQL 文本。
|
||||
func (s *SQLStore) Schema() string {
|
||||
return strings.Join(s.SchemaStatements(), ";\n")
|
||||
}
|
||||
|
||||
// SchemaStatements 返回 eventlog 所需的分表建表语句。
|
||||
func (s *SQLStore) SchemaStatements() []string {
|
||||
table := quoteIdentifier(s.table)
|
||||
eventIDTable := quoteIdentifier(s.eventIDTable())
|
||||
idempotencyTable := quoteIdentifier(s.idempotencyTable())
|
||||
return []string{
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
id VARCHAR(128) NOT NULL,
|
||||
stream VARCHAR(128) NOT NULL,
|
||||
event_type VARCHAR(128) NOT NULL,
|
||||
aggregate_id VARCHAR(128) NULL,
|
||||
idempotency_key VARCHAR(191) NULL,
|
||||
payload_json JSON NOT NULL,
|
||||
headers_json JSON NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
available_at DATETIME(6) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
published_at DATETIME(6) NULL,
|
||||
last_error TEXT NULL,
|
||||
PRIMARY KEY (id, created_at),
|
||||
KEY idx_outbox_idempotency (idempotency_key, created_at),
|
||||
KEY idx_outbox_pending (status, available_at, id, created_at),
|
||||
KEY idx_outbox_aggregate_id (aggregate_id, created_at),
|
||||
KEY idx_outbox_stream (stream, aggregate_id, created_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
PARTITION BY RANGE COLUMNS(created_at) (
|
||||
%s
|
||||
)`, table, monthlyPartitions(defaultPartStart(), defPartMonths)),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
id VARCHAR(128) NOT NULL,
|
||||
event_created_at DATETIME(6) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_outbox_event_ids_created_at (event_created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, eventIDTable),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
idempotency_key VARCHAR(191) NOT NULL,
|
||||
event_id VARCHAR(128) NOT NULL,
|
||||
event_created_at DATETIME(6) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
PRIMARY KEY (idempotency_key),
|
||||
KEY idx_outbox_idempotency_event (event_id, event_created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, idempotencyTable),
|
||||
}
|
||||
}
|
||||
|
||||
// Append 在 SQL 中追加事件,并用辅助表保证事件 ID 与幂等键唯一。
|
||||
func (s *SQLStore) Append(ctx context.Context, event Event) (Event, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return Event{}, ErrNilSQLDB
|
||||
}
|
||||
if txDB, ok := s.db.(SQLTransactor); ok {
|
||||
return s.appendInTransaction(ctx, txDB, event)
|
||||
}
|
||||
return s.appendDirect(ctx, event)
|
||||
}
|
||||
|
||||
func (s *SQLStore) appendInTransaction(ctx context.Context, txDB SQLTransactor, event Event) (Event, error) {
|
||||
tx, err := txDB.BeginEventLogTx(ctx)
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
txStore := *s
|
||||
txStore.db = tx
|
||||
appended, err := txStore.appendDirect(ctx, event)
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
if recovered, ok := s.recoverAppend(ctx, appended); ok {
|
||||
committed = true
|
||||
return recovered, nil
|
||||
}
|
||||
return Event{}, err
|
||||
}
|
||||
committed = true
|
||||
return appended, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) recoverAppend(parent context.Context, event Event) (Event, bool) {
|
||||
ctx, cancel := sqlCleanupCtx(parent)
|
||||
defer cancel()
|
||||
if event.IdempotencyKey != "" {
|
||||
existing, ok, err := s.GetByIdempotencyKey(ctx, event.IdempotencyKey)
|
||||
if err == nil && ok && sameIdempotentEvent(existing, event) {
|
||||
return existing, true
|
||||
}
|
||||
}
|
||||
existing, ok, err := s.Get(ctx, event.ID)
|
||||
if err == nil && ok && sameIdempotentEvent(existing, event) {
|
||||
return existing, true
|
||||
}
|
||||
return Event{}, false
|
||||
}
|
||||
|
||||
func (s *SQLStore) appendDirect(ctx context.Context, event Event) (Event, error) {
|
||||
// Append 先查幂等键和事件 ID,再用辅助表保留 ID;主表按 created_at 分区,不能单靠分区主键保证 ID 全局唯一。
|
||||
if event.IdempotencyKey != "" {
|
||||
if existing, ok, err := s.GetByIdempotencyKey(ctx, event.IdempotencyKey); err != nil {
|
||||
return Event{}, err
|
||||
} else if ok {
|
||||
if !sameIdempotentEvent(existing, event) {
|
||||
return Event{}, ErrIdempotencyConflict
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
if _, ok, err := s.Get(ctx, event.ID); err != nil {
|
||||
return Event{}, err
|
||||
} else if ok {
|
||||
return Event{}, ErrEventExists
|
||||
}
|
||||
reservedID := false
|
||||
if err := s.reserveEventID(ctx, event); err != nil {
|
||||
if _, ok, getErr := s.Get(ctx, event.ID); getErr != nil {
|
||||
return Event{}, getErr
|
||||
} else if ok {
|
||||
return Event{}, ErrEventExists
|
||||
}
|
||||
return Event{}, err
|
||||
}
|
||||
reservedID = true
|
||||
reservedIdempotency := false
|
||||
if event.IdempotencyKey != "" {
|
||||
if err := s.reserveIdempotency(ctx, event); err != nil {
|
||||
if reservedID {
|
||||
s.releaseEventIDSafe(ctx, event)
|
||||
}
|
||||
if existing, ok, getErr := s.GetByIdempotencyKey(ctx, event.IdempotencyKey); getErr != nil {
|
||||
return Event{}, getErr
|
||||
} else if ok {
|
||||
if !sameIdempotentEvent(existing, event) {
|
||||
return Event{}, ErrIdempotencyConflict
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
return Event{}, err
|
||||
}
|
||||
reservedIdempotency = true
|
||||
}
|
||||
payload := string(event.Payload)
|
||||
headers, err := nullableJSON(event.Headers)
|
||||
if err != nil {
|
||||
if reservedID {
|
||||
s.releaseEventIDSafe(ctx, event)
|
||||
}
|
||||
if reservedIdempotency {
|
||||
s.releaseIdemSafe(ctx, event)
|
||||
}
|
||||
return Event{}, err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
"INSERT INTO "+quoteIdentifier(s.table)+" (id, stream, event_type, aggregate_id, idempotency_key, payload_json, headers_json, status, attempts, available_at, created_at, updated_at, published_at, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
event.ID,
|
||||
event.Stream,
|
||||
event.Type,
|
||||
nullableString(event.AggregateID),
|
||||
nullableString(event.IdempotencyKey),
|
||||
payload,
|
||||
headers,
|
||||
event.Status,
|
||||
event.Attempts,
|
||||
event.AvailableAt,
|
||||
event.CreatedAt,
|
||||
event.UpdatedAt,
|
||||
nullableTime(event.PublishedAt),
|
||||
nullableString(event.LastError),
|
||||
)
|
||||
if err != nil {
|
||||
if reservedID {
|
||||
s.releaseEventIDSafe(ctx, event)
|
||||
}
|
||||
if reservedIdempotency {
|
||||
s.releaseIdemSafe(ctx, event)
|
||||
}
|
||||
if event.IdempotencyKey != "" {
|
||||
if existing, ok, getErr := s.GetByIdempotencyKey(ctx, event.IdempotencyKey); getErr != nil {
|
||||
return Event{}, getErr
|
||||
} else if ok {
|
||||
if !sameIdempotentEvent(existing, event) {
|
||||
return Event{}, ErrIdempotencyConflict
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
return Event{}, err
|
||||
}
|
||||
return cloneEvent(event), nil
|
||||
}
|
||||
|
||||
// Get 按事件 ID 查询 SQL 中的事件。
|
||||
func (s *SQLStore) Get(ctx context.Context, id string) (Event, bool, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return Event{}, false, nil
|
||||
}
|
||||
return s.queryOne(ctx, "SELECT "+eventColumns()+" FROM "+quoteIdentifier(s.table)+" WHERE id = ? LIMIT 1", id)
|
||||
}
|
||||
|
||||
// GetByIdempotencyKey 按幂等键查询 SQL 中的事件。
|
||||
func (s *SQLStore) GetByIdempotencyKey(ctx context.Context, key string) (Event, bool, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return Event{}, false, nil
|
||||
}
|
||||
return s.queryOne(ctx,
|
||||
"SELECT "+prefixedEventColumns("e")+" FROM "+quoteIdentifier(s.table)+" e JOIN "+quoteIdentifier(s.idempotencyTable())+" i ON e.id = i.event_id AND e.created_at = i.event_created_at WHERE i.idempotency_key = ? LIMIT 1",
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
// ListByStreamAggregate 按事件流和聚合 ID 查询最近事件。
|
||||
func (s *SQLStore) ListByStreamAggregate(ctx context.Context, stream, aggregateID string, limit int) ([]Event, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return nil, ErrNilSQLDB
|
||||
}
|
||||
stream = strings.TrimSpace(stream)
|
||||
aggregateID = strings.TrimSpace(aggregateID)
|
||||
if stream == "" {
|
||||
return nil, fmt.Errorf("%w: stream is required", ErrInvalidEvent)
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultPublishLimit
|
||||
}
|
||||
query := "SELECT " + eventColumns() + " FROM " + quoteIdentifier(s.table) + " WHERE stream = ?"
|
||||
args := []any{stream}
|
||||
if aggregateID == "" {
|
||||
query += " AND aggregate_id IS NULL"
|
||||
} else {
|
||||
query += " AND aggregate_id = ?"
|
||||
args = append(args, aggregateID)
|
||||
}
|
||||
query += " ORDER BY created_at DESC, id DESC LIMIT ?"
|
||||
args = append(args, limit)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanEvents(rows)
|
||||
}
|
||||
|
||||
// ClaimPending 领取一批 SQL 中到期的待发布事件。
|
||||
func (s *SQLStore) ClaimPending(ctx context.Context, limit int, now time.Time, claimTimeout time.Duration) ([]Event, error) {
|
||||
return s.claimPending(ctx, limit, now, claimTimeout, nil)
|
||||
}
|
||||
|
||||
// ClaimPendingExcept 领取到期事件时排除指定事件流。
|
||||
func (s *SQLStore) ClaimPendingExcept(ctx context.Context, limit int, now time.Time, claimTimeout time.Duration, excludeStreams []string) ([]Event, error) {
|
||||
return s.claimPending(ctx, limit, now, claimTimeout, normalizeStreamList(excludeStreams))
|
||||
}
|
||||
|
||||
func (s *SQLStore) claimPending(ctx context.Context, limit int, now time.Time, claimTimeout time.Duration, excludeStreams []string) ([]Event, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return nil, ErrNilSQLDB
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultPublishLimit
|
||||
}
|
||||
now = now.UTC()
|
||||
if claimTimeout <= 0 {
|
||||
claimTimeout = defaultClaimTimeout
|
||||
}
|
||||
claimUntil := now.Add(claimTimeout)
|
||||
// SQL claim 通过状态和 available_at 条件竞争领取,领取成功后 available_at 变成本次 claim deadline。
|
||||
events, err := s.fetchClaimable(ctx, limit, now, excludeStreams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claimed := make([]Event, 0, len(events))
|
||||
for _, event := range events {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
"UPDATE "+quoteIdentifier(s.table)+" SET status = ?, available_at = ?, updated_at = ? WHERE id = ? AND status IN (?, ?, ?) AND available_at <= ?",
|
||||
StatusProcessing,
|
||||
claimUntil,
|
||||
now,
|
||||
event.ID,
|
||||
StatusPending,
|
||||
StatusFailed,
|
||||
StatusProcessing,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected == 0 {
|
||||
continue
|
||||
}
|
||||
event.Status = StatusProcessing
|
||||
event.AvailableAt = claimUntil
|
||||
event.UpdatedAt = now
|
||||
claimed = append(claimed, cloneEvent(event))
|
||||
}
|
||||
return claimed, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) fetchClaimable(ctx context.Context, limit int, now time.Time, excludeStreams []string) ([]Event, error) {
|
||||
query := "SELECT " + eventColumns() + " FROM " + quoteIdentifier(s.table) + " WHERE status IN (?, ?, ?) AND available_at <= ?"
|
||||
args := []any{
|
||||
StatusPending,
|
||||
StatusFailed,
|
||||
StatusProcessing,
|
||||
now,
|
||||
}
|
||||
if len(excludeStreams) > 0 {
|
||||
query += " AND stream NOT IN (" + placeholders(len(excludeStreams)) + ")"
|
||||
for _, stream := range excludeStreams {
|
||||
args = append(args, stream)
|
||||
}
|
||||
}
|
||||
query += " ORDER BY available_at ASC, id ASC LIMIT ?"
|
||||
args = append(args, limit)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanEvents(rows)
|
||||
}
|
||||
|
||||
// MarkPublished 将当前 claim 持有的 SQL 事件推进为已发布。
|
||||
func (s *SQLStore) MarkPublished(ctx context.Context, id string, publishedAt time.Time, claimDeadline time.Time) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return ErrNilSQLDB
|
||||
}
|
||||
publishedAt = publishedAt.UTC()
|
||||
result, err := s.execClaimUpdate(ctx, claimDeadline,
|
||||
"UPDATE "+quoteIdentifier(s.table)+" SET status = ?, published_at = ?, updated_at = ?, last_error = NULL WHERE id = ?",
|
||||
"UPDATE "+quoteIdentifier(s.table)+" SET status = ?, published_at = ?, updated_at = ?, last_error = NULL WHERE id = ? AND status = ? AND available_at = ?",
|
||||
StatusPublished,
|
||||
publishedAt,
|
||||
publishedAt,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkClaimAffected(result, claimDeadline)
|
||||
}
|
||||
|
||||
// MarkFailed 将当前 claim 持有的 SQL 事件推进为失败并设置下次重试时间。
|
||||
func (s *SQLStore) MarkFailed(ctx context.Context, id string, message string, nextAttempt time.Time, updatedAt time.Time, claimDeadline time.Time) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return ErrNilSQLDB
|
||||
}
|
||||
result, err := s.execClaimUpdate(ctx, claimDeadline,
|
||||
"UPDATE "+quoteIdentifier(s.table)+" SET status = ?, attempts = attempts + 1, available_at = ?, updated_at = ?, last_error = ? WHERE id = ?",
|
||||
"UPDATE "+quoteIdentifier(s.table)+" SET status = ?, attempts = attempts + 1, available_at = ?, updated_at = ?, last_error = ? WHERE id = ? AND status = ? AND available_at = ?",
|
||||
StatusFailed,
|
||||
nextAttempt.UTC(),
|
||||
updatedAt.UTC(),
|
||||
message,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkClaimAffected(result, claimDeadline)
|
||||
}
|
||||
|
||||
// MarkDeadLetter 将当前 claim 持有的 SQL 事件推进为死信。
|
||||
func (s *SQLStore) MarkDeadLetter(ctx context.Context, id string, message string, updatedAt time.Time, claimDeadline time.Time) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return ErrNilSQLDB
|
||||
}
|
||||
updatedAt = updatedAt.UTC()
|
||||
result, err := s.execClaimUpdate(ctx, claimDeadline,
|
||||
"UPDATE "+quoteIdentifier(s.table)+" SET status = ?, attempts = attempts + 1, available_at = ?, updated_at = ?, last_error = ? WHERE id = ?",
|
||||
"UPDATE "+quoteIdentifier(s.table)+" SET status = ?, attempts = attempts + 1, available_at = ?, updated_at = ?, last_error = ? WHERE id = ? AND status = ? AND available_at = ?",
|
||||
StatusDeadLetter,
|
||||
updatedAt,
|
||||
updatedAt,
|
||||
message,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkClaimAffected(result, claimDeadline)
|
||||
}
|
||||
|
||||
// ListDeadLetters 查询 SQL 中的死信事件。
|
||||
func (s *SQLStore) ListDeadLetters(ctx context.Context, limit int) ([]Event, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return nil, ErrNilSQLDB
|
||||
}
|
||||
if limit < 0 {
|
||||
return nil, ErrInvalidLimit
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = defDeadLetterLimit
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
"SELECT "+eventColumns()+" FROM "+quoteIdentifier(s.table)+" WHERE status = ? ORDER BY updated_at ASC, id ASC LIMIT ?",
|
||||
StatusDeadLetter,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanEvents(rows)
|
||||
}
|
||||
|
||||
// RequeueDeadLetter 把 SQL 中的死信事件重新放回待发布队列。
|
||||
func (s *SQLStore) RequeueDeadLetter(ctx context.Context, id string, availableAt time.Time, updatedAt time.Time, preserveAttempts bool) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return ErrNilSQLDB
|
||||
}
|
||||
availableAt = availableAt.UTC()
|
||||
updatedAt = updatedAt.UTC()
|
||||
var (
|
||||
result sql.Result
|
||||
err error
|
||||
)
|
||||
if preserveAttempts {
|
||||
result, err = s.db.ExecContext(ctx,
|
||||
"UPDATE "+quoteIdentifier(s.table)+" SET status = ?, available_at = ?, updated_at = ?, published_at = NULL, last_error = NULL WHERE id = ? AND status = ?",
|
||||
StatusPending,
|
||||
availableAt,
|
||||
updatedAt,
|
||||
id,
|
||||
StatusDeadLetter,
|
||||
)
|
||||
} else {
|
||||
result, err = s.db.ExecContext(ctx,
|
||||
"UPDATE "+quoteIdentifier(s.table)+" SET status = ?, attempts = 0, available_at = ?, updated_at = ?, published_at = NULL, last_error = NULL WHERE id = ? AND status = ?",
|
||||
StatusPending,
|
||||
availableAt,
|
||||
updatedAt,
|
||||
id,
|
||||
StatusDeadLetter,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkAffected(result)
|
||||
}
|
||||
|
||||
// Snapshot 汇总 SQL store 当前各状态数量和最早到期时间。
|
||||
func (s *SQLStore) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return Snapshot{}, ErrNilSQLDB
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT "+eventColumns()+" FROM "+quoteIdentifier(s.table)+" ORDER BY created_at ASC, id ASC")
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
events, err := scanEvents(rows)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
snapshot := Snapshot{Name: s.name, Total: len(events), ByStatus: make(map[string]int)}
|
||||
for _, event := range events {
|
||||
snapshot.ByStatus[event.Status]++
|
||||
if isDueStatus(event.Status) && (snapshot.OldestDue.IsZero() || event.AvailableAt.Before(snapshot.OldestDue)) {
|
||||
snapshot.OldestDue = event.AvailableAt
|
||||
}
|
||||
}
|
||||
if len(snapshot.ByStatus) == 0 {
|
||||
snapshot.ByStatus = nil
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package eventlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *SQLStore) queryOne(ctx context.Context, query string, args ...any) (Event, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Event{}, false, err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return Event{}, false, ErrNilSQLDB
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return Event{}, false, err
|
||||
}
|
||||
events, err := scanEvents(rows)
|
||||
if err != nil {
|
||||
return Event{}, false, err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return Event{}, false, nil
|
||||
}
|
||||
return events[0], true, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) reserveEventID(ctx context.Context, event Event) error {
|
||||
createdAt := event.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Now().UTC()
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
"INSERT INTO "+quoteIdentifier(s.eventIDTable())+" (id, event_created_at, created_at) VALUES (?, ?, ?)",
|
||||
event.ID,
|
||||
event.CreatedAt,
|
||||
createdAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) releaseEventID(ctx context.Context, event Event) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
"DELETE FROM "+quoteIdentifier(s.eventIDTable())+" WHERE id = ? AND event_created_at = ?",
|
||||
event.ID,
|
||||
event.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) releaseEventIDSafe(parent context.Context, event Event) {
|
||||
cleanupCtx, cancel := sqlCleanupCtx(parent)
|
||||
defer cancel()
|
||||
_ = s.releaseEventID(cleanupCtx, event)
|
||||
}
|
||||
|
||||
func scanEvents(rows SQLRows) (events []Event, err error) {
|
||||
if rows == nil {
|
||||
return nil, ErrInvalidSQLRows
|
||||
}
|
||||
defer closeSQLRowsErr(rows, &err)
|
||||
for rows.Next() {
|
||||
event, err := scanEvent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func closeSQLRowsErr(rows SQLRows, err *error) {
|
||||
if rows == nil || err == nil {
|
||||
return
|
||||
}
|
||||
if closeErr := rows.Close(); closeErr != nil && *err == nil {
|
||||
*err = closeErr
|
||||
}
|
||||
}
|
||||
|
||||
func scanEvent(rows SQLRows) (Event, error) {
|
||||
var event Event
|
||||
var aggregateID sql.NullString
|
||||
var idempotencyKey sql.NullString
|
||||
var payload string
|
||||
var headers sql.NullString
|
||||
var publishedAt sql.NullTime
|
||||
var lastError sql.NullString
|
||||
if err := rows.Scan(
|
||||
&event.ID,
|
||||
&event.Stream,
|
||||
&event.Type,
|
||||
&aggregateID,
|
||||
&idempotencyKey,
|
||||
&payload,
|
||||
&headers,
|
||||
&event.Status,
|
||||
&event.Attempts,
|
||||
&event.AvailableAt,
|
||||
&event.CreatedAt,
|
||||
&event.UpdatedAt,
|
||||
&publishedAt,
|
||||
&lastError,
|
||||
); err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
if aggregateID.Valid {
|
||||
event.AggregateID = aggregateID.String
|
||||
}
|
||||
if idempotencyKey.Valid {
|
||||
event.IdempotencyKey = idempotencyKey.String
|
||||
}
|
||||
event.Payload = json.RawMessage(payload)
|
||||
if headers.Valid && strings.TrimSpace(headers.String) != "" {
|
||||
if err := json.Unmarshal([]byte(headers.String), &event.Headers); err != nil {
|
||||
return Event{}, fmt.Errorf("decode event headers: %w", err)
|
||||
}
|
||||
}
|
||||
if publishedAt.Valid {
|
||||
event.PublishedAt = publishedAt.Time
|
||||
}
|
||||
if lastError.Valid {
|
||||
event.LastError = lastError.String
|
||||
}
|
||||
return cloneEvent(event), nil
|
||||
}
|
||||
|
||||
func eventColumns() string {
|
||||
return "id, stream, event_type, aggregate_id, idempotency_key, payload_json, headers_json, status, attempts, available_at, created_at, updated_at, published_at, last_error"
|
||||
}
|
||||
|
||||
func prefixedEventColumns(prefix string) string {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return eventColumns()
|
||||
}
|
||||
columns := strings.Split(eventColumns(), ", ")
|
||||
for idx, column := range columns {
|
||||
columns[idx] = prefix + "." + column
|
||||
}
|
||||
return strings.Join(columns, ", ")
|
||||
}
|
||||
|
||||
func (s *SQLStore) reserveIdempotency(ctx context.Context, event Event) error {
|
||||
createdAt := event.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Now().UTC()
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
"INSERT INTO "+quoteIdentifier(s.idempotencyTable())+" (idempotency_key, event_id, event_created_at, created_at) VALUES (?, ?, ?, ?)",
|
||||
event.IdempotencyKey,
|
||||
event.ID,
|
||||
event.CreatedAt,
|
||||
createdAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) releaseIdempotency(ctx context.Context, event Event) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
"DELETE FROM "+quoteIdentifier(s.idempotencyTable())+" WHERE idempotency_key = ? AND event_id = ?",
|
||||
event.IdempotencyKey,
|
||||
event.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) releaseIdemSafe(parent context.Context, event Event) {
|
||||
cleanupCtx, cancel := sqlCleanupCtx(parent)
|
||||
defer cancel()
|
||||
_ = s.releaseIdempotency(cleanupCtx, event)
|
||||
}
|
||||
|
||||
func sqlCleanupCtx(parent context.Context) (context.Context, context.CancelFunc) {
|
||||
base := context.Background()
|
||||
if parent != nil {
|
||||
base = context.WithoutCancel(parent)
|
||||
}
|
||||
return context.WithTimeout(base, defPubStateTimeout)
|
||||
}
|
||||
|
||||
func (s *SQLStore) idempotencyTable() string {
|
||||
if s == nil || strings.TrimSpace(s.table) == "" {
|
||||
return defaultSQLTable + "_idempotency"
|
||||
}
|
||||
return s.table + "_idempotency"
|
||||
}
|
||||
|
||||
func (s *SQLStore) eventIDTable() string {
|
||||
if s == nil || strings.TrimSpace(s.table) == "" {
|
||||
return defaultSQLTable + "_ids"
|
||||
}
|
||||
return s.table + "_ids"
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func nullableTime(value time.Time) any {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return value.UTC()
|
||||
}
|
||||
|
||||
func nullableJSON(value map[string]string) (any, error) {
|
||||
if len(value) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func checkAffected(result sql.Result) error {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrEventNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) execClaimUpdate(ctx context.Context, claimDeadline time.Time, query string, claimQuery string, args ...any) (sql.Result, error) {
|
||||
if claimDeadline.IsZero() {
|
||||
return s.db.ExecContext(ctx, query, args...)
|
||||
}
|
||||
// 带 claimDeadline 的更新只允许当前 claim 持有者写状态;RowsAffected=0 表示 claim 已丢失。
|
||||
claimArgs := append(append([]any(nil), args...), StatusProcessing, claimDeadline.UTC())
|
||||
return s.db.ExecContext(ctx, claimQuery, claimArgs...)
|
||||
}
|
||||
|
||||
func checkClaimAffected(result sql.Result, claimDeadline time.Time) error {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
// claim 过期后其他 worker 可能重新领取;此时旧 worker 的状态更新必须被拒绝。
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
if claimDeadline.IsZero() {
|
||||
return ErrEventNotFound
|
||||
}
|
||||
return ErrClaimLost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeTable(table string) (string, error) {
|
||||
table = strings.TrimSpace(table)
|
||||
if table == "" {
|
||||
table = defaultSQLTable
|
||||
}
|
||||
if !sqlTablePattern.MatchString(table) {
|
||||
return "", ErrInvalidTable
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
func quoteIdentifier(identifier string) string {
|
||||
return "`" + strings.ReplaceAll(identifier, "`", "``") + "`"
|
||||
}
|
||||
|
||||
func placeholders(count int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
if count == 1 {
|
||||
return "?"
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < count; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteByte('?')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var sqlTablePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
func defaultPartStart() time.Time {
|
||||
start, err := time.Parse("2006-01-02", defPartStartYMD)
|
||||
if err != nil {
|
||||
return time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
return start.UTC()
|
||||
}
|
||||
|
||||
func monthlyPartitions(start time.Time, months int) string {
|
||||
if months <= 0 {
|
||||
months = defPartMonths
|
||||
}
|
||||
start = time.Date(start.UTC().Year(), start.UTC().Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
var b strings.Builder
|
||||
for idx := 0; idx < months; idx++ {
|
||||
lessThan := start.AddDate(0, idx+1, 0)
|
||||
partitionMonth := start.AddDate(0, idx, 0)
|
||||
if idx > 0 {
|
||||
b.WriteString(",\n")
|
||||
}
|
||||
fmt.Fprintf(&b, " PARTITION p%04d%02d VALUES LESS THAN ('%04d-%02d-01')",
|
||||
partitionMonth.Year(), partitionMonth.Month(),
|
||||
lessThan.Year(), lessThan.Month(),
|
||||
)
|
||||
}
|
||||
b.WriteString(",\n PARTITION pmax VALUES LESS THAN (MAXVALUE)")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package eventlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrPublishWorkerConfig 表示发布 worker 配置缺少必要字段。
|
||||
ErrPublishWorkerConfig = errors.New("eventlog publish worker config is invalid")
|
||||
|
||||
// ErrPublishWorkerStopping 表示 worker 正在停机,不能再次启动。
|
||||
ErrPublishWorkerStopping = errors.New("eventlog publish worker is stopping")
|
||||
)
|
||||
|
||||
// PublishWorkerOptions 描述后台发布 worker 的日志、发布器和重试节奏。
|
||||
type PublishWorkerOptions struct {
|
||||
Name string
|
||||
Log *Log
|
||||
Publisher Publisher
|
||||
Interval time.Duration
|
||||
Limit int
|
||||
RetryDelay time.Duration
|
||||
ClaimTimeout time.Duration
|
||||
MaxAttempts int
|
||||
ExcludeStreams []string
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// PublishWorker 周期性领取 eventlog 待发布事件并调用 Publisher。
|
||||
type PublishWorker struct {
|
||||
name string
|
||||
log *Log
|
||||
publisher Publisher
|
||||
interval time.Duration
|
||||
limit int
|
||||
retryDelay time.Duration
|
||||
claimTimeout time.Duration
|
||||
maxAttempts int
|
||||
excludeStreams []string
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stopping bool
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
stopped chan struct{}
|
||||
}
|
||||
|
||||
// NewPublishWorker 创建后台发布 worker 并补齐默认名称、间隔和重试延迟。
|
||||
func NewPublishWorker(options PublishWorkerOptions) (*PublishWorker, error) {
|
||||
name := options.Name
|
||||
if name == "" {
|
||||
name = "eventlog-publisher"
|
||||
}
|
||||
if options.Log == nil {
|
||||
return nil, fmt.Errorf("%w: log is required", ErrPublishWorkerConfig)
|
||||
}
|
||||
if options.Publisher == nil {
|
||||
return nil, fmt.Errorf("%w: publisher is required", ErrPublishWorkerConfig)
|
||||
}
|
||||
interval := options.Interval
|
||||
if interval <= 0 {
|
||||
interval = time.Second
|
||||
}
|
||||
retryDelay := options.RetryDelay
|
||||
if retryDelay <= 0 {
|
||||
retryDelay = time.Second
|
||||
}
|
||||
return &PublishWorker{
|
||||
name: name,
|
||||
log: options.Log,
|
||||
publisher: options.Publisher,
|
||||
interval: interval,
|
||||
limit: options.Limit,
|
||||
retryDelay: retryDelay,
|
||||
claimTimeout: options.ClaimTimeout,
|
||||
maxAttempts: options.MaxAttempts,
|
||||
excludeStreams: normalizeStreamList(options.ExcludeStreams),
|
||||
logger: options.Logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name 返回 worker 名称,空实例返回默认名称。
|
||||
func (w *PublishWorker) Name() string {
|
||||
if w == nil || w.name == "" {
|
||||
return "eventlog-publisher"
|
||||
}
|
||||
return w.name
|
||||
}
|
||||
|
||||
// Start 启动后台发布循环;重复启动保持幂等。
|
||||
func (w *PublishWorker) Start(ctx context.Context) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if w == nil || w.log == nil || w.publisher == nil {
|
||||
return ErrPublishWorkerConfig
|
||||
}
|
||||
w.mu.Lock()
|
||||
if w.running {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if w.stopping {
|
||||
w.mu.Unlock()
|
||||
return ErrPublishWorkerStopping
|
||||
}
|
||||
// Worker 使用独立后台 context,不继承启动请求的生命周期。
|
||||
// 停机时必须走 Stop,确保当前发布循环有机会收敛。
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
w.done = make(chan struct{})
|
||||
w.stopped = make(chan struct{})
|
||||
w.running = true
|
||||
w.cancel = cancel
|
||||
done := w.done
|
||||
stopped := w.stopped
|
||||
w.mu.Unlock()
|
||||
|
||||
go w.run(runCtx, done, stopped)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 请求后台发布循环退出并等待收敛。
|
||||
func (w *PublishWorker) Stop(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
w.mu.Lock()
|
||||
if !w.running && !w.stopping {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
done := w.done
|
||||
stopped := w.stopped
|
||||
cancel := w.cancel
|
||||
shouldSignal := w.running
|
||||
if shouldSignal {
|
||||
w.cancel = nil
|
||||
w.running = false
|
||||
w.stopping = true
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
if shouldSignal && done != nil {
|
||||
close(done)
|
||||
}
|
||||
if stopped == nil {
|
||||
return nil
|
||||
}
|
||||
// Stop 等待 run 退出;超时后取消后台 context,避免进程关停被永久卡住。
|
||||
select {
|
||||
case <-stopped:
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
w.clearStopped(stopped)
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *PublishWorker) run(ctx context.Context, done <-chan struct{}, stopped chan struct{}) {
|
||||
defer func() {
|
||||
close(stopped)
|
||||
w.clearStopped(stopped)
|
||||
}()
|
||||
// 启动后先跑一轮,避免 outbox 事件必须等到下一个 tick 才发布。
|
||||
w.publishOnce(ctx)
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.publishOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *PublishWorker) clearStopped(stopped chan struct{}) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.stopped != stopped {
|
||||
return
|
||||
}
|
||||
w.running = false
|
||||
w.stopping = false
|
||||
w.cancel = nil
|
||||
w.done = nil
|
||||
w.stopped = nil
|
||||
}
|
||||
|
||||
func (w *PublishWorker) publishOnce(ctx context.Context) {
|
||||
// PublishPending 内部负责 claim、发布、重试、死信;Worker 只负责节拍和日志。
|
||||
stats, err := w.log.PublishPending(ctx, w.publisher, PublishOptions{
|
||||
Limit: w.limit,
|
||||
RetryDelay: w.retryDelay,
|
||||
ClaimTimeout: w.claimTimeout,
|
||||
MaxAttempts: w.maxAttempts,
|
||||
ExcludeStreams: w.excludeStreams,
|
||||
})
|
||||
if err != nil && w.logger != nil {
|
||||
w.logger.Error("eventlog publish failed", "worker", w.Name(), "fetched", stats.Fetched, "published", stats.Published, "failed", stats.Failed, "dead_lettered", stats.DeadLettered, "error", err)
|
||||
return
|
||||
}
|
||||
if stats.Published > 0 && w.logger != nil {
|
||||
w.logger.Info("eventlog published events", "worker", w.Name(), "published", stats.Published, "failed", stats.Failed, "dead_lettered", stats.DeadLettered)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user