实现七月迭代公共技术基础
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
This commit is contained in:
326
internal/infrastructure/messaging/outbox/relay.go
Normal file
326
internal/infrastructure/messaging/outbox/relay.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
)
|
||||
|
||||
// DeliveryEnvelope 是 Relay 原样传播到 Asynq 的公共结构化信封。
|
||||
type DeliveryEnvelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
PayloadVersion int `json:"payload_version"`
|
||||
AggregateType string `json:"aggregate_type"`
|
||||
AggregateID string `json:"aggregate_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
BusinessKey string `json:"business_key,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
Payload sonic.NoCopyRawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// Publisher 是 Relay 的队列边界。
|
||||
type Publisher interface {
|
||||
Publish(ctx context.Context, envelope DeliveryEnvelope) error
|
||||
}
|
||||
|
||||
// PermanentError 表示重试无法修复的投递错误。
|
||||
type PermanentError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// Error 返回安全的错误文本,仅供内部日志和错误链判断使用。
|
||||
func (e *PermanentError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
// Unwrap 返回原始错误。
|
||||
func (e *PermanentError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
// Permanent 将不可恢复错误标记为永久失败,Relay 会直接保留最终失败事实。
|
||||
func Permanent(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &PermanentError{err: err}
|
||||
}
|
||||
|
||||
// QueuePublisher 使用项目统一队列客户端发布结构化信封。
|
||||
type QueuePublisher struct {
|
||||
client *queue.Client
|
||||
}
|
||||
|
||||
// NewQueuePublisher 创建项目统一队列客户端 Adapter。
|
||||
func NewQueuePublisher(client *queue.Client) *QueuePublisher {
|
||||
return &QueuePublisher{client: client}
|
||||
}
|
||||
|
||||
// Publish 将公共信封作为 struct 入队,禁止调用方预序列化。
|
||||
func (p *QueuePublisher) Publish(ctx context.Context, envelope DeliveryEnvelope) error {
|
||||
return p.client.EnqueueTask(ctx, constants.TaskTypeOutboxDeliver, envelope)
|
||||
}
|
||||
|
||||
// RelayOptions 控制单个 Relay 实例的领取与重试行为。
|
||||
type RelayOptions struct {
|
||||
Owner string
|
||||
BatchSize int
|
||||
LeaseDuration time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Relay 完成公共 Outbox 的租约领取和至少一次队列投递。
|
||||
type Relay struct {
|
||||
db *gorm.DB
|
||||
publisher Publisher
|
||||
logger *zap.Logger
|
||||
options RelayOptions
|
||||
}
|
||||
|
||||
// NewRelay 创建公共 Outbox Relay。
|
||||
func NewRelay(db *gorm.DB, publisher Publisher, logger *zap.Logger, options RelayOptions) (*Relay, error) {
|
||||
if db == nil || publisher == nil || options.Owner == "" {
|
||||
return nil, stderrors.New("Outbox Relay 依赖和租约所有者不能为空")
|
||||
}
|
||||
if options.BatchSize <= 0 {
|
||||
options.BatchSize = constants.OutboxDefaultBatchSize
|
||||
}
|
||||
if options.LeaseDuration <= 0 {
|
||||
options.LeaseDuration = constants.OutboxDefaultLeaseDuration
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &Relay{db: db, publisher: publisher, logger: logger, options: options}, nil
|
||||
}
|
||||
|
||||
// ProcessBatch 领取并投递一批到期事件。
|
||||
func (r *Relay) ProcessBatch(ctx context.Context) (int, error) {
|
||||
events, err := r.ClaimBatch(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
processed := 0
|
||||
for _, event := range events {
|
||||
if err := r.publisher.Publish(ctx, deliveryEnvelope(event)); err != nil {
|
||||
var permanentError *PermanentError
|
||||
permanent := stderrors.As(err, &permanentError)
|
||||
code := "OUTBOX_ENQUEUE_FAILED"
|
||||
summary := "队列暂时不可用"
|
||||
if permanent {
|
||||
code = "OUTBOX_PERMANENT_FAILURE"
|
||||
summary = "事件无法投递,已停止自动重试"
|
||||
}
|
||||
if failErr := r.markFailed(ctx, event, code, summary, permanent); failErr != nil {
|
||||
return processed, failErr
|
||||
}
|
||||
r.logger.Warn("Outbox 事件投递失败",
|
||||
zap.String("event_id", event.EventID), zap.String("correlation_id", event.CorrelationID),
|
||||
zap.String("error_code", code), zap.Bool("permanent", permanent))
|
||||
continue
|
||||
}
|
||||
if err := r.MarkDelivered(ctx, event.ID); err != nil {
|
||||
// 入队成功但标记失败时保留租约,过期后会使用同一 event_id 再次投递。
|
||||
return processed, err
|
||||
}
|
||||
processed++
|
||||
}
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
// ClaimBatch 通过行锁跳过竞争行,并领取待投递或租约过期事件。
|
||||
func (r *Relay) ClaimBatch(ctx context.Context) ([]model.OutboxEvent, error) {
|
||||
now := r.options.Now().UTC()
|
||||
expiresAt := now.Add(r.options.LeaseDuration)
|
||||
claimed := make([]model.OutboxEvent, 0, r.options.BatchSize)
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var candidates []model.OutboxEvent
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
||||
Where("(status = ? AND next_attempt_at <= ?) OR (status = ? AND lease_expires_at <= ?)",
|
||||
constants.OutboxStatusPending, now, constants.OutboxStatusDelivering, now).
|
||||
Order("created_at ASC, id ASC").Limit(r.options.BatchSize).Find(&candidates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range candidates {
|
||||
result := tx.Model(&model.OutboxEvent{}).
|
||||
Where("id = ? AND ((status = ? AND next_attempt_at <= ?) OR (status = ? AND lease_expires_at <= ?))",
|
||||
candidates[index].ID, constants.OutboxStatusPending, now, constants.OutboxStatusDelivering, now).
|
||||
Updates(map[string]any{
|
||||
"status": constants.OutboxStatusDelivering, "lease_owner": r.options.Owner,
|
||||
"lease_expires_at": expiresAt, "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 1 {
|
||||
candidates[index].Status = constants.OutboxStatusDelivering
|
||||
candidates[index].LeaseOwner = &r.options.Owner
|
||||
candidates[index].LeaseExpiresAt = &expiresAt
|
||||
claimed = append(claimed, candidates[index])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return claimed, err
|
||||
}
|
||||
|
||||
// RenewLease 仅允许当前租约所有者续租投递中的事件。
|
||||
func (r *Relay) RenewLease(ctx context.Context, eventID uint) (bool, error) {
|
||||
now := r.options.Now().UTC()
|
||||
result := r.db.WithContext(ctx).Model(&model.OutboxEvent{}).
|
||||
Where("id = ? AND status = ? AND lease_owner = ? AND lease_expires_at > ?",
|
||||
eventID, constants.OutboxStatusDelivering, r.options.Owner, now).
|
||||
Updates(map[string]any{"lease_expires_at": now.Add(r.options.LeaseDuration), "updated_at": now})
|
||||
return result.RowsAffected == 1, result.Error
|
||||
}
|
||||
|
||||
// MarkDelivered 仅允许当前租约所有者把事件标记为已投递。
|
||||
func (r *Relay) MarkDelivered(ctx context.Context, eventID uint) error {
|
||||
now := r.options.Now().UTC()
|
||||
result := r.db.WithContext(ctx).Model(&model.OutboxEvent{}).
|
||||
Where("id = ? AND status = ? AND lease_owner = ?", eventID, constants.OutboxStatusDelivering, r.options.Owner).
|
||||
Updates(map[string]any{
|
||||
"status": constants.OutboxStatusDelivered, "delivered_at": now,
|
||||
"lease_owner": nil, "lease_expires_at": nil, "last_error_code": "",
|
||||
"last_error_summary": "", "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return stderrors.New("Outbox 投递完成条件不满足")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkFailed 记录安全错误并退避;达到上限后保留最终失败事实。
|
||||
func (r *Relay) MarkFailed(ctx context.Context, event model.OutboxEvent, code, summary string) error {
|
||||
return r.markFailed(ctx, event, code, summary, false)
|
||||
}
|
||||
|
||||
func (r *Relay) markFailed(ctx context.Context, event model.OutboxEvent, code, summary string, permanent bool) error {
|
||||
now := r.options.Now().UTC()
|
||||
retryCount := event.RetryCount + 1
|
||||
status := constants.OutboxStatusPending
|
||||
nextAttemptAt := now.Add(backoff(retryCount))
|
||||
if permanent || retryCount >= event.MaxRetries {
|
||||
status = constants.OutboxStatusFailed
|
||||
nextAttemptAt = now
|
||||
r.logger.Error("Outbox 事件停止自动重试",
|
||||
zap.String("event_id", event.EventID), zap.String("correlation_id", event.CorrelationID),
|
||||
zap.String("error_code", code), zap.Bool("permanent", permanent))
|
||||
}
|
||||
result := r.db.WithContext(ctx).Model(&model.OutboxEvent{}).
|
||||
Where("id = ? AND status = ? AND lease_owner = ?", event.ID, constants.OutboxStatusDelivering, r.options.Owner).
|
||||
Updates(map[string]any{
|
||||
"status": status, "retry_count": retryCount, "next_attempt_at": nextAttemptAt,
|
||||
"lease_owner": nil, "lease_expires_at": nil, "last_error_code": code,
|
||||
"last_error_summary": summary, "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return stderrors.New("Outbox 失败更新条件不满足")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deliveryEnvelope(event model.OutboxEvent) DeliveryEnvelope {
|
||||
return DeliveryEnvelope{
|
||||
EventID: event.EventID, EventType: event.EventType, PayloadVersion: event.PayloadVersion,
|
||||
AggregateType: event.AggregateType, AggregateID: event.AggregateID,
|
||||
ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey,
|
||||
RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
Payload: sonic.NoCopyRawMessage(event.Payload),
|
||||
}
|
||||
}
|
||||
|
||||
func backoff(retryCount int) time.Duration {
|
||||
delay := float64(constants.OutboxBaseRetryDelay) * math.Pow(2, float64(retryCount-1))
|
||||
if delay > float64(constants.OutboxMaxRetryDelay) {
|
||||
return constants.OutboxMaxRetryDelay
|
||||
}
|
||||
return time.Duration(delay)
|
||||
}
|
||||
|
||||
// EventConsumer 是业务消费者公开实现的事件处理边界。
|
||||
type EventConsumer interface {
|
||||
Consume(ctx context.Context, envelope DeliveryEnvelope) error
|
||||
}
|
||||
|
||||
// ConsumerRegistry 按稳定事件类型分发到业务消费者。
|
||||
type ConsumerRegistry struct {
|
||||
mu sync.RWMutex
|
||||
consumers map[string]EventConsumer
|
||||
}
|
||||
|
||||
// NewConsumerRegistry 创建空消费者注册表。
|
||||
func NewConsumerRegistry() *ConsumerRegistry {
|
||||
return &ConsumerRegistry{consumers: map[string]EventConsumer{}}
|
||||
}
|
||||
|
||||
// Register 注册一个由业务 PRD 拥有的事件消费者。
|
||||
func (r *ConsumerRegistry) Register(eventType string, consumer EventConsumer) error {
|
||||
if eventType == "" || consumer == nil {
|
||||
return stderrors.New("Outbox 消费者注册信息不完整")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, exists := r.consumers[eventType]; exists {
|
||||
return stderrors.New("Outbox 事件类型重复注册")
|
||||
}
|
||||
r.consumers[eventType] = consumer
|
||||
return nil
|
||||
}
|
||||
|
||||
// Consume 将公共信封交给对应业务消费者;公共层不实现业务副作用。
|
||||
func (r *ConsumerRegistry) Consume(ctx context.Context, envelope DeliveryEnvelope) error {
|
||||
r.mu.RLock()
|
||||
consumer := r.consumers[envelope.EventType]
|
||||
r.mu.RUnlock()
|
||||
if consumer == nil {
|
||||
return stderrors.New("Outbox 事件消费者尚未注册")
|
||||
}
|
||||
return consumer.Consume(ctx, envelope)
|
||||
}
|
||||
|
||||
// Handler 是公共 Outbox Asynq 任务 Handler。
|
||||
type Handler struct {
|
||||
consumer EventConsumer
|
||||
}
|
||||
|
||||
// NewHandler 创建公共 Outbox Asynq Handler。
|
||||
func NewHandler(consumer EventConsumer) *Handler {
|
||||
return &Handler{consumer: consumer}
|
||||
}
|
||||
|
||||
// Handle 解析结构化信封并调用公开消费者边界。
|
||||
func (h *Handler) Handle(ctx context.Context, task *asynq.Task) error {
|
||||
var envelope DeliveryEnvelope
|
||||
if err := sonic.Unmarshal(task.Payload(), &envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
if envelope.EventID == "" || envelope.EventType == "" || envelope.PayloadVersion <= 0 {
|
||||
return stderrors.New("Outbox 事件信封不完整")
|
||||
}
|
||||
return h.consumer.Consume(ctx, envelope)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package outbox_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
)
|
||||
|
||||
type recordingPublisher struct {
|
||||
mu sync.Mutex
|
||||
envelopes []outbox.DeliveryEnvelope
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *recordingPublisher) Publish(_ context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.envelopes = append(p.envelopes, envelope)
|
||||
return p.err
|
||||
}
|
||||
|
||||
func TestExpiredLeaseRedeliversOriginalEnvelope(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
event, err := repository.Append(context.Background(), db, newEnvelope("event-stable", "business-stable"))
|
||||
if err != nil {
|
||||
t.Fatalf("准备 Outbox 事件失败:%v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
firstPublisher := &recordingPublisher{}
|
||||
first, err := outbox.NewRelay(db, firstPublisher, zap.NewNop(), outbox.RelayOptions{
|
||||
Owner: "relay-a", BatchSize: 1, LeaseDuration: time.Second, Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建首个 Relay 失败:%v", err)
|
||||
}
|
||||
claimed, err := first.ClaimBatch(context.Background())
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("首个 Relay 领取失败:%v,数量:%d", err, len(claimed))
|
||||
}
|
||||
// 模拟入队成功但数据库标记前崩溃:公开队列信封已经可观察,但租约没有完成。
|
||||
if err := firstPublisher.Publish(context.Background(), deliveryFromModel(claimed[0])); err != nil {
|
||||
t.Fatalf("模拟首次入队失败:%v", err)
|
||||
}
|
||||
|
||||
now = now.Add(2 * time.Second)
|
||||
secondPublisher := &recordingPublisher{}
|
||||
second, err := outbox.NewRelay(db, secondPublisher, zap.NewNop(), outbox.RelayOptions{
|
||||
Owner: "relay-b", BatchSize: 1, LeaseDuration: time.Minute, Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建恢复 Relay 失败:%v", err)
|
||||
}
|
||||
processed, err := second.ProcessBatch(context.Background())
|
||||
if err != nil || processed != 1 {
|
||||
t.Fatalf("恢复投递失败:%v,数量:%d", err, processed)
|
||||
}
|
||||
if len(firstPublisher.envelopes) != 1 || len(secondPublisher.envelopes) != 1 {
|
||||
t.Fatalf("至少一次投递次数不正确:%d/%d", len(firstPublisher.envelopes), len(secondPublisher.envelopes))
|
||||
}
|
||||
firstEnvelope := firstPublisher.envelopes[0]
|
||||
secondEnvelope := secondPublisher.envelopes[0]
|
||||
if firstEnvelope.EventID != event.EventID || secondEnvelope.EventID != event.EventID ||
|
||||
firstEnvelope.CorrelationID != secondEnvelope.CorrelationID || string(firstEnvelope.Payload) != string(secondEnvelope.Payload) {
|
||||
t.Fatalf("恢复投递改变了事件身份或载荷:%+v / %+v", firstEnvelope, secondEnvelope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayFailureBackoffAndFinalFailure(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
event, err := repository.Append(context.Background(), db, newEnvelope("event-retry", "business-retry"))
|
||||
if err != nil {
|
||||
t.Fatalf("准备 Outbox 事件失败:%v", err)
|
||||
}
|
||||
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Update("max_retries", 2).Error; err != nil {
|
||||
t.Fatalf("设置最大重试次数失败:%v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
publisher := &recordingPublisher{err: stderrors.New("测试队列失败")}
|
||||
relay, err := outbox.NewRelay(db, publisher, zap.NewNop(), outbox.RelayOptions{
|
||||
Owner: "relay-retry", BatchSize: 1, LeaseDuration: time.Minute, Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建 Relay 失败:%v", err)
|
||||
}
|
||||
if _, err := relay.ProcessBatch(context.Background()); err != nil {
|
||||
t.Fatalf("记录首次失败失败:%v", err)
|
||||
}
|
||||
var afterFirst model.OutboxEvent
|
||||
if err := db.First(&afterFirst, event.ID).Error; err != nil {
|
||||
t.Fatalf("读取首次失败事实失败:%v", err)
|
||||
}
|
||||
if afterFirst.Status != constants.OutboxStatusPending || afterFirst.RetryCount != 1 || !afterFirst.NextAttemptAt.After(now) {
|
||||
t.Fatalf("首次失败未按退避重试:%+v", afterFirst)
|
||||
}
|
||||
|
||||
now = afterFirst.NextAttemptAt
|
||||
if _, err := relay.ProcessBatch(context.Background()); err != nil {
|
||||
t.Fatalf("记录最终失败失败:%v", err)
|
||||
}
|
||||
var final model.OutboxEvent
|
||||
if err := db.First(&final, event.ID).Error; err != nil {
|
||||
t.Fatalf("读取最终失败事实失败:%v", err)
|
||||
}
|
||||
if final.Status != constants.OutboxStatusFailed || final.RetryCount != 2 || final.LastErrorSummary != "队列暂时不可用" {
|
||||
t.Fatalf("最终失败事实不正确:%+v", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayPermanentFailureStopsRetryImmediately(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
event, err := repository.Append(context.Background(), db, newEnvelope("event-permanent", "business-permanent"))
|
||||
if err != nil {
|
||||
t.Fatalf("准备永久失败事件失败:%v", err)
|
||||
}
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
publisher := &recordingPublisher{err: outbox.Permanent(stderrors.New("载荷版本不受支持"))}
|
||||
relay, err := outbox.NewRelay(db, publisher, zap.NewNop(), outbox.RelayOptions{
|
||||
Owner: "relay-permanent", BatchSize: 1, LeaseDuration: time.Minute, Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建 Relay 失败:%v", err)
|
||||
}
|
||||
if _, err := relay.ProcessBatch(context.Background()); err != nil {
|
||||
t.Fatalf("记录永久失败事实失败:%v", err)
|
||||
}
|
||||
var failed model.OutboxEvent
|
||||
if err := db.First(&failed, event.ID).Error; err != nil {
|
||||
t.Fatalf("读取永久失败事实失败:%v", err)
|
||||
}
|
||||
if failed.Status != constants.OutboxStatusFailed || failed.RetryCount != 1 || failed.LastErrorCode != "OUTBOX_PERMANENT_FAILURE" {
|
||||
t.Fatalf("永久失败未立即停止重试:%+v", failed)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingConsumer struct {
|
||||
envelope outbox.DeliveryEnvelope
|
||||
}
|
||||
|
||||
func (c *recordingConsumer) Consume(_ context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
c.envelope = envelope
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPublicAsynqHandlerObservesStructuredEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
envelope := outbox.DeliveryEnvelope{
|
||||
EventID: "event-handler", EventType: "foundation.example.created", PayloadVersion: 1,
|
||||
CorrelationID: "correlation-handler", Payload: sonic.NoCopyRawMessage(`{"visible":true}`),
|
||||
}
|
||||
payload, err := sonic.Marshal(envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化公开信封失败:%v", err)
|
||||
}
|
||||
consumer := &recordingConsumer{}
|
||||
handler := outbox.NewHandler(consumer)
|
||||
if err := handler.Handle(context.Background(), asynq.NewTask(constants.TaskTypeOutboxDeliver, payload)); err != nil {
|
||||
t.Fatalf("公开 Handler 处理失败:%v", err)
|
||||
}
|
||||
if consumer.envelope.EventID != envelope.EventID || consumer.envelope.CorrelationID != envelope.CorrelationID {
|
||||
t.Fatalf("公开 Handler 未原样传播身份:%+v", consumer.envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresRelayRedisAsynqAndPublicHandlerChain(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
|
||||
repository := outbox.NewRepository()
|
||||
event, err := repository.Append(context.Background(), db, newEnvelope("event-real-chain", "business-real-chain"))
|
||||
if err != nil {
|
||||
t.Fatalf("准备真实链路事件失败:%v", err)
|
||||
}
|
||||
queueClient := queue.NewClient(redisClient, zap.NewNop())
|
||||
t.Cleanup(func() { _ = queueClient.Close() })
|
||||
publisher := outbox.NewQueuePublisher(queueClient)
|
||||
relay, err := outbox.NewRelay(db, publisher, zap.NewNop(), outbox.RelayOptions{Owner: "relay-real-chain", BatchSize: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("创建真实链路 Relay 失败:%v", err)
|
||||
}
|
||||
processed, err := relay.ProcessBatch(context.Background())
|
||||
if err != nil || processed != 1 {
|
||||
t.Fatalf("真实链路投递失败:%v,数量:%d", err, processed)
|
||||
}
|
||||
|
||||
options := redisClient.Options()
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{Addr: options.Addr, Password: options.Password, DB: options.DB})
|
||||
t.Cleanup(func() { _ = inspector.Close() })
|
||||
tasks, err := inspector.ListPendingTasks(constants.QueueOutboxDeliver, asynq.PageSize(1000))
|
||||
if err != nil {
|
||||
t.Fatalf("检查 Asynq 待处理任务失败:%v", err)
|
||||
}
|
||||
var matched *asynq.TaskInfo
|
||||
for _, info := range tasks {
|
||||
var queued outbox.DeliveryEnvelope
|
||||
if sonic.Unmarshal(info.Payload, &queued) == nil && queued.EventID == event.EventID {
|
||||
matched = info
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
t.Fatal("真实 Asynq 队列中未找到本次公共事件")
|
||||
}
|
||||
t.Cleanup(func() { _ = inspector.DeleteTask(matched.Queue, matched.ID) })
|
||||
|
||||
consumer := &recordingConsumer{}
|
||||
handler := outbox.NewHandler(consumer)
|
||||
if err := handler.Handle(context.Background(), asynq.NewTask(matched.Type, matched.Payload)); err != nil {
|
||||
t.Fatalf("公开 Handler 处理真实队列载荷失败:%v", err)
|
||||
}
|
||||
if consumer.envelope.EventID != event.EventID || consumer.envelope.CorrelationID != event.CorrelationID {
|
||||
t.Fatalf("真实链路未原样传播事件身份:%+v", consumer.envelope)
|
||||
}
|
||||
|
||||
var delivered model.OutboxEvent
|
||||
if err := db.First(&delivered, event.ID).Error; err != nil {
|
||||
t.Fatalf("读取已投递事件失败:%v", err)
|
||||
}
|
||||
if delivered.Status != constants.OutboxStatusDelivered || delivered.DeliveredAt == nil {
|
||||
t.Fatalf("真实链路未完成 Outbox 状态:%+v", delivered)
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryFromModel(event model.OutboxEvent) outbox.DeliveryEnvelope {
|
||||
return outbox.DeliveryEnvelope{
|
||||
EventID: event.EventID, EventType: event.EventType, PayloadVersion: event.PayloadVersion,
|
||||
AggregateType: event.AggregateType, AggregateID: event.AggregateID,
|
||||
ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey,
|
||||
RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
Payload: sonic.NoCopyRawMessage(event.Payload),
|
||||
}
|
||||
}
|
||||
78
internal/infrastructure/messaging/outbox/repository.go
Normal file
78
internal/infrastructure/messaging/outbox/repository.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Package outbox 实现公共 Outbox 的持久化与投递基础设施。
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/asynctask"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// Envelope 是业务 UseCase 在事务内追加的公共事件信封。
|
||||
type Envelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
PayloadVersion int `json:"payload_version"`
|
||||
AggregateType string `json:"aggregate_type"`
|
||||
AggregateID string `json:"aggregate_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
BusinessKey string `json:"business_key,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
Payload any `json:"payload"`
|
||||
}
|
||||
|
||||
// Repository 通过调用方显式传入的 GORM 事务句柄追加事件。
|
||||
type Repository struct{}
|
||||
|
||||
// NewRepository 创建公共 Outbox Repository。
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
// Append 在业务事务中持久化稳定事件身份和必要快照。
|
||||
func (r *Repository) Append(ctx context.Context, tx *gorm.DB, envelope Envelope) (*model.OutboxEvent, error) {
|
||||
if tx == nil {
|
||||
return nil, stderrors.New("Outbox 追加必须传入 GORM 事务句柄")
|
||||
}
|
||||
if strings.TrimSpace(envelope.EventType) == "" || strings.TrimSpace(envelope.AggregateType) == "" ||
|
||||
strings.TrimSpace(envelope.AggregateID) == "" || strings.TrimSpace(envelope.ResourceType) == "" ||
|
||||
strings.TrimSpace(envelope.ResourceID) == "" {
|
||||
return nil, stderrors.New("Outbox 事件类型和资源定位不能为空")
|
||||
}
|
||||
if err := asynctask.ValidatePayload(envelope.Payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := sonic.Marshal(envelope.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if envelope.EventID == "" {
|
||||
envelope.EventID = uuid.NewString()
|
||||
}
|
||||
if envelope.PayloadVersion <= 0 {
|
||||
envelope.PayloadVersion = 1
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
event := &model.OutboxEvent{
|
||||
EventID: envelope.EventID, EventType: envelope.EventType, PayloadVersion: envelope.PayloadVersion,
|
||||
AggregateType: envelope.AggregateType, AggregateID: envelope.AggregateID,
|
||||
ResourceType: envelope.ResourceType, ResourceID: envelope.ResourceID, BusinessKey: envelope.BusinessKey,
|
||||
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, Payload: datatypes.JSON(payload),
|
||||
Status: constants.OutboxStatusPending, MaxRetries: constants.OutboxDefaultMaxRetries, NextAttemptAt: now,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(event).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package outbox_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
)
|
||||
|
||||
func TestBusinessFactAndOutboxCommitAndRollbackTogether(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("INSERT INTO test_foundation_business_fact (business_key) VALUES (?)", "fact-success").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := repository.Append(context.Background(), tx, newEnvelope("event-success", "fact-success"))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("提交业务事实和 Outbox 失败:%v", err)
|
||||
}
|
||||
assertTableCount(t, db, "test_foundation_business_fact", 1)
|
||||
assertTableCount(t, db, "tb_outbox_event", 1)
|
||||
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("INSERT INTO test_foundation_business_fact (business_key) VALUES (?)", "fact-rollback").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := repository.Append(context.Background(), tx, newEnvelope("event-rollback", "fact-rollback")); err != nil {
|
||||
return err
|
||||
}
|
||||
return stderrors.New("注入业务失败")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("注入业务失败时事务应回滚")
|
||||
}
|
||||
assertTableCount(t, db, "test_foundation_business_fact", 1)
|
||||
assertTableCount(t, db, "tb_outbox_event", 1)
|
||||
}
|
||||
|
||||
func TestOutboxUniqueFailureRollsBackBusinessFact(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
_, err := repository.Append(context.Background(), tx, newEnvelope("event-duplicate", "first"))
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatalf("准备重复事件失败:%v", err)
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("INSERT INTO test_foundation_business_fact (business_key) VALUES (?)", "must-rollback").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
_, appendErr := repository.Append(context.Background(), tx, newEnvelope("event-duplicate", "second"))
|
||||
return appendErr
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("重复事件 ID 必须导致 Outbox 写入失败")
|
||||
}
|
||||
assertTableCount(t, db, "test_foundation_business_fact", 0)
|
||||
assertTableCount(t, db, "tb_outbox_event", 1)
|
||||
}
|
||||
|
||||
func newEnvelope(eventID, businessKey string) outbox.Envelope {
|
||||
return outbox.Envelope{
|
||||
EventID: eventID, EventType: "foundation.example.created", PayloadVersion: 1,
|
||||
AggregateType: "example", AggregateID: businessKey,
|
||||
ResourceType: "example", ResourceID: businessKey, BusinessKey: businessKey,
|
||||
RequestID: "request-1", CorrelationID: "correlation-1",
|
||||
Payload: struct {
|
||||
BusinessKey string `json:"business_key"`
|
||||
}{BusinessKey: businessKey},
|
||||
}
|
||||
}
|
||||
|
||||
func createTemporaryOutboxTables(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE test_foundation_business_fact (
|
||||
id bigserial PRIMARY KEY, business_key varchar(100) NOT NULL UNIQUE
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建业务事实测试表失败:%v", err)
|
||||
}
|
||||
testutil.CreateTemporaryOutboxTable(t, db)
|
||||
}
|
||||
|
||||
func assertTableCount(t *testing.T, db *gorm.DB, table string, expected int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Table(table).Count(&count).Error; err != nil {
|
||||
t.Fatalf("统计表 %s 失败:%v", table, err)
|
||||
}
|
||||
if count != expected {
|
||||
t.Fatalf("表 %s 行数错误:得到 %d,期望 %d", table, count, expected)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user