169 lines
6.5 KiB
Go
169 lines
6.5 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"hyapp/pkg/appcode"
|
|
"hyapp/pkg/outboxreplay"
|
|
"hyapp/pkg/rocketmqx"
|
|
servicemq "hyapp/pkg/servicekit/mq"
|
|
"hyapp/pkg/walletmq"
|
|
"hyapp/services/wallet-service/internal/config"
|
|
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
|
|
)
|
|
|
|
// ReplayWalletOutboxOnce publishes at most scope.Limit claimable facts from
|
|
// exactly one App partition, then closes both MySQL and MQ. Each iteration uses
|
|
// the production claim/publish/mark path with batch size one, so a process
|
|
// timeout cannot leave a claimed tail waiting for lease expiry.
|
|
func ReplayWalletOutboxOnce(ctx context.Context, cfg config.Config, scope outboxreplay.Scope, eventTypes []string) (outboxreplay.Result, error) {
|
|
validatedScope, err := outboxreplay.NewScope(scope.AppCode, scope.Limit, 500)
|
|
if err != nil {
|
|
return outboxreplay.Result{}, err
|
|
}
|
|
includeEventTypes, err := ValidateWalletReplayEventTypes(cfg, eventTypes)
|
|
if err != nil {
|
|
return outboxreplay.Result{}, err
|
|
}
|
|
if !cfg.RocketMQ.WalletOutbox.Enabled {
|
|
return outboxreplay.Result{}, errors.New("wallet RocketMQ outbox is not enabled")
|
|
}
|
|
|
|
repository, err := mysqlstorage.Open(ctx, cfg.MySQLDSN)
|
|
if err != nil {
|
|
return outboxreplay.Result{}, err
|
|
}
|
|
defer func() { _ = repository.Close() }()
|
|
producer, err := rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ProducerGroup))
|
|
if err != nil {
|
|
return outboxreplay.Result{}, err
|
|
}
|
|
if err := servicemq.StartProducers([]*rocketmqx.Producer{producer}); err != nil {
|
|
return outboxreplay.Result{}, err
|
|
}
|
|
defer servicemq.ShutdownProducers([]*rocketmqx.Producer{producer})
|
|
|
|
workerOptions := cfg.OutboxWorker
|
|
workerOptions.BatchSize = 1
|
|
workerOptions.Concurrency = 1
|
|
replayApp := &App{
|
|
mysqlRepo: repository,
|
|
walletEventTagMode: cfg.RocketMQ.WalletEventTagMode,
|
|
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
|
|
outboxWorkerCfg: workerOptions,
|
|
}
|
|
workerID := fmt.Sprintf("ops-wallet-outbox-%s-%d", validatedScope.AppCode, time.Now().UTC().UnixMilli())
|
|
return outboxreplay.Run(ctx, validatedScope, func(runCtx context.Context) (int, error) {
|
|
return replayApp.replayOneWalletOutboxRecord(runCtx, workerID, workerOptions, producer, cfg.RocketMQ.WalletOutbox.Topic, validatedScope.AppCode, includeEventTypes)
|
|
})
|
|
}
|
|
|
|
func (a *App) replayOneWalletOutboxRecord(ctx context.Context, workerID string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, targetAppCode string, includeEventTypes []string) (int, error) {
|
|
if options.PublishTimeout <= 0 {
|
|
options.PublishTimeout = 3 * time.Second
|
|
}
|
|
claimCtx := appcode.WithContext(ctx, targetAppCode)
|
|
records, err := a.mysqlRepo.ClaimPendingWalletOutboxFiltered(
|
|
claimCtx,
|
|
workerID,
|
|
1,
|
|
time.Now().UTC().Add(walletOutboxSingleRecordLease(options.PublishTimeout)).UnixMilli(),
|
|
mysqlstorage.WalletOutboxClaimFilter{IncludeEventTypes: includeEventTypes},
|
|
)
|
|
if err != nil || len(records) == 0 {
|
|
return 0, err
|
|
}
|
|
record := records[0]
|
|
if record.RetryCount >= options.MaxRetryCount {
|
|
markCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
|
|
markErr := a.mysqlRepo.MarkWalletOutboxDead(markCtx, record.EventID, deadWalletOutboxReason(record))
|
|
cancel()
|
|
if markErr != nil {
|
|
return 1, markErr
|
|
}
|
|
return 1, fmt.Errorf("wallet outbox event %s reached max retry count and was marked dead", record.EventID)
|
|
}
|
|
|
|
publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
|
|
publishErr := a.publishWalletOutboxRecord(publishCtx, producer, topic, record)
|
|
cancel()
|
|
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
|
|
defer markCancel()
|
|
if publishErr != nil {
|
|
nextRetryAtMS := time.Now().UTC().Add(walletOutboxBackoff(record.RetryCount+1, options)).UnixMilli()
|
|
if markErr := a.mysqlRepo.MarkWalletOutboxRetryable(markCtx, record.EventID, publishErr.Error(), nextRetryAtMS); markErr != nil {
|
|
return 1, errors.Join(publishErr, markErr)
|
|
}
|
|
// The row was safely released for a later retry, but this explicit run must
|
|
// fail instead of reporting a merely claimed row as a delivered replay.
|
|
return 1, publishErr
|
|
}
|
|
if err := a.mysqlRepo.MarkWalletOutboxDelivered(markCtx, record.EventID); err != nil {
|
|
return 1, err
|
|
}
|
|
return 1, nil
|
|
}
|
|
|
|
// NormalizeWalletReplayEventTypes validates the exact MQ tags before an
|
|
// operator dry-run is promoted to an executing run.
|
|
func NormalizeWalletReplayEventTypes(values []string) ([]string, error) {
|
|
if len(values) > 32 {
|
|
return nil, errors.New("wallet event-type count cannot exceed 32")
|
|
}
|
|
normalized := make([]string, 0, len(values))
|
|
seen := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
eventType, err := walletmq.EventTypeTag(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, exists := seen[eventType]; exists {
|
|
continue
|
|
}
|
|
seen[eventType] = struct{}{}
|
|
normalized = append(normalized, eventType)
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
// ValidateWalletReplayEventTypes applies the lane boundary in both dry-run and
|
|
// execute modes. The normal-topic repair must reject realtime event types even
|
|
// while that worker or topic is temporarily disabled; pausing a lane does not
|
|
// transfer ownership of its durable rows to another topic.
|
|
func ValidateWalletReplayEventTypes(cfg config.Config, values []string) ([]string, error) {
|
|
normalized, err := NormalizeWalletReplayEventTypes(values)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(normalized) == 0 {
|
|
// Wallet fanout has both high-volume balance notices and low-volume config
|
|
// facts. Requiring an explicit set prevents a small operational command
|
|
// from accidentally mixing stages merely because their timestamps overlap.
|
|
return nil, errors.New("at least one wallet event-type is required")
|
|
}
|
|
if err := rejectRealtimeReplayEventTypes(normalized, cfg.RealtimeOutboxWorker.EventTypes); err != nil {
|
|
return nil, err
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func rejectRealtimeReplayEventTypes(included []string, realtime []string) error {
|
|
realtimeSet := make(map[string]struct{}, len(realtime))
|
|
for _, eventType := range realtime {
|
|
eventType = strings.TrimSpace(eventType)
|
|
if eventType != "" {
|
|
realtimeSet[eventType] = struct{}{}
|
|
}
|
|
}
|
|
for _, eventType := range included {
|
|
if _, exists := realtimeSet[eventType]; exists {
|
|
return fmt.Errorf("wallet event-type %s belongs to the realtime outbox lane", eventType)
|
|
}
|
|
}
|
|
return nil
|
|
}
|