2026-05-12 19:34:47 +08:00

237 lines
8.3 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 service
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/services/room-service/internal/room/outbox"
)
const (
// outboxRetryExponentialBackoff 是房间事件补偿的默认策略,避免外部故障时每秒刷同一条错误。
outboxRetryExponentialBackoff = "exponential_backoff"
// outboxWorkerName 固定写入日志,便于后续按 worker 聚合检索。
outboxWorkerName = "room_outbox"
)
// OutboxWorkerOptions 控制 room_outbox pending 事件的持续补偿循环。
type OutboxWorkerOptions struct {
// PollInterval 是两轮 pending 扫描之间的间隔;非正数会被归一到安全默认值。
PollInterval time.Duration
// BatchSize 是单轮最多处理条数;非正数会被归一,避免全表扫描。
BatchSize int
// PublishTimeout 限制单条外部投递耗时shutdown 时当前事件最多阻塞该时长。
PublishTimeout time.Duration
// RetryStrategy 当前只支持 exponential_backoff。
RetryStrategy string
// MaxRetryCount 是转入 failed 前允许的最大失败次数。
MaxRetryCount int
// InitialBackoff 是首次失败后的等待时间。
InitialBackoff time.Duration
// MaxBackoff 是指数退避上限。
MaxBackoff time.Duration
}
func defaultOutboxWorkerOptions() OutboxWorkerOptions {
// 默认值优先避免 busy loop、无限重试和日志污染。
return OutboxWorkerOptions{
PollInterval: time.Second,
BatchSize: 100,
PublishTimeout: 3 * time.Second,
RetryStrategy: outboxRetryExponentialBackoff,
MaxRetryCount: 10,
InitialBackoff: 5 * time.Second,
MaxBackoff: 5 * time.Minute,
}
}
func normalizeOutboxWorkerOptions(options OutboxWorkerOptions) OutboxWorkerOptions {
// worker 层再次归一化,防止测试或手动构造绕过 config.Normalize。
defaults := defaultOutboxWorkerOptions()
if options.PollInterval <= 0 {
// 非正间隔不能直接传给 ticker否则会 panic 或形成忙循环。
options.PollInterval = defaults.PollInterval
}
if options.BatchSize <= 0 {
// 非正批量按安全默认值处理,避免 repository 扫描不受控。
options.BatchSize = defaults.BatchSize
}
if options.PublishTimeout <= 0 {
// 单条外部投递必须有 deadlineshutdown 才能等待有界。
options.PublishTimeout = defaults.PublishTimeout
}
if options.MaxRetryCount <= 0 {
options.MaxRetryCount = defaults.MaxRetryCount
}
if options.InitialBackoff <= 0 {
options.InitialBackoff = defaults.InitialBackoff
}
if options.MaxBackoff <= 0 {
options.MaxBackoff = defaults.MaxBackoff
}
if options.MaxBackoff < options.InitialBackoff {
options.MaxBackoff = options.InitialBackoff
}
options.RetryStrategy = strings.TrimSpace(options.RetryStrategy)
if options.RetryStrategy == "" {
// 空策略按指数退避处理,保持和配置默认值一致。
options.RetryStrategy = defaults.RetryStrategy
}
return options
}
// RunOutboxWorker 持续扫描 pending outbox它不执行房间命令只投递已持久化事件。
func (s *Service) RunOutboxWorker(ctx context.Context, options OutboxWorkerOptions) {
options = normalizeOutboxWorkerOptions(options)
if options.RetryStrategy != outboxRetryExponentialBackoff {
// 配置层会拒绝未知策略;这里兜底避免测试或手写构造静默改变语义。
logx.Error(ctx, "worker_stopped", fmt.Errorf("unsupported retry_strategy"), slog.String("worker", outboxWorkerName), slog.String("node_id", s.nodeID))
return
}
if err := s.ProcessPendingOutbox(ctx, options); err != nil && ctx.Err() == nil {
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", outboxWorkerName), slog.String("node_id", s.nodeID), slog.Int("batch_size", options.BatchSize))
}
ticker := time.NewTicker(options.PollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.ProcessPendingOutbox(ctx, options); err != nil && ctx.Err() == nil {
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", outboxWorkerName), slog.String("node_id", s.nodeID), slog.Int("batch_size", options.BatchSize))
}
}
}
}
// ProcessPendingOutbox 把待投递事件补偿性地推送给异步消费者。
func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorkerOptions) error {
options = normalizeOutboxWorkerOptions(options)
if options.RetryStrategy != outboxRetryExponentialBackoff {
return fmt.Errorf("unsupported outbox retry_strategy %q", options.RetryStrategy)
}
// 补偿 worker 先抢占 pending/retryable 事件,再投递,避免多机同时处理同一批。
records, err := s.repository.ClaimPendingOutbox(ctx, s.nodeID, options.BatchSize, time.Now().UTC().Add(options.PublishTimeout).UnixMilli())
if err != nil {
return err
}
for _, record := range records {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if record.RetryCount >= options.MaxRetryCount {
// 已达到最大失败次数的历史事件直接转死信,防止每轮 worker 继续打外部系统。
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
markErr := s.repository.MarkOutboxDead(markCtx, record.EventID, deadLetterReason(record))
markCancel()
if markErr != nil {
return markErr
}
logx.Error(ctx, "outbox_dead_letter", nil,
slog.String("worker", outboxWorkerName),
slog.String("node_id", s.nodeID),
slog.String("event_id", record.EventID),
slog.String("event_type", record.EventType),
slog.String("room_id", record.RoomID),
slog.Int("retry_count", record.RetryCount),
slog.String("last_error", trimOutboxError(record.LastError)),
)
continue
}
publishCtx, cancel := context.WithTimeout(context.Background(), options.PublishTimeout)
err := s.outboxPublisher.PublishOutboxEvent(publishCtx, record.Envelope)
cancel()
if err != nil {
// 投递失败转为 retryable并写入指数退避时间等待下一轮 worker 到期后再抢占。
nextRetryAtMS := time.Now().UTC().Add(outboxBackoff(record.RetryCount+1, options)).UnixMilli()
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
markErr := s.repository.MarkOutboxRetryable(markCtx, record.EventID, trimOutboxError(err.Error()), nextRetryAtMS)
markCancel()
if markErr != nil {
return markErr
}
logx.Warn(ctx, "outbox_publish_retryable",
slog.String("worker", outboxWorkerName),
slog.String("node_id", s.nodeID),
slog.String("event_id", record.EventID),
slog.String("event_type", record.EventType),
slog.String("room_id", record.RoomID),
slog.Int("retry_count", record.RetryCount+1),
slog.Int64("next_retry_at_ms", nextRetryAtMS),
slog.String("error", trimOutboxError(err.Error())),
)
continue
}
// 只有外部发布成功后才能标记 delivered。
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
markErr := s.repository.MarkOutboxDelivered(markCtx, record.EventID)
markCancel()
if markErr != nil {
return markErr
}
logx.Info(ctx, "outbox_delivered",
slog.String("worker", outboxWorkerName),
slog.String("node_id", s.nodeID),
slog.String("event_id", record.EventID),
slog.String("event_type", record.EventType),
slog.String("room_id", record.RoomID),
slog.Int("retry_count", record.RetryCount),
)
}
return nil
}
func outboxBackoff(retryCount int, options OutboxWorkerOptions) time.Duration {
if retryCount <= 1 {
return options.InitialBackoff
}
backoff := options.InitialBackoff
for i := 1; i < retryCount; i++ {
if backoff >= options.MaxBackoff/2 {
return options.MaxBackoff
}
backoff *= 2
}
if backoff > options.MaxBackoff {
return options.MaxBackoff
}
return backoff
}
func deadLetterReason(record outbox.Record) string {
if strings.TrimSpace(record.LastError) == "" {
return "outbox retry limit exceeded"
}
return trimOutboxError("outbox retry limit exceeded: " + record.LastError)
}
func trimOutboxError(value string) string {
// last_error 会写入 MySQL 和日志,先去掉首尾空白避免不可见差异影响排障。
value = strings.TrimSpace(value)
if len(value) <= 512 {
return value
}
// V1 只保留错误前缀,避免外部响应过大撑爆日志或 TEXT 字段查询成本。
return value[:512]
}