234 lines
7.3 KiB
Go
234 lines
7.3 KiB
Go
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"strings"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
|
||
)
|
||
|
||
// SaveBroadcastOutbox 插入一条持久化播报 outbox。
|
||
// 主键是 (app_code,event_id),重复写入会返回已有记录,保证 room outbox 重放、gRPC 重试和 cron 重跑不会重复发播报。
|
||
func (r *Repository) SaveBroadcastOutbox(ctx context.Context, record broadcastdomain.OutboxRecord) (bool, broadcastdomain.OutboxRecord, error) {
|
||
if r == nil || r.db == nil {
|
||
return false, broadcastdomain.OutboxRecord{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
record.AppCode = appcode.FromContext(ctx)
|
||
_, err := r.db.ExecContext(ctx, `
|
||
INSERT INTO im_broadcast_outbox (
|
||
app_code, event_id, scope, group_id, broadcast_type, payload_json, status,
|
||
attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms,
|
||
created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, '', 0, ?, ?)`,
|
||
record.AppCode,
|
||
record.EventID,
|
||
record.Scope,
|
||
record.GroupID,
|
||
record.BroadcastType,
|
||
record.PayloadJSON,
|
||
record.Status,
|
||
record.AttemptCount,
|
||
record.NextRetryAtMS,
|
||
record.LastError,
|
||
record.CreatedAtMS,
|
||
record.UpdatedAtMS,
|
||
)
|
||
if err == nil {
|
||
return true, record, nil
|
||
}
|
||
if !isMySQLDuplicate(err) {
|
||
return false, broadcastdomain.OutboxRecord{}, err
|
||
}
|
||
// duplicate 不能直接当成功丢弃;返回已有记录让上层知道当前状态和目标 group_id。
|
||
existing, exists, findErr := r.GetBroadcastOutbox(ctx, record.EventID)
|
||
if findErr != nil {
|
||
return false, broadcastdomain.OutboxRecord{}, findErr
|
||
}
|
||
if !exists {
|
||
return false, broadcastdomain.OutboxRecord{}, xerr.New(xerr.Conflict, "broadcast outbox event_id already exists")
|
||
}
|
||
return false, existing, nil
|
||
}
|
||
|
||
// GetBroadcastOutbox 按幂等键读取一条播报 outbox,测试和后台排障都会用到。
|
||
func (r *Repository) GetBroadcastOutbox(ctx context.Context, eventID string) (broadcastdomain.OutboxRecord, bool, error) {
|
||
row := r.db.QueryRowContext(ctx, `
|
||
SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR),
|
||
status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms,
|
||
created_at_ms, updated_at_ms
|
||
FROM im_broadcast_outbox
|
||
WHERE app_code = ? AND event_id = ?`,
|
||
appcode.FromContext(ctx), eventID,
|
||
)
|
||
record, err := scanBroadcastOutbox(row)
|
||
if err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return broadcastdomain.OutboxRecord{}, false, nil
|
||
}
|
||
return broadcastdomain.OutboxRecord{}, false, err
|
||
}
|
||
return record, true, nil
|
||
}
|
||
|
||
// ClaimPendingBroadcastOutbox 抢占一页可投递播报消息。
|
||
// FOR UPDATE SKIP LOCKED 让多个 worker 可以并行拉取不同记录;locked_until_ms 处理 worker 崩溃后的接管。
|
||
func (r *Repository) ClaimPendingBroadcastOutbox(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, limit int, maxRetry int) ([]broadcastdomain.OutboxRecord, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
if strings.TrimSpace(workerID) == "" {
|
||
workerID = "activity-broadcast"
|
||
}
|
||
if limit <= 0 {
|
||
limit = 100
|
||
}
|
||
if maxRetry <= 0 {
|
||
maxRetry = 8
|
||
}
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rows, err := tx.QueryContext(ctx, `
|
||
SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR),
|
||
status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms,
|
||
created_at_ms, updated_at_ms
|
||
FROM im_broadcast_outbox
|
||
WHERE app_code = ?
|
||
AND attempt_count < ?
|
||
AND (
|
||
(status IN (?, ?) AND next_retry_at_ms <= ?)
|
||
OR (status = ? AND locked_until_ms <= ?)
|
||
)
|
||
ORDER BY created_at_ms ASC
|
||
LIMIT ?
|
||
FOR UPDATE SKIP LOCKED`,
|
||
appcode.FromContext(ctx),
|
||
maxRetry,
|
||
broadcastdomain.StatusPending,
|
||
broadcastdomain.StatusRetryable,
|
||
nowMS,
|
||
broadcastdomain.StatusDelivering,
|
||
nowMS,
|
||
limit,
|
||
)
|
||
if err != nil {
|
||
_ = tx.Rollback()
|
||
return nil, err
|
||
}
|
||
records := make([]broadcastdomain.OutboxRecord, 0, limit)
|
||
for rows.Next() {
|
||
record, err := scanBroadcastOutbox(rows)
|
||
if err != nil {
|
||
_ = rows.Close()
|
||
_ = tx.Rollback()
|
||
return nil, err
|
||
}
|
||
records = append(records, record)
|
||
}
|
||
if err := rows.Close(); err != nil {
|
||
_ = tx.Rollback()
|
||
return nil, err
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
_ = tx.Rollback()
|
||
return nil, err
|
||
}
|
||
for _, record := range records {
|
||
// 先在同一事务里改成 delivering,再提交给调用方发送,避免两个 worker 同时发送同一 event_id。
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE im_broadcast_outbox
|
||
SET status = ?, locked_by = ?, locked_until_ms = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND event_id = ?`,
|
||
broadcastdomain.StatusDelivering,
|
||
workerID,
|
||
lockUntilMS,
|
||
nowMS,
|
||
record.AppCode,
|
||
record.EventID,
|
||
); err != nil {
|
||
_ = tx.Rollback()
|
||
return nil, err
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return nil, err
|
||
}
|
||
for i := range records {
|
||
records[i].Status = broadcastdomain.StatusDelivering
|
||
records[i].LockedBy = workerID
|
||
records[i].LockedUntilMS = lockUntilMS
|
||
}
|
||
return records, nil
|
||
}
|
||
|
||
// MarkBroadcastOutboxDelivered 在腾讯 REST 接受消息后标记 delivered。
|
||
// delivered 只代表服务端投递成功,不代表所有客户端已在线收到或已展示。
|
||
func (r *Repository) MarkBroadcastOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error {
|
||
_, err := r.db.ExecContext(ctx, `
|
||
UPDATE im_broadcast_outbox
|
||
SET status = ?, locked_by = '', locked_until_ms = 0, last_error = '', updated_at_ms = ?
|
||
WHERE app_code = ? AND event_id = ?`,
|
||
broadcastdomain.StatusDelivered,
|
||
nowMS,
|
||
appcode.FromContext(ctx),
|
||
eventID,
|
||
)
|
||
return err
|
||
}
|
||
|
||
// MarkBroadcastOutboxFailed 释放失败记录,未达上限时进入 retryable,达到上限时进入 failed。
|
||
// WHERE locked_by 限定当前 worker,防止锁过期后旧 worker 把新 worker 的处理结果覆盖掉。
|
||
func (r *Repository) MarkBroadcastOutboxFailed(ctx context.Context, eventID string, workerID string, nowMS int64, nextRetryAtMS int64, maxRetry int, lastError string) error {
|
||
if maxRetry <= 0 {
|
||
maxRetry = 8
|
||
}
|
||
_, err := r.db.ExecContext(ctx, `
|
||
UPDATE im_broadcast_outbox
|
||
SET status = CASE WHEN attempt_count + 1 >= ? THEN ? ELSE ? END,
|
||
attempt_count = attempt_count + 1,
|
||
next_retry_at_ms = CASE WHEN attempt_count + 1 >= ? THEN 0 ELSE ? END,
|
||
last_error = ?,
|
||
locked_by = '',
|
||
locked_until_ms = 0,
|
||
updated_at_ms = ?
|
||
WHERE app_code = ? AND event_id = ? AND locked_by = ?`,
|
||
maxRetry,
|
||
broadcastdomain.StatusFailed,
|
||
broadcastdomain.StatusRetryable,
|
||
maxRetry,
|
||
nextRetryAtMS,
|
||
lastError,
|
||
nowMS,
|
||
appcode.FromContext(ctx),
|
||
eventID,
|
||
workerID,
|
||
)
|
||
return err
|
||
}
|
||
|
||
func scanBroadcastOutbox(scanner interface{ Scan(dest ...any) error }) (broadcastdomain.OutboxRecord, error) {
|
||
var record broadcastdomain.OutboxRecord
|
||
err := scanner.Scan(
|
||
&record.AppCode,
|
||
&record.EventID,
|
||
&record.Scope,
|
||
&record.GroupID,
|
||
&record.BroadcastType,
|
||
&record.PayloadJSON,
|
||
&record.Status,
|
||
&record.AttemptCount,
|
||
&record.NextRetryAtMS,
|
||
&record.LastError,
|
||
&record.LockedBy,
|
||
&record.LockedUntilMS,
|
||
&record.CreatedAtMS,
|
||
&record.UpdatedAtMS,
|
||
)
|
||
return record, err
|
||
}
|