fix: stabilize dashboard cdc worker

This commit is contained in:
zhx 2026-05-23 12:27:44 +08:00
parent 84e29c689f
commit db491b92e5
22 changed files with 1527 additions and 86 deletions

View File

@ -17,6 +17,7 @@ import (
"dashboard-cdc-worker/internal/config" "dashboard-cdc-worker/internal/config"
"dashboard-cdc-worker/internal/dashboard" "dashboard-cdc-worker/internal/dashboard"
"dashboard-cdc-worker/internal/health" "dashboard-cdc-worker/internal/health"
"dashboard-cdc-worker/internal/monitor"
"dashboard-cdc-worker/internal/storage" "dashboard-cdc-worker/internal/storage"
) )
@ -42,7 +43,8 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
healthServer := health.New(cfg.Health.Addr) metrics := monitor.New()
healthServer := health.New(cfg.Health.Addr, metrics)
healthServer.Start(logger) healthServer.Start(logger)
defer func() { defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
@ -55,11 +57,12 @@ func main() {
os.Exit(1) os.Exit(1)
} }
repo := storage.NewMySQLRepository(db, cfg) repo := storage.NewMySQLRepository(db, cfg, metrics)
if err := repo.EnsureSchema(ctx); err != nil { if err := repo.EnsureSchema(ctx); err != nil {
logger.Error("ensure schema failed", "error", err) logger.Error("ensure schema failed", "error", err)
os.Exit(1) os.Exit(1)
} }
repo.StartCleanup(ctx, logger)
var redisClient *redis.Client var redisClient *redis.Client
if cfg.Redis.Enabled { if cfg.Redis.Enabled {
@ -76,8 +79,8 @@ func main() {
} }
} }
projector := dashboard.NewProjector(cfg, repo, redisClient) projector := dashboard.NewProjector(cfg, repo, redisClient, metrics)
worker, err := cdc.NewWorker(cfg, repo, projector, logger) worker, err := cdc.NewWorker(cfg, repo, projector, logger, metrics)
if err != nil { if err != nil {
logger.Error("create cdc worker failed", "error", err) logger.Error("create cdc worker failed", "error", err)
os.Exit(1) os.Exit(1)

View File

@ -5,9 +5,17 @@ DASHBOARD_MYSQL_DSN=dashboard_cdc:password@tcp(127.0.0.1:3306)/likei?charset=utf
CDC_START_MODE=checkpoint CDC_START_MODE=checkpoint
CDC_SERVER_ID=29301 CDC_SERVER_ID=29301
CDC_DRY_RUN=true CDC_DRY_RUN=true
CDC_BATCH_SIZE=1000
CDC_FLUSH_INTERVAL=3s
CDC_BACKFILL_LOCK_WAIT=1s
CDC_MASTER_POSITION_POLL_INTERVAL=1m
DASHBOARD_SYS_ORIGINS=LIKEI DASHBOARD_SYS_ORIGINS=LIKEI
DASHBOARD_STAT_TIMEZONES=Asia/Riyadh DASHBOARD_STAT_TIMEZONES=Asia/Riyadh
DASHBOARD_STORAGE_TIMEZONE=Asia/Riyadh DASHBOARD_STORAGE_TIMEZONE=Asia/Riyadh
DASHBOARD_CLEANUP_INTERVAL=30m
DASHBOARD_DEDUP_RETENTION=72h
DASHBOARD_USER_METRIC_RETENTION_DAYS=120
DASHBOARD_CLEANUP_BATCH_SIZE=5000
REDIS_ENABLED=false REDIS_ENABLED=false
REDIS_ADDR=127.0.0.1:6379 REDIS_ADDR=127.0.0.1:6379
HEALTH_ADDR=:2910 HEALTH_ADDR=:2910

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"sync" "sync"
"time"
"github.com/go-mysql-org/go-mysql/canal" "github.com/go-mysql-org/go-mysql/canal"
"github.com/go-mysql-org/go-mysql/mysql" "github.com/go-mysql-org/go-mysql/mysql"
@ -12,6 +13,7 @@ import (
"dashboard-cdc-worker/internal/config" "dashboard-cdc-worker/internal/config"
"dashboard-cdc-worker/internal/dashboard" "dashboard-cdc-worker/internal/dashboard"
"dashboard-cdc-worker/internal/monitor"
) )
type handler struct { type handler struct {
@ -23,15 +25,22 @@ type handler struct {
logger *slog.Logger logger *slog.Logger
mu sync.RWMutex mu sync.RWMutex
file string file string
batch []dashboard.Event
lastFlush time.Time
metrics *monitor.Metrics
} }
func newHandler(cfg config.Config, store dashboard.Store, projector *dashboard.Projector, logger *slog.Logger) *handler { func newHandler(cfg config.Config, store dashboard.Store, projector *dashboard.Projector,
logger *slog.Logger, metrics *monitor.Metrics) *handler {
return &handler{ return &handler{
cfg: cfg, cfg: cfg,
store: store, store: store,
projector: projector, projector: projector,
mapper: dashboard.NewMapper(store, cfg.Dashboard.SysOrigins, cfg.Dashboard.UnknownCountryCode), mapper: dashboard.NewMapper(store, cfg.Dashboard.SysOrigins, cfg.Dashboard.UnknownCountryCode),
logger: logger, logger: logger,
batch: make([]dashboard.Event, 0, cfg.CDC.BatchSize),
lastFlush: time.Now(),
metrics: metrics,
} }
} }
@ -47,18 +56,21 @@ func (h *handler) OnRow(e *canal.RowsEvent) error {
case canal.InsertAction: case canal.InsertAction:
for index, row := range e.Rows { for index, row := range e.Rows {
if err := h.applyRows(ctx, e, index, nil, row); err != nil { if err := h.applyRows(ctx, e, index, nil, row); err != nil {
h.recordError(err)
return err return err
} }
} }
case canal.DeleteAction: case canal.DeleteAction:
for index, row := range e.Rows { for index, row := range e.Rows {
if err := h.applyRows(ctx, e, index, row, nil); err != nil { if err := h.applyRows(ctx, e, index, row, nil); err != nil {
h.recordError(err)
return err return err
} }
} }
case canal.UpdateAction: case canal.UpdateAction:
for index := 0; index+1 < len(e.Rows); index += 2 { for index := 0; index+1 < len(e.Rows); index += 2 {
if err := h.applyRows(ctx, e, index/2, e.Rows[index], e.Rows[index+1]); err != nil { if err := h.applyRows(ctx, e, index/2, e.Rows[index], e.Rows[index+1]); err != nil {
h.recordError(err)
return err return err
} }
} }
@ -72,10 +84,24 @@ func (h *handler) OnPosSynced(_ *replication.EventHeader, pos mysql.Position, _
if h.cfg.CDC.DryRun { if h.cfg.CDC.DryRun {
return nil return nil
} }
if !force && h.cfg.CDC.FlushInterval > 0 { if len(h.batch) > 0 {
// canal already throttles calls; keep this hook simple and always persist when invoked. if force || h.shouldFlush() {
if err := h.flushBatch(context.Background()); err != nil {
h.recordError(err)
return err
}
} else {
return nil
}
} }
return h.store.SaveCheckpoint(context.Background(), pos) if err := h.store.SaveCheckpoint(context.Background(), pos); err != nil {
h.recordError(err)
return err
}
if h.metrics != nil {
h.metrics.SetLastSyncedPosition(pos)
}
return nil
} }
func (h *handler) String() string { func (h *handler) String() string {
@ -120,9 +146,55 @@ func (h *handler) applyRows(ctx context.Context, e *canal.RowsEvent, rowIndex in
contributions = append(contributions, items...) contributions = append(contributions, items...)
} }
eventKey := fmt.Sprintf("%s:%d:%s:%s:%d", h.currentFile(), e.Header.LogPos, source, e.Action, rowIndex) if user == nil && len(contributions) == 0 {
if user != nil { return nil
return h.projector.ApplyUser(ctx, eventKey, source, *user, contributions) }
eventKey := fmt.Sprintf("%s:%d:%s:%s:%d", h.currentFile(), e.Header.LogPos, source, e.Action, rowIndex)
event := dashboard.Event{
EventKey: eventKey,
Source: source,
User: user,
Contributions: contributions,
}
h.batch = append(h.batch, event)
if h.shouldFlush() {
return h.flushBatch(ctx)
}
return nil
}
func (h *handler) shouldFlush() bool {
if len(h.batch) == 0 {
return false
}
if len(h.batch) >= h.cfg.CDC.BatchSize {
return true
}
return h.cfg.CDC.FlushInterval > 0 && time.Since(h.lastFlush) >= h.cfg.CDC.FlushInterval
}
func (h *handler) flushBatch(ctx context.Context) error {
if len(h.batch) == 0 {
h.lastFlush = time.Now()
return nil
}
events := make([]dashboard.Event, len(h.batch))
copy(events, h.batch)
start := time.Now()
if err := h.projector.ApplyEvents(ctx, events); err != nil {
return err
}
if h.metrics != nil {
h.metrics.RecordBatch(len(events), time.Since(start))
}
h.logger.Info("dashboard cdc batch flushed", "events", len(events))
h.batch = h.batch[:0]
h.lastFlush = time.Now()
return nil
}
func (h *handler) recordError(err error) {
if h.metrics != nil {
h.metrics.RecordError(err)
} }
return h.projector.Apply(ctx, eventKey, source, contributions)
} }

View File

@ -12,6 +12,7 @@ import (
"dashboard-cdc-worker/internal/config" "dashboard-cdc-worker/internal/config"
"dashboard-cdc-worker/internal/dashboard" "dashboard-cdc-worker/internal/dashboard"
"dashboard-cdc-worker/internal/monitor"
) )
type Worker struct { type Worker struct {
@ -20,9 +21,11 @@ type Worker struct {
projector *dashboard.Projector projector *dashboard.Projector
logger *slog.Logger logger *slog.Logger
canal *canal.Canal canal *canal.Canal
metrics *monitor.Metrics
} }
func NewWorker(cfg config.Config, store dashboard.Store, projector *dashboard.Projector, logger *slog.Logger) (*Worker, error) { func NewWorker(cfg config.Config, store dashboard.Store, projector *dashboard.Projector,
logger *slog.Logger, metrics *monitor.Metrics) (*Worker, error) {
canalCfg := canal.NewDefaultConfig() canalCfg := canal.NewDefaultConfig()
canalCfg.Addr = cfg.MySQL.CDCAddr canalCfg.Addr = cfg.MySQL.CDCAddr
canalCfg.User = cfg.MySQL.CDCUser canalCfg.User = cfg.MySQL.CDCUser
@ -51,16 +54,18 @@ func NewWorker(cfg config.Config, store dashboard.Store, projector *dashboard.Pr
projector: projector, projector: projector,
logger: logger, logger: logger,
canal: instance, canal: instance,
metrics: metrics,
}, nil }, nil
} }
func (w *Worker) Run(ctx context.Context) error { func (w *Worker) Run(ctx context.Context) error {
handler := newHandler(w.cfg, w.store, w.projector, w.logger) handler := newHandler(w.cfg, w.store, w.projector, w.logger, w.metrics)
w.canal.SetEventHandler(handler) w.canal.SetEventHandler(handler)
go func() { go func() {
<-ctx.Done() <-ctx.Done()
w.canal.Close() w.canal.Close()
}() }()
w.startMasterPositionReporter(ctx)
if w.cfg.CDC.StartMode == "checkpoint" { if w.cfg.CDC.StartMode == "checkpoint" {
pos, ok, err := w.store.LoadCheckpoint(ctx) pos, ok, err := w.store.LoadCheckpoint(ctx)
@ -69,6 +74,9 @@ func (w *Worker) Run(ctx context.Context) error {
} }
if ok && pos.Name != "" && pos.Pos > 0 { if ok && pos.Name != "" && pos.Pos > 0 {
handler.setCurrentFile(pos.Name) handler.setCurrentFile(pos.Name)
if w.metrics != nil {
w.metrics.SetStartPosition(pos)
}
w.logger.Info("starting cdc from checkpoint", "position", pos.String()) w.logger.Info("starting cdc from checkpoint", "position", pos.String())
return w.canal.RunFrom(pos) return w.canal.RunFrom(pos)
} }
@ -79,10 +87,42 @@ func (w *Worker) Run(ctx context.Context) error {
return fmt.Errorf("get master position: %w", err) return fmt.Errorf("get master position: %w", err)
} }
handler.setCurrentFile(pos.Name) handler.setCurrentFile(pos.Name)
if w.metrics != nil {
w.metrics.SetStartPosition(pos)
w.metrics.SetMasterPosition(pos)
}
if w.cfg.CDC.StartMode == "current" {
if err := w.store.SaveCheckpoint(ctx, pos); err != nil {
return fmt.Errorf("save current checkpoint: %w", err)
}
}
w.logger.Info("starting cdc from current master position", "position", pos.String()) w.logger.Info("starting cdc from current master position", "position", pos.String())
return w.canal.RunFrom(pos) return w.canal.RunFrom(pos)
} }
func (w *Worker) startMasterPositionReporter(ctx context.Context) {
if w.metrics == nil || w.cfg.CDC.MasterPollEvery <= 0 {
return
}
go func() {
ticker := time.NewTicker(w.cfg.CDC.MasterPollEvery)
defer ticker.Stop()
for {
pos, err := w.canal.GetMasterPos()
if err == nil {
w.metrics.SetMasterPosition(pos)
} else {
w.metrics.RecordError(err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
func includeRegex(tables []string) []string { func includeRegex(tables []string) []string {
out := make([]string, 0, len(tables)) out := make([]string, 0, len(tables))
for _, table := range tables { for _, table := range tables {

View File

@ -38,12 +38,15 @@ type RedisConfig struct {
} }
type CDCConfig struct { type CDCConfig struct {
ServerID uint32 ServerID uint32
StartMode string StartMode string
DryRun bool DryRun bool
IncludeTables []string IncludeTables []string
FlushInterval time.Duration FlushInterval time.Duration
HeartbeatEvery time.Duration HeartbeatEvery time.Duration
BatchSize int
BackfillLockWait time.Duration
MasterPollEvery time.Duration
} }
type DashboardConfig struct { type DashboardConfig struct {
@ -53,6 +56,10 @@ type DashboardConfig struct {
RedisKeyPrefix string RedisKeyPrefix string
RealtimeRedisTTL time.Duration RealtimeRedisTTL time.Duration
UnknownCountryCode string UnknownCountryCode string
CleanupInterval time.Duration
DedupRetention time.Duration
UserMetricDays int
CleanupBatchSize int
} }
type HealthConfig struct { type HealthConfig struct {
@ -79,12 +86,15 @@ func Load() (Config, error) {
DB: envInt("REDIS_DB", 0), DB: envInt("REDIS_DB", 0),
}, },
CDC: CDCConfig{ CDC: CDCConfig{
ServerID: uint32(envInt("CDC_SERVER_ID", 29301)), ServerID: uint32(envInt("CDC_SERVER_ID", 29301)),
StartMode: strings.ToLower(env("CDC_START_MODE", "checkpoint")), StartMode: strings.ToLower(env("CDC_START_MODE", "checkpoint")),
DryRun: envBool("CDC_DRY_RUN", true), DryRun: envBool("CDC_DRY_RUN", true),
IncludeTables: csv(env("CDC_INCLUDE_TABLES", defaultTables())), IncludeTables: csv(env("CDC_INCLUDE_TABLES", defaultTables())),
FlushInterval: envDuration("CDC_FLUSH_INTERVAL", 3*time.Second), FlushInterval: envDuration("CDC_FLUSH_INTERVAL", 3*time.Second),
HeartbeatEvery: envDuration("CDC_HEARTBEAT_EVERY", 30*time.Second), HeartbeatEvery: envDuration("CDC_HEARTBEAT_EVERY", 30*time.Second),
BatchSize: envInt("CDC_BATCH_SIZE", 1000),
BackfillLockWait: envDuration("CDC_BACKFILL_LOCK_WAIT", time.Second),
MasterPollEvery: envDuration("CDC_MASTER_POSITION_POLL_INTERVAL", time.Minute),
}, },
Dashboard: DashboardConfig{ Dashboard: DashboardConfig{
SysOrigins: csv(env("DASHBOARD_SYS_ORIGINS", "LIKEI")), SysOrigins: csv(env("DASHBOARD_SYS_ORIGINS", "LIKEI")),
@ -93,6 +103,10 @@ func Load() (Config, error) {
RedisKeyPrefix: env("DASHBOARD_REDIS_KEY_PREFIX", "country_dashboard"), RedisKeyPrefix: env("DASHBOARD_REDIS_KEY_PREFIX", "country_dashboard"),
RealtimeRedisTTL: envDuration("DASHBOARD_REALTIME_REDIS_TTL", 48*time.Hour), RealtimeRedisTTL: envDuration("DASHBOARD_REALTIME_REDIS_TTL", 48*time.Hour),
UnknownCountryCode: env("DASHBOARD_UNKNOWN_COUNTRY_CODE", "UNKNOWN"), UnknownCountryCode: env("DASHBOARD_UNKNOWN_COUNTRY_CODE", "UNKNOWN"),
CleanupInterval: envDuration("DASHBOARD_CLEANUP_INTERVAL", 30*time.Minute),
DedupRetention: envDuration("DASHBOARD_DEDUP_RETENTION", 72*time.Hour),
UserMetricDays: envInt("DASHBOARD_USER_METRIC_RETENTION_DAYS", 120),
CleanupBatchSize: envInt("DASHBOARD_CLEANUP_BATCH_SIZE", 5000),
}, },
Health: HealthConfig{ Health: HealthConfig{
Addr: env("HEALTH_ADDR", ":2910"), Addr: env("HEALTH_ADDR", ":2910"),
@ -114,6 +128,12 @@ func Load() (Config, error) {
if cfg.CDC.ServerID == 0 { if cfg.CDC.ServerID == 0 {
return Config{}, errors.New("CDC_SERVER_ID must be greater than 0") return Config{}, errors.New("CDC_SERVER_ID must be greater than 0")
} }
if cfg.CDC.BatchSize <= 0 {
cfg.CDC.BatchSize = 1000
}
if cfg.Dashboard.CleanupBatchSize <= 0 {
cfg.Dashboard.CleanupBatchSize = 5000
}
if cfg.CDC.StartMode != "checkpoint" && cfg.CDC.StartMode != "current" { if cfg.CDC.StartMode != "checkpoint" && cfg.CDC.StartMode != "current" {
return Config{}, fmt.Errorf("unsupported CDC_START_MODE=%s", cfg.CDC.StartMode) return Config{}, fmt.Errorf("unsupported CDC_START_MODE=%s", cfg.CDC.StartMode)
} }

View File

@ -89,6 +89,8 @@ func (m *Mapper) mapOfficialRecharge(ctx context.Context, schema string, table s
c.GoogleRecharge = amount c.GoogleRecharge = amount
} }
c.NewUserRechargeCandidate = amount c.NewUserRechargeCandidate = amount
c.RechargeDetail = rechargeDetail("OFFICIAL", rowPK(row), payPlatform, row, user, amount,
row.Decimal("coin_quantity"), "")
return []Contribution{c}, nil return []Contribution{c}, nil
} }
@ -98,18 +100,30 @@ func (m *Mapper) mapMifapayRecharge(ctx context.Context, schema string, table st
row.String("refund_status") != "NONE" { row.String("refund_status") != "NONE" {
return nil, nil return nil, nil
} }
return m.amountContribution(ctx, schema, table, action, row, row.Decimal("compute_usd_amount"), func(c *Contribution, amount decimal.Decimal) { items, err := m.amountContribution(ctx, schema, table, action, row, row.Decimal("compute_usd_amount"), func(c *Contribution, amount decimal.Decimal) {
c.MifapayRecharge = amount c.MifapayRecharge = amount
}) })
if err != nil || len(items) == 0 {
return items, err
}
items[0].RechargeDetail = rechargeDetail("MIFAPAY", rowPK(row), row.String("factory_code"), row,
detailUser(items[0]), row.Decimal("compute_usd_amount"), decimal.Zero, "")
return items, nil
} }
func (m *Mapper) mapDealerRecharge(ctx context.Context, schema string, table string, action string, row Row) ([]Contribution, error) { func (m *Mapper) mapDealerRecharge(ctx context.Context, schema string, table string, action string, row Row) ([]Contribution, error) {
if row.String("origin") != "PURCHASE" || row.Int64("type") != 0 || !row.Decimal("amount").GreaterThan(decimal.Zero) { if row.String("origin") != "PURCHASE" || row.Int64("type") != 0 || !row.Decimal("amount").GreaterThan(decimal.Zero) {
return nil, nil return nil, nil
} }
return m.amountContribution(ctx, schema, table, action, row, row.Decimal("amount"), func(c *Contribution, amount decimal.Decimal) { items, err := m.amountContribution(ctx, schema, table, action, row, row.Decimal("amount"), func(c *Contribution, amount decimal.Decimal) {
c.DealerRecharge = amount c.DealerRecharge = amount
}) })
if err != nil || len(items) == 0 {
return items, err
}
items[0].RechargeDetail = rechargeDetail("DEALER", rowPK(row), row.String("recharge_type"), row,
detailUser(items[0]), row.Decimal("amount"), row.Decimal("quantity"), row.String("remark"))
return items, nil
} }
func (m *Mapper) mapSalaryExchange(ctx context.Context, schema string, table string, action string, row Row) ([]Contribution, error) { func (m *Mapper) mapSalaryExchange(ctx context.Context, schema string, table string, action string, row Row) ([]Contribution, error) {

View File

@ -90,6 +90,33 @@ func rowPK(row Row) string {
return strings.TrimSpace(row.String("event_id")) return strings.TrimSpace(row.String("event_id"))
} }
func rechargeDetail(sourceType string, recordID string, sourceChannel string, row Row, user UserCountry,
amount decimal.Decimal, coinQuantity decimal.Decimal, remark string) *RechargeDetail {
if recordID == "" || user.UserID == 0 {
return nil
}
return &RechargeDetail{
SourceType: sourceType,
RecordID: recordID,
SourceChannel: strings.TrimSpace(sourceChannel),
SysOrigin: user.SysOrigin,
UserID: user.UserID,
Country: user.Country,
Amount: amount,
CoinQuantity: coinQuantity,
Remark: strings.TrimSpace(remark),
EventTime: row.Time("create_time"),
}
}
func detailUser(c Contribution) UserCountry {
return UserCountry{
UserID: c.UserID,
SysOrigin: c.SysOrigin,
Country: c.Country,
}
}
func signForAction(action string) int { func signForAction(action string) int {
if action == "delete" { if action == "delete" {
return -1 return -1

View File

@ -3,12 +3,14 @@ package dashboard
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"log/slog" "log/slog"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"dashboard-cdc-worker/internal/config" "dashboard-cdc-worker/internal/config"
"dashboard-cdc-worker/internal/monitor"
) )
type Projector struct { type Projector struct {
@ -16,21 +18,17 @@ type Projector struct {
store Store store Store
redisClient *redis.Client redisClient *redis.Client
storageZone *time.Location storageZone *time.Location
statZones map[string]*time.Location metrics *monitor.Metrics
} }
func NewProjector(cfg config.Config, store Store, redisClient *redis.Client) *Projector { func NewProjector(cfg config.Config, store Store, redisClient *redis.Client, metrics *monitor.Metrics) *Projector {
storageZone := mustLocation(cfg.Dashboard.StorageTimezone) storageZone := mustLocation(cfg.Dashboard.StorageTimezone)
statZones := make(map[string]*time.Location, len(cfg.Dashboard.StatTimezones))
for _, zone := range cfg.Dashboard.StatTimezones {
statZones[zone] = mustLocation(zone)
}
return &Projector{ return &Projector{
cfg: cfg, cfg: cfg,
store: store, store: store,
redisClient: redisClient, redisClient: redisClient,
storageZone: storageZone, storageZone: storageZone,
statZones: statZones, metrics: metrics,
} }
} }
@ -43,53 +41,93 @@ func (p *Projector) ApplyUser(ctx context.Context, eventKey string, source strin
} }
func (p *Projector) apply(ctx context.Context, eventKey string, source string, user *UserCountry, contributions []Contribution) error { func (p *Projector) apply(ctx context.Context, eventKey string, source string, user *UserCountry, contributions []Contribution) error {
if user == nil && len(contributions) == 0 { event := Event{EventKey: eventKey, Source: source, User: user, Contributions: contributions}
return p.ApplyEvents(ctx, []Event{event})
}
func (p *Projector) ApplyEvents(ctx context.Context, events []Event) error {
if len(events) == 0 {
return nil return nil
} }
if len(contributions) == 0 { events = compactEvents(events)
contributions = nil if len(events) == 0 {
return nil
} }
if p.cfg.CDC.DryRun { if p.cfg.CDC.DryRun {
slog.Info("dashboard cdc dry run", "eventKey", eventKey, "source", source, "user", user != nil, "contributions", len(contributions)) slog.Info("dashboard cdc dry run batch", "events", len(events))
return nil return nil
} }
if err := p.store.WithTx(ctx, func(tx *sql.Tx) error {
applied, err := p.store.MarkEventApplied(ctx, tx, eventKey, source) var appliedContributions []Contribution
if err != nil { err := p.withDashboardLock(ctx, func() error {
return err return p.store.WithTx(ctx, func(tx *sql.Tx) error {
} batchContributions := make([]Contribution, 0, len(events))
if !applied { for _, event := range events {
applied, err := p.store.MarkEventApplied(ctx, tx, event.EventKey, event.Source)
if err != nil {
return err
}
if !applied {
continue
}
if event.User != nil {
if err := p.store.UpsertUserCountry(ctx, tx, *event.User); err != nil {
return err
}
}
for _, contribution := range event.Contributions {
if contribution.Empty() {
continue
}
if contribution.DeltaSign == 0 {
contribution.DeltaSign = 1
}
batchContributions = append(batchContributions, contribution)
}
}
if err := p.store.ApplyContributionBatch(ctx, tx, batchContributions,
p.cfg.Dashboard.StorageTimezone, p.cfg.Dashboard.StatTimezones); err != nil {
return err
}
appliedContributions = batchContributions
return nil return nil
} })
if user != nil { })
if err := p.store.UpsertUserCountry(ctx, tx, *user); err != nil { if err != nil {
return err
}
}
for _, contribution := range contributions {
if contribution.Empty() {
continue
}
if contribution.DeltaSign == 0 {
contribution.DeltaSign = 1
}
if err := p.store.ApplyDailyContribution(ctx, tx, contribution, p.cfg.Dashboard.StorageTimezone); err != nil {
return err
}
for statTimezone := range p.statZones {
if err := p.store.ApplyPeriodContribution(ctx, tx, contribution, statTimezone); err != nil {
return err
}
if err := p.store.ApplyGamePeriodContribution(ctx, tx, contribution, statTimezone); err != nil {
return err
}
}
}
return nil
}); err != nil {
return err return err
} }
return p.writeRealtimeRedis(ctx, contributions) return p.writeRealtimeRedis(ctx, appliedContributions)
}
func (p *Projector) withDashboardLock(ctx context.Context, fn func() error) error {
for {
err := p.store.WithDashboardLock(ctx, p.cfg.CDC.BackfillLockWait, fn)
if !errors.Is(err, ErrDashboardBackfillLocked) {
return err
}
if p.metrics != nil {
p.metrics.RecordLockWait()
}
slog.Warn("dashboard cdc paused because backfill lock is held")
timer := time.NewTimer(p.cfg.CDC.FlushInterval)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
func compactEvents(events []Event) []Event {
out := make([]Event, 0, len(events))
for _, event := range events {
if event.User == nil && len(event.Contributions) == 0 {
continue
}
out = append(out, event)
}
return out
} }
func mustLocation(name string) *time.Location { func mustLocation(name string) *time.Location {

View File

@ -3,15 +3,18 @@ package dashboard
import ( import (
"context" "context"
"database/sql" "database/sql"
"time"
"github.com/go-mysql-org/go-mysql/mysql" "github.com/go-mysql-org/go-mysql/mysql"
) )
type Store interface { type Store interface {
WithTx(ctx context.Context, fn func(*sql.Tx) error) error WithTx(ctx context.Context, fn func(*sql.Tx) error) error
WithDashboardLock(ctx context.Context, timeout time.Duration, fn func() error) error
MarkEventApplied(ctx context.Context, tx *sql.Tx, eventKey string, source string) (bool, error) MarkEventApplied(ctx context.Context, tx *sql.Tx, eventKey string, source string) (bool, error)
SaveCheckpoint(ctx context.Context, pos mysql.Position) error SaveCheckpoint(ctx context.Context, pos mysql.Position) error
LoadCheckpoint(ctx context.Context) (mysql.Position, bool, error) LoadCheckpoint(ctx context.Context) (mysql.Position, bool, error)
Cleanup(ctx context.Context) (int64, error)
UpsertUserCountry(ctx context.Context, tx *sql.Tx, user UserCountry) error UpsertUserCountry(ctx context.Context, tx *sql.Tx, user UserCountry) error
UserCountry(ctx context.Context, userID int64) (UserCountry, bool, error) UserCountry(ctx context.Context, userID int64) (UserCountry, bool, error)
@ -20,4 +23,5 @@ type Store interface {
ApplyDailyContribution(ctx context.Context, tx *sql.Tx, c Contribution, storageZone string) error ApplyDailyContribution(ctx context.Context, tx *sql.Tx, c Contribution, storageZone string) error
ApplyPeriodContribution(ctx context.Context, tx *sql.Tx, c Contribution, statTimezone string) error ApplyPeriodContribution(ctx context.Context, tx *sql.Tx, c Contribution, statTimezone string) error
ApplyGamePeriodContribution(ctx context.Context, tx *sql.Tx, c Contribution, statTimezone string) error ApplyGamePeriodContribution(ctx context.Context, tx *sql.Tx, c Contribution, statTimezone string) error
ApplyContributionBatch(ctx context.Context, tx *sql.Tx, contributions []Contribution, storageTimezone string, statTimezones []string) error
} }

View File

@ -1,11 +1,14 @@
package dashboard package dashboard
import ( import (
"errors"
"time" "time"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
var ErrDashboardBackfillLocked = errors.New("dashboard backfill lock is held")
const ( const (
PeriodDay = "DAY" PeriodDay = "DAY"
PeriodWeek = "WEEK" PeriodWeek = "WEEK"
@ -58,6 +61,15 @@ type Contribution struct {
GamePayoutBy decimal.Decimal GamePayoutBy decimal.Decimal
GameProfit decimal.Decimal GameProfit decimal.Decimal
GameOrder int64 GameOrder int64
RechargeDetail *RechargeDetail
}
type Event struct {
EventKey string
Source string
User *UserCountry
Contributions []Contribution
} }
func (c Contribution) Empty() bool { func (c Contribution) Empty() bool {
@ -78,7 +90,21 @@ func (c Contribution) Empty() bool {
c.GameConsume.IsZero() && c.GameConsume.IsZero() &&
c.GamePayoutBy.IsZero() && c.GamePayoutBy.IsZero() &&
c.GameProfit.IsZero() && c.GameProfit.IsZero() &&
c.GameOrder == 0 c.GameOrder == 0 &&
c.RechargeDetail == nil
}
type RechargeDetail struct {
SourceType string
RecordID string
SourceChannel string
SysOrigin string
UserID int64
Country Country
Amount decimal.Decimal
CoinQuantity decimal.Decimal
Remark string
EventTime time.Time
} }
type PeriodWindow struct { type PeriodWindow struct {

View File

@ -7,17 +7,31 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
"dashboard-cdc-worker/internal/monitor"
) )
type Server struct { type Server struct {
server *http.Server server *http.Server
} }
func New(addr string) *Server { func New(addr string, metrics *monitor.Metrics) *Server {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"code": "ok"}) payload := map[string]interface{}{"code": "ok"}
if metrics != nil {
payload["metrics"] = metrics.Snapshot()
}
_ = json.NewEncoder(w).Encode(payload)
})
mux.HandleFunc("/metrics", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if metrics == nil {
_ = json.NewEncoder(w).Encode(map[string]string{"code": "ok"})
return
}
_ = json.NewEncoder(w).Encode(metrics.Snapshot())
}) })
return &Server{server: &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}} return &Server{server: &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}}
} }

173
internal/monitor/metrics.go Normal file
View File

@ -0,0 +1,173 @@
package monitor
import (
"strconv"
"strings"
"sync"
"time"
"github.com/go-mysql-org/go-mysql/mysql"
)
type Metrics struct {
mu sync.RWMutex
startedAt time.Time
masterPosition mysql.Position
checkpointPosition mysql.Position
lastSyncedPosition mysql.Position
startPosition mysql.Position
lastFlushAt time.Time
lastFlushEvents int
totalFlushes uint64
totalEvents uint64
totalRetries uint64
totalLockWaits uint64
totalCleanupRows uint64
lastCleanupAt time.Time
lastCleanupRows int64
lastError string
lastErrorAt time.Time
lastBatchDuration time.Duration
}
func New() *Metrics {
return &Metrics{startedAt: time.Now()}
}
func (m *Metrics) SetStartPosition(pos mysql.Position) {
m.mu.Lock()
defer m.mu.Unlock()
m.startPosition = pos
}
func (m *Metrics) SetMasterPosition(pos mysql.Position) {
m.mu.Lock()
defer m.mu.Unlock()
m.masterPosition = pos
}
func (m *Metrics) SetCheckpointPosition(pos mysql.Position) {
m.mu.Lock()
defer m.mu.Unlock()
m.checkpointPosition = pos
}
func (m *Metrics) SetLastSyncedPosition(pos mysql.Position) {
m.mu.Lock()
defer m.mu.Unlock()
m.lastSyncedPosition = pos
}
func (m *Metrics) RecordBatch(events int, duration time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
m.lastFlushAt = time.Now()
m.lastFlushEvents = events
m.totalFlushes++
m.totalEvents += uint64(events)
m.lastBatchDuration = duration
}
func (m *Metrics) RecordRetry() {
m.mu.Lock()
defer m.mu.Unlock()
m.totalRetries++
}
func (m *Metrics) RecordLockWait() {
m.mu.Lock()
defer m.mu.Unlock()
m.totalLockWaits++
}
func (m *Metrics) RecordCleanup(rows int64) {
m.mu.Lock()
defer m.mu.Unlock()
m.lastCleanupAt = time.Now()
m.lastCleanupRows = rows
if rows > 0 {
m.totalCleanupRows += uint64(rows)
}
}
func (m *Metrics) RecordError(err error) {
if err == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
m.lastError = err.Error()
m.lastErrorAt = time.Now()
}
func (m *Metrics) Snapshot() map[string]interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
return map[string]interface{}{
"started_at": formatTime(m.startedAt),
"start_position": positionMap(m.startPosition),
"master_position": positionMap(m.masterPosition),
"checkpoint_position": positionMap(m.checkpointPosition),
"last_synced_position": positionMap(m.lastSyncedPosition),
"binlog_file_gap": fileGap(m.masterPosition, m.checkpointPosition),
"binlog_position_gap": positionGap(m.masterPosition, m.checkpointPosition),
"last_flush_at": formatTime(m.lastFlushAt),
"last_flush_events": m.lastFlushEvents,
"last_batch_duration_ms": m.lastBatchDuration.Milliseconds(),
"total_flushes": m.totalFlushes,
"total_events": m.totalEvents,
"total_retries": m.totalRetries,
"total_lock_waits": m.totalLockWaits,
"last_cleanup_at": formatTime(m.lastCleanupAt),
"last_cleanup_rows": m.lastCleanupRows,
"total_cleanup_rows": m.totalCleanupRows,
"last_error": m.lastError,
"last_error_at": formatTime(m.lastErrorAt),
}
}
func positionMap(pos mysql.Position) map[string]interface{} {
return map[string]interface{}{
"file": pos.Name,
"pos": pos.Pos,
}
}
func positionGap(master mysql.Position, checkpoint mysql.Position) interface{} {
if master.Name == "" || checkpoint.Name == "" || master.Name != checkpoint.Name || master.Pos < checkpoint.Pos {
return nil
}
return master.Pos - checkpoint.Pos
}
func fileGap(master mysql.Position, checkpoint mysql.Position) interface{} {
masterSeq, ok := binlogSequence(master.Name)
if !ok {
return nil
}
checkpointSeq, ok := binlogSequence(checkpoint.Name)
if !ok {
return nil
}
return masterSeq - checkpointSeq
}
func binlogSequence(file string) (int64, bool) {
index := strings.LastIndex(file, ".")
if index < 0 || index == len(file)-1 {
return 0, false
}
value, err := strconv.ParseInt(file[index+1:], 10, 64)
return value, err == nil
}
func formatTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format(time.RFC3339)
}

380
internal/storage/batch.go Normal file
View File

@ -0,0 +1,380 @@
package storage
import (
"context"
"database/sql"
"strings"
"time"
"github.com/shopspring/decimal"
"dashboard-cdc-worker/internal/dashboard"
)
const bulkInsertChunkSize = 500
type amountTotals struct {
newUserRecharge decimal.Decimal
officialRecharge decimal.Decimal
mifapayRecharge decimal.Decimal
googleRecharge decimal.Decimal
dealerRecharge decimal.Decimal
salaryExchange decimal.Decimal
luckyGiftTotalFlow decimal.Decimal
luckyGiftPayout decimal.Decimal
gameTotalFlow decimal.Decimal
gamePayout decimal.Decimal
}
type gameAmountTotals struct {
consume decimal.Decimal
payout decimal.Decimal
profit decimal.Decimal
orders int64
}
type dailyAmountKey struct {
day time.Time
sysOrigin string
countryCode string
countryName string
}
type periodAmountKey struct {
periodType string
periodKey string
statTimezone string
periodName string
startDate time.Time
endDate time.Time
sysOrigin string
countryCode string
countryName string
}
type gameAmountKey struct {
periodType string
periodKey string
statTimezone string
periodName string
startDate time.Time
endDate time.Time
sysOrigin string
countryCode string
countryName string
gameProvider string
gameID string
gameName string
}
type dailyUserKey struct {
day time.Time
sysOrigin string
countryCode string
countryName string
metricType string
}
type periodUserKey struct {
periodType string
periodKey string
statTimezone string
periodName string
startDate time.Time
endDate time.Time
sysOrigin string
countryCode string
countryName string
metricType string
}
type gameUserKey struct {
periodType string
periodKey string
statTimezone string
periodName string
startDate time.Time
endDate time.Time
sysOrigin string
countryCode string
countryName string
gameProvider string
gameID string
gameName string
}
func (r *MySQLRepository) ApplyContributionBatch(ctx context.Context, tx *sql.Tx,
contributions []dashboard.Contribution, storageTimezone string, statTimezones []string) error {
if len(contributions) == 0 {
return nil
}
storageZone, err := time.LoadLocation(storageTimezone)
if err != nil {
storageZone = time.UTC
}
statZones := make(map[string]*time.Location, len(statTimezones))
for _, statTimezone := range statTimezones {
zone, err := time.LoadLocation(statTimezone)
if err != nil {
zone = time.UTC
}
statZones[statTimezone] = zone
}
dailyAmounts := make(map[dailyAmountKey]amountTotals)
periodAmounts := make(map[periodAmountKey]amountTotals)
gameAmounts := make(map[gameAmountKey]gameAmountTotals)
dailyUsers := make(map[dailyUserKey]map[int64]struct{})
periodUsers := make(map[periodUserKey]map[int64]struct{})
gameUsers := make(map[gameUserKey]map[int64]struct{})
var negative []dashboard.Contribution
for _, c := range contributions {
if err := r.applyRechargeDetail(ctx, tx, c); err != nil {
return err
}
if c.EventTime.IsZero() {
continue
}
if c.DeltaSign < 0 {
negative = append(negative, c)
continue
}
if c.DeltaSign == 0 {
c.DeltaSign = 1
}
day := dashboard.DailyDate(c.EventTime, storageZone)
newUserRecharge := decimal.Zero
if dashboard.SamePeriod(c.UserCreatedAt, c.EventTime, dashboard.PeriodWindow{Type: dashboard.PeriodDay}, storageZone) {
newUserRecharge = c.NewUserRechargeCandidate
}
dailyKey := dailyAmountKey{day: day, sysOrigin: c.SysOrigin, countryCode: c.Country.Code, countryName: c.Country.Name}
totals := dailyAmounts[dailyKey]
totals.add(c, newUserRecharge)
dailyAmounts[dailyKey] = totals
addDailyUserGroups(dailyUsers, c, day)
for statTimezone, zone := range statZones {
for _, window := range dashboard.PeriodWindows(c.EventTime, zone) {
startDate, endDate := windowDates(window)
newUserRecharge = decimal.Zero
if dashboard.SamePeriod(c.UserCreatedAt, c.EventTime, window, zone) {
newUserRecharge = c.NewUserRechargeCandidate
}
periodKey := periodAmountKey{
periodType: window.Type, periodKey: window.Key, statTimezone: statTimezone,
periodName: window.Name, startDate: startDate, endDate: endDate,
sysOrigin: c.SysOrigin, countryCode: c.Country.Code, countryName: c.Country.Name,
}
periodTotal := periodAmounts[periodKey]
periodTotal.add(c, newUserRecharge)
periodAmounts[periodKey] = periodTotal
addPeriodUserGroups(periodUsers, c, window, statTimezone)
if c.GameProvider != "" && c.GameID != "" {
gameKey := gameAmountKey{
periodType: window.Type, periodKey: window.Key, statTimezone: statTimezone,
periodName: window.Name, startDate: startDate, endDate: endDate,
sysOrigin: c.SysOrigin, countryCode: c.Country.Code, countryName: c.Country.Name,
gameProvider: c.GameProvider, gameID: c.GameID, gameName: c.GameName,
}
gameTotal := gameAmounts[gameKey]
gameTotal.add(c)
gameAmounts[gameKey] = gameTotal
if c.GameUser {
addGameUserGroup(gameUsers, c, window, statTimezone)
}
}
}
}
}
for key, totals := range dailyAmounts {
if totals.empty() {
continue
}
if err := r.upsertDailyAmountTotals(ctx, tx, key, totals); err != nil {
return err
}
}
for key, totals := range periodAmounts {
if totals.empty() {
continue
}
if err := r.upsertPeriodAmountTotals(ctx, tx, key, totals); err != nil {
return err
}
}
for key, totals := range gameAmounts {
if totals.empty() {
continue
}
if err := r.upsertGameAmountTotals(ctx, tx, key, totals); err != nil {
return err
}
}
for key, users := range dailyUsers {
if err := r.applyDailyUserGroup(ctx, tx, key, users); err != nil {
return err
}
}
for key, users := range periodUsers {
if err := r.applyPeriodUserGroup(ctx, tx, key, users); err != nil {
return err
}
}
for key, users := range gameUsers {
if err := r.applyGameUserGroup(ctx, tx, key, users); err != nil {
return err
}
}
for _, c := range negative {
if err := r.ApplyDailyContribution(ctx, tx, c, storageTimezone); err != nil {
return err
}
for _, statTimezone := range statTimezones {
if err := r.ApplyPeriodContribution(ctx, tx, c, statTimezone); err != nil {
return err
}
if err := r.ApplyGamePeriodContribution(ctx, tx, c, statTimezone); err != nil {
return err
}
}
}
return nil
}
func (t *amountTotals) add(c dashboard.Contribution, newUserRecharge decimal.Decimal) {
sign := decimal.NewFromInt(int64(c.DeltaSign))
t.newUserRecharge = t.newUserRecharge.Add(newUserRecharge.Mul(sign))
t.officialRecharge = t.officialRecharge.Add(c.OfficialRecharge.Mul(sign))
t.mifapayRecharge = t.mifapayRecharge.Add(c.MifapayRecharge.Mul(sign))
t.googleRecharge = t.googleRecharge.Add(c.GoogleRecharge.Mul(sign))
t.dealerRecharge = t.dealerRecharge.Add(c.DealerRecharge.Mul(sign))
t.salaryExchange = t.salaryExchange.Add(c.SalaryExchange.Mul(sign))
t.luckyGiftTotalFlow = t.luckyGiftTotalFlow.Add(c.LuckyGiftTotalFlow.Mul(sign))
t.luckyGiftPayout = t.luckyGiftPayout.Add(c.LuckyGiftPayout.Mul(sign))
t.gameTotalFlow = t.gameTotalFlow.Add(c.GameTotalFlow.Mul(sign))
t.gamePayout = t.gamePayout.Add(c.GamePayout.Mul(sign))
}
func (t amountTotals) empty() bool {
return t.newUserRecharge.IsZero() && t.officialRecharge.IsZero() && t.mifapayRecharge.IsZero() &&
t.googleRecharge.IsZero() && t.dealerRecharge.IsZero() && t.salaryExchange.IsZero() &&
t.luckyGiftTotalFlow.IsZero() && t.luckyGiftPayout.IsZero() &&
t.gameTotalFlow.IsZero() && t.gamePayout.IsZero()
}
func (t *gameAmountTotals) add(c dashboard.Contribution) {
sign := decimal.NewFromInt(int64(c.DeltaSign))
t.consume = t.consume.Add(c.GameConsume.Mul(sign))
t.payout = t.payout.Add(c.GamePayoutBy.Mul(sign))
t.profit = t.profit.Add(c.GameProfit.Mul(sign))
t.orders += c.GameOrder * int64(c.DeltaSign)
}
func (t gameAmountTotals) empty() bool {
return t.consume.IsZero() && t.payout.IsZero() && t.profit.IsZero() && t.orders == 0
}
func addDailyUserGroups(groups map[dailyUserKey]map[int64]struct{}, c dashboard.Contribution, day time.Time) {
if c.UserID == 0 {
return
}
if c.CountryNewUser > 0 {
addDailyUser(groups, c, day, dashboard.MetricCountryNewUser)
}
if c.LuckyGiftUser {
addDailyUser(groups, c, day, dashboard.MetricLuckyGiftUser)
}
if c.GameUser {
addDailyUser(groups, c, day, dashboard.MetricGameUser)
}
}
func addPeriodUserGroups(groups map[periodUserKey]map[int64]struct{}, c dashboard.Contribution, window dashboard.PeriodWindow, statTimezone string) {
if c.UserID == 0 {
return
}
if c.CountryNewUser > 0 {
addPeriodUser(groups, c, window, statTimezone, dashboard.MetricCountryNewUser)
}
if c.LuckyGiftUser {
addPeriodUser(groups, c, window, statTimezone, dashboard.MetricLuckyGiftUser)
}
if c.GameUser {
addPeriodUser(groups, c, window, statTimezone, dashboard.MetricGameUser)
}
}
func addDailyUser(groups map[dailyUserKey]map[int64]struct{}, c dashboard.Contribution, day time.Time, metricType string) {
key := dailyUserKey{day: day, sysOrigin: c.SysOrigin, countryCode: c.Country.Code, countryName: c.Country.Name, metricType: metricType}
addUserID(groups, key, c.UserID)
}
func addPeriodUser(groups map[periodUserKey]map[int64]struct{}, c dashboard.Contribution, window dashboard.PeriodWindow, statTimezone string, metricType string) {
startDate, endDate := windowDates(window)
key := periodUserKey{
periodType: window.Type, periodKey: window.Key, statTimezone: statTimezone, periodName: window.Name,
startDate: startDate, endDate: endDate, sysOrigin: c.SysOrigin, countryCode: c.Country.Code,
countryName: c.Country.Name, metricType: metricType,
}
addUserID(groups, key, c.UserID)
}
func addGameUserGroup(groups map[gameUserKey]map[int64]struct{}, c dashboard.Contribution, window dashboard.PeriodWindow, statTimezone string) {
if c.UserID == 0 {
return
}
startDate, endDate := windowDates(window)
key := gameUserKey{
periodType: window.Type, periodKey: window.Key, statTimezone: statTimezone, periodName: window.Name,
startDate: startDate, endDate: endDate, sysOrigin: c.SysOrigin, countryCode: c.Country.Code,
countryName: c.Country.Name, gameProvider: c.GameProvider, gameID: c.GameID, gameName: c.GameName,
}
addUserID(groups, key, c.UserID)
}
func addUserID[K comparable](groups map[K]map[int64]struct{}, key K, userID int64) {
users := groups[key]
if users == nil {
users = make(map[int64]struct{})
groups[key] = users
}
users[userID] = struct{}{}
}
func windowDates(window dashboard.PeriodWindow) (time.Time, time.Time) {
var startDate, endDate time.Time
if window.StartDate != nil {
startDate = *window.StartDate
}
if window.EndDate != nil {
endDate = *window.EndDate
}
return startDate, endDate
}
func ptrOrNil(value time.Time) interface{} {
if value.IsZero() {
return nil
}
return value
}
func userIDs(users map[int64]struct{}) []int64 {
out := make([]int64, 0, len(users))
for userID := range users {
out = append(out, userID)
}
return out
}
func placeholders(count, columns int) string {
groups := make([]string, count)
item := "(" + strings.TrimRight(strings.Repeat("?,", columns), ",") + ")"
for i := range groups {
groups[i] = item
}
return strings.Join(groups, ",")
}

View File

@ -0,0 +1,231 @@
package storage
import (
"context"
"database/sql"
"time"
)
func (r *MySQLRepository) upsertDailyAmountTotals(ctx context.Context, tx *sql.Tx, key dailyAmountKey, totals amountTotals) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO country_dashboard_daily_metric (
stat_date, date_number, sys_origin, country_code, country_name,
new_user_recharge, official_recharge, mifapay_recharge, google_recharge,
dealer_recharge, salary_exchange, lucky_gift_total_flow, lucky_gift_payout,
game_total_flow, game_payout, refreshed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
country_name = VALUES(country_name),
new_user_recharge = new_user_recharge + VALUES(new_user_recharge),
official_recharge = official_recharge + VALUES(official_recharge),
mifapay_recharge = mifapay_recharge + VALUES(mifapay_recharge),
google_recharge = google_recharge + VALUES(google_recharge),
dealer_recharge = dealer_recharge + VALUES(dealer_recharge),
salary_exchange = salary_exchange + VALUES(salary_exchange),
lucky_gift_total_flow = lucky_gift_total_flow + VALUES(lucky_gift_total_flow),
lucky_gift_payout = lucky_gift_payout + VALUES(lucky_gift_payout),
game_total_flow = game_total_flow + VALUES(game_total_flow),
game_payout = game_payout + VALUES(game_payout),
refreshed_at = NOW()
`, key.day, dateNumber(key.day), key.sysOrigin, key.countryCode, key.countryName,
totals.newUserRecharge, totals.officialRecharge, totals.mifapayRecharge, totals.googleRecharge,
totals.dealerRecharge, totals.salaryExchange, totals.luckyGiftTotalFlow, totals.luckyGiftPayout,
totals.gameTotalFlow, totals.gamePayout)
return err
}
func (r *MySQLRepository) upsertPeriodAmountTotals(ctx context.Context, tx *sql.Tx, key periodAmountKey, totals amountTotals) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO country_dashboard_period_metric (
period_type, period_key, stat_timezone, period_name, period_start_date, period_end_date,
sys_origin, country_code, country_name, new_user_recharge, official_recharge, mifapay_recharge,
google_recharge, dealer_recharge, salary_exchange, lucky_gift_total_flow, lucky_gift_payout,
game_total_flow, game_payout, refreshed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
period_name = VALUES(period_name),
period_start_date = VALUES(period_start_date),
period_end_date = VALUES(period_end_date),
country_name = VALUES(country_name),
new_user_recharge = new_user_recharge + VALUES(new_user_recharge),
official_recharge = official_recharge + VALUES(official_recharge),
mifapay_recharge = mifapay_recharge + VALUES(mifapay_recharge),
google_recharge = google_recharge + VALUES(google_recharge),
dealer_recharge = dealer_recharge + VALUES(dealer_recharge),
salary_exchange = salary_exchange + VALUES(salary_exchange),
lucky_gift_total_flow = lucky_gift_total_flow + VALUES(lucky_gift_total_flow),
lucky_gift_payout = lucky_gift_payout + VALUES(lucky_gift_payout),
game_total_flow = game_total_flow + VALUES(game_total_flow),
game_payout = game_payout + VALUES(game_payout),
refreshed_at = NOW()
`, key.periodType, key.periodKey, key.statTimezone, key.periodName, ptrOrNil(key.startDate), ptrOrNil(key.endDate),
key.sysOrigin, key.countryCode, key.countryName, totals.newUserRecharge, totals.officialRecharge,
totals.mifapayRecharge, totals.googleRecharge, totals.dealerRecharge, totals.salaryExchange,
totals.luckyGiftTotalFlow, totals.luckyGiftPayout, totals.gameTotalFlow, totals.gamePayout)
return err
}
func (r *MySQLRepository) upsertGameAmountTotals(ctx context.Context, tx *sql.Tx, key gameAmountKey, totals gameAmountTotals) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO country_dashboard_game_period_metric (
period_type, period_key, stat_timezone, period_name, period_start_date, period_end_date,
sys_origin, country_code, country_name, game_provider, game_id, game_name,
consume_amount, payout_amount, profit_amount, order_count, refreshed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
period_name = VALUES(period_name),
period_start_date = VALUES(period_start_date),
period_end_date = VALUES(period_end_date),
country_name = VALUES(country_name),
game_name = VALUES(game_name),
consume_amount = consume_amount + VALUES(consume_amount),
payout_amount = payout_amount + VALUES(payout_amount),
profit_amount = profit_amount + VALUES(profit_amount),
order_count = order_count + VALUES(order_count),
refreshed_at = NOW()
`, key.periodType, key.periodKey, key.statTimezone, key.periodName, ptrOrNil(key.startDate), ptrOrNil(key.endDate),
key.sysOrigin, key.countryCode, key.countryName, key.gameProvider, key.gameID, key.gameName,
totals.consume, totals.payout, totals.profit, totals.orders)
return err
}
func (r *MySQLRepository) applyDailyUserGroup(ctx context.Context, tx *sql.Tx, key dailyUserKey, users map[int64]struct{}) error {
ids := userIDs(users)
inserted, err := bulkInsertDailyUsers(ctx, tx, key, ids)
if err != nil || inserted == 0 {
return err
}
column := dailyMetricColumn(key.metricType)
if column == "" {
return nil
}
_, err = tx.ExecContext(ctx, `
INSERT INTO country_dashboard_daily_metric (
stat_date, date_number, sys_origin, country_code, country_name, refreshed_at
) VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE `+column+` = `+column+` + ?, country_name = VALUES(country_name), refreshed_at = NOW()
`, key.day, dateNumber(key.day), key.sysOrigin, key.countryCode, key.countryName, inserted)
return err
}
func (r *MySQLRepository) applyPeriodUserGroup(ctx context.Context, tx *sql.Tx, key periodUserKey, users map[int64]struct{}) error {
ids := userIDs(users)
inserted, err := bulkInsertPeriodUsers(ctx, tx, key, ids)
if err != nil || inserted == 0 {
return err
}
column := periodMetricColumn(key.metricType)
if column == "" {
return nil
}
_, err = tx.ExecContext(ctx, `
INSERT INTO country_dashboard_period_metric (
period_type, period_key, stat_timezone, period_name, period_start_date, period_end_date,
sys_origin, country_code, country_name, refreshed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE `+column+` = `+column+` + ?, country_name = VALUES(country_name), refreshed_at = NOW()
`, key.periodType, key.periodKey, key.statTimezone, key.periodName, ptrOrNil(key.startDate), ptrOrNil(key.endDate),
key.sysOrigin, key.countryCode, key.countryName, inserted)
return err
}
func (r *MySQLRepository) applyGameUserGroup(ctx context.Context, tx *sql.Tx, key gameUserKey, users map[int64]struct{}) error {
ids := userIDs(users)
inserted, err := bulkInsertGameUsers(ctx, tx, key, ids)
if err != nil || inserted == 0 {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO country_dashboard_game_period_metric (
period_type, period_key, stat_timezone, period_name, period_start_date, period_end_date,
sys_origin, country_code, country_name, game_provider, game_id, game_name, user_count, refreshed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE user_count = user_count + VALUES(user_count), country_name = VALUES(country_name),
game_name = VALUES(game_name), refreshed_at = NOW()
`, key.periodType, key.periodKey, key.statTimezone, key.periodName, ptrOrNil(key.startDate), ptrOrNil(key.endDate),
key.sysOrigin, key.countryCode, key.countryName, key.gameProvider, key.gameID, key.gameName, inserted)
return err
}
func bulkInsertDailyUsers(ctx context.Context, tx *sql.Tx, key dailyUserKey, ids []int64) (int64, error) {
var inserted int64
for start := 0; start < len(ids); start += bulkInsertChunkSize {
end := min(start+bulkInsertChunkSize, len(ids))
args := make([]interface{}, 0, (end-start)*6)
for _, userID := range ids[start:end] {
args = append(args, key.day, key.sysOrigin, key.countryCode, key.countryName, userID, key.metricType)
}
result, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO country_dashboard_daily_user_metric (
stat_date, sys_origin, country_code, country_name, user_id, metric_type
) VALUES `+placeholders(end-start, 6), args...)
if err != nil {
return 0, err
}
affected, err := result.RowsAffected()
if err != nil {
return 0, err
}
inserted += affected
}
return inserted, nil
}
func bulkInsertPeriodUsers(ctx context.Context, tx *sql.Tx, key periodUserKey, ids []int64) (int64, error) {
var inserted int64
for start := 0; start < len(ids); start += bulkInsertChunkSize {
end := min(start+bulkInsertChunkSize, len(ids))
args := make([]interface{}, 0, (end-start)*11)
for _, userID := range ids[start:end] {
args = append(args, key.periodType, key.periodKey, key.statTimezone, key.periodName,
ptrOrNil(key.startDate), ptrOrNil(key.endDate), key.sysOrigin, key.countryCode,
key.countryName, userID, key.metricType)
}
result, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO country_dashboard_period_user_metric (
period_type, period_key, stat_timezone, period_name, period_start_date, period_end_date,
sys_origin, country_code, country_name, user_id, metric_type
) VALUES `+placeholders(end-start, 11), args...)
if err != nil {
return 0, err
}
affected, err := result.RowsAffected()
if err != nil {
return 0, err
}
inserted += affected
}
return inserted, nil
}
func bulkInsertGameUsers(ctx context.Context, tx *sql.Tx, key gameUserKey, ids []int64) (int64, error) {
var inserted int64
for start := 0; start < len(ids); start += bulkInsertChunkSize {
end := min(start+bulkInsertChunkSize, len(ids))
args := make([]interface{}, 0, (end-start)*12)
for _, userID := range ids[start:end] {
args = append(args, key.periodType, key.periodKey, key.statTimezone, key.periodName,
ptrOrNil(key.startDate), ptrOrNil(key.endDate), key.sysOrigin, key.countryCode,
key.countryName, key.gameProvider, key.gameID, userID)
}
result, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO country_dashboard_game_period_user_metric (
period_type, period_key, stat_timezone, period_name, period_start_date, period_end_date,
sys_origin, country_code, country_name, game_provider, game_id, user_id
) VALUES `+placeholders(end-start, 12), args...)
if err != nil {
return 0, err
}
affected, err := result.RowsAffected()
if err != nil {
return 0, err
}
inserted += affected
}
return inserted, nil
}
func dateNumber(day time.Time) int {
year, month, date := day.Date()
return year*10000 + int(month)*100 + date
}

58
internal/storage/cache.go Normal file
View File

@ -0,0 +1,58 @@
package storage
import (
"sync"
"time"
)
type ttlCache[K comparable, V any] struct {
mu sync.RWMutex
values map[K]ttlCacheEntry[V]
ttl time.Duration
maxSize int
}
type ttlCacheEntry[V any] struct {
value V
expiresAt time.Time
}
func newTTLCache[K comparable, V any](ttl time.Duration, maxSize int) *ttlCache[K, V] {
return &ttlCache[K, V]{
values: make(map[K]ttlCacheEntry[V]),
ttl: ttl,
maxSize: maxSize,
}
}
func (c *ttlCache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
entry, ok := c.values[key]
c.mu.RUnlock()
var zero V
if !ok {
return zero, false
}
if time.Now().After(entry.expiresAt) {
c.mu.Lock()
delete(c.values, key)
c.mu.Unlock()
return zero, false
}
return entry.value, true
}
func (c *ttlCache[K, V]) Set(key K, value V) {
if c.ttl <= 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.maxSize > 0 && len(c.values) >= c.maxSize {
c.values = make(map[K]ttlCacheEntry[V])
}
c.values[key] = ttlCacheEntry[V]{
value: value,
expiresAt: time.Now().Add(c.ttl),
}
}

119
internal/storage/cleanup.go Normal file
View File

@ -0,0 +1,119 @@
package storage
import (
"context"
"log/slog"
"time"
)
func (r *MySQLRepository) StartCleanup(ctx context.Context, logger *slog.Logger) {
if r.cfg.Dashboard.CleanupInterval <= 0 {
return
}
go func() {
r.cleanupOnce(ctx, logger)
ticker := time.NewTicker(r.cfg.Dashboard.CleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.cleanupOnce(ctx, logger)
}
}
}()
}
func (r *MySQLRepository) cleanupOnce(ctx context.Context, logger *slog.Logger) {
rows, err := r.Cleanup(ctx)
if err != nil {
if r.metrics != nil {
r.metrics.RecordError(err)
}
logger.Error("dashboard cleanup failed", "error", err)
return
}
if r.metrics != nil {
r.metrics.RecordCleanup(rows)
}
if rows > 0 {
logger.Info("dashboard cleanup finished", "rows", rows)
}
}
func (r *MySQLRepository) Cleanup(ctx context.Context) (int64, error) {
var total int64
if r.cfg.Dashboard.DedupRetention > 0 {
cutoff := time.Now().Add(-r.cfg.Dashboard.DedupRetention)
rows, err := r.deleteLimit(ctx, `
DELETE FROM dashboard_cdc_event_dedup
WHERE created_at < ?
LIMIT ?`, cutoff, r.cfg.Dashboard.CleanupBatchSize)
if err != nil {
return total, err
}
total += rows
}
if r.cfg.Dashboard.UserMetricDays > 0 {
cutoffDate := time.Now().AddDate(0, 0, -r.cfg.Dashboard.UserMetricDays).Format("2006-01-02")
deletes := []struct {
sql string
args []interface{}
}{
{
sql: `
DELETE FROM country_dashboard_daily_user_metric
WHERE stat_date < ?
LIMIT ?`,
args: []interface{}{cutoffDate, r.cfg.Dashboard.CleanupBatchSize},
},
{
sql: `
DELETE FROM country_dashboard_period_user_metric
WHERE period_type <> 'ALL'
AND period_end_date IS NOT NULL
AND period_end_date < ?
LIMIT ?`,
args: []interface{}{cutoffDate, r.cfg.Dashboard.CleanupBatchSize},
},
{
sql: `
DELETE FROM country_dashboard_game_period_user_metric
WHERE period_type <> 'ALL'
AND period_end_date IS NOT NULL
AND period_end_date < ?
LIMIT ?`,
args: []interface{}{cutoffDate, r.cfg.Dashboard.CleanupBatchSize},
},
{
sql: `
DELETE FROM country_dashboard_recharge_detail
WHERE create_time < ?
LIMIT ?`,
args: []interface{}{cutoffDate, r.cfg.Dashboard.CleanupBatchSize},
},
}
for _, item := range deletes {
rows, err := r.execRows(ctx, item.sql, item.args...)
if err != nil {
return total, err
}
total += rows
}
}
return total, nil
}
func (r *MySQLRepository) deleteLimit(ctx context.Context, query string, args ...interface{}) (int64, error) {
return r.execRows(ctx, query, args...)
}
func (r *MySQLRepository) execRows(ctx context.Context, query string, args ...interface{}) (int64, error) {
result, err := r.db.ExecContext(ctx, query, args...)
if err != nil {
return 0, err
}
return result.RowsAffected()
}

View File

@ -8,12 +8,19 @@ import (
) )
func (r *MySQLRepository) ResolveWalletGame(ctx context.Context, sysOrigin string, eventType string, eventID string) (string, string, string, error) { func (r *MySQLRepository) ResolveWalletGame(ctx context.Context, sysOrigin string, eventType string, eventID string) (string, string, string, error) {
if provider, gameID, gameName, ok, err := r.resolveBaishunByWalletEvent(ctx, sysOrigin, eventID); ok || err != nil { if provider, gameID, fallback, ok := directGameMeta(eventType); ok {
gameName, err := r.lookupGameName(ctx, sysOrigin, provider, gameID)
if gameName == "" {
gameName = fallback
}
return provider, gameID, gameName, err return provider, gameID, gameName, err
} }
if provider, gameID, gameName, ok, err := r.resolveGameOpenByWalletEvent(ctx, sysOrigin, eventType, eventID); ok || err != nil { if provider, gameID, gameName, ok, err := r.resolveGameOpenByWalletEvent(ctx, sysOrigin, eventType, eventID); ok || err != nil {
return provider, gameID, gameName, err return provider, gameID, gameName, err
} }
if provider, gameID, gameName, ok, err := r.resolveBaishunByWalletEvent(ctx, sysOrigin, eventID); ok || err != nil {
return provider, gameID, gameName, err
}
provider, gameID, fallback := fallbackGameMeta(eventType) provider, gameID, fallback := fallbackGameMeta(eventType)
gameName, err := r.lookupGameName(ctx, sysOrigin, provider, gameID) gameName, err := r.lookupGameName(ctx, sysOrigin, provider, gameID)
@ -87,6 +94,10 @@ LIMIT 1
} }
func (r *MySQLRepository) lookupGameName(ctx context.Context, sysOrigin string, provider string, gameID string) (string, error) { func (r *MySQLRepository) lookupGameName(ctx context.Context, sysOrigin string, provider string, gameID string) (string, error) {
cacheKey := strings.Join([]string{sysOrigin, provider, gameID}, "|")
if name, ok := r.gameNameCache.Get(cacheKey); ok {
return name, nil
}
var name string var name string
if provider == "BAISHUN" { if provider == "BAISHUN" {
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
@ -96,6 +107,9 @@ WHERE sys_origin = ? AND profile = 'PROD' AND vendor_game_id = ?
LIMIT 1 LIMIT 1
`, sysOrigin, gameID).Scan(&name) `, sysOrigin, gameID).Scan(&name)
if err == nil || err != sql.ErrNoRows { if err == nil || err != sql.ErrNoRows {
if err == nil {
r.gameNameCache.Set(cacheKey, name)
}
return name, err return name, err
} }
} }
@ -106,6 +120,9 @@ WHERE sys_origin = ? AND profile = 'PROD' AND vendor_game_id = ?
LIMIT 1 LIMIT 1
`, sysOrigin, gameID).Scan(&name) `, sysOrigin, gameID).Scan(&name)
if err == nil || err != sql.ErrNoRows { if err == nil || err != sql.ErrNoRows {
if err == nil {
r.gameNameCache.Set(cacheKey, name)
}
return name, err return name, err
} }
err = r.db.QueryRowContext(ctx, ` err = r.db.QueryRowContext(ctx, `
@ -115,11 +132,33 @@ WHERE sys_origin = ? AND game_id = ?
LIMIT 1 LIMIT 1
`, sysOrigin, gameID).Scan(&name) `, sysOrigin, gameID).Scan(&name)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
r.gameNameCache.Set(cacheKey, "")
return "", nil return "", nil
} }
if err == nil {
r.gameNameCache.Set(cacheKey, name)
}
return name, err return name, err
} }
func directGameMeta(eventType string) (string, string, string, bool) {
switch {
case strings.HasPrefix(eventType, "BAISHUN_GAME_"),
strings.HasPrefix(eventType, "GAME_OPEN_"),
strings.HasPrefix(eventType, "YOMI_GAME_"),
strings.HasPrefix(eventType, "HKYS_GAME_"),
strings.HasPrefix(eventType, "HOT_GAME_"),
in(eventType, "FRUIT_GAME", "DOUBLE_LAYER_FRUIT", "GAME_FRUIT_BET", "GAME_FRUIT_AWARD",
"LUDO_GAME", "LUDO_VICTORY", "LUDO_REFUND", "GAME_LUDO_REFUND",
"GAME_BARBECUE", "DOUBLE_LAYER_BARBECUE", "GAME_SLOT_MACHINE", "TURNTABLE_GAME",
"GAME_BURST_CRYSTAL", "TEEN_PATTI", "BET_TEEN_PATTI", "LOTTERY_NUMBER_BET", "LOTTERY_REWARD"):
provider, gameID, fallback := fallbackGameMeta(eventType)
return provider, gameID, fallback, true
default:
return "", "", "", false
}
}
func fallbackGameMeta(eventType string) (string, string, string) { func fallbackGameMeta(eventType string) (string, string, string) {
switch { switch {
case strings.HasPrefix(eventType, "BAISHUN_GAME_"): case strings.HasPrefix(eventType, "BAISHUN_GAME_"):

View File

@ -0,0 +1,45 @@
package storage
import (
"context"
"database/sql"
"dashboard-cdc-worker/internal/dashboard"
)
func (r *MySQLRepository) applyRechargeDetail(ctx context.Context, tx *sql.Tx, c dashboard.Contribution) error {
detail := c.RechargeDetail
if detail == nil || detail.RecordID == "" || detail.SourceType == "" {
return nil
}
if c.DeltaSign < 0 {
_, err := tx.ExecContext(ctx, `
DELETE FROM country_dashboard_recharge_detail
WHERE source_type = ? AND record_id = ?
`, detail.SourceType, detail.RecordID)
return err
}
if detail.EventTime.IsZero() {
return nil
}
_, err := tx.ExecContext(ctx, `
INSERT INTO country_dashboard_recharge_detail (
source_type, record_id, source_channel, sys_origin, user_id, country_code, country_name,
amount, coin_quantity, remark, create_time, update_time
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
source_channel = VALUES(source_channel),
sys_origin = VALUES(sys_origin),
user_id = VALUES(user_id),
country_code = VALUES(country_code),
country_name = VALUES(country_name),
amount = VALUES(amount),
coin_quantity = VALUES(coin_quantity),
remark = VALUES(remark),
create_time = VALUES(create_time),
update_time = NOW()
`, detail.SourceType, detail.RecordID, detail.SourceChannel, detail.SysOrigin, detail.UserID,
detail.Country.Code, detail.Country.Name, detail.Amount, detail.CoinQuantity,
detail.Remark, detail.EventTime)
return err
}

View File

@ -3,34 +3,108 @@ package storage
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"math"
"time"
"github.com/go-mysql-org/go-mysql/mysql" "github.com/go-mysql-org/go-mysql/mysql"
driverMysql "github.com/go-sql-driver/mysql"
"dashboard-cdc-worker/internal/config" "dashboard-cdc-worker/internal/config"
"dashboard-cdc-worker/internal/dashboard"
"dashboard-cdc-worker/internal/monitor"
) )
const serviceName = "dashboard-cdc-worker" const serviceName = "dashboard-cdc-worker"
const dashboardBackfillLockName = "country_dashboard_backfill_lock"
type MySQLRepository struct { type MySQLRepository struct {
db *sql.DB db *sql.DB
cfg config.Config cfg config.Config
metrics *monitor.Metrics
userCache *ttlCache[int64, dashboard.UserCountry]
gameNameCache *ttlCache[string, string]
} }
func NewMySQLRepository(db *sql.DB, cfg config.Config) *MySQLRepository { func NewMySQLRepository(db *sql.DB, cfg config.Config, metrics *monitor.Metrics) *MySQLRepository {
return &MySQLRepository{db: db, cfg: cfg} 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 { func (r *MySQLRepository) WithTx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) 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 { if err != nil {
return err return err
} }
if err := fn(tx); err != nil { defer conn.Close()
_ = tx.Rollback()
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 return err
} }
return tx.Commit() 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) { func (r *MySQLRepository) LoadCheckpoint(ctx context.Context) (mysql.Position, bool, error) {
@ -47,7 +121,11 @@ WHERE service_name = ?
if err != nil { if err != nil {
return mysql.Position{}, false, err return mysql.Position{}, false, err
} }
return mysql.Position{Name: file, Pos: pos}, true, nil 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 { func (r *MySQLRepository) SaveCheckpoint(ctx context.Context, pos mysql.Position) error {
@ -59,6 +137,9 @@ ON DUPLICATE KEY UPDATE
binlog_pos = VALUES(binlog_pos), binlog_pos = VALUES(binlog_pos),
updated_at = NOW() updated_at = NOW()
`, serviceName, pos.Name, pos.Pos) `, serviceName, pos.Name, pos.Pos)
if err == nil && r.metrics != nil {
r.metrics.SetCheckpointPosition(pos)
}
return err return err
} }

View File

@ -36,6 +36,7 @@ func (r *MySQLRepository) EnsureSchema(ctx context.Context) error {
createPeriodUserMetricTable, createPeriodUserMetricTable,
createGamePeriodMetricTable, createGamePeriodMetricTable,
createGamePeriodUserMetricTable, createGamePeriodUserMetricTable,
createRechargeDetailTable,
}) })
} }
@ -172,3 +173,23 @@ const createGamePeriodUserMetricTable = `CREATE TABLE IF NOT EXISTS country_dash
UNIQUE KEY uk_game_period_user (period_type, period_key, stat_timezone, sys_origin, country_code, game_provider, game_id, user_id), UNIQUE KEY uk_game_period_user (period_type, period_key, stat_timezone, sys_origin, country_code, game_provider, game_id, user_id),
KEY idx_game_user (sys_origin, stat_timezone, user_id) KEY idx_game_user (sys_origin, stat_timezone, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
const createRechargeDetailTable = `CREATE TABLE IF NOT EXISTS country_dashboard_recharge_detail (
id bigint NOT NULL AUTO_INCREMENT,
source_type varchar(32) NOT NULL,
record_id varchar(128) NOT NULL,
source_channel varchar(64) NOT NULL DEFAULT '',
sys_origin varchar(50) NOT NULL,
user_id bigint NOT NULL,
country_code varchar(64) NOT NULL DEFAULT 'UNKNOWN',
country_name varchar(128) NOT NULL DEFAULT 'UNKNOWN',
amount decimal(20,2) NOT NULL DEFAULT 0,
coin_quantity decimal(20,2) NULL,
remark varchar(512) NOT NULL DEFAULT '',
create_time datetime NOT NULL,
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_recharge_source_record (source_type, record_id),
KEY idx_recharge_query (sys_origin, create_time, country_code),
KEY idx_recharge_user (user_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`

View File

@ -22,10 +22,16 @@ ON DUPLICATE KEY UPDATE
user_updated_at = VALUES(user_updated_at), user_updated_at = VALUES(user_updated_at),
updated_at = NOW() updated_at = NOW()
`, user.UserID, user.SysOrigin, user.Country.Code, user.Country.Name, user.IsDeleted, nullTime(user.CreatedAt), nullTime(user.UpdatedAt)) `, user.UserID, user.SysOrigin, user.Country.Code, user.Country.Name, user.IsDeleted, nullTime(user.CreatedAt), nullTime(user.UpdatedAt))
if err == nil {
r.userCache.Set(user.UserID, user)
}
return err return err
} }
func (r *MySQLRepository) UserCountry(ctx context.Context, userID int64) (dashboard.UserCountry, bool, error) { func (r *MySQLRepository) UserCountry(ctx context.Context, userID int64) (dashboard.UserCountry, bool, error) {
if user, ok := r.userCache.Get(userID); ok {
return user, true, nil
}
var user dashboard.UserCountry var user dashboard.UserCountry
var countryCode, countryName string var countryCode, countryName string
var isDeleted bool var isDeleted bool
@ -49,6 +55,7 @@ WHERE user_id = ?
if updatedAt.Valid { if updatedAt.Valid {
user.UpdatedAt = updatedAt.Time user.UpdatedAt = updatedAt.Time
} }
r.userCache.Set(userID, user)
return user, true, nil return user, true, nil
} }
@ -84,6 +91,7 @@ INSERT INTO dashboard_cdc_user_country (
) VALUES (?, ?, ?, ?, ?, ?, ?, NOW()) ) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE updated_at = updated_at ON DUPLICATE KEY UPDATE updated_at = updated_at
`, user.UserID, user.SysOrigin, user.Country.Code, user.Country.Name, user.IsDeleted, nullTime(user.CreatedAt), nullTime(user.UpdatedAt)) `, user.UserID, user.SysOrigin, user.Country.Code, user.Country.Name, user.IsDeleted, nullTime(user.CreatedAt), nullTime(user.UpdatedAt))
r.userCache.Set(userID, user)
return user, true, nil return user, true, nil
} }

View File

@ -40,3 +40,23 @@ CREATE TABLE IF NOT EXISTS country_dashboard_daily_user_metric (
UNIQUE KEY uk_daily_user_metric (stat_date, sys_origin, country_code, user_id, metric_type), UNIQUE KEY uk_daily_user_metric (stat_date, sys_origin, country_code, user_id, metric_type),
KEY idx_daily_user_metric (sys_origin, user_id, metric_type) KEY idx_daily_user_metric (sys_origin, user_id, metric_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS country_dashboard_recharge_detail (
id bigint NOT NULL AUTO_INCREMENT,
source_type varchar(32) NOT NULL,
record_id varchar(128) NOT NULL,
source_channel varchar(64) NOT NULL DEFAULT '',
sys_origin varchar(50) NOT NULL,
user_id bigint NOT NULL,
country_code varchar(64) NOT NULL DEFAULT 'UNKNOWN',
country_name varchar(128) NOT NULL DEFAULT 'UNKNOWN',
amount decimal(20,2) NOT NULL DEFAULT 0,
coin_quantity decimal(20,2) NULL,
remark varchar(512) NOT NULL DEFAULT '',
create_time datetime NOT NULL,
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_recharge_source_record (source_type, record_id),
KEY idx_recharge_query (sys_origin, create_time, country_code),
KEY idx_recharge_user (user_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;