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),
|
PRIMARY KEY (app_code, outbox_id),
|
||||||
KEY idx_activity_outbox_status_retry (app_code, status, next_retry_at_ms),
|
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_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 表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='活动事件 outbox 表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lucky_gift_rules (
|
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',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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),
|
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_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)
|
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 广播发件箱表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IM 广播发件箱表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS task_definitions (
|
CREATE TABLE IF NOT EXISTS task_definitions (
|
||||||
|
|||||||
@ -93,50 +93,55 @@ func (r *Repository) ClaimPendingBroadcastOutbox(ctx context.Context, workerID s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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),
|
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,
|
status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms,
|
||||||
created_at_ms, updated_at_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 = ?
|
WHERE app_code = ?
|
||||||
AND attempt_count < ?
|
AND attempt_count < ?
|
||||||
AND (
|
AND status IN (?, ?)
|
||||||
(status IN (?, ?) AND next_retry_at_ms <= ?)
|
AND next_retry_at_ms <= ?
|
||||||
OR (status = ? AND locked_until_ms <= ?)
|
|
||||||
)
|
|
||||||
ORDER BY created_at_ms ASC
|
ORDER BY created_at_ms ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
FOR UPDATE SKIP LOCKED`,
|
FOR UPDATE SKIP LOCKED`,
|
||||||
appcode.FromContext(ctx),
|
args: []any{appCode, maxRetry, broadcastdomain.StatusPending, broadcastdomain.StatusRetryable, nowMS},
|
||||||
maxRetry,
|
},
|
||||||
broadcastdomain.StatusPending,
|
{
|
||||||
broadcastdomain.StatusRetryable,
|
query: `
|
||||||
nowMS,
|
SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR),
|
||||||
broadcastdomain.StatusDelivering,
|
status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms,
|
||||||
nowMS,
|
created_at_ms, updated_at_ms
|
||||||
limit,
|
FROM im_broadcast_outbox FORCE INDEX (idx_im_broadcast_outbox_lock)
|
||||||
)
|
WHERE app_code = ?
|
||||||
if err != nil {
|
AND attempt_count < ?
|
||||||
_ = tx.Rollback()
|
AND status = ?
|
||||||
return nil, err
|
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 _, branch := range claimBranches {
|
||||||
for rows.Next() {
|
remaining := limit - len(records)
|
||||||
record, err := scanBroadcastOutbox(rows)
|
if remaining <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
args := append(append([]any{}, branch.args...), remaining)
|
||||||
|
branchRecords, err := queryBroadcastOutboxRecords(ctx, tx, branch.query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = rows.Close()
|
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
records = append(records, record)
|
records = append(records, branchRecords...)
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
// 先在同一事务里改成 delivering,再提交给调用方发送,避免两个 worker 同时发送同一 event_id。
|
// 先在同一事务里改成 delivering,再提交给调用方发送,避免两个 worker 同时发送同一 event_id。
|
||||||
@ -166,6 +171,27 @@ func (r *Repository) ClaimPendingBroadcastOutbox(ctx context.Context, workerID s
|
|||||||
return records, nil
|
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。
|
// MarkBroadcastOutboxDelivered 在腾讯 REST 接受消息后标记 delivered。
|
||||||
// delivered 只代表服务端投递成功,不代表所有客户端已在线收到或已展示。
|
// delivered 只代表服务端投递成功,不代表所有客户端已在线收到或已展示。
|
||||||
func (r *Repository) MarkBroadcastOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error {
|
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 {
|
if err := r.ensureLuckyGiftOutboxIndexes(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := r.ensureBroadcastOutboxIndexes(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := r.ensureLuckyDrawRewardSettlementColumns(ctx); err != nil {
|
if err := r.ensureLuckyDrawRewardSettlementColumns(ctx); err != nil {
|
||||||
return err
|
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_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_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 {
|
for _, addition := range additions {
|
||||||
exists, err := r.indexExists(ctx, "activity_outbox", addition.name)
|
exists, err := r.indexExists(ctx, "activity_outbox", addition.name)
|
||||||
@ -325,6 +329,27 @@ func (r *Repository) ensureLuckyGiftOutboxIndexes(ctx context.Context) error {
|
|||||||
return nil
|
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 {
|
func (r *Repository) ensureLuckyDrawRewardSettlementColumns(ctx context.Context) error {
|
||||||
additions := []struct {
|
additions := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@ -183,10 +183,17 @@ CREATE TABLE IF NOT EXISTS game_outbox (
|
|||||||
coin_amount BIGINT NOT NULL COMMENT '金币数量',
|
coin_amount BIGINT NOT NULL COMMENT '金币数量',
|
||||||
payload_json JSON NOT NULL COMMENT '业务负载 JSON 快照',
|
payload_json JSON NOT NULL COMMENT '业务负载 JSON 快照',
|
||||||
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
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',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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),
|
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)
|
KEY idx_game_outbox_order(app_code, order_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏统计事件 outbox 表';
|
) 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) {
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@ -193,7 +194,8 @@ func (a *App) processOutboxBatch() (int, error) {
|
|||||||
err := a.publishGameOutboxRecord(publishCtx, record)
|
err := a.publishGameOutboxRecord(publishCtx, record)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
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 0, markErr
|
||||||
}
|
}
|
||||||
return len(records), err
|
return len(records), err
|
||||||
|
|||||||
@ -229,10 +229,17 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
coin_amount BIGINT NOT NULL,
|
coin_amount BIGINT NOT NULL,
|
||||||
payload_json JSON NOT NULL,
|
payload_json JSON NOT NULL,
|
||||||
status VARCHAR(32) 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,
|
created_at_ms BIGINT NOT NULL,
|
||||||
updated_at_ms BIGINT NOT NULL,
|
updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY(app_code, event_id),
|
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)
|
KEY idx_game_outbox_order(app_code, order_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) 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 {
|
if err := r.ensureColumn(ctx, "game_orders", "region_id", "region_id BIGINT NOT NULL DEFAULT 0 AFTER room_id"); err != nil {
|
||||||
return err
|
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)
|
return r.seedDefaults(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -270,6 +301,53 @@ func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnN
|
|||||||
return err
|
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 {
|
func (r *Repository) seedDefaults(ctx context.Context) error {
|
||||||
if _, err := r.db.ExecContext(ctx,
|
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)
|
`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
|
CoinAmount int64
|
||||||
PayloadJSON string
|
PayloadJSON string
|
||||||
CreatedAtMS int64
|
CreatedAtMS int64
|
||||||
|
RetryCount int
|
||||||
|
LastError string
|
||||||
|
WorkerID string
|
||||||
|
LockUntilMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimPendingGameOutbox locks pending game facts so one worker publishes them.
|
// 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 {
|
if r == nil || r.db == nil {
|
||||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
}
|
}
|
||||||
if batchSize <= 0 {
|
if batchSize <= 0 {
|
||||||
batchSize = 100
|
batchSize = 100
|
||||||
}
|
}
|
||||||
|
workerID = strings.TrimSpace(workerID)
|
||||||
|
if workerID == "" {
|
||||||
|
workerID = "game-outbox-worker"
|
||||||
|
}
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
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,
|
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
|
coin_amount, CAST(payload_json AS CHAR), created_at_ms
|
||||||
FROM game_outbox
|
FROM game_outbox FORCE INDEX (idx_game_outbox_status_created)
|
||||||
WHERE status = 'pending'
|
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
|
ORDER BY created_at_ms ASC, event_id ASC
|
||||||
LIMIT ? FOR UPDATE SKIP LOCKED
|
LIMIT ? FOR UPDATE SKIP LOCKED
|
||||||
`, batchSize)
|
`,
|
||||||
if err != nil {
|
args: []any{appCode, nowMS},
|
||||||
return nil, err
|
},
|
||||||
|
{
|
||||||
|
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 _, branch := range claimBranches {
|
||||||
for rows.Next() {
|
remaining := batchSize - len(records)
|
||||||
var record GameOutboxRecord
|
if remaining <= 0 {
|
||||||
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 {
|
break
|
||||||
_ = rows.Close()
|
}
|
||||||
|
args := append(append([]any{}, branch.args...), remaining)
|
||||||
|
branchRecords, err := queryGameOutboxRecords(ctx, tx, branch.query, args...)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
for _, record := range records {
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE game_outbox SET status = 'running', updated_at_ms = ?
|
UPDATE game_outbox
|
||||||
WHERE app_code = ? AND event_id = ? AND status = 'pending'
|
SET status = 'running', worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
|
||||||
`, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil {
|
WHERE app_code = ? AND event_id = ?
|
||||||
|
`, workerID, lockUntilMS, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, err
|
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
|
return records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) MarkGameOutboxDelivered(ctx context.Context, appCode string, eventID string) error {
|
func (r *Repository) MarkGameOutboxDelivered(ctx context.Context, appCode string, eventID string) error {
|
||||||
_, err := r.db.ExecContext(ctx, `
|
_, 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 = ?
|
WHERE app_code = ? AND event_id = ?
|
||||||
`, time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
|
`, time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
|
||||||
return err
|
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, `
|
_, 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'
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,7 @@ wallet_notice_worker:
|
|||||||
max_backoff: 5m
|
max_backoff: 5m
|
||||||
|
|
||||||
room_notice_worker:
|
room_notice_worker:
|
||||||
enabled: true
|
enabled: false
|
||||||
poll_interval: 1s
|
poll_interval: 1s
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
lock_ttl: 30s
|
lock_ttl: 30s
|
||||||
@ -38,13 +38,18 @@ room_notice_worker:
|
|||||||
max_backoff: 5m
|
max_backoff: 5m
|
||||||
|
|
||||||
rocketmq:
|
rocketmq:
|
||||||
# Docker 本地默认关闭;接入外部 RocketMQ 后可消费 room_outbox 私有通知事实。
|
# Docker 本地默认关闭;接入外部 RocketMQ 后只消费 owner outbox 私有通知事实。
|
||||||
enabled: false
|
enabled: false
|
||||||
name_servers: []
|
name_servers: []
|
||||||
name_server_domain: ""
|
name_server_domain: ""
|
||||||
access_key: ""
|
access_key: ""
|
||||||
secret_key: ""
|
secret_key: ""
|
||||||
namespace: ""
|
namespace: ""
|
||||||
|
wallet_outbox:
|
||||||
|
enabled: false
|
||||||
|
topic: "hyapp_wallet_outbox"
|
||||||
|
consumer_group: "hyapp-notice-wallet-outbox"
|
||||||
|
consumer_max_reconsume_times: 16
|
||||||
room_outbox:
|
room_outbox:
|
||||||
enabled: false
|
enabled: false
|
||||||
topic: "hyapp_room_outbox"
|
topic: "hyapp_room_outbox"
|
||||||
|
|||||||
@ -18,8 +18,8 @@ tencent_im:
|
|||||||
request_timeout: 5s
|
request_timeout: 5s
|
||||||
|
|
||||||
wallet_notice_worker:
|
wallet_notice_worker:
|
||||||
enabled: true
|
enabled: false
|
||||||
poll_interval: 500ms
|
poll_interval: 1s
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
lock_ttl: 30s
|
lock_ttl: 30s
|
||||||
publish_timeout: 3s
|
publish_timeout: 3s
|
||||||
@ -28,8 +28,8 @@ wallet_notice_worker:
|
|||||||
max_backoff: 5m
|
max_backoff: 5m
|
||||||
|
|
||||||
room_notice_worker:
|
room_notice_worker:
|
||||||
enabled: true
|
enabled: false
|
||||||
poll_interval: 500ms
|
poll_interval: 1s
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
lock_ttl: 30s
|
lock_ttl: 30s
|
||||||
publish_timeout: 3s
|
publish_timeout: 3s
|
||||||
@ -45,6 +45,11 @@ rocketmq:
|
|||||||
access_key: "TENCENT_ROCKETMQ_ACCESS_KEY"
|
access_key: "TENCENT_ROCKETMQ_ACCESS_KEY"
|
||||||
secret_key: "TENCENT_ROCKETMQ_SECRET_KEY"
|
secret_key: "TENCENT_ROCKETMQ_SECRET_KEY"
|
||||||
namespace: "hyapp-prod"
|
namespace: "hyapp-prod"
|
||||||
|
wallet_outbox:
|
||||||
|
enabled: true
|
||||||
|
topic: "hyapp_wallet_outbox"
|
||||||
|
consumer_group: "hyapp-notice-wallet-outbox"
|
||||||
|
consumer_max_reconsume_times: 16
|
||||||
room_outbox:
|
room_outbox:
|
||||||
enabled: true
|
enabled: true
|
||||||
topic: "hyapp_room_outbox"
|
topic: "hyapp_room_outbox"
|
||||||
|
|||||||
@ -28,7 +28,7 @@ wallet_notice_worker:
|
|||||||
max_backoff: 5m
|
max_backoff: 5m
|
||||||
|
|
||||||
room_notice_worker:
|
room_notice_worker:
|
||||||
enabled: true
|
enabled: false
|
||||||
poll_interval: 1s
|
poll_interval: 1s
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
lock_ttl: 30s
|
lock_ttl: 30s
|
||||||
@ -38,13 +38,18 @@ room_notice_worker:
|
|||||||
max_backoff: 5m
|
max_backoff: 5m
|
||||||
|
|
||||||
rocketmq:
|
rocketmq:
|
||||||
# 本地默认关闭;开启后 notice 直接消费 room_outbox MQ fanout,仍写 notice_delivery_events 做幂等。
|
# 本地默认关闭;开启后 notice 只消费 owner outbox MQ fanout,仍写 notice_delivery_events 做幂等。
|
||||||
enabled: false
|
enabled: false
|
||||||
name_servers: []
|
name_servers: []
|
||||||
name_server_domain: ""
|
name_server_domain: ""
|
||||||
access_key: ""
|
access_key: ""
|
||||||
secret_key: ""
|
secret_key: ""
|
||||||
namespace: ""
|
namespace: ""
|
||||||
|
wallet_outbox:
|
||||||
|
enabled: false
|
||||||
|
topic: "hyapp_wallet_outbox"
|
||||||
|
consumer_group: "hyapp-notice-wallet-outbox"
|
||||||
|
consumer_max_reconsume_times: 16
|
||||||
room_outbox:
|
room_outbox:
|
||||||
enabled: false
|
enabled: false
|
||||||
topic: "hyapp_room_outbox"
|
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',
|
created_at_ms BIGINT NOT NULL COMMENT '通知位点创建时间,UTC epoch ms',
|
||||||
updated_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),
|
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)
|
KEY idx_notice_delivery_target (app_code, target_user_id, updated_at_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
COMMENT='通知服务外部通知投递位点和死信表';
|
COMMENT='通知服务外部通知投递位点和死信表';
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import (
|
|||||||
"hyapp/pkg/rocketmqx"
|
"hyapp/pkg/rocketmqx"
|
||||||
"hyapp/pkg/roommq"
|
"hyapp/pkg/roommq"
|
||||||
"hyapp/pkg/tencentim"
|
"hyapp/pkg/tencentim"
|
||||||
|
"hyapp/pkg/walletmq"
|
||||||
"hyapp/services/notice-service/internal/config"
|
"hyapp/services/notice-service/internal/config"
|
||||||
"hyapp/services/notice-service/internal/modules/roomnotice"
|
"hyapp/services/notice-service/internal/modules/roomnotice"
|
||||||
"hyapp/services/notice-service/internal/modules/walletnotice"
|
"hyapp/services/notice-service/internal/modules/walletnotice"
|
||||||
@ -92,6 +93,11 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
_ = store.Close()
|
_ = store.Close()
|
||||||
return nil, errors.New("room notice worker requires tencent_im.enabled")
|
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 {
|
if cfg.RocketMQ.RoomOutbox.Enabled && publisher == nil {
|
||||||
_ = listener.Close()
|
_ = listener.Close()
|
||||||
_ = store.Close()
|
_ = store.Close()
|
||||||
@ -114,14 +120,37 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
walletSvc := walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher)
|
walletSvc := walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher)
|
||||||
roomSvc := roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher)
|
roomSvc := roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher)
|
||||||
roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker)
|
roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker)
|
||||||
mqConsumers := make([]*rocketmqx.Consumer, 0, 1)
|
mqConsumers := make([]*rocketmqx.Consumer, 0, 2)
|
||||||
if cfg.RocketMQ.RoomOutbox.Enabled {
|
if cfg.RocketMQ.WalletOutbox.Enabled {
|
||||||
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ))
|
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = listener.Close()
|
_ = listener.Close()
|
||||||
_ = store.Close()
|
_ = store.Close()
|
||||||
return nil, err
|
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 {
|
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||||
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
|
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -132,6 +161,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
_ = listener.Close()
|
_ = listener.Close()
|
||||||
_ = store.Close()
|
_ = store.Close()
|
||||||
|
shutdownConsumers(mqConsumers)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
mqConsumers = append(mqConsumers, consumer)
|
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{
|
return rocketmqx.ConsumerConfig{
|
||||||
EndpointConfig: rocketmqx.EndpointConfig{
|
EndpointConfig: rocketmqx.EndpointConfig{
|
||||||
NameServers: cfg.NameServers,
|
NameServers: cfg.NameServers,
|
||||||
@ -278,8 +314,8 @@ func rocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig
|
|||||||
Namespace: cfg.Namespace,
|
Namespace: cfg.Namespace,
|
||||||
VIPChannel: cfg.VIPChannel,
|
VIPChannel: cfg.VIPChannel,
|
||||||
},
|
},
|
||||||
GroupName: cfg.RoomOutbox.ConsumerGroup,
|
GroupName: group,
|
||||||
MaxReconsumeTimes: cfg.RoomOutbox.ConsumerMaxReconsumeTimes,
|
MaxReconsumeTimes: maxReconsumeTimes,
|
||||||
ConsumeRetryDelay: time.Second,
|
ConsumeRetryDelay: time.Second,
|
||||||
ConsumePullBatch: 32,
|
ConsumePullBatch: 32,
|
||||||
ConsumePullTimeout: 15 * time.Minute,
|
ConsumePullTimeout: 15 * time.Minute,
|
||||||
|
|||||||
@ -21,9 +21,9 @@ type Config struct {
|
|||||||
HealthHTTPAddr string `yaml:"health_http_addr"`
|
HealthHTTPAddr string `yaml:"health_http_addr"`
|
||||||
// MySQLDSN 是 notice-service 自己的投递位点和死信状态库。
|
// MySQLDSN 是 notice-service 自己的投递位点和死信状态库。
|
||||||
MySQLDSN string `yaml:"mysql_dsn"`
|
MySQLDSN string `yaml:"mysql_dsn"`
|
||||||
// WalletDatabase 是 wallet_outbox 所在库名;notice 只把它当 append-only 事实源读取。
|
// WalletDatabase 仅保留给历史离线修复工具使用;运行态 notice 不再直扫 wallet_outbox。
|
||||||
WalletDatabase string `yaml:"wallet_database"`
|
WalletDatabase string `yaml:"wallet_database"`
|
||||||
// RoomDatabase 是 room_outbox 所在库名;notice 只读取需要私有通知的房间事实。
|
// RoomDatabase 仅保留给历史离线修复工具使用;运行态 notice 不再直扫 room_outbox。
|
||||||
RoomDatabase string `yaml:"room_database"`
|
RoomDatabase string `yaml:"room_database"`
|
||||||
// TencentIM 是私有 IM 投递通道配置;本地默认关闭,避免误发真实消息。
|
// TencentIM 是私有 IM 投递通道配置;本地默认关闭,避免误发真实消息。
|
||||||
TencentIM TencentIMConfig `yaml:"tencent_im"`
|
TencentIM TencentIMConfig `yaml:"tencent_im"`
|
||||||
@ -31,7 +31,7 @@ type Config struct {
|
|||||||
WalletNoticeWorker WalletNoticeWorkerConfig `yaml:"wallet_notice_worker"`
|
WalletNoticeWorker WalletNoticeWorkerConfig `yaml:"wallet_notice_worker"`
|
||||||
// RoomNoticeWorker 消费 room_outbox 中需要给单个用户发送的房间通知。
|
// RoomNoticeWorker 消费 room_outbox 中需要给单个用户发送的房间通知。
|
||||||
RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"`
|
RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"`
|
||||||
// RocketMQ 可直接消费 room-service 发布的 room_outbox fanout topic。
|
// RocketMQ 消费 owner service 发布的 outbox fanout topic。
|
||||||
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
||||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||||
Log logx.Config `yaml:"log"`
|
Log logx.Config `yaml:"log"`
|
||||||
@ -76,7 +76,7 @@ type WalletNoticeWorkerConfig struct {
|
|||||||
MaxBackoff time.Duration `yaml:"max_backoff"`
|
MaxBackoff time.Duration `yaml:"max_backoff"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RocketMQConfig 描述 notice-service 消费房间事实的 MQ 连接。
|
// RocketMQConfig 描述 notice-service 消费 owner outbox 事实的 MQ 连接。
|
||||||
type RocketMQConfig struct {
|
type RocketMQConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
NameServers []string `yaml:"name_servers"`
|
NameServers []string `yaml:"name_servers"`
|
||||||
@ -86,6 +86,7 @@ type RocketMQConfig struct {
|
|||||||
SecurityToken string `yaml:"security_token"`
|
SecurityToken string `yaml:"security_token"`
|
||||||
Namespace string `yaml:"namespace"`
|
Namespace string `yaml:"namespace"`
|
||||||
VIPChannel bool `yaml:"vip_channel"`
|
VIPChannel bool `yaml:"vip_channel"`
|
||||||
|
WalletOutbox RoomOutboxMQConfig `yaml:"wallet_outbox"`
|
||||||
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,6 +149,12 @@ func Default() Config {
|
|||||||
func defaultRocketMQConfig() RocketMQConfig {
|
func defaultRocketMQConfig() RocketMQConfig {
|
||||||
return RocketMQConfig{
|
return RocketMQConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
WalletOutbox: RoomOutboxMQConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Topic: "hyapp_wallet_outbox",
|
||||||
|
ConsumerGroup: "hyapp-notice-wallet-outbox",
|
||||||
|
ConsumerMaxReconsumeTimes: 16,
|
||||||
|
},
|
||||||
RoomOutbox: RoomOutboxMQConfig{
|
RoomOutbox: RoomOutboxMQConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Topic: "hyapp_room_outbox",
|
Topic: "hyapp_room_outbox",
|
||||||
@ -201,6 +208,12 @@ func Load(path string) (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
cfg.RocketMQ = rocketMQ
|
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) == "" {
|
if strings.TrimSpace(cfg.TencentIM.AdminIdentifier) == "" {
|
||||||
cfg.TencentIM.AdminIdentifier = "administrator"
|
cfg.TencentIM.AdminIdentifier = "administrator"
|
||||||
}
|
}
|
||||||
@ -233,7 +246,16 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
|
|||||||
if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||||
cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes
|
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
|
cfg.Enabled = true
|
||||||
}
|
}
|
||||||
if cfg.Enabled {
|
if cfg.Enabled {
|
||||||
|
|||||||
@ -7,17 +7,23 @@ func TestLoadLocalKeepsRocketMQDisabled(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load local config failed: %v", err)
|
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)
|
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")
|
cfg, err := Load("../../configs/config.tencent.example.yaml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load tencent example failed: %v", err)
|
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)
|
t.Fatalf("tencent example must configure room outbox MQ consumer: %+v", cfg.RocketMQ)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
|
||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
)
|
)
|
||||||
@ -96,7 +97,7 @@ func TestMySQLRepositoryProcessesRealRoomKickOutbox(t *testing.T) {
|
|||||||
|
|
||||||
publisher := &fakePublisher{}
|
publisher := &fakePublisher{}
|
||||||
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
|
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",
|
WorkerID: "notice-real-room-test-worker",
|
||||||
BatchSize: 1,
|
BatchSize: 1,
|
||||||
LockTTL: 30 * time.Second,
|
LockTTL: 30 * time.Second,
|
||||||
@ -166,7 +167,7 @@ func ensureRoomOutboxTable(ctx context.Context, db *sql.DB, roomDatabase string)
|
|||||||
last_error TEXT NULL,
|
last_error TEXT NULL,
|
||||||
updated_at_ms BIGINT NOT NULL,
|
updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, event_id),
|
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`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
quoteDB(roomDatabase),
|
quoteDB(roomDatabase),
|
||||||
))
|
))
|
||||||
|
|||||||
@ -103,35 +103,72 @@ type roomKickCandidate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *MySQLRepository) listRoomKickCandidates(ctx context.Context, limit int, nowMs int64) ([]roomKickCandidate, error) {
|
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
|
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
|
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 = ?
|
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 = ?
|
WHERE ro.app_code = ? AND ro.event_type = ?
|
||||||
AND (
|
AND nde.source_event_id IS NULL
|
||||||
nde.source_event_id IS NULL
|
|
||||||
OR (nde.status = ? AND nde.next_retry_at_ms <= ?)
|
|
||||||
OR (nde.status = ? AND nde.lock_until_ms <= ?)
|
|
||||||
)
|
|
||||||
ORDER BY ro.created_at_ms ASC, ro.event_id ASC
|
ORDER BY ro.created_at_ms ASC, ro.event_id ASC
|
||||||
LIMIT ?`, r.roomOutboxTable)
|
LIMIT ?`, r.roomOutboxTable),
|
||||||
rows, err := r.db.QueryContext(ctx, query,
|
args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked},
|
||||||
sourceRoomOutbox,
|
},
|
||||||
channelTencentIMC2C,
|
{
|
||||||
eventRoomUserKicked,
|
query: fmt.Sprintf(`
|
||||||
deliveryStatusRetryable,
|
SELECT ro.app_code, ro.event_id
|
||||||
nowMs,
|
FROM %s ro FORCE INDEX (idx_room_outbox_event_created)
|
||||||
deliveryStatusDelivering,
|
JOIN notice_delivery_events nde
|
||||||
nowMs,
|
ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ?
|
||||||
limit,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
candidates := make([]roomKickCandidate, 0, limit)
|
candidates := make([]roomKickCandidate, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var candidate roomKickCandidate
|
var candidate roomKickCandidate
|
||||||
if err := rows.Scan(&candidate.AppCode, &candidate.EventID); err != nil {
|
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
|
UPDATE notice_delivery_events
|
||||||
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
|
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
|
||||||
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
|
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
|
||||||
AND (
|
AND status = ? AND next_retry_at_ms <= ?`,
|
||||||
(status = ? AND next_retry_at_ms <= ?)
|
|
||||||
OR (status = ? AND lock_until_ms <= ?)
|
|
||||||
)`,
|
|
||||||
deliveryStatusDelivering,
|
deliveryStatusDelivering,
|
||||||
workerID,
|
workerID,
|
||||||
lockUntilMS,
|
lockUntilMS,
|
||||||
@ -328,16 +362,40 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev
|
|||||||
channelTencentIMC2C,
|
channelTencentIMC2C,
|
||||||
deliveryStatusRetryable,
|
deliveryStatusRetryable,
|
||||||
nowMs,
|
nowMs,
|
||||||
deliveryStatusDelivering,
|
|
||||||
nowMs,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, false, err
|
return 0, false, err
|
||||||
}
|
}
|
||||||
affected, err := result.RowsAffected()
|
affected, err := result.RowsAffected()
|
||||||
if err != nil || affected == 0 {
|
if err != nil {
|
||||||
return 0, false, err
|
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
|
var retryCount int
|
||||||
err = tx.QueryRowContext(ctx, `
|
err = tx.QueryRowContext(ctx, `
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
|
func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
|
||||||
@ -82,7 +83,7 @@ func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
|
|||||||
|
|
||||||
publisher := &fakePublisher{}
|
publisher := &fakePublisher{}
|
||||||
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
|
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",
|
WorkerID: "notice-real-test-worker",
|
||||||
BatchSize: 1,
|
BatchSize: 1,
|
||||||
LockTTL: 30 * time.Second,
|
LockTTL: 30 * time.Second,
|
||||||
@ -186,7 +187,7 @@ func TestMySQLRepositoryProcessesRealVipActivatedOutbox(t *testing.T) {
|
|||||||
|
|
||||||
publisher := &fakePublisher{}
|
publisher := &fakePublisher{}
|
||||||
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
|
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",
|
WorkerID: "notice-real-test-worker",
|
||||||
BatchSize: 1,
|
BatchSize: 1,
|
||||||
LockTTL: 30 * time.Second,
|
LockTTL: 30 * time.Second,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/walletmq"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -108,40 +109,71 @@ type walletBalanceCandidate struct {
|
|||||||
|
|
||||||
func (r *MySQLRepository) listWalletBalanceCandidates(ctx context.Context, limit int, nowMs int64) ([]walletBalanceCandidate, error) {
|
func (r *MySQLRepository) listWalletBalanceCandidates(ctx context.Context, limit int, nowMs int64) ([]walletBalanceCandidate, error) {
|
||||||
eventTypes := walletPrivateNoticeEventTypes()
|
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
|
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
|
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 = ?
|
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)
|
WHERE wo.app_code = ? AND wo.event_type IN (%s)
|
||||||
AND (
|
AND nde.source_event_id IS NULL
|
||||||
nde.source_event_id IS NULL
|
|
||||||
OR (nde.status = ? AND nde.next_retry_at_ms <= ?)
|
|
||||||
OR (nde.status = ? AND nde.lock_until_ms <= ?)
|
|
||||||
)
|
|
||||||
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
|
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
|
||||||
LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes)))
|
LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))),
|
||||||
args := []any{
|
args: append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...),
|
||||||
sourceWalletOutbox,
|
},
|
||||||
channelTencentIMC2C,
|
{
|
||||||
|
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 {
|
for _, branch := range branches {
|
||||||
args = append(args, eventType)
|
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,
|
return candidates, nil
|
||||||
deliveryStatusRetryable,
|
}
|
||||||
nowMs,
|
|
||||||
deliveryStatusDelivering,
|
func queryWalletBalanceCandidates(ctx context.Context, db *sql.DB, query string, args ...any) ([]walletBalanceCandidate, error) {
|
||||||
nowMs,
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
limit,
|
|
||||||
)
|
|
||||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
candidates := make([]walletBalanceCandidate, 0)
|
||||||
candidates := make([]walletBalanceCandidate, 0, limit)
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var candidate walletBalanceCandidate
|
var candidate walletBalanceCandidate
|
||||||
if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.EventType); err != nil {
|
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
|
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) {
|
func (r *MySQLRepository) lockWalletBalanceEvent(ctx context.Context, tx *sql.Tx, candidate walletBalanceCandidate) (WalletBalanceEvent, error) {
|
||||||
var event WalletBalanceEvent
|
var event WalletBalanceEvent
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
@ -241,10 +340,7 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev
|
|||||||
UPDATE notice_delivery_events
|
UPDATE notice_delivery_events
|
||||||
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
|
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
|
||||||
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
|
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
|
||||||
AND (
|
AND status = ? AND next_retry_at_ms <= ?`,
|
||||||
(status = ? AND next_retry_at_ms <= ?)
|
|
||||||
OR (status = ? AND lock_until_ms <= ?)
|
|
||||||
)`,
|
|
||||||
deliveryStatusDelivering,
|
deliveryStatusDelivering,
|
||||||
workerID,
|
workerID,
|
||||||
lockUntilMS,
|
lockUntilMS,
|
||||||
@ -256,16 +352,40 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev
|
|||||||
channelTencentIMC2C,
|
channelTencentIMC2C,
|
||||||
deliveryStatusRetryable,
|
deliveryStatusRetryable,
|
||||||
nowMs,
|
nowMs,
|
||||||
deliveryStatusDelivering,
|
|
||||||
nowMs,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, false, err
|
return 0, false, err
|
||||||
}
|
}
|
||||||
affected, err := result.RowsAffected()
|
affected, err := result.RowsAffected()
|
||||||
if err != nil || affected == 0 {
|
if err != nil {
|
||||||
return 0, false, err
|
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
|
var retryCount int
|
||||||
err = tx.QueryRowContext(ctx, `
|
err = tx.QueryRowContext(ctx, `
|
||||||
@ -369,6 +489,14 @@ func sqlPlaceholders(count int) string {
|
|||||||
return strings.Join(parts, ",")
|
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 {
|
func isDuplicateKey(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -10,11 +10,13 @@ import (
|
|||||||
|
|
||||||
"hyapp/pkg/logx"
|
"hyapp/pkg/logx"
|
||||||
"hyapp/pkg/tencentim"
|
"hyapp/pkg/tencentim"
|
||||||
|
"hyapp/pkg/walletmq"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository 是 notice-service 需要的持久化能力。
|
// Repository 是 notice-service 需要的持久化能力。
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
ClaimWalletBalanceEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]WalletBalanceEvent, error)
|
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
|
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
|
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
|
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
|
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 {
|
func (s *Service) publishWalletBalanceEvent(ctx context.Context, event WalletBalanceEvent, options WalletNoticeWorkerOptions) error {
|
||||||
payload, err := walletBalanceNoticePayload(event)
|
payload, err := walletBalanceNoticePayload(event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -131,6 +153,15 @@ func walletNoticeExt(eventType string) string {
|
|||||||
return "wallet_notice"
|
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) {
|
func walletBalanceNoticePayload(event WalletBalanceEvent) (json.RawMessage, error) {
|
||||||
var payload map[string]any
|
var payload map[string]any
|
||||||
if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil {
|
if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp/pkg/tencentim"
|
"hyapp/pkg/tencentim"
|
||||||
|
"hyapp/pkg/walletmq"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProcessWalletBalanceNoticesPublishesPrivateIM(t *testing.T) {
|
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) {
|
func TestProcessWalletBalanceNoticesMarksRetryable(t *testing.T) {
|
||||||
repo := &fakeRepository{
|
repo := &fakeRepository{
|
||||||
events: []WalletBalanceEvent{{
|
events: []WalletBalanceEvent{{
|
||||||
@ -117,10 +146,11 @@ func TestProcessWalletBalanceNoticesMarksRetryable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeRepository struct {
|
type fakeRepository struct {
|
||||||
events []WalletBalanceEvent
|
events []WalletBalanceEvent
|
||||||
delivered []WalletBalanceEvent
|
claimedMessages []walletmq.WalletOutboxMessage
|
||||||
retryable []retryMarker
|
delivered []WalletBalanceEvent
|
||||||
failed []WalletBalanceEvent
|
retryable []retryMarker
|
||||||
|
failed []WalletBalanceEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
type retryMarker struct {
|
type retryMarker struct {
|
||||||
@ -134,6 +164,23 @@ func (r *fakeRepository) ClaimWalletBalanceEvents(context.Context, string, int,
|
|||||||
return r.events, nil
|
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 {
|
func (r *fakeRepository) MarkWalletBalanceDelivered(_ context.Context, event WalletBalanceEvent, _ json.RawMessage, _ int64) error {
|
||||||
r.delivered = append(r.delivered, event)
|
r.delivered = append(r.delivered, event)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -143,9 +143,11 @@ CREATE TABLE IF NOT EXISTS room_outbox (
|
|||||||
last_error TEXT NULL COMMENT '最后一次失败原因',
|
last_error TEXT NULL COMMENT '最后一次失败原因',
|
||||||
updated_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),
|
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_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),
|
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)
|
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 表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间事件 outbox 表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS room_user_feed_entries (
|
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,
|
last_error TEXT NULL,
|
||||||
updated_at_ms BIGINT NOT NULL,
|
updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, event_id),
|
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_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)
|
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`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS room_user_feed_entries (
|
`CREATE TABLE IF NOT EXISTS room_user_feed_entries (
|
||||||
app_code VARCHAR(32) NOT NULL,
|
app_code VARCHAR(32) NOT NULL,
|
||||||
@ -425,14 +427,26 @@ func (r *Repository) ensureOutboxRetrySchema(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hasIndex, err := r.indexExists(ctx, "room_outbox", "idx_room_outbox_retry")
|
indexes := []struct {
|
||||||
if err != nil {
|
name string
|
||||||
return err
|
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 {
|
for _, index := range indexes {
|
||||||
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 {
|
hasIndex, err := r.indexExists(ctx, "room_outbox", index.name)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if !hasIndex {
|
||||||
|
if _, err := r.db.ExecContext(ctx, index.statement); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -1172,15 +1186,14 @@ func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox
|
|||||||
nowMS := time.Now().UTC().UnixMilli()
|
nowMS := time.Now().UTC().UnixMilli()
|
||||||
rows, err := r.db.QueryContext(ctx,
|
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, '')
|
`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 (?, ?)
|
WHERE app_code = ? AND status IN (?, ?)
|
||||||
AND (status = ? OR next_retry_at_ms <= ?)
|
AND next_retry_at_ms <= ?
|
||||||
ORDER BY created_at_ms ASC
|
ORDER BY created_at_ms ASC
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
appcode.FromContext(ctx),
|
appcode.FromContext(ctx),
|
||||||
outbox.StatusPending,
|
outbox.StatusPending,
|
||||||
outbox.StatusRetryable,
|
outbox.StatusRetryable,
|
||||||
outbox.StatusPending,
|
|
||||||
nowMS,
|
nowMS,
|
||||||
limit,
|
limit,
|
||||||
)
|
)
|
||||||
@ -1235,68 +1248,52 @@ func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, li
|
|||||||
}
|
}
|
||||||
|
|
||||||
nowMS := time.Now().UTC().UnixMilli()
|
nowMS := time.Now().UTC().UnixMilli()
|
||||||
rows, err := tx.QueryContext(ctx,
|
appCode := appcode.FromContext(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
|
|
||||||
}
|
|
||||||
|
|
||||||
records := make([]outbox.Record, 0, limit)
|
records := make([]outbox.Record, 0, limit)
|
||||||
eventIDs := make([]string, 0, limit)
|
claimBranches := []struct {
|
||||||
for rows.Next() {
|
query string
|
||||||
var envelopeBytes []byte
|
args []any
|
||||||
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 {
|
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, '')
|
||||||
_ = rows.Close()
|
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()
|
_ = tx.Rollback()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if lockUntilValue.Valid {
|
records = append(records, branchRecords...)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, eventID := range eventIDs {
|
for _, record := range records {
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`UPDATE room_outbox
|
`UPDATE room_outbox
|
||||||
SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
|
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,
|
workerID,
|
||||||
lockUntilMS,
|
lockUntilMS,
|
||||||
nowMS,
|
nowMS,
|
||||||
appcode.FromContext(ctx),
|
appCode,
|
||||||
eventID,
|
record.EventID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -1326,6 +1323,40 @@ func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, li
|
|||||||
return records, nil
|
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 标记事件投递成功。
|
// MarkOutboxDelivered 标记事件投递成功。
|
||||||
func (r *Repository) MarkOutboxDelivered(ctx context.Context, eventID string) error {
|
func (r *Repository) MarkOutboxDelivered(ctx context.Context, eventID string) error {
|
||||||
// 成功后清空 last_error,后续扫描不再返回该事件。
|
// 成功后清空 last_error,后续扫描不再返回该事件。
|
||||||
|
|||||||
@ -401,11 +401,18 @@ CREATE TABLE IF NOT EXISTS user_outbox (
|
|||||||
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
|
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
|
||||||
aggregate_id BIGINT NOT NULL COMMENT '聚合 ID',
|
aggregate_id BIGINT NOT NULL COMMENT '聚合 ID',
|
||||||
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
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 快照',
|
payload_json JSON NOT NULL COMMENT '业务负载 JSON 快照',
|
||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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),
|
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)
|
KEY idx_user_outbox_aggregate (app_code, aggregate_type, aggregate_id, created_at_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户发件箱表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户发件箱表';
|
||||||
|
|
||||||
|
|||||||
@ -433,7 +433,8 @@ func (a *App) processUserOutboxBatch(workerID string) (int, error) {
|
|||||||
cancel()
|
cancel()
|
||||||
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), a.cfg.OutboxWorker.PublishTimeout)
|
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), a.cfg.OutboxWorker.PublishTimeout)
|
||||||
if err != nil {
|
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()
|
markCancel()
|
||||||
if markErr != nil {
|
if markErr != nil {
|
||||||
return 0, markErr
|
return 0, markErr
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package mysql
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
@ -17,6 +19,10 @@ type UserOutboxRecord struct {
|
|||||||
AggregateID int64
|
AggregateID int64
|
||||||
PayloadJSON string
|
PayloadJSON string
|
||||||
CreatedAtMS int64
|
CreatedAtMS int64
|
||||||
|
RetryCount int
|
||||||
|
LastError string
|
||||||
|
WorkerID string
|
||||||
|
LockUntilMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimPendingUserOutbox locks pending user facts so one worker can publish them.
|
// 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 {
|
if batchSize <= 0 {
|
||||||
batchSize = 100
|
batchSize = 100
|
||||||
}
|
}
|
||||||
|
workerID = strings.TrimSpace(workerID)
|
||||||
|
if workerID == "" {
|
||||||
|
workerID = "user-outbox-worker"
|
||||||
|
}
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
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
|
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
|
||||||
FROM user_outbox
|
FROM user_outbox FORCE INDEX (idx_user_outbox_status_created)
|
||||||
WHERE status = 'pending'
|
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
|
ORDER BY created_at_ms ASC, event_id ASC
|
||||||
LIMIT ? FOR UPDATE SKIP LOCKED
|
LIMIT ? FOR UPDATE SKIP LOCKED
|
||||||
`, batchSize)
|
`,
|
||||||
if err != nil {
|
args: []any{appCode, nowMS},
|
||||||
return nil, err
|
},
|
||||||
|
{
|
||||||
|
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 _, branch := range claimBranches {
|
||||||
for rows.Next() {
|
remaining := batchSize - len(records)
|
||||||
var record UserOutboxRecord
|
if remaining <= 0 {
|
||||||
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.AggregateType, &record.AggregateID, &record.PayloadJSON, &record.CreatedAtMS); err != nil {
|
break
|
||||||
_ = rows.Close()
|
}
|
||||||
|
args := append(append([]any{}, branch.args...), remaining)
|
||||||
|
branchRecords, err := queryUserOutboxRecords(ctx, tx, branch.query, args...)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
for _, record := range records {
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE user_outbox
|
UPDATE user_outbox
|
||||||
SET status = 'running', updated_at_ms = ?
|
SET status = 'running', worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
|
||||||
WHERE app_code = ? AND event_id = ? AND status = 'pending'
|
WHERE app_code = ? AND event_id = ?
|
||||||
`, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil {
|
`, workerID, lockUntilMS, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, err
|
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
|
return records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,21 +139,30 @@ func (r *Repository) MarkUserOutboxDelivered(ctx context.Context, eventID string
|
|||||||
}
|
}
|
||||||
_, err := r.db.ExecContext(ctx, `
|
_, err := r.db.ExecContext(ctx, `
|
||||||
UPDATE user_outbox
|
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 = ?
|
WHERE app_code = ? AND event_id = ?
|
||||||
`, time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID)
|
`, time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkUserOutboxPending returns a failed publish to pending so the next tick retries.
|
// MarkUserOutboxRetryable releases a failed publish with a bounded retry delay.
|
||||||
func (r *Repository) MarkUserOutboxPending(ctx context.Context, eventID string) error {
|
func (r *Repository) MarkUserOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
}
|
}
|
||||||
_, err := r.db.ExecContext(ctx, `
|
_, err := r.db.ExecContext(ctx, `
|
||||||
UPDATE user_outbox
|
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'
|
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
|
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',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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),
|
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_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),
|
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_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_asset_event_created (app_code, asset_type, event_type, created_at_ms, event_id),
|
||||||
KEY idx_wallet_outbox_tx (app_code, transaction_id)
|
KEY idx_wallet_outbox_tx (app_code, transaction_id)
|
||||||
@ -325,15 +326,29 @@ PREPARE stmt FROM @ddl;
|
|||||||
EXECUTE stmt;
|
EXECUTE stmt;
|
||||||
DEALLOCATE PREPARE 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;
|
PREPARE stmt FROM @ddl;
|
||||||
EXECUTE stmt;
|
EXECUTE stmt;
|
||||||
DEALLOCATE PREPARE stmt;
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
-- claim 索引用于查找锁过期的消息;仅在缺失时添加,避免重复执行 initdb 报重复索引。
|
-- claim 索引用于查找锁过期的消息;列顺序也跟查询条件绑定,存在旧结构时重建。
|
||||||
SET @ddl := IF(
|
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,
|
(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)',
|
'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'
|
'SELECT 1'
|
||||||
);
|
);
|
||||||
PREPARE stmt FROM @ddl;
|
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()
|
lockUntilMS = time.Now().UTC().Add(30 * time.Second).UnixMilli()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appCode := appcode.FromContext(ctx)
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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,
|
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,
|
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)
|
retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0)
|
||||||
FROM wallet_outbox
|
FROM wallet_outbox FORCE INDEX (idx_wallet_outbox_pending)
|
||||||
WHERE status IN (?, ?)
|
WHERE app_code = ? AND status = ?
|
||||||
AND (next_retry_at_ms IS NULL OR next_retry_at_ms <= ?)
|
AND next_retry_at_ms IS NULL
|
||||||
AND (lock_until_ms IS NULL OR lock_until_ms <= ?)
|
AND lock_until_ms IS NULL
|
||||||
ORDER BY created_at_ms ASC, event_id ASC
|
ORDER BY created_at_ms ASC, event_id ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
FOR UPDATE`,
|
FOR UPDATE SKIP LOCKED`,
|
||||||
outboxStatusPending,
|
args: []any{appCode, outboxStatusPending},
|
||||||
outboxStatusRetryable,
|
},
|
||||||
nowMS,
|
{
|
||||||
nowMS,
|
query: `
|
||||||
limit,
|
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,
|
||||||
if err != nil {
|
retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0)
|
||||||
_ = tx.Rollback()
|
FROM wallet_outbox FORCE INDEX (idx_wallet_outbox_pending)
|
||||||
return nil, err
|
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)
|
for _, record := range records {
|
||||||
eventIDs := make([]string, 0, limit)
|
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() {
|
for rows.Next() {
|
||||||
var record WalletOutboxRecord
|
var record WalletOutboxRecord
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
@ -1601,45 +1673,13 @@ func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID stri
|
|||||||
&record.WorkerID,
|
&record.WorkerID,
|
||||||
&record.LockUntilMS,
|
&record.LockUntilMS,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
_ = rows.Close()
|
|
||||||
_ = tx.Rollback()
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
records = append(records, record)
|
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 {
|
if err := rows.Err(); err != nil {
|
||||||
_ = tx.Rollback()
|
|
||||||
return nil, err
|
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
|
return records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user