Files
junhong_cmp_fiber/internal/infrastructure/messaging/outbox/relay_integration_test.go
break 17782d5f8e
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
实现七月迭代公共技术基础
2026-07-23 17:52:48 +09:00

254 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
}
}