2026-05-12 09:53:20 +08:00

329 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package region
import (
"context"
"database/sql"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
"hyapp/services/user-service/internal/storage/mysql/shared"
)
func (r *Repository) ProcessNextRegionRebuildTask(ctx context.Context, workerID string, nowMs int64, lockTTL time.Duration, batchSize int) (userdomain.RegionRebuildProcessResult, error) {
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
if batchSize <= 0 {
batchSize = 500
}
// claim 必须独立提交running/locked_until_ms/attempt_count 要对其他 worker 和运维可见。
task, ok, err := claimRegionRebuildTask(ctx, r.db, appcode.FromContext(ctx), workerID, nowMs, lockTTL)
if err != nil {
return userdomain.RegionRebuildProcessResult{}, err
}
if !ok {
return userdomain.RegionRebuildProcessResult{NoTask: true}, nil
}
return r.processClaimedRegionRebuildTask(ctx, task.TaskID, workerID, nowMs, batchSize)
}
func (r *Repository) processClaimedRegionRebuildTask(ctx context.Context, taskID int64, workerID string, nowMs int64, batchSize int) (userdomain.RegionRebuildProcessResult, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return userdomain.RegionRebuildProcessResult{}, err
}
defer tx.Rollback()
task, ok, err := lockClaimedRegionRebuildTask(ctx, tx, taskID, workerID)
if err != nil {
return userdomain.RegionRebuildProcessResult{}, err
}
if !ok {
// task 在 claim 提交后被接管或人工改动时,本轮不应继续写 users。
return userdomain.RegionRebuildProcessResult{TaskID: taskID}, xerr.New(xerr.Conflict, "region rebuild task lock is lost")
}
result := userdomain.RegionRebuildProcessResult{
AppCode: task.AppCode,
TaskID: task.TaskID,
CountryCode: task.CountryCode,
TargetRegionID: task.TargetRegionID,
Status: userdomain.RegionRebuildTaskStatusRunning,
CursorUserID: task.CursorUserID,
}
stale, err := newerRegionRebuildTaskExists(ctx, tx, task.AppCode, task.CountryCode, task.MappingRevision, task.TaskID)
if err != nil {
return result, err
}
if stale {
// 旧任务不能覆盖新映射;直接标记 skipped等待较新任务刷新历史用户。
if err := finishRegionRebuildTask(ctx, tx, task.TaskID, workerID, userdomain.RegionRebuildTaskStatusSkipped, task.CursorUserID, "stale mapping revision", nowMs); err != nil {
return result, err
}
if err := tx.Commit(); err != nil {
return result, err
}
result.Status = userdomain.RegionRebuildTaskStatusSkipped
return result, nil
}
userIDs, err := selectRegionRebuildUserIDs(ctx, tx, task.AppCode, task.CountryCode, task.CursorUserID, batchSize)
if err != nil {
return result, err
}
if len(userIDs) == 0 {
if err := finishRegionRebuildTask(ctx, tx, task.TaskID, workerID, userdomain.RegionRebuildTaskStatusCompleted, task.CursorUserID, "", nowMs); err != nil {
return result, err
}
if err := tx.Commit(); err != nil {
return result, err
}
result.Status = userdomain.RegionRebuildTaskStatusCompleted
return result, nil
}
if err := updateUsersRegionByIDs(ctx, tx, task.AppCode, userIDs, task.TargetRegionID, nowMs); err != nil {
return result, err
}
newCursor := userIDs[len(userIDs)-1]
status := userdomain.RegionRebuildTaskStatusPending
if len(userIDs) < batchSize {
status = userdomain.RegionRebuildTaskStatusCompleted
}
if err := finishRegionRebuildTask(ctx, tx, task.TaskID, workerID, status, newCursor, "", nowMs); err != nil {
return result, err
}
if err := tx.Commit(); err != nil {
return result, err
}
result.Status = status
result.ProcessedUsers = len(userIDs)
result.CursorUserID = newCursor
return result, nil
}
func insertRebuildTask(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, countryCode string, targetRegionID int64, revision int64) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO user_region_rebuild_tasks (app_code, region_id, country_code, target_region_id, mapping_revision, status, cursor_user_id, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
`, appcode.Normalize(appCode), shared.NullableRegionID(regionID), countryCode, shared.NullableRegionID(targetRegionID), revision, userdomain.RegionRebuildTaskStatusPending, revision, revision)
return err
}
func claimRegionRebuildTask(ctx context.Context, db *sql.DB, appCode string, workerID string, nowMs int64, lockTTL time.Duration) (userdomain.UserRegionRebuildTask, bool, error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return userdomain.UserRegionRebuildTask{}, false, err
}
defer tx.Rollback()
task, ok, err := selectClaimableRegionRebuildTask(ctx, tx, appCode, nowMs)
if err != nil {
return userdomain.UserRegionRebuildTask{}, false, err
}
if !ok {
return userdomain.UserRegionRebuildTask{}, false, nil
}
lockedUntilMs := nowMs + lockTTL.Milliseconds()
result, err := tx.ExecContext(ctx, `
UPDATE user_region_rebuild_tasks
SET status = ?, locked_by = ?, locked_until_ms = ?, attempt_count = attempt_count + 1, error_message = NULL, updated_at_ms = ?
WHERE task_id = ?
`, userdomain.RegionRebuildTaskStatusRunning, workerID, lockedUntilMs, nowMs, task.TaskID)
if err != nil {
return userdomain.UserRegionRebuildTask{}, false, err
}
if affected, err := result.RowsAffected(); err != nil {
return userdomain.UserRegionRebuildTask{}, false, err
} else if affected != 1 {
return userdomain.UserRegionRebuildTask{}, false, xerr.New(xerr.Conflict, "region rebuild task claim conflict")
}
if err := tx.Commit(); err != nil {
return userdomain.UserRegionRebuildTask{}, false, err
}
task.Status = userdomain.RegionRebuildTaskStatusRunning
task.LockedBy = workerID
task.LockedUntilMs = lockedUntilMs
task.AttemptCount++
task.UpdatedAtMs = nowMs
return task, true, nil
}
func selectClaimableRegionRebuildTask(ctx context.Context, tx *sql.Tx, appCode string, nowMs int64) (userdomain.UserRegionRebuildTask, bool, error) {
row := tx.QueryRowContext(ctx, `
SELECT app_code, task_id, region_id, country_code, target_region_id, mapping_revision, status, cursor_user_id,
locked_by, locked_until_ms, attempt_count, error_message, created_at_ms, updated_at_ms
FROM user_region_rebuild_tasks
WHERE app_code = ?
AND (status = ?
OR (status = ? AND COALESCE(locked_until_ms, 0) <= ?))
ORDER BY updated_at_ms ASC, task_id ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`, appcode.Normalize(appCode), userdomain.RegionRebuildTaskStatusPending, userdomain.RegionRebuildTaskStatusRunning, nowMs)
task, err := scanRegionRebuildTask(row)
if err == sql.ErrNoRows {
return userdomain.UserRegionRebuildTask{}, false, nil
}
if err != nil {
return userdomain.UserRegionRebuildTask{}, false, err
}
return task, true, nil
}
func lockClaimedRegionRebuildTask(ctx context.Context, tx *sql.Tx, taskID int64, workerID string) (userdomain.UserRegionRebuildTask, bool, error) {
row := tx.QueryRowContext(ctx, `
SELECT app_code, task_id, region_id, country_code, target_region_id, mapping_revision, status, cursor_user_id,
locked_by, locked_until_ms, attempt_count, error_message, created_at_ms, updated_at_ms
FROM user_region_rebuild_tasks
WHERE task_id = ? AND status = ? AND locked_by = ?
FOR UPDATE
`, taskID, userdomain.RegionRebuildTaskStatusRunning, workerID)
task, err := scanRegionRebuildTask(row)
if err == sql.ErrNoRows {
return userdomain.UserRegionRebuildTask{}, false, nil
}
if err != nil {
return userdomain.UserRegionRebuildTask{}, false, err
}
return task, true, nil
}
func scanRegionRebuildTask(scanner interface{ Scan(dest ...any) error }) (userdomain.UserRegionRebuildTask, error) {
var task userdomain.UserRegionRebuildTask
var regionID sql.Null[int64]
var targetRegionID sql.Null[int64]
var lockedBy sql.Null[string]
var lockedUntil sql.Null[int64]
var errorMessage sql.Null[string]
err := scanner.Scan(
&task.AppCode,
&task.TaskID,
&regionID,
&task.CountryCode,
&targetRegionID,
&task.MappingRevision,
&task.Status,
&task.CursorUserID,
&lockedBy,
&lockedUntil,
&task.AttemptCount,
&errorMessage,
&task.CreatedAtMs,
&task.UpdatedAtMs,
)
if err != nil {
return userdomain.UserRegionRebuildTask{}, err
}
if regionID.Valid {
task.RegionID = regionID.V
}
if targetRegionID.Valid {
task.TargetRegionID = targetRegionID.V
}
if lockedBy.Valid {
task.LockedBy = lockedBy.V
}
if lockedUntil.Valid {
task.LockedUntilMs = lockedUntil.V
}
if errorMessage.Valid {
task.ErrorMessage = errorMessage.V
}
return task, nil
}
func newerRegionRebuildTaskExists(ctx context.Context, q queryer, appCode string, countryCode string, revision int64, taskID int64) (bool, error) {
var one int
err := q.QueryRowContext(ctx, `
SELECT 1
FROM user_region_rebuild_tasks
WHERE app_code = ?
AND country_code = ?
AND (mapping_revision > ? OR (mapping_revision = ? AND task_id > ?))
LIMIT 1
`, appcode.Normalize(appCode), countryCode, revision, revision, taskID).Scan(&one)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func selectRegionRebuildUserIDs(ctx context.Context, q queryer, appCode string, countryCode string, cursorUserID int64, batchSize int) ([]int64, error) {
rows, err := queryRows(ctx, q, `
SELECT user_id
FROM users
WHERE app_code = ? AND country = ? AND user_id > ?
ORDER BY user_id ASC
LIMIT ?
`, appcode.Normalize(appCode), countryCode, cursorUserID, batchSize)
if err != nil {
return nil, err
}
defer rows.Close()
userIDs := make([]int64, 0, batchSize)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, err
}
userIDs = append(userIDs, userID)
}
return userIDs, rows.Err()
}
func updateUsersRegionByIDs(ctx context.Context, tx *sql.Tx, appCode string, userIDs []int64, targetRegionID int64, nowMs int64) error {
if len(userIDs) == 0 {
return nil
}
placeholders := make([]string, 0, len(userIDs))
args := make([]any, 0, len(userIDs)+3)
args = append(args, shared.NullableRegionID(targetRegionID), nowMs, appcode.Normalize(appCode))
for _, userID := range userIDs {
placeholders = append(placeholders, "?")
args = append(args, userID)
}
_, err := tx.ExecContext(ctx, `
UPDATE users
SET region_id = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id IN (`+strings.Join(placeholders, ",")+`)
`, args...)
return err
}
func finishRegionRebuildTask(ctx context.Context, tx *sql.Tx, taskID int64, workerID string, status string, cursorUserID int64, errorMessage string, nowMs int64) error {
result, err := tx.ExecContext(ctx, `
UPDATE user_region_rebuild_tasks
SET status = ?, cursor_user_id = ?, locked_by = NULL, locked_until_ms = NULL, error_message = ?, updated_at_ms = ?
WHERE task_id = ? AND status = ? AND locked_by = ?
`, status, cursorUserID, shared.NullableString(errorMessage), nowMs, taskID, userdomain.RegionRebuildTaskStatusRunning, workerID)
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err != nil {
return err
} else if affected != 1 {
// finish 和 users 更新在同一个事务里;丢失 claim 时让事务回滚,避免旧 worker 覆盖新进度。
return xerr.New(xerr.Conflict, "region rebuild task lock is lost")
}
return nil
}