231 lines
5.8 KiB
Go
231 lines
5.8 KiB
Go
package cdc
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-mysql-org/go-mysql/canal"
|
|
"github.com/go-mysql-org/go-mysql/mysql"
|
|
"github.com/go-mysql-org/go-mysql/replication"
|
|
|
|
"dashboard-cdc-worker/internal/config"
|
|
"dashboard-cdc-worker/internal/dashboard"
|
|
"dashboard-cdc-worker/internal/monitor"
|
|
)
|
|
|
|
type handler struct {
|
|
canal.DummyEventHandler
|
|
cfg config.Config
|
|
store dashboard.Store
|
|
projector *dashboard.Projector
|
|
mapper *dashboard.Mapper
|
|
logger *slog.Logger
|
|
mu sync.RWMutex
|
|
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, metrics *monitor.Metrics) *handler {
|
|
return &handler{
|
|
cfg: cfg,
|
|
store: store,
|
|
projector: projector,
|
|
mapper: dashboard.NewMapper(store, cfg.Dashboard.SysOrigins,
|
|
cfg.Dashboard.UnknownCountryCode, cfg.User.CreateTimeDisplayOffset,
|
|
cfg.Dashboard.SourceTimeOffset),
|
|
logger: logger,
|
|
batch: make([]dashboard.Event, 0, cfg.CDC.BatchSize),
|
|
lastFlush: time.Now(),
|
|
metrics: metrics,
|
|
}
|
|
}
|
|
|
|
func (h *handler) OnRotate(_ *replication.EventHeader, rotateEvent *replication.RotateEvent) error {
|
|
h.setCurrentFile(string(rotateEvent.NextLogName))
|
|
return nil
|
|
}
|
|
|
|
func (h *handler) OnRow(e *canal.RowsEvent) error {
|
|
ctx := context.Background()
|
|
source := e.Table.Schema + "." + e.Table.Name
|
|
switch e.Action {
|
|
case canal.InsertAction:
|
|
for index, row := range e.Rows {
|
|
if err := h.applyRows(ctx, e, index, nil, row); err != nil {
|
|
h.recordError(err)
|
|
return err
|
|
}
|
|
}
|
|
case canal.DeleteAction:
|
|
for index, row := range e.Rows {
|
|
if err := h.applyRows(ctx, e, index, row, nil); err != nil {
|
|
h.recordError(err)
|
|
return err
|
|
}
|
|
}
|
|
case canal.UpdateAction:
|
|
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 {
|
|
h.recordError(err)
|
|
return err
|
|
}
|
|
}
|
|
default:
|
|
h.logger.Debug("ignore unsupported row action", "source", source, "action", e.Action)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *handler) OnPosSynced(_ *replication.EventHeader, pos mysql.Position, _ mysql.GTIDSet, force bool) error {
|
|
if h.cfg.CDC.DryRun {
|
|
return nil
|
|
}
|
|
if len(h.batch) > 0 {
|
|
if force || h.shouldFlush() {
|
|
if err := h.flushBatch(context.Background()); err != nil {
|
|
h.recordError(err)
|
|
return err
|
|
}
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
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 {
|
|
return "dashboard-cdc-worker"
|
|
}
|
|
|
|
func (h *handler) setCurrentFile(file string) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.file = file
|
|
}
|
|
|
|
func (h *handler) currentFile() string {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
return h.file
|
|
}
|
|
|
|
func (h *handler) applyRows(ctx context.Context, e *canal.RowsEvent, rowIndex int, oldRaw []interface{}, newRaw []interface{}) error {
|
|
var contributions []dashboard.Contribution
|
|
var user *dashboard.UserCountry
|
|
source := e.Table.Schema + "." + e.Table.Name
|
|
|
|
if oldRaw != nil {
|
|
oldRow := rowMap(e, oldRaw)
|
|
items, err := h.mapper.Map(ctx, e.Table.Schema, e.Table.Name, canal.DeleteAction, oldRow)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
contributions = append(contributions, items...)
|
|
}
|
|
if newRaw != nil {
|
|
newRow := rowMap(e, newRaw)
|
|
if e.Table.Name == "user_base_info" {
|
|
u := dashboard.UserCountryFromRow(newRow, h.cfg.Dashboard.UnknownCountryCode)
|
|
user = &u
|
|
}
|
|
items, err := h.mapper.Map(ctx, e.Table.Schema, e.Table.Name, canal.InsertAction, newRow)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
contributions = append(contributions, items...)
|
|
}
|
|
|
|
if user == nil && len(contributions) == 0 {
|
|
return nil
|
|
}
|
|
eventKey := fmt.Sprintf("%s:%d:%s:%s:%d", h.currentFile(), e.Header.LogPos, source, e.Action, rowIndex)
|
|
if key := stableRechargeEventKey(source, contributions); key != "" {
|
|
eventKey = key
|
|
} else if key := stableContributionEventKey(contributions); key != "" {
|
|
eventKey = key
|
|
}
|
|
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 stableContributionEventKey(contributions []dashboard.Contribution) string {
|
|
for _, contribution := range contributions {
|
|
if contribution.DeltaSign < 0 || contribution.EventKey == "" {
|
|
continue
|
|
}
|
|
return contribution.EventKey
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func stableRechargeEventKey(source string, contributions []dashboard.Contribution) string {
|
|
if source != "likei.order_purchase_history" && source != "likei.order_user_purchase_pay" {
|
|
return ""
|
|
}
|
|
for _, contribution := range contributions {
|
|
if contribution.DeltaSign < 0 || contribution.SourcePK == "" || contribution.RechargeDetail == nil {
|
|
continue
|
|
}
|
|
return fmt.Sprintf("reconcile:%s:%s:complete", source, contribution.SourcePK)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|