fix: govern outbox polling and archiving

This commit is contained in:
zhx 2026-06-01 14:30:23 +08:00
parent 815275676e
commit 60b7277e5c
27 changed files with 1150 additions and 309 deletions

View File

@ -0,0 +1,127 @@
SET @outbox_archive_cutoff_ms := IFNULL(
@outbox_archive_cutoff_ms,
UNIX_TIMESTAMP(DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 30 DAY)) * 1000
);
CREATE TABLE IF NOT EXISTS hyapp_wallet.wallet_outbox_archive LIKE hyapp_wallet.wallet_outbox;
INSERT IGNORE INTO hyapp_wallet.wallet_outbox_archive
SELECT * FROM hyapp_wallet.wallet_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
ORDER BY updated_at_ms ASC
LIMIT 10000;
DELETE FROM hyapp_wallet.wallet_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
AND EXISTS (
SELECT 1 FROM hyapp_wallet.wallet_outbox_archive a
WHERE a.app_code = wallet_outbox.app_code AND a.event_id = wallet_outbox.event_id
)
ORDER BY updated_at_ms ASC
LIMIT 10000;
CREATE TABLE IF NOT EXISTS hyapp_room.room_outbox_archive LIKE hyapp_room.room_outbox;
INSERT IGNORE INTO hyapp_room.room_outbox_archive
SELECT * FROM hyapp_room.room_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
ORDER BY updated_at_ms ASC
LIMIT 10000;
DELETE FROM hyapp_room.room_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
AND EXISTS (
SELECT 1 FROM hyapp_room.room_outbox_archive a
WHERE a.app_code = room_outbox.app_code AND a.event_id = room_outbox.event_id
)
ORDER BY updated_at_ms ASC
LIMIT 10000;
CREATE TABLE IF NOT EXISTS hyapp_activity.activity_outbox_archive LIKE hyapp_activity.activity_outbox;
INSERT IGNORE INTO hyapp_activity.activity_outbox_archive
SELECT * FROM hyapp_activity.activity_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
ORDER BY updated_at_ms ASC
LIMIT 10000;
DELETE FROM hyapp_activity.activity_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
AND EXISTS (
SELECT 1 FROM hyapp_activity.activity_outbox_archive a
WHERE a.app_code = activity_outbox.app_code AND a.outbox_id = activity_outbox.outbox_id
)
ORDER BY updated_at_ms ASC
LIMIT 10000;
CREATE TABLE IF NOT EXISTS hyapp_activity.im_broadcast_outbox_archive LIKE hyapp_activity.im_broadcast_outbox;
INSERT IGNORE INTO hyapp_activity.im_broadcast_outbox_archive
SELECT * FROM hyapp_activity.im_broadcast_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
ORDER BY updated_at_ms ASC
LIMIT 10000;
DELETE FROM hyapp_activity.im_broadcast_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
AND EXISTS (
SELECT 1 FROM hyapp_activity.im_broadcast_outbox_archive a
WHERE a.app_code = im_broadcast_outbox.app_code AND a.event_id = im_broadcast_outbox.event_id
)
ORDER BY updated_at_ms ASC
LIMIT 10000;
CREATE TABLE IF NOT EXISTS hyapp_game.game_outbox_archive LIKE hyapp_game.game_outbox;
INSERT IGNORE INTO hyapp_game.game_outbox_archive
SELECT * FROM hyapp_game.game_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
ORDER BY updated_at_ms ASC
LIMIT 10000;
DELETE FROM hyapp_game.game_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
AND EXISTS (
SELECT 1 FROM hyapp_game.game_outbox_archive a
WHERE a.app_code = game_outbox.app_code AND a.event_id = game_outbox.event_id
)
ORDER BY updated_at_ms ASC
LIMIT 10000;
CREATE TABLE IF NOT EXISTS hyapp_game.game_level_event_outbox_archive LIKE hyapp_game.game_level_event_outbox;
INSERT IGNORE INTO hyapp_game.game_level_event_outbox_archive
SELECT * FROM hyapp_game.game_level_event_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
ORDER BY updated_at_ms ASC
LIMIT 10000;
DELETE FROM hyapp_game.game_level_event_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
AND EXISTS (
SELECT 1 FROM hyapp_game.game_level_event_outbox_archive a
WHERE a.app_code = game_level_event_outbox.app_code AND a.event_id = game_level_event_outbox.event_id
)
ORDER BY updated_at_ms ASC
LIMIT 10000;
CREATE TABLE IF NOT EXISTS hyapp_user.user_outbox_archive LIKE hyapp_user.user_outbox;
INSERT IGNORE INTO hyapp_user.user_outbox_archive
SELECT * FROM hyapp_user.user_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
ORDER BY updated_at_ms ASC
LIMIT 10000;
DELETE FROM hyapp_user.user_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
AND EXISTS (
SELECT 1 FROM hyapp_user.user_outbox_archive a
WHERE a.app_code = user_outbox.app_code AND a.event_id = user_outbox.event_id
)
ORDER BY updated_at_ms ASC
LIMIT 10000;
CREATE TABLE IF NOT EXISTS hyapp_notice.notice_delivery_events_archive LIKE hyapp_notice.notice_delivery_events;
INSERT IGNORE INTO hyapp_notice.notice_delivery_events_archive
SELECT * FROM hyapp_notice.notice_delivery_events
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
ORDER BY updated_at_ms ASC
LIMIT 10000;
DELETE FROM hyapp_notice.notice_delivery_events
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
AND EXISTS (
SELECT 1 FROM hyapp_notice.notice_delivery_events_archive a
WHERE a.source_name = notice_delivery_events.source_name
AND a.app_code = notice_delivery_events.app_code
AND a.source_event_id = notice_delivery_events.source_event_id
AND a.channel = notice_delivery_events.channel
)
ORDER BY updated_at_ms ASC
LIMIT 10000;

View File

@ -46,7 +46,8 @@ CREATE TABLE IF NOT EXISTS activity_outbox (
PRIMARY KEY (app_code, outbox_id),
KEY idx_activity_outbox_status_retry (app_code, status, next_retry_at_ms),
KEY idx_activity_outbox_lucky_retry (app_code, event_type, status, next_retry_at_ms, created_at_ms, outbox_id),
KEY idx_activity_outbox_lucky_lock (app_code, event_type, status, lock_until_ms, created_at_ms, outbox_id)
KEY idx_activity_outbox_lucky_lock (app_code, event_type, status, lock_until_ms, created_at_ms, outbox_id),
KEY idx_activity_outbox_retention (app_code, status, updated_at_ms, outbox_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='活动事件 outbox 表';
CREATE TABLE IF NOT EXISTS lucky_gift_rules (
@ -312,8 +313,9 @@ CREATE TABLE IF NOT EXISTS im_broadcast_outbox (
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, event_id),
KEY idx_im_broadcast_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms),
KEY idx_im_broadcast_outbox_lock (app_code, status, locked_until_ms, created_at_ms)
KEY idx_im_broadcast_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_im_broadcast_outbox_lock (app_code, status, locked_until_ms, created_at_ms, event_id),
KEY idx_im_broadcast_outbox_retention (app_code, status, updated_at_ms, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IM 广播发件箱表';
CREATE TABLE IF NOT EXISTS task_definitions (

View File

@ -93,50 +93,55 @@ func (r *Repository) ClaimPendingBroadcastOutbox(ctx context.Context, workerID s
if err != nil {
return nil, err
}
rows, err := tx.QueryContext(ctx, `
appCode := appcode.FromContext(ctx)
records := make([]broadcastdomain.OutboxRecord, 0, limit)
claimBranches := []struct {
query string
args []any
}{
{
query: `
SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR),
status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms,
created_at_ms, updated_at_ms
FROM im_broadcast_outbox
FROM im_broadcast_outbox FORCE INDEX (idx_im_broadcast_outbox_pending)
WHERE app_code = ?
AND attempt_count < ?
AND (
(status IN (?, ?) AND next_retry_at_ms <= ?)
OR (status = ? AND locked_until_ms <= ?)
)
AND status IN (?, ?)
AND next_retry_at_ms <= ?
ORDER BY created_at_ms ASC
LIMIT ?
FOR UPDATE SKIP LOCKED`,
appcode.FromContext(ctx),
maxRetry,
broadcastdomain.StatusPending,
broadcastdomain.StatusRetryable,
nowMS,
broadcastdomain.StatusDelivering,
nowMS,
limit,
)
if err != nil {
_ = tx.Rollback()
return nil, err
args: []any{appCode, maxRetry, broadcastdomain.StatusPending, broadcastdomain.StatusRetryable, nowMS},
},
{
query: `
SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR),
status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms,
created_at_ms, updated_at_ms
FROM im_broadcast_outbox FORCE INDEX (idx_im_broadcast_outbox_lock)
WHERE app_code = ?
AND attempt_count < ?
AND status = ?
AND locked_until_ms <= ?
ORDER BY created_at_ms ASC
LIMIT ?
FOR UPDATE SKIP LOCKED`,
args: []any{appCode, maxRetry, broadcastdomain.StatusDelivering, nowMS},
},
}
records := make([]broadcastdomain.OutboxRecord, 0, limit)
for rows.Next() {
record, err := scanBroadcastOutbox(rows)
for _, branch := range claimBranches {
remaining := limit - len(records)
if remaining <= 0 {
break
}
args := append(append([]any{}, branch.args...), remaining)
branchRecords, err := queryBroadcastOutboxRecords(ctx, tx, branch.query, args...)
if err != nil {
_ = rows.Close()
_ = tx.Rollback()
return nil, err
}
records = append(records, record)
}
if err := rows.Close(); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := rows.Err(); err != nil {
_ = tx.Rollback()
return nil, err
records = append(records, branchRecords...)
}
for _, record := range records {
// 先在同一事务里改成 delivering再提交给调用方发送避免两个 worker 同时发送同一 event_id。
@ -166,6 +171,27 @@ func (r *Repository) ClaimPendingBroadcastOutbox(ctx context.Context, workerID s
return records, nil
}
func queryBroadcastOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]broadcastdomain.OutboxRecord, error) {
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]broadcastdomain.OutboxRecord, 0)
for rows.Next() {
record, err := scanBroadcastOutbox(rows)
if err != nil {
return nil, err
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
return records, nil
}
// MarkBroadcastOutboxDelivered 在腾讯 REST 接受消息后标记 delivered。
// delivered 只代表服务端投递成功,不代表所有客户端已在线收到或已展示。
func (r *Repository) MarkBroadcastOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error {

View File

@ -51,6 +51,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureLuckyGiftOutboxIndexes(ctx); err != nil {
return err
}
if err := r.ensureBroadcastOutboxIndexes(ctx); err != nil {
return err
}
if err := r.ensureLuckyDrawRewardSettlementColumns(ctx); err != nil {
return err
}
@ -310,6 +313,7 @@ func (r *Repository) ensureLuckyGiftOutboxIndexes(ctx context.Context) error {
}{
{"idx_activity_outbox_lucky_retry", `CREATE INDEX idx_activity_outbox_lucky_retry ON activity_outbox (app_code, event_type, status, next_retry_at_ms, created_at_ms, outbox_id)`},
{"idx_activity_outbox_lucky_lock", `CREATE INDEX idx_activity_outbox_lucky_lock ON activity_outbox (app_code, event_type, status, lock_until_ms, created_at_ms, outbox_id)`},
{"idx_activity_outbox_retention", `CREATE INDEX idx_activity_outbox_retention ON activity_outbox (app_code, status, updated_at_ms, outbox_id)`},
}
for _, addition := range additions {
exists, err := r.indexExists(ctx, "activity_outbox", addition.name)
@ -325,6 +329,27 @@ func (r *Repository) ensureLuckyGiftOutboxIndexes(ctx context.Context) error {
return nil
}
func (r *Repository) ensureBroadcastOutboxIndexes(ctx context.Context) error {
additions := []struct {
name string
statement string
}{
{"idx_im_broadcast_outbox_retention", `CREATE INDEX idx_im_broadcast_outbox_retention ON im_broadcast_outbox (app_code, status, updated_at_ms, event_id)`},
}
for _, addition := range additions {
exists, err := r.indexExists(ctx, "im_broadcast_outbox", addition.name)
if err != nil {
return err
}
if !exists {
if _, err := r.db.ExecContext(ctx, addition.statement); err != nil {
return err
}
}
}
return nil
}
func (r *Repository) ensureLuckyDrawRewardSettlementColumns(ctx context.Context) error {
additions := []struct {
name string

View File

@ -183,10 +183,17 @@ CREATE TABLE IF NOT EXISTS game_outbox (
coin_amount BIGINT NOT NULL COMMENT '金币数量',
payload_json JSON NOT NULL COMMENT '业务负载 JSON 快照',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
worker_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MQ 投递 worker ID',
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'MQ 投递锁过期时间UTC epoch ms',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下一次 MQ 投递重试时间UTC epoch ms',
last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次失败原因',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY(app_code, event_id),
KEY idx_game_outbox_status_created(app_code, status, created_at_ms),
KEY idx_game_outbox_status_created(app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_game_outbox_lock(app_code, status, lock_until_ms, created_at_ms, event_id),
KEY idx_game_outbox_retention(app_code, status, updated_at_ms, event_id),
KEY idx_game_outbox_order(app_code, order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏统计事件 outbox 表';

View File

@ -184,7 +184,8 @@ func (a *App) runOutboxWorker() {
}
func (a *App) processOutboxBatch() (int, error) {
records, err := a.repo.ClaimPendingGameOutbox(a.outboxCtx, a.cfg.OutboxWorker.BatchSize)
workerID := "game-outbox-" + a.cfg.NodeID
records, err := a.repo.ClaimPendingGameOutbox(a.outboxCtx, workerID, a.cfg.OutboxWorker.BatchSize)
if err != nil {
return 0, err
}
@ -193,7 +194,8 @@ func (a *App) processOutboxBatch() (int, error) {
err := a.publishGameOutboxRecord(publishCtx, record)
cancel()
if err != nil {
if markErr := a.repo.MarkGameOutboxPending(context.Background(), record.AppCode, record.EventID); markErr != nil {
nextRetryAtMS := time.Now().UTC().Add(a.cfg.OutboxWorker.PollInterval).UnixMilli()
if markErr := a.repo.MarkGameOutboxRetryable(context.Background(), record.AppCode, record.EventID, err.Error(), nextRetryAtMS); markErr != nil {
return 0, markErr
}
return len(records), err

View File

@ -229,10 +229,17 @@ func (r *Repository) Migrate(ctx context.Context) error {
coin_amount BIGINT NOT NULL,
payload_json JSON NOT NULL,
status VARCHAR(32) NOT NULL,
worker_id VARCHAR(128) NOT NULL DEFAULT '',
lock_until_ms BIGINT NOT NULL DEFAULT 0,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
last_error VARCHAR(512) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, event_id),
KEY idx_game_outbox_status_created(app_code, status, created_at_ms),
KEY idx_game_outbox_status_created(app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_game_outbox_lock(app_code, status, lock_until_ms, created_at_ms, event_id),
KEY idx_game_outbox_retention(app_code, status, updated_at_ms, event_id),
KEY idx_game_outbox_order(app_code, order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
}
@ -250,6 +257,30 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureColumn(ctx, "game_orders", "region_id", "region_id BIGINT NOT NULL DEFAULT 0 AFTER room_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "worker_id", "worker_id VARCHAR(128) NOT NULL DEFAULT '' AFTER status"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "lock_until_ms", "lock_until_ms BIGINT NOT NULL DEFAULT 0 AFTER worker_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "retry_count", "retry_count INT NOT NULL DEFAULT 0 AFTER lock_until_ms"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "next_retry_at_ms", "next_retry_at_ms BIGINT NOT NULL DEFAULT 0 AFTER retry_count"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "last_error", "last_error VARCHAR(512) NOT NULL DEFAULT '' AFTER next_retry_at_ms"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_outbox", "idx_game_outbox_status_created", []string{"app_code", "status", "next_retry_at_ms", "created_at_ms", "event_id"}, "ALTER TABLE game_outbox ADD INDEX idx_game_outbox_status_created(app_code, status, next_retry_at_ms, created_at_ms, event_id)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_outbox", "idx_game_outbox_lock", []string{"app_code", "status", "lock_until_ms", "created_at_ms", "event_id"}, "ALTER TABLE game_outbox ADD INDEX idx_game_outbox_lock(app_code, status, lock_until_ms, created_at_ms, event_id)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_outbox", "idx_game_outbox_retention", []string{"app_code", "status", "updated_at_ms", "event_id"}, "ALTER TABLE game_outbox ADD INDEX idx_game_outbox_retention(app_code, status, updated_at_ms, event_id)"); err != nil {
return err
}
return r.seedDefaults(ctx)
}
@ -270,6 +301,53 @@ func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnN
return err
}
func (r *Repository) ensureIndexDefinition(ctx context.Context, tableName string, indexName string, columns []string, createDDL string) error {
rows, err := r.db.QueryContext(ctx,
`SELECT COLUMN_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
ORDER BY SEQ_IN_INDEX`,
tableName, indexName,
)
if err != nil {
return err
}
defer rows.Close()
existing := make([]string, 0, len(columns))
for rows.Next() {
var column string
if err := rows.Scan(&column); err != nil {
return err
}
existing = append(existing, column)
}
if err := rows.Err(); err != nil {
return err
}
if equalStringSlices(existing, columns) {
return nil
}
if len(existing) > 0 {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` DROP INDEX `+indexName); err != nil {
return err
}
}
_, err = r.db.ExecContext(ctx, createDDL)
return err
}
func equalStringSlices(left []string, right []string) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func (r *Repository) seedDefaults(ctx context.Context) error {
if _, err := r.db.ExecContext(ctx,
`INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms)
@ -669,75 +747,138 @@ type GameOutboxRecord struct {
CoinAmount int64
PayloadJSON string
CreatedAtMS int64
RetryCount int
LastError string
WorkerID string
LockUntilMS int64
}
// ClaimPendingGameOutbox locks pending game facts so one worker publishes them.
func (r *Repository) ClaimPendingGameOutbox(ctx context.Context, batchSize int) ([]GameOutboxRecord, error) {
func (r *Repository) ClaimPendingGameOutbox(ctx context.Context, workerID string, batchSize int) ([]GameOutboxRecord, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if batchSize <= 0 {
batchSize = 100
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
workerID = "game-outbox-worker"
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, `
nowMS := time.Now().UTC().UnixMilli()
lockUntilMS := nowMS + (30 * time.Second).Milliseconds()
appCode := appcode.FromContext(ctx)
records := make([]GameOutboxRecord, 0, batchSize)
claimBranches := []struct {
query string
args []any
}{
{
query: `
SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
coin_amount, CAST(payload_json AS CHAR), created_at_ms
FROM game_outbox
WHERE status = 'pending'
FROM game_outbox FORCE INDEX (idx_game_outbox_status_created)
WHERE app_code = ? AND status = 'pending' AND next_retry_at_ms <= ? AND lock_until_ms = 0
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED
`, batchSize)
if err != nil {
return nil, err
`,
args: []any{appCode, nowMS},
},
{
query: `
SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
coin_amount, CAST(payload_json AS CHAR), created_at_ms
FROM game_outbox FORCE INDEX (idx_game_outbox_status_created)
WHERE app_code = ? AND status = 'retryable' AND next_retry_at_ms <= ? AND lock_until_ms = 0
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED
`,
args: []any{appCode, nowMS},
},
{
query: `
SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
coin_amount, CAST(payload_json AS CHAR), created_at_ms
FROM game_outbox FORCE INDEX (idx_game_outbox_lock)
WHERE app_code = ? AND status = 'running' AND lock_until_ms <= ?
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED
`,
args: []any{appCode, nowMS},
},
}
records := make([]GameOutboxRecord, 0, batchSize)
for rows.Next() {
var record GameOutboxRecord
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.OrderID, &record.UserID, &record.PlatformCode, &record.GameID, &record.OpType, &record.CoinAmount, &record.PayloadJSON, &record.CreatedAtMS); err != nil {
_ = rows.Close()
for _, branch := range claimBranches {
remaining := batchSize - len(records)
if remaining <= 0 {
break
}
args := append(append([]any{}, branch.args...), remaining)
branchRecords, err := queryGameOutboxRecords(ctx, tx, branch.query, args...)
if err != nil {
return nil, err
}
records = append(records, record)
records = append(records, branchRecords...)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
nowMS := time.Now().UTC().UnixMilli()
for _, record := range records {
if _, err := tx.ExecContext(ctx, `
UPDATE game_outbox SET status = 'running', updated_at_ms = ?
WHERE app_code = ? AND event_id = ? AND status = 'pending'
`, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil {
UPDATE game_outbox
SET status = 'running', worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?
`, workerID, lockUntilMS, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
for index := range records {
records[index].WorkerID = workerID
records[index].LockUntilMS = lockUntilMS
}
return records, nil
}
func queryGameOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]GameOutboxRecord, error) {
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]GameOutboxRecord, 0)
for rows.Next() {
var record GameOutboxRecord
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.OrderID, &record.UserID, &record.PlatformCode, &record.GameID, &record.OpType, &record.CoinAmount, &record.PayloadJSON, &record.CreatedAtMS); err != nil {
return nil, err
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
return records, nil
}
func (r *Repository) MarkGameOutboxDelivered(ctx context.Context, appCode string, eventID string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE game_outbox SET status = 'delivered', updated_at_ms = ?
UPDATE game_outbox
SET status = 'delivered', worker_id = '', lock_until_ms = 0, last_error = '', updated_at_ms = ?
WHERE app_code = ? AND event_id = ?
`, time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
return err
}
func (r *Repository) MarkGameOutboxPending(ctx context.Context, appCode string, eventID string) error {
func (r *Repository) MarkGameOutboxRetryable(ctx context.Context, appCode string, eventID string, lastErr string, nextRetryAtMS int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE game_outbox SET status = 'pending', updated_at_ms = ?
UPDATE game_outbox
SET status = 'retryable', worker_id = '', lock_until_ms = 0, retry_count = retry_count + 1,
next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ? AND status = 'running'
`, time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
`, nextRetryAtMS, truncate(lastErr, 512), time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
return err
}

View File

@ -28,7 +28,7 @@ wallet_notice_worker:
max_backoff: 5m
room_notice_worker:
enabled: true
enabled: false
poll_interval: 1s
batch_size: 100
lock_ttl: 30s
@ -38,13 +38,18 @@ room_notice_worker:
max_backoff: 5m
rocketmq:
# Docker 本地默认关闭;接入外部 RocketMQ 后可消费 room_outbox 私有通知事实。
# Docker 本地默认关闭;接入外部 RocketMQ 后只消费 owner outbox 私有通知事实。
enabled: false
name_servers: []
name_server_domain: ""
access_key: ""
secret_key: ""
namespace: ""
wallet_outbox:
enabled: false
topic: "hyapp_wallet_outbox"
consumer_group: "hyapp-notice-wallet-outbox"
consumer_max_reconsume_times: 16
room_outbox:
enabled: false
topic: "hyapp_room_outbox"

View File

@ -18,8 +18,8 @@ tencent_im:
request_timeout: 5s
wallet_notice_worker:
enabled: true
poll_interval: 500ms
enabled: false
poll_interval: 1s
batch_size: 100
lock_ttl: 30s
publish_timeout: 3s
@ -28,8 +28,8 @@ wallet_notice_worker:
max_backoff: 5m
room_notice_worker:
enabled: true
poll_interval: 500ms
enabled: false
poll_interval: 1s
batch_size: 100
lock_ttl: 30s
publish_timeout: 3s
@ -45,6 +45,11 @@ rocketmq:
access_key: "TENCENT_ROCKETMQ_ACCESS_KEY"
secret_key: "TENCENT_ROCKETMQ_SECRET_KEY"
namespace: "hyapp-prod"
wallet_outbox:
enabled: true
topic: "hyapp_wallet_outbox"
consumer_group: "hyapp-notice-wallet-outbox"
consumer_max_reconsume_times: 16
room_outbox:
enabled: true
topic: "hyapp_room_outbox"

View File

@ -28,7 +28,7 @@ wallet_notice_worker:
max_backoff: 5m
room_notice_worker:
enabled: true
enabled: false
poll_interval: 1s
batch_size: 100
lock_ttl: 30s
@ -38,13 +38,18 @@ room_notice_worker:
max_backoff: 5m
rocketmq:
# 本地默认关闭;开启后 notice 直接消费 room_outbox MQ fanout仍写 notice_delivery_events 做幂等。
# 本地默认关闭;开启后 notice 只消费 owner outbox MQ fanout仍写 notice_delivery_events 做幂等。
enabled: false
name_servers: []
name_server_domain: ""
access_key: ""
secret_key: ""
namespace: ""
wallet_outbox:
enabled: false
topic: "hyapp_wallet_outbox"
consumer_group: "hyapp-notice-wallet-outbox"
consumer_max_reconsume_times: 16
room_outbox:
enabled: false
topic: "hyapp_room_outbox"

View File

@ -26,7 +26,9 @@ CREATE TABLE IF NOT EXISTS notice_delivery_events (
created_at_ms BIGINT NOT NULL COMMENT '通知位点创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '通知位点更新时间UTC epoch ms',
PRIMARY KEY (source_name, app_code, source_event_id, channel),
KEY idx_notice_delivery_pending (status, next_retry_at_ms, lock_until_ms, updated_at_ms),
KEY idx_notice_delivery_retry (source_name, app_code, channel, status, next_retry_at_ms, updated_at_ms),
KEY idx_notice_delivery_lock (source_name, app_code, channel, status, lock_until_ms, updated_at_ms),
KEY idx_notice_delivery_retention (app_code, status, updated_at_ms, source_name, source_event_id, channel),
KEY idx_notice_delivery_target (app_code, target_user_id, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='通知服务外部通知投递位点和死信表';

View File

@ -19,6 +19,7 @@ import (
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
"hyapp/pkg/tencentim"
"hyapp/pkg/walletmq"
"hyapp/services/notice-service/internal/config"
"hyapp/services/notice-service/internal/modules/roomnotice"
"hyapp/services/notice-service/internal/modules/walletnotice"
@ -92,6 +93,11 @@ func New(cfg config.Config) (*App, error) {
_ = store.Close()
return nil, errors.New("room notice worker requires tencent_im.enabled")
}
if cfg.RocketMQ.WalletOutbox.Enabled && publisher == nil {
_ = listener.Close()
_ = store.Close()
return nil, errors.New("wallet outbox mq consumer requires tencent_im.enabled")
}
if cfg.RocketMQ.RoomOutbox.Enabled && publisher == nil {
_ = listener.Close()
_ = store.Close()
@ -114,14 +120,37 @@ func New(cfg config.Config) (*App, error) {
walletSvc := walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher)
roomSvc := roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher)
roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker)
mqConsumers := make([]*rocketmqx.Consumer, 0, 1)
if cfg.RocketMQ.RoomOutbox.Enabled {
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ))
mqConsumers := make([]*rocketmqx.Consumer, 0, 2)
if cfg.RocketMQ.WalletOutbox.Enabled {
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes))
if err != nil {
_ = listener.Close()
_ = store.Close()
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body)
if err != nil {
return err
}
_, err = walletSvc.ProcessWalletOutboxMessage(appcode.WithContext(ctx, walletMessage.AppCode), walletMessage, walletNoticeWorkerOptions(cfg.WalletNoticeWorker))
return err
}); err != nil {
_ = consumer.Shutdown()
_ = listener.Close()
_ = store.Close()
return nil, err
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.RocketMQ.RoomOutbox.Enabled {
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.ConsumerGroup, cfg.RocketMQ.RoomOutbox.ConsumerMaxReconsumeTimes))
if err != nil {
shutdownConsumers(mqConsumers)
_ = listener.Close()
_ = store.Close()
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
if err != nil {
@ -132,6 +161,7 @@ func New(cfg config.Config) (*App, error) {
}); err != nil {
_ = listener.Close()
_ = store.Close()
shutdownConsumers(mqConsumers)
return nil, err
}
mqConsumers = append(mqConsumers, consumer)
@ -267,7 +297,13 @@ func (a *App) shutdownMQ() {
}
}
func rocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
func shutdownConsumers(consumers []*rocketmqx.Consumer) {
for _, consumer := range consumers {
_ = consumer.Shutdown()
}
}
func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsumeTimes int32) rocketmqx.ConsumerConfig {
return rocketmqx.ConsumerConfig{
EndpointConfig: rocketmqx.EndpointConfig{
NameServers: cfg.NameServers,
@ -278,8 +314,8 @@ func rocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig
Namespace: cfg.Namespace,
VIPChannel: cfg.VIPChannel,
},
GroupName: cfg.RoomOutbox.ConsumerGroup,
MaxReconsumeTimes: cfg.RoomOutbox.ConsumerMaxReconsumeTimes,
GroupName: group,
MaxReconsumeTimes: maxReconsumeTimes,
ConsumeRetryDelay: time.Second,
ConsumePullBatch: 32,
ConsumePullTimeout: 15 * time.Minute,

View File

@ -21,9 +21,9 @@ type Config struct {
HealthHTTPAddr string `yaml:"health_http_addr"`
// MySQLDSN 是 notice-service 自己的投递位点和死信状态库。
MySQLDSN string `yaml:"mysql_dsn"`
// WalletDatabase 是 wallet_outbox 所在库名notice 只把它当 append-only 事实源读取
// WalletDatabase 仅保留给历史离线修复工具使用;运行态 notice 不再直扫 wallet_outbox
WalletDatabase string `yaml:"wallet_database"`
// RoomDatabase 是 room_outbox 所在库名notice 只读取需要私有通知的房间事实
// RoomDatabase 仅保留给历史离线修复工具使用;运行态 notice 不再直扫 room_outbox
RoomDatabase string `yaml:"room_database"`
// TencentIM 是私有 IM 投递通道配置;本地默认关闭,避免误发真实消息。
TencentIM TencentIMConfig `yaml:"tencent_im"`
@ -31,7 +31,7 @@ type Config struct {
WalletNoticeWorker WalletNoticeWorkerConfig `yaml:"wallet_notice_worker"`
// RoomNoticeWorker 消费 room_outbox 中需要给单个用户发送的房间通知。
RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"`
// RocketMQ 可直接消费 room-service 发布的 room_outbox fanout topic。
// RocketMQ 消费 owner service 发布的 outbox fanout topic。
RocketMQ RocketMQConfig `yaml:"rocketmq"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Log logx.Config `yaml:"log"`
@ -76,7 +76,7 @@ type WalletNoticeWorkerConfig struct {
MaxBackoff time.Duration `yaml:"max_backoff"`
}
// RocketMQConfig 描述 notice-service 消费房间事实的 MQ 连接。
// RocketMQConfig 描述 notice-service 消费 owner outbox 事实的 MQ 连接。
type RocketMQConfig struct {
Enabled bool `yaml:"enabled"`
NameServers []string `yaml:"name_servers"`
@ -86,6 +86,7 @@ type RocketMQConfig struct {
SecurityToken string `yaml:"security_token"`
Namespace string `yaml:"namespace"`
VIPChannel bool `yaml:"vip_channel"`
WalletOutbox RoomOutboxMQConfig `yaml:"wallet_outbox"`
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
}
@ -148,6 +149,12 @@ func Default() Config {
func defaultRocketMQConfig() RocketMQConfig {
return RocketMQConfig{
Enabled: false,
WalletOutbox: RoomOutboxMQConfig{
Enabled: false,
Topic: "hyapp_wallet_outbox",
ConsumerGroup: "hyapp-notice-wallet-outbox",
ConsumerMaxReconsumeTimes: 16,
},
RoomOutbox: RoomOutboxMQConfig{
Enabled: false,
Topic: "hyapp_room_outbox",
@ -201,6 +208,12 @@ func Load(path string) (Config, error) {
return Config{}, err
}
cfg.RocketMQ = rocketMQ
if cfg.WalletNoticeWorker.Enabled {
return Config{}, errors.New("wallet_notice_worker direct wallet_outbox polling is disabled; enable rocketmq.wallet_outbox")
}
if cfg.RoomNoticeWorker.Enabled {
return Config{}, errors.New("room_notice_worker direct room_outbox polling is disabled; enable rocketmq.room_outbox")
}
if strings.TrimSpace(cfg.TencentIM.AdminIdentifier) == "" {
cfg.TencentIM.AdminIdentifier = "administrator"
}
@ -233,7 +246,16 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes
}
if cfg.RoomOutbox.Enabled {
if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" {
cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic
}
if cfg.WalletOutbox.ConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.ConsumerGroup); cfg.WalletOutbox.ConsumerGroup == "" {
cfg.WalletOutbox.ConsumerGroup = defaults.WalletOutbox.ConsumerGroup
}
if cfg.WalletOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.WalletOutbox.ConsumerMaxReconsumeTimes = defaults.WalletOutbox.ConsumerMaxReconsumeTimes
}
if cfg.RoomOutbox.Enabled || cfg.WalletOutbox.Enabled {
cfg.Enabled = true
}
if cfg.Enabled {

View File

@ -7,17 +7,23 @@ func TestLoadLocalKeepsRocketMQDisabled(t *testing.T) {
if err != nil {
t.Fatalf("Load local config failed: %v", err)
}
if cfg.RocketMQ.Enabled || cfg.RocketMQ.RoomOutbox.Enabled {
if cfg.RocketMQ.Enabled || cfg.RocketMQ.WalletOutbox.Enabled || cfg.RocketMQ.RoomOutbox.Enabled {
t.Fatalf("local config must not require RocketMQ: %+v", cfg.RocketMQ)
}
}
func TestLoadTencentExampleEnablesRoomOutboxMQ(t *testing.T) {
func TestLoadTencentExampleEnablesOutboxMQConsumers(t *testing.T) {
cfg, err := Load("../../configs/config.tencent.example.yaml")
if err != nil {
t.Fatalf("Load tencent example failed: %v", err)
}
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.Topic == "" || cfg.RocketMQ.RoomOutbox.ConsumerGroup == "" {
if cfg.WalletNoticeWorker.Enabled || cfg.RoomNoticeWorker.Enabled {
t.Fatalf("tencent example must keep legacy DB poll workers disabled: wallet=%+v room=%+v", cfg.WalletNoticeWorker, cfg.RoomNoticeWorker)
}
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.WalletOutbox.Enabled || cfg.RocketMQ.WalletOutbox.Topic == "" || cfg.RocketMQ.WalletOutbox.ConsumerGroup == "" {
t.Fatalf("tencent example must configure wallet outbox MQ consumer: %+v", cfg.RocketMQ)
}
if !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.Topic == "" || cfg.RocketMQ.RoomOutbox.ConsumerGroup == "" {
t.Fatalf("tencent example must configure room outbox MQ consumer: %+v", cfg.RocketMQ)
}
}

View File

@ -12,6 +12,7 @@ import (
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
_ "github.com/go-sql-driver/mysql"
)
@ -96,7 +97,7 @@ func TestMySQLRepositoryProcessesRealRoomKickOutbox(t *testing.T) {
publisher := &fakePublisher{}
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
processed, err := service.ProcessRoomKickNotices(ctx, RoomNoticeWorkerOptions{
processed, err := service.ProcessRoomKickNotices(appcode.WithContext(ctx, appCode), RoomNoticeWorkerOptions{
WorkerID: "notice-real-room-test-worker",
BatchSize: 1,
LockTTL: 30 * time.Second,
@ -166,7 +167,7 @@ func ensureRoomOutboxTable(ctx context.Context, db *sql.DB, roomDatabase string)
last_error TEXT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_room_outbox_notice (app_code, event_type, created_at_ms)
KEY idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
quoteDB(roomDatabase),
))

View File

@ -103,35 +103,72 @@ type roomKickCandidate struct {
}
func (r *MySQLRepository) listRoomKickCandidates(ctx context.Context, limit int, nowMs int64) ([]roomKickCandidate, error) {
query := fmt.Sprintf(`
appCode := appcode.FromContext(ctx)
candidates := make([]roomKickCandidate, 0, limit)
branches := []struct {
query string
args []any
}{
{
query: fmt.Sprintf(`
SELECT ro.app_code, ro.event_id
FROM %s ro
FROM %s ro FORCE INDEX (idx_room_outbox_event_created)
LEFT JOIN notice_delivery_events nde
ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ?
WHERE ro.event_type = ?
AND (
nde.source_event_id IS NULL
OR (nde.status = ? AND nde.next_retry_at_ms <= ?)
OR (nde.status = ? AND nde.lock_until_ms <= ?)
)
WHERE ro.app_code = ? AND ro.event_type = ?
AND nde.source_event_id IS NULL
ORDER BY ro.created_at_ms ASC, ro.event_id ASC
LIMIT ?`, r.roomOutboxTable)
rows, err := r.db.QueryContext(ctx, query,
sourceRoomOutbox,
channelTencentIMC2C,
eventRoomUserKicked,
deliveryStatusRetryable,
nowMs,
deliveryStatusDelivering,
nowMs,
limit,
)
LIMIT ?`, r.roomOutboxTable),
args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked},
},
{
query: fmt.Sprintf(`
SELECT ro.app_code, ro.event_id
FROM %s ro FORCE INDEX (idx_room_outbox_event_created)
JOIN notice_delivery_events nde
ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ?
WHERE ro.app_code = ? AND ro.event_type = ?
AND nde.status = ? AND nde.next_retry_at_ms <= ?
ORDER BY ro.created_at_ms ASC, ro.event_id ASC
LIMIT ?`, r.roomOutboxTable),
args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked, deliveryStatusRetryable, nowMs},
},
{
query: fmt.Sprintf(`
SELECT ro.app_code, ro.event_id
FROM %s ro FORCE INDEX (idx_room_outbox_event_created)
JOIN notice_delivery_events nde
ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ?
WHERE ro.app_code = ? AND ro.event_type = ?
AND nde.status = ? AND nde.lock_until_ms <= ?
ORDER BY ro.created_at_ms ASC, ro.event_id ASC
LIMIT ?`, r.roomOutboxTable),
args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked, deliveryStatusDelivering, nowMs},
},
}
for _, branch := range branches {
remaining := limit - len(candidates)
if remaining <= 0 {
break
}
args := append(append([]any{}, branch.args...), remaining)
branchCandidates, err := queryRoomKickCandidates(ctx, r.db, branch.query, args...)
if err != nil {
return nil, err
}
candidates = append(candidates, branchCandidates...)
}
return candidates, nil
}
func queryRoomKickCandidates(ctx context.Context, db *sql.DB, query string, args ...any) ([]roomKickCandidate, error) {
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
candidates := make([]roomKickCandidate, 0, limit)
candidates := make([]roomKickCandidate, 0)
for rows.Next() {
var candidate roomKickCandidate
if err := rows.Scan(&candidate.AppCode, &candidate.EventID); err != nil {
@ -313,10 +350,7 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev
UPDATE notice_delivery_events
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
AND (
(status = ? AND next_retry_at_ms <= ?)
OR (status = ? AND lock_until_ms <= ?)
)`,
AND status = ? AND next_retry_at_ms <= ?`,
deliveryStatusDelivering,
workerID,
lockUntilMS,
@ -328,16 +362,40 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev
channelTencentIMC2C,
deliveryStatusRetryable,
nowMs,
deliveryStatusDelivering,
nowMs,
)
if err != nil {
return 0, false, err
}
affected, err := result.RowsAffected()
if err != nil || affected == 0 {
if err != nil {
return 0, false, err
}
if affected == 0 {
result, err = tx.ExecContext(ctx, `
UPDATE notice_delivery_events
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
AND status = ? AND lock_until_ms <= ?`,
deliveryStatusDelivering,
workerID,
lockUntilMS,
string(payload),
nowMs,
sourceRoomOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
deliveryStatusDelivering,
nowMs,
)
if err != nil {
return 0, false, err
}
affected, err = result.RowsAffected()
if err != nil || affected == 0 {
return 0, false, err
}
}
var retryCount int
err = tx.QueryRowContext(ctx, `

View File

@ -11,6 +11,7 @@ import (
"time"
_ "github.com/go-sql-driver/mysql"
"hyapp/pkg/appcode"
)
func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
@ -82,7 +83,7 @@ func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
publisher := &fakePublisher{}
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
processed, err := service.ProcessWalletBalanceNotices(ctx, WalletNoticeWorkerOptions{
processed, err := service.ProcessWalletBalanceNotices(appcode.WithContext(ctx, appCode), WalletNoticeWorkerOptions{
WorkerID: "notice-real-test-worker",
BatchSize: 1,
LockTTL: 30 * time.Second,
@ -186,7 +187,7 @@ func TestMySQLRepositoryProcessesRealVipActivatedOutbox(t *testing.T) {
publisher := &fakePublisher{}
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
processed, err := service.ProcessWalletBalanceNotices(ctx, WalletNoticeWorkerOptions{
processed, err := service.ProcessWalletBalanceNotices(appcode.WithContext(ctx, appCode), WalletNoticeWorkerOptions{
WorkerID: "notice-real-test-worker",
BatchSize: 1,
LockTTL: 30 * time.Second,

View File

@ -11,6 +11,7 @@ import (
"unicode"
"hyapp/pkg/appcode"
"hyapp/pkg/walletmq"
"hyapp/pkg/xerr"
)
@ -108,40 +109,71 @@ type walletBalanceCandidate struct {
func (r *MySQLRepository) listWalletBalanceCandidates(ctx context.Context, limit int, nowMs int64) ([]walletBalanceCandidate, error) {
eventTypes := walletPrivateNoticeEventTypes()
query := fmt.Sprintf(`
appCode := appcode.FromContext(ctx)
candidates := make([]walletBalanceCandidate, 0)
branches := []struct {
query string
args []any
}{
{
query: fmt.Sprintf(`
SELECT wo.app_code, wo.event_id, wo.event_type
FROM %s wo
FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created)
LEFT JOIN notice_delivery_events nde
ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ?
WHERE wo.event_type IN (%s)
AND (
nde.source_event_id IS NULL
OR (nde.status = ? AND nde.next_retry_at_ms <= ?)
OR (nde.status = ? AND nde.lock_until_ms <= ?)
)
WHERE wo.app_code = ? AND wo.event_type IN (%s)
AND nde.source_event_id IS NULL
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes)))
args := []any{
sourceWalletOutbox,
channelTencentIMC2C,
LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))),
args: append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...),
},
{
query: fmt.Sprintf(`
SELECT wo.app_code, wo.event_id, wo.event_type
FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created)
JOIN notice_delivery_events nde
ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ?
WHERE wo.app_code = ? AND wo.event_type IN (%s)
AND nde.status = ? AND nde.next_retry_at_ms <= ?
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))),
args: append(append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), deliveryStatusRetryable, nowMs),
},
{
query: fmt.Sprintf(`
SELECT wo.app_code, wo.event_id, wo.event_type
FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created)
JOIN notice_delivery_events nde
ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ?
WHERE wo.app_code = ? AND wo.event_type IN (%s)
AND nde.status = ? AND nde.lock_until_ms <= ?
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))),
args: append(append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), deliveryStatusDelivering, nowMs),
},
}
for _, eventType := range eventTypes {
args = append(args, eventType)
for _, branch := range branches {
remaining := limit - len(candidates)
if remaining <= 0 {
break
}
args := append(append([]any{}, branch.args...), remaining)
branchCandidates, err := queryWalletBalanceCandidates(ctx, r.db, branch.query, args...)
if err != nil {
return nil, err
}
candidates = append(candidates, branchCandidates...)
}
args = append(args,
deliveryStatusRetryable,
nowMs,
deliveryStatusDelivering,
nowMs,
limit,
)
rows, err := r.db.QueryContext(ctx, query, args...)
return candidates, nil
}
func queryWalletBalanceCandidates(ctx context.Context, db *sql.DB, query string, args ...any) ([]walletBalanceCandidate, error) {
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
candidates := make([]walletBalanceCandidate, 0, limit)
candidates := make([]walletBalanceCandidate, 0)
for rows.Next() {
var candidate walletBalanceCandidate
if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.EventType); err != nil {
@ -182,6 +214,73 @@ func (r *MySQLRepository) claimWalletBalanceCandidate(ctx context.Context, candi
return event, true, nil
}
// ClaimWalletBalanceMessage records a wallet_outbox MQ message in notice_delivery_events.
func (r *MySQLRepository) ClaimWalletBalanceMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (WalletBalanceEvent, bool, error) {
if r == nil || r.db == nil {
return WalletBalanceEvent{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured")
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return WalletBalanceEvent{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
event, err := walletBalanceEventFromMessage(message)
if err != nil {
return WalletBalanceEvent{}, false, err
}
now := time.Now()
lockUntilMS := now.Add(lockTTL).UnixMilli()
nowMs := now.UnixMilli()
ctx = appcode.WithContext(ctx, event.AppCode)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return WalletBalanceEvent{}, false, err
}
defer func() { _ = tx.Rollback() }()
retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
if err != nil || !claimed {
return WalletBalanceEvent{}, false, err
}
event.RetryCount = retryCount
if err := tx.Commit(); err != nil {
return WalletBalanceEvent{}, false, err
}
return event, true, nil
}
func walletBalanceEventFromMessage(message walletmq.WalletOutboxMessage) (WalletBalanceEvent, error) {
event := WalletBalanceEvent{
AppCode: appcode.Normalize(message.AppCode),
EventID: message.EventID,
EventType: message.EventType,
TransactionID: message.TransactionID,
CommandID: message.CommandID,
UserID: message.UserID,
AssetType: message.AssetType,
AvailableDelta: message.AvailableDelta,
FrozenDelta: message.FrozenDelta,
PayloadJSON: message.PayloadJSON,
CreatedAtMS: message.OccurredAtMS,
}
if event.PayloadJSON == "" {
event.PayloadJSON = "{}"
}
if event.AppCode == "" || event.EventID == "" || event.TransactionID == "" || event.CommandID == "" {
return WalletBalanceEvent{}, fmt.Errorf("wallet outbox message is incomplete")
}
if !isWalletPrivateNoticeEvent(event.EventType) {
return WalletBalanceEvent{}, fmt.Errorf("unexpected wallet event type %q", event.EventType)
}
if event.UserID <= 0 {
return WalletBalanceEvent{}, fmt.Errorf("wallet notice user_id is invalid")
}
return event, nil
}
func (r *MySQLRepository) lockWalletBalanceEvent(ctx context.Context, tx *sql.Tx, candidate walletBalanceCandidate) (WalletBalanceEvent, error) {
var event WalletBalanceEvent
query := fmt.Sprintf(`
@ -241,10 +340,7 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev
UPDATE notice_delivery_events
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
AND (
(status = ? AND next_retry_at_ms <= ?)
OR (status = ? AND lock_until_ms <= ?)
)`,
AND status = ? AND next_retry_at_ms <= ?`,
deliveryStatusDelivering,
workerID,
lockUntilMS,
@ -256,16 +352,40 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev
channelTencentIMC2C,
deliveryStatusRetryable,
nowMs,
deliveryStatusDelivering,
nowMs,
)
if err != nil {
return 0, false, err
}
affected, err := result.RowsAffected()
if err != nil || affected == 0 {
if err != nil {
return 0, false, err
}
if affected == 0 {
result, err = tx.ExecContext(ctx, `
UPDATE notice_delivery_events
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
AND status = ? AND lock_until_ms <= ?`,
deliveryStatusDelivering,
workerID,
lockUntilMS,
event.PayloadJSON,
nowMs,
sourceWalletOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
deliveryStatusDelivering,
nowMs,
)
if err != nil {
return 0, false, err
}
affected, err = result.RowsAffected()
if err != nil || affected == 0 {
return 0, false, err
}
}
var retryCount int
err = tx.QueryRowContext(ctx, `
@ -369,6 +489,14 @@ func sqlPlaceholders(count int) string {
return strings.Join(parts, ",")
}
func stringSliceToAny(values []string) []any {
out := make([]any, 0, len(values))
for _, value := range values {
out = append(out, value)
}
return out
}
func isDuplicateKey(err error) bool {
if err == nil {
return false

View File

@ -10,11 +10,13 @@ import (
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/pkg/walletmq"
)
// Repository 是 notice-service 需要的持久化能力。
type Repository interface {
ClaimWalletBalanceEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]WalletBalanceEvent, error)
ClaimWalletBalanceMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (WalletBalanceEvent, bool, error)
MarkWalletBalanceDelivered(ctx context.Context, event WalletBalanceEvent, deliveredPayload json.RawMessage, nowMs int64) error
MarkWalletBalanceRetryable(ctx context.Context, event WalletBalanceEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error
MarkWalletBalanceFailed(ctx context.Context, event WalletBalanceEvent, retryCount int, lastErr string, nowMs int64) error
@ -65,6 +67,26 @@ func (s *Service) ProcessWalletBalanceNotices(ctx context.Context, options Walle
return processed, nil
}
// ProcessWalletOutboxMessage handles one wallet_outbox MQ message. Unknown
// event types are acknowledged by returning handled=false.
func (s *Service) ProcessWalletOutboxMessage(ctx context.Context, message walletmq.WalletOutboxMessage, options WalletNoticeWorkerOptions) (bool, error) {
if !isWalletPrivateNoticeEvent(message.EventType) {
return false, nil
}
options = normalizeWalletNoticeWorkerOptions(options, s.cfg.NodeID)
if s.repository == nil {
return true, fmt.Errorf("notice repository is not configured")
}
if s.publisher == nil {
return true, fmt.Errorf("notice publisher is not configured")
}
event, claimed, err := s.repository.ClaimWalletBalanceMessage(ctx, options.WorkerID, message, options.LockTTL)
if err != nil || !claimed {
return true, err
}
return true, s.publishWalletBalanceEvent(ctx, event, options)
}
func (s *Service) publishWalletBalanceEvent(ctx context.Context, event WalletBalanceEvent, options WalletNoticeWorkerOptions) error {
payload, err := walletBalanceNoticePayload(event)
if err != nil {
@ -131,6 +153,15 @@ func walletNoticeExt(eventType string) string {
return "wallet_notice"
}
func isWalletPrivateNoticeEvent(eventType string) bool {
for _, expected := range walletPrivateNoticeEventTypes() {
if eventType == expected {
return true
}
}
return false
}
func walletBalanceNoticePayload(event WalletBalanceEvent) (json.RawMessage, error) {
var payload map[string]any
if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil {

View File

@ -8,6 +8,7 @@ import (
"time"
"hyapp/pkg/tencentim"
"hyapp/pkg/walletmq"
)
func TestProcessWalletBalanceNoticesPublishesPrivateIM(t *testing.T) {
@ -86,6 +87,34 @@ func TestProcessWalletBalanceNoticesPublishesVipActivatedIM(t *testing.T) {
}
}
func TestProcessWalletOutboxMessagePublishesPrivateIM(t *testing.T) {
repo := &fakeRepository{}
publisher := &fakePublisher{}
service := New(Config{NodeID: "node-a"}, repo, publisher)
handled, err := service.ProcessWalletOutboxMessage(context.Background(), walletmq.WalletOutboxMessage{
AppCode: "lalu",
EventID: "evt-mq-1",
EventType: "WalletBalanceChanged",
TransactionID: "tx-mq-1",
CommandID: "cmd-mq-1",
UserID: 789,
AssetType: "COIN",
AvailableDelta: 30,
PayloadJSON: `{"available_after":130}`,
OccurredAtMS: 1710000000000,
}, WalletNoticeWorkerOptions{MaxRetryCount: 3})
if err != nil {
t.Fatalf("ProcessWalletOutboxMessage failed: %v", err)
}
if !handled || len(repo.claimedMessages) != 1 || len(repo.delivered) != 1 || len(publisher.messages) != 1 {
t.Fatalf("unexpected mq result: handled=%v claimed=%d delivered=%d messages=%d", handled, len(repo.claimedMessages), len(repo.delivered), len(publisher.messages))
}
if publisher.messages[0].ToAccount != "789" || publisher.messages[0].EventID != "evt-mq-1" {
t.Fatalf("unexpected mq c2c message: %+v", publisher.messages[0])
}
}
func TestProcessWalletBalanceNoticesMarksRetryable(t *testing.T) {
repo := &fakeRepository{
events: []WalletBalanceEvent{{
@ -117,10 +146,11 @@ func TestProcessWalletBalanceNoticesMarksRetryable(t *testing.T) {
}
type fakeRepository struct {
events []WalletBalanceEvent
delivered []WalletBalanceEvent
retryable []retryMarker
failed []WalletBalanceEvent
events []WalletBalanceEvent
claimedMessages []walletmq.WalletOutboxMessage
delivered []WalletBalanceEvent
retryable []retryMarker
failed []WalletBalanceEvent
}
type retryMarker struct {
@ -134,6 +164,23 @@ func (r *fakeRepository) ClaimWalletBalanceEvents(context.Context, string, int,
return r.events, nil
}
func (r *fakeRepository) ClaimWalletBalanceMessage(_ context.Context, _ string, message walletmq.WalletOutboxMessage, _ time.Duration) (WalletBalanceEvent, bool, error) {
r.claimedMessages = append(r.claimedMessages, message)
return WalletBalanceEvent{
AppCode: message.AppCode,
EventID: message.EventID,
EventType: message.EventType,
TransactionID: message.TransactionID,
CommandID: message.CommandID,
UserID: message.UserID,
AssetType: message.AssetType,
AvailableDelta: message.AvailableDelta,
FrozenDelta: message.FrozenDelta,
PayloadJSON: message.PayloadJSON,
CreatedAtMS: message.OccurredAtMS,
}, true, nil
}
func (r *fakeRepository) MarkWalletBalanceDelivered(_ context.Context, event WalletBalanceEvent, _ json.RawMessage, _ int64) error {
r.delivered = append(r.delivered, event)
return nil

View File

@ -143,9 +143,11 @@ CREATE TABLE IF NOT EXISTS room_outbox (
last_error TEXT NULL COMMENT '最后一次失败原因',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, event_id),
KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms),
KEY idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms),
KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms)
KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id),
KEY idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id),
KEY idx_room_outbox_retention (app_code, status, updated_at_ms, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间事件 outbox 表';
CREATE TABLE IF NOT EXISTS room_user_feed_entries (

View File

@ -199,8 +199,10 @@ func (r *Repository) Migrate(ctx context.Context) error {
last_error TEXT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms),
KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms)
KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id),
KEY idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id),
KEY idx_room_outbox_retention (app_code, status, updated_at_ms, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_user_feed_entries (
app_code VARCHAR(32) NOT NULL,
@ -425,14 +427,26 @@ func (r *Repository) ensureOutboxRetrySchema(ctx context.Context) error {
return err
}
}
hasIndex, err := r.indexExists(ctx, "room_outbox", "idx_room_outbox_retry")
if err != nil {
return err
indexes := []struct {
name string
statement string
}{
{"idx_room_outbox_pending", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id)`},
{"idx_room_outbox_retry", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms, event_id)`},
{"idx_room_outbox_claim", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id)`},
{"idx_room_outbox_event_created", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id)`},
{"idx_room_outbox_retention", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retention (app_code, status, updated_at_ms, event_id)`},
}
if !hasIndex {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms)`); err != nil {
for _, index := range indexes {
hasIndex, err := r.indexExists(ctx, "room_outbox", index.name)
if err != nil {
return err
}
if !hasIndex {
if _, err := r.db.ExecContext(ctx, index.statement); err != nil {
return err
}
}
}
return nil
}
@ -1172,15 +1186,14 @@ func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox
nowMS := time.Now().UTC().UnixMilli()
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM room_outbox
FROM room_outbox FORCE INDEX (idx_room_outbox_pending)
WHERE app_code = ? AND status IN (?, ?)
AND (status = ? OR next_retry_at_ms <= ?)
AND next_retry_at_ms <= ?
ORDER BY created_at_ms ASC
LIMIT ?`,
appcode.FromContext(ctx),
outbox.StatusPending,
outbox.StatusRetryable,
outbox.StatusPending,
nowMS,
limit,
)
@ -1235,68 +1248,52 @@ func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, li
}
nowMS := time.Now().UTC().UnixMilli()
rows, err := tx.QueryContext(ctx,
`SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM room_outbox
WHERE app_code = ?
AND (
status = ?
OR (status = ? AND next_retry_at_ms <= ?)
OR (status = ? AND lock_until_ms IS NOT NULL AND lock_until_ms <= ?)
)
ORDER BY created_at_ms ASC
LIMIT ?
FOR UPDATE SKIP LOCKED`,
appcode.FromContext(ctx),
outbox.StatusPending,
outbox.StatusRetryable,
nowMS,
outbox.StatusDelivering,
nowMS,
limit,
)
if err != nil {
_ = tx.Rollback()
return nil, err
}
appCode := appcode.FromContext(ctx)
records := make([]outbox.Record, 0, limit)
eventIDs := make([]string, 0, limit)
for rows.Next() {
var envelopeBytes []byte
var record outbox.Record
var lockUntilValue sql.Null[int64]
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntilValue, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil {
_ = rows.Close()
claimBranches := []struct {
query string
args []any
}{
{
query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM room_outbox FORCE INDEX (idx_room_outbox_pending)
WHERE app_code = ? AND status = ? AND next_retry_at_ms <= ? AND lock_until_ms IS NULL
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED`,
args: []any{appCode, outbox.StatusPending, nowMS},
},
{
query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM room_outbox FORCE INDEX (idx_room_outbox_pending)
WHERE app_code = ? AND status = ? AND next_retry_at_ms <= ? AND lock_until_ms IS NULL
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED`,
args: []any{appCode, outbox.StatusRetryable, nowMS},
},
{
query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM room_outbox FORCE INDEX (idx_room_outbox_claim)
WHERE app_code = ? AND status = ? AND lock_until_ms <= ?
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED`,
args: []any{appCode, outbox.StatusDelivering, nowMS},
},
}
for _, branch := range claimBranches {
remaining := limit - len(records)
if remaining <= 0 {
break
}
args := append(append([]any{}, branch.args...), remaining)
branchRecords, err := r.queryRoomOutboxRecords(ctx, tx, branch.query, args...)
if err != nil {
_ = tx.Rollback()
return nil, err
}
if lockUntilValue.Valid {
record.LockUntilMS = lockUntilValue.V
}
var envelope roomeventsv1.EventEnvelope
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
_ = rows.Close()
_ = tx.Rollback()
return nil, err
}
record.Envelope = &envelope
record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
records = append(records, record)
eventIDs = append(eventIDs, record.EventID)
}
if err := rows.Close(); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := rows.Err(); err != nil {
_ = tx.Rollback()
return nil, err
records = append(records, branchRecords...)
}
for _, eventID := range eventIDs {
for _, record := range records {
if _, err := tx.ExecContext(ctx,
`UPDATE room_outbox
SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
@ -1305,8 +1302,8 @@ func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, li
workerID,
lockUntilMS,
nowMS,
appcode.FromContext(ctx),
eventID,
appCode,
record.EventID,
); err != nil {
_ = tx.Rollback()
return nil, err
@ -1326,6 +1323,40 @@ func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, li
return records, nil
}
func (r *Repository) queryRoomOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]outbox.Record, error) {
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]outbox.Record, 0)
for rows.Next() {
var envelopeBytes []byte
var record outbox.Record
var lockUntilValue sql.Null[int64]
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntilValue, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil {
return nil, err
}
if lockUntilValue.Valid {
record.LockUntilMS = lockUntilValue.V
}
var envelope roomeventsv1.EventEnvelope
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
return nil, err
}
record.Envelope = &envelope
record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
return records, nil
}
// MarkOutboxDelivered 标记事件投递成功。
func (r *Repository) MarkOutboxDelivered(ctx context.Context, eventID string) error {
// 成功后清空 last_error后续扫描不再返回该事件。

View File

@ -401,11 +401,18 @@ CREATE TABLE IF NOT EXISTS user_outbox (
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id BIGINT NOT NULL COMMENT '聚合 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
worker_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MQ 投递 worker ID',
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'MQ 投递锁过期时间UTC epoch ms',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下一次 MQ 投递重试时间UTC epoch ms',
last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次失败原因',
payload_json JSON NOT NULL COMMENT '业务负载 JSON 快照',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, event_id),
KEY idx_user_outbox_status_created (app_code, status, created_at_ms),
KEY idx_user_outbox_status_created (app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_user_outbox_lock (app_code, status, lock_until_ms, created_at_ms, event_id),
KEY idx_user_outbox_retention (app_code, status, updated_at_ms, event_id),
KEY idx_user_outbox_aggregate (app_code, aggregate_type, aggregate_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户发件箱表';

View File

@ -433,7 +433,8 @@ func (a *App) processUserOutboxBatch(workerID string) (int, error) {
cancel()
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), a.cfg.OutboxWorker.PublishTimeout)
if err != nil {
markErr := a.mysqlRepo.MarkUserOutboxPending(markCtx, record.EventID)
nextRetryAtMS := time.Now().UTC().Add(a.cfg.OutboxWorker.PollInterval).UnixMilli()
markErr := a.mysqlRepo.MarkUserOutboxRetryable(markCtx, record.EventID, err.Error(), nextRetryAtMS)
markCancel()
if markErr != nil {
return 0, markErr

View File

@ -2,6 +2,8 @@ package mysql
import (
"context"
"database/sql"
"strings"
"time"
"hyapp/pkg/appcode"
@ -17,6 +19,10 @@ type UserOutboxRecord struct {
AggregateID int64
PayloadJSON string
CreatedAtMS int64
RetryCount int
LastError string
WorkerID string
LockUntilMS int64
}
// ClaimPendingUserOutbox locks pending user facts so one worker can publish them.
@ -27,49 +33,102 @@ func (r *Repository) ClaimPendingUserOutbox(ctx context.Context, workerID string
if batchSize <= 0 {
batchSize = 100
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
workerID = "user-outbox-worker"
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, `
nowMS := time.Now().UTC().UnixMilli()
lockUntilMS := nowMS + (30 * time.Second).Milliseconds()
appCode := appcode.FromContext(ctx)
records := make([]UserOutboxRecord, 0, batchSize)
claimBranches := []struct {
query string
args []any
}{
{
query: `
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
FROM user_outbox
WHERE status = 'pending'
FROM user_outbox FORCE INDEX (idx_user_outbox_status_created)
WHERE app_code = ? AND status = 'pending' AND next_retry_at_ms <= ? AND lock_until_ms = 0
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED
`, batchSize)
if err != nil {
return nil, err
`,
args: []any{appCode, nowMS},
},
{
query: `
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
FROM user_outbox FORCE INDEX (idx_user_outbox_status_created)
WHERE app_code = ? AND status = 'retryable' AND next_retry_at_ms <= ? AND lock_until_ms = 0
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED
`,
args: []any{appCode, nowMS},
},
{
query: `
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
FROM user_outbox FORCE INDEX (idx_user_outbox_lock)
WHERE app_code = ? AND status = 'running' AND lock_until_ms <= ?
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED
`,
args: []any{appCode, nowMS},
},
}
records := make([]UserOutboxRecord, 0, batchSize)
for rows.Next() {
var record UserOutboxRecord
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.AggregateType, &record.AggregateID, &record.PayloadJSON, &record.CreatedAtMS); err != nil {
_ = rows.Close()
for _, branch := range claimBranches {
remaining := batchSize - len(records)
if remaining <= 0 {
break
}
args := append(append([]any{}, branch.args...), remaining)
branchRecords, err := queryUserOutboxRecords(ctx, tx, branch.query, args...)
if err != nil {
return nil, err
}
records = append(records, record)
records = append(records, branchRecords...)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
nowMS := time.Now().UTC().UnixMilli()
for _, record := range records {
if _, err := tx.ExecContext(ctx, `
UPDATE user_outbox
SET status = 'running', updated_at_ms = ?
WHERE app_code = ? AND event_id = ? AND status = 'pending'
`, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil {
SET status = 'running', worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?
`, workerID, lockUntilMS, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
for index := range records {
records[index].WorkerID = workerID
records[index].LockUntilMS = lockUntilMS
}
return records, nil
}
func queryUserOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]UserOutboxRecord, error) {
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]UserOutboxRecord, 0)
for rows.Next() {
var record UserOutboxRecord
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.AggregateType, &record.AggregateID, &record.PayloadJSON, &record.CreatedAtMS); err != nil {
return nil, err
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
return records, nil
}
@ -80,21 +139,30 @@ func (r *Repository) MarkUserOutboxDelivered(ctx context.Context, eventID string
}
_, err := r.db.ExecContext(ctx, `
UPDATE user_outbox
SET status = 'delivered', updated_at_ms = ?
SET status = 'delivered', worker_id = '', lock_until_ms = 0, last_error = '', updated_at_ms = ?
WHERE app_code = ? AND event_id = ?
`, time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID)
return err
}
// MarkUserOutboxPending returns a failed publish to pending so the next tick retries.
func (r *Repository) MarkUserOutboxPending(ctx context.Context, eventID string) error {
// MarkUserOutboxRetryable releases a failed publish with a bounded retry delay.
func (r *Repository) MarkUserOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE user_outbox
SET status = 'pending', updated_at_ms = ?
SET status = 'retryable', worker_id = '', lock_until_ms = 0, retry_count = retry_count + 1,
next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ? AND status = 'running'
`, time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID)
`, nextRetryAtMS, trimUserOutboxError(lastErr), time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID)
return err
}
func trimUserOutboxError(value string) string {
value = strings.TrimSpace(value)
if len(value) > 512 {
return value[:512]
}
return value
}

View File

@ -73,8 +73,9 @@ CREATE TABLE IF NOT EXISTS wallet_outbox (
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, event_id),
KEY idx_wallet_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms),
KEY idx_wallet_outbox_claim (app_code, status, lock_until_ms, created_at_ms),
KEY idx_wallet_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_wallet_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id),
KEY idx_wallet_outbox_retention (app_code, status, updated_at_ms, event_id),
KEY idx_wallet_outbox_event_created (app_code, event_type, created_at_ms, event_id),
KEY idx_wallet_outbox_asset_event_created (app_code, asset_type, event_type, created_at_ms, event_id),
KEY idx_wallet_outbox_tx (app_code, transaction_id)
@ -325,15 +326,29 @@ PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := 'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms)';
SET @ddl := 'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id)';
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- claim 索引用于查找锁过期的消息;仅在缺失时添加,避免重复执行 initdb 报重复索引
-- claim 索引用于查找锁过期的消息;列顺序也跟查询条件绑定,存在旧结构时重建
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_outbox' AND INDEX_NAME = 'idx_wallet_outbox_claim') = 0,
'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_claim (app_code, status, lock_until_ms, created_at_ms)',
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_outbox' AND INDEX_NAME = 'idx_wallet_outbox_claim') > 0,
'ALTER TABLE wallet_outbox DROP INDEX idx_wallet_outbox_claim',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := 'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id)';
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_outbox' AND INDEX_NAME = 'idx_wallet_outbox_retention') = 0,
'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_retention (app_code, status, updated_at_ms, event_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;

View File

@ -1554,34 +1554,106 @@ func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID stri
lockUntilMS = time.Now().UTC().Add(30 * time.Second).UnixMilli()
}
appCode := appcode.FromContext(ctx)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
rows, err := tx.QueryContext(ctx, `
defer func() { _ = tx.Rollback() }()
records := make([]WalletOutboxRecord, 0, limit)
claimBranches := []struct {
query string
args []any
}{
{
query: `
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms,
retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0)
FROM wallet_outbox
WHERE status IN (?, ?)
AND (next_retry_at_ms IS NULL OR next_retry_at_ms <= ?)
AND (lock_until_ms IS NULL OR lock_until_ms <= ?)
FROM wallet_outbox FORCE INDEX (idx_wallet_outbox_pending)
WHERE app_code = ? AND status = ?
AND next_retry_at_ms IS NULL
AND lock_until_ms IS NULL
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ?
FOR UPDATE`,
outboxStatusPending,
outboxStatusRetryable,
nowMS,
nowMS,
limit,
)
if err != nil {
_ = tx.Rollback()
return nil, err
FOR UPDATE SKIP LOCKED`,
args: []any{appCode, outboxStatusPending},
},
{
query: `
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms,
retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0)
FROM wallet_outbox FORCE INDEX (idx_wallet_outbox_pending)
WHERE app_code = ? AND status = ?
AND next_retry_at_ms <= ?
AND lock_until_ms IS NULL
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ?
FOR UPDATE SKIP LOCKED`,
args: []any{appCode, outboxStatusRetryable, nowMS},
},
{
query: `
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms,
retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0)
FROM wallet_outbox FORCE INDEX (idx_wallet_outbox_claim)
WHERE app_code = ? AND status = ?
AND lock_until_ms <= ?
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ?
FOR UPDATE SKIP LOCKED`,
args: []any{appCode, outboxStatusDelivering, nowMS},
},
}
for _, branch := range claimBranches {
remaining := limit - len(records)
if remaining <= 0 {
break
}
args := append(append([]any{}, branch.args...), remaining)
branchRecords, err := queryWalletOutboxRecords(ctx, tx, branch.query, args...)
if err != nil {
return nil, err
}
records = append(records, branchRecords...)
}
records := make([]WalletOutboxRecord, 0, limit)
eventIDs := make([]string, 0, limit)
for _, record := range records {
if _, err := tx.ExecContext(ctx, `
UPDATE wallet_outbox
SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
outboxStatusDelivering,
workerID,
lockUntilMS,
nowMS,
record.AppCode,
record.EventID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
for index := range records {
records[index].WorkerID = workerID
records[index].LockUntilMS = lockUntilMS
}
return records, nil
}
func queryWalletOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]WalletOutboxRecord, error) {
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]WalletOutboxRecord, 0)
for rows.Next() {
var record WalletOutboxRecord
if err := rows.Scan(
@ -1601,45 +1673,13 @@ func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID stri
&record.WorkerID,
&record.LockUntilMS,
); err != nil {
_ = rows.Close()
_ = tx.Rollback()
return nil, err
}
records = append(records, record)
eventIDs = append(eventIDs, record.EventID)
}
if err := rows.Close(); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := rows.Err(); err != nil {
_ = tx.Rollback()
return nil, err
}
for index, eventID := range eventIDs {
if _, err := tx.ExecContext(ctx, `
UPDATE wallet_outbox
SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
outboxStatusDelivering,
workerID,
lockUntilMS,
nowMS,
records[index].AppCode,
eventID,
); err != nil {
_ = tx.Rollback()
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
for index := range records {
records[index].WorkerID = workerID
records[index].LockUntilMS = lockUntilMS
}
return records, nil
}