fix: govern outbox polling and archiving
This commit is contained in:
parent
815275676e
commit
60b7277e5c
127
scripts/mysql/archive_delivered_outbox.sql
Normal file
127
scripts/mysql/archive_delivered_outbox.sql
Normal 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;
|
||||
@ -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 (
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 表';
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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='通知服务外部通知投递位点和死信表';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
))
|
||||
|
||||
@ -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, `
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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,后续扫描不再返回该事件。
|
||||
|
||||
@ -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='用户发件箱表';
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user