2026-05-23 12:27:44 +08:00

169 lines
4.4 KiB
Go

package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"time"
"github.com/go-mysql-org/go-mysql/mysql"
driverMysql "github.com/go-sql-driver/mysql"
"dashboard-cdc-worker/internal/config"
"dashboard-cdc-worker/internal/dashboard"
"dashboard-cdc-worker/internal/monitor"
)
const serviceName = "dashboard-cdc-worker"
const dashboardBackfillLockName = "country_dashboard_backfill_lock"
type MySQLRepository struct {
db *sql.DB
cfg config.Config
metrics *monitor.Metrics
userCache *ttlCache[int64, dashboard.UserCountry]
gameNameCache *ttlCache[string, string]
}
func NewMySQLRepository(db *sql.DB, cfg config.Config, metrics *monitor.Metrics) *MySQLRepository {
return &MySQLRepository{
db: db,
cfg: cfg,
metrics: metrics,
userCache: newTTLCache[int64, dashboard.UserCountry](30*time.Minute, 200000),
gameNameCache: newTTLCache[string, string](6*time.Hour, 10000),
}
}
func (r *MySQLRepository) WithTx(ctx context.Context, fn func(*sql.Tx) error) error {
const maxAttempts = 6
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
err = fn(tx)
if err != nil {
_ = tx.Rollback()
} else {
err = tx.Commit()
}
if err == nil {
return nil
}
lastErr = err
if !isRetryableMySQLError(err) || attempt == maxAttempts {
return err
}
if r.metrics != nil {
r.metrics.RecordRetry()
}
backoff := time.Duration(math.Pow(2, float64(attempt-1))) * 100 * time.Millisecond
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
}
return lastErr
}
func (r *MySQLRepository) WithDashboardLock(ctx context.Context, timeout time.Duration, fn func() error) error {
conn, err := r.db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
timeoutSeconds := int(math.Ceil(timeout.Seconds()))
if timeoutSeconds < 0 {
timeoutSeconds = 0
}
var acquired sql.NullInt64
if err := conn.QueryRowContext(ctx, `SELECT GET_LOCK(?, ?)`, dashboardBackfillLockName, timeoutSeconds).Scan(&acquired); err != nil {
return err
}
if !acquired.Valid || acquired.Int64 != 1 {
if r.metrics != nil {
r.metrics.RecordLockWait()
}
return dashboard.ErrDashboardBackfillLocked
}
defer func() {
var released sql.NullInt64
_ = conn.QueryRowContext(context.Background(), `SELECT RELEASE_LOCK(?)`, dashboardBackfillLockName).Scan(&released)
}()
return fn()
}
func isRetryableMySQLError(err error) bool {
var mysqlErr *driverMysql.MySQLError
if !errors.As(err, &mysqlErr) {
return false
}
return mysqlErr.Number == 1213 || mysqlErr.Number == 1205
}
func (r *MySQLRepository) LoadCheckpoint(ctx context.Context) (mysql.Position, bool, error) {
var file string
var pos uint32
err := r.db.QueryRowContext(ctx, `
SELECT binlog_file, binlog_pos
FROM dashboard_cdc_checkpoint
WHERE service_name = ?
`, serviceName).Scan(&file, &pos)
if err == sql.ErrNoRows {
return mysql.Position{}, false, nil
}
if err != nil {
return mysql.Position{}, false, err
}
position := mysql.Position{Name: file, Pos: pos}
if r.metrics != nil {
r.metrics.SetCheckpointPosition(position)
}
return position, true, nil
}
func (r *MySQLRepository) SaveCheckpoint(ctx context.Context, pos mysql.Position) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO dashboard_cdc_checkpoint (service_name, binlog_file, binlog_pos, updated_at)
VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
binlog_file = VALUES(binlog_file),
binlog_pos = VALUES(binlog_pos),
updated_at = NOW()
`, serviceName, pos.Name, pos.Pos)
if err == nil && r.metrics != nil {
r.metrics.SetCheckpointPosition(pos)
}
return err
}
func (r *MySQLRepository) MarkEventApplied(ctx context.Context, tx *sql.Tx, eventKey string, source string) (bool, error) {
result, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO dashboard_cdc_event_dedup (event_key, source, created_at)
VALUES (?, ?, NOW())
`, eventKey, source)
if err != nil {
return false, err
}
affected, err := result.RowsAffected()
if err != nil {
return false, err
}
return affected > 0, nil
}
func execAll(ctx context.Context, db *sql.DB, statements []string) error {
for _, stmt := range statements {
if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("exec schema failed: %w; sql=%s", err, stmt)
}
}
return nil
}