DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+161
View File
@@ -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
}
+5
View File
@@ -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()
}
+223
View File
@@ -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)
}
}