173 lines
4.5 KiB
Go
173 lines
4.5 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"sync"
|
|
"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]
|
|
giftMu sync.Mutex
|
|
giftHighWater map[string]int64
|
|
}
|
|
|
|
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),
|
|
giftHighWater: make(map[string]int64),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|