zhx ec35fcad5e room_outbox 增加 delivered 历史事件定期清理并移除重复索引
- outbox_worker.retention 配置块(默认关闭,max_age<24h 拒绝启动)
- 清理走预留的 idx_room_outbox_retention 索引,先选主键再小批量 DELETE
- 移除与 idx_room_outbox_pending 完全重复的 idx_room_outbox_retry,
  auto_migrate=true 时 Migrate 自动 DROP
- 开启清理前需和数据侧确认保留窗口覆盖统计重放/补数工具

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 01:12:25 +08:00

188 lines
8.5 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 config 验证 room-service 本地 YAML 配置契约。
package config
import (
"testing"
"time"
)
// TestLoad 验证 room-service 的本地 config.yaml 能被正常读取。
func TestLoad(t *testing.T) {
// 读取真实本地配置,确保端口和关键下游地址没有漂移。
cfg, err := Load("../../configs/config.yaml")
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// room-service 业务只走 gRPCHTTP health 仅服务 CLB 探活,端口必须保持 13xxx 约束。
if cfg.GRPCAddr != ":13001" {
t.Fatalf("unexpected grpc addr: %s", cfg.GRPCAddr)
}
if cfg.HealthHTTPAddr != ":13101" {
t.Fatalf("unexpected health http addr: %s", cfg.HealthHTTPAddr)
}
if cfg.AdvertiseAddr != "127.0.0.1:13001" || cfg.NodeRegistryTTL != 30*time.Second || cfg.NodeRegistryHeartbeatInterval != 10*time.Second || cfg.OwnerForwardTimeout != 2*time.Second {
t.Fatalf("unexpected owner routing config: advertise=%s ttl=%s heartbeat=%s timeout=%s", cfg.AdvertiseAddr, cfg.NodeRegistryTTL, cfg.NodeRegistryHeartbeatInterval, cfg.OwnerForwardTimeout)
}
// lease TTL 是故障接管抖动窗口的基础参数。
if cfg.LeaseTTL != 10*time.Second {
t.Fatalf("unexpected lease ttl: %s", cfg.LeaseTTL)
}
if cfg.RoomListCacheRefreshInterval != 5*time.Minute {
t.Fatalf("unexpected room list cache refresh interval: %s", cfg.RoomListCacheRefreshInterval)
}
// SendGift 主链路依赖 wallet-service默认地址必须与本地编排一致。
if cfg.WalletServiceAddr != "127.0.0.1:13004" {
t.Fatalf("unexpected wallet addr: %s", cfg.WalletServiceAddr)
}
if cfg.TencentIM.SDKAppID != 20040101 || cfg.TencentIM.SecretKey == "" || cfg.TencentIM.AdminIdentifier != "administrator" {
t.Fatalf("local room-service must use test Tencent IM account: %+v", cfg.TencentIM)
}
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.OutboxWorker.PollInterval != 200*time.Millisecond || cfg.OutboxWorker.BatchSize != 50 || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 || cfg.OutboxWorker.InitialBackoff != 5*time.Second || cfg.OutboxWorker.MaxBackoff != 5*time.Minute {
t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker)
}
// 本地默认仍走 room_outbox MQ 和火箭延迟唤醒;房间 UI IM 已改为 Room Cell 提交后直发,不再启 MQ IM bridge。
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled || !cfg.RocketMQ.RocketLaunch.Enabled || !cfg.RocketMQ.UserOutbox.Enabled {
t.Fatalf("local config must enable room MQ fanout and disable MQ IM bridge: %+v", cfg.RocketMQ)
}
}
// TestLoadTencentExample 验证线上腾讯云 IM 模板能覆盖本地默认配置。
func TestLoadTencentExample(t *testing.T) {
cfg, err := Load("../../configs/config.tencent.example.yaml")
if err != nil {
t.Fatalf("Load tencent example failed: %v", err)
}
if !cfg.TencentIM.Enabled {
t.Fatalf("tencent example should enable Tencent IM direct publisher")
}
if cfg.TencentIM.SDKAppID == 0 || cfg.TencentIM.SecretKey == "" {
t.Fatalf("sdk_app_id and secret_key templates must be present")
}
if cfg.TencentIM.AdminIdentifier == "" {
t.Fatalf("admin_identifier template must be present")
}
if cfg.TencentIM.Endpoint == "" || cfg.TencentIM.GroupType == "" {
t.Fatalf("endpoint and group_type templates must be present")
}
if cfg.TencentIM.RequestTimeout != 5*time.Second {
t.Fatalf("unexpected request timeout: %s", cfg.TencentIM.RequestTimeout)
}
if cfg.RoomListCacheRefreshInterval != 5*time.Minute {
t.Fatalf("unexpected room list cache refresh interval: %s", cfg.RoomListCacheRefreshInterval)
}
if cfg.AdvertiseAddr == "" || cfg.AdvertiseAddr == "room-service.internal:13001" {
t.Fatalf("tencent example must use per-instance advertise_addr, got %q", cfg.AdvertiseAddr)
}
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PollInterval != 200*time.Millisecond || cfg.OutboxWorker.BatchSize != 50 || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 {
t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker)
}
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RocketLaunch.Enabled {
t.Fatalf("tencent example must route room outbox and rocket launch through RocketMQ: mode=%s mq=%+v", cfg.OutboxWorker.PublishMode, cfg.RocketMQ)
}
}
func TestLoadDockerConfigDoesNotStartRoomIMBridgeWhenOutboxUsesMQ(t *testing.T) {
cfg, err := Load("../../configs/config.docker.yaml")
if err != nil {
t.Fatalf("Load docker config failed: %v", err)
}
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ {
t.Fatalf("docker config should publish room outbox through MQ, got %q", cfg.OutboxWorker.PublishMode)
}
if cfg.OutboxWorker.PollInterval != 200*time.Millisecond || cfg.OutboxWorker.BatchSize != 50 || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second {
t.Fatalf("docker config must run room outbox with 16 workers, short polling, and a bounded claim window: %+v", cfg.OutboxWorker)
}
if !cfg.RocketMQ.RoomOutbox.Enabled {
t.Fatalf("docker config must enable room outbox MQ")
}
if cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled {
t.Fatalf("docker mq room outbox must not start Tencent IM bridge consumer")
}
if cfg.TencentIM.SDKAppID != 20040101 || cfg.TencentIM.AdminIdentifier != "administrator" {
t.Fatalf("docker config must use test IM account: im=%+v", cfg.TencentIM)
}
if !cfg.TencentIM.Enabled {
t.Fatalf("direct room IM publishing requires tencent_im.enabled")
}
}
func TestNormalizeRejectsUnknownOutboxRetryStrategy(t *testing.T) {
cfg := Default()
cfg.OutboxWorker.RetryStrategy = "exponential"
if _, err := Normalize(cfg); err == nil {
t.Fatal("Normalize should reject unsupported outbox retry_strategy")
}
}
func TestNormalizeRejectsUnknownOutboxPublishMode(t *testing.T) {
cfg := Default()
cfg.OutboxWorker.PublishMode = "random"
if _, err := Normalize(cfg); err == nil {
t.Fatal("Normalize should reject unsupported outbox publish_mode")
}
}
func TestNormalizeRejectsMQPublishModeWithoutRoomOutboxMQ(t *testing.T) {
cfg := Default()
cfg.OutboxWorker.PublishMode = OutboxPublishModeMQ
if _, err := Normalize(cfg); err == nil {
t.Fatal("Normalize should reject mq publish_mode without rocketmq.room_outbox.enabled")
}
}
func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
cfg := Default()
cfg.OutboxWorker.PollInterval = 0
cfg.OutboxWorker.BatchSize = 0
cfg.OutboxWorker.Concurrency = 0
cfg.OutboxWorker.PublishTimeout = 0
cfg.OutboxWorker.RetryStrategy = ""
cfg.OutboxWorker.MaxRetryCount = 0
cfg.OutboxWorker.InitialBackoff = 0
cfg.OutboxWorker.MaxBackoff = 0
normalized, err := Normalize(cfg)
if err != nil {
t.Fatalf("Normalize failed: %v", err)
}
if normalized.OutboxWorker.PublishMode != OutboxPublishModeDirect || normalized.OutboxWorker.PollInterval != time.Second || normalized.OutboxWorker.BatchSize != 100 || normalized.OutboxWorker.Concurrency != 1 || normalized.OutboxWorker.PublishTimeout != 3*time.Second || normalized.OutboxWorker.RetryStrategy != "exponential_backoff" || normalized.OutboxWorker.MaxRetryCount != 10 || normalized.OutboxWorker.InitialBackoff != 5*time.Second || normalized.OutboxWorker.MaxBackoff != 5*time.Minute {
t.Fatalf("outbox worker defaults not restored: %+v", normalized.OutboxWorker)
}
}
func TestNormalizeOutboxRetentionKeepsSafeDefaults(t *testing.T) {
cfg := Default()
if cfg.OutboxWorker.Retention.Enabled {
// 清理会删除统计重放依赖的历史事件,必须显式开启,默认配置绝不能自动生效。
t.Fatal("outbox retention must default to disabled")
}
cfg.OutboxWorker.Retention = OutboxRetentionConfig{Enabled: true}
normalized, err := Normalize(cfg)
if err != nil {
t.Fatalf("Normalize failed: %v", err)
}
retention := normalized.OutboxWorker.Retention
if !retention.Enabled || retention.MaxAge != 30*24*time.Hour || retention.ScanInterval != 5*time.Minute || retention.BatchSize != 500 {
t.Fatalf("outbox retention defaults not restored: %+v", retention)
}
}
func TestNormalizeRejectsOutboxRetentionMaxAgeBelowSafetyFloor(t *testing.T) {
cfg := Default()
cfg.OutboxWorker.Retention.Enabled = true
cfg.OutboxWorker.Retention.MaxAge = time.Hour
if _, err := Normalize(cfg); err == nil {
t.Fatal("Normalize should reject outbox retention max_age below 24h")
}
}