Files

1119 lines
34 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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())
}