1119 lines
34 KiB
Go
1119 lines
34 KiB
Go
// Package zset contains the long-running Redis ZSet task consumer. It is an
|
||
// independent runtime role; wiring it into the process is intentionally left
|
||
// to the application bootstrap.
|
||
package zset
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/errors/gerror"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
|
||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/supply"
|
||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||
)
|
||
|
||
var (
|
||
ErrAlreadyRunning = errors.New("zset consumer is already running")
|
||
ErrLockLost = errors.New("zset consumer lock was lost")
|
||
ErrLeaseLost = errors.New("zset task lease was lost")
|
||
)
|
||
|
||
// Handler is the boundary to the independent Chan gateway and result store.
|
||
type Handler interface {
|
||
Query(context.Context, queue.Task) Result
|
||
Apply(context.Context, []Result) error
|
||
}
|
||
|
||
type Result struct {
|
||
Task queue.Task
|
||
UsageMB float64
|
||
// CardStatus carries status-sync results without overloading the traffic
|
||
// usage field. It is additive so the existing monthly usage handler keeps
|
||
// its original contract.
|
||
CardStatus *int
|
||
// Imei carries the IMEI-sync value. It is separate from CardStatus because
|
||
// an IMEI is a string and because "synced with an empty value" is a
|
||
// meaningful state that Success alone cannot express.
|
||
Imei string
|
||
// ImeiSynced reports that the carrier answered successfully, so the card
|
||
// was covered by this polling pass. It is independent of Success: a
|
||
// successful-but-empty answer sets ImeiSynced without Success, which is how
|
||
// the write path records coverage while leaving the stored value untouched.
|
||
ImeiSynced bool
|
||
Code int
|
||
ErrCode string
|
||
Message string
|
||
ErrorClass string
|
||
Success bool
|
||
Retryable bool
|
||
BlockChannel bool
|
||
// Covered marks a terminal answer that says something definitive about the
|
||
// card but yields no value, such as "the carrier holds no record of this
|
||
// card" (code 14000) or "the carrier returned an empty IMEI". The run was
|
||
// covered for that card, so it is neither retried nor counted as a failure;
|
||
// Success stays false because nothing may be written to the stored value.
|
||
Covered bool
|
||
}
|
||
|
||
// Config controls the bounded scheduling window. Values are deliberately
|
||
// explicit so production limits can be selected after the capacity PoC.
|
||
type Config struct {
|
||
SupplyObservation *supply.Tracker
|
||
LockKey string
|
||
LockTTL time.Duration
|
||
PollInterval time.Duration
|
||
LeaseTTL time.Duration
|
||
TaskTimeout time.Duration
|
||
ClaimBatch int
|
||
MobileConcurrency int
|
||
TelecomConcurrency int
|
||
GenericConcurrency int
|
||
// GenericQPS is the per-channel pacing rate used for unknown templates.
|
||
GenericQPS float64
|
||
// TemplateQPS overrides the per-channel pacing rate for channel templates.
|
||
// A nil map uses the built-in template defaults.
|
||
TemplateQPS map[string]float64
|
||
RunQueueEnabled bool
|
||
RoundRetryEnabled bool
|
||
MaxRounds int
|
||
FailureRetention time.Duration
|
||
// FailurePoolAlertThreshold emits a warning when one run's terminal failure
|
||
// pool exceeds this many entries. It is an observability guard, not a
|
||
// limiter: the run is never aborted, because a genuinely broken upstream
|
||
// must still leave a complete, reviewable failure list. Zero disables it.
|
||
FailurePoolAlertThreshold int64
|
||
// ShouldRetryNextRound selects failures that are deferred into the next
|
||
// Run round. A nil predicate preserves the monthly-usage mobile_busy rule.
|
||
ShouldRetryNextRound func(Result) bool
|
||
OnFailure func(context.Context, Result)
|
||
// BlockRun optionally provides a run-scoped channel blocker. The legacy
|
||
// monthly consumer continues to use Store.BlockRun by default.
|
||
BlockRun func(context.Context, string, string) (int64, error)
|
||
// Status synchronization counts blocked queued tasks as terminal failures.
|
||
BlockedTasksAsFailures bool
|
||
EventPrefix string
|
||
}
|
||
|
||
const (
|
||
telecomTempKey = "sim_dx_v1"
|
||
mobileTempKey = "sim_yd_v5"
|
||
mobileMockKey = "sim_yd_mock_v5"
|
||
sharedTengyuTempKey = "sim_onelink_tengyu_v1"
|
||
sharedXuzhouMobileTempKey = "sim_onelink_xuzhou_mobile_v1"
|
||
sharedLelianTempKey = "sim_onelink_lelian_v1"
|
||
sharedQianxunTempKey = "sim_onelink_qianxun_v1"
|
||
sharedHenanTempKey = "sim_onelink_henan_xinjing_v1"
|
||
sharedDaiheTempKey = "sim_onelink_daihe_v1"
|
||
sharedBeixinTempKey = "sim_onelink_beixin_v1"
|
||
// lanshang_8000 / lanshang_8003 是蓝尚的 OneLink 共享 Token 模板。它们
|
||
// 虽然名字不带 sim_onelink_ 前缀,但走的是同一套移动 OneLink 协议,
|
||
// 必须归入 carrierMobile:否则会落到 carrierGeneric 的单通道 1.5 QPS,
|
||
// 全量同步一张卡要 0.67 秒,6060 张卡要跑 1 小时以上。
|
||
sharedLanshang8000TempKey = "lanshang_8000"
|
||
sharedLanshang8003TempKey = "lanshang_8003"
|
||
telecomQPS = 25.0
|
||
mobileQPS = 30.0
|
||
defaultGenericQPS = 1.5
|
||
)
|
||
|
||
type carrier string
|
||
|
||
const (
|
||
carrierMobile carrier = "mobile"
|
||
carrierTelecom carrier = "telecom"
|
||
carrierGeneric carrier = "generic"
|
||
)
|
||
|
||
func (c *Config) setDefaults() {
|
||
if c.LockKey == "" {
|
||
c.LockKey = "flow:consumer:active"
|
||
}
|
||
if c.LockTTL <= 0 {
|
||
c.LockTTL = time.Minute
|
||
}
|
||
if c.PollInterval <= 0 {
|
||
c.PollInterval = time.Second
|
||
}
|
||
if c.LeaseTTL <= 0 {
|
||
c.LeaseTTL = time.Minute
|
||
}
|
||
if c.TaskTimeout <= 0 {
|
||
c.TaskTimeout = 30 * time.Second
|
||
}
|
||
if c.ClaimBatch <= 0 {
|
||
c.ClaimBatch = 100
|
||
}
|
||
if c.MobileConcurrency <= 0 {
|
||
c.MobileConcurrency = 30
|
||
}
|
||
if c.TelecomConcurrency <= 0 {
|
||
c.TelecomConcurrency = 10
|
||
}
|
||
if c.GenericConcurrency <= 0 {
|
||
c.GenericConcurrency = 1
|
||
}
|
||
if c.GenericQPS <= 0 {
|
||
c.GenericQPS = defaultGenericQPS
|
||
}
|
||
if len(c.TemplateQPS) > 0 {
|
||
normalized := make(map[string]float64, len(c.TemplateQPS))
|
||
for key, qps := range c.TemplateQPS {
|
||
key = strings.ToLower(strings.TrimSpace(key))
|
||
if key != "" && qps > 0 {
|
||
normalized[key] = qps
|
||
}
|
||
}
|
||
c.TemplateQPS = normalized
|
||
}
|
||
if c.RoundRetryEnabled {
|
||
c.RunQueueEnabled = true
|
||
}
|
||
if c.RunQueueEnabled && !c.RoundRetryEnabled {
|
||
c.MaxRounds = 1
|
||
} else if c.MaxRounds <= 0 {
|
||
c.MaxRounds = 5
|
||
}
|
||
if c.EventPrefix == "" {
|
||
c.EventPrefix = "monthly_usage"
|
||
}
|
||
if c.FailureRetention <= 0 {
|
||
c.FailureRetention = 30 * 24 * time.Hour
|
||
}
|
||
}
|
||
|
||
// Consumer periodically enumerates registered channels and handles a bounded
|
||
// claim window for each one. It does not scan the database or implement any
|
||
// carrier protocol.
|
||
type Consumer struct {
|
||
store *queue.Store
|
||
handler Handler
|
||
config Config
|
||
owner string
|
||
pools *channelPools
|
||
pacer *channelPacer
|
||
runningMu sync.Mutex
|
||
runningChannels map[string]struct{}
|
||
channelGroup sync.WaitGroup
|
||
summaryMu sync.Mutex
|
||
roundSummaries map[string]*roundSummary
|
||
}
|
||
|
||
type roundSummary struct {
|
||
runID string
|
||
round int
|
||
inputCount int
|
||
successCount int
|
||
failedCount int
|
||
deferredCount int
|
||
terminalFailedCount int
|
||
startedAt time.Time
|
||
lastBatchAt time.Time
|
||
batchCount int
|
||
}
|
||
|
||
func New(store *queue.Store, handler Handler, config Config) (*Consumer, error) {
|
||
if store == nil {
|
||
return nil, fmt.Errorf("zset store is nil")
|
||
}
|
||
if handler == nil {
|
||
return nil, fmt.Errorf("zset handler is nil")
|
||
}
|
||
config.setDefaults()
|
||
if config.BlockRun == nil {
|
||
config.BlockRun = store.BlockRun
|
||
}
|
||
if config.LeaseTTL <= config.TaskTimeout {
|
||
return nil, fmt.Errorf("lease ttl must exceed task timeout")
|
||
}
|
||
return &Consumer{
|
||
store: store,
|
||
handler: handler,
|
||
config: config,
|
||
owner: newOwner(),
|
||
pools: newChannelPools(config.MobileConcurrency, config.TelecomConcurrency, config.GenericConcurrency),
|
||
pacer: newChannelPacer(),
|
||
runningChannels: make(map[string]struct{}),
|
||
roundSummaries: make(map[string]*roundSummary),
|
||
}, nil
|
||
}
|
||
|
||
// Run obtains a single active-consumer lease and processes until ctx is done.
|
||
// A second active instance receives ErrAlreadyRunning and does not claim work.
|
||
func (c *Consumer) Run(ctx context.Context) (err error) {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
startedAt := time.Now()
|
||
acquired, err := c.store.AcquireLock(ctx, c.config.LockKey, c.owner, c.config.LockTTL)
|
||
if err != nil {
|
||
g.Log().Errorf(ctx, "event=%s_consumer status=failed stage=lock durationMs=%d error=%q", c.config.EventPrefix, time.Since(startedAt).Milliseconds(), err.Error())
|
||
return err
|
||
}
|
||
if !acquired {
|
||
g.Log().Warningf(ctx, "event=%s_consumer status=skipped reason=already_running durationMs=%d", c.config.EventPrefix, time.Since(startedAt).Milliseconds())
|
||
return ErrAlreadyRunning
|
||
}
|
||
if observer := c.config.SupplyObservation; observer != nil {
|
||
observer.ConsumerActive.Store(true)
|
||
defer observer.ConsumerActive.Store(false)
|
||
}
|
||
g.Log().Infof(ctx, "event=%s_consumer status=started lockKey=%s", c.config.EventPrefix, c.config.LockKey)
|
||
|
||
runCtx, cancel := context.WithCancel(ctx)
|
||
defer func() {
|
||
cancel()
|
||
c.channelGroup.Wait()
|
||
releaseCtx, releaseCancel := context.WithTimeout(context.Background(), time.Second)
|
||
defer releaseCancel()
|
||
_, _ = c.store.ReleaseLock(releaseCtx, c.config.LockKey, c.owner)
|
||
status := "stopped"
|
||
if err != nil {
|
||
status = "failed"
|
||
}
|
||
message := fmt.Sprintf("event=%s_consumer status=%s durationMs=%d error=%q", c.config.EventPrefix, status, time.Since(startedAt).Milliseconds(), errorMessage(err))
|
||
if err != nil {
|
||
g.Log().Error(ctx, message)
|
||
return
|
||
}
|
||
g.Log().Info(ctx, message)
|
||
}()
|
||
|
||
renewErr := make(chan error, 1)
|
||
go c.renewLock(runCtx, cancel, renewErr)
|
||
|
||
for {
|
||
_, err := c.processOnce(runCtx)
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
return nil
|
||
}
|
||
select {
|
||
case lockErr := <-renewErr:
|
||
return lockErr
|
||
default:
|
||
}
|
||
return err
|
||
}
|
||
timer := time.NewTimer(c.config.PollInterval)
|
||
select {
|
||
case <-runCtx.Done():
|
||
if ctx.Err() != nil {
|
||
return nil
|
||
}
|
||
select {
|
||
case err := <-renewErr:
|
||
return err
|
||
default:
|
||
return runCtx.Err()
|
||
}
|
||
case err := <-renewErr:
|
||
return err
|
||
case <-timer.C:
|
||
}
|
||
}
|
||
}
|
||
|
||
func (c *Consumer) renewLock(ctx context.Context, cancel context.CancelFunc, errors chan<- error) {
|
||
interval := c.config.LockTTL / 3
|
||
if interval <= 0 {
|
||
interval = time.Millisecond
|
||
}
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
ok, err := c.store.RenewLock(ctx, c.config.LockKey, c.owner, c.config.LockTTL)
|
||
if err != nil {
|
||
select {
|
||
case errors <- err:
|
||
default:
|
||
}
|
||
cancel()
|
||
return
|
||
}
|
||
if !ok {
|
||
select {
|
||
case errors <- ErrLockLost:
|
||
default:
|
||
}
|
||
cancel()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (c *Consumer) processOnce(ctx context.Context) (bool, error) {
|
||
started := false
|
||
if c.config.RunQueueEnabled {
|
||
runs, err := c.store.ActiveRuns(ctx)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
for _, runID := range runs {
|
||
// The transition is guarded by the Run metadata counters, so it is
|
||
// safe for every poller to attempt it.
|
||
meta, err := c.store.GetRunMeta(ctx, runID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if c.config.RoundRetryEnabled {
|
||
maxRounds := c.config.MaxRounds
|
||
if meta.MaxRound > 0 {
|
||
maxRounds = meta.MaxRound
|
||
}
|
||
advanced, err := c.store.AdvanceRunRound(ctx, runID, maxRounds)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if advanced {
|
||
c.flushRoundSummary(ctx, runID, meta.CurrentRound)
|
||
}
|
||
}
|
||
channels, err := c.store.RunChannels(ctx, runID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
for _, chanKey := range channels {
|
||
started = c.startRunChannel(ctx, runID, chanKey) || started
|
||
}
|
||
if err := c.completeRun(ctx, runID); err != nil {
|
||
return false, err
|
||
}
|
||
}
|
||
}
|
||
channels, err := c.store.ActiveChannels(ctx)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if len(channels) == 0 {
|
||
return started, nil
|
||
}
|
||
|
||
for _, chanKey := range channels {
|
||
started = c.startChannel(ctx, chanKey) || started
|
||
}
|
||
return started, nil
|
||
}
|
||
|
||
func (c *Consumer) startRunChannel(ctx context.Context, runID, chanKey string) bool {
|
||
key := runID + "\x00" + chanKey
|
||
c.runningMu.Lock()
|
||
if _, exists := c.runningChannels[key]; exists {
|
||
c.runningMu.Unlock()
|
||
return false
|
||
}
|
||
c.runningChannels[key] = struct{}{}
|
||
c.channelGroup.Add(1)
|
||
c.runningMu.Unlock()
|
||
|
||
go func() {
|
||
defer c.channelGroup.Done()
|
||
defer func() {
|
||
c.runningMu.Lock()
|
||
delete(c.runningChannels, key)
|
||
c.runningMu.Unlock()
|
||
}()
|
||
startedAt := time.Now()
|
||
for ctx.Err() == nil {
|
||
processed, err := c.processRunChannel(ctx, runID, chanKey)
|
||
if err != nil {
|
||
g.Log().Errorf(ctx, "event=%s_batch status=failed stage=claim_or_process runId=%s chanKey=%s durationMs=%d error=%q", c.config.EventPrefix, runID, chanKey, time.Since(startedAt).Milliseconds(), err.Error())
|
||
return
|
||
}
|
||
if !processed {
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
return true
|
||
}
|
||
|
||
func (c *Consumer) processRunChannel(ctx context.Context, runID, chanKey string) (bool, error) {
|
||
now := time.Now()
|
||
claimed, err := c.store.ClaimRun(ctx, runID, chanKey, now, now.Add(c.config.LeaseTTL), c.config.ClaimBatch)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if len(claimed) == 0 {
|
||
if observer := c.config.SupplyObservation; observer != nil {
|
||
observer.Empty(runID, chanKey)
|
||
}
|
||
return false, nil
|
||
}
|
||
if observer := c.config.SupplyObservation; observer != nil {
|
||
observer.Processing(runID, chanKey, 1)
|
||
defer observer.Processing(runID, chanKey, -1)
|
||
}
|
||
return true, c.handleRunClaims(ctx, claimed)
|
||
}
|
||
|
||
func (c *Consumer) handleRunClaims(ctx context.Context, claimed []queue.ClaimedTask) error {
|
||
if len(claimed) == 0 {
|
||
return nil
|
||
}
|
||
startedAt := time.Now()
|
||
channelCarrier, qps := policyForTemplate(claimed[0].Task.TempKey, c.config)
|
||
batchCtx, cancelBatch := context.WithCancel(ctx)
|
||
queryCtx, cancelQueries := context.WithCancel(batchCtx)
|
||
defer cancelQueries()
|
||
renewErr := make(chan error, 1)
|
||
renewDone := make(chan struct{})
|
||
go c.renewBatchLeases(batchCtx, cancelBatch, claimed, renewErr, renewDone)
|
||
defer func() { cancelBatch(); <-renewDone }()
|
||
|
||
var group sync.WaitGroup
|
||
resultsCh := make(chan Result, len(claimed))
|
||
for _, task := range claimed {
|
||
task := task
|
||
group.Add(1)
|
||
go func() {
|
||
defer group.Done()
|
||
result := c.queryClaim(queryCtx, task, channelCarrier, qps)
|
||
if result.BlockChannel {
|
||
cancelQueries()
|
||
}
|
||
resultsCh <- result
|
||
}()
|
||
}
|
||
group.Wait()
|
||
close(resultsCh)
|
||
results := make([]Result, 0, len(claimed))
|
||
blockChannel := false
|
||
for result := range resultsCh {
|
||
results = append(results, result)
|
||
blockChannel = blockChannel || result.BlockChannel
|
||
}
|
||
if err := currentBatchError(batchCtx, renewErr); err != nil {
|
||
return err
|
||
}
|
||
for _, result := range results {
|
||
if !result.Success && !result.Covered && c.config.OnFailure != nil {
|
||
c.config.OnFailure(ctx, result)
|
||
}
|
||
}
|
||
if err := c.handler.Apply(batchCtx, results); err != nil {
|
||
return gerror.Wrap(err, "apply run result batch")
|
||
}
|
||
if err := currentBatchError(batchCtx, renewErr); err != nil {
|
||
return err
|
||
}
|
||
|
||
meta, err := c.store.GetRunMeta(batchCtx, claimed[0].Task.RunID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
maxRounds := c.config.MaxRounds
|
||
if meta.MaxRound > 0 {
|
||
maxRounds = meta.MaxRound
|
||
}
|
||
finalizedClaims := make([]queue.ClaimedTask, 0, len(results))
|
||
outcomes := make([]queue.TaskOutcome, 0, len(results))
|
||
deferred := 0
|
||
for _, result := range results {
|
||
// A telecom channel-limit response is handled by BlockRunScoped below.
|
||
// Leave the triggering lease in place so the same atomic block operation
|
||
// marks it (and all other queued/leased channel tasks) channel_blocked;
|
||
// it must not become a mobile round retry or a card-level final failure.
|
||
if result.BlockChannel {
|
||
continue
|
||
}
|
||
roundRetry := result.Retryable && c.shouldRetryNextRound(result)
|
||
if result.Retryable && !roundRetry && !result.Success && !result.BlockChannel {
|
||
// Infrastructure and lease-recoverable failures intentionally stay
|
||
// leased; the existing lease expiry path will recover them.
|
||
continue
|
||
}
|
||
claimedTask := claimedTaskForResult(claimed, result)
|
||
if claimedTask.Task.TaskID == "" {
|
||
continue
|
||
}
|
||
outcome := queue.TaskOutcome{
|
||
Success: result.Success,
|
||
Retryable: false,
|
||
ErrorClass: result.ErrorClass,
|
||
FailureType: result.ErrorClass,
|
||
Code: result.Code,
|
||
ErrCode: result.ErrCode,
|
||
Message: result.Message,
|
||
BlockChannel: result.BlockChannel,
|
||
Covered: result.Covered,
|
||
}
|
||
if c.config.RoundRetryEnabled && roundRetry && meta.CurrentRound < maxRounds {
|
||
outcome.Retry = true
|
||
deferred++
|
||
}
|
||
finalizedClaims = append(finalizedClaims, claimedTask)
|
||
outcomes = append(outcomes, outcome)
|
||
}
|
||
finalized := 0
|
||
if len(finalizedClaims) > 0 {
|
||
count, finalizeErr := c.store.FinalizeRunTasks(batchCtx, finalizedClaims, outcomes, maxRounds)
|
||
if finalizeErr != nil {
|
||
return finalizeErr
|
||
}
|
||
if count != len(finalizedClaims) {
|
||
return ErrLeaseLost
|
||
}
|
||
finalized = count
|
||
}
|
||
if blockChannel {
|
||
if c.config.BlockedTasksAsFailures {
|
||
for _, result := range results {
|
||
if !result.BlockChannel {
|
||
continue
|
||
}
|
||
removed, err := c.store.BlockRunScopedAsFailed(batchCtx, claimedTaskForResult(claimed, result), queue.TaskOutcome{Code: result.Code, ErrCode: result.ErrCode, Message: result.Message})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if removed < 0 {
|
||
return ErrLeaseLost
|
||
}
|
||
c.recordRoundCounts(claimed[0].Task.RunID, meta.CurrentRound, int(removed), 0, 0, int(removed), startedAt)
|
||
break
|
||
}
|
||
} else if _, err := c.store.BlockRunScoped(batchCtx, claimed[0].Task.RunID, claimed[0].Task.ChanKey); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
c.recordRoundBatch(claimed[0].Task.RunID, meta.CurrentRound, finalizedClaims, outcomes, startedAt)
|
||
if err := c.completeRun(batchCtx, claimed[0].Task.RunID); err != nil {
|
||
return err
|
||
}
|
||
g.Log().Infof(ctx, "event=%s_round_batch status=completed runId=%s queryMonth=%s chanKey=%s tempKey=%s round=%d claimed=%d success=%d covered=%d failed=%d deferred=%d finalized=%d durationMs=%d", c.config.EventPrefix, claimed[0].Task.RunID, claimed[0].Task.QueryMonth, claimed[0].Task.ChanKey, claimed[0].Task.TempKey, meta.CurrentRound, len(claimed), countSuccessful(results), countCovered(results), len(results)-countSuccessful(results)-countCovered(results), deferred, finalized, time.Since(startedAt).Milliseconds())
|
||
return nil
|
||
}
|
||
|
||
func (c *Consumer) recordRoundBatch(runID string, round int, claimed []queue.ClaimedTask, outcomes []queue.TaskOutcome, startedAt time.Time) {
|
||
if len(claimed) == 0 || len(claimed) != len(outcomes) {
|
||
return
|
||
}
|
||
success, failed, deferred := 0, 0, 0
|
||
for _, outcome := range outcomes {
|
||
if outcome.Success || outcome.Covered {
|
||
// A covered task answered definitively and produced no failure, so
|
||
// it closes the round like a success. Keeping it out of both buckets
|
||
// would break the enqueued = success + nextRound + failed identity
|
||
// that the run archive validates.
|
||
success++
|
||
continue
|
||
}
|
||
failed++
|
||
if outcome.Retry {
|
||
deferred++
|
||
}
|
||
}
|
||
c.recordRoundCounts(runID, round, len(claimed), success, deferred, failed-deferred, startedAt)
|
||
}
|
||
|
||
func (c *Consumer) recordRoundCounts(runID string, round, input, success, deferred, terminalFailed int, startedAt time.Time) {
|
||
if input <= 0 {
|
||
return
|
||
}
|
||
key := runID + "\x00" + fmt.Sprint(round)
|
||
now := time.Now()
|
||
c.summaryMu.Lock()
|
||
summary := c.roundSummaries[key]
|
||
if summary == nil {
|
||
summary = &roundSummary{runID: runID, round: round, startedAt: startedAt}
|
||
c.roundSummaries[key] = summary
|
||
}
|
||
if startedAt.Before(summary.startedAt) {
|
||
summary.startedAt = startedAt
|
||
}
|
||
summary.inputCount += input
|
||
summary.successCount += success
|
||
summary.failedCount += deferred + terminalFailed
|
||
summary.deferredCount += deferred
|
||
summary.terminalFailedCount += terminalFailed
|
||
summary.batchCount++
|
||
summary.lastBatchAt = now
|
||
c.summaryMu.Unlock()
|
||
}
|
||
|
||
func (c *Consumer) flushRoundSummary(ctx context.Context, runID string, round int) {
|
||
key := runID + "\x00" + fmt.Sprint(round)
|
||
c.summaryMu.Lock()
|
||
summary := c.roundSummaries[key]
|
||
c.summaryMu.Unlock()
|
||
if summary == nil {
|
||
return
|
||
}
|
||
if summary.lastBatchAt.IsZero() {
|
||
summary.lastBatchAt = time.Now()
|
||
}
|
||
completedAt := summary.lastBatchAt
|
||
stats, err := c.store.GetRoundStats(ctx, runID)
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "event=%s_round_summary status=failed runId=%s round=%d stage=read_existing error=%q", c.config.EventPrefix, runID, round, err.Error())
|
||
return
|
||
}
|
||
startedAt := summary.startedAt
|
||
entry := queue.RoundStats{
|
||
RunID: runID,
|
||
Round: round,
|
||
EnqueuedCount: int64(summary.inputCount),
|
||
NextRoundCount: int64(summary.deferredCount),
|
||
SuccessCount: int64(summary.successCount),
|
||
FailedCount: int64(summary.terminalFailedCount),
|
||
BatchCount: int64(summary.batchCount),
|
||
StartedAt: &startedAt,
|
||
CompletedAt: &completedAt,
|
||
}
|
||
found := false
|
||
for i := range stats {
|
||
if stats[i].Round == round {
|
||
stats[i] = entry
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
stats = append(stats, entry)
|
||
}
|
||
if err := c.store.SaveRoundStats(ctx, runID, stats); err != nil {
|
||
g.Log().Warningf(ctx, "event=%s_round_summary status=failed runId=%s round=%d stage=persist error=%q", c.config.EventPrefix, runID, round, err.Error())
|
||
return
|
||
}
|
||
c.summaryMu.Lock()
|
||
delete(c.roundSummaries, key)
|
||
c.summaryMu.Unlock()
|
||
g.Log().Infof(ctx, "event=%s_round_summary status=completed runId=%s round=%d inputCount=%d successCount=%d failedCount=%d deferredCount=%d terminalFailedCount=%d startedAt=%s completedAt=%s durationMs=%d", c.config.EventPrefix, runID, round, summary.inputCount, summary.successCount, summary.failedCount, summary.deferredCount, summary.terminalFailedCount, summary.startedAt.Format(time.RFC3339), summary.lastBatchAt.Format(time.RFC3339), summary.lastBatchAt.Sub(summary.startedAt).Milliseconds())
|
||
}
|
||
|
||
func (c *Consumer) completeRun(ctx context.Context, runID string) error {
|
||
completed, err := c.store.CompleteRunIfIdle(ctx, runID, c.config.FailureRetention)
|
||
if err != nil || !completed {
|
||
return err
|
||
}
|
||
meta, err := c.store.GetRunMeta(ctx, runID)
|
||
if err != nil {
|
||
return gerror.Wrap(err, "read completed run metadata")
|
||
}
|
||
c.flushRoundSummary(ctx, runID, meta.CurrentRound)
|
||
g.Log().Infof(ctx, "event=%s_run status=%s runId=%s successCount=%d failedCount=%d completedAt=%s", c.config.EventPrefix, meta.Status, runID, meta.SuccessCount, meta.FailedCount, meta.CompletedAt.Format(time.RFC3339))
|
||
c.reportFailurePoolSize(ctx, runID, meta)
|
||
return nil
|
||
}
|
||
|
||
// reportFailurePoolSize warns when one run left behind an unusually large
|
||
// terminal failure pool.
|
||
//
|
||
// The pool is the only per-card structure that grows with the card count
|
||
// (each FailureRecord is roughly 1KB of JSON), and it is retained for
|
||
// FailureRetention — so an upstream-wide outage on a large run is the one
|
||
// scenario that can push Redis usage up by hundreds of megabytes. The alert
|
||
// exists so that condition is visible in logs instead of being discovered as a
|
||
// memory graph later; the run itself is deliberately never truncated.
|
||
func (c *Consumer) reportFailurePoolSize(ctx context.Context, runID string, meta queue.RunMeta) {
|
||
threshold := c.config.FailurePoolAlertThreshold
|
||
// Strictly greater than: the documented contract is "exceeds this many
|
||
// entries", so a run sitting exactly on the threshold is not yet an
|
||
// anomaly.
|
||
if threshold <= 0 || meta.FailedCount <= threshold {
|
||
return
|
||
}
|
||
failureKey, err := c.store.FailedKey(runID)
|
||
if err != nil {
|
||
return
|
||
}
|
||
size, err := c.store.RedisClient().Do(ctx, "HLEN", failureKey)
|
||
if err != nil {
|
||
return
|
||
}
|
||
g.Log().Warningf(ctx, "event=%s_failure_pool status=large runId=%s failedCount=%d poolEntries=%d threshold=%d retention=%s action=none_observability_only",
|
||
c.config.EventPrefix, runID, meta.FailedCount, size.Int(), threshold, c.config.FailureRetention)
|
||
}
|
||
|
||
func (c *Consumer) shouldRetryNextRound(result Result) bool {
|
||
if c.config.ShouldRetryNextRound != nil {
|
||
return c.config.ShouldRetryNextRound(result)
|
||
}
|
||
return result.ErrorClass == "mobile_busy"
|
||
}
|
||
|
||
func (c *Consumer) startChannel(ctx context.Context, chanKey string) bool {
|
||
c.runningMu.Lock()
|
||
if _, exists := c.runningChannels[chanKey]; exists {
|
||
c.runningMu.Unlock()
|
||
return false
|
||
}
|
||
c.runningChannels[chanKey] = struct{}{}
|
||
c.channelGroup.Add(1)
|
||
c.runningMu.Unlock()
|
||
|
||
go func() {
|
||
defer c.channelGroup.Done()
|
||
defer func() {
|
||
c.runningMu.Lock()
|
||
delete(c.runningChannels, chanKey)
|
||
c.runningMu.Unlock()
|
||
}()
|
||
startedAt := time.Now()
|
||
for ctx.Err() == nil {
|
||
processed, err := c.processChannel(ctx, chanKey)
|
||
if err != nil {
|
||
g.Log().Errorf(ctx, "event=monthly_usage_batch status=failed stage=claim_or_process chanKey=%s durationMs=%d error=%q", chanKey, time.Since(startedAt).Milliseconds(), err.Error())
|
||
return
|
||
}
|
||
if !processed {
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
return true
|
||
}
|
||
|
||
func (c *Consumer) processChannel(ctx context.Context, chanKey string) (bool, error) {
|
||
now := time.Now()
|
||
claimed, err := c.store.Claim(ctx, chanKey, now, now.Add(c.config.LeaseTTL), c.config.ClaimBatch)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if len(claimed) == 0 {
|
||
return false, nil
|
||
}
|
||
return true, c.handleClaims(ctx, claimed)
|
||
}
|
||
|
||
func (c *Consumer) handleClaims(ctx context.Context, claimed []queue.ClaimedTask) error {
|
||
if len(claimed) == 0 {
|
||
return nil
|
||
}
|
||
startedAt := time.Now()
|
||
logBatchFailure := func(stage string, err error) {
|
||
g.Log().Errorf(ctx, "event=monthly_usage_batch status=failed stage=%s runId=%s queryMonth=%s chanKey=%s tempKey=%s claimed=%d durationMs=%d error=%q", stage, claimed[0].Task.RunID, claimed[0].Task.QueryMonth, claimed[0].Task.ChanKey, claimed[0].Task.TempKey, len(claimed), time.Since(startedAt).Milliseconds(), errorMessage(err))
|
||
}
|
||
channelCarrier, qps := policyForTemplate(claimed[0].Task.TempKey, c.config)
|
||
batchCtx, cancelBatch := context.WithCancel(ctx)
|
||
queryCtx, cancelQueries := context.WithCancel(batchCtx)
|
||
defer cancelQueries()
|
||
renewErr := make(chan error, 1)
|
||
renewDone := make(chan struct{})
|
||
go c.renewBatchLeases(batchCtx, cancelBatch, claimed, renewErr, renewDone)
|
||
defer func() {
|
||
cancelBatch()
|
||
<-renewDone
|
||
}()
|
||
|
||
var group sync.WaitGroup
|
||
resultsCh := make(chan Result, len(claimed))
|
||
for _, task := range claimed {
|
||
task := task
|
||
group.Add(1)
|
||
go func() {
|
||
defer group.Done()
|
||
result := c.queryClaim(queryCtx, task, channelCarrier, qps)
|
||
if result.BlockChannel {
|
||
cancelQueries()
|
||
}
|
||
resultsCh <- result
|
||
}()
|
||
}
|
||
group.Wait()
|
||
close(resultsCh)
|
||
results := make([]Result, 0, len(claimed))
|
||
blockChannel := false
|
||
for result := range resultsCh {
|
||
results = append(results, result)
|
||
blockChannel = blockChannel || result.BlockChannel
|
||
}
|
||
if err := currentBatchError(batchCtx, renewErr); err != nil {
|
||
logBatchFailure("query_or_lease", err)
|
||
return err
|
||
}
|
||
for _, result := range results {
|
||
if !result.Success && c.config.OnFailure != nil {
|
||
c.config.OnFailure(ctx, result)
|
||
}
|
||
}
|
||
|
||
if applyErr := c.handler.Apply(batchCtx, results); applyErr != nil {
|
||
logBatchFailure("apply", applyErr)
|
||
return fmt.Errorf("apply monthly usage result batch: %w", applyErr)
|
||
}
|
||
if err := currentBatchError(batchCtx, renewErr); err != nil {
|
||
logBatchFailure("lease", err)
|
||
return err
|
||
}
|
||
if blockChannel {
|
||
removed, err := c.config.BlockRun(batchCtx, claimed[0].Task.ChanKey, claimed[0].Task.RunID)
|
||
if err != nil {
|
||
logBatchFailure("block", err)
|
||
return err
|
||
}
|
||
g.Log().Errorf(ctx, "event=monthly_usage_channel status=blocked runId=%s queryMonth=%s chanKey=%s tempKey=%s errCode=S00004 claimed=%d removed=%d durationMs=%d", claimed[0].Task.RunID, claimed[0].Task.QueryMonth, claimed[0].Task.ChanKey, claimed[0].Task.TempKey, len(claimed), removed, time.Since(startedAt).Milliseconds())
|
||
return nil
|
||
}
|
||
acknowledged := make([]queue.ClaimedTask, 0, len(claimed))
|
||
for _, result := range results {
|
||
// The legacy channel queue has no round state. A mobile busy response
|
||
// therefore remains a terminal result there; only Run-scoped consumers
|
||
// can defer it into the next round. Infrastructure failures retain the
|
||
// historical lease-recovery behaviour.
|
||
if result.Success || !result.Retryable || (result.ErrorClass == "mobile_busy" && !c.config.RoundRetryEnabled) {
|
||
acknowledged = append(acknowledged, claimedTaskForResult(claimed, result))
|
||
}
|
||
}
|
||
if err := c.ackBatch(batchCtx, acknowledged); err != nil {
|
||
logBatchFailure("ack", err)
|
||
return err
|
||
}
|
||
success := countSuccessful(results)
|
||
retryCount := 0
|
||
for _, result := range results {
|
||
if !result.Success && result.Retryable && !result.BlockChannel && !(result.ErrorClass == "mobile_busy" && !c.config.RoundRetryEnabled) {
|
||
retryCount++
|
||
}
|
||
}
|
||
g.Log().Infof(ctx, "event=monthly_usage_batch status=completed runId=%s queryMonth=%s chanKey=%s tempKey=%s claimed=%d success=%d failed=%d retryPending=%d dbUpdated=%d acked=%d durationMs=%d", claimed[0].Task.RunID, claimed[0].Task.QueryMonth, claimed[0].Task.ChanKey, claimed[0].Task.TempKey, len(claimed), success, len(results)-success, retryCount, success, len(acknowledged), time.Since(startedAt).Milliseconds())
|
||
return nil
|
||
}
|
||
|
||
func (c *Consumer) renewBatchLeases(ctx context.Context, cancel context.CancelFunc, claimed []queue.ClaimedTask, errors chan<- error, done chan<- struct{}) {
|
||
defer close(done)
|
||
interval := c.config.LeaseTTL / 3
|
||
if interval <= 0 {
|
||
interval = time.Millisecond
|
||
}
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
var count int
|
||
var err error
|
||
if claimed[0].Round > 0 {
|
||
count, err = c.store.RenewRunBatch(ctx, claimed, time.Now().Add(c.config.LeaseTTL))
|
||
} else {
|
||
count, err = c.store.RenewBatch(ctx, claimed, time.Now().Add(c.config.LeaseTTL))
|
||
}
|
||
if err == nil && count != len(claimed) {
|
||
err = ErrLeaseLost
|
||
}
|
||
if err != nil {
|
||
select {
|
||
case errors <- err:
|
||
default:
|
||
}
|
||
cancel()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func currentBatchError(ctx context.Context, renewErr <-chan error) error {
|
||
select {
|
||
case err := <-renewErr:
|
||
return err
|
||
default:
|
||
}
|
||
return ctx.Err()
|
||
}
|
||
|
||
func (c *Consumer) queryClaim(ctx context.Context, claimed queue.ClaimedTask, channelCarrier carrier, qps float64) Result {
|
||
if err := c.pacer.Wait(ctx, claimed.Task.ChanKey, qps); err != nil {
|
||
return Result{Task: claimed.Task, ErrorClass: "canceled", Message: err.Error(), Retryable: true}
|
||
}
|
||
semaphore := c.pools.get(claimed.Task.ChanKey, channelCarrier)
|
||
select {
|
||
case semaphore <- struct{}{}:
|
||
case <-ctx.Done():
|
||
return Result{Task: claimed.Task, ErrorClass: "canceled", Message: ctx.Err().Error(), Retryable: true}
|
||
}
|
||
defer func() { <-semaphore }()
|
||
taskCtx := ctx
|
||
var cancel context.CancelFunc
|
||
if c.config.TaskTimeout > 0 {
|
||
taskCtx, cancel = context.WithTimeout(ctx, c.config.TaskTimeout)
|
||
defer cancel()
|
||
}
|
||
return c.handler.Query(taskCtx, claimed.Task)
|
||
}
|
||
|
||
func claimedTaskForResult(claimed []queue.ClaimedTask, result Result) queue.ClaimedTask {
|
||
for _, item := range claimed {
|
||
if item.Task.TaskID == result.Task.TaskID {
|
||
return item
|
||
}
|
||
}
|
||
return queue.ClaimedTask{}
|
||
}
|
||
|
||
func (c *Consumer) ackBatch(ctx context.Context, claimed []queue.ClaimedTask) error {
|
||
if len(claimed) == 0 {
|
||
return nil
|
||
}
|
||
count, err := c.store.AckBatch(ctx, claimed)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if count != len(claimed) {
|
||
return ErrLeaseLost
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func countSuccessful(results []Result) int {
|
||
count := 0
|
||
for _, result := range results {
|
||
if result.Success {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
// countCovered counts results that were answered definitively but carry no
|
||
// value. They are logged separately from failures because they are neither a
|
||
// success (nothing was written) nor a failure (nothing went wrong).
|
||
func countCovered(results []Result) int {
|
||
count := 0
|
||
for _, result := range results {
|
||
if !result.Success && result.Covered {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func errorMessage(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
return err.Error()
|
||
}
|
||
|
||
// channelPools hands each chanKey its own in-flight semaphore. One channel is
|
||
// one carrier account, so concurrency must never be shared across channels.
|
||
type channelPools struct {
|
||
mu sync.Mutex
|
||
sems map[string]chan struct{}
|
||
mobile int
|
||
telecom int
|
||
generic int
|
||
}
|
||
|
||
func newChannelPools(mobile, telecom, generic int) *channelPools {
|
||
return &channelPools{sems: make(map[string]chan struct{}), mobile: mobile, telecom: telecom, generic: generic}
|
||
}
|
||
|
||
func (p *channelPools) get(chanKey string, channelCarrier carrier) chan struct{} {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if sem, ok := p.sems[chanKey]; ok {
|
||
return sem
|
||
}
|
||
size := p.mobile
|
||
if channelCarrier == carrierTelecom {
|
||
size = p.telecom
|
||
} else if channelCarrier == carrierGeneric {
|
||
size = p.generic
|
||
}
|
||
sem := make(chan struct{}, size)
|
||
p.sems[chanKey] = sem
|
||
return sem
|
||
}
|
||
|
||
type channelPacer struct {
|
||
mu sync.Mutex
|
||
next map[string]time.Time
|
||
}
|
||
|
||
func newChannelPacer() *channelPacer {
|
||
return &channelPacer{next: make(map[string]time.Time)}
|
||
}
|
||
|
||
func (p *channelPacer) Wait(ctx context.Context, chanKey string, qps float64) error {
|
||
delay := p.reserve(chanKey, qps, time.Now())
|
||
if delay <= 0 {
|
||
return nil
|
||
}
|
||
timer := time.NewTimer(delay)
|
||
defer timer.Stop()
|
||
select {
|
||
case <-timer.C:
|
||
return nil
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
}
|
||
}
|
||
|
||
func (p *channelPacer) reserve(chanKey string, qps float64, now time.Time) time.Duration {
|
||
if qps <= 0 {
|
||
return 0
|
||
}
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
interval := time.Duration(float64(time.Second) / qps)
|
||
if interval < time.Nanosecond {
|
||
interval = time.Nanosecond
|
||
}
|
||
next := p.next[chanKey]
|
||
if next.Before(now) {
|
||
next = now
|
||
}
|
||
p.next[chanKey] = next.Add(interval)
|
||
return next.Sub(now)
|
||
}
|
||
|
||
func policyForTemplate(tempKey string, config Config) (carrier, float64) {
|
||
tempKey = strings.ToLower(strings.TrimSpace(tempKey))
|
||
qps := func(defaultRate float64) float64 {
|
||
if configured, ok := config.TemplateQPS[tempKey]; ok && configured > 0 {
|
||
return configured
|
||
}
|
||
return defaultRate
|
||
}
|
||
if model.IsTelecomTemplate(tempKey) {
|
||
return carrierTelecom, qps(telecomQPS)
|
||
}
|
||
switch tempKey {
|
||
case mobileTempKey, mobileMockKey, sharedTengyuTempKey, sharedXuzhouMobileTempKey, sharedLelianTempKey, sharedQianxunTempKey, sharedHenanTempKey, sharedDaiheTempKey, sharedBeixinTempKey, sharedLanshang8000TempKey, sharedLanshang8003TempKey:
|
||
return carrierMobile, qps(mobileQPS)
|
||
default:
|
||
genericRate := config.GenericQPS
|
||
if genericRate <= 0 {
|
||
genericRate = defaultGenericQPS
|
||
}
|
||
return carrierGeneric, qps(genericRate)
|
||
}
|
||
}
|
||
|
||
// carrierForOperator is retained as a small compatibility helper for status
|
||
// policy tests and callers that only have the channel's operator dictionary.
|
||
func carrierForOperator(operator int) (carrier, float64) {
|
||
switch operator {
|
||
case 1:
|
||
return carrierMobile, mobileQPS
|
||
case 2:
|
||
return carrierTelecom, telecomQPS
|
||
default:
|
||
return carrierGeneric, 0
|
||
}
|
||
}
|
||
|
||
func newOwner() string {
|
||
var bytes [12]byte
|
||
if _, err := rand.Read(bytes[:]); err == nil {
|
||
return "consumer-" + hex.EncodeToString(bytes[:])
|
||
}
|
||
return fmt.Sprintf("consumer-%d", time.Now().UnixNano())
|
||
}
|